diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55023efd99..5c99e98751 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,12 @@ jobs: runs-on: ubuntu-latest env: TEST_TIMEOUT_MULTIPLIER: 2 + # Used by examples/prisma-next-cloudflare-worker's vitest-pool-workers + # integration test. Mirrors the .env.example pattern; the container is + # brought up by `pnpm db:up` below (docker-compose, not a service + # container, because GitHub Actions service containers can't override + # the postgres CMD to enable shared_preload_libraries=pg_stat_statements). + WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE: postgres://postgres:postgres@127.0.0.1:5433/prisma_next_cloudflare_worker services: postgres: image: postgres:15 @@ -114,6 +120,8 @@ jobs: run: pnpm build - name: Link bins run: pnpm install --frozen-lockfile + - name: Start cloudflare-worker Postgres (5433, pg_stat_statements) + run: pnpm --filter prisma-next-cloudflare-worker db:up - name: Test packages run: pnpm test:packages - name: Test examples diff --git a/docs/README.md b/docs/README.md index 4db5a876f3..0993afb03b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,10 @@ This directory contains the primary documentation for the repository. - [Getting Started](./onboarding/Getting-Started.md) — build, test, and run the demo - [Testing Guide](./Testing%20Guide.md) — testing philosophy and commands +## Deploying + +- [Serverless Deployment Guide](./Serverless%20Deployment%20Guide.md) — deploying to per-request runtimes (Cloudflare Workers + Hyperdrive worked example, with pointers for AWS Lambda, Vercel, Deno, Bun) + ## Architecture deep dives - [ADRs](./architecture%20docs/adrs/) — decisions (append-only) diff --git a/docs/Serverless Deployment Guide.md b/docs/Serverless Deployment Guide.md new file mode 100644 index 0000000000..4d8a296c9b --- /dev/null +++ b/docs/Serverless Deployment Guide.md @@ -0,0 +1,292 @@ +# Serverless Deployment Guide + +How to deploy Prisma Next to per-request runtimes — Cloudflare Workers + Hyperdrive as the primary worked path, with pointers for AWS Lambda (Node), Vercel Edge / Vercel Serverless, Deno Deploy, and Bun edge. + +This guide covers the per-request facade `@prisma-next/postgres/serverless`. If you are deploying to a long-lived Node process (a server, a container, a non-edge Vercel function with bundling that keeps the process warm), use the existing `@prisma-next/postgres/runtime` facade — the long-lived shape is unchanged and not in scope here. + +## Two facades, one driver + +`@prisma-next/postgres` exports two facades that compose the same execution stack and differ only in lifecycle ergonomics: + +| Surface | `postgres()` — `/runtime` | `postgresServerless()` — `/serverless` | +| -------------------- | ------------------------------------------------ | ------------------------------------------------------------------- | +| Lifecycle | Long-lived process | Per-request invocation | +| `sql` | yes | yes | +| `context` | yes | yes | +| `stack` | yes | yes | +| `contract` | yes | yes | +| `orm` | closure-cached on the client | constructed per request via `createOrmClient(runtime)` | +| `runtime()` | closure-cached `Runtime` | (no member) acquired per request via `db.connect({ url })` | +| `transaction(...)` | closure-cached entrypoint | (no member) used per request via `withTransaction(runtime, ...)` | +| Cursor default | disabled | enabled | +| Disposal | (none — process owns the lifetime) | `Symbol.asyncDispose` on the runtime; `await using` disposes | + +The static authoring surface (`sql`, `context`, `stack`, `contract`) is identical on both sides — it is a pure function of the contract and is closure-cached safely per isolate. The runtime-bound surface differs because long-lived and per-request lifecycles have different invariants. See [ADR 207 — Per-environment facade asymmetry](./architecture%20docs/adrs/ADR%20207%20-%20Per-environment%20facade%20asymmetry.md) for the architectural rationale and the rejected alternatives. + +The practical version: closure-caching a `Runtime` (and the `pg.Client` wired into it) across `fetch` invocations is two flavors of unsafe in per-request runtimes — stale-connection failures after isolate idle, and concurrent-`fetch` races on a single shared `pg.Client`. The per-request facade makes the lifetime explicit at every call site: + +```ts +export default { + async fetch(_req: Request, env: Env): Promise { + await using runtime = await db.connect({ url: env.HYPERDRIVE.connectionString }); + // ... use runtime, ORM, transactions ... + // runtime.close() runs automatically when the fetch body returns + // (including on the throw-and-rethrow path). + }, +}; +``` + +## Cloudflare Workers + Hyperdrive (worked example) + +Cloudflare Workers + [Hyperdrive](https://developers.cloudflare.com/hyperdrive/) is the primary tested path. Hyperdrive is Cloudflare's managed Postgres connection pooler at the edge: the Worker connects to it with the standard Postgres wire protocol via the `pg` library, Hyperdrive terminates that connection at the edge and pools connections to your origin Postgres. The Worker reads the connection string off `env.HYPERDRIVE.connectionString`. + +A complete worked example lives at `examples/prisma-next-cloudflare-worker/`. This section documents the pattern; the example documents the example. + +### Architecture + +``` +┌─────────────────┐ ┌────────────────┐ ┌─────────────────┐ +│ Worker isolate │ ───→ │ Hyperdrive │ ───→ │ Origin Postgres │ +│ (per fetch) │ pg │ (edge pooler) │ pg │ (PPg, RDS, ...) │ +│ db.connect() │ │ pgbouncer- │ │ │ +│ one pg.Client │ │ equivalent │ │ │ +└─────────────────┘ └────────────────┘ └─────────────────┘ + ▲ ▲ + │ │ + │ runtime queries Node-side migrations + │ (per fetch) run from Node directly + │ against the origin URL, + │ NOT through Hyperdrive + │ (see Migrations below). +``` + +The runtime path goes Worker → Hyperdrive → origin. The control-plane (migrations) path goes Node → origin directly. + +### Setup + +#### 1. Provision the origin + +Any Postgres-compatible origin works (Prisma Postgres / PPg, AWS RDS, Neon, Supabase, etc.). Hyperdrive holds the origin credentials; the Worker never sees them. + +#### 2. Provision Hyperdrive + +```bash +pnpm exec wrangler hyperdrive create my-hyperdrive \ + --connection-string="postgres://USER:PASS@HOST:PORT/DBNAME" +``` + +Wrangler prints a binding ID. Wire it into `wrangler.jsonc`: + +```jsonc +{ + "name": "my-worker", + "main": "src/worker.ts", + "compatibility_date": "2025-07-18", + "compatibility_flags": ["nodejs_compat"], + "hyperdrive": [ + { + "binding": "HYPERDRIVE", + "id": "" + } + ] +} +``` + +`nodejs_compat` is required: the Postgres driver (`pg`) uses several Node built-ins that workerd polyfills under that flag. The M1 audit confirmed `pg` + `pg-cursor` work under `nodejs_compat` end-to-end (open / read / cursor early-break / close) when validated against a localhost Postgres origin and against `vitest-pool-workers`'s miniflare emulator — i.e., paths that do not put real Hyperdrive in front of the origin. + +> **Production caveat — read this before deploying.** Against real Hyperdrive, the default cursor path hangs (`pg-cursor`'s extended-query named portal trips a Hyperdrive parser bug — full diagnostic in the [Cursor mode hangs on Cloudflare Hyperdrive](#known-limitations) entry below). Until the upstream fix lands, pass `cursor: { disabled: true }` to `postgresServerless({...})`. The miniflare emulator and localhost Postgres paths above don't reproduce the hang, so the example's local tests pass with cursor enabled — the bug only surfaces against a real deployed Hyperdrive config. + +#### 3. Local dev + +For `wrangler dev` and `vitest-pool-workers`, the Hyperdrive binding needs a local connection string. Wrangler reads it from a `WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_` environment variable in `.env` ([Cloudflare docs](https://developers.cloudflare.com/hyperdrive/configuration/local-development)). For a binding named `HYPERDRIVE`: + +```bash +# .env (gitignored) +WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgres://user:pass@127.0.0.1:5432/mydb" +``` + +This goes in `.env`, not `.dev.vars`. `.dev.vars` is for runtime worker secrets; the `WRANGLER_*_LOCAL_CONNECTION_STRING_*` variable is consumed by Wrangler itself when it builds the Hyperdrive binding for local dev. The `WRANGLER_*` prefix is being deprecated in favour of `CLOUDFLARE_*` in newer Wrangler — both work as of `wrangler@4.87`. + +### Worker code shape + +Module-scope construction; per-request runtime acquisition; three query surfaces; cursor streaming. The full file is `examples/prisma-next-cloudflare-worker/src/worker.ts`. + +#### Module scope + +```ts +// src/prisma/db.ts +import postgresServerless from '@prisma-next/postgres/serverless'; +import type { Contract } from './contract.d'; +import contractJson from './contract.json' with { type: 'json' }; + +// Constructed once per isolate. Only the static authoring surface +// (sql / context / stack / contract) is closure-cached — those are +// pure functions of the contract and are safe to cache. The +// runtime-bound surface is acquired per fetch via db.connect(...). +export const db = postgresServerless({ + contractJson, + // middleware: [...], // optional — telemetry, lints, budgets, ... + // extensions: [...], // optional + // cursor: { disabled: true }, // REQUIRED if your origin is behind Cloudflare + // Hyperdrive — see Production caveat above. + // Default is enabled; safe to leave as-is on + // any non-Hyperdrive origin. +}); +``` + +#### Per request + +```ts +// src/worker.ts +import { withTransaction } from '@prisma-next/sql-runtime'; +import { createOrmClient } from './orm-client/client'; +import { db } from './prisma/db'; + +interface Env { + HYPERDRIVE: { connectionString: string }; +} + +export default { + async fetch(request: Request, env: Env): Promise { + // Fresh runtime per fetch. AsyncDisposable: when the fetch body + // returns (or throws), runtime.close() runs and ends the + // underlying pg.Client. No closure cache, no shared state across + // concurrent fetches in this isolate. + await using runtime = await db.connect({ url: env.HYPERDRIVE.connectionString }); + + const url = new URL(request.url); + + // SQL DSL plan — runtime.execute returns AsyncIterable. + if (url.pathname === '/sql/users') { + const rows = await runtime.execute( + db.sql.user.select('id', 'email').limit(10).build(), + ); + return Response.json(rows); + } + + // ORM — constructed against the per-request runtime. + if (url.pathname === '/orm/users') { + const orm = createOrmClient(runtime); + const rows = await orm.User.newestFirst().take(10).all(); + return Response.json(rows); + } + + // Transactions — withTransaction takes the per-request runtime. + // BEGIN/COMMIT/ROLLBACK happen on the same underlying pg.Client. + if (url.pathname === '/tx/example') { + const result = await withTransaction(runtime, async (tx) => { + await tx.execute(db.sql.user.update({ /* ... */ }).where(/* ... */).build()); + await tx.execute(db.sql.post.insert({ /* ... */ }).build()); + return { ok: true }; + }); + return Response.json(result); + } + + return new Response('not found', { status: 404 }); + }, +}; +``` + +#### Cursor streaming + +`postgresServerless` enables `pg-cursor` by default. The `for-await ... break` shape exits early without materializing the rest of the result; the cursor closes cleanly on `break`: + +```ts +if (url.pathname === '/cursor/large') { + const consumed: { id: string; title: string }[] = []; + // Bounded SELECT (see budgets middleware). Cursor-on means the driver + // opens a server-side cursor and streams ~100-row batches — early + // break only fetches one batch and closes the cursor. Cursor-off + // would buffer all 10_000 rows before yielding the first one. + const iter = runtime.execute( + db.sql.post.select('id', 'title').orderBy((f) => f.createdAt, { direction: 'asc' }).limit(10_000).build(), + ); + for await (const row of iter) { + consumed.push(row); + if (consumed.length >= 50) break; + } + return Response.json({ consumed: consumed.length }); +} +``` + +The cursor default is the inverse of the long-lived `postgres()` facade's default (off) because the dominant per-request shape is "stream and return early"; isolate memory pressure makes buffering a 10k-row result before yielding the first row a foot-gun. Both facades expose a `cursor` option for opt-out / opt-in. + +### Wiring the ORM client + +`createOrmClient(runtime)` is the existing pattern from `examples/prisma-next-demo/src/orm-client/`; the per-request facade reuses it unchanged: + +```ts +// src/orm-client/client.ts +import type { Runtime } from '@prisma-next/sql-runtime'; +import { orm } from '@prisma-next/sql-orm-client'; +import { db } from '../prisma/db'; +import { UserCollection, PostCollection } from './collections'; + +export function createOrmClient(runtime: Runtime) { + return orm({ + runtime, + context: db.context, + collections: { + User: UserCollection, + Post: PostCollection, + }, + }); +} +``` + +Custom collections, repositories, and ORM extensions work the same way they do on Node — the only difference is that you call the factory inside `fetch` against the per-request `runtime` instead of reading a closure-cached `db.orm`. + +## Other per-request runtimes + +The `postgresServerless` facade is generic across per-request runtimes. The only thing that differs per runtime is how you source the connection string — the facade itself is environment-shaped, not Cloudflare-product-shaped. + +This guide does not ship worked examples or CI for non-Cloudflare runtimes. The pattern is identical; only the connection-string source changes. + +| Runtime | Connection-string source | +| -------------------------- | --------------------------------------------------------------------- | +| AWS Lambda (Node) | `process.env.DATABASE_URL` (set via Lambda env vars / secrets layer) | +| Vercel Serverless (Node) | `process.env.DATABASE_URL` | +| Vercel Edge | `process.env.DATABASE_URL` (per Vercel edge runtime conventions) | +| Deno Deploy | `Deno.env.get('DATABASE_URL')` | +| Bun edge | `process.env.DATABASE_URL` (Bun's Node-compat env shim) | + +The Worker code shape is the same on all of them: module-scope `db = postgresServerless({...})`, per-request `await using runtime = await db.connect({ url: })`. Hyperdrive is Cloudflare-specific; on other runtimes the URL points directly at the origin or at whatever pooler your platform exposes (RDS Proxy on Lambda, Vercel Postgres pooler, etc.). + +## Migrations + +Migrations stay on Node, against the **origin** database connection string (not Hyperdrive). + +There is no per-request migration story and there is no Hyperdrive control-plane driver. The reasons: + +- Migration commands (`prisma-next migrate`, `prisma-next db init`, `prisma-next db reset`) are control-plane operations: they speak to the `migration` plane through the control-plane Postgres driver, run in long-lived Node processes (CI runners, dev workstations, deploy hooks), and are inherently long-lived shapes — DDL does not benefit from per-request lifecycle. +- Hyperdrive caches query results at the edge. That is desirable for many runtime read patterns and undesirable for DDL: a stale read of the migration ledger or marker leads to duplicate-apply or skipped-apply confusion. The Cloudflare-recommended pattern is to bypass Hyperdrive for control-plane operations, and we follow that. + +The existing migration commands accept a connection string (typically via `DATABASE_URL`) and use the `@prisma-next/driver-postgres/control` driver. Run them from CI / your deploy pipeline / a one-shot Node task pointed at the origin URL — see the existing migration docs and the [Getting Started guide](./onboarding/Getting-Started.md) for the command surface. Nothing about deploying to a per-request runtime changes that. + +## Known limitations + +- **Transaction affinity within a single underlying connection.** A `withTransaction(runtime, async (tx) => ...)` body runs all of its statements on the per-request runtime's single underlying `pg.Client`. Crossing runtime boundaries inside a transaction body is undefined; constructing a second `await using runtime2 = await db.connect(...)` inside a transaction body and routing some statements through it will not be transactional with the outer body. This is the same invariant Hyperdrive itself documents — transactions need to land on one client connection — and the per-request facade enforces it by structure (one `runtime` per `connect()`, one client per `runtime`). + +- **Isolate memory limits.** Workers isolates have bounded memory (128 MiB by default; higher on Workers Unbound). ORM `findMany`-style operations materialize the result set into a JS array before returning; `take(...)` is your hard memory cap on those. If you need to stream, use the SQL DSL with `runtime.execute(...)` — the iterator is cursor-backed by default and yields rows as they arrive, with `for-await ... break` cancelling cleanly without buffering the rest of the result set. + +- **Cursor enabled by default.** The default for `postgresServerless` is `cursor: { /* enabled */ }`. Long-lived `postgres()` defaults to `cursor: { disabled: true }`. The asymmetry is intentional (see [ADR 207](./architecture%20docs/adrs/ADR%20207%20-%20Per-environment%20facade%20asymmetry.md) and the cursor section above). To opt out on the per-request side, pass `cursor: { disabled: true }` to `postgresServerless({...})`. + +- **Cursor mode hangs on Cloudflare Hyperdrive — pass `cursor: { disabled: true }` if your origin sits behind Hyperdrive.** Empirically verified during the May 2026 production smoke. The default cursor path uses `pg-cursor`'s extended-query named-portal protocol; after rows are returned and the client sends `Close portal + Sync`, Hyperdrive emits `Protocol Error: Unexpected protocol code: C` (SQLSTATE `58000`) and never follows up with the expected `ReadyForQuery`. The connection wedges; Cloudflare's runtime kills the request at 30 s with error 1101. This affects every read path (SQL DSL, ORM `.all()` / `.first()`, `for await`) — there is no per-call short-circuit, the cursor decision is made at the driver layer for every read. Wrapping the read in `withTransaction(...)` does not help: the failure is in Hyperdrive's protocol parser state, not in connection pinning. The driver's catch-block fallback to simple-query mode does **not** save you either — it only fires on certain thrown errors, and a hang doesn't throw. Workaround: pass `cursor: { disabled: true }` to `postgresServerless({...})` to force the simple-protocol path. Tracking upstream as a Cloudflare Hyperdrive bug. + +- **The `@prisma-next/postgres` package statically imports `pg-pool` and `pg-cloudflare`.** The serverless facade does not construct a `pg.Pool` and does not exercise the pool path, but the `pg` library imports both at module load. The bundle includes them. This is not a correctness concern — `pg-cloudflare` activates only when `navigator.userAgent === 'Cloudflare-Workers'` is true at runtime — but it adds bundle weight. The example's full bundle measures around 254 KiB gzipped including these. + +- **Migrations run from Node.** As above — no per-request migration story, no Hyperdrive control-plane driver. If your deploy pipeline expects to apply migrations from the same surface that runs the Worker, you need a separate Node task (CI step, deploy hook, one-shot script). + +## Validating end-to-end + +The `examples/prisma-next-cloudflare-worker/` example provides a `vitest-pool-workers` integration test that boots the Worker under `workerd`, points the Hyperdrive binding at a local Docker Postgres, and exercises SQL DSL, ORM, transactions, and cursor streaming. That suite is the canonical "does my pattern work end-to-end" reference and is the one you should mirror when bootstrapping your own deployment. + +The example is intentionally minimal — minimum schema, minimum routes — so you can compare your setup against it side-by-side. See its README for the local-dev workflow (`pnpm db:up` / `pnpm db:init` / `pnpm seed` / `pnpm dev`) and the bundle-size / cold-start measurements. + +## See also + +- [ADR 207 — Per-environment facade asymmetry](./architecture%20docs/adrs/ADR%20207%20-%20Per-environment%20facade%20asymmetry.md) — the architectural rationale for the two-facade design. +- [ADR 159 — Runtime Driver Lifecycle](./architecture%20docs/adrs/ADR%20159%20-%20Driver%20Terminology%20and%20Lifecycle.md) — how the underlying driver lifecycle works (both facades inherit it unchanged). +- [Architecture Overview](./Architecture%20Overview.md) — Prisma Next's broader plane / target / adapter / driver model. +- [Cloudflare Hyperdrive docs](https://developers.cloudflare.com/hyperdrive/) — Hyperdrive setup, configuration, and observability. +- The example: `examples/prisma-next-cloudflare-worker/` (in this repo). diff --git a/docs/architecture docs/ADR-INDEX.md b/docs/architecture docs/ADR-INDEX.md index fc8ea0b9da..718c73abf7 100644 --- a/docs/architecture docs/ADR-INDEX.md +++ b/docs/architecture docs/ADR-INDEX.md @@ -122,6 +122,7 @@ This document provides a comprehensive index of all Architectural Decision Recor |-----|-------|-------------|------| | 065 | Adapter capability schema & negotiation v1 | Defines adapter capability schema and negotiation protocol | [ADR 065 - Adapter capability schema & negotiation v1.md](adrs/ADR%20065%20-%20Adapter%20capability%20schema%20&%20negotiation%20v1.md) | | 068 | Error mapping to RuntimeError | Establishes stable mapping from engine/driver errors to RuntimeError envelope | [ADR 068 - Error mapping to RuntimeError.md](adrs/ADR%20068%20-%20Error%20mapping%20to%20RuntimeError.md) | +| 207 | Per-environment facade asymmetry | Records why `postgres()` (long-lived) and `postgresServerless()` (per-request) ship asymmetric runtime-bound surfaces — same authoring surface, different lifecycle ergonomics — and rejects AsyncLocalStorage / single-facade / per-product alternatives | [ADR 207 - Per-environment facade asymmetry.md](adrs/ADR%20207%20-%20Per-environment%20facade%20asymmetry.md) | ## Development & Tooling diff --git a/docs/architecture docs/adrs/ADR 207 - Per-environment facade asymmetry.md b/docs/architecture docs/adrs/ADR 207 - Per-environment facade asymmetry.md new file mode 100644 index 0000000000..3ab73d7666 --- /dev/null +++ b/docs/architecture docs/adrs/ADR 207 - Per-environment facade asymmetry.md @@ -0,0 +1,182 @@ +# ADR 207 — Per-environment facade asymmetry + +**Status:** Implemented +**Date:** 2026-05-01 +**Domain:** Adapters / Targets, Runtime + +## At a glance + +A user writing against `@prisma-next/postgres` picks one of two import paths depending on where their code runs. + +In a long-lived Node process: + +```ts +import { postgres } from '@prisma-next/postgres/runtime'; +import contractJson from './contract.json' with { type: 'json' }; +import type { Contract } from './contract'; + +const db = postgres({ contractJson, url: process.env.DATABASE_URL! }); + +// Anywhere in the process: +const users = await db.orm.User.take(10).all(); +``` + +In a per-request runtime (Cloudflare Workers, AWS Lambda Node, Vercel Edge / Vercel Serverless, Deno Deploy, Bun edge): + +```ts +import { postgresServerless } from '@prisma-next/postgres/serverless'; +import { withTransaction } from '@prisma-next/sql-runtime'; +import { createOrmClient } from './orm-client'; +import contractJson from './contract.json' with { type: 'json' }; +import type { Contract } from './contract'; + +const db = postgresServerless({ contractJson }); + +export default { + async fetch(_req: Request, env: Env): Promise { + await using runtime = await db.connect({ url: env.HYPERDRIVE.connectionString }); + const orm = createOrmClient(runtime); + const users = await orm.User.take(10).all(); + return Response.json(users); + }, +}; +``` + +The same package; the same `Contract` type; the same `db.sql` plan-builder if either side reaches for it. The two factories take the same option keys at construction (`contractJson`, `extensions`, `middleware`). What differs is everything that touches a connection: the long-lived client gives you a closure-cached `Runtime` (and an `orm` member, and a `transaction()` member, all bound to that runtime); the per-request client gives you a `connect()` entrypoint that returns a fresh `Runtime & AsyncDisposable` per call, and nothing else. ORM and transactions on the per-request side are constructed from the just-acquired runtime, not reached for from a closure. + +## Decision + +`@prisma-next/postgres` exports two clients with deliberately asymmetric runtime surfaces. + +- `postgres()` (`@prisma-next/postgres/runtime`) suits long-lived processes. It closure-caches a `Runtime` and exposes `db.orm`, `db.runtime()`, and `db.transaction(...)` as members of the returned client. +- `postgresServerless()` (`@prisma-next/postgres/serverless`) suits per-request runtimes. It exposes `db.connect(binding)` returning `Promise`, and **omits** `db.orm`, `db.runtime()`, and `db.transaction(...)`. Per-request callers acquire a runtime via `connect()`, build the ORM client from it, run transactions through `withTransaction(runtime, ...)`, and release the connection by letting the `using` scope exit. + +The static authoring surface (`db.sql`, `db.context`, `db.stack`, `db.contract`) is identical on both sides — it is a pure function of the contract and never touches a connection. Cursor defaults differ to match the dominant per-side shape (off on Node, on under serverless); both expose a `cursor` option for parity. + +The two clients compose the same execution stack underneath (`postgresTarget + postgresAdapter + postgresDriver`). The driver layer is unchanged from one side to the other. + +This document is the rationale. The remainder explains why two clients, why they share construction but diverge at the runtime seam, and what each asymmetric difference exists to protect. + +## Why two clients, not one + +### A long-lived process has one runtime lifetime; the wrapper can match it + +A Node process has one beginning and (essentially) one end. A `Runtime` constructed at boot is a `Runtime` valid until shutdown: the underlying `pg.Pool` (or singleton `pg.Client`) handles connection lifecycle internally, query routing serializes through the pool's checkout/release dance, and call sites never have to remember to release anything. Closure-caching the `Runtime` over `(stack, context, contract, driver)` is exactly right for this lifecycle. The same is true of the `orm` client and the `transaction()` member — they thread the cached runtime, and `db.orm.User.take(10).all()` reads the way a long-lived API should read. + +### A per-request runtime has many short, parallel runtime lifetimes; the wrapper cannot match them all with one cache + +A per-request runtime — a Cloudflare Worker, a Lambda invocation, a Vercel Edge function, a Deno Deploy handler, a Bun edge response — has no long-lived process to anchor a `Runtime` to. There is an isolate that may handle one `fetch` invocation, several invocations in succession, several invocations in parallel, or be evicted between invocations. The natural unit of "one runtime lifetime" is the `fetch` body itself: it has a clear start (the request arrives) and a clear end (the response is returned, or an error propagates). + +Apply the long-lived wrapper shape to that environment and three specific things go wrong: + +1. **Stale connections after isolate idle.** A closure-cached `Runtime` outlives any single `fetch`. After a minutes-long idle, the underlying TCP connection has been reaped by the network or the origin, but the cached client object is still in scope. The next `fetch` reaches for `db.orm` and fails with a stale-socket error far from the misconfiguration that caused it. + +2. **Head-of-line blocking + cross-`fetch` transaction-state contamination on a shared `pg.Client`.** Multiple `fetch` invocations within one isolate share the closure. `pg.Client` queues queries client-side (FIFO), so concurrent invocations don't race for the wire — they wait. Fetch B's query queues behind fetch A's query and only runs after A's response is parsed, defeating the parallelism the runtime is designed to give. Worse, if fetch A opens a transaction with `BEGIN`, fetch B's queries run inside A's transaction until A's `COMMIT` (or `ROLLBACK`) clears it, contaminating B's reads and writes with A's transaction state. The Node facade avoids this with `pg.Pool` (each query gets its own checked-out connection), but `pg.Pool` is itself a long-lived-process construct: background connection reaping, idle eviction, periodic health checks. Constructing a fresh `pg.Pool` per `fetch` would mean spinning up and tearing down those background tasks on every request — costly and pointless. + +3. **No release point.** A `fetch` returning is the natural moment to call `client.end()`, but the closure-cached client has no idea a `fetch` returned. The connection lingers until the isolate evicts. Memory pressure, file-descriptor pressure, or origin-side connection-count limits all surface as later, harder-to-diagnose failures. + +The per-request lifecycle therefore needs a wrapper whose runtime acquisition is per-`fetch`, whose runtime release is `using`-bound, and whose surface omits the closure-cached members that would re-introduce all three failure modes. + +### What this implies for the wrapper + +Reverse the three failure modes and you get the per-request shape: + +- The runtime is constructed per `fetch`, not at boot. → `db.connect(binding)` returns a fresh `Runtime` per call. +- The runtime's lifetime is the `fetch` body. → `connect()` returns `Runtime & AsyncDisposable`; consumers use `await using runtime = await db.connect(...)` and disposal is automatic. +- Closure-cached convenience members would re-introduce the cache. → They are omitted; ORM and transactions are constructed from the per-request runtime. + +That is the shape the per-request client provides. The static authoring surface (`sql`, `context`, `stack`, `contract`) is preserved as-is because it never touches a connection — it is a pure function of the contract. Caching it once per isolate is a win on both sides. + +## What's the same, and what's different + +Four concrete differences between the two clients. Each exists to enforce one part of the lifecycle invariant above. + +### 1. The static authoring surface is shared; the runtime-bound surface is not + +Both sides expose `db.sql` (the plan-builder), `db.context`, `db.stack`, and `db.contract`. None of these reach a connection: `db.sql` builds plans against the contract; `db.context` and `db.stack` are descriptor-shaped values; `db.contract` is the validated contract. + +The long-lived client *additionally* exposes `db.orm`, `db.runtime()`, and `db.transaction(...)`. Each of these reaches for the closure-cached `Runtime` and is therefore shape-incompatible with the per-request lifecycle. The per-request client omits all three. + +### 2. `connect()` returns an `AsyncDisposable` runtime; consumers use `await using` + +```ts +await using runtime = await db.connect({ url: env.HYPERDRIVE.connectionString }); +// ... use runtime, ORM, transactions ... +// runtime.close() runs automatically when the fetch body returns +// (including on the throw-and-rethrow path). +``` + +The returned object carries `[Symbol.asyncDispose]` that calls `runtime.close()`, which ends the underlying `pg.Client`. This is the seam that makes the asymmetry honest. The long-lived client has no scope at which "the runtime is done"; the per-request client does — it is the `fetch` body. Encoding that scope as an `AsyncDisposable` returned from `connect()` makes the lifetime visible at every call site and makes it impossible to forget release. + +### 3. ORM and transactions take the runtime; they are not closure-cached + +```ts +const orm = createOrmClient(runtime); +const users = await orm.User.take(10).all(); + +await withTransaction(runtime, async (tx) => { + await tx.execute(/* ... */); + await tx.execute(/* ... */); +}); +``` + +`createOrmClient(runtime)` and `withTransaction(runtime, ...)` are runtime-parameterized helpers that already exist for the Node side (the demo's ORM client follows this pattern). The per-request client uses them unchanged. Re-introducing closure-cached `db.orm` / `db.transaction(...)` members on the per-request client would re-introduce the closure cache they depend on, which would re-introduce the stale-connection failure mode the per-request client exists to prevent. + +### 4. Cursor defaults differ to match the dominant per-side shape + +The long-lived `postgres()` client defaults `cursor: { disabled: true }`. Long-lived consumers commonly materialize results into containers (arrays, paginated views, batch processors) that benefit from the buffered path's predictability and lower per-row overhead. + +The `postgresServerless()` client leaves cursor enabled by default. The dominant per-request shape is "stream a result and return early via `for-await … break`" — exactly what `pg-cursor` is built for, and exactly what isolate memory pressure makes a buffered fetch dangerous for (a 10 000-row result materialized before the first row yields is a foot-gun under any per-request memory budget). + +Both clients expose a `cursor` option for parity; the default reflects the dominant shape on each side. + +## Consequences + +### Positive + +- **The lifecycle is visible at the call site.** `await using runtime = await db.connect(...)` reads as "acquire a runtime for this scope; release it when the scope exits". A reviewer can see that the connection is bounded by the `fetch` body without consulting documentation. +- **Stale-connection failures are structurally impossible on the per-request side.** The per-request runtime cannot outlive its `fetch`; there is no closure to cache it in. An isolate that handles two `fetch` invocations gets two independent runtimes. +- **Cross-`fetch` interference is structurally impossible on the per-request side.** Each `fetch` constructs its own `pg.Client`. Concurrent invocations within one isolate cannot block on each other's queue or contaminate each other's transaction state. +- **The runtime-threading pattern is uniform.** `createOrmClient(runtime)` and `withTransaction(runtime, ...)` work the same way on both sides. A user who learns the pattern in one environment carries it forward to the other. +- **Cursor default reflects the dominant shape.** Each side's default fits how that side typically reads results. + +### Trade-offs + +- **The per-request client is not a drop-in replacement for the long-lived client.** Migrating a Node app to a per-request runtime is not "swap one import for another"; it is also "thread `runtime` through every call site that previously used `db.orm` / `db.runtime()` / `db.transaction()`". This is intended — the lifecycle change *is* the migration — but it is a real cost. +- **Two surfaces to keep symmetric at construction.** The same option keys appear on both factories at the construction boundary (`contractJson`, `extensions`, `middleware`). There is no type-level constraint that enforces this; drift between the two surfaces is an authoring mistake, not a compiler error. +- **One extra line per per-request route to construct the ORM client.** `const orm = createOrmClient(runtime)` is repeated per route. The cost is negligible (the ORM client is a closure over the runtime, not a heavy object) but it is observable. + +## Interaction with other ADRs + +- **[ADR 159 — Runtime Driver Lifecycle](ADR%20159%20-%20Driver%20Terminology%20and%20Lifecycle.md)** defines the unbound → bound → connected lifecycle of `SqlDriver`. The per-request client uses that lifecycle unchanged: it constructs a fresh `pg.Client` per `connect()` call and routes through the existing `pgClient` `PostgresBinding` kind, which already implements the per-request shape (lazy `client.connect()`, no `pg.Pool`, explicit `client.end()`, mutex-serialized `acquireConnection` for transaction affinity). No new binding kinds were needed. +- **[ADR 155 — Driver/Codec Boundary and Lowering Responsibilities](ADR%20155%20-%20Driver%20Codec%20Boundary%20and%20Lowering%20Responsibilities.md)** governs the codec/driver/lowering split. Both clients sit above that split and inherit it; the asymmetry is at the wrapper layer, not at the driver layer. +- **[ADR 152 — Execution Plane Descriptors and Instances](ADR%20152%20-%20Execution%20Plane%20Descriptors%20and%20Instances.md)** defines the descriptor/instance pattern that both clients compose (`postgresTarget + postgresAdapter + postgresDriver`). The execution stack is the same on both sides. + +## Alternatives considered + +### One client, runtime always per-call + +Keep one `postgres()` factory; have it return a runtime that is *always* per-call. Drop the closure-cached `orm` / `runtime()` / `transaction(...)` from Node too, and require Node consumers to write `await using runtime = await db.connect(...)` per request as well. + +**Rejected.** Long-lived processes legitimately benefit from closure-caching: the `pg.Pool` is the right unit of connection lifecycle, the runtime threading is amortized, and the call-site idiom matches the lifetime. Forcing per-call acquisition adds either pool-checkout churn or a layer of indirection that obscures the pooling story, for no safety gain. It would also be a breaking change to every existing Node consumer with no migration story other than "rewrite every route handler". + +### One client, `AsyncLocalStorage`-based per-request convenience surface + +Keep the `db.orm` / `db.transaction(...)` members. Implement them as accessors that read the "current request's runtime" from an `AsyncLocalStorage` set up at the top of `fetch`. + +**Rejected.** Three reasons: + +1. The lifecycle becomes invisible. Call sites read like the long-lived shape but behave correctly only if the ALS context is threaded. Forgetting to set it up surfaces as a runtime error far from the cause. +2. It introduces a load-bearing dependency on `node:async_hooks` semantics. Workable on Node and on Workers under `nodejs_compat`, but the polyfill story across other per-request runtimes (Bun, Deno Deploy, edge runtimes that disable Node-compat shims) is uneven. +3. It dilutes the design intent. The per-request client exists to make the per-request lifecycle *explicit and visible*. ALS exists to make context *implicit and invisible*. The two are at odds. + +### Per-product clients (`postgresWorkers`, `postgresLambda`, …) instead of per-environment-class + +Ship one client per per-request product. Each could carry product-specific ergonomics — e.g. `postgresWorkers({ hyperdrive: env.HYPERDRIVE })` instead of `postgresServerless({ contractJson }) … db.connect({ url: env.HYPERDRIVE.connectionString })`. + +**Rejected.** Two reasons: + +1. The product-specific ergonomic is shallow. Every per-request runtime exposes "a connection string from somewhere" — `env.HYPERDRIVE.connectionString` on Workers, `process.env.DATABASE_URL` on Lambda, `Deno.env.get('DATABASE_URL')` on Deno, etc. Wrapping each in a bespoke factory just to skip a `.connectionString` field access trades a generic surface for N near-identical surfaces with N maintenance footprints. +2. The lifecycle invariants are uniform across products. Per-request is per-request whether the host is Workers or Lambda. Making the API shape track product instead of lifecycle would invite product-specific lifecycle drift. + +The per-environment-class shape (one client, one shape, sourced URL) reflects the actual invariant. diff --git a/examples/prisma-next-cloudflare-worker/.env.example b/examples/prisma-next-cloudflare-worker/.env.example new file mode 100644 index 0000000000..1d47ab6b44 --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/.env.example @@ -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" diff --git a/examples/prisma-next-cloudflare-worker/.gitignore b/examples/prisma-next-cloudflare-worker/.gitignore new file mode 100644 index 0000000000..589126e3a4 --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.wrangler +.env +.env.local +*.tsbuildinfo +.turbo diff --git a/examples/prisma-next-cloudflare-worker/README.md b/examples/prisma-next-cloudflare-worker/README.md new file mode 100644 index 0000000000..ada0664065 --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/README.md @@ -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({ 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 + +``` +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 +``` + +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. + +## 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. diff --git a/examples/prisma-next-cloudflare-worker/biome.jsonc b/examples/prisma-next-cloudflare-worker/biome.jsonc new file mode 100644 index 0000000000..b8994a7330 --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/biome.jsonc @@ -0,0 +1,4 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", + "extends": "//" +} diff --git a/examples/prisma-next-cloudflare-worker/docker-compose.yml b/examples/prisma-next-cloudflare-worker/docker-compose.yml new file mode 100644 index 0000000000..33c283cff2 --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/docker-compose.yml @@ -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 diff --git a/examples/prisma-next-cloudflare-worker/package.json b/examples/prisma-next-cloudflare-worker/package.json new file mode 100644 index 0000000000..3fadb07be3 --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/package.json @@ -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" + } +} diff --git a/examples/prisma-next-cloudflare-worker/prisma-next.config.ts b/examples/prisma-next-cloudflare-worker/prisma-next.config.ts new file mode 100644 index 0000000000..53525ca39a --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/prisma-next.config.ts @@ -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']!, + }, +}); diff --git a/examples/prisma-next-cloudflare-worker/prisma/schema.prisma b/examples/prisma-next-cloudflare-worker/prisma/schema.prisma new file mode 100644 index 0000000000..a139dd0150 --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/prisma/schema.prisma @@ -0,0 +1,64 @@ +type Address { + street String + city String + zip String? + country String +} + +enum user_type { + admin + user +} + +model User { + id String @id @default(uuid()) + email String + displayName String + createdAt DateTime @default(now()) + kind user_type + address Address? + posts Post[] + tasks Task[] + + @@map("user") +} + +model Post { + id String @id @default(uuid()) + title String + userId String + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) + + @@map("post") +} + +model Task { + id String @id @default(uuid()) + title String + description String? + status String @default("open") + type String + userId String + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) + + @@discriminator(type) + @@map("task") +} + +model Bug { + severity String + stepsToRepro String? + @@base(Task, "bug") + @@map("bug") +} + +model Feature { + priority String + targetRelease String? + @@base(Task, "feature") + @@map("feature") +} diff --git a/examples/prisma-next-cloudflare-worker/scripts/seed.ts b/examples/prisma-next-cloudflare-worker/scripts/seed.ts new file mode 100644 index 0000000000..59908c4db7 --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/scripts/seed.ts @@ -0,0 +1,115 @@ +/** + * Seeds the demo schema with users, posts, and tasks. + * + * Mirrors examples/prisma-next-demo/scripts/seed.ts minus the pgvector + * embeddings (this example exercises the per-request facade, not vectors). + */ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { db } from '../src/prisma/db'; + +function loadDotEnv(filename: string): Record { + const path = resolve(process.cwd(), filename); + if (!existsSync(path)) return {}; + const out: Record = {}; + for (const line of readFileSync(path, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + const raw = trimmed.slice(eq + 1).trim(); + out[key] = raw.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1'); + } + return out; +} + +const HYPERDRIVE_VAR = 'WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE'; + +async function main() { + const fileVars = loadDotEnv('.env'); + const url = + fileVars[HYPERDRIVE_VAR] ?? process.env[HYPERDRIVE_VAR] ?? process.env['DATABASE_URL']; + + if (!url) { + throw new Error(`Set ${HYPERDRIVE_VAR} in .env (or DATABASE_URL) before running pnpm seed.`); + } + + await using runtime = await db.connect({ url }); + + await runtime.execute( + db.sql.user + .insert({ + email: 'alice@example.com', + displayName: 'Alice', + createdAt: new Date('2026-04-01T00:00:00.000Z'), + kind: 'admin', + address: { street: '123 Main St', city: 'San Francisco', zip: '94102', country: 'US' }, + }) + .build(), + ); + + await runtime.execute( + db.sql.user + .insert({ + email: 'bob@example.com', + displayName: 'Bob', + createdAt: new Date('2026-04-02T00:00:00.000Z'), + kind: 'user', + address: { street: '456 Oak Ave', city: 'Portland', zip: null, country: 'US' }, + }) + .build(), + ); + + const aliceRows = await runtime.execute( + db.sql.user + .select('id', 'email') + .where((f, fns) => fns.eq(f.email, 'alice@example.com')) + .limit(1) + .build(), + ); + const bobRows = await runtime.execute( + db.sql.user + .select('id', 'email') + .where((f, fns) => fns.eq(f.email, 'bob@example.com')) + .limit(1) + .build(), + ); + const alice = aliceRows[0]; + const bob = bobRows[0]; + if (!alice || !bob) { + throw new Error('Failed to find seeded users'); + } + + for (let i = 0; i < 5; i++) { + await runtime.execute( + db.sql.post + .insert({ + title: `Alice post ${i + 1}`, + userId: alice.id, + createdAt: new Date(Date.UTC(2026, 3, 10 + i)), + }) + .build(), + ); + } + + for (let i = 0; i < 3; i++) { + await runtime.execute( + db.sql.post + .insert({ + title: `Bob post ${i + 1}`, + userId: bob.id, + createdAt: new Date(Date.UTC(2026, 3, 20 + i)), + }) + .build(), + ); + } + + console.log(`Seeded users: alice=${alice.id}, bob=${bob.id}`); + console.log('Seed complete (tasks/bugs/features intentionally empty — exercised by tests).'); +} + +main().catch((err) => { + console.error('Seed failed:', err); + process.exitCode = 1; +}); diff --git a/examples/prisma-next-cloudflare-worker/scripts/setup-schema.ts b/examples/prisma-next-cloudflare-worker/scripts/setup-schema.ts new file mode 100644 index 0000000000..95df7ba080 --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/scripts/setup-schema.ts @@ -0,0 +1,45 @@ +/** + * Applies the contract schema to a local Postgres origin via `prisma-next db init`. + * + * Reads WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE from `.env` + * (the same env var Wrangler uses for the Hyperdrive binding's local + * connection string), falls back to the same name in the process env, then + * to `DATABASE_URL`. Idempotent: safe to re-run. + */ +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +function loadDotEnv(filename: string): Record { + const path = resolve(process.cwd(), filename); + if (!existsSync(path)) return {}; + const out: Record = {}; + for (const line of readFileSync(path, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + const raw = trimmed.slice(eq + 1).trim(); + out[key] = raw.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1'); + } + return out; +} + +const HYPERDRIVE_VAR = 'WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE'; +const fileVars = loadDotEnv('.env'); +const url = fileVars[HYPERDRIVE_VAR] ?? process.env[HYPERDRIVE_VAR] ?? process.env['DATABASE_URL']; + +if (!url) { + console.error( + `Set ${HYPERDRIVE_VAR} in .env (or DATABASE_URL in the environment) before running db:init.`, + ); + console.error('Hint: `pnpm db:dev` prints the TCP URL.'); + process.exit(1); +} + +const result = spawnSync('pnpm', ['exec', 'prisma-next', 'db', 'init', '--db', url, '--yes'], { + stdio: 'inherit', +}); + +process.exit(result.status ?? 1); diff --git a/examples/prisma-next-cloudflare-worker/src/orm-client/client.ts b/examples/prisma-next-cloudflare-worker/src/orm-client/client.ts new file mode 100644 index 0000000000..028618fe5e --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/src/orm-client/client.ts @@ -0,0 +1,19 @@ +import { orm } from '@prisma-next/sql-orm-client'; +import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; +import type { Runtime } from '@prisma-next/sql-runtime'; +import type { Contract } from '../prisma/contract.d'; +import { db } from '../prisma/db'; +import { PostCollection, UserCollection } from './collections'; + +const context = db.context as ExecutionContext; + +export function createOrmClient(runtime: Runtime) { + return orm({ + runtime, + context, + collections: { + User: UserCollection, + Post: PostCollection, + }, + }); +} diff --git a/examples/prisma-next-cloudflare-worker/src/orm-client/collections.ts b/examples/prisma-next-cloudflare-worker/src/orm-client/collections.ts new file mode 100644 index 0000000000..54cb385b1e --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/src/orm-client/collections.ts @@ -0,0 +1,27 @@ +import { Collection } from '@prisma-next/sql-orm-client'; +import type { Contract } from '../prisma/contract.d'; + +export class UserCollection extends Collection { + admins() { + return this.where({ kind: 'admin' }); + } + + newestFirst() { + return this.orderBy((user) => user.createdAt.desc()); + } +} + +export class PostCollection extends Collection { + forUser(userId: string) { + return this.where({ userId }); + } + + newestFirst() { + return this.orderBy((post) => post.createdAt.desc()); + } +} + +// Note: a `TaskCollection` would mirror the demo, but `Task` queries fail +// against the discriminated schema (`column "bug.id" does not exist`); the +// class-table-inheritance code path is broken at the ORM layer and tracked +// as pre-existing drift in M3 R2 — wire it in when the framework supports it. diff --git a/examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts b/examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts new file mode 100644 index 0000000000..72de230104 --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts @@ -0,0 +1,588 @@ +// ⚠️ GENERATED FILE - DO NOT EDIT +// This file is automatically generated by 'prisma-next contract emit'. +// To regenerate, run: prisma-next contract emit +import type { CodecTypes as PgTypes } from '@prisma-next/target-postgres/codec-types'; +import type { JsonValue } from '@prisma-next/target-postgres/codec-types'; +import type { Char } from '@prisma-next/target-postgres/codec-types'; +import type { Varchar } from '@prisma-next/target-postgres/codec-types'; +import type { Numeric } from '@prisma-next/target-postgres/codec-types'; +import type { Bit } from '@prisma-next/target-postgres/codec-types'; +import type { VarBit } from '@prisma-next/target-postgres/codec-types'; +import type { Timestamp } from '@prisma-next/target-postgres/codec-types'; +import type { Timestamptz } from '@prisma-next/target-postgres/codec-types'; +import type { Time } from '@prisma-next/target-postgres/codec-types'; +import type { Timetz } from '@prisma-next/target-postgres/codec-types'; +import type { Interval } from '@prisma-next/target-postgres/codec-types'; +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; + +import type { + ContractWithTypeMaps, + TypeMaps as TypeMapsType, +} from '@prisma-next/sql-contract/types'; +import type { + Contract as ContractType, + ExecutionHashBase, + ProfileHashBase, + StorageHashBase, +} from '@prisma-next/contract/types'; + +export type StorageHash = + StorageHashBase<'sha256:d97e55ac0dd752218949204fa3c20022d2c9d31da80a7439d9d5b290bb8619a1'>; +export type ExecutionHash = + ExecutionHashBase<'sha256:516d134296237bb5f427dfe28f42f79077d0b72cbcae281fdd1ba3c974b9568e'>; +export type ProfileHash = + ProfileHashBase<'sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e'>; + +export type CodecTypes = PgTypes; +export type OperationTypes = Record; +export type LaneCodecTypes = CodecTypes; +export type QueryOperationTypes = PgAdapterQueryOps; +type DefaultLiteralValue = CodecId extends keyof CodecTypes + ? CodecTypes[CodecId]['output'] + : _Encoded; +export type AddressOutput = { + readonly street: CodecTypes['pg/text@1']['output']; + readonly city: CodecTypes['pg/text@1']['output']; + readonly zip: CodecTypes['pg/text@1']['output'] | null; + readonly country: CodecTypes['pg/text@1']['output']; +}; +export type AddressInput = { + readonly street: CodecTypes['pg/text@1']['input']; + readonly city: CodecTypes['pg/text@1']['input']; + readonly zip: CodecTypes['pg/text@1']['input'] | null; + readonly country: CodecTypes['pg/text@1']['input']; +}; +export type FieldOutputTypes = { + readonly Bug: { + readonly severity: CodecTypes['pg/text@1']['output']; + readonly stepsToRepro: CodecTypes['pg/text@1']['output'] | null; + }; + readonly Feature: { + readonly priority: CodecTypes['pg/text@1']['output']; + readonly targetRelease: CodecTypes['pg/text@1']['output'] | null; + }; + readonly Post: { + readonly id: Char<36>; + readonly title: CodecTypes['pg/text@1']['output']; + readonly userId: CodecTypes['pg/text@1']['output']; + readonly createdAt: CodecTypes['pg/timestamptz@1']['output']; + }; + readonly Task: { + readonly id: Char<36>; + readonly title: CodecTypes['pg/text@1']['output']; + readonly description: CodecTypes['pg/text@1']['output'] | null; + readonly status: CodecTypes['pg/text@1']['output']; + readonly type: CodecTypes['pg/text@1']['output']; + readonly userId: CodecTypes['pg/text@1']['output']; + readonly createdAt: CodecTypes['pg/timestamptz@1']['output']; + }; + readonly User: { + readonly id: Char<36>; + readonly email: CodecTypes['pg/text@1']['output']; + readonly displayName: CodecTypes['pg/text@1']['output']; + readonly createdAt: CodecTypes['pg/timestamptz@1']['output']; + readonly kind: 'admin' | 'user'; + readonly address: AddressOutput | null; + }; +}; +export type FieldInputTypes = { + readonly Bug: { + readonly severity: CodecTypes['pg/text@1']['input']; + readonly stepsToRepro: CodecTypes['pg/text@1']['input'] | null; + }; + readonly Feature: { + readonly priority: CodecTypes['pg/text@1']['input']; + readonly targetRelease: CodecTypes['pg/text@1']['input'] | null; + }; + readonly Post: { + readonly id: CodecTypes['sql/char@1']['input']; + readonly title: CodecTypes['pg/text@1']['input']; + readonly userId: CodecTypes['pg/text@1']['input']; + readonly createdAt: CodecTypes['pg/timestamptz@1']['input']; + }; + readonly Task: { + readonly id: CodecTypes['sql/char@1']['input']; + readonly title: CodecTypes['pg/text@1']['input']; + readonly description: CodecTypes['pg/text@1']['input'] | null; + readonly status: CodecTypes['pg/text@1']['input']; + readonly type: CodecTypes['pg/text@1']['input']; + readonly userId: CodecTypes['pg/text@1']['input']; + readonly createdAt: CodecTypes['pg/timestamptz@1']['input']; + }; + readonly User: { + readonly id: CodecTypes['sql/char@1']['input']; + readonly email: CodecTypes['pg/text@1']['input']; + readonly displayName: CodecTypes['pg/text@1']['input']; + readonly createdAt: CodecTypes['pg/timestamptz@1']['input']; + readonly kind: CodecTypes['pg/enum@1']['input']; + readonly address: AddressInput | null; + }; +}; +export type TypeMaps = TypeMapsType< + CodecTypes, + OperationTypes, + QueryOperationTypes, + FieldOutputTypes, + FieldInputTypes +>; + +type ContractBase = ContractType< + { + readonly tables: { + readonly bug: { + columns: { + readonly severity: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly stepsToRepro: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: true; + }; + }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + readonly feature: { + columns: { + readonly priority: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly targetRelease: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: true; + }; + }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + readonly post: { + columns: { + readonly id: { + readonly nativeType: 'character'; + readonly codecId: 'sql/char@1'; + readonly nullable: false; + readonly typeParams: { readonly length: 36 }; + }; + readonly title: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly userId: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly createdAt: { + readonly nativeType: 'timestamptz'; + readonly codecId: 'pg/timestamptz@1'; + readonly nullable: false; + readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly [ + { + readonly columns: readonly ['userId']; + readonly references: { readonly table: 'user'; readonly columns: readonly ['id'] }; + readonly constraint: true; + readonly index: true; + }, + ]; + }; + readonly task: { + columns: { + readonly id: { + readonly nativeType: 'character'; + readonly codecId: 'sql/char@1'; + readonly nullable: false; + readonly typeParams: { readonly length: 36 }; + }; + readonly title: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly description: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: true; + }; + readonly status: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + readonly default: { + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/text@1', 'open'>; + }; + }; + readonly type: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly userId: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly createdAt: { + readonly nativeType: 'timestamptz'; + readonly codecId: 'pg/timestamptz@1'; + readonly nullable: false; + readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly [ + { + readonly columns: readonly ['userId']; + readonly references: { readonly table: 'user'; readonly columns: readonly ['id'] }; + readonly constraint: true; + readonly index: true; + }, + ]; + }; + readonly user: { + columns: { + readonly id: { + readonly nativeType: 'character'; + readonly codecId: 'sql/char@1'; + readonly nullable: false; + readonly typeParams: { readonly length: 36 }; + }; + readonly email: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly displayName: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly createdAt: { + readonly nativeType: 'timestamptz'; + readonly codecId: 'pg/timestamptz@1'; + readonly nullable: false; + readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + }; + readonly kind: { + readonly nativeType: 'user_type'; + readonly codecId: 'pg/enum@1'; + readonly nullable: false; + readonly typeRef: 'user_type'; + }; + readonly address: { + readonly nativeType: 'jsonb'; + readonly codecId: 'pg/jsonb@1'; + readonly nullable: true; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + }; + readonly types: { + readonly user_type: { + readonly codecId: 'pg/enum@1'; + readonly nativeType: 'user_type'; + readonly typeParams: { readonly values: readonly ['admin', 'user'] }; + }; + }; + readonly storageHash: StorageHash; + }, + { + readonly Bug: { + readonly fields: { + readonly severity: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly stepsToRepro: { + readonly nullable: true; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'bug'; + readonly fields: { + readonly severity: { readonly column: 'severity' }; + readonly stepsToRepro: { readonly column: 'stepsToRepro' }; + }; + }; + readonly base: 'Task'; + }; + readonly Feature: { + readonly fields: { + readonly priority: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly targetRelease: { + readonly nullable: true; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'feature'; + readonly fields: { + readonly priority: { readonly column: 'priority' }; + readonly targetRelease: { readonly column: 'targetRelease' }; + }; + }; + readonly base: 'Task'; + }; + readonly Post: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'sql/char@1'; + readonly typeParams: { readonly length: 36 }; + }; + }; + readonly title: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly userId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly createdAt: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/timestamptz@1' }; + }; + }; + readonly relations: { + readonly user: { + readonly to: 'User'; + readonly cardinality: 'N:1'; + readonly on: { + readonly localFields: readonly ['userId']; + readonly targetFields: readonly ['id']; + }; + }; + }; + readonly storage: { + readonly table: 'post'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly title: { readonly column: 'title' }; + readonly userId: { readonly column: 'userId' }; + readonly createdAt: { readonly column: 'createdAt' }; + }; + }; + }; + readonly Task: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'sql/char@1'; + readonly typeParams: { readonly length: 36 }; + }; + }; + readonly title: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly description: { + readonly nullable: true; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly status: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly type: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly userId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly createdAt: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/timestamptz@1' }; + }; + }; + readonly relations: { + readonly user: { + readonly to: 'User'; + readonly cardinality: 'N:1'; + readonly on: { + readonly localFields: readonly ['userId']; + readonly targetFields: readonly ['id']; + }; + }; + }; + readonly storage: { + readonly table: 'task'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly title: { readonly column: 'title' }; + readonly description: { readonly column: 'description' }; + readonly status: { readonly column: 'status' }; + readonly type: { readonly column: 'type' }; + readonly userId: { readonly column: 'userId' }; + readonly createdAt: { readonly column: 'createdAt' }; + }; + }; + readonly discriminator: { readonly field: 'type' }; + readonly variants: { + readonly Bug: { readonly value: 'bug' }; + readonly Feature: { readonly value: 'feature' }; + }; + }; + readonly User: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'sql/char@1'; + readonly typeParams: { readonly length: 36 }; + }; + }; + readonly email: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly displayName: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly createdAt: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/timestamptz@1' }; + }; + readonly kind: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/enum@1' }; + }; + readonly address: { + readonly nullable: true; + readonly type: { readonly kind: 'valueObject'; readonly name: 'Address' }; + }; + }; + readonly relations: { + readonly posts: { + readonly to: 'Post'; + readonly cardinality: '1:N'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['userId']; + }; + }; + readonly tasks: { + readonly to: 'Task'; + readonly cardinality: '1:N'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['userId']; + }; + }; + }; + readonly storage: { + readonly table: 'user'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly email: { readonly column: 'email' }; + readonly displayName: { readonly column: 'displayName' }; + readonly createdAt: { readonly column: 'createdAt' }; + readonly kind: { readonly column: 'kind' }; + readonly address: { readonly column: 'address' }; + }; + }; + }; + } +> & { + readonly target: 'postgres'; + readonly targetFamily: 'sql'; + readonly roots: { readonly user: 'User'; readonly post: 'Post'; readonly task: 'Task' }; + readonly capabilities: { + readonly postgres: { + readonly jsonAgg: true; + readonly lateral: true; + readonly limit: true; + readonly orderBy: true; + readonly returning: true; + }; + readonly sql: { + readonly defaultInInsert: true; + readonly enums: true; + readonly returning: true; + }; + }; + readonly extensionPacks: {}; + readonly execution: { + readonly executionHash: ExecutionHash; + readonly mutations: { + readonly defaults: readonly [ + { + readonly ref: { readonly table: 'post'; readonly column: 'id' }; + readonly onCreate: { readonly kind: 'generator'; readonly id: 'uuidv4' }; + }, + { + readonly ref: { readonly table: 'task'; readonly column: 'id' }; + readonly onCreate: { readonly kind: 'generator'; readonly id: 'uuidv4' }; + }, + { + readonly ref: { readonly table: 'user'; readonly column: 'id' }; + readonly onCreate: { readonly kind: 'generator'; readonly id: 'uuidv4' }; + }, + ]; + }; + }; + readonly meta: {}; + readonly valueObjects: { + readonly Address: { + readonly fields: { + readonly street: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly city: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly zip: { + readonly nullable: true; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly country: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + }; + }; + readonly profileHash: ProfileHash; +}; + +export type Contract = ContractWithTypeMaps; + +export type Tables = Contract['storage']['tables']; +export type Models = Contract['models']; diff --git a/examples/prisma-next-cloudflare-worker/src/prisma/contract.json b/examples/prisma-next-cloudflare-worker/src/prisma/contract.json new file mode 100644 index 0000000000..e9b6ff0436 --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/src/prisma/contract.json @@ -0,0 +1,664 @@ +{ + "schemaVersion": "1", + "targetFamily": "sql", + "target": "postgres", + "profileHash": "sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e", + "roots": { + "post": "Post", + "task": "Task", + "user": "User" + }, + "models": { + "Bug": { + "base": "Task", + "fields": { + "severity": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "stepsToRepro": { + "nullable": true, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "severity": { + "column": "severity" + }, + "stepsToRepro": { + "column": "stepsToRepro" + } + }, + "table": "bug" + } + }, + "Feature": { + "base": "Task", + "fields": { + "priority": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "targetRelease": { + "nullable": true, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "priority": { + "column": "priority" + }, + "targetRelease": { + "column": "targetRelease" + } + }, + "table": "feature" + } + }, + "Post": { + "fields": { + "createdAt": { + "nullable": false, + "type": { + "codecId": "pg/timestamptz@1", + "kind": "scalar" + } + }, + "id": { + "nullable": false, + "type": { + "codecId": "sql/char@1", + "kind": "scalar", + "typeParams": { + "length": 36 + } + } + }, + "title": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "userId": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": { + "user": { + "cardinality": "N:1", + "on": { + "localFields": [ + "userId" + ], + "targetFields": [ + "id" + ] + }, + "to": "User" + } + }, + "storage": { + "fields": { + "createdAt": { + "column": "createdAt" + }, + "id": { + "column": "id" + }, + "title": { + "column": "title" + }, + "userId": { + "column": "userId" + } + }, + "table": "post" + } + }, + "Task": { + "discriminator": { + "field": "type" + }, + "fields": { + "createdAt": { + "nullable": false, + "type": { + "codecId": "pg/timestamptz@1", + "kind": "scalar" + } + }, + "description": { + "nullable": true, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "id": { + "nullable": false, + "type": { + "codecId": "sql/char@1", + "kind": "scalar", + "typeParams": { + "length": 36 + } + } + }, + "status": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "title": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "type": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "userId": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": { + "user": { + "cardinality": "N:1", + "on": { + "localFields": [ + "userId" + ], + "targetFields": [ + "id" + ] + }, + "to": "User" + } + }, + "storage": { + "fields": { + "createdAt": { + "column": "createdAt" + }, + "description": { + "column": "description" + }, + "id": { + "column": "id" + }, + "status": { + "column": "status" + }, + "title": { + "column": "title" + }, + "type": { + "column": "type" + }, + "userId": { + "column": "userId" + } + }, + "table": "task" + }, + "variants": { + "Bug": { + "value": "bug" + }, + "Feature": { + "value": "feature" + } + } + }, + "User": { + "fields": { + "address": { + "nullable": true, + "type": { + "kind": "valueObject", + "name": "Address" + } + }, + "createdAt": { + "nullable": false, + "type": { + "codecId": "pg/timestamptz@1", + "kind": "scalar" + } + }, + "displayName": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "email": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "id": { + "nullable": false, + "type": { + "codecId": "sql/char@1", + "kind": "scalar", + "typeParams": { + "length": 36 + } + } + }, + "kind": { + "nullable": false, + "type": { + "codecId": "pg/enum@1", + "kind": "scalar" + } + } + }, + "relations": { + "posts": { + "cardinality": "1:N", + "on": { + "localFields": [ + "id" + ], + "targetFields": [ + "userId" + ] + }, + "to": "Post" + }, + "tasks": { + "cardinality": "1:N", + "on": { + "localFields": [ + "id" + ], + "targetFields": [ + "userId" + ] + }, + "to": "Task" + } + }, + "storage": { + "fields": { + "address": { + "column": "address" + }, + "createdAt": { + "column": "createdAt" + }, + "displayName": { + "column": "displayName" + }, + "email": { + "column": "email" + }, + "id": { + "column": "id" + }, + "kind": { + "column": "kind" + } + }, + "table": "user" + } + } + }, + "valueObjects": { + "Address": { + "fields": { + "city": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "country": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "street": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "zip": { + "nullable": true, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + } + } + }, + "storage": { + "storageHash": "sha256:d97e55ac0dd752218949204fa3c20022d2c9d31da80a7439d9d5b290bb8619a1", + "tables": { + "bug": { + "columns": { + "severity": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "stepsToRepro": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": true + } + }, + "foreignKeys": [], + "indexes": [], + "uniques": [] + }, + "feature": { + "columns": { + "priority": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "targetRelease": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": true + } + }, + "foreignKeys": [], + "indexes": [], + "uniques": [] + }, + "post": { + "columns": { + "createdAt": { + "codecId": "pg/timestamptz@1", + "default": { + "expression": "now()", + "kind": "function" + }, + "nativeType": "timestamptz", + "nullable": false + }, + "id": { + "codecId": "sql/char@1", + "nativeType": "character", + "nullable": false, + "typeParams": { + "length": 36 + } + }, + "title": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "userId": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + } + }, + "foreignKeys": [ + { + "columns": [ + "userId" + ], + "constraint": true, + "index": true, + "references": { + "columns": [ + "id" + ], + "table": "user" + } + } + ], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [] + }, + "task": { + "columns": { + "createdAt": { + "codecId": "pg/timestamptz@1", + "default": { + "expression": "now()", + "kind": "function" + }, + "nativeType": "timestamptz", + "nullable": false + }, + "description": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": true + }, + "id": { + "codecId": "sql/char@1", + "nativeType": "character", + "nullable": false, + "typeParams": { + "length": 36 + } + }, + "status": { + "codecId": "pg/text@1", + "default": { + "kind": "literal", + "value": "open" + }, + "nativeType": "text", + "nullable": false + }, + "title": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "type": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "userId": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + } + }, + "foreignKeys": [ + { + "columns": [ + "userId" + ], + "constraint": true, + "index": true, + "references": { + "columns": [ + "id" + ], + "table": "user" + } + } + ], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [] + }, + "user": { + "columns": { + "address": { + "codecId": "pg/jsonb@1", + "nativeType": "jsonb", + "nullable": true + }, + "createdAt": { + "codecId": "pg/timestamptz@1", + "default": { + "expression": "now()", + "kind": "function" + }, + "nativeType": "timestamptz", + "nullable": false + }, + "displayName": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "email": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "id": { + "codecId": "sql/char@1", + "nativeType": "character", + "nullable": false, + "typeParams": { + "length": 36 + } + }, + "kind": { + "codecId": "pg/enum@1", + "nativeType": "user_type", + "nullable": false, + "typeRef": "user_type" + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [] + } + }, + "types": { + "user_type": { + "codecId": "pg/enum@1", + "nativeType": "user_type", + "typeParams": { + "values": [ + "admin", + "user" + ] + } + } + } + }, + "execution": { + "executionHash": "sha256:516d134296237bb5f427dfe28f42f79077d0b72cbcae281fdd1ba3c974b9568e", + "mutations": { + "defaults": [ + { + "onCreate": { + "id": "uuidv4", + "kind": "generator" + }, + "ref": { + "column": "id", + "table": "post" + } + }, + { + "onCreate": { + "id": "uuidv4", + "kind": "generator" + }, + "ref": { + "column": "id", + "table": "task" + } + }, + { + "onCreate": { + "id": "uuidv4", + "kind": "generator" + }, + "ref": { + "column": "id", + "table": "user" + } + } + ] + } + }, + "capabilities": { + "postgres": { + "jsonAgg": true, + "lateral": true, + "limit": true, + "orderBy": true, + "returning": true + }, + "sql": { + "defaultInInsert": true, + "enums": true, + "returning": true + } + }, + "extensionPacks": {}, + "meta": {}, + "_generated": { + "warning": "⚠️ GENERATED FILE - DO NOT EDIT", + "message": "This file is automatically generated by \"prisma-next contract emit\".", + "regenerate": "To regenerate, run: prisma-next contract emit" + } +} \ No newline at end of file diff --git a/examples/prisma-next-cloudflare-worker/src/prisma/db.ts b/examples/prisma-next-cloudflare-worker/src/prisma/db.ts new file mode 100644 index 0000000000..496877d0cc --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/src/prisma/db.ts @@ -0,0 +1,24 @@ +import { createTelemetryMiddleware } from '@prisma-next/middleware-telemetry'; +import postgresServerless from '@prisma-next/postgres/serverless'; +import { budgets, lints } from '@prisma-next/sql-runtime'; +import type { Contract } from './contract.d'; +import contractJson from './contract.json' with { type: 'json' }; + +/** + * Module-scope client. Constructing once per isolate is correct: only the static + * authoring surface (`sql`, `context`, `stack`, `contract`) is closure-cached. + * The per-request runtime is acquired inside `fetch` via `db.connect({ url })`. + */ +export const db = postgresServerless({ + contractJson, + middleware: [ + createTelemetryMiddleware(), + lints(), + budgets({ + maxRows: 10_000, + defaultTableRows: 10_000, + tableRows: { user: 10_000, post: 10_000 }, + maxLatencyMs: 5_000, + }), + ], +}); diff --git a/examples/prisma-next-cloudflare-worker/src/worker.ts b/examples/prisma-next-cloudflare-worker/src/worker.ts new file mode 100644 index 0000000000..f8f1c80ec9 --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/src/worker.ts @@ -0,0 +1,177 @@ +import { withTransaction } from '@prisma-next/sql-runtime'; +import { Client } from 'pg'; +import { createOrmClient } from './orm-client/client'; +import { db } from './prisma/db'; + +interface Env { + HYPERDRIVE: { connectionString: string }; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/health') { + return Response.json({ ok: true }); + } + + await using runtime = await db.connect({ url: env.HYPERDRIVE.connectionString }); + + if (url.pathname === '/sql/users') { + const limit = parseLimit(url.searchParams.get('limit'), 10); + const rows = await runtime.execute( + db.sql.user.select('id', 'email', 'displayName', 'kind', 'createdAt').limit(limit).build(), + ); + return Response.json({ ok: true, route: 'sql/users', count: rows.length, rows }); + } + + if (url.pathname === '/orm/users') { + const limit = parseLimit(url.searchParams.get('limit'), 10); + const orm = createOrmClient(runtime); + const rows = await orm.User.newestFirst().take(limit).all(); + return Response.json({ ok: true, route: 'orm/users', count: rows.length, rows }); + } + + if (url.pathname === '/orm/posts') { + const userId = url.searchParams.get('userId'); + if (!userId) { + return Response.json({ ok: false, error: 'userId required' }, { status: 400 }); + } + const limit = parseLimit(url.searchParams.get('limit'), 10); + const orm = createOrmClient(runtime); + const rows = await orm.Post.forUser(userId) + .orderBy((post) => post.createdAt.desc()) + .take(limit) + .all(); + return Response.json({ ok: true, route: 'orm/posts', count: rows.length, rows }); + } + + if (url.pathname === '/tx/commit') { + const userId = url.searchParams.get('userId'); + const newDisplayName = url.searchParams.get('displayName') ?? 'Updated'; + if (!userId) { + return Response.json({ ok: false, error: 'userId required' }, { status: 400 }); + } + const result = await withTransaction(runtime, async (tx) => { + await tx.execute( + db.sql.post + .insert({ + title: `Post written in tx for ${userId}`, + userId, + createdAt: new Date(), + }) + .build(), + ); + await tx.execute( + db.sql.user + .update({ displayName: newDisplayName }) + .where((f, fns) => fns.eq(f.id, userId)) + .build(), + ); + return { committed: true }; + }); + return Response.json({ ok: true, route: 'tx/commit', ...result }); + } + + if (url.pathname === '/tx/rollback') { + try { + await withTransaction(runtime, async (tx) => { + await tx.execute( + db.sql.user + .update({ displayName: 'rolled-back-write' }) + .where((f, fns) => fns.eq(f.email, 'alice@example.com')) + .build(), + ); + throw new Error('intentional rollback'); + }); + return Response.json({ ok: false, error: 'expected rollback but transaction committed' }); + } catch (err) { + return Response.json({ + ok: true, + route: 'tx/rollback', + message: err instanceof Error ? err.message : String(err), + }); + } + } + + if (url.pathname === '/cursor/large') { + const breakAfter = parseLimit(url.searchParams.get('break'), 50); + const consumed: { id: string; title: string }[] = []; + let cancelled = false; + + // Open a side-channel pg.Client to instrument the cursor query via + // pg_stat_statements (loaded via shared_preload_libraries in + // docker-compose / CI). Two-client pattern: the runtime owns the + // primary connection that runs the SELECT; this observer connection + // resets stats before and reads them after, so the test can prove + // that with cursor enabled the server transmitted only ~one batch + // worth of rows (not the full LIMIT). With cursor disabled the + // observer would see the full ~10_000 rows row count. + const observer = new Client({ connectionString: env.HYPERDRIVE.connectionString }); + await observer.connect(); + try { + await observer.query('SELECT pg_stat_statements_reset()'); + + const t0 = Date.now(); + // SELECT bounded to the post-table budget cap (10_000 — see + // `src/prisma/db.ts`). With cursor enabled the driver opens a + // server-side cursor and streams in ~100-row batches; an early + // `break` only fetches one batch and closes. With cursor disabled + // the driver buffers all 10_000 rows before the first yield. + const iter = runtime.execute( + db.sql.post + .select('id', 'title') + .orderBy((f) => f.createdAt, { direction: 'asc' }) + .limit(10_000) + .build(), + ); + for await (const row of iter) { + consumed.push(row); + if (consumed.length >= breakAfter) { + cancelled = true; + break; + } + } + const elapsedMs = Date.now() - t0; + + // Sum rows over every statement that touched the post table since + // the reset above. pg_stat_statements normalizes parameters but + // preserves table names, so the LIKE filter is precise enough. + const statsResult = await observer.query<{ rows: string }>( + `SELECT COALESCE(SUM(rows), 0)::text AS rows + FROM pg_stat_statements + WHERE query ILIKE '%from%post%'`, + ); + const rowsTransmitted = Number(statsResult.rows[0]?.rows ?? '0'); + + return Response.json({ + ok: true, + route: 'cursor/large', + consumed: consumed.length, + cancelled, + elapsedMs, + rowsTransmitted, + }); + } finally { + await observer.end(); + } + } + + // The Task collection (and its Bug/Feature variants) is wired in + // `src/orm-client/collections.ts` for parity with the demo schema, but + // queries against it currently fail with `column "bug.id" does not exist` + // — class-table inheritance with @@map is broken at the ORM layer. Not + // exercised here; flagged as pre-existing drift in M3 R2. + + return Response.json( + { ok: false, error: 'unknown route', path: url.pathname }, + { status: 404 }, + ); + }, +}; + +function parseLimit(raw: string | null, fallback: number): number { + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} diff --git a/examples/prisma-next-cloudflare-worker/test/cloudflare-test.d.ts b/examples/prisma-next-cloudflare-worker/test/cloudflare-test.d.ts new file mode 100644 index 0000000000..f2d39c19ef --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/test/cloudflare-test.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/prisma-next-cloudflare-worker/test/global-setup.ts b/examples/prisma-next-cloudflare-worker/test/global-setup.ts new file mode 100644 index 0000000000..46a7e9b1af --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/test/global-setup.ts @@ -0,0 +1,161 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { Client } from 'pg'; +import type { ProvidedContext } from 'vitest'; + +interface GlobalSetupContext { + provide(key: K, value: ProvidedContext[K]): void; +} + +declare module 'vitest' { + export interface ProvidedContext { + 'database-url': string; + 'alice-id': string; + 'bob-id': string; + } +} + +const HYPERDRIVE_VAR = 'WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE'; +const exampleRoot = fileURLToPath(new URL('..', import.meta.url)); + +function normalize(connectionString: string): string { + const url = new URL(connectionString); + if (url.hostname === 'localhost' || url.hostname === '::1') { + url.hostname = '127.0.0.1'; + } + return url.toString(); +} + +function loadDotEnv(filename: string): Record { + const path = `${exampleRoot}/${filename}`; + if (!existsSync(path)) return {}; + const out: Record = {}; + for (const line of readFileSync(path, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + const raw = trimmed.slice(eq + 1).trim(); + out[key] = raw.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1'); + } + return out; +} + +function resolveDatabaseUrl(): string { + const fileVars = loadDotEnv('.env'); + const url = fileVars[HYPERDRIVE_VAR] ?? process.env[HYPERDRIVE_VAR]; + if (!url) { + throw new Error( + `[global-setup] ${HYPERDRIVE_VAR} not set. Run \`pnpm db:up\` and copy \`.env.example\` to \`.env\`.`, + ); + } + return normalize(url); +} + +async function ensureContainerReady(databaseUrl: string): Promise { + const deadline = Date.now() + 15_000; + let lastErr: unknown; + while (Date.now() < deadline) { + const client = new Client({ connectionString: databaseUrl }); + try { + await client.connect(); + await client.query('select 1'); + return; + } catch (err) { + lastErr = err; + await new Promise((r) => setTimeout(r, 500)); + } finally { + try { + await client.end(); + } catch { + // ignore + } + } + } + throw new Error( + `[global-setup] Postgres at ${databaseUrl} unreachable after 15s. Did you run \`pnpm db:up\`? Last error: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`, + ); +} + +async function applySchema(databaseUrl: string): Promise { + const result = spawnSync( + 'pnpm', + ['exec', 'prisma-next', 'db', 'init', '--db', databaseUrl, '--yes', '--no-color'], + { cwd: exampleRoot, stdio: 'inherit' }, + ); + if (result.status !== 0) { + throw new Error(`prisma-next db init failed with status ${result.status ?? 'unknown'}`); + } +} + +const ALICE_ID = '00000000-0000-4000-8000-000000000001'; +const BOB_ID = '00000000-0000-4000-8000-000000000002'; + +// Sized to the budgets cap in `src/prisma/db.ts` (`tableRows.post: 10_000`) +// — large enough that the cursor early-break test (`/cursor/large`) is +// observably fast under cursor=on and observably slow under cursor=off. +const POST_SEED_COUNT = 10_000; + +async function ensurePgStatStatements(databaseUrl: string): Promise { + const client = new Client({ connectionString: databaseUrl }); + await client.connect(); + try { + // pg_stat_statements is preloaded via docker-compose's shared_preload_libraries; + // CREATE EXTENSION makes its catalog views queryable from the test session. + await client.query('CREATE EXTENSION IF NOT EXISTS pg_stat_statements'); + } finally { + await client.end(); + } +} + +async function resetAndSeed(databaseUrl: string): Promise { + const client = new Client({ connectionString: databaseUrl }); + await client.connect(); + try { + // Wipe in dependency order so re-runs against a long-lived container start clean. + await client.query('TRUNCATE "post", "task", "user" RESTART IDENTITY CASCADE'); + + await client.query( + `INSERT INTO "user" (id, email, "displayName", "createdAt", kind, address) VALUES + ($1, 'alice@example.com', 'Alice', '2026-04-01T00:00:00Z', 'admin', + '{"street":"123 Main St","city":"San Francisco","zip":"94102","country":"US"}'::jsonb), + ($2, 'bob@example.com', 'Bob', '2026-04-02T00:00:00Z', 'user', + '{"street":"456 Oak Ave","city":"Portland","zip":null,"country":"US"}'::jsonb)`, + [ALICE_ID, BOB_ID], + ); + + // Single set-based INSERT via generate_series — bulk-loads 10k rows in + // one round trip (much faster than batched multi-row VALUES). + await client.query( + `INSERT INTO "post" (id, title, "userId", "createdAt") + SELECT + '10000000-0000-4000-8000-' || lpad(g::text, 12, '0'), + 'Post ' || g, + CASE WHEN g % 2 = 0 THEN $1 ELSE $2 END, + TIMESTAMPTZ '2026-04-01 00:00:00+00' + ((g % 365) * INTERVAL '1 hour') + FROM generate_series(1, $3::int) AS g`, + [ALICE_ID, BOB_ID, POST_SEED_COUNT], + ); + } finally { + await client.end(); + } +} + +export default async function setup({ provide }: GlobalSetupContext) { + const databaseUrl = resolveDatabaseUrl(); + console.log(`[global-setup] connecting to Postgres at ${databaseUrl}`); + + await ensureContainerReady(databaseUrl); + await applySchema(databaseUrl); + await ensurePgStatStatements(databaseUrl); + await resetAndSeed(databaseUrl); + + provide('database-url', databaseUrl); + provide('alice-id', ALICE_ID); + provide('bob-id', BOB_ID); + + // No teardown: the container is owned by the maker (`pnpm db:up`/`pnpm db:down`). + return async () => {}; +} diff --git a/examples/prisma-next-cloudflare-worker/test/worker.integration.test.ts b/examples/prisma-next-cloudflare-worker/test/worker.integration.test.ts new file mode 100644 index 0000000000..13adcf4222 --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/test/worker.integration.test.ts @@ -0,0 +1,111 @@ +import { SELF } from 'cloudflare:test'; +import { describe, expect, inject, it } from 'vitest'; + +const ALICE = inject('alice-id'); +const BOB = inject('bob-id'); + +async function get(path: string): Promise { + return await SELF.fetch(new Request(`https://worker.local${path}`)); +} + +describe('worker — postgresServerless against Hyperdrive (local)', () => { + it('boots and responds to /health (TC-3 — module load under nodejs_compat)', async () => { + const res = await get('/health'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + }); + + it('SQL DSL select returns seeded users (TC-4)', async () => { + const res = await get('/sql/users?limit=5'); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean; rows: { id: string; email: string }[] }; + expect(body.ok).toBe(true); + expect(body.rows.length).toBe(2); + expect(body.rows.map((r) => r.email).sort()).toEqual(['alice@example.com', 'bob@example.com']); + }); + + it('ORM client list returns seeded users (TC-5)', async () => { + const res = await get('/orm/users?limit=10'); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean; rows: { id: string; email: string }[] }; + expect(body.ok).toBe(true); + expect(body.rows.length).toBe(2); + }); + + it('ORM relation traversal returns posts for a user', async () => { + const res = await get(`/orm/posts?userId=${ALICE}&limit=10`); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean; rows: { userId: string }[] }; + expect(body.ok).toBe(true); + expect(body.rows.length).toBeGreaterThan(0); + expect(body.rows.every((row) => row.userId === ALICE)).toBe(true); + }); + + it('withTransaction commits a multi-statement transaction (TC-6, AC-10)', async () => { + const res = await get(`/tx/commit?userId=${BOB}&displayName=Bob+the+Builder`); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean; committed?: boolean }; + expect(body.ok).toBe(true); + expect(body.committed).toBe(true); + + const verify = await get('/sql/users?limit=10'); + const verified = (await verify.json()) as { + rows: { id: string; displayName: string }[]; + }; + const bob = verified.rows.find((r) => r.id === BOB); + expect(bob?.displayName).toBe('Bob the Builder'); + }); + + it('withTransaction rolls back on thrown error (AC-10/AC-11)', async () => { + const before = (await (await get('/sql/users?limit=10')).json()) as { + rows: { email: string; displayName: string }[]; + }; + const aliceBefore = before.rows.find((r) => r.email === 'alice@example.com'); + + const res = await get('/tx/rollback'); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean; message?: string }; + expect(body.ok).toBe(true); + expect(body.message).toContain('intentional rollback'); + + const after = (await (await get('/sql/users?limit=10')).json()) as { + rows: { email: string; displayName: string }[]; + }; + const aliceAfter = after.rows.find((r) => r.email === 'alice@example.com'); + expect(aliceAfter?.displayName).toBe(aliceBefore?.displayName); + expect(aliceAfter?.displayName).not.toBe('rolled-back-write'); + }); + + it('cursor early-break consumes only the requested rows (TC-9, AC-6)', async () => { + const breakAfter = 7; + const res = await get(`/cursor/large?break=${breakAfter}`); + expect(res.status).toBe(200); + const body = (await res.json()) as { + ok: boolean; + consumed: number; + cancelled: boolean; + elapsedMs: number; + rowsTransmitted: number; + }; + expect(body.ok).toBe(true); + // global-setup seeds 10_000 posts. `consumed === breakAfter` proves the + // for-await loop exited via `break`, not via the iterator running out. + expect(body.consumed).toBe(breakAfter); + expect(body.cancelled).toBe(true); + // The behavioral assertion that demonstrably fails when cursor is + // disabled: pg_stat_statements (queried by the route via a side-channel + // pg.Client) reports how many rows the server actually transmitted. + // With cursor enabled, only the first ~100-row batch is fetched before + // the early `break` closes the cursor. With cursor disabled, the driver + // buffers all 10_000 rows. Threshold of 500 leaves headroom for the + // default 100-row batch size + one round of refill jitter, while still + // failing decisively at 10_000. + expect(body.rowsTransmitted).toBeGreaterThan(0); + expect(body.rowsTransmitted).toBeLessThan(500); + }); + + it('returns 404 for unknown routes', async () => { + const res = await get('/no/such/route'); + expect(res.status).toBe(404); + }); +}); diff --git a/examples/prisma-next-cloudflare-worker/tsconfig.json b/examples/prisma-next-cloudflare-worker/tsconfig.json new file mode 100644 index 0000000000..08618e62ce --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": ["@prisma-next/tsconfig/base"], + "compilerOptions": { + "outDir": "dist", + "lib": ["ES2022", "ESNext.Disposable", "WebWorker"], + "types": ["@cloudflare/workers-types", "node"], + "moduleResolution": "bundler" + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "test/**/*.d.ts", + "scripts/**/*.ts", + "prisma/**/*.ts", + "prisma-next.config.ts", + "vitest.config.ts" + ], + "exclude": ["dist"] +} diff --git a/examples/prisma-next-cloudflare-worker/vitest.config.ts b/examples/prisma-next-cloudflare-worker/vitest.config.ts new file mode 100644 index 0000000000..892fcf0b7f --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/vitest.config.ts @@ -0,0 +1,69 @@ +import 'dotenv/config'; +import { cloudflareTest } from '@cloudflare/vitest-pool-workers'; +import { defineConfig } from 'vitest/config'; + +const WRANGLER_HYPERDRIVE_VAR = 'WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE'; +const CLOUDFLARE_HYPERDRIVE_VAR = 'CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE'; + +// vitest-pool-workers' parseCustomPoolOptions calls wrangler's +// `unstable_getMiniflareWorkerOptions` BEFORE the `cloudflareTest` callback +// runs, so wrangler must already see the Hyperdrive env var when the config +// is parsed. Mirror WRANGLER_* into CLOUDFLARE_* (wrangler 4.87 deprecated +// the WRANGLER_* prefix). Soft-fail when neither is set: globalSetup throws +// the actionable error (`pnpm db:up && cp .env.example .env`); throwing +// here would crash any tooling that imports the config (e.g. `vitest list`, +// IDE integrations, `pnpm test:examples` filter passes that don't actually +// need the binding). +const databaseUrl = process.env[WRANGLER_HYPERDRIVE_VAR] ?? process.env[CLOUDFLARE_HYPERDRIVE_VAR]; +if (databaseUrl) { + process.env[CLOUDFLARE_HYPERDRIVE_VAR] ??= databaseUrl; +} + +export default defineConfig({ + plugins: [ + cloudflareTest(({ inject }) => ({ + wrangler: { configPath: './wrangler.jsonc' }, + miniflare: { + compatibilityFlags: ['nodejs_compat'], + compatibilityDate: '2025-07-18', + hyperdrives: { + HYPERDRIVE: inject('database-url'), + }, + }, + })), + ], + test: { + globalSetup: ['./test/global-setup.ts'], + testTimeout: 60_000, + hookTimeout: 120_000, + // Pre-bundle pg and friends so vitest-pool-workers' module-fallback server + // (which currently mis-resolves dual ESM/CJS exports under Vite 8 — see + // cloudflare/workers-sdk#12984 and #13037) doesn't see them as bare + // node_modules at workerd-load time. + deps: { + optimizer: { + ssr: { + enabled: true, + include: ['pg', 'pg-protocol', 'pg-connection-string', 'pg-cursor', 'pg-cloudflare'], + rolldownOptions: { + external: [ + 'net', + 'events', + 'util', + 'tls', + 'path', + 'fs', + 'dns', + 'crypto', + 'stream', + 'string_decoder', + 'os', + 'buffer', + 'url', + ], + }, + }, + }, + }, + }, +}); diff --git a/examples/prisma-next-cloudflare-worker/wrangler.jsonc b/examples/prisma-next-cloudflare-worker/wrangler.jsonc new file mode 100644 index 0000000000..d65582c0ac --- /dev/null +++ b/examples/prisma-next-cloudflare-worker/wrangler.jsonc @@ -0,0 +1,17 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "prisma-next-cloudflare-worker", + "main": "src/worker.ts", + "compatibility_date": "2025-07-18", + "compatibility_flags": ["nodejs_compat"], + "hyperdrive": [ + { + // Production binding ID is provisioned in M4 task 4.2 via `wrangler hyperdrive create`. + // For local dev, set WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE in + // `.env` (gitignored); see README. The vitest-pool-workers integration test + // injects the URL via miniflare's `hyperdrives` option in `vitest.config.ts`. + "binding": "HYPERDRIVE", + "id": "00000000000000000000000000000000" + } + ] +} diff --git a/package.json b/package.json index a237396f27..b3f843c224 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,8 @@ "rules:footprint": "node scripts/rules-footprint.mjs", "lint:docs": "node scripts/validate-package-readmes.mjs", "check:publish-deps": "node scripts/check-publish-deps.mjs", - "fixtures:emit": "pnpm --filter @prisma-next/sql-builder --filter @prisma-next/sql-orm-client --filter @prisma-next/e2e-tests --filter @prisma-next/e2e-sqlite-tests --filter @prisma-next/integration-tests --filter prisma-next-demo -r emit", - "fixtures:check": "pnpm fixtures:emit && git diff --exit-code -- packages/2-sql/4-lanes/sql-builder/test/fixtures/generated/ packages/3-extensions/sql-orm-client/test/fixtures/generated/ test/e2e/framework/test/fixtures/generated/ test/e2e/sqlite/test/fixtures/generated/ test/integration/test/fixtures/ examples/prisma-next-demo/src/prisma/contract.json examples/prisma-next-demo/src/prisma/contract.d.ts", + "fixtures:emit": "pnpm --filter @prisma-next/sql-builder --filter @prisma-next/sql-orm-client --filter @prisma-next/e2e-tests --filter @prisma-next/e2e-sqlite-tests --filter @prisma-next/integration-tests --filter prisma-next-demo --filter prisma-next-cloudflare-worker -r emit", + "fixtures:check": "pnpm fixtures:emit && git diff --exit-code -- packages/2-sql/4-lanes/sql-builder/test/fixtures/generated/ packages/3-extensions/sql-orm-client/test/fixtures/generated/ test/e2e/framework/test/fixtures/generated/ test/e2e/sqlite/test/fixtures/generated/ test/integration/test/fixtures/ examples/prisma-next-demo/src/prisma/contract.json examples/prisma-next-demo/src/prisma/contract.d.ts examples/prisma-next-cloudflare-worker/src/prisma/contract.json examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts", "typecheck": "turbo run typecheck", "typecheck:all": "pnpm typecheck && pnpm typecheck:examples", "typecheck:packages": "turbo run typecheck --filter='!./examples/**'", diff --git a/packages/3-extensions/postgres/README.md b/packages/3-extensions/postgres/README.md index 725fd4a627..b8df3fb348 100644 --- a/packages/3-extensions/postgres/README.md +++ b/packages/3-extensions/postgres/README.md @@ -2,11 +2,18 @@ One-package Postgres setup for Prisma Next. Install this single package to get config, runtime, and all transitive type dependencies. +Two runtime facades ship under different entrypoints: + +- `@prisma-next/postgres/runtime` — long-lived Node process facade with closure-cached `runtime()`, `orm`, and `transaction()`. +- `@prisma-next/postgres/serverless` — per-request facade for serverless / edge runtimes (Cloudflare Workers + Hyperdrive, AWS Lambda, Vercel, Deno Deploy, Bun edge). Each `connect()` returns a fresh `Runtime & AsyncDisposable`. + +Pick the facade that matches your deployment lifecycle. The asymmetry is intentional: closure caching is unsafe across `fetch` invocations (stale connections after isolate idle, concurrent-query races, no clean shutdown), so the serverless facade deliberately omits `orm`, `runtime()`, and `transaction()`. See `docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md` and the deployment guide for the rationale. + ## Package Classification - **Domain**: extensions - **Layer**: adapters -- **Planes**: shared (config), runtime (runtime) +- **Planes**: shared (config), runtime (runtime, serverless) ## Quick Start @@ -20,6 +27,8 @@ export default defineConfig({ }); ``` +### Node (long-lived process) + ```typescript // db.ts import postgres from '@prisma-next/postgres/runtime'; @@ -29,6 +38,28 @@ import contractJson from './contract.json' with { type: 'json' }; export const db = postgres({ contractJson }); ``` +### Serverless / per-request runtimes + +```typescript +// db.ts — module scope: only the static authoring surface is built here. +import postgresServerless from '@prisma-next/postgres/serverless'; +import type { Contract } from './contract.d'; +import contractJson from './contract.json' with { type: 'json' }; + +export const db = postgresServerless({ contractJson }); + +// worker.ts — per-request: acquire a fresh Runtime, dispose with `await using`. +export default { + async fetch(_req: Request, env: Env): Promise { + await using runtime = await db.connect({ url: env.HYPERDRIVE.connectionString }); + const rows = await runtime.execute(db.sql.from(/* ... */).build()); + return Response.json(rows); + }, +}; +``` + +The returned client exposes `sql`, `context`, `stack`, `contract`, and `connect()` — and intentionally nothing else. Construct ORM clients (or invoke `withTransaction` from `@prisma-next/sql-runtime`) against the runtime returned by `connect()` instead of caching one on the closure. + ## Exports ### `@prisma-next/postgres/config` @@ -40,14 +71,10 @@ Simplified `defineConfig` that pre-wires all Postgres internals (family, target, `@prisma-next/postgres/runtime` exposes a single `postgres(...)` helper that composes the Postgres execution stack and returns query/runtime roots: - `db.sql` -- `db.kysely` (lane-owned build-only authoring surface: `build(query)` + `whereExpr(query)`) -- `db.schema` - `db.orm` - `db.context` - `db.stack` -`db.kysely` is produced by `@prisma-next/sql-kysely-lane` and intentionally exposes lane behavior, not raw Kysely execution APIs. `build(query)` infers plan row type from `query.compile()`, and `whereExpr(query)` produces `ToWhereExpr` payloads for ORM `.where(...)` interop. - Runtime resources are deferred until `db.runtime()` or `db.connect(...)` is called. Connection binding can be provided up front (`url`, `pg`, `binding`) or deferred via `db.connect(...)`. @@ -56,11 +83,23 @@ When URL binding is used, pool timeouts are configurable via `poolOptions`: - `poolOptions.connectionTimeoutMillis` (default `20_000`) - `poolOptions.idleTimeoutMillis` (default `30_000`) +### `@prisma-next/postgres/serverless` + +`@prisma-next/postgres/serverless` exposes `postgresServerless(...)` for per-request runtimes. The returned client exposes only: + +- `db.sql` +- `db.context` +- `db.stack` +- `db.contract` +- `db.connect({ url })` — returns `Promise` + +Each `connect()` call constructs a fresh `pg.Client` and a fresh `Runtime`. No `pg.Pool` is allocated. `[Symbol.asyncDispose]` calls `runtime.close()`, which closes the underlying client. `pg-cursor` is enabled by default; opt out via `cursor: { disabled: true }`. + ## Responsibilities - Build a static Postgres execution stack from target, adapter, and driver descriptors -- Build typed SQL and a build-only Kysely authoring surface from the same execution context -- Build static schema and ORM roots from the execution context +- Build a typed SQL authoring surface from the execution context +- Build a static ORM root from the execution context - Normalize runtime binding input (`binding`, `url`, `pg`) - Lazily instantiate runtime resources on first `db.runtime()` or `db.connect(...)` call - Connect the internal Postgres driver through `db.connect(...)` or from initial binding options @@ -73,19 +112,17 @@ When URL binding is used, pool timeouts are configurable via `poolOptions`: - `@prisma-next/target-postgres` for target descriptor - `@prisma-next/adapter-postgres` for adapter descriptor - `@prisma-next/driver-postgres` for driver descriptor -- `@prisma-next/sql-lane` for `sql(...)` -- `@prisma-next/sql-kysely-lane` for contract-to-Kysely typing and build-only Kysely plan assembly -- `@prisma-next/sql-relational-core` for `schema(...)` +- `@prisma-next/sql-builder` for `sql(...)` - `@prisma-next/sql-orm-client` for `orm(...)` - `@prisma-next/sql-contract` for `validateContract(...)` and contract types -- `pg` for lazy `Pool` construction when using URL binding +- `pg` for `Pool` construction (URL / `pgPool` binding on the Node factory) and `Client` construction (`pgClient` binding on the Node factory; per-`connect()` on the serverless facade) ## Architecture ```mermaid flowchart TD App[App Code] --> Client[postgres(...)] - Client --> Static[Roots: sql kysely(build-only) schema orm context stack] + Client --> Static[Roots: sql orm context stack] Client --> Lazy[runtime()] Lazy --> Instantiate[instantiateExecutionStack] diff --git a/packages/3-extensions/postgres/package.json b/packages/3-extensions/postgres/package.json index 7486efae21..2fb88eec24 100644 --- a/packages/3-extensions/postgres/package.json +++ b/packages/3-extensions/postgres/package.json @@ -57,6 +57,7 @@ "exports": { "./config": "./dist/config.mjs", "./runtime": "./dist/runtime.mjs", + "./serverless": "./dist/serverless.mjs", "./package.json": "./package.json" }, "main": "./dist/runtime.mjs", diff --git a/packages/3-extensions/postgres/src/exports/serverless.ts b/packages/3-extensions/postgres/src/exports/serverless.ts new file mode 100644 index 0000000000..772a8b6ed5 --- /dev/null +++ b/packages/3-extensions/postgres/src/exports/serverless.ts @@ -0,0 +1,9 @@ +export type { + PostgresServerlessClient, + PostgresServerlessCursorOptions, + PostgresServerlessOptions, + PostgresServerlessOptionsBase, + PostgresServerlessOptionsWithContract, + PostgresServerlessOptionsWithContractJson, +} from '../runtime/postgres-serverless'; +export { default } from '../runtime/postgres-serverless'; diff --git a/packages/3-extensions/postgres/src/runtime/postgres-serverless.ts b/packages/3-extensions/postgres/src/runtime/postgres-serverless.ts new file mode 100644 index 0000000000..c55592cdd4 --- /dev/null +++ b/packages/3-extensions/postgres/src/runtime/postgres-serverless.ts @@ -0,0 +1,185 @@ +import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; +import type { Contract } from '@prisma-next/contract/types'; +import postgresDriver, { + type PostgresDriverCreateOptions, +} from '@prisma-next/driver-postgres/runtime'; +import { emptyCodecLookup } from '@prisma-next/framework-components/codec'; +import { instantiateExecutionStack } from '@prisma-next/framework-components/execution'; +import { sql as sqlBuilder } from '@prisma-next/sql-builder/runtime'; +import type { Db } from '@prisma-next/sql-builder/types'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import { validateContract } from '@prisma-next/sql-contract/validate'; +import type { + ExecutionContext, + Runtime, + RuntimeVerifyOptions, + SqlExecutionStackWithDriver, + SqlMiddleware, + SqlRuntimeExtensionDescriptor, +} from '@prisma-next/sql-runtime'; +import { + createExecutionContext, + createRuntime, + createSqlExecutionStack, +} from '@prisma-next/sql-runtime'; +import postgresTarget from '@prisma-next/target-postgres/runtime'; +import { ifDefined } from '@prisma-next/utils/defined'; +import { Client } from 'pg'; + +import type { PostgresTargetId } from './postgres'; + +export type PostgresServerlessCursorOptions = NonNullable; + +export interface PostgresServerlessClient> { + readonly sql: Db; + readonly context: ExecutionContext; + readonly stack: SqlExecutionStackWithDriver; + readonly contract: TContract; + connect(binding: { readonly url: string }): Promise; +} + +export interface PostgresServerlessOptionsBase { + readonly extensions?: readonly SqlRuntimeExtensionDescriptor[]; + readonly middleware?: readonly SqlMiddleware[]; + readonly verify?: RuntimeVerifyOptions; + readonly cursor?: PostgresServerlessCursorOptions; +} + +export type PostgresServerlessOptionsWithContract> = + PostgresServerlessOptionsBase & { + readonly contract: TContract; + readonly contractJson?: never; + }; + +export type PostgresServerlessOptionsWithContractJson> = + PostgresServerlessOptionsBase & { + readonly contractJson: unknown; + readonly contract?: never; + readonly _contract?: TContract; + }; + +export type PostgresServerlessOptions> = + | PostgresServerlessOptionsWithContract + | PostgresServerlessOptionsWithContractJson; + +function hasContractJson>( + options: PostgresServerlessOptions, +): options is PostgresServerlessOptionsWithContractJson { + return 'contractJson' in options; +} + +function resolveContract>( + options: PostgresServerlessOptions, +): TContract { + const contractInput = hasContractJson(options) ? options.contractJson : options.contract; + return validateContract(contractInput, emptyCodecLookup); +} + +function validateConnectionString(url: string): string { + const trimmed = url.trim(); + if (trimmed.length === 0) { + throw new Error('Postgres URL must be a non-empty string'); + } + return trimmed; +} + +/** + * Per-request Postgres facade for serverless / edge runtimes (Cloudflare Workers + Hyperdrive, + * AWS Lambda, Vercel, Deno Deploy, Bun edge, etc.). + * + * Construction shape mirrors the Node `postgres()` factory but the returned client deliberately + * omits `orm`, `runtime()`, and `transaction()`. Closure-cached convenience surfaces are unsafe + * across `fetch` invocations: stale connections after isolate idle, concurrent-query races on a + * shared `pg.Client`, no clean shutdown. Per-request callers acquire a fresh `Runtime` via + * `db.connect({ url })` and dispose it via `await using` on scope exit. + * + * @example + * ```ts + * const db = postgresServerless({ contractJson }); + * + * export default { + * async fetch(_req: Request, env: Env): Promise { + * await using runtime = await db.connect({ url: env.HYPERDRIVE.connectionString }); + * const rows = await runtime.execute(db.sql.from(t).select(...).build()); + * return Response.json(rows); + * }, + * }; + * ``` + */ +export default function postgresServerless>( + options: PostgresServerlessOptionsWithContract, +): PostgresServerlessClient; +export default function postgresServerless>( + options: PostgresServerlessOptionsWithContractJson, +): PostgresServerlessClient; +export default function postgresServerless>( + options: PostgresServerlessOptions, +): PostgresServerlessClient { + const contract = resolveContract(options); + const stack = createSqlExecutionStack({ + target: postgresTarget, + adapter: postgresAdapter, + driver: postgresDriver, + extensionPacks: options.extensions ?? [], + }); + + const context = createExecutionContext({ + contract, + stack, + }); + + const sql: Db = sqlBuilder({ context }); + + return { + sql, + context, + stack, + contract, + + async connect(binding) { + const url = validateConnectionString(binding.url); + + const driverDescriptor = stack.driver; + if (!driverDescriptor) { + throw new Error('Driver descriptor missing from execution stack'); + } + + const stackInstance = instantiateExecutionStack(stack); + const driver = driverDescriptor.create({ + ...ifDefined('cursor', options.cursor), + }); + + const client = new Client({ connectionString: url }); + await driver.connect({ kind: 'pgClient', client }); + + let runtime: Runtime; + try { + runtime = createRuntime({ + stackInstance, + context, + driver, + verify: options.verify ?? { mode: 'onFirstUse', requireMarker: false }, + ...ifDefined('middleware', options.middleware), + }); + } catch (err) { + // The driver is bound to the pg.Client at this point; without a runtime + // to wrap it, the caller has no handle to dispose. Close the driver so + // the underlying pg.Client is released even if its TCP socket has not + // yet opened (lazy connect): keeps cleanup symmetric with successful + // construction and prevents real socket leaks if pg ever changes its + // connect semantics. + await driver.close().catch(() => undefined); + throw err; + } + + Object.defineProperty(runtime, Symbol.asyncDispose, { + value: () => runtime.close(), + configurable: true, + writable: false, + enumerable: false, + }); + + return runtime as Runtime & AsyncDisposable; + }, + }; +} diff --git a/packages/3-extensions/postgres/test/postgres-serverless.test.ts b/packages/3-extensions/postgres/test/postgres-serverless.test.ts new file mode 100644 index 0000000000..dbe7c89f0a --- /dev/null +++ b/packages/3-extensions/postgres/test/postgres-serverless.test.ts @@ -0,0 +1,327 @@ +import { createContract } from '@prisma-next/contract/testing'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import type { SqlMiddleware, SqlRuntimeExtensionDescriptor } from '@prisma-next/sql-runtime'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + instantiateExecutionStack: vi.fn(), + createRuntime: vi.fn(), + createExecutionContext: vi.fn(), + createSqlExecutionStack: vi.fn(), + sqlBuilder: vi.fn(), + driverCreate: vi.fn(), + driverConnect: vi.fn(), + driverClose: vi.fn(), + validateContract: vi.fn(), + poolCtor: vi.fn(), + clientCtor: vi.fn(), + runtimeClose: vi.fn(), +})); + +vi.mock('@prisma-next/framework-components/execution', () => ({ + instantiateExecutionStack: mocks.instantiateExecutionStack, +})); + +vi.mock('@prisma-next/sql-runtime', () => ({ + createExecutionContext: mocks.createExecutionContext, + createSqlExecutionStack: mocks.createSqlExecutionStack, + createRuntime: mocks.createRuntime, +})); + +vi.mock('@prisma-next/sql-contract/validate', () => ({ + validateContract: mocks.validateContract, +})); + +vi.mock('@prisma-next/sql-builder/runtime', () => ({ + sql: mocks.sqlBuilder, +})); + +vi.mock('@prisma-next/target-postgres/runtime', () => ({ + default: { id: 'target-postgres' }, +})); + +vi.mock('@prisma-next/adapter-postgres/runtime', () => ({ + default: { id: 'adapter-postgres' }, +})); + +vi.mock('@prisma-next/driver-postgres/runtime', () => ({ + default: { id: 'driver-postgres' }, +})); + +vi.mock('pg', () => { + class Pool { + constructor(options: unknown) { + mocks.poolCtor(options); + } + } + + class Client { + constructor(options: unknown) { + mocks.clientCtor(options); + } + } + + return { Pool, Client }; +}); + +import { Client } from 'pg'; +import postgresServerless from '../src/runtime/postgres-serverless'; + +const contract = createContract(); + +describe('postgresServerless', () => { + beforeEach(() => { + mocks.instantiateExecutionStack.mockReset(); + mocks.createRuntime.mockReset(); + mocks.createExecutionContext.mockReset(); + mocks.createSqlExecutionStack.mockReset(); + mocks.driverCreate.mockReset(); + mocks.driverConnect.mockReset(); + mocks.driverClose.mockReset(); + mocks.validateContract.mockReset(); + mocks.poolCtor.mockReset(); + mocks.clientCtor.mockReset(); + mocks.sqlBuilder.mockReset(); + mocks.runtimeClose.mockReset(); + + mocks.createExecutionContext.mockReturnValue({ + contract, + codecs: {}, + queryOperations: { entries: () => ({}) }, + types: {}, + }); + mocks.createSqlExecutionStack.mockReturnValue({ + target: { id: 'target-postgres' }, + adapter: { id: 'adapter-postgres' }, + driver: { create: mocks.driverCreate }, + extensionPacks: [], + }); + mocks.instantiateExecutionStack.mockReturnValue({ adapter: {} }); + mocks.driverConnect.mockResolvedValue(undefined); + mocks.driverClose.mockResolvedValue(undefined); + mocks.driverCreate.mockReturnValue({ + id: 'driver-instance', + connect: mocks.driverConnect, + close: mocks.driverClose, + }); + mocks.runtimeClose.mockResolvedValue(undefined); + mocks.createRuntime.mockImplementation(() => ({ + id: 'runtime-instance', + close: mocks.runtimeClose, + })); + mocks.validateContract.mockReturnValue(contract); + mocks.sqlBuilder.mockReturnValue({ lane: 'sql' }); + }); + + it('exposes only the static authoring surface synchronously', () => { + const db = postgresServerless({ contract }); + + expect(mocks.sqlBuilder).toHaveBeenCalledTimes(1); + expect(db.sql).toEqual({ lane: 'sql' }); + expect(db.context).toBeDefined(); + expect(db.stack).toBeDefined(); + expect(db.contract).toBe(contract); + expect(typeof db.connect).toBe('function'); + }); + + it('does not expose orm/runtime/transaction at runtime', () => { + const db = postgresServerless({ contract }); + // Probe runtime keys the typed surface intentionally hides so the negative + // assertion can index them by string without tripping the type checker. + const indexable = db as unknown as Record; + expect(indexable['orm']).toBeUndefined(); + expect(indexable['runtime']).toBeUndefined(); + expect(indexable['transaction']).toBeUndefined(); + }); + + it('does not allocate runtime resources at construction time', () => { + postgresServerless({ contract }); + + expect(mocks.instantiateExecutionStack).not.toHaveBeenCalled(); + expect(mocks.createRuntime).not.toHaveBeenCalled(); + expect(mocks.driverCreate).not.toHaveBeenCalled(); + expect(mocks.clientCtor).not.toHaveBeenCalled(); + expect(mocks.poolCtor).not.toHaveBeenCalled(); + }); + + it('connect() constructs pg.Client exactly once with the given URL and routes through pgClient binding', async () => { + const db = postgresServerless({ contract }); + const url = 'postgres://localhost:5432/db'; + + const runtime = await db.connect({ url }); + + expect(mocks.clientCtor).toHaveBeenCalledTimes(1); + expect(mocks.clientCtor).toHaveBeenCalledWith({ connectionString: url }); + expect(mocks.poolCtor).not.toHaveBeenCalled(); + expect(mocks.instantiateExecutionStack).toHaveBeenCalledTimes(1); + expect(mocks.driverCreate).toHaveBeenCalledTimes(1); + expect(mocks.driverConnect).toHaveBeenCalledTimes(1); + expect(mocks.driverConnect).toHaveBeenCalledWith({ + kind: 'pgClient', + client: expect.any(Client), + }); + expect(mocks.createRuntime).toHaveBeenCalledTimes(1); + expect(runtime).toBeDefined(); + }); + + it('connect() defaults cursor option to enabled (no cursor: { disabled: true })', async () => { + const db = postgresServerless({ contract }); + + await db.connect({ url: 'postgres://localhost:5432/db' }); + + expect(mocks.driverCreate).toHaveBeenCalledTimes(1); + expect(mocks.driverCreate).toHaveBeenCalledWith({}); + }); + + it('connect() forwards cursor option when provided', async () => { + const db = postgresServerless({ + contract, + cursor: { disabled: true, batchSize: 25 }, + }); + + await db.connect({ url: 'postgres://localhost:5432/db' }); + + expect(mocks.driverCreate).toHaveBeenCalledTimes(1); + expect(mocks.driverCreate).toHaveBeenCalledWith({ + cursor: { disabled: true, batchSize: 25 }, + }); + }); + + it('returns distinct Runtime instances for each connect() call (no closure cache)', async () => { + const runtimes = [ + { id: 'runtime-1', close: mocks.runtimeClose }, + { id: 'runtime-2', close: mocks.runtimeClose }, + ]; + let call = 0; + mocks.createRuntime.mockImplementation(() => { + const r = runtimes[call]; + call++; + if (!r) throw new Error('unexpected createRuntime call'); + return r; + }); + + const db = postgresServerless({ contract }); + const first = await db.connect({ url: 'postgres://localhost:5432/db' }); + const second = await db.connect({ url: 'postgres://localhost:5432/db' }); + + expect(first).not.toBe(second); + expect(mocks.clientCtor).toHaveBeenCalledTimes(2); + expect(mocks.driverCreate).toHaveBeenCalledTimes(2); + expect(mocks.driverConnect).toHaveBeenCalledTimes(2); + expect(mocks.createRuntime).toHaveBeenCalledTimes(2); + }); + + it('closes the driver if createRuntime throws after connect() resolved', async () => { + const failure = new Error('createRuntime boom'); + mocks.createRuntime.mockImplementation(() => { + throw failure; + }); + + const db = postgresServerless({ contract }); + + await expect(db.connect({ url: 'postgres://localhost:5432/db' })).rejects.toBe(failure); + + expect(mocks.driverConnect).toHaveBeenCalledTimes(1); + expect(mocks.driverClose).toHaveBeenCalledTimes(1); + }); + + it('rethrows the original error even when driver.close itself fails during cleanup', async () => { + const failure = new Error('createRuntime boom'); + mocks.createRuntime.mockImplementation(() => { + throw failure; + }); + mocks.driverClose.mockRejectedValue(new Error('close boom')); + + const db = postgresServerless({ contract }); + + await expect(db.connect({ url: 'postgres://localhost:5432/db' })).rejects.toBe(failure); + expect(mocks.driverClose).toHaveBeenCalledTimes(1); + }); + + it('returned runtime is AsyncDisposable and disposes via close()', async () => { + const db = postgresServerless({ contract }); + + { + await using runtime = await db.connect({ url: 'postgres://localhost:5432/db' }); + expect(runtime).toBeDefined(); + expect(mocks.runtimeClose).not.toHaveBeenCalled(); + } + + expect(mocks.runtimeClose).toHaveBeenCalledTimes(1); + }); + + it('explicit Symbol.asyncDispose invocation calls runtime.close exactly once', async () => { + const db = postgresServerless({ contract }); + const runtime = await db.connect({ url: 'postgres://localhost:5432/db' }); + + await runtime[Symbol.asyncDispose](); + + expect(mocks.runtimeClose).toHaveBeenCalledTimes(1); + }); + + it('does not construct pg.Pool over a full connect+dispose lifecycle', async () => { + const db = postgresServerless({ contract }); + + { + await using _runtime = await db.connect({ url: 'postgres://localhost:5432/db' }); + } + + expect(mocks.poolCtor).not.toHaveBeenCalled(); + }); + + it('forwards extensions and middleware to the execution stack and runtime', async () => { + // The mocked stack/runtime never invokes these descriptors, so opaque marker + // objects are sufficient to assert pass-through. Cast keeps the test focused + // on wiring without manufacturing a full descriptor/middleware shape. + const extension = { id: 'ext-pack' } as unknown as SqlRuntimeExtensionDescriptor<'postgres'>; + const middleware = [{ id: 'mw-1' } as unknown as SqlMiddleware]; + const db = postgresServerless({ + contract, + extensions: [extension], + middleware, + }); + + await db.connect({ url: 'postgres://localhost:5432/db' }); + + expect(mocks.createSqlExecutionStack).toHaveBeenCalledWith( + expect.objectContaining({ extensionPacks: [extension] }), + ); + expect(mocks.createRuntime).toHaveBeenCalledWith(expect.objectContaining({ middleware })); + }); + + it('forwards verify option to createRuntime', async () => { + const verify = { mode: 'always', requireMarker: true } as const; + const db = postgresServerless({ contract, verify }); + + await db.connect({ url: 'postgres://localhost:5432/db' }); + + expect(mocks.createRuntime).toHaveBeenCalledWith(expect.objectContaining({ verify })); + }); + + it('defaults verify to onFirstUse without requireMarker', async () => { + const db = postgresServerless({ contract }); + + await db.connect({ url: 'postgres://localhost:5432/db' }); + + expect(mocks.createRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + verify: { mode: 'onFirstUse', requireMarker: false }, + }), + ); + }); + + it('validates contractJson input', () => { + const contractJson = { models: {} }; + postgresServerless({ contractJson }); + + expect(mocks.validateContract).toHaveBeenCalledTimes(1); + expect(mocks.validateContract).toHaveBeenCalledWith(contractJson, expect.anything()); + }); + + it('validates direct contract input', () => { + postgresServerless({ contract }); + + expect(mocks.validateContract).toHaveBeenCalledTimes(1); + expect(mocks.validateContract).toHaveBeenCalledWith(contract, expect.anything()); + }); +}); diff --git a/packages/3-extensions/postgres/test/postgres-serverless.types.test-d.ts b/packages/3-extensions/postgres/test/postgres-serverless.types.test-d.ts new file mode 100644 index 0000000000..fee886482b --- /dev/null +++ b/packages/3-extensions/postgres/test/postgres-serverless.types.test-d.ts @@ -0,0 +1,84 @@ +import type { Contract } from '@prisma-next/contract/types'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import type { Runtime } from '@prisma-next/sql-runtime'; +import { expectTypeOf, test } from 'vitest'; +import type { + PostgresOptionsWithContract, + PostgresOptionsWithContractJson, +} from '../src/runtime/postgres'; +import type postgresServerless from '../src/runtime/postgres-serverless'; +import type { PostgresServerlessClient } from '../src/runtime/postgres-serverless'; + +type TestContract = Contract; +type Db = PostgresServerlessClient; + +test('exposes only the static authoring surface plus connect()', () => { + type Keys = keyof Db; + expectTypeOf().toEqualTypeOf<'sql' | 'context' | 'stack' | 'contract' | 'connect'>(); +}); + +test('does not expose orm', () => { + type HasOrm = 'orm' extends keyof Db ? true : false; + expectTypeOf().toEqualTypeOf(); + + const db = {} as Db; + // @ts-expect-error db.orm is intentionally absent on the serverless facade + void db.orm; +}); + +test('does not expose runtime() helper', () => { + type HasRuntime = 'runtime' extends keyof Db ? true : false; + expectTypeOf().toEqualTypeOf(); + + const db = {} as Db; + // @ts-expect-error db.runtime is intentionally absent on the serverless facade + void db.runtime; +}); + +test('does not expose transaction()', () => { + type HasTransaction = 'transaction' extends keyof Db ? true : false; + expectTypeOf().toEqualTypeOf(); + + const db = {} as Db; + // @ts-expect-error db.transaction is intentionally absent on the serverless facade + void db.transaction; +}); + +test('connect() returns Promise', () => { + const db = {} as Db; + expectTypeOf(db.connect).parameter(0).toEqualTypeOf<{ readonly url: string }>(); + expectTypeOf>>().toMatchTypeOf(); + expectTypeOf>>().toMatchTypeOf(); +}); + +test('connect() rejects bindings other than { url }', () => { + const db = {} as Db; + // @ts-expect-error binding is restricted to { url }; pg/binding shapes are not accepted + void db.connect({ pg: {} as unknown }); + // @ts-expect-error binding is restricted to { url }; binding shape is not accepted + void db.connect({ binding: { kind: 'url', url: 'x' } }); +}); + +test('factory accepts the same option keys as the Node postgres() factory', async () => { + const { default: postgres } = await import('../src/runtime/postgres'); + type NodeOptionKeys = keyof Pick< + PostgresOptionsWithContract, + 'contract' | 'extensions' | 'middleware' | 'verify' + >; + type ServerlessOptionKeys = Parameters>[0] extends infer O + ? Extract + : never; + expectTypeOf().toEqualTypeOf(); + + type NodeJsonKeys = keyof Pick< + PostgresOptionsWithContractJson, + 'contractJson' | 'extensions' | 'middleware' | 'verify' + >; + type ServerlessJsonKeys = Parameters>[0] extends infer O + ? Extract + : never; + expectTypeOf().toEqualTypeOf(); + + // postgres() also accepts these but the unrelated `postgres()` ensures the symbol is referenced + void postgres; +}); diff --git a/packages/3-extensions/postgres/tsconfig.json b/packages/3-extensions/postgres/tsconfig.json index 7afa587436..22b469d057 100644 --- a/packages/3-extensions/postgres/tsconfig.json +++ b/packages/3-extensions/postgres/tsconfig.json @@ -2,7 +2,8 @@ "extends": ["@prisma-next/tsconfig/base"], "compilerOptions": { "rootDir": ".", - "outDir": "dist" + "outDir": "dist", + "lib": ["ES2022", "ESNext.Disposable"] }, "include": ["src/**/*.ts", "test/**/*.ts"], "exclude": ["dist"] diff --git a/packages/3-extensions/postgres/tsdown.config.ts b/packages/3-extensions/postgres/tsdown.config.ts index bcc947162e..ac9c6df863 100644 --- a/packages/3-extensions/postgres/tsdown.config.ts +++ b/packages/3-extensions/postgres/tsdown.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from '@prisma-next/tsdown'; export default defineConfig({ - entry: ['src/exports/config.ts', 'src/exports/runtime.ts'], + entry: ['src/exports/config.ts', 'src/exports/runtime.ts', 'src/exports/serverless.ts'], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b7ff34c39..3dc9f3d7c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,6 +186,91 @@ importers: specifier: 'catalog:' version: 4.0.17(@types/node@24.10.4)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1) + examples/prisma-next-cloudflare-worker: + dependencies: + '@prisma-next/adapter-postgres': + specifier: workspace:* + version: link:../../packages/3-targets/6-adapters/postgres + '@prisma-next/contract': + specifier: workspace:* + version: link:../../packages/1-framework/0-foundation/contract + '@prisma-next/driver-postgres': + specifier: workspace:* + version: link:../../packages/3-targets/7-drivers/postgres + '@prisma-next/family-sql': + specifier: workspace:* + version: link:../../packages/2-sql/9-family + '@prisma-next/middleware-telemetry': + specifier: workspace:* + version: link:../../packages/3-extensions/middleware-telemetry + '@prisma-next/postgres': + specifier: workspace:* + version: link:../../packages/3-extensions/postgres + '@prisma-next/sql-builder': + specifier: workspace:* + version: link:../../packages/2-sql/4-lanes/sql-builder + '@prisma-next/sql-contract': + specifier: workspace:* + version: link:../../packages/2-sql/1-core/contract + '@prisma-next/sql-orm-client': + specifier: workspace:* + version: link:../../packages/3-extensions/sql-orm-client + '@prisma-next/sql-relational-core': + specifier: workspace:* + version: link:../../packages/2-sql/4-lanes/relational-core + '@prisma-next/sql-runtime': + specifier: workspace:* + version: link:../../packages/2-sql/5-runtime + '@prisma-next/target-postgres': + specifier: workspace:* + version: link:../../packages/3-targets/3-targets/postgres + arktype: + specifier: 'catalog:' + version: 2.1.29 + pg: + specifier: 'catalog:' + version: 8.16.3 + devDependencies: + '@cloudflare/vitest-pool-workers': + specifier: 0.15.2 + version: 0.15.2(@cloudflare/workers-types@4.20260430.1)(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@types/node@24.10.4)(@vitest/coverage-v8@4.0.17(vitest@4.0.17))(@vitest/ui@4.0.17(vitest@4.0.17))(jsdom@28.1.0(@noble/hashes@2.0.1))(vite@8.0.9(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.1))) + '@cloudflare/workers-types': + specifier: 4.20260430.1 + version: 4.20260430.1 + '@prisma-next/cli': + specifier: workspace:* + version: link:../../packages/1-framework/3-tooling/cli + '@prisma-next/sql-contract-psl': + specifier: workspace:* + version: link:../../packages/2-sql/2-authoring/contract-psl + '@prisma-next/test-utils': + specifier: workspace:* + version: link:../../test/utils + '@prisma-next/tsconfig': + specifier: workspace:* + version: link:../../packages/0-config/tsconfig + '@types/node': + specifier: 'catalog:' + version: 24.10.4 + '@types/pg': + specifier: 'catalog:' + version: 8.16.0 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + tsx: + specifier: ^4.19.2 + version: 4.20.6 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.5(@types/node@24.10.4)(@vitest/coverage-v8@4.0.17(vitest@4.0.17))(@vitest/ui@4.0.17(vitest@4.0.17))(jsdom@28.1.0(@noble/hashes@2.0.1))(vite@8.0.9(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.1)) + wrangler: + specifier: 4.87.0 + version: 4.87.0(@cloudflare/workers-types@4.20260430.1) + examples/prisma-next-demo: dependencies: '@prisma-next/adapter-postgres': @@ -472,7 +557,7 @@ importers: version: 0.19.1(typescript@5.9.3) '@react-router/dev': specifier: ^7.14.0 - version: 7.14.2(@react-router/serve@7.14.2(react-router@7.14.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(react-router@7.14.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(tsx@4.20.6)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1))(yaml@2.8.1) + version: 7.14.2(@react-router/serve@7.14.2(react-router@7.14.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(react-router@7.14.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(tsx@4.20.6)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1))(wrangler@4.87.0(@cloudflare/workers-types@4.20260430.1))(yaml@2.8.1) '@types/node': specifier: 'catalog:' version: 24.10.4 @@ -3547,7 +3632,7 @@ importers: version: vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1) vite8: specifier: npm:vite@8.0.9 - version: vite@8.0.9(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.1) + version: vite@8.0.9(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.1) vitest: specifier: 'catalog:' version: 4.0.17(@types/node@24.10.4)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1) @@ -3977,6 +4062,63 @@ packages: '@clack/prompts@1.1.0': resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==} + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/vitest-pool-workers@0.15.2': + resolution: {integrity: sha512-zQ6H1sEIhApOJm08EuKUmRvpxiiploPnNmx4R+X8Slp76MRXyaNxYkA9TW/r0fiDu98SWqJwACkuHh3nKZvfUQ==} + peerDependencies: + '@vitest/runner': ^4.1.0 + '@vitest/snapshot': ^4.1.0 + vitest: ^4.1.0 + + '@cloudflare/workerd-darwin-64@1.20260430.1': + resolution: {integrity: sha512-ADohZUHf7NBvPp2PdZig2Opxx+hDkk3ve7jrTne3JRx9kDSB73zc4LzcEeEN8LKkbAcqZmvfRJfpChSlusu0lA==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260430.1': + resolution: {integrity: sha512-/DoYC/1wHs+YRZzzqSQg1/EHB4hiv1yV5U8FnmapRRIzVaPtnt+ApeOXeMrIdKidgKOI8TqQzgBU8xbIM7Cl4Q==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260430.1': + resolution: {integrity: sha512-koJhBWvEVZPKCVFtMLp2iMHlYr+lFCF47wGbnlKdHVlemV0zTxJEyHI8aLlrhPLhBmOmYLp46rXw09/qJkRIhQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260430.1': + resolution: {integrity: sha512-hMdapNAzNQZDXGGkg4Slydc3fRJP5FUZLJVVcZCW/+imhhJro9Z1rv5n/wfR+txKoSWhTYR8eOp8Pyi2bzLzlw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260430.1': + resolution: {integrity: sha512-jS3ffixjb5USOwz4frw4WzCz0HrjVxkgyU3WiYb06N7hBAfN6eOrveAJ4QRef0+suK4V1vQFoB1oKdRBsXe9Dw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@4.20260430.1': + resolution: {integrity: sha512-Rguf/SdQNm1HG2yZi8m57K4qvVh2fic+Xny4UidY1pCgHagOAq7Cy0WIp1JKQx54T8lgOgOWFzIaCK4iwLpxlg==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@csstools/color-helpers@6.0.2': resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} engines: {node: '>=20.19.0'} @@ -4052,6 +4194,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.11': resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==} engines: {node: '>=18'} @@ -4064,6 +4212,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.11': resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==} engines: {node: '>=18'} @@ -4076,6 +4230,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.11': resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==} engines: {node: '>=18'} @@ -4088,6 +4248,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.11': resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==} engines: {node: '>=18'} @@ -4100,6 +4266,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.11': resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==} engines: {node: '>=18'} @@ -4112,6 +4284,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.11': resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==} engines: {node: '>=18'} @@ -4124,6 +4302,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.11': resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==} engines: {node: '>=18'} @@ -4136,6 +4320,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.11': resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==} engines: {node: '>=18'} @@ -4148,6 +4338,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.11': resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==} engines: {node: '>=18'} @@ -4160,6 +4356,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.11': resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==} engines: {node: '>=18'} @@ -4172,6 +4374,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.11': resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==} engines: {node: '>=18'} @@ -4184,6 +4392,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.11': resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==} engines: {node: '>=18'} @@ -4196,6 +4410,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.11': resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==} engines: {node: '>=18'} @@ -4208,6 +4428,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.11': resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==} engines: {node: '>=18'} @@ -4220,6 +4446,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.11': resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==} engines: {node: '>=18'} @@ -4232,6 +4464,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.11': resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==} engines: {node: '>=18'} @@ -4244,6 +4482,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.11': resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==} engines: {node: '>=18'} @@ -4256,6 +4500,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.11': resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==} engines: {node: '>=18'} @@ -4268,6 +4518,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.11': resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==} engines: {node: '>=18'} @@ -4280,6 +4536,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.11': resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==} engines: {node: '>=18'} @@ -4292,6 +4554,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.11': resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==} engines: {node: '>=18'} @@ -4304,6 +4572,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.11': resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==} engines: {node: '>=18'} @@ -4316,6 +4590,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.11': resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==} engines: {node: '>=18'} @@ -4328,6 +4608,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.11': resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==} engines: {node: '>=18'} @@ -4340,6 +4626,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.11': resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==} engines: {node: '>=18'} @@ -4352,6 +4644,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@exodus/bytes@1.15.0': resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -4551,6 +4849,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@mjackson/node-fetch-server@0.2.0': resolution: {integrity: sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==} @@ -4638,6 +4939,15 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@prisma/debug@7.1.0': resolution: {integrity: sha512-pPAckG6etgAsEBusmZiFwM9bldLSNkn++YuC4jCTJACdK5hLOVnOzX7eSL2FgaU6Gomd6wIw21snUX2dYroMZQ==} @@ -5480,6 +5790,13 @@ packages: cpu: [x64] os: [win32] + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.15': + resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -5736,6 +6053,9 @@ packages: '@vitest/expect@4.0.17': resolution: {integrity: sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==} + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + '@vitest/mocker@4.0.17': resolution: {integrity: sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==} peerDependencies: @@ -5747,18 +6067,41 @@ packages: vite: optional: true + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@4.0.17': resolution: {integrity: sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==} + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + '@vitest/runner@4.0.17': resolution: {integrity: sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==} + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + '@vitest/snapshot@4.0.17': resolution: {integrity: sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==} + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + '@vitest/spy@4.0.17': resolution: {integrity: sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==} + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + '@vitest/ui@4.0.17': resolution: {integrity: sha512-hRDjg6dlDz7JlZAvjbiCdAJ3SDG+NH8tjZe21vjxfvT2ssYAn72SRXMge3dKKABm3bIJ3C+3wdunIdur8PHEAw==} peerDependencies: @@ -5767,6 +6110,9 @@ packages: '@vitest/utils@4.0.17': resolution: {integrity: sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==} + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -5934,6 +6280,9 @@ packages: birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + body-parser@1.20.5: resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -6010,6 +6359,9 @@ packages: citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -6248,6 +6600,9 @@ packages: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -6259,6 +6614,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -6273,6 +6631,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -6642,6 +7005,10 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + kysely@0.28.10: resolution: {integrity: sha512-ksNxfzIW77OcZ+QWSAPC7yDqUSaIVwkTWnTPNiIy//vifNbwsSgQ57OkkncHxxpcBHM3LRfLAZVEh7kjq5twVA==} engines: {node: '>=20.0.0'} @@ -6832,6 +7199,11 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + miniflare@4.20260430.0: + resolution: {integrity: sha512-MWvMm3Siho9Yj7lbJZidLs8hbrRvIcOrif2mnsHQZdvoKfedpea+GaN8XJxbpRcq0B2WzNI1BB1ihdnqes3/ZA==} + engines: {node: '>=22.0.0'} + hasBin: true + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -7024,6 +7396,9 @@ packages: path-to-regexp@0.1.13: resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -7495,6 +7870,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + streamx@2.25.0: resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} @@ -7539,6 +7917,10 @@ packages: babel-plugin-macros: optional: true + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -7591,6 +7973,10 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tldts-core@7.0.25: resolution: {integrity: sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==} @@ -7740,6 +8126,13 @@ packages: resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} engines: {node: '>=20.18.1'} + undici@7.24.8: + resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + uniku@0.0.12: resolution: {integrity: sha512-wqt0D/ZcBTDprQxlFpxDm4jCHlQyHWQ62PMXPF0AfU7oxHm4g5++7EY7/+uwApCMOhfzRE8fTf4WATe6lH1vkg==} engines: {node: '>=24.13.0'} @@ -7930,6 +8323,47 @@ packages: jsdom: optional: true + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -7969,6 +8403,21 @@ packages: engines: {node: '>=8'} hasBin: true + workerd@1.20260430.1: + resolution: {integrity: sha512-KEgIWyiw3Jmn+DCd/L3ePo5fmiiYb/UcwKvDWPf/nLLOiwShDFzDSsegU5NY/JcwgvO/QsLHVi2FYrbkcXNY5Q==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.87.0: + resolution: {integrity: sha512-lfhfKwLfQlowwgV0xhlYgE9fU3n0I30d4ccGY/rTCEm/n42Mjvlr0Ng3ZPNqlsrsKBcDR531V7dsPkgELvrk/Q==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20260430.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrap-ansi@10.0.0: resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} engines: {node: '>=20'} @@ -7977,6 +8426,18 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -8003,9 +8464,18 @@ packages: resolution: {integrity: sha512-k1isifdbpNSFEHFJ1ZY4YDewv0IH9FR61lDetaRMD3j2ae3bIXGV+7c+LHCqtQGofSd8PIyV4X6+dHMAnSr60A==} engines: {node: '>=12'} + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + zeptomatch@2.1.0: resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + snapshots: '@acemir/cssom@0.9.31': {} @@ -8308,6 +8778,50 @@ snapshots: '@clack/core': 1.1.0 sisteransi: 1.0.5 + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260430.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260430.1 + + '@cloudflare/vitest-pool-workers@0.15.2(@cloudflare/workers-types@4.20260430.1)(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@types/node@24.10.4)(@vitest/coverage-v8@4.0.17(vitest@4.0.17))(@vitest/ui@4.0.17(vitest@4.0.17))(jsdom@28.1.0(@noble/hashes@2.0.1))(vite@8.0.9(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.1)))': + dependencies: + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + cjs-module-lexer: 1.4.3 + esbuild: 0.27.3 + miniflare: 4.20260430.0 + vitest: 4.1.5(@types/node@24.10.4)(@vitest/coverage-v8@4.0.17(vitest@4.0.17))(@vitest/ui@4.0.17(vitest@4.0.17))(jsdom@28.1.0(@noble/hashes@2.0.1))(vite@8.0.9(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.1)) + wrangler: 4.87.0(@cloudflare/workers-types@4.20260430.1) + zod: 3.25.76 + transitivePeerDependencies: + - '@cloudflare/workers-types' + - bufferutil + - utf-8-validate + + '@cloudflare/workerd-darwin-64@1.20260430.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260430.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260430.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260430.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260430.1': + optional: true + + '@cloudflare/workers-types@4.20260430.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@6.0.2': {} '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -8373,156 +8887,234 @@ snapshots: '@esbuild/aix-ppc64@0.27.2': optional: true + '@esbuild/aix-ppc64@0.27.3': + optional: true + '@esbuild/android-arm64@0.25.11': optional: true '@esbuild/android-arm64@0.27.2': optional: true + '@esbuild/android-arm64@0.27.3': + optional: true + '@esbuild/android-arm@0.25.11': optional: true '@esbuild/android-arm@0.27.2': optional: true + '@esbuild/android-arm@0.27.3': + optional: true + '@esbuild/android-x64@0.25.11': optional: true '@esbuild/android-x64@0.27.2': optional: true + '@esbuild/android-x64@0.27.3': + optional: true + '@esbuild/darwin-arm64@0.25.11': optional: true '@esbuild/darwin-arm64@0.27.2': optional: true + '@esbuild/darwin-arm64@0.27.3': + optional: true + '@esbuild/darwin-x64@0.25.11': optional: true '@esbuild/darwin-x64@0.27.2': optional: true + '@esbuild/darwin-x64@0.27.3': + optional: true + '@esbuild/freebsd-arm64@0.25.11': optional: true '@esbuild/freebsd-arm64@0.27.2': optional: true + '@esbuild/freebsd-arm64@0.27.3': + optional: true + '@esbuild/freebsd-x64@0.25.11': optional: true '@esbuild/freebsd-x64@0.27.2': optional: true + '@esbuild/freebsd-x64@0.27.3': + optional: true + '@esbuild/linux-arm64@0.25.11': optional: true '@esbuild/linux-arm64@0.27.2': optional: true + '@esbuild/linux-arm64@0.27.3': + optional: true + '@esbuild/linux-arm@0.25.11': optional: true '@esbuild/linux-arm@0.27.2': optional: true + '@esbuild/linux-arm@0.27.3': + optional: true + '@esbuild/linux-ia32@0.25.11': optional: true '@esbuild/linux-ia32@0.27.2': optional: true + '@esbuild/linux-ia32@0.27.3': + optional: true + '@esbuild/linux-loong64@0.25.11': optional: true '@esbuild/linux-loong64@0.27.2': optional: true + '@esbuild/linux-loong64@0.27.3': + optional: true + '@esbuild/linux-mips64el@0.25.11': optional: true '@esbuild/linux-mips64el@0.27.2': optional: true + '@esbuild/linux-mips64el@0.27.3': + optional: true + '@esbuild/linux-ppc64@0.25.11': optional: true '@esbuild/linux-ppc64@0.27.2': optional: true + '@esbuild/linux-ppc64@0.27.3': + optional: true + '@esbuild/linux-riscv64@0.25.11': optional: true '@esbuild/linux-riscv64@0.27.2': optional: true + '@esbuild/linux-riscv64@0.27.3': + optional: true + '@esbuild/linux-s390x@0.25.11': optional: true '@esbuild/linux-s390x@0.27.2': optional: true + '@esbuild/linux-s390x@0.27.3': + optional: true + '@esbuild/linux-x64@0.25.11': optional: true '@esbuild/linux-x64@0.27.2': optional: true + '@esbuild/linux-x64@0.27.3': + optional: true + '@esbuild/netbsd-arm64@0.25.11': optional: true '@esbuild/netbsd-arm64@0.27.2': optional: true + '@esbuild/netbsd-arm64@0.27.3': + optional: true + '@esbuild/netbsd-x64@0.25.11': optional: true '@esbuild/netbsd-x64@0.27.2': optional: true + '@esbuild/netbsd-x64@0.27.3': + optional: true + '@esbuild/openbsd-arm64@0.25.11': optional: true '@esbuild/openbsd-arm64@0.27.2': optional: true + '@esbuild/openbsd-arm64@0.27.3': + optional: true + '@esbuild/openbsd-x64@0.25.11': optional: true '@esbuild/openbsd-x64@0.27.2': optional: true + '@esbuild/openbsd-x64@0.27.3': + optional: true + '@esbuild/openharmony-arm64@0.25.11': optional: true '@esbuild/openharmony-arm64@0.27.2': optional: true + '@esbuild/openharmony-arm64@0.27.3': + optional: true + '@esbuild/sunos-x64@0.25.11': optional: true '@esbuild/sunos-x64@0.27.2': optional: true + '@esbuild/sunos-x64@0.27.3': + optional: true + '@esbuild/win32-arm64@0.25.11': optional: true '@esbuild/win32-arm64@0.27.2': optional: true + '@esbuild/win32-arm64@0.27.3': + optional: true + '@esbuild/win32-ia32@0.25.11': optional: true '@esbuild/win32-ia32@0.27.2': optional: true + '@esbuild/win32-ia32@0.27.3': + optional: true + '@esbuild/win32-x64@0.25.11': optional: true '@esbuild/win32-x64@0.27.2': optional: true + '@esbuild/win32-x64@0.27.3': + optional: true + '@exodus/bytes@1.15.0(@noble/hashes@2.0.1)': optionalDependencies: '@noble/hashes': 2.0.1 @@ -8548,8 +9140,7 @@ snapshots: dependencies: hono: 4.11.4 - '@img/colour@1.1.0': - optional: true + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: @@ -8664,6 +9255,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@mjackson/node-fetch-server@0.2.0': {} '@mongodb-js/saslprep@1.4.6': @@ -8719,6 +9315,18 @@ snapshots: '@polka/url@1.0.0-next.29': optional: true + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + '@prisma/debug@7.1.0': {} '@prisma/dev@0.19.1(typescript@5.9.3)': @@ -9091,7 +9699,7 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@react-router/dev@7.14.2(@react-router/serve@7.14.2(react-router@7.14.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(react-router@7.14.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(tsx@4.20.6)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1))(yaml@2.8.1)': + '@react-router/dev@7.14.2(@react-router/serve@7.14.2(react-router@7.14.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(react-router@7.14.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(tsx@4.20.6)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1))(wrangler@4.87.0(@cloudflare/workers-types@4.20260430.1))(yaml@2.8.1)': dependencies: '@babel/core': 7.29.0 '@babel/generator': 7.28.5 @@ -9126,6 +9734,7 @@ snapshots: optionalDependencies: '@react-router/serve': 7.14.2(react-router@7.14.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) typescript: 5.9.3 + wrangler: 4.87.0(@cloudflare/workers-types@4.20260430.1) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -9393,6 +10002,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.15': {} + '@standard-schema/spec@1.1.0': {} '@swc/core-darwin-arm64@1.15.18': @@ -9624,7 +10237,16 @@ snapshots: '@vitest/spy': 4.0.17 '@vitest/utils': 4.0.17 chai: 6.2.2 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 + + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 '@vitest/mocker@4.0.17(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: @@ -9634,23 +10256,49 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1) + '@vitest/mocker@4.1.5(vite@8.0.9(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.9(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.1) + '@vitest/pretty-format@4.0.17': dependencies: - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 '@vitest/runner@4.0.17': dependencies: '@vitest/utils': 4.0.17 pathe: 2.0.3 + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + '@vitest/snapshot@4.0.17': dependencies: '@vitest/pretty-format': 4.0.17 magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@4.0.17': {} + '@vitest/spy@4.1.5': {} + '@vitest/ui@4.0.17(vitest@4.0.17)': dependencies: '@vitest/utils': 4.0.17 @@ -9659,7 +10307,7 @@ snapshots: pathe: 2.0.3 sirv: 3.0.2 tinyglobby: 0.2.16 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 vitest: 4.0.17(@types/node@24.10.4)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1) optional: true @@ -9668,6 +10316,12 @@ snapshots: '@vitest/pretty-format': 4.0.17 tinyrainbow: 3.0.3 + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -9748,7 +10402,7 @@ snapshots: ast-kit@2.2.0: dependencies: - '@babel/parser': 7.28.6 + '@babel/parser': 7.29.2 pathe: 2.0.3 ast-v8-to-istanbul@0.3.10: @@ -9816,6 +10470,8 @@ snapshots: birpc@4.0.0: {} + blake3-wasm@2.1.5: {} + body-parser@1.20.5: dependencies: bytes: 3.1.2 @@ -9917,6 +10573,8 @@ snapshots: dependencies: consola: 3.4.2 + cjs-module-lexer@1.4.3: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -10120,12 +10778,16 @@ snapshots: environment@1.1.0: {} + error-stack-parser-es@1.0.5: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -10188,6 +10850,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -10557,6 +11248,8 @@ snapshots: kleur@3.0.3: {} + kleur@4.1.5: {} + kysely@0.28.10: optional: true @@ -10713,6 +11406,18 @@ snapshots: min-indent@1.0.1: {} + miniflare@4.20260430.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.24.8 + workerd: 1.20260430.1 + ws: 8.18.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + minimist@1.2.8: {} moment@2.30.1: {} @@ -10899,6 +11604,8 @@ snapshots: path-to-regexp@0.1.13: {} + path-to-regexp@6.3.0: {} + pathe@1.1.2: {} pathe@2.0.3: {} @@ -11156,9 +11863,9 @@ snapshots: rolldown-plugin-dts@0.20.0(rolldown@1.0.0-beta.57(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(typescript@5.9.3): dependencies: - '@babel/generator': 7.28.5 - '@babel/parser': 7.28.6 - '@babel/types': 7.28.6 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 ast-kit: 2.2.0 birpc: 4.0.0 dts-resolver: 2.1.3 @@ -11361,7 +12068,6 @@ snapshots: '@img/sharp-win32-arm64': 0.34.5 '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 - optional: true shebang-command@2.0.0: dependencies: @@ -11440,6 +12146,8 @@ snapshots: std-env@3.10.0: {} + std-env@4.1.0: {} + streamx@2.25.0: dependencies: events-universal: 1.0.1 @@ -11479,6 +12187,8 @@ snapshots: client-only: 0.0.1 react: 19.2.4 + supports-color@10.2.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -11535,6 +12245,8 @@ snapshots: tinyrainbow@3.0.3: {} + tinyrainbow@3.1.0: {} + tldts-core@7.0.25: {} tldts@7.0.25: @@ -11668,6 +12380,12 @@ snapshots: undici@7.22.0: {} + undici@7.24.8: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + uniku@0.0.12: dependencies: '@noble/hashes': 2.0.1 @@ -11758,7 +12476,7 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 - vite@8.0.9(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.1): + vite@8.0.9(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -11767,7 +12485,7 @@ snapshots: tinyglobby: 0.2.16 optionalDependencies: '@types/node': 24.10.4 - esbuild: 0.27.2 + esbuild: 0.27.3 fsevents: 2.3.3 jiti: 2.6.1 tsx: 4.20.6 @@ -11787,12 +12505,12 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 vite: 7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: @@ -11812,6 +12530,36 @@ snapshots: - tsx - yaml + vitest@4.1.5(@types/node@24.10.4)(@vitest/coverage-v8@4.0.17(vitest@4.0.17))(@vitest/ui@4.0.17(vitest@4.0.17))(jsdom@28.1.0(@noble/hashes@2.0.1))(vite@8.0.9(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.1)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@8.0.9(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.9(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.4 + '@vitest/coverage-v8': 4.0.17(vitest@4.0.17) + '@vitest/ui': 4.0.17(vitest@4.0.17) + jsdom: 28.1.0(@noble/hashes@2.0.1) + transitivePeerDependencies: + - msw + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -11846,6 +12594,31 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + workerd@1.20260430.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260430.1 + '@cloudflare/workerd-darwin-arm64': 1.20260430.1 + '@cloudflare/workerd-linux-64': 1.20260430.1 + '@cloudflare/workerd-linux-arm64': 1.20260430.1 + '@cloudflare/workerd-windows-64': 1.20260430.1 + + wrangler@4.87.0(@cloudflare/workers-types@4.20260430.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260430.1) + blake3-wasm: 2.1.5 + esbuild: 0.27.3 + miniflare: 4.20260430.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260430.1 + optionalDependencies: + '@cloudflare/workers-types': 4.20260430.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + wrap-ansi@10.0.0: dependencies: ansi-styles: 6.2.3 @@ -11858,6 +12631,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.2 + ws@8.18.0: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} @@ -11875,7 +12650,22 @@ snapshots: buffer-crc32: 0.2.13 pend: 1.2.0 + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.15 + cookie: 1.1.1 + youch-core: 0.3.3 + zeptomatch@2.1.0: dependencies: grammex: 3.1.12 graphmatch: 1.1.0 + + zod@3.25.76: {} diff --git a/turbo.json b/turbo.json index 704f3a058e..cf2fc0136d 100644 --- a/turbo.json +++ b/turbo.json @@ -13,7 +13,11 @@ "test": { "dependsOn": ["^build"], "inputs": ["src/**", "test/**", "dist/**"], - "env": ["TEST_TIMEOUT_MULTIPLIER"] + "env": ["TEST_TIMEOUT_MULTIPLIER"], + "passThroughEnv": [ + "WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE", + "CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE" + ] }, "test:journeys": { "dependsOn": ["^build"],