diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dca7ee7240..9cab295a42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -238,6 +238,11 @@ jobs: runs-on: ubuntu-latest env: TEST_TIMEOUT_MULTIPLIER: 2 + # Exposed only to runs from prisma/prisma-next (not fork PRs). The + # `Require PPG service token` step below hard-fails own-repo runs that + # are missing the secret; the cloud-PPG integration test itself + # `describe.skipIf`s when the var is empty (fork PRs, local). + PRISMA_POSTGRES_SERVICE_TOKEN: ${{ secrets.PRISMA_POSTGRES_SERVICE_TOKEN }} services: postgres: image: postgres:15 @@ -263,6 +268,23 @@ jobs: - name: Build (restored from Turbo cache) if: needs.changes.outputs.inert != 'true' run: pnpm build + - name: Require PPG service token on own-repo PR runs + # Fork PRs can't access repo secrets — skip this gate there and let + # the integration test `describe.skipIf` handle it. On own-repo PR + # runs the secret MUST be configured, otherwise the cloud-PPG + # integration test would silently skip and AC-4 would go uncovered. + if: | + needs.changes.outputs.inert != 'true' && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository + env: + TOKEN: ${{ secrets.PRISMA_POSTGRES_SERVICE_TOKEN }} + run: | + if [ -z "$TOKEN" ]; then + echo "::error::PRISMA_POSTGRES_SERVICE_TOKEN is not configured in repo secrets — required for the cloud-PPG integration test on prisma/prisma-next PRs." + exit 1 + fi + echo "PPG service token is configured." - name: Run Integration tests if: needs.changes.outputs.inert != 'true' run: pnpm test:integration diff --git a/architecture.config.json b/architecture.config.json index 1940f781e3..8679d49202 100644 --- a/architecture.config.json +++ b/architecture.config.json @@ -264,6 +264,30 @@ "layer": "drivers", "plane": "runtime" }, + { + "glob": "packages/3-targets/7-drivers/ppg-serverless/src/core/**", + "domain": "targets", + "layer": "drivers", + "plane": "shared" + }, + { + "glob": "packages/3-targets/7-drivers/ppg-serverless/src/ppg-driver.ts", + "domain": "targets", + "layer": "drivers", + "plane": "shared" + }, + { + "glob": "packages/3-targets/7-drivers/ppg-serverless/src/normalize-error.ts", + "domain": "targets", + "layer": "drivers", + "plane": "shared" + }, + { + "glob": "packages/3-targets/7-drivers/ppg-serverless/src/exports/runtime.ts", + "domain": "targets", + "layer": "drivers", + "plane": "runtime" + }, { "glob": "packages/3-extensions/postgres/src/config/**", "domain": "extensions", @@ -312,6 +336,48 @@ "layer": "adapters", "plane": "shared" }, + { + "glob": "packages/3-extensions/prisma-postgres-serverless/src/exports/config.ts", + "domain": "extensions", + "layer": "adapters", + "plane": "shared" + }, + { + "glob": "packages/3-extensions/prisma-postgres-serverless/src/exports/contract-builder.ts", + "domain": "extensions", + "layer": "adapters", + "plane": "shared" + }, + { + "glob": "packages/3-extensions/prisma-postgres-serverless/src/exports/family.ts", + "domain": "extensions", + "layer": "adapters", + "plane": "shared" + }, + { + "glob": "packages/3-extensions/prisma-postgres-serverless/src/exports/migration.ts", + "domain": "extensions", + "layer": "adapters", + "plane": "migration" + }, + { + "glob": "packages/3-extensions/prisma-postgres-serverless/src/exports/runtime.ts", + "domain": "extensions", + "layer": "adapters", + "plane": "runtime" + }, + { + "glob": "packages/3-extensions/prisma-postgres-serverless/src/runtime/**", + "domain": "extensions", + "layer": "adapters", + "plane": "runtime" + }, + { + "glob": "packages/3-extensions/prisma-postgres-serverless/src/exports/target.ts", + "domain": "extensions", + "layer": "adapters", + "plane": "shared" + }, { "glob": "packages/3-extensions/mongo/src/config/**", "domain": "extensions", diff --git a/docs/architecture docs/Package-Layering.md b/docs/architecture docs/Package-Layering.md index 56677eaf9e..50b7783485 100644 --- a/docs/architecture docs/Package-Layering.md +++ b/docs/architecture docs/Package-Layering.md @@ -132,7 +132,9 @@ The targets domain (`packages/3-targets/`) contains concrete target extension pa |-- 6-adapters/postgres (multi-plane: shared, migration, runtime) | |-- → @prisma-next/adapter-postgres (adapter with control/runtime entrypoints) |-- 7-drivers/postgres (runtime plane) - |-- → @prisma-next/driver-postgres (driver implementation) +| |-- → @prisma-next/driver-postgres (TCP/pg driver implementation) +|-- 7-drivers/ppg-serverless (multi-plane: runtime, migration) + |-- → @prisma-next/driver-ppg-serverless (PPG WebSocket driver; runtime entry, migration entry re-exports driver-postgres/control) ``` ### Mongo Targets Domain @@ -158,7 +160,11 @@ The extensions domain (`packages/3-extensions/`) contains ecosystem extensions a |-- sql-orm-client/ (runtime plane) | |-- → @prisma-next/sql-orm-client |-- pgvector/ (multi-plane) - |-- → @prisma-next/extension-pgvector +| |-- → @prisma-next/extension-pgvector +|-- postgres/ (multi-plane: shared, runtime, migration) +| |-- → @prisma-next/postgres (long-lived Node-process facade; TCP via @prisma-next/driver-postgres) +|-- prisma-postgres-serverless/ (multi-plane: shared, runtime, migration) + |-- → @prisma-next/prisma-postgres-serverless (edge/serverless facade; WebSocket via @prisma-next/driver-ppg-serverless) ``` ### Layer Structure @@ -303,7 +309,8 @@ Database adapters, drivers, and targets (dialects) live in the Targets domain as - `src/exports/runtime.ts` → runtime plane (runtime factory) **Drivers (Runtime Plane):** -- `packages/3-targets/7-drivers/postgres/` → `@prisma-next/driver-postgres` - Postgres driver +- `packages/3-targets/7-drivers/postgres/` → `@prisma-next/driver-postgres` - Postgres TCP driver via `pg` +- `packages/3-targets/7-drivers/ppg-serverless/` → `@prisma-next/driver-ppg-serverless` - Prisma Postgres WebSocket driver via `@prisma/ppg`; ships `./runtime` (substantive) + `./control` (re-export of `@prisma-next/driver-postgres/control`) ## Naming Conventions @@ -358,8 +365,11 @@ Database adapters, drivers, and targets (dialects) live in the Targets domain as | `packages/3-targets/3-targets/postgres/` | `@prisma-next/target-postgres` | | `packages/3-targets/6-adapters/postgres/` | `@prisma-next/adapter-postgres` | | `packages/3-targets/7-drivers/postgres/` | `@prisma-next/driver-postgres` | +| `packages/3-targets/7-drivers/ppg-serverless/` | `@prisma-next/driver-ppg-serverless` | | `packages/3-extensions/sql-orm-client/` | `@prisma-next/sql-orm-client` | | `packages/3-extensions/pgvector/` | `@prisma-next/extension-pgvector` | +| `packages/3-extensions/postgres/` | `@prisma-next/postgres` | +| `packages/3-extensions/prisma-postgres-serverless/` | `@prisma-next/prisma-postgres-serverless` | ## Dependency Rules diff --git a/drive/calibration/dod.md b/drive/calibration/dod.md index 6043d6f1e1..14a14fa77c 100644 --- a/drive/calibration/dod.md +++ b/drive/calibration/dod.md @@ -116,4 +116,8 @@ Beyond the canonical project DoD items: Walk `design-decisions.md` for any decision that hasn't migrated to an ADR. If unmigrated decisions exist that are architecturally durable (cross-cutting, hard to reverse, affect future work), block close-out until they have ADRs — closing with un-ADR'd architectural decisions is a known close-out failure mode. +### Substrate-substitution wire-compat coverage (added 2026-06-03 retro) + +When a project introduces a new driver / adapter / runtime substrate that claims wire-compat parity with an existing one (e.g. a serverless driver substituting for the TCP driver against the same database family), **live-wire integration coverage against the substituted backend is a slice-DoD prerequisite, not a project-DoD nice-to-have**. Mocked-driver tests can verify the new substrate's own logic but cannot see column-hydration, error-shape, or protocol-framing gaps at the wire boundary by construction — they shape the row themselves before it crosses the seam. The framework adapter layer often banks on per-column behaviour (e.g. `pg`'s native `text[]` -> JS-array hydration) that mocked tests trivially satisfy but the new substrate may not. Land the live-wire integration test in the slice that introduces the substrate, not in a later validation slice; otherwise wire-compat regressions surface only at project close-out (or first real-user contact) when the cost of pivoting design is highest. + _(Living; add overlays as the team discovers them.)_ diff --git a/packages/3-extensions/prisma-postgres-serverless/README.md b/packages/3-extensions/prisma-postgres-serverless/README.md new file mode 100644 index 0000000000..ed14850ebb --- /dev/null +++ b/packages/3-extensions/prisma-postgres-serverless/README.md @@ -0,0 +1,208 @@ +# @prisma-next/prisma-postgres-serverless + +Edge/serverless-friendly Prisma Postgres facade for Prisma Next. Install this single package to get config, runtime, and the transitive type dependencies needed to author and run a Prisma Postgres app against the `@prisma/ppg` WebSocket client — no `pg` and no TCP transport on the data plane, so the runtime entry is portable to edge runtimes that do not expose raw TCP sockets. + +The facade composes the existing Postgres execution stack with a different driver: + +- the existing `postgres` target (`@prisma-next/target-postgres`) — same dialect, same migration ops. +- the existing `postgres` adapter (`@prisma-next/adapter-postgres`) — shared SQL lowering. +- the new `@prisma-next/driver-ppg-serverless` driver — WebSocket transport via `@prisma/ppg`. + +It is the serverless sibling of [`@prisma-next/postgres`](../postgres/README.md) (the long-lived Node-process facade backed by TCP `pg`). Pick the facade that matches your deployment lifecycle; both expose the same authoring + ORM surface. + +## Package Classification + +- **Domain**: extensions +- **Layer**: adapters +- **Planes**: shared (`config`, `contract-builder`, `control`, `family`, `target`), runtime (`runtime`), migration (`migration`) + +## Quick Start + +```typescript +// prisma-next.config.ts +import { defineConfig } from '@prisma-next/prisma-postgres-serverless/config'; + +export default defineConfig({ + contract: './prisma/contract.prisma', + db: { connection: process.env['PPG_URL']! }, +}); +``` + +```typescript +// db.ts +import prismaPostgresServerless from '@prisma-next/prisma-postgres-serverless/runtime'; +import type { Contract } from './contract.d'; +import contractJson from './contract.json' with { type: 'json' }; + +export const db = prismaPostgresServerless({ + contractJson, + binding: { kind: 'url', url: process.env['PPG_URL']! }, +}); +``` + +### Cloudflare Workers + +```typescript +// worker.ts +import prismaPostgresServerless from '@prisma-next/prisma-postgres-serverless/runtime'; +import type { Contract } from './contract.d'; +import contractJson from './contract.json' with { type: 'json' }; + +interface Env { + PPG_URL: string; +} + +export default { + async fetch(_req: Request, env: Env): Promise { + const db = prismaPostgresServerless({ + contractJson, + binding: { kind: 'url', url: env.PPG_URL }, + }); + try { + const rows = await db.orm.User.findMany(); + return Response.json(rows); + } finally { + await db.close(); + } + }, +}; +``` + +The PPG-compatible URL form is `postgres://identifier:key@db.prisma.io:5432/postgres?sslmode=require`. The `prisma+postgres://accelerate.prisma-data.net/?api_key=…` form returned by Prisma Accelerate / data-proxy is **not** a PPG URL — it carries a different wire protocol (GraphQL over HTTPS) and is rejected by `@prisma/ppg` upstream of the facade. If you provision via the Prisma Data Platform Management API, take the URL from `endpoints.pooled.connectionString`. + +## Runtime environments + +The runtime entry uses only `fetch` and `WebSocket` at runtime (transitively, through `@prisma/ppg`). Tested under: + +- Node.js 20+ +- Cloudflare Workers +- Vercel Edge Functions +- Deno / Deno Deploy +- Bun (Node + edge) + +## Exports + +| Subpath | Status | Notes | +|---|---|---| +| `./runtime` | Substantive | `prismaPostgresServerless(options)` factory. Returns a client with `sql` / `orm` / `context` / `runtime()` / `connect()` / `transaction()` / `prepare()` / `close()` / `[Symbol.asyncDispose]`. | +| `./config` | Re-export | `@prisma-next/postgres/config` (`defineConfig`). | +| `./contract-builder` | Re-export | `@prisma-next/postgres/contract-builder` (`defineContract`, `field`, `model`, `rel`, …). | +| `./control` | Re-export | `@prisma-next/postgres/control` (control-plane descriptor + `createPostgresControlClient` for migration tooling). Pulls `pg` into the install graph; never into the runtime bundle. | +| `./family` | Re-export | `@prisma-next/family-sql/pack` (the value passed as `family:` to `defineContract`). | +| `./migration` | Re-export | `@prisma-next/target-postgres/migration` — `Migration` base class, CLI runner, op helpers. | +| `./target` | Re-export | `@prisma-next/target-postgres/pack` (the value passed as `target:` to `defineContract`). | + +Compared to `@prisma-next/postgres`, two exports are deliberately absent: + +- **No `./serverless`.** This package _is_ the serverless surface; there is no second facade hiding behind a subpath. +- No separate Node / Pool factory — the runtime is always per-call session-based (one `@prisma/ppg` session per top-level call; one long-lived session per `acquireConnection()`), so there is no `pg.Pool` to surface. + +## Authoring + ORM + +The contract-builder, family, and target re-exports point at the same packages `@prisma-next/postgres` uses, so contracts authored against either facade are interchangeable: + +```typescript +import { defineContract, field, model } from '@prisma-next/prisma-postgres-serverless/contract-builder'; + +export const contract = defineContract( + { extensionPacks: {} }, + ({ field: f, model: m }) => ({ + models: { + Item: m('Item', { + fields: { + id: f.id.uuidv7(), + name: f.text(), + }, + }), + }, + }), +); +``` + +The migration plane runs over a direct TCP connection (re-exported `./control` from `@prisma-next/postgres/control`). Running migrations in CI / locally typically uses the same `prisma-next` CLI tooling against a TCP URL; runtime queries from Workers / Edge use the WebSocket data plane. Both planes target the same Prisma Postgres database. + +## Binding variants + +The `runtime()` factory takes a `binding` of one of two kinds: + +```typescript +// (a) Connection-string URL — the facade constructs and owns the PPG client. +// Array-OID parsers are registered automatically. +const db = prismaPostgresServerless({ + contractJson, + binding: { kind: 'url', url: env.PPG_URL }, +}); + +// (b) Pre-built @prisma/ppg Client — the caller owns the lifecycle. +// Wire array parsers in yourself if you read array-typed columns +// (text[], uuid[], int4[], jsonb[], …). +import { client as createPpgClient, defaultClientConfig } from '@prisma/ppg'; +import { withArrayParsers } from '@prisma-next/driver-ppg-serverless/runtime'; + +const config = defaultClientConfig(env.PPG_URL); +const ppgClient = createPpgClient({ + ...config, + parsers: withArrayParsers(config.parsers ?? []), +}); +const db = prismaPostgresServerless({ + contractJson, + binding: { kind: 'ppgClient', client: ppgClient }, +}); +``` + +## Transactions + +`db.transaction(fn)` opens a long-lived session, issues `BEGIN`, runs the callback, then `COMMIT`s on return or `ROLLBACK`s on throw. The callback receives a transaction-scoped `tx` whose `orm` / `sql` / `context` mirror `db`'s top-level surface: + +```typescript +await db.transaction(async (tx) => { + await tx.orm.Item.create({ id: crypto.randomUUID(), name: 'alice' }); + await tx.orm.Item.create({ id: crypto.randomUUID(), name: 'bob' }); +}); +// Both rows committed atomically. Throw inside the callback to roll back. +``` + +## Responsibilities + +- Build a static Prisma Postgres execution stack from target, adapter, and driver descriptors. +- Build a typed SQL authoring surface and ORM root from the execution context. +- Forward the caller's `PpgBinding` through to the driver. +- Lazily instantiate runtime resources on first `db.runtime()` or `db.connect(...)` call; memoise so repeated calls return one instance. +- Forward the control / config / contract-builder surfaces from `@prisma-next/postgres` so consumers get a single-import experience. + +## Dependencies + +- `@prisma/ppg` (via `@prisma-next/driver-ppg-serverless`) — Prisma Postgres WebSocket client. +- `@prisma-next/sql-runtime` — stack / context / runtime primitives. +- `@prisma-next/framework-components/execution` — stack instantiation. +- `@prisma-next/target-postgres` — target descriptor (shared with the long-lived facade). +- `@prisma-next/adapter-postgres` — adapter descriptor (shared with the long-lived facade). +- `@prisma-next/driver-ppg-serverless` — driver descriptor (this facade's defining choice). +- `@prisma-next/postgres` — re-exported for the `./config`, `./contract-builder`, and `./control` surfaces. Pulls `pg` into the install graph through the control re-export, but the runtime bundle stays edge-clean (bundlers tree-shake the unimported `./control` re-export from the `./runtime` entry). +- `@prisma-next/sql-builder`, `@prisma-next/sql-orm-client`, `@prisma-next/sql-contract` — authoring + ORM surfaces. + +## Architecture + +```mermaid +flowchart TD + App[App Code] --> Client[prisma-postgres-serverless runtime] + Client --> Static[Roots: sql, orm, context, contract] + Client --> Lazy[runtime / connect] + + Lazy --> Bind[Resolve binding: url, ppgClient, or binding] + Bind --> NewSession[ppg Client.newSession per call or per connection] + Lazy --> Runtime[createRuntime] + + Runtime --> Target[@prisma-next/target-postgres] + Runtime --> Adapter[@prisma-next/adapter-postgres] + Runtime --> Driver[@prisma-next/driver-ppg-serverless] + Runtime --> SqlRuntime[@prisma-next/sql-runtime] + Runtime --> ExecPlane[@prisma-next/framework-components/execution] +``` + +## Related Docs + +- Architecture: [`docs/Architecture Overview.md`](../../../docs/Architecture%20Overview.md) +- Subsystem: [`docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md`](../../../docs/architecture%20docs/subsystems/4.%20Runtime%20%26%20Middleware%20Framework.md) +- Subsystem: [`docs/architecture docs/subsystems/5. Adapters & Targets.md`](../../../docs/architecture%20docs/subsystems/5.%20Adapters%20%26%20Targets.md) +- ADR: [`docs/architecture docs/adrs/ADR 207 - Per-environment facade asymmetry.md`](../../../docs/architecture%20docs/adrs/ADR%20207%20-%20Per-environment%20facade%20asymmetry.md) diff --git a/packages/3-extensions/prisma-postgres-serverless/biome.jsonc b/packages/3-extensions/prisma-postgres-serverless/biome.jsonc new file mode 100644 index 0000000000..6e06bcc87c --- /dev/null +++ b/packages/3-extensions/prisma-postgres-serverless/biome.jsonc @@ -0,0 +1,4 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "extends": "//" +} diff --git a/packages/3-extensions/prisma-postgres-serverless/package.json b/packages/3-extensions/prisma-postgres-serverless/package.json new file mode 100644 index 0000000000..9743872008 --- /dev/null +++ b/packages/3-extensions/prisma-postgres-serverless/package.json @@ -0,0 +1,79 @@ +{ + "name": "@prisma-next/prisma-postgres-serverless", + "version": "0.12.0", + "license": "Apache-2.0", + "type": "module", + "sideEffects": false, + "description": "Edge/serverless-friendly Prisma Postgres client composition for Prisma Next", + "scripts": { + "build": "tsdown", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc --project tsconfig.json --noEmit", + "lint": "biome check . --error-on-warnings", + "lint:fix": "biome check --write .", + "lint:fix:unsafe": "biome check --write --unsafe .", + "clean": "rm -rf dist dist-tsc dist-tsc-prod coverage .tmp-output" + }, + "dependencies": { + "@prisma-next/adapter-postgres": "workspace:0.12.0", + "@prisma-next/cli": "workspace:0.12.0", + "@prisma-next/config": "workspace:0.12.0", + "@prisma-next/contract": "workspace:0.12.0", + "@prisma-next/driver-ppg-serverless": "workspace:0.12.0", + "@prisma-next/family-sql": "workspace:0.12.0", + "@prisma-next/framework-components": "workspace:0.12.0", + "@prisma-next/postgres": "workspace:0.12.0", + "@prisma-next/sql-builder": "workspace:0.12.0", + "@prisma-next/sql-contract": "workspace:0.12.0", + "@prisma-next/sql-contract-psl": "workspace:0.12.0", + "@prisma-next/sql-contract-ts": "workspace:0.12.0", + "@prisma-next/sql-orm-client": "workspace:0.12.0", + "@prisma-next/sql-relational-core": "workspace:0.12.0", + "@prisma-next/sql-runtime": "workspace:0.12.0", + "@prisma-next/target-postgres": "workspace:0.12.0", + "@prisma-next/utils": "workspace:0.12.0", + "@prisma/ppg": "catalog:", + "pathe": "^2.0.3" + }, + "devDependencies": { + "@prisma-next/psl-parser": "workspace:0.12.0", + "@prisma-next/test-utils": "workspace:0.12.0", + "@prisma-next/tsconfig": "workspace:0.12.0", + "@prisma-next/tsdown": "workspace:0.12.0", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "typescript": ">=5.9" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, + "files": [ + "dist", + "src" + ], + "types": "./dist/runtime.d.mts", + "exports": { + "./config": "./dist/config.mjs", + "./contract-builder": "./dist/contract-builder.mjs", + "./control": "./dist/control.mjs", + "./family": "./dist/family.mjs", + "./migration": "./dist/migration.mjs", + "./runtime": "./dist/runtime.mjs", + "./target": "./dist/target.mjs", + "./package.json": "./package.json" + }, + "engines": { + "node": ">=24" + }, + "repository": { + "type": "git", + "url": "https://github.com/prisma/prisma-next.git", + "directory": "packages/3-extensions/prisma-postgres-serverless" + } +} diff --git a/packages/3-extensions/prisma-postgres-serverless/src/exports/config.ts b/packages/3-extensions/prisma-postgres-serverless/src/exports/config.ts new file mode 100644 index 0000000000..7d025bd67a --- /dev/null +++ b/packages/3-extensions/prisma-postgres-serverless/src/exports/config.ts @@ -0,0 +1 @@ +export * from '@prisma-next/postgres/config'; diff --git a/packages/3-extensions/prisma-postgres-serverless/src/exports/contract-builder.ts b/packages/3-extensions/prisma-postgres-serverless/src/exports/contract-builder.ts new file mode 100644 index 0000000000..f87d27f37e --- /dev/null +++ b/packages/3-extensions/prisma-postgres-serverless/src/exports/contract-builder.ts @@ -0,0 +1 @@ +export * from '@prisma-next/postgres/contract-builder'; diff --git a/packages/3-extensions/prisma-postgres-serverless/src/exports/control.ts b/packages/3-extensions/prisma-postgres-serverless/src/exports/control.ts new file mode 100644 index 0000000000..d0844f49d4 --- /dev/null +++ b/packages/3-extensions/prisma-postgres-serverless/src/exports/control.ts @@ -0,0 +1 @@ +export * from '@prisma-next/postgres/control'; diff --git a/packages/3-extensions/prisma-postgres-serverless/src/exports/family.ts b/packages/3-extensions/prisma-postgres-serverless/src/exports/family.ts new file mode 100644 index 0000000000..d83f6f7b90 --- /dev/null +++ b/packages/3-extensions/prisma-postgres-serverless/src/exports/family.ts @@ -0,0 +1 @@ +export { default } from '@prisma-next/family-sql/pack'; diff --git a/packages/3-extensions/prisma-postgres-serverless/src/exports/migration.ts b/packages/3-extensions/prisma-postgres-serverless/src/exports/migration.ts new file mode 100644 index 0000000000..305906a916 --- /dev/null +++ b/packages/3-extensions/prisma-postgres-serverless/src/exports/migration.ts @@ -0,0 +1 @@ +export * from '@prisma-next/target-postgres/migration'; diff --git a/packages/3-extensions/prisma-postgres-serverless/src/exports/runtime.ts b/packages/3-extensions/prisma-postgres-serverless/src/exports/runtime.ts new file mode 100644 index 0000000000..f235d4ff3a --- /dev/null +++ b/packages/3-extensions/prisma-postgres-serverless/src/exports/runtime.ts @@ -0,0 +1,12 @@ +export type { PpgBinding } from '@prisma-next/driver-ppg-serverless/runtime'; +export type { + PpgServerlessTargetId, + PrismaPostgresServerlessBindingOptions, + PrismaPostgresServerlessClient, + PrismaPostgresServerlessOptions, + PrismaPostgresServerlessOptionsBase, + PrismaPostgresServerlessOptionsWithContract, + PrismaPostgresServerlessOptionsWithContractJson, + PrismaPostgresServerlessTransactionContext, +} from '../runtime/prisma-postgres-serverless'; +export { default } from '../runtime/prisma-postgres-serverless'; diff --git a/packages/3-extensions/prisma-postgres-serverless/src/exports/target.ts b/packages/3-extensions/prisma-postgres-serverless/src/exports/target.ts new file mode 100644 index 0000000000..15d0fcfaff --- /dev/null +++ b/packages/3-extensions/prisma-postgres-serverless/src/exports/target.ts @@ -0,0 +1 @@ +export { default } from '@prisma-next/target-postgres/pack'; diff --git a/packages/3-extensions/prisma-postgres-serverless/src/runtime/prisma-postgres-serverless.ts b/packages/3-extensions/prisma-postgres-serverless/src/runtime/prisma-postgres-serverless.ts new file mode 100644 index 0000000000..8da7ac3140 --- /dev/null +++ b/packages/3-extensions/prisma-postgres-serverless/src/runtime/prisma-postgres-serverless.ts @@ -0,0 +1,314 @@ +import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; +import type { Contract } from '@prisma-next/contract/types'; +import ppgDriver, { type PpgBinding } from '@prisma-next/driver-ppg-serverless/runtime'; +import { instantiateExecutionStack } from '@prisma-next/framework-components/execution'; +import { sql } from '@prisma-next/sql-builder/runtime'; +import type { Db } from '@prisma-next/sql-builder/types'; +import type { ExtractCodecTypes, SqlStorage } from '@prisma-next/sql-contract/types'; +import { type OrmClient, orm } from '@prisma-next/sql-orm-client'; +import type { CodecTypesBase, RawSqlTag } from '@prisma-next/sql-relational-core/expression'; +import { createRawSql } from '@prisma-next/sql-relational-core/expression'; +import type { SqlQueryPlan } from '@prisma-next/sql-relational-core/plan'; +import type { + BindSiteParams, + Declaration, + ExecutionContext, + ParamsFromDeclaration, + PreparedStatement, + Runtime, + SqlExecutionStackWithDriver, + SqlMiddleware, + SqlRuntimeExtensionDescriptor, + TransactionContext, + VerifyMarkerOption, +} from '@prisma-next/sql-runtime'; +import { + createExecutionContext, + createRuntime, + createSqlExecutionStack, + withTransaction, +} from '@prisma-next/sql-runtime'; +import postgresTarget, { PostgresContractSerializer } from '@prisma-next/target-postgres/runtime'; +import { blindCast } from '@prisma-next/utils/casts'; +import { ifDefined } from '@prisma-next/utils/defined'; + +export type PpgServerlessTargetId = 'postgres'; + +export interface PrismaPostgresServerlessTransactionContext> + extends TransactionContext { + readonly sql: Db; + readonly orm: OrmClient; +} + +export interface PrismaPostgresServerlessClient> { + readonly sql: Db; + readonly orm: OrmClient; + readonly raw: RawSqlTag; + readonly context: ExecutionContext; + readonly stack: SqlExecutionStackWithDriver; + connect(binding?: PpgBinding): Promise; + runtime(): Runtime; + transaction( + fn: (tx: PrismaPostgresServerlessTransactionContext) => PromiseLike, + ): Promise; + prepare< + D extends Declaration, + Row, + CT extends CodecTypesBase = ExtractCodecTypes & CodecTypesBase, + >( + declaration: D, + callback: (sql: Db, params: BindSiteParams) => SqlQueryPlan, + ): Promise, Row>>; + close(): Promise; + [Symbol.asyncDispose](): Promise; +} + +export interface PrismaPostgresServerlessOptionsBase { + readonly extensions?: readonly SqlRuntimeExtensionDescriptor[]; + readonly middleware?: readonly SqlMiddleware[]; + readonly verifyMarker?: VerifyMarkerOption; +} + +export interface PrismaPostgresServerlessBindingOptions { + readonly binding?: PpgBinding; +} + +export type PrismaPostgresServerlessOptionsWithContract> = + PrismaPostgresServerlessBindingOptions & + PrismaPostgresServerlessOptionsBase & { + readonly contract: TContract; + readonly contractJson?: never; + }; + +export type PrismaPostgresServerlessOptionsWithContractJson< + TContract extends Contract, +> = PrismaPostgresServerlessBindingOptions & + PrismaPostgresServerlessOptionsBase & { + readonly contractJson: unknown; + readonly contract?: never; + readonly _contract?: TContract; + }; + +export type PrismaPostgresServerlessOptions> = + | PrismaPostgresServerlessOptionsWithContract + | PrismaPostgresServerlessOptionsWithContractJson; + +function hasContractJson>( + options: PrismaPostgresServerlessOptions, +): options is PrismaPostgresServerlessOptionsWithContractJson { + return 'contractJson' in options; +} + +const contractSerializer = new PostgresContractSerializer(); + +function resolveContract>( + options: PrismaPostgresServerlessOptions, +): TContract { + const contractInput = hasContractJson(options) ? options.contractJson : options.contract; + return blindCast< + TContract, + 'the contract serializer returns the generic Contract base shape; the caller asserts (via the TContract type parameter) that the deserialised contract matches their literal model schema. The runtime values are unchanged; the cast only widens the public-surface type back to the caller-supplied generic.' + >(contractSerializer.deserializeContract(contractInput)); +} + +/** + * Lazy Prisma Postgres serverless client. The `sql` / `orm` / `context` / + * `stack` surfaces are available synchronously; the driver and runtime are + * instantiated on first `runtime()` / `connect()` call. + */ +export default function prismaPostgresServerless>( + options: PrismaPostgresServerlessOptionsWithContract, +): PrismaPostgresServerlessClient; +export default function prismaPostgresServerless>( + options: PrismaPostgresServerlessOptionsWithContractJson, +): PrismaPostgresServerlessClient; +export default function prismaPostgresServerless>( + options: PrismaPostgresServerlessOptions, +): PrismaPostgresServerlessClient { + const contract = resolveContract(options); + let binding = options.binding; + const stack = createSqlExecutionStack({ + target: postgresTarget, + adapter: postgresAdapter, + driver: ppgDriver, + extensionPacks: options.extensions ?? [], + }); + + const context = createExecutionContext({ + contract, + stack, + }); + + const rawCodecInferer = stack.adapter.rawCodecInferer; + const rawSqlTag: RawSqlTag = createRawSql(rawCodecInferer); + + let runtimeInstance: Runtime | undefined; + let runtimeDriver: { connect(binding: unknown): Promise } | undefined; + let driverConnected = false; + let connectPromise: Promise | undefined; + let backgroundConnectError: unknown; + let closed = false; + + const connectDriver = async (resolvedBinding: PpgBinding): Promise => { + if (driverConnected) return; + if (!runtimeDriver) throw new Error('Prisma Postgres runtime driver missing'); + if (connectPromise) return connectPromise; + connectPromise = runtimeDriver + .connect(resolvedBinding) + .then(() => { + driverConnected = true; + }) + .catch((err) => { + backgroundConnectError = err; + connectPromise = undefined; + throw err; + }); + return connectPromise; + }; + const getRuntime = (): Runtime => { + if (closed) { + throw new Error('Prisma Postgres serverless client is closed'); + } + + if (backgroundConnectError !== undefined) { + throw backgroundConnectError; + } + + if (runtimeInstance) { + return runtimeInstance; + } + + const stackInstance = instantiateExecutionStack(stack); + const driverDescriptor = stack.driver; + if (!driverDescriptor) { + throw new Error('Driver descriptor missing from execution stack'); + } + + const driver = driverDescriptor.create(); + runtimeDriver = driver; + if (binding !== undefined) { + void connectDriver(binding).catch(() => undefined); + } + + runtimeInstance = createRuntime({ + stackInstance, + context, + driver, + ...ifDefined('verifyMarker', options.verifyMarker), + ...ifDefined('middleware', options.middleware), + }); + + return runtimeInstance; + }; + const ormClient: OrmClient = orm({ + runtime: { + execute(plan) { + return getRuntime().execute(plan); + }, + connection() { + return getRuntime().connection(); + }, + }, + context, + }); + + const sqlClient: Db = sql({ context, rawCodecInferer }); + + return { + sql: sqlClient, + orm: ormClient, + raw: rawSqlTag, + context, + stack, + + async connect(bindingInput) { + if (closed) { + throw new Error('Prisma Postgres serverless client is closed'); + } + + if (driverConnected || connectPromise) { + throw new Error('Prisma Postgres serverless client already connected'); + } + + if (bindingInput !== undefined) { + binding = bindingInput; + } + + if (binding === undefined) { + throw new Error( + 'Prisma Postgres serverless binding not configured. Pass binding in prismaPostgresServerless(...) options or call db.connect(binding).', + ); + } + + const runtime = getRuntime(); + if (driverConnected) { + return runtime; + } + + await connectDriver(binding); + return runtime; + }, + + runtime() { + return getRuntime(); + }, + + prepare< + D extends Declaration, + Row, + CT extends CodecTypesBase = ExtractCodecTypes & CodecTypesBase, + >( + declaration: D, + callback: (sql: Db, params: BindSiteParams) => SqlQueryPlan, + ): Promise, Row>> { + return getRuntime().prepare(declaration, (params) => callback(sqlClient, params)); + }, + + transaction( + fn: (tx: PrismaPostgresServerlessTransactionContext) => PromiseLike, + ): Promise { + return withTransaction(getRuntime(), (txCtx) => { + const txSql: Db = sql({ + context, + rawCodecInferer, + }); + + const txOrm: OrmClient = orm({ + runtime: { + execute(plan) { + return txCtx.execute(plan); + }, + }, + context, + }); + + // Use `txCtx` as the prototype instead of spreading it so that live + // accessors (notably the `invalidated` getter, which reads a closure + // variable in `withTransaction`) remain wired to the original object. + // Spreading would evaluate the getter once and freeze its value. + const tx: PrismaPostgresServerlessTransactionContext = Object.assign( + blindCast< + TransactionContext, + 'Object.create(txCtx) returns the prototype-only sibling; the sibling structurally is a TransactionContext (the prototype carries the live accessors) but TS sees it as the wider Object return type' + >(Object.create(txCtx)), + { sql: txSql, orm: txOrm }, + ); + + return fn(tx); + }); + }, + + async close(): Promise { + if (closed) return; + closed = true; + // Swallow a still-pending connect failure: the caller has signalled + // they are done, and the error was either already surfaced via + // `runtime()` or never observed. + await connectPromise?.catch(() => undefined); + }, + + [Symbol.asyncDispose](): Promise { + return this.close(); + }, + }; +} diff --git a/packages/3-extensions/prisma-postgres-serverless/test/_fakes.ts b/packages/3-extensions/prisma-postgres-serverless/test/_fakes.ts new file mode 100644 index 0000000000..e96515893c --- /dev/null +++ b/packages/3-extensions/prisma-postgres-serverless/test/_fakes.ts @@ -0,0 +1,95 @@ +/** + * Slim local fake of `@prisma/ppg`'s `Client` / `Session` / `Resultset` + * surface, scoped to what the facade end-to-end test needs: a `Client` whose + * `newSession()` returns a `Session` whose `query` returns a canned + * resultset. Not a substitute for the driver-package fake — that one has + * richer probes (per-session history, close counts, transaction-statement + * shortcuts) the facade does not exercise from this boundary. + */ +import type { Column, Client as PpgClient, Resultset, Row, Session } from '@prisma/ppg'; + +export interface ResultsetSpec { + readonly columns: ReadonlyArray; + readonly rows: ReadonlyArray; +} + +export type QueryHandler = ( + sql: string, + params: readonly unknown[], +) => ResultsetSpec | Promise | Error | Promise; + +export function makeFakeClient(handler: QueryHandler): PpgClient { + const newSession = async (): Promise => { + let active = true; + const session: Session = { + query: async (sql: string, ...params: unknown[]): Promise => { + const out = await handler(sql, params); + if (out instanceof Error) { + throw out; + } + return makeResultset(out); + }, + exec: async (_sql: string, ..._params: unknown[]): Promise => { + throw new Error('fake-ppg-client: exec not implemented for tests'); + }, + close: () => { + active = false; + }, + get active() { + return active; + }, + [Symbol.dispose]() { + this.close(); + }, + }; + return session; + }; + + return { + newSession, + query: async (_sql: string, ..._params: unknown[]) => { + throw new Error('fake-ppg-client: top-level query not used by the driver'); + }, + exec: async (_sql: string, ..._params: unknown[]) => { + throw new Error('fake-ppg-client: top-level exec not used by the driver'); + }, + }; +} + +function makeResultset(spec: ResultsetSpec): Resultset { + const rows = [...spec.rows]; + let i = 0; + const iter = { + async next(): Promise> { + if (i < rows.length) { + const value = rows[i++] as Row; + return { value, done: false }; + } + return { value: undefined, done: true }; + }, + async return(): Promise> { + i = rows.length; + return { value: undefined, done: true }; + }, + async collect(): Promise { + const remaining = rows.slice(i); + i = rows.length; + return remaining; + }, + [Symbol.asyncIterator]() { + return iter; + }, + }; + return { + columns: [...spec.columns], + rows: iter as unknown as Resultset['rows'], + }; +} + +export function col(name: string, oid = 25): Column { + return { name, oid }; +} + +export function row(...values: unknown[]): Row { + return { values }; +} diff --git a/packages/3-extensions/prisma-postgres-serverless/test/prisma-postgres-serverless.test.ts b/packages/3-extensions/prisma-postgres-serverless/test/prisma-postgres-serverless.test.ts new file mode 100644 index 0000000000..1e92f6f3f3 --- /dev/null +++ b/packages/3-extensions/prisma-postgres-serverless/test/prisma-postgres-serverless.test.ts @@ -0,0 +1,362 @@ +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import { createContract } from '@prisma-next/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + instantiateExecutionStack: vi.fn(), + createRuntime: vi.fn(), + createExecutionContext: vi.fn(), + createSqlExecutionStack: vi.fn(), + withTransaction: vi.fn(), + sqlBuilder: vi.fn(), + driverCreate: vi.fn(), + driverConnect: vi.fn(), + deserializeContract: 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, + withTransaction: mocks.withTransaction, +})); + +vi.mock('@prisma-next/sql-builder/runtime', () => ({ + sql: mocks.sqlBuilder, +})); + +vi.mock('@prisma-next/sql-orm-client', () => ({ + orm: vi.fn(() => ({ lane: 'orm' })), +})); + +vi.mock('@prisma-next/target-postgres/runtime', () => ({ + default: { id: 'target-postgres' }, + PostgresContractSerializer: class { + deserializeContract(value: unknown) { + return mocks.deserializeContract(value); + } + }, +})); + +vi.mock('@prisma-next/adapter-postgres/runtime', () => ({ + default: { id: 'adapter-postgres' }, +})); + +vi.mock('@prisma-next/driver-ppg-serverless/runtime', () => ({ + default: { id: 'driver-ppg-serverless' }, +})); + +import prismaPostgresServerless from '../src/runtime/prisma-postgres-serverless'; + +const contract = createContract(); + +describe('prisma-postgres-serverless', () => { + beforeEach(() => { + mocks.instantiateExecutionStack.mockReset(); + mocks.createRuntime.mockReset(); + mocks.createExecutionContext.mockReset(); + mocks.createSqlExecutionStack.mockReset(); + mocks.withTransaction.mockReset(); + mocks.driverCreate.mockReset(); + mocks.driverConnect.mockReset(); + mocks.deserializeContract.mockReset(); + mocks.sqlBuilder.mockReset(); + + mocks.createExecutionContext.mockReturnValue({ + contract, + codecs: {}, + queryOperations: { entries: () => ({}) }, + types: {}, + }); + mocks.createSqlExecutionStack.mockReturnValue({ + target: { id: 'target-postgres' }, + adapter: { + id: 'adapter-postgres', + rawCodecInferer: { inferCodec: () => 'ppg/text' }, + create: () => ({}), + }, + driver: { create: mocks.driverCreate }, + extensionPacks: [], + }); + mocks.instantiateExecutionStack.mockReturnValue({ adapter: {} }); + mocks.driverConnect.mockResolvedValue(undefined); + mocks.driverCreate.mockReturnValue({ id: 'driver-instance', connect: mocks.driverConnect }); + mocks.createRuntime.mockReturnValue({ id: 'runtime-instance' }); + mocks.deserializeContract.mockReturnValue(contract); + mocks.sqlBuilder.mockReturnValue({ lane: 'sql' }); + mocks.withTransaction.mockImplementation( + async (_runtime: unknown, fn: (ctx: unknown) => unknown) => { + const mockTxCtx = { + invalidated: false, + execute: vi.fn(), + }; + return fn(mockTxCtx); + }, + ); + }); + + describe('construction', () => { + it('accepts { contract } and constructs synchronously', () => { + const db = prismaPostgresServerless({ + contract, + binding: { kind: 'url', url: 'postgres://localhost:5432/db' }, + }); + + const thenable = db as unknown as { then?: unknown }; + expect(typeof thenable.then).toBe('undefined'); + expect(db.sql).toBeDefined(); + expect(mocks.deserializeContract).toHaveBeenCalledWith(contract); + }); + + it('accepts { contractJson } and routes it through the contract serializer', () => { + const contractJson = { models: {} }; + + prismaPostgresServerless({ + contractJson, + binding: { kind: 'url', url: 'postgres://localhost:5432/db' }, + }); + + expect(mocks.deserializeContract).toHaveBeenCalledTimes(1); + expect(mocks.deserializeContract).toHaveBeenCalledWith(contractJson); + }); + }); + + describe('static surface', () => { + it('exposes sql / orm / raw / context / stack / connect / runtime / transaction / prepare / close / [Symbol.asyncDispose]', () => { + const db = prismaPostgresServerless({ + contract, + binding: { kind: 'url', url: 'postgres://localhost:5432/db' }, + }); + + expect(db).toMatchObject({ + sql: expect.anything(), + orm: expect.anything(), + raw: expect.any(Function), + context: expect.anything(), + stack: expect.anything(), + connect: expect.any(Function), + runtime: expect.any(Function), + transaction: expect.any(Function), + prepare: expect.any(Function), + close: expect.any(Function), + }); + expect(typeof db[Symbol.asyncDispose]).toBe('function'); + }); + + it('builds sql eagerly without instantiating the driver / runtime', () => { + const db = prismaPostgresServerless({ + contract, + binding: { kind: 'url', url: 'postgres://localhost:5432/db' }, + }); + + expect(mocks.sqlBuilder).toHaveBeenCalledTimes(1); + expect(db.sql).toEqual({ lane: 'sql' }); + expect(mocks.instantiateExecutionStack).not.toHaveBeenCalled(); + expect(mocks.createRuntime).not.toHaveBeenCalled(); + expect(mocks.driverCreate).not.toHaveBeenCalled(); + }); + }); + + describe('runtime lifecycle', () => { + it('lazily instantiates driver and runtime on first runtime() call, memoised thereafter', () => { + const db = prismaPostgresServerless({ + contract, + binding: { kind: 'url', url: 'postgres://localhost:5432/db' }, + }); + + const first = db.runtime(); + const second = db.runtime(); + + expect(first).toBe(second); + expect(mocks.instantiateExecutionStack).toHaveBeenCalledTimes(1); + expect(mocks.createRuntime).toHaveBeenCalledTimes(1); + expect(mocks.driverCreate).toHaveBeenCalledTimes(1); + }); + + it('driver.create() is called with no argument (no PPG cursor mode)', () => { + const db = prismaPostgresServerless({ + contract, + binding: { kind: 'url', url: 'postgres://localhost:5432/db' }, + }); + db.runtime(); + expect(mocks.driverCreate).toHaveBeenCalledTimes(1); + expect(mocks.driverCreate).toHaveBeenCalledWith(); + }); + }); + + describe('binding forwarding', () => { + it('forwards a { kind: "url" } binding to the driver unchanged', async () => { + const db = prismaPostgresServerless({ + contract, + binding: { kind: 'url', url: 'postgres://localhost:5432/db' }, + }); + + await db.connect(); + expect(mocks.driverConnect).toHaveBeenCalledTimes(1); + expect(mocks.driverConnect).toHaveBeenCalledWith({ + kind: 'url', + url: 'postgres://localhost:5432/db', + }); + }); + + it('forwards a { kind: "ppgClient" } binding to the driver unchanged', async () => { + const fakeClient = { __brand: 'ppg' }; + + const db = prismaPostgresServerless({ + contract, + binding: { kind: 'ppgClient', client: fakeClient }, + } as unknown as Parameters>[0]); + await db.connect(); + + expect(mocks.driverConnect).toHaveBeenCalledWith({ + kind: 'ppgClient', + client: fakeClient, + }); + }); + }); + + describe('connect()', () => { + it('rejects a second connect with "already connected"', async () => { + const db = prismaPostgresServerless({ + contract, + binding: { kind: 'url', url: 'postgres://localhost:5432/db' }, + }); + + await db.connect(); + await expect( + db.connect({ kind: 'url', url: 'postgres://localhost:5432/db2' }), + ).rejects.toThrow('Prisma Postgres serverless client already connected'); + + expect(mocks.driverConnect).toHaveBeenCalledTimes(1); + }); + + it('rejects when called with no configured binding', async () => { + const db = prismaPostgresServerless({ + contract, + } as Parameters>[0]); + + await expect(db.connect()).rejects.toThrow( + 'Prisma Postgres serverless binding not configured', + ); + }); + }); + + describe('transaction()', () => { + it('delegates to withTransaction with the lazy runtime', async () => { + const db = prismaPostgresServerless({ + contract, + binding: { kind: 'url', url: 'postgres://localhost:5432/db' }, + }); + + const result = await db.transaction(async () => 'tx-value'); + + expect(mocks.withTransaction).toHaveBeenCalledOnce(); + expect(mocks.withTransaction).toHaveBeenCalledWith( + mocks.createRuntime.mock.results[0]?.value, + expect.any(Function), + ); + expect(result).toBe('tx-value'); + }); + + it('provides sql + orm on the transaction context', async () => { + const txSqlProxy = { lane: 'tx-sql' }; + let callCount = 0; + mocks.sqlBuilder.mockImplementation(() => { + callCount++; + if (callCount === 1) return { lane: 'sql' }; + return txSqlProxy; + }); + + const { orm: ormMock } = await import('@prisma-next/sql-orm-client'); + const txOrmProxy = { lane: 'tx-orm' }; + let ormCallCount = 0; + vi.mocked(ormMock).mockImplementation((() => { + ormCallCount++; + if (ormCallCount === 1) return { lane: 'orm' }; + return txOrmProxy; + }) as typeof ormMock); + + const db = prismaPostgresServerless({ + contract, + binding: { kind: 'url', url: 'postgres://localhost:5432/db' }, + }); + + let receivedTx: { sql?: unknown; orm?: unknown } | undefined; + await db.transaction(async (tx) => { + receivedTx = tx; + }); + + expect(receivedTx).toBeDefined(); + expect(receivedTx!.sql).toBe(txSqlProxy); + expect(receivedTx!.orm).toBe(txOrmProxy); + }); + }); + + describe('close() and [Symbol.asyncDispose]', () => { + it('close() is idempotent (no-op on second call)', async () => { + const db = prismaPostgresServerless({ + contract, + binding: { kind: 'url', url: 'postgres://localhost:5432/db' }, + }); + db.runtime(); + await Promise.resolve(); + + await db.close(); + await db.close(); + // No facade-owned resource to dispose; the test asserts no throw and + // that subsequent runtime() / connect() reject as closed. + expect(() => db.runtime()).toThrow('Prisma Postgres serverless client is closed'); + await expect(db.connect()).rejects.toThrow('Prisma Postgres serverless client is closed'); + }); + + it('[Symbol.asyncDispose] delegates to close()', async () => { + async function run() { + await using db = prismaPostgresServerless({ + contract, + binding: { kind: 'url', url: 'postgres://localhost:5432/db' }, + }); + db.runtime(); + await Promise.resolve(); + // exiting scope triggers Symbol.asyncDispose -> close() + } + + await run(); + // No throw means asyncDispose ran cleanly. + expect(true).toBe(true); + }); + + it('close() before any connect is a clean no-op', async () => { + const db = prismaPostgresServerless({ + contract, + binding: { kind: 'url', url: 'postgres://localhost:5432/db' }, + }); + await db.close(); + // No throw; no driver work attempted. + expect(mocks.driverConnect).not.toHaveBeenCalled(); + }); + + it('close() resolves cleanly while a lazy connect is in flight (rejection)', async () => { + let rejectConnect!: (err: Error) => void; + mocks.driverConnect.mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectConnect = reject; + }), + ); + const db = prismaPostgresServerless({ + contract, + binding: { kind: 'url', url: 'postgres://localhost:5432/db' }, + }); + db.runtime(); + + const closePromise = db.close(); + rejectConnect(new Error('connect failed')); + + await expect(closePromise).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/3-extensions/prisma-postgres-serverless/tsconfig.build.json b/packages/3-extensions/prisma-postgres-serverless/tsconfig.build.json new file mode 100644 index 0000000000..671541c1a3 --- /dev/null +++ b/packages/3-extensions/prisma-postgres-serverless/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true + }, + "include": ["src/**/*.ts"], + "exclude": ["test", "dist"] +} diff --git a/packages/3-extensions/prisma-postgres-serverless/tsconfig.json b/packages/3-extensions/prisma-postgres-serverless/tsconfig.json new file mode 100644 index 0000000000..22b469d057 --- /dev/null +++ b/packages/3-extensions/prisma-postgres-serverless/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": ["@prisma-next/tsconfig/base"], + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + "lib": ["ES2022", "ESNext.Disposable"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["dist"] +} diff --git a/packages/3-extensions/prisma-postgres-serverless/tsconfig.prod.json b/packages/3-extensions/prisma-postgres-serverless/tsconfig.prod.json new file mode 100644 index 0000000000..b08d4c908a --- /dev/null +++ b/packages/3-extensions/prisma-postgres-serverless/tsconfig.prod.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": ["@prisma-next/tsconfig/prod"] +} diff --git a/packages/3-extensions/prisma-postgres-serverless/tsdown.config.ts b/packages/3-extensions/prisma-postgres-serverless/tsdown.config.ts new file mode 100644 index 0000000000..ab0d485a72 --- /dev/null +++ b/packages/3-extensions/prisma-postgres-serverless/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@prisma-next/tsdown'; + +export default defineConfig({ + entry: [ + 'src/exports/config.ts', + 'src/exports/contract-builder.ts', + 'src/exports/control.ts', + 'src/exports/family.ts', + 'src/exports/migration.ts', + 'src/exports/runtime.ts', + 'src/exports/target.ts', + ], +}); diff --git a/packages/3-extensions/prisma-postgres-serverless/vitest.config.ts b/packages/3-extensions/prisma-postgres-serverless/vitest.config.ts new file mode 100644 index 0000000000..86a962725b --- /dev/null +++ b/packages/3-extensions/prisma-postgres-serverless/vitest.config.ts @@ -0,0 +1,24 @@ +import { timeouts } from '@prisma-next/test-utils'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + testTimeout: timeouts.default, + hookTimeout: timeouts.default, + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: [ + 'dist/**', + 'test/**', + '**/*.test.ts', + '**/*.test-d.ts', + '**/*.config.ts', + '**/exports/**', + ], + reporter: ['text', 'json', 'html'], + }, + }, +}); diff --git a/packages/3-extensions/sql-orm-client/src/exports/index.ts b/packages/3-extensions/sql-orm-client/src/exports/index.ts index e8261172ee..183df23c78 100644 --- a/packages/3-extensions/sql-orm-client/src/exports/index.ts +++ b/packages/3-extensions/sql-orm-client/src/exports/index.ts @@ -2,7 +2,7 @@ export { Collection } from '../collection'; export { all, and, not, or } from '../filters'; export { GroupedCollection } from '../grouped-collection'; export { createModelAccessor } from '../model-accessor'; -export type { OrmOptions } from '../orm'; +export type { OrmClient, OrmOptions } from '../orm'; export { orm } from '../orm'; export type { AggregateBuilder, diff --git a/packages/3-extensions/sql-orm-client/src/orm.ts b/packages/3-extensions/sql-orm-client/src/orm.ts index 5e942d1bb2..03ae1dad80 100644 --- a/packages/3-extensions/sql-orm-client/src/orm.ts +++ b/packages/3-extensions/sql-orm-client/src/orm.ts @@ -48,9 +48,9 @@ type ModelCollectionMap< [K in ModelNames]: ModelCollection; }; -type OrmClient< +export type OrmClient< TContract extends Contract, - Collections extends Partial>, + Collections extends Partial> = Record, > = ModelCollectionMap; export function orm< diff --git a/packages/3-targets/7-drivers/ppg-serverless/README.md b/packages/3-targets/7-drivers/ppg-serverless/README.md new file mode 100644 index 0000000000..ab8bd3cf74 --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/README.md @@ -0,0 +1,163 @@ +# @prisma-next/driver-ppg-serverless + +Prisma Postgres (PPG) serverless driver for Prisma Next. WebSocket-only data-plane transport via the official `@prisma/ppg` client — no `pg`, no TCP, no `pg-cursor`, portable to edge runtimes that do not expose raw TCP sockets. + +## Package Classification + +- **Domain**: targets +- **Layer**: drivers +- **Planes**: runtime (`./runtime`), migration (`./control`) + +## Overview + +The PPG serverless driver implements the `SqlDriver` interface for Prisma Next, using `@prisma/ppg` as its sole transport. Every call goes through a `Client.newSession()` WebSocket session: top-level `execute` / `query` / `executePrepared` open a one-shot session per call, while `acquireConnection()` returns a long-lived session the caller can reuse across operations and an explicit `BEGIN` / `COMMIT` / `ROLLBACK` transaction. There is no connection pool at this layer — PPG handles pooling on the wire side. + +In Prisma Next, "driver" refers to the Prisma Next interface (not the underlying client library). Drivers are transport-agnostic from the framework's perspective: they own connection management and transport protocol (TCP, HTTP, WebSocket, …) but contain no dialect-specific logic. Dialect behaviour lives in adapters. Instantiation is separate from connection; `create()` returns an unbound driver, `connect(binding)` binds at the boundary ([ADR 159](../../../../docs/architecture%20docs/adrs/ADR%20159%20-%20Driver%20Terminology%20and%20Lifecycle.md)). + +This package reuses the existing `postgres` target and `postgres` adapter (same `familyId: 'sql'`, same `targetId: 'postgres'` as `@prisma-next/driver-postgres`). The runtime entry binds to `@prisma/ppg`; the control entry re-exports `@prisma-next/driver-postgres/control` so migrations / `dbInit` / `dbVerify` continue to run over a direct TCP connection (those are not edge workloads and do not need to be wire-compatible with PPG). + +## Purpose + +Provide a WebSocket-based PPG transport for Prisma Next that runs in edge and serverless environments where raw TCP is unavailable. + +## Responsibilities + +- **Session lifecycle**: open / close `@prisma/ppg` sessions; one per call for the top-level API, one per `acquireConnection()` for the long-lived API. +- **Statement execution**: execute SQL statements with parameters; collect rows or stream them via PPG's `CollectableIterator`. +- **Row hydration**: map PPG's positional `Row.values` into framework-shaped name-keyed records, and lift array-typed columns (`text[]`, `int4[]`, `jsonb[]`, …) into JS arrays at the driver boundary so the framework's adapter layer sees the same row shape it sees from `@prisma-next/driver-postgres`. +- **Transactions**: issue `BEGIN` / `COMMIT` / `ROLLBACK` on a long-lived session via `beginTransaction()`. +- **Error normalisation**: translate PPG's `DatabaseError` / `WebSocketError` / `ValidationError` into the same `SqlQueryError`-shaped surface that `driver-postgres` produces. + +**Non-goals:** + +- Dialect-specific SQL lowering (adapters). +- Query compilation (`sql-query`). +- Runtime execution orchestration (`sql-runtime`). +- TCP transport — served by `@prisma-next/driver-postgres`. +- Streaming cursors with explicit batch sizes — PPG's `CollectableIterator` streams row-by-row; the `pg-cursor`-style `cursor: { batchSize }` option from `driver-postgres` has no equivalent here. +- First-class prepared statements with explicit handles — PPG has no client-side `PREPARE` step (parameters are still safely parameterised). `executePrepared` collapses to `execute`; the `handle` argument is accepted for interface compatibility but never written. + +## Architecture + +```mermaid +flowchart TD + Caller[Caller: facade or createBoundDriverFromBinding] + Driver[PpgServerlessRuntimeDriver] + Resolve[Resolve binding kind url or ppgClient] + OwnClient[Construct @prisma/ppg Client from URL + withArrayParsers] + BorrowClient[Use caller-owned @prisma/ppg Client] + OneShot[client.newSession per call] + LongLived[client.newSession per acquireConnection] + PPG[Prisma Postgres service] + + Caller --> Driver + Driver --> Resolve + Resolve -->|url| OwnClient + Resolve -->|ppgClient| BorrowClient + OwnClient --> OneShot + OwnClient --> LongLived + BorrowClient --> OneShot + BorrowClient --> LongLived + OneShot -->|WebSocket| PPG + LongLived -->|WebSocket| PPG +``` + +## Usage + +The driver descriptor is the default export from `./runtime`. The `create()` method returns an unbound driver; `connect(binding)` binds it to a `@prisma/ppg` client. + +```typescript +import ppgServerlessDriver from '@prisma-next/driver-ppg-serverless/runtime'; + +const driver = ppgServerlessDriver.create(); +await driver.connect({ + kind: 'url', + url: process.env['PPG_URL']!, +}); + +// driver is now bound; use acquireConnection, execute, query, etc. +``` + +### Binding variants + +```typescript +import { client as createPpgClient, defaultClientConfig } from '@prisma/ppg'; +import ppgServerlessDriver, { + withArrayParsers, +} from '@prisma-next/driver-ppg-serverless/runtime'; + +// (a) URL binding — the driver constructs and owns the PPG client. +// Array-OID parsers are registered automatically. +await driver.connect({ kind: 'url', url: 'postgres://identifier:key@db.prisma.io:5432/postgres?sslmode=require' }); + +// (b) ppgClient binding — the caller owns the client and its lifecycle. +// Wire array parsers into the client config yourself if you read +// array-typed columns (text[], uuid[], int4[], jsonb[], …). +const config = defaultClientConfig('postgres://identifier:key@db.prisma.io:5432/postgres?sslmode=require'); +const ppgClient = createPpgClient({ + ...config, + parsers: withArrayParsers(config.parsers ?? []), +}); +await driver.connect({ kind: 'ppgClient', client: ppgClient }); +``` + +The PPG-compatible URL form is `postgres://identifier:key@db.prisma.io:5432/postgres?sslmode=require`. The `prisma+postgres://accelerate.prisma-data.net/?api_key=…` form returned by Prisma Accelerate / data-proxy is **not** a PPG URL — it carries a different wire protocol (GraphQL over HTTPS) and is rejected by `@prisma/ppg`'s own connection-string parser. If you provision via the Prisma Data Platform Management API, use `endpoints.pooled.connectionString` for the PPG data plane. + +### Runtime environments + +The driver and its dependencies (`@prisma/ppg`, `postgres-array`) use only `fetch` and `WebSocket` at runtime. Tested under: + +- Node.js 20+ +- Cloudflare Workers +- Vercel Edge Functions +- Deno / Deno Deploy +- Bun (Node + edge) + +## Components + +### Driver runtime (`src/ppg-driver.ts`) + +- `PpgServerlessBoundDriverImpl` — the bound driver. Owns the `@prisma/ppg` `Client` (when the binding was `{ kind: 'url' }`) or borrows it (when the binding was `{ kind: 'ppgClient' }`). +- `PpgServerlessSessionConnection` — the long-lived session opened by `acquireConnection()`. +- `PpgServerlessSessionTransaction` — wraps a session inside `BEGIN` / `COMMIT` / `ROLLBACK`. +- `createBoundDriverFromBinding(binding)` — exported for facade composition; resolves a `PpgBinding` into a bound driver, wiring `withArrayParsers` when constructing the client from a URL. + +### Array-OID parsers (`src/core/array-parsers.ts`) + +`@prisma/ppg`'s `defaultClientConfig` ships parsers for scalar OIDs only (bool, int*, float*, text/varchar, json/jsonb). Without extension, array-typed columns surface as the raw Postgres text-format string (`'{a,b,c}'`) — but the framework's adapter layer assumes the driver hydrates `text[]` as JS arrays, matching `pg`'s native behaviour. `withArrayParsers` lifts a scalar-only parser table into one that also handles the array variants (`_bool`, `_int2`, `_int4`, `_int8`, `_float4`, `_float8`, `_text`, `_varchar`, `_json`, `_jsonb`). The driver wires it automatically for `{ kind: 'url' }` bindings; users supplying their own `Client` (the `ppgClient` binding) opt in by calling the exported helper. + +### Error normalisation (`src/normalize-error.ts`) + +Translates PPG's three error classes into framework `SqlQueryError` subclasses. Same surface that `@prisma-next/driver-postgres` produces, so middleware and user error handling can branch on error kind, not on driver. + +### Descriptor metadata (`src/core/descriptor-meta.ts`) + +Exports `ppgServerlessDriverDescriptorMeta` with `kind: 'driver'`, `familyId: 'sql'`, `targetId: 'postgres'`, `id: 'ppg-serverless'`. + +## Dependencies + +- **`@prisma/ppg`**: Prisma Postgres WebSocket client (pinned in the workspace catalog at `1.0.1`). +- **`postgres-array`**: Postgres array-text-format decoder (pinned at `2.0.0`; pure-JS, edge-safe). +- **`@prisma-next/driver-postgres`**: re-exported by `./control` for the migration plane. +- **`@prisma-next/framework-components`**: Driver descriptor + instance types. +- **`@prisma-next/sql-relational-core`**: `SqlDriver` interface. +- **`@prisma-next/sql-contract`**, **`@prisma-next/sql-errors`**, **`@prisma-next/sql-operations`**, **`@prisma-next/contract`**, **`@prisma-next/errors`**, **`@prisma-next/utils`**: standard SQL-driver dependencies. + +## Related Subsystems + +- **[Adapters & Targets](../../../../docs/architecture%20docs/subsystems/5.%20Adapters%20%26%20Targets.md)**: Driver specification. + +## Related ADRs + +- [ADR 159 — Driver Terminology and Lifecycle](../../../../docs/architecture%20docs/adrs/ADR%20159%20-%20Driver%20Terminology%20and%20Lifecycle.md) +- [ADR 005 — Thin Core Fat Targets](../../../../docs/architecture%20docs/adrs/ADR%20005%20-%20Thin%20Core%20Fat%20Targets.md) +- [ADR 016 — Adapter SPI for Lowering](../../../../docs/architecture%20docs/adrs/ADR%20016%20-%20Adapter%20SPI%20for%20Lowering.md) + +## Exports + +- `./runtime` — Runtime entry point. + - Default: `ppgServerlessRuntimeDriverDescriptor`. Use `create()` for an unbound driver, then `connect(binding)`. + - Named: `createBoundDriverFromBinding`, `withArrayParsers`. + - Types: `PpgBinding`, `PpgServerlessDriverCreateOptions`, `PpgServerlessRuntimeDriver`. +- `./control` — Migration-plane entry point. + - Re-exports `@prisma-next/driver-postgres/control` so consumers can drive migrations through a single import surface alongside the data-plane runtime. Pulls `pg` into the install graph but never into the runtime bundle; bundlers tree-shake the unimported control re-export. diff --git a/packages/3-targets/7-drivers/ppg-serverless/biome.jsonc b/packages/3-targets/7-drivers/ppg-serverless/biome.jsonc new file mode 100644 index 0000000000..6e06bcc87c --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/biome.jsonc @@ -0,0 +1,4 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "extends": "//" +} diff --git a/packages/3-targets/7-drivers/ppg-serverless/package.json b/packages/3-targets/7-drivers/ppg-serverless/package.json new file mode 100644 index 0000000000..5196c40010 --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/package.json @@ -0,0 +1,61 @@ +{ + "name": "@prisma-next/driver-ppg-serverless", + "version": "0.12.0", + "license": "Apache-2.0", + "type": "module", + "sideEffects": false, + "scripts": { + "build": "tsdown", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc --project tsconfig.json --noEmit", + "lint": "biome check . --error-on-warnings", + "lint:fix": "biome check --write .", + "lint:fix:unsafe": "biome check --write --unsafe .", + "clean": "rm -rf dist dist-tsc dist-tsc-prod coverage .tmp-output" + }, + "dependencies": { + "@prisma-next/contract": "workspace:0.12.0", + "@prisma-next/driver-postgres": "workspace:0.12.0", + "@prisma-next/errors": "workspace:0.12.0", + "@prisma-next/framework-components": "workspace:0.12.0", + "@prisma-next/sql-contract": "workspace:0.12.0", + "@prisma-next/sql-errors": "workspace:0.12.0", + "@prisma-next/sql-operations": "workspace:0.12.0", + "@prisma-next/sql-relational-core": "workspace:0.12.0", + "@prisma-next/utils": "workspace:0.12.0", + "@prisma/ppg": "catalog:", + "arktype": "^2.2.0", + "postgres-array": "catalog:" + }, + "devDependencies": { + "@prisma-next/test-utils": "workspace:0.12.0", + "@prisma-next/tsconfig": "workspace:0.12.0", + "@prisma-next/tsdown": "workspace:0.12.0", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "typescript": ">=5.9" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, + "files": [ + "dist", + "src" + ], + "exports": { + "./control": "./dist/control.mjs", + "./runtime": "./dist/runtime.mjs", + "./package.json": "./package.json" + }, + "repository": { + "type": "git", + "url": "https://github.com/prisma/prisma-next.git", + "directory": "packages/3-targets/7-drivers/ppg-serverless" + } +} diff --git a/packages/3-targets/7-drivers/ppg-serverless/src/core/array-parsers.ts b/packages/3-targets/7-drivers/ppg-serverless/src/core/array-parsers.ts new file mode 100644 index 0000000000..df3ea63a45 --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/src/core/array-parsers.ts @@ -0,0 +1,52 @@ +import type { ValueParser } from '@prisma/ppg'; +import * as postgresArray from 'postgres-array'; + +// `[array OID, element OID]` for the scalars `defaultClientConfig` already +// parses. Mirrors `pg`'s built-in array decoder set so `text[]` / `int4[]` / +// `jsonb[]` etc. land as JS arrays at the framework adapter, not as the raw +// Postgres text form `'{a,b,c}'`. +const ARRAY_OID_TO_ELEMENT_OID: ReadonlyMap = new Map([ + [1000, 16], // _bool -> bool + [1005, 21], // _int2 -> int2 + [1007, 23], // _int4 -> int4 + [1016, 20], // _int8 -> int8 + [1021, 700], // _float4 -> float4 + [1022, 701], // _float8 -> float8 + [1009, 25], // _text -> text + [1015, 1043], // _varchar -> varchar + [199, 114], // _json -> json + [3807, 3802], // _jsonb -> jsonb +]); + +/** + * Extend a `ValueParser` table with array variants for the scalar OIDs above. + * Scalars without a known array counterpart, and array OIDs whose element + * parser is missing from `parsers`, are silently skipped. + */ +export function withArrayParsers( + parsers: ReadonlyArray>, +): ValueParser[] { + const byOid = new Map>(); + for (const parser of parsers) { + byOid.set(parser.oid, parser); + } + + const arrayParsers: ValueParser[] = []; + for (const [arrayOid, elementOid] of ARRAY_OID_TO_ELEMENT_OID) { + const elementParser = byOid.get(elementOid); + if (elementParser === undefined) { + continue; + } + arrayParsers.push({ + oid: arrayOid, + parse: (value: string | null) => { + if (value === null) { + return null; + } + return postgresArray.parse(value, (element: string) => elementParser.parse(element)); + }, + }); + } + + return [...parsers, ...arrayParsers]; +} diff --git a/packages/3-targets/7-drivers/ppg-serverless/src/core/descriptor-meta.ts b/packages/3-targets/7-drivers/ppg-serverless/src/core/descriptor-meta.ts new file mode 100644 index 0000000000..2e047cd4d4 --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/src/core/descriptor-meta.ts @@ -0,0 +1,8 @@ +export const ppgServerlessDriverDescriptorMeta = { + kind: 'driver', + familyId: 'sql', + targetId: 'postgres', + id: 'ppg-serverless', + version: '0.0.1', + capabilities: {}, +} as const; diff --git a/packages/3-targets/7-drivers/ppg-serverless/src/core/row-mapper.ts b/packages/3-targets/7-drivers/ppg-serverless/src/core/row-mapper.ts new file mode 100644 index 0000000000..b2d68d2ebf --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/src/core/row-mapper.ts @@ -0,0 +1,23 @@ +import { blindCast } from '@prisma-next/utils/casts'; + +/** + * Recombine PPG's positional `Row.values` with the resultset's `columns` + * into a name-keyed record (the row shape the framework expects). + */ +export function mapRowToRecord>( + ppgRow: { readonly values: readonly unknown[] }, + columns: ReadonlyArray<{ readonly name: string }>, +): Row { + const record: Record = {}; + for (let i = 0; i < columns.length; i++) { + const column = columns[i]; + if (column === undefined) { + continue; + } + record[column.name] = ppgRow.values[i]; + } + return blindCast< + Row, + 'shape-only reassembly from positional ppg Row.values into name-keyed Record; values stay unknown at runtime, only the record-vs-array dimension changes, and the caller-supplied Row parameter is by convention the row schema they expect this query to return' + >(record); +} diff --git a/packages/3-targets/7-drivers/ppg-serverless/src/exports/control.ts b/packages/3-targets/7-drivers/ppg-serverless/src/exports/control.ts new file mode 100644 index 0000000000..32773d3376 --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/src/exports/control.ts @@ -0,0 +1,2 @@ +export * from '@prisma-next/driver-postgres/control'; +export { default } from '@prisma-next/driver-postgres/control'; diff --git a/packages/3-targets/7-drivers/ppg-serverless/src/exports/runtime.ts b/packages/3-targets/7-drivers/ppg-serverless/src/exports/runtime.ts new file mode 100644 index 0000000000..e1b5e84531 --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/src/exports/runtime.ts @@ -0,0 +1,185 @@ +import type { + RuntimeDriverDescriptor, + RuntimeDriverInstance, +} from '@prisma-next/framework-components/execution'; +import type { + PreparedExecuteRequest, + SqlConnection, + SqlDriver, + SqlDriverState, + SqlExecuteRequest, + SqlExplainResult, + SqlQueryResult, +} from '@prisma-next/sql-relational-core/ast'; +import { blindCast } from '@prisma-next/utils/casts'; +import { ppgServerlessDriverDescriptorMeta } from '../core/descriptor-meta'; +import { + createBoundDriverFromBinding, + type PpgBinding, + type PpgServerlessDriverCreateOptions, +} from '../ppg-driver'; + +export type PpgServerlessRuntimeDriver = RuntimeDriverInstance<'sql', 'postgres'> & + SqlDriver; + +const USE_BEFORE_CONNECT_MESSAGE = + 'driver-ppg-serverless: driver not connected. Call connect(binding) before acquireConnection or execute.'; +const ALREADY_CONNECTED_MESSAGE = + 'driver-ppg-serverless: driver already connected. Call close() before reconnecting with a new binding.'; + +interface DriverRuntimeError extends Error { + readonly code: + | 'DRIVER.NOT_CONNECTED' + | 'DRIVER.ALREADY_CONNECTED' + | 'DRIVER.EXPLAIN_NOT_SUPPORTED'; + readonly category: 'RUNTIME'; + readonly severity: 'error'; + readonly details?: Record; +} + +function driverError( + code: DriverRuntimeError['code'], + message: string, + details?: Record, +): DriverRuntimeError { + const error = blindCast< + DriverRuntimeError, + 'augmenting a fresh Error with code / category / severity / details properties below; the assertion only widens the in-construction value so Object.assign can populate the readonly fields without TS losing track of them' + >(new Error(message)); + Object.defineProperty(error, 'name', { + value: 'RuntimeError', + configurable: true, + }); + return Object.assign(error, { + code, + category: 'RUNTIME' as const, + severity: 'error' as const, + message, + details, + }); +} + +function unboundExecute(): AsyncIterable { + return { + [Symbol.asyncIterator]() { + return { + async next(): Promise> { + throw driverError('DRIVER.NOT_CONNECTED', USE_BEFORE_CONNECT_MESSAGE); + }, + }; + }, + }; +} + +/** + * Public unbound wrapper. Lifecycle: + * unbound → connect(binding) → connected → close() → closed (reconnectable). + */ +class PpgServerlessUnboundDriverImpl implements PpgServerlessRuntimeDriver { + readonly familyId = 'sql' as const; + readonly targetId = 'postgres' as const; + + #delegate: SqlDriver | null = null; + #closed = false; + readonly #options: PpgServerlessDriverCreateOptions | undefined; + + constructor(options?: PpgServerlessDriverCreateOptions) { + this.#options = options; + } + + get state(): SqlDriverState { + if (this.#delegate !== null) { + return 'connected'; + } + if (this.#closed) { + return 'closed'; + } + return 'unbound'; + } + + #requireDelegate(): SqlDriver { + const delegate = this.#delegate; + if (delegate === null) { + throw driverError('DRIVER.NOT_CONNECTED', USE_BEFORE_CONNECT_MESSAGE); + } + return delegate; + } + + async connect(binding: PpgBinding): Promise { + if (this.#delegate !== null) { + throw driverError('DRIVER.ALREADY_CONNECTED', ALREADY_CONNECTED_MESSAGE, { + bindingKind: binding.kind, + }); + } + this.#delegate = createBoundDriverFromBinding(binding, this.#options); + this.#closed = false; + } + + async acquireConnection(): Promise { + const delegate = this.#requireDelegate(); + return delegate.acquireConnection(); + } + + async close(): Promise { + const delegate = this.#delegate; + if (delegate !== null) { + this.#delegate = null; + await delegate.close(); + } + this.#closed = true; + } + + execute>(request: SqlExecuteRequest): AsyncIterable { + const delegate = this.#delegate; + if (delegate === null) { + return unboundExecute(); + } + return delegate.execute(request); + } + + executePrepared>( + request: PreparedExecuteRequest, + ): AsyncIterable { + const delegate = this.#delegate; + if (delegate === null) { + return unboundExecute(); + } + return delegate.executePrepared(request); + } + + async query>( + sql: string, + params?: readonly unknown[], + ): Promise> { + const delegate = this.#requireDelegate(); + return delegate.query(sql, params); + } + + async explain(request: SqlExecuteRequest): Promise { + const delegate = this.#requireDelegate(); + if (delegate.explain === undefined) { + throw driverError( + 'DRIVER.EXPLAIN_NOT_SUPPORTED', + 'driver-ppg-serverless: explain is not supported by this driver.', + ); + } + return delegate.explain(request); + } +} + +const ppgServerlessRuntimeDriverDescriptor: RuntimeDriverDescriptor< + 'sql', + 'postgres', + PpgServerlessDriverCreateOptions, + PpgServerlessRuntimeDriver +> = { + ...ppgServerlessDriverDescriptorMeta, + create(options?: PpgServerlessDriverCreateOptions): PpgServerlessRuntimeDriver { + return new PpgServerlessUnboundDriverImpl(options); + }, +}; + +export default ppgServerlessRuntimeDriverDescriptor; +export { withArrayParsers } from '../core/array-parsers'; +export type { PpgBinding, PpgServerlessDriverCreateOptions } from '../ppg-driver'; +export { createBoundDriverFromBinding } from '../ppg-driver'; diff --git a/packages/3-targets/7-drivers/ppg-serverless/src/normalize-error.ts b/packages/3-targets/7-drivers/ppg-serverless/src/normalize-error.ts new file mode 100644 index 0000000000..733deeab17 --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/src/normalize-error.ts @@ -0,0 +1,76 @@ +import { DatabaseError, HttpResponseError, ValidationError, WebSocketError } from '@prisma/ppg'; +import { SqlConnectionError, SqlQueryError } from '@prisma-next/sql-errors'; + +/** + * Translate `@prisma/ppg` errors into the shared `SqlQueryError` / + * `SqlConnectionError` vocabulary. PPG-specific shapes worth noting: + * `DatabaseError` carries the Postgres `constraint` / `table` / `column` / + * `detail` fields under `error.details` (not on the top-level object the way + * `pg` exposes them); `ValidationError` (e.g. malformed connection string) + * passes through unwrapped so the actionable shape stays visible to callers. + */ +export function normalizePpgError(error: unknown): SqlQueryError | SqlConnectionError | Error { + if (error instanceof DatabaseError) { + const options: { + cause: Error; + sqlState: string; + constraint?: string; + table?: string; + column?: string; + detail?: string; + } = { + cause: error, + sqlState: error.code, + }; + const constraint = error.details['constraint']; + if (constraint !== undefined) options.constraint = constraint; + const table = error.details['table']; + if (table !== undefined) options.table = table; + const column = error.details['column']; + if (column !== undefined) options.column = column; + const detail = error.details['detail']; + if (detail !== undefined) options.detail = detail; + return new SqlQueryError(error.message, options); + } + + if (error instanceof WebSocketError) { + return new SqlConnectionError(error.message, { + cause: error, + transient: isTransientWebSocketClosure(error.closureCode), + }); + } + + if (error instanceof HttpResponseError) { + return new SqlConnectionError(error.message, { + cause: error, + transient: error.status >= 500, + }); + } + + if (error instanceof ValidationError) { + return error; + } + + if (error instanceof Error) { + return error; + } + + return new Error(String(error)); +} + +// Per RFC 6455: only server-side / temporary closure codes are retryable. +// Protocol / policy / data-shape codes (1002, 1003, 1007, 1008, 1009, 1010) +// won't succeed on retry without the caller changing something, so they are +// non-transient. Unknown / missing codes are conservatively non-transient. +function isTransientWebSocketClosure(code: number | undefined): boolean { + switch (code) { + case 1006: // abnormal closure + case 1011: // internal server error + case 1012: // service restart + case 1013: // try again later + case 1014: // bad gateway + return true; + default: + return false; + } +} diff --git a/packages/3-targets/7-drivers/ppg-serverless/src/ppg-driver.ts b/packages/3-targets/7-drivers/ppg-serverless/src/ppg-driver.ts new file mode 100644 index 0000000000..8859eb36ea --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/src/ppg-driver.ts @@ -0,0 +1,294 @@ +import type { Client, Session } from '@prisma/ppg'; +import { client, defaultClientConfig } from '@prisma/ppg'; +import type { + PreparedExecuteRequest, + SqlConnection, + SqlDriver, + SqlDriverState, + SqlExecuteRequest, + SqlQueryable, + SqlQueryResult, + SqlTransaction, +} from '@prisma-next/sql-relational-core/ast'; +import { blindCast } from '@prisma-next/utils/casts'; +import { withArrayParsers } from './core/array-parsers'; +import { mapRowToRecord } from './core/row-mapper'; +import { normalizePpgError } from './normalize-error'; + +export type PpgBinding = + | { readonly kind: 'url'; readonly url: string } + | { readonly kind: 'ppgClient'; readonly client: Client }; + +/** + * Reserved for a future codec-customisation hook. `descriptor.create` keeps + * its option-bag arity so adding a field later does not break callers. + */ +export type PpgServerlessDriverCreateOptions = { + readonly _reservedForFutureCodecCustomisation?: never; +}; + +interface DriverRuntimeError extends Error { + readonly code: 'DRIVER.CLOSED' | 'DRIVER.CONNECTION_RELEASED'; + readonly category: 'RUNTIME'; + readonly severity: 'error'; +} + +function driverError(code: DriverRuntimeError['code'], message: string): DriverRuntimeError { + const error = blindCast< + DriverRuntimeError, + 'augmenting a fresh Error with code / category / severity properties below; the assertion only widens the in-construction value so Object.assign can populate the readonly fields without TS losing track of them' + >(new Error(message)); + Object.defineProperty(error, 'name', { + value: 'RuntimeError', + configurable: true, + }); + return Object.assign(error, { + code, + category: 'RUNTIME' as const, + severity: 'error' as const, + }); +} + +const CLOSED_MESSAGE = + 'driver-ppg-serverless: driver is closed. Reconnect with connect(binding) before issuing further calls.'; + +const RELEASED_MESSAGE = + 'driver-ppg-serverless: connection has been released; acquire a new connection before issuing further queries.'; + +/** + * Shared substrate for the three queryable kinds (bound driver, long-lived + * connection, transaction). Subclasses override `acquireSession` / + * `releaseSession` to decide whether each call opens a fresh PPG session or + * reuses a held one; the row-mapping + error-normalisation + iterator-cleanup + * boilerplate lives here once. + */ +abstract class PpgServerlessQueryable implements SqlQueryable { + protected abstract acquireSession(): Promise; + protected abstract releaseSession(session: Session): Promise; + + execute>(request: SqlExecuteRequest): AsyncIterable { + return this.#executeStreaming(request.sql, request.params); + } + + executePrepared>( + request: PreparedExecuteRequest, + ): AsyncIterable { + // PPG has no client-side PREPARE; params are still parameterised on the + // wire. The SPI's `handle` cache slot is accepted but unused. + return this.#executeStreaming(request.sql, request.params); + } + + async query>( + sql: string, + params?: readonly unknown[], + ): Promise> { + const session = await this.acquireSession(); + try { + const resultset = await session.query(sql, ...(params ?? [])); + const ppgRows = await resultset.rows.collect(); + const rows = ppgRows.map((ppgRow) => mapRowToRecord(ppgRow, resultset.columns)); + return { rows, rowCount: rows.length }; + } catch (err) { + throw normalizePpgError(err); + } finally { + await this.releaseSession(session); + } + } + + async *#executeStreaming( + sql: string, + params: readonly unknown[] | undefined, + ): AsyncIterable { + const session = await this.acquireSession(); + try { + const resultset = await session.query(sql, ...(params ?? [])); + for await (const ppgRow of resultset.rows) { + yield mapRowToRecord(ppgRow, resultset.columns); + } + } catch (err) { + throw normalizePpgError(err); + } finally { + await this.releaseSession(session); + } + } +} + +/** + * Bound `SqlDriver`. Top-level calls open a fresh WebSocket + * session per call (PPG has no stateless HTTP path here); `acquireConnection` + * returns a long-lived session callers can hold across statements + a + * transaction. + */ +class PpgServerlessBoundDriverImpl extends PpgServerlessQueryable implements SqlDriver { + readonly familyId = 'sql' as const; + readonly targetId = 'postgres' as const; + + readonly #client: Client; + #closed = false; + + constructor(ppgClient: Client) { + super(); + this.#client = ppgClient; + } + + get state(): SqlDriverState { + return this.#closed ? 'closed' : 'connected'; + } + + async connect(_binding: PpgBinding): Promise { + throw new Error( + 'driver-ppg-serverless: PpgServerlessBoundDriverImpl is constructed already-bound; call connect() on the unbound wrapper instead.', + ); + } + + async acquireConnection(): Promise { + if (this.#closed) { + throw driverError('DRIVER.CLOSED', CLOSED_MESSAGE); + } + const session = await this.#client.newSession(); + return new PpgServerlessSessionConnection(session); + } + + async close(): Promise { + // PPG's `Client` has no `.close()` — only sessions do. The state flip + // short-circuits future `acquireConnection` / `acquireSession` calls; + // already-acquired connections / transactions hold their own sessions + // until the caller releases them. + this.#closed = true; + } + + protected override async acquireSession(): Promise { + if (this.#closed) { + throw driverError('DRIVER.CLOSED', CLOSED_MESSAGE); + } + return this.#client.newSession(); + } + + protected override async releaseSession(session: Session): Promise { + session.close(); + } +} + +/** + * Long-lived `SqlConnection` over a single held PPG session. The transaction + * subclass shares the same session so the `BEGIN` / statements / `COMMIT` + * sequence stays on one transport. + */ +class PpgServerlessSessionConnection extends PpgServerlessQueryable implements SqlConnection { + readonly #session: Session; + #released = false; + + constructor(session: Session) { + super(); + this.#session = session; + } + + protected override acquireSession(): Promise { + if (this.#released) { + throw driverError('DRIVER.CONNECTION_RELEASED', RELEASED_MESSAGE); + } + return Promise.resolve(this.#session); + } + + protected override releaseSession(_session: Session): Promise { + return Promise.resolve(); + } + + async beginTransaction(): Promise { + if (this.#released) { + throw driverError('DRIVER.CONNECTION_RELEASED', RELEASED_MESSAGE); + } + try { + await this.#session.query('BEGIN'); + } catch (err) { + throw normalizePpgError(err); + } + return new PpgServerlessSessionTransaction(this.#session); + } + + async release(): Promise { + if (this.#released) { + return; + } + this.#released = true; + this.#session.close(); + } + + async destroy(_reason?: unknown): Promise { + // PPG's `Session.close()` has no clean-release vs forced-eviction + // distinction, so `destroy` and `release` are the same teardown. + if (this.#released) { + return; + } + this.#released = true; + this.#session.close(); + } +} + +/** + * `SqlTransaction` sharing the connection's PPG session. Does not close the + * session on `commit` / `rollback` — the connection is free to issue further + * statements (or open another transaction) afterwards. + */ +class PpgServerlessSessionTransaction extends PpgServerlessQueryable implements SqlTransaction { + readonly #session: Session; + + constructor(session: Session) { + super(); + this.#session = session; + } + + protected override acquireSession(): Promise { + return Promise.resolve(this.#session); + } + + protected override releaseSession(_session: Session): Promise { + return Promise.resolve(); + } + + async commit(): Promise { + try { + await this.#session.query('COMMIT'); + } catch (err) { + throw normalizePpgError(err); + } + } + + async rollback(): Promise { + try { + await this.#session.query('ROLLBACK'); + } catch (err) { + throw normalizePpgError(err); + } + } +} + +export function createBoundDriverFromBinding( + binding: PpgBinding, + _options?: PpgServerlessDriverCreateOptions, +): PpgServerlessBoundDriverImpl { + switch (binding.kind) { + case 'url': { + // Framework adapter expects `text[]` etc. as JS arrays (matching `pg`'s + // native hydration); PPG's `defaultClientConfig` parsers are scalar-only, + // so extend before constructing. User-owned clients opt in via the + // exported `withArrayParsers`. + const config = defaultClientConfig(binding.url); + const ppgClient = client({ + ...config, + /* v8 ignore next — `defaultClientConfig` always populates `parsers`; the `?? []` is a defensive fallback for the optional type only. */ + parsers: withArrayParsers(config.parsers ?? []), + }); + return new PpgServerlessBoundDriverImpl(ppgClient); + } + case 'ppgClient': { + return new PpgServerlessBoundDriverImpl(binding.client); + } + } +} + +export type { + PpgServerlessBoundDriverImpl, + PpgServerlessSessionConnection, + PpgServerlessSessionTransaction, +}; diff --git a/packages/3-targets/7-drivers/ppg-serverless/test/_fakes.ts b/packages/3-targets/7-drivers/ppg-serverless/test/_fakes.ts new file mode 100644 index 0000000000..099c365e89 --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/test/_fakes.ts @@ -0,0 +1,132 @@ +/** + * Hand-built `@prisma/ppg` fakes passed to the driver via the `{ ppgClient }` + * binding so the driver lifecycle runs without a WebSocket server or a + * `vi.mock` on the package. + */ +import type { Column, Client as PpgClient, Resultset, Row, Session } from '@prisma/ppg'; + +export interface ResultsetSpec { + readonly columns: ReadonlyArray; + readonly rows: ReadonlyArray; +} + +export type QueryHandler = ( + sql: string, + params: readonly unknown[], +) => ResultsetSpec | Promise | Error | Promise; + +/** Returns empty resultsets for `BEGIN` / `COMMIT` / `ROLLBACK`; defers other SQL. */ +export function withTxnControlStatements( + inner: QueryHandler = () => ({ columns: [], rows: [] }), +): QueryHandler { + return (sql, params) => { + const head = sql.trim().slice(0, 8).toUpperCase(); + if (head.startsWith('BEGIN') || head.startsWith('COMMIT') || head.startsWith('ROLLBACK')) { + return { columns: [], rows: [] }; + } + return inner(sql, params); + }; +} + +export interface FakeClientControls { + readonly client: PpgClient; + readonly newSessionCalls: () => number; + readonly queryCalls: () => Array<{ sql: string; params: readonly unknown[] }>; + readonly sessionCloseCalls: () => number; + /** Alias for `queryCalls`. */ + readonly sessionQueryHistory: () => Array<{ sql: string; params: readonly unknown[] }>; + /** Alias for `sessionCloseCalls`. */ + readonly closeCount: () => number; +} + +export function makeFakeClient(handler: QueryHandler): FakeClientControls { + let newSessionCount = 0; + let sessionCloseCount = 0; + const queryCalls: Array<{ sql: string; params: readonly unknown[] }> = []; + + const newSession = async (): Promise => { + newSessionCount++; + let active = true; + const session: Session = { + query: async (sql: string, ...params: unknown[]): Promise => { + queryCalls.push({ sql, params }); + const out = await handler(sql, params); + if (out instanceof Error) { + throw out; + } + return makeResultset(out); + }, + exec: async (_sql: string, ..._params: unknown[]): Promise => { + throw new Error('fake-client: exec not implemented for tests'); + }, + close: () => { + sessionCloseCount++; + active = false; + }, + get active() { + return active; + }, + [Symbol.dispose]() { + this.close(); + }, + }; + return session; + }; + + const client: PpgClient = { + newSession, + query: async (_sql: string, ..._params: unknown[]) => { + throw new Error('fake-client: top-level query not used by the driver'); + }, + exec: async (_sql: string, ..._params: unknown[]) => { + throw new Error('fake-client: top-level exec not used by the driver'); + }, + }; + + return { + client, + newSessionCalls: () => newSessionCount, + queryCalls: () => queryCalls, + sessionCloseCalls: () => sessionCloseCount, + sessionQueryHistory: () => queryCalls, + closeCount: () => sessionCloseCount, + }; +} + +function makeResultset(spec: ResultsetSpec): Resultset { + const rows = [...spec.rows]; + let i = 0; + const iter = { + async next(): Promise> { + if (i < rows.length) { + const value = rows[i++] as Row; + return { value, done: false }; + } + return { value: undefined, done: true }; + }, + async return(): Promise> { + i = rows.length; + return { value: undefined, done: true }; + }, + async collect(): Promise { + const remaining = rows.slice(i); + i = rows.length; + return remaining; + }, + [Symbol.asyncIterator]() { + return iter; + }, + }; + return { + columns: [...spec.columns], + rows: iter as unknown as Resultset['rows'], + }; +} + +export function col(name: string, oid = 25): Column { + return { name, oid }; +} + +export function row(...values: unknown[]): Row { + return { values }; +} diff --git a/packages/3-targets/7-drivers/ppg-serverless/test/array-parsers.test.ts b/packages/3-targets/7-drivers/ppg-serverless/test/array-parsers.test.ts new file mode 100644 index 0000000000..7cb2b1e6fd --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/test/array-parsers.test.ts @@ -0,0 +1,87 @@ +import type { ValueParser } from '@prisma/ppg'; +import { describe, expect, it } from 'vitest'; +import { withArrayParsers } from '../src/core/array-parsers'; + +// Element parsers mirroring the subset of `@prisma/ppg`'s default scalar +// parsers that this test cares about. Kept inline so the tests do not +// depend on PPG runtime internals — `withArrayParsers` only reads the +// `.parse` function via the published `ValueParser` contract. +const textParser: ValueParser = { oid: 25, parse: (v) => v }; +const int4Parser: ValueParser = { + oid: 23, + parse: (v) => (v === null ? null : Number.parseInt(v, 10)), +}; +const boolParser: ValueParser = { + oid: 16, + parse: (v) => v === 't', +}; + +function lookup(parsers: ReadonlyArray>, oid: number): ValueParser { + const parser = parsers.find((p) => p.oid === oid); + if (!parser) throw new Error(`expected parser for oid ${oid}`); + return parser; +} + +describe('withArrayParsers', () => { + it('preserves the original scalar parsers', () => { + const extended = withArrayParsers([textParser, int4Parser, boolParser]); + expect(extended).toEqual(expect.arrayContaining([textParser, int4Parser, boolParser])); + }); + + it('appends an array parser for each scalar with a known array OID', () => { + const extended = withArrayParsers([textParser, int4Parser, boolParser]); + expect(extended.map((p) => p.oid)).toEqual( + expect.arrayContaining([ + 25, + 23, + 16, // originals + 1009, + 1007, + 1000, // text[], int4[], bool[] + ]), + ); + }); + + it('skips array OIDs whose element parser is missing', () => { + const extended = withArrayParsers([textParser]); // no int4 / bool scalar + const oids = extended.map((p) => p.oid); + expect(oids).toContain(1009); // text[] still added + expect(oids).not.toContain(1007); // int4[] skipped (no element parser) + expect(oids).not.toContain(1000); // bool[] skipped (no element parser) + }); + + it('decodes a simple text[] (`{a,b,c}` -> ["a","b","c"])', () => { + const arrParser = lookup(withArrayParsers([textParser]), 1009); + expect(arrParser.parse('{a,b,c}')).toEqual(['a', 'b', 'c']); + }); + + it('decodes an empty text[] (`{}` -> [])', () => { + const arrParser = lookup(withArrayParsers([textParser]), 1009); + expect(arrParser.parse('{}')).toEqual([]); + }); + + it('surfaces NULL elements as JS null', () => { + const arrParser = lookup(withArrayParsers([textParser]), 1009); + expect(arrParser.parse('{a,NULL,b}')).toEqual(['a', null, 'b']); + }); + + it('decodes quoted elements containing the delimiter', () => { + const arrParser = lookup(withArrayParsers([textParser]), 1009); + expect(arrParser.parse('{"hello, world","a"}')).toEqual(['hello, world', 'a']); + }); + + it('applies the element parser to each entry (int4[] -> number[])', () => { + const arrParser = lookup(withArrayParsers([int4Parser]), 1007); + expect(arrParser.parse('{1,2,3}')).toEqual([1, 2, 3]); + }); + + it('applies the element parser per entry (bool[] -> boolean[])', () => { + const arrParser = lookup(withArrayParsers([boolParser]), 1000); + expect(arrParser.parse('{t,f,t}')).toEqual([true, false, true]); + }); + + it('passes a NULL column value through as JS null', () => { + const arrParser = lookup(withArrayParsers([textParser]), 1009); + expect(arrParser.parse(null)).toBeNull(); + }); +}); diff --git a/packages/3-targets/7-drivers/ppg-serverless/test/driver.basic.test.ts b/packages/3-targets/7-drivers/ppg-serverless/test/driver.basic.test.ts new file mode 100644 index 0000000000..9c152c6b26 --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/test/driver.basic.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest'; +import ppgServerlessRuntimeDriverDescriptor from '../src/exports/runtime'; +import { col, makeFakeClient, row } from './_fakes'; + +describe('@prisma-next/driver-ppg-serverless / basic', () => { + describe('execute', () => { + it('streams rows keyed by column name', async () => { + const fake = makeFakeClient(() => ({ + columns: [col('id'), col('name')], + rows: [row(1, 'alice'), row(2, 'bob')], + })); + + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const collected: Array<{ id: number; name: string }> = []; + for await (const r of driver.execute<{ id: number; name: string }>({ + sql: 'select id, name from items', + })) { + collected.push(r); + } + + expect(collected).toEqual([ + { id: 1, name: 'alice' }, + { id: 2, name: 'bob' }, + ]); + expect(fake.newSessionCalls()).toBe(1); + expect(fake.sessionCloseCalls()).toBe(1); + }); + + it('spreads params into the underlying session.query call', async () => { + const fake = makeFakeClient(() => ({ columns: [col('x')], rows: [row(7)] })); + + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const consumed = []; + for await (const r of driver.execute<{ x: number }>({ + sql: 'select $1::int as x', + params: [7], + })) { + consumed.push(r); + } + + expect(consumed).toEqual([{ x: 7 }]); + const [call] = fake.queryCalls(); + expect(call?.sql).toBe('select $1::int as x'); + expect(call?.params).toEqual([7]); + }); + + it('closes the session even if iteration aborts early', async () => { + const fake = makeFakeClient(() => ({ + columns: [col('id')], + rows: [row(1), row(2), row(3)], + })); + + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const iter = driver.execute<{ id: number }>({ sql: 'select id from items' }); + const iterator = iter[Symbol.asyncIterator](); + const first = await iterator.next(); + expect(first.value).toEqual({ id: 1 }); + await iterator.return?.(undefined); + + expect(fake.sessionCloseCalls()).toBe(1); + }); + }); + + describe('query', () => { + it('collects rows and reports rowCount', async () => { + const fake = makeFakeClient(() => ({ + columns: [col('id'), col('name')], + rows: [row(1, 'alice'), row(2, 'bob'), row(3, 'carol')], + })); + + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const result = await driver.query<{ id: number; name: string }>('select id, name from items'); + + expect(result.rows).toEqual([ + { id: 1, name: 'alice' }, + { id: 2, name: 'bob' }, + { id: 3, name: 'carol' }, + ]); + expect(result.rowCount).toBe(3); + expect(fake.sessionCloseCalls()).toBe(1); + }); + + it('handles empty result sets', async () => { + const fake = makeFakeClient(() => ({ columns: [col('id')], rows: [] })); + + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const result = await driver.query<{ id: number }>('select id from items'); + expect(result.rows).toEqual([]); + expect(result.rowCount).toBe(0); + }); + + it('passes params through', async () => { + const fake = makeFakeClient(() => ({ columns: [col('id')], rows: [row(42)] })); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + await driver.query('select id from items where id = $1', [42]); + + const [call] = fake.queryCalls(); + expect(call?.params).toEqual([42]); + }); + }); + + describe('executePrepared', () => { + it('streams rows just like execute (handle is ignored)', async () => { + const fake = makeFakeClient(() => ({ + columns: [col('id')], + rows: [row(1), row(2)], + })); + + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + let handleValue: unknown = 'untouched'; + const handle = { + get: () => handleValue, + set: (v: unknown) => { + handleValue = v; + }, + }; + + const collected: Array<{ id: number }> = []; + for await (const r of driver.executePrepared<{ id: number }>({ + sql: 'select id from items', + params: [], + handle, + })) { + collected.push(r); + } + + expect(collected).toEqual([{ id: 1 }, { id: 2 }]); + // Handle is never touched by this driver — that is the documented design. + expect(handleValue).toBe('untouched'); + }); + }); + + describe('row mapping', () => { + it('preserves nulls and non-primitive values', async () => { + const date = new Date('2025-01-01T00:00:00Z'); + const fake = makeFakeClient(() => ({ + columns: [col('id'), col('payload'), col('created_at')], + rows: [row(1, null, date)], + })); + + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const result = await driver.query<{ + id: number; + payload: unknown; + created_at: Date; + }>('select * from t'); + + expect(result.rows[0]).toEqual({ id: 1, payload: null, created_at: date }); + }); + }); + + describe('one-shot session lifecycle', () => { + it('opens and closes a session per call', async () => { + const fake = makeFakeClient(() => ({ columns: [col('x')], rows: [row(1)] })); + + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + await driver.query('select 1 as x'); + await driver.query('select 1 as x'); + await driver.query('select 1 as x'); + + expect(fake.newSessionCalls()).toBe(3); + expect(fake.sessionCloseCalls()).toBe(3); + }); + }); +}); diff --git a/packages/3-targets/7-drivers/ppg-serverless/test/driver.bound-impl.test.ts b/packages/3-targets/7-drivers/ppg-serverless/test/driver.bound-impl.test.ts new file mode 100644 index 0000000000..92d056aeda --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/test/driver.bound-impl.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { createBoundDriverFromBinding } from '../src/exports/runtime'; +import { col, makeFakeClient, row } from './_fakes'; + +/** + * Direct tests for the bound impl (`PpgServerlessBoundDriverImpl`), bypassing + * the unbound wrapper. The wrapper intercepts every public call before the + * delegate is consulted, so the bound impl's own guards (closed-state checks, + * the misuse `connect()` throw) are unreachable through the runtime entry + * point. Exercising the factory directly is the only way to keep coverage + * on those paths. + */ +describe('@prisma-next/driver-ppg-serverless / bound impl (direct)', () => { + describe('createBoundDriverFromBinding', () => { + it('constructs from a { kind: "url" } binding (builds its own PPG client)', () => { + const bound = createBoundDriverFromBinding({ + kind: 'url', + url: 'postgres://user:pass@example.invalid:5432/db', + }); + expect(bound.state).toBe('connected'); + }); + }); + + describe('connect()', () => { + it('throws because the bound impl is constructed already-bound', async () => { + const fake = makeFakeClient(() => ({ columns: [], rows: [] })); + const bound = createBoundDriverFromBinding({ kind: 'ppgClient', client: fake.client }); + + await expect(bound.connect({ kind: 'ppgClient', client: fake.client })).rejects.toThrow( + /already-bound/, + ); + }); + }); + + describe('post-close guards', () => { + it('acquireConnection() throws DRIVER.CLOSED after close()', async () => { + const fake = makeFakeClient(() => ({ columns: [], rows: [] })); + const bound = createBoundDriverFromBinding({ kind: 'ppgClient', client: fake.client }); + + await bound.close(); + expect(bound.state).toBe('closed'); + + await expect(bound.acquireConnection()).rejects.toMatchObject({ + code: 'DRIVER.CLOSED', + category: 'RUNTIME', + }); + }); + + it('query() throws DRIVER.CLOSED after close()', async () => { + const fake = makeFakeClient(() => ({ columns: [col('x')], rows: [row(1)] })); + const bound = createBoundDriverFromBinding({ kind: 'ppgClient', client: fake.client }); + + await bound.close(); + + await expect(bound.query('select 1 as x')).rejects.toMatchObject({ + code: 'DRIVER.CLOSED', + category: 'RUNTIME', + }); + }); + + it('execute() throws DRIVER.CLOSED after close()', async () => { + const fake = makeFakeClient(() => ({ columns: [col('x')], rows: [row(1)] })); + const bound = createBoundDriverFromBinding({ kind: 'ppgClient', client: fake.client }); + + await bound.close(); + + const iter = bound.execute({ sql: 'select 1 as x' }); + await expect(iter[Symbol.asyncIterator]().next()).rejects.toMatchObject({ + code: 'DRIVER.CLOSED', + category: 'RUNTIME', + }); + }); + + it('executePrepared() throws DRIVER.CLOSED after close()', async () => { + const fake = makeFakeClient(() => ({ columns: [col('x')], rows: [row(1)] })); + const bound = createBoundDriverFromBinding({ kind: 'ppgClient', client: fake.client }); + + await bound.close(); + + const iter = bound.executePrepared({ + sql: 'select 1 as x', + params: [], + handle: { get: () => undefined, set: () => undefined }, + }); + await expect(iter[Symbol.asyncIterator]().next()).rejects.toMatchObject({ + code: 'DRIVER.CLOSED', + category: 'RUNTIME', + }); + }); + }); +}); diff --git a/packages/3-targets/7-drivers/ppg-serverless/test/driver.connection.test.ts b/packages/3-targets/7-drivers/ppg-serverless/test/driver.connection.test.ts new file mode 100644 index 0000000000..0557e99899 --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/test/driver.connection.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from 'vitest'; +import ppgServerlessRuntimeDriverDescriptor from '../src/exports/runtime'; +import { col, makeFakeClient, row, withTxnControlStatements } from './_fakes'; + +describe('@prisma-next/driver-ppg-serverless / connection', () => { + describe('acquireConnection', () => { + it('returns a connection that round-trips query through a single held session', async () => { + const fake = makeFakeClient(() => ({ + columns: [col('id'), col('name')], + rows: [row(1, 'alice')], + })); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + const result = await connection.query<{ id: number; name: string }>('select id, name from t'); + + expect(result.rows).toEqual([{ id: 1, name: 'alice' }]); + // One session opened (the connection's), zero closed yet (release hasn't fired). + expect(fake.newSessionCalls()).toBe(1); + expect(fake.closeCount()).toBe(0); + + await connection.release(); + }); + + it('reuses the same session across multiple execute / query / executePrepared calls', async () => { + const fake = makeFakeClient(() => ({ columns: [col('x')], rows: [row(1)] })); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + await connection.query('select 1 as x'); + for await (const _r of connection.execute({ sql: 'select 1 as x' })) { + // drain + } + for await (const _r of connection.executePrepared({ + sql: 'select 1 as x', + params: [], + handle: { get: () => undefined, set: () => undefined }, + })) { + // drain + } + + // Three calls, still only one underlying session. + expect(fake.newSessionCalls()).toBe(1); + expect(fake.closeCount()).toBe(0); + expect(fake.sessionQueryHistory()).toHaveLength(3); + + await connection.release(); + }); + + it('streams rows from execute via the held session', async () => { + const fake = makeFakeClient(() => ({ + columns: [col('id')], + rows: [row(1), row(2), row(3)], + })); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + const ids: number[] = []; + for await (const r of connection.execute<{ id: number }>({ sql: 'select id from t' })) { + ids.push(r.id); + } + expect(ids).toEqual([1, 2, 3]); + expect(fake.newSessionCalls()).toBe(1); + + await connection.release(); + }); + + it('exposes the SqlConnection-shaped surface', async () => { + const fake = makeFakeClient(withTxnControlStatements()); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + expect(connection).toMatchObject({ + execute: expect.any(Function), + executePrepared: expect.any(Function), + query: expect.any(Function), + beginTransaction: expect.any(Function), + release: expect.any(Function), + destroy: expect.any(Function), + }); + + await connection.release(); + }); + }); + + describe('release', () => { + it('closes the underlying session', async () => { + const fake = makeFakeClient(() => ({ columns: [], rows: [] })); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + expect(fake.closeCount()).toBe(0); + await connection.release(); + expect(fake.closeCount()).toBe(1); + }); + + it('is a no-op on the second call', async () => { + const fake = makeFakeClient(() => ({ columns: [], rows: [] })); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + await connection.release(); + await connection.release(); + // close fired exactly once. + expect(fake.closeCount()).toBe(1); + }); + + it('rejects subsequent query / execute / executePrepared with DRIVER.CONNECTION_RELEASED', async () => { + const fake = makeFakeClient(() => ({ columns: [], rows: [] })); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + await connection.release(); + + await expect(connection.query('select 1')).rejects.toMatchObject({ + code: 'DRIVER.CONNECTION_RELEASED', + category: 'RUNTIME', + }); + + const execIter = connection.execute({ sql: 'select 1' }); + await expect(execIter[Symbol.asyncIterator]().next()).rejects.toMatchObject({ + code: 'DRIVER.CONNECTION_RELEASED', + }); + + const prepIter = connection.executePrepared({ + sql: 'select 1', + params: [], + handle: { get: () => undefined, set: () => undefined }, + }); + await expect(prepIter[Symbol.asyncIterator]().next()).rejects.toMatchObject({ + code: 'DRIVER.CONNECTION_RELEASED', + }); + }); + + it('rejects beginTransaction after release', async () => { + const fake = makeFakeClient(withTxnControlStatements()); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + await connection.release(); + + await expect(connection.beginTransaction()).rejects.toMatchObject({ + code: 'DRIVER.CONNECTION_RELEASED', + category: 'RUNTIME', + }); + }); + }); + + describe('destroy', () => { + it('closes the underlying session and accepts an advisory reason', async () => { + const fake = makeFakeClient(() => ({ columns: [], rows: [] })); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + const reason = new Error('transaction rollback failed'); + await connection.destroy(reason); + // Session closed; reason is informational only, not rethrown. + expect(fake.closeCount()).toBe(1); + }); + + it('is idempotent with release (release-then-destroy and destroy-then-release both close once)', async () => { + const fake = makeFakeClient(() => ({ columns: [], rows: [] })); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const a = await driver.acquireConnection(); + await a.release(); + await a.destroy(new Error('after release')); + expect(fake.closeCount()).toBe(1); + + const b = await driver.acquireConnection(); + await b.destroy('failed'); + await b.release(); + // total close count is now 2 (a's release + b's destroy). + expect(fake.closeCount()).toBe(2); + }); + + it('rejects subsequent query with DRIVER.CONNECTION_RELEASED', async () => { + const fake = makeFakeClient(() => ({ columns: [], rows: [] })); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + await connection.destroy(); + + await expect(connection.query('select 1')).rejects.toMatchObject({ + code: 'DRIVER.CONNECTION_RELEASED', + }); + }); + }); + + describe('multiple connections from the same bound driver', () => { + it('opens a fresh session per acquireConnection', async () => { + const fake = makeFakeClient(() => ({ columns: [], rows: [] })); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const a = await driver.acquireConnection(); + const b = await driver.acquireConnection(); + const c = await driver.acquireConnection(); + + expect(fake.newSessionCalls()).toBe(3); + expect(fake.closeCount()).toBe(0); + + await a.release(); + await b.release(); + await c.release(); + + expect(fake.closeCount()).toBe(3); + }); + + it('isolates released-state per connection (releasing A does not release B)', async () => { + const fake = makeFakeClient(() => ({ columns: [col('x')], rows: [row(1)] })); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const a = await driver.acquireConnection(); + const b = await driver.acquireConnection(); + await a.release(); + + const result = await b.query<{ x: number }>('select 1'); + expect(result.rows).toEqual([{ x: 1 }]); + + await b.release(); + }); + }); +}); diff --git a/packages/3-targets/7-drivers/ppg-serverless/test/driver.errors.test.ts b/packages/3-targets/7-drivers/ppg-serverless/test/driver.errors.test.ts new file mode 100644 index 0000000000..9b3eb77071 --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/test/driver.errors.test.ts @@ -0,0 +1,192 @@ +import { DatabaseError, HttpResponseError, ValidationError, WebSocketError } from '@prisma/ppg'; +import { SqlConnectionError, SqlQueryError } from '@prisma-next/sql-errors'; +import { describe, expect, it } from 'vitest'; +import ppgServerlessRuntimeDriverDescriptor from '../src/exports/runtime'; +import { col, makeFakeClient, row } from './_fakes'; + +describe('@prisma-next/driver-ppg-serverless / errors', () => { + it('normalizes DatabaseError to SqlQueryError on query', async () => { + const pgErr = new DatabaseError({ + message: 'duplicate key value violates unique constraint "user_email_unique"', + code: '23505', + constraint: 'user_email_unique', + table: 'user', + column: 'email', + detail: 'Key (email)=(a@b) already exists.', + }); + + const fake = makeFakeClient(() => pgErr); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const promise = driver.query('insert into users(email) values ($1)', ['a@b']); + await expect(promise).rejects.toBeInstanceOf(SqlQueryError); + + try { + await driver.query('insert into users(email) values ($1)', ['a@b']); + } catch (e) { + expect(SqlQueryError.is(e)).toBe(true); + if (SqlQueryError.is(e)) { + expect(e.sqlState).toBe('23505'); + expect(e.constraint).toBe('user_email_unique'); + expect(e.table).toBe('user'); + expect(e.column).toBe('email'); + expect(e.detail).toBe('Key (email)=(a@b) already exists.'); + expect(e.cause).toBe(pgErr); + } + } + // Sessions must still be closed even when the underlying call rejects. + expect(fake.sessionCloseCalls()).toBe(2); + }); + + it('normalizes DatabaseError thrown during execute streaming to SqlQueryError', async () => { + const pgErr = new DatabaseError({ + message: 'syntax error at or near "FROMM"', + code: '42601', + }); + const fake = makeFakeClient(() => pgErr); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const consume = async () => { + for await (const _r of driver.execute({ sql: 'selct 1' })) { + // unused + } + }; + await expect(consume()).rejects.toBeInstanceOf(SqlQueryError); + expect(fake.sessionCloseCalls()).toBe(1); + }); + + it('normalizes WebSocketError with abnormal closure to transient SqlConnectionError', async () => { + const wsErr = new WebSocketError({ + message: 'WebSocket closed abnormally', + closureCode: 1011, + closureReason: 'server error', + }); + const fake = makeFakeClient(() => wsErr); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + try { + await driver.query('select 1'); + expect.fail('expected reject'); + } catch (e) { + expect(SqlConnectionError.is(e)).toBe(true); + if (SqlConnectionError.is(e)) { + expect(e.transient).toBe(true); + expect(e.cause).toBe(wsErr); + } + } + }); + + it('normalizes WebSocketError with normal closure (1000) to non-transient SqlConnectionError', async () => { + const wsErr = new WebSocketError({ + message: 'normal closure', + closureCode: 1000, + }); + const fake = makeFakeClient(() => wsErr); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + try { + await driver.query('select 1'); + expect.fail('expected reject'); + } catch (e) { + expect(SqlConnectionError.is(e)).toBe(true); + if (SqlConnectionError.is(e)) { + expect(e.transient).toBe(false); + } + } + }); + + it('normalizes HttpResponseError 5xx to transient SqlConnectionError', async () => { + const httpErr = new HttpResponseError({ message: 'gateway timeout', statusCode: 504 }); + const fake = makeFakeClient(() => httpErr); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + try { + await driver.query('select 1'); + expect.fail('expected reject'); + } catch (e) { + expect(SqlConnectionError.is(e)).toBe(true); + if (SqlConnectionError.is(e)) { + expect(e.transient).toBe(true); + expect(e.cause).toBe(httpErr); + } + } + }); + + it('normalizes HttpResponseError 4xx to non-transient SqlConnectionError', async () => { + const httpErr = new HttpResponseError({ message: 'unauthorized', statusCode: 401 }); + const fake = makeFakeClient(() => httpErr); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + try { + await driver.query('select 1'); + expect.fail('expected reject'); + } catch (e) { + expect(SqlConnectionError.is(e)).toBe(true); + if (SqlConnectionError.is(e)) { + expect(e.transient).toBe(false); + } + } + }); + + it('passes ValidationError through unchanged', async () => { + const validationErr = new ValidationError('connection string is malformed'); + const fake = makeFakeClient(() => validationErr); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + try { + await driver.query('select 1'); + expect.fail('expected reject'); + } catch (e) { + expect(e).toBe(validationErr); + } + }); + + it('closes session even when query rejects (try/finally)', async () => { + const fake = makeFakeClient(() => new Error('boom')); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + await expect(driver.query('select 1')).rejects.toThrow('boom'); + expect(fake.sessionCloseCalls()).toBe(1); + }); + + it('preserves the original error on cause for SqlQueryError', async () => { + const pgErr = new DatabaseError({ message: 'oops', code: '23502' }); + const fake = makeFakeClient(() => pgErr); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + try { + await driver.query('select 1'); + expect.fail('expected reject'); + } catch (e) { + if (SqlQueryError.is(e)) { + expect(e.cause).toBe(pgErr); + } else { + expect.fail('expected SqlQueryError'); + } + } + }); + + it('still works on a happy-path call after a previous query rejected', async () => { + let n = 0; + const fake = makeFakeClient(() => { + n++; + if (n === 1) return new DatabaseError({ message: 'first call fails', code: '42601' }); + return { columns: [col('x')], rows: [row(1)] }; + }); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + await expect(driver.query('select 1')).rejects.toBeInstanceOf(SqlQueryError); + const result = await driver.query<{ x: number }>('select 1'); + expect(result.rows).toEqual([{ x: 1 }]); + }); +}); diff --git a/packages/3-targets/7-drivers/ppg-serverless/test/driver.transaction.test.ts b/packages/3-targets/7-drivers/ppg-serverless/test/driver.transaction.test.ts new file mode 100644 index 0000000000..a8d036c6fc --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/test/driver.transaction.test.ts @@ -0,0 +1,252 @@ +import { DatabaseError } from '@prisma/ppg'; +import { SqlQueryError } from '@prisma-next/sql-errors'; +import { describe, expect, it } from 'vitest'; +import ppgServerlessRuntimeDriverDescriptor from '../src/exports/runtime'; +import { col, makeFakeClient, row, withTxnControlStatements } from './_fakes'; + +describe('@prisma-next/driver-ppg-serverless / transaction', () => { + describe('beginTransaction', () => { + it("issues 'BEGIN' on the held session", async () => { + const fake = makeFakeClient(withTxnControlStatements()); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + const txn = await connection.beginTransaction(); + + const history = fake.sessionQueryHistory(); + expect(history).toHaveLength(1); + expect(history[0]?.sql).toBe('BEGIN'); + // Transaction shares the connection's underlying session — no new session. + expect(fake.newSessionCalls()).toBe(1); + + // Cleanup + await txn.rollback(); + await connection.release(); + }); + + it('returns a transaction that exposes execute / query / executePrepared / commit / rollback', async () => { + const fake = makeFakeClient(withTxnControlStatements()); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + const txn = await connection.beginTransaction(); + + expect(txn).toMatchObject({ + execute: expect.any(Function), + executePrepared: expect.any(Function), + query: expect.any(Function), + commit: expect.any(Function), + rollback: expect.any(Function), + }); + + await txn.commit(); + await connection.release(); + }); + + it('routes execute and query through the same held session', async () => { + const fake = makeFakeClient( + withTxnControlStatements(() => ({ columns: [col('x')], rows: [row(1)] })), + ); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + const txn = await connection.beginTransaction(); + + const result = await txn.query<{ x: number }>('select 1 as x'); + expect(result.rows).toEqual([{ x: 1 }]); + + for await (const _r of txn.execute({ sql: 'select 1 as x' })) { + // drain + } + + await txn.commit(); + await connection.release(); + + // BEGIN + query + execute + COMMIT = 4 statements; still one session opened. + expect(fake.sessionQueryHistory().map((h) => h.sql)).toEqual([ + 'BEGIN', + 'select 1 as x', + 'select 1 as x', + 'COMMIT', + ]); + expect(fake.newSessionCalls()).toBe(1); + }); + }); + + describe('commit', () => { + it("issues 'COMMIT' on the held session", async () => { + const fake = makeFakeClient(withTxnControlStatements()); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + const txn = await connection.beginTransaction(); + await txn.commit(); + + const history = fake.sessionQueryHistory(); + expect(history.map((h) => h.sql)).toEqual(['BEGIN', 'COMMIT']); + + await connection.release(); + }); + + it('normalizes commit failure to SqlQueryError', async () => { + let n = 0; + const fake = makeFakeClient((sql) => { + if (sql.toUpperCase().startsWith('BEGIN')) { + return { columns: [], rows: [] }; + } + if (sql.toUpperCase().startsWith('COMMIT')) { + n++; + return new DatabaseError({ + message: 'no active transaction', + code: '25P01', + }); + } + return { columns: [], rows: [] }; + }); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + const txn = await connection.beginTransaction(); + + await expect(txn.commit()).rejects.toBeInstanceOf(SqlQueryError); + expect(n).toBe(1); + + await connection.release(); + }); + }); + + describe('rollback', () => { + it("issues 'ROLLBACK' on the held session", async () => { + const fake = makeFakeClient(withTxnControlStatements()); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + const txn = await connection.beginTransaction(); + await txn.rollback(); + + const history = fake.sessionQueryHistory(); + expect(history.map((h) => h.sql)).toEqual(['BEGIN', 'ROLLBACK']); + + await connection.release(); + }); + + it('normalizes rollback failure to SqlQueryError', async () => { + const fake = makeFakeClient((sql) => { + if (sql.toUpperCase().startsWith('BEGIN')) { + return { columns: [], rows: [] }; + } + if (sql.toUpperCase().startsWith('ROLLBACK')) { + return new DatabaseError({ message: 'no active transaction', code: '25P01' }); + } + return { columns: [], rows: [] }; + }); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + const txn = await connection.beginTransaction(); + + await expect(txn.rollback()).rejects.toBeInstanceOf(SqlQueryError); + await connection.release(); + }); + }); + + describe('sequential transactions on the same connection', () => { + it('supports begin → commit → begin → commit on the same connection', async () => { + const fake = makeFakeClient(withTxnControlStatements()); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + + const txn1 = await connection.beginTransaction(); + await txn1.commit(); + + const txn2 = await connection.beginTransaction(); + await txn2.commit(); + + expect(fake.sessionQueryHistory().map((h) => h.sql)).toEqual([ + 'BEGIN', + 'COMMIT', + 'BEGIN', + 'COMMIT', + ]); + // Still one session opened across the whole sequence. + expect(fake.newSessionCalls()).toBe(1); + + await connection.release(); + }); + + it('supports begin → rollback → begin → commit on the same connection', async () => { + const fake = makeFakeClient(withTxnControlStatements()); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + + const txn1 = await connection.beginTransaction(); + await txn1.rollback(); + + const txn2 = await connection.beginTransaction(); + await txn2.commit(); + + expect(fake.sessionQueryHistory().map((h) => h.sql)).toEqual([ + 'BEGIN', + 'ROLLBACK', + 'BEGIN', + 'COMMIT', + ]); + + await connection.release(); + }); + }); + + describe('error normalisation inside a transaction', () => { + it('normalizes a DatabaseError from a statement inside the transaction to SqlQueryError', async () => { + const fake = makeFakeClient((sql) => { + if ( + sql.toUpperCase().startsWith('BEGIN') || + sql.toUpperCase().startsWith('COMMIT') || + sql.toUpperCase().startsWith('ROLLBACK') + ) { + return { columns: [], rows: [] }; + } + return new DatabaseError({ + message: 'syntax error at or near "FROMM"', + code: '42601', + }); + }); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + const txn = await connection.beginTransaction(); + + await expect(txn.query('selct 1')).rejects.toBeInstanceOf(SqlQueryError); + + await txn.rollback(); + await connection.release(); + }); + }); + + describe('beginTransaction error path', () => { + it('normalizes a BEGIN failure to SqlQueryError', async () => { + const fake = makeFakeClient( + () => new DatabaseError({ message: 'cannot begin tx', code: '25001' }), + ); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + await expect(connection.beginTransaction()).rejects.toBeInstanceOf(SqlQueryError); + + await connection.release(); + }); + }); +}); diff --git a/packages/3-targets/7-drivers/ppg-serverless/test/driver.unbound.test.ts b/packages/3-targets/7-drivers/ppg-serverless/test/driver.unbound.test.ts new file mode 100644 index 0000000000..6dd195d965 --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/test/driver.unbound.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import ppgServerlessRuntimeDriverDescriptor from '../src/exports/runtime'; +import { col, makeFakeClient, row } from './_fakes'; + +describe('@prisma-next/driver-ppg-serverless runtime driver lifecycle', () => { + describe('descriptor.create', () => { + it('returns an unbound driver with stable identity fields', () => { + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + expect(driver).toMatchObject({ + familyId: 'sql', + targetId: 'postgres', + acquireConnection: expect.any(Function), + connect: expect.any(Function), + close: expect.any(Function), + }); + expect(driver.state).toBe('unbound'); + }); + + it('descriptor metadata is correctly populated', () => { + const d = ppgServerlessRuntimeDriverDescriptor; + expect(d.familyId).toBe('sql'); + expect(d.targetId).toBe('postgres'); + expect(d.id).toBe('ppg-serverless'); + expect(d.kind).toBe('driver'); + }); + }); + + describe('given an unbound driver', () => { + const useBeforeConnectMessage = + 'driver-ppg-serverless: driver not connected. Call connect(binding) before acquireConnection or execute.'; + + it('throws when acquireConnection is called', async () => { + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await expect(driver.acquireConnection()).rejects.toMatchObject({ + code: 'DRIVER.NOT_CONNECTED', + category: 'RUNTIME', + message: useBeforeConnectMessage, + }); + }); + + it('throws when query is called', async () => { + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await expect(driver.query('select 1')).rejects.toMatchObject({ + code: 'DRIVER.NOT_CONNECTED', + category: 'RUNTIME', + message: useBeforeConnectMessage, + }); + }); + + it('throws when execute is iterated', async () => { + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + const iter = driver.execute({ sql: 'select 1' }); + const iterator = iter[Symbol.asyncIterator](); + await expect(iterator.next()).rejects.toMatchObject({ + code: 'DRIVER.NOT_CONNECTED', + category: 'RUNTIME', + message: useBeforeConnectMessage, + }); + }); + + it('throws when executePrepared is iterated', async () => { + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + const iter = driver.executePrepared({ + sql: 'select 1', + params: [], + handle: { get: () => undefined, set: () => undefined }, + }); + const iterator = iter[Symbol.asyncIterator](); + await expect(iterator.next()).rejects.toMatchObject({ + code: 'DRIVER.NOT_CONNECTED', + category: 'RUNTIME', + }); + }); + }); + + describe('state transitions', () => { + it('walks unbound → connected → closed → connected (reconnect after close)', async () => { + const fakeA = makeFakeClient(() => ({ columns: [], rows: [] })); + const fakeB = makeFakeClient(() => ({ columns: [], rows: [] })); + + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + expect(driver.state).toBe('unbound'); + + await driver.connect({ kind: 'ppgClient', client: fakeA.client }); + expect(driver.state).toBe('connected'); + + await driver.close(); + expect(driver.state).toBe('closed'); + + await driver.connect({ kind: 'ppgClient', client: fakeB.client }); + expect(driver.state).toBe('connected'); + }); + + it('rejects double-connect without a close in between', async () => { + const fake = makeFakeClient(() => ({ columns: [], rows: [] })); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + await expect( + driver.connect({ kind: 'ppgClient', client: fake.client }), + ).rejects.toMatchObject({ + code: 'DRIVER.ALREADY_CONNECTED', + category: 'RUNTIME', + message: + 'driver-ppg-serverless: driver already connected. Call close() before reconnecting with a new binding.', + }); + }); + + it('allows close to be called multiple times', async () => { + const fake = makeFakeClient(() => ({ columns: [], rows: [] })); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + await driver.close(); + await driver.close(); + expect(driver.state).toBe('closed'); + }); + }); + + describe('when connected with ppgClient binding', () => { + it('queries successfully', async () => { + const fake = makeFakeClient(() => ({ + columns: [col('id'), col('name')], + rows: [row(1, 'alice')], + })); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const result = await driver.query<{ id: number; name: string }>('select id, name from items'); + expect(result.rows).toEqual([{ id: 1, name: 'alice' }]); + }); + + it('routes acquireConnection to the bound impl, which returns a usable SqlConnection', async () => { + const fake = makeFakeClient(() => ({ columns: [], rows: [] })); + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'ppgClient', client: fake.client }); + + const connection = await driver.acquireConnection(); + expect(connection).toMatchObject({ + execute: expect.any(Function), + executePrepared: expect.any(Function), + query: expect.any(Function), + beginTransaction: expect.any(Function), + release: expect.any(Function), + destroy: expect.any(Function), + }); + await connection.release(); + }); + }); + + describe('when constructed from { kind: "url" } binding', () => { + it('builds a ppg client from the URL string', async () => { + // Build a fake URL that defaultClientConfig accepts; we don't actually + // open a WebSocket since the driver does no I/O on connect (sessions + // are opened per-call). close() is a state flip so we can verify the + // binding wiring without a real server. + const driver = ppgServerlessRuntimeDriverDescriptor.create(); + await driver.connect({ + kind: 'url', + url: 'postgres://user:pass@example.invalid:5432/db', + }); + expect(driver.state).toBe('connected'); + await driver.close(); + expect(driver.state).toBe('closed'); + }); + }); +}); diff --git a/packages/3-targets/7-drivers/ppg-serverless/test/normalize-error.test.ts b/packages/3-targets/7-drivers/ppg-serverless/test/normalize-error.test.ts new file mode 100644 index 0000000000..e362815ee7 --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/test/normalize-error.test.ts @@ -0,0 +1,211 @@ +import { DatabaseError, HttpResponseError, ValidationError, WebSocketError } from '@prisma/ppg'; +import { SqlConnectionError, SqlQueryError } from '@prisma-next/sql-errors'; +import { describe, expect, it } from 'vitest'; +import { normalizePpgError } from '../src/normalize-error'; + +describe('normalizePpgError', () => { + describe('DatabaseError', () => { + it('maps to SqlQueryError with SQLSTATE and known details fields', () => { + const pgErr = new DatabaseError({ + message: 'duplicate key value', + code: '23505', + constraint: 'users_email_unique', + table: 'users', + column: 'email', + detail: 'Key (email)=(a@b) already exists.', + }); + + const normalized = normalizePpgError(pgErr); + expect(SqlQueryError.is(normalized)).toBe(true); + if (SqlQueryError.is(normalized)) { + expect(normalized.sqlState).toBe('23505'); + expect(normalized.constraint).toBe('users_email_unique'); + expect(normalized.table).toBe('users'); + expect(normalized.column).toBe('email'); + expect(normalized.detail).toBe('Key (email)=(a@b) already exists.'); + expect(normalized.cause).toBe(pgErr); + expect(normalized.message).toBe('duplicate key value'); + } + }); + + it('leaves optional fields undefined when details does not carry them', () => { + const pgErr = new DatabaseError({ + message: 'syntax error', + code: '42601', + }); + + const normalized = normalizePpgError(pgErr); + expect(SqlQueryError.is(normalized)).toBe(true); + if (SqlQueryError.is(normalized)) { + expect(normalized.sqlState).toBe('42601'); + expect(normalized.constraint).toBeUndefined(); + expect(normalized.table).toBeUndefined(); + expect(normalized.column).toBeUndefined(); + expect(normalized.detail).toBeUndefined(); + } + }); + + it('propagates partial details (e.g. table without constraint)', () => { + const pgErr = new DatabaseError({ + message: 'foreign key violation', + code: '23503', + table: 'posts', + }); + + const normalized = normalizePpgError(pgErr); + if (SqlQueryError.is(normalized)) { + expect(normalized.table).toBe('posts'); + expect(normalized.constraint).toBeUndefined(); + } else { + expect.fail('expected SqlQueryError'); + } + }); + }); + + describe('WebSocketError', () => { + it('maps abnormal closure (1011) to transient SqlConnectionError', () => { + const wsErr = new WebSocketError({ message: 'server error', closureCode: 1011 }); + const normalized = normalizePpgError(wsErr); + + expect(SqlConnectionError.is(normalized)).toBe(true); + if (SqlConnectionError.is(normalized)) { + expect(normalized.transient).toBe(true); + expect(normalized.cause).toBe(wsErr); + } + }); + + it('maps normal closure (1000) to non-transient SqlConnectionError', () => { + const wsErr = new WebSocketError({ message: 'normal', closureCode: 1000 }); + const normalized = normalizePpgError(wsErr); + + if (SqlConnectionError.is(normalized)) { + expect(normalized.transient).toBe(false); + } else { + expect.fail('expected SqlConnectionError'); + } + }); + + it('maps going-away (1001) to non-transient SqlConnectionError', () => { + const wsErr = new WebSocketError({ message: 'going away', closureCode: 1001 }); + const normalized = normalizePpgError(wsErr); + + if (SqlConnectionError.is(normalized)) { + expect(normalized.transient).toBe(false); + } else { + expect.fail('expected SqlConnectionError'); + } + }); + + it('treats missing closureCode as non-transient (no signal)', () => { + const wsErr = new WebSocketError({ message: 'unknown closure' }); + const normalized = normalizePpgError(wsErr); + + if (SqlConnectionError.is(normalized)) { + expect(normalized.transient).toBe(false); + } else { + expect.fail('expected SqlConnectionError'); + } + }); + + it.each([ + [1006, 'abnormal closure'], + [1012, 'service restart'], + [1013, 'try again later'], + [1014, 'bad gateway'], + ])('maps server/temporary closure %d (%s) to transient', (closureCode, label) => { + const wsErr = new WebSocketError({ message: label, closureCode }); + const normalized = normalizePpgError(wsErr); + + if (SqlConnectionError.is(normalized)) { + expect(normalized.transient).toBe(true); + } else { + expect.fail('expected SqlConnectionError'); + } + }); + + it.each([ + [1002, 'protocol error'], + [1003, 'unsupported data'], + [1008, 'policy violation'], + [1009, 'message too big'], + ])('maps protocol/policy closure %d (%s) to non-transient', (closureCode, label) => { + const wsErr = new WebSocketError({ message: label, closureCode }); + const normalized = normalizePpgError(wsErr); + + if (SqlConnectionError.is(normalized)) { + expect(normalized.transient).toBe(false); + } else { + expect.fail('expected SqlConnectionError'); + } + }); + }); + + describe('HttpResponseError', () => { + it('maps 5xx to transient SqlConnectionError', () => { + const httpErr = new HttpResponseError({ message: 'bad gateway', statusCode: 502 }); + const normalized = normalizePpgError(httpErr); + + if (SqlConnectionError.is(normalized)) { + expect(normalized.transient).toBe(true); + expect(normalized.cause).toBe(httpErr); + } else { + expect.fail('expected SqlConnectionError'); + } + }); + + it('maps 4xx to non-transient SqlConnectionError', () => { + const httpErr = new HttpResponseError({ message: 'forbidden', statusCode: 403 }); + const normalized = normalizePpgError(httpErr); + + if (SqlConnectionError.is(normalized)) { + expect(normalized.transient).toBe(false); + } else { + expect.fail('expected SqlConnectionError'); + } + }); + }); + + describe('ValidationError', () => { + it('passes through unchanged', () => { + const v = new ValidationError('bad config'); + const normalized = normalizePpgError(v); + expect(normalized).toBe(v); + }); + }); + + describe('unknown errors', () => { + it('returns plain Errors as-is', () => { + const plain = new Error('random failure'); + const normalized = normalizePpgError(plain); + expect(normalized).toBe(plain); + }); + + it('wraps non-Error values in an Error', () => { + expect(normalizePpgError('string failure').message).toBe('string failure'); + expect(normalizePpgError(42).message).toBe('42'); + expect(normalizePpgError(null).message).toBe('null'); + }); + }); + + describe('cause preservation', () => { + it('preserves the cause for SqlQueryError', () => { + const pgErr = new DatabaseError({ message: 'oops', code: '23502' }); + pgErr.stack = 'Error: oops\n at orig.js:1:1'; + const normalized = normalizePpgError(pgErr); + if (SqlQueryError.is(normalized)) { + expect(normalized.cause).toBe(pgErr); + if (normalized.cause instanceof Error) { + expect(normalized.cause.stack).toContain('orig.js'); + } + } + }); + + it('preserves the cause for SqlConnectionError', () => { + const wsErr = new WebSocketError({ message: 'closed', closureCode: 1011 }); + const normalized = normalizePpgError(wsErr); + if (SqlConnectionError.is(normalized)) { + expect(normalized.cause).toBe(wsErr); + } + }); + }); +}); diff --git a/packages/3-targets/7-drivers/ppg-serverless/test/row-mapper.test.ts b/packages/3-targets/7-drivers/ppg-serverless/test/row-mapper.test.ts new file mode 100644 index 0000000000..2765c8d120 --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/test/row-mapper.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { mapRowToRecord } from '../src/core/row-mapper'; + +describe('mapRowToRecord', () => { + it('skips undefined slots in the columns array (defensive branch)', () => { + // PPG's typed contract says `columns` is a dense readonly array of + // `{ name: string }`, but the helper carries a runtime guard for the + // pathological case where the array carries an undefined slot (a sparse + // array, or an upstream typing bug). Construct that case explicitly and + // assert the undefined slot is skipped without producing a stray key. + const columns = [{ name: 'a' }, undefined, { name: 'b' }] as ReadonlyArray<{ name: string }>; + const ppgRow = { values: [1, 'middle', 3] }; + + const record = mapRowToRecord>(ppgRow, columns); + + expect(record).toEqual({ a: 1, b: 3 }); + expect(Object.keys(record)).toEqual(['a', 'b']); + }); +}); diff --git a/packages/3-targets/7-drivers/ppg-serverless/tsconfig.json b/packages/3-targets/7-drivers/ppg-serverless/tsconfig.json new file mode 100644 index 0000000000..7afa587436 --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": ["@prisma-next/tsconfig/base"], + "compilerOptions": { + "rootDir": ".", + "outDir": "dist" + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["dist"] +} diff --git a/packages/3-targets/7-drivers/ppg-serverless/tsconfig.prod.json b/packages/3-targets/7-drivers/ppg-serverless/tsconfig.prod.json new file mode 100644 index 0000000000..b08d4c908a --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/tsconfig.prod.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": ["@prisma-next/tsconfig/prod"] +} diff --git a/packages/3-targets/7-drivers/ppg-serverless/tsdown.config.ts b/packages/3-targets/7-drivers/ppg-serverless/tsdown.config.ts new file mode 100644 index 0000000000..8d36211f47 --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/tsdown.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from '@prisma-next/tsdown'; + +export default defineConfig({ + entry: ['src/exports/control.ts', 'src/exports/runtime.ts'], +}); diff --git a/packages/3-targets/7-drivers/ppg-serverless/vitest.config.ts b/packages/3-targets/7-drivers/ppg-serverless/vitest.config.ts new file mode 100644 index 0000000000..f679bd8804 --- /dev/null +++ b/packages/3-targets/7-drivers/ppg-serverless/vitest.config.ts @@ -0,0 +1,30 @@ +import { timeouts } from '@prisma-next/test-utils'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + testTimeout: timeouts.default, + hookTimeout: timeouts.default, + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.ts'], + exclude: [ + 'dist/**', + 'test/**', + '**/*.test.ts', + '**/*.test-d.ts', + '**/*.config.ts', + '**/exports/**', + ], + thresholds: { + lines: 94, + branches: 95, + functions: 95, + statements: 94, + }, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b9f61a1a2..b2d748297e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,12 @@ catalogs: '@prisma/dev': specifier: 0.24.7 version: 0.24.7 + '@prisma/management-api-sdk': + specifier: 1.35.0 + version: 1.35.0 + '@prisma/ppg': + specifier: 1.0.1 + version: 1.0.1 '@types/node': specifier: 25.6.0 version: 25.6.0 @@ -30,6 +36,9 @@ catalogs: pg: specifier: 8.20.0 version: 8.20.0 + postgres-array: + specifier: 2.0.0 + version: 2.0.0 tsdown: specifier: 0.22.0 version: 0.22.0 @@ -3357,6 +3366,88 @@ importers: specifier: 'catalog:' version: 4.1.6(@types/node@25.6.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.13(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.8.4)) + packages/3-extensions/prisma-postgres-serverless: + dependencies: + '@prisma-next/adapter-postgres': + specifier: workspace:0.12.0 + version: link:../../3-targets/6-adapters/postgres + '@prisma-next/cli': + specifier: workspace:0.12.0 + version: link:../../1-framework/3-tooling/cli + '@prisma-next/config': + specifier: workspace:0.12.0 + version: link:../../1-framework/1-core/config + '@prisma-next/contract': + specifier: workspace:0.12.0 + version: link:../../1-framework/0-foundation/contract + '@prisma-next/driver-ppg-serverless': + specifier: workspace:0.12.0 + version: link:../../3-targets/7-drivers/ppg-serverless + '@prisma-next/family-sql': + specifier: workspace:0.12.0 + version: link:../../2-sql/9-family + '@prisma-next/framework-components': + specifier: workspace:0.12.0 + version: link:../../1-framework/1-core/framework-components + '@prisma-next/postgres': + specifier: workspace:0.12.0 + version: link:../postgres + '@prisma-next/sql-builder': + specifier: workspace:0.12.0 + version: link:../../2-sql/4-lanes/sql-builder + '@prisma-next/sql-contract': + specifier: workspace:0.12.0 + version: link:../../2-sql/1-core/contract + '@prisma-next/sql-contract-psl': + specifier: workspace:0.12.0 + version: link:../../2-sql/2-authoring/contract-psl + '@prisma-next/sql-contract-ts': + specifier: workspace:0.12.0 + version: link:../../2-sql/2-authoring/contract-ts + '@prisma-next/sql-orm-client': + specifier: workspace:0.12.0 + version: link:../sql-orm-client + '@prisma-next/sql-relational-core': + specifier: workspace:0.12.0 + version: link:../../2-sql/4-lanes/relational-core + '@prisma-next/sql-runtime': + specifier: workspace:0.12.0 + version: link:../../2-sql/5-runtime + '@prisma-next/target-postgres': + specifier: workspace:0.12.0 + version: link:../../3-targets/3-targets/postgres + '@prisma-next/utils': + specifier: workspace:0.12.0 + version: link:../../1-framework/0-foundation/utils + '@prisma/ppg': + specifier: 'catalog:' + version: 1.0.1 + pathe: + specifier: ^2.0.3 + version: 2.0.3 + devDependencies: + '@prisma-next/psl-parser': + specifier: workspace:0.12.0 + version: link:../../1-framework/2-authoring/psl-parser + '@prisma-next/test-utils': + specifier: workspace:0.12.0 + version: link:../../../test/utils + '@prisma-next/tsconfig': + specifier: workspace:0.12.0 + version: link:../../0-config/tsconfig + '@prisma-next/tsdown': + specifier: workspace:0.12.0 + version: link:../../0-config/tsdown + tsdown: + specifier: 'catalog:' + version: 0.22.0(tsx@4.22.3)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.6(@types/node@25.6.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.13(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.8.4)) + packages/3-extensions/sql-orm-client: dependencies: '@prisma-next/contract': @@ -4081,6 +4172,64 @@ importers: specifier: 'catalog:' version: 4.1.6(@types/node@25.6.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.13(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.8.4)) + packages/3-targets/7-drivers/ppg-serverless: + dependencies: + '@prisma-next/contract': + specifier: workspace:0.12.0 + version: link:../../../1-framework/0-foundation/contract + '@prisma-next/driver-postgres': + specifier: workspace:0.12.0 + version: link:../postgres + '@prisma-next/errors': + specifier: workspace:0.12.0 + version: link:../../../1-framework/1-core/errors + '@prisma-next/framework-components': + specifier: workspace:0.12.0 + version: link:../../../1-framework/1-core/framework-components + '@prisma-next/sql-contract': + specifier: workspace:0.12.0 + version: link:../../../2-sql/1-core/contract + '@prisma-next/sql-errors': + specifier: workspace:0.12.0 + version: link:../../../2-sql/1-core/errors + '@prisma-next/sql-operations': + specifier: workspace:0.12.0 + version: link:../../../2-sql/1-core/operations + '@prisma-next/sql-relational-core': + specifier: workspace:0.12.0 + version: link:../../../2-sql/4-lanes/relational-core + '@prisma-next/utils': + specifier: workspace:0.12.0 + version: link:../../../1-framework/0-foundation/utils + '@prisma/ppg': + specifier: 'catalog:' + version: 1.0.1 + arktype: + specifier: ^2.2.0 + version: 2.2.0 + postgres-array: + specifier: 'catalog:' + version: 2.0.0 + devDependencies: + '@prisma-next/test-utils': + specifier: workspace:0.12.0 + version: link:../../../../test/utils + '@prisma-next/tsconfig': + specifier: workspace:0.12.0 + version: link:../../../0-config/tsconfig + '@prisma-next/tsdown': + specifier: workspace:0.12.0 + version: link:../../../0-config/tsdown + tsdown: + specifier: 'catalog:' + version: 0.22.0(tsx@4.22.3)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.6(@types/node@25.6.2)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.13(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.8.4)) + packages/3-targets/7-drivers/sqlite: dependencies: '@prisma-next/contract': @@ -4259,6 +4408,9 @@ importers: '@prisma-next/driver-sqlite': specifier: workspace:0.12.0 version: link:../../packages/3-targets/7-drivers/sqlite + '@prisma-next/driver-ppg-serverless': + specifier: workspace:0.12.0 + version: link:../../packages/3-targets/7-drivers/ppg-serverless '@prisma-next/emitter': specifier: workspace:0.12.0 version: link:../../packages/1-framework/3-tooling/emitter @@ -4319,6 +4471,9 @@ importers: '@prisma-next/postgres': specifier: workspace:0.12.0 version: link:../../packages/3-extensions/postgres + '@prisma-next/prisma-postgres-serverless': + specifier: workspace:0.12.0 + version: link:../../packages/3-extensions/prisma-postgres-serverless '@prisma-next/psl-parser': specifier: workspace:0.12.0 version: link:../../packages/1-framework/2-authoring/psl-parser @@ -4380,6 +4535,9 @@ importers: '@prisma-next/tsconfig': specifier: workspace:0.12.0 version: link:../../packages/0-config/tsconfig + '@prisma/management-api-sdk': + specifier: 'catalog:' + version: 1.35.0 '@types/pg': specifier: 'catalog:' version: 8.20.0 @@ -5843,6 +6001,12 @@ packages: '@prisma/management-api-sdk@1.29.0': resolution: {integrity: sha512-TnrTj+9crmeAV9J/XxjxdPdAsYHWWMLXvre4+G2Ng9gNxuKiUAn7PElezy5Algi2e5WkpbArW6vQvx8f6l+ipg==} + '@prisma/management-api-sdk@1.35.0': + resolution: {integrity: sha512-ugUROU6SkKUhfjZ9LLV3vtryevPxKaqzet36m5ncD4ceI4PPoqNUPyFdhK9uWsdRgxR2peN7Nw2iUvZsc9aqBg==} + + '@prisma/ppg@1.0.1': + resolution: {integrity: sha512-rRRXuPPerXwNWjSA3OE0e/bqXSTfsE82EsMvoiluc0fN0DizQSe3937/Tnl5+DPbxY5rdAOlYjWXG0A2wwTbKA==} + '@prisma/query-plan-executor@7.2.0': resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} @@ -10730,6 +10894,17 @@ snapshots: dependencies: openapi-fetch: 0.14.0 + '@prisma/management-api-sdk@1.35.0': + dependencies: + openapi-fetch: 0.14.0 + + '@prisma/ppg@1.0.1': + dependencies: + ws: 8.20.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@prisma/query-plan-executor@7.2.0': {} '@prisma/streams-local@0.1.5': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d25930f096..abeee1651a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,6 +16,8 @@ blockExoticSubdeps: true catalog: '@prisma/dev': 0.24.7 + '@prisma/management-api-sdk': 1.35.0 + '@prisma/ppg': 1.0.1 '@types/node': 25.6.0 '@types/pg': 8.20.0 arktype: ^2.2.0 @@ -23,6 +25,7 @@ catalog: mongodb: ^7.2.0 mongodb-memory-server: 11.1.0 pg: 8.20.0 + postgres-array: 2.0.0 tsdown: 0.22.0 tsx: ^4.22.3 typescript: 5.9.3 diff --git a/test/integration/package.json b/test/integration/package.json index ade6e6e0cd..208221834b 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -24,6 +24,7 @@ "@prisma-next/cli": "workspace:0.12.0", "@prisma-next/contract": "workspace:0.12.0", "@prisma-next/driver-postgres": "workspace:0.12.0", + "@prisma-next/driver-ppg-serverless": "workspace:0.12.0", "@prisma-next/emitter": "workspace:0.12.0", "@prisma-next/extension-arktype-json": "workspace:0.12.0", "@prisma-next/extension-paradedb": "workspace:0.12.0", @@ -33,6 +34,7 @@ "@prisma-next/migration-tools": "workspace:0.12.0", "@prisma-next/operations": "workspace:0.12.0", "@prisma-next/postgres": "workspace:0.12.0", + "@prisma-next/prisma-postgres-serverless": "workspace:0.12.0", "@prisma-next/psl-parser": "workspace:0.12.0", "@prisma-next/sql-contract": "workspace:0.12.0", "@prisma-next/sql-contract-emitter": "workspace:0.12.0", @@ -70,6 +72,7 @@ "@prisma-next/mongo-lowering": "workspace:0.12.0", "@prisma-next/test-utils": "workspace:0.12.0", "@prisma-next/tsconfig": "workspace:0.12.0", + "@prisma/management-api-sdk": "catalog:", "@types/pg": "catalog:", "commander": "^14.0.3", "mongodb": "catalog:", diff --git a/test/integration/test/prisma-postgres-serverless/cloud-integration.test.ts b/test/integration/test/prisma-postgres-serverless/cloud-integration.test.ts new file mode 100644 index 0000000000..f15f99e13e --- /dev/null +++ b/test/integration/test/prisma-postgres-serverless/cloud-integration.test.ts @@ -0,0 +1,226 @@ +/** + * Real-cloud integration test: provisions a fresh Prisma Postgres project + * via the Management API, applies the contract over TCP (control plane), + * exercises ORM round-trip + transaction COMMIT/ROLLBACK over PPG WebSocket + * (data plane), then deletes the project. Skipped without + * `PRISMA_POSTGRES_SERVICE_TOKEN`; the CI workflow hard-fails own-repo PR + * runs missing the secret. + */ + +import { randomUUID } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createManagementApiClient } from '@prisma/management-api-sdk'; +import { defineContract } from '@prisma-next/prisma-postgres-serverless/contract-builder'; +import { createPostgresControlClient } from '@prisma-next/prisma-postgres-serverless/control'; +import prismaPostgresServerless, { + type PrismaPostgresServerlessClient, +} from '@prisma-next/prisma-postgres-serverless/runtime'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const SERVICE_TOKEN = process.env['PRISMA_POSTGRES_SERVICE_TOKEN']; +const REGION = 'us-east-1' as const; + +/** Used in `beforeAll` to wait out PPG's TCP gateway warm-up window. */ +async function retryWithBackoff( + fn: () => Promise, + opts: { + readonly backoffSchedule: ReadonlyArray; + readonly isTransient: (err: unknown) => boolean; + readonly onAttempt?: ( + attempt: number, + elapsedMs: number, + outcome: 'ok' | 'transient' | 'fatal', + ) => void; + }, +): Promise { + const start = Date.now(); + let lastErr: unknown; + for (let i = 0; i < opts.backoffSchedule.length; i++) { + const waitMs = opts.backoffSchedule[i] ?? 0; + if (waitMs > 0) { + await new Promise((r) => setTimeout(r, waitMs)); + } + const elapsed = Date.now() - start; + try { + const result = await fn(); + opts.onAttempt?.(i + 1, elapsed, 'ok'); + return result; + } catch (err) { + lastErr = err; + if (!opts.isTransient(err)) { + opts.onAttempt?.(i + 1, elapsed, 'fatal'); + throw err; + } + opts.onAttempt?.(i + 1, elapsed, 'transient'); + } + } + throw lastErr; +} + +// PPG's TCP gateway transient-rejects with a non-Postgres ErrorResponse during +// the warm-up window between `POST /v1/projects` returning `status: "ready"` +// and the gateway finishing its backend routing. The marker string is the same +// whether the error surfaces bare (from `pg`) or wrapped (framework's +// `errorRuntime` moves it into `why`). +function isGatewayWarmupError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const marker = 'Failed to connect to upstream database'; + if (err.message.includes(marker)) return true; + if ('why' in err && typeof err.why === 'string') { + return err.why.includes(marker); + } + return false; +} + +// Explicit ids on `create(...)`: `defineContract`'s factory form doesn't yet +// propagate field-level execution defaults to `CreateInput` type-level +// optionality. Same pattern as `collection-mutation-defaults.test.ts`. +const contract = defineContract({}, ({ field, model }) => ({ + models: { + Item: model('Item', { + fields: { + id: field.id.uuidv7(), + name: field.text(), + }, + }), + }, +})); + +type Contract = typeof contract; + +describe.skipIf(!SERVICE_TOKEN)('prisma-postgres-serverless / cloud ORM round-trip', () => { + let mgmt: ReturnType; + let projectId: string | undefined; + let migrationsDir: string | undefined; + let db: PrismaPostgresServerlessClient | undefined; + + beforeAll(async () => { + mgmt = createManagementApiClient({ token: SERVICE_TOKEN! }); + const name = `pn-ci-${Date.now()}-${randomUUID().slice(0, 8)}`; + + const { data: response, error } = await mgmt.POST('/v1/projects', { + body: { name, region: REGION }, + }); + if (error || !response) { + throw new Error(`mgmt-api: provision failed: ${JSON.stringify(error ?? 'no data')}`); + } + // Capture the id before anything else can throw — afterAll needs it to + // teardown the project even if dbInit (the failure-prone step) blows up. + projectId = response.data.id; + + // `endpoints.pooled` is the PPG raw-SQL endpoint (data plane); + // `endpoints.direct` is raw TCP (control plane). `endpoints.accelerate` + // is the GraphQL data-proxy and is NOT consumable by `@prisma/ppg` + // despite the shared `prisma+postgres://` scheme. + const database = response.data.database; + const conn = database?.connections[0]; + const ppgUrl = conn?.endpoints.pooled?.connectionString; + const tcpUrl = conn?.endpoints.direct?.connectionString; + if (!ppgUrl) { + throw new Error(`mgmt-api: project ${projectId} has no pooled (PPG) connection endpoint`); + } + if (!tcpUrl) { + throw new Error(`mgmt-api: project ${projectId} has no direct TCP connection endpoint`); + } + + // `dbInit` requires a `migrationsDir` even from-scratch (per-space flow + // reads on-disk refs from it); an empty temp dir is sufficient. + const dir = await mkdtemp(join(tmpdir(), 'pn-cloud-it-')); + migrationsDir = dir; + + // PPG's TCP gateway has a ~5–10s warm-up window after `POST /v1/projects` + // returns ready, during which `pg.Client.connect` transient-rejects with + // `isGatewayWarmupError`. Retry only on that envelope; everything else + // surfaces immediately. + const controlClient = createPostgresControlClient({ connection: tcpUrl }); + try { + const result = await retryWithBackoff( + () => controlClient.dbInit({ contract, mode: 'apply', migrationsDir: dir }), + { + backoffSchedule: [0, 5_000, 10_000, 20_000, 40_000], + isTransient: isGatewayWarmupError, + onAttempt: (attempt, elapsedMs, outcome) => { + console.log( + `dbInit attempt ${attempt} at t=${(elapsedMs / 1000).toFixed(1)}s: ${outcome}`, + ); + }, + }, + ); + if (!result.ok) { + throw new Error( + `dbInit failed: ${result.failure.summary}\n\n${JSON.stringify(result.failure, null, 2)}`, + ); + } + } finally { + await controlClient.close(); + } + + db = prismaPostgresServerless({ contract, binding: { kind: 'url', url: ppgUrl } }); + await db.connect(); + }, 120_000); + + afterAll(async () => { + // Each step is guarded so one failure does not block the rest; the + // project-delete failure mode is the only one with a real leak cost. + try { + await db?.close(); + } catch {} + + if (migrationsDir !== undefined) { + await rm(migrationsDir, { recursive: true, force: true }).catch(() => undefined); + } + + if (!projectId) return; + const { error } = await mgmt.DELETE('/v1/projects/{id}', { + params: { path: { id: projectId } }, + }); + if (error) { + // Leak the breadcrumb instead of failing the suite — provision + tests + // already ran, manual cleanup is still possible from the project id. + console.warn( + `mgmt-api: teardown leak — manual delete needed for project ${projectId}:`, + JSON.stringify(error), + ); + } + }, 60_000); + + it('round-trips INSERT and SELECT through the ORM', async () => { + if (!db) throw new Error('db not initialised — beforeAll failed'); + const aliceId = randomUUID(); + const created = await db.orm.Item.create({ id: aliceId, name: 'alice' }); + expect(created.name).toBe('alice'); + expect(created.id).toBe(aliceId); + + const rows = await db.orm.Item.all(); + expect(rows).toEqual([{ id: aliceId, name: 'alice' }]); + }, 60_000); + + it('commits a transaction', async () => { + if (!db) throw new Error('db not initialised — beforeAll failed'); + const bobId = randomUUID(); + await db.transaction(async (tx) => { + await tx.orm.Item.create({ id: bobId, name: 'bob' }); + }); + + const rows = await db.orm.Item.all(); + const names = rows.map((row) => row.name).sort(); + expect(names).toEqual(['alice', 'bob']); + }, 60_000); + + it('rolls back a transaction on thrown error', async () => { + if (!db) throw new Error('db not initialised — beforeAll failed'); + const carolId = randomUUID(); + await expect( + db.transaction(async (tx) => { + await tx.orm.Item.create({ id: carolId, name: 'carol' }); + throw new Error('intentional rollback'); + }), + ).rejects.toThrow('intentional rollback'); + + const rows = await db.orm.Item.all(); + const names = rows.map((row) => row.name).sort(); + expect(names).toEqual(['alice', 'bob']); + }, 60_000); +}); diff --git a/test/utils/src/exports/index.ts b/test/utils/src/exports/index.ts index d9519574f1..2b36430aef 100644 --- a/test/utils/src/exports/index.ts +++ b/test/utils/src/exports/index.ts @@ -18,6 +18,13 @@ function normalizeConnectionString(raw: string): string { export interface DevDatabase { readonly connectionString: string; + /** + * `@prisma/dev`'s `server.ppg.url`. As of `@prisma/dev@0.24.7` this endpoint + * serves the Accelerate / data-proxy GraphQL protocol — NOT `@prisma/ppg`'s + * raw-SQL protocol, despite the shared `prisma+postgres://` scheme. Surfaced + * for forward compatibility; not consumable by `driver-ppg-serverless` today. + */ + readonly ppgUrl: string; close(): Promise; } @@ -36,6 +43,7 @@ export async function createDevDatabase(options?: ServerOptions): Promise