references/build.md
# Prisma 8 — Build-System Integration
> **Edit your data contract. Prisma handles the rest.**
This skill covers Prisma 8's build-tool plugins — the dev-server / build-system integrations that re-emit contract artifacts automatically as the user edits the contract source. Today that's [`@internal/vite-plugin-contract-emit`](https://github.com/prisma/prisma/blob/main/packages/1-framework/3-tooling/vite-plugin-contract-emit/README.md) for Vite 7 and Vite 8. Next.js, Webpack, esbuild, Rollup, and Turbopack plugins are documented under *What Prisma 8 doesn't do yet* with the workaround.
**If the project is using Vite and consuming the contract, install the plugin.** There's no good reason not to — manual `prisma contract emit` during dev is friction the plugin eliminates. The agent should proactively offer the plugin whenever it sees a `vite.config.ts` in the project; the user doesn't need to ask.
## When to Use
- The project is using Vite (the agent sees a `vite.config.ts` or `@vitejs/*` deps) and the contract is being consumed at runtime — proactively offer the plugin.
- User asks how to wire Prisma 8 into their Vite project.
- User asks about Next.js integration, Webpack integration, or any other bundler — the answer is *"not yet, here's the workaround"* and the skill walks them through it.
- User mentions: *vite plugin, vite-plugin, vite.config.ts, prismaVitePlugin, contract emit on save, HMR, hot reload contract, dev server, vite 7, vite 8*.
- User mentions Next.js / Webpack / esbuild / Rollup / Turbopack in the context of Prisma 8 integration — the gap-listing path fires.
## When Not to Use
- User wants to wire `db.ts` and middleware → `references/runtime.md`.
- User wants to file a feature request for an unbuilt bundler plugin → `references/feedback.md`.
## Key Concepts
- **The plugin's job is `contract emit`, on a schedule the bundler knows about.** It is *not* a runtime concern — at runtime, the application reads `contract.json` / `contract.d.ts` the same way whether the plugin emitted them or a script did. The plugin saves you the manual command during development.
- **Vite 7 and Vite 8 only.** Peer range `^7.0.0 || ^8.0.0`. Vite 6 is not on the support matrix.
- **`executeContractEmit` is the canonical publish path.** Custom plugins for other bundlers must also call it — never re-implement the load → emit → publish dance. The atomic-rename invariant (`contract.d.ts` renamed before `contract.json`) and the per-output FIFO queue live in `@internal/cli/control-api`.
- **No build-time / production emission.** The Vite plugin runs in `vite dev` only. For `vite build` / production, run `prisma contract emit` from a `prebuild` script.
## Workflow — Vite (the supported path)
### 1. Install the plugin
```bash
pnpm add -D @internal/vite-plugin-contract-emit
```
(Or `npm install --save-dev`, `yarn add -D`, `bun add -d` — use what the project's package manager is.)
### 2. Wire `vite.config.ts`
```typescript
// vite.config.ts
import { defineConfig } from 'vite';
import { prismaVitePlugin } from '@internal/vite-plugin-contract-emit';
export default defineConfig({
plugins: [prismaVitePlugin('prisma.config.ts')],
});
```
The argument is the **path to `prisma.config.ts` relative to Vite root**. Not the path to `schema.psl` or `contract.ts` — the plugin reads the config to discover the contract source.
### 3. Configure (optional)
```typescript
plugins: [
prismaVitePlugin('prisma.config.ts', {
debounceMs: 150, // delay before re-emitting (default 150)
logLevel: 'info', // 'silent' | 'info' | 'debug' (default 'info')
}),
],
```
Set `logLevel: 'debug'` only while troubleshooting; default `'info'` in committed config so the dev server isn't noisy.
### 4. Verify the dev loop
1. Start `vite dev`.
2. Watch for the success log: `[prisma] emitted contract.d.ts + contract.json`.
3. Edit `prisma/schema.psl` (e.g. add a field to a model).
4. Within ~150ms (the debounce), watch for a re-emit log line.
5. Type-check your application code that uses the new field — should pass without restarting the dev server.
If the plugin warns about *config-only watching*, see [Common Pitfalls](#common-pitfalls).
### 5. CI / production builds
The plugin does **not** run during `vite build`. For CI and production deploys, run `prisma contract emit` as a prebuild step:
```json
// package.json
{
"scripts": {
"prebuild": "prisma contract emit",
"build": "vite build"
}
}
```
`pnpm build` then runs `prebuild` automatically before `build`.
## Workflow — React Router v7 Framework Mode
The Vite plugin is compatible with `@react-router/dev/vite`. Both plugins are listed in `vite.config.ts`; there's no ordering constraint between them today, and the Prisma 8 plugin's re-emit fires alongside React Router's own SSR re-load.
```typescript
import { reactRouter } from '@react-router/dev/vite';
import { prismaVitePlugin } from '@internal/vite-plugin-contract-emit';
export default defineConfig({
plugins: [
reactRouter(),
prismaVitePlugin('prisma.config.ts'),
],
});
```
See [`examples/react-router-demo`](https://github.com/prisma/prisma/tree/main/examples/react-router-demo) for the canonical configuration plus a smoke test that proves the dev loop.
## Common Pitfalls
1. **Pointing the plugin at `schema.psl` instead of `prisma.config.ts`.** The argument is the config path. The plugin reads the config to find the contract source.
2. **Vite 6 or earlier.** Not supported. Upgrade Vite to 7 or 8.
3. **The plugin warns: *"watching only the config; loader resolved inputs unavailable."*** The plugin couldn't resolve `contract.source.inputs` from the loader. The fallback watches only `prisma.config.ts` itself, so contract edits won't re-emit. Causes: the config file throws during loading; the contract source path resolves outside the Vite root. Fix the config error first, then check that the contract source path in the config is relative to (or inside) the Vite root.
4. **Expecting `vite build` to re-emit.** It doesn't. Add a `prebuild` script.
5. **Emit errors during dev**: the plugin surfaces them via Vite's error overlay. Read the overlay; the underlying cause is a contract authoring problem — chain to `references/debug.md` for resolution (PSL syntax, missing namespace, conflicting extensions).
6. **Re-installing dependencies without the plugin's peer-range move.** When PN bumps the plugin's peer range, you must re-run `pnpm install` so the lockfile picks up the new range. A stale lockfile keeps the old plugin and produces confusing version mismatch warnings.
## What Prisma 8 doesn't do yet
- **Next.js plugin.** No first-party `@internal/next-plugin-*` exists. Workaround: run `prisma contract emit` from a `prebuild` script in `package.json` and run it manually during development when the contract changes. Many Next.js projects also run a dev-time `tsx --watch` against a small script that calls the CLI on contract-source change. If you want a first-party Next.js plugin, file a feature request via the `references/feedback.md` skill.
- **Webpack, esbuild, Rollup, Turbopack plugins.** None exist yet as first-party. Workaround: the canonical `executeContractEmit` surface lives in `@internal/cli/control-api` — a small per-bundler plugin can call it from the bundler's prebuild hook, but PN doesn't ship one for you. The `vite-plugin-contract-emit` source is the reference implementation if you want to write one yourself. If you want a first-party plugin for your bundler, file a feature request via the `references/feedback.md` skill.
- **`vite build` integration.** The plugin runs in `vite dev` only. Workaround: a `prebuild` script that runs `prisma contract emit`. If you want the plugin to also run during `vite build`, file a feature request via the `references/feedback.md` skill.
- **Vite 6 or earlier.** Not on the support matrix. Workaround: upgrade Vite to 7 or 8. If you have a hard reason to stay on Vite 6, file a feature request via the `references/feedback.md` skill.
## Reference Files
- The plugin's own README: <https://github.com/prisma/prisma/blob/main/packages/1-framework/3-tooling/vite-plugin-contract-emit/README.md> — support matrix, full API surface, architecture diagram, the *canonical publish path* warning for custom plugin authors.
- ADR 008 (Dev Auto-Emit, CI Explicit Emit) — the rationale for splitting dev-time auto-emit from the explicit CI / build step.
- ADR 032 (Dev Auto-Emit Integration) — the plugin's integration contract with the CLI control API.
## Checklist
- [ ] Plugin pointed at `prisma.config.ts` (not the contract source).
- [ ] Vite version 7 or 8 (`pnpm ls vite`).
- [ ] `vite dev` log shows the initial emit on server start.
- [ ] Editing the contract source triggers a re-emit log line.
- [ ] `prebuild` script (or equivalent) runs `prisma contract emit` for CI / production builds.
- [ ] No `vite build` expectation that the plugin will run.
- [ ] For non-Vite bundlers: surfaced the *What PN doesn't do yet* entry and routed the user to `references/feedback.md` if they want first-party support.
- [ ] Did NOT confabulate a `@internal/next-plugin-contract-emit` package or any other bundler-specific plugin that doesn't exist.
references/contract.md
# Prisma 8 — Contract Authoring
> **Edit your data contract. Prisma handles the rest.**
The data contract is the single source of truth for your data layer. You edit a contract source — `contract.prisma` (PSL, the canonical surface) or `contract.ts` (TypeScript builder) — and the framework derives types, migrations, and runtime configuration from it. The three-step user model:
1. **You edit your data contract.**
2. **The system plans the migrations for you.** (`references/migrations.md`)
3. **If you need data migrations, you edit `migration.ts` and execute it.** (`references/migrations.md`)
Behind step 1 the agent runs `prisma contract emit` after every contract edit (or installs the Vite plugin so the bundler runs it on save — see `references/build.md`). Emit reads the contract source through the provider the façade picks based on the file extension of `contract:` in `prisma.config.ts`, then writes two artefacts colocated with the source:
- `contract.json` — the canonical, content-hashed Contract IR. Read by the planner, the runtime, and `db verify`.
- `contract.d.ts` — the precise TypeScript types the runtime + lanes propagate when you import `Contract` from it.
Both files are **emitted artefacts**. Edit the source; never the JSON or `.d.ts`.
## When to Use
- User wants to add, change, or remove a model / field / relation.
- User wants to add an index, unique constraint, check constraint, enum, or value object (composite type).
- User wants to add a namespace block (Postgres schema) or a cross-contract foreign key.
- User wants to set `@@control` on a model or configure `defaultControlPolicy`.
- User wants to use a custom type from an extension (`pgvector.Vector(length: 1536)`, `postgis.Geometry(...)`).
- User needs a column of a database type no Prisma column type covers (`tsvector`, `ltree`, `circle`, a domain): a raw column, ``Raw(sql`...`)``.
- User wants to install or configure an extension via `extensions: [...]` in `prisma.config.ts`, including `@internal/extension-supabase`.
- User is migrating between authoring sources (PSL ↔ TypeScript builder).
- User received `CONFIG.CONTRACT_MISSING`, `CONTRACT.VALIDATION_FAILED`, or `CONFIG.MISSING_EXTENSION_PACKS` from `contract emit`.
- User mentions: *schema, fields, models, attributes, prisma schema, PSL, contract.prisma, contract.ts, contract.json, contract.d.ts, contract emit, façade imports, `@internal/postgres/config`, `@internal/postgres/contract-builder`, extensions, pgvector, postgis, paradedb, supabase, namespaces, cross-space FK, `@@control`, enums, check constraints, `@@check`, value objects, validations, callbacks, soft delete, paranoid, scopes*. (The last cluster routes to *What Prisma 8 doesn't do yet* below.)
## When Not to Use
- User wants to apply a contract change to the DB → `references/migrations.md`.
- User wants to write a query against the contract → `references/queries.md`.
- User wants to wire `db.ts` (runtime entry point, middleware, env config) → `references/runtime.md`.
- User wants the Vite / bundler integration → `references/build.md`.
- User wants to set up Prisma 8 for the first time → `references/quickstart.md`.
- User wants a deeper read of a single structured error envelope → `references/debug.md`.
- User wants to file a missing-feature request → `references/feedback.md`.
## Key Concepts
- **The `@internal/<target>` façade is the only surface user-authored code imports from.** For a Postgres app: `@internal/postgres/config`, `@internal/postgres/contract-builder`, `@internal/postgres/control`, `@internal/postgres/runtime`, `@internal/postgres/static`. Mongo has the same layout (`@internal/mongo/config`, `@internal/mongo/contract-builder`, `@internal/mongo/runtime`, `@internal/mongo/static`). Each extension publishes its own façade — `@internal/extension-pgvector/control`, `@internal/extension-postgis/control`, `@internal/extension-paradedb/control`. **Never reach into `@internal/cli/*`, `@internal/family-*`, `@internal/target-*`, `@internal/adapter-*`, `@internal/driver-*`, or `@internal/sql-contract-*` from user code.** The façade bakes the family / target / adapter / driver wiring in. See *Common Pitfalls* #4.
- **Contract source.** A file the framework reads and lowers to the canonical Contract IR. Two flavours, both first-class:
- **`contract.prisma` (PSL)** — schema-flavoured DSL. Canonical for typical apps and brownfield Prisma users. Wired by `contract: './<path>/contract.prisma'` — the target config (`ormConfig`, below) detects the `.prisma` extension and routes through the PSL provider. The first line of every `.prisma` file is `// use prisma-8`; `orm init` and `contract infer` write it, and the language server only serves diagnostics, completion, and formatting on files that carry it (it still recognises the header earlier releases wrote and rewrites it on Format).
- **`contract.ts` (TypeScript builder)** — programmatic authoring with `defineContract({...}, ({ field, model, rel, type }) => ({...}))` from `@internal/postgres/contract-builder` (or `@internal/mongo/contract-builder`). Wired by `contract: './<path>/contract.ts'` — the façade detects the `.ts` extension and routes through the TS provider. Use when you need programmatic composition (per-tenant variants, generated fields) or constructs PSL doesn't yet express (e.g. registering a parameterised extension type — see pgvector's contract).
- **An existing Prisma schema, during adoption.** A project that still runs an earlier Prisma can point `contract:` at its current `schema.prisma` instead of writing a Prisma 8 contract: `contract: prisma6Schema('prisma/schema.prisma')` from `@internal/mongo/config` reads a Prisma 6 MongoDB schema, and `prisma7Schema` from `@internal/postgres/config` reads a Prisma 7 Postgres schema. The earlier Prisma keeps owning the database; after each change there, run `contract emit` and `db sign`. A construct Prisma 8 cannot express fails `contract emit` with a `PSL.PRISMA6_MONGO_*` or `PSL.PRISMA7_*` diagnostic. See the façade READMEs for the details.
- **`prisma.config.ts`.** Wires the contract source, the database connection, the migrations directory, and any installed extensions. The file is an *envelope*: the unified CLI's `definePrismaConfig({...})` from `@prisma/cli-engine` wraps an `orm:` section built by the target's `defineConfig` — conventionally imported as `ormConfig` from `@internal/postgres/config` (or `@internal/mongo/config`). There is no flat form; a bare `defineConfig({ contract, ... })` default export fails with `CONFIG.VERSION_MARKER_MISSING`.
```typescript
// prisma.config.ts — what `prisma orm init` scaffolds
import 'dotenv/config';
import { definePrismaConfig } from '@prisma/cli-engine';
import { defineConfig as ormConfig } from '@internal/postgres/config';
export default definePrismaConfig({
orm: ormConfig({
contract: './src/prisma/contract.prisma',
db: { connection: process.env['DATABASE_URL']! },
}),
});
```
The fields `ormConfig` accepts: `contract` (path string — `.prisma` or `.ts`), `output` (optional override for where `contract.json` lands), `db` (`{ connection?: string }`), `extensions` (array of control descriptors), `migrations` (`{ dir?: string }`). Without `output`, the path for `contract.json` is derived from `contract` (e.g. `./src/prisma/contract.prisma` → `./src/prisma/contract.json`). Keep `import 'dotenv/config'` when the config reads `process.env`.
- **Emit pipeline.** `prisma contract emit --config <path>?` reads `prisma.config.ts`, calls the provider the façade picked, validates the resulting Contract, then atomically writes `contract.json` + `contract.d.ts` colocated with the source.
- **Extension namespaces.** Extensions contribute namespaced constructors (`pgvector.Vector(length: 1536)`, `postgis.Geometry(...)`) and type aliases. Install them by adding the descriptor to **two** places — both fields are named `extensions`, but the two surfaces consume two different descriptor types and shapes:
- **In the config (façade and core):** `extensions: [pgvector]` — array of *control* descriptors imported from `@internal/extension-<name>/control`.
- **In the TS builder's `defineContract` (only when authoring `contract.ts`):** `extensions: { pgvector }` — record of *pack* descriptors imported from `@internal/extension-<name>/pack`.
- **Contract space.** Every package that emits a contract owns its own *contract space* — a `prisma.config.ts` at package root, a contract source, the colocated emitted artefacts, and a `migrations/` directory. **There are two intentional on-disk layouts**, picked by whether the contract space is the consuming application or a contract-space package (an extension, an internal aggregate-root package, etc.):
- **Application layout** (what you use when building an *app*). `prisma.config.ts` at repo root; `src/prisma/contract.{prisma,ts}`; `src/prisma/contract.{json,d.ts}` colocated; `src/prisma/db.ts` colocated; migrations under `migrations/app/<timestamp>_<slug>/`. The `app/` segment is the consuming application's space-id; extension space-ids land in sibling `migrations/<extension-space-id>/` directories that the extension packages manage. This is what `examples/prisma-8-demo` uses and what `prisma orm init` scaffolds by default (`src/prisma/contract.prisma` with `db.ts` beside it).
- **Contract-space-package layout** (what you use when *publishing* a contract-space package — extensions, internal monorepo packages). `prisma.config.ts` at package root; `src/contract.{prisma,ts}` directly (no `prisma/` subdir); `src/contract.{json,d.ts}` colocated; `migrations/<timestamp>_<slug>/` directly under `migrations/` (no `<space-id>` segment — the package *is* a single space). Documented in `.cursor/rules/contract-space-package-layout.mdc` and ADR 212.
Both layouts let `ormConfig`'s `contract:` path point at the source; the framework derives everything else (emit output, migration root) from there. Pick the layout that matches what you're building and stick with it — don't mix.
## Diagnostic codes you route on
`prisma contract emit` surfaces structured errors with stable codes; branch on `code` rather than message text.
| Code | Meaning | Next move |
|---|---|---|
| `CONFIG.CONTRACT_MISSING` | `contract` not set in `prisma.config.ts`. | Add `contract: './src/prisma/contract.prisma'` (app layout) or `'./src/contract.prisma'` (contract-space-package layout) — likewise for `.ts` sources — to the `ormConfig({...})` section. |
| `CONFIG.VERSION_MARKER_MISSING` | The default export was not produced by `definePrismaConfig` / `ormConfig` (a flat `defineConfig({...})`, a plain object, or a Prisma 7 config). | Rewrite to the envelope form in *Key Concepts* above. |
| `CONTRACT.VALIDATION_FAILED` | Source loaded but the Contract IR failed structural validation. | Read `meta.errors` for the offending model/field, fix the source, re-emit. |
| `CONFIG.MISSING_EXTENSION_PACKS` | The contract uses a namespaced constructor (e.g. `pgvector.Vector(...)`) but `extensions` in the config does not list a matching descriptor. `meta.missingExtensionPacks` names them. | Install the package, import its control descriptor (`import pgvector from '@internal/extension-pgvector/control'`), add it to `extensions: [...]` in `ormConfig({...})`. |
## Workflow — Read the contract source of truth
The concept: every contract change starts by locating the source file. The config is authoritative — read `prisma.config.ts`, find the `contract:` field (a path string under the façade), and open the file it points at. The same field tells you the installed `extensions: [...]`.
```bash
cat prisma.config.ts
```
If `contract:` ends in `.prisma`, the source is PSL; if it ends in `.ts`, the source is the TS builder. If `prisma.config.ts` is missing, route to `references/quickstart.md`.
## Workflow — Edit a model / field / relation (PSL)
The concept: PSL models lower to tables (or collections, on Mongo); fields lower to columns; `@relation(...)` declares the FK side. Add the relation only on the owning side — the framework derives the back-reference automatically.
```prisma
model User {
id Int @id @default(autoincrement())
email String @unique
}
model Post {
id Int @id @default(autoincrement())
title String
authorId Int
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
@@unique([title, authorId])
@@index([authorId])
}
```
Then run `pnpm prisma contract emit` (or rely on the Vite plugin — see `references/build.md`). Specify cascade behaviour explicitly with `onDelete` / `onUpdate`; the default is `Restrict`.
**Integer, decimal, float, text, boolean and binary types.** On PostgreSQL, `Int`, `BigInt`, `Float`, `Decimal`, `String`, `Boolean` and `Bytes` are convenience types: bare, each is the usual type (`Int` an `int4`, `BigInt` an `int8` read as a `bigint`, `Decimal` a `numeric` read as decimal text, `String` a `text`). An optional `storage` chooses another database type, and `BigInt` also takes `as`: `Int(storage: int2)`, `BigInt(as: number)` (an `int8` read as a `number` in the safe-integer range), `BigInt(storage: numeric)` (an integer of any size, read as a `bigint`), `Float(storage: float4)`, `Decimal(precision: 10, scale: 2)`, `String(storage: varchar, length: 12)`, `String(storage: char, length: 3)`. `String(length: 12)` is refused: `length` applies only to `varchar` and `char`. The contract stores the explicit type each call chooses, named after the database type (`Int2`, `Int4`, `Int8BigInt`, `Int8Number`, `NumericString`, `NumericBigInt`, `Float4`, `Float8`, `Text`, `Varchar`, `Char`, `Bool`, `Bytea`), and writing that type directly is the same. `SmallInt`, `BigIntNumber`, `Numeric`, `UnboundedInt`, `Real` and `VarChar` are deprecated names of `Int2`, `Int8Number`, `NumericString`, `NumericBigInt`, `Float4` and `Varchar`, and warn `PSL_DEPRECATED_SCALAR_NAME`. In a TypeScript contract: `type.BigInt({ as: 'number' })`, `type.String({ storage: 'varchar', length: 64 })`.
**Date and time types.** On PostgreSQL, write a field with a convenience type named for what it means: `DateTime` (a moment in time, a `timestamptz(3)` read as a JavaScript `Date`), `Date` (a calendar date, read as a string such as `"2024-02-29"`), `Time` (a time of day, read as a string) or `Duration` (an ISO 8601 duration string such as `"P1M"`). None needs `Temporal`. Optional arguments choose another explicit type: `as`, the value the application reads (`jsDate`, `temporal` or `string` for `DateTime`; `string` or `temporal` for `Date` and `Time`; `string` or `parts` for `Duration`), `storage`, the database type that stores it (`timestamptz` or `timestamp` for `DateTime`; `time` or, with `as: string`, `timetz` for `Time`), and `precision`: `DateTime(as: temporal)`, `DateTime(storage: timestamp)`, `Time(precision: 0)`. The contract stores only the explicit type, and `contract print` writes it; the full table is [Scalar types](https://github.com/prisma/orm/blob/main/docs/reference/scalar-types.md). SQLite has the same four types with the same defaults, all stored as text: `DateTime` reads a `Date`, `DateTime(as: temporal)` a `Temporal.Instant` and `DateTime(as: string)` the stored text `"2024-01-01T00:00:00.000Z"`; `Date` and `Time` read `"2024-02-29"` and `"09:30:00.000"`, or `Temporal.PlainDate` and `Temporal.PlainTime` with `as: temporal`; `Duration` reads an ISO 8601 duration. SQLite has no `storage` choices and no `precision`: an instant and a time hold milliseconds. MySQL has the same four types with the same defaults: `DateTime` is a `datetime(3)` read as a `Date`, `storage: timestamp` picks a `timestamp` column (1970 to 2038), and `as: temporal` and `as: string` default to `precision: 6`; `Date` and `Time` read `"2024-02-29"` and `"09:30:00"`; `Duration` is ISO 8601 text in a `varchar(191)`. Every MySQL instant is UTC, and a value written with more fraction digits than its column holds is cut to the column's precision, not rounded, but a value with digits below a microsecond (a `Temporal` value ending in `.123456789`) is refused, so round it to microseconds first; a column with no written precision holds whole seconds. `Time(3)` is refused, because the first argument is `as`; write `Time(precision: 3)`. In a TypeScript contract the helpers take one object: `type.DateTime({ as: 'string', precision: 6 })`, `type.Time({ storage: 'timetz' })`.
**Temporal columns.** On PostgreSQL, `DateTemporal`, `TimestampTemporal(p)`, `TimestampInstant(p)`, `TimestamptzTemporal(p)` and `TimeTemporal(p)`, on SQLite `DatetimeTemporal`, `DateTemporal` and `TimeTemporal`, on MySQL `DatetimeTemporal(p)`, `TimestampTemporal(p)`, `DateTemporal` and `TimeTemporal(p)` (and a convenience type with `as: temporal` on any of them) read and write `Temporal` values (`Temporal.PlainDate`, `PlainDateTime`, `Instant` for both instant types, `PlainTime`), never JavaScript `Date`. They need a global `Temporal` at query time: Node.js 26.8.2 and later ship `globalThis.Temporal`; 26.8.1 and earlier — including every 22 and 24 release — do not, and the first read or write of such a column throws `RUNTIME.TEMPORAL_UNAVAILABLE`. On those runtimes either `import 'temporal-polyfill/full/global'` before the first query (add `temporal-polyfill` as a dependency) or author the column as `DateString` / `TimestampString(p)` / `TimestamptzString(p)` / `TimeString(p)`, which carry ISO 8601 text, the same on every server (`2024-01-01T00:00:00Z` for a `timestamptz`, the instant in UTC), and need no `Temporal`, or as `TimestamptzJsDate(p)` / `TimestampJsDate(p)`, which read a JavaScript `Date`. `IntervalParts(p)` reads `{ months, days, micros }` and `IntervalString(p)` an ISO 8601 duration such as `P1Y2M`. `prisma contract emit`, `prisma db init` and the other commands need no global `Temporal`: the Postgres, SQLite and MySQL targets load `temporal-polyfill` as a fallback `Temporal` for the control plane, and set no global. `temporal-polyfill` is a required peer dependency of `@prisma/orm-postgres`, `@prisma/orm-sqlite` and `@prisma/orm-mysql`. npm, pnpm and bun install it automatically. With Yarn, add `temporal-polyfill` (`^1.0.4`) to the project's dependencies, or the commands fail because Node.js cannot find the package. That fallback is held once per process. An application that loads control-plane code in its own process, such as server code under `vite dev` with the Prisma Vite plugin or a script that calls the control client, decodes dates without its own `Temporal`, and then fails in production. An application that uses the Temporal codecs must load its own `Temporal`. A TypeScript contract file that constructs a `Temporal` value must load an implementation too.
`@@index` also accepts `expression:` (instead of a fields list), `where:` (partial-index predicate), `unique:`, `type:`/`options:` (target-registered access method), and `name:` xor `map:`. Raw SQL in `expression:` and `where:` is a `sql` literal; a quoted string is refused with `PSL_VALUE_TYPE_INCOMPATIBLE`, whose message gives the rewrite:
```prisma
@@index(expression: sql`lower(email)`, name: "users_email_lower")
@@index([authorId], where: sql`(archived_at IS NULL)`, name: "posts_author_active")
```
`name:` declares a wire-named index (physical name `<name>_<8-hex hash>`, renames plan as `ALTER INDEX … RENAME`); `map:` adopts an exact physical name verbatim (for infer-captured objects — combining it with a SQL body warns, because drift detection byte-compares the authored text against Postgres's reprint). An `expression:` requires `name:` or `map:`. The TS builder mirrors this via `constraints.index([cols.x], {...})` / `constraints.index({ expression, ... })`, where `expression` and `where` are `sql` values such as ``sql`lower(email)` `` (a string does not compile) — see `packages/2-sql-family/2-authoring/contract-ts/README.md`.
**Full-text search indexes (PostgreSQL).** Do not hand-write the `to_tsvector` expression — Postgres only uses the index when the query carries the same search document: the same columns, configuration and weights. Declare `@@fullTextIndex`, and the index and the queries `fullTextMatches` / `fullTextRank` / `fullTextHeadline` build are rendered from the same definition (see `references/queries-postgres.md`):
```prisma
@@fullTextIndex([text], name: "message_text_search")
@@fullTextIndex([summary], language: "german", name: "message_summary_search_de")
@@fullTextIndex([text], where: sql`archived_at IS NULL`, name: "message_text_search_live")
@@fullTextIndex([[title, subtitle], body], name: "post_search")
```
The fields are one field, or a list whose items are fields or lists of fields. Each top-level item is a weight group, strongest first (`A` to `D`, so at most four groups); fields in a nested list share a weight. `[title, body]` is two groups, `[[title, body]]` one. A single field has no weight. Diagnostics refuse more than four groups, an empty group, a field named twice, an unknown field and a field that is not text.
The TS builder has the same helper: `fullTextIndex(cols.text, { name: 'message_text_search' })` or `fullTextIndex([[cols.title, cols.subtitle], cols.body], { name: 'post_search' })`, from `@prisma/orm-postgres/contract-builder`, inside the model's `sql({ indexes: [...] })`. Both take an optional `language` (default `english`, from the same allowlist the operations accept), an optional `where:` for a partial index (a `sql` value, ``where: sql`archived_at IS NULL` ``), and `name:` xor `map:`. Both are repeatable. In the SQL builder, pass the index itself, `table.indexes.<name>`, to `fullTextMatches` and `fullTextRank`, which then search its document with its groups and language. A column or a `fullTextDocument` given other groups or another `language` than the index is silent, costing the index and falling back to a sequential scan. The parser that builds the query (`websearchToTsquery` and the others) takes its own `language` for the query side; it does not affect index use. With `map:` the index keeps the exact database name you give it, and `db verify` compares the rendered search document with the text Postgres prints back for the index exactly, character for character. Postgres prints it in its own form (`to_tsvector('english'::regconfig, title)`), so a `map:` full-text index reports drift; `@@fullTextIndex` and `fullTextIndex` warn about this with `PN_EXACT_NAME_BODY_COMPARISON`. Use `name:` unless the database already has the index under that name.
The contract stores the index as data, not SQL: an index of type `fullText` whose `columns` are the covered storage columns and whose `options` are `{ weightGroups, language }`, the weight groups as storage column names. The DDL is a GIN index over the rendered document, `to_tsvector('<language>', "<column>")` for one field, and `setweight(...) || ...` with every column wrapped in `coalesce` for several. `@@index(expression: …)` remains only for expressions this attribute does not cover.
PSL aliases for repeated types are declared in top-level `types {}` blocks. A schema may have several `types` blocks, in one file or several; each name may be declared only once across all of them, and a repeated name fails with `PSL_DUPLICATE_DECLARATION`:
```prisma
types {
Email = String
}
model User {
id Int @id @default(autoincrement())
email Email @unique
}
```
An alias is expanded into each field that uses it: the column stores the alias's codec and type params, and the contract stores no alias. An alias may carry `@default`, `@onCreate` and `@onUpdate`, which every field typed by it takes: `UpdatedAt = TimestampString(3) @onCreate(now()) @onUpdate(now())`. It may not carry `@id` or `@unique`; write those on the field. A field may add attributes its alias does not set (`updatedAt UpdatedAt @default(now())`), but repeating one the alias sets is `PSL_DUPLICATE_ATTRIBUTE`. An alias may not name another alias. The targets contribute `temporal.CreatedAt` (generated on create) and `temporal.UpdatedAt` (on create and on update), written bare: `createdAt temporal.CreatedAt`. On Postgres they are `DateTime` with its defaults, a `timestamptz(3)` column read as a JavaScript `Date`; on SQLite `DateTime`; on Mongo `Date`. They take no arguments; for another kind of value or a precision, write the type and attributes, as in `DateTime(as: string) @onCreate(now())`. The SQL targets also contribute an alias for the column of each identifier format the client generates: `sql.UuidString`, `sql.Ulid`, `sql.Nanoid`, `sql.Cuid2` and `sql.Ksuid`, which Postgres also names at the top level, as `Ulid`. Each stores `Char(n)` of its format's length and carries no generator, so the key writes `id Ulid @id @onCreate(ulid())` and a foreign key writes `authorId Ulid`. On Postgres, a UUID is better stored in the native column: `id Uuid @id @onCreate(uuid(7))`.
Values the client generates are written `@onCreate(fn())` and `@onUpdate(fn())`: `id String @id @onCreate(uuid(7))`, `slug String @onCreate(nanoid(12))`, `updatedAt DateTime @onCreate(now()) @onUpdate(now())`. `@default(...)` is a database default only (`now()`, `autoincrement()`, a literal, or `` sql`...` ``); `@default(uuid())` is refused with `PSL_GENERATOR_IN_DEFAULT`. In the TS builder, write `.onCreate(gen.uuid(7))` and `.onUpdate(gen.now())`. Column defaults and generators can sit on one field: `@default(now()) @onCreate(now())`.
In the TS builder, an alias is a constant passed as the field, because field builders are immutable: `const Slug = field.column(type.String({ storage: 'varchar', length: 64 }))`, `const UpdatedAt = field.column(type.TimestampString(3)).onCreate(gen.now()).onUpdate(gen.now())`, then `slug: Slug, updatedAt: UpdatedAt.default(now())`. `type.*` has a helper for every PSL type constructor, and the contributed aliases are `type.temporal.CreatedAt()`, `type.temporal.UpdatedAt()` and the identifier aliases such as `type.Ulid()` (`type.sql.Ulid()` on every SQL target), used as `field.column(type.temporal.UpdatedAt())` and `field.column(type.Ulid()).id().onCreate(gen.ulid())`. A definition has no `types` key (`CONTRACT.TYPES_KEY_REMOVED`), and `field.namedType(...)` takes only an enum handle.
Note: scalar lists (e.g. `String[]`) are gated on the target's `sql.scalarList` capability — PostgreSQL declares it, so `tags String[]` lowers to an array column (with a generated no-NULL-elements check; write `tags String?[]` for a list whose elements may be null, which gets no such check); SQLite does not, and rejects the field with `PSL_SCALAR_LIST_UNSUPPORTED_TARGET`. Implicit Prisma-ORM many-to-many (list navigation on both sides without a join model) is rejected by the SQL interpreter — declare the join model explicitly and the ORM traverses it for you (`references/queries.md`). Composite/embeddable types (`type Address { ... }` with `address Address` on a model) are supported: the interpreter lowers them to storage value objects, at `storage.namespaces.<ns>.entries.valueObject`, and stores them as `jsonb` columns. See *Workflow — Value objects* below.
## Workflow — Edit a model / field / relation (TS builder)
The concept: same model, different authoring surface. The façade re-exports `defineContract`, `field`, `model`, `rel`, plus the `family`/`target` packs as default exports of `@internal/postgres/family` and `@internal/postgres/target`. Use the callback overload (`defineContract({...}, ({ field, model, rel, type, gen }) => ({...}))`) to get the type constructors, type aliases and generator calls (`field.column(type.String())`, `field.column(type.Uuid()).id().onCreate(gen.uuid(7))`, `field.column(type.temporal.CreatedAt())`, `type.sql.String(35)`).
```typescript
import sqlFamily from '@internal/postgres/family';
import { defineContract } from '@internal/postgres/contract-builder';
import postgresPack from '@internal/postgres/target';
export const contract = defineContract(
{
family: sqlFamily,
target: postgresPack,
},
({ field, model, gen, type }) => ({
models: {
User: model('User', {
fields: {
id: field.column(type.Uuid()).id().onCreate(gen.uuid(7)),
email: field.column(type.String()).unique(),
createdAt: field.column(type.temporal.CreatedAt()),
},
}).sql({ table: 'app_user' }),
},
}),
);
```
Then `pnpm prisma contract emit`. On Postgres, `@prisma/orm-postgres/contract-builder` exports `field`, `type` and `gen`, the same helpers the callback receives, without the ones an extension adds (such as pgvector's `type.pgvector.Vector`); for those, use the callback's helpers. On other targets `type` and `gen` are only available inside the callback overload.
For Mongo, swap every `@internal/postgres/*` import for `@internal/mongo/*`. The Mongo builder also exposes `index` and `valueObject`.
Mongo scalar types, as PSL name / TS builder / application type: `String` / `field.string()` / `string`; `Int32` / `field.int32()` / `number`; `Int64` / `field.int64()` / `bigint`; `Double` / `field.double()` / `number`; `Decimal128` / `field.decimal128()` / decimal text as a `string`; `Bool` / `field.bool()` / `boolean`; `Date` / `field.date()` / `Date`; `ObjectId` / `field.objectId()` / `string`; `Binary` / `field.binary()` / `Uint8Array`; `Json` / `field.json()` / `JsonValue`; `Bson` / `field.bson()` / `BsonValue`. `Json` holds a JSON value only, validated as the JSON-representable BSON types; `Bson` holds any BSON value, unconstrained by the validator. The full table, with codec ids and storage types, is [Scalar types](https://github.com/prisma/orm/blob/main/docs/reference/scalar-types.md).
Mongo timestamps the ORM fills: in PSL, write `createdAt Date @onCreate(now())` (set on create) or `updatedAt Date @onCreate(now()) @onUpdate(now())` (set on create and on every update with a non-empty payload), or type the field `temporal.CreatedAt` or `temporal.UpdatedAt`; in TS, use `field.date().onCreate(gen.now())` or `field.date().onCreate(gen.now()).onUpdate(gen.now())`. They store a BSON date read back as `Date`, one value per ORM operation, and an explicit value in the write wins. The fields are optional on create. A field with a generated value cannot be optional (`?`), a list, `@id`, or a field of a composite type.
## Workflow — Add an extension-typed scalar (pgvector)
The concept: an extension contributes a namespace (`pgvector.*`) plus two descriptor flavours — a *control* descriptor for the config and a *pack* descriptor for the TS builder. Register the control descriptor in `ormConfig`'s `extensions` (array form). If you're authoring with the TS builder, also register the pack descriptor in `defineContract.extensions` (record form). Then reference the namespaced constructor from the contract.
`prisma.config.ts`:
```typescript
import 'dotenv/config';
import { definePrismaConfig } from '@prisma/cli-engine';
import pgvector from '@internal/extension-pgvector/control';
import { defineConfig as ormConfig } from '@internal/postgres/config';
export default definePrismaConfig({
orm: ormConfig({
contract: './src/prisma/contract.prisma',
extensions: [pgvector],
db: { connection: process.env['DATABASE_URL']! },
}),
});
```
`src/prisma/contract.prisma`:
```prisma
model Document {
id Int @id @default(autoincrement())
content String
embedding pgvector.Vector(length: 1536)
}
```
Emit. The named-type lowering puts `vector(1536)` on the column and the type map in `contract.d.ts` carries the right TS type.
If you reference `pgvector.*` without registering the descriptor in the config, emit fails with `CONFIG.MISSING_EXTENSION_PACKS` and `meta.missingExtensionPacks: ['pgvector']`. The envelope's `fix` text says *"Add the missing extension descriptors to `extensions` in prisma.config.ts"* — that field name matches `ormConfig`.
For canonical worked examples covering single and multi-extension setups, read `examples/prisma-8-demo/prisma.config.ts`, `examples/multi-extension-monorepo/app/prisma.config.ts` and `examples/prisma-8-postgis-demo/prisma.config.ts`.
## Workflow — Raw columns (a type no column type covers)
The concept: ``Raw(sql`<type>`)`` declares a column whose type Prisma does not model, such as `tsvector`, `ltree`, `circle` or a domain of the user's own. Migrations write the text as given, and `db verify` compares it with the type the database reports, exactly. Values are typed `unknown`, read the same flat and through includes, and write back as read. On Postgres the ORM reads each value as the text the database prints for it. On SQLite a value keeps the shape of its storage class: a number, a string, a `Uint8Array` for a BLOB, or null. The ORM offers no filters or ordering on raw values. Raw columns exist on Postgres and SQLite.
```prisma
model Category {
id Int @id
search Raw(sql`tsvector`)
area Raw(sql`circle`) @default(sql`'<(0,0),1>'::circle`)
net Raw(sql`cidr`)?
tags Raw(sql`"MyTag"[]`)
}
```
In the TypeScript builder, use ``field.column(type.Raw(sql`tsvector`))`` in the callback overload, or ``field.column(rawColumn(sql`tsvector`))`` with `rawColumn` from the adapter's column types and `sql` from the contract builder. A quoted string, `Raw("tsvector")`, is refused with ``Expected sql`...`; write sql`tsvector` ``.
- Write the type as the database catalog prints it: `integer`, not `int4`; `character varying(10)`, not `varchar(10)`. Another form reports a type mismatch in `db verify`, and the finding names the text to write.
- A raw column's default is a `sql` expression. A literal default is refused at emit (`` Expected sql`...` ``).
- The text is one type name: words and double-quoted names separated by single spaces, the first optionally after one schema name and a dot, each optionally followed by parentheses of letters, digits, underscores, commas, spaces and a minus sign before a digit, and at most one trailing `[]`. `geometry(Point,4326)`, `double precision` and `app.email` (a domain whose schema is not on the `search_path`) are fine; quotes of other kinds, `$` and operators are refused.
- A type whose name needs double quotes is written with them inside the literal: ``Raw(sql`"MyType"`)``.
- Changing the text changes the column's type: `migration plan` writes `ALTER COLUMN ... TYPE`. If the database has no cast between the two types, name the field with `--convert` and write the conversion. The generated call is `alterColumnType({ ..., type: "text", raw: true })`; keep `raw: true` when editing it, since a raw type is checked as written.
## Workflow — Polymorphism (`@@discriminator` / `@@base`)
The concept (SQL targets): one base model declares the discriminator field; each variant model declares its base + discriminator value. The variant chooses STI vs MTI by **whether it sets `@@map(...)`**: no `@@map` means the variant inherits the base's table (single-table inheritance); `@@map("variant_table")` means the variant gets its own table joined 1:1 by primary key (multi-table inheritance).
```prisma
model Task {
id Int @id @default(autoincrement())
title String
type String
@@discriminator(type)
@@map("tasks")
}
// STI variant — shares the `tasks` table.
model Bug {
severity String
@@base(Task, "bug")
}
// MTI variant — joins to `tasks` via PK; carries its own `features` table.
model Feature {
priority Int
@@base(Task, "feature")
@@map("features")
}
```
A variant's base can be in another namespace: write `@@base(ns.Model, "value")`. Such a variant must set `@@map(...)`, because a variant that shares its base's table has to be in the base's namespace.
Verify the polymorphism syntax against the interpreter tests if in doubt: `packages/2-sql-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts`.
Mongo PSL takes the same `@@discriminator(field)` / `@@base(Base, "value")` attributes (the Mongo interpreter implements both; `examples/mongo-demo/src/contract.prisma` declares `Post` with `@@discriminator(kind)` and `Article` / `Tutorial` variants via `@@base(Post, "article")`). Variants share the base's collection — there is no `@@map` on a Mongo variant, since there is no second table to join. The TS builder's `discriminator` option on the model is the programmatic equivalent.
Querying the variants is a runtime concern — see `references/queries.md`.
## Workflow — Value objects (composite types)
The concept: `type Foo { ... }` blocks declare value-object shapes. The interpreter lowers them to storage value objects, at `storage.namespaces.<ns>.entries.valueObject`, and stores them as `jsonb` columns; a domain field refers to one by coordinate. Nested value-object references are supported.
```prisma
type Address {
street String
city String
zip String?
country String
}
model User {
id String @id @onCreate(uuid())
email String
address Address?
}
```
Emitted `contract.json` carries the value object in storage, at `storage.namespaces.<ns>.entries.valueObject.Address` with its field descriptors, and the domain field `address` refers to it by coordinate: `{ "kind": "valueObject", "plane": "storage", "namespaceId": "public", "entityKind": "valueObject", "entityName": "Address" }`. The `address` column lands as `codecId: "pg/jsonb@1"` / `dataType: "pg/jsonb"`. Because the value object is in storage, renaming, adding or removing one of its fields changes the storage hash, and `prisma migration plan` writes a migration for it.
On Postgres, a managed table gets a derived check on each value object column, `<table>_<column>_shape_<hash>`: the value must be an object (an array of them for a list) with every required member, each of its JSON type, and no other key. A nullable column also accepts `NULL`. On SQL, a polymorphic base's discriminator column refers to a value set `<Base>.<field>` holding the variants' values, and Postgres derives a check from it, as for an enum.
Canonical worked example: `examples/prisma-8-demo/src/prisma/contract.prisma`.
## Workflow — Enums
The concept: PSL `enum` blocks declare a domain enum: a named value-set stored through a declared codec (`@@type("pg/text@1")` → a `text` column) and enforced by a planner-generated CHECK constraint. Each member maps to its database value with `Name = "value"`. Use the enum name as a field type on any model in the same contract.
```prisma
enum user_type {
@@type("pg/text@1")
admin = "admin"
user = "user"
}
model User {
id String @id @onCreate(uuid())
kind user_type
}
```
**Reading members at runtime (`db.enums`).** `db.enums.<namespace>.<Enum>` on Postgres, and `db.enums.<Enum>` on SQLite and Mongo, holds each member as a query returns it: a `pg/int8@1` member written `Low = "1"` is `1n`, a date or timestamp member is the `Date` or Temporal value its codec reads, and a float member written `"NaN"` is `NaN`. So `db.enums.public.Level.members.Low === row.level` holds wherever `===` applies. `has(value)`, `nameOf(value)` and `ordinalOf(value)` find a value equal to a member: a string, number or bigint must be the member itself, and an object such as a `Date` must be of the member's kind and stored as the member is, so a date equal to a member matches although it is a different object. Text a codec would normalize, such as an upper-case uuid, is no member. Each read of a mutable member, such as a `Date` or a `Uint8Array`, returns a fresh copy, so changing it does not change the enum; a Temporal member is immutable and is the same value on every read. An enum's members are decoded when the enum is first read, so a client builds without `Temporal` even when its contract has a Temporal enum. `contract.d.ts` types each member as that same value. The rule behind this: the value a codec reads from the contract's stored form equals the value a query returns for it. The stored form itself can differ from the text the database prints, such as ISO 8601 for a timestamp.
**Codecs an enum cannot use.** An enum compares values with its members, so a codec whose values cannot be compared for equality, or whose values read back never equal a member as the contract stores it, is refused, in TypeScript with `CONTRACT.ENUM_INVALID` and in PSL with `PSL_EXTENSION_INVALID_VALUE` at the `@@type`. The message says why and what to use instead. On Postgres: `pg/json@1`, `pg/jsonb@1`, `pg/bytea@1` and `pg/tsquery@1` (use a text enum). The text date and time codecs read their values in ISO 8601, so an enum can use them.
**Mongo enums.** A Mongo contract's collection validator lists each enum's members in their stored JSON forms, so an enum needs a codec whose BSON type JSON holds: string, int, double, bool, object or array, with finite numbers for doubles. PSL and TypeScript contracts derive the same validator. An enum over `mongo/int64@1`, `mongo/date@1` or `mongo/objectId@1` is refused: with `PSL_EXTENSION_INVALID_VALUE` in PSL, and with `CONTRACT.ENUM_INVALID` by `defineContract`.
**Generated checks cannot be waived.** On a managed table every enum column gets a membership check, and every list column with non-null elements gets a no-NULL-elements check. A list whose elements may be null is written `String?[]` (TS: `.many({ elementsNullable: true })`) and gets no element check. `contract infer` writes `String?[]` for a list column whose database has no element check, and a Prisma 7 schema's lists are read as `String?[]`.
Canonical worked example: `examples/prisma-8-demo/src/prisma/contract.prisma`.
## Workflow — CHECK constraints (`@@check`)
The concept: `@@check` declares a hand-written CHECK constraint on a model — a rule you want the database to enforce, on top of whatever generated checks the model's enums and list columns already get (see *Workflow — Enums* above). Without it, a constraint you added by hand had no way into the contract, so it looked like an undeclared extra a destructive migration could drop.
```prisma
model Order {
id Int @id
total Decimal
// name: is a prefix — the physical constraint becomes order_total_positive_<8-hex hash>.
@@check(expression: sql`total > 0`, name: "order_total_positive")
}
```
`expression` is the raw predicate — the text that goes inside `CHECK (...)`, written as a `sql` literal (a quoted string is refused) — and it is never parsed, so get it right; Prisma 8 does not validate SQL syntax. Exactly one of `name:` or `map:` is required, and they're mutually exclusive:
- **`name:`** — declaring a new rule. Prisma 8 picks the physical constraint name and future plans compare by that name, so Postgres's own reprint of your predicate (which rarely matches what you typed byte-for-byte) never causes false drift.
- **`map:`** — adopting a rule that already exists. Give the constraint's exact physical name and Prisma 8 compares the predicate byte-for-byte against what's live. This is the form `contract infer` writes for you (see *Workflow — Brownfield introspection* below) when it finds a hand-written check in the database. Every `map:` body warns at emit time (`PN_EXACT_NAME_BODY_COMPARISON`) — the warning fires on the text, not on who wrote it, so the check `contract infer` just wrote warns again on your next `contract emit` too. That is expected, not a defect: the comparison is still sound because both sides are Postgres's own reprint. Prefer `name:` for anything you're authoring fresh: your text and Postgres's reprint of it rarely match character-for-character, and a byte comparison reports that as drift even when both mean exactly the same thing. Reserve `map:` for adopting what's already there, where both sides are the database's own reprint and so do match.
A model can carry any number of `@@check` attributes. The TS builder mirrors this with `check({ expression, name })` / `check({ expression, map })` on a model's `.sql({ checks: [...] })`, next to `index()`; `expression` is a `sql` value, as in ``check({ expression: sql`total > 0`, name: 'order_total_positive' })``. The `sql` tag, from the same contract-builder module, may interpolate other `sql` values: ``sql`${owner} AND deleted_at IS NULL` ``.
Check constraints need the target's `checkConstraint` capability. Postgres has it; SQLite does not. `@@check` on a SQLite contract is rejected at authoring time; `check()` on a SQLite contract isn't caught until migration DDL is rendered, but either way you get a refusal, never a silently-dropped constraint.
## Workflow — Namespaces (Postgres schemas)
The concept: wrap models in a `namespace <name> { ... }` block to place them in a non-default Postgres schema. Models outside any block go into the implicit default namespace.
```prisma
namespace public {
model Profile {
id String @id @onCreate(uuid())
username String
userId String @unique
@@map("profile")
}
}
```
Canonical worked example: `examples/supabase/src/contract.prisma`.
## Workflow — Mixins (shared members)
The concept: a mixin is a named set of members for one kind of block. Declare it with the block's keyword followed by `mixin`, and include it in a block with `+<Name>`. The emitted contract is the same as if the mixin's members were written at the position of the inclusion.
```prisma
model mixin Timestamps {
createdAt DateTime @default(now())
updatedAt DateTime @onCreate(now()) @onUpdate(now())
@@index([createdAt])
}
enum mixin BaseRoles {
ADMIN = "admin"
USER = "user"
}
model User {
id Int @id
+Timestamps
email String
}
enum Role {
@@type("pg/text@1")
+BaseRoles
GUEST = "guest"
}
```
`User` has the fields `id`, `createdAt`, `updatedAt`, `email`, in that order, and the index on `createdAt`. `Role` has the members `ADMIN`, `USER`, `GUEST`.
Rules:
- **One keyword per mixin.** A `model mixin` can be included only in models, a `type mixin` only in composite types, an `enum mixin` only in enums, and a mixin for a block an extension defines (for example `policy_select mixin`) only in blocks of that keyword.
- **A member provided twice is an error.** A field name or an enum member that the block already has, from its own body or from another mixin, is reported and the earlier one is kept. Including the same mixin twice in one block is also an error.
- **A mixin's attributes can name only its own fields.** `@@index([createdAt])` inside `Timestamps` works because `createdAt` is a field of the mixin. An attribute that needs a field of the including model belongs in that model; a model's own attributes can name the fields a mixin provides, as in `@@id([tenantId, id])`.
- **A mixin cannot include a mixin.**
- **Names follow namespaces.** A mixin declared in `namespace auth { ... }` is included from another namespace as `+auth.Timestamps`. The types of its fields are looked up from the mixin's namespace, not from the including block's.
- **A mixin is not a type.** It cannot be used as a field type, and it produces nothing in the contract unless a block includes it.
## Workflow — Cross-contract foreign keys
The concept: a relation field can reference a model in another contract space using the `<space>:<namespace>.<Model>` form. The contract also supports top-level named-type aliases in a `types {}` block, backed by the same bare type-position constructors used by fields. The `@db.X(args)` channel is removed: rewrite `@db.X` as `X` and `@db.X(args)` as `X(args)`; remaining uses fail with an actionable diagnostic naming the replacement.
```prisma
types {
AuthUserId = Uuid
}
namespace public {
model Profile {
id String @id @onCreate(uuid())
username String
userId AuthUserId @unique
user supabase:auth.AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("profile")
}
}
```
`supabase:auth.AuthUser` means: model `AuthUser` in namespace `auth` of contract space `supabase`. The target space is provided by a registered extension pack (here `@internal/extension-supabase/pack`).
Canonical worked example: `examples/supabase/src/contract.prisma`.
## Workflow — `@@control` (control policy)
The concept: `@@control(<policy>)` on a model sets whether Prisma manages that table's DDL in migrations. The argument is a positional lowercase literal — one of `managed`, `tolerated`, `external`, or `observed`.
```prisma
model AuditLog {
id Int @id
message String
@@control(observed)
}
```
A contract-level default can be set via `defaultControlPolicy` on `prismaContract(path, { defaultControlPolicy })`. See `references/migrations.md` for how control policies affect DDL planning.
## Workflow — `@internal/extension-supabase`
The concept: the Supabase extension provides the `supabase` contract space (the `auth` / `storage` schemas as `external` tables, plus the platform roles) and its own role-first runtime factory. Its descriptor is the `pack` export, and it goes into the same `extensions` array as any other extension. See `examples/supabase` for the full working pattern.
`prisma.config.ts` (mirrors `examples/supabase/prisma.config.ts`):
```typescript
import { definePrismaConfig } from '@prisma/cli-engine';
import supabasePack from '@internal/extension-supabase/pack';
import { defineConfig as ormConfig } from '@internal/postgres/config';
export default definePrismaConfig({
orm: ormConfig({
contract: './src/contract.prisma',
extensions: [supabasePack],
migrations: { dir: 'migrations' },
}),
});
```
`db.ts` does **not** use the stock `postgres()` factory — a Supabase app builds its client with the `supabase()` factory from `@internal/extension-supabase/runtime` (role-first: `asUser(jwt)` / `asAnon()` / `asServiceRole()`, JWT validation, RLS). That runtime — and RLS policy authoring (`policy_select` / `@@rls`) — is covered by **`references/supabase.md`**; load it for anything past the config wiring.
Export subpaths: `@internal/extension-supabase/pack`, `@internal/extension-supabase/runtime`, `@internal/extension-supabase/contract`. Canonical worked example: `examples/supabase`.
## Workflow — Brownfield introspection
The concept: pull a contract source out of an existing database and continue from there. `prisma contract infer --db <url>` reads the live schema and writes a `contract.prisma` file. It stops there — follow it with `contract emit` and (when the schema matches a pinned hash) `db sign` as separate steps.
```bash
pnpm prisma contract infer --db $DATABASE_URL --output ./src/prisma/contract.prisma
pnpm prisma contract emit
```
Infer captures indexes at full fidelity — expression, partial (`where:`), unique non-constraint, `type:`/`options:` — adopting each under `map:` with the live name, except that a name shaped like a wire name whose hash recomputes from the content re-detects as wire-named and emits `name:` with the prefix. RLS surfaces too: `@@rls` on RLS-enabled models, and every policy as a `policy_<operation>` block with `@@map("<live name>")`, verbatim predicate reprints, and `permissive = false` for RESTRICTIVE rows (a policy whose role name can't be spelled as a PSL identifier is skipped with a comment note). Replacing an adopted `map:` with the plain wire spelling later converges via a single rename migration.
## Common Pitfalls
1. **Forgetting to re-emit after an edit.** `contract.json` and `contract.d.ts` go stale; downstream typecheck and `migration plan` see the old shape. Re-emit, or install the Vite plugin (`references/build.md`).
2. **Editing the emitted artefacts.** `contract.json` and `contract.d.ts` are emitted; edits there round-trip away on the next emit. Edit the source.
3. **Wrong factory/import path for the TS builder.** `defineContract`, `field`, `model`, `rel` come from `@internal/postgres/contract-builder` (or `@internal/mongo/contract-builder`). On Postgres the facade also exports `type` and `gen` (`field.column(type.TimestamptzTemporal())`, `.onCreate(gen.uuid())`) without an extension's helpers; elsewhere, outside the callback overload, the available field constructors are `field.column(...)` and `field.namedType(...)`.
4. **Reaching into internal packages from user code.** User-authored files (`prisma.config.ts`, `contract.ts`, `db.ts`, control clients) import only from `@internal/<target>/<subpath>` and `@internal/extension-<name>/<subpath>`. Imports from `@internal/cli/*`, `@internal/family-*`, `@internal/target-*`, `@internal/adapter-*`, `@internal/driver-*`, or `@internal/sql-contract-*` are framework-internal — the façade composes them for you. If a façade subpath you need is missing for your target, see *What Prisma 8 doesn't do yet* and route to `references/feedback.md`. The canonical worked examples are `examples/multi-extension-monorepo/app/prisma.config.ts` and `examples/prisma-8-postgis-demo/prisma.config.ts`.
5. **Confusing the config `extensions` with the TS builder's `extensions`.** Same packs, two surfaces, one field name but two shapes: `ormConfig({ extensions: [pgvector] })` (array of *control* descriptors from `@internal/extension-<name>/control`) versus `defineContract({ extensions: { pgvector } })` (record of *pack* descriptors from `@internal/extension-<name>/pack`).
6. **Writing a flat `prisma.config.ts`.** `export default defineConfig({ contract, extensions })` from the target config alone is the pre-rc.4 shape and fails with `CONFIG.VERSION_MARKER_MISSING`. Wrap it: `definePrismaConfig({ orm: ormConfig({...}) })`.
7. **Renaming a model or field and expecting the planner to detect it.** The contract does not record renames, so the planner sees a destructive drop+add. State the rename on the command line with `--rename old:new` on `migration plan` or `db update` (see `references/migrations.md`); the planner then renames instead of dropping. Do not invent a rename attribute in the contract source.
8. **Quoting a type-constructor argument.** A type constructor's argument is a value, read as an attribute argument is. Write a length, precision or scale as a plain whole number: `Varchar(255)`, `NumericString(10, 2)`, `String(storage: varchar, length: 255)`. `Varchar("255")` is refused with `Expected a number; write 255`, and on Postgres `Varchar(255.0)` is refused too, because a number with a fraction is not a 32-bit integer.
## What Prisma 8 doesn't do yet
- **In-contract rename hint.** No `@@rename(old: ..., new: ...)` or similar; renames are stated with `--rename` on the command line, as in *Common Pitfalls* #7.
- **Model validations.** No declarative `@validates(...)` surface. Validate in application code (arktype). The maintainers decided it stays out of the core; an extension may provide it (prisma/orm ADR 293).
- **Lifecycle callbacks** (`beforeSave`, `afterCreate`, etc.). Not supported. Use middleware (`references/runtime.md`) or app code. The maintainers decided it stays out of the core; an extension may provide it (prisma/orm ADR 293).
- **Soft delete / `paranoid: true`.** No built-in soft-delete column. Add a nullable `deletedAt DateTime?` and filter explicitly in queries (or in middleware). The maintainers decided it stays out of the core; an extension may provide it (prisma/orm ADR 293).
- **Scopes / default filters.** No ActiveRecord-style scopes. Compose query helpers yourself. To request scopes, file via `references/feedback.md`.
- **Implicit Prisma-ORM many-to-many.** List navigation on both sides without an explicit join model is rejected. Author the join model explicitly. To request implicit M2M, file via `references/feedback.md`.
## Reference
- Run `pnpm prisma contract --help` for the live command surface.
- PSL feature surface and what the interpreter accepts: `packages/2-sql-family/2-authoring/contract-psl/README.md`.
- TS builder surface and the callback-helper vocabulary: `packages/2-sql-family/2-authoring/contract-ts/README.md`.
- Layouts (where `contract.prisma`, `contract.json`, `contract.d.ts`, and `migrations/` live):
- **App layout** (`src/prisma/...` + `migrations/app/...`) — what `examples/prisma-8-demo` demonstrates; the canonical shape consuming applications use.
- **Contract-space-package layout** (`src/contract.{prisma,ts}` directly, `migrations/<timestamp>_<slug>/` without a space-id segment) — for extensions and aggregate-root packages, documented in `.cursor/rules/contract-space-package-layout.mdc` and ADR 212.
## Checklist
- [ ] Read `prisma.config.ts` and identified the contract source (path string ending in `.prisma` or `.ts`) and the installed `extensions: [...]`.
- [ ] All user-authored imports resolve to `@internal/<target>/<subpath>` (e.g. `@internal/postgres/config`) or `@internal/extension-<name>/<subpath>`. No imports from `@internal/cli/*`, `@internal/family-*`, `@internal/target-*`, `@internal/adapter-*`, `@internal/driver-*`, or `@internal/sql-contract-*` in user files.
- [ ] Edited the contract source (`contract.prisma` or `contract.ts`), not an emitted artefact.
- [ ] For new extension namespaces: added the package, imported its control descriptor (`@internal/extension-<name>/control`), added it to `extensions: [...]` in `ormConfig({...})` (and the matching pack descriptor to `defineContract({extensions: {...}})` if using the TS builder).
- [ ] `prisma.config.ts` is the envelope form — `definePrismaConfig({ orm: ormConfig({...}) })` — not a flat `defineConfig({...})`.
- [ ] For renames: passed `--rename old:new` to `migration plan` or `db update` (or hand-edited `migration.ts` where statements do not cover the rename).
- [ ] Ran `pnpm prisma contract emit` after the edit (or let the Vite plugin re-emit on save).
- [ ] Confirmed `contract.json` and `contract.d.ts` updated next to the source.
- [ ] Did **not** hand-edit `contract.json` / `contract.d.ts`.
- [ ] Did **not** confabulate a missing feature (validations, callbacks, soft delete, scopes, a rename attribute in the contract source) — referred the user to *What Prisma 8 doesn't do yet* + `references/feedback.md`.
references/debug.md
# Prisma 8 — Debug
> **Edit your data contract. Prisma handles the rest.**
When a Prisma 8 call fails, the framework returns a **structured envelope**. The agent's job is to read the envelope, route on the `code`, and chain to the right authoring skill for the actual fix. This skill teaches the envelope shapes and the routing — it does not duplicate sibling-skill workflows.
## When to Use
- User pastes an error envelope (CLI failure, runtime exception, `--json` output).
- User says *"my query won't typecheck"*, *"my migration won't apply"*, *"my emit failed"*, *"the runtime crashed"*.
- User mentions a stable code (`CONFIG.*`, `CLI.*`, `CONTRACT.*`, `MIGRATION.*`, `ORM.*`, `RUNTIME.*`, `DRIVER.*`, `LINT.*`, `BUDGET.*`, `PLAN.*`). A `PN-CLI-4001`-style numeric code is from a pre-0.17 release; the crosswalk to the dotted name is in ADR 239 and `docs/reference/error-reference.md`.
- User mentions: *Studio, EXPLAIN, query log, prepared statements, drift, hash mismatch, capability, planner*.
## When Not to Use
- User wants to author a query / model / migration → the matching authoring skill.
- User wants to *prevent* errors (lints, budgets, type-level guards) → `references/runtime.md`.
- User wants the framework changed because the surface itself is the problem (no envelope to route on, capability genuinely missing) → `references/feedback.md`.
## Key Concepts
### Two envelope shapes
Prisma 8 emits **two distinct envelopes** depending on which seam threw. Read which one you have *before* routing.
**1. CLI envelope** — produced by `prisma ...` commands (emit, db init/update/verify/sign/schema, migration plan/apply/show/status, init). Shape (see `CliErrorEnvelope` in `packages/1-framework/1-core/errors/src/control.ts`):
```json
{
"ok": false,
"code": "MIGRATION.UNFILLED_PLACEHOLDER",
"severity": "error",
"summary": "Unfilled migration placeholder",
"why": "...",
"fix": "...",
"nextActions": [],
"where": { "path": "...", "line": 42 },
"meta": { "slot": "..." },
"docsUrl": "https://docs.prisma.io/docs/orm/v8/reference/error-reference#MIGRATION.UNFILLED_PLACEHOLDER"
}
```
Every code is a dotted `NAMESPACE.SUBCODE`; the namespace is the prefix (`CONFIG`, `CLI`, `CONTRACT`, `PSL`, `ORM`, `RUNTIME`, `DRIVER`, `MIGRATION`, `PLAN`, `BUDGET`, `LINT`, plus one per extension). The full catalogue, one entry per code with its payload, is `docs/reference/error-reference.md`. Severity is `error | warn | info`, and exit codes carry meaning: `2` is "could not run", `3` is a user abort, and `4` is "ran and found something" — `db verify` / `db sign` exit `4` with their findings as `error` diagnostics on a completed envelope, and `migration status` exits `0` with `warn` diagnostics. Route on **severity + code together**, not on exit code alone.
**2. Runtime envelope** — thrown by the in-process runtime when executing a query (see `RuntimeErrorEnvelope` in `packages/1-framework/1-core/framework-components/src/execution/runtime-error.ts`):
```ts
{ name: 'RuntimeError', code: 'BUDGET.TIME_EXCEEDED', category: 'BUDGET', severity: 'error', message: '...', details: { ... } }
```
`category` is the prefix of `code` (`PLAN`, `CONTRACT`, `LINT`, `BUDGET`, `RUNTIME`, `ORM`, …). `details` holds the structured context (`details` is the runtime envelope's equivalent of the CLI envelope's `meta`). Recognise either shape programmatically with `isStructuredError` and match on `error.code` — never `instanceof`.
**3. SQL driver errors** — surface as `SqlQueryError` / `SqlConnectionError` (see `packages/2-sql-family/1-core/errors/`). Fields on `SqlQueryError`: `kind: 'sql_query'`, `sqlState` (Postgres SQLSTATE, e.g. `'23505'`), `constraint`, `table`, `column`, `detail`, `cause`. These carry no dotted code — route on `sqlState` and the constraint metadata. SQL driver errors are typically wrapped by middleware before reaching the user, but raw-SQL paths can surface them directly.
### Migration apply failures
`db migrate`, `db init`, and `db update` report a failure of the migration runner with the runner's own code, such as `MIGRATION.SCHEMA_VERIFY_FAILED`, `MIGRATION.PRECHECK_FAILED`, `MIGRATION.POSTCHECK_FAILED` or `MIGRATION.EXECUTION_FAILED`, and its `meta` unchanged. The facts are typed `meta` keys: `schemaIssues` (the schema check's issues), `failedCheck` (`operationId`, `phase`, `description`), `failedTypeChange`, `rowsWithoutValue`, and `failingSpace`, the contract space whose plan failed. Read `why` for what failed and `nextActions` for the way out.
### How to ask for the full envelope
If the user only pasted the human summary, ask for `--json` output (machine envelope) or re-run with `-v` (CLI prints the full structured fields). `--json` and `-v` are global flags on every CLI command.
## Routing — script teardown and closed client
These symptoms are not structured envelopes — route on the message text and chain to `references/runtime.md` § *Running as a script (teardown)*.
| Symptom | Next move |
|---|---|
| `TypeError: db.end is not a function` | The runtime client does not expose `db.end()` — that's the `node-postgres` pool API (`pool.end()`). The right call is `await db.close()`. See `references/runtime.md` § *Running as a script (teardown)*. |
| Script hangs after queries print / process won't exit | On Postgres the façade-owned `pg.Pool` keeps the event loop alive. Call `await db.close()` before the script returns, or `await using db = postgres({ contract, ... })` at the top of a script module (do NOT put `await using` inside a request handler — block-scoped, would close per-request). See `references/runtime.md` § *Running as a script (teardown)*. |
| `Error('Postgres client is closed')` / `Error('SQLite client is closed')` / `Error('Mongo client is closed')` | The client was closed via `db.close()` (terminal state). Remove the early `close()`, reorder so `close()` runs last after all queries, or construct a new `db` if reconnection is intended. See `references/runtime.md` § *Running as a script (teardown)*. |
## Routing — symptom and code → next move
The single source of truth: read the envelope, find the row by `code`, follow the next move. Every code below has an entry in `docs/reference/error-reference.md` (anchored `#<CODE>`); when a code is missing here, read it there.
| Code | Where it surfaces | Next move |
|---|---|---|
| `CONFIG.FILE_NOT_FOUND` | Most `prisma` commands | Run `prisma orm init`, or pass `--config <path>`. |
| `CONFIG.CONTRACT_MISSING` | `contract emit`, `db *` | Add `contract: './src/prisma/contract.prisma'` to the `ormConfig({...})` section of `prisma.config.ts`. See `references/contract.md`. |
| `CONFIG.VERSION_MARKER_MISSING` | Any command loading config | The default export was not built by the current `definePrismaConfig` / `ormConfig` pair (plain object, spread copy, or a Prisma 7 config). Rewrite to the envelope form in `references/contract.md`. |
| `CONFIG.VALIDATION_FAILED` | Any command reading the malformed section | `meta.section` / `meta.field` name the config section. Fix `prisma.config.ts`. |
| `CONTRACT.VALIDATION_FAILED` | `contract emit`, `db *` | Re-run `pnpm prisma contract emit` after fixing the contract source named in `where.path`; `meta.errors` lists the issues. See `references/contract.md`. |
| `CONFIG.DB_CONNECTION_REQUIRED` | `db *`, `db migrate`, `migration status` | Pass `--db <url>` or set `db.connection` in `prisma.config.ts`. |
| `CONFIG.MISSING_EXTENSION_PACKS` | `contract emit` (e.g. contract uses `pgvector.Vector(...)` but config does not list the pgvector descriptor) | Add the descriptors named in `meta.missingExtensionPacks` to `extensions` in `prisma.config.ts`. See `references/contract.md`. |
| `MIGRATION.PLANNING_FAILED` | `db init`, `db update`, `migration plan` | Inspect `meta.conflicts`. Recovery is per-conflict — chain to `references/migrations.md`. When `migration plan` raises it for a first migration whose contract describes nothing to create (conflict kind `nothingToCreate`), the conflict's `why` gives the next step: `migration new`, with the operations written by hand. A plan from an earlier contract with no operations is not refused: it is written, and `db migrate` moves the marker. |
| `CLI.INIT_MISSING_FLAGS` / `CLI.INIT_INVALID_FLAG_VALUE` / `CLI.INIT_REINIT_NEEDS_FORCE` / `CLI.INIT_*` | `prisma orm init` | Re-run with the flags listed in `meta.missingFlags` or `meta.allowed`; a re-init needs `--confirm <directory name>`. `CLI.INIT_INSTALL_FAILED` (exit 4) and `CLI.INIT_EMIT_FAILED` (exit 5) are findings on a completed scaffold — the files are on disk; fix and re-run the named step. |
| `MIGRATION.UNFILLED_PLACEHOLDER` | `node <package dir>/migration.ts` (self-emit) or `db migrate` | Edit `migration.ts`, replace the `placeholder("<slot>")` named by `meta.slot` with a real query closure, self-emit. See `references/migrations.md`. |
| `MIGRATION.FILE_MISSING` | Reading a migration package | Restore from version control or scaffold a fresh package with `migration plan` / `migration new`. |
| `MIGRATION.INVALID_DEFAULT_EXPORT` | Loading `migration.ts` | Use `export default class extends Migration<Start, End> { ... }` (or a factory returning `{ operations, targetId, destination }`). See `references/migrations.md`. |
| `MIGRATION.DATA_TRANSFORM_CONTRACT_MISMATCH` | Building a data-transform query plan | Pass the same `endContract` reference to both `this.dataTransform(endContract, …)` and the query-builder context. |
| `MIGRATION.HASH_MISMATCH` | Any read of a migration package (`plan`, `list`, `db migrate`) | `ops.json` / `migration.json` were edited without self-emitting. Run `node <package dir>/migration.ts` to re-emit. |
| `CONTRACT.MARKER_MISSING` | `db verify` (`error` diagnostic, exit 4), runtime startup (warning) | DB has no marker yet. Run `prisma db init --db <url>` (baseline empty DB), `db update --db <url>` (apply contract directly), or `db sign --db <url>` if the schema already matches the contract. |
| `CONTRACT.MARKER_MISMATCH` | `db verify` (exit 4), runtime startup (warning) | Marker disagrees with contract hash (`meta.expected` / `meta.actual`). Either migrate forward (`db migrate` / `db update`), or — if the DB is correct after a manual fix-up — `db sign`. See `references/migrations.md`. |
| `CONTRACT.TARGET_MISMATCH` | `db verify`, runtime startup | Contract target ≠ config target; align them (see `meta.expected` / `meta.actual`). |
| `CONTRACT.SCHEMA_VERIFICATION_FAILED` | `db verify`, `db sign` (both exit 4 with the finding) | Live schema does not satisfy the contract; `meta.schemaIssues` lists the schema diff issues per `meta.space`, in the same shape as `MIGRATION.SCHEMA_VERIFY_FAILED`. Run `db update` to reconcile, or adjust the contract. |
| `MIGRATION.SCHEMA_VERIFY_FAILED` | `db migrate`, `db update`, `db init` | The schema after apply does not satisfy the contract; the marker stays at its origin, and on SQL targets the apply rolled back (on MongoDB the operations that ran stay applied). `meta.schemaIssues` lists the issues and `why` names them. Change the contract source to resolve them, re-emit, and re-run; `db migrate` also says how to plan the failed migration again. |
| `MIGRATION.PRECHECK_FAILED` | `db migrate`, `db update`, `db init` | An operation's check before it ran did not hold (`meta.failedCheck`); the apply rolled back on SQL targets. `meta.rowsWithoutValue` is set when a field becoming required has rows with no value. Follow `nextActions`. |
| `CLI.CONSENT_REQUIRED` | `migration plan` / `db update` when a plan would lose data (or, on `db update`, widen access) and nobody can answer (`--no-interactive`, CI, `--yes`) | `meta.unanswered[]` and `nextActions` list each question with its flags. Re-run with `--delete <subject>`, `--rename <subject>:<new name>` or `--allow <Model>` as each subject means. `--confirm` and `--yes` answer none of them. `db migrate` does not ask. |
| `CLI.CONSENT_UNUSED` | `migration plan` / `db update` | Every question was answered, and a `--delete` or `--allow` was left over: a typo, a subject the plan does not touch, or a `--confirm` (which consents to nothing). Fix or drop the flag. A typo next to a question nobody answered is reported as `CLI.CONSENT_REQUIRED` instead, which names the flag that matched nothing. On `db update --dry-run` the same mistake is `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`. |
| `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS` | A rename typed at the data-loss prompt | The rename still loses the subject's data. Answer with `delete`, or give a rename whose old name is the subject. |
| `MIGRATION.NO_INVARIANT_PATH` / `MIGRATION.UNKNOWN_INVARIANT` | `db migrate` | Concurrent-migration and invariant flows — `references/migration-review.md`. |
| `DRIVER.NOT_CONNECTED` with message `Runtime is closed` | A query, prepared statement or `db.runtime().connection()` that starts after `db.close()` and after the runtime was idle for one turn of the event loop, or after the `await using` scope that held a serverless connection ended | The work started after the runtime closed. Almost always it is a lazy read (`db.orm...all()`, `db.query(...)`) returned without `await`, which starts only when the caller awaits it, or work that waited on something other than the database first. Write `return await ...`, or move the call before `close()`. See `references/runtime.md` § *Workflow — Serverless and per-request runtimes*. |
| `await db.close()` or the end of an `await using` scope never settles in a test | A test that installed fake timers (`vi.useFakeTimers()`, Jest's modern fake timers) before the Prisma runtime module was imported | `close()` waits one turn of the event loop through a `setTimeout(0)` that the runtime module takes when it loads. Install fake timers after the imports, or advance the fake clock (`await vi.runAllTimersAsync()`) before awaiting the close. |
| `MIGRATION.MARKER_MISMATCH` | `db migrate`, `db migrate --show`, a Composer deploy | The database's marker is not in the migration graph, usually because a migration it applied was planned again or deleted. `why` names the last graph contract it applied and the missing migrations. Follow `references/migrations.md` § *A database whose marker is not in the migration graph*. Never reset or drop the database. |
| `MIGRATION.PATH_UNREACHABLE` | `db migrate`, a Composer deploy | The marker is in the graph but no migration reaches the target from it. Run `migration plan --from <marker> --to <target>`, then `db migrate`; `migration list` audits the graph. |
| `MIGRATION.PLAN_ORIGIN_UNKNOWN` / `MIGRATION.HASH_NOT_IN_GRAPH` / `MIGRATION.SNAPSHOT_MISSING` | `migration plan`, `migration new` | Origin resolution — `references/migration-model.md` § *The trap* and `references/migrations.md` § *Dev → ship transition*. |
| `MIGRATION.MISSING_INVARIANTS` | `migration status` `warn` diagnostic (exit 0) | The live marker reached the destination hash structurally but doesn't carry all invariants the target ref requires. Run `db migrate --to <name> --db $URL` to take a path that covers the missing invariants. See `references/migration-review.md`. |
| `MIGRATION.MARKER_NOT_IN_HISTORY` / `CONTRACT.UNREADABLE` | `migration status` `warn` diagnostics (exit 0) | Read `severity` *and* `code`. Up-to-date / pending / no-marker states are not codes — read `spaces[].currentContract` and `migrations[].status` in the `--json` document. `references/migration-review.md` covers the marker-out-of-history flow. |
| `BUDGET.ROWS_EXCEEDED` / `BUDGET.TIME_EXCEEDED` | Runtime, when the `budgets` middleware is active | Tune `budgets({ maxRows, maxLatencyMs, ... })` or rewrite the query. See `references/runtime.md`. |
| `LINT.SELECT_STAR` / `LINT.NO_LIMIT` / `LINT.DELETE_WITHOUT_WHERE` / `LINT.UPDATE_WITHOUT_WHERE` / `LINT.READ_ONLY_MUTATION` | Runtime, when the `lints` middleware is active | Fix the query (add a `WHERE` / `LIMIT` / explicit columns), or relax the lint config. See `references/runtime.md`. |
| `PLAN.HASH_MISMATCH` | Runtime, executing a precompiled plan | The contract the plan was built against does not match the runtime contract. Re-emit, rebuild, redeploy. |
| `RUNTIME.TEMPORAL_UNAVAILABLE` | Runtime, first read or write touching a Temporal-backed column (`DateTemporal`, `TimestampTemporal(p)`, `TimestampInstant(p)`, `TimestamptzTemporal(p)`, `TimeTemporal(p)`, and `DateTime`, `Date` or `Time` with `as: temporal`) | No global `Temporal`. Node.js 26.8.2 and later ship `globalThis.Temporal`; 26.8.1 and earlier — including every 22 and 24 — do not. Either `import 'temporal-polyfill/full/global'` before the first query, or write the field as a convenience type at its default (`DateTime`, `Date`, `Time`), which reads a JavaScript `Date` or a string. |
| `ORM.RELATION_MUTATION_UNSUPPORTED` | ORM nested `create` / `connect` on an N:M relation whose junction has required payload columns | Write the junction table directly or use the SQL builder (`meta.junction`). |
| `RUNTIME.ABORTED` (`details.phase` = `encode\|decode\|stream\|beforeEncode\|onRow`) | Runtime, when an `AbortSignal` fires mid-execute | Cancellation, not a bug; surface to the caller. |
| `SqlQueryError` (no dotted code) | Raw-SQL paths surfacing a driver error | Inspect `sqlState` + `constraint` + `table` + `column`. Postgres `23505` = unique violation, `23503` = foreign-key violation, etc. Fix the data or the schema. |
| TypeScript error mentioning a capability (e.g. `returning()` not on the type, `include` of a many-relation off a many-load) | Authoring-time, before any envelope fires | Capability gates are declared in the **contract** (`capabilities` block, namespaced by target/family), not in `prisma.config.ts`. Route to `references/contract.md` for capability declaration and to `references/queries.md` for which method gates on which capability. Re-emit (`pnpm prisma contract emit`) after enabling. |
| TypeScript error mentioning a missing field/method on `db.orm.<ns>.<Model>` or a stale `Contract` shape | Authoring-time | Re-emit (`pnpm prisma contract emit`); confirm `db.ts` builds its contract with `contractFromJson<Contract>(contractJson)` and passes it to `postgres({ contract })` (the client infers the contract types from it). See `references/runtime.md` and `references/contract.md`. |
If the envelope's `code` is not in this table, follow the envelope's `fix` field literally — it's the framework's first-party next move. If `fix` is empty or unhelpful, escalate via `references/feedback.md`.
## Common Pitfalls
1. **Reading only `summary`, not the rest of the envelope.** `code`, `severity`, `why`, `fix`, `meta`/`details`, and (for CLI errors) `where` all carry information the recovery depends on. The agent routes on `code`; the user sees `summary`.
2. **Ignoring `severity`.** `migration status` emits warn-level diagnostics and **exits 0**. An agent that only checks exit code misses every concurrent-migration warning.
3. **Matching `MIGRATION.RUNNER_FAILED`.** That code no longer exists: an apply failure carries the runner's own code, and its facts are in typed `meta` keys.
4. **Treating drift as something to silence with `db sign`.** `db sign` writes the marker from the current contract hash and, by default, advances the `db` ref to it (`--no-advance-ref` skips the ref), but it requires schema verification to pass first. Run `db verify` before reaching for `db sign`.
5. **Re-running `db migrate` after a partial failure without inspecting state.** `db schema --db <url>` shows the live shape; `migration status --db <url> --json` shows where the marker actually is.
## What Prisma 8 doesn't do yet
- **Studio / GUI database browser.** No first-party Studio. Workaround: `prisma db schema` for a CLI tree of the live schema, or use a third-party tool (TablePlus, DataGrip, `psql`) against your `DATABASE_URL`. If you need a built-in GUI, file a feature request via `references/feedback.md`.
- **First-class query logger middleware.** No built-in "log every query" middleware ships with the framework. Workaround: write a small custom middleware that wraps each operation (see `references/runtime.md` for middleware composition). If you need a built-in query log, file a feature request via `references/feedback.md`.
- **`EXPLAIN` integration.** No first-class `.explain()` on plans. Workaround: write the EXPLAIN as a raw query (``db.raw.sql`EXPLAIN ANALYZE ...` ``; see `references/queries.md`). If you need first-class EXPLAIN, file a feature request via `references/feedback.md`.
- **Prepared statements are not a gap.** `db.prepare(declaration, (params) => plan)` (or `runtime.prepare(...)`, or `tx.prepare(...)` inside a transaction) builds a statement once; a row-returning statement runs with `ps.query(target, params)`, an affected-count one with `ps.execute(target, params)`, where `target` is the runtime, a connection or a transaction's `tx`. See *Prepared statements* in `references/queries.md`. TypedSQL (`.sql` files compiled to callables) is the thing that does not exist.
## Asking for help when the envelope doesn't route
1. Re-run with `-v` (or `--json` for machine output) to get the full envelope.
2. If the envelope is genuinely uninformative — empty `fix`, missing `meta`, generic `summary` — that's a framework affordance gap; route to `references/feedback.md` with the envelope, the contract source (sanitised), and the reproduction steps.
## Checklist
- [ ] Identified which envelope shape (`CliErrorEnvelope`, `RuntimeErrorEnvelope`, `SqlQueryError`).
- [ ] Read every field — `code`, `severity`, `why`, `fix`, `meta` (or `details`), `where` if present.
- [ ] For an apply failure, read the typed `meta` keys (`schemaIssues`, `failedCheck`, `rowsWithoutValue`, `failedTypeChange`).
- [ ] Routed on `code` to the next move (and chained to the matching authoring skill where the table says so).
- [ ] Re-verified with the relevant CLI command (`db verify`, `migration status --json`, `contract emit`, `db migrate`).
- [ ] Did not confabulate a Studio / EXPLAIN / query-log API — used the documented workaround and routed unmet capability gaps to `references/feedback.md`.
references/feedback.md
# Prisma 8 — Feedback (Bug Reports, Feature Requests, Team Q&A)
> **Edit your data contract. Prisma handles the rest.**
This skill is the *terminal* of the capability-gap routing pattern. Every other Prisma 8 skill's *What Prisma 8 doesn't do yet* entries route here when the user wants the gap closed; the skill also fires directly on prompts like *"this is a bug"*, *"file an issue"*, *"feature request"*, *"can I ask the team about this?"*, *"how should I integrate X with Prisma 8?"*.
The skill's job is to pick the *right channel* — GitHub issue or Discord — and then either produce a **structured, public-safe** issue body (no secrets, no proprietary schema) the framework team can act on, or hand the user a direct link to the Prisma Discord for synchronous Q&A. Submission to GitHub never happens without explicit user confirmation.
Canonical channels:
- **GitHub Issues** (bugs + concrete feature requests): <https://github.com/prisma/orm/issues/new/choose>
- **Prisma Discord** (Q&A, design feedback, direct team contact — including extension authors): <https://pris.ly/discord>
## When to Use
- A capability-gap entry from another `prisma-8` skill fired and the user said *"yes, file the feature request"*.
- User says *"this is a bug"*, *"file this"*, *"report this"*, *"file an issue against PN"*, *"send feedback"*, *"this should be a feature"*.
- User describes an unexpected behaviour — wrong exit code, error message that didn't match what happened, type signature that doesn't match runtime behaviour, planner refused a migration that looked safe — and wants it on the framework team's radar.
- User asks *"can I ask the Prisma team about this?"*, *"is there somewhere I can talk to the team?"*, *"is this the intended way to do X?"*, *"how should I integrate <my extension / my tool> with PN?"*, or any other open-ended Q&A or design-feedback prompt — including extension authors asking integration questions.
## When Not to Use
- User wants to fix the bug themselves in the user's own code. The fix lives in another workflow reference (debug / contract / migrations / queries / runtime / build). Open the right reference first; only fall back to feedback if the user explicitly wants the framework to do something differently.
- User wants to upgrade Prisma 8 (the bug may already be fixed) → [`upgrade-app.md`](upgrade-app.md); this reference mentions it as a pre-flight check.
- The user's question is already covered by a workflow reference in this skill (*"how do I add a column?"* → `references/contract.md`; *"what's the right query interface?"* → `references/queries.md`). Route to the workflow reference, not to the team — open the reference, answer the question, and only escalate to Discord if the agent can't.
## Key Concepts
- **Three channels, one decision.** GitHub Issues (bugs + concrete feature requests), Prisma Discord (Q&A, design feedback, direct team contact), or another workflow reference in this skill (when the question turns out to be a workflow question, not a hand-off-to-team question). The first move is the channel decision; everything else follows.
- **Public artifact.** GitHub issues *and* Discord messages are world-readable and archived. The body / message must not contain `DATABASE_URL` strings, internal company schema fragments, customer data in sample rows, or any other content the user wouldn't share publicly. The agent redacts before either kind of submission.
- **Bug vs feature vs question.** A *bug* is "documented surface behaved unexpectedly". A *feature request* is "I want a capability that doesn't exist". A *question* is "I want to discuss X with someone, or I'm not sure this is a bug at all". Many capability-gap routes are feature requests; many extension-author prompts are questions.
- **The framework team needs to reproduce (issues only).** A bug report without a reproduction is much harder to act on. Where possible, the agent produces a minimal repro the team can re-run locally — ideally a small change against [`examples/prisma-8-demo`](https://github.com/prisma/prisma/tree/main/examples/prisma-8-demo), which the team already has checked out. Discord Q&A doesn't require a full repro — a short code snippet plus the question is usually enough.
## Workflow
### 1. Pick the channel
The user wants to hand something off to the team. Which channel?
**GitHub Issue** if any of:
- The user describes a concrete bug (see *Classify* below for the bug-vs-feature split).
- The user has a concrete feature request — a named capability, a specific API shape, a specific CLI flag — that they want on the backlog.
- A capability-gap entry from another `prisma-8` skill routed them here for a feature request.
**Prisma Discord** (<https://pris.ly/discord>) if any of:
- The user is asking an open-ended question — *"is this the intended way to do X?"*, *"how would you approach Y?"*, *"I'm seeing weird behaviour but I'm not sure if it's a bug."*
- The user wants design feedback before committing to a feature request — *"we're thinking of building a custom middleware that does X, does this fit the framework's direction?"*
- The user is an extension author with an integration question that needs back-and-forth with the team (peer-dependency coordination, breaking-change timing, a new extension surface).
- The user explicitly asks for the team — *"can I ask the team about this?"*, *"is there somewhere I can talk to Prisma?"*, *"where do extension authors discuss things with the team?"*
**Both, in sequence**, if any of:
- The user has a bug *and* a related feature request — file two separate GitHub issues, do not mix them in one issue.
- The user wants to discuss a design before filing the feature request — start in Discord, file the issue once the shape is settled.
### 2. Classify (issue path only)
The user is filing a GitHub issue. Is it a bug or a feature request?
**Bug** if any of:
- A documented CLI command exited with the wrong code.
- The `fix` field of an error envelope was misleading or wrong.
- A published TypeScript signature doesn't match runtime behaviour.
- The planner refused a migration that should have been valid (or accepted one that shouldn't have been).
- The contract emit produced an artifact that doesn't load at runtime.
- Any other case where the documented surface did the wrong thing.
**Feature request** if any of:
- The user wants a capability that doesn't exist yet (most of the *What PN doesn't do yet* entries land here).
- The user wants a better error message, an additional CLI flag, a new middleware, an additional bundler plugin, etc.
If both — a bug *and* the user wants a related feature — file two separate issues. Mixing them makes the framework team's triage harder.
### 3. Collect the minimum body (issue path only)
For **either** kind:
- **Prisma 8 version**: `pnpm ls @internal/postgres` (or `@internal/mongo`). If the project uses a target package, that version is canonical.
- **Node version**: `node -v`.
- **Package manager**: `pnpm` / `npm` / `yarn` / `bun` / `deno`.
- **OS**: `darwin` / `linux` / `win32` and the version string is enough.
For **bug reports**, additionally:
- **The exact command** that misbehaved (e.g. `prisma migration plan --name add-email`).
- **The full output**, with `-v` if a structured error envelope is involved. Redact `DATABASE_URL` and any other secrets.
- **A minimal `src/prisma/contract.prisma` / `src/prisma/contract.ts` excerpt** that reproduces the issue. Strip unrelated models. Keep the original model and field names from the user's contract when they don't expose anything compromising — a faithful excerpt is much easier for the framework team (and future readers of the issue) to reason about than a re-themed one. Only rename to neutral placeholders (`User`, `Post`, `Tag`) when the original names would leak confidential domain detail (product names, internal codenames, customer identifiers, regulated-data field names).
- **Steps to reproduce**, as a numbered list.
- **Expected behaviour** — one sentence.
- **Actual behaviour** — one sentence plus the relevant output line.
- **Workaround**, if any — one sentence.
For **feature requests**, additionally:
- **Desired API or behaviour** — one paragraph. Concrete shape (CLI flag, config field, middleware export, plugin API) where possible.
- **Where the gap surfaces today** — which skill's *What PN doesn't do yet* entry triggered the request, or the workflow the user was trying to complete.
- **Current workaround**, if any — one sentence (and the skill body the user is following may already say this).
### 4. Render the body
The repository ships GitHub Issue Forms (`.github/ISSUE_TEMPLATE/bug_report.yml` and `feature_request.yml`). When the user lands on <https://github.com/prisma/orm/issues/new/choose> they pick the matching template and fill in the form fields; the skill produces the body in the same structured shape so it maps onto the form one-to-one (and so `gh issue create --body-file` produces a parseable artifact even when the form isn't in play).
Bug-report body shape (fields named to match `.github/ISSUE_TEMPLATE/bug_report.yml`):
~~~markdown
## Package and version
<e.g. @internal/postgres@0.5.2>
## What happened?
<one-sentence summary plus the relevant output line, secrets redacted>
## What did you expect to happen?
<one sentence>
## Minimal reproduction
1. <step one>
2. <step two>
3. <step three>
```ts
// schema.psl excerpt + the query / command that fails
```
## Environment
- Node: <version>
- OS: <darwin/linux/win32> <version>
- Package manager: <pnpm/npm/yarn/bun/deno> <version>
- Database: <Postgres / Mongo> <version>
## Additional context
<optional — link to source skill's capability-gap entry, related
issue number, partner extension involved>
~~~
Feature-request body shape (fields named to match `.github/ISSUE_TEMPLATE/feature_request.yml`):
~~~markdown
## What problem are you trying to solve?
<paragraph — the use case or pain point this would address>
## Proposed solution
```ts
// imagined usage of the API / behaviour you'd want
```
## Alternatives considered
<sentence or two — what you tried with the current API and why it didn't work>
## Scope and impact
<which package(s) this would touch; target-specific implications>
~~~
### 5. Title (issue path only)
- **Bug**: `bug(<area>): <one-line summary>` — e.g. `bug(cli): migration plan exits 0 when there is no diff`.
- **Feature request**: `feat(<area>): <one-line summary>` — e.g. `feat(build): first-party Next.js plugin for contract emit`.
Areas mirror the cluster of skills: `cli`, `contract`, `migration`, `query`, `runtime`, `build`, `error`, `docs`.
### 6. Surface for confirmation (issue path only)
**Never auto-submit.** The agent shows the rendered title and body to the user and asks: *"This looks good to file. Shall I submit it to GitHub?"*. Submission only happens after explicit user approval.
### 7. Submit (issue path only)
Preferred. Two steps:
1. **Write the rendered body to a temporary file.** Use your file-write tool (the same tool you'd use to create any other file on disk) to write the body to e.g. `wip/pn-issue-body.md` or `/tmp/pn-issue-body.md`. The body content is just the markdown produced in step 4 of this workflow — no surrounding shell quoting, no heredoc.
2. **Reference that file from `gh`.** Run:
~~~bash
gh issue create \
--repo prisma/prisma \
--title "<title>" \
--body-file <path-from-step-1>
~~~
**Anti-pattern (do not do this):** inlining the body via `--body "$(cat <<EOF …)"` or `--body-file <(cat <<EOF …)`. Those one-liners reliably leak literal `cat <<'EOF'` / `EOF` markers into the issue body when the agent reuses the template verbatim with the body interpolated. Always write the body to a real file first and pass the path.
If `gh` is not installed: open the prefilled new-issue URL in the browser:
~~~text
https://github.com/prisma/orm/issues/new/choose
~~~
…and instruct the user to paste the rendered body. The agent can copy the body to the clipboard via `pbcopy` (macOS), `xclip` (Linux), or by simply printing it in the chat for the user to copy.
### 8. Route to Discord (Q&A / design-feedback / direct-team-contact path)
When step 1 picked the Discord channel (steps 2–7 do not apply):
1. **Surface the link.** Give the user the canonical invite: <https://pris.ly/discord>. Suggest the channel that fits the question:
- General usage / Q&A → the public `#help` channel (the invite landing page lists current channels).
- Extension-author / partner-integration / breaking-change-coordination questions → the public extension-authors channel, or the user can ping a maintainer directly once they're in the server.
2. **Help draft the opening message.** Prisma's Discord is searchable; a well-framed opening message gets a faster, more useful answer. The agent drafts a short message with:
- One-sentence summary of what the user is trying to do.
- The Prisma 8 version (`pnpm ls @internal/postgres` or equivalent).
- A short code snippet (PSL excerpt, query, config file) where relevant — redacted the same way as a GitHub issue body (no `DATABASE_URL`, no customer schema names).
- The specific question the user wants answered.
3. **Do not auto-post.** The agent surfaces the drafted message to the user — *"here's an opening message you can paste into Discord; want to adjust before sending?"* — and lets the user decide whether to paste it as-is, edit it, or pick a different framing.
4. **Set expectations honestly.** Discord is synchronous and best-effort. Bugs and concrete feature requests should land in GitHub regardless (use the issue path); Discord is for the conversation that gets you to *"yes, this is a bug, file it"* or *"yes, this should be a feature, file it"*.
### 9. Follow up
- **Issue path**: record the issue URL in the user's project notes (or in the project's `wip/` if there is one) so a later upgrade or related work can reference it. If the bug is the symptom of an old version of Prisma 8, suggest an upgrade following [`upgrade-app.md`](upgrade-app.md) — many bugs are fixed in newer releases.
- **Discord path**: once the conversation on Discord settles into a concrete bug or a concrete feature request, return to step 1 of this skill and file the issue (the Discord thread becomes the *Notes* / *Where the gap surfaces* reference in the issue body).
## Common Pitfalls
1. **Auto-submitting without confirmation.** Always show the body first. The user owns the public-facing artifact, not the agent.
2. **Pasting `DATABASE_URL` or other secrets into the body.** `redact` aggressively. Replace with `postgresql://USER:PASS@HOST/DB` placeholders.
3. **Pasting a customer's confidential domain schema.** When original model and field names would leak confidential information (product codenames, customer identifiers, regulated-data fields), rename to neutral placeholders before the body goes into a public issue. Otherwise, keep the original names — a faithful excerpt is easier for the framework team to reason about than a re-themed one. Over-renaming is its own readability cost.
4. **Filing a documentation question as a bug.** Documentation questions belong in another skill or in a GitHub Discussion (if the repo enables them). Bugs are about the surface misbehaving.
5. **Conflating bug + feature in one issue.** File two. Mixed issues are hard to triage and hard to close.
6. **Filing without a version.** "I'm using Prisma 8, it's broken" without the version makes triage hopeless. The version is the cheapest piece of context to capture; always include it.
## What Prisma 8 doesn't do yet
- **In-product feedback channel.** No `prisma feedback` CLI command. The GitHub Issues page is the canonical surface. If you want a CLI-side feedback command, file a feature request via this skill.
## Reference Files
- <https://github.com/prisma/orm/issues/new/choose> — the canonical submission surface.
- <https://cli.github.com/manual/gh_issue_create> — the `gh` command reference.
## Checklist
- [ ] Classified as bug or feature request (not both in one issue).
- [ ] Environment block present: PN version, Node, package manager, OS.
- [ ] Reproduction is minimal, public-safe, secret-free.
- [ ] Schema fragments use original names where safe; renamed to neutral placeholders only where original names would leak confidential domain detail.
- [ ] Title in conventional-commit form (`bug(area): …` / `feat(area): …`).
- [ ] Body shown to the user for confirmation before submission.
- [ ] Submitted via `gh issue create` (preferred) or via the prefilled new-issue URL.
- [ ] Issue URL captured for future reference.
- [ ] Suggested an upgrade ([`upgrade-app.md`](upgrade-app.md)) if the bug may already be fixed in a newer release.
references/migration-model.md
# Prisma 8 — The Migration Graph and Refs (Mental Model)
> **Edit your data contract. Prisma handles the rest.**
This reference teaches the model behind migration planning: what the migration graph is, what refs are, how `migration plan` chooses where to start, and the one silent mistake the model exists to prevent — a plan that starts from an empty database while migrations already exist. Hold this model and both authoring loops (dev-database loop, deploy-first loop) follow from it; skip it and the planner's defaults will eventually produce a migration you didn't intend.
## When to Use
- Deciding where the next `migration plan` should chain from.
- `migration plan` output shows `from: (baseline)` and you didn't expect a from-scratch plan.
- Setting up on-disk migrations for a project whose databases are managed by a deploy pipeline (Prisma Composer or your own CD) rather than by `db init` / `db update`.
- A database already exists (marked and accurate) but the repo has no on-disk migrations for it — retrofit.
- Questions about refs: `migration ref set` / `list` / `delete`, what the `db` ref means, `--advance-ref`, why a ref is stale.
## When Not to Use
- Filling placeholders, applying migrations, hash mismatches, drift recovery → `references/migrations.md`.
- What runs on deploy, environment refs in CI, concurrent-migration conflicts → `references/migration-review.md`.
- First-time adoption of an existing database (`contract infer` + `db sign` mechanics) → `references/quickstart.md` § *Brownfield-DB*.
## Key Concepts
### The graph is a static artifact
The on-disk migrations under `migrations/<space>/` form a directed graph. **Each migration is an edge recording a `from` and a `to` contract storage hash** (in its `migration.json`; `from: null` denotes an edge from the empty database). Nodes are the hashes those edges mention. That is the whole structure — the graph records which contract states migrations exist between, and nothing else.
Consequences worth internalising:
- **No node is privileged.** There is no "current" node, no HEAD, no special tip the planner chains from. Multiple branch tips are legal (two migrations planned off the same node). Cycles are legal (a rollback edge back to an earlier hash creates one).
- **The graph does not know where your database is.** "Where is my database" is answered by the database's **marker** (a live-DB record of "this database is at hash X") and, offline, by **refs**. Never by the graph.
- **The graph does not know where your next migration should start.** The planner has to be told an origin. That is the entire job of refs and `--from`.
### Refs — version-controlled pointers
A ref is a small committed file, `migrations/<space>/refs/<name>.json` (for your app: `migrations/app/refs/<name>.json`), containing `{ hash, invariants }`. Two roles, by convention:
- **The `db` ref** records which contract hash the project's dev database has been brought to. It is a **checkpoint, not a promise**: dev commands write it after the fact so the offline planner knows where dev iteration left off, without opening a database connection.
- **Environment refs** (`staging`, `production`, …) name **the contract CD will migrate that environment to** — a forward promise the repo makes, consumed at deploy time. See `references/migration-review.md` for the deploy-side workflows.
`db` is a **default name, not a magic one**. It gets no special storage, no protection; dev commands simply default to advancing a ref of that name. You can point it anywhere with `migration ref set db <hash>` and a later `db update` on the default URL will overwrite it.
Manage refs with:
```bash
pnpm prisma migration ref set <name> <hash-or-ref>
pnpm prisma migration ref list
pnpm prisma migration ref delete <name>
```
`migration ref set` requires the target to be the `to` hash of an on-disk migration. A hash outside the graph is refused (`MIGRATION.HASH_NOT_IN_GRAPH`); a hash that appears in the graph only as a `from` — no bundle produces it — is refused too (`MIGRATION.REF_SET_BUNDLE_NOT_FOUND`). Find node hashes with `migration list`.
### Who advances refs
| Command | Ref advancement |
|---|---|
| `db init` / `db update` (default URL) | Implicitly advance `db` (override the name with `--advance-ref <name>`; suppressed whenever `--db` is passed without `--advance-ref`, regardless of the URL — even `--db $DATABASE_URL` pointing at the default database) |
| `db sign` | Advances `db` after a successful signature (override the name with `--advance-ref <name>`; `--no-advance-ref` skips it, writing no ref and no snapshot); an existing ref is overwritten and the previous hash is reported in the human output (the JSON `advancedRefs` lists `{ space, name, hash }` for each contract space it advanced). `--db` does **not** suppress it, unlike init/update: sign never mutates the schema, and adoption is normally done via `--db` |
| `db migrate --advance-ref <name>` | The **only** apply-time advancement |
| plain `db migrate` | **Never advances anything** — deliberate: deploy and CI applies must not infer dev intent |
| `migration plan` | Never advances anything — chaining discipline is yours |
| deploys (Composer / CD) | Write the database's marker; structurally cannot and do not touch repo refs |
### How `migration plan` picks its origin
`migration plan` resolves its origin in exactly this order:
1. Explicit `--from <ref-name | hash | hash-prefix | migration-dir | migration-dir^ | @empty>` — `@empty` names the empty database deliberately. The reserved forms `@db` and `@contract` exist in the shared ref grammar but do not resolve here: `migration plan` is offline, so `@db` (the live marker) has nothing to read, and `@contract` needs a contract hash the plan resolver does not pass. Use them with `db migrate --show` / `migration status`, not with `plan`.
2. No `--from` → the `db` ref (`migrations/app/refs/db.json`).
3. No `db` ref → **greenfield: the plan starts from the empty database.** On an empty graph the human output adds a muted notice beneath the summary — `No db ref set — planning from an empty database. Run db init, db update, or db sign if a database already exists.` — and the JSON document carries `fromDefaulted: true`, so this case is distinguishable from an explicit `--from @empty`.
It is **offline** — it never consults a database, never reads a marker (which is why `--from @db` is not an option here). Whatever the refs on disk say is what it believes. The destination defaults to the emitted `contract.json` (`--to` overrides).
When the origin came from the `db` ref by default and that node **already has an outgoing migration**, the plan still succeeds but warns: planning from there forks the graph. Pass `--from` to name the origin deliberately if that is what you want.
**`migration new` picks its origin the same way, with one difference.** With `--from <hash-or-prefix>` it uses that migration's `to` hash. Without `--from`: the `db` ref (which must be a graph node), else greenfield on an empty graph, else `MIGRATION.PLAN_ORIGIN_UNKNOWN`. The difference: on an empty graph that has a `db` ref, `migration plan` writes the baseline (auto-baseline above) while `migration new` refuses with `MIGRATION.HASH_NOT_IN_GRAPH` and tells you to run `migration plan` first. Neither command ever chains from "the newest migration on disk" — there is no such node.
The human output names the resolved origin on its `from:` line. **`from: (baseline)` means the origin resolved to nothing — the plan starts from an empty database** and will contain a create for every object in the contract.
**Auto-baseline.** When the graph is *empty* and the origin resolved through a ref to a real hash (the typical first plan after `db update` cycles), the planner emits **two** bundles in one invocation — a baseline `null → ref-hash` plus the delta `ref-hash → contract` — so the ref's hash becomes a graph node and the plan can be applied. Expect two new directories in `git status`. Details and the related refusals (`MIGRATION.HASH_NOT_IN_GRAPH`, `MIGRATION.SNAPSHOT_MISSING`) are in `references/migrations.md` § *Dev → ship transition*.
## The trap — a greenfield plan over existing migrations
**A plan whose origin is the empty contract while migrations already exist on disk is almost always a mistake.** A full-create migration cannot do what you meant: a database that has the prior migrations applied refuses it (`MIGRATION.PATH_UNREACHABLE` — no path from its marker to the new plan's destination), and running its create statements against any populated schema fails outright. The CLI refuses this at plan time: when origin resolution falls all the way through (no `--from`, no `db` ref) and migrations exist, `migration plan` stops with `MIGRATION.PLAN_ORIGIN_UNKNOWN` instead of writing the package — the error's suggestions are the three exits below; do not reflexively take the `--from @empty` one, pick by intent.
How the fall-through happens: a project that never runs `db init` / `db update` / `db sign` (the deploy-first path below) never acquires a `db` ref, so *every* default plan resolves to the empty origin. Running the dev loop with an explicit `--db` has the same effect: `db init` / `db update` with that flag never advance the ref, whatever URL it carries (`db sign` is the exception — it advances the ref with or without `--db`). The first time that is correct (it is the baseline; an empty migration graph plans with only the muted notice); every later time it is the trap the refusal catches.
**Recognize a from-empty plan** that was produced anyway (an explicit `--from @empty`, or an older CLI without the refusal), at either layer:
- Plan output says `from: (baseline)` — while `migrations/app/` already contains migration directories.
- The new package's `migration.json` has `"from": null` — while sibling migrations exist.
- The planned operations create objects you know already exist.
**Three exits.** Pick by intent, delete the mistaken package directory first if one was written:
1. **Set a ref to the intended origin, then re-plan with the default.** Usually the last shipped migration's `to` hash: `migration list` to find it, `migration ref set db <hash>`, `migration plan --name <slug>`. Do this when you want future plans to chain without flags.
2. **Pass the origin explicitly:** `migration plan --from <ref-or-hash-or-migration-dir> --name <slug>`. Do this for a one-off, or when a different ref (e.g. `production`) is the honest origin.
3. **You genuinely mean the empty origin** — a first baseline, or a deliberate rebuild of everything. Say it explicitly: `migration plan --from @empty --name <slug>`. This is the only case where `from: (baseline)` over a non-empty directory is right, and it should be rare enough to say out loud.
## Workflow — the dev loop
The concept: while the schema is in flux, iterate the dev database with `db init` / `db update` — they apply the contract *and* keep the `db` ref current. When the shape settles, plan: the plan chains from the ref, and the auto-baseline covers the case where the graph is still empty.
```bash
pnpm prisma db init # once; advances the db ref
pnpm prisma contract emit && pnpm prisma db update # iterate; each advances the db ref
pnpm prisma contract emit && pnpm prisma migration plan --name <slug>
pnpm prisma db migrate --db $DATABASE_URL
```
After a plain `db migrate` the marker advances but the ref lags; refresh with `db update` (no-op on the DB when already current) or apply with `db migrate --advance-ref db` in the first place. Full mechanics, refusals, and recovery: `references/migrations.md` § *Dev → ship transition*.
## Workflow — the deploy-first loop (Composer / CD-managed databases)
The concept: the deploy pipeline owns the databases, so `db init` / `db update` never run and nothing ever advances a `db` ref for you. Deploys replay each database from its marker to the contract emitted at build time; they never write to `migrations/app/refs/`. Two disciplines keep this loop safe:
**Author the baseline before the first deploy.** Before anything is deployed:
```bash
pnpm prisma contract emit
pnpm prisma migration plan --name init # from: (baseline) — intended, this once
git add migrations/ && git commit
```
This is the one intended greenfield plan. With the baseline committed, the deployed database's marker always corresponds to a graph node, and every later state is reachable by planned migrations.
**Chain every later plan from the last shipped contract.** Nothing advances refs in this loop, so either keep the `db` ref current yourself — after each plan, `migration ref set db <new-migration-to-hash>` (the hash is a graph node as soon as the plan is written; `migration list` shows it) — or pass `--from <last-migration-dir>` on every plan. Committing the ref together with the migration keeps teammates' default plans chaining correctly too.
If you skip the chaining, the next default plan resolves to greenfield: the trap above. And note the planner accepts *any* graph-node origin without complaint — planning from a stale ref silently creates a second branch tip (legal, occasionally intended, usually not). Check `migration list` when in doubt.
## Workflow — adopt a pre-existing database
The concept: a database that predates Prisma 8 enters the system by describing it, not migrating it — `contract infer` derives the contract from the live schema, and after review + `contract emit`, `db sign` records the marker. Full recipe: `references/quickstart.md` § *Brownfield-DB*.
```bash
pnpm prisma contract infer --db "$DATABASE_URL" --output src/prisma/contract.prisma
# review and re-author, then:
pnpm prisma contract emit
pnpm prisma db sign --db "$DATABASE_URL" # advances the db ref even with --db
```
By default `db sign` sets the `db` ref to the signed contract's hash and stores its snapshot, so the next `migration plan` chains from the adopted schema (`--advance-ref <name>` writes another ref instead, and `--no-advance-ref` writes none; after either, set the `db` ref yourself with `migration ref set db <hash>` or pass `--from` on the next plan): the graph is still empty at that point, so the plan auto-emits the baseline `null → signed-hash`, plus a delta `signed-hash → contract` once the contract has moved on from the signed one. No baseline is written at sign time. If the graph is already non-empty and the signed hash is not a graph node, the next plan refuses with `MIGRATION.HASH_NOT_IN_GRAPH`, exactly as after `db update`.
## Workflow — retrofit a database that has no on-disk migrations
The concept: the database exists and its marker is accurate (hash **M**) — it was built by `db update` or signed with `db sign` in another checkout, or built by a deploy pipeline — but the migration graph doesn't reach M. (A database adopted with `db sign` in *this* checkout already has its ref set; retrofit is for the case where the ref was never set here.) The goal is to **make the graph reach the marker's hash**: once a baseline `null → M` exists, applying against the marked database is clean by construction — the runner starts at the marker, so the baseline never executes; only real deltas past M run.
- **It's your dev database.** Run `db update` (default URL; no-op on the DB when the contract already matches) — it advances the `db` ref and stores the contract snapshot. The next `migration plan` auto-emits the baseline plus your delta. This is just the dev loop's dev → ship transition.
- **It's a deployed database you must not touch.** Build the baseline offline, at the deployed contract state:
1. Bring the contract source back to the deployed state (check out the deployed revision of the contract file, or stash your edits), then `contract emit`.
2. `migration plan --name baseline` — greenfield, intended: one bundle `null → M`. Confirm its `to` matches the database (`db verify --db "$URL"` is clean at this revision).
3. Restore the current contract source, `contract emit`.
4. `migration ref set db <M>` (M is now a graph node), then `migration plan --name <slug>` — the delta `M → current`.
`db migrate` against the marked database applies only the delta.
## Common Pitfalls
1. **Assuming `migration plan` or `migration new` chains from the newest migration on disk.** Neither does. The origin is `--from`, else the `db` ref. With neither, only an empty graph plans from scratch (with the muted `No db ref set` notice); once migrations exist, both commands refuse with `MIGRATION.PLAN_ORIGIN_UNKNOWN`.
2. **Expecting `migration plan` or plain `db migrate` to keep the `db` ref current.** Neither touches refs. Only `db init` / `db update` / `db sign` advance implicitly, and only `--advance-ref` advances at apply time.
3. **Expecting a deploy to update refs.** Deploys write the database's marker; the files under `migrations/app/refs/` only change when you change them.
4. **Reading `from: (baseline)` as informational.** Over a non-empty migrations directory it is the trap announcing itself. Stop and pick an exit before applying or committing.
5. **`migration ref set` with a hash no on-disk migration produces.** A ref target must be the `to` hash of an on-disk migration bundle. A hash outside the graph is refused (`MIGRATION.HASH_NOT_IN_GRAPH`); a from-only graph node is refused with `MIGRATION.REF_SET_BUNDLE_NOT_FOUND`, whose fix text points at fixtures and is unhelpful here. Either way: plan the edge whose `to` is the hash first (baseline or delta), then set the ref.
6. **Treating `db` as reserved.** It's a naming default. Setting it yourself is fine and sometimes exactly right (deploy-first chaining, retrofit); just expect dev commands on the default URL to overwrite it.
7. **Authoring the first migration after the first deploy.** Then no graph node corresponds to what shipped, and every incremental path needs the retrofit. Baseline before the first deploy — it's one command.
## What Prisma 8 doesn't do yet
- **No plan-time ref advancement.** `migration plan` cannot advance a ref for you; keeping the chain current is manual (`migration ref set` after each plan, or `--from` every time). If you want a plan-time advancement flag, file a feature request via the `references/feedback.md` skill.
## Checklist
- [ ] Named the intended origin before planning — a ref, a hash, or a deliberate `--from @empty`.
- [ ] Read the plan output's `from:` line and confirmed it names that origin — not `(baseline)` over an existing graph.
- [ ] In a deploy-first project: baseline authored and committed before the first deploy; every later plan chained via the `db` ref or `--from`.
- [ ] After each plan in a loop where nothing advances refs: advanced the `db` ref (`migration ref set db <to-hash>`) or resolved to pass `--from` next time.
- [ ] For a marked database with no on-disk migrations: made the graph reach the marker's hash (auto-baseline via `db update` or `db sign`, or an offline baseline plan) before planning deltas.
- [ ] Did NOT expect plain `db migrate`, `migration plan`, or a deploy to advance any ref.
- [ ] Did NOT apply or commit a `from: (baseline)` plan without confirming the empty origin was the intent.
references/migration-review.md
# Prisma 8 — Migration Review (Deployment + Concurrency)
> **Edit your data contract. Prisma handles the rest.**
This skill is about *reviewing* migrations, not authoring them. It covers the questions that come up at deploy time and when multiple developers are landing migrations concurrently.
The skill teaches *the system's mental model* — what a ref is, what a marker is, what the migration graph is — and shows how to ask the system for its state. It does **not** prescribe rigid step-by-step procedures: most "review" questions are answered by understanding the model and querying the right thing. Rigid procedures are reserved for the rare case where there's literally one safe path.
## When to Use
- User asks *"what migrations will run when I merge this?"* or *"what's about to run on deploy?"*.
- User hit a concurrent-migration conflict (`main` advanced while their branch was open).
- User wants to wire up a `staging` / `production` ref so CI can deploy against it.
- User wants to run a migration against a deployed environment.
- User asks about CI integration for migrations.
## When Not to Use
- User wants to *author* a migration → `references/migrations.md`.
- User wants to fix a hash-mismatch / drift in a single env → `references/migrations.md` (re-plan path) or `references/debug.md` (envelope-driven).
- User wants to edit the contract → `references/contract.md`.
## Key Concepts — the navigation model
**Every migration question is a navigation from an *origin* to a *destination*.** Once you have this model, the rest of the skill is just "which command asks the system about which navigation."
### Origin
The **origin** is the database's *current contract hash*. The database carries a row in PN's marker table that records *"this database is at hash X"*. When the CLI runs online (a `--db <url>` is provided, or `db.connection` is set in `prisma.config.ts`), PN reads the marker and that hash is the origin. Offline (no DB connection), the origin is unknown — many commands degrade to listing the on-disk migrations and skip the per-edge applied/pending status.
A live DB is therefore the authoritative source of origin. The "recorded marker" in any other artifact (refs, local cache, your assumptions) is a working copy that can drift; the live DB never does.
### Destination
The **destination** is the contract hash you want the database to be at. Two ways to name a destination:
- **A `--to <name>`** — a named pointer to a hash, stored under `migrations/app/refs/<name>.json`. Refs are named after environments by convention (`staging`, `production`) to communicate *"this is where production is expected to be"*. The ref itself is just a hash + an optional set of required invariants; it has nothing to do with which database you connect to.
- **The current contract head** — implicit when no `--to` is passed. This is the hash of the current `contract.json` on disk.
`--to staging` does **not** mean "connect to the staging database." It means "navigate the database I connected to (via `--db` or config) toward whatever hash this ref points at." Database selection is orthogonal: pass `--db $STAGING_DATABASE_URL` to actually point at staging.
### The migration graph
The on-disk migrations form a directed graph: **nodes are contract hashes; edges are migrations.** Each migration declares a `from` hash and a `to` hash. A migration applies only when the database's current marker matches its `from` hash; running it advances the marker to its `to` hash.
The graph is a static, committed artifact. Several branch tips may coexist, rollback edges may form cycles, and no node is privileged — "where is my database" is answered by the marker and refs, never by the graph itself. `references/migration-model.md` carries the full model, including how `migration plan` chooses its origin.
`migration status` queries the graph for the path from origin to destination and reports per-edge status:
- **applied** — the database's ledger records the migration as applied. Only a run connected to a database reports this.
- **pending** — on the path from the origin to the destination and not yet applied (what would run).
- **`null`** — neither: the migration is off the path to the destination, or the run is offline and cannot see what the database applied. In data lines this is an empty field.
### Diagnostic codes
`migration status` answers with a document (`--json`): `spaces[]`, each with `currentContract` (the marker hash, or `null` when the database has no marker for that space), `targetContract`, and `migrations[]` carrying the `applied` / `pending` / `null` status above; a `summary` line; and `diagnostics[]`. The ordinary states — up to date, N pending, no marker yet, marker on another branch, no path to the target — are read from `spaces[]` and the summary, not from diagnostic codes. The three diagnostics it can attach are all `warn`, and each carries a `message` and `hints` — the same hints the CLI prints under the summary line:
| Code | Meaning in the navigation model | Next move |
|---|---|---|
| `MIGRATION.MARKER_NOT_IN_HISTORY` | Online; marker hash is not a node in the graph. The database was changed outside the migration system. | Decide which side is truth: `db sign` (accept DB as truth), `db update` (push contract to DB), `contract infer` (re-derive contract from DB), or `db verify` (inspect first). **Not** the same as `MIGRATION.MARKER_MISMATCH`, which `db migrate` (before any DDL) and `db migrate --show` raise as an error when the marker hash is not a graph node. |
| `MIGRATION.MISSING_INVARIANTS` | Marker reached the destination structurally but lacks invariants the target ref declares. | `db migrate --to <name> --db $URL` to take a path that covers them. |
| `CONTRACT.UNREADABLE` | `contract.json` couldn't be read. | `contract emit` to regenerate it. |
Conditions that make the run *refuse* instead (exit `2`) arrive as ordinary errors: `MIGRATION.NO_INVARIANT_PATH` (no path covers the missing invariants), `MIGRATION.UNKNOWN_INVARIANT` (the ref names an invariant no edge provides), and the ref-resolution errors for a bad `--to` / `--from`.
### Graph-tree output
`migration status` (and `migration list`) render the migration graph as a colored lane tree in the terminal. Two flags control the rendering:
- `--legend` — prints the key for the tree glyphs and lane colors before the tree.
- `--ascii` — replaces box-drawing glyphs with pipe-safe ASCII characters (useful in CI logs or environments that don't support Unicode).
Both flags are also available on `migration list` and `migration graph`. `migration log` supports `--ascii` only (it renders a flat chronological table, not a tree).
### Plan- and apply-time diagnostics
These codes surface on `migration plan`, `migration ref set`, and `db migrate` — not on `migration status`. See [Migration System § Recovery affordances](https://github.com/prisma/orm/blob/main/docs/architecture%20docs/subsystems/7.%20Migration%20System.md#recovery-affordances) and [ADR 218](https://github.com/prisma/orm/blob/main/docs/architecture%20docs/adrs/ADR%20218%20-%20Refs%20with%20paired%20contract%20snapshots%20and%20universal%20graph-node%20invariant.md).
| Code | When | Meaning | Next move |
|---|---|---|---|
| `MIGRATION.HASH_NOT_IN_GRAPH` | `migration plan` (non-empty graph) or `migration ref set` | Resolved hash is not a node in the on-disk migration graph — typical when the default `db` ref points past the graph tip after `db update` cycles. | `migration plan --from <reachable-ref>` (e.g. `--from production`); or realign the ref with `migration ref set db <graph-node-hash>`. |
| `MIGRATION.SNAPSHOT_MISSING` | `migration plan` | A named ref has no pointer file (`<name>.json`), and the hash being resolved isn't a node in the migration graph either. | `migration ref set <name> <hash>` to create the ref, `db update --advance-ref <name>` to advance it, or pass a hash that is a graph node. |
| `MIGRATION.MARKER_MISMATCH` | `db migrate` (pre-DDL, before the runner), `db migrate --show` | Live DB marker hash is not a graph node. Usually a migration the database applied was planned again or deleted. | Follow the error's next actions, and `references/migrations.md` § *A database whose marker is not in the migration graph*. Never reset the database. |
| `MIGRATION.PATH_UNREACHABLE` | `db migrate` (path resolution) | No migration path from the current marker to the resolved target in the on-disk graph. | Read the improved `fix` payload — it names `fromHash` / `targetHash` and suggests `migration plan --from <from> --to <target>`; run `migration list` to inspect the graph. |
## Workflow — *"What's about to run on deploy?"*
The user asks: *"I'm about to merge this PR. What migrations are going to run when I deploy to staging?"*
This is the navigation question: **origin** = staging's live marker; **destination** = the ref `staging` (or the contract head if you haven't set one). Ask the system:
```bash
pnpm prisma migration status --to staging --db "$STAGING_DATABASE_URL"
```
The command:
1. Reads the staging DB's marker (the origin).
2. Resolves `staging` to a contract hash (the destination).
3. Renders the path between them as an ordered list of migrations, with per-edge `applied` / `pending` / `null` status, and an explicit summary line of the form *"N migration(s) behind ref 'staging'"*.
4. Prints a header that names the config, migrations directory, the active ref, and the database connection (masked) — so the framing is visible in the output.
If you omit `--db`, the command runs offline: it lists the migrations on disk but cannot tell you what's applied, because it has no origin. That's fine for *"what's on this branch?"*; it's not fine for *"what's about to run on staging?"* — for that you need staging's live marker.
If you omit `--to`, the destination defaults to the contract head — which answers *"is this branch's contract reachable from the database, and how?"*, not *"what runs on deploy"*. Pass the ref explicitly when the question is about a specific environment.
`migration status` summarises each pending migration's operations by class (`additive`, `widening`, `data`, `destructive`) and reports a destructive-op count when destructive operations are present. Surface that count to the user before they merge or deploy — destructive operations are the class that warrants manual review.
## Workflow — *"What state is each environment at?"*
Just `migration status --db $URL` for each environment's DB. The marker (origin) comes back from the DB itself; the summary line tells you whether the environment is at the contract head, at a named ref, ahead of head, or on a divergent branch.
## Concept — concurrent migrations on the same branch point
This used to be called *diamond convergence* in some PN docs; the situation is the same regardless of the label.
**What's happening.** Two topic branches each authored a migration off the same parent contract hash. The first branch merges to `main`; the destination ref (e.g. `production`) advances to that branch's `to` hash. Your branch's migration still has its `from` hash pointing at the *old* parent. The migration graph, after rebase, no longer has a clean path through your migration:
- Your migration's `from` is no longer an ancestor of the new head.
- Or your migration's `from` is reachable, but the path through your migration arrives at a hash that's not the union of both branches' changes.
Either way, the on-disk plan is stale.
**Resolution.** The on-disk plan is stale because its `from` hash is no longer the head of the graph; apply the cluster's standard *edit → plan → apply* loop to the post-rebase state and the planner produces a fresh migration whose `from` matches the new head.
**The one thing the planner can't do for you** is port custom data-transform logic from the abandoned `migration.ts` into the new one — schema deltas are derived from the contract, but any hand-written `data` operations are yours to carry across before applying. There is no separate "revalidate" step, no special "diamond apply" flow.
## Workflow — set, list, get, delete refs
Refs are small artifacts. There's no per-environment lifecycle; you just point a name at a hash.
```bash
pnpm prisma migration ref set production <contract-hash>
pnpm prisma migration ref list
# `migration ref get` was removed — use `migration ref list` and filter by name
pnpm prisma migration ref list | grep production
pnpm prisma migration ref delete production
```
`migration ref set` writes a file at `migrations/app/refs/<name>.json` carrying the hash and any required invariants. Refs are commit-friendly artifacts — keep them in git; the team agrees on what `production` points at the same way they agree on what `main` is. The hash being set must be the `to` of an on-disk migration, or the command refuses — see `references/migration-model.md` for the refusal codes.
Two ref roles, one mechanism: environment refs like `production` are the contract CD will migrate that environment to (a forward promise), while the `db` ref is a checkpoint of where the project's dev database was last brought to — written by `db init` / `db update` on the default URL and by `db sign` by default (`--advance-ref <name>` targets another ref, `--no-advance-ref` skips the write), consumed by `migration plan` as its default origin. `references/migration-model.md` covers the `db` ref, advancement rules, and plan-origin resolution.
## Workflow — apply a migration against an environment
```bash
pnpm prisma db migrate --to production --db "$PRODUCTION_DATABASE_URL"
```
The destination is the ref's hash; the origin is the production DB's live marker. The command computes the path between them and applies each pending migration in order, advancing the marker.
`--db` is the environment selection knob. `--to` is the destination-hash knob. They're independent.
## Concept — ref-mismatch on CI / deploy
CI reports: *"the recorded ref `production` is at hash X; the live DB is at hash Y."*
The mismatch is a fact about *two pieces of state that disagree*. The investigation is the same regardless of which piece is wrong:
- **DB ahead of the ref.** Someone applied a migration outside CI without updating the ref in git. Re-record the ref with `prisma migration ref set <ref-name> <db-marker-hash>` (commit + push); then audit how the out-of-band apply happened.
- **DB behind the ref.** A previous deploy was rolled back, or the DB was restored from an older backup. Either re-apply forward with `prisma db migrate --to <ref-name> --db $URL`, or re-route the ref backward to match what's actually deployed with `prisma migration ref set <ref-name> <db-marker-hash>`. The choice is the user's — name both options.
- **DB on a different branch.** An out-of-band schema change (manual SQL, ad-hoc migration) wrote something the migration graph doesn't model. Run `prisma db verify` to inspect the drift, then either `prisma contract infer` to re-derive the contract from the database, or edit the contract and run `prisma migration plan` so the database is the eventual destination.
`migration ref set` to silently align the ref with whatever the DB happens to be at is almost never the right move. It papers over drift that you'll pay for later.
## Workflow — CI: deploy to an environment
The whole job is one command:
```yaml
- name: Migrate staging
run: pnpm prisma db migrate --to staging --db "$STAGING_DATABASE_URL"
```
`db migrate` is safe to run unattended, by design. Before any operation runs it reads the live marker and refuses with `MIGRATION.MARKER_MISMATCH` if that hash is not a node in the on-disk graph, which is what a database changed outside the migration system looks like. Each operation then evaluates its own `precheck[]` and stops if the database is not in the state the operation expects. A successful run writes the destination hash as the new marker. Nothing runs before `db migrate` in the job.
`migration status --to staging --db $URL` is what a human or agent runs to answer *"what will run on deploy?"* before merging: it reports the path from the live marker to the ref without changing anything. `db migrate --show --db $URL` gives the same path as an ordered preview; `migration log --db $URL` gives the applied history after a deploy.
`db migrate` is interactive-free and asks nothing — the questions about data loss are asked by `migration plan` when it writes the package, and by `db update` (see the `references/migrations.md` skill). Whatever the planner put in the migration graph is what `db migrate` runs; review happens at `migration plan` and at `migration status` time, before the apply step.
## Common Pitfalls
1. **Reading `migration status` without `--to` for a deploy question.** That asks *"can this branch's contract reach the head?"*, not *"what's about to run on staging?"*. Always pass the ref when the question is about a specific environment.
2. **Reading `migration status` without `--db` for a deploy question.** Without a live DB, you have no origin. The output lists what's on disk; it can't say what's applied on the environment. Pass `--db $URL` for any high-stakes question.
3. **Confusing the ref with a DB connection.** `--to staging` selects the destination hash, not the database. Pass both `--to` and `--db` explicitly.
4. **Treating diamond convergence as a special procedure.** It's not. It's the normal *edit → plan → apply* loop applied to the post-rebase state. The only extra step is *"port any data-transform logic from your old `migration.ts` over."*
5. **Running `migration ref set` to silence a CI mismatch without understanding the cause.** That can mask out-of-band changes or rollback drift. Investigate first.
## What Prisma 8 doesn't do yet
- **Per-environment migration ordering beyond the default chain.** If you need staging to skip a migration that production requires (or vice versa), the supported path is to author the per-env divergence as separate migrations and gate them in your deploy script. If you want first-class per-env routing, file a feature request via the `references/feedback.md` skill.
- **A built-in side-by-side "branch diff" view.** There is a full-graph render (`migration graph`) that shows branches, but no `git diff`-style comparison between two branches' migration sets. Workaround: run `migration status` on each branch and `diff` the output. If you want a built-in branch-comparison view, file a feature request via the `references/feedback.md` skill.
## Reference Files
This skill is intentionally body-only; the underlying CLI reference (`prisma migration status --help`, `prisma db migrate --help`, `prisma migration ref --help`) is the authoritative surface for flag-level detail. When in doubt, run `--help` and read the actual command's description rather than guessing from this skill.
## Checklist
- [ ] Named both the **origin** (live DB marker) and the **destination** (ref or contract head) for the question the user asked.
- [ ] Passed `--db $URL` whenever the question involves a specific environment.
- [ ] Passed `--to <name>` whenever the question is about deploying *to* a named environment, not just *from* the current branch's head.
- [ ] Read the `migration status` header (it names config, ref, database) and the summary line (it names the origin/destination distance) before reading the per-edge list.
- [ ] For concurrent-migration conflicts: re-applied the *core* workflow (edit → plan → apply) rather than following a memorised "diamond convergence" procedure. Ported any data-transform logic from the abandoned `migration.ts` over.
- [ ] For a ref-mismatch: investigated *which* piece of state is wrong (DB ahead, DB behind, DB on a divergent branch). Did NOT `migration ref set` to silence the mismatch.
- [ ] Surfaced the destructive-op count from `migration status` (the only operation class that warrants manual review pre-deploy) before the user merges or deploys.
- [ ] In CI: the deploy job is `prisma db migrate --to <ref> --db $URL` and nothing else. Did NOT rely on a `--dry-run` flag on `db migrate` (no such flag exists).
- [ ] Did NOT confuse `--to` with database selection (`--to` picks the destination hash; `--db` picks the database).
- [ ] Did NOT use `--ref` (removed; use `--to`).
- [ ] Did NOT confabulate a "branch diff" CLI subcommand, a `migration revalidate` step, or any other API the skill above doesn't reference.
references/migrations.md
# Prisma 8 — Migration Authoring
> **Edit your data contract. Prisma 8 plans the migration. You fill in any data transforms.**
The three-step user model:
1. **You edit your data contract.** (`references/contract.md`)
2. **Prisma 8 plans the migration for you.** ← this skill
3. **If a data transform is needed, you edit `migration.ts` and self-emit.** ← this skill
Once the contract changes, you choose how the change reaches the database. This skill covers the two paths (`db update` and `migration plan` + `db migrate`), the migration-package contract, the `migration.ts` authoring API, and the failure modes you recover from without leaving the loop.
**Targets.** Migration authoring is first-class for **Postgres** and **Mongo**. The CLI reads the target from `prisma.config.ts` (set during `prisma orm init --target …`). Migration commands do not accept a `--target` flag — use a config scoped to the target you need. Examples below call out target-specific imports, markers, factories, and transaction behavior where they diverge.
## When to Use
- User edited the contract and wants to apply the change to the DB.
- User wants to author a migration with a data transform.
- User wants to run pending migrations against a local DB.
- User hit `MIGRATION.HASH_MISMATCH`, `MIGRATION.UNFILLED_PLACEHOLDER`, or a partially-applied migration.
- User mentions: *migrate, migration, db push, db update, `prisma migrate dev`, `prisma migrate deploy`, drift, hash mismatch, data backfill*.
- A deploy or `db migrate` fails with `MIGRATION.MARKER_MISMATCH` (older Composer releases: "no authored path"), or someone proposes resetting a database → *A database whose marker is not in the migration graph* below. For `MIGRATION.PATH_UNREACHABLE`, where the marker is in the graph but no migration reaches the target, plan from the marker to the target instead (`references/debug.md`).
## When Not to Use
- User wants to know what migrations *will run on deploy* / on merge, or to manage refs and invariants → `references/migration-review.md`.
- User is deciding where a plan should chain from, saw `from: (baseline)` unexpectedly, is setting up migrations for a deploy-first (Composer / CD-managed) project, or is retrofitting migrations onto an existing database → `references/migration-model.md`.
- User wants to edit the contract → `references/contract.md`.
- User wants a deeper read of a single structured error envelope → `references/debug.md`.
## Key Concepts
- **`db update` (quick path).** Reads the emitted contract, diffs against the live DB, applies the change. Optional `--dry-run` prints the plan without executing. Before an apply it asks what each operation that would lose data means, answered by `--delete <subject>` or `--rename <subject>:<new name>`, and whether each operation that would change who can read or write a model's data may run, answered by `--allow <Model>` (see *Answer the data-loss questions* below). **Writes no migration directory.** Operations needing data transforms are not handled by this path — `db update` excludes the `data` operation class entirely and short-circuits where a data transform would be required. It refuses `--convert` and `--backfill` (`MIGRATION.STATEMENT_NEEDS_MIGRATION_FILE`) and prints the `migration plan` command to run instead. Use it on a database whose migration history does not need to be kept (a local, development, staging or preview database), shared or not. Never use it on a database that changes only through migrations, such as production: it leaves no migration behind.
- **`migration plan` (formal path).** Reads the emitted contract, diffs it against a resolved origin — explicit `--from`, else the `db` ref, else the empty database; there is no "head of the graph" to chain from (see `references/migration-model.md`) — and writes a new migration package under `migrations/app/<YYYYMMDDTHHMM>_<snake_slug>/`. A plan that would lose data is written only once `--delete`, `--rename` or, for a type change, `--convert` says what each such operation means. The package's `migration.ts` contains `placeholder(...)` calls only where `--convert` or `--backfill` asked for a slot (see *Type changes and new required fields*).
- **The `app/` segment in migration paths is the consuming application's contract-space id.** Every migration *you* author lives under `migrations/app/`. Extensions your contract depends on get their own sibling directories (`migrations/<extension-space-id>/`) — those are managed by the extension package and you don't write into them. The `app/` segment lands automatically the first time you run `migration plan` / `db init` against an app-level config.
- **Migration package files** (inside each `migrations/app/<dir>/`):
- `migration.json` — manifest (metadata + `migrationHash`).
- `ops.json` — canonical operation list. Content-addressed; `migrationHash` is computed over this.
- `migration.ts` — TypeScript authoring source, **framework-rendered** by `migration plan` (or `migration new`). You edit specific holes in it (see *Fill a placeholder* below) and re-emit `ops.json` / `migration.json` by running it.
- **Contract snapshots.** `migration.ts` imports its bookend contracts from the shared, content-addressed store at `migrations/snapshots/<hex>/contract.json` + `contract.d.ts` (`<hex>` is the contract's 64-hex storage hash) — not from files inside the migration package.
- **Self-emit.** Running `node <package dir>/migration.ts` regenerates `ops.json` and `migration.json` from the (possibly edited) TS source. This is the only supported way to update an existing migration package after edits. `<package dir>` is the directory `migration plan` or `migration new` printed; it sits under the config's `migrations.dir` (by default `migrations/app/<dir>`), so the path depends on that setting. The command works from any directory. Without `--config`, the file uses the config whose migrations directory holds it: the nearest `prisma.config.ts` above it, else the one in the working directory; if neither does, it fails with `CLI.MIGRATION_CONFIG_NOT_FOUND`. If you gave the CLI `--config`, pass the same `--config <path>` to the file.
- **`migration.ts` shape.** Framework-rendered. A class `M extends Migration<Start, End>` (from `@internal/postgres/migration` on Postgres, `@prisma/orm-mongo/target/migration` on Mongo — see the framing block below) that assigns the two snapshot imports to `startContractJson` / `endContractJson` (`Start` is `never` and there is no `startContractJson` on a baseline) and has an `operations` getter returning an array of operation values. **On Postgres the operation factories are methods on the base class** (`this.addColumn({...})`, `this.setNotNull({...})`, `this.dataTransform(...)`) taking one options object; free helpers like `col(...)` build the column descriptors they take. **On Mongo they are free factories** (`createIndex(...)`, `dataTransform(...)`) imported beside `Migration`. The file ends with `MigrationCLI.run(import.meta.url, M)` so executing it self-emits.
- **`placeholder(slot)`.** A sentinel the planner emits into the rendered `migration.ts` (from the same `.../migration` import as `Migration`) wherever a data transform is needed. Calling `placeholder(...)` at emit time throws `MIGRATION.UNFILLED_PLACEHOLDER` with `meta.slot` naming the hole. The user replaces the `() => placeholder(...)` arrow with a real query-plan closure (Postgres) or fills `dataTransform({ check, run })` sources (Mongo — see *Fill a placeholder*), then self-emits.
- **`this.dataTransform(endContract, name, { check, run })`.** The data-transform factory. `check` is a rowset query whose presence-of-any-row signals "work remains"; `run` is one or more mutation queries that perform the backfill. Both are lazy closures returning query-plans built against `endContract`. The runner wraps `check` as `EXISTS(...)` for precheck and `NOT EXISTS(...)` for postcheck, so the same closure asserts both "there is work" and "the work is done".
- **`pendingPlaceholders`.** A boolean field on the JSON result of `migration plan`. `true` means the package was written but contains unfilled placeholders — `db migrate` will throw `MIGRATION.UNFILLED_PLACEHOLDER` until you edit `migration.ts` and self-emit.
- **`migrationHash`.** Content-addressed identity of a migration package. `MIGRATION.HASH_MISMATCH` fires when the stored hash in `migration.json` disagrees with the hash recomputed from the on-disk files (almost always: someone edited `migration.ts` without self-emitting).
- **Marker.** Records "this database is at contract hash X for space Y". **Postgres:** a row in `prisma_contract.marker`. **Mongo:** a document in the `_prisma_migrations` collection (keyed by space). Each successful migration advances the marker once schema verification passes for that space. `db sign` writes the marker from the current contract hash, but only after a schema-verification pass succeeds (it will not sign a database whose live schema disagrees with the contract).
- **Apply atomicity.** **Postgres:** one `db migrate` run is one transaction — the runner issues a single `BEGIN`, applies every pending migration for every contract space, then one `COMMIT`; any failure issues `ROLLBACK` for the whole run, so the marker stays where it was before the command. **Mongo:** DDL ops (`createCollection`, `createIndex`, `collMod`, `setValidation`, …) are not wrapped in a multi-document transaction; the runner applies ops, verifies the live schema against the destination contract, and advances the marker only on verify-pass (resumable across spaces — see the MongoDB family doc). Ordinary DDL + `dataTransform` flows stay consistent; partial state from failed mid-migration runs is diagnosed with `db verify` / `db schema`, not assumed away.
- **Changed foreign key actions and primary keys (Postgres).** A changed `onDelete`/`onUpdate` plans a drop and an add of the foreign key. A moved or removed primary key plans a drop (and an add), and the foreign keys that reference the old key are dropped first and added back after it. These ask no data-loss question.
- **Operation classes.** Every operation declares an `operationClass`: `additive`, `widening`, `data`, or `destructive`. The CLI surfaces these in the plan preview and in JSON output. There is no `long-running` class and the framework does not emit `CREATE INDEX CONCURRENTLY` — operations stay transactional.
## `migration.ts` is framework-rendered, not hand-authored
Files under `migrations/<space-id>/<timestamp>/migration.ts` (for your own app, `<space-id>` is always `app/`) are **rendered for you** by the framework — `prisma migration plan` writes a populated package whenever the contract changes, and `prisma migration new` writes an empty scaffold when you want to author operations directly. You do not write these files from scratch. You edit specific holes the framework leaves behind — chiefly replacing `placeholder("<slot>")` sentinels (Postgres) or filling `dataTransform({ check, run })` pipeline slots (Mongo) — then self-emit.
**Postgres** rendered imports point at `@internal/postgres/migration` (or `@internal/sqlite/migration` for SQLite projects): one line carrying `Migration`, `MigrationCLI`, `col`, `placeholder`, `rawSql`, `sql` when the file writes a `sql` value, and any other free helper the operations need.
**Mongo** rendered imports point at one module too, `@prisma/orm-mongo/target/migration`, which carries `Migration`, `MigrationCLI`, `placeholder`, and the operation factories (`createIndex`, `dataTransform`, …). Raw command classes for data transforms come from `@prisma/orm-mongo/query-ast/execution`.
Treat the rendered import lines as framework-managed on both targets:
- Leave them where they are. Don't rewrite them to a different path; the framework's renderer is the authoritative shape and any change you make by hand will be reverted (and may trip `MIGRATION.HASH_MISMATCH`) the next time the package is re-rendered or self-emitted.
- If you need an additional helper symbol, **add it to the existing rendered import line** rather than introducing a second import from a different subpath.
- The "user code imports only from `@internal/<target>`" convention applies to *your* own modules (queries, runtime setup, contract authoring). The framework-rendered `migration.ts` scaffold is the framework's surface, not yours; the rule is suspended for that one file.
## Diagnostic codes you route on
| Code | Source | Move |
|---|---|---|
| `MIGRATION.UNFILLED_PLACEHOLDER` | Throwing `placeholder(...)` at emit time | Open `migration.ts`, replace the `placeholder("<slot>")` call named by `meta.slot` with the real query closure, self-emit. |
| `MIGRATION.FILE_MISSING` | Reading a migration package | `migration.ts`, `migration.json`, or `ops.json` is absent. Recover from version control, re-emit via `migration.ts`, or run `prisma migration new` for a fresh one. |
| `MIGRATION.INVALID_DEFAULT_EXPORT` | Loading `migration.ts` | The file's default export is not a `Migration` subclass or factory function. Restore the planner-emitted scaffold from version control or re-run `migration plan` for a clean package. |
| `MIGRATION.DATA_TRANSFORM_CONTRACT_MISMATCH` | Building a data-transform query plan | The query builder was instantiated with a contract reference different from the `endContract` passed to `this.dataTransform(...)`. Use the `endContract` imported at module scope for both. |
| `MIGRATION.HASH_MISMATCH` *Migration package is corrupt* | `db migrate` (or any read of the package) | `ops.json` / `migration.json` were edited without self-emitting. Run `node <package dir>/migration.ts` to re-emit, then re-run `db migrate`. |
| `CLI.CONSENT_REQUIRED` | `migration plan` / `db update` run with nobody to answer (`--no-interactive`, CI, `--yes`) | The plan would lose data or (on `db update`) widen access. `meta.unanswered` and `nextActions` list each question with the flags that answer it: `--delete <subject>`, `--rename <subject>:<new name>`, `--allow <Model>`. Re-run with the ones you mean. `--confirm` and `--yes` answer none of them. |
| `CLI.CONSENT_UNUSED` | `migration plan` / `db update` | Every question was answered, and a `--delete` or `--allow` was left over: a typo, a subject the plan does not touch, or a `--confirm` (which consents to nothing). Fix or drop the flag. A typo next to a question nobody answered is reported as `CLI.CONSENT_REQUIRED` instead, which names the flag that matched nothing. On `db update --dry-run` the same mistake is `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`. |
| `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS` | A rename typed at the data-loss prompt | The plan was made again with the rename and still loses the subject's data. Answer with `delete`, or give a rename whose old name is the subject. |
| `CONTRACT.MARKER_MISMATCH` | `db verify` (finding, exit 4) | The marker disagrees with the contract hash (**Postgres:** `prisma_contract.marker`; **Mongo:** `_prisma_migrations`). The DB is at a different contract version than the code thinks. Either run a migration forward, or — if the DB is correct and the marker is stale after a manual fix-up — run `db sign`. |
| `CONTRACT.MARKER_MISSING` | `db verify` (finding, exit 4), runtime startup (warning) | The DB has no marker yet. Run `prisma db init --db <url>` to baseline an empty database, `db update --db <url>` to apply the current contract directly, or `db sign --db <url>` if the schema already matches the contract. |
| `MIGRATION.STATEMENT_INVALID` | `migration plan` / `db update` with `--rename` | The statement is malformed, mixes a model and a field, renames something to itself, swaps two names, or repeats a name. The error's next step is the statement to type instead; use it. |
| `MIGRATION.STATEMENT_UNRESOLVED` | `migration plan` / `db update` with `--rename` | A name is not where a rename needs it. The error lists the names it found and says what to type: the statement the right way round, the order that works, or to leave out a rename that has already happened. |
| `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` | `db update --rename` | `db update` has no snapshot of the contract the database is at. Follow the error's steps: emit that contract, run `db update --advance-ref <name> --dry-run` and check it plans no operations, then run it without `--dry-run` to store its snapshot, then emit the new contract and run the rename. Do not drop the statements to get past it: without them the plan drops the renamed table with its rows. |
## Decision — which path do you take?
| Situation | Path | Why |
|---|---|---|
| Moving a database whose history does not need to be kept (local, development, staging, preview) to the contract | `db update` | Fast, no migration files. Works whether or not the database is shared. |
| A change other developers or environments will receive | `migration plan` + `db migrate` | Replayable, reviewable, content-hashed. |
| A database that changes only through migrations, such as production | `migration plan` + `db migrate` | It must run a reviewed, hashed migration. |
| A deploy says there is no migration path, or `MIGRATION.MARKER_MISMATCH` | See *A database whose marker is not in the migration graph* | Never reset the database. |
| Adding a column that needs a backfill | `migration plan --backfill Model.field` (writes `placeholder`), edit `migration.ts`, self-emit, then `db migrate` | `db update` does not author data transforms; the formal path does. Without `--backfill`, existing rows get a temporary value. |
| Changing a field's type so values may not survive | `migration plan --convert Model.field`, fill `using`, self-emit, `db migrate` | Or `--delete Model.field` to let the database cast; the cast may cut or round values (Postgres cuts a longer text to a shorter `varchar`), and a value it cannot cast fails the apply. |
| Recovering from drift (DB diverged from contract) | `db sign` after manual fix, *or* `migration plan` if PN can plan the fix | Depends on which side is right. See *Recover from drift* below. |
## Dev → ship transition (the `db` ref pattern)
Example — iterate locally with `db update`, then publish the first real migration:
```bash
pnpm prisma db init --db $DATABASE_URL
pnpm prisma contract emit && pnpm prisma db update --db $DATABASE_URL
pnpm prisma contract emit && pnpm prisma migration plan --name add_feature
pnpm prisma db migrate --db $DATABASE_URL
pnpm prisma db verify --db $DATABASE_URL
```
The `db` ref is a named pointer at `migrations/app/refs/db.json` — just `{ hash, invariants }`. It records which contract hash the project's dev database has been brought up to — the offline planner's stand-in for "where is my local DB?" without opening a connection at plan time. The contract it names resolves through the shared content-addressed store at `migrations/snapshots/<hex>/contract.json` by that hash, the same store every migration graph node resolves through.
**What `db init` / `db update` / `db sign` write.** When run against the project's default `--db` URL (no explicit `--db` flag), `db init` and `db update` implicitly advance the `db` ref: they write-if-absent the post-command contract IR into the snapshot store, then write the ref's pointer. Override the ref name with `--advance-ref <name>`. When you pass `--db <non-default-url>`, ref advancement is suppressed unless `--advance-ref` is explicit — reconciling a different database is not the same as checkpointing this project's dev state. `db sign` also advances the `db` ref after a successful signature, writing the signed contract into the snapshot store first; `--advance-ref <name>` overrides the name, and `--db` does **not** suppress it — sign never mutates the schema, and adoption is normally done against the real database via `--db`. The only opt-out is `--no-advance-ref`, which signs without writing any ref or snapshot — what a CI or deployment pipeline, or a dev checkout re-signing a production database, usually wants.
The on-disk layout is just the pointer:
```text
migrations/app/refs/
└── db.json # { "hash": "<hex>", "invariants": [] }
```
**First `migration plan` after dev iteration.** `migration plan` defaults `--from` to the `db` ref (and, when no `db` ref exists at all, falls back to planning from an empty database only while the migration graph is empty — the human output then adds a muted notice beneath the summary, `No db ref set — planning from an empty database. Run db init, db update, or db sign if a database already exists.`, and the JSON document carries `fromDefaulted: true`; over a non-empty graph there is no fallback: the command refuses with `MIGRATION.PLAN_ORIGIN_UNKNOWN`; see `references/migration-model.md` § *The trap*). When the on-disk migration graph is still **empty** and the `db` ref points at a non-null hash with a store entry (typical after one or more `db update` cycles), the planner emits **two** bundles instead of one:
1. Baseline: `null → from-hash` (introduces `from-hash` as a graph node)
2. Delta: `from-hash → current_contract`
Both land on disk in one invocation — expect two new directories in `git status`. `db migrate` then finds a path through the baseline and applies the delta. This closes the dev → ship trap where a single-bundle plan referenced a hash that was not yet a graph node and produced an unapplyable migration (`MIGRATION.PATH_UNREACHABLE` at apply time).
**The forgot-the-flag pitfall.** After the graph is **non-empty**, the default `db` ref may point **past the graph tip** (the ref advanced on every `db update` while you iterated, but you never committed migrations). The next implicit-default `migration plan` refuses with `MIGRATION.HASH_NOT_IN_GRAPH` and names reachable refs that point at graph nodes.
Recovery when you see `MIGRATION.HASH_NOT_IN_GRAPH` on plan:
```bash
# Option A — plan from a graph node explicitly
pnpm prisma migration plan --from production --name my_change
# Option B — realign the db ref to a graph-node hash, then plan with the default
pnpm prisma migration ref set db <graph-node-hash>
pnpm prisma migration plan --name my_change
```
If the `db` ref's pointer is itself missing and the hash isn't a graph node either (`MIGRATION.SNAPSHOT_MISSING`), create it with `migration ref set db <hash>` or advance it with `db update --advance-ref db`.
**After plain `db migrate`.** `db migrate` does not implicitly advance the `db` ref (production-shaped commands stay explicit). The live marker advances while the ref may lag. Refresh with `db update` (no-op on DB when already current) or `db migrate --advance-ref db` in the same invocation.
**When to switch paths.** Use `db update` while the schema is in flux, on a database whose migration history does not need to be kept (a local, development, staging or preview database). Switch to `migration plan` + `db migrate` when the change needs a reviewable, replayable migration — typically before opening a PR, and always for a database that changes only through migrations, such as production. The `db` ref bridges the two: it captures dev iteration state on disk so the first formal plan knows where you left off.
**Graph-node rule (plan time).** Any hash used as a `from` end — explicit `--from`, default `db` ref, or ref name — must already be a node in the on-disk migration graph once the graph is non-empty. The auto-baseline two-bundle emission is the one exception: it applies only on an **empty** graph with a non-null ref-resolved `from` and an available store entry. If the ref's pointer is missing and the hash isn't a graph node either, plan refuses with `MIGRATION.SNAPSHOT_MISSING` instead.
**Apply-time complement.** `db migrate` reads the live marker before DDL. If the marker hash is not a graph node, the command refuses with `MIGRATION.MARKER_MISMATCH` — catching drift the offline planner cannot see. This is separate from `MIGRATION.MARKER_NOT_IN_HISTORY`, which fires later during the runner's graph walk when the marker is off the path being traversed. See `references/migration-review.md` for the full diagnostic catalog.
`db` is a **default ref name**, not a reserved one. The framework overwrites it on the next dev cycle; you may `migration ref set db <hash>` explicitly and accept that a subsequent `db update` replaces it when run against the default URL.
Canonical detail: [Migration System § Contract resolution through the snapshot store](https://github.com/prisma/orm/blob/main/docs/architecture%20docs/subsystems/7.%20Migration%20System.md#contract-resolution-through-the-snapshot-store), [§ `migration plan`](https://github.com/prisma/orm/blob/main/docs/architecture%20docs/subsystems/7.%20Migration%20System.md#migration-plan), [§ Recovery affordances](https://github.com/prisma/orm/blob/main/docs/architecture%20docs/subsystems/7.%20Migration%20System.md#recovery-affordances), [ADR 218 — Refs with paired contract snapshots and universal graph-node invariant](https://github.com/prisma/orm/blob/main/docs/architecture%20docs/adrs/ADR%20218%20-%20Refs%20with%20paired%20contract%20snapshots%20and%20universal%20graph-node%20invariant.md) (TML-2629, its paired-snapshot part superseded — see the ADR's Status note), and [ADR 240 — Contract snapshots live in a content-addressed store](https://github.com/prisma/orm/blob/main/docs/architecture%20docs/adrs/ADR%20240%20-%20Contract%20snapshots%20live%20in%20a%20content-addressed%20store.md).
## Workflow — `db update` (quick path)
The concept: `db update` resolves the destination (`emitted contract`) against the live DB and applies the difference. Preview with `--dry-run`, which asks nothing and lists the questions an apply would ask under `dataLoss` and `accessChanges`. An apply that would lose data or change access asks first (see *Answer the data-loss questions*). The path excludes operations of the `data` class entirely — if the diff requires a data transform, `db update` fails with a planning error and you switch to `migration plan` to author the transform.
Run after a contract edit:
```bash
pnpm prisma contract emit
# Postgres: --db postgresql://...
# Mongo: --db mongodb://... (dev scaffolds often need ?replicaSet=rs0)
pnpm prisma db update --db $DATABASE_URL --dry-run
pnpm prisma db update --db $DATABASE_URL
```
`db update` already verifies schema and advances the marker on success — a follow-up `db verify` is redundant on the happy path. Use `db verify` only when you need a standalone diagnostic (see *Verify contract vs DB*).
Inspect the JSON output to drive the next move:
```bash
pnpm prisma db update --db $DATABASE_URL --json
```
The JSON contains `plan.operations[]` with each `operationClass`, plus (in apply mode) `execution.operationsExecuted` and the post-apply `marker.storageHash`. If the command was refused because nobody answered its questions, the error is `CLI.CONSENT_REQUIRED`, and its `meta.unanswered[]` lists each subject with the flags that answer it.
## Workflow — `migration plan` + `db migrate` (formal path)
The concept: `migration plan` writes a new migration package on disk. If the planner needed any data transforms, the package is *pending* — `migration.ts` holds `placeholder(...)` calls until you fill them in. `db migrate` runs every pending package in graph order — on Postgres inside one transaction for the whole run; on Mongo op by op with verify-gated marker advancement (see *Apply atomicity* above).
Plan a change:
```bash
pnpm prisma contract emit
pnpm prisma migration plan --name <snake_slug>
```
Read the result. The JSON shape exposes the queryable signals:
- `dir` — the path of the new package (e.g. `migrations/app/20260515T1200_add_user_email/`).
- `pendingPlaceholders` — `true` if `migration.ts` still contains `placeholder(...)` calls.
- `operations[].operationClass` — for spotting `destructive` and `data` ops.
- `preview.statements` — family-agnostic textual preview.
Inspect the package (the `<target>` positional is required — a directory name, hash or hash prefix, ref, or path):
```bash
pnpm prisma migration show <dirName-or-migrationHash-prefix>
pnpm prisma migration show migrations/app/20260515T1200_add_user_email
```
`migration show` displays a single migration package. To see the ordered list of migrations that would run — across all contract spaces — use `db migrate --show`:
```bash
# Online: reads the live DB marker as the origin.
pnpm prisma db migrate --show --db $DATABASE_URL
# Offline: hypothetical path from any ref or hash.
pnpm prisma db migrate --show --from <hash-or-ref> --to <hash-or-ref>
```
`db migrate --show` is read-only and never writes to the DB or the migration graph. Use it before applying to confirm the execution order.
Fill in any data transforms (see *Fill a placeholder*), self-emit if you edited `migration.ts`, then:
```bash
pnpm prisma db migrate --db $DATABASE_URL
```
`db migrate` runs without prompting: the data-loss questions were answered when `migration plan` wrote the package. Review destructive ops in the plan output or in `migration show` *before* applying.
## Workflow — Fill a placeholder
The concept: the planner can detect *that* a data transform is needed but not *what* it should do. It writes a typed scaffold and stops; you fill the transform, then self-emit.
### Postgres
`--backfill` asks the planner for a data transform it cannot write itself, for example to fill a new required column `name` on a table with rows: `pnpm prisma migration plan --name add_user_name --backfill User.name`. Without the flag the rows get a temporary value instead. Each slot is named by the field and the part to fill, `User.name:check` and `User.name:run`. You fill `check` and `run` closures with real query plans built against `endContract`.
The scaffold the planner emits looks like:
```typescript
// migrations/app/20260515T1200_add_user_name/migration.ts
import { col, Migration, MigrationCLI, placeholder } from '@internal/postgres/migration';
import type { Contract as End } from '../../snapshots/93f07d1b…c9e1e5a2/contract';
import endContract from '../../snapshots/93f07d1b…c9e1e5a2/contract.json' with { type: 'json' };
import type { Contract as Start } from '../../snapshots/f62a4154…d07dddc/contract';
import startContract from '../../snapshots/f62a4154…d07dddc/contract.json' with { type: 'json' };
export default class M extends Migration<Start, End> {
override readonly startContractJson = startContract;
override readonly endContractJson = endContract;
override get operations() {
return [
this.addColumn({
schema: 'public',
table: 'user',
column: col('name', 'text', { codecRef: { codecId: 'pg/text@1' } }),
}),
this.dataTransform(endContract, 'backfill-user-name', {
backfillsColumn: { schema: 'public', table: 'user', column: 'name' },
check: () => placeholder('User.name:check'),
run: () => placeholder('User.name:run'),
}),
this.setNotNull({ schema: 'public', table: 'user', column: 'name' }),
];
}
}
MigrationCLI.run(import.meta.url, M);
```
(`examples/prisma-8-demo/migrations/app/20260810T1108_add_post_engagement_counters/migration.ts` is a committed rendered package to compare against.)
Replace both `placeholder(...)` calls with query-plan closures built from `endContract`. The `check` closure must return a **rowset query whose presence of any row signals "work remains"** — conventionally `<table>.select('id').where(<violation predicate>).limit(1)`. Scalar/aggregate shapes (`count(*)`, `bool_and(...)`) silently break the contract: the runner wraps `check` twice (`EXISTS(...)` for precheck, `NOT EXISTS(...)` for postcheck), and a query that always returns one row makes `EXISTS` always true and `NOT EXISTS` always false.
Build the query builder against `endContract` so the storage hashes line up — using a different contract reference raises `MIGRATION.DATA_TRANSFORM_CONTRACT_MISMATCH`. The cheapest way to get a typed SQL builder over the end contract is the façade itself: `postgres({ contract: contractFromJson<End>(endContract) })` connects lazily, so constructing it inside `migration.ts` opens no connection; its `sql` is the builder and its `contract` is the validated contract to hand to `this.dataTransform`. The filled-in shape is the rendered scaffold above with only the two `placeholder(...)` arrows replaced (the operation list, including the `this.setNotNull({...})` the planner rendered after the transform, stays as rendered):
```typescript
import { col, Migration, MigrationCLI } from '@internal/postgres/migration';
import postgres from '@internal/postgres/runtime';
import { contractFromJson } from '@internal/postgres/static';
import type { Contract as End } from '../../snapshots/93f07d1b…c9e1e5a2/contract';
import endContract from '../../snapshots/93f07d1b…c9e1e5a2/contract.json' with { type: 'json' };
import type { Contract as Start } from '../../snapshots/f62a4154…d07dddc/contract';
import startContract from '../../snapshots/f62a4154…d07dddc/contract.json' with { type: 'json' };
const { sql: db, contract } = postgres({ contract: contractFromJson<End>(endContract) });
export default class M extends Migration<Start, End> {
override readonly startContractJson = startContract;
override readonly endContractJson = endContract;
override get operations() {
return [
this.addColumn({
schema: 'public',
table: 'user',
column: col('name', 'text', { codecRef: { codecId: 'pg/text@1' } }),
}),
this.dataTransform(contract, 'backfill-user-name', {
backfillsColumn: { schema: 'public', table: 'user', column: 'name' },
check: () => db.public.user.select('id').where((f, fns) => fns.eq(f.name, null)).limit(1),
run: () => db.public.user.update({ name: '' }).where((f, fns) => fns.eq(f.name, null)),
}),
this.setNotNull({ schema: 'public', table: 'user', column: 'name' }),
];
}
}
MigrationCLI.run(import.meta.url, M);
```
`backfillsColumn` names the column the transform fills. Keep it: when `check` still finds rows after `run`, the apply fails, changes nothing, and counts the rows that still hold NULL, as on SQLite: `1 row of "user" still holds NULL in "name" after its backfill step, so it cannot become required.` The advice then says to make `run` fill every row in that `migration.ts` and re-run `db migrate`. Without `backfillsColumn` the same failure reports only that the postcheck failed.
`run` takes one closure, or an array of closures, each returning one query: `run: [() => db.public.user.update(…), () => db.public.post.update(…)]`. It does not take one closure that returns an array.
Self-emit:
```bash
node migrations/app/20260515T1200_add_user_name/migration.ts
```
### SQLite
On SQLite, `--backfill User.name` writes a `dataTransform` with one slot, `User.name:run`, and no `check`; the table rebuild after it makes the column required, and fails, changing nothing, if a row still has no value. `run` is a closure that returns the SQL text of one statement:
```typescript
dataTransform({
id: 'data_migration.backfill-User-name',
label: 'Backfill NULLs in "User"."name" before NOT NULL tightening',
table: 'User',
description: 'Backfill NULL name values in User',
run: () => `UPDATE "User" SET name = 'user ' || id WHERE name IS NULL`,
}),
```
Write the statement so it fills every row the field has no value in, then self-emit with `node <package dir>/migration.ts` as on Postgres.
Self-emit regenerates `ops.json` and recomputes `migrationHash` in `migration.json`. The next `db migrate` will see a consistent package.
### Mongo
Mongo `dataTransform` operations are free factories taking `{ check, run }` objects whose `source` / `run` return Mongo query-plan shapes (often `RawAggregateCommand` / `RawUpdateManyCommand` from `@prisma/orm-mongo/query-ast/execution`). The planner may leave `placeholder(...)` inside those sources until you fill them. A rendered package binds its bookends through `startContractJson` / `endContractJson` exactly as on Postgres; a hand-authored `migration new` package may instead override `describe()` with the `from` / `to` hashes from its `migration.json`, as below. Everything comes from one import:
```typescript
import { createIndex, dataTransform, Migration, MigrationCLI } from '@prisma/orm-mongo/target/migration';
import { RawAggregateCommand, RawUpdateManyCommand } from '@prisma/orm-mongo/query-ast/execution';
class M extends Migration {
override describe() {
return { from: '<hex>', to: '<hex>' };
}
override get operations() {
return [
createIndex('users', [{ field: 'name', direction: 1 }]),
dataTransform('lowercase-user-name', {
check: {
source: () => ({
collection: 'users',
command: new RawAggregateCommand('users', [
{ $match: { name: { $regex: '[A-Z]' } } },
{ $limit: 1 },
]),
meta: { target: 'mongo', storageHash: '…', lane: 'mongo-pipeline', paramDescriptors: [] },
}),
},
run: () => ({
collection: 'users',
command: new RawUpdateManyCommand(
'users',
{ name: { $exists: true } },
[{ $set: { name: { $toLower: '$name' } } }],
),
meta: { target: 'mongo', storageHash: '…', lane: 'mongo-raw', paramDescriptors: [] },
}),
}),
];
}
}
export default M;
MigrationCLI.run(import.meta.url, M);
```
Self-emit the same way: `node <package dir>/migration.ts`.
## Type changes and new required fields
Postgres and SQLite. `migration plan` cannot see rows, so these rules are about what you state, not what the table holds.
- **A type change that can lose values is a question.** Answer it with `--convert User.age` (the plan has a slot for the conversion: `using: placeholder('User.age:convert')` in `this.alterColumnType(...)` on Postgres, the column's copy expression in `this.recreateTable(...)` on SQLite; write SQL over the column, such as `'"age"::integer'`, self-emit, `db migrate`) or `--delete User.age` (the database's own cast runs; it is listed as `accept lossy type change of field "User.age"`). The cast may cut or round values: on Postgres a longer text is cut to a shorter `VarChar` and a number rounded to fewer digits. A value the cast cannot convert fails the apply, changes nothing, and the error names `--convert`. On SQLite the rebuild checks after its copy that every value took the new type: each column listed in `retypedColumns` is checked, as the conversion when it has a copy expression in `copyExpressions` and as SQLite's own cast when it has none, and a column with a copy expression that is not listed is checked as a conversion too. A hand-written rebuild that converts needs only its copy expression. A widening such as `int4` to `int8` asks nothing.
- **A new required field** gets a temporary value in the existing rows (`''`, `0`, `false`, …) with no question and no placeholder; the output names the field and the value. On SQLite the rebuild writes it in `temporaryValues: { name: sql`''` }`, apart from the conversions in `copyExpressions`. `--backfill User.name` writes a data transform to fill instead: add nullable, fill (`check` and `run` slots on Postgres, `run` on SQLite), then make it required. A new unique, primary-key or foreign-key field, or one whose values come from a value set, gets no temporary value: on a table with rows, use `--backfill`.
- **An optional field made required** is tightened directly. If rows hold NULL, the apply fails and counts them (`3 rows of "Item" hold NULL in "note", so it cannot become required.`); set the values and re-run, or plan with `--backfill`.
- **Following failure advice under `db migrate`:** the migration is already written, so the error says to copy any code you wrote in its `migration.ts`, delete its directory, and run the printed `migration plan --from <hash> --name <name> …` command, which keeps the migration's name, together with the other statements you planned it with. Paths in the advice are relative to the directory the command ran from, and the commands it prints repeat the `--config` it was given, so each runs as printed from there.
## Workflow — Author a migration by hand
The concept: the same `Migration` class shape lets you author operations directly when the planner has nothing to plan (a custom data fix, an extension install, a baseline). Even here you don't write the file from scratch — `migration new` renders an empty package for you, and you edit the `operations` getter inside it, then self-emit.
```bash
pnpm prisma migration new --name <snake_slug>
```
On Postgres the operations are **methods on the `Migration` base class**, each taking one options object (`this.addColumn({ schema, table, column })`); only helpers such as `col(...)` and `rawSql(...)` are imported, on the rendered `@internal/postgres/migration` line. On Mongo the operations are **free factories** imported from `@prisma/orm-mongo/target/migration`. The authoritative list for either target is the base class / module's declaration file in your `node_modules`.
**Postgres** operations (representative set, all `this.<name>({...})`):
- Tables: `createTable`, `renameTable`, `dropTable`. `renameTable` reads the migration's start and end contracts and returns the table rename plus a rename of every constraint and index named after the old table, so spread it: `...this.renameTable({ table: 'userProfile', to: 'UserProfile' })`.
- Columns: `addColumn` (`column: col(name, nativeType, { codecRef })`), `renameColumn` (also renames the constraints and indexes named after the column, so spread it: `...this.renameColumn({ table: 'User', column: 'email', to: 'emailAddress' })`), `dropColumn`, `alterColumnType({ schema, table, column, type, using? })` (`type` is the new type as a migration writes it, such as `int8` or `varchar(20)`; `using` is a SQL expression over the column that converts each value, and without it the database casts the column; the class is `'data'` with `using` and `'destructive'` without it; pass `operationClass: 'widening'` only when every value of the old type converts to the new type unchanged, such as `int4` to `int8`; on a column that owns a sequence it also changes the sequence to the new width, and refuses to narrow past the sequence's last value; a column whose `nextval` default uses a sequence it does not own gets only the type change; with `using`, it refuses with `MIGRATION.ALTER_COLUMN_TYPE_USING_WITHOUT_TYPE_CHANGE` when the start contract already gives the column that type, since the runner would skip the conversion: write a `this.dataTransform` instead), `setNotNull`, `dropNotNull`, `setDefault` (`column: col(name, nativeType, { default: lit(value) or fn(…), codecRef })`, where `fn` takes a SQL expression; `fn`, `checkExpression` and the methods that take a SQL fragment accept a ``sql`...` `` value, which generated files write, or a string; a `sql` value's text is canonicalized like a contract's, a string is kept exactly as written, so do not wrap a committed string in `sql`; `fn('autoincrement()')` on a smallint, integer or bigint column reuses any sequence the column already owns (found with `pg_get_serial_sequence`), creates `{table}_{column}_seq` only when it owns none, attaches it, and starts it past the column's largest value; `setDefault` takes no `operationClass`: the class comes from the migration's start contract after earlier renames, `widening` when the column had a default and `additive` otherwise; a literal or function default has no postcheck, so it always runs and the final schema check compares the value), `dropDefault`.
- Constraints: `addPrimaryKey`, `addForeignKey`, `addUnique`, `addCheckConstraint`, `renameConstraint` (`kind`: `primaryKey`, `unique`, `foreignKey` or `checkConstraint`), `renameCheckConstraint`, `dropCheckConstraint`, `dropConstraint`.
- Indexes: `createIndex`, `renameIndex`, `dropIndex`.
- Enums: `createNativeEnumType`, `addNativeEnumValue`, `dropNativeEnumType`.
- Row-level security: `enableRowLevelSecurity`, `disableRowLevelSecurity`, `createRlsPolicy`, `renameRlsPolicy`, `dropRlsPolicy`.
- Dependencies: `createSchema`, `installExtension`.
- Free helpers on the import line: `col`, `primaryKey`, `unique`, `foreignKey`, `checkExpression`, `lit`, `fn` (column and constraint descriptors), `createExtension`, and the raw escape hatch `rawSql({ id, label, operationClass, target, precheck, execute, postcheck, ... })`. The runner runs an operation's postchecks first and skips the operation when they all hold, so a `rawSql` postcheck must hold only when the operation's full effect is in place. If you cannot check that, leave `postcheck` empty: the operation then always runs.
- Data transforms: `this.dataTransform(endContract, name, { check, run })`.
**Mongo** factories (from `@prisma/orm-mongo/target/migration`):
- Collections: `createCollection`, `dropCollection`, `renameCollection(from, to)` (keeps the documents, indexes and validator), `validatedCollection`, `setValidation`.
- Indexes: `createIndex`, `dropIndex`.
- Document rewrites: `renameField(collection, from, to, { filter? })` and `unsetField(collection, field, { filter? })`, each one `updateMany` that skips validation; `filter` limits them to one variant's documents, such as `{ filter: { type: 'bug' } }`.
- Collection options: `collMod`.
- Data transforms: `dataTransform(name, { check, run })` (free factory; `check`/`run` use Mongo query-plan shapes).
Self-emit (`node <package dir>/migration.ts`) after each edit.
## Workflow — Inspect the live schema
The concept: `db schema` is read-only and never writes files. It prints the live schema as a tree by default or as JSON with `--json`. Use it during planning and as part of verification.
```bash
pnpm prisma db schema --db $DATABASE_URL
pnpm prisma db schema --db $DATABASE_URL --json > schema.json
```
There is no built-in filter flag — pipe the JSON through `jq` (or your favourite JSON tool) if you only want one table.
## Workflow — Verify contract vs DB (diagnostic)
The concept: `db verify` is a **standalone diagnostic** — not a routine step after `db update` or `db migrate` on the happy path (those commands already verify and advance the marker when they succeed). Reach for `db verify` when you suspect drift or need to prove the DB matches the contract:
- Following manual SQL or ad-hoc edits outside Prisma 8.
- When restoring a database from backup.
- If a `db migrate` fails or partially applies (especially on Mongo, where DDL is resumable rather than transaction-wrapped).
- When `CONTRACT.MARKER_MISMATCH` / `CONTRACT.MARKER_MISSING` surfaces at runtime or from another command.
Modes:
- Default — full verification (schema + marker).
- `--marker-only` — skip schema verification, only check the marker.
- `--schema-only` — skip marker verification, only check schema satisfies contract.
- `--strict` adds: schema elements not present in the contract are an error (default is "DB may have extras").
```bash
pnpm prisma db verify --db $DATABASE_URL
```
`db verify` exits `0` when everything matches, `4` when it ran and found something, and `2` only when it could not run. Findings ride the completed envelope as `error` diagnostics: `CONTRACT.MARKER_MISMATCH`, `CONTRACT.MARKER_MISSING`, `CONTRACT.TARGET_MISMATCH`, `CONTRACT.SCHEMA_VERIFICATION_FAILED` (with `meta.schemaIssues` listing the schema diff issues).
## Workflow — Re-sign the marker
The concept: `db sign` rewrites the marker to the current contract hash and moves the `db` ref to it (`--advance-ref <name>` overrides the ref name; `--db` does not suppress the ref write; `--no-advance-ref` skips it). Use after a manual repair where the DB is the source of truth and the marker is stale. `db sign` performs a schema-verify first and refuses to sign a DB whose schema disagrees with the contract — so a successful sign always means the schema matches and the marker is now correct.
```bash
pnpm prisma db sign --db $DATABASE_URL
```
## Workflow — A database whose marker is not in the migration graph
**The symptom.** `db migrate` or a deploy fails with `MIGRATION.MARKER_MISMATCH`: the database's marker names a contract that no migration in `migrations/` starts or ends at. (Composer releases older than this skill print it as `MIGRATION_PATH_NOT_FOUND`, "no authored path from the database's current schema".)
**The usual cause.** The database applied a migration that was later planned again or deleted. The common case is a branch deployed to a shared environment, then rebased: each new plan of its migration has a new target hash, and the version the database applied is gone. The error's `why` names the last contract in the graph the database applied (`meta.lastGraphHash`) and the applied migrations that are no longer on disk (`meta.missingMigrations`).
**Never reset or drop the database.** It can always be moved. Pick the recovery by what kind of database it is.
When the error names an extension's contract space (`meta.spaceId` is not `app`), that space's migrations come with the extension package: install a version of the extension whose migrations include the database's contract (`prisma migration list --space <space>` lists them), or ask its maintainers. The recoveries below are for the application space.
### When the database's history does not need to be kept (local, development, staging, preview)
Reconcile it to the target with `db update`:
1. `prisma db update --db $DATABASE_URL --to <target> --dry-run`, and read every operation, above all every drop.
2. `db update` does not run data transforms or seed steps. When the error has no `meta.lastGraphHash`, the ledger cannot say which migrations it would skip: stop and ask a person. Otherwise read the data transforms of the migrations on disk between `meta.lastGraphHash` and the target (`prisma migration list`, then each one's `migration.ts`). The migrations in `meta.missingMigrations` already ran on this database, so never repeat them; read them, in ledger order (`prisma migration log`) from the git commits that held them, only to learn what the database already holds, since a migration on disk may be a new plan of one of them. If a missing migration's source cannot be recovered, stop and ask a person. For each on-disk transform whose data matters and whose work the database does not already hold:
- if it moves data into a new column or table, create that column or table by hand and copy the data with SQL, the way the transform would;
- if it inserts rows the application needs, insert them by hand.
Run the dry run again to confirm the drops that remain lose nothing you need.
3. `prisma db update --db $DATABASE_URL --to <target>`, answering the data-loss questions with `--delete` or `--rename` (see *Answer the data-loss questions*).
4. Deploy again. The deploy finds the database at its target.
### When the database changes only through migrations (production)
Author migrations that bring it back onto the graph, and deploy them. The marker's contract is `<marker hash>`; the last graph contract is `meta.lastGraphHash`. When the error has no `lastGraphHash`, the ledger shows no contract the database applied that is still in the graph, or could not be read: check with `prisma migration log`, and if no applied contract is in the graph, stop and ask a person.
`migration plan --to` accepts only contracts already in the graph, so the database's contract goes through the contract source:
1. Commit or copy aside any uncommitted edits to the contract source; step 4 restores it from `HEAD`. Then put the contract the database is at into the contract source. Prefer the contract source as it was when the deleted migration was planned: find the commit with `git log --all --oneline -- migrations/snapshots/<marker hash>` and check out the contract source file from it. If git no longer has it, infer it from the database into a separate file, `prisma contract infer --db $DATABASE_URL --output ./inferred.prisma` (without `--output` it overwrites the contract source), then copy that file over the contract source.
2. `prisma contract emit`, and check that the emitted storage hash is `<marker hash>`. If it is not, stop and ask a person.
3. Plan a migration from the last graph contract to the database's contract: `prisma migration plan --from <lastGraphHash> --name rejoin_<database>`. Nothing will run it against this database; it only puts the database's contract in the graph. A fresh database could still run it, so copy the data transforms of every migration in `meta.missingMigrations` into it, in ledger order (`prisma migration log`), from the git commits that held them, and review them.
4. Restore the contract source (`git checkout HEAD -- <contract source>`; without `HEAD` git restores the version step 1 staged), run `prisma contract emit`, then plan from the database's contract to the target: `prisma migration plan --from <marker hash> --name <slug>`. A plan that loses data needs `--delete` or `--rename` (see *Answer the data-loss questions*). Review it like any migration, and add data transforms for any data the deleted migration's replacement would have moved.
5. Check how a fresh database reaches the target: `prisma migration status --from @empty --to <target>` lists the migrations it would run. Path finding takes the shortest route, so if these two migrations make a shorter route from the last graph contract to the target than the main line, a new database would take it. When they do, ask a person how to proceed.
6. Commit both migrations and deploy.
Do not `db sign` the database to a contract you inferred: that rewrites its marker without a migration.
## Workflow — Recover from drift
The concept: drift means `db verify` reports the live DB schema doesn't match what the marker says it should be. Two valid moves, picked by which side is correct:
- **The contract is right; the DB is wrong** → run a migration. Either `db update`, on a database whose migration history does not need to be kept (a local, development, staging or preview database), or `migration plan` + `db migrate`, which every database accepts and a database that changes only through migrations, such as production requires.
- **The DB is right; the contract or marker is wrong** → edit the contract to match the DB (see `references/contract.md`), emit, then `db sign` to refresh the marker. The sign also moves the `db` ref to the signed hash; when the migration graph is non-empty and that hash is not a graph node, the next default `migration plan` refuses with `MIGRATION.HASH_NOT_IN_GRAPH` (see *The forgot-the-flag pitfall* above for the recovery).
The diagnostic that reveals which side is right:
```bash
pnpm prisma db schema --db $DATABASE_URL --json
pnpm prisma db verify --db $DATABASE_URL --json
```
Use `db verify` to confirm which side is wrong, then re-run it after either branch until it returns `ok` with no diagnostics.
## Workflow — Recover from a partially-applied migration
The concept: on **Postgres**, the whole `db migrate` run is one transaction — a failure anywhere rolls back every migration the run had applied, and the marker stays where it was before the command. On **Mongo**, DDL is resumable with verify-gated marker advancement; diagnose with `db verify` / `db schema`, fix the failed package's `migration.ts`, self-emit, and re-run `db migrate`.
Failures that *can* leak partial state: Mongo DDL that partially applied before verify failed, and external side-effects (calls out to other systems from a `run` closure). On Postgres nothing runs outside the transaction — `rawSql(...)` steps are ordinary steps inside it and roll back with the rest.
Diagnose:
```bash
pnpm prisma db verify --db $DATABASE_URL --json
pnpm prisma db schema --db $DATABASE_URL --json
```
Fix and re-run `db migrate`:
```bash
node <package dir>/migration.ts
pnpm prisma db migrate --db $DATABASE_URL
```
If the failure was an out-of-band side-effect that left external systems half-changed, repair those by hand before re-applying.
## Workflow — Recover from `MIGRATION.HASH_MISMATCH`
The concept: `migrationHash` is content-addressed. A mismatch means `migration.json`'s stored hash disagrees with the hash recomputed from `ops.json` (and metadata). The cause is almost always: someone edited `migration.ts` and forgot to self-emit. The remediation is to self-emit the offending package.
```bash
node <package dir>/migration.ts
pnpm prisma db migrate --db $DATABASE_URL
```
If self-emit itself fails (e.g. the contract has moved on and the operations no longer make sense against the migration's end contract), the package is stale. Either restore it from version control or delete it and re-plan with `migration plan`.
## Workflow — Answer the data-loss questions (`migration plan` and `db update`)
The concept: the planner cannot tell whether a dropped model or field was meant to go, or was renamed. So before `migration plan` writes a package, or `db update` applies, the command asks one question per model, field or storage name an operation would lose data from. `db update` also asks one per model whose rows an operation would open up, such as dropping a row-level-security policy. Each question is answered by a statement:
- `--delete <subject>` lets the data go: `--delete Legacy`, `--delete User.nickname`. A subject no model stores is named by its storage name (`public.audit_log` on Postgres, `audit_log` on SQLite, `profiles.nickname` for a MongoDB field when `db update` has no snapshot of the origin contract) and can only be deleted. On MongoDB, removing a field from a model is asked about, and `--delete` removes it from every document that holds it.
- `--rename <subject>:<new name>` keeps it under a new name: `--rename Profile:User`, or for a field `--rename User.nickname:User.handle` (the new name keeps its model). A field of a renamed model is named through the new name (`--delete User.nickname` after `--rename Profile:User`).
- On Postgres, namespaces are subjects too. `--rename public.Invoice:billing.Invoice` moves a model to another namespace, creating the schema if needed. `--rename billing:finance` renames a namespace; namespace renames run first, whatever order the flags are in. A namespace removed from the contract is asked about one model at a time, and `--delete billing` answers all of them; a table in it that no contract declares is its own question, `--delete billing.audit_log`. `public` and a schema another contract space declares cannot be renamed. When a name is both a namespace and a model, name the model with its namespace (`app.billing:app.finance`).
- On Postgres and SQLite, enum values are subjects too. A member removed from an enum, or one that stores a new value, is asked about as `Status.ARCHIVED`. `--rename Status.ARCHIVED:Status.RETIRED` replaces the stored value in every row and list that holds it; `--delete Status.ARCHIVED` sets it to NULL in optional columns and takes it out of lists. While rows of a required column hold it, the apply fails with `MIGRATION.PRECHECK_FAILED` and changes nothing; follow its next actions (rename to a new member, or keep the rows by hand). A member renamed with its stored value kept changes nothing. A member that keeps its name but stores a new value, including two members that swap values, is asked about with `--delete` only; the question gives the `migration.ts` edits that keep the data, and under `db update` the way to plan them with `migration plan --from <hash>` or to rename the member to a new member and back in two runs. Renames that shift values along (3 to 4 while 4 to 5) work in one run; renames that move values in a cycle are refused and say how to write them by hand.
- On Postgres and SQLite, fields of value objects are subjects too, named through their own value object (`GeoPoint.lat`, never a path from a model). Each statement acts on every column that holds the value object, in a list or nested in another value object:
- `--rename GeoPoint.lat:GeoPoint.latitude` renames the key in every object (`this.renameStoredKey(...)`).
- `--delete GeoPoint.lng` removes the key from every object (`this.removeStoredKey(...)`).
- An enum value inside a value object is asked about with the enum value: `--rename Status.ARCHIVED:Status.RETIRED` rewrites it at every path, and `--delete Status.DRAFT` sets an optional member to JSON `null` and takes the value out of a list.
- `--backfill GeoPoint.country` (`migration plan` only) fills a field that becomes required: the plan writes `this.fillStoredKey({ ..., value: placeholder('GeoPoint.country:value'), field: 'GeoPoint.country' })`; write one JSON value there, self-emit, `db migrate` (which refuses while a placeholder is left). Self-emit fails with `CONTRACT.ARGUMENT_INVALID` when the field does not take the value: a wrong scalar type, an enum value outside its enum, a list element of another type, or an object without a required field. Without it `migration plan` refuses, naming `--backfill`; when the value object also lost a field, the refusal offers `--rename` of the lost field first, since the new field may be it renamed, and says `--delete` of the lost field still needs `--backfill`. `db update` asks about the lost field (`--rename` or `--delete`). `db update` fills a temporary value (`""`, `0`, `false`, `[]`) when the field's type takes it, and refuses a field such as a `DateTime`, which `""` is not.
- On Postgres and SQLite, a variant whose discriminator value changes, or that the contract removes, is asked about as the model:
- `--convert Bug` rewrites the old value to the new one in the base table's discriminator column (`this.replaceStoredValue(...)`). It works on `db update` too, and the question offers it first.
- `--rename Bug:Defect` of a variant whose value also changed rewrites the value the same way.
- `--delete Bug` deletes the rows that hold the old value (`this.deleteRows(...)`), then drops a removed variant's own table or the columns only it used. On SQLite a deleted row that another table references fails the apply at `PRAGMA foreign_key_check`, except the rows of the variant's own table, which the plan deletes first (`cascade` on `deleteRows`).
- `--allow <Model>` (`db update` only) lets an operation that changes who can read or write the model's data run: one flag per operation, so on Postgres a policy drop and disabling row-level security on one model take `--allow User --allow User`.
```bash
pnpm prisma migration plan --name drop_legacy --delete Legacy
pnpm prisma db update --db $DATABASE_URL --rename Profile:User --delete Legacy --allow User
pnpm prisma migration plan --name tidy_points --rename GeoPoint.lat:GeoPoint.latitude --delete GeoPoint.lng
pnpm prisma db update --db $DATABASE_URL --convert Bug
```
In a terminal the command asks each question in turn; type `delete`, `allow`, or `rename <subject>:<new name>`. Where nobody can answer (CI, `--no-interactive`, `--yes`), it fails with `CLI.CONSENT_REQUIRED`; read `meta.unanswered[]` or the `nextActions`, decide what each subject means, and re-run with those flags. `--confirm` and `--yes` answer none of these questions. Don't reach for `--delete` by reflex: if the model or field was renamed, `--rename` keeps its rows.
`db migrate` does *not* ask: whatever the package contains runs, so review the plan or call `migration show` before `db migrate`.
## Common Pitfalls
1. **Using `db update` on a database that changes only through migrations, such as production.** The change leaves no migration history. Use `migration plan` + `db migrate`. A shared development or staging database is fine.
2. **Skipping a data transform.** Leaving `placeholder(...)` in `migration.ts` makes the next `db migrate` throw `MIGRATION.UNFILLED_PLACEHOLDER`. Fill every placeholder slot and self-emit.
3. **Editing `ops.json` directly.** It's the canonical artifact, not the authoring source. Edit `migration.ts`, then self-emit.
4. **Forgetting to self-emit after editing `migration.ts`.** The next `db migrate` either uses the stale `ops.json` (if you only added comments) or fails with `MIGRATION.HASH_MISMATCH` (if you changed operations). Always self-emit.
5. **Routine `db verify` after a successful `db update` or `db migrate`.** Redundant on the happy path — reserve `db verify` for drift diagnosis (manual edits, restore, failed `db migrate`).
6. **Aggregate `check` closure in Postgres `this.dataTransform`.** Returning `count(*)` or `bool_and(...)` breaks the precheck/postcheck contract — both sides resolve to constants. Use a rowset shape: `select('id').where(<violation>).limit(1)`.
7. **Two contract references in one migration.** Building a query plan against a different contract than the one passed to `this.dataTransform(endContract, ...)` raises `MIGRATION.DATA_TRANSFORM_CONTRACT_MISMATCH`. Always import `endContract` once at module scope and use the same reference.
11. **Calling Postgres operations as free functions.** `addColumn('public', 'user', {...})` does not exist as an import; the operations are `this.addColumn({ schema, table, column })` and friends on the `Migration` base class, with `col(...)` building the column. Only `col`, `rawSql`, `placeholder`, `Migration`, and `MigrationCLI` are imported.
8. **Renaming a model or field without a statement.** The planner cannot tell a rename from a drop and a create, so without a statement it plans a destructive drop+add. State the rename on the command line: `migration plan --name <slug> --rename User:Person --rename Person.email:Person.emailAddress`, or the same `--rename` flags on `db update`. A field is named through its model as the new contract names it. The planner then renames the table or column and the constraints and indexes named after it; on MongoDB it renames the collection, and the field in every document that holds it (only in that variant's documents for a variant's field). On MongoDB a field is named by its name in the contract, as on SQL: `id @map("_id")` is `User.id`. A statement that keeps the stored name, such as `--rename User.name:User.title` where both are stored as `display_name`, plans nothing; one that changes it renames the field in the documents. A field whose `@map` changes while its name stays is planned as a removal of its old stored name, as on SQL, so the plan asks about it; no statement states a stored-name-only change, so to keep the values, edit the planned `migration.ts` to call `renameField(...)` instead, or rename the field in the database yourself before `db update`. For a rename statements do not cover, such as a model that keeps its name but changes its `@@map`, hand-edit `migration.ts` with `...this.renameTable(...)` or `...this.renameColumn(...)` (Postgres, SQLite) or `renameCollection(...)` (MongoDB), then self-emit.
9. **Planning with no `db` ref and no `--from` in a project that already has migrations.** The origin falls through to the empty database, which would make the plan a full-create migration; `migration plan` refuses with `MIGRATION.PLAN_ORIGIN_UNKNOWN` rather than writing it. Pick the exit that matches your intent — the error lists them, and `references/migration-model.md` § *The trap* explains which to choose.
10. **Hand-authoring `migration.ts` from a blank file, or rewriting the rendered import line.** Migration files are framework-rendered — let `prisma migration plan` (or `migration new`) render the package, then edit only the holes the framework leaves for you. On Postgres leave the rendered `@internal/postgres/migration` (or `@internal/sqlite/migration`) import path alone; on Mongo leave `@prisma/orm-mongo/target/migration` as rendered. Add symbols to the existing import line rather than introducing new import paths.
12. **Replaying migrations onto a database that is already ahead.** `db migrate` on a database with no marker runs every migration, skipping operations already applied. When the database is already past a migration (it has `x int8` where the migration adds `x int4`), the run stops at that migration's precheck with `MIGRATION.PRECHECK_FAILED`. Adopt such a database with `prisma db sign` (after `contract infer` if needed), not by replaying history.
13. **Running Prisma 7 `migrate` commands against Prisma 8.** Each one answers `CLI.COMMAND_MOVED` with its Prisma 8 command: `migrate dev` is `contract emit`, `migration plan --name <name>`, then `db migrate --advance-ref db`; `migrate deploy` is `db migrate`; `migrate reset` has no equivalent, because Prisma 8 has no reset: `db update` brings the database to the contract instead, asking before it loses data, but it keeps existing rows and runs no seed steps or data transforms (a database that changes only through migrations is moved with `db migrate`); `migrate status` is `migration status`; `migrate diff` is `db update --dry-run`, or `migration plan` to write a migration package; `migrate resolve` is `db sign <migration>`, which moves the database marker to that migration after checking its schema, but leaves `migration status` without an applied mark. While Prisma 7 still owns the migrations, run every one of them except `migrate diff`, whose replacement only reads, with Prisma 7's CLI instead.
## What Prisma 8 doesn't do yet
- **Runtime-apply migrations.** Prisma 8 doesn't apply pending migrations from your app's startup code (the "Drizzle pattern" for serverless / edge). Workaround: run `prisma db migrate` from your deploy pipeline before the app starts. If you need runtime-apply built-in, file a feature request via the `references/feedback.md` skill.
- **Seeds-as-first-class.** Prisma 8 doesn't ship a `prisma db seed` equivalent. Workaround: write a TypeScript script that imports your `db` instance and runs your setup queries; invoke it from `package.json`'s scripts. If you need first-class seeding, file a feature request via the `references/feedback.md` skill.
- **Migration squashing.** Prisma 8 doesn't squash older migrations into a baseline. They accumulate; for very large histories, manual baseline-and-truncate is the path. If you need built-in squashing, file a feature request via the `references/feedback.md` skill.
- **Renames beyond models, fields, namespaces, enum values and value object fields.** `--rename` covers models and fields on Postgres, SQLite and MongoDB. On Postgres it also moves a model to another namespace and renames a namespace (not on MongoDB or SQLite). On Postgres and SQLite it also renames enum values, fields of value objects and variants. It does not rename value objects, fields of value objects, enum values or variants with a new discriminator value on MongoDB, or native Postgres enum values. MongoDB also refuses `--convert` of a variant and `--backfill` of a value object field. On MongoDB it also refuses `_id`, a field whose stored name contains `.` or starts with `$`, and a time-series collection; each refusal (`MIGRATION.PLANNING_FAILED`) says what to do instead. If you need one of these, file a feature request via the `references/feedback.md` skill.
## Graph and history commands
After planning or applying, you can inspect the migration graph offline:
- `pnpm prisma migration list` — enumerate all on-disk migrations, rendered as a graph tree. Supports `--legend` (print the glyph key), `--ascii` (pipe-safe glyphs), and `--json`.
- `pnpm prisma migration log --db $DATABASE_URL` — flat chronological table of applied migrations, read from the live DB. Supports `--ascii` and `--json`.
For the full graph topology: `pnpm prisma migration graph` (also supports `--legend`, `--ascii`, `--dot`, `--json`).
## `@@control` and DDL scope
Objects whose `@@control` policy excludes them from Prisma 8's managed surface are omitted from planned DDL. The four policies are: `managed` (Prisma plans and applies DDL), `tolerated` (object may exist, no DDL emitted), `external` (object is expected to exist, no DDL), `observed` (Prisma reads but never writes). Declare `@@control(managed|tolerated|external|observed)` in your schema; see `references/contract.md` and [`packages/2-sql-family/2-authoring/contract-psl/README.md`](https://github.com/prisma/orm/blob/main/packages/2-sql-family/2-authoring/contract-psl/README.md) for authoring syntax.
## Telemetry
The CLI collects anonymous usage data by default. To opt out, set `PRISMA_DISABLE_TELEMETRY=1` or `DO_NOT_TRACK=1` in your environment. See [`docs/Telemetry.md`](https://github.com/prisma/orm/blob/main/docs/Telemetry.md) for the full opt-out reference.
## Checklist
- [ ] Contract emitted (`contract.json` + `contract.d.ts` current).
- [ ] Chose the right path: `db update` (a database whose history does not need to be kept) vs `migration plan` + `db migrate` (a change others will receive, or a database that changes only through migrations).
- [ ] For `migration plan`: confirmed the output's `from:` line names the intended origin — not `(baseline)` over an existing graph (`references/migration-model.md`).
- [ ] For `migration plan`: ran `migration show <dir>` to review before `db migrate`.
- [ ] Filled every `placeholder(...)` in `migration.ts` (if any), built against `endContract`.
- [ ] `check` closures are rowset queries, not scalar aggregates.
- [ ] Self-emitted (`node <package dir>/migration.ts`) after editing the TS.
- [ ] Ran `db migrate` (or `db update`) and saw it complete.
- [ ] Used `db verify` only when diagnosing drift — not as a routine post-apply step.
- [ ] Did NOT use `db update` on a database that changes only through migrations, such as production, and did NOT reset or drop any database to fix a migration problem.
- [ ] Did NOT edit `ops.json` directly.
- [ ] Answered each data-loss question deliberately: `--rename` for a rename, `--delete` only for data that may go, `--allow` only for an access change that is meant.
references/queries-mongo.md
# Prisma 8 — Queries (Mongo)
> Load this guide when `db.ts` imports from `@internal/mongo/runtime`.
Shared concepts (result consumption, script teardown, cross-target pitfalls, capability gaps) live in [`queries.md`](./queries.md).
## Key Concepts
**Mongo** (`mongo({ contract, ... })` from `@internal/mongo/runtime`):
- **`db.orm.<root>`** — ORM, lowercased plural contract root (`db.orm.users`, `db.orm.posts`). Same fluent chaining; `.where({ field: value })` object equality is the idiomatic filter form.
- **`db.query`** — typed aggregation-pipeline builder. Start with `db.query.from('<root>')`, chain `.match(...)` / `.project(...)` / `.group(...)` / `.lookup(...)`, terminal with `.build()`. Run via `(await db.runtime()).query(plan)` for anything that returns documents; `execute(plan)` is only for a write you want an affected count from, and it throws `RUNTIME.MONGO_STATISTICS_UNSUPPORTED` on a find or aggregate.
Reach for the ORM first; drop to `db.query` when the ORM can't express the shape. Lane choice is local — one query function picks one lane, not the whole app.
**Lane decision table:**
| Need | Choose | Why |
|---|---|---|
| Standard CRUD with reference relations | **ORM (`db.orm.<root>`)** | Collection-shaped; object `.where({ ... })`; `.create` / `.update` / `.delete` / `.upsert`. |
| Eager-load a reference relation | **ORM `.include('<relation>')`** | Lowers to `$lookup`; composes with `.where` / `.select` / `.orderBy` / `.limit`. |
| Polymorphic root (discriminated variants) | **ORM `.variant('<discriminator value>')`** | Narrows to the variant declaring that value and injects the discriminator filter. |
| Field-level Mongo updates (`$push`, `$inc`, dot-path `$set`) | **ORM `.update((f) => [f.field.inc(1)])`** | Field-accessor callback; plain-object `.update({ ... })` for whole-field replacement. |
| Aggregation pipeline (group, facet, `$lookup` with reshaping) | **Query builder (`db.query.from(...)`)** | Full pipeline surface; typed row shape through `.build()`. |
| Typed cross-collection join in a pipeline | **Query builder `.lookup((from) => from('users').on(...).as('author'))`** | `$lookup` with compile-time foreign-root checking. |
| Bulk writes with pipeline semantics | **Query builder write terminals** (`.insertOne`, `.updateMany`, `.findOneAndUpdate`, `.upsertOne`, …) | Filtered writes after `.match(...)`; plans execute through the runtime. |
## Workflow — ORM reads
The concept matches Postgres — `db.orm.<root>` returns a collection you compose method-by-method — but roots are **lowercased plurals** from the emitted contract (`users`, `posts`, not `User` / `Post`), and filters are usually **object equality**:
```typescript
// src/queries/users.ts — adjust the relative import to match file depth.
import { db } from '../prisma/db';
// All users.
const users = await db.orm.users.all();
// Single row by equality filter.
const alice = await db.orm.users.where({ email: 'alice@example.com' }).first();
// Projection, sort, pagination — same chaining as Postgres.
const recent = await db.orm.posts
.select('title', 'authorId', 'createdAt')
.orderBy({ createdAt: -1 })
.limit(10)
.all();
```
**Field names, not stored names.** The ORM names a field by its name in the contract, never by the key its documents store it under. A field declared `id ObjectId @id @map("_id")` is `id` in every ORM call and row, and documents store it as `_id`; passing `_id` to the ORM fails with `ORM.FIELD_UNKNOWN`, whose message names the field to use. The pipeline builder (`db.query`) works on documents, so it names stored names: `_id` there. A raw filter expression passed to the ORM's `.where(...)`, such as `MongoFieldFilter.eq('displayName', 'Ada')` or a `MongoExprFilter`, names fields too and is translated; one built for `db.query` names stored names, so do not pass it to the ORM.
**`.where(...)`** accepts a plain object whose keys are model field names and values are compared with equality (codec-aware — `ObjectId` fields accept string ids from the contract). Chain multiple `.where({ ... })` calls to AND-compose filters.
For operators the object form doesn't cover (`.in([...])`, range comparisons, nested logic), pass a `MongoFilterExpr` — today that means importing filter helpers from `@prisma/orm-mongo/query-ast/execution` (a façade-completeness gap; see *What Prisma 8 doesn't do yet* in [`queries.md`](./queries.md)). Prefer the object form whenever equality suffices.
**Polymorphic roots.** When the contract declares variants on a model, narrow before querying. `.variant()` takes the discriminator value a variant declares (`"article"` from `@@base(Post, "article")`), not the variant's model name. Call it once, on the base collection; a second `.variant()` on a variant collection is refused:
```typescript
const articles = await db.orm.posts.variant('article').all();
const tutorials = await db.orm.posts.variant('tutorial').where({ authorId }).all();
```
**Sorting and pagination.** `.orderBy({ field: 1 | -1 })` (Mongo sort directions). `.limit(n)` maps to `$limit`; `.offset(n)` maps to `$skip`.
**`.first()` vs `.all()`.** `.first()` issues a limit-1 read; `.all()` returns every matching document. There is no `.first({ pk })` shorthand on Mongo — filter on the id field explicitly: `.where({ id }).first()`.
Mongo `.all()` returns the same `AsyncIterableResult` shape as Postgres — `await db.orm.users.all()` yields an array; see *Consuming the result* in [`queries.md`](./queries.md).
## Workflow — Eager-loading relations (`.include`)
Mongo reference relations eager-load through the same `.include('<relation>')` surface; the ORM lowers to `$lookup`:
```typescript
const posts = await db.orm.posts
.include('author')
.orderBy({ createdAt: -1 })
.all();
// → Array<{ title, authorId, createdAt, author: { name, email, ... } }>
```
Relation names match the contract's `@relation` field names. Nested includes follow the same chaining rules as the parent collection.
## Workflow — ORM writes
Mongo mutations require a preceding `.where(...)` filter (except `.create` / `.createAll`). Updates accept either a partial document or a field-accessor callback for Mongo operators:
```typescript
// Create — returns the row with the server-assigned `id` (stored as `_id`).
const user = await db.orm.users.create({
name: 'Alice',
email: 'alice@example.com',
bio: null,
address: null,
});
// Update one — plain object replaces top-level fields.
await db.orm.users.where({ id: user.id }).update({ bio: 'Writer' });
// Update one — field operations ($push, $inc, dot-path $set).
await db.orm.users
.where({ id: user.id })
.update((u) => [u.tags.push('admin'), u.loginCount.inc(1)]);
// Update many / delete many — iterate or count.
const updated = await db.orm.users
.where({ bio: null })
.updateAll({ bio: 'filled' });
for await (const row of updated) { /* each modified doc */ }
await db.orm.users.where({ id: user.id }).delete();
// Upsert — filter via .where(), split create vs update branches.
await db.orm.users.where({ email: 'alice@example.com' }).upsert({
create: { name: 'Alice', email: 'alice@example.com', bio: null, address: null },
update: { bio: 'Editor' },
});
```
**Count-only terminals.** `.createAndCount(...)`, `.updateAndCount(...)`, `.deleteAndCount()` return numbers without re-reading full documents — useful for bulk operations where you only need the modified count.
**Upsert + dot-path.** The upsert `update` callback cannot use dot-path field operations — use top-level field replacement in the upsert branch or a separate `.update((u) => [...])` call.
## Workflow — Aggregates
The Mongo ORM does not expose `.aggregate(...)` / `.groupBy(...)`. Express aggregations through **`db.query`** — the pipeline builder — with `.group(...)` and accumulator helpers:
```typescript
import { acc } from '@prisma/orm-mongo/query-builder';
const runtime = await db.runtime();
const plan = db.query
.from('posts')
.match((f) => f.authorId.eq(authorId))
.group((f) => ({
_id: f.kind,
postCount: acc.count(),
latest: acc.max(f.createdAt),
}))
.sort({ postCount: -1 })
.build();
const byKind = await runtime.query(plan);
```
Import `acc` and expression helpers (`fn`) from `@prisma/orm-mongo/query-builder` when building computed pipeline stages (as `examples/mongo-demo/src/server.ts` does).
## Workflow — Query builder (`db.query`)
The concept: `db.query.from('<root>')` starts a typed aggregation-pipeline chain over documents, so it names each field by its stored name (`_id` for a field declared `id … @map("_id")`). Terminal methods produce a `MongoQueryPlan`; run it through the runtime with `query(plan)` (an `AsyncIterableResult` of documents — `await` it for an array):
```typescript
// src/queries/analytics.ts
import { acc, fn } from '@prisma/orm-mongo/query-builder';
import { db } from '../prisma/db';
const runtime = await db.runtime();
// Read pipeline — match, project, sort, limit.
const plan = db.query
.from('posts')
.match((f) => f.authorId.eq(authorId))
.sort({ createdAt: -1 })
.limit(10)
.project('title', 'authorId', 'createdAt')
.build();
const recent = await runtime.query(plan);
// Cross-collection join ($lookup).
const withAuthor = db.query
.from('posts')
.lookup((from) =>
from('users')
.on((local, foreign) => ({
local: local.authorId,
foreign: foreign._id,
}))
.as('author'),
)
.build();
const rows = await runtime.query(withAuthor);
```
**Filters — `.match(...)`.** Callback form: `.match((f) => f.status.eq('active'))`. Filters AND-compose across chained `.match(...)` calls. Field accessors support property access (`f.email`), callable dot paths (`f('address.city').eq('NYC')`), and `f.rawPath('path')` for migration/backfill paths outside the current contract.
**Write terminals on the builder.** After `.from('users')` or `.from('users').match(...)`, use insert/update/delete terminals. Write plans run through `query(plan)` too (one result row carrying the driver's response — the inserted ids, or the document `findOneAndUpdate` returns); reach for `execute(plan)` only on an `update*` / `delete*` plan when all you want is the affected count (any other command kind throws `RUNTIME.MONGO_STATISTICS_UNSUPPORTED`):
```typescript
const inserted = await runtime.query(
db.query.from('users').insertOne({ name: 'Alice', email: 'a@e.com', bio: null }),
);
const { affectedRows } = await runtime.execute(
db.query
.from('users')
.match((f) => f.name.eq('Alice'))
.updateMany((f) => [f.bio.set('filled')]),
);
const [updated] = await runtime.query(
db.query
.from('users')
.match((f) => f.email.eq('a@e.com'))
.findOneAndUpdate((f) => [f.bio.set('updated')], { returnDocument: 'after' }),
);
```
Update callbacks return arrays of field operations (`.set`, `.inc`, `.push`, `.pull`, …). Pipeline-style updates use `f.stage.set(...)` inside an aggregation chain, then `.updateMany()` with no callback.
**Plans vs ORM.** The ORM's `.create` / `.update` / `.all` issue queries directly. Don't pass ORM collections to `runtime.query` / `runtime.execute` — those entry points are for `db.query` plans (and migration/runtime internals).
## Common Pitfalls (Mongo)
1. **Reaching for the lower-level lane when the ORM would have done.** Default to the ORM; drop to `db.query` only for shapes the ORM can't express.
2. **Using `.all()` when you wanted one row.** Use `.where({ ... }).first()` — not `.all()`.
3. **Calling `.update()` / `.delete()` without `.where()`.** Mutations other than `.create` / `.createAll` require a filter — the compiler enforces this at the type level where possible.
4. **Using PascalCase model names on ORM.** Roots are lowercased plurals from the contract (`db.orm.users`, not `db.orm.User`).
5. **Expecting Postgres-style lambda `.where((u) => u.email.eq(...))` on ORM.** Prefer object equality `.where({ email: '...' })`; richer operators need `MongoFilterExpr` helpers (façade gap today).
6. **Expecting `db.transaction(...)`.** The Mongo façade does not expose it today. Multi-document atomicity requires MongoDB transactions on a replica set via the driver — not yet wrapped in the Prisma 8 façade. Route to *What Prisma 8 doesn't do yet* / `references/feedback.md` if the user needs this.
7. **Trying to use `db.sql`.** There is no `db.sql` on Mongo.
8. **Trying to `db.execute(plan)` directly, or reading documents with `execute`.** Run query-builder plans via `(await db.runtime()).query(plan)`. `execute(plan)` resolves statistics only and throws `RUNTIME.MONGO_STATISTICS_UNSUPPORTED` for a find or aggregate.
9. **Expecting ORM `.aggregate(...)` / `.groupBy(...)`.** Use `db.query.from(...).group(...).build()` instead.
## Reference Files
- Example queries under [`examples/mongo-demo/src/server.ts`](examples/mongo-demo/src/server.ts) — ORM reads, `.include`, `.variant`, and pipeline DSL via `db.query`.
- Integration tests under `examples/mongo-demo/test/` (`blog.test.ts`, `crud-lifecycle.test.ts`, `query-builder-writes.test.ts`).
- Query builder README under `packages/2-mongo-family/5-query-builders/query-builder/README.md`.
- ORM collection surface under `packages/2-mongo-family/5-query-builders/orm/src/collection.ts`.
## Checklist
- [ ] Used lowercased plural ORM roots (`db.orm.users`, not `db.orm.User`).
- [ ] Chose the right lane (ORM by default; `db.query` for shapes the ORM doesn't express).
- [ ] Used `.where({ ... }).first()` for single-row reads — not `.all()`.
- [ ] Ran query-builder plans via `(await db.runtime()).query(plan)`; used `execute(plan)` only for an affected count on a write.
- [ ] For aggregations, used `db.query.from(...).group(...)` rather than a non-existent ORM `.aggregate(...)`.
- [ ] Did NOT confabulate `db.transaction`, `db.sql`, or ORM `.aggregate(...)` — routed to *What Prisma 8 doesn't do yet* / `references/feedback.md` instead.
- [ ] Did NOT use the lower-level builder for something the ORM cleanly expresses.
references/queries-postgres.md
# Prisma 8 — Queries (Postgres)
> Load this guide when `db.ts` imports from `@internal/postgres/runtime`.
Shared concepts (result consumption, script teardown, cross-target pitfalls, capability gaps) live in [`queries.md`](./queries.md).
## Key Concepts
**Postgres** (`postgres({ contract, ... })` from `@internal/postgres/runtime`):
- **`db.orm.<ns>.<Model>`** — ORM, PascalCase model name (`db.orm.public.User`). Fluent `.where(...).select(...).orderBy(...).all()`, fully typed against `Contract`. Default lane for CRUD with relations.
- **`db.sql.<ns>.<table>`** — SQL builder, lowercase storage name (`db.sql.public.user`). Produces a *plan*. A plan that returns rows (`select`, or a write with `.returning(...)`) runs through `db.query(plan)`; a write with no `RETURNING` runs through `db.execute(plan)`, which resolves `{ affectedRows }`. Use when the ORM is too high-level — explicit `JOIN`, computed projections, set operations, window functions.
Reach for the ORM first; drop to `db.sql` when the ORM can't express the shape. Lane choice is local — one query function picks one lane, not the whole app.
**Lane decision table:**
| Need | Choose | Why |
| --- | --- | --- |
| Standard CRUD with relations | **ORM (`db.orm.<ns>.<Model>`)** | Highest ergonomics; fully typed; model-shaped. |
| Eager-load related records | **ORM `.include(...)`** | Composes with `.where` / `.select` / `.orderBy` / `.limit` per branch. |
| Aggregate (count, sum, avg) | **ORM `.aggregate(...)`** | Typed result; works with grouping (`.groupBy(...).aggregate(...)`). |
| `INSERT ... RETURNING` / `UPDATE ... RETURNING` typed result | **ORM mutations** (returns updated rows) or **`db.sql.<ns>.<t>.insert(...).returning(...)`** | ORM returns inserted/updated rows; SQL builder exposes `.returning(...)` explicitly. |
| Computed projection (e.g. `ST_DistanceSphere(location, point) AS meters`) alongside model fields | **SQL builder (`db.sql.<ns>.<t>`)** | The ORM projects model fields; arbitrary expression projection is the SQL builder's seam. |
| Complex `JOIN`, set operation, window function | **SQL builder** | The ORM doesn't express arbitrary joins. |
| Postgres-specific feature (`LATERAL`, `FILTER`, custom aggregates) | **SQL builder**, falling back to extension operators when the extension provides them | DSL first; extensions can contribute operators (`postgis`, `pgvector`). |
## Workflow — ORM reads
The concept: `db.orm.<ns>.<Model>` returns a *collection* you compose method-by-method. Each call returns a new collection (immutable chaining); the terminal verb (`.all()` / `.first()` / `.aggregate(...)`) issues the query. There is no `.count()` terminal on the collection — `count()` is an `include` reducer and an `aggregate(...)` operation (both below). Predicates are lambdas over a field proxy: `u.field.<op>(value)`.
```typescript
// src/queries/users.ts — one directory deep under src/, so the import is '../prisma/db'
import { db } from '../prisma/db';
// Find one record by primary key shorthand.
const user = await db.orm.public.User.first({ id: userId });
// Returns the full row or `null`.
// Find one matching a predicate.
const alice = await db.orm.public.User
.where((u) => u.email.eq('alice@example.com'))
.first();
// Find many with projection, sort, and limit.
const recentUsers = await db.orm.public.User
.select('id', 'email', 'createdAt')
.orderBy((u) => u.createdAt.desc())
.limit(10)
.all();
```
**Predicates** (`.where(...)`) come in two forms:
```typescript
// Lambda form — full expression power.
db.orm.public.User.where((u) => u.email.eq('alice@example.com'));
// Shorthand object form — equality on the named fields.
db.orm.public.User.where({ kind: 'admin' });
```
Operators on the field proxy include `.eq`, `.neq`, `.lt`, `.lte`, `.gt`, `.gte`, `.like`, `.ilike`, `.in([...])`, `.isNull()`, `.isNotNull()`. Extensions add target-specific operators on extension-typed columns (`pgvector`'s `.cosineDistance(...)`, `postgis`'s `.within(...)` / `.intersectsBbox(...)` / `.distanceSphere(...)`).
**Full-text search** is built into the Postgres target, on any text column: `.fullTextMatches(q)` is the predicate, `.fullTextRank(q)` scores a row so you can order by relevance, and `.fullTextHeadline(q)` returns the text with `<b>` around the matches. The argument `q` is a `tsquery`, built with a helper from `@prisma/orm-postgres/target/full-text`. A bare string is a type error, because Postgres would read it as `tsquery` syntax without lowercasing or stemming it. Pick the helper by where the text comes from:
- A search box: `websearchToTsquery(input)`. `"an exact phrase"`, `-excluded` and `or` work, and it never errors. `plaintoTsquery` requires every word; `phrasetoTsquery` requires the words in order.
- User input inside operator syntax, such as typeahead: the `tsquery` tag, `` tsquery`${term}:*` ``. The literal parts are trusted `tsquery` syntax you write. Each interpolated value becomes exactly one quoted term, so user input cannot add operators or break the syntax; an empty value adds no words, like a stop word. A value with several words becomes a phrase: `` tsquery`${'new y'}:*` `` gives `'new':* <-> 'y':*`, so the words must be adjacent and in order, and `:*` applies to each word. Do not put quotes around the interpolation yourself: `` tsquery`'${term}':*` `` is a syntax error for every input. Postgres `to_tsquery` then lowercases and stems every word. `` tsquery({ language: 'german' })`...` `` picks the configuration.
- Operator syntax you write in full: `toTsquery("'zebra' & !'graze'")`. Malformed text fails at execution, so never pass user input to it; use the `tsquery` tag for that.
The four parsers are also `fns` members in the SQL builder. Each takes the text (a string, or a text column of any kind, `varchar` included) and `{ language? }`, and binds the text as a parameter. The `tsquery` tag is an import in both the ORM and the SQL builder. A `tsquery` value read back from a query can be passed straight back as the query.
Each operation takes an options object as its second argument. `language` (default `'english'`) is the configuration for the column's `to_tsvector`, the expression the index covers; the parser's or tag's own `language` governs the query side, and the two normally match. It only accepts the configurations a stock PostgreSQL server ships with (`'simple'`, `'german'`, `'french'`, …); `fullTextRank` also takes `normalization` (the `ts_rank` bitmask, 0 to 63) and `coverDensity` (for `ts_rank_cd`); `fullTextHeadline` also takes `startSel`, `stopSel`, `maxWords`, `minWords` and `highlightAll`. Every one of them is written into the SQL as a literal, so anything invalid throws `RUNTIME.ARGUMENT_INVALID` when the query is built.
```typescript
import { tsquery, websearchToTsquery } from '@prisma/orm-postgres/target/full-text';
// ORM: filter by the search-box query, order by relevance. Build the query once and reuse it.
const q = websearchToTsquery(query);
const hits = await db.orm.public.Message
.select('id', 'text')
.where((m) => m.text.fullTextMatches(q))
.orderBy((m) => m.text.fullTextRank(q).desc())
.limit(20)
.all();
// ORM: typeahead, the typed text as a prefix term.
const suggestions = await db.orm.public.Message
.select('id', 'text')
.where((m) => m.text.fullTextMatches(tsquery`${term}:*`))
.all();
// SQL builder: the same query filters and highlights, so the snippet marks what selected the row.
const snippets = db.sql.public.message
.select('id')
.select('snippet', (f, fns) =>
fns.fullTextHeadline(f.text, q, { startSel: '<mark>', stopSel: '</mark>', maxWords: 20 }),
)
.where((f, fns) => fns.fullTextMatches(f.text, q))
.build();
```
Without an index Postgres recomputes `to_tsvector` for every row, and it only uses one whose expression is the same `to_tsvector` over the same configuration literal and the same column. `@@fullTextIndex` declares that index for you — pass it the field and, if you use one, the same language:
```prisma
@@fullTextIndex([text], name: "message_text_search")
@@fullTextIndex([text], where: sql`archived_at IS NULL`, name: "message_text_search_live")
```
Give the index and the operation the same `language`: a mismatch raises no error, the query silently falls back to a sequential scan.
In a TypeScript contract, the same helper from `@prisma/orm-postgres/contract-builder`:
```typescript
model('Message', { fields: { id, text } }).sql(({ cols }) => ({
indexes: [fullTextIndex(cols.text, { name: 'message_text_search' })],
}));
```
**Searching several columns with weights.** One index can cover several columns. Each top-level item of the list is a weight group, strongest first; fields in a nested list share a weight:
```prisma
@@fullTextIndex([[title, subtitle], body], name: "post_search")
```
**Searching an index in the ORM: `using`.** Every collection has a method under `using` for each full-text index of its model's table, named after the index. It filters to the matching rows and orders them by relevance, best first, and returns the same collection, so it chains like `where`:
```typescript
const q = websearchToTsquery(query);
const posts = await db.orm.public.Post.select('id', 'title').using.post_search(q).limit(20).all();
const mine = await db.orm.public.Post.where({ userId }).using.post_search(q).all();
const users = await db.orm.public.User
.include('posts', (posts) => posts.using.post_search(q).limit(3))
.all();
```
The relevance order is added where the method is called: an `orderBy` before it sorts first, one after it breaks ties. A custom collection class stays the class, so `this.using.post_search(q)` works in a class method. A model with no full-text index has no full-text methods, and a misspelled name is a type error. With the pgvector extension, `using` also has `<field>_nearest(vector, maxDistance?)` for each vector field, such as `db.orm.public.Post.using.embedding_nearest(v, 0.5)`: nearest by cosine distance first. `using` is reserved: a custom class cannot define its own `using`.
**Searching an index by hand: name it.** For anything `using` does not do, such as another order or `fullTextHeadline`, name the full-text index and pass it to `fns.fullTextMatches` and `fns.fullTextRank` in place of a column. The operation searches the document the index was built over, with the index's weight groups and language, so the query always matches the index, Postgres uses it, and a title match ranks above a body match. The index states its language, so passing `language` with one is a type error. An index of another type, or a name the model's table does not have, is a type error too.
In the ORM, every `where` and `orderBy` callback receives a second argument `{ fns, indexes }`: `fns` is the SQL builder's functions, and `indexes` holds the indexes of the model's table under the name the contract gave each, `name:` or `map:`. A value from `fns`, such as a rank, has `.asc()` and `.desc()`:
```typescript
const q = websearchToTsquery(query);
const posts = await db.orm.public.Post
.select('id', 'title')
.where((_p, { fns, indexes }) => fns.fullTextMatches(indexes.post_search, q))
.orderBy((_p, { fns, indexes }) => fns.fullTextRank(indexes.post_search, q).desc())
.limit(20)
.all();
```
The same argument reaches the callbacks inside an include refinement, a fragment and a relation filter (`some`, `every`, `none`), where the index's columns follow the alias the ORM gives the related table. A condition from `fns` combines with the ORM's `and`, `or` and `not`. The body of a fragment for any model (`db.orm.fragment(fields, body)`) gets `fns` but no indexes.
In the SQL builder, a table's `indexes` holds the same references:
```typescript
const post = db.sql.public.post;
const posts = post
.select('id', 'title')
.where((_f, fns) => fns.fullTextMatches(post.indexes.post_search, q))
.orderBy((_f, fns) => fns.fullTextRank(post.indexes.post_search, q), { direction: 'desc' })
.build();
```
An index read from an aliased table searches that alias's columns, so `post.as('p').indexes.post_search` works in a self-join.
To search several columns that no index covers, build the document with `fullTextDocument` from `@prisma/orm-postgres/target/full-text`, which takes the weight groups of columns, and pass the language in the options: `fns.fullTextMatches(fullTextDocument([[f.title, f.subtitle], [f.body]]), q, { language: 'english' })`. Postgres uses an index only for a query over the same document, so a document with another grouping, order or language than an index runs without it, and raises no error. `fullTextHeadline` stays per column. One column, `fns.fullTextMatches(f.title, q)` or `row.title.fullTextMatches(q)`, searches that column and uses a single-field index with the same language.
**There is no `.between(a, b)` operator.** Express ranges either as two chained `.where(...)` clauses (the idiomatic form — clauses AND-compose) or with the `and(...)` combinator inside one clause:
```typescript
// Chained .where() — each clause AND-composes with the previous one.
await db.orm.public.Sale
.where((s) => s.day.gte(start))
.where((s) => s.day.lte(end))
.all();
// Equivalent with an explicit `and(...)` inside one clause.
import { and } from '@prisma/orm-postgres/orm-client';
await db.orm.public.Sale
.where((s) => and(s.day.gte(start), s.day.lte(end)))
.all();
```
The two forms emit the same SQL. Pick chained `.where()` when each clause adds a separate condition that reads as its own thought; pick `and(...)` when one logical predicate happens to have two parts and you want the visual grouping. Don't reach for a `between` helper — there isn't one.
**Combinators** (`and`, `or`, `not`) compose predicates, and **relation predicates** (`.some(...)`, `.none(...)`, `.every(...)`) recurse into a relation. `.some()` and `.none()` take an optional predicate — `u.posts.none()` means "has no posts", `u.posts.some()` means "has at least one" — while `.every(...)` requires one: `u.posts.every((p) => p.published.eq(true))`. The combinators are exported from the façade's `orm-client` subpath:
```typescript
import { and, or, not } from '@prisma/orm-postgres/orm-client';
await db.orm.public.User
.where((u) =>
and(
or(u.kind.eq('admin'), u.email.ilike('%@example.com')),
not(u.posts.none((p) => p.title.ilike('%draft%'))),
),
)
.all();
```
**Sorting and pagination.** `.orderBy(...)` accepts a single lambda or an array of lambdas. Each calls `.asc()` / `.desc()` on a field, an extension-operation result, a to-one relation's field, or a to-many relation's `count(...)`. Every `.asc()` / `.desc()` takes `{ nulls: 'first' | 'last' }`. `.limit(n)` limits; `.offset(n)` offsets.
```typescript
await db.orm.public.Post
.where((p) => p.authorId.eq(userId))
.orderBy([(p) => p.createdAt.desc(), (p) => p.id.desc()])
.limit(20)
.all();
```
**Cursor pagination.** Call `.cursor({ field: lastValue })` after `.orderBy(...)` to resume from a known position. `.cursor(...)` and `.distinctOn(...)` require a prior `orderBy` — the type system enforces this on the collection they are called on, so a cast on the argument does not get past it. Direction (forward or backward) follows the sort: ascending order means "greater than the cursor value", descending means "less than".
```typescript
const page1 = await db.orm.public.Post
.orderBy((p) => p.createdAt.desc())
.limit(20)
.all();
const last = page1[page1.length - 1]!;
const page2 = await db.orm.public.Post
.orderBy((p) => p.createdAt.desc())
.cursor({ createdAt: last.createdAt })
.limit(20)
.all();
```
**Ordering by a relation.** A to-one relation (`1:1`, `N:1`) exposes the related model's orderable fields; a to-many relation (`1:N`, `N:M`) exposes `count(predicate?)`, through the junction for `N:M`. One hop only. Each lowers to a correlated scalar subquery, so the main query gains no join.
```typescript
await db.orm.public.Post
.orderBy([(p) => p.author.name.asc(), (p) => p.id.asc()])
.all();
await db.orm.public.User
.orderBy((u) => u.posts.count((p) => p.views.gt(10)).desc())
.all();
await db.orm.public.User
.orderBy((u) => u.tags.count().desc())
.all();
await db.orm.public.User
.orderBy((u) => u.invitedBy.name.desc({ nulls: 'last' }))
.all();
```
A missing related row (null foreign key) orders as `NULL`. To-one relations have no `count`; to-many relations expose no fields.
Cursor keys must match fields in the active `orderBy`. For a composite `orderBy`, pass a value for each ordering column — a partial cursor seeks only on the columns you supply, which gives an incomplete keyset. An empty cursor object is a no-op: you get the unfiltered first page back. `cursor()` keys on plain columns only: it throws `ORM.ARGUMENT_INVALID` when an active order is a relation field, a relation `count(...)`, an extension-operation result (`fullTextRank`, vector distance) or sets `nulls`. `distinctOn()` throws the same only when one of its leading orders, as many as there are `distinctOn` columns, is not a plain column; relation, count and operation orders after them are fine. Paginate those orders with `.limit(n).offset(n)`.
**`.first()` vs `.first({ pk })` vs `.all()`.** Use `.first()` for a single row (issues a `LIMIT 1`); use `.first({ pk })` for primary-key lookups; reserve `.all()` for the genuine many case (no implicit `LIMIT`).
## Workflow — Eager-loading relations (`.include`)
The concept: `.include('<relation>', (branch) => branch.<chain>)` adds a relation branch to the parent query. The branch is its own collection — compose `.where` / `.select` / `.orderBy` / `.limit` on it just like the parent.
```typescript
await db.orm.public.User
.select('id', 'email')
.include('posts', (post) =>
post
.select('id', 'title', 'createdAt')
.orderBy((p) => p.createdAt.desc())
.limit(5),
)
.limit(10)
.all();
// → Array<{ id, email, posts: Array<{ id, title, createdAt }> }>
```
**Reduce a to-many relation to a scalar.** A refinement callback may return a *reducer* — `count()`, `sum(field)`, `avg(field)`, `min(field)`, `max(field)`, plus the lossless `countBigInt()`, `sumBigInt(field)`, and `avgDecimal(field)` — instead of a collection. The parent's relation field then carries that one value rather than an array. Reducers exist only inside an `include(...)` callback; calling one elsewhere throws.
```typescript
await db.orm.public.User.include('posts', (posts) => posts.count()).all();
// → Array<{ ...user, posts: number }> — a parent with no posts reads 0
await db.orm.public.User.include('posts', (posts) => posts.sum('views')).all();
// → Array<{ ...user, posts: number | null }>
await db.orm.public.User.include('posts', (posts) => posts.avg('views')).all();
// → Array<{ ...user, posts: number | null }>
await db.orm.public.User.include('posts', (posts) => posts.min('views')).all();
await db.orm.public.User.include('posts', (posts) => posts.max('views')).all();
// → Array<{ ...user, posts: number | null }>
// The lossless form, for a total that may outgrow a JS number:
await db.orm.public.User.include('posts', (posts) => posts.sumBigInt('views')).all();
// → Array<{ ...user, posts: bigint | null }>
// Several sub-views of one relation at once:
await db.orm.public.User.include('posts', (posts) =>
posts.combine({ recent: posts.limit(3), total: posts.count() }),
).all();
// → Array<{ ...user, posts: { recent: Post[]; total: number } }>
```
A reducer's result type is the one the target declares for that aggregate — the same types *Workflow — Aggregates* tabulates below, including the `null` a field-taking reducer answers for a parent with no related rows. The reducer set is read from the contract, so an operation a target or extension contributes shows up as a reducer under its own name, with no client change.
Nested `1:N → 1:N` includes (e.g. `User → posts → comments`) require the contract to advertise the `lateral` + `jsonAgg` capabilities for the active target. The Postgres adapter advertises both by default, so most apps get this for free; if the type system rejects a nested include with a *missing capability* error, route to `references/contract.md` to add the required capability declarations and use `references/queries.md` for query-shape guidance.
## Workflow — ORM writes
```typescript
// Create — returns the inserted row.
const user = await db.orm.public.User.create({ id, email, displayName, kind, createdAt });
// Create with selected return — narrows the return shape.
const summary = await db.orm.public.User
.select('id', 'email', 'kind')
.create({ id, email, displayName, kind, createdAt });
// Update by predicate.
await db.orm.public.User.where({ id }).update({ email: newEmail });
// Update with selected return.
await db.orm.public.User
.where({ id })
.select('id', 'email', 'kind')
.update({ email: newEmail });
// Delete by predicate.
await db.orm.public.User.where({ id }).delete();
// Upsert — typed by the create branch's shape.
await db.orm.public.User
.select('id', 'email', 'kind', 'createdAt')
.upsert({
create: { id, email, displayName, kind, createdAt: new Date() },
update: { email, displayName, kind },
});
```
The ORM returns inserted / updated rows by default. The `.returning(...)` selector lives on the SQL builder (next section), where you build a plan and execute it explicitly.
**A write needs a filter on every code path.** `update`, `updateAll`, `updateAndCount`, `delete`, `deleteAll` and `deleteAndCount` compile only on a collection that is known to be filtered. A collection that is filtered on one path and not on another is not:
```typescript
const posts = search ? db.orm.public.Post.where({ title: search }) : db.orm.public.Post;
await posts.deleteAll(); // error: The 'this' context of type '...' is not assignable to method's 'this' of type 'HasWhere'
```
Make the write only where the filter was applied, or filter on every path:
```typescript
if (search) {
await db.orm.public.Post.where({ title: search }).deleteAll();
}
```
The same holds for an `if` with an early return, a `switch`, a loop and a reassigned `let`.
**A write refuses what it would ignore.** `updateAll`, `updateAndCount`, `deleteAll` and `deleteAndCount` change every row that matches the filter, and throw `ORM.ARGUMENT_INVALID` on a collection with a `limit`, an `offset`, a `cursor`, `distinct` or `distinctOn`; an `orderBy` is accepted. To change a page of rows, read their ids first and filter on them. `update` and `delete` change the one row `first()` returns, so the order, offset, cursor, `distinct` and `distinctOn` choose it; after `limit(0)` they change nothing and return `null`. `update` with a relation callback finds its row by the filter alone and throws on any of these and on an order.
## Workflow — Custom collection classes
A custom collection class gives a model its own named queries. Extend `Collection` and pass the class to the client with the `collections` option. `db.orm` then returns the class for that model, and so does `tx.orm` inside `db.transaction(...)`:
```typescript
import { Collection, type Filtered, type Ordered, type QueryFragment } from '@prisma/orm-postgres/orm-client';
import postgres from '@prisma/orm-postgres/runtime';
import { contractFromJson } from '@prisma/orm-postgres/static';
import type { Contract } from './prisma/contract.d';
import contractJson from './prisma/contract.json' with { type: 'json' };
class PostCollection extends Collection<Contract, 'Post'> {
byAuthor(userId: string) {
return this.where({ userId });
}
newestFirst() {
return this.orderBy((p) => p.createdAt.desc());
}
}
export const contract = contractFromJson<Contract>(contractJson);
export const db = postgres({ contract, url: process.env['DATABASE_URL']!, collections: { Post: PostCollection } });
const { Post } = db.orm.public;
```
`postgresServerless({ contract, collections })` takes the same option, and every connection it opens uses the classes. The client's type carries the classes as its second type parameter: `PostgresClient<Contract, { Post: typeof PostCollection }>`. A parameter typed `PostgresClient<Contract>` still accepts a client with classes.
Class methods chain with each other and with the built-in methods, in any order, and after `.include(...)`:
```typescript
await Post.byAuthor(userId).newestFirst().limit(20).all();
await Post.where({ title }).byAuthor(userId).all();
await Post.include('user').newestFirst().all();
```
After `.select(...)` or `.variant(...)` the class methods are gone: those return the base `Collection` type. Call class methods before them. Inside an include refinement, the related collection is the base `Collection` type, not its registered class.
Inside a class body, a class method called on the result of another call loses what that call established. So a class method whose body chains two class methods loses the first call's facts for every caller: with `latest() { return this.byAuthor(id).newestFirst(); }`, `Post.latest()` is known to be ordered but not filtered. The same holds for `.prepared` after `.include(...)` inside the class: it describes the class's row without the included relation. Inside the class, follow a class method with built-in methods (`this.byAuthor(id).orderBy(...)`), or chain the class methods from outside the class, where they keep every fact.
`with(fn)` calls a function with the collection and returns its result. The function is a query fragment, described in Workflow — Query fragments below, of type `QueryFragment<In, Out>`, so a query can be written once and applied to any collection of that class:
```typescript
const newest: QueryFragment<PostCollection, Ordered<PostCollection>> = (posts) => posts.newestFirst();
await Post.with(newest).limit(20).all();
```
What a chain has established is part of its type. Write a filtered collection as `Filtered<C>` and an ordered one as `Ordered<C>`; `Filtered<C>` is `C & HasWhere`, the name error messages print. A function that takes `Filtered<PostCollection>` accepts only a collection that is filtered:
```typescript
function deleteMatching(posts: Filtered<PostCollection>) {
return posts.deleteAll();
}
await deleteMatching(Post.byAuthor(userId)).toArray();
```
## Workflow — Query fragments
A piece of a query used in several places is a function. Do not build a filter object and spread it into each query; write a function and pass it to `.where(...)`, `.orderBy(...)` or `.with(...)`. A **row fragment** is a function of the model accessor, and `.where(...)` and `.orderBy(...)` take it. A **query fragment** (a **fragment** for short) is a function from a collection to a collection, and `.with(...)` runs it. A **scope** is a fragment that only imposes conditions on the query, such as `createdSince` below, and is also run with `.with(...)`. When the condition is a function of one row and needs no declared fields, pass a row fragment to `.where(...)`. When the same condition should apply to every model that has some fields, write a scope: it declares the fields, checks them at run time, and returns the collection with the `Filtered` fact. `.with(...)` also runs what `.where(...)` cannot express, such as a shared `select` and `include`, an order, a limit or offset, or a variant.
**The same filter on several models.** Define a scope with `db.orm.fragment(fields, body)`; the method is named `fragment` because it cannot know whether the body only filters. Declare each field the fragment needs with the same builder the schema uses, from the `field` and `type` that `@prisma/orm-postgres/contract-builder` exports, adding `.optional()` for a field that may be null. The body sees only those fields:
```typescript
import { field, type } from '@prisma/orm-postgres/contract-builder';
const createdSince = (since: Temporal.Instant) =>
db.orm.fragment({ createdAt: field.column(type.TimestamptzTemporal()) }, (rows) =>
rows.where((r) => r.createdAt.gte(since)),
);
await db.orm.public.User.with(createdSince(since)).all();
await db.orm.public.Post.with(createdSince(since)).deleteAll();
```
Pick the builder whose codec matches the field's codec in `contract.d.ts`: a PSL `TimestamptzTemporal` is `field.column(type.TimestamptzTemporal())` (`pg/timestamptz-temporal@1`), a `String` is `field.column(type.String())` (`pg/text@1`), a `Uuid` is `field.column(type.Uuid())` (`pg/uuid@1`). `field.column(columnType)` is the explicit form. A package that offers a fragment and does not import the facade writes `{ codecId: 'pg/text@1', nullable: false }`, with a codec of the contract. For a list field such as `String[]`, add `.many()` to the builder or `many: { elementNullable: false }` to the object; for a `String?[]`, whose elements may be null, add `.many({ elementsNullable: true })` or `many: { elementNullable: true }`. Without it the declaration does not match a list field, and with it the element nullability must match too. A list of value objects such as `Address[]` is stored as one `jsonb` value and is still a list: `field.column(jsonbColumn).many()`. The body may call `where`, `orderBy`, `limit` and `offset`. The result keeps the collection's class and records the filter, so `update` and `delete` are allowed after a fragment that filters. `updateAll`, `deleteAll` and their `AndCount` forms change every matching row and throw `ORM.ARGUMENT_INVALID` when the chain has a `limit`, `offset`, `cursor`, `distinct` or `distinctOn`, so do not put a limit in a fragment that will be followed by one of them. `update` and `delete` change the one row `first()` returns, so the order, offset, cursor, `distinct` and `distinctOn` choose it, and after `limit(0)` they change nothing and return `null`; `update` with a relation callback throws on an order, a limit, an offset, a cursor, `distinct` or `distinctOn`, because it finds its row by the filter alone. A model without the field, or with the field under another codec or nullability, is a compile error, and a run-time `ORM.FIELD_UNKNOWN`. A custom collection class carries no namespace in its type, so at compile time it matches only a field that every model of that name has, with the same codec and nullability, and its real namespace is checked at run time. The codec, the nullability, whether the field is a list and the nullability of its elements are compared; the column type's parameters are not, so `field.column(type.Char(36))` (`char(36)`) also matches a `char(10)` field.
For a filter used inside a larger `where`, a plain function of the row works too. Type the field with `CodecField<Contract, CodecId, Nullable>`, the type of any field with that codec:
```typescript
import { and, type CodecField } from '@prisma/orm-postgres/orm-client';
type CreatedAt = CodecField<Contract, 'pg/timestamptz-temporal@1'>;
const created = (since: Temporal.Instant) => (row: { createdAt: CreatedAt }) => row.createdAt.gte(since);
await db.orm.public.Post.where((p) => and(created(since)(p), p.title.ilike('%orm%'))).all();
```
Both forms check values against the codec's type, not the field's. For a field that narrows its codec's values, such as an enum stored as `pg/text@1`, they accept values the field would refuse: `row.kind.eq('superuser')` compiles even when `kind` has no such member. Write a filter on such a field inline, on the model, where the field's own type checks the value.
**The same `select` and `include` in several queries.** Define it once with `.fragment(...)` on the model's root collection, such as `db.orm.public.Post`, run it with `.with(...)`, and name its row with `CollectionRowOf`:
```typescript
import type { CollectionRowOf } from '@prisma/orm-postgres/orm-client';
const postSummary = db.orm.public.Post.fragment((posts) =>
posts.select('id', 'title', 'createdAt').include('tags'),
);
type PostSummary = CollectionRowOf<ReturnType<typeof postSummary>>;
await db.orm.public.Post.where({ userId }).with(postSummary).limit(20).all();
await db.orm.public.User.include('posts', (posts) => posts.with(postSummary)).all();
```
The body receives the plain collection of the model, without a custom class's methods. Apply the fragment before `.select(...)` or `.variant(...)`: it is refused on a collection they narrowed. Its result is always typed as the plain collection, even when the body keeps the row, so a custom class's methods are gone after it, and a filter or order applied before it is not recorded, although an earlier `.where(...)` still runs. `update` and `delete` are refused after it unless the body itself calls `where`, and `cursor` unless the body calls `orderBy`. For a filter on one model, use a class method or `db.orm.fragment`. A fragment for one model refuses a collection of another model or namespace at run time with `ORM.ARGUMENT_INVALID`. Calling `.fragment(...)` on a collection with chained calls throws `ORM.ARGUMENT_INVALID`, because the fragment is built from the model alone; a filter, an order or an include before it, or a receiver typed by a type parameter such as `this` in a class method, is a compile error. Inside a class method, chain on `this` directly instead.
**A field to order by, from a request.** Pass the request's string to `orderByField` with the fields the endpoint allows. Do not index the field proxy with the raw string:
```typescript
import { orderByField } from '@prisma/orm-postgres/orm-client';
await db.orm.public.Post
.orderBy(orderByField(db.orm.public.Post, input.orderBy, input.direction, ['title', 'createdAt']))
.limit(20)
.all();
```
Pass the request's direction string as it is; `undefined` means `'asc'`. The allowed list is required: without it a request could order by a secret field and learn its values from the order of the results. A name outside the list, a relation, a field that cannot be ordered, or a direction other than `'asc'` or `'desc'` throws `ORM.ARGUMENT_INVALID` before the query runs; answer it as a bad request. Only the model's own fields can be named, not the fields of one variant.
## Workflow — Aggregates
```typescript
const totals = await db.orm.public.User.aggregate((aggregate) => ({
totalUsers: aggregate.count(),
}));
const adminTotals = await db.orm.public.User
.where({ kind: 'admin' })
.aggregate((aggregate) => ({
adminUsers: aggregate.count(),
}));
// Group-by + aggregate.
const byKind = await db.orm.public.User
.groupBy('kind')
.having((having) => having.count().gte(minUsers))
.aggregate((aggregate) => ({
totalUsers: aggregate.count(),
}));
```
`aggregate` exposes `.count()`, `.sum(field)`, `.avg(field)`, `.min(field)`, `.max(field)`, and the lossless `.countBigInt()`, `.sumBigInt(field)`, `.avgDecimal(field)`. Project the aggregates into named result keys; the result type narrows accordingly.
**The bare operations answer as JS numbers. The suffixed ones answer losslessly.** An aggregate's type is the one its target declares and its nullability matches SQL semantics, both read from the contract. On PostgreSQL:
| Aggregate | Type | Empty result |
| --- | --- | --- |
| `count()` | `number` | `0` |
| `countBigInt()` | `bigint` | `0n` |
| `sum(field)` over `int2` / `int4` / `int8` / `BigInt(as: number)` | `number \| null` | `null` (SQL `SUM` over zero rows is `NULL`) |
| `sumBigInt(field)` over any integer | `bigint \| null` | `null` |
| `sum(field)` over `float4` / `float8` | `number \| null` | `null` |
| `sum(field)` over `numeric` | decimal `string \| null` | `null` |
| `sum(field)` over `BigInt(storage: numeric)` | `bigint \| null` — that column's own family | `null` |
| `avg(field)` over any integer | `number \| null` | `null` |
| `avgDecimal(field)` over any integer or `numeric` | decimal `string \| null` | `null` |
| `avg(field)` over a float | `number \| null` | `null` |
| `min(field)` / `max(field)` | the column's own type `\| null` — `text` where the column is `varchar` | `null` |
**`count()`, and `sum` over an integer column, throw outside ±(2^53 − 1) — they never round.** Those two are the results a guarded integer codec produces. The rows above the guard does not reach: `sum` over `numeric`, `BigInt(storage: numeric)`, or a float column, and every `avg`, which is a fraction already. The error is `RUNTIME.DECODE_FAILED` (`… value must be an integer within the safe integer range, got …`), and it fires on the include path too. That is the trade the defaults make: a `number` you can compare, serialise, and do arithmetic with, and a loud failure instead of a quietly wrong total. Switch that one call to `sumBigInt` / `countBigInt` where the magnitude is real.
**Float and Decimal columns keep their own family.** `sum` over `numeric` is still a decimal string, and `sum` over `float8` still a `number` — the defaults policy is about integers, and a column whose author chose `numeric` already chose its representation.
**SQLite states the same policy.** `count`, `sum` over integers, and `avg` all read as `number`; `countBigInt` and `sumBigInt` are there too. There is no `avgDecimal` — SQLite has no exact decimal type, so the method does not exist on a SQLite contract.
**The ORM's `having(...)` stays a `number`.** Its comparand is typed `number` outright, whatever result type the aggregate carries, so `having.count().gte(minUsers)` takes a plain number — the value is inlined as a SQL literal and compared inside the database. Only the aggregate's *result*, the value that reaches your code, carries its target's type. The lossless variants are projection-only and have no HAVING method at all.
**The SQL builder types its comparands differently.** `fns.gt(a, b)` types both operands from one codec, so a literal compared against an aggregate follows that aggregate's result codec — `fns.gt(fns.count(), 1)`, not `1n`. That applies in `having(...)`, in `where(...)`, and inside any larger expression.
Nullability isn't a typing bug — it's faithful to what the database returns. Coalesce client-side when you want zero-fill:
```typescript
const revenue = await db.orm.public.Sale
.where((s) => s.day.gte(start))
.aggregate((a) => ({ total: a.sum('amount') }));
// revenue.total: number | null
const safe = revenue.total ?? 0; // ← apply at the consumption site, not in the aggregate spec.
```
If `?? 0` is showing up on every aggregate, that's a signal you're calling `sum` (or peers) over potentially-empty filters — which is exactly when SQL returns NULL. The pattern is correct; the typing is honest.
## Workflow — SQL builder (`db.sql.<ns>.<table>`)
The concept: `db.sql.<ns>.<table>` is a table-shaped builder that produces a *plan*. The plan is a serialisable description of the query (AST + parameters); you run it through the client. Pick the method by the result the plan declares: `db.query(plan)` for rows (it returns the same `AsyncIterableResult` as `.all()`, so `await` it for an array), `db.execute(plan)` for a write with no `RETURNING` (it resolves `{ affectedRows }` and returns no rows). The builder gives you the lanes the ORM doesn't express — explicit `JOIN`, arbitrary expression projection, target-specific operations through extension helpers — without dropping to raw SQL.
```typescript
// src/queries/posts.ts — adjust the relative import to match file depth.
import { db } from '../prisma/db';
// Select with predicate and limit.
const plan = db.sql.public.post
.select('id', 'title', 'userId', 'createdAt')
.where((f, fns) => fns.eq(f.userId, userId))
.limit(limit)
.build();
const rows = await db.query(plan);
```
The `.where(...)` callback receives `(fields, fns)` — `fields` is the field proxy (column references), `fns` is the operator namespace (`fns.eq`, `fns.ne`, `fns.gt`, …). Extensions inject extension-shaped helpers into the same `fns` namespace (`fns.distanceSphere`, `fns.cosineDistance`, etc.).
### `INSERT` / `UPDATE` / `DELETE` with `RETURNING`
```typescript
// Insert and return selected columns. `insert()` takes an array of rows.
const plan = db.sql.public.user
.insert([{ email, displayName, kind }])
.returning('id', 'email')
.build();
const [row] = await db.query(plan);
// Update with predicate and returning.
const updatePlan = db.sql.public.user
.update({ email: newEmail })
.where((f, fns) => fns.eq(f.id, userId))
.returning('id', 'email')
.build();
const rows = await db.query(updatePlan);
// Delete with predicate, no RETURNING — `execute` resolves the affected count.
const deletePlan = db.sql.public.user
.delete()
.where((f, fns) => fns.eq(f.id, userId))
.build();
const { affectedRows } = await db.execute(deletePlan);
```
Each row must include every column that is not nullable and has no default; leaving one out is a type error that names the column. A value typed `T | undefined` for such a column is also an error, so narrow it first.
`.returning(...)` requires the target adapter to advertise the `returning` capability. The Postgres adapter advertises it by default.
### Computed projections and joins
```typescript
// Project a computed expression alongside model fields.
const plan = db.sql.public.cafe
.select('id', 'name')
.select('meters', (f, fns) => fns.distanceSphere(f.location, point))
.orderBy((f, fns) => fns.distanceSphere(f.location, point), { direction: 'asc' })
.orderBy((f) => f.id, { direction: 'asc' })
.limit(limit)
.build();
const rows = await db.query(plan);
// Self-join with an alias.
db.sql.public.post
.innerJoin(db.sql.public.post.as('p2'), (f, fns) => fns.ne(f.p1.userId, f.p2.userId))
// ...
.build();
```
The SQL builder's `.orderBy(column, { direction, nulls })` takes the same null placement: `.orderBy('invited_by_id', { direction: 'asc', nulls: 'first' })` renders `ORDER BY "invited_by_id" ASC NULLS FIRST`.
## Workflow — Transactions
The concept: `db.transaction(fn)` opens a transaction and passes a `tx` context to the callback. `tx` offers the client's query members with the same types, and each runs inside the transaction: `sql`, `orm` (with the client's custom collection classes), `raw`, `prepare`, `query(plan)` (rows) and `execute(plan)` (affected count), plus `toSql`, `enums` and `nativeEnums`. A prepared statement runs on what it is given, so `ps.query(tx, params)` runs inside the transaction. See *Workflow — Transactions* in `references/runtime.md` for raw SQL and `tx.prepare`. The transaction commits on the callback's successful return and rolls back on any thrown error.
```typescript
await db.transaction(async (tx) => {
const user = await tx.orm.public.User.create({ id, email });
await tx.orm.public.Post.create({ userId: user.id, title: 'hello' });
// SQL-builder plan inside the transaction — no RETURNING, so `execute`.
const plan = tx.sql.public.post.update({ status: 'archived' })
.where((f, fns) => fns.lt(f.createdAt, cutoff))
.build();
const { affectedRows } = await tx.execute(plan);
// If anything throws, all three operations roll back.
});
```
The callback's return value passes through `db.transaction(...)`. Capture inserted ids out of the callback and use them downstream after commit.
To read a row and then write it without another transaction changing it in between, lock it when you read it. The lock lasts until the transaction ends; outside a transaction it is released when the statement ends.
```typescript
await db.transaction(async (tx) => {
// ORM: locks only the model's own rows (`FOR UPDATE OF "product"`).
const product = await tx.orm.public.Product.where({ id }).forUpdate().first();
// SQL builder: the same lock on a plan.
const [row] = await tx.query(
tx.sql.public.product.select('id', 'stock').where((f, fns) => fns.eq(f.id, id)).forUpdate().build(),
);
// Work queue: claim the next unclaimed job, skipping jobs other workers hold.
const job = await tx.orm.public.Job.where({ state: 'queued' })
.orderBy((j) => j.createdAt.asc())
.forUpdate({ skipLocked: true })
.first();
});
```
The four methods are `forUpdate()`, `forNoKeyUpdate()`, `forShare()` and `forKeyShare()`, each with `{ nowait: true }` or `{ skipLocked: true }`. On the ORM a lock cannot be combined with `include`, `groupBy`, `aggregate`, `distinct`, `distinctOn` or a mutation terminal (`ORM.LOCK_INCOMPATIBLE`).
## Namespace-aware accessors
On Postgres both `db.sql` and `db.orm` are keyed by storage namespace (the Postgres schema) — always, not only when a contract declares more than one. A model outside any `namespace { }` block is in `public`:
```typescript
// db.sql.<namespace>.<table>
const plan = db.sql.public.user.select('id', 'email').build();
const authPlan = db.sql.auth.users.select('id', 'token').build();
const rows = await db.query(plan);
// db.orm.<namespace>.<Model>
const user = await db.orm.public.User.create({ id: 1, email: 'a@x.io' });
const authUser = await db.orm.auth.User.create({ id: 2, token: 'tok' });
```
There is no flat `db.sql.user` / `db.orm.User` on the Postgres façade (`Db` is "one facet per storage namespace, and nothing else"); reaching for one is a type error. The flat spelling belongs to SQLite, whose façade exposes its single unbound namespace directly — see `references/queries.md` § *Namespace-aware accessors*.
Cross-namespace relations (e.g. `public.Profile` → `auth.User`) follow the same `.include()` syntax; the ORM resolves the correct schema-qualified join automatically.
## Common Pitfalls (Postgres)
1. **Reaching for the lower-level lane when the ORM would have done.** The ORM covers most CRUD shapes; drop to `db.sql` only for shapes the ORM can't express. Default to the ORM.
2. **Using `.all()` when you wanted one row.** `.all()` issues no implicit limit. Use `.first()` or `.first({ pk })`.
3. **Coalescing `count()` with `?? 0` "just in case".** `count()` is `number`, not `number | null` — SQL answers an empty set with `0`. The `?? 0` belongs on `sum` / `avg` / `min` / `max`, and its zero should match the aggregate's own type (`0` for an integer sum, `0n` for `sumBigInt`, `'0'` where the result is a decimal string).
4. **Reaching for `.between(a, b)` on a field proxy.** It doesn't exist. Either chain `.where((m) => m.field.gte(a)).where((m) => m.field.lte(b))` or use `and(m.field.gte(a), m.field.lte(b))` inside one `.where()` clause.
5. **Importing `and` / `or` / `not` from an internal package.** They are exported from `@prisma/orm-postgres/orm-client`; do not reach into `@internal/sql-orm-client`.
6. **Trying to `db.sql.from(tables.user)`.** That surface does not exist. The builder is table-shaped: `db.sql.<ns>.<tableName>.select(...)`. There is no `db.schema.tables` either.
7. **Reading rows with `execute`.** Plans run through the client: `db.query(plan)` for rows, `db.execute(plan)` for a non-returning write (`{ affectedRows }`). Inside a transaction, `tx.query(plan)` / `tx.execute(plan)`. `execute` never yields rows — a `select` or `.returning(...)` plan passed to it gives you statistics, not data.
8. **Setting `capabilities: { lateral: true }` in `prisma.config.ts`.** The ORM config (`ormConfig({...})`) does not take `capabilities`. Capabilities are declared by the active adapter and become part of the emitted contract; the Postgres adapter advertises `lateral`, `jsonAgg`, and `returning` out of the box. Enable extension capabilities through `extensions: [...]` in the config (see `references/contract.md`).
9. **Confabulating a TypedSQL or `.stream()` surface.** Neither exists. Raw SQL does: the client's raw lane, ``db.raw.sql`…` ``. Reusable statements do: `db.prepare(...)` (see *Prepared statements* in [`queries.md`](./queries.md)). Streaming: `for await` over a read terminal or `runtime.query(plan)` — with the caveats in *Streaming* in [`queries.md`](./queries.md).
10. **Mixing the ORM mutation return with `db.query(plan)` / `db.execute(plan)`.** ORM terminals issue the query themselves and return rows. `query` and `execute` are for plans.
11. **Adding a `cursor()` to a relation, count, operation or `nulls` order.** It throws `ORM.ARGUMENT_INVALID`. Use `.limit(n).offset(n)`, or order by plain columns.
12. **Ordering grouped rows by an aggregate metric.** The grouped collection supports `.orderBy(...)` on group keys plus `.limit(...)` / `.offset(...)`, but it cannot order by an aggregate alias such as `SUM(amount)`. Sorting the materialized aggregate result in JS is fine at small cardinalities; for large grouped result sets, drop to `db.sql.<ns>.<table>`.
13. **Locking a parent row with `forUpdate()` while other transactions write rows that reference it.** Every insert or update of a row with a foreign key to the locked row takes `FOR KEY SHARE` on it to check the key, and `FOR KEY SHARE` conflicts with `FOR UPDATE`, so those writes wait and can deadlock. Use `forNoKeyUpdate()`: it still blocks writes to the row, but not the foreign-key check.
## Reference Files
- Example queries under [`examples/prisma-8-demo/src/orm-client/`](examples/prisma-8-demo/src/orm-client/) and [`examples/prisma-8-demo/src/queries/`](examples/prisma-8-demo/src/queries/) — canonical ORM and SQL-builder shapes.
- ORM client source under `packages/3-extensions/sql-orm-client/src/`.
- SQL builder source under `packages/2-sql-family/4-lanes/sql-builder/src/`.
## Checklist
- [ ] Chose the right lane (ORM by default; `db.sql` for shapes the ORM doesn't express).
- [ ] Used `.first()` / `.first({ pk })` for single-row reads — not `.all()`.
- [ ] Coalesced `sum` / `avg` / `min` / `max` results at the consumption site when zero-fill is desired, with a zero of the aggregate's own type — did NOT coalesce `count()`, which is `number` and never null.
- [ ] Reached for `countBigInt` / `sumBigInt` / `avgDecimal` where the value can outgrow a JS number or the exact decimal matters — `count()` and `sum` over an integer column throw `RUNTIME.DECODE_FAILED` outside ±(2^53 − 1) rather than rounding, and `avg` rounds as any double does.
- [ ] Compared and serialised aggregate *results* as what they are — a `bigint` from a suffixed variant needs `0n` literals and `String(value)` rather than bare `JSON.stringify` — leaving the ORM's `having(...)` operands as numbers, and matching each SQL-builder comparison literal to the aggregate's own result codec (`fns.gt(fns.count(), 1)`).
- [ ] Expressed ranges as chained `.where(...)` clauses or a single `and(...)` clause — did NOT reach for a non-existent `.between(...)` operator.
- [ ] For cursor pagination, used `.orderBy(...).cursor({ field: lastValue }).limit(n).all()` — did NOT hand-write a `.where(p => p.field.lt(cursor))` workaround when the `.cursor()` API serves the same purpose.
- [ ] For ORM combinators, imported `and` / `or` / `not` from `@prisma/orm-postgres/orm-client`.
- [ ] Wrote a query piece shared between places as a function: a query fragment from `db.orm.fragment(fields, body)` or `collection.fragment(body)` run with `.with(...)`, a `where` callback typed with `CodecField`, or `orderByField` for an order parameter from a request.
- [ ] Ran SQL-builder plans via `db.query(plan)` when they return rows and `db.execute(plan)` only for non-returning writes (`tx.query` / `tx.execute` inside a transaction). Passed `insert()` an array of rows.
- [ ] Wrapped multi-statement work in `db.transaction(async (tx) => { ... })` where atomicity matters.
- [ ] For top-N grouped aggregates at meaningful scale, dropped to `db.sql.<ns>.<table>` rather than JS-side sort + slice over `groupBy(...).aggregate(...)`.
- [ ] Did NOT confabulate TypedSQL, `.stream()`, `db.batch`, `.between(...)`, a collection-level `.count()`, a `capabilities` field on the config, or a `db.sql.from(tables.user)` API — routed to *What Prisma 8 doesn't do yet* / `references/feedback.md` instead. Raw SQL is spelled `db.raw.sql`, not `db.sql.raw`.
references/queries.md
# Prisma 8 — Queries
> **Edit your data contract. Prisma handles the rest.**
Once the contract is emitted and the DB is up to date, this skill covers everything you do *with* the data: reading, writing, eager-loading relations, aggregating, and the choice between the ORM and the lower-level query lane.
## When to Use
- User wants to read, write, update, or delete data.
- User wants to include / eager-load relations.
- User wants to paginate, sort, filter, project.
- User wants to wrap operations in a transaction (`db.transaction(...)` — Postgres, SQLite and Supabase role-bound clients).
- User wants to aggregate (`count`, `sum`, `avg`, …).
- User asks about query lanes (ORM vs SQL builder / query builder).
- User mentions: *query, select, where, orderBy, limit, offset, take, skip, include, eager load, first, all, count, aggregate, create, update, delete, upsert, returning, drizzle-style, kysely-style, prisma client*.
## When Not to Use
- User wants to add / change a model → `references/contract.md`.
- User wants to wire `db.ts` or add middleware → `references/runtime.md`.
- User is querying through a Supabase role-bound db (`asUser` / `asAnon` / `asServiceRole`, RLS, `auth.*` admin reads) → `references/supabase.md` for the role-binding surface; everything in this skill then applies to the returned `RoleBoundDb`.
- User wants to debug a query failure (structured error envelope) → `references/debug.md`.
## Pick your target
Prisma 8 ships **two query lanes per target** on the same `db` value from `src/prisma/db.ts`. **Before writing queries, read `db.ts` and load the matching target guide:**
| Runtime import in `db.ts` | Load |
| --- | --- |
| `@internal/postgres/runtime` | [`queries-postgres.md`](./queries-postgres.md) — `db.orm.<ns>.<Model>` + `db.sql.<ns>.<table>` |
| `@internal/mongo/runtime` | [`queries-mongo.md`](./queries-mongo.md) — `db.orm.<root>` + `db.query.from(...)` |
| `@internal/extension-supabase/runtime` | [`queries-postgres.md`](./queries-postgres.md) — a Supabase `RoleBoundDb` is a Postgres surface (`db.orm.<ns>.<Model>` + `db.sql.<ns>.<table>`); bind a role first via `references/supabase.md` |
Both targets share the contract and connection on one `db` value. Reach for the ORM first; drop to the lower-level lane when the ORM can't express the shape. Lane choice is local — one query function picks one lane, not the whole app.
**Do not mix target examples.** Postgres uses PascalCase model roots (`db.orm.public.User`) and `db.sql.public.user`; Mongo uses lowercased plural roots (`db.orm.users`) and `db.query.from('users')`. There is no `db.sql` on Mongo and no `db.query` SQL-builder equivalent on Postgres.
## Namespace-aware accessors
On Postgres, models and tables are **always** addressed by namespace coordinate — the storage namespace is the Postgres schema, and a model declared outside any `namespace { }` block lands in `public`:
- **ORM**: `db.orm.<namespace>.<Model>` — e.g. `db.orm.public.User`, `db.orm.auth.User`
- **SQL builder**: `db.sql.<namespace>.<table>` — e.g. `db.sql.public.user`, `db.sql.auth.users`
There is no flat `db.orm.User` / `db.sql.user` on the Postgres façade: `db.sql` is one facet per storage namespace and nothing else, and `db.orm` is keyed the same way (`examples/prisma-8-demo` uses `db.orm.public.User` and `db.sql.public.user` throughout). SQLite has no schemas, so its façade exposes the single unbound namespace directly — `db.orm.User` and `db.sql.user` are the SQLite spellings (`examples/prisma-8-demo-sqlite`). Mongo is keyed by collection storage name (`db.orm.users`).
See [`queries-postgres.md` § Namespace-aware accessors](./queries-postgres.md#namespace-aware-accessors) for a worked example.
## Consuming the result: `await`, `.toArray()`, or `for await`
Critical to get right early — on **both Postgres and Mongo**, `.all()` returns an **`AsyncIterableResult<Row>`**, which is *both* a `PromiseLike<Row[]>` and an `AsyncIterable<Row>`. That means three consumption forms all work, and the canonical one is the shortest:
```typescript
const users = await db.orm.public.User.select('id', 'email').all();
// ^? Row[] ← the Thenable resolves to a real array. This is the default idiom.
```
You do **not** need a `collect()` / `toArray()` helper — `await` is enough. Internally `await` invokes the result's `then(...)`, which buffers the rows into an array. Two equivalent alternatives exist for the cases where they read better:
```typescript
// `.toArray()` returns a genuine `Promise<Row[]>`. Reach for it only when
// something needs a real `Promise` and not merely a thenable: a slot typed
// `Promise<Row[]>` (an `AsyncIterableResult` has only `then`, not `catch` /
// `finally`, so it does not satisfy that annotation), or a runtime
// `instanceof Promise` check. Note that `await` and the `Promise.all` /
// `Promise.race` combinators all accept the thenable directly — those are
// NOT reasons to call `.toArray()`. Whenever you are just going to await it
// here, use `await ...all()` and skip `.toArray()`.
const rows: Promise<User[]> = db.orm.public.User.select('id', 'email').all().toArray();
// Iterate — decode and handle rows one at a time. Whether the raw rows are
// also fetched incrementally depends on the `cursor` option; see *Streaming* below.
for await (const user of db.orm.public.User.select('id', 'email').all()) {
process(user);
}
```
Two single-row shortcuts also exist on the result, in addition to the collection-level `.first()` and `.firstOrThrow()` (which issue `LIMIT 1` on Postgres):
```typescript
const user = await db.orm.public.User.where({ id }).all().first();
// ^? Row | null ← buffers, returns the first row or null. Issues no LIMIT.
const required = await db.orm.public.User.where({ id }).all().firstOrThrow();
// ^? Row ← buffers; throws `RUNTIME.NO_ROWS` if empty.
```
For genuine single-row reads, prefer the *collection*-level `.first()` or `.firstOrThrow()` (which add `LIMIT 1` to the SQL on Postgres) over `.all().first()` / `.all().firstOrThrow()` (which fetch all rows and discard the rest). Collection-level `.firstOrThrow()` runs the same query as `.first()`, returns the row without `null`, and rejects with `RUNTIME.NO_ROWS` when nothing matches: `await db.orm.public.User.firstOrThrow({ id })` on Postgres, `await db.orm.users.where({ email }).firstOrThrow()` on Mongo (no arguments there). The result-level helpers are for cases where you already need the full result and want the first row without an extra round-trip.
**The result is single-consumption.** Each `AsyncIterableResult` instance can be consumed once — by `await`, by `.toArray()`, or by `for await`. Trying to consume it a second time throws **`RUNTIME.ITERATOR_CONSUMED`**. The fix is almost always to store the array in a variable on first consumption and reuse the variable:
```typescript
// Bad — second await throws RUNTIME.ITERATOR_CONSUMED.
const result = db.orm.public.User.select('id', 'email').all();
const a = await result;
const b = await result;
// Good — buffer once, reuse the array.
const users = await db.orm.public.User.select('id', 'email').all();
const a = users;
const b = users;
```
If you've seen `collect(...)` / `toArray(...)` helpers in a codebase wrapping `.all()`, they're vestigial — `await` does the same thing for free. Remove them when you touch the surrounding code.
## Running queries from a short script
When the user is running a one-off `tsx my-script.ts` (not a long-lived server), call `await db.close()` at the end so the process exits cleanly — on Postgres the façade-owned pool keeps Node's event loop alive; on Mongo the façade-owned `MongoClient` does the same. See `references/runtime.md` § *Running as a script (teardown)* for the full pattern including `await using`.
```typescript
// src/scripts/seed.ts
import { db } from '../prisma/db';
// Postgres — PascalCase model root from contract
for (const u of users) {
await db.orm.public.User.create(u);
}
// Mongo — lowercased plural root from contract (e.g. users, not User)
// for (const u of users) {
// await db.orm.users.create(u);
// }
console.log('Seeded.');
await db.close();
```
## Running a plan and printing its SQL (SQL targets)
A plan from `db.sql`, `db.raw` or the ORM (`.prepared.all().plan`) runs on the client: `db.query(plan)` returns its rows as an `AsyncIterableResult`, and `db.execute(plan)` resolves `{ affectedRows }` with no rows. Inside a transaction use `tx.query` / `tx.execute`; they have the same types. On Supabase, call them on a role-bound client (`db.asAnon()`, `await db.asUser(jwt)`). `db.query` on an ORM plan yields the database rows, not the result the ORM builds from them. To run a plan, use `db.query` and `db.execute`, not `db.runtime()`. `db.runtime()` remains for runtime-level work: `connection()`, `close()`, `telemetry()`, and as the target of a prepared statement (`ps.query(db.runtime(), params)`).
`db.toSql(plan)` returns `{ sql, params }` and needs no database, so a script can import `db.ts` with no `DATABASE_URL` set and print what a query would send. `params` are the plan's values before codecs encode them for the driver. Middleware rewrites (`beforeCompile`) are not applied. A plan with `prepare` bind sites throws `RUNTIME.PREPARE_BIND_ON_ADHOC`. `postgresStatic()` and `sqliteStatic()` have `toSql` too.
```typescript
import { db } from '../src/prisma/db';
const plan = db.orm.public.Post.where({ published: true }).prepared.all().plan;
console.log(db.toSql(plan));
```
## Streaming
Every read terminal (`.all()`, and `db.query(plan)` for a SQL-builder plan) returns an `AsyncIterableResult`, so `for await` is always available. What it buys you depends on the `cursor` option, which `postgres()` and `postgresServerless()` (`@prisma/orm-postgres/serverless`) both accept:
- **Default (cursors off), on a client and on a connection:** the full result set is fetched from the server before the first row is yielded; only *decoding* happens per row. `for await` therefore does not bound the memory held by the raw result. For very large sets, paginate (`.limit()` / `.offset()`, or `.orderBy(...).cursor(...)`) instead.
- **`cursor` set in the options of `postgres()` or `postgresServerless()` (`{ batchSize: 100 }`; `{}` or `{ batchSize: undefined }` for batches of 100; a `batchSize` that is not a positive integer fails the factory call; there is no flag that turns cursors off):** the driver reads through a server-side cursor in batches of that size, so `for await` over `db.orm...all()` or `db.query(plan)` really does stream, and an early `break` stops reading. In a Worker, put the option only on a second serverless client that the streaming paths open their connections from, and keep the serverless client every other path uses without it. On a connection with cursors on, finish or `break` the `for await` loop before sending another query through `db`: the cursor holds the connection's only database connection until the loop ends, so a query inside the loop waits forever. On a client, the same query takes another database connection from the pool. Behind Cloudflare Hyperdrive, reads with cursors on hang, so the streaming paths hang there and the other paths do not.
There is no `.stream()` method on a client or on a connection.
## Prepared statements (Postgres, SQLite)
`db.prepare(declaration, (params) => plan)` builds a statement once and binds it per call. The declaration names each parameter's codec (`{ email: 'pg/text@1' }`); the callback receives typed `params`, builds the plan with `db.sql` (or an ORM `.prepared` description), and returns it. A row-returning plan gives a `PreparedStatement` you run with `ps.query(target, params)`; a plan whose result is an affected count gives a `PreparedExecution` you run with `ps.execute(target, params)`. A prepared statement runs on what it is given: the runtime, a connection, a transaction's `tx`, or a Supabase role-bound client, which runs it under its role. `ps.query(tx, params)` runs inside the transaction. Preparing sends nothing to the database. Inside a transaction, `tx.prepare(...)` prepares without the outer client. Declaring a parameter the plan never references throws `RUNTIME.PREPARE_UNUSED_PARAM`. `runtime.prepare(declaration, (params) => plan)` is the same thing on a `Runtime`.
```typescript
// examples/prisma-8-demo/src/queries/get-user-by-email-prepared.ts
const ps = await db.prepare({ email: 'pg/text@1' }, (params) =>
db.sql.public.user
.select('id', 'email', 'displayName', 'createdAt', 'kind')
.where((f, fns) => fns.eq(f.email, params.email))
.limit(1)
.build(),
);
const runtime = db.runtime();
for (const email of emails) {
const rows = await ps.query(runtime, { email });
}
```
## Naming model and result types
The model is the whole row plus its relations, and each related model carries its own relations in turn, so no query returns a value of the model type. A query returns the fields it fetched. The default fetch returns `Scalars<Model>`, the model without relations: `db.orm.public.User.first()` returns `Scalars<Model> | null`, and `db.orm.public.User.all()` returns `Scalars<Model>[]` (or its async iterable). Four types cover every case, and none needs a client in scope:
- `Models.<ns>_<Model>` (from `contract.d.ts`) — every scalar field and every relation. On SQLite, which has no schemas, the name is bare: `Models.User` and `typeof models.User`. On Postgres the schema is part of the name: `Models.public_User`, or `typeof models.public.User` by dotted access — a model declared outside any `namespace { }` block is in `public`, so that is also its name. Mongo names its models the same way. A polymorphic base also emits one member per variant and an `Any<Base>` union (`Models.public_AnyTask`).
- `Scalars<M>` — the model without relations; what a default fetch returns. Distributes over unions, so `Scalars<Models.public_AnyTask>` is the union of variant rows.
- `Shape<M, Spec>` — a data structure derived from the model, for declaring an endpoint's response type once and having the compiler check the body at the `return`. At every level of `Spec`: `'+'` is a union of scalar and relation names to keep (a relation named there comes with all of its scalars and none of its relations; the scalars are narrowed only when `'+'` names a scalar, so `'+': 'posts'` alone is every scalar plus posts); `'-'` is a union of scalar names to drop; `'+'` naming a scalar beside `'-'` is a compile error, while `{ '-': 'passwordHash'; '+': 'posts' }` is every scalar but the hash plus posts; any other key is a relation whose value is a nested spec that narrows the related model. Relations are absent unless asked for; `X[]`, `X | null`, or `X` comes from the model. Wrong names, a relation in `'-'`, a non-object relation value, and a relation both in `'+'` and as a key are compile errors. No `where`/`orderBy`/`limit`; compose extras with TypeScript (`Shape<M> & { postCount: number }`).
- `ResultType<typeof query>` — the row of any ORM collection value (plain, `.include()`, `.select()`, `.variant()`), and of SQL lane plans. Bind the query to a name first; `typeof` needs a value. `.variant()` takes the variant's discriminator value, not its model name: `db.orm.public.Task.variant('bug')` for `@@base(Task, "bug")`. Call it once, on the base collection; a second `.variant()` on a variant collection is refused.
```ts
import type { models, Models } from './prisma/contract';
import type { Scalars, Shape } from '@prisma/orm-postgres/family-contract/types';
import type { ResultType } from '@prisma/orm-postgres/components/runtime';
type User = typeof models.public.User; // same type as Models.public_User
type UserRow = ResultType<typeof db.orm.public.User>; // Scalars<Models.public_User>
const usersWithTasks = () => db.orm.public.User.include('tasks');
type UserWithTasks = ResultType<ReturnType<typeof usersWithTasks>>; // Shape<Models.public_User, { '+': 'tasks' }>
const projected = db.orm.public.User.select('id');
type UserId = ResultType<typeof projected>; // { id: number }
// An endpoint declares its response from the model; the query behind it is an implementation detail.
type UserResponse = Shape<Models.public_User, { '-': 'email'; posts: { '+': 'id' | 'title' | 'tags' } }>;
async function getUserWithPosts(userId: Models.public_User['id']): Promise<UserResponse | null> {
const user = await db.orm.public.User.where({ id: userId })
.include('posts', (posts) => posts.include('tags'))
.first();
if (user === null) return null;
const { email: _email, ...rest } = user;
return { ...rest, posts: user.posts.map(({ id, title, tags }) => ({ id, title, tags })) };
}
// @ts-expect-error 'nope' is not a relation of User
type Bad = Shape<Models.public_User, { nope: {} }>;
```
On Mongo the imports are `@prisma/orm-mongo/family-contract/types` and `@prisma/orm-mongo/components/runtime`; embedded documents are fields, so they stay in `Scalars`. To name a model plus some of its relations, write `Shape<Models.public_User, { '+': 'id' | 'posts' }>`, not `Pick<Models.public_User, 'id' | 'posts'>`: `Pick` demands fully loaded nested posts that no query returns. Input types (`CreateInput<Contract, 'User'>`, `MutationUpdateInput<Contract, 'User'>`, `ShorthandWhereFilter<Contract, 'public', 'User'>`) come from `@prisma/orm-postgres/orm-client`.
Coming from Prisma 7: `Prisma.User` → `Models.public_User` (note: now carries relations; the scalars-only row is `Scalars<Models.public_User>`); `Prisma.UserGetPayload<{ include: { posts: true } }>` → `Shape<Models.public_User, { '+': 'posts' }>`; `Prisma.UserGetPayload<{ select: { id: true; posts: { select: { title: true } } } }>` → `Shape<Models.public_User, { '+': 'id'; posts: { '+': 'title' } }>`; `Prisma.UserCreateInput` → `CreateInput<Contract, 'User'>`; `Awaited<ReturnType<typeof fn>>` → `ResultType<typeof query>`.
## Ordering by a relation (SQL targets)
Inside an ORM `.orderBy(...)`: a to-one relation's field (`(p) => p.author.name.asc()`), a to-many relation's count (`(u) => u.posts.count().desc()`), a filtered count (`(u) => u.posts.count((p) => p.views.gt(10)).desc()`), and null placement on any `.asc()` / `.desc()` (`(u) => u.invitedBy.name.desc({ nulls: 'last' })`). One hop. `cursor()` rejects these orders with `ORM.ARGUMENT_INVALID`. Details in [`queries-postgres.md`](./queries-postgres.md). Mongo's `.orderBy({ field: 1 | -1 })` has neither.
## Common Pitfalls (cross-target)
1. **Using Postgres examples on a Mongo project (or vice versa).** Check `db.ts` and load the correct target guide ([`queries-postgres.md`](./queries-postgres.md) or [`queries-mongo.md`](./queries-mongo.md)).
2. **Writing a `collect()` / `toArray()` helper to convert `.all()` to an array.** `.all()` returns an `AsyncIterableResult<Row>` which *is* a `PromiseLike<Row[]>` — `await collection.all()` directly yields `Row[]`. See *Consuming the result* above.
3. **Consuming an `AsyncIterableResult` twice.** Each result is single-use. The second consumer throws `RUNTIME.ITERATOR_CONSUMED`. Buffer once into a variable and reuse the variable.
Target-specific pitfalls live in the per-target guides.
## What Prisma 8 doesn't do yet
- **Many-to-many relations work through the junction.** `.include('tags', (tag) => tag.select(...))` traverses an N:M relation's `through` table, and nested `create` / `connect` / `disconnect` on an N:M relation write the junction rows for you (`examples/prisma-8-demo/src/orm-client/get-post-tags.ts`, `create-post-with-tags.ts`). The one refusal: a junction with required payload columns the relation API cannot populate throws `ORM.RELATION_MUTATION_UNSUPPORTED` — write that junction directly or use the SQL builder.
- **Ordering grouped aggregates by an aggregate alias (Postgres).** `db.orm.<ns>.<Model>.groupBy(...)` supports `.orderBy(...)` on group keys plus `.limit(...)` / `.offset(...)`, but the grouped collection cannot order by an aggregate alias such as `SUM(amount)`. A "top-N groups by SUM" query therefore falls back to JS-side sort + slice over the full grouped result, which is fine at small cardinalities and bad at scale. Workarounds: (a) drop to `db.sql.<ns>.<table>` and write the `GROUP BY` + `ORDER BY` + `LIMIT` against the aggregated table directly; (b) live with the JS-side sort/slice if the grouped cardinality is bounded. File a feature request via `references/feedback.md` if this is hitting you in production.
- **A raw-SQL lane.** This one exists. Write whole-query raw SQL through the client's raw lane: ``db.raw.sql`SELECT ...`.returnsRow({ ... }).build()`` for rows, or `.affectedCount()` for a mutation's row count. Each declared column names the codec that decodes it, so the row stays typed. For an expression fragment inside a builder query, use `fns.raw` in a `.select(...)` callback instead.
- **TypedSQL (`.sql` files compiled into typed callables).** Not implemented. For a repeated query, use `db.prepare(...)` (see *Prepared statements* above) or a function that returns the built plan and `db.query(plan)` at the call site. If you want a `.sql`-file compile path, file a feature request via `references/feedback.md`.
- **`EXPLAIN` / query-plan inspection.** Prisma 8 does not expose an `.explain()` method. Workaround: connect a `pg.Pool` you control via the runtime's `pg:` binding (see `references/runtime.md`) and issue `EXPLAIN ANALYZE` through it. If you want a first-class plan-inspection surface, file a feature request via `references/feedback.md`.
- **Multi-statement batching (Prisma-7-style `db.$transaction([call1, call2])`).** Prisma 8 runs each call sequentially. Workaround: wrap atomically-related work in `db.transaction(async (tx) => { ... })` on Postgres. If you want batch-as-array semantics, file a feature request via `references/feedback.md`.
- **Mongo façade transactions.** `@internal/mongo/runtime` does not expose `db.transaction(...)`. Multi-document atomicity is not yet wrapped in the Prisma 8 Mongo façade. Workaround: use the MongoDB driver's session API directly if you control the client binding (`mongoClient:` option). File a feature request via `references/feedback.md` if you need a first-class façade surface.
- **Mongo ORM aggregates.** No `.aggregate(...)` / `.groupBy(...)` on `db.orm.<root>`. Workaround: express aggregations through `db.query.from(...).group(...).build()` and `runtime.query(plan)`.
- **Mongo filter helpers on the façade.** Rich filters (`.in`, ranges, boolean composition) currently import from `@prisma/orm-mongo/query-ast/execution` (`MongoFieldFilter`, etc.) — not re-exported on `@internal/mongo/runtime`. Workaround: use object equality `.where({ field: value })` where possible; import from the internal package only when necessary. The façade does not re-export them yet.
- **Automatic N+1 detection.** Prisma 8 does not warn when an `.include(...)` is missing. Workaround: be deliberate about `.include(...)` in code review; the `lints` middleware (see `references/runtime.md`) catches the more common authoring slips (missing `WHERE` on a `DELETE` / `UPDATE`, missing `LIMIT` on a `SELECT`).
## Reference Files
This skill is split for selective loading. Target-specific reference paths live in the per-target guides:
- **Postgres** — [`queries-postgres.md` § Reference Files](./queries-postgres.md#reference-files)
- **Mongo** — [`queries-mongo.md` § Reference Files](./queries-mongo.md#reference-files)
## Checklist
- [ ] Confirmed the active target from `db.ts` and loaded the matching guide ([`queries-postgres.md`](./queries-postgres.md) or [`queries-mongo.md`](./queries-mongo.md)).
- [ ] On Postgres, used `db.orm.<ns>.<Model>` / `db.sql.<ns>.<table>` coordinates (usually `public`) — not a flat `db.orm.User`, which exists only on SQLite.
- [ ] Chose the right lane (ORM by default; lower-level builder for shapes the ORM doesn't express).
- [ ] Used `.first()` / `.first({ pk })` (Postgres) or `.where({ ... }).first()` (Mongo) for single-row reads — not `.all()`.
- [ ] Consumed `.all()` with plain `await` (not a `collect()` / `toArray()` helper). Used `for await` only when per-row handling is actually wanted — and did not promise it bounds memory on the long-lived façade — and never iterated the same result twice.
- [ ] Did NOT use `db.sql` on a Mongo project or `db.query` where the Postgres SQL builder is meant.
- [ ] Completed the target-specific checklist in the loaded guide.
references/quickstart.md
# Prisma 8 — Quickstart (Adoption)
> **Edit your data contract. Prisma handles the rest.**
This skill takes the user from zero (or near-zero) to a first working query against Prisma 8. Three paths — and they all converge on the same first arc: **connect → write → read**. Schema editing comes *after* the first arc, not before.
- **First-touch orientation** — the user has arrived at a Prisma 8 project for the first time (a scaffold tool like `npx createprisma` dropped them in, they cloned a teammate's repo, or they ran `prisma orm init` themselves and now want to make their first move) and they're asking *"what can I do with Prisma 8?"*, *"where do I start?"*, or *"what's next?"*. The goal is to anchor them on the contract, get them connected to a database, round-trip one row, and let further commands surface organically.
- **Greenfield** — new project, fresh database. User runs `prisma orm init` themselves. `init` seeds a starter contract with a sample model, so the path joins the first-touch orientation arc as soon as the database is initialised.
- **Brownfield-DB** — existing database, no contract yet. Infer the contract from the database with `contract infer`, sign the marker with `db sign`, then write queries against one of the existing tables.
This skill does **not** cover migrating from another ORM (Drizzle, Prisma 6/7, Sequelize, TypeORM, Kysely, Knex, raw drivers). Those are separately-installable skills.
## When to Use
- User asks *"what can I do with Prisma 8?"*, *"what can I do next with Prisma?"*, *"where do I start?"*, *"what should I do first?"* — and a PN project already exists on disk. **First-touch orientation** path below.
- User just ran `createprisma` (or equivalent scaffold tool) and is asking what to do next. **First-touch orientation** path.
- User is starting a new project and wants to use Prisma 8. **Greenfield** path.
- User has an existing database (no PN contract) and wants to introduce PN. **Brownfield-DB** path.
- User typed *"prisma orm init"*, *"get started with PN"*, *"set up PN"*, *"how do I scaffold a project"*. **Greenfield** path.
- User says *"I have an existing Postgres/Mongo, how do I start using PN?"*. **Brownfield-DB** path.
## When Not to Use
- User already has a PN project and wants to add a model → `references/contract.md`.
- User wants to migrate FROM a specific ORM → install `@internal/migrate-from-<orm>-skill` (separate).
- User wants to wire `db.ts` in a project that already has a contract → `references/runtime.md`.
- User wants to integrate Prisma 8 with a build tool (Vite plugin, Next.js, …) → `references/build.md`.
## Key Concepts
- **Contract**: the data model. Authored as `contract.prisma` (PSL, the canonical surface) or `contract.ts` (TypeScript builder). The framework reads it and emits two artefacts: `contract.json` (runtime IR) and `contract.d.ts` (types).
- **Target**: the backing store. Today: `postgres` or `mongodb`. Picked at `init` time; baked into the `@internal/<target>` façade the scaffold imports from.
- **Authoring mode**: how you write the contract. `psl` (Prisma Schema Language, default) or `typescript` (programmatic builder, optionally paired with the Vite plugin for auto-emit during `vite dev` — see `references/build.md`).
- **Façade packages.** The scaffold installs exactly one façade per target — `@internal/postgres` (or `@internal/mongo`). User code imports from façade subpaths (`@internal/postgres/config`, `@internal/postgres/runtime`, `@internal/postgres/static`, `@internal/postgres/contract-builder`). The façade bakes in the family / target / adapter / driver wiring; never reach past it. See `references/contract.md` for the full list.
- **`db.ts`**: the runtime entry point. Lives next to the contract source at `src/prisma/db.ts`. Imports the contract artefacts and exports a `db` value the rest of the app uses.
- **Marker**: a row in the `prisma_contract.marker` table (Postgres) or a document in the `_prisma_migrations` collection (Mongo) that records the contract hash per contract space. Lets PN detect drift between contract and live DB. Created by `db init` (greenfield / first-touch orientation) or `db sign` (brownfield).
### Canonical on-disk layout
Every application that consumes Prisma 8 uses the same shape:
```text
<app-root>/
├── prisma.config.ts ← project config at repo root
├── src/
│ └── prisma/
│ ├── contract.prisma ← (or contract.ts) — schema source you author
│ ├── contract.json ← emitted by `contract emit` — do not edit
│ ├── contract.d.ts ← emitted by `contract emit` — do not edit
│ └── db.ts ← runtime entry; the rest of `src/` imports from here
└── migrations/
├── snapshots/ ← content-addressed contract store, shared across spaces
│ └── <hex>/
│ ├── contract.json
│ └── contract.d.ts
└── app/ ← created on first `migration plan` / `db init`
├── refs/db.json
└── <timestamp>_<slug>/
├── migration.json
├── ops.json
└── migration.ts
```
Three things to internalise:
- **`src/prisma/` is the home for the contract** — source + emitted artefacts + `db.ts` all colocated. The rest of `src/` imports from `./prisma/db` (or `../prisma/db`, depending on file depth).
- **`migrations/app/`** — the `app/` segment is the consuming application's space-id. Extensions you depend on get sibling directories under `migrations/` (one per extension contract-space), but you don't write into those — only the `app/` subtree is your migrations.
- **`prisma.config.ts` lives at the repo root**, not under `src/`. Every command resolves paths relative to the config's directory.
**Contributors building extension packages or aggregate-root monorepo packages use a different layout** — `src/contract.{prisma,ts}` (no `prisma/` subdir) + `migrations/<timestamp>_<slug>/` (no `app/` segment). That distinction is intentional; see `references/contract.md` for which path applies to you.
`prisma orm init` scaffolds this layout by default: the contract at `src/prisma/contract.prisma` (or `.ts`) and `db.ts` beside it. A project scaffolded by an older init may have a top-level `prisma/` directory instead — read the `contract` path in `prisma.config.ts` rather than assuming either.
## Your first arc — connect, write, read
All three paths in this skill converge here. Once the project is scaffolded and the database is reachable, the first move is **always** the same: connect, write a row, read it back, against whatever model the contract already declares. Don't touch the contract source on this first move — extend it later, after the round-trip works.
Write the snippet in a fresh file directly under `src/` (e.g. `src/first-arc.ts`) so the relative import resolves to one level deep:
```typescript
// src/first-arc.ts
import 'dotenv/config';
import { db } from './prisma/db';
// Write a row against the starter model. Adapt the field names to whatever
// model your contract source actually declares — read it first.
await db.orm.public.User.create({ email: 'alice@example.com' });
// Read it back.
const users = await db.orm.public.User.select('id', 'email').all();
console.log(users);
```
If that prints `[{ id: 1, email: 'alice@example.com' }]`, the project is wired end-to-end and the user has crossed from *"I have a project"* to *"I'm building."*
`db.orm.<ns>.<Model>` is the default ORM lane — model-shaped, fully typed against the contract, lazily connects to the database on first use (it picks up `DATABASE_URL` from `.env` via the runtime's `dotenv/config`-loaded environment). The deeper `references/queries.md` reference covers the rest of the supported surface (filters, joins, transactions, the SQL builder, raw SQL via `db.raw.sql`, prepared statements) when the user is ready — and names the gaps (TypedSQL is not available).
> **SQLite target:** `prisma orm init` scaffolds only `postgres` and `mongodb`; a SQLite project is wired by hand with the `@internal/sqlite` façade (`references/runtime.md` § *Switch between Postgres, SQLite, and Mongo*; `examples/prisma-8-demo-sqlite`). SQLite has no schemas, so that façade exposes the unbound namespace directly — write `db.orm.User` rather than `db.orm.public.User`.
>
> **Mongo target:** the snippet above is SQL-target shape. On `@internal/mongo`, `db.orm` is keyed by the collection's storage name (`@@map(...)`, or the lowercased model name if no `@@map`), so the same arc reads `await db.orm.users.create(...)` / `await db.orm.users.select('id', 'email').all()` — not `db.orm.public.User`. Full rule and rewrite recipe in `references/queries.md` § *MongoDB ORM addressing*.
**Prerequisites for the arc to work.** All three paths leave these in place by the time you reach the arc:
- `prisma.config.ts` exists at the repo root and declares the target + contract source (typically `src/prisma/contract.prisma` or `src/prisma/contract.ts`).
- The contract source exists at `src/prisma/contract.{prisma,ts}` (a starter model from `init`, or the inferred contract from `contract infer`, or whatever the bootstrap tool generated).
- `src/prisma/db.ts` exists and instantiates the runtime with the emitted contract.
- `DATABASE_URL` is set in `.env` (or wherever the runtime's config tells it to look).
- The database has been initialised (`db init`) or marker-signed (`db sign`), so the marker row exists and the schema matches the contract.
The three workflows below each describe how their path gets the user to that state. After that, the arc above is the same.
## Workflow — First-touch orientation
Triggers: *"what can I do with Prisma 8?"*, *"what can I do next with Prisma?"*, *"where do I start?"*, *"I just ran createprisma"*, *"what's next?"*, or any close variant — paired with a PN project already on disk (scaffolded by `createprisma`, by `prisma orm init`, by a teammate, however).
The user's high-level intent is *"I want to be running an application against my database, against this thing called Prisma 8."* The job of this workflow is to anchor them on the contract, get one round-trip working, and let further commands surface organically as their next move requires them. **It is orientation, not a tour, not a feature inventory, not a syllabus.**
### Concept — what to communicate first
Prisma 8 is contract-first. Everything the framework does — query types, migrations, runtime types, drift detection — flows from a single source of truth: the **contract**. The contract describes the user's application's data model. The framework reads it; the framework derives the rest. Lead with this.
The first response to *"what can I do with Prisma 8?"* names the contract path, frames its role in one sentence, and then steers toward getting the user's application running. Don't open with a feature inventory. Don't open with a list of commands. Open with: *"Your contract is at `<path>`. It describes your application — your query types, migrations, and runtime types all flow from it. Let's get you connected to a database so your app can actually run against it."*
The first **arc** — once oriented — is **connect → write → read**. Not edit-the-contract-first, not plan-a-migration-first. The user's win is *I have application code running against my database*.
### Step 1 — Read the project, name the contract
Before saying anything specific to the user, read:
- `prisma.config.ts` at the repo root — what target (`postgres` / `mongodb`) is wired, what `contract:` path it declares, what extensions are installed.
- The contract source the config declares (canonically `src/prisma/contract.prisma` or `src/prisma/contract.ts`; a project scaffolded by an older init may have it at `prisma/contract.{prisma,ts}` instead — check the `contract` field of the config) — what starter models, if any, exist.
- `src/prisma/db.ts` (next to the contract) — the runtime entry point.
- `.env` / `.env.example` — is `DATABASE_URL` set, or only the example?
- Optionally `pnpm prisma db verify` — does the live DB match the contract? (Exit `4` with findings means drift or no marker; `2` means it could not run.)
Then **say the contract path back to the user, with its role attached**. Something like: *"Your contract is at `src/prisma/contract.prisma`, and it currently declares a `User` model. The contract describes your app — every query type, migration, and runtime type the framework gives you flows from this file. Let's get your app connected to a database next."* The exact wording is up to the agent; what matters is that the user leaves the first response knowing *where the contract is* and *that it is the source of truth*.
### Step 2 — Get the user's app connected and round-tripping
The motivation is *"so your app can actually run against your database"*, not *"so the prerequisite checklist passes"*. The mechanics depend on what's already in place from Step 1:
- **Everything already wired.** Go straight to writing and reading a row (see *Your first arc — connect, write, read* above). Adapt the snippet to whatever model the contract declares.
- **`DATABASE_URL` not set.** Have the user set it in `.env` (not in `prisma.config.ts` — see Pitfall 5). Then `pnpm prisma db init` to apply the current contract to that database and write the marker row. Now the app can connect.
- **Database is connectable but not yet aware of the contract** (marker row missing; `db verify` reports drift). Run `pnpm prisma db init`. (`db update` is the alternative for quick dev cycles — it's looser, doesn't write a migration history, and is what users reach for when they want to iterate on the schema fast. Mention it if the user asks how to make schema changes flow to the DB; don't pre-explain it.)
- **Contract is empty** (bootstrap left the source blank). Add **one** model with **two** fields (e.g. `User { id, email }`), `pnpm prisma contract emit`, then `pnpm prisma db init`. Minimal — get the round-trip working, *then* extend.
The user encounters `db init` (and optionally `db update`, `contract emit`) here because they're the commands their current move *requires*. They learn what those commands are by using them.
### Step 3 — Round-trip a row
Run the snippet from *Your first arc — connect, write, read* above against whatever model the contract declares. When it prints the row back, the user has crossed from *"I have a project"* to *"my app runs against my database"*. That's the win.
### Step 4 — Hand off to the next move
Now ask the user what they want to build. Route to the skill that owns that move:
- More queries (filters, joins, transactions) → `references/queries.md`.
- Add a model, change a field, add a relation → `references/contract.md`. They'll touch `contract emit` and `db update` (or `migration plan` + `db migrate`) as part of that workflow.
- Middleware, environment config, multiple targets → `references/runtime.md`.
- Vite / Next.js / dev-server integration → `references/build.md`.
- They want a fuller toolbelt overview at this point — *Commands you'll use day-to-day* below is the one-glance summary.
### Anti-patterns on this path
- **Leading with a feature tour or capability inventory.** The user asked what they can *do*. Get them doing it.
- **Listing commands before any have been used.** Commands belong to specific moves; surface them when the move requires them.
- **Diving into migration concepts before one query has run.** Migrations exist; their value lands later.
- **Adding several models in one go.** Add one, get one query green, then iterate.
- **Walking the user through `prisma.config.ts` keys.** The scaffold's defaults are correct; revisit when the user needs to change something.
- **Skipping the contract framing.** Even one line — *"your contract is at `<path>`, it's the source of truth"* — anchors the user; without it, the rest of the workflow lands as disconnected ceremony.
## Workflow — Greenfield
The concept: `prisma orm init` is one CLI command that scaffolds config, schema, runtime, dependencies, and the contract emit step. It operates on the current working directory — there is no positional project-name argument. Make the directory, `cd` in, then run init.
```bash
mkdir my-app && cd my-app
pnpm init # if no package.json yet
pnpm dlx prisma@latest orm init # interactive
# or non-interactive (CI / agent runs):
pnpm dlx prisma@latest orm init --yes --target postgres --authoring psl
```
> **Telemetry is opt-out.** The CLI collects anonymous usage data by default. Every command — including `init` — prints a one-time notice to **stderr** on first use, then sends; there is no interactive consent prompt. Opt out anytime by running `prisma telemetry disable`, or with `DO_NOT_TRACK=1` or `PRISMA_DISABLE_TELEMETRY=1`. The command stores `"enableTelemetry": false` in your user config for you (the CLI's per-user config dir, **not** `prisma.config.ts`). Run `prisma telemetry status` to see what's currently in effect. This is relevant for agent-driven runs — the CLI records that an agent invoked it. What's collected, the per-user config path, and how to fully reset are documented in `docs/Telemetry.md`.
The flags `init` accepts (run `prisma orm init --help` for the source of truth):
- `--target <db>` — `postgres` or `mongodb`.
- `--authoring <style>` — `psl` or `typescript`.
- `--schema-path <path>` — where to write the starter schema. Defaults to `src/prisma/contract.prisma` (or `src/prisma/contract.ts` with `--authoring typescript`), the canonical layout above. The extension must agree with `--authoring` (`CLI.INIT_AUTHORING_SCHEMA_PATH_MISMATCH` otherwise).
- `--confirm <directory name>` — grant the reinit consent non-interactively. Re-running init in a scaffolded directory asks you to type the directory name back before it overwrites; non-interactive runs pass the name with this flag instead (`--yes` does not grant it).
- `--write-env` — also write `.env` (default writes only `.env.example`; `.env` stays under your control).
- `--probe-db` — connect to `DATABASE_URL` once and check the server version against the target's minimum.
- `--strict-probe` — fail init if the probe fails (errors without `--probe-db`).
- `--skip-install` — skip dependency install + initial contract emit.
- `--keep-previous-facade` — when re-running init to switch targets, keep the previous target package in `package.json`.
`init` does not install agent skills and has no `--skip-skills` flag: the `prisma-8` skill ships inside the `@prisma/orm-*` package the project installs, and the family-level `prisma init` / `prisma skills sync` commands copy it into the agent harness directories.
`init` writes (when it runs cleanly):
- `prisma.config.ts` at the project root (envelope form — see `references/contract.md`).
- The contract source at `--schema-path` (`src/prisma/contract.prisma` by default).
- `db.ts` in the same directory as the contract source.
- `prisma-8.md` — a human quick-reference.
- `.env.example` (and `.env` if `--write-env`).
- Updates `package.json` (deps + a `contract:emit` script) and `tsconfig.json` (required compiler options).
- Installs deps and runs `prisma contract emit` once. If the install or the emit fails, the scaffold is still on disk and init exits `4` (`CLI.INIT_INSTALL_FAILED`) or `5` (`CLI.INIT_EMIT_FAILED`) with the step to re-run.
After init succeeds, the path converges on *Your first arc — connect, write, read* above. `init` has already seeded a starter contract with `User` and `Post` models (with a relation between them) and run `contract emit` once; the only remaining prerequisites are setting `DATABASE_URL` and initialising the database. Two commands:
1. Set `DATABASE_URL` in `.env` (copy from `.env.example`).
2. Initialise the database: `pnpm prisma db init`. Creates tables, indexes, constraints, and writes the marker row — using the starter contract `init` generated.
Then run the snippet from *Your first arc* above against the `User` model. When the user is ready to extend the contract — add more models, change fields, add relations — chain to `references/contract.md`. For more queries, chain to `references/queries.md`.
**Why this is queries-first, not schema-editing-first.** `init` ships with `User` and `Post` on purpose: the user shouldn't have to design a schema to prove their setup works. Extending the contract is the next move *after* the first arc lands, not part of getting there. If the user asks you to skip straight to *"add a Comment model"* — sure, do that — but get one query green against `User` or `Post` first if there's any doubt the project is wired correctly.
## Workflow — Brownfield-DB (existing database, no contract)
The concept: against an existing database with no PN contract, `contract infer` walks the live schema (tables, columns, indexes — including expression and partial ones — constraints, and RLS enablement + policies) and writes a PSL contract that describes it. Where authoring would generate a CHECK constraint the database does not carry (the element-non-null check on a list column), infer writes the list with nullable elements (`String?[]`), which derives no such check, so the contract declares exactly what the database enforces. The reverse gap is closed too: a hand-written CHECK constraint the database enforces that authoring would never have generated comes back as `@@check(expression: <reprint>, map: "<name>")`, so it is a declared object from the first pull instead of an invisible extra a later destructive plan could drop. That inferred check warns (`PN_EXACT_NAME_BODY_COMPARISON`) the next time you run `contract emit` — expected, not a defect: the warning fires on any `map:` body regardless of who wrote it, and the comparison stays sound because both sides are Postgres's own reprint. Columns come back as the convenience types with their defaults, and only the arguments that differ: `int2` as `Int(storage: int2)`, `numeric(10,2)` as `Decimal(precision: 10, scale: 2)`, `varchar(12)` as `String(storage: varchar, length: 12)`, `timestamptz(3)` as `DateTime` (a JavaScript `Date`), a `timestamptz` with no declared precision as `DateTime(precision: 6)`, `timestamp(3)` as `DateTime(storage: timestamp)`, `date` as `Date`, `time` as `Time` and `interval` as `Duration` (ISO strings). A `Date` holds milliseconds, so a timestamp column whose values use microseconds reads with them dropped: change such a field to `DateTime(as: temporal)` or `DateTime(as: string)` to keep them. The result is a *starting point*, not the final contract — review and clean it up, then `db sign` to record the current contract hash as the marker (instead of letting `db init` try to recreate the schema from scratch). By default `db sign` also sets the `db` ref and stores the contract snapshot, so the next `migration plan` chains from the adopted schema — even when the database is named with `--db`. `--advance-ref <name>` writes another ref instead and `--no-advance-ref` writes none; in either case the next plan needs `--from` or a `db` ref you set yourself.
```bash
mkdir my-app && cd my-app
pnpm init
pnpm dlx prisma@latest orm init --yes --target postgres --authoring psl
# scaffold lands at src/prisma/; you'll overwrite the starter schema below
```
Then, with `DATABASE_URL` set in `.env`:
```bash
pnpm prisma contract infer --db "$DATABASE_URL" --output src/prisma/contract.prisma
```
(Note: the flag is `--output`, not `--out`. Run `prisma contract infer --help` for the full surface.)
The agent should pause here and read the inferred PSL. Symptoms a re-author pass is needed:
- Tables PN couldn't categorise (e.g. legacy linking tables you could express as relations).
- Columns where PN's type guess is wrong (e.g. `String` where you want an extension type like `pgvector.Vector(length: 1536)`).
- Missing `@unique` / `@index` hints PN couldn't see.
- Field names you'd prefer to alias.
Then re-emit and sign:
```bash
pnpm prisma contract emit
pnpm prisma db sign # writes the marker and sets the db ref
pnpm prisma db verify # exit 0 immediately after a pull; exit 4 with findings if the DB drifts later
```
Then run the snippet from *Your first arc — connect, write, read* above, using one of your existing tables in place of the starter model. The arc is the same; only the path that got you there differs.
## Commands you'll use day-to-day
A reference table — not a script to recite at the user. Commands surface in the workflow above as the user's next move requires them; this table is here for the moment the user asks for a wider view (typically after the first round-trip), and as a one-glance summary anyone newly oriented to Prisma 8 can scan. For flag-level detail, run `<command> --help`; the help output is the source of truth.
| What you want to do | Command | Deeper skill |
|---|---|---|
| Apply the current contract to the DB the first time | `prisma db init` | this skill |
| Re-emit `contract.json` + `contract.d.ts` after editing the contract source | `prisma contract emit` | `references/contract.md` |
| Move a database whose history does not need to be kept to the contract (no migration written) | `prisma db update` | `references/migrations.md` |
| Plan a migration from a contract diff | `prisma migration plan --name <slug>` | `references/migrations.md` |
| Apply pending migrations | `prisma db migrate` | `references/migrations.md` |
| Inspect the live database | `prisma db schema` | `references/debug.md` |
| Confirm the DB matches the contract (drift check) | `prisma db verify` | `references/debug.md` |
| Bring an existing DB into a PN contract | `prisma contract infer --db "$DATABASE_URL"` | this skill (brownfield) |
| Decode a structured error envelope | (read the `code` / `why` / `fix` fields) | `references/debug.md` |
| Report a bug or request a feature | (file via the feedback skill) | `references/feedback.md` |
## Decision — PSL vs TypeScript authoring
- **PSL** (`contract.prisma`) — the default. Concise, declarative, familiar to anyone who has used Prisma. Recommended for most projects.
- **TypeScript** (`contract.ts`) — a programmatic builder. Use when the contract is genuinely computed (multi-tenant per-tenant variants), when you reuse contract fragments across files, or when an extension requires constructs PSL doesn't yet express (e.g. pgvector's parameterised storage-type registration). Pairs with the Vite plugin from `references/build.md` for auto-emit on save.
Switch authoring later by re-running `prisma orm init` in the same directory. The init flow detects the existing scaffold and prompts to reinit (non-interactive runs grant the consent with `--confirm <directory name>`). Existing contract content is *not* automatically translated — you'll re-author by hand in the target language.
## Common Pitfalls
1. **Running `prisma orm init <project-name>` with a positional argument.** `init` operates on the current working directory; there is no positional project-name argument. `mkdir foo && cd foo && pnpm dlx prisma@latest orm init`.
2. **`init` doesn't connect to your database.** It only scaffolds files and installs dependencies (and runs the initial `contract emit`). You connect with `db init` / `db update` / `db migrate`. If `init` succeeds and queries fail, the issue is `DATABASE_URL`, not `init`.
3. **Treating inferred PSL as the final contract.** `contract infer` produces a starting point. Don't `db sign` against a contract you haven't read.
4. **Forgetting to emit after editing the contract.** The contract artefacts (`contract.json`, `contract.d.ts`) are stale until you run `contract emit`. If the type-checker says a model "doesn't exist", you skipped emit.
5. **Setting `DATABASE_URL` in `prisma.config.ts` instead of `.env`.** Nothing reads `.env` on its own: the scaffolded `prisma.config.ts` starts with `import 'dotenv/config'`, and that import is what loads `.env` into `process.env` before the config (and the CLI running it) reads `process.env['DATABASE_URL']`. Keep the import; a config without it sees no `.env` values. Hardcoding the URL leaks credentials and bypasses per-environment overrides. See `references/runtime.md`.
6. **Hand-editing `contract.json` or `contract.d.ts`.** They're emitted artefacts; the next `contract emit` overwrites your changes. Edit the source instead.
7. **Using `--out` for `contract infer`.** The flag is `--output`.
## What Prisma 8 doesn't do yet
- **Migration from another ORM.** Prisma 8 doesn't migrate your schema *from* Drizzle / Sequelize / TypeORM / Kysely / Knex / a raw driver. A Prisma 6 MongoDB schema and a Prisma 7 Postgres schema are the exception: they can be the contract source as they are (`prisma6Schema` / `prisma7Schema`, see `references/contract.md`). For the others: install the matching `@internal/migrate-from-<orm>-skill` if one exists for your source, or treat the source as a brownfield database and `contract infer` from it. If you need a guided migration flow built-in, file a feature request via the `references/feedback.md` skill.
- **`prisma db push`-style production sync.** `db update` is for databases whose migration history does not need to be kept (local, development, staging, preview); for production, use migrations (`migration plan` + `db migrate`). PN deliberately does not offer a "push-to-prod-without-a-migration" surface — see `references/migrations.md`.
- **Studio / GUI database browser.** Use `prisma db schema` for a CLI tree-style summary of the live DB. If you need an interactive UI, file a feature request via the `references/feedback.md` skill.
## Reference Files
This skill is intentionally body-only; `prisma orm init --help`, `contract infer --help`, and `db sign --help` are the authoritative surfaces for flag-level detail. When in doubt, run `--help` and read the actual command's description rather than guessing from this skill.
## Checklist
- [ ] Confirmed which path applies (first-touch orientation / greenfield / brownfield) before proposing commands.
- [ ] **First-touch orientation:** named the contract path back to the user and framed its role (*source of truth from which query types, migrations, and runtime types flow*) before proposing any commands.
- [ ] **All paths:** brought the project to the *Your first arc* prerequisites (config, contract source, `db.ts`, `DATABASE_URL`, marker row) *before* writing application code.
- [ ] **All paths:** ran the first arc — one `create` + one `select` against the starter (or inferred) model — and got the round-trip working green.
- [ ] **All paths:** did *not* edit the contract source as part of the first arc. Schema extension is the *next* move, not the first.
- [ ] **All paths:** did *not* lead with a feature tour, capability inventory, or recital of CLI commands. Commands surfaced as the user's current move required them.
- [ ] Confirmed the user's target (`postgres` / `mongodb`) and authoring mode (`psl` / `typescript`).
- [ ] **First-touch orientation:** read `prisma.config.ts`, the contract source, `db.ts`, and `.env` before proposing anything — didn't assume what the scaffold tool / teammate left in place.
- [ ] **Greenfield path:** ran `prisma orm init` from the project directory — no positional project-name argument.
- [ ] **All paths (application projects):** the project ended up in the canonical `src/prisma/contract.{prisma,ts}` + `src/prisma/db.ts` + `migrations/app/` layout (what `init` scaffolds by default). An extension or aggregate-root package keeps its own `src/contract.{prisma,ts}` + `migrations/<timestamp>_<slug>/` layout — do not relocate it.
- [ ] **Brownfield path:** ran `contract infer --db "$DATABASE_URL" --output src/prisma/contract.prisma`, reviewed the result, then `contract emit` + `db sign` (which by default also sets the `db` ref, so the next `migration plan` chains from the adopted schema; not with `--no-advance-ref` or `--advance-ref <name>`).
- [ ] Set `DATABASE_URL` in `.env` and confirmed the value is reachable.
- [ ] Initialised the DB (`db init` greenfield / first-touch orientation) or signed the marker (`db sign` brownfield — by default sets the `db` ref too, with or without `--db`).
- [ ] Did NOT hand-edit `contract.json` or `contract.d.ts`.
- [ ] Did NOT set `DATABASE_URL` in `prisma.config.ts`.
- [ ] Confirmed the user understands what the *next* skill is for their workflow (typically `references/queries.md` for more queries, then `references/contract.md` when they're ready to extend the schema).
references/runtime.md
# Prisma 8 — Runtime (`db.ts` Wiring)
> **Edit your data contract. Prisma handles the rest.**
This skill covers the **runtime entry point** — `db.ts` — and how to compose the database client with extensions, middleware, and environment configuration.
## When to Use
- User is wiring up `db.ts` for the first time (post-init).
- User wants to add middleware (lints, budgets, cache, custom).
- User wants per-environment config (dev vs prod, multi-region).
- User wants to switch between the Postgres, SQLite, and Mongo façades.
- User wants to wrap operations in `db.transaction(...)` (Postgres, SQLite and Supabase role-bound clients).
- User is running a one-off script (`tsx my-script.ts`, Node CLI, CI task) and the process won't exit after queries finish, or they need script teardown (`db.close()`, `await using`).
- User is deploying to a per-request runtime (Cloudflare Workers + Hyperdrive, AWS Lambda, Vercel Edge, Deno Deploy, Bun edge) and needs `postgresServerless()` with `postgres.connect({ url })` per request.
- User mentions: *db.ts, postgres(), mongo(), middleware, lints, budgets, cache, query log, slow query, DATABASE_URL, .env, connection pool, poolOptions, dev vs prod, transactions, read replicas, multi-database, script won't exit, hangs, db.close, db.end, close connection, pool.end, await using, serverless, edge, Cloudflare Workers, Hyperdrive, postgresServerless, connect per request*.
## When Not to Use
- User wants to write queries → `references/queries.md`.
- User is on Supabase — the `supabase()` role-first factory, `asUser(jwt)` / `asAnon()` / `asServiceRole()`, JWT config, RLS → `references/supabase.md`.
- User wants to edit the contract → `references/contract.md`.
- User wants to wire Prisma 8 into a build tool (Vite plugin, Next.js, …) → `references/build.md`.
- User wants to debug a connection / runtime error → `references/debug.md`.
- User wants to file a bug or feature request → `references/feedback.md`.
## Key Concepts
- **`db.ts` is the runtime entry point.** Imports the runtime factory from the `@internal/<target>` façade (`@internal/postgres/runtime`, `@internal/sqlite/runtime`, or `@internal/mongo/runtime`), `contractFromJson` from the same façade's `/static` entry point, the contract artefacts (`contract.json` + the `Contract` type from `contract.d.ts`), and any middleware. Exports the typed `contract` and a `db` value the rest of your app imports. On a per-request runtime, `db.ts` imports `postgresServerless` from `@internal/postgres/serverless` instead and exports the serverless client `postgres`; see *Workflow — Serverless and per-request runtimes*.
- **`db.ts` imports only from the façade's `/runtime` and `/static` entry points.** Each factory is a *default* export. For Postgres: `import postgres from '@internal/postgres/runtime'`; SQLite: `import sqlite from '@internal/sqlite/runtime'`; Mongo: `import mongo from '@internal/mongo/runtime'`. The factory takes one options object and no type argument: it infers the contract type from `contract`. Turn the emitted JSON into a typed value once with `contractFromJson<Contract>(contractJson)` from the façade's `/static` entry point (`@internal/postgres/static`, `@internal/sqlite/static`, `@internal/mongo/static`; Supabase uses the Postgres one). That call is the only place you write the `Contract` type argument. A contract written in TypeScript with `defineContract` is already a typed value and goes to `{ contract }` directly. A contract value is validated once, however many clients use it.
- **Lazy connect.** The factory does not connect to the database synchronously. `db.sql` and `db.orm` are available immediately; the driver / pool is instantiated on the first call that needs a runtime (or when you explicitly call `await db.connect({ url })`, which binds the client to that URL), and the pool opens its first database connection on the first query. This is why `db.ts` can be imported in modules that load before the env is ready.
- **Middleware composes in order.** Every hook runs in the order of the `middleware: [...]` array — the first middleware's `beforeEncode` sees the statement first, and `intercept.onQuery` handlers are consulted in registration order with the first non-`undefined` result winning. Register a cache first so it gets first claim on a hit. Every middleware's `activityEnded` still fires on a cache hit, with `result.source: 'middleware'`.
- **`prisma.config.ts` vs `.env`.** The config (`definePrismaConfig({ orm: ormConfig({ contract, db, extensions, migrations }) })` — see `references/contract.md`) is for static project shape: contract path, installed extensions, migrations directory, default connection string. `.env` is for per-environment values (`DATABASE_URL`, secrets). Nothing reads `.env` on its own: the scaffolded `prisma.config.ts` starts with `import 'dotenv/config'`, and that import is what loads `.env` into `process.env` before the config (and the CLI running it) reads `process.env['DATABASE_URL']`. Keep the import; a config without it sees no `.env` values. Hardcoding `DATABASE_URL` in the config file leaks credentials and bypasses per-env overrides.
- **Build-system / dev-server integration is a separate skill.** `vite dev` auto-emit lives in `references/build.md`. The runtime side (this skill) reads `contract.json` / `contract.d.ts` regardless of how they got onto disk, so the two skills compose cleanly.
## Workflow — Basic `db.ts`
The concept: `db.ts` is the seam between the emitted contract artefacts (target-shaped) and the runtime that executes queries against them. Four imports are required — the runtime factory, `contractFromJson` from the façade's `/static` entry point, the `Contract` type (so the static query surfaces are typed), and the JSON artefact (which `contractFromJson` validates once).
`init` scaffolds something like this (for `--target postgres`):
```typescript
// src/prisma/db.ts
import postgres from '@internal/postgres/runtime';
import { contractFromJson } from '@internal/postgres/static';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
export const contract = contractFromJson<Contract>(contractJson);
export const db = postgres({
contract,
url: process.env['DATABASE_URL']!,
});
```
(The rest of `src/` imports from `./prisma/db` or `../prisma/db` depending on depth.)
Three things to know:
- **Name `Contract` on `contractFromJson`, not on the client.** `contractFromJson<Contract>(contractJson)` is where the JSON gets its type; without the type argument, the static surfaces collapse to a generic shape and you lose autocomplete on model names. Always import `Contract` from the emitted `./contract.d.ts`. The clients take no type argument and no `contractJson` option.
- **`with { type: 'json' }` is required.** Node's ESM JSON-import-attribute spec. Without it, the import errors.
- **`url` is optional at construct time.** If `DATABASE_URL` is not set when `db.ts` loads, the factory still returns a client; you can call `await db.connect({ url })` later. The factory throws lazily — only when a runtime is actually needed.
The Mongo façade has the same construction shape — `import mongo from '@internal/mongo/runtime'` — and the same `db.connect(...)` / `db.close()` lifecycle methods. **The Mongo façade does not expose `db.transaction(...)`.** See *What Prisma 8 doesn't do yet* for the workaround. **The ORM surface differs in one place: keys.** On Mongo, `db.orm` is keyed by the collection's storage name (from `@@map(...)`, or the lowercased model name if no `@@map` is set), not by the PSL model name — so `model User { … @@map("users") }` is reached at `db.orm.users`, not `db.orm.public.User`. The SQL builder lane (`db.sql.<ns>.<table>`) doesn't exist on Mongo at all (`db.sql` is `undefined`). See `references/queries.md` § *MongoDB ORM addressing* for the full rule and a rewrite recipe for SQL-target examples.
## Workflow — Running as a script (teardown)
The concept: short scripts that connect, query, then expect the process to exit will **hang on Postgres** because the `pg.Pool` owned by `postgres()` keeps Node's event loop alive. The data round-trip succeeds; the script never exits. Call `await db.close()` before the script returns (or use `await using` **at the top of a script module** so teardown runs when the module exits — see the block-scope warning below for why this matters).
**Plain shape** — export `db` from `db.ts`, import it in the script, close at the end:
```typescript
// src/scripts/hello.ts
import { db } from '../prisma/db';
const created = await db.orm.public.User.create({ email: 'alice@example.com', name: 'Alice' });
const read = await db.orm.public.User.first();
console.log({ created, read });
await db.close();
```
**TS 5.2+ idiomatic shape** — construct the client at the **top of a script module** and let `[Symbol.asyncDispose]` call `close()` when the module exits:
```typescript
// src/scripts/hello.ts — top-level await in a script module
import postgres from '@internal/postgres/runtime';
import { contract } from '../prisma/db';
await using db = postgres({ contract, url: process.env.DATABASE_URL! });
const user = await db.orm.public.User.first();
console.log(user);
// db.close() runs automatically when the script module exits.
```
### `await using` on a `postgres()` client is **block-scoped** — do not put it inside a request handler
This rule is about the long-lived `postgres()` client. On a per-request runtime, `await using db = await postgres.connect({ url })` inside the handler is the correct pattern; see *Workflow — Serverless and per-request runtimes* below.
This is the most important rule in this section. `await using db = postgres(...)` disposes when the *enclosing block* exits. In a script module, that block is the module body and disposal fires at process exit — fine. In a request handler, the enclosing block is the handler function, so disposal fires **after every request** — a fresh `pg.Pool` per call, TCP-connect storm, hot loop tearing connections up and down.
```typescript
// DO NOT do this — closes the pool after every request.
app.get('/users', async (req, res) => {
await using db = postgres({ contract, url: process.env.DATABASE_URL! });
const users = await db.orm.public.User.all();
res.json(users);
});
```
The right server pattern is a **module-level singleton** in `db.ts`, imported by handlers, never closed during the process lifetime:
```typescript
// src/prisma/db.ts — constructed once, lives for the process
export const db = postgres({ contract, url: process.env.DATABASE_URL });
// src/routes/users.ts
import { db } from '../prisma/db';
app.get('/users', async (req, res) => {
const users = await db.orm.public.User.all();
res.json(users);
});
```
Servers (HTTP handlers, workers in a request loop) **do not call `db.close()`** at all in steady state. The pool stays open for the process lifetime. `db.close()` and `await using` are for short-lived scripts — `tsx my-script.ts`, Node CLI commands, CI tasks, one-off seed runs — not for code that runs inside a request loop.
**Semantics:**
- **`close()` is idempotent.** Calling it twice is a no-op.
- **`close()` is terminal.** There is no reconnect on a closed `db` — construct a new client if you need to reach the database again. After close, `db.connect(...)`, `db.query(...)`, `db.execute(...)`, `db.transaction(...)`, `db.prepare(...)` and ORM calls reject with `Error('<target> client is closed')` (e.g. `'Postgres client is closed'`, `'SQLite client is closed'`, `'Mongo client is closed'`); none of them throws at the call, so `.catch(...)` sees the error. A mistake in the call itself, such as an ORM query the database's capabilities do not allow, still throws at the call. `db.runtime()` returns a runtime, so on Postgres and SQLite it throws the same error at the call; on MongoDB it returns a promise, which rejects.
- **`close()` waits for work already running and refuses new work.** On a Postgres client built from `{ url }`, `close()` refuses every new call at once with `DRIVER.NOT_CONNECTED` ("Postgres client is closed"), and a runtime captured with `db.runtime()` before `close()` is refused at once with "Runtime is closed"; it waits for the queries, transactions and ORM calls already in flight, then ends the pool. An ORM call that resolves to one value, such as `first()`, `create()` or a nested create with its reload, starts when it is called, so `close()` waits for it and it completes. On a serverless connection, `close()` waits until the runtime has been idle for one turn of the event loop, so a chain of queries that keeps it busy from the close onward completes, then ends the `pg.Client`; work that starts after that rejects with `DRIVER.NOT_CONNECTED` ("Runtime is closed"). A lazy result such as `db.orm...all()` or `db.query(plan)` starts when it is awaited or iterated, not when it is built; awaited after `close()`, an ORM `all()` is refused with "Postgres client is closed". A Postgres client given a caller's `pg.Pool` or `pg.Client` does not close its runtime, so work through a runtime taken before `close()` keeps running on the caller's pool. `await` outstanding work before calling `close()`.
- **Ownership.** `close()` releases only what the façade constructed (`pg.Pool` from `{ url }`, `MongoClient` from `{ url }` / `{ uri, dbName }`, SQLite handle from `{ path }`). If you supplied your own `pg.Pool` / `pg.Client` (Postgres `pg:` option), `mongodb.MongoClient` (Mongo `mongoClient:` option), or a pre-built `binding`, `db.close()` does **not** touch those — you own their lifecycle.
**`db.end()` does not exist.** The universal `node-postgres` name is `pool.end()` on a `pg.Pool`; the Prisma 8 runtime client is not a `pg.Pool`. The right call is `await db.close()`.
## Workflow — Serverless and per-request runtimes
The concept: a per-request runtime (Cloudflare Workers + Hyperdrive, AWS Lambda, Vercel Edge, Deno Deploy, Bun edge) must not keep a database connection at module scope. Use `postgresServerless()` from `@internal/postgres/serverless`. It returns a **serverless client**; name it `postgres`. It holds no database connection. In each request, open a **connection** named `db` with `await using db = await postgres.connect({ url })`. A connection owns one database connection, a `pg.Client`, and `await using` closes it when the handler returns. `db` has the members of a `postgres()` client except `connect`.
```typescript
// src/prisma/db.ts — the serverless client: module scope, built once per isolate, holds no database connection
import postgresServerless from '@internal/postgres/serverless';
import { contractFromJson } from '@internal/postgres/static';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
export const contract = contractFromJson<Contract>(contractJson);
export const postgres = postgresServerless({ contract });
// src/worker.ts
import { postgres } from './prisma/db';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
await using db = await postgres.connect({ url: env.HYPERDRIVE.connectionString });
const users = await db.orm.public.User.all();
return Response.json(users);
},
};
```
The rule to remember: inside a request, `db` does everything the `db` from `postgres()` does, so any documented `db.orm...`, `db.sql...`, `db.raw...`, `db.transaction(...)`, `db.prepare(...)` or `db.query(...)` snippet works unchanged. Await every query before the `await using` scope ends. The connection's `close()` waits until the runtime has been idle for one turn of the event loop (a `setTimeout(0)`), then refuses new work. A lazy ORM read such as `db.orm.public.User.all()`, or `db.query(plan)`, returned from the scope without `await` starts only when the caller awaits it, after the scope has closed, so it rejects with `DRIVER.NOT_CONNECTED` ("Runtime is closed"), whose `fix` names the missing `await`. Work that keeps the runtime busy from the close onward completes: a `first()`, `execute()` or `transaction()`, an ORM write and its reload, including `include(...).create()` and a nested `create()`, and an async helper whose steps go through `db.orm`, `db.transaction()` or `db.prepare()`; a transaction commits. Such work is never refused because of the close; if it fails for its own reason, it rejects like any unawaited promise. A helper that calls `db.runtime()` after the scope has ended throws "Postgres connection is closed" at that call. Work that starts after the runtime was idle for a turn is refused. Whether a query sent after waiting on something other than the database counts as idle depends on when that callback runs relative to the timer, so do not rely on it. An unawaited promise that fails then, or fails for its own reason, rejects like any unawaited promise, and Node reports it as unhandled. Write `return await db.orm...`, not `return db.orm...`.
- **`connect` connects before it returns.** When the database refuses the connection, rejects the credentials, or does not answer within 20 seconds, `postgres.connect({ url })` rejects with `DRIVER.CONNECTION_FAILED` and leaves nothing open; an empty URL, a string that is not a URL, or a scheme other than `postgres://` or `postgresql://` rejects with `RUNTIME.BINDING_INVALID`. Handle an unreachable database at the `connect` call, not at the first query. Answer a request that needs no query, such as an unknown route, before `connect`, because `connect` opens a database connection whether or not a query follows.
- **Never call `connect` at module scope.** A connection opened there is shared by every request in the isolate, and fails in four ways: its database connection goes stale after the isolate idles, concurrent requests queue behind each other on one `pg.Client`, nothing closes it when a request ends, and Cloudflare Workers reject a socket used across requests.
- **`db.orm` is the way to use the ORM.** For custom collection classes, pass `collections` to `postgresServerless({ contract, collections })` at module scope; every connection's `db.orm` and `tx.orm` then returns the classes. See *Workflow — Custom collection classes* in [`queries-postgres.md`](queries-postgres.md).
- **Anything that takes a runtime gets `db.runtime()`.** That includes `withTransaction(runtime, fn)` and a prepared statement's `query(runtime, params)`. Do not pass `db` itself.
- **Inside `db.transaction(async (tx) => ...)`, run every query through `tx`.** A connection has one database connection, so inside `db.transaction(async (tx) => ...)` a query through `db` is not independent of the transaction; run every query through `tx`. A query that uses the connection's database connection directly, such as a `db.orm` read, a `db.orm` create of one row, an `updateAll(...)` or `deleteAll()`, or `db.query(...)`, runs inside the open transaction without saying so. An operation that asks for a database connection of its own, such as a `db.orm` `update(...)` or `delete()` of one row, `db.runtime().connection()`, a `db.orm` create that also writes related rows, or a nested `db.transaction(...)`, waits for the database connection the transaction holds, and the request hangs.
- **After `db.close()`** (or the end of the `await using` scope), `db.runtime()` throws `DRIVER.NOT_CONNECTED` ("Postgres connection is closed"), and ORM queries, `db.transaction(...)` and prepared statements that start after the runtime was idle for a turn reject with `DRIVER.NOT_CONNECTED` ("Runtime is closed"). Call `postgres.connect({ url })` again for a new connection.
How connections read rows, and the `cursor` option on the serverless client (with the Hyperdrive caveat), is in `references/queries.md` § *Streaming*.
## Workflow — Custom middleware (query log, slow-query warning)
The concept: the runtime runs every statement and every transaction as an **activity**, and a middleware is a plain object with `name`, `familyId: 'sql'`, and any of these hooks:
- Lifecycle events, for every activity: `activityStarted(activity, ctx)`, `activityEnded(activity, result, ctx)` and `activityFinalized(activity, result, ctx)`. Check `activity.kind` (`'statement'`, `'transaction'`, or a client kind such as `'orm-call'`) at the top. `activityEnded` fires when the act ends: for a statement with `result.outcome` (`'succeeded'`, `'failed'`, or `'stopped'` when the caller stopped reading), `result.latencyMs`, `result.source` (`'driver'`, or `'middleware'` for a cache hit) and `result.rowCount` or `result.stats` according to `result.returns`. `activityFinalized` fires once the effects are final, with `result.outcome` `'committed'`, `'rolled-back'` or `'unknown'`: right after the end outside a transaction, and when the transaction ends inside one. Every statement that started is finalized, even one blocked before it ran.
- Interceptors, for statements: `beforeEncode(plan, ctx, params)` before parameters are encoded (throw to block the statement), `intercept.onQuery` / `intercept.onExecute` to supply a result instead of running it, and `onRow(row, plan, ctx)` for each driver row.
- Client activities: a client library can start an activity of its own kind, which contains a hyphen, and send its statements beneath it. The SQL ORM runs every terminal method as one `orm-call` activity: `db.orm.public.User.first(...)` is one `orm-call`, with its statements, its own transaction and its read-backs beneath it. A middleware sees it through the same lifecycle events, with `activity.kind` set to that kind and `ctx.parent` of its statements pointing at it, and can supply its result through `intercept.onActivity(activity, ctx)`; the ORM then sends no statement. **The annotations of an ORM call are on the `orm-call` activity**, read with `handle.read(activity)` in `activityStarted`; the statements it sends carry none, so reading them from `plan.meta.annotations` finds nothing. `activity.contentHash()` gives the call's digest, computed only when asked. Outside a transaction it is finalized right after it ends; inside one, when the transaction ends, after its statements. A middleware that handles only some kinds should ignore the others rather than fail on them.
Every hook of one activity receives the same `ctx`. Keep per-statement state in `ctx.state(key)` (use a `Symbol()` key); `activity.id` identifies the activity, and `ctx.parent` is the enclosing transaction's context. There is no separate telemetry package — observe statements with `activityEnded`. The `SqlMiddleware` type comes from `@prisma/orm-postgres/family-runtime`. `examples/prisma-8-demo/src/prisma/slow-query-warning.ts` is the canonical example:
```typescript
import type { SqlMiddleware } from '@prisma/orm-postgres/family-runtime';
const statementSql = Symbol('slow-query-warning.sql');
export function slowQueryWarning(options?: { readonly thresholdMs?: number }): SqlMiddleware {
const thresholdMs = options?.thresholdMs ?? 250;
return {
name: 'slow-query-warning',
familyId: 'sql',
async beforeEncode(plan, ctx) {
ctx.state(statementSql)['sql'] = plan.sql;
},
async activityEnded(activity, result, ctx) {
if (result.kind !== 'statement' || result.latencyMs <= thresholdMs) return;
ctx.log.warn({
code: 'APP.SLOW_QUERY',
message: `Query took ${result.latencyMs}ms (threshold: ${thresholdMs}ms)`,
details: { sql: ctx.state(statementSql)['sql'], source: result.source, activityId: activity.id },
});
},
};
}
```
A "log every query" middleware is the same shape without the threshold. The runtime also exposes its last telemetry event through `db.runtime().telemetry()`.
## Workflow — Lints and budgets middleware
The concept: lints catch authoring mistakes that survive type-check (e.g. `DELETE` without a `WHERE`, `SELECT` without a `LIMIT` on a large table); budgets enforce row-count and latency ceilings at runtime. Both surface findings through the structured-error envelope so an agent can branch on the code.
Both are exported from the façade's `family-runtime` subpath — `@prisma/orm-postgres/family-runtime` (and `@prisma/orm-sqlite/family-runtime`, `@prisma/orm-mongo/family-runtime`). `examples/prisma-8-demo/src/prisma/db.ts` shows the canonical import.
```typescript
import postgres from '@internal/postgres/runtime';
import { contractFromJson } from '@internal/postgres/static';
import { budgets, lints } from '@prisma/orm-postgres/family-runtime';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
export const contract = contractFromJson<Contract>(contractJson);
export const db = postgres({
contract,
url: process.env['DATABASE_URL'],
middleware: [
lints({
severities: {
selectStar: 'warn',
noLimit: 'error',
deleteWithoutWhere: 'error',
updateWithoutWhere: 'error',
readOnlyMutation: 'error',
},
}),
budgets({
maxRows: 10_000,
defaultTableRows: 10_000,
tableRows: { user: 10_000, post: 50_000 },
maxLatencyMs: 1_000,
severities: { rowCount: 'error', latency: 'warn' },
}),
],
});
```
`mode` decides what a budget violation does. It is `'strict'` by default, and every `budgets()` violation throws. Pass `mode: 'permissive'` to `postgres()`, `postgresServerless()`, `sqlite()` or `supabase()`, and a violation whose severity is `'warn'` goes to `log.warn` instead, and the query runs; a violation whose severity is `'error'` still throws. `budgets()` gives latency `'warn'` and the row count `'error'` by default, so a row-count limit warns only with `severities: { rowCount: 'warn' }`.
For the full option surface, read the source: `packages/2-sql-family/5-runtime/src/middleware/lints.ts` and `.../budgets.ts`. The `severities` keys (`selectStar`, `noLimit`, `deleteWithoutWhere`, `updateWithoutWhere`, `readOnlyMutation` for lints; `rowCount`, `latency` for budgets) are the source of truth; do not extrapolate to a key that is not in those two files. `lints()` with no argument uses the default severities.
## Workflow — Cache middleware
The concept: `@prisma/orm-extension-middleware-cache` ships an opt-in read cache. An annotated ORM call is cached as a whole, through `intercept.onActivity`: on a hit the ORM sends no statement; on a miss the value the call returned is stored. An annotated SQL builder query is cached as rows, through `intercept.onQuery`: on a hit the driver is never called; on a miss the rows are buffered and stored when the query completes. Only a read carrying `cacheAnnotation` is cached; `cacheAnnotation({ bypass: true })` skips the cache for one call. Cache keys default to the content hash of the ORM call or of the plan (`key` overrides), and reads inside a transaction or on a pinned connection are neither served nor stored. How long an entry lives is the store's policy: the default store keeps up to 1000 entries for 60 seconds each, and `createInMemoryCacheStore({ maxEntries, ttlMs })` changes that (`ttlMs: Infinity` never expires). `CacheStore` is the interface for a Redis-style backend. A write removes entries only when told which: annotate it with `invalidateAnnotation({ keys, meta })`, and the cache invalidates once the write's effects are final (after its own commit, or when the enclosing `db.transaction(fn)` commits; not after a rollback), or call `cache.invalidate({ keys })` after the write commits. Mongo ORM writes take no annotations, so on Mongo call `invalidate`. `cacheAnnotation({ meta })` hands any value to the store with the entry, and `cache.invalidate({ meta })` hands one to its `unset`; only a custom store that indexes `meta` can act on it, and the default store throws `RUNTIME.CACHE_STORE_META_UNSUPPORTED`.
```typescript
import {
cacheAnnotation,
createCacheMiddleware,
createInMemoryCacheStore,
invalidateAnnotation,
} from '@prisma/orm-extension-middleware-cache';
export const cache = createCacheMiddleware({
store: createInMemoryCacheStore({ maxEntries: 1_000, ttlMs: 60_000 }),
});
export const db = postgres({
contract,
url: process.env['DATABASE_URL']!,
middleware: [cache, lints(), budgets({ maxRows: 10_000 })],
});
// Cached for the store's 60 s; an identical read in that window is served without a driver call.
const user = await db.orm.public.User.first({ id: 1 }, (meta) =>
meta.annotate(cacheAnnotation({ key: 'user-1' })),
);
// Un-annotated queries always hit the database.
// The write invalidates 'user-1' once it has committed; inside db.transaction(fn), when that commits.
await db.orm.public.User.where({ id: 1 }).update({ name: 'Alicia' }, (meta) =>
meta.annotate(invalidateAnnotation({ keys: ['user-1'] })),
);
// Or, after a write to user 1 has committed (outside the transaction, never inside it):
await cache.invalidate({ keys: ['user-1'] });
```
**Invalidate after the commit, not inside the transaction.** `invalidateAnnotation` waits for the commit on its own; a manual `invalidate` must be called after it. A read from another request can refill the cache with the old rows before the commit lands. A read that missed before the `invalidate` and finishes after it does not store its rows: the store moves the key's version on `unset`, and the read's `set` is conditional on the version it saw. This holds across processes that share a store.
**The cache key carries no identity.** The default key is a content hash of the statement — contract hash, SQL text, and bound parameters — or, for an ORM call, of the terminal method and the statement it sends first, so two callers issuing the same read share one entry regardless of who they are. Never annotate a read whose rows depend on the caller (per-user, per-tenant, or RLS-filtered data) on the plain `postgres()` façade unless the identity is part of the key: `cacheAnnotation({ key: `user:${userId}:profile` })`, or a `where` clause that binds the identity as a parameter (the parameter is in the hash). Reads that run on a pinned connection or inside a transaction bypass the cache entirely (`ctx.scope !== 'runtime'` or `ctx.inTransaction()`), which is why a Supabase `RoleBoundDb` read — executed on a connection with the role bound via `set_config` — is never served from cache; the plain façade has no such protection.
## Workflow — Compose multiple middleware
```typescript
middleware: [
createCacheMiddleware(), // first — gets first claim on an intercept.onQuery hit
lints({ severities: { noLimit: 'error' } }),
budgets({ maxLatencyMs: 5_000 }),
slowQueryWarning({ thresholdMs: 250 }), // activityEnded fires for cache hits too (source: 'middleware')
],
```
Order matters: `beforeEncode` runs in registration order for every middleware before any `intercept.onQuery` is consulted, so lints and budgets still check a query the cache then answers; `activityEnded` fires for all of them either way. A cached ORM call is answered before any statement exists, so lints and budgets see no statement for a hit.
## Workflow — Configure the connection
The concept: the runtime takes one of three binding shapes — `url`, `pg` (a pre-constructed `pg.Pool` or `pg.Client`), or `binding` (an explicit kind tag). They're mutually exclusive. The `pg` form is for projects that already manage their own pool (e.g. a Lambda layer); `url` is the default. Pool tuning is `poolOptions.connectionTimeoutMillis` / `poolOptions.idleTimeoutMillis` — *not* `driverOptions`. Reads are buffered by default; setting `cursor` (`{ batchSize: 100 }`; `{}` or `{ batchSize: undefined }` for batches of 100; a `batchSize` that is not a positive integer fails the factory call) makes them stream through a server-side cursor, and there is no flag that turns cursors off (see `references/queries.md` § *Streaming*). `postgresServerless()` takes the same `cursor` option. Pass `log` (an object with `info`, `warn`, `error` and an optional `debug`; `console` works, and the type is `Log` from `@prisma/orm-postgres/family-runtime`) to receive what the runtime logs: the marker-verification warnings `CONTRACT.MARKER_MISSING` and `CONTRACT.MARKER_MISMATCH` from the first query, the warnings `lints()` emits for rules whose severity is `warn`, and the errors a middleware's `activityEnded` or `activityFinalized` hook throws, which the runtime logs and swallows. If `log` has a `debug` method, it also receives debug events, for example when a middleware intercepts a statement. `budgets()` violations reach `log` only in `mode: 'permissive'` (see *Lints and budgets middleware*). Without `log`, the runtime drops these events. `postgresServerless()`, `sqlite()` and `supabase()` take the same option.
```typescript
// Default — URL string, factory constructs the pool.
postgres({
contract,
url: process.env['DATABASE_URL'],
poolOptions: {
connectionTimeoutMillis: 20_000,
idleTimeoutMillis: 30_000,
},
// cursor: { batchSize: 100 }, // optional — stream reads in batches instead of buffering
// log: console, // optional — receive marker and lints() warnings
});
// BYO pool — pass a pg.Pool you already created.
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
postgres({ contract, pg: pool });
```
The `url` and `pg` keys are mutually exclusive at the type level; passing both errors.
`DATABASE_URL` lives in `.env`. The CLI reads it for emit / verify / migration commands; the runtime reads it through `process.env` at `db.ts` load time.
## Workflow — Per-environment config (dev vs prod)
The concept: one `DATABASE_URL` per environment; the rest of the `db.ts` shape is the same. For middleware divergence (e.g. strict lints in dev only), branch in `db.ts` on `process.env['NODE_ENV']`.
```typescript
const isProd = process.env['NODE_ENV'] === 'production';
export const db = postgres({
contract,
url: process.env['DATABASE_URL']!,
middleware: isProd
? [slowQueryWarning({ thresholdMs: 250 })]
: [
slowQueryWarning({ thresholdMs: 250 }),
lints({ severities: { noLimit: 'error', deleteWithoutWhere: 'error' } }),
],
});
```
`.env` for local; the deploy platform's secrets for prod. Never commit `.env`.
## Workflow — Transactions
The concept applies to **Postgres, SQLite and Supabase**. `db.transaction(fn)` opens a transaction, passes the callback a `tx`, commits when the callback returns and rolls back when it throws. `tx` offers the client's query members with the same types: `sql`, `orm` (with the client's custom collection classes), `raw`, `prepare`, `query` (rows), `execute` (affected count) and `toSql`. Postgres and serverless-connection transactions also have `enums` and `nativeEnums`; SQLite's has `enums`. `tx` has no `close`, `[Symbol.asyncDispose]`, `transaction` or `runtime()`. Inside the callback, send every query through `tx`, not `db`, so it runs inside the transaction. A Supabase role-bound client (`asUser`, `asAnon`, `asServiceRole`) has `transaction(fn)` too, and its `tx` runs every statement under the bound role. The Mongo façade does not expose `db.transaction(...)`.
```typescript
await db.transaction(async (tx) => {
const user = await tx.orm.public.User.create({ email: 'alice@example.com' });
await tx.orm.public.Post.create({ userId: user.id, title: 'hello' });
// If either call throws, both inserts roll back.
});
```
The callback returns whatever you return from it — the transaction wrapper passes it through. Raw SQL and prepared statements work inside the transaction too:
```typescript
await db.transaction(async (tx) => {
const posts = await tx.orm.public.Post.published().forUpdate().all();
await tx.execute(tx.raw.sql`UPDATE post SET pinned = false WHERE id = ${posts[0]!.id}`.affectedCount().build());
const byId = await tx.prepare({ id: 'pg/int4@1' }, (params) =>
tx.sql.public.post.select('id').where((f, fns) => fns.eq(f.id, params.id)).build(),
);
await byId.query(tx, { id: 1 });
});
```
`published()` is a method of a custom collection class. A prepared statement runs on what it is given: `ps.query(tx, params)` runs inside the transaction, even when `ps` came from `db.prepare(...)`. `tx.prepare` exists so code inside a transaction needs nothing from the outer client. Preparing sends nothing to the database.
## Workflow — Switch between Postgres, SQLite, and Mongo
The concept: the façade selection is baked into `db.ts` (`@internal/postgres` or `@internal/mongo`) and `prisma.config.ts` (which target's `config` subpath `ormConfig` is imported from). To switch a project's target, re-run `prisma orm init` in the same directory and pick the other target — the init flow detects the existing scaffold and prompts to reinit (non-interactive runs grant the consent with `--confirm <directory name>`). PN re-scaffolds `prisma.config.ts` and `db.ts` for the new façade. The contract source needs to be re-authored for the new target's idioms (Mongo expresses nested documents; Postgres expresses relations).
After the switch (Mongo):
```typescript
// src/prisma/db.ts (Mongo)
import mongo from '@internal/mongo/runtime';
import { contractFromJson } from '@internal/mongo/static';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
export const contract = contractFromJson<Contract>(contractJson);
export const db = mongo({ contract, url: process.env['DATABASE_URL'] });
```
SQLite:
```typescript
// src/prisma/db.ts (SQLite)
import sqlite from '@internal/sqlite/runtime';
import { contractFromJson } from '@internal/sqlite/static';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
export const contract = contractFromJson<Contract>(contractJson);
export const db = sqlite({ contract, path: 'app.db' });
```
`path` is optional at construct time (you can call `db.connect({ path })` later); omit it and the façade still returns a client. The SQLite façade exposes the same `db.sql`, `db.orm`, `db.raw`, `db.prepare(...)`, `db.transaction(...)`, `db.close()`, and `[Symbol.asyncDispose]` surfaces as Postgres, with one addressing difference: SQLite has no schemas, so `db.sql` and `db.orm` are the unbound namespace itself (`db.orm.User`, `db.sql.user`) instead of Postgres's `db.orm.public.User` / `db.sql.public.user`. `db.orm` and `tx.orm` also offer `fragment` beside the models, unless a model is named `fragment`: `const titled = db.orm.fragment({ title: { codecId: 'sqlite/text@1', nullable: false } }, (rows) => rows.where((r) => r.title.eq('Hello')));` then `await db.orm.Post.with(titled).all();`. The Mongo façade shares `db.orm`, `db.close()`, and `[Symbol.asyncDispose]` but has no `db.sql` and no `db.transaction(...)`.
The `db.sql` / `db.orm` surfaces stay the same in name; the operators each surface exposes are target-shaped (Mongo has no `JOIN`).
## Workflow — Build-system / dev-server integration
If you want contract artefacts to re-emit automatically while the dev server is running (instead of running `prisma contract emit` by hand each time the contract source changes), reach for the build-tool plugin from `references/build.md`:
- **Vite**: install `@internal/vite-plugin-contract-emit` and register `prismaVitePlugin('prisma.config.ts')` in `vite.config.ts`.
- **Next.js, Webpack, esbuild, Rollup, Turbopack**: no first-party plugin yet — the workaround is a `prebuild` script that runs `prisma contract emit`. See `references/build.md` for the walkthrough.
The runtime side (this skill) is the same regardless: `db.ts` reads `contract.json` + `contract.d.ts` from disk. The build-system plugin's job is to keep those files current during development.
## Common Pitfalls
1. **Hardcoding `DATABASE_URL` in `prisma.config.ts`.** Leaks credentials; bypasses per-environment overrides. Use `.env`.
2. **Writing the type argument on the client, or passing `contractJson`.** `postgres<Contract>({ contractJson })` no longer compiles: the clients take `{ contract }` and infer its type. Write `contractFromJson<Contract>(contractJson)` and pass the result. Omitting `<Contract>` there collapses the static surfaces to a generic shape and you lose autocomplete for models.
3. **Forgetting `with { type: 'json' }` on the contract import.** Required by Node's ESM JSON-import-attribute spec.
4. **Middleware order matters.** Registration order is hook order; put the cache first so its `intercept.onActivity` and `intercept.onQuery` are consulted first.
5. **Importing middleware from a non-existent package or subpath.** There is no `@internal/postgres/middleware` subpath and no `@internal/middleware-telemetry` package. `lints` / `budgets` / `SqlMiddleware` come from `@prisma/orm-postgres/family-runtime`; the cache comes from `@prisma/orm-extension-middleware-cache`; a query log or telemetry hook is a custom `activityEnded` middleware (above).
6. **Confabulating lint / budget option names.** Lints take `severities` (with the five keys above), not `requireWhere` / `maxRowsWithoutLimit`. Budgets use `maxLatencyMs` (not `maxDurationMs`) plus `maxRows` / `defaultTableRows` / `tableRows`. When in doubt, read the source.
7. **Switching targets without re-emitting.** The contract artefacts are target-shaped; emit after the target change.
8. **Script hangs after queries finish on Postgres.** The `pg.Pool` keeps Node's event loop alive. Solution: `await db.close()` before the script returns, or `await using db = postgres({ contract, ... })` at the top of a script module. Do not put `await using db = postgres(...)` inside a request handler — it's block-scoped and would close the pool after every request. The right server pattern is a module-level singleton in `db.ts` that lives for the process lifetime. On a per-request runtime, use `postgresServerless()` and `await using db = await postgres.connect({ url })` in the handler instead (see *Workflow — Serverless and per-request runtimes*).
## What Prisma 8 doesn't do yet
- **A `/middleware` subpath or a telemetry package.** Neither exists. The middleware surface is `lints`, `budgets`, and the `SqlMiddleware` type on `@prisma/orm-postgres/family-runtime`, plus the separately installed `@prisma/orm-extension-middleware-cache`. Anything else (query log, tracing spans, metrics) is a custom middleware you write on the lifecycle events. File additional gaps you hit via `references/feedback.md`.
- **Multi-database routing / read replicas.** Prisma 8 doesn't ship a built-in primary/replica router or shard-aware client. Workaround: configure separate `db.ts` instances per data store and call the right one in your application code. If you need first-class multi-database routing, file a feature request via the `references/feedback.md` skill.
- **Connection pooling as a first-class config field.** `poolOptions.connectionTimeoutMillis` and `poolOptions.idleTimeoutMillis` are wired through, but the rest of `pg.Pool`'s tuning surface (max connections, `allowExitOnIdle`, ssl options, …) is not exposed by name. Workaround: construct the `pg.Pool` yourself and pass it via `pg:`. If you need more pool fields surfaced on the façade, file a feature request via the `references/feedback.md` skill.
- **Query logger middleware as a built-in.** Prisma 8 doesn't ship a "log every query" middleware. Workaround: a custom `activityEnded` middleware (see *Workflow — Custom middleware*). If you need a built-in query log, file a feature request via the `references/feedback.md` skill.
## Reference Files
This skill is intentionally body-only; `prisma orm init --help`, the target `defineConfig` (`ormConfig`) factory in `packages/3-targets/postgres/9-facade/src/config/define-config.ts`, the `postgres()` factory in `packages/3-targets/postgres/9-facade/src/runtime/postgres.ts`, the middleware sources in `packages/2-sql-family/5-runtime/src/middleware/{lints,budgets}.ts`, and `packages/3-extensions/middleware-cache/README.md` are the authoritative surfaces for option-level detail. When in doubt, read the source.
## Checklist
- [ ] `db.ts` imports the runtime factory from `@internal/<target>/runtime` (`postgres`, `sqlite`, or `mongo`), `contractFromJson` from `@internal/<target>/static`, and the `Contract` type from `./contract.d`.
- [ ] `with { type: 'json' }` on the contract JSON import.
- [ ] `Contract` is named once, on `contractFromJson<Contract>(contractJson)`, and the client gets `{ contract }` with no type argument.
- [ ] `DATABASE_URL` lives in `.env`, not in `prisma.config.ts`.
- [ ] Middleware ordered intentionally (cache first when used).
- [ ] `lints` / `budgets` imported from `@prisma/orm-postgres/family-runtime` and using the verified option keys (`severities`, `maxLatencyMs`, `maxRows`, `tableRows`).
- [ ] Per-env divergence (if any) gated by `NODE_ENV` or similar.
- [ ] Did NOT hardcode credentials in any committed file.
- [ ] Did NOT confabulate a `@internal/postgres/middleware` subpath, a `@internal/middleware-telemetry` package, a `@internal/postgres-extension-audit` package, or a type argument or `contractJson` option on `postgres(...)`.
- [ ] Did NOT claim `db.transaction(...)` exists on the Mongo façade — only Postgres, SQLite and Supabase role-bound clients expose it.
- [ ] Did NOT confabulate read-replica / multi-DB / extra pool config — pointed at *What Prisma 8 doesn't do yet* and routed to `references/feedback.md`.
- [ ] For build-system / dev-server prompts (Vite plugin, Next.js plugin, …) routed to `references/build.md`.
references/supabase.md
# Prisma 8 — Supabase
> **Edit your data contract. Prisma handles the rest.**
This skill covers using Prisma 8 against a **Supabase** project end-to-end: composing the Supabase extension pack, referencing Supabase-owned tables from your contract, authoring row-level-security (RLS) policies, and running role-bound queries through the `supabase()` runtime.
## When to Use
- User has a Supabase project (or wants one) and is wiring Prisma 8 into it.
- User wants RLS policies on their tables (`policy_select`, `@@rls`, `auth.uid()`).
- User wants per-request role binding (`asUser(jwt)`, `asAnon()`, `asServiceRole()`).
- User wants a foreign key into `auth.users` (cross-space FK).
- User wants to read Supabase-internal tables (`auth.*`, `storage.*`) as an admin.
- User mentions: *supabase, RLS, row level security, policy, anon, authenticated, service_role, auth.users, auth.uid(), JWT, jwtSecret, jwksUrl, SUPABASE.JWT_INVALID, RoleBoundDb, session pooler*.
## When Not to Use
- General contract editing (models, fields, relations) → `references/contract.md`.
- Non-Supabase `db.ts` wiring, middleware, teardown → `references/runtime.md`.
- General query shapes (filtering, includes, aggregates) → `references/queries.md` — everything there applies to a role-bound `db` too.
- Migration planning / applying → `references/migrations.md`.
## Key Concepts
- **The pack is an `external` contract space.** `@internal/extension-supabase/pack` ships a complete, introspection-generated contract of everything Supabase owns — the `auth` and `storage` schemas, their native enum types, and the platform roles (`anon`, `authenticated`, `service_role`) — all with control policy `external`. Composed via `extensions`, it means: the migration planner **emits no DDL** for those objects (Supabase manages them), and `db verify` **confirms they exist** in the live database. Your own tables stay `managed` as usual.
- **Roles come from the pack; you never declare them.** RLS `roles = [authenticated]` identifiers resolve against the composed contract. Pointing the runtime at a non-Supabase Postgres fails verify with a `not-found` issue naming the missing role — the common "wrong database" misconfiguration surfaces before queries run.
- **The runtime is role-first.** `supabase()` returns a `SupabaseDb` with **no top-level query surface** — there is no `db.sql` / `db.orm` until you bind a role. `await db.asUser(jwt)` / `db.asAnon()` / `db.asServiceRole()` each return a `RoleBoundDb` exposing `.sql`, `.orm`, `.raw`, `.prepare(...)`, `.query(plan)` (rows), `.execute(plan)` (affected count), `.enums`, `.nativeEnums`, and `.transaction(fn)`. This is deliberate: in a Supabase app there is no meaningful "no role" execution context, and defaulting to the connection's login role is a silent-RLS-bypass footgun.
- **Role binding is below middleware and cannot leak.** Each role-bound query runs on a connection that had `set_config('role', …)` and `set_config('request.jwt.claims', …)` applied beneath the user-middleware chain, with `RESET ALL` on release. Postgres-side `auth.uid()` / `auth.jwt()` read those session vars — RLS enforcement is Postgres's job; the runtime's job is binding the context.
- **RLS is enforced by policies *and* grants.** Policies filter *rows*; `GRANT` controls *table access*. Prisma 8 authors and migrates the policies; it does not author grants (see *What Prisma 8 doesn't do yet*). A role with policies but no `GRANT` gets a permission error, not filtered rows. On Supabase your `public` tables already carry the platform-role grants via default privileges — the grant that is actually missing out of the box is `service_role`'s on `auth.*` / `storage.*` (see *Workflow — Grants*).
- **JWT validation is eager and configurable — current Supabase projects need `jwksUrl`.** `asUser(jwt)` verifies the token (via `jose`) *before* any connection is acquired: signature + expiry against `jwksUrl` (asymmetric signing keys — **the default on current Supabase projects**, which sign ES256) **xor** `jwtSecret` (the symmetric HS256 secret — legacy projects only). Both or neither → a structured error with code `SUPABASE.CONFIG_INVALID`. Bad tokens throw a structured error with code `SUPABASE.JWT_INVALID` and a typed `meta.reason` — including a mismatch between the token's algorithm and the configured key source (an ES256 token against a `jwtSecret` client names the problem and tells you to switch to `jwksUrl`). The Postgres role is derived from the token's `role` claim (defaults to `authenticated`). Note: `supabase status` still prints a `JWT_SECRET` even on projects that sign ES256 — its presence does not mean your project uses it.
- **Admin access to `auth.*` / `storage.*` is a secondary root on `service_role` only — and needs a one-time grant.** `db.asServiceRole().supabase` exposes the pack's own contract (`.sql`, `.orm`, `.nativeEnums`, `.execute`). The root exists only on `service_role` by design, but a real Supabase project grants `service_role` **no table privileges** on `auth.*` / `storage.*` (only schema `USAGE`; only `postgres` holds table grants). Before the admin root can read a Supabase-internal table, run the narrow grant once (see *Workflow — Grants*). `asUser` / `asAnon` have no `.supabase`, and the primary `asServiceRole().sql` / `.orm` stay scoped to *your* contract.
## Workflow — Wire the pack into the config
The concept: the pack registers the Supabase contract space so your contract can reference it and the planner/verifier know what Supabase owns. Its descriptor is the `pack` export and it goes into the ordinary `extensions` array of the target config, inside the standard envelope (see `references/contract.md` § *Key Concepts*). The block mirrors `examples/supabase/prisma.config.ts`:
```typescript
// prisma.config.ts
import { definePrismaConfig } from '@prisma/cli-engine';
import supabasePack from '@internal/extension-supabase/pack';
import { defineConfig as ormConfig } from '@internal/postgres/config';
export default definePrismaConfig({
orm: ormConfig({
contract: './src/contract.prisma',
extensions: [supabasePack],
migrations: { dir: 'migrations' },
}),
});
```
## Workflow — Contract: FK into `auth.users` + RLS policies
The concept: your models live in your namespaces (`public`); Supabase's live in the pack's (`auth`, `storage`). A relation field typed `supabase:auth.AuthUser` is a **cross-space FK** — the planner emits `REFERENCES "auth"."users"("id")`, and the target table is verified, never migrated. RLS policies are top-level `policy_<operation>` blocks in the same namespace as their target model, and the target model must opt in with `@@rls`. Mirror `examples/supabase/src/contract.prisma`:
```prisma
namespace public {
model Profile {
id Uuid @id @onCreate(uuid())
username String
userId Uuid @unique
user supabase:auth.AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("profile")
@@rls
}
// authenticated may read only their own profile.
policy_select profile_owner_read {
target = Profile
roles = [authenticated]
using = sql`"userId"::uuid = auth.uid()`
}
// anon may read every profile (a public directory listing).
policy_select profile_public_read {
target = Profile
roles = [anon]
using = sql`true`
}
// authenticated may update only their own profile, and may not
// reassign it to another owner (WITH CHECK).
policy_update profile_owner_write {
target = Profile
roles = [authenticated]
using = sql`"userId"::uuid = auth.uid()`
withCheck = sql`"userId"::uuid = auth.uid()`
}
}
```
The `Uuid` constructor selects native UUID storage in type position. The legacy `@db.Uuid` spelling is removed; rewrite any `String @db.Uuid` alias or field as `Uuid` before emitting.
The pieces:
- **Per-operation policy blocks**: `policy_select`, `policy_insert`, `policy_update`, `policy_delete`, `policy_all`. Body is `key = value`: `target` (a model in this namespace), `roles` (resolve against the composed contract — the pack supplies `anon` / `authenticated` / `service_role`), `using`, and (for write operations) `withCheck`. `using` and `withCheck` are `sql` literals, as in `` using = sql`"userId"::uuid = auth.uid()` ``; a quoted string is refused, and the error message gives the rewrite. Multiple permissive policies per `(target, operation)` are valid — Postgres ORs them. A block may also carry `@@map("physical name")` to adopt an existing live policy under its exact name (no wire-name hash; drift detection then byte-compares the body against Postgres's reprint, so keep the text as captured — hand-authoring it warns).
- **`@@rls` is required on policy targets.** A `policy_*` block whose target model lacks `@@rls` fails emit with `PSL_EXTENSION_TARGET_MODEL_MISSING_ATTRIBUTE`. A model with `@@rls` and *no* policies is also meaningful: RLS enabled, deny-all.
- **Predicates are `sql` literals holding verbatim SQL.** Quote camelCase column names inside them with ordinary double quotes (`"userId"`; inside a backtick `sql` literal the only escapes are `` \` `` and `\\`), and cast where needed — `auth.uid()` returns `uuid`. Renames in your contract do not rewrite predicate bodies.
- **TS-builder parity exists.** `@internal/postgres/contract-builder` exports `policySelect` / `policyInsert` / `policyUpdate` / `policyDelete` / `policyAll`, `rlsEnabled(Model)`, and `role('anon')` — mirroring the PSL lowering key-for-key (identical emitted wire names). PSL is the canonical path shown here.
Emit + migrate as usual (`prisma contract emit`, then `references/migrations.md`). The plan creates your table, its FK, `ENABLE ROW LEVEL SECURITY`, and the `CREATE POLICY` statements — and **no DDL for `auth.*`**.
## Workflow — `db.ts` with the `supabase()` factory
The concept: instead of the stock `postgres()` factory, a Supabase app builds its client with `supabase()` from the extension's `/runtime` subpath. The factory is **async** (it prepares JWT key material — including the one-time JWKS fetch when `jwksUrl` is set), and the result is role-first.
```typescript
// src/prisma/db.ts
import { supabase } from '@internal/extension-supabase/runtime';
import { contractFromJson } from '@internal/postgres/static';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
export const contract = contractFromJson<Contract>(contractJson);
export const db = await supabase({
contract,
url: process.env['DATABASE_URL'], // direct Postgres connection — see pitfalls
jwksUrl: process.env['SUPABASE_JWKS_URL'], // https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json
// Legacy HS256 projects use jwtSecret: process.env['SUPABASE_JWT_SECRET'] instead — exactly one of the two.
});
```
Options beyond the basics: `middleware` (same composition as `postgres()` — see `references/runtime.md`; middleware never sees the role-binding `set_config` calls), `poolOptions`, `pg` (BYO `pg.Pool` instead of `url`; a `pg.Client` is rejected with `SUPABASE.CONFIG_INVALID`). Teardown is `await db.close()` / `await using` exactly as in `references/runtime.md` — the same script-hang rules apply.
## Workflow — Role-bound queries
The concept: bind the role that should execute the request, then query through the returned `RoleBoundDb` — every query surface from `references/queries.md` works, RLS-filtered by Postgres.
```typescript
// A signed-in user: rows are RLS-scoped to the JWT's auth.uid().
const userDb = await db.asUser(jwt); // async — rejects with code SUPABASE.JWT_INVALID on a bad/expired token
const mine = await userDb.orm.public.Profile.select('id', 'username').all();
// The anon role: sees what anon policies permit.
const listing = await db.asAnon().orm.public.Profile.select('id', 'username').all();
// service_role: BYPASSRLS — sees everything in YOUR contract.
const all = await db.asServiceRole().orm.public.Profile.select('id', 'username').all();
// Writes ride the same surfaces; RLS filters them too. An UPDATE against
// another owner's row affects 0 rows; a withCheck violation raises an error.
const updated = await userDb.orm.public.Profile
.where({ userId: me })
.updateAndCount({ username: 'new-name' });
```
Notes: `asAnon()` / `asServiceRole()` are sync; only `asUser` is async. Multi-namespace contracts address models by coordinate (`orm.public.Profile`, `sql.public.profile`) — see `references/queries.md` § *Namespace-aware accessors*. `RoleBoundDb.transaction(fn)` wraps work in a transaction on the role-bound session; its `tx` offers `sql`, `orm` (with the client's custom collection classes), `raw`, `prepare`, `query`, `execute`, `enums` and `nativeEnums`, and runs every statement under the bound role. A prepared statement runs on what it is given: `ps.query(userDb, params)` runs under that client's role, and `ps.query(tx, params)` runs inside the transaction.
## Workflow — Admin reads of `auth.*` / `storage.*`
The concept: Supabase-internal tables are not part of your contract, so they are not on your query surfaces. The `service_role` binding carries a **secondary root** — `db.asServiceRole().supabase` — which is the *pack's* contract surface:
```typescript
const admin = db.asServiceRole();
// SQL builder over the pack contract — rows come from `query`, not `execute`:
const users = await admin.supabase.query(
admin.supabase.sql.auth.users.select('id', 'email').build(),
);
// ORM over the pack contract:
const sessions = await admin.supabase.orm.auth.AuthSession.select('id', 'aal').all();
// Native enum values (e.g. auth.aal_level) come typed:
type AalLevel = (typeof admin.supabase.nativeEnums.auth.AalLevel)['Value'];
```
**The admin root needs a one-time grant.** A real Supabase project gives `service_role` no table privileges on `auth.*` / `storage.*` — out of the box, the reads above fail with `permission denied for table users` (sqlState `42501`). Grant exactly what you read, narrowly:
```sql
GRANT USAGE ON SCHEMA auth TO service_role;
GRANT SELECT ON TABLE auth.users TO service_role;
```
Other boundaries to respect: `asUser` / `asAnon` have **no** `.supabase`; the admin root has **no** `.transaction` (it is a separate contract-bound runtime sharing the pool — a transaction spanning both roots is out of scope); and for user *management* (creating users, password resets) prefer the GoTrue Admin API — Supabase-internal schemas can drift across platform upgrades; direct `service_role` SQL is for ad-hoc admin reads.
## Workflow — Grants
The concept: RLS policies are row filters on top of ordinary table privileges — a role with policies but no `GRANT` gets `permission denied`, not filtered rows. On Supabase the two directions are easy to get backwards:
- **Your own `public` tables need nothing.** Supabase ships `ALTER DEFAULT PRIVILEGES` on `public`, so tables created by `prisma db init` / `prisma db migrate` inherit full grants for `anon` / `authenticated` / `service_role` automatically — the same as dashboard-created tables. RLS policies are what actually protect the rows; do not add per-table grants, and do not narrow the defaults unless you have a reason.
- **The one grant you do need is for admin reads of Supabase-internal tables** — `service_role` has no table privileges on `auth.*` / `storage.*` (see *Admin reads* above for the narrow `GRANT USAGE` / `GRANT SELECT` pair).
Run grants via the Supabase SQL editor or `psql`. Symptom of a missing grant: `permission denied for table …` (sqlState `42501`) instead of an empty result.
## Workflow — Connecting to a real Supabase project
The concept: the runtime needs a **direct, session-capable** Postgres connection — it binds roles with session-scoped `set_config` + `RESET ALL`.
- **Session pooler** (`aws-0-<region>.pooler.supabase.com:5432`, username `postgres.<project-ref>`) — works everywhere, IPv4. The default choice.
- **Direct connection** (`db.<project-ref>.supabase.co:5432`) — works, but is **IPv6-only** on new projects; from IPv4-only environments it fails DNS/connect.
- **Transaction pooler (port 6543) — do not use.** Transaction pooling breaks session GUCs; role binding will misbehave.
`.env` carries `DATABASE_URL` and the JWT key source. For current projects that is `SUPABASE_JWKS_URL` — `https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json` (local stack: `http://127.0.0.1:54321/auth/v1/.well-known/jwks.json`). Only legacy HS256 projects use `SUPABASE_JWT_SECRET` (Project Settings → API → JWT Secret) — and note `supabase status` prints a `JWT_SECRET` even on ES256 projects, so don't infer the mode from its presence; check the JWKS endpoint or a token's header `alg`.
## Common Pitfalls
1. **Using the transaction pooler (port 6543).** Session GUC role binding requires a session-capable connection — use the session pooler (5432) or the direct connection.
2. **Wiring `jwtSecret` because `supabase status` prints a `JWT_SECRET`.** Current projects sign ES256; `asUser` then throws `SUPABASE.JWT_INVALID` explaining the token is ES256 and the client needs `jwksUrl`. Configure `SUPABASE_JWKS_URL`; reserve `jwtSecret` for legacy HS256 projects.
3. **Grants in the wrong direction.** Your `public` tables need no grants (Supabase's default privileges cover them; RLS protects the rows) — the grant you need is the narrow `auth.*` pair for `service_role` admin reads. `permission denied` (42501) means a missing grant, not a filtered result.
4. **Expecting `db.sql` / `db.orm` on the top-level `db`.** The Supabase db is role-first; bind a role, query the `RoleBoundDb`.
5. **Forgetting `await`** — on the `supabase()` factory and on `asUser(jwt)`. Both are async; `asAnon()` / `asServiceRole()` are not.
6. **Expecting `.supabase` on `asUser` / `asAnon`.** Admin access to `auth.*` is `service_role`-only by construction — and even `service_role` needs the one-time narrow grant first.
7. **A `policy_*` block whose target lacks `@@rls`.** Emit fails with `PSL_EXTENSION_TARGET_MODEL_MISSING_ATTRIBUTE` — add `@@rls` to the model.
8. **Unquoted camelCase columns or missing casts in predicates.** Predicates are verbatim SQL: `"userId"` needs quotes; compare uuid to `auth.uid()` with a `::uuid` cast where the column isn't already `uuid`.
9. **Passing both `jwksUrl` and `jwtSecret`** (or neither) — the `supabase()` promise rejects with `SUPABASE.CONFIG_INVALID`. It's an async factory, so the misconfiguration surfaces as a rejection (`await` / `.catch`), not a synchronous throw.
10. **Treating an RLS-filtered write as an error.** An `UPDATE` against a row the role can't see affects **0 rows** (no exception); only `withCheck` violations raise.
## What Prisma 8 doesn't do yet
- **`GRANT` authoring.** Table privileges are not contract elements; the one grant a Supabase app needs (the `service_role` `auth.*` pair for admin reads) is run once by hand (SQL editor / `psql`). If you want grants managed by the contract, file via `references/feedback.md`.
- **Transactions spanning the app root and the `.supabase` admin root.** The two roots are separate contract-bound runtimes sharing one pool; a cross-root transaction is not supported.
- **Triggers / functions as contract elements.** The classic "create a profile row on signup" `auth.users` trigger is authored as raw SQL against your database, not in the contract. `auth.uid()` etc. appear only inside opaque policy predicate strings.
- **Supabase Realtime, storage uploads, PostgREST / `@supabase/supabase-js` interop, edge runtimes.** Out of scope for the extension — it speaks Postgres directly (Node.js / Bun).
## Reference Files
- `examples/supabase` — the canonical runnable app: config, contract, `db.ts`, acceptance tests, README.
- `packages/3-extensions/supabase/README.md` — package-level reference (JWT modes, role-binding model, unsupported scope).
- `packages/3-extensions/supabase/src/runtime/supabase.ts` — the authoritative options/type surface (`SupabaseOptions`, `RoleBoundDb`, `ServiceRoleDb`).
## Checklist
- [ ] `extensions: [supabasePack]` (the `pack` export) in `ormConfig({...})` inside `definePrismaConfig({ orm: ... })`.
- [ ] Cross-space FK typed `supabase:auth.AuthUser` with explicit `fields` / `references` (+ `onDelete` if wanted).
- [ ] Every policy target model carries `@@rls`; predicates quote camelCase columns and cast for `auth.uid()`.
- [ ] `db.ts` uses `await supabase({ contract, url, jwksUrl | jwtSecret })`, with `contract` from `contractFromJson<Contract>(contractJson)` of `@internal/postgres/static` — exactly one JWT key source; `jwksUrl` for current projects, `jwtSecret` only for legacy HS256.
- [ ] Queries go through a `RoleBoundDb` from `asUser` / `asAnon` / `asServiceRole`; `asUser` is awaited.
- [ ] `auth.*` / `storage.*` reads go through `asServiceRole().supabase` only, after the one-time narrow grant (`GRANT USAGE ON SCHEMA auth` + `GRANT SELECT` on the tables you read).
- [ ] No per-table grants added for your own `public` tables — Supabase default privileges cover them; RLS does the protecting.
- [ ] Connection is session-capable: session pooler or direct connection — never the 6543 transaction pooler.
- [ ] Did NOT confabulate a `/control` subpath, a top-level `db.sql`, `.supabase` on non-service roles, or grant authoring in the contract.
references/upgrade-app.md
# Upgrade Prisma 8 (user app)
This reference upgrades a project that **consumes** Prisma 8 via the public package API (`@prisma/orm-postgres`, `@prisma/orm-mongo`, `@prisma/orm-sqlite`, the contract files in `prisma/`, etc.). Package names below are the published `@prisma/orm-*` names, which is what `package.json`, the lockfile, and the registry use; the other references in this skill spell import paths as `@internal/<target>`, and SKILL.md explains that mapping. If the project is itself a Prisma 8 *extension*, use [`upgrade-extension.md`](upgrade-extension.md) instead — or both, if the repo contains both an app and an extension package.
The per-transition instructions this reference reads live under [`../upgrading/app/upgrades/`](../upgrading/app/upgrades/).
## Step 0 — Upgrade to the newest instructions, then re-read
The upgrade instructions ship inside the installed Prisma packages, so the copy on disk describes the version currently installed — not the version being upgraded *to*. Bug fixes to *old* per-transition instructions ship with each release as part of the cumulative set, so the newest copy is the one to run.
Do the version bump first (step 1 of the per-step flow below), re-sync the skills from the newly installed packages (`prisma skills sync`), and re-read this reference and the per-transition instructions before applying any code translation. If the agent runtime supports an in-session refresh, perform it after the sync; otherwise finish the session's reasoning against the re-read files.
## Pre-flight — extension compatibility
Before changing any code, refuse to upgrade past any installed extension's pinned Prisma 8 version. Extensions in Prisma 8 pin every `@prisma/orm-*` dependency to a single exact version (no carets, no ranges); that pin is the highest version the extension has been validated against. Upgrading the user app past that pin would silently desynchronise the extension's type identity from the app's.
Steps:
1. **Read `prisma.config.ts`** (or its TS-discoverable equivalent at the project root) and enumerate the list of extension packages it imports. Each `extensions: [...]` entry corresponds to an installed npm package.
2. **For each extension**, read its installed `package.json` from `node_modules/<extension-package-name>/package.json` and find every `@prisma/orm-*` entry under `dependencies`, `peerDependencies`, or `optionalDependencies` (`@prisma/orm-framework`, `@prisma/orm-family-sql` or `@prisma/orm-family-mongo`, `@prisma/orm-toolchain`, and a `@prisma/orm-target-*` peer). By construction those entries are exact-version pins (e.g. `"8.0.0-rc.11"`), set when the extension author last ran their own upgrade.
3. **Compute the lowest pinned version across all extensions.** That is the highest Prisma 8 version reachable by this app on its current extension set.
4. **Compare to the user's target.** If the target exceeds the lowest pin, halt with a structured message naming each lagging extension and its pinned version, and offer two paths:
- (a) Wait for the lagging extension to publish a compatible release, then re-run.
- (b) Re-run with `--to=<highest-reachable>` (or whatever flag/option the user is using to set the target).
Do not auto-downgrade the target; do not skip the lagging extension; do not bump past it. If the user explicitly overrides the halt, surface the risk clearly first.
If `prisma.config.ts` is absent or names no extensions, skip the pre-flight.
## Role detection
This flow applies when the project **consumes** Prisma 8:
- `package.json` declares `@prisma/orm-postgres`, `@prisma/orm-mongo`, or `@prisma/orm-sqlite` under `dependencies` / `devDependencies`, and
- the package is *not* itself an extension (no `@prisma/orm-framework` or other SPI package under `dependencies`/`peerDependencies`; name does not match `^@.*/extension-`; not referenced from a sibling app's `prisma.config.ts`).
If the project also matches the extension-author role, run **this** flow first and then [`upgrade-extension.md`](upgrade-extension.md) in the same session. If detection is ambiguous, ask the user.
## Version detection
- **From-version.** Read the currently-installed Prisma 8 version from `pnpm-lock.yaml` (or `package-lock.json` / `yarn.lock`) by inspecting the resolved version of the app's Prisma packages: `@prisma/orm-postgres`, `@prisma/orm-mongo`, or `@prisma/orm-sqlite`. Do not read it from a `@prisma/orm-extension-*` package; extensions carry their own version and are handled by the pre-flight above. Compare full semver strings, prerelease identifier included: `8.0.0-rc.10` and `8.0.0-rc.11` are different versions and different steps in the chain below. If the lockfile shows the app's Prisma packages at different versions (already broken), the **lowest** is the from-version.
- **To-version.** Either the version the user specified, or whatever `npm view @prisma/orm-postgres dist-tags.latest` reports. Do not assume that is a stable version: while Prisma 8 is a release candidate, `latest` tracks the newest release, `8.0.0-rc.N` included. If the user wants a stable version specifically, they must name it.
Report both back to the user before continuing.
## Transition chain
If the from-to delta spans more than one release (e.g. `0.6 → 0.8`), build the chain of steps between them:
```text
0.6 → 0.7 → 0.8
```
The [`../upgrading/app/upgrades/`](../upgrading/app/upgrades/) directories name the steps — read the chain off the directory names rather than deriving it arithmetically. Each directory is `<from>-to-<to>`. A step normally spans one stable minor (`0.7-to-0.8`) or one release candidate (`8.0.0-rc.1-to-8.0.0-rc.2`). When intermediate versions were not published, a guide may span a larger hop; follow the available directory chain rather than inventing missing steps. Moving onto the RC line from the last stable minor is a single step of its own (`0.17-to-8.0.0-rc.1`).
Apply each step in order, fully: bump, install, run instructions, validate, commit — before moving to the next. Halt the chain on the first failed step; do not skip ahead.
The chain order does not depend on which extensions are installed; the pre-flight has already established the target is reachable.
## Per-step flow
For each `(from, to)` step in the chain:
1. **Bump the app's Prisma packages.** Rewrite every `@prisma/orm-postgres`, `@prisma/orm-mongo`, and `@prisma/orm-sqlite` entry in the project's `package.json` to the exact `<to>` version (no caret, no tilde). All entries advance to the same version. Cover `dependencies` and `devDependencies`. Leave `@prisma/orm-extension-*` entries unchanged: each extension pins its own Prisma version, and the pre-flight has already confirmed every installed extension supports `<to>`. The skill itself ships inside the Prisma packages, so bumping them is what updates it; there is no separate skill package to bump.
2. **Install.** Run `pnpm install` (or the project's lockfile-managing command). The project's code is now broken against the new types — the upgrade instructions for `<from> → <to>` exist to fix it.
3. **Read the upgrade instructions.** Re-sync the skills (`prisma skills sync`) so the tree matches the version just installed, then load `../upgrading/app/upgrades/<from>-to-<to>/instructions.md`. Parse the YAML frontmatter and pay particular attention to its `changes[]` array.
4. **Apply each change.** When the guide's body has a section that orders the steps, such as `Order of the steps`, apply the changes in that order: it says which script runs before which, and when to sign the databases and when to plan and apply a migration. Where an entry's prose says to sign or migrate, do it at the step that section names. Otherwise take the entries in the order of `changes[]`. For each entry in `changes[]`:
- If the entry has a `detection` block (glob + content predicate), run it; skip the change if no files match. No `detection` → apply unconditionally. Run the glob over the project's own files: skip `node_modules`, and build output such as `dist`, `build`, `.next` or `out` (whatever directories the project's build writes). A match there is generated code, which the next build rewrites; editing it changes nothing. Migration snapshots under `migrations/snapshots/` are committed files, not build output, so keep them.
- How a `detection` block runs: `glob` selects files relative to the project root. Each `matches` entry, once read from the YAML, is a JavaScript regular expression, built as `new RegExp(pattern)` with no flags and tested against the whole content of one file at a time; each `contains` entry is a plain substring. A file is a hit when any entry matches it. Patterns may span lines, and some look at the whole file (for example, to skip a file that declares something elsewhere in it), so a line-by-line search such as `grep` or `rg` without `-U` is not the same test and can report files the pattern excludes. From a shell, `rg -U --pcre2 -l -e '<pattern>' <files>` tests whole files and gives the same hits.
- If the entry names a `script:` (a relative path next to `instructions.md`), invoke it from the project root:
- `*.ts` → the command the guide's prose gives, such as `node <script>` (Node 24 or later runs TypeScript directly); with no command in the prose, `pnpm exec tsx <skill>/upgrading/app/upgrades/<from>-to-<to>/<script>`
- `*.sh` → `bash <skill>/upgrading/app/upgrades/<from>-to-<to>/<script>`
- codemods → invoke per the script's own prose.
No `script` → follow the prose body directly.
Empty `changes[]` (placeholder shape for transitions with no user-side breaking changes) is a no-op — proceed to validation.
5. **Validate.** Run `pnpm typecheck && pnpm test` (or the project's equivalent — the `scripts` field of the project's `package.json` is the discovery surface). If anything is red, halt the chain. Do **not** auto-roll-back; surface the failure to the user with the failing change's `id` (from the frontmatter), the file paths the change operated on, and the inferred remediation.
6. **Commit.** One commit per step containing the `package.json` bump, lockfile churn, and any source rewrites:
```text
chore: upgrade @prisma/orm-* to <to-version>
```
(Or the project's own commit-message convention.) Never squash steps. The user may squash on merge; the in-flight history must be per-step so a failed step is bisectable.
Then move on to the next step.
## When the chain is done
Report back to the user: the number of steps applied, the SHAs of the commits you made, and any open follow-ups (e.g. tests that were already red before the upgrade and still are).
## Failure surfaces
When a step fails: surface a structured error with code `PN-UPGRADE-NNNN`, the failing change's `id`, the file paths touched (or the lockfile, or the validation command), and the inferred remediation. Do not retry automatically; do not auto-roll-back. The user can revert if they want a clean slate.
If a pre-flight halt fires, do not bump anything; the project is left unchanged.
references/upgrade-extension.md
# Upgrade Prisma 8 (extension)
This reference upgrades a project that **is** a Prisma 8 extension — a package that consumes the framework SPI (`@prisma/orm-framework`, `@prisma/orm-family-sql` or `@prisma/orm-family-mongo`, `@prisma/orm-toolchain`, with a `@prisma/orm-target-*` peer) and exposes contract / middleware / codec / migration surfaces that downstream apps install via `prisma.config.ts`.
If the project you are upgrading is a consumer **app** (it depends on `@prisma/orm-postgres`, `@prisma/orm-mongo`, or `@prisma/orm-sqlite` from its application code), use [`upgrade-app.md`](upgrade-app.md) instead — or both, if the repo contains both a consumer app and an extension package, in which case run the app flow first then this one in the same session.
The per-transition instructions this reference reads live under [`../upgrading/extension/upgrades/`](../upgrading/extension/upgrades/).
## Step 0 — Upgrade to the newest instructions, then re-read
The upgrade instructions ship inside the installed Prisma packages, so the copy on disk describes the version currently installed — not the version being upgraded *to*. Bug fixes to *old* per-transition instructions ship with each release as part of the cumulative set, so the newest copy is the one to run.
Do the version bump first (step 1 of the per-step flow below), re-sync the skills from the newly installed packages (`prisma skills sync`), and re-read this reference and the per-transition instructions before applying any code translation.
## Role detection
This flow applies when the project **is** a Prisma 8 extension. Heuristics:
- `package.json` declares `@prisma/orm-framework` (or another `@prisma/orm-*` SPI package) under `dependencies` or `peerDependencies`, and
- the package's `name` matches `^@.*/extension-` (the convention used by `@prisma/orm-extension-pgvector`, etc.), or
- the package is referenced as an `extensions` entry from a sibling app's `prisma.config.ts` in the same monorepo.
If the project additionally consumes Prisma 8 from its own app code, run [`upgrade-app.md`](upgrade-app.md) first, then this flow in the same session.
If detection is ambiguous, ask the user which role to operate under.
## Version detection
- **From-version.** Read the currently-installed Prisma 8 version from `pnpm-lock.yaml` (or `package-lock.json` / `yarn.lock`) by inspecting the resolved version of any `@prisma/orm-*` SPI package (`@prisma/orm-framework`, `@prisma/orm-family-*`, `@prisma/orm-target-*`, `@prisma/orm-toolchain`). Do not read it from a `@prisma/orm-extension-*` dependency; another extension carries its own version. Compare full semver strings, prerelease identifier included: `8.0.0-rc.10` and `8.0.0-rc.11` are different versions and different steps in the chain below. If the lockfile shows the SPI packages at different versions, the lowest is the from-version.
- **To-version.** Either the version the user specified, or whatever `npm view @prisma/orm-framework dist-tags.latest` reports. Do not assume that is a stable version: while Prisma 8 is a release candidate, `latest` tracks the newest release, `8.0.0-rc.N` included. If the user wants a stable version specifically, they must name it.
Report both back to the user before continuing.
## Transition chain
If the from-to delta spans more than one release (e.g. `0.6 → 0.8`), build the chain of steps between them:
```text
0.6 → 0.7 → 0.8
```
The [`../upgrading/extension/upgrades/`](../upgrading/extension/upgrades/) directories name the steps — read the chain off the directory names rather than deriving it arithmetically. Each directory is `<from>-to-<to>`. A step normally spans one stable minor (`0.7-to-0.8`) or one release candidate (`8.0.0-rc.1-to-8.0.0-rc.2`). When intermediate versions were not published, a guide may span a larger hop; follow the available directory chain rather than inventing missing steps. Moving onto the RC line from the last stable minor is a single step of its own (`0.17-to-8.0.0-rc.1`).
Apply each step in order, fully: bump, install, run instructions, check pins, validate, commit — before moving to the next. Halt the chain on the first failed step.
## Per-step flow
This flow assumes you are an **external extension author** — your extension lives in its own repo and consumes `@prisma/orm-*` from npm. (Extensions inside the `prisma/prisma` monorepo itself are bumped via `pnpm bump-version` / `scripts/set-version.ts`, which rewrites every `workspace:<X.Y.Z>` spec in lockstep with the root version; they do not run this skill.)
For each `(from, to)` step in the chain:
1. **Bump `@prisma/orm-*` deps.** Rewrite every `@prisma/orm-*` entry other than `@prisma/orm-extension-*` in the extension's `package.json` to the exact `<to>` version (e.g. `"8.0.0-rc.11"` — no caret, no tilde, no range, no `workspace:` specifier; the exact-pin rule below details why). All entries advance to the same version. Cover whichever dep field(s) the extension uses today — `dependencies` and/or `peerDependencies` — and any `optionalDependencies`. The skill itself ships inside the Prisma packages, so bumping them is what updates it; there is no separate skill package to bump.
2. **Install.** Run `pnpm install` (or the project's lockfile-managing command). The extension's source is now broken against the new SPI — the upgrade instructions for `<from> → <to>` exist to fix it.
3. **Check pins.** Every `@prisma/orm-*` entry other than `@prisma/orm-extension-*` across `dependencies`, `peerDependencies`, and `optionalDependencies` must now be the single exact string `<to>`. This prints any entry that is not, and exits 1:
```bash
node -e 'const p=require("./package.json");const to=process.argv[1];const bad=[];for(const f of ["dependencies","peerDependencies","optionalDependencies"])for(const [n,v] of Object.entries(p[f]??{}))if(n.startsWith("@prisma/orm-")&&!n.startsWith("@prisma/orm-extension-")&&v!==to)bad.push(f+": "+n+"@"+v);if(bad.length){console.error(bad.join("\n"));process.exit(1)}' <to>
```
If it fails, the bump step did not rewrite every spec — fix the offending entries and re-run before proceeding.
4. **Read the upgrade instructions.** Re-sync the skills (`prisma skills sync`) so the tree matches the version just installed, then load `../upgrading/extension/upgrades/<from>-to-<to>/instructions.md`. Parse the YAML frontmatter and pay particular attention to its `changes[]` array.
5. **Apply each change.** When the guide's body has a section that orders the steps, such as `Order of the steps`, apply the changes in that order: it says which script runs before which, and when to sign the databases and when to plan and apply a migration. Where an entry's prose says to sign or migrate, do it at the step that section names. Otherwise take the entries in the order of `changes[]`. For each entry in `changes[]`:
- If the entry has a `detection` block (a glob + content predicate), run it. If no files match, skip this change. Run the glob over the project's own files: skip `node_modules`, and build output such as `dist`, `build`, `.next` or `out` (whatever directories the project's build writes). A match there is generated code, which the next build rewrites; editing it changes nothing. Migration snapshots under `migrations/snapshots/` are committed files, not build output, so keep them.
- How a `detection` block runs: `glob` selects files relative to the project root. Each `matches` entry, once read from the YAML, is a JavaScript regular expression, built as `new RegExp(pattern)` with no flags and tested against the whole content of one file at a time; each `contains` entry is a plain substring. A file is a hit when any entry matches it. Patterns may span lines, and some look at the whole file (for example, to skip a file that declares something elsewhere in it), so a line-by-line search such as `grep` or `rg` without `-U` is not the same test and can report files the pattern excludes. From a shell, `rg -U --pcre2 -l -e '<pattern>' <files>` tests whole files and gives the same hits.
- If the entry has no `detection`, apply unconditionally.
- If the entry names a `script:` (a relative path next to `instructions.md`), invoke it from the project root:
- `*.ts` → the command the guide's prose gives, such as `node <script>` (Node 24 or later runs TypeScript directly); with no command in the prose, `pnpm exec tsx <skill>/upgrading/extension/upgrades/<from>-to-<to>/<script>`
- `*.sh` → `bash <skill>/upgrading/extension/upgrades/<from>-to-<to>/<script>`
- codemods → invoke per the script's own `instructions.md` prose.
- If the entry has no `script`, follow the prose body in `instructions.md` directly.
If `changes[]` is empty (the placeholder shape for transitions with no extension-side breaking changes), this sub-step is a no-op — proceed to validation.
6. **Validate.** Run `pnpm build && pnpm test` (or the project's equivalent — the `scripts` field of the extension's `package.json` is the discovery surface). If anything is red, halt the chain. Do **not** auto-roll-back; surface the failure to the user with the failing change's `id` (from the frontmatter), the file paths the change operated on, and the inferred remediation.
7. **Commit.** Create one commit containing this step's changes: the `package.json` bump, the lockfile churn from `pnpm install`, and any source-file rewrites from the applied changes. Use the message:
```text
chore: upgrade @prisma/orm-* to <to-version>
```
(Or the extension's own commit-message convention, if it has one.) One commit per step — never squash steps.
Move on to the next step. Repeat.
## Exact-pin rule
Prisma 8 extensions pin every `@prisma/orm-*` SPI dependency (everything except `@prisma/orm-extension-*`) to a single **exact** version (no `^`, no `~`, no range, no wildcard, no `workspace:` specifier in the published `package.json`). All of those entries share the same version. The pin advances after each successful upgrade step, release-candidate steps included.
The version is the one the application's database package (`@prisma/orm-postgres`, `@prisma/orm-mongo`, …) uses. Values and registries made by one copy of Prisma ORM are not recognised by another, so they must never cross between copies. A host may load two copies on purpose, as Prisma Composer's boot wrapper does beside the application bundle, but the application, its extensions and the `prisma` CLI must share one. When an extension pins a different version, the package manager installs a second copy beside the application's, and the extension's values then cross between copies. The target package (such as `@prisma/orm-target-postgres`) is therefore a **peer dependency**, so npm refuses a mismatched install instead of adding a copy. The framework and family packages are ordinary dependencies at the same version. pnpm and bun only warn about a mismatched peer, so a second copy can still be installed. `npm ls @prisma/orm-framework`, `pnpm why @prisma/orm-framework` or `bun why @prisma/orm-framework` lists every copy and the package that brought it.
The one-line check in step 3 of the per-step flow enforces the rule; the `prisma-8-check-pins` guard the monorepo uses for its own extensions is not published. Wire the check into the extension's CI alongside the build/test step so an accidental range pin fails the PR before it lands.
## When the chain is done
Report back to the user: the number of steps applied, the SHAs of the commits you made, and any open follow-ups.
## Failure surfaces
When a step fails:
- Surface a structured error with code `PN-UPGRADE-NNNN`, the failing change's `id`, the file paths the change touched (or the lockfile, or the pin check, or the validation command), and the inferred remediation.
- Do not retry automatically.
- Do not auto-roll-back the commit. The user can revert if they want a clean slate.
SKILL.md
---
name: prisma-8
description: >-
Use when working in a project that depends on @prisma/orm-postgres,
@prisma/orm-sqlite, or @prisma/orm-mongo (Prisma 8, formerly Prisma Next): editing
contract.prisma or a contract.ts builder, running `prisma contract emit`,
planning or applying migrations, editing migration.ts, writing db.orm /
db.sql / db.query queries, wiring db.ts or middleware, integrating a build
tool, using the Supabase extension or RLS, or reading a dotted error code
such as MIGRATION.HASH_MISMATCH. Use when the user asks "what is Prisma
8", "where do I start", or compares it to another ORM. Use when the user
asks to upgrade or bump Prisma 8 in an app or an extension package. Use when
you see @internal/* or @prisma/orm-* imports, prisma.config.ts with
definePrismaConfig, or contract.json / contract.d.ts. Do not use for Prisma
ORM 7 or earlier (schema.prisma + @prisma/client).
metadata:
library: '@prisma/orm-postgres'
library_version: '8.0.0-rc.23'
version: '2026-09-12'
---
# Prisma 8 (Prisma 8)
> **Edit your data contract. Prisma handles the rest.**
Prisma 8 moves fast, and your training data about it is very likely outdated. This skill ships inside the installed Prisma packages, so it describes the exact version this project has — treat it and its reference files as the source of truth, over anything you remember about Prisma.
## Pre-conditions
Check these before acting on anything below. Halt on the first one that fails and tell the user what is missing.
1. **The project is on Prisma 8.** `prisma.config.ts` exports `definePrismaConfig({ orm: ... })`, and `package.json` depends on `@prisma/orm-postgres`, `@prisma/orm-sqlite`, or `@prisma/orm-mongo`. A project with `schema.prisma` and `@prisma/client` is Prisma 7 or earlier; this skill does not apply to it, and its instructions will break such a project.
2. **The skill matches the installed version.** Compare `metadata.library_version` in this file's frontmatter with the installed `@prisma/orm-*` version in `package.json`. If they differ, run `prisma skills sync` and re-read this file before continuing.
3. **The contract artefacts exist.** `contract.json` and `contract.d.ts` sit next to the contract source named by `prisma.config.ts`. If they are missing or older than the source, run `prisma contract emit` first; every query and migration instruction below assumes current artefacts.
**Import paths in the references.** The reference files spell façade imports as `@internal/<target>/<subpath>` and `@internal/extension-<name>/<subpath>`. In an application those packages are published as `@prisma/orm-<target>/<subpath>` (`@prisma/orm-postgres/runtime`, `@prisma/orm-mongo/config`, `@prisma/orm-sqlite/runtime`) and `@prisma/orm-extension-<name>/<subpath>` (`@prisma/orm-extension-pgvector/control`). Write the `@prisma/orm-*` name in user code; the two spellings are the same package. Paths already written as `@prisma/orm-*` in the references are exact. The `metadata.library_version` in this file's frontmatter is the version it was published with; if it does not match the project's installed Prisma packages, run `prisma skills sync` and re-read.
## Never reset or drop a database
Never reset or drop a database to fix a migration, marker or contract problem, and never propose it. A database can always be moved to the contract you want. When its migration history does not need to be kept (a local, development, staging or preview database), reconcile it with `prisma db update`. When it changes only through migrations, as production should, author the migrations that bring it there. A deploy that says there is no migration path, or `MIGRATION.MARKER_MISMATCH`, is covered in [`references/migrations.md`](references/migrations.md) § *A database whose marker is not in the migration graph*.
Prisma 8 is a contract-first data layer. This skill routes every Prisma 8 task to the right reference file — open the reference before writing code; do not answer from this file alone.
## The canonical model (one paragraph)
You author a **data contract** (a `contract.prisma` file, or a TypeScript builder). The framework emits machine-readable artifacts (`contract.json`, `contract.d.ts`) and gives you two runtime surfaces on SQL targets: a typed SQL query builder (`db.sql.<ns>.<table>`) and a typed ORM client (`db.orm.<ns>.<Model>`). On MongoDB targets only the ORM lane exists, and its keys are collection storage names (`db.orm.users`) rather than PSL model names — [`references/queries.md`](references/queries.md) § *MongoDB ORM addressing* covers the rule. Migrations are planned from the contract diff; you review them, optionally edit the `migration.ts` for data transforms, and apply.
Three steps the user does:
1. **Edit your data contract.** ([`references/contract.md`](references/contract.md))
2. **The system plans the migrations for you.** ([`references/migrations.md`](references/migrations.md))
3. **If you need data migrations, you edit `migration.ts` and execute it.** ([`references/migrations.md`](references/migrations.md))
Everything else — queries, runtime wiring, build integration, debugging, feedback — sits on top of those three.
One cross-cutting migration fact: `migration plan` does **not** chain from the newest migration on disk. Its origin is `--from`, else the `db` ref, else an empty database — so a project with no ref keeps planning from scratch. Over existing migrations the CLI refuses that (`MIGRATION.PLAN_ORIGIN_UNKNOWN`) instead of writing a full-create package; choose the exit that matches your intent rather than reflexively passing `--from @empty`. [`references/migration-model.md`](references/migration-model.md) § *The trap* explains which to choose.
## Routing table
Open the reference whose triggers match the task. If more than one matches, open each — they are written to compose.
| Task | Reference | Triggers |
| --- | --- | --- |
| Adopt / set up / first steps | [`references/quickstart.md`](references/quickstart.md) | new project, existing database, "what can I do with Prisma 8", "where do I start", "just ran createprisma", `npx create-prisma`, first steps, first query, `prisma orm init` greenfield setup, `contract infer` + `db sign` brownfield adoption, connect-write-read first arc, day-to-day commands (`contract emit`, `db init`, `db update`, `migration plan`, `db migrate`, `db schema`, `db verify`), flags `--target` / `--authoring` / `--schema-path` / `--probe-db` / `--output` |
| Edit the data contract | [`references/contract.md`](references/contract.md) | schema, models, fields, attributes, relations, indexes, enums, value objects (composite types), type aliases, namespaces (Postgres schemas), cross-contract foreign keys (cross-space FK), polymorphic types (`@@discriminator` / `@@base`), extension namespaces (`pgvector.Vector(...)`, `postgis.Geometry(...)`), `prisma.config.ts` / `definePrismaConfig` / `ormConfig`, `prisma contract emit`, PSL, `contract.prisma`, `contract.ts`, `contract.json`, `contract.d.ts`, `@internal/postgres/config`, `@internal/postgres/contract-builder`, `@internal/mongo/config`, `extensions:`, pgvector, postgis, paradedb, Temporal / `temporal-polyfill` / `RUNTIME.TEMPORAL_UNAVAILABLE`, `@@control`, control policy (managed / tolerated / external / observed), soft delete, validations, callbacks |
| Author migrations | [`references/migrations.md`](references/migrations.md) | "no migration path", "migration path not found", "no authored path", `MIGRATION.MARKER_MISMATCH`, "reset the database", "drop the database", `db update` vs `migration plan`, `db migrate`, `migration new`, `migration show`, `db update --dry-run`, `db verify`, `db sign`, data migration, `dataTransform`, placeholder sentinels in framework-rendered `migration.ts`, `MIGRATION.HASH_MISMATCH`, `MIGRATION.UNFILLED_PLACEHOLDER`, data-loss questions (`--delete`, `--rename`, `--allow`, `CLI.CONSENT_REQUIRED`), schema drift |
| Migration graph, refs, plan origin | [`references/migration-model.md`](references/migration-model.md) | migration graph, refs, `migration ref set` / `list` / `delete`, the `db` ref, `--advance-ref`, `--no-advance-ref`, `migration plan --from`, `from: (baseline)` in plan output, greenfield / from-scratch plan, baseline, first migration before deploy (Composer / CD-managed databases), chaining migrations, retrofitting migrations onto an existing database, `MIGRATION.HASH_NOT_IN_GRAPH`, `MIGRATION.PATH_UNREACHABLE` at plan/chain time |
| Review migrations on deploy | [`references/migration-review.md`](references/migration-review.md) | "what migrations are going to run", "what runs on deploy / merge", merge conflict, diamond convergence, concurrent migrations, migration status, ref management for CI, staging / production environment refs, `MIGRATION.MARKER_NOT_IN_HISTORY`, `MIGRATION.MISSING_INVARIANTS`, `db migrate --show` |
| Write queries | [`references/queries.md`](references/queries.md) | query, where, select, project, orderBy, limit, offset, take, skip, include, lookup, first, all, count, aggregate, groupBy, create, update, delete, upsert, returning, transaction, `db.orm`, `db.sql`, `runtime.query(plan)` vs `runtime.execute(plan)`, `db.prepare` / prepared statements, streaming / `for await`, many-to-many `include` and nested `connect`, `and` / `or` / `not` from `@prisma/orm-postgres/orm-client`, `db.query.from(...)` (Mongo pipeline), namespace-aware accessors, `.all()` Thenable, single-use iterators (`RUNTIME.ITERATOR_CONSUMED`), target-declared aggregate types (`count`, integer `sum`, and integer `avg` are `number`; `count` and integer `sum` throw outside ±(2^53 − 1) rather than round, while `avg` is a fraction already and carries no guard; `countBigInt` / `sumBigInt` / `avgDecimal` are the lossless forms, `avgDecimal` on PostgreSQL only), custom collection class (`extends Collection`), `with` and query fragments (`QueryFragment`, `Filtered`, `Ordered`), shared query fragments and scopes (`db.orm.fragment(fields, body)` for a filter on every model with given fields, `CodecField`, `db.orm.public.Post.fragment(...)` for a shared `select` / `include`, `orderByField` for an order or sort parameter from a request), drizzle-style, kysely-style. Naming types (§ *Naming model and result types*): "type of my model", "return type of a query", `ResultType`, `Scalars`, `Shape`, `models` / `Models` namespace in `contract.d.ts`, `Models.public_User`, Prisma 7 `Prisma.User` / `GetPayload` / `UserGetPayload` equivalents. Postgres/SQLite specifics: [`references/queries-postgres.md`](references/queries-postgres.md); Mongo specifics: [`references/queries-mongo.md`](references/queries-mongo.md) |
| Wire the runtime | [`references/runtime.md`](references/runtime.md) | `db.ts`, `contractFromJson<Contract>(...)`, `postgres({ contract })` / `sqlite({ contract })` / `mongo({ contract })` façades, middleware composition (lints, budgets, cache via `@prisma/orm-extension-middleware-cache`, custom `activityEnded` middleware, `@prisma/orm-postgres/family-runtime`), `DATABASE_URL`, `.env`, connection pool / `poolOptions`, dev vs prod config, transactions, read replicas, multi-database, script won't exit / hangs, `db.close` / `pool.end`, `await using` / `[Symbol.asyncDispose]` |
| Build-tool integration | [`references/build.md`](references/build.md) | Vite plugin (`@internal/vite-plugin-contract-emit`, Vite 7/8), `vite.config.ts`, contract emit on save, HMR / dev server, Next.js / Webpack / esbuild / Rollup / Turbopack (named gaps, not fabricated) |
| Supabase | [`references/supabase.md`](references/supabase.md) | `@internal/extension-supabase`, RLS, row level security, policies (`policy_select` / `policy_update` / `@@rls`, `auth.uid()`), role binding (`asUser(jwt)` / `asAnon()` / `asServiceRole()`), `auth.users`, cross-space FKs to `supabase:auth.AuthUser`, JWT / JWKS (`SUPABASE_JWKS_URL`, `SUPABASE_JWT_SECRET`), `SUPABASE.JWT_INVALID`, `SUPABASE.CONFIG_INVALID`, `RoleBoundDb`, session pooler |
| Debug an error | [`references/debug.md`](references/debug.md) | any structured error envelope (code, severity, why, fix, nextActions, meta), emit failed, query won't typecheck, query crashed, migration won't apply, `MIGRATION.HASH_MISMATCH`, `MIGRATION.SCHEMA_VERIFY_FAILED`, `MIGRATION.PRECHECK_FAILED`, `BUDGET.ROWS_EXCEEDED`, `BUDGET.TIME_EXCEEDED`, `RUNTIME.ABORTED`, `RUNTIME.TEMPORAL_UNAVAILABLE`, `PLAN.HASH_MISMATCH`, `CONTRACT.MARKER_MISSING`, `CONFIG.*` / `CLI.*` / `CONTRACT.*` / `MIGRATION.*` / `ORM.*` / `RUNTIME.*` codes, legacy `PN-*` codes, exit code 4 findings, drift, capability missing, planner conflict, EXPLAIN, query log, script won't exit / close connection |
| Upgrade Prisma in an app | [`references/upgrade-app.md`](references/upgrade-app.md) | "upgrade Prisma", "upgrade Prisma 8", "bump Prisma 8", "move to Prisma 8 X.Y", `@internal/*` version bump in an application, per-transition upgrade instructions in [`upgrading/app/upgrades/`](upgrading/app/upgrades/), extension-pin pre-flight, `PN-UPGRADE-*` |
| Upgrade Prisma in an extension | [`references/upgrade-extension.md`](references/upgrade-extension.md) | the same request in a package that *is* a Prisma extension (`@internal/contract` / SPI dependency, `^@.*/extension-` name), `prisma-8-check-pins`, exact-pin rule, per-transition instructions in [`upgrading/extension/upgrades/`](upgrading/extension/upgrades/) |
| File feedback / ask the team | [`references/feedback.md`](references/feedback.md) | bug report, file an issue, feature request, missing feature, capability gap, "this is broken", surprising behaviour, Q&A / design discussion, ask the Prisma team, Prisma Discord (pris.ly/discord), extension-author questions |
## Routing rules
If the task clearly matches a row, open that reference directly without asking.
For a vague prompt, ask **one** disambiguating question. Pick from:
- *"Are you new to Prisma 8 and asking what you can do with it, or where to start?"* → [`references/quickstart.md`](references/quickstart.md) (first-touch orientation path).
- *"Do you want to set up a new Prisma 8 project, or wire it into an existing database?"* → [`references/quickstart.md`](references/quickstart.md).
- *"Do you want to edit your data contract (add a model / field / relation), or work with the database (migrations, queries)?"* → [`references/contract.md`](references/contract.md) vs the others.
- *"Is this about authoring a migration, or about reviewing what's going to run on deploy?"* → [`references/migrations.md`](references/migrations.md) vs [`references/migration-review.md`](references/migration-review.md). If it's about where a plan starts, refs, or an unexpected from-scratch plan → [`references/migration-model.md`](references/migration-model.md).
- *"Is this about wiring Prisma 8 into your build tool (Vite / Next.js / …), or about wiring `db.ts` and middleware at runtime?"* → [`references/build.md`](references/build.md) vs [`references/runtime.md`](references/runtime.md).
- *"What error or symptom are you seeing?"* → [`references/debug.md`](references/debug.md).
- *"Do you want to report this as a bug to the Prisma 8 team, or is this a feature request?"* → [`references/feedback.md`](references/feedback.md).
- *"Is the project you want to upgrade an application, or a Prisma extension package?"* → [`references/upgrade-app.md`](references/upgrade-app.md) vs [`references/upgrade-extension.md`](references/upgrade-extension.md).
If you still can't tell which reference applies, ask the user what they want to do. Do not guess.
## Checklist
- [ ] If the task matches a routing-table row, open that reference before writing code.
- [ ] If the prompt is vague, ask one disambiguating question.
- [ ] Do not attempt to answer from this file alone — the references carry the verified tool surface.
- [ ] If the user describes a missing feature or a misbehaviour they want fixed, open [`references/feedback.md`](references/feedback.md).
upgrading/app/upgrades/0.10-to-0.11/instructions.md
---
from: "0.10"
to: "0.11"
changes:
- id: insert-single-row-wrap-in-array
summary: Wrap single-row `.insert({...})` call sites in an array — `.insert([{...}])`. The single-object overload is removed; `.insert()` now exclusively accepts an array of row objects.
detection:
glob: "**/*.{ts,tsx}"
contains:
- ".insert("
anyMatch: true
---
# 0.10 → 0.11 — User upgrade instructions
## `insert-single-row-wrap-in-array`
Starting at the 0.11 release, the `.insert()` method on the SQL builder accepts **only** an array of row objects. The single-object overload that previously allowed `.insert({ field: value })` is removed.
Before 0.11:
```ts
await runtime.execute(db.sql.user.insert({ email: 'alice@example.com' }).build());
```
Starting at 0.11:
```ts
await runtime.execute(db.sql.user.insert([{ email: 'alice@example.com' }]).build());
```
Walk every `.ts` / `.tsx` file matched by the `detection.glob` above. For each call site that passes a plain object directly to `.insert(...)`, wrap the argument in an array:
- `.insert(row)` → `.insert([row])`
- `.insert({ field: value })` → `.insert([{ field: value }])`
Variable references to a row object are safe to wrap directly:
```ts
// Before
for (const item of items) {
await runtime.execute(db.sql.table.insert(item).build());
}
// After
for (const item of items) {
await runtime.execute(db.sql.table.insert([item]).build());
}
```
If a call site already passes an array (`.insert([row1, row2])`), it is already correct — leave it unchanged.
TypeScript will flag bare-object call sites as type errors after the bump, providing a reliable compile-time signal for every affected site.
### Validation
After applying the rule above, run `pnpm typecheck && pnpm test` (or your application's equivalent). The change is mechanical — every affected call site is flagged at compile time.
upgrading/app/upgrades/0.11-to-0.12/instructions.md
---
from: "0.11"
to: "0.12"
changes:
- id: replace-verify-with-verify-marker
summary: |
The SQL runtime's `verify: { mode; requireMarker }` option is removed; replaced by `verifyMarker?: 'onFirstUse' | false` (default `'onFirstUse'`). The runtime no longer throws on contract-marker drift — instead it emits a structured `warn`-level log line once per runtime instance and proceeds with the query. Callers that previously caught `CONTRACT.MARKER_MISMATCH` to detect deploy-skew migrate to log scraping (filter on `code: 'CONTRACT.MARKER_MISMATCH'` / `code: 'CONTRACT.MARKER_MISSING'` from the runtime's `Log.warn` sink) or invoke the explicit `db-verify` CLI for fail-fast verification.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "verify:"
- "requireMarker"
anyMatch: false
- id: remove-capabilities-from-define-contract
summary: |
The `capabilities` field on the first argument of `defineContract({...}, ...)` is removed. Capabilities are now contributed automatically by extension packs and target components; declaring them by hand is no longer accepted and the contract builder will refuse the literal. Delete the `capabilities: { ... }` block from every `defineContract` call site, then re-emit your contract artefacts (`pnpm emit`, which runs `prisma-next contract emit`) to refresh `contract.json` / `contract.d.ts`. The regenerated artefacts pick up the contributor-declared capabilities — including two new ones in the 0.12 line, `postgres.distinctOn` and `sql.lateral`, which extensions contribute on your behalf when their pack is in `extensionPacks`.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "defineContract"
- "capabilities:"
anyMatch: false
- id: strip-migration-labels-hints
summary: |
The 0.12 migration manifest schema is closed (`'+': 'reject'`) and the metadata model no longer carries `labels` or `hints`; any on-disk `migration.json` still holding either key fails to load with `INVALID_MANIFEST` naming the offending key. Both fields are also dropped from the content-addressed migration identity, so `migrationHash` is now computed over `{ from, to, providedInvariants, createdAt }` plus the sibling `ops.json`. Run the colocated codemod to strip both keys from every `migration.json` and recompute its `migrationHash` over the slimmed envelope.
detection:
glob: "**/migration.json"
contains:
- '"labels"'
- '"hints"'
anyMatch: true
script: ./strip-migration-labels-hints.ts
- id: re-emit-closed-mongo-contracts
summary: |
Re-emit Mongo contract artefacts so emitted `$jsonSchema` validators are closed (`additionalProperties: false` at every level, including polymorphic `oneOf` branches). Each non-variant Mongo model must resolve to an `objectId` `_id` before emit succeeds — otherwise interpret fails with `PSL_MONGO_ID_REQUIRED`. After re-emitting, apply the open→closed validator migration with `prisma-next db update -y`; the planner classifies the tightening as `destructive` and refuses without confirmation.
detection:
glob: "**/contract.json"
contains:
- '"kind": "mongo-database"'
anyMatch: true
script: ./re-emit-closed-mongo-contracts.ts
- id: public-default-namespace
summary: |
Un-namespaced Postgres models now emit under the `public` namespace instead of the `__unbound__` sentinel; explicit `namespace unbound { … }` in PSL still round-trips to `__unbound__`. Re-emit contract artefacts (`pnpm emit`, i.e. `prisma-next contract emit`) so `contract.json` / `contract.d.ts` pick up the new storage/domain namespace key (`__unbound__`/`postgres-unbound-schema` → `public`/`postgres-schema`). No hand-editing of emitted JSON is required when the PSL/TS contract source is unchanged.
detection:
glob: "**/contract.json"
contains:
- '"kind": "postgres-unbound-schema"'
anyMatch: true
script: ./re-emit-postgres-public-default.ts
- id: domain-plane-namespaced-contract
summary: |
`contract.models` / `contract.valueObjects` moved under `contract.domain.namespaces.<ns>` (symmetric domain plane). Re-emit contract artefacts (`pnpm emit`) so emitted JSON and `contract.d.ts` carry the namespaced domain envelope; generated types switch from `Contract['models']` to `ContractModelsMap<Contract>`.
detection:
glob: "**/contract.d.ts"
contains:
- "Contract['models']"
anyMatch: true
script: ./re-emit-domain-namespaced-contracts.ts
- id: runtime-qualified-sql-default-namespace
summary: |
Postgres runtime SQL now emits namespace-qualified table identifiers (e.g. `"public"."user"`). Flat `db.sql.*` / `db.*` call sites are unchanged. Update integration or snapshot tests that assert raw SQL strings. Re-emit contract artefacts only if you are still catching up an earlier 0.12 namespacing transition.
detection:
glob: "**/*.{ts,tsx}"
contains:
- 'FROM "user"'
anyMatch: true
---
# 0.11 → 0.12 — User upgrade instructions
## `replace-verify-with-verify-marker`
Starting at the 0.12 release, the SQL runtime's marker-verification API is simplified. The previous `verify: { mode; requireMarker }` option carried two concerns — *when* to verify and *whether to throw on absent markers* — both of which leaked internal implementation detail into the public API. The new option is a single discriminated union: `verifyMarker?: 'onFirstUse' | false`, with `'onFirstUse'` as the default.
The runtime's response to contract-marker drift also changes. Previously the runtime threw `CONTRACT.MARKER_MISMATCH` (or `CONTRACT.MARKER_MISSING`) on every query when the database's contract hash didn't match the runtime's. From 0.12 onward, the runtime emits a structured `warn`-level log line **once per runtime instance** and proceeds with the query. The intent is to make rolling deploys safe by default: a drifted-but-running app surfaces the warning loudly without crashing every query for the duration of the deploy window.
### Migration
Walk every call site that constructs a SQL runtime via `createRuntime(...)` or the convenience wrappers (`sqlite(...)`, `postgres(...)`, `postgresServerless(...)`).
For each call site that passes `verify: {...}`:
- `verify: { mode: 'onFirstUse', requireMarker: false }` → `verifyMarker: 'onFirstUse'` (or simply omit the option — `'onFirstUse'` is the default).
- `verify: { mode: 'onFirstUse', requireMarker: true }` → `verifyMarker: 'onFirstUse'`. The `requireMarker: true` semantics (throw on absent marker) is removed; if you need fail-fast verification, use the `db-verify` CLI command at deploy time instead of relying on the runtime to crash.
- `verify: { mode: 'always', requireMarker: ... }` → `verifyMarker: 'onFirstUse'`. The `'always'` mode (re-verify on every query) is dropped; verification is now once-per-runtime regardless of mode. The CLI `db-verify` command remains the explicit-verification surface.
- `verify: { mode: 'startup', requireMarker: ... }` → `verifyMarker: 'onFirstUse'`. The `'startup'` mode is dropped for the same reason — without the throw-on-mismatch semantic, the `'startup'` vs `'onFirstUse'` distinction collapsed to "same behaviour, different timing." Verification fires lazily on the first `execute()` call.
- If you explicitly want to skip marker verification entirely (e.g. during a known-skewed deploy window where contract drift is expected and tolerated): `verifyMarker: false`.
### Before 0.12
```ts
const runtime = createRuntime({
stackInstance,
context,
driver,
verify: { mode: 'onFirstUse', requireMarker: false },
});
try {
for await (const row of runtime.execute(plan)) {
// ...
}
} catch (err) {
if (err.code === 'CONTRACT.MARKER_MISMATCH') {
// deploy-skew detected — crash and let the orchestrator restart us
process.exit(1);
}
throw err;
}
```
### Starting at 0.12
```ts
const runtime = createRuntime({
stackInstance,
context,
driver,
log: {
info: console.info,
warn: (payload) => {
console.warn(payload);
if (
payload.code === 'CONTRACT.MARKER_MISMATCH' ||
payload.code === 'CONTRACT.MARKER_MISSING'
) {
// optional: forward to your observability surface
sendToTelemetry(payload);
}
},
error: console.error,
},
// verifyMarker omitted — 'onFirstUse' is the default
});
for await (const row of runtime.execute(plan)) {
// ...
}
```
The runtime now does not crash on drift — it emits one structured log line per runtime instance, then proceeds. Operators who want fail-fast verification at deploy time (rather than as a per-runtime diagnostic) should invoke the `db-verify` CLI as part of their deployment pipeline.
### Type-level change
The `RuntimeVerifyOptions` type is removed from `@internal/sql-runtime` exports; replaced by `VerifyMarkerOption = 'onFirstUse' | false`. Any consumer code that imports `RuntimeVerifyOptions` will fail to compile after the bump.
```diff
-import type { RuntimeVerifyOptions } from '@internal/sql-runtime';
+import type { VerifyMarkerOption } from '@internal/sql-runtime';
```
### Validation
After applying the rule above, run `pnpm typecheck && pnpm test` (or your application's equivalent). The change is mechanical: TypeScript flags every `verify: {...}` call site as a type error after the bump, and every `RuntimeVerifyOptions` import similarly. Once those errors are resolved, the behaviour change (warn-log instead of throw on drift) shows up only at runtime when a marker mismatch actually occurs.
## `remove-capabilities-from-define-contract`
Starting at the 0.12 release, the `capabilities` field on the first argument of `defineContract({...}, ...)` is removed. Capabilities are now contributed automatically by the target's components and the extension packs you load via `extensionPacks: { ... }`; the contract builder will refuse a literal `capabilities` key. Hand-declaring capabilities was redundant with — and frequently drifted from — the contributor-declared set, so the authoring surface drops the field outright.
Two consumer-visible consequences:
- **Source change**: delete the `capabilities: { ... }` block from every `defineContract` call site.
- **Emitted artefacts**: the regenerated `contract.json` / `contract.d.ts` will pick up the contributor-declared capabilities. In the 0.12 line, two new capability keys land automatically — `postgres.distinctOn` and `sql.lateral` — when the matching adapter / target component is in the contract's component graph.
### Before 0.12
```ts
import { defineContract } from '@internal/postgres/contract-builder';
import { pgvector } from '@internal/pgvector';
export const contract = defineContract(
{
extensionPacks: { pgvector },
capabilities: {
postgres: {
lateral: true,
jsonAgg: true,
returning: true,
'pgvector.cosine': true,
},
},
},
({ field, model }) => {
// … model definitions …
},
);
```
### Starting at 0.12
```ts
import { defineContract } from '@internal/postgres/contract-builder';
import { pgvector } from '@internal/pgvector';
export const contract = defineContract(
{
extensionPacks: { pgvector },
},
({ field, model }) => {
// … model definitions …
},
);
```
If your first argument becomes `{}` after the deletion (the only field it carried was `capabilities`), simplify to `defineContract({}, ({ field, model }) => { … })`. TypeScript flags any remaining `capabilities:` key on a `defineContract` call as an excess-property error after the bump, so every affected site is pinpointed at compile time.
### Re-emit your contract
After updating the source, regenerate the emitted artefacts so the new contributor-declared capabilities land in `contract.json` and `contract.d.ts`:
```bash
pnpm emit
# (runs `prisma-next contract emit` under the hood)
```
You should see capability keys appear in the regenerated `contract.json` — for SQL targets, expect `postgres.distinctOn: true` and `sql.lateral: true` to show up if your contract uses the matching adapter / extensions.
### Validation
After applying the rule above, run `pnpm typecheck && pnpm test` (or your application's equivalent). The change is mechanical and TypeScript pinpoints every affected call site; the regenerated `contract.json` diff confirms the capabilities flowed through unchanged.
## `strip-migration-labels-hints`
Starting at the 0.12 release, the migration manifest schema is closed (`'+': 'reject'`) and the metadata model no longer carries `labels` or `hints`. Any on-disk `migration.json` that still holds either key fails to load: the loader rejects the manifest with `INVALID_MANIFEST`, naming the first offending key (`labels` or `hints`). The two fields are also removed from the content-addressed migration identity — `migrationHash` is now computed over `{ from, to, providedInvariants, createdAt }` plus the sibling `ops.json` — so every migrated manifest additionally needs its hash recomputed over the slimmed envelope, or it fails hash verification on the next load.
Run the colocated codemod from your project root:
```bash
pnpm exec tsx ./strip-migration-labels-hints.ts
```
It walks every `migration.json` that has a sibling `ops.json` (a complete on-disk migration package), removes the `labels` and `hints` keys, and recomputes `migrationHash` over the slimmed metadata plus the operations. The edit is format-preserving — only the two key lines are removed and the hash value is swapped in place, so the rest of each manifest (key order, indentation, inline-vs-expanded arrays) is left untouched and the diff stays minimal. The codemod is idempotent: re-running it over already-migrated manifests makes no further changes.
### Confirm every manifest is migrated
Run the codemod in dry-run mode to confirm no manifest still carries the removed keys or a stale hash:
```bash
pnpm exec tsx ./strip-migration-labels-hints.ts --check
```
`--check` lists every manifest that still needs fixing and exits non-zero if any remain, so wire it into a pre-commit hook or CI step to keep stale manifests out of the tree. A fully migrated tree reports `0 needing fix` and exits `0`.
### Validation
After running the codemod, exercise any command that loads your migrations (your deploy or migration-status step). The loader recomputes and verifies each manifest's `migrationHash` on read: a manifest that still carried `labels`/`hints` would have thrown `INVALID_MANIFEST`, and a manifest with a stale hash would fail verification. Once the codemod has run, every manifest loads cleanly and its recomputed hash verifies against the slimmed envelope.
## `re-emit-closed-mongo-contracts`
Starting at the 0.12 release, MongoDB emits **closed** `$jsonSchema` validators by default. Every object schema in the emitted contract — collection validators, nested objects, and each branch of a polymorphic `oneOf` — carries `additionalProperties: false`. The contract canonicalizer also preserves `additionalProperties` through emission, so the on-disk migration for consumers is to re-emit their Mongo contracts and apply the resulting validator change to the database.
Two authoring constraints apply before emit succeeds:
- **Closed validators** land automatically on re-emit; no hand-editing of `contract.json` is required.
- **Non-variant models need an `objectId` `_id`**. The new interpret-time rule `PSL_MONGO_ID_REQUIRED` rejects any non-variant Mongo model whose `_id` field does not resolve to `objectId`. Fix the PSL or TS contract source first — for example, ensure `@id` is present and typed as MongoDB's default `ObjectId` — then re-emit.
### Re-emit your Mongo contracts
Run the colocated script from your project root:
```bash
pnpm exec tsx ./re-emit-closed-mongo-contracts.ts
```
It finds every directory with a `prisma.config.ts` and a committed Mongo `contract.json`, then runs `pnpm emit` (or `prisma-next contract emit` when no emit script exists) in each. The regenerated `contract.json` / `contract.d.ts` pick up closed validators and an updated `storageHash`.
Use `--check` to list contracts that still need re-emitting without writing files:
```bash
pnpm exec tsx ./re-emit-closed-mongo-contracts.ts --check
```
### Apply the validator migration
Re-emitting changes the contract's `$jsonSchema` shape. The planner classifies the open→closed validator tightening as **`destructive`** — MongoDB replaces collection validators, and documents with fields outside the closed schema will fail validation after apply.
Plan first to review the ops:
```bash
pnpm prisma-next db update --plan-only
# or: prisma-next migration plan
```
Then apply with explicit confirmation:
```bash
pnpm prisma-next db update -y
```
Wire `-y` into your deploy pipeline only after you have reviewed the plan in a lower environment. Without `-y`, apply refuses when destructive ops are present.
### Validation
After re-emitting and applying, run `pnpm typecheck && pnpm test` (or your application's equivalent). Contract hash/type drift shows up immediately in TypeScript imports of `StorageHash`. At runtime, confirm `db verify` passes against the updated validators.
## `public-default-namespace`
Starting at the 0.12 release, un-namespaced Postgres models resolve to the `public` namespace id instead of falling back to the `__unbound__` sentinel. The emitted contract's default storage namespace key changes from `__unbound__` with `"kind": "postgres-unbound-schema"` to `public` with `"kind": "postgres-schema"`. Domain roots, FK `namespaceId` fields, and `contract.d.ts` namespace literals follow the same rename.
Explicit opt-in to the sentinel remains available: `namespace unbound { … }` in PSL still round-trips to `__unbound__` on Postgres. Only contracts whose *default* namespace is still the old sentinel shape need this migration.
### Re-emit Postgres contracts
Run the colocated script from your project root:
```bash
pnpm exec tsx ./re-emit-postgres-public-default.ts
```
It finds every committed `contract.json` whose storage tree still carries `"kind": "postgres-unbound-schema"`, then runs `pnpm emit` (or `prisma-next contract emit`) in the matching contract space. Use `--check` to list spaces that still need re-emitting without writing files:
```bash
pnpm exec tsx ./re-emit-postgres-public-default.ts --check
```
### After re-emit
If your database marker or migration head still references the old contract hash, plan and apply the resulting migration (`prisma-next db update --plan-only`, then `prisma-next db update -y` once reviewed). The schema ops are typically hash/metadata drift only when your PSL source did not change.
### Validation
After re-emitting, run `pnpm typecheck && pnpm test`. Inspect the `contract.json` diff: default models should sit under `storage.namespaces.public` and `domain.namespaces.public`, not `__unbound__`.
## `domain-plane-namespaced-contract`
Starting at the 0.12 release, the application plane is symmetric with storage: models and value objects live under `contract.domain.namespaces.<ns>` instead of flat `contract.models` / `contract.valueObjects` at the contract root (ADR 221). Emitted `contract.d.ts` exports `Models` via `ContractModelsMap<Contract>` rather than `Contract['models']`.
### Re-emit your contracts
Run the colocated script from your project root:
```bash
pnpm exec tsx ./re-emit-domain-namespaced-contracts.ts
```
It finds contract spaces whose on-disk artefacts still use the flat domain shape (JSON missing `domain.namespaces`, or `contract.d.ts` still referencing `Contract['models']`), then re-emits each space. Use `--check` for a dry-run:
```bash
pnpm exec tsx ./re-emit-domain-namespaced-contracts.ts --check
```
If you already re-emitted for `public-default-namespace` on 0.12, a single emit pass covers both transitions — run whichever entry's detection matches your tree.
### Validation
After re-emitting, run `pnpm typecheck && pnpm test`. The regenerated `contract.json` should carry a `domain.namespaces` envelope; `contract.d.ts` should export a `Models` alias derived from the contract type (on current 0.12 builds this is typically `Contract extends ContractType<StorageBase, infer TModels> ? TModels : never`, not `ContractModelsMap`, which was removed with runtime default-namespace qualification — see `runtime-qualified-sql-default-namespace` below).
## `runtime-qualified-sql-default-namespace`
Starting at the 0.12 release, runtime SQL on Postgres qualifies table identifiers with the storage namespace the flat DSL/ORM surface resolved ([ADR 223](../../../../docs/architecture%20docs/adrs/ADR%20223%20-%20Target-owned%20default%20namespace.md)). Un-namespaced Postgres models continue to resolve through the `public` default; explicit `namespace unbound { … }` in PSL still maps to `__unbound__`.
### Application code
No change is required for normal query code:
```ts
await db.sql.user.findMany();
await db.User.findMany();
```
Bare names still resolve default-namespace-first; only the emitted SQL changes.
### Tests and observability
If you assert raw SQL strings (integration tests, query logs, migration snapshots), expect qualified Postgres identifiers:
```diff
-FROM "user"
+FROM "public"."user"
```
SQLite and Mongo behaviour for bare names is unchanged at the SQL/collection string level (SQLite `qualifyTable` is a no-op; Mongo has no SQL-style qualification).
### Contract artefacts
Re-emit (`pnpm emit` / `prisma-next contract emit`) is **not** required solely for this change when your PSL/contract source is already on the 0.12 namespaced shape. If you have not yet run the `domain-plane-namespaced-contract` or `public-default-namespace` transitions, complete those first — a single emit pass covers all on-disk contract updates.
### Emitted types note
If you maintain hand-written code against generated `contract.d.ts`, replace any use of removed `ContractModelsMap<Contract>` with the emitted `Models` export or `ContractModelDefinitions<YourContract>` from `@internal/contract/types`. That removal affects extension authors directly; application projects that only import the generated `Models` alias pick up the new shape on re-emit.
upgrading/app/upgrades/0.11-to-0.12/re-emit-closed-mongo-contracts.ts
/**
* Re-emits every Mongo contract in the consumer project so emitted
* `contract.json` / `contract.d.ts` pick up closed `$jsonSchema`
* validators (`additionalProperties: false` at every level, including
* polymorphic `oneOf` branches).
*
* Background: starting at the 0.12 release, MongoDB emits closed
* `$jsonSchema` validators by default. The contract canonicalizer also
* preserves `additionalProperties` through emission, so re-emitting is
* the consumer-facing migration for on-disk contract artefacts. A
* non-variant Mongo model must resolve to an `objectId` `_id`; otherwise
* interpret fails with `PSL_MONGO_ID_REQUIRED` — fix the PSL/TS source
* before re-emitting.
*
* After re-emitting, apply the resulting open→closed validator migration
* with `prisma-next db update -y` (or `pnpm db:update -y` if your
* project wraps it). The planner classifies the validator tightening as
* `destructive`; without `-y` the apply step refuses to run.
*
* Dispatch: walks the project root for directories that contain both
* `prisma.config.ts` and a committed `contract.json` whose storage
* tree includes `"kind": "mongo-database"`. In each match, runs
* `pnpm emit` when a `package.json` scripts.emit entry exists, otherwise
* `pnpm exec prisma-next contract emit`.
*
* Flags:
* --check dry-run; lists directories that would be re-emitted and
* exits 1 if any contract.json still lacks closed validators.
*/
import { execFile } from 'node:child_process';
import { access, readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
const dryRun = process.argv.includes('--check');
const projectRoot = process.cwd();
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
async function findPrismaNextConfigDirs(root: string): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
let entries: Awaited<ReturnType<typeof readdir>>;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
await walk(join(dir, entry.name));
} else if (entry.isFile() && entry.name === 'prisma.config.ts') {
out.push(dir);
}
}
}
await walk(root);
return out.sort();
}
function contractJsonCandidates(configDir: string): string[] {
return [
join(configDir, 'src', 'contract.json'),
join(configDir, 'src', 'prisma', 'contract.json'),
join(configDir, 'prisma', 'contract.json'),
join(configDir, 'contract.json'),
];
}
async function resolveContractJson(configDir: string): Promise<string | null> {
for (const candidate of contractJsonCandidates(configDir)) {
if (await pathExists(candidate)) return candidate;
}
return null;
}
async function isMongoContract(contractPath: string): Promise<boolean> {
const raw = await readFile(contractPath, 'utf-8');
return raw.includes('"kind": "mongo-database"') || raw.includes('"kind":"mongo-database"');
}
/**
* A contract is in the closed-validator (post-0.12) format when every object
* schema that declares a `properties` map also carries `additionalProperties:
* false`. That covers collection validators, nested value objects, and each
* polymorphic `oneOf` branch — all of which expose `properties`.
*
* The one exception is a polymorphic schema's top-level node: it carries both
* base `properties` and a `oneOf`, and is deliberately left open because
* closure is enforced on each branch (a document must match exactly one closed
* branch). Such a node is exempt from the `additionalProperties: false`
* requirement, but its branches are still walked and checked.
*
* A substring scan is unsafe here: a single closed branch would mask a sibling
* that still needs re-emitting.
*/
/** Narrows an arbitrary JSON-parsed value to a plain object (non-null, non-array). */
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function contractLooksClosed(raw: string): boolean {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return false;
}
function isClosed(node: unknown): boolean {
if (Array.isArray(node)) return node.every(isClosed);
if (!isJsonObject(node)) return true;
const hasProperties = isJsonObject(node['properties']);
const isPolymorphicTopLevel = Array.isArray(node['oneOf']);
if (hasProperties && !isPolymorphicTopLevel && node['additionalProperties'] !== false) {
return false;
}
return Object.values(node).every(isClosed);
}
return isClosed(parsed);
}
async function packageJsonHasEmitScript(configDir: string): Promise<boolean> {
const pkgPath = join(configDir, 'package.json');
if (!(await pathExists(pkgPath))) return false;
const raw = await readFile(pkgPath, 'utf-8');
try {
const parsed: unknown = JSON.parse(raw);
if (!isJsonObject(parsed)) return false;
const scripts = parsed['scripts'];
if (!isJsonObject(scripts)) return false;
return typeof scripts['emit'] === 'string' && scripts['emit'].length > 0;
} catch {
return false;
}
}
async function runEmit(configDir: string): Promise<void> {
const hasEmitScript = await packageJsonHasEmitScript(configDir);
const cmd = hasEmitScript ? 'pnpm' : 'pnpm';
const args = hasEmitScript ? ['emit'] : ['exec', 'prisma-next', 'contract', 'emit'];
await execFileAsync(cmd, args, { cwd: configDir, env: process.env });
}
const configDirs = await findPrismaNextConfigDirs(projectRoot);
const mongoDirs: Array<{ dir: string; contractPath: string }> = [];
for (const dir of configDirs) {
const contractPath = await resolveContractJson(dir);
if (contractPath === null) continue;
if (!(await isMongoContract(contractPath))) continue;
mongoDirs.push({ dir, contractPath });
}
if (mongoDirs.length === 0) {
console.error(`No Mongo contract directories found under ${projectRoot}.`);
process.exit(1);
}
let needsFix = 0;
let alreadyClean = 0;
for (const { dir, contractPath } of mongoDirs) {
const rel = dir.slice(projectRoot.length + 1) || '.';
const raw = await readFile(contractPath, 'utf-8');
if (contractLooksClosed(raw)) {
alreadyClean += 1;
console.log(`OK ${rel}`);
continue;
}
needsFix += 1;
if (dryRun) {
console.log(`WOULD RE-EMIT ${rel}`);
continue;
}
console.log(`EMIT ${rel}`);
await runEmit(dir);
}
console.log();
console.log(
`${mongoDirs.length} Mongo contract(s): ${needsFix} ${dryRun ? 'needing re-emit' : 're-emitted'}, ${alreadyClean} already closed.`,
);
if (dryRun && needsFix > 0) process.exit(1);
upgrading/app/upgrades/0.11-to-0.12/re-emit-domain-namespaced-contracts.ts
/**
* Re-emits every contract artefact still on the pre-0.12 flat domain plane
* (`contract.models` / `contract.valueObjects` at the contract root) so
* emitted JSON and `contract.d.ts` pick up `contract.domain.namespaces.<ns>`.
*
* Background: starting at 0.12 (symmetric domain plane, ADR 221), models
* and value objects live under `domain.namespaces`. The supported read
* paths are `contract.domain.namespaces` plus helpers such as
* `contractModels()` / `ContractModelsMap`.
*
* Dispatch: walks the project root for `prisma.config.ts` directories,
* resolves each space's committed `contract.json` / `contract.d.ts`, and
* re-emits when the flat domain shape remains. Uses the nearest ancestor
* `package.json` `scripts.emit` when present; otherwise runs
* `prisma-next contract emit --config <path>`.
*
* Flags:
* --check dry-run; lists contract-spaces that still need re-emitting
* and exits 1 if any remain.
*/
import { execFile } from 'node:child_process';
import { access, readdir, readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
const dryRun = process.argv.includes('--check');
const projectRoot = process.cwd();
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
async function findPrismaNextConfigDirs(root: string): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
let entries: Awaited<ReturnType<typeof readdir>>;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
await walk(join(dir, entry.name));
} else if (entry.isFile() && entry.name === 'prisma.config.ts') {
out.push(dir);
}
}
}
await walk(root);
return out.sort();
}
function contractJsonCandidates(configDir: string): string[] {
return [
join(configDir, 'src', 'contract.json'),
join(configDir, 'src', 'prisma', 'contract.json'),
join(configDir, 'prisma', 'contract.json'),
join(configDir, 'contract.json'),
];
}
function contractDtsCandidates(configDir: string): string[] {
return [
join(configDir, 'src', 'contract.d.ts'),
join(configDir, 'src', 'prisma', 'contract.d.ts'),
join(configDir, 'prisma', 'contract.d.ts'),
join(configDir, 'contract.d.ts'),
];
}
function contractJsonNeedsDomainPlaneMigration(raw: string): boolean {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return false;
}
if (!isJsonObject(parsed)) return false;
const domain = parsed['domain'];
if (!isJsonObject(domain)) return true;
const namespaces = domain['namespaces'];
return !isJsonObject(namespaces) || Object.keys(namespaces).length === 0;
}
function contractDtsNeedsDomainPlaneMigration(raw: string): boolean {
return raw.includes("Contract['models']");
}
async function packageJsonHasEmitScript(dir: string): Promise<boolean> {
const pkgPath = join(dir, 'package.json');
if (!(await pathExists(pkgPath))) return false;
const raw = await readFile(pkgPath, 'utf-8');
try {
const parsed: unknown = JSON.parse(raw);
if (!isJsonObject(parsed)) return false;
const scripts = parsed['scripts'];
if (!isJsonObject(scripts)) return false;
return typeof scripts['emit'] === 'string' && scripts['emit'].length > 0;
} catch {
return false;
}
}
async function resolveEmitInvocation(configDir: string): Promise<{
readonly cwd: string;
readonly args: string[];
readonly key: string;
}> {
let dir = configDir;
while (dir.startsWith(projectRoot)) {
if (await packageJsonHasEmitScript(dir)) {
return { cwd: dir, args: ['emit'], key: `script:${dir}` };
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
const configPath = join(configDir, 'prisma.config.ts');
return {
cwd: projectRoot,
args: ['exec', 'prisma-next', 'contract', 'emit', '--config', configPath],
key: `config:${configPath}`,
};
}
async function runEmit(configDir: string): Promise<void> {
const { cwd, args } = await resolveEmitInvocation(configDir);
await execFileAsync('pnpm', args, { cwd, env: process.env });
}
async function configDirNeedsDomainPlaneMigration(configDir: string): Promise<boolean> {
for (const candidate of contractJsonCandidates(configDir)) {
if (!(await pathExists(candidate))) continue;
const raw = await readFile(candidate, 'utf-8');
if (contractJsonNeedsDomainPlaneMigration(raw)) return true;
}
for (const candidate of contractDtsCandidates(configDir)) {
if (!(await pathExists(candidate))) continue;
const raw = await readFile(candidate, 'utf-8');
if (contractDtsNeedsDomainPlaneMigration(raw)) return true;
}
return false;
}
const configDirs = await findPrismaNextConfigDirs(projectRoot);
const emitKeys = new Set<string>();
const targets: string[] = [];
for (const configDir of configDirs) {
if (!(await configDirNeedsDomainPlaneMigration(configDir))) continue;
const { key } = await resolveEmitInvocation(configDir);
if (emitKeys.has(key)) continue;
emitKeys.add(key);
targets.push(configDir);
}
if (targets.length === 0) {
console.error(`No flat-domain contract candidates under ${projectRoot}.`);
process.exit(dryRun ? 0 : 1);
}
let needsFix = 0;
for (const configDir of targets) {
const rel = configDir.slice(projectRoot.length + 1) || '.';
if (!(await configDirNeedsDomainPlaneMigration(configDir))) {
console.log(`OK ${rel}`);
continue;
}
needsFix += 1;
if (dryRun) {
console.log(`WOULD RE-EMIT ${rel}`);
continue;
}
console.log(`EMIT ${rel}`);
await runEmit(configDir);
}
console.log();
console.log(
`${targets.length} contract-space(s): ${needsFix} ${dryRun ? 'needing re-emit' : 're-emitted'}.`,
);
if (dryRun && needsFix > 0) process.exit(1);
upgrading/app/upgrades/0.11-to-0.12/re-emit-postgres-public-default.ts
/**
* Re-emits every Postgres contract whose default namespace still uses the
* pre-0.12 `__unbound__` / `postgres-unbound-schema` sentinel so emitted
* `contract.json` / `contract.d.ts` pick up the `public` / `postgres-schema`
* default (public-by-default).
*
* Starting at 0.12, un-namespaced Postgres models resolve to the `public`
* namespace id. Explicit `namespace unbound { … }` in PSL still round-trips
* to `__unbound__`; this script targets only contracts whose *default*
* namespace is still the old sentinel shape.
*
* Dispatch: walks the project root for `prisma.config.ts` directories,
* resolves each space's committed `contract.json`, and re-emits when the
* storage tree still includes `"kind": "postgres-unbound-schema"`. Uses
* the nearest ancestor `package.json` `scripts.emit` when present; otherwise
* runs `prisma-next contract emit --config <path>`.
*
* Flags:
* --check dry-run; lists contract-spaces that still need re-emitting and
* exits 1 if any remain.
*/
import { execFile } from 'node:child_process';
import { access, readdir, readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
const dryRun = process.argv.includes('--check');
const projectRoot = process.cwd();
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
async function findPrismaNextConfigDirs(root: string): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
let entries: Awaited<ReturnType<typeof readdir>>;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
await walk(join(dir, entry.name));
} else if (entry.isFile() && entry.name === 'prisma.config.ts') {
out.push(dir);
}
}
}
await walk(root);
return out.sort();
}
function contractJsonCandidates(configDir: string): string[] {
return [
join(configDir, 'src', 'contract.json'),
join(configDir, 'src', 'prisma', 'contract.json'),
join(configDir, 'prisma', 'contract.json'),
join(configDir, 'contract.json'),
];
}
async function resolveContractJson(configDir: string): Promise<string | null> {
for (const candidate of contractJsonCandidates(configDir)) {
if (await pathExists(candidate)) return candidate;
}
return null;
}
function contractNeedsPublicDefaultMigration(raw: string): boolean {
return (
raw.includes('"kind": "postgres-unbound-schema"') ||
raw.includes('"kind":"postgres-unbound-schema"')
);
}
async function packageJsonHasEmitScript(dir: string): Promise<boolean> {
const pkgPath = join(dir, 'package.json');
if (!(await pathExists(pkgPath))) return false;
const raw = await readFile(pkgPath, 'utf-8');
try {
const parsed: unknown = JSON.parse(raw);
if (!isJsonObject(parsed)) return false;
const scripts = parsed['scripts'];
if (!isJsonObject(scripts)) return false;
return typeof scripts['emit'] === 'string' && scripts['emit'].length > 0;
} catch {
return false;
}
}
async function packageJsonHasBuildContractSpaceScript(dir: string): Promise<boolean> {
const pkgPath = join(dir, 'package.json');
if (!(await pathExists(pkgPath))) return false;
const raw = await readFile(pkgPath, 'utf-8');
try {
const parsed: unknown = JSON.parse(raw);
if (!isJsonObject(parsed)) return false;
const scripts = parsed['scripts'];
if (!isJsonObject(scripts)) return false;
return (
typeof scripts['build:contract-space'] === 'string' &&
scripts['build:contract-space'].length > 0
);
} catch {
return false;
}
}
async function resolveEmitInvocation(configDir: string): Promise<{
readonly cwd: string;
readonly args: string[];
readonly key: string;
}> {
let dir = configDir;
while (dir.startsWith(projectRoot)) {
if (await packageJsonHasEmitScript(dir)) {
return { cwd: dir, args: ['emit'], key: `script:${dir}` };
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
const configPath = join(configDir, 'prisma.config.ts');
return {
cwd: projectRoot,
args: ['exec', 'prisma-next', 'contract', 'emit', '--config', configPath],
key: `config:${configPath}`,
};
}
async function runEmit(configDir: string): Promise<void> {
const { cwd, args } = await resolveEmitInvocation(configDir);
await execFileAsync('pnpm', args, { cwd, env: process.env });
}
const configDirs = await findPrismaNextConfigDirs(projectRoot);
const emitKeys = new Set<string>();
const targets: Array<{ configDir: string; contractPath: string }> = [];
for (const configDir of configDirs) {
if (await packageJsonHasBuildContractSpaceScript(configDir)) continue;
const contractPath = await resolveContractJson(configDir);
if (contractPath === null) continue;
const raw = await readFile(contractPath, 'utf-8');
if (!contractNeedsPublicDefaultMigration(raw)) continue;
const { key } = await resolveEmitInvocation(configDir);
if (emitKeys.has(key)) continue;
emitKeys.add(key);
targets.push({ configDir, contractPath });
}
if (targets.length === 0) {
console.error(`No Postgres public-default migration candidates under ${projectRoot}.`);
process.exit(dryRun ? 0 : 1);
}
let needsFix = 0;
for (const { configDir, contractPath } of targets) {
const rel = configDir.slice(projectRoot.length + 1) || '.';
const raw = await readFile(contractPath, 'utf-8');
if (!contractNeedsPublicDefaultMigration(raw)) {
console.log(`OK ${rel}`);
continue;
}
needsFix += 1;
if (dryRun) {
console.log(`WOULD RE-EMIT ${rel}`);
continue;
}
console.log(`EMIT ${rel}`);
await runEmit(configDir);
}
console.log();
console.log(
`${targets.length} contract-space(s): ${needsFix} ${dryRun ? 'needing re-emit' : 're-emitted'}.`,
);
if (dryRun && needsFix > 0) process.exit(1);
upgrading/app/upgrades/0.11-to-0.12/strip-migration-labels-hints.ts
/**
* Brings on-disk `migration.json` manifests into the slimmed 0.12 metadata
* model: drops the now-removed `labels` and `hints` keys and recomputes
* `migrationHash` over the surviving metadata envelope + sibling `ops.json`.
*
* Background: starting at the 0.12 release the migration manifest schema is
* closed (`'+': 'reject'`) — `labels` and `hints` are no longer part of the
* model, so any manifest still carrying either key fails to load with
* `INVALID_MANIFEST` naming the offending key. The two fields also no longer
* participate in the content-addressed migration identity: `migrationHash` is
* now computed over `{ from, to, providedInvariants, createdAt }` plus the
* sibling operations, so every migrated manifest gets a freshly recomputed
* hash over the slimmed envelope.
*
* Before 0.12 the on-disk shape was:
*
* {
* "from": null,
* "to": "sha256:…",
* "labels": [],
* "providedInvariants": ["…"],
* "createdAt": "2026-…",
* "hints": { "used": [], "applied": [], "plannerVersion": "2.0.0" },
* "migrationHash": "sha256:…"
* }
*
* Starting at 0.12 the same manifest is:
*
* {
* "from": null,
* "to": "sha256:…",
* "providedInvariants": ["…"],
* "createdAt": "2026-…",
* "migrationHash": "sha256:…" // recomputed over the slimmed envelope
* }
*
* Format-preserving edit: rather than reparse-and-reserialise (which would
* reflow every value to a single canonical style and bloat the diff), this
* codemod performs a surgical text edit — it removes only the `labels` and
* `hints` top-level key lines and swaps the `migrationHash` value in place.
* Every other byte (key order, indentation, and whether arrays like
* `providedInvariants` are written inline or expanded) is left exactly as the
* authoring tool wrote it, so the diff is limited to the two removed keys and
* the new hash value.
*
* Confinement: an on-disk migration package is a `migration.json` paired with
* a sibling `ops.json` (the operations the hash is computed over). The walk
* keys off that pair rather than off a `migrations/` directory name, because
* migration packages live under several roots in practice (`migrations/`,
* `migration-fixtures/`, …); a `migration.json` with no sibling `ops.json` is
* not a complete package and is left untouched.
*
* The hash algorithm is replicated inline (canonicalisation rules from
* `@internal/framework-components` `canonicalizeJson` + the migration-tools
* `computeMigrationHash`) so this script stays self-contained — consumers run
* it via `pnpm exec tsx` from their project root with no dependency on any
* `@internal/*` package being resolvable from that root.
*
* The codemod is idempotent: an already-slimmed manifest carries no
* `labels`/`hints` and already has its recomputed hash, so the edit is a no-op
* and the file is left untouched.
*
* Flags:
* --check dry-run; lists manifests that still need fixing and exits 1 if
* any remain.
*/
import { createHash } from 'node:crypto';
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
const dryRun = process.argv.includes('--check');
const projectRoot = process.cwd();
// --- Inline canonicalisation + hash --------------------------------------
// Replicated from `@internal/framework-components` `canonicalizeJson`
// (sortKeys + JSON.stringify) and the migration-tools `computeMigrationHash`.
// Kept inline so the script has no `@internal/*` import — pnpm's strict
// node_modules layout won't resolve transitive framework deps from a
// consumer's project root.
function sortKeys(value: unknown): unknown {
if (value === null || typeof value !== 'object') {
return value;
}
if (Array.isArray(value)) {
return value.map(sortKeys);
}
const sorted: Record<string, unknown> = Object.create(null);
for (const [key, entry] of Object.entries(value).sort(([a], [b]) =>
a < b ? -1 : a > b ? 1 : 0,
)) {
sorted[key] = sortKeys(entry);
}
return sorted;
}
function canonicalizeJson(value: unknown): string {
return JSON.stringify(sortKeys(value));
}
function sha256Hex(input: string): string {
return createHash('sha256').update(input).digest('hex');
}
/**
* Content-addressed migration hash over (metadata envelope, ops). The
* `migrationHash` field is stripped before hashing so the same function works
* at write time (no hash yet) and at recompute time (rehashing an
* already-attested record over the slimmed envelope).
*/
function computeMigrationHash(metadata: Record<string, unknown>, ops: unknown): string {
const { migrationHash: _migrationHash, ...strippedMeta } = metadata;
const partHashes = [canonicalizeJson(strippedMeta), canonicalizeJson(ops)].map(sha256Hex);
return `sha256:${sha256Hex(canonicalizeJson(partHashes))}`;
}
// --- Format-preserving text surgery --------------------------------------
/**
* Returns the index just past the end of the JSON value that starts at
* `start` (which must point at the value's first character). Handles strings
* (with escapes), nested objects/arrays, and primitives. Used to locate the
* full span of a top-level key's value when removing the key from the raw
* text without reparsing the whole document.
*/
function scanValueEnd(text: string, start: number): number {
const c = text[start];
if (c === '"') {
let i = start + 1;
while (i < text.length) {
if (text[i] === '\\') {
i += 2;
continue;
}
if (text[i] === '"') return i + 1;
i += 1;
}
throw new Error('unterminated string while scanning JSON value');
}
if (c === '{' || c === '[') {
const open = c;
const close = c === '{' ? '}' : ']';
let depth = 0;
let i = start;
while (i < text.length) {
const ch = text[i];
if (ch === '"') {
i = scanValueEnd(text, i);
continue;
}
if (ch === open) depth += 1;
else if (ch === close) {
depth -= 1;
if (depth === 0) return i + 1;
}
i += 1;
}
throw new Error('unterminated container while scanning JSON value');
}
// Primitive (number / true / false / null) — run to the next structural
// terminator.
let i = start;
while (i < text.length && !',}]\r\n \t'.includes(text[i]!)) i += 1;
return i;
}
/**
* Removes a top-level object key (and its value) from `text`, preserving the
* surrounding bytes exactly. No-op (returns `text`) if the key is absent.
* Only the top-level `labels` / `hints` keys are ever passed here; both always
* precede the trailing `migrationHash` key, so a removed key always carries a
* trailing comma that is consumed along with the line.
*/
function removeTopLevelKey(text: string, key: string): string {
// A top-level key is the only occurrence of `"key":` preceded by a newline
// (line 1 is the opening `{`). Tolerant of any indentation width.
const re = new RegExp(`\\n([ \\t]*)"${key}"[ \\t]*:[ \\t]*`);
const match = re.exec(text);
if (match === null) return text;
const lineStart = match.index + 1; // position just after the leading newline
const valueStart = match.index + match[0].length;
let after = scanValueEnd(text, valueStart);
while (text[after] === ' ' || text[after] === '\t') after += 1;
if (text[after] === ',') after += 1;
if (text[after] === '\r') after += 1;
if (text[after] === '\n') after += 1;
return text.slice(0, lineStart) + text.slice(after);
}
function replaceMigrationHash(text: string, oldHash: string, newHash: string): string {
if (oldHash === newHash) return text;
// Tolerate any whitespace around the colon (`"migrationHash":"…"`,
// `"migrationHash" : "…"`), matching the leniency of `removeTopLevelKey`.
const escapedOld = oldHash.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`("migrationHash"[ \\t]*:[ \\t]*)"${escapedOld}"`);
const match = re.exec(text);
if (match === null) {
throw new Error('could not locate the migrationHash value to replace');
}
return text.replace(re, (_full, prefix: string) => `${prefix}"${newHash}"`);
}
// --- Filesystem walk ------------------------------------------------------
async function findMigrationManifests(root: string): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
let entries: Awaited<ReturnType<typeof readdir>>;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
// Unreadable directory — skip silently. The consumer's project root may
// legitimately contain restricted directories.
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
await walk(join(dir, entry.name));
} else if (entry.isFile() && entry.name === 'migration.json') {
out.push(join(dir, entry.name));
}
}
}
await walk(root);
return out.sort();
}
// --- Per-file transform ---------------------------------------------------
/** Narrows an arbitrary JSON-parsed value to a plain object (manifest shape). */
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
type Status = 'already-clean' | 'needs-fix' | 'fixed' | 'skipped-no-ops';
interface Result {
readonly path: string;
readonly status: Status;
}
async function processFile(path: string): Promise<Result> {
const raw = await readFile(path, 'utf-8');
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(
`${path}: not valid JSON (${error instanceof Error ? error.message : String(error)})`,
);
}
if (!isJsonObject(parsed)) {
return { path, status: 'already-clean' }; // not a manifest object
}
const metadata = parsed;
// A complete on-disk migration package pairs `migration.json` with a sibling
// `ops.json` (the operations the hash is computed over); without it we cannot
// recompute the hash, so this is not a package we should touch.
const opsPath = join(dirname(path), 'ops.json');
let ops: unknown;
try {
ops = JSON.parse(await readFile(opsPath, 'utf-8'));
} catch {
return { path, status: 'skipped-no-ops' };
}
// Recompute over the slimmed envelope (canonicalisation is order/whitespace
// independent, so the parsed object is the right input regardless of on-disk
// formatting). `computeMigrationHash` strips `migrationHash` internally.
const slimmed = { ...metadata };
delete slimmed['labels'];
delete slimmed['hints'];
const newHash = computeMigrationHash(slimmed, ops);
let out = raw;
out = removeTopLevelKey(out, 'labels');
out = removeTopLevelKey(out, 'hints');
const oldHash = metadata['migrationHash'];
if (typeof oldHash === 'string') {
out = replaceMigrationHash(out, oldHash, newHash);
} else if (out !== raw) {
// labels/hints were present but there is no string migrationHash to update
// — a malformed manifest we refuse to guess at.
throw new Error(`${path}: manifest is missing a string \`migrationHash\` field`);
}
if (out === raw) {
return { path, status: 'already-clean' };
}
if (!dryRun) await writeFile(path, out, 'utf-8');
return { path, status: dryRun ? 'needs-fix' : 'fixed' };
}
// --- Driver ---------------------------------------------------------------
const manifests = await findMigrationManifests(projectRoot);
if (manifests.length === 0) {
console.error(`No migration.json files found under ${projectRoot}.`);
process.exit(1);
}
let changed = 0;
let alreadyClean = 0;
let skipped = 0;
for (const path of manifests) {
const result = await processFile(path);
const rel = path.slice(projectRoot.length + 1);
if (result.status === 'already-clean') {
alreadyClean += 1;
} else if (result.status === 'skipped-no-ops') {
skipped += 1;
console.log(`SKIP ${rel} (no sibling ops.json — not a migration package)`);
} else {
changed += 1;
const verb = dryRun ? 'WOULD FIX' : 'FIXED';
console.log(`${verb} ${rel}`);
}
}
console.log();
console.log(
`${manifests.length} manifest(s) scanned: ${changed} ${dryRun ? 'needing fix' : 'fixed'}, ${alreadyClean} already clean${skipped > 0 ? `, ${skipped} skipped (no ops.json)` : ''}.`,
);
if (dryRun && changed > 0) process.exit(1);
upgrading/app/upgrades/0.12-to-0.13/instructions.md
---
from: "0.12"
to: "0.13"
changes:
- id: sqlite-create-table-method
summary: |
SQLite migrations: `createTable` is no longer a free function exported from `@internal/sqlite/migration`. It is now a protected method on the `Migration` base class. Replace every free `createTable(...)` call in your SQLite migration files with `this.createTable({ table: ..., columns: [...], constraints: [...] })`. The `col()`, `lit()`, `fn()`, `primaryKey()`, `foreignKey()`, and `unique()` builder helpers are now exported from `@internal/sqlite/migration` directly, so your import line stays a single entry point.
detection:
glob: "**/migration.ts"
contains:
- "createTable"
- "@internal/sqlite/migration"
anyMatch: false
- id: re-emit-mti-variant-link-columns
summary: |
MTI variant models — PSL `@@base(Parent, "tag")` models that carry their own `@@map` and are therefore stored in their own table — now materialise base-PK link columns in storage. On re-emit, each such variant table gains a copy of the base table's full primary-key column set (same names and types), a primary key over those columns, and a cascading foreign key referencing the base table's primary key; the contract's `storageHash` changes accordingly. Re-emit your contract artefacts (`pnpm emit`), then advance your database with the corresponding migration (`prisma-next migration plan` → `prisma-next migrate`) so the variant tables gain the link column, PK, and cascading FK. Contracts whose variants share the base table (single-table inheritance, no own `@@map`) are unaffected.
detection:
glob: "**/contract.json"
contains:
- '"base":'
anyMatch: true
script: ./re-emit-mti-variant-link-columns.ts
- id: cross-space-fk-psl-pattern
summary: |
New opt-in PSL authoring pattern: reference a model from an extension contract space
(e.g. `supabase:auth.AuthUser`) in a relation field and declare a named-type alias
(`types { Uuid = String @db.Uuid }`) for database-native types. No action required
for consumers who do not use cross-space foreign keys; this entry documents the
pattern for new adopters.
- id: storage-namespace-envelope-re-emit
summary: |
The storage IR in `contract.json` moved to a namespace envelope
(`storage.namespaces.<ns>.entries.<kind>`). This changes `storageHash` for every
SQL and Mongo contract. Re-emit your contract artefacts (`prisma-next contract
emit`), then plan and apply the corresponding migration (`prisma-next migration
plan` → `prisma-next migrate`) so your database schema is reconciled with the new
contract shape. No source change is required — re-emitting is sufficient.
detection:
glob: "**/contract.json"
anyMatch: true
- id: telemetry-now-opt-out
summary: |
Telemetry is now opt-out by default. If you previously relied on the opt-in
default to keep telemetry off, set `PRISMA_NEXT_DISABLE_TELEMETRY=1` or
`DO_NOT_TRACK=1` in your environment to restore that behaviour. No code change
is required.
---
<!--
TML-2500(M3b): advances the `examples/supabase` walking skeleton to wire a
cross-space FK from `public.Profile.userId` to `supabase:auth.AuthUser.id` with
a cascading delete. The diff is new sample code that exercises a capability
shipped in M2/M3a — no existing consumer has cross-space FKs to migrate. This
entry serves as the canonical first-use reference for the PSL authoring pattern.
dependabot/runtime-deps: bumped pg 8.20→8.21, pg-cursor 2.19→2.20, vitest 4.1.6→4.1.7,
vite 8.0.13→8.0.15, tsdown 0.22.0→0.22.1, tsx 4.22.3→4.22.4, next 16.2.4→16.2.6,
postcss 8.5.14→8.5.15, evlog 2.16.0→2.18.1, @prisma/dev 0.24.7→0.24.8,
@types/node 25.6.0→25.9.1 — all incidental to examples; no user-side action required.
TML-2808: the SQL/Mongo contract storage IR moved to a namespace
envelope (`namespaces.<ns>.entries.<kind>`) and lifted cross-references
from bare strings to `{ namespace, model }` objects in `domain`.
Consumer impact is incidental: re-emitting `contract.json` /
`contract.d.ts` via the existing `prisma-next contract emit` produces
the new shape with no source change. No codemod is required.
TML-2834: scaffolds the new `@internal/extension-supabase` package
and adds `examples/supabase` as the Supabase walking-skeleton app. Two
enabling framework changes ride along: (a) the emitter now emits
multi-namespace contracts (single-namespace output is byte-identical),
and (b) `db init` / `db verify` introspect all declared namespaces
across a composed contract aggregate instead of only `public`. Both
are forward-compatible — single-namespace contracts emit byte-identical
output and introspect through the same path as before. The new
extension package is purely additive (consumers opt in by adding
`extensionPacks: [supabasePack]`). No codemod or user-side action
required.
TML-2754: points stale migration tests at the post-#751
`SqlControlAdapter` API (`createPlanner(controlAdapter)` and the
`adapter` option on `executeDbInit`/`executeDbUpdate`). Touches
`examples/multi-extension-monorepo/test/` only — a test-only diff with
no runtime, contract, or public-API change; incidental to examples, no
user-side action required.
dependabot/runtime-deps (PR #761): bumps react 19.2.6→19.2.7, vitest
4.1.7→4.1.8, next 16.2.6→16.2.7, react-router 7.15.1→7.16.0, vite
8.0.15→8.0.16, lucide-react 1.16.0→1.17.0, @prisma/dev 0.24.8→0.24.9,
mongodb-memory-server 11.1.0→11.2.0. Touches examples/ only via
package.json version fields; no runtime, contract, or public-API change.
TML-2838: regenerates example-app migration snapshots via pnpm
fixtures:emit. The prisma-8-demo initial migration was updated from
the removed standalone createTable function to this.createTable({...})
(the base-class method introduced by the planner-create-table-adopts-ddl-ast
refactor). The ops.json snapshots are regenerated accordingly. No
user-side action required.
TML-2843: `@internal/sqlite` gained an additive facade transaction
API (`db.transaction(async (tx) => …)`) demonstrated in
`examples/prisma-8-demo-sqlite`. No user action required; incidental
substrate diff.
Release bump 0.13.0 (#789): version-number changes across all workspace
`package.json` files and `pnpm-lock.yaml` specifiers; the
`examples/supabase/src/contract.json` and `contract.d.ts` `version`
field updated to `0.13.0`. Incidental substrate diff — no user-side
action required.
-->
# 0.12 → 0.13 — User upgrade instructions
## `sqlite-create-table-method`
Starting at this release, `createTable` is no longer a free function exported from `@internal/sqlite/migration`. It is now a protected method on the `Migration` base class — call it as `this.createTable({...})` inside `get operations()`.
The column builder helpers `col()`, `lit()`, `fn()`, `primaryKey()`, `foreignKey()`, and `unique()` are now exported from `@internal/sqlite/migration` directly, so you do not need an additional import.
### Before 0.13
```ts
import { Migration, MigrationCLI, createTable, col, primaryKey } from '@internal/sqlite/migration';
export default class M extends Migration {
override describe() { return { from: null, to: '...' }; }
override get operations() {
return [
createTable('user', [
col('id', 'INTEGER', { primaryKey: true }),
col('email', 'TEXT', { notNull: true }),
]),
];
}
}
MigrationCLI.run(import.meta.url, M);
```
### Starting at 0.13
```ts
import { Migration, MigrationCLI, col, primaryKey } from '@internal/sqlite/migration';
export default class M extends Migration {
override describe() { return { from: null, to: '...' }; }
override get operations() {
return [
this.createTable({
table: 'user',
columns: [
col('id', 'INTEGER', { primaryKey: true }),
col('email', 'TEXT', { notNull: true }),
],
}),
];
}
}
MigrationCLI.run(import.meta.url, M);
```
### Migration steps
1. Remove `createTable` from the import list for `@internal/sqlite/migration`.
2. In `get operations()`, replace each `createTable(tableName, columns, constraints?)` call with `this.createTable({ table: tableName, columns, constraints? })`.
3. Run `pnpm typecheck && pnpm test` to confirm the migration compiled and all tests pass.
TypeScript flags the removed `createTable` import as an error after the bump, so every affected call site is pinpointed at compile time. No contract re-emit is required — this is an authoring-surface change only.
## `re-emit-mti-variant-link-columns`
Starting at this release, a Multi-Table Inheritance (MTI) variant model stores an explicit link to its base row. An MTI variant is a PSL model that declares `@@base(Parent, "tag")` **and** carries its own `@@map`, so it lives in a dedicated table rather than sharing the base table:
```prisma
model Task {
id String @id @default(uuid())
type String
// …
@@discriminator(type)
@@map("task")
}
model Bug {
severity String
@@base(Task, "bug")
@@map("bug")
}
```
Before this release, the `bug` table held only the variant-specific columns (`severity`, …) with **no primary key** and no relationship to `task`. From this release on, re-emitting the contract materialises the base-PK link in the variant's storage table:
- a copy of the base table's full primary-key column set — the same column names and types (one column for a single-column PK like `id`, or every component for a composite PK),
- a primary key over those link columns,
- a cascading foreign key (`ON DELETE CASCADE`) from those columns to the base table's matching primary-key columns.
The variant row's link columns mirror its parent base row's primary key — the same identity links a `task` row to its `bug`/`feature` detail row. This is the storage shape the runtime already assumed when writing base + variant rows together; the change makes it explicit and enforced at the database level.
Single-table inheritance variants — `@@base(...)` models **without** their own `@@map`, which share the base table — are unaffected: there is no separate table to link.
### Re-emit your contracts
Run the colocated script from your project root:
```bash
pnpm exec tsx ./re-emit-mti-variant-link-columns.ts
```
It walks the project for `prisma.config.ts` directories, resolves each space's committed `contract.json`, and re-emits any contract whose MTI variant table still lacks its link column (an MTI variant model whose storage table has no `primaryKey`). It prefers a package's `emit` script when present, otherwise runs `prisma-next contract emit --config <path>`.
Use `--check` for a dry-run that lists the contract-spaces still needing re-emit and exits non-zero if any remain:
```bash
pnpm exec tsx ./re-emit-mti-variant-link-columns.ts --check
```
The regenerated `contract.json` gains the variant's link columns (the base PK's column set), their primary key, and the cascading foreign key under `storage.namespaces.<ns>.tables.<variant>`, and the contract's `storageHash` changes. `contract.d.ts` picks up the new columns on the variant's row type.
### Migrate your database
Re-emitting changes `storageHash`, so your live database needs the matching schema change. Plan and apply it:
```bash
prisma-next migration plan --name mti-variant-link-columns
prisma-next migrate
```
The plan adds the variant's link columns, sets them `NOT NULL`, adds the primary key over them, and adds the cascading foreign key to the base table.
A variant row's link columns **must equal its parent base row's primary key** — that shared identity is what links a `task` row to its `bug`/`feature` detail row, and the cascading foreign key to the base table enforces it. There is therefore no correct backfill, and you must **never fabricate** the link values (for example with `gen_random_uuid()`): fabricated values have no matching base row, so the validating foreign key in this same migration would immediately reject them.
The runtime always wrote each variant row together with its base row, sharing the same primary-key values. On a database provisioned that way there are no rows missing the link columns, so the `SET NOT NULL` step is a no-op and the migration applies cleanly with no backfill. Author the migration with no `dataTransform` — just `addColumn` (nullable) → `setNotNull` → primary key → foreign key — then run `node <migration>.ts` (or `pnpm exec tsx <migration>.ts`) to self-emit `ops.json` and attest the package before `prisma-next migrate`.
If your database does hold variant rows that predate the link columns, they are unlinkable orphans — nothing in those rows maps them back to their base row. The `SET NOT NULL` precheck ("ensure no NULL values") halts the migration before any destructive step. Resolve those rows by hand — map each to the correct base primary key, or delete it — and re-run. Do not paper over the halt with fabricated link values.
### Validation
After re-emitting and migrating, run `pnpm typecheck && pnpm test` (or your application's equivalent), then `prisma-next migration check` to confirm the on-disk chain is consistent. Inspect the `contract.json` diff: each MTI variant table should carry the base PK's link columns, a `primaryKey` over them, and a cascading `foreignKey` to its base table.
## `cross-space-fk-psl-pattern`
This release ships PSL support for referencing a model from an extension contract space (e.g. `supabase:auth.AuthUser`) in a relation field, together with named-type aliases for database-native column types.
**This entry is informational.** No existing consumer has cross-space foreign keys to change — this is a new opt-in capability. Adopt it when you want a field in your model to reference a row owned by an extension (such as Supabase's `auth.users` table).
### Named-type aliases
Declare a `types` block at the top of your `contract.prisma` to give a database-native type a reusable name:
```prisma
types {
Uuid = String @db.Uuid
}
```
You can then use `Uuid` as a field type anywhere in the same contract. On emit the column receives `nativeType: "uuid"` in `contract.json`.
### Cross-space relation field
Reference another contract space's model using the `<space>:<namespace>.<Model>` syntax in a relation field. The relation requires `extensionPacks` to declare the dependency on the space:
```prisma
// Before (no cross-space FK)
namespace public {
model Profile {
id String @id @default(uuid())
username String
@@map("profile")
}
}
// After (cross-space FK to supabase:auth.AuthUser)
types {
Uuid = String @db.Uuid
}
namespace public {
model Profile {
id String @id @default(uuid())
username String
userId Uuid @unique
user supabase:auth.AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("profile")
}
}
```
On emit, `contract.json` gains:
- A `types.Uuid` entry under `storage` for the named-type alias.
- The `userId` column with `typeRef: "Uuid"` on the storage table.
- A cross-space `foreignKey` entry on the storage table pointing at the extension space's table.
Run `prisma-next contract emit` after updating `contract.prisma`, then plan and apply the migration (`prisma-next migration plan --name add-user-fk && prisma-next migrate`) to add the column and foreign key to your database.
## `storage-namespace-envelope-re-emit`
The storage IR inside `contract.json` moved to a namespace envelope in 0.13. Every
table and type entry that was previously at the top level of `storage` now lives under
`storage.namespaces.<ns>.entries.<kind>`. Cross-references that were bare strings are
now `{ namespace, model }` objects in `domain`. The emitter handles this automatically
— no schema source change is needed.
Because the shape change affects `storageHash`, every SQL and Mongo contract must be
re-emitted, and the database must be migrated to match.
### Re-emit your contract
```bash
prisma-next contract emit
```
### Migrate your database
```bash
prisma-next migration plan --name storage-namespace-envelope
prisma-next migrate
```
The migration records the hash transition; no column or table is added or removed — this
is a metadata-only change. Confirm with `prisma-next migration check` once done.
## `telemetry-now-opt-out`
**Informational — no code change required.**
Starting at 0.13, the CLI collects anonymised usage telemetry by default (previously
opt-in). If you want to disable it, set either of the following environment variables:
```bash
PRISMA_NEXT_DISABLE_TELEMETRY=1
# or
DO_NOT_TRACK=1
```
Either variable takes effect immediately — no config file change needed. See
[Telemetry](https://github.com/prisma/prisma/blob/main/docs/Telemetry.md) for
what is collected and how to opt out permanently.
upgrading/app/upgrades/0.12-to-0.13/re-emit-mti-variant-link-columns.ts
/**
* Re-emits every contract whose MTI variant tables (PSL `@@base(Parent, "tag")`
* models that carry their own `@@map`) predate the base-PK link column.
*
* Starting at this release, a `@@base` variant stored in its own table
* materialises a base-PK link column in storage: the variant table gains an
* `id` column, a single-column primary key on it, and a cascading foreign key
* referencing the base table's primary key. Before the change, the variant
* table held only the variant-specific columns with no primary key.
*
* Detection: a contract is a candidate when its domain carries a model with a
* `base` reference (an MTI variant) whose matching storage table has no
* `primaryKey` — the pre-change shape. After re-emit the table gains its
* `id` PK + cascading FK and the contract's `storageHash` changes.
*
* Dispatch: walks the project root for `prisma.config.ts` directories,
* resolves each space's committed `contract.json`, and re-emits when a variant
* table still lacks its link column. Uses the nearest ancestor `package.json`
* `scripts.emit` when present; otherwise runs
* `prisma-next contract emit --config <path>`.
*
* Flags:
* --check dry-run; lists contract-spaces that still need re-emitting and
* exits 1 if any remain.
*/
import { execFile } from 'node:child_process';
import { access, readdir, readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
const dryRun = process.argv.includes('--check');
const projectRoot = process.cwd();
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
async function findPrismaNextConfigDirs(root: string): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
let entries: Awaited<ReturnType<typeof readdir>>;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
await walk(join(dir, entry.name));
} else if (entry.isFile() && entry.name === 'prisma.config.ts') {
out.push(dir);
}
}
}
await walk(root);
return out.sort();
}
function contractJsonCandidates(configDir: string): string[] {
return [
join(configDir, 'src', 'contract.json'),
join(configDir, 'src', 'prisma', 'contract.json'),
join(configDir, 'prisma', 'contract.json'),
join(configDir, 'contract.json'),
];
}
async function resolveContractJson(configDir: string): Promise<string | null> {
for (const candidate of contractJsonCandidates(configDir)) {
if (await pathExists(candidate)) return candidate;
}
return null;
}
/**
* A contract needs the MTI link-column migration when any of its domain models
* is an MTI variant (carries a `base` reference) whose matching storage table
* has no `primaryKey` — the pre-change shape that lacks the link column.
*/
function contractNeedsMtiLinkColumns(raw: string): boolean {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return false;
}
if (!isJsonObject(parsed)) return false;
const domain = parsed['domain'];
const storage = parsed['storage'];
if (!isJsonObject(domain) || !isJsonObject(storage)) return false;
const domainNamespaces = domain['namespaces'];
const storageNamespaces = storage['namespaces'];
if (!isJsonObject(domainNamespaces) || !isJsonObject(storageNamespaces)) return false;
for (const [nsKey, ns] of Object.entries(domainNamespaces)) {
if (!isJsonObject(ns)) continue;
const models = ns['models'];
if (!isJsonObject(models)) continue;
for (const model of Object.values(models)) {
if (!isJsonObject(model)) continue;
if (!isJsonObject(model['base'])) continue;
const variantStorage = model['storage'];
if (!isJsonObject(variantStorage)) continue;
const tableName = variantStorage['table'];
// The variant table's namespace defaults to the model's enclosing domain
// namespace when `storage.namespace` is absent.
const namespaceId =
typeof variantStorage['namespace'] === 'string' ? variantStorage['namespace'] : nsKey;
if (typeof tableName !== 'string') continue;
const storageNs = storageNamespaces[namespaceId];
if (!isJsonObject(storageNs)) continue;
const tables = storageNs['tables'];
if (!isJsonObject(tables)) continue;
const table = tables[tableName];
if (!isJsonObject(table)) continue;
if (table['primaryKey'] === undefined || table['primaryKey'] === null) {
return true;
}
}
}
return false;
}
async function packageJsonHasScript(dir: string, name: string): Promise<boolean> {
const pkgPath = join(dir, 'package.json');
if (!(await pathExists(pkgPath))) return false;
const raw = await readFile(pkgPath, 'utf-8');
try {
const parsed: unknown = JSON.parse(raw);
if (!isJsonObject(parsed)) return false;
const scripts = parsed['scripts'];
if (!isJsonObject(scripts)) return false;
const value = scripts[name];
return typeof value === 'string' && value.length > 0;
} catch {
return false;
}
}
async function resolveEmitInvocation(configDir: string): Promise<{
readonly cwd: string;
readonly args: string[];
readonly key: string;
}> {
let dir = configDir;
while (dir.startsWith(projectRoot)) {
if (await packageJsonHasScript(dir, 'emit')) {
return { cwd: dir, args: ['emit'], key: `script:${dir}` };
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
const configPath = join(configDir, 'prisma.config.ts');
return {
cwd: projectRoot,
args: ['exec', 'prisma-next', 'contract', 'emit', '--config', configPath],
key: `config:${configPath}`,
};
}
async function runEmit(configDir: string): Promise<void> {
const { cwd, args } = await resolveEmitInvocation(configDir);
await execFileAsync('pnpm', args, { cwd, env: process.env });
}
const configDirs = await findPrismaNextConfigDirs(projectRoot);
const emitKeys = new Set<string>();
const targets: Array<{ configDir: string; contractPath: string }> = [];
for (const configDir of configDirs) {
if (await packageJsonHasScript(configDir, 'build:contract-space')) continue;
const contractPath = await resolveContractJson(configDir);
if (contractPath === null) continue;
const raw = await readFile(contractPath, 'utf-8');
if (!contractNeedsMtiLinkColumns(raw)) continue;
const { key } = await resolveEmitInvocation(configDir);
if (emitKeys.has(key)) continue;
emitKeys.add(key);
targets.push({ configDir, contractPath });
}
if (targets.length === 0) {
console.error(`No MTI variant link-column migration candidates under ${projectRoot}.`);
process.exit(dryRun ? 0 : 1);
}
let needsFix = 0;
for (const { configDir, contractPath } of targets) {
const rel = configDir.slice(projectRoot.length + 1) || '.';
const raw = await readFile(contractPath, 'utf-8');
if (!contractNeedsMtiLinkColumns(raw)) {
console.log(`OK ${rel}`);
continue;
}
needsFix += 1;
if (dryRun) {
console.log(`WOULD RE-EMIT ${rel}`);
continue;
}
console.log(`EMIT ${rel}`);
await runEmit(configDir);
}
console.log();
console.log(
`${targets.length} contract-space(s): ${needsFix} ${dryRun ? 'needing re-emit' : 're-emitted'}.`,
);
if (dryRun && needsFix > 0) process.exit(1);
upgrading/app/upgrades/0.13-to-0.14/instructions.md
---
from: "0.13"
to: "0.14"
changes:
- id: uuid-preset-rename
summary: |
The uuid field presets are renamed: `field.uuid()` → `field.uuidString()`,
`field.id.uuidv4()` → `field.id.uuidv4String()`, `field.id.uuidv7()` →
`field.id.uuidv7String()`. These names now describe the storage encoding
(char(36) string). Postgres-native uuid storage uses the new
`field.uuidNative()` / `field.id.uuidv4Native()` / `field.id.uuidv7Native()`
presets from `@internal/postgres/contract-builder`.
detection:
glob: "**/*.ts"
contains:
- "field.uuid()"
- "field.id.uuidv4()"
- "field.id.uuidv7()"
anyMatch: true
script: uuid-preset-rename.ts
- id: qualify-flat-builder-accessors
summary: |
The builder-layer flat accessors are removed: the query builder and ORM client now
expose per-namespace facets only, and the Postgres facade exposes the qualified
surface. Code that builds queries against a Postgres (multi-namespace) contract must
name the namespace the table/model is declared in: `db.sql.<table>` becomes
`db.sql.<namespace>.<table>` and `db.orm.<Model>` becomes
`db.orm.<namespace>.<Model>` (for a standard single-schema Postgres project the
namespace is `public`). Code that calls the builder outputs directly migrates the
same way: `sql.<table>` → `sql.<namespace>.<table>`, `orm.<Model>` →
`orm.<namespace>.<Model>`. SQLite and Mongo projects are unaffected — their
single-namespace facade keeps flat `db.sql.<table>` / `db.orm.<Model>` working. There
is no codemod: the correct namespace is the one each table/model is declared in, which
is call-site-specific.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "db.sql."
- "db.orm."
anyMatch: true
- id: sql-runtime-base-class-naming
summary: |
`@internal/sql-runtime` now exports `abstract class SqlRuntimeBase` (previously
`SqlRuntime`) — the family-layer subclass seam. Target classes are now named with
`Impl` suffix: `PostgresRuntimeImpl` and `SqliteRuntimeImpl`. The bare names
`PostgresRuntime` and `SqliteRuntime` are now interfaces — the correct types to
depend on in extension and app code. App code using the facade factories
(`postgres(...)`, `sqlite(...)`) is unaffected.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "SqlRuntime"
- "PostgresRuntime"
- "SqliteRuntime"
anyMatch: true
- id: create-runtime-removed
summary: |
`createRuntime` is removed from `@internal/sql-runtime`. Use the target
factory (`postgres(...)` / `sqlite(...)`) or construct the target class
directly: `new PostgresRuntimeImpl({...})` from `@internal/postgres/runtime`,
`new SqliteRuntimeImpl({...})` from `@internal/sqlite/runtime`. App code
using the facade factories (`postgres(...)`, `sqlite(...)`) is unaffected.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "createRuntime"
- id: migration-op-factories-to-methods
summary: |
The bare migration op factory functions are removed from
`@internal/postgres/migration`. Replace each import and call-site with
the corresponding method on `this` inside your `Migration` subclass. The
option shapes changed from positional arguments to a single options object.
detection:
glob: "**/migration.ts"
contains:
- "from '@internal/postgres/migration'"
- "from '@internal/target-postgres/migration'"
anyMatch: true
script: migration-op-factories-to-methods.ts
- id: postgres-contract-serializer
summary: |
`SqlContractSerializer` (from `@internal/family-sql/ir`) can no longer
deserialize Postgres contracts. The family serializer has an empty entries
registry and now rejects the `type` key that every Postgres namespace carries.
Any migration file or app code that calls
`new SqlContractSerializer().deserializeContract(postgresContractJson)` must
switch to `new PostgresContractSerializer()` imported from
`@internal/target-postgres/runtime`.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "SqlContractSerializer"
- id: enum-becomes-domain-concept
summary: |
The PSL `enum` keyword now authors the domain enum (a text-class column whose
value set is enforced by a CHECK constraint) — the native Postgres
`CREATE TYPE … AS ENUM` semantics are gone. An `enum` block must carry
`@@type("<codec-id>")` (e.g. `@@type("pg/text@1")`), members map to database
values with `Name = "value"` (a bare member name defaults to itself where the
codec accepts it), and `@map` on members is removed — the member value IS the
mapping. The transitional `enum2` keyword is retired; rename those blocks to
`enum` (emitted contract is identical). The TS authoring equivalent is
`enumType(name, codecRef, ...member(name, value))` from
`@internal/postgres/contract-builder` returned under the contract's `enums`
key; the old native `enumType(name, values[])` / `enumColumn` from
`@internal/adapter-postgres/column-types` are deleted. Databases that
already carry a native enum type need a one-time converting migration (alter
column to text USING ::text, add the value-set CHECK, DROP TYPE) — `contract
infer` refuses native enum types by design and names them in its diagnostic.
detection:
glob: "**/*.prisma"
contains:
- "enum "
- "enum2 "
anyMatch: true
- id: generated-models-export-removed
summary: |
The generated `contract.d.ts` no longer emits the flat top-level
`export type Models`. Models resolve per-namespace from the domain plane:
replace a reference to the generated `Models` with
`Contract['domain']['namespaces']['<namespace>']['models']` (use `public`
for a standard single-schema Postgres project, `__unbound__` for SQLite or
Mongo). Re-emit the contract to drop the export.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "Models"
---
<!--
TML-2867: codec-routed DDL defaults. The migration planner now resolves each plan
operation lazily (operations are `Promise<Op>[]`), and DDL execute steps carry a
`params` array. The example migration fixtures (`prisma-8-demo`,
`prisma-8-postgis-demo`) were regenerated to reflect the added `params` field.
No user-side API change. Incidental substrate diff only.
-->
<!--
TML-2852: the enum read surface. `enumType`-authored enums become first-class in
application code — an enum-restricted field's value union flows into the static
read/write types of both query lanes, `db.enums.<namespace>.<Name>` exposes the
enum at runtime (a lane-agnostic facade map), and `ORDER BY` on an enum column
sorts by declaration order. Purely additive and opt-in: PSL `enum` stays native
until the cutover, so only `enumType`-authored contracts exercise it, and
`fixtures:check` is byte-identical for every existing contract. No user-side
action — the examples/ diff is the new feature's demonstration. Incidental
substrate diff only.
TML-2838: the PGlite-backed example apps (`prisma-8-demo`, `react-router-demo`,
`supabase`, `bundle-size`, `multi-extension-monorepo`) switched their vitest
`pool` from `threads` to `forks` and pass `--no-memory-protection-keys`. Running
PGlite (WebAssembly) across vitest worker threads intermittently aborts on Linux
with a residual V8 JIT-page race (`jit_page_->allocations_.erase`) that
`@prisma/dev` 0.24.12 reduced but did not fully eliminate; process-per-fork with
PKU JIT-hardening disabled removes it. Test-harness only — no runtime, contract,
or public-API change. Incidental substrate diff only.
-->
# 0.13 → 0.14 — User upgrade instructions
## `uuid-preset-rename`
The uuid field preset names now include the storage encoding suffix:
| Before | After |
| --- | --- |
| `field.uuid()` | `field.uuidString()` |
| `field.id.uuidv4()` | `field.id.uuidv4String()` |
| `field.id.uuidv7()` | `field.id.uuidv7String()` |
These presets store UUIDs as `char(36)` strings and work across all SQL targets. If you want the Postgres-native `uuid` column type instead, use `field.uuidNative()` / `field.id.uuidv4Native()` / `field.id.uuidv7Native()` from `@internal/postgres/contract-builder`.
The rename is mechanical. Run the colocated script or apply the following find-and-replace in your `contract.ts` (or wherever you use the field builder):
```ts
// Before
id: field.id.uuidv7(),
userId: field.id.uuidv4(),
externalId: field.uuid(),
// After
id: field.id.uuidv7String(),
userId: field.id.uuidv4String(),
externalId: field.uuidString(),
```
No change to `contract.json` — both the old and new preset names emit the same codec (`sql/char@1`), so existing emitted contracts remain valid.
## `qualify-flat-builder-accessors`
The query builder and ORM client are now **always qualified by namespace**. The flat by-bare-name accessors are gone: there is no `sql.<table>` and no `orm.<Model>` at the builder layer, and the **Postgres** facade exposes the qualified surface (`db.sql` / `db.orm` are the namespace map). You reach a table or model by naming its namespace.
Namespace selection separates *which namespace's table* from *the ergonomic shorthand for the single-namespace case*. The builder layer always names the namespace; the single-namespace shorthand is recovered by the facade on targets that have only one namespace (SQLite, Mongo).
### Who needs to change code
**Postgres projects** that build queries through the facade or the builder outputs. A standard Postgres project keeps its tables and models in the `public` schema, so the namespace to insert is `public`:
```ts
// Before
const users = await db.sql.user.select('id', 'email').build().execute();
const alice = await db.orm.User.find({ where: { id } });
// After — name the namespace the table/model is declared in (`public` for a standard schema)
const users = await db.sql.public.user.select('id', 'email').build().execute();
const alice = await db.orm.public.User.find({ where: { id } });
```
The same rule applies inside a `transaction` (`tx.sql.public.user`, `tx.orm.public.User`), inside a `prepare(...)` callback (`(sql) => sql.public.user…`), and to code that imports the builder outputs directly rather than through the facade (`sql.public.user`, `orm.public.User`). If your Postgres contract declares more than one namespace, name the namespace each table/model actually sits in — `db.sql.auth.user` for a table in the `auth` schema, `db.sql.public.profile` for one in `public`.
### Who does **not** need to change anything
**SQLite and Mongo projects.** These targets have a single namespace, so their facade still exposes the flat surface — `db.sql.<table>` and `db.orm.<Model>` keep working unchanged. No edits are required.
### How to migrate
There is no codemod, because the correct namespace is the one each table or model is declared in — a fact that lives at the call site, not in a mechanical rule. For each flagged file:
1. If the project's facade is SQLite or Mongo (`sqlite(...)` / `mongo(...)`), leave it unchanged.
2. If it is Postgres (`postgres(...)`), insert the namespace segment after `.sql` / `.orm` (and on direct `sql` / `orm` builder calls): use `public` for a standard single-schema project, or the specific schema name for each table/model in a multi-schema contract.
After migrating, run your project's `pnpm typecheck` (or equivalent) — a missed site is a compile error (`Property '<table>' does not exist on type 'Db<…>'`), so the type checker pins every remaining flat access for you.
## `sql-runtime-base-class-naming`
The SQL runtime class hierarchy now follows the repo naming convention:
- `SqlRuntime` (previously exported) → now `SqlRuntimeBase` (abstract family base)
- `PostgresRuntime` (previously a class) → now an **interface** (the type to depend on); the concrete class is `PostgresRuntimeImpl`
- `SqliteRuntime` (previously a class) → now an **interface** (the type to depend on); the concrete class is `SqliteRuntimeImpl`
App code using the facade factories (`postgres(...)`, `sqlite(...)`) is unaffected — those return `Runtime` / the interface. Only code that referenced the class names directly needs to change:
```ts
// Before — referencing the class as a type
import { PostgresRuntime } from '@internal/postgres/runtime';
function takesRuntime(r: PostgresRuntime) { ... }
// After — use the interface (same import path)
import type { PostgresRuntime } from '@internal/postgres/runtime';
function takesRuntime(r: PostgresRuntime) { ... }
// Before — subclassing
import { PostgresRuntime } from '@internal/postgres/runtime';
class MyRuntime extends PostgresRuntime { ... }
// After — subclass the Impl
import { PostgresRuntimeImpl } from '@internal/postgres/runtime';
class MyRuntime extends PostgresRuntimeImpl { ... }
```
## `create-runtime-removed`
`createRuntime` is removed from `@internal/sql-runtime`. App code using the facade factories (`postgres(...)`, `sqlite(...)`) is unaffected — those still return a `Runtime` as before. Only code that imported and called `createRuntime` directly needs to change.
Replace direct `createRuntime` calls with the appropriate target class constructor or factory:
```ts
// Before
import { createRuntime } from '@internal/sql-runtime';
const runtime = createRuntime({ stackInstance, context, driver, ...opts });
// After — use the target factory (recommended for app code)
import { postgres } from '@internal/postgres';
const db = postgres({ contract, ...opts });
// runtime is accessed via db.connect() / db.runtime() etc.
// Or construct the target class directly (for advanced/test use)
import { PostgresRuntimeImpl } from '@internal/postgres/runtime';
const runtime = new PostgresRuntimeImpl({ adapter: stackInstance.adapter, context, driver, ...opts });
```
The constructor options are identical to what `createRuntime` accepted, except `stackInstance` is not taken: pass `adapter` from `stackInstance.adapter` directly.
## `migration-op-factories-to-methods`
The bare op factory functions previously exported from `@internal/postgres/migration` (and the deprecated `@internal/target-postgres/migration` alias) are removed. Each function is now a protected method on the `PostgresMigration` base class — call it as `this.<method>(...)` inside your `Migration` subclass body.
The option shapes also changed: positional arguments are replaced by a single options object.
Remove the bare names from your import and replace each call-site:
| Before (bare function) | After (method) |
| --- | --- |
| `dropColumn(schema, table, column)` | `this.dropColumn({ schema, table, column })` |
| `setNotNull(schema, table, column)` | `this.setNotNull({ schema, table, column })` |
| `setDefault(schema, table, column, defaultSql)` | `this.setDefault({ schema, table, column, defaultSql })` |
| `addPrimaryKey(schema, table, name, columns)` | `this.addPrimaryKey({ schema, table, constraint: name, columns })` |
| `addForeignKey(schema, table, { name, columns, references, onDelete })` | `this.addForeignKey({ schema, table, foreignKey: { name, columns, references, onDelete } })` |
| `addCheckConstraint(schema, table, name, column, values)` | `this.addCheckConstraint({ schema, table, constraint: name, column, values })` |
| `createIndex(schema, table, indexName, columns)` | `this.createIndex({ schema, table, index: indexName, columns })` |
| `installExtension({ id, extensionName, invariantId })` | `this.installExtension({ id, extensionName, invariantId })` |
Example:
```ts
// Before
import { addForeignKey, createIndex, dropColumn } from '@internal/postgres/migration';
override get operations() {
return [
dropColumn('public', 'user', 'legacyName'),
addForeignKey('public', 'post', {
name: 'post_userId_fkey',
columns: ['userId'],
references: { schema: 'public', table: 'user', columns: ['id'] },
}),
createIndex('public', 'post', 'post_userId_idx', ['userId']),
];
}
// After
import { Migration, MigrationCLI } from '@internal/postgres/migration';
override get operations() {
return [
this.dropColumn({ schema: 'public', table: 'user', column: 'legacyName' }),
this.addForeignKey({
schema: 'public',
table: 'post',
foreignKey: {
name: 'post_userId_fkey',
columns: ['userId'],
references: { schema: 'public', table: 'user', columns: ['id'] },
},
}),
this.createIndex({ schema: 'public', table: 'post', index: 'post_userId_idx', columns: ['userId'] }),
];
}
```
The colocated script applies this transformation automatically. Run it from your project root:
```bash
pnpm exec tsx .claude/skills/prisma-8/upgrading/app/upgrades/0.13-to-0.14/migration-op-factories-to-methods.ts
```
## `postgres-contract-serializer`
`SqlContractSerializer` (from `@internal/family-sql/ir`) now rejects Postgres contracts. The family serializer validates entries against a registry of known entity kinds; it only knows the SQL-family built-ins (`table`, `valueSet`) and has no knowledge of the Postgres-specific `type` key (Postgres enum types). Every Postgres namespace carries `"type": {}` in its `entries`, so the family serializer throws a `ContractValidationError` naming `type` as an unregistered kind.
Replace `SqlContractSerializer` with `PostgresContractSerializer` in any migration file or app code that deserializes a Postgres-emitted contract:
```ts
// Before
import { SqlContractSerializer } from '@internal/family-sql/ir';
const contract = new SqlContractSerializer().deserializeContract(contractJson) as Contract;
// After
import { PostgresContractSerializer } from '@internal/target-postgres/runtime';
const contract = new PostgresContractSerializer().deserializeContract(contractJson) as Contract;
```
SQLite and family-only (non-Postgres) contracts are unaffected — their namespaces carry only `table` entries, which the family serializer knows about.
## `enum-becomes-domain-concept`
The `enum` keyword changed meaning. Before 0.14 a PSL `enum` block authored a **native Postgres enum** (`CREATE TYPE <name> AS ENUM (…)`, columns typed with the named type). Starting at 0.14 the same keyword authors the **domain enum**: the column stores plain values through a declared codec (typically `pg/text@1` → a `text` column) and the value set is enforced by a CHECK constraint the migration planner generates and verifies. The native enum machinery (the `pg/enum@1` codec, native `CREATE TYPE` planning, native-enum introspection adoption) is deleted.
### Who needs to change code
Any project whose `.prisma` schema contains an `enum` block **without** an `@@type(...)` attribute (the old native form), or with `@map` on members, or whose schema uses the transitional `enum2` keyword. Projects that already author enums with `@@type` + member values (the `enum2`-era shape introduced in 0.13) only need the keyword rename described below — the emitted contract is identical.
### 1. Convert the schema syntax
```prisma
// Before — native enum (0.13)
enum user_type {
admin
user
}
// After — domain enum (0.14)
enum user_type {
@@type("pg/text@1")
admin = "admin"
user = "user"
}
```
Rules:
- `@@type("<codec-id>")` is **required**. For string-valued enums use `@@type("pg/text@1")`.
- Each member maps to its database value with `member = "value"`. Under the native semantics the stored label was the member name, so a faithful conversion sets each value to the member's name (`admin = "admin"`). A member that previously carried `@map("dbvalue")` becomes `member = "dbvalue"` — `@map` on enum members is removed; the member value is the mapping.
- If your schema uses the transitional `enum2` keyword (added in 0.13), rename `enum2` → `enum`. Nothing else changes — that block shape is exactly what `enum` now means.
If you author contracts in TypeScript instead of PSL: the native `enumType(name, values[])` and `enumColumn(...)` helpers from `@internal/adapter-postgres/column-types` are deleted. Author the domain enum with `enumType` + `member` from your target's contract-builder and return it under the `enums` key:
```ts
import { defineContract, enumType, member } from '@internal/postgres/contract-builder';
const pgText = { codecId: 'pg/text@1', nativeType: 'text' } as const;
const UserType = enumType('user_type', pgText, member('admin', 'admin'), member('user', 'user'));
export const contract = defineContract({ /* … */ }, ({ field, model }) => ({
enums: { user_type: UserType },
models: {
User: model('User', {
fields: { /* … */ kind: field.namedType(UserType) },
}),
},
}));
```
Then re-emit: `prisma-next contract emit`. The emitted contract carries the enum as a domain entity plus a storage `valueSet`; the column becomes `pg/text@1` / `text` with a `valueSet` reference and a table-level check entry.
### 2. Migrate the database off the native type
A database created under 0.13 still has the native enum type and columns typed with it. Author a one-time converting migration — for each native enum type, in order:
1. Alter each column off the native type, casting the stored labels: `ALTER TABLE … ALTER COLUMN <col> TYPE text USING <col>::text`.
2. Add the value-set CHECK constraint the contract now declares (name it as the contract does, e.g. `<table>_<col>_check`).
3. Drop the native type: `DROP TYPE "<schema>"."<type>"`.
Because the contract hash does not change (the schema conversion in step 1 and the emitted contract are the end state), scaffold the migration as a data-only edge on the current hash: `prisma-next migration new --name convert-<type>-to-value-set --from <current-storage-hash>`, give the ALTER op `operationClass: 'data'`, and self-emit by running the scaffolded `migration.ts`. The `DROP TYPE` has no op builder — express it as an inline `rawSql` op.
A complete worked example ships in the Prisma 8 repo: `examples/prisma-8-demo/migrations/app/20260611T1856_convert_user_type_to_value_set/migration.ts` — three ops (data-class ALTER … USING, `addCheckConstraint`, rawSql `DROP TYPE`), each with pre/postchecks that make replay idempotent.
Note: `prisma-next contract infer` **refuses** databases containing native enum types — it names each offending type and points at this conversion. Convert the database first, then infer.
### 3. Verify
Run `prisma-next db verify` (or your project's test suite) after applying the converting migration: the live schema must now match the contract — `text` column, CHECK constraint present, native type gone.
## `generated-models-export-removed`
The generated `contract.d.ts` no longer emits the flat top-level `export type Models` (the first-name-wins map of every model across namespaces). Models now resolve per-namespace from the domain plane, matching how the runtime and DSL read them.
If your code imported `Models` from the generated contract, read a namespace's models instead:
```ts
// Before
import type { Contract, Models } from './prisma/contract';
type UserModel = Models['User'];
// After — name the namespace the model is declared in
import type { Contract } from './prisma/contract';
type Models = Contract['domain']['namespaces']['public']['models'];
type UserModel = Models['User'];
```
Use `public` for a standard single-schema Postgres project, or `__unbound__` for SQLite and Mongo. In a multi-schema Postgres contract, name the schema each model is declared in. Re-emit your contract (`prisma-next contract emit`) so the generated `.d.ts` drops the `Models` export; the emitted `contract.json` is unchanged.
<!--
TML-2882: transitional PSL `enum2` block (PR #805). The demo authors `enum2 Priority`
and a `priority` field; emitted artifacts and migrations regenerate accordingly, and
the `ValueSetRef` carrier / `StorageValueSet` node tag land in their first persisted
form. Additive and opt-in: no existing consumer contract changes shape, native `enum`
is untouched, and re-emit round-trips. No consumer action required; the keyword is
transitional and is renamed to `enum` at the cutover (TML-2853), which will carry the
user-facing upgrade entry.
-->
<!--
TML-2855: member defaults via `@default(member)` (PR #808). The PSL interpreter and
contract-ts authoring surface now resolve `@default(EnumType.Member)` to a
`{ kind: 'literal', value: '<dbValue>' }` default. The demo `priority` field gains
`@default(Priority.Low)` and a new migration (`20260610T2216_set_priority_default`)
is emitted. Additive and opt-in: only fields that declare `@default(<EnumType>.<Member>)`
are affected; no existing contract changes shape. No consumer action required; the
cutover (TML-2853) will carry the user-facing docs.
-->
<!--
TML-2885: typed domain enum block in emitted contract.d.ts (PR #809). The emitter
now generates a `domain` block in `contract.d.ts` that exposes each PSL-authored enum
as a `ContractEnumAccessor<Entry>` with literal `values`, `names`, and `members` types.
`contract.json` is unchanged — the enum data was already there; this is a types-only
addition. Consumers that re-emit gain a literal-typed `db.enums.<namespace>.<Name>`
surface at compile time (e.g. `db.enums.public.Priority.members.Low` resolves to
`'low'` rather than `string`). Additive — no existing contract shape changes.
No consumer action required.
-->
<!--
TML-2886: typed ALTER TABLE … ADD COLUMN via AlterTable DDL IR (PR #813). The
example migrations that used the bare `addColumn()` helper are updated to
`this.addColumn(...)` (the method on the `Migration` base class, which now carries
full column typing via the `col()` builder). The column-attribute order in emitted
CREATE TABLE SQL changed from `… NOT NULL DEFAULT …` to `… DEFAULT … NOT NULL` as a
by-product of the AlterTable IR alignment. The example fixture snapshots are
regenerated accordingly. No user-facing contract or migration format change.
Incidental substrate diff only.
-->
<!--
TML-2886 (redo, PR #841): type SQL enum columns via a baked storage column lookup.
The SQL emitter now generates a top-level `StorageColumnTypes` map keyed
`[namespace][table][column]`; `FieldOutputTypes`/`FieldInputTypes` are derived from
it at emit time. The query builder (sql-builder) reads `StorageColumnTypes` directly;
the ORM still reads `FieldOutputTypes`. `contract.json` and both hashes are
byte-identical; `FieldOutputTypes` is byte-identical to main. The examples/ diff is
purely `.d.ts` regeneration (the new `StorageColumnTypes` block added; observable
types unchanged). No consumer action required. Incidental substrate diff only.
-->
<!--
#788: enum input types widened to their member union in emitted contract.d.ts (PR
#797). The emitter now renders an enum-restricted field's input type as the literal
member union on the write side, matching the existing output side: a `pg/enum@1`
field's `FieldInputTypes` entry flips from `CodecTypes['pg/enum@1']['input']` (≈
`string`) to e.g. `'admin' | 'user'`. The example `contract.d.ts` goldens are
regenerated accordingly. `contract.json` is unchanged — this is a types-only
addition that makes create/update exhaustiveness-checked. Additive; no existing
contract shape changes. No consumer action required. Incidental substrate diff only.
-->
<!--
TML-2853 (PR #829): regenerate the `prisma-8-demo` example migration chain into
the new value-set representation, recovering work that #817 (the user-facing
`enum-becomes-domain-concept` cutover, already in main) left undone in the example.
The committed chain previously created `user_type` as a native `CREATE TYPE … AS ENUM`
and converted it in a later self-edge migration — a start state the post-cutover
system can no longer produce. The chain is re-authored as a multi-step incremental
history in which the initial migration creates `user.kind` as a `text` column with a
`user_kind_check` CHECK constraint from the start; the native-enum arc and the
`convert_user_type_to_value_set` self-edge are removed. The remaining incremental
milestones (displayName, MTI variant link columns, `post.priority` value-set + default)
are preserved so the chain still demonstrates the incremental migration CLI. Diff is
`examples/prisma-8-demo/migrations/**` only. No NEW consumer action beyond the
existing `enum-becomes-domain-concept` entry above. Incidental substrate diff only.
-->
<!--
TML-2550: per-namespace typed resolution. The emitted contract.d.ts TypeMaps
(`FieldOutputTypes` / `FieldInputTypes`) now nest by namespace
(`{ [namespace]: { [model]: { [field] } } }`), so the query builder and ORM client
resolve each namespace's own columns/fields — fixing same-bare-name models declared
in more than one namespace. The example contract.d.ts fixtures regenerate to the
nested shape; a consumer re-emit round-trips. The user-facing always-qualified query
surface is already covered by `qualify-flat-builder-accessors` above — this slice is
the type-resolution fix beneath it. No user action: re-emit picks up the new shape.
Incidental substrate diff only.
-->
<!--
TML-2916: un-namespaced Postgres models now correctly default to the `public`
namespace per ADR 223, dropping the spurious empty `__unbound__` storage slot the
authoring + serializer pipeline was injecting. Example contract.json / contract.d.ts /
end-contract.* / migration.json files regenerate to drop the `__unbound__` slot;
migration content hashes update. No user action: re-emit picks up the new shape.
Incidental substrate diff only.
-->
upgrading/app/upgrades/0.13-to-0.14/migration-op-factories-to-methods.ts
/**
* Removes bare migration op factory imports and rewrites call-sites to use
* the method form on `this` (0.13 → 0.14):
*
* dropColumn(schema, table, col) → this.dropColumn({ schema, table, column: col })
* setNotNull(schema, table, col) → this.setNotNull({ schema, table, column: col })
* setDefault(schema, table, col, sql)
* → this.setDefault({ schema, table, column: col, defaultSql: sql })
* addPrimaryKey(schema, table, name, cols)
* → this.addPrimaryKey({ schema, table, constraint: name, columns: cols })
* addForeignKey(schema, table, { name, columns, references, onDelete })
* → this.addForeignKey({ schema, table, foreignKey: { name, columns, references, onDelete } })
* addCheckConstraint(schema, table, name, col, vals)
* → this.addCheckConstraint({ schema, table, constraint: name, column: col, values: vals })
* createIndex(schema, table, idx, cols)
* → this.createIndex({ schema, table, index: idx, columns: cols })
* installExtension({ ... }) → this.installExtension({ ... })
*
* Applies to files importing from '@internal/postgres/migration',
* '@internal/target-postgres/migration', '@internal/sqlite/migration', or
* '@internal/target-sqlite/migration'. Handles only call-sites where all
* arguments are simple literals or identifiers on a single logical token (no
* multi-line positional calls). For complex cases the type-checker will flag
* remaining sites.
*
* Run from the project root:
* pnpm exec tsx <path-to-this-file>
*/
import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'pathe';
const FACTORY_NAMES = [
'dropColumn',
'setNotNull',
'setDefault',
'addPrimaryKey',
'addForeignKey',
'addCheckConstraint',
'createIndex',
'installExtension',
];
/**
* Strip bare factory names from import declarations (handles both single-line
* and multi-line `import { ... } from '...'` forms). Removes the whole import
* line (including trailing newline) when all names are factories.
*/
function stripFactoriesFromImports(src: string): string {
const importRe =
/^[^\S\n]*import\s*\{([^}]+)\}\s*from\s*'@internal\/(?:postgres|target-postgres|sqlite|target-sqlite)\/migration'[^\S\n]*;?[^\S\n]*\n?/gms;
return src.replace(importRe, (full, nameBlock) => {
const names = nameBlock
.split(',')
.map((n: string) => n.trim())
.filter((n: string) => n.length > 0 && !FACTORY_NAMES.includes(n));
if (names.length === 0) return '';
const fromClause = full.slice(full.indexOf('}') + 1);
return `import { ${names.join(', ')} }${fromClause}`;
});
}
/** Reads a quoted string or bare identifier/bracket-balanced token starting at offset. */
function readToken(src: string, offset: number): { value: string; end: number } | null {
let i = offset;
while (i < src.length && src[i] === ' ') i++;
if (i >= src.length) return null;
if (src[i] === "'" || src[i] === '"' || src[i] === '`') {
const q = src[i];
let end = i + 1;
while (end < src.length && src[end] !== q) end++;
return { value: src.slice(i, end + 1), end: end + 1 };
}
let depth = 0;
let end = i;
while (end < src.length) {
const c = src[end];
if (c === '(' || c === '[' || c === '{') depth++;
else if (c === ')' || c === ']' || c === '}') {
if (depth === 0) break;
depth--;
} else if ((c === ',' || c === '\n') && depth === 0) break;
end++;
}
return { value: src.slice(i, end).trim(), end };
}
type Rewrite = {
pattern: RegExp;
rewrite: (m: RegExpExecArray) => string | null;
};
const rewrites: Rewrite[] = [
// dropColumn(schema, table, column)
{
pattern: /\bdropColumn\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const c = readToken(rest, t.end + 1);
if (!c) return null;
return `this.dropColumn({ schema: ${s.value}, table: ${t.value}, column: ${c.value} })`;
},
},
// setNotNull(schema, table, column)
{
pattern: /\bsetNotNull\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const c = readToken(rest, t.end + 1);
if (!c) return null;
return `this.setNotNull({ schema: ${s.value}, table: ${t.value}, column: ${c.value} })`;
},
},
// setDefault(schema, table, column, defaultSql)
{
pattern: /\bsetDefault\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const c = readToken(rest, t.end + 1);
if (!c) return null;
const d = readToken(rest, c.end + 1);
if (!d) return null;
return `this.setDefault({ schema: ${s.value}, table: ${t.value}, column: ${c.value}, defaultSql: ${d.value} })`;
},
},
// addPrimaryKey(schema, table, constraintName, columns)
{
pattern: /\baddPrimaryKey\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const n = readToken(rest, t.end + 1);
if (!n) return null;
const c = readToken(rest, n.end + 1);
if (!c) return null;
return `this.addPrimaryKey({ schema: ${s.value}, table: ${t.value}, constraint: ${n.value}, columns: ${c.value} })`;
},
},
// addCheckConstraint(schema, table, constraintName, column, values)
{
pattern: /\baddCheckConstraint\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const n = readToken(rest, t.end + 1);
if (!n) return null;
const c = readToken(rest, n.end + 1);
if (!c) return null;
const v = readToken(rest, c.end + 1);
if (!v) return null;
return `this.addCheckConstraint({ schema: ${s.value}, table: ${t.value}, constraint: ${n.value}, column: ${c.value}, values: ${v.value} })`;
},
},
// createIndex(schema, table, indexName, columns)
{
pattern: /\bcreateIndex\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const idx = readToken(rest, t.end + 1);
if (!idx) return null;
const c = readToken(rest, idx.end + 1);
if (!c) return null;
return `this.createIndex({ schema: ${s.value}, table: ${t.value}, index: ${idx.value}, columns: ${c.value} })`;
},
},
// addForeignKey(schema, table, { ... }) — wraps opts in `foreignKey:`
{
pattern: /\baddForeignKey\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const opts = readToken(rest, t.end + 1);
if (!opts) return null;
return `this.addForeignKey({ schema: ${s.value}, table: ${t.value}, foreignKey: ${opts.value} })`;
},
},
];
function applyRewrites(src: string): string {
// installExtension already takes an object — just prepend `this.`
let out = src.replace(/(?<!this\.)(?<!\.)\binstallExtension\(/g, 'this.installExtension(');
for (const { pattern, rewrite } of rewrites) {
pattern.lastIndex = 0;
let result = '';
let last = 0;
let match = pattern.exec(out);
while (match !== null) {
const before = out.slice(Math.max(0, match.index - 5), match.index);
if (before.endsWith('this.')) {
result += out.slice(last, match.index + match[0].length);
last = match.index + match[0].length;
match = pattern.exec(out);
continue;
}
const replacement = rewrite(match);
if (replacement === null) {
result += out.slice(last, match.index + match[0].length);
last = match.index + match[0].length;
match = pattern.exec(out);
continue;
}
// Find the matching closing paren for the original call
let depth = 1;
let end = match.index + match[0].length;
while (end < out.length && depth > 0) {
if (out[end] === '(') depth++;
else if (out[end] === ')') depth--;
end++;
}
result += out.slice(last, match.index) + replacement;
last = end;
pattern.lastIndex = last;
match = pattern.exec(out);
}
out = result + out.slice(last);
}
return out;
}
function processFile(src: string): string {
const MIGRATION_IMPORT_RE =
/import\s*\{[^}]+\}\s*from\s*'@internal\/(?:postgres|target-postgres|sqlite|target-sqlite)\/migration'/s;
if (!MIGRATION_IMPORT_RE.test(src)) return src;
const withImports = stripFactoriesFromImports(src);
return applyRewrites(withImports);
}
const raw = execSync(
'git ls-files --cached --others --exclude-standard -- "**migration.ts" "migration.ts"',
{ encoding: 'utf-8' },
).trim();
const files = raw
.split('\n')
.filter(Boolean)
.filter((f) => f.endsWith('migration.ts'));
let changed = 0;
for (const file of files) {
const abs = join(process.cwd(), file);
let content: string;
try {
content = readFileSync(abs, 'utf-8');
} catch {
continue;
}
const relevant =
content.includes("from '@internal/postgres/migration'") ||
content.includes("from '@internal/target-postgres/migration'") ||
content.includes("from '@internal/sqlite/migration'") ||
content.includes("from '@internal/target-sqlite/migration'");
if (!relevant) continue;
const updated = processFile(content);
if (updated !== content) {
writeFileSync(abs, updated, 'utf-8');
console.log(`updated ${file}`);
changed++;
}
}
console.log(`done — ${changed} file(s) updated`);
upgrading/app/upgrades/0.13-to-0.14/uuid-preset-rename.ts
/**
* Renames uuid field presets (0.13 → 0.14):
* Before: field.uuid() / field.id.uuidv4() / field.id.uuidv7()
* After: field.uuidString() / field.id.uuidv4String() / field.id.uuidv7String()
*
* Run from the project root: pnpm exec tsx <path-to-this-file>
*/
import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'pathe';
const replacements: Array<[RegExp, string]> = [
[/\bfield\.id\.uuidv4\(\)/g, 'field.id.uuidv4String()'],
[/\bfield\.id\.uuidv7\(\)/g, 'field.id.uuidv7String()'],
[/\bfield\.uuid\(\)/g, 'field.uuidString()'],
];
const raw = execSync('git ls-files --cached --others --exclude-standard -- "*.ts"', {
encoding: 'utf-8',
}).trim();
const files = raw.split('\n').filter(Boolean);
let changed = 0;
for (const file of files) {
const abs = join(process.cwd(), file);
let content: string;
try {
content = readFileSync(abs, 'utf-8');
} catch {
continue;
}
const original = content;
for (const [pattern, replacement] of replacements) {
content = content.replace(pattern, replacement);
}
if (content !== original) {
writeFileSync(abs, content, 'utf-8');
console.log(`updated ${file}`);
changed++;
}
}
console.log(`done — ${changed} file(s) updated`);
upgrading/app/upgrades/0.14-to-0.15/instructions.md
---
from: "0.14"
to: "0.15"
changes:
- id: sql-orm-includes-decode-codec-values
summary: |
SQL ORM includes now decode every scalar child field through its contract-bound codec, matching top-level query results. Update application code and tests that relied on included fields retaining the database's JSON representation: for example, Postgres `bytea` include fields now return `Uint8Array` instead of `\\x`-prefixed hex text, and timestamp include fields return `Date` instead of timestamp strings. Custom codec-backed include fields likewise return the value produced by the codec's `decodeJson` method.
- id: db-verify-json-single-issue-list
summary: |
`prisma-next db verify --json` (and `db verify --schema-only --json`) now report a single
`schema.issues` array instead of the split `schema.issues` / `schema.schemaDiffIssues`
pair — the one-differ-two-ir-planner slice unified the CLI's schema-issue vocabulary onto
one shape: `{ path, reason, message, expected?, actual? }`. `reason` replaces the retired
`outcome` field (`'missing'` -> `'not-found'`, `'extra'` -> `'not-expected'`, `'mismatch'`
-> `'not-equal'`). The same collapse applies to `schema.warnings`. If a script or CI step
parses `db verify --json` output and reads `schema.schemaDiffIssues` /
`schema.warnings.schemaDiffIssues`, or compares an issue's `.outcome` field, update it:
read `schema.issues` (it already carries everything the two lists used to, concatenated)
and switch any `.outcome` comparison to the matching `.reason` value.
detection:
glob: "**/*.{ts,mts,cts,js,mjs,sh}"
contains:
- "schemaDiffIssues"
- ".outcome === 'missing'"
- ".outcome === 'extra'"
- ".outcome === 'mismatch'"
anyMatch: true
- id: policy-target-models-require-rls-attribute
summary: |
RLS enablement is now an explicit, authored table attribute: a `policy_select` block's
`target` model must declare `@@rls`, or `prisma-next contract emit` fails with
`PSL_EXTENSION_TARGET_MODEL_MISSING_ATTRIBUTE` naming the model and the policy. Add
`@@rls` to every policy-bearing model and re-run `prisma-next contract emit`; the
re-emitted `contract.json` gains an `rls` marker entity and a new storage hash. Plan
semantics follow the marker, not the policy set: a marked table with RLS off plans
`ENABLE ROW LEVEL SECURITY` (even with policies in sync), removing every policy keeps
RLS enabled (fail-closed deny-all), removing `@@rls` itself plans
`DISABLE ROW LEVEL SECURITY` (requires the destructive allowance), and changing only a
policy's name prefix plans a single `ALTER POLICY ... RENAME TO` instead of drop+create.
detection:
glob: "**/*.prisma"
contains:
- "policy_select"
anyMatch: true
---
<!--
Release bump to 0.15.0 (PR #988): the version bump itself. Every workspace
`package.json` advances to 0.15.0 (version field + `workspace:` specifier
lockstep), and the `examples/supabase` contract artifacts regenerate because the
emitted contract embeds the composed extension pack's version
(`packs.supabase.version: '0.14.0' → '0.15.0'` in `contract.json` /
`contract.d.ts`) — no structural contract change. No user action beyond the
normal dependency upgrade this recipe covers. Incidental substrate diff only.
-->
<!--
TML-2503 (extension-supabase Slice E — launch close-out, PR #985): docs + test only.
The `examples/` touch is `examples/supabase/README.md` (new) plus
`examples/supabase/test/real-supabase.acceptance.test.ts` — an env-guarded (`skipIf`
on DATABASE_URL / SUPABASE_JWT_SECRET) real-Supabase acceptance test, skipped on the
normal CI path. No framework surface, contract shape, or emitted artefact change.
Incidental substrate diff only.
-->
<!--
TML-2501 (extension-supabase slice B close-out, this PR): test-only. The only
`examples/` touch is `examples/supabase/test/rls-role-binding.integration.test.ts`:
the acceptance test's fixture no longer hand-applies `ENABLE ROW LEVEL SECURITY` /
`CREATE POLICY` SQL — the test now exercises exactly the policies `dbInit` applies
from `contract.prisma`, and gains a WITH CHECK assertion (reassigning an owned row
to another owner is rejected). No framework surface, contract shape, or emitted
artefact changes. No user action required. Incidental substrate diff only.
-->
<!--
TML-2870 (Postgres RLS slice 4: all policy operations + roles): additive. The
PSL RLS surface gains the non-select policy keywords `policy_insert`,
`policy_update`, `policy_delete`, and `policy_all`, each with an optional
`withCheck` predicate (per-operation predicate matrix enforced at load time),
alongside the existing `policy_select`. Postgres database roles also enter
`db verify`: a role a contract declares but the live cluster lacks fails verify
under every control policy, while an undeclared live role is tolerated
unconditionally (the framework references but does not own the cluster's role
list). Both are opt-in and additive — existing schemas that use only
`policy_select` (or no `policy_*` blocks) emit and verify byte-identically, and
no contract declares a role today unless authored to. The only `examples/`
touch is the `examples/supabase` walking skeleton: `Profile` gains an `anon`
public-read policy and an `authenticated` UPDATE-own policy (`using` +
`withCheck`), its `contract.json`/`contract.d.ts` regenerate, and the
integration tests extend to prove WITH CHECK enforcement under `SET ROLE` and
role verify. No user upgrade action — a re-emit picks up any contract shape.
Incidental substrate diff only.
-->
<!--
Postgres-RLS slice 2.5 (one-differ-two-ir-planner), final unit: retires the
coordinate-based issue vocabulary (`BaseSchemaIssue` / `SchemaIssue` /
`EnumValuesChangedIssue` / the legacy `outcome` field) now that the migration
planner and `db verify` both run on the one node-typed differ. The only
`examples/` touch is `examples/supabase/test/skeleton.integration.test.ts`,
which read a verify result's `schema.schemaDiffIssues` list and an issue's
`.outcome` field directly — updated to `schema.issues` and `.reason` per the
`db-verify-json-single-issue-list` entry above. Superseds the "internal
refactor... not a stable shipped API" framing of the TML-2931 entry below: the
JSON shape is now settled and consumer-facing action is required for the
collapse.
-->
<!--
TML-2891 (eliminate the SQL family placeholder namespace): app authors who build
contracts through the public facades / target `defineContract` wrappers
(`@internal/postgres`, `@internal/sqlite`) are unaffected — those wrappers
supply the now-required `createNamespace` factory, so no app-author code changes.
The only `examples/` diff is regenerated migration `end-contract.d.ts` snapshots
whose SQL namespace `kind` changed from `'sql-namespace'` to `'postgres-schema'`;
the next `contract emit` picks this up automatically, and historical migration
snapshots are type-only (the runtime reads `contract.json`, which already carried
`postgres-schema`). No app-author action. Incidental substrate diff only.
-->
<!--
TML-2868 (Postgres RLS slice 1): adds the additive Postgres row-level-security
authoring feature (PSL `policy_select` blocks lower to RLS policies; `db verify`
diffs them, scoped to the namespaces a contract owns). The `examples/supabase/*`
touches — re-emitted `contract.json` / `contract.d.ts` / `contract.prisma`, the
`profile-queries.ts` demo, and the `skeleton.integration.test.ts` walking
skeleton — only demonstrate the new feature plus merge regeneration. RLS is opt
in; existing schemas without `policy_*` blocks emit and verify unchanged. No user
upgrade action — re-emit picks up the contract shape. Incidental substrate diff
only.
-->
<!--
TML-2931 (Postgres RLS migration seam): internal refactor of how the RLS schema
diff is represented — policies nest under a table node, the framework differ keys
nodes by an `id()` path rather than an entity coordinate, and `SchemaDiffIssue`
carries `path` instead of `coordinate`. The only `examples/` touch is
`examples/supabase/test/skeleton.integration.test.ts`, which reads a diff issue's
subject differently (`issue.path` instead of `issue.coordinate`). The RLS
schema-diff surface is still in development (not a stable shipped API), and the
authored RLS feature behaviour is unchanged. No user upgrade action. Incidental
substrate diff only.
-->
<!--
TML-2795: the `prisma-8-demo` example gains a Post<->Tag many-to-many. The demo
authors the relation in PSL (with a re-baselined `add_post_tags` migration), switches
its id fields to native uuid storage for M:N parity, and adds M:N ORM examples, CLI
commands, seed data, and PGlite integration coverage. Demonstrates the many-to-many
authoring surface that slice 5 (TML-2794) added to the framework; the example diff
spans `examples/prisma-8-demo/**` only. Additive and opt-in — no existing consumer
contract changes shape and no migration is forced. No consumer action required.
Incidental substrate diff only.
-->
<!--
TML-2886 (redo, PR #841): type SQL enum columns via a baked storage column lookup.
The SQL emitter now generates a top-level `StorageColumnTypes` map keyed
`[namespace][table][column]`; `FieldOutputTypes`/`FieldInputTypes` are derived from
it at emit time. The query builder (sql-builder) reads `StorageColumnTypes` directly;
the ORM still reads `FieldOutputTypes`. `contract.json` and both hashes are
byte-identical; `FieldOutputTypes` is byte-identical to main. The examples/ diff is
purely `.d.ts` regeneration (the new `StorageColumnTypes` block added; observable
types unchanged). No consumer action required. Incidental substrate diff only.
-->
<!--
TML-2884 (PR #834): end-to-end MongoDB enum enforcement via `$jsonSchema`.
The `mongo-demo` example gains a `UserRole` enum field (`role` on `users`),
authored in both PSL and the TypeScript builder. The planner-generated migration
applies a `collMod` with a `$jsonSchema` validator that enforces the enum values
at the MongoDB layer. Three migration `end-contract.d.ts` / `start-contract.d.ts`
snapshots were also updated for the TML-2891 `'mongo-namespace'` → `'mongo-database'`
kind rename. Re-emit picks up the new contract shape automatically; no consumer
action required. Incidental substrate diff only.
-->
<!--
TML-2503 (extension-supabase slice D): the `examples/supabase` diff adds two new
integration/type tests exercising the additive `db.asServiceRole().supabase.{sql,orm}`
admin surface — a secondary root for reading Supabase-internal `auth.*`/`storage.*`
tables as `service_role`. App authors are unaffected: the admin root is additive, and
the primary `db.asServiceRole().sql`/`.orm` surface (plus `asUser`/`asAnon`) is
unchanged. No user action. Incidental substrate diff only.
-->
<!--
TML-2892 (PR #879): the `Migration` base now takes the migration's start/end
contract JSON as typed inputs and derives `describe()` from their `storage.storageHash`,
and generated migrations use `Migration<Start, End>` with `endContractJson`/
`startContractJson` fields instead of hand-written from/to hashes; the base exposes
typed `this.startContract`/`this.endContract` ContractViews for the (hand-authored)
data-transform case. Every example `migration.ts` is regenerated to this shape; the
`operations` bodies are preserved verbatim, so `ops.json`/`migration.json` and every
emitted contract are byte-identical. No consumer action — re-scaffold via
`migration plan` picks up the new shape. Incidental substrate diff only.
-->
# Upgrade 0.14 → 0.15
No consumer-facing action is required for this transition.
The diff under `examples/` (and the example migration snapshots) is incidental —
emitted contract artefacts (`contract.json` / `contract.d.ts`) were regenerated
for two internal substrate changes:
- **Scalar-list storage machinery.** The emitted contracts now carry the
adapter-reported `scalarList` capability marker and the bumped envelope
version. The scalar-list machinery threaded through this release is internal —
no authoring path emits a list storage column yet, so generated types and
runtime behaviour for existing schemas are unchanged.
- **Namespace-scoped execution-default refs (M:N).** The contract's
`ExecutionMutationDefault.ref` now carries a `namespace` alongside `table` and
`column`, so an execution-time mutation default is keyed by
`(namespace, table, column)`, disambiguating same-named tables across
namespaces. The runtime applies defaults by namespace transparently.
No user action — a re-emit picks up the new contract shape.
<!--
Exercise Mongo enums in retail-store (this PR): the retail-store example replaces
order-type string literals with typed enum accessors via `buildNamespacedEnums` from
`@internal/contract/enum-accessor`. The `MongoClient` facade also gains a direct
`db.execute(plan)` method and a `db.raw` property (both additive). None of these
changes affect the emitted contract shape; a re-emit picks them up automatically.
No user action required. Incidental substrate diff only.
-->
<!--
TML-2954 (reshaping-pipeline decode): the Mongo query builder now reifies a per-stage
result shape, so reads through reshaping aggregation stages (`$project`/`$addFields`,
with more stages to follow) decode their output fields through the contract codecs
instead of returning raw BSON. Previously any reshaping stage collapsed the plan to an
un-decoded pass-through — a projected `_id` came back as a raw `ObjectId`; it now comes
back decoded, matching the row type the builder already declared. `$vectorSearch`
(shape-preserving) is reclassified as identity, so the retail-store `findSimilarProducts`
example drops its `db.raw` + `blindCast` for the typed builder. This is a runtime
behaviour fix — no API or contract-shape change and no re-emit needed; code that relied
on the previous un-decoded values would now observe decoded ones. Incidental to emit.
-->
<!--
TML-2955 (expose the static ExecutionContext symmetrically): additive client-safe
static surface. New `@internal/{mongo,postgres,sqlite}/static` entrypoints export
`<target>Static({ contractJson })`, returning the driver-free `ExecutionContext`
plus derived `enums` / query builder / `raw` / `contract`; the facades also expose
`db.context` (Mongo now typed `MongoExecutionContext<TContract>`) and `db.contract`.
All additive — existing app code is unaffected. The `retail-store` example's
`src/enums.ts` switches from the interim `buildNamespacedEnums` + `blindCast` to
`mongoStatic(...).enums` (example-internal). No user action required. Incidental
substrate diff only.
-->
<!--
TML-2952 (this PR): route SQL enum/value-set column TS typing through the codec.
A field/column restricted to a value set now derives its narrowed TS literal union
by rendering each stored value through its codec, replacing the framework's
(now-deleted) domain-enum override. The only `examples/` touch is a type test —
`examples/prisma-8-demo/test/demo-dx.types.test.ts` — asserting the emitted
`FieldOutputTypes` enum field equals the no-emit `typeof contract` value union
(emit-vs-no-emit agreement). The emitted contract is byte-identical (`fixtures:check`
clean; `contract.json`, `contract.d.ts`, and both hashes unchanged). No user action
required. Incidental substrate diff only.
-->
<!--
Slow-query warning middleware example (PR #912): the `prisma-8-demo` example
gains a `slowQueryWarning` custom middleware (`src/prisma/slow-query-warning.ts`,
wired into the runtime `middleware: [...]` chain in `src/prisma/db.ts`, with
offline unit tests). Documentation-driven example code only — it exercises the
existing public `SqlMiddleware` `afterExecute` hook and changes no framework
surface, contract shape, or emitted artefact. No user action required.
Incidental substrate diff only.
-->
<!--
TML-2953 (this PR): Mongo enum fields now type through a storage value set, the same
way SQL does. Authoring a Mongo enum writes a value set into
`contract.storage.namespaces[<ns>].entries.valueSet[<Enum>]` (the codec-encoded
member values) alongside the domain enum, and the emit typing + `$jsonSchema`
validator source from it. The `mongo-demo` and `retail-store` example contracts
regenerate to carry the value set (`contract.json` gains `entries.valueSet` and its
`storageHash` updates); the emitted `contract.d.ts` field types and the `$jsonSchema`
validator are byte-identical. `db.enums` runtime behaviour is unchanged. A re-emit
picks up the new `contract.json` shape; existing migrations are unaffected (the value
set is non-physical — no new migration op). No user action required. Incidental
substrate diff only.
-->
<!--
TML-2976 (native Postgres enums, external Supabase types — this PR): adds external
native Postgres enum support — Postgres `CREATE TYPE ... AS ENUM` types the database
already owns (e.g. Supabase's `auth.aal_level`), represented via a `native_enum` PSL
entity, typed as a value union, and read at runtime through a Postgres-only
`db.nativeEnums` accessor. The `examples/` diff is additive:
- `examples/supabase` gains `src/session-queries.ts` and
`test/native-enum-session.integration.test.ts` (reading `auth.aal_level`), plus a
regenerated `src/contract.d.ts`.
- `examples/prisma-8-demo` and `examples/retail-store` switch their enum
value-union annotations from `EnumValues<Db['enums'][X]>` to the equivalent `.Value`
phantom (`Db['enums'][X]['Value']`). `EnumValues` is unchanged and still exported;
`.Value` is the new preferred form, so this is an optional style adoption, not a
forced migration.
Native enums are opt-in — existing schemas without a `native_enum` emit and run
unchanged, and a re-emit picks up any contract shape. No user action required.
Incidental substrate diff only.
-->
<!--
Dependabot runtime-deps group bump (PR #962): runtime dependency version
bumps only (arktype 2.2.x, prettier 3.9.4, pg 8.22, pg-cursor 2.21, next
16.2.10, react-router 7.18, radix/tailwind patches and friends), plus a
`pnpm dedupe arktype` so every workspace package resolves the same arktype
instance (the split 2.2.2/2.2.3 resolution broke cross-package `Type`
assignability). The `examples/` diff is package.json dependency version
ranges only — no framework surface, contract shape, or emitted artefact
changes. No user action required. Incidental substrate diff only.
-->
<!--
TML-2503 (extension-supabase: the complete, introspected Supabase contract, this
PR): additive + test-only. The pack's emitted `contract.json` / `contract.d.ts`
grow from a partial hand-scoped shape to the full contract introspected from a
Supabase reference instance (every `auth`/`storage` table, native enum, and
platform role), so consumers re-import a now-complete set of types — additive;
nothing previously shipped is renamed or removed except two internal named-type
aliases (`Uuid` / `Timestamptz`) folded into the canonicalized named-type set.
Back-relation field names are corrected from the inferrer's double-pluralized
form (`sessionses` → `sessions`, `identitieses` → `identities`, …); those names
were introduced by this PR's full regeneration and never shipped, so there is no
rename for existing consumers (the general inferrer fix is TML-3024). The
`examples/` diff is the `examples/supabase` walking-skeleton test plus a CI-only
vitest `retry` config that absorbs a known intermittent PGlite (WASM) abort. A
re-emit picks up any contract shape. No user action required. Incidental
substrate diff only.
-->
<!--
PR #915 (middleware doc-comment lifecycle fixes): comments-only. The only
`examples/` touches are doc comments in
`examples/prisma-8-demo/src/prisma/db.ts` and
`examples/prisma-8-demo/src/orm-client/find-user-by-id-cached.ts`,
correcting stale claims about what runs on a cache-middleware hit (every
`beforeExecute` has already run before `intercept` is consulted,
`afterExecute` still fires with `source: 'middleware'`, and `decodeRow`
still runs on the hit path). No code, contract, or emitted-artefact change.
No user action required. Incidental substrate diff only.
-->
<!--
Dependabot dev-deps group bump (PR #961): dev-dependency version bumps only
(biome 2.5.2, wrangler, @types/react, @cloudflare/* and friends), plus the
biome.jsonc schema-version alignment and the handful of code sites biome 2.5
newly flags (useOptionalChain / noProto in tests). The `examples/` diff is
package.json devDependency version ranges only — no framework surface,
contract shape, or emitted artefact changes. No user action required.
Incidental substrate diff only.
-->
upgrading/app/upgrades/0.15-to-0.16/instructions.md
---
from: "0.15"
to: "0.16"
changes:
- id: extension-supabase-test-utils-export-removed
summary: |
`@internal/extension-supabase` no longer exports the `./test/utils` subpath
(`bootstrapSupabaseShim`). The import typechecked (types shipped in `dist`), but the
subpath never worked from npm — the shim reads fixture `.sql` files that were never
published, so every call failed with ENOENT before touching a database. There is no
working code to migrate: delete the import and whatever test setup called
`bootstrapSupabaseShim`.
detection:
glob: "**/*.{ts,mts,cts,js,mjs}"
contains:
- "extension-supabase/test/utils"
anyMatch: true
- id: identity-columns-need-explicit-default-under-strict-verify
summary: |
`contract infer` now emits `@default(autoincrement())` for a Postgres `GENERATED ALWAYS AS IDENTITY` / `GENERATED BY DEFAULT AS IDENTITY` column (previously it emitted a bare column with no default, since Postgres reports no `column_default` for an identity column). Symmetrically, `db verify` introspecting a live identity column now resolves its default to `autoincrement()` too (previously it resolved to nothing). This only changes `db verify --strict` — without `--strict`, an undeclared live default is tolerated either way. If you run `db verify --strict` against a table with an identity column whose contract does not declare `@default(autoincrement())` (because it predates this fix), verify now reports that default as an unexpected extra. Re-run `contract infer` for the affected table, or add `@default(autoincrement())` by hand, to match what the database has always generated.
- id: pluralize-back-relation-names-no-longer-double-pluralize
summary: |
`contract infer`'s back-relation field name generation used a hand-rolled pluralization rule that appended `es` to any table name already ending in `s`/`x`/`z`/`ch`/`sh`, doubling an already-plural table name (`sessions` -> `sessionses`). `contract infer` now uses real inflection (the `pluralize` library) and produces the correct name (`sessions` stays `sessions`; a genuinely singular `status` still becomes `statuses`). This only affects a future `contract infer` run — an already-generated `.prisma` file is untouched, so nothing breaks until you next re-run infer. If you do re-run `contract infer` against a database with an already-plural table name, diff the regenerated `.prisma` file for any back-relation field whose name changed — that's a public field name your application code accesses via `.include()`/`.select()`/the generated TypeScript types, so update those call sites to the corrected name.
- id: scalar-type-descriptors-channel-removed
summary: |
The scalar-type descriptor channel is retired in favour of the unified authoring type
namespace. Projects with custom control-stack setups that import
`createPostgresScalarTypeDescriptors` / `createSqliteScalarTypeDescriptors`, or that read
`scalarTypeDescriptors` from a control stack or contract-source context, must migrate:
those exports are deleted, and scalar types are now zero-arg type-constructor
contributions in the component's `authoring.type` namespace — e.g.
`String: { kind: 'typeConstructor', output: { codecId: 'pg/text@1', nativeType: 'text' } }`.
Read the scalar type names via `stack.scalarTypes`, or the full name ->
`{ codecId, nativeType }` map via `collectScalarTypeConstructors(stack.authoringContributions.type)`
from `@internal/framework-components/authoring`. Standard target setups
(`@internal/postgres`, `@internal/sqlite`) supply the contributions themselves.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "createPostgresScalarTypeDescriptors"
- "createSqliteScalarTypeDescriptors"
- "scalarTypeDescriptors"
anyMatch: true
- id: postgres-json-rebound-to-native-json
summary: |
On the postgres target the PSL `Json` scalar re-binds from `pg/jsonb@1` / `jsonb` to
`pg/json@1` / `json`; a new bare `Jsonb` scalar carries `pg/jsonb@1` / `jsonb`. Postgres
schemas that use `Json` and mean jsonb storage (which every pre-0.16 `Json` field did)
must switch those fields — and `types {}` aliases — to `Jsonb`, then re-run
`prisma-next contract emit`; with `Jsonb` the emitted `contract.json` is byte-identical
to the pre-0.16 output. A field left as `Json` now emits a native `json` column and a
new storage hash, which against an existing jsonb database is a schema change. The
legacy `@db.Json` attribute path is unchanged (`Json @db.Json` still yields
`pg/json@1` / `json`), and sqlite/mongo `Json` bindings are untouched. The TS builder
surface (`field.json()`, `jsonbColumn`) is unchanged and stays jsonb.
detection:
glob: "**/*.prisma"
contains:
- "Json"
anyMatch: true
- id: default-generators-no-longer-set-storage
summary: |
`@default(<generator>)` no longer influences a column's storage — the type position is
the only storage decider. Pre-0.16, a generator default on a bare `String` field re-picked
the column's storage to a sized char: `String @default(uuid())` / `@default(uuid(7))`
emitted `sql/char@1` / `character(36)`, `@default(cuid(2))` `character(24)`,
`@default(nanoid())` `character(21)` (or `character(<size>)` for `nanoid(<size>)`), and
`@default(ulid())` `character(26)`. From 0.16 such fields emit the target's `String`
storage (postgres: `pg/text@1` / `text`) with the same execution-time generator, so a
re-emit produces a new storage hash — against an existing database created with the char
storage this is a schema change. To keep the prior storage byte-identical, name it in the
type position: `Char(36) @default(uuid())`, `Char(24) @default(cuid(2))`,
`Char(21) @default(nanoid())` (or `Char(<size>)` for a sized nanoid), `Char(26)
@default(ulid())` — or adopt native `Uuid` for `uuid()` if a `uuid`-typed column is
preferred (that is a schema change too). Then re-run `prisma-next contract emit` and, if
you accepted a storage change, plan/apply the matching migration. Generator applicability
validation is unchanged (`uuid()` on `Int` still fails with
`PSL_INVALID_DEFAULT_APPLICABILITY`), and the TS builder presets
(`field.id.uuidv4String()`, `field.generated(uuidv4())`, …) are untouched — they bundle
their `char(N)` storage explicitly.
detection:
glob: "**/*.prisma"
contains:
- "@default(uuid("
- "@default(cuid("
- "@default(nanoid("
- "@default(ulid("
anyMatch: true
---
<!--
Release bump to 0.16.0 (PR #1019): the version bump itself. Every workspace
`package.json` advances to 0.16.0 (version field + `workspace:` specifier
lockstep). The bump commit touches only `package.json` files and
`pnpm-lock.yaml` — no source, contract shape, or emitted artefact change. No
user action beyond the normal dependency upgrade this recipe covers. Incidental
substrate diff only.
-->
<!--
TML-3027 (foreign keys and indexes are discrete contract entities): emitted
contract-shape change. `contract emit` now materializes the per-FK `constraint`/
`index` authoring booleans into discrete entities — a `foreignKeys[]` entry is the
referential constraint only (no `constraint`/`index` fields), and every backing
index (including one backing a FK) is its own named `indexes[]` entry. The booleans
remain as authoring input (`@relation(index:)`, TS `fk({ constraint, index })`,
`foreignKeyDefaults`). Every FK-bearing `contract.json` / `contract.d.ts` in the
repo re-emits to the new shape (the `examples/` diff is that regeneration); a
downstream `contract emit` picks it up automatically with no source change. The
only caller-visible break is TypeScript that reads `.constraint` / `.index` off a
contract's `foreignKeys[]` entry (contract internals, not an app-authoring
surface) — those fields are gone; read the discrete `indexes[]` entry instead. No
migration or DDL change: the schema the planner and `db verify` derive is
identical.
-->
<!--
Supabase integration close-out (TML-2503): docs-only. The `examples/` touch is
`examples/supabase/README.md` — a link into the deleted
`projects/supabase-integration/` workspace removed. No framework surface,
contract shape, or emitted artefact change. Incidental substrate diff only.
-->
<!--
TML-3028 (dependency-graph migration ordering; SchemaDiffIssue.reason removed):
the migration-diff internal `SchemaDiffIssue` lost its `reason` field —
discriminate via the presence of `expected`/`actual`, or the exported
`issueOutcome(issue): ExpectationFailureReason` helper. `ExpectationFailureReason`
keeps its `'not-found' | 'not-expected' | 'not-equal'` values and its export path;
it is now the helper's return type rather than the removed field's type. This is a
framework migration-control internal, not an app-authoring surface. The
`examples/` diff is supabase-example TEST assertions updated from `.reason` to
presence — no runtime, contract, or DDL change. Incidental test-only diff.
-->
<!--
Supabase example env template (TML-2503): docs-only. The `examples/` touch adds
`examples/supabase/.env.example`, naming the two env vars the real-Supabase
acceptance lane already reads (`DATABASE_URL`, `SUPABASE_JWT_SECRET`). Nothing
loads the file — it documents what to export. No framework surface, contract
shape, or emitted artefact change. Incidental substrate diff only.
-->
<!--
Dependabot dev-deps group bump (PR #961): dev-dependency version bumps only
(biome 2.5.2, wrangler, @types/react, @cloudflare/* and friends), plus the
biome.jsonc schema-version alignment and the handful of code sites biome 2.5
newly flags (useOptionalChain / noProto in tests). The `examples/` diff is
package.json devDependency version ranges and biome.jsonc schema versions only —
no framework surface, contract shape, or emitted artefact changes. No user
action required. Incidental substrate diff only.
-->
<!--
Dependabot dev-deps group bump (PR #1006): `changes: []`. dev-dependency version
bumps only (biome 2.5.2 → 2.5.3, wrangler, @prisma/compute-sdk, pkg-pr-new,
@cloudflare/vitest-pool-workers), plus the test-only sites biome 2.5.3 newly
flags via noUnsafeOptionalChaining (`(x?.y as T).z` → `(x!.y as T).z`). The
`examples/` diff is package.json devDependency version ranges only (wrangler and
@cloudflare/vitest-pool-workers in bundle-size and the cloudflare-worker example)
— no framework surface, contract shape, or emitted artefact change. No user
action required. Incidental substrate diff only.
-->
<!--
Dependabot runtime-deps group bump (PR #1014): `changes: []`. Dependency version
bumps only. The `examples/` diff is package.json version ranges in the demo apps
(isbot in react-router-demo; @radix-ui/react-radio-group, @radix-ui/react-select,
lucide-react, postcss in retail-store) — dependencies of the example applications
themselves, not of any `@internal/*` package. No framework surface, contract
shape, or emitted artefact change. No user action required. Incidental substrate
diff only.
-->
upgrading/app/upgrades/0.16-to-0.17/instructions.md
---
from: "0.16"
to: "0.17"
changes:
- id: one-prisma-package-per-application
summary: |
Do this first — nothing else in this upgrade can run until the project installs. From
0.17 the `@prisma-next/*` scope is retired: nothing publishes under it again, so a
manifest that still names it fails at install. The public surface is the `@prisma`
scope, and an application depends on exactly ONE database facade —
`@prisma/orm-postgres`, `@prisma/orm-sqlite`, or `@prisma/orm-mongo` — plus any
extension packs it uses, which remain direct dependencies. Everything else that used
to be a separate dependency (framework, family, target, adapter, driver, CLI) arrives
transitively as the facade's exact-pinned dependencies. In `package.json`: delete
EVERY `@prisma-next/*` entry across dependencies/devDependencies/peerDependencies,
add the one facade for your database, and keep your extension packs — renamed
(`@prisma-next/extension-<x>` → `@prisma/orm-extension-<x>`; the middleware cache is
`@prisma/orm-extension-middleware-cache`). Drop the `prisma-next` devDependency if you
have one — the facade provides the `prisma-next` bin; the standalone `prisma-next`
package remains only as the bootstrap path for projects with no Prisma dependencies
yet. Reinstall, then regenerate your contract artefacts (`prisma-next contract emit`):
generated files now import facade entrypoints (e.g. `@prisma/orm-postgres/components`)
instead of old-scope package names. The `contractHash` is unchanged by regeneration —
`prisma-next db verify` passes with no database work. Finally rewrite hand-written
imports: same-package entrypoints keep their subpath under the facade
(`@prisma-next/postgres/config` → `@prisma/orm-postgres/config`, likewise `/runtime`,
`/target`, `/family`, `/migration`, `/control`, …); programmatic tooling imports
(`@prisma-next/cli/*`, `@prisma-next/config-loader`, `@prisma-next/migration-tools/*`,
`@prisma-next/emitter`) move to the matching `@prisma/orm-toolchain/*` subpath
(`@prisma/orm-toolchain/cli/config-types`, `/cli/control-api`, `/config-loader`,
`/migration-tools/<subpath>`, `/emitter`). The rule for every rewrite: import only
from packages your manifest names directly — the facade, your extension packs, and
(for tooling authors) `@prisma/orm-toolchain`.
detection:
glob: "**/*.{json,ts,tsx,mts,cts,js,mjs,cjs}"
contains:
- '@prisma-next/'
anyMatch: true
- id: strip-sha256-hash-prefixes
summary: |
Content hashes are bare lowercase hex from 0.17 — the `sha256:` prefix is gone from every
surface (emitted `contract.json` / `contract.d.ts`, migration manifests, refs, CLI output,
and the database marker/ledger), and loaders reject the legacy prefixed form. Contract hash
VALUES are unchanged (only the prefix drops; `pnpm emit` regenerates live artefacts), but
`migrationHash` VALUES change because the hashed manifest bytes embed the now-bare
`from`/`to` strings. Run the colocated codemod over your checked-in `migrations/` trees
FIRST, before the snapshot-layout migrator in the entries below — the 0.17 layout migrator
accepts only bare-hex trees. The codemod handles both layouts: it strips the prefix from
every hash literal (manifests, `ops.json`, pre-store sibling contract snapshots, store
entries under `migrations/snapshots/`, `.d.ts` branded literals), maps the empty-tree
sentinel `sha256:empty` to `empty`, recomputes each `migrationHash`, and repoints
`refs/*.json`. Store directory names are the hash's hex, which does not change. Databases
whose marker/ledger still hold prefixed values report a hash mismatch on verify — there is
no compatibility shim; re-sign against the regenerated contract (`prisma-next db sign`).
detection:
glob: "**/*.{json,ts,tsx}"
contains:
- 'sha256:'
anyMatch: true
script: ./strip-sha256-hash-prefixes.ts
- id: migration-contract-snapshots-moved-to-content-addressed-store
summary: |
Committed migration contract snapshots move from per-package sibling files
(`start-contract.json` / `start-contract.d.ts` / `end-contract.json` /
`end-contract.d.ts`) and per-space head copies
(`migrations/<space-id>/contract.json` / `contract.d.ts`) into a single
content-addressed store per migrations root, at
`migrations/snapshots/<hex>/contract.json` + `contract.d.ts`, where `<hex>`
is the contract's 64-hex storage hash (bare hex after the
`strip-sha256-hash-prefixes` entry above, which must run first). Every
distinct contract is stored once, however many migrations reference it.
Every emitted `migration.ts` now imports its bookend contracts from the
store (`../../snapshots/<hex>/contract.json`, `../../snapshots/<hex>/contract.d.ts`)
instead of from sibling files in its own directory.
This is a clean break: there is no fallback reader for the old sibling-file
layout, so a committed migrations tree that has not been converted fails to
load once you upgrade — `migration plan` / `migration new` / `migrate` /
`migration check` all read contract snapshots through the store only, and
a missing store entry fails with `MIGRATION.CONTRACT_SNAPSHOT_MISSING`
naming the expected hash and path. `migration.json` / `ops.json` /
`migrationHash` are unaffected — the contract snapshot was never part of
migration identity, so converting the layout changes no migration's hash.
To convert an existing project, run the migrator once per migrations root
from a checkout of the `prisma/prisma` repository at (or above) the
version you're upgrading to: `node scripts/migrate-migrations-layout.mjs
[migrationsRoot...]` (with no arguments it auto-discovers every migrations
root under the current directory). Per migration package, it reads
`migration.json`, write-if-absents the destination contract (and the
source contract, when present) into the store under the matching hash,
rewrites the committed `migration.ts` import specifiers, and deletes the
four sibling files. Per contract space, it store-writes any remaining
per-space `contract.json` / `contract.d.ts` keyed by that space's
`refs/head.json` hash, then deletes it. It asserts every contract's inner
`storage.storageHash` against the hash it's filed under before writing
anything (mismatch aborts the whole run, nothing is deleted), and
re-verifies every `migrationHash` is unchanged after conversion. Run it,
review the diff, then `pnpm typecheck` (or your project's equivalent) to
confirm every rewritten `migration.ts` import resolves.
detection:
glob: "**/migration.ts"
contains:
- "./start-contract.json"
- "./end-contract.json"
- "./start-contract'"
- "./end-contract'"
anyMatch: true
- id: ref-paired-snapshots-moved-to-content-addressed-store
summary: |
Ref-paired contract snapshot files (`refs/<name>.contract.json` /
`refs/<name>.contract.d.ts`, written by `ref set` and `--advance-ref`) are
no longer written or read. A ref is now only its pointer file,
`refs/<name>.json` (`{ hash, invariants }`); the contract it names
resolves through the same content-addressed store as every migration
graph node, `migrations/snapshots/<hex>/contract.json` + `contract.d.ts`,
by that hash. This is a clean break: a pointer whose store entry is
missing now fails with `MIGRATION.CONTRACT_SNAPSHOT_MISSING` naming the
expected hash and path, rather than silently falling back to the
migration graph. The same one-shot migrator that folds per-package and
per-space sibling snapshots (see the entry above) also folds any
existing `refs/<name>.contract.json` / `refs/<name>.contract.d.ts`
pairs: it write-if-absents the pair into the store under the sibling
pointer's `hash`, then deletes the pair — the pointer file itself is
read but never written, so it stays byte-identical. A `.contract.json`
with no sibling pointer, or whose inner `storage.storageHash` disagrees
with the pointer's `hash`, aborts the whole run before anything is
written or deleted. Run `node scripts/migrate-migrations-layout.mjs
[migrationsRoot...]` (same invocation as above; one run folds both
migration-package and ref-paired snapshots), review the diff, then
re-run `prisma-next ref list` to confirm your refs are unaffected.
detection:
glob: "**/refs/*.contract.json"
anyMatch: true
- id: extension-packs-config-key-renamed-to-extensions
summary: |
The `extensionPacks` key is renamed to `extensions` everywhere: the
low-level `defineConfig` in `prisma.config.ts`, the TS builder's
`defineContract` (record form), runtime/control client options, and the
top-level key of the emitted `contract.json` / `contract.d.ts`. The old
config key now fails loudly with "Config.extensionPacks is no longer
supported; rename it to Config.extensions" — it is never silently
ignored. Rename the key in `prisma.config.ts` (and `contract.ts` /
`db.ts` if they pass `extensionPacks` to client factories). The target
façades' `defineConfig` already used `extensions`; only projects on the
low-level config change. Because the key sits in the canonicalized bytes
of every contract hash, all three hashes (`storageHash`,
`executionHash`, `profileHash`) change for every contract: re-emit with
`prisma-next contract emit`, then re-anchor migrations — regenerate
`migrations/snapshots/<hex>/` store entries and refs for the new hashes
(a schema-unchanged project needs a hash-advance migration or a
re-baseline; the database schema itself does not change).
detection:
glob: "**/{prisma.config.ts,contract.ts,db.ts}"
contains:
- "extensionPacks"
- id: contract-source-format-key-renamed
summary: |
The contract source provider field `sourceFormat` is renamed to `format`
(`contract.source.format` in the low-level config; provider objects from
`prismaContract()` / `typescriptContract()` emit the new field
automatically once upgraded). Rename any literal `sourceFormat:` in
hand-written provider objects or config assertions.
detection:
glob: "**/prisma.config.ts"
contains:
- "sourceFormat"
- id: sugar-output-path-key-renamed-to-output
summary: |
The target façades' `defineConfig` option `outputPath` is renamed to
`output`. Semantics are unchanged (a directory; `contract.json` is
written inside it). Rename the key in `prisma.config.ts`.
detection:
glob: "**/prisma.config.ts"
contains:
- "outputPath"
- id: orm-count-only-mutation-terminals-renamed
summary: Replace `createCount(...)`, `updateCount(...)`, and `deleteCount()` with `createAndCount(...)`, `updateAndCount(...)`, and `deleteAndCount()` in ORM call sites; arguments, guards, behavior, and `Promise<number>` results are unchanged, and no compatibility aliases remain.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- ".createCount("
- ".updateCount("
- ".deleteCount("
anyMatch: true
- id: psl-format-error-class-removed
summary: |
The `PslFormatError` class is deleted from `@internal/psl-parser`. `format()`
on source with parse errors now throws a structured envelope with code
`PSL.PARSE_FAILED`; the diagnostics previously on `error.diagnostics` are at
`error.meta.diagnostics`. Replace `error instanceof PslFormatError` with
`isStructuredError(error) && error.code === 'PSL.PARSE_FAILED'`
(`isStructuredError` from `@internal/utils/structured-error`). The message
text is unchanged.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "PslFormatError"
- id: scalar-type-descriptors-channel-removed
summary: |
The scalar-type descriptor channel is retired in favour of the unified authoring type
namespace. Projects with custom control-stack setups that import
`createPostgresScalarTypeDescriptors` / `createSqliteScalarTypeDescriptors`, or that read
`scalarTypeDescriptors` from a control stack or contract-source context, must migrate:
those exports are deleted, and scalar types are now zero-arg type-constructor
contributions in the component's `authoring.type` namespace — e.g.
`String: { kind: 'typeConstructor', output: { codecId: 'pg/text@1', nativeType: 'text' } }`.
Read the scalar type names via `stack.scalarTypes`, or the full name ->
`{ codecId, nativeType }` map via `collectScalarTypeConstructors(stack.authoringContributions.type)`
from `@internal/framework-components/authoring`. Standard target setups
(`@internal/postgres`, `@internal/sqlite`) supply the contributions themselves.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "createPostgresScalarTypeDescriptors"
- "createSqliteScalarTypeDescriptors"
- "scalarTypeDescriptors"
anyMatch: true
- id: postgres-native-types-move-to-type-position
summary: |
PostgreSQL native storage types are authored directly in PSL type position, and the legacy `@db.*` attribute channel is removed. Rewrite `BaseType @db.Type` as `Type` and `BaseType @db.Type(args)` as `Type(args)` in both `types {}` aliases and model fields, then re-run `prisma-next contract emit`. Any remaining `@db.X(args)` fails with `@db.X(args) is no longer supported; use X(args) in type position`, preserving the constructor name and arguments in the suggested replacement. The supported translations are `@db.Char` → `Char`, `@db.VarChar` → `VarChar`, `@db.Numeric` → `Numeric`, `@db.Uuid` → `Uuid`, `@db.Inet` → `Inet`, `@db.SmallInt` → `SmallInt`, `@db.Real` → `Real`, `@db.Timestamp` → `Timestamp`, `@db.Timestamptz` → `Timestamptz`, `@db.Date` → `Date`, `@db.Time` → `Time`, and `@db.Timetz` → `Timetz`; preserve constructor arguments. Rewrite the old native-json spelling `Json @db.Json` as bare `Json`. This source migration preserves native types and supplied type parameters. It also preserves codec ids except for `@db.Date` → `Date`, which rebinds `pg/timestamptz@1` to `pg/date@1`, changes the contract storage hash, and requires re-emission plus re-signing; see the `postgres-date-rebound-to-pg-date` entry below. Separately, apply the `postgres-json-rebound-to-native-json` entry below to old bare `Json` fields that meant jsonb storage.
detection:
glob: "**/*.prisma"
contains:
- "@db."
anyMatch: true
- id: postgres-json-rebound-to-native-json
summary: |
On the postgres target the PSL `Json` scalar re-binds from `pg/jsonb@1` / `jsonb` to
`pg/json@1` / `json`; a new bare `Jsonb` scalar carries `pg/jsonb@1` / `jsonb`. Postgres
schemas that use `Json` and mean jsonb storage (which every pre-0.16 `Json` field did)
must switch those fields — and `types {}` aliases — to `Jsonb`, then re-run
`prisma-next contract emit`; with `Jsonb` the emitted `contract.json` is byte-identical
to the pre-0.16 output. A field left as `Json` now emits a native `json` column and a
new storage hash, which against an existing jsonb database is a schema change. The
removed `@db.Json` spelling must be rewritten from `Json @db.Json` to bare `Json`; any remaining use fails with migration guidance to use `Json` in type position. SQLite and Mongo `Json` bindings are untouched. The TS builder surface (`field.json()`, `jsonbColumn`) is unchanged and stays jsonb.
detection:
glob: "**/*.prisma"
contains:
- "Json"
anyMatch: true
- id: default-generators-no-longer-set-storage
summary: |
`@default(<generator>)` no longer influences a column's storage — the type position is
the only storage decider. Pre-0.16, a generator default on a bare `String` field re-picked
the column's storage to a sized char: `String @default(uuid())` / `@default(uuid(7))`
emitted `sql/char@1` / `character(36)`, `@default(cuid(2))` `character(24)`,
`@default(nanoid())` `character(21)` (or `character(<size>)` for `nanoid(<size>)`), and
`@default(ulid())` `character(26)`. From 0.16 such fields emit the target's `String`
storage (postgres: `pg/text@1` / `text`) with the same execution-time generator, so a
re-emit produces a new storage hash — against an existing database created with the char
storage this is a schema change. To keep the prior storage byte-identical, name it in the
type position: `Char(36) @default(uuid())`, `Char(24) @default(cuid(2))`,
`Char(21) @default(nanoid())` (or `Char(<size>)` for a sized nanoid), `Char(26)
@default(ulid())` — or adopt native `Uuid` for `uuid()` if a `uuid`-typed column is
preferred (that is a schema change too). Then re-run `prisma-next contract emit` and, if
you accepted a storage change, plan/apply the matching migration. Generator applicability
validation is unchanged (`uuid()` on `Int` still fails with
`PSL_INVALID_DEFAULT_APPLICABILITY`), and the TS builder presets
(`field.id.uuidv4String()`, `field.generated(uuidv4())`, …) are untouched — they bundle
their `char(N)` storage explicitly.
detection:
glob: "**/*.prisma"
contains:
- "@default(uuid("
- "@default(cuid("
- "@default(nanoid("
- "@default(ulid("
anyMatch: true
- id: postgres-date-rebound-to-pg-date
summary: |
On the postgres target, the bare `Date` type constructor re-binds from `pg/timestamptz@1` to the dedicated `pg/date@1` codec. Rewrite the removed `DateTime @db.Date` spelling as `Date`; leaving it unchanged now fails with migration guidance to use `Date` in type position. The stored native type is unchanged (`date`), so no schema migration is needed, but a re-emit changes the column's codec ref and therefore the contract's storage hash: run `prisma-next contract emit`, then re-sign any signed database against the regenerated contract (`prisma-next db sign`) — verify reports a hash mismatch until you do.
Contracts emitted before the upgrade keep working (`pg/timestamptz@1` still exists).
Runtime behavior changes on re-emit: date columns decode as a `Date` at UTC midnight
(previously the driver's local-midnight `Date` passed through, so the instant depended
on the process timezone — code reading local getters near midnight in negative-UTC-offset
zones saw the neighboring day), encode formats `YYYY-MM-DD` from UTC getters, and
relation `.include()` over a date column now decodes instead of failing with
`RUNTIME.DECODE_FAILED`. Update tests or application code that pinned the old
local-midnight instants to expect `new Date(Date.UTC(y, m, d))`.
detection:
glob: "**/*.prisma"
regex:
- '@db\.Date'
- '\sDate(\s|\?|\[|$)'
anyMatch: true
- id: sql-escape-error-class-removed
summary: |
The `SqlEscapeError` class is deleted from `@internal/target-postgres` and
`@internal/target-sqlite` (including its re-export from the postgres/sqlite
adapter `control` entrypoints). Identifier/literal escaping failures now throw a
structured envelope with code `CONTRACT.IDENTIFIER_INVALID`. Replace
`error instanceof SqlEscapeError` with
`isStructuredError(error) && error.code === 'CONTRACT.IDENTIFIER_INVALID'`
(`isStructuredError` from `@internal/utils/structured-error`). Message text is
unchanged.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "SqlEscapeError"
- id: supabase-error-classes-removed
summary: |
The `SupabaseConfigError` and `InvalidJwtError` classes are deleted from
`@internal/extension-supabase/runtime`. The same failures now throw
structured envelopes with codes `SUPABASE.CONFIG_INVALID` and
`SUPABASE.JWT_INVALID`. Replace `error instanceof SupabaseConfigError` with
`isStructuredError(error) && error.code === 'SUPABASE.CONFIG_INVALID'` and
`error instanceof InvalidJwtError` with
`isStructuredError(error) && error.code === 'SUPABASE.JWT_INVALID'`
(`isStructuredError` from `@internal/utils/structured-error`). Message
text is unchanged.
detection:
glob: "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"
contains:
- "SupabaseConfigError"
- "InvalidJwtError"
anyMatch: true
- id: indexes-are-name-identified
summary: |
Secondary indexes are name-identified from 0.17. Every index entry in the emitted
`contract.json` / `contract.d.ts` now carries `name` (the full physical name) and
`unique`, plus `prefix` when the name is toolchain-owned; `columns` became optional
(an index carries either `columns` or an opaque `expression` — never both). Contracts
emitted by 0.16 fail validation on load (the error message contains "indexes[0].name
must be a string (was missing)"), and storage hashes move for every contract that
declares indexes — re-emit with `prisma-next contract emit`. Physical names change for wire-named indexes: an unnamed
PSL `@@index([a, b])` / TS `constraints.index([a, b])` and every FK-backing index now
CREATE as `<default-prefix>_<8hex>` content-hash wire names (e.g.
`user_email_idx_46df9cad`), and a TS `constraints.index([...], { name: "x" })` name is
now a wire prefix — the physical name becomes `x_<8hex>`. PSL
`@@index([...], map: "x")` is now an exact physical name whose identity is verified
against the live catalog. Existing databases converge without rebuilds: after
re-emitting, the first plan that allows the `widening` class (`db update`, or
`migration plan` + `migrate`) is `ALTER INDEX … RENAME TO` ops only — renames happen
only when a widening plan runs FIRST. Under an additive-only policy the rename pairing
does not run: the new wire-named index is created beside the old one, and once both
exist a later plan can no longer pair them — the old index is removed only by a
destructive-allowed plan dropping it. Update any code or tests that hard-code the old
physical index names.
detection:
glob: "**/*.{prisma,ts,json}"
contains:
- "@@index"
- "constraints.index"
- '"indexes":'
anyMatch: true
- id: rls-policy-migration-literal-carries-the-naming-union
summary: |
A generated migration's `this.createRlsPolicy({ policy: … })` literal spells the policy's
name differently from 0.17. The flat `name` / `prefix` pair is replaced by a single
`naming` field carrying one of two shapes: `{ kind: "exact", name: "<physical name>" }`
for a policy whose name the author owns, or
`{ kind: "wire", prefix: "<prefix>", hash: "<8hex>" }` for a toolchain-named one
(0.16's `name: "<prefix>_<8hex>"` plus `prefix: "<prefix>"`). Every other key is
unchanged. A migration file emitted by 0.16 that calls `createRlsPolicy` therefore stops
compiling — TypeScript reports `Property 'naming' is missing`. Regenerate the affected
migrations with `prisma-next migration plan`, or edit the literal by hand: the shape is
mechanical, and the migration's identity (`migrationHash`, the SQL it executes) does not
depend on the literal's spelling.
detection:
glob: "**/migrations/**/migration.ts"
contains:
- "createRlsPolicy"
- id: framework-error-classes-removed
summary: |
Three exported framework error classes are deleted: `ConfigFileNotFoundError`
(from `@internal/config-loader`), `ConfigValidationError` (from
`@internal/config/config-validation`), and `DomainNamespaceResolutionError`
(from `@internal/contract/types`). The same failures now throw structured
envelopes with codes `CONFIG.FILE_NOT_FOUND`, `CONFIG.VALIDATION_FAILED`, and
`CONTRACT.NAMESPACE_INVALID` respectively. Replace each
`error instanceof <Class>` with
`isStructuredError(error) && error.code === '<CODE>'`
(`isStructuredError` from `@internal/utils/structured-error`). Message
text is unchanged.
detection:
glob: "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"
contains:
- "ConfigFileNotFoundError"
- "ConfigValidationError"
- "DomainNamespaceResolutionError"
anyMatch: true
- id: pg-int8-application-values-are-bigint
summary: |
`pg/int8@1` carries `bigint` application values where it carried `number`. A JS `number`
cannot represent the whole signed 64-bit range, so any value past 2^53 was already being
silently rounded. Every read of an `int8` column now yields a `bigint`, and every value
compared against one must be a `bigint` literal. `count()` is the widest instance: it
resolves to `pg/int8@1`, so a counted column's row type is `bigint` and a `having`
comparison reads `fns.gt(fns.count(), 5n)`. Update row-type annotations, comparison
literals, and any arithmetic that mixes a counted value with a `number` — TypeScript will
not implicitly convert between the two, so `pnpm typecheck` locates every site.
A contract's `int8` literal defaults are also emitted as decimal strings rather than JSON
numbers; re-emit to pick that up.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- "fns.count("
- "pg/int8@1"
- "int8Column"
anyMatch: true
- id: pg-interval-values-are-structured-durations
summary: |
Reading a `pg/interval@1` column returns `{ months, days, micros }` — the three fields
PostgreSQL stores — where it returned a `JSON.stringify` of the driver's component object
such as `{"days":1}`. `months` and `days` are `number`; `micros` is `bigint`, because
PostgreSQL stores it as a 64-bit integer. Writing takes the same object. Replace any
parsing of the old string with field access, and replace interval literals with the object
(`{ months: 0, days: 1, micros: 0n }` for one day). The three fields stay independent
because a month has no fixed length: one month and thirty days are different values and
neither converts to the other. Serialized form is unchanged in kind but not in spelling —
a contract holds the ISO-8601 duration string (`P1M`, `P1Y2M3DT4H5M6S`, `PT0S`), so
re-emit; `micros` past microsecond resolution rounds as PostgreSQL rounds.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- "pg/interval@1"
- "intervalColumn"
anyMatch: true
- id: codec-json-forms-are-canonical
summary: |
Several codecs' JSON representation changed so a value survives the round trip through a
contract. `pg/numeric@1` and `sqlite/bigint@1` are decimal text where they were JSON
numbers — `9007199254740993` reached JSON as `…992` before, and arbitrary-precision
decimals lost their tail. `pg/bytea@1` is base64 where it was PostgreSQL's `\x`-prefixed
hex. `sqlite/blob@1` is uppercase hexadecimal where it was base64. `sqlite/bigint@1`
additionally accepts values it previously refused outright: half of SQLite's INTEGER range
had no JSON representation at all.
This now reaches **reads**, not only contract literals. A query that returns JSON — an
`.include()`'s nested rows, an aggregated child row set — projects each column through its
codec, so these codecs' values arrive in the forms above where they previously arrived in
whatever the database's own JSON conversion produced. Nine codecs project non-identically:
`pg/numeric@1`, `pg/int8@1`, `pg/bytea@1`, `pg/interval@1`, `pg/timestamptz@1`,
`pg/vector@1`, `sqlite/bigint@1`, `sqlite/blob@1` and `sqlite/json@1`. If you read such a
column out of an include and parse or compare its raw JSON yourself — rather than letting
the ORM decode it — update that code to the new form.
Run `prisma-next contract emit` to regenerate
`contract.json` / `contract.d.ts`; any literal default on one of these codecs changes
spelling, and with it the `storageHash`. Code that reads such a default out of a contract,
or that hand-writes one, must use the new form.
detection:
glob: "**/*.{ts,tsx,mts,cts,json,d.ts}"
contains:
- "pg/numeric@1"
- "pg/bytea@1"
- "pg/int8@1"
- "pg/interval@1"
- "pg/timestamptz@1"
- "pg/vector@1"
- "sqlite/bigint@1"
- "sqlite/blob@1"
- "sqlite/json@1"
anyMatch: true
- id: float-json-requires-extra-float-digits-at-least-one
summary: |
The canonical JSON of `pg/float4@1`, `pg/float8@1`, `pg/float@1` and `pg/vector@1` holds
only where the PostgreSQL session's `extra_float_digits` is 1 or above. That is the default
from PostgreSQL 12 onward, so most deployments already satisfy it — but a connection that
sets the GUC to 0 or below reverts to a fixed digit count and truncates: `1/3` reads back
as `0.333333333333333` rather than `0.3333333333333333`, and the value no longer
round-trips. Check any connection string, pool `options`, server config or proxy that sets
`extra_float_digits` and remove settings of 0 or below.
detection:
glob: "**/*.{ts,tsx,mts,cts,js,mjs,cjs,json,toml,yaml,yml,env}"
contains:
- "extra_float_digits"
anyMatch: true
- id: sqlite-real-rejects-non-finite-values
summary: |
`sqlite/real@1` rejects infinities and `NaN` on both the encode and decode sides. JSON has
no spelling for either, and SQLite renders an infinity as `9.0e+999`, which reads back as
`Infinity` rather than failing — so a non-finite value used to pass through and corrupt the
value silently. Guard any computation that can produce a non-finite float before writing it
to a `REAL` column, or store it in a column whose codec admits it.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- "sqlite/real@1"
- "realColumn"
anyMatch: true
- id: pg-timestamptz-json-is-utc-iso
summary: |
`pg/timestamptz@1`'s canonical JSON is a UTC ISO-8601 timestamp with an explicit `+00:00`
offset, constructed by the projection rather than inherited from the session. The form
previously followed the connection's `DateStyle` and `TimeZone`, so the same stored instant
read back differently on two connections, and under a non-ISO `DateStyle` could fail to
parse at all. Nothing to change if you decode through the ORM. If you read a timestamptz out
of database-produced JSON yourself it is now always `YYYY-MM-DDTHH:MM:SS.mmm+00:00`: drop any
session-dependent parsing, and drop any `SET DateStyle` / `SET TimeZone` you added to
stabilise it.
detection:
glob: "**/*.{ts,tsx,mts,cts,sql}"
contains:
- "pg/timestamptz@1"
- "timestamptzColumn"
- "DateStyle"
anyMatch: true
- id: sqlite-json-documents-survive-nesting
summary: |
A `sqlite/json@1` column read through a nested `.include()` arrives as a parsed document
where it previously arrived as a string containing JSON. SQLite carries "this text is JSON"
as a subtype on the value, and that subtype does not survive a derived table — which every
include's child row set passes through — so a document came back double-encoded. The
projection retags it at the boundary that consumes it. A `sqlite/text@1` column whose
characters happen to look like JSON is unaffected and still arrives as a string: the retag
follows the column's codec, not its content. Remove any `JSON.parse` you added to compensate
for the double encoding.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- "sqlite/json@1"
- "jsonColumn"
anyMatch: true
- id: sqlite-blob-null-is-distinct-from-empty
summary: |
A `NULL` `sqlite/blob@1` column read through database-produced JSON is `null`, where it
previously became an empty `Uint8Array`. SQLite's `hex(NULL)` is the empty string, which is
also the hex of a zero-length blob, so absence and emptiness were the same value and nothing
raised. If your code distinguishes "no blob" from "empty blob" — and especially if it worked
around the old behaviour by treating a zero-length blob as absent — that check now needs to
test for `null`.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- "sqlite/blob@1"
- "blobColumn"
anyMatch: true
- id: sql-float-rejects-non-finite-values
summary: |
`sql/float@1` rejects infinities and `NaN` in both JSON directions, matching
`sqlite/real@1`. Its `decodeJson` previously performed no check at all, and a database can
hold a non-finite float and spells it as a *string* in JSON — PostgreSQL emits `"NaN"` — so
the codec handed back a string typed as `number`, silently. Guard any computation that can
produce a non-finite float before writing it to a `sql/float@1` column, or use
`pg/numeric@1`, whose application value is text and which admits all three.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- "sql/float@1"
- "sqlFloatColumn"
anyMatch: true
- id: explicit-codec-refs-need-readable-type-params
summary: |
A codec ref supplied explicitly — `sql.value(v, { codec: { codecId: 'pg/enum@1' } })` and
the other surfaces that take a bare `codecId` — must carry `typeParams` the codec's schema
accepts when that codec is parameterized. For `pg/enum@1` that means
`typeParams: { typeName: '<enum type>' }`. Such a ref never passes contract validation, so
the omission used to surface as a static `text` native type — correct only because
PostgreSQL implicitly casts text to an enum, and wrong for any parameterized codec whose
type is not text-compatible. It now fails at lowering instead. The failure currently
surfaces as a params-validation error rather than a message naming the surface that produced
it; that diagnostic is tracked as
[TML-3114](https://linear.app/prisma-company/issue/TML-3114). Add the `typeParams` your
column declares, or drop the explicit codec and let the column's own codec resolve.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- "codec: { codecId"
- "pg/enum@1"
anyMatch: true
- id: sql-timestamp-json-is-utc-not-local
summary: |
**`sql/timestamp@1` now reads a zone-less timestamp as UTC where it read it in the running
process's local zone.** This is an interpretation change, not a formatting one, and it is the
dangerous half: `new Date('2026-01-02T03:04:05')` resolves in the local zone, so the same
stored value used to decode to a different instant on a machine in `Europe/Berlin` than on one
in `UTC` — shifted by the offset, silently. It now resolves as UTC on every machine.
If you compensated for the old shift anywhere downstream — adding the offset back, forcing
`TZ=UTC` on the process, normalising after decode — **remove that compensation**, or it now
double-corrects and the instant is wrong by twice the offset. Nothing raises: the value is
plausible, just wrong. If you ran with `TZ=UTC` there was no shift to compensate for and
nothing to change.
The JSON form changes with it: `encodeJson` emits `2026-01-02T03:04:05.678` where it emitted
`2026-01-02T03:04:05.678Z`. A `timestamp` carries no zone, so the trailing `Z` claimed one it
did not have; `decodeJson` now rejects an offset-bearing string outright rather than
reinterpreting it, since this codec cannot reproduce an offset it was handed. Update any
hand-written JSON, fixture or comparison that spells the old form.
`pg/timestamp@1` is unaffected — it already read as UTC and already emitted the zone-less
form.
detection:
glob: "**/*.{ts,tsx,mts,cts,json}"
contains:
- "sql/timestamp@1"
- "sqlTimestampColumn"
anyMatch: true
---
# 0.16 → 0.17 — User upgrade instructions
## `strip-sha256-hash-prefixes`
Starting at the 0.17 release, every content hash Prisma 8 mints or accepts is bare lowercase hex — the `sha256:` prefix is removed across the board: emitted `contract.json` / `contract.d.ts` (including the `StorageHashBase<'…'>` / `ProfileHashBase<'…'>` branded type literals), migration manifests, refs, CLI output, and the marker/ledger bookkeeping tables in your database. The prefix carried no information (the algorithm never varied per hash), and the hash **value** — not an in-band tag — signals a format change. Loaders and validators now reject the legacy prefixed form outright.
Two distinct effects on your checked-in artefacts:
- **Contract hashes keep their value.** `storageHash` / `profileHash` are computed over contract content, which never embedded its own hash — only the textual prefix drops.
- **Migration hash values change.** `migrationHash` is computed over the manifest bytes, which embed the `from` / `to` contract-hash strings; with those now bare, every recomputed `migrationHash` differs from the stored one.
### Migrate checked-in `migrations/` trees — before the layout migrator
Run the colocated codemod from your project root, **before** `scripts/migrate-migrations-layout.mjs` (the snapshot-layout entries below) — the 0.17 layout migrator accepts only bare-hex trees:
```bash
pnpm exec tsx ./strip-sha256-hash-prefixes.ts
```
For every on-disk migration package (a `migration.json` with a sibling `ops.json`) it strips the prefix from the manifest's `from` / `to`, from hash literals inside `ops.json`, in pre-store sibling contract snapshots (`*-contract.json`, `*.d.ts`, `migration.ts`), and in content-addressed store entries (`migrations/snapshots/<hex>/contract.json` + `contract.d.ts` — the directory name is the hash's hex and does not change), recomputes `migrationHash` over the bare-hex content, and rewrites `refs/*.json` — repointing refs that held old migration hashes at the recomputed ones, and mapping the empty-tree sentinel `sha256:empty` to `empty`. The edit is format-preserving (only hash literals and the recomputed hash value change) and idempotent: re-running over an already-bare tree makes no further changes.
Use `--check` for a dry run that lists files still needing the fix and exits non-zero if any remain:
```bash
pnpm exec tsx ./strip-sha256-hash-prefixes.ts --check
```
### Re-emit live contract artefacts
Regenerate your emitted artefacts so `contract.json` / `contract.d.ts` pick up the bare-hex form:
```bash
pnpm emit
# (runs `prisma-next contract emit` under the hood)
```
The regenerated files differ only in hash representation — the hash values themselves are unchanged.
### Update hash literals your own code carries
If your application or tests hard-code hash strings (asserting a `migrationHash`, comparing a `storageHash`, matching CLI output), drop the `sha256:` prefix — and for migration hashes, read the new value from the regenerated manifest, since the value itself changed.
### Database marker/ledger
There is no compatibility shim: a database whose marker/ledger rows still hold prefixed values reports a hash mismatch on `prisma-next db verify`. Re-sign the database against your regenerated contract:
```bash
prisma-next db sign
```
### Validation
After the codemod and re-emit, run `pnpm typecheck && pnpm test` (or your application's equivalent), and exercise any command that loads your migrations (deploy or migration-status step) — the loader recomputes and verifies each manifest's `migrationHash` on read, so a stale or still-prefixed manifest fails immediately. `git grep -n "sha256:"` over your project should return no hits in committed artefacts.
Also in this release, the ORM client's internal `throw new Error(...)` sites
were converted to a structured-error scheme (`ORM.*` codes via `structuredError`,
or `InternalError` for invariants). These are internal throw sites: the errors
are still `Error` instances with unchanged message text, so application code
that catches them by message or by `instanceof Error` is unaffected. No action
required beyond the migration contract-snapshot layout change above.
## `indexes-are-name-identified`
Secondary indexes are **name-identified**: the contract stores every index's full physical name, and schema verification and migration planning pair indexes by that name instead of by column tuple.
### What changed in the emitted contract
Each entry in a table's `indexes` array in `contract.json` / `contract.d.ts` now always carries:
- `name` — the full physical name of the index in the database.
- `unique` — always present (`false` for everything authored today).
- `prefix` — present when the name is toolchain-owned: the physical name is then `<prefix>_<8hex>`, where the suffix is a content hash of the index definition.
- `columns` — now optional; an index carries either `columns` or an opaque `expression` string, never both.
Newly available in 0.17 (additive — no migration needed): `contract infer` captures the full index matrix (expression, partial `where:`, unique non-constraint, `type:`/`options:`) and the RLS surface (`@@rls`, every policy as a `policy_<operation>` block with `@@map` and verbatim reprinted bodies, `permissive = false` for RESTRICTIVE rows), so `infer → emit → db verify` is zero-issue on databases carrying those objects. Re-running `contract infer` therefore rewrites `contract.prisma` with more entries than 0.16 emitted; an index whose live name is wire-shaped (`<prefix>_<8hex>`, created by this toolchain) and whose hash recomputes now re-infers as wire-named `name:` instead of exact `map:` — both spellings verify clean, the wire-named one keeps renames first-class. `permissive` is an authorable policy-block property (default `true`; wire names for `permissive = true` policies are byte-unchanged). Contracts may also now carry two content-identical exact-named (`map:`) indexes under different names — legal twins a signed database can have. RLS policy blocks (`policy_select` etc.) accept `@@map("physical name")` to adopt an existing live policy under its exact name — no wire-name hash, drift detection byte-compares the body against Postgres's reprint (hand-authoring the text warns with `PN_EXACT_NAME_BODY_COMPARISON`), and replacing `@@map` with the plain head later converges via a single `ALTER POLICY … RENAME`. Also newly available: both authoring surfaces accept the full index parameter matrix. PSL `@@index` and TS `constraints.index` take `expression:` (instead of a fields list; requires `name:` or `map:`), `where:` (partial-index predicate), `unique:`, `type:`/`options:` (target-registered access method), and `name:` xor `map:`. Combining `map:` with a SQL body emits the `PN_EXACT_NAME_BODY_COMPARISON` warning at build time — drift detection byte-compares hand-authored text against Postgres's reprint, so prefer `name:` unless the text was captured by `contract infer`. SQLite contracts reject `expression:`/`where:` with `CONTRACT.ARGUMENT_INVALID` (the target does not support them).
A contract emitted by 0.16 fails validation when a 0.17 toolchain loads it — a `Contract structural validation failed: storage.namespaces.<ns> …` error whose message contains `indexes[0].name must be a string (was missing)` and `indexes[0].unique must be boolean (was missing)` — and the storage hash moves for every contract that declares indexes. Re-emit:
```bash
prisma-next contract emit
```
### What changed about physical index names
| Authoring input | 0.16 physical name | 0.17 physical name |
| --- | --- | --- |
| PSL `@@index([a, b])` / TS `constraints.index([cols.a, cols.b])` (unnamed) | `<table>_<a>_<b>_idx` | `<table>_<a>_<b>_idx_<8hex>` (wire-named) |
| FK-backing index (derived from a relation) | `<table>_<col>_idx` | `<table>_<col>_idx_<8hex>` (wire-named) |
| TS `constraints.index([...], { name: "x" })` | `x` | `x_<8hex>` — the name is now a wire *prefix* |
| PSL `@@index([...], map: "x")` | `x` | `x` — an exact physical name, now verified against the live catalog |
The `<8hex>` suffix is a content hash over the index definition (element list, predicate, uniqueness, access method, options), so an unchanged definition always produces the same name.
### Converging an existing database
No index is rebuilt. After re-emitting the contract, the first plan that allows the `widening` operation class converges the live names with `ALTER INDEX … RENAME TO` ops only:
- `prisma-next db update` (its default policy includes widening), or
- `prisma-next migration plan --name converge-index-names` followed by `prisma-next migrate`.
Inspect the plan before applying — for a schema whose only drift is the index naming, it contains nothing but renames.
Under an **additive-only** policy (e.g. `db init`'s class set) the rename pairing is skipped: the plan creates the new wire-named index beside the old one. Once both indexes exist, a later widening plan has nothing left to pair — the new name is already present, and the rename op's own precheck requires its target name to be absent — so after the additive create the old index is removed **only** by a destructive-allowed plan dropping it. A rename happens only when a widening-allowed plan is the *first* convergence, before any create. This degradation is deliberate — an additive-only run never emits an op class it is not allowed to execute; if you want renames instead of create-then-drop, run the widening plan first.
### Hard-coded names
If application code, tests, or operational scripts hard-code physical index names (e.g. `user_email_idx`), read the new names from the regenerated `contract.json` — wire names now carry the hash suffix. PSL schemas that must keep a byte-exact legacy name can pin it with `@@index([...], map: "<exact name>")`.
## `rls-policy-migration-literal-carries-the-naming-union`
Generated migrations that create an RLS policy carry the policy as a literal. Where 0.16 spelled its name as two flat fields, 0.17 spells it as one `naming` field with two shapes:
```ts
// 0.16
this.createRlsPolicy({ schema: "public", table: "post", policy: {
name: "post_owner_a1b2c3d4",
prefix: "post_owner",
// …
} })
// 0.17
this.createRlsPolicy({ schema: "public", table: "post", policy: {
naming: { kind: "wire", prefix: "post_owner", hash: "a1b2c3d4" },
// …
} })
```
A policy whose name the author owns (adopted through `@@map`) carries `naming: { kind: "exact", name: "Tenant members can read" }` instead. Every other key of the literal is unchanged.
The two fields could disagree — a `prefix` that is not what `name` ends with was representable and had to be checked at runtime — while the union cannot be written wrong. A 0.16 migration file that calls `createRlsPolicy` stops compiling against 0.17 with `Property 'naming' is missing`. Either regenerate the migration (`prisma-next migration plan`) or rewrite the two fields as the one union field by hand; the migration's identity and the SQL it runs do not depend on the literal's spelling, so a hand edit needs no re-hashing.
## Incidental dependency and lint-config bumps
Routine dev-dependency bumps and biome `$schema` version alignment in `examples/` (dependabot `dev-deps` group, PR #1058) require no Prisma 8-specific upgrade action; review and test the affected examples as with any routine dependency update.
## Incidental dependency bumps in examples
Routine runtime dependency bumps in `examples/` (dependabot `runtime-deps` group, PR #1065) require no Prisma 8-specific upgrade action; review and test the affected examples as with any routine dependency update.
## Incidental runtime dependency bumps in examples (August 2026)
Routine runtime dependency bumps in `examples/` (dependabot `runtime-deps` group, PR #29872) require no Prisma 8-specific upgrade action; review and test the affected examples as with any routine dependency update.
## Incidental dev-dependency bumps in examples (August 2026)
Routine dev-dependency bumps in `examples/` (dependabot `dev-deps` group, PR #29877) require no Prisma 8-specific upgrade action; review and test the affected examples as with any routine dependency update.
## Incidental example dependency bumps (react-router 8)
The `react-router-demo` example moves its `react-router`, `@react-router/dev`, `@react-router/node`, and `@react-router/serve` dependencies from 7.x to 8.x. This is an example-local framework upgrade and requires no Prisma 8-specific upgrade action; the Prisma 8 surfaces the example uses are unchanged.
## `pg-int8-application-values-are-bigint`
An `int8` is a signed 64-bit integer; a JS `number` holds integers exactly only to 2^53. The codec previously handed you a `number`, so anything larger was already wrong by the time your code saw it. It now hands you a `bigint`.
TypeScript does not implicitly convert between `number` and `bigint`, so `pnpm typecheck` finds every affected site. Three shapes recur:
- **Row-type annotations.** A counted column is `bigint`: `SqlQueryPlan<{ name: string; postCount: bigint }>`.
- **Comparison literals.** `fns.gt(fns.count(), 5)` becomes `fns.gt(fns.count(), 5n)`.
- **Values read from a driver.** A raw `pg` query returns an `int8` as a decimal *string*; convert with `BigInt(row.id)` rather than annotating it `number`.
Arithmetic mixing the two throws at runtime rather than coercing, so a site that typechecks after a cast is worth reading again.
## `pg-interval-values-are-structured-durations`
An interval is not a duration. PostgreSQL stores three independent fields — months, days and microseconds — because a month has no fixed length, so `{ months: 1 }` and `{ days: 30 }` are different intervals and neither can be converted into the other. The application value is now those three fields, so reading an interval hands you numbers to compute with rather than a string to parse.
```ts
// before
const gap: string = row.gap; // "{\"days\":1}"
// after
const gap = row.gap; // { months: 0, days: 1, micros: 0n }
const totalDays = gap.days + gap.months * 30; // your calendar rule, not ours
```
The representation is separate from the value, as it is for `pg/bytea@1` (a `Uint8Array` carried as base64) and `pg/int8@1` (a `bigint` carried as decimal text). A contract holds the ISO-8601 duration string, so re-emit to pick up the spelling — `P1M`, `P1Y2M3DT4H5M6S`, `PT0S` for zero, each component carrying its own sign.
Two details worth knowing:
- **The ISO rendering normalises where the value does not.** Thirteen months render as `P1Y1M` and read back as `{ months: 13 }`. The value keeps what you gave it.
- **Fractional seconds round.** PostgreSQL rounds past microsecond resolution rather than truncating — `1.1234567` seconds is `1.123457` — and both paths into the value now agree with it.
## `codec-json-forms-are-canonical`
The rule these follow is that a value written through a codec and read back must be the same value. Where a codec's JSON form could not carry its own range, the form changed rather than the range being quietly clipped.
Re-emit first (`prisma-next contract emit`), then reconcile any code that reads or writes one of these forms directly. Literal defaults are where this most often surfaces: an `int8` default of `0` is now `"0"` in `contract.json`, and the `storageHash` moves with it.
The second place it surfaces is reads. A query that returns JSON projects each column through its codec, so a column whose codec is one of the nine listed above arrives in that codec's canonical form rather than in whatever the database's own JSON conversion produced. Decoding through the ORM needs no change — the codec's `decodeJson` is the other half of the same pair, and the two moved together. What needs checking is code that bypasses the ORM's decoding: a raw query that reads an aggregated JSON column and parses it itself, a comparison against a hand-written JSON string, a snapshot of database-produced JSON.
Where a form is a strict improvement in range, nothing downstream breaks by widening. Where a form changes spelling — `bytea` from `\x`-hex to base64, `sqlite/blob@1` from base64 to uppercase hex — a hand-written comparison is the thing that breaks, and it breaks loudly rather than silently.
## `sql-timestamp-json-is-utc-not-local`
The formatting change is easy to see and easy to fix. The interpretation change is neither, so take it first.
A `timestamp` column carries no time zone. Its JSON form is therefore a zone-less string, and something has to decide which instant that string denotes. `sql/timestamp@1` used to hand the string to `new Date(...)`, which resolves a zone-less form **in the zone the process happens to be running in**. The same stored value decoded to a different instant depending on where the code ran, and it decoded silently — a `Date` is a `Date`, whichever instant it holds.
It now resolves as UTC, unconditionally, and `encodeJson` writes UTC. The pair round-trips on any machine.
The migration hazard is compensation you may already have in place:
- If you added an offset back after decoding, **remove it.** It now double-corrects, and the result is wrong by twice your offset.
- If you set `TZ=UTC` on the process specifically to stabilise these values, you can drop that — though leaving it costs nothing, since UTC was already the case it produced.
- If you normalised timestamps after reading them, check whether the normalisation is still doing anything.
None of these fail loudly. A doubled offset produces a timestamp that parses, compares and serialises perfectly well and denotes the wrong moment, which is why this entry leads with the interpretation rather than the dropped `Z`.
Two smaller consequences follow:
- `encodeJson` emits `2026-01-02T03:04:05.678` rather than `2026-01-02T03:04:05.678Z`. Update fixtures, snapshots and hand-written comparisons.
- `decodeJson` rejects an offset-bearing string instead of reinterpreting it. The codec cannot reproduce an offset, so accepting one would decode a value it could never encode back.
`pg/timestamp@1` needs no attention: it already read as UTC and already emitted the zone-less form, and this change brings the generic codec into line with it.
## Incidental release version bump to 0.17.0
The `chore(release): bump to 0.17.0` commit rewrites every workspace manifest's `version` field and `workspace:` pins, which touches `examples/` manifests. No user action beyond the entries above.
upgrading/app/upgrades/0.16-to-0.17/strip-sha256-hash-prefixes.ts
/**
* Brings checked-in `migrations/` trees onto the 0.17 bare-hex hash
* representation: strips the legacy `sha256:` prefix from every hash literal,
* maps the empty-tree sentinel `sha256:empty` to `empty`, recomputes each
* migration's `migrationHash` over the bare-hex content, and repoints
* `refs/*.json` at the recomputed hashes.
*
* Background: starting at the 0.17 release, content hashes are bare
* lowercase hex — the `sha256:` prefix is gone from every surface (emitted
* `contract.json` / `contract.d.ts`, migration manifests, refs, CLI output,
* and the marker/ledger tables). The algorithm never varied per hash, so the
* prefix carried no information; a format change is signalled by the hash
* value changing, not by an in-band tag. Loaders now reject the legacy
* prefixed form outright.
*
* Two distinct effects on checked-in migration artifacts:
*
* - Contract hashes (`from` / `to` in `migration.json`, `storageHash` /
* `profileHash` in contract snapshots and `.d.ts` branded literals,
* `storageHash` stamps inside `ops.json`) keep their VALUE — only the
* `sha256:` prefix drops.
* - `migrationHash` VALUES change, because the hashed manifest bytes embed
* the (now bare) `from` / `to` strings. Every manifest gets a freshly
* recomputed hash, and every `refs/*.json` that pointed at an old
* migration hash is rewritten to the recomputed one.
*
* Before 0.17 a manifest was:
*
* {
* "from": "sha256:8ee1e7ce…",
* "to": "sha256:059f3f35…",
* "providedInvariants": [],
* "createdAt": "2026-…",
* "migrationHash": "sha256:3c5205d2…"
* }
*
* Starting at 0.17 the same manifest is:
*
* {
* "from": "8ee1e7ce…",
* "to": "059f3f35…",
* "providedInvariants": [],
* "createdAt": "2026-…",
* "migrationHash": "2be2085f…" // recomputed over the bare-hex bytes
* }
*
* Format-preserving edit: hash literals are rewritten in place via a targeted
* pattern (`"sha256:<64 hex>"` / `"sha256:empty"`, single- or double-quoted),
* and the `migrationHash` value is swapped in place. Every other byte (key
* order, indentation, inline-vs-expanded arrays) is left exactly as the
* authoring tool wrote it, so diffs stay minimal.
*
* Confinement: an on-disk migration package is a `migration.json` paired
* with a sibling `ops.json`. The walk keys off that pair; within a package
* directory every `.json` / `.ts` sibling (pre-store contract snapshots,
* `.d.ts` branded types, the executable `migration.ts`) has its hash
* literals stripped. Content-addressed store entries
* (`migrations/snapshots/<hex>/contract.json` + `contract.d.ts`) are also
* covered, so the codemod handles both the pre-store sibling layout and the
* store layout — run it BEFORE `scripts/migrate-migrations-layout.mjs` when
* converting the layout in the same upgrade (the 0.17 migrator accepts only
* bare-hex trees). Store directory names are the hash's hex, which is
* unchanged by the prefix drop, so no directory is renamed. Ref files are
* `refs/*.json` under a directory named `migrations`; each gets old-hash →
* recomputed-hash repointing plus prefix stripping (which also maps the
* `sha256:empty` sentinel to `empty`).
*
* The hash algorithm is replicated inline (canonicalisation rules from
* `@internal/framework-components` `canonicalizeJson` + the
* migration-tools `computeMigrationHash`, which returns bare hex from 0.17)
* so this script stays self-contained — consumers run it via `pnpm exec tsx`
* from their project root with no dependency on any `@internal/*` package
* being resolvable from that root.
*
* The codemod is idempotent: an already-bare tree carries no `sha256:`
* literals and its recomputed hashes match the stored ones, so the edit is a
* no-op and every file is left untouched.
*
* Flags:
* --check dry-run; lists files that still need fixing and exits 1 if
* any remain.
*/
import { createHash } from 'node:crypto';
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { basename, dirname, join, sep } from 'node:path';
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
const dryRun = process.argv.includes('--check');
const projectRoot = process.cwd();
// --- Inline canonicalisation + hash --------------------------------------
// Replicated from `@internal/framework-components` `canonicalizeJson`
// (sortKeys + JSON.stringify) and the migration-tools `computeMigrationHash`.
// Kept inline so the script has no `@internal/*` import — pnpm's strict
// node_modules layout won't resolve transitive framework deps from a
// consumer's project root.
function sortKeys(value: unknown): unknown {
if (value === null || typeof value !== 'object') {
return value;
}
if (Array.isArray(value)) {
return value.map(sortKeys);
}
const sorted: Record<string, unknown> = Object.create(null);
for (const [key, entry] of Object.entries(value).sort(([a], [b]) =>
a < b ? -1 : a > b ? 1 : 0,
)) {
sorted[key] = sortKeys(entry);
}
return sorted;
}
function canonicalizeJson(value: unknown): string {
return JSON.stringify(sortKeys(value));
}
function sha256Hex(input: string): string {
return createHash('sha256').update(input).digest('hex');
}
/**
* Content-addressed migration hash over (metadata envelope, ops), 0.17 form:
* bare hex, no prefix. The `migrationHash` field is stripped before hashing
* so the same function works at write time (no hash yet) and at recompute
* time (rehashing an already-attested record over the bare-hex envelope).
*/
function computeMigrationHash(metadata: Record<string, unknown>, ops: unknown): string {
const { migrationHash: _migrationHash, ...strippedMeta } = metadata;
const partHashes = [canonicalizeJson(strippedMeta), canonicalizeJson(ops)].map(sha256Hex);
return sha256Hex(canonicalizeJson(partHashes));
}
// --- Prefix stripping ------------------------------------------------------
// A legacy hash literal: `sha256:` + 64 lowercase hex chars, or the
// empty-tree sentinel `sha256:empty`, in single or double quotes. Quoting is
// required so prose that merely mentions the prefix is never rewritten.
const LEGACY_HASH_LITERAL = /(["'])sha256:([0-9a-f]{64}|empty)\1/g;
function stripHashPrefixes(text: string): string {
return text.replace(LEGACY_HASH_LITERAL, (_full, quote: string, hash: string) => {
return `${quote}${hash}${quote}`;
});
}
function replaceMigrationHash(text: string, oldHash: string, newHash: string): string {
if (oldHash === newHash) return text;
const re = new RegExp(`("migrationHash"[ \\t]*:[ \\t]*)"${oldHash}"`);
if (re.exec(text) === null) {
throw new Error('could not locate the migrationHash value to replace');
}
return text.replace(re, (_full, prefix: string) => `${prefix}"${newHash}"`);
}
// --- Filesystem walk ------------------------------------------------------
interface WalkResult {
readonly manifests: string[];
readonly refFiles: string[];
readonly snapshotFiles: string[];
}
async function findMigrationArtifacts(root: string): Promise<WalkResult> {
const manifests: string[] = [];
const refFiles: string[] = [];
const snapshotFiles: string[] = [];
async function walk(dir: string): Promise<void> {
let entries: Awaited<ReturnType<typeof readdir>>;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
// Unreadable directory — skip silently. The consumer's project root may
// legitimately contain restricted directories.
return;
}
for (const entry of entries) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
await walk(path);
} else if (entry.isFile() && entry.name === 'migration.json') {
manifests.push(path);
} else if (
entry.isFile() &&
entry.name.endsWith('.json') &&
basename(dir) === 'refs' &&
dirname(dir).split(sep).includes('migrations')
) {
refFiles.push(path);
} else if (
entry.isFile() &&
(entry.name.endsWith('.json') || entry.name.endsWith('.ts')) &&
basename(dirname(dir)) === 'snapshots' &&
dirname(dirname(dir)).split(sep).includes('migrations')
) {
snapshotFiles.push(path);
}
}
}
await walk(root);
return {
manifests: manifests.sort(),
refFiles: refFiles.sort(),
snapshotFiles: snapshotFiles.sort(),
};
}
// --- Per-file transforms ---------------------------------------------------
/** Narrows an arbitrary JSON-parsed value to a plain object (manifest shape). */
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
type Status = 'already-clean' | 'needs-fix' | 'fixed' | 'skipped-no-ops';
interface Result {
readonly path: string;
readonly status: Status;
}
const results: Result[] = [];
/** Old migration hash (as previously stored, prefixed) → recomputed bare hash. */
const migrationHashMap = new Map<string, string>();
async function emit(path: string, before: string, after: string): Promise<Result> {
if (after === before) {
return { path, status: 'already-clean' };
}
if (!dryRun) await writeFile(path, after, 'utf-8');
return { path, status: dryRun ? 'needs-fix' : 'fixed' };
}
async function processSibling(path: string): Promise<Result> {
const raw = await readFile(path, 'utf-8');
return emit(path, raw, stripHashPrefixes(raw));
}
async function processPackage(manifestPath: string): Promise<Result[]> {
const raw = await readFile(manifestPath, 'utf-8');
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(
`${manifestPath}: not valid JSON (${error instanceof Error ? error.message : String(error)})`,
);
}
if (!isJsonObject(parsed)) {
return [{ path: manifestPath, status: 'already-clean' }]; // not a manifest object
}
const packageDir = dirname(manifestPath);
// A complete on-disk migration package pairs `migration.json` with a sibling
// `ops.json` (the operations the hash is computed over); without it we cannot
// recompute the hash, so this is not a package we should touch.
const opsPath = join(packageDir, 'ops.json');
let opsRaw: string;
try {
opsRaw = await readFile(opsPath, 'utf-8');
} catch {
return [{ path: manifestPath, status: 'skipped-no-ops' }];
}
const out: Result[] = [];
// Ops: strip prefixes (e.g. `meta.storageHash` stamps inside operation
// payloads), then parse the stripped text — the recomputed hash covers the
// bare-hex operations exactly as they will sit on disk.
const strippedOpsRaw = stripHashPrefixes(opsRaw);
const ops: unknown = JSON.parse(strippedOpsRaw);
out.push(await emit(opsPath, opsRaw, strippedOpsRaw));
// Manifest: strip prefixes from `from` / `to` (and the sentinel), then
// recompute `migrationHash` over the bare-hex envelope + bare-hex ops.
// Canonicalisation is order/whitespace independent, so parsing the stripped
// text is the right input regardless of on-disk formatting.
const strippedManifestRaw = stripHashPrefixes(raw);
const strippedMeta = JSON.parse(strippedManifestRaw);
if (!isJsonObject(strippedMeta)) {
throw new Error(`${manifestPath}: manifest is not a JSON object`);
}
const newHash = computeMigrationHash(strippedMeta, ops);
const oldStoredHash = parsed['migrationHash'];
if (typeof oldStoredHash !== 'string') {
throw new Error(`${manifestPath}: manifest is missing a string \`migrationHash\` field`);
}
const strippedOldHash = stripHashPrefixes(`"${oldStoredHash}"`).slice(1, -1);
migrationHashMap.set(oldStoredHash, newHash);
migrationHashMap.set(strippedOldHash, newHash);
out.push(
await emit(
manifestPath,
raw,
replaceMigrationHash(strippedManifestRaw, strippedOldHash, newHash),
),
);
// Siblings: contract snapshots (`*-contract.json`), branded-literal type
// files (`*.d.ts`), and the executable `migration.ts` all carry contract
// hash literals whose value is stable — only the prefix drops.
let entries: Awaited<ReturnType<typeof readdir>>;
try {
entries = await readdir(packageDir, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
if (!entry.isFile()) continue;
if (entry.name === 'migration.json' || entry.name === 'ops.json') continue;
if (!entry.name.endsWith('.json') && !entry.name.endsWith('.ts')) continue;
out.push(await processSibling(join(packageDir, entry.name)));
}
return out;
}
async function processRefFile(path: string): Promise<Result> {
const raw = await readFile(path, 'utf-8');
// Repoint at recomputed migration hashes first (a ref stores the migration
// hash of the package it names), then strip any remaining prefixes — which
// covers contract-hash refs and maps `sha256:empty` → `empty`.
let text = raw;
for (const [oldHash, newHash] of migrationHashMap) {
if (oldHash === newHash) continue;
text = text.replaceAll(`"${oldHash}"`, `"${newHash}"`);
}
text = stripHashPrefixes(text);
return emit(path, raw, text);
}
// --- Driver ---------------------------------------------------------------
const { manifests, refFiles, snapshotFiles } = await findMigrationArtifacts(projectRoot);
if (manifests.length === 0 && refFiles.length === 0 && snapshotFiles.length === 0) {
console.error(`No migration artifacts found under ${projectRoot}.`);
process.exit(1);
}
for (const manifestPath of manifests) {
results.push(...(await processPackage(manifestPath)));
}
for (const snapshotPath of snapshotFiles) {
results.push(await processSibling(snapshotPath));
}
for (const refPath of refFiles) {
results.push(await processRefFile(refPath));
}
let changed = 0;
let alreadyClean = 0;
let skipped = 0;
for (const result of results) {
const rel = result.path.slice(projectRoot.length + 1);
if (result.status === 'already-clean') {
alreadyClean += 1;
} else if (result.status === 'skipped-no-ops') {
skipped += 1;
console.log(`SKIP ${rel} (no sibling ops.json — not a migration package)`);
} else {
changed += 1;
const verb = dryRun ? 'WOULD FIX' : 'FIXED';
console.log(`${verb} ${rel}`);
}
}
console.log();
console.log(
`${results.length} file(s) scanned: ${changed} ${dryRun ? 'needing fix' : 'fixed'}, ${alreadyClean} already clean${skipped > 0 ? `, ${skipped} skipped (no ops.json)` : ''}.`,
);
if (dryRun && changed > 0) process.exit(1);
upgrading/app/upgrades/0.17-to-8.0.0-rc.1/instructions.md
---
from: "0.17"
to: "8.0.0-rc.1"
changes:
- id: aggregate-results-carry-their-target-s-codec
summary: |
Aggregates read back through the codec their target declares for the result, so their
application types change. `count()` is a `bigint` on both targets — a count is a cardinality
and is not capped at 2^53. On PostgreSQL, `sum` over `int2`/`int4` widens to `int8` and reads
as a `bigint`, while `sum(int8)` and `avg` over any integer are `numeric` and read as decimal
**strings**; `min`/`max` keep the column's own type, except over `varchar`, where PostgreSQL
returns `text`. On SQLite, `sum` over an integer column is a `bigint` and `avg` is always a
`number` (real). The two targets genuinely diverge on `avg` — a portable query must handle
both, and there is no shim that flattens them. Include reducers
(`.include('posts', (p) => p.count())`) decode the same way, so an included count is a
`bigint` too, and an empty relation reads `0n` rather than `0`. Comparisons are where this
bites quietly: `count === 2` is false where `count` is `2n`, and `JSON.stringify` throws on a
bigint. Sweep for `toBe(<number>)` / `=== <number>` / arithmetic against an aggregate result
and switch to `2n` or the decimal string, and render bigints explicitly (`String(value)`)
wherever you serialise. `having(...)` operands are the exception and stay numbers — they are
compared inside SQL and never cross a codec. Regenerate your contracts
(`prisma-next contract emit`): the emitted `contract.d.ts` gains an `AggregateTypes` block
that types every aggregate per operation and input codec, and the ORM and SQL builder both
resolve their result types from it — against a contract emitted before 8.0.0-rc.1 an aggregate
resolves to `never` in the ORM and to `unknown` in the SQL builder.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- "aggregate("
- ".count()"
- "groupBy("
anyMatch: true
---
# 0.17 → 8.0.0-rc.1 — User upgrade instructions
## `aggregate-results-carry-their-target-s-codec`
An aggregate's result is a value the database computes, and 8.0.0-rc.1 reads it back through the codec its target declares for that result rather than through whatever the driver happened to hand over. What each aggregate returns is now the target's answer, stated in the contract and honoured by the runtime:
| Target | Aggregate | Reads as |
| --- | --- | --- |
| PostgreSQL | `count()` (with or without an argument) | `bigint` |
| PostgreSQL | `sum` over `int2` / `int4` | `bigint` (the sum widens to `int8`) |
| PostgreSQL | `sum` over `int8`, `avg` over any integer | decimal `string` (the result is `numeric`) |
| PostgreSQL | `sum` / `avg` over `float4` / `float8` | `number` |
| PostgreSQL | `min` / `max` | the column's own type — except over `varchar`, which returns `text` |
| SQLite | `count()` | `bigint` |
| SQLite | `sum` over an integer column | `bigint` |
| SQLite | `avg` over anything | `number` (SQLite's `avg` is always real) |
| SQLite | `min` / `max` | the column's own type |
The targets diverge on `avg`, and deliberately: PostgreSQL computes it as `numeric`, SQLite as a float. A query written against both handles both.
Include reducers follow the same rules — `.include('posts', (posts) => posts.count())` yields a `bigint`, and a parent with no related rows reads `0n` where it read `0`.
Two failure modes are worth sweeping for, because neither announces itself:
- **Equality against a number.** `row.count === 2` is `false` when `row.count` is `2n`, and `expect(count).toBe(2)` fails the same way. Change the literal (`2n`), or compare through `Number(...)` where the magnitude is known to be small.
- **Serialisation.** `JSON.stringify` throws `TypeError: Do not know how to serialize a BigInt`. Render explicitly — `String(count)`, or a replacer that maps bigints to strings.
Arithmetic mixing a bigint with a number also throws (`2n + 1` is a `TypeError`); convert one side deliberately.
`having(...)` is the one place that does not move. A HAVING operand is compared inside SQL against the aggregate the database is computing, so it never crosses a codec: `having.count().gte(2)` keeps the plain number it always took. Only the aggregate's *result* — the value that reaches your code — carries its target's type.
Finally, regenerate your contracts:
```bash
prisma-next contract emit
```
The emitted `contract.d.ts` gains an `AggregateTypes` block — the settled result identity per operation and per input codec — and both the ORM client and the SQL builder resolve their aggregate result types from it. Against a contract emitted before 8.0.0-rc.1 the block is absent, so an aggregate resolves to `never` in the ORM and to `unknown` in the SQL builder: a type error at the call site in the first case, an untyped value in the second, rather than a wrong runtime value in either.
upgrading/app/upgrades/0.7-to-0.8/instructions.md
---
from: "0.7"
to: "0.8"
changes: []
---
No user-side migration actions are required for this transition at this time.
`changes: []` intentionally marks this transition as a no-op.
upgrading/app/upgrades/0.8-to-0.9/instructions.md
---
from: "0.8"
to: "0.9"
changes:
- id: strip-inline-contracts-from-migration-manifests
summary: Remove the inlined `fromContract` / `toContract` fields from every committed `migration.json`; the destination contract continues to live next door as `end-contract.json`.
detection:
glob: "**/migrations/**/migration.json"
contains:
- '"fromContract"'
- '"toContract"'
anyMatch: true
script: ./strip-inline-contracts.ts
---
# 0.8 → 0.9 — User upgrade instructions
## `strip-inline-contracts-from-migration-manifests`
Starting at the 0.9 release, `migration.json` no longer carries `fromContract` or `toContract`. The schema rejects those keys as unknown, so any committed manifest that still inlines them fails to load with a `MIGRATION.INVALID_MANIFEST` error from the loader (which is what powers `prisma-next migration plan` / `apply` / `verify`).
The destination contract was already being written to disk next door as `end-contract.json` (and the source as `start-contract.json`); the in-manifest copy was redundant. `migrationHash` is unaffected — it has always been computed without those two fields, so stripping them does not change the stored hash and existing `from` / `to` storage-hash bookends remain valid.
### What `strip-inline-contracts.ts` does
The colocated script walks the project root, descends into every directory named `migrations/` (skipping `node_modules`, `.git`, `dist`, `build`), and rewrites every `migration.json` whose JSON object contains either key:
- Manifests that already lack both keys are left untouched (idempotent — safe to re-run).
- Manifests with either key are rewritten with the two key/value spans excised at the text level. The formatting of every surviving field (whitespace, inline-vs-multiline arrays, key ordering, trailing newline) is preserved byte-for-byte; only the removed keys (and their trailing comma+newline) disappear from the diff. The script reparses the result to guard against accidental corruption.
- A `--check` flag turns the script into a dry-run; it lists which manifests would be modified and exits non-zero if any still need fixing.
### Validation
After running the script, run `pnpm typecheck && pnpm test` per the per-step flow. The migration loader's schema check is the structural validation; the project's own test suite covers any consumer-side code that previously read `metadata.fromContract` / `metadata.toContract` (rare — the fields were unused in the apply / verify path).
If you have application code that inspected `metadata.toContract` for any reason, read the contract from the sibling `end-contract.json` file instead (and `metadata.fromContract` becomes the predecessor migration's `end-contract.json`).
upgrading/app/upgrades/0.8-to-0.9/strip-inline-contracts.ts
/**
* Removes the inlined `fromContract` / `toContract` fields from every
* committed `migration.json` manifest reachable from the project root.
*
* Background: starting at the 0.9 release, `migration.json` no longer
* carries `fromContract` / `toContract` (the schema rejects them as
* unknown keys). The destination contract continues to live next door
* as `end-contract.json` (and the source as `start-contract.json`); the
* manifest copy was redundant. `migrationHash` is unaffected — it was
* already computed without those two fields, so stripping them does
* not change the stored hash.
*
* Behaviour:
* - Walks the project root recursively, ignoring `node_modules`, `.git`,
* `dist`, and `build`. Picks up every file named `migration.json`
* whose JSON object has the migration-manifest shape (`from`, `to`,
* and `migrationHash` keys). Other `migration.json` files (e.g.
* unrelated artefacts that happen to share the name) are skipped.
* - Manifests that already lack both removed keys are left untouched.
* - Manifests with either removed key are rewritten with the two key /
* value spans excised at the text level, so the formatting of all
* surviving fields (whitespace, inline-vs-multiline arrays, key
* ordering, trailing newline) is preserved byte-for-byte. Only the
* key being removed and its trailing comma+newline disappear from
* the diff.
* - Idempotent: re-running the script after success is a no-op.
*
* Flags:
* --check dry-run; exit 1 if any manifest still needs fixing.
*/
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
const REMOVED_KEYS = ['fromContract', 'toContract'] as const;
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
const dryRun = process.argv.includes('--check');
const projectRoot = process.cwd();
interface Result {
readonly path: string;
readonly status: 'already-clean' | 'needs-fix' | 'fixed';
readonly removed: readonly string[];
}
async function findManifests(root: string): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
await walk(join(dir, entry.name));
} else if (entry.isFile() && entry.name === 'migration.json') {
const path = join(dir, entry.name);
try {
const parsed: unknown = JSON.parse(await readFile(path, 'utf-8'));
if (looksLikeMigrationManifest(parsed)) out.push(path);
} catch {
// Not valid JSON, or not the manifest shape — skip silently.
}
}
}
}
await walk(root);
return out.sort();
}
function looksLikeMigrationManifest(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null) return false;
const obj = value as Record<string, unknown>;
return 'from' in obj && 'to' in obj && 'migrationHash' in obj;
}
/**
* Find the end index (exclusive) of a JSON value starting at `start`
* inside `text`. Handles strings, numbers, booleans, null, arrays, and
* objects. Brace / bracket nesting is tracked while respecting string
* literals (with escapes). Throws if `text` is not well-formed JSON
* starting at `start`.
*/
function jsonValueEnd(text: string, start: number): number {
const head = text[start];
let depth = 0;
let inString = false;
let inEscape = false;
if (head === '"') {
let i = start + 1;
while (i < text.length) {
const ch = text[i];
if (inEscape) inEscape = false;
else if (ch === '\\') inEscape = true;
else if (ch === '"') return i + 1;
i += 1;
}
throw new Error(`Unterminated string starting at ${start}`);
}
if (head === '{' || head === '[') {
let i = start;
while (i < text.length) {
const ch = text[i];
if (inString) {
if (inEscape) inEscape = false;
else if (ch === '\\') inEscape = true;
else if (ch === '"') inString = false;
} else {
if (ch === '"') inString = true;
else if (ch === '{' || ch === '[') depth += 1;
else if (ch === '}' || ch === ']') {
depth -= 1;
if (depth === 0) return i + 1;
}
}
i += 1;
}
throw new Error(`Unterminated container starting at ${start}`);
}
let i = start;
while (i < text.length && !',}\n\r\t '.includes(text[i] ?? '')) i += 1;
return i;
}
/**
* Remove a top-level key (and its `: value` and trailing comma) from a
* pretty-printed JSON object text. Returns the new text. If the key
* isn't present, returns the input unchanged.
*
* Preserves all surrounding whitespace and the formatting of every
* other field byte-for-byte. Handles both "key in the middle" (eats the
* trailing comma + newline) and "key at the end" (eats the leading
* comma + newline).
*/
function removeTopLevelKey(text: string, key: string): string {
const needle = `"${key}"`;
const keyIndex = text.indexOf(needle);
if (keyIndex < 0) return text;
let cursor = keyIndex + needle.length;
while (cursor < text.length && /\s/.test(text[cursor] ?? '')) cursor += 1;
if (text[cursor] !== ':') {
throw new Error(`Expected ':' after ${needle} at ${cursor}`);
}
cursor += 1;
while (cursor < text.length && /\s/.test(text[cursor] ?? '')) cursor += 1;
const valueEnd = jsonValueEnd(text, cursor);
let removeStart = keyIndex;
let removeEnd = valueEnd;
if (text[removeEnd] === ',') {
removeEnd += 1;
if (text[removeEnd] === '\n') removeEnd += 1;
let lineStart = removeStart;
while (lineStart > 0 && text[lineStart - 1] !== '\n') lineStart -= 1;
if (text.slice(lineStart, removeStart).trim() === '') removeStart = lineStart;
} else {
let back = removeStart - 1;
while (back > 0 && /[ \t]/.test(text[back] ?? '')) back -= 1;
if (text[back] === '\n') {
let prev = back - 1;
while (prev > 0 && /[ \t]/.test(text[prev] ?? '')) prev -= 1;
if (text[prev] === ',') {
removeStart = prev;
if (text[removeEnd] === '\n') removeEnd += 1;
}
}
}
return text.slice(0, removeStart) + text.slice(removeEnd);
}
async function processManifest(path: string): Promise<Result> {
const raw = await readFile(path, 'utf-8');
const data: Record<string, unknown> = JSON.parse(raw);
const removed = REMOVED_KEYS.filter((key) => key in data);
if (removed.length === 0) return { path, status: 'already-clean', removed: [] };
let stripped = raw;
for (const key of removed) stripped = removeTopLevelKey(stripped, key);
// Sanity: stripped output must still be valid JSON and must agree on
// every field except the two we removed.
const reparsed: Record<string, unknown> = JSON.parse(stripped);
for (const key of removed) {
if (key in reparsed) {
throw new Error(`Internal: ${key} survived strip in ${path}`);
}
}
if (!dryRun) await writeFile(path, stripped, 'utf-8');
return { path, status: dryRun ? 'needs-fix' : 'fixed', removed };
}
const manifests = await findManifests(projectRoot);
if (manifests.length === 0) {
console.error(`No migration.json files found under ${projectRoot}.`);
process.exit(1);
}
let changed = 0;
let alreadyClean = 0;
for (const path of manifests) {
const result = await processManifest(path);
const rel = path.slice(projectRoot.length + 1);
if (result.status === 'already-clean') {
alreadyClean += 1;
console.log(`OK ${rel} (already clean)`);
} else {
changed += 1;
const verb = dryRun ? 'WOULD FIX' : 'FIXED';
console.log(`${verb} ${rel} (removed: ${result.removed.join(', ')})`);
}
}
console.log();
console.log(
`${manifests.length} manifest(s) scanned: ${changed} ${dryRun ? 'needing fix' : 'fixed'}, ${alreadyClean} already clean.`,
);
if (dryRun && changed > 0) process.exit(1);
upgrading/app/upgrades/0.9-to-0.10/instructions.md
---
from: "0.9"
to: "0.10"
changes:
- id: stamp-storage-types-kind-on-contract-snapshots
summary: Stamp the `kind` discriminator (`"codec-instance"` / `"postgres-enum"`) on every entry in `storage.types` inside every committed `start-contract.json` / `end-contract.json` snapshot. The SQL family's contract serializer is now strict — untagged entries fail to load with a deserializer diagnostic naming the offending entry.
detection:
glob: "**/migrations/**/{start,end}-contract.json"
contains:
- '"codecId"'
anyMatch: true
script: ./stamp-storage-types-kind.ts
---
# 0.9 → 0.10 — User upgrade instructions
## `stamp-storage-types-kind-on-contract-snapshots`
Starting at the 0.10 release, the SQL family's contract serializer (`familyInstance.validateContract`, the seam every on-disk contract read now crosses) is **strict** about the `storage.types` polymorphic slot. Every entry must carry a `kind` discriminator (`"codec-instance"` for codec triples, `"postgres-enum"` for Postgres enums). The previous silent fallthrough in `normaliseTypeEntry` that quietly accepted untagged codec triples is gone.
Before 0.10, on-disk contract snapshots committed alongside your migrations looked like this:
```jsonc
{
"storage": {
"types": {
"Embedding1536": {
"codecId": "pg/vector@1",
"nativeType": "vector",
"typeParams": { "length": 1536 }
},
"user_type": {
"codecId": "pg/enum@1",
"nativeType": "user_type",
"typeParams": { "values": ["admin", "user"] }
}
}
}
}
```
Starting at 0.10 the same snapshots must look like this:
```jsonc
{
"storage": {
"types": {
"Embedding1536": {
"kind": "codec-instance",
"codecId": "pg/vector@1",
"nativeType": "vector",
"typeParams": { "length": 1536 }
},
"user_type": {
"kind": "postgres-enum",
"name": "user_type",
"nativeType": "user_type",
"values": ["admin", "user"],
"codecId": "pg/enum@1"
}
}
}
}
```
The two-shape split reflects how the SQL family's hydration path discriminates: codec triples are plain JSON envelopes that round-trip through the slot unchanged; Postgres enums hydrate into a target-specific IR class instance (`PostgresEnumType`) whose structural shape includes `name` / `values` directly (no `typeParams` indirection).
Without this stamp, every CLI command that reads an on-disk contract — `prisma-next migration plan`, `migration new`, `migrate`, `migration show`, `db verify` — fails with a `Contract validation failed` envelope pointing at the offending snapshot and naming the missing `kind` discriminator. The failure is loud and the diagnostic identifies the file, but the only fix is to stamp the entries; there is no migration-time legacy shape acceptance any more.
### What `stamp-storage-types-kind.ts` does
The colocated script walks the project root, descends into every directory named `migrations/` (skipping `node_modules`, `.git`, `dist`, `build`), and rewrites every `start-contract.json` and `end-contract.json` whose `storage.types` slot has at least one untagged entry:
- **Already-stamped entries** (those carrying a `"codec-instance"` or `"postgres-enum"` `kind`) are left untouched. The codemod is idempotent — safe to re-run.
- **`pg/enum@1`** entries are rewritten as the `"postgres-enum"` shape: the entry's map key is lifted to `name`; `values` is lifted out of `typeParams.values`; `nativeType` and `codecId` are preserved; `typeParams` is dropped (its only meaningful content was `values`).
- **Every other codecId** (including extension-contributed codecs like `pg/vector@1`, `cipherstash/*`, etc.) is rewritten as the `"codec-instance"` shape: `kind` is prepended; `codecId`, `nativeType`, and `typeParams` are preserved verbatim.
- The whole snapshot is re-serialised via a JSON pretty-printer that mirrors the CLI's authoring shape: multi-line objects and arrays at `JSON.stringify(value, null, 2)` indentation, with short primitive arrays (`["admin", "user"]`, `["id"]`, etc.) inlined on a single line — the same on-disk shape the CLI emits. On CLI-authored snapshots, the diff outside `storage.types` is zero. Hand-edited snapshots may experience cosmetic whitespace shifts; this is acceptable because on-disk contract snapshots are CLI artefacts, not user-edited source.
- A `--check` flag turns the script into a dry-run; it lists which snapshots would be modified and exits non-zero if any still need fixing.
If a snapshot contains a `storage.types` entry that is neither already-stamped nor an untagged codec triple (i.e. some structurally unexpected shape), the script throws and names the offending entry. That case is a hand-edit required — the codemod refuses to guess.
### Validation
After running the script, run `pnpm prisma-next migration plan` against your project. It should be a no-op (no schema changes detected) — that's the loudest end-to-end check that every snapshot now loads cleanly under the strict serializer. If it crashes with `Contract validation failed`, the message names the snapshot path and the entry; either re-run the codemod (some snapshots may have been missed by the glob), or hand-edit the entry to the expected shape above.
Then run `pnpm typecheck && pnpm test` per the per-step flow. The new strict serializer changes only the on-disk loading path; in-memory contracts authored via the builder DSL are unaffected.
upgrading/app/upgrades/0.9-to-0.10/stamp-storage-types-kind.ts
/**
* Stamps the `kind` discriminator on every entry in `storage.types`
* inside every on-disk contract snapshot reachable from the project
* root (`start-contract.json` / `end-contract.json` under any
* `migrations/` directory).
*
* Background: starting at the 0.10 release, the SQL family's
* `SqlStorage.types` polymorphic slot is strictly typed via an
* enumerable `kind` discriminator (`'codec-instance'` for codec
* triples; `'postgres-enum'` for Postgres enums). The family
* `ContractSerializer` rejects untagged entries with a diagnostic
* naming the offending entry — the previous silent fallthrough in
* `normaliseTypeEntry` is gone. See TML-2536.
*
* Before 0.10, the on-disk shape was:
*
* "storage": {
* "types": {
* "Embedding1536": {
* "codecId": "pg/vector@1",
* "nativeType": "vector",
* "typeParams": { "length": 1536 }
* },
* "user_type": {
* "codecId": "pg/enum@1",
* "nativeType": "user_type",
* "typeParams": { "values": ["admin", "user"] }
* }
* }
* }
*
* Starting at 0.10 the same entries are:
*
* "storage": {
* "types": {
* "Embedding1536": {
* "kind": "codec-instance",
* "codecId": "pg/vector@1",
* "nativeType": "vector",
* "typeParams": { "length": 1536 }
* },
* "user_type": {
* "kind": "postgres-enum",
* "name": "user_type",
* "nativeType": "user_type",
* "values": ["admin", "user"],
* "codecId": "pg/enum@1"
* }
* }
* }
*
* Dispatch rules (per entry):
*
* - Already-stamped entry (carries a `kind` field) → left untouched.
* - `codecId === "pg/enum@1"` → rewritten as the `postgres-enum`
* shape: `kind`, `name` (lifted from the entry key), `nativeType`,
* `values` (lifted out of `typeParams.values`), `codecId`.
* `typeParams` is dropped (its only meaningful content was `values`).
* - Any other `codecId` → rewritten as the `codec-instance` shape:
* `kind` prepended; `codecId`, `nativeType`, `typeParams` preserved.
* This is the safe default for unknown codec IDs (including any
* future extension-contributed codecs).
*
* The transformation re-serialises each affected file via
* `JSON.stringify(value, null, 2) + '\n'` — the same formatting the
* CLI uses when authoring snapshots originally, so the diff outside
* `storage.types` is zero on files the CLI generated. Hand-edited
* contract snapshots may experience cosmetic whitespace shifts; this
* is acceptable because on-disk contract snapshots are CLI-authored
* artefacts, not user-edited source.
*
* The codemod is idempotent: running it on already-stamped snapshots
* is a no-op (every entry passes the `kind`-already-present check).
*
* Flags:
* --check dry-run; lists affected files and exits 1 if any still
* need fixing.
*/
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
const CONTRACT_FILES = new Set(['start-contract.json', 'end-contract.json']);
const POSTGRES_ENUM_CODEC_ID = 'pg/enum@1';
const dryRun = process.argv.includes('--check');
const projectRoot = process.cwd();
interface Result {
readonly path: string;
readonly status: 'already-clean' | 'needs-fix' | 'fixed';
readonly stamped: number;
}
async function findContractSnapshots(root: string): Promise<string[]> {
const out: string[] = [];
// The `inMigrations` flag confines snapshot rewrites to the
// `migrations/` subtree the doc promises. Without the flag the walk
// would happily stamp any `start-contract.json` / `end-contract.json`
// found anywhere under the project root, including non-migration
// fixtures (e.g. inline contract test snapshots).
async function walk(dir: string, inMigrations: boolean): Promise<void> {
let entries: Awaited<ReturnType<typeof readdir>>;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
// Unreadable directory — skip silently. Mirrors the predecessor
// 0.8→0.9 codemod's failure-tolerant walk; the user's project
// root may legitimately contain restricted directories.
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
await walk(join(dir, entry.name), inMigrations || entry.name === 'migrations');
} else if (inMigrations && entry.isFile() && CONTRACT_FILES.has(entry.name)) {
out.push(join(dir, entry.name));
}
}
}
await walk(root, false);
return out.sort();
}
interface UntaggedCodecTriple {
readonly codecId: string;
readonly nativeType: string;
readonly typeParams: Record<string, unknown>;
}
interface StampedCodecInstance {
readonly kind: 'codec-instance';
readonly codecId: string;
readonly nativeType: string;
readonly typeParams: Record<string, unknown>;
}
interface StampedPostgresEnum {
readonly kind: 'postgres-enum';
readonly name: string;
readonly nativeType: string;
readonly values: readonly string[];
readonly codecId: string;
}
type StampedEntry = StampedCodecInstance | StampedPostgresEnum;
function isAlreadyStamped(value: unknown): boolean {
if (typeof value !== 'object' || value === null) return false;
const kind = (value as { kind?: unknown }).kind;
return kind === 'codec-instance' || kind === 'postgres-enum';
}
function looksLikeUntaggedCodecTriple(value: unknown): value is UntaggedCodecTriple {
if (typeof value !== 'object' || value === null) return false;
const obj = value as Record<string, unknown>;
if (
typeof obj['codecId'] !== 'string' ||
typeof obj['nativeType'] !== 'string' ||
typeof obj['typeParams'] !== 'object' ||
obj['typeParams'] === null
) {
return false;
}
// A `pg/enum@1` triple is only recognisable as an untagged enum if its
// `typeParams.values` is already a string[]. Without this guard a
// malformed enum entry would slip through the classifier and surface
// a different (more specific) diagnostic from `stampEntry` than the
// outer "neither stamped nor untagged-triple — hand-edit required"
// throw. Folding that case into the predicate gives every malformed
// entry the same single diagnostic shape.
if (obj['codecId'] === POSTGRES_ENUM_CODEC_ID) {
const values = (obj['typeParams'] as { values?: unknown })['values'];
if (!Array.isArray(values) || !values.every((v) => typeof v === 'string')) {
return false;
}
}
return true;
}
function stampEntry(name: string, raw: UntaggedCodecTriple): StampedEntry {
if (raw.codecId === POSTGRES_ENUM_CODEC_ID) {
const values = (raw.typeParams as { values?: unknown })['values'];
// Invariant: `looksLikeUntaggedCodecTriple` already gated this — a
// `pg/enum@1` entry that reaches `stampEntry` has a string[]
// `typeParams.values`. The runtime check stays as a defensive
// marker so a future loosening of the predicate doesn't silently
// produce a malformed StampedPostgresEnum.
if (!Array.isArray(values) || !values.every((v) => typeof v === 'string')) {
throw new Error(
`invariant: storage.types[${JSON.stringify(name)}] reached stampEntry with codecId="${POSTGRES_ENUM_CODEC_ID}" but typeParams.values is not a string[]; the classifier should have rejected this entry`,
);
}
return {
kind: 'postgres-enum',
name,
nativeType: raw.nativeType,
values,
codecId: raw.codecId,
};
}
return {
kind: 'codec-instance',
codecId: raw.codecId,
nativeType: raw.nativeType,
typeParams: raw.typeParams,
};
}
interface ProcessOutcome {
readonly transformed: Record<string, unknown> | null;
readonly stamped: number;
}
function processContract(parsed: unknown, filePath: string): ProcessOutcome {
if (typeof parsed !== 'object' || parsed === null) {
return { transformed: null, stamped: 0 };
}
const root = parsed as Record<string, unknown>;
const storage = root['storage'];
if (typeof storage !== 'object' || storage === null) {
return { transformed: null, stamped: 0 };
}
const storageObj = storage as Record<string, unknown>;
const types = storageObj['types'];
if (typeof types !== 'object' || types === null) {
return { transformed: null, stamped: 0 };
}
const typesObj = types as Record<string, unknown>;
let stamped = 0;
const newTypes: Record<string, unknown> = {};
for (const [name, entry] of Object.entries(typesObj)) {
if (isAlreadyStamped(entry)) {
newTypes[name] = entry;
continue;
}
if (!looksLikeUntaggedCodecTriple(entry)) {
throw new Error(
`${filePath}: storage.types[${JSON.stringify(name)}] is neither a stamped entry nor an untagged codec triple — refusing to guess. Hand-edit required.`,
);
}
newTypes[name] = stampEntry(name, entry);
stamped += 1;
}
if (stamped === 0) return { transformed: null, stamped: 0 };
return {
transformed: { ...root, storage: { ...storageObj, types: newTypes } },
stamped,
};
}
/**
* Pretty-print JSON with multi-line objects/arrays (per
* `JSON.stringify(null, 2)` conventions) but inline arrays of primitives
* (strings, numbers, booleans, null) whose serialised length fits
* within `INLINE_ARRAY_THRESHOLD`. This matches the on-disk shape the
* CLI's contract-snapshot writer produces (e.g. `"columns": ["id"]`,
* `"values": ["admin", "user"]`) — the alternative (Node's default
* `JSON.stringify(null, 2)`) reflows every such array onto multiple
* lines and bloats the diff with hundreds of lines of cosmetic noise.
*
* The non-array, non-object surfaces match `JSON.stringify(null, 2)`
* byte-for-byte (same key ordering — insertion order — and same
* quoting/escaping). The only divergence is inline-primitive-arrays.
*/
const INLINE_ARRAY_THRESHOLD = 80;
function isPrimitive(value: unknown): boolean {
return (
value === null ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
);
}
function formatJson(value: unknown, indentLevel = 0): string {
const indent = ' '.repeat(indentLevel);
const childIndent = ' '.repeat(indentLevel + 1);
if (value === null) return 'null';
if (typeof value === 'string') return JSON.stringify(value);
if (typeof value === 'number' || typeof value === 'boolean') return JSON.stringify(value);
if (Array.isArray(value)) {
if (value.length === 0) return '[]';
if (value.every(isPrimitive)) {
const inline = `[${value.map((v) => JSON.stringify(v)).join(', ')}]`;
if (inline.length <= INLINE_ARRAY_THRESHOLD) return inline;
}
const items = value.map((v) => `${childIndent}${formatJson(v, indentLevel + 1)}`);
return `[\n${items.join(',\n')}\n${indent}]`;
}
if (typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) return '{}';
const lines = entries.map(
([k, v]) => `${childIndent}${JSON.stringify(k)}: ${formatJson(v, indentLevel + 1)}`,
);
return `{\n${lines.join(',\n')}\n${indent}}`;
}
throw new Error(`Unsupported value: ${typeof value}`);
}
async function processFile(path: string): Promise<Result> {
const raw = await readFile(path, 'utf-8');
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(
`${path}: not valid JSON (${error instanceof Error ? error.message : String(error)})`,
);
}
const outcome = processContract(parsed, path);
if (outcome.transformed === null) {
return { path, status: 'already-clean', stamped: 0 };
}
const serialised = `${formatJson(outcome.transformed)}\n`;
if (!dryRun) await writeFile(path, serialised, 'utf-8');
return { path, status: dryRun ? 'needs-fix' : 'fixed', stamped: outcome.stamped };
}
const contracts = await findContractSnapshots(projectRoot);
if (contracts.length === 0) {
console.error(`No start-contract.json / end-contract.json files found under ${projectRoot}.`);
process.exit(1);
}
let changed = 0;
let alreadyClean = 0;
let totalStamped = 0;
for (const path of contracts) {
const result = await processFile(path);
const rel = path.slice(projectRoot.length + 1);
if (result.status === 'already-clean') {
alreadyClean += 1;
console.log(`OK ${rel} (already stamped or no storage.types)`);
} else {
changed += 1;
totalStamped += result.stamped;
const verb = dryRun ? 'WOULD FIX' : 'FIXED';
console.log(`${verb} ${rel} (stamped ${result.stamped} entry/entries)`);
}
}
console.log();
console.log(
`${contracts.length} snapshot(s) scanned: ${changed} ${dryRun ? 'needing fix' : 'fixed'} (${totalStamped} entries), ${alreadyClean} already clean.`,
);
if (dryRun && changed > 0) process.exit(1);
upgrading/app/upgrades/8.0.0-rc.1-to-8.0.0-rc.2/instructions.md
---
from: "8.0.0-rc.1"
to: "8.0.0-rc.2"
changes:
- id: check-constraints-are-opaque-expressions
summary: |
CHECK constraints in `contract.json` changed shape: `{ name, column, valueSet }` became
`{ name, prefix, expression }`, where `expression` is the raw SQL predicate and `name`
is a content-addressed wire name (`<prefix>_<8hex>`, the same convention indexes and RLS
policies already use). Run `prisma-next contract emit` to regenerate `contract.json` and
`contract.d.ts` — an old-shape contract is rejected on read, so this is not optional.
Regeneration also changes the physical names of your enum CHECK constraints, because the
hash suffix is new: `prisma-next migration plan` will show a DROP of the old unsuffixed
constraint plus an ADD of the wire-named one. That plan needs `destructive` to drop the
stale constraint; under an additive-only policy the new constraint installs and the old
one survives, and `prisma-next db verify --strict` reports it as an undeclared extra
until you allow the drop. Every list (`many`) column now also carries a declared
element-non-null CHECK that the planner previously invented behind your back; it appears
in the contract and in the plan for the first time.
Introspection also stopped parsing predicates, so every CHECK constraint on a managed
table is now visible — including hand-written ones (`price > 0`, a composite `AND`, a
`NOT VALID` constraint) that earlier versions could not see at all. An undeclared check
is an extra: `prisma-next db verify --strict` reports it, and a plan run under a policy
that allows `destructive` emits a `dropCheckConstraint` operation for it. Read the first
plan for `dropCheckConstraint` operations naming constraints you wrote by hand. There is
To keep one, declare it: `@@check(expression: "…", map: "<its physical name>")` adopts the
constraint under the name it already has, after which `db verify` is clean and no plan drops
it. `contract infer` writes that form for you, so re-running a pull is the quickest route.
Let a drop through only when the constraint is deliberately retired. Running the table under
an additive-only policy also keeps it, but leaves it reported by `--strict` as undeclared.
detection:
glob: "**/contract.json"
contains:
- '"valueSet"'
- '"checks"'
anyMatch: true
- id: add-check-constraint-takes-an-expression
summary: |
In committed migration files, `this.addCheckConstraint({ schema, table, constraint,
column, values })` is now `this.addCheckConstraint({ schema, table, constraint,
expression })`. Replace the `column` and `values` pair with the predicate they used to
describe — `column: 'kind', values: ['admin', 'user']` becomes
`` expression: `"kind" IN ('admin', 'user')` `` — and use the wire name from your
regenerated contract as `constraint`. If the constraint was created by the same
migration's `createTable`, prefer moving it inline: add
`checkExpression(<name>, <expression>)` to that table's `constraints` array and delete
the follow-up `addCheckConstraint` call, which is what a freshly planned migration now
produces. Import `checkExpression` from the same migration entrypoint as `col` and
`primaryKey`.
detection:
glob: "**/migrations/**/*.ts"
contains:
- 'addCheckConstraint'
anyMatch: true
- id: specifier-default-control-policy-requires-create-namespace
summary: |
If your `prisma.config.ts` passes `defaultControlPolicy` in the options bag of
`typescriptContract` or `typescriptContractFromPath`, that bag now also requires
`createNamespace`. Stamping a default policy strips derived CHECK constraints from
tables the policy leaves non-managed, and the strip rebuilds storage namespaces through
the target's factory, so the two options travel together.
`typescriptContract(contract, output, { defaultControlPolicy: 'external' })` becomes
`typescriptContract(contract, output, { defaultControlPolicy: 'external',
createNamespace: postgresCreateNamespace })`, with `postgresCreateNamespace` imported
from the Postgres target's types entrypoint (`@internal/target-postgres/types`) — the
same factory the PSL specifier already takes. Calls without an options bag are
unchanged, and `emptyContract` already took `createNamespace`.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- 'typescriptContract'
- 'defaultControlPolicy'
anyMatch: false
- id: int-backed-enums-fail-at-authoring
summary: |
An `enumType()` whose codec is numeric (e.g. `pg/int4@1`) used to build fine and fail
later, at migrate time. It now throws `CONTRACT.ENUM_INVALID` while the contract is being
built, because a numeric member set cannot be rendered as a CHECK predicate. If
`prisma-next contract emit` fails with "numeric-enum CHECK constraints are not yet
supported", change that enum's codec to a text one (`pg/text@1`) and give each member a
string value, or replace it with a Postgres native enum (`pg.enum`), which enforces
membership through the column type and needs no CHECK at all.
detection:
glob: "**/*.{ts,mts,cts,prisma}"
contains:
- 'enumType'
anyMatch: true
- id: aggregate-methods-come-from-the-emitted-contract
summary: |
The aggregate methods — `count`, `sum`, `avg`, `min`, `max` — are no longer declared on the
ORM and SQL-builder surfaces outright. Each surface is derived from the operation names in
the emitted `contract.d.ts`'s `AggregateTypes` block, so a target or extension can contribute
an operation and it appears under its own name with no client change. Deriving the surface
neither adds nor removes a method by itself, but the block a re-emit produces is not the
list it was: PostgreSQL now contributes eight operations and SQLite seven, and every bare
result type moved — `count-over-a-field-counts-that-field` and
`aggregate-defaults-are-js-native-numbers` cover that, so work them too. If it is not — you author it
in code with `defineContract(...)` and hand that value straight to the client (the no-emit
flow), or you have not re-emitted since before 8.0.0-rc.1 — every aggregate surface resolves
to `AggregateOperationsUnavailable`, an empty type, and each call becomes
`Property 'count' does not exist on type 'AggregateOperationsUnavailable'`. That covers
`aggregate()`, `groupBy().aggregate()`, `groupBy().having()`, the `include(...)` reducers,
and `sql()`'s `fns.count` / `fns.sum` / … Runtime behaviour is unchanged; this is a
compile-time change. Re-emit the contract, or cast the builder to a dynamic record.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- "defineContract"
- ".aggregate("
- ".having("
- "fns.count"
anyMatch: true
- id: count-over-a-field-counts-that-field
summary: |
`aggregate.count(field)` renders `COUNT(<column>)`. It used to accept the argument, discard
it, and render `COUNT(*)`. PostgreSQL declares `count` over any input, so the derived method
carries both arities honestly: `count()` counts rows, `count(field)` counts that field's
non-null values. No previously type-safe call changes meaning — `count` took no argument
before, so the field-taking form never typechecked. What changes is a call that got past
the types: a `@ts-expect-error` above a `count(...)`, a `count(x as never)`, or dynamic
dispatch. Those now count a column instead of rows, which differs whenever the column holds
NULLs. Sweep them and drop the argument wherever `COUNT(*)` was what you meant.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- "count("
- ".aggregate("
anyMatch: true
- id: aggregate-defaults-are-js-native-numbers
summary: |
`count()`, `sum()` over an integer column, and `avg()` over an integer column all return
`number`. On PostgreSQL they returned, respectively, a `bigint`; a `bigint` or a decimal
string depending on the column's width; and a decimal string. On SQLite the first two
returned a `bigint` and `avg()` was already a `number`. The lossless results moved to three new
operations beside them — `countBigInt()` → `bigint`, `sumBigInt()` → `bigint` (on
PostgreSQL exact past 2^63 over a `BigInt` / `BigIntNumber` / `UnboundedInt` column, whose
total the database computes as `numeric`; over the narrower integers the total is an `int8`
and PostgreSQL raises `bigint out of range` past 2^63), `avgDecimal()` → decimal string
(PostgreSQL only; SQLite has no
decimal type and contributes none). An empty input set answers `count()` with `0`, not `0n`.
A `count()`, or a `sum()` over an integer column, whose value passes ±(2^53 − 1) raises
`RUNTIME.DECODE_FAILED` instead of returning a rounded number — on the `.include()` path as
well as the top level — so switch that call to `countBigInt()` / `sumBigInt()` wherever the
magnitude is real. No other result is guarded: a `sum` outside the integer columns keeps its
own family, and `avg` is a fraction that rounds as any double does.
Unchanged: `min` / `max`, `sum` / `avg` over a float column, `sum` over `numeric` (still a
decimal string), `sum` over `UnboundedInt` (still a `bigint`), and the ORM's `having(...)`
operands, which the ORM types as `number` whatever the aggregate's result type is. The SQL
builder's comparison operands are the other case, and they do move: `fns.gt(a, b)` types
both sides from one codec, so `fns.gt(fns.count(), 1n)` becomes `fns.gt(fns.count(), 1)`.
Sweep aggregate results for
`2n`-style literals, `String(count)` serialisation, `Number(...)` unwrapping, and `?? '0'`
coalescing, and write each as the plain number it now is. Then re-emit with
`prisma-next contract emit`: `contract.d.ts`'s `AggregateTypes` block carries the new
result codecs and the three new operations, and until you re-emit, the types describe the
old results and the new methods do not exist.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- ".aggregate("
- ".count()"
- ".groupBy("
- ".include("
anyMatch: true
- id: integer-columns-refuse-the-wrong-js-type
summary: |
A `BigInt` or `UnboundedInt` column refuses a JS `number`, and a `BigIntNumber` column
refuses a `bigint`, with `RUNTIME.ENCODE_FAILED` and a message naming the type that
arrived: `pg/int8@1 value must be a bigint, got number 9`. The wide-integer codecs used to
accept a number and stringify it, which let a fractional value such as `1.5` reach an
integer column unremarked. No typed call site changes — a `BigInt` column's application
type has always been `bigint` — so sweep the ones that bypassed the types: a
`// @ts-expect-error` over a create/update value, an `as never` / `as any` argument, a
value that came out of `JSON.parse` (which yields numbers, never bigints), and dynamic
dispatch. Convert each to the column's own type, `BigInt(value)` for a `bigint` column.
Schema-written literal defaults are unaffected: `BigInt @default(0)` still emits, because
the JSON side of these codecs accepts a safe-integer number and only the wire side does not.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- "bigint"
- "BigInt"
anyMatch: true
- id: authored-check-constraints
summary: |
A CHECK constraint can now be declared in the contract: `@@check(expression: "…", name: "…")`
in PSL, `check({ expression, name })` in the TypeScript builder. `name:` is a wire-name
prefix — the physical constraint becomes `name_<8hex>`, hashed over the predicate, and
compared by name, so Postgres reprinting the expression never causes drift. Use `map:`
instead to adopt a constraint that already exists under its own physical name; that form
compares the predicate byte-for-byte, and warns whenever the body was not captured from the
database — including on contracts `contract infer` wrote, which warn on the next
`contract emit`. `contract infer` now writes the `map:` form for you: pulling a database
emits `@@check` for every live check Prisma 8 did not derive, so a hand-written constraint
is declared from the first pull instead of reading as an undeclared extra that a plan allowing
destructive changes would drop. Nothing is required of an existing contract — the surface is
additive.
detection:
glob: "**/*.{prisma,ts}"
contains:
- '@@check'
anyMatch: true
- id: runtime-query-execute-hard-cut
summary: |
Runtime row execution uses `query()`, while rc.1 prepared rows use `target.queryPrepared(prepared, params, options?)` and rc.2 uses `prepared.query(target, params, options?)`. Classify each call by its consumed result rather than replacing every `execute`: move row plans to `query`, prepared rows to `prepared.query(target, params, options?)`, and keep non-returning writes on `execute` while reading `affectedRows` when needed. Middleware uses operation-specific `beforeQuery` / `interceptQuery` / `afterQuery` and `beforeExecute` / `interceptExecute` / `afterExecute` hooks, with shared `beforeCompile`; interception returns `{ rows }` for queries and `{ stats }` for execution. There is no operation discriminator, compatibility alias, or generic fallback hook. The Mongo facade keeps static `db.query` and removes row `db.execute`; execute a built row plan through `(await db.runtime()).query(plan)`.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- ".execute("
- ".queryPrepared("
- "beforeQuery"
- "interceptExecute"
anyMatch: true
- id: config-file-is-prisma-config-with-an-orm-section
summary: |
The CLI config file is `prisma.config.ts` — the `prisma-next.config.ts` name is
deprecated — and the config value is engine-shaped: `defineConfig` from
`@prisma/cli-engine` wraps the whole ORM config as its `orm` section. Rename the file,
then wrap the existing export: alias the current `defineConfig` import (from the target
facade or CLI config-types) as `ormConfig` and write
`export default defineConfig({ orm: ormConfig({ ...existing config... }) })`, adding
`@prisma/cli-engine` to devDependencies. Both the deprecated filename and the flat shape
still load, each printing a deprecation warning on stderr, so the two steps can land
separately; anything asserting clean stderr around CLI invocations sees the warning
until both are done.
detection:
glob: "**/prisma*.config.*"
contains:
- "defineConfig"
anyMatch: true
- id: published-prisma-next-bin-retired
summary: |
Nothing published ships a `prisma-next` bin anymore: `@prisma/orm-toolchain` publishes
the `orm` command family at `@prisma/orm-toolchain/cli` and no bin, and the database
facades forward no launcher. The only user-facing binary is the unified `prisma` CLI
(the prisma-cli distribution), which mounts the same commands. Replace
`prisma-next <command>` invocations in package scripts and CI with the unified CLI's
equivalent, and drop any dependency that was taken only to put the bin on PATH.
detection:
glob: "**/package.json"
contains:
- "prisma-next"
anyMatch: true
- id: raw-is-a-reserved-storage-namespace
summary: |
A storage namespace named `raw` is refused: the SQL surface exposes the whole-query raw
statement tag as `db.sql.raw`, so a namespace of that name would be unreachable through
the builder while the emitted types still promised its tables. Building the client raises
`ORM.NAMESPACE_RESERVED` naming the namespace. Rename the namespace in your schema —
`@@schema("raw")` becomes any other name — re-emit the contract, and plan the rename
against the database as you would any other namespace rename. Only `raw` is reserved; every other namespace name is unaffected.
detection:
glob: "**/*.{prisma,json}"
contains:
- '@@schema("raw")'
- '"raw": {'
anyMatch: true
- id: codec-ids-are-checked-where-they-are-authored
summary: |
A codec id you write in a prepared declaration or in a contract-bound raw fragment is
now checked against your contract's codec map, so an id the contract does not carry is
a compile error where before it compiled and failed at execution with
`RUNTIME.PARAM_REF_MISSING_CODEC`. The usual cause is an unversioned id:
`db.prepare({ id: 'pg/int4' }, ...)` becomes `db.prepare({ id: 'pg/int4@1' }, ...)`, and
`fns.raw\`...\`.returns('pg/text')` becomes `.returns('pg/text@1')`. Take the id from your
emitted `contract.d.ts` — every id it carries now completes at both positions, so the
editor offers the correct spelling rather than accepting a wrong one. Raw fragments
built through a contract-free lane are unaffected; they have no map to check against.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- "prepare({"
- ".returns('pg/"
- ".returns(\"pg/"
anyMatch: true
---
# 8.0.0-rc.1 → 8.0.0-rc.2 — User upgrade instructions
## `aggregate-methods-come-from-the-emitted-contract`
Which aggregate methods exist is now the contract's answer rather than a fixed list in the client. The emitted `contract.d.ts` carries an `AggregateTypes` block naming every operation your target and extensions declare, and each surface below is derived from it:
| Surface | What it offers |
| --- | --- |
| `db.orm.User.aggregate((a) => …)` | one selector method per declared operation |
| `db.orm.User.groupBy('kind').aggregate((a) => …)` | the same |
| `db.orm.User.groupBy('kind').having((h) => …)` | the same, restricted to `count` / `sum` / `avg` / `min` / `max` |
| `db.orm.User.include('posts', (posts) => posts.count())` | one reducer per declared operation |
| `db.sql.public.user.select('n', (f, fns) => fns.count())` | one function per declared operation |
**If your contract is emitted, re-emit it — and keep reading.** Deriving the surface takes nothing away on its own: whatever the composed targets and extensions declare is what the block names. But the built-in targets changed what they declare in this same release. PostgreSQL now contributes eight operations and SQLite seven, and the bare results over integer columns moved — `count`, `sum`, and `avg`. `min` / `max` did not, nor did `sum` and `avg` over a float, `numeric`, or temporal column, nor `sum` over an `UnboundedInt` column. Two entries below carry those changes, and a re-emitted contract lands you in both: [`count-over-a-field-counts-that-field`](#count-over-a-field-counts-that-field) and [`aggregate-defaults-are-js-native-numbers`](#aggregate-defaults-are-js-native-numbers).
**If your contract's block is unknown, the surfaces are empty.** Two situations reach that state:
- You author the contract in TypeScript with `defineContract(...)` and pass the value straight to the client, never running `prisma-next contract emit`. A contract value built in code carries no emitted type maps.
- You are still using a `contract.d.ts` emitted before 8.0.0-rc.1, when the `AggregateTypes` block did not exist yet.
Either way the call is a compile error:
```text
Property 'count' does not exist on type 'AggregateOperationsUnavailable'.
```
The type is an empty interface whose name is the diagnosis; hovering it shows the reason. Nothing changes at runtime — the client installs its aggregate methods from the composed target and extensions, exactly as it always has.
**Preferred fix: emit the contract.** Run
```bash
prisma-next contract emit
```
and type the client from the emitted `Contract`. That gives you the whole aggregate surface back, plus the per-operation result types and the field names each operation admits.
**Alternative, where the contract is deliberately un-emitted:** cast the builder and dispatch by name.
```ts
import type { AggregateSpec } from '@prisma/orm-postgres/orm-client';
type DynamicAggregates = Record<string, (field?: string) => AggregateSpec[string]>;
const stats = await db.User.aggregate((aggregate) => {
const dynamic = aggregate as DynamicAggregates;
return { total: dynamic['sum']!('views'), peak: dynamic['max']!('views') };
});
```
If you previously widened the *argument* instead — `aggregate.sum('views' as never)`, which compiled because the admitted field names were already `never` for such a contract — move the cast from the argument to the builder and pass the field name as a plain string.
## `count-over-a-field-counts-that-field`
```ts
await db.User.aggregate((aggregate) => ({ all: aggregate.count() }));
// SELECT COUNT(*) …
await db.User.aggregate((aggregate) => ({ withEmail: aggregate.count('email') }));
// SELECT COUNT("email") … — rows whose email is NULL are not counted
```
The second form used to render `COUNT(*)`: the argument was accepted and thrown away. Both arities are now read off what the target declares for `count` — PostgreSQL declares it over any input, which means both a call with a value and a call without one — so the argument is honoured.
No previously type-safe call changes meaning, because `count` took no argument and the field-taking form did not typecheck. Sweep instead for calls that bypassed the types:
- a `// @ts-expect-error` directly above a `count(...)` call — where the argument is a field your contract admits, that suppression is now unused and TypeScript flags the unused directive;
- `count(field as never)` or `count(field as any)`;
- dynamic dispatch through a `Record<string, …>` cast.
For each, decide which count you meant: `count()` for rows, `count(field)` for that field's non-null values.
## `aggregate-defaults-are-js-native-numbers`
The aggregate vocabulary is split in two. The bare operations answer in the type a JS developer expects; three new suffixed operations answer losslessly.
| Call | Reads as | Empty input set |
| --- | --- | --- |
| `count()` | `number` | `0` |
| `countBigInt()` | `bigint` | `0n` |
| `sum(field)` over `Int` / `BigInt` / `BigIntNumber` | `number \| null` | `null` |
| `sumBigInt(field)` over any integer column | `bigint \| null` | `null` |
| `avg(field)` over any integer column | `number \| null` | `null` |
| `avgDecimal(field)` over any integer or `Decimal` column | decimal `string \| null` | `null` |
These do not move: `min` / `max` keep the column's own type; `sum` and `avg` over a float column stay `number`; `sum` over `Decimal` stays a decimal string; `sum` over `UnboundedInt` stays a `bigint`; and the ORM's `having(...)` operands stay plain numbers, because the ORM types a HAVING comparand as `number` whatever result type the aggregate carries.
The SQL builder's comparison operands are the other case, and they do move. `fns.gt(a, b)` types both sides from one codec, so a literal compared against an aggregate follows that aggregate's result codec:
```ts
// before
.having((_f, fns) => fns.gt(fns.count(), 1n))
// after
.having((_f, fns) => fns.gt(fns.count(), 1))
```
Make the same one-token change wherever a `fns.count()` or an integer `fns.sum(...)` meets a literal — in `having(...)`, in `where(...)`, and inside a larger expression.
SQLite states the same policy in its own terms — `count`, integer `sum`, and `avg` are all `number`, with `countBigInt` and `sumBigInt` beside them. **SQLite has no `avgDecimal`**: an exact mean needs a decimal type the database does not have, so the method is absent from a SQLite contract and calling it is a type error.
### What to change
1. **Re-emit first.** `prisma-next contract emit` rewrites the `AggregateTypes` block. Until you do, the types describe the old results and the three new methods do not exist.
2. **Unwrap the bigint handling around bare aggregates.** Each of these is now noise or a type error:
```ts
const { total } = await db.User.aggregate((a) => ({ total: a.count() }));
total === 2n // ← was needed; now `total === 2`
Number(total) // ← was needed; `total` is already a number
String(total) // ← was needed for JSON; JSON.stringify handles it now
JSON.stringify(rows, (_k, v) => typeof v === 'bigint' ? String(v) : v)
// ↑ the replacer can go
```
3. **Change the method, not the value, where you need exactness.** A decimal-string average was doing real work in a money or reporting path; `avgDecimal(field)` returns exactly what `avg(field)` used to, and `countBigInt()` exactly what `count()` used to.
`sumBigInt(field)` matches the old `sum(field)` everywhere but one column class. On PostgreSQL, a `BigInt` or `BigIntNumber` column's `sum` used to be a decimal `string`, because the database totals a 64-bit column as `numeric`; `sumBigInt` reads that same total as a `bigint`. So a money path summing a `BigInt` column gets a `bigint` where it had a string — exact either way, but a different type. Convert at the consumption site (`String(total)`) if a decimal library or a string comparison is downstream. Over every other integer column, and on SQLite, `sumBigInt` is the old `sum` unchanged.
### The bare operations throw rather than round
A `count()`, or a `sum()` over an integer column, whose value passes ±(2^53 − 1) raises a structured error instead of answering with a rounded one:
```text
RUNTIME.DECODE_FAILED: pg/int8number@1 value must be an integer within
the safe integer range, got 9007199254740992
```
That is the trade these defaults make: a value you can compare, serialise, and do arithmetic with, and a loud failure rather than a quietly wrong total. It fires on the `.include()` path too — the reducer's value travels as a JSON number, but the guard runs after the parse, and rounding is monotone, so a value that was outside the range is still outside it after parsing.
Those two are the results a guarded integer codec produces. A `sum` over a `Decimal`, `UnboundedInt`, or float column stays in that column's own family and has no such guard, and neither does `avg`, which is a fraction already and rounds as any double does — reach for `avgDecimal` where the exact mean matters.
Totals cross the boundary in practice where counts do not: summing 64-bit IDs, or cent amounts across a large table. If a `sum` in your code can plausibly get there, move it to `sumBigInt` now rather than waiting for the error in production.
### If you are upgrading from before 8.0.0-rc.1
You cross two hops, and the aggregate result types move in both. The `0.17 → 8.0.0-rc.1` step changes `count()` to `bigint` and integer averages to decimal strings; this step changes those same calls to `number` and adds the suffixed variants. Apply the steps in order — that is what the upgrade skill does — but do the sweeping once, at the end: for `count()` and integer `sum()` / `avg()`, the destination is `number`, which is where a pre-8.0.0-rc.1 codebase already was. What genuinely changed for you across both hops is the throw outside ±(2^53 − 1) on `count()` and integer `sum()`, and the three new operations; the empty-relation `count` ends where it started, at `0`.
## `integer-columns-refuse-the-wrong-js-type`
Writing a JS `number` to a `BigInt` or `UnboundedInt` column now fails before any SQL runs:
```text
RUNTIME.ENCODE_FAILED: pg/int8@1 value must be a bigint, got number 9
```
The codec used to accept the number and stringify it, so `9` wrote `9` and `1.5` wrote `1.5` — a fractional value in an integer column, unremarked. The mirror case reports as clearly: passing `9n` to a `BigIntNumber` column names the type that arrived rather than complaining about a range the value is plainly inside.
No typed call site changes, because a `BigInt` column's application type has always been `bigint`. Sweep the ones that got past the types:
- a `// @ts-expect-error` over a `create(...)` / `update(...)` value;
- `value as never` or `value as any` in a write;
- a value that came out of `JSON.parse`, which yields numbers and never bigints;
- dynamic dispatch through a `Record<string, unknown>`.
Convert each to the column's own type — `BigInt(value)` for a `bigint` column, and a plain number for a `BigIntNumber` one.
Schema-written defaults need nothing. `BigInt @default(0)` still emits and still migrates: the JSON side of these codecs accepts a safe-integer number, because a schema language writes no `bigint` literal, and only the query-parameter side requires the exact type.
## `config-file-is-prisma-config-with-an-orm-section`
Two mechanical steps, in either order:
1. `git mv prisma-next.config.ts prisma.config.ts` (same for `.mts` / `.mjs` variants).
2. Wrap the flat export in the engine shape:
```ts
// before
import { defineConfig } from '@prisma/orm-postgres/config';
export default defineConfig({ ... });
// after
import { defineConfig } from '@prisma/cli-engine';
import { defineConfig as ormConfig } from '@prisma/orm-postgres/config';
export default defineConfig({ orm: ormConfig({ ... }) });
```
Add `@prisma/cli-engine` to `devDependencies`. The inner config is unchanged — only the file
name and the outer wrapper move. The loader still discovers the deprecated filename and still
accepts the flat shape, each with a stderr deprecation warning, so nothing breaks mid-rename;
finish both steps to silence the warnings.
## `published-prisma-next-bin-retired`
`prisma-next ...` in a package script resolved through a bin the database facades forwarded
from the toolchain. That chain is gone: the published toolchain is bin-less and exports the
`orm` command family at `@prisma/orm-toolchain/cli` for the unified `prisma` CLI (the
prisma-cli distribution) to mount. Point scripts and CI at the unified CLI, which serves the
same command paths.
<!--
PR #30049: `changes: []`. The 8.0.0-rc.2 release cut. The example diff is the lockstep version bump, the `@prisma/cli-engine` peer moving to 0.1.1, and the supabase contract fixtures re-emitted so their embedded extension version matches. No user API, contract, configuration, generated-artifact, or source translation follows from it — the consumer-facing changes this release ships are the entries above, recorded by the PRs that made them.
PR #29910: `changes: []`. The example changes repair test instrumentation and fixture/runtime isolation after the driver SPI split; they require no user API, contract, configuration, generated-artifact, or source translation.
PR #29902: `changes: []`. Generated contracts gain additive aggregate rows for new opt-in integer representation codecs, but existing schemas and source require no migration; users re-emit only when adopting the new target-scoped types.
PR #29950: `changes: []`. The demo applications adopt the integer representation types and the precision-preserving aggregates on their own models, and the reference docs gain the matching examples; the diff is confined to example apps and documentation and requires no user API, contract, configuration, generated-artifact, or source translation.
PR #29939: `changes: []`. Dependabot's weekly runtime-dependency bumps (`ws`, `lucide-react`, `postcss`, `uniku`, `@vercel/detect-agent`, and the `@types/node` / `@types/pg` / `tsx` catalog entries). The example diff is dependency version strings, and requires no user API, contract, configuration, generated-artifact, or source translation.
PR #29940: `changes: []`. Dependabot's weekly dev-dependency bumps (type packages, wrangler, biome, test tooling) plus the matching `biome.jsonc` `$schema` realignment; the example diffs are devDependency version strings and a schema URL, and require no user API, contract, configuration, generated-artifact, or source translation.
PR #29965: `changes: []`. Dependabot's dev-dependency bumps (`wrangler`, `@cloudflare/vitest-pool-workers`, `@biomejs/biome`) plus the matching `biome.jsonc` `$schema` realignment to 2.5.7; the example diffs are devDependency version strings and a schema URL, and require no user API, contract, configuration, generated-artifact, or source translation.
PR #30031: `changes: []`. Dependabot's weekly runtime-dependency bumps (`esbuild`, `evlog`, `next`). The example diff is dependency version strings, and requires no user API, contract, configuration, generated-artifact, or source translation.
-->
## Regenerating is the first step
`prisma-next contract emit` rewrites `contract.json` / `contract.d.ts` into the new check shape
and mints the wire names every later step refers to. Do it before editing migration files, so
the constraint names you paste into `addCheckConstraint` / `checkExpression` are the ones the
contract actually declares.
## What the first plan after upgrading looks like
For each enum-restricted column: a DROP of the old unsuffixed constraint and an ADD of the
wire-named one. For each list column: an ADD of an element-non-null constraint that was
previously created without ever being declared. Neither is a data change — but the DROP is
classified `destructive`, so a plan run under an additive-only policy converges only partway
and `db verify --strict` will report the leftovers until you allow it.
There may also be a third kind of operation, and it is the one to read carefully. Introspection
no longer parses predicates: it captures every CHECK constraint on a managed table verbatim,
including the hand-written and platform-installed ones that earlier versions were structurally
unable to see. A check the contract does not declare is an undeclared extra, so
`db verify --strict` reports it and a plan run under a policy that allows `destructive` emits a
`dropCheckConstraint` for it — a constraint you wrote by hand and that has been enforcing your
data all along. Grep the first plan for `dropCheckConstraint` and check every constraint named:
- to keep it, run plans for that table under an additive-only policy. The constraint stays in
place and keeps enforcing; plain `db verify` tolerates it, and only `--strict` reports it as
an undeclared extra. Better: declare it with `@@check(expression: "…", map: "<physical name>")`,
or re-run `contract infer`, which now emits exactly that for every live check Prisma 8 did
not derive — the constraint becomes declared and stops being an extra at all;
- if it was already dead, let the drop through under the destructive plan.
Nothing drops silently — an additive-only policy never emits the operation at all — but the
first plan after upgrading is the moment to look, because it is the first plan that can see
these constraints.
## `runtime-query-execute-hard-cut`
Runtime operations state whether the caller expects rows or statement statistics. Do not apply a global `execute` → `query` replacement: an insert, update, or delete that does not return rows belongs on `execute`, while a select, a returning write, a Mongo command-result plan, or any plan whose result is iterated, awaited as an array, indexed, decoded, or otherwise read belongs on `query`.
| 8.0.0-rc.1 | 8.0.0-rc.2 |
| --- | --- |
| `await runtime.execute(rowPlan)` | `await runtime.query(rowPlan)` |
| `runtime.execute(rowPlan).toArray()` | `runtime.query(rowPlan).toArray()` |
| `await target.queryPrepared(prepared, params, options?)` | `await prepared.query(target, params, options?)` |
| `await runtime.execute(nonReturningWrite)` with ignored rows | `await runtime.execute(nonReturningWrite)` and ignore the returned statistics |
| A count or status derived from rows returned by a non-returning write | `const stats = await runtime.execute(writePlan)` and use `stats.affectedRows` |
Apply the same classification to connection and transaction scopes. `query()` and `prepared.query(target, params, options?)` remain lazy row results, so consume them inside the scope when their connection or transaction must remain valid. `execute()` is eager and resolves to `{ affectedRows: number }`; it does not return an iterable, and `affectedRows` must not be synthesized from a row array's length.
If the application defines runtime middleware, use the operation-specific hooks: query interception returns `{ rows }`, execute interception returns `{ stats }`, and completion handlers use their matching `afterQuery` or `afterExecute` result. `beforeQuery` / `interceptQuery` / `onRow` / `afterQuery` and `beforeExecute` / `interceptExecute` / `afterExecute` are distinct capabilities, while `beforeCompile` remains shared. Hook selection carries the operation distinction; contexts and results have no operation discriminator. Row-oriented middleware must not derive statistics from rows, and no compatibility aliases or generic fallback hooks are provided.
Tests that observe row queries should spy on `driver.query`, not `driver.execute`; statistics tests should observe `driver.execute`. Keep separate row-result and statistics queues so a wrong route fails loudly. Behavior intended for both operations assigns one private implementation to both corresponding hook names. Mongo keeps `db.query` as the static builder and has no row-execution `db.execute` facade method: build with `db.query`, obtain the connected runtime, then query through `(await db.runtime()).query(plan)`.
Search broadly for `.execute(` and retired prepared execution, then inspect each candidate's plan and downstream use. Rows being iterated, indexed, decoded, compared as arrays, or passed to a row mapper identify `query`; reads of `affectedRows` or ignored results from non-returning DML identify `execute`. Leave unrelated APIs such as migration runners alone.
## `raw-is-a-reserved-storage-namespace`
The SQL surface answers `db.sql.raw` with the whole-query raw statement tag, so `raw` is no
longer available as a storage namespace name. A contract that declares one is refused where the
client is built, before any query runs:
```text
ORM.NAMESPACE_RESERVED: The SQL surface exposes the raw statement tag as "db.raw", so a storage
namespace named "raw" cannot be reached through it. Rename the namespace in the schema.
```
Rename the namespace and re-emit:
```prisma
// Before: unreachable through the builder
model Event {
id String @id
@@schema("raw")
}
// After: any other name
model Event {
id String @id
@@schema("ingest")
}
```
Then re-emit the contract, and plan the rename against the database as you would any
other namespace rename — the physical schema still carries the old name until a plan moves it.
Only `raw` is reserved; no other namespace name is affected.
## `codec-ids-are-checked-where-they-are-authored`
Two places where you write a codec id by hand now check it against the codec map your
contract emitted: the declaration passed to `db.prepare(...)`, and `.returns(...)` on a raw
fragment built from a contract-bound tag.
```ts
// Before: compiled, then failed at execution with RUNTIME.PARAM_REF_MISSING_CODEC
await db.prepare({ id: 'pg/int4' }, (sql, params) => ...);
const upper = fns.raw`UPPER(${f.email})`.returns('pg/text');
// After: the id is the one your contract carries
await db.prepare({ id: 'pg/int4@1' }, (sql, params) => ...);
const upper = fns.raw`UPPER(${f.email})`.returns('pg/text@1');
```
The compile error is the messenger, not the injury: an id no codec registry carries could
never have executed. If a declaration or fragment of yours stops compiling, the id in it was
already wrong at runtime.
Read the correct spelling off your emitted `contract.d.ts`, or let the editor offer it — the
ids now complete at both positions, which is the other half of this change.
A raw fragment built through a contract-free lane keeps accepting any string: that lane has
no contract map to check an id against.
upgrading/app/upgrades/8.0.0-rc.10-to-8.0.0-rc.11/instructions.md
---
from: "8.0.0-rc.10"
to: "8.0.0-rc.11"
changes:
- id: engine-pin-moves-to-0-4-0
summary: |
The toolchain now requires `@prisma/cli-engine@0.4.0` (up from 0.3.0). Projects assembled
by the unified `prisma` CLI resolve the engine automatically. A project that pins
`@prisma/cli-engine` itself must move the pin to `0.4.0`. The new engine adds a
`--format markdown` output format to every command; nothing else in its public API changed.
detection:
glob: "**/package.json"
contains:
- '"@prisma/cli-engine": "0.3.0"'
- id: contract-artifacts-restamp
summary: |
The emitted `contract.json` / `contract.d.ts` embed the toolchain version, which moves
to 8.0.0-rc.11. Run `contract emit` once after upgrading so the emitted artifacts match
the installed toolchain.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.10"'
---
# 8.0.0-rc.10 → 8.0.0-rc.11 — User upgrade instructions
## `engine-pin-moves-to-0-4-0`
For every `package.json` matched by `detection`, change the `@prisma/cli-engine` version from `0.3.0` to `0.4.0` and reinstall. No code changes are needed: the engine only adds `--format markdown`, and every existing flag and output format keeps working.
## `contract-artifacts-restamp`
For every `contract.json` matched by `detection`, run the project's emit command (`prisma contract emit`, or the project's `contract:emit` script) once after upgrading. The only expected diff is the embedded `version` moving to `8.0.0-rc.11`.
upgrading/app/upgrades/8.0.0-rc.11-to-8.0.0-rc.12/instructions.md
---
from: "8.0.0-rc.11"
to: "8.0.0-rc.12"
# The Prisma 7 contract source adds `prisma7Schema` and the `examples/prisma7-adoption` example.
# The surface itself is new, so there is nothing to translate for it; the entries below cover the
# changes it made to paths every Postgres project already uses.
# contract.d.ts now orders every collection the way contract.json does; a re-emit reorders, nothing else.
changes:
- id: define-config-becomes-define-prisma-config
summary: |
Rename the engine config marker import in `prisma.config.ts` from `defineConfig` to
`definePrismaConfig`. Required: `@prisma/cli-engine@0.6.1` no longer exports the deprecated
`defineConfig` alias.
detection:
glob: "**/prisma.config.ts"
contains:
- "import { defineConfig } from '@prisma/cli-engine'"
- id: engine-pin-moves-to-0-6-1
summary: |
The toolchain now requires `@prisma/cli-engine@0.6.1` (up from 0.4.0). A project that pins `@prisma/cli-engine` itself must move the pin to `0.6.1`. The engine no longer exports the deprecated `defineConfig` alias, so `prisma.config.ts` must import `definePrismaConfig`.
detection:
glob: "**/package.json"
contains:
- '"@prisma/cli-engine": "0.4.0"'
- id: config-paths-resolve-from-declaring-file
summary: |
A relative path in `prisma.config.ts` now resolves from the directory of the config file that wrote it, not from the working directory. Commands run from the config file's directory behave as before. The engine also discovers `prisma.config.ts` files up to the repository root and merges them, with the nearest file's values winning.
- id: psl-schema-requires-use-prisma-8-directive
summary: |
`prisma contract emit` now refuses to load a PSL schema file that does not carry
`// use prisma-8` as its first line, failing with `PSL_NO_OPTED_IN_SCHEMA_FILES`. Add the
directive to every PSL schema file your `contract.source.inputs` matches.
detection:
glob: "**/*.prisma"
regex:
- '^(?!\s*// *use +(?:prisma-8|prisma-next)(?: *)(?!\S))'
script: ./scripts/multifile-psl/add-use-prisma-8-directive.mjs
- id: psl-model-names-table-verbatim
summary: |
A PSL `model` with no `@@map` now names its table (or Mongo collection) after the model
verbatim: `model UserProfile` maps to `"UserProfile"`, where it used to map to `"userProfile"`.
Run the colocated codemod over every `.prisma` file, including the `contract.prisma` copies
under `migrations/`, so each unmapped model gets `@@map("<current table name>")` and keeps the
table it already has. Emitted contracts, storage hashes, and migration history are unchanged
after the codemod. Planning without it fails with `MIGRATION.TABLE_NAME_CASE_CHANGED`
instead of dropping and recreating the table.
detection:
glob: "**/*.prisma"
regex:
- '\bmodel\s+[A-Za-z_][A-Za-z0-9_]*\s*\{'
script: ./scripts/psl-verbatim-table-names/add-model-map.mjs
- id: contract-infer-prints-emittable-defaults
summary: |
`contract infer` now prints each column default in the form `contract emit` accepts: an unquoted number with every digit for `Decimal` and `Numeric`, plain digits for a large `BigInt`, and a `sql` tagged literal for a list default holding a `NULL` element.
- id: infer-prints-a-literal-where-it-printed-dbgenerated
summary: |
`prisma contract infer` now prints a default as a literal wherever it can read the literal
back as the stored value, including forms it used to print as `dbgenerated("...")`. Re-running
infer produces different schema text for the same database. Nothing to fix; review the diff.
detection:
glob: "**/*.prisma"
contains:
- "dbgenerated("
- id: dbgenerated-removed-from-psl
summary: |
`@default(dbgenerated("..."))` is removed from PSL. `contract emit` refuses it with
`PSL_UNKNOWN_DEFAULT_FUNCTION`. Write a raw SQL default as the `sql` tagged literal, and a
value the column's data type writes as that literal.
detection:
glob: "**/*.prisma"
contains:
- "dbgenerated("
- id: default-sql-method-deprecated
summary: |
`.defaultSql('...')` on the TypeScript contract builder is deprecated and is removed at
8.0.0. Rewrite each call to `.default(...)` with `now()`, `autoincrement()`, or the `sql`
template tag.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\.defaultSql\('
- id: a-json-default-is-a-json-tag
summary: |
A `Json` or `Jsonb` column's default is written ``@default(json`{ "a": 1 }`)``. A quoted
string is refused: `pg/jsonb` casts from `pg/json`, not from `pg/text`.
detection:
glob: "**/*.prisma"
matches:
- '\b(Jsonb|Json)(\[\])?\??([ \t]+@[\w.]+(\([^)\n]*\))?)*?[ \t]+@default\([\s\[]*"'
- id: a-decimal-default-is-written-unquoted
summary: |
A `Decimal` or `Numeric` column's default is written as a number, not as a quoted string:
`@default(1.50)`. Trailing zeros are kept.
detection:
glob: "**/*.prisma"
matches:
- '\b(Decimal|Numeric)(\([^)]*\))?(\[\])?\??([ \t]+@[\w.]+(\([^)\n]*\))?)*?[ \t]+@default\([\s\[]*"'
- id: a-float-non-finite-default-is-written-bare
summary: |
A `Float` or `Real` column's default is written as a number, and `NaN`, `Infinity` and
`-Infinity` are written bare: `@default(NaN)`, not `@default("NaN")`.
detection:
glob: "**/*.prisma"
matches:
- '\b(Float|Real)(\[\])?\??([ \t]+@[\w.]+(\([^)\n]*\))?)*?[ \t]+@default\([\s\[]*"'
- id: a-json-list-default-is-one-json-literal
summary: |
A written list on a `Json` or `Jsonb` column that holds one value is refused. A JSON list
default is one JSON document: ``@default(json`[1, 2]`)``.
detection:
glob: "**/*.prisma"
matches:
- '\b(Jsonb|Json)\??([ \t]+@[\w.]+(\([^)\n]*\))?)*?[ \t]+@default\([ \t]*\['
- id: psl-number-defaults-keep-digits
summary: |
A PSL number `@default` on a `Decimal` or `Numeric` column now emits as decimal text with every digit (`"10"`, `"1.50"`) instead of a JSON number. Re-emitting such a contract changes its storage hash, so re-sign databases signed with the old contract. `BigInt` and `UnboundedInt` defaults beyond 2^53 now emit, and `contract infer` prints such `BigInt` defaults as plain numbers.
detection:
glob: "**/*.prisma"
regex:
- '@default\(\[?-?(\d|NaN|Infinity)'
- id: number-valued-64-bit-columns-store-their-default-as-digit-text
summary: |
This flags every contract that holds a `pg/int8number@1` or `sqlite/bigintnumber@1` column.
Only those columns that carry a literal default change form: the default is digit text now,
where it was a JSON number. A column with no default, or with a function default, is
unaffected. For an affected contract, re-run `prisma contract emit`, then `prisma db sign`.
detection:
glob: "**/contract.json"
contains:
- '"codecId": "pg/int8number@1"'
- '"codecId": "sqlite/bigintnumber@1"'
- id: client-generated-created-at-presets
summary: Re-emit contracts using creation timestamp presets and migrate their removed database defaults.
- id: re-emit-the-contract-for-the-moved-query-operation-types
summary: |
Emitted Postgres `contract.d.ts` files import `QueryOperationTypes` from the target package
instead of the adapter. Run `prisma contract emit` once; an un-emitted contract names a
subpath that no longer exists and stops type-checking. Application source that imported that
subpath directly changes the same way.
detection:
# Covers the emitted `contract.d.ts` and hand-written source alike: both
# name the subpath, and both stop compiling until they are changed.
glob: "**/*.{ts,tsx,mts}"
contains:
- "/adapter/operation-types"
- id: native-enum-columns-have-no-text-operations
summary: |
A native Postgres enum column (`pg.enum(...)`) no longer offers `like`, `ilike`,
`fullTextMatches`, `fullTextRank` or `fullTextHeadline`, is no longer accepted as the text of
a tsquery parser, and cannot carry `@@fullTextIndex` or `fullTextIndex`. These calls used to
compile and then fail in Postgres. Now they fail to compile.
- id: re-emit-for-the-insert-conflict-skip-capabilities
summary: "The Postgres and SQLite adapters report two new capability keys, sql.insertOnConflictSkip and sql.insertOnConflictWithoutTarget, which the new createAll/createAndCount option { onConflict: 'skip' } requires; a contract emitted before this release does not carry them and the option is refused against it, so re-emit the contract before using it."
detection:
glob: "**/contract.json"
contains:
- '"defaultInInsert"'
- id: params-only-sql-facade-prepare
summary: Replace injected SQL-builder preparation callbacks with params-only callbacks and lexical facade SQL access.
- id: postgres-target-owned-list-framing
summary: |
PostgreSQL list result decoding is target-owned; direct driver reads now expose raw array literals, and fixed-scale numeric arrays return database-normalized decimal text such as `"1.5000000000"`.
- id: postgres-verify-reads-more-default-spellings
summary: |
`db verify` now reads negative and cast numerals, enum literals cast to a type in another schema, zoneless `timestamp` literals, and `ARRAY[...]` list defaults as the values they are. Columns previously reported as drift verify clean; no contract or database change is needed.
- id: postgres-introspection-pinned-session
summary: |
Introspection now reads defaults, check constraints, index predicates, and policy text with `TimeZone = UTC`, `DateStyle = ISO, MDY`, and `IntervalStyle = postgres`, restoring the caller's settings. A contract inferred earlier from a server outside UTC that holds a `timestamptz` constant in such text shows that text once as a difference.
- id: source-load-failure-carries-diagnostics
summary: |
A contract source that fails to load now reports each finding in a `diagnostics` array on `CONTRACT.SOURCE_LOAD_FAILED`, alongside the existing `meta.diagnostics` and `meta.issues`.
- id: migration-new-defaults-to-the-db-ref
summary: |
Without `--from`, `migration new` now starts from the `db` ref, or from an empty database when there are no migrations, instead of from the newest migration. With migrations on disk and no `db` ref it refuses with `MIGRATION.PLAN_ORIGIN_UNKNOWN`. Scripts that relied on the old default must pass `--from`.
detection:
glob: "**/{package.json,*.sh,*.yml,*.yaml}"
contains:
- "migration new"
- id: migration-tip-error-codes-removed
summary: |
`MIGRATION.AMBIGUOUS_TARGET`, `MIGRATION.NO_TARGET` and `MIGRATION.NO_INITIAL_MIGRATION` are removed, and `graphTip` / `graphTipHash` are gone from error `meta`. A migration history with two branches now reports the real error, such as `MIGRATION.HASH_NOT_IN_GRAPH`.
detection:
glob: "**/*.{ts,tsx,js,mjs,cjs}"
matches:
- 'AMBIGUOUS_TARGET|NO_TARGET|NO_INITIAL_MIGRATION|graphTip'
- id: contract-artifacts-restamp
summary: |
The emitted `contract.json` / `contract.d.ts` embed the toolchain version, which moves
to 8.0.0-rc.12. Run `contract emit` once after upgrading so the emitted artifacts match
the installed toolchain.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.11"'
---
# 8.0.0-rc.11 → 8.0.0-rc.12 — User upgrade instructions
## `define-config-becomes-define-prisma-config`
For every `prisma.config.ts` matched by `detection`, rename the engine import and its call site from `defineConfig` to `definePrismaConfig`:
```ts
// before
import { defineConfig } from '@prisma/cli-engine';
export default defineConfig({ ... });
// after
import { definePrismaConfig } from '@prisma/cli-engine';
export default definePrismaConfig({ ... });
```
`definePrismaConfig` has been the engine's name for the marker since `@prisma/cli-engine@0.2.0`. `defineConfig` was a deprecated alias, and `@prisma/cli-engine@0.6.1` removes it, so this rename is required before the engine pin moves (`engine-pin-moves-to-0-6-1`, next). Leave any `defineConfig` imported from a product package (for example `@prisma/orm-postgres/config`) untouched — those helpers keep their name. The engine function's behaviour is identical; only the name changes.
## `engine-pin-moves-to-0-6-1`
For every `package.json` matched by `detection`, change the `@prisma/cli-engine` version from `0.4.0` to `0.6.1` and reinstall. Projects assembled by the `prisma` CLI resolve the engine automatically.
The engine removed the deprecated `defineConfig` alias. If `prisma.config.ts` still imports `defineConfig` from `@prisma/cli-engine`, apply `define-config-becomes-define-prisma-config` (the previous entry) in the same upgrade.
## `config-paths-resolve-from-declaring-file`
No change is needed for a project that runs commands from the directory holding its `prisma.config.ts`. A script that runs `prisma ... --config <path>` from another directory and relied on relative paths resolving from the working directory must drop that workaround: `contract`, `contract.output`, and `migrations.dir` now resolve from the config file's own directory.
A project inside a repository whose root, or any directory between the root and the project, holds another `prisma.config.ts` now inherits that file's values for any key the project's own config does not set. If the project must not inherit from a parent config, add `parent: false` to the project's own `prisma.config.ts`, which stops the search at that file, or remove the stray parent config.
## `psl-schema-requires-use-prisma-8-directive`
Every PSL schema file `prisma contract emit` reads must now carry `// use prisma-8` as its literal first line (before any other comment or whitespace beyond leading blank lines). A file a configured glob matches but that lacks the directive is silently excluded from the emitted contract; if excluding it would leave zero opted-in files, emission fails outright:
```
CONTRACT.SOURCE_LOAD_FAILED
why: No schema file carries the "// use prisma-8" directive
PSL_NO_OPTED_IN_SCHEMA_FILES: None of the matched files carry the "// use prisma-8" directive: <path>
```
From the project root, run the codemod that sits next to this guide over every PSL schema file `contract.source.inputs` names (a plain path or a glob). `<skill>` is the directory of the synced `prisma-8` skill:
```bash
node <skill>/upgrading/app/upgrades/8.0.0-rc.11-to-8.0.0-rc.12/scripts/multifile-psl/add-use-prisma-8-directive.mjs 'prisma/**/*.prisma'
```
It inserts `// use prisma-8` followed by a blank line at the top of every matched file that lacks the directive (or its earlier `// use prisma-next` spelling); a file that already carries either form is left untouched, so running it twice is a no-op. This is the same directive `orm init` already scaffolds into a fresh project.
The directive is content, not configuration: it does not change `contract.source.inputs`, the emitted `contract.json`, or any migration history. Re-run `prisma contract emit` afterward to confirm the schema loads.
## `psl-model-names-table-verbatim`
A PSL `model` with no `@@map` used to name its table, or its Mongo collection, after the model with the first letter lowered: `model UserProfile` read and wrote `"userProfile"`. It now uses the model name verbatim, `"UserProfile"`, the same rule every other Prisma 8 authoring surface already followed. Every model without `@@map` therefore points at a table that does not exist yet, so the schema must say which table it means.
From the project root, run the codemod that sits next to this guide once over every schema file, including the `contract.prisma` copy inside each migration directory. `<skill>` is the directory of the synced `prisma-8` skill. The script exits with an error if no file matches:
```bash
node <skill>/upgrading/app/upgrades/8.0.0-rc.11-to-8.0.0-rc.12/scripts/psl-verbatim-table-names/add-model-map.mjs '**/*.prisma'
```
It adds `@@map("<model name with its first letter lowered>")` as the last line of every `model` block that has no `@@map`, keeps the file's indentation and line endings, leaves models that already have `@@map` alone, and leaves a variant with `@@base(...)` and no `@@map` alone because it shares its base's table. It never descends into `node_modules` or `dist`, prints every model it mapped as `<file>: model <Name> -> @@map("<name>")`, and is idempotent. If a `model` block is written in a shape it cannot read it prints `<file>:<line>: model block not understood` and exits 1; add the `@@map` to that block by hand.
The codemod cannot see storage. It is for schemas written against the previous release only, where every unmapped model's table was created with its first letter lowered. Run it once, before you re-run `contract infer`, and never on a schema that was inferred or written after upgrading: such a schema already names its tables verbatim, and the codemod would point each unmapped model at a lowercase table that does not exist. Read the printed list and remove the `@@map` from any model whose table already has the verbatim name.
Then run the project's emit command (`prisma contract emit`, or its `contract:emit` script) and check that `contract.json` did not change; `prisma db verify --schema-only` against the database must also be clean. An unchanged `contract.json` proves the codemod was run on the right schema: if it changed, or verify reports the lowercase tables as missing, the schema was already verbatim, so revert the codemod's edits. Storage hashes, migration history, and refs are unchanged after a correct run, so no `db sign`, migration, or data move is needed.
In this upgrade, other entries in this guide also change `contract.json` (the `version` stamp, and the defaults some later entries name). Emit also fails while the schema still holds a form this release refuses, such as `dbgenerated(...)`, until a later entry rewrites it. In either case, run this check after those entries, and compare the table names in `contract.json` rather than the whole file.
If you plan a migration (`prisma migration plan`, `prisma db update`, `prisma migrate`) without running the codemod, planning fails instead of dropping the table:
```text
✘ [MIGRATION.PLANNING_FAILED] Migration planning failed
why: MIGRATION.TABLE_NAME_CASE_CHANGED: table "UserProfile" would be created and table "userProfile" dropped. Prisma 8 changed the default table name: a model with no @@map now names its table verbatim, so model UserProfile points at "UserProfile" instead of "userProfile".
→ To keep table "userProfile" and its rows, add @@map("userProfile") to model UserProfile (or run the add-model-map codemod over the schema) and plan again. Prisma 8 has no rename-table operation, so a deliberate rename is done by hand: run ALTER TABLE "userProfile" RENAME TO "UserProfile" (schema-qualified where applicable), after which the plan is empty.
```
The conflict fires for each pair where the table to drop equals the table to create with its first letter lowered, in the same namespace, whatever the columns. It does not fire on an empty database or on tables the contract's control policy marks `external` or `observed`. Mongo has no planner and gives no error: an unmapped model silently reads and writes an empty `UserProfile` collection while the documents stay in `userProfile`, so run the codemod before deploying.
To adopt the verbatim names on purpose instead of mapping, rename the storage by hand and skip the codemod for those models. Postgres and SQLite: `ALTER TABLE "userProfile" RENAME TO "UserProfile"` (`ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"` inside a schema). Mongo: `db.userProfile.renameCollection("UserProfile")`. After the rename the migration plan is empty and `db verify` is clean.
`contract infer` follows the same rule: a table whose name already equals the model name (`"UserProfile"`, `"User"`) infers to a model with no `@@map` and verifies clean, where the previous output pointed the model at a lowercase table that did not exist. A snake_case table still infers with `@@map("user_profile")`. There is nothing to detect for this: the inferred text for such a table is the same as before, it is now correct.
## `contract-infer-prints-emittable-defaults`
No edit to an existing contract. This changes what `prisma contract infer` writes the next time you run it.
Each default is now printed in the form `contract emit` reads back, so an inferred `contract.prisma` emits without hand-editing:
- a `Decimal` or `Numeric` default is printed as an unquoted number that keeps every digit (`@default(1.50)`); a quoted form is refused, as `a-decimal-default-is-written-unquoted` describes;
- a `BigInt` default beyond ±(2^53 − 1) is printed as its digits instead of `dbgenerated(...)`;
- `NaN`, `Infinity` and `-Infinity` are printed bare (`@default(NaN)`), as `a-float-non-finite-default-is-written-bare` describes;
- a list default holding a `NULL` element is printed as `` @default(sql`<expression>`) `` (`dbgenerated(...)` is removed in this release; see `dbgenerated-removed-from-psl`), because no PSL list literal spells a null element. Earlier the default was dropped in silence and the column was emitted without it. `contract emit` stops at such a field with a diagnostic; edit the field or drop the default from the inferred file.
If you keep an inferred contract in version control, re-run `contract infer`, review the diff for these spellings, and re-emit. The stored defaults in the database do not change.
## `infer-prints-a-literal-where-it-printed-dbgenerated`
`prisma contract infer` classifies a stored default with the same rules a written value uses, prints it with the same authoring entry, and reads the text straight back to prove it returns the stored value. A default it can read back is now printed as a literal, including forms it used to print as `dbgenerated("...")` or as a quoted string:
| Column in the database | Before | After |
| --- | --- | --- |
| `jsonb NOT NULL DEFAULT '{}'::jsonb` | `@default(dbgenerated("'{}'::jsonb"))` | ``@default(json`{}`)`` |
| `jsonb DEFAULT 'null'::jsonb` | `@default(dbgenerated("'null'::jsonb"))` | ``@default(json`null`)`` |
| `timestamp(3) NOT NULL DEFAULT '2024-01-01 00:00:00'` | `@default(dbgenerated("'2024-01-01 00:00:00'::timestamp without time zone"))` | `@default("2024-01-01 00:00:00")` |
| `numeric(65,30) DEFAULT -0.5` | `@default("-0.5")` | `@default(-0.5)` |
| `numeric(10,2) NOT NULL DEFAULT 1.50` | `@default("1.50")` | `@default(1.50)` |
| `float8 DEFAULT 'NaN'` | `@default("NaN")` | `@default(NaN)` |
| `timestamp(3)[] DEFAULT ARRAY['2024-01-01 00:00:00'::timestamp(3)]` | `@default(dbgenerated("ARRAY[...]"))` | `@default(["2024-01-01 00:00:00"])` |
This is not a break to fix. The contract is the same; only the schema text differs. Re-run `prisma contract infer`, read the diff, and commit the new text. A default whose value the codec cannot read back, such as `NULL::character varying`, prints as `` @default(sql`<expression>`) `` (`dbgenerated(...)` is removed in this release; see `dbgenerated-removed-from-psl`), so infer never prints a schema that emit cannot read.
## `dbgenerated-removed-from-psl`
`@default(dbgenerated("<expression>"))` no longer parses. Every use is reported at its span as `PSL_UNKNOWN_DEFAULT_FUNCTION` with the message `` Default function "dbgenerated" was removed. Write the SQL as a tagged literal: @default(sql`<expression>`). Supported functions: ... ``. `prisma contract infer` no longer prints it either: a raw expression prints as a `sql` tagged literal, and a value the column's data type writes prints as that literal.
In PSL, a raw SQL column default is written as a tagged literal, ``@default(sql`...`)`` or `@default(sql"...")`. Rewrite each use by what the expression is:
| You wrote | Write instead |
| --- | --- |
| `@default(dbgenerated("gen_random_uuid()"))` | `` @default(sql`gen_random_uuid()`) `` |
| `@default(dbgenerated("now()"))`, `@default(dbgenerated("CURRENT_TIMESTAMP"))` on Postgres | `@default(now())` |
| `@default(dbgenerated("autoincrement()"))`, `@default(dbgenerated("nextval('<seq>'::regclass)"))` on a serial column | `@default(autoincrement())` |
| `@default(dbgenerated("'<json>'::jsonb"))` on a `Json` or `Jsonb` column | `` @default(json`<json>`) `` |
| `@default(dbgenerated("'<member>'::<enum type>"))` on a column typed by that enum | `@default("<member>")` |
| `@default(dbgenerated("'<text>'::text"))` on a text column | `@default("<text>")` |
| `@default(dbgenerated("<anything else>"))` | `` @default(sql`<anything else>`) `` |
The `now()` and `autoincrement()` rows are required, not a matter of style: `` sql`now()` `` and `` sql`autoincrement()` `` are refused with `PSL_INVALID_DEFAULT_SQL`, because Prisma reads those two expressions as its own default functions. Every other expression, including `NOW()` written in capitals, is used exactly as written.
The mechanical rewrite for the last row is `@default(sql"<expression>")` with the argument text copied unchanged: the double-quote fence uses the same escapes as the `dbgenerated("...")` argument, so the contract cannot change. The backtick fence reads better for SQL; to use it, undo the quoted string's escaping (`\"` becomes `"`, `\n` becomes a line break), then write each backtick in the body as `` \` ``. Inside backticks every other backslash is kept as written, `\$` included, because PSL has no escape for `$`, and `${` is ordinary text. A body that contains a backtick is easiest to keep in the double-quote fence.
The JSON and enum rows change the emitted contract: the default becomes `{ kind: 'literal', value }` instead of `{ kind: 'function', expression }`, so the storage hash moves. Run `prisma contract emit`, then `prisma db verify`: the literal compares equal to the live default, so verify passes and no migration is needed. Every other row emits the same contract as before; the storage hash does not move.
Find the uses with `grep -rn "dbgenerated(" prisma/` (or wherever the schema lives).
## `default-sql-method-deprecated`
Rewrite every `.defaultSql('<expression>')` call by its expression:
| Call | Replacement | Import |
| --- | --- | --- |
| `.defaultSql('now()')` | `.default(now())` | `now` from the contract builder |
| `.defaultSql('autoincrement()')` | `.default(autoincrement())` | `autoincrement` from the contract builder |
| `.defaultSql('gen_random_uuid()')` | `` .default(sql`gen_random_uuid()`) `` | `sql` from the contract builder |
| `.defaultSql('<anything else>')` | `` .default(sql`<anything else>`) `` | `sql` from the contract builder |
There is no named helper for other database functions, so they use the `sql` tag, as `gen_random_uuid()` does above. `` sql`now()` `` and `` sql`autoincrement()` `` are refused in the TypeScript `sql` tag as they are in PSL, so those two must use the named form.
Copy the expression's value, not its source string: first undo the TypeScript string's own escaping, so `.defaultSql('it\'s')` contributes `it's`. Then write each backtick as `` \` ``. Write a backslash that precedes a dollar sign as `\\$`, because the tag reads `\$` as the escape for `$`. Every other backslash can be written as it is, or doubled; both give one backslash. An expression that contains `${` is written `\${` inside the `sql` tag, which resolves it back to the two characters; in PSL it is written as it is.
Import the helpers from the module the code already imports `defineContract`, `field`, and `model` from (`@prisma/orm-postgres/contract-builder`, `@prisma/orm-sqlite/contract-builder`, or the internal `@internal/sql-contract-ts/contract-builder`). Every form lowers to the same `{ kind: 'function', expression }` default, so the emitted contract does not change: re-run `prisma contract emit` and confirm the column defaults in `contract.json` are unchanged.
Find the uses with `grep -rn "defaultSql(" src/` (or wherever the contract is defined).
## `a-json-default-is-a-json-tag`
Every value written in PSL now has a data type of its own, decided by what is written rather than by the column. A quoted string is text, and a JSON column's type does not cast from text, so a quoted JSON default is refused with `PSL_DEFAULT_TYPE_INCOMPATIBLE`:
```text
Field "Account.meta": pg/jsonb has no cast from pg/text; it casts from pg/json
```
The `json` tag reads its body as a JSON document, which is what `pg/json` holds, and `pg/jsonb` casts from `pg/json`:
| Before | After |
| --- | --- |
| `meta Jsonb @default("{}")` | ``meta Jsonb @default(json`{}`)`` |
| `meta Jsonb @default("{\"plan\":\"free\"}")` | ``meta Jsonb @default(json`{ "plan": "free" }`)`` |
| `docs Jsonb[] @default(["{}"])` | ``docs Jsonb[] @default([json`{}`])`` |
| `meta Jsonb? @default("null")` | ``meta Jsonb? @default(json`null`)`` |
The body inside the tag is the JSON document itself, so it needs none of the escaping a PSL string needed. The backtick fence resolves `` \` `` and `\\` and nothing else, so `` json`{ "plan": "free" }` `` needs no escaping at all.
A backslash has to survive the fence and then JSON, so a JSON string that holds one backslash is written with four:
| In the schema | After the fence | JSON reads |
| --- | --- | --- |
| ``json`{ "re": "\\\\d+" }` `` | `{ "re": "\\d+" }` | the string `\d+` |
Two backslashes are not enough: the fence turns them into one, and `\d` is not a JSON escape, so the body is refused with `PSL_INVALID_JSON_LITERAL` — as is any other body that is not a JSON document.
## `a-decimal-default-is-written-unquoted`
A written number's data type comes from its own size and precision. Quoted digits are text, and `pg/numeric` does not cast from text:
```text
Field "Account.price": pg/numeric has no cast from pg/text; it casts from pg/int2, pg/int4, pg/int8
```
| Before | After |
| --- | --- |
| `price Decimal @default("1.50")` | `price Decimal @default(1.50)` |
| `price Numeric(10, 2) @default("-1.25")` | `price Numeric(10, 2) @default(-1.25)` |
| `prices Numeric(65, 30)[] @default(["-1.5", "2"])` | `prices Numeric(65, 30)[] @default([-1.5, 2])` |
The stored value does not change. Trailing zeros are kept (`1.50` stays `1.50`), leading zeros are dropped (`007.50` is `7.50`), and `-0.0` is `0.0` — the values these defaults always had.
## `a-float-non-finite-default-is-written-bare`
`NaN`, `Infinity` and `-Infinity` are number tokens in PSL, not identifiers and not text. A `Float` or `Real` column's type casts from the number types, not from text, so the quoted forms are refused with `PSL_DEFAULT_TYPE_INCOMPATIBLE`.
| Before | After |
| --- | --- |
| `ratio Float @default("NaN")` | `ratio Float @default(NaN)` |
| `ratio Float @default("-Infinity")` | `ratio Float @default(-Infinity)` |
| `ratio Real @default("NaN")` | `ratio Real @default(NaN)` |
| `ratios Float[] @default(["-1.5", "2"])` | `ratios Float[] @default([-1.5, 2])` |
## `a-json-list-default-is-one-json-literal`
A written list is several values, and the column takes it only when the column is a list or when the column's data type declares a list cast. `pg/json` and `pg/jsonb` declare none, so a written list on a column that holds one JSON value is refused:
```text
Field "Account.meta": pg/jsonb has no cast from a list; it casts from pg/json
```
A JSON list default is one JSON document, written inside the tag:
| Before | After |
| --- | --- |
| `meta Jsonb @default([1, 2])` | ``meta Jsonb @default(json`[1, 2]`)`` |
| `meta Jsonb @default([])` | ``meta Jsonb @default(json`[]`)`` |
A `Jsonb[]` column is unaffected: it is a list of JSON columns, and each element is written as its own `json` tag — ``docs Jsonb[] @default([json`{}`, json`[]`])``.
## `psl-number-defaults-keep-digits`
In every PSL schema matched by `detection`, look for number defaults on fields typed `Decimal`, `Numeric`, `Numeric(...)`, or a `types {}` alias of one of them, including list fields. If there are none, this entry changes nothing. The schema itself needs no edit.
Before you re-emit: if a database was signed with a contract holding such a default with more digits than a JavaScript number holds (for example `12345678901234567890.123456789`), `db verify` and `db sign` report a default mismatch on that column.
Run the project's emit command (`prisma contract emit`, or the project's `contract:emit` script). In `contract.json`, each such default becomes decimal text: `@default(10)` becomes `"10"`, `@default(1.50)` becomes `"1.50"`, `@default(007)` becomes `"7"`, `@default(-0)` becomes `"0"`, a long value keeps every digit, and `@default(NaN)` becomes `"NaN"` instead of `null`. The storage hash changes. The column default in the database does not, so no migration is needed.
Then run `prisma db sign` against the regenerated contract for every database signed with the old one. Until you do, `db verify` reports a hash mismatch and the application logs `CONTRACT.MARKER_MISMATCH`.
`db init` can now create these defaults; before, it failed with `pg/numeric@1 database JSON value must be a decimal string`. `BigInt` and `UnboundedInt` defaults within ±(2^53 − 1) emit exactly as before. Larger ones now emit instead of failing, and `contract infer` prints such `BigInt` defaults as plain numbers instead of `dbgenerated(...)`.
## `number-valued-64-bit-columns-store-their-default-as-digit-text`
Every codec of one data type now stores and reads that type's one canonical form. `pg/int8` stores digit text, so `pg/int8number@1` — the codec behind `BigIntNumber`, which reads a 64-bit integer as a JavaScript `number` — stores digit text too, where it used to store a JSON number. `sqlite/bigintnumber@1` changed the same way.
The detection flags every contract holding such a column, because a JSON file gives no reliable way to ask for the two facts together. A column is affected only when both are true: its codec is `pg/int8number@1` or `sqlite/bigintnumber@1`, **and** it carries a literal default. A column with no default, or with a function default, is unaffected — read the flagged file and check. In `contract.json` an affected column reads:
```json
"viewCount": {
"codecId": "pg/int8number@1",
"default": { "kind": "literal", "value": 10 },
"nativeType": "int8",
"nullable": false
}
```
and becomes:
```json
"viewCount": {
"codecId": "pg/int8number@1",
"default": { "kind": "literal", "value": "10" },
"nativeType": "int8",
"nullable": false
}
```
Re-run `prisma contract emit` to rewrite `contract.json`, then `prisma db sign` so the signature matches the new contract. Nothing in the schema changes, and nothing in the database changes.
No example in this repository has such a column, so a project is affected only if its own contract holds one.
## `client-generated-created-at-presets`
Creation timestamp presets now use the client clock. For schemas using `temporal.createdAt()`, `temporal.createdAtString()`, or `temporal.createdAtJsDate()` (including the matching `field.temporal.*` TypeScript helpers), re-emit the contract with `prisma contract emit`. Review and apply a migration that removes the corresponding database defaults so the database matches the new storage contract. The creation timestamp now uses the same client-side generator as its matching update preset; it is still set only on create.
Update direct SQL writers to supply these required timestamps once their database defaults are removed. If database-generated time is intentional, replace the convenience preset with an explicit timestamp type and `@default(now())` in PSL, or an explicit column with `.default(now())` in TypeScript (`.defaultSql('now()')` is deprecated; see `default-sql-method-deprecated`). Preserve the original native type, precision, and codec representation. Explicit database defaults retain their existing behavior.
For Temporal-backed creation presets, provide a global `Temporal` implementation before writes as well as reads, for example `import 'temporal-polyfill/full/global'` when the runtime lacks native support. String and JavaScript Date presets do not require Temporal for their clock.
## `re-emit-the-contract-for-the-moved-query-operation-types`
The built-in Postgres query operations (`ilike`, and the new `fullTextMatches`, `fullTextRank` and `fullTextHeadline`) are contributed by the Postgres target rather than the Postgres adapter. Emitted contract types follow: the generated line
```ts
import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma/orm-postgres/adapter/operation-types';
```
becomes
```ts
import type { QueryOperationTypes as PgTargetQueryOps } from '@prisma/orm-postgres/target/operation-types';
```
Run `prisma contract emit` and commit the regenerated `contract.d.ts`. Nothing else in the file changes, `contract.json` does not change, and no contract hash moves. Do not hand-edit the generated file.
Application code that imported `@prisma/orm-postgres/adapter/operation-types` directly imports `@prisma/orm-postgres/target/operation-types` instead. That subpath no longer exists; there is no compatibility re-export.
To use the new operations, index the column with `@@fullTextIndex`, which renders the `to_tsvector` expression the predicate needs:
```prisma
model Message {
id Int @id
text String
@@fullTextIndex([text], name: "message_text_search")
}
```
In a TypeScript contract, use the matching helper inside the model's `sql({ indexes: [...] })`:
```ts
import { fullTextIndex } from '@prisma/orm-postgres/contract-builder';
model('Message', { fields: { id, text } }).sql(({ cols }) => ({
indexes: [
fullTextIndex(cols.text, { name: 'message_text_search' }),
fullTextIndex(cols.text, { where: 'archived_at IS NULL', name: 'message_text_search_live' }),
],
}));
```
The first argument of each operation is a `tsquery`, built with a helper from `@prisma/orm-postgres/target/full-text`. A bare string is a type error. Use `websearchToTsquery(query)` for search-box text; `plaintoTsquery` requires every word and `phrasetoTsquery` the words in order. In the SQL builder these four parsers are also `fns` members. For user input inside `tsquery` operator syntax, such as a typeahead prefix match, use the `tsquery` tag: `` tsquery`${term}:*` `` quotes each interpolated value as one term, so user input cannot add operators or break the syntax, and Postgres then lowercases and stems the words. A value with several words becomes a phrase: `` tsquery`${'new y'}:*` `` gives `'new':* <-> 'y':*`, so the words must be adjacent and in order, and `:*` applies to each word. Do not put quotes around the interpolation yourself: `` tsquery`'${term}':*` `` is a syntax error for every input. `toTsquery` takes operator syntax the application writes in full; never pass user input to it.
The operations also take an options object as their second argument — `language` for all three, plus `normalization` and `coverDensity` on `fullTextRank` and the `ts_headline` options (`startSel`, `stopSel`, `maxWords`, `minWords`, `highlightAll`) on `fullTextHeadline`:
```ts
import { tsquery, websearchToTsquery } from '@prisma/orm-postgres/target/full-text';
const q = websearchToTsquery(query);
await db.orm.public.Message.select('id', 'text')
.where((m) => m.text.fullTextMatches(q))
.orderBy((m) => m.text.fullTextRank(q, { normalization: 32 }).desc())
.all();
const suggestions = await db.orm.public.Message.select('id', 'text')
.where((m) => m.text.fullTextMatches(tsquery`${term}:*`))
.all();
```
The operation's `language` configures only the searched column. The parser or tag takes its own `language` for the query; pass the same value to both.
Postgres only uses a full-text index whose expression is the same `to_tsvector` over the same configuration literal and the same column as the query, so prefer these over writing `@@index(expression: "to_tsvector(…)", type: "gin", …)` by hand. Pass the same `language` to the index and to the operation: a mismatch is silent, and the query falls back to a sequential scan.
## `native-enum-columns-have-no-text-operations`
Postgres has no `LIKE`, `ILIKE` or `to_tsvector` for an enum type. Every one of these calls on a native enum column failed when the query ran, with `operator does not exist` or `function ... does not exist`. A `@@fullTextIndex` on a native enum column failed when the migration ran. So code that uses them never worked. It is now a type error, and `@@fullTextIndex` / `fullTextIndex` on a native enum column is refused when the contract is built (`PSL_FULL_TEXT_INDEX_TEXT_FIELD` in PSL, `CONTRACT.INDEX_INVALID` in TypeScript).
Replace a pattern match on an enum with an equality check on its members:
```ts
// before: failed at runtime
db.orm.public.Ticket.where((t) => t.status.ilike('open%'));
// after
db.orm.public.Ticket.where((t) => t.status.in(['open', 'reopened']));
```
Remove any `@@fullTextIndex` or `fullTextIndex` on a native enum column. If you really need a pattern search over the enum labels, write that query through the raw SQL lane (``db.raw.sql`…` ``) and cast the column to text there, for example `status::text ILIKE $1`.
Nothing else about native enum columns changes: `eq`, `in`, ordering, and `min`/`max` work as before. A PSL `enum` block stored as text (`@@type("pg/text@1")`) is a text column and keeps every text operation.
## `re-emit-for-the-insert-conflict-skip-capabilities`
`createAll` and `createAndCount` on the SQL ORM client take a new option in second position that asks the database to skip rows colliding with a unique constraint:
```ts
const inserted = await db.orm.User.createAll(rows, { onConflict: 'skip' });
const added = await db.orm.User.createAndCount(rows, {
onConflict: 'skip',
conflictOn: ['email'],
});
```
The option requires two capability keys that the Postgres and SQLite adapters now report: `sql.insertOnConflictSkip`, and `sql.insertOnConflictWithoutTarget` for the untargeted form. A `contract.json` emitted before this release carries neither, so the ORM refuses the option with `ORM.CAPABILITY_MISSING` before running any statement.
Re-emit your contract to pick up the keys:
```console
prisma contract emit
```
Nothing else changes. The keys are additive, the storage hash does not move, and every existing call — including `createAll(rows, configure)` with the annotation callback in second position — behaves exactly as before. You only need to re-emit if you want to use the new option.
## `params-only-sql-facade-prepare`
Find calls to `prepare(declaration, callback)` on clients created by the Postgres or SQLite facade (`@prisma/orm-postgres/runtime`, `@prisma/orm-sqlite/runtime`, or their `@internal/postgres/runtime` and `@internal/sqlite/runtime` counterparts). Resolve the receiver and callback rather than rewriting every method named `prepare`: native SQLite `database.prepare(sql)` and SQL runtime's existing params-only preparation are different APIs and must remain unchanged.
Change callbacks from `(sql, params) => ...` to `(params) => ...`. Replace references bound to the removed `sql` callback argument with the same facade receiver's lexical `.sql` property. Preserve the params argument's name, declaration, SQL chain, row selection, filters and invocation target/options. For extracted callbacks, capture the same client in the enclosing scope; do not capture an invocation target or evaluate the callback twice. Update explicit callback type annotations to accept only the placeholder-params argument.
```ts
// Before
const query = await db.prepare({ id: 'pg/int4@1' }, (sql, params) =>
sql.public.users.select('id').where((f, fns) => fns.eq(f.id, params.id)).build(),
);
// After
const query = await db.prepare({ id: 'pg/int4@1' }, (params) =>
db.sql.public.users.select('id').where((f, fns) => fns.eq(f.id, params.id)).build(),
);
```
Apply the same translation to SQLite's flat SQL facade (`sql.users` becomes `db.sql.users`), retaining its existing codec ids. Keep `.query(target, params, options?)` and SQL statistics `.execute(target, params, options?)` calls unchanged. Do not rewrite historical release notes, applied upgrade recipes, generated contracts or tests as part of this source translation.
## `postgres-target-owned-list-framing`
Review application code and snapshots that assert exact PostgreSQL list result spellings. Ordinary Prisma 8 runtime reads still return JavaScript arrays, and builtin and enum lists now use the same raw-text-to-element-codec path. If you assert `Decimal[]` / `numeric[]` strings for fixed-scale columns, update those expectations to PostgreSQL's database-normalized scale: a `numeric(30,10)[]` element inserted as `1.5` reads as `"1.5000000000"`; scalar numeric decoding already follows this text-preserving policy. If you use lower-level Postgres driver direct-query rows, parse raw PostgreSQL array literal strings such as `'{a,b}'` instead of expecting registered builtin arrays to arrive as JavaScript arrays. Do not re-emit contracts solely for this change: codec ids, `typeParams`, and `CodecRef.many` stay unchanged.
## `postgres-verify-reads-more-default-spellings`
No code or schema edit. Run `prisma db verify` once against each Postgres database and read the result.
Postgres prints a column default in spellings the reader did not all recognise. It now reads a negative or cast numeral (`'-1'::integer`, `(5)::smallint`) as the number, an enum literal cast to a type in another schema (`'confidential'::auth.oauth_client_type`) as the enum value, a zoneless `timestamp` literal as that timestamp, and an `ARRAY[...]` default of text, boolean, integer, bigint, float, decimal, timestamp, or enum elements — with the casts Postgres prints, and an empty `VARCHAR(n)[]` — as the list. An element that is an expression or a function call still stays a raw expression.
The effect is one-way: columns that were reported as drift on these spellings now report nothing. No column that verified before starts failing. If a test pins the exact findings `db verify` returns for such a column, remove that expectation rather than adjusting it.
The same rendering is now used when planning a migration, so a list literal default is written with its cast (`ARRAY['1', '-2']::int8[]`). Update snapshots of planned DDL that hold the uncast form.
## `postgres-introspection-pinned-session`
No code or schema edit. This matters only if you have a committed contract that was inferred from a server whose session time zone was not UTC.
Every introspection read now runs with `TimeZone = UTC`, `DateStyle = ISO, MDY`, and `IntervalStyle = postgres`, and the caller's settings are restored afterwards, so the text read back no longer depends on the server, the role, or the caller. Postgres prints a `timestamptz` constant in the session time zone, so a check constraint or index predicate whose text contains one was recorded in that server's zone.
Run `prisma db verify`. If a check constraint or an index predicate is reported as different and the only difference is the time zone written into a `timestamptz` constant, that is this change. Re-emit and re-sign once; the new text is stable from then on. Column defaults are compared as instants where they can be, so most of them are unaffected.
## `source-load-failure-carries-diagnostics`
Only for code or agents that read the CLI's `--json` output.
When a contract source fails to load, `CONTRACT.SOURCE_LOAD_FAILED` now carries a `diagnostics` array: one entry per finding, each with `code`, `summary`, `severity`, and, where the source gave a position, `where.path` and `where.line`. A finding whose source code is dotted (`PSL.PRISMA7_VIEW_UNSUPPORTED`) carries that code directly. A finding whose source code is an undotted legacy code (`PSL_INVALID_DECLARATION`) carries `CONTRACT.SOURCE_DIAGNOSTIC` with the original code in `meta.code` and at the start of the summary.
Nothing is removed: `meta.diagnostics` and `meta.issues` carry what they carried before. Read `diagnostics` in new code, and leave existing readers alone until you want the codes.
## `migration-new-defaults-to-the-db-ref`
`migration new` now picks its starting point the same way `migration plan` does. For every file matched by `detection` that runs `migration new` without `--from`:
- If the project keeps a `db` ref that points at a migration, nothing changes in practice: the command starts there.
- If the project has migrations on disk but no `db` ref, the command now refuses with `MIGRATION.PLAN_ORIGIN_UNKNOWN`. Add `--from <contract hash or ref>` naming the migration to build on; `prisma migration list` shows the hashes.
- If the migration history is empty and a `db` ref exists, the command refuses and points at `migration plan`, which writes the baseline. Run `migration plan` first.
## `migration-tip-error-codes-removed`
The CLI no longer looks for a single newest migration, so the errors that came from that lookup are gone. For every file matched by `detection`, remove handling of `MIGRATION.AMBIGUOUS_TARGET`, `MIGRATION.NO_TARGET` and `MIGRATION.NO_INITIAL_MIGRATION`, and stop reading `graphTip` or `graphTipHash` from an error's `meta`. A command that used to fail with one of those codes on a migration history with two branches now fails with the error that describes the actual problem, for example `MIGRATION.HASH_NOT_IN_GRAPH` for a hash that is not in the history, or `MIGRATION.MARKER_MISMATCH` for a database marker outside it.
## `contract-artifacts-restamp`
For every `contract.json` matched by `detection`, run the project's emit command (`prisma contract emit`, or the project's `contract:emit` script) once after upgrading. This entry accounts for the embedded `version` moving to `8.0.0-rc.12`; any other difference in the emitted files comes from an earlier entry in this guide.
upgrading/app/upgrades/8.0.0-rc.11-to-8.0.0-rc.12/scripts/multifile-psl/add-use-prisma-8-directive.mjs
#!/usr/bin/env node
/**
* Adds `// use prisma-8` as the first line of every PSL schema file that
* lacks it, matching the directive `isPrismaNextSchema` checks for
* (`@internal/psl-parser`). A file that already carries the directive (or
* its legacy `// use prisma-next` spelling) is left untouched.
*
* Usage:
* node scripts/multifile-psl/add-use-prisma-8-directive.mjs <file-or-glob> [...more]
*
* Globs never descend into `node_modules` or `dist`.
*
* Every matched file is printed as `<file>: added directive` (or `already
* present`) for review. Running it twice is a no-op.
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { glob } from 'node:fs/promises';
import { argv, exit, stderr, stdout } from 'node:process';
import { fileURLToPath } from 'node:url';
const DIRECTIVE = /^(\s*\/\/ *use +)(prisma-8|prisma-next)( *)(?!\S)/;
async function expand(patterns) {
const files = [];
for (const pattern of patterns) {
for await (const match of glob(pattern, { exclude: ['**/node_modules/**', '**/dist/**'] })) {
files.push(match);
}
}
return files;
}
function addDirective(path) {
const original = readFileSync(path, 'utf8');
if (DIRECTIVE.test(original)) {
stdout.write(`${path}: already present\n`);
return;
}
writeFileSync(path, `// use prisma-8\n\n${original}`, 'utf8');
stdout.write(`${path}: added directive\n`);
}
async function main() {
const patterns = argv.slice(2);
if (patterns.length === 0) {
stderr.write('usage: add-use-prisma-8-directive.mjs <file-or-glob> [...more]\n');
exit(1);
}
const files = await expand(patterns);
if (files.length === 0) {
stderr.write('no files matched\n');
exit(1);
}
for (const file of files) addDirective(file);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
await main();
}
upgrading/app/upgrades/8.0.0-rc.11-to-8.0.0-rc.12/scripts/psl-verbatim-table-names/add-model-map.mjs
#!/usr/bin/env node
/**
* Adds `@@map("<model name with its first letter lowered>")` to every PSL
* `model` block that has no `@@map`, so a schema written before Prisma 8 made
* model names verbatim keeps the table (or collection) names it already has.
*
* Usage:
* node <path to this file> <file-or-glob> [...more]
*
* Globs never descend into `node_modules` or `dist`.
*
* A model with `@@base(...)` and no `@@map` shares its base's storage, so it
* is left alone: adding `@@map` there would split it into its own table or
* collection.
*
* Files are rewritten in place; every mapped model is printed as
* `<file>: model <Name> -> @@map("<name>")` for review. Running it twice is a
* no-op. Only run it on a schema written against the previous release: a
* schema inferred after upgrading already names its tables verbatim.
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { glob } from 'node:fs/promises';
import { argv, exit, stderr, stdout } from 'node:process';
import { fileURLToPath } from 'node:url';
const MODEL_LINE = /^\s*model\s/;
const MODEL_START = /^(\s*)model\s+([A-Za-z_][A-Za-z0-9_]*)\s*(\{)?(.*)$/;
const OPEN_BRACE_LINE = /^\s*\{\s*(\/\/.*)?$/;
const BLANK_OR_COMMENT = /^\s*(\/\/.*)?$/;
const BLOCK_CLOSE = /^\s*\}\s*(\/\/.*)?$/;
const MAP_ATTRIBUTE = /^\s*@@map\s*\(/;
const BASE_ATTRIBUTE = /^\s*@@base\s*\(/;
const OWN_STORAGE_ATTRIBUTE = /@@(map|base)\s*\(/;
function lowerFirst(name) {
return name.charAt(0).toLowerCase() + name.slice(1);
}
function bodyIndent(lines, start, end, headerIndent) {
for (let i = start; i < end; i += 1) {
const match = /^(\s*)\S/.exec(lines[i]);
if (match) return match[1];
}
return `${headerIndent} `;
}
function stripCr(line) {
return line.replace(/\r$/, '');
}
/**
* Classifies the `model` line at `index`. Returns the header indent, the model
* name, the index of the first body line, any body text that shares the
* header line, and whether the whole block sits on this one line.
*/
function nextNonBlankIndex(lines, index) {
let i = index + 1;
while (i < lines.length && BLANK_OR_COMMENT.test(stripCr(lines[i]))) i += 1;
return i;
}
/**
* A `model` line is a declaration only when the block's `{` follows the model
* name on that line, or opens the next line that is not blank or a comment.
* Any other `model` line is a field named `model` inside some block and is
* left alone.
*/
function isModelDeclaration(lines, index) {
const match = MODEL_START.exec(stripCr(lines[index]));
if (!match) return false;
if (match[3] !== undefined) return true;
const next = lines[nextNonBlankIndex(lines, index)];
return next !== undefined && OPEN_BRACE_LINE.test(stripCr(next));
}
function readModelStart(lines, index) {
const match = MODEL_START.exec(stripCr(lines[index]));
if (!match) return undefined;
const [, indent, modelName, brace, rest] = match;
if (brace === undefined) {
if (!BLANK_OR_COMMENT.test(rest)) return undefined;
const braceAt = nextNonBlankIndex(lines, index);
const next = lines[braceAt];
if (next === undefined || !OPEN_BRACE_LINE.test(stripCr(next))) return undefined;
return { indent, modelName, bodyStart: braceAt + 1, inlineBody: '', singleLine: false };
}
const closeAt = closingBraceIndex(rest);
if (closeAt !== -1) {
return { indent, modelName, bodyStart: index + 1, inlineBody: rest, singleLine: true };
}
return {
indent,
modelName,
bodyStart: index + 1,
inlineBody: BLANK_OR_COMMENT.test(rest) ? '' : rest,
singleLine: false,
};
}
/** The line without a trailing `//` comment; a `//` inside a double-quoted string is content. */
function stripLineComment(text) {
let quoted = false;
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
if (quoted) {
if (char === '\\') i += 1;
else if (char === '"') quoted = false;
} else if (char === '"') {
quoted = true;
} else if (char === '/' && text[i + 1] === '/') {
return text.slice(0, i).trimEnd();
}
}
return text;
}
/** Index of a `}` that ends the block on this line (only whitespace or a `//` comment may follow), else -1. */
function closingBraceIndex(text) {
const code = stripLineComment(text);
const closeAt = code.lastIndexOf('}');
return closeAt !== -1 && /^\s*$/.test(code.slice(closeAt + 1)) ? closeAt : -1;
}
function addMapToSingleLineModel(line, modelName) {
const raw = stripCr(line);
const closeAt = closingBraceIndex(raw);
const body = raw.slice(0, closeAt);
if (OWN_STORAGE_ATTRIBUTE.test(body)) return line;
return `${body.trimEnd()} @@map("${lowerFirst(modelName)}") ${line.slice(closeAt)}`;
}
/** Thrown when a `model` declaration is written in a shape the codemod does not recognise. */
export class UnhandledModelError extends Error {
constructor(lineNumbers) {
super(`model block(s) not understood at line(s) ${lineNumbers.join(', ')}`);
this.lineNumbers = lineNumbers;
}
}
/**
* Rewrites `source` and also returns the models that received an `@@map`, so a
* caller can show the user what was mapped. The codemod cannot see storage:
* it assumes every unmapped model was written against the release whose
* default lowered the first letter.
*/
export function addModelMapsWithReport(source) {
const newline = source.includes('\r\n') ? '\r' : '';
const lines = source.split('\n');
const out = [];
const unhandled = [];
const mapped = [];
let i = 0;
while (i < lines.length) {
if (!MODEL_LINE.test(lines[i]) || !isModelDeclaration(lines, i)) {
out.push(lines[i]);
i += 1;
continue;
}
const start = readModelStart(lines, i);
if (start === undefined) {
unhandled.push(i + 1);
out.push(lines[i]);
i += 1;
continue;
}
if (start.singleLine) {
const rewritten = addMapToSingleLineModel(lines[i], start.modelName);
if (rewritten !== lines[i]) mapped.push(start.modelName);
out.push(rewritten);
i += 1;
continue;
}
let close = start.bodyStart;
while (close < lines.length && !BLOCK_CLOSE.test(lines[close])) close += 1;
if (close >= lines.length) {
unhandled.push(i + 1);
out.push(...lines.slice(i));
break;
}
const body = [start.inlineBody, ...lines.slice(start.bodyStart, close)];
out.push(...lines.slice(i, close));
if (!body.some((line) => MAP_ATTRIBUTE.test(line) || BASE_ATTRIBUTE.test(line))) {
const indent = bodyIndent(lines, start.bodyStart, close, start.indent);
out.push(`${indent}@@map("${lowerFirst(start.modelName)}")${newline}`);
mapped.push(start.modelName);
}
out.push(lines[close]);
i = close + 1;
}
if (unhandled.length > 0) throw new UnhandledModelError(unhandled);
return { source: out.join('\n'), mapped };
}
export function addModelMaps(source) {
return addModelMapsWithReport(source).source;
}
const SKIPPED_DIRECTORIES = /(^|\/)(node_modules|dist)\//;
async function expandPatterns(patterns) {
const files = new Set();
for (const pattern of patterns) {
for await (const match of glob(pattern, {
exclude: (path) => SKIPPED_DIRECTORIES.test(`${path}/`),
})) {
files.add(match);
}
}
return [...files].sort();
}
async function main() {
const patterns = argv.slice(2);
if (patterns.length === 0) {
stderr.write('usage: node add-model-map.mjs <file-or-glob> [...more]\n');
exit(2);
}
const files = await expandPatterns(patterns);
if (files.length === 0) {
stderr.write('no files matched\n');
exit(1);
}
let failed = false;
let mappedCount = 0;
for (const file of files) {
const before = readFileSync(file, 'utf8');
let result;
try {
result = addModelMapsWithReport(before);
} catch (error) {
if (!(error instanceof UnhandledModelError)) throw error;
for (const line of error.lineNumbers)
stderr.write(`${file}:${line}: model block not understood\n`);
failed = true;
continue;
}
if (result.source !== before) {
writeFileSync(file, result.source);
for (const modelName of result.mapped) {
stdout.write(`${file}: model ${modelName} -> @@map("${lowerFirst(modelName)}")\n`);
}
mappedCount += result.mapped.length;
}
}
if (mappedCount > 0) {
stdout.write(
`${mappedCount} model(s) mapped. Review the list: every one must be a model whose table or collection was created under the previous release's lowered-first-letter default. A schema written or inferred after upgrading must not be run through this codemod.\n`,
);
}
if (failed) exit(1);
}
if (argv[1] && fileURLToPath(import.meta.url) === argv[1]) {
await main();
}
upgrading/app/upgrades/8.0.0-rc.12-to-8.0.0-rc.13/instructions.md
---
from: "8.0.0-rc.12"
to: "8.0.0-rc.13"
changes:
- id: execution-ref-entry-field
summary: |
Each entry in `contract.execution.mutations.defaults` now names its target as
`ref: { namespace, entry, field }` instead of `ref: { namespace, table, column }`. The values
are the same table and column names. Re-emit the contract; code that reads `.ref.table` or
`.ref.column` reads `.ref.entry` and `.ref.field`.
detection:
glob: "**/*.{json,ts,mts,cts}"
matches:
- '"ref"\s*:\s*\{(?![^{}]*"kind")[^{}]*"(?:table|column)"\s*:'
- '\.ref\??\.(?:table|column)\b'
- '\bref\s*:\s*\{(?![^{}]*\bkind\b)[^{}]*\b(?:table|column)\s*:[^{}]*\b(?:table|column)\s*:'
script: ./scripts/execution-ref-entry-field/rename-execution-ref-keys.ts
- id: ts-defaults-encoded-by-codec
summary: |
`defineContract` from the Postgres and SQLite packages now encodes every literal `.default(value)` through the column's codec. The literal is the codec's input type. TypeScript checks it for fields built inside the `defineContract` factory, and the build fails with `CONTRACT.DEFAULT_INVALID` for a value the codec refuses. Pass a value of the codec's input type, or choose the field preset whose codec takes the value you have.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\.default\(\s*(?!now\(\)|autoincrement\(\)|sql`)'
- id: contract-format-formats-prisma7-schema
summary: prisma contract format now formats a Prisma 7 schema configured through prisma7Schema().
- id: config-contract-source-requires-format
summary: A contract source object written in prisma.config.ts must declare format 'psl' or 'typescript'.
- id: policy-expression-json-escapes
summary: A policy expression in a PSL contract decodes every JSON escape, so \t, \b, \f, \/ and \uXXXX no longer read as written.
- id: cursor-rejects-expression-orders
summary: "cursor() now throws ORM.ARGUMENT_INVALID when an active orderBy item is not a plain column (extension-operation orders such as vector distance were previously dropped from the keyset silently)"
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\.cursor\('
- id: mongo-codec-subpaths-move-to-target
summary: |
The Mongo codec subpaths moved from the adapter to the target:
`adapter/codec-types`, `adapter/codecs`, `adapter/codec-ids` and `adapter/data-types` under
`@prisma/orm-mongo` and `@prisma/orm-target-mongo` are now `target/...`. Emitted
`contract.d.ts` files, including migration snapshots, import `adapter/codec-types` until
rewritten or re-emitted. Under `skipLibCheck: true` that import fails silently and the
contract's field types turn wrong where they are used, instead of failing to compile.
detection:
glob: "**/*.{ts,mts,cts,md}"
matches:
- '@prisma/orm-(?:target-)?mongo/adapter/(?:codec-types|codecs|codec-ids|data-types)(?![\w-])'
- id: create-mongo-runner-deps-removed
summary: |
`createMongoRunnerDeps(...)` is removed from `@prisma/orm-mongo/adapter/control`. Build the
runner dependencies with `new MongoControlAdapterImpl().createRunnerDependencies(controlDriver)`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bcreateMongoRunnerDeps\b'
- id: mongo-runner-dependency-types-move-to-family
summary: |
`MongoRunnerDependencies` and `MarkerOperations` are no longer exported from
`adapter/control` or `target/control`; import them from `@prisma/orm-mongo/family/control-adapter`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\b(?:MongoRunnerDependencies|MarkerOperations)\b[^;]*?from\s*[''"]@prisma/orm-(?:target-)?mongo/(?:adapter|target)/control[''"]'
- id: mongo-create-runner-needs-adapter-on-stack
summary: |
`mongoTargetDescriptor.migrations.createRunner(family)` now reaches the database through the
control adapter on the family's control stack. A family instance created from an empty
stack (`createMongoFamilyInstance({} as ...)`), or from a `createControlStack(...)` with no
`adapter`, fails with "Mongo family requires an adapter descriptor in ControlStack" when the
runner executes.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'createMongoFamilyInstance\(\s*\{\s*\}'
- 'createControlStack\(\s*\{(?:(?!adapter)[^}])*mongoTargetDescriptor(?:(?!adapter)[^}])*\}\s*\)'
- id: mongo-psl-scalar-names
summary: |
Four Mongo PSL scalar names are deprecated in favour of the name of the BSON type they store:
`Int` → `Int32`, `Float` → `Double`, `Boolean` → `Bool`, `DateTime` → `Date`. The old names
are still accepted, with a `PSL_DEPRECATED_SCALAR_NAME` warning, and will be removed in a later
release; rename them now. Codec ids, `contract.json` and every hash are unchanged. Rename only
in the Prisma 8 contract source and its `migrations/app/*/contract.prisma` copies, never in a
Prisma 6 `schema.prisma`.
detection:
glob: "**/*.prisma"
matches:
- '(?<![\s\S])(?![\s\S]*\bprovider\s*=\s*"mongodb")[\s\S]*?(?:^|\n)[ \t]*[A-Za-z_][A-Za-z0-9_]*[ \t]+(?:Int|Float|Boolean|DateTime)(?:\[\])?\??(?![ \t]*\{)(?=\s|$)'
- id: mongo-variant-field-codecs
summary: |
Through `.variant(...)`, a field declared only on the variant model is now written and read
through its codec, as base-model fields always were. An `ObjectId` field there is stored as
an `ObjectId` and read back as a hex string, and a where filter on it encodes a hex string.
- id: contract-artifacts-restamp
summary: |
The emitted `contract.json` / `contract.d.ts` embed the toolchain version, which moves
to 8.0.0-rc.13. Run `contract emit` once after upgrading so the emitted artifacts match
the installed toolchain.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.12"'
---
# 8.0.0-rc.12 → 8.0.0-rc.13 — User upgrade instructions
## `execution-ref-entry-field`
A contract with generated defaults (`temporal.createdAt()`, `temporal.updatedAt()`, `@default(uuid())` and the like) carries them under `execution.mutations.defaults`. Each entry's `ref` changes keys; the values do not:
```json
// before
{ "ref": { "namespace": "public", "table": "user", "column": "updated_at" }, "onUpdate": { "kind": "generator", "id": "timestampNow" } }
// after
{ "ref": { "entry": "user", "field": "updated_at", "namespace": "public" }, "onUpdate": { "kind": "generator", "id": "timestampNow" } }
```
1. Run `prisma contract emit` so `contract.json` and `contract.d.ts` use the new keys. The runtime rejects a contract whose refs still say `table` and `column` with `Contract structural validation failed: execution.mutations.defaults[0].ref.entry must be a string`.
2. Contracts stored under `migrations/` carry the old keys too: the snapshots in `migrations/snapshots/<hash>/contract.json` and `contract.d.ts`, and any intermediate contract a migration imports from its own directory, such as `migrations/app/<dir>/intermediate.json` and `intermediate.d.ts`. From the project root, run the script that sits next to this guide. `<skill>` is the directory of the synced `prisma-8` skill:
```bash
pnpm exec tsx <skill>/upgrading/app/upgrades/8.0.0-rc.12-to-8.0.0-rc.13/scripts/execution-ref-entry-field/rename-execution-ref-keys.ts
```
The script reads every `.json` and `.d.ts` file under a `migrations/` directory, skipping `node_modules`, `.git` and `dist`, and changes only `execution.mutations.defaults[].ref` entries. In each one it renames `table` to `entry` and `column` to `field`, and writes the keys in the order `entry`, `field`, `namespace`, which is the order `prisma contract emit` writes. The old key order varies between files: older `contract.d.ts` files list `namespace`, `table`, `column`, newer ones `column`, `namespace`, `table`. The script handles any order; a hand edit or a search-and-replace that assumes one order misses some refs. A `contract.json` in canonical form stays canonical, so a snapshot matches a fresh emit apart from `executionHash`; an indented JSON file keeps its indentation. The script leaves every `executionHash` as it is. That hash no longer matches the renamed content, but the snapshot loader re-hashes only the storage section, so nothing checks it. `storageHash` and `profileHash` do not move, so snapshot directory names stay the same. A second run changes nothing. Pass `--check` to list the files it would change without writing them; it exits 1 if any would change. A `.json` file it cannot parse is listed as `NOT JSON` and also makes it exit 1.
To confirm the rewrite, name one migration directory under `migrations/app/` as both ends of a plan: `prisma migration plan --from <dir> --to <dir>`. It loads that migration's snapshot without touching the database. Before the rewrite it fails with `CONTRACT.VALIDATION_FAILED` and `execution.mutations.defaults[0].ref.entry must be a string`; after the rewrite it prints `No changes detected` and writes no migration. In a project with extensions it also re-pins their files under `migrations/<space-id>/`, as every `migration plan` does. `prisma db migrate --to <hash>` does not confirm it: it applies migrations, and on a database already past that snapshot it has no route back, so it fails either way. `prisma migration check` and `prisma db verify` never validate an app snapshot's `execution` section, so they pass before and after the rewrite.
3. Code that reads the section directly changes `.ref.table` to `.ref.entry` and `.ref.column` to `.ref.field`.
`executionHash` changes for every contract with generated defaults, because the canonical JSON changes. Nothing compares it against the database, so no migration or re-sign is needed.
## `ts-defaults-encoded-by-codec`
A TypeScript contract used to store the value passed to `.default(value)` as it stood. The column's codec now encodes it, so the value must be the codec's input type. For a field built inside the `defineContract` factory, `.default()` is typed with that input type, so a wrong value is a type error in `contract.ts`. PSL contracts, `.default(now())`, `.default(autoincrement())` and `` .default(sql`...`) `` are not affected.
What now fails, on Postgres:
```typescript
// before: emitted, although the codec of field.dateTime() holds a Temporal.Instant
createdAt: field.dateTime().default('2024-01-01T00:00:00Z'),
```
```text
CONTRACT.DEFAULT_INVALID: Field "Event.createdAt" has a default that its codec refuses: Codec 'pg/timestamptz-temporal@1' encodes a Temporal.Instant, but received a string.
```
1. Search your contract files for `.default(` with a literal argument.
2. Type-check the contract file, then run `prisma contract emit`. TypeScript reports a default of the wrong type; the emit reports each default the codec refuses, with its model and field.
3. For each one, either pass the codec's type or change the preset:
| You have | Write |
| --- | --- |
| an ISO 8601 string | `field.temporal.timestamptzString().default('2024-01-01T00:00:00Z')` |
| a JavaScript `Date` | `field.temporal.timestamptzJsDate().default(new Date('2024-01-01T00:00:00Z'))` |
| a `Temporal.Instant` | `field.dateTime().default(Temporal.Instant.from('2024-01-01T00:00:00Z'))` |
Changing the preset changes the column's codec, and so the type your queries read and write for that field. On Postgres, `field.bigint()` takes a `bigint` and `field.bytes()` takes a `Uint8Array`. On SQLite, `field.temporal.datetime()` takes a `Date`, `field.column(bigintColumn)` takes a `bigint` and `field.column(blobColumn)` takes a `Uint8Array`.
On Node 24 there is no global `Temporal`. A contract file that creates a `Temporal` value must load an implementation itself, for example with `import 'temporal-polyfill/full/global'` as its first import. The type check on a `Temporal` field needs the `Temporal` type declarations in the project, for example from `temporal-polyfill/global`; without them the parameter is unchecked. The error shown above is the one you get with an implementation loaded; without one, the codec reports that the runtime has no global `Temporal` implementation.
`.default(null)` used to store `null`. It is now a type error on a field whose codec input type does not include `null`, which is every built-in codec except the JSON codecs: `field.json().optional().default(null)` still compiles and stores `null`. When the contract is built, the codec receives the `null`: a codec that checks its input, such as `pg/int8@1`, refuses it with `CONTRACT.DEFAULT_INVALID`, and a codec that passes any value through, such as `pg/text@1`, still stores `null`. A column without a default already defaults to `NULL` in the database, so remove the call.
A JavaScript `number` on a `bigint` field (codec `pg/int8@1`), such as `field.bigint().default(1)` inside the `defineContract` factory, is now a type error. Write a `bigint` literal: `field.bigint().default(1n)`. The default is stored as `"1"`, as the table below shows.
A default that gets past the type check, for example from an untyped caller, is stored in a different form than before:
| Default | Stored before | Stored now |
| --- | --- | --- |
| `field.bigint().default(1)` (also SQLite `bigintColumn`) | `1` | `"1"` |
| `field.bytes().default('x')` | `"x"` | `"eA=="` (base64) |
| SQLite `blobColumn` with `.default('x')` | `"x"` | `"78"` (hex) |
A contract with such a default emits a different `contract.json` and a different storage hash. Re-emit the contract and review the diff of `contract.json`.
A literal default on a column whose codec no pack in the contract declares now fails with `CONTRACT.DEFAULT_INVALID`, because nothing can check it. List the pack that owns the codec in the `extensions` of `defineContract`.
## `contract-format-formats-prisma7-schema`
A project whose `contract` is `prisma7Schema('./prisma/schema.prisma')` used to be skipped by `prisma contract format`. The Prisma 7 source is now a PSL source, so the command formats that file with the Prisma 8 formatter when it parses, and refuses with `PSL.PARSE_FAILED`, without writing, when it does not (for example, a schema with a model whose closing brace is missing). A schema with a `view` block parses, so the command formats it.
If the Prisma 7 schema must keep Prisma 7's own formatting, do not run `prisma contract format` on it; format it with Prisma 7's `prisma format` instead.
## `config-contract-source-requires-format`
A config that builds `contract.source` itself, as an object with a `load` function, must now give it a `format`: `'psl'` when its inputs are PSL text, and `'typescript'` when it builds the contract in TypeScript, for example `source: { format: 'typescript', load: async () => ok(contract) }`. Without it, or with any other value, every command that reads the config fails with `CONFIG.VALIDATION_FAILED` on the field `contract.source.format`. Sources made by `defineConfig`, `prisma7Schema()`, `prismaContract()` and the TypeScript contract helpers already declare one.
## `policy-expression-json-escapes`
A `using` or `withCheck` expression in a `policy_*` block of a PSL contract is a JSON string, the form `prisma contract print` and `prisma contract infer` write. The reader used to decode only `\n`, `\r`, `\"` and `\\`, and kept every other backslash sequence as written. It now also decodes `\t`, `\b`, `\f`, `\/` and `\uXXXX`, so `"a\tb"` reads as `a`, a tab and `b`. A backslash sequence that is not a JSON escape, such as `\d`, is still kept as written.
If a PSL contract writes one of those five sequences in a policy expression and means the backslash and the letter, write the backslash twice (`\\t`), then run `prisma contract emit`. Otherwise the emitted policy, and the contract's storage hash, change.
## `cursor-rejects-expression-orders`
`db.orm.<ns>.<Model>....cursor(...)` builds its keyset from plain model columns. It throws `ORM.ARGUMENT_INVALID`, naming the 1-based `orderBy` position, when an active order is any of these:
- an extension-operation result, such as `(p) => p.embedding.cosineDistance(v).asc()` or `(m) => m.text.fullTextRank(q).desc()`;
- a relation field, such as `(p) => p.author.name.asc()`;
- a relation count, such as `(u) => u.posts.count().desc()`;
- an order with null placement, such as `(p) => p.title.asc({ nulls: 'last' })`.
The check runs when `cursor()` is called and again when the query is planned, so an order added after `cursor()` is refused too. Before this change an extension-operation order was left out of the keyset without an error, which returned wrong pages.
For each `.cursor(` call on a `db.orm` chain that also calls `.orderBy(`, look at every `orderBy` lambda in the chain. If any of them is one of the orders above, do one of these:
- Paginate with `.limit(n).offset(n)` and remove `.cursor(...)`:
```ts
const page = await db.orm.public.Post
.orderBy((p) => p.embedding.cosineDistance(v).asc())
.limit(20)
.offset(pageIndex * 20)
.all();
```
- Keep the cursor and order by plain columns only, for example `(p) => p.createdAt.desc()` and `(p) => p.id.desc()`.
`distinctOn()` throws the same error when one of the first N orders, where N is the number of `distinctOn` columns, is not a plain column: Postgres needs those leading orders to match the `DISTINCT ON` columns, so such a query already failed in the database; it now fails earlier, with `ORM.ARGUMENT_INVALID`. Put the `distinctOn` columns first; a relation order, a count or an operation result may follow them. For example, `.orderBy([(post) => post.title.asc(), (post) => post.author.name.asc()]).distinctOn('title')` is accepted.
## `mongo-codec-subpaths-move-to-target`
The Mongo target package owns the codecs now. Rewrite each specifier, in every file that names it (source, emitted `contract.d.ts`, the `contract.d.ts` in each `migrations/snapshots/<hash>/` directory, and docs):
| Before | After |
| --- | --- |
| `@prisma/orm-mongo/adapter/codec-types` | `@prisma/orm-mongo/target/codec-types` |
| `@prisma/orm-mongo/adapter/codecs` | `@prisma/orm-mongo/target/codecs` |
| `@prisma/orm-mongo/adapter/codec-ids` | `@prisma/orm-mongo/target/codec-ids` |
| `@prisma/orm-mongo/adapter/data-types` | `@prisma/orm-mongo/target/data-types` |
| `@prisma/orm-target-mongo/adapter/<same four>` | `@prisma/orm-target-mongo/target/<same four>` |
The exported names are unchanged. For the application's own `contract.d.ts`, running `prisma contract emit` produces the same result as the rewrite. Snapshot `contract.d.ts` files under `migrations/snapshots/` are not re-emitted, so rewrite them. The contract JSON and every hash stay the same.
Do not rely on `tsc` to find the `contract.d.ts` files. They are declaration files, and a project with `skipLibCheck: true` (the `tsconfig.json` that `prisma orm init` writes sets it) gets no error for their stale import. Instead the contract's field types stop resolving to the codec types, so type errors appear where the contract is used, such as a seed script or a query, rather than at the import, and some fields are no longer type-checked. Rewrite or re-emit every file the detection finds, whether or not `tsc` complains.
## `create-mongo-runner-deps-removed`
```ts
// before
import { createMongoRunnerDeps, extractDb } from '@prisma/orm-mongo/adapter/control';
import { MongoDriverImpl } from '@prisma/orm-mongo/driver';
const runner = new MongoMigrationRunner(
createMongoRunnerDeps(controlDriver, MongoDriverImpl.fromDb(extractDb(controlDriver)), family),
);
// after
import { MongoControlAdapterImpl } from '@prisma/orm-mongo/adapter/control';
const runner = new MongoMigrationRunner(
new MongoControlAdapterImpl().createRunnerDependencies(controlDriver),
);
```
Drop imports that are now unused (`extractDb`, `MongoDriverImpl`, `createMongoFamilyInstance`) and any family instance built only to pass as the third argument.
## `mongo-runner-dependency-types-move-to-family`
Change the import of `MongoRunnerDependencies` or `MarkerOperations` to `@prisma/orm-mongo/family/control-adapter`. The shapes are unchanged.
## `mongo-create-runner-needs-adapter-on-stack`
Build the family instance from a control stack that includes the Mongo adapter:
```ts
import mongoAdapter from '@prisma/orm-mongo/adapter/control';
import { createMongoFamilyInstance, mongoFamilyDescriptor } from '@prisma/orm-mongo/family/control';
import { createControlStack } from '@prisma/orm-mongo/components/control';
import { mongoTargetDescriptor } from '@prisma/orm-mongo/target/control';
const family = createMongoFamilyInstance(
createControlStack({ family: mongoFamilyDescriptor, target: mongoTargetDescriptor, adapter: mongoAdapter }),
);
```
Code that goes through the CLI or `defineConfig` already has the adapter on the stack and needs no change.
## `mongo-psl-scalar-names`
`Int`, `Float`, `Boolean` and `DateTime` are deprecated in Mongo schemas. They are still accepted, and they produce the same contract as the new names, but `prisma contract emit` and the language server report a `PSL_DEPRECATED_SCALAR_NAME` warning for each use, and a later release removes them. Rename them now.
Apply this only to the Prisma 8 Mongo contract source, the `.prisma` file that `prisma.config.ts` passes to `defineConfig` from `@prisma/orm-mongo/config`, and to its copies under `migrations/app/<migration>/contract.prisma`. The detection pattern also matches Postgres and SQLite schemas, whose scalar names do not change in this release; leave them alone.
Never rename in a Prisma 6 `schema.prisma`, such as one kept beside the Prisma 8 contract in a project that still runs Prisma 6: Prisma 6 has no `Int32`, `Double`, `Bool` or `Date`, and would reject the schema. The detection skips a file that declares `provider = "mongodb"`, which every Prisma 6 MongoDB schema does, but a multi-file Prisma 6 schema declares it in one file only, so also skip the other `.prisma` files of a Prisma 6 schema directory.
In each field whose type is one of the deprecated names, replace the type name, keeping any `[]` and `?`:
| Deprecated | Use | Stored as |
| --- | --- | --- |
| `Int` | `Int32` | BSON int |
| `Float` | `Double` | BSON double |
| `Boolean` | `Bool` | BSON bool |
| `DateTime` | `Date` | BSON date |
```prisma
// before
model Post {
id ObjectId @id @map("_id")
views Int
rating Float?
published Boolean
createdAt DateTime
tags Int[]
}
// after
model Post {
id ObjectId @id @map("_id")
views Int32
rating Double?
published Bool
createdAt Date
tags Int32[]
}
```
This includes the `contract.prisma` copies under `migrations/app/<migration>/`. Then run `prisma contract emit`: `contract.json` and `contract.d.ts` come out the same as before, so no migration or `db sign` is needed. Until the rename, each use reports `warning <file>:<line>:<column> PSL_DEPRECATED_SCALAR_NAME Scalar type "Int" is deprecated and will be removed; use "Int32" (stored as BSON int).`
Docs: list only `Int32`, `Double`, `Bool`, `Date`; the old names must not appear in the scalar tables.
## `mongo-variant-field-codecs`
This change has no detection pattern: it applies to every model with `@@base` whose own `ObjectId` fields are written or read through `.variant(...)`.
Before, a field declared only on a variant model (`model Photo { ownerId ObjectId @@base(Asset, "photo") }`) was passed to the driver as the application wrote it and returned as the driver read it. An `ObjectId` field written as a hex string was refused by the collection validator when the contract was written in Prisma 8 PSL; a contract built with the TypeScript builder has no validator, so there the hex string was stored as a string. A stored `ObjectId` came back as the driver's `ObjectId` class instead of a hex string. A where filter on such a field passed its value unencoded, so a hex string matched nothing where an `ObjectId` was stored; it is now encoded and matches.
1. Remove any workaround that converted such values by hand, for example passing `new ObjectId(hex)` for a variant `ObjectId` field or calling `.toHexString()` on what a read returned; pass and expect hex strings, as for any other `ObjectId` field.
2. Documents written before this change through a TypeScript-builder contract may hold a variant `ObjectId` field as a string; a filter with a hex string now looks for an `ObjectId` and does not match them. Convert the ones that are 24-character hex strings with `db.<collection>.updateMany({ <field>: { $type: 'string', $regex: /^[0-9a-fA-F]{24}$/ } }, [{ $set: { <field>: { $toObjectId: '$<field>' } } }])`. The regex matters: without it, one string that is not an `ObjectId` in hex makes `$toObjectId` fail the whole command. Other strings, `ObjectId` values and documents without the field are left as they are.
3. A read through the base collection (without `.variant(...)`) still returns variant-only fields as the driver read them.
## `contract-artifacts-restamp`
For every `contract.json` matched by `detection`, run the project's emit command (`prisma contract emit`, or the project's `contract:emit` script) once after upgrading. This entry accounts for the embedded `version` moving to `8.0.0-rc.13`; any other difference in the emitted files comes from an earlier entry in this guide.
upgrading/app/upgrades/8.0.0-rc.12-to-8.0.0-rc.13/scripts/execution-ref-entry-field/rename-execution-ref-keys.ts
// Renames `table`/`column` to `entry`/`field` in execution default refs of contracts under `migrations/`.
import { readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { basename, dirname, join, relative, sep } from 'node:path';
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist']);
const RENAMES = new Map([
['table', 'entry'],
['column', 'field'],
]);
const check = process.argv.includes('--check');
const projectRoot = process.cwd();
function isContractFile(name: string): boolean {
return name.endsWith('.json') || name.endsWith('.d.ts');
}
async function findMigrationFiles(dir: string, found: string[] = []): Promise<string[]> {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
await findMigrationFiles(path, found);
} else if (
entry.isFile() &&
isContractFile(entry.name) &&
relative(projectRoot, dir).split(sep).includes('migrations')
) {
found.push(path);
}
}
return found;
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function sortKeys(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortKeys);
if (!isObject(value)) return value;
const sorted: Record<string, unknown> = {};
for (const key of Object.keys(value).sort()) sorted[key] = sortKeys(value[key]);
return sorted;
}
function parseJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
function serializeLike(original: string, value: unknown): string {
if (original === `${JSON.stringify(sortKeys(parseJson(original)))}\n`) {
return `${JSON.stringify(sortKeys(value))}\n`;
}
const indent = /\n([ \t]+)/.exec(original)?.[1] ?? 2;
return `${JSON.stringify(value, null, indent)}${original.endsWith('\n') ? '\n' : ''}`;
}
function renameInJson(text: string): string | undefined {
const contract = parseJson(text);
if (contract === undefined) return undefined;
if (!isObject(contract) || !isObject(contract['execution'])) return text;
const mutations = contract['execution']['mutations'];
const defaults = isObject(mutations) ? mutations['defaults'] : undefined;
if (!Array.isArray(defaults)) return text;
let changed = false;
for (const entry of defaults) {
if (!isObject(entry) || !isObject(entry['ref'])) continue;
const keys = Object.keys(entry['ref']);
if (!keys.some((key) => RENAMES.has(key))) continue;
entry['ref'] = sortKeys(
Object.fromEntries(
Object.entries(entry['ref']).map(([key, value]) => [RENAMES.get(key) ?? key, value]),
),
);
changed = true;
}
return changed ? serializeLike(text, contract) : text;
}
const DEFAULT_ELEMENT_PATH = ['execution', 'mutations', 'defaults', ''];
const MEMBER_BEFORE_OPENER = /readonly (\w+): (?:readonly )?$/;
const REF_MEMBER = /readonly (\w+): ('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")/g;
interface OpenBlock {
readonly member: string;
readonly start: number;
readonly isDefaultRef: boolean;
}
interface Span {
readonly start: number;
readonly end: number;
}
function closingQuote(text: string, start: number): number {
let i = start + 1;
while (i < text.length && text[i] !== text[start]) i += text[i] === '\\' ? 2 : 1;
return i;
}
function commentEnd(text: string, start: number): number {
const end =
text[start + 1] === '/' ? text.indexOf('\n', start) : text.indexOf('*/', start + 2) + 1;
return end > start ? end : text.length;
}
function isDefaultElement(open: readonly OpenBlock[]): boolean {
const members = open.slice(-DEFAULT_ELEMENT_PATH.length).map((block) => block.member);
return members.join('/') === DEFAULT_ELEMENT_PATH.join('/');
}
function executionDefaultRefs(text: string): Span[] {
const open: OpenBlock[] = [];
const refs: Span[] = [];
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (char === "'" || char === '"' || char === '`') {
i = closingQuote(text, i);
} else if (char === '/' && (text[i + 1] === '/' || text[i + 1] === '*')) {
i = commentEnd(text, i);
} else if (char === '{' || char === '[') {
const member = MEMBER_BEFORE_OPENER.exec(text.slice(Math.max(0, i - 64), i))?.[1] ?? '';
const isDefaultRef = char === '{' && member === 'ref' && isDefaultElement(open);
open.push({ member, start: i, isDefaultRef });
} else if (char === '}' || char === ']') {
const block = open.pop();
if (block?.isDefaultRef) refs.push({ start: block.start, end: i + 1 });
}
}
return refs;
}
function renameRefMembers(block: string): string {
const members = [...block.matchAll(REF_MEMBER)];
if (!members.some(([, key = '']) => RENAMES.has(key))) return block;
const sorted = members
.map(([, key = '', value = '']) => `readonly ${RENAMES.get(key) ?? key}: ${value}`)
.sort();
let i = 0;
return block.replace(REF_MEMBER, () => sorted[i++] ?? '');
}
function renameInDts(text: string): string {
let renamed = text;
for (const { start, end } of executionDefaultRefs(text).reverse()) {
const block = renameRefMembers(renamed.slice(start, end));
renamed = `${renamed.slice(0, start)}${block}${renamed.slice(end)}`;
}
return renamed;
}
async function replaceFile(path: string, content: string): Promise<void> {
const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`);
try {
await writeFile(temporary, content, 'utf-8');
await rename(temporary, path);
} catch (error) {
await rm(temporary, { force: true });
throw error;
}
}
async function main(): Promise<void> {
const files = await findMigrationFiles(projectRoot);
let changed = 0;
let unparseable = 0;
for (const path of files.sort()) {
const before = await readFile(path, 'utf-8');
const after = path.endsWith('.json') ? renameInJson(before) : renameInDts(before);
if (after === undefined) {
unparseable += 1;
console.log(`NOT JSON ${relative(projectRoot, path)}`);
continue;
}
if (after === before) continue;
changed += 1;
console.log(`${check ? 'WOULD FIX' : 'FIXED'} ${relative(projectRoot, path)}`);
if (!check) await replaceFile(path, after);
}
console.log(
`${files.length} file(s) under migrations/ scanned, ${changed} ${check ? 'need' : 'got'} the rename.`,
);
if (unparseable > 0) {
console.log(`${unparseable} .json file(s) could not be parsed; check them by hand.`);
}
if (unparseable > 0 || (check && changed > 0)) process.exit(1);
}
void main();
upgrading/app/upgrades/8.0.0-rc.13-to-8.0.0-rc.14/instructions.md
---
from: "8.0.0-rc.13"
to: "8.0.0-rc.14"
changes:
- id: engine-pin-moves-to-0-6-2
summary: |
The toolchain now requires `@prisma/cli-engine@0.6.2` (up from 0.6.1). A project that pins `@prisma/cli-engine` itself must move the pin to `0.6.2`. With this engine the CLI prints its own name in hints and messages where it used to print a literal `{bin}`.
detection:
glob: "**/package.json"
contains:
- '"@prisma/cli-engine": "0.6.1"'
- id: temporal-polyfill-is-a-peer-dependency
summary: |
`temporal-polyfill` is now a required peer dependency of `@prisma/orm-postgres` and `@prisma/orm-target-postgres`, not a dependency. The Postgres control plane, such as the `prisma` commands and the Vite plugin, imports it. npm, pnpm and bun install it automatically. A project that installs with Yarn must add `temporal-polyfill` (`^1.0.4`) to its own dependencies.
detection:
glob: "**/yarn.lock"
contains:
- "@prisma/orm-postgres@"
- "@prisma/orm-target-postgres@"
anyMatch: true
- id: serverless-connect-returns-connection
summary: "connect({ url }) on the serverless client from @prisma/orm-postgres/serverless returns a connection, not a Runtime. Call db.runtime().query(plan) and db.runtime().execute(plan), and pass db.runtime() wherever the connect() result was used as a runtime. connect() now connects before it returns and rejects with DRIVER.CONNECTION_FAILED when the database cannot be reached."
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx}"
matches:
- '[''"]@prisma/orm-postgres/serverless[''"]'
- id: serverless-connection-orm-and-transaction
summary: "Optional: the connection has orm and transaction(fn), so db.orm replaces a hand-built orm({ runtime, context }) in queries that call no custom collection method, and db.transaction(fn) replaces withTransaction(runtime, fn)."
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx}"
matches:
- '[''"]@prisma/orm-postgres/serverless[''"]'
- id: serverless-cursor-default-off
summary: "Reads through connections from @prisma/orm-postgres/serverless no longer go through a server-side cursor by default. A path that must keep batched streaming opens its connection from a second serverless client with cursor: { batchSize: 100 }; that path hangs behind Cloudflare Hyperdrive. PostgresServerlessCursorOptions is now PostgresCursorOptions, which is { batchSize?: number | undefined } with no disabled flag, so cursor: { disabled: true } no longer compiles and must be deleted."
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx}"
matches:
- '[''"]@prisma/orm-postgres/serverless[''"]'
- id: date-time-default-stored-in-canonical-form
summary: |
A PSL date or time default is stored in `contract.json` in its type's canonical form, however it was written: `@default("2024-01-01T01:00:00+01:00")` on a `DateTime` column is stored as `"2024-01-01T00:00:00Z"`. A default that was not already in that form gets a new storage hash when the contract is re-emitted. The database needs no change: re-emit, then `prisma db sign`, or record an empty migration with `prisma migration new`.
detection:
glob: "**/*.prisma"
matches:
- '\b(DateTime|Timestamptz|TimestamptzJsDate|TimestamptzString|Timestamp|TimestampString|Date|DateString|Time|TimeString|Timetz)(\([^)]*\))?(\[\])?\??([ \t]+@[\w.]+(\([^)\n]*\))?)*?[ \t]+@default\([\s\[]*"'
- id: date-time-default-refused-text
summary: |
`prisma contract emit` refuses a date or time default its column's type does not hold, with `PSL_INVALID_DEFAULT_LITERAL`: an offset on `Timestamp`, `Date` or `Time`, no offset on `DateTime`, `Timestamptz` or `Timetz`, a date on a time column, a time on a `Date` column, more than six digits after the decimal point (three on a SQLite `DateTime`), a date or time that does not exist, a year outside the range the type holds, or a ` BC` suffix on a SQLite `DateTime`. The message shows text the column takes.
detection:
glob: "**/*.prisma"
matches:
- '\b(DateTime|Timestamptz|TimestamptzJsDate|TimestamptzString|Timestamp|TimestampString|Date|DateString|Time|TimeString|Timetz)(\([^)]*\))?(\[\])?\??([ \t]+@[\w.]+(\([^)\n]*\))?)*?[ \t]+@default\([\s\[]*"'
- id: date-time-ts-default-stored-in-canonical-form
summary: |
In a TypeScript contract, a default on `field.temporal.timestamptzJsDate()`, `field.temporal.timestamptzString()`, `field.temporal.timestampString()`, the SQLite `field.temporal.datetime()`, and the `dateStringColumn`, `timeStringColumn`, `timetzColumn` and SQLite `sqliteDatetimeColumn` helpers is stored in the same canonical form as in PSL: `new Date('2024-01-01T00:00:00Z')` is stored as `"2024-01-01T00:00:00Z"`, not `"2024-01-01T00:00:00.000Z"`. A `Temporal` default with digits below one microsecond is refused. The storage hash changes; re-emit, then sign or migrate as for PSL.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\.(timestamptzJsDate|timestamptzString|timestampString|datetime)\([^)]*\)(\s*\.\w+\([^)]*\))*?\s*\.default\('
- '\b(timestamptzJsDateColumn|timestamptzStringColumn|timestampStringColumn|dateStringColumn|timeStringColumn|timetzColumn|sqliteDatetimeColumn|datetimeColumn)\b[^\n]*\.default\('
- id: mongo-create-returns-stored-document
summary: |
Mongo `create()` and `createAll()` now return each inserted document as stored, decoded like
a read, instead of the input with the new `_id`. The ORM computes it from the document it
sent, through the client's BSON options, without a second query. A `Bson` field comes back
as a read returns it (a `Long` in the safe-integer range as a number, bytes as `Binary`, a
`BSONRegExp` as a `RegExp`), and a nullable field left out comes back as `null`.
- id: mongo-reads-decode-includes-value-objects-and-absent-fields
summary: |
Mongo reads now decode included documents (`include(...)`) and composite-type (value
object) fields through their codecs, and a nullable field missing from the stored document
reads as `null` instead of `undefined`.
- id: mongo-where-filter-expressions-encoded
summary: |
A filter expression passed to the Mongo ORM's `where()` now encodes its comparison and
`$in`/`$nin` values through the field's codec, as the object form of `where()` does, and each
element of a whole-list comparison through the element codec. A value the codec refuses,
such as a driver `Long` for an `Int64` field (the codec takes a `bigint`) or a fraction for
an `Int32` field, fails with `RUNTIME.ENCODE_FAILED`. The object form of `where()` now also
refuses a fraction or an out-of-range number for an `Int32` field. This applies to the ORM
only: the query builder's `match()` sends values as given.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\.where\(\s*(?:MongoFieldFilter|MongoAndExpr|MongoOrExpr|MongoNotExpr)\.'
- id: mongo-writes-check-int32-enum-and-null-values
summary: |
The Mongo ORM refuses, with `RUNTIME.ENCODE_FAILED` naming the field, a write of a fraction
or an out-of-range number to an `Int32` field, of a value outside the field's enum, and of
`null` to a field that is not nullable. Before, depending on the field's type, the field's
codec refused it, a contract with a collection validator got a bare "Document failed
validation" from the server, or a contract without one (TypeScript builder, Prisma 6 schema)
stored the value. Filters still accept a value outside the enum and `null`, so they can find
documents that hold one.
- id: mongo-codecs-check-value-types
summary: |
The Mongo `String`, `Bool`, `Date`, `ObjectId` and vector codecs refuse a value of the wrong
type with `RUNTIME.ENCODE_FAILED` naming the field, in writes and filters, as `Int32` and
`Double` do. Before, a `null` element in a `String[]`, `Bool[]` or `Date[]` list or a
mistyped value was stored as given, an invalid `Date` was stored as the epoch, and `null`,
`undefined` or a number for an `ObjectId` field became a new id or a timestamp.
- id: mongo-list-elements-encoded
summary: |
The Mongo ORM encodes each element of a list field through the field's codec, on writes and
in filters. An `ObjectId[]`, `Int64[]`, `Decimal128[]` or `Binary[]` field now stores its
elements as `ObjectId`, `long`, `decimal` and `binData`, where before the whole list reached
the element codec and the write failed with `RUNTIME.ENCODE_FAILED`. A whole number in a
`Double[]` field is stored as a `double`.
- id: mongo-query-builder-bson-values-match
summary: |
A query-builder filter or raw command that compares with a driver class such as `ObjectId`,
`Long`, `Decimal128` or `Binary` now sends it as that BSON value. Before, the Mongo adapter
copied it into a plain object, so the filter matched nothing.
- id: mongo-upsert-keeps-create-values
summary: |
When `create` sets a field that has an update default (`@updatedAt`,
`temporal.updatedAt()`, `temporal.timestamp(onUpdate: now)`), `upsert()` now inserts the
`create` value, and still applies the update default on update, in one atomic command.
Before, the insert got the current time instead. Such an upsert refuses `pull()` by a match
document with `ORM.OPERATION_UNSUPPORTED`.
- id: contract-source-warnings-are-diagnostics
summary: |
`prisma contract emit` and `prisma contract print` report contract source warnings, such as
`PSL_DEPRECATED_SCALAR_NAME`, as `warn` diagnostics of the result instead of free-text
`warning …` messages. With `--json` they are in the result envelope's `diagnostics`. Their
file, and the file of a source error from either command, is shown relative to the working
directory.
- id: mongo-ts-preset-field-not-optional
summary: |
In a Mongo TypeScript contract, `.optional()` or `.many()` on a field a preset fills, such
as `field.temporal.createdAt()`, is now a type error that says "A preset fills this field on
write, so it cannot be optional" (or "a list"); it always failed when the contract was built.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\btemporal\.(?:createdAt|updatedAt|timestamp)\([^)]*\)\s*\.(?:optional|many)\('
- id: contract-artifacts-restamp
summary: |
The emitted `contract.json` / `contract.d.ts` embed the toolchain version, which moves
to 8.0.0-rc.14. Run `contract emit` once after upgrading so the emitted artifacts match
the installed toolchain.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.13"'
---
# 8.0.0-rc.13 → 8.0.0-rc.14 — User upgrade instructions
## `engine-pin-moves-to-0-6-2`
For every `package.json` matched by `detection`, change the `@prisma/cli-engine` version from `0.6.1` to `0.6.2` and reinstall. Projects assembled by the `prisma` CLI resolve the engine automatically.
Engine 0.6.2 replaces the placeholder `{bin}` with the name of the CLI that was run. A hint that used to print as `{bin} db migrate` now prints as `prisma db migrate`. The replacement applies to next actions, warnings, errors, and summary and list text, in human, `--json`, and `--format markdown` output. If a script or a test of yours matches the literal text `{bin}` in the CLI's output, change it to match the CLI name.
## `temporal-polyfill-is-a-peer-dependency`
For each Yarn project that `detection` matches, add `"temporal-polyfill": "^1.0.4"` to `dependencies` in the `package.json` that depends on `@prisma/orm-postgres` or `@prisma/orm-target-postgres`, then reinstall. Without it, `prisma contract emit` and the other commands fail because Node.js cannot find the package `temporal-polyfill`.
A project that already depends on `temporal-polyfill` with a range inside `^1.0.4` needs no change.
## `serverless-connect-returns-connection`
`postgresServerless(...).connect({ url })` used to return a `Runtime`. It now returns a **connection** with the members of a `postgres()` client except `connect`: `sql`, `raw`, `enums`, `nativeEnums`, `context`, `contract`, `stack`, `orm`, `runtime()`, `transaction(fn)`, `prepare(...)`, `close()` and `[Symbol.asyncDispose]`. It is not a `Runtime`: it has no `query` or `execute`. `db.runtime()` returns the runtime. A connection owns one database connection, and `await using` still closes it when the scope ends.
The **serverless client** returned by `postgresServerless(...)` still holds no database connection. It now also has `raw`, `enums` and `nativeEnums`.
In each file that imports `@prisma/orm-postgres/serverless`, or that uses the result of its `connect()`:
1. Name the serverless client `postgres` and the connection that `connect()` returns `db`. With these names, code written for a `postgres()` client (`db.orm...`, `db.transaction(...)`, `db.runtime().query(...)`) works unchanged inside a request, as long as every query is awaited before the `await using` scope ends: the connection closes when the scope ends, so a lazy ORM read or `db.runtime().query(plan)` returned from the scope without `await` starts after the close and rejects with `DRIVER.NOT_CONNECTED` ("Runtime is closed"), whose `fix` names the missing `await`; a `first()`, `execute()` or `transaction()` returned without `await` had already started, so the close waits for it and it completes. Update every import of the serverless client to the new name.
2. Replace `runtime.query(plan)` with `db.runtime().query(plan)`, and `runtime.execute(plan)` with `db.runtime().execute(plan)`. The old `connect()` result was a full `Runtime`, so the same applies to its other methods: `runtime.connection()`, `runtime.telemetry()` and `runtime.prepare(...)` become `db.runtime().connection()`, `db.runtime().telemetry()` and `db.runtime().prepare(...)`. `db.prepare(...)` also exists and accepts ORM queries as well as SQL plans. `db.sql` is the same object as `postgres.sql`, so `postgres.sql...` inside a request may be written `db.sql...`.
3. Anything that took the `connect()` result as a runtime takes `db.runtime()` instead: `withTransaction(runtime, fn)`, `orm({ runtime, context })`, `preparedStatement.query(runtime, params)`, and your own functions whose parameter is typed `Runtime`. A function that needs both the runtime and the context can take the connection, typed with `PostgresServerlessConnection<Contract>` from `@prisma/orm-postgres/serverless`, and read `db.runtime()` and `db.context`. That removes any cast of the serverless client's `context` to `ExecutionContext<Contract>`, and the `Runtime`, `ExecutionContext` and serverless client imports that only served it.
4. Update comments that describe the old shape. A comment that names the old variable or says the runtime is acquired through `db.connect(...)` now names `postgres.connect({ url })` and the connection `db`. A comment that calls the serverless client a facade, or calls a connection a per-request client or a runtime, now says serverless client or connection. For example, the doc comment on the serverless client says that it is built once per isolate, holds no database connection, and that each request opens its own connection with `postgres.connect({ url })`; it sits directly above the `postgresServerless(...)` declaration, so move it there if it sits above another declaration. Update any README that describes the old shape in the same way.
5. `connect({ url })` now connects to the database before it returns. It rejects with `DRIVER.CONNECTION_FAILED` when the database refuses the connection, rejects the credentials, or does not answer within 20 seconds, and leaves nothing open; a malformed URL, or one with a scheme such as `http://`, now fails at `connect()` with `RUNTIME.BINDING_INVALID`; before, `connect()` resolved and the first query failed instead. Move any handling of an unreachable database (a `try`/`catch` that answers with an error response, for example) from the first query to the `connect()` call. Answer a request that needs no query, such as an unknown route or a missing query parameter, before `connect()`, because `connect()` now opens a database connection whether or not a query follows; move such checks above the `connect()` call.
Before:
```ts
// src/prisma/db.ts
export const db = postgresServerless<Contract>({ contractJson });
// src/orm-client/client.ts
import type { Runtime } from '@prisma/orm-postgres/family-runtime';
import type { ExecutionContext } from '@prisma/orm-postgres/relational-core/query-lane-context';
import { db } from '../prisma/db';
const context = db.context as ExecutionContext<Contract>;
export function createOrmClient(runtime: Runtime) {
return orm({ runtime, context, collections: { User: UserCollection } }).public;
}
// src/worker.ts
import { db } from './prisma/db';
await using runtime = await db.connect({ url: env.HYPERDRIVE.connectionString });
const rows = await runtime.query(db.sql.public.user.select('id').build());
const users = await createOrmClient(runtime).User.newestFirst().all();
```
After:
```ts
// src/prisma/db.ts
/**
* Serverless client, built once per isolate. It holds no database connection. Each request opens
* its own connection with `postgres.connect({ url })`.
*/
export const postgres = postgresServerless<Contract>({ contractJson });
// src/orm-client/client.ts
import { orm } from '@prisma/orm-postgres/orm-client';
import type { PostgresServerlessConnection } from '@prisma/orm-postgres/serverless';
import type { Contract } from '../prisma/contract.d';
import { UserCollection } from './collections';
export function createOrmClient(
db: Pick<PostgresServerlessConnection<Contract>, 'runtime' | 'context'>,
) {
return orm({
runtime: db.runtime(),
context: db.context,
collections: { User: UserCollection },
}).public;
}
// src/worker.ts
import { postgres } from './prisma/db';
await using db = await postgres.connect({ url: env.HYPERDRIVE.connectionString });
const rows = await db.runtime().query(db.sql.public.user.select('id').build());
const users = await createOrmClient(db).User.newestFirst().all();
```
The same applies to scripts that connect through the serverless client, for example a seed script: `await using db = await postgres.connect({ url })`, then `db.runtime().execute(...)`.
## `serverless-connection-orm-and-transaction`
This change is optional. The connection builds an ORM client and runs transactions itself.
- For each query on a hand-built `orm({ runtime, context })` client, check whether it calls a method defined on a custom collection class. If it calls none, run it on `db.orm` instead, even when the hand-built client registers custom collections, and drop the hand-built client from that code path when nothing else there uses it. For example, `const orm = createOrmClient(db); const rows = await orm.Post.where({ userId }).all();` becomes `const rows = await db.orm.public.Post.where({ userId }).all();`. Keep the hand-built client, built from `db.runtime()` and `db.context`, for queries that call custom collection methods, such as `orm.User.newestFirst()`.
- Replace `withTransaction(runtime, async (tx) => ...)` with `db.transaction(async (tx) => ...)`. `tx` has the same `execute` and `query` as before, plus `tx.sql`, `tx.orm`, `tx.enums` and `tx.nativeEnums`. Remove the `withTransaction` import when nothing else uses it.
- Update comments and README text that name `withTransaction(...)`, or a hand-built ORM client for a query that now runs on `db.orm`, to say `db.transaction(...)` and `db.orm`.
Before:
```ts
import { withTransaction } from '@prisma/orm-postgres/family-runtime';
await using runtime = await db.connect({ url });
const posts = await orm({ runtime, context }).public.Post.where({ userId }).all();
await withTransaction(runtime, async (tx) => {
await tx.execute(db.sql.public.user.update({ displayName }).where((f, fns) => fns.eq(f.id, userId)).build());
});
```
After:
```ts
await using db = await postgres.connect({ url });
const posts = await db.orm.public.Post.where({ userId }).all();
await db.transaction(async (tx) => {
await tx.execute(db.sql.public.user.update({ displayName }).where((f, fns) => fns.eq(f.id, userId)).build());
});
```
## `serverless-cursor-default-off`
`postgresServerless()` used to read through `pg-cursor` in batches of 100 rows unless you passed `cursor: { disabled: true }`. Reads are now buffered by default, the same as on `postgres()`: the whole result arrives before the first row is yielded. The `cursor` option is now `PostgresCursorOptions`, which is `{ batchSize?: number | undefined }`: leaving it unset keeps cursors off, and setting it turns them on, in batches of 100 when `batchSize` is omitted or `undefined` (`{}`), or of `n` for `{ batchSize: n }` with a positive integer `n`; any other `batchSize` fails the factory call. There is no `disabled` flag: `cursor: { disabled: true }` and `cursor: { disabled: false }` no longer compile, and a `disabled` key that reaches the factory at run time, from JavaScript or from options loaded from JSON, fails the call with `RUNTIME.ARGUMENT_INVALID`.
1. Find the paths that rely on batched streaming, for example `for await` over a large result with an early `break`. Leave the serverless client every other path uses without a `cursor` option; those paths now buffer. To keep a streaming path streaming, give it its own serverless client, for example `streamingPostgres`, created with the same options as the serverless client the other paths use (such as `middleware` and `extensions`) plus `cursor: { batchSize: 100 }`, and open that path's connection from it, so each request still opens one connection. Document on that serverless client that it is used only by that path and that reads through its connections hang behind Cloudflare Hyperdrive. Behind real Cloudflare Hyperdrive that path hangs, because reads with cursors on hang there; the other paths do not. If the path must work behind real Hyperdrive, do not create the second serverless client and accept buffered reads on it instead. Never put the `cursor` option on the serverless client every path uses. On a connection from the streaming serverless client, a `for await` over a read must end before the next query through `db`; a query inside the loop waits forever, because the cursor holds the connection's only database connection until the loop ends.
2. Delete `cursor: { disabled: true }` from `postgresServerless(...)` options. Unset is now off, and the flag no longer compiles. Delete `disabled: false` and keep the rest: `cursor: { disabled: false }` becomes `cursor: {}` (batches of 100), and `cursor: { disabled: false, batchSize: 50 }` becomes `cursor: { batchSize: 50 }`. Leave `cursor: { batchSize: n }` as it is; it means the same as before.
3. Replace the type `PostgresServerlessCursorOptions` with `PostgresCursorOptions`, exported from `@prisma/orm-postgres/serverless` and `@prisma/orm-postgres/runtime`.
4. Update comments and README text that say the serverless client streams through a cursor by default. Say which paths use the streaming serverless client, and that those paths hang behind real Cloudflare Hyperdrive while the other paths do not.
A streaming path, before:
```ts
// src/prisma/db.ts
export const postgres = postgresServerless<Contract>({ contractJson });
// src/worker.ts, in fetch; the /cursor/large path streams with `for await` and breaks early
await using db = await postgres.connect({ url: env.HYPERDRIVE.connectionString });
```
After:
```ts
// src/prisma/db.ts
export const postgres = postgresServerless<Contract>({ contractJson });
/**
* Serverless client with cursors on, used only by the `/cursor/large` route to stream a large
* result. Reads through its connections hang behind Cloudflare Hyperdrive.
*/
export const streamingPostgres = postgresServerless<Contract>({
contractJson,
cursor: { batchSize: 100 },
});
// src/worker.ts
import { postgres, streamingPostgres } from './prisma/db';
// in fetch
const routePostgres = url.pathname === '/cursor/large' ? streamingPostgres : postgres;
await using db = await routePostgres.connect({ url: env.HYPERDRIVE.connectionString });
```
`cursor: { disabled: true }` and the old type, before:
```ts
import postgresServerless, {
type PostgresServerlessCursorOptions,
} from '@prisma/orm-postgres/serverless';
const cursor: PostgresServerlessCursorOptions = { disabled: true };
export const postgres = postgresServerless<Contract>({ contractJson, cursor });
```
After:
```ts
import postgresServerless from '@prisma/orm-postgres/serverless';
export const postgres = postgresServerless<Contract>({ contractJson });
```
`disabled: false` next to a batch size, before:
```ts
export const streamingPostgres = postgresServerless<Contract>({
contractJson,
cursor: { disabled: false, batchSize: 50 },
});
```
After:
```ts
export const streamingPostgres = postgresServerless<Contract>({ contractJson, cursor: { batchSize: 50 } });
```
A batch size alone, before and after (unchanged):
```ts
export const streamingPostgres = postgresServerless<Contract>({ contractJson, cursor: { batchSize: 100 } });
```
## `date-time-default-stored-in-canonical-form`
Each date and time type now stores one text for each value, its canonical form. These three defaults are one instant, and each is stored as `"2024-01-01T00:00:00Z"`:
```prisma
model Event {
id Int @id
a DateTime @default("2024-01-01T00:00:00Z")
b DateTime @default("2024-01-01T00:00:00.000Z")
c DateTime @default("2024-01-01T01:00:00+01:00")
}
```
| Column type | Written | Stored before | Stored now |
| --- | --- | --- | --- |
| `DateTime`, `Timestamptz` | `2024-01-01 01:00:00+01` | as written | `2024-01-01T00:00:00Z` (UTC) |
| `Timestamp` | `2024-01-01 12:34:56.500` | as written | `2024-01-01T12:34:56.5` |
| `Date` | `0044-03-15 BC` | as written | `-000043-03-15` |
| `Time` | `12:34` | as written | `12:34:00` |
| `Timetz` | `12:34:56+02` | as written | `12:34:56+02:00` |
| SQLite `DateTime` | `2024-01-01 01:00:00+01:00` | as written | `2024-01-01T00:00:00Z` |
A year outside 0000 to 9999 is a sign and six digits, and year 0000 is 1 BC. `prisma contract infer` and `prisma contract print` print a date or time default in the same form.
1. Search your `.prisma` files for `@default("` on a date or time column.
2. Run `prisma contract emit` and review the diff of `contract.json`. A default whose text changed changes the storage hash. The database does not change: schema verification compares the old and the new text as the same value. Re-emit before you run `prisma db verify` or `prisma db update`. With the earlier `contract.json`, `prisma db verify` names the refusal of a default this version refuses and suggests re-emitting. `prisma db update`, `prisma db init` and `prisma migration plan` refuse to write that default with `CONTRACT.DEFAULT_INVALID`.
3. If you create the database with `prisma db init` or `prisma db update`, `prisma db verify` now reports that the database is signed with the earlier contract. Run `prisma db verify --schema-only` to confirm the schema matches, then `prisma db sign` to sign the database with the re-emitted contract. Sign the database before you deploy the re-emitted contract. Until then the application logs `CONTRACT.MARKER_MISMATCH`, because the database marker holds the earlier storage hash.
4. If you use migrations, `prisma migration plan` refuses with "Contract changed but planner produced no operations", because nothing in the database changes. Run `prisma migration new --name canonical-date-defaults` to write an empty migration from the earlier contract to the re-emitted one, then `prisma db migrate`. When `migration new` cannot tell where to start, pass `--from` with the storage hash of the earlier contract: the `to` hash of your latest migration, which `prisma migration list` shows. Apply the migration before you deploy the re-emitted contract. Until then the application logs `CONTRACT.MARKER_MISMATCH`, because the database marker holds the earlier storage hash.
### SQLite tables created before the upgrade
SQLite compares text byte by byte, so a `DateTime` default must be the same text the application writes for the same instant. Tables and columns created from now on get that text, such as `'2024-01-01T00:00:00.000Z'`. A table created before the upgrade keeps the default text it was created with, such as `'2024-01-01T01:00:00+01:00'` or `'2024-01-01T00:00:00Z'`. Every row that took that default compares and sorts wrongly against rows the application wrote. `prisma db verify` does not report it, because it compares the two texts as the same instant.
Find the rows that hold the old text, and rewrite them to the text the application writes:
```sql
SELECT count(*) FROM "event" WHERE "at" = '2024-01-01T01:00:00+01:00';
UPDATE "event" SET "at" = '2024-01-01T00:00:00.000Z' WHERE "at" = '2024-01-01T01:00:00+01:00';
```
SQLite cannot change a column's default in place, so new rows keep taking the old text until the table is rebuilt. A migration that rebuilds the table, which the planner writes for a change to one of its columns, writes the new default text. Until then, run the `UPDATE` again after inserts that take the default.
## `date-time-default-refused-text`
`prisma contract emit` now refuses a date or time default that its column's type does not hold, with `PSL_INVALID_DEFAULT_LITERAL`:
```text
Field "Event.localAt": pg/timestamp holds no UTC offset, but "2024-01-01T00:00:00Z" has one. Leave it out, as in "2024-01-01T12:34:56".
```
| Refused | Fix |
| --- | --- |
| an offset on `Timestamp`, `Date` or `Time` | remove the offset, or make the column `DateTime` if it holds an instant |
| no offset on `DateTime`, `Timestamptz`, `Timetz` or SQLite `DateTime` | add `Z` for UTC, or the offset, as in `2024-01-01T00:00:00Z` |
| a time on a `Date` column, or a date on a `Time` or `Timetz` column | remove the part the column does not hold |
| more than six digits after the decimal point, or more than three on a SQLite `DateTime`, which holds milliseconds | round to six digits or fewer, or three on SQLite |
| a date or time that does not exist, such as `2024-02-30`, `25:00:00` or `24:00:00` | write a real date or time; for `24:00:00`, write ``@default(sql`'24:00:00'::time`)`` |
| a date outside the range the column's type holds, such as a date before 24 November 4714 BC on Postgres | write a date inside the range the message names |
| a ` BC` suffix on a SQLite `DateTime` | write a signed year, as in `-000043-03-15T00:00:00Z` for 44 BC |
Run `prisma contract emit` after each fix until it succeeds.
A fix that gives the default a different value, such as a rounded time or another date, also changes the default the database holds. That needs a real migration: plan one with `prisma migration plan`, or run `prisma db update`. The empty migration and `prisma db sign` of `date-time-default-stored-in-canonical-form` cover only a new text for the same value.
## `date-time-ts-default-stored-in-canonical-form`
A TypeScript contract stores the same canonical form as PSL. The codecs whose value is a `Date` or database text now write it:
```typescript
createdAt: field.temporal.timestamptzJsDate().default(new Date('2024-01-01T00:00:00Z')),
// stored before: "2024-01-01T00:00:00.000Z"; stored now: "2024-01-01T00:00:00Z"
```
A `*String` preset or column helper default is read by the column's type the same way PSL text is, so `'2024-01-01 00:00:00+00'` on `field.temporal.timestamptzString()` is stored as `"2024-01-01T00:00:00Z"`. Text the type does not hold is refused with `CONTRACT.DEFAULT_INVALID`, and the message says what is wrong, as in the table above. Defaults on `field.dateTime()` and the other `Temporal` presets were already stored in this form and do not change. A `Temporal` default with digits below one microsecond, such as `Temporal.Instant.from('2024-01-01T00:00:00.123456789Z')`, is now refused the same way, because the database holds microseconds; round it to six digits.
1. Search your contract files for `.default(` on the presets and helpers named above.
2. Run `prisma contract emit` and review the diff of `contract.json`.
3. Sign the database or record an empty migration, as steps 3 and 4 of `date-time-default-stored-in-canonical-form` describe.
## `mongo-create-returns-stored-document`
This change has no detection pattern: it depends on what the code does with the value `create()` or `createAll()` returns.
Code that relied on getting its input objects back, such as a `Long`, `Int32`, `Double` or `Uint8Array` inside a `Bson` field, or a key missing for a nullable field it did not pass, now gets the values a read returns. Compare with what a read returns, or keep a reference to the input instead.
## `mongo-reads-decode-includes-value-objects-and-absent-fields`
This change has no detection pattern: it depends on the fields and the data.
Remove code that converted driver classes by hand in included documents or composite-type fields, such as `author._id.toHexString()`, `karma.toBigInt()` or `new Uint8Array(avatar.value())`: they now arrive as a hex string, `bigint`, decimal text and `Uint8Array`, as their types say. Replace checks like `user.name === undefined` for an optional field that Prisma 6 or the driver left out of the document with `user.name === null`.
## `mongo-where-filter-expressions-encoded`
The pattern finds filter expressions written inside `where(...)`; also check expressions built elsewhere and passed to the ORM's `where()`. Leave filters passed to the query builder's `match()` as they are.
For each field filter, pass the field's application value: a `bigint` for an `Int64` field, decimal text for a `Decimal128` field, a hex string or an `ObjectId` for an `ObjectId` field, and an integer in the signed 32-bit range for an `Int32` field. A value that is not a `MongoValue`, such as a `bigint`, an `ObjectId` or a driver `Long`, goes in a `MongoParamRef` from `@prisma/orm-mongo/value`: write `MongoFieldFilter.gt('views', new MongoParamRef(5n))` in place of `MongoFieldFilter.gt('views', Long.fromNumber(5))` or `5`. A value the field's codec refuses now fails with `RUNTIME.ENCODE_FAILED` before the query runs; before, it was sent as is. A filter on a `Bson` field with an `ObjectId`, `Long` or `Decimal128` now matches the stored value; before, it matched nothing.
## `mongo-codecs-check-value-types`
This change has no detection pattern: which writes carry such values depends on the data.
Pass each field's application type: a string for `String`, a boolean for `Bool`, a valid `Date` for `Date`, and a 24-digit hex string or an `ObjectId` for `ObjectId`. Remove `null` elements from lists before writing them; a list field's elements cannot be `null`.
## `mongo-list-elements-encoded`
This change has no detection pattern: it applies to every list field.
Remove workarounds for list fields that failed to write, such as declaring an `ObjectId[]` field as `String[]` or `Bson`.
## `mongo-query-builder-bson-values-match`
This change has no detection pattern: filters that matched nothing do not look different in code.
Remove workarounds for query-builder filters that compared with a driver class and matched nothing, such as running those queries through the driver's own collection.
## `mongo-upsert-keeps-create-values`
This change has no detection pattern: it depends on which fields `create` sets.
Remove code that corrected such a field after an upsert inserted a document. If an upsert whose `create` sets such a field pulls by a match document, pull a single value instead, or leave the field out of `create` so the update default applies.
## `mongo-writes-check-int32-enum-and-null-values`
This change has no detection pattern: which writes carry such values depends on the data.
Round a number before writing it to an `Int32` field, or declare the field `Double` or `Int64`. Map any value outside an enum, such as a Prisma 6 member name like `'ADMIN'` where the stored value is `'admin'`, to one of the enum's values. The error's `details.allowed` lists them. Instead of writing `null` to a required field, write a value, or declare the field optional (`String?`) when documents may hold `null`.
## `contract-source-warnings-are-diagnostics`
This change has no detection pattern: scripts that read the CLI's output are not TypeScript sources.
A script that read contract source warnings from `contract emit --json` or `contract print --json` message lines of the form `warning <path>:<line>:<column> <CODE> <message>` reads the result envelope's `diagnostics` instead. Each has `code: 'CONTRACT.SOURCE_DIAGNOSTIC'`, `severity: 'warn'`, `where: { path, line }` and `meta: { code, span }`, where `meta.code` is the source's code and `meta.span.start.column` the column.
## `mongo-ts-preset-field-not-optional`
Remove `.optional()` or `.many()` from each match. A field a preset fills cannot be optional or a list; to keep an optional timestamp that nothing fills, use `field.date().optional()`.
## `contract-artifacts-restamp`
For every `contract.json` matched by `detection`, run the project's emit command (`prisma contract emit`, or the project's `contract:emit` script) once after upgrading. This entry accounts for the embedded `version` moving to `8.0.0-rc.14`; any other difference in the emitted files comes from an earlier entry in this guide.
upgrading/app/upgrades/8.0.0-rc.14-to-8.0.0-rc.15/instructions.md
---
from: "8.0.0-rc.14"
to: "8.0.0-rc.15"
changes:
- id: contract-stores-data-type
summary: |
A SQL contract names each column's data type in `dataType` (for example `pg/int4`) instead of
its database type name in `nativeType`. Upgrade every extension that ships migrations in the
same step, then run the colocated script on the project: it rewrites every contract, writes
its lists in this release's new form, renames the snapshot directories to the new storage
hashes, and rewrites the migrations, refs, `migration.ts` files and `contract.d.ts` files
that name them.
detection:
glob: "**/*.json"
matches:
- '"nativeType"\s*:\s*"'
script: ./scripts/data-type-in-contract/data-type-in-contract.ts
- id: sign-databases-after-upgrade
summary: |
The upgrade changes every contract's storage hash, so every database's marker names a hash
the project no longer has. Run `prisma db sign` against every database before deploying the
application built with the new contract. `db sign` now signs every contract space, and its
`--json` document is `{ ok, summary, spaces, advancedRefs }`.
- id: column-descriptors-drop-native-type
summary: |
A hand-written column descriptor names its codec only: `{ codecId: 'pg/text@1' }` instead of
`{ codecId: 'pg/text@1', nativeType: 'text' }`. Code that reads or builds a column of a stored
contract uses `dataType` (for example `pg/text`) instead of `nativeType` (`text`).
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$])(?<!readonly\s+)nativeType\s*:\s*[''"]'
- '\.nativeType\b'
- id: parameter-casts-use-base-names
summary: |
PostgreSQL parameter casts are written with the data type's base name: `$1::int4` instead of
`$1::integer`, and likewise `int2`, `int8`, `float4`, `float8` and `bool` instead of
`smallint`, `bigint`, `real`, `double precision` and `boolean`. Logged SQL and SQL snapshots
in tests change to match.
detection:
glob: "**/*.{ts,mts,cts,sql,json,snap}"
matches:
- '\$\d+::(?:integer|smallint|bigint|real|double precision|boolean)\b'
- id: ts-contract-lists-extension-codecs
summary: |
A TypeScript contract built with `defineContract` from `@prisma/orm-postgres/contract-builder`
or `@prisma/orm-sqlite/contract-builder` names each column's database type from the data type
its codec represents. A contract that uses an extension's codec without listing the extension
in `extensions` now fails with `CONTRACT.CODEC_DESCRIPTOR_MISSING`. List the extension.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\s\S])(?![\s\S]*GENERATED FILE - DO NOT EDIT)(?=[\s\S]*(?<![\w$])defineContract(?![\w$]))[\s\S]*[''"]@prisma/orm-extension-(?:pgvector|postgis|arktype-json)/column-types[''"]'
- '(?<![\s\S])(?![\s\S]*GENERATED FILE - DO NOT EDIT)(?=[\s\S]*(?<![\w$])defineContract(?![\w$]))[\s\S]*(?<![\w$])codecId\s*:\s*[''"](?:pg/vector|pg/geometry|arktype/json)@\d+[''"]'
- id: sqlite-contract-d-ts-char-aggregates
summary: |
On SQLite, `sql/char@1` and `sql/varchar@1` are registered codecs. Re-emit the contract:
`contract.d.ts` gains `min` and `max` aggregate rows for both codecs. `contract.json`, its
hashes and the migration SQL do not change.
detection:
glob: "**/contract.d.ts"
matches:
- '^(?![\s\S]*[''"]sql/char@1[''"]\s*:\s*\{\s*readonly output)[\s\S]*@prisma/orm-(?:target-)?sqlite/'
- id: column-helpers-raise-type-params-invalid
summary: |
pgvector's `vector(length)` and PostGIS's `geometry({ srid })` and `pgGeometryColumn({ srid })`
no longer check their arguments. Building the contract checks every column's parameters
against its data type: an argument outside its bounds now fails `defineContract` with
`CONTRACT.TYPE_PARAMS_INVALID` instead of failing the helper call with
`CONTRACT.ARGUMENT_INVALID`, and `srid: 0` is refused when the contract is built, not later
when a migration is planned.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bCONTRACT\.ARGUMENT_INVALID\b'
- '\bsrid\s*:\s*0\b'
- id: reemit-explicit-list-cardinality
summary: Re-emit list contracts with nested element nullability while preserving scalar JSON.
- id: refresh-historical-list-contracts
summary: Refresh historical MongoDB contract snapshots and their migration references together; for SQL contracts, `contract-stores-data-type` rewrites them.
- id: mongo-nullable-list-containers
summary: Re-emit Mongo contracts and migrate validators to accept nullable list containers.
- id: domain-types-match-their-columns
summary: |
The domain half of an emitted SQL contract now carries the type parameters and enum value sets the schema declares: on fields typed by a named type, on enum list fields, and on composite type members. In `contract.d.ts`, a composite type member with type parameters now has the parameterized output type. Re-emit the contract. This change leaves the storage half, every hash and migration snapshots unchanged.
detection:
glob: "**/contract.json"
matches:
- '"typeRef"\s*:'
- '"valueObjects"\s*:'
- '"valueSet"\s*:'
- id: value-object-default-matches-composite-type
summary: |
A literal default on a field typed by a composite type must now match the composite type, with each member value read by the member's codec and each enum member value one of the enum's values, or the schema is refused. Fix the default the diagnostic names.
detection:
glob: "**/*.prisma"
matches:
- '^\s*type\s+\w+\s*\{'
- id: composite-type-attributes-refused
summary: |
An attribute on a composite type or on one of its members is now refused, where it used to be ignored. Remove it.
detection:
glob: "**/*.prisma"
matches:
- '^\s*type\s+\w+\s*\{'
- id: codecs-check-stored-json
summary: |
A TypeScript `.default()` value or `enumType` member that its column's codec does not take is now refused when the contract is built, with `CONTRACT.DEFAULT_INVALID` or `CONTRACT.ENUM_INVALID`. A `contract.json` that holds such a default stops `db init`, `db update` and `migration plan` with `CONTRACT.DEFAULT_INVALID`, and a `migration.ts` that holds one fails when it runs. Correct the value the error names.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\.default\(\s*(?!now\(|sql`|autoincrement\()'
- '\benumType\s*\('
- id: psl-values-checked-by-codecs
summary: |
A PSL schema whose SQL enum member its codec does not take, or whose literal default its column's type does not hold, is now refused at `contract emit`, where it used to load. Correct the member or the default.
detection:
glob: "**/*.prisma"
matches:
- '@@type\(\s*"(?:pg|sql|sqlite)/'
- '^\s*\w+\s*=\s*-?\d{10,}\s*$'
- '@default\(\s*(?:"|-?\d|\[)'
- id: uuid-defaults-stored-as-postgresql-writes
summary: |
A uuid default written in upper case, in braces or without hyphens, in PSL or in a TypeScript `.default()`, is now stored as PostgreSQL writes it, so emitting the contract again changes its storage hash. Earlier versions could not apply such a contract: the command that applied it failed and changed nothing. Emit the contract again, then run that command again. With migrations, first delete the migration package that never applied.
detection:
glob: "**/*.{prisma,ts,mts,cts,tsx}"
matches:
- '\bUuid\b[^\n]*@default\(\s*"(?![0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")\{?[0-9A-Fa-f]{4}'
- '\b(?:uuidNative|pgUuidColumn)\s*\([^\n]*\.default\(\s*[''"`](?![0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}[''"`])\{?[0-9A-Fa-f]{4}'
- '^\s*\.default\(\s*[''"`](?![0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}[''"`])(?=[^''"`\n]*[A-F{-])\{?[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}\}?[''"`]'
- id: ts-enum-member-written-as-stored
summary: |
`defineContract` from the Postgres and SQLite packages now refuses an `enumType` member that its codec takes but stores as a different value, with `CONTRACT.ENUM_INVALID`. A uuid member written with an upper-case hex digit, in braces, or with hyphens anywhere other than the 8-4-4-4-12 positions Postgres prints (including none) is refused, because `pg/uuid@1` stores lower-case 8-4-4-4-12 text. Write each refused member as the error message says, re-emit, and apply a migration that replaces the enum's CHECK constraint.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\benumType\('
- id: mongo-codecs-check-json
summary: |
The built-in Mongo codecs now refuse a JSON value that is not the JSON form of their type, where most passed it through: a PSL enum member whose value its `@@type` codec does not take is now refused at `contract emit`, and a TypeScript `enumType` member that `mongo/objectId@1` or `mongo/int32@1` does not hold is refused when the contract is built. Correct the member.
detection:
glob: "**/*.{prisma,ts,mts,cts,tsx}"
matches:
- '@@type\(\s*"mongo/'
- '[''"]mongo/(?:objectId|int32)@1[''"]'
- '\bMONGO_(?:OBJECTID|INT32)_CODEC_ID\b'
- id: text-array-elements-nullable
summary: |
A `textArray()` column's elements are now typed `string | null`, because a `text[]` holds NULL elements, which it reads as `null`. Handle the `null`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\btextArray\s*\('
- '[''"]pg/text-array@1[''"]'
- id: char-reads-drop-only-padding
summary: |
A `char(n)` column now reads the same through `.include()` as through a flat read: without the trailing spaces that pad it, where an include used to return them, and keeping a trailing tab or newline, which a flat read used to drop. Compare `char` values without their padding.
detection:
glob: "**/contract.json"
matches:
- '"codecId"\s*:\s*"(?:sql|pg)/char@1"'
- id: sqlite-nan-parameters-refused
summary: |
On SQLite, NaN written to a float column or used as a filter value now throws `RUNTIME.ENCODE_FAILED` naming the codec, where SQLite stored NULL or matched nothing. Write `null` for no value.
detection:
glob: "**/contract.json"
matches:
- '"target"\s*:\s*"sqlite"'
- id: sqlite-int-include-refuses-inexact-values
summary: |
On SQLite, an `.include()` of a row whose `sql/int@1` column holds an INTEGER past 2^53, or a REAL, now throws `RUNTIME.DECODE_FAILED`, where it read the value rounded or with a fraction. Store such values in a `BigInt` or `Float` column.
detection:
glob: "**/contract.json"
matches:
- '"codecId"\s*:\s*"sql/int@1"'
- id: prefixed-sql-tags-are-removed
summary: |
The tags `pg.sql` and `sqlite.sql` are removed. Write `sql`.
detection:
glob: "**/*.{prisma,ts}"
matches:
- '(?<![\w./-])(pg|sqlite)\s*\.\s*sql\s*\\?[\x60"'']'
- id: default-diagnostic-codes-changed
summary: |
Four PSL diagnostic codes for written values changed: `PSL_UNKNOWN_DEFAULT_LITERAL_TAG` is now `PSL_UNKNOWN_LITERAL_TAG`, `PSL_INVALID_JSON_LITERAL` is now `PSL_INVALID_LITERAL`, `PSL_DEFAULT_TYPE_INCOMPATIBLE` is now `PSL_VALUE_TYPE_INCOMPATIBLE`, or `PSL_DEFAULT_LIST_EXPECTED` for a single value on a list column, and most cases of `PSL_INVALID_DEFAULT_LITERAL` moved to `PSL_INVALID_LITERAL`.
detection:
glob: "**/*.{ts,mts,cts,js,mjs}"
matches:
- '\bPSL_INVALID_JSON_LITERAL\b'
- '\bPSL_UNKNOWN_DEFAULT_LITERAL_TAG\b'
- '\bPSL_DEFAULT_TYPE_INCOMPATIBLE\b'
- '\bPSL_INVALID_DEFAULT_LITERAL\b'
- id: migration-ts-column-defaults
summary: |
In `migration.ts`, the adapter writes every column default, reading it with the column's codec. Postgres `setDefault` takes the column as `col(name, type, { default, codecRef })` instead of `column` (the name) and `defaultSql`. A SQLite `addColumn` or `recreateTable` column carries `default` and `codecRef` instead of `defaultSql`, and a `recreateTable` postcheck for a default is `{ description, columnDefault }`. An earlier `migration.ts` that uses `defaultSql` no longer compiles, and running it with `node migration.ts` stops with `MIGRATION.OPERATION_OPTION_REMOVED`; its `ops.json` still applies.
detection:
glob: "**/migration.ts"
matches:
- '\bdefaultSql\s*:'
- id: postgres-changed-default-applied
summary: |
On PostgreSQL, `db update` and `db migrate` now change a column default that is already there. They used to skip the change and then fail with `MIGRATION.SCHEMA_VERIFY_FAILED`. A migration an earlier version planned still skips it: before you apply it, delete its package and plan it again, or rewrite its `setDefault` call to the form `migration-ts-column-defaults` shows and run its `migration.ts` to write `ops.json` again.
detection:
glob: "**/ops.json"
matches:
- '"id":\s*"setDefault\.'
- id: cli-error-from-caught
summary: |
`mapCaughtMigrationError` is removed from `@prisma/orm-toolchain/cli/control-api` (`@internal/cli/control-api`). Use `errorFromCaught(error, why)`, which always returns an error: a CLI error as it is, any error with a structured code as itself, and anything else as `CLI.UNEXPECTED` with `why(message)`. It throws an `InternalError` again.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bmapCaughtMigrationError\b'
- id: sql-with-a-line-comment-gets-a-new-wire-name
summary: |
An index, check or policy whose SQL body contains both `--` and a line break gets a new name in `contract.json` once. The next `migration plan` drops and recreates the object.
detection:
glob: "**/contract.json"
matches:
- '"(?:[^"\\]|\\.)*?--(?:[^"\\]|\\.)*?\\n|"(?:[^"\\]|\\.)*?\\n(?:[^"\\]|\\.)*?--'
- id: strict-verify-unclaimed-code
summary: |
`prisma db verify --strict` now reports a database holding tables no contract declares under `CONTRACT.SCHEMA_VERIFICATION_FAILED`, in both the diagnostic and the JSON result's `code`. It used to report `CONTRACT.MARKER_REQUIRED`. The exit code is still 4. `CONTRACT.MARKER_REQUIRED` now only means the database has not been signed.
detection:
glob: "**/*.{ts,mts,cts,js,mjs,cjs,sh,yml,yaml,json}"
matches:
- 'CONTRACT\.MARKER_REQUIRED'
- id: prisma7-schema-date-types-are-text
summary: |
A contract from `prisma7Schema(...)` now reads a Prisma 7 `DateTime` column and `@db.Timestamp`, `@db.Timestamptz`, `@db.Date` and `@db.Time` columns as the text PostgreSQL prints (`TimestampString(3)`, `TimestampString(p)`, `TimestamptzString(p)`, `DateString`, `TimeString(p)`), not as `Temporal` values. `@updatedAt` still writes UTC. The application needs no `Temporal` for them. Re-emit, change code that treats these fields as `Temporal` values, then run `prisma db sign`.
detection:
glob: "**/prisma.config.{ts,mts,cts,js,mjs}"
matches:
- '\bprisma7Schema\s*\('
- id: contract-infer-writes-text-date-types
summary: |
`prisma contract infer` now writes `TimestampString(p)`, `TimestamptzString(p)`, `DateString` and `TimeString(p)` for `timestamp`, `timestamptz`, `date` and `time` columns, where it wrote `Timestamp(p)`, `Timestamptz(p)`, `Date` and `Time(p)`. A contract inferred earlier keeps its types until infer runs again.
detection:
glob: "**/*.prisma"
matches:
- 'Contract inferred from the live database schema'
- id: text-timestamp-now-is-utc
summary: |
A `timestamp` column of type `TimestampString(p)` that the ORM fills with `now` now receives the UTC wall-clock time on a host outside UTC. Before, it received the host's local time. No code changes.
detection:
glob: "**/*.{prisma,ts,mts,cts}"
matches:
- '\btimestampString\s*\([^)\n]*\bnow\b'
- id: contract-infer-writes-bytea-default-literals
summary: |
`prisma contract infer` now writes a `bytea` column default as a base64 literal, `@default("aGVsbG8=")`, where it wrote ``@default(sql`'\\x68656c6c6f'::bytea`)``. A contract emitted from the new output stores a value instead of an expression, so it gets a new storage hash while the database does not change. Re-emit, then `prisma db sign`, or record an empty migration with `prisma migration new`.
detection:
glob: "**/*.prisma"
matches:
- '\bBytes(?:\[\])?\??[ \t]+[^\n]*@default\(sql[^\n]*::bytea'
- id: prisma6-int-written-as-long
summary: |
A contract read with `prisma6Schema(...)` now gives a plain Prisma 6 `Int` field, and `Int @db.Long`,
the `mongo/int64Number@1` codec: the application type stays `number`, and Prisma 8 now writes these
fields as a BSON long, as Prisma 6 does, instead of an int. A document in which such a field holds a
fractional number, which the previous contract let Prisma 8 write, now fails to read with
`RUNTIME.DECODE_FAILED`: repair those documents, then re-emit the contract.
detection:
glob: "**/prisma.config.{ts,mts,cts,js,mjs}"
matches:
- '\bprisma6Schema\s*\('
- id: prisma6-bytes-objectid-is-hex
summary: |
A Prisma 6 MongoDB schema with a native type Prisma 6 accepts now emits with `prisma6Schema(...)`
(only `DateTime @db.Timestamp` is still refused). A `Bytes @db.ObjectId` field holds an ObjectId, so
Prisma 8 reads it as a 24-digit hex string and writes only a hex string or an ObjectId; a 12-byte
`Buffer` or `Uint8Array`, which the Prisma 6 client uses for it, is refused.
detection:
glob: "**/*.prisma"
matches:
- '\bBytes(?:\[\])?\??[ \t]+[^\n]*@db\.ObjectId\b'
- id: writes-on-a-conditional-collection-are-refused
summary: |
A write (`update`, `updateAll`, `updateAndCount`, `delete`, `deleteAll`, `deleteAndCount`) on a collection that is filtered on some code paths and not on others no longer compiles. A pattern cannot tell which collections those are: act only where the compiler reports "The 'this' context of type '...' is not assignable to method's 'this' of type 'HasWhere'". Filter on every path, or make the write only where the filter was applied.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\.(?:updateAll|updateAndCount|deleteAll|deleteAndCount)\s*\('
- '\.update\s*\('
- '\.delete\s*\(\s*(?:\)|\()'
- id: writes-refuse-what-they-would-ignore
summary: |
`updateAll`, `updateAndCount`, `deleteAll` and `deleteAndCount` now throw `ORM.ARGUMENT_INVALID` on a collection that has a `limit`, an `offset`, a `cursor`, `distinct` or `distinctOn`. These writes change every row that matches the filter; their statement cannot apply any of these, so they were ignored and more rows changed than the chain asked for. Remove them before the write, or read the rows first and change them by their ids. `update` with a relation callback now throws on a collection with an order, a limit, an offset, a cursor, `distinct` or `distinctOn`, which it ignored; filter it to the one row instead. `update` and `delete` without a relation callback change the row `first()` returns, as before, except after `limit(0)`: they changed one row and now change none and return `null`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\.(?:limit|offset|cursor|distinct|distinctOn)\s*\([^)]*\)[\s\S]{0,300}?\.(?:update|updateAll|updateAndCount|delete|deleteAll|deleteAndCount)\s*\('
- '\.orderBy\s*\([\s\S]{0,300}?\.update\s*\('
- id: cursor-and-distinct-on-check-the-receiver
summary: |
`cursor` and `distinctOn` now require an order on the collection they are called on, checked on the receiver. A cast on the argument, such as `cursor({ id } as never)`, no longer bypasses the check; add the `orderBy`, or cast the collection to `Ordered<C>` where the query is meant to have no order.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\.(?:cursor|distinctOn)\s*\([^)]*\bas\s+never\b'
- id: apply-is-a-collection-member
summary: |
Every collection now has an `apply` method. A custom collection class that declares its own `apply` member with another signature no longer compiles; rename it.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?:^|\n)[ \t]*(?:(?:public|protected|private|readonly|static|async|override)\s+)*apply\s*[(<:=?]'
- id: scope-is-a-collection-member
summary: |
Every collection now has a `scope` method. A custom collection class that declares its own `scope` member with another signature no longer compiles; rename it.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?:^|\n)[ \t]*(?:(?:public|protected|private|readonly|static|async|override|get|set)\s+)*scope\s*[!(<:=?]'
- id: overriding-a-chaining-method
summary: |
In a class that extends `Collection`, an override of a chaining method (`where`, `orderBy`, `limit`, `offset`, `distinct`, `distinctOn`, `cursor`, `include`) or of a method that returns rows (`all`, `first`, `create`, `createAll`, `upsert`, `update`, `updateAll`, `updateAndCount`, `delete`, `deleteAll`, `deleteAndCount`) must use the new signature, which takes a `this` parameter.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?:^|\n)[ \t]*(?:(?:public|protected|override|async)\s+)*(?:where|orderBy|limit|offset|distinct|distinctOn|cursor|include|all|first|create|createAll|upsert|update|updateAll|updateAndCount|delete|deleteAll|deleteAndCount)\s*[(<]'
- id: collection-state-flags-are-boolean
summary: |
In `DefaultCollectionTypeState`, `hasWhere`, `hasOrderBy` and `hasUniqueFilter` are `boolean` (not known) instead of `false`. Code that expects `false` on a collection with no filter or order must expect `boolean`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bhas(?:Where|OrderBy|UniqueFilter)\b[''"]?\]?\s*,\s*false\b'
- '\bhas(?:Where|OrderBy|UniqueFilter)\s*:\s*false\b'
- id: read-collection-state-and-row-with-helpers
summary: |
A collection's type state and row are read with `CollectionTypeStateOf<C>` and `CollectionRowOf<C>`, not by inferring the type arguments of `Collection`. The type arguments keep what the collection started with.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bCollection<[^;]*?\binfer\b'
- id: return-type-of-a-chaining-method
summary: |
`ReturnType` of `where`, `orderBy`, `limit`, `offset`, `distinct`, `distinctOn`, `cursor` or `include` no longer gives a collection. Write `Filtered<C>` after `where`, `Ordered<C>` after `orderBy`, and `C` after the others, or take `typeof` of a value.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bReturnType<[^>]*\[[''"](?:where|orderBy|limit|offset|distinct|distinctOn|cursor|include)[''"]\]'
- '\bReturnType<\s*typeof\s+[\w$.]+\.(?:where|orderBy|limit|offset|distinct|distinctOn|cursor|include)\b'
- id: chaining-methods-take-no-explicit-type-arguments
summary: |
`include`, `distinct` and `distinctOn` with explicit type arguments no longer compile: `posts.include<'user'>('user')` and `posts.distinct<['title']>('title')` fail, and `ReturnType<typeof posts.include<'user'>>` is `never`. Drop the type arguments; they are inferred from the arguments, so `posts.distinct('title')` needs none.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\.(?:include|distinct|distinctOn)<'
- id: custom-collection-methods-chain
summary: |
Optional. Custom collection methods now stay available after the built-in chaining methods. Where code repeats a class method's body inline after a chaining call, it can call the method instead.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bextends\s+Collection<'
- id: variant-takes-discriminator-value
summary: |
`.variant()` on a polymorphic SQL or Mongo ORM collection takes the discriminator value a variant declares instead of the variant's model name: `db.orm.public.Task.variant('bug')` for `@@base(Task, "bug")`, where it used to be `.variant('Bug')`. A value the model does not declare, or a call on a model with no discriminator, now throws `ORM.ARGUMENT_INVALID` instead of returning the collection unchanged. `.variant()` on a collection that already has a variant selected is now rejected: select the variant from the base collection.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\.variant\('
- id: imported-postgres-field-checks-defaults
summary: |
The `field` exported by the Postgres facade's `contract-builder` entry now has the Postgres presets and checks a `.default(...)` value against the Postgres target's column types, as the `defineContract` callback's `field` does; it does not know the column types an extension adds, such as pgvector's, so it does not check those. A default of the wrong type, which compiled before and failed when the contract was built, is now a compile error; give the value the column's type.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'import\s*\{[^}]*\bfield\b[^}]*\}\s*from\s*[''"]@(?:prisma/orm-|internal/)postgres/contract-builder[''"]'
- id: re-emit-for-the-row-locking-capabilities
summary: "The Postgres adapter reports seven new capability keys (sql.forUpdate, sql.forShare, sql.lockOf, sql.lockNowait, sql.lockSkipLocked, postgres.forNoKeyUpdate, postgres.forKeyShare), which gate the new row-locking methods on the SQL builder; a contract emitted before this release does not carry them and the methods are unavailable against it, so re-emit the contract before using them."
detection:
glob: "**/contract.json"
contains:
- '"distinctOn"'
- id: cache-annotation-ttl-removed
summary: "cacheAnnotation from @prisma/orm-extension-middleware-cache no longer takes ttl. Every annotated read is now cached, including cacheAnnotation({}) and cacheAnnotation({ key }), which used to pass through uncached; how long an entry lives is the store's policy (the default store: 60 seconds). Remove ttl from every cacheAnnotation call."
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx}"
matches:
- '\bcacheAnnotation\s*\('
- id: cache-annotation-skip-renamed-bypass
summary: "cacheAnnotation({ skip }) is now cacheAnnotation({ bypass }), and the CachePayload type is now CacheAnnotationOptions. Detection finds skip written inside a cacheAnnotation({ ... }) literal and any use of CachePayload; options built elsewhere without that type are not detected."
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx}"
matches:
- '\bCachePayload\b'
- '\bcacheAnnotation\s*\(\s*\{[^}]*\bskip\s*:'
- id: cache-middleware-store-options
summary: "createCacheMiddleware no longer takes maxEntries or clock. Pass them to createInMemoryCacheStore and hand that store to createCacheMiddleware({ store }), or drop them when they match the new defaults (1000 entries). Detection finds maxEntries or clock written in the options literal of a createCacheMiddleware call; it also matches an already-migrated createCacheMiddleware({ store: createInMemoryCacheStore({ maxEntries }) }), which needs no change."
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx}"
matches:
- '\bcreateCacheMiddleware\s*\(\s*\{[^}]*\b(maxEntries|clock)\s*:'
- id: cache-store-object-arguments
summary: "A custom CacheStore is now a cache of values with a version per key: CacheStore<TMeta, TValue>. get({ key, meta }) replaces get(key) and returns a CacheEntry { key, meta, version, data } whose data is { empty: true } or { empty: false, value }; set(entry, value) replaces set(key, entry, ttlMs), stores only if the key's version still equals entry.version, and returns whether it stored; a new required unset({ keys, meta }) removes values and increments their keys' versions. CachedEntry is replaced by CacheEntry, and the rows type by CachedRows."
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx}"
matches:
- '\bCacheStore\b'
- '\bCachedEntry\b'
- '\bcreateCacheMiddleware\s*\('
- id: contract-artifacts-restamp
summary: |
An extension that writes its own package version into the contracts it emits, such as the
Supabase extension, now writes 8.0.0-rc.15. Run `contract emit` once after upgrading so the
emitted `contract.json` and `contract.d.ts` match the installed extension.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.14"'
---
# 8.0.0-rc.14 → 8.0.0-rc.15 — User upgrade instructions
## Order of steps
Apply this guide from top to bottom. Several entries tell you to run `prisma contract emit`. In a SQL project, do the first steps in this order:
1. Upgrade the framework packages and every extension that ships migrations in the same step (`contract-stores-data-type`).
2. Run the `data-type-in-contract` script (`contract-stores-data-type`) before any step that re-emits a contract. The script changes only files in the old format.
3. Run `prisma db sign` against every database (`sign-databases-after-upgrade`) before you re-emit. `db sign` must see the contract the database was created from, as the script rewrote it, before a re-emit changes it again.
4. Make the changes the other entries describe in your schema, your TypeScript contract and your application code.
5. Run `prisma contract emit`. One emit after all those changes covers every entry that asks you to re-emit.
6. Where an entry says the re-emit changes the storage hash, follow that entry's database steps: sign again, or plan and apply a migration. Do this before you deploy the application built with the new contract.
A MongoDB project skips steps 2 and 3, because the script rewrites SQL contracts only.
## `contract-stores-data-type`
Upgrade every extension that ships migrations (for example `@prisma/orm-extension-pgvector` and `@prisma/orm-extension-postgis`) in the same step as the framework, to the release its authors published for this change. An extension whose contract space is still in the old format makes the project refuse to load.
Do this before any other step of this release that re-emits a contract: the script changes only files in the old format, and `prisma db sign` (`sign-databases-after-upgrade`) must see the contract the database was created from before a re-emit changes it again. Commit your work first, so the script's changes can be reviewed and undone with git. The script follows links to files and directories, also outside the root, and rewrites the files there; commit or back up those too. Then run the script from the project root:
```sh
node <skill>/upgrading/app/upgrades/8.0.0-rc.14-to-8.0.0-rc.15/scripts/data-type-in-contract/data-type-in-contract.ts
```
`<skill>` is the directory of the synced `prisma-8` skill. Node 24 or later runs the TypeScript script directly; it needs no `tsx`. It reads and writes files only and needs no database. It rewrites every `*.json` file under the root that parses as a SQL contract in the old format (a column or `storage.types` entry that stores `nativeType`), skipping `node_modules`, `.git`, `dist` and `build`. That includes a test fixture of an old-format contract: if you keep such a fixture on purpose, restore it with git afterwards (`git restore <file>`), or keep it outside the project root. In each contract it rewrites, it also writes lists in this release's new form (`reemit-explicit-list-cardinality`): a list column or field stored as `many: true` becomes `many: { elementNullable: false }`, and `contract.d.ts` declares `many` on every column. This is the SQL part of `refresh-historical-list-contracts`.
On PostgreSQL, a list column of an enum keeps the membership check its database has, because the database still holds that check. Run `prisma db sign` before you emit the contract again. The next `prisma contract emit` writes the check as `array_remove(…) <@ ARRAY[…]` under a new name; `prisma migration plan` then writes a migration that drops the old check and adds the new one, and `prisma db migrate` applies it. The plan warns that dropping the old check may lose data; it does not, because only the check changes, not the column. If you already emitted before signing, `db sign` reports the new check as missing:
- In a project with migrations, sign each database with the storage hash the script printed (`prisma db sign <new hash>`), then plan and migrate as above.
- In a project without migrations, kept with `db init` or `db update`, run `prisma db update` and type the database name when it asks; where nobody can answer, run `prisma db update --no-interactive --confirm <database>`. It drops the old check and adds the new one, and `prisma db verify` then passes. `prisma db sign <new hash>` fails there with `MIGRATION.REF_NOT_FOUND`, because no migration holds that hash.
If the script stops on an error, for example on a full disk, it prints the error and `the upgrade stopped partway, run the script again to finish it`, and exits 1. After an error, Ctrl-C or a crash, run it again: it finishes the upgrade. A file the script was writing at that moment is either unchanged or complete, and it removes its own temporary files (ending in `.data-type-in-contract-tmp`) on the next run.
Run your formatter afterwards. The script replaces text in `migration.ts` and `contract.d.ts`, so the import order in `migration.ts` and the line wrapping in `contract.d.ts` can differ from what a fresh emit and your formatter produce.
When it finishes, it prints how many files it rewrote and how many snapshot directories it renamed, and each storage hash it replaced (`<old> -> <new>`). A contract already in the new format is never changed, even when its stored hash does not match its content, so a project already in the new format is left unchanged, and the script says that nothing changed. If it finds no SQL contract under the root, it says so; run it again from the project root. It prints `<file>: stored hash did not recompute; rehashed from content` for an old-format contract whose stored storage hash does not match its content, and rewrites it anyway. It changes no file and exits 1 when a column uses a codec it does not know (`<file>: unknown codec <id>; name its data type with --data-type <id>=<data type id>`) or when a renamed snapshot directory already exists with different content.
The script knows every codec that Prisma and its own extensions ship. For a codec from another extension, pass the line that extension publishes in its upgrade notes, once per codec, for example `--data-type acme/shape@1=acme/shape`. The option cannot change the data type of a codec the script already knows for a contract's target, but it can name the data type of a shared `sql/*` codec on a target the script does not know.
On SQLite, the contract now stores a literal default of an `Int` column as digit text, as it already stored a `BigInt` default: an `Int` default that was the JSON number `42` becomes the text `"42"`. The script makes this change. Planned SQL does not change (`DEFAULT 42`), and a database the previous release created still verifies. A `BigInt` default of 2^53 or less, which the previous release's check after `prisma db migrate` reported as missing, now passes. The same holds for the members of an `enum` typed by an integer codec (`@@type("sqlite/integer@1")`, `@@type("sql/int@1")`): the script writes their stored values as digit text. The schema does not change: members are written as before, for example `Low = 1`. An `enum` typed by `sqlite/json@1` now stores the JSON text of each member's document, and the script rewrites its stored values to that text; in the schema, write each member as a string holding that JSON text, for example `Low = '"low"'` instead of `Low = "low"`, and `Level = "1"` instead of `Level = 1`.
## `sign-databases-after-upgrade`
The upgrade gives every contract a new storage hash. Each database's marker still holds the old hash, so until you sign it:
- `prisma db migrate` refuses to run with `MIGRATION.MARKER_MISMATCH`;
- `prisma db verify` exits with code 4 and `CONTRACT.MARKER_MISMATCH`;
- `prisma migration status` does not label the migrations applied before the upgrade as applied.
The running application does not report the mismatch: it keeps answering queries and logs nothing, because the `postgres()` client has no logger for the marker check.
Run `prisma db sign` against every database (development, staging, production) before you deploy the application built with the new contract:
```sh
prisma db sign --db "$DATABASE_URL"
```
`db sign` verifies the live schema of every contract space (the application's and each extension's) against its contract, then writes the marker of every space that verified, in one transaction on PostgreSQL and SQLite. It advances each signed space's `db` ref, or the ref `--advance-ref <name>` names. Running it again changes nothing. A space that fails verification is not signed: the command prints its differences and exits with code 4. Repair that schema first (for example a Supabase database whose `auth` schema drifted from the extension's contract), then sign again.
A script that reads `db sign --json` reads one outcome per space. The document was `{ ok, summary, contract, target, marker }` for the application's space; it is now:
```json
{
"ok": true,
"summary": "Database signed",
"spaces": [
{
"space": "app",
"status": "updated",
"contract": { "storageHash": "…", "profileHash": "…" },
"previous": { "storageHash": "…", "profileHash": "…" }
}
],
"advancedRefs": [{ "space": "app", "name": "db", "hash": "…" }]
}
```
Every space has `space`, `status` and `contract`, the hashes of the space's contract. `status` is `created` (the space had no marker), `updated` (with `previous`, the hashes the marker held), `unchanged` (the marker already held the contract's hashes), `failed` or `conflict`. A failed space also has `schema`, the verification result, and `ok` is `false`. A space in conflict is one whose marker another process, such as `migrate`, changed while `db sign` ran: it has `expected` and `found`, the marker hashes `db sign` read and the ones it found, it is not signed, and the command exits with code 4; run `db sign` again once that process has finished.
Code that signs through the programmatic control API calls `client.dbSign({ contract, migrationsDir })` instead of `client.sign({ contract })`, which is removed with `SignOptions` and `SignDatabaseResult`. `dbSign` verifies every contract space and signs each one that verified, as `db sign` does.
## `column-descriptors-drop-native-type`
In `contract.ts` and every other file that builds a column descriptor by hand, delete the `nativeType` property. The contract takes the column's data type from its codec.
```ts
// before
const pgText = { codecId: 'pg/text@1', nativeType: 'text' } as const;
const Priority = enumType('Priority', { codecId: 'pg/int4@1', nativeType: 'int4' }, member('Low', 0));
// after
const pgText = { codecId: 'pg/text@1' } as const;
const Priority = enumType('Priority', { codecId: 'pg/int4@1' }, member('Low', 0));
```
Code that reads a column of a stored contract (`contract.storage…tables[name].columns[name]`) reads `dataType`, the data type id such as `pg/text`, instead of `nativeType`, the database type name such as `text`:
```tsx
// before
<span className="col-type">{column.nativeType}</span>
// after
<span className="col-type">{column.dataType}</span>
```
Code that builds a stored contract's column by hand, for example a test fixture, writes the data type id in `dataType` instead of the type name in `nativeType`:
```ts
// before
id: { nativeType: 'uuid', nullable: false, codecId: 'pg/uuid@1' },
// after
id: { dataType: 'pg/uuid', nullable: false, codecId: 'pg/uuid@1' },
```
A type written by hand for such a contract changes the same way: `readonly nativeType: 'int4'` becomes `readonly dataType: 'pg/int4'`.
## `parameter-casts-use-base-names`
Update tests that assert query text or SQL snapshots:
| Before | After |
| --- | --- |
| `$1::integer` | `$1::int4` |
| `$1::smallint` | `$1::int2` |
| `$1::bigint` | `$1::int8` |
| `$1::real` | `$1::float4` |
| `$1::double precision` | `$1::float8` |
| `$1::boolean` | `$1::bool` |
| `$1::integer[]` | `$1::int4[]` |
Extension types keep their names (`$1::vector`, `$1::geometry`).
## `ts-contract-lists-extension-codecs`
A column's stored database type name is now written from the data type of the column's codec, so the contract build needs the pack that provides the codec. Add every extension whose codec the contract uses:
```ts
import pgvector from '@prisma/orm-extension-pgvector/pack';
import { defineContract } from '@prisma/orm-postgres/contract-builder';
export const contract = defineContract(
{ extensions: { pgvector } },
({ field, model }) => ({
// …
}),
);
```
A contract that already lists the extension changes nothing. `contract.json` does not change.
## `sqlite-contract-d-ts-char-aggregates`
Run `prisma contract emit` for a SQLite project. The emitted `contract.d.ts` adds these rows under both `AggregateTypes.max.byCodec` and `AggregateTypes.min.byCodec`:
```ts
readonly 'sql/char@1': { readonly output: 'sql/char@1'; readonly nullable: true };
readonly 'sql/varchar@1': { readonly output: 'sql/varchar@1'; readonly nullable: true };
```
`contract.json`, `storageHash`, `profileHash` and migration snapshots do not change, so no migration or re-sign is needed.
## `column-helpers-raise-type-params-invalid`
The error now comes from `defineContract`, not from the helper call. Code that catches it by its code checks the new code, around the contract build:
```ts
// before
if (error.code === 'CONTRACT.ARGUMENT_INVALID') { /* … */ }
// after
if (error.code === 'CONTRACT.TYPE_PARAMS_INVALID') { /* … */ }
```
The error's `meta` is `{ dataType, parameters, modelName, fieldName }`, for example `{ dataType: 'postgis/geometry', parameters: ['srid'], modelName: 'Place', fieldName: 'location' }`, in place of `helperPath`, `expected` and `received`. A contract that passes `srid: 0` now fails when it is built; PostgreSQL refuses an SRID below 1, so such a column never migrated. Use a real SRID such as `4326`, or `geometryColumn` for a column with no SRID.
The build checks every column, so the PostgreSQL column helpers' parameters are checked too: a contract with `varcharColumn(0)` or `numericColumn(2000)` now fails when it is built, where before `migration plan` or `db verify` failed.
## `reemit-explicit-list-cardinality`
Re-run contract emission from each application's original PSL or TypeScript authoring source using its existing Prisma configuration (`pnpm exec prisma contract emit`, with `--config` where needed). Replace both `contract.json` and `contract.d.ts`, including contracts for composed spaces. Do not patch just the generated declarations or copy old hashes into newly emitted JSON. Scalar-only contract JSON remains compatible: omitted `many` in serialized model and value-object fields still means scalar. Generated domain-field declarations also omit scalar `many`; SQL storage-column declarations retain required cardinality.
For hand-authored contract objects, replace a list's `many: true` with `many: { elementNullable: false }` to preserve its existing strict-element meaning. Serialized non-list model and value-object fields may omit `many`; deserialization normalizes omission to `many: false`, and canonical emission omits it again. Keep nested `elementNullable: false` in list descriptors. Native SQL array columns use the same descriptor; non-array storage columns use `many: false`. If adopting an intermediate representation with a sibling `elementNullable`, move that property into the `many` descriptor and remove the sibling. Explicit malformed `many` values, including the old boolean-list form and descriptors without a boolean `elementNullable`, and sibling-property representations are rejected. Do not change relation cardinality or mark JSON-backed value-object storage as a native SQL array.
Keep `nullable` unchanged: it describes the whole value, not list elements. Existing `String[]` and `String[]?` declarations and `.many()` calls retain non-null elements. Only when nullable elements are intended, use `String?[]` / `String?[]?` or `.many({ elementsNullable: true })` / `.many({ elementsNullable: true }).nullable()`. The authoring option is plural `elementsNullable`; the emitted descriptor uses singular `elementNullable`. Generated types distinguish `ReadonlyArray<T | null>` from `ReadonlyArray<T> | null`.
Keep explicit `.noCheck('elementNotNull')` / `@noCheck(elementNotNull)` waivers on strict lists. They waive enforcement without permitting null in the declared element type. Do not infer nullable elements from a waiver or replace waivers during this representation upgrade. If intentionally changing a list to nullable elements, remove that now-inapplicable waiver and review the resulting PostgreSQL check-constraint change or MongoDB validator change separately.
## `refresh-historical-list-contracts`
For SQL contracts, the `contract-stores-data-type` script of the same release rewrites stored list columns and fields to this form and refreshes hashes and snapshot names, so historical SQL snapshots need no re-emission; MongoDB contracts still do. On PostgreSQL, the script keeps the membership check of an enum list column as the database holds it; run `prisma db sign` before you emit the contract again, and the next `prisma contract emit` writes the new check under a new name, which `prisma migration plan` and `prisma db migrate` then apply.
For MongoDB contracts, inventory every stored contract pair, not only the current application contract: include migration snapshots, fixture migration chains, composed-space snapshots, and any generated contracts imported by application tooling. Re-emit each historical state from its own authoring source and configuration, preserving that state's extensions, storage mappings, defaults, and explicit waivers. Do not emit today's schema over every historical snapshot.
Use the project's snapshot-store and migration-generation tooling to write each emitted JSON/declaration pair and update its references. The new native-list representation can change contract hashes even when existing strict-list DDL is unchanged. When a hash changes, create the corresponding content-addressed snapshot entry and update imports of both JSON and declarations, migration start/end contract hashes, and dependent migration metadata consistently. Process predecessor states before successors, including all branches and composed-space dependencies; regenerate derived migration identifiers and parent references where the tooling requires it. Preserve the recorded operations unless a separate reviewed schema change is intended.
Do not blanket-replace hexadecimal filenames, rename snapshot directories without updating references, edit hash fields by hand, or delete snapshots still referenced by a migration. Check that every reference resolves and that every stored JSON/declaration pair describes the same historical state. If the original source or a complete reference mapping is unavailable, stop and recover it rather than guessing.
For already-applied migrations, retain the original history and reconcile the database's recorded migration identities and contract markers through the project's supported migration procedure before deploying newly emitted contracts. Do not rewrite applied history or reset a database merely to satisfy the new hashes. Rehearse the transition on a disposable database and verify both the migration graph and database contract verification; a clean typecheck alone cannot establish consistency.
## `mongo-nullable-list-containers`
For Mongo schemas with nullable list containers (`T[]?` or `T?[]?`), re-emit the current `contract.json` and `contract.d.ts` using your normal `prisma contract emit` command. The collection validator now uses `bsonType: ['null', 'array']` for these fields; element constraints and required-list behavior are unchanged. The changed validator changes the storage hash, so keep the generated contract pair together.
For an existing database, preserve applied migrations and their contract snapshots. Plan a new migration from the existing contract to the re-emitted contract, review the validator update, and apply it through your normal migration deployment flow before writing explicit null containers. Deploying new application code alone does not update a Mongo collection validator. Do not rewrite applied historical migrations or their hashes.
For disposable example or test databases whose migration fixtures are regenerated from source, regenerate that fixture chain with its existing tooling, including referenced contract snapshots and migration metadata, and recreate the disposable database. In the Prisma 8 source repository, `pnpm fixtures:emit` re-emits current contracts and regenerates example migration fixtures; retain existing content-addressed snapshots alongside any newly emitted snapshots, then run the example's formatter/import organizer if new snapshot paths change import order. This fixture-only procedure is not a production migration procedure.
## `domain-types-match-their-columns`
Run `prisma contract emit`. `contract.json` and `contract.d.ts` gain these entries in the domain half:
| PSL | Added to the field's domain entry |
| --- | --- |
| `code Short`, with `types { Short = VarChar(10) }` (also `Short[]`) | `"typeParams": { "length": 10 }` on `type` |
| `roles Role[]`, where `Role` is an `enum` | `"valueSet": { "plane": "domain", "entityKind": "enum", "namespaceId": "public", "entityName": "Role" }`, as `role Role` already had |
| composite type member `amount Numeric(10, 2)` (also a list, or a named type) | `"typeParams": { "precision": 10, "scale": 2 }` on `type` |
| composite type member `role Role` or `roles Role[]` | the same `valueSet` as a model field of that enum |
A named type without parameters, such as `Email = String`, adds nothing.
In `contract.d.ts`, a composite type's output type (`AddressOutput`) now gives a member with type parameters the parameterized output type a model field of that type has, such as `Varchar<10>` or `Numeric<10, 2>`, instead of the codec's plain output type. These are branded strings, so code that builds such an output object from plain strings, such as a test fixture or a mock, no longer type-checks. Build the value as the ORM returns it, or type it with the composite type's input type (`AddressInput`), which is unchanged.
Migration snapshots under `migrations/snapshots/<hash>/` need no change. Migration commands read only their storage half, which is unchanged.
`prisma contract print` now expects a field typed by a parameterized named type, and an enum list field, to carry these domain entries. It refuses a contract emitted before this change that lacks them. Re-emit it first.
## `value-object-default-matches-composite-type`
A literal default on a field typed by a composite type used to be stored whatever its shape. It is now checked, naming the path that is wrong, as in `Field "User.home.street"`:
- A single value object takes a JSON object, and a list of them a JSON array: `` homes Address[] @default(json`{"street": "x"}`) `` is refused; write `@default([])` or `` @default(json`[{"street": "x"}]`) ``. JSON `null` is taken when the field is optional. `PSL_VALUE_TYPE_INCOMPATIBLE`.
- A key that is not a member is refused, and so is a missing member that is not optional, and `null` for a member that is not optional. `PSL_VALUE_TYPE_INCOMPATIBLE`.
- The default holds each member in the form its codec stores, so the member's codec must read the value. A `Decimal`, `Numeric(p, s)` or `BigInt` member takes a decimal string, `"1.5"`, and a number is refused; a `String` member takes a JSON string, so `"street": 1` is refused; a `DateTime` member takes a date and time string; a `Json` member takes any JSON value. `PSL_INVALID_DEFAULT_LITERAL`, with the codec's message.
- A member typed by an enum takes only the enum's values: `PSL_INVALID_DEFAULT_LITERAL`, `Expected one of:` the values.
- Nested value objects are checked the same way.
Correct the value the diagnostic names.
## `composite-type-attributes-refused`
An attribute inside a `type` block was ignored: `street String @default("x")` stored no default, and `@@map` mapped nothing. Each is now refused, `PSL_UNSUPPORTED_FIELD_ATTRIBUTE` on a member and `PSL_UNSUPPORTED_COMPOSITE_TYPE_ATTRIBUTE` on the type. Remove the attribute. To give a value object a default, write it on the model field as a whole value, such as `` home Address @default(json`{"street": "x"}`) ``.
## `codecs-check-stored-json`
The built-in SQL, PostgreSQL and SQLite codecs now refuse a value that is not a stored form of their type, including one its type parameters rule out, where most used to pass it through. A TypeScript `.default()` given such a value is now refused when the contract is built, with `CONTRACT.DEFAULT_INVALID` naming the model and field: a value of another kind, such as a number for a text column, or one the PostgreSQL column rules in `psl-values-checked-by-codecs` refuse, such as `'toolong'` for a `varchar(3)` column. A TypeScript `enumType` member its codec does not take is refused the same way, with `CONTRACT.ENUM_INVALID` naming the enum, the member and the codec: for example a `pg/char@1` or `sql/char@1` member longer than one character on PostgreSQL, since the enum's column is `character`, which holds one. Correct the value the error names.
A `contract.json` with such a default, emitted by an earlier version or edited by hand, still loads. `db init`, `db update` and `migration plan` used to plan the default; they now stop with `CONTRACT.DEFAULT_INVALID`, which names the table, the column, the codec and the value. Emit the contract again with this version, and correct the default in the contract source if emit refuses it. Running a `migration.ts` that an earlier version planned with such a default fails with the same message; correct the default in that file. Every statement that writes a default reads it this way: a new table or column, a changed default, and on SQLite a rebuilt table, which used to write a changed default or a rebuilt table's defaults unread. Each element of a list default is read the same way, and the message names the element's position: `Column "post"."tags" has a default (element 2) its codec pg/text@1 refuses: pg/text@1 JSON value must be a string`. A NULL element stays NULL. The codecs that carry PostgreSQL's own date and time text, those of `DateString`, `TimeString`, `TimestampString`, `TimestamptzString` and `Timetz`, read only a date or time in ISO 8601 or as PostgreSQL writes it, so such a default as `'now'`, which PostgreSQL would read once when it creates the table, is refused the same way. An `.include()` of such a column reads the text PostgreSQL writes in the ISO DateStyle, its default; on a server set to another DateStyle it now fails with `RUNTIME.DECODE_FAILED`, where it passed the text through. Set `DateStyle` to `ISO` on that server.
## `psl-values-checked-by-codecs`
The PSL reader reads each literal default, and each member of a SQL `enum`, with the column's codec, so the stricter codecs refuse schemas that loaded before. A value of another kind is refused: a text codec takes a string, an integer codec an integer in its range, `Boolean` `true` or `false`, and `Uuid` a UUID. On PostgreSQL the codecs also check what the column stores:
- `VarChar(n)` and `Char(n)` take at most n characters, counted by code point, and a `Char` value's trailing spaces do not count. A `Char` without a length is `character(1)`, so it takes one character.
- `Bit(n)` takes exactly n bits and `VarBit(n)` at most n, and a bit column without a length is `bit(1)`.
- `Numeric(p, s)` takes a value it stores without rounding.
- `Int`, `sql/int@1` and an enum whose members are integers take an integer from -2147483648 to 2147483647, and `SmallInt` one from -32768 to 32767.
- `Real` takes a finite number only if float4 holds it, neither overflowing to an infinity nor becoming 0.
Such a default used to load, and the migration planned and applied; the first insert that used the default then failed. A `Char` or bit column without a length, which did not apply on PostgreSQL whatever its default, now applies as `character(1)` or `bit(1)`. SQLite does not enforce a declared length, so on SQLite the char and varchar codecs take text of any length. Each of these is now refused at `contract emit`:
| Schema | Diagnostic |
| --- | --- |
| `enum P { @@type("pg/int4@1") Low = "low" }` | `PSL_EXTENSION_INVALID_VALUE`: `enum "P" member "Low" was rejected by codec "pg/int4@1": pg/int4@1 JSON value must be an integer from -2147483648 to 2147483647` |
| the same enum with a bare `Low` | `PSL_ENUM_BARE_MEMBER_NON_STRING_CODEC`: `enum "P" member "Low" has no value and codec "pg/int4@1" does not accept a bare name as input` |
| `enum P { @@type("pg/text@1") Low = 1 }` | `PSL_EXTENSION_INVALID_VALUE`, `enum "P" member "Low": pg/text has no cast from pg/int2; it casts from nothing` |
| an enum without `@@type` whose integer members include one outside -2147483648 to 2147483647, such as `Low = 3000000000` | `PSL_EXTENSION_INVALID_VALUE`, `enum "P" member "Low": pg/int4 has no cast from pg/int8; it casts from pg/int2` |
| `u Uuid @default("nope")` | `PSL_INVALID_LITERAL`, `"nope" is not a UUID: PostgreSQL reads 32 hexadecimal digits, with a hyphen after any group of four and optionally in braces.` |
| `s VarChar(3) @default("toolong")` | `PSL_INVALID_DEFAULT_LITERAL`, `sql/varchar@1 JSON value must be a string of at most 3 characters` |
| `c Char @default("abc")` on PostgreSQL | `PSL_INVALID_DEFAULT_LITERAL`, `sql/char@1 JSON value must be a string of at most 1 character before any trailing spaces` |
| `enum P { @@type("sql/int@1") Low = 3000000000 }` on PostgreSQL | `PSL_EXTENSION_INVALID_VALUE`, `enum "P" member "Low": pg/int4 has no cast from pg/int8; it casts from pg/int2` |
| `n Numeric(5, 2) @default(1.555)` | `PSL_INVALID_DEFAULT_LITERAL`, `pg/numeric@1 JSON value must be a decimal string that numeric(5, 2) stores without rounding` |
Give each enum member a value its codec takes, and each default a value its column's type holds unchanged. A `Uuid` default is still read in any form PostgreSQL reads; see `uuid-defaults-stored-as-postgresql-writes`.
## `uuid-defaults-stored-as-postgresql-writes`
A `Uuid` default may be written in any form PostgreSQL reads: either case, with or without a hyphen after any group of four digits, and optionally in braces. The contract now stores it as PostgreSQL writes it, in lower case and hyphenated 8-4-4-4-12, and so does a TypeScript `.default()` on a `pg/uuid@1` column, so the applied default verifies against the database with no difference.
Earlier versions stored such a default as written. The database stores the lower-case form, so the check that runs after the change is applied failed: `db init`, `db update` and `db migrate` stopped with `MIGRATION.RUNNER_FAILED` and rolled the change back. The database has none of the changes that contract adds, and no marker for it. With this version, a `contract.json` that still holds such a default stops `db init`, `db update` and `migration plan` with `CONTRACT.DEFAULT_INVALID`, as `codecs-check-stored-json` describes.
Emit the contract again with this version. The stored default changes, and with it the storage hash. Then:
- For a project kept with `db init` or `db update`, run the command that failed again. It applies the contract, and `db verify` then passes.
- For a project with migrations, delete the migration package that never applied: its directory under `migrations/app/`, and its contract snapshot `migrations/snapshots/<hash>/`, where `<hash>` is the `to` hash in the package's `migration.json`. Then run `prisma migration plan` and `prisma db migrate`. Left in place, the package stays in the migration graph, ending at a contract no database reaches.
## `ts-enum-member-written-as-stored`
The types of a TypeScript contract name each `enumType` member as written, in `db.enums` and in the types of the fields that use the enum, while `contract.json` and the database hold what the column's codec stores. The two now have to be the same value, so `defineContract` refuses a member its codec stores as something else and says what to write:
```text
CONTRACT.ENUM_INVALID: enumType("Key"): member "A" is written "A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11", but the column stores "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11". Write the member as "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11".
```
A member its codec does not take at all, such as text that is not a uuid on `pg/uuid@1`, is refused with a different message; `codecs-check-stored-json` describes it.
Refused members:
- On a uuid column (`pg/uuid@1`), a member with an upper-case hex digit, in braces, or with hyphens anywhere other than the 8-4-4-4-12 positions (including none). Write it in lower case, hyphenated 8-4-4-4-12.
- A member that got past the type check with a value of another type its codec still takes, for example through a cast: the number `1` on `pg/int8@1`, which stores `"1"`, is refused with "Write the member as 1n".
1. Run `prisma contract emit`, or run the code that calls `defineContract`. Each refused member is reported with the value to write. To find uuid members first, search the calls to `member(` in your contract for a uuid that is not lower case, hyphenated 8-4-4-4-12. The detection for this change looks for calls to `enumType(`; if you import it under another name, as in `import { enumType as defineEnum }`, it does not find them, so search for that name.
2. Rewrite each refused member as the message says. Code that reads members through the enum, such as `Key.members.A` or `db.enums.public.Key.members.A`, needs no change. Code that compares a value with the old spelling does: values read from the database were always in the stored form.
3. Re-emit. If a column uses the enum, its membership CHECK constraint's expression changes, and a CHECK constraint's name is derived from its expression, so the storage hash and the constraint's name both change. Plan and apply a migration: it drops the old CHECK constraint and adds the new one. Dropping a constraint is a destructive operation, so the plan needs the destructive operation class allowed.
## `mongo-codecs-check-json`
A Mongo codec's `decodeJson` reads the JSON form of its type. The Mongo runtime reads documents through `decode` and never calls it; the PSL reader calls it for each member of an enum. `mongo/string@1`, `mongo/objectId@1`, `mongo/int32@1`, `mongo/double@1`, `mongo/bool@1`, `mongo/vector@1` and `mongo/bson@1` used to return any JSON value as it was, so an enum member of the wrong kind was stored in the contract:
```prisma
enum Priority {
@@type("mongo/int32@1")
Low = "low"
}
```
This is now refused with `PSL_EXTENSION_INVALID_VALUE`, naming the codec's message, `mongo/int32@1 JSON value must be an integer from -2147483648 to 2147483647`. A member written without a value, such as a bare `Low`, under a codec that does not take text is `PSL_ENUM_BARE_MEMBER_NON_STRING_CODEC`. Give each member a value of the codec's type.
Each codec now takes: `mongo/string@1` a string; `mongo/objectId@1` 24 hexadecimal digits; `mongo/int32@1` an integer from -2147483648 to 2147483647; `mongo/double@1` a number, or the text `"NaN"`, `"Infinity"` or `"-Infinity"`, which its `encodeJson` now writes for those values instead of a number JSON cannot hold; `mongo/bool@1` a boolean; `mongo/date@1` the text `Date.toISOString()` writes; `mongo/vector@1` an array of numbers; and `mongo/bson@1` canonical Extended JSON, the form its `encodeJson` writes. Another value throws `RUNTIME.DECODE_FAILED` with the codec id in `meta`. A TypeScript `enumType` member that `mongo/objectId@1` or `mongo/int32@1` does not hold now throws `RUNTIME.ENCODE_FAILED` when the contract is built.
## `text-array-elements-nullable`
`pg/text-array@1`, the codec of a contract-free `textArray()` column, reads a `text[]` column's NULL elements as `null`, so its application type is `readonly (string | null)[]` where it was `readonly string[]`. Code typed by a `textArray()` column, or by `min` or `max` over one, sees `string | null` elements; handle the `null`. Read through an `.include()`, a two-dimensional `text[]` value now throws `RUNTIME.DECODE_FAILED`, where it read as the text `"a,b"`.
## `char-reads-drop-only-padding`
PostgreSQL pads a `char(n)` value with spaces to its length: `'a'` in a `char(3)` column is stored as `'a '`. A flat read dropped every trailing whitespace character, so a stored `'a\t'` also read as `"a"`, while `.include()` returned the padded text, `"a "`. Both reads now return the value without the padding and nothing more: `"a"` for `'a'`, and `"a\t"` for `'a\t'`. On SQLite, which does not pad, both reads drop trailing spaces, as a flat read did. Code that compared an included `char` value with its padding, or relied on a flat read dropping a trailing tab or newline, compares the value without its padding.
## `sqlite-nan-parameters-refused`
SQLite cannot store NaN: bound as a parameter, it becomes NULL. So `create({ value: 0 / 0 })` on an optional `Float` column stored NULL, and `where((p) => p.value.eq(Number.NaN))` matched nothing. On SQLite, `sqlite/real@1` and `sql/float@1` now refuse NaN with `RUNTIME.ENCODE_FAILED`, `<codecId> value must be a number other than NaN, which SQLite cannot store`, with `meta.codecId` and `meta.received`: when they encode a value to write or filter by, and when they encode a TypeScript `.default()`, which is still refused when the contract is built with `CONTRACT.DEFAULT_INVALID`, now with this message. Their `decodeJson` refuses the text `"NaN"`. A NaN parameter no codec encoded, such as one in raw SQL, is refused by the SQLite driver with the same code: `Parameter 2 is NaN, which SQLite cannot store: it would bind it as NULL. Pass null to store no value.`, with `meta.paramIndex`, counted from 0. On a required column SQLite already refused the NULL, so only the error changes. Where a computed value can be NaN, write `null` for no value, and filter with `isNull()` for rows that have none. Infinity and -Infinity are stored and read back as before.
## `sqlite-int-include-refuses-inexact-values`
`sql/int@1` holds a JavaScript safe integer. On SQLite, an INTEGER column can hold a larger integer or a REAL. An `.include()` read such a value rounded or with a fraction; it now throws `RUNTIME.DECODE_FAILED`, naming the codec. A flat read is unchanged. Store an integer past 2^53 in a `BigInt` column and a fraction in a `Float` column.
## `prefixed-sql-tags-are-removed`
`pg.sql` and `sqlite.sql` were second names for the `sql` tag. They are removed, and a schema that uses one is refused with `PSL_UNKNOWN_LITERAL_TAG`: `Unknown literal tag "pg.sql". Known tags: sql, json.`
Replace the tag with `sql` and leave the text unchanged:
```diff
- createdAt DateTime @default(pg.sql`(now() + interval '1 hour')`)
+ createdAt DateTime @default(sql`(now() + interval '1 hour')`)
```
The stored default does not change, so no migration follows.
## `default-diagnostic-codes-changed`
This matters only to code that reads PSL diagnostic codes, such as a test that asserts one. The messages did not change, except for a `sql` literal inside a list literal and the list of known tags, both described below the table.
| Refusal | Old code | New code |
| --- | --- | --- |
| A tag no pack registered | `PSL_UNKNOWN_DEFAULT_LITERAL_TAG` | `PSL_UNKNOWN_LITERAL_TAG` |
| A `json` literal whose text is not a JSON document | `PSL_INVALID_JSON_LITERAL` | `PSL_INVALID_LITERAL` |
| Text an authoring entry or a cast refused | `PSL_INVALID_DEFAULT_LITERAL` | `PSL_INVALID_LITERAL` |
| A value whose type the column's type has no cast from, including a list written on a column that holds one value | `PSL_DEFAULT_TYPE_INCOMPATIBLE` | `PSL_VALUE_TYPE_INCOMPATIBLE` |
| A written form the target has no data type for | `PSL_DEFAULT_TYPE_INCOMPATIBLE` | `PSL_VALUE_TYPE_INCOMPATIBLE` |
| A single value on a list column | `PSL_DEFAULT_TYPE_INCOMPATIBLE` | `PSL_DEFAULT_LIST_EXPECTED` |
| A value the column's codec refused | `PSL_INVALID_DEFAULT_LITERAL` | unchanged |
| A `sql` literal inside a list literal | `PSL_INVALID_DEFAULT_LITERAL`, at the element | `PSL_VALUE_TYPE_INCOMPATIBLE`, at the `@default` attribute |
`PSL_INVALID_JSON_LITERAL` no longer exists.
The unknown-tag message lists the known tags in the order the stack registers them. The SQL family registers `sql` before the target registers `json`, so a Postgres or SQLite stack lists `sql, json`, where it used to list `json, sql, pg.sql` or `json, sql, sqlite.sql`. The completion list and the `Expected one of` message of `@default` offer `sql` before `json` for the same reason.
A `sql` literal inside a list literal used to report `Literal tag "sql" produces a default of its own and cannot be an element of a list literal.` It is now refused by the cast rule, like any other value the column's type does not take: `Field "Post.tags" at element 1: pg/text has no cast from sql/expression; it casts from nothing`. Write the whole list as one `sql` literal instead, as in `` @default(sql`'{}'::text[]`) ``.
This supersedes the codes named in the `data-types-column-defaults` app instructions of the upgrade from 8.0.0-rc.11 to 8.0.0-rc.12: where they name `PSL_DEFAULT_TYPE_INCOMPATIBLE` for a value a column's type has no cast from, or `PSL_INVALID_JSON_LITERAL`, read the new codes above.
## `migration-ts-column-defaults`
A `migration.ts` no longer carries a column default as SQL text. The control adapter writes the `DEFAULT …` clause for every statement, the same way for a new table, a new column, a changed default and a rebuilt SQLite table, and it reads a literal default with the column's codec first. A `migration.ts` that sets a default the codec refuses fails when it runs, with `CONTRACT.DEFAULT_INVALID` naming the table and the column.
On PostgreSQL, `setDefault` takes the column and its default:
```typescript
// before
this.setDefault({ table: 'user', column: 'role', defaultSql: "DEFAULT 'member'" })
// after
this.setDefault({ table: 'user', column: col('role', 'text', { default: lit('member'), codecRef: { codecId: 'pg/text@1' } }) })
```
On SQLite, a column in `addColumn` or `recreateTable` carries the default and its codec, and a `recreateTable` postcheck that checks a default names the column:
```typescript
// before
{ name: 'role', typeSql: 'TEXT', defaultSql: "DEFAULT 'member'", nullable: false }
{ description: 'verify "role" default on "user"', sql: "SELECT COUNT(*) > 0 FROM pragma_table_info('user') WHERE ..." }
// after
{ name: 'role', typeSql: 'TEXT', default: { kind: 'literal', value: 'member' }, codecRef: { codecId: 'sqlite/text@1' }, nullable: false }
{ description: 'verify "role" default on "user"', columnDefault: 'role' }
```
An applied migration needs nothing: `db migrate` applies `ops.json`, which holds the SQL. Change a `migration.ts` this way only when you run it again to write `ops.json`. `node migration.ts` does not check types, so an earlier file still runs; `setDefault`, `addColumn` and `recreateTable` then refuse a `defaultSql` with `MIGRATION.OPERATION_OPTION_REMOVED`, naming the table and the column, rather than leave the default out. Rewrite the call as shown, or, if the migration is not applied, delete its package and run `migration plan` again.
The `migration.ts` that `migration plan` writes for a new SQLite table now gives each column its `codecRef`, so running it writes the same `ops.json` as the plan. To apply a changed PostgreSQL default that an earlier version planned, see [`postgres-changed-default-applied`](#postgres-changed-default-applied).
## `postgres-changed-default-applied`
A migration operation that changes an existing default on PostgreSQL checked afterwards only that the column has a default. The old default passes that check, and the runner skips an operation whose check already passes, so the default stayed as it was and verification then failed with `MIGRATION.SCHEMA_VERIFY_FAILED`. Such an operation now has no check afterwards and always runs; setting a default twice changes nothing.
A migration package an earlier version planned keeps the old check in `ops.json`. If one changes a default and you have not applied it, write its `ops.json` again before you apply it with `prisma db migrate`. Either delete the package and run `prisma migration plan` again, or rewrite its `setDefault` call in `migration.ts` to the form [`migration-ts-column-defaults`](#migration-ts-column-defaults) shows and then run the file with node from the directory that holds `prisma.config.ts` (`node <path to the package>/migration.ts`). Run unchanged, the file stops with `MIGRATION.OPERATION_OPTION_REMOVED`, because its `setDefault` still passes `defaultSql`. `db update` plans again each time, so it needs nothing.
## `cli-error-from-caught`
`mapCaughtMigrationError(error)`, exported from `@prisma/orm-toolchain/cli/control-api` (`@internal/cli/control-api`), returned a CLI error unchanged and `null` for anything else, which the caller wrapped as `CLI.UNEXPECTED`. `errorFromCaught(error, why)`, exported from the same place, does the whole job: it returns a CLI error unchanged, reports any other error with a structured `NAMESPACE.SUBCODE` code as itself, reports anything else as `CLI.UNEXPECTED` with `why` given the error's message, and throws an `InternalError` again. Replace `mapCaughtMigrationError(error) ?? errorUnexpected(...)` with `errorFromCaught(error, (message) => ...)`. A caller that holds a database connection string passes it as `errorFromCaught(error, why, { connection })`, which removes it from every field of the reported error.
## `sql-with-a-line-comment-gets-a-new-wire-name`
Prisma names an index, check or policy after a hash of its SQL body. Before hashing, it normalizes the body. A body that contains `--` now keeps its line breaks, because a line break ends the comment and so changes what the body means. Every other body normalizes as before.
This affects an index expression or predicate, a check expression, or a policy `using` or `withCheck` that contains both `--` and a line break. For such an object, re-emitting the contract changes the stored index, check or policy name in `contract.json`. The stored body does not change; only the hash input does.
Re-emit the contract and run `migration plan`. The name's hash suffix changes, so the planned migration drops and recreates each affected object once.
## `strict-verify-unclaimed-code`
For each place that reads `CONTRACT.MARKER_REQUIRED` from a `prisma db verify --strict` run to detect tables no contract declares, read `CONTRACT.SCHEMA_VERIFICATION_FAILED` instead, or read the names from the result's `unclaimed` list. Leave code that reads `CONTRACT.MARKER_REQUIRED` to detect an unsigned database unchanged.
## `prisma7-schema-date-types-are-text`
For each project whose `prisma.config.ts` uses `prisma7Schema(...)`:
1. Run `prisma contract emit`. The date and time fields change type:
| Prisma 7 field | Before | Now | Example value |
| --- | --- | --- | --- |
| `DateTime`, `DateTime @db.Timestamp(p)` | `Temporal.PlainDateTime` | `string` | `"2026-09-14 10:00:00.123"` (UTC, as Prisma 7 writes it) |
| `DateTime @db.Timestamptz(p)` | `Temporal.Instant` | `string` | `"2026-09-14 10:00:00.123+00"` (on a server whose `TimeZone` is UTC) |
| `DateTime @db.Date` | `Temporal.PlainDate` | `string` | `"2026-09-14"` |
| `DateTime @db.Time(p)` | `Temporal.PlainTime` | `string` | `"10:00:00.123"` |
`@db.Timetz` fields already read as text and do not change.
2. Change the code that reads or writes these fields:
- `DateTime` and `@db.Timestamp`: the text holds UTC wall-clock time with a space between the date and the time, where `.toString()` on a `Temporal.PlainDateTime` printed a `T`. Where code printed or stored that form, replace `value.toString()` with `value.replace(' ', 'T')`. Where it compares or computes with the value, `` new Date(`${value.replace(' ', 'T')}Z`) `` is the instant.
- `@db.Timestamptz`: the text carries the offset of the database session's `TimeZone`, `+00` on a server set to UTC. Do not apply `replace(' ', 'T')` to it. `new Date(value)` parses it in Node.js, and `new Date(value).toISOString()` prints the instant in UTC ending in `Z`, the form `.toString()` on a `Temporal.Instant` printed, with milliseconds always present.
- `@db.Date` and `@db.Time`: the text is the form `.toString()` on a `Temporal.PlainDate` or `Temporal.PlainTime` printed.
Write a string PostgreSQL reads, such as `"2026-09-14 10:00:00"` for `DateTime` or `"2026-09-14T10:00:00Z"` for `@db.Timestamptz`, instead of a `Temporal` value. `@updatedAt` still writes UTC, as it did before and as Prisma 7 does, so existing rows need no change.
3. If no other code in the application uses `Temporal`, remove the `import 'temporal-polyfill/full/global'` it had for these fields, and remove `temporal-polyfill` from the application's `dependencies`. Keep the dependency in a project that installs with Yarn: `@prisma/orm-postgres` declares it as a peer dependency, and Yarn does not install peers on its own.
4. Run `prisma db sign` against every database the application uses. The storage hash changed with the column types. Until a database is signed, `prisma db verify --db "$DATABASE_URL"` exits with code 4 and reports `CONTRACT.MARKER_MISMATCH`. The running application does not report the mismatch: it keeps answering queries and logs nothing, because the `postgres()` client has no logger for the marker check. The database itself needs no migration.
`prisma7Schema(...)` has no option to keep the `Temporal` types. A project that wants them writes a Prisma 8 contract, for example with `prisma contract print --output prisma/contract.prisma`, and changes the types there.
## `contract-infer-writes-text-date-types`
Nothing changes until `prisma contract infer` runs again. When it does, the rewritten contract uses the text types for `timestamp`, `timestamptz`, `date` and `time` columns. Follow steps 1 to 4 of `prisma7-schema-date-types-are-text` for the fields that changed, or change the types back to `Timestamp(p)`, `Timestamptz(p)`, `Date` and `Time(p)` in the inferred file to keep `Temporal` values.
A default of `infinity` or `-infinity` on one of these columns now prints as `@default("infinity")` where it printed a `sql` expression.
## `text-timestamp-now-is-utc`
This covers `temporal.timestampString(p, onCreate: now, onUpdate: now)` in PSL and `field.temporal.timestampString(...)` with `'now'` in TypeScript. Rows these fields wrote before this release on a host outside UTC hold that host's local time; rows written from now on hold UTC. A Prisma 7 `DateTime @updatedAt` read through `prisma7Schema(...)` is not affected: it wrote UTC before and still does.
## `contract-infer-writes-bytea-default-literals`
Nothing changes until `prisma contract infer` runs again. A schema that keeps the `sql` default keeps working: schema verification reads it as the same bytes as the default in the database.
When infer runs again, it writes each `bytea` default as the base64 the `Bytes` codec stores:
| Before | Now |
| --- | --- |
| ``@default(sql`'\\x68656c6c6f'::bytea`)`` | `@default("aGVsbG8=")` |
| ``@default(sql`ARRAY['\\x68656c6c6f'::bytea]`)`` | `@default(["aGVsbG8="])` |
1. Run `prisma contract emit`. The default is now stored as a value, so the storage hash changes. The default in the database does not change.
2. If you create the database with `prisma db init` or `prisma db update`, run `prisma db verify --schema-only` to confirm the schema matches, then `prisma db sign` to sign the database with the re-emitted contract.
3. If you use migrations, `prisma migration plan` refuses with "Contract changed but planner produced no operations", because nothing in the database changes. Run `prisma migration new --name bytea-default-literals` to write an empty migration from the earlier contract to the re-emitted one, then `prisma db migrate`. When `migration new` cannot tell where to start, pass `--from` with the `to` hash of your latest migration, which `prisma migration list` shows.
Sign the database or apply the migration before you deploy the re-emitted contract. Until then `prisma db verify` exits with code 4 and `CONTRACT.MARKER_MISMATCH`, because the database marker holds the earlier storage hash. The running application does not report the mismatch: it keeps answering queries and logs nothing.
## `prisma6-int-written-as-long`
Prisma 6 stores a plain `Int` on MongoDB as a BSON long and presents it as a `number`. A contract read with `prisma6Schema(...)` used to give such a field the 32-bit int codec, so Prisma 8 wrote new values as BSON ints, and a fractional number was accepted and stored as a double. It now uses `mongo/int64Number@1`: a stored long reads as a `number`, Prisma 8 writes a `number` back as a long, and a value that is not a whole number within ±(2^53 − 1) is refused instead of rounded. The application type does not change.
1. Repair every document in which a plain `Int` field holds a fractional number, whether the field is on the model, in an `Int[]` list, or in a composite type the model holds once or in a list. The previous contract let Prisma 8 store such a value as a BSON double, and `mongo/int64Number@1` refuses to read it (`RUNTIME.DECODE_FAILED`, "wire value is the fractional double 2.5"), so a query that returns such a document fails as a whole. For each plain `Int` field, in the collection its model is stored in, with the MongoDB shell (`mongosh`) or the driver:
- List the affected documents, to decide how to repair them. For a field on the model, this finds the fractional values:
```js
db.Post.find({ likes: { $type: 'double' }, $expr: { $ne: ['$likes', { $trunc: '$likes' }] } })
```
For a list or a composite value, this finds every document with a double in the field: `db.Post.find({ scores: { $type: 'double' } })` for an `Int[]` field, and `db.Post.find({ 'addresses.zip': { $type: 'double' } })` for `zip` in a composite type, whether `addresses` holds one value or a list.
- Store every double in the field as a long. Choose `$round` or `$trunc`: `$round` rounds to the nearest whole number and a half to the even one (2.5 becomes 2, 3.5 becomes 4); `$trunc` drops the fraction (2.9 becomes 2). Whole-number doubles become longs either way.
```js
db.Post.updateMany({ likes: { $type: 'double' } }, [
{ $set: { likes: { $toLong: { $round: ['$likes', 0] } } } },
])
```
- For an `Int[]` field, convert each double in the list:
```js
db.Post.updateMany({ scores: { $type: 'double' } }, [
{
$set: {
scores: {
$map: {
input: '$scores',
in: {
$cond: [
{ $eq: [{ $type: '$$this' }, 'double'] },
{ $toLong: { $round: ['$$this', 0] } },
'$$this',
],
},
},
},
},
},
])
```
- For a field of a composite type the model holds once (`address Address?`), write its dotted path (`'address.zip'` and `'$address.zip'`) in the filter and the update above.
- For a field of a composite type the model holds in a list (`addresses Address[]`), the dotted path fails with "$round only supports numeric types, not array". Rewrite each element of the list instead:
```js
db.Post.updateMany({ 'addresses.zip': { $type: 'double' } }, [
{
$set: {
addresses: {
$map: {
input: '$addresses',
in: {
$mergeObjects: [
'$$this',
{
zip: {
$cond: [
{ $eq: [{ $type: '$$this.zip' }, 'double'] },
{ $toLong: { $round: ['$$this.zip', 0] } },
'$$this.zip',
],
},
},
],
},
},
},
},
},
])
```
Here `Post`, `likes`, `scores`, `address`, `addresses` and `zip` stand for the collection and the field names in the database, after `@@map` and `@map`.
2. Run `prisma contract emit`. In `contract.d.ts`, each plain `Int` field of the Prisma 6 schema is now typed with `mongo/int64Number@1` instead of `mongo/int32@1`; both read and write a `number`, so application code needs no change. `db sign` and `db verify` need nothing new: the contract carries no validators.
## `prisma6-bytes-objectid-is-hex`
Before this release, `prisma6Schema(...)` refused every native type other than `String @db.ObjectId`. It now reads each native type Prisma 6 accepts on MongoDB as the codec for the BSON type Prisma 6 stores. Only `DateTime @db.Timestamp` is still refused.
A `Bytes @db.ObjectId` field stores an ObjectId, so Prisma 8 treats it like `String @db.ObjectId`: reads return the 24-digit hex string, and writes take a hex string or an ObjectId. The Prisma 6 client uses 12 bytes for the same field, and Prisma 8 refuses a `Buffer` or `Uint8Array` there.
Where application code that moves to the Prisma 8 client passes or reads such a field, convert at the boundary:
- bytes to the hex string Prisma 8 takes: `bytes.toString('hex')` for a `Buffer`, `Buffer.from(bytes).toString('hex')` for a `Uint8Array`
- the hex string Prisma 8 returns to bytes: `Buffer.from(hex, 'hex')`
The stored values do not change: Prisma 6 and Prisma 8 read and write the same ObjectId.
## `writes-on-a-conditional-collection-are-refused`
`where`, `orderBy`, `limit`, `offset`, `distinct`, `distinctOn`, `cursor` and `include` now return the collection they were called on, with what they establish added to its type as a fact. A custom collection class keeps its methods through the chain, so `db.Post.where({ userId }).withTitle('orm')` and `db.Post.include('user').withTitle('orm')` compile.
The facts have names. Write a filtered collection's type as `Filtered<C>` and an ordered one as `Ordered<C>`. `Filtered<C>` is `C & HasWhere`, and `HasWhere` is the name error messages print. Import the names from the `orm-client` entry of your facade, for example `@prisma/orm-postgres/orm-client`.
This entry and `cursor-and-distinct-on-check-the-receiver`, `apply-is-a-collection-member`, `overriding-a-chaining-method`, `collection-state-flags-are-boolean`, `read-collection-state-and-row-with-helpers`, `return-type-of-a-chaining-method`, `chaining-methods-take-no-explicit-type-arguments` and `custom-collection-methods-chain` apply to the SQL ORM client only. The MongoDB ORM client did not change; skip matches in code that uses it.
A write needs a collection that is filtered on every code path. Code that filters only on some paths compiled before and no longer does:
```ts
const posts = search ? db.Post.withTitle(search) : db.Post;
await posts.deleteAll(); // error: The 'this' context of type 'PostCollection' is not assignable to method's 'this' of type 'HasWhere'
```
The same applies to an `if` with an early return, a `switch`, a loop, and a `let` reassigned in an `if`. Make the write only where the filter was applied, or filter on every path:
```diff
- const posts = search ? db.Post.withTitle(search) : db.Post;
- await posts.deleteAll();
+ if (search) {
+ await db.Post.withTitle(search).deleteAll();
+ }
```
If the code relied on deleting or updating every row when there is no filter, that was the unsafe case the check now refuses. State the intent with an explicit filter instead.
## `writes-refuse-what-they-would-ignore`
`updateAll`, `updateAndCount`, `deleteAll` and `deleteAndCount` change every row that matches the filter. They ignored a `limit`, an `offset`, a `cursor`, `distinct` and `distinctOn` on the collection, so a chain such as `db.orm.public.Post.where(...).limit(10).deleteAll()` deleted every matching row, not ten, and one with `.cursor({ id })` deleted the rows before the cursor too. They now throw `ORM.ARGUMENT_INVALID` instead. An order does not change which rows they change, and they still accept it. Remove what the write would ignore, or read the rows and change them by their ids:
```diff
- await db.orm.public.Post.where({ userId }).limit(10).deleteAll();
+ const ids = (await db.orm.public.Post.where({ userId }).select('id').limit(10).all()).map((p) => p.id);
+ await db.orm.public.Post.where((p) => p.id.in(ids)).deleteAll();
```
`update` and `delete` change one row, the one `first()` returns, so the order, the offset, the cursor, `distinct` and `distinctOn` choose it; they are unchanged, except after `limit(0)`. `first()` replaces the limit with 1, so after `limit(0)` they changed one row; they now change none and return `null`. A `limit(0)` that a request can set, such as a page size of zero, no longer writes.
The exception is `update` with a relation callback, such as `posts: (posts) => posts.connect(...)`. It finds its row by the filter alone and ignored the order, the limit, the offset, the cursor, `distinct` and `distinctOn`, so it could change another row than the chain chose. It now throws `ORM.ARGUMENT_INVALID` on a collection with any of them. Find the row first and filter to it:
```diff
- await users.where({ teamId }).orderBy((u) => u.createdAt.asc()).update({ posts: (posts) => posts.connect([{ id: postId }]) });
+ const oldest = await users.where({ teamId }).orderBy((u) => u.createdAt.asc()).select('id').first();
+ if (oldest) await users.where({ id: oldest.id }).update({ posts: (posts) => posts.connect([{ id: postId }]) });
```
## `cursor-and-distinct-on-check-the-receiver`
`cursor` and `distinctOn` need an order on the collection they are called on. The check is on the receiver, so a cast on the argument no longer bypasses it:
```ts
await db.Post.cursor({ id } as never).all(); // error: The 'this' context of type 'PostCollection' is not assignable to method's 'this' of type 'HasOrderBy'
```
Add the order the query needs:
```diff
- await db.Post.cursor({ id } as never).all();
+ await db.Post.orderBy((post) => post.id.asc()).cursor({ id }).all();
```
Where the query is meant to run without an order, cast the collection instead of the argument:
```ts
import type { Ordered } from '@prisma/orm-postgres/orm-client';
const unordered = db.Post as Ordered<typeof db.Post>;
await unordered.cursor({ id }).all();
```
## `apply-is-a-collection-member`
Collections have a new method, `apply(fn)`, which calls `fn` with the collection and returns the result. A custom collection class that declares its own `apply` with another signature no longer compiles. Rename that member and its call sites:
```diff
class PostCollection extends Collection<Contract, 'Post'> {
- apply(limit: number) { return this.limit(limit); }
+ firstPage(limit: number) { return this.limit(limit); }
}
```
An aggregate operation named `apply` is now refused with `ORM.AGGREGATE_OPERATION_RESERVED` when the client is built; rename the operation.
## `scope-is-a-collection-member`
The `scope` change applies to the SQL ORM client only. The MongoDB ORM client did not change; skip matches in code that uses it.
Collections have a new method, `scope(body)`, which defines a scope for the collection's model: `db.orm.public.Post.scope((posts) => posts.select('id', 'title'))`. A custom collection class that declares its own `scope` with another signature no longer compiles. Rename that member and its call sites:
```diff
class PostCollection extends Collection<Contract, 'Post'> {
- scope(userId: string) { return this.where({ userId }); }
+ ownedBy(userId: string) { return this.where({ userId }); }
}
```
An aggregate operation named `scope` is now refused with `ORM.AGGREGATE_OPERATION_RESERVED` when the client is built; rename the operation.
## `overriding-a-chaining-method`
A class that extends `Collection` and overrides one of the chaining methods, or one of the methods that return rows (`all`, `first`, `create`, `createAll`, `upsert` and the writes), must declare the override with the new signature: a type parameter for the receiver, a `this` parameter of that type, and the result type the base method returns. Call the base method with `call` and explicit type arguments, so that the receiver type passes through:
```diff
class PostCollection extends Collection<Contract, 'Post'> {
- override limit(n: number) {
- return super.limit(Math.min(n, 100));
- }
+ override limit<Self>(this: Self, n: number): Self {
+ return super.limit.call<Self, [number], Self>(this, Math.min(n, 100));
+ }
}
```
Only classes that extend the SQL `Collection` are affected; skip matches in other classes.
## `collection-state-flags-are-boolean`
`DefaultCollectionTypeState` declares `hasWhere`, `hasOrderBy` and `hasUniqueFilter` as `boolean`, meaning not known. A method that establishes a flag sets it to `true`. Where your code expects `false`, expect `boolean`:
```diff
- type Check = Equal<CollectionTypeStateOf<typeof users>['hasOrderBy'], false>;
+ type Check = Equal<CollectionTypeStateOf<typeof users>['hasOrderBy'], boolean>;
```
A type of your own that sets a flag to `false` should set it to `boolean`. The writes still need `hasWhere: true`, and `cursor` and `distinctOn` still need `hasOrderBy: true`.
## `read-collection-state-and-row-with-helpers`
`where`, `orderBy` and `include` record what they establish in two declared properties, not in the type arguments of `Collection`. Inferring the third or fourth type argument gives the row and the state the collection started with. Read them with `CollectionRowOf` and `CollectionTypeStateOf` instead:
```diff
- type RowOf<C> = C extends Collection<infer _C, infer _M, infer Row, infer _S> ? Row : never;
- type UsersRow = RowOf<typeof users>;
+ import type { CollectionRowOf } from '@prisma/orm-postgres/orm-client';
+ type UsersRow = CollectionRowOf<typeof users>;
```
To keep a helper of your own, constrain its parameter, because both helpers require one:
```ts
import type { CollectionRowOf, CollectionTypeStateOf, HasRow, HasTypeState } from '@prisma/orm-postgres/orm-client';
type RowOf<C extends HasRow> = CollectionRowOf<C>;
type StateOf<C extends HasTypeState> = CollectionTypeStateOf<C>;
```
## `return-type-of-a-chaining-method`
The chaining methods are generic in their receiver, and `ReturnType` of a generic method uses the constraint of its type parameter. `ReturnType<PostCollection['where']>` is now `HasWhere`, and `ReturnType<PostCollection['limit']>` is `unknown`. Write the type with `Filtered` after `where`, `Ordered` after `orderBy`, and the collection type itself after `limit`, `offset`, `distinct`, `distinctOn` and `cursor`. You can also take `typeof` of a value:
```diff
- type MatchingPosts = ReturnType<PostCollection['where']>;
+ import type { Filtered } from '@prisma/orm-postgres/orm-client';
+ type MatchingPosts = Filtered<PostCollection>;
```
`ReturnType` of a method of your own class, such as `ReturnType<PostCollection['withTitle']>`, still works.
## `chaining-methods-take-no-explicit-type-arguments`
These methods infer their receiver from the call. With explicit type arguments the receiver is not inferred: `posts.include<'user'>('user')` does not compile, `posts.distinct<['title']>('title')` and `posts.distinctOn<['title']>('title')` fail with "Expected 2 type arguments, but got 1", and `ReturnType<typeof posts.include<'user'>>` is `never`. Drop the type arguments. They are inferred from the arguments, so `distinct<['title']>('title')` becomes `distinct('title')`:
```diff
- const titles = posts.distinct<['title']>('title');
+ const titles = posts.distinct('title');
```
To name the type of an include, call `include` on a value and take its type:
```diff
- type WithUser = ReturnType<typeof posts.include<'user'>>;
+ const withUser = posts.include('user');
+ type WithUser = typeof withUser;
```
## `custom-collection-methods-chain`
Custom collection methods are now available after `where`, `orderBy`, `limit` and the other chaining methods. Where code on a client built with `orm({ collections })` repeats a class method's body inline after a chaining call on that class, it can call the method. A client without custom classes, such as `db.orm`, has no such methods.
```diff
return db.Post.forUser(userId)
- .orderBy((post) => post.createdAt.desc())
+ .newestFirst()
.limit(limit)
.all();
```
Here `newestFirst()` is a method of the application's `PostCollection` whose body is that `orderBy`.
## `variant-takes-discriminator-value`
For every `.variant(...)` call matched by `detection`, replace the variant's model name with the discriminator value that variant declares. Read the value from the contract: in PSL it is the second argument of the variant's `@@base(Base, "<value>")`; in the TypeScript builder it is the `value` under the base model's `discriminator.variants.<VariantName>`; in `contract.json` it is `domain.namespaces.<ns>.models.<Base>.variants.<VariantName>.value`.
```ts
// before
db.orm.public.Task.variant('Bug');
db.orm.events.variant('ViewProductEvent');
// after, for @@base(Task, "bug") and a ViewProductEvent variant declaring "view-product"
db.orm.public.Task.variant('bug');
db.orm.events.variant('view-product');
```
Custom collection methods that call `this.variant('<ModelName>')`, and helpers or closures that call `.variant()`, change the same way. When the argument is a variable or a forwarded parameter, follow it back to where the model name is written and replace it there, including in type annotations that list variant model names. Update code comments and READMEs that show `.variant('<ModelName>')` too.
The type checker catches most old call sites, because the parameter only accepts the base model's declared values. It does not catch a call whose model-name argument happens to equal a declared value, for example a variant model declared with `@@base(Base, "Admin")`. Such a call keeps compiling and now selects the variant declaring that value, so check every call site against the contract rather than relying on type errors.
A call that passes a value through `as never` or another cast also escapes the type checker. At runtime, a value the base model does not declare, or a `variant()` call on a model without a discriminator, throws `ORM.ARGUMENT_INVALID`. The error names the model and lists its declared values. Previously the call returned the collection unchanged, so the query read every variant. Code that relied on that fallback must stop calling `variant()` in that case.
A second `.variant()` call on a variant collection is rejected. It no longer replaces the first selection: it is a type error, and at runtime it throws `ORM.OPERATION_UNSUPPORTED` naming the model and the discriminator value already selected. Select each variant from the base collection instead, and keep a reference to the base collection where code needs more than one variant.
```ts
// before
const bugs = db.orm.public.Task.variant('Bug');
const features = bugs.variant('Feature');
// after
const tasks = db.orm.public.Task;
const bugs = tasks.variant('bug');
const features = tasks.variant('feature');
```
## `imported-postgres-field-checks-defaults`
`import { field } from '@prisma/orm-postgres/contract-builder'` now gives the same field builders the `defineContract` callback receives, without the ones an extension adds: `field.text()`, `field.temporal.timestamptz()`, `field.uuidString()`, and `field.column(columnType)` as before. Its `.default(...)` now checks the value against the Postgres target's column types; a column type an extension adds, such as pgvector's `vector`, is not checked. A default of another type no longer compiles:
```diff
- field.column(int8Column).default(1)
+ field.column(int8Column).default(1n)
```
## `re-emit-for-the-row-locking-capabilities`
The typed SQL builder gains four methods that lock the rows a select reads, named after the SQL they render: `forUpdate()`, `forNoKeyUpdate()`, `forShare()` and `forKeyShare()`. Each takes an optional `{ of, nowait, skipLocked }`: `of` names the tables or aliases to lock, and `nowait` and `skipLocked` exclude each other. A lock lasts until the transaction ends, so use it inside `db.transaction(...)`:
```ts
await db.transaction(async (tx) => {
const [job] = await tx.query(
tx.sql.public.job
.select('id')
.where((f, fns) => fns.eq(f.state, 'queued'))
.limit(1)
.forUpdate({ skipLocked: true })
.build(),
);
});
```
Each method and each option is gated on a capability key that the Postgres adapter now reports: `sql.forUpdate`, `sql.forShare`, `postgres.forNoKeyUpdate`, `postgres.forKeyShare`, and `sql.lockOf`, `sql.lockNowait`, `sql.lockSkipLocked` for the options. A `contract.json` emitted before this release carries none of them, so the methods do not exist on its builder.
Re-emit your contract to pick up the keys:
```console
prisma contract emit
```
Nothing else changes. The keys are additive, the storage hash does not move, and every existing query behaves exactly as before. You only need to re-emit if you want to use the new methods. SQLite reports none of the keys, because SQLite has no row locks.
## `cache-annotation-ttl-removed`
The read annotation no longer carries a lifetime. The store decides how long an entry lives. The default store, which `createCacheMiddleware()` uses when you pass no `store`, keeps every entry for 60 seconds.
This also changes behaviour without a type error. Before, a read annotated without `ttl` (`cacheAnnotation({})`, `cacheAnnotation({ key })`, or a `ttl` that evaluated to `undefined`) was not cached. Now every annotated read in runtime scope is cached. If a read must stay uncached, remove its annotation, or pass `cacheAnnotation({ bypass: true })`.
In each file that calls `cacheAnnotation(...)`:
1. Remove the `ttl` property. `cacheAnnotation({ ttl: 60_000 })` becomes `cacheAnnotation({})`, and `cacheAnnotation({ ttl, key })` becomes `cacheAnnotation({ key })`.
2. Remove any variable, function parameter or option field that existed only to supply that `ttl` (for example a `ttlMs` option on a helper that wraps the annotated read), and remove it from its callers.
3. If every read used the same lifetime and it was not 60 seconds, set it once on the store: `createCacheMiddleware({ store: createInMemoryCacheStore({ ttlMs }) })`. `ttlMs: Infinity` never expires. If reads need different lifetimes, write a custom `CacheStore` that reads the lifetime from the annotation's `meta` (see `cache-store-object-arguments`), and pass it as `cacheAnnotation({ meta: { ttlMs } })`.
4. Update comments and READMEs that say the annotation needs a `ttl`, that caching happens "within the TTL window" of the annotation, or that an annotation without `ttl` passes through. Say instead that an annotated read is cached and the default store keeps the entry for 60 seconds.
Before:
```ts
export async function getUsersCached(limit = 10, ttlMs = 60_000) {
const plan = db.sql.public.user
.select('id', 'email')
.annotate(cacheAnnotation({ ttl: ttlMs }))
.limit(limit)
.build();
return db.runtime().query(plan);
}
```
After:
```ts
export async function getUsersCached(limit = 10) {
const plan = db.sql.public.user
.select('id', 'email')
.annotate(cacheAnnotation({}))
.limit(limit)
.build();
return db.runtime().query(plan);
}
```
## `cache-annotation-skip-renamed-bypass`
In each file that imports from `@prisma/orm-extension-middleware-cache`:
1. Rename `skip` to `bypass` in every `cacheAnnotation(...)` argument: `cacheAnnotation({ skip: forceRefresh })` becomes `cacheAnnotation({ bypass: forceRefresh })`. Only rename `skip` inside a `cacheAnnotation` argument or a value typed `CachePayload`; leave other `skip` properties alone.
2. Rename the type `CachePayload` to `CacheAnnotationOptions`, in imports and in uses.
3. Update comments that describe `skip` on the annotation, or that say `skip` wins over a `ttl`.
## `cache-middleware-store-options`
In each call to `createCacheMiddleware(...)`:
- If the options contain only `maxEntries: 1000` (or `1_000`), and no `clock`, call `createCacheMiddleware()` with no options.
- Otherwise, move `maxEntries` and `clock` to a store: `createCacheMiddleware({ maxEntries: 500 })` becomes `createCacheMiddleware({ store: createInMemoryCacheStore({ maxEntries: 500 }) })`, importing `createInMemoryCacheStore` from `@prisma/orm-extension-middleware-cache`. The middleware's `clock` used to stamp `storedAt` only; it now drives the store's expiry.
`createInMemoryCacheStore` options are all optional now: `maxEntries` (default 1000), `ttlMs` (default 60 000) and `clock` (default `Date.now`).
## `cache-store-object-arguments`
Only for code that implements `CacheStore`, as a typed object, a class, or an object literal passed inline to `createCacheMiddleware({ store: { ... } })`. The types reject the old shape, but an untyped JavaScript store fails only at run time, so check each one.
1. Keep a version per key: an integer, 0 for a key never seen, that only `unset` changes. Keep it even for keys that hold no value, for at least as long as a read can take.
2. Change `get(key)` to `get({ key, meta })`, returning a `CacheEntry`: `{ key, meta, version, data }`, where `meta` is the `meta` passed in, `version` the key's current version, and `data` is `{ empty: true }` on a miss or `{ empty: false, value }` with the stored rows. `meta` is the read annotation's `meta`, or `undefined`. A store that matches `meta` folds the versions of whatever `meta` names into `version`, and does the same in `set`; a store that does not index `meta` ignores it here.
3. Change `set(key, entry, ttlMs)` to `set(entry, value)`, returning a boolean. `entry` is the `CacheEntry` your `get` returned; `value` is the rows. Store `value` under `entry.key` only if the key's version still equals `entry.version`, and return whether you stored. The comparison and the write must be atomic against `unset`: one synchronous step in memory, or one server-side script (for example Lua on Redis). There is no unconditional write and no `ttlMs` argument: the store sets the lifetime itself, as a fixed value or read from `entry.meta`. Code that prefilled the cache with `set(key, entry, ttlMs)` now calls `get` and then `set`.
4. Add `unset({ keys, meta })`. It removes the values stored under `keys` when `keys` is not `undefined`, and increments the version of every one of those keys, whether or not it holds a value. When `meta` is not `undefined`, it removes the values whose `meta` matches, compared by value, and increments their versions; a store that does not index `meta` must throw instead of ignoring it.
5. Replace `CachedEntry` with `CachedRows` (the rows, `readonly Record<string, unknown>[]`) for stored values and `CacheEntry` for what `get` returns. Type the store as `CacheStore<TMeta>`, which is `CacheStore<TMeta, CachedRows>`; `createCacheMiddleware` rejects a store of any other value type, `unknown` included. There is no `storedAt`.
Before:
```ts
const store: CacheStore = {
async get(key) {
const raw = await redis.get(key);
return raw ? (JSON.parse(raw) as CachedEntry) : undefined;
},
async set(key, entry, ttlMs) {
await redis.set(key, JSON.stringify(entry), 'PX', ttlMs);
},
};
```
After:
```ts
const SET_IF_VERSION = `
if tonumber(redis.call('GET', KEYS[2]) or '0') ~= tonumber(ARGV[2]) then return 0 end
redis.call('SET', KEYS[1], ARGV[1], 'PX', 60000)
return 1`;
const store: CacheStore<unknown, CachedRows> = {
async get({ key, meta }) {
const [raw, version] = await redis.mget(`value:${key}`, `version:${key}`);
return {
key,
meta,
version: Number(version ?? 0),
data: raw ? { empty: false, value: JSON.parse(raw) as CachedRows } : { empty: true },
};
},
async set(entry, value) {
const keys = [`value:${entry.key}`, `version:${entry.key}`];
const json = JSON.stringify(value);
return (await redis.eval(SET_IF_VERSION, 2, ...keys, json, entry.version)) === 1;
},
async unset({ keys, meta }) {
if (meta !== undefined) {
throw new Error('This store does not index meta');
}
for (const key of keys ?? []) {
await redis
.multi()
.del(`value:${key}`)
.incr(`version:${key}`)
.pexpire(`version:${key}`, 60_000)
.exec();
}
},
};
```
## `contract-artifacts-restamp`
For every `contract.json` matched by `detection`, run the project's emit command (`prisma contract emit`, or the project's `contract:emit` script) once after upgrading. This entry accounts for the extension's embedded `version` moving to `8.0.0-rc.15`; any other difference in the emitted files comes from an earlier entry in this guide.
upgrading/app/upgrades/8.0.0-rc.14-to-8.0.0-rc.15/scripts/data-type-in-contract/data-type-in-contract.ts
#!/usr/bin/env node
/**
* Rewrites every SQL contract in a project from `nativeType` to `dataType`, and a list stored as
* `many: true` to `many: { elementNullable: false }`, then renames the snapshot directories and
* rewrites every migration, ref, `migration.ts` and `contract.d.ts` that names an old storage hash.
*
* Usage: node data-type-in-contract.ts [project-root] [--data-type <codec id>=<data type id>]...
*
* `--data-type` names the data type of a codec the script's table does not know for a contract's
* target, such as an extension's own codec. It cannot change the data type of a codec the table
* knows for that target.
*
* The project root defaults to the working directory. The script reads and writes files only. It
* needs no database, network or configured stack, and a project already in the new format is left
* unchanged. When it stops (an unknown codec, or a snapshot directory that already exists with
* different content) it changes no file, prints one line per case and exits 1. When it finishes it
* prints how many files and snapshot directories it changed and each storage hash it replaced.
*
* The storage hash and migration hash rules are copied from `@internal/contract` and
* `@internal/migration-tools` because a project's strict `node_modules` does not expose them.
*/
import { createHash } from 'node:crypto';
import {
chmodSync,
type Dirent,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
type Json = null | boolean | number | string | Json[] | JsonRecord;
interface JsonRecord {
[key: string]: Json;
}
const DATA_TYPES: Readonly<Record<string, Readonly<Record<string, string>>>> = {
postgres: {
'pg/text@1': 'pg/text',
'pg/text-array@1': 'pg/text-array',
'pg/enum@1': 'pg/enum',
'pg/char@1': 'pg/char',
'pg/varchar@1': 'pg/varchar',
'pg/int@1': 'pg/int4',
'pg/int2@1': 'pg/int2',
'pg/int4@1': 'pg/int4',
'pg/int8@1': 'pg/int8',
'pg/int8number@1': 'pg/int8',
'pg/float@1': 'pg/float8',
'pg/float4@1': 'pg/float4',
'pg/float8@1': 'pg/float8',
'pg/numeric@1': 'pg/numeric',
'pg/unboundedint@1': 'pg/numeric',
'pg/bool@1': 'pg/bool',
'pg/bit@1': 'pg/bit',
'pg/varbit@1': 'pg/varbit',
'pg/bytea@1': 'pg/bytea',
'pg/uuid@1': 'pg/uuid',
'pg/inet@1': 'pg/inet',
'pg/tsquery@1': 'pg/tsquery',
'pg/interval@1': 'pg/interval',
'pg/json@1': 'pg/json',
'pg/jsonb@1': 'pg/jsonb',
'pg/timetz@1': 'pg/timetz',
'pg/date-temporal@1': 'pg/date',
'pg/timestamp-temporal@1': 'pg/timestamp',
'pg/timestamptz-temporal@1': 'pg/timestamptz',
'pg/time-temporal@1': 'pg/time',
'pg/date-string@1': 'pg/date',
'pg/timestamp-string@1': 'pg/timestamp',
'pg/timestamptz-string@1': 'pg/timestamptz',
'pg/time-string@1': 'pg/time',
'pg/timestamptz-date@1': 'pg/timestamptz',
'pg/timestamptz@1': 'pg/timestamptz',
'pg/timestamp@1': 'pg/timestamp',
'pg/time@1': 'pg/time',
'pg/date@1': 'pg/date',
'sql/char@1': 'pg/char',
'sql/varchar@1': 'pg/varchar',
'sql/int@1': 'pg/int4',
'sql/float@1': 'pg/float8',
'sql/text@1': 'pg/text',
'sql/timestamp@1': 'pg/timestamptz',
'pg/vector@1': 'pgvector/vector',
'pg/geometry@1': 'postgis/geometry',
'arktype/json@1': 'pg/jsonb',
},
sqlite: {
'sqlite/text@1': 'sqlite/text',
'sqlite/json@1': 'sqlite/text',
'sqlite/datetime@1': 'sqlite/text',
'sqlite/integer@1': 'sqlite/integer',
'sqlite/bigint@1': 'sqlite/integer',
'sqlite/bigintnumber@1': 'sqlite/integer',
'sql/int@1': 'sqlite/integer',
'sqlite/real@1': 'sqlite/real',
'sql/float@1': 'sqlite/real',
'sqlite/blob@1': 'sqlite/blob',
'sql/char@1': 'sqlite/character',
'sql/varchar@1': 'sqlite/character-varying',
},
};
const SQLITE_INTEGER_CODECS = new Set([
'sqlite/integer@1',
'sql/int@1',
'sqlite/bigint@1',
'sqlite/bigintnumber@1',
]);
const SQLITE_JSON_CODEC = 'sqlite/json@1';
const SKIPPED_DIRECTORIES = new Set(['node_modules', '.git', 'dist', 'build']);
const HASH = /^[0-9a-f]{64}$/;
const TEMPORARY_SUFFIX = '.data-type-in-contract-tmp';
function isRecord(value: unknown): value is JsonRecord {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const prototype: unknown = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function parseJson(text: string): Json | undefined {
try {
const value: Json = JSON.parse(text);
return value;
} catch {
return undefined;
}
}
function sha256(text: string): string {
return createHash('sha256').update(text).digest('hex');
}
function compareCodeUnits(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
function sortKeys(value: Json): Json {
if (Array.isArray(value)) return value.map(sortKeys);
if (!isRecord(value)) return value;
const sorted: JsonRecord = {};
for (const key of Object.keys(value).sort(compareCodeUnits)) {
const child = value[key];
if (child !== undefined) sorted[key] = sortKeys(child);
}
return sorted;
}
function canonicalizeJson(value: Json): string {
return JSON.stringify(sortKeys(value));
}
type PathSegment = string | readonly string[];
function matchesPath(path: readonly string[], pattern: readonly PathSegment[]): boolean {
if (path.length !== pattern.length) return false;
return pattern.every((segment, index) => {
const value = path[index];
if (value === undefined) return false;
if (segment === '*') return true;
return typeof segment === 'string' ? value === segment : segment.includes(value);
});
}
const SQL_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
['storage', 'namespaces', '*', 'entries', 'table'],
['storage', 'namespaces', '*', 'entries', 'table', '*'],
['storage', 'namespaces', '*', 'entries', 'table', '*', ['uniques', 'indexes', 'foreignKeys']],
['storage', 'namespaces', '*', 'entries', 'table', '*', 'indexes', 'unique'],
['storage', 'namespaces', '*', 'entries', 'table', '*', 'columns', '*', 'default', 'value'],
];
const COLUMN_DEFAULT_VALUE: readonly PathSegment[] = [
'storage',
'namespaces',
'*',
'entries',
'table',
'*',
'columns',
'*',
'default',
'value',
];
const COLUMN_PATHS: readonly (readonly PathSegment[])[] = [
['storage', 'namespaces', '*', 'entries', 'table', '*', 'columns', '*'],
['storage', 'namespaces', '*', 'tables', '*', 'columns', '*'],
];
const NATIVE_TYPE_PATHS: readonly (readonly PathSegment[])[] = [
...COLUMN_PATHS,
['storage', 'types', '*'],
];
const LIST_PATHS: readonly (readonly PathSegment[])[] = [
...COLUMN_PATHS,
['domain', 'namespaces', '*', ['models', 'valueObjects'], '*', 'fields', '*'],
];
const FRAMEWORK_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
['domain', 'namespaces'],
['domain', 'namespaces', '*'],
['domain', 'namespaces', '*', 'models'],
['domain', 'namespaces', '*', 'models', '*', 'relations'],
['domain', 'namespaces', '*', 'models', '*', 'storage'],
['storage', 'namespaces'],
['storage', 'namespaces', '*', 'entries'],
['roots'],
['extensions'],
['extensions', '*'],
['capabilities'],
['meta'],
['execution', 'mutations', 'defaults'],
];
const TOP_LEVEL_ORDER = [
'schemaVersion',
'canonicalVersion',
'targetFamily',
'target',
'profileHash',
'roots',
'domain',
'storage',
'execution',
'capabilities',
'extensions',
'defaultControlPolicy',
'meta',
];
function isDefaultValue(value: Json): boolean {
if (value === false) return true;
if (Array.isArray(value)) return value.length === 0;
return isRecord(value) && Object.keys(value).length === 0;
}
function isPreservedEmpty(path: readonly string[], key: string): boolean {
if (key === 'nullable') return true;
if (key === 'elementNullable' && path.at(-2) === 'many') return true;
if (FRAMEWORK_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern))) return true;
if (
path.length >= COLUMN_DEFAULT_VALUE.length &&
matchesPath(path.slice(0, COLUMN_DEFAULT_VALUE.length), COLUMN_DEFAULT_VALUE)
)
return true;
return SQL_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern));
}
function omitDefaults(value: Json, path: readonly string[]): Json {
if (Array.isArray(value)) return value.map((item) => omitDefaults(item, path));
if (!isRecord(value)) return value;
const result: JsonRecord = {};
for (const [key, child] of Object.entries(value)) {
const childPath = [...path, key];
if (key === '_generated') continue;
if (key === 'generated' && child === false) continue;
if ((key === 'onDelete' || key === 'onUpdate') && child === 'noAction') continue;
if (isDefaultValue(child) && !isPreservedEmpty(childPath, key)) continue;
result[key] = omitDefaults(child, childPath);
}
return result;
}
function compareByName(a: Json, b: Json): number {
const nameA = isRecord(a) && typeof a['name'] === 'string' ? a['name'] : '';
const nameB = isRecord(b) && typeof b['name'] === 'string' ? b['name'] : '';
return compareCodeUnits(nameA, nameB);
}
function sortTableArrays(storage: Json): Json {
if (!isRecord(storage)) return storage;
const namespaces = storage['namespaces'];
if (!isRecord(namespaces)) return storage;
const sortedNamespaces: JsonRecord = {};
for (const [namespaceId, namespace] of Object.entries(namespaces)) {
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
const tables = isRecord(entries) ? entries['table'] : undefined;
if (!isRecord(namespace) || !isRecord(entries) || !isRecord(tables)) {
sortedNamespaces[namespaceId] = namespace;
continue;
}
const sortedTables: JsonRecord = {};
for (const [tableName, table] of Object.entries(tables)) {
if (!isRecord(table)) {
sortedTables[tableName] = table;
continue;
}
const sortedTable: JsonRecord = { ...table };
for (const key of ['checks', 'indexes', 'uniques']) {
const list = table[key];
if (Array.isArray(list)) sortedTable[key] = [...list].sort(compareByName);
}
sortedTables[tableName] = sortedTable;
}
sortedNamespaces[namespaceId] = { ...namespace, entries: { ...entries, table: sortedTables } };
}
return { ...storage, namespaces: sortedNamespaces };
}
function orderTopLevel(value: JsonRecord): JsonRecord {
const ordered: JsonRecord = {};
const remaining = new Set(Object.keys(value));
for (const key of TOP_LEVEL_ORDER) {
const child = value[key];
if (child !== undefined) {
ordered[key] = child;
remaining.delete(key);
}
}
for (const key of [...remaining].sort(compareCodeUnits)) {
const child = value[key];
if (child !== undefined) ordered[key] = child;
}
return ordered;
}
function omitNamespaceKinds(storage: JsonRecord): JsonRecord {
const namespaces = storage['namespaces'];
if (!isRecord(namespaces)) return storage;
const stripped: JsonRecord = {};
for (const [namespaceId, namespace] of Object.entries(namespaces)) {
if (isRecord(namespace)) {
const { kind: _kind, ...rest } = namespace;
stripped[namespaceId] = rest;
} else {
stripped[namespaceId] = namespace;
}
}
return { ...storage, namespaces: stripped };
}
function computeStorageHash(contract: JsonRecord): string {
const storage = isRecord(contract['storage']) ? contract['storage'] : {};
const { storageHash: _published, ...withoutHash } = storage;
const hashed: JsonRecord = {
schemaVersion: '1',
targetFamily: typeof contract['targetFamily'] === 'string' ? contract['targetFamily'] : '',
target: typeof contract['target'] === 'string' ? contract['target'] : '',
profileHash: '',
roots: {},
domain: { namespaces: {} },
storage: omitNamespaceKinds(withoutHash),
extensions: {},
capabilities: {},
meta: {},
};
const withoutDefaults = omitDefaults(hashed, []);
if (!isRecord(withoutDefaults)) return '';
const withSortedStorage = {
...withoutDefaults,
storage: sortTableArrays(withoutDefaults['storage'] ?? {}),
};
const sorted = sortKeys(withSortedStorage);
return isRecord(sorted) ? sha256(JSON.stringify(orderTopLevel(sorted), null, 2)) : '';
}
function computeMigrationHash(metadata: JsonRecord, ops: Json): string {
const { migrationHash: _migrationHash, ...withoutHash } = metadata;
const parts = [canonicalizeJson(withoutHash), canonicalizeJson(ops)].map(sha256);
return sha256(canonicalizeJson(parts));
}
interface ProjectFiles {
readonly json: readonly string[];
readonly migrationJson: readonly string[];
readonly refs: readonly string[];
readonly migrationTs: readonly string[];
readonly leftovers: readonly string[];
}
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
/** A file, or a link to one: the script reads and writes through links to files. */
function isFileEntry(dir: string, entry: Dirent): boolean {
return entry.isFile() || (entry.isSymbolicLink() && isFile(join(dir, entry.name)));
}
/** A file the script reads, by the path it was reached through: its own path rather than a link's. */
interface FoundFile {
readonly path: string;
readonly linked: boolean;
readonly inMigrationPackage: boolean;
}
function listFiles(root: string): ProjectFiles {
const leftovers: string[] = [];
const visited = new Set<string>();
const found = new Map<string, FoundFile>();
const visit = (dir: string): void => {
const realDir = realpathSync(dir);
if (visited.has(realDir)) return;
visited.add(realDir);
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
compareCodeUnits(a.name, b.name),
);
const inMigrationPackage = entries.some(
(entry) => entry.name === 'migration.json' && isFileEntry(dir, entry),
);
for (const entry of entries) {
const path = join(dir, entry.name);
if (entry.name.endsWith(TEMPORARY_SUFFIX)) {
leftovers.push(path);
continue;
}
if (entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(path))) {
if (!SKIPPED_DIRECTORIES.has(entry.name)) visit(path);
continue;
}
if (!isFileEntry(dir, entry)) continue;
const realFile = realpathSync(path);
const linked = entry.isSymbolicLink();
const known = found.get(realFile);
if (known === undefined || (known.linked && !linked))
found.set(realFile, { path, linked, inMigrationPackage });
}
};
visit(root);
const files = [...found.values()];
const named = (test: (file: FoundFile, name: string) => boolean): string[] =>
files.filter((file) => test(file, basename(file.path))).map((file) => file.path);
return {
json: named((_file, name) => name.endsWith('.json')),
migrationJson: named((_file, name) => name === 'migration.json'),
refs: named((file, name) => name.endsWith('.json') && basename(dirname(file.path)) === 'refs'),
migrationTs: named((file, name) => name === 'migration.ts' && file.inMigrationPackage),
leftovers,
};
}
function isSnapshotContract(path: string): boolean {
const snapshotDir = dirname(path);
return (
basename(path) === 'contract.json' &&
HASH.test(basename(snapshotDir)) &&
basename(dirname(snapshotDir)) === 'snapshots'
);
}
interface Rewrite {
readonly contract: JsonRecord;
readonly changed: boolean;
readonly unknownCodecs: readonly string[];
readonly defaultRewrites: ReadonlyMap<string, Json>;
readonly integerEnums: ReadonlySet<string>;
readonly jsonEnums: ReadonlyMap<string, JsonEnumValues>;
readonly columnCardinalities: ReadonlyMap<string, Json>;
}
/** The rewritten values of an enum typed by `sqlite/json@1`: its value set and its members by name. */
interface JsonEnumValues {
values: readonly Json[] | undefined;
readonly members: Map<string, Json>;
}
function rewriteDefault(target: string, codecId: string, value: JsonRecord): JsonRecord {
const defaultValue = value['default'];
if (target !== 'sqlite' || !isRecord(defaultValue) || defaultValue['kind'] !== 'literal')
return value;
const literal = defaultValue['value'];
if (literal === undefined) return value;
if (codecId === SQLITE_JSON_CODEC)
return { ...value, default: { ...defaultValue, value: canonicalizeJson(literal) } };
if (
SQLITE_INTEGER_CODECS.has(codecId) &&
typeof literal === 'number' &&
Number.isInteger(literal)
)
return { ...value, default: { ...defaultValue, value: BigInt(literal).toString() } };
return value;
}
function digitText(value: Json): Json {
return typeof value === 'number' && Number.isInteger(value) ? BigInt(value).toString() : value;
}
function mapRecord(value: Json, map: (key: string, child: Json) => Json): Json {
if (!isRecord(value)) return value;
const result: JsonRecord = {};
for (const [key, child] of Object.entries(value)) result[key] = map(key, child);
return result;
}
function mapEntries(
value: Json,
path: readonly string[],
patterns: readonly (readonly PathSegment[])[],
rewrite: (entry: JsonRecord, path: readonly string[]) => JsonRecord,
): Json {
if (!isRecord(value)) return value;
if (patterns.some((pattern) => matchesPath(path, pattern))) return rewrite(value, path);
const leadsToEntry = patterns.some(
(pattern) => path.length < pattern.length && matchesPath(path, pattern.slice(0, path.length)),
);
return leadsToEntry
? mapRecord(value, (key, child) => mapEntries(child, [...path, key], patterns, rewrite))
: value;
}
function mapContractEntries(
contract: JsonRecord,
patterns: readonly (readonly PathSegment[])[],
rewrite: (entry: JsonRecord, path: readonly string[]) => JsonRecord,
): JsonRecord {
const result: JsonRecord = {};
for (const [key, child] of Object.entries(contract))
result[key] = mapEntries(child, [key], patterns, rewrite);
return result;
}
/** Release 8.0.0-rc.14 stored a list column or field as `many: true`; the current form is `many: { elementNullable: false }`. */
function currentListForm(entry: JsonRecord): JsonRecord {
return entry['many'] === true ? { ...entry, many: { elementNullable: false } } : entry;
}
function storageColumnKey(namespaceId: string, table: string, column: string): string {
return `${namespaceId}\u0000${table}\u0000${column}`;
}
/** The `many` of every storage column, `false` for a column that is not a list. */
function columnCardinalities(contract: JsonRecord): ReadonlyMap<string, Json> {
const cardinalities = new Map<string, Json>();
const storage = contract['storage'];
const namespaces = isRecord(storage) ? storage['namespaces'] : undefined;
for (const [namespaceId, namespace] of isRecord(namespaces) ? Object.entries(namespaces) : []) {
if (!isRecord(namespace)) continue;
const entries = namespace['entries'];
const tables = isRecord(entries) ? entries['table'] : namespace['tables'];
for (const [tableName, table] of isRecord(tables) ? Object.entries(tables) : []) {
const columns = isRecord(table) ? table['columns'] : undefined;
for (const [columnName, column] of isRecord(columns) ? Object.entries(columns) : [])
cardinalities.set(
storageColumnKey(namespaceId, tableName, columnName),
isRecord(column) ? (column['many'] ?? false) : false,
);
}
}
return cardinalities;
}
function enumKey(namespaceId: string, name: string): string {
return `${namespaceId}\u0000${name}`;
}
/**
* On SQLite the integer codecs store digit text and `sqlite/json@1` stores the JSON text of a
* document, so the members of an enum typed by one of them, in the domain and in the storage value
* set its columns name, are rewritten to that text too.
*/
function rewriteSqliteEnums(contract: JsonRecord): {
readonly contract: JsonRecord;
readonly integerEnums: ReadonlySet<string>;
readonly jsonEnums: ReadonlyMap<string, JsonEnumValues>;
} {
const integerEnums = new Set<string>();
const jsonEnums = new Map<string, JsonEnumValues>();
const jsonEnum = (key: string): JsonEnumValues => {
const existing = jsonEnums.get(key);
if (existing !== undefined) return existing;
const created: JsonEnumValues = { values: undefined, members: new Map() };
jsonEnums.set(key, created);
return created;
};
const domainCodecs = new Map<string, string>();
const domain = mapRecord(contract['domain'] ?? null, (key, namespaces) =>
key !== 'namespaces'
? namespaces
: mapRecord(namespaces, (namespaceId, namespace) =>
mapRecord(namespace, (kind, enums) =>
kind !== 'enum'
? enums
: mapRecord(enums, (name, enumType) => {
const codecId = isRecord(enumType) ? enumType['codecId'] : undefined;
if (typeof codecId === 'string')
domainCodecs.set(enumKey(namespaceId, name), codecId);
const members = isRecord(enumType) ? enumType['members'] : undefined;
if (!isRecord(enumType) || typeof codecId !== 'string' || !Array.isArray(members))
return enumType;
const key = enumKey(namespaceId, name);
if (SQLITE_INTEGER_CODECS.has(codecId)) {
integerEnums.add(key);
return {
...enumType,
members: members.map((member) =>
isRecord(member) && member['value'] !== undefined
? { ...member, value: digitText(member['value']) }
: member,
),
};
}
if (codecId !== SQLITE_JSON_CODEC) return enumType;
const rewritten = jsonEnum(key);
return {
...enumType,
members: members.map((member) => {
const value = isRecord(member) ? member['value'] : undefined;
if (!isRecord(member) || value === undefined) return member;
const text = canonicalizeJson(value);
if (typeof member['name'] === 'string')
rewritten.members.set(member['name'], text);
return { ...member, value: text };
}),
};
}),
),
),
);
const columnCodecs = new Map<string, string>();
const storage = contract['storage'];
const namespaces = isRecord(storage) ? storage['namespaces'] : undefined;
for (const namespace of isRecord(namespaces) ? Object.values(namespaces) : []) {
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
const tables = isRecord(entries) ? entries['table'] : undefined;
for (const table of isRecord(tables) ? Object.values(tables) : []) {
const columns = isRecord(table) ? table['columns'] : undefined;
for (const column of isRecord(columns) ? Object.values(columns) : []) {
const valueSet = isRecord(column) ? column['valueSet'] : undefined;
const codecId = isRecord(column) ? column['codecId'] : undefined;
if (
isRecord(valueSet) &&
typeof valueSet['namespaceId'] === 'string' &&
typeof valueSet['entityName'] === 'string' &&
typeof codecId === 'string'
)
columnCodecs.set(enumKey(valueSet['namespaceId'], valueSet['entityName']), codecId);
}
}
}
const nextStorage = mapRecord(storage ?? null, (key, storageNamespaces) =>
key !== 'namespaces'
? storageNamespaces
: mapRecord(storageNamespaces, (namespaceId, namespace) =>
mapRecord(namespace, (namespaceKey, entries) =>
namespaceKey !== 'entries'
? entries
: mapRecord(entries, (entryKind, valueSets) =>
entryKind !== 'valueSet'
? valueSets
: mapRecord(valueSets, (name, valueSet) => {
const key = enumKey(namespaceId, name);
const codecId = columnCodecs.get(key) ?? domainCodecs.get(key);
const values = isRecord(valueSet) ? valueSet['values'] : undefined;
if (codecId === undefined || !isRecord(valueSet) || !Array.isArray(values))
return valueSet;
if (SQLITE_INTEGER_CODECS.has(codecId)) {
integerEnums.add(key);
return { ...valueSet, values: values.map(digitText) };
}
if (codecId !== SQLITE_JSON_CODEC) return valueSet;
const texts = values.map(canonicalizeJson);
jsonEnum(key).values = texts;
return { ...valueSet, values: texts };
}),
),
),
),
);
return {
contract: {
...contract,
...(contract['domain'] === undefined ? {} : { domain }),
...(storage === undefined ? {} : { storage: nextStorage }),
},
integerEnums,
jsonEnums,
};
}
function dataTypesFor(
target: string,
extra: ReadonlyMap<string, string>,
): Readonly<Record<string, string>> {
return { ...Object.fromEntries(extra), ...DATA_TYPES[target] };
}
const CODEC_ID = /^[^\s=]+$/;
const DATA_TYPE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/;
interface Options {
readonly root: string;
readonly dataTypes: ReadonlyMap<string, string>;
readonly errors: readonly string[];
}
function parseOptions(args: readonly string[]): Options {
const dataTypes = new Map<string, string>();
const errors: string[] = [];
const positional: string[] = [];
for (let index = 0; index < args.length; index++) {
const arg = args[index] ?? '';
if (arg !== '--data-type' && !arg.startsWith('--data-type=')) {
if (arg.startsWith('--')) errors.push(`${arg}: unknown option`);
else positional.push(arg);
continue;
}
const value = arg === '--data-type' ? (args[++index] ?? '') : arg.slice('--data-type='.length);
const separator = value.indexOf('=');
const codecId = value.slice(0, separator);
const dataType = value.slice(separator + 1);
if (separator === -1 || !CODEC_ID.test(codecId) || !DATA_TYPE_ID.test(dataType)) {
errors.push(
`--data-type ${value}: expected <codec id>=<data type id>, for example acme/shape@1=acme/shape`,
);
continue;
}
dataTypes.set(codecId, dataType);
}
return { root: resolve(positional[0] ?? process.cwd()), dataTypes, errors };
}
function rewriteContract(contract: JsonRecord, extra: ReadonlyMap<string, string>): Rewrite {
const target = typeof contract['target'] === 'string' ? contract['target'] : '';
const table = dataTypesFor(target, extra);
const unknownCodecs = new Set<string>();
const defaultRewrites = new Map<string, Json>();
let changed = false;
const rewriteEntry = (value: JsonRecord, path: readonly string[]): JsonRecord => {
const codecId = value['codecId'];
if (typeof codecId !== 'string' || typeof value['nativeType'] !== 'string') return value;
const dataType = table[codecId];
if (dataType === undefined) {
unknownCodecs.add(codecId);
return value;
}
changed = true;
const { nativeType: _nativeType, ...rest } = value;
const rewritten = rewriteDefault(target, codecId, { ...rest, dataType });
const before = value['default'];
const after = rewritten['default'];
if (isRecord(before) && isRecord(after) && before['value'] !== after['value']) {
const columnKey = path.slice(-3);
const newValue = after['value'];
if (columnKey[1] === 'columns' && newValue !== undefined)
defaultRewrites.set(`${columnKey[0]}\u0000${columnKey[2]}`, newValue);
}
return rewritten;
};
const rewritten = mapContractEntries(contract, NATIVE_TYPE_PATHS, rewriteEntry);
const extensions = contract['extensions'];
for (const pack of isRecord(extensions) ? Object.values(extensions) : []) {
const types = isRecord(pack) ? pack['types'] : undefined;
const storage = isRecord(types) ? types['storage'] : undefined;
if (Array.isArray(storage) && storage.some((entry) => isRecord(entry) && 'nativeType' in entry))
changed = true;
}
if (changed && isRecord(extensions)) {
rewritten['extensions'] = mapRecord(extensions, (_packId, pack) => {
const types = isRecord(pack) ? pack['types'] : undefined;
if (!isRecord(pack) || !isRecord(types) || !('storage' in types)) return pack;
const { storage: _storage, ...rest } = types;
return { ...pack, types: rest };
});
}
const withLists = changed
? mapContractEntries(rewritten, LIST_PATHS, currentListForm)
: rewritten;
const enums =
target === 'sqlite' && changed
? rewriteSqliteEnums(withLists)
: { contract: withLists, integerEnums: new Set<string>(), jsonEnums: new Map() };
return {
contract: enums.contract,
changed,
unknownCodecs: [...unknownCodecs].sort(compareCodeUnits),
defaultRewrites,
integerEnums: enums.integerEnums,
jsonEnums: enums.jsonEnums,
columnCardinalities: columnCardinalities(withLists),
};
}
function withStorageHash(contract: JsonRecord, hash: string): JsonRecord {
const storage = isRecord(contract['storage']) ? contract['storage'] : {};
return { ...contract, storage: { ...storage, storageHash: hash } };
}
function emittedForm(contract: JsonRecord, original: string): string {
const { _generated: generated, ...rest } = contract;
const sorted = sortKeys(rest);
const ordered = isRecord(sorted) ? orderTopLevel(sorted) : {};
if (generated !== undefined) ordered['_generated'] = generated;
return `${JSON.stringify(ordered, null, 2)}${original.endsWith('\n') ? '\n' : ''}`;
}
function snapshotForm(contract: JsonRecord): string {
return `${canonicalizeJson(contract)}\n`;
}
interface Block {
readonly start: number;
end: number;
readonly label: string;
readonly parent: Block | undefined;
}
function scanBlocks(text: string): Block[] {
const blocks: Block[] = [];
const open: Block[] = [];
let quote: string | undefined;
for (let index = 0; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === "'" || char === '"' || char === '`') {
quote = char;
continue;
}
if (char === '{') {
const key =
/(?:readonly\s+)?([\p{ID_Start}_$][\p{ID_Continue}$]*|'[^']*'|"[^"]*")\s*\??\s*:\s*$/u.exec(
text.slice(Math.max(0, index - 200), index),
);
const block: Block = {
start: index,
end: text.length,
label: key?.[1]?.replace(/^['"]|['"]$/g, '') ?? '',
parent: open.at(-1),
};
open.push(block);
blocks.push(block);
} else if (char === '}') {
const block = open.pop();
if (block !== undefined) block.end = index;
}
}
return blocks;
}
function innermostBlock(blocks: readonly Block[], position: number): Block | undefined {
let found: Block | undefined;
for (const block of blocks) {
if (
block.start < position &&
position < block.end &&
(found === undefined || block.start > found.start)
)
found = block;
}
return found;
}
function isColumnBlock(block: Block): boolean {
return block.parent?.label === 'columns';
}
function isStorageTypeBlock(blocks: readonly Block[], block: Block): boolean {
const types = block.parent;
return (
types?.label === 'types' &&
blocks.some((other) => other.parent === types.parent && other.label === 'namespaces')
);
}
function isInColumnDefault(block: Block): boolean {
for (let ancestor = block.parent; ancestor !== undefined; ancestor = ancestor.parent)
if (
ancestor.label === 'default' &&
ancestor.parent !== undefined &&
isColumnBlock(ancestor.parent)
)
return true;
return false;
}
function holdsNativeType(blocks: readonly Block[], block: Block): boolean {
return (isColumnBlock(block) || isStorageTypeBlock(blocks, block)) && !isInColumnDefault(block);
}
/** The key of a storage column block, in the `entries.table` and the older `tables` layout. */
function columnBlockKey(block: Block): string | undefined {
const table = block.parent?.parent;
const tables = table?.parent;
const namespace = tables?.label === 'table' ? tables.parent?.parent : tables?.parent;
if (!isColumnBlock(block) || table === undefined || namespace === undefined) return undefined;
if (isInColumnDefault(block)) return undefined;
return storageColumnKey(namespace.label, table.label, block.label);
}
function manyType(many: Json): string {
return isRecord(many) && typeof many['elementNullable'] === 'boolean'
? `{ readonly elementNullable: ${many['elementNullable']} }`
: 'false';
}
/** Where a member written last in `block` goes, and the text that writes it. */
function lastMember(text: string, block: Block, member: string): { start: number; text: string } {
const lineStart = text.lastIndexOf('\n', block.end - 1) + 1;
const closingIndent = text.slice(lineStart, block.end);
if (closingIndent.trim() === '')
return { start: lineStart, text: `${closingIndent} ${member};\n` };
const contentEnd = block.start + 1 + text.slice(block.start + 1, block.end).trimEnd().length;
return {
start: contentEnd,
text: text[contentEnd - 1] === ';' ? ` ${member};` : `; ${member}`,
};
}
function prettierString(value: string): string {
const singles = (value.match(/'/g) ?? []).length;
const doubles = (value.match(/"/g) ?? []).length;
const quote = singles > doubles ? '"' : "'";
const escaped = JSON.stringify(value)
.slice(1, -1)
.replaceAll(String.fromCharCode(0x2028), '\\u2028')
.replaceAll(String.fromCharCode(0x2029), '\\u2029')
.replace(
/\\(.)|(["'])/gs,
(match: string, escapedChar: string | undefined, bare: string | undefined) => {
if (bare !== undefined) return bare === quote ? `\\${bare}` : bare;
if (escapedChar === quote) return match;
if (escapedChar === '"' || escapedChar === "'") return escapedChar;
return match;
},
);
return `${quote}${escaped}${quote}`;
}
function typeLiteral(value: Json): string {
return typeof value === 'string' ? prettierString(value) : JSON.stringify(value);
}
function genericArgumentEnd(text: string, start: number): number {
let depth = 0;
let quote: string | undefined;
for (let index = start; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === "'" || char === '"' || char === '`') quote = char;
else if (char === '{' || char === '[' || char === '(' || char === '<') depth++;
else if (char === '}' || char === ']' || char === ')') depth--;
else if (char === '>') {
if (depth === 0) return index;
depth--;
}
}
return text.length;
}
function memberOf(
text: string,
blocks: readonly Block[],
block: Block,
name: string,
): string | undefined {
const pattern = new RegExp(`readonly ${name}: (['"])((?:\\\\.|(?!\\1).)*)\\1`, 'g');
for (const member of text.slice(block.start, block.end).matchAll(pattern)) {
if (innermostBlock(blocks, block.start + member.index) === block) return member[2];
}
return undefined;
}
/** The end of the type literal that starts at `start`: the first `;`, `,` or closing bracket outside it. */
function literalEnd(text: string, start: number): number {
let depth = 0;
let quote: string | undefined;
for (let index = start; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === "'" || char === '"' || char === '`') quote = char;
else if (char === '{' || char === '[' || char === '(' || char === '<') depth++;
else if (char === '}' || char === ']' || char === ')' || char === '>') {
if (depth === 0) return index;
depth--;
} else if ((char === ';' || char === ',') && depth === 0) return index;
}
return text.length;
}
function bracketEnd(text: string, open: number): number {
let depth = 0;
let quote: string | undefined;
for (let index = open; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === "'" || char === '"' || char === '`') quote = char;
else if (char === '[' || char === '{' || char === '(') depth++;
else if (char === ']' || char === '}' || char === ')') {
depth--;
if (depth === 0) return index + 1;
}
}
return text.length;
}
function statementRemoval(
text: string,
start: number,
end: number,
): { start: number; end: number; text: string } {
const after = /^;?[ \t]*/.exec(text.slice(end))?.[0].length ?? 0;
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
const ownsLines = text.slice(lineStart, start).trim() === '' && text[end + after] === '\n';
return ownsLines
? { start: lineStart, end: end + after + 1, text: '' }
: { start, end: end + after, text: '' };
}
function quoteIntegers(text: string): string {
return text.replace(/(?<![\w'".-])-?\d+(?![\w'".])/g, (digits: string) => `'${BigInt(digits)}'`);
}
function rewriteDts(
text: string,
target: string,
extra: ReadonlyMap<string, string>,
defaultRewrites: ReadonlyMap<string, Json>,
integerEnums: ReadonlySet<string>,
jsonEnums: ReadonlyMap<string, JsonEnumValues>,
columnCardinalities: ReadonlyMap<string, Json>,
hashes: ReadonlyMap<string, string>,
): string {
const table = dataTypesFor(target, extra);
const blocks = scanBlocks(text);
const edits: { start: number; end: number; text: string }[] = [];
for (const match of text.matchAll(/readonly storage: readonly \[/g)) {
const types = innermostBlock(blocks, match.index);
if (types?.label !== 'types' || types.parent?.parent?.label !== 'extensions') continue;
const end = bracketEnd(text, match.index + match[0].length - 1);
edits.push(statementRemoval(text, match.index, end));
}
for (const match of text.matchAll(/readonly nativeType: (['"])(?:\\.|(?!\1).)*\1/g)) {
const block = innermostBlock(blocks, match.index);
if (block === undefined || !holdsNativeType(blocks, block)) continue;
const codecId = memberOf(text, blocks, block, 'codecId');
const dataType = codecId === undefined ? undefined : table[codecId];
if (dataType === undefined) continue;
edits.push({
start: match.index,
end: match.index + match[0].length,
text: `readonly dataType: ${prettierString(dataType)}`,
});
}
const listMembers = new Set<Block>();
for (const match of text.matchAll(/readonly many: (true|false|\{)/g)) {
const block = innermostBlock(blocks, match.index);
if (block === undefined) continue;
listMembers.add(block);
if (match[1] !== 'true') continue;
const key = columnBlockKey(block);
const many = key === undefined ? undefined : columnCardinalities.get(key);
if (many === undefined && block.parent?.label !== 'fields') continue;
edits.push({
start: match.index,
end: match.index + match[0].length,
text: `readonly many: ${manyType(many ?? { elementNullable: false })}`,
});
}
for (const block of blocks) {
const key = columnBlockKey(block);
const many = key === undefined ? undefined : columnCardinalities.get(key);
if (many === undefined || listMembers.has(block)) continue;
const member = lastMember(text, block, `readonly many: ${manyType(many)}`);
edits.push({ start: member.start, end: member.start, text: member.text });
}
for (const match of text.matchAll(/DefaultLiteralValue<\s*(['"])(?:\\.|(?!\1).)*\1\s*,\s*/g)) {
const argumentStart = match.index + match[0].length;
const defaultBlock = innermostBlock(blocks, match.index);
const column = defaultBlock?.parent;
const tableBlock = column?.parent?.parent;
if (column === undefined || tableBlock === undefined || column.parent?.label !== 'columns')
continue;
const value = defaultRewrites.get(`${tableBlock.label}\u0000${column.label}`);
if (value === undefined) continue;
const argumentEnd = genericArgumentEnd(text, argumentStart);
const trimmedEnd = argumentStart + text.slice(argumentStart, argumentEnd).trimEnd().length;
edits.push({ start: argumentStart, end: trimmedEnd, text: typeLiteral(value) });
}
for (const match of text.matchAll(/readonly values: readonly \[([^\]]*)\]/g)) {
const valueSet = innermostBlock(blocks, match.index);
const kind = valueSet?.parent;
const namespace = kind?.parent?.parent;
if (
valueSet === undefined ||
kind?.label !== 'valueSet' ||
kind.parent?.label !== 'entries' ||
namespace === undefined ||
!integerEnums.has(enumKey(namespace.label, valueSet.label))
)
continue;
const values = match[1] ?? '';
const start = match.index + match[0].length - values.length - 1;
edits.push({ start, end: start + values.length, text: quoteIntegers(values) });
}
for (const match of text.matchAll(/readonly value: (-?\d+)(?=\s*[;},])/g)) {
const member = innermostBlock(blocks, match.index);
const enumType = member?.parent;
const namespace = enumType?.parent?.parent;
const digits = match[1] ?? '';
if (
member === undefined ||
member.label !== '' ||
enumType?.parent?.label !== 'enum' ||
namespace === undefined ||
!integerEnums.has(enumKey(namespace.label, enumType.label))
)
continue;
const start = match.index + match[0].length - digits.length;
edits.push({ start, end: start + digits.length, text: quoteIntegers(digits) });
}
for (const match of text.matchAll(/readonly values: readonly \[/g)) {
const valueSet = innermostBlock(blocks, match.index);
const kind = valueSet?.parent;
const namespace = kind?.parent?.parent;
if (
valueSet === undefined ||
kind?.label !== 'valueSet' ||
kind.parent?.label !== 'entries' ||
namespace === undefined
)
continue;
const values = jsonEnums.get(enumKey(namespace.label, valueSet.label))?.values;
if (values === undefined) continue;
const open = match.index + match[0].length - 1;
edits.push({
start: open + 1,
end: bracketEnd(text, open) - 1,
text: values.map(typeLiteral).join(', '),
});
}
for (const match of text.matchAll(/readonly value: /g)) {
const member = innermostBlock(blocks, match.index);
const enumType = member?.parent;
const namespace = enumType?.parent?.parent;
if (
member === undefined ||
member.label !== '' ||
enumType?.parent?.label !== 'enum' ||
namespace === undefined
)
continue;
const name = memberOf(text, blocks, member, 'name');
const value =
name === undefined
? undefined
: jsonEnums.get(enumKey(namespace.label, enumType.label))?.members.get(name);
if (value === undefined) continue;
const start = match.index + match[0].length;
const end = start + text.slice(start, literalEnd(text, start)).trimEnd().length;
edits.push({ start, end, text: typeLiteral(value) });
}
for (const match of text.matchAll(/(['"])([0-9a-f]{64})\1/g)) {
const hash = match[2];
const next = hash === undefined ? undefined : hashes.get(hash);
if (next === undefined) continue;
edits.push({
start: match.index,
end: match.index + match[0].length,
text: `${match[1]}${next}${match[1]}`,
});
}
let result = text;
for (const edit of edits.sort((a, b) => b.start - a.start))
result = `${result.slice(0, edit.start)}${edit.text}${result.slice(edit.end)}`;
return result;
}
function replaceQuotedHashes(text: string, hashes: ReadonlyMap<string, string>): string {
return text.replace(/"([0-9a-f]{64})"/g, (match: string, hash: string) => {
const next = hashes.get(hash);
return next === undefined ? match : `"${next}"`;
});
}
interface ContractPlan {
readonly path: string;
readonly snapshot: boolean;
readonly oldHash: string;
readonly newHash: string;
readonly content: string;
readonly target: string;
readonly defaultRewrites: ReadonlyMap<string, Json>;
readonly integerEnums: ReadonlySet<string>;
readonly jsonEnums: ReadonlyMap<string, JsonEnumValues>;
readonly columnCardinalities: ReadonlyMap<string, Json>;
}
function readDirectory(dir: string): Map<string, string> {
const files = new Map<string, string>();
if (!existsSync(dir)) return files;
for (const entry of readdirSync(dir, { withFileTypes: true }))
if (isFileEntry(dir, entry) && !entry.name.endsWith(TEMPORARY_SUFFIX))
files.set(entry.name, readFileSync(join(dir, entry.name), 'utf8'));
return files;
}
function sameFiles(a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): boolean {
return a.size === b.size && [...a].every(([name, content]) => b.get(name) === content);
}
function temporaryPath(path: string): string {
return join(dirname(path), `.${basename(path)}${TEMPORARY_SUFFIX}`);
}
function writeWithModeOf(path: string, text: string, modeSource: string): void {
writeFileSync(path, text);
if (existsSync(modeSource)) chmodSync(path, statSync(modeSource).mode & 0o7777);
}
function writeFile(path: string, text: string): void {
const target = realpathSync(path);
const temporary = temporaryPath(target);
writeWithModeOf(temporary, text, target);
renameSync(temporary, target);
}
function writeDirectory(dir: string, files: ReadonlyMap<string, string>, sourceDir: string): void {
const temporary = temporaryPath(dir);
mkdirSync(temporary);
for (const [name, text] of files)
writeWithModeOf(join(temporary, name), text, join(sourceDir, name));
renameSync(temporary, dir);
}
function removeDirectory(dir: string): void {
const temporary = temporaryPath(dir);
renameSync(dir, temporary);
rmSync(temporary, { recursive: true, force: true });
}
function count(amount: number, singular: string, plural: string): string {
return `${amount} ${amount === 1 ? singular : plural}`;
}
function summary(
root: string,
contractsFound: number,
files: number,
directories: number,
hashes: ReadonlyMap<string, string>,
): readonly string[] {
if (contractsFound === 0) return [`No SQL contract was found under ${root}; nothing changed.`];
if (files === 0 && directories === 0)
return ['The project is already in the new format; nothing changed.'];
const renamed = count(directories, 'snapshot directory', 'snapshot directories');
const changes = `Rewrote ${count(files, 'file', 'files')} and renamed ${renamed}.`;
if (hashes.size === 0) return [changes];
return [
`${changes} Storage hashes changed (old -> new):`,
...[...hashes]
.sort(([a], [b]) => compareCodeUnits(a, b))
.map(([oldHash, newHash]) => ` ${oldHash} -> ${newHash}`),
];
}
const JSON_FILES_THAT_MUST_PARSE = new Set(['contract.json', 'migration.json']);
function main({ root, dataTypes, errors }: Options): number {
if (errors.length > 0) {
process.stderr.write(`${errors.join('\n')}\n`);
return 1;
}
const display = (path: string): string => relative(root, path).split(sep).join('/');
const files = listFiles(root);
const plans: ContractPlan[] = [];
const notices: string[] = [];
const stops: string[] = [];
let contractsFound = 0;
for (const path of files.json) {
const text = readFileSync(path, 'utf8');
const contract = parseJson(text);
if (
contract === undefined &&
(JSON_FILES_THAT_MUST_PARSE.has(basename(path)) || files.refs.includes(path))
) {
stops.push(`${display(path)}: not valid JSON`);
continue;
}
if (
!isRecord(contract) ||
contract['targetFamily'] !== 'sql' ||
typeof contract['target'] !== 'string'
)
continue;
const storage = contract['storage'];
if (!isRecord(storage)) continue;
contractsFound++;
const snapshot = isSnapshotContract(path);
const stored = storage['storageHash'];
const oldHash = snapshot ? basename(dirname(path)) : typeof stored === 'string' ? stored : '';
const recomputes = computeStorageHash(contract) === oldHash;
const rewrite = rewriteContract(contract, dataTypes);
for (const codecId of rewrite.unknownCodecs)
stops.push(
`${display(path)}: unknown codec ${codecId}; name its data type with --data-type ${codecId}=<data type id>`,
);
if (!rewrite.changed) continue;
if (!recomputes)
notices.push(`${display(path)}: stored hash did not recompute; rehashed from content`);
const newHash = computeStorageHash(rewrite.contract);
const contractWithHash = withStorageHash(rewrite.contract, newHash);
plans.push({
path,
snapshot,
oldHash,
newHash,
content: snapshot ? snapshotForm(contractWithHash) : emittedForm(contractWithHash, text),
target: contract['target'],
defaultRewrites: rewrite.defaultRewrites,
integerEnums: rewrite.integerEnums,
jsonEnums: rewrite.jsonEnums,
columnCardinalities: rewrite.columnCardinalities,
});
}
for (const target of [...new Set(plans.map((plan) => plan.target))].sort(compareCodeUnits)) {
for (const [codecId, dataType] of dataTypes) {
const known = DATA_TYPES[target]?.[codecId];
if (known !== undefined)
stops.push(
`--data-type ${codecId}=${dataType}: the script already maps ${codecId} to ${known} on target ${target}`,
);
}
}
const hashes = new Map<string, string>();
for (const plan of [...plans].sort((a, b) => Number(b.snapshot) - Number(a.snapshot)))
if (plan.oldHash !== plan.newHash && !hashes.has(plan.oldHash))
hashes.set(plan.oldHash, plan.newHash);
const newDirectories = new Map<string, Map<string, string>>();
const directorySources = new Map<string, string>();
const sourceDirectories = new Set<string>();
const removals = new Set<string>();
const snapshotRewrites: [string, string][] = [];
const referenceWrites = new Map<string, string>();
const contractWrites = new Map<string, string>();
for (const plan of plans) {
const dtsPath = plan.snapshot
? join(dirname(plan.path), 'contract.d.ts')
: `${plan.path.slice(0, -'.json'.length)}.d.ts`;
const dtsHashes = plan.snapshot ? hashes : new Map([...hashes, [plan.oldHash, plan.newHash]]);
const dts = existsSync(dtsPath)
? rewriteDts(
readFileSync(dtsPath, 'utf8'),
plan.target,
dataTypes,
plan.defaultRewrites,
plan.integerEnums,
plan.jsonEnums,
plan.columnCardinalities,
dtsHashes,
)
: undefined;
if (!plan.snapshot) {
if (dts !== undefined) referenceWrites.set(dtsPath, dts);
contractWrites.set(plan.path, plan.content);
continue;
}
const oldDir = dirname(plan.path);
const newDir = join(dirname(oldDir), plan.newHash);
sourceDirectories.add(oldDir);
if (newDir === oldDir) {
if (dts !== undefined) snapshotRewrites.push([dtsPath, dts]);
snapshotRewrites.push([plan.path, plan.content]);
continue;
}
removals.add(oldDir);
const content = readDirectory(oldDir);
content.set('contract.json', plan.content);
if (dts !== undefined) content.set('contract.d.ts', dts);
const planned = newDirectories.get(newDir);
if (planned !== undefined && !sameFiles(planned, content)) {
stops.push(`${display(newDir)}: snapshot directory already exists with different content`);
continue;
}
newDirectories.set(newDir, content);
directorySources.set(newDir, oldDir);
}
for (const [dir, content] of newDirectories) {
if (sourceDirectories.has(dir) || !existsSync(dir)) continue;
if (sameFiles(readDirectory(dir), content)) newDirectories.delete(dir);
else stops.push(`${display(dir)}: snapshot directory already exists with different content`);
}
for (const path of files.migrationJson) {
const text = readFileSync(path, 'utf8');
const metadata = parseJson(text);
if (!isRecord(metadata)) continue;
const from = metadata['from'];
const to = metadata['to'];
const nextFrom = typeof from === 'string' ? (hashes.get(from) ?? from) : from;
const nextTo = typeof to === 'string' ? (hashes.get(to) ?? to) : to;
if (nextFrom === from && nextTo === to) continue;
const opsPath = join(dirname(path), 'ops.json');
const ops = existsSync(opsPath) ? parseJson(readFileSync(opsPath, 'utf8')) : undefined;
if (existsSync(opsPath) && ops === undefined) {
stops.push(`${display(opsPath)}: not valid JSON`);
continue;
}
const oldMigrationHash = metadata['migrationHash'];
let next = replaceQuotedHashes(text, hashes);
if (
ops !== undefined &&
typeof oldMigrationHash === 'string' &&
nextFrom !== undefined &&
nextTo !== undefined
) {
const migrationHash = computeMigrationHash({ ...metadata, from: nextFrom, to: nextTo }, ops);
next = next.replace(`"${oldMigrationHash}"`, `"${migrationHash}"`);
}
referenceWrites.set(path, next);
}
for (const path of files.refs) {
const text = readFileSync(path, 'utf8');
const next = replaceQuotedHashes(text, hashes);
if (next !== text) referenceWrites.set(path, next);
}
for (const path of files.migrationTs) {
const text = readFileSync(path, 'utf8');
const next = text.replace(
/(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])/g,
(hash: string) => hashes.get(hash) ?? hash,
);
if (next !== text) referenceWrites.set(path, next);
}
if (stops.length > 0) {
process.stderr.write(`${[...new Set(stops)].join('\n')}\n`);
return 1;
}
try {
for (const path of files.leftovers) rmSync(path, { recursive: true, force: true });
for (const [dir, content] of newDirectories)
writeDirectory(dir, content, directorySources.get(dir) ?? dir);
for (const [path, text] of snapshotRewrites) writeFile(path, text);
for (const [path, text] of referenceWrites) writeFile(path, text);
for (const [path, text] of contractWrites) writeFile(path, text);
for (const dir of removals) removeDirectory(dir);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(
`${message}; the upgrade stopped partway, run the script again to finish it\n`,
);
return 1;
}
const rewrittenFiles = snapshotRewrites.length + referenceWrites.size + contractWrites.size;
const lines = [
...notices,
...summary(root, contractsFound, rewrittenFiles, removals.size, hashes),
];
process.stdout.write(`${lines.join('\n')}\n`);
return 0;
}
process.exitCode = main(parseOptions(process.argv.slice(2)));
upgrading/app/upgrades/8.0.0-rc.15-to-8.0.0-rc.16/instructions.md
---
from: "8.0.0-rc.15"
to: "8.0.0-rc.16"
changes:
- id: engine-pin-moves-to-0-6-3
summary: |
The toolchain now requires `@prisma/cli-engine@0.6.3` (up from 0.6.2). A project that pins `@prisma/cli-engine` itself must move the pin to `0.6.3`. The engine's only change is that it accepts any ArkType `^2.2.7`, so it shares one ArkType copy with the Prisma ORM packages.
detection:
glob: "**/package.json"
contains:
- '"@prisma/cli-engine": "0.6.2"'
- id: default-refusals-point-at-the-written-value
summary: |
`@default` reports `PSL_VALUE_TYPE_INCOMPATIBLE` and `PSL_INVALID_LITERAL` at the written value, or at the list element they are about, not at the whole attribute. The `@default` list no longer offers `sql` as an element.
detection:
glob: "**/*.{ts,mts,cts,js,mjs}"
matches:
- '\bPSL_VALUE_TYPE_INCOMPATIBLE\b'
- '\bPSL_INVALID_LITERAL\b'
- 'list of \([^)]*\bsql`\.\.\.`'
- id: default-refusals-say-what-to-write
summary: |
A `@default` refusal from the cast rule starts with what to write instead, as in `Expected a number`, not the list of types the column casts from. It names the column's type and the value's type only when a value of an admitted form is still refused, such as a number too large for the column. `Unknown literal tag` in a `@default` starts with the field it is about, and `this target has no data type for` comes after what to write.
detection:
glob: "**/*.{ts,mts,cts,js,mjs,json}"
matches:
- '; it casts from '
- '(: |[''"`])this target has no data type for a (string|boolean|number) value[''"`]'
- '[''"`]Unknown literal tag '
- id: function-call-arguments-keep-their-diagnostics
summary: |
A call to a function that exactly one alternative of an attribute argument names, such as `@default(uuid(5))`, reports what is wrong with its arguments instead of `Expected one of: …`.
detection:
glob: "**/*.{ts,mts,cts,js,mjs}"
matches:
- 'Expected one of: string \| number \| boolean \| autoincrement\(\)'
- id: contract-artifacts-restamp
summary: |
An extension that writes its own package version into the contracts it emits, such as the
Supabase extension, now writes 8.0.0-rc.16. Run `contract emit` once after upgrading so the
emitted `contract.json` and `contract.d.ts` match the installed extension.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.15"'
---
# 8.0.0-rc.15 → 8.0.0-rc.16 — User upgrade instructions
## `engine-pin-moves-to-0-6-3`
For every `package.json` matched by `detection`, change the `@prisma/cli-engine` version from `0.6.2` to `0.6.3` and reinstall. Projects assembled by the `prisma` CLI resolve the engine automatically.
Engine 0.6.2 required ArkType `2.2.3` exactly, while the Prisma ORM packages accept any `^2.2.2`. A package manager could then install two ArkType copies, and with Bun `prisma contract emit` failed on a valid contract with `CONTRACT.VALIDATION_FAILED`. Engine 0.6.3 accepts `^2.2.7`, so one copy serves both. Nothing in the engine's commands or output changes.
## `default-refusals-point-at-the-written-value`
This entry, `default-refusals-say-what-to-write` and `function-call-arguments-keep-their-diagnostics` matter only to code that reads the location of a PSL diagnostic, or the text of a diagnostic message, such as a test that asserts one. Schemas and contracts do not change.
`PSL_VALUE_TYPE_INCOMPATIBLE` and `PSL_INVALID_LITERAL` from `@default` used to point at the whole `@default(...)` attribute. They now point at the written value. When the message names a list element (`at element 2`), they point at that element. The codes are unchanged; the next section describes the new wording. `PSL_DEFAULT_LIST_EXPECTED` and `PSL_INVALID_DEFAULT_LITERAL` still point at the attribute.
For `tags Int[] @default([1, "x"])`, the diagnostic `Field "Post.tags" at element 2: Expected a number` now spans `"x"`.
When a tagged literal has both an unknown tag and a NUL character or more text than the limit, `@default` now reports the canonicalization code, `PSL_TAGGED_LITERAL_NUL` or `PSL_TAGGED_LITERAL_TOO_LARGE`, where it used to report `PSL_UNKNOWN_LITERAL_TAG`.
This supersedes the last row of the table in `default-diagnostic-codes-changed` in the 8.0.0-rc.14 to 8.0.0-rc.15 guide: a `sql` literal inside a list literal is reported with `PSL_VALUE_TYPE_INCOMPATIBLE` at the element, not at the `@default` attribute.
Update an assertion on the span or range of one of these diagnostics to the written value.
### `sql` is no longer offered as a list element
A `sql` literal is never a list element, so the list arm of `@default` no longer lists it. The editor stops offering `sql` inside `@default([`, and an `Expected one of` message for `@default` ends with `list of (string | number | boolean | json`...`)` instead of `list of (string | number | boolean | sql`...` | json`...`)`. A `sql` literal written inside a list is still refused by the cast rule, as before.
Update an assertion on that message to the new text.
## `default-refusals-say-what-to-write`
`@default` and every other position that takes a value of a data type now word a refusal of the cast rule the same way. A refusal starts with what to write instead, the forms the column's type admits, as in `Expected a number`, not the list of types it casts from. It names the column's type and the value's type only when the value has an admitted form and is still refused, such as a number too large for the column. A quoted string on a column whose type has a tag also gets that string as a tagged literal to write, when the column's type takes it, as the `Jsonb` row shows. An element of a list written on a column whose type has a list cast, such as a vector, is refused with the forms of the list cast's element types. The two other refusals change as the table shows. The codes do not change.
| Written | Message before | Message now |
| --- | --- | --- |
| `count Int @default(100000000000000099)` | `Field "N.count": pg/int4 has no cast from pg/int8; it casts from pg/int2` | `Field "N.count": Expected a number that pg/int4 can hold; got pg/int8` |
| `meta Jsonb @default("{}")` | `Field "N.meta": pg/jsonb has no cast from pg/text; it casts from pg/json` | ``Field "N.meta": Expected json`...`; write json`{}` `` |
| `count Int @default([1])` | `Field "N.count": pg/int4 has no cast from a list; it casts from pg/int2` | `Field "N.count": Expected a number; got a list` |
| `embed pgvector.Vector(3) @default([1, "x", 3])` | `Field "N.embed" at element 2: pgvector/vector has no cast from pg/text; it casts from pg/int2, pg/int4, pg/int8, pg/numeric` | `Field "N.embed" at element 2: Expected a number` |
| `active Int @default(true)` on SQLite | `Field "N.active": this target has no data type for a boolean value` | `Field "N.active": Expected a number; this target has no data type for a boolean value` |
| ``v String @default(pg.sql`x`)`` | `Unknown literal tag "pg.sql". Known tags: sql, json.` | `Field "N.v": Unknown literal tag "pg.sql". Known tags: sql, json.` |
| ``tags String[] @default([sql`'a'`])`` | `Field "Post.tags" at element 1: pg/text has no cast from sql/expression; it casts from nothing` | `Field "Post.tags" at element 1: Expected a quoted string` |
| `enum P { @@type("pg/text@1") Low = 1 }` | `enum "P" member "Low": pg/text has no cast from pg/int2; it casts from nothing` | `enum "P" member "Low": Expected a quoted string` |
| `enum P { Low = 3000000000 }`, an enum without `@@type` | `enum "P" member "Low": pg/int4 has no cast from pg/int8; it casts from pg/int2` | `enum "P" member "Low": Expected a number that pg/int4 can hold; got pg/int8` |
Update an assertion on one of these messages to the new text.
## `function-call-arguments-keep-their-diagnostics`
A call to a default function with wrong arguments used to report `PSL_INVALID_ATTRIBUTE_SYNTAX` with `Expected one of: string | number | boolean | autoincrement() | …` at the whole `@default` value. When exactly one alternative names the called function, the diagnostic now comes from that function, at the argument it is about. The code is still `PSL_INVALID_ATTRIBUTE_SYNTAX`. The same holds for any attribute argument that offers several functions, such as the field functions of a MongoDB `@@index`.
| Written | Message before | Message now |
| --- | --- | --- |
| `@default(uuid(5))` | `Expected one of: string \| number \| …`, at `uuid(5)` | `Expected one of: 4 \| 7`, at `5` |
| `@default(nanoid(1))` | `Expected one of: string \| number \| …`, at `nanoid(1)` | `Expected an integer between 2 and 255`, at `1` |
| `@default(cuid())` | `Expected one of: string \| number \| …`, at `cuid()` | `Attribute "cuid" is missing required argument "version"`, at `cuid()` |
A call to a function no alternative names, such as `@default(other(1))`, still reports `Expected one of: …`. Schemas and contracts do not change. Update an assertion on the old message or span to the new one.
## `contract-artifacts-restamp`
For every `contract.json` matched by `detection`, run the project's emit command (`prisma contract emit`, or the project's `contract:emit` script) once after upgrading. This entry accounts for the extension's embedded `version` moving to `8.0.0-rc.16`; any other difference in the emitted files comes from an earlier entry in this guide.
upgrading/app/upgrades/8.0.0-rc.16-to-8.0.0-rc.17/instructions.md
---
from: "8.0.0-rc.16"
to: "8.0.0-rc.17"
changes:
- id: ts-enum-members-written-as-stored
summary: |
`defineContract` from the Postgres package now refuses a `pg/numeric@1` enum member written with a leading zero or as negative zero, such as "01.5" or "-0", and a `pg/inet@1` member Postgres prints differently, such as "10.0.0.1/32" or "::FFFF:10.0.0.1", with `CONTRACT.ENUM_INVALID`. The message says the text to write. An inet member that is not an address is refused too. Rewrite each refused member, re-emit, and apply a migration that replaces the enum's CHECK constraint.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\benumType\('
- id: psl-enum-members-written-as-stored
summary: |
A PSL enum block is now refused at `contract emit` with `PSL_EXTENSION_INVALID_VALUE` when a member is not written as its codec stores it. On Postgres this covers `@@type("pg/numeric@1")`, `@@type("pg/inet@1")`, `@@type("pg/int8@1")`, `@@type("pg/int8number@1")` and `@@type("pg/unboundedint@1")`, with members such as "01.5", "-0", "10.0.0.1/32", "::FFFF:10.0.0.1", "007", or an inet member that is not an address. On SQLite it covers `@@type("sqlite/bigint@1")`, `@@type("sqlite/bigintnumber@1")`, `@@type("sqlite/integer@1")` and `@@type("sql/int@1")`, with members such as "007" or "-0". The message says the text to write. Rewrite each refused member and re-emit. A numeric or inet enum's CHECK constraint changes, so apply a migration that replaces it; an integer enum's contract is unchanged.
detection:
glob: "**/*.prisma"
matches:
- '@@type\(\s*"pg/(?:numeric|inet|int8|int8number|unboundedint)@1"\s*\)'
- '@@type\(\s*"(?:sqlite/(?:bigint|bigintnumber|integer)|sql/int)@1"\s*\)'
- id: numeric-inet-defaults-stored-normalised
summary: |
A numeric default written with a leading zero or as negative zero in a TypeScript `.default()`, and an inet default written in a form Postgres prints differently, in PSL or in a TypeScript `.default()`, are now stored as Postgres prints them, so emitting the contract again changes its storage hash. An inet default that is not an address is now refused. Earlier versions could not apply most such contracts: the command that applied them failed and changed nothing. Emit the contract again, then run that command again.
detection:
glob: "**/*.{prisma,ts,mts,cts,tsx}"
matches:
- '\bInet\b[^\n]*@default\('
- '@db\.Inet\b[^\n]*@default\(|@default\([^\n]*@db\.Inet\b'
- '\.default\(\s*[''"`]-?0(?:\d|\.0*[''"`]|[''"`])'
- '(?<![\s\S])(?![\s\S]*GENERATED FILE - DO NOT EDIT)[\s\S]*?(?:[''"]pg/inet@1[''"]|\bpgInetColumn\b)'
- id: enum-codecs-refused
summary: |
An enum typed by `pg/timestamp-string@1`, `pg/timestamptz-string@1`, `pg/bytea@1` or `pg/tsquery@1` is now refused: `defineContract` from the Postgres package refuses its `enumType` with `CONTRACT.ENUM_INVALID`, and PSL refuses its `@@type` with `PSL_EXTENSION_INVALID_VALUE`. No value a query reads back can equal a member of such an enum, and a bytea enum column's CHECK constraint refused every member. Type a string timestamp enum with `pg/timestamp-temporal@1` or `pg/timestamptz-temporal@1` and write its members as Temporal values; replace a bytea or tsquery enum with a text enum. On MongoDB, `defineContract` now refuses an `enumType` typed by `mongo/json@1` or `mongo/bson@1` with `CONTRACT.ENUM_INVALID`; type it with `mongo/string@1`.
detection:
glob: "**/*.{prisma,ts,tsx,mts,cts}"
matches:
- '@@type\(\s*"pg/(?:timestamp(?:tz)?-string|bytea|tsquery)@1"\s*\)'
- '(?<![\s\S])(?![\s\S]*GENERATED FILE - DO NOT EDIT)[\s\S]*?(?:[''"]pg/timestamp(?:tz)?-string@1[''"]|\b(?:PG_TIMESTAMP(?:TZ)?_STRING_CODEC_ID|pgTimestamp(?:tz)?StringColumn)\b)'
- '(?<![\s\S])(?![\s\S]*GENERATED FILE - DO NOT EDIT)(?=[\s\S]*\benumType\()[\s\S]*?(?:[''"]pg/(?:bytea|tsquery)@1[''"]|\b(?:PG_BYTEA_CODEC_ID|PG_TSQUERY_CODEC_ID|pgByteaColumn)\b)'
- '(?<![\s\S])(?![\s\S]*GENERATED FILE - DO NOT EDIT)(?=[\s\S]*\benumType\()[\s\S]*?(?:[''"]mongo/(?:json|bson)@1[''"]|\bMONGO_(?:JSON|BSON)_CODEC_ID\b)'
- id: enum-json-codec-refused
summary: |
An enum typed by `pg/json@1` is now refused: `defineContract` refuses its `enumType` with `CONTRACT.ENUM_INVALID`, and PSL refuses its `@@type("pg/json@1")` with `PSL_EXTENSION_INVALID_VALUE`. The `json` type has no equality operator, so a scalar column never applied and a list column's new CHECK constraint refuses every insert. Type the enum with `pg/jsonb@1` and re-emit.
detection:
glob: "**/*.{prisma,ts,tsx,mts,cts}"
matches:
- '@@type\(\s*"pg/json@1"\s*\)'
- '(?<![\s\S])(?![\s\S]*GENERATED FILE - DO NOT EDIT)(?=[\s\S]*\benumType\()[\s\S]*?(?:[''"]pg/json@1[''"]|\b(?:PG_JSON_CODEC_ID|jsonColumn)\b)'
- id: enum-list-check-compares-in-column-type
summary: |
The CHECK constraint on a Postgres list column typed by an enum now compares each element with the members in the column's own type, not as text, so an inet enum list takes a host address such as "127.0.0.1". The constraint's expression and name change, so re-emit the contract and apply a migration that replaces the constraint.
detection:
glob: "**/contract.json"
matches:
- '::(?:text|numeric)\[\](?:, NULL\))? <@ ARRAY\['
- id: integer-text-in-contract-json-refused
summary: |
A `contract.json` value on a SQLite integer codec or on `mongo/int64@1` or `mongo/int64Number@1` that is digit text with a leading zero or a minus sign on zero, such as "007" or "-0", now fails to load with `RUNTIME.DECODE_FAILED`, naming the text to write. `contract emit` never wrote such a value, so only a hand-written or edited `contract.json` is affected. Rewrite the value as the message says, or re-emit the contract.
detection:
glob: "**/contract.json"
matches:
- '(?<![\s\S])(?=[\s\S]*"(?:sqlite/(?:bigint|bigintnumber|integer)|sql/int|mongo/int64(?:Number)?)@1")[\s\S]*"(?:-0|-?0\d+)"'
- id: db-enums-members-hold-read-values
summary: |
`db.enums` now holds each enum member as a query returns it, where it used to hold the member as `contract.json` stores it. A member changes wherever its codec's stored JSON form is not the application value: bigint codecs, codecs that read decimal text as a number, date, time, timestamp and interval codecs, byte codecs, and non-finite float members, written as "NaN", "Infinity" or "-Infinity". `has()`, `nameOf()` and `ordinalOf()` find a value equal to a member, such as a value read from the database, and no longer find the stored form. Remove any conversion your code applied to these members or to values before passing them to `has()`.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\benums\s*(?:\.|\[)'
- id: float-enum-columns-type-non-finite-members-as-number
summary: |
On Postgres, a column typed by a float enum (`pg/float4@1`, `pg/float8@1`, `pg/float@1`) that has a NaN, Infinity or -Infinity member is now typed `number` in `contract.d.ts`, where it was a union of the strings "NaN", "Infinity" and "-Infinity". Re-emit the contract and write those members as numbers.
detection:
glob: "**/contract.json"
matches:
- '"codecId"\s*:\s*"pg/float(?:4|8)?@1"[\s\S]*"(?:NaN|-?Infinity)"'
- id: psl-enum-bson-types-refused
summary: |
A Mongo PSL enum whose codec stores a BSON type that has no JSON value, such as `mongo/int64@1`, `mongo/int64Number@1`, `mongo/date@1`, `mongo/objectId@1`, `mongo/decimal128@1`, `mongo/binary@1` or `mongo/vector@1`, is now refused at `contract emit` with `PSL_EXTENSION_INVALID_VALUE`, and so is a `mongo/double@1` member written as "NaN", "Infinity" or "-Infinity". The collection validator listed such members in their JSON forms and refused every write of those fields. Change the enum to a codec whose BSON type is string, int, double, bool, object or array, or author the enum in a TypeScript contract, which has no collection validator.
detection:
glob: "**/*.prisma"
matches:
- '@@type\(\s*"mongo/(?:int64|int64Number|date|objectId|decimal128|binary|vector)@1"'
- '@@type\(\s*"mongo/double@1"\s*\)[^}]*"(?:NaN|-?Infinity)"'
- id: mongo-orm-takes-enum-accessors
summary: |
`mongoOrm()` and `createMongoCollection()` from `@prisma/orm-mongo/orm` now require the contract's enum accessors, which they check a written enum value against. Build them with `buildMongoEnums(contract, context.codecs)` from `@prisma/orm-mongo/family-runtime` and pass them as `enums`: `mongoOrm({ contract, executor, enums })`, and `createMongoCollection(contract, model, executor, enums, mutationDefaults)`, whose accessors come before the optional `mutationDefaults`. Clients built with `mongo()` need no change.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\b(?:mongoOrm|createMongoCollection)\s*(?:<[^>]*>)?\s*\('
- id: contract-dts-enum-member-types
summary: |
An emitted `contract.d.ts` now gives every namespace that declares enums an `enumMemberTypes` entry, which types each member as `db.enums` holds it. Re-emit the contract. `contract.json`, every hash and migration snapshots are unchanged.
detection:
glob: "**/contract.d.ts"
matches:
- 'readonly enum: \{'
- id: prisma7-schema-states-constraint-names
summary: |
A contract from `prisma7Schema(...)` now states the name of each primary key and foreign key whose Prisma 7 name differs from the name Prisma 8 derives: a `map` on `@id`, `@@id` or `@relation`, the primary key of an implicit many-to-many junction (`_PostToTag_AB_pkey`), and a foreign key name Prisma 7 cut to 63 bytes. A contract with any of these gets a new storage hash. Re-emit, then re-sign, plan a migration, or run `db update`, depending on who manages each database.
detection:
glob: "**/prisma.config.{ts,mts,cts,js,mjs}"
matches:
- '\bprisma7Schema\s*\('
- id: foreign-keys-name-their-backing-index
summary: |
Each foreign key in `contract.json` now states what backs it in a new `index` field: `{ "name": "<index>" }` for an index, `{ "primaryKey": true }` for the primary key, or `{ "unique": ["<column>", …] }` for a unique constraint by its columns, each one whose first columns are its columns, absent for `index: false`. Every SQL contract with a foreign key gets a new storage hash, and so does the Supabase extension's contract space. Re-emit the contract. When `prisma migration plan` then finds nothing to change in the database, follow its advice: write a migration with no operations with `prisma migration new --from <hash>`, or run `prisma db sign` on a database you manage with `prisma db init` or `prisma db update`.
detection:
glob: "**/contract.json"
matches:
- '"foreignKeys"\s*:\s*\[\s*\{'
- id: partial-or-typed-index-no-longer-backs-a-foreign-key
summary: |
A relation used to get no backing index when its table had any index on the same columns, including a partial index (`where:`), an index with a non-default `type` such as `hash` or `gin`, or one with `options`. Such an index does not serve every lookup a foreign key needs, so the relation now gets its own backing index, and `prisma migration plan` creates it. An index with `type: "btree"` and no options or predicate still counts as the same index. To keep the database as it is, point the relation at your index with `index: "<name>"`, or opt out with `index: false`.
detection:
glob: "**/*.{prisma,ts,mts,cts}"
matches:
- '@@index\([^)]*\b(?:where|type|options)\s*:'
- 'constraints\.index\([^)]*\b(?:where|type|options)\s*:'
- id: unnamed-index-on-unique-columns-left-out
summary: |
An `@@index` without `name` or `map`, with no `where`, `options` or non-default `type`, whose columns are exactly those of a unique constraint, a unique index or the primary key, is now left out of the contract, because the unique one already serves its lookups. `prisma migration plan` drops it from the database. To keep it, give it a `name` or `map`; `prisma contract emit` then warns that it duplicates the unique one.
detection:
glob: "**/*.{prisma,ts,mts,cts}"
matches:
- '@@index\(\s*\[[^\]]*\]\s*\)'
- 'constraints\.index\(\s*\[[^\]]*\]\s*\)'
- id: infer-and-print-write-the-relation-index-argument
summary: |
`prisma contract infer` now writes `index: false` on a relation whose only index on its columns is partial, has a non-default type (such as `hash` or `gin`) or has options, where it used to write nothing, and `index: "<name>"` on a relation whose columns lead a named key or a plain index with more columns, where it used to write `index: false`. `prisma contract print` writes `index: "<name>"` on a relation backed by such an index, nothing on a relation backed by its default index or a key, and `index: false` only where nothing backs the foreign key. Re-running either command can change the `@relation` lines it writes; review the diff.
detection:
glob: "**/*.prisma"
matches:
- '@relation\([^)]*\bindex\s*:'
- id: mongo-index-sort-function
summary: Replace MongoDB PSL field sort modifiers with sort(field, direction).
- id: non-data-drops-are-widening
summary: |
Dropping an index, a unique or foreign-key constraint, a check, a row-level-security policy, a default or a native enum type, and disabling row-level security, are now `widening` operations on Postgres and SQLite. They no longer count as data loss, so `prisma db update` and `prisma migration plan` do not ask about them as data loss; `db update` asks about the row-level-security changes as access changes instead. `--confirm` no longer consents to anything; see `db-update-confirm-no-longer-consents` and `migration-plan-refuses-data-loss`.
detection:
glob: "**/*.{sh,bash,zsh,yml,yaml,json,toml,mjs,cjs,js,ts,mts,cts}"
matches:
- '(?<![\w-])--confirm(?![\w-])'
- id: rename-statements
summary: |
`prisma migration plan` and `prisma db update` accept `--rename <old>:<new>`, repeatable, to rename a model or a field instead of dropping and creating its table or column. Each side is `Model`, `namespace.Model`, `Model.field` or `namespace.Model.field`, and a field's model is named as the new contract names it: `--rename Profile:User --rename User.name:User.fullName`.
- id: destructive-means-data-loss
summary: |
An operation is `destructive` only when it can lose rows or values. Postgres `setNotNull` and the type changes that keep every value (`int2` to `int4` to `int8`, `float4` to `float8`), a SQLite table rebuild that only changes nullability, and MongoDB `dropIndex`, `setValidation`, `collMod` and the planner's validator and change-stream image changes are now `widening`. Running an existing `migration.ts` again writes `widening` for these operations in `ops.json`, and a new `migrationHash`.
detection:
glob: "**/migration.ts"
matches:
- '\bsetNotNull\('
- '(?<![\w$.])(?:dropIndex|setValidation|collMod)\('
- id: migration-plan-refuses-data-loss
summary: |
`prisma migration plan` refuses every plan that would lose data, not only an auto-baseline, until each operation that would is answered: `--delete <subject>` lets the data go, `--rename <subject>:<new name>` keeps it. Where nobody can answer it fails with `CLI.CONSENT_REQUIRED`, whose `nextActions` name the flags. `--confirm` answers none of these questions.
detection:
glob: "**/*.{sh,bash,zsh,yml,yaml,json,toml,mjs,cjs,js,ts,mts,cts}"
matches:
- '(?<![\w-])migration\s+plan(?![\w-])'
- id: db-update-confirm-no-longer-consents
summary: |
`prisma db update --confirm <database>` no longer consents to data loss. An apply asks about each operation that would lose data, answered by `--delete <Model|Model.field>` or `--rename`, and about each that would widen who can read or write a model's rows, answered by `--allow <Model>`. Without them it fails with `CLI.CONSENT_REQUIRED` where nobody can answer.
detection:
glob: "**/*.{sh,bash,zsh,yml,yaml,json,toml,mjs,cjs,js,ts,mts,cts}"
matches:
- 'db\s+update(?:[^\n\\]|\\\r?\n|\\.)*(?<![\w-])--confirm(?![\w-])'
- id: db-update-reads-origin-snapshot
summary: |
`prisma db update` reads the snapshot of the contract the database is at on every run, to name what an operation would lose. Nothing to change.
- id: control-api-answer-questions
summary: |
In `@prisma/orm-toolchain/cli/control-api`, `executeMigrationPlanCommand` and `executeDbUpdate`, and the control client's `dbUpdate`, require an `answerQuestions` callback; `consent` and `carryEmittedExtensionDirs` are gone. `delete` and `allow` statements in `statements` answer questions without asking, and one that answers none fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`. `acceptDataLoss: true` no longer answers access-widening questions; `acceptAccessWidening: true` does.
detection:
glob: "**/*.{ts,mts,cts,js,mjs,cjs}"
matches:
- '(?<![\w$])(?:executeMigrationPlanCommand|executeDbUpdate)\s*\('
- '\.dbUpdate\s*\('
- '(?<![\w$])carryEmittedExtensionDirs(?![\w$])'
- id: applied-statement-report-verb
summary: |
`AppliedStatementReport` from `@prisma/orm-toolchain/cli/control-api` is a union discriminated by `verb` (`rename`, `delete` or `allow`), and `StatementVerb` is `'rename' | 'delete' | 'allow'`. Narrow on `verb === 'rename'` before reading a rename's `statement`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\w$])(?:AppliedStatementReport|StatementVerb)(?![\w$])'
- id: consent-errors-removed
summary: |
`ERROR_CODE_DESTRUCTIVE_CHANGES`, `errorDestructiveChanges`, `ERROR_CODE_CONSENT_PLAN_MISMATCH` and `errorConsentPlanMismatch` are removed from `@prisma/orm-framework/errors/execution`; `DbUpdateFailureCode` loses `'DESTRUCTIVE_CHANGES'` and `'CONSENT_PLAN_MISMATCH'`, and `DbUpdateFailure` loses `destructiveChanges` and `consentPlanMismatch`. The codes `MIGRATION.DESTRUCTIVE_CHANGES`, `MIGRATION.CONSENT_PLAN_MISMATCH`, `CLI.CONSENT_TOKEN_UNRESOLVED` and `CLI.CONSENT_OPERATIONS_MISSING` are no longer raised.
detection:
glob: "**/*.{ts,mts,cts,js,mjs,cjs,sh,bash,zsh,yml,yaml,json}"
matches:
- '(?<![\w$])(?:ERROR_CODE_DESTRUCTIVE_CHANGES|errorDestructiveChanges|ERROR_CODE_CONSENT_PLAN_MISMATCH|errorConsentPlanMismatch|destructiveChanges|consentPlanMismatch)(?![\w$])'
- '(?<![\w])(?:DESTRUCTIVE_CHANGES|CONSENT_PLAN_MISMATCH|CONSENT_TOKEN_UNRESOLVED|CONSENT_OPERATIONS_MISSING)(?![\w])'
- id: collection-apply-is-now-with
summary: |
The collection method `apply(fn)` is renamed to `with(fn)`. Rename every call on a collection of the SQL ORM client, such as `db.orm.public.Post.apply(notDeleted)` or `posts.apply((p) => p.limit(10))`, to `.with(...)`. The detection matches `.apply(` only when its first argument is a name followed by `)`, `(` or `=>`, or an arrow function, because `Function.prototype.apply` (`fn.apply(this, args)`) and `Reflect.apply` share the name; it can still match a `Function.prototype.apply` call with one argument. Rename only where the receiver is a collection.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\.apply\s*\(\s*(?:\(|[A-Za-z_$][\w$.]*\s*(?:\)|\(|=>))'
- id: with-is-a-collection-member
summary: |
Every collection now has a `with` method instead of `apply`. A custom collection class that declares its own `with` member with another signature no longer compiles; rename it. An aggregate operation named `with` is refused with `ORM.AGGREGATE_OPERATION_RESERVED`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?:^|\n)[ \t]*(?:(?:public|protected|private|readonly|static|async|override)\s+)*with\s*[(<:=?]'
- id: orm-scope-is-now-fragment
summary: |
The SQL ORM client's `scope` methods are renamed to `fragment`: `db.orm.scope(fields, body)` is now `db.orm.fragment(fields, body)`, and `collection.scope(body)`, such as `db.orm.public.Post.scope(...)`, is now `collection.fragment(body)`. Rename each use whose receiver is the ORM client or a collection, including `typeof db.orm.scope` and `const { scope } = db.orm`. The detection matches every `.scope(` call; leave calls on other objects as they are.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\.scope\s*[(<]'
- '\btypeof\s+[\w$.]*\.scope\b'
- '\{[^}]*\bscope\b[^}]*\}\s*=\s*[\w$.]*\borm\b'
- id: orm-scope-types-are-now-fragment-types
summary: |
The types `Scope`, `FieldScope` and `ScopeFacts` exported by the SQL ORM client (`@prisma/orm-postgres/orm-client` and the other facades' `orm-client` entries) are renamed to `Fragment`, `FieldFragment` and `FragmentFacts`. The SQL builder's own `Scope` and `ScopeField` types are a different thing and keep their names; the detection matches `Scope` only in an import or re-export from an `orm-client` entry, or after a namespace import of one.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\b(?:FieldScope|ScopeFacts)\b'
- '(?:import|export)\s+(?:type\s+)?\{[^}]*\bScope\b[^}]*\}\s*from\s*[''"]@prisma/[\w-]+/orm-client[''"]'
- 'import\s+(?:type\s+)?\*\s+as\s+([\w$]+)\s+from\s*[''"]@prisma/[\w-]+/orm-client[''"][\s\S]*\b\1\.Scope\b'
- id: fragment-is-a-collection-member
summary: |
Every collection now has a `fragment` method instead of `scope`. A custom collection class that declares its own `fragment` member with another signature no longer compiles; rename it. An aggregate operation named `fragment` is refused with `ORM.AGGREGATE_OPERATION_RESERVED`. The name `scope` is free again. The detection matches a member named `fragment` only in a file that extends `Collection`, and an aggregate operation declared as `operation: 'fragment'`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\s\S])(?=[\s\S]*\bextends\s+Collection\b)[\s\S]*\n[ \t]*(?:(?:public|protected|private|readonly|static|async|override|get|set)\s+)*fragment\s*(?:\?\s*)?[(<:=]'
- '\boperation\s*:\s*[''"]fragment[''"]'
- id: namespace-named-fragment-hides-the-client-method
summary: |
A contract namespace named `fragment` now takes the name of the client's `fragment` method, so `db.orm.fragment` is that namespace and the client has no method to make a fragment for any model; code that called `db.orm.scope(fields, body)` with such a contract must make its fragments another way. A namespace named `scope` no longer hides anything.
detection:
glob: "**/*.prisma"
matches:
- '(?:^|\n)[ \t]*namespace\s+fragment\b'
- id: fragment-error-texts
summary: |
Errors and compile errors about these functions say "fragment" where they said "scope", such as `Cannot define the fragment: the body is not a function` and `Pass the fragment to with on a collection: collection.with(fragment).` Update tests that assert on the old text.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'Cannot (?:define|apply) (?:the|a) scope|Pass the scope to with|Run the scope with apply|A scope (?:passed to with|applied with apply)|The scope was (?:made|declared)|the scope could not read the model|declaration in the scope'
- id: orm-collections-lock-rows
summary: "ORM collections gain forUpdate(), forNoKeyUpdate(), forShare() and forKeyShare(), which need the same capability keys as the SQL builder's row-locking methods and also sql.lockOf; a contract emitted before this release does not carry them and the methods are unavailable against it, so re-emit the contract before using them."
detection:
glob: "**/contract.json"
contains:
- '"distinctOn"'
- id: contract-artifacts-restamp
summary: |
An extension that writes its own package version into the contracts it emits, such as the
Supabase extension, now writes 8.0.0-rc.17. Run `contract emit` once after upgrading so the
emitted `contract.json` and `contract.d.ts` match the installed extension.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.16"'
---
# 8.0.0-rc.16 → 8.0.0-rc.17 — User upgrade instructions
## `ts-enum-members-written-as-stored`
A value in a contract now decodes to the value a query returns for it, so where Postgres normalises a value's text, the contract stores the normalised text. Postgres reads `01.5` as a `numeric` and prints `1.5`, and reads `10.0.0.1/32` as an `inet` and prints `10.0.0.1`. An enum member written the first way was stored that way in `contract.json` and in the enum's CHECK constraint, so `db.enums.<namespace>.<Enum>.has(row.value)` was false for every value read back. `defineContract` now refuses such a member and says what to write:
```text
CONTRACT.ENUM_INVALID: enumType("Ratio"): member "Half" is written "01.5", but the column stores "1.5". Write the member as "1.5".
```
Refused members:
- On `pg/numeric@1`, a member with a leading zero (`"01.5"`, `"007"`) or written as negative zero (`"-0"`, `"-0.00"`). Write it without the leading zeros or the minus sign: `"1.5"`, `"7"`, `"0"`, `"0.00"`. Trailing zeros stay, because Postgres keeps them on a `numeric` with no scale.
- On `pg/inet@1`, an IPv4 host address with `/32`, an IPv6 host address with `/128`, IPv6 with an upper-case hex digit, IPv6 with zeros Postgres compresses (`2001:db8:0:0:0:0:0:1` is `2001:db8::1`), or an IPv4-mapped address written in hex (`::ffff:a00:1` is `::ffff:10.0.0.1`). Write it as the message says.
- On `pg/inet@1`, a member that is not an address, refused with `pg/inet@1 JSON value must be an IP address as PostgreSQL writes it`.
1. Run `prisma contract emit`, or run the code that calls `defineContract`. Each refused member is reported with the value to write. The detection for this change looks for calls to `enumType(`; if you import it under another name, search for that name, and look for enums typed by `pg/numeric@1` or `pg/inet@1`.
2. Rewrite each refused member as the message says. Code that reads members through the enum, such as `db.enums.public.Ratio.members.Half`, needs no change. Code that compares a value with the old spelling does.
3. Re-emit. The enum's membership CHECK constraint's expression changes, and its name is derived from its expression, so the storage hash and the constraint's name both change. Plan and apply a migration: it drops the old CHECK constraint and adds the new one. Dropping a CHECK constraint is a `widening` operation, so the plan asks no question about it; see `non-data-drops-are-widening`.
Creating a client from a `contract.json` emitted by an earlier version that still holds such a member fails with `RUNTIME.DECODE_FAILED`, because `db.enums` reads every member through its codec. Re-emit the contract with this version first.
## `psl-enum-members-written-as-stored`
The same rule holds in PSL. An enum block typed by `pg/numeric@1`, `pg/inet@1`, `pg/int8@1`, `pg/int8number@1` or `pg/unboundedint@1` whose member is not written as Postgres prints it is refused at `contract emit`:
```text
PSL_EXTENSION_INVALID_VALUE: enum "Ratio" member "Half" was rejected by codec "pg/numeric@1": pg/numeric@1 JSON value must be "1.5", as PostgreSQL writes this value
```
Rewrite each member as the message says.
- For a numeric or inet enum, the contract held the member as written, so follow steps 2 and 3 of `ts-enum-members-written-as-stored`.
- For an enum typed by `pg/int8@1`, `pg/int8number@1` or `pg/unboundedint@1`, such as a member written `"007"` or `"-0"`, earlier versions stored it as Postgres prints it, `"7"` or `"0"`. Write it that way and re-emit; `contract.json`, the CHECK constraint and every hash are unchanged. The message for these codecs ends `the integer's decimal text without leading zeros or a minus sign on zero`.
On SQLite, the integer codecs store an integer as digit text, and now read only the integer's decimal text: no leading zeros and no minus sign on zero. A PSL enum member written another way is refused:
```text
PSL_EXTENSION_INVALID_VALUE: enum "BigLevel" member "Low" was rejected by codec "sqlite/bigint@1": sqlite/bigint@1 JSON value must be "7", the integer's decimal text without leading zeros or a minus sign on zero
```
Earlier versions stored such a member in that form, so rewrite it as the message says, `"7"` for `"007"` and `"0"` for `"-0"`, and re-emit. `contract.json`, the CHECK constraint and every hash are unchanged.
## `numeric-inet-defaults-stored-normalised`
A default is converted rather than refused, as a uuid default already is:
| Written | Stored |
| --- | --- |
| `.default('01.5')` on `pg/numeric@1` | `"1.5"` |
| `.default('-0')` on `pg/numeric@1` | `"0"` |
| `@default("10.0.0.1/32")` on `Inet`, or `.default('10.0.0.1/32')` on `pg/inet@1` | `"10.0.0.1"` |
| `@default("::FFFF:10.0.0.1")` on `Inet`, or the same `.default()` on `pg/inet@1` | `"::ffff:10.0.0.1"` |
| `@default("not an address")` on `Inet` | refused, `PSL_INVALID_LITERAL` |
A PSL numeric default was already converted: `@default(01.5)` was stored as `"1.5"`.
Earlier versions stored such a default as written, and Postgres stores the converted form, so the check that runs after the change is applied failed: `db init`, `db update` and `db migrate` stopped with `MIGRATION.RUNNER_FAILED` (`MIGRATION.SCHEMA_VERIFY_FAILED`) and rolled the change back. The database has none of the changes that contract adds, and no marker for it. With this version, a `contract.json` that still holds such a default stops `db init`, `db update` and `migration plan` with `CONTRACT.DEFAULT_INVALID`.
Emit the contract again with this version. The stored default changes, and with it the storage hash. Then:
- For a project kept with `db init` or `db update`, run the command that failed again. It applies the contract, and `db verify` then passes.
- For a project with migrations, delete the migration package that never applied: its directory under `migrations/app/`, and its contract snapshot `migrations/snapshots/<hash>/`, where `<hash>` is the `to` hash in the package's `migration.json`. Then run `prisma migration plan` and `prisma db migrate`.
One case did apply: a numeric default with a leading zero or a minus sign on zero, on a column with a precision such as `numeric(10,2)`, because earlier versions compared such a default by value. After you re-emit, the database needs no change but its marker names the old storage hash. Run `prisma db sign` for a project kept with `db init` or `db update`, or plan and apply a migration for a project with migrations.
## `enum-codecs-refused`
An enum compares a value with its members, so a codec whose values read back can never equal a member is now refused when the contract is authored. Four Postgres codecs and two MongoDB codecs are refused this way.
`pg/timestamp-string@1` and `pg/timestamptz-string@1` read a value as the text Postgres prints, such as `2024-01-02 03:04:05` or `2024-01-02 03:04:05+00`, while the contract stores ISO 8601, such as `2024-01-02T03:04:05` or `2024-01-02T03:04:05Z`. The `timestamptz` text also depends on the session's time zone. `defineContract` refuses an `enumType` typed by either codec:
```text
CONTRACT.ENUM_INVALID: enumType("Stamp"): an enum cannot use the codec pg/timestamp-string@1. A query reads each value as the text PostgreSQL prints, such as "2024-01-02 03:04:05", while the contract stores it in ISO 8601, such as "2024-01-02T03:04:05", so no value read back equals a member. Use pg/timestamp-temporal@1, whose members are Temporal values.
```
Type the enum with the Temporal codec of the same column type, `pg/timestamp-temporal@1` or `pg/timestamptz-temporal@1`, and write each member as a `Temporal.PlainDateTime` or a `Temporal.Instant`. `db.enums` then holds Temporal values and finds a value read back. Re-emit the contract; the enum's codec changes, and with it the storage hash. Plan and apply a migration, or run `prisma db sign` for a project kept with `db init` or `db update`.
`pg/bytea@1` stores a value in the contract as base64 text. The enum's CHECK constraint wrote that text as a `bytea` literal, which Postgres reads as the bytes of the text itself, so the constraint refused every member: `db init` applied the column, and every write of a member failed. `pg/tsquery@1` stores a member as written, such as `a & b`, while Postgres prints it normalised, `'a' & 'b'`, so `db.enums` never found a value read back. No enum can use either codec. Replace the enum with a text enum (`pg/text@1`) whose members name the values, and convert at the application boundary.
PSL refuses an enum block typed by any of these codecs with the same reason, as `PSL_EXTENSION_INVALID_VALUE` at its `@@type`; for the string timestamp codecs it used to report the codec as unknown.
On MongoDB, `mongo/json@1` and `mongo/bson@1` declare no equality, so a value read back cannot be compared with a member. Earlier versions accepted an `enumType` typed by either codec with string members; `defineContract` from the MongoDB package now refuses it:
```text
CONTRACT.ENUM_INVALID: enumType("Shape"): an enum cannot use the codec mongo/json@1. The codec does not declare the equality trait, so no value can be compared with a member. Use a codec that declares it.
```
Type the enum with `mongo/string@1` and re-emit the contract. The enum's codec changes, and with it the storage hash, so plan and apply a migration. MongoDB PSL already refused an enum block typed by either codec, because an enum's codec must declare exactly one BSON type, so a PSL contract needs no change.
The TypeScript detection for the bytea, tsquery, `mongo/json@1` and `mongo/bson@1` codecs matches a file that calls `enumType(` and names the codec anywhere, even on a column that is not an enum. Check only the enums.
## `enum-json-codec-refused`
The `json` type has no equality operator, so Postgres cannot compare a `json` value with an enum's members. A scalar column typed by a `pg/json@1` enum never applied: its CHECK constraint failed with `operator does not exist: json = unknown`. A list column applied, but with the CHECK constraint described under `enum-list-check-compares-in-column-type` every insert fails with `could not identify an equality operator for type json`, an empty list included. An enum typed by `pg/json@1` is now refused when the contract is authored:
```text
CONTRACT.ENUM_INVALID: enumType("Payload"): an enum cannot use the codec pg/json@1. The json type has no equality operator, so no CHECK can compare a value with the members. Use pg/jsonb@1, whose type has one.
```
PSL refuses an enum block with `@@type("pg/json@1")` with the same reason, as `PSL_EXTENSION_INVALID_VALUE`.
1. Type the enum with `pg/jsonb@1`: change `@@type("pg/json@1")` to `@@type("pg/jsonb@1")` in PSL, or pass `{ codecId: 'pg/jsonb@1' }` (or `jsonbColumn`) to `enumType` in TypeScript. The members stay as they are.
2. Re-emit the contract. The enum's codec and its columns' type change from `json` to `jsonb`, and with them the storage hash. Plan and apply a migration, which changes each column's type.
The TypeScript detection matches a file that calls `enumType(` and names the json codec anywhere, even on a column that is not an enum. Check only the enums.
## `enum-list-check-compares-in-column-type`
A list column typed by an enum, such as `hosts Host[]` in PSL or `field.namedType(Host).many()` in TypeScript, has a CHECK constraint that every element is a member. Earlier versions cast the column to `text[]` before comparing. 8.0.0-rc.14 wrote:
```sql
"hosts"::text[] <@ ARRAY['127.0.0.1', '10.0.0.0/8']::text[]
```
and 8.0.0-rc.15 wrote:
```sql
array_remove("hosts"::text[], NULL) <@ ARRAY['127.0.0.1', '10.0.0.0/8']::text[]
```
Postgres writes an `inet` value as text with its prefix length, `127.0.0.1/32`, so that constraint refused every host address. The constraint now compares in the column's type:
```sql
array_remove("hosts", NULL) <@ '{"127.0.0.1","10.0.0.0/8"}'
```
1. Re-emit the contract. Every list column typed by an enum gets the new expression, and the constraint's name is derived from its expression, so the name and the storage hash change.
2. Plan and apply a migration: it drops the old CHECK constraint and adds the new one. Dropping a CHECK constraint is a `widening` operation, so the plan asks no question about it; see `non-data-drops-are-widening`.
The detection for this change looks in `contract.json` for the old expression. If you do not keep `contract.json` in the project, look for list fields typed by an enum.
The order of the steps matters when you come from 8.0.0-rc.14, whose upgrade to 8.0.0-rc.15 runs a script and then `prisma db sign`. Run that `db sign` step before you emit the contract with this version. If you already emitted, `db sign` reports the old CHECK constraint as missing:
- In a project with migrations, run `prisma db sign <hash>` with the hash the 8.0.0-rc.15 script printed, then plan and apply a migration as in step 2.
- In a project without migrations, kept with `db init` or `db update`, run `prisma db update`. It drops the old CHECK constraint and adds the new one; `db sign <hash>` fails there, because no migration holds that hash.
The `contract.json` snapshots under `migrations/snapshots/` keep the old expression, so the detection keeps matching them after the upgrade. They record past contracts and need no change.
A numeric enum's CHECK constraint, on a scalar or a list column, compares values as numbers, so the column also takes a value equal to a member but written with another scale, such as `0.50` for the member `0.5`, which reads back as `0.50` and which `db.enums` does not find.
## `integer-text-in-contract-json-refused`
The same rule holds when a contract is loaded. `contract emit` writes these values without leading zeros or a minus sign on zero, so a `contract.json` holds another spelling only when it was written or edited by hand. Loading it fails with `RUNTIME.DECODE_FAILED`, as in `mongo/int64@1 JSON value must be "7", the integer's decimal text without leading zeros or a minus sign on zero`. Rewrite each value the message names, or re-emit the contract from its source.
The detection for this change matches a `contract.json` that names one of these codecs and holds a string such as `"007"` or `"-0"` anywhere, even on another codec. Check only the values typed by these codecs.
## `db-enums-members-hold-read-values`
`db.enums` (`db.enums.<namespace>.<Enum>` on Postgres, `db.enums.<Enum>` on SQLite and Mongo) used to hold each member in the form `contract.json` stores it. Where a codec's stored form is not the value a query returns, a member and a value read from the database never matched:
```ts
// Level is @@type("pg/int8@1") with Low = "1"
db.enums.public.Level.members.Low; // was "1", now 1n
db.enums.public.Level.has(row.level); // was false for row.level === 1n, now true
```
The rule: a member changes when its codec's stored JSON form is not the application value. That covers:
- bigint codecs: `pg/int8@1`, `pg/unboundedint@1`, `sqlite/bigint@1`, `mongo/int64@1` (a bigint, where it was decimal text);
- codecs that read decimal text as a number: `pg/int8number@1`, `sqlite/bigintnumber@1`, `mongo/int64Number@1` (a number, where it was text);
- date, time, timestamp and interval codecs: `pg/timestamptz-date@1`, `pg/date-temporal@1`, `pg/time-temporal@1`, `pg/timestamp-temporal@1`, `pg/timestamptz-temporal@1`, `pg/interval@1`, `sqlite/datetime@1`, `mongo/date@1` (a `Date`, a Temporal value or an interval object, where it was text);
- byte codecs, `sqlite/blob@1` and `mongo/binary@1` (a `Uint8Array`, where it was JSON). An enum can no longer use `pg/bytea@1`; see `enum-codecs-refused`;
- float members written "NaN", "Infinity" or "-Infinity" on `pg/float4@1`, `pg/float8@1`, `pg/float@1` or `mongo/double@1`, and members written "Infinity" or "-Infinity" on `sqlite/real@1`, which refuses NaN (a number, where it was text).
Text, integer, uuid, numeric, boolean and JSON members are unchanged. A mutable member, such as a `Date` or a `Uint8Array`, is a fresh copy on every read, so changing one does not change the enum. An enum's members are decoded when the enum is first read, so a client whose contract has a Temporal enum builds in a runtime without `Temporal` and fails only when that enum is read.
`has()`, `nameOf()` and `ordinalOf()` find a value equal to a member. A string, number or bigint must be the member itself; an object must be of the member's kind and stored as the member is, so a date equal to a member matches although it is a different object. A value of another type, such as `"1"` for an int8 member or an ISO string for a date member, is no member.
1. Find the code that reads `db.enums`. The detection for this change looks for `enums.` and `enums[`; if you hold an enum accessor under another name, search for that name too.
2. For enums whose codec is listed above, remove any conversion that turned a member into the value a query returns, such as `BigInt(db.enums.public.Level.members.Low)` or `new Date(...)` around a member, and any conversion that turned a value back into the stored form before calling `has()`, `nameOf()` or `ordinalOf()`. Pass the value as a query returns it.
3. Code that compared a member with the stored form, for example `members.Low === '1'`, now compares with the value: `members.Low === 1n`. Compare object members by value, for example `members.Launch.getTime() === row.at.getTime()`, not with `===`.
## `float-enum-columns-type-non-finite-members-as-number`
Re-emit the contract. A float enum column that has a NaN or infinite member is typed `number` in `contract.d.ts`, because those members are numbers. Where code wrote the strings `"NaN"`, `"Infinity"` or `"-Infinity"` to such a column, write `NaN`, `Infinity` or `-Infinity`, or the enum's member, such as `db.enums.public.Special.members.Nan`.
## `psl-enum-bson-types-refused`
A Mongo PSL contract derives a collection validator whose `$jsonSchema` lists each enum's members as JSON. A BSON long, date, ObjectId, decimal, binary or vector value is never equal to its JSON form, so the validator refused every write of a field typed by such an enum. `prisma contract emit` now reports the enum at its `@@type` argument, or a double member written as text at the member, instead of emitting a contract no write can satisfy.
For each enum reported, do one of these:
- Store the enum through a codec the validator can list, for example a `mongo/string@1` enum whose members are the values' text, and convert at the application boundary.
- Author the enum and the models that use it in a TypeScript contract (`@internal/mongo/contract-builder`), which carries no collection validator; the ORM checks enum membership on write there.
## `mongo-orm-takes-enum-accessors`
Code that builds the Mongo ORM itself, rather than through `mongo()`, passes the contract's enum accessors, the same ones `db.enums` holds. Built without them, the ORM compared a written enum value with the enum's stored forms and refused every value of an enum whose stored form is not the value, such as a bigint or a date member; it is now a type error.
Before:
```ts
import { createMongoExecutionContext, createMongoRuntime } from '@prisma/orm-mongo/family-runtime';
import { mongoOrm } from '@prisma/orm-mongo/orm';
const context = createMongoExecutionContext({ contract, stack });
const runtime = createMongoRuntime({ context, driver });
const orm = mongoOrm({ contract, executor: runtime });
```
After:
```ts
import {
buildMongoEnums,
createMongoExecutionContext,
createMongoRuntime,
} from '@prisma/orm-mongo/family-runtime';
import { mongoOrm } from '@prisma/orm-mongo/orm';
const context = createMongoExecutionContext({ contract, stack });
const runtime = createMongoRuntime({ context, driver });
const orm = mongoOrm({ contract, executor: runtime, enums: buildMongoEnums(contract, context.codecs) });
```
`buildMongoEnums` refuses a contract whose enum codec the context lacks with `RUNTIME.CODEC_DESCRIPTOR_MISSING`. `createMongoCollection()` takes the accessors as its fourth argument, before the optional `mutationDefaults`: change `createMongoCollection(contract, 'User', executor, mutationDefaults)` to `createMongoCollection(contract, 'User', executor, enums, mutationDefaults)`. `mongoOrm()` refuses `enums` that lack an accessor for an enum the contract declares with `ORM.ARGUMENT_INVALID` when it is called; `createMongoCollection()` refuses a write of an enum field whose accessor is missing from `enums` with the same error.
## `contract-dts-enum-member-types`
Run `prisma contract emit`. Each namespace with enums gains an optional, type-only `enumMemberTypes` entry next to its `enum` entry. The `enum` entry still types each member as `contract.json` stores it; `db.enums` takes its member types from `enumMemberTypes`. Until you re-emit, `db.enums` keeps typing members in their stored form, which no longer matches what it holds.
## `prisma7-schema-states-constraint-names`
For each project whose `prisma.config.ts` uses `prisma7Schema(...)`, run `prisma contract emit`. If the storage hash in the emitted `contract.json` is unchanged, the schema has none of these constraints and nothing else is needed. Otherwise, handle each database the application uses by how it is managed:
1. **Prisma 7 still owns the migrations** (the database was signed before the handover). Run `prisma db sign` against it. Prisma 7 created the database, so it already has the names the contract now states; only the contract changed. Until it is signed, `prisma db verify` reports a mismatch and the application logs a marker warning on its first query; queries still run.
2. **Prisma 8 owns the migrations.** Run `prisma migration plan --name constraint-names` and apply it with `prisma db migrate --advance-ref db`. The migration renames each affected constraint from the name Prisma 8 derived to the name Prisma 7 chose. On a database Prisma 7 built, the constraints already have those names, so each rename is skipped; on a database Prisma 8 built from the migrations, they are renamed.
3. **Prisma 8 created the database with `prisma db init` or `prisma db update`**, as for a test or preview database. Run `prisma db update`. It reads the constraint names from the database and renames each one whose name differs from the contract's, for example `_PostToTag_pkey` to `_PostToTag_AB_pkey`.
## `foreign-keys-name-their-backing-index`
A foreign key in `contract.json` now says what serves its lookups:
```jsonc
// table "Post": a relation on authorId, beside a partial index on the same column
"foreignKeys": [
{ "source": { "columns": ["authorId"], "namespaceId": "public", "tableName": "Post" },
"target": { "columns": ["id"], "namespaceId": "public", "tableName": "User" },
"index": { "name": "Post_authorId_idx_e47547ed" } }
]
// table "Profile": a relation on userId, which is @unique
"foreignKeys": [
{ "source": { "columns": ["userId"], … }, "target": { … }, "index": { "unique": ["userId"] } }
]
```
A relation still gets its own backing index unless it says `index: false`. When the table already declares an identical index, or a unique constraint, unique index or primary key on the same columns, the contract keeps one index and the foreign key names it, or names the key by kind. The field is absent only with `index: false`. A `contract.json` from Prisma Next 0.15, which stored `"index": true` or `false` on each foreign key, still loads; the boolean is read as absent.
1. Run `prisma contract emit`. Every contract with a foreign key gets a new storage hash.
2. Run `prisma migration plan`. If nothing else in this upgrade changes your database, it reports that the contract changed but nothing in the database did. Do what it says:
- If you deploy with migrations, run the `prisma migration new --from <hash>` command it prints, which writes a migration with no operations, then `prisma db migrate`.
- If you manage a database with `prisma db init` or `prisma db update`, run `prisma db sign` against it.
3. If your project composes the Supabase extension, its contract space also gets a new storage hash: run `prisma db sign` against each database signed with the previous one.
`prisma contract emit` now also warns when two indexes of a table that both carry a `name` or `map` are identical (`PN_INDEX_DUPLICATE`), or when a named plain index has the same columns as a unique constraint, unique index or the primary key (`PN_INDEX_REDUNDANT`). The contract keeps both indexes; remove the one you do not need. Two identical indexes both named with `name:` are refused, because the planner could not tell their wire names apart: remove one, or name one with `map:`.
## `partial-or-typed-index-no-longer-backs-a-foreign-key`
Deleting or updating a referenced row looks up the referencing rows by the foreign key columns. A partial index covers only the rows its predicate selects, and an index with another access method or options may not serve that lookup, so the relation now gets a plain backing index beside it. For example:
```prisma
model Post {
id Int @id
authorId Int
archivedAt DateTime?
author User @relation(fields: [authorId], references: [id])
@@index([authorId], where: "\"archivedAt\" IS NULL", name: "post_author_live")
}
```
The next `prisma migration plan` creates `Post_authorId_idx_e47547ed`. If that is what you want, apply it. Otherwise choose one:
- To use your index, name it on the relation. No backing index is derived:
```prisma
author User @relation(fields: [authorId], references: [id], index: "post_author_live")
```
In a TypeScript contract, pass the same name: `rel.belongsTo(User, { from: 'authorId', to: 'id' }).sql({ fk: { index: 'post_author_live' } })` or `constraints.foreignKey(cols.authorId, User.refs.id, { index: 'post_author_live' })`.
- To have no backing index at all, write `index: false` on the relation, or `fk: { index: false }` in TypeScript.
The name is the `name` or `map` you gave the index, unique constraint or primary key, or an index's stored name from `contract.json`. The default name of an index you did not name does not count. `prisma contract emit` refuses a name that the table does not have, that an index and a key share, or whose object's first columns are not the foreign key's columns.
## `unnamed-index-on-unique-columns-left-out`
For example, with `email String @unique`, an `@@index([email])` is left out of the contract, and `prisma migration plan` drops the index from the database. The unique constraint keeps serving the lookups. If you want to keep the index, write `@@index([email], name: "user_email_lookup")`; `prisma contract emit` then warns with `PN_INDEX_REDUNDANT` on each emit.
## `infer-and-print-write-the-relation-index-argument`
Nothing to change in your source. If you re-run `prisma contract infer` or `prisma contract print`, expect `@relation` lines to differ from what an earlier release wrote: `index: false` beside a hash, gin, optioned or partial index, `index: "<name>"` for a composite index or named key whose first columns are the relation's, and `index: "<name>"` or no `index` argument where `contract print` used to write `index: false` on every relation. Emitting either result gives the same contract.
A contract emitted from a Prisma 7 schema (`prisma7Schema(...)`) also states what backs each foreign key where the model's own indexes or keys serve it, such as the unique index of a one-to-one relation or the primary key of an implicit many-to-many junction. Prisma 7 created no other backing index, and the contract still adds none; only the storage hash changes, which `foreign-keys-name-their-backing-index` covers.
## `mongo-index-sort-function`
In MongoDB PSL schemas, replace `field(sort: Asc)` with `sort(field, Asc)` and `field(sort: Desc)` with `sort(field, Desc)` in `@@index`, `@@unique`, and `@@textIndex` field lists. The old syntax is no longer accepted.
For example, change `@@index([createdAt(sort: Desc), authorId])` to `@@index([sort(createdAt, Desc), authorId])`. Keep field order, direction, and the attribute's other arguments unchanged. Use the model field name, not its mapped database name.
Apply this translation to current schema files, PSL schema snapshots stored in migration directories, and documentation examples. When documentation describes the old spelling as a "per-field form", call the new spelling a "function form" instead.
Plain field references and `wildcard()` / `wildcard(field)` remain unchanged. There is no null-ordering option. Do not change SQL schemas, generated contracts, or migration operations: this is a MongoDB PSL syntax change, not a database index change.
## `non-data-drops-are-widening`
A plan that only drops an index, a unique or foreign-key constraint, a check, a default or a native enum type no longer asks anything. A plan that drops a table or a column, or changes a type in a way that can lose data, asks about each subject, and `--confirm` no longer answers it: replace each `--confirm` as `db-update-confirm-no-longer-consents` and `migration-plan-refuses-data-loss` describe. Dropping a row-level-security policy or disabling row-level security is `widening` too, but `db update` still asks about it as an access change, answered with `--allow <Model>`.
## `rename-statements`
When a contract change renames a model or a field and the plan drops and creates its table or column, state the rename instead of editing the migration by hand: `prisma migration plan --rename <old>:<new>` or `prisma db update --rename <old>:<new>`. The planner then renames the table or column, and the constraints and indexes named after it, and keeps the rows. `db update` resolves the old names against the contract snapshot of the database's last update, which it stores when it advances a ref; with `--db <url>`, pass `--advance-ref <name>` on the run before the rename so the snapshot exists. Nothing in an existing project needs to change.
## `destructive-means-data-loss`
The planner and the migration factories now class an operation as `destructive` only when it can lose rows or values. An operation that fails rather than losing a value is `widening`: `SET NOT NULL` fails on a NULL, and a MongoDB validator applies only to later writes. `prisma migration show` marks fewer operations with ⚠, `prisma migration plan` and `prisma db update` print the data-loss warning less often, and `prisma db update` asks for consent less often.
A migration that is already applied needs nothing: `prisma db migrate` applies its `ops.json` as written, and the database ledger records the migration by its hash.
For each migration package that no database has applied yet and whose `migration.ts` calls `setNotNull` (Postgres), or `dropIndex`, `setValidation` or `collMod` without an `operationClass` (MongoDB), run its `migration.ts` again with node from the directory that holds `prisma.config.ts` (`node <path to the package>/migration.ts`) so that `ops.json` records the new class. This rewrites `ops.json` and the `migrationHash` in `migration.json`. Do not run it again for a package a database has applied: its new hash would no longer match the hash the database's ledger recorded for it.
## `migration-plan-refuses-data-loss`
`prisma migration plan` used to ask for consent only before an auto-baseline that would lose data. It now asks about every operation of every plan that would lose data: dropping a table, a column or a collection, or a type change that can change values. It asks one question per model, field or storage name, before it writes anything. A rename on the command line is planned from the start, so the drop it replaces is never asked about.
Detection lists every script and CI file that runs `migration plan`; it cannot tell which of them plan a drop. For each one that runs non-interactively, run it once against a contract change that drops something, or read the failure when it next happens. The failure is `CLI.CONSENT_REQUIRED`; its `meta.unanswered[]` names each subject and the verbs that answer it, and its `nextActions` give the flags, for example `--delete Legacy` or `--rename 'Legacy:<new name>'` (a value with characters a shell would read is single-quoted). Add the flags you mean to that run. A subject no model of the starting contract stores is named by its storage name and can only be deleted. `--rename` is offered only for a model or field the new contract no longer has, so a field whose type changes is answered with `--delete`, and on MongoDB, whose planner refuses renames in this release, only `--delete` is offered; the question then says how to keep the documents by renaming the collection in `mongosh` before a plan that drops it is applied, and that a migration written by `migration plan` still drops it. A field of a model the plan renames is named through the model's new name: `--rename Profile:User --delete User.nickname`. Do not keep a `--confirm <directory>` for this: it answers nothing.
## `db-update-confirm-no-longer-consents`
Detection finds `--confirm` on the same command line as `db update`, including a command continued over several lines with `\`. For each `prisma db update ... --confirm <database>`, replace `--confirm <database>` with one statement per operation it was there to allow:
- `--delete <Model>` or `--delete <Model.field>` for each model or field whose data the update may lose, or `--rename <Model>:<New>` / `--rename <Model.field>:<Model.newField>` if it was renamed.
- `--delete <storage name>` for data no model stores, such as a table or column added by hand; on Postgres the name is schema-qualified (`public.audit_log`).
- `--allow <Model>` before each operation that changes who can read or write that model's rows, such as dropping its row-level-security policy or disabling row-level security; each operation is its own question, so both on one model take `--allow User --allow User`.
To find the subjects, run the same command with `--no-interactive --json` and read `meta.unanswered[]`, or `--dry-run --json` and read `dataLoss` and `accessWidening`. A `--delete` or `--allow` that answers no question fails the run with `CLI.CONSENT_UNUSED`; on `--dry-run`, which asks nothing, it fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, so the exact command you will apply can be previewed with `--dry-run` first.
## `db-update-reads-origin-snapshot`
`prisma db update` now reads the snapshot of the contract the database's marker names on every run, not only when `--rename` is given, so it can name what an operation would lose by model and field. Without a snapshot (a database last updated with `--db <url>` and no `--advance-ref`), each subject is its storage name, and the question says so. The planned operations are the same either way.
## `control-api-answer-questions`
These come from `@prisma/orm-toolchain/cli/control-api`.
- `executeDbUpdate(options)` and the control client's `dbUpdate(options)` require `answerQuestions`, typed `AnswerPlanQuestions` (with `PlanQuestion`, `PlanAnswer` and `PlanQuestionVerb`, all exported from `@prisma/orm-toolchain/cli/control-api`). Before an apply it is called with every question no statement answered, at least once, with an empty list when nothing is in question, and also under `acceptDataLoss: true`. Return one `{ verb, text }` per question, in order, with a verb from `question.verbs` and `question.subject` as the text (or `<subject>:<new name>` for a rename); throw to refuse. A callback that returns too few answers, or an answer its question rejects, throws an `InternalError`. To keep a script that refuses every data loss, write `answerQuestions: async (questions) => { if (questions.length > 0) throw new Error('db update would lose data or widen access'); return []; }`.
- `acceptDataLoss: true` answers every data-loss question, and `acceptAccessWidening: true` every access-widening question. A caller that passed `acceptDataLoss: true` to apply whatever the plan holds passes both.
- `delete` and `allow` statements in `statements` (`{ verb: 'delete', text: 'Legacy' }`) answer their questions without asking. A `delete` answers every loss of its subject; an `allow` answers one operation, in order, so two access changes on one model take two `allow` statements. One that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode.
- `consent` is gone from `executeDbUpdate`'s and `dbUpdate`'s options: the plan-hash binding it carried no longer exists. Remove it.
- `executeMigrationPlanCommand(options)` requires `answerQuestions`, with the same contract, and loses `consent` and `carryEmittedExtensionDirs`. Remove both.
- `DbUpdateSuccess` gains `dataLoss` and `accessWidening`, the operations of the plan that lose data or widen access, by position, each an `AskedSubject` with the subject's `text` as the question writes it. `AskedSubject`, `MigrationSubject`, `MigrationOperationSubject` and `MigrationPlanSubjects` are exported from `@prisma/orm-toolchain/cli/control-api`.
## `applied-statement-report-verb`
`AppliedStatementReport` is now a union on `verb`. A `rename` entry keeps its `statement` as before; a `delete` or `allow` entry's `statement` is `{ kind: 'delete' | 'allow', subject }`, where `subject` is a model, a field, or `{ kind: 'storage', name }`. Code that reads `report.statement.from` or `.to` narrows on `report.verb === 'rename'` first. An exhaustive `switch` over `StatementVerb` gains `case 'delete':` and `case 'allow':`.
## `consent-errors-removed`
Remove imports of `ERROR_CODE_DESTRUCTIVE_CHANGES`, `errorDestructiveChanges`, `ERROR_CODE_CONSENT_PLAN_MISMATCH` and `errorConsentPlanMismatch` from `@prisma/orm-framework/errors/execution`; nothing raises those errors any more. Code that checks a `db update` failure for `code === 'DESTRUCTIVE_CHANGES'` or `'CONSENT_PLAN_MISMATCH'`, or reads `failure.destructiveChanges` or `failure.consentPlanMismatch`, handles `CLI.CONSENT_REQUIRED` from the CLI instead, or, through the control API, the refusal its own `answerQuestions` throws. A script that matches `MIGRATION.DESTRUCTIVE_CHANGES`, `MIGRATION.CONSENT_PLAN_MISMATCH`, `CLI.CONSENT_TOKEN_UNRESOLVED` or `CLI.CONSENT_OPERATIONS_MISSING` in CLI output matches `CLI.CONSENT_REQUIRED` instead.
## `apply` is now `with`
The collection method that runs a function on a collection is now `with`. A pure filter is still written `where(rowFragment)`; `with` runs a query fragment, a function from a collection to a collection, for what `where` cannot express, such as a shared `select` and `include`, an order, a limit or offset, or a variant.
These changes apply to the SQL ORM client only. The MongoDB ORM client did not change; skip matches in code that uses it.
### Rename `apply` to `with`
Rename each call on a collection:
```diff
- const posts = await db.orm.public.Post.apply(notDeleted).apply(postSummary).all();
+ const posts = await db.orm.public.Post.with(notDeleted).with(postSummary).all();
```
The same holds inside an include refinement and inside a fragment's body:
```diff
- db.orm.public.User.include('posts', (posts) => posts.apply(postSummary));
+ db.orm.public.User.include('posts', (posts) => posts.with(postSummary));
```
Do not rename `Function.prototype.apply` or `Reflect.apply`. A call such as `fn.apply(this, args)`, `fragment.apply(undefined, [collection])` or `Reflect.apply(fn, target, args)` calls a function, not a collection; leave it as it is. When a match is unclear, rename it only if its receiver's type is a collection: `db.orm.<namespace>.<Model>`, a chain on one, a custom class that extends `Collection`, or the collection an include refinement or a fragment's body receives.
Also rename `apply` to `with` in comments and documentation that name it as the collection method, such as "run with `apply` on any collection of posts". The next section renames `scope` to `fragment` in the same places.
`with` is a reserved word in JavaScript, but a valid method name. `collection.with(fragment)` works; destructuring it as `const { with } = collection` does not.
The error texts that named `apply` change as well; the final texts are under "Error texts" in the next section.
### `with` is a member of every collection
A custom collection class that declares its own `with` with another signature no longer compiles. Rename that member and its call sites:
```diff
class PostCollection extends Collection<Contract, 'Post'> {
- with(term: string) { return this.where((post) => post.title.ilike(`%${term}%`)); }
+ withTitle(term: string) { return this.where((post) => post.title.ilike(`%${term}%`)); }
}
```
A class that declared its own `apply` because the name was reserved may keep it under that name or rename it back.
An aggregate operation named `with` is now refused with `ORM.AGGREGATE_OPERATION_RESERVED` when the client is built; rename the operation. An aggregate operation may now be named `apply`.
## The `scope` methods and types are renamed to `fragment`
The general term for a function from a collection to a collection, run with `collection.with(fn)`, is **query fragment**, or **fragment** for short. A **scope** is a fragment that only imposes conditions on the query, such as a soft-delete filter. The methods and types that make fragments were named `scope`, although they also make fragments that select, include or order, so they are renamed to `fragment`. What they do has not changed.
These changes apply to the SQL ORM client only. The MongoDB ORM client did not change; skip matches in code that uses it.
### Rename `scope` to `fragment`
Rename the client's method and the collection method:
```diff
- const notDeleted = db.orm.scope(
+ const notDeleted = db.orm.fragment(
{ deletedAt: field.temporal.timestamptz().optional() },
(rows) => rows.where((r) => r.deletedAt.isNull()),
);
- const postSummary = db.orm.public.Post.scope((posts) => posts.select('id', 'title').include('user'));
+ const postSummary = db.orm.public.Post.fragment((posts) => posts.select('id', 'title').include('user'));
```
The same holds on a chained collection, a custom collection class and `this` inside one, and in a type such as `typeof db.orm.scope` or a destructuring such as `const { scope } = db.orm`. Rename a use only when its receiver is the ORM client (`db.orm`, or the client `orm()` returns) or a collection. Leave `.scope(` calls on other objects as they are.
### Rename the types
```diff
- import type { FieldScope, Scope, ScopeFacts } from '@prisma/orm-postgres/orm-client';
+ import type { FieldFragment, Fragment, FragmentFacts } from '@prisma/orm-postgres/orm-client';
```
Rename every use of these types in the file, including a re-export such as `export type { Scope } from '@prisma/orm-postgres/orm-client'` and a qualified name such as `Orm.Scope` after `import * as Orm from '@prisma/orm-postgres/orm-client'`. Do not rename the SQL builder's `Scope` or `ScopeField`, which describe the tables and columns a builder query can see; they are imported from a `builder` entry, not from `orm-client`.
### Rename what you named after scopes
Code that calls a fragment a "scope" still compiles, but rename it to match the new words. Name a module, a variable or a comment for a fragment that selects, includes, orders or limits a fragment, and keep "scope" for a fragment that only imposes conditions on the query. For example, a module `scopes.ts` that holds both a filter and a shared `select` becomes `fragments.ts`, with its imports updated, while a comment that describes a filter in it as a scope stays. Update documentation the same way, such as a README that names `db.orm.scope`, `.scope(...)` or a module you renamed.
### `fragment` is a member of every collection
A custom collection class that declares its own `fragment` with another signature no longer compiles. Rename that member and its call sites:
```diff
class PostCollection extends Collection<Contract, 'Post'> {
- fragment(text: string) { return this.where((post) => post.body.ilike(`%${text}%`)); }
+ containing(text: string) { return this.where((post) => post.body.ilike(`%${text}%`)); }
}
```
A class may now declare a method named `scope`.
An aggregate operation named `fragment` is now refused with `ORM.AGGREGATE_OPERATION_RESERVED` when the client is built; rename the operation. An aggregate operation may now be named `scope`.
### A namespace named `fragment`
A contract namespace takes its name on the client even when the client has a method of that name. If your contract has a namespace named `fragment`, `db.orm.fragment` is that namespace, and the client has no method to make a fragment for any model. If code calls `db.orm.scope(fields, body)` with such a contract, replace each call: a fragment for one model with `collection.fragment(body)` on each model it serves, or a filter with a function of the model accessor (a row fragment) whose parameter is typed with `CodecField`, passed to `where`. A namespace named `scope` no longer hides a method, and `db.orm.scope` still reaches it.
### Error texts
Errors and compile errors about fragments say "fragment" where they said "scope". Before this change, after `apply` was renamed to `with`, they read `Cannot define the scope: the body is not a function`, `Pass the scope to with on a collection: collection.with(scope).` and, for the refused bulk writes, `A scope passed to with can add one without showing it at the call site.` They now read `Cannot define the fragment: the body is not a function`, `Pass the fragment to with on a collection: collection.with(fragment).` and `A fragment passed to with can add one without showing it at the call site.` Every other text that said "the scope" or "a scope" says "the fragment" or "a fragment" in the same place. The compile error for a model that lacks a declared field names the property `the model has no field that matches the declaration in the fragment`. Update tests that assert on the old text.
## `orm-collections-lock-rows`
The ORM client's collections gain four methods that lock the rows a read selects: `forUpdate()`, `forNoKeyUpdate()`, `forShare()` and `forKeyShare()`. Each takes an optional `{ nowait, skipLocked }`, which exclude each other. There is no `of` option, because the ORM always locks only the model's own table. A lock lasts until the transaction ends, so use it inside `db.transaction(...)`:
```ts
await db.transaction(async (tx) => {
const job = await tx.orm.public.Job.where({ state: 'queued' })
.orderBy((j) => j.createdAt.asc())
.forUpdate({ skipLocked: true })
.first();
});
```
A lock cannot be combined with `include`, `groupBy`, `aggregate`, `distinct`, `distinctOn` or a mutation terminal, and a lock method cannot be called inside an `include()` refinement callback. Each of these throws `ORM.LOCK_INCOMPATIBLE`.
Each method needs `sql.forUpdate`, `postgres.forNoKeyUpdate`, `sql.forShare` or `postgres.forKeyShare`, and also `sql.lockOf`, because the ORM always renders `OF` the model's table. The `nowait` and `skipLocked` options need `sql.lockNowait` and `sql.lockSkipLocked`. A `contract.json` emitted before this release carries none of them, so the methods do not exist on its collections. Re-emit your contract to pick up the keys:
```console
prisma contract emit
```
## `contract-artifacts-restamp`
For every `contract.json` matched by `detection`, run the project's emit command (`prisma contract emit`, or the project's `contract:emit` script) once after upgrading. This entry accounts for the extension's embedded `version` moving to `8.0.0-rc.17`; any other difference in the emitted files comes from an earlier entry in this guide.
upgrading/app/upgrades/8.0.0-rc.17-to-8.0.0-rc.18/instructions.md
---
from: "8.0.0-rc.17"
to: "8.0.0-rc.18"
changes:
- id: psl-default-namespace-duplicate-declaration
summary: |
A declaration at the top level of a PSL schema and one inside `namespace public { … }` (Postgres) now share one set of names. The same name declared both ways is `PSL_DUPLICATE_DECLARATION`, and so is a member of `namespace public { … }` named like a `types { }` binding or a namespace. Remove or rename one.
detection:
glob: "**/*.{prisma,psl}"
matches:
- '(^|\n)\s*namespace\s+public\s*\{'
- id: psl-default-namespace-name-is-reserved
summary: |
A top-level model, composite type, block or `types { }` binding cannot be named like the target's default namespace: `public` on Postgres; `unbound` or `__unbound__` on SQLite and MongoDB. This also holds for a schema read with `prisma7Schema(...)` or `prisma6Schema(...)`. Rename the declaration.
detection:
glob: "**/*.{prisma,psl}"
matches:
- '(^|\n)[ \t]*(?!namespace[ \t])[A-Za-z_]\w*[ \t]+(public|unbound|__unbound__)[ \t]*\{'
- '(^|\n)\s*(public|unbound|__unbound__)\s*=\s*\S'
- id: raw-sql-is-a-sql-literal
summary: |
`@@index(where:)`, `@@index(expression:)`, `@@fullTextIndex(where:)`, `@@check(expression:)` and a policy's `using` and `withCheck` take `sql` literals. A quoted string there is refused with `PSL_VALUE_TYPE_INCOMPATIBLE`.
detection:
glob: "**/*.prisma"
matches:
- '\b(where|expression)\s*:\s*["'']'
- '^\s*(using|withCheck)\s*=\s*["'']'
script: ./scripts/sql-expression-literals-psl/rewrite-sql-strings.mjs
- id: psl-storage-hash-may-change-once
summary: |
A raw SQL text with indentation shared by every line, blank lines at the start or end, a whitespace-only line or CRLF line breaks is stored as its canonical text once it is written as a `sql` literal, which changes the contract's storage hash once. For an index or check named with `map:`, `migration plan` then stops with a conflict and asks for a migration written with `migration new`. For a policy named with `@@map`, `migration plan` writes a migration that drops the policy and creates it again.
detection:
glob: "**/*.prisma"
matches:
- '\b(where|expression)\s*:\s*["'']'
- '^\s*(using|withCheck)\s*=\s*["'']'
- id: builder-raw-sql-is-a-sql-value
summary: |
In a TypeScript contract, `index({ where, expression })`, `check({ expression })`, `fullTextIndex({ where })` and the `using` and `withCheck` of `policySelect`, `policyInsert`, `policyUpdate`, `policyDelete` and `policyAll` take a `sql` value. A string there does not compile. Migration files keep strings.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<!readonly |''function'', )\b(where|expression|using|withCheck)\s*:\s*[''"\x60]'
- id: sql-tag-interpolates-sql-values
summary: |
The `sql` tag interpolates other `sql` values: `` sql`${owner} AND deleted_at IS NULL` ``. Compose predicates this way instead of joining strings.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<!\bsql)\x60\$\{[\w.]+\}\s+(AND|OR)\b'
- id: sql-tag-error-codes-renamed
summary: |
`CONTRACT.DEFAULT_SQL_INTERPOLATION` is renamed `CONTRACT.SQL_EXPRESSION_INTERPOLATION`. A NUL character or an oversize text in a `sql` value is `CONTRACT.SQL_EXPRESSION_INVALID`, not `CONTRACT.DEFAULT_INVALID`. `.default()` raises `CONTRACT.DEFAULT_INVALID` for `` sql`now()` ``, `` sql`autoincrement()` `` and unsafe SQL.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bDEFAULT_SQL_INTERPOLATION\b'
- '\bCONTRACT\.DEFAULT_INVALID\b'
- id: ts-storage-hash-may-change-once
summary: |
A raw SQL string with indentation shared by every line, blank lines at the start or end, a whitespace-only line or CRLF line breaks is stored as its canonical text once it is written as a `sql` value, which changes the contract's storage hash once.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<!readonly )\b(where|expression|using|withCheck)\s*:\s*(\x60[ \t]*$|[''"\x60][ \t\r\n])'
- id: infer-notes-defaults-that-do-not-read-back
summary: |
`contract infer` writes a column default whose text a `sql` literal cannot write back unchanged and adds a note to the model; `contract print` refuses such a default with `CONTRACT.PRINT_UNSUPPORTED`.
detection:
glob: "**/contract.prisma"
matches:
- '@default\(\s*sql\x60[^\x60]*$'
- '@default\(\s*sql"[^"]*\\[rn]'
- id: re-emit-contracts-with-a-full-text-index
summary: |
A full-text index declared before this change is stored in `contract.json` in a new form: an index of type `fullText` over its columns whose `options` hold the weight groups and the language, with no `expression`. Its storage hash and its index name change, so the first migration after upgrading changes the index: `migration plan` renames it, and `db update` drops and recreates it. Re-emit the contract and plan a migration.
detection:
glob: "**/*.{prisma,ts,mts}"
matches:
- '@@fullTextIndex\s*\('
- '(?<![\w$.])fullTextIndex\s*\('
- id: full-text-index-one-field-diagnostic-removed
summary: |
`@@fullTextIndex` now takes several fields, so the diagnostic `PSL_FULL_TEXT_INDEX_ONE_FIELD` is gone. Code that asserts it asserts a diagnostic that is never raised.
detection:
glob: "**/*.{ts,mts,cts,js,mjs}"
matches:
- '\bPSL_FULL_TEXT_INDEX_ONE_FIELD\b'
- id: re-emit-contracts-with-polymorphic-models
summary: |
A polymorphic base model's `variants` is stored in a new form in `contract.json` and `contract.d.ts`: keyed by discriminator value, each entry naming the variant model and its namespace. A contract in the previous form is rejected with `CONTRACT.VALIDATION_FAILED`. Re-emit the contract, and rewrite `variants` in the contract snapshots under `migrations/snapshots/`.
detection:
glob: "**/contract*.json"
matches:
- '"variants"\s*:\s*\{\s*"[^"]+"\s*:\s*\{\s*"value"\s*:'
- id: read-variants-by-discriminator-value
summary: |
Code that reads a base model's `variants` from a contract finds the variant model at `variants[<value>].model`, where it used to find the value at `variants[<Model>].value`.
detection:
glob: "**/*.{ts,tsx,mts,cts,js,mjs,cjs}"
matches:
- '(?<![\w$])variants(?![\w$])'
- id: prisma7-ignore-keeps-storage
summary: |
A Prisma 7 schema read with `prisma7Schema()` keeps the columns, tables, keys, indexes and foreign keys of `@ignore` fields and `@@ignore` models in the contract's storage, as storage with no model. Ignored objects now meet every rule of the reader, so a schema that loaded only because they were dropped can be refused. A project whose Prisma 7 schema uses `@ignore` or `@@ignore` gets a new storage hash: it emits its contract again and brings each existing database to the new hash, after recording a migration if Prisma 8 already plans its migrations. The detection matches a `.prisma` file that uses `@ignore` or `@@ignore`.
detection:
glob: "**/*.prisma"
matches:
- '@ignore\b'
- id: mongo-enum-member-written-as-stored
summary: |
A Mongo contract now refuses an enum member that its codec stores as a value it reads back differently, with `CONTRACT.ENUM_INVALID` in TypeScript and `PSL_EXTENSION_INVALID_VALUE` in PSL and Prisma 6 schemas. The built-in Mongo codecs store every enum member as written, so only an enum typed by your own codec can be refused. Write each refused member as the message says, or fix the codec so `decodeJson(encodeJson(value))` gives the value back.
- id: typescript-contract-namespace-keys
summary: |
A contract built with `defineContract` from TypeScript now types `contract.domain.namespaces`, `contract.storage.namespaces` and `db.orm` with the same namespace keys the built contract has at runtime, each holding only its own models and tables. Before, any string key typechecked and every namespace listed every model. Code that reads a namespace the contract does not have, or indexes `domain.namespaces` with a `string` variable, now fails to typecheck. ORM rows of such a contract, which were typed `unknown` field by field, now have each field's codec output type, so a cast to a type that disagrees with the codec type now fails with TS2352. The detection matches dot and bracket access to `domain.namespaces`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\.domain\.namespaces\b'
- '\[\s*[''"]domain[''"]\s*\]\s*\[\s*[''"]namespaces[''"]\s*\]'
- id: model-with-no-fields-has-no-keys
summary: |
A model with no fields now has no keys at the type level: reading a property of its row, such as `row.legacy_key`, fails to compile. In an emitted `contract.d.ts`, the fields and output types of such a model change from `Record<string, never>` to `{}`. In the SQL ORM client, writing a key in the create or update input of such a model, such as `create({ legacy_key: 'x' })`, also fails to compile now; before, both the read and the write compiled, for an emitted contract and for a contract built with `defineContract`. In the Mongo ORM, writes behave as before: an emitted contract already rejected such a key, and the update methods now take the type `UpdateInput` (see the next entry).
- id: generic-update-data-uses-update-input-type
summary: |
Code that is generic over the model name and passes a value typed `Partial<DefaultModelRow<Contract, M>>` to `updateAll`, `updateAndCount` or the `update` of `upsert` no longer compiles. Type the value as `ScalarUpdateInput<Contract, M>` from the SQL ORM client (`orm-client` entry point of the SQL facade, such as `@prisma/orm-postgres/orm-client`), or `UpdateInput<Contract, M>` from the Mongo ORM, instead.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'Partial<\s*DefaultModelRow<'
- id: orm-refuses-names-that-are-not-fields
summary: |
The SQL ORM client refuses any name a caller passes that is not a field of the model, or of the variant the collection is narrowed to, with `ORM.FIELD_UNKNOWN`. That covers `where` shorthand and callbacks, `orderBy`, `select`, `distinct`, `distinctOn`, `groupBy`, `cursor`, aggregates, `having`, create, update and upsert data, `conflictOn`, include nested selects and relation filters. A column name that differs from its field name is no longer accepted, and rows no longer carry a column no field maps. `conflictOn` with a name that is not a field raised `ORM.ARGUMENT_INVALID` and now raises `ORM.FIELD_UNKNOWN`. The detection matches a file that names both `conflictOn` and `ORM.ARGUMENT_INVALID`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\s\S])(?=[\s\S]*\bconflictOn\b)[\s\S]*\bORM\.ARGUMENT_INVALID\b'
- id: fragment-is-made-from-the-root-collection
summary: "The SQL ORM client's collection.fragment(body) refuses a collection with chained calls, such as db.Post.where(...).fragment(body), which used to ignore those calls; chain on the collection directly instead, or define a shared fragment on the model's root collection. A receiver typed by a type parameter, such as this in a custom collection class method, is refused at compile time. The detection matches .fragment( right after a closing parenthesis, as in a chained call, and this.fragment(."
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\)\s*\.fragment\('
- '\bthis\s*\.fragment\('
- id: fragment-types-are-renamed
summary: |
The SQL ORM client's types `Fragment` and `FieldFragment`, which 8.0.0-rc.17 exports, are renamed to `QueryFragment` and `DeclaredFieldsFragment`. Code still on the 8.0.0-rc.16 names renames `Scope` to `QueryFragment`, `FieldScope` to `DeclaredFieldsFragment` and `ScopeFacts` to `FragmentFacts` directly. `FragmentFacts`, `DeclaredField`, `db.orm.fragment` and `collection.fragment` keep their names. The detection matches `Fragment` and `Scope` only in an import or re-export from an `orm-client` entry, after a namespace import of one, or in an inline `import(...)` type of one, so React's `Fragment` and the SQL builder's `Scope` do not match.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\b(?:FieldFragment|FieldScope|ScopeFacts)\b'
- '(?:import|export)\s+(?:type\s+)?\{[^}]*\b(?:Fragment|Scope)\b[^}]*\}\s*from\s*[''"]@prisma/[\w-]+/orm-client[''"]'
- 'import\s+(?:type\s+)?\*\s+as\s+([\w$]+)\s+from\s*[''"]@prisma/[\w-]+/orm-client[''"][\s\S]*\b\1\.(?:Fragment|Scope)\b'
- 'import\(\s*[''"]@prisma/[\w-]+/orm-client[''"]\s*\)\.(?:Fragment|FieldFragment|Scope|FieldScope)\b'
- id: unique-criterion-rejects-null
summary: |
`UniqueConstraintCriterion` in the SQL ORM client (`@prisma/orm-postgres/orm-client` and the other facades' `orm-client` entries) no longer accepts `null` for a nullable unique column, so the `conflictOn` of `upsert` and the criteria of a relation `connect` or `disconnect` stop compiling when a value is `null` or may be `null`. Pass a non-null value, or handle the null case before the call. The detection matches every `conflictOn`, `.connect(` and `.disconnect(`; leave as they are the calls on other objects, such as `postgres.connect(...)`, and the `conflictOn` of `createAll` and `createAndCount`, which is an array of field names.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\bconflictOn\b|\.(?:connect|disconnect)\s*\(|\bUniqueConstraintCriterion\b'
- id: prepared-all-and-aggregate-need-their-receiver
summary: |
On every collection of the SQL ORM client, `prepared.all` and `prepared.aggregate` no longer compile when called without the `prepared` object as their receiver, such as after `const { all } = collection.prepared`. Call them on the `prepared` object. `prepared.first` and `prepared.firstOrThrow` are not affected, and nothing changes at run time.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\{[^}]*\b(?:all|aggregate)\b[^}]*\}\s*=\s*[^;\n]*\.prepared\b'
- id: native-enums-leave-out-domain-enums
summary: |
On Postgres, `db.nativeEnums` now holds only native enum types (`native_enum` blocks and `nativeEnum()`). It used to also hold every domain enum (`enum` blocks and `enumType()`), with its values as stored, such as `['1', '10']` for an enum over `pg/int8`. Read a domain enum through `db.enums`, which holds its values as a query returns them.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\.nativeEnums\b'
- id: middleware-hooks-are-activity-hooks
summary: |
The per-statement middleware hooks are replaced. `beforeQuery` and `beforeExecute` become `beforeEncode`; `interceptQuery` and `interceptExecute` become `intercept.onQuery` and `intercept.onExecute`; `afterQuery` and `afterExecute` become `activityEnded` on a statement; `afterTransaction` becomes `activityFinalized` on a statement. `onRow` and `beforeCompile` are unchanged.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\b(beforeQuery|beforeExecute|interceptQuery|interceptExecute|afterQuery|afterExecute|afterTransaction)\b'
- id: middleware-context-is-activity-context
summary: |
`RuntimeMiddlewareContext` is renamed `ActivityContext`. It gains `parent`, `state(key)` and `inTransaction()`, and loses `planExecutionId`; use `activity.id` instead.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bRuntimeMiddlewareContext\b'
- '\bplanExecutionId\b'
- id: after-result-types-removed
summary: |
`AfterQueryResult`, `AfterExecuteResult` and `AfterTransactionResult` are removed. Use `ActivityEndedResult` (narrow it with `result.kind === 'statement'` and `result.returns`) and `ActivityFinalizedResult`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bAfter(Query|Execute|Transaction)Result\b'
- id: aborted-phase-before-encode
summary: |
A `RUNTIME.ABORTED` error raised during the middleware stage before encoding reports `details.phase: 'beforeEncode'`, not `'beforeQuery'` or `'beforeExecute'`. The phases `'afterQuery'` and `'afterExecute'` no longer occur. Mongo's `RUNTIME.CONTENT_HASH_REQUIRES_RESOLVED_COMMAND` also reports `details.phase: 'beforeEncode'`, not `'beforeExecute'`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '[''"`](beforeQuery|beforeExecute|afterQuery|afterExecute)[''"`]'
- id: every-started-statement-is-ended-and-finalized
summary: |
Every statement the runtime accepts now starts an activity, and every started statement ends and is finalized exactly once. A call with an already-aborted signal fires `activityStarted` before it fails. A statement that fails before it runs, in `beforeCompile`, `beforeEncode` or parameter encoding, now ends `failed` and is finalized, where it got no `afterTransaction` before. `activityEnded` also fires for a statement the caller stopped reading (outcome `'stopped'`) and for a statement that failed before it ran (outcome `'failed'`, `latencyMs: 0`, `source: 'driver'`); `afterQuery` and `afterExecute` fired for neither, so a metrics or slow-query middleware now counts them unless it checks `result.outcome`. When a middleware's `activityStarted` throws, the middleware after it receive no `activityEnded` or `activityFinalized` for that activity. A statement still running when its transaction ends, such as a row stream still being read, is finalized when it ends, with the transaction's outcome, and keeps its `ctx.state` until then.
- id: observed-row-budget-runs
summary: |
The `budgets()` middleware now counts the rows a query returns and enforces `maxRows` on them. Before, the observed-row check never ran. With the default row severity `'error'`, or in `strict` mode, a query that returns more than `maxRows` rows fails with `BUDGET.ROWS_EXCEEDED`; with `'warn'` in `permissive` mode it logs one warning for the statement and keeps streaming.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bbudgets\('
- id: lossy-type-changes-ask
summary: |
On Postgres and SQLite, `migration plan` and `db update` no longer write a type change that can lose values without asking. A script that runs them without a terminal fails with `CLI.CONSENT_REQUIRED` until it passes `--convert <Model.field>` (`migration plan` only: write the conversion into the slot) or `--delete <Model.field>` (the database's own cast runs: it may cut or round values, such as a longer text cut to a shorter `VarChar` on Postgres, and a value it cannot cast fails the apply).
- id: placeholders-only-when-asked
summary: |
`migration plan` writes `placeholder(...)` slots on Postgres and SQLite only for a field that `--convert` or `--backfill` names. A new required field now gets a temporary value in the existing rows (`''`, `0`, …), which the output names, and an optional field made required is tightened directly. Pass `--backfill <Model.field>` to get the backfill step to fill instead.
- id: required-field-failures-count-rows
summary: |
A migration that makes a field required while rows have no value for it fails with `MIGRATION.RUNNER_FAILED`, whose summary counts the rows and whose advice names the fix. Scripts that matched the old summary text (`… failed during precheck: ensure no NULL values in …`) must match the new one, or the `rowsWithoutValue` failure field.
- id: postgres-alter-column-type-plain-options
summary: |
`this.alterColumnType(...)` in a Postgres `migration.ts` now takes `{ schema, table, column, type, using?, operationClass? }`. The `options` object with `qualifiedTargetType`, `formatTypeExpected`, `rawTargetTypeForLabel` and `using` is gone. Move `options.qualifiedTargetType` to `type` and `options.using` to `using`, and drop the other two.
detection:
glob: "**/migration.ts"
matches:
- '\bqualifiedTargetType\s*:'
- id: mongo-rename-statements-carried-out
summary: |
On MongoDB, `migration plan` and `db update` carry out `--rename`: a model rename renames its collection, keeping the documents, indexes and validator, and a field rename renames the field in every document that holds it. They used to refuse every statement with `MIGRATION.PLANNING_FAILED`. A question about a dropped collection now offers `--rename` as well as `--delete`.
- id: mongo-removed-field-asks
summary: |
On MongoDB, removing a field from a model is now data loss. `migration plan` and `db update` ask about it, and fail with `CLI.CONSENT_REQUIRED` where nobody can answer, until `--delete <Model>.<field>` or a `--rename` answers it. `--delete` removes the field from every document that holds it.
- id: mongo-preview-get-collection
summary: |
A MongoDB plan's preview writes each collection as `db.getCollection("users")`, for example `db.getCollection("users").createIndex(...)` where it wrote `db.users.createIndex(...)`. `ops.json` does not change.
detection:
glob: "**/*.{ts,mts,cts,js,mjs,cjs,snap}"
matches:
- 'db\.[A-Za-z_][\w]*\.(?:createIndex|dropIndex|drop)\('
- id: mongo-time-series-update
summary: |
`db update` on a database with a MongoDB time-series collection no longer fails with "Cannot change immutable collection option 'timeseries'" when nothing changed.
- id: storage-question-wording
summary: |
A data-loss question about data named by its storage name, because the origin contract is unknown, ends with `--delete loses that data` instead of `--delete loses its rows`.
detection:
glob: "**/*.{ts,mts,cts,js,mjs,cjs}"
matches:
- '--delete loses its rows'
- id: migration-ts-finds-its-config
summary: |
Running a `migration.ts` with node and no `--config` uses the config that owns the migration, whose migrations directory holds the file: the nearest `prisma.config.ts` above the migration's directory, else the one in the working directory. If neither owns it, it fails with `CLI.MIGRATION_CONFIG_NOT_FOUND`. It used to load only the working directory's config, whichever project it belonged to.
- id: contract-artifacts-restamp
summary: |
An extension that writes its own package version into the contracts it emits, such as the
Supabase extension, now writes 8.0.0-rc.18. Run `contract emit` once after upgrading so the
emitted `contract.json` and `contract.d.ts` match the installed extension.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.17"'
---
# 8.0.0-rc.17 → 8.0.0-rc.18 — User upgrade instructions
## Top-level declarations belong to the default namespace
A declaration written at the top level of a schema is a member of the target's default namespace: `public` on Postgres, the late-bound namespace (`unbound`, contract id `__unbound__`) on SQLite and MongoDB. Emitted contracts do not change.
### `psl-default-namespace-duplicate-declaration`
```prisma
model Account {
id Int @id
}
namespace public {
model Account {
id Int @id
}
}
```
This is `PSL_DUPLICATE_DECLARATION: Duplicate declaration of "Account"` on the second declaration. Remove one or rename it. The same holds for a member of `namespace public { … }` named like a `types { }` binding, like another namespace, or `public`.
### `psl-default-namespace-name-is-reserved`
A top-level declaration named `public` (Postgres), or `unbound` or `__unbound__` (SQLite, MongoDB), is rejected, even when the schema has the name once, because the default namespace already has it:
```text
PSL_DUPLICATE_DECLARATION: Duplicate declaration of "public"
```
On Postgres, `unbound` and `__unbound__` are rejected only when the schema also has a `namespace unbound { … }` or `namespace __unbound__ { … }` block. Rename the declaration; on a model, `@@map("public")` keeps the table or collection name. A schema read with `prisma7Schema(...)` or `prisma6Schema(...)` is the file the Prisma 7 or Prisma 6 client reads, so the rename applies there too.
## Raw SQL in PSL is a `sql` literal
### Every place that holds raw SQL takes a `sql` literal
Raw SQL in a PSL schema is written as a `sql` literal, the form `@default` already uses. A quoted string is refused in these places:
| Place | Before | After |
| --- | --- | --- |
| `@@index(where:)` | `@@index([email], where: "(archived_at IS NULL)", name: "users_email_active")` | ``@@index([email], where: sql`(archived_at IS NULL)`, name: "users_email_active")`` |
| `@@index(expression:)` | `@@index(expression: "lower(email)", name: "users_email_lower")` | ``@@index(expression: sql`lower(email)`, name: "users_email_lower")`` |
| `@@fullTextIndex(where:)` | `@@fullTextIndex([text], where: "archived_at IS NULL", name: "message_text_search_live")` | ``@@fullTextIndex([text], where: sql`archived_at IS NULL`, name: "message_text_search_live")`` |
| `@@check(expression:)` | `@@check(expression: "total > 0", name: "order_total_positive")` | ``@@check(expression: sql`total > 0`, name: "order_total_positive")`` |
| a policy's `using` | `using = "\"userId\"::uuid = auth.uid()"` | ``using = sql`"userId"::uuid = auth.uid()` `` |
| a policy's `withCheck` | `withCheck = "\"userId\"::uuid = auth.uid()"` | ``withCheck = sql`"userId"::uuid = auth.uid()` `` |
Inside a `sql` literal no quote is escaped: `\"` becomes `"`. A literal may span lines.
Run the script from the project root to rewrite every `.prisma` file:
```sh
node <skill>/upgrading/app/upgrades/8.0.0-rc.17-to-8.0.0-rc.18/scripts/sql-expression-literals-psl/rewrite-sql-strings.mjs '**/*.prisma'
```
It finds each quoted string in those places, outside `//` and `///` comments, decodes its escapes, and writes the same text as a `sql` literal. A text that holds a backtick is written in the double-quote form, `sql"..."`. A text that spans lines is written with the text on its own lines. The script prints each changed file and its number of rewrites, and running it twice changes nothing. Check the diff by hand.
A place the script did not rewrite, for example a string the script cannot read, is refused when the schema is emitted:
```text
PSL_VALUE_TYPE_INCOMPATIBLE: Expected sql`...`; write sql`(archived_at IS NULL)`
```
The message ends with the literal to write. When the string's text would read back from a `sql` literal as different text (it has indentation shared by every line, blank lines at the start or end, a whitespace-only line, or a carriage return), the message is ``Expected sql`...` `` alone; write the SQL in a `sql` literal, and see the next section. A number, `true` or `false` is refused the same way, an identifier is `PSL_INVALID_ATTRIBUTE_SYNTAX` (``Expected sql`...`; got an identifier``), and `pg.sql` is `PSL_UNKNOWN_LITERAL_TAG`.
`contract infer` now writes these places as `sql` literals. When an index's SQL would read back from a `sql` literal as different text and the index has a Prisma-generated name, `contract infer` writes the canonical text, which keeps the same name. It skips an index, check or policy named with `map:` whose SQL would read back as different text, and writes a comment in its place, such as `// prisma: skipped index "users_email_active": its SQL cannot be written as a sql literal that reads back unchanged. It is not in this schema, so migration plan will drop it. A sql literal written by hand holds different text, so migration plan then stops with a conflict for an index or check, or drops and recreates a policy. Either change the SQL in the database to the text of the literal, or add the object without map: or @@map so Prisma names it.` Such an object is still in the database. If you run `migration plan` without adding it to the schema, the plan drops it; for a policy, that removes a row-level security rule. Adding it by hand as written does not fix this: a `sql` literal holds canonical text, which is not the text in the database, so `migration plan` stops with a conflict for an index or check, and drops and recreates a policy. Do one of these instead:
- Change the SQL in the database to the canonical text, for example with a migration written with `migration new`, then add the object with that text.
- Add the object without `map:` (for a policy, without `@@map`) so Prisma names it. `migration plan` then drops the old object and creates it again under its new name, once.
`contract print` refuses such an object with `CONTRACT.PRINT_UNSUPPORTED`; write its SQL in the canonical form in the contract's source.
This supersedes the PSL `@@index(expression: "to_tsvector(…)", type: "gin", …)` example of the `postgres-full-text-search` app instructions in the upgrade from 8.0.0-rc.11 to 8.0.0-rc.12: write it as ``@@index(expression: sql`to_tsvector(…)`, type: "gin", …)``.
### `psl-storage-hash-may-change-once`
A `sql` literal stores its canonical text: indentation shared by every line, blank lines at the start and end, whitespace-only lines and carriage returns are removed. Every blank line at the start and end is now removed, not only the first and the last, so a `sql` literal in PSL or a `sql` template in TypeScript that starts or ends with two or more blank lines also stores a different text once. A quoted string whose text had any of these is stored differently once it is rewritten, so the contract's storage hash changes once. Prisma-generated index, check and policy names do not change, because their hash is computed from text with its whitespace collapsed.
After emitting the rewritten schema, run `prisma migration plan` once and commit the migration it writes. For objects with Prisma-generated names that migration has no operations; it records the new storage hash. For an index or check named with `map:` whose text changed, `migration plan` stops with a conflict for that object and says to write a custom migration with `migration new`. The database needs no change, so write that migration with no operations. For a policy named with `@@map` whose text changed, `migration plan` writes a migration that drops the policy and creates it again with the canonical text. A schema whose texts had none of these forms emits the same contract as before, and `migration plan` reports no change.
## Raw SQL in a TypeScript contract is a `sql` value
### `builder-raw-sql-is-a-sql-value`
Every field of the TypeScript contract builder that holds raw SQL takes a `sql` value, as `.default()` already did. Wrap each string in the `sql` tag, imported from the same contract-builder module as the other helpers:
| Place | Before | After |
| --- | --- | --- |
| `index` `where` | `constraints.index([cols.email], { where: '(archived_at IS NULL)', name: 'users_email_active' })` | ``constraints.index([cols.email], { where: sql`(archived_at IS NULL)`, name: 'users_email_active' })`` |
| `index` `expression` | `constraints.index({ expression: 'lower(email)', name: 'users_email_lower' })` | ``constraints.index({ expression: sql`lower(email)`, name: 'users_email_lower' })`` |
| `fullTextIndex` `where` | `fullTextIndex(cols.text, { where: 'archived_at IS NULL', name: 'message_text_search_live' })` | ``fullTextIndex(cols.text, { where: sql`archived_at IS NULL`, name: 'message_text_search_live' })`` |
| `check` `expression` | `check({ expression: 'total > 0', name: 'order_total_positive' })` | ``check({ expression: sql`total > 0`, name: 'order_total_positive' })`` |
| a policy's `using` and `withCheck` | `policySelect(Profile, { name: 'profile_owner_read', roles: [authenticated], using: '"userId"::uuid = auth.uid()' })` | ``policySelect(Profile, { name: 'profile_owner_read', roles: [authenticated], using: sql`"userId"::uuid = auth.uid()` })`` |
```ts
import { check, policySelect, sql } from '@prisma/orm-postgres/contract-builder';
```
Copy the string's value, not its source: undo the TypeScript string's own escaping first, so `'"userId"'` and `"\"userId\""` both become `` sql`"userId"` ``. Inside the template, write each backtick as `` \` ``, each `${` as `\${`, and a backslash that precedes a backtick, a dollar sign or another backslash as `\\`; every other backslash is kept as written, so `E'\n'` stays `E'\n'`. An index or check object written by hand in `.sql({ indexes, checks })` takes a `sql` value in the same fields. A shared predicate held in a `const` becomes a `sql` value too: `` const owner = sql`"userId"::uuid = auth.uid()`; ``.
Skip files under a `migrations/` folder and leave them as they are: migration functions take a string or a `sql` value, and a string keeps its exact text, while wrapping it in `sql` canonicalizes it, which can change the SQL that migration runs.
The string form is a type error. JavaScript that is not type-checked and passes a string fails when the contract is built, with `CONTRACT.ARGUMENT_INVALID`, for example ``Index "users_email_active" where must be a sql`...` value.`` The message names the index, check or policy; an index or check with no `name` or `map` is named by its model, as in `Index on "User" where`. `.default('draft')` is not raw SQL: a string there is still a literal default. The `{ fields, render }` form of an index expression still returns a string from `render`, because that text is generated by code; lowering canonicalizes that text as it canonicalizes a `sql` value.
This supersedes the TypeScript `fullTextIndex(cols.text, { where: 'archived_at IS NULL', … })` example of the `postgres-full-text-search` app instructions in the upgrade from 8.0.0-rc.11 to 8.0.0-rc.12: write it as ``fullTextIndex(cols.text, { where: sql`archived_at IS NULL`, … })``.
### `sql-tag-interpolates-sql-values`
The `sql` tag accepts other `sql` values inside `${…}`. It joins their text into the template and canonicalizes the whole text once. A predicate that was built by joining strings is written as one `sql` template:
```ts
const owner = sql`owner_id = current_setting('app.uid')::int`;
const ownerAlive = sql`${owner} AND deleted_at IS NULL`;
```
Anything else inside `${…}`, such as a string or a number, is a type error, and throws `CONTRACT.SQL_EXPRESSION_INTERPOLATION` from JavaScript that is not type-checked. Prisma never quotes a value into SQL, so write any other text inside the template. Each line of an interpolated value after its first takes the indentation of the template line the `${…}` sits on, so a multi-line value inside an indented template stores the same text as the same SQL written out in one template.
### `sql-tag-error-codes-renamed`
- `CONTRACT.DEFAULT_SQL_INTERPOLATION` is now `CONTRACT.SQL_EXPRESSION_INTERPOLATION`, and its message is ``sql`...` only interpolates other sql`...` values; write any other text inside the template.``
- A `sql` value whose text holds a NUL character or is larger than 65536 bytes throws `CONTRACT.SQL_EXPRESSION_INVALID` when it is made. It was `CONTRACT.DEFAULT_INVALID`.
- `` sql`now()` ``, `` sql`autoincrement()` `` and unsafe SQL (`;`, a comment token, `$$` or `SELECT`) are no longer refused by the tag. `.default()` refuses them with `CONTRACT.DEFAULT_INVALID` and the same messages, so `` const current = sql`now()` `` compiles and runs, and `.default(current)` throws. In every other place such text is passed to the database unchanged.
Update code that matches on these codes. Code that catches `CONTRACT.DEFAULT_INVALID` around a `sql` template that is not passed to `.default()` no longer sees it.
### `ts-storage-hash-may-change-once`
A `sql` value stores its canonical text, as a PSL `sql` literal does: indentation shared by every line, blank lines at the start and end, whitespace-only lines and carriage returns are removed. A raw SQL string whose text had any of these, for example a multi-line template string, is stored differently once it is written as a `sql` value, so the contract's storage hash changes once. Prisma-generated index, check and policy names do not change. Skip files under a `migrations/` folder and leave them as they are: migration functions take a string or a `sql` value, and a string keeps its exact text, while wrapping it in `sql` canonicalizes it, which can change the SQL that migration runs. Follow the steps of `psl-storage-hash-may-change-once` in this guide: run `prisma migration plan` once and commit the migration it writes. For an index or check named with `map:` whose text changed, `migration plan` stops with a conflict and asks for a migration written with `migration new`, which needs no operations. The TypeScript policy helpers take no exact name, so a policy never needs this. A `{ fields, render }` index expression whose `render` returns text that is not canonical, such as indented multi-line text, also stores different text once, with the same steps; `fullTextIndex` stores its fields and language as data, renders no SQL, and is not affected.
### `infer-notes-defaults-that-do-not-read-back`
A column default whose text a `sql` literal cannot write back unchanged, for example a string constant holding a carriage return or a whitespace-only line, is treated differently by the two commands that write PSL, for the reason ADR 268 gives ("Column defaults that do not read back"):
- `contract infer` still writes the default as a `sql` literal and adds the comment `// prisma: default of "<column>" holds text a sql literal cannot write back unchanged; check its string constants before applying a migration` to the model. The literal reads back as a different constant, so a database created from the inferred schema gets that constant. Check the constant, and fix it in the schema or in the database before you apply a migration planned from it.
- `contract print` refuses the default with `CONTRACT.PRINT_UNSUPPORTED` (``default of column "public"."post"."slug" holds SQL that a sql literal cannot write back unchanged``). Write the default in its canonical form in the contract's source. A contract holds such a default only when it was built before this release, or through `.default({ kind: 'function', expression })` or `.defaultSql('...')`.
This applies to projects that write only PSL as well as TypeScript projects.
## A full-text index is stored as data
### `re-emit-contracts-with-a-full-text-index`
`@@fullTextIndex` and the TypeScript `fullTextIndex` helper used to store a SQL expression in the contract. They now store the index's definition as an index of type `fullText`, and the SQL is rendered from it when the index is created and when a query searches it. The database index is still a `gin` index over the same expression. For `@@fullTextIndex([title], name: "post_title_search")` the emitted index changes like this:
```diff
{
- "expression": "to_tsvector('english', \"title\")",
- "name": "post_title_search_724b05e5",
+ "columns": ["title"],
+ "name": "post_title_search_e0dd1131",
+ "options": { "language": "english", "weightGroups": [["title"]] },
"prefix": "post_title_search",
- "type": "gin",
+ "type": "fullText",
"unique": false
}
```
The schema source does not change. The index's name ends in a hash of its contract entry, so the name changes, and the database needs one rename. Do this:
1. Run `prisma contract emit` and commit the regenerated `contract.json` and `contract.d.ts`. The storage hash changes. Do not hand-edit the generated files.
2. Run `prisma migration plan` and apply the migration as usual. Planned from the previous contract, it renames each full-text index: `ALTER INDEX "post_title_search_724b05e5" RENAME TO "post_title_search_e0dd1131"`. A rename is instant and keeps the index.
Do not use `prisma db update` for this step if a full-text index is large. `db update` compares the contract with the live database, where Postgres prints the expression in its own form, so it cannot tell that the old index is the new one under another name. It drops the index and builds it again. For the index above it plans:
```
├─ ⚠ Drop index "post_title_search_724b05e5"
└─ Create index "post_title_search_e0dd1131" on "post"
DROP INDEX "public"."post_title_search_724b05e5";
CREATE INDEX "post_title_search_e0dd1131" ON "public"."post" USING "gin" (to_tsvector('english', "title"));
```
Building a GIN index reads the whole table. If you must use `db update`, rename each index by hand first, with the old name from the database and the new one from the re-emitted contract (`ALTER INDEX "post_title_search_724b05e5" RENAME TO "post_title_search_e0dd1131"`). `db update` then has nothing to do for it.
Until the rename, `prisma db verify` reports the new index name as missing. With `--strict` it also reports the old name as an extra index.
If code reads a full-text index's `expression` from the contract, recognise the index by `type: "fullText"` and read `options.weightGroups` and `options.language` instead. `weightGroups` lists the weight groups, each a list of storage column names, and `columns` is the same columns in the same order. The search document is no longer stored anywhere in the contract.
An index declared with `map:` keeps its exact name, so neither the name nor the database changes. Only the storage hash moves. `db verify` compares a `map:` index's expression with the text Postgres prints for it, character for character, so it reports a `map:` full-text index as changed, as it did before.
A full-text index can now cover several fields, each top-level item a weight group, strongest first. This is optional:
```prisma
@@fullTextIndex([[title, subtitle], body], name: "post_search")
```
In an index of several fields, every column is wrapped in `coalesce(column, '')`, so the index does not change when a column becomes optional or required. To search it, pass the index from the table's `indexes` to `fns.fullTextMatches` and `fns.fullTextRank` in the SQL builder, which then search its weight groups in its language: `fns.fullTextMatches(post.indexes.post_search, q)`. To search several columns without an index, wrap the groups in `fullTextDocument` from `@prisma/orm-postgres/target/full-text`.
### `full-text-index-one-field-diagnostic-removed`
`@@fullTextIndex([a, b])` was refused with `PSL_FULL_TEXT_INDEX_ONE_FIELD`. It is now a valid index with two weight groups, and that code is never raised. Remove assertions on it. The new refusals are `PSL_FULL_TEXT_INDEX_TOO_MANY_GROUPS` (more than four groups), `PSL_FULL_TEXT_INDEX_EMPTY_GROUP` (an empty group) and `PSL_FULL_TEXT_INDEX_DUPLICATE_FIELD` (a field named twice). `PSL_FULL_TEXT_INDEX_TEXT_FIELD`, `PSL_FULL_TEXT_INDEX_REQUIRES_NAME` and `PSL_FULL_TEXT_INDEX_NAME_XOR_MAP` are unchanged.
## A base model lists its variants by discriminator value
### `re-emit-contracts-with-polymorphic-models`
For `@@base(Task, "bug")` and `@@base(Task, "feature")` the base model's entry changes like this:
```diff
"Task": {
"discriminator": { "field": "type" },
"variants": {
- "Bug": { "value": "bug" },
- "Feature": { "value": "feature" }
+ "bug": { "model": "Bug", "namespace": "public" },
+ "feature": { "model": "Feature", "namespace": "public" }
}
}
```
The schema source does not change, and neither does a variant model's `base`. The storage hash does not change, so no migration is needed and the database marker stays valid.
1. Run `prisma contract emit` and commit the regenerated `contract.json` and `contract.d.ts`.
2. Rewrite each contract snapshot under `migrations/snapshots/<hash>/` whose `contract.json` matches `detection`. No command regenerates a snapshot, and a snapshot in the previous form stops `prisma migration plan` when it is the plan's origin. The directory names stay as they are.
In a snapshot's `contract.json`, for every model that has `variants`:
- replace each entry `"<Model>": { "value": "<value>" }` with `"<value>": { "model": "<Model>", "namespace": "<namespace>" }`, where `<namespace>` is the key under `domain.namespaces` that holds the base model;
- order the entries by key;
- keep the file on one line with no added whitespace.
In the snapshot's `contract.d.ts`, make the same change to the type of `variants`:
```diff
readonly variants: {
- readonly Bug: { readonly value: 'bug' };
- readonly Feature: { readonly value: 'feature' };
+ readonly bug: { readonly namespace: 'public' & NamespaceId; readonly model: 'Bug' };
+ readonly feature: { readonly namespace: 'public' & NamespaceId; readonly model: 'Feature' };
};
```
Quote a key that is not an identifier, such as `'view-product'`. Change nothing else in either file.
A MongoDB contract has one namespace, `__unbound__`; use it as `<namespace>`.
### `read-variants-by-discriminator-value`
This affects only code that reads `variants` from a contract object or from `contract.json`. The `variants` option of the MongoDB TypeScript contract builder's `discriminator` is unchanged: it still lists `<Model>: { value }`.
```ts
// before
const value = models.Task.variants.Bug.value;
const variantNames = Object.keys(models.Task.variants);
// after
const model = models.Task.variants.bug.model;
const values = Object.keys(models.Task.variants);
const variantNames = Object.values(models.Task.variants).map((variant) => variant.model);
```
To find the value of a known variant model, search the entries for the one whose `model` and `namespace` are that model's.
## A Prisma 7 schema's `@ignore` and `@@ignore` objects stay in the contract
The contract read from a Prisma 7 schema now describes every table and column Prisma 7 created, including those of `@ignore` fields and `@@ignore` models. The ORM still leaves them out of its models. The contract's storage hash therefore changes for a schema that uses `@ignore` or `@@ignore`, and no existing database's signature matches it any more.
### A schema that loaded before can be refused
An ignored field or model now becomes part of the contract, so it meets every rule the reader applies, not only to the fields Prisma 8 maps. `prisma contract emit` can refuse a schema that loaded before only because its ignored objects were dropped. Each diagnostic says what to change in the Prisma 7 schema. Common examples, not every case:
- `PSL.PRISMA7_NATIVE_TYPE_UNSUPPORTED`: a `@db.*` column type with no Prisma 8 codec, on an `@ignore` field or in an `@@ignore` model. `PSL.PRISMA7_UNSUPPORTED_TYPE`: an `Unsupported("...")` field in an `@@ignore` model. `@ignore` and `@@ignore` no longer work around a missing codec; the schema cannot be read until Prisma 8 supports the type.
- `PSL.PRISMA7_INDEX_ARGUMENT_UNSUPPORTED`: an index on an `@@ignore` model with `sort`, `length` or `ops` arguments, such as the `sort: Desc` that `prisma db pull` writes.
- `PSL.PRISMA7_REFERENTIAL_ACTION_UNSUPPORTED`: an `@ignore` relation with `onDelete: SetNull` or `SetDefault` over a required field that cannot take it.
- `PSL.PRISMA7_JSON_NULL_DEFAULT_UNSUPPORTED`: an ignored `Json` field with `@default("null")`.
### While Prisma 7 still applies the migrations
If you have no `migrations/app` folder of Prisma 8 migrations yet, emit the contract and sign each existing database again:
```bash
prisma contract emit
prisma db sign
```
### After Prisma 8 took over the migrations
Emit the contract, then plan a migration before anything else. The migration creates the ignored objects, which a database rebuilt from `migrations/` needs:
```bash
prisma contract emit
prisma migration plan --name keep-ignored-objects
```
Then bring each existing database (production, staging, every developer's) to the new hash. Either sign it:
```bash
prisma db sign
```
or run your usual deploy step with the `db` ref:
```bash
prisma db migrate --advance-ref db
```
`db migrate` skips each create because the object already exists. Plain `prisma db migrate` also brings the database to the new hash, but leaves the `db` ref behind, so the next `migration plan` would plan the same migration again; run `prisma db sign` afterwards to move the ref.
If you signed before planning, the `db` ref points at a hash that is not in the migration graph, and `prisma migration plan` fails with `MIGRATION.HASH_NOT_IN_GRAPH`. Plan from the hash the database had before, which `prisma migration list` shows as the last migration's destination:
```bash
prisma migration plan --from <previous hash> --name keep-ignored-objects
```
## `mongo-enum-member-written-as-stored`
`contract.json` holds each enum member in the form its codec stores, and `db.enums` reads it back through the codec. Both must give the member as written, so a Mongo contract now refuses a member whose stored form the codec reads back as another value, and says what to write:
```text
CONTRACT.ENUM_INVALID: enumType("Volume"): member "Quiet" is written "quiet", but the field stores "QUIET". Write the member as "QUIET".
```
In a PSL or Prisma 6 schema the same member is reported at the member:
```text
enum "Volume" member "Quiet" is written "quiet", but its codec stores "QUIET". Write the member as "QUIET".
```
In TypeScript, a codec that throws on a member is now reported as `CONTRACT.ENUM_INVALID` naming the member, with the codec's error as the cause.
1. Run `prisma contract emit`. Each refused member is reported with the value to write.
2. If the codec is yours and should store the value as written, fix its `encodeJson` or `decodeJson` so `decodeJson(encodeJson(value))` returns `value`. Otherwise write each refused member as the message says.
3. Re-emit.
## A TypeScript contract's namespaces and rows have precise types
### Namespace keys equal the runtime keys
A contract built with `defineContract` from TypeScript typed `contract.domain.namespaces` as a record with any string key, and every key listed every model. The types now have exactly the keys the built contract has at runtime:
- `contract.domain.namespaces` and `db.orm` have a key for each namespace that holds a model. They also have the default namespace (`public` on Postgres, `__unbound__` on SQLite) when the contract has no models or declares enums. Each namespace lists only its own models.
- `contract.storage.namespaces` has the default namespace, each namespace listed in `namespaces`, and each model's namespace. Each lists only the tables of its own models.
A Postgres contract whose models all have `namespace: 'auth'` has no `public` domain namespace, so `db.orm.public` no longer typechecks. At runtime it was always `undefined`. Code that reads a namespace the contract does not have now fails to typecheck. Use the namespace the model is in:
```diff
// A Postgres contract built with defineContract, with User in the default namespace
- type User = (typeof contract.domain.namespaces)['__unbound__']['models']['User'];
+ type User = (typeof contract.domain.namespaces)['public']['models']['User'];
```
Indexing `domain.namespaces` with a `string` variable no longer typechecks, because the type has no index signature. Narrow the key to the contract's keys first:
```diff
- const namespace = contract.domain.namespaces[name];
+ const namespace = Object.hasOwn(contract.domain.namespaces, name)
+ ? contract.domain.namespaces[name as keyof typeof contract.domain.namespaces]
+ : undefined;
```
A model is placed in its namespace only when `namespace` is a string literal. When any model's `namespace` comes from a `string` variable, the contract's namespace types fall back to a `string` index, and every namespace lists every model of the contract, as before this change. Access a namespace with brackets and check it for `undefined`, for example `db.orm['billing']?.Invoice`.
On SQLite, `defineContract` no longer accepts `namespaces`. The build always rejected it at runtime; the type now rejects it too. Remove the option.
### Rows have their codec output types
The ORM client of such a contract now types each row field as its codec's output type, narrowed to the enum's values for an enum field. Before, every field was `unknown`. This also applies to `field.column({ codecId, nativeType })` with an inline codec id, which no longer needs `as const`.
A cast that agrees with the codec type still typechecks and can be removed. A cast that disagrees with the codec type now fails with TS2352, and shows a real mismatch: fix the code that expected the other type.
```diff
const row = await db.orm.Sample.first();
- const when = row.when as string;
+ if (row === null) throw new Error('No sample');
+ const when = row.when.toISOString();
```
`first()` returns `null` when no row matches, which its type now says, so code that read a field of the result without checking for `null` fails to typecheck too.
Rows of an included relation are still typed `unknown`; casts on those still typecheck.
## A model with no fields has no keys
Re-emit the contract. If code reads a property of a row of a model with no fields, it now fails to compile, because the model has no field of that name. With the SQL ORM client, passing a key in the create or update input of such a model fails to compile too. Remove the read or the key, or add a field to the model that maps the column.
## Generic update data uses the update input type
The update methods of the SQL and Mongo ORM clients now take the update input type of the model, which rejects every key for a model with no fields. While the model name is a type parameter, TypeScript cannot tell that `Partial<DefaultModelRow<Contract, M>>` is that type. Use the update input type instead.
SQL ORM client:
```diff
- import type { DefaultModelRow } from '@prisma/orm-postgres/orm-client';
- function touchAll<M extends 'User' | 'Post'>(rows: Collection<Contract, M>, data: Partial<DefaultModelRow<Contract, M>>) {
+ import type { ScalarUpdateInput } from '@prisma/orm-postgres/orm-client';
+ function touchAll<M extends 'User' | 'Post'>(rows: Collection<Contract, M>, data: ScalarUpdateInput<Contract, M>) {
return rows.where({}).updateAndCount(data);
}
```
Mongo ORM: replace `Partial<DefaultModelRow<Contract, M>>` with `UpdateInput<Contract, M>`, imported from `@prisma/orm-mongo/orm`, the same entry point as `DefaultModelRow`.
Where the model name is a concrete name, such as `Partial<DefaultModelRow<Contract, 'User'>>`, nothing changes.
## The SQL ORM client accepts only field names
Every name passed to the SQL ORM client must be the name of a field of the model, or of the variant the collection is narrowed to. A name that is not a field throws `ORM.FIELD_UNKNOWN` with the message `Model "<model>" has no field "<name>"`. A column name is not a field name: pass the field instead.
```diff
- await db.orm.Post.where({ user_id: 1 }).all();
+ await db.orm.Post.where({ userId: 1 }).all();
```
Code that catches the error a `conflictOn` name that is not a field raises (in a create with `onConflict: 'skip'`) matched `ORM.ARGUMENT_INVALID`. Match `ORM.FIELD_UNKNOWN` instead.
```diff
- if (error.code === 'ORM.ARGUMENT_INVALID') {
+ if (error.code === 'ORM.FIELD_UNKNOWN') {
```
Leave other checks of `ORM.ARGUMENT_INVALID` alone; only the `conflictOn` refusal changed code.
## `fragment` is called on the model's root collection
A fragment for one model, made with `collection.fragment(body)`, is built from the model alone. Calls chained before `.fragment(...)`, such as `where`, `orderBy`, `include`, `limit` or `forUpdate`, were ignored without a word. Now `fragment` throws `ORM.ARGUMENT_INVALID` on such a collection, and a `where`, an `orderBy` or an `include` before it is also a compile error. A receiver typed by a type parameter, such as `this` in a custom collection class method or `C` in a generic function, is a compile error too, because it may be a chained collection.
In your code, chain on the collection directly instead of defining a fragment from it. Inside a class method, chain on `this`:
```diff
titles() {
- return this.with(this.fragment((posts) => posts.select('id', 'title')));
+ return this.select('id', 'title');
}
```
If the fragment is shared between places, define it once as a module-level constant on the model's root collection, and move the chained calls that were meant to be part of it into its body:
```diff
- const recentSummary = db.orm.public.Post.orderBy((p) => p.id.desc()).fragment((posts) =>
- posts.select('id', 'title'),
- );
+ const recentSummary = db.orm.public.Post.fragment((posts) =>
+ posts.orderBy((p) => p.id.desc()).select('id', 'title'),
+ );
```
The detection does not find a `.fragment(` call whose receiver is a variable or a parameter holding a chained collection, such as `const published = db.orm.public.Post.where(...)` followed by `published.fragment(...)`, or a generic receiver. Check those `.fragment(` calls by hand.
Skip a match on the fragment for any model, `fragment(fields, body)`, called on a client returned by a call, such as `orm(...).fragment(fields, body)`, which did not change, and a match in code that uses the MongoDB ORM client, which did not change either.
## `Fragment` is now `QueryFragment`, and `FieldFragment` is now `DeclaredFieldsFragment`
A function from a collection to a collection, run with `collection.with(fn)`, is a query fragment, and its type is now named `QueryFragment<In, Out>`. "Fragment" alone also covers a row fragment, a function of the model accessor that `where` and `orderBy` take, and it is the name of React's `Fragment`. What `db.orm.fragment(fields, body)` returns, a fragment declared over the fields it needs, is now typed `DeclaredFieldsFragment`. The types work as before.
These changes apply to the SQL ORM client only. The MongoDB ORM client did not change; skip matches in code that uses it.
Rename `Fragment` to `QueryFragment` and `FieldFragment` to `DeclaredFieldsFragment`. These are the names 8.0.0-rc.17 exports.
```diff
- import type { FieldFragment, Fragment } from '@prisma/orm-postgres/orm-client';
+ import type { DeclaredFieldsFragment, QueryFragment } from '@prisma/orm-postgres/orm-client';
- const newest: Fragment<PostCollection, Ordered<PostCollection>> = (posts) => posts.newestFirst();
+ const newest: QueryFragment<PostCollection, Ordered<PostCollection>> = (posts) => posts.newestFirst();
```
Code that still uses the 8.0.0-rc.16 names renames them to the new names directly: `Scope` to `QueryFragment`, `FieldScope` to `DeclaredFieldsFragment` and `ScopeFacts` to `FragmentFacts`.
Rename every use of these types in the file, including a re-export such as `export type { Fragment } from '@prisma/orm-postgres/orm-client'`, a qualified name such as `Orm.Fragment` after `import * as Orm from '@prisma/orm-postgres/orm-client'`, and an inline type such as `import('@prisma/orm-postgres/orm-client').Fragment`. After renaming, sort the named imports again, since `QueryFragment` sorts in a different place than `Fragment` or `Scope`, and wrap any line the longer names make too long for the formatter.
Do not rename React's `Fragment`, the SQL builder's `Scope` and `ScopeField` (imported from a `builder` entry), or any `Fragment` or `Scope` that is not imported from the ORM client. Do not rename `FragmentFacts` or `DeclaredField`, which keep their names.
## `unique-criterion-rejects-null`
`UniqueConstraintCriterion<TContract, ModelName>`, exported by the SQL ORM client (`@prisma/orm-postgres/orm-client` and the other facades' `orm-client` entries), is the object that names one row by its primary key or by one of its unique constraints. Each of its values is now the field's type without `null`. A `null` value cannot identify one row: a unique constraint allows any number of rows whose column is `NULL`.
The type is the argument of three calls, and each of them now refuses `null` and a value whose type includes `null`:
- `conflictOn` in `collection.upsert({ create, update, conflictOn })`
- `mutator.connect(criterion)` and `mutator.connect([criterion, ...])` in a relation callback of `create` or `update`
- `mutator.disconnect([criterion, ...])` in a relation callback of `update`
Only a model with a nullable unique column is affected. Given `Account.handle`, a nullable column with a unique constraint, these no longer compile:
```ts
await db.orm.Account.upsert({ create, update, conflictOn: { handle: null } });
await db.orm.Account.upsert({ create, update, conflictOn: { handle: maybeHandle } }); // maybeHandle: string | null
await db.orm.Owner.where({ id }).update({
accounts: (accounts) => accounts.connect({ handle: maybeHandle }),
});
```
The compiler reports `Type 'null' is not assignable to type 'string'` on the field.
The detection also matches code this change does not affect. Leave as they are: a `.connect(` or `.disconnect(` call on another object, such as `postgres.connect({ url })`; and the `conflictOn` of `createAll` and `createAndCount`, which is an array of field names, such as `conflictOn: ['handle']`.
What to write instead:
- Where the value is known to be present, narrow it before the call, so that its type has no `null`:
```ts
if (maybeHandle === null) {
throw new Error('an account needs a handle to be connected');
}
await db.orm.Owner.where({ id }).update({
accounts: (accounts) => accounts.connect({ handle: maybeHandle }),
});
```
- Where the value can be absent, decide what that case means and write it as its own branch. For `upsert`, create the row with `create(...)` in that branch, or use a constraint whose columns all have values, such as the primary key: `conflictOn: { id }`. For `connect` and `disconnect`, leave the criterion out of the list in that branch, or name the row by another key.
- To read or write the rows whose column is `NULL`, use `where({ handle: null })`. It can match more than one row.
- Where your own code declares a variable, a parameter or a return value as `UniqueConstraintCriterion<...>`, the same rule applies to the values assigned to it.
## `prepared-all-and-aggregate-need-their-receiver`
`all` and `aggregate` on a collection's `prepared` object now declare the object they are called on. This applies to every collection, not only one filtered with `whereUnique`. A call without that receiver no longer compiles:
```ts
const { all, aggregate } = db.orm.Post.prepared;
all(); // error: The 'this' context of type 'void' is not assignable to method's 'this'
aggregate((a) => ({ posts: a.count() })); // the same error
```
Call the method on the `prepared` object instead:
```ts
const prepared = db.orm.Post.prepared;
prepared.all();
prepared.aggregate((a) => ({ posts: a.count() }));
```
These still compile and need no change: a destructured `first` or `firstOrThrow`, as in `const { first, firstOrThrow } = db.orm.Post.prepared; first(); firstOrThrow()`, and `prepared.all` passed as a callback without being called. What the methods do at run time is unchanged.
## `native-enums-leave-out-domain-enums`
`db.nativeEnums.<namespace>` lists the native Postgres enum types of the namespace, keyed as before by the name in the schema. It no longer lists a domain enum, which is an `enum` block in PSL or an `enumType()` in TypeScript. `db.enums.<namespace>` holds each domain enum, with its values decoded as a query returns them.
For each `db.nativeEnums.<namespace>.<Name>` in the code, check whether `<Name>` is a native enum (`native_enum <Name>` in PSL, `nativeEnum('<Name>', …)` in TypeScript). If it is a domain enum, read it through `db.enums` instead:
```ts
// before
db.nativeEnums.public.Priority.values; // ['1', '10'], the stored text
// after
db.enums.public.Priority.values; // [1n, 10n], the values a query returns
```
A domain enum's values in `db.enums` are the values a query returns, which can differ from the stored text that `db.nativeEnums` held: a `pg/int8` member is a `bigint`, a `pg/timestamptz-temporal` member a `Temporal.Instant`. Change code that compared them with stored text. A member's name is its name in the enum, such as `Low`, where `db.nativeEnums` named each member by its value.
## Middleware hooks are activity hooks
The runtime now runs every statement and every transaction as an **activity**. A middleware observes an activity through three lifecycle events, `activityStarted`, `activityEnded` and `activityFinalized`, and changes or stops a statement through interceptors: `beforeEncode`, `intercept.onQuery`, `intercept.onExecute` and `onRow`. The per-statement hooks are gone.
### Rename the hooks
| Before | After |
|---|---|
| `beforeQuery`, `beforeExecute` | `beforeEncode` |
| `interceptQuery`, `interceptExecute` | `intercept.onQuery`, `intercept.onExecute` |
| `onRow` | `onRow` |
| `afterQuery`, `afterExecute` | `activityEnded` on a statement |
| `afterTransaction` | `activityFinalized` on a statement |
| `beforeCompile` | `beforeCompile` |
`beforeEncode` keeps the arguments of `beforeQuery` and `beforeExecute`: the lowered plan, the context and the parameter mutator. It runs for both `query()` and `execute()`. A middleware that defined both `beforeQuery` and `beforeExecute` with the same body defines `beforeEncode` once. A middleware that must tell them apart reads `activity.returns` (`'rows'` or `'stats'`) in `activityStarted` and keeps it in `ctx.state`.
The interceptors move under one `intercept` object:
```diff
- async interceptQuery(plan, ctx) {
- return { rows: cached };
- },
+ intercept: {
+ async onQuery(plan, ctx) {
+ return { rows: cached };
+ },
+ },
```
### Read the result from `activityEnded`
`afterQuery(plan, result, ctx)` and `afterExecute(plan, result, ctx)` become one hook, `activityEnded(activity, result, ctx)`. It fires for transactions too, so check the kind first. `result.completed` is replaced by `result.outcome`, which is `'succeeded'`, `'failed'` or `'stopped'` (the caller stopped reading the rows). `latencyMs` and `source` are unchanged; `rowCount` or `stats` is there according to `result.returns`. The plan the caller sent is `activity.plan`; the lowered SQL is not on the activity, so a middleware that logs it keeps it from `beforeEncode` in `ctx.state`:
```diff
- async afterQuery(plan, result, ctx) {
- if (result.latencyMs > 250) ctx.log.warn({ sql: plan.sql, rowCount: result.rowCount });
- },
+ async beforeEncode(plan, ctx) {
+ ctx.state(statementSql)['sql'] = plan.sql;
+ },
+ async activityEnded(activity, result, ctx) {
+ if (result.kind !== 'statement' || result.latencyMs <= 250) return;
+ ctx.log.warn({
+ sql: ctx.state(statementSql)['sql'],
+ ...(result.returns === 'rows' ? { rowCount: result.rowCount } : {}),
+ });
+ },
```
`statementSql` is a module-level `Symbol('…')`, for example `const statementSql = Symbol('slow-query-warning.sql')`. An error thrown from `activityEnded` after a successful statement still fails it and reaches the caller, as an error from `afterQuery` did; every other middleware still receives `activityEnded`, and the caller receives the first error. A transaction also ends: `succeeded` when its `commit()` or `rollback()` resolved, `failed` when it rejected. So a rolled-back transaction ends `succeeded` and is finalized `rolled-back`. `activityEnded` also fires where `afterQuery` and `afterExecute` did not: for a statement the caller stopped reading, with `result.outcome` `'stopped'`, and for a statement that failed before it ran, in `beforeCompile`, `beforeEncode` or parameter encoding, with `result.outcome` `'failed'`, `latencyMs: 0` and `source: 'driver'`. A middleware that counts statements or latencies checks `result.outcome` if it should skip them.
### Replace `afterTransaction` with `activityFinalized`
`afterTransaction(plan, result, ctx)` becomes `activityFinalized(activity, result, ctx)`. A statement still running when its transaction ends is finalized when it ends, after its `activityEnded`, where `afterTransaction` fired at the transaction's end, before the statement's after-hook. `result.outcome` keeps its three values, `'committed'`, `'rolled-back'` and `'unknown'`, and the same rules decide it. An error thrown from it is still logged and swallowed. It also fires for transactions, after their statements, so a middleware that acts on statements checks `activity.kind === 'statement'`. A statement that fails before it runs, in `beforeCompile`, `beforeEncode` or parameter encoding, is now finalized too: `unknown` outside a transaction, and with the transaction's outcome inside one.
### Rename the context type and the execution id
`RuntimeMiddlewareContext` is now `ActivityContext`, and `SqlMiddlewareContext` and `MongoMiddlewareContext` extend it. A SQL middleware imports `SqlMiddleware` and `SqlMiddlewareContext` from `@prisma/orm-postgres/family-runtime` (or `@prisma/orm-sqlite/family-runtime`), a Mongo middleware imports `MongoMiddleware` and `MongoMiddlewareContext` from `@prisma/orm-mongo/family-runtime`, and a cross-family middleware imports `CrossFamilyMiddleware` and `ActivityContext` from `@prisma/orm-framework/components/runtime`. `ctx.planExecutionId` is removed; use `activity.id`, a fresh id for each activity that every hook of the activity sees. A log detail or field that carried `planExecutionId: ctx.planExecutionId` becomes `activityId: activity.id`. Keep per-statement state in `ctx.state(key)` instead of a `WeakMap` keyed by the plan: each activity has its own state, and middleware that use the same key share the object. `AfterQueryResult`, `AfterExecuteResult` and `AfterTransactionResult` are removed; use `ActivityEndedResult` and `ActivityFinalizedResult`.
### Abort phases and already-aborted calls
A `RUNTIME.ABORTED` error raised during the middleware stage before encoding has `details.phase` `'beforeEncode'`. Code that matches `'beforeQuery'` or `'beforeExecute'` matches `'beforeEncode'` instead. The same holds for the Mongo error `RUNTIME.CONTENT_HASH_REQUIRES_RESOLVED_COMMAND`, raised when a Mongo `beforeEncode` hook calls `ctx.contentHash`: its `details.phase` is `'beforeEncode'`. A call whose signal was aborted before it was made now fires `activityStarted`, then ends `failed`, before it rejects with `RUNTIME.ABORTED`.
### The observed-row budget now runs
`budgets()` now counts the rows each query returns. Before, this check never ran, so a query that returned more than `maxRows` rows succeeded. Now, with the default row severity `'error'` or in `strict` mode, such a query fails with `BUDGET.ROWS_EXCEEDED` once the count passes `maxRows`; with `severities: { rowCount: 'warn' }` in `permissive` mode it logs one warning for the statement and keeps streaming. The `postgres()` client runs its runtime in `strict` mode, where every `budgets()` violation throws whatever its severity, so the warn path does not apply there until the client exposes `mode`. Raise `maxRows` for reads that are expected to return more rows. `tableRows` and `defaultTableRows` only change the estimate checked before a read runs; they do not change the limit on rows a read actually returns.
## Statements decide placeholders and type changes
A Postgres or SQLite project that changes a field's type so that values may not survive, adds a required field to a table with rows, or makes an optional field required, plans differently.
For each script, CI job or documented workflow that runs `prisma migration plan` or `prisma db update` without a terminal:
1. If it changes a field's type, add `--convert <Model.field>` to `migration plan` and write the conversion into the `using: placeholder('<Model.field>:convert')` slot of the written `migration.ts` as SQL text over the column, such as `using: '"age"::integer'` (on SQLite, the column's copy expression in `recreateTable`, such as `copyExpressions: { age: 'CAST("age" AS INTEGER)' }`), then run `node <package dir>/migration.ts`. Or add `--delete <Model.field>` to accept the database's cast. `db update` accepts only `--delete`.
2. If it adds a required field to a table with rows, or makes an optional field required, and relied on the backfill step `migration plan` used to write, add `--backfill <Model.field>`. Without it the existing rows get a temporary value, or, for a field made required, the apply fails when rows hold NULL. A new unique, primary-key or foreign-key field, or one whose values come from a value set, has no temporary value: use `--backfill` when the table has rows. Fill the slots the plan writes, then run `node <package dir>/migration.ts`. On Postgres, `check` returns a query that finds a row still without a value, and `run` is one closure, or an array of closures, each returning one update (not one closure returning an array): `check: () => db.public.user.select('id').where((f, fns) => fns.eq(f.name, null)).limit(1)` and `run: () => db.public.user.update({ name: '' }).where((f, fns) => fns.eq(f.name, null))`, with `db` the `sql` of `postgres<End>({ contractJson: endContract })`. On SQLite there is only `run`, a closure that returns the SQL text of one statement: ``run: () => `UPDATE "User" SET name = 'user ' || id WHERE name IS NULL` ``.
3. If it parses the failure of such an apply, match the new summary, for example `3 rows of "Item" hold NULL in "note", so it cannot become required.`
## `alterColumnType` takes the new type and the conversion directly
A hand-written Postgres migration that changes a column's type called `alterColumnType` with an `options` object that carried internal names:
```ts
this.alterColumnType({
schema: 'public',
table: 'user',
column: 'age',
options: {
qualifiedTargetType: 'int4',
formatTypeExpected: 'integer',
rawTargetTypeForLabel: 'int4',
using: '"age"::integer',
},
}),
```
It now takes the new type and the conversion directly:
```ts
this.alterColumnType({
schema: 'public',
table: 'user',
column: 'age',
type: 'int4',
using: '"age"::integer',
}),
```
A `migration.ts` that still passes `options` stops with `MIGRATION.OPERATION_OPTION_REMOVED` when it runs, naming the column and the new shape.
For each `migration.ts` the detection matches:
1. Replace `options: { ... }` with `type: <the value of qualifiedTargetType>`, and `using: <the value of options.using>` when the old call had one.
2. Delete `formatTypeExpected` and `rawTargetTypeForLabel`. The operation now works out the type the database prints from `type`, through the data types of the configured stack. A type it cannot read fails with `MIGRATION.COLUMN_TYPE_UNREADABLE`, which names the call. Write the type as a migration writes it, such as `int4`, `varchar(20)`, or `"Mood"` for an enum, or add the extension that provides it to the configuration.
3. Check `operationClass`. Left out, it is now `data` when the call has a `using`, and `destructive` otherwise. A call with a `using` that said `operationClass: 'destructive'` keeps that class only if you keep the option; delete it to class the conversion as `data`. A call that said `operationClass: 'widening'` keeps it.
4. Check `using`. A semicolon, a SQL comment or dollar quoting outside a quoted literal, or a comma or a keyword such as `FROM` or `UNION` outside parentheses, now fails with `MIGRATION.SQL_EXPRESSION_INVALID`, which names the call. `FROM` directly after `DISTINCT`, as in `IS DISTINCT FROM`, is accepted. Remove a trailing comment. If the expression holds a comma or a keyword such as `FROM` at the top level, wrap it in parentheses.
5. If the migration has not been applied to any database, run it (`node <package dir>/migration.ts`) to write `ops.json` and `migration.json` again. If it has been applied, leave those two files as they are: the operation's label now names the type as written in `type`, so writing them again would change the migration's hash.
## MongoDB carries out `--rename` and `--delete`
### `mongo-rename-statements-carried-out`
`--rename` now works on MongoDB, with the same syntax as on SQL. A field is named by its contract key, which on MongoDB is its stored name: `id @map("_id")` is `User._id`.
```sh
prisma migration plan --name tidy --rename Profile:User --rename User.name:User.fullName
prisma db update --rename Profile:User --rename User.name:User.fullName
```
- A model rename whose collection name changes renames the collection (`renameCollection`), keeping its documents, indexes and validator.
- A field rename renames the field in every document that holds it (`renameField`), skipping validation. A variant's field is renamed only in that variant's documents.
- The planner refuses, with the reason and what to do instead: a variant rename whose discriminator value changes, `_id`, a field whose stored name contains `.` or starts with `$`, a time-series collection, a target collection that already exists or that MongoDB rejects, and a collection whose control policy is not `managed`. A field removed from a model whose collection is not `managed` is not asked about and not removed.
If you renamed a collection or a field by hand in `mongosh` because `--rename` was refused, nothing changes for migrations you already applied. From now on, give the `--rename` statement instead.
A script or test that expects `--rename` on MongoDB to fail with `MIGRATION.PLANNING_FAILED` now gets a plan. A script that reads `meta.unanswered` of `CLI.CONSENT_REQUIRED` for a dropped MongoDB collection now finds `verbs: ['rename', 'delete']` instead of `['delete']`.
### `mongo-removed-field-asks`
Removing a field from a MongoDB model used to update the collection's validator and ask nothing. Documents that still held the field then failed every update with `Document failed validation`. Now the plan removes the field from those documents (`unsetField`, a `destructive` operation) and asks first. Where nobody can answer, the command fails with `CLI.CONSENT_REQUIRED`, whose next actions name `--delete User.nickname` and `--rename 'User.nickname:User.<new name>'`.
In a script or CI job that runs `prisma db update --no-interactive` or `prisma migration plan` after a field was removed, add `--delete <Model>.<field>` for each removed field, or `--rename` if the field was renamed. When `db update` has no snapshot of the contract the database is at, the field is named by its collection: `--delete profiles.nickname`.
### `mongo-preview-get-collection`
A test or a script that compares the preview of a MongoDB plan (`migration plan`, `db update --dry-run`, `migration show`) expects `db.getCollection("<name>")` in place of `db.<name>`, so a collection name such as `my-coll` is valid `mongosh`. Plans and `ops.json` are unchanged.
### `mongo-time-series-update`
A repeated `db update` on a database whose contract declares a time-series collection used to fail with "Cannot change immutable collection option 'timeseries'", because MongoDB adds defaults such as `bucketMaxSpanSeconds` that the contract leaves out. The planner now compares only the options the contract declares, so the command succeeds and plans nothing. A workaround that skipped `db update` for such a database is no longer needed.
### `storage-question-wording`
A test that asserts the text of a data-loss question about data named by its storage name expects `--delete loses that data` where it expected `--delete loses its rows`.
### `migration-ts-finds-its-config`
Without `--config`, `node migration.ts` now picks the config that owns the migration: a config owns it when the migration file is inside that config's `migrations.dir`. It tries the nearest `prisma.config.ts` above the migration's directory first, then the `prisma.config.ts` in the working directory. So the file runs from inside its own directory, from the project directory, or from any other directory, and with `migrations.dir` outside the project, such as `../shared-db/migrations`, when run from the project directory. If neither config owns the migration, it fails with `CLI.MIGRATION_CONFIG_NOT_FOUND`, naming the configs it checked; run it with `--config <path>`. `--config` still overrides the lookup.
A command that ran the file from the project directory keeps working. A script that ran it from a directory whose `prisma.config.ts` belongs to another project, and relied on that config, now fails with `CLI.MIGRATION_CONFIG_NOT_FOUND`; pass `--config`. `migration new` and `migration plan` given `--config` now repeat it in the `node` command they print. Messages that tell you to run a `migration.ts` say to run it with node, without a path or a working directory.
## `contract-artifacts-restamp`
For every `contract.json` matched by `detection`, run the project's emit command (`prisma contract emit`, or the project's `contract:emit` script) once after upgrading. This entry accounts for the extension's embedded `version` moving to `8.0.0-rc.18`; any other difference in the emitted files comes from an earlier entry in this guide.
upgrading/app/upgrades/8.0.0-rc.17-to-8.0.0-rc.18/scripts/sql-expression-literals-psl/rewrite-sql-strings.mjs
#!/usr/bin/env node
/**
* Rewrites raw SQL written as a quoted string in PSL into a `sql` literal with the same text:
* the `where:` and `expression:` arguments of `@@index`, `@@fullTextIndex` and `@@check`, and
* `using =` and `withCheck =` in `policy_*` blocks.
*
* Usage:
* node scripts/codemods/rewrite-sql-strings.mjs <file-or-glob> [...more]
*
* Globs never descend into `node_modules` or `dist`. Files are rewritten in place, and each
* changed file is printed with its number of rewrites. Running it twice is a no-op.
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { glob } from 'node:fs/promises';
import { argv, exit, stderr, stdout } from 'node:process';
import { fileURLToPath } from 'node:url';
const ATTRIBUTE_START = /@@(index|fullTextIndex|check)\s*\(/g;
const ATTRIBUTE_ARGUMENT = /^(where|expression)\s*:\s*$/;
const POLICY_START = /^[ \t]*policy_[A-Za-z]+\s+[A-Za-z_][A-Za-z0-9_]*\s*\{/gm;
const POLICY_ENTRY = /(^|\n)([ \t]*(using|withCheck)\s*=\s*)$/;
const TAG = 'sql';
/** The end (exclusive) of the quoted string that starts at `start`, or -1 when it is not closed. */
function stringEnd(source, start) {
const quote = source[start];
for (let i = start + 1; i < source.length; i += 1) {
if (source[i] === '\\') {
i += 1;
continue;
}
if (source[i] === quote) return i + 1;
if (source[i] === '\n') return -1;
}
return -1;
}
/** The index of the line break that ends the `//` comment starting at `start`, or the source length. */
function commentEnd(source, start) {
const newline = source.indexOf('\n', start);
return newline === -1 ? source.length : newline;
}
/** The `[start, end)` ranges of `//` and `///` comments, quoted strings and backtick literals: text in which nothing declares an attribute or a policy. */
function inertRanges(source) {
const ranges = [];
for (let i = 0; i < source.length; i += 1) {
const ch = source[i];
if (ch === '"' || ch === "'") {
const end = stringEnd(source, i);
if (end !== -1) ranges.push([i, end]);
i = end === -1 ? commentEnd(source, i) : end - 1;
continue;
}
if (ch === '`') {
const end = source.indexOf('`', i + 1);
if (end === -1) continue;
ranges.push([i, end + 1]);
i = end;
continue;
}
if (ch === '/' && source[i + 1] === '/') {
const end = commentEnd(source, i);
ranges.push([i, end]);
i = end;
}
}
return ranges;
}
/** The end (exclusive) of the bracketed region opening at `open`, skipping strings and tagged literals. */
function closingEnd(source, open, openChar, closeChar) {
let depth = 0;
for (let i = open; i < source.length; i += 1) {
const ch = source[i];
if (ch === '"' || ch === "'" || ch === '`') {
const end = ch === '`' ? source.indexOf('`', i + 1) + 1 : stringEnd(source, i);
if (end <= 0) return -1;
i = end - 1;
continue;
}
if (ch === '/' && source[i + 1] === '/') {
const newline = source.indexOf('\n', i);
if (newline === -1) return -1;
i = newline;
continue;
}
if (ch === openChar) depth += 1;
if (ch === closeChar) {
depth -= 1;
if (depth === 0) return i + 1;
}
}
return -1;
}
function hexAt(raw, start, length) {
const digits = raw.slice(start, start + length);
return digits.length === length && /^[0-9A-Fa-f]+$/.test(digits)
? String.fromCharCode(Number.parseInt(digits, 16))
: undefined;
}
const SIMPLE_ESCAPES = {
'/': '/',
b: '\b',
f: '\f',
n: '\n',
r: '\r',
t: '\t',
'"': '"',
"'": "'",
'\\': '\\',
};
/** The text of a PSL string literal body, decoded as the PSL parser decodes it. */
export function decodePslString(raw) {
let out = '';
for (let i = 0; i < raw.length; i += 1) {
const ch = raw[i];
if (ch !== '\\' || i + 1 >= raw.length) {
out += ch;
continue;
}
const next = raw[i + 1];
if (Object.hasOwn(SIMPLE_ESCAPES, next)) {
out += SIMPLE_ESCAPES[next];
i += 1;
continue;
}
const length = next === 'x' ? 2 : next === 'u' ? 4 : 0;
const decoded = length === 0 ? undefined : hexAt(raw, i + 2, length);
if (decoded === undefined) {
out += `\\${next}`;
i += 1;
continue;
}
out += decoded;
i += 1 + length;
}
return out;
}
/** A `sql` literal holding `text`: the backtick form, or the double-quote form when the text holds a backtick. A backtick body doubles a backslash only before another backslash or at its end, where it would be read as an escape. */
export function printSqlLiteral(text) {
if (text.includes('`')) {
const escaped = text
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r');
return `${TAG}"${escaped}"`;
}
const fenced = text.replace(/\\(?=\\|$)/g, '\\\\');
return text.includes('\n') ? `${TAG}\`\n${fenced}\n\`` : `${TAG}\`${fenced}\``;
}
/** The quoted strings to rewrite, as `[start, end)` ranges of `source`. */
function stringRanges(source) {
const inert = inertRanges(source);
const isInert = (index) => inert.some(([start, end]) => index >= start && index < end);
const ranges = [];
for (const match of source.matchAll(ATTRIBUTE_START)) {
if (isInert(match.index)) continue;
const open = match.index + match[0].length - 1;
const close = closingEnd(source, open, '(', ')');
if (close === -1) continue;
let depth = 0;
let argumentStart = open + 1;
for (let i = open + 1; i < close - 1; i += 1) {
const ch = source[i];
if (ch === '/' && source[i + 1] === '/') {
i = commentEnd(source, i);
continue;
}
if (ch === '"' || ch === "'") {
const end = stringEnd(source, i);
if (end === -1) break;
const before = source.slice(argumentStart, i).trim();
if (depth === 0 && ATTRIBUTE_ARGUMENT.test(before)) ranges.push([i, end]);
i = end - 1;
continue;
}
if (ch === '`') {
i = source.indexOf('`', i + 1);
if (i === -1) break;
continue;
}
if (ch === '(' || ch === '[' || ch === '{') depth += 1;
if (ch === ')' || ch === ']' || ch === '}') depth -= 1;
if (ch === ',' && depth === 0) argumentStart = i + 1;
}
}
for (const match of source.matchAll(POLICY_START)) {
if (isInert(match.index + match[0].search(/\S/))) continue;
const open = match.index + match[0].length - 1;
const close = closingEnd(source, open, '{', '}');
if (close === -1) continue;
for (let i = open + 1; i < close - 1; i += 1) {
const ch = source[i];
if (ch === '/' && source[i + 1] === '/') {
i = commentEnd(source, i);
continue;
}
if (ch === '"' || ch === "'") {
const end = stringEnd(source, i);
if (end === -1) break;
if (POLICY_ENTRY.test(source.slice(open + 1, i))) ranges.push([i, end]);
i = end - 1;
continue;
}
if (ch === '`') {
i = source.indexOf('`', i + 1);
if (i === -1) break;
}
}
}
return ranges.sort((a, b) => a[0] - b[0]);
}
/** Rewrites `source` and returns it with the number of strings rewritten. */
export function rewriteSqlStringsWithCount(source) {
const ranges = stringRanges(source);
let out = '';
let position = 0;
for (const [start, end] of ranges) {
out += source.slice(position, start);
out += printSqlLiteral(decodePslString(source.slice(start + 1, end - 1)));
position = end;
}
out += source.slice(position);
return { source: out, count: ranges.length };
}
export function rewriteSqlStrings(source) {
return rewriteSqlStringsWithCount(source).source;
}
const SKIPPED_DIRECTORIES = /(^|\/)(node_modules|dist)\//;
async function filesMatching(patterns) {
const files = new Set();
for (const pattern of patterns) {
for await (const match of glob(pattern, {
exclude: (path) => /(^|\/)(node_modules|dist)$/.test(path),
})) {
if (!SKIPPED_DIRECTORIES.test(match)) files.add(match);
}
}
return [...files].sort();
}
async function main() {
const patterns = argv.slice(2);
if (patterns.length === 0) {
stderr.write('usage: node rewrite-sql-strings.mjs <file-or-glob> [...more]\n');
exit(2);
}
for (const file of await filesMatching(patterns)) {
const before = readFileSync(file, 'utf8');
const { source, count } = rewriteSqlStringsWithCount(before);
if (count === 0) continue;
writeFileSync(file, source);
stdout.write(`${file}: ${count} rewritten\n`);
}
}
if (argv[1] && fileURLToPath(import.meta.url) === argv[1]) {
await main();
}
upgrading/app/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/instructions.md
---
from: "8.0.0-rc.18"
to: "8.0.0-rc.19"
changes:
- id: stored-contracts-drop-nocheck
summary: |
A stored SQL contract that has a `noCheck` key on a column no longer loads. Before any other step of this change, run `scripts/remove-nocheck/remove-nocheck.ts` from the project root, then run `prisma db sign` against every database. Sign a database that has not applied every migration with `prisma db sign --contract <the last migration it applied>`, and migrate it once each `ops.json` is written again. The script removes the key from every stored contract, migration snapshots and the emitted `contract.json` included. A list whose element check was waived becomes a list whose elements may be null, which is what the database allows. The script also writes each PostgreSQL default in the form this release stores (`postgres-defaults-stored-in-another-spelling`). It refreshes every hash and reference that changes.
detection:
glob: "**/*.json"
matches:
- '"noCheck"\s*:'
script: ./scripts/remove-nocheck/remove-nocheck.ts
- id: psl-nocheck-removed
summary: |
The PSL attribute `@noCheck` no longer exists, and writing it is an unknown-attribute error. Replace `T[] @noCheck(elementNotNull)` with `T?[]`, delete `@noCheck(membership)`, and replace a bare `@noCheck` with both. Re-emit the contract. Where you deleted a membership waiver, the next migration adds the membership check. The detection matches PSL files that use the attribute.
detection:
glob: "**/*.prisma"
matches:
- '@noCheck\b'
- id: ts-nocheck-method-removed
summary: |
The TypeScript field builder no longer has a `noCheck()` method. Replace `.many().noCheck('elementNotNull')` with `.many({ elementsNullable: true })`, delete `.noCheck('membership')`, and replace a bare `.noCheck()` with both. Re-emit the contract. Where you deleted a membership waiver, the next migration adds the membership check. The detection matches TypeScript files that call the method.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\.noCheck\('
- id: prisma7-lists-allow-null-elements
summary: |
A contract read from a Prisma 7 `schema.prisma` now types the elements of every list as `T | null`, because Prisma 7 creates no check against null elements, and its storage hash changes. Run `stored-contracts-drop-nocheck` first. Otherwise, re-emit the contract and then run `prisma db sign` against every database. A database Prisma 7 built still matches the contract with no migration. The detection matches a Prisma 7 schema file, one without the `// use prisma-8` directive, with a list of a scalar type, or with any list when the same file declares an enum; check whether such a list is of an enum, and check a list of an enum declared in another file of the schema yourself.
detection:
glob: "**/*.prisma"
matches:
- '(?<![\s\S])(?!\s*// *use +(?:prisma-8|prisma-next) *(?!\S))[\s\S]*\n[ \t]*\w+[ \t]+(?:String|Int|BigInt|Float|Decimal|Boolean|DateTime|Json|Bytes)\[\]'
- '(?<![\s\S])(?!\s*// *use +(?:prisma-8|prisma-next) *(?!\S))(?=[\s\S]*\benum[ \t]+\w+[ \t]*\{)[\s\S]*\n[ \t]*\w+[ \t]+\w+\[\]'
- id: supabase-contract-lists-allow-null-elements
summary: |
The `@prisma/orm-extension-supabase` contract now declares its four `text[]` columns as lists whose elements may be null, so code that reads them gets `string | null` elements, and the Supabase space's storage hash changes. Run `prisma db sign` against every database after upgrading.
detection:
glob: "**/package.json"
matches:
- '"@prisma/orm-extension-supabase"'
- id: sqlite-contracts-name-datetime-and-json-data-types
summary: |
On SQLite, a column whose codec is `sqlite/datetime@1` or `sqlite/json@1` names its own data type (`sqlite/datetime`, `sqlite/json`) instead of `sqlite/text`, and a datetime default or enum value is stored with exactly three fraction digits (`2024-01-01T00:00:00.000Z`). Run the colocated script on the project: it rewrites every SQLite contract and migration snapshot, renames the snapshot directories to the new storage hashes, and rewrites the migrations, refs, `migration.ts` files and `contract.d.ts` files that name them. The CLI refuses the old contract; the application keeps running on it, so upgrade before the next `db migrate`.
detection:
glob: "**/*.json"
matches:
- '"codecId"\s*:\s*"sqlite/(?:datetime|json)@1"\s*,\s*"dataType"\s*:\s*"sqlite/text"'
script: ./scripts/data-type-values/data-type-values.ts
- id: date-time-interval-and-bytes-defaults-use-tags
summary: |
A date, time, interval or bytes default is written with its type's tag instead of a quoted string: `@default(timestamptz`2024-01-01T00:00:00Z`)` on PostgreSQL, `@default(datetime`2024-01-01T00:00:00Z`)` on SQLite, and `@default(bytea`\x0102`)` for bytes on PostgreSQL and `@default(blob`0102`)` on SQLite. `contract emit` refuses the quoted form and names the tag to write. The detection matches Prisma 8 schema files, those with the `// use prisma-8` directive, with a quoted default that looks like a date or an interval, or that sits on a date, time, interval or bytes column. It skips a quoted time such as `"12:30"` on a column whose type is a type alias, because a `String` default can look the same; `contract emit` names such a default.
detection:
glob: "**/*.prisma"
matches:
- '(?<![\s\S])\s*// *use +(?:prisma-8|prisma-next) *(?!\S)[\s\S]*@default\(\s*"(?:[+-]?\d{4,6}-\d{2}-\d{2}|-?P(?:\d|T\d)|[+-]?infinity")'
- '(?<![\s\S])\s*// *use +(?:prisma-8|prisma-next) *(?!\S)[\s\S]*\b(?:DateTime|Date|Time|Timetz|Timestamp|Timestamptz|Interval)\b[^\n]*@default\(\s*"'
- '(?<![\s\S])\s*// *use +(?:prisma-8|prisma-next) *(?!\S)[\s\S]*\bBytes\??[^\n]*@default\(\s*"'
- id: postgres-defaults-stored-in-another-spelling
summary: |
The CLI refuses a PostgreSQL contract that an earlier release emitted when it stores a default in another spelling than its column's parameters give, for example `"1.5"` on `numeric(10,2)`, or a date or time default written as earlier releases stored it. Migration snapshots store such defaults too, and the CLI refuses to plan from them. Run `scripts/remove-nocheck/remove-nocheck.ts` (`stored-contracts-drop-nocheck`): it writes each stored default in the new form, migration snapshots included, and refreshes every hash that changes. Then run `prisma contract emit`, then `prisma db sign`.
script: ./scripts/remove-nocheck/remove-nocheck.ts
- id: prisma7-datetime-default-finer-than-column
summary: |
A project that reads a Prisma 7 schema: a `DateTime` default with more fraction digits than its column holds is now refused instead of rounded. Write the value PostgreSQL stores, which the message names. The detection matches a quoted default with more fraction digits than the precision of its `@db.Timestamp`, `@db.Timestamptz`, `@db.Time` or `@db.Timetz` type, or with more than 3 on a field without a `@db` type.
detection:
glob: "**/*.prisma"
matches:
- '(?:^|\n)(?![^\n]*@db\.)[^\n]*@default\(\s*"\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}\.\d{4,}'
- '(?:^|\n)(?=[^\n]*@db\.(?:Timestamp|Timestamptz|Time|Timetz)\(\s*0\s*\))[^\n]*@default\(\s*"[^"\n]*:\d{2}\.\d{1,}'
- '(?:^|\n)(?=[^\n]*@db\.(?:Timestamp|Timestamptz|Time|Timetz)\(\s*1\s*\))[^\n]*@default\(\s*"[^"\n]*:\d{2}\.\d{2,}'
- '(?:^|\n)(?=[^\n]*@db\.(?:Timestamp|Timestamptz|Time|Timetz)\(\s*2\s*\))[^\n]*@default\(\s*"[^"\n]*:\d{2}\.\d{3,}'
- '(?:^|\n)(?=[^\n]*@db\.(?:Timestamp|Timestamptz|Time|Timetz)\(\s*3\s*\))[^\n]*@default\(\s*"[^"\n]*:\d{2}\.\d{4,}'
- '(?:^|\n)(?=[^\n]*@db\.(?:Timestamp|Timestamptz|Time|Timetz)\(\s*4\s*\))[^\n]*@default\(\s*"[^"\n]*:\d{2}\.\d{5,}'
- '(?:^|\n)(?=[^\n]*@db\.(?:Timestamp|Timestamptz|Time|Timetz)\(\s*5\s*\))[^\n]*@default\(\s*"[^"\n]*:\d{2}\.\d{6,}'
- id: stored-values-a-column-type-does-not-hold
summary: |
Reading a value its column's type does not hold now throws `RUNTIME.DECODE_FAILED`, in a plain read and in an include alike: text or bytes in a SQLite INTEGER or REAL column, text or a number in a SQLite BLOB column, and a multi-dimensional array in a PostgreSQL `text[]` column read with `pg/text-array@1`.
- id: sqlite-datetime-without-offset-read-as-utc
summary: |
A SQLite `DateTime` row holding date and time text without a UTC offset, such as `2024-01-01 12:00:00`, is now read as UTC, as SQLite's own date and time functions read it, instead of in the time zone of the machine running the query. Prisma never writes such text; another tool may.
- id: text-array-min-max-return-arrays
summary: |
`min` and `max` of a `pg/text-array@1` column return an array of strings, as a row of the column does, instead of the array's text (`{a,b}`).
- id: sign-databases-after-data-type-values
summary: |
The upgrade changes the storage hash of every SQLite contract with a datetime or JSON column, and of every PostgreSQL contract you emit again. Run `prisma db sign` against every database before deploying the application built with the new contract. Sign a database that has not applied every migration with `prisma db sign --contract <the last migration it applied>`.
- id: client-functions-move-from-default-to-on-create
summary: |
`@default` now sets a database default only. `@default(uuid())`, `@default(cuid(2))`, `@default(ulid())` and `@default(nanoid())`, with any arguments, are refused with `PSL_GENERATOR_IN_DEFAULT`. Write `@onCreate(uuid())` and so on instead. Run `scripts/default-generators-to-on-create/rewrite-default-generators.mjs` with every `.prisma` file of the project as arguments, including the schema copies in migration directories; it rewrites the Prisma 8 ones and leaves the others unchanged. The contract does not change, so there is nothing to re-emit, sign or migrate. The detection matches Prisma 8 schema files, those with the `// use prisma-8` directive, that write one of these functions in `@default`.
detection:
glob: "**/*.prisma"
matches:
- '(?<![\s\S])\s*// *use +(?:prisma-8|prisma-next) *(?!\S)[\s\S]*@default\(\s*(?:uuid|cuid|ulid|nanoid)\s*\('
script: ./scripts/default-generators-to-on-create/rewrite-default-generators.mjs
- id: contract-print-writes-generator-attributes
summary: |
`contract print` now writes every generated value as `@onCreate(fn())` or `@onUpdate(fn())` after the column type, instead of a `temporal.*` or `id.*` field preset call or `@default(uuid())`. A printed file reads back as the same contract, with the same hashes. It also writes columns it refused before: a value generated on update, and a generated value beside a database default. Nothing to do unless you compare printed files with saved copies.
- id: prisma7-default-function-arguments-checked
summary: |
The reader for a Prisma 7 schema checks the arguments of `uuid`, `cuid` and `nanoid` as Prisma 8 does. An argument the function does not take, such as `uuid(5)`, `cuid(1)`, `nanoid(1)` or `nanoid(1000)`, is refused with `PSL.PRISMA7_UNKNOWN_DEFAULT` and the function's message, where it used to be read as something else (`uuid(5)` as a version 4 UUID, `cuid(1)` as `cuid(2)`). A named argument, such as `nanoid(size: 8)`, is refused too, and so is a number written with a fraction, such as `uuid(4.0)` or `nanoid(8.0)`, which is not an integer value. Write the argument positionally, as a whole number, inside the function's range: `uuid(4)`, `nanoid(8)`.
detection:
glob: "**/*.prisma"
matches:
- '\buuid\(\s*(?![47]\s*\))[^\s)]'
- '\bcuid\(\s*(?!2\s*\))[^\s)]'
- '\bnanoid\(\s*(?:[01]\s*\)|\d{4,}|[3-9]\d\d|2[6-9]\d|25[6-9]|[^)]*[.:]|[-"''])'
- id: generator-function-argument-diagnostics
summary: |
In Prisma 8 PSL, an argument of `uuid`, `cuid` or `nanoid` the function does not take is reported with the code `PSL_INVALID_GENERATOR_ARGUMENT` instead of `PSL_INVALID_ATTRIBUTE_SYNTAX`, and with the function's message, such as `uuid: version must be 4 or 7, got 5`. A missing or extra argument names the callee `Generator function "<name>"` instead of `Attribute "<name>"`, and `Default function "<name>"` for `now` and `autoincrement` in `@default`.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,json}"
matches:
- 'Expected one of: 4 \| 7'
- 'Expected an integer between 2 and 255'
- 'Attribute \\?"(?:uuid|cuid|nanoid|ulid|ksuid|now|autoincrement)\\?"'
- id: generator-function-argument-with-fraction
summary: |
A generator function argument written with a fraction, such as `nanoid(8.0)`, `uuid(4.0)`, `uuid(7.0)` or `cuid(2.0)`, is refused with `PSL_VALUE_TYPE_INCOMPATIBLE`, because a number with a fraction is not an integer value. It used to be read as the whole number. Write `nanoid(8)`, `uuid(4)`, `uuid(7)` or `cuid(2)`.
detection:
glob: "**/*.prisma"
matches:
- '\b(?:uuid|cuid|nanoid)\(\s*\d+\.\d'
- id: generator-fits-column
summary: |
A generator is refused on a Postgres column too short for what it writes, such as `Char(5) @onCreate(nanoid())` (a Nano ID is 21 characters), `VarChar(5) @onCreate(uuid())`, or a bare `Char`, which holds one character. The message names both lengths: `Field "Row.value": nanoid() writes 21 characters, which does not fit a column of 5 characters.` The code is `PSL_GENERATOR_NOT_APPLICABLE` in Prisma 8 PSL, `PSL.PRISMA7_GENERATOR_REFUSED` for a Prisma 7 `@default(...)`, and `CONTRACT.DEFAULT_INVALID` in a TypeScript contract. Such a column used to fail at the first insert. SQLite does not limit the length, so it takes any of them. Widen the column, or for `nanoid` pass a size that fits.
- id: discriminator-must-be-text
summary: |
A SQL `@@discriminator` field that is not text is refused with the message `must be text, such as String, but is "<type>"` instead of `must be of type String, but is "<type>"`. A discriminator may now also be a `Char(n)` or `VarChar(n)` column on Postgres, or a `sql.String(n)` column on SQLite. A `@@base` value is checked against the column's type, so on Postgres a value longer than a `VarChar(n)` column holds is refused; SQLite does not limit the length, so it takes any value. Mongo's message is unchanged.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,json}"
matches:
- '(?<![\s\S])(?![\s\S]*[Mm]ongo)[\s\S]*must be of type String'
- id: string-timestamp-presets-write-text
summary: |
The `temporal.*String` presets on Postgres (`timestampString`, `timestamptzString`, `createdAtString`, `updatedAtString`), and every `@updatedAt` field a `prisma7Schema(...)` contract reads, now fill the column with text from a new generator instead of a `Date`. Re-emit the contract with `prisma contract emit`. Only the execution hash changes, so the database needs no migration and no `db sign`. The detection matches a schema or contract source that uses one of these presets or `@updatedAt`.
detection:
glob: "**/*.{prisma,ts,mts,cts}"
matches:
- 'temporal\.(?:timestamp|timestamptz|createdAt|updatedAt)String\('
- '@updatedAt\b'
- id: string-timestamp-codecs-take-text-only
summary: |
The Postgres `pg/timestamp-string@1` and `pg/timestamptz-string@1` codecs, behind `TimestampString(p)` and `TimestamptzString(p)`, now refuse a value that is not text with `RUNTIME.ENCODE_FAILED`. Before, a `Date` that reached them at runtime was converted. The ORM types were already `string`, so code that compiled still compiles. Code that passes a `Date` through a cast or from JavaScript now fails at runtime; write the text instead, such as `date.toISOString()`. A direct call of the codec's `encode` with a `Date` is now a type error.
- id: type-constructor-arguments-are-values
summary: |
A type-constructor argument is read as a value of a data type, as an attribute argument is. A quoted number, such as `VarChar("255")`, `VarChar(length: "255")` or `Timestamp("3")`, is refused with `PSL_VALUE_TYPE_INCOMPATIBLE` (`Expected a number; write 255`); write the number without quotes. On Postgres a whole number written with a fraction, such as `VarChar(255.0)`, is refused too (`Expected a number that pg/int4 can hold; got pg/numeric`); write `255`. A number its type cannot hold, such as `VarChar(3000000000)` or `Timestamp(2.5)`, is refused at the argument. The same applies to a field preset's precision, such as `temporal.timestamp("3")`.
detection:
glob: "**/*.prisma"
matches:
- '(?<![@\w])(?:[a-z]\w*\.)?[A-Z]\w*\((?:[^()"]*[,:])?\s*"[+-]?\d'
- '(?<![@\w])(?:[a-z]\w*\.)?[A-Z]\w*\((?:[^()"]*[,:])?\s*[+-]?\d+\.0+\s*[,)]'
- '(?<![\w$])temporal\.\w+\((?:[^()"]*[,:])?\s*"[+-]?\d'
- '(?<![\w$])temporal\.\w+\((?:[^()"]*[,:])?\s*[+-]?\d+\.0+\s*[,)]'
- id: more-codecs-refused-for-enums
summary: |
An enum typed by `pg/jsonb@1`, `pg/timetz@1`, `pg/enum@1`, `arktype/json@1`, `pg/vector@1` or `pg/geometry@1` is now refused: `defineContract` from the Postgres package refuses its `enumType` with `CONTRACT.ENUM_INVALID`, and PSL refuses its `@@type` with `PSL_EXTENSION_INVALID_VALUE`. A jsonb enum's CHECK constraint failed to apply: it lists a string member as text PostgreSQL reads as JSON, and a number member as a number it cannot compare with `jsonb`. A query reads a timetz value as the text PostgreSQL prints, such as "12:34:56+02", so a value read back did not equal a member stored as "12:34:56+02:00". A `pg/enum@1` codec types a native enum's column; declare a native enum instead. Replace any other refused enum with a text enum.
detection:
glob: "**/*.{prisma,ts,tsx,mts,cts}"
matches:
- '@@type\(\s*"(?:pg/(?:jsonb|timetz|enum|vector|geometry)|arktype/json)@1"\s*\)'
- '(?<![\s\S])(?![\s\S]*GENERATED FILE - DO NOT EDIT)(?=[\s\S]*\benumType\()[\s\S]*?(?:[''"`](?:pg/(?:jsonb|timetz|enum|vector|geometry)|arktype/json)@1[''"`]|\b(?:timetzColumn|jsonbColumn|arktypeJson|geometryColumn)\b|\b(?:vector|geometry)\()'
- id: psl-enum-members-read-by-the-cast-rule
summary: |
A PSL enum member is now read as a written value of the enum codec's data type, as a `@default` is. A quoted member is text, so it is refused with `PSL_VALUE_TYPE_INCOMPATIBLE` on a codec whose data type has no cast from text, such as `pg/numeric@1`, `pg/int8@1`, the float, date and time codecs, `sqlite/bigint@1`, `sqlite/datetime@1` and `sqlite/json@1`; the message says what to write, worded as the same value refused as a `@default`. Write a number unquoted (`Half = 0.5`, `Low = 1`, `Nan = NaN`, `Infinite = Infinity`), a date or time with its tag (`` Launch = date`2024-01-01` ``, `` Launch = timestamptz`2024-01-01T00:00:00Z` ``), and a `sqlite/json@1` member with the `json` tag (`` Low = json`"low"` ``). A member refused for its type now reports `PSL_VALUE_TYPE_INCOMPATIBLE` instead of `PSL_EXTENSION_INVALID_VALUE`: `Low = 1` under `pg/text@1` keeps its message, `Expected a quoted string`, and `Low = "low"` under `pg/int4@1` now says `Expected a number` in place of the codec's own reason. The stored members do not change, so the contract does not either. A member written in TypeScript, an application value, is unchanged, and so is MongoDB PSL.
detection:
glob: "**/*.prisma"
matches:
- 'enum\s+\w+\s*\{(?=[^{}]*@@type\(\s*"(?!(?:pg/(?:text|char|varchar|uuid|inet|bit|varbit)|sql/(?:text|char|varchar)|sqlite/text|mongo/[\w-]+)@1")[^"]+"\s*\))[^{}]*=\s*"'
- id: enum-members-compare-by-their-data-type
summary: |
An enum typed by `pg/numeric@1`, `pg/interval@1` or `mongo/decimal128@1` now compares values as its data type does, as PostgreSQL's CHECK constraint already did: `db.enums.<namespace>.<Enum>.has()`, `nameOf()` and `ordinalOf()` find `0.50` as the numeric member `0.5`, and `PT24H` as the interval member `P1D`. Two members that are one value in different forms, such as `0.5` and `0.50`, or `P1D` and `PT24H`, are now refused as duplicates: `CONTRACT.ENUM_INVALID` from `defineContract`, and `PSL_ENUM_DUPLICATE_MEMBER_VALUE` from PSL. Such an enum's members, and a column typed by it, are typed as the codec's type, such as `string` for `pg/numeric@1`, because a read can return a member in another form. This holds both in the emitted `contract.d.ts` and in the types `defineContract` infers. A `mongo/decimal128@1` enum is refused when the contract is built, because a collection validator cannot list BSON decimals, so for it only the types changed.
detection:
glob: "**/*.{prisma,ts,tsx,mts,cts}"
matches:
- '@@type\(\s*"(?:pg/(?:numeric|interval)|mongo/decimal128)@1"\s*\)'
- '(?<![\s\S])(?![\s\S]*GENERATED FILE - DO NOT EDIT)(?=[\s\S]*\benumType\()[\s\S]*?(?:[''"`](?:pg/(?:numeric|interval)|mongo/decimal128)@1[''"`]|\b(?:numericColumn|intervalColumn)\()'
- id: enum-columns-typed-by-member-literals
summary: |
A column typed by an enum whose codec is `pg/uuid@1`, `pg/inet@1`, `pg/bit@1`, `pg/varbit@1`, `pg/date-string@1`, `pg/time-string@1`, `sql/char@1`, `sql/varchar@1`, `sql/text@1`, `sql/int@1` or `sql/float@1` on Postgres, or `sqlite/text@1`, `sqlite/integer@1`, `sqlite/real@1`, `sqlite/bigint@1`, `sqlite/json@1`, `sql/char@1`, `sql/varchar@1`, `sql/int@1` or `sql/float@1` on SQLite, is now typed in the emitted `contract.d.ts` as the union of its members' values, as a `pg/text@1` or `pg/int4@1` enum column already was, instead of the codec's whole type. Re-emit the contract. Code that writes a value that is not a member to such a column, or compares a value read from it with one, no longer typechecks; use a member, such as `db.enums.public.Host.members.Loopback`.
- id: db-schema-json-names-type-text
summary: |
`prisma db schema --json` names each column's type field `typeText` instead of `nativeType`, holding the type as introspection reads it from the database (`integer`, `timestamp(3) with time zone`), and no longer has `resolvedNativeType`. The tree output of `prisma db schema` shows the same text, such as `integer` where it showed `int4`.
detection:
glob: "**/*.{ts,mts,cts,js,mjs,cjs,sh,json}"
matches:
- '(?<![\s\S])(?=[\s\S]*\bdb schema\b)[\s\S]*\bnativeType\b'
- id: default-invalid-meta-type-text
summary: |
`CONTRACT.DEFAULT_INVALID`, raised when an `autoincrement()` default sits on a Postgres column whose type is not a `SERIAL` type, carries the column's type in `meta.typeText` instead of `meta.nativeType`.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs}"
matches:
- '(?<![\s\S])(?=[\s\S]*\bDEFAULT_INVALID\b)[\s\S]*\bnativeType\b'
- id: client-takes-contract-value
summary: |
Every client and static entry takes the contract only as a value, `{ contract }`. The `contractJson` option is removed, so `postgres<Contract>({ contractJson, url })` no longer compiles, and the client infers its contract type from the value, so the call needs no type argument. Turn the emitted JSON into a typed value once with `contractFromJson<Contract>(contractJson)` from the facade's `/static` entry (`@prisma/orm-postgres/static`, `@prisma/orm-sqlite/static`, `@prisma/orm-mongo/static`; Supabase uses the Postgres one), export it from the module that creates the client, and pass `{ contract }`. This applies to `postgres()`, `postgresServerless()`, `sqlite()`, `supabase()`, `mongo()`, `postgresStatic()`, `sqliteStatic()`, `mongoStatic()` and `mongoQuery()`. A `migration.ts` that builds a client from `contractJson`, for example for a data transform, fails to load until it is moved too, so move it before you run it again. The colocated script moves source files; move test files by hand.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '(?<![\w$.])(?:postgres|postgresServerless|sqlite|supabase|mongo|postgresStatic|sqliteStatic|mongoStatic|mongoQuery)(?:<[^<>()]*(?:<[^<>()]*>[^<>()]*)*>)?\(\s*\{(?:(?:[^{}]|\{[^{}]*\})*?,)?\s*contractJson\s*[,}:]'
- '(?<![\s\S])(?:(?!import\s+[\w$]+\s*(?:,\s*\{[^{}]*\}\s*)?from\s*[''"]@prisma/orm-(?:(?:postgres|sqlite|mongo)/(?:runtime|serverless|static|query-builder)|extension-supabase/runtime)[''"])[\s\S])*import\s+([\w$]+)\s*(?:,\s*\{[^{}]*\}\s*)?from\s*[''"]@prisma/orm-(?:(?:postgres|sqlite|mongo)/(?:runtime|serverless|static|query-builder)|extension-supabase/runtime)[''"][\s\S]*(?<![\w$.])\1(?:<[^<>()]*(?:<[^<>()]*>[^<>()]*)*>)?\(\s*\{(?:(?:[^{}]|\{[^{}]*\})*?,)?\s*contractJson\s*[,}:]'
- '(?<![\s\S])(?:(?!import\s*(?:[\w$]+\s*,\s*)?\{[^{}]*\b(?:default|supabase|mongoQuery|postgresStatic|sqliteStatic|mongoStatic)\s+as\s+[\w$]+[^{}]*\}\s*from\s*[''"]@prisma/orm-(?:(?:postgres|sqlite|mongo)/(?:runtime|serverless|static|query-builder)|extension-supabase/runtime)[''"])[\s\S])*import\s*(?:[\w$]+\s*,\s*)?\{[^{}]*\b(?:default|supabase|mongoQuery|postgresStatic|sqliteStatic|mongoStatic)\s+as\s+([\w$]+)[^{}]*\}\s*from\s*[''"]@prisma/orm-(?:(?:postgres|sqlite|mongo)/(?:runtime|serverless|static|query-builder)|extension-supabase/runtime)[''"][\s\S]*(?<![\w$.])\1(?:<[^<>()]*(?:<[^<>()]*>[^<>()]*)*>)?\(\s*\{(?:(?:[^{}]|\{[^{}]*\})*?,)?\s*contractJson\s*[,}:]'
script: ./scripts/client-takes-contract-value/contract-from-json.ts
- id: collections-on-the-client
summary: |
Not a breaking change. Every SQL client takes your custom collection classes as an option, `collections`, and uses them on `db.orm`, on the ORM of every transaction (`tx.orm`), on every connection a serverless client opens, and on every Supabase role-bound client: `postgres({ contract, url, collections: { Post: PostCollection } })`, then `db.orm.public.Post.published()`. Code that builds its own ORM client with `orm({ runtime, context, collections })` to reach its classes still works; replace it with the option and use the client's ORM. The detection matches a call to `orm({ ... })`, or to `orm` imported under another name, that passes `collections`.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '(?<![\w$.])orm\s*(?:<[^<>()]*(?:<[^<>()]*>[^<>()]*)*>)?\(\s*\{(?:(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*?,)?\s*collections\s*[,}:]'
- '(?<![\s\S])(?:(?!import\s*\{[^{}]*\borm\s+as\s+[\w$]+[^{}]*\}\s*from\s*[''\"][^''\"]+[''\"])[\s\S])*import\s*\{[^{}]*\borm\s+as\s+([\w$]+)[^{}]*\}\s*from\s*[''\"][^''\"]+[''\"][\s\S]*(?<![\w$.])\1\s*(?:<[^<>()]*(?:<[^<>()]*>[^<>()]*)*>)?\(\s*\{(?:(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*?,)?\s*collections\s*[,}:]'
- id: client-query-and-to-sql
summary: |
Additive for code that only uses these types. Every SQL client gets `query(plan)` and `execute(plan)`, with the types a transaction's have, and `toSql(plan)`, which returns `{ sql, params }` with no database. A serverless connection gets `query` and `execute` too; the serverless client, `postgresStatic()`, `sqliteStatic()` and every transaction get `toSql`. `postgres()`, `postgresServerless()`, `sqlite()` and `supabase()` take `mode: 'strict' | 'permissive'`. `supabase()` creates its pool on the first query instead of when it is called. Application code can move from `db.runtime().query(plan)` to `db.query(plan)`; code that builds its own object of one of the changed types, such as a test double, must add the new members. The detection matches a file that imports one of the changed types from a Prisma package, calls `runtime().query(` or `runtime().execute(`, or reads the renamed error details or the old message texts.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- 'import\s*(?:type\s*)?(?:[\w$]+\s*,\s*)?\{[^}]*(?<![\w$])(?:PostgresClient|PostgresServerlessClient|PostgresServerlessConnection|PostgresStaticContext|PostgresStaticMembers|SqliteClient|SqliteStaticContext|RoleBoundDb|ServiceRoleDb|SupabaseInternalDb|PostgresTransactionContext|SqliteTransactionContext)(?![\w$])[^}]*\}\s*from\s*[''"]@prisma/orm-'
- '\.runtime\(\)\s*\.(?:query|execute)\('
- '(?<![\w$])(?:runtimeTarget|runtimeStorageHash)\s*:|\.(?:runtimeTarget|runtimeStorageHash)\b|[''"](?:runtimeTarget|runtimeStorageHash)[''"]'
- 'Ad-hoc execute received a bind-site slot|Plan target does not match runtime target|Plan storage hash does not match runtime contract'
- id: orm-collection-member-using-reserved
summary: |
Every ORM collection has a `using` member, which holds the collection methods packages contribute for the model, such as `db.orm.public.Post.using.post_search(q)` for a Postgres full-text index. A custom collection class that declares its own member named `using` no longer compiles; rename that member. The detection matches a file that extends `Collection` and declares a class member named `using`.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '(?<![\s\S])(?=[\s\S]*\bextends\s+Collection\b)[\s\S]*[\n{;][ \t]*(?:(?:public|private|protected|override|readonly|async|declare|static|accessor)[ \t]+)*(?:(?:(?:get|set)[ \t]+)?using[ \t]*[?!]?[ \t]*[(<]|using[ \t]*[?!]?[ \t]*=(?![=>])|using[ \t]*[?!]?[ \t]*:[^\n]*[^,\s][ \t]*(?:\n|$))'
- id: orm-annotations-on-the-call
summary: |
Every SQL ORM terminal method runs as one `orm-call` activity, and the annotations a `configure` callback records live on that activity. The statements the call sends carry none, so a middleware that reads an ORM annotation from a statement plan (`handle.read(plan)` or `plan.meta.annotations`) in `beforeEncode`, `intercept.onQuery`, `onRow` or `activityStarted` of a statement finds nothing. Read it from the call instead: in `activityStarted(activity)`, `if (activity.kind === 'orm-call') handle.read(activity)`. The detection matches a file with a middleware hook, not a MongoDB one, that reads an annotation from something other than the activity, or reads `meta.annotations`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\s\S])(?![\s\S]*(?:familyId:\s*[''"]mongo[''"]|\bMongoMiddleware\b))(?=[\s\S]*\b(?:beforeEncode|beforeCompile|onQuery|onExecute|onRow|activityStarted)\b)[\s\S]*\.read\(\s*(?!activity\b)[\w$]+(?:\.[\w$]+)*\s*\)'
- '(?<![\s\S])(?![\s\S]*(?:familyId:\s*[''"]mongo[''"]|\bMongoMiddleware\b))(?=[\s\S]*\b(?:beforeEncode|beforeCompile|onQuery|onExecute|onRow|activityStarted)\b)[\s\S]*\bmeta\.annotations\b'
- id: cached-orm-reads-key-on-the-call
summary: |
The cache middleware caches an annotated ORM read as a whole: a hit sends no statement. Without a `key`, the read is keyed by the call's content hash, and the `deriveKey` option applies only to annotated SQL builder queries. Where a `deriveKey` added a prefix (for example a tenant) to ORM reads, pass that prefix in `cacheAnnotation({ key })` on those reads. If your `deriveKey` separates tenants or users, ORM reads are no longer separated: until each annotated ORM read has a tenant-prefixed `key`, one tenant's cached ORM read can be served to another. Statement middleware such as a slow-query log sees no statement for a cache hit of an ORM read.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bderiveKey\b(?!FromContentHash)'
- id: cache-store-holds-cached-values
summary: |
A cache store holds `CachedValue`, `{ kind: 'statement-rows', rows } | { kind: 'call-result', result }`, not `CachedRows`. Type a custom store as `CacheStore<TMeta, CachedValue>` (or `CacheStore<TMeta>`), and make `get` give back a value equivalent to the one `set` was given, `kind` included. An ORM call's result holds decoded values such as `Date`, `bigint`, Temporal values and extension class instances, so a store that serializes needs a serializer that rebuilds them; `JSON.stringify` throws on a `bigint` and turns a `Date` into a string. `createInMemoryCacheStore` takes only the meta type now and always holds `CachedValue`: drop its second type argument. The detection matches a file that names `CachedRows`, or imports from the cache package and names `CacheStore` or `CacheEntry` but not `CachedValue`, or passes `createInMemoryCacheStore` a second type argument.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bCachedRows\b'
- 'createInMemoryCacheStore<[^>(]*,'
- '(?<![\s\S])(?![\s\S]*\bCachedValue\b)(?=[\s\S]*[''"](?:@prisma/orm-extension-middleware-cache|@internal/middleware-cache)[''"])[\s\S]*(?<![\w$])(?:CacheStore|CacheEntry)(?![\w$])'
- id: orm-needs-a-runtime-queryable
summary: |
Pass `orm()` a runtime, connection or transaction from the SQL runtime itself, or `db.runtime()`. A hand-built object that copies only `query` and `execute` (for example `orm({ context, runtime: { query(plan) { return runtime.query(plan); }, execute(plan) { return runtime.execute(plan); } } })`) has no activity source, so every terminal method on it now fails with `ORM.ACTIVITY_SOURCE_MISSING`: `all()` and the other streaming terminals throw when called, and the others reject. If you need the wrapper, keep the original's `[activitySource]` member on it; the symbol `activitySource` is exported from `@prisma/orm-postgres/family-runtime` (`@prisma/orm-sqlite/family-runtime` on SQLite).
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'runtime:\s*\{[^}]*?\b(?:query|execute)\s*[(:]'
- id: close-lets-open-orm-calls-finish
summary: |
On a client that closes at once (`postgres({ contract, url })`, `sqlite()`, `supabase()`), an ORM call that resolves to one value, such as `first()`, `create()`, `include(...).create()` or a nested create, starts when it is called, and `close()` waits for it, so one left pending at `close()` now completes instead of rejecting: a `create()` used to reject with "Runtime is closed", and `include(...).create()` and a nested create with "Postgres client is closed". A streaming read such as `all()` starts when it is read; read after `close()`, it rejects with "Postgres client is closed" instead of "Runtime is closed". Update code or tests that expect the old outcome.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'Runtime is closed|Postgres client is closed'
- id: typescript-contract-include-row-types
summary: |
A contract built with `defineContract` from TypeScript now types each relation with its target model, namespace and cardinality, as an emitted `contract.d.ts` does. Rows from `include(...)`, which were typed `unknown` and always as an array, now have the target model's field types: a has-many or many-to-many relation is an array, a belongs-to relation is one row (or `null` when optional), and a has-one relation is one row or `null`. A cast on an included value that disagrees with this type now fails to typecheck. The detection matches a file that imports `defineContract` from a SQL contract builder; the change matters only where code queries through the contract it builds without an emitted `contract.d.ts`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bimport\s*\{[^}]*\bdefineContract\b[^}]*\}\s*from\s*[''"]@prisma/orm-(?:postgres|sqlite|family-sql/contract-ts)/contract-builder[''"]'
- id: typescript-contract-aggregates-and-operations
summary: |
A contract built with `defineContract` now carries the aggregate types and query operation types of its target, adapter and extensions, and the capabilities of its adapter, in its type and in its value. `count`, `sum`, `max` and the other aggregates, inside `aggregate(...)` and inside include refinements, are typed; field operations such as `ilike` and capability-gated methods such as `distinctOn` and `forUpdate` are available in the types; `contract.capabilities.postgres.distinctOn` is `true` at run time. Casts that dispatched aggregates by name can be removed. The detection matches casts of the aggregate builder to a record of functions.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'as\s+Record<string,\s*\(field\?:\s*string\)'
- 'AggregateSpec\[string\]'
- id: typescript-contract-parameterized-output-types
summary: |
A contract built with `defineContract` now types a column with type parameters as an emitted `contract.d.ts` does: `varchar(50)` reads as `Varchar<50>`, `numeric(10, 2)` as `Numeric<10, 2>`, `char(36)` as `Char<36>`, and the same for `bit`, `varbit`, `timetz`, `interval`, the string time and timestamp codecs, and pgvector's `Vector<N>`. The field presets read as the column they create: `field.id.uuidv4String()`, `field.id.uuidv7String()` and `field.uuidString()` as `Char<36>`, `field.ulid()` as `Char<26>`, `field.nanoid()` as `Char<21>` or the given size, `field.cuid2()` as `Char<24>`, `field.ksuid()` as `Char<27>`; so does a field written as `field.generated(uuidv4())` or with another id generator. These types are branded strings, so reading them as `string` still works, and filters and writes take plain strings (see `orm-filters-and-writes-take-input-types`). A type assertion or annotation that expects plain `string` for such a field when reading it must change. The detection matches the presets, column helpers and generators that carry type parameters.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bfield\.(id\.)?(uuidv4String|uuidv7String|uuidString|ulid|nanoid|cuid2|ksuid)\('
- '\bfield\.temporal\.(timestampString|timestamptzString)\(\s*\d'
- '\b(charColumn|varcharColumn|numericColumn|bitColumn|varbitColumn|timetzColumn|intervalColumn|timeStringColumn)\(\s*\d'
- '\btype\.(?:sql\.String|pgvector\.Vector)\(\s*\d'
- '(?<![\s\S])(?=[\s\S]*\bimport\s*(?:type\s*)?\{[^}]*\bvector\b[^}]*\}\s*from\s*[''"]@prisma/orm-extension-pgvector/column-types[''"])[\s\S]*(?<![\w$.''"`])vector\(\s*\d'
- '\bfield\.generated\('
- id: orm-filters-and-writes-take-input-types
summary: |
ORM filters and writes take each field's input type instead of its read type, for emitted contracts and for contracts built with `defineContract`: the `where` shorthand, field predicates (`eq`, `in`, `gt` and the rest), `whereUnique`, `create`, `createAll`, `update`, `updateAll`, `upsert` and nested writes. A field read as `Char<36>`, `Varchar<255>` or `Numeric<10, 2>` now accepts a plain string in these positions, so casts such as `'abc' as Char<36>` there are no longer needed. A `CodecField` fragment checks values against the codec's input type, as the model's own fields do. The detection matches casts to the branded string types.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'as\s+(unknown\s+as\s+)?(Char|Varchar|Numeric|Bit|VarBit|Timetz|Interval|TimeString|TimestampString|TimestamptzString)<'
- id: typescript-contract-index-references
summary: |
A contract built with `defineContract` now types each table's indexes as an emitted `contract.d.ts` does: the indexes `.sql({ indexes })` declares, under the `name:` or `map:` they were given or their default `<table>_<columns>_idx` name, and the backing indexes the build derives for foreign keys. In `where` and `orderBy` callbacks, `indexes.<name>` offers exactly these names, so a misspelt index name fails to typecheck instead of failing at run time, and `fns.fullTextMatches(indexes.<name>, query)` and `fns.fullTextRank(...)` now typecheck for a `fullTextIndex`. The detection matches a file that imports `defineContract` from a SQL contract builder; the change matters only where code queries through the contract it builds without an emitted `contract.d.ts`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bimport\s*\{[^}]*\bdefineContract\b[^}]*\}\s*from\s*[''"]@prisma/orm-(?:postgres|sqlite|family-sql/contract-ts)/contract-builder[''"]'
- id: typescript-contract-storage-and-domain-types
summary: |
A contract built with `defineContract` now types its unique constraints with their columns, so `whereUnique` accepts only the fields of the primary key or of one unique constraint, as with an emitted contract; it no longer accepts any record. `db.enums` lists only the enums the contract declares; a contract without enums no longer offers an accessor under any name. A many-to-many relation whose target has no primary key types its junction columns from the target's unique key. The detection matches a file that imports `defineContract` from a SQL contract builder; the change matters only where code queries through the contract it builds without an emitted `contract.d.ts`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bimport\s*\{[^}]*\bdefineContract\b[^}]*\}\s*from\s*[''"]@prisma/orm-(?:postgres|sqlite|family-sql/contract-ts)/contract-builder[''"]'
- id: typescript-contract-enums-in-domain
summary: |
The type of a contract built with `defineContract` carries its enums as an emitted `contract.d.ts` does: in the domain namespace's `enum` and `enumMemberTypes`, and in the storage namespace's `valueSet`. The type-only `enumAccessors` key is gone. `db.enums` reads its accessors from `enumMemberTypes` for both kinds of contract, so its types are the emitted ones: `has(value)` takes any value and narrows it to the enum's values, and `nameOf` and `ordinalOf` take the enum's values as before. An `enumType` keeps its codec id as a literal, so its fields gain their codec's query methods, such as `eq`, `ilike`, `gt` and `asc`. Code that read `enumAccessors` from the contract's type should use `NamespacedEnums<typeof contract>` from `@prisma/orm-framework/contract/enum-accessor`, or `db.enums`. The detection matches reads of `enumAccessors`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'enumAccessors'
- id: emitted-contract-types-from-generics
summary: |
`prisma contract emit` now writes the field and storage column type maps of a SQL contract (`FieldOutputTypes`, `FieldInputTypes`, `StorageColumnTypes`, `StorageColumnInputTypes`) as generics applied to the contract's own shape, the ones a contract built with `defineContract` uses, with only the types a codec computes at emit time written out (`FieldTypeOverrides`, `ColumnTypeOverrides`). A column with type parameters is typed through the pack's type function (`ParameterizedOutputTypeFunctions`) instead of a named type such as `Char<24>`; it resolves to the same type. `Models` members index `FieldOutputTypes`. The types do not change; re-emit to update the committed `contract.d.ts`. The extension metadata in `contract.json` changes too: a pack's codec types name their `parameterizedOutputTypes` instead of `typeImports` such as `Vector`. Migration snapshots keep the form they were written in and need no change. The detection matches a SQL `contract.d.ts` emitted before this change; Mongo contracts never have `StorageColumnTypes`. It also matches the `contract.d.ts` of each migration snapshot (`migrations/snapshots/<hash>/`); leave those as they are.
detection:
glob: "**/contract.d.ts"
matches:
- 'export type StorageColumnTypes = \{'
- id: emitted-field-types-from-shared-generic
summary: |
`prisma contract emit` now writes `FieldOutputTypes` and `FieldInputTypes` of every contract, Mongo and SQL, as `FieldTypesFromDomain` from the contract types entry (`@prisma/orm-mongo/contract/types`, `@prisma/orm-postgres/contract/types`, `@prisma/orm-sqlite/contract/types`) applied to the contract's own shape, and each `<Name>Output` and `<Name>Input` value object type as an entry of `ValueObjectTypesFromDomain`. A Mongo vector field with a length is typed through the target's type function instead of the written-out `Vector<N>`. The types do not change; re-emit every `contract.d.ts`. A SQL `contract.d.ts` written by a development build after 8.0.0-rc.18 imports `FieldTypesFromDomain` from the family contract types entry, which no longer exports it, so it does not compile until it is re-emitted. The detection matches a `contract.d.ts` that writes its field types out, as one emitted by 8.0.0-rc.18 does, and a SQL one that imports the generic from the family entry. It also matches the `contract.d.ts` of each migration snapshot (`migrations/snapshots/<hash>/`), which no command emits again; leave those as they are.
detection:
glob: "**/contract.d.ts"
matches:
- 'export type FieldOutputTypes = \{'
- 'FieldTypesFromDomain,\s+RelationKeys,'
- id: value-object-enum-fields-narrow
summary: |
An enum field inside a value object (a composite type in PSL) now reads and writes the members of its enum, in an emitted `contract.d.ts` and in a contract built with `defineContract`, as an enum field of a model does. Before, the emitted contract typed it as its codec's type, such as `string`. A value object field whose codec renders its own type text, such as `arktype/json@1`, has that type, as before this change. Code that assigns an arbitrary string to such a field now fails to typecheck. Re-emit `contract.d.ts`. The detection matches PSL sources that declare both an enum and a composite type, so check whether a field of the composite type has the enum's type (an enum in another file of a multi-file schema is not found), and TypeScript value objects whose fields include an enum field, also in the factory form of `defineContract` with renamed helpers.
detection:
glob: "**/*.{prisma,ts,mts,cts,tsx}"
matches:
- '(?<![\s\S])(?=(?:[\s\S]*\n)?enum[ \t]+\w+\s*\{)(?:[\s\S]*\n)?[ \t]*type[ \t]+\w+\s*\{'
- '(?:\bvalueObject|\bvalueObject\s*:\s*(\w+)[\s\S]*?\b\1)\(\s*[''"][^''"]+[''"]\s*,\s*\{\s*fields:\s*\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\.namedType\('
- id: mongo-contracts-record-stored-field-names
summary: |
A MongoDB contract keys each model's fields by their names in the schema and records the key each is stored under in `model.storage.fields`, as a SQL contract records each field's column. `id ObjectId @id @map("_id")` is the field `id`, stored as `_id`; it used to be the field `_id`. A contract without `storage.fields` is rejected with `CONTRACT.VALIDATION_FAILED`. Run `prisma contract emit`, then run the colocated script, which rewrites the contract snapshots under `migrations/snapshots/` and any other MongoDB contract in the old form. Storage hashes do not change, so no migration is needed and database markers stay valid.
detection:
glob: "**/*.json"
matches:
- '"storage"\s*:\s*\{\s*(?:"collection"\s*:\s*"[^"]*"\s*)?\}'
- '"storage"\s*:\s*\{\s*"collection"\s*:\s*"[^"]*"\s*,\s*"relations"'
script: ./scripts/mongo-field-storage-names/mongo-field-storage-names.ts
- id: mongo-orm-uses-field-names
summary: |
The MongoDB ORM names every field by its name in the schema: a field declared `id ObjectId @id @map("_id")` is `user.id` in rows and `where({ id })`, `select('id')`, `orderBy({ id: 1 })` and create, update and upsert data, where it was `_id`. Passing a stored name such as `_id` fails with `ORM.FIELD_UNKNOWN`, and a row no longer carries a key that no field stores. The pipeline builder (`db.query`) works on documents and keeps naming stored names (`_id`). This applies only to a project with a MongoDB contract; skip it otherwise. The detection matches code that reads or names `_id`, outside generated `contract.d.ts` files, contract sources and `migration.ts`; a match in pipeline code needs no change. It cannot find other fields with `@map`; the type checker finds them after you re-emit.
detection:
glob: "**/*.{ts,tsx,mts,cts,js,mjs,cjs}"
matches:
- '(?<![\s\S])(?![\s\S]*(?:GENERATED FILE - DO NOT EDIT|\bdefineContract\b|\bextends\s+Migration\b))[\s\S]*\._id\b'
- '(?<![\s\S])(?![\s\S]*(?:GENERATED FILE - DO NOT EDIT|\bdefineContract\b|\bextends\s+Migration\b))[\s\S]*(?<![\w$])_id\s*[:,}]'
- '(?<![\s\S])(?![\s\S]*(?:GENERATED FILE - DO NOT EDIT|\bdefineContract\b|\bextends\s+Migration\b))[\s\S]*\.(?:select|orderBy|include|where|distinct)\([^)]*[''"]_id[''"]'
- '(?<![\s\S])(?![\s\S]*(?:GENERATED FILE - DO NOT EDIT|\bdefineContract\b|\bextends\s+Migration\b))[\s\S]*\b(?:Omit|Pick)<[^>]*[''"]_id[''"]'
- id: mongo-statements-name-fields
summary: |
A MongoDB `--rename` or `--delete` statement names a field by its name in the schema, as on SQL: `id @map("_id")` is `User.id`, where it was `User._id`. A rename that keeps the stored name, such as `--rename User.name:User.title` with both stored as `display_name`, plans nothing; a field whose `@map` changes while its name stays is asked about as a removal, as a column is on SQL; to keep its values, write `renameField(...)` into the planned `migration.ts`.
detection:
glob: "**/*.{sh,json,yml,yaml,ts,mts,cts,js,mjs,cjs,md}"
matches:
- '--(?:rename|delete)[ =][^\s]*\._id\b'
- id: mongo-composite-field-map-refused
summary: |
MongoDB PSL refuses `@map` on a field of a composite type (`type Address { street String @map("st") }`) with `PSL_MONGO_TYPE_FIELD_MAP_UNSUPPORTED`. It used to be ignored silently, so the field was stored under its own name all along: remove `@map`, or rename the field to the name it is stored under.
detection:
glob: "**/*.prisma"
matches:
- '(?<![\w$])type\s+\w+\s*\{[^}]*@map\('
- id: mongo-typescript-stored-field-names
summary: |
The MongoDB TypeScript builder names a field's stored name with `.mongo({ field })`, as the SQL builder names a column with `.sql({ column })`: `id: field.objectId().mongo({ field: '_id' })`. Relations, the discriminator and indexes name fields; the builder writes stored names into indexes, validators and execution defaults. A field without it is stored under its own name, so `_id: field.objectId()` keeps working unchanged. Two fields stored under one name fail with `CONTRACT.NAME_DUPLICATE`, and `.mongo({ field })` on a field of a value object fails with `CONTRACT.ARGUMENT_INVALID`.
- id: mongo-define-contract-readonly-rows
summary: |
A Mongo contract built with `defineContract` now types its fields with the same generic as an emitted `contract.d.ts`. Lists read and write as `readonly T[]` instead of `T[]`, value object fields are `readonly`, and a vector field with a `length` type parameter reads as `Vector<N>` instead of `readonly number[]`. `field.vector()` without type parameters no longer types the field's `typeParams` as `Record<string, unknown>`; its contract type has no `typeParams`, as the built value has none. A type annotation that expects a mutable array or mutable value object from a row must change to the readonly form, or copy the value first. The detection matches Mongo `defineContract` sources with lists, value objects or vectors.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\s\S])(?=[\s\S]*orm-(?:mongo|family-mongo/contract-ts)/contract-builder)[\s\S]*(\.many\(|\.valueObject\(|\.vector\()'
- id: mongo-define-contract-factory-field-types
summary: |
`defineContract(scaffold, factory)` from `@prisma/orm-mongo/contract-builder` typed every codec, and so every field, row and create input, as `never`, because the scaffold's forbidden `family` and `target` keys made the whole definition type `never`. The contract type now leaves them out, so fields have their codec types. Code that compiled only because a field was `never`, such as assigning a row field to an unrelated type, now fails to typecheck. The detection matches the factory form of the Mongo `defineContract`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\s\S])(?=[\s\S]*orm-(?:mongo|family-mongo/contract-ts)/contract-builder)[\s\S]*defineContract\(\s*(?:\w+|\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\})\s*,\s*(?:\(|function\b|\w+\s*=>)'
- id: mongo-update-accessors-take-input-types
summary: |
In Mongo ORM updates, the field accessor's `set`, `push`, `pull` and `addToSet` take each field's input type from the contract, including fields reached by a dot path into a value object. `u.role.set(...)` on an enum field takes only its members, a list field takes a readonly list, and a vector field takes `readonly number[]`, which a `Vector<N>` satisfies. Code that passed a value outside an enum now fails to typecheck. A list of numbers no longer offers `inc` and `mul`, which only a single number takes. The detection matches update callbacks, `(u) => [...]`, that call one of these methods on a field of their argument, by name or by a dot path such as `u('home.kind')`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?:\(\s*)?(?<![\w$])(\w+)\s*\)?\s*=>\s*\[[^\]]*?\b\1(?:\.\w+|\(\s*[''"][^''"]+[''"]\s*\))+\.(?:set|push|pull|addToSet|inc|mul)\('
- id: mongo-typescript-contract-validator
summary: |
A MongoDB contract written in TypeScript with `defineContract` now gets the same closed collection validator as a PSL contract, and its storage hash changes once. `defineContract` refuses what a validator cannot describe: a field whose codec has no MongoDB data type, a model whose `_id` field can hold an array, a regular expression, undefined or null, an enum a validator cannot list, and an embed relation to a model with variants. MongoDB does not check existing documents when a validator is added, so find and fix the documents the validator would refuse, then apply it with `prisma db update`, or with `prisma migration plan` and `prisma db migrate`. Writing an enum in a TypeScript contract to avoid the validator, as the rc.17 notes advised, no longer works.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '@prisma/orm-(?:mongo/contract-builder|family-mongo/contract-ts/contract-builder)'
- id: mongo-shared-collection-validator
summary: |
A MongoDB collection that stores several models that are not variants of one base, such as two PSL models with the same `@@map`, now gets a validator that accepts a document of any of them. Before, the validator described only the last model and refused the other models' documents. Re-emit the contract and apply the new validator with `prisma db update`, or with `prisma migration plan` and `prisma db migrate`.
- id: mongo-recursive-value-objects-emit
summary: |
A PSL MongoDB contract with a composite type that contains itself, such as a navigation item with a list of child items, now emits. Before, `prisma contract emit` failed with `Maximum call stack size exceeded`. The collection validator checks the type in full down to where it repeats, and accepts any object there.
- id: control-client-apply-takes-invocation
summary: |
`dbInit`, `dbUpdate` and `migrate` on a control client take a required `invocation` option: `{ cwd, configPath, dbGiven }`, where the commands an error's advice prints are run from, the `--config` they repeat, and whether they repeat `--db`. Pass `{ cwd: process.cwd(), configPath: undefined, dbGiven: false }` when nothing is run from a terminal. Their failures are now `CliStructuredError`s, so `DbInitFailure`, `DbUpdateFailure` and `MigrateFailure` are gone from `control-api`.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\.(?:dbInit|dbUpdate|migrate)\(\s*\{'
- '\b(?:DbInitFailure|DbUpdateFailure|MigrateFailure)(?:Code)?\b'
- id: runner-failures-report-own-codes
summary: |
A failed migration apply reports the runner's own code, such as `MIGRATION.SCHEMA_VERIFY_FAILED`, `MIGRATION.PRECHECK_FAILED`, `MIGRATION.POSTCHECK_FAILED` or `MIGRATION.EXECUTION_FAILED`, instead of `MIGRATION.RUNNER_FAILED` with `meta.runnerErrorCode`. `MIGRATION.RUNNER_FAILED` no longer exists. The facts move into typed `meta` keys: a failed schema check lists its issues in `meta.schemaIssues` (was `meta.issues`), and a failed check is `meta.failedCheck` (`operationId`, `phase`, `description`; was `operationId`, `phase`, `stepDescription`).
detection:
glob: "**/*.{ts,tsx,mts,cts,js,mjs,cjs,sh}"
matches:
- 'MIGRATION\.RUNNER_FAILED|\brunnerErrorCode\b'
- id: setdefault-operation-class-derived
summary: |
On PostgreSQL, `this.setDefault` in `migration.ts` no longer takes `operationClass`. The operation is widening when the migration's start contract gives the column a default, and additive otherwise. `migration plan` no longer writes `operationClass` into the call. A `migration.ts` that passes it no longer type-checks; delete the option.
detection:
glob: "**/migration.ts"
matches:
- '\bsetDefault\(\{(?:(?!\bthis\.|\brawSql\s*\()[\s\S])*?\boperationClass\s*:'
- id: setdefault-has-no-postcheck
summary: |
On PostgreSQL, a `setDefault` operation no longer checks afterwards that the column has a default, so the runner no longer skips it when the column already has a different default. A package planned by 8.0.0-rc.15 or a later release before this one whose `migration.ts` has a hand-written `setDefault` that changes an existing default still carries the old check in `ops.json`; write its `ops.json` again before you apply it.
detection:
glob: "**/ops.json"
matches:
- 'verify column \\"(?:[^"\\]|\\.)*?\\" has a default'
- id: postgres-postcheck-create-table
summary: |
On PostgreSQL, the check that runs after `createTable` checks that the relation is a table, not a view, sequence or index of the same name. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "create table \\"(?:[^"\\]|\\.)*?\\""(?:(?!"postcheck")[\s\S])*?"postcheck": \[\s*\{\s*"description": "verify table \\"(?:[^"\\]|\\.)*?\\" exists",\s*"sql": "SELECT \(?to_regclass\('
- id: postgres-postcheck-add-column
summary: |
On PostgreSQL, the check that runs after `addColumn` checks the column's type, and that it is NOT NULL when the column is declared NOT NULL. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "verify column \\"(?:[^"\\]|\\.)*?\\" exists",[^{}]*\}\s*\]'
- id: postgres-postcheck-add-column-with-temporary-default
summary: |
On PostgreSQL, the check that runs after `addColumnWithTemporaryDefault` checks the column's type. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- 'has no default after temporary default removal",[^{}]*\}\s*\]'
- id: postgres-postcheck-add-required-column-to-empty-table
summary: |
On PostgreSQL, the check that runs after `addRequiredColumnToEmptyTable` checks the column's type. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "verify column \\"(?:[^"\\]|\\.)*?\\" exists",[^{}]*\},\s*\{\s*"description": "verify column \\"(?:[^"\\]|\\.)*?\\" is NOT NULL",[^{}]*\}\s*\]'
- id: postgres-postcheck-create-index
summary: |
On PostgreSQL, the check that runs after `createIndex` checks that the relation is an index on the planned table, with the planned uniqueness, access method and columns in order, and with an expression and a predicate only where the call has them. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "create index \\"(?:[^"\\]|\\.)*?\\"",(?:(?!"postcheck")[\s\S])*?"postcheck": \[\s*\{\s*"description": "verify index \\"(?:[^"\\]|\\.)*?\\" exists",'
- id: postgres-postcheck-rename-index
summary: |
On PostgreSQL, the check that runs after `renameIndex` also checks that no index has the old name any more. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "rename index \\"(?:[^"\\]|\\.)*?\\" to \\"(?:[^"\\]|\\.)*?\\"",(?:(?!"postcheck")[\s\S])*?"postcheck": \[\s*\{\s*"description": "verify index \\"(?:[^"\\]|\\.)*?\\" exists",[^{}]*\}\s*\]'
- id: postgres-postcheck-add-primary-key
summary: |
On PostgreSQL, the check that runs after `addPrimaryKey` checks that the constraint is a primary key on the planned columns in order. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "verify primary key \\"(?:[^"\\]|\\.)*?\\" exists"'
- id: postgres-postcheck-add-unique
summary: |
On PostgreSQL, the check that runs after `addUnique` checks that the constraint is a unique constraint on the planned columns in order. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "add unique constraint \\"(?:[^"\\]|\\.)*?\\"",(?:(?!"postcheck")[\s\S])*?"postcheck": \[\s*\{\s*"description": "verify constraint \\"(?:[^"\\]|\\.)*?\\" exists",'
- id: postgres-postcheck-add-foreign-key
summary: |
On PostgreSQL, the check that runs after `addForeignKey` checks the constraint's columns, referenced table and columns in order, and its delete and update actions. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "verify FK \\"(?:[^"\\]|\\.)*?\\" exists"'
- id: postgres-postcheck-add-check-constraint
summary: |
On PostgreSQL, the check that runs after `addCheckConstraint` checks that the constraint is a check constraint. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "add check constraint \\"(?:[^"\\]|\\.)*?\\"",(?:(?!"postcheck")[\s\S])*?"postcheck": \[\s*\{\s*"description": "verify constraint \\"(?:[^"\\]|\\.)*?\\" exists",'
- id: postgres-postcheck-rename-constraint
summary: |
On PostgreSQL, the check that runs after `renameConstraint` also checks that no constraint has the old name any more. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "rename (?:primary key|unique constraint|foreign key|check constraint) \\"(?:[^"\\]|\\.)*?\\" to \\"(?:[^"\\]|\\.)*?\\"",(?:(?!"postcheck")[\s\S])*?"postcheck": \[\s*\{\s*"description": "verify constraint \\"(?:[^"\\]|\\.)*?\\" exists",[^{}]*\}\s*\]'
- id: postgres-postcheck-create-rls-policy
summary: |
On PostgreSQL, the check that runs after `createRlsPolicy` checks the policy's command, roles and whether it is permissive. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "create RLS policy \\"(?:[^"\\]|\\.)*?\\"",(?:(?!"postcheck")[\s\S])*?"postcheck": \[\s*\{\s*"description": "verify RLS policy \\"(?:[^"\\]|\\.)*?\\" exists",'
- id: postgres-postcheck-rename-rls-policy
summary: |
On PostgreSQL, the check that runs after `renameRlsPolicy` also checks that no policy has the old name any more; its precheck now also refuses a policy that already has the new name. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "rename RLS policy \\"(?:[^"\\]|\\.)*?\\" to \\"(?:[^"\\]|\\.)*?\\"",(?:(?!"postcheck")[\s\S])*?"postcheck": \[\s*\{\s*"description": "verify RLS policy \\"(?:[^"\\]|\\.)*?\\" exists",[^{}]*\}\s*\]'
- id: postgres-postcheck-autoincrement-default
summary: |
On PostgreSQL, the check that runs after `setDefault` with `autoincrement()` also checks that the column owns the sequence of the planned name and that the sequence has the column's integer width. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- 'takes its default from an attached sequence",[^{}]*\}\s*\]'
- id: postgres-autoincrement-sequence-follows-column-type
summary: |
On PostgreSQL, changing the integer type of an `autoincrement()` column now also changes the sequence the column owns to the new width, and checks both afterwards. Before, a column widened from `int4` to `int8` kept an `integer` sequence, which stops at 2147483647. An `ops.json` written before this change has an `alterColumnType` without the sequence step, and an `alter type of "<column>"` step that does not name the new type; run `migration.ts` again for each migration that is not applied yet. For a database where such a migration is already applied, run `ALTER SEQUENCE … AS bigint` on the sequence `pg_get_serial_sequence` names yourself.
detection:
glob: "**/ops.json"
matches:
- '"description": "alter type of \\"(?:[^"\\]|\\.)*?\\"",(?:(?!"postcheck")[\s\S])*?"postcheck": \[\s*\{\s*"description": "verify column \\"(?:[^"\\]|\\.)*?\\" has type \\"(?:smallint|integer|bigint)\\""'
- id: postgres-alter-column-type-unchanged-refused
summary: |
On PostgreSQL, `this.alterColumnType` with `using` now refuses, with `MIGRATION.ALTER_COLUMN_TYPE_USING_WITHOUT_TYPE_CHANGE`, a column the start contract, after the earlier renames in the migration, already gives the new type. Its check compares only the type, so the runner skipped the conversion. Write the conversion as a `dataTransform`.
detection:
glob: "**/migration.ts"
matches:
- '\balterColumnType\(\{(?:(?!\bthis\.)[\s\S])*?\busing\s*:'
- id: sqlite-postchecks-check-full-definition
summary: |
On SQLite, the checks that let the runner skip an operation whose effect is already in place now check the whole definition. `createTable` checks every column's name, type, nullability and primary key position; `addColumn` checks the column's type and nullability; `createIndex` checks the index's table, ordered columns and uniqueness; `recreateTable` checks foreign key actions and `autoincrement()`. An `ops.json` written by an earlier release still carries the old checks; write it again before you apply it.
detection:
glob: "**/ops.json"
matches:
- '(?<![\s\S])(?![\s\S]*has exactly its columns)[\s\S]*"description":\s*"verify table \\"(?:[^"\\]|\\.)*\\" exists",\s*"sql":\s*"(?:[^"\\]|\\.)*sqlite_master'
- '(?<![\s\S])(?![\s\S]*\\" has type )[\s\S]*"description":\s*"verify column \\"(?:[^"\\]|\\.)*\\" exists",\s*"sql":\s*"(?:[^"\\]|\\.)*pragma_table_info'
- '(?<![\s\S])(?![\s\S]*\\" covers \()[\s\S]*"description":\s*"verify index \\"(?:[^"\\]|\\.)*\\" exists",\s*"sql":\s*"(?:[^"\\]|\\.)*sqlite_master'
- 'pragma_foreign_key_list\(''(?:[^''\\]|\\.)*''\) f WHERE f\.\\"table\\" = ''(?:[^''\\]|\\.)*'' GROUP BY'
- id: mongo-postchecks-check-full-definition
summary: |
On MongoDB, the checks that let the runner skip an operation whose effect is already in place now check the whole definition. `createIndex` checks every index option the planner tells indexes apart by, not only the keys and `unique: true`. `collMod` with a validator that is not only a `$jsonSchema` compares the whole validator. An `ops.json` written by an earlier release still carries the old checks; write it again before you apply it.
detection:
glob: "**/ops.json"
matches:
- '(?<![\s\S])(?![\s\S]*"field":\s*"sparse")[\s\S]*"description":\s*"index exists on '
- '(?<![\s\S])(?![\s\S]*"field":\s*"options\.validator)[\s\S]*"description":\s*"validator applied on '
- id: contract-artifacts-restamp
summary: |
An extension that writes its own package version into the contracts it emits, such as the Supabase extension, now writes 8.0.0-rc.19. Run `contract emit` once after upgrading so the emitted `contract.json` and `contract.d.ts` match the installed extension.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.18"'
---
# 8.0.0-rc.18 → 8.0.0-rc.19 — User upgrade instructions
## Order of the steps
Several changes in this release rewrite stored contracts and change storage hashes. Do the steps in this order, and skip a step that does not apply to the project:
1. Commit your work, so every script's changes can be reviewed and undone with git. Then upgrade the Prisma packages.
2. Rewrite the stored contracts, from the project root: first `scripts/remove-nocheck/remove-nocheck.ts` (`stored-contracts-drop-nocheck`), then `scripts/data-type-values/data-type-values.ts` (`sqlite-contracts-name-datetime-and-json-data-types`). Each script leaves a project it does not apply to unchanged and says so.
3. If either script changed a storage hash, run `prisma db sign` against every database. Sign a database that has not applied every migration with `--contract <the last migration it applied>`, as `stored-contracts-drop-nocheck` describes, and migrate it in step 8.
4. Change the contract source as the entries below say. `scripts/default-generators-to-on-create/rewrite-default-generators.mjs` rewrites `@default(uuid())` and the other client generators in Prisma 8 schema files (`client-functions-move-from-default-to-on-create`).
5. Run `prisma contract emit`. In a MongoDB project, then run `scripts/mongo-field-storage-names/mongo-field-storage-names.ts` (`mongo-contracts-record-stored-field-names`).
6. Change the application code and every `migration.ts`. `scripts/client-takes-contract-value/contract-from-json.ts` moves each client to a contract value (`client-takes-contract-value`); a `migration.ts` that builds a client from `contractJson` fails to load on this release until it is moved. In `migration.ts`, also delete `operationClass` from `setDefault` (`setdefault-operation-class-derived`) and rewrite an `alterColumnType` that `postgres-alter-column-type-unchanged-refused` refuses.
7. For each migration that is not applied yet, write its `ops.json` again when a postcheck entry at the end of this guide matches it: run its `migration.ts` (`node migration.ts`). Do this before any database applies the migration, or it applies with the old checks.
8. Bring each database to the new contract: sign it where an entry says the database does not change (`sign-databases-after-data-type-values`, `prisma7-lists-allow-null-elements`, `supabase-contract-lists-allow-null-elements`), and plan and apply a migration where an entry says one is needed.
The script paths are relative to the directory that holds this guide, which the commands below call `<guide>`.
## Generated checks can no longer be waived
Prisma 8 adds a check to a list column of a managed table so that its elements cannot be null, and a check to an enum-typed column so that it holds only the enum's values. `@noCheck` in PSL and `.noCheck()` in TypeScript used to skip these checks while the field kept its non-null element type. The client type then said the elements could not be null while the database accepted null. Both are removed.
Do the steps in this order: rewrite the stored contracts and sign the databases (`stored-contracts-drop-nocheck`), then change the contract source (`psl-nocheck-removed`, `ts-nocheck-method-removed`), then re-emit. Plan and apply a migration in step 8 of "Order of the steps", after each `ops.json` is written again.
### `stored-contracts-drop-nocheck`
Migration snapshots, the emitted `contract.json` and any other stored contract written by an earlier release may have a `noCheck` key on a column. This release refuses such a contract and says to re-emit it or run this script. A historical snapshot cannot be re-emitted from today's source, so run the script. Do this before any other step of this change, and commit your work first so the script's changes can be reviewed and undone with git. Run it from the project root:
```sh
node <guide>/scripts/remove-nocheck/remove-nocheck.ts
```
`<guide>` is the directory that holds this guide. Node 24 or later runs the TypeScript script directly; it needs no `tsx`. The script reads and writes files only and needs no database. It rewrites every `*.json` file under the root that is a SQL contract, whatever its name, skipping `node_modules`, `dist`, `build` and every directory whose name starts with a dot, such as `.git`, `.agents` or `.claude`. It follows links to files and directories, also outside the root; commit or back up the files there too. That includes a test fixture of an old contract: if you keep one on purpose, restore it with git afterwards. A copy of the project under a directory whose name starts with a dot, such as a worktree under `.claude/worktrees/`, is left alone: run the script in that copy separately. The script takes the project root and one option, `--default`, described below; any other option, a malformed `--default` or a root that is not a directory makes it print its usage and exit 1.
In each contract it removes the `noCheck` key from every column. Where the key waived the element check of a list, it also makes the column, the field that maps it and their types in `contract.d.ts`, the row types such as `ReadonlyArray<CodecTypes['pg/text@1']['output'] | null>` included, lists whose elements may be null. That is what the database allows, and it is what the source says after `psl-nocheck-removed` or `ts-nocheck-method-removed`. The script does not change which checks a contract declares: an enum column whose membership check was waived still has none until you migrate.
In each PostgreSQL contract it also writes each literal default in the form this release stores, for example `"1.5"` on `numeric(10,2)` as `"1.50"` (`postgres-defaults-stored-in-another-spelling`). The script carries this release's rules for each PostgreSQL built-in data type and for the extension data types `postgis/geometry` and `pgvector/vector`, so its result does not depend on the installed packages. A geometry default is written as the upper-case little-endian HEXEWKB PostGIS prints, whatever case and byte order it had. It leaves a default of any other data type as it is.
The storage hash changes, so the script renames each changed snapshot directory to its new hash. It rewrites every `migration.json`, ref, `migration.ts` and `contract.d.ts` that names an old hash, and gives each changed migration a new `migrationHash`. When it finishes, it prints how many files it rewrote, how many snapshot directories it renamed, and each storage hash it replaced (`<old> -> <new>`). A project with no `noCheck` key and no default in another form is left unchanged, and the script says so.
It changes no file and exits 1 in four cases, and names the file:
- a `contract.json`, `migration.json` or ref is not valid JSON, or the `ops.json` of a migration whose hashes change is not;
- a contract's stored storage hash, or the name of its snapshot directory, does not match its content. Restore that file from version control, or re-emit it, and run the script again;
- a renamed snapshot directory already exists with different content;
- this release refuses a PostgreSQL default: it is not a value of its column's type, for example a time with more fraction digits than its column holds, a `pgvector/vector` element that `real` cannot hold exactly, or a `postgis/geometry` in another SRID than its column's; or a list column's default is not a list. The script names the column. Do not edit the stored file: that breaks its storage hash, and the next run refuses it. Instead choose the value the database stores, and run the script again with that value as JSON, for example `--default 'public.product.opensAt="12:34:00.123457"'`. The script stores it in every stored contract where that column's default is refused, migration snapshots included, checks it by the same rules and refreshes the hashes. It stops when the value is refused too, or when a `--default` names a column whose default it does not refuse. Then change the default in the contract source to the same value, so that `prisma contract emit` writes it.
If it stops partway, for example on a full disk, it says so; run it again to finish.
Each database's marker still holds the old storage hash, so `prisma db verify` and `prisma db migrate` refuse it until you sign it. The database itself does not change. Run `prisma db sign` against every database (development, staging, production) right after the script:
```sh
prisma db sign --db "$DATABASE_URL"
```
`db sign` checks the database against the rewritten contract and writes the new hash to its marker.
A plain `prisma db sign` checks the database against the emitted contract, so it signs only a database that has applied every migration. A database that has not, such as production while the latest migrations wait for review, is refused: `db sign` says its schema does not satisfy the contract, and `prisma db migrate` stops with `MIGRATION.MARKER_MISMATCH`, because the marker holds an old hash that no migration names any more. Sign such a database with the contract of the last migration it applied:
```sh
prisma db sign --contract <the last migration it applied> --db "$DATABASE_URL"
```
Then apply the other migrations with `prisma db migrate` in step 8 of "Order of the steps", after step 7 has written their `ops.json` again.
`--contract` takes the migration's directory name, such as `20260917T0818_add_post_expires_at`, or the new hash that migration goes to. To find it, run `prisma migration status` against the database. It prints the start of the old hash the marker holds: `Database marker <hash> is not in the on-disk migration graph`. The error of `prisma db migrate` prints the whole hash: `The database's marker names <hash>`, or `DB marker is <hash>` before 8.0.0-rc.21. Recover with `db sign --contract` as this section says, not with the error's next actions: the marker is outside the migration graph only because the upgrade gave the migrations new hashes. Find the line of the script's `<old> -> <new>` list whose old hash is that one. The new hash on that line is the one to sign with, and the migration that goes to it is the last one the database applied.
The script also gives each rewritten migration a new hash, and the database's migration history keeps the old ones. After the upgrade, `prisma migration status` therefore no longer marks the migrations applied before it as applied, although it still reports the database as up to date. Nothing else is affected: `prisma db migrate` runs none of them again, `prisma db verify` passes, and migrations you plan and apply after the upgrade are marked as usual.
### `psl-nocheck-removed`
Write a list whose elements may be null as `T?[]`:
```prisma
// Before
tags String[] @noCheck(elementNotNull)
// After
tags String?[]
```
The element type gains `| null`, so code that reads the list must handle null elements. The database does not change: neither form has a check. If you ran `stored-contracts-drop-nocheck`, re-emitting gives the contract the script wrote, so there is nothing to sign or migrate. If you did not, re-emit and then run `prisma db sign` against every database.
If you want the check instead, delete the attribute and keep `T[]`. Re-emit, then, in step 8 of "Order of the steps", run `prisma migration plan` and `prisma db migrate`, or `prisma db update` in a project without migrations. The migration adds the check. Applying it fails if a stored list already holds a null element, so fix those rows first.
Delete `@noCheck(membership)`. Re-emit, then plan and apply a migration the same way, in step 8. The migration adds the membership check to the enum column. Applying it fails if a stored value is not one of the enum's values, so fix those rows first.
A bare `@noCheck` waived every check the column had. Apply both rules above: a list column becomes `T?[]`, and an enum column loses the attribute.
### `ts-nocheck-method-removed`
The same rules apply to the TypeScript builder:
```ts
// Before
tags: field.text().many().noCheck('elementNotNull'),
role: field.column(roleColumn).noCheck('membership'),
// After
tags: field.text().many({ elementsNullable: true }),
role: field.column(roleColumn),
```
`.many({ elementsNullable: true })` leaves the database as it is and types each element as `T | null`. Deleting `.noCheck('membership')` makes the next migration add the membership check. Re-emit and then sign or migrate as `psl-nocheck-removed` says.
### `prisma7-lists-allow-null-elements`
A Prisma 7 database has no check on its list columns, so it accepts null elements. The Prisma 7 reader now says so: a `String[]` field reads as a list of `string | null`. Fix the type errors where code assumed non-null elements.
The contract's storage hash changes. If your project stores a contract an earlier release emitted, run `stored-contracts-drop-nocheck` first: after it and `prisma db sign`, re-emitting with `prisma contract emit` gives the same contract, and there is nothing more to do. Otherwise, re-emit the contract and then run `prisma db sign` against every database that was signed before.
A database Prisma 7 built matches the new contract with no migration. A project that hands over from Prisma 7 for the first time with this release verifies with `prisma db verify` after `prisma db sign`, as before. A database that was already signed fails `prisma db verify` with `CONTRACT.MARKER_MISMATCH` until you sign it again.
### `supabase-contract-lists-allow-null-elements`
The `@prisma/orm-extension-supabase` contract now declares four `text[]` columns as lists whose elements may be null, because the Supabase database has no check against null elements in them:
- `auth.custom_oauth_providers.acceptable_client_ids` (`CustomOauthProviders.acceptableClientIds`)
- `auth.custom_oauth_providers.scopes` (`CustomOauthProviders.scopes`)
- `storage.buckets.allowed_mime_types` (`StorageBucket.allowedMimeTypes`)
- `storage.objects.path_tokens` (`StorageObject.pathTokens`)
Code that reads these fields now gets `string | null` elements. The Supabase space's storage hash changes from `cb23216599e58b222c1fe7d880105d8f166c8490935cec5ed539dbe77847d679` to `292489649ae6d6928de98a32b544bd9caf6b7d49718c10fe2720b83545a4b5a2`.
A contract that composes the Supabase space references it by id, so your own `contract.json` and `contract.d.ts` do not change. What changes is the signature: a database that was signed against the previous Supabase contract no longer matches the new hash, so run `prisma db sign` against it after upgrading. The Supabase database itself does not change.
## Stored values follow their column's data type
### `sqlite-contracts-name-datetime-and-json-data-types`
A SQLite contract stores the data type of each column. This release gives datetime and JSON values their own data types, and stores a datetime as `toISOString()` writes it, with exactly three fraction digits. A contract emitted by the previous release still names `sqlite/text`, so the CLI of this release refuses it: every command that reads the contract (`migration plan`, `db migrate`, `db verify` and others) fails with `CONTRACT.VALIDATION_FAILED`:
```text
storage.namespaces.__unbound__.entries.table.events.columns.at: codec sqlite/datetime@1 represents sqlite/datetime, not sqlite/text
```
The application does not refuse it: built with this release, it keeps running on the old contract until you upgrade. So do the upgrade below before the next `db migrate`, or any other CLI command, against the project.
Commit your work first, so the script's changes can be reviewed and undone with git. Then run the script from the project root:
```sh
node <guide>/scripts/data-type-values/data-type-values.ts
```
Node 24 or later runs the TypeScript script directly; it needs no `tsx`. It reads and writes files only and needs no database. It rewrites every `*.json` file under the root that parses as a SQLite contract in the previous release's form, skipping `node_modules`, `dist`, `build` and every directory whose name starts with a dot, such as `.git`, `.agents` or `.claude`, and follows links to files and directories, also outside the root; commit or back up the files there too. That includes a test fixture of a contract in the previous release's form: if you keep such a fixture on purpose, restore it with git afterwards (`git restore <file>`), or keep it outside the project root. In each contract it changes:
- the `dataType` of each column and named type whose codec is `sqlite/datetime@1` or `sqlite/json@1`, from `sqlite/text` to `sqlite/datetime` or `sqlite/json`;
- each literal default of a `sqlite/datetime@1` column, and each value of an enum typed by `sqlite/datetime@1`, to three fraction digits: `"2024-01-01T00:00:00Z"` becomes `"2024-01-01T00:00:00.000Z"`, and `"2024-01-01T00:00:00.5Z"` becomes `"2024-01-01T00:00:00.500Z"`. A JSON default keeps its value.
It then writes the storage hash each contract now has, renames the contract's snapshot directory under `migrations/snapshots/` to that hash, and replaces the old hash in every `migration.json` (recomputing its `migrationHash`), every ref under `refs/`, every `migration.ts` and every `contract.d.ts`. The planned SQL does not change: SQLite stores the same text in the column default.
When it finishes, it prints how many files it rewrote and how many snapshot directories it renamed, and each storage hash it replaced (`<old> -> <new>`). A project already in the new form is left unchanged, and the script says so. If it finds no SQLite contract under the root, it says so and changes nothing; that is the expected result in a PostgreSQL or MongoDB project. In a SQLite project, check that you ran it from the project root. It prints `<file>: stored hash did not recompute; rehashed from content` for a contract whose stored hash does not match its content, and rewrites it anyway. It changes no file and exits 1 when a `contract.json`, `migration.json` or ref is not valid JSON, when the `ops.json` of a migration whose hashes change is not valid JSON, or when a renamed snapshot directory already exists with different content. An option or a project root that is not a directory makes it print its usage and exit 1.
If the script stops on an error partway, for example on a full disk, it prints the error and `the upgrade stopped partway, run the script again to finish it`, and exits 1. Run it again: it finishes the upgrade and removes its own temporary files (ending in `.data-type-values-tmp`).
Run your formatter afterwards. The script writes each `contract.json` as `contract emit` does and replaces text in `contract.d.ts` without reformatting it, so a file your formatter keeps in another layout, or the line wrapping of `contract.d.ts`, can differ from what your formatter produces.
### `date-time-interval-and-bytes-defaults-use-tags`
This applies to Prisma 8 schemas. A Prisma 7 schema read with `prisma7Schema(...)` keeps its quoted defaults; see `prisma7-datetime-default-finer-than-column`. A default of a date, time, interval or bytes column is written with the tag of the column's type, as a JSON default already is. `contract emit` refuses a quoted string and shows the tag to write:
```text
Field "Event.at": Expected timestamptz`...`; write timestamptz`2024-01-01T00:00:00Z`
```
```prisma
// before
model Event {
id Int @id
at DateTime @default("2024-01-01T00:00:00Z")
local Timestamp @default("2024-01-01 12:34:56.500")
day Date @default("2024-03-15")
clock Time @default("12:34")
zoned Timetz @default("12:34:56+02")
span Interval @default("P1D")
payload Bytes @default("AQID")
}
// after
model Event {
id Int @id
at DateTime @default(timestamptz`2024-01-01T00:00:00Z`)
local Timestamp @default(timestamp`2024-01-01 12:34:56.500`)
day Date @default(date`2024-03-15`)
clock Time @default(time`12:34`)
zoned Timetz @default(timetz`12:34:56+02`)
span Interval @default(interval`P1D`)
payload Bytes @default(bytea`\x010203`)
}
```
The tag is named after the column's PostgreSQL type: `timestamptz` for `DateTime`, `timestamp`, `date`, `time`, `timetz` and `interval` for the types of those names, and `bytea` for `Bytes`. A date or time tag takes the text you wrote. A `bytea` tag takes the bytes as hexadecimal after `\x`, not base64: `"AQID"` is the bytes 01 02 03, so write `bytea`\x010203``. For bytes, the message depends on what you quoted. A quoted hex string gets the full rewrite (`Expected bytea`...`; write bytea`\x010203``). A quoted base64 string gets a message naming only the tag (`Expected bytea`...``), because `contract emit` cannot tell base64 from hex text; convert the bytes to hex yourself.
On SQLite, a `DateTime` default uses the `datetime` tag, and a `Bytes` default the `blob` tag, whose body is the hex digits the quoted string held before. A quoted string on a SQLite `Bytes` column was read as hex; it is now refused, naming the `blob` tag:
```prisma
// before
createdAt DateTime @default("2024-01-01T00:00:00Z")
payload Bytes @default("68656c6c6f")
// after
createdAt DateTime @default(datetime`2024-01-01T00:00:00Z`)
payload Bytes @default(blob`68656c6c6f`)
```
The stored value is the same as before, so emitting the schema after the rewrite gives the contract the script above writes.
### `postgres-defaults-stored-in-another-spelling`
A stored default is now a value of its column's type, written in the one spelling the type and its parameters give. A PostgreSQL contract that an earlier release emitted can store a default in another spelling. The CLI of this release refuses such a contract: every command that reads it (`migration plan`, `db init`, `db verify`, `migration check` and others) fails with `CONTRACT.VALIDATION_FAILED`. The application does not refuse it, and keeps running on it until you emit the contract again; do that before the next `db migrate`. The CLI's message names the column, the stored value and the spelling the type expects:
```text
Namespace "public", table "product", column "price": the default "1.5" is not a value of pg/numeric: pg/numeric JSON value must be "1.50", the spelling its parameters give this value
```
The cases are:
- a number default spelled without its scale, such as `"1.5"` on `numeric(10,2)`, which is now `"1.50"`;
- a `char` default padded with spaces, which is now stored without them;
- a time default with more fraction digits than its column's precision;
- a date or time default in a spelling earlier releases stored: an instant with `.000Z` (`"2024-01-01T00:00:00.000Z"` is now `"2024-01-01T00:00:00Z"`), an offset such as `+01:00` on a `timestamptz` (now converted to `Z`), a time without seconds (`"12:34"` is now `"12:34:00"`), or a space instead of `T` between date and time;
- a `postgis/geometry` default in lower-case or big-endian HEXEWKB, which is now upper-case little-endian HEXEWKB, as PostGIS prints it, or a geometry whose SRID is not its column's;
- a `pgvector/vector` default with an element that `real` cannot hold exactly, such as `0.123456789`, which the database stores as `0.12345679`.
Migration snapshots store defaults too. `migration plan`, `migration check` and every other command that reads a snapshot refuse an old one the same way, and name it (`Predecessor contract at migrations/snapshots/<hash>/contract.json failed to deserialize: ...`). A snapshot cannot be emitted again, so `scripts/remove-nocheck/remove-nocheck.ts` rewrites it (`stored-contracts-drop-nocheck`). It writes each default of every stored PostgreSQL contract in the form this release stores, renames each changed snapshot directory to its new storage hash, and rewrites every migration, ref, `migration.ts` and `contract.d.ts` that names an old hash. A snapshot rewritten this way has the storage hash `contract emit` gives the same schema, so `migration plan` plans nothing for this change. If a default is not a value of its column's type at all, for example a time with more fraction digits than its column holds, the script changes no file, names the file and column, and exits 1. Run it again with `--default` and the value the database stores, as `stored-contracts-drop-nocheck` describes; do not edit the stored file.
Then run `prisma contract emit`, which writes the default in the new spelling, then `prisma db sign` (`sign-databases-after-data-type-values`). Two kinds of default in the schema need rewriting first, or `contract emit` refuses them:
- a quoted date, time, interval or bytes default, which is written with its tag (`date-time-interval-and-bytes-defaults-use-tags`);
- a default with more fraction digits or more scale than its column holds, on a `time`, `timestamp`, `timestamptz`, `interval` or `numeric` column. Earlier releases let PostgreSQL round it; write the rounded value instead. For example, `Timestamptz(3) @default(timestamptz`2024-01-01T00:00:00.1234Z`)` is refused with:
```text
Field "Event.at": pg/timestamptz JSON value must be a value with at most 3 fraction digits of a second, which precision 3 holds without rounding
```
Write `timestamptz`2024-01-01T00:00:00.123Z``. A `Numeric(10, 2)` default of `1.234` is refused the same way (`pg/numeric JSON value must be a decimal string that numeric(10, 2) stores without rounding`); write `1.23`.
### `prisma7-datetime-default-finer-than-column`
This applies to a project whose contract source is a Prisma 7 schema. Prisma 7 let PostgreSQL round a `DateTime` default with more fraction digits than its column holds. This release refuses it, so the contract stores the value the database holds. A plain `DateTime` is `timestamp(3)`, which holds three fraction digits:
```text
Field "Event.at": @default holds "2024-01-02T03:04:05.1234Z", with 4 digits after the decimal point, but the column holds 3. PostgreSQL stores it as "2024-01-02T03:04:05.123Z"; write that instead.
```
Write the value the message names:
```prisma
// before
at DateTime @default("2024-01-02T03:04:05.1234Z")
// after
at DateTime @default("2024-01-02T03:04:05.123Z")
```
Then run `prisma contract emit` and `prisma db sign`.
### `stored-values-a-column-type-does-not-hold`
A codec now reads each value a query returns with one method, `fromWire`, in a plain read and in an include alike, and refuses a value its column's type does not hold. A query that returned such a value before now throws `RUNTIME.DECODE_FAILED`:
| Column | Value | Message |
| --- | --- | --- |
| SQLite `INTEGER` (`sqlite/integer@1`, `sql/int@1`) | text that is not a number, or bytes | `sqlite/integer@1 wire value must be an integer or its decimal text` |
| SQLite `REAL` (`sqlite/real@1`, `sql/float@1`) | text other than `Infinity` and `-Infinity`, or bytes | `sqlite/real@1 wire value must be a number, or the text Infinity or -Infinity` |
| SQLite `BLOB` (`sqlite/blob@1`) | text or a number | `sqlite/blob@1 wire value must be bytes, or the array of their hex text an include carries` |
| PostgreSQL `text[]` (`pg/text-array@1`) | an array of two or more dimensions | `pg/text-array@1 reads a one-dimensional text[], and {{a},{b}} has more dimensions` |
SQLite stores any value in any column of a table that is not `STRICT`, so these values come from writes outside the application. Find them, for example with `SELECT id FROM t WHERE typeof(col) NOT IN ('integer', 'null')`, and correct them, or read the column with a codec of the type it holds.
### `sqlite-datetime-without-offset-read-as-utc`
A SQLite `DateTime` column (`sqlite/datetime@1`) can hold date and time text without a UTC offset, such as `2024-01-01 12:00:00` or `2024-01-01`, when another tool wrote it: `CURRENT_TIMESTAMP` in SQL, or a client library that writes local text. Prisma writes `toISOString()` text, which ends in `Z`, so a row Prisma wrote reads as before.
Such a row is now read as UTC, as SQLite's `datetime()` and `strftime()` read it. Before, it was read in the time zone of the machine running the query, so the same row gave a different `Date` on a server in UTC and on one in another zone. A date alone is read as its midnight in UTC. Every value that was read before is still read; only the instant of text without an offset changes, and it changes only on a machine not set to UTC.
If such text was written meaning local time, rewrite it with its offset, or as UTC. To find these rows:
```sql
SELECT id, created_at FROM "Event"
WHERE typeof(created_at) = 'text'
AND created_at NOT LIKE '%Z'
AND created_at NOT GLOB '*[+-][0-9][0-9]:[0-9][0-9]';
```
### `text-array-min-max-return-arrays`
`min` and `max` of a column whose codec is `pg/text-array@1` return what a row of the column returns, an array of strings, where they returned the text PostgreSQL prints for the array: `['c']` instead of `'{c}'`. Code that parsed that text reads the array directly.
### `sign-databases-after-data-type-values`
The upgrade gives every SQLite contract with a datetime or JSON column a new storage hash, and so does `prisma contract emit` for a PostgreSQL contract it rewrites. Each database's marker still holds the old hash, so until you sign it `prisma db migrate` refuses to run with `MIGRATION.MARKER_MISMATCH` and `prisma db verify` exits with code 4 and `CONTRACT.MARKER_MISMATCH`. Run `prisma db sign` against every database (development, staging, production) before you deploy the application built with the new contract:
```sh
prisma contract emit
prisma db sign --db "$DATABASE_URL"
```
A database that has not applied every migration is refused by a plain `prisma db sign`. Sign it with `prisma db sign --contract <the last migration it applied>`, then migrate it, as `stored-contracts-drop-nocheck` describes. `data-type-values.ts` prints its `<old> -> <new>` list in the same form.
## Client generators are written `@onCreate(...)` and `@onUpdate(...)`
Prisma 8 PSL now says who writes a value. `@default(...)` is a database default: the database fills the column when an insert leaves it out. `@onCreate(fn())` and `@onUpdate(fn())` are values the client generates, on create and on every update. A function resolves for the column's codec, so `uuid()` works on `String`, `VarChar(n)`, `Char(n)` and `Uuid`, and `now()` gives each date and time type the value its codec takes.
```prisma
// Before
model Post {
id String @id @default(uuid(7))
slug String @default(nanoid(12))
createdAt DateTime @default(now())
}
// After
model Post {
id String @id @onCreate(uuid(7))
slug String @onCreate(nanoid(12))
createdAt DateTime @default(now())
}
```
`@default(now())` and `@default(autoincrement())` do not change. A column may have both: `createdAt DateTime @default(now()) @onCreate(now())` makes the client write its time on create and leaves the database default for other inserts.
### `client-functions-move-from-default-to-on-create`
Commit your work first, so the change can be reviewed and undone with git. Run the script from the project root with every `.prisma` file git tracks as arguments:
```sh
git ls-files -z '*.prisma' | xargs -0 node <guide>/scripts/default-generators-to-on-create/rewrite-default-generators.mjs
```
`<guide>` is the directory that holds this guide. Pass every file, not only the schema `contract emit` reads: a migration directory can hold a copy of the schema, such as `migrations/app/<migration>/intermediate.prisma` or a `contract.prisma` beside a migration, and the CLI reads those too. The script reads and rewrites only the files it is given, and with no file it prints its usage and exits 1. For each one it prints how many calls it rewrote, or that it left the file unchanged. It rewrites every `@default(uuid(...))`, `@default(cuid(...))`, `@default(ulid())` and `@default(nanoid(...))` to `@onCreate(...)`, keeping the call exactly as written, spaces included, and changes nothing else. It leaves string literals and `//` comments unchanged and does not count them, so rewrite a commented-out field yourself if you want it in step.
A Prisma 7 `schema.prisma` that your config reads with `prisma7Schema(...)` keeps `@default(uuid())`, and the Prisma 7 reader turns it into the same generator. Passing it is safe: the script rewrites only a file that has the `// use prisma-8` directive (or the older `// use prisma-next`), read by the same rule `contract emit` uses. It leaves every other file unchanged and prints a line for it, so a Prisma 7 schema split across files is safe even when a file holds only models.
The contract does not change: `@onCreate(uuid())` lowers to the generator `@default(uuid())` lowered to, so `prisma contract emit` writes the same `contract.json` and hashes, and nothing needs to be signed or migrated.
In a TypeScript contract, `field.generated(...)` keeps working for now. Its replacement is `.onCreate(...)` with a call from `gen`, which this release adds:
```ts
import { field, gen } from '@prisma/orm-postgres/contract-builder';
const id = field.column(textColumn).id().onCreate(gen.uuid(7));
const updatedAt = field.column(timestamptzColumn).onCreate(gen.now()).onUpdate(gen.now());
```
### `contract-print-writes-generator-attributes`
`contract print` writes each generated column as its type followed by the generator attributes. A `temporal.timestamptz(3, onCreate: now, onUpdate: now)` field prints as `Timestamptz(3) @onCreate(now()) @onUpdate(now())`, and `@default(uuid())` prints as `@onCreate(uuid())`. Both read back as the same contract.
## Generator arguments are checked, and a generated value must fit its column
### `prisma7-default-function-arguments-checked`
A Prisma 7 schema the reader used to accept with a changed meaning is now refused:
```prisma
// Before: read as a version 4 UUID, a CUID2 identifier, and a 1-character Nano ID
id String @id @default(uuid(5))
code String @default(cuid(1))
short String @default(nanoid(1))
named String @default(nanoid(size: 8))
whole String @default(uuid(4.0))
// After: write what the generator makes
id String @id @default(uuid(4))
code String @default(cuid(2))
short String @default(nanoid(2))
named String @default(nanoid(8))
whole String @default(uuid(4))
```
Each refusal is `PSL.PRISMA7_UNKNOWN_DEFAULT`, reported at the argument, with the function's message: `Field "User.id": uuid: version must be 4 or 7, got 5`. `cuid()` with no argument still reads as `cuid(2)`. Pick the value that keeps what your application stores today: `uuid(5)` made version 4 UUIDs, `cuid(1)` made CUID2 identifiers.
### `generator-function-argument-diagnostics`
Code or tests that match the diagnostics of a Prisma 8 schema read the new code and messages:
```ts
// Before
expect(diagnostic).toMatchObject({ code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', message: 'Expected one of: 4 | 7' });
// After
expect(diagnostic).toMatchObject({
code: 'PSL_INVALID_GENERATOR_ARGUMENT',
message: 'uuid: version must be 4 or 7, got 5',
});
```
The messages are `uuid: version must be 4 or 7, got <n>`, `cuid: version must be 2, got <n>` and `nanoid: size must be between 2 and 255, got <n>`. An argument that is not written as a whole number, such as `nanoid(8.5)` or `nanoid("8")`, is `PSL_VALUE_TYPE_INCOMPATIBLE`, as for any value of the integer type.
### `generator-function-argument-with-fraction`
```prisma
// Before: read as nanoid(8) and uuid(7)
slug String @default(nanoid(8.0))
id String @id @default(uuid(7.0))
// After
slug String @onCreate(nanoid(8))
id String @id @onCreate(uuid(7))
```
### `generator-fits-column`
```prisma
// Before: emitted, then on Postgres every insert failed with "value too long"
code Char(5) @default(nanoid())
// After
code Char(21) @onCreate(nanoid())
code Char(5) @onCreate(nanoid(5))
```
The lengths are: `uuid` 36, `ksuid` 27, `ulid` 26, `cuid(2)` 24, `nanoid()` 21 and `nanoid(n)` n. A column's length is read in its type's normal form, so a bare `Char` is `Char(1)` and a bare `VarChar` has no limit.
### `discriminator-must-be-text`
This applies to SQL schemas only; a Mongo schema's discriminator message is unchanged.
```ts
// Before
'Discriminator field "type" on model "Task" must be of type String, but is "Int"'
// After
'Discriminator field "type" on model "Task" must be text, such as String, but is "Int"'
```
## Re-emit contracts that use a string timestamp preset
This is `string-timestamp-presets-write-text`.
On Postgres, a field written `temporal.timestampString(3, onCreate: now, onUpdate: now)`, `temporal.updatedAtString()` or another `temporal.*String` preset is filled by a generator that writes text, such as `2024-01-02 03:04:05.678` or `2024-01-02 03:04:05.678+00`. Before, it was filled by `timestampNow`, which produced a `Date`. A Prisma 7 schema read through `prisma7Schema(...)` gets the same preset for each `@updatedAt` field.
The generator's id is in the execution section of `contract.json`, so the execution hash changes. Run `prisma contract emit` again and commit the new `contract.json` and `contract.d.ts`. The storage hash does not change, and the database marker holds only the storage and profile hashes, so there is no migration to plan and no need to run `prisma db sign`.
## A string timestamp column takes text, not a `Date`
This is `string-timestamp-codecs-take-text-only`.
`TimestampString(p)` and `TimestamptzString(p)` columns take and return text, and the ORM types for them were already `string`, so code that compiled before still compiles. What changes is at runtime: their codecs used to convert a `Date` that reached them, and now they refuse it with `RUNTIME.ENCODE_FAILED`. A `Date` reaches them only through a cast, from JavaScript, or from a contract emitted before this change, whose generator still produced a `Date`, so re-emit the contract first.
```ts
// Before: compiles only through a cast, and was converted at runtime
await db.orm.public.Post.create({ title: 'Hello', publishedAt: new Date() as unknown as string });
// After
await db.orm.public.Post.create({ title: 'Hello', publishedAt: new Date().toISOString() });
```
A direct call of the codec's `encode` with a `Date` is now a type error as well.
To work with `Date` values, you can change the column to `TimestamptzJsDate(p)` (`temporal.timestamptzJsDate(...)` as a preset). That changes the column type to `timestamptz`, so it needs a migration: plan and apply one with `prisma migration plan` and `prisma db migrate` in step 8 of "Order of the steps", and check how existing `timestamp` values convert, because Postgres reads them in the session's time zone.
## Type-constructor arguments are values of data types
### `type-constructor-arguments-are-values`
Write each number argument of a type constructor as a number, positional or named. `VarChar("255")` becomes `VarChar(255)`, `VarChar(length: "255")` becomes `VarChar(length: 255)`, and `Numeric("10", "2")` becomes `Numeric(10, 2)`.
A length, precision, scale or SRID on Postgres is a 32-bit integer, so a number written with a fraction is refused, even when the fraction is zero: `VarChar(255.0)` becomes `VarChar(255)`, and `Timestamptz(3.0)` becomes `Timestamptz(3)`. A number larger than 2147483647 is refused too; write the value the column needs.
On SQLite, the length of `sql.String` is a value of `sqlite/real`, so `sql.String(2.0)` is accepted and gives the length `2`. Write `sql.String(2)` anyway, so the schema reads the same on every target. The detection matches `sql.String(2.0)` on every target for this reason. A SQLite length is at most 2147483647, SQLite's largest string length.
## Enums refuse more codecs and read members as values of their data type
### `more-codecs-refused-for-enums`
`defineContract` refuses an enum typed by one of these codecs, for example:
```text
CONTRACT.ENUM_INVALID: enumType("Payload"): an enum cannot use the codec pg/jsonb@1. A CHECK lists a string member as SQL text, which PostgreSQL reads as the JSON text of a document rather than as a string, and a number member as a number, which PostgreSQL cannot compare with jsonb, so no CHECK can compare a value with the members. No enum can use a jsonb codec; use a text enum instead.
```
PSL refuses an enum block with `@@type` naming one of these codecs with the same reason, as `PSL_EXTENSION_INVALID_VALUE`.
1. For a `pg/enum@1` enum, declare a native enum: a `native_enum` block in PSL, or `nativeEnum` in TypeScript.
2. For any other refused enum, type it with `pg/text@1`: change the `@@type` in PSL, or pass `{ codecId: 'pg/text@1' }` (or `textColumn`) to `enumType` in TypeScript. Write each member as text.
3. Re-emit the contract. The enum's columns change type, and with them the storage hash. Plan and apply a migration, which changes each column's type.
The TypeScript detection matches a file that calls `enumType(` and names one of these codecs or their column helpers anywhere, even on a column that is not an enum. Check only the enums.
### `psl-enum-members-read-by-the-cast-rule`
`contract emit` refuses a quoted member on a codec whose data type has no cast from text:
```text
enum "Ratio" member "Half": Expected a number; write 0.5
```
Rewrite each refused member as the message says, then emit the contract again. The stored members are the same, so the storage hash does not change and no migration is needed.
```prisma
// before
enum Ratio {
@@type("pg/numeric@1")
Half = "0.5"
Whole = "1.50"
}
// after
enum Ratio {
@@type("pg/numeric@1")
Half = 0.5
Whole = 1.50
}
```
### `enum-members-compare-by-their-data-type`
1. If the contract build refuses two members as one value, such as `enum "Ratio": members "Half" and "Halves" store "0.5" and "0.50", which the database holds equal`, remove one of them. The database already held them equal, so a row could never tell them apart.
2. Re-emit the contract.
3. Typecheck the project. Code that reads such an enum column as a member literal type, such as `'0.5' | '1.50'`, now reads the codec's type; compare the value with a member through `db.enums.<namespace>.<Enum>.has()` or `nameOf()`, which find `0.50` as `0.5`.
The TypeScript detection matches a file that calls `enumType(` and names one of these codecs or `numericColumn(` or `intervalColumn(` anywhere, even on a column that is not an enum. Check only the enums.
### `enum-columns-typed-by-member-literals`
Re-emit the contract. Then typecheck the project. Where a write to an enum column, or a comparison with a value read from one, no longer typechecks, the value is not a member; use a member instead. A test that writes a non-member on purpose, to see the CHECK constraint refuse it, can write that value `as never`.
## Column types are named as the database prints them
### `db-schema-json-names-type-text`
A script that reads column types from `prisma db schema --json` reads `typeText`:
```sh
# Before
prisma db schema --json | jq '.schema.tables[].columns[].nativeType'
# After
prisma db schema --json | jq '.schema.tables[].columns[].typeText'
```
The value is the type as introspection reads it from the database: on Postgres the text `format_type` prints (`integer`, `character varying(20)`), not the short names (`int4`, `varchar(20)`) earlier releases printed. It differs from what the database prints in three cases:
- On SQLite, the declared type is lower-cased: a column declared `VARCHAR(255)` reads `varchar(255)`.
- On Postgres, an enum is named as a contract names it: `My Mood` where `format_type` prints `"My Mood"`, and `app.status` for an enum outside `public`.
- On Postgres, a type in a schema that is not on the search path keeps its schema, as `format_type` prints it: `extensions.vector(3)` for a pgvector column when the `extensions` schema is not on the search path, and `vector(3)` when it is.
### `default-invalid-meta-type-text`
Code that reads the column's type from a `CONTRACT.DEFAULT_INVALID` error reads `meta.typeText`:
```ts
// Before
if (error.code === 'CONTRACT.DEFAULT_INVALID') console.log(error.meta.nativeType);
// After
if (error.code === 'CONTRACT.DEFAULT_INVALID') console.log(error.meta.typeText);
```
## A client takes a typed contract value
A client no longer takes `contractJson`, and it needs no type argument. The emitted JSON becomes a typed value in one call, and every client infers its contract type from that value:
```ts
// Before
import postgres from '@prisma/orm-postgres/runtime';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
export const db = postgres<Contract>({ contractJson, url: process.env['DATABASE_URL']! });
// After
import postgres from '@prisma/orm-postgres/runtime';
import { contractFromJson } from '@prisma/orm-postgres/static';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
export const contract = contractFromJson<Contract>(contractJson);
export const db = postgres({ contract, url: process.env['DATABASE_URL']! });
```
A contract value is validated once, however many clients are built from it. A contract written in TypeScript with `defineContract` is already a value: keep passing it as `{ contract }`.
### Run the script
Run `node <guide>/scripts/client-takes-contract-value/contract-from-json.ts <project root>`. The script reads source files with the TypeScript compiler from the project's `typescript` package; if the project has none, it changes nothing, says so and exits 1. It skips `node_modules`, `dist`, `build` and every directory whose name starts with a dot, such as `.git`, `.agents` or `.claude`; move a client in such a directory by hand. In each source file that passes `contractJson` to a client, static entry or `mongoQuery`, it:
- adds `import { contractFromJson } from '<facade>/static';`, or adds `contractFromJson` to an existing import of that entry, in the file's quote, semicolon and line-ending style;
- declares `export const contract = contractFromJson<Contract>(contractJson);` after the imports. In a module with a default export, such as a Worker entry, it declares `const contract` without `export` and prints a note: a Worker treats every named export of its entry module as an entrypoint, so a module that needs the value gets it from a separate module (see step 3 below);
- removes the type argument from the call and replaces the `contractJson` property with `contract`;
- where the call's result is destructured with `contract` in it, as in a `migration.ts` (`const { sql: db, contract } = postgres<End>({ contractJson: endContract })`), removes `contract` from the destructuring, because the declared value is that contract: `const { sql: db } = postgres({ contract })`.
It prints each file it moved. It prints to stderr, with the reason, each file that passes `contractJson` but that it did not move, and then exits 1: a test file, a file that already has another binding named `contract` (rename that binding and run the script again), a file whose clients use different contracts, a file whose `contractJson` value uses a binding the file declares after its imports, a file whose `contractJson` value uses a parameter or local binding of the function that creates the client (pass `contract: contractFromJson<Contract>(<value>)` to that call instead; the message gives the exact text), and a call it cannot read. Move those by hand in the same way.
### Then, by hand
1. **Tests and other modules.** Where a test or another module builds a client or static entry from `contractJson`, import the exported `contract` from the module that creates the client instead of calling `contractFromJson` again:
```ts
// Before
const db = postgres<Contract>({ contractJson, url });
// After
import { contract } from '../src/prisma/db';
const db = postgres({ contract, url });
```
2. **`migration.ts` files.** The script moves a `migration.ts` that builds a client over a contract snapshot, such as `const { sql: db, contract } = postgres<End>({ contractJson: endContract })` for a data transform. Move by hand only a `migration.ts` the script refuses, with the text its message gives. Move every such file, so that `node migration.ts` still runs when you write its `ops.json` again (step 7 of the order above). `prisma db migrate` applies `ops.json` and does not load `migration.ts`, so an applied migration keeps working either way.
3. **Browser modules.** If a module that browser code imports (for example a file a `'use client'` component imports, to read `enums`) also needs the contract, it must not import the module that creates the client, because that module loads the database driver. Move the declaration into a module that imports no driver, and import it from both:
```ts
// src/contract-value.ts
import { contractFromJson } from '@prisma/orm-mongo/static';
import type { Contract } from './contract';
import contractJson from './contract.json' with { type: 'json' };
export const contract = contractFromJson<Contract>(contractJson);
```
```ts
// src/db.ts
import mongo from '@prisma/orm-mongo/runtime';
import { contract } from './contract-value';
// src/enums.ts
import mongoStatic from '@prisma/orm-mongo/static';
import { contract } from './contract-value';
export const enums = mongoStatic({ contract }).enums;
```
4. **Several clients from one contract**, such as two serverless clients in a Worker, take the same `contract` value; the script already gives a file one value for all its clients.
5. **Types.** The option types `PostgresOptionsWithContract`, `PostgresOptionsWithContractJson`, `PostgresServerlessOptionsWithContract`, `PostgresServerlessOptionsWithContractJson`, `SqliteOptionsWithContract`, `SqliteOptionsWithContractJson`, `MongoOptionsWithContract`, `MongoOptionsWithContractJson`, `SupabaseOptionsWithContract` and `SupabaseOptionsWithContractJson` are removed. Use `PostgresOptions<Contract>`, `PostgresServerlessOptions<Contract>`, `SqliteOptions<Contract>`, `MongoOptions<Contract>` or `SupabaseOptions<Contract>`.
6. **Docs and comments** in the project that show `postgres<Contract>({ contractJson })` or another client called with `contractJson`: rewrite them to the new form.
## Custom collection classes go on the client
This is `collections-on-the-client`.
`postgres()`, `postgresServerless()`, `sqlite()` and `supabase()` take an option, `collections`, with the same type `orm({ collections })` takes. The client types are inferred from it, and the classes reach every ORM the client offers:
```ts
// Before
import { orm } from '@prisma/orm-postgres/orm-client';
import { db } from './prisma/db';
export const posts = orm({
runtime: db.runtime(),
context: db.context,
collections: { Post: PostCollection },
}).public.Post;
// After, in the module that creates the client
export const db = postgres({ contract, url, collections: { Post: PostCollection } });
await db.orm.public.Post.published().all();
await db.transaction(async (tx) => tx.orm.public.Post.published().forUpdate().all());
```
1. Pass the classes as `collections` where the client is created. If the module that defines the classes imports the client's module, import only the `Contract` type there (`import type { Contract } from './contract.d'`), so the two modules do not import each other.
2. Delete the hand-built ORM client (for example a `createOrmClient(runtime)` helper) and every `orm({ runtime, context, collections })` that only existed to reach the classes. Use `db.orm` instead. On SQLite, `db.orm` is the model collections themselves: `db.orm.Post`.
3. A function that took a `runtime` only to build an ORM no longer needs it. Inside a transaction, pass `tx.orm`, which has the classes too.
4. On a serverless client, the classes are on every connection: `await using db = await postgres.connect({ url }); db.orm.public.Post.published()`.
5. On Supabase, the classes are on `asUser`, `asAnon` and `asServiceRole` clients. Supabase's internal `.supabase` ORM does not take them.
A type written with only the contract, such as `PostgresClient<Contract>`, still describes a client without classes and accepts a client created with them. To name a client with classes, give the second type parameter: `PostgresClient<Contract, { Post: typeof PostCollection }>`.
## A client runs plans and renders SQL
This is `client-query-and-to-sql`.
Every SQL client now runs a plan itself and renders the SQL a plan would send:
```ts
const plan = db.sql.public.post.select('id', 'title').limit(10).build();
console.log(db.toSql(plan)); // { sql: 'SELECT … LIMIT 10', params: [] }
for await (const row of db.query(plan)) console.log(row);
await db.execute(db.raw.sql`UPDATE post SET pinned = false`.affectedCount().build());
```
`db.query(plan)` and `db.execute(plan)` behave as `db.runtime().query(plan)` and `db.runtime().execute(plan)` do, and have the types `tx.query` and `tx.execute` have. Replace `db.runtime().query(` with `db.query(` and `db.runtime().execute(` with `db.execute(`. Keep `db.runtime()` where the code needs `connection()`, `close()` or `telemetry()`. On Supabase, call them on a role-bound client, as before.
`toSql(plan)` needs only the adapter and the contract, so it works with no database. `params` are the plan's values before codecs encode them. Middleware rewrites (`beforeCompile`) are not applied. A plan with `prepare` bind sites throws `RUNTIME.PREPARE_BIND_ON_ADHOC`, and a plan built for another contract throws `PLAN.HASH_MISMATCH`, as running it would.
The message of `RUNTIME.PREPARE_BIND_ON_ADHOC` changed; its code did not. Code that matches the old message text ("Ad-hoc execute received a bind-site slot") must match the code instead. The details of `PLAN.TARGET_MISMATCH` and `PLAN.HASH_MISMATCH` rename `runtimeTarget` to `contractTarget` and `runtimeStorageHash` to `contractStorageHash`, and their messages changed; match the codes.
Code that only reads these types needs no change. Code that creates an object of one of them, for example a test double typed `PostgresClient<Contract>`, must add the new members:
| Type | New members |
| --- | --- |
| `PostgresClient`, `PostgresServerlessConnection` (`@prisma/orm-postgres/runtime`, `/serverless`) | `query`, `execute`, `toSql` |
| `PostgresServerlessClient`, `PostgresStaticContext`, `PostgresStaticMembers` | `toSql` |
| `PostgresQueryMembers` | `query`, `execute`, `toSql` |
| `SqliteClient` (`@prisma/orm-sqlite/runtime`) | `query`, `execute`, `toSql` |
| `SqliteStaticContext` | `toSql` |
| `SqliteQueryMembers` | `query`, `execute`, `toSql` |
| `PostgresTransactionContext`, `SqliteTransactionContext`, `SupabaseTransactionContext` | `toSql` |
| `RoleBoundDb`, `ServiceRoleDb` (`@prisma/orm-extension-supabase/runtime`) | `toSql`; `query` and `execute` now take the plan type every SQL client shares |
| `SupabaseInternalDb` (the service role's `supabase` member) | `toSql`, over the Supabase contract |
| `SqliteStaticMembers` (`@prisma/orm-sqlite/runtime`) | new: the static context plus `stack`, which `SqliteClient` now extends |
`supabase()` no longer creates its `pg.Pool`, driver or runtimes when it is called. It creates them on the first query, so creating a client with a URL opens nothing. It is still `async` and still requires `jwtSecret` or `jwksUrl`. `close()` now ends only a pool the client created from `url`; a `pg.Pool` passed in with the `pg` option stays open, as with `postgres()`, and the caller ends it.
The new `mode` option, typed `RuntimeMode`, is `'strict'` by default, which is how every client ran before. Under `'permissive'`, a `budgets()` violation whose severity is `'warn'` goes to `log.warn` and the query runs.
## `using` is a member of every collection
This is `orm-collection-member-using-reserved`.
A collection's `using` member holds the collection methods that packages contribute for the model. The Postgres target contributes one for each full-text index, so `db.orm.public.Post.using.post_search(q)` filters to the posts that match `q` and orders them by relevance, and pgvector contributes one for each vector field, such as `db.orm.public.Post.using.embedding_nearest(v)`.
A custom collection class registered with `orm({ collections })` can no longer declare its own `using`, whether as a method, a getter or a property. TypeScript refuses it because the base class defines `using` as an accessor. Rename the member, and its callers:
```ts
// Before
class PostCollection extends Collection<Contract, 'Post'> {
using(tag: string) {
return this.where({ tag });
}
}
// After
class PostCollection extends Collection<Contract, 'Post'> {
withTag(tag: string) {
return this.where({ tag });
}
}
```
## ORM calls are activities
This section covers `orm-annotations-on-the-call`, `cached-orm-reads-key-on-the-call`, `cache-store-holds-cached-values`, `orm-needs-a-runtime-queryable` and `close-lets-open-orm-calls-finish`. Their summaries above say what to do for the entries that have no section of their own.
Each SQL ORM terminal method is one `orm-call` activity. Its statements, its own transaction and the read-back after a nested write are its children, and the user's annotations are on it, not on the statements.
### Read ORM annotations from the call
Before:
```ts
const audit: SqlMiddleware = {
name: 'audit',
familyId: 'sql',
async beforeEncode(plan) {
const actor = auditAnnotation.read(plan);
if (actor) log(actor);
},
};
```
After:
```ts
const audit: SqlMiddleware = {
name: 'audit',
familyId: 'sql',
async activityStarted(activity) {
if (activity.kind !== 'orm-call') return;
const actor = auditAnnotation.read(activity);
if (actor) log(actor);
},
};
```
A middleware that must also see annotated SQL builder queries keeps its statement path: those queries are statement activities and carry their annotations on the plan as before.
### Custom cache stores
Change the value type from `CachedRows` to `CachedValue`:
```ts
import type { CachedValue, CacheStore } from '@prisma/orm-extension-middleware-cache';
const store: CacheStore<unknown, CachedValue> = {
// get, set and unset as before; value is now { kind: 'statement-rows', rows } or { kind: 'call-result', result }
};
```
## A TypeScript contract has the same types as an emitted one
This section covers `typescript-contract-include-row-types`, `typescript-contract-aggregates-and-operations`, `typescript-contract-parameterized-output-types`, `orm-filters-and-writes-take-input-types`, `typescript-contract-index-references`, `typescript-contract-storage-and-domain-types`, `emitted-contract-types-from-generics` and `typescript-contract-enums-in-domain`.
This applies to a contract built with `defineContract` from TypeScript, used without an emitted `contract.d.ts`. Its types now match what `prisma contract emit` would write for it. One change, filters and writes taking input types, applies to emitted contracts too.
### Included rows have precise types
The ORM typed every included relation as an array of `unknown` rows. It now reads each relation's target model and cardinality from the contract:
| Relation | Included value |
| --- | --- |
| `rel.hasMany`, `rel.manyToMany` | An array of the target model's rows |
| `rel.belongsTo` | One row, or the row or `null` when the relation is optional or any `from` field is optional |
| `rel.hasOne` | The row or `null` |
A cast that agrees with the new type still typechecks and can be removed. A cast that disagrees now fails to typecheck, and shows a real mismatch. A to-one relation was never an array at runtime, so fix the code that read it as one:
```diff
const post = await db.orm.Post.include('author').first();
- const author = (post?.author as User[])[0];
+ const author = post?.author;
```
Include refinements now see the target model's fields, so `where`, `select` and `orderBy` inside them need no cast.
A relation to a model in another contract space keeps the general type, as before.
### Aggregates, operations and capabilities are typed
Aggregates, field operations and adapter capabilities were missing from the types, so code dispatched aggregates by name through a cast. Call them directly:
```diff
- const stats = await db.orm.Post.aggregate((aggregate) => {
- const dynamic = aggregate as Record<string, (field?: string) => AggregateSpec[string]>;
- return { total: dynamic['sum']!('views') };
- });
+ const stats = await db.orm.Post.aggregate((aggregate) => ({ total: aggregate.sum('views') }));
```
The same holds for reducers inside `include(...)`, such as `posts.count()`. The aggregates come from the aggregate descriptors of the target, the adapter and every extension, as `prisma contract emit` collects them. Aggregates contributed only by the execution stack, not by a pack in the contract, are still not in the contract's types and still need the cast.
The contract's `capabilities` now include the adapter's in its value as well as in its type, and record only enabled capabilities, as an emitted `contract.json` does. Code that read `contract.capabilities` before an execution context existed now sees the adapter's capabilities.
### Columns with type parameters keep them
A field whose column has type parameters now has the parameterized output type. This includes the field presets:
```ts
const contract = defineContract({}, ({ field, model, type }) => ({
models: {
Reading: model('Reading', {
fields: { id: field.id.uuidv4String(), label: field.column(type.sql.String(50)) },
}),
},
}));
// row.id: Char<36> (was string)
// row.label: Varchar<50> (was string)
```
`Varchar<N>`, `Char<N>`, `Numeric<P, S>` and the others are branded strings exported from `@prisma/orm-postgres/contract-builder` and `@prisma/orm-postgres/target/codec-types`, so code that reads them as `string` keeps working. Update a type assertion or an annotation that expects plain `string` for such a value read from a row to the branded type, or widen the value with `String(value)`.
### Filters and writes take plain strings
For emitted contracts and contracts built with `defineContract`, the ORM types filters and writes with each field's input type. A field read as `Char<36>` takes a plain `string` in `where`, in field predicates, in `whereUnique`, and in `create`, `update` and `upsert`. Remove the casts that made a plain string fit:
```diff
- await db.orm.User.where({ id: idFromRequest as Char<36> }).first();
- await db.orm.User.create({ id: '123e4567-e89b-12d3-a456-426614174000' as Char<36>, email });
+ await db.orm.User.where({ id: idFromRequest }).first();
+ await db.orm.User.create({ id: '123e4567-e89b-12d3-a456-426614174000', email });
```
A field whose codec accepts more forms on input than it returns on output, such as a temporal codec that takes a string or a `Date`, now accepts those forms in filters too. A `CodecField` fragment checks values against the codec's input type, as the model's own fields do.
### Index references name the declared indexes
In a `where` or `orderBy` callback, `indexes` now has one entry per index of the model's table, as with an emitted contract: an index from `.sql({ indexes })` under its `name:` or `map:`, or under `<table>_<columns>_idx` when it has neither, and the backing index of a foreign key under `<table>_<columns>_idx`. Before, any name typechecked and a full-text index could not be passed to `fullTextMatches` or `fullTextRank`. A name that is not an index of the table now fails to typecheck:
```ts
db.orm.public.Document.where((_doc, { fns, indexes }) =>
fns.fullTextMatches(indexes.documents_search, websearchToTsquery(query)),
);
```
The backing index of a foreign key is left out, in the build and in the type, when a key or a plain index on the same columns already serves it, or when the foreign key or the contract's `foreignKeyDefaults` sets `index: false`. A partial or typed index on those columns does not serve it. The build shortens a backing-index name to 54 bytes, and the type does the same. If that name has a character outside ASCII, the type cannot count its bytes and leaves the index out; give the foreign key's columns a declared index with `name:` to read it.
### Unique criteria, enums and junction columns
`whereUnique` on a contract built with `defineContract` now accepts only the fields of the primary key or of one unique constraint, from `.unique()` fields and the `uniques` attribute. Before, any record typechecked. A criterion that named a field outside every unique key now fails to typecheck; it failed at run time before.
`db.enums` lists only the enums the contract declares. A contract without enums offered an accessor under any name before.
A many-to-many relation whose target has no primary key now types its junction's target columns from the target's unique key, as the build does.
### Exporting a contract with declaration emit
An application that exports a contract built with `defineContract` and builds with `declaration: true` no longer gets TS2742, TS4023 or TS4094 errors when the contract uses pgvector, PostGIS or ParadeDB, or a field preset. Remove type annotations or workarounds added only to make declaration emit pass, such as an `import type {} from '@prisma/orm-extension-postgis/geojson'` line.
### Emitted contract types use the same generics
Re-run `prisma contract emit` and commit the new `contract.d.ts`. Leave the `contract.d.ts` files in migration snapshots as they are. The file now types fields and storage columns with the generics a contract built with `defineContract` uses, and writes out only the types a codec computes when emitting, such as the literal union of an enum's values. A column with type parameters reads as before, for example `Char<24>`, through `ParameterizedOutputTypeFunctions`. No code needs to change. The next section, `emitted-field-types-from-shared-generic`, covers the same re-emit for MongoDB contracts.
### Enums of a TypeScript contract
A contract built with `defineContract` no longer has an `enumAccessors` key in its type. Read enum accessors from `db.enums`, or name their type with `NamespacedEnums<typeof contract>`:
```diff
- type Accessors = (typeof contract)['enumAccessors'];
+ type Accessors = NamespacedEnums<typeof contract>['public'];
```
`db.enums.<namespace>.<Enum>.has(value)` now takes any value and narrows it to the enum's values, as it does for an emitted contract.
An `enumType` keeps its codec id as a literal type, as the emitted contract does, so a field typed by it now has its codec's query methods: `where((i) => i.role.eq('admin'))`, `i.role.ilike('ad%')`, `i.priority.gt(1)` and `orderBy((i) => i.priority.asc())` compile where they failed before with "Property 'eq' does not exist". Remove the casts or raw SQL that worked around this.
## Emitted field types name the shared generic; Mongo `defineContract` rows match emitted ones
This section covers `emitted-field-types-from-shared-generic`, `mongo-define-contract-readonly-rows`, `mongo-define-contract-factory-field-types`, `value-object-enum-fields-narrow` and `mongo-update-accessors-take-input-types`. Their summaries above say what changes for each.
Re-emit every contract with `prisma contract emit`, and leave the `contract.d.ts` files in migration snapshots as they are. The committed `contract.d.ts` changes form, not meaning: `FieldOutputTypes`, `FieldInputTypes` and the value object types are now applications of `FieldTypesFromDomain` and `ValueObjectTypesFromDomain`, with only the types a codec computes at emit time written out in `FieldTypeOverrides`. A SQL `contract.d.ts` written by a development build after 8.0.0-rc.18 imports `FieldTypesFromDomain` from the family contract types entry, which no longer exports it; it fails to compile until you re-emit it. This is the same re-emit as `emitted-contract-types-from-generics`.
For a Mongo contract written with `defineContract`, rows now have the types an emitted contract gives:
```ts
// before
const tags: string[] = user.tags;
// after: lists are readonly, as in an emitted contract
const tags: readonly string[] = user.tags;
const copy: string[] = [...user.tags];
```
A vector field with a length, such as `field.vector({ typeParams: { length: 3 } })`, reads as `Vector<3>`, which is still a `readonly number[]`.
## MongoDB fields keep their own names
### `mongo-contracts-record-stored-field-names`
For this schema:
```prisma
model User {
id ObjectId @id @map("_id")
displayName String @map("display_name")
@@map("users")
}
```
the model's entry in `contract.json` changes like this:
```diff
"User": {
"fields": {
- "_id": { ... },
- "display_name": { ... }
+ "id": { ... },
+ "displayName": { ... }
},
"relations": {},
- "storage": { "collection": "users" }
+ "storage": {
+ "collection": "users",
+ "fields": { "displayName": { "field": "display_name" }, "id": { "field": "_id" } }
+ }
}
```
Relations name fields too: `"on": { "localFields": ["authorId"], "targetFields": ["id"] }`, where they named `_id`. So does a base model's `discriminator`. Indexes, validators and `execution` keep naming stored names, so neither the storage hash nor the execution hash changes. The documents in the database do not change.
1. Commit your work first, so the changes can be reviewed and undone with git.
2. Run `prisma contract emit` and commit the regenerated `contract.json` and `contract.d.ts`.
3. Run the colocated script from the project root:
```sh
node <guide>/scripts/mongo-field-storage-names/mongo-field-storage-names.ts
```
Node 24 or later runs the TypeScript script directly. It reads and writes files only. It rewrites every `*.json` file under the root that is a MongoDB contract with a model whose `storage` has no `fields`, skipping `node_modules`, `dist`, `build`, every directory whose name starts with a dot, such as `.git`, `.agents` or `.claude`, and every link it cannot follow, and the `contract.d.ts` next to it (`<name>.d.ts` next to `<name>.json`). These are the contract snapshots under `migrations/snapshots/<hash>/`, which no command regenerates, and any other contract you keep in the old form, such as a test fixture; if you keep one on purpose, restore it with git afterwards. A contract that step 2 emitted is already in the new form and is left alone. The script prints each file it rewrote, or that nothing changed.
A snapshot cannot be rebuilt as a re-emit would write it, because the old contract does not say what the schema called the field (`_id` could have been `id @map("_id")` or `_id`). So the script keeps every field's name and records it as stored under that name, which is what the old contract meant: `"_id"` stays the field `_id`, stored as `_id`. The snapshot directory names stay as they are. Planning from such a snapshot to the re-emitted contract plans nothing and asks nothing, because the planner compares stored names.
The script edits the type of `storage` in each `contract.d.ts` in place, on one line; run your formatter afterwards. If it cannot find a model's storage type in a `contract.d.ts`, it says so and exits 1; add the type by hand there: for each model, `readonly fields: { readonly <field>: { readonly field: '<field>' }; ... }` inside `readonly storage: { ... }`, or `readonly fields: Record<string, never>` for a model without fields.
To rewrite a file by hand instead: in each model under `domain.namespaces.<namespace>.models`, add `"fields": { "<field>": { "field": "<field>" } }` to `storage`, one entry for each key of the model's `fields`, and the matching type in `contract.d.ts`. Change nothing else.
### `mongo-orm-uses-field-names`
ORM code names fields by their names in the schema:
```ts
// before
const user = await db.orm.users.create({ name: 'Alice' });
await db.orm.users.where({ _id: user._id }).update({ name: 'Ada' });
const posts = await db.orm.posts.where({ authorId: user._id }).include('author').all();
// after
const user = await db.orm.users.create({ name: 'Alice' });
await db.orm.users.where({ id: user.id }).update({ name: 'Ada' });
const posts = await db.orm.posts.where({ authorId: user.id }).include('author').all();
```
The same holds for every field with `@map`: `firstName String @map("first_name")` is `firstName` in the ORM, where it was `first_name`, and for the fields of a raw filter expression passed to `where`, such as `MongoFieldFilter.eq('id', ...)`. A type that picks or omits `_id` from `FieldInputTypes` or `FieldOutputTypes` names the field instead, for example `Omit<FieldInputTypes['__unbound__']['User'], 'id'>`. The type checker finds most places: run it after you re-emit.
The pipeline builder reads documents, so it names stored names, and its rows have them: `db.query.from('users').match((f) => f._id.eq(id))` and `foreign._id` in a lookup stay as they are. Code that hands a pipeline row to code that expects an ORM row renames the stored names, for example `({ _id, ...rest }) => ({ ...rest, id: _id })`.
### `mongo-statements-name-fields`
```sh
# before
prisma migration plan --name tidy --rename User._id:User.userId
# after
prisma migration plan --name tidy --rename User.id:User.userId
```
A statement that renames the field stored as `_id` to a field still stored as `_id` plans nothing; one that would change `_id` is refused, as before.
The first migration you plan after running the script starts from a rewritten snapshot, which names each field by its stored name (see `mongo-contracts-record-stored-field-names`). A statement in that migration names the old field by its stored name, and the new field by its name in the schema. For a schema that had `name String @map("display_name")` and now has `title String`, which renames the stored field to `title`:
```sh
# the first migration after the upgrade
prisma migration plan --name rename-title --rename User.display_name:User.title
# every later migration
prisma migration plan --name rename-headline --rename User.title:User.headline
```
Data-loss questions in that first migration name the old fields by their stored names too. A field that keeps its stored name needs no statement at all: planning from the rewritten snapshot to the re-emitted contract compares stored names and plans nothing for it.
### `mongo-composite-field-map-refused`
Remove `@map` from the field of the composite type, or rename the field to the name in `@map`. Either way the stored documents do not change: the field was always stored under its own name.
### `mongo-typescript-stored-field-names`
To read the document id as `id` in a TypeScript contract, as a PSL contract does, store it under `_id`:
```ts
// before
const User = model('User', {
collection: 'users',
fields: { _id: field.objectId(), name: field.string() },
});
const Post = model('Post', {
collection: 'posts',
fields: { _id: field.objectId(), authorId: field.objectId() },
relations: { author: rel.belongsTo(User, { from: 'authorId', to: User.ref('_id') }) },
});
// after
const User = model('User', {
collection: 'users',
fields: { id: field.objectId().mongo({ field: '_id' }), name: field.string() },
});
const Post = model('Post', {
collection: 'posts',
fields: { id: field.objectId().mongo({ field: '_id' }), authorId: field.objectId() },
relations: { author: rel.belongsTo(User, { from: 'authorId', to: User.ref('id') }) },
});
```
This is optional, and it does not change the storage hash. Code that reads the field then follows `mongo-orm-uses-field-names`.
## TypeScript MongoDB contracts get collection validators
### `mongo-typescript-contract-validator`
#### What changes
Before, a MongoDB contract written in TypeScript had no collection validators, so MongoDB accepted any document shape. Now each collection whose control policy is `managed`, the default, gets the validator the same schema gets in PSL: a closed `$jsonSchema` validator with `validationLevel: strict` and `validationAction: error`. It refuses a field the model does not declare, a missing required field, and a value of the wrong BSON type. A `field.vector()` field is validated as an array. The contract's storage hash changes once, because it now includes the validators.
#### What `defineContract` now refuses
Building the contract fails with one of these errors. Change the contract until it builds.
- A field whose codec has no MongoDB data type:
`Model "Place" field "location" uses codec "geo/point@1", which no pack in the contract provides, so its BSON type is unknown and the contract cannot describe the field.`
List the extension that provides the codec in `extensions` in `defineContract`.
- A model whose `_id` field (the field stored as `_id`; see `mongo-typescript-stored-field-names`) can hold an array, a regular expression, undefined or null, such as `_id: field.objectId().many()`, `_id: field.string().optional()`, `_id: field.json()`, `_id: field.vector()` or `_id: field.bson()`:
`Model "User" field "_id" can hold BSON type array or null (codec "mongo/json@1"). An _id cannot be an array, a regular expression, undefined or null: MongoDB refuses the first three, and every document has an _id.`
Give the `_id` field a required, single-valued type. Its validator requires `_id` with the field's BSON type, so a string, int, date, bytes or document `_id` is kept as it is. A model that leaves `_id` out gets a validator that requires an `objectId` `_id`, which MongoDB adds to every document inserted without one. A model whose `_id` is not stored as an ObjectId must give its `_id` on every create: without one, MongoDB adds an ObjectId `_id`, which the validator refuses.
- An enum a validator cannot list:
`enumType("Level") codec "mongo/int64@1" stores BSON type long, which a collection validator cannot list as an enum value. Use a codec whose BSON type is string, int, double, bool, object or array.`
A member whose stored value is not of that type is refused too: `enumType("Ratio") member "All" is stored as "1", which a collection validator cannot list as a double. A member of a double enum must be a finite number.`
- An embed relation to a model with variants:
`Relation "Owner.task" embeds model "Task", which has variants. The ORM reads and writes only the base fields of an embedded model, so an embedded model cannot have variants.`
The rc.17 upgrade notes advised writing an enum over a codec such as `mongo/int64@1` or `mongo/date@1` in a TypeScript contract, which had no validator. That no longer works: give the enum a codec whose BSON type is string, int, double, bool, object or array.
#### The risk: documents already stored
MongoDB does not check existing documents when a validator is added. After it is added, every insert, and every update to a document, must produce a valid document. A stored document that does not match the validator can no longer be updated, and writes from other applications that add fields outside the contract fail.
To keep a collection without a validator, for example because other applications write fields the contract does not declare, give its model a control policy other than `managed`. With `controlPolicy: 'observed'`, Prisma creates and changes nothing in that collection, so it adds no validator, and `prisma db verify` reports the collection's differences from the contract as warnings:
```ts
const Event = model('Event', {
collection: 'events',
controlPolicy: 'observed',
fields: { _id: field.objectId(), name: field.string() },
});
```
`defaultControlPolicy: 'observed'` in `defineContract` does the same for every collection. Such a collection is not created by Prisma either, so create it yourself.
#### Steps
1. Run `prisma contract emit`. Each `managed` collection in `contract.json` gains a `validator`, and `storage.storageHash` changes.
2. List the documents each validator would refuse. In `mongosh`, connected to your database, run this from the directory of the emitted `contract.json` (change the path if it is elsewhere):
```js
const contract = JSON.parse(require('fs').readFileSync('contract.json', 'utf8'));
for (const namespace of Object.values(contract.storage.namespaces)) {
for (const [name, collection] of Object.entries(namespace.entries.collection ?? {})) {
if (collection.validator === undefined) continue;
const refused = db
.getCollection(name)
.find({ $nor: [{ $jsonSchema: collection.validator.jsonSchema }] })
.toArray();
print(name, refused.length, refused);
}
}
```
3. Fix each listed document so it matches its model, or move it to another collection. Run step 2 again until every collection lists 0 documents.
4. Apply the validators: run `prisma db update`, or run `prisma migration plan` from the migration the database is at (`--from <its directory name>` if the project has no `db` ref) followed by `prisma db migrate`. The plan has one `widening` operation per collection, `validator.<collection>.add`, which adds its validator.
5. Run `prisma db verify`. It reports no problems.
## A collection that stores several models accepts each model's documents
### `mongo-shared-collection-validator`
If two or more models map to one collection without being variants of one base model, the collection's validator is now one `anyOf` with a branch per model. Before, it described only one of the models, so inserting or updating a document of another model failed with `Document failed validation`.
Run `prisma contract emit`. The collection's `validator` in `contract.json` and `storage.storageHash` change. Then apply the validator with `prisma db update`, or with `prisma migration plan` followed by `prisma db migrate`.
No discriminator tells these models' documents apart, so `prisma migration plan` and `prisma db update` refuse to rename or remove a field of one of them when another model in the collection stores the same field: the change would reach that model's documents too. Renaming the field to the same new name in every model that stores it, in one plan, is planned as one rename. Move the models to separate collections, or carry out the change in a `dataTransform` operation of a `migration.ts`, with a filter that selects only the documents to change.
Every collection validator now requires `_id`, in each branch. A model that declares a field stored as `_id` gets that field's BSON type, so a PSL model may now declare `id String @id @map("_id")` or any other single type. A model that declares none gets an `objectId` `_id`, because MongoDB adds one to every document inserted without it, so a MongoDB model in PSL may leave out its `@id` field. A field stored as `_id` cannot hold an array, a regular expression, undefined or null, so it cannot be a list, optional, `Json` or `Bson`. A PSL contract that built before still builds.
## A MongoDB composite type that contains itself now emits
### `mongo-recursive-value-objects-emit`
No change to your code is needed. If you removed a self-referencing composite type to make `prisma contract emit` work, you can now write it:
```prisma
type NavItem {
label String
children NavItem[]
}
```
The validator for the collection that holds it requires the fields of the first `NavItem` level and refuses any other field there. It accepts any object inside `children`, because a MongoDB `$jsonSchema` validator cannot describe a type at every depth of itself. Apply the contract as usual, with `prisma db update` or with `prisma migration plan` and `prisma db migrate`.
## Migration apply failures report the runner's own code
This section covers `control-client-apply-takes-invocation` and `runner-failures-report-own-codes`.
### The control client's apply operations take the invocation
`dbInit`, `dbUpdate` and `migrate` write advice that names commands to run, and that advice needs to know where it will be run from. Pass it as `invocation`:
```ts
// Before
await control.dbUpdate({ contract, mode: 'apply', migrationsDir: 'migrations', answerQuestions });
// After
await control.dbUpdate({
contract,
mode: 'apply',
migrationsDir: 'migrations',
answerQuestions,
invocation: { cwd: process.cwd(), configPath: undefined, dbGiven: false },
});
```
Add the same `invocation` to every `dbInit`, `dbUpdate` and `migrate` call. Set `configPath` to the config path your tool passes to `prisma`, and `dbGiven` to `true` when it passes `--db`.
A failure of these three operations is a `CliStructuredError`. Replace any use of `DbInitFailure`, `DbUpdateFailure`, `MigrateFailure` or their code types with `CliStructuredError`, and branch on `failure.code`, a dotted code such as `MIGRATION.PLANNING_FAILED`, instead of `'PLANNING_FAILED'`, `'RUNNER_FAILED'` or `'MIGRATION_PATH_NOT_FOUND'`.
The failure's fields change too:
| Before | After |
| --- | --- |
| `failure.summary` | `failure.message` |
| `failure.code === 'PLANNING_FAILED'`, `failure.conflicts` | `failure.code === 'MIGRATION.PLANNING_FAILED'`, `failure.meta.conflicts` |
| `failure.warnings` on a failed `dbInit` or `dbUpdate`, and `value.warnings` on a successful one | the `onPlannerWarnings(warnings)` option, called once per run when planning raised warnings, whether the apply then succeeds or fails |
| `failure.rowsWithoutValue`, `failure.failedTypeChange` | the runner's facts in `failure.meta`: `rowsWithoutValue` and `failedTypeChange`, and `schemaIssues` and `failedCheck` when the runner found them. A field in them is a subject object, not text such as `"User.age"` |
| `failure.failedMigration`, `failure.databaseStorageHash` | removed; the advice in `failure.fix` names the migration directory and the `--from` hash |
In `--json` output, `db init` and `db update` print the planner's warnings of a failed apply as `warn` messages before the error, instead of in the error's `meta.plannerWarnings`.
### Match the runner's code, not `MIGRATION.RUNNER_FAILED`
Code that reads `prisma db migrate`, `db init` or `db update` output with `--json`, or a control client's failure, and matches `MIGRATION.RUNNER_FAILED` or `meta.runnerErrorCode`, now matches the code itself:
```ts
// Before
if (error.code === 'MIGRATION.RUNNER_FAILED' && error.meta.runnerErrorCode === 'MIGRATION.SCHEMA_VERIFY_FAILED') {
report(error.meta.issues);
}
// After
if (error.code === 'MIGRATION.SCHEMA_VERIFY_FAILED') {
report(error.meta.schemaIssues);
}
```
A check that matched `MIGRATION.RUNNER_FAILED` alone, meaning "the apply failed", matches every code the runner reports: `MIGRATION.SCHEMA_VERIFY_FAILED`, `MIGRATION.PRECHECK_FAILED`, `MIGRATION.POSTCHECK_FAILED`, `MIGRATION.EXECUTION_FAILED`, `MIGRATION.POLICY_VIOLATION`, `MIGRATION.MARKER_ORIGIN_MISMATCH`, `MIGRATION.MARKER_CAS_FAILURE`, `MIGRATION.DESTINATION_CONTRACT_MISMATCH`, `MIGRATION.FOREIGN_KEY_VIOLATION` and, on MongoDB, `MIGRATION.DATA_TRANSFORM_CHECK_NOT_READ_ONLY`. `db init` and `db update` also report an extension space they cannot plan as `MIGRATION.PATH_UNREACHABLE` or `MIGRATION.NO_INVARIANT_PATH`, with `meta.spaceId`.
Read the facts from these `meta` keys:
| Before | After |
| --- | --- |
| `meta.issues` | `meta.schemaIssues` |
| `meta.operationId`, `meta.phase`, `meta.stepDescription` of a failed check | `meta.failedCheck.operationId`, `meta.failedCheck.phase`, `meta.failedCheck.description` |
| `meta.runnerErrorCode` | `code` |
`meta.failingSpace` is unchanged. `MIGRATION.PATH_UNREACHABLE` for a database with no marker now has `'empty'`, the empty contract's hash, in `meta.fromHash`, where it had `'<empty>'`. A failure also carries `meta.failedTypeChange` and `meta.rowsWithoutValue` when the runner found them.
## `setDefault` derives its operation class and has no postcheck
### `setdefault-operation-class-derived`
`this.setDefault` on the PostgreSQL `Migration` class used to take `operationClass`, and `migration plan` wrote `operationClass: 'widening'` when a migration changed an existing default. The option is gone. The method reads the migration's start contract: the operation is widening when the start contract gives the column a default, and additive otherwise, including in a migration with no start contract. Delete the option:
```typescript
// before
this.setDefault({ schema: 'public', table: 'user', column: col('role', 'text', { default: lit('member'), codecRef: { codecId: 'pg/text@1' } }), operationClass: 'widening' })
// after
this.setDefault({ schema: 'public', table: 'user', column: col('role', 'text', { default: lit('member'), codecRef: { codecId: 'pg/text@1' } }) })
```
`node migration.ts` does not check types, so a file that still passes `operationClass` runs and ignores it. An applied migration needs nothing: `prisma db migrate` applies `ops.json`.
### `setdefault-has-no-postcheck`
A `setDefault` operation checked afterwards that the column has a default. The old default passes that check, and the runner skips an operation whose check already passes before it runs. From 8.0.0-rc.15 until this release, only an additive `setDefault` carried the check, and a hand-written `this.setDefault` was additive unless it passed `operationClass: 'widening'`. Such a call on a column that already had a different default was skipped, and `prisma db migrate` then failed with `MIGRATION.SCHEMA_VERIFY_FAILED`. A `setDefault` operation now carries no check afterwards; the final schema verification compares the default's value, and setting a default twice changes nothing.
A matched `ops.json` needs action only when its migration is not applied yet and a hand-written `setDefault` in its `migration.ts` changes a default the column already has. Write its `ops.json` again before you apply it: run its `migration.ts` (`node migration.ts`), or delete the package and run `prisma migration plan` again. A package the planner wrote for a column with no default, and an applied migration, need nothing.
## PostgreSQL postchecks check the full definition
The runner runs an operation's postchecks before the operation. When they all hold, it records the operation as already applied and skips it. Several PostgreSQL operations checked only that an object with the planned name exists, so a database holding an index, constraint, policy, table or column of that name but another definition skipped the operation, and the run failed later or left the object as it was. Each of these operations now checks the object's definition in the catalog: kind, table, columns in order, types, referenced table and actions, access method, command and roles. It never compares text that PostgreSQL prints in its own form, such as expressions, predicates, check and policy bodies, or defaults. Renames now also check that the old name is gone.
### The `postgres-postcheck-*` entries
A matched `ops.json` needs action only when its migration is not applied yet. Write its `ops.json` again before you apply it: run its `migration.ts` (`node migration.ts`), or delete the package and run `prisma migration plan` again. An applied migration needs nothing; its operations do not run again. Each detection matches the old check of one operation.
### `postgres-alter-column-type-unchanged-refused`
`this.alterColumnType({ schema, table, column, type, using })` to the type the migration's start contract, after its earlier renames, already gives the column, used to build an operation whose check, the column's type, already held, so the runner skipped it and `using` never ran. `migration.ts` now refuses the call. Write the conversion as a `dataTransform`, which checks the rows themselves:
```typescript
// before
this.alterColumnType({ schema: 'public', table: 'user', column: 'email', type: 'text', using: 'lower(email)' })
// after
this.dataTransform(endContract, 'Lower-case user emails', {
check: () => /* a query for the users whose email is not lower-case */,
run: () => /* an update that lower-cases those emails */,
})
```
A matched `migration.ts` needs action only when the call converts a column to the type the start contract already gives it.
## The sequence of an `autoincrement()` column follows the column's type
An `autoincrement()` column takes its values from a sequence it owns. `ALTER COLUMN … TYPE` does not change that sequence, so a column widened from `int4` to `int8` kept a sequence `AS integer`, and inserts failed once it reached 2147483647. An `alterColumnType` on such a column, planned or hand-written in `migration.ts`, now also runs `ALTER SEQUENCE … AS <new width>`. It first refuses, before any step, when the sequence has already handed out a value past a narrower width. Its postcheck asks for both the column type and the sequence width. A column whose `nextval(...)` default reads a sequence it does not own changes type as before, and that sequence is left alone.
### `postgres-autoincrement-sequence-follows-column-type`
The detection matches every integer `alterColumnType` written before this change, and none written after it; only those on an `autoincrement()` column need action.
- Migration not applied yet: write its `ops.json` again before you apply it. Run its `migration.ts` (`node migration.ts`), or delete the package and run `prisma migration plan` again.
- Migration already applied: the sequence still has the old width. Change it to the column's width, for example `ALTER SEQUENCE "public"."user_id_seq" AS bigint`. `pg_get_serial_sequence('"public"."user"', 'id')` names the sequence.
## SQLite postchecks check the whole definition
### `sqlite-postchecks-check-full-definition`
The runner checks an operation's postchecks before it runs the operation, and skips the operation when they already hold. Some SQLite postchecks held for a different state:
- `createTable` checked only that a table of that name exists, whatever its columns.
- `addColumn` checked only that a column of that name exists, whatever its type or nullability.
- `createIndex` checked only that an index of that name exists, whatever its table or columns.
- `recreateTable` did not check foreign key actions (`onDelete`, `onUpdate`) or `autoincrement()`. A migration that changed only those was skipped, and `prisma db migrate` then failed with `MIGRATION.SCHEMA_VERIFY_FAILED`.
Each of these postchecks now checks the whole definition, so the runner skips an operation only when its full effect is in place.
The checks are written into `ops.json` when the migration package is written. A matched `ops.json` needs action only when its migration is not applied yet. Write its `ops.json` again before you apply it: run its `migration.ts` (`node migration.ts`), or delete the package and run `prisma migration plan` again. An applied migration needs nothing. A `recreateTable` written for an `autoincrement()` change has no text to detect; write it again as well when its migration is not applied yet.
## MongoDB postchecks check the whole definition
### `mongo-postchecks-check-full-definition`
The runner checks an operation's postchecks before it runs the operation, and skips the operation when they already hold. MongoDB has no transaction, so a run that fails part way relies on this skip when it runs again. Two MongoDB postchecks held for a different state:
- `createIndex` checked only the index keys, and `unique: true` when the index is unique. An index on the same keys with other options, such as `sparse`, a TTL, a partial filter, a collation, a wildcard projection, or other text weights or languages, passed the check, so the operation was skipped. It now checks `unique` and `sparse` both ways, `expireAfterSeconds`, `partialFilterExpression`, `wildcardProjection`, the collation fields the call gives (MongoDB fills in the others) or no collation when it gives none, and a text index's fields, `weights`, `default_language` and `language_override`.
- `collMod` with a validator that is not only a `$jsonSchema`, such as `{ status: { $in: [...] } }`, checked only the collection's validation level and action. It now compares the whole validator.
The checks are written into `ops.json` when the migration package is written. A matched `ops.json` needs action only when its migration is not applied yet. Write its `ops.json` again before you apply it: run its `migration.ts` (`node migration.ts`), or delete the package and run `prisma migration plan` again. An applied migration needs nothing.
## `contract-artifacts-restamp`
For every `contract.json` matched by `detection`, run the project's emit command (`prisma contract emit`, or the project's `contract:emit` script) once after upgrading. This entry accounts for the extension's embedded `version` moving to `8.0.0-rc.19`; any other difference in the emitted files comes from an earlier entry in this guide.
upgrading/app/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/scripts/client-takes-contract-value/contract-from-json.ts
// Moves every Prisma 8 client call that passes `contractJson` to a contract value:
// postgres<Contract>({ contractJson, url })
// becomes
// export const contract = contractFromJson<Contract>(contractJson);
// postgres({ contract, url })
//
// Usage: node contract-from-json.ts [project-root] [--typescript <path to the typescript package>]
//
// The script reads source files with the TypeScript compiler, which it loads from the project.
// It rewrites each source file it can move, and prints it. In a module with a default export, such
// as a Worker entry, it declares `contract` without `export` and says so. It prints to stderr, with the reason,
// each file that passes `contractJson` to a client but that it did not move: a test file, a file
// that already has another binding named `contract`, a file whose clients use different contracts,
// a file whose `contractJson` value is not readable at the top of the file, or a call it cannot
// read. A `contract` destructured from the moved call is dropped from the destructuring. It exits 1 when it printed such a file, and changes nothing and exits 1
// when TypeScript is not installed or an argument is wrong. It skips node_modules, dist, build and every directory whose
// name starts with a dot, such as .git, .agents or .claude, and links that lead nowhere.
import { readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join, relative, resolve } from 'node:path';
import type * as TS from 'typescript';
const SOURCE_FILE = /\.(?:ts|tsx|mts|cts)$/;
const TEST_FILE = /\.(?:test|spec)(?:-d)?\.[cm]?tsx?$/;
const TEST_DIRECTORIES = new Set(['test', 'tests', '__tests__']);
const SKIPPED_DIRECTORIES = new Set(['node_modules', 'dist', 'build']);
const FACADE_ENTRY =
/^@prisma\/orm-(postgres|sqlite|mongo)\/(?:runtime|serverless|static|query-builder)$/;
const SUPABASE_ENTRY = '@prisma/orm-extension-supabase/runtime';
const FACTORY_EXPORTS = new Set(['default', 'supabase', 'mongoQuery']);
const DETECTION =
/(?<![\w$.])(?:postgres|postgresServerless|sqlite|supabase|mongo|postgresStatic|sqliteStatic|mongoStatic|mongoQuery)(?:<[^<>()]*(?:<[^<>()]*>[^<>()]*)*>)?\(\s*\{(?:(?:[^{}]|\{[^{}]*\})*?,)?\s*contractJson\s*[,}:]/;
interface Edit {
readonly start: number;
readonly end: number;
readonly text: string;
}
interface MovedCall {
readonly node: TS.CallExpression;
readonly edits: readonly Edit[];
readonly typeArguments: string;
readonly json: string;
readonly jsonNames: readonly string[];
readonly staticEntry: string;
}
type Outcome =
| { readonly kind: 'unchanged' }
| { readonly kind: 'rewritten'; readonly source: string; readonly exported: boolean }
| { readonly kind: 'refused'; readonly reason: string };
function staticEntryOf(specifier: string): string | undefined {
const facade = FACADE_ENTRY.exec(specifier);
if (facade !== null) return `@prisma/orm-${facade[1]}/static`;
return specifier === SUPABASE_ENTRY ? '@prisma/orm-postgres/static' : undefined;
}
function specifierOf(ts: typeof TS, statement: TS.ImportDeclaration): string {
return ts.isStringLiteral(statement.moduleSpecifier) ? statement.moduleSpecifier.text : '';
}
function compareSpecifiers(left: string, right: string): number {
const rank = (specifier: string) =>
specifier.startsWith('node:') ? 0 : specifier.startsWith('.') ? 2 : 1;
return rank(left) - rank(right) || (left < right ? -1 : left > right ? 1 : 0);
}
function factoriesOf(ts: typeof TS, imports: readonly TS.ImportDeclaration[]) {
const factories = new Map<string, string>();
for (const statement of imports) {
const clause = statement.importClause;
const staticEntry = staticEntryOf(specifierOf(ts, statement));
if (clause === undefined || clause.isTypeOnly || staticEntry === undefined) continue;
if (clause.name !== undefined) factories.set(clause.name.text, staticEntry);
const bindings = clause.namedBindings;
if (bindings === undefined || !ts.isNamedImports(bindings)) continue;
for (const element of bindings.elements) {
const imported = (element.propertyName ?? element.name).text;
if (!element.isTypeOnly && FACTORY_EXPORTS.has(imported)) {
factories.set(element.name.text, staticEntry);
}
}
}
return factories;
}
function topLevelNames(ts: typeof TS, sourceFile: TS.SourceFile) {
const imported = new Set<string>();
const declared = new Set<string>();
for (const statement of sourceFile.statements) {
if (ts.isImportDeclaration(statement)) {
const clause = statement.importClause;
if (clause?.name !== undefined) imported.add(clause.name.text);
const bindings = clause?.namedBindings;
if (bindings !== undefined && ts.isNamespaceImport(bindings))
imported.add(bindings.name.text);
else for (const element of bindings?.elements ?? []) imported.add(element.name.text);
} else if (ts.isVariableStatement(statement)) {
for (const declaration of statement.declarationList.declarations) {
for (const name of bindingNames(ts, declaration.name)) declared.add(name);
}
} else if (
(ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
statement.name !== undefined
) {
declared.add(statement.name.text);
}
}
return { imported, declared };
}
function bindingIdentifiers(ts: typeof TS, name: TS.BindingName): TS.Identifier[] {
if (ts.isIdentifier(name)) return [name];
return name.elements.flatMap((element) =>
ts.isOmittedExpression(element) ? [] : bindingIdentifiers(ts, element.name),
);
}
function bindingNames(ts: typeof TS, name: TS.BindingName): string[] {
return bindingIdentifiers(ts, name).map((identifier) => identifier.text);
}
function bindingsOf(ts: typeof TS, root: TS.Node, wanted: string): TS.Node[] {
const found: TS.Node[] = [];
const visit = (node: TS.Node): void => {
const binding = ownBindingOf(ts, node, wanted);
if (binding !== undefined) found.push(binding);
ts.forEachChild(node, visit);
};
visit(root);
return found;
}
function ownBindingOf(ts: typeof TS, node: TS.Node, wanted: string): TS.Node | undefined {
if (ts.isVariableDeclaration(node) || ts.isParameter(node)) {
return bindingIdentifiers(ts, node.name).find((name) => name.text === wanted);
}
if (
(ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isClassDeclaration(node) ||
ts.isClassExpression(node) ||
ts.isEnumDeclaration(node) ||
ts.isModuleDeclaration(node) ||
ts.isImportEqualsDeclaration(node) ||
ts.isExportSpecifier(node) ||
ts.isNamespaceExport(node) ||
ts.isImportSpecifier(node) ||
ts.isNamespaceImport(node) ||
ts.isImportClause(node)) &&
node.name !== undefined &&
ts.isIdentifier(node.name) &&
node.name.text === wanted
) {
return node.name;
}
return undefined;
}
/** Whether `identifier` names a variable the file declares at its top level. */
function isTopLevelVariable(ts: typeof TS, identifier: TS.Node): boolean {
let node = identifier.parent;
while (
ts.isBindingElement(node) ||
ts.isObjectBindingPattern(node) ||
ts.isArrayBindingPattern(node)
)
node = node.parent;
return (
ts.isVariableDeclaration(node) &&
ts.isVariableDeclarationList(node.parent) &&
ts.isVariableStatement(node.parent.parent) &&
ts.isSourceFile(node.parent.parent.parent)
);
}
function topLevelStatementOf(ts: typeof TS, node: TS.Node): TS.Node | undefined {
let current = node;
while (current.parent !== undefined && !ts.isSourceFile(current.parent)) current = current.parent;
return current.parent === undefined ? undefined : current;
}
/** The edit that drops `contract` from `const { ..., contract } = client(...)`, whose value the moved declaration now holds. */
function destructuredContractEdit(
ts: typeof TS,
sourceFile: TS.SourceFile,
call: TS.CallExpression,
exported: boolean,
): { readonly binding: TS.Node; readonly edit: Edit } | undefined {
const declaration = call.parent;
if (!ts.isVariableDeclaration(declaration) || declaration.initializer !== call) return undefined;
const pattern = declaration.name;
if (!ts.isObjectBindingPattern(pattern)) return undefined;
if (pattern.elements.some((element) => element.dotDotDotToken !== undefined)) return undefined;
const index = pattern.elements.findIndex(
(element) =>
element.propertyName === undefined &&
element.initializer === undefined &&
ts.isIdentifier(element.name) &&
element.name.text === 'contract',
);
const element = pattern.elements[index];
if (element === undefined) return undefined;
const statement = declaration.parent.parent;
const isExported =
ts.isVariableStatement(statement) &&
(ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false);
if (isExported && !exported) return undefined;
if (pattern.elements.length === 1) {
if (
!ts.isVariableStatement(statement) ||
isExported ||
statement.declarationList.declarations.length !== 1
)
return undefined;
return {
binding: element.name,
edit: { start: statement.getStart(sourceFile), end: call.getStart(sourceFile), text: '' },
};
}
const next = pattern.elements[index + 1];
const previous = pattern.elements[index - 1];
const edit =
next !== undefined
? { start: element.getStart(sourceFile), end: next.getStart(sourceFile), text: '' }
: { start: previous?.end ?? element.getStart(sourceFile), end: element.end, text: '' };
return { binding: element.name, edit };
}
function isReference(ts: typeof TS, identifier: TS.Identifier): boolean {
const parent = identifier.parent;
return !(
(ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
(ts.isQualifiedName(parent) && parent.right === identifier) ||
((ts.isPropertyAssignment(parent) ||
ts.isMethodDeclaration(parent) ||
ts.isGetAccessorDeclaration(parent) ||
ts.isSetAccessorDeclaration(parent)) &&
parent.name === identifier)
);
}
function identifiersIn(ts: typeof TS, node: TS.Node): string[] {
const names: string[] = [];
const visit = (child: TS.Node): void => {
if (ts.isIdentifier(child) && isReference(ts, child)) names.push(child.text);
ts.forEachChild(child, visit);
};
visit(node);
return names;
}
function contractJsonProperty(ts: typeof TS, object: TS.ObjectLiteralExpression) {
return object.properties.find(
(property): property is TS.ShorthandPropertyAssignment | TS.PropertyAssignment =>
(ts.isShorthandPropertyAssignment(property) || ts.isPropertyAssignment(property)) &&
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
property.name.text === 'contractJson',
);
}
function movedCalls(
ts: typeof TS,
sourceFile: TS.SourceFile,
factories: ReadonlyMap<string, string>,
): MovedCall[] {
const source = sourceFile.text;
const calls: MovedCall[] = [];
const visit = (node: TS.Node): void => {
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
const staticEntry = factories.get(node.expression.text);
const [options] = node.arguments;
const property =
options !== undefined && ts.isObjectLiteralExpression(options)
? contractJsonProperty(ts, options)
: undefined;
if (staticEntry !== undefined && property !== undefined) {
const edits: Edit[] = [
{ start: property.getStart(sourceFile), end: property.end, text: 'contract' },
];
const typeArguments = node.typeArguments;
if (typeArguments !== undefined) {
edits.push({
start: node.expression.end,
end: source.indexOf('>', typeArguments.end) + 1,
text: '',
});
}
calls.push({
node,
edits,
typeArguments:
typeArguments === undefined
? ''
: `<${typeArguments.map((type) => type.getText(sourceFile)).join(', ')}>`,
json: ts.isPropertyAssignment(property)
? property.initializer.getText(sourceFile)
: 'contractJson',
jsonNames: ts.isPropertyAssignment(property)
? identifiersIn(ts, property.initializer)
: ['contractJson'],
staticEntry,
});
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return calls;
}
function importEdit(
ts: typeof TS,
sourceFile: TS.SourceFile,
imports: readonly TS.ImportDeclaration[],
staticEntry: string,
style: { readonly quote: string; readonly semicolon: string; readonly eol: string },
): Edit | { readonly after: string } {
const existing = imports.find(
(statement) =>
specifierOf(ts, statement) === staticEntry &&
statement.importClause !== undefined &&
!statement.importClause.isTypeOnly,
);
const clause = existing?.importClause;
const bindings = clause?.namedBindings;
if (bindings !== undefined && ts.isNamedImports(bindings)) {
const first = bindings.elements[0];
return first === undefined
? { start: bindings.getStart(sourceFile), end: bindings.end, text: '{ contractFromJson }' }
: {
start: first.getStart(sourceFile),
end: first.getStart(sourceFile),
text: 'contractFromJson, ',
};
}
if (clause?.name !== undefined && bindings === undefined) {
return { start: clause.name.end, end: clause.name.end, text: ', { contractFromJson }' };
}
const line = `import { contractFromJson } from ${style.quote}${staticEntry}${style.quote}${style.semicolon}`;
const before = imports.find(
(statement) =>
statement.importClause !== undefined &&
compareSpecifiers(specifierOf(ts, statement), staticEntry) > 0,
);
if (before === undefined) return { after: `${style.eol}${line}` };
const at = before.getStart(sourceFile);
return { start: at, end: at, text: `${line}${style.eol}` };
}
export function moveFile(ts: typeof TS, fileName: string, source: string): Outcome {
const sourceFile = ts.createSourceFile(
fileName,
source,
ts.ScriptTarget.Latest,
true,
fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
const imports = sourceFile.statements.filter(ts.isImportDeclaration);
const calls = movedCalls(ts, sourceFile, factoriesOf(ts, imports));
const first = calls[0];
if (first === undefined) {
return DETECTION.test(source)
? {
kind: 'refused',
reason: 'it passes contractJson to a client in a form the script cannot read',
}
: { kind: 'unchanged' };
}
if (
calls.some(
(call) =>
call.json !== first.json ||
call.typeArguments !== first.typeArguments ||
call.staticEntry !== first.staticEntry,
)
) {
return {
kind: 'refused',
reason: 'its clients use different contracts; give each its own contractFromJson value',
};
}
const names = topLevelNames(ts, sourceFile);
const topLevelStatement = topLevelStatementOf(ts, first.node);
const inner = first.jsonNames.find(
(name) =>
topLevelStatement !== undefined &&
bindingsOf(ts, topLevelStatement, name).some((found) => !isTopLevelVariable(ts, found)),
);
if (inner !== undefined) {
return {
kind: 'refused',
reason: `its contractJson value uses ${inner}, which is a parameter or local binding where the client is created, not a value the top of the file can read; pass contract: contractFromJson${first.typeArguments}(${first.json}) to the call instead of contractJson, and import contractFromJson from ${first.staticEntry}`,
};
}
const exported = !hasDefaultExport(ts, sourceFile);
const destructured = calls
.map((call) => destructuredContractEdit(ts, sourceFile, call.node, exported))
.filter((found) => found !== undefined);
const binding = bindingsOf(ts, sourceFile, 'contract').find(
(found) => !destructured.some((entry) => entry.binding === found),
);
if (binding !== undefined) {
const line = sourceFile.getLineAndCharacterOfPosition(binding.getStart(sourceFile)).line + 1;
return {
kind: 'refused',
reason: ts.isExportSpecifier(binding.parent)
? `it exports contract in an export statement (line ${line}); remove contract from that statement and run the script again`
: `it already has a binding named contract (line ${line}); rename that binding and run the script again`,
};
}
const local = first.jsonNames.find((name) => names.declared.has(name));
if (local !== undefined) {
return {
kind: 'refused',
reason: `its contractJson value uses ${local}, which the file declares after its imports; declare the contract value after ${local} by hand`,
};
}
const lastImport = imports.at(-1);
if (lastImport === undefined) {
return { kind: 'refused', reason: 'it imports no client' };
}
const firstImport = imports[0];
const firstImportText = firstImport?.getText(sourceFile) ?? '';
const style = {
quote: firstImport?.moduleSpecifier.getText(sourceFile).startsWith('"') ? '"' : "'",
semicolon: firstImportText.endsWith(';') ? ';' : '',
eol: source.includes('\r\n') ? '\r\n' : '\n',
};
const edits: Edit[] = [
...calls.flatMap((call) => call.edits),
...destructured.map((entry) => entry.edit),
];
const staticImport = importEdit(ts, sourceFile, imports, first.staticEntry, style);
const importedAfter = 'after' in staticImport ? staticImport.after : '';
if ('start' in staticImport) edits.push(staticImport);
let afterImports = lastImport.end;
while (source[afterImports] === '\r' || source[afterImports] === '\n') afterImports++;
const declaration = `${exported ? 'export ' : ''}const contract = contractFromJson${first.typeArguments}(${first.json})${style.semicolon}`;
edits.push({
start: lastImport.end,
end: afterImports,
text: `${importedAfter}${style.eol}${style.eol}${declaration}${style.eol}${afterImports < source.length ? style.eol : ''}`,
});
let moved = source;
for (const edit of [...edits].sort((left, right) => right.start - left.start)) {
moved = moved.slice(0, edit.start) + edit.text + moved.slice(edit.end);
}
return { kind: 'rewritten', source: moved, exported };
}
function hasDefaultExport(ts: typeof TS, sourceFile: TS.SourceFile): boolean {
return sourceFile.statements.some(
(statement) =>
(ts.isExportAssignment(statement) && !statement.isExportEquals) ||
(ts.isExportDeclaration(statement) &&
statement.exportClause !== undefined &&
ts.isNamedExports(statement.exportClause) &&
statement.exportClause.elements.some((element) => element.name.text === 'default')) ||
(ts.canHaveModifiers(statement) &&
(ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) ??
false)),
);
}
function isTestPath(path: string): boolean {
return TEST_FILE.test(path) || path.split(/[\\/]/).some((part) => TEST_DIRECTORIES.has(part));
}
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
/** Each source file under `root`, once, by the first path that reaches it; a link to a directory already visited is not followed again. */
function sourceFiles(root: string): string[] {
const visited = new Set<string>();
const found = new Map<string, string>();
const visit = (directory: string): void => {
const realDirectory = realpathSync(directory);
if (visited.has(realDirectory)) return;
visited.add(realDirectory);
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(path))) {
if (!SKIPPED_DIRECTORIES.has(entry.name) && !entry.name.startsWith('.')) visit(path);
} else if (
(entry.isFile() || (entry.isSymbolicLink() && isFile(path))) &&
SOURCE_FILE.test(entry.name) &&
!entry.name.endsWith('.d.ts')
) {
const realFile = realpathSync(path);
if (!found.has(realFile)) found.set(realFile, path);
}
}
};
visit(root);
return [...found.values()];
}
function loadTypeScript(root: string, explicit: string | undefined): typeof TS | undefined {
const require = createRequire(join(root, 'package.json'));
try {
return require(explicit ?? 'typescript');
} catch {
return undefined;
}
}
const USAGE =
'Usage: node contract-from-json.ts [project-root] [--typescript <path to the typescript package>]';
function parseArgs(args: readonly string[]): {
readonly root: string;
readonly typescript: string | undefined;
readonly errors: string[];
} {
const errors: string[] = [];
const positional: string[] = [];
let typescript: string | undefined;
for (let index = 0; index < args.length; index++) {
const arg = args[index] ?? '';
if (arg === '--typescript') {
const path = args[++index];
if (path === undefined || path.startsWith('-'))
errors.push('--typescript: needs the path to the typescript package');
else typescript = resolve(path);
} else if (arg.startsWith('-')) errors.push(`${arg}: unknown option`);
else positional.push(arg);
}
if (positional.length > 1) errors.push(`expected one project root, got ${positional.length}`);
const root = resolve(positional[0] ?? process.cwd());
if (errors.length === 0 && !isDirectory(root)) errors.push(`${root}: not a directory`);
if (errors.length > 0) errors.push(USAGE);
return { root, typescript, errors };
}
function main(): number {
const { root, typescript, errors } = parseArgs(process.argv.slice(2));
if (errors.length > 0) {
process.stderr.write(`${errors.join('\n')}\n`);
return 1;
}
const ts = loadTypeScript(root, typescript);
if (ts === undefined) {
process.stderr.write(
`TypeScript is not installed in ${root}. Install it (for example pnpm add -D typescript) and run the script again, or move each client by hand as the guide describes. No file was changed.\n`,
);
return 1;
}
let refused = 0;
for (const path of sourceFiles(root).sort()) {
const name = relative(root, path).split('\\').join('/');
const source = readFileSync(path, 'utf8');
const outcome: Outcome = isTestPath(name)
? DETECTION.test(source)
? {
kind: 'refused',
reason:
'it is a test file; import the contract value from the module that creates the client',
}
: { kind: 'unchanged' }
: moveFile(ts, path, source);
if (outcome.kind === 'rewritten') {
writeFileSync(path, outcome.source);
process.stdout.write(
outcome.exported
? `${name}: moved to contractFromJson\n`
: `${name}: moved to contractFromJson; contract is not exported, because the module has a default export (a Worker treats every named export of its entry module as an entrypoint); a module that needs the value gets it from a separate module\n`,
);
} else if (outcome.kind === 'refused') {
refused++;
process.stderr.write(`${name}: not moved, because ${outcome.reason}\n`);
}
}
return refused === 0 ? 0 : 1;
}
process.exitCode = main();
upgrading/app/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/scripts/data-type-values/data-type-values.ts
#!/usr/bin/env node
/**
* Rewrites every stored SQLite contract in a project to this release's form: a column or named type
* of `sqlite/datetime@1` or `sqlite/json@1` names its own data type (`sqlite/datetime`,
* `sqlite/json`) instead of `sqlite/text`, and a datetime default or enum value is written with
* exactly three fraction digits (`2024-01-01T00:00:00.000Z`). It then renames the snapshot
* directories and rewrites every migration, ref, `migration.ts` and `contract.d.ts` that names an
* old storage hash.
*
* Usage: node data-type-values.ts [project-root]
*
* The project root defaults to the working directory. The script reads and writes files only. It
* needs no database, network or configured stack, and a project already in the new form is left
* unchanged. When it stops (a file that must be JSON is not, or a snapshot directory that already
* exists with different content) it changes no file, prints one line per case and exits 1. When it
* finishes it prints how many files and snapshot directories it changed and each storage hash it
* replaced.
*
* The storage hash and migration hash rules are copied from `@internal/contract` and
* `@internal/migration-tools` because a project's strict `node_modules` does not expose them.
*/
import { createHash } from 'node:crypto';
import {
chmodSync,
type Dirent,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
type Json = null | boolean | number | string | Json[] | JsonRecord;
interface JsonRecord {
[key: string]: Json;
}
const DATA_TYPES: Readonly<Record<string, string>> = {
'sqlite/datetime@1': 'sqlite/datetime',
'sqlite/json@1': 'sqlite/json',
};
const DATETIME_CODEC = 'sqlite/datetime@1';
const PREVIOUS_DATA_TYPE = 'sqlite/text';
const UTC_INSTANT = /^(.*:\d{2})(?:\.(\d{1,3}))?Z$/;
const SKIPPED_DIRECTORIES = new Set(['node_modules', 'dist', 'build']);
const HASH = /^[0-9a-f]{64}$/;
const TEMPORARY_SUFFIX = '.data-type-values-tmp';
function isRecord(value: unknown): value is JsonRecord {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const prototype: unknown = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function parseJson(text: string): Json | undefined {
try {
const value: Json = JSON.parse(text);
return value;
} catch {
return undefined;
}
}
function sha256(text: string): string {
return createHash('sha256').update(text).digest('hex');
}
function compareCodeUnits(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
function sortKeys(value: Json): Json {
if (Array.isArray(value)) return value.map(sortKeys);
if (!isRecord(value)) return value;
const sorted: JsonRecord = {};
for (const key of Object.keys(value).sort(compareCodeUnits)) {
const child = value[key];
if (child !== undefined) sorted[key] = sortKeys(child);
}
return sorted;
}
function canonicalizeJson(value: Json): string {
return JSON.stringify(sortKeys(value));
}
type PathSegment = string | readonly string[];
function matchesPath(path: readonly string[], pattern: readonly PathSegment[]): boolean {
if (path.length !== pattern.length) return false;
return pattern.every((segment, index) => {
const value = path[index];
if (value === undefined) return false;
if (segment === '*') return true;
return typeof segment === 'string' ? value === segment : segment.includes(value);
});
}
const SQL_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
['storage', 'namespaces', '*', 'entries', 'table'],
['storage', 'namespaces', '*', 'entries', 'table', '*'],
['storage', 'namespaces', '*', 'entries', 'table', '*', ['uniques', 'indexes', 'foreignKeys']],
['storage', 'namespaces', '*', 'entries', 'table', '*', 'indexes', 'unique'],
['storage', 'namespaces', '*', 'entries', 'table', '*', 'columns', '*', 'default', 'value'],
];
const COLUMN_DEFAULT_VALUE: readonly PathSegment[] = [
'storage',
'namespaces',
'*',
'entries',
'table',
'*',
'columns',
'*',
'default',
'value',
];
const COLUMN_PATHS: readonly (readonly PathSegment[])[] = [
['storage', 'namespaces', '*', 'entries', 'table', '*', 'columns', '*'],
['storage', 'namespaces', '*', 'tables', '*', 'columns', '*'],
];
const DATA_TYPE_PATHS: readonly (readonly PathSegment[])[] = [
...COLUMN_PATHS,
['storage', 'types', '*'],
];
const FRAMEWORK_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
['domain', 'namespaces'],
['domain', 'namespaces', '*'],
['domain', 'namespaces', '*', 'models'],
['domain', 'namespaces', '*', 'models', '*', 'relations'],
['domain', 'namespaces', '*', 'models', '*', 'storage'],
['storage', 'namespaces'],
['storage', 'namespaces', '*', 'entries'],
['roots'],
['extensions'],
['extensions', '*'],
['capabilities'],
['meta'],
['execution', 'mutations', 'defaults'],
];
const TOP_LEVEL_ORDER = [
'schemaVersion',
'canonicalVersion',
'targetFamily',
'target',
'profileHash',
'roots',
'domain',
'storage',
'execution',
'capabilities',
'extensions',
'defaultControlPolicy',
'meta',
];
function isDefaultValue(value: Json): boolean {
if (value === false) return true;
if (Array.isArray(value)) return value.length === 0;
return isRecord(value) && Object.keys(value).length === 0;
}
function isPreservedEmpty(path: readonly string[], key: string): boolean {
if (key === 'nullable') return true;
if (key === 'elementNullable' && path.at(-2) === 'many') return true;
if (FRAMEWORK_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern))) return true;
if (
path.length >= COLUMN_DEFAULT_VALUE.length &&
matchesPath(path.slice(0, COLUMN_DEFAULT_VALUE.length), COLUMN_DEFAULT_VALUE)
)
return true;
return SQL_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern));
}
function omitDefaults(value: Json, path: readonly string[]): Json {
if (Array.isArray(value)) return value.map((item) => omitDefaults(item, path));
if (!isRecord(value)) return value;
const result: JsonRecord = {};
for (const [key, child] of Object.entries(value)) {
const childPath = [...path, key];
if (key === '_generated') continue;
if (key === 'generated' && child === false) continue;
if ((key === 'onDelete' || key === 'onUpdate') && child === 'noAction') continue;
if (isDefaultValue(child) && !isPreservedEmpty(childPath, key)) continue;
result[key] = omitDefaults(child, childPath);
}
return result;
}
function compareByName(a: Json, b: Json): number {
const nameA = isRecord(a) && typeof a['name'] === 'string' ? a['name'] : '';
const nameB = isRecord(b) && typeof b['name'] === 'string' ? b['name'] : '';
return compareCodeUnits(nameA, nameB);
}
function sortTableArrays(storage: Json): Json {
if (!isRecord(storage)) return storage;
const namespaces = storage['namespaces'];
if (!isRecord(namespaces)) return storage;
const sortedNamespaces: JsonRecord = {};
for (const [namespaceId, namespace] of Object.entries(namespaces)) {
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
const tables = isRecord(entries) ? entries['table'] : undefined;
if (!isRecord(namespace) || !isRecord(entries) || !isRecord(tables)) {
sortedNamespaces[namespaceId] = namespace;
continue;
}
const sortedTables: JsonRecord = {};
for (const [tableName, table] of Object.entries(tables)) {
if (!isRecord(table)) {
sortedTables[tableName] = table;
continue;
}
const sortedTable: JsonRecord = { ...table };
for (const key of ['checks', 'indexes', 'uniques']) {
const list = table[key];
if (Array.isArray(list)) sortedTable[key] = [...list].sort(compareByName);
}
sortedTables[tableName] = sortedTable;
}
sortedNamespaces[namespaceId] = { ...namespace, entries: { ...entries, table: sortedTables } };
}
return { ...storage, namespaces: sortedNamespaces };
}
function orderTopLevel(value: JsonRecord): JsonRecord {
const ordered: JsonRecord = {};
const remaining = new Set(Object.keys(value));
for (const key of TOP_LEVEL_ORDER) {
const child = value[key];
if (child !== undefined) {
ordered[key] = child;
remaining.delete(key);
}
}
for (const key of [...remaining].sort(compareCodeUnits)) {
const child = value[key];
if (child !== undefined) ordered[key] = child;
}
return ordered;
}
function omitNamespaceKinds(storage: JsonRecord): JsonRecord {
const namespaces = storage['namespaces'];
if (!isRecord(namespaces)) return storage;
const stripped: JsonRecord = {};
for (const [namespaceId, namespace] of Object.entries(namespaces)) {
if (isRecord(namespace)) {
const { kind: _kind, ...rest } = namespace;
stripped[namespaceId] = rest;
} else {
stripped[namespaceId] = namespace;
}
}
return { ...storage, namespaces: stripped };
}
function computeStorageHash(contract: JsonRecord): string {
const storage = isRecord(contract['storage']) ? contract['storage'] : {};
const { storageHash: _published, ...withoutHash } = storage;
const hashed: JsonRecord = {
schemaVersion: '1',
targetFamily: typeof contract['targetFamily'] === 'string' ? contract['targetFamily'] : '',
target: typeof contract['target'] === 'string' ? contract['target'] : '',
profileHash: '',
roots: {},
domain: { namespaces: {} },
storage: omitNamespaceKinds(withoutHash),
extensions: {},
capabilities: {},
meta: {},
};
const withoutDefaults = omitDefaults(hashed, []);
if (!isRecord(withoutDefaults)) return '';
const withSortedStorage = {
...withoutDefaults,
storage: sortTableArrays(withoutDefaults['storage'] ?? {}),
};
const sorted = sortKeys(withSortedStorage);
return isRecord(sorted) ? sha256(JSON.stringify(orderTopLevel(sorted), null, 2)) : '';
}
function computeMigrationHash(metadata: JsonRecord, ops: Json): string {
const { migrationHash: _migrationHash, ...withoutHash } = metadata;
const parts = [canonicalizeJson(withoutHash), canonicalizeJson(ops)].map(sha256);
return sha256(canonicalizeJson(parts));
}
interface ProjectFiles {
readonly json: readonly string[];
readonly migrationJson: readonly string[];
readonly refs: readonly string[];
readonly migrationTs: readonly string[];
readonly leftovers: readonly string[];
}
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
/** A file, or a link to one: the script reads and writes through links to files. */
function isFileEntry(dir: string, entry: Dirent): boolean {
return entry.isFile() || (entry.isSymbolicLink() && isFile(join(dir, entry.name)));
}
/** A file the script reads, by the path it was reached through: its own path rather than a link's. */
interface FoundFile {
readonly path: string;
readonly linked: boolean;
readonly inMigrationPackage: boolean;
}
function listFiles(root: string): ProjectFiles {
const leftovers: string[] = [];
const visited = new Set<string>();
const found = new Map<string, FoundFile>();
const visit = (dir: string): void => {
const realDir = realpathSync(dir);
if (visited.has(realDir)) return;
visited.add(realDir);
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
compareCodeUnits(a.name, b.name),
);
const inMigrationPackage = entries.some(
(entry) => entry.name === 'migration.json' && isFileEntry(dir, entry),
);
for (const entry of entries) {
const path = join(dir, entry.name);
if (entry.name.endsWith(TEMPORARY_SUFFIX)) {
leftovers.push(path);
continue;
}
if (entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(path))) {
if (!SKIPPED_DIRECTORIES.has(entry.name) && !entry.name.startsWith('.')) visit(path);
continue;
}
if (!isFileEntry(dir, entry)) continue;
const realFile = realpathSync(path);
const linked = entry.isSymbolicLink();
const known = found.get(realFile);
if (known === undefined || (known.linked && !linked))
found.set(realFile, { path, linked, inMigrationPackage });
}
};
visit(root);
const files = [...found.values()];
const named = (test: (file: FoundFile, name: string) => boolean): string[] =>
files.filter((file) => test(file, basename(file.path))).map((file) => file.path);
return {
json: named((_file, name) => name.endsWith('.json')),
migrationJson: named((_file, name) => name === 'migration.json'),
refs: named((file, name) => name.endsWith('.json') && basename(dirname(file.path)) === 'refs'),
migrationTs: named((file, name) => name === 'migration.ts' && file.inMigrationPackage),
leftovers,
};
}
function isSnapshotContract(path: string): boolean {
const snapshotDir = dirname(path);
return (
basename(path) === 'contract.json' &&
HASH.test(basename(snapshotDir)) &&
basename(dirname(snapshotDir)) === 'snapshots'
);
}
function mapRecord(value: Json, map: (key: string, child: Json) => Json): Json {
if (!isRecord(value)) return value;
const result: JsonRecord = {};
for (const [key, child] of Object.entries(value)) result[key] = map(key, child);
return result;
}
function mapEntries(
value: Json,
path: readonly string[],
patterns: readonly (readonly PathSegment[])[],
rewrite: (entry: JsonRecord, path: readonly string[]) => JsonRecord,
): Json {
if (!isRecord(value)) return value;
if (patterns.some((pattern) => matchesPath(path, pattern))) return rewrite(value, path);
const leadsToEntry = patterns.some(
(pattern) => path.length < pattern.length && matchesPath(path, pattern.slice(0, path.length)),
);
return leadsToEntry
? mapRecord(value, (key, child) => mapEntries(child, [...path, key], patterns, rewrite))
: value;
}
function mapContractEntries(
contract: JsonRecord,
patterns: readonly (readonly PathSegment[])[],
rewrite: (entry: JsonRecord, path: readonly string[]) => JsonRecord,
): JsonRecord {
const result: JsonRecord = {};
for (const [key, child] of Object.entries(contract))
result[key] = mapEntries(child, [key], patterns, rewrite);
return result;
}
function enumKey(namespaceId: string, name: string): string {
return `${namespaceId}\u0000${name}`;
}
function withStorageHash(contract: JsonRecord, hash: string): JsonRecord {
const storage = isRecord(contract['storage']) ? contract['storage'] : {};
return { ...contract, storage: { ...storage, storageHash: hash } };
}
function emittedForm(contract: JsonRecord, original: string): string {
const { _generated: generated, ...rest } = contract;
const sorted = sortKeys(rest);
const ordered = isRecord(sorted) ? orderTopLevel(sorted) : {};
if (generated !== undefined) ordered['_generated'] = generated;
return `${JSON.stringify(ordered, null, 2)}${original.endsWith('\n') ? '\n' : ''}`;
}
function snapshotForm(contract: JsonRecord): string {
return `${canonicalizeJson(contract)}\n`;
}
interface Block {
readonly start: number;
end: number;
readonly label: string;
readonly parent: Block | undefined;
}
function scanBlocks(text: string): Block[] {
const blocks: Block[] = [];
const open: Block[] = [];
let quote: string | undefined;
for (let index = 0; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === "'" || char === '"' || char === '`') {
quote = char;
continue;
}
if (char === '{') {
const key =
/(?:readonly\s+)?([\p{ID_Start}_$][\p{ID_Continue}$]*|'[^']*'|"[^"]*")\s*\??\s*:\s*$/u.exec(
text.slice(Math.max(0, index - 200), index),
);
const block: Block = {
start: index,
end: text.length,
label: key?.[1]?.replace(/^['"]|['"]$/g, '') ?? '',
parent: open.at(-1),
};
open.push(block);
blocks.push(block);
} else if (char === '}') {
const block = open.pop();
if (block !== undefined) block.end = index;
}
}
return blocks;
}
function innermostBlock(blocks: readonly Block[], position: number): Block | undefined {
let found: Block | undefined;
for (const block of blocks) {
if (
block.start < position &&
position < block.end &&
(found === undefined || block.start > found.start)
)
found = block;
}
return found;
}
function isColumnBlock(block: Block): boolean {
return block.parent?.label === 'columns';
}
function isStorageTypeBlock(blocks: readonly Block[], block: Block): boolean {
const types = block.parent;
return (
types?.label === 'types' &&
blocks.some((other) => other.parent === types.parent && other.label === 'namespaces')
);
}
function isInColumnDefault(block: Block): boolean {
for (let ancestor = block.parent; ancestor !== undefined; ancestor = ancestor.parent)
if (
ancestor.label === 'default' &&
ancestor.parent !== undefined &&
isColumnBlock(ancestor.parent)
)
return true;
return false;
}
function holdsDataType(blocks: readonly Block[], block: Block): boolean {
return (isColumnBlock(block) || isStorageTypeBlock(blocks, block)) && !isInColumnDefault(block);
}
function genericArgumentEnd(text: string, start: number): number {
let depth = 0;
let quote: string | undefined;
for (let index = start; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === "'" || char === '"' || char === '`') quote = char;
else if (char === '{' || char === '[' || char === '(' || char === '<') depth++;
else if (char === '}' || char === ']' || char === ')') depth--;
else if (char === '>') {
if (depth === 0) return index;
depth--;
}
}
return text.length;
}
function memberOf(
text: string,
blocks: readonly Block[],
block: Block,
name: string,
): string | undefined {
const pattern = new RegExp(`readonly ${name}: (['"])((?:\\\\.|(?!\\1).)*)\\1`, 'g');
for (const member of text.slice(block.start, block.end).matchAll(pattern)) {
if (innermostBlock(blocks, block.start + member.index) === block) return member[2];
}
return undefined;
}
/** The end of the type literal that starts at `start`: the first `;`, `,` or closing bracket outside it. */
function literalEnd(text: string, start: number): number {
let depth = 0;
let quote: string | undefined;
for (let index = start; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === "'" || char === '"' || char === '`') quote = char;
else if (char === '{' || char === '[' || char === '(' || char === '<') depth++;
else if (char === '}' || char === ']' || char === ')' || char === '>') {
if (depth === 0) return index;
depth--;
} else if ((char === ';' || char === ',') && depth === 0) return index;
}
return text.length;
}
function bracketEnd(text: string, open: number): number {
let depth = 0;
let quote: string | undefined;
for (let index = open; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === "'" || char === '"' || char === '`') quote = char;
else if (char === '[' || char === '{' || char === '(') depth++;
else if (char === ']' || char === '}' || char === ')') {
depth--;
if (depth === 0) return index + 1;
}
}
return text.length;
}
function replaceQuotedHashes(text: string, hashes: ReadonlyMap<string, string>): string {
return text.replace(/"([0-9a-f]{64})"/g, (match: string, hash: string) => {
const next = hashes.get(hash);
return next === undefined ? match : `"${next}"`;
});
}
function readDirectory(dir: string): Map<string, string> {
const files = new Map<string, string>();
if (!existsSync(dir)) return files;
for (const entry of readdirSync(dir, { withFileTypes: true }))
if (isFileEntry(dir, entry) && !entry.name.endsWith(TEMPORARY_SUFFIX))
files.set(entry.name, readFileSync(join(dir, entry.name), 'utf8'));
return files;
}
function sameFiles(a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): boolean {
return a.size === b.size && [...a].every(([name, content]) => b.get(name) === content);
}
function temporaryPath(path: string): string {
return join(dirname(path), `.${basename(path)}${TEMPORARY_SUFFIX}`);
}
function writeWithModeOf(path: string, text: string, modeSource: string): void {
writeFileSync(path, text);
if (existsSync(modeSource)) chmodSync(path, statSync(modeSource).mode & 0o7777);
}
function writeFile(path: string, text: string): void {
const target = realpathSync(path);
const temporary = temporaryPath(target);
writeWithModeOf(temporary, text, target);
renameSync(temporary, target);
}
function writeDirectory(dir: string, files: ReadonlyMap<string, string>, sourceDir: string): void {
const temporary = temporaryPath(dir);
mkdirSync(temporary);
for (const [name, text] of files)
writeWithModeOf(join(temporary, name), text, join(sourceDir, name));
renameSync(temporary, dir);
}
function removeDirectory(dir: string): void {
const temporary = temporaryPath(dir);
renameSync(dir, temporary);
rmSync(temporary, { recursive: true, force: true });
}
function count(amount: number, singular: string, plural: string): string {
return `${amount} ${amount === 1 ? singular : plural}`;
}
function summary(
root: string,
contractsFound: number,
files: number,
directories: number,
hashes: ReadonlyMap<string, string>,
): readonly string[] {
if (contractsFound === 0) return [`No SQLite contract was found under ${root}; nothing changed.`];
if (files === 0 && directories === 0)
return ['The project is already in the new form; nothing changed.'];
const renamed = count(directories, 'snapshot directory', 'snapshot directories');
const changes = `Rewrote ${count(files, 'file', 'files')} and renamed ${renamed}.`;
if (hashes.size === 0) return [changes];
return [
`${changes} Storage hashes changed (old -> new):`,
...[...hashes]
.sort(([a], [b]) => compareCodeUnits(a, b))
.map(([oldHash, newHash]) => ` ${oldHash} -> ${newHash}`),
];
}
/** The text `sqlite/datetime` stores for an instant the previous release stored: three fraction digits, always. */
function storedDatetime(value: Json): Json {
if (typeof value !== 'string') return value;
const match = UTC_INSTANT.exec(value);
return match === null ? value : `${match[1]}.${(match[2] ?? '').padEnd(3, '0')}Z`;
}
function storedDatetimes(value: Json): Json {
return Array.isArray(value) ? value.map(storedDatetime) : storedDatetime(value);
}
interface Rewrite {
readonly contract: JsonRecord;
readonly changed: boolean;
readonly datetimeEnums: ReadonlySet<string>;
}
/**
* A `sqlite/datetime@1` enum's members in the domain, and the storage value set its columns name,
* hold instants, so they are written as `sqlite/datetime` stores them too.
*/
function rewriteDatetimeEnums(contract: JsonRecord): Rewrite {
const datetimeEnums = new Set<string>();
let changed = false;
const rewritten = (values: readonly Json[]): Json[] => {
const next = values.map(storedDatetime);
if (canonicalizeJson(next) !== canonicalizeJson([...values])) changed = true;
return next;
};
const domain = mapRecord(contract['domain'] ?? null, (key, namespaces) =>
key !== 'namespaces'
? namespaces
: mapRecord(namespaces, (namespaceId, namespace) =>
mapRecord(namespace, (kind, enums) =>
kind !== 'enum'
? enums
: mapRecord(enums, (name, enumType) => {
const members = isRecord(enumType) ? enumType['members'] : undefined;
if (
!isRecord(enumType) ||
enumType['codecId'] !== DATETIME_CODEC ||
!Array.isArray(members)
)
return enumType;
datetimeEnums.add(enumKey(namespaceId, name));
const values = rewritten(
members.map((member) => (isRecord(member) ? (member['value'] ?? null) : null)),
);
return {
...enumType,
members: members.map((member, index) =>
isRecord(member) && member['value'] !== undefined
? { ...member, value: values[index] ?? null }
: member,
),
};
}),
),
),
);
const storage = contract['storage'];
const namespaces = isRecord(storage) ? storage['namespaces'] : undefined;
for (const namespace of isRecord(namespaces) ? Object.values(namespaces) : []) {
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
const tables = isRecord(entries) ? entries['table'] : undefined;
for (const table of isRecord(tables) ? Object.values(tables) : []) {
const columns = isRecord(table) ? table['columns'] : undefined;
for (const column of isRecord(columns) ? Object.values(columns) : []) {
const valueSet = isRecord(column) ? column['valueSet'] : undefined;
if (
isRecord(column) &&
column['codecId'] === DATETIME_CODEC &&
isRecord(valueSet) &&
typeof valueSet['namespaceId'] === 'string' &&
typeof valueSet['entityName'] === 'string'
)
datetimeEnums.add(enumKey(valueSet['namespaceId'], valueSet['entityName']));
}
}
}
const nextStorage = mapRecord(storage ?? null, (key, storageNamespaces) =>
key !== 'namespaces'
? storageNamespaces
: mapRecord(storageNamespaces, (namespaceId, namespace) =>
mapRecord(namespace, (namespaceKey, entries) =>
namespaceKey !== 'entries'
? entries
: mapRecord(entries, (entryKind, valueSets) =>
entryKind !== 'valueSet'
? valueSets
: mapRecord(valueSets, (name, valueSet) => {
const values = isRecord(valueSet) ? valueSet['values'] : undefined;
if (
!datetimeEnums.has(enumKey(namespaceId, name)) ||
!isRecord(valueSet) ||
!Array.isArray(values)
)
return valueSet;
return { ...valueSet, values: rewritten(values) };
}),
),
),
),
);
return {
contract: {
...contract,
...(contract['domain'] === undefined ? {} : { domain }),
...(storage === undefined ? {} : { storage: nextStorage }),
},
changed,
datetimeEnums,
};
}
function rewriteContract(contract: JsonRecord): Rewrite {
if (contract['target'] !== 'sqlite')
return { contract, changed: false, datetimeEnums: new Set() };
let changed = false;
const rewriteEntry = (entry: JsonRecord): JsonRecord => {
const codecId = entry['codecId'];
const dataType = typeof codecId === 'string' ? DATA_TYPES[codecId] : undefined;
if (dataType === undefined) return entry;
let next = entry;
if (entry['dataType'] === PREVIOUS_DATA_TYPE) {
next = { ...next, dataType };
changed = true;
}
const columnDefault = next['default'];
if (
codecId === DATETIME_CODEC &&
isRecord(columnDefault) &&
columnDefault['kind'] === 'literal' &&
columnDefault['value'] !== undefined
) {
const value = storedDatetimes(columnDefault['value']);
if (canonicalizeJson(value) !== canonicalizeJson(columnDefault['value'])) {
next = { ...next, default: { ...columnDefault, value } };
changed = true;
}
}
return next;
};
const enums = rewriteDatetimeEnums(mapContractEntries(contract, DATA_TYPE_PATHS, rewriteEntry));
return {
contract: enums.contract,
changed: changed || enums.changed,
datetimeEnums: enums.datetimeEnums,
};
}
interface Edit {
readonly start: number;
readonly end: number;
readonly text: string;
}
/** The quoted instants between `start` and `end`, each written as `sqlite/datetime` stores it. */
function datetimeEdits(text: string, start: number, end: number): Edit[] {
const edits: Edit[] = [];
for (const match of text.slice(start, end).matchAll(/(['"])((?:\\.|(?!\1).)*)\1/g)) {
const value = match[2] ?? '';
const stored = storedDatetime(value);
if (stored === value) continue;
const at = start + match.index + 1;
edits.push({ start: at, end: at + value.length, text: String(stored) });
}
return edits;
}
function rewriteDts(
text: string,
datetimeEnums: ReadonlySet<string>,
hashes: ReadonlyMap<string, string>,
): string {
const blocks = scanBlocks(text);
const edits: Edit[] = [];
for (const match of text.matchAll(/readonly dataType: (['"])sqlite\/text\1/g)) {
const block = innermostBlock(blocks, match.index);
if (block === undefined || !holdsDataType(blocks, block)) continue;
const codecId = memberOf(text, blocks, block, 'codecId');
const dataType = codecId === undefined ? undefined : DATA_TYPES[codecId];
if (dataType === undefined) continue;
const quote = match[1] ?? "'";
edits.push({
start: match.index,
end: match.index + match[0].length,
text: `readonly dataType: ${quote}${dataType}${quote}`,
});
}
for (const match of text.matchAll(/DefaultLiteralValue<\s*(['"])sqlite\/datetime@1\1\s*,\s*/g)) {
const start = match.index + match[0].length;
edits.push(...datetimeEdits(text, start, genericArgumentEnd(text, start)));
}
for (const match of text.matchAll(/readonly values: readonly \[/g)) {
const valueSet = innermostBlock(blocks, match.index);
const kind = valueSet?.parent;
const namespace = kind?.parent?.parent;
if (
valueSet === undefined ||
kind?.label !== 'valueSet' ||
kind.parent?.label !== 'entries' ||
namespace === undefined ||
!datetimeEnums.has(enumKey(namespace.label, valueSet.label))
)
continue;
const open = match.index + match[0].length - 1;
edits.push(...datetimeEdits(text, open, bracketEnd(text, open)));
}
for (const match of text.matchAll(/readonly value: /g)) {
const member = innermostBlock(blocks, match.index);
const enumType = member?.parent;
const namespace = enumType?.parent?.parent;
if (
member === undefined ||
member.label !== '' ||
enumType?.parent?.label !== 'enum' ||
namespace === undefined ||
!datetimeEnums.has(enumKey(namespace.label, enumType.label))
)
continue;
const start = match.index + match[0].length;
edits.push(...datetimeEdits(text, start, literalEnd(text, start)));
}
for (const match of text.matchAll(/(['"])([0-9a-f]{64})\1/g)) {
const hash = match[2];
const next = hash === undefined ? undefined : hashes.get(hash);
if (next === undefined) continue;
edits.push({
start: match.index,
end: match.index + match[0].length,
text: `${match[1]}${next}${match[1]}`,
});
}
let result = text;
for (const edit of edits.sort((a, b) => b.start - a.start))
result = `${result.slice(0, edit.start)}${edit.text}${result.slice(edit.end)}`;
return result;
}
interface ContractPlan {
readonly path: string;
readonly snapshot: boolean;
readonly oldHash: string;
readonly newHash: string;
readonly content: string;
readonly datetimeEnums: ReadonlySet<string>;
}
const JSON_FILES_THAT_MUST_PARSE = new Set(['contract.json', 'migration.json']);
function parseRoot(args: readonly string[]): { readonly root: string; readonly errors: string[] } {
const errors = args.filter((arg) => arg.startsWith('-')).map((arg) => `${arg}: unknown option`);
const positional = args.filter((arg) => !arg.startsWith('-'));
if (positional.length > 1) errors.push(`expected one project root, got ${positional.length}`);
const root = resolve(positional[0] ?? process.cwd());
if (errors.length === 0 && !isDirectory(root)) errors.push(`${root}: not a directory`);
if (errors.length > 0) errors.push('Usage: node data-type-values.ts [project-root]');
return { root, errors };
}
function main({
root,
errors,
}: {
readonly root: string;
readonly errors: readonly string[];
}): number {
if (errors.length > 0) {
process.stderr.write(`${errors.join('\n')}\n`);
return 1;
}
const display = (path: string): string => relative(root, path).split(sep).join('/');
const files = listFiles(root);
const plans: ContractPlan[] = [];
const notices: string[] = [];
const stops: string[] = [];
let contractsFound = 0;
for (const path of files.json) {
const text = readFileSync(path, 'utf8');
const contract = parseJson(text);
if (
contract === undefined &&
(JSON_FILES_THAT_MUST_PARSE.has(basename(path)) || files.refs.includes(path))
) {
stops.push(`${display(path)}: not valid JSON`);
continue;
}
if (
!isRecord(contract) ||
contract['targetFamily'] !== 'sql' ||
contract['target'] !== 'sqlite' ||
!isRecord(contract['storage'])
)
continue;
contractsFound++;
const snapshot = isSnapshotContract(path);
const stored = contract['storage']['storageHash'];
const oldHash = snapshot ? basename(dirname(path)) : typeof stored === 'string' ? stored : '';
const rewrite = rewriteContract(contract);
if (!rewrite.changed) continue;
if (computeStorageHash(contract) !== oldHash)
notices.push(`${display(path)}: stored hash did not recompute; rehashed from content`);
const newHash = computeStorageHash(rewrite.contract);
const contractWithHash = withStorageHash(rewrite.contract, newHash);
plans.push({
path,
snapshot,
oldHash,
newHash,
content: snapshot ? snapshotForm(contractWithHash) : emittedForm(contractWithHash, text),
datetimeEnums: rewrite.datetimeEnums,
});
}
const hashes = new Map<string, string>();
for (const plan of [...plans].sort((a, b) => Number(b.snapshot) - Number(a.snapshot)))
if (plan.oldHash !== plan.newHash && !hashes.has(plan.oldHash))
hashes.set(plan.oldHash, plan.newHash);
const newDirectories = new Map<string, Map<string, string>>();
const directorySources = new Map<string, string>();
const sourceDirectories = new Set<string>();
const removals = new Set<string>();
const snapshotRewrites: [string, string][] = [];
const referenceWrites = new Map<string, string>();
const contractWrites = new Map<string, string>();
for (const plan of plans) {
const dtsPath = plan.snapshot
? join(dirname(plan.path), 'contract.d.ts')
: `${plan.path.slice(0, -'.json'.length)}.d.ts`;
const dtsHashes = plan.snapshot ? hashes : new Map([...hashes, [plan.oldHash, plan.newHash]]);
const dts = existsSync(dtsPath)
? rewriteDts(readFileSync(dtsPath, 'utf8'), plan.datetimeEnums, dtsHashes)
: undefined;
if (!plan.snapshot) {
if (dts !== undefined) referenceWrites.set(dtsPath, dts);
contractWrites.set(plan.path, plan.content);
continue;
}
const oldDir = dirname(plan.path);
const newDir = join(dirname(oldDir), plan.newHash);
sourceDirectories.add(oldDir);
if (newDir === oldDir) {
if (dts !== undefined) snapshotRewrites.push([dtsPath, dts]);
snapshotRewrites.push([plan.path, plan.content]);
continue;
}
removals.add(oldDir);
const content = readDirectory(oldDir);
content.set('contract.json', plan.content);
if (dts !== undefined) content.set('contract.d.ts', dts);
const planned = newDirectories.get(newDir);
if (planned !== undefined && !sameFiles(planned, content)) {
stops.push(`${display(newDir)}: snapshot directory already exists with different content`);
continue;
}
newDirectories.set(newDir, content);
directorySources.set(newDir, oldDir);
}
for (const [dir, content] of newDirectories) {
if (sourceDirectories.has(dir) || !existsSync(dir)) continue;
if (sameFiles(readDirectory(dir), content)) newDirectories.delete(dir);
else stops.push(`${display(dir)}: snapshot directory already exists with different content`);
}
for (const path of files.migrationJson) {
const text = readFileSync(path, 'utf8');
const metadata = parseJson(text);
if (!isRecord(metadata)) continue;
const from = metadata['from'];
const to = metadata['to'];
const nextFrom = typeof from === 'string' ? (hashes.get(from) ?? from) : from;
const nextTo = typeof to === 'string' ? (hashes.get(to) ?? to) : to;
if (nextFrom === from && nextTo === to) continue;
const opsPath = join(dirname(path), 'ops.json');
const ops = existsSync(opsPath) ? parseJson(readFileSync(opsPath, 'utf8')) : undefined;
if (existsSync(opsPath) && ops === undefined) {
stops.push(`${display(opsPath)}: not valid JSON`);
continue;
}
const oldMigrationHash = metadata['migrationHash'];
let next = replaceQuotedHashes(text, hashes);
if (
ops !== undefined &&
typeof oldMigrationHash === 'string' &&
nextFrom !== undefined &&
nextTo !== undefined
) {
const migrationHash = computeMigrationHash({ ...metadata, from: nextFrom, to: nextTo }, ops);
next = next.replace(`"${oldMigrationHash}"`, `"${migrationHash}"`);
}
referenceWrites.set(path, next);
}
for (const path of files.refs) {
const text = readFileSync(path, 'utf8');
const next = replaceQuotedHashes(text, hashes);
if (next !== text) referenceWrites.set(path, next);
}
for (const path of files.migrationTs) {
const text = readFileSync(path, 'utf8');
const next = text.replace(
/(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])/g,
(hash: string) => hashes.get(hash) ?? hash,
);
if (next !== text) referenceWrites.set(path, next);
}
if (stops.length > 0) {
process.stderr.write(`${[...new Set(stops)].join('\n')}\n`);
return 1;
}
try {
for (const path of files.leftovers) rmSync(path, { recursive: true, force: true });
for (const [dir, content] of newDirectories)
writeDirectory(dir, content, directorySources.get(dir) ?? dir);
for (const [path, text] of snapshotRewrites) writeFile(path, text);
for (const [path, text] of referenceWrites) writeFile(path, text);
for (const [path, text] of contractWrites) writeFile(path, text);
for (const dir of removals) removeDirectory(dir);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(
`${message}; the upgrade stopped partway, run the script again to finish it\n`,
);
return 1;
}
const rewrittenFiles = snapshotRewrites.length + referenceWrites.size + contractWrites.size;
const lines = [
...notices,
...summary(root, contractsFound, rewrittenFiles, removals.size, hashes),
];
process.stdout.write(`${lines.join('\n')}\n`);
return 0;
}
process.exitCode = main(parseRoot(process.argv.slice(2)));
upgrading/app/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/scripts/default-generators-to-on-create/rewrite-default-generators.mjs
#!/usr/bin/env node
// Rewrites `@default(uuid(...))`, `@default(cuid(...))`, `@default(ulid())` and `@default(nanoid(...))`
// to `@onCreate(...)` in the Prisma 8 schema files named on the command line. The call is kept as
// written; string literals and `//` comments are left unchanged. A file without the `// use prisma-8` directive (or the
// older `// use prisma-next`) is left unchanged.
import { readFileSync, writeFileSync } from 'node:fs';
import process from 'node:process';
const CLIENT_FUNCTION_IN_DEFAULT = /@default\((\s*(?:uuid|cuid|ulid|nanoid)\s*\([^()]*\)\s*)\)/g;
// The directive rule contract emit uses, copied from isPrismaNextSchema in
// packages/1-framework/2-authoring/psl-parser/src/schema-directive.ts; this script ships standalone.
const PRISMA8_DIRECTIVE = /^(\s*\/\/ *use +)(prisma-8|prisma-next)( *)(?!\S)/;
/** The line cut into code, string literals and a trailing `//` comment, with only the code to rewrite. */
function spansOf(line) {
const spans = [];
let codeStart = 0;
let index = 0;
while (index < line.length) {
if (line[index] === '/' && line[index + 1] === '/') break;
if (line[index] !== '"') {
index += 1;
continue;
}
spans.push({ text: line.slice(codeStart, index), code: true });
let close = index + 1;
while (close < line.length && line[close] !== '"') close += line[close] === '\\' ? 2 : 1;
spans.push({ text: line.slice(index, close + 1), code: false });
index = close + 1;
codeStart = index;
}
spans.push({ text: line.slice(codeStart, index), code: true });
spans.push({ text: line.slice(index), code: false });
return spans;
}
function rewrite(text) {
let count = 0;
const lines = text.split('\n').map((line) =>
spansOf(line)
.map(({ text: span, code }) =>
code
? span.replace(CLIENT_FUNCTION_IN_DEFAULT, (_match, call) => {
count += 1;
return `@onCreate(${call})`;
})
: span,
)
.join(''),
);
return { text: lines.join('\n'), count };
}
const files = process.argv.slice(2);
if (files.length === 0) {
process.stderr.write('Usage: node rewrite-default-generators.mjs <schema.prisma>...\n');
process.exit(1);
}
const texts = new Map();
for (const file of files) {
try {
texts.set(file, readFileSync(file, 'utf8'));
} catch {
process.stderr.write(`${file}: cannot be read; no file was changed.\n`);
process.exit(1);
}
}
for (const [file, text] of texts) {
if (!PRISMA8_DIRECTIVE.test(text)) {
process.stdout.write(
`${file}: has no "// use prisma-8" directive, so it is not a Prisma 8 schema; left unchanged. A Prisma 7 schema keeps @default(uuid()).\n`,
);
continue;
}
const rewritten = rewrite(text);
if (rewritten.count === 0) {
process.stdout.write(`${file}: no client function in @default; left unchanged.\n`);
continue;
}
writeFileSync(file, rewritten.text);
process.stdout.write(
`${file}: rewrote ${rewritten.count} @default ${rewritten.count === 1 ? 'call' : 'calls'} to @onCreate.\n`,
);
}
upgrading/app/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/scripts/mongo-field-storage-names/mongo-field-storage-names.ts
#!/usr/bin/env node
// Adds `storage.fields` to every MongoDB contract written before models recorded the stored name of each field.
// Each field keeps its name and is recorded as stored under that name, which is what the old contract meant.
// Usage: node mongo-field-storage-names.ts [project-root]
import {
readdirSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { join, relative, resolve } from 'node:path';
const SKIPPED_DIRECTORIES = new Set(['node_modules', 'dist', 'build']);
const TEMPORARY_SUFFIX = '.mongo-field-storage-names-tmp';
type JsonObject = { [key: string]: unknown };
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
/** The JSON files under `root`, each once, by the first path that reaches it. */
function listJsonFiles(root: string): string[] {
const visited = new Set<string>();
const found = new Map<string, string>();
const visit = (dir: string): void => {
const realDir = realpathSync(dir);
if (visited.has(realDir)) return;
visited.add(realDir);
for (const entry of readdirSync(dir).sort()) {
const path = join(dir, entry);
if (entry.endsWith(TEMPORARY_SUFFIX)) {
rmSync(path, { force: true });
continue;
}
if (isDirectory(path)) {
if (!SKIPPED_DIRECTORIES.has(entry) && !entry.startsWith('.')) visit(path);
continue;
}
if (!entry.endsWith('.json') || !isFile(path)) continue;
const realFile = realpathSync(path);
if (!found.has(realFile)) found.set(realFile, path);
}
};
visit(root);
return [...found.values()];
}
function parseRoot(args: readonly string[]): { readonly root: string; readonly errors: string[] } {
const errors = args.filter((arg) => arg.startsWith('-')).map((arg) => `${arg}: unknown option`);
const positional = args.filter((arg) => !arg.startsWith('-'));
if (positional.length > 1) errors.push(`expected one project root, got ${positional.length}`);
const root = resolve(positional[0] ?? process.cwd());
if (errors.length === 0 && !isDirectory(root)) errors.push(`${root}: not a directory`);
if (errors.length > 0) errors.push('Usage: node mongo-field-storage-names.ts [project-root]');
return { root, errors };
}
function modelsOf(contract: JsonObject): [string, JsonObject][] {
const domain = contract['domain'];
if (!isObject(domain) || !isObject(domain['namespaces'])) return [];
return Object.values(domain['namespaces']).flatMap((namespace) =>
isObject(namespace) && isObject(namespace['models'])
? Object.entries(namespace['models']).filter((entry): entry is [string, JsonObject] =>
isObject(entry[1]),
)
: [],
);
}
function needsStorageFields(model: JsonObject): boolean {
return isObject(model['fields']) && isObject(model['storage']) && !('fields' in model['storage']);
}
function withStorageFields(model: JsonObject): JsonObject {
const storage = model['storage'];
const fields = model['fields'];
if (!isObject(storage) || !isObject(fields)) return model;
const storageFields = Object.fromEntries(
Object.keys(fields).map((name) => [name, { field: name }]),
);
const rewritten: JsonObject = {};
if ('collection' in storage) rewritten['collection'] = storage['collection'];
rewritten['fields'] = storageFields;
for (const [key, value] of Object.entries(storage)) {
if (key !== 'collection') rewritten[key] = value;
}
return { ...model, storage: rewritten };
}
function rewriteContract(contract: JsonObject, modelNames: ReadonlySet<string>): JsonObject {
const domain = contract['domain'];
if (!isObject(domain) || !isObject(domain['namespaces'])) return contract;
const namespaces = Object.fromEntries(
Object.entries(domain['namespaces']).map(([id, namespace]) => {
if (!isObject(namespace) || !isObject(namespace['models'])) return [id, namespace];
const models = Object.fromEntries(
Object.entries(namespace['models']).map(([name, model]) => [
name,
isObject(model) && modelNames.has(name) ? withStorageFields(model) : model,
]),
);
return [id, { ...namespace, models }];
}),
);
return { ...contract, domain: { ...domain, namespaces } };
}
function serializeLike(original: string, value: JsonObject): string {
const trailingNewline = original.endsWith('\n') ? '\n' : '';
const singleLine = !original.trimEnd().includes('\n');
return `${singleLine ? JSON.stringify(value) : JSON.stringify(value, null, 2)}${trailingNewline}`;
}
function matchingBrace(text: string, open: number): number {
let depth = 0;
for (let index = open; index < text.length; index++) {
const char = text[index];
if (char === "'" || char === '"' || char === '`') {
index = text.indexOf(char, index + 1);
if (index === -1) return -1;
continue;
}
if (char === '{') depth++;
if (char === '}') {
depth--;
if (depth === 0) return index;
}
}
return -1;
}
function typeKey(name: string, quote: string): string {
return /^[A-Za-z_$][\w$]*$/.test(name) ? name : `${quote}${name}${quote}`;
}
function storageFieldsType(fieldNames: readonly string[], quote: string): string {
if (fieldNames.length === 0) return 'readonly fields: Record<string, never>';
const entries = fieldNames.map(
(name) => `readonly ${typeKey(name, quote)}: { readonly field: ${quote}${name}${quote} }`,
);
return `readonly fields: { ${entries.join('; ')} }`;
}
const STORAGE_KEY = /readonly storage:\s*/g;
/** How many braces are open at `end` in `text`, outside string literals. */
function braceDepth(text: string, end: number): number {
let depth = 0;
for (let index = 0; index < end; index++) {
const char = text[index];
if (char === "'" || char === '"' || char === '`') {
const close = text.indexOf(char, index + 1);
if (close === -1 || close >= end) return depth;
index = close;
continue;
}
if (char === '{') depth++;
if (char === '}') depth--;
}
return depth;
}
/** The model's `readonly storage: ...` type with `readonly fields` added, or undefined when the model's block is not found. */
function rewriteModelType(
declarations: string,
modelName: string,
fieldNames: readonly string[],
): string | undefined {
const quote = declarations.includes("readonly collection: '") ? "'" : '"';
const header = new RegExp(`readonly (?:${modelName}|'${modelName}'|"${modelName}"):\\s*\\{`, 'g');
for (const match of declarations.matchAll(header)) {
const open = match.index + match[0].length - 1;
const close = matchingBrace(declarations, open);
if (close === -1) continue;
const block = declarations.slice(open, close + 1);
STORAGE_KEY.lastIndex = 0;
for (const storageMatch of block.matchAll(STORAGE_KEY)) {
if (braceDepth(block, storageMatch.index) !== 1) continue;
const typeStart = open + storageMatch.index + storageMatch[0].length;
const fields = storageFieldsType(fieldNames, quote);
if (declarations.startsWith('Record<string, never>', typeStart)) {
return `${declarations.slice(0, typeStart)}{ ${fields} }${declarations.slice(typeStart + 'Record<string, never>'.length)}`;
}
if (declarations[typeStart] !== '{') continue;
const typeEnd = matchingBrace(declarations, typeStart);
const inner = declarations
.slice(typeStart + 1, typeEnd)
.trim()
.replace(/;$/, '');
const members = inner.length === 0 ? [] : [inner];
const collectionEnd = inner.match(/^readonly collection:\s*(['"])[^'"]*\1/)?.[0];
const rewritten =
collectionEnd === undefined
? [fields, ...members]
: [collectionEnd, fields, inner.slice(collectionEnd.length).replace(/^;\s*/, '')].filter(
(member) => member.length > 0,
);
return `${declarations.slice(0, typeStart)}{ ${rewritten.join('; ')} }${declarations.slice(typeEnd + 1)}`;
}
}
return undefined;
}
function writeFile(path: string, content: string): void {
const target = realpathSync(path);
const temporary = `${target}${TEMPORARY_SUFFIX}`;
writeFileSync(temporary, content);
renameSync(temporary, target);
}
function main({
root,
errors,
}: {
readonly root: string;
readonly errors: readonly string[];
}): number {
if (errors.length > 0) {
process.stderr.write(`${errors.join('\n')}\n`);
return 1;
}
const files = listJsonFiles(root);
const changed: string[] = [];
const warnings: string[] = [];
let mongoContracts = 0;
for (const path of files.sort()) {
let contract: unknown;
try {
contract = JSON.parse(readFileSync(path, 'utf8'));
} catch {
continue;
}
if (!isObject(contract) || contract['targetFamily'] !== 'mongo') continue;
mongoContracts++;
const oldModels = modelsOf(contract).filter(([, model]) => needsStorageFields(model));
if (oldModels.length === 0) continue;
const original = readFileSync(path, 'utf8');
const modelNames = new Set(oldModels.map(([name]) => name));
writeFile(path, serializeLike(original, rewriteContract(contract, modelNames)));
changed.push(relative(root, path));
const declarationsPath = `${path.slice(0, -'.json'.length)}.d.ts`;
let declarations: string;
try {
declarations = readFileSync(declarationsPath, 'utf8');
} catch {
continue;
}
let rewritten = declarations;
for (const [name, model] of oldModels) {
const fields = isObject(model['fields']) ? Object.keys(model['fields']) : [];
const next = rewriteModelType(rewritten, name, fields);
if (next === undefined) {
warnings.push(
`${relative(root, declarationsPath)}: no storage type found for model ${name}; add readonly fields to it by hand`,
);
continue;
}
rewritten = next;
}
if (rewritten !== declarations) {
writeFile(declarationsPath, rewritten);
changed.push(relative(root, declarationsPath));
}
}
for (const warning of warnings) process.stderr.write(`${warning}\n`);
if (mongoContracts === 0) {
process.stdout.write('No MongoDB contract found under the project root; nothing changed.\n');
return 0;
}
if (changed.length === 0) {
process.stdout.write(
'Every MongoDB contract already records its stored names; nothing changed.\n',
);
return 0;
}
for (const path of changed) process.stdout.write(`${path}\n`);
process.stdout.write(`Rewrote ${changed.length} file${changed.length === 1 ? '' : 's'}.\n`);
return warnings.length > 0 ? 1 : 0;
}
process.exitCode = main(parseRoot(process.argv.slice(2)));
upgrading/app/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/scripts/remove-nocheck/remove-nocheck.ts
#!/usr/bin/env node
/**
* Removes the `noCheck` key from every column of every SQL contract in a project. Where the key waived the element check of a list, the column and the field that maps it become lists whose elements may be null, in `contract.json` and `contract.d.ts`, because that is what the database enforces. The checks each contract declares are left as they are. In a PostgreSQL contract it also writes each literal default in the contract form this release stores, as its column's data type and parameters give it. The script then renames the snapshot directories and rewrites every migration, ref, `migration.ts` and `contract.d.ts` that names an old storage hash.
*
* Usage: node remove-nocheck.ts [project-root] [--default <namespace>.<table>.<column>=<JSON value>]...
*
* The project root defaults to the working directory. The script reads and writes files only. It needs no database, network or configured stack, and a project already in this release's form is left unchanged. When it stops (a file it must read is not valid JSON, a contract whose stored hash does not match its content, a default that this release refuses, or a snapshot directory that already exists with different content) it changes no file, prints one line per case and exits 1. When it finishes it prints how many files and snapshot directories it changed and each storage hash it replaced.
*
* `--default` gives the value to store in place of a default this release refuses, in every stored contract where that column's default is refused. The script checks the value by the same rules, and stops when a `--default` names a column whose default it does not refuse.
*
* The storage hash and migration hash rules are copied from `@internal/contract` and `@internal/migration-tools`, and the default rules from the data types of 8.0.0-rc.19, PostgreSQL's built-in types, `postgis/geometry` and `pgvector/vector`, so the script gives the same result whatever is installed.
*/
import { createHash } from 'node:crypto';
import {
chmodSync,
type Dirent,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
type Json = null | boolean | number | string | Json[] | JsonRecord;
interface JsonRecord {
[key: string]: Json;
}
const SKIPPED_DIRECTORIES = new Set(['node_modules', 'dist', 'build']);
const HASH = /^[0-9a-f]{64}$/;
const TEMPORARY_SUFFIX = '.remove-nocheck-tmp';
function isRecord(value: unknown): value is JsonRecord {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const prototype: unknown = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function parseJson(text: string): Json | undefined {
try {
const value: Json = JSON.parse(text);
return value;
} catch {
return undefined;
}
}
function sha256(text: string): string {
return createHash('sha256').update(text).digest('hex');
}
function compareCodeUnits(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
function sortKeys(value: Json): Json {
if (Array.isArray(value)) return value.map(sortKeys);
if (!isRecord(value)) return value;
const sorted: JsonRecord = {};
for (const key of Object.keys(value).sort(compareCodeUnits)) {
const child = value[key];
if (child !== undefined) sorted[key] = sortKeys(child);
}
return sorted;
}
function canonicalizeJson(value: Json): string {
return JSON.stringify(sortKeys(value));
}
type PathSegment = string | readonly string[];
function matchesPath(path: readonly string[], pattern: readonly PathSegment[]): boolean {
if (path.length !== pattern.length) return false;
return pattern.every((segment, index) => {
const value = path[index];
if (value === undefined) return false;
if (segment === '*') return true;
return typeof segment === 'string' ? value === segment : segment.includes(value);
});
}
const SQL_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
['storage', 'namespaces', '*', 'entries', 'table'],
['storage', 'namespaces', '*', 'entries', 'table', '*'],
['storage', 'namespaces', '*', 'entries', 'table', '*', ['uniques', 'indexes', 'foreignKeys']],
['storage', 'namespaces', '*', 'entries', 'table', '*', 'indexes', 'unique'],
['storage', 'namespaces', '*', 'entries', 'table', '*', 'columns', '*', 'default', 'value'],
];
const COLUMN_DEFAULT_VALUE: readonly PathSegment[] = [
'storage',
'namespaces',
'*',
'entries',
'table',
'*',
'columns',
'*',
'default',
'value',
];
const FRAMEWORK_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
['domain', 'namespaces'],
['domain', 'namespaces', '*'],
['domain', 'namespaces', '*', 'models'],
['domain', 'namespaces', '*', 'models', '*', 'relations'],
['domain', 'namespaces', '*', 'models', '*', 'storage'],
['storage', 'namespaces'],
['storage', 'namespaces', '*', 'entries'],
['roots'],
['extensions'],
['extensions', '*'],
['capabilities'],
['meta'],
['execution', 'mutations', 'defaults'],
];
const TOP_LEVEL_ORDER = [
'schemaVersion',
'canonicalVersion',
'targetFamily',
'target',
'profileHash',
'roots',
'domain',
'storage',
'execution',
'capabilities',
'extensions',
'defaultControlPolicy',
'meta',
];
function isDefaultValue(value: Json): boolean {
if (value === false) return true;
if (Array.isArray(value)) return value.length === 0;
return isRecord(value) && Object.keys(value).length === 0;
}
function isPreservedEmpty(path: readonly string[], key: string): boolean {
if (key === 'nullable') return true;
if (key === 'elementNullable' && path.at(-2) === 'many') return true;
if (FRAMEWORK_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern))) return true;
if (
path.length >= COLUMN_DEFAULT_VALUE.length &&
matchesPath(path.slice(0, COLUMN_DEFAULT_VALUE.length), COLUMN_DEFAULT_VALUE)
)
return true;
return SQL_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern));
}
function omitDefaults(value: Json, path: readonly string[]): Json {
if (Array.isArray(value)) return value.map((item) => omitDefaults(item, path));
if (!isRecord(value)) return value;
const result: JsonRecord = {};
for (const [key, child] of Object.entries(value)) {
const childPath = [...path, key];
if (key === '_generated') continue;
if (key === 'generated' && child === false) continue;
if ((key === 'onDelete' || key === 'onUpdate') && child === 'noAction') continue;
if (isDefaultValue(child) && !isPreservedEmpty(childPath, key)) continue;
result[key] = omitDefaults(child, childPath);
}
return result;
}
function compareByName(a: Json, b: Json): number {
const nameA = isRecord(a) && typeof a['name'] === 'string' ? a['name'] : '';
const nameB = isRecord(b) && typeof b['name'] === 'string' ? b['name'] : '';
return compareCodeUnits(nameA, nameB);
}
function sortTableArrays(storage: Json): Json {
if (!isRecord(storage)) return storage;
const namespaces = storage['namespaces'];
if (!isRecord(namespaces)) return storage;
const sortedNamespaces: JsonRecord = {};
for (const [namespaceId, namespace] of Object.entries(namespaces)) {
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
const tables = isRecord(entries) ? entries['table'] : undefined;
if (!isRecord(namespace) || !isRecord(entries) || !isRecord(tables)) {
sortedNamespaces[namespaceId] = namespace;
continue;
}
const sortedTables: JsonRecord = {};
for (const [tableName, table] of Object.entries(tables)) {
if (!isRecord(table)) {
sortedTables[tableName] = table;
continue;
}
const sortedTable: JsonRecord = { ...table };
for (const key of ['checks', 'indexes', 'uniques']) {
const list = table[key];
if (Array.isArray(list)) sortedTable[key] = [...list].sort(compareByName);
}
sortedTables[tableName] = sortedTable;
}
sortedNamespaces[namespaceId] = { ...namespace, entries: { ...entries, table: sortedTables } };
}
return { ...storage, namespaces: sortedNamespaces };
}
function orderTopLevel(value: JsonRecord): JsonRecord {
const ordered: JsonRecord = {};
const remaining = new Set(Object.keys(value));
for (const key of TOP_LEVEL_ORDER) {
const child = value[key];
if (child !== undefined) {
ordered[key] = child;
remaining.delete(key);
}
}
for (const key of [...remaining].sort(compareCodeUnits)) {
const child = value[key];
if (child !== undefined) ordered[key] = child;
}
return ordered;
}
function omitNamespaceKinds(storage: JsonRecord): JsonRecord {
const namespaces = storage['namespaces'];
if (!isRecord(namespaces)) return storage;
const stripped: JsonRecord = {};
for (const [namespaceId, namespace] of Object.entries(namespaces)) {
if (isRecord(namespace)) {
const { kind: _kind, ...rest } = namespace;
stripped[namespaceId] = rest;
} else {
stripped[namespaceId] = namespace;
}
}
return { ...storage, namespaces: stripped };
}
function computeStorageHash(contract: JsonRecord): string {
const storage = isRecord(contract['storage']) ? contract['storage'] : {};
const { storageHash: _published, ...withoutHash } = storage;
const hashed: JsonRecord = {
schemaVersion: '1',
targetFamily: typeof contract['targetFamily'] === 'string' ? contract['targetFamily'] : '',
target: typeof contract['target'] === 'string' ? contract['target'] : '',
profileHash: '',
roots: {},
domain: { namespaces: {} },
storage: omitNamespaceKinds(withoutHash),
extensions: {},
capabilities: {},
meta: {},
};
const withoutDefaults = omitDefaults(hashed, []);
if (!isRecord(withoutDefaults)) return '';
const withSortedStorage = {
...withoutDefaults,
storage: sortTableArrays(withoutDefaults['storage'] ?? {}),
};
const sorted = sortKeys(withSortedStorage);
return isRecord(sorted) ? sha256(JSON.stringify(orderTopLevel(sorted), null, 2)) : '';
}
function computeMigrationHash(metadata: JsonRecord, ops: Json): string {
const { migrationHash: _migrationHash, ...withoutHash } = metadata;
const parts = [canonicalizeJson(withoutHash), canonicalizeJson(ops)].map(sha256);
return sha256(canonicalizeJson(parts));
}
interface ProjectFiles {
readonly json: readonly string[];
readonly migrationJson: readonly string[];
readonly refs: readonly string[];
readonly migrationTs: readonly string[];
readonly leftovers: readonly string[];
}
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
/** A file, or a link to one: the script reads and writes through links to files. */
function isFileEntry(dir: string, entry: Dirent): boolean {
return entry.isFile() || (entry.isSymbolicLink() && isFile(join(dir, entry.name)));
}
/** A file the script reads, by the path it was reached through: its own path rather than a link's. */
interface FoundFile {
readonly path: string;
readonly linked: boolean;
readonly inMigrationPackage: boolean;
}
function listFiles(root: string): ProjectFiles {
const leftovers: string[] = [];
const visited = new Set<string>();
const found = new Map<string, FoundFile>();
const visit = (dir: string): void => {
const realDir = realpathSync(dir);
if (visited.has(realDir)) return;
visited.add(realDir);
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
compareCodeUnits(a.name, b.name),
);
const inMigrationPackage = entries.some(
(entry) => entry.name === 'migration.json' && isFileEntry(dir, entry),
);
for (const entry of entries) {
const path = join(dir, entry.name);
if (entry.name.endsWith(TEMPORARY_SUFFIX)) {
leftovers.push(path);
continue;
}
if (entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(path))) {
if (!SKIPPED_DIRECTORIES.has(entry.name) && !entry.name.startsWith('.')) visit(path);
continue;
}
if (!isFileEntry(dir, entry)) continue;
const realFile = realpathSync(path);
const linked = entry.isSymbolicLink();
const known = found.get(realFile);
if (known === undefined || (known.linked && !linked))
found.set(realFile, { path, linked, inMigrationPackage });
}
};
visit(root);
const files = [...found.values()];
const named = (test: (file: FoundFile, name: string) => boolean): string[] =>
files.filter((file) => test(file, basename(file.path))).map((file) => file.path);
return {
json: named((_file, name) => name.endsWith('.json')),
migrationJson: named((_file, name) => name === 'migration.json'),
refs: named((file, name) => name.endsWith('.json') && basename(dirname(file.path)) === 'refs'),
migrationTs: named((file, name) => name === 'migration.ts' && file.inMigrationPackage),
leftovers,
};
}
function isSnapshotContract(path: string): boolean {
const snapshotDir = dirname(path);
return (
basename(path) === 'contract.json' &&
HASH.test(basename(snapshotDir)) &&
basename(dirname(snapshotDir)) === 'snapshots'
);
}
function replaceQuotedHashes(text: string, hashes: ReadonlyMap<string, string>): string {
return text.replace(/"([0-9a-f]{64})"/g, (match: string, hash: string) => {
const next = hashes.get(hash);
return next === undefined ? match : `"${next}"`;
});
}
function readDirectory(dir: string): Map<string, string> {
const files = new Map<string, string>();
if (!existsSync(dir)) return files;
for (const entry of readdirSync(dir, { withFileTypes: true }))
if (isFileEntry(dir, entry) && !entry.name.endsWith(TEMPORARY_SUFFIX))
files.set(entry.name, readFileSync(join(dir, entry.name), 'utf8'));
return files;
}
function sameFiles(a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): boolean {
return a.size === b.size && [...a].every(([name, content]) => b.get(name) === content);
}
function temporaryPath(path: string): string {
return join(dirname(path), `.${basename(path)}${TEMPORARY_SUFFIX}`);
}
function writeWithModeOf(path: string, text: string, modeSource: string): void {
writeFileSync(path, text);
if (existsSync(modeSource)) chmodSync(path, statSync(modeSource).mode & 0o7777);
}
function writeFile(path: string, text: string): void {
const target = realpathSync(path);
const temporary = temporaryPath(target);
writeWithModeOf(temporary, text, target);
renameSync(temporary, target);
}
function writeDirectory(dir: string, files: ReadonlyMap<string, string>, sourceDir: string): void {
const temporary = temporaryPath(dir);
mkdirSync(temporary);
for (const [name, text] of files)
writeWithModeOf(join(temporary, name), text, join(sourceDir, name));
renameSync(temporary, dir);
}
function removeDirectory(dir: string): void {
const temporary = temporaryPath(dir);
renameSync(dir, temporary);
rmSync(temporary, { recursive: true, force: true });
}
function count(amount: number, singular: string, plural: string): string {
return `${amount} ${amount === 1 ? singular : plural}`;
}
const REMOVED_KEY = 'noCheck';
const ELEMENT_KIND = 'elementNotNull';
function listKey(namespace: string, owner: string, member: string): string {
return `${namespace}\u0000${owner}\u0000${member}`;
}
interface Rewrite {
readonly contract: JsonRecord;
readonly changed: boolean;
readonly columns: ReadonlySet<string>;
readonly fields: ReadonlySet<string>;
readonly defaults: ReadonlyMap<string, readonly DefaultRewrite[]>;
readonly notValues: readonly string[];
}
interface DefaultRewrite {
readonly from: string | number;
readonly to: string | number;
}
type DefaultForm =
| { readonly kind: 'unchanged' }
| { readonly kind: 'rewritten'; readonly value: Json }
| { readonly kind: 'notAValue'; readonly reason: string };
type Params = Readonly<Record<string, Json>>;
class NotAValue extends Error {}
class CastRefused extends Error {}
function refuseJsonValue(typeId: string, expected: string): never {
throw new NotAValue(`${typeId} JSON value must be ${expected}`);
}
function withoutTrailing(text: string, character: string): string {
let end = text.length;
while (end > 0 && text[end - 1] === character) end -= 1;
return end === text.length ? text : text.slice(0, end);
}
function counted(amount: number, noun: string): string {
return `${amount} ${amount === 1 ? noun : `${noun}s`}`;
}
function integerParam(params: Params, name: string): number | undefined {
const value = params[name];
return typeof value === 'number' ? value : undefined;
}
function readJsonString(typeId: string, json: Json): string {
return typeof json === 'string' ? json : refuseJsonValue(typeId, 'a string');
}
function readJsonMatching(typeId: string, json: Json, pattern: RegExp, form: string): string {
return typeof json === 'string' && pattern.test(json) ? json : refuseJsonValue(typeId, form);
}
function readJsonInteger(typeId: string, json: Json, min: number, max: number): Json {
return typeof json === 'number' && Number.isInteger(json) && json >= min && json <= max
? json
: refuseJsonValue(typeId, `an integer from ${min} to ${max}`);
}
const INT64_MIN = -(2n ** 63n);
const INT64_MAX = 2n ** 63n - 1n;
function readInt8(json: Json): Json {
const expected = `a decimal integer string from ${INT64_MIN} to ${INT64_MAX}`;
const value = BigInt(readJsonMatching('pg/int8', json, /^-?\d+$/, expected));
if (value < INT64_MIN || value > INT64_MAX) return refuseJsonValue('pg/int8', expected);
const written = value.toString();
if (json !== written)
return refuseJsonValue(
'pg/int8',
`"${written}", the integer's decimal text without leading zeros or a minus sign on zero`,
);
return json;
}
const NON_FINITE_TEXT: ReadonlySet<string> = new Set(['NaN', 'Infinity', '-Infinity']);
function readJsonFloat(typeId: string, json: Json): number {
if (typeof json === 'number' && Number.isFinite(json)) return json;
if (typeof json === 'string' && NON_FINITE_TEXT.has(json)) return Number(json);
return refuseJsonValue(typeId, 'a finite number or the text NaN, Infinity or -Infinity');
}
const FLOAT4_MAX = 3.4028234663852886e38;
function fitsFloat4(value: number): boolean {
const single = Math.fround(value);
return Number.isFinite(single) && (value === 0 || single !== 0);
}
function readFloat4(json: Json): Json {
const value = readJsonFloat('pg/float4', json);
if (Number.isFinite(value) && !fitsFloat4(value))
return refuseJsonValue(
'pg/float4',
`a number float4 holds, at most ${FLOAT4_MAX} in magnitude and not so small that it becomes 0, or the text NaN, Infinity or -Infinity`,
);
return json;
}
const DECIMAL_NUMERAL_TEXT = /^-?\d+(?:\.\d+)?$/;
const STORED_NUMERIC_TEXT = /^(?:(?!-0(?:\.0+)?$)-?(?:0|[1-9]\d*)(?:\.\d+)?|NaN|-?Infinity)$/;
function storedNumeralText(text: string): string {
const numeral = /^([+-]?)0*(\d+)(\.\d+)?$/.exec(text);
if (numeral === null) return text;
const [, sign = '', whole = '', fraction = ''] = numeral;
const digits = `${whole}${fraction}`;
return sign !== '-' || /^[0.]+$/.test(digits) ? digits : `-${digits}`;
}
function fitsNumeric(text: string, precision: number, scale: number): boolean {
if (text === 'NaN') return true;
if (text.endsWith('Infinity')) return false;
const [whole = '', fraction = ''] = text.replace(/^-/, '').split('.');
const significantFraction = withoutTrailing(fraction, '0');
const digits = `${whole}${significantFraction}`.replace(/^0+/, '');
if (digits === '') return true;
const shift = scale - significantFraction.length;
if (shift >= 0) return digits.length + shift <= precision;
const units = withoutTrailing(digits, '0');
const droppedZeros = digits.length - units.length;
if (droppedZeros < -shift) return false;
return digits.length + shift <= precision;
}
function readNumeric(json: Json, params: Params): Json {
if (typeof json !== 'string' || !STORED_NUMERIC_TEXT.test(json)) {
const printed =
typeof json === 'string' && DECIMAL_NUMERAL_TEXT.test(json)
? storedNumeralText(json)
: undefined;
return refuseJsonValue(
'pg/numeric',
printed === undefined ? 'a decimal string' : `"${printed}", as PostgreSQL writes this value`,
);
}
const precision = integerParam(params, 'precision');
if (precision === undefined) return json;
const scale = integerParam(params, 'scale') ?? 0;
if (!fitsNumeric(json, precision, scale))
return refuseJsonValue(
'pg/numeric',
`a decimal string that numeric(${precision}, ${scale}) stores without rounding`,
);
return json;
}
function spellNumeric(json: Json, params: Params): Json {
const precision = integerParam(params, 'precision');
if (precision === undefined || typeof json !== 'string' || json === 'NaN') return json;
const scale = integerParam(params, 'scale') ?? 0;
const [whole = '', fraction = ''] = json.split('.');
return scale <= 0 ? whole : `${whole}.${fraction.slice(0, scale).padEnd(scale, '0')}`;
}
function fitsCharacterLength(text: string, length: number, blankPadded: boolean): boolean {
const significant = blankPadded ? withoutTrailing(text, ' ') : text;
return significant.length <= length || [...significant].length <= length;
}
function readCharacter(json: Json, params: Params): Json {
const length = integerParam(params, 'length') ?? 1;
if (!fitsCharacterLength(readJsonString('pg/char', json), length, true))
return refuseJsonValue(
'pg/char',
`a string of at most ${counted(length, 'character')} before any trailing spaces`,
);
return json;
}
function spellCharacter(json: Json): Json {
return typeof json === 'string' ? withoutTrailing(json, ' ') : json;
}
function readCharacterVarying(json: Json, params: Params): Json {
const length = integerParam(params, 'length');
const text = readJsonString('pg/varchar', json);
if (length !== undefined && !fitsCharacterLength(text, length, false))
return refuseJsonValue('pg/varchar', `a string of at most ${counted(length, 'character')}`);
return json;
}
const BIT_STRING = /^[01]*$/;
function readBit(json: Json, params: Params): Json {
const length = integerParam(params, 'length') ?? 1;
const bits = readJsonMatching('pg/bit', json, BIT_STRING, 'a string of 0 and 1 digits');
if (bits.length !== length)
return refuseJsonValue('pg/bit', `a string of exactly ${counted(length, 'bit')}`);
return json;
}
function readBitVarying(json: Json, params: Params): Json {
const length = integerParam(params, 'length');
const bits = readJsonMatching('pg/varbit', json, BIT_STRING, 'a string of 0 and 1 digits');
if (length !== undefined && bits.length > length)
return refuseJsonValue('pg/varbit', `a string of at most ${counted(length, 'bit')}`);
return json;
}
function readTextArray(json: Json): Json {
const expected = 'an array of strings and nulls';
if (!Array.isArray(json)) return refuseJsonValue('pg/text-array', expected);
for (const entry of json)
if (entry !== null && typeof entry !== 'string')
return refuseJsonValue('pg/text-array', expected);
return json;
}
const UUID_INPUT = /^(?:\{[0-9a-f]{4}(?:-?[0-9a-f]{4}){7}\}|[0-9a-f]{4}(?:-?[0-9a-f]{4}){7})$/i;
function storedUuid(text: string): string | undefined {
if (!UUID_INPUT.test(text)) return undefined;
const hex = text.replace(/[{}-]/g, '').toLowerCase();
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
interface InetValue {
readonly bytes: readonly number[];
readonly bits: number;
}
const isDigit = (ch: string | undefined): ch is string => ch !== undefined && /^[0-9]$/.test(ch);
function storedInet(text: string): string | undefined {
if (text.includes(':')) {
const value = readIpv6(text);
return value === undefined ? undefined : printIpv6(value);
}
const value = readIpv4(text);
return value === undefined ? undefined : printIpv4(value);
}
function readIpv4(text: string): InetValue | undefined {
const octets: number[] = [];
let index = 0;
let ch = text[index++];
while (isDigit(ch)) {
let octet = 0;
do {
octet = octet * 10 + Number(ch);
if (octet > 255) return undefined;
ch = text[index++];
} while (isDigit(ch));
if (octets.length === 4) return undefined;
octets.push(octet);
if (ch === undefined || ch === '/') break;
if (ch !== '.') return undefined;
ch = text[index++];
}
let bits = -1;
if (ch === '/' && isDigit(text[index]) && octets.length > 0) {
ch = text[index++];
bits = 0;
do {
bits = bits * 10 + Number(ch);
ch = text[index++];
} while (isDigit(ch));
if (ch !== undefined || bits > 32) return undefined;
}
if (ch !== undefined) return undefined;
if (bits === -1) {
if (octets.length !== 4) return undefined;
bits = 32;
}
if (octets.length === 0 || Math.floor(bits / 8) > octets.length) return undefined;
return { bytes: [...octets, 0, 0, 0, 0].slice(0, 4), bits };
}
function readBits(text: string): number | undefined {
let bits = 0;
let digits = 0;
for (const ch of text) {
if (!isDigit(ch)) return undefined;
if (digits++ !== 0 && bits === 0) return undefined;
bits = bits * 10 + Number(ch);
if (bits > 128) return undefined;
}
return digits === 0 ? undefined : bits;
}
function readIpv4Tail(
text: string,
): { readonly octets: readonly number[]; readonly bits: number | undefined } | undefined {
const octets: number[] = [];
let octet = 0;
let digits = 0;
for (const [index, ch] of [...text].entries()) {
if (isDigit(ch)) {
if (digits++ !== 0 && octet === 0) return undefined;
octet = octet * 10 + Number(ch);
if (octet > 255) return undefined;
continue;
}
if (ch !== '.' && ch !== '/') return undefined;
if (octets.length > 3) return undefined;
octets.push(octet);
if (ch === '/') {
const bits = readBits(text.slice(index + 1));
return bits === undefined ? undefined : { octets, bits };
}
octet = 0;
digits = 0;
}
if (digits === 0 || octets.length > 3) return undefined;
return { octets: [...octets, octet], bits: undefined };
}
function readIpv6(text: string): InetValue | undefined {
const bytes = new Array<number>(16).fill(0);
let filled = 0;
let compressedAt: number | undefined;
let index = 0;
if (text[0] === ':') {
if (text[1] !== ':') return undefined;
index = 1;
}
let tokenStart = index;
let sawHexDigit = false;
let group = 0;
let groupDigits = 0;
let bits = 128;
while (index < text.length) {
const ch = text[index++] ?? '';
if (/^[0-9a-fA-F]$/.test(ch)) {
group = (group << 4) | Number.parseInt(ch, 16);
if (++groupDigits > 4) return undefined;
sawHexDigit = true;
continue;
}
if (ch === ':') {
tokenStart = index;
if (!sawHexDigit) {
if (compressedAt !== undefined) return undefined;
compressedAt = filled;
continue;
}
if (index >= text.length || filled + 2 > 16) return undefined;
bytes[filled++] = group >> 8;
bytes[filled++] = group & 0xff;
sawHexDigit = false;
group = 0;
groupDigits = 0;
continue;
}
if (ch === '.' && filled + 4 <= 16) {
const tail = readIpv4Tail(text.slice(tokenStart));
if (tail !== undefined) {
tail.octets.forEach((octet, offset) => {
bytes[filled + offset] = octet;
});
filled += 4;
sawHexDigit = false;
bits = tail.bits ?? bits;
break;
}
}
if (ch === '/') {
const prefix = readBits(text.slice(index));
if (prefix !== undefined) {
bits = prefix;
break;
}
}
return undefined;
}
if (sawHexDigit) {
if (filled + 2 > 16) return undefined;
bytes[filled++] = group >> 8;
bytes[filled++] = group & 0xff;
}
if (compressedAt !== undefined) {
if (filled === 16) return undefined;
const shifted = bytes.slice(compressedAt, filled);
bytes.fill(0, compressedAt, filled);
bytes.splice(16 - shifted.length, shifted.length, ...shifted);
filled = 16;
}
return filled === 16 ? { bytes, bits } : undefined;
}
function printIpv4({ bytes, bits }: InetValue): string {
const address = bytes.join('.');
return bits === 32 ? address : `${address}/${bits}`;
}
function printIpv6({ bytes, bits }: InetValue): string {
const groups = Array.from(
{ length: 8 },
(_, i) => ((bytes[2 * i] ?? 0) << 8) | (bytes[2 * i + 1] ?? 0),
);
const zeroRun = longestZeroRun(groups);
let text = '';
for (let i = 0; i < 8; i++) {
if (zeroRun !== undefined && i >= zeroRun.start && i < zeroRun.start + zeroRun.length) {
if (i === zeroRun.start) text += ':';
continue;
}
if (i !== 0) text += ':';
const ipv4Tail =
i === 6 &&
zeroRun?.start === 0 &&
(zeroRun.length === 6 || (zeroRun.length === 5 && groups[5] === 0xffff));
if (ipv4Tail) {
text += bytes.slice(12).join('.');
break;
}
text += (groups[i] ?? 0).toString(16);
}
if (zeroRun !== undefined && zeroRun.start + zeroRun.length === 8) text += ':';
return bits === 128 ? text : `${text}/${bits}`;
}
function longestZeroRun(
groups: readonly number[],
): { readonly start: number; readonly length: number } | undefined {
let best: { start: number; length: number } | undefined;
let current: { start: number; length: number } | undefined;
for (const [i, group] of groups.entries()) {
if (group === 0) {
current =
current === undefined
? { start: i, length: 1 }
: { ...current, length: current.length + 1 };
continue;
}
if (current !== undefined && (best === undefined || current.length > best.length))
best = current;
current = undefined;
}
if (current !== undefined && (best === undefined || current.length > best.length)) best = current;
return best !== undefined && best.length >= 2 ? best : undefined;
}
const BASE64_TEXT = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
function spellBytea(json: Json): Json {
return typeof json === 'string' ? Buffer.from(json, 'base64').toString('base64') : json;
}
const ISO_DURATION =
/^P(?!$)(-?\d+Y)?(-?\d+M)?(-?\d+D)?(?:T(?!$)(-?\d+H)?(-?\d+M)?(-?\d+(?:\.\d+)?S)?)?$/;
const MICROS_PER_SECOND = 1_000_000n;
interface IntervalFields {
readonly months: number;
readonly days: number;
readonly micros: bigint;
}
function microsFromFraction(fraction: string, negative: boolean): bigint {
if (fraction === '') return 0n;
const kept = BigInt(fraction.slice(0, 6).padEnd(6, '0'));
const rest = fraction.slice(6).replace(/0+$/, '');
if (rest === '') return kept;
const half = rest[0] === '5' && rest.length === 1;
const roundsUp = rest > '5' || (half && !negative);
return roundsUp ? kept + 1n : kept;
}
function intervalFieldsOf(match: RegExpExecArray): IntervalFields {
const [, years, months, days, hours, minutes, seconds] = match;
const wholeNumber = (part: string | undefined): number =>
part === undefined ? 0 : Number(part.slice(0, -1));
const secondsLiteral = seconds === undefined ? '0' : seconds.slice(0, -1);
const negative = secondsLiteral.startsWith('-');
const [whole = '0', fraction = ''] = secondsLiteral.replace('-', '').split('.');
const magnitude = BigInt(whole) * MICROS_PER_SECOND + microsFromFraction(fraction, negative);
return {
months: wholeNumber(years) * 12 + wholeNumber(months),
days: wholeNumber(days),
micros:
BigInt(wholeNumber(hours)) * 3_600n * MICROS_PER_SECOND +
BigInt(wholeNumber(minutes)) * 60n * MICROS_PER_SECOND +
(negative ? -magnitude : magnitude),
};
}
function secondsText(micros: bigint): string {
const sign = micros < 0n ? '-' : '';
const magnitude = micros < 0n ? -micros : micros;
const whole = (magnitude / MICROS_PER_SECOND) % 60n;
const fraction = magnitude % MICROS_PER_SECOND;
const fractionText =
fraction === 0n ? '' : `.${fraction.toString().padStart(6, '0').replace(/0+$/, '')}`;
return `${sign}${whole}${fractionText}`;
}
function formatIsoDuration({ months, days, micros }: IntervalFields): string {
const years = Math.trunc(months / 12);
const restMonths = months % 12;
const sign = micros < 0n ? '-' : '';
const magnitude = micros < 0n ? -micros : micros;
const hours = magnitude / (3_600n * MICROS_PER_SECOND);
const minutes = (magnitude / (60n * MICROS_PER_SECOND)) % 60n;
const hasSeconds = magnitude % (60n * MICROS_PER_SECOND) !== 0n;
const parts = [
years === 0 ? '' : `${years}Y`,
restMonths === 0 ? '' : `${restMonths}M`,
days === 0 ? '' : `${days}D`,
];
const time = [
hours === 0n ? '' : `${sign}${hours}H`,
minutes === 0n ? '' : `${sign}${minutes}M`,
hasSeconds ? `${secondsText(micros)}S` : '',
].join('');
const rendered = `P${parts.join('')}${time === '' ? '' : `T${time}`}`;
return rendered === 'P' ? 'PT0S' : rendered;
}
const POSTGRES_INTERVAL =
/^(?:([+-]?\d+) years? ?)?(?:([+-]?\d+) mons? ?)?(?:([+-]?\d+) days? ?)?(?:([+-])?(\d+):(\d{2}):(\d{2})(?:\.(\d+))?)?$/;
function postgresIntervalFields(text: string): IntervalFields | undefined {
const match = POSTGRES_INTERVAL.exec(text);
if (match === null || text === '' || text.endsWith(' ')) return undefined;
const [, years = '0', months = '0', days = '0', sign, hours = '0', minutes = '0', seconds = '0'] =
match;
const fraction = match[8] ?? '';
const magnitude =
(BigInt(hours) * 3_600n + BigInt(minutes) * 60n + BigInt(seconds)) * MICROS_PER_SECOND +
microsFromFraction(fraction, sign === '-');
return {
months: Number(years) * 12 + Number(months),
days: Number(days),
micros: sign === '-' ? -magnitude : magnitude,
};
}
function intervalStoredText(text: string): string {
const fractionDigits = /\.(\d+)/.exec(text)?.[1]?.length ?? 0;
if (fractionDigits > 6)
throw new CastRefused(
`"${text}" has ${fractionDigits} digits after the decimal point, but pg/interval holds microseconds, so at most 6. Round it, as in "PT1.123456S".`,
);
const iso = ISO_DURATION.exec(text);
if (iso !== null) return formatIsoDuration(intervalFieldsOf(iso));
const fields = postgresIntervalFields(text);
if (fields === undefined)
throw new CastRefused(
`pg/interval cannot read "${text}". Write an ISO 8601 duration, as in "P1Y2M3DT4H5M6S".`,
);
return formatIsoDuration(fields);
}
function isoYearText(text: string): string {
const match = /^(\d{4,6})(-.*?)( BC)?$/.exec(text);
if (match === null) return text;
const [, digits = '', rest = '', bc] = match;
const year = Number(digits);
if (bc !== undefined) {
if (year === 0) return text;
return `${yearText(1 - year)}${rest}`;
}
return digits.length > 4 ? `${yearText(year)}${rest}` : text;
}
type DateTimeShape = 'date' | 'time' | 'timeWithOffset' | 'dateTime' | 'instant';
interface DateTimeOptions {
readonly shape: DateTimeShape;
readonly typeId: string;
readonly maxOffsetHours?: number;
readonly range?: { readonly earliest: string; readonly latest: string };
}
interface DateFields {
readonly year: number;
readonly month: number;
readonly day: number;
}
interface TimeFields {
readonly hour: number;
readonly minute: number;
readonly second: number;
readonly fraction: string;
}
interface WrittenDateTime {
readonly date: DateFields | undefined;
readonly time: TimeFields | undefined;
readonly offsetSeconds: number | undefined;
readonly fractionDigits: number;
}
const SHAPES: Readonly<
Record<
DateTimeShape,
{
readonly description: string;
readonly plural: string;
readonly example: string;
readonly date: boolean;
readonly time: boolean;
readonly offset: boolean;
}
>
> = {
date: {
description: 'a date',
plural: 'dates',
example: '2024-01-01',
date: true,
time: false,
offset: false,
},
time: {
description: 'a time of day',
plural: 'times of day',
example: '12:34:56',
date: false,
time: true,
offset: false,
},
timeWithOffset: {
description: 'a time of day with a UTC offset',
plural: 'times of day with a UTC offset',
example: '12:34:56+02:00',
date: false,
time: true,
offset: true,
},
dateTime: {
description: 'a date and time',
plural: 'dates and times',
example: '2024-01-01T12:34:56',
date: true,
time: true,
offset: false,
},
instant: {
description: 'a date and time with a UTC offset',
plural: 'instants',
example: '2024-01-01T12:34:56Z',
date: true,
time: true,
offset: true,
},
};
const DATE_PREFIX = /^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})/;
const TIME_PREFIX = /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?/;
const OFFSET = /^([+-])(\d{2})(?::(\d{2})(?::(\d{2}))?)?$/;
const MAX_FRACTION_DIGITS = 6;
const SECONDS_PER_DAY = 86_400;
const MIDNIGHT: TimeFields = { hour: 0, minute: 0, second: 0, fraction: '' };
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
function daysInMonth(year: number, month: number): number {
if (month === 2) return isLeapYear(year) ? 29 : 28;
return [4, 6, 9, 11].includes(month) ? 30 : 31;
}
function daysFromCivil({ year, month, day }: DateFields): number {
const shifted = month <= 2 ? year - 1 : year;
const era = Math.floor(shifted / 400);
const yearOfEra = shifted - era * 400;
const dayOfYear = Math.floor((153 * (month + (month > 2 ? -3 : 9)) + 2) / 5) + day - 1;
const dayOfEra =
yearOfEra * 365 + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100) + dayOfYear;
return era * 146_097 + dayOfEra - 719_468;
}
function civilFromDays(days: number): DateFields {
const shifted = days + 719_468;
const era = Math.floor(shifted / 146_097);
const dayOfEra = shifted - era * 146_097;
const yearOfEra = Math.floor(
(dayOfEra -
Math.floor(dayOfEra / 1460) +
Math.floor(dayOfEra / 36_524) -
Math.floor(dayOfEra / 146_096)) /
365,
);
const dayOfYear =
dayOfEra - (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
const monthIndex = Math.floor((5 * dayOfYear + 2) / 153);
const day = dayOfYear - Math.floor((153 * monthIndex + 2) / 5) + 1;
const month = monthIndex < 10 ? monthIndex + 3 : monthIndex - 9;
return { year: yearOfEra + era * 400 + (month <= 2 ? 1 : 0), month, day };
}
function pad(value: number, width = 2): string {
return String(value).padStart(width, '0');
}
function yearText(year: number): string {
if (year >= 0 && year <= 9999) return pad(year, 4);
return `${year < 0 ? '-' : '+'}${pad(Math.abs(year), 6)}`;
}
function dateText(date: DateFields): string {
return `${yearText(date.year)}-${pad(date.month)}-${pad(date.day)}`;
}
function timeText(time: TimeFields): string {
const fraction = time.fraction.replace(/0+$/, '');
return `${pad(time.hour)}:${pad(time.minute)}:${pad(time.second)}${fraction === '' ? '' : `.${fraction}`}`;
}
function offsetText(seconds: number): string {
if (seconds === 0) return 'Z';
const magnitude = Math.abs(seconds);
const rest = magnitude % 60;
return `${seconds < 0 ? '-' : '+'}${pad(Math.floor(magnitude / 3600))}:${pad(Math.floor(magnitude / 60) % 60)}${rest === 0 ? '' : `:${pad(rest)}`}`;
}
function readWritten(text: string): WrittenDateTime | undefined {
let rest = text;
let date: DateFields | undefined;
let timeFollows = true;
const dateMatch = DATE_PREFIX.exec(rest);
if (dateMatch !== null) {
const [whole, yearDigits = '', month = '', day = ''] = dateMatch;
if (yearDigits === '-000000') return undefined;
date = { year: Number(yearDigits), month: Number(month), day: Number(day) };
rest = rest.slice(whole.length);
timeFollows = /^[Tt ]/.test(rest);
if (timeFollows) rest = rest.slice(1);
}
let time: TimeFields | undefined;
let fractionDigits = 0;
if (timeFollows) {
const timeMatch = TIME_PREFIX.exec(rest);
if (timeMatch === null) return undefined;
const [whole, hour = '', minute = '', second = '00', fraction = ''] = timeMatch;
time = { hour: Number(hour), minute: Number(minute), second: Number(second), fraction };
fractionDigits = fraction.length;
rest = rest.slice(whole.length);
}
let offsetSeconds: number | undefined;
if (rest === 'Z' || rest === 'z') {
offsetSeconds = 0;
} else if (rest !== '') {
const offset = OFFSET.exec(rest);
if (offset === null) return undefined;
const [, sign, hours = '', minutes = '00', seconds = '00'] = offset;
if (Number(minutes) > 59 || Number(seconds) > 59) return undefined;
const magnitude = Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds);
offsetSeconds = sign === '-' ? -magnitude : magnitude;
}
return { date, time, offsetSeconds, fractionDigits };
}
type Position = readonly [days: number, seconds: number, fraction: string];
interface NormalizedDateTime {
readonly date: DateFields | undefined;
readonly time: TimeFields;
readonly offsetSeconds: number;
}
function normalized(written: WrittenDateTime, shape: DateTimeShape): NormalizedDateTime {
const clock = written.time ?? MIDNIGHT;
const offsetSeconds = written.offsetSeconds ?? 0;
if (written.date === undefined || shape !== 'instant')
return { date: written.date, time: clock, offsetSeconds };
const utc =
daysFromCivil(written.date) * SECONDS_PER_DAY +
clock.hour * 3600 +
clock.minute * 60 +
clock.second -
offsetSeconds;
const days = Math.floor(utc / SECONDS_PER_DAY);
const secondOfDay = utc - days * SECONDS_PER_DAY;
return {
date: civilFromDays(days),
time: {
hour: Math.floor(secondOfDay / 3600),
minute: Math.floor(secondOfDay / 60) % 60,
second: secondOfDay % 60,
fraction: clock.fraction,
},
offsetSeconds: 0,
};
}
function position(value: NormalizedDateTime): Position {
const { time } = value;
return [
value.date === undefined ? 0 : daysFromCivil(value.date),
time.hour * 3600 + time.minute * 60 + time.second,
time.fraction.padEnd(MAX_FRACTION_DIGITS, '0'),
];
}
function isBefore(left: Position, right: Position): boolean {
if (left[0] !== right[0]) return left[0] < right[0];
if (left[1] !== right[1]) return left[1] < right[1];
return left[2] < right[2];
}
function printed(value: NormalizedDateTime, shape: DateTimeShape): string {
const { date, time, offsetSeconds } = value;
if (date === undefined)
return shape === 'timeWithOffset'
? `${timeText(time)}${offsetText(offsetSeconds)}`
: timeText(time);
if (shape === 'date') return dateText(date);
if (shape === 'dateTime') return `${dateText(date)}T${timeText(time)}`;
return `${dateText(date)}T${timeText(time)}Z`;
}
function boundPosition(text: string, shape: DateTimeShape): Position {
const written = readWritten(text);
if (written === undefined)
throw new Error(`The range bound "${text}" is not in its stored form.`);
return position(normalized(written, shape));
}
function dateTimeStoredText(text: string, options: DateTimeOptions, written: string): string {
const shape = SHAPES[options.shape];
const id = options.typeId;
const parts = readWritten(text);
if (parts === undefined)
throw new CastRefused(
`${id} cannot read "${written}". Write ${shape.description}, as in "${shape.example}".`,
);
if (parts.date !== undefined && !shape.date)
throw new CastRefused(
`${id} holds a time of day without a date, but "${written}" has a date. Write the time alone, as in "${shape.example}".`,
);
if (parts.date === undefined && shape.date)
throw new CastRefused(
shape.time
? `${id} holds ${shape.description}, but "${written}" has no date. Write the date too, as in "${shape.example}".`
: `${id} holds a date, and "${written}" is a time of day. Write a date, as in "${shape.example}".`,
);
if (parts.time !== undefined && !shape.time)
throw new CastRefused(
`${id} holds a date without a time of day, but "${written}" has a time. Write the date alone, as in "${shape.example}".`,
);
if (parts.time === undefined && shape.offset)
throw new CastRefused(
`${id} holds ${shape.description}, but "${written}" has no time of day. Write the time too, as in "${shape.example}".`,
);
if (parts.offsetSeconds !== undefined && !shape.offset)
throw new CastRefused(
`${id} holds no UTC offset, but "${written}" has one. Leave it out, as in "${shape.example}".`,
);
if (parts.offsetSeconds === undefined && shape.offset)
throw new CastRefused(
`${id} needs a UTC offset, but "${written}" has none. Add Z for UTC or an offset such as +02:00, as in "${shape.example}".`,
);
if (parts.fractionDigits > MAX_FRACTION_DIGITS)
throw new CastRefused(
`"${written}" has ${parts.fractionDigits} digits after the decimal point, but ${id} holds microseconds, so at most ${MAX_FRACTION_DIGITS}. Round it, as in "${shape.example.replace(/(\d{2}:\d{2}:\d{2})/, '$1.123456')}".`,
);
const { date, time } = parts;
if (
date !== undefined &&
(date.month < 1 ||
date.month > 12 ||
date.day < 1 ||
date.day > daysInMonth(date.year, date.month))
)
throw new CastRefused(
`"${written}" is not a date that exists. Write a real date, as in "${shape.example}".`,
);
if (time !== undefined && (time.hour > 23 || time.minute > 59 || time.second > 59))
throw new CastRefused(
`"${written}" is not a time of day that exists: hours run from 00 to 23, and minutes and seconds from 00 to 59. Write one, as in "${shape.example}".`,
);
const offsetLimit = options.maxOffsetHours ?? 23;
if (
parts.offsetSeconds !== undefined &&
Math.abs(parts.offsetSeconds) >= (offsetLimit + 1) * 3600
)
throw new CastRefused(
`"${written}" has a UTC offset outside -${pad(offsetLimit)}:59 to +${pad(offsetLimit)}:59, which ${id} does not hold. Write a smaller offset, as in "${shape.example}".`,
);
const value = normalized(parts, options.shape);
const { range } = options;
if (range !== undefined) {
const at = position(value);
if (
isBefore(at, boundPosition(range.earliest, options.shape)) ||
isBefore(boundPosition(range.latest, options.shape), at)
)
throw new CastRefused(
`${id} holds ${shape.plural} from ${range.earliest} to ${range.latest}, and "${written}" is outside them.`,
);
}
return printed(value, options.shape);
}
const INFINITIES: ReadonlySet<string> = new Set(['infinity', '-infinity']);
function postgresDateTime(
options: DateTimeOptions,
holdsInfinity: boolean,
): (text: string) => string {
return (text) =>
holdsInfinity && INFINITIES.has(text)
? text
: dateTimeStoredText(isoYearText(text), options, text);
}
function storedForm(
storedText: (text: string) => string,
text: string,
): { readonly stored: string } | { readonly reason: string } {
try {
return { stored: storedText(text) };
} catch (error) {
if (error instanceof CastRefused) return { reason: error.message };
throw error;
}
}
const SECONDS_FRACTION = /:\d{2}\.(\d+)/;
const INTERVAL_SECONDS_FRACTION = /\d\.(\d+)S$/;
function storedTextType(
typeId: string,
storedText: (text: string) => string,
description: string,
fraction: RegExp,
): Pick<PostgresDataType, 'read' | 'spell'> {
return {
read: (json, params) => {
if (typeof json !== 'string') return refuseJsonValue(typeId, description);
const form = storedForm(storedText, json);
if ('reason' in form) return refuseJsonValue(typeId, `${description}: ${form.reason}`);
const precision = integerParam(params, 'precision') ?? MAX_FRACTION_DIGITS;
if (withoutTrailing(fraction.exec(form.stored)?.[1] ?? '', '0').length > precision)
return refuseJsonValue(
typeId,
`a value with at most ${counted(precision, 'fraction digit')} of a second, which precision ${precision} holds without rounding`,
);
return json;
},
spell: (json) => {
if (typeof json !== 'string') return json;
const form = storedForm(storedText, json);
return 'stored' in form ? form.stored : json;
},
};
}
type ParamRule = (value: Json | undefined) => boolean;
const optionalInteger =
(min: number, max: number): ParamRule =>
(value) =>
value === undefined ||
(typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max);
interface PostgresDataType {
readonly params?: Readonly<Record<string, ParamRule>>;
readonly read: (json: Json, params: Params) => Json;
readonly spell?: (json: Json, params: Params) => Json;
}
const NUMERIC_PARAMS = {
precision: optionalInteger(1, 1000),
scale: optionalInteger(-1000, 1000),
};
const CHARACTER_LENGTH_PARAMS = { length: optionalInteger(1, 10485760) };
const BIT_LENGTH_PARAMS = { length: optionalInteger(1, 83886080) };
const PRECISION_PARAMS = { precision: optionalInteger(0, 6) };
const readString =
(typeId: string): PostgresDataType['read'] =>
(json) =>
readJsonString(typeId, json);
const anyJson: PostgresDataType['read'] = (json) => json;
const requiredInteger =
(min: number, max: number): ParamRule =>
(value) =>
typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max;
const HEX_TEXT = /^(?:[0-9A-Fa-f]{2})*$/;
const EWKB_FLAG_Z = 0x80000000;
const EWKB_FLAG_M = 0x40000000;
const EWKB_FLAG_SRID = 0x20000000;
const EWKB_TYPE_MASK = 0x1fffffff;
const EWKB_POINT = 1;
const EWKB_LINESTRING = 2;
const EWKB_POLYGON = 3;
const EWKB_MULTI_PARTS: ReadonlyMap<number, number> = new Map([
[4, EWKB_POINT],
[5, EWKB_LINESTRING],
[6, EWKB_POLYGON],
]);
class NotEwkb extends Error {}
interface EwkbGeometry {
readonly srid: number | undefined;
/** The geometry as the upper-case little-endian HEXEWKB PostGIS prints, or undefined when a coordinate is not finite. */
readonly printed: string | undefined;
}
/** Reads HEXEWKB in either case and byte order, as `postgis/geometry` of 8.0.0-rc.19 does: points, line strings, polygons and their multi forms, without Z or M coordinates. */
function readEwkb(hex: string): EwkbGeometry {
const bytes = Buffer.from(hex, 'hex');
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const out: number[] = [];
let offset = 0;
let finite = true;
const take = (size: number): number => {
if (offset + size > view.byteLength) throw new NotEwkb();
const at = offset;
offset += size;
return at;
};
const writeUint32 = (value: number): void => {
const buffer = Buffer.alloc(4);
buffer.writeUInt32LE(value >>> 0);
out.push(...buffer);
};
const header = (top: boolean): { type: number; little: boolean; srid: number | undefined } => {
const order = view.getUint8(take(1));
if (order !== 0 && order !== 1) throw new NotEwkb();
const little = order === 1;
const code = view.getUint32(take(4), little) >>> 0;
if ((code & EWKB_FLAG_Z) !== 0 || (code & EWKB_FLAG_M) !== 0) throw new NotEwkb();
const type = code & EWKB_TYPE_MASK;
const srid = (code & EWKB_FLAG_SRID) !== 0 ? view.getUint32(take(4), little) >>> 0 : undefined;
out.push(1);
writeUint32(top && srid !== undefined ? type | EWKB_FLAG_SRID : type);
if (top && srid !== undefined) writeUint32(srid);
return { type, little, srid };
};
const count = (little: boolean): number => {
const value = view.getUint32(take(4), little) >>> 0;
writeUint32(value);
return value;
};
const positions = (amount: number, little: boolean): void => {
for (let index = 0; index < amount * 2; index++) {
const value = view.getFloat64(take(8), little);
if (!Number.isFinite(value)) finite = false;
const buffer = Buffer.alloc(8);
buffer.writeDoubleLE(value);
out.push(...buffer);
}
};
const body = (type: number, little: boolean): void => {
if (type === EWKB_POINT) positions(1, little);
else if (type === EWKB_LINESTRING) positions(count(little), little);
else if (type === EWKB_POLYGON)
for (let ring = count(little); ring > 0; ring--) positions(count(little), little);
else throw new NotEwkb();
};
const top = header(true);
const part = EWKB_MULTI_PARTS.get(top.type);
if (part === undefined) body(top.type, top.little);
else
for (let index = count(top.little); index > 0; index--) {
const sub = header(false);
if (sub.type !== part) throw new NotEwkb();
body(sub.type, sub.little);
}
if (offset !== view.byteLength) throw new NotEwkb();
return {
srid: top.srid,
printed: finite ? Buffer.from(out).toString('hex').toUpperCase() : undefined,
};
}
function ewkbOf(json: Json): EwkbGeometry | undefined {
if (typeof json !== 'string') return undefined;
try {
return readEwkb(json);
} catch (error) {
if (error instanceof NotEwkb) return undefined;
throw error;
}
}
function readGeometry(json: Json, params: Params): Json {
readJsonMatching('postgis/geometry', json, HEX_TEXT, 'a HEXEWKB string');
const srid = integerParam(params, 'srid');
const geometry = ewkbOf(json);
if (geometry === undefined || (srid !== undefined && geometry.srid !== srid))
return refuseJsonValue(
'postgis/geometry',
srid !== undefined ? `a HEXEWKB string of a geometry with SRID ${srid}` : 'a HEXEWKB string',
);
return json;
}
function spellGeometry(json: Json): Json {
const geometry = ewkbOf(json);
if (geometry === undefined) return json;
if (geometry.printed === undefined)
throw new NotAValue('Geometry encode: coordinates must be finite numbers');
return geometry.printed;
}
/** The text PostgreSQL prints for `value` stored as a `real`: the shortest decimal that reads back as the same single-precision number. */
function realText(value: number): string {
const single = Math.fround(value);
for (let digits = 1; digits <= 9; digits++) {
const text = String(Number(single.toPrecision(digits)));
if (Math.fround(Number(text)) === single) return text;
}
return String(single);
}
function readVector(json: Json, params: Params): Json {
const length = integerParam(params, 'length');
const expected =
length === undefined
? 'an array of finite numbers'
: `an array of ${counted(length, 'finite number')}`;
if (!Array.isArray(json) || (length !== undefined && json.length !== length))
return refuseJsonValue('pgvector/vector', expected);
for (const element of json)
if (typeof element !== 'number' || !Number.isFinite(element))
return refuseJsonValue('pgvector/vector', expected);
for (const [index, element] of json.entries()) {
if (typeof element !== 'number') continue;
const refuse = (why: string) =>
refuseJsonValue(
'pgvector/vector',
`an array of numbers real holds exactly; element ${index + 1}, ${element}, ${why}`,
);
if (!Number.isFinite(Math.fround(element)))
return refuse('is outside the range real holds, which PostgreSQL refuses');
const stored = realText(element);
if (Number(stored) !== element) return refuse(`would be stored as ${stored}`);
}
return json;
}
const POSTGRES_DATA_TYPES: ReadonlyMap<string, PostgresDataType> = new Map<
string,
PostgresDataType
>([
['pg/text', { read: readString('pg/text') }],
['pg/text-array', { read: readTextArray }],
[
'pg/enum',
{
params: { typeName: (value) => typeof value === 'string' && value.length > 0 },
read: readString('pg/enum'),
},
],
['pg/int2', { read: (json) => readJsonInteger('pg/int2', json, -32768, 32767) }],
[
'pg/bool',
{
read: (json) => (typeof json === 'boolean' ? json : refuseJsonValue('pg/bool', 'a boolean')),
},
],
['pg/json', { read: anyJson }],
['pg/tsquery', { read: readString('pg/tsquery') }],
['pg/int4', { read: (json) => readJsonInteger('pg/int4', json, -(2 ** 31), 2 ** 31 - 1) }],
['pg/int8', { read: readInt8 }],
['pg/numeric', { params: NUMERIC_PARAMS, read: readNumeric, spell: spellNumeric }],
['pg/float4', { read: readFloat4 }],
[
'pg/float8',
{
read: (json) => {
readJsonFloat('pg/float8', json);
return json;
},
},
],
['pg/jsonb', { read: anyJson }],
['pg/char', { params: CHARACTER_LENGTH_PARAMS, read: readCharacter, spell: spellCharacter }],
['pg/varchar', { params: CHARACTER_LENGTH_PARAMS, read: readCharacterVarying }],
[
'pg/uuid',
{
read: (json) =>
typeof json === 'string' && storedUuid(json) !== undefined
? json
: refuseJsonValue(
'pg/uuid',
'a UUID in a spelling PostgreSQL reads: 32 hexadecimal digits in either case, with or without a hyphen after any group of four, optionally in braces',
),
spell: (json) => (typeof json === 'string' ? (storedUuid(json) ?? json) : json),
},
],
[
'pg/inet',
{
read: (json) =>
typeof json === 'string' && storedInet(json) !== undefined
? json
: refuseJsonValue(
'pg/inet',
'an IP address or network in a spelling PostgreSQL reads, as in 10.0.0.1 or 2001:db8::/32',
),
spell: (json) => (typeof json === 'string' ? (storedInet(json) ?? json) : json),
},
],
['pg/bit', { params: BIT_LENGTH_PARAMS, read: readBit }],
['pg/varbit', { params: BIT_LENGTH_PARAMS, read: readBitVarying }],
[
'pg/timetz',
{
params: PRECISION_PARAMS,
...storedTextType(
'pg/timetz',
postgresDateTime(
{ shape: 'timeWithOffset', typeId: 'pg/timetz', maxOffsetHours: 15 },
false,
),
'a time of day with its UTC offset, as in "12:34:56+02:00" or "12:34:56Z"',
SECONDS_FRACTION,
),
},
],
[
'pg/interval',
{
params: PRECISION_PARAMS,
...storedTextType(
'pg/interval',
intervalStoredText,
'an ISO 8601 duration, as in "P1Y2M3DT4H5M6.5S"',
INTERVAL_SECONDS_FRACTION,
),
},
],
[
'pg/bytea',
{
read: (json) => readJsonMatching('pg/bytea', json, BASE64_TEXT, 'a base64 string'),
spell: spellBytea,
},
],
[
'pg/date',
storedTextType(
'pg/date',
postgresDateTime(
{
shape: 'date',
typeId: 'pg/date',
range: { earliest: '-004713-11-24', latest: '+275760-09-13' },
},
true,
),
'a date, as in "2024-01-01", or infinity or -infinity',
SECONDS_FRACTION,
),
],
[
'pg/time',
{
params: PRECISION_PARAMS,
...storedTextType(
'pg/time',
postgresDateTime({ shape: 'time', typeId: 'pg/time' }, false),
'a time of day, as in "12:34:56"',
SECONDS_FRACTION,
),
},
],
[
'pg/timestamp',
{
params: PRECISION_PARAMS,
...storedTextType(
'pg/timestamp',
postgresDateTime(
{
shape: 'dateTime',
typeId: 'pg/timestamp',
range: { earliest: '-004713-11-24T00:00:00', latest: '+275760-09-13T23:59:59.999999' },
},
true,
),
'a date and time of day, as in "2024-01-01T12:34:56", or infinity or -infinity',
SECONDS_FRACTION,
),
},
],
[
'pg/timestamptz',
{
params: PRECISION_PARAMS,
...storedTextType(
'pg/timestamptz',
postgresDateTime(
{
shape: 'instant',
typeId: 'pg/timestamptz',
range: { earliest: '-004713-11-24T00:00:00Z', latest: '+275760-09-13T00:00:00Z' },
},
true,
),
'an instant in UTC, as in "2024-01-01T00:00:00Z", or infinity or -infinity',
SECONDS_FRACTION,
),
},
],
[
'postgis/geometry',
{
params: { srid: optionalInteger(1, Number.POSITIVE_INFINITY) },
read: readGeometry,
spell: spellGeometry,
},
],
['pgvector/vector', { params: { length: requiredInteger(1, 16000) }, read: readVector }],
]);
function resolvedTypeParams(column: JsonRecord, namedTypes: Json | undefined): Json | undefined {
const typeRef = column['typeRef'];
const named =
typeof typeRef === 'string' && isRecord(namedTypes) ? namedTypes[typeRef] : undefined;
const typeParams = column['typeParams'] ?? (isRecord(named) ? named['typeParams'] : undefined);
return isRecord(typeParams) && Object.keys(typeParams).length > 0 ? typeParams : undefined;
}
/** The parameters of the column that its data type declares, or undefined when the type refuses them. */
function declaredParams(type: PostgresDataType, typeParams: Json | undefined): Params | undefined {
const params: Record<string, Json> = {};
for (const [name, accepts] of Object.entries(type.params ?? {})) {
const value = isRecord(typeParams) ? typeParams[name] : undefined;
if (!accepts(value)) return undefined;
if (value !== undefined) params[name] = value;
}
if (params['scale'] !== undefined && params['precision'] === undefined) return undefined;
return params;
}
interface ColumnType {
readonly id: string;
readonly type: PostgresDataType;
readonly params: Params;
readonly list: boolean;
}
/** The column's data type and parameters, or undefined when this release does not register the type or refuses the parameters, and so does not read the default. */
function columnType(column: JsonRecord, namedTypes: Json | undefined): ColumnType | undefined {
const id = column['dataType'];
const type = typeof id === 'string' ? POSTGRES_DATA_TYPES.get(id) : undefined;
if (typeof id !== 'string' || type === undefined) return undefined;
const params = declaredParams(type, resolvedTypeParams(column, namedTypes));
if (params === undefined) return undefined;
const many = column['many'];
return { id, type, params, list: many !== undefined && many !== false };
}
/** A literal default value in the contract form this release stores. */
function contractFormOfDefault(value: Json, { type, params }: ColumnType): DefaultForm {
let stored: Json;
try {
const read = type.read(value, params);
stored = type.spell === undefined ? read : type.spell(read, params);
} catch (error) {
if (error instanceof NotAValue) return { kind: 'notAValue', reason: error.message };
throw error;
}
return canonicalizeJson(stored) === canonicalizeJson(value)
? { kind: 'unchanged' }
: { kind: 'rewritten', value: stored };
}
type StoredDefault =
| { readonly kind: 'stored'; readonly value: Json }
| { readonly kind: 'refused'; readonly reason: string };
/** A column's default in the contract form this release stores, element by element on a list column, or why this release refuses it. */
function storedDefault(value: Json, columnType: ColumnType): StoredDefault {
if (value === null) return { kind: 'stored', value };
if (columnType.list && !Array.isArray(value))
return {
kind: 'refused',
reason: `the default ${JSON.stringify(value)} is not a list, and the column holds a list of ${columnType.id}`,
};
const values = columnType.list && Array.isArray(value) ? value : [value];
const stored: Json[] = [];
for (const element of values) {
const form = element === null ? undefined : contractFormOfDefault(element, columnType);
if (form?.kind === 'notAValue')
return {
kind: 'refused',
reason: `the default ${JSON.stringify(element)} is not a value of ${columnType.id} (${form.reason})`,
};
stored.push(form?.kind === 'rewritten' ? form.value : element);
}
return { kind: 'stored', value: columnType.list ? stored : (stored[0] ?? null) };
}
/** The scalar values that changed between two defaults, pairing list elements by position, for the literal types of `contract.d.ts`. */
function defaultRewrites(from: Json, to: Json): DefaultRewrite[] {
const pairs: [Json, Json][] =
Array.isArray(from) && Array.isArray(to)
? from.length === to.length
? from.map((element, index) => [element, to[index] ?? null])
: []
: [[from, to]];
return pairs.flatMap(([a, b]) =>
(typeof a === 'string' && typeof b === 'string') ||
(typeof a === 'number' && typeof b === 'number')
? a === b
? []
: [{ from: a, to: b }]
: [],
);
}
/** How `--default` names a column: `<namespace>.<table>.<column>`. */
function defaultKey(namespaceId: string, tableName: string, columnName: string): string {
return `${namespaceId}.${tableName}.${columnName}`;
}
interface ColumnEntry {
readonly namespaceId: string;
readonly tableName: string;
readonly columnName: string;
readonly column: JsonRecord;
}
function columnsOf(contract: JsonRecord): ColumnEntry[] {
const storage = contract['storage'];
const namespaces = isRecord(storage) ? storage['namespaces'] : undefined;
return Object.entries(isRecord(namespaces) ? namespaces : {}).flatMap(
([namespaceId, namespace]) => {
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
const tables = isRecord(entries) ? entries['table'] : undefined;
return Object.entries(isRecord(tables) ? tables : {}).flatMap(([tableName, table]) => {
const columns = isRecord(table) ? table['columns'] : undefined;
return Object.entries(isRecord(columns) ? columns : {}).flatMap(([columnName, column]) =>
isRecord(column) ? [{ namespaceId, tableName, columnName, column }] : [],
);
});
},
);
}
/** Mutates `copy`: each literal default in the contract form this release stores, element by element on a list column. A default this release refuses takes the value given for its column with `--default`, and each column whose replacement was taken is added to `used`. */
function rewriteDefaults(
copy: JsonRecord,
replacements: ReadonlyMap<string, Json>,
used: Set<string>,
): {
readonly defaults: Map<string, DefaultRewrite[]>;
readonly notValues: string[];
} {
const defaults = new Map<string, DefaultRewrite[]>();
const notValues: string[] = [];
const storage = copy['storage'];
const namedTypes = isRecord(storage) ? storage['types'] : undefined;
for (const { namespaceId, tableName, columnName, column } of columnsOf(copy)) {
const columnDefault = column['default'];
if (!isRecord(columnDefault) || columnDefault['kind'] !== 'literal') continue;
const original = columnDefault['value'];
const type = columnType(column, namedTypes);
if (original === undefined || type === undefined) continue;
const place = `namespace ${namespaceId}, table ${tableName}, column ${columnName}`;
const key = defaultKey(namespaceId, tableName, columnName);
let stored = storedDefault(original, type);
if (stored.kind === 'refused') {
const replacement = replacements.get(key);
if (replacement === undefined) {
notValues.push(
`${place}: ${stored.reason}; choose the value the database stores and run the script again with --default '${key}=<that value as JSON>'`,
);
continue;
}
used.add(key);
stored = storedDefault(replacement, type);
if (stored.kind === 'refused') {
notValues.push(`${place}: --default ${key}: ${stored.reason}`);
continue;
}
}
if (canonicalizeJson(stored.value) === canonicalizeJson(original)) continue;
columnDefault['value'] = stored.value;
defaults.set(
listKey(namespaceId, tableName, columnName),
defaultRewrites(original, stored.value),
);
}
return { defaults, notValues };
}
function allowNullElements(holder: JsonRecord): void {
const many = holder['many'];
if (isRecord(many)) holder['many'] = { ...many, elementNullable: true };
}
/** Mutates `copy`: the domain fields that map a column whose element check was waived. */
function fieldsMappingColumns(copy: JsonRecord, columns: ReadonlySet<string>): Set<string> {
const fields = new Set<string>();
const domain = copy['domain'];
const namespaces = isRecord(domain) ? domain['namespaces'] : undefined;
for (const [namespaceId, namespace] of isRecord(namespaces) ? Object.entries(namespaces) : []) {
const models = isRecord(namespace) ? namespace['models'] : undefined;
for (const [modelName, model] of isRecord(models) ? Object.entries(models) : []) {
if (!isRecord(model)) continue;
const storage = model['storage'];
const modelFields = model['fields'];
if (!isRecord(storage) || !isRecord(modelFields)) continue;
const table = storage['table'];
const tableNamespace =
typeof storage['namespaceId'] === 'string' ? storage['namespaceId'] : namespaceId;
const mapped = storage['fields'];
for (const [fieldName, mapping] of isRecord(mapped) ? Object.entries(mapped) : []) {
const column = isRecord(mapping) ? mapping['column'] : undefined;
const field = modelFields[fieldName];
if (typeof table !== 'string' || typeof column !== 'string' || !isRecord(field)) continue;
if (!columns.has(listKey(tableNamespace, table, column))) continue;
allowNullElements(field);
fields.add(listKey(namespaceId, modelName, fieldName));
}
}
}
return fields;
}
/** The contract with the key removed from each column, the lists whose element check it waived made element-nullable, and each default in the contract form this release stores. */
function rewriteContract(
contract: JsonRecord,
replacements: ReadonlyMap<string, Json>,
used: Set<string>,
): Rewrite {
const copy = parseJson(JSON.stringify(contract));
if (!isRecord(copy))
return {
contract,
changed: false,
columns: new Set(),
fields: new Set(),
defaults: new Map(),
notValues: [],
};
let changed = false;
const columns = new Set<string>();
const storage = copy['storage'];
const namespaces = isRecord(storage) ? storage['namespaces'] : undefined;
for (const [namespaceId, namespace] of isRecord(namespaces) ? Object.entries(namespaces) : []) {
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
const tables = isRecord(entries) ? entries['table'] : undefined;
for (const [tableName, table] of isRecord(tables) ? Object.entries(tables) : []) {
const tableColumns = isRecord(table) ? table['columns'] : undefined;
for (const [columnName, column] of isRecord(tableColumns)
? Object.entries(tableColumns)
: []) {
if (!isRecord(column) || !Object.hasOwn(column, REMOVED_KEY)) continue;
const kinds = column[REMOVED_KEY];
delete column[REMOVED_KEY];
changed = true;
if (Array.isArray(kinds) && kinds.includes(ELEMENT_KIND) && isRecord(column['many'])) {
allowNullElements(column);
columns.add(listKey(namespaceId, tableName, columnName));
}
}
}
}
const { defaults, notValues } =
contract['target'] === 'postgres'
? rewriteDefaults(copy, replacements, used)
: { defaults: new Map<string, DefaultRewrite[]>(), notValues: [] };
return {
contract: copy,
changed: changed || defaults.size > 0,
columns,
fields: fieldsMappingColumns(copy, columns),
defaults,
notValues,
};
}
function withStorageHash(contract: JsonRecord, hash: string): JsonRecord {
const storage = contract['storage'];
if (!isRecord(storage) || typeof storage['storageHash'] !== 'string') return contract;
return { ...contract, storage: { ...storage, storageHash: hash } };
}
/** The contract written the way the file held it: a snapshot's canonical line, or indented JSON. */
function writtenForm(contract: JsonRecord, original: JsonRecord, text: string): string {
const newline = text.endsWith('\n') ? '\n' : '';
if (text === `${canonicalizeJson(original)}${newline}`)
return `${canonicalizeJson(contract)}${newline}`;
return `${JSON.stringify(contract, null, 2)}${newline}`;
}
interface Block {
readonly start: number;
end: number;
readonly label: string;
readonly parent: Block | undefined;
}
function scanBlocks(text: string): Block[] {
const blocks: Block[] = [];
const open: Block[] = [];
let quote: string | undefined;
for (let index = 0; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === "'" || char === '"' || char === '`') {
quote = char;
continue;
}
if (char === '{') {
const before = text.slice(Math.max(0, index - 200), index);
const key =
/(?:readonly\s+)?([\p{ID_Start}_$][\p{ID_Continue}$]*|'[^']*'|"[^"]*")\s*\??\s*:\s*$/u.exec(
before,
) ?? /\b(?:type|namespace)\s+([\p{ID_Start}_$][\p{ID_Continue}$]*)\s*=?\s*$/u.exec(before);
const block: Block = {
start: index,
end: text.length,
label: key?.[1]?.replace(/^['"]|['"]$/g, '') ?? '',
parent: open.at(-1),
};
open.push(block);
blocks.push(block);
} else if (char === '}') {
const block = open.pop();
if (block !== undefined) block.end = index;
}
}
return blocks;
}
function innermostBlock(blocks: readonly Block[], position: number): Block | undefined {
let found: Block | undefined;
for (const block of blocks)
if (
block.start < position &&
position < block.end &&
(found === undefined || block.start > found.start)
)
found = block;
return found;
}
/** The key of a storage column block (`namespaces.<ns>.entries.table.<table>.columns.<column>`). */
function columnBlockKey(block: Block): string | undefined {
const table = block.parent?.parent;
const namespace = table?.parent?.parent?.parent;
if (block.parent?.label !== 'columns' || table?.parent?.label !== 'table') return undefined;
if (table.parent.parent?.label !== 'entries' || namespace === undefined) return undefined;
return listKey(namespace.label, table.label, block.label);
}
/** The key of a domain field block (`namespaces.<ns>.models.<model>.fields.<field>`). */
function fieldBlockKey(block: Block): string | undefined {
const model = block.parent?.parent;
const namespace = model?.parent?.parent;
if (block.parent?.label !== 'fields' || model?.parent?.label !== 'models') return undefined;
if (namespace === undefined) return undefined;
return listKey(namespace.label, model.label, block.label);
}
const FIELD_TYPE_MAPS = new Set(['FieldOutputTypes', 'FieldInputTypes']);
const COLUMN_TYPE_MAPS = new Set(['StorageColumnTypes', 'StorageColumnInputTypes']);
/** Whether a row type member names a waived list: in the field and column type maps, keyed namespace, model or table, member; or in a `Models` type alias, `<namespace>_<Model>` or `<Model>`. */
function isWaivedRowMember(
block: Block,
member: string,
columns: ReadonlySet<string>,
fields: ReadonlySet<string>,
): boolean {
const namespace = block.parent;
const map = namespace?.parent;
if (map !== undefined && map.parent === undefined) {
const key = listKey(namespace?.label ?? '', block.label, member);
if (FIELD_TYPE_MAPS.has(map.label)) return fields.has(key);
if (COLUMN_TYPE_MAPS.has(map.label)) return columns.has(key);
}
if (namespace?.label !== 'Models' || namespace.parent !== undefined) return false;
return [...fields].some((key) => {
const [fieldNamespace, model, field] = key.split('\u0000');
return (
field === member && (block.label === model || block.label === `${fieldNamespace}_${model}`)
);
});
}
/** The end of the type argument that starts at `start`: the index of its closing `>`. */
function typeArgumentEnd(text: string, start: number): number {
let depth = 1;
let quote: string | undefined;
for (let index = start; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
} else if (char === "'" || char === '"' || char === '`') quote = char;
else if (char === '<') depth++;
else if (char === '>' && text[index - 1] !== '=' && --depth === 0) return index;
}
return -1;
}
function quoted(text: string, quote: string): string {
return `${quote}${text.replaceAll('\\', '\\\\').replaceAll(quote, `\\${quote}`)}${quote}`;
}
const NUMBER_TOKEN = /(?<![\w.$'"])-?\d+(?:\.\d+)?(?:e[+-]?\d+)?(?![\w.$'"])/gi;
/** A default's literal type with each rewritten value replaced: a string in either quote, a number as its own token. */
function withRewrittenLiterals(literal: string, rewrites: readonly DefaultRewrite[]): string {
let result = literal;
const numbers = new Map<number, number>();
for (const { from, to } of rewrites) {
if (typeof from === 'number' && typeof to === 'number') numbers.set(from, to);
if (typeof from === 'string' && typeof to === 'string')
for (const quote of ["'", '"'])
result = result.replaceAll(quoted(from, quote), quoted(to, quote));
}
if (numbers.size === 0) return result;
return result.replace(NUMBER_TOKEN, (token) => String(numbers.get(Number(token)) ?? token));
}
function rewriteDts(
text: string,
columns: ReadonlySet<string>,
fields: ReadonlySet<string>,
defaults: ReadonlyMap<string, readonly DefaultRewrite[]>,
hashes: ReadonlyMap<string, string>,
): string {
const blocks = scanBlocks(text);
const edits: { start: number; end: number; text: string }[] = [];
for (const match of text.matchAll(/DefaultLiteralValue</g)) {
const holder = innermostBlock(blocks, match.index);
const column = holder?.label === 'default' ? holder.parent : undefined;
const key = column === undefined ? undefined : columnBlockKey(column);
const rewrites = key === undefined ? undefined : defaults.get(key);
const end = typeArgumentEnd(text, match.index + match[0].length);
if (rewrites === undefined || end === -1) continue;
const codecEnd = /^\s*(['"])(?:\\.|(?!\1).)*\1/.exec(
text.slice(match.index + match[0].length, end),
);
const start = match.index + match[0].length + (codecEnd?.[0].length ?? 0);
edits.push({ start, end, text: withRewrittenLiterals(text.slice(start, end), rewrites) });
}
const rowMember =
/(?:readonly\s+)?([\p{ID_Start}_$][\p{ID_Continue}$]*|'[^']*'|"[^"]*")\s*\??\s*:\s*ReadonlyArray</gu;
for (const match of text.matchAll(rowMember)) {
const block = innermostBlock(blocks, match.index);
const member = match[1]?.replace(/^['"]|['"]$/g, '') ?? '';
if (block === undefined || !isWaivedRowMember(block, member, columns, fields)) continue;
const elementStart = match.index + match[0].length;
const elementEnd = typeArgumentEnd(text, elementStart);
if (elementEnd === -1 || /\|\s*null\s*$/.test(text.slice(elementStart, elementEnd))) continue;
edits.push({ start: elementEnd, end: elementEnd, text: ' | null' });
}
for (const match of text.matchAll(/readonly elementNullable:\s*false/g)) {
const many = innermostBlock(blocks, match.index);
const holder = many?.label === 'many' ? many.parent : undefined;
if (holder === undefined) continue;
const column = columnBlockKey(holder);
const field = fieldBlockKey(holder);
if (
!(column !== undefined && columns.has(column)) &&
!(field !== undefined && fields.has(field))
)
continue;
edits.push({
start: match.index,
end: match.index + match[0].length,
text: 'readonly elementNullable: true',
});
}
let result = text;
for (const edit of edits.sort((a, b) => b.start - a.start))
result = `${result.slice(0, edit.start)}${edit.text}${result.slice(edit.end)}`;
return replaceHashTokens(result, hashes);
}
function replaceHashTokens(text: string, hashes: ReadonlyMap<string, string>): string {
return text.replace(
/(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])/g,
(hash: string) => hashes.get(hash) ?? hash,
);
}
interface ContractPlan {
readonly path: string;
readonly snapshot: boolean;
readonly oldHash: string;
readonly newHash: string;
readonly content: string;
readonly columns: ReadonlySet<string>;
readonly fields: ReadonlySet<string>;
readonly defaults: ReadonlyMap<string, readonly DefaultRewrite[]>;
}
function summary(
root: string,
contractsFound: number,
files: number,
directories: number,
hashes: ReadonlyMap<string, string>,
): readonly string[] {
if (contractsFound === 0) return [`No SQL contract was found under ${root}; nothing changed.`];
if (files === 0 && directories === 0)
return ['No contract stores a noCheck key or a default in another form; nothing changed.'];
const renamed = count(directories, 'snapshot directory', 'snapshot directories');
const changes = `Rewrote ${count(files, 'file', 'files')} and renamed ${renamed}.`;
if (hashes.size === 0) return [changes];
return [
`${changes} Storage hashes changed (old -> new):`,
...[...hashes]
.sort(([a], [b]) => compareCodeUnits(a, b))
.map(([oldHash, newHash]) => ` ${oldHash} -> ${newHash}`),
];
}
const JSON_FILES_THAT_MUST_PARSE = new Set(['contract.json', 'migration.json']);
const USAGE =
'Usage: node remove-nocheck.ts [project-root] [--default <namespace>.<table>.<column>=<JSON value>]...';
interface Args {
readonly root: string;
readonly replacements: ReadonlyMap<string, Json>;
readonly errors: readonly string[];
}
function parseArgs(args: readonly string[]): Args {
const errors: string[] = [];
const positional: string[] = [];
const replacements = new Map<string, Json>();
for (let index = 0; index < args.length; index++) {
const arg = args[index] ?? '';
const given = arg === '--default' ? args[++index] : undefined;
const option = arg.startsWith('--default=') ? arg.slice('--default='.length) : given;
if (option === undefined && !arg.startsWith('-')) {
positional.push(arg);
continue;
}
if (option === undefined && arg !== '--default') {
errors.push(`${arg}: unknown option`);
continue;
}
const separator = option?.indexOf('=') ?? -1;
const value = option === undefined ? undefined : parseJson(option.slice(separator + 1));
if (option === undefined || separator < 1 || value === undefined) {
const shown = option === undefined ? '--default' : `--default ${option}`;
errors.push(`${shown}: expected <namespace>.<table>.<column>=<JSON value>`);
continue;
}
replacements.set(option.slice(0, separator), value);
}
if (positional.length > 1) errors.push(`expected one project root, got ${positional.length}`);
const root = resolve(positional[0] ?? process.cwd());
if (errors.length === 0 && !isDirectory(root)) errors.push(`${root}: not a directory`);
if (errors.length > 0) errors.push(USAGE);
return { root, replacements, errors };
}
function main({ root, replacements, errors }: Args): number {
if (errors.length > 0) {
process.stderr.write(`${errors.join('\n')}\n`);
return 1;
}
const display = (path: string): string => relative(root, path).split(sep).join('/');
const files = listFiles(root);
const plans: ContractPlan[] = [];
const stops: string[] = [];
let contractsFound = 0;
const used = new Set<string>();
for (const path of files.json) {
const text = readFileSync(path, 'utf8');
const contract = parseJson(text);
if (
contract === undefined &&
(JSON_FILES_THAT_MUST_PARSE.has(basename(path)) || files.refs.includes(path))
) {
stops.push(`${display(path)}: not valid JSON`);
continue;
}
if (
!isRecord(contract) ||
contract['targetFamily'] !== 'sql' ||
typeof contract['target'] !== 'string' ||
!isRecord(contract['storage'])
)
continue;
contractsFound++;
const rewrite = rewriteContract(contract, replacements, used);
for (const problem of rewrite.notValues) stops.push(`${display(path)}: ${problem}`);
if (!rewrite.changed || rewrite.notValues.length > 0) continue;
const snapshot = isSnapshotContract(path);
const stored = contract['storage']['storageHash'];
const oldHash = snapshot ? basename(dirname(path)) : typeof stored === 'string' ? stored : '';
const computed = computeStorageHash(contract);
if (computed !== oldHash || (typeof stored === 'string' && stored !== oldHash)) {
stops.push(
`${display(path)}: the stored storage hash ${oldHash} does not match the contract's content, which hashes to ${computed}; restore the file from version control, or re-emit it, and run the script again`,
);
continue;
}
const newHash = computeStorageHash(rewrite.contract);
plans.push({
path,
snapshot,
oldHash,
newHash,
content: writtenForm(withStorageHash(rewrite.contract, newHash), contract, text),
columns: rewrite.columns,
fields: rewrite.fields,
defaults: rewrite.defaults,
});
}
for (const key of replacements.keys())
if (!used.has(key))
stops.push(
`--default ${key}: no stored contract has a default of this column that this release refuses, so the script replaces nothing`,
);
const hashes = new Map<string, string>();
for (const plan of [...plans].sort((a, b) => Number(b.snapshot) - Number(a.snapshot)))
if (plan.oldHash !== plan.newHash && !hashes.has(plan.oldHash))
hashes.set(plan.oldHash, plan.newHash);
const newDirectories = new Map<string, Map<string, string>>();
const directorySources = new Map<string, string>();
const sourceDirectories = new Set<string>();
const removals = new Set<string>();
const snapshotRewrites: [string, string][] = [];
const referenceWrites = new Map<string, string>();
const contractWrites = new Map<string, string>();
for (const plan of plans) {
const dtsPath = plan.snapshot
? join(dirname(plan.path), 'contract.d.ts')
: `${plan.path.slice(0, -'.json'.length)}.d.ts`;
const dtsHashes = new Map([...hashes, [plan.oldHash, plan.newHash]]);
const dts = existsSync(dtsPath)
? rewriteDts(
readFileSync(dtsPath, 'utf8'),
plan.columns,
plan.fields,
plan.defaults,
dtsHashes,
)
: undefined;
if (!plan.snapshot) {
if (dts !== undefined) referenceWrites.set(dtsPath, dts);
contractWrites.set(plan.path, plan.content);
continue;
}
const oldDir = dirname(plan.path);
const newDir = join(dirname(oldDir), plan.newHash);
sourceDirectories.add(oldDir);
if (newDir === oldDir) {
if (dts !== undefined) snapshotRewrites.push([dtsPath, dts]);
snapshotRewrites.push([plan.path, plan.content]);
continue;
}
removals.add(oldDir);
const content = readDirectory(oldDir);
content.set('contract.json', plan.content);
if (dts !== undefined) content.set('contract.d.ts', dts);
const planned = newDirectories.get(newDir);
if (planned !== undefined && !sameFiles(planned, content)) {
stops.push(`${display(newDir)}: snapshot directory already exists with different content`);
continue;
}
newDirectories.set(newDir, content);
directorySources.set(newDir, oldDir);
}
for (const [dir, content] of newDirectories) {
if (sourceDirectories.has(dir) || !existsSync(dir)) continue;
if (sameFiles(readDirectory(dir), content)) newDirectories.delete(dir);
else stops.push(`${display(dir)}: snapshot directory already exists with different content`);
}
for (const path of files.migrationJson) {
const text = readFileSync(path, 'utf8');
const metadata = parseJson(text);
if (!isRecord(metadata)) continue;
const from = metadata['from'];
const to = metadata['to'];
const nextFrom = typeof from === 'string' ? (hashes.get(from) ?? from) : from;
const nextTo = typeof to === 'string' ? (hashes.get(to) ?? to) : to;
if (nextFrom === from && nextTo === to) continue;
const opsPath = join(dirname(path), 'ops.json');
const ops = existsSync(opsPath) ? parseJson(readFileSync(opsPath, 'utf8')) : undefined;
if (existsSync(opsPath) && ops === undefined) {
stops.push(`${display(opsPath)}: not valid JSON`);
continue;
}
const oldMigrationHash = metadata['migrationHash'];
let next = replaceQuotedHashes(text, hashes);
if (
ops !== undefined &&
typeof oldMigrationHash === 'string' &&
nextFrom !== undefined &&
nextTo !== undefined
) {
const migrationHash = computeMigrationHash({ ...metadata, from: nextFrom, to: nextTo }, ops);
next = next.replace(`"${oldMigrationHash}"`, `"${migrationHash}"`);
}
referenceWrites.set(path, next);
}
for (const path of files.refs) {
const text = readFileSync(path, 'utf8');
const next = replaceQuotedHashes(text, hashes);
if (next !== text) referenceWrites.set(path, next);
}
for (const path of files.migrationTs) {
const text = readFileSync(path, 'utf8');
const next = replaceHashTokens(text, hashes);
if (next !== text) referenceWrites.set(path, next);
}
if (stops.length > 0) {
process.stderr.write(`${[...new Set(stops)].join('\n')}\n`);
return 1;
}
try {
for (const path of files.leftovers) rmSync(path, { recursive: true, force: true });
for (const [dir, content] of newDirectories)
writeDirectory(dir, content, directorySources.get(dir) ?? dir);
for (const [path, text] of snapshotRewrites) writeFile(path, text);
for (const [path, text] of referenceWrites) writeFile(path, text);
for (const [path, text] of contractWrites) writeFile(path, text);
for (const dir of removals) removeDirectory(dir);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(
`${message}; the upgrade stopped partway, run the script again to finish it\n`,
);
return 1;
}
const rewrittenFiles = snapshotRewrites.length + referenceWrites.size + contractWrites.size;
const lines = [...summary(root, contractsFound, rewrittenFiles, removals.size, hashes)];
process.stdout.write(`${lines.join('\n')}\n`);
return 0;
}
process.exitCode = main(parseArgs(process.argv.slice(2)));
upgrading/app/upgrades/8.0.0-rc.19-to-8.0.0-rc.20/instructions.md
---
from: "8.0.0-rc.19"
to: "8.0.0-rc.20"
changes:
- id: stored-contracts-expand-type-aliases
summary: |
A stored SQL contract whose columns name a type alias in `typeRef` no longer loads. Run `scripts/expand-type-aliases/expand-type-aliases.ts` from the project root, then re-emit the contract, then run `prisma db sign` against every database. Sign a database that has not applied every migration with `prisma db sign --contract <the last migration it applied>`, then run `prisma db migrate`. The script writes each alias's parameters into the columns typed by it, in every stored contract, migration snapshots and the emitted `contract.json` included, and refreshes every hash and reference that changes.
detection:
glob: "**/contract.json"
matches:
- '"typeRef"\s*:'
script: scripts/expand-type-aliases/expand-type-aliases.ts
- id: psl-alias-attributes
summary: |
No action is needed. A `types {}` alias in PSL may now carry `@default`, `@onCreate` and `@onUpdate`, and the targets contribute `temporal.CreatedAt` and `temporal.UpdatedAt`. Aliases are still valid PSL, so a `contract.prisma` that uses them does not change.
- id: ts-types-key-removed
summary: |
The TypeScript contract builder no longer takes a `types` key, and `field.namedType(...)` accepts only an enum. Replace `types: { X: type.Y(...) }` and each `field.namedType(types.X)` with a constant `const X = field.column(type.Y(...))` used as the field, and remove `types` from the object the contract returns. A `types` entry written `{ kind: 'codec-instance', codecId, typeParams }` becomes such a constant too if a field uses it; delete it if no field uses it. No `types` key stays in the contract. Re-emit the contract. The detection matches TypeScript files that call `field.namedType` with an alias or a string, or that return a `types` key from `defineContract`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$])field\.namedType\(\s*(?:types\b|[\x27"`])'
- '(?<![\s\S])(?=[\s\S]*\bdefineContract\()[\s\S]*(?<![\w$.\x27"])types\s*(?::(?!\s*(?:field\.|\{\s*codecTypes\b))|,|\})'
- id: supabase-contract-expanded
summary: |
The `@prisma/orm-extension-supabase` contract no longer stores its nine type aliases, so the Supabase space's storage hash changes. Run `prisma db sign` against every database after upgrading.
detection:
glob: "**/package.json"
matches:
- '"@prisma/orm-extension-supabase"'
- id: engine-pin-moves-to-0-9-0
summary: |
The toolchain now requires `@prisma/cli-engine@0.9.0` (up from 0.7.0). A project whose Prisma config, tests or tooling load `@prisma/cli-engine` alongside the Prisma toolchain must move it to `0.9.0`, in `package.json` or in a `pnpm-workspace.yaml` catalog. A separate CLI that the project builds on the engine is not affected. The detection matches an engine version below 0.9 in either file.
detection:
glob: "**/{package.json,pnpm-workspace.yaml}"
matches:
- '[\x27"]@prisma/cli-engine[\x27"]\s*:\s*[\x27"]?[^\x27"\n#]*(?<![\d.])0\.[0-8](?!\d)'
- id: piped-output-needs-json
summary: |
A piped Prisma ORM command no longer prints JSON. Without `--json`, stdout receives the command's plain data lines, such as one tab-separated line per migration. A script that reads JSON from `prisma contract …`, `prisma db …`, `prisma migration …` or `prisma orm init` must pass `--json`.
- id: access-changes-rename
summary: |
`accessWidening` is renamed `accessChanges`, because it also lists changes that do not widen access. The `db update --dry-run --json` output key and `DbUpdateSuccess.accessWidening` become `accessChanges`, and the `executeDbUpdate` and control client `dbUpdate` option `acceptAccessWidening` becomes `acceptAccessChanges`. There is no alias for the old names.
detection:
glob: "**/*.{ts,tsx,mts,cts,js,mjs,cjs,sh,bash,yml,yaml}"
matches:
- '(?<![\w$])(?:acceptAccessWidening|accessWidening)(?![\w$])'
- id: removed-namespace-questions
summary: |
On Postgres, when the contract no longer declares a namespace the database's contract declared, `migration plan` and `db update` plan to drop its tables, asking about each model, and then the schema. Before, those tables were left in place without a word. If the namespace was renamed, say so with `--rename <old namespace>:<new namespace>`, which renames the schema and keeps every row. Answer with `--delete <namespace>` to let the tables go, or keep a model by moving it with `--rename <namespace>.<Model>:<other namespace>.<Model>`.
- id: schema-verification-failed-schema-issues
summary: |
The `CONTRACT.SCHEMA_VERIFICATION_FAILED` diagnostic of `db verify --json` and `db sign --json` carries the schema differences in `meta.schemaIssues`, as issue objects, instead of `meta.issues`, as label strings. Code that reads `meta.issues` from that diagnostic reads `meta.schemaIssues` instead.
detection:
glob: "**/*.{ts,tsx,mts,cts,js,mjs,cjs}"
matches:
- '(?<![\s\S])(?=[\s\S]*(?:\bdb (?:verify|sign)\b|SCHEMA_VERIFICATION_FAILED))[\s\S]*\bmeta(?:\?\.|\.|\?\.\[|\[)(?:issues\b|[''"]issues[''"]\])'
- id: check-expectation
summary: |
A migration check that expects one object to be absent or present can now say so in an optional `expectation` field, and a failed precheck's message then names the object: `That check failed: the database already has index "Item_state_idx".` instead of `That check failed on this database.` Checks written without it keep working and keep the generic sentence.
detection:
glob: "**/ops.json"
matches:
- '(?:\{\s*"description": "(?:[^"\\]|\\.)*",\s*"sql": "(?:SELECT (?:NOT )?EXISTS \(SELECT 1 AS \\"one\\" FROM \\"(?:information_schema\\"\.\\"columns|pg_extension|pg_constraint|pg_class|pg_policies|pg_type|pg_enum|pg_indexes|pg_namespace)\\"(?: AS \\"\w+\\")?(?: INNER JOIN \\"pg_(?:namespace|type|class)\\" AS \\"\w+\\" ON \\"\w+\\"\.\\"\w+\\" = \\"\w+\\"\.\\"\w+\\")* WHERE (?:[^"\\]|\\"(?:table_schema|table_name|column_name|extname|conname|connamespace|conrelid|nspname|schemaname|tablename|policyname|indexname|typname|typnamespace|enumlabel|enumtypid|relname|relkind|oid|type|name|c|n|t|e|i|p)\\")*?\) AS \\"result\\"|SELECT \(to_regclass\(\$\d+\)\) IS (?:NOT )?NULL AS \\"result\\"|SELECT COUNT\(\*\) (?:= 0|> 0) AS \\"result\\" FROM (?:\\"sqlite_master\\"|pragma_table_info\([^)"]*\)|pragma_index_list\([^)"]*\)) WHERE (?:[^"\\]|\\"(?:table_schema|table_name|column_name|extname|conname|connamespace|conrelid|nspname|schemaname|tablename|policyname|indexname|typname|typnamespace|enumlabel|enumtypid|relname|relkind|oid|type|name|c|n|t|e|i|p)\\")*?)"(?:,\s*"params": \[[^\]]*\])?\s*\}|"source": \{\s*"kind": "list(?:Indexes|Collections)"(?:,\s*"collection": "(?:[^"\\]|\\.)*")?\s*\},\s*"filter": \{\s*"kind": "field",(?:[^{}]|\{[^{}]*\})*\},\s*"expect": "(?:exists|notExists)"\s*\})'
- id: check-expectation-raw-sql
summary: |
A precheck or postcheck you write by hand in a `rawSql` operation of a `migration.ts` can carry `expectation`, `{ expects: 'absent' | 'present', object }`, so a failure of it names the object. Add it where the check asks whether one object exists, then run `node migration.ts` for a migration no database has applied yet.
detection:
glob: "**/migration.ts"
matches:
- '\brawSql\(\{[\s\S]*?\b(?:precheck|postcheck):\s*\[(?:\s*\{(?:[^{}]|\{[^{}]*\})*\},)*?\s*\{[^{}]*\}'
- id: mongo-quoted-enum-members
summary: |
A MongoDB PSL `enum` member is now read in the syntax of its codec's data type, as on SQL, and a quoted member is text. A quoted member on a type that does not take text is refused with `PSL_VALUE_TYPE_INCOMPATIBLE` at the member: write a Double's `"NaN"`, `"Infinity"` and `"-Infinity"` and an Int64's or Decimal128's number unquoted (`Unknown = NaN`, `Big = 9007199254740993`, `Half = 0.5`), a Date member as `` date`2024-01-01T00:00:00Z` `` and a Binary member as `` binary`aGVsbG8=` ``. A Decimal128 `"-0"` cannot be written in PSL any more, because `-0` is an int32 zero: write `0`, or declare the enum in TypeScript. The detection matches an enum on one of those codecs with a quoted member.
detection:
glob: "**/*.prisma"
matches:
- 'enum\s+\w+\s*\{(?=[^{}]*@@type\(\s*"mongo/(?:double|int64|int64Number|decimal128|date|binary)@1"\s*\))[^{}]*\n\s*\w+\s*=\s*"'
- id: mongo-integer-enum-members-with-a-fraction
summary: |
A MongoDB PSL number with a fraction, such as `1.0`, is now a Decimal128 value, and no integer type casts from Decimal128, so an Int32 or Int64 member written with a fraction is refused with `PSL_VALUE_TYPE_INCOMPATIBLE` (`Expected a number that mongo/int32 can hold; got mongo/decimal128`). Write the whole number, `One = 1`. The detection matches an Int32 or Int64 enum with a member written with a fraction.
detection:
glob: "**/*.prisma"
matches:
- 'enum\s+\w+\s*\{(?=[^{}]*@@type\(\s*"mongo/(?:int32|int64|int64Number)@1"\s*\))[^{}]*\n\s*\w+\s*=\s*-?\d+\.\d'
- id: discriminator-optional-or-list
summary: |
A `@@discriminator` field must be required and hold one value, in SQL and MongoDB schemas alike. An optional field is refused with `Discriminator field "<field>" on model "<Model>" must be required, but it is optional. Remove the "?"`, and a list with `... must hold one value, but it is a list. Remove the "[]"`, both `PSL_INVALID_ATTRIBUTE_ARGUMENT`. Make the field required and single: a document or row without the value has no variant. The detection matches a model whose `@@discriminator` names a field declared with `?` or `[]`.
detection:
glob: "**/*.prisma"
matches:
- 'model\s+\w+\s*\{(?=[^}]*@@discriminator\(\s*(\w+)\s*\))[^}]*\n\s*\1\s+[\w.]+(?:\?|\[\])'
- id: discriminator-base-value-not-a-member
summary: |
When an enum types a `@@discriminator` field, each `@@base` value must be one of the enum's members, as the field stores them. A value that is not is refused at the value with `PSL_INVALID_ATTRIBUTE_ARGUMENT`: `Model "<Variant>" declares @@base value "<value>", which is not a member of enum "<Enum>", the type of discriminator field "<Model>.<field>". Write one of: ...`. Write a member's stored value, or add the value to the enum. The detection matches a schema that declares both a `@@discriminator` and an `enum`; check each `@@base` value of a discriminator typed by an enum.
detection:
glob: "**/*.prisma"
matches:
- '(?<![\s\S])(?=[\s\S]*@@discriminator\()(?=[\s\S]*(?:^|\n)\s*enum\s+\w+\s*\{)[\s\S]*@@base\('
- id: psl-value-refused-code
summary: |
An enum member or `@@base` value that the receiving data type or codec refuses now reports `PSL_VALUE_REFUSED`, in SQL and MongoDB schemas. SQL reported `PSL_INVALID_DEFAULT_LITERAL` for these, and MongoDB reported `PSL_EXTENSION_INVALID_VALUE` (`was rejected by codec`) for a member. A member written in a form its type does not take reports `PSL_VALUE_TYPE_INCOMPATIBLE`, and text an entry or a cast cannot read reports `PSL_INVALID_LITERAL`. Update code or tests that match these codes or messages. A MongoDB discriminator that is not text now says `must be text, such as String`, as SQL does, in place of `must be of type String`, and a type alias of `String` is accepted as a discriminator. The detection matches code that names the old member code or messages.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs}"
matches:
- 'was rejected by codec \\?"mongo/'
- 'must be of type String'
- '(?<![\s\S])(?=[\s\S]*@@base\()[\s\S]*PSL_INVALID_DEFAULT_LITERAL'
- id: decimal128-spelling
summary: |
`mongo/decimal128` stores its values as MongoDB writes them: without leading zeros, keeping negative zero, and only a value a Decimal128 holds exactly. A `contract.json` that stores a Decimal128 value with leading zeros, such as `"007.5"`, is refused when it is loaded with `RUNTIME.DECODE_FAILED`, which names the value to store; emit the contract again. A TypeScript enum member such as `member('Half', '007.5')` is stored as `7.5`, so its contract gets a new hash on the next emit. A value with more than 34 significant digits, counting zeros after the decimal point, such as `1.000000000000000000000000000000000000`, is refused in PSL, TypeScript and `contract.json` instead of being rounded; write a value with at most 34 significant digits. The detection matches a Decimal128 value with leading zeros or more than 34 digits.
detection:
glob: "**/*.{json,ts,mts,cts,tsx,prisma}"
matches:
- '(?<![\s\S])(?=([\s\S]*?mongo/decimal128@1))\1[\s\S]*?(?:[=,(:]\s*["\x27`]?-?0\d(?![0-9a-f]{62}["\x27`])|[=,(:]\s*["\x27`]?-?\d[\d.]{35,})'
- id: sql-builder-insert-required-columns
summary: |
A SQL builder insert, `db.sql.<namespace>.<table>.insert(rows)`, must now include every column that is not nullable and has no default. Leaving one out is a type error that names the column, and so is a value typed `T | undefined` for such a column. Add a real value, narrowed to `T`.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\.insert\('
- id: orm-create-on-update-only-field
summary: |
In ORM `create` and `upsert`, a field that is not nullable, has no default and generates a value only on update is now required. That is a field with `@onUpdate(...)` and no `@onCreate(...)`, a temporal preset with only its update phase such as `temporal.timestamptz(onUpdate: now)` or `field.temporal.timestamptz(undefined, undefined, 'now')`, or a TypeScript field with `.onUpdate(...)` and no `.onCreate(...)`. Pass a value for it on create, or make it generate on create too.
detection:
glob: "**/*.{prisma,ts}"
matches:
- '@onUpdate\('
- 'onUpdate:\s*now'
- '\.onUpdate\('
- '\.temporal\.\w+\([^)]*,\s*[''"]now[''"]'
- id: supabase-for-role-replaces-with-role-methods
summary: |
`SupabaseRuntime` no longer has `queryWithRole`, `executeWithRole`, `preparedQueryWithRole` or `preparedExecuteWithRole`, and `SupabaseRuntimeImpl` no longer has `roleActivitySource`. Call `runtime.forRole(binding)` once and use its members: `runtime.queryWithRole(plan, binding, options)` becomes `runtime.forRole(binding).query(plan, options)`, `executeWithRole` becomes `forRole(binding).execute`, and a prepared statement runs on the role with `statement.query(runtime.forRole(binding), params)` or `statement.execute(runtime.forRole(binding), params)`. The detection matches files that call one of the removed methods.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs}"
matches:
- '\.(?:queryWithRole|executeWithRole|preparedQueryWithRole|preparedExecuteWithRole|roleActivitySource)\('
- id: supabase-role-types-gain-members
summary: |
Three public Supabase types require new members, so a test double or hand-built object typed with them stops compiling. `RoleBoundDb` and `ServiceRoleDb` gain `enums` and `nativeEnums`. `SupabaseTransactionContext` is now `PostgresTransactionContext`, so it gains `enums` and `nativeEnums` too. `SupabaseRuntime` requires `forRole(binding)`, which returns a `SupabaseRoleScope` (`query`, `execute`, the prepared-statement operations and `connection`), in place of the four removed `*WithRole` methods. Add the members to each double: take `enums` and `nativeEnums` from `postgresStatic({ contract })` of `@prisma/orm-postgres/static`, or from a role-bound client. The detection matches files that name one of these types.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$])(?:RoleBoundDb|ServiceRoleDb|SupabaseTransactionContext|SupabaseRuntime)(?![\w$])'
- id: supabase-pg-takes-a-pool
summary: |
The `pg` option of `supabase()` takes a `pg.Pool` only. A `pg.Client` is a type error and fails at `supabase()` with `SUPABASE.CONFIG_INVALID`. Replace `pg: new Client(...)` with `pg: new Pool(...)` built from the same settings, or pass `url`. The detection matches every `supabase()` call that passes `pg`; leave a call that already passes a `pg.Pool` as it is.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs}"
matches:
- '(?<![\w$.])supabase(?:<[^>]*>)?\(\s*\{(?:[^{}]|\{[^{}]*\})*?(?<![\w$.])pg\s*[:,}]'
- id: supabase-close-waits-for-work
summary: |
`db.close()` on a Supabase client now refuses new work at once on every role-bound client and on `.supabase`, with `DRIVER.NOT_CONNECTED` "Runtime is closed", and waits for work already running, open role transactions included, before it ends the pool. With a pool you passed as `pg`, a query after `close()` used to throw at the call; its promise now rejects instead. Await every role transaction before `close()`, or `close()` waits for it.
- id: contract-artifacts-restamp
summary: |
An extension that writes its own package version into the contracts it emits, such as the Supabase extension, now writes 8.0.0-rc.20. Run `contract emit` once after upgrading so the emitted `contract.json` and `contract.d.ts` match the installed extension.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.19"'
---
# 8.0.0-rc.19 → 8.0.0-rc.20 — User upgrade instructions
## Order of the steps
Two changes in this release change storage hashes: the type-alias change (`stored-contracts-expand-type-aliases`) and the re-emitted Supabase contract (`supabase-contract-expanded`). Do the steps in this order, and skip a step that does not apply to the project:
1. Commit your work, so the script's changes can be reviewed and undone with git. Then upgrade the Prisma packages. If the project's Prisma config, tests or tooling load `@prisma/cli-engine` alongside the Prisma toolchain, move it to `0.9.0` (`engine-pin-moves-to-0-9-0`).
2. Rewrite the stored contracts, from the project root, with `scripts/expand-type-aliases/expand-type-aliases.ts` (`stored-contracts-expand-type-aliases`). It leaves a project without type aliases unchanged and says so.
3. Change the contract source as the entries below say: a TypeScript contract drops its `types` key (`ts-types-key-removed`), and a MongoDB schema rewrites the enum members and discriminators the MongoDB entries name.
4. Run `prisma contract emit` (this also covers `contract-artifacts-restamp`).
5. Run `prisma db sign` against every database when step 2 changed a storage hash, or when the project uses the Supabase extension. A database that has not applied every migration needs `--contract`, as `stored-contracts-expand-type-aliases` describes.
6. For each migration that is not applied yet and that `check-expectation` or `check-expectation-raw-sql` matches, run its `migration.ts` again. This is optional.
7. Change the application code and scripts: add `--json` where a script parses a command's output (`piped-output-needs-json`), rename `accessWidening` (`access-changes-rename`), read `meta.schemaIssues` (`schema-verification-failed-schema-issues`), complete SQL builder inserts and ORM creates (`sql-builder-insert-required-columns`, `orm-create-on-update-only-field`), and move Supabase code to `forRole` (`supabase-for-role-replaces-with-role-methods` and the entries after it).
The script path is relative to the directory that holds this guide, which the commands below call `<guide>`.
## Type aliases are written into each column
A type alias is now authoring shorthand only. In PSL, `types { Slug = VarChar(64) }` still works, but the contract no longer stores `Slug` in `storage.types` with a `typeRef` on each column that uses it. Each column stores the alias's codec and parameters itself. In TypeScript, an alias is a constant field builder.
Do the steps in this order: run the script that rewrites the stored contracts (`stored-contracts-expand-type-aliases`), change the TypeScript contract source if you have one (`ts-types-key-removed`), re-emit the contract, and then sign every database.
### `stored-contracts-expand-type-aliases`
Migration snapshots, the emitted `contract.json` and any other stored contract written by an earlier release may have a column with a `typeRef`. This release refuses such a contract and says to re-emit it or run this script. A historical snapshot cannot be re-emitted from today's source, so run the script. Commit your work first so the script's changes can be reviewed and undone with git. Run it from the project root:
```sh
node <guide>/scripts/expand-type-aliases/expand-type-aliases.ts
```
`<guide>` is the directory that holds this guide. Node 24 or later runs the TypeScript script directly; it needs no `tsx`. The script reads and writes files only and needs no database. It rewrites every SQL contract under the root, skipping `node_modules`, `dist`, `build` and every directory whose name starts with `.`, such as `.git` and the worktrees under `.claude/worktrees/`. That includes a test fixture of an old contract: if you keep one on purpose, restore it with git afterwards.
In each contract it replaces every column's `typeRef` with the parameters of the `storage.types` entry it names, and removes each entry a column named. In a contract of your app's own contract space it also removes every entry that no column names. Your app's contract never needs such an entry: it is an alias that the source declares but no field uses, which a re-emit drops as well, or, in a TypeScript source, a `codec-instance` entry that no field uses, which `ts-types-key-removed` tells you to delete. Only an extension's contract space registers a database type. So in a contract of an extension's contract space, such as the copy of pgvector's space in `migrations/snapshots/`, the script keeps an entry that no column names, such as `vector`, and lists it. It makes the same change in the `contract.d.ts` next to the contract. The script tells the spaces apart by the migrations directory: a snapshot that a migration or ref under `migrations/app/` names is your app's, and one that a migration or ref under any other `migrations/<space>/` names is an extension's. A snapshot that no migration or ref names may be either, so the script keeps its unused entries and lists them. The emitted `contract.json` is your app's.
The storage hash changes, so the script renames each changed snapshot directory to its new hash. It rewrites every `migration.json`, ref, `migration.ts` and `contract.d.ts` that names an old hash, and gives each changed migration a new `migrationHash`. When it finishes, it prints how many files it rewrote, how many snapshot directories it renamed, each storage hash it replaced (`<old> -> <new>`), each `storage.types` entry it removed, per contract, and each entry it kept. A project with no `typeRef` is left unchanged, and the script says so. The script replaces the hashes in the import paths of `migration.ts` where they are, so if your project sorts imports with a linter, run its fix command afterwards.
It changes no file and exits 1 in these cases, and names the file:
- a `contract.json`, `migration.json`, `ops.json` or ref is not valid JSON;
- a contract's stored storage hash, or the name of its snapshot directory, does not match its content;
- a column names an alias that `storage.types` does not declare, stores both `typeRef` and `typeParams`, or has a codec or data type other than its alias's;
- a migration goes between two snapshots that become the same snapshot once their aliases are written into their columns, and has no data operation;
- a renamed snapshot directory already exists with different content.
For the first three cases, restore that file from version control, or re-emit it, and run the script again. Two snapshots become the same one when the only change between them was an alias: a migration that replaced `slug VarChar(64)` with `types { Slug = VarChar(64) }` and `slug Slug`, or that renamed an alias. After the upgrade such a migration goes from a snapshot to itself, which only a migration with a data operation may do. The script names each such migration that has no data operation. Delete its directory and run the script again. When that migration is your latest, nothing else names its target snapshot, so the script names that snapshot directory too; delete it as well. The script then writes the hash of the merged snapshot into every `migration.json`, ref and `migration.ts` that names either old hash, and gives each changed migration a new `migrationHash`. A migration with a data operation keeps its operations and goes from the merged snapshot to itself. If it stops partway, for example on a full disk, it says so; run it again to finish.
If you upgrade from a release before 8.0.0-rc.19 and also run `remove-nocheck.ts` from the 8.0.0-rc.18 to 8.0.0-rc.19 guide, the two scripts may run in either order: each checks the hash of the content it reads.
Then re-emit the contract with `prisma contract emit`. A contract written in PSL gives the contract the script wrote, so the latest snapshot, the head ref and the emitted contract keep one storage hash. A contract written in TypeScript needs `ts-types-key-removed` first.
Each database's marker still holds the old storage hash, so `prisma db verify` and `prisma db migrate` refuse it until you sign it. The database itself does not change. After the re-emit, run `prisma db sign` against every database (development, staging, production):
```sh
prisma db sign --db "$DATABASE_URL"
```
A plain `prisma db sign` signs the database against the emitted contract, so it signs only a database that has applied every migration. A database that has not, such as production while the latest migrations wait for review, is refused: `db sign` says its schema does not satisfy the contract, and `prisma db migrate` stops with `MIGRATION.MARKER_MISMATCH`, because the marker holds an old hash that no migration names any more. Sign such a database with the contract of the last migration it applied, then migrate it:
```sh
prisma db sign --contract <the last migration it applied> --db "$DATABASE_URL"
prisma db migrate --db "$DATABASE_URL"
```
`--contract` takes the migration's directory name, such as `20260917T0818_add_post_expires_at`, or the new hash that migration goes to. To find it, run `prisma migration status` against the database. It prints the start of the old hash the marker holds: `Database marker <hash> is not in the on-disk migration graph`. The error of `prisma db migrate` prints the whole hash: `The database's marker names <hash>`, or `DB marker is <hash>` before 8.0.0-rc.21. Recover with `db sign --contract` as this section says, not with the error's next actions: the marker is outside the migration graph only because the upgrade gave the migrations new hashes. Find the line of the script's `<old> -> <new>` list whose old hash is that one. The new hash on that line is the one to sign with, and the migration that goes to it is the last one the database applied.
The script has already written the new hash into each ref, so when `db sign` advances a ref, such as `migrations/app/refs/db.json`, it prints the new hash twice: `Advanced ref "db" → <new hash> (was <new hash>)`. This is expected, and the database is signed.
The script also gives each rewritten migration a new hash, and the database's migration history keeps the old ones. After the upgrade, `prisma migration status` therefore no longer marks the migrations applied before it as applied, although it still reports the database as up to date. `prisma db migrate` runs none of them again, and `prisma db verify` passes.
### `psl-alias-attributes`
There is nothing to change. PSL gains two things:
```prisma
types {
Slug = VarChar(64)
UpdatedAt = TimestampString(3) @onCreate(now()) @onUpdate(now())
}
model Post {
id String @id @onCreate(uuid())
slug Slug
createdAt temporal.CreatedAt
updatedAt UpdatedAt @default(now())
}
```
- An alias may carry `@default`, `@onCreate` and `@onUpdate`. Each field typed by it gets them. A field may add an attribute its alias does not set, but writing one the alias already sets is the error `PSL_DUPLICATE_ATTRIBUTE`. `@id` and `@unique` belong on the field, not the alias.
- Postgres, SQLite and MongoDB contribute `temporal.CreatedAt` and `temporal.UpdatedAt`, written bare as a field type. Each stores exactly what the field presets `temporal.createdAt()` and `temporal.updatedAt()` stored. 8.0.0-rc.21 removes field presets, and its upgrade guide gives the replacement of every preset. Postgres also has `temporal.CreatedAtString`, `temporal.UpdatedAtString`, `temporal.CreatedAtJsDate` and `temporal.UpdatedAtJsDate`.
### `ts-types-key-removed`
Write an alias as a constant field builder and use it as the field:
```ts
// Before
({ field, model, type }) => {
const types = { Embedding1536: type.pgvector.Vector(1536) } as const;
const Post = model('Post', {
fields: {
embedding: field.namedType(types.Embedding1536).optional(),
},
});
return { types, models: { Post } };
}
// After
({ field, model, type }) => {
const Embedding1536 = field.column(type.pgvector.Vector(1536));
const Post = model('Post', {
fields: {
embedding: Embedding1536.optional(),
},
});
return { models: { Post } };
}
```
A field builder is immutable, so one constant serves every model. `type.*` now has a helper for every PSL type constructor of the stack, such as `type.VarChar(64)` and `type.TimestampString(3)`, and for the contributed aliases, such as `field.column(type.temporal.UpdatedAt())`. A string such as `field.namedType('Slug')` named an alias the same way; replace it with the alias's constant.
`field.namedType(...)` still takes an enum handle, such as `field.namedType(Priority)`; leave those calls alone.
A `types` key left in the definition fails with `CONTRACT.TYPES_KEY_REMOVED`. An entry may also be written `{ kind: 'codec-instance', codecId: ..., typeParams: ... }`. If a field uses it, through `field.namedType(types.X)` or `field.namedType('X')`, replace it with a constant `field.column(type.Y(...))` of the same type and parameters, as above. If no field uses it, delete it. Your app's contract space never needs a registered database type: only an extension's contract space registers one, under `databaseTypes`. The script removed such an entry from your stored contracts and listed it in its summary, so the re-emit gives the storage hash the script wrote.
Two calls that set the same thing on one builder now fail instead of the second one winning. `.default()` on a builder that already has a default throws `CONTRACT.DEFAULT_INVALID` with reason `default-set-twice`, and `.onCreate()` or `.onUpdate()` on a builder that already sets that phase throws, as before.
Re-emit the contract. If you ran `stored-contracts-expand-type-aliases`, the column gets the same type and parameters it had, so there is nothing more to sign or migrate.
### `supabase-contract-expanded`
The `@prisma/orm-extension-supabase` contract is re-emitted from its PSL, which keeps its nine aliases. The contract no longer stores them in `storage.types`, and each column stores its own type. The Supabase space's storage hash changes from `292489649ae6d6928de98a32b544bd9caf6b7d49718c10fe2720b83545a4b5a2` to `110ad99e1e466691bdb0a4349a17365561e76ad017f58beb272f4496b05b8e9e`.
A contract that composes the Supabase space references it by id, so this change does not touch your own `contract.json` and `contract.d.ts`. Only the extension version stamped in them moves, which `contract-artifacts-restamp` covers. A database that was signed against the previous Supabase contract no longer matches the new hash, so run `prisma db sign` against it after upgrading. The Supabase database itself does not change.
## The CLI engine is 0.9.0, and piped output needs `--json`
### `engine-pin-moves-to-0-9-0`
This applies to a project whose Prisma config, tests or tooling load `@prisma/cli-engine` alongside the Prisma toolchain. A separate CLI that the project builds on the engine runs its own engine, so it does not have to move with this upgrade.
For every `package.json` or `pnpm-workspace.yaml` catalog matched by `detection`, change the `@prisma/cli-engine` version (`0.7.0`, `^0.7.0` or any other 0.x below 0.9) to `0.9.0` and reinstall. Projects assembled by the `prisma` CLI resolve the engine automatically.
### `piped-output-needs-json`
Engine 0.9.0 chooses the output format from flags only. Before, a command whose stdout was not a terminal (a pipe, a file, `$(…)`, a CI log or an agent) printed JSON frames. Now it prints the human card on stderr and the command's data lines on stdout. JSON needs `--json`.
Find every place that runs a Prisma ORM command and parses its stdout as JSON: `package.json` scripts and shell scripts that pipe into `jq`, CI steps, and code that spawns `prisma` and calls `JSON.parse` on its output. Add `--json` to each such command. For example, `prisma migration status | jq '.envelope.result'` becomes `prisma migration status --json | jq '.envelope.result'`.
A script that only needs a list or a value can read the data lines instead. Fields are separated by a tab and come in the order below. A field whose JSON value is null or absent is an empty field. A backslash, tab, newline or carriage return inside a value is written as `\\`, `\t`, `\n` or `\r`. Paths are relative to the working directory. New fields are only ever added at the end of a line.
- `migration list`: space, name, from, to and operation count, one line per migration.
- `migration status`: space, name, from, to, operation count and status (`applied`, `pending`, or empty when the JSON status is null), one line per migration.
- `migration log`: applied at, space, name, from, to and operation count, one line per applied migration.
- `migration graph`: space, name, from and to, one line per migration; with `--dot`, the DOT text.
- `migration show`: `key<TAB>value` lines, then one `operation` line per operation.
- `migration check`: space, code and where (the path the failure is about), one line per failure.
- `migration new` and `migration plan`: the directories written.
- `migration ref list`: name, hash and comma-separated invariants, one line per ref. `migration ref set`: the hash the ref points at.
- `db migrate`: space, name, from, to and operations run, one line per applied migration; with `--show`, space, name, from and to per migration that will run.
- `db sign`: space, status and contract storage hash, one line per space.
- `orm init`: the paths of the files written.
- `contract emit`: the paths of `contract.json` and `contract.d.ts`. `contract infer`: the path written. `contract format`: the paths formatted. `contract print`: the PSL.
`db init`, `db update`, `db verify`, `db schema` and `migration ref delete` print nothing on stdout; their exit code is the result, and `--json` carries the document. An error in a pipe is now human text on stderr, with the same exit code.
## `accessWidening` is renamed `accessChanges`
`db update` asks about each operation that changes who can read or write a model's data. Such an operation does not always widen access, so the list of them is now `accessChanges`, and the option that answers all of them is `acceptAccessChanges`. Rename each use:
```ts
// Before
const result = await client.dbUpdate({ contract, mode: 'apply', migrationsDir, acceptAccessWidening: true, answerQuestions });
if (result.ok) console.log(result.value.accessWidening);
// After
const result = await client.dbUpdate({ contract, mode: 'apply', migrationsDir, acceptAccessChanges: true, answerQuestions });
if (result.ok) console.log(result.value.accessChanges);
```
A script that reads `prisma db update --dry-run --json` reads `accessChanges` in place of `accessWidening`. The output is one JSON object per line, and the result is in the last one:
```bash
prisma db update --dry-run --json | jq -s 'last | .envelope.result.accessChanges'
```
The question `db update` asks now ends with "who can read and write its data" in place of "who can read and write its rows". A script that matches the question text changes the same way.
## A removed namespace is asked about
If your contract removes a `namespace` block on Postgres, the next `migration plan` or `db update` asks about each model that namespace held, as it asks about any model you remove.
If you renamed the block, for example from `billing` to `finance`, give the rename and nothing is dropped:
```bash
prisma db update --rename billing:finance
```
Otherwise the namespace is removed. A script that runs without a terminal fails with `CLI.CONSENT_REQUIRED`, listing each model. To drop them all and the schema, add `--delete <namespace>`:
```bash
prisma db update --delete billing
```
To keep a model's data, move it to a namespace the contract keeps in the same run, for example `--rename billing.Invoice:public.Invoice`, or put the namespace back in the contract.
A table in that schema that no contract declared, such as one created by hand, is asked about too, by its storage name: `--delete billing.audit_log`. `--delete billing` does not answer it. Another object no contract declared, such as a view or a function, makes the run fail and changes nothing: a view on one of the tables stops that table's drop, and any other object stops the schema drop. The failure names each object; drop or move it yourself, then run the command again.
A namespace that only another contract space declares, and a schema no contract ever declared, such as Supabase's `auth`, are still left alone.
## The schema check's differences are in `meta.schemaIssues`
When `db verify` or `db sign` finds that the database schema does not match the contract, its `--json` output has a diagnostic with the code `CONTRACT.SCHEMA_VERIFICATION_FAILED`. Its `meta.issues` was a list of label strings, such as `"missing: public/users/email"`. It is now `meta.schemaIssues`, a list of issue objects, the same key and shape the `MIGRATION.SCHEMA_VERIFY_FAILED` failure of `db init`, `db update` and `db migrate` already uses. `meta.space` and `meta.code` are unchanged, and so is `schema.issues` in the result of a check that passed.
Each issue has a `path`, the location of the difference as a list of names, and `expected` and `actual`, the contract's and the database's side, one of which is absent for a missing or an extra object. It has an `explanation` with a `text` when the CLI can say more.
In code that reads the diagnostic, read `meta.schemaIssues` where it read `meta.issues`. Where it used the label strings, build them from the issue. A label was the outcome, then the path: `missing` when only `expected` is set, `extra` when only `actual` is set, and `mismatch` when both are:
```typescript
// before
const labels: string[] = diagnostic.meta.issues;
// after
const labels = diagnostic.meta.schemaIssues.map((issue) => {
const outcome = issue.actual === undefined ? 'missing' : issue.expected === undefined ? 'extra' : 'mismatch';
return `${outcome}: ${issue.path.join('/')}`;
});
```
The `why` of the diagnostic still lists every difference as text.
## Checks say which object they expect absent or present
Before an operation runs, the migration runner runs its prechecks. When one fails, the error's `why` named the check and said only that it failed on this database. A check that expects one object to be absent or present can now carry that as data, in `expectation`, and the `why` then says what the database already has or lacks. On MongoDB, a check carries the object's name in `object`, and its `expect` says whether it should exist.
Nothing breaks without it. An `ops.json` that has no `expectation` reads, verifies its hash and runs as before.
### Migrations Prisma writes
The PostgreSQL, SQLite and MongoDB operations now write it on every check that asks only whether one object exists. A matched `ops.json`, or a MongoDB `ops.json` written before this change, gains it when you write it again: run its `migration.ts` (`node migration.ts`). Writing it again changes its hash, so do this only for a migration that no database has applied yet. Leave an applied migration as it is.
### Checks you write yourself
In your app's migrations, a check you write in a `rawSql` operation can carry it too. Add `expectation` to the precheck or postcheck step, with `expects: 'absent'` or `'present'` and the object as a reader would name it, then run `node migration.ts`:
```typescript
rawSql({
// ...
precheck: [
{
description: 'verify extension "vector" is not already enabled',
sql: "SELECT NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector')",
expectation: { expects: 'absent', object: 'extension "vector"' },
},
],
})
```
Leave it out of a check about data or about several objects at once, such as a check that a column holds no NULL values.
## MongoDB values are written in the syntax of their data type
MongoDB PSL now reads every enum member the way SQL PSL does: by the syntax it is written in, then the casts of the enum codec's data type, then the codec. The MongoDB target has an authoring entry or a cast for every data type except `mongo/bson`:
- a quoted string is `mongo/string`, and `true` and `false` are `mongo/bool`;
- a number is the smallest type that holds it: `mongo/int32`, `mongo/int64`, `mongo/decimal128` when a Decimal128 holds it exactly, `NaN` and the infinities included, and `mongo/double` otherwise;
- the `date`, `binary` and `json` tags write `mongo/date`, `mongo/binary` and `mongo/json`.
A member that was quoted to get a number through the JSON reader is now text, and a member that was written with a fraction on an integer enum is now a Decimal128 value. Rewrite each as the change entries above say:
```prisma
enum Ratio {
@@type("mongo/double@1")
Unknown = NaN
Half = 0.5
}
enum Level {
@@type("mongo/int32@1")
One = 1
}
```
Discriminators follow one rule in both families: the field must be required, hold one value, and have one of the stack's text data types, and when an enum types it, each `@@base` value must be one of its members. A field is text by its data type, so on MongoDB a type alias of `String` is now a discriminator.
An enum on an Int64, Date, Decimal128, Binary or ObjectId codec, or a Double enum with a `NaN` or infinite member, is still refused when a collection the contract manages uses it, because a collection validator cannot list those members.
## Inserts must include the columns the database requires
The SQL builder's `insert` used to accept a row that left out any column. A row without a value for a column that is not nullable and has no default compiled, and then the database rejected it with a not-null error. The row type now requires those columns:
```typescript
// User.displayName is `String` with no default
db.sql.public.user.insert([{ email: 'admin@example.com', kind: 'admin' }]);
// error: Property 'displayName' is missing
db.sql.public.user.insert([{ email: 'admin@example.com', kind: 'admin', displayName: 'Admin' }]);
```
A column is still optional when it is nullable, when it has a default (`@default(...)`, including `now()` and `autoincrement()`), or when the client generates its value on create with `@onCreate(...)` or a preset that generates on create. Rows in one `insert` call are checked one by one, so they may set different optional columns.
For each insert that stops compiling, add a real value for each column the error names. Do not cast the row. A required property does not accept `undefined`, so a value typed `T | undefined` for one of these columns is now an error too: check it before the insert. Rows built in a variable and passed as `insert(rows)` get the same check, so give the variable the row type or build each row with every required column.
### ORM create for fields that generate only on update
A field generates its value on update only when it has `@onUpdate(...)` without `@onCreate(...)`, or when a temporal preset sets only its update phase: `temporal.timestamptz(onUpdate: now)` and `temporal.timestamp(3, onUpdate: now)` in PSL, `field.temporal.timestamptz(undefined, undefined, 'now')` in TypeScript. Such a field that is not nullable and has no default was optional in ORM `create` input, and the create then failed in the database. It is now required. Pass a value for it on create, or make it generate on create too: add `@onCreate(...)`, or set the preset's create phase (`temporal.timestamptz(onCreate: now, onUpdate: now)`).
## Supabase runs every role operation through one object
`SupabaseRuntime.forRole(binding)` returns a `SupabaseRoleScope`, the runtime operations under one role: `query`, `execute`, the prepared-statement operations and `connection`. Run a transaction under the role with `withTransaction(runtime.forRole(binding), fn)`.
| Type | New required members |
| --- | --- |
| `RoleBoundDb`, `ServiceRoleDb` | `enums`, `nativeEnums` |
| `SupabaseTransactionContext` | `enums`, `nativeEnums` (it is now `PostgresTransactionContext`) |
| `SupabaseRuntime` | `forRole(binding)`, in place of the four `*WithRole` methods |
```ts
// Before
const rows = await runtime.queryWithRole(plan, { role: 'anon' }).toArray();
const stats = await runtime.executeWithRole(plan, { role: 'anon' });
const named = await runtime.preparedQueryWithRole(statement, params, { role: 'anon' }).toArray();
// After
const anon = runtime.forRole({ role: 'anon' });
const rows = await anon.query(plan).toArray();
const stats = await anon.execute(plan);
const named = await statement.query(anon, params).toArray();
```
The `pg` option takes a `pg.Pool`:
```ts
// Before
const db = await supabase({ contract, pg: new Client({ connectionString }), jwtSecret });
// After: close() does not end a pool you pass in, so keep it and end it at shutdown
const pool = new Pool({ connectionString });
const db = await supabase({ contract, pg: pool, jwtSecret });
// ...
await db.close();
await pool.end();
// Or let Supabase create and end the pool
const db = await supabase({ contract, url: connectionString, jwtSecret });
```
## `contract-artifacts-restamp`
For every `contract.json` matched by `detection`, run the project's emit command (`prisma contract emit`, or the project's `contract:emit` script) once after upgrading. This entry accounts for the extension's embedded `version` moving to `8.0.0-rc.20`; any other difference in the emitted files comes from an earlier entry in this guide.
upgrading/app/upgrades/8.0.0-rc.19-to-8.0.0-rc.20/scripts/expand-type-aliases/expand-type-aliases.ts
#!/usr/bin/env node
/**
* Writes every type alias a column of a SQL contract is typed by into the column. Contracts emitted before this release store an alias as a `storage.types` entry and each column typed by it as a `typeRef` naming the entry; this release stores the alias's params on the column itself and refuses a stored `typeRef`. In each contract the script replaces every column's `typeRef` with the entry's `typeParams`, removes each entry a column named, and makes the same change in `contract.d.ts`. In a contract of the app's own contract space it also removes every entry no column names, an alias nothing uses, so the result is what a re-emit gives. In a contract of an extension's contract space, where registered database types live, it keeps such an entry and lists it. The script then renames the snapshot directories and rewrites every migration, ref, `migration.ts` and `contract.d.ts` that names an old storage hash.
*
* A contract belongs to an extension's contract space when a ref or migration of an extension space names its hash: one under `migrations/<space>/` other than `migrations/app/`, or one in a package's own single-space `migrations/` directory. It belongs to the app's when only refs and migrations under `migrations/app/` name it. A snapshot that nothing names keeps its unreferenced entries, as the script cannot tell whose it is. For an emitted contract that nothing names, the nearest migrations directory decides: one with an `app/` space directory, or none at all, is the app's; one with `refs/` directly in it is a package's own space.
*
* Usage: node expand-type-aliases.ts [project-root]
*
* The project root defaults to the working directory. The script reads and writes files only. It needs no database, network or configured stack, and a project with no `typeRef` and no unused alias in the app's space is left unchanged. When it stops (a file it must read is not valid JSON, a contract whose stored hash does not match its content, a column whose alias is missing or has another codec or data type, a migration with no data operation between two snapshots that become the same snapshot, or a snapshot directory that already exists with different content) it changes no file, prints one line per case and exits 1. When it finishes it prints how many files and snapshot directories it changed, each storage hash it replaced, and each entry it removed or kept.
*
* The storage hash and migration hash rules are copied from `@internal/contract` and `@internal/migration-tools` because a project's strict `node_modules` does not expose them.
*/
import { createHash } from 'node:crypto';
import {
chmodSync,
type Dirent,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
type Json = null | boolean | number | string | Json[] | JsonRecord;
interface JsonRecord {
[key: string]: Json;
}
const SKIPPED_DIRECTORIES = new Set(['node_modules', 'dist', 'build']);
const APP_SPACE_ID = 'app';
const HASH = /^[0-9a-f]{64}$/;
const TEMPORARY_SUFFIX = '.expand-type-aliases-tmp';
const BYTE_ORDER_MARK = '\uFEFF';
function isRecord(value: unknown): value is JsonRecord {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const prototype: unknown = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function withoutByteOrderMark(text: string): string {
return text.startsWith(BYTE_ORDER_MARK) ? text.slice(BYTE_ORDER_MARK.length) : text;
}
function parseJson(text: string): Json | undefined {
try {
const value: Json = JSON.parse(withoutByteOrderMark(text));
return value;
} catch {
return undefined;
}
}
function sha256(text: string): string {
return createHash('sha256').update(text).digest('hex');
}
function compareCodeUnits(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
function sortKeys(value: Json): Json {
if (Array.isArray(value)) return value.map(sortKeys);
if (!isRecord(value)) return value;
const sorted: JsonRecord = {};
for (const key of Object.keys(value).sort(compareCodeUnits)) {
const child = value[key];
if (child !== undefined) sorted[key] = sortKeys(child);
}
return sorted;
}
function canonicalizeJson(value: Json): string {
return JSON.stringify(sortKeys(value));
}
type PathSegment = string | readonly string[];
function matchesPath(path: readonly string[], pattern: readonly PathSegment[]): boolean {
if (path.length !== pattern.length) return false;
return pattern.every((segment, index) => {
const value = path[index];
if (value === undefined) return false;
if (segment === '*') return true;
return typeof segment === 'string' ? value === segment : segment.includes(value);
});
}
const SQL_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
['storage', 'namespaces', '*', 'entries', 'table'],
['storage', 'namespaces', '*', 'entries', 'table', '*'],
['storage', 'namespaces', '*', 'entries', 'table', '*', ['uniques', 'indexes', 'foreignKeys']],
['storage', 'namespaces', '*', 'entries', 'table', '*', 'indexes', 'unique'],
['storage', 'namespaces', '*', 'entries', 'table', '*', 'columns', '*', 'default', 'value'],
];
const COLUMN_DEFAULT_VALUE: readonly PathSegment[] = [
'storage',
'namespaces',
'*',
'entries',
'table',
'*',
'columns',
'*',
'default',
'value',
];
const FRAMEWORK_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
['domain', 'namespaces'],
['domain', 'namespaces', '*'],
['domain', 'namespaces', '*', 'models'],
['domain', 'namespaces', '*', 'models', '*', 'relations'],
['domain', 'namespaces', '*', 'models', '*', 'storage'],
['storage', 'namespaces'],
['storage', 'namespaces', '*', 'entries'],
['roots'],
['extensions'],
['extensions', '*'],
['capabilities'],
['meta'],
['execution', 'mutations', 'defaults'],
];
const TOP_LEVEL_ORDER = [
'schemaVersion',
'canonicalVersion',
'targetFamily',
'target',
'profileHash',
'roots',
'domain',
'storage',
'execution',
'capabilities',
'extensions',
'defaultControlPolicy',
'meta',
];
function isDefaultValue(value: Json): boolean {
if (value === false) return true;
if (Array.isArray(value)) return value.length === 0;
return isRecord(value) && Object.keys(value).length === 0;
}
function isPreservedEmpty(path: readonly string[], key: string): boolean {
if (key === 'nullable') return true;
if (key === 'elementNullable' && path.at(-2) === 'many') return true;
if (FRAMEWORK_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern))) return true;
if (
path.length >= COLUMN_DEFAULT_VALUE.length &&
matchesPath(path.slice(0, COLUMN_DEFAULT_VALUE.length), COLUMN_DEFAULT_VALUE)
)
return true;
return SQL_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern));
}
function omitDefaults(value: Json, path: readonly string[]): Json {
if (Array.isArray(value)) return value.map((item) => omitDefaults(item, path));
if (!isRecord(value)) return value;
const result: JsonRecord = {};
for (const [key, child] of Object.entries(value)) {
const childPath = [...path, key];
if (key === '_generated') continue;
if (key === 'generated' && child === false) continue;
if ((key === 'onDelete' || key === 'onUpdate') && child === 'noAction') continue;
if (isDefaultValue(child) && !isPreservedEmpty(childPath, key)) continue;
result[key] = omitDefaults(child, childPath);
}
return result;
}
function compareByName(a: Json, b: Json): number {
const nameA = isRecord(a) && typeof a['name'] === 'string' ? a['name'] : '';
const nameB = isRecord(b) && typeof b['name'] === 'string' ? b['name'] : '';
return compareCodeUnits(nameA, nameB);
}
function sortTableArrays(storage: Json): Json {
if (!isRecord(storage)) return storage;
const namespaces = storage['namespaces'];
if (!isRecord(namespaces)) return storage;
const sortedNamespaces: JsonRecord = {};
for (const [namespaceId, namespace] of Object.entries(namespaces)) {
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
const tables = isRecord(entries) ? entries['table'] : undefined;
if (!isRecord(namespace) || !isRecord(entries) || !isRecord(tables)) {
sortedNamespaces[namespaceId] = namespace;
continue;
}
const sortedTables: JsonRecord = {};
for (const [tableName, table] of Object.entries(tables)) {
if (!isRecord(table)) {
sortedTables[tableName] = table;
continue;
}
const sortedTable: JsonRecord = { ...table };
for (const key of ['checks', 'indexes', 'uniques']) {
const list = table[key];
if (Array.isArray(list)) sortedTable[key] = [...list].sort(compareByName);
}
sortedTables[tableName] = sortedTable;
}
sortedNamespaces[namespaceId] = { ...namespace, entries: { ...entries, table: sortedTables } };
}
return { ...storage, namespaces: sortedNamespaces };
}
function orderTopLevel(value: JsonRecord): JsonRecord {
const ordered: JsonRecord = {};
const remaining = new Set(Object.keys(value));
for (const key of TOP_LEVEL_ORDER) {
const child = value[key];
if (child !== undefined) {
ordered[key] = child;
remaining.delete(key);
}
}
for (const key of [...remaining].sort(compareCodeUnits)) {
const child = value[key];
if (child !== undefined) ordered[key] = child;
}
return ordered;
}
function omitNamespaceKinds(storage: JsonRecord): JsonRecord {
const namespaces = storage['namespaces'];
if (!isRecord(namespaces)) return storage;
const stripped: JsonRecord = {};
for (const [namespaceId, namespace] of Object.entries(namespaces)) {
if (isRecord(namespace)) {
const { kind: _kind, ...rest } = namespace;
stripped[namespaceId] = rest;
} else {
stripped[namespaceId] = namespace;
}
}
return { ...storage, namespaces: stripped };
}
function computeStorageHash(contract: JsonRecord): string {
const storage = isRecord(contract['storage']) ? contract['storage'] : {};
const { storageHash: _published, ...withoutHash } = storage;
const hashed: JsonRecord = {
schemaVersion: '1',
targetFamily: typeof contract['targetFamily'] === 'string' ? contract['targetFamily'] : '',
target: typeof contract['target'] === 'string' ? contract['target'] : '',
profileHash: '',
roots: {},
domain: { namespaces: {} },
storage: omitNamespaceKinds(withoutHash),
extensions: {},
capabilities: {},
meta: {},
};
const withoutDefaults = omitDefaults(hashed, []);
if (!isRecord(withoutDefaults)) return '';
const withSortedStorage = {
...withoutDefaults,
storage: sortTableArrays(withoutDefaults['storage'] ?? {}),
};
const sorted = sortKeys(withSortedStorage);
return isRecord(sorted) ? sha256(JSON.stringify(orderTopLevel(sorted), null, 2)) : '';
}
function computeMigrationHash(metadata: JsonRecord, ops: Json): string {
const { migrationHash: _migrationHash, ...withoutHash } = metadata;
const parts = [canonicalizeJson(withoutHash), canonicalizeJson(ops)].map(sha256);
return sha256(canonicalizeJson(parts));
}
interface ProjectFiles {
readonly json: readonly string[];
readonly migrationJson: readonly string[];
readonly refs: readonly string[];
readonly migrationTs: readonly string[];
readonly leftovers: readonly string[];
}
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
/** A file, or a link to one: the script reads and writes through links to files. */
function isFileEntry(dir: string, entry: Dirent): boolean {
return entry.isFile() || (entry.isSymbolicLink() && isFile(join(dir, entry.name)));
}
/** A file the script reads, by the path it was reached through: its own path rather than a link's. */
interface FoundFile {
readonly path: string;
readonly linked: boolean;
readonly inMigrationPackage: boolean;
}
function listFiles(root: string): ProjectFiles {
const leftovers: string[] = [];
const visited = new Set<string>();
const found = new Map<string, FoundFile>();
const visit = (dir: string): void => {
const realDir = realpathSync(dir);
if (visited.has(realDir)) return;
visited.add(realDir);
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
compareCodeUnits(a.name, b.name),
);
const inMigrationPackage = entries.some(
(entry) => entry.name === 'migration.json' && isFileEntry(dir, entry),
);
for (const entry of entries) {
const path = join(dir, entry.name);
if (entry.name.endsWith(TEMPORARY_SUFFIX)) {
leftovers.push(path);
continue;
}
if (entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(path))) {
if (!SKIPPED_DIRECTORIES.has(entry.name) && !entry.name.startsWith('.')) visit(path);
continue;
}
if (!isFileEntry(dir, entry)) continue;
const realFile = realpathSync(path);
const linked = entry.isSymbolicLink();
const known = found.get(realFile);
if (known === undefined || (known.linked && !linked))
found.set(realFile, { path, linked, inMigrationPackage });
}
};
visit(root);
const files = [...found.values()];
const named = (test: (file: FoundFile, name: string) => boolean): string[] =>
files.filter((file) => test(file, basename(file.path))).map((file) => file.path);
return {
json: named((_file, name) => name.endsWith('.json')),
migrationJson: named((_file, name) => name === 'migration.json'),
refs: named((file, name) => name.endsWith('.json') && basename(dirname(file.path)) === 'refs'),
migrationTs: named((file, name) => name === 'migration.ts' && file.inMigrationPackage),
leftovers,
};
}
function isSnapshotContract(path: string): boolean {
const snapshotDir = dirname(path);
return (
basename(path) === 'contract.json' &&
HASH.test(basename(snapshotDir)) &&
basename(dirname(snapshotDir)) === 'snapshots'
);
}
function replaceQuotedHashes(text: string, hashes: ReadonlyMap<string, string>): string {
return text.replace(/"([0-9a-f]{64})"/g, (match: string, hash: string) => {
const next = hashes.get(hash);
return next === undefined ? match : `"${next}"`;
});
}
function readDirectory(dir: string): Map<string, string> {
const files = new Map<string, string>();
if (!existsSync(dir)) return files;
for (const entry of readdirSync(dir, { withFileTypes: true }))
if (isFileEntry(dir, entry) && !entry.name.endsWith(TEMPORARY_SUFFIX))
files.set(entry.name, readFileSync(join(dir, entry.name), 'utf8'));
return files;
}
function sameFiles(a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): boolean {
return a.size === b.size && [...a].every(([name, content]) => b.get(name) === content);
}
function temporaryPath(path: string): string {
return join(dirname(path), `.${basename(path)}${TEMPORARY_SUFFIX}`);
}
function writeWithModeOf(path: string, text: string, modeSource: string): void {
writeFileSync(path, text);
if (existsSync(modeSource)) chmodSync(path, statSync(modeSource).mode & 0o7777);
}
function writeFile(path: string, text: string): void {
const target = realpathSync(path);
const temporary = temporaryPath(target);
writeWithModeOf(temporary, text, target);
renameSync(temporary, target);
}
function writeDirectory(dir: string, files: ReadonlyMap<string, string>, sourceDir: string): void {
const temporary = temporaryPath(dir);
mkdirSync(temporary);
for (const [name, text] of files)
writeWithModeOf(join(temporary, name), text, join(sourceDir, name));
renameSync(temporary, dir);
}
function removeDirectory(dir: string): void {
const temporary = temporaryPath(dir);
renameSync(dir, temporary);
rmSync(temporary, { recursive: true, force: true });
}
function count(amount: number, singular: string, plural: string): string {
return `${amount} ${amount === 1 ? singular : plural}`;
}
const REMOVED_KEY = 'typeRef';
const DTS_LINE_WIDTH = 100;
interface Rewrite {
readonly contract: JsonRecord;
readonly changed: boolean;
/** The `storage.types` entries a column referenced, whose params the rewrite wrote into the columns. */
readonly expanded: ReadonlyMap<string, JsonRecord>;
/** Every `storage.types` entry the rewrite removed: the referenced ones, and in the app's space the unreferenced ones too. */
readonly removed: ReadonlySet<string>;
/** The `storage.types` entries no column referenced, which the rewrite kept. */
readonly kept: readonly string[];
readonly problems: readonly string[];
}
function nonEmptyParams(entry: JsonRecord): JsonRecord | undefined {
const params = entry['typeParams'];
return isRecord(params) && Object.keys(params).length > 0 ? params : undefined;
}
/** The column with its `typeRef` replaced, in place, by the referenced entry's params. */
function expandedColumn(column: JsonRecord, entry: JsonRecord): JsonRecord {
const params = nonEmptyParams(entry);
const result: JsonRecord = {};
for (const [key, value] of Object.entries(column)) {
if (key !== REMOVED_KEY) result[key] = value;
else if (params !== undefined) result['typeParams'] = params;
}
return result;
}
function describeType(record: JsonRecord): string {
return `codec ${String(record['codecId'])} and data type ${String(record['dataType'])}`;
}
/** The contract with every column's type alias written into the column, the aliases it named removed from `storage.types`, and in a contract of the app's space every other entry too. */
function rewriteContract(contract: JsonRecord, space: SpaceKind): Rewrite {
const copy = parseJson(JSON.stringify(contract));
const storage = isRecord(copy) ? copy['storage'] : undefined;
if (!isRecord(copy) || !isRecord(storage))
return {
contract,
changed: false,
expanded: new Map(),
removed: new Set(),
kept: [],
problems: [],
};
const types = isRecord(storage['types']) ? storage['types'] : {};
const expanded = new Map<string, JsonRecord>();
const problems: string[] = [];
let changed = false;
const namespaces = storage['namespaces'];
for (const namespace of isRecord(namespaces) ? Object.values(namespaces) : []) {
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
const tables = isRecord(entries) ? entries['table'] : undefined;
for (const [tableName, table] of isRecord(tables) ? Object.entries(tables) : []) {
const columns = isRecord(table) ? table['columns'] : undefined;
if (!isRecord(columns)) continue;
for (const [columnName, column] of Object.entries(columns)) {
if (!isRecord(column) || !Object.hasOwn(column, REMOVED_KEY)) continue;
changed = true;
const alias = column[REMOVED_KEY];
const entry = typeof alias === 'string' ? types[alias] : undefined;
const where = `column ${tableName}.${columnName}`;
if (typeof alias !== 'string' || !isRecord(entry)) {
problems.push(
`${where} is typed by the type alias ${JSON.stringify(alias)}, which storage.types does not declare`,
);
continue;
}
if (Object.hasOwn(column, 'typeParams')) {
problems.push(`${where} stores both typeRef and typeParams`);
continue;
}
if (column['codecId'] !== entry['codecId'] || column['dataType'] !== entry['dataType']) {
problems.push(
`${where} has ${describeType(column)}, but its type alias ${alias} has ${describeType(entry)}`,
);
continue;
}
columns[columnName] = expandedColumn(column, entry);
expanded.set(alias, entry);
}
}
}
const removed = new Set(space === 'app' ? Object.keys(types) : expanded.keys());
for (const alias of removed) delete types[alias];
if (removed.size > 0 && Object.keys(types).length === 0) delete storage['types'];
const kept = Object.keys(types).sort(compareCodeUnits);
return {
contract: copy,
changed: changed || removed.size > 0,
expanded,
removed,
kept,
problems,
};
}
function withStorageHash(contract: JsonRecord, hash: string): JsonRecord {
const storage = contract['storage'];
if (!isRecord(storage) || typeof storage['storageHash'] !== 'string') return contract;
return { ...contract, storage: { ...storage, storageHash: hash } };
}
function lineEndingOf(text: string): string {
return text.includes('\r\n') ? '\r\n' : '\n';
}
/** The contract written the way the file held it: a snapshot's canonical line, or indented JSON, with the file's byte order mark and line ending. */
function writtenForm(contract: JsonRecord, original: JsonRecord, text: string): string {
const mark = text.startsWith(BYTE_ORDER_MARK) ? BYTE_ORDER_MARK : '';
const body = withoutByteOrderMark(text);
const lineEnding = lineEndingOf(body);
const finalNewline = body.endsWith('\n') ? lineEnding : '';
if (body === `${canonicalizeJson(original)}${finalNewline}`)
return `${mark}${canonicalizeJson(contract)}${finalNewline}`;
const indented = JSON.stringify(contract, null, 2).replaceAll('\n', lineEnding);
return `${mark}${indented}${finalNewline}`;
}
interface Block {
readonly start: number;
end: number;
readonly label: string;
readonly parent: Block | undefined;
}
function scanBlocks(text: string): Block[] {
const blocks: Block[] = [];
const open: Block[] = [];
let quote: string | undefined;
for (let index = 0; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === '/' && text[index + 1] === '/') {
const end = text.indexOf('\n', index);
index = end === -1 ? text.length : end;
continue;
}
if (char === '/' && text[index + 1] === '*') {
const end = text.indexOf('*/', index + 2);
index = end === -1 ? text.length : end + 1;
continue;
}
if (char === "'" || char === '"' || char === '`') {
quote = char;
continue;
}
if (char === '{') {
const key =
/(?:readonly\s+)?([\p{ID_Start}_$][\p{ID_Continue}$]*|'[^']*'|"[^"]*")\s*\??\s*:\s*$/u.exec(
text.slice(Math.max(0, index - 200), index),
);
const block: Block = {
start: index,
end: text.length,
label: key?.[1]?.replace(/^['"]|['"]$/g, '') ?? '',
parent: open.at(-1),
};
open.push(block);
blocks.push(block);
} else if (char === '}') {
const block = open.pop();
if (block !== undefined) block.end = index;
}
}
return blocks;
}
function innermostBlock(blocks: readonly Block[], position: number): Block | undefined {
let found: Block | undefined;
for (const block of blocks)
if (
block.start < position &&
position < block.end &&
(found === undefined || block.start > found.start)
)
found = block;
return found;
}
/** Whether the block is a storage column (`namespaces.<ns>.entries.table.<table>.columns.<column>`). */
function isColumnBlock(block: Block): boolean {
const table = block.parent?.parent;
return (
block.parent?.label === 'columns' &&
table?.parent?.label === 'table' &&
table.parent.parent?.label === 'entries'
);
}
function childBlocks(blocks: readonly Block[], parent: Block): Block[] {
return blocks.filter((block) => block.parent === parent);
}
/** The `readonly types` block of the storage type: the one whose parent also holds `namespaces`. */
function storageTypesBlock(blocks: readonly Block[]): Block | undefined {
return blocks.find(
(block) =>
block.label === 'types' &&
block.parent !== undefined &&
childBlocks(blocks, block.parent).some((sibling) => sibling.label === 'namespaces'),
);
}
function lineStart(text: string, position: number): number {
return text.lastIndexOf('\n', position - 1) + 1;
}
/** The end of the member a block closes: past its `;` and the line break after it. */
function memberEnd(text: string, block: Block): number {
const rest = /^\s*;?[ \t]*(?:\r?\n)?/.exec(text.slice(block.end + 1));
return block.end + 1 + (rest?.[0].length ?? 0);
}
function indentOf(text: string, position: number): string {
return /^[ \t]*/.exec(text.slice(lineStart(text, position)))?.[0] ?? '';
}
/** Splits `{ a; b }` into its top-level members. */
function topLevelMembers(literal: string): string[] {
const inner = literal.trim().slice(1, -1);
const members: string[] = [];
let depth = 0;
let quote: string | undefined;
let current = '';
for (let index = 0; index < inner.length; index++) {
const char = inner[index] ?? '';
current += char;
if (quote !== undefined) {
if (char === '\\') current += inner[++index] ?? '';
else if (char === quote) quote = undefined;
continue;
}
if (char === "'" || char === '"' || char === '`') quote = char;
else if (char === '{' || char === '[' || char === '(') depth++;
else if (char === '}' || char === ']' || char === ')') depth--;
else if (char === ';' && depth === 0) {
members.push(current.slice(0, -1).trim());
current = '';
}
}
if (current.trim() !== '') members.push(current.trim());
return members;
}
/** The `readonly typeParams` member, laid out as the emitter's formatter lays it out at `indent`. */
function typeParamsMember(literal: string, indent: string, lineEnding: string): string {
const lines = literal.split('\n');
const head = `${indent}readonly typeParams: `;
if (lines.length > 1) {
const literalIndent = /^[ \t]*/.exec(lines.at(-1) ?? '')?.[0] ?? '';
const shifted = lines.map((line, index) =>
index === 0 ? line : `${indent}${line.slice(literalIndent.length)}`,
);
return `${head}${shifted.join('\n')};`;
}
const single = `${head}${literal};`;
if (single.length <= DTS_LINE_WIDTH) return single;
const members = topLevelMembers(literal).map((member) => `${indent} ${member};`);
return `${head}{${lineEnding}${members.join(lineEnding)}${lineEnding}${indent}};`;
}
/** The text of `typeParams` in an entry block of `readonly types`, as the emitter wrote it. */
function entryParamsLiteral(
text: string,
blocks: readonly Block[],
entry: Block,
): string | undefined {
const params = childBlocks(blocks, entry).find((block) => block.label === 'typeParams');
return params === undefined ? undefined : text.slice(params.start, params.end + 1);
}
function jsonParamsLiteral(params: JsonRecord): string {
const members = Object.entries(params).map(
([key, value]) =>
`readonly ${/^[A-Za-z_$][\w$]*$/.test(key) ? key : `'${key}'`}: ${JSON.stringify(value)}`,
);
return `{ ${members.join('; ')} }`;
}
interface Edit {
readonly start: number;
readonly end: number;
readonly text: string;
}
function rewriteDts(
text: string,
expanded: ReadonlyMap<string, JsonRecord>,
removedEntries: ReadonlySet<string>,
hashes: ReadonlyMap<string, string>,
): string {
const lineEnding = lineEndingOf(text);
const blocks = scanBlocks(text);
const typesBlock = storageTypesBlock(blocks);
const entryBlocks = typesBlock === undefined ? [] : childBlocks(blocks, typesBlock);
const literals = new Map<string, string>();
for (const [alias, entry] of expanded) {
const params = nonEmptyParams(entry);
if (params === undefined) continue;
const block = entryBlocks.find((candidate) => candidate.label === alias);
literals.set(
alias,
(block === undefined ? undefined : entryParamsLiteral(text, blocks, block)) ??
jsonParamsLiteral(params),
);
}
const edits: Edit[] = [];
for (const match of text.matchAll(/readonly typeRef:\s*(?:'([^']*)'|"([^"]*)")\s*;?/g)) {
const alias = match[1] ?? match[2] ?? '';
const column = innermostBlock(blocks, match.index);
if (column === undefined || !isColumnBlock(column) || !expanded.has(alias)) continue;
const start = lineStart(text, match.index);
const literal = literals.get(alias);
if (literal === undefined) {
const end = match.index + match[0].length;
const lineBreak = /^\r?\n/.exec(text.slice(end))?.[0] ?? '';
edits.push({ start, end: end + lineBreak.length, text: '' });
} else {
edits.push({
start,
end: match.index + match[0].length,
text: typeParamsMember(literal, indentOf(text, match.index), lineEnding),
});
}
}
if (typesBlock !== undefined) {
const removed = entryBlocks.filter((block) => removedEntries.has(block.label));
if (removed.length > 0 && removed.length === entryBlocks.length) {
edits.push({
start: lineStart(text, typesBlock.start),
end: memberEnd(text, typesBlock),
text: '',
});
} else {
for (const block of removed)
edits.push({ start: lineStart(text, block.start), end: memberEnd(text, block), text: '' });
}
}
let result = text;
for (const edit of edits.sort((a, b) => b.start - a.start))
result = `${result.slice(0, edit.start)}${edit.text}${result.slice(edit.end)}`;
return replaceHashTokens(result, hashes);
}
function replaceHashTokens(text: string, hashes: ReadonlyMap<string, string>): string {
return text.replace(
/(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])/g,
(hash: string) => hashes.get(hash) ?? hash,
);
}
interface ContractPlan {
readonly path: string;
readonly snapshot: boolean;
readonly oldHash: string;
readonly newHash: string;
readonly content: string;
readonly expanded: ReadonlyMap<string, JsonRecord>;
readonly removed: ReadonlySet<string>;
readonly kept: readonly string[];
}
/** Whose contract space a contract is: the app's own; an extension's, which registers database types; or unknown, for a snapshot no migration or ref names. Only a contract of the app's space loses the entries no column references. */
type SpaceKind = 'app' | 'extension' | 'unknown';
/** A hash a migration or ref names, and the space whose directory holds it. */
interface HashReference {
readonly path: string;
readonly space: SpaceKind;
readonly hashes: readonly string[];
/** A migration's `from` and `to`; `undefined` for a ref. */
readonly edge: { readonly from: string; readonly to: string } | undefined;
}
/** The space of the directory that holds a migration package or a `refs` directory: `migrations/app` is the app's; any other space directory, or a package's own single-space `migrations`, an extension's. */
function spaceOfDirectory(spaceDir: string): SpaceKind {
return basename(spaceDir) === APP_SPACE_ID ? 'app' : 'extension';
}
function hashReferences(files: ProjectFiles): HashReference[] {
const references: HashReference[] = [];
for (const path of files.migrationJson) {
const metadata = parseJson(readFileSync(path, 'utf8'));
if (!isRecord(metadata)) continue;
const from = metadata['from'];
const to = metadata['to'];
const hashes = [from, to].filter((hash): hash is string => typeof hash === 'string');
references.push({
path: dirname(path),
space: spaceOfDirectory(dirname(dirname(path))),
hashes,
edge: typeof from === 'string' && typeof to === 'string' ? { from, to } : undefined,
});
}
for (const path of files.refs) {
const ref = parseJson(readFileSync(path, 'utf8'));
const hash = isRecord(ref) ? ref['hash'] : undefined;
if (typeof hash !== 'string') continue;
references.push({
path,
space: spaceOfDirectory(dirname(dirname(path))),
hashes: [hash],
edge: undefined,
});
}
return references;
}
/** The space of a migrations directory, by its layout: an `app` space directory, or no `refs` directly in it, is the app's. */
function spaceOfMigrationsDirectory(dir: string): SpaceKind {
if (isDirectory(join(dir, APP_SPACE_ID))) return 'app';
return isDirectory(join(dir, 'refs')) ? 'extension' : 'app';
}
/** The space of a contract that no ref or migration names: unknown for a snapshot, as every space shares the snapshot store; for an emitted contract, that of the nearest `migrations` directory above it, or the app's when there is none. */
function spaceByLayout(path: string, snapshot: boolean, root: string): SpaceKind {
if (snapshot) return 'unknown';
for (let dir = dirname(path); ; dir = dirname(dir)) {
const migrations = join(dir, 'migrations');
if (isDirectory(migrations)) return spaceOfMigrationsDirectory(migrations);
if (dir === root || dirname(dir) === dir) return 'app';
}
}
function summary(
root: string,
contractsFound: number,
files: number,
directories: number,
hashes: ReadonlyMap<string, string>,
removed: readonly (readonly [string, readonly string[]])[],
kept: readonly (readonly [string, readonly string[]])[],
): readonly string[] {
if (contractsFound === 0) return [`No SQL contract was found under ${root}; nothing changed.`];
if (files === 0 && directories === 0)
return ['No contract stores a type alias; nothing changed.'];
const renamed = count(directories, 'snapshot directory', 'snapshot directories');
const changes = `Rewrote ${count(files, 'file', 'files')} and renamed ${renamed}.`;
const listed = (entries: readonly (readonly [string, readonly string[]])[]) =>
entries.map(([path, names]) => ` ${path}: ${names.join(', ')}`);
return [
...(hashes.size === 0
? [changes]
: [
`${changes} Storage hashes changed (old -> new):`,
...[...hashes]
.sort(([a], [b]) => compareCodeUnits(a, b))
.map(([oldHash, newHash]) => ` ${oldHash} -> ${newHash}`),
]),
...(removed.length === 0 ? [] : ['Removed storage.types entries:', ...listed(removed)]),
...(kept.length === 0
? []
: [
"Kept storage.types entries that no column references, in contracts of an extension's contract space and in snapshots that no migration or ref names. Each is a database type a contract space registers, or a type alias nothing uses:",
...listed(kept),
]),
];
}
/** Each path with the names listed for it, merged and sorted, in path order. */
function byPath(
entries: readonly (readonly [string, readonly string[]])[],
): (readonly [string, readonly string[]])[] {
const merged = new Map<string, Set<string>>();
for (const [path, names] of entries)
merged.set(path, new Set([...(merged.get(path) ?? []), ...names]));
return [...merged]
.map(([path, names]): readonly [string, readonly string[]] => [
path,
[...names].sort(compareCodeUnits),
])
.sort(([a], [b]) => compareCodeUnits(a, b));
}
interface MigrationOps {
readonly path: string;
/** `undefined` when the package has no `ops.json` or it is not valid JSON. */
readonly ops: Json | undefined;
readonly unreadable: boolean;
}
function readOps(migrationDir: string): MigrationOps {
const path = join(migrationDir, 'ops.json');
if (!existsSync(path)) return { path, ops: undefined, unreadable: false };
const ops = parseJson(readFileSync(path, 'utf8'));
return { path, ops, unreadable: ops === undefined };
}
/** Whether the operations hold one of class `data`, the one kind that lets a migration go from a snapshot to itself. The migration checker's rule (`checkIntegrity` in `@internal/migration-tools`). */
function hasDataOperation(ops: Json | undefined): boolean {
return Array.isArray(ops) && ops.some((op) => isRecord(op) && op['operationClass'] === 'data');
}
/**
* The snapshot directories that the rewrite turns two or more snapshots into, and a stop line for each migration between such snapshots that has no data operation, naming too each of its snapshots that nothing else names, or, when its `ops.json` is not valid JSON, for that file. Expanding the aliases can make two contracts equal: one that wrote a type inline and one that wrote it through an alias, or two that named one alias differently. A migration between them then goes from a snapshot to itself, which the migration checker allows only for a migration with a data operation. A snapshot nothing names, such as a copy an interrupted run left, is not counted.
*/
function mergedSnapshots(
files: ProjectFiles,
plans: readonly ContractPlan[],
references: readonly HashReference[],
display: (path: string) => string,
): { readonly lines: readonly string[]; readonly directories: ReadonlySet<string> } {
const planned = new Map(plans.map((plan) => [plan.path, plan.newHash]));
const referenced = new Set(references.flatMap((reference) => reference.hashes));
const stores = new Map<string, Map<string, Set<string>>>();
for (const path of files.json) {
if (!isSnapshotContract(path)) continue;
const hash = basename(dirname(path));
if (!referenced.has(hash)) continue;
const store = dirname(dirname(path));
const merged = stores.get(store) ?? new Map<string, Set<string>>();
stores.set(store, merged);
const newHash = planned.get(path) ?? hash;
merged.set(newHash, new Set([...(merged.get(newHash) ?? []), hash]));
}
const lines: string[] = [];
const directories = new Set<string>();
for (const [store, merged] of stores) {
for (const [newHash, oldHashes] of merged) {
if (oldHashes.size < 2) continue;
directories.add(join(store, newHash));
const names = [...oldHashes].sort(compareCodeUnits).join(' and ');
for (const { path, edge } of references) {
if (
edge === undefined ||
edge.from === edge.to ||
!oldHashes.has(edge.from) ||
!oldHashes.has(edge.to) ||
!path.startsWith(`${dirname(store)}${sep}`)
)
continue;
const ops = readOps(path);
if (ops.unreadable) {
lines.push(`${display(ops.path)}: not valid JSON`);
continue;
}
if (hasDataOperation(ops.ops)) continue;
const orphaned = [edge.from, edge.to].filter(
(hash) => !references.some((other) => other.path !== path && other.hashes.includes(hash)),
);
const snapshots = orphaned.map((hash) => display(join(store, hash)));
const toDelete =
snapshots.length === 0
? `the migration directory ${display(path)}`
: `the migration directory ${display(path)} and the snapshot ${snapshots.length === 1 ? 'directory' : 'directories'} ${snapshots.join(' and ')}, which no other migration or ref names,`;
lines.push(
`${display(store)}: the snapshots ${names} become the same snapshot, ${newHash}, once their type aliases are written into their columns. Migration ${display(path)} goes from ${edge.from} to ${edge.to} and has no data operation, so it would go from ${newHash} to itself, which only a migration with a data operation may do. Delete ${toDelete} and run the script again.`,
);
}
}
}
return { lines, directories };
}
const JSON_FILES_THAT_MUST_PARSE = new Set(['contract.json', 'migration.json']);
function parseRoot(args: readonly string[]): { readonly root: string; readonly errors: string[] } {
const errors = args.filter((arg) => arg.startsWith('-')).map((arg) => `${arg}: unknown option`);
const positional = args.filter((arg) => !arg.startsWith('-'));
if (positional.length > 1) errors.push(`expected one project root, got ${positional.length}`);
const root = resolve(positional[0] ?? process.cwd());
if (errors.length === 0 && !isDirectory(root)) errors.push(`${root}: not a directory`);
if (errors.length > 0) errors.push('Usage: node expand-type-aliases.ts [project-root]');
return { root, errors };
}
function main({
root,
errors,
}: {
readonly root: string;
readonly errors: readonly string[];
}): number {
if (errors.length > 0) {
process.stderr.write(`${errors.join('\n')}\n`);
return 1;
}
const display = (path: string): string => relative(root, path).split(sep).join('/');
const files = listFiles(root);
const references = hashReferences(files);
const spacesByHash = new Map<string, Set<SpaceKind>>();
for (const reference of references)
for (const hash of reference.hashes)
spacesByHash.set(hash, new Set([...(spacesByHash.get(hash) ?? []), reference.space]));
const spaceOf = (path: string, snapshot: boolean, hash: string): SpaceKind => {
const spaces = spacesByHash.get(hash);
if (spaces?.has('extension')) return 'extension';
if (spaces?.has('app')) return 'app';
return spaceByLayout(path, snapshot, root);
};
const plans: ContractPlan[] = [];
const stops: string[] = [];
let contractsFound = 0;
for (const path of files.json) {
const text = readFileSync(path, 'utf8');
const contract = parseJson(text);
if (
contract === undefined &&
(JSON_FILES_THAT_MUST_PARSE.has(basename(path)) || files.refs.includes(path))
) {
stops.push(`${display(path)}: not valid JSON`);
continue;
}
if (
!isRecord(contract) ||
contract['targetFamily'] !== 'sql' ||
typeof contract['target'] !== 'string' ||
!isRecord(contract['storage'])
)
continue;
contractsFound++;
const snapshot = isSnapshotContract(path);
const stored = contract['storage']['storageHash'];
const oldHash = snapshot ? basename(dirname(path)) : typeof stored === 'string' ? stored : '';
const rewrite = rewriteContract(contract, spaceOf(path, snapshot, oldHash));
if (!rewrite.changed) continue;
const computed = computeStorageHash(contract);
if (computed !== oldHash || (typeof stored === 'string' && stored !== oldHash)) {
stops.push(
`${display(path)}: the stored storage hash ${oldHash} does not match the contract's content, which hashes to ${computed}; restore the file from version control, or re-emit it, and run the script again`,
);
continue;
}
if (rewrite.problems.length > 0) {
for (const problem of rewrite.problems)
stops.push(
`${display(path)}: ${problem}; restore the file from version control, or re-emit it, and run the script again`,
);
continue;
}
const newHash = computeStorageHash(rewrite.contract);
plans.push({
path,
snapshot,
oldHash,
newHash,
content: writtenForm(withStorageHash(rewrite.contract, newHash), contract, text),
expanded: rewrite.expanded,
removed: rewrite.removed,
kept: rewrite.kept,
});
}
const hashes = new Map<string, string>();
for (const plan of [...plans].sort((a, b) => Number(b.snapshot) - Number(a.snapshot)))
if (plan.oldHash !== plan.newHash && !hashes.has(plan.oldHash))
hashes.set(plan.oldHash, plan.newHash);
const merged = mergedSnapshots(files, plans, references, display);
stops.push(...merged.lines);
const newDirectories = new Map<string, Map<string, string>>();
const directorySources = new Map<string, string>();
const sourceDirectories = new Set<string>();
const removals = new Set<string>();
const snapshotRewrites: [string, string][] = [];
const referenceWrites = new Map<string, string>();
const contractWrites = new Map<string, string>();
const kept: [string, readonly string[]][] = [];
const removedEntries: [string, readonly string[]][] = [];
for (const plan of plans) {
const dtsPath = plan.snapshot
? join(dirname(plan.path), 'contract.d.ts')
: `${plan.path.slice(0, -'.json'.length)}.d.ts`;
const dtsHashes = new Map([...hashes, [plan.oldHash, plan.newHash]]);
const dts = existsSync(dtsPath)
? rewriteDts(readFileSync(dtsPath, 'utf8'), plan.expanded, plan.removed, dtsHashes)
: undefined;
const writtenPath = plan.snapshot
? join(dirname(dirname(plan.path)), plan.newHash, 'contract.json')
: plan.path;
if (plan.kept.length > 0) kept.push([display(writtenPath), plan.kept]);
if (plan.removed.size > 0) removedEntries.push([display(writtenPath), [...plan.removed]]);
if (!plan.snapshot) {
if (dts !== undefined) referenceWrites.set(dtsPath, dts);
contractWrites.set(plan.path, plan.content);
continue;
}
const oldDir = dirname(plan.path);
const newDir = join(dirname(oldDir), plan.newHash);
sourceDirectories.add(oldDir);
if (newDir === oldDir) {
if (dts !== undefined) snapshotRewrites.push([dtsPath, dts]);
snapshotRewrites.push([plan.path, plan.content]);
continue;
}
removals.add(oldDir);
const content = readDirectory(oldDir);
content.set('contract.json', plan.content);
if (dts !== undefined) content.set('contract.d.ts', dts);
const planned = newDirectories.get(newDir);
if (planned !== undefined && !sameFiles(planned, content)) {
if (!merged.directories.has(newDir))
stops.push(`${display(newDir)}: snapshot directory already exists with different content`);
continue;
}
newDirectories.set(newDir, content);
directorySources.set(newDir, oldDir);
}
for (const [dir, content] of newDirectories) {
if (sourceDirectories.has(dir) || !existsSync(dir)) continue;
if (merged.directories.has(dir) || sameFiles(readDirectory(dir), content))
newDirectories.delete(dir);
else stops.push(`${display(dir)}: snapshot directory already exists with different content`);
}
for (const path of files.migrationJson) {
const text = readFileSync(path, 'utf8');
const metadata = parseJson(text);
if (!isRecord(metadata)) continue;
const from = metadata['from'];
const to = metadata['to'];
const nextFrom = typeof from === 'string' ? (hashes.get(from) ?? from) : from;
const nextTo = typeof to === 'string' ? (hashes.get(to) ?? to) : to;
if (nextFrom === from && nextTo === to) continue;
const { path: opsPath, ops, unreadable } = readOps(dirname(path));
if (unreadable) {
stops.push(`${display(opsPath)}: not valid JSON`);
continue;
}
const oldMigrationHash = metadata['migrationHash'];
let next = replaceQuotedHashes(text, hashes);
if (
ops !== undefined &&
typeof oldMigrationHash === 'string' &&
nextFrom !== undefined &&
nextTo !== undefined
) {
const migrationHash = computeMigrationHash({ ...metadata, from: nextFrom, to: nextTo }, ops);
next = next.replace(`"${oldMigrationHash}"`, `"${migrationHash}"`);
}
referenceWrites.set(path, next);
}
for (const path of files.refs) {
const text = readFileSync(path, 'utf8');
const next = replaceQuotedHashes(text, hashes);
if (next !== text) referenceWrites.set(path, next);
}
for (const path of files.migrationTs) {
const text = readFileSync(path, 'utf8');
const next = replaceHashTokens(text, hashes);
if (next !== text) referenceWrites.set(path, next);
}
if (stops.length > 0) {
process.stderr.write(`${[...new Set(stops)].join('\n')}\n`);
return 1;
}
try {
for (const path of files.leftovers) rmSync(path, { recursive: true, force: true });
for (const [dir, content] of newDirectories)
writeDirectory(dir, content, directorySources.get(dir) ?? dir);
for (const [path, text] of snapshotRewrites) writeFile(path, text);
for (const [path, text] of referenceWrites) writeFile(path, text);
for (const [path, text] of contractWrites) writeFile(path, text);
for (const dir of removals) removeDirectory(dir);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(
`${message}; the upgrade stopped partway, run the script again to finish it\n`,
);
return 1;
}
const rewrittenFiles = snapshotRewrites.length + referenceWrites.size + contractWrites.size;
const lines = summary(
root,
contractsFound,
rewrittenFiles,
removals.size,
hashes,
byPath(removedEntries),
byPath(kept),
);
process.stdout.write(`${lines.join('\n')}\n`);
return 0;
}
process.exitCode = main(parseRoot(process.argv.slice(2)));
upgrading/app/upgrades/8.0.0-rc.2-to-8.0.0-rc.3/instructions.md
---
from: "8.0.0-rc.2"
to: "8.0.0-rc.3"
changes: []
---
upgrading/app/upgrades/8.0.0-rc.20-to-8.0.0-rc.21/instructions.md
---
from: "8.0.0-rc.20"
to: "8.0.0-rc.21"
changes:
- id: mongo-contract-target-option
summary: |
`mongoContract(schemaPath, options)` now requires `options.target`, the MongoDB target pack, which the contract records. Pass `{ target: mongoTargetPack }` with `mongoTargetPack` imported from `@prisma/orm-mongo/target/pack`, alongside any options you already pass. A config written with `defineConfig` from `@prisma/orm-mongo/config` needs no change.
detection:
glob: "**/*.{ts,mts,cts,js,mjs}"
matches:
- '(?<![\w$.])mongoContract\(\s*[^,()]*(?:\([^()]*\)[^,()]*)*,?\s*\)'
- '(?<![\w$.])mongoContract\(\s*[^,()]*(?:\([^()]*\)[^,()]*)*,\s*\{(?![^{}]*\btarget\b)[^{}]*\}\s*\)'
- '(?<![\w$.])mongoContract\(\s*[^,()]*(?:\([^()]*\)[^,()]*)*,\s*[A-Za-z_$][\w$.]*\s*\)'
- id: mongo-contract-input
summary: |
The MongoDB type for the object you pass to `defineContract`, `ContractDefinition`, is renamed `ContractInput`. It is the same type under SQL's name for it. `ContractDefinition` now names the plain-data definition every MongoDB contract source builds from, which takes no type parameters, so the detection flags an import from `@prisma/orm-family-mongo/contract-ts` only where the file writes `ContractDefinition<...>`. Import `ContractInput` from `@prisma/orm-mongo/contract-builder` where you imported `ContractDefinition`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?:import|export)\s+(?:type\s+)?\{[^}]*\bContractDefinition\b[^}]*\}\s*from\s*[\x27"]@prisma/orm-mongo/contract-builder[\x27"]'
- '(?:import|export)\s+(?:type\s+)?\{[^}]*\bContractDefinition\b[^}]*\}\s*from\s*[\x27"]@prisma/orm-family-mongo/contract-ts(?:/contract-builder)?[\x27"][\s\S]*\bContractDefinition<'
- id: mongo-model-definition-removed
summary: |
`MongoModelDefinition` is removed. It was another name for `ContractModel<MongoModelStorage>`. Write `ContractModel<MongoModelStorage>` instead, with `ContractModel` from `@prisma/orm-mongo/contract/types` and `MongoModelStorage` from `@prisma/orm-mongo/family-contract`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$])MongoModelDefinition(?![\w$])'
- id: mongo-ts-polymorphism-rules
summary: |
`defineContract` now applies the MongoDB polymorphism rules that PSL applied. It refuses a discriminator that is optional, a list or not text, such as an `Int32` field (`CONTRACT.ARGUMENT_INVALID`); a variant value the discriminator field cannot hold or that is not a member of its enum; a variant listed in `discriminator.variants` that no model declares with that base; a variant stored in another collection than its base (`CONTRACT.COLLECTION_INVALID`); and, in TypeScript and PSL, a variant field stored under a name a field of its base is stored under, such as the discriminator (`CONTRACT.NAME_DUPLICATE`, `PSL_DUPLICATE_STORED_NAME`), which would overwrite that field; and, in TypeScript and PSL, a variant field that restates a base field with a different stored name, type, nullability or list shape (`CONTRACT.ARGUMENT_INVALID`, `PSL_VARIANT_FIELD_DIFFERS_FROM_BASE`), whose documents the collection's validator, written from the base, would refuse. Make the discriminator a required `field.string()` or a string enum, list only declared variants, leave a variant's `collection` out or set it to its base's, give each variant field a stored name its base does not use, and restate a base field on a variant exactly as the base declares it, or not at all.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '@prisma/orm-(?:mongo|family-mongo/contract-ts)/contract-builder[\x27"][\s\S]*\bdiscriminator\s*:\s*\{'
- id: mongo-index-shape-rules
summary: |
Every MongoDB contract source now refuses an index MongoDB would refuse (`CONTRACT.INDEX_INVALID` in TypeScript, `PSL_INVALID_INDEX` in PSL): a unique wildcard index, a wildcard with a hashed, 2d or 2dsphere key, a hashed index on several fields, a TTL wildcard index, a wildcard projection without a wildcard key, a collation without a locale, and a second text index in one collection. PSL also refuses an index that another model on the same collection, or the same model, already declares, such as `@unique` and `@@unique` on one field. Remove the index or change it to a shape MongoDB accepts; remove a duplicate.
detection:
glob: "**/*.{ts,mts,cts,tsx,prisma}"
matches:
- '@prisma/orm-(?:mongo|family-mongo/contract-ts)/contract-builder[\x27"][\s\S]*(?:\$\*\*|\bwildcardProjection\b|[\x27"]hashed[\x27"]|\bcollation\s*:)'
- '\bwildcard\('
- id: mongo-value-object-enum-check
summary: |
`defineContract` now refuses a value object field typed by an enum the contract does not declare (`CONTRACT.ENUM_UNKNOWN`), as it already did for a model field. Add the enum to `defineContract({ enums })`.
- id: mongo-newly-accepted
summary: |
No action is needed. An optional field that a type alias, `@onCreate(...)`/`@onUpdate(...)` or `.onCreate(...)`/`.onUpdate(...)` fills is now accepted, as on SQL; a list that generates a value is still refused. A TypeScript variant may leave its `collection` out and is stored in its base's.
- id: mongo-prisma6-merged-indexes
summary: |
A Prisma 6 schema read by `prisma6Schema()` in which one collection holds two identical indexes, such as `@unique` and `@@unique` on one field, or the same `@@index` on two models stored in one collection, now gives one index, as the database holds one. That contract's storage hash changes. Run `prisma contract emit`, then `prisma db sign` to record the new hash; the database itself needs no change.
- id: removed-enum-value-questions
summary: |
On Postgres and SQLite, when the contract removes a member of an enum, or changes the value a member stores, `migration plan` and `db update` now rewrite the rows that hold the old stored value, and ask about it first. Before, Postgres failed at apply on the new check when a row held the old value, and SQLite planned nothing. If the member was renamed, say so with `--rename Status.ARCHIVED:Status.RETIRED`, which changes the stored value in every row and list that holds it. Answer with `--delete Status.ARCHIVED` to set it to NULL in optional columns and take it out of lists.
- id: client-members-reject-instead-of-throwing
summary: |
A client member that returns a promise or a result no longer throws at the call when the client or the transaction is in a failed state. On `postgres()`, `sqlite()`, a `supabase()` role-bound client and a transaction, `query`, `execute`, `prepare`, `transaction`, ORM calls and a prepared statement's `query(target, params)` and `execute(target, params)` now report a closed client, a failed connection or an ended transaction through what they return: the promise rejects, and a result rejects when it is awaited or read. Error codes and messages do not change. Code that wraps such a call in `try`/`catch` without awaiting it no longer catches the error; add `await`, or handle it with `.catch(...)`. `db.runtime()` still throws at the call, and so does a mistake in the call itself, such as an ORM query the database's capabilities do not allow. The detection matches a file that imports an `@prisma/orm-*` package and has a `try` block, one line or many, nested or not, that calls one of these members or an ORM terminal (`all`, `first`, `create`, `update`, `delete`, `upsert`, …) without `await` directly before it, on a receiver shaped like a client: a name ending in `db`, `tx`, a name ending in `client`, `admin`, a prepared statement, or a chain through `.orm`; chains may span lines and take callbacks; leave a call whose result is awaited or returned to an awaiting caller as it is.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs}"
matches:
- '(?<![\s\S])(?=[\s\S]*[\x27\x22]@prisma/orm-)[\s\S]*\btry\s*\{(?:(?!\}\s*(?:catch|finally)\b)[\s\S])*?(?<![\w$.])(?<!\bawait\s+)(?:this\s*\.\s*)?(?:[\w$]*(?:db|Db|DB)|tx|trx|[\w$]*[Cc]lient|admin|[\w$]*[Pp]repared[\w$]*|[\w$]+\s*\.\s*orm)(?:\s*\.\s*[\w$]+(?:\s*\((?:[^()]|\([^()]*\))*\))?)*?\s*\.\s*(?:query|execute|prepare|transaction|all|first|firstOrThrow|create|createAll|createAndCount|update|updateAll|updateAndCount|delete|deleteAll|deleteAndCount|upsert|aggregate)\s*\('
- id: close-ends-the-client-not-your-pool
summary: |
`close()` on a Postgres or Supabase client built on a pool or `pg.Client` you passed in now also closes its runtime, so a runtime you captured with `db.runtime()` before `close()` refuses new work. Your pool or `pg.Client` stays open, as before; end it yourself.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '(?:postgres|supabase)\(\s*\{[\s\S]*?\b(?:pg|binding|pool|client)\s*[:,}]'
- id: sqlite-open-failure-is-a-connection-error
summary: |
A SQLite database file that cannot be opened (a missing directory, or a file that is not a database) is now a connection error (`SqlConnectionError`), not a query error (`SqlQueryError`), in the driver. Code that caught the query error to handle a bad database path catches the connection error instead.
- id: verify-contract-spaces-marker-location
summary: |
`verifyContractSpaces` (from the `spaces` subpath of the migration tools) takes a required `markerLocation`, the name of the target's marker table or collection, which the remediation of an orphan marker names.
detection:
glob: "**/*.{ts,tsx,mts,cts,js,mjs,cjs}"
matches:
- '(?<![\w$])verifyContractSpaces\s*\('
- id: refuse-marker-outside-graph-removed
summary: |
`refuseMarkerOutsideGraph` is no longer exported from `@prisma/orm-toolchain/cli/control-api`. The control client's `migrate` and `executeMigrateShowPlan` now check every contract space's marker against its migration graph themselves, and fail with `MIGRATION.MARKER_MISMATCH` when a marker is outside it. Delete the call and its handling; branch on the `MIGRATION.MARKER_MISMATCH` failure those operations return instead.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs}"
matches:
- '\brefuseMarkerOutsideGraph\b'
- id: execute-migrate-show-plan-requires-invocation
summary: |
`executeMigrateShowPlan` takes a required `invocation` option, `{ cwd, configPath, dbGiven }`, like the control client's `migrate`: where the commands its advice prints are run from, the `--config` they repeat, and whether they repeat `--db`. Pass the same `invocation` you pass to `migrate`.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs}"
matches:
- '\bexecuteMigrateShowPlan\s*\('
- id: marker-mismatch-meta
summary: |
The `meta` of `MIGRATION.MARKER_MISMATCH` no longer has `reachableHashes`. It has `spaceId`, `markerHash` and `targetHash`; `lastGraphHash`, the last contract in the migration graph the database applied, when its ledger shows one; and `missingMigrations`, the applied migrations after it that are no longer on disk, absent when the ledger could not be read. Code that read `reachableHashes` should read these fields, or show the error's `why` and `nextActions`.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs}"
matches:
- '\breachableHashes\b'
- id: psl-temporal-type-renames
summary: |
On PostgreSQL, the PSL types `Timestamptz`, `Timestamp` and `Interval` are renamed to `TimestamptzTemporal`, `TimestampTemporal` and `IntervalParts`, and the old names are errors. Rename each use in your `.prisma` files, keeping its arguments: `Timestamptz(3)` becomes `TimestamptzTemporal(3)`. Each new name reads the same value with the same codec, so the contract and its hashes do not change. `@db.Timestamp` and `@db.Timestamptz` in a Prisma 7 schema are not affected.
detection:
glob: "**/*.prisma"
matches:
- '(?<![\w$.@"\x27`])(?:Timestamptz|Timestamp|Interval)(?![\w$])'
- id: ts-temporal-type-helpers-renamed
summary: |
In a TypeScript contract, `type.Timestamptz(...)`, `type.Timestamp(...)` and `type.Interval(...)` are renamed to `type.TimestamptzTemporal(...)`, `type.TimestampTemporal(...)` and `type.IntervalParts(...)`, and the column helper `intervalColumn(...)` from `@prisma/orm-postgres/adapter/column-types` is renamed to `intervalPartsColumn(...)`. Rename each call; the arguments do not change.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$])type\.(?:Timestamptz|Timestamp|Interval)\('
- '(?<![\w$])intervalColumn(?![\w$])'
- id: interval-with-precision-typed-as-parts
summary: |
An `IntervalParts(p)` column, an interval with a precision, is now typed as the `{ months, days, micros }` object its codec reads, as an interval without a precision already was, instead of a string. Type such a value as `PgInterval` from `@prisma/orm-postgres/target/codec-types`. Code that treated the value as a string was wrong at run time and now fails to type-check.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'import\s+(?:type\s+)?\{[^}]*(?<![\w$])Interval(?![\w$])[^}]*\}\s*from\s*[\x27"]@prisma/orm-postgres/(?:contract-builder|target/codec-types)[\x27"]'
- id: branded-time-types-removed
summary: |
The string types `Timestamp`, `Timestamptz`, `Time` and `Interval` are no longer exported from `@prisma/orm-postgres/target/codec-types` (and `Interval` no longer from `@prisma/orm-postgres/contract-builder`). No column was typed by the first three. Remove each of these names from the import that names it, and delete the import if it names nothing else. A `contract.d.ts` written by an earlier release, such as one in a migration snapshot, imports them without using them, so removing them from its import is the only change it needs.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'import\s+(?:type\s+)?\{[^}]*(?<![\w$])(?:Timestamptz|Timestamp|Time|Interval)(?![\w$])[^}]*\}\s*from\s*[\x27"]@prisma/orm-postgres/(?:contract-builder|target/codec-types)[\x27"]'
- id: temporal-types-from-temporal-spec
summary: |
Temporal fields are now typed with the `Temporal` types of the `temporal-spec` package rather than a global `Temporal` declaration, so they are typed even in a project without the polyfill's global types. The values are the same and assign to and from the global types. A type test that requires a field to be exactly `Temporal.Instant` (or another `Temporal` type) from the global declarations now fails: add `temporal-spec` (`1.0.1`) to your development dependencies and import the type with `import type { Temporal } from 'temporal-spec'` in that test.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\s\S])(?![\s\S]*import\s+(?:type\s+)?\{[^}]*(?<![\w$])Temporal(?![\w$])(?!\s+as(?![\w$]))[^}]*\}\s*from\s*[\x27"]temporal-spec[\x27"])[\s\S]*toEqualTypeOf<\s*Temporal\.'
- id: contract-types-list-new-aggregates
summary: |
Optional. An emitted `contract.d.ts` lists the aggregates of every codec in the stack, so `prisma contract emit` now adds rows for the three new codecs to it, and the contract itself does not change. Re-emit when you next change the contract; until then the existing `contract.d.ts` keeps working. A migration snapshot's `contract.d.ts` does not need the new rows; it needs only the import change `branded-time-types-removed` describes.
- id: iso-string-date-time-values
summary: |
The PostgreSQL text date and time types (`TimestamptzString`, `TimestampString`, `DateString`, `TimeString`, `Timetz`) read and write ISO 8601, the form the contract stores, instead of the text PostgreSQL prints. A `timestamptz` reads as the instant in UTC whatever the session's time zone, as in `2024-01-01T00:00:00Z`. A literal default or enum member on these types must be written in that form.
detection:
glob: "**/*.{prisma,ts,tsx,mts,cts,js,mjs,cjs}"
matches:
- '(?<![\w$])(?:TimestamptzString|TimestampString|DateString|TimeString|Timetz)(?![\w$])'
- '(?<![\w$])(?:timestamptzStringColumn|timestampStringColumn|dateStringColumn|timeStringColumn|timetzColumn|createdAtString|updatedAtString)(?![\w$])'
- 'pg/(?:timestamptz-string|timestamp-string|date-string|time-string|timetz)@1'
- id: date-style-iso-required
summary: |
The PostgreSQL driver ensures `DateStyle=ISO` on every connection it uses, including a `pg.Pool` or `pg.Client` the application passes to Prisma, which it sets with a statement. A statement in the application that sets a `DateStyle` other than `ISO`, or a pooler that drops the startup option, makes every read of a date or timestamp fail with `RUNTIME.DATE_STYLE_NOT_ISO`.
detection:
glob: "**/*.{ts,tsx,mts,cts,js,mjs,cjs,sql,env}"
matches:
- 'new\s+(?:pg\.)?(?:Pool|Client)\s*\('
- '\b[Ss][Ee][Tt]\s+(?:[Ss][Ee][Ss][Ss][Ii][Oo][Nn]\s+|[Ll][Oo][Cc][Aa][Ll]\s+)?[Dd][Aa][Tt][Ee][Ss][Tt][Yy][Ll][Ee]\b'
- '\b(?:[Dd][Aa][Tt][Ee][Ss][Tt][Yy][Ll][Ee]|PGDATESTYLE)\s*='
- id: psl-field-presets-removed
summary: |
PSL field presets are removed. A field typed with a preset call, such as `id.uuidv4String()`, `nanoid({ size: 12 })`, `temporal.createdAt()` or `temporal.time(3, onUpdate: now)`, is refused with `PSL_UNRESOLVED_REFERENCE`, and the message gives the exact replacement. Rewrite each call as the tables in this guide say: a type constructor or a contributed alias, plus `@id`, `@onCreate(...)` and `@onUpdate(...)`. The contract does not change, so there is nothing to re-emit, sign or migrate. The detection matches schema files that type a field with a preset call.
detection:
glob: "**/*.prisma"
matches:
- '(?:^|\n)[ \t]*\w+[ \t]+(?:temporal|id)\.[a-z]\w*[ \t]*\('
- '(?:^|\n)[ \t]*\w+[ \t]+(?:text|int|bigint|float|decimal|boolean|json|bytes|dateTime|uuidString|uuidNative|ulid|nanoid|cuid2|ksuid)[ \t]*\('
- id: ts-field-presets-removed
summary: |
The TypeScript contract builders have no field presets. `field.text()`, `field.uuidString()`, `field.json()` (on Postgres) and the other scalar presets, every `field.id.*` and every `field.temporal.*` no longer exist. TypeScript reports a missing property, and `prisma contract emit` on a contract that was not type-checked fails with `CONTRACT.FIELD_HELPER_UNKNOWN`, which names the rewrite. Rewrite each call as `field.column(type.X(...))` with `.id()`, `.onCreate(gen.fn())` and `.onUpdate(gen.fn())`, as the tables in this guide say, including the fields of query fragments. The contract does not change. MongoDB's `field.date()`, `field.string()`, `field.json()` and the other codec helpers are not presets and stay. The detection matches TypeScript files that call a preset; it matches a `field.json()` call only in a file that names `@prisma/orm-postgres`, so a Postgres file that imports its builder through a local module is not matched.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$.])field\.(?:id|temporal)\.'
- '(?<![\w$.])field\.(?:text|int|bigint|float|decimal|boolean|bytes|dateTime|uuidString|uuidNative|ulid|nanoid|cuid2|ksuid)\('
- '(?<![\s\S])(?=[\s\S]*@prisma/orm-postgres)[\s\S]*(?<![\w$.])field\.json\('
- id: ts-field-generated-removed
summary: |
`field.generated(...)` and the spec helpers `uuidv4()`, `uuidv7()`, `ulid()`, `nanoid()`, `cuid2()` and `ksuid()` from `@prisma/orm-framework/ids` are removed. Write the identifier's type alias with `.onCreate(gen.fn())` instead, such as `field.column(type.Ulid()).onCreate(gen.ulid())`, as the table in this guide says, and remove the import. The contract does not change. The detection matches TypeScript files that call `field.generated` or import one of those helpers.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$.])field\.generated\('
- 'import\s*(?:type\s*)?\{[^}]*(?<![\w$])(?:uuidv4|uuidv7|ulid|nanoid|cuid2|ksuid)(?![\w$])[^}]*\}\s*from\s*[\x27"]@prisma/orm-framework/ids[\x27"]'
- id: postgres-contract-builder-exports-type
summary: |
No action is needed. `@prisma/orm-postgres/contract-builder` now exports `type` beside `field` and `gen`, the same type constructors and aliases the `defineContract` callback receives, so a query fragment declares a field as `field.column(type.TimestamptzTemporal())`.
- id: sqlite-sql-char
summary: |
No action is needed. Every SQL target now has the type constructor `sql.Char(n)` in PSL and `type.sql.Char(n)` in TypeScript, which stores a fixed-length text column of `n` characters. On SQLite it is the only way to write a fixed-length text column, such as the column of a nanoid of another size.
- id: identifier-type-aliases
summary: |
No action is needed. Every SQL target now has a type alias for the column of each identifier format the client generates: `sql.UuidString`, `sql.Ulid`, `sql.Nanoid`, `sql.Cuid2` and `sql.Ksuid` in PSL, and `type.sql.Ulid()` and the others in TypeScript. Postgres also names them at the top level, as `Ulid` and `type.Ulid()`. Each stores the same column as `Char(n)` of its length, so the identifier presets are rewritten with them.
- id: prisma7-schema-now-default-writes-on-create
summary: |
A contract from `prisma7Schema(...)` now reads Prisma 7's `@default(now())` on a date or time field as both the database default `now()` and the client generator `now` on create, as Prisma 8 PSL reads `@default(now()) @onCreate(now())`. A create through Prisma 8 now writes the current UTC time, as Prisma 7's client did, instead of leaving the column to the database's clock in the session time zone. Re-emit the contract with `prisma contract emit` and commit it. Only the execution hash changes, so the database needs no migration and no `db sign`.
detection:
glob: "**/prisma.config.{ts,mts,cts,js,mjs}"
matches:
- '\bprisma7Schema\s*\('
- id: contract-artifacts-restamp
summary: |
An extension that writes its own package version into the contracts it emits, such as the Supabase extension, now writes 8.0.0-rc.21. Run `contract emit` once after upgrading so the emitted `contract.json` and `contract.d.ts` match the installed extension.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.20"'
---
# 8.0.0-rc.20 → 8.0.0-rc.21 — User upgrade instructions
## Order of the steps
Do the steps in this order, and skip a step that does not apply to the project:
1. Upgrade the Prisma packages.
2. Change a MongoDB contract source as the MongoDB entries below say: pass the target pack to `mongoContract` (`mongo-contract-target-option`), rename the contract types (`mongo-contract-input`, `mongo-model-definition-removed`), and fix any discriminator, index or enum the contract now refuses (`mongo-ts-polymorphism-rules`, `mongo-index-shape-rules`, `mongo-value-object-enum-check`).
3. Change a PostgreSQL contract source: rename the date and time types (`psl-temporal-type-renames`, `ts-temporal-type-helpers-renamed`), and write each literal default or enum member of a text date or time type in ISO 8601 (`iso-string-date-time-values`).
4. Rewrite every field preset and `field.generated(...)` in the contract sources and query fragments, with the type constructors, aliases and attributes the tables in "Field presets are removed" give (`psl-field-presets-removed`, `ts-field-presets-removed`, `ts-field-generated-removed`). Do this after step 3, because the tables use the new date and time type names.
5. Run `prisma contract emit` (this also covers `contract-types-list-new-aggregates`, `prisma7-schema-now-default-writes-on-create` and `contract-artifacts-restamp`).
6. Run `prisma db sign` against every database when the project reads a Prisma 6 MongoDB schema whose storage hash changed (`mongo-prisma6-merged-indexes`).
7. On Postgres and SQLite, answer the question about a removed enum value the next time you run `migration plan` or `db update` (`removed-enum-value-questions`).
8. Change the application code: await client calls inside `try` blocks (`client-members-reject-instead-of-throwing`), stop using a runtime captured before `close()` (`close-ends-the-client-not-your-pool`), catch the connection error for a bad SQLite path (`sqlite-open-failure-is-a-connection-error`), pass `markerLocation` to `verifyContractSpaces` (`verify-contract-spaces-marker-location`), and, in code that calls the control API, drop `refuseMarkerOutsideGraph`, pass `invocation` to `executeMigrateShowPlan` and read the new `MIGRATION.MARKER_MISMATCH` fields (`refuse-marker-outside-graph-removed`, `execute-migrate-show-plan-requires-invocation`, `marker-mismatch-meta`).
9. Change the code that uses PostgreSQL date and time values: remove the date and time types no longer exported from imports, including those in migration snapshots (`branded-time-types-removed`, `interval-with-precision-typed-as-parts`), import `Temporal` from `temporal-spec` in type tests (`temporal-types-from-temporal-spec`), read text date and time values as ISO 8601 (`iso-string-date-time-values`), and remove any statement that sets a `DateStyle` other than `ISO` (`date-style-iso-required`).
## `mongoContract` takes the target pack
The MongoDB PSL contract source now builds its contract through the same builder `defineContract` uses, and that builder records the stack's target and extension packs. The source reads them from its options.
### `mongo-contract-target-option`
Pass the MongoDB target pack as `target`:
```typescript
import { mongoContract } from '@prisma/orm-family-mongo/contract-psl/provider';
import mongoTargetPack from '@prisma/orm-mongo/target/pack';
const contract = mongoContract('./prisma/schema.prisma', { target: mongoTargetPack });
```
Keep any other options you pass, such as `output`, in the same object. A config that names its contract through `defineConfig({ contract: './prisma/schema.prisma' })` from `@prisma/orm-mongo/config` already passes the pack. An app that calls `mongoContract` itself imports it from `@prisma/orm-family-mongo/contract-psl/provider`, so it depends on `@prisma/orm-family-mongo` as well as `@prisma/orm-mongo`; `@prisma/orm-mongo` does not export `mongoContract`. A call without `target` is refused with `CONTRACT.ARGUMENT_INVALID` (`reason: 'target-pack-missing'`).
## MongoDB contract types take SQL's names
Two MongoDB type names change so that they match SQL's. The types themselves do not change.
### `mongo-contract-input`
The type of the object you pass to `defineContract` is now `ContractInput`, as it is in SQL. Rename the import and each use:
```typescript
import type { ContractInput } from '@prisma/orm-mongo/contract-builder';
```
### `mongo-model-definition-removed`
`MongoModelDefinition` was an alias for a model of a built contract. SQL has no such alias. Write the type it stood for:
```typescript
import type { ContractModel } from '@prisma/orm-mongo/contract/types';
import type { MongoModelStorage } from '@prisma/orm-mongo/family-contract';
function collectionOf(model: ContractModel<MongoModelStorage>): string | undefined {
return model.storage.collection;
}
```
## MongoDB contract sources share one builder
Every MongoDB contract source, `defineContract`, Prisma 8 PSL and the Prisma 6 reader, now builds through one builder, so each MongoDB rule applies to every source in the same way. The entries below are where a source now refuses or accepts what it did not before. A contract that every source accepted before builds to the same `contract.json`.
### `mongo-ts-polymorphism-rules`
The detection matches a file that imports the MongoDB contract builder and declares a discriminator; an emitted `contract.d.ts` imports no builder, so it is not matched.
A discriminator must be a required text field, and each variant must be declared and stored in its base's collection:
```typescript
const Task = model('Task', {
collection: 'tasks',
fields: { _id: field.objectId(), type: field.string() },
discriminator: { field: 'type', variants: { Bug: { value: 'bug' } } },
});
const Bug = model('Bug', { base: Task, fields: { severity: field.int32() } });
```
### `mongo-index-shape-rules`
MongoDB refuses these indexes when it creates them, so the contract now refuses them first. A matched file uses a wildcard, a hashed key, a wildcard projection or a collation; check each such index against the list in the summary.
### `mongo-value-object-enum-check`
Declare every enum a value object field uses in `defineContract({ enums })`.
### `mongo-newly-accepted`
Nothing to change.
### `mongo-prisma6-merged-indexes`
Emit the contract again and sign the database, so `db verify` compares against the new storage hash.
## A removed enum value is asked about
### `removed-enum-value-questions`
If your contract removes a member of an enum, for example `ARCHIVED` from `Status`, the next `migration plan` or `db update` asks about it, as it asks about a model or field you remove: once, however many columns hold it.
If you renamed the member, give the rename, and the rows that held the old stored value hold the new one:
```bash
prisma db update --rename Status.ARCHIVED:Status.RETIRED
```
Otherwise the value is removed. A script that runs without a terminal fails with `CLI.CONSENT_REQUIRED`, naming the value. To let it go, add `--delete Status.ARCHIVED`: optional columns that hold it are set to NULL, and lists lose it. A required column cannot take NULL, so while rows of a required column hold the value, the apply fails with `MIGRATION.PRECHECK_FAILED`, changes nothing, counts the rows, and says how to keep them: rename the value to a new member, or keep that data by hand.
A member renamed with its stored value kept, for example with `RETIRED = "archived"`, changes nothing in the rows, and is not asked about. A member that keeps its name but stores a new value is asked about with `--delete` only, and so are two members that swap stored values; to keep the rows, the question names, for every column the enum types, the `this.removeStoredValue(...)` call to replace with `this.replaceStoredValue(...)` calls in the `migration.ts` that `migration plan` writes, and the `node` command that runs it. A swap goes through a temporary value. `db update` cannot keep that data in one run: its question says to plan the change with `migration plan --from <hash>`, or to rename the member to a new member and back in two runs of `db update`. For a swap only the plan works, and without migrations `db update` says it cannot keep the data.
Renames that shift stored values along, such as `Higher = 3` to `A = 4` while `Max = 4` becomes `B = 5`, are planned in one run, the last value first. Renames that move stored values in a cycle are refused; the refusal prints the `migration plan` command to write those rewrites by hand in, through a value no member stores.
Native Postgres enums (`pg.enum(...)`) are unchanged.
## A client reports failures through what its members return
### `client-members-reject-instead-of-throwing`
```ts
await db.close();
// Before: throws at the call, so .catch never runs
db.query(plan).toArray().catch(handle);
// After: the result rejects, so handle runs
db.query(plan).toArray().catch(handle);
// A synchronous try/catch around an unawaited call stops catching
try {
db.transaction(work);
} catch (error) {
handle(error);
}
// Await it instead
try {
await db.transaction(work);
} catch (error) {
handle(error);
}
```
## Postgres and SQLite errors are classified by code
### `close-ends-the-client-not-your-pool`
When you pass your own `pg.Pool` or `pg.Client` to `postgres()` or `supabase()`, `close()` on the Prisma client now also closes its runtime. Calls on `db` already failed after `close()`; what changes is a runtime you captured with `db.runtime()` before `close()`, which used to keep working and now refuses new work. Your pool or client is still yours: Prisma never ends it. If you used a captured runtime after `close()`, create a new Prisma client on the same pool instead:
```ts
await db.close();
const db2 = postgres({ contract, pg: pool });
```
A `pg.Client` you pass in may be connected or not: Prisma connects it if it has not started connecting. If its connection is lost, create a new `pg.Client`; a pg client cannot reconnect.
### `sqlite-open-failure-is-a-connection-error`
When the SQLite database file cannot be opened, the driver now raises a connection error (`SqlConnectionError`) instead of a query error (`SqlQueryError`).
## `verifyContractSpaces` names the marker location
### `verify-contract-spaces-marker-location`
A project that checks its contract spaces itself passes the marker location of its target to `verifyContractSpaces`, so the advice for an orphan marker names the right table:
```ts
// Before
verifyContractSpaces({ loadedSpaces, spaceDirsOnDisk, headRefsBySpace, markerRowsBySpace });
// After
verifyContractSpaces({
loadedSpaces,
spaceDirsOnDisk,
headRefsBySpace,
markerRowsBySpace,
markerLocation: 'prisma_contract.marker',
});
```
The marker location is `prisma_contract.marker` on Postgres, `_prisma_marker` on SQLite and MySQL, and `_prisma_migrations` on MongoDB.
## The control API checks markers against the migration graph itself
These three changes affect only code that calls `@prisma/orm-toolchain/cli/control-api`. A project that runs the `prisma` CLI has nothing to change.
```ts
// Before
const refusal = refuseMarkerOutsideGraph({ markerHash, graph });
if (refusal) return notOk(refusal);
const plan = await executeMigrateShowPlan({ ...options });
// After: the operation returns MIGRATION.MARKER_MISMATCH itself
const plan = await executeMigrateShowPlan({ ...options, invocation });
if (!plan.ok && plan.failure.code === 'MIGRATION.MARKER_MISMATCH') {
// plan.failure.meta: spaceId, markerHash, targetHash, lastGraphHash?, missingMigrations?
}
```
### `refuse-marker-outside-graph-removed`
For every file matched by `detection`, delete the call to `refuseMarkerOutsideGraph` and the handling of what it returned. `migrate` and `executeMigrateShowPlan` now make the same check for every contract space and return `MIGRATION.MARKER_MISMATCH` as their failure.
### `execute-migrate-show-plan-requires-invocation`
For every call matched by `detection`, add `invocation: { cwd, configPath, dbGiven }`, the same value the project passes to `migrate`. Set `dbGiven: true` when the project passes a database URL, so the printed commands include `--db <url>`.
### `marker-mismatch-meta`
For every file matched by `detection` that reads the `meta` of `MIGRATION.MARKER_MISMATCH`, read `spaceId`, `markerHash`, `targetHash`, `lastGraphHash` and `missingMigrations` instead of `reachableHashes`, or show the error's `why` and `nextActions`. `MIGRATION.HASH_NOT_IN_GRAPH` still has `reachableHashes`; leave code that reads it there unchanged.
The error's text changes too. `prisma db migrate` now prints `The database's marker names <hash>, which no migration in <directory> starts or ends at.`, followed by what the database applied after the last contract of the graph it reached. Its next actions give two recoveries, `prisma db update` for a database whose migration history does not need to be kept, and new migrations for one that changes only through migrations. None of them resets the database.
## Explicit date and time types carry the kind of value they read
On PostgreSQL, a column type that several codecs read now names the kind of value in its name. The `Temporal` types gain the suffix `Temporal`, and the interval type that reads `{ months, days, micros }` gains the suffix `Parts`:
| Before | After | Reads |
| --- | --- | --- |
| `Timestamptz(p?)` | `TimestamptzTemporal(p?)` | `Temporal.Instant` |
| `Timestamp(p?)` | `TimestampTemporal(p?)` | `Temporal.PlainDateTime` |
| `Interval(p?)` | `IntervalParts(p?)` | `{ months, days, micros }` |
This change leaves `DateTime`, `Date` and `Time` as they are, and adds `DateTemporal` and `TimeTemporal` as the explicit names of what `Date` and `Time` read today. A later release gives `DateTime`, `Date` and `Time` new meanings, as convenience date and time types, and brings its own instructions. To keep what a field reads today through that change, you can already write the explicit name: `TimestamptzTemporal` for `DateTime`, `DateTemporal` for `Date` and `TimeTemporal` for `Time`.
Three explicit types are new, and need no change to existing schemas: `TimestampJsDate(p?)`, a `timestamp` column read as a JavaScript `Date`; `TimestampInstant(p?)`, a `timestamp` column read as a `Temporal.Instant`; and `IntervalString(p?)`, an `interval` column read as an ISO 8601 duration such as `P1Y2M`. Both timestamp types read and write the stored date and time as UTC.
### `psl-temporal-type-renames`
Rename each field type in your `.prisma` files using the table above, keeping its arguments and its attributes:
```prisma
model Event {
id Int @id
at TimestamptzTemporal(3) // was Timestamptz(3)
local TimestampTemporal // was Timestamp
span IntervalParts // was Interval
}
```
A type alias written with one of the names, such as `types { Stamp = Timestamp(3) }`, is renamed the same way. The detection can match one of the words in a comment or a model name; rename only a field type or an alias's type.
### `ts-temporal-type-helpers-renamed`
```ts
field.column(type.TimestamptzTemporal(3)); // was type.Timestamptz(3)
field.column(intervalPartsColumn(6)); // was intervalColumn(6)
```
### `interval-with-precision-typed-as-parts`
```ts
import type { PgInterval } from '@prisma/orm-postgres/target/codec-types';
const span: PgInterval = row.span; // an IntervalParts(6) column
```
### `branded-time-types-removed`
Remove the four names from the import, and leave the rest of the file as it is:
```ts
import type {
CodecTypes as PgTypes,
JsonValue,
} from '@prisma/orm-postgres/target/codec-types'; // was also importing Interval, Time, Timestamp and Timestamptz
```
### `temporal-types-from-temporal-spec`
```ts
import type { Temporal } from 'temporal-spec';
expectTypeOf<Row['createdAt']>().toEqualTypeOf<Temporal.Instant>();
```
If the test file also creates `Temporal` values, import the type under another name, such as `import type { Temporal as TemporalTypes } from 'temporal-spec'`, so the runtime `Temporal` stays in scope.
### `contract-types-list-new-aggregates`
No change is needed now. The next `prisma contract emit` adds rows for the three new codecs to `contract.d.ts`.
## Text date and time values are ISO 8601
### `iso-string-date-time-values`
A column of `TimestamptzString`, `TimestampString`, `DateString`, `TimeString` or `Timetz` used to read the text PostgreSQL prints, which depends on the session's `TimeZone` and `DateStyle`. It now reads ISO 8601, the same on every server:
| Type | Before | After |
| --- | --- | --- |
| `TimestamptzString` | `2024-01-01 01:00:00+01` on a server in `Europe/Berlin` | `2024-01-01T00:00:00Z` |
| `TimestampString` | `2024-01-01 12:34:56.5` | `2024-01-01T12:34:56.5` |
| `DateString`, 44 BC | `0044-03-15 BC` | `-000043-03-15` |
| `Timetz` | `12:34:56+02`, `12:34:56+00` | `12:34:56+02:00`, `12:34:56Z` |
The same text is read inside an `.include()`. `infinity` and `-infinity` are unchanged. The generated `now()` values of these columns, such as `temporal.UpdatedAtString`, are written in the same form.
Change the code that reads these values:
- Code that parses the old text, for example by replacing the space with `T` or appending `Z` before `new Date(...)`, can pass the value to `new Date(...)` or `Temporal.Instant.from(...)` directly for a `TimestamptzString`. A `TimestampString` value has no offset; append `Z` to read it as UTC, as before.
- Code that compares a read value with text in the old form, in application code or in tests, compares with the ISO form.
- Code that shows a value to a user formats it for display, since the value is no longer in the session's time zone.
A value the application writes may still be in any form PostgreSQL reads, and it reads back in ISO 8601. A literal default or an enum member on these types must be written in the stored form, because Prisma compares it with the value a query returns:
```ts
// Before
scheduledAt: field.column(timestamptzStringColumn).default('2024-01-15 10:30:00+00'),
// After
scheduledAt: field.column(timestamptzStringColumn).default('2024-01-15T10:30:00Z'),
```
`contract emit` refuses a default in another form with `CONTRACT.DEFAULT_INVALID`, and the message shows the form to write. A PSL default written with a tag, such as `` @default(timestamptz`2024-01-15 10:30:00+00`) ``, is unchanged. An enum on `TimestampString`, `TimestamptzString` or `Timetz` is now allowed.
The `now()` value of a `TimestampString(p)` or `TimestamptzString(p)` column is now rounded down to the column's precision, as a time column's is, so the column stores the value generated. Emit the contract again: its execution hash changes where such a column has a `now()` generator.
### `date-style-iso-required`
The PostgreSQL driver now ensures `DateStyle=ISO` on every connection it uses. A connection Prisma opens from a URL starts with it as a startup option, after any `options` in the URL or `PGOPTIONS`. On a `pg.Pool` or `pg.Client` you pass to Prisma, the driver sets it with one statement on each connection before first use, and again after a connection held for a transaction, an ORM operation or a Supabase role scope is released. Nothing needs changing for either.
Remove any statement that sets a `DateStyle` other than `ISO` on a connection Prisma uses, such as a `SET DateStyle` in a pool's `connect` handler or in raw SQL: every date and time codec that reads a date or timestamp (text, `Temporal` or `Date`) refuses text in another style with `RUNTIME.DATE_STYLE_NOT_ISO`. Behind a connection pooler that refuses startup options, pass Prisma your own `pg.Pool` instead of a URL; behind one that runs each statement on another server connection, also run `ALTER ROLE <role> SET DateStyle = 'ISO'` (or set it on the database).
## Field presets are removed
This section replaces two pieces of advice from the 8.0.0-rc.19 upgrade guide. That guide said that `field.generated(...)` keeps working for now, and that a field preset keeps its `number`, `option` and `object` arguments. Neither holds any more: this release removes `field.generated(...)` and every field preset, in PSL and in TypeScript, and the tables below give what to write instead.
A field preset was a call in a field's type position that set the column, the identity and the generated values at once, such as `id.uuidv4String()` or `temporal.createdAt()`. Each is now written with what the rest of a contract uses: a type constructor or a contributed alias, such as `Ulid` or `temporal.CreatedAt`, plus `@id`, `@onCreate(...)` and `@onUpdate(...)` in PSL, or `.id()`, `.onCreate(gen.fn())` and `.onUpdate(gen.fn())` in TypeScript. Every replacement stores the same contract as its preset, with the same storage and execution hashes. So after the rewrite nothing is re-emitted, signed or migrated: `prisma contract emit` writes the `contract.json` you have. Rewrite the calls where your project's comments and documentation show them too, such as a README, in PSL and in TypeScript; the detections look only at `.prisma` and TypeScript files.
```prisma
// Before
model Event {
id id.uuidv4String()
code nanoid({ size: 12 })
seenAt temporal.time(3, onUpdate: now)
createdAt temporal.createdAt()
payload json()
}
// After
model Event {
id UuidString @id @onCreate(uuid())
code Char(12)
seenAt Time(3) @onUpdate(now())
createdAt temporal.CreatedAt
payload Jsonb
}
```
```ts
// Before
({ field, model }) => ({
models: {
Event: model('Event', {
fields: {
id: field.id.uuidv4String(),
code: field.nanoid({ size: 12 }),
seenAt: field.temporal.time(3, undefined, 'now'),
createdAt: field.temporal.createdAt(),
payload: field.json(),
slug: field.generated(nanoid({ size: 16 })),
},
}),
},
})
// After
({ field, model, gen, type }) => ({
models: {
Event: model('Event', {
fields: {
id: field.column(type.UuidString()).id().onCreate(gen.uuid()),
code: field.column(type.Char(12)),
seenAt: field.column(type.Time(3)).onUpdate(gen.now()),
createdAt: field.column(type.temporal.CreatedAt()),
payload: field.column(type.Jsonb()),
slug: field.column(type.Char(16)).onCreate(gen.nanoid(16)),
},
}),
},
})
```
### The replacement of each preset
In the tables, a PSL preset is written as in a schema. Its TypeScript form is the same call on `field`: `id.uuidv4String()` is `field.id.uuidv4String()`, and `temporal.createdAt()` is `field.temporal.createdAt()`.
#### SQL family presets, on Postgres and SQLite
Each identifier preset is replaced by the type alias for its column: `UuidString`, `Ulid`, `Nanoid`, `Cuid2` or `Ksuid`. The field writes `@id` and `@onCreate(...)` itself where the preset set them. On Postgres, write the alias as a top-level name, `Ulid` in PSL and `type.Ulid()` in TypeScript, as the table does. On SQLite, write it in the `sql` namespace, `sql.Ulid` and `type.sql.Ulid()`. Both forms store the same column as `Char(26)`. A nanoid of another size has no alias, so its column is written out: `Char(n)` on Postgres, `sql.Char(n)` on SQLite.
| Preset | Stores | PSL replacement | TypeScript replacement |
|---|---|---|---|
| `uuidString()` | `sql/char@1`, `{ length: 36 }` | `UuidString` | `field.column(type.UuidString())` |
| `ulid()` | `sql/char@1`, `{ length: 26 }` | `Ulid` | `field.column(type.Ulid())` |
| `nanoid()` | `sql/char@1`, `{ length: 21 }` | `Nanoid` | `field.column(type.Nanoid())` |
| `nanoid({ size: n })` | `sql/char@1`, `{ length: n }` | `Char(n)` | `field.column(type.Char(n))` |
| `cuid2()` | `sql/char@1`, `{ length: 24 }` | `Cuid2` | `field.column(type.Cuid2())` |
| `ksuid()` | `sql/char@1`, `{ length: 27 }` | `Ksuid` | `field.column(type.Ksuid())` |
| `id.uuidv4String()` | as `uuidString()`, `@id`, `uuidv4` on create | `UuidString @id @onCreate(uuid())` | `field.column(type.UuidString()).id().onCreate(gen.uuid())` |
| `id.uuidv7String()` | as above, `uuidv7` | `UuidString @id @onCreate(uuid(7))` | `field.column(type.UuidString()).id().onCreate(gen.uuid(7))` |
| `id.ulid()` | `{ length: 26 }`, `@id`, `ulid` | `Ulid @id @onCreate(ulid())` | `field.column(type.Ulid()).id().onCreate(gen.ulid())` |
| `id.nanoid()` | `{ length: 21 }`, `@id`, `nanoid` | `Nanoid @id @onCreate(nanoid())` | `field.column(type.Nanoid()).id().onCreate(gen.nanoid())` |
| `id.nanoid({ size: n })` | `{ length: n }`, `@id`, `nanoid` of size `n` | `Char(n) @id @onCreate(nanoid(n))` | `field.column(type.Char(n)).id().onCreate(gen.nanoid(n))` |
| `id.cuid2()` | `{ length: 24 }`, `@id`, `cuid2` | `Cuid2 @id @onCreate(cuid(2))` | `field.column(type.Cuid2()).id().onCreate(gen.cuid(2))` |
| `id.ksuid()` | `{ length: 27 }`, `@id`, `ksuid` | `Ksuid @id @onCreate(ksuid())` | `field.column(type.Ksuid()).id().onCreate(gen.ksuid())` |
A foreign key that holds one of these identifiers uses the alias alone, as in `authorId Ulid`, because its value is copied from the row it points at.
A model, composite type or type alias your schema declares with an alias's name, such as `model Ulid`, hides the top-level alias: `Ulid` then names your declaration. Write `sql.Ulid` in that schema instead; it names the same column.
To write a generated id once, declare a mixin and include it with `+` in each model:
```prisma
model mixin UlidId {
id Ulid @id @onCreate(ulid())
}
model Post {
+UlidId
authorId Ulid
author User @relation(fields: [authorId], references: [id])
}
```
In TypeScript, declare the field once and use it in each model. Declare it inside the `defineContract` callback, or with the `field`, `type` and `gen` that `@prisma/orm-postgres/contract-builder` exports:
```ts
const UlidId = field.column(type.Ulid()).id().onCreate(gen.ulid());
```
On Postgres, the native `Uuid` column stores a UUID in 16 bytes and is a better choice for a new schema, written `Uuid @id @onCreate(uuid(7))`. Moving an existing column from `UuidString` to `Uuid` changes the contract and needs a migration, so it is not part of this upgrade.
A TypeScript `id` preset's trailing `{ name }`, as in `field.id.uuidv4String({ name: 'user_pk' })`, moves to `.id({ name: 'user_pk' })`.
#### Postgres presets
| Preset | Stores | PSL replacement | TypeScript replacement |
|---|---|---|---|
| `text()` | `pg/text@1` | `String` | `field.column(type.String())` |
| `int()` | `pg/int4@1` | `Int` | `field.column(type.Int())` |
| `bigint()` | `pg/int8@1` | `BigInt` | `field.column(type.BigInt())` |
| `float()` | `pg/float8@1` | `Float` | `field.column(type.Float())` |
| `decimal()` | `pg/numeric@1`, no parameters | `Decimal` | `field.column(type.Decimal())` |
| `boolean()` | `pg/bool@1` | `Boolean` | `field.column(type.Boolean())` |
| `json()` | `pg/jsonb@1` | `Jsonb` (not `Json`, which is `pg/json@1`) | `field.column(type.Jsonb())` |
| `bytes()` | `pg/bytea@1` | `Bytes` | `field.column(type.Bytes())` |
| `dateTime()` | `pg/timestamptz-temporal@1` | `DateTime` | `field.column(type.DateTime())` |
| `uuidNative()` | `pg/uuid@1` | `Uuid` | `field.column(type.Uuid())` |
| `id.uuidv4Native()` | `pg/uuid@1`, `@id`, `uuidv4` | `Uuid @id @onCreate(uuid())` | `field.column(type.Uuid()).id().onCreate(gen.uuid())` |
| `id.uuidv7Native()` | `pg/uuid@1`, `@id`, `uuidv7` | `Uuid @id @onCreate(uuid(7))` | `field.column(type.Uuid()).id().onCreate(gen.uuid(7))` |
| `temporal.createdAt()` | `pg/timestamptz-temporal@1`, now on create | `temporal.CreatedAt` | `field.column(type.temporal.CreatedAt())` |
| `temporal.updatedAt()` | the same, on create and update | `temporal.UpdatedAt` | `field.column(type.temporal.UpdatedAt())` |
| `temporal.createdAtString()`, `temporal.updatedAtString()` | `pg/timestamptz-string@1` | `temporal.CreatedAtString`, `temporal.UpdatedAtString` | `field.column(type.temporal.CreatedAtString())`, `field.column(type.temporal.UpdatedAtString())` |
| `temporal.createdAtJsDate()`, `temporal.updatedAtJsDate()` | `pg/timestamptz-date@1` | `temporal.CreatedAtJsDate`, `temporal.UpdatedAtJsDate` | `field.column(type.temporal.CreatedAtJsDate())`, `field.column(type.temporal.UpdatedAtJsDate())` |
| `temporal.timestamp(p, onCreate: now, onUpdate: now)` | `pg/timestamp-temporal@1`, `{ precision: p }` | `TimestampTemporal(p) @onCreate(now()) @onUpdate(now())` | `field.column(type.TimestampTemporal(p)).onCreate(gen.now()).onUpdate(gen.now())` |
| `temporal.timestamptz(...)` | `pg/timestamptz-temporal@1` | `TimestamptzTemporal(p)` and the attributes | `field.column(type.TimestamptzTemporal(p))` and the calls |
| `temporal.timestampString(...)` | `pg/timestamp-string@1` | `TimestampString(p)` and the attributes | `field.column(type.TimestampString(p))` and the calls |
| `temporal.timestamptzString(...)` | `pg/timestamptz-string@1` | `TimestamptzString(p)` and the attributes | `field.column(type.TimestamptzString(p))` and the calls |
| `temporal.timestamptzJsDate(...)` | `pg/timestamptz-date@1` | `TimestamptzJsDate(p)` and the attributes | `field.column(type.TimestamptzJsDate(p))` and the calls |
| `temporal.date(onCreate: now, onUpdate: now)` | `pg/date-temporal@1` | `Date` and the attributes | `field.column(type.Date())` and the calls |
| `temporal.dateString(...)` | `pg/date-string@1` | `DateString` and the attributes | `field.column(type.DateString())` and the calls |
| `temporal.time(p, ...)` | `pg/time-temporal@1` | `Time(p)` and the attributes | `field.column(type.Time(p))` and the calls |
| `temporal.timeString(p, ...)` | `pg/time-string@1` | `TimeString(p)` and the attributes | `field.column(type.TimeString(p))` and the calls |
| `temporal.timetz(p, ...)` | `pg/timetz@1` | `Timetz(p)` and the attributes | `field.column(type.Timetz(p))` and the calls |
The `temporal.<type>(...)` presets take an optional precision `p` (the first argument, or `precision: p`) and the phases `onCreate: now` and `onUpdate: now`. Write the precision as the constructor's argument, and leave it out when the preset left it out: `temporal.timestamptz()` is `TimestamptzTemporal`. Write each phase the preset set as its attribute, `@onCreate(now())` or `@onUpdate(now())`, and leave out a phase it did not set: `temporal.time(3, onUpdate: now)` is `Time(3) @onUpdate(now())`. A preset with no phase is the bare constructor.
In TypeScript the precision and the phases are positional, and a phase is `'now'` or `undefined`: `field.temporal.timestamp(3, 'now', 'now')` is `field.column(type.TimestampTemporal(3)).onCreate(gen.now()).onUpdate(gen.now())`, `field.temporal.time(3, undefined, 'now')` is `field.column(type.Time(3)).onUpdate(gen.now())`, and `field.temporal.date('now')` is `field.column(type.Date()).onCreate(gen.now())`.
Attributes and calls written after the preset stay after the replacement: `temporal.updatedAt() @map("updated_at")` is `temporal.UpdatedAt @map("updated_at")`, `field.temporal.createdAt().optional()` is `field.column(type.temporal.CreatedAt()).optional()`, and `temporal.timestamptz(onUpdate: now) @onCreate(now())` is `TimestamptzTemporal @onCreate(now()) @onUpdate(now())`.
#### SQLite presets
| Preset | Stores | PSL replacement | TypeScript replacement |
|---|---|---|---|
| `temporal.createdAt()`, `temporal.updatedAt()` | `sqlite/datetime@1`, now on create, and on update for `updatedAt` | `temporal.CreatedAt`, `temporal.UpdatedAt` | `field.column(type.temporal.CreatedAt())`, `field.column(type.temporal.UpdatedAt())` |
| `temporal.datetime(onCreate: now, onUpdate: now)` | `sqlite/datetime@1` | `DateTime @onCreate(now()) @onUpdate(now())` | `field.column(type.DateTime()).onCreate(gen.now()).onUpdate(gen.now())` |
The SQL family presets in the first table also existed on SQLite. Their replacements use the `sql.` aliases, such as `sql.Ulid` and `type.sql.Ulid()`, and `sql.Char(n)` and `type.sql.Char(n)` for a nanoid of another size.
#### MongoDB presets
| Preset | Stores | PSL replacement | TypeScript replacement |
|---|---|---|---|
| `temporal.createdAt()` | `mongo/date@1`, now on create | `temporal.CreatedAt` | `field.date().onCreate(gen.now())` |
| `temporal.updatedAt()` | the same, on create and update | `temporal.UpdatedAt` | `field.date().onCreate(gen.now()).onUpdate(gen.now())` |
| `temporal.timestamp(onCreate: now, onUpdate: now)` | `mongo/date@1` | `Date @onCreate(now()) @onUpdate(now())` | `field.date().onCreate(gen.now()).onUpdate(gen.now())` |
The MongoDB TypeScript builder has no `type` helpers, so its replacements use `field.date()`.
#### `field.generated(...)`, in the SQL TypeScript builder
| Before | After |
|---|---|
| `field.generated(uuidv4())` | `field.column(type.UuidString()).onCreate(gen.uuid())` |
| `field.generated(uuidv7())` | `field.column(type.UuidString()).onCreate(gen.uuid(7))` |
| `field.generated(ulid())` | `field.column(type.Ulid()).onCreate(gen.ulid())` |
| `field.generated(nanoid())` | `field.column(type.Nanoid()).onCreate(gen.nanoid())` |
| `field.generated(nanoid({ size: n }))` | `field.column(type.Char(n)).onCreate(gen.nanoid(n))` |
| `field.generated(cuid2())` | `field.column(type.Cuid2()).onCreate(gen.cuid(2))` |
| `field.generated(ksuid())` | `field.column(type.Ksuid()).onCreate(gen.ksuid())` |
On SQLite write the `sql.` forms, such as `type.sql.Ulid()` and `type.sql.Char(n)`. A `.id()` after `field.generated(...)` stays `.id()` on the replacement.
### `psl-field-presets-removed`
Rewrite every preset call in your Prisma 8 schema files as the tables say. `prisma contract emit` refuses each one with `PSL_UNRESOLVED_REFERENCE` and the exact rewrite, one diagnostic per field:
```text
PSL_UNRESOLVED_REFERENCE: id.uuidv4String() was a field preset, and Prisma 8 removed field presets. Write UuidString @id @onCreate(uuid()).
PSL_UNRESOLVED_REFERENCE: temporal.time(3, onUpdate: now) was a field preset, and Prisma 8 removed field presets. Write Time(3) @onUpdate(now()).
```
The message writes the alias as the schema's target names it: `UuidString` on Postgres and `sql.UuidString` on SQLite. On Postgres you may write `sql.UuidString` too; both store the same column. If a preset was called with an argument it did not take, the message gives the type and tells you which attributes to add.
Run `prisma contract emit`. It writes the same `contract.json` and `contract.d.ts`, with the same hashes.
### `ts-field-presets-removed`
Rewrite every preset call in your TypeScript contract and query fragments as the tables say.
In a `defineContract` callback, take `type` and `gen` from the callback's argument where the rewritten fields use them. Add each missing name at the end of the destructuring, `gen` before `type`: `({ field, model })` becomes `({ field, model, gen, type })`, and `({ field, model, type })` becomes `({ field, model, type, gen })`. If you rename the callback's `field`, as in `({ field: f })`, a preset call is written `f.id.uuidv4String()`; the detection does not see it, so search for it yourself.
On Postgres, use the constructors in the Postgres table and the top-level aliases, such as `type.Ulid()`, for the SQL family presets. On SQLite, use the `sql.` aliases, such as `type.sql.Ulid()`. Code that does not know its target can write `type.sql.Ulid()` and `type.sql.Char(n)`, which every SQL target has.
If the file already has a binding named `type`, such as `import { type } from 'arktype'`, the callback's `type` hides it inside the callback. Rename one of them, as in `({ field, model, gen, type: t })`, if the callback uses both.
A contract that calls the `field` imported from a facade's `contract-builder` outside a callback needs `type` too. `@prisma/orm-postgres/contract-builder` exports it. The other facades do not, so move the models into the `defineContract` callback, `defineContract({}, ({ field, model, gen, type }) => ({ models: { ... } }))`, and remove `field` from the import.
A query fragment that declares its fields with the `field` exported by `@prisma/orm-postgres/contract-builder` imports `type` from the same entry:
```ts
// Before
import { field } from '@prisma/orm-postgres/contract-builder';
export const createdSince = (since: Temporal.Instant) =>
db.orm.fragment({ createdAt: field.temporal.timestamptz() }, (rows) =>
rows.where((row) => row.createdAt.gte(since)),
);
// After
import { field, type } from '@prisma/orm-postgres/contract-builder';
export const createdSince = (since: Temporal.Instant) =>
db.orm.fragment({ createdAt: field.column(type.TimestamptzTemporal()) }, (rows) =>
rows.where((row) => row.createdAt.gte(since)),
);
```
A fragment field matches a column by its codec and nullability, so the replacement matches the same models the preset did.
In MongoDB contracts, `field.temporal.*` is the only preset. Take `gen` from the callback and write `field.date()` with `.onCreate(gen.now())` and `.onUpdate(gen.now())`. Leave `field.json()`, `field.date()`, `field.string()` and the other MongoDB field helpers alone; they are not presets.
Then type-check the project and run `prisma contract emit`. The contract does not change.
### `ts-field-generated-removed`
Replace each `field.generated(...)` as the table says, and remove `uuidv4`, `uuidv7`, `ulid`, `nanoid`, `cuid2` and `ksuid` from your imports of `@prisma/orm-framework/ids`. Remove the import if nothing is left in it. Take `gen` and `type` from the callback, as for `ts-field-presets-removed`. The contract does not change.
### `postgres-contract-builder-exports-type`
There is nothing to change. `@prisma/orm-postgres/contract-builder` exports `type` beside `field` and `gen`. Use it to declare the fields of a query fragment, as shown above.
### `sqlite-sql-char`
There is nothing to change. `sql.Char(n)` in PSL and `type.sql.Char(n)` in TypeScript are new on every SQL target and store `sql/char@1` with `{ length: n }`. A SQLite contract emitted from a nanoid preset of another size already stores this column, so rewriting the preset as `sql.Char(n)` keeps the contract.
### `identifier-type-aliases`
There is nothing to change. The identifier aliases are new: `sql.UuidString`, `sql.Ulid`, `sql.Nanoid`, `sql.Cuid2` and `sql.Ksuid` on every SQL target, and `UuidString`, `Ulid`, `Nanoid`, `Cuid2` and `Ksuid` on Postgres. Each stores `sql/char@1` with the length of its format, so `Ulid` and `Char(26)` store the same column with the same hashes. A schema that already writes `Char(26)` can keep it or switch to `Ulid`.
## A Prisma 7 `@default(now())` is written by the client on create
### `prisma7-schema-now-default-writes-on-create`
Prisma 7's client filled a `@default(now())` field itself on create, with the current time in UTC, and also wrote `now()` as the column default for inserts from outside Prisma. A contract from `prisma7Schema(...)` used to keep only the column default, so a create through Prisma 8 got the database's clock. On a `timestamp` column, that is the wall-clock time of the session's time zone, which differs from what Prisma 7 wrote unless the session runs in UTC.
The contract now keeps the column default and adds the `now` generator on create, chosen for the column's type:
```prisma
// Prisma 7 schema, read by prisma7Schema('prisma/schema.prisma')
model Post {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
}
```
reads as the Prisma 8 PSL field `createdAt TimestampString(3) @default(now()) @onCreate(now())`. A create that supplies `createdAt` still stores the supplied value. An `INSERT` from outside Prisma still gets the column default.
For each project whose `prisma.config.ts` uses `prisma7Schema(...)`, run `prisma contract emit` again and commit the new `contract.json` and `contract.d.ts`. If the schema has a `@default(now())` field, the generator's id is in the execution section of `contract.json`, so the execution hash changes. The storage hash does not change, and the database marker holds only the storage and profile hashes, so there is no migration to plan and no need to run `prisma db sign`.
If the schema has no `@default(now())` field, the contract does not change and there is nothing to do.
## `contract-artifacts-restamp`
For every `contract.json` matched by `detection`, run the project's emit command (`prisma contract emit`, or the project's `contract:emit` script) once after upgrading. This entry accounts for the extension's embedded `version` moving to `8.0.0-rc.21`; any other difference in the emitted files comes from an earlier entry in this guide.
upgrading/app/upgrades/8.0.0-rc.21-to-8.0.0-rc.22/instructions.md
---
from: "8.0.0-rc.21"
to: "8.0.0-rc.22"
changes:
- id: one-orm-version
summary: |
Every `@prisma/orm-*` package and the `prisma` CLI must run one version of Prisma ORM. In 8.0.0-rc.22, commands and clients stop with `RUNTIME.MULTIPLE_ORM_COPIES` when more than one copy is installed; later releases remove that check, but a second copy still breaks values that cross between copies.
detection:
glob: "package.json"
matches:
- '"@prisma/orm-'
- id: value-objects-in-storage
summary: |
A contract keeps its value objects in storage, at `storage.namespaces.<ns>.entries.valueObject`, and a field holding one names it by its storage coordinate. On SQL, a discriminator column names a value set, `<Base>.<field>`, of its variants' values. A `contract.json` that keeps `valueObjects` in the domain plane is refused with `CONTRACT.VALIDATION_FAILED`, saying to re-emit it. Run the colocated script on the project: it rewrites every contract and snapshot, renames the snapshot directories to their new storage hashes, and rewrites the migrations, refs, `migration.ts` files and `contract.d.ts` files that name them. Then sign each database and re-emit the contract.
detection:
glob: "**/*.json"
matches:
- '"valueObjects"\s*:'
script: ./scripts/value-objects-in-storage/value-objects-in-storage.ts
- id: value-object-types-from-storage
summary: |
`ValueObjectTypesFromDomain`, exported by `@prisma/orm-mongo/contract/types`, is now `ValueObjectTypesFromStorage` and takes the contract's storage namespaces. A committed `contract.d.ts` that imports it compiles again once it is re-emitted. Code of your own that imports it changes the name and passes `Contract['storage']['namespaces']`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bValueObjectTypesFromDomain\b'
- id: postgres-value-object-checks
summary: |
On PostgreSQL, the contract derives a check for every value object column and every discriminator column of a managed table. The next `prisma migration plan` adds them, and `prisma db migrate` fails on a row that does not fit, including a row whose value object has a key the value object does not declare, which the SQL ORM never refused. Find such rows before you apply the migration.
- id: discriminator-typed-by-variant-values
summary: |
A SQL discriminator field is typed by its variants' values, such as `'bug' | 'feature'`, instead of `string`. Code that assigns such a field to a `string` still compiles; a type assertion that expects `string` changes to the union.
- id: psl-date-time-types-are-convenience-types
summary: |
In a Prisma 8 schema on PostgreSQL, `DateTime`, `Date` and `Time` are now convenience types that choose an explicit type from their arguments, and their defaults read different values: `DateTime` is a `timestamptz(3)` column read as a JavaScript `Date`, and `Date` and `Time` are read as strings. To keep what a field reads today, and its contract, rename `DateTime` to `TimestamptzTemporal`, `Date` to `DateTemporal` and `Time` to `TimeTemporal`, keeping the arguments: `Time(3)` becomes `TimeTemporal(3)`. A Prisma 7 schema, which has a `datasource` block, and SQLite and MongoDB schemas are not affected.
detection:
glob: "**/*.prisma"
matches:
- '(?<![\s\S])(?![\s\S]*(?<![\w$])datasource[ \t]+\w+[ \t]*\{)[\s\S]*\n[ \t]+[A-Za-z_]\w*[ \t]+(?:=[ \t]*)?(?:DateTime|Date|Time)(?![\w$])'
- id: ts-date-time-helpers-are-convenience-types
summary: |
In a TypeScript contract on PostgreSQL, `type.DateTime(...)`, `type.Date(...)` and `type.Time(...)` are now the convenience types, whose arguments are one object with the keys `as`, `storage` and `precision`, so `type.Time(3)` no longer compiles. To keep what a field reads today, call `type.TimestamptzTemporal(...)`, `type.DateTemporal()` and `type.TimeTemporal(...)` with the same arguments.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$])type\.(?:DateTime|Date|Time)\('
- id: prisma7-schema-reads-dates
summary: |
A contract from `prisma7Schema(...)` on PostgreSQL now reads a Prisma 7 `DateTime` field as a JavaScript `Date`, as Prisma 7 did, instead of a string: a plain `DateTime` is `TimestampJsDate(3)` and a `@db.Timestamptz(p)` field is `TimestamptzJsDate(p)`. The columns do not change, but the contract's codecs and storage hash do. Re-emit with `prisma contract emit`, run `prisma db sign`, and change application code that read those fields as strings to read a `Date`.
detection:
glob: "**/prisma.config.{ts,mts,cts,js,mjs}"
matches:
- '\bprisma7Schema\s*\('
- id: temporal-type-aliases-on-datetime
summary: |
On PostgreSQL, `temporal.CreatedAt` and `temporal.UpdatedAt` are now built on `DateTime`, a `timestamptz(3)` column read as a JavaScript `Date`, instead of a `timestamptz` column read as a `Temporal.Instant`, and the `temporal.CreatedAtString`, `temporal.UpdatedAtString`, `temporal.CreatedAtJsDate` and `temporal.UpdatedAtJsDate` aliases are removed, in PSL and as `type.temporal.*()` in a TypeScript contract. Write the type and the generators instead, as the table in the body shows, to keep each field and its contract.
detection:
glob: "**/*.{prisma,ts,mts,cts,tsx}"
matches:
- '(?<![\w$])temporal\.(?:Created|Updated)At(?:String|JsDate)?(?![\w$])'
- id: date-default-below-a-millisecond
summary: |
On PostgreSQL and MySQL, a column read as a JavaScript `Date` (`DateTime` at its default, `TimestamptzJsDate`, `TimestampJsDate`, MySQL's `DateTime`) now refuses a `@default` with digits below a millisecond, which a `Date` cannot hold, and `prisma contract emit` reports it. Keep the default by changing the field to `DateTime(as: string)` or `DateTime(as: temporal)`, or by writing the default as a `sql` expression.
detection:
glob: "**/*.prisma"
matches:
- '@default\((?:timestamp(?:tz)?|datetime)`[^`]*\.\d{3}\d*[1-9]'
- id: date-now-takes-column-precision
summary: |
On PostgreSQL, the `now` generator of a timestamp column read as a JavaScript `Date` (`DateTime`, `TimestamptzJsDate`, `TimestampJsDate`, `temporal.CreatedAt` and `temporal.UpdatedAt`) or as a `Temporal` value (`DateTime(as: temporal)`, `TimestamptzTemporal`, `TimestampTemporal` and `TimestampInstant`) now takes the column's precision and rounds the time down to it, so the value the client holds is the value PostgreSQL stores. A `timestamp(0)` column no longer gets a time PostgreSQL rounds up to the next second. The generator's precision is in the contract's execution section, so re-emit with `prisma contract emit` and commit `contract.json` and `contract.d.ts`; only the execution hash changes, so there is no migration and no `prisma db sign`.
detection:
glob: "**/*.{prisma,ts,mts,cts,tsx}"
matches:
- '\bprisma7Schema\s*\('
- '@on(?:Create|Update)\(now\(\)\)'
- '(?<![\w$])temporal\.(?:Created|Updated)At(?![\w$])'
- '(?<![\w$])temporal\.timestamp(?:tz(?:JsDate)?)?\s*\('
- '(?<![\w$])gen\.now\(\)'
- id: contract-emit-pins-pack-files
summary: |
`prisma contract emit` now writes each composed extension pack's files under `migrations/<space-id>/` (the pack's head ref, its contract snapshot and its migrations), as `migration plan` already did. Re-run `prisma contract emit` and commit what it writes under `migrations/`. A project that composes a pack and never ran `migration plan` gains those files; `prisma db sign` needs them.
detection:
glob: "**/prisma.config.{ts,mts,cts,js,mjs}"
matches:
- 'extensions\s*:\s*\[\s*[^\]\s]'
- id: packs-declare-their-postgres-extension
summary: |
The pgvector, PostGIS and ParadeDB packs now declare their Postgres extension (`vector`, `postgis`, `pg_search`) in their contract, and their head ref no longer requires the `pgvector:install-vector-v1`, `postgis:install-postgis-v1` or `paradedb:install-pg-search-v1` invariant. After `prisma contract emit` moves `migrations/<space-id>/` to the new head, run `prisma db migrate` on each database: one migration moves the pack's space from its old head and creates the extension only if it is missing. A database that has the extension and no pack marker can instead be adopted with `prisma db sign`.
detection:
glob: "**/migrations/*/refs/head.json"
matches:
- '(?:pgvector:install-vector|postgis:install-postgis|paradedb:install-pg-search)-v1'
- id: mongo-merged-redundant-indexes
summary: |
Every MongoDB contract source now merges identical and redundant indexes in a collection. Identical indexes, which TypeScript and PSL refused before (`CONTRACT.NAME_DUPLICATE`, `PSL_INVALID_INDEX`), become one index. So do two indexes that differ only in an option set to `false`, such as `{ unique: false }` beside the same index without it. A unique and a plain index on the same keys, such as `@unique` and `@@index` on one field, become one unique index. Before, a TypeScript or PSL contract held both indexes of such a pair, and the migration that created them failed. No source change is needed. Run `prisma contract emit`; a contract that held such a pair gets a new storage hash. Delete a migration planned earlier that creates both indexes, and plan it again.
detection:
glob: "**/*.{ts,mts,cts,tsx,prisma}"
matches:
- '(?<![\s\S])(?=[\s\S]*(?:\bObjectId\b|"mongodb"))[\s\S]*\n\s*(\w+)\s[^\n]*@unique\b(?!\()[\s\S]*@@index\(\[\1\]\)'
- '(?<![\s\S])(?=[\s\S]*(?:\bObjectId\b|"mongodb"))[\s\S]*@@unique\((\[[^\]]*\])\)[\s\S]*@@index\(\1\)'
- '(?<![\s\S])(?=[\s\S]*(?:\bObjectId\b|"mongodb"))[\s\S]*@@index\((\[[^\]]*\])\)[\s\S]*@@unique\(\1\)'
- '@prisma/orm-(?:mongo|family-mongo/contract-ts)/contract-builder[\x27"][\s\S]*\bindex\(\s*(\{[^}]*\})\s*,\s*\{\s*unique\s*:\s*true\s*\}\s*\)[\s\S]*\bindex\(\s*\1\s*\)'
- '@prisma/orm-(?:mongo|family-mongo/contract-ts)/contract-builder[\x27"][\s\S]*\bindex\(\s*(\{[^}]*\})\s*\)[\s\S]*\bindex\(\s*\1\s*,\s*\{\s*unique\s*:\s*true\s*\}\s*\)'
- id: mongo-driver-from-runtime-descriptor
summary: |
`createMongoDriver` and `MongoDriverImpl` are no longer exported. `@prisma/orm-mongo/driver` and `@prisma/orm-target-mongo/driver` now export what `driver/control` and `driver/runtime` export, as the Postgres packages do. Create a runtime driver from the descriptor that `@prisma/orm-mongo/driver/runtime` exports by default, then connect it. The detection matches files that name `createMongoDriver` or `MongoDriverImpl`.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx}"
matches:
- '(?<![\w$])(?:createMongoDriver|MongoDriverImpl)(?![\w$])'
- id: mongo-query-takes-context
summary: |
`mongoQuery({ contract })` from `@prisma/orm-mongo/query-builder` no longer compiles: `mongoQuery` takes `{ context }`, an execution context. Replace `mongoQuery({ contract })` with `mongoStatic({ contract }).query` from `@prisma/orm-mongo/static`, or with `db.query` when you have a client from `mongo({ contract })`. Both validate the contract once. The detection matches files that call `mongoQuery` with a `contract` option, import it under another name, or call it through a namespace import.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx}"
matches:
- '(?<![\w$.])mongoQuery\s*(?:<[^>()]*>)?\s*\(\s*\{\s*contract\b'
- '(?<![\w$.])mongoQuery\s+as\s+[\w$]+'
- '(?<![\s\S])(?=[\s\S]*\bimport\s*\*\s*as\s+([\w$]+)\s+from\s*[\x27\x22](?:@prisma/orm-(?:family-)?mongo/query-builder|@internal/mongo-query-builder)[\x27\x22])[\s\S]*(?<![\w$.])\1\s*\.\s*mongoQuery\s*\('
- id: mongo-orm-takes-context
summary: |
`mongoOrm()` takes `{ context, runtime }`, as the SQL `orm({ context, runtime })` does: pass the execution context instead of the contract, rename `executor` to `runtime`, and drop `enums` and `mutationDefaults`, which the ORM now reads from the context. `mongoRaw()` takes `{ context }` instead of `{ contract }`. `createMongoCollection()` takes `({ context, runtime }, modelName)`. The `MongoOrmEnums` type and the error code `ORM.MUTATION_DEFAULTS_MISSING` are gone. Use `db.orm` and `db.raw` from `mongo({ contract })`, or `mongoStatic({ contract }).raw`. The detection matches files that call `mongoOrm` or `mongoRaw` with a `contract` option, call `mongoOrm` with `executor` or `enums`, call `createMongoCollection` without a context object, pass `mutationDefaults`, or name `MongoOrmEnums` or `ORM.MUTATION_DEFAULTS_MISSING`.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx}"
matches:
- '(?<![\w$.])mongo(?:Orm|Raw)\s*(?:<[^>()]*>)?\s*\(\s*\{\s*contract\b'
- '(?<![\w$.])mongoOrm\s*(?:<[^>()]*>)?\s*\(\s*\{(?:[^{}]|\{[^{}]*\})*\b(?:executor|enums)\b'
- '(?<![\w$.])createMongoCollection\s*(?:<[^>()]*>)?\s*\(\s*(?!\{)'
- '(?<![\w$])mutationDefaults\s*:'
- '(?<![\w$])MongoOrmEnums(?![\w$])'
- 'MUTATION_DEFAULTS_MISSING'
- id: mongo-runtime-takes-adapter
summary: |
`createMongoRuntime()` takes `{ context, adapter, driver }`, as the SQL runtime does: create the adapter with `stack.adapter.create(stack)` and pass it beside the context. The runtime reads the target from `context.contract.target`, so build the context over the validated contract. `MongoExecutionContext` no longer has a `stack` member or a second type parameter, and its contract must be a MongoDB contract, as SQL's `ExecutionContext` requires a SQL contract: write `MongoExecutionContext<Contract>`, and build it with `createMongoExecutionContext({ contract, stack })` over a validated contract. `createMongoExecutionStack()` still takes an optional `driver`; given one, it returns `MongoExecutionStackWithDriver`, whose `driver` is always set, and without one it returns `MongoExecutionStack`, which has no `driver` member, as for SQL. `buildMongoEnums()` is removed: read the contract's enum accessors from `db.enums`, from `mongo({ contract })` or `mongoStatic({ contract })`. `MongoExecutionContext` has no `enums` member. The detection matches files that call `createMongoRuntime(`, read `.stack` from a context, give `MongoExecutionContext` two type arguments or a non-contract type argument, or name `buildMongoEnums` or `MongoEnumAccessors`.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx}"
matches:
- '(?<![\w$.])createMongoRuntime\s*\('
- '(?<![\w$])[\w$]*[cC]ontext\s*\.\s*stack(?![\w$])'
- '(?<![\w$])MongoExecutionContext\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*,'
- '(?<![\w$])MongoExecutionContext\s*<\s*(?:unknown|\{)'
- '(?<![\w$.])(?:buildMongoEnums|MongoEnumAccessors)(?![\w$])'
- id: client-connect-safe-to-call-again
summary: |
`connect()` without options on a `postgres()`, `sqlite()`, `mysql()` or `mongo()` client no longer rejects when the client is connecting or connected. It waits for the connection and resolves with the runtime. `connect(options)` on such a client still rejects with `DRIVER.ALREADY_CONNECTED`, but its message is now `<Target> client is already bound` (for example `Postgres client is already bound`), it has a `why`, and its `fix` changed. Remove code that catches `DRIVER.ALREADY_CONNECTED` from a `connect()` without options, or that avoids calling `connect()` after a query: neither is needed. Update code or tests that match the old message `<Target> client already connected` or the old `fix` "Call connect() at most once per client.". A `connect()` that is waiting when `close()` is called now rejects with `DRIVER.NOT_CONNECTED`.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs}"
matches:
- 'DRIVER\.ALREADY_CONNECTED'
- '(?:Postgres|SQLite|MySQL|Mongo) client already connected'
- 'Call connect\(\) at most once per client'
- id: contract-artifacts-restamp
summary: |
An extension that writes its own package version into the contracts it emits, such as the Supabase extension, now writes 8.0.0-rc.22. Run `contract emit` once after upgrading so the emitted `contract.json` and `contract.d.ts` match the installed extension.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.21"'
---
# 8.0.0-rc.21 → 8.0.0-rc.22 — User upgrade instructions
## Order of the steps
Do the steps in this order, and skip a step that does not apply to the project:
1. Upgrade the Prisma packages, and check that one copy of Prisma ORM is installed (`one-orm-version`).
2. If a `contract.json` keeps `valueObjects` in its domain plane, run the colocated script and sign each database before anything re-emits the contract (`value-objects-in-storage`, steps up to and including "Sign each database").
3. Change a PostgreSQL contract source. First rename every `DateTime`, `Date` and `Time` field type, and `type.DateTime(...)`, `type.Date(...)` and `type.Time(...)`, to the explicit type it stood for (`psl-date-time-types-are-convenience-types`, `ts-date-time-helpers-are-convenience-types`). Then rewrite each `temporal.CreatedAt`, `temporal.UpdatedAt` and removed `temporal.*` alias from its table (`temporal-type-aliases-on-datetime`). Do the renames first: the alias table writes `DateTime(as: ...)`, which is already the new convenience type and must not be renamed again. Then fix a default the new `Date` fields refuse (`date-default-below-a-millisecond`).
4. Change MongoDB application code: create the driver from its descriptor (`mongo-driver-from-runtime-descriptor`), and pass an execution context to the query builder, raw lane, ORM and runtime (`mongo-query-takes-context`, `mongo-orm-takes-context`, `mongo-runtime-takes-adapter`).
5. Run `prisma contract emit` and commit what it writes, including the files under `migrations/` (this also covers `contract-emit-pins-pack-files`, `date-now-takes-column-precision`, `mongo-merged-redundant-indexes` and `contract-artifacts-restamp`).
6. Bring each database up to date: run `prisma db migrate` for a pgvector, PostGIS or ParadeDB pack (`packs-declare-their-postgres-extension`); run `prisma db sign` for a project that reads a Prisma 7 schema (`prisma7-schema-reads-dates`); on MongoDB, replace a planned migration that creates two indexes now merged (`mongo-merged-redundant-indexes`); on PostgreSQL, plan and apply the value object checks (`postgres-value-object-checks`).
7. Change the remaining application code: read a Prisma 7 `DateTime` field as a `Date` (`prisma7-schema-reads-dates`), drop handlers for a repeated `connect()` (`client-connect-safe-to-call-again`), and update value object and discriminator types (`value-object-types-from-storage`, `discriminator-typed-by-variant-values`).
## One version of Prisma ORM
In 8.0.0-rc.22, commands and clients refuse to start when Prisma ORM is loaded from more than one folder. Later releases remove that check, because a host that loads the ORM in two module instances cannot satisfy it. A project with mismatched versions can still fail with an internal error, for example on the first literal `@default` in `prisma contract emit`.
After upgrading, check that one copy is installed:
1. Every `@prisma/orm-*` entry in `package.json` other than `@prisma/orm-extension-*` names the same version. Extensions carry their own version; each one must depend on that same version of Prisma ORM.
2. The `prisma` CLI runs that version. `npm ls @prisma/orm-framework`, `pnpm why @prisma/orm-framework` or `bun why @prisma/orm-framework` lists every copy and the package that brought it.
If more than one version is listed, align the versions and reinstall. If every copy is the same version, delete `node_modules` and the lockfile and reinstall. With pnpm, copies of one version can also differ by their peer dependencies: `pnpm why @prisma/orm-framework` shows the difference, and giving every dependent the same peer leaves one copy.
## Value objects are storage entities
### `value-objects-in-storage`
Before you upgrade, apply every pending migration to each database (development, staging, production) with the release you are upgrading from, so each database is at the last migration. Then commit your work, so the script's changes can be reviewed and undone with git. Then upgrade the Prisma packages to this release. Run the script before you re-emit the contract: `prisma db sign` must see the contract the database was created from before a re-emit adds the new checks. Run it from the project root:
```sh
node <path-to-this-guide>/scripts/value-objects-in-storage/value-objects-in-storage.ts
```
Node 24 or later runs the TypeScript script directly; it needs no `tsx`. It reads and writes files only and needs no database. It rewrites every `*.json` file under the root that parses as a SQL or MongoDB contract keeping value objects in the domain plane, skipping `node_modules`, `dist`, `build` and directories whose name starts with a dot. In each contract it:
- moves each value object from `domain.namespaces.<ns>.valueObjects` to `storage.namespaces.<ns>.entries.valueObject`;
- writes each field that holds a value object as `{ kind: 'valueObject', plane: 'storage', namespaceId, entityKind: 'valueObject', entityName }` instead of `{ kind: 'valueObject', name }`;
- points a value object member typed by an enum at the enum's storage value set (`plane: 'storage'`, `entityKind: 'valueSet'`);
- on SQL, writes the value set of each discriminator, keyed `<Base>.<field>` in the base table's namespace and holding the variants' values, sorted, and points the discriminator column at it. A column an enum types keeps the enum's value set.
Storage now holds the value objects, so each contract it rewrites has a new storage hash. The script renames each snapshot directory to the new hash and rewrites every `migration.json` (with its `migrationHash`), ref, `migration.ts` and `contract.d.ts` that names an old one. It does not add the PostgreSQL checks described in `postgres-value-object-checks`: a snapshot records what its migrations created, and none created those checks.
When it finishes, it prints how many files it rewrote, how many snapshot directories it renamed, and each storage hash it replaced (`<old> -> <new>`). A project already in the new form is left unchanged, and the script says that nothing changed. It changes no file and exits 1 when a file it must read is not valid JSON, when a contract's stored hash does not match its content, when a field holds a value object the contract does not declare, when a member's enum has no storage value set, when a discriminator's value set name is already taken by an enum, when a renamed snapshot directory already exists with different content, or when it cannot read a directory under the root. If it stops on an error partway, for example on a full disk, run it again: it finishes the upgrade.
The script rewrites a snapshot's `contract.d.ts` for the moved value objects. In every `contract.d.ts` it finds, including those of contracts without value objects, it also passes the storage namespaces to `FieldTypesFromDomain`, which now requires them. It does not add the types of the discriminator value sets. Nothing reads them from a snapshot: a `migration.ts` that imports a snapshot's `contract.d.ts` compiles without them, and `prisma contract emit` writes them for the emitted contract.
Then:
1. Sign each database (development, staging, production) with the rewritten contract, before you re-emit: `prisma db sign --db "$DATABASE_URL"`. Its marker still holds the old storage hash, so until it is signed `prisma db migrate` refuses with `MIGRATION.MARKER_MISMATCH` and `prisma db verify` with `CONTRACT.MARKER_MISMATCH`. This works for a database at the last migration. It also points your local `db` ref at the rewritten contract, writing the ref if the project had none; `prisma migration plan` starts from that ref.
2. Re-emit the contract: `prisma contract emit`.
3. On PostgreSQL, plan the migration that adds the new checks with `prisma migration plan`, and apply it to each database with `prisma db migrate --advance-ref db --db "$DATABASE_URL"`, as `postgres-value-object-checks` describes. `--advance-ref db` moves the `db` ref to the new contract. Without it the ref stays at the contract of step 1, and the next `prisma migration plan` starts from there and writes a second migration that adds the same checks. On MongoDB there is nothing to plan: the collection validators do not change.
A database you could not migrate before the upgrade is behind the last migration, and `prisma db sign --db "$DATABASE_URL"` fails on it with `CONTRACT.SCHEMA_VERIFICATION_FAILED`, because it signs against the last contract. Do not follow its advice to run `prisma db update`: that skips the pending migrations. Instead, sign the database at the new hash of the hash its marker holds, then migrate it:
```sh
prisma db sign --contract <new hash> --no-advance-ref --db "$DATABASE_URL"
prisma db migrate --db "$DATABASE_URL"
prisma db verify --strict --db "$DATABASE_URL"
```
Find the new hash in the script's output: the marker holds the `<old>` hash of one `<old> -> <new>` line. `--no-advance-ref` leaves your local `db` ref where it is.
### `value-object-types-from-storage`
```ts
// before
import type { ValueObjectTypesFromDomain } from '@prisma/orm-mongo/contract/types';
type Outputs = ValueObjectTypesFromDomain<Contract['domain']['namespaces'], CodecTypes, never, {}, 'output'>;
// after
import type { ValueObjectTypesFromStorage } from '@prisma/orm-mongo/contract/types';
type Outputs = ValueObjectTypesFromStorage<Contract['storage']['namespaces'], CodecTypes, never, {}, 'output'>;
```
### `postgres-value-object-checks`
For each column that holds a value object, the contract derives a check named `<table>_<column>_shape_<hash>`. It accepts a JSON object with every required member, each of its JSON type, no other key, and for a list column an array of such objects; a nullable column also accepts SQL `NULL` and JSON `null`. For each discriminator column it derives `<table>_<column>_check_<hash>`, which accepts only the variants' values. A table whose control policy is not managed gets neither check.
After you re-emit the contract, `prisma migration plan` writes a migration that adds the checks. It starts from the `db` ref that `prisma db sign` wrote. Its DDL preview prints each check's expression. Before you run `prisma db migrate`, find the rows that would fail by selecting the rows where the expression is not true, for example:
```sql
SELECT id, address FROM "public"."user"
WHERE NOT ("address" IS NULL OR jsonb_path_match("address", 'strict … ') IS TRUE);
SELECT id, type FROM "public"."task" WHERE NOT ("type" IN ('bug', 'feature'));
```
Adding a check takes a lock on the table while Postgres reads every row to check it. Any later change to a value object renames the check, so the check is dropped and added again, and the rows are read again. Apply the migration when the table can wait for that read.
Copy the expression from the preview between `CHECK (` and the closing `)`. Fix or delete each row the query returns, for example by removing the extra keys with `UPDATE "public"."user" SET address = address - 'extra' WHERE address ? 'extra'`, then apply the migration with `prisma db migrate --advance-ref db`. A migration that fails on a row changes nothing, and the error names the check, its table and column, and the query that finds the rows; fix the rows and run `prisma db migrate --advance-ref db` again.
### `discriminator-typed-by-variant-values`
```ts
// before
expectTypeOf(task.type).toEqualTypeOf<string>();
// after
expectTypeOf(task.type).toEqualTypeOf<'bug' | 'feature'>();
```
## `DateTime`, `Date` and `Time` are convenience types on PostgreSQL
On PostgreSQL, Prisma 8 has two layers of date and time types. The explicit types name the column type and the kind of value the application reads, such as `TimestamptzTemporal` or `DateString`. The convenience types `DateTime`, `Date`, `Time` and the new `Duration` are named for what a field means, and each becomes one explicit type, chosen by its optional `as` and `storage` arguments. Their defaults need no `Temporal` polyfill:
| Written | Becomes | Column | The application reads |
| --- | --- | --- | --- |
| `DateTime` | `TimestamptzJsDate(3)` | `timestamptz(3)` | a JavaScript `Date` |
| `Date` | `DateString` | `date` | a string, such as `"2024-02-29"` |
| `Time` | `TimeString` | `time` | a string, such as `"09:30:00"` |
| `Duration` | `IntervalString` | `interval` | an ISO 8601 duration, such as `"P1M"` |
Until this release, `DateTime`, `Date` and `Time` were explicit types that read `Temporal.Instant`, `Temporal.PlainDate` and `Temporal.PlainTime`. A schema that keeps those names now reads other values, and its contract changes.
### `psl-date-time-types-are-convenience-types`
To keep each field as it is, rename its type, keeping its arguments and attributes:
| Before | After |
| --- | --- |
| `DateTime` | `TimestamptzTemporal` |
| `Date` | `DateTemporal` |
| `Time(p?)` | `TimeTemporal(p?)` |
```prisma
model Event {
id Int @id
at TimestamptzTemporal // was DateTime
day DateTemporal // was Date
opensAt TimeTemporal(0) // was Time(0)
}
```
A field written as `DateTime(as: ...)`, `Date(as: ...)` or `Time(as: ...)` already uses the convenience type, for example one the `temporal-type-aliases-on-datetime` table wrote; leave it as it is. The detection cannot tell which target a schema is for, so it also matches SQLite's and MySQL's `DateTime` and MongoDB's `Date`. A match in a project whose `prisma.config.ts` uses the SQLite, MySQL or MongoDB target needs no change: on those targets the names keep their meaning.
A type alias written with one of the names, such as `types { Stamp = DateTime @onCreate(now()) }`, is renamed the same way. Rename only field types and alias types in Prisma 8 schemas whose config uses the PostgreSQL target, including the `contract.prisma` copies in migration folders; leave a model or field named `Date`, comments, and Prisma 7 schemas as they are. Re-emit the contract with `prisma contract emit`: it is byte for byte the same as before.
To move a field to the new defaults instead, keep the name, update the application code that reads the field, and run `prisma contract emit`. What the database needs then depends on the type:
- **`Date` and `Time`** keep their `date` and `time(p)` columns, when a precision is written by name (`Time(precision: 0)` for `Time(0)`), and change only the codec, so the database does not change, but the contract hash does.
- With migrations: `prisma migration plan` writes a migration with no operations, then `prisma db migrate` applies it and moves the marker.
- With `prisma db update`: run it. It applies no operations and records the new contract on the database.
- **`DateTime`** makes the column `timestamptz(3)`, and a schema that wrote `DateTime` before had a `timestamptz` column, which holds microseconds. Lowering the precision rounds stored values to milliseconds, so both commands ask before they change it. Run `prisma migration plan --delete <Model>.<field>` and then `prisma db migrate`, or `prisma db update --delete <Model>.<field>`, which accepts the rounding. A JavaScript `Date` holds milliseconds only, so to keep microseconds, keep the `Temporal.Instant` field: write `DateTime(as: temporal)`, which is exactly `TimestamptzTemporal`.
### `ts-date-time-helpers-are-convenience-types`
Rename only in contracts on PostgreSQL; `type.DateTime(...)` in a SQLite or MySQL contract keeps its meaning.
```ts
field.column(type.TimestamptzTemporal()); // was type.DateTime()
field.column(type.DateTemporal()); // was type.Date()
field.column(type.TimeTemporal(3)); // was type.Time(3)
```
## The tools write the convenience date and time types
### `prisma7-schema-reads-dates`
`prisma7Schema(...)` reads each Prisma 7 date and time field as the type the convenience default gives for its column:
| Prisma 7 field | Prisma 8 type | The application reads |
| --- | --- | --- |
| `DateTime` | `TimestampJsDate(3)`, printed `DateTime(storage: timestamp)` | a JavaScript `Date`, the stored value read as UTC |
| `@db.Timestamptz(p)` | `TimestamptzJsDate(p)`, printed `DateTime(precision: p)` | a JavaScript `Date` |
| `@db.Timestamp(p)` | `TimestampJsDate(p)` | a JavaScript `Date`, the stored value read as UTC |
| `@db.Date` | `DateString` | an ISO 8601 string, such as `2026-09-14`, the same text as before |
| `@db.Time(p)` | `TimeString(p)` | an ISO 8601 string, such as `10:00:00.5`, the same text as before |
| `@db.Timetz(p)` | `Timetz(p)` | an ISO 8601 string with a full offset, such as `10:00:00+02:00`, or `10:00:00Z` for UTC; it was `10:00:00+02` and `10:00:00+00` |
Before this release the first three read strings, such as `2026-09-14 10:00:00.123`. `@updatedAt` and `@default(now())` now write a `Date` for these fields. The three string types read ISO 8601 since 8.0.0-rc.21, whatever the contract source. The `iso-string-date-time-values` change in the 8.0.0-rc.20 → 8.0.0-rc.21 guide gives every form that changes, such as a date before year 1, and how to update code that reads them.
For each project whose `prisma.config.ts` uses `prisma7Schema(...)`:
1. Run `prisma contract emit` and commit the new `contract.json` and `contract.d.ts`. The storage hash changes, because the columns' codecs change; the columns themselves do not.
2. Run `prisma db sign` against each database, so its marker records the new contract. Commit the snapshot and the `db` ref it writes under `migrations/`, replacing the previous ones.
3. Change code that read a `DateTime` or `@db.Timestamptz` field as a string. It now gets a `Date`, and a write takes a `Date`:
```ts
// before: user.updatedAt was "2026-09-14 10:00:00.123"
console.log(user.updatedAt.replace(' ', 'T'));
// after: user.updatedAt is a Date
console.log(user.updatedAt.toISOString());
```
A `Date` holds milliseconds. A `@db.Timestamptz(6)` or `@db.Timestamp(6)` column with values that use the extra digits reads with them dropped. To keep them, write the Prisma 8 schema yourself with `DateTime(as: string)` or `DateTime(as: temporal)` for that field.
A `Date` cannot hold `infinity` or `-infinity` either. A row that stores one now fails to read with `RUNTIME.DECODE_FAILED`, where it read as the string `infinity` before. For a column that can hold them, write the Prisma 8 schema yourself with `DateTime(as: string, storage: timestamp, precision: 3)` for that field; `DateTime(as: temporal)` cannot hold them either.
### `temporal-type-aliases-on-datetime`
`temporal.CreatedAt` is now `DateTime @onCreate(now())` and `temporal.UpdatedAt` is `DateTime @onCreate(now()) @onUpdate(now())`, a `timestamptz(3)` column read as a JavaScript `Date`. To keep each field exactly as it is, and its contract, write the type and the generators the alias stood for:
| Before | After, in PSL |
| --- | --- |
| `temporal.CreatedAt` | `DateTime(as: temporal) @onCreate(now())` |
| `temporal.UpdatedAt` | `DateTime(as: temporal) @onCreate(now()) @onUpdate(now())` |
| `temporal.CreatedAtString` | `DateTime(as: string) @onCreate(now())` |
| `temporal.UpdatedAtString` | `DateTime(as: string) @onCreate(now()) @onUpdate(now())` |
| `temporal.CreatedAtJsDate` | `TimestamptzJsDate @onCreate(now())` |
| `temporal.UpdatedAtJsDate` | `TimestamptzJsDate @onCreate(now()) @onUpdate(now())` |
`DateTime(as: temporal)` is exactly `TimestamptzTemporal`, and `DateTime(as: string)` exactly `TimestamptzString`; either form gives the same contract. `TimestamptzJsDate` keeps its explicit name, because `DateTime` gives `TimestamptzJsDate(3)`, which stores a precision the alias did not.
In a TypeScript contract, `field.column(type.temporal.CreatedAtString())` becomes `field.column(type.DateTime({ as: 'string' })).onCreate(gen.now())`, adding `.onUpdate(gen.now())` for an `UpdatedAt` alias, with `gen` taken from the `defineContract` callback beside `field` and `type`. Do the same for each alias in the table. Re-emit with `prisma contract emit`: the contract is the same as before.
To move a `temporal.CreatedAt` or `temporal.UpdatedAt` field to the new default instead, keep the alias and update the code that reads it to a `Date`. Its column becomes `timestamptz(3)`, which rounds stored values to milliseconds, so plan it with `prisma migration plan --delete <Model>.<field>` and apply it with `prisma db migrate`, or run `prisma db update --delete <Model>.<field>`.
If you followed the 8.0.0-rc.20 → 8.0.0-rc.21 guide's `remove-field-presets` change, a field it rewrote from `temporal.createdAt()` or `temporal.updatedAt()` to `temporal.CreatedAt` or `temporal.UpdatedAt` is such a field: write it from the table above to keep its contract.
The detection also matches `temporal.CreatedAt` and `temporal.UpdatedAt` in SQLite and MongoDB projects. There they keep their meaning and need no change.
### `date-default-below-a-millisecond`
A JavaScript `Date` holds milliseconds. A column read as a `Date` now refuses a default with more digits, instead of storing it and reading it back at the millisecond. For a field such as
```prisma
at DateTime(precision: 6) @default(timestamptz`2024-01-01T01:00:00.123456Z`)
```
write one of:
```prisma
at DateTime(as: string, precision: 6) @default(timestamptz`2024-01-01T01:00:00.123456Z`)
at DateTime(precision: 6) @default(sql`'2024-01-01 01:00:00.123456+00'::timestamptz`)
```
The first reads the field as a string, which keeps every digit. The second keeps the field a `Date` and the database default as written. Run `prisma contract emit` again. A Prisma 7 schema read by `prisma7Schema(...)` needs no change: such a default is kept as the SQL Prisma 7 created.
### `date-now-takes-column-precision`
A JavaScript `Date` holds milliseconds. On a column with fewer fraction digits, such as a Prisma 7 `@db.Timestamp(0) @updatedAt` field, the client used to generate a time such as `10:00:00.700` and PostgreSQL stored `10:00:01`, so the value a create returned before a read differed from the stored one. The `now` generator of a column read as a `Date`, such as `TimestamptzJsDate(0) @onCreate(now())`, now receives the column's precision, as the string and time generators already do, and writes `10:00:00`. The same holds for a timestamp column read as a `Temporal` value, such as `TimestamptzTemporal(0) @onCreate(now())` or `TimestampInstant(0) @onCreate(now())`: its `instantNow` or `plainDateTimeNow` generator now receives the precision too. Before, such a column stored `10:00:01`, a time after the write, and at `23:59:59.700` the next day's date.
The contract records the precision beside the generator, for example `{ "kind": "generator", "id": "timestampNow", "params": { "precision": 3 } }`. For each project with such a field, including every project whose `prisma.config.ts` uses `prisma7Schema(...)`, run `prisma contract emit` and commit the new `contract.json` and `contract.d.ts`. The storage hash does not change, and the database marker holds only the storage and profile hashes, so there is no migration to plan and no need to run `prisma db sign`. SQLite and MongoDB contracts do not change.
## Extension packs declare their Postgres extension
Re-run `prisma contract emit` and commit the files it writes under `migrations/`. For pgvector, PostGIS and ParadeDB this moves `migrations/<space-id>/refs/head.json` to a head with no invariants and adds two migrations to `migrations/<space-id>/`: one from the pack's old head and one from the empty contract.
Then bring each database to the new head:
```bash
prisma db migrate
```
On a database the old install migration already ran on, the extension is installed, so the new migration only moves the pack's marker.
## MongoDB merges identical and redundant indexes
Two indexes in one collection are identical when they have the same keys and the same options, counting an option set to `false` as unset. A plain index is redundant beside a unique index that is otherwise identical. The contract keeps the unique index, or the first of two identical ones, where it was written:
```prisma
model User {
id ObjectId @id @map("_id")
email String @unique
@@index([email])
}
```
This now gives one unique index on `{ email: 1 }`. Indexes on the same keys with a different collation, partial filter or other option stay separate.
### `mongo-merged-redundant-indexes`
The detection matches a MongoDB schema or `defineContract` file that writes a unique and a plain index on the same keys. Identical indexes are not matched: the contract builder refused them before, so no working project has them.
1. Run `prisma contract emit`. If the storage hash changed, the contract held two indexes on the same keys that are now one: a unique and a plain index, or two indexes that differ only in an option set to `false`, such as `{ unique: false }` or `{ sparse: false }` beside the same index without it.
2. For a TypeScript or PSL contract, a migration planned before this upgrade that creates both indexes of such a pair never applied: its second `createIndex` fails with `MIGRATION.PRECHECK_FAILED`. Delete it and run `prisma migration plan` again.
3. If `prisma migration apply` stopped part way on that migration, what it created is still in the database:
- If that migration also created the collection, for example the first migration of a new model, the new migration stops again at `collection.<Name>.create`, because creating a collection is never skipped. Drop it in `mongosh` (`db.User.drop()`) and apply the new migration. It creates the collection and the unique index. The collection is new, so it holds data only if something wrote to it after the failed apply. To keep that data, first find documents that share a value the unique index will refuse: `db.User.aggregate([{ $group: { _id: "$email", count: { $sum: 1 } } }, { $match: { count: { $gt: 1 } } }])`. Change or remove them until that returns nothing. Then export the collection (`mongodump --uri "$DATABASE_URL" --collection User --out backup`), drop it, apply the new migration, and load the data back (`mongorestore --uri "$DATABASE_URL" --noIndexRestore --stopOnError backup`). `--noIndexRestore` keeps the plain index the failed migration created out of the collection. `--stopOnError` makes `mongorestore` stop instead of skipping a document the unique index refuses.
- If the collection existed before and the index created first is the unique one, do nothing. The new migration finds it and skips creating it.
- If the collection existed before and the index created first is the plain one, drop it before you apply the new migration, or its `createIndex` fails again with `MIGRATION.PRECHECK_FAILED`. This happens when the plain index was written first, for example `index({ email: 1 })` before `index({ email: 1 }, { unique: true })`, or `@@index([email])` on a model before `email String @unique` on a later model in the same collection. In `mongosh`, drop it by its key pattern, so its name does not matter: `db.User.dropIndex({ email: 1 })`.
In the `mongosh` commands above, replace `User` with your collection's name. It is the model name unless `@@map` changes it.
## Create a Mongo runtime driver from its descriptor
Put the descriptor on the execution stack, create the driver from the stack, and connect it to a binding. A `{ kind: 'url', url, dbName }` binding makes the driver open a `MongoClient` and close it on `close()`. A `{ kind: 'mongoClient', client, dbName }` binding uses your client and leaves it open.
```ts
// Before
import { createMongoDriver } from '@prisma/orm-mongo/driver';
const stack = createMongoExecutionStack({ target: mongoRuntimeTarget, adapter: mongoRuntimeAdapter });
const driver = await createMongoDriver(url, dbName);
// After
import mongoRuntimeDriver from '@prisma/orm-mongo/driver/runtime';
const stack = createMongoExecutionStack({
target: mongoRuntimeTarget,
adapter: mongoRuntimeAdapter,
driver: mongoRuntimeDriver,
});
const driver = stack.driver.create();
await driver.connect({ kind: 'url', url, dbName });
```
Replace `MongoDriverImpl.fromDb(client.db(dbName))` with `driver.connect({ kind: 'mongoClient', client, dbName })` on a driver created the same way. Code that only sends DDL through a control adapter can use the control driver instead: `await mongoControlDriver.create(url)`, with `mongoControlDriver` the default export of `@prisma/orm-mongo/driver/control`. The control driver uses the database the URL names, so put `dbName` in the URL's path, for example `mongodb://localhost:27017/app`.
`createMongoRuntime(...)` closes the driver when the runtime closes, so keep calling `runtime.close()` and drop any separate `driver.close()`.
## The MongoDB query surfaces and runtime take an execution context
`mongoQuery`, `mongoRaw` and `mongoOrm` take `{ context }`, a MongoDB execution context, instead of `{ contract }`, as `sql({ context })` and the SQL ORM do. The context comes from `mongoStatic({ contract })` or `mongo({ contract })`, which validate the contract once.
```ts
// Before
import { mongoQuery } from '@prisma/orm-mongo/query-builder';
const query = mongoQuery({ contract });
const raw = mongoRaw({ contract });
// After
import mongoStatic from '@prisma/orm-mongo/static';
const { query, raw } = mongoStatic({ contract });
// Or, with a client
const db = mongo({ contract, url });
db.query.from('orders');
```
Code that builds the ORM itself passes the context, which also fills generated fields:
```ts
// Before
const orm = mongoOrm({ contract, executor, mutationDefaults: context, enums });
const users = createMongoCollection(contract, 'User', executor, enums, context);
// After, as the SQL orm({ context, runtime })
const orm = mongoOrm({ context, runtime: executor });
const users = createMongoCollection({ context, runtime: executor }, 'User');
```
The runtime takes an adapter beside the context, as the SQL runtime does, and reads the target from the context's contract:
```ts
// Before
const runtime = createMongoRuntime({ context, stack, driver });
// After
const stack = createMongoExecutionStack({ target, adapter });
const context = createMongoExecutionContext({ contract, stack });
const runtime = createMongoRuntime({ context, adapter: stack.adapter.create(stack), driver });
```
`MongoExecutionContext` takes one type parameter, a MongoDB contract: `MongoExecutionContext<Contract>`.
## `connect()` on a client is safe to call again
```ts
// Before
await db.orm.public.User.first();
await db.connect(); // rejected with DRIVER.ALREADY_CONNECTED
// After
await db.orm.public.User.first();
await db.connect(); // resolves with the same runtime
await db.connect({ url: other }); // rejects: "Postgres client is already bound"
```
A handler that only guarded a `connect()` without options can go:
```ts
// Before
await db.connect().catch((error) => {
if (error.code !== 'DRIVER.ALREADY_CONNECTED') throw error;
});
// After
await db.connect();
```
Keep a handler that guards `connect(options)`: that call still rejects with `DRIVER.ALREADY_CONNECTED` on a bound client. Replace an assertion on the old message with the new one:
```ts
// Before
await expect(db.connect({ url })).rejects.toThrow('Postgres client already connected');
// After
await expect(db.connect({ url })).rejects.toThrow('Postgres client is already bound');
```
## `contract-artifacts-restamp`
For every `contract.json` matched by `detection`, run the project's emit command (`prisma contract emit`, or the project's `contract:emit` script) once after upgrading. This entry accounts for the extension's embedded `version` moving to `8.0.0-rc.22`; any other difference in the emitted files comes from an earlier entry in this guide.
upgrading/app/upgrades/8.0.0-rc.21-to-8.0.0-rc.22/scripts/value-objects-in-storage/value-objects-in-storage.ts
#!/usr/bin/env node
/**
* Moves the value objects of every SQL and MongoDB contract from the domain plane into storage. Contracts emitted before this release keep value objects at `domain.namespaces.<ns>.valueObjects` and type a field holding one as `{ kind: 'valueObject', name }`; this release keeps them at `storage.namespaces.<ns>.entries.valueObject` and types the field by the value object's storage coordinate. In each contract the script moves every value object into the storage namespace of the same id, points every field at it by coordinate, and points a member typed by an enum at the enum's storage value set. In a SQL contract it also writes the value set of each discriminator, keyed `<Base>.<field>` and holding the variants' values, and points the discriminator column at it, unless an enum already types that column. It makes the same changes in `contract.d.ts`.
*
* Storage now holds the value objects, so each rewritten contract has a new storage hash. The script renames each snapshot directory to its new hash and rewrites every migration, ref, `migration.ts` and `contract.d.ts` that names an old hash, and each migration's `migrationHash`. It does not add the checks PostgreSQL now derives for value object and discriminator columns: a snapshot records what its migrations created, and they created no such check. The next `prisma migration plan` adds them.
*
* Usage: node value-objects-in-storage.ts [project-root]
*
* The project root defaults to the working directory. The script reads and writes files only. It needs no database, network or configured stack, and a project whose contracts keep no value object in the domain plane is left unchanged. When it stops (a file it must read is not valid JSON, a contract whose stored hash does not match its content, a field naming a value object the contract does not declare, a value object whose namespace storage does not have, a member enum without a storage value set, a discriminator value set that already exists, a snapshot directory that already exists with different content, or a directory it cannot read) it changes no file, prints one line per case and exits 1. When it finishes it prints how many files and snapshot directories it changed and each storage hash it replaced.
*
* The storage hash and migration hash rules are copied from `@internal/contract`, `@internal/sql-contract`, `@internal/mongo-contract` and `@internal/migration-tools` because a project's strict `node_modules` does not expose them.
*/
import { createHash } from 'node:crypto';
import {
chmodSync,
type Dirent,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
type Json = null | boolean | number | string | Json[] | JsonRecord;
interface JsonRecord {
[key: string]: Json;
}
type Family = 'sql' | 'mongo';
const SKIPPED_DIRECTORIES = new Set(['node_modules', 'dist', 'build']);
const HASH = /^[0-9a-f]{64}$/;
const TEMPORARY_SUFFIX = '.value-objects-in-storage-tmp';
const BYTE_ORDER_MARK = '\uFEFF';
function isRecord(value: unknown): value is JsonRecord {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const prototype: unknown = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function withoutByteOrderMark(text: string): string {
return text.startsWith(BYTE_ORDER_MARK) ? text.slice(BYTE_ORDER_MARK.length) : text;
}
function parseJson(text: string): Json | undefined {
try {
const value: Json = JSON.parse(withoutByteOrderMark(text));
return value;
} catch {
return undefined;
}
}
function sha256(text: string): string {
return createHash('sha256').update(text).digest('hex');
}
function compareCodeUnits(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
function sortKeys(value: Json): Json {
if (Array.isArray(value)) return value.map(sortKeys);
if (!isRecord(value)) return value;
const sorted: JsonRecord = {};
for (const key of Object.keys(value).sort(compareCodeUnits)) {
const child = value[key];
if (child !== undefined) sorted[key] = sortKeys(child);
}
return sorted;
}
function canonicalizeJson(value: Json): string {
return JSON.stringify(sortKeys(value));
}
type PathSegment = string | readonly string[];
function matchesPath(path: readonly string[], pattern: readonly PathSegment[]): boolean {
if (path.length !== pattern.length) return false;
return pattern.every((segment, index) => {
const value = path[index];
if (value === undefined) return false;
if (segment === '*') return true;
return typeof segment === 'string' ? value === segment : segment.includes(value);
});
}
const SQL_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
['storage', 'namespaces', '*', 'entries', 'table'],
['storage', 'namespaces', '*', 'entries', 'table', '*'],
['storage', 'namespaces', '*', 'entries', 'table', '*', ['uniques', 'indexes', 'foreignKeys']],
['storage', 'namespaces', '*', 'entries', 'table', '*', 'indexes', 'unique'],
['storage', 'namespaces', '*', 'entries', 'table', '*', 'columns', '*', 'default', 'value'],
];
const COLUMN_DEFAULT_VALUE: readonly PathSegment[] = [
'storage',
'namespaces',
'*',
'entries',
'table',
'*',
'columns',
'*',
'default',
'value',
];
const MONGO_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
['storage', 'namespaces', '*', 'entries', 'collection'],
['storage', 'namespaces', '*', 'entries', 'collection', '*'],
];
const FRAMEWORK_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
['domain', 'namespaces'],
['domain', 'namespaces', '*'],
['domain', 'namespaces', '*', 'models'],
['domain', 'namespaces', '*', 'models', '*', 'relations'],
['domain', 'namespaces', '*', 'models', '*', 'storage'],
['storage', 'namespaces'],
['storage', 'namespaces', '*', 'entries'],
['roots'],
['extensions'],
['extensions', '*'],
['capabilities'],
['meta'],
['execution', 'mutations', 'defaults'],
];
const TOP_LEVEL_ORDER = [
'schemaVersion',
'canonicalVersion',
'targetFamily',
'target',
'profileHash',
'roots',
'domain',
'storage',
'execution',
'capabilities',
'extensions',
'defaultControlPolicy',
'meta',
];
function isDefaultValue(value: Json): boolean {
if (value === false) return true;
if (Array.isArray(value)) return value.length === 0;
return isRecord(value) && Object.keys(value).length === 0;
}
function isFamilyPreservedEmpty(family: Family, path: readonly string[]): boolean {
if (family === 'mongo') {
return (
path.at(-1) === 'additionalProperties' ||
(path.includes('jsonSchema') && (path.at(-2) === 'properties' || path.at(-1) === 'items')) ||
MONGO_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern))
);
}
if (
path.length >= COLUMN_DEFAULT_VALUE.length &&
matchesPath(path.slice(0, COLUMN_DEFAULT_VALUE.length), COLUMN_DEFAULT_VALUE)
)
return true;
return SQL_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern));
}
function isPreservedEmpty(family: Family, path: readonly string[], key: string): boolean {
if (key === 'nullable') return true;
if (key === 'elementNullable' && path.at(-2) === 'many') return true;
if (FRAMEWORK_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern))) return true;
return isFamilyPreservedEmpty(family, path);
}
function omitDefaults(family: Family, value: Json, path: readonly string[]): Json {
if (Array.isArray(value)) return value.map((item) => omitDefaults(family, item, path));
if (!isRecord(value)) return value;
const result: JsonRecord = {};
for (const [key, child] of Object.entries(value)) {
const childPath = [...path, key];
if (key === '_generated') continue;
if (key === 'generated' && child === false) continue;
if ((key === 'onDelete' || key === 'onUpdate') && child === 'noAction') continue;
if (isDefaultValue(child) && !isPreservedEmpty(family, childPath, key)) continue;
result[key] = omitDefaults(family, child, childPath);
}
return result;
}
function compareByName(a: Json, b: Json): number {
const nameA = isRecord(a) && typeof a['name'] === 'string' ? a['name'] : '';
const nameB = isRecord(b) && typeof b['name'] === 'string' ? b['name'] : '';
return compareCodeUnits(nameA, nameB);
}
function sortTableArrays(storage: Json): Json {
if (!isRecord(storage)) return storage;
const namespaces = storage['namespaces'];
if (!isRecord(namespaces)) return storage;
const sortedNamespaces: JsonRecord = {};
for (const [namespaceId, namespace] of Object.entries(namespaces)) {
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
const tables = isRecord(entries) ? entries['table'] : undefined;
if (!isRecord(namespace) || !isRecord(entries) || !isRecord(tables)) {
sortedNamespaces[namespaceId] = namespace;
continue;
}
const sortedTables: JsonRecord = {};
for (const [tableName, table] of Object.entries(tables)) {
if (!isRecord(table)) {
sortedTables[tableName] = table;
continue;
}
const sortedTable: JsonRecord = { ...table };
for (const key of ['checks', 'indexes', 'uniques']) {
const list = table[key];
if (Array.isArray(list)) sortedTable[key] = [...list].sort(compareByName);
}
sortedTables[tableName] = sortedTable;
}
sortedNamespaces[namespaceId] = { ...namespace, entries: { ...entries, table: sortedTables } };
}
return { ...storage, namespaces: sortedNamespaces };
}
function orderTopLevel(value: JsonRecord): JsonRecord {
const ordered: JsonRecord = {};
const remaining = new Set(Object.keys(value));
for (const key of TOP_LEVEL_ORDER) {
const child = value[key];
if (child !== undefined) {
ordered[key] = child;
remaining.delete(key);
}
}
for (const key of [...remaining].sort(compareCodeUnits)) {
const child = value[key];
if (child !== undefined) ordered[key] = child;
}
return ordered;
}
function omitNamespaceKinds(storage: JsonRecord): JsonRecord {
const namespaces = storage['namespaces'];
if (!isRecord(namespaces)) return storage;
const stripped: JsonRecord = {};
for (const [namespaceId, namespace] of Object.entries(namespaces)) {
if (isRecord(namespace)) {
const { kind: _kind, ...rest } = namespace;
stripped[namespaceId] = rest;
} else {
stripped[namespaceId] = namespace;
}
}
return { ...storage, namespaces: stripped };
}
function computeStorageHash(family: Family, contract: JsonRecord): string {
const storage = isRecord(contract['storage']) ? contract['storage'] : {};
const { storageHash: _published, ...withoutHash } = storage;
const hashed: JsonRecord = {
schemaVersion: '1',
targetFamily: typeof contract['targetFamily'] === 'string' ? contract['targetFamily'] : '',
target: typeof contract['target'] === 'string' ? contract['target'] : '',
profileHash: '',
roots: {},
domain: { namespaces: {} },
storage: omitNamespaceKinds(withoutHash),
extensions: {},
capabilities: {},
meta: {},
};
const withoutDefaults = omitDefaults(family, hashed, []);
if (!isRecord(withoutDefaults)) return '';
const withSortedStorage =
family === 'sql'
? { ...withoutDefaults, storage: sortTableArrays(withoutDefaults['storage'] ?? {}) }
: withoutDefaults;
const sorted = sortKeys(withSortedStorage);
return isRecord(sorted) ? sha256(JSON.stringify(orderTopLevel(sorted), null, 2)) : '';
}
function computeMigrationHash(metadata: JsonRecord, ops: Json): string {
const { migrationHash: _migrationHash, ...withoutHash } = metadata;
const parts = [canonicalizeJson(withoutHash), canonicalizeJson(ops)].map(sha256);
return sha256(canonicalizeJson(parts));
}
interface ProjectFiles {
readonly json: readonly string[];
readonly migrationJson: readonly string[];
readonly refs: readonly string[];
readonly migrationTs: readonly string[];
readonly leftovers: readonly string[];
readonly unreadable: readonly { readonly dir: string; readonly code: string }[];
}
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
/** A file, or a link to one: the script reads and writes through links to files. */
function isFileEntry(dir: string, entry: Dirent): boolean {
return entry.isFile() || (entry.isSymbolicLink() && isFile(join(dir, entry.name)));
}
/** A file the script reads, by the path it was reached through: its own path rather than a link's. */
interface FoundFile {
readonly path: string;
readonly linked: boolean;
readonly inMigrationPackage: boolean;
}
function listFiles(root: string): ProjectFiles {
const leftovers: string[] = [];
const visited = new Set<string>();
const found = new Map<string, FoundFile>();
const unreadable: { dir: string; code: string }[] = [];
const visit = (dir: string): void => {
const realDir = realpathSync(dir);
if (visited.has(realDir)) return;
visited.add(realDir);
const listed = listDirectory(dir);
if (!listed.ok) {
unreadable.push({ dir, code: listed.code });
return;
}
const entries = listed.entries.sort((a, b) => compareCodeUnits(a.name, b.name));
const inMigrationPackage = entries.some(
(entry) => entry.name === 'migration.json' && isFileEntry(dir, entry),
);
for (const entry of entries) {
const path = join(dir, entry.name);
if (entry.name.endsWith(TEMPORARY_SUFFIX)) {
leftovers.push(path);
continue;
}
if (entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(path))) {
if (!SKIPPED_DIRECTORIES.has(entry.name) && !entry.name.startsWith('.')) visit(path);
continue;
}
if (!isFileEntry(dir, entry)) continue;
const realFile = realpathSync(path);
const linked = entry.isSymbolicLink();
const known = found.get(realFile);
if (known === undefined || (known.linked && !linked))
found.set(realFile, { path, linked, inMigrationPackage });
}
};
visit(root);
const files = [...found.values()];
const named = (test: (file: FoundFile, name: string) => boolean): string[] =>
files.filter((file) => test(file, basename(file.path))).map((file) => file.path);
return {
json: named((_file, name) => name.endsWith('.json')),
migrationJson: named((_file, name) => name === 'migration.json'),
refs: named((file, name) => name.endsWith('.json') && basename(dirname(file.path)) === 'refs'),
migrationTs: named((file, name) => name === 'migration.ts' && file.inMigrationPackage),
leftovers,
unreadable,
};
}
type DirectoryListing =
| { readonly ok: true; readonly entries: Dirent[] }
| { readonly ok: false; readonly code: string };
function listDirectory(dir: string): DirectoryListing {
try {
return { ok: true, entries: readdirSync(dir, { withFileTypes: true }) };
} catch (error) {
const code = error instanceof Error && 'code' in error ? String(error.code) : 'unknown error';
return { ok: false, code };
}
}
function isSnapshotContract(path: string): boolean {
const snapshotDir = dirname(path);
return (
basename(path) === 'contract.json' &&
HASH.test(basename(snapshotDir)) &&
basename(dirname(snapshotDir)) === 'snapshots'
);
}
function replaceQuotedHashes(text: string, hashes: ReadonlyMap<string, string>): string {
return text.replace(/"([0-9a-f]{64})"/g, (match: string, hash: string) => {
const next = hashes.get(hash);
return next === undefined ? match : `"${next}"`;
});
}
function replaceHashTokens(text: string, hashes: ReadonlyMap<string, string>): string {
return text.replace(
/(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])/g,
(hash: string) => hashes.get(hash) ?? hash,
);
}
function readDirectory(dir: string): Map<string, string> {
const files = new Map<string, string>();
if (!existsSync(dir)) return files;
for (const entry of readdirSync(dir, { withFileTypes: true }))
if (isFileEntry(dir, entry) && !entry.name.endsWith(TEMPORARY_SUFFIX))
files.set(entry.name, readFileSync(join(dir, entry.name), 'utf8'));
return files;
}
function sameFiles(a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): boolean {
return a.size === b.size && [...a].every(([name, content]) => b.get(name) === content);
}
function temporaryPath(path: string): string {
return join(dirname(path), `.${basename(path)}${TEMPORARY_SUFFIX}`);
}
function writeWithModeOf(path: string, text: string, modeSource: string): void {
writeFileSync(path, text);
if (existsSync(modeSource)) chmodSync(path, statSync(modeSource).mode & 0o7777);
}
function writeFile(path: string, text: string): void {
const target = realpathSync(path);
const temporary = temporaryPath(target);
writeWithModeOf(temporary, text, target);
renameSync(temporary, target);
}
function writeDirectory(dir: string, files: ReadonlyMap<string, string>, sourceDir: string): void {
const temporary = temporaryPath(dir);
mkdirSync(temporary);
for (const [name, text] of files)
writeWithModeOf(join(temporary, name), text, join(sourceDir, name));
renameSync(temporary, dir);
}
function removeDirectory(dir: string): void {
const temporary = temporaryPath(dir);
renameSync(dir, temporary);
rmSync(temporary, { recursive: true, force: true });
}
function count(amount: number, singular: string, plural: string): string {
return `${amount} ${amount === 1 ? singular : plural}`;
}
function recordsOf(value: Json | undefined): [string, JsonRecord][] {
return isRecord(value)
? Object.entries(value).filter((entry): entry is [string, JsonRecord] => isRecord(entry[1]))
: [];
}
function coordinate(namespaceId: string, entityKind: string, entityName: string): JsonRecord {
return { plane: 'storage', namespaceId, entityKind, entityName };
}
interface Rewrite {
readonly contract: JsonRecord;
readonly changed: boolean;
/** The storage namespace each moved value object now lives in, by name. */
readonly valueObjectNamespaces: ReadonlyMap<string, string>;
readonly problems: readonly string[];
}
/** Whether the contract keeps value objects in the domain plane, or refers to one by name. */
function holdsOldValueObjects(contract: JsonRecord): boolean {
const domain = isRecord(contract['domain']) ? contract['domain'] : {};
for (const [, namespace] of recordsOf(domain['namespaces'])) {
if (Object.hasOwn(namespace, 'valueObjects')) return true;
for (const [, model] of recordsOf(namespace['models']))
for (const [, field] of recordsOf(model['fields'])) {
const type = field['type'];
if (isRecord(type) && type['kind'] === 'valueObject' && typeof type['name'] === 'string')
return true;
}
}
return false;
}
/** A field that names a value object by `name`, pointed at it by its storage coordinate. */
function withValueObjectCoordinate(
field: JsonRecord,
namespaceOf: (name: string) => string | undefined,
where: string,
problems: string[],
): JsonRecord {
const type = field['type'];
if (!isRecord(type) || type['kind'] !== 'valueObject' || typeof type['name'] !== 'string')
return field;
const namespaceId = namespaceOf(type['name']);
if (namespaceId === undefined) {
problems.push(
`${where} holds the value object ${type['name']}, which the contract does not declare`,
);
return field;
}
return {
...field,
type: { kind: 'valueObject', ...coordinate(namespaceId, 'valueObject', type['name']) },
};
}
function valueSetsOf(storageNamespace: JsonRecord): JsonRecord {
const entries = isRecord(storageNamespace['entries']) ? storageNamespace['entries'] : {};
return isRecord(entries['valueSet']) ? entries['valueSet'] : {};
}
/** The old MongoDB schema let a member leave `nullable` out, meaning `false`, and say `dict: false`; storage requires `nullable` and keeps `dict` only when it is `true`. */
function withStorageModifiers(member: JsonRecord): JsonRecord {
const { dict, ...rest } = member;
return {
...rest,
nullable: member['nullable'] === undefined ? false : member['nullable'],
...(dict === undefined || dict === false ? {} : { dict }),
};
}
/** A value object member, with an enum reference pointed at the enum's storage value set. */
function rewrittenMember(
stored: JsonRecord,
storageNamespaces: JsonRecord,
where: string,
problems: string[],
): JsonRecord {
const member = withStorageModifiers(stored);
const valueSet = member['valueSet'];
if (!isRecord(valueSet) || valueSet['plane'] === 'storage') return member;
const namespaceId = valueSet['namespaceId'];
const name = valueSet['entityName'];
const storageNamespace =
typeof namespaceId === 'string' ? storageNamespaces[namespaceId] : undefined;
if (
typeof namespaceId !== 'string' ||
typeof name !== 'string' ||
!isRecord(storageNamespace) ||
!Object.hasOwn(valueSetsOf(storageNamespace), name)
) {
problems.push(
`${where} is typed by the enum ${String(name)}, which has no value set in storage namespace ${String(namespaceId)}`,
);
return member;
}
return { ...member, valueSet: { ...valueSet, ...coordinate(namespaceId, 'valueSet', name) } };
}
/** Writes each discriminator's value set, `<Base>.<field>`, in its base table's namespace, and points the column at it, unless an enum already types the column. */
function writeDiscriminatorValueSets(
domainNamespaces: JsonRecord,
storageNamespaces: JsonRecord,
problems: string[],
): boolean {
let changed = false;
for (const [modelNamespaceId, namespace] of recordsOf(domainNamespaces)) {
for (const [modelName, model] of recordsOf(namespace['models'])) {
const discriminator = model['discriminator'];
const variants = model['variants'];
const storage = model['storage'];
if (!isRecord(discriminator) || !isRecord(variants) || !isRecord(storage)) continue;
const field = discriminator['field'];
const fields = isRecord(storage['fields']) ? storage['fields'] : {};
const fieldStorage = typeof field === 'string' ? fields[field] : undefined;
const columnName = isRecord(fieldStorage) ? fieldStorage['column'] : undefined;
const tableName = storage['table'];
const namespaceId =
typeof storage['namespaceId'] === 'string' ? storage['namespaceId'] : modelNamespaceId;
const storageNamespace = storageNamespaces[namespaceId];
const entries = isRecord(storageNamespace) ? storageNamespace['entries'] : undefined;
const tables = isRecord(entries) ? entries['table'] : undefined;
const table =
isRecord(tables) && typeof tableName === 'string' ? tables[tableName] : undefined;
const columns = isRecord(table) ? table['columns'] : undefined;
const column =
isRecord(columns) && typeof columnName === 'string' ? columns[columnName] : undefined;
if (
typeof field !== 'string' ||
typeof columnName !== 'string' ||
!isRecord(entries) ||
!isRecord(columns) ||
!isRecord(column) ||
column['valueSet'] !== undefined
)
continue;
const name = `${modelName}.${field}`;
const valueSets = isRecord(entries['valueSet']) ? entries['valueSet'] : {};
if (Object.hasOwn(valueSets, name)) {
problems.push(
`the discriminator ${name} writes the value set ${name} in storage namespace ${namespaceId}, which already holds a value set of that name; rename the enum`,
);
continue;
}
entries['valueSet'] = {
...valueSets,
[name]: { kind: 'valueSet', values: Object.keys(variants).sort(compareCodeUnits) },
};
columns[columnName] = { ...column, valueSet: coordinate(namespaceId, 'valueSet', name) };
changed = true;
}
}
return changed;
}
/** The contract with its value objects moved into storage, and on SQL each discriminator's value set written. */
function rewriteContract(contract: JsonRecord, family: Family): Rewrite {
const unchanged: Rewrite = {
contract,
changed: false,
valueObjectNamespaces: new Map(),
problems: [],
};
if (!holdsOldValueObjects(contract)) return unchanged;
const copy = parseJson(JSON.stringify(contract));
if (!isRecord(copy) || !isRecord(copy['domain']) || !isRecord(copy['storage'])) return unchanged;
const domainNamespaces = isRecord(copy['domain']['namespaces'])
? copy['domain']['namespaces']
: {};
const storageNamespaces = isRecord(copy['storage']['namespaces'])
? copy['storage']['namespaces']
: {};
const problems: string[] = [];
const valueObjectNamespaces = new Map<string, string>();
const declaredIn = new Map<string, Set<string>>();
for (const [namespaceId, namespace] of recordsOf(domainNamespaces))
for (const [name] of recordsOf(namespace['valueObjects']))
declaredIn.set(name, new Set([...(declaredIn.get(name) ?? []), namespaceId]));
const namespaceFor =
(ownNamespaceId: string) =>
(name: string): string | undefined => {
const namespaces = declaredIn.get(name);
if (namespaces === undefined) return undefined;
if (namespaces.has(ownNamespaceId)) return ownNamespaceId;
return namespaces.size === 1 ? [...namespaces][0] : undefined;
};
for (const [namespaceId, namespace] of recordsOf(domainNamespaces)) {
const valueObjects = namespace['valueObjects'];
delete namespace['valueObjects'];
for (const [modelName, model] of recordsOf(namespace['models'])) {
const fields = model['fields'];
for (const [fieldName, field] of recordsOf(fields))
if (isRecord(fields))
fields[fieldName] = withValueObjectCoordinate(
field,
namespaceFor(namespaceId),
`field ${modelName}.${fieldName}`,
problems,
);
}
if (!isRecord(valueObjects)) continue;
const storageNamespace = storageNamespaces[namespaceId];
if (!isRecord(storageNamespace)) {
problems.push(
`the value objects of domain namespace ${namespaceId} have no storage namespace ${namespaceId} to move to`,
);
continue;
}
const moved: JsonRecord = {};
for (const [name, valueObject] of recordsOf(valueObjects)) {
valueObjectNamespaces.set(name, namespaceId);
const members: JsonRecord = {};
for (const [memberName, member] of recordsOf(valueObject['fields'])) {
const where = `value object member ${name}.${memberName}`;
members[memberName] = rewrittenMember(
withValueObjectCoordinate(member, namespaceFor(namespaceId), where, problems),
storageNamespaces,
where,
problems,
);
}
moved[name] = { ...valueObject, fields: members };
}
const entries = isRecord(storageNamespace['entries']) ? storageNamespace['entries'] : {};
storageNamespace['entries'] = { ...entries, valueObject: moved };
}
if (family === 'sql') writeDiscriminatorValueSets(domainNamespaces, storageNamespaces, problems);
if (Object.hasOwn(copy, 'valueObjects')) delete copy['valueObjects'];
return { contract: copy, changed: true, valueObjectNamespaces, problems };
}
function withStorageHash(contract: JsonRecord, hash: string): JsonRecord {
const storage = contract['storage'];
if (!isRecord(storage) || typeof storage['storageHash'] !== 'string') return contract;
return { ...contract, storage: { ...storage, storageHash: hash } };
}
function lineEndingOf(text: string): string {
return text.includes('\r\n') ? '\r\n' : '\n';
}
/** The contract written the way the file held it: a snapshot's canonical line, or indented JSON, with the file's byte order mark and line ending. */
function writtenForm(contract: JsonRecord, original: JsonRecord, text: string): string {
const mark = text.startsWith(BYTE_ORDER_MARK) ? BYTE_ORDER_MARK : '';
const body = withoutByteOrderMark(text);
const lineEnding = lineEndingOf(body);
const finalNewline = body.endsWith('\n') ? lineEnding : '';
if (body === `${canonicalizeJson(original)}${finalNewline}`)
return `${mark}${canonicalizeJson(contract)}${finalNewline}`;
const indented = JSON.stringify(contract, null, 2).replaceAll('\n', lineEnding);
return `${mark}${indented}${finalNewline}`;
}
interface Block {
readonly start: number;
end: number;
readonly label: string;
readonly parent: Block | undefined;
}
function scanBlocks(text: string): Block[] {
const blocks: Block[] = [];
const open: Block[] = [];
let quote: string | undefined;
for (let index = 0; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === '/' && text[index + 1] === '/') {
const end = text.indexOf('\n', index);
index = end === -1 ? text.length : end;
continue;
}
if (char === '/' && text[index + 1] === '*') {
const end = text.indexOf('*/', index + 2);
index = end === -1 ? text.length : end + 1;
continue;
}
if (char === "'" || char === '"' || char === '`') {
quote = char;
continue;
}
if (char === '{') {
const key =
/(?:readonly\s+)?([\p{ID_Start}_$][\p{ID_Continue}$]*|'[^']*'|"[^"]*")\s*\??\s*:\s*$/u.exec(
text.slice(Math.max(0, index - 200), index),
);
const block: Block = {
start: index,
end: text.length,
label: key?.[1]?.replace(/^['"]|['"]$/g, '') ?? '',
parent: open.at(-1),
};
open.push(block);
blocks.push(block);
} else if (char === '}') {
const block = open.pop();
if (block !== undefined) block.end = index;
}
}
return blocks;
}
function lineStart(text: string, position: number): number {
return text.lastIndexOf('\n', position - 1) + 1;
}
/** The end of the member a block closes: past its `;` and the line break after it. */
function memberEnd(text: string, block: Block): number {
const rest = /^\s*;?[ \t]*(?:\r?\n)?/.exec(text.slice(block.end + 1));
return block.end + 1 + (rest?.[0].length ?? 0);
}
function indentOf(text: string, position: number): string {
return /^[ \t]*/.exec(text.slice(lineStart(text, position)))?.[0] ?? '';
}
function childBlocks(blocks: readonly Block[], parent: Block): Block[] {
return blocks.filter((block) => block.parent === parent);
}
function isUnder(block: Block, labels: readonly string[]): boolean {
let current = block.parent;
for (const label of labels) {
if (current?.label !== label) return false;
current = current.parent;
}
return true;
}
/** A block re-indented from `from` to `to`, its first line left as it starts. */
function reindent(literal: string, from: string, to: string): string {
return literal
.split('\n')
.map((line, index) =>
index === 0 || !line.startsWith(from) ? line : `${to}${line.slice(from.length)}`,
)
.join('\n');
}
interface Edit {
readonly start: number;
readonly end: number;
readonly text: string;
}
const STORAGE_NAMESPACES = "ContractBase['storage']['namespaces']";
function dtsPathOf(contractPath: string, snapshot: boolean): string {
return snapshot
? join(dirname(contractPath), 'contract.d.ts')
: `${contractPath.slice(0, -'.json'.length)}.d.ts`;
}
const DTS_LINE_WIDTH = 100;
/**
* The same move in `contract.d.ts`: each `valueObjects` type of a domain namespace goes into its storage namespace's `entries` as `valueObject`, a member's enum reference names the storage value set, a field's value object type names its coordinate, the top-level `valueObjects` type goes, and the value object and field type aliases read storage. The discriminator value sets are not typed: a re-emit types them.
*/
function rewriteDts(
text: string,
valueObjectNamespaces: ReadonlyMap<string, string>,
hashes: ReadonlyMap<string, string>,
): string {
const lineEnding = lineEndingOf(text);
const blocks = scanBlocks(text);
const edits: Edit[] = [];
const moved = new Map<string, string>();
for (const block of blocks) {
if (block.label !== 'valueObjects') continue;
const namespace = block.parent;
if (namespace !== undefined && isUnder(namespace, ['namespaces', 'domain'])) {
const literal = text
.slice(block.start, block.end + 1)
.replace(/readonly plane: (['"])domain\1/g, "readonly plane: 'storage'")
.replace(/readonly entityKind: (['"])enum\1/g, "readonly entityKind: 'valueSet'");
moved.set(namespace.label, reindent(literal, indentOf(text, block.start), ''));
edits.push({ start: lineStart(text, block.start), end: memberEnd(text, block), text: '' });
} else if (
namespace !== undefined &&
childBlocks(blocks, namespace).some((sibling) => sibling.label === 'domain')
) {
edits.push({ start: lineStart(text, block.start), end: memberEnd(text, block), text: '' });
}
}
for (const block of blocks) {
if (block.label !== 'entries' || block.parent === undefined) continue;
if (!isUnder(block.parent, ['namespaces']) || isUnder(block.parent, ['namespaces', 'domain']))
continue;
const literal = moved.get(block.parent.label);
if (literal === undefined) continue;
const indent = `${indentOf(text, block.start)} `;
const closing = lineStart(text, block.end);
edits.push({
start: closing,
end: closing,
text: `${indent}readonly valueObject: ${reindent(literal, '', indent)};${lineEnding}`,
});
}
let result = text;
for (const edit of edits.sort((a, b) => b.start - a.start))
result = `${result.slice(0, edit.start)}${edit.text}${result.slice(edit.end)}`;
result = result
.replace(
/\{\s*readonly kind: (['"])valueObject\1;\s*readonly name: (['"])([^'"]*)\2;?\s*\}/g,
(
match: string,
_quote: string,
_nameQuote: string,
name: string,
offset: number,
whole: string,
) => {
const namespaceId = valueObjectNamespaces.get(name);
return namespaceId === undefined
? match
: coordinateTypeLiteral(namespaceId, name, whole, offset, lineEnding);
},
)
.replace(
/ValueObjectTypesFromDomain<(\s*)ContractBase\['domain'\]\['namespaces'\]/g,
`ValueObjectTypesFromStorage<$1${STORAGE_NAMESPACES}`,
)
.replace(/\bValueObjectTypesFromDomain\b/g, 'ValueObjectTypesFromStorage');
return replaceHashTokens(withStorageNamespacesArgument(result), hashes);
}
/** `FieldTypesFromDomain` with the storage namespaces as its sixth argument, which it now requires of every contract. */
function withStorageNamespacesArgument(text: string): string {
const lineEnding = lineEndingOf(text);
return text.replace(
/(FieldTypesFromDomain<[^<>]*?(['"])(?:output|input)\2)(\s*)>/g,
(_match: string, head: string, _quote: string, space: string) =>
space.includes('\n')
? `${head},${lineEnding} ${STORAGE_NAMESPACES}${lineEnding}>`
: `${head}, ${STORAGE_NAMESPACES}>`,
);
}
/** A value object field's coordinate type, on one line when it fits the emitter's line width, else one member per line. */
function coordinateTypeLiteral(
namespaceId: string,
name: string,
text: string,
offset: number,
lineEnding: string,
): string {
const members = [
"readonly kind: 'valueObject'",
"readonly plane: 'storage'",
`readonly namespaceId: '${namespaceId}'`,
"readonly entityKind: 'valueObject'",
`readonly entityName: '${name}'`,
];
const single = `{ ${members.join('; ')} }`;
if (offset - lineStart(text, offset) + single.length + 1 <= DTS_LINE_WIDTH) return single;
const indent = indentOf(text, offset);
const lines = members.map((member) => `${indent} ${member};`);
return `{${lineEnding}${lines.join(lineEnding)}${lineEnding}${indent}}`;
}
interface ContractPlan {
readonly path: string;
readonly snapshot: boolean;
readonly oldHash: string;
readonly newHash: string;
readonly content: string;
readonly valueObjectNamespaces: ReadonlyMap<string, string>;
}
interface MigrationOps {
readonly path: string;
/** `undefined` when the package has no `ops.json` or it is not valid JSON. */
readonly ops: Json | undefined;
readonly unreadable: boolean;
}
function readOps(migrationDir: string): MigrationOps {
const path = join(migrationDir, 'ops.json');
if (!existsSync(path)) return { path, ops: undefined, unreadable: false };
const ops = parseJson(readFileSync(path, 'utf8'));
return { path, ops, unreadable: ops === undefined };
}
function familyOf(contract: JsonRecord): Family | undefined {
const family = contract['targetFamily'];
return family === 'sql' || family === 'mongo' ? family : undefined;
}
function summary(
root: string,
contractsFound: number,
files: number,
directories: number,
hashes: ReadonlyMap<string, string>,
): readonly string[] {
if (contractsFound === 0)
return [`No SQL or MongoDB contract was found under ${root}; nothing changed.`];
if (files === 0 && directories === 0)
return ['No contract keeps value objects in the domain plane; nothing changed.'];
const renamed = count(directories, 'snapshot directory', 'snapshot directories');
const changes = `Rewrote ${count(files, 'file', 'files')} and renamed ${renamed}.`;
if (hashes.size === 0) return [changes];
return [
`${changes} Storage hashes changed (old -> new):`,
...[...hashes]
.sort(([a], [b]) => compareCodeUnits(a, b))
.map(([oldHash, newHash]) => ` ${oldHash} -> ${newHash}`),
];
}
const JSON_FILES_THAT_MUST_PARSE = new Set(['contract.json', 'migration.json']);
function main({
root,
errors,
}: {
readonly root: string;
readonly errors: readonly string[];
}): number {
if (errors.length > 0) {
process.stderr.write(`${errors.join('\n')}\n`);
return 1;
}
const display = (path: string): string => relative(root, path).split(sep).join('/');
const files = listFiles(root);
if (files.unreadable.length > 0) {
process.stderr.write(
`${files.unreadable
.map(
({ dir, code }) =>
`${display(dir)}: cannot read the directory (${code}); give the script read access to it, or move it out of the project, and run the script again`,
)
.join('\n')}\n`,
);
return 1;
}
const plans: ContractPlan[] = [];
const argumentWrites = new Map<string, string>();
const stops: string[] = [];
let contractsFound = 0;
for (const path of files.json) {
const text = readFileSync(path, 'utf8');
const contract = parseJson(text);
if (
contract === undefined &&
(JSON_FILES_THAT_MUST_PARSE.has(basename(path)) || files.refs.includes(path))
) {
stops.push(`${display(path)}: not valid JSON`);
continue;
}
if (!isRecord(contract) || typeof contract['target'] !== 'string') continue;
const family = familyOf(contract);
if (family === undefined || !isRecord(contract['storage'])) continue;
contractsFound++;
const rewrite = rewriteContract(contract, family);
const snapshot = isSnapshotContract(path);
if (!rewrite.changed) {
const dtsPath = dtsPathOf(path, snapshot);
if (existsSync(dtsPath)) {
const dts = readFileSync(dtsPath, 'utf8');
const next = withStorageNamespacesArgument(dts);
if (next !== dts) argumentWrites.set(dtsPath, next);
}
continue;
}
const stored = contract['storage']['storageHash'];
const oldHash = snapshot ? basename(dirname(path)) : typeof stored === 'string' ? stored : '';
const computed = computeStorageHash(family, contract);
if (computed !== oldHash || (typeof stored === 'string' && stored !== oldHash)) {
stops.push(
`${display(path)}: the stored storage hash ${oldHash} does not match the contract's content, which hashes to ${computed}; restore the file from version control, or re-emit it, and run the script again`,
);
continue;
}
if (rewrite.problems.length > 0) {
for (const problem of rewrite.problems)
stops.push(
`${display(path)}: ${problem}; restore the file from version control, or re-emit it, and run the script again`,
);
continue;
}
const newHash = computeStorageHash(family, rewrite.contract);
plans.push({
path,
snapshot,
oldHash,
newHash,
content: writtenForm(withStorageHash(rewrite.contract, newHash), contract, text),
valueObjectNamespaces: rewrite.valueObjectNamespaces,
});
}
const hashes = new Map<string, string>();
for (const plan of [...plans].sort((a, b) => Number(b.snapshot) - Number(a.snapshot)))
if (plan.oldHash !== plan.newHash && !hashes.has(plan.oldHash))
hashes.set(plan.oldHash, plan.newHash);
const newDirectories = new Map<string, Map<string, string>>();
const directorySources = new Map<string, string>();
const sourceDirectories = new Set<string>();
const removals = new Set<string>();
const snapshotRewrites: [string, string][] = [];
const referenceWrites = new Map<string, string>();
const contractWrites = new Map<string, string>();
for (const plan of plans) {
const dtsPath = dtsPathOf(plan.path, plan.snapshot);
const dtsHashes = new Map([...hashes, [plan.oldHash, plan.newHash]]);
const dts = existsSync(dtsPath)
? rewriteDts(readFileSync(dtsPath, 'utf8'), plan.valueObjectNamespaces, dtsHashes)
: undefined;
if (!plan.snapshot) {
if (dts !== undefined) referenceWrites.set(dtsPath, dts);
contractWrites.set(plan.path, plan.content);
continue;
}
const oldDir = dirname(plan.path);
const newDir = join(dirname(oldDir), plan.newHash);
sourceDirectories.add(oldDir);
if (newDir === oldDir) {
if (dts !== undefined) snapshotRewrites.push([dtsPath, dts]);
snapshotRewrites.push([plan.path, plan.content]);
continue;
}
removals.add(oldDir);
const content = readDirectory(oldDir);
content.set('contract.json', plan.content);
if (dts !== undefined) content.set('contract.d.ts', dts);
const planned = newDirectories.get(newDir);
if (planned !== undefined && !sameFiles(planned, content)) {
stops.push(`${display(newDir)}: snapshot directory already exists with different content`);
continue;
}
newDirectories.set(newDir, content);
directorySources.set(newDir, oldDir);
}
for (const [dir, content] of newDirectories) {
if (sourceDirectories.has(dir) || !existsSync(dir)) continue;
if (sameFiles(readDirectory(dir), content)) newDirectories.delete(dir);
else stops.push(`${display(dir)}: snapshot directory already exists with different content`);
}
for (const path of files.migrationJson) {
const text = readFileSync(path, 'utf8');
const metadata = parseJson(text);
if (!isRecord(metadata)) continue;
const from = metadata['from'];
const to = metadata['to'];
const nextFrom = typeof from === 'string' ? (hashes.get(from) ?? from) : from;
const nextTo = typeof to === 'string' ? (hashes.get(to) ?? to) : to;
if (nextFrom === from && nextTo === to) continue;
const { path: opsPath, ops, unreadable } = readOps(dirname(path));
if (unreadable) {
stops.push(`${display(opsPath)}: not valid JSON`);
continue;
}
const oldMigrationHash = metadata['migrationHash'];
let next = replaceQuotedHashes(text, hashes);
if (
ops !== undefined &&
typeof oldMigrationHash === 'string' &&
nextFrom !== undefined &&
nextTo !== undefined
) {
const migrationHash = computeMigrationHash({ ...metadata, from: nextFrom, to: nextTo }, ops);
next = next.replace(`"${oldMigrationHash}"`, `"${migrationHash}"`);
}
referenceWrites.set(path, next);
}
for (const path of files.refs) {
const text = readFileSync(path, 'utf8');
const next = replaceQuotedHashes(text, hashes);
if (next !== text) referenceWrites.set(path, next);
}
for (const path of files.migrationTs) {
const text = readFileSync(path, 'utf8');
const next = replaceHashTokens(text, hashes);
if (next !== text) referenceWrites.set(path, next);
}
if (stops.length > 0) {
process.stderr.write(`${[...new Set(stops)].join('\n')}\n`);
return 1;
}
try {
for (const path of files.leftovers) rmSync(path, { recursive: true, force: true });
for (const [dir, content] of newDirectories)
writeDirectory(dir, content, directorySources.get(dir) ?? dir);
for (const [path, text] of snapshotRewrites) writeFile(path, text);
for (const [path, text] of referenceWrites) writeFile(path, text);
for (const [path, text] of contractWrites) writeFile(path, text);
for (const [path, text] of argumentWrites) writeFile(path, text);
for (const dir of removals) removeDirectory(dir);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(
`${message}; the upgrade stopped partway, run the script again to finish it\n`,
);
return 1;
}
const rewrittenFiles =
snapshotRewrites.length + referenceWrites.size + contractWrites.size + argumentWrites.size;
const lines = summary(root, contractsFound, rewrittenFiles, removals.size, hashes);
process.stdout.write(`${lines.join('\n')}\n`);
return 0;
}
function parseRoot(args: readonly string[]): { readonly root: string; readonly errors: string[] } {
const errors = args.filter((arg) => arg.startsWith('-')).map((arg) => `${arg}: unknown option`);
const positional = args.filter((arg) => !arg.startsWith('-'));
if (positional.length > 1) errors.push(`expected one project root, got ${positional.length}`);
const root = resolve(positional[0] ?? process.cwd());
if (errors.length === 0 && !isDirectory(root)) errors.push(`${root}: not a directory`);
if (errors.length > 0) errors.push('Usage: node value-objects-in-storage.ts [project-root]');
return { root, errors };
}
process.exitCode = main(parseRoot(process.argv.slice(2)));
upgrading/app/upgrades/8.0.0-rc.22-to-8.0.0-rc.23/instructions.md
---
from: "8.0.0-rc.22"
to: "8.0.0-rc.23"
changes:
- id: temporal-polyfill-is-a-peer-dependency-of-mysql
summary: |
`temporal-polyfill` is now a required peer dependency of `@prisma/orm-mysql` and `@prisma/orm-target-mysql`. The MySQL control plane, such as the `prisma` commands, imports it, because MySQL now has the convenience date and time types, and `DateTime(as: temporal)`, `Date(as: temporal)` and `Time(as: temporal)` read `Temporal` values. npm, pnpm and bun install it automatically. A project that installs with Yarn must add `temporal-polyfill` (`^1.0.4`) to its own dependencies.
detection:
glob: "**/yarn.lock"
contains:
- "@prisma/orm-mysql@"
- "@prisma/orm-target-mysql@"
anyMatch: true
- id: mysql-datetime-writes-are-checked
summary: |
A MySQL `DateTime` field now refuses to write a year before 1000 or after 9999 with `RUNTIME.ENCODE_FAILED`, before the value reaches MySQL. Reads are unchanged, and no migration is needed.
detection:
glob: "**/package.json"
contains:
- "@prisma/orm-mysql"
- "@prisma/orm-target-mysql"
anyMatch: true
- id: temporal-polyfill-is-a-peer-dependency-of-sqlite
summary: |
`temporal-polyfill` is now a required peer dependency of `@prisma/orm-sqlite` and `@prisma/orm-target-sqlite`. The SQLite control plane, such as the `prisma` commands and the Vite plugin, imports it, because SQLite now has the convenience date and time types, and `DateTime(as: temporal)`, `Date(as: temporal)` and `Time(as: temporal)` read `Temporal` values. npm, pnpm and bun install it automatically. A project that installs with Yarn must add `temporal-polyfill` (`^1.0.4`) to its own dependencies.
detection:
glob: "**/yarn.lock"
contains:
- "@prisma/orm-sqlite@"
- "@prisma/orm-target-sqlite@"
anyMatch: true
- id: psl-renamed-postgres-scalar-types
summary: |
In a Prisma 8 schema on PostgreSQL, `SmallInt`, `BigIntNumber`, `Numeric`, `UnboundedInt`, `Real`, `VarChar` and `VarBit` are deprecated and report `PSL_DEPRECATED_SCALAR_NAME` as a warning. Write the convenience call that gives the same type instead: `Int(storage: int2)`, `BigInt(as: number)`, `Decimal(precision: p, scale: s)`, `BigInt(storage: numeric)`, `Float(storage: float4)` and `String(storage: varchar, length: n)`; `VarBit` has no convenience type and becomes `Varbit`. The contract does not change. A Prisma 7 schema, which has a `datasource` block, is not affected.
detection:
glob: "**/*.prisma"
matches:
- '(?<![\s\S])(?![\s\S]*(?<![\w$])datasource[ \t]+\w+[ \t]*\{)[\s\S]*\n[ \t]+[A-Za-z_]\w*[ \t]+(?:=[ \t]*)?(?:SmallInt|BigIntNumber|Numeric|UnboundedInt|Real|VarChar|VarBit)(?![\w$])'
- id: ts-renamed-postgres-scalar-helpers
summary: |
In a TypeScript contract on PostgreSQL, `type.SmallInt()`, `type.BigIntNumber()`, `type.Numeric(...)`, `type.UnboundedInt()`, `type.Real()`, `type.VarChar(...)` and `type.VarBit(...)` are deprecated. They still compile and give no warning, and they are removed together with the deprecated schema names. Call the convenience helper that gives the same type instead, such as `type.String({ storage: 'varchar', length: 64 })` for `type.VarChar(64)`, or `type.Varbit(n)` for `type.VarBit(n)`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$])type\.(?:SmallInt|BigIntNumber|Numeric|UnboundedInt|Real|VarChar|VarBit)\('
- id: ts-renamed-postgres-column-helpers
summary: |
The PostgreSQL column helpers `int8Column` and `numericColumn` are renamed to `int8BigIntColumn` and `numericStringColumn`, after the explicit types they give (`Int8BigInt` and `NumericString`). The old names are removed. The contract does not change.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'import[ \t]+(?:type[ \t]+)?\{[^}]*(?<![\w$])(?:int8Column|numericColumn)(?![\w$])[^}]*\}[ \t]*from[ \t]*[''"](?:@prisma/orm-postgres/adapter/column-types|@internal/adapter-postgres/column-types)[''"]'
- id: pack-extension-found-on-search-path
summary: |
The pgvector, PostGIS, ParadeDB and citext packs declare their extension in the unbound namespace, so `db sign` and `db verify` accept the extension in any schema on the connection's search path, such as Supabase's `extensions`. Each pack has a new head and one new migration with no operations. A project that composes one of these packs runs `prisma contract emit`, which writes the pack's new migration under `migrations/<pack>/`, and then `prisma db migrate`, which applies it without changing the database.
detection:
glob: "**/prisma.config.{ts,mts,cts,js,mjs,cjs}"
matches:
- '@prisma/orm-extension-(pgvector|postgis|paradedb)/control|@prisma/orm-postgres-contrib/citext/control'
- id: remove-pack-extension-declaration
summary: |
An application contract may not declare an extension that a composed pack declares. `contract emit` refuses such a PSL declaration with `PSL_ENTITY_DECLARED_BY_PACK`, and `migration plan` and `db update` refuse it as a conflict. Remove the application's own `extension vector {}`, `extension postgis {}`, `extension pg_search {}` or `extension citext {}` block, or its `extension(...)` handle, when the project composes the pack that declares it.
detection:
glob: "**/*.{prisma,ts,mts,cts}"
matches:
- 'extension\s+(vector|postgis|pg_search|citext)\s*\{'
- 'extension\(\s*[''"](vector|postgis|pg_search|citext)[''"]'
- id: mongo-contracts-record-index-names
summary: |
Every index of a MongoDB contract records the name MongoDB stores it under, so every MongoDB contract with an index gets a new storage hash. An index written without a name is named after its keys and directions, such as `email_1`, which is the name earlier releases gave it in the database, so no database changes. A contract without index names is rejected with `CONTRACT.VALIDATION_FAILED`. Run `prisma contract emit`, run the colocated script, which names the indexes of the contract snapshots under `migrations/snapshots/`, then run `prisma db sign` or plan a migration, which has no operations.
detection:
glob: "**/*.json"
matches:
- '"kind"\s*:\s*"mongo-index"(?!\s*,\s*"name")'
script: ./scripts/mongo-index-names/mongo-index-names.ts
- id: mongo-unnamed-indexes-with-one-name-refused
summary: |
MongoDB PSL and TypeScript `defineContract` refuse two indexes of one collection that get one name. Two indexes written without a name on the same keys and directions, such as a unique and a plain index on `email`, a sparse and a plain one, or one `@@index([title])` on each of two variants of one base, are both named `email_1` or `title_1`. MongoDB could never hold both under that name, so no migration could create them. PSL reports `PSL_INVALID_INDEX` at the second index and TypeScript throws `CONTRACT.NAME_DUPLICATE`. Name one of them with `map:` in PSL or the index option `name` in TypeScript.
detection:
glob: "**/*.{prisma,ts,mts,cts}"
matches:
- '@@base\([\s\S]*@@(?:index|unique|textIndex)\('
- '\bindex\(\s*\{[^}]*\}\s*,\s*\{[^}]*\b(?:sparse|partialFilterExpression|collation|expireAfterSeconds)\b'
- id: mongo-prisma6-index-names
summary: |
A contract read from a Prisma 6 MongoDB schema with `prisma6Schema()` names each index as Prisma 6 named it: by `map:` (or `name:` on `@@index`), otherwise `<collection>_<fields>_key` for a unique index and `_idx` for others, such as `User_email_key`. `db verify` now checks these names against the database. The reader refuses what Prisma 6 refuses: two indexes of one model with one name (`PSL.PRISMA6_MONGO_INDEX_NAME_DUPLICATE`), and an empty `map` or one longer than 127 bytes (`PSL.PRISMA6_MONGO_INDEX_NAME_INVALID`).
detection:
glob: "**/*.prisma"
matches:
- '(?<![\s\S])(?=[\s\S]*@db\.ObjectId\b)[\s\S]*(?:@unique\b|@@(?:unique|index|fulltext)\()'
- id: mongo-index-operations-by-name
summary: |
A MongoDB migration creates and drops each index by its name. `prisma migration plan` writes the name into `migration.ts`, as in `createIndex("users", [{ field: "email", direction: 1 }], { name: "User_email_key", unique: true })` and `dropIndex("users", [{ field: "email", direction: 1 }], { name: "User_email_key", unique: true })`. The operation id is `index.<collection>.<create|drop>(<name>)`, such as `index.users.create(email_1)`, where it was `index.users.create(email:1)`. A `migration.ts` written by an earlier release keeps working: an index without a name is named after its keys, as before.
detection:
glob: "**/*.{ts,mts,cts,js,mjs,cjs,json,sh,yml,yaml}"
matches:
- 'index\.[\w$-]+\.(?:create|drop)\([^)]*:'
- id: mongo-prisma6-indexes-as-prisma6-reads-them
summary: |
A Prisma 6 MongoDB schema read by `prisma6Schema()` no longer has its indexes merged. Two indexes of one model with the same fields and the same written sort, of any kind (`@unique`, `@@unique`, `@@index`, `@@fulltext`), are refused with `PSL.PRISMA6_MONGO_INDEX_ALREADY_EXISTS`, as Prisma 6 refuses them. A unique and a plain index on the same keys that Prisma 6 accepts, such as `@unique` beside `@@index([email(sort: Asc)])`, are both kept, so the contract describes the database Prisma 6 built and gets a new storage hash. Prisma 8 PSL and TypeScript `defineContract` still merge identical and redundant indexes; their contracts do not change.
detection:
glob: "**/*.prisma"
matches:
- '(?<![\s\S])(?=[\s\S]*@db\.ObjectId\b)[\s\S]*(?:@unique\b|@@(?:unique|index|fulltext)\()'
- id: mongo-client-takes-extensions
summary: |
`mongo()` and `mongoStatic()` from `@prisma/orm-mongo` take an `extensions` option, as `postgres()` does, and refuse a contract that requires an extension pack they are not given, with `RUNTIME.MISSING_EXTENSION_PACK`. A MongoDB project whose `prisma.config.ts` composes extension packs passes each pack's runtime descriptor to `mongo({ extensions })` and `mongoStatic({ extensions })`.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx}"
matches:
- '(?<![\s\S])(?=[\s\S]*@prisma/orm-mongo/config)[\s\S]*[{,]\s{0,100}extensions\s{0,20}[:,}]'
- id: mongo-contract-requires-target
summary: |
A MongoDB `contract.json` must have `"target"`, as a SQL contract must. `contractFromJson`, `mongo()` and `mongoStatic()` refuse a contract without it with `CONTRACT.VALIDATION_FAILED`. Every contract `prisma contract emit` writes has it, so only a hand-written or hand-edited contract is affected.
detection:
glob: "**/*.json"
matches:
- '(?<![\s\S])(?![\s\S]*"target"\s*:)[\s\S]*"targetFamily"\s*:\s*"mongo"'
- id: supabase-one-binding-input
summary: |
`supabase()` now checks its database input the way `postgres()` does, when it is called (see the change `postgres-checks-database-input` for the checks both share). Passing both `url` and `pg` rejects with `RUNTIME.BINDING_INVALID`, where `pg` used to win silently; pass only one. An empty `url` used to make `pg` connect with the `PG*` environment variables or to localhost; it now rejects with `RUNTIME.BINDING_INVALID`, so check that the variable you pass as `url` is set. A `url` that cannot be parsed or is not `postgres://` / `postgresql://` also rejects with `RUNTIME.BINDING_INVALID`, instead of failing on the first query. `pg: null` and a `pg` value that is neither a `pg.Pool` nor a `pg.Client` now reject with `RUNTIME.BINDING_INVALID` instead of `SUPABASE.CONFIG_INVALID`. A `pg.Client` still rejects with `SUPABASE.CONFIG_INVALID`, and its message is now "Supabase takes a pg.Pool or a url, not a pg.Client". The `RUNTIME.BINDING_INVALID` errors of `supabase()` list only the inputs it takes (`url`, a `pg.Pool`, `binding: { kind: 'url', url }` or `{ kind: 'pgPool', pool }`) and carry `meta.extension: 'supabase'`.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs}"
matches:
- '(?<![\w$.])supabase(?:<[^>]*>)?\(\s*[{A-Za-z_$]'
- 'The pg option takes a pg\.Pool'
- 'import\s*\{[^}]*(?<![\w$])supabase\s+as\s+[A-Za-z_$][^}]*\}\s*from\s*[''"]@(?:prisma/orm-|internal/)extension-supabase(?:/[^''"]*)?[''"]'
- id: postgres-checks-database-input
summary: |
`postgres()` and `supabase()` check their database input when they are called and reject a wrong one with `RUNTIME.BINDING_INVALID`, with a message that names the option, a `why` and a `fix`; `meta.receivedType` gives the value's type, never the value. `null` or a value of the wrong type as `pg`, `url` or `binding` (a `pg` or `binding` that is not an object, a `url` that is not a string) rejects; `postgres()` used to throw a `TypeError` with no error code. The check runs before the inputs are counted, so `{ url: null, pg: pool }` fails on `url`, not with "Provide one binding input"; match on the new code, and pass the option only when it has a value. A `binding` object whose `kind` is not `'url'`, `'pgPool'` or `'pgClient'` (for example `{}`, an array or a misspelt kind) rejects; `postgres()` used to accept it and throw a `TypeError` on the first query. A `binding` of kind `'url'` whose `url` is not a string, is empty, cannot be parsed or is not `postgres://` / `postgresql://` rejects; `postgres()` used to pass it to `pg`, and an empty one connected with the `PG*` environment variables or to localhost. A `binding` whose `pool` or `client` is not an object rejects; `postgres()` used to fail on the first query.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs}"
matches:
- '(?<![\w$.])(?:pg|url|binding)\s*:\s*(?:null\b|[^,}\n]*(?:\?|\|\||:)\s*null\b)'
- '(?<![\w$.])(?:postgres|supabase)(?:<[^>]*>)?\(\s*\{(?:[^()]|\((?:[^()]|\([^()]*\))*\))*?(?<![\w$])binding\s*:\s*\{[^}]*?(?<![\w$])kind\s*:\s*([''"`])(?!(?:url|pgPool|pgClient)\1)[^''"`]*\1'
- '(?<![\w$.])postgres(?:<[^>]*>)?\(\s*\{(?:[^()]|\((?:[^()]|\([^()]*\))*\))*?(?<![\w$])binding\s*:\s*\{[^}]*?(?<![\w$])kind\s*:\s*([''"`])url\1'
- id: contract-artifacts-restamp
summary: |
An extension that writes its own package version into the contracts it emits, such as the Supabase extension, now writes 8.0.0-rc.23. Run `contract emit` once after upgrading so the emitted `contract.json` and `contract.d.ts` match the installed extension.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.22"'
---
# 8.0.0-rc.22 → 8.0.0-rc.23 — User upgrade instructions
## Order of the steps
Do the steps in this order, and skip a step that does not apply to the project:
1. Upgrade the Prisma packages. A project that installs with Yarn and uses MySQL or SQLite adds `temporal-polyfill` to its dependencies (`temporal-polyfill-is-a-peer-dependency-of-mysql`, `temporal-polyfill-is-a-peer-dependency-of-sqlite`).
2. Change the contract sources. On PostgreSQL, rewrite the deprecated scalar type names and helpers (`psl-renamed-postgres-scalar-types`, `ts-renamed-postgres-scalar-helpers`, `ts-renamed-postgres-column-helpers`). Remove the application's own declaration of an extension that a composed pack declares (`remove-pack-extension-declaration`). On MongoDB, name one of two indexes that get one name (`mongo-unnamed-indexes-with-one-name-refused`).
3. Change MongoDB and PostgreSQL application code: pass the extension packs to `mongo()` and `mongoStatic()` (`mongo-client-takes-extensions`), and give `supabase()` and `postgres()` one valid database input (`supabase-one-binding-input`, `postgres-checks-database-input`).
4. Run `prisma contract emit` and commit what it writes, including the files under `migrations/`. Fix what it refuses for a Prisma 6 MongoDB schema (`mongo-prisma6-indexes-as-prisma6-reads-them`, `mongo-prisma6-index-names`). This step also covers `mongo-contract-requires-target`, `pack-extension-found-on-search-path` and `contract-artifacts-restamp`.
5. On MongoDB, run the colocated script to name the indexes of the contract snapshots under `migrations/snapshots/` (`mongo-contracts-record-index-names`).
6. Bring each database up to date: run `prisma db migrate` for a pgvector, PostGIS, ParadeDB or citext pack (`pack-extension-found-on-search-path`). On MongoDB, run `prisma db sign`, or plan and apply the migration with no operations (`mongo-contracts-record-index-names`). For a Prisma 6 MongoDB schema, run `prisma db verify` before you sign (`mongo-prisma6-index-names`).
7. Change the remaining code: scripts and tests that match a MongoDB index operation id (`mongo-index-operations-by-name`), and MySQL writes of a year before 1000 or after 9999 (`mysql-datetime-writes-are-checked`). Check a SQLite `date('now')` default that `db verify` reports (`temporal-polyfill-is-a-peer-dependency-of-sqlite`).
## MySQL: `temporal-polyfill` is a peer dependency, and `DateTime` writes are range-checked
### `temporal-polyfill-is-a-peer-dependency-of-mysql`
For each Yarn project that `detection` matches, add `"temporal-polyfill": "^1.0.4"` to `dependencies` in the `package.json` that depends on `@prisma/orm-mysql` or `@prisma/orm-target-mysql`, then reinstall. Without it, `prisma contract emit` and the other commands fail because Node.js cannot find the package `temporal-polyfill`.
A project that already depends on `temporal-polyfill` with a range inside `^1.0.4` needs no change.
An existing MySQL contract needs no other change: `DateTime` with no arguments is the type `DateTime` was before, renamed `DatetimeJsDate(3)`, and emits the same contract.
### `mysql-datetime-writes-are-checked`
Nothing to change in code. A `Date` before the year 1000 or after 9999 written to a MySQL `DateTime` field is now refused with `RUNTIME.ENCODE_FAILED`. MySQL documents only those years for `datetime`. If the application writes such dates, keep them in range or store them in a `String` field.
## SQLite: `temporal-polyfill` is a peer dependency
### `temporal-polyfill-is-a-peer-dependency-of-sqlite`
For each Yarn project that `detection` matches, add `"temporal-polyfill": "^1.0.4"` to `dependencies` in the `package.json` that depends on `@prisma/orm-sqlite` or `@prisma/orm-target-sqlite`, then reinstall. Without it, `prisma contract emit` and the other commands fail because Node.js cannot find the package `temporal-polyfill`.
A project that already depends on `temporal-polyfill` with a range inside `^1.0.4` needs no change.
An existing SQLite contract needs no other change: `DateTime` with no arguments is the type `DateTime` was before, renamed `DatetimeJsDate`, and emits the same contract.
One case reads differently. `db verify` used to read a database default of `date('now')` or `CURRENT_DATE` as `now()`, so it matched a field written `@default(now())` of any type. It now reads it as the current date, which only a `Date @default(now())` field writes, and a `now()` on a `DateTime` or `String` field means the current instant. If `db verify` now reports such a column, write the field as `Date @default(now())` when it holds a calendar date, or as ``@default(sql`date('now')`)`` to keep its type. Do not run `db update` first: it would change the column's default to the current instant, and new rows would get the instant instead of the date.
## PostgreSQL's integer, decimal, float and text types are convenience types
On PostgreSQL, `Int`, `BigInt`, `Float`, `Decimal`, `String`, `Boolean` and `Bytes` are now convenience types, as `DateTime` already is. Each is named for what a field means, and its optional `storage` argument (and `as`, for `BigInt`) chooses an explicit type named after the database type. Written bare, each gives the same type as before, so schemas that use these names need no change.
The explicit types took the database's names. Seven old names stay for one release line as deprecated aliases: they give the same codec and the same contract, and `prisma contract emit` and the editor report a warning where one is written.
### `psl-renamed-postgres-scalar-types`
Rewrite each field or `types {}` alias written with an old name, keeping its other arguments and attributes:
| Before | After | Explicit type it gives |
| --- | --- | --- |
| `SmallInt` | `Int(storage: int2)` | `Int2` |
| `BigIntNumber` | `BigInt(as: number)` | `Int8Number` |
| `Numeric` | `Decimal` | `NumericString` |
| `Numeric(p)` | `Decimal(precision: p)` | `NumericString(p)` |
| `Numeric(p, s)` | `Decimal(precision: p, scale: s)` | `NumericString(p, s)` |
| `UnboundedInt` | `BigInt(storage: numeric)` | `NumericBigInt` |
| `Real` | `Float(storage: float4)` | `Float4` |
| `VarChar` | `String(storage: varchar)` | `Varchar` |
| `VarChar(n)` | `String(storage: varchar, length: n)` | `Varchar(n)` |
| `VarBit` | `Varbit` | `Varbit` |
| `VarBit(n)` | `Varbit(n)` | `Varbit(n)` |
`VarBit` has no convenience type, so its replacement is the explicit type. For the others, writing the explicit type in the last column instead is equally correct; it is the same type, and both emit the same contract. The warning names the explicit type because it is the exact replacement for the old name.
```prisma
model Account {
id Int @id
visits Int(storage: int2) // was SmallInt
rank BigInt(as: number) // was BigIntNumber
price Decimal(precision: 10, scale: 2) // was Numeric(10, 2)
code String(storage: varchar, length: 12) // was VarChar(12)
}
```
Rewrite only Prisma 8 schemas whose config uses the PostgreSQL target, including the `contract.prisma` copies in migration folders. The detection cannot tell which target a schema is for, so it also matches SQLite's `BigIntNumber` and MySQL's `VarChar`; a match in a project whose `prisma.config.ts` uses the SQLite or MySQL target needs no change. Update comments and documentation that name the old types the same way. Re-emit the contract with `prisma contract emit`: it is byte for byte the same as before.
### `ts-renamed-postgres-scalar-helpers`
Rewrite only in contracts on PostgreSQL:
```ts
field.column(type.Int({ storage: 'int2' })); // was type.SmallInt()
field.column(type.BigInt({ as: 'number' })); // was type.BigIntNumber()
field.column(type.Decimal({ precision: 10, scale: 2 })); // was type.Numeric(10, 2)
field.column(type.BigInt({ storage: 'numeric' })); // was type.UnboundedInt()
field.column(type.Float({ storage: 'float4' })); // was type.Real()
field.column(type.String({ storage: 'varchar', length: 64 })); // was type.VarChar(64)
field.column(type.Varbit(16)); // was type.VarBit(16)
```
The explicit helpers `type.Int2()`, `type.Int8Number()`, `type.NumericString(10, 2)`, `type.NumericBigInt()`, `type.Float4()` and `type.Varchar(64)` give the same types.
The old helpers still compile, and nothing warns where they are called. They are removed in the same release as the deprecated schema names, so rewrite them now.
### `ts-renamed-postgres-column-helpers`
The detection matches a file that imports `int8Column` or `numericColumn` from `@prisma/orm-postgres/adapter/column-types` or `@internal/adapter-postgres/column-types`. In that file, rename the import and every use of the imported name. Each column helper is named after the explicit type it gives:
```ts
import { int8BigIntColumn, numericStringColumn } from '@prisma/orm-postgres/adapter/column-types';
field.column(int8BigIntColumn); // was int8Column
field.column(numericStringColumn(10, 2)); // was numericColumn(10, 2)
```
## Pack extensions are found through the search path
The pgvector pack's contract now declares `vector` in the unbound namespace, the same as this PSL:
```prisma
namespace unbound {
extension vector {}
}
```
### `pack-extension-found-on-search-path`
1. Run `prisma contract emit`. It writes the pack's new migration and head under `migrations/<pack>/`.
2. Run `prisma db migrate` against each database. The new migration has no operations.
A database where the extension is in a schema that is not on the connection's search path now fails `db verify`, and the message names the schema and the search path. Add that schema to the search path of the role the application connects as, or reinstall the extension in a schema on the search path.
### `remove-pack-extension-declaration`
If the project composes the pgvector, PostGIS, ParadeDB or citext pack, delete the application's own declaration of that pack's extension, then run `prisma contract emit`. The pack installs the extension. Keep the declaration of any other extension.
## MongoDB indexes carry their names
Every index of a MongoDB contract now records its name, and migrations create and drop each index by that name. The Prisma 6 reader names indexes as Prisma 6 did, and it no longer merges them. Do the changes in this section with one `prisma contract emit` and one `prisma db sign` (or one migration) per project.
### `mongo-contracts-record-index-names`
For this schema:
```prisma
model Post {
id ObjectId @id @map("_id")
authorId ObjectId
@@index([authorId])
@@map("posts")
}
```
each index in `contract.json` gains its name:
```diff
{
"keys": [{ "direction": 1, "field": "authorId" }],
- "kind": "mongo-index"
+ "kind": "mongo-index",
+ "name": "authorId_1"
}
```
`authorId_1` is the name the index already has in the database, so the database needs no change. Only the storage hash changes.
1. Commit your work first, so the changes can be reviewed and undone with git.
2. Run `prisma contract emit` and commit the regenerated `contract.json` and `contract.d.ts`.
3. Run the colocated script from the project root:
```sh
node <path-to-this-guide>/scripts/mongo-index-names/mongo-index-names.ts
```
Node 24 or later runs the TypeScript script directly. It reads and writes files only. It rewrites every `*.json` file under the root that is a MongoDB contract with an index without a name, skipping `node_modules`, `.git`, `dist` and `build`, and the `contract.d.ts` next to it (`<name>.d.ts` next to `<name>.json`). These are the contract snapshots under `migrations/snapshots/<hash>/`, which no command regenerates. Each snapshot index takes the name that its own project's emitted contract gives the index with the same collection, keys and options, so a project whose contract comes from `prisma6Schema()` gets Prisma 6's names, such as `User_email_key`. A snapshot's project is the nearest folder, going up from the folder that holds its `migrations` folder, that holds an emitted contract, and the walk stops at the first folder with a `package.json`, the project root, so a name never comes from another project of a monorepo; contracts under a `snapshots` folder are never used as a source. When no emitted contract of the project has the index, or two of them name it differently, the index gets the name earlier releases gave it in the database, its keys and directions such as `email_1`, and the script prints one line saying so for each such index; check those names against the database with `prisma db verify` after the upgrade. The script writes each file in the layout `contract emit` writes it. The snapshot directory names stay as they are. A contract that step 2 emitted already has names and is left alone. The script prints each file it rewrote, or that nothing changed. It does not follow symbolic links: it names each one it finds and exits 1, and if a link holds migration snapshots, run the script again on the folder it points to. If it cannot match the index types of a `contract.d.ts` with its `contract.json`, it says so and exits 1; add `readonly name: '<name>'` beside `readonly kind: 'mongo-index'` in each index type by hand.
To rewrite a file by hand instead, add `"name"` to each object whose `kind` is `mongo-index`: the name the emitted contract gives the same index, or else its keys and directions, such as `"name": "createdAt_-1_authorId_1"` for the keys `createdAt` descending and `authorId` ascending. Add the matching type in `contract.d.ts`.
4. Bring each database to the new storage hash:
- For a project kept with `db init` or `db update`, run `prisma db sign`.
- For a project with migrations, run `prisma migration plan`. The migration it plans has no operations; apply it with `prisma db migrate`.
For a disposable example or test database whose migration chain is regenerated from its schemas, regenerate the chain with its own tooling instead of steps 3 and 4, and recreate the database. In this repository, `pnpm fixtures:emit` re-emits every contract and regenerates the example migrations, writing their snapshots under the new storage hashes; delete the snapshot directories no migration names any more. This is not a procedure for a production database: keep applied migrations and their snapshots, and follow steps 3 and 4.
A database that a Prisma 6 schema read with `prisma6Schema()` describes holds the names Prisma 6 gave its indexes, not the names after their keys; see `mongo-prisma6-index-names`.
### `mongo-unnamed-indexes-with-one-name-refused`
This schema is now refused, because both indexes would be named `email_1`:
```prisma
model User {
id ObjectId @id @map("_id")
email String @unique
@@index([email], sparse: true)
@@map("users")
}
```
Name one of them:
```prisma
@@index([email], sparse: true, map: "email_sparse")
```
In TypeScript:
```ts
indexes: [index({ email: 1 }, { unique: true }), index({ email: 1 }, { sparse: true, name: 'email_sparse' })],
```
No database holds such a pair yet: earlier releases planned both under `email_1`, and MongoDB refused the second. The index you leave unnamed keeps its name, so its database index stays; the migration creates the one you named.
Two indexes of variants of one base on the same field are refused the same way; name each, such as `@@index([title], map: "bug_title")` on `Bug` and `@@index([title], map: "feature_title")` on `Feature`.
Two identical indexes with different names are refused too, because MongoDB cannot hold an index twice: PSL reports `PSL_INVALID_INDEX` at the second, and TypeScript throws `CONTRACT.NAME_DUPLICATE`. Remove one, or give both the same name, which merges them.
### `mongo-prisma6-indexes-as-prisma6-reads-them`
`prisma6Schema()` describes the database Prisma 6 built, so it no longer merges indexes. This schema is now refused, because Prisma 6 refuses it too:
```prisma
model User {
id String @id @default(auto()) @map("_id") @db.ObjectId
email String @unique
@@index([email])
}
```
1. Run `prisma contract emit` for each project that reads a Prisma 6 schema with `prisma6Schema()`.
2. If it reports `PSL.PRISMA6_MONGO_INDEX_ALREADY_EXISTS`, the schema has two indexes that Prisma 6 refuses, or two that Prisma 6 accepts but MongoDB cannot hold, because they have the same keys and options in one collection. The message names both. If one is unique and the other is not, remove the one that is not, so the unique constraint stays. Otherwise remove either.
3. If the storage hash changed, the schema writes a unique and a plain index on the same keys with different written sorts, and the contract now holds both, as the database does. Run `prisma db sign` again.
### `mongo-prisma6-index-names`
For this Prisma 6 schema:
```prisma
model User {
id String @id @default(auto()) @map("_id") @db.ObjectId
email String @unique
@@index([email(sort: Asc)])
}
```
the contract names the indexes `User_email_key` and `User_email_idx`, as Prisma 6 `db push` named them. Run `prisma contract emit` and `prisma db verify`; a database Prisma 6 built verifies. Then run `prisma db sign` for the new storage hash. Run the script of `mongo-contracts-record-index-names` after the emit: it gives each index of your migration snapshots the Prisma 6 name the emitted contract gives it, so the next `prisma migration plan` does not drop and recreate them.
If the emit reports `PSL.PRISMA6_MONGO_INDEX_NAME_DUPLICATE`, two indexes of one model get one name, which Prisma 6 refuses too, such as `@@index([name])` beside `@@index([name(sort: Asc)])`, both named `User_name_idx`. Give one a name with `map:`. If it reports `PSL.PRISMA6_MONGO_INDEX_NAME_INVALID`, a `map:` is empty or longer than 127 bytes; give the index a name Prisma 6 accepts.
If `db verify` reports an index whose name differs, the database holds that index under another name than Prisma 6 would give it. Write that name with `map:`, so the contract states what the database holds.
### `mongo-index-operations-by-name`
A `migration.ts` that `prisma migration plan` writes now names each index:
```ts
// before
createIndex('users', [{ field: 'email', direction: 1 }], { unique: true }),
dropIndex('users', [{ field: 'legacy', direction: 1 }]),
// after
createIndex('users', [{ field: 'email', direction: 1 }], { name: 'email_1', unique: true }),
dropIndex('users', [{ field: 'legacy', direction: 1 }], { name: 'legacy_1', unique: true }),
```
`dropIndex`'s options must list every option the index has, as `migration plan` writes them: its precheck requires an index with that name, those keys and exactly those options, so `dropIndex('users', keys, { name: 'legacy_1' })` fails for a unique index. A `dropIndex` without options checks the keys only.
Existing migration files need no change: a call without `name` uses the name after the keys, as before, and a `dropIndex` without options checks the index's keys only, as before. An applied migration's `ops.json` is applied as written.
A script or test that matches an operation id, such as `index.users.create(email:1)`, matches the name instead: `index.users.create(email_1)`.
## `mongo()` checks the extension packs a contract requires
A contract built with extension packs records them, and `mongo()` and `mongoStatic()` now refuse it unless they are given the same packs:
```typescript
import mongo from '@prisma/orm-mongo/runtime';
import myPack from 'my-mongo-pack/runtime';
export const db = mongo({ contract, url, extensions: [myPack] });
```
### `mongo-client-takes-extensions`
1. For each extension pack in `extensions` in `prisma.config.ts`, import the pack's runtime descriptor, usually from its `/runtime` entry.
2. Pass the descriptors as `extensions` to every `mongo(...)` and `mongoStatic(...)` call that takes this project's contract.
### `mongo-contract-requires-target`
Re-emit the contract with `prisma contract emit`, or add `"target": "mongo"` beside `"targetFamily": "mongo"` in a contract written by hand.
## `supabase()` and `postgres()` check their database input when they are called
### `supabase-one-binding-input`
```ts
// Before: pg was used and url ignored
const db = await supabase({ contract, url, pg: pool, jwtSecret });
// After
const db = await supabase({ contract, pg: pool, jwtSecret });
```
In each `supabase(...)` call, keep exactly one of `url`, `pg` and `binding`. If you import `supabase` under another name (`import { supabase as createDb } from ...`), check the calls of that name. If you passed both `url` and `pg`, keep `pg`: that is the one the client used.
If `url` comes from an environment variable, make sure it is set. An empty `url` used to connect to whatever the `PG*` environment variables or localhost pointed at; it now fails at startup.
Code or tests that match a `pg` refusal on `SUPABASE.CONFIG_INVALID` keep working for a `pg.Client`. Update a match on the old message "The pg option takes a pg.Pool" to "Supabase takes a pg.Pool or a url, not a pg.Client". A `pg` value that is neither a pool nor a client now fails with `RUNTIME.BINDING_INVALID`.
`supabase()`'s errors about its input list only what it takes, and their `meta.extension` is `'supabase'`.
### `postgres-checks-database-input`
These checks apply to `postgres()` and to `supabase()`.
`null` as `pg`, `url` or `binding` now fails with `RUNTIME.BINDING_INVALID` on `postgres()` and on `supabase()`, even when another input is given beside it: `{ url: null, pg: pool }` fails on `url`. Code such as `pg: config.pool ?? null`, `url: value || null`, `url: ready ? value : null` or `url: ready ? null : value` should leave the option out instead:
```ts
// Before
postgres({ contract, url: process.env['DATABASE_URL'] ?? null });
// After
const url = process.env['DATABASE_URL'];
postgres({ contract, ...(url === undefined ? {} : { url }) });
```
A `binding` whose `kind` is not `'url'`, `'pgPool'` or `'pgClient'` now fails with `RUNTIME.BINDING_INVALID` when `postgres()` or `supabase()` is called, not on the first query. Correct the kind, for example `{ kind: 'pgpool', pool }` becomes `{ kind: 'pgPool', pool }`.
A `binding` of kind `'url'` now has its `url` checked like a top-level `url`: it must be a non-empty `postgres://` or `postgresql://` string. An empty one used to connect to whatever the `PG*` environment variables or localhost pointed at. A `binding` whose `pool` or `client` is missing or not an object now fails when `postgres()` is called, not on the first query.
## `contract-artifacts-restamp`
For every `contract.json` matched by `detection`, run the project's emit command (`prisma contract emit`, or the project's `contract:emit` script) once after upgrading. This entry accounts for the extension's embedded `version` moving to `8.0.0-rc.23`; any other difference in the emitted files comes from an earlier entry in this guide.
upgrading/app/upgrades/8.0.0-rc.22-to-8.0.0-rc.23/scripts/mongo-index-names/mongo-index-names.ts
#!/usr/bin/env node
// Names every index of a MongoDB contract written before contracts recorded index names.
// A contract snapshot's index takes the name its own project's emitted contract gives the index with the same
// collection, keys and options. A snapshot's project is the nearest folder, going up from the folder that holds
// its `migrations` folder, that holds an emitted contract (a MongoDB contract outside every `snapshots` folder).
// The walk stops at the first folder with a `package.json`, the project root, so no name comes from another project.
// When no emitted contract of the project has the index, or two of them name it differently, the index gets the
// name earlier releases gave it in the database, its keys and directions such as `email_1`, and the script says so.
// Usage: node mongo-index-names.ts [<root>]
import { existsSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';
const SKIPPED_DIRECTORIES = new Set(['node_modules', '.git', 'dist', 'build']);
const TEMPORARY_SUFFIX = '.mongo-index-names-tmp';
type JsonObject = { [key: string]: unknown };
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function walk(dir: string, found: string[], links: string[]): void {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
if (!SKIPPED_DIRECTORIES.has(entry.name)) walk(path, found, links);
continue;
}
if (entry.isSymbolicLink()) {
links.push(path);
continue;
}
if (!entry.isFile()) continue;
if (entry.name.endsWith(TEMPORARY_SUFFIX)) {
rmSync(path);
continue;
}
if (entry.name.endsWith('.json')) found.push(path);
}
}
function isUnnamedIndex(value: unknown): value is JsonObject {
return (
isObject(value) &&
value['kind'] === 'mongo-index' &&
Array.isArray(value['keys']) &&
!('name' in value)
);
}
function defaultName(keys: unknown): string {
return (Array.isArray(keys) ? keys : [])
.map((key) => (isObject(key) ? `${String(key['field'])}_${String(key['direction'])}` : ''))
.join('_');
}
/** The index with its name, its keys in sorted order as `contract emit` writes them. */
function named(index: JsonObject, name: string): JsonObject {
const entries = [...Object.entries(index), ['name', name] as const];
return Object.fromEntries(entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
}
/** An index's collection, keys and options, as a string: what the database tells two indexes apart by. */
function contentKey(collection: string, index: JsonObject): string {
const { name: _name, kind: _kind, ...content } = index;
return canonicalJson([collection, content]);
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
if (!isObject(value)) return JSON.stringify(value);
const entries = Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`);
return `{${entries.join(',')}}`;
}
/** Each index of a contract, with the collection it belongs to. */
function indexesOf(
value: unknown,
found: { readonly collection: string; readonly index: JsonObject }[],
collection = '',
): void {
if (Array.isArray(value)) {
for (const item of value) indexesOf(item, found, collection);
return;
}
if (!isObject(value)) return;
if (value['kind'] === 'mongo-index') {
found.push({ collection, index: value });
return;
}
for (const [key, item] of Object.entries(value)) {
if (key === 'collection' && isObject(item)) {
for (const [name, entry] of Object.entries(item)) indexesOf(entry, found, name);
} else {
indexesOf(item, found, collection);
}
}
}
/** The names emitted contracts give indexes, by collection and content; each name is listed once. */
type EmittedNames = Map<string, readonly string[]>;
function recordEmittedNames(contract: JsonObject, emitted: EmittedNames): void {
const found: { readonly collection: string; readonly index: JsonObject }[] = [];
indexesOf(contract, found);
for (const { collection, index } of found) {
const name = index['name'];
if (typeof name !== 'string') continue;
const key = contentKey(collection, index);
const names = emitted.get(key) ?? [];
if (!names.includes(name)) emitted.set(key, [...names, name]);
}
}
type NameFor = (collection: string, index: JsonObject) => string;
/** The value with each unnamed index named, and the names given, in the order the JSON lists the indexes. */
function nameIndexes(value: unknown, names: string[], nameFor: NameFor, collection = ''): unknown {
if (Array.isArray(value))
return value.map((item) => nameIndexes(item, names, nameFor, collection));
if (!isObject(value)) return value;
if (isUnnamedIndex(value)) {
const name = nameFor(collection, value);
names.push(name);
return named(value, name);
}
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
key === 'collection' && isObject(item)
? Object.fromEntries(
Object.entries(item).map(([name, entry]) => [
name,
nameIndexes(entry, names, nameFor, name),
]),
)
: nameIndexes(item, names, nameFor, collection),
]),
);
}
function hasUnnamedIndex(contract: JsonObject): boolean {
const found: { readonly collection: string; readonly index: JsonObject }[] = [];
indexesOf(contract, found);
return found.some(({ index }) => !('name' in index));
}
function sortKeys(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortKeys);
if (!isObject(value)) return value;
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, sortKeys(value[key])]),
);
}
/** The contract in the layout it had: one line with sorted keys, as snapshots are written, or indented by two. */
function serializeLike(original: string, value: unknown): string {
const trailingNewline = original.endsWith('\n') ? '\n' : '';
const singleLine = !original.trimEnd().includes('\n');
return `${singleLine ? JSON.stringify(sortKeys(value)) : JSON.stringify(value, null, 2)}${trailingNewline}`;
}
const INDEX_KIND = /readonly kind: (['"])mongo-index\1;?(\n[ \t]*)?/g;
/** The declarations with `readonly name` added after each index's `readonly kind`, or undefined when their indexes are not the JSON's. */
function nameIndexTypes(declarations: string, names: readonly string[]): string | undefined {
const matches = [...declarations.matchAll(INDEX_KIND)];
if (matches.length !== names.length) return undefined;
let rewritten = '';
let from = 0;
matches.forEach((match, position) => {
const quote = match[1];
const name = `readonly name: ${quote}${names[position]}${quote}`;
const lineBreak = match[2];
const kindEnd = match.index + match[0].length - (lineBreak?.length ?? 0);
if (lineBreak === undefined) {
const kind = declarations.slice(match.index, kindEnd).replace(/;$/, '');
rewritten += `${declarations.slice(from, match.index)}${kind}; ${name}${match[0].endsWith(';') ? ';' : ''}`;
} else {
const indent = declarations.slice(
declarations.lastIndexOf('\n', match.index) + 1,
match.index,
);
rewritten += `${declarations.slice(from, kindEnd)}\n${indent}${name};`;
}
from = kindEnd;
});
return rewritten + declarations.slice(from);
}
function writeFile(path: string, content: string): void {
const temporary = `${path}${TEMPORARY_SUFFIX}`;
writeFileSync(temporary, content);
renameSync(temporary, path);
}
function isSnapshot(path: string): boolean {
return path.split(sep).includes('snapshots');
}
/** The folder a snapshot's project is in: the one that holds the `migrations` folder its `snapshots` folder is in. */
function migrationsParentOf(snapshotPath: string): string {
const parts = snapshotPath.split(sep);
const snapshots = parts.lastIndexOf('snapshots');
return parts.slice(0, Math.max(snapshots - 1, 1)).join(sep) || sep;
}
function keysText(index: JsonObject): string {
const keys = Array.isArray(index['keys']) ? index['keys'] : [];
return keys
.map((key) => (isObject(key) ? `${String(key['field'])}: ${String(key['direction'])}` : ''))
.join(', ');
}
function main(): number {
const root = resolve(process.argv[2] ?? '.');
const files: string[] = [];
const links: string[] = [];
walk(root, files, links);
const contracts: {
readonly path: string;
readonly original: string;
readonly contract: JsonObject;
}[] = [];
for (const path of files.sort()) {
const original = readFileSync(path, 'utf8');
let contract: unknown;
try {
contract = JSON.parse(original);
} catch {
continue;
}
if (isObject(contract) && contract['targetFamily'] === 'mongo') {
contracts.push({ path, original, contract });
}
}
const emittedContracts = contracts.filter(
({ path, contract }) => !isSnapshot(path) && !hasUnnamedIndex(contract),
);
/** The names the emitted contracts of the nearest folder up from `start` that holds any give their indexes, going no higher than the first folder with a `package.json`. */
const projectNames = (
start: string,
): { readonly folder: string; readonly names: EmittedNames } => {
for (let folder = start; ; folder = dirname(folder)) {
const inFolder = emittedContracts.filter(({ path }) => path.startsWith(`${folder}${sep}`));
const projectRoot = existsSync(join(folder, 'package.json'));
if (inFolder.length > 0 || projectRoot || folder === root || folder === dirname(folder)) {
const names: EmittedNames = new Map();
for (const { contract } of inFolder) recordEmittedNames(contract, names);
return { folder, names };
}
}
};
const changed: string[] = [];
const notes: string[] = [];
const warnings: string[] = links.map(
(link) =>
`${relative(root, link)}: is a symbolic link, so the script did not read it; if it holds migration snapshots, run the script on the folder it points to.`,
);
for (const { path, original, contract } of contracts) {
const project = projectNames(isSnapshot(path) ? migrationsParentOf(path) : dirname(path));
const nameFor: NameFor = (collection, index) => {
const fallback = defaultName(index['keys']);
const emitted = project.names.get(contentKey(collection, index)) ?? [];
const [only] = emitted;
if (emitted.length === 1 && only !== undefined) return only;
const where = `${relative(root, path)}: the index on { ${keysText(index)} } in collection "${collection}"`;
notes.push(
emitted.length === 0
? `${where} is in no emitted contract under ${relative(root, project.folder) || '.'}; named ${fallback} after its keys.`
: `${where} is named ${emitted.join(' and ')} by the emitted contracts under ${relative(root, project.folder) || '.'}; named ${fallback} after its keys.`,
);
return fallback;
};
const names: string[] = [];
const rewritten = nameIndexes(contract, names, nameFor);
if (names.length === 0) continue;
writeFile(path, serializeLike(original, rewritten));
changed.push(path);
const declarationsPath = path.replace(/\.json$/, '.d.ts');
let declarations: string;
try {
declarations = readFileSync(declarationsPath, 'utf8');
} catch {
continue;
}
const namedDeclarations = nameIndexTypes(declarations, names);
if (namedDeclarations === undefined) {
warnings.push(
`${relative(root, declarationsPath)}: could not match its index types with ${relative(root, path)}. Add readonly name: '<name>' to each index type by hand.`,
);
continue;
}
writeFile(declarationsPath, namedDeclarations);
changed.push(declarationsPath);
}
for (const path of changed) console.log(relative(root, path));
for (const note of notes) console.log(note);
for (const warning of warnings) console.error(warning);
console.log(
changed.length === 0
? 'Nothing to change.'
: `Rewrote ${changed.length} file${changed.length === 1 ? '' : 's'}.`,
);
return warnings.length === 0 ? 0 : 1;
}
process.exitCode = main();
upgrading/app/upgrades/8.0.0-rc.3-to-8.0.0-rc.4/instructions.md
---
from: "8.0.0-rc.3"
to: "8.0.0-rc.4"
changes:
- id: prisma-config-hard-cut-and-top-level-commands
summary: |
The deprecated fallbacks are gone: the CLI no longer reads
`prisma-next.config.ts`, no longer accepts the flat (un-nested) config shape, and the
`prisma-next` command no longer exists. The unified CLI (`@prisma/cli`, installed from
the `next` dist-tag; its binary is currently `prisma-cli`) runs the ORM commands at the
top level — `contract emit`, `db init`, `migration plan`, `migrate` — with only `init`
under the `orm` group (`orm init`), and the only config it reads is `prisma.config.ts`
in the engine envelope shape.
1. Rename `prisma-next.config.ts` to `prisma.config.ts` if you have not already.
2. Rewrite the export to the envelope shape. Old flat shape:
`import { defineConfig } from '@prisma/orm-postgres/config';`
`export default defineConfig({ contract: '…', db: { connection: … } });`
New shape:
`import { definePrismaConfig } from '@prisma/cli-engine';`
`import { defineConfig as ormConfig } from '@prisma/orm-postgres/config';`
`export default definePrismaConfig({ orm: ormConfig({ contract: '…', db: { connection: … } }) });`
The options object moves into the target helper unchanged. The same pattern applies
to `@prisma/orm-sqlite/config` and `@prisma/orm-mongo/config`.
3. If the config reads `process.env`, keep (or add) `import 'dotenv/config';` as the
first import — the loader does not read `.env` for you.
4. In `package.json`, replace the `prisma-next` devDependency with `prisma@latest`
plus `@prisma/cli-engine` at the exact version that `@prisma/cli` names in its own
dependencies, and rewrite scripts: `prisma-next <subcommand>` becomes
`prisma-cli <subcommand>` (`migration apply` becomes `migrate`; `init` alone moves
under the orm group as `prisma-cli orm init`).
5. Run `prisma-cli contract emit` to confirm the config loads and to regenerate the
artifacts (their generated-file headers change with this release).
detection:
glob: "**/prisma-next.config.ts"
- id: raw-moves-to-its-own-lane
summary: |
Whole-query raw SQL moves address: ``db.sql.raw`SELECT ...` `` becomes
``db.raw.sql`SELECT ...` ``. Everything after the template is unchanged.
`.returnsRow(spec)`, `.affectedCount()`, `.returns(codecId)`, and splicing a
row-returning statement into another template all behave as they did.
The client's `raw` property changes shape in the same move. It was a tagged template you
could call directly for an expression fragment. It is now the raw lane, an object whose
`sql` key holds the statement tag.
An author who called `db.raw` as a fragment tag has two replacements. Use `fns.raw`
inside a builder callback, which is where fragments belong. Or terminate the lane's tag
with `.returns(codecId)` for the same expression, now bound to the contract.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
regex:
# The old whole-query address, on any receiver.
- '\.sql\.raw`'
# The client tag being repurposed. `fns.raw` is a fragment call site and
# is deliberately excluded: fragments are unchanged by this release.
- '(?<!(?<![\w$])fns)\.raw`'
anyMatch: true
- id: raw-is-no-longer-a-reserved-namespace
summary: |
A storage namespace named `raw` is allowed again. 8.0.0-rc.2 and 8.0.0-rc.3 refused such a contract when
the client was built, with `ORM.NAMESPACE_RESERVED`, because the SQL surface answered
`db.sql.raw` with the raw tag. The tag has moved to `db.raw.sql`, so nothing a contract
declares can collide with it.
If you renamed a namespace to get past that error, you may rename it back:
`@@schema("raw")` is an ordinary name, reachable as `db.sql.raw.<table>`. Re-emit the
contract afterwards, then plan the rename against the database as you would any other
namespace rename. The physical schema keeps the name it has until a plan moves it.
Renaming back is optional: a namespace you renamed away stays valid.
detection:
glob: "**/*.{prisma,json}"
contains:
- "ORM.NAMESPACE_RESERVED"
- '@@schema("raw")'
anyMatch: true
- id: contract-artifacts-restamp
summary: |
The emitted `contract.json` / `contract.d.ts` embed the toolchain version, which moves
to 8.0.0-rc.4. Run `contract emit` once after upgrading so the emitted artifacts match
the installed toolchain. This applies even to projects whose config needed no
migration — the restamp is independent of the config changes above.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.3"'
---
# 8.0.0-rc.3 → 8.0.0-rc.4 — User upgrade instructions
## `raw-moves-to-its-own-lane`
Whole-query raw SQL has its own front door. The tag that sat inside the namespace map is now a
lane on the client:
```ts
// Before
const plan = db.sql.raw`SELECT id, email FROM users WHERE id = ${1}`
.returnsRow({ id: users.columns.id, email: users.columns.email })
.build();
// After
const plan = db.raw.sql`SELECT id, email FROM users WHERE id = ${1}`
.returnsRow({ id: users.columns.id, email: users.columns.email })
.build();
```
Everything after the template is unchanged: the terminators, the row specs they take, the plans
they build, and splicing a row-returning statement into another template.
The client's `raw` property changes shape in the same move. It was the expression tag you could
call directly; it is now the lane, whose `sql` key holds the statement tag:
```ts
// Before: `raw` is a tag
const upper = db.raw`UPPER(${email})`.returns('pg/text@1');
// After, inside a builder callback — where fragments belong
const rows = db.sql.public.users
.select((f, fns) => ({ upper: fns.raw`UPPER(${f.email})`.returns('pg/text@1') }))
.build();
// After, from the lane, for a fragment you hold on its own
const upper = db.raw.sql`UPPER(${email})`.returns('pg/text@1');
```
The detector looks for the old address and for `raw` used as a tag. It skips the receiver `fns`
exactly, including `x.fns.raw`, because that is a fragment call site and needs no change. A
receiver that merely ends in those letters, such as `myfns.raw`, still matches. So does a
builder callback that names its functions object something else. Check whether the receiver is
a client before you change anything.
## `raw-is-no-longer-a-reserved-namespace`
8.0.0-rc.2 and 8.0.0-rc.3 refused a contract whose storage declared a namespace named `raw`, because that was
the key the SQL surface answered with the raw tag:
```text
ORM.NAMESPACE_RESERVED: The SQL surface exposes the raw statement tag as "db.raw", so a storage
namespace named "raw" cannot be reached through it. Rename the namespace in the schema.
```
That constraint is gone. `db.sql` is a namespace map and nothing else, and the lane is composed
by the client rather than derived from your contract, so the two cannot collide.
If you renamed a namespace to get past the error, you may rename it back:
```prisma
model Event {
id String @id
@@schema("raw")
}
```
Its tables are then reachable as `db.sql.raw.<table>`, like any other namespace. Re-emit the
contract, and plan the rename against the database as you would any other namespace rename —
the physical schema carries the old name until a plan moves it. Renaming back is optional; the
name you moved to stays valid.
upgrading/app/upgrades/8.0.0-rc.4-to-8.0.0-rc.5/instructions.md
---
from: "8.0.0-rc.4"
to: "8.0.0-rc.5"
changes:
- id: attach-pg-client-error-listener
summary: |
Attach an 'error' listener to any pg `Client` or `Pool` your own code constructs outside the Prisma runtime. node-postgres emits 'error' on the pool or client when an idle connection drops (database restart, pooler timeout, network blip); with no listener Node treats it as an uncaught exception and kills the process. Starting at rc.5 every pool and client the Prisma runtime creates or receives — including a pool you pass via the `pg:` binding — gets a listener automatically, so this only applies to pg handles your code uses directly (health checks, side-channel observers, hand-rolled scripts).
detection:
glob: "**/*.{ts,tsx}"
contains:
- "new Client("
- "new pg.Client("
- "new Pool("
- "new pg.Pool("
anyMatch: true
- id: contract-artifacts-restamp
summary: |
The emitted `contract.json` / `contract.d.ts` embed the toolchain version, which moves
to 8.0.0-rc.5. Run `contract emit` once after upgrading so the emitted artifacts match
the installed toolchain. The restamp is independent of the other changes in this
release.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.4"'
---
# 8.0.0-rc.4 → 8.0.0-rc.5 — User upgrade instructions
## `attach-pg-client-error-listener`
Walk every file matched by `detection.glob`. For each `pg.Client` or `pg.Pool` your code constructs and uses directly (not one handed to `postgres({ pg: ... })` / `supabase({ pg: ... })` — the runtime covers those since rc.5), attach an `'error'` listener right after construction, before `connect()`:
```ts
const client = new pg.Client({ connectionString });
client.on('error', () => {});
await client.connect();
```
A no-op listener is enough: connect and query failures still reject their own promises, so nothing real is masked — the listener only stops a dropped idle connection from becoming an uncaught exception. If the handle is long-lived and you have a logging channel, log the error instead of discarding it.
Note that a surrounding `try/catch` does **not** cover this case — the `'error'` event is emitted on the client object asynchronously, outside any promise chain the `catch` can see.
upgrading/app/upgrades/8.0.0-rc.5-to-8.0.0-rc.6/instructions.md
---
from: "8.0.0-rc.5"
to: "8.0.0-rc.6"
changes:
- id: postgres-temporal-representations
summary: |
PostgreSQL temporal columns no longer read as JavaScript `Date`. Each of `date`,
`timestamp(p)`, `timestamptz(p)` and `time(p)` now offers two explicit representations,
and five codecs were removed with no compatibility aliases:
| Retired | Replace with (Temporal) | Replace with (text) |
| --- | --- | --- |
| `pg/date@1` | `Date` → `pg/date-temporal@1` (`Temporal.PlainDate`) | `DateString` |
| `pg/timestamp@1` | `Timestamp(p)` → `pg/timestamp-temporal@1` (`Temporal.PlainDateTime`) | `TimestampString(p)` |
| `pg/timestamptz@1` | `Timestamptz(p)` → `pg/timestamptz-temporal@1` (`Temporal.Instant`) | `TimestamptzString(p)` |
| `pg/time@1` | `Time(p)` → `pg/time-temporal@1` (`Temporal.PlainTime`) | `TimeString(p)` |
| `sql/timestamp@1` (`field.timestamp()`) | `Timestamptz(p)` or `field.temporal.timestamptz(p)` | `TimestamptzString(p)` |
1. **Decide a representation per column.** The bare PSL spellings (`Date`, `Timestamp`,
`Timestamptz`, `Time`) keep working and now select the Temporal-backed codec. If a
column's values should stay text — because your code treats them as strings, or
because they include values Temporal cannot denote (`infinity`, non-ISO `DateStyle`
output, years beyond ±271821) — rename the type to its `*String` spelling. Note
`pg/time@1` already handed back a `string`: a `time` column whose value you treat as
text needs `TimeString(p)`, not `Time(p)`.
2. **Replace `field.timestamp()`.** The generic cross-target helper and its
`sql/timestamp@1` codec are gone. On PostgreSQL use `field.temporal.timestamptz(p)`
(or `field.temporal.timestamptzString(p)`), or the bare `Timestamptz(p)` type.
3. **Repoint any codec id you wrote by hand.** Ids appear in raw-lane return
declarations (``db.raw.sql`now()`.returns('pg/timestamptz@1')``), in
`prepare({ id })`, and in hand-built contracts. A retired id no longer resolves and
fails contract validation rather than degrading silently.
4. **Re-emit every contract.** `prisma contract emit` rewrites `contract.json` and
`contract.d.ts` together; the generated application types are where the new
representation becomes visible. Commit the regenerated artifacts. A contract emitted
before this release references a codec the registry cannot resolve and is rejected at
runtime.
5. **Provide a Temporal implementation if you kept any Temporal-backed column.** Prisma
bundles no polyfill. Where the runtime has no native `Temporal`, install a global one
in your entry point before any query runs — `import 'temporal-polyfill/full/global';`.
Take `full/global`, not `global`: the default build omits non-ISO calendars and its
published types resolve to `export {}`, so TypeScript will not see the namespace.
**Every read** of a Temporal-backed column needs it, and so does any insert into a
table carrying `temporal.updatedAt()`, whose generated value is a `Temporal.Instant`.
Without it the operation fails with `RUNTIME.TEMPORAL_UNAVAILABLE`, which names the
codec and recommends the matching `*String` type. A contract whose temporal columns
are all `*String` needs no Temporal anywhere.
6. **Update application code that consumed a `Date`.** `Temporal.Instant.from()` parses
**only an ISO string carrying an offset** — it throws on a `Date`, on an
epoch-millisecond number, and on a date-time string with no offset. Convert by source:
| You have | Use |
| --- | --- |
| a `Date` | `instant = date.toTemporalInstant()`, or `Temporal.Instant.fromEpochMilliseconds(date.getTime())` |
| epoch milliseconds | `Temporal.Instant.fromEpochMilliseconds(ms)` |
| an ISO string **with** an offset (`…Z`, `…+02:00`) | `Temporal.Instant.from(text)` |
| an ISO string **without** an offset | pick the zone it meant: `Temporal.PlainDateTime.from(text).toZonedDateTime('UTC').toInstant()` |
| "now" | `Temporal.Now.instant()` |
Match the column, not just the type name: a `date` column takes a
`Temporal.PlainDate` (`Temporal.PlainDate.from('2024-01-01')`), a `timestamp` column a
`Temporal.PlainDateTime`, a `time` column a `Temporal.PlainTime`. Only `timestamptz`
takes an `Instant`.
`Temporal.Instant.compare(a, b)` replaces `a.getTime() - b.getTime()`, but **both
operands must already be `Instant`s** — it throws on a `Date`. Values read back from
the ORM already are; convert anything you brought from elsewhere first.
In tests, be careful with **`toMatchObject`**. A Temporal value has no own enumerable
properties — every accessor lives on the prototype — so a subset matcher finds nothing
to compare and passes for *any* value of the same type. `toEqual` is not affected
(Vitest compares these correctly), but `toMatchObject` will silently stop checking the
timestamp. Where you need a subset match, compare `toString()` or use the type's own
`equals` / `compare`.
detection:
glob: "**/*.{ts,mts,cts,prisma,json}"
regex:
- "pg/(date|timestamp|timestamptz|time)@1"
- "sql/timestamp@1"
- "field\\.timestamp\\("
anyMatch: true
- id: literal-default-needs-the-string-spelling
summary: |
A literal `@default(...)` on a Temporal-backed temporal column cannot be emitted today.
The default value is encoded through the column's codec while the contract is being
emitted, inside the CLI's own process, and stock Node ships no `Temporal`. So
```prisma
occurredAt Timestamptz @default("2024-01-01T00:00:00Z")
```
fails `prisma contract emit` with `CONTRACT.SOURCE_LOAD_FAILED` and "this runtime has no
global Temporal implementation".
Use the string spelling for a column that needs a literal default:
```prisma
occurredAt TimestamptzString @default("2024-01-01T00:00:00Z")
```
Function defaults are unaffected — `@default(now())` lowers to a PostgreSQL `now()`
storage default, never passes through a codec, and works on either representation.
detection:
glob: "**/*.prisma"
regex:
- "(Date|Timestamp|Timestamptz|Time)(\\([0-9]+\\))?\\s+@default\\(\""
anyMatch: true
- id: orm-init-no-longer-installs-agent-skills
summary: |
`prisma orm init` no longer installs agent skills: the GitHub fetch (`npx skills add`)
is removed and nothing inside `orm init` replaces it. Agent-skills setup belongs to the
family-level `prisma init` command. The `--skip-skills` flag is removed with the
behavior it opted out of. Existing projects keep whatever skills they already have;
only scripts that invoke `orm init` and expect it to deliver skills (or pass
`--skip-skills`) need to change.
detection:
glob: "**/*.{sh,yml,yaml,json,md}"
regex:
- '\borm\s+init\b'
anyMatch: true
- id: contract-artifacts-restamp
summary: |
The emitted `contract.json` / `contract.d.ts` embed the toolchain version, which moves
to 8.0.0-rc.6. Run `contract emit` once after upgrading so the emitted artifacts match
the installed toolchain. The restamp is independent of the other changes in this
release.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.5"'
---
# 8.0.0-rc.5 → 8.0.0-rc.6 — User upgrade instructions
# PostgreSQL temporal representations
Both entries are schema-and-code renames; there is no codemod, because the choice between the
Temporal and the string representation is per column and only you know which values a column
holds. Work through the schema first, re-emit, then let the generated `contract.d.ts` types drive
the application-code changes — the compiler will point at every site whose value type moved.
Two behaviours are worth knowing before you choose. A Temporal codec rejects what `Temporal` cannot
denote — `infinity`, years beyond roughly ±271821, and output rendered under a non-ISO `DateStyle` —
naming the `*String` type that reads the same column losslessly. And a nested read returns the same
text a flat read does, because temporal expressions are cast to `text` before PostgreSQL builds the
JSON, which means both reflect the session `TimeZone`.
## `orm-init-no-longer-installs-agent-skills`
Nothing to change in an existing project: skills already on disk stay as they are. Walk every `orm init` invocation the detection finds (a mention in prose that is not a command needs no action):
- An invocation passing `--skip-skills`: drop the flag — it no longer exists and the invocation fails with an unknown-flag error. The behavior it opted out of is gone, so the flagless invocation is the equivalent.
- A plain invocation with no flag: the scaffold itself is unchanged, but it no longer installs agent skills. If the script (or the person following it) relied on that, run `prisma init` in the project afterwards; that command owns skills setup now. If skills were incidental, no change is needed.
## `contract-artifacts-restamp`
For every `contract.json` matched by `detection`, run the project's emit command (`prisma contract emit`, or the project's `contract:emit` script) once after upgrading. The only expected diff is the embedded `version` moving to `8.0.0-rc.6`.
upgrading/app/upgrades/8.0.0-rc.6-to-8.0.0-rc.7/instructions.md
---
from: "8.0.0-rc.6"
to: "8.0.0-rc.7"
changes:
- id: rename-orm-pagination-methods
summary: |
Rename ORM collection pagination calls from `.take(n)` to `.limit(n)` and from `.skip(n)` to `.offset(n)`. This applies to SQL and Mongo ORM collections, including relation refinements and grouped SQL collections. Do not rename Mongo query-builder `.skip(n)` calls: that lower-level API continues to mirror the `$skip` pipeline stage.
- id: contract-artifacts-restamp
summary: |
The emitted `contract.json` / `contract.d.ts` embed the toolchain version, which moves
to 8.0.0-rc.7. Run `contract emit` once after upgrading so the emitted artifacts match
the installed toolchain. The toolchain also re-released against
`@prisma/cli-engine@0.2.3` (a CLI-side change with no ORM-facing surface).
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.6"'
---
# 8.0.0-rc.6 → 8.0.0-rc.7 — User upgrade instructions
## `rename-orm-pagination-methods`
Find calls on Prisma 8 ORM collections and apply these translations:
- `.take(n)` → `.limit(n)`
- `.skip(n)` → `.offset(n)`
Apply the same translation inside `include(...)` refinement callbacks, `combine(...)` branches, and after SQL ORM `groupBy(...)`. Leave Mongo query-builder chains that start from `db.query.from(...)` unchanged: their `.limit(...)` and `.skip(...)` methods name Mongo aggregation pipeline stages rather than the ORM collection API.
## `contract-artifacts-restamp`
For every `contract.json` matched by `detection`, run the project's emit command (`prisma contract emit`, or the project's `contract:emit` script) once after upgrading. The only expected diff beyond the pagination rename is the embedded `version` moving to `8.0.0-rc.7`.
upgrading/app/upgrades/8.0.0-rc.7-to-8.0.0-rc.8/instructions.md
---
from: "8.0.0-rc.7"
to: "8.0.0-rc.8"
changes:
- id: engine-pin-moves-to-0-3-0
summary: |
The toolchain now peers `@prisma/cli-engine@0.3.0` (up from 0.2.3). Projects assembled
by the unified `prisma` CLI resolve the engine automatically. A project that pins
`@prisma/cli-engine` itself must move the pin to `0.3.0`. The engine now declares
`@prisma/management-api-sdk` as a peer dependency (`^1.55.0`) instead of a regular
dependency; the `prisma` CLI shell supplies it at runtime, so only a project that runs
the engine outside the CLI shell needs to install the SDK itself.
detection:
glob: "**/package.json"
contains:
- '"@prisma/cli-engine": "0.2.3"'
- id: contract-artifacts-restamp
summary: |
The emitted `contract.json` / `contract.d.ts` embed the toolchain version, which moves
to 8.0.0-rc.8. Run `contract emit` once after upgrading so the emitted artifacts match
the installed toolchain.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.7"'
---
# 8.0.0-rc.7 → 8.0.0-rc.8 — User upgrade instructions
## `engine-pin-moves-to-0-3-0`
For every `package.json` matched by `detection`, change the `@prisma/cli-engine` version from `0.2.3` to `0.3.0` and reinstall. If the project runs the engine outside the unified `prisma` CLI shell (rare), also install `@prisma/management-api-sdk` at a version satisfying `^1.55.0` — the engine now declares it as a peer dependency and no longer bundles it.
## `contract-artifacts-restamp`
For every `contract.json` matched by `detection`, run the project's emit command (`prisma contract emit`, or the project's `contract:emit` script) once after upgrading. The only expected diff is the embedded `version` moving to `8.0.0-rc.8`.
upgrading/app/upgrades/8.0.0-rc.8-to-8.0.0-rc.9/instructions.md
---
from: "8.0.0-rc.8"
to: "8.0.0-rc.9"
changes:
- id: reemit-supabase-extension-version
summary: |
Re-emit contracts using the Supabase extension so their extension version metadata matches the installed rc.9 package.
- id: mongo-index-projections-use-native-lists
summary: |
MongoDB wildcard index `include` and `exclude` projections in Prisma schema files must use native PSL lists instead of encoded list strings.
detection:
glob: "**/*.prisma"
matches:
- '\b(?:include|exclude)\s*:\s*"\[[^"\r\n]*\]"'
- id: mongo-text-index-weights-use-native-records
summary: |
MongoDB text-index `weights` in Prisma schema files must use native PSL records instead of encoded JSON strings.
detection:
glob: "**/*.prisma"
matches:
- '\bweights\s*:\s*"\{\s*(?:\\.|[^"\\])*\}"'
- id: explicit-enum-sort-values
summary: |
PostgreSQL text-backed enums now sort by stored value rather than declaration order; encode semantic ranks explicitly when ordering matters.
- id: namespace-qualify-sql-orm-filter-types
summary: |
SQL ORM reusable filter types now require the domain namespace before the model name: `<Contract, Namespace, Model>`.
- id: mongo-unlowered-attributes-are-rejected
summary: |
MongoDB Prisma schema files must not carry `@default(...)`, `@updatedAt`, or `@db.*` attributes; the Mongo interpreter never lowered them and now rejects them.
detection:
glob: "**/*.prisma"
matches:
- '@(?:default\(|updatedAt\b|db\.)'
---
# 8.0.0-rc.8 → 8.0.0-rc.9 — User upgrade instructions
## `reemit-supabase-extension-version`
After updating the Supabase extension to rc.9, run `prisma contract emit` for each contract that uses it and commit the regenerated `contract.json` and `contract.d.ts`. Verify that the Supabase extension's version metadata is `8.0.0-rc.9` in both artifacts; do not edit generated contracts by hand.
## `mongo-index-projections-use-native-lists`
For every Prisma schema matched by `detection`, replace encoded projection strings with native PSL lists. For example, change `include: "[a, b]"` to `include: ["a", "b"]` and `exclude: "[a]"` to `exclude: ["a"]`. Decode each string's fields in their existing order and preserve whether the argument is `include` or `exclude`.
## `mongo-text-index-weights-use-native-records`
For every Prisma schema matched by `detection`, replace the encoded JSON string passed to `weights` with a native PSL record. For example, change `weights: "{\"title\": 10}"` to `weights: { title: 10 }`, preserving every field name and numeric weight.
## `explicit-enum-sort-values`
Review queries that order text-backed enum columns and rely on declaration order. To retain semantic ranking, use an explicit ranking expression or numeric enum values. For example, a priority enum can use `{ codecId: 'pg/int4@1', nativeType: 'int4' }` with `member('Low', 0)`, `member('High', 1)`, and `member('Urgent', 2)`. In PSL, use `@@type("pg/int4@1")` with `Low = 0`, `High = 1`, and `Urgent = 2`. Update literal writes and type annotations to the numeric values, then re-emit the contract. For an existing database, create a data-preserving migration mapping the old strings to their numeric ranks and update defaults and constraints; do not rewrite applied migration history. PostgreSQL native enum columns retain the database's native ordering.
## `namespace-qualify-sql-orm-filter-types`
Find TypeScript references to `ShorthandWhereFilter`, `RelationPredicate`, `RelationPredicateInput`, and `RelationFilterAccessor`. Add the model's domain namespace as the second generic argument and place the model name third. Rewrite `ShorthandWhereFilter<Contract, Model>` as `ShorthandWhereFilter<Contract, Namespace, Model>` and `ShorthandWhereFilter<Contract, Model, Namespace>` as `ShorthandWhereFilter<Contract, Namespace, Model>`. Rewrite the relation types from `<Contract, Model>` to `<Contract, Namespace, Model>`. Use the namespace facet through which the model is queried, such as `'public'` for `db.public.User`.
## `mongo-unlowered-attributes-are-rejected`
First, before editing any schema, determine whether the project targets MongoDB: its `prisma.config.ts` imports `@prisma/orm-mongo`, or its `package.json` depends on that package. If neither holds, skip this change entirely and leave every schema untouched. The `detection` pattern also matches SQL schemas, where `@default(...)` and `@db.<Type>` are supported and deleting them breaks the contract.
For every matched MongoDB schema, delete each `@default(...)`, `@updatedAt`, and `@db.<Type>` attribute from the field that carries it, leaving the field's type and its other attributes in place. For example, change `status ProductStatus @default(Active)` to `status ProductStatus` and `updatedAt DateTime @updatedAt` to `updatedAt DateTime`. The Mongo interpreter never lowered these attributes into the contract, so the emitted `contract.json` does not change; defaults and timestamps stay the responsibility of application code, as they already were. `prisma contract emit` now fails with `PSL_UNSUPPORTED_FIELD_ATTRIBUTE` while any of them remain.
upgrading/app/upgrades/8.0.0-rc.9-to-8.0.0-rc.10/instructions.md
---
from: "8.0.0-rc.9"
to: "8.0.0-rc.10"
changes:
- id: schema-header-use-prisma-8
summary: |
The schema header that marks a Prisma 8 schema is now `// use prisma-8`. The language server
still serves the old header and its Format action rewrites it; new schemas and the
inferred-schema printer write the new form. Replace `// use prisma-next`
at the top of every `.prisma` file.
detection:
glob: "**/*.prisma"
contains:
- "// use prisma-next"
- id: env-vars-drop-next-infix
summary: |
The CLI environment variables lost their `NEXT_` infix: `PRISMA_NEXT_DISABLE_TELEMETRY`,
`PRISMA_NEXT_TELEMETRY_ENDPOINT`, `PRISMA_NEXT_DEBUG`, and the rest are now
`PRISMA_DISABLE_TELEMETRY`, `PRISMA_TELEMETRY_ENDPOINT`, `PRISMA_DEBUG`, and so on. The old
`PRISMA_NEXT_DISABLE_TELEMETRY` opt-out is still honoured; the others are not. Rename them
in shell profiles, `.env` files, and CI configuration. The per-user telemetry config also moved
from `~/.config/prisma-next/` to `~/.config/prisma-8/`, so the consent prompt runs once more.
detection:
glob: "**/*"
contains:
- "PRISMA_NEXT_"
- id: primer-file-prisma-8-md
summary: |
The quick-reference primer `init` writes at the project root is now `prisma-8.md`. Rename the
existing `prisma-next.md` and update any README or agent instruction that points at it.
detection:
glob: "**/prisma-next.md"
contains:
- "#"
- id: to-one-relations-record-nullable
summary: |
Every `1:1` and `N:1` relation in `contract.json` now carries a `nullable` boolean. A
contract without it still loads, with the flag derived from the foreign-key columns (or
fields), but its `contract.d.ts` lacks the `Models` namespace. Re-run
`prisma contract emit` so the emitted `contract.json` / `contract.d.ts` match the installed
toolchain.
detection:
glob: "**/contract.json"
matches:
- '"cardinality":\s*"(?:N:1|1:1)",\s*"on":'
- id: reemit-supabase-extension-version
summary: |
Contracts that use the Supabase extension carry its package version in their extension
metadata. Re-emit them after upgrading so `contract.json` and `contract.d.ts` say
`8.0.0-rc.10` instead of `8.0.0-rc.9`.
detection:
glob: "**/contract.json"
contains:
- '"id": "supabase"'
- id: contract-dts-exports-models
summary: |
`contract.d.ts` now exports a `Models` namespace and a `models` constant that name every
model with its fields and relations. The re-emit above produces them; use them with
`Scalars` and `Shape` to name row types without a client in scope.
---
## `to-one-relations-record-nullable`
For every `contract.json` matched by `detection`, run the project's emit command (`prisma contract emit`, or the project's `contract:emit` script) once after upgrading. The emit reads the `?` on each to-one relation field in the schema and writes `"nullable": true` or `"nullable": false` next to that relation's `"cardinality"`. The emit also fails, rather than emitting, when a required relation field sits over a nullable foreign key or the reverse; fix the schema so the field's `?` matches the key's `?`, then emit again. Until the re-emit, the client and the migration tools still load the old `contract.json`: a to-one relation without the flag is treated as nullable when any of its foreign-key columns (Mongo: fields) is nullable, and as required otherwise. Migration contract snapshots written by earlier versions are never rewritten and load the same way.
## `contract-dts-exports-models`
After the emit, `import type { Models, models } from './prisma/contract'` (the project's contract path) gives `Models.<namespace>_<Model>` for every model, and `Scalars<M>` / `Shape<M, { '+': 'relation' }>` from `@prisma/orm-postgres/family-contract/types` (or the Mongo family package) derive the default row and a data structure with relations from it. Replace hand-written row types that duplicate a model's fields with these when convenient.
## `schema-header-use-prisma-8`
For every `.prisma` file matched by `detection`, replace the first-line header `// use prisma-next` with `// use prisma-8`. Nothing else in the file changes.
## `env-vars-drop-next-infix`
For every file matched by `detection`, replace the `PRISMA_NEXT_` prefix with `PRISMA_` on each environment variable name. Delete `~/.config/prisma-next/` (or `%APPDATA%\prisma-next\` on Windows); the CLI recreates its config under `prisma-8` and asks for telemetry consent once.
## `primer-file-prisma-8-md`
Rename `prisma-next.md` at the project root to `prisma-8.md`, and update any link to it in the project README or agent instructions.
## `reemit-supabase-extension-version`
For every `contract.json` matched by `detection`, run `prisma contract emit` once after upgrading the Supabase extension and commit the regenerated `contract.json` and `contract.d.ts`. The only expected change is the Supabase extension's `version` field moving from `8.0.0-rc.9` to `8.0.0-rc.10` in both files. Do not edit the generated files by hand.
upgrading/extension/upgrades/0.10-to-0.11/instructions.md
---
from: "0.10"
to: "0.11"
changes:
- id: namespace-kind-required-on-handcrafted-contract-literals
summary: Add the `kind` discriminator (`'sql-namespace'` for SQL namespaces; `'mongo-namespace'` for Mongo namespaces) to every handcrafted `Contract<{ namespaces: { ... } }>` type literal in extension test-d files and inline-typed fixtures. The framework `Namespace` interface tightened `kind` from optional to required; emitted `contract.d.ts` literals carry it automatically, but anything you write by hand will fail typecheck without it.
detection:
glob: "**/*.test-d.ts"
contains:
- "Contract<"
- "namespaces"
anyMatch: true
- id: facade-add-close-and-async-dispose
summary: |
The official Prisma 8 facades (`@internal/postgres`, `@internal/sqlite`, `@internal/mongo`) now expose `close()` and `[Symbol.asyncDispose]` so short-lived scripts can release facade-owned resources cleanly and exit instead of hanging on a live connection. Extensions that expose a facade in the same shape should add the same surface for parity, honouring the ownership rule (only close resources the facade itself constructed) and managing a terminal closed state (subsequent operations reject with a clear error). No script — manual code authoring per extension.
detection:
glob: "**/src/runtime/*.ts"
contains:
- "createRuntimeCore"
anyMatch: true
- id: mongo-close-ownership-rule
summary: |
The Mongo facade's `db.close()` is **silently breaking**. Before 0.11 it unconditionally called `await runtime.close()`, which closed the underlying `MongoClient` regardless of how the facade was constructed. From 0.11 it honours the ownership rule and closes only facade-constructed clients (from a `{ url }` binding); when the facade was constructed with `{ mongoClient }` the caller-supplied `MongoClient` is left untouched. Extensions that wrap or extend `@internal/mongo` and relied on `db.close()` disposing of a shared `MongoClient` need to either switch to a `{ url }` binding (so the facade owns the client) or close the `MongoClient` themselves.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "mongoClient"
- "@internal/mongo"
anyMatch: true
- id: insert-single-row-wrap-in-array
summary: Wrap single-row `.insert({...})` call sites in an array — `.insert([{...}])`. The single-object overload is removed; `.insert()` now exclusively accepts an array of row objects.
detection:
glob: "**/*.{ts,tsx}"
contains:
- ".insert("
anyMatch: true
- id: insert-ast-with-values-to-with-rows
summary: Replace `InsertAst.withValues(assignments)` with `InsertAst.withRows([assignments])`. The `withValues` method is removed; `withRows` accepts an array of assignment maps.
detection:
glob: "**/*.{ts,tsx}"
contains:
- ".withValues("
---
# 0.10 → 0.11 — Extension-author upgrade instructions
## `namespace-kind-required-on-handcrafted-contract-literals`
Starting at the 0.11 release, the framework `Namespace` interface tightens `kind` from optional (inherited from `IRNode`) to required. Any **handcrafted** `Contract<{ namespaces: { ... } }>` type literal — typically used in extension test-d files and inline-typed fixtures — must add `kind: 'sql-namespace'` (or `'mongo-namespace'`) to each namespace literal or TypeScript will reject the literal as not structurally assignable to `Namespace`.
This **does not** affect:
- Real `contract.d.ts` files emitted by `prisma-next contract emit`. The emitter prints `kind` automatically.
- Real `contract.json` envelopes. The runtime `kind` field is non-enumerable on namespace class instances, so it doesn't surface in the JSON on-disk envelope and the byte hash is unchanged.
- Plain-literal `Contract` values constructed at runtime (the framework only tightens the **type** declaration of `kind`; the runtime walks tolerate missing values).
It **does** affect:
- `.test-d.ts` files in extension packages that handcraft `Contract<{ namespaces: { __unbound__: { id, tables, ... } } }>` literals to assert generic-inference shapes. Each namespace literal needs `kind: 'sql-namespace'` (SQL namespaces) or `kind: 'mongo-namespace'` (Mongo namespaces) added alongside `id`.
- Any inline-typed fixture file (`test/fixtures/*.d.ts`, etc.) that mirrors what the emitter would produce for testing.
### Before 0.11
```ts
type ContractUnderTest = Contract<
SqlStorage<string> & {
readonly namespaces: {
readonly __unbound__: {
readonly id: '__unbound__';
readonly tables: {
readonly user: {
// ... columns, primaryKey, etc.
};
};
};
};
readonly storageHash: StorageHash;
},
// ...
>;
```
### Starting at 0.11
```ts
type ContractUnderTest = Contract<
SqlStorage<string> & {
readonly namespaces: {
readonly __unbound__: {
readonly id: '__unbound__';
readonly kind: 'sql-namespace'; // ← new: required
readonly tables: {
readonly user: {
// ... columns, primaryKey, etc.
};
};
};
};
readonly storageHash: StorageHash;
},
// ...
>;
```
### Mapping table
| Namespace family | Discriminator literal | Where it surfaces in handcrafted types |
|---|---|---|
| SQL (`SqlNamespace`, `SqlUnboundNamespace`) | `'sql-namespace'` | Anywhere you write `Contract<{ namespaces: { … } }>` with a SQL-style namespace |
| Mongo (`MongoNamespace`, `MongoUnboundNamespace`) | `'mongo-namespace'` | Anywhere you write `Contract<{ namespaces: { … } }>` with a Mongo-style namespace |
| Postgres (`PostgresSchema`, when handcrafted) | `'postgres-schema'` or `'postgres-unbound-schema'` | Rare in extension code; only needed if you handcraft a literal with a Postgres-specific namespace class |
### Detection
Run the matcher's grep over your extension's source:
```bash
rg --files-with-matches -t ts -e 'Contract<' -e 'namespaces' -g '**/*.test-d.ts'
```
For each match, open the file and add the `kind` literal to every namespace entry under `namespaces:`. If you have many such literals, a regex-driven mechanical pass works (e.g. find every `readonly id: '<ns-id>';` inside a `namespaces:` block and inject `readonly kind: 'sql-namespace';` immediately after).
### Why the change
The framework needs every namespace IR node to carry its family discriminator at the type level so that cross-family namespace walks (the new `elementCoordinates(storage)` surface in `@internal/framework-components/ir`) can dispatch on a known-present `kind`, not an optional one.
The runtime invariant has always held — every concrete namespace class sets `kind` non-enumerably via `Object.defineProperty(this, 'kind', { value, enumerable: false })` in its constructor. The type tightening makes the invariant honest at the consumer surface.
### What you do **not** need to change
- No `.d.ts` regeneration is needed. Run `prisma-next contract emit` only as part of your normal authoring flow; the emitter handles the `kind` field for you.
- No `contract.json` snapshot changes. The hash inputs are unchanged because `kind` is non-enumerable on namespace class instances.
- No runtime API changes. Extension factories that construct namespaces via `new SqlStorage(...)`, `new PostgresSchema(...)`, etc. are unchanged — the constructors continue to materialise `kind` non-enumerably.
## `facade-add-close-and-async-dispose`
Starting at the 0.11 release the three official facades (`postgres()`, `sqlite()`, `mongo()`) expose two new methods:
```typescript
interface ClientFacade {
// ...existing surface...
close(): Promise<void>;
[Symbol.asyncDispose](): Promise<void>;
}
```
This is the surface that lets a short-lived script (`tsx my-script.ts`) release facade-owned connection resources and exit cleanly. Without it, a `pg.Pool` (or analogous keep-alive in SQLite / Mongo) keeps Node's event loop alive and the script hangs after its last query prints.
If your extension exposes a facade in the same shape (e.g. you publish your own `postgresServerless()` or `someDriver()` factory that returns the same client object), add the equivalent surface. Three properties the surface must have:
1. **Ownership rule.** `close()` releases only the resources the facade *itself* constructed. A `{ url }` (or similar opaque-string) binding means the facade opened the connection — facade owns it, `close()` disposes it. A `{ pool }` / `{ client }` / `{ mongoClient }` (caller-supplied opaque-handle) binding means the caller owns it — `close()` leaves it untouched. The facade must capture this ownership decision at construction time and remember it.
2. **Idempotence.** `close()` can be called multiple times in a row without throwing. The second and later calls are no-ops.
3. **Terminal closed state.** After `close()` resolves, the facade is permanently locked. Any subsequent `db.orm.X.<op>(...)`, `db.runtime()`, `db.connect()` call rejects with `Error('<target> client is closed')`. This catches use-after-close bugs cleanly instead of silently re-opening resources.
Reference implementations in this repo:
- `packages/3-extensions/postgres/src/runtime/postgres.ts` (full pattern; `{ url }` vs `{ pool }` ownership)
- `packages/3-extensions/sqlite/src/runtime/sqlite.ts` (only-`{ path }` shape; the facade always owns)
- `packages/3-extensions/mongo/src/runtime/mongo.ts` (`{ url }` vs `{ mongoClient }`)
The `[Symbol.asyncDispose]` alias is one line — delegate to `close()` — and enables the TS 5.2+ `await using db = yourFacade(...)` syntax. There is no good reason not to add both methods together.
## `mongo-close-ownership-rule`
Before 0.11, `@internal/mongo`'s `db.close()` looked like this:
```typescript
async close() {
try {
await runtimePromise;
await runtime.close(); // unconditional — closed any MongoClient, owned or not
} catch { /* swallow */ }
closed = true;
}
```
From 0.11 it captures the ownership decision at construction time and only closes the `MongoClient` if the facade owns it:
```typescript
let ownedDispose: (() => Promise<void>) | undefined;
if (resolvedBinding.kind === 'url') {
ownedDispose = () => driver.close();
}
// ...
async close() {
try {
await runtimePromise;
await ownedDispose?.(); // no-op when the caller supplied the MongoClient
} catch { /* swallow */ }
closed = true;
}
```
**If your extension uses `mongo({ mongoClient: someClient })`** and previously called `db.close()` expecting `someClient` to be closed, your test suite will now show the `MongoClient` outliving the facade. Two ways to migrate:
- **Switch to a `{ url }` binding.** If your extension constructs the `MongoClient` purely to hand it to `mongo()`, drop the manual construction — pass the connection string in `{ url }` and let the facade own the client.
- **Close the `MongoClient` explicitly.** If your extension genuinely needs to share a `MongoClient` across multiple consumers (e.g. one client backs several `mongo()` facades, or the client is held in a higher-level connection-management seam), keep the `{ mongoClient }` binding and add `await someClient.close()` after your last facade is disposed.
No script — the right migration depends on why your extension was sharing the client in the first place. Walk the call sites by hand.
## `insert-single-row-wrap-in-array`
Starting at the 0.11 release, the `.insert()` method on the SQL builder accepts **only** an array of row objects. The single-object overload that previously allowed `.insert({ field: value })` is removed.
Before 0.11:
```ts
const ast = InsertAst.into(TableSource.named(tableName))
.insert({ field: value });
// or via the query builder:
db.sql.table.insert({ field: value }).build();
```
Starting at 0.11:
```ts
const ast = InsertAst.into(TableSource.named(tableName))
.insert([{ field: value }]);
// or via the query builder:
db.sql.table.insert([{ field: value }]).build();
```
Walk every `.ts` / `.tsx` file matched by the `detection.glob` above. For each call site that passes a plain object directly to `.insert(...)`, wrap the argument in an array:
- `.insert(row)` → `.insert([row])`
- `.insert({ field: value })` → `.insert([{ field: value }])`
Variable references to a row object are safe to wrap directly:
```ts
// Before
await runtime.execute(db.sql.table.insert(row).build());
// After
await runtime.execute(db.sql.table.insert([row]).build());
```
If a call site already passes an array (`.insert([row1, row2])`), it is already correct — leave it unchanged.
TypeScript will report bare-object call sites as type errors after the bump, which is a reliable compile-time signal for every affected site.
## `insert-ast-with-values-to-with-rows`
Starting at the 0.11 release, the `InsertAst.withValues(assignments)` method is removed. Extensions that build `InsertAst` directly via the AST layer must switch to `InsertAst.withRows([assignments])`.
Before 0.11:
```ts
const ast = InsertAst.into(TableSource.named(tableName))
.withValues(createAssignments.assignments)
.withOnConflict(onConflict);
```
Starting at 0.11:
```ts
const ast = InsertAst.into(TableSource.named(tableName))
.withRows([createAssignments.assignments])
.withOnConflict(onConflict);
```
The change is:
- Replace `.withValues(expr)` with `.withRows([expr])` — the single-row overload is removed; `withRows` accepts an array of assignment maps.
Walk every `.ts` / `.tsx` file matched by the `detection.glob` above. For each call site matching `.withValues(`, apply the replacement. The argument remains a single expression; it just needs to be wrapped in an array.
### Validation
After applying the rules above, run `pnpm typecheck && pnpm test` (or your extension's equivalent). `prisma-8-check-pins` should also pass — the pin set is unchanged for this transition; the breaking change is in the `InsertAst` builder method surface, not the dependency contract.
## Validation by execution
These entries are prose-only (no scripts). The substrate diff on `packages/3-extensions/` is additive (new methods on the three official facades) plus the Mongo behaviour change documented above; the `namespace-kind-required-on-handcrafted-contract-literals` entry covers a type-only tightening with no runtime substrate transform. There is no codemod to apply against a reverted substrate — the framework changes *are* the new surfaces, and these instructions describe the consumer-side translation, not a substrate transform.
The release-pipeline gate (`pnpm check:upgrade-coverage`) is satisfied by this directory existing with at least one entry. The substantive verification of the consumer-facing translation lives in the published skill's per-step bump-install-instructions-validate-commit loop, which runs in extension authors' own CI.
upgrading/extension/upgrades/0.11-to-0.12/instructions.md
---
from: "0.11"
to: "0.12"
changes:
- id: expr-visitor-add-window-func-method
summary: |
The `ExprVisitor<R>` interface in `@internal/sql-relational-core/ast` gained a required `windowFunc(expr: WindowFuncExpr): R` method (added to support `ROW_NUMBER() OVER (…)` lowering for `.distinct(cols)`). Every `ExprVisitor<R>` implementation in your extension — typically the object literal you pass to `expr.accept({ … })` — must add the new method or TypeScript will refuse the literal. The right body depends on what the visitor does: binding/encoding/transforming visitors usually treat `WindowFuncExpr` similarly to `AggregateExpr`; visitors that reject unsupported kinds in restricted contexts (e.g. grouped `HAVING`) should reject window functions there too. No automated codemod — author the body per visitor by hand.
detection:
glob: "**/*.ts"
contains:
- "ExprVisitor"
- "aggregate"
anyMatch: false
- id: any-expression-exhaustive-switch-add-window-func-case
summary: |
The `AnyExpression` discriminated union in `@internal/sql-relational-core/ast` gained a `WindowFuncExpr` variant (`kind: 'window-func'`). Exhaustive switches over `expr.kind` that use the `satisfies never` exhaustiveness pattern — typically in SQL renderers, AST walkers, and analysis passes — will fail to compile until they add a `case 'window-func':` arm. The arm's body depends on the switch's purpose; the most common shape is "render the window function as `fn() OVER (…)`" (matching Postgres/SQLite syntax) or "reject as unsupported in this context".
detection:
glob: "**/*.ts"
contains:
- "case 'aggregate':"
- "satisfies never"
anyMatch: false
- id: distinct-cols-now-collapses-by-specified-columns
summary: |
`.distinct(cols)` on `@internal/sql-orm-client` `Collection` (and on nested `.include(…, c => c.distinct(cols)…)`) now keeps **one representative row per `(cols)` group**, matching Prisma semantics. Prior to 0.12, `.distinct(cols)` did not actually collapse rows on the specified columns — when the projection contained any other distinguishing column (typically `id`), rows that differed only in those other columns were all returned. No code change is required for consumer call sites, but any extension tests or fixtures that asserted the pre-0.12 no-collapse output will fail and need updating to reflect the new collapsed shape. The representative within each partition is picked by the user's `.orderBy(…)` (if any); when the orderBy doesn't fully order rows in a partition the pick is implementation-defined, matching Prisma's documented behaviour.
detection:
glob: "**/*.{ts,tsx}"
contains:
- ".distinct("
anyMatch: true
- id: replace-runtime-verify-options-with-verify-marker
summary: |
The `@internal/sql-runtime` export `RuntimeVerifyOptions` is removed; replaced by `VerifyMarkerOption = 'onFirstUse' | false`. Extension convenience wrappers that expose a `*OptionsBase` interface must rename `verify?: RuntimeVerifyOptions` to `verifyMarker?: VerifyMarkerOption`, drop the hard-coded `verify: { mode: 'onFirstUse', requireMarker: false }` default in the wrapper's `createRuntime(...)` call, and thread the caller's value through via `...ifDefined('verifyMarker', options.verifyMarker)` so the runtime's own default (`'onFirstUse'`) applies when the option is omitted. The runtime no longer throws `CONTRACT.MARKER_MISMATCH` / `CONTRACT.MARKER_MISSING` on drift — it emits a structured `warn`-level log line once per runtime instance (single-flighted under concurrent first queries) and proceeds.
detection:
glob: "**/*.ts"
contains:
- "RuntimeVerifyOptions"
- "verify: {"
anyMatch: true
- id: define-contract-drop-capabilities-generic
summary: |
The `Capabilities` type parameter is removed from the framework `baseDefineContract` factory and from `ContractInput` in `@internal/contract`. Extension authors who ship their own target-facade-style `defineContract` (a thin wrapper that re-exports `baseDefineContract` with `family` / `target` pre-bound) must drop the `Capabilities` generic from every facade type alias (`*Result`, `*BaseScaffold`, `*Definition`, `*Scaffold`) and from every overload signature; the corresponding `ContractInput<…, Capabilities>` and `baseDefineContract<…, Capabilities>` instantiations lose their trailing argument. Extensions that don't ship a facade have no source change — their emitted `contract.json` / `contract.d.ts` will pick up two new auto-contributed capabilities (`postgres.distinctOn`, `sql.lateral`) on re-emit; re-run `pnpm fixtures:emit` (or the equivalent for your extension) to refresh fixtures.
detection:
glob: "**/*.ts"
contains:
- "baseDefineContract"
- "Capabilities"
anyMatch: false
- id: strip-migration-labels-hints
summary: |
The 0.12 migration manifest schema is closed (`'+': 'reject'`) and the metadata model no longer carries `labels` or `hints`; any on-disk `migration.json` your extension ships (e.g. an install-extension migration under `migrations/`) still holding either key fails to load with `INVALID_MANIFEST` naming the offending key. Both fields are also dropped from the content-addressed migration identity, so `migrationHash` is now computed over `{ from, to, providedInvariants, createdAt }` plus the sibling `ops.json`. Run the colocated codemod to strip both keys from every `migration.json` and recompute its `migrationHash` over the slimmed envelope.
detection:
glob: "**/migration.json"
contains:
- '"labels"'
- '"hints"'
anyMatch: true
script: ./strip-migration-labels-hints.ts
- id: extension-public-default-baseline
summary: |
Published Postgres extension packs' empty default namespace flips `__unbound__` → `public` (`postgres-unbound-schema` → `postgres-schema`), changing the extension's `storageHash`, `migrations/refs/head.json` hash, and each baseline `migration.json` `to` / `migrationHash`. Migration ops are unchanged. Re-emit the contract-space (`pnpm build:contract-space` / `prisma-next contract emit`) and regenerate the install migration baseline so the head ref matches the new contract hash.
detection:
glob: "**/contract.json"
contains:
- '"kind": "postgres-unbound-schema"'
anyMatch: true
script: ./regenerate-extension-public-baseline.ts
- id: domain-plane-spi-and-testing-subpath
summary: |
Contract SPI is namespaced: read models/value objects through `contract.domain.namespaces.<ns>` (helpers: `domainModelsAtDefaultNamespace(contract.domain)`, `ContractModelDefinitions`) instead of flat `contract.models`. The `@internal/contract/testing` subpath export was removed — test factories (`createContract`, `createSqlContract`, `DUMMY_HASH`, `applicationDomainOf`) now live in `@repo/test-utils`. Run the colocated import codemod and update SPI consumption to the namespaced contract shape.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "@internal/contract/testing"
anyMatch: true
script: ./migrate-contract-testing-imports.ts
- id: default-namespace-domain-access-retire-projection-helpers
summary: |
The transitional `@internal/contract` helpers `contractModels`, `contractValueObjects`, `resolveSingleDomainNamespaceId`, `ContractModelsMap`, and `ContractValueObjectsMap` are removed. Read models/value objects through `domainModelsAtDefaultNamespace(contract.domain)` / `domainValueObjectsAtDefaultNamespace(contract.domain)` (these read the contract's sole namespace and throw on a multi-namespace contract). Typed model shapes use `ContractModelDefinitions<Contract>`. SQL namespace concretions must expose `qualifyTable`; hydrate migration scaffolds with `PostgresContractSerializer` (not `structuredClone`) so `qualifyTable` survives. Runtime SQL is namespace-qualified on Postgres.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "contractModels"
anyMatch: true
---
# 0.11 → 0.12 — Extension-author upgrade instructions
## `expr-visitor-add-window-func-method`
Starting at the 0.12 release, the framework `ExprVisitor<R>` interface in `@internal/sql-relational-core/ast` gained a required method:
```ts
windowFunc(expr: WindowFuncExpr): R;
```
This method was added to support `WindowFuncExpr` — the new AST node for window functions, currently lowering `ROW_NUMBER() OVER (PARTITION BY … ORDER BY …)` used by `.distinct(cols)` (and reserved for `RANK` / `DENSE_RANK` as future additions).
Every `ExprVisitor<R>` implementation needs to add the new method. The natural body depends on what the visitor does:
- **Binding / encoding / transforming visitors** — usually treat `WindowFuncExpr` the same way they treat `AggregateExpr` (recurse into `args`, `partitionBy`, and `orderBy`).
- **Validating visitors** that restrict which expression kinds are allowed in a given context (e.g. grouped `HAVING` clauses) — typically reject window functions just like they reject aggregates in unrelated contexts.
### Before 0.12
```ts
expr.accept<AnyExpression>({
columnRef: (e) => bindExpression(contract, e),
identifierRef: (e) => e,
subquery: (e) => bindExpression(contract, e),
operation: (e) => bindExpression(contract, e),
aggregate: (e) => bindExpression(contract, e),
// … other methods …
});
```
### Starting at 0.12
```ts
expr.accept<AnyExpression>({
columnRef: (e) => bindExpression(contract, e),
identifierRef: (e) => e,
subquery: (e) => bindExpression(contract, e),
operation: (e) => bindExpression(contract, e),
aggregate: (e) => bindExpression(contract, e),
windowFunc: (e) => bindExpression(contract, e), // ← new: required
// … other methods …
});
```
### Or, for a context that rejects unsupported kinds
```ts
expr.accept<AnyExpression>({
// …
aggregate: rejectInThisContext,
windowFunc: rejectInThisContext, // ← new: required
// …
});
```
TypeScript will report missing-property errors on every visitor literal after the bump; that's a reliable compile-time signal for every affected site. No automated codemod — the right body depends on what your visitor does, so author each one by hand.
## `any-expression-exhaustive-switch-add-window-func-case`
Starting at the 0.12 release, the `AnyExpression` discriminated union in `@internal/sql-relational-core/ast` gained `WindowFuncExpr` (`kind: 'window-func'`). Exhaustive switches over `expr.kind` that use the `satisfies never` exhaustiveness pattern will fail to compile until they add a matching arm.
The most common case is in SQL renderers — Postgres and SQLite both render `WindowFuncExpr` as `fn() OVER (PARTITION BY … ORDER BY …)` (the syntax is identical across the two targets we ship).
### Before 0.12
```ts
function renderExpr(expr: AnyExpression): string {
switch (expr.kind) {
case 'column-ref':
return renderColumn(expr);
case 'aggregate':
return renderAggregate(expr);
// … other cases …
// v8 ignore next 4
default:
throw new Error(
`Unsupported expression node kind: ${(expr satisfies never as { kind: string }).kind}`,
);
}
}
```
### Starting at 0.12
```ts
function renderExpr(expr: AnyExpression): string {
switch (expr.kind) {
case 'column-ref':
return renderColumn(expr);
case 'aggregate':
return renderAggregate(expr);
case 'window-func':
return renderWindowFunc(expr); // ← new: required
// … other cases …
default:
throw new Error(
`Unsupported expression node kind: ${(expr satisfies never as { kind: string }).kind}`,
);
}
}
function renderWindowFunc(expr: WindowFuncExpr): string {
const fn = expr.fn.toUpperCase();
const args = expr.args.map(renderExpr).join(', ');
const partition =
expr.partitionBy && expr.partitionBy.length > 0
? `PARTITION BY ${expr.partitionBy.map(renderExpr).join(', ')}`
: '';
const order =
expr.orderBy && expr.orderBy.length > 0
? `ORDER BY ${expr.orderBy.map((o) => `${renderExpr(o.expr)} ${o.dir.toUpperCase()}`).join(', ')}`
: '';
const over = [partition, order].filter((s) => s.length > 0).join(' ');
return `${fn}(${args}) OVER (${over})`;
}
```
If your switch builds an `isAtomicExpressionKind` predicate or anything similar (used to decide whether the rendered expression needs surrounding parentheses), treat `'window-func'` as atomic — `fn() OVER (…)` is self-delimited by its own parentheses.
No automated codemod — the body of the new arm depends on what the switch does. TypeScript pinpoints every site at compile time.
## `distinct-cols-now-collapses-by-specified-columns`
Starting at the 0.12 release, `.distinct(cols)` on the `@internal/sql-orm-client` `Collection` API — at the top level (`db.Post.distinct('title')`), on leaf includes (`include('posts', p => p.distinct('title'))`), and on non-leaf includes (`include('posts', p => p.distinct('title').include('comments'))`) — keeps one representative row per `(cols)` group, matching Prisma's documented semantics.
Prior to 0.12, `.distinct(cols)` did not actually collapse rows on the specified columns: when the projection contained any other distinguishing column (typically `id`), rows that differed only in those other columns were all returned. From 0.12 onwards, `.distinct(cols)` keeps one representative row per `(cols)` group, matching the way Prisma documents `distinct`.
### No code change for consumer call sites
```ts
// Both 0.11 and 0.12 — same call site, different runtime behaviour:
const posts = await db.Post
.orderBy([(p) => p.title.asc(), (p) => p.id.asc()])
.distinct('title')
.all();
// 0.11: returns every post (if seed has 3 posts including two sharing title='A',
// you get 3 back).
// 0.12: returns one post per title (you get 2 back — title='A' picks the
// lower-id row per the orderBy; title='B' is unaffected).
```
The API surface is unchanged. Type-level signatures are unchanged. Only the SQL produced and the rows returned differ.
### Tests and fixtures that assert pre-0.12 output
Any extension test that exercises `.distinct(cols)` and asserts the result set will fail under 0.12. Updates needed:
- **Seed data with duplicates** on every column passed to `.distinct(...)` so the test actually exercises dedup (a test with no duplicates is a no-op assertion in either era).
- **Pair `.distinct(...)` with an `.orderBy(...)`** that fully orders rows within each partition (e.g. `[distinctCol.asc(), id.asc()]`) so the picked representative is deterministic. When the orderBy doesn't fully order a partition the choice is implementation-defined — matches Prisma's behaviour, but makes assertions flaky.
- **Update `expect(rows).toEqual([…])` shapes** to match the post-collapse output. The dropped row's grandchildren (where `.distinct(cols).include(grandchild)` is in play) do not appear in the output either.
### Representative-selection behaviour
The user's `.orderBy(…)` drives the OVER ORDER BY of the underlying `ROW_NUMBER()` — the row with rank 1 in each partition wins. When the orderBy doesn't fully order rows within a partition, the choice between tied rows is implementation-defined (Postgres and SQLite are each entitled to pick any row in the tie). This matches Prisma's documented behaviour; if your extension needs deterministic picks across partition ties, add a primary-key tiebreaker to the orderBy.
### Validation
After updating fixture / test data, run your extension's standard `pnpm test` (or `pnpm test:integration` for tests that exercise live SQL). No type-level changes — TypeScript will not pinpoint sites; runtime assertions are the signal.
## `replace-runtime-verify-options-with-verify-marker`
Starting at the 0.12 release, `@internal/sql-runtime` simplifies marker verification. The previous `RuntimeVerifyOptions` type and the `verify: { mode; requireMarker }` field on `RuntimeOptions` are removed; replaced by a single optional field `verifyMarker?: VerifyMarkerOption` where `VerifyMarkerOption = 'onFirstUse' | false` and `'onFirstUse'` is the runtime default.
If your extension ships a convenience wrapper around `createRuntime(...)` — the pattern used by `@internal/sqlite`, `@internal/postgres`, and `@internal/postgres/serverless` — you need four mechanical edits in the wrapper source:
1. Rename the type import from `RuntimeVerifyOptions` to `VerifyMarkerOption`.
2. Rename the option on your `*OptionsBase` interface from `verify?` to `verifyMarker?`.
3. Drop the hard-coded default literal in the `createRuntime(...)` call.
4. Thread the caller's value through with `ifDefined` so omitted options defer to the runtime default.
The runtime's read-side behaviour also changes: it no longer throws `CONTRACT.MARKER_MISMATCH` or `CONTRACT.MARKER_MISSING` when the database marker is absent or drifted. Instead, on the first `execute()` call per runtime instance, it emits one structured `warn`-level log line (payload includes `code`, `scope`, `expected`, `actual`, `message`) and proceeds with the query. Extension authors do not need to implement this behaviour — it lives inside `@internal/sql-runtime` — but tests that previously asserted thrown errors need retargeting (see *Tests and fixtures* below).
### Before 0.12 — type import and options interface
```ts
import type {
ExecutionContext,
Runtime,
RuntimeVerifyOptions,
SqlExecutionStackWithDriver,
SqlMiddleware,
SqlRuntimeExtensionDescriptor,
} from '@internal/sql-runtime';
export interface MyTargetOptionsBase {
readonly extensions?: readonly SqlRuntimeExtensionDescriptor<MyTargetId>[];
readonly middleware?: readonly SqlMiddleware[];
readonly verify?: RuntimeVerifyOptions;
}
```
### Starting at 0.12 — type import and options interface
```ts
import type {
ExecutionContext,
Runtime,
SqlExecutionStackWithDriver,
SqlMiddleware,
SqlRuntimeExtensionDescriptor,
VerifyMarkerOption,
} from '@internal/sql-runtime';
import { ifDefined } from '@internal/utils/defined';
export interface MyTargetOptionsBase {
readonly extensions?: readonly SqlRuntimeExtensionDescriptor<MyTargetId>[];
readonly middleware?: readonly SqlMiddleware[];
readonly verifyMarker?: VerifyMarkerOption;
}
```
Import `ifDefined` from `@internal/utils/defined` if your wrapper does not already use it for other optional fields.
### Before 0.12 — `createRuntime(...)` call inside the wrapper
```ts
const runtime = createRuntime({
stackInstance,
context,
driver,
verify: options.verify ?? { mode: 'onFirstUse', requireMarker: false },
...ifDefined('middleware', options.middleware),
});
```
The hard-coded `{ mode: 'onFirstUse', requireMarker: false }` default duplicated what the runtime already applied when `verify` was omitted. From 0.12 the wrapper should not inject a default — let the runtime's `'onFirstUse'` default stand.
### Starting at 0.12 — `createRuntime(...)` call inside the wrapper
```ts
const runtime = createRuntime({
stackInstance,
context,
driver,
...ifDefined('verifyMarker', options.verifyMarker),
...ifDefined('middleware', options.middleware),
});
```
When the caller omits `verifyMarker`, the spread adds nothing and the runtime default (`'onFirstUse'`) applies. When the caller passes `verifyMarker: false`, verification is skipped entirely.
### Semantics mapping for callers of your wrapper
| Before 0.12 (`verify`) | Starting at 0.12 (`verifyMarker`) |
| --- | --- |
| `{ mode: 'onFirstUse', requireMarker: false }` (or omitted — your wrapper defaulted to this) | omit `verifyMarker` (runtime default `'onFirstUse'`) |
| `{ mode: 'onFirstUse', requireMarker: true }` | `verifyMarker: 'onFirstUse'` — but the throw-on-missing-marker semantics are removed; use the `db-verify` CLI for fail-fast deploy checks |
| `{ mode: 'always', requireMarker: ... }` | `verifyMarker: 'onFirstUse'` — `'always'` mode is dropped; verification is once-per-runtime |
| `{ mode: 'startup', requireMarker: ... }` | `verifyMarker: 'onFirstUse'` — `'startup'` mode is dropped for the same reason |
| Explicit skip | `verifyMarker: false` |
### Tests and fixtures
Extension wrapper tests that exercised the old surface need two kinds of updates:
**Option-forwarding tests** — rename the option and adjust assertions about defaults:
```ts
// Before 0.12
it('forwards verify option to createRuntime', async () => {
const verify = { mode: 'always', requireMarker: true } as const;
const db = myTarget({ contract, verify });
await db.connect(/* … */);
expect(mocks.createRuntime).toHaveBeenCalledWith(expect.objectContaining({ verify }));
});
it('defaults verify to onFirstUse without requireMarker', async () => {
const db = myTarget({ contract });
await db.connect(/* … */);
expect(mocks.createRuntime).toHaveBeenCalledWith(
expect.objectContaining({ verify: { mode: 'onFirstUse', requireMarker: false } }),
);
});
// Starting at 0.12
it('forwards verifyMarker option to createRuntime', async () => {
const db = myTarget({ contract, verifyMarker: false });
await db.connect(/* … */);
expect(mocks.createRuntime).toHaveBeenCalledWith(
expect.objectContaining({ verifyMarker: false }),
);
});
it('omits verifyMarker from createRuntime when not provided (runtime default applies)', async () => {
const db = myTarget({ contract });
await db.connect(/* … */);
expect(mocks.createRuntime).toHaveBeenCalledTimes(1);
const callArg = mocks.createRuntime.mock.calls[0]?.[0] as Record<string, unknown>;
expect(callArg).not.toHaveProperty('verifyMarker');
});
```
**Drift / missing-marker integration tests** — grep for `rejects.toMatchObject({ code: 'CONTRACT.MARKER_MISSING' })` or `rejects.toMatchObject({ code: 'CONTRACT.MARKER_MISMATCH' })`. These patterns no longer apply: the runtime logs instead of throwing. Retarget to assert on the `Log.warn` sink:
```ts
// Before 0.12
await expect(runtime.execute(plan).toArray()).rejects.toMatchObject({
code: 'CONTRACT.MARKER_MISSING',
});
// Starting at 0.12
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() } satisfies Log;
const runtime = createRuntime({ stackInstance, context, driver, log });
const rows = await runtime.execute(plan).toArray();
expect(rows).toEqual(/* expected rows — query proceeds */);
expect(log.warn).toHaveBeenCalledOnce();
expect(log.warn).toHaveBeenCalledWith({
code: 'CONTRACT.MARKER_MISSING',
scope: 'marker-verification',
expected: { storageHash: contract.storage.storageHash, profileHash: contract.profileHash ?? null },
actual: null,
message: 'Contract marker not found in database',
});
```
Pass a `log` object into `createRuntime(...)` (or through your wrapper if you expose a `log` option) so tests can spy on `warn` without touching stdout.
### Validation
After applying the edits above, run `pnpm typecheck` on your extension package. TypeScript flags every remaining `RuntimeVerifyOptions` import and every `verify?:` field on your options interface. Then run your extension's test suite — option-forwarding unit tests and any marker-drift integration tests are the sites most likely to need the retargeting described above.
## `define-contract-drop-capabilities-generic`
Starting at the 0.12 release, the framework `baseDefineContract` factory in `@internal/contract` drops its `Capabilities` type parameter, and the `ContractInput<Family, Target, Types, Models, ExtensionPacks, Capabilities>` shape loses its trailing argument. Capabilities are no longer declared at authoring time — they are contributed automatically by target components and extension packs, and flow into the emitted `contract.json` / `contract.d.ts` from there.
There are two kinds of impact on an extension, depending on what your extension ships:
- **Extensions that ship their own target-facade `defineContract`** (the pattern used by `@internal/postgres`, `@internal/sqlite`, and any third-party adapter that pre-binds `family` + `target` for its consumers): you need to drop the `Capabilities` generic from every facade type alias and overload signature. TypeScript will pinpoint every site once you bump.
- **Extensions that only contribute pack metadata + emit fixtures** (the more common shape — `@internal/pgvector`, `@internal/paradedb`, etc.): no source change. Re-emit your contract fixtures (`pnpm fixtures:emit` or the equivalent script for your package) so the regenerated `contract.json` / `contract.d.ts` picks up the new auto-contributed capability keys — in the 0.12 line, `postgres.distinctOn: true` and `sql.lateral: true` appear in every SQL-target fixture that loads the relevant adapter.
### Facade-style extensions — drop the generic
If your extension ships a `defineContract` that wraps `baseDefineContract` with `family` / `target` pre-bound, walk every type alias and every overload signature in your facade and remove the `Capabilities` parameter.
#### Before 0.12
```ts
import { defineContract as baseDefineContract } from '@internal/contract';
import type { ContractInput, ExtensionPackRef } from '@internal/contract';
type MyTargetResult<
Types extends TypesConstraint,
Models extends ModelsConstraint,
ExtensionPacks extends Record<string, ExtensionPackRef<'sql', string>> | undefined,
Capabilities extends Record<string, Record<string, boolean>> | undefined,
> = Omit<
ReturnType<
typeof baseDefineContract<
MyFamily,
MyTargetPack,
Types,
Models,
ExtensionPacks,
Capabilities
>
>,
'target' | 'targetFamily'
> & {
readonly target: MyTargetPack['targetId'];
readonly targetFamily: MyFamily['familyId'];
};
type MyTargetBaseScaffold<
ExtensionPacks extends Record<string, ExtensionPackRef<'sql', string>> | undefined,
Capabilities extends Record<string, Record<string, boolean>> | undefined,
> = Omit<
ContractInput<
MyFamily,
MyTargetPack,
Record<never, never>,
Record<never, never>,
ExtensionPacks,
Capabilities
>,
'family' | 'target' | 'types' | 'models'
>;
export function defineContract<
const Types extends TypesConstraint = Record<never, never>,
const Models extends ModelsConstraint = Record<never, never>,
const ExtensionPacks extends
| Record<string, ExtensionPackRef<'sql', string>>
| undefined = undefined,
const Capabilities extends Record<string, Record<string, boolean>> | undefined = undefined,
>(
definition: MyTargetDefinition<Types, Models, ExtensionPacks, Capabilities>,
): MyTargetResult<Types, Models, ExtensionPacks, Capabilities>;
```
#### Starting at 0.12
```ts
import { defineContract as baseDefineContract } from '@internal/contract';
import type { ContractInput, ExtensionPackRef } from '@internal/contract';
type MyTargetResult<
Types extends TypesConstraint,
Models extends ModelsConstraint,
ExtensionPacks extends Record<string, ExtensionPackRef<'sql', string>> | undefined,
> = Omit<
ReturnType<
typeof baseDefineContract<MyFamily, MyTargetPack, Types, Models, ExtensionPacks>
>,
'target' | 'targetFamily'
> & {
readonly target: MyTargetPack['targetId'];
readonly targetFamily: MyFamily['familyId'];
};
type MyTargetBaseScaffold<
ExtensionPacks extends Record<string, ExtensionPackRef<'sql', string>> | undefined,
> = Omit<
ContractInput<
MyFamily,
MyTargetPack,
Record<never, never>,
Record<never, never>,
ExtensionPacks
>,
'family' | 'target' | 'types' | 'models'
>;
export function defineContract<
const Types extends TypesConstraint = Record<never, never>,
const Models extends ModelsConstraint = Record<never, never>,
const ExtensionPacks extends
| Record<string, ExtensionPackRef<'sql', string>>
| undefined = undefined,
>(
definition: MyTargetDefinition<Types, Models, ExtensionPacks>,
): MyTargetResult<Types, Models, ExtensionPacks>;
```
Drop the same parameter from every other overload signature (the factory-form overload, any convenience overload). Drop the matching entry from the type alias for `*Definition` and `*Scaffold` shapes. Drop the `Capabilities` argument from every internal `baseDefineContract<…, Capabilities>` instantiation and every internal `ContractInput<…, Capabilities>` instantiation. TypeScript will flag any remaining occurrence after the bump.
### Type tests that asserted authoring-time capability literals
If your facade ships a `define-contract.test-d.ts` (or similar) that asserts a `capabilities` literal is acceptable as an input to your `defineContract` — flip the assertion. The literal is now refused at the type level:
```ts
// Starting at 0.12
// @ts-expect-error — capabilities are contributed by components, not authoring input
defineContract({ capabilities: { sql: { lateral: true } } });
```
### Extensions that only emit fixtures — re-emit
If your extension does not ship a facade, you have no source change. The contract fixtures your extension emits as part of its test suite will, however, gain new capability keys after the bump. Re-run your fixture-emit script (commonly `pnpm fixtures:emit` or `pnpm test:fixtures:emit`) and commit the regenerated `contract.json` / `contract.d.ts`. Expect to see:
- `postgres.distinctOn: true` (added when a Postgres adapter is in the component graph)
- `sql.lateral: true` (added when the SQL family + a supporting adapter is in the component graph)
No fixture-shape changes other than capability additions; if your re-emit produces diffs in other sections of `contract.json`, that's a separate framework change, not this entry.
### Validation
After applying the edits, run `pnpm typecheck` and the matching test suite for your extension package. For facade-style extensions, the typecheck pinpoints every remaining occurrence of the `Capabilities` generic at compile time. For fixture-only extensions, the regenerated `contract.json` / `contract.d.ts` diff is the signal — review it to confirm the new capability keys landed where you expect.
## `strip-migration-labels-hints`
Starting at the 0.12 release, the migration manifest schema is closed (`'+': 'reject'`) and the metadata model no longer carries `labels` or `hints`. If your extension ships on-disk migration packages — for example an install-extension migration (`migrations/<timestamp>_install_…/migration.json`) that provisions your extension's database objects — any manifest that still holds either key fails to load: the loader rejects it with `INVALID_MANIFEST`, naming the first offending key (`labels` or `hints`). The two fields are also removed from the content-addressed migration identity — `migrationHash` is now computed over `{ from, to, providedInvariants, createdAt }` plus the sibling `ops.json` — so every migrated manifest additionally needs its hash recomputed over the slimmed envelope, or it fails hash verification on the next load.
Run the colocated codemod from your extension's package root:
```bash
pnpm exec tsx ./strip-migration-labels-hints.ts
```
It walks every `migration.json` that has a sibling `ops.json` (a complete on-disk migration package), removes the `labels` and `hints` keys, and recomputes `migrationHash` over the slimmed metadata plus the operations. The edit is format-preserving — only the two key lines are removed and the hash value is swapped in place, so the rest of each manifest (key order, indentation, inline-vs-expanded arrays) is left untouched and the diff stays minimal. The codemod is idempotent: re-running it over already-migrated manifests makes no further changes.
### Confirm every manifest is migrated
Run the codemod in dry-run mode to confirm no committed manifest still carries the removed keys or a stale hash:
```bash
pnpm exec tsx ./strip-migration-labels-hints.ts --check
```
`--check` lists every manifest that still needs fixing and exits non-zero if any remain, so wire it into your extension's CI alongside `prisma-8-check-pins`. A fully migrated tree reports `0 needing fix` and exits `0`.
### Validation
After running the codemod, run your extension's migration-loading tests (the integration suite that applies your install migration, or whatever exercises the on-disk packages). The loader recomputes and verifies each manifest's `migrationHash` on read: a manifest that still carried `labels`/`hints` would have thrown `INVALID_MANIFEST`, and a manifest with a stale hash would fail verification. Once the codemod has run, every manifest loads cleanly and its recomputed hash verifies against the slimmed envelope.
## `extension-public-default-baseline`
Starting at the 0.12 release, Postgres extension packs whose contract-space declares only storage types (no tables) emit their empty default namespace under `public` / `postgres-schema` instead of `__unbound__` / `postgres-unbound-schema`. The on-disk migration ops (`CREATE EXTENSION …`, invariant registration, etc.) are unchanged — only the contract hash envelope moves. Expect diffs in:
- `src/contract.json` / `src/contract.d.ts` — new `storageHash` and namespace keys
- `migrations/<baseline>/migration.json` — updated `to` and `migrationHash`
- `migrations/<baseline>/end-contract.json` / `end-contract.d.ts` — regenerated snapshots
- `migrations/<baseline>/migration.ts` — updated `describe().to` storage hash literal
- `migrations/refs/head.json` — updated `hash`
### Regenerate contract-space and baseline
Run the colocated script from your extension package root (or monorepo root if it hosts multiple extension packs):
```bash
pnpm exec tsx ./regenerate-extension-public-baseline.ts
```
For each extension root whose `src/contract.json` still carries `"kind": "postgres-unbound-schema"`, the script runs `pnpm build:contract-space`, copies `src/contract.{json,d.ts}` into each baseline migration directory as `end-contract.{json,d.ts}`, patches the baseline `migration.ts` `to` hash, self-emits the migration (`pnpm exec tsx migrations/.../migration.ts`), and updates `migrations/refs/head.json`.
Use `--check` to list packs that still need regeneration:
```bash
pnpm exec tsx ./regenerate-extension-public-baseline.ts --check
```
Path B baselines (hand-authored install migrations with no planner scaffold) follow the same loop documented in your extension README: edit `describe().to`, then self-emit.
### Validation
Run your extension's test suite and any migration-loading integration tests. Confirm `migrations/refs/head.json` `hash` matches `src/contract.json` `storage.storageHash`, and that the baseline `migration.json` `to` field matches as well.
## `domain-plane-spi-and-testing-subpath`
Starting at the 0.12 release, two SPI changes affect extension authors:
1. **Namespaced domain plane** — stop reading flat `contract.models` / `contract.valueObjects`. Models and value objects live under `contract.domain.namespaces.<ns>`. Use `domainModelsAtDefaultNamespace(contract.domain)` (reads the contract's sole namespace; throws on a multi-namespace contract — select explicitly per TML-2550) and `ContractModelDefinitions<C>` from `@internal/contract/types` for typed access. Storage remains under `contract.storage.namespaces.<ns>` (unchanged shape).
2. **Removed `@internal/contract/testing` subpath** — test factories moved to `@repo/test-utils`. Add `@repo/test-utils` to your extension's `devDependencies` at the same version pin as your other `@internal/*` packages if it is not already present.
### Migrate test imports
Run the colocated codemod from your extension root:
```bash
pnpm exec tsx ./migrate-contract-testing-imports.ts
```
It rewrites every `@internal/contract/testing` import to `@repo/test-utils`. Use `--check` for a dry-run:
```bash
pnpm exec tsx ./migrate-contract-testing-imports.ts --check
```
Exports are unchanged — only the package path moves:
```diff
-import { createContract, createSqlContract } from '@internal/contract/testing';
+import { createContract, createSqlContract } from '@repo/test-utils';
```
Subpath imports such as `@repo/test-utils/typed-expectations` were already on `@repo/test-utils` and are unaffected.
### Update SPI reads to the namespaced domain shape
Walk extension source that constructs or reads contracts directly (tests, control adapters, planners). TypeScript will flag most stale reads after the bump; the mechanical rewrites are:
**Reading models** — resolve through the target's default domain namespace:
```diff
-const models = contract.models;
+import { domainModelsAtDefaultNamespace } from '@internal/contract/types';
+
+const models = domainModelsAtDefaultNamespace(contract.domain);
```
**Patching models in tests** — nest under the domain namespace:
```diff
return {
...contract,
- models: patch({ ...contract.models }),
+ domain: {
+ namespaces: {
+ ...contract.domain.namespaces,
+ [namespaceId]: {
+ ...namespace,
+ models: patch({ ...domainModelsAtDefaultNamespace(contract.domain) }),
+ },
+ },
+ },
};
```
**Hard-coded `__unbound__` namespace lookups for table resolution** — scan all storage namespaces (a table name is unique within the contract's default resolution path):
```diff
-const table = contract.storage.namespaces['__unbound__']?.tables[tableName];
+const table = Object.values(contract.storage.namespaces).find(
+ (ns) => ns.tables[tableName] !== undefined,
+)?.tables[tableName];
```
After source updates, re-emit fixture contracts (`pnpm fixtures:emit` or your package's equivalent) so committed `contract.json` / `contract.d.ts` under `test/` pick up `domain.namespaces`.
### Validation
Run `pnpm typecheck && pnpm test` on your extension package. The import codemod is deterministic; remaining errors indicate hand-edits for namespaced domain reads. Regenerated fixture diffs should show `domain.namespaces` and `ContractModelDefinitions` (or the emitted `Models` infer alias) in types.
## `default-namespace-domain-access-retire-projection-helpers`
Starting at the 0.12 release (runtime qualification, [ADR 223](../../../../docs/architecture%20docs/adrs/ADR%20223%20-%20Target-owned%20default%20namespace.md)), the foundation `contract` package retires the transitional projection helpers introduced during the symmetric domain-plane migration. Extension code that still calls them will fail to compile after the bump.
The default namespace a bare name resolves through is **inferred** from the contract (sole namespace, else insertion order) — there are no `…ForSqlTarget` / `…ForMongo` helpers to import. A target's default namespace is declared on its descriptor (`defaultNamespaceId`) and consumed only by authoring; runtime code resolves target-agnostically.
### Removed exports (old → new)
| Removed | Replacement |
|---|---|
| `contractModels(contract)` | `domainModelsAtDefaultNamespace(contract.domain)` (reads the sole namespace; throws on multi-namespace) |
| `contractValueObjects(contract)` | `domainValueObjectsAtDefaultNamespace(contract.domain)` |
| `resolveSingleDomainNamespaceId(domain)` | `soleDomainNamespaceId(domain)` (same fail-loud single-namespace behaviour) |
| `ContractModelsMap<C>` | `ContractModelDefinitions<C>` |
| `ContractValueObjectsMap<C>` | Read `contract.domain.namespaces[ns].valueObjects` for a specific namespace, or `domainValueObjectsAtDefaultNamespace(contract.domain)` for the default slot |
Import the replacements from `@internal/contract/types`.
### `qualifyTable` on SQL namespace concretions
Storage namespace envelopes in SQL-family contracts must carry a `qualifyTable(tableName: string): string` method. The Postgres and SQLite packs in this repo already implement it on bound/unbound namespace concretions; custom serializers or hand-built namespace objects in tests must include it or rendering falls back incorrectly.
### Hydrating contracts in tests
Do not `structuredClone` hydrated contracts — it strips functions such as `qualifyTable`. Round-trip through the target serializer instead:
```ts
import { PostgresContractSerializer } from '@internal/target-postgres/runtime';
const serializer = new PostgresContractSerializer();
const hydrated = serializer.deserializeContract(serializer.serializeContract(rawContract));
```
### Namespace-qualified runtime SQL
Postgres query renderers now emit `"<schema>"."<table>"` (default schema `public`). Update extension integration tests that assert raw SQL strings (`FROM "user"` → `FROM "public"."user"`). SQLite remains unqualified. Application/extension call sites for `db.sql.*` / `db.*` are unchanged.
### Emitter guard (unchanged for multi-namespace extensions)
`assertSingleDomainNamespaceForEmission` still fails when emitting `contract.d.ts` for contracts with multiple domain namespaces ([TML-2550](https://linear.app/prisma-company/issue/TML-2550)). Runtime execution does not throw for multi-namespace contracts; only emission stays fail-loud.
### Validation
Run `pnpm typecheck && pnpm test` on your extension package. Grep for the removed symbol names should return no hits outside historical upgrade prose.
## Validation by execution
Apart from `strip-migration-labels-hints` (which ships the colocated codemod described above, validated against the migration manifests under `packages/3-extensions/`), these entries are prose-only (no codemod scripts). The substrate diffs inside `packages/3-extensions/` in this transition are the same code translations downstream extension authors will replicate by hand:
- The `windowFunc` method literally added to `bindWhereExprNode`'s `ExprVisitor` literal in `where-binding.ts`.
- The `windowFunc: rejectHavingExpr` literally added to `validateGroupedHavingExpr`'s `ExprVisitor` literal in `query-plan-aggregate.ts`.
- The `case 'window-func':` arms in the Postgres and SQLite adapter renderers.
- Flipped fixture row counts in the distinct integration tests.
- The `RuntimeVerifyOptions` → `VerifyMarkerOption` import rename, `verify?` → `verifyMarker?` on `*OptionsBase`, and `...ifDefined('verifyMarker', options.verifyMarker)` thread-through in `packages/3-extensions/sqlite/src/runtime/sqlite.ts`, `packages/3-extensions/postgres/src/runtime/postgres.ts`, and `packages/3-extensions/postgres/src/runtime/postgres-serverless.ts`.
- Retargeted option-forwarding and marker-drift tests in `packages/3-extensions/postgres/test/postgres-serverless.test.ts`.
There is no scriptable transform — the right body for the `ExprVisitor` method and the right arm for the exhaustive switch depend on what the consumer's visitor / switch does; the right test retargeting for marker drift depends on whether the test asserted throws or option forwarding. The release-pipeline gate (`pnpm check:upgrade-coverage`) is satisfied by this directory existing with at least one entry; the substantive verification of the consumer-facing translation lives in the published extension-upgrade skill's per-step bump-install-instructions-validate-commit loop, which runs in extension authors' own CI.
upgrading/extension/upgrades/0.11-to-0.12/migrate-contract-testing-imports.ts
/**
* Rewrites test-only imports from the removed `@internal/contract/testing`
* subpath to `@repo/test-utils`.
*
* Background: starting at 0.12 the contract test factories (`createContract`,
* `createSqlContract`, `DUMMY_HASH`, `applicationDomainOf`, …) live in
* `@repo/test-utils`. The `@internal/contract/testing` export was
* removed from `@internal/contract`.
*
* Behaviour:
* - Walks the project root recursively, ignoring `node_modules`, `.git`,
* `dist`, and `build`.
* - Rewrites every `.ts` / `.tsx` file whose source contains
* `@internal/contract/testing`.
* - Idempotent: files already importing from `@repo/test-utils` are
* left untouched.
*
* Flags:
* --check dry-run; lists files that still need rewriting and exits 1 if
* any remain.
*/
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
const FROM = '@internal/contract/testing';
const TO = '@repo/test-utils';
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
const dryRun = process.argv.includes('--check');
const projectRoot = process.cwd();
async function findSourceFiles(root: string): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
let entries: Awaited<ReturnType<typeof readdir>>;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
await walk(join(dir, entry.name));
} else if (
entry.isFile() &&
(entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) &&
entry.name !== 'migrate-contract-testing-imports.ts'
) {
out.push(join(dir, entry.name));
}
}
}
await walk(root);
return out.sort();
}
const files = await findSourceFiles(projectRoot);
const targets: string[] = [];
for (const path of files) {
const raw = await readFile(path, 'utf-8');
if (raw.includes(FROM)) targets.push(path);
}
if (targets.length === 0) {
console.log('No @internal/contract/testing imports found.');
process.exit(0);
}
let needsFix = 0;
let fixed = 0;
for (const path of targets) {
const rel = path.slice(projectRoot.length + 1);
const raw = await readFile(path, 'utf-8');
const next = raw.replaceAll(FROM, TO);
if (next === raw) continue;
needsFix += 1;
if (dryRun) {
console.log(`WOULD REWRITE ${rel}`);
continue;
}
await writeFile(path, next);
fixed += 1;
console.log(`REWRITE ${rel}`);
}
console.log();
console.log(
`${targets.length} file(s) with legacy import: ${dryRun ? needsFix : fixed} ${dryRun ? 'needing rewrite' : 'rewritten'}.`,
);
if (dryRun && needsFix > 0) process.exit(1);
upgrading/extension/upgrades/0.11-to-0.12/regenerate-extension-public-baseline.ts
/**
* Re-emits a Postgres extension pack's contract-space and regenerates its
* install migration baseline after the 0.12 public-by-default flip
* (`__unbound__`/`postgres-unbound-schema` → `public`/`postgres-schema`).
*
* The migration ops are unchanged — only the contract hash envelope moves.
* This script:
* 1. Finds extension package roots (nearest `package.json` with a
* `build:contract-space` script) whose `src/contract.json` still
* carries `postgres-unbound-schema`.
* 2. Runs `pnpm build:contract-space`.
* 3. Patches each baseline `migrations/<dir>/migration.ts` `describe().to`
* hash to match the new `storageHash`.
* 4. Self-emits each migration (`pnpm exec tsx <migration.ts>`).
* 5. Updates `migrations/refs/head.json` `hash` to the new storage hash
* (preserves `invariants`).
*
* Flags:
* --check dry-run; lists extension roots that still need regeneration
* and exits 1 if any remain.
*/
import { execFile } from 'node:child_process';
import { access, copyFile, readdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
const dryRun = process.argv.includes('--check');
const projectRoot = process.cwd();
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
async function findPackageJsonFiles(root: string): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
let entries: Awaited<ReturnType<typeof readdir>>;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
await walk(join(dir, entry.name));
} else if (entry.isFile() && entry.name === 'package.json') {
out.push(join(dir, entry.name));
}
}
}
await walk(root);
return out.sort();
}
function contractNeedsPublicDefaultMigration(raw: string): boolean {
return (
raw.includes('"kind": "postgres-unbound-schema"') ||
raw.includes('"kind":"postgres-unbound-schema"')
);
}
async function packageHasBuildContractSpace(pkgPath: string): Promise<boolean> {
const raw = await readFile(pkgPath, 'utf-8');
try {
const parsed: unknown = JSON.parse(raw);
if (!isJsonObject(parsed)) return false;
const scripts = parsed['scripts'];
if (!isJsonObject(scripts)) return false;
return (
typeof scripts['build:contract-space'] === 'string' &&
scripts['build:contract-space'].length > 0
);
} catch {
return false;
}
}
async function findMigrationDirs(migrationsDir: string): Promise<string[]> {
const out: string[] = [];
let entries: Awaited<ReturnType<typeof readdir>>;
try {
entries = await readdir(migrationsDir, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const migrationDir = join(migrationsDir, entry.name);
if (await pathExists(join(migrationDir, 'migration.ts'))) out.push(migrationDir);
}
return out.sort();
}
async function readStorageHash(contractPath: string): Promise<string | null> {
const raw = await readFile(contractPath, 'utf-8');
try {
const parsed: unknown = JSON.parse(raw);
if (!isJsonObject(parsed)) return null;
const storage = parsed['storage'];
if (!isJsonObject(storage)) return null;
const storageHash = storage['storageHash'];
return typeof storageHash === 'string' ? storageHash : null;
} catch {
return null;
}
}
async function patchMigrationToHash(
migrationTsPath: string,
storageHash: string,
): Promise<boolean> {
const raw = await readFile(migrationTsPath, 'utf-8');
const patched = raw.replace(/(\bto:\s*['"])sha256:[0-9a-f]{64}(['"])/, `$1${storageHash}$2`);
if (patched === raw) return false;
await writeFile(migrationTsPath, patched);
return true;
}
async function patchHeadRef(headPath: string, storageHash: string): Promise<boolean> {
const raw = await readFile(headPath, 'utf-8');
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return false;
}
if (!isJsonObject(parsed)) return false;
if (parsed['hash'] === storageHash) return false;
parsed['hash'] = storageHash;
await writeFile(headPath, `${JSON.stringify(parsed, null, 2)}\n`);
return true;
}
interface ExtensionRoot {
readonly dir: string;
readonly contractPath: string;
}
const extensionRoots: ExtensionRoot[] = [];
for (const pkgPath of await findPackageJsonFiles(projectRoot)) {
if (!(await packageHasBuildContractSpace(pkgPath))) continue;
const dir = join(pkgPath, '..');
const contractPath = join(dir, 'src', 'contract.json');
if (!(await pathExists(contractPath))) continue;
const raw = await readFile(contractPath, 'utf-8');
if (!contractNeedsPublicDefaultMigration(raw)) continue;
extensionRoots.push({ dir, contractPath });
}
if (extensionRoots.length === 0) {
console.error(`No extension public-default migration candidates under ${projectRoot}.`);
process.exit(dryRun ? 0 : 1);
}
let needsFix = 0;
let alreadyClean = 0;
for (const { dir, contractPath } of extensionRoots) {
const rel = dir.slice(projectRoot.length + 1) || '.';
const raw = await readFile(contractPath, 'utf-8');
if (!contractNeedsPublicDefaultMigration(raw)) {
alreadyClean += 1;
console.log(`OK ${rel}`);
continue;
}
needsFix += 1;
if (dryRun) {
console.log(`WOULD REGENERATE ${rel}`);
continue;
}
console.log(`REGENERATE ${rel}`);
await execFileAsync('pnpm', ['build:contract-space'], { cwd: dir, env: process.env });
const storageHash = await readStorageHash(contractPath);
if (storageHash === null) {
throw new Error(`Could not read storageHash from ${contractPath}`);
}
const migrationsDir = join(dir, 'migrations');
const srcContractJson = join(dir, 'src', 'contract.json');
const srcContractDts = join(dir, 'src', 'contract.d.ts');
for (const migrationDir of await findMigrationDirs(migrationsDir)) {
await copyFile(srcContractJson, join(migrationDir, 'end-contract.json'));
if (await pathExists(srcContractDts)) {
await copyFile(srcContractDts, join(migrationDir, 'end-contract.d.ts'));
}
const migrationTs = join(migrationDir, 'migration.ts');
await patchMigrationToHash(migrationTs, storageHash);
await execFileAsync('pnpm', ['exec', 'tsx', migrationTs], { cwd: dir, env: process.env });
}
const headPath = join(migrationsDir, 'refs', 'head.json');
if (await pathExists(headPath)) {
await patchHeadRef(headPath, storageHash);
}
}
console.log();
console.log(
`${extensionRoots.length} extension pack(s): ${needsFix} ${dryRun ? 'needing regeneration' : 'regenerated'}, ${alreadyClean} already on public default.`,
);
if (dryRun && needsFix > 0) process.exit(1);
upgrading/extension/upgrades/0.11-to-0.12/strip-migration-labels-hints.ts
/**
* Brings on-disk `migration.json` manifests into the slimmed 0.12 metadata
* model: drops the now-removed `labels` and `hints` keys and recomputes
* `migrationHash` over the surviving metadata envelope + sibling `ops.json`.
*
* Background: starting at the 0.12 release the migration manifest schema is
* closed (`'+': 'reject'`) — `labels` and `hints` are no longer part of the
* model, so any manifest still carrying either key fails to load with
* `INVALID_MANIFEST` naming the offending key. The two fields also no longer
* participate in the content-addressed migration identity: `migrationHash` is
* now computed over `{ from, to, providedInvariants, createdAt }` plus the
* sibling operations, so every migrated manifest gets a freshly recomputed
* hash over the slimmed envelope.
*
* Before 0.12 the on-disk shape was:
*
* {
* "from": null,
* "to": "sha256:…",
* "labels": [],
* "providedInvariants": ["…"],
* "createdAt": "2026-…",
* "hints": { "used": [], "applied": [], "plannerVersion": "2.0.0" },
* "migrationHash": "sha256:…"
* }
*
* Starting at 0.12 the same manifest is:
*
* {
* "from": null,
* "to": "sha256:…",
* "providedInvariants": ["…"],
* "createdAt": "2026-…",
* "migrationHash": "sha256:…" // recomputed over the slimmed envelope
* }
*
* Format-preserving edit: rather than reparse-and-reserialise (which would
* reflow every value to a single canonical style and bloat the diff), this
* codemod performs a surgical text edit — it removes only the `labels` and
* `hints` top-level key lines and swaps the `migrationHash` value in place.
* Every other byte (key order, indentation, and whether arrays like
* `providedInvariants` are written inline or expanded) is left exactly as the
* authoring tool wrote it, so the diff is limited to the two removed keys and
* the new hash value.
*
* Confinement: an on-disk migration package is a `migration.json` paired with
* a sibling `ops.json` (the operations the hash is computed over). The walk
* keys off that pair rather than off a `migrations/` directory name, because
* migration packages live under several roots in practice (`migrations/`,
* `migration-fixtures/`, …); a `migration.json` with no sibling `ops.json` is
* not a complete package and is left untouched.
*
* The hash algorithm is replicated inline (canonicalisation rules from
* `@internal/framework-components` `canonicalizeJson` + the migration-tools
* `computeMigrationHash`) so this script stays self-contained — consumers run
* it via `pnpm exec tsx` from their project root with no dependency on any
* `@internal/*` package being resolvable from that root.
*
* The codemod is idempotent: an already-slimmed manifest carries no
* `labels`/`hints` and already has its recomputed hash, so the edit is a no-op
* and the file is left untouched.
*
* Flags:
* --check dry-run; lists manifests that still need fixing and exits 1 if
* any remain.
*/
import { createHash } from 'node:crypto';
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
const dryRun = process.argv.includes('--check');
const projectRoot = process.cwd();
// --- Inline canonicalisation + hash --------------------------------------
// Replicated from `@internal/framework-components` `canonicalizeJson`
// (sortKeys + JSON.stringify) and the migration-tools `computeMigrationHash`.
// Kept inline so the script has no `@internal/*` import — pnpm's strict
// node_modules layout won't resolve transitive framework deps from a
// consumer's project root.
function sortKeys(value: unknown): unknown {
if (value === null || typeof value !== 'object') {
return value;
}
if (Array.isArray(value)) {
return value.map(sortKeys);
}
const sorted: Record<string, unknown> = Object.create(null);
for (const [key, entry] of Object.entries(value).sort(([a], [b]) =>
a < b ? -1 : a > b ? 1 : 0,
)) {
sorted[key] = sortKeys(entry);
}
return sorted;
}
function canonicalizeJson(value: unknown): string {
return JSON.stringify(sortKeys(value));
}
function sha256Hex(input: string): string {
return createHash('sha256').update(input).digest('hex');
}
/**
* Content-addressed migration hash over (metadata envelope, ops). The
* `migrationHash` field is stripped before hashing so the same function works
* at write time (no hash yet) and at recompute time (rehashing an
* already-attested record over the slimmed envelope).
*/
function computeMigrationHash(metadata: Record<string, unknown>, ops: unknown): string {
const { migrationHash: _migrationHash, ...strippedMeta } = metadata;
const partHashes = [canonicalizeJson(strippedMeta), canonicalizeJson(ops)].map(sha256Hex);
return `sha256:${sha256Hex(canonicalizeJson(partHashes))}`;
}
// --- Format-preserving text surgery --------------------------------------
/**
* Returns the index just past the end of the JSON value that starts at
* `start` (which must point at the value's first character). Handles strings
* (with escapes), nested objects/arrays, and primitives. Used to locate the
* full span of a top-level key's value when removing the key from the raw
* text without reparsing the whole document.
*/
function scanValueEnd(text: string, start: number): number {
const c = text[start];
if (c === '"') {
let i = start + 1;
while (i < text.length) {
if (text[i] === '\\') {
i += 2;
continue;
}
if (text[i] === '"') return i + 1;
i += 1;
}
throw new Error('unterminated string while scanning JSON value');
}
if (c === '{' || c === '[') {
const open = c;
const close = c === '{' ? '}' : ']';
let depth = 0;
let i = start;
while (i < text.length) {
const ch = text[i];
if (ch === '"') {
i = scanValueEnd(text, i);
continue;
}
if (ch === open) depth += 1;
else if (ch === close) {
depth -= 1;
if (depth === 0) return i + 1;
}
i += 1;
}
throw new Error('unterminated container while scanning JSON value');
}
// Primitive (number / true / false / null) — run to the next structural
// terminator.
let i = start;
while (i < text.length && !',}]\r\n \t'.includes(text[i]!)) i += 1;
return i;
}
/**
* Removes a top-level object key (and its value) from `text`, preserving the
* surrounding bytes exactly. No-op (returns `text`) if the key is absent.
* Only the top-level `labels` / `hints` keys are ever passed here; both always
* precede the trailing `migrationHash` key, so a removed key always carries a
* trailing comma that is consumed along with the line.
*/
function removeTopLevelKey(text: string, key: string): string {
// A top-level key is the only occurrence of `"key":` preceded by a newline
// (line 1 is the opening `{`). Tolerant of any indentation width.
const re = new RegExp(`\\n([ \\t]*)"${key}"[ \\t]*:[ \\t]*`);
const match = re.exec(text);
if (match === null) return text;
const lineStart = match.index + 1; // position just after the leading newline
const valueStart = match.index + match[0].length;
let after = scanValueEnd(text, valueStart);
while (text[after] === ' ' || text[after] === '\t') after += 1;
if (text[after] === ',') after += 1;
if (text[after] === '\r') after += 1;
if (text[after] === '\n') after += 1;
return text.slice(0, lineStart) + text.slice(after);
}
function replaceMigrationHash(text: string, oldHash: string, newHash: string): string {
if (oldHash === newHash) return text;
// Tolerate any whitespace around the colon (`"migrationHash":"…"`,
// `"migrationHash" : "…"`), matching the leniency of `removeTopLevelKey`.
const escapedOld = oldHash.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`("migrationHash"[ \\t]*:[ \\t]*)"${escapedOld}"`);
const match = re.exec(text);
if (match === null) {
throw new Error('could not locate the migrationHash value to replace');
}
return text.replace(re, (_full, prefix: string) => `${prefix}"${newHash}"`);
}
// --- Filesystem walk ------------------------------------------------------
async function findMigrationManifests(root: string): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
let entries: Awaited<ReturnType<typeof readdir>>;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
// Unreadable directory — skip silently. The consumer's project root may
// legitimately contain restricted directories.
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
await walk(join(dir, entry.name));
} else if (entry.isFile() && entry.name === 'migration.json') {
out.push(join(dir, entry.name));
}
}
}
await walk(root);
return out.sort();
}
// --- Per-file transform ---------------------------------------------------
/** Narrows an arbitrary JSON-parsed value to a plain object (manifest shape). */
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
type Status = 'already-clean' | 'needs-fix' | 'fixed' | 'skipped-no-ops';
interface Result {
readonly path: string;
readonly status: Status;
}
async function processFile(path: string): Promise<Result> {
const raw = await readFile(path, 'utf-8');
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(
`${path}: not valid JSON (${error instanceof Error ? error.message : String(error)})`,
);
}
if (!isJsonObject(parsed)) {
return { path, status: 'already-clean' }; // not a manifest object
}
const metadata = parsed;
// A complete on-disk migration package pairs `migration.json` with a sibling
// `ops.json` (the operations the hash is computed over); without it we cannot
// recompute the hash, so this is not a package we should touch.
const opsPath = join(dirname(path), 'ops.json');
let ops: unknown;
try {
ops = JSON.parse(await readFile(opsPath, 'utf-8'));
} catch {
return { path, status: 'skipped-no-ops' };
}
// Recompute over the slimmed envelope (canonicalisation is order/whitespace
// independent, so the parsed object is the right input regardless of on-disk
// formatting). `computeMigrationHash` strips `migrationHash` internally.
const slimmed = { ...metadata };
delete slimmed['labels'];
delete slimmed['hints'];
const newHash = computeMigrationHash(slimmed, ops);
let out = raw;
out = removeTopLevelKey(out, 'labels');
out = removeTopLevelKey(out, 'hints');
const oldHash = metadata['migrationHash'];
if (typeof oldHash === 'string') {
out = replaceMigrationHash(out, oldHash, newHash);
} else if (out !== raw) {
// labels/hints were present but there is no string migrationHash to update
// — a malformed manifest we refuse to guess at.
throw new Error(`${path}: manifest is missing a string \`migrationHash\` field`);
}
if (out === raw) {
return { path, status: 'already-clean' };
}
if (!dryRun) await writeFile(path, out, 'utf-8');
return { path, status: dryRun ? 'needs-fix' : 'fixed' };
}
// --- Driver ---------------------------------------------------------------
const manifests = await findMigrationManifests(projectRoot);
if (manifests.length === 0) {
console.error(`No migration.json files found under ${projectRoot}.`);
process.exit(1);
}
let changed = 0;
let alreadyClean = 0;
let skipped = 0;
for (const path of manifests) {
const result = await processFile(path);
const rel = path.slice(projectRoot.length + 1);
if (result.status === 'already-clean') {
alreadyClean += 1;
} else if (result.status === 'skipped-no-ops') {
skipped += 1;
console.log(`SKIP ${rel} (no sibling ops.json — not a migration package)`);
} else {
changed += 1;
const verb = dryRun ? 'WOULD FIX' : 'FIXED';
console.log(`${verb} ${rel}`);
}
}
console.log();
console.log(
`${manifests.length} manifest(s) scanned: ${changed} ${dryRun ? 'needing fix' : 'fixed'}, ${alreadyClean} already clean${skipped > 0 ? `, ${skipped} skipped (no ops.json)` : ''}.`,
);
if (dryRun && changed > 0) process.exit(1);
upgrading/extension/upgrades/0.12-to-0.13/instructions.md
---
from: "0.12"
to: "0.13"
changes:
- id: sqlite-create-table-method
summary: |
SQLite migrations: `createTable` is no longer a free function exported from `@internal/sqlite/migration`. It is now a protected method on the `Migration` base class. If your extension ships SQLite migration files, replace every free `createTable(...)` call with `this.createTable({ table: ..., columns: [...], constraints: [...] })`. If your extension's migration facade re-export test asserts `createTable` is defined, remove that assertion. The `col()`, `lit()`, `fn()`, `primaryKey()`, `foreignKey()`, and `unique()` builder helpers are now exported from `@internal/sqlite/migration` directly.
detection:
glob: "**/migration.ts"
contains:
- "createTable"
- "@internal/sqlite/migration"
anyMatch: false
- id: regen-extension-contracts-strip-empty-type-params
summary: |
The canonicalizer now strips empty `typeParams: {}` from `storage.types` entries in
`contract.json`. Any extension that has shipped a `contract.json` with `typeParams: {}`
on its named-type entries (e.g. `types { Uuid = String @db.Uuid }`) must re-emit its
contract artefacts and re-pin its migration baselines so the on-disk hashes match the
new canonical form.
detection:
glob: "**/contract.json"
contains:
- '"typeParams": {}'
anyMatch: true
- id: thread-namespace-id-through-codec-ref-resolver-spi
summary: |
The codec-resolution SPI in `@internal/sql-relational-core` now takes a leading, required `namespaceId` coordinate. The `CodecDescriptorRegistry.codecRefForColumn(table, column)` build-time helper — the one AST authors call to stamp `codec` onto every column-bound `ParamRef` / `ProjectionItem`, exported from `@internal/sql-relational-core/query-lane-context` and `@internal/sql-relational-core/codec-descriptor-registry` — is now `codecRefForColumn(namespaceId, table, column)`. The underlying free function `codecRefForStorageColumn(storage, table, column)` (exported from `@internal/sql-relational-core/codec-descriptor-registry`) is now `codecRefForStorageColumn(storage, namespaceId, table, column)`. Extension authors who derive codec refs directly must thread the namespace the table sits in at every call site: pass the explicit `namespaceId` ahead of `table`. There is no codemod — the right namespace is call-site-specific (read it from the model/table you are building the ref for). Two same-bare-named tables in different namespaces now resolve to their own per-namespace columns/codecs instead of the first scan hit.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "codecRefForColumn("
- "codecRefForStorageColumn("
anyMatch: true
- id: storage-namespace-envelope-re-emit
summary: |
The storage IR in `contract.json` moved to a namespace envelope
(`storage.namespaces.<ns>.entries.<kind>`). This changes `storageHash` for every
SQL and Mongo extension contract. Re-emit your extension contract artefacts
(`pnpm --filter <your-extension-package> build:contract-space`), then re-pin your
migration baselines so `migrations/refs/head.json`, `end-contract.json`,
`end-contract.d.ts`, `migration.json`, `migration.ts`, and `ops.json` all reflect
the new hash. No source change is required — re-emitting is sufficient.
detection:
glob: "**/contract.json"
anyMatch: true
---
<!--
TML-2843: @internal/sqlite gained a facade-level transaction API
(`SqliteClient.transaction()` + `SqliteTransactionContext`), mirroring
the existing Postgres facade. Purely additive public surface backed by
the unchanged SQL runtime `withTransaction` helper; existing extension
code is unaffected. Incidental substrate diff only.
TML-2838: vitest configs in `packages/3-extensions/postgres` and
`packages/3-extensions/supabase` now pass `--no-memory-protection-keys`
to the test worker forks to stop a V8 WASM-teardown crash on Linux CI.
Test-harness only — no runtime, contract, or public-API change.
Incidental substrate diff only.
TML-2500 M4: `packages/3-extensions/supabase/README.md` link updated
from the old project spec to the canonical ecosystem-extensions doc and
ADR 226. Docs-only; no runtime, contract, or public-API change.
Incidental substrate diff only.
TML-2784: many-to-many became a first-class, validatable contract shape.
`ContractReferenceRelation` is now a cardinality-discriminated union — the
`'N:M'` variant requires a `through` junction descriptor ({ table,
namespaceId, parentColumns, childColumns, targetColumns }); the
non-junction variant carries `through?: never`. Purely additive: N:M
contracts did not validate before this change, so no working extension
constructs them, and existing 1:1 / 1:N / N:1 relation values match the
non-junction variant unchanged. No codemod required.
Bug fix in @internal/sql-orm-client — `orderBy` on a
variant-narrowed collection now resolves MTI variant-owned fields
(previously threw), mirroring the existing variant-aware `where`/`first`
treatment. Additive: the no-variant `orderBy` path and its types are
unchanged; no extension API change. No codemod required.
Release bump 0.13.0 (#789): version-number changes across all workspace
`package.json` files and `pnpm-lock.yaml` specifiers. Incidental substrate
diff — no extension-author action required.
-->
# 0.12 → 0.13 — Extension-author upgrade instructions
## `sqlite-create-table-method`
Starting at this release, `createTable` is no longer a free function exported from `@internal/sqlite/migration`. It is now a protected method on the `Migration` base class — call it as `this.createTable({...})` inside `get operations()`.
If your extension ships SQLite migration files, update them to use `this.createTable(...)` and remove `createTable` from the import list.
If your extension has a facade re-export parity test that asserts `createTable` is defined, remove that assertion; add assertions for `col`, `lit`, `fn`, `primaryKey`, `foreignKey`, and `unique` if your test also checks that the column builders are exported.
The `col()`, `lit()`, `fn()`, `primaryKey()`, `foreignKey()`, and `unique()` builder helpers are now exported from `@internal/sqlite/migration` directly.
See the user-skill entry `sqlite-create-table-method` for the full before/after migration steps — the authoring-surface change is identical for both user and extension migration files.
## `regen-extension-contracts-strip-empty-type-params`
The contract canonicalizer now omits `typeParams` from `storage.types` entries when the
value is an empty object. Previously, emitting a named-type alias like:
```prisma
types {
Uuid = String @db.Uuid
}
```
produced a `contract.json` entry such as:
```json
"types": {
"Uuid": {
"codecId": "pg/text@1",
"kind": "codec-instance",
"nativeType": "uuid",
"typeParams": {}
}
}
```
From this release the canonicalizer strips `typeParams` when it is empty, so the emitted
form is:
```json
"types": {
"Uuid": {
"codecId": "pg/text@1",
"kind": "codec-instance",
"nativeType": "uuid"
}
}
```
Empty and absent `typeParams` are treated as equivalent at every comparison boundary, so
the runtime behaviour is unchanged. The only visible effect is that re-emitting produces a
different `storageHash` — the hash now reflects a `contract.json` without the empty key.
### Re-emit your extension contract
If your extension's `contract.json` carries `"typeParams": {}` on any `storage.types`
entry, re-emit to pick up the canonical form:
```bash
pnpm fixtures:emit
# or, for a single package:
pnpm --filter <your-extension-package> build:contract-space
```
### Re-pin migration baselines
Because the `storageHash` changes, re-generate the migration baselines so
`migrations/refs/head.json`, `end-contract.json`, `end-contract.d.ts`, `migration.json`,
`migration.ts`, and `ops.json` all reflect the new hash.
> **Note:** `scripts/regen-extension-migrations.mjs` is a monorepo-internal tool that
> hard-codes `packages/3-extensions/` paths. It does not exist in external extension
> repos. Follow the manual steps below.
1. Copy the freshly-emitted `src/contract.json` → `migrations/refs/end-contract.json`
and `src/contract.d.ts` → `migrations/refs/end-contract.d.ts`.
2. Open your HEAD migration's `migration.ts` and update the `to` literal to the new
`storageHash` from `src/contract.json`.
3. Run `pnpm exec tsx migrations/<head-migration>/migration.ts` (from the extension
package root) to re-emit `ops.json` and `migration.json`.
4. Update `migrations/refs/head.json` — set `"hash"` to the new `storageHash`,
preserving the existing `"invariants"` array unchanged.
### Validation
After re-emitting and re-pinning, run `pnpm typecheck && pnpm test --filter <your-extension-package>`,
then confirm `prisma-next migration check` passes. The `contract.json` diff should show
`"typeParams": {}` removed from every `storage.types` entry.
## `thread-namespace-id-through-codec-ref-resolver-spi`
Starting at the 0.13 release, every model/table sits in an explicit namespace, and the column-bound codec-resolution SPI in `@internal/sql-relational-core` carries that namespace as a leading, required coordinate. If your extension stamps `codec: CodecRef` onto AST nodes at build time (the "CodecRef invariant for AST authors" path — `descriptors.codecRefForColumn(...)`), or calls the free `codecRefForStorageColumn(...)` against `SqlStorage` directly, you must thread the namespace coordinate through.
### `CodecDescriptorRegistry.codecRefForColumn`
The registry method exported from `@internal/sql-relational-core/query-lane-context` (the `CodecDescriptorRegistry` interface) and built by `buildCodecDescriptorRegistry` (`@internal/sql-relational-core/codec-descriptor-registry`) gained a leading `namespaceId` parameter.
```ts
// Before 0.13
const ref = descriptors.codecRefForColumn('document', 'embedding');
// Starting at 0.13 — namespaceId leads the coordinate args
const ref = descriptors.codecRefForColumn('public', 'document', 'embedding');
```
The namespace is whatever namespace the model/table you are building the ref for lives in — read it from the resolved table coordinate you already hold at the construction site, not a hard-coded literal. The table is now resolved strictly within that namespace, so two same-bare-named tables in different namespaces resolve to their own per-namespace column codecs without colliding.
### `codecRefForStorageColumn`
The free function exported from `@internal/sql-relational-core/codec-descriptor-registry` gained the same leading coordinate, inserted between `storage` and `tableName`.
```ts
// Before 0.13
const ref = codecRefForStorageColumn(storage, 'document', 'embedding');
// Starting at 0.13
const ref = codecRefForStorageColumn(storage, 'public', 'document', 'embedding');
```
It now resolves the table via `resolveStorageTable(storage, tableName, namespaceId)` rather than scanning every namespace for the first bare-name match, so a name that is ambiguous across namespaces is no longer silently bound to whichever namespace happened to enumerate first.
### Validation
This is a type-level signature change — `pnpm typecheck` (or `pnpm build`) pinpoints every call site that still passes the pre-0.13 argument list. Fix each one by inserting the namespace coordinate, then run your extension's standard `pnpm test`.
## Validation by execution
This entry is prose-only — there is no colocated codemod, so no execution-replay applies. The right namespace coordinate is call-site-specific (it depends on which model/table the AST node is bound to), so the translation is per-site agent reasoning rather than a deterministic transform. The substrate diff inside `packages/3-extensions/` in this transition is the same translation downstream extension authors replicate by hand: the namespace coordinate threaded through every column-bound codec-ref construction site. The release-pipeline gate (`pnpm check:upgrade-coverage`) is satisfied by this directory carrying at least one entry; the substantive verification of the consumer-facing translation lives in the published extension-upgrade skill's per-step bump-install-instructions-validate-commit loop, which runs in extension authors' own CI.
## `storage-namespace-envelope-re-emit`
The storage IR inside `contract.json` moved to a namespace envelope in 0.13. Every
table and type entry that was previously at the top level of `storage` now lives under
`storage.namespaces.<ns>.entries.<kind>`. Cross-references that were bare strings are
now `{ namespace, model }` objects in `domain`. The emitter handles the shape change
automatically — no source change is needed.
Because the shape change affects `storageHash`, every extension contract must be
re-emitted and migration baselines re-pinned.
### Re-emit your extension contract
```bash
pnpm --filter <your-extension-package> build:contract-space
```
### Re-pin migration baselines
Follow the manual re-pin steps described in the
[`regen-extension-contracts-strip-empty-type-params`](#regen-extension-contracts-strip-empty-type-params)
section above: copy `src/contract.{json,d.ts}` to `migrations/refs/end-contract.*`,
update `migration.ts` with the new `storageHash`, re-run `tsx migration.ts` to
re-emit `ops.json` + `migration.json`, then update `migrations/refs/head.json`.
### Validation
After re-emitting and re-pinning, run `pnpm typecheck && pnpm test --filter
<your-extension-package>`, then confirm `prisma-next migration check` passes.
## Declarative PSL-block SPI (additive)
**Informational — no action required.**
This release adds a declarative SPI for extension-contributed top-level PSL blocks.
Register an `AuthoringPslBlockDescriptor` under `AuthoringContributions.pslBlockDescriptors`
(exported from `@internal/framework-components`) and the framework's generic PSL
parser, validator, and printer handle the block round-trip through `contract infer`
without any per-block parsing code. Each descriptor claims a PSL keyword and supplies
the argument schema; a matching `entityTypes` entry lowers the parsed node to an IR
class instance.
This is purely additive — existing extensions that use hand-written PSL-block parsers
are unaffected. Adopt `pslBlockDescriptors` when you want the framework to own the
parse/print cycle for a new top-level block your extension introduces.
## Many-to-many contracts (additive)
No extension-author action required for the many-to-many change: M:N relations became a first-class, validatable contract shape this release (`'N:M'` cardinality with a required `through` junction descriptor). It is additive — existing non-junction relations and the public framework factories (`crossRef`, the contract-builder) are unchanged.
upgrading/extension/upgrades/0.13-to-0.14/instructions.md
---
from: "0.13"
to: "0.14"
changes:
- id: uuid-preset-rename
summary: |
The uuid field presets are renamed: `field.uuid()` → `field.uuidString()`,
`field.id.uuidv4()` → `field.id.uuidv4String()`, `field.id.uuidv7()` →
`field.id.uuidv7String()`. These names now describe the storage encoding
(char(36) string). Postgres-native uuid storage uses the new
`field.uuidNative()` / `field.id.uuidv4Native()` / `field.id.uuidv7Native()`
presets from `@internal/postgres/contract-builder`.
detection:
glob: "**/*.ts"
contains:
- "field.uuid()"
- "field.id.uuidv4()"
- "field.id.uuidv7()"
anyMatch: true
script: uuid-preset-rename.ts
- id: qualify-flat-builder-accessors
summary: |
The builder-layer flat accessors are removed: `@internal/sql-builder`'s `sql()` and
`@internal/sql-orm-client`'s `orm()` now expose per-namespace facets only. Extension
code that builds queries by accessing a bare table/model on the builder output
(`sql.<table>` / `orm.<Model>`) must name the namespace the table/model is declared in:
`sql.<namespace>.<table>`, `orm.<namespace>.<Model>` (`public` for a standard
single-schema SQL contract; the late-bound `__unbound__` namespace for an unbound/SQLite
contract). There is no codemod — the correct namespace is the one each table/model is
declared in, which is call-site-specific. Extensions that only contribute codecs, types,
or migrations (and never build queries through `sql`/`orm`) are unaffected.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "@internal/sql-builder"
- "@internal/sql-orm-client"
anyMatch: true
- id: create-runtime-removed
summary: |
`createRuntime` is removed from `@internal/sql-runtime`. Extension code that
constructed a runtime via `createRuntime(...)` must switch to the target class
constructor directly: `new PostgresRuntimeImpl({...})` from
`@internal/postgres/runtime`, or `new SqliteRuntimeImpl({...})` from
`@internal/sqlite/runtime`. Pass the same options minus the `stackInstance`
unpacking — supply `adapter` directly instead of `stackInstance.adapter`.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "createRuntime"
- id: migration-op-factories-to-methods
summary: |
The bare migration op factory functions are removed from
`@internal/postgres/migration` (and the deprecated
`@internal/target-postgres/migration` alias). Replace each import and
call-site with the corresponding method on `this` inside your `Migration`
subclass. The option shapes changed from positional arguments to a single
options object.
detection:
glob: "**/migration.ts"
contains:
- "from '@internal/postgres/migration'"
- "from '@internal/target-postgres/migration'"
anyMatch: true
script: migration-op-factories-to-methods.ts
- id: namespace-entries-open-dict
summary: |
`SqlNamespace.entries` is now an open dictionary typed
`Readonly<Record<string, Readonly<Record<string, unknown>>>>`. The previously
closed shape (`{ table?: ..., valueSet?: ... }`) is gone — dot-access
like `.entries.table` or `.entries.collection` no longer compiles. Read tables via
the `namespaceTables(ns)` helper from `@internal/sql-contract/types`, or via
bracket notation `entries['table']`. For typed getter access on the concrete
class instances use the non-enumerable getters (`ns.table`, `db.collection`).
Annotations and type constraints that hard-code the closed shape
must be widened to the open dict.
detection:
glob: "**/*.{ts,tsx}"
contains:
- ".entries.table"
- ".entries.collection"
- ".entries.valueSet"
anyMatch: true
- id: enum-becomes-domain-concept
summary: |
The native Postgres enum surface is deleted from the SPI. `PostgresEnumStorageEntry`
no longer exists in `@internal/sql-contract/types` — the `SqlStorage.types` slot
now holds codec-instance entries only (`StorageTypeInstance`). The `pg/enum@1`
codec surface is deleted from `@internal/target-postgres` (`PgEnumDescriptor`,
`pgEnumColumn`, `PG_ENUM_CODEC_ID`, the `enum` codec-type-map entry), as are the
native `enumType` / `enumColumn` helpers from
`@internal/adapter-postgres/column-types`. Enums are domain entities plus a
storage `valueSet` enforced by a CHECK constraint; columns reference them as
`pg/text@1` (or another codec) with a `valueSet` ref. Extensions that referenced
`PostgresEnumStorageEntry` in type constraints drop it (use `StorageTypeInstance`
alone); fixtures that used `pg/enum@1` as a codec id must switch to a live codec
or an inert fixture id.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "PostgresEnumStorageEntry"
- "pg/enum@1"
- "pgEnumColumn"
- "PgEnumDescriptor"
- "PG_ENUM_CODEC_ID"
anyMatch: true
- id: namespaced-type-resolution
summary: |
Per-namespace type resolution. The emitted TypeMaps `ExtractFieldOutputTypes` /
`ExtractFieldInputTypes` (from `@internal/sql-contract`) now nest by namespace —
`{ [namespace]: { [model]: { [field] } } }` — and `TableProxy` (from
`@internal/sql-builder`) takes a required namespace coordinate: `TableProxy<C, Name>`
becomes `TableProxy<C, NsId, Name>`. Extension code that indexes those TypeMaps or
constructs `TableProxy` types directly must thread the namespace coordinate
(`ExtractFieldOutputTypes<C>[namespace][Model][Field]`, `TableProxy<C, namespace, Name>`):
`public` for a standard single-schema contract, the late-bound `__unbound__` namespace
for an unbound/SQLite contract. Extensions that only contribute codecs, native types, or
migrations and never reference these types are unaffected.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "ExtractFieldOutputTypes"
- "ExtractFieldInputTypes"
- "TableProxy<"
anyMatch: true
- id: contract-model-definitions-removed
summary: |
`ContractModelDefinitions` is removed from `@internal/contract` (and its
`/types` export), along with the second `TModels` type parameter on
`Contract` (now `Contract<TStorage>`). The flat cross-namespace model union
it produced is gone; resolve models per-namespace instead. Replace
`ContractModelDefinitions<C>` with
`C['domain']['namespaces'][<ns>]['models']` (use `[keyof C['domain']['namespaces']]`
for the sole-namespace case). Family wrappers like `MongoContract<S, M>`
become single-arg `MongoContract<S>`; carry precise per-model types via an
explicit per-namespace `domain` override.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "ContractModelDefinitions"
- "Contract<"
- "MongoContract<"
anyMatch: true
---
<!--
control-query-extension-codecs: pgvector test files
(planner.behavior.test.ts, planner.contract-to-schema-ir.test.ts,
planner.storage-types.test.ts) were updated to pass an explicit
`PostgresControlAdapter` to `createPostgresMigrationPlanner`, which now
requires an adapter argument. Internal test harness change only — no
extension-author API change. Incidental substrate diff only.
-->
<!--
TML-2867: codec-routed DDL defaults. The pgvector extension test files were updated
to await lazy plan operations (`Promise.all(result.plan.operations)`) and to use
`PostgresControlAdapter` instead of the removed `createPostgresAdapter`. The
`packages/3-extensions/postgres re-export test` deletion was already declared by
TML-2859 above. No extension-author API change. Incidental substrate diff only.
-->
<!--
TML-2859: SQLite createTable authoring method. The free `createTable` function from
`@internal/sqlite/migration` is now a protected method on the `SqliteMigration`
base class. The `createTable` re-export test in `packages/3-extensions/sqlite/` was
removed (it asserted the free function was exported, which is no longer true). The
README was updated to reflect the current authoring surface. No extension-author action
beyond what the `sqlite-create-table-method` entry in the 0.12-to-0.13 instructions
already covers. Incidental substrate diff only.
-->
<!--
TML-2785: the sql-orm-client runtime gained M:N correlated include
reads — `.include()` of an N:M relation resolves child rows through the
junction table via a correlated subquery. Internal runtime only; no
extension API or contract-shape change. No extension-author action
required.
TML-2786: the sql-orm-client runtime gained M:N relation filters — a
`where` predicate on an N:M relation lowers to an EXISTS subquery joined
through the junction table. Internal runtime only; no extension API or
contract-shape change. No extension-author action required.
TML-2787: the sql-orm-client runtime gained M:N nested writes — connect /
disconnect / nested create on an N:M relation insert and delete
junction-table rows. Internal runtime only; no extension API or
contract-shape change. No extension-author action required.
TML-2838: the temporary `--no-memory-protection-keys` test-harness workaround
has been removed from every PGlite-backed vitest config (including
`packages/3-extensions/{postgres,supabase}`) now that the WAL-teardown crash is
fixed upstream in `@prisma/dev` 0.24.12 (which pulls in the
`@prisma/streams-local` worker-termination fix). Test-harness only — no
runtime, contract, or public-API change. Incidental substrate diff only.
-->
<!--
#823: `jose` bumped from ^5 to ^6 in `packages/3-extensions/supabase`. The Supabase
extension's transitive auth dependency only — no extension-author API, runtime, or
contract-shape change. Incidental substrate diff only.
-->
<!--
TML-2852: the enum read surface. Additive surface for `enumType`-authored enums.
`@internal/postgres/contract-builder` gains `enumType` / `member` exports (the
Postgres-bound `enumType` constrains member values to the column codec) and the
factory `defineContract` overload threads a top-level `enums` key;
`@internal/sql-orm-client` gains the lane-agnostic `db.enums.<namespace>.<Name>`
runtime accessor map (built from `domain.namespaces[ns].enum`) and value-union
narrowing of enum-restricted fields, plus emit-time narrowing in the emitter from a
field's `valueSet` ref. All additive — existing exports and the framework SPI are
unchanged, PSL `enum` stays native until the cutover, and `fixtures:check` is
byte-identical. No extension-author action. Incidental substrate diff only.
-->
# 0.13 → 0.14 — Extension-author upgrade instructions
## `uuid-preset-rename`
The uuid field preset names now include the storage encoding suffix:
| Before | After |
| --- | --- |
| `field.uuid()` | `field.uuidString()` |
| `field.id.uuidv4()` | `field.id.uuidv4String()` |
| `field.id.uuidv7()` | `field.id.uuidv7String()` |
Apply the rename in any extension test files, contract fixture files, or documentation that uses these presets. The rename is mechanical — run the colocated script or apply the substitutions directly:
```ts
// Before
id: field.id.uuidv7(),
userId: field.id.uuidv4(),
externalId: field.uuid(),
// After
id: field.id.uuidv7String(),
userId: field.id.uuidv4String(),
externalId: field.uuidString(),
```
No change to emitted `contract.json` — both old and new preset names emit the same codec (`sql/char@1`).
## `qualify-flat-builder-accessors`
The query builder (`@internal/sql-builder`) and ORM client (`@internal/sql-orm-client`) are now **always qualified by namespace**. The flat by-bare-name accessors are gone: the value returned by `sql({ … })` / `orm({ … })` is a map of per-namespace facets, so there is no `sql.<table>` and no `orm.<Model>` at the top level. You reach a table or model by naming its namespace.
This affects extension code that *builds queries* through these packages. Extensions that only contribute codecs, native types, or migration operations — and never construct a `sql`/`orm` query — need no change.
### Migrate query-building call sites
Insert the namespace segment after the builder output, naming the namespace each table/model is declared in:
```ts
// Before
const plan = sql.user.select('id', 'email').build();
const row = await orm.User.find({ where: { id } });
// After — name the namespace (`public` for a standard single-schema SQL contract)
const plan = sql.public.user.select('id', 'email').build();
const row = await orm.public.User.find({ where: { id } });
```
For an unbound contract (e.g. SQLite, or any target whose entities live in the late-bound namespace) the namespace segment is `__unbound__` — import `UNBOUND_NAMESPACE_ID` from `@internal/framework-components/ir` and index with it (`sql[UNBOUND_NAMESPACE_ID].user`) rather than hard-coding the string. For a multi-namespace contract, name the specific namespace each table/model sits in.
### Validation
This is a type-level change — `pnpm typecheck` (or `pnpm build`) pinpoints every remaining flat access as a compile error (`Property '<table>' does not exist on type 'Db<…>'`). Fix each by inserting the namespace segment, then run your extension's standard `pnpm test`.
## `migration-op-factories-to-methods`
The bare op factory functions previously exported from `@internal/postgres/migration` (and the deprecated `@internal/target-postgres/migration` alias) are removed. Each function is now a protected method on the `PostgresMigration` base class — call it as `this.<method>(...)` inside your extension's `Migration` subclass.
The option shapes also changed: positional arguments are replaced by a single options object.
Remove the bare names from your import and replace each call-site:
| Before (bare function) | After (method) |
| --- | --- |
| `dropColumn(schema, table, column)` | `this.dropColumn({ schema, table, column })` |
| `setNotNull(schema, table, column)` | `this.setNotNull({ schema, table, column })` |
| `setDefault(schema, table, column, defaultSql)` | `this.setDefault({ schema, table, column, defaultSql })` |
| `addPrimaryKey(schema, table, name, columns)` | `this.addPrimaryKey({ schema, table, constraint: name, columns })` |
| `addForeignKey(schema, table, { name, columns, references, onDelete })` | `this.addForeignKey({ schema, table, foreignKey: { name, columns, references, onDelete } })` |
| `addCheckConstraint(schema, table, name, column, values)` | `this.addCheckConstraint({ schema, table, constraint: name, column, values })` |
| `createIndex(schema, table, indexName, columns)` | `this.createIndex({ schema, table, index: indexName, columns })` |
| `installExtension({ id, extensionName, invariantId })` | `this.installExtension({ id, extensionName, invariantId })` |
Example (extension migration):
```ts
// Before
import { installExtension, Migration, MigrationCLI } from '@internal/target-postgres/migration';
override get operations() {
return [
installExtension({
id: 'my-ext.install',
extensionName: 'my_extension',
invariantId: MY_INVARIANTS.install,
}),
];
}
// After
import { Migration, MigrationCLI } from '@internal/target-postgres/migration';
override get operations() {
return [
this.installExtension({
id: 'my-ext.install',
extensionName: 'my_extension',
invariantId: MY_INVARIANTS.install,
}),
];
}
```
The colocated script applies this transformation automatically. Run it from your extension root:
```bash
pnpm exec tsx .claude/skills/prisma-8/upgrading/extension/upgrades/0.13-to-0.14/migration-op-factories-to-methods.ts
```
## `create-runtime-removed`
`createRuntime` is removed from `@internal/sql-runtime`. Construct the target runtime class directly instead.
```ts
// Before
import { createRuntime } from '@internal/sql-runtime';
const runtime = createRuntime({ stackInstance, context, driver, ...opts });
// After — Postgres
import { PostgresRuntimeImpl } from '@internal/postgres/runtime';
const runtime = new PostgresRuntimeImpl({ adapter: stackInstance.adapter, context, driver, ...opts });
// After — SQLite
import { SqliteRuntimeImpl } from '@internal/sqlite/runtime';
const runtime = new SqliteRuntimeImpl({ adapter: stackInstance.adapter, context, driver, ...opts });
```
The options are identical except `stackInstance` is no longer passed: supply `adapter` from `stackInstance.adapter` directly. Depend on the bare-name interfaces (`PostgresRuntime`, `SqliteRuntime`) for type annotations, not the `Impl` classes.
## `namespace-entries-open-dict`
The `entries` property on every namespace class is now an open dictionary:
```ts
// The type is now:
entries: Readonly<Record<string, Readonly<Record<string, unknown>>>>
// Previously it was a closed shape:
entries: {
table?: Readonly<Record<string, StorageTable>>;
valueSet?: Readonly<Record<string, StorageValueSet>>;
}
```
Dot-access like `.entries.table` or `.entries.collection` no longer compiles. Migrate to one of the two canonical read styles:
**Generic/walker code** — bracket notation:
```ts
// Before
const tables = ns.entries.table;
// After — bracket notation
const tables = ns.entries['table'] as Record<string, StorageTable> | undefined;
```
**Typed family/target code** — use the exported family helpers or the class getters:
```ts
// Using the namespaceTables() helper (for SqlNamespace values)
import { namespaceTables, namespaceValueSets } from '@internal/sql-contract/types';
const tables = namespaceTables(ns); // Record<string, StorageTable>
const vsets = namespaceValueSets(ns); // Record<string, StorageValueSet> | undefined
// Using the namespaceCollections() helper (for MongoNamespace values)
import { namespaceCollections } from '@internal/mongo-contract';
const collections = namespaceCollections(ns); // Record<string, MongoCollection>
```
**Type annotations** — widen any closed-shape annotation to the open dict:
```ts
// Before
const ns = namespaces[id] as { entries: { table: Record<string, StorageTable> } };
// After — open dict annotation
const ns = namespaces[id] as { entries: Record<string, Record<string, unknown>> };
// then narrow via the helper:
const tables = namespaceTables(ns);
```
This is a compile-time-only change when using the helpers — no runtime behavior differs. Run `pnpm typecheck` to find all remaining dot-access sites.
## `enum-becomes-domain-concept`
Native Postgres enums are removed from the framework. Enums are now a **domain concept**: a domain enum entity plus a storage `valueSet` entity, with member values stored through an ordinary codec (typically `pg/text@1` → a `text` column) and the value set enforced by a planner-generated CHECK constraint. The whole native surface is deleted:
- `PostgresEnumStorageEntry` is gone from `@internal/sql-contract/types`. The polymorphic `SqlStorage.types` slot now carries codec-instance entries only. Type constraints that accepted both narrow to `StorageTypeInstance`:
```ts
// Before
import type { PostgresEnumStorageEntry, StorageTypeInstance } from '@internal/sql-contract/types';
type TypesConstraint = Record<string, StorageTypeInstance | PostgresEnumStorageEntry>;
// After
import type { StorageTypeInstance } from '@internal/sql-contract/types';
type TypesConstraint = Record<string, StorageTypeInstance>;
```
- The `pg/enum@1` codec and its registry surface are deleted from `@internal/target-postgres`: `PgEnumDescriptor`, `pgEnumColumn`, `PG_ENUM_CODEC_ID`, and the `enum` entry in the codec type map. Test fixtures that used `'pg/enum@1'` as an opaque codec id must switch to a live codec id or an inert fixture id (e.g. `app/test-enum@1`) — the id no longer resolves to a registered codec.
- The native `enumType(name, values[])` / `enumColumn(...)` authoring helpers are deleted from `@internal/adapter-postgres/column-types`. The domain authoring surface is `enumType(name, codecRef, ...member(name, value))` + `member` from the target contract-builder, returned under the contract's `enums` key.
- Introspection no longer adopts native enum types: the adapter records detected native enum type names under `annotations.pg.nativeEnumTypeNames` (names only), and `contract infer` refuses with a diagnostic naming them. The old `annotations.pg.enumTypes` structure is gone.
Columns restricted to an enum now carry `codecId: 'pg/text@1'` (or another codec), `nativeType: 'text'`, and a `valueSet` reference; the owning table carries a check entry. If your extension reads `storage.types` looking for enum shapes, read the namespace's `valueSet` entries instead.
### Validation
`pnpm typecheck` flags every deleted-symbol reference. After fixing, run your extension's standard `pnpm test`.
## `namespaced-type-resolution`
The SQL/ORM type machinery now resolves columns, fields, and models **by namespace coordinate** rather than by bare name across all namespaces. Two type-shape changes affect extension code that depends on these types directly:
1. **Emitted TypeMaps nest by namespace.** `ExtractFieldOutputTypes<C>` / `ExtractFieldInputTypes<C>` (from `@internal/sql-contract`) now return `{ [namespace]: { [model]: { [field]: <type> } } }` instead of the flat `{ [model]: { [field] } }`. Index the namespace first:
```ts
// Before
type Row = ExtractFieldOutputTypes<C>['User'];
// After — name the namespace the model is declared in
type Row = ExtractFieldOutputTypes<C>['public']['User'];
```
2. **`TableProxy` takes a required namespace coordinate.** `TableProxy<C, Name>` (from `@internal/sql-builder`) becomes `TableProxy<C, NsId, Name>`:
```ts
// Before
let p: TableProxy<C, 'users'>;
// After
let p: TableProxy<C, 'public', 'users'>;
```
Use `public` for a standard single-schema SQL contract; for an unbound/SQLite contract use the late-bound namespace (`UNBOUND_NAMESPACE_ID` from `@internal/framework-components/ir`); for a multi-namespace contract, name the namespace each model/table actually sits in. There is no codemod — the correct namespace is call-site-specific. `pnpm typecheck` pins every remaining flat access (`Property '<model>' does not exist on type '{ public: ... }'`). Extensions that only contribute codecs, native types, or migrations — and never reference `ExtractFieldOutputTypes` / `ExtractFieldInputTypes` / `TableProxy` directly — need no change.
## `contract-model-definitions-removed`
`ContractModelDefinitions` is removed from `@internal/contract` (and the `@internal/contract/types` re-export), and the `Contract` interface loses its second `TModels` type parameter — `Contract<TStorage, TModels>` becomes `Contract<TStorage>`. The flat, first-name-wins cross-namespace model union is gone; models resolve per-namespace from the domain plane.
Replace any `ContractModelDefinitions<C>` use with a read of a namespace's models:
```ts
// Before
import type { Contract, ContractModelDefinitions } from '@internal/contract/types';
type Models = ContractModelDefinitions<C>;
type UserModel = Models['User'];
// After — read the sole namespace's models (or name a specific namespace)
type Models = C['domain']['namespaces'][keyof C['domain']['namespaces']]['models'];
type UserModel = Models['User'];
```
If you need a bare model shape rather than the contract's own models, use `ContractModelBase` from `@internal/contract/types`. Family contract aliases drop their model parameter too — `MongoContract<S, M>` becomes `MongoContract<S>`. When you build a contract *type* that must carry precise per-model shapes (e.g. a test fixture or a `defineContract` result type), override the `domain` explicitly:
```ts
type MyContract = Omit<Contract<MyStorage>, 'domain'> & {
readonly domain: {
readonly namespaces: {
readonly public: { readonly models: MyModels };
};
};
};
```
<!--
TML-2550: per-namespace typed resolution. The extension-package contract.d.ts fixtures
(supabase, paradedb, pgvector, postgis) regenerate to the namespace-nested TypeMaps shape
above; the diff round-trips on a consumer re-emit and needs no extension-author action beyond
the `namespaced-type-resolution` entry. Incidental substrate diff only.
-->
<!--
TML-2918: schema-namespaced op-ids for addColumn. The pgvector test files
(planner.behavior.test.ts, planner.contract-to-schema-ir.test.ts) were updated to
assert the new `column.${schema}.${table}.${column}` op-id format for add-column
operations. Test-only assertion updates — no extension-author API change. Incidental
substrate diff only.
-->
<!--
TML-2919: typed-DDL conversion of the not-null-with-temporary-default recipe. The
recipe's ADD COLUMN execute step now lowers a typed `PostgresAlterTable` DDL node
through the adapter, with the temporary backfill value carried as a
`FunctionColumnDefault` — so the emitted DEFAULT clause parenthesizes its expression
(e.g. `DEFAULT ('')` instead of the previous `DEFAULT ''`). Semantically identical
in PostgreSQL (parenthesizing an atomic primary expression in a `DEFAULT` clause is
a no-op). The pgvector `planner.behavior.test.ts` assertion that pins the recipe's
emitted ADD COLUMN SQL was updated to the parenthesized form. Test-only assertion
update — no extension-author API change. Incidental substrate diff only.
-->
<!--
TML-2916: un-namespaced Postgres extension contracts (pgvector, paradedb, postgis,
supabase) regenerate to drop the spurious empty `__unbound__` storage namespace slot
the authoring + serializer pipeline was injecting, restoring ADR 223 compliance.
Migration `head.json` and `migration.ts` hashes update. No extension-author action:
re-emit picks up the new shape. Incidental substrate diff only.
-->
<!--
TML-2886 (redo, PR #841): type SQL enum columns via a baked storage column lookup.
The SQL emitter generates a new `StorageColumnTypes` map in `contract.d.ts`, keyed
`[namespace][table][column]`; `FieldOutputTypes`/`FieldInputTypes` are derived from it
at emit time. The extension-package `contract.d.ts` fixtures (paradedb, pgvector,
postgis, supabase, sql-orm-client test fixture) regenerate to add the `StorageColumnTypes`
block. `contract.json` and hashes are byte-identical; `FieldOutputTypes` is unchanged.
No extension-author API or surface change. Incidental substrate diff only.
-->
upgrading/extension/upgrades/0.13-to-0.14/migration-op-factories-to-methods.ts
/**
* Removes bare migration op factory imports and rewrites call-sites to use
* the method form on `this` (0.13 → 0.14):
*
* dropColumn(schema, table, col) → this.dropColumn({ schema, table, column: col })
* setNotNull(schema, table, col) → this.setNotNull({ schema, table, column: col })
* setDefault(schema, table, col, sql)
* → this.setDefault({ schema, table, column: col, defaultSql: sql })
* addPrimaryKey(schema, table, name, cols)
* → this.addPrimaryKey({ schema, table, constraint: name, columns: cols })
* addForeignKey(schema, table, { name, columns, references, onDelete })
* → this.addForeignKey({ schema, table, foreignKey: { name, columns, references, onDelete } })
* addCheckConstraint(schema, table, name, col, vals)
* → this.addCheckConstraint({ schema, table, constraint: name, column: col, values: vals })
* createIndex(schema, table, idx, cols)
* → this.createIndex({ schema, table, index: idx, columns: cols })
* installExtension({ ... }) → this.installExtension({ ... })
*
* Applies to files importing from '@internal/postgres/migration',
* '@internal/target-postgres/migration', '@internal/sqlite/migration', or
* '@internal/target-sqlite/migration'. Handles only call-sites where all
* arguments are simple literals or identifiers on a single logical token (no
* multi-line positional calls). For complex cases the type-checker will flag
* remaining sites.
*
* Run from the project root:
* pnpm exec tsx <path-to-this-file>
*/
import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'pathe';
const FACTORY_NAMES = [
'dropColumn',
'setNotNull',
'setDefault',
'addPrimaryKey',
'addForeignKey',
'addCheckConstraint',
'createIndex',
'installExtension',
];
/**
* Strip bare factory names from import declarations (handles both single-line
* and multi-line `import { ... } from '...'` forms). Removes the whole import
* line (including trailing newline) when all names are factories.
*/
function stripFactoriesFromImports(src: string): string {
const importRe =
/^[^\S\n]*import\s*\{([^}]+)\}\s*from\s*'@internal\/(?:postgres|target-postgres|sqlite|target-sqlite)\/migration'[^\S\n]*;?[^\S\n]*\n?/gms;
return src.replace(importRe, (full, nameBlock) => {
const names = nameBlock
.split(',')
.map((n: string) => n.trim())
.filter((n: string) => n.length > 0 && !FACTORY_NAMES.includes(n));
if (names.length === 0) return '';
const fromClause = full.slice(full.indexOf('}') + 1);
return `import { ${names.join(', ')} }${fromClause}`;
});
}
/** Reads a quoted string or bare identifier/bracket-balanced token starting at offset. */
function readToken(src: string, offset: number): { value: string; end: number } | null {
let i = offset;
while (i < src.length && src[i] === ' ') i++;
if (i >= src.length) return null;
if (src[i] === "'" || src[i] === '"' || src[i] === '`') {
const q = src[i];
let end = i + 1;
while (end < src.length && src[end] !== q) end++;
return { value: src.slice(i, end + 1), end: end + 1 };
}
let depth = 0;
let end = i;
while (end < src.length) {
const c = src[end];
if (c === '(' || c === '[' || c === '{') depth++;
else if (c === ')' || c === ']' || c === '}') {
if (depth === 0) break;
depth--;
} else if ((c === ',' || c === '\n') && depth === 0) break;
end++;
}
return { value: src.slice(i, end).trim(), end };
}
type Rewrite = {
pattern: RegExp;
rewrite: (m: RegExpExecArray) => string | null;
};
const rewrites: Rewrite[] = [
// dropColumn(schema, table, column)
{
pattern: /\bdropColumn\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const c = readToken(rest, t.end + 1);
if (!c) return null;
return `this.dropColumn({ schema: ${s.value}, table: ${t.value}, column: ${c.value} })`;
},
},
// setNotNull(schema, table, column)
{
pattern: /\bsetNotNull\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const c = readToken(rest, t.end + 1);
if (!c) return null;
return `this.setNotNull({ schema: ${s.value}, table: ${t.value}, column: ${c.value} })`;
},
},
// setDefault(schema, table, column, defaultSql)
{
pattern: /\bsetDefault\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const c = readToken(rest, t.end + 1);
if (!c) return null;
const d = readToken(rest, c.end + 1);
if (!d) return null;
return `this.setDefault({ schema: ${s.value}, table: ${t.value}, column: ${c.value}, defaultSql: ${d.value} })`;
},
},
// addPrimaryKey(schema, table, constraintName, columns)
{
pattern: /\baddPrimaryKey\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const n = readToken(rest, t.end + 1);
if (!n) return null;
const c = readToken(rest, n.end + 1);
if (!c) return null;
return `this.addPrimaryKey({ schema: ${s.value}, table: ${t.value}, constraint: ${n.value}, columns: ${c.value} })`;
},
},
// addCheckConstraint(schema, table, constraintName, column, values)
{
pattern: /\baddCheckConstraint\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const n = readToken(rest, t.end + 1);
if (!n) return null;
const c = readToken(rest, n.end + 1);
if (!c) return null;
const v = readToken(rest, c.end + 1);
if (!v) return null;
return `this.addCheckConstraint({ schema: ${s.value}, table: ${t.value}, constraint: ${n.value}, column: ${c.value}, values: ${v.value} })`;
},
},
// createIndex(schema, table, indexName, columns)
{
pattern: /\bcreateIndex\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const idx = readToken(rest, t.end + 1);
if (!idx) return null;
const c = readToken(rest, idx.end + 1);
if (!c) return null;
return `this.createIndex({ schema: ${s.value}, table: ${t.value}, index: ${idx.value}, columns: ${c.value} })`;
},
},
// addForeignKey(schema, table, { ... }) — wraps opts in `foreignKey:`
{
pattern: /\baddForeignKey\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const opts = readToken(rest, t.end + 1);
if (!opts) return null;
return `this.addForeignKey({ schema: ${s.value}, table: ${t.value}, foreignKey: ${opts.value} })`;
},
},
];
function applyRewrites(src: string): string {
// installExtension already takes an object — just prepend `this.`
let out = src.replace(/(?<!this\.)(?<!\.)\binstallExtension\(/g, 'this.installExtension(');
for (const { pattern, rewrite } of rewrites) {
pattern.lastIndex = 0;
let result = '';
let last = 0;
let match = pattern.exec(out);
while (match !== null) {
const before = out.slice(Math.max(0, match.index - 5), match.index);
if (before.endsWith('this.')) {
result += out.slice(last, match.index + match[0].length);
last = match.index + match[0].length;
match = pattern.exec(out);
continue;
}
const replacement = rewrite(match);
if (replacement === null) {
result += out.slice(last, match.index + match[0].length);
last = match.index + match[0].length;
match = pattern.exec(out);
continue;
}
// Find the matching closing paren for the original call
let depth = 1;
let end = match.index + match[0].length;
while (end < out.length && depth > 0) {
if (out[end] === '(') depth++;
else if (out[end] === ')') depth--;
end++;
}
result += out.slice(last, match.index) + replacement;
last = end;
pattern.lastIndex = last;
match = pattern.exec(out);
}
out = result + out.slice(last);
}
return out;
}
function processFile(src: string): string {
const MIGRATION_IMPORT_RE =
/import\s*\{[^}]+\}\s*from\s*'@internal\/(?:postgres|target-postgres|sqlite|target-sqlite)\/migration'/s;
if (!MIGRATION_IMPORT_RE.test(src)) return src;
const withImports = stripFactoriesFromImports(src);
return applyRewrites(withImports);
}
const raw = execSync(
'git ls-files --cached --others --exclude-standard -- "**migration.ts" "migration.ts"',
{ encoding: 'utf-8' },
).trim();
const files = raw
.split('\n')
.filter(Boolean)
.filter((f) => f.endsWith('migration.ts'));
let changed = 0;
for (const file of files) {
const abs = join(process.cwd(), file);
let content: string;
try {
content = readFileSync(abs, 'utf-8');
} catch {
continue;
}
const relevant =
content.includes("from '@internal/postgres/migration'") ||
content.includes("from '@internal/target-postgres/migration'") ||
content.includes("from '@internal/sqlite/migration'") ||
content.includes("from '@internal/target-sqlite/migration'");
if (!relevant) continue;
const updated = processFile(content);
if (updated !== content) {
writeFileSync(abs, updated, 'utf-8');
console.log(`updated ${file}`);
changed++;
}
}
console.log(`done — ${changed} file(s) updated`);
upgrading/extension/upgrades/0.13-to-0.14/uuid-preset-rename.ts
/**
* Renames uuid field presets (0.13 → 0.14):
* Before: field.uuid() / field.id.uuidv4() / field.id.uuidv7()
* After: field.uuidString() / field.id.uuidv4String() / field.id.uuidv7String()
*
* Run from the project root: pnpm exec tsx <path-to-this-file>
*/
import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'pathe';
const replacements: Array<[RegExp, string]> = [
[/\bfield\.id\.uuidv4\(\)/g, 'field.id.uuidv4String()'],
[/\bfield\.id\.uuidv7\(\)/g, 'field.id.uuidv7String()'],
[/\bfield\.uuid\(\)/g, 'field.uuidString()'],
];
const raw = execSync('git ls-files --cached --others --exclude-standard -- "*.ts"', {
encoding: 'utf-8',
}).trim();
const files = raw.split('\n').filter(Boolean);
let changed = 0;
for (const file of files) {
const abs = join(process.cwd(), file);
let content: string;
try {
content = readFileSync(abs, 'utf-8');
} catch {
continue;
}
const original = content;
for (const [pattern, replacement] of replacements) {
content = content.replace(pattern, replacement);
}
if (content !== original) {
writeFileSync(abs, content, 'utf-8');
console.log(`updated ${file}`);
changed++;
}
}
console.log(`done — ${changed} file(s) updated`);
upgrading/extension/upgrades/0.14-to-0.15/instructions.md
---
from: "0.14"
to: "0.15"
changes:
- id: sql-contract-createnamespace-required
summary: |
The SQL family no longer materialises a placeholder namespace, so authoring a SQL contract now
requires a target namespace factory. If your extension builds a contract via `prismaContract(...)`
or `defineContract(...)` from `@internal/sql-contract-psl` / `@internal/sql-contract-ts`
(rather than through a target pack's own `defineContract` wrapper, which already supplies it),
pass the now-required `createNamespace` option: `postgresCreateNamespace` from
`@internal/target-postgres/types`, or `sqliteCreateNamespace` from `@internal/target-sqlite/control`.
Without it, `contract emit` / build fails at runtime with "createNamespace is not a function".
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "prismaContract("
- "defineContract("
anyMatch: true
- id: sql-namespace-types-renamed-and-removed
summary: |
`SqlNamespace` is now an abstract class and the family placeholder concretion is gone. Rename the
factory-input type `SqlNamespaceTablesInput` -> `SqlNamespaceInput` (it is the `createNamespace`
factory input, not a tables-only type). The removed symbols `buildSqlNamespace`,
`buildSqlNamespaceMap`, `SqlBoundNamespace`, and `SqlUnboundNamespace` have no drop-in replacement:
construct SQL namespaces only through a target `createNamespace` factory (`postgresCreateNamespace`
/ `sqliteCreateNamespace`). Any hand-written SQL namespace type literal or fixture must carry the
target `kind` (e.g. `'postgres-schema'`) instead of the removed `'sql-namespace'` discriminator.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
contains:
- "SqlNamespaceTablesInput"
- "buildSqlNamespace"
- "SqlBoundNamespace"
- "SqlUnboundNamespace"
- "'sql-namespace'"
anyMatch: true
- id: codec-render-value-literal-for-restricted-columns
summary: |
A field/column restricted to a value set (e.g. an enum) now derives its narrowed TS literal
union **through the codec**, not the framework's (now-deleted) domain-enum override. If your
extension authors a custom codec descriptor (`extends CodecDescriptorImpl`) used by a
restricted/enum column, implement `renderValueLiteral(value, side)` on it so the column narrows
to its value union; without it the column widens to the codec's output type
(`CodecTypes[id][side]`). If your extension builds a `CodecLookup` by hand and drives the
framework emitter (`generateContractDts`), expose `renderValueLiteralFor` so the emit path can
reach your descriptor's renderer. Framework-built lookups (via the CLI/build / control-stack)
already supply it — no action there. (`side`: `output` = the read/SELECT type, `input` = the
create/update type.)
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "CodecDescriptorImpl"
- "renderValueTypeFor"
- "renderOutputType"
anyMatch: true
- id: sql-codec-json-result-decoding
summary: |
SQL `encodeJson` / `decodeJson` now use the exact scalar shape produced by the corresponding
database inside JSON values. SQL include decoding calls `decodeJson`; ordinary column decoding
continues to call `decode`. Update custom SQL codecs whose database JSON representation differs
from their normal driver wire representation, then re-emit committed contracts and defaults.
Built-in representation changes are: `pg/bytea@1` base64 -> `\\x`-prefixed hex,
`pg/numeric@1` string -> JSON number, `pg/timestamp@1` UTC `Z` suffix -> no timezone suffix,
`pg/timestamptz@1` UTC `Z` suffix -> `+00:00`, `sqlite/bigint@1` string -> JSON number,
`pg/vector@1` JSON array -> Postgres vector text, and `pg/geometry@1` GeoJSON object -> HEXEWKB
text. SQLite cannot represent BLOB values inside its native JSON values; such queries still fail
at the database boundary rather than receiving a synthetic codec representation.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "encodeJson"
- "decodeJson"
anyMatch: true
- id: mongo-derive-json-schema-value-sets-param
summary: |
`deriveJsonSchema` / `derivePolymorphicJsonSchema` (from `@internal/mongo-contract-psl`) now
source a value-set field's `$jsonSchema` `enum` keyword from a value-set map, not the domain
enum. Their fourth argument changed from a domain-enum map
(`Record<string, ContractEnum>`, read as `members.map(m => m.value)`) to a value-set map
(`FieldValueSets` = `Record<string, { values: readonly JsonValue[] }>`, keyed by the field's
`valueSet` `entityName`). If your extension calls either function directly, pass the storage
value sets (`contract.storage.namespaces[<ns>].entries.valueSet`) instead of `domain.enum`; the
values are identical for enums, so the rendered validator is unchanged. Most extensions author
Mongo contracts through `mongoContract(...)` / `defineContract(...)`, which call these
internally — those need no change.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "deriveJsonSchema"
- "derivePolymorphicJsonSchema"
anyMatch: true
- id: sql-migration-planner-keep-diff-issue-to-ownership-oracle
summary: |
`MigrationPlanner.plan()` (and the SQL family's `SqlMigrationPlannerPlanOptions`) drops the
`keepDiffIssue` option — a caller-supplied `(issue: DiffIssue) => boolean` predicate the
planner applied to its schema diff for multi-space ownership scoping. It is replaced by
`ownership?: SchemaOwnership` — an ownership oracle (`{ declaresEntity(entityName): boolean }`,
exported from `@internal/framework-components/control`) that the `ContractSpaceAggregate`
satisfies. The planner asks it, per live extra node, whether any contract space declares that
entity: a node another space owns is left untouched, a node no space owns is a genuine extra it
may drop under a destructive policy. If your extension calls `planner.plan(...)` directly with
`keepDiffIssue` (rather than through the aggregate's `db init` / `db update` / `migrate`
orchestration, which passes the aggregate as the oracle for you), drop the predicate and pass
the aggregate (or any object implementing `SchemaOwnership`) as `ownership`. There is no
names-set and no filter function — ownership lives in the aggregate; the planner only asks.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "keepDiffIssue"
anyMatch: true
- id: family-sql-collect-sql-schema-issues-removed
summary: |
`collectSqlSchemaIssues`, `collectSqlSchemaIssuesPerNamespace`, and their
`CollectSqlSchemaIssuesOptions` options type are removed from `@internal/family-sql/diff`.
They implemented the coordinate-based relational schema diff the migration planner used before
it moved onto the generic node differ (`plan(start, end)`). There is no drop-in replacement —
if your extension called either function directly to compare a contract against a live/derived
schema, use the generic node differ instead: `diffSchemas` (from
`@internal/framework-components/control`) over two schema-IR trees, or a target's own
`buildXPlanDiff` (e.g. `buildPostgresPlanDiff` from `@internal/target-postgres/diff-database-schema`,
`buildSqlitePlanDiff` from the sqlite target) for the same op-render-stamped comparison the
planner itself runs.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "collectSqlSchemaIssues"
- "collectSqlSchemaIssuesPerNamespace"
- "CollectSqlSchemaIssuesOptions"
anyMatch: true
- id: sql-control-target-descriptor-diff-database-schema-removed
summary: |
`SqlControlTargetDescriptor` (from `@internal/family-sql/control`) drops the
`diffDatabaseSchema` field — the per-target `SchemaDiffer` hook that used to back the
coordinate-based relational diff. If your extension implements a custom SQL target descriptor
and supplied this field, remove it; the migration planner reaches the one differ directly via
the target's own diff-tree builder now (see the `family-sql-collect-sql-schema-issues-removed`
entry above). `diffSchemaForVerdict` (the full-tree node diff the verify verdict derives from)
is unaffected and still required.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "diffDatabaseSchema"
anyMatch: true
- id: migration-tools-aggregate-strategy-rename
summary: |
`@internal/migration-tools/aggregate` renames its exported graph-walk strategy to say what
it does, not how it's implemented: `graphWalkStrategy` -> `resolveRecordedPath`,
`GraphWalkOutcome` -> `ResolveRecordedPathOutcome`, `GraphWalkStrategyInputs` ->
`ResolveRecordedPathInputs`. The function's behaviour, inputs, and outcome shape are
unchanged — only the names. If your extension imports any of these symbols directly (rather
than going through `planMigration`, which handles this internally), update the import names.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "graphWalkStrategy"
- "GraphWalkOutcome"
- "GraphWalkStrategyInputs"
anyMatch: true
- id: target-postgres-diff-postgres-database-schema-removed
summary: |
`diffPostgresDatabaseSchema` is removed from `@internal/target-postgres/planner` — the
Postgres-specific coordinate-based `SchemaDiffer` implementation, retired alongside
`SqlControlTargetDescriptor.diffDatabaseSchema` (see the entry above). If your extension
imported it directly, use `buildPostgresPlanDiff` from
`@internal/target-postgres/diff-database-schema` instead — it runs the same one-differ
comparison the planner itself uses (relational + RLS-policy issues in one node-typed list,
filter to the subset you need) and additionally stamps the op-render payload the planner reads.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "diffPostgresDatabaseSchema"
anyMatch: true
- id: schema-issue-vocabulary-retired
summary: |
The coordinate-based issue vocabulary is gone: `BaseSchemaIssue`, `SchemaIssue`,
`EnumValuesChangedIssue`, and the `DiffIssue` union are removed from
`@internal/framework-components/control`. `SchemaDiffIssue` (`{ path, reason, message,
expected?, actual? }`) is the only issue shape everywhere now — verify results, the codec
`verifyType` hook, and `SchemaVerifier.issues` all report it. Its `outcome` field is also
gone; use `reason` (`'not-found'` | `'not-expected'` | `'not-equal'`) instead — `outcome`'s
`'missing'` / `'extra'` / `'mismatch'` map onto those three respectively. If your extension
imports any of the removed types, constructs a `{ kind, table, message }`-shaped issue by
hand, or reads `.outcome` off a `SchemaDiffIssue`, switch to the node-typed shape and
`reason`.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "BaseSchemaIssue"
- "EnumValuesChangedIssue"
- "SchemaDiffOutcome"
- ".outcome === 'missing'"
- ".outcome === 'extra'"
- ".outcome === 'mismatch'"
anyMatch: true
- id: schema-finding-lists-single-list
summary: |
`SchemaFindingLists` (and therefore `VerifyDatabaseSchemaResult.schema` /
`.schema.warnings`) collapses from two lists (`issues: SchemaIssue[]`, `schemaDiffIssues:
SchemaDiffIssue[]`) to one: `{ issues: SchemaDiffIssue[] }`. The framework `SchemaDiff`
class follows the same collapse — its constructor now takes one issue array instead of
two (`new SchemaDiff(issues)`, not `new SchemaDiff(issues, schemaDiffIssues)`), and
`.filter()` narrows the single list. If your extension reads
`result.schema.schemaDiffIssues` (or `.schema.warnings.schemaDiffIssues`) directly, or
constructs a `SchemaDiff` by hand, update both call sites to the single-list shape —
concatenate the old two lists into one, in the same order, if you need to reproduce prior
combined output.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "schemaDiffIssues"
- "new SchemaDiff("
anyMatch: true
- id: codec-verify-type-hook-returns-schema-diff-issue
summary: |
`CodecControlHooks.verifyType` (the storage-type verification hook,
`@internal/family-sql/control`) now returns `readonly SchemaDiffIssue[]` instead of
`readonly SchemaIssue[]` — no more `kind` string; classify by `reason` instead. A storage
type (e.g. a native enum) only ever diverges in its value set, so every paired
`not-equal` finding grades as value drift (suppressed under an `external` control policy,
same as before); `not-found` is a missing type, `not-expected` an extra one. If your
extension implements a custom codec's `verifyType` hook, return `{ path, reason, message,
expected?, actual? }` issues instead of the old `{ kind, table, message }` shape.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "verifyType:"
- "verifyType("
anyMatch: true
- id: policy-target-models-require-rls-attribute
summary: |
If your extension's contract space authors `policy_select` blocks (PSL), each block's
`target` model must now declare `@@rls`; `contract emit` / `build:contract-space` fails
with `PSL_EXTENSION_TARGET_MODEL_MISSING_ATTRIBUTE` otherwise. Add `@@rls` to the
policy-bearing models and re-emit; the contract gains an `rls` marker entity
(`entries.rls[tableName]`) and a new storage hash.
detection:
glob: "**/*.prisma"
contains:
- "policy_select"
anyMatch: true
- id: postgres-table-schema-node-rls-enabled-required
summary: |
`PostgresTableSchemaNodeInput.rlsEnabled` (from `@internal/target-postgres/types`) is
now a required boolean, and `isEqualTo` compares it alongside the table name. Every
`new PostgresTableSchemaNode({ ... })` construction in your extension (planner tests,
diff-tree fixtures, tooling) must supply it explicitly - `false` for a table that is not
RLS-controlled. The expected side derives the value from the contract's `entries.rls`
marker; the actual side from `pg_class.relrowsecurity` at introspection.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "new PostgresTableSchemaNode("
anyMatch: true
- id: authoring-contributions-model-attributes-slot
summary: |
`AuthoringContributions` gains a `modelAttributes` slot and the assembled control-stack
shape (`AssembledAuthoringContributions`) is now five fields - code that constructs the
assembled shape literally (e.g. a stubbed `ContractSourceContext.authoringContributions`
in tests) must add `modelAttributes: {}`. New SPI for pack authors: a target/extension
pack can contribute declarative `@@` model attributes via
`AuthoringContributions.modelAttributes` (an `AuthoringModelAttributeDescriptor` carries
the bare attribute name, an ADR-231 `modelAttribute()` spec, and a lowering that files an
entity into the namespace's `entries[attribute][key]`), and a PSL block descriptor can
declare `requiresModelAttribute: { parameter, attribute }` to demand that the model
named by a ref parameter carries a bare `@@` attribute.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "AssembledAuthoringContributions"
- "authoringContributions: {"
anyMatch: true
- id: native-enum-serialized-in-contract-json
summary: |
`native_enum` entities now serialize into an extension's emitted `contract.json` (previously they
were authoring-time-only — stripped on emit, leaving only the derived `valueSet`). If your
extension declares native Postgres enums — `native_enum` blocks in a `.prisma` contract, or
`pg.enum(...)` / `nativeEnum(...)` columns in the TypeScript DSL — re-emit your bundled contract
(`prisma-next contract emit`) and commit the result, so the `entries.native_enum` maps and the
recomputed `storageHash` land in your checked-in `contract.{json,d.ts}`. Re-emitting is what makes
your pack's enum type names visible in the published contract: a consumer running `contract infer`
with your pack in the stack subtracts your pack-owned enum types by matching those serialized type
names, so an un-re-emitted contract leaves the consumer re-declaring types your pack already owns.
The change is backward compatible (a pre-existing contract still hydrates), so re-emit at your
next release rather than urgently.
detection:
glob: "**/*.{prisma,ts,mts,cts}"
contains:
- "native_enum"
- "pg.enum("
- "nativeEnum("
anyMatch: true
- id: native-enum-entry-keyed-by-physical-type-name
summary: |
A serialized `native_enum` entry is now keyed by its physical Postgres type name — the `@@map`
value, or the declared type name when unmapped — not the TS-facing PascalCase name it previously
used (`entries.native_enum.aal_level`, not `entries.native_enum.AalLevel`). This aligns the
`native_enum` key with every other storage entry (a table keys by its physical name) per ADR 221.
If your extension declares native Postgres enums, re-emit your bundled contract
(`prisma-next contract emit`) and commit the result so the re-keyed `entries.native_enum` map and
the recomputed `storageHash` land in your checked-in `contract.{json,d.ts}`. If your extension code
addresses a `native_enum` entry by key
(`contract.storage.namespaces[<ns>].entries.native_enum[<name>]`), switch that key from the
PascalCase type name to the physical type name.
detection:
glob: "**/*.{prisma,ts,mts,cts}"
contains:
- "native_enum"
- "pg.enum("
- "nativeEnum("
anyMatch: true
- id: scalar-field-state-descriptor-generic
summary: |
`ScalarFieldState` (from `@internal/sql-contract-ts/contract-builder`) changes its first
type parameter from the codec-id string (`CodecId extends string = string`) to the full column
descriptor type (`Descriptor extends ColumnTypeDescriptor = ColumnTypeDescriptor`), so field
states preserve the whole descriptor type — including a native-enum entity's member literal
tuple — instead of only the codec id. If your extension names `ScalarFieldState<...>` with
positional generics, wrap the codec id in the descriptor type: `ScalarFieldState<'pg/text@1',
...>` becomes `ScalarFieldState<ColumnTypeDescriptor<'pg/text@1'>, ...>` (import
`ColumnTypeDescriptor` from `@internal/framework-components/codec`); the remaining six
parameters are unchanged. Two narrowing ride-alongs can surface in exact-type test assertions:
built contract types now keep a descriptor's literal `nativeType`/`typeParams` (previously
widened to `string`), and `pg.enum(handle)` (from `@internal/postgres`) returns a descriptor
whose `entityRef` is non-optional and whose `entityRef.entity` is `PostgresNativeEnum<Members>`
instead of `unknown`. Both remain assignable everywhere the old types were accepted — update
`expectTypeOf`-style equality assertions to the narrowed types; do not re-widen production
types to satisfy them.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "ScalarFieldState"
anyMatch: true
- id: schema-ir-fk-unbound-referenced-schema-absent
summary: |
The family's `contractToSchemaIR` (from `@internal/family-sql/control`) no longer stamps
`referencedSchema` on a derived `SqlForeignKeyIR` whose target is the unbound namespace — the
field is now absent for that case (it previously carried the `__unbound__` sentinel). Namespace
identity is answered by the namespace node's new `isUnbound` getter (on `NamespaceBase` /
`SqlNamespace`), never by comparing an id against the sentinel. If your extension rebuilds a
target schema-IR tree from a `contractToSchemaIR`-derived one and reconstructs each
`SqlForeignKeyIR` (as the Postgres target does in `contractToPostgresDatabaseSchemaNode`),
default the absent value back to the target's own coordinate for the unbound slot:
`referencedSchema: fk.referencedSchema ?? UNBOUND_NAMESPACE_ID`. Extensions that read
`referencedSchema` only for bound (named-schema) FK targets need no change — absence already
meant "unbound" downstream.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "SqlForeignKeyIR"
- "referencedSchema"
anyMatch: true
- id: supabase-pack-contract-complete
summary: |
The `@internal/extension-supabase` shipped contract is now the complete, introspection-generated
description of everything Supabase owns — every `auth` (23) and `storage` (10) table of the
reference platform version (supabase/postgres:17.6.1.106), all 10 native enum types, and the three
roles — up from the previous 5-table minimum. All additive and still `external`: composing apps
re-emit and pick up the new pack storageHash; `db.asServiceRole().supabase.{sql,orm}` now exposes
the full owned table set; extension-aware `contract infer` omits correspondingly more. `db verify`
now requires the full owned set to exist in the live database — real Supabase projects have them;
a local or CI stand-in database should restore the pack's reference fixture: `bootstrapSupabaseShim`
from `@internal/extension-supabase/test/utils` now does exactly that (it restores the complete
reference schema — all Supabase schemas and roles — instead of a hand-authored 5-table subset), so
shim users need no change beyond re-running. The curated `/contract` model handles (AuthUser,
AuthIdentity, AuthSession, StorageBucket, StorageObject) are unchanged.
detection:
glob: "**/*.{ts,mts,cts,tsx,prisma,json}"
contains:
- "@internal/extension-supabase"
anyMatch: true
- id: psl-relation-index-argument
summary: |
PSL's `@relation(...)` gained an optional boolean `index` argument that lowers onto the foreign
key's existing IR `index` flag: `@relation(fields: [x], references: [y], index: false)` declares
the FK without the derived backing-index expectation, for databases whose FK columns genuinely
have no physical index (previously unexpressible in PSL — verify would report the synthesized
index `not-found`). `contract infer` now emits `index: false` automatically for FKs it introspects
without a live backing index, using the same column-key predicate verify uses (shared helper
`backingIndexColumnKeys`/`isBackedByColumnKeys` in `@internal/family-sql`). Purely additive —
omitted `index` keeps the default `true`; existing contracts re-emit byte-identically.
detection:
glob: "**/*.prisma"
contains:
- "@relation"
anyMatch: true
- id: contract-canonicalization-preserves-false
summary: |
The contract canonicalizer no longer strips `value: false` from resolved default-value objects
(bare `false` was treated as an omittable empty value, so a `@default(false)` column lost its
default in the emitted `contract.json` and never round-tripped against live introspection).
Re-emitting a contract that has boolean-`false` column defaults changes its emitted JSON (the
default is now present) and therefore its storageHash. No authoring-surface change; re-emit and
commit the refreshed artifacts.
detection:
glob: "**/*.prisma"
contains:
- "@default(false)"
anyMatch: true
- id: sql-array-columns-round-trip
summary: |
Fixes for scalar-list (array) columns and introspection fidelity that can change emitted/derived
artifacts for affected schemas: (1) the family's `contractToSchemaIR` now keeps an array column's
`nativeType` as the bare element type with `many: true` (previously it baked `"text[]"` into
`nativeType`, so every list column verified `not-equal` against live introspection); (2) Postgres
introspection now excludes expression-keyed indexes (e.g. on `lower(email)`) and no longer
collides a unique and non-unique index over identical columns; (3) `contract infer` carries a
non-default index access method through as `@@index(..., type: "<method>")` — note the type must
be registered in the stack's IndexTypeRegistry to emit. Extensions that snapshot introspection
output or assert on derived schema-IR for array/expression-indexed tables should re-run and
refresh expectations.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "contractToSchemaIR"
- "introspect"
anyMatch: true
- id: postgres-inet-codec
summary: |
The postgres target gains a `pg/inet@1` codec (transparent string carrier, like `pg/uuid@1`):
`inet` columns are now authorable as `String @db.Inet` in PSL and representable in contracts,
and `contract infer` maps an introspected `inet` column to `String @db.Inet` instead of
`Unsupported("inet")`. Purely additive — no existing contract changes; re-running `contract
infer` against a database with inet columns now includes them in the output.
detection:
glob: "**/*.{prisma,ts,mts,cts}"
contains:
- "inet"
- "db.Inet"
anyMatch: true
- id: psl-role-block
summary: |
PSL gains a standalone `role` block on the postgres target, authored inside the explicit
unbound namespace: `namespace unbound { role anon {} }` (name-only, no parameters) lowers to a
first-class `PostgresRole` entity in the contract's `__unbound__` storage slot
(`control: 'external'` — roles are referenced, never owned; the planner emits no role DDL and
`db verify` checks existence via `pg_roles`). The unbound namespace's purpose is late binding
(search_path-resolved tables); roles are declared there because they are cluster-scoped and
belong to no schema. To make this authorable, the "no `namespace unbound { }` alongside named
namespaces" restriction is narrowed to models: a blocks-only unbound namespace is legal next
to named namespaces, while one containing models next to named namespaces stays rejected
(`PSL_RESERVED_NAMESPACE_NAME`). A `role` block anywhere else — a named namespace or the
document top level — is rejected with `PSL_ROLE_BLOCK_OUTSIDE_UNBOUND_NAMESPACE`. Purely
additive for existing contracts.
detection:
glob: "**/*.{prisma,ts,mts,cts}"
contains:
- "role "
- "AuthoringPslBlockDescriptor"
anyMatch: true
- id: supabase-pack-contract-declares-roles
summary: |
The `@internal/extension-supabase` shipped contract now declares Supabase's three standard
Postgres roles (`anon`, `authenticated`, `service_role`) as first-class `role` entities with
`control: 'external'`. `db verify` on a project composing the pack now fails with a `not-found`
schema issue naming each declared role the live database lacks. Real Supabase databases always
have these roles, so hosted projects need no change; a local or CI database that stands in for
Supabase must create them — `bootstrapSupabaseShim` from
`@internal/extension-supabase/test/utils` already does. The public
`SupabaseRoleBinding['role']` type is unchanged (`'anon' | 'authenticated' | 'service_role'`);
it is now derived from the `SupabaseRole` Prisma 8 enum handle's values; the contract declares the roles via the
new PSL `role` blocks inside `namespace unbound { }` (see the `psl-role-block` entry).
detection:
glob: "**/*.{ts,mts,cts,tsx,prisma,json}"
contains:
- "@internal/extension-supabase"
anyMatch: true
---
<!--
Release bump to 0.15.0 (PR #988): the version bump itself. Every
`packages/3-extensions/*/package.json` advances to 0.15.0 (version field +
`workspace:` specifier lockstep; one stray `workspace:*` in target-postgres
normalized to the pinned form). No SPI, contract shape, or emitted artefact
change beyond the pack version stamp. No extension-author action beyond the
normal dependency upgrade this recipe covers. Incidental substrate diff only.
-->
<!--
TML-2503 (extension-supabase Slice E — launch close-out, PR #985): docs only.
The `packages/3-extensions/` touch is `packages/3-extensions/supabase/README.md` —
the package README corrected to as-built (runtime usage, JWT validation modes, the
service_role admin root, unsupported scope). No SPI, contract shape, or emitted
artefact change. Incidental substrate diff only.
-->
<!--
TML-2787 (M:N slice 3): namespace-scoped execution-default refs land in
`@internal/sql-orm-client` (nested writes through a junction, the
required-payload gate, and the namespace-keyed `ExecutionMutationDefault.ref`).
The changes are internal to the ORM client and its emitted-contract consumption;
the extension-author surface is unchanged. No extension-author action — re-emit
picks up the new contract ref shape. Incidental substrate diff only.
TML-2929 (replace legacy PSL parser with CST symbol table): the SQL/Mongo PSL
interpreters now consume a symbol table built from the CST parser instead of the
legacy `parsePslDocument` AST. The only `packages/3-extensions/` touch is a
test-file call-shape rewire in `postgres/test/psl-namespace-qualifier-routing.test.ts`
(`{ document }` → the symbol-table interpreter input); no extension-author API
changed. No extension-author action. Incidental substrate diff only.
TML-2794 (M:N slice 5): wires the `mn-psl` integration fixture into the
`@internal/sql-orm-client` test `emit` script. Test-fixture infrastructure
only; no extension-author surface change. Incidental substrate diff only.
TML-2868 (Postgres RLS slice 1): adds the additive Postgres row-level-security
authoring feature. The only `packages/3-extensions/` touches are the re-emitted
`supabase/src/contract/contract.d.ts` (regeneration picks up the new RLS-capable
contract shape) and the `supabase/test/supabase-bootstrap.ts` test helper. No
extension-author API changed — the framework SPI is unchanged and re-emit
absorbs the contract shape. Incidental substrate diff only.
TML-2931 (entity-kind-migration-seam): implements the entity-kind seam for
schema diffing and provenance-symmetric RLS diff. The `packages/3-extensions/`
touches are test updates in `pgvector/test/migrations/` (planner fixtures
converted to `PostgresSchemaIR`) and `pgvector/test/descriptor.test.ts`
(contract shape updated to remove `__unbound__` namespace and adjust
`FieldOutputTypes`/`FieldInputTypes` to namespace-keyed form; precheck/postcheck
SQL assertions updated for parameterised queries). No extension-author API
changed. Incidental substrate diff only.
TML-2884 (Mongo enum end-to-end vertical): adds the Mongo domain-enum authoring
surface. The `packages/3-extensions/mongo/` touches are:
- New `mongo/src/contract/enum-type.ts` and exports in `mongo/src/exports/contract-builder.ts`
(`enumType`, `member`, `EnumTypeHandle`, `EnumMember`) — all net-new exports; nothing
existing was changed or removed.
- `mongo/src/runtime/mongo.ts` gains a `db.enums` facade property — an additive
field on `MongoClient`; existing fields are unchanged.
- `mongo/package.json` gains `@internal/emitter` and `@internal/mongo-emitter`
devDependencies for the new e2e test.
The `EnumTypeHandle` brand changed from a `Symbol()` to a string-key phantom (`__prismaNextEnumTypeHandle__`);
extension authors never construct or assert against the brand directly, so the structural surface
is unchanged. No extension-author action required — the enum surface is purely additive and
re-emit absorbs the new contract shape. Incidental substrate diff only.
-->
<!--
TML-2886 (redo, PR #841): type SQL enum columns via a baked storage column lookup.
The SQL emitter generates a new `StorageColumnTypes` map in `contract.d.ts`, keyed
`[namespace][table][column]`; `FieldOutputTypes`/`FieldInputTypes` are derived from it
at emit time. The extension-package `contract.d.ts` fixtures (paradedb, pgvector,
postgis, supabase, sql-orm-client test fixture) regenerate to add the `StorageColumnTypes`
block. `contract.json` and hashes are byte-identical; `FieldOutputTypes` is unchanged.
No extension-author API or surface change. Incidental substrate diff only.
-->
<!--
TML-2919: typed-DDL conversion of the not-null-with-temporary-default recipe (slice
1 of the typed-DDL migration-ops project). The recipe's ADD COLUMN execute step
now lowers a typed `PostgresAlterTable` DDL node through the adapter, with the
temporary backfill value carried as a `FunctionColumnDefault` — so the emitted
DEFAULT clause parenthesizes its expression (e.g. `DEFAULT ('')` instead of the
previous `DEFAULT ''`). Semantically identical in PostgreSQL. The recipe's DROP
DEFAULT step also routes through a new typed `DropDefaultAction`. The pgvector
`planner.behavior.test.ts` assertion that pins the recipe's emitted ADD COLUMN
SQL was updated to the parenthesized form. Test-only assertion update — no
extension-author API change. Incidental substrate diff only. (The 0.13 → 0.14
counterpart entry already records the same change; this entry covers the same
substrate diff against the post-0.14.0 main.)
-->
<!--
TML-2911 (native scalar-array storage machinery): the emitted contracts now carry
the adapter-reported `scalarList` capability marker and the bumped envelope
version. The scalar-list machinery threaded through this release is internal — no
authoring path emits a list storage column yet, so extension contracts and runtime
behaviour are unchanged. No extension-author API or surface change. Incidental
substrate diff only.
-->
<!--
PR #894 (postgres-rls slice 2, schema-node-tree-restructure): restructures the
schema-diff node tree, splits `db verify` into per-space contract-satisfaction
plus one unclaimed-elements list, and moves plan/verify scoping into the
aggregate orchestration. The only `packages/3-extensions/` touches are test
files: `pgvector/test/migrations/planner.*.test.ts` (planner fixtures rebuilt
for the schema-node tree) and `supabase/test/classification.e2e.test.ts`
(comment wording). The renamed internals (`AggregateContractSpace`,
`combineVerifyResults`, the planner keep-predicate) are migration-tools/CLI
internals with no references in any extension source. No extension-author API
changed; no extension-author action. Incidental substrate diff only.
-->
<!--
Exercise Mongo enums in retail-store (this PR): the `MongoClient` facade gains two
additive members — `raw` (MongoRawClient) and `execute<Row>(plan)` (direct query
execution without going through `runtime()`). Both additive; existing extension code
is unaffected. No extension-author action required. Incidental substrate diff only.
-->
<!--
TML-2955 (expose the static ExecutionContext symmetrically): the built-in target
facades gain a client-safe `@internal/{mongo,postgres,sqlite}/static` entrypoint
(`<target>Static`) and expose `db.context` / `db.contract`. Internally, the mongo
adapter codec now imports `ObjectId` from `bson` instead of `mongodb` so the static
`ExecutionContext` is genuinely driver-free (client-bundle-safe). All additive /
internal — no extension-author API or surface change; existing extensions are
unaffected. No extension-author action required. Incidental substrate diff only.
-->
<!--
TML-2503 (extension-supabase slice D): `@internal/extension-supabase` gains a
secondary `.supabase` admin root on `asServiceRole()` — new `ServiceRoleDb` /
`SupabaseInternalDb` exports from `/runtime`, backed by the extension contract's own
execution context plus a second runtime sharing the app pool + `service_role` session.
Purely additive for extension authors — no other extension's API is affected, and no
released surface changed (the `WithExtensionNamespaces` export existed only on this
branch's earlier, unmerged merge-design revision and was removed before merge). No
extension-author API change. Incidental substrate diff only.
-->
<!--
TML-2892 (migration-author ContractView): the `unboundNamespace` helper that the
SQLite and Mongo runtimes use to unwrap their single default namespace was lifted
into the shared foundation (`@internal/framework-components/ir`); the two
extension runtimes (`packages/3-extensions/{mongo,sqlite}/src/runtime`) now import
it from there instead of defining a local copy. Behaviour-preserving — the runtime
facade surface (`db.enums`, `sql`, `orm`) is byte-identical. No extension-author
API or surface change; nothing to migrate. Incidental substrate diff only.
-->
<!--
TML-2915 (infer an enum's `@@type` from its members): a PSL `enum` block may now
omit `@@type` and have the codec inferred (text for bare/string members, int for
integers). Additive: the framework gains an optional `AuthoringEntityContext.enumInferenceCodecs`
and a `resolveEnumCodecId` export; each built-in target's config supplies its default
codec ids, and `@internal/adapter-mongo` gains a `./codec-ids` entrypoint. Explicit
`@@type` is unchanged. No extension-author action required — the new context field is
optional and framework-populated. Incidental substrate diff only.
-->
<!--
TML-2912 (PSL native scalar lists, end-to-end): PSL now lowers scalar-list fields
(`String[]`, `Int[]`, …) to native array storage columns instead of the JSONB
fallback, gated on the adapter-reported `scalarList` capability. The review-round
follow-up also makes the adapter capability matrix required end-to-end on the
contract-source seam — `ContractSourceContext.capabilities`,
`InterpretPslDocumentToSqlContractInput`, and the SQL PSL resolution inputs are no
longer optional. These are framework-internal contract-emission types that the
control stack always populates; extension authors do not construct them. The only
`packages/3-extensions/` touch is a one-line test-context update in
`postgres/test/psl-namespace-qualifier-routing.test.ts` (threading the now-required
`capabilities` field). No extension-author API changed — re-emit absorbs the
scalar-list contract shape. No extension-author action required. Incidental
substrate diff only.
-->
<!--
Postgres-RLS slice 2.5 (one-differ-two-ir-planner, the cutover to
`plan(start, end)`): the migration planner now diffs two derived schema IRs
via the generic node differ instead of the coordinate-based relational walk.
The only `packages/3-extensions/` touch is a fixture fix in
`pgvector/test/migrations/{planner.behavior,planner.contract-to-schema-ir}.test.ts`
— the hand-built `PostgresTableSchemaNode` foreign-key fixtures now stamp
`resolvedReferencedSchema` (the differ pairs FK nodes by id, which folds in
that field; an unresolved FK on a hand-built actual node no longer paired with
the derived expected side). Test-fixture-only; no extension-author API
changed. See the `sql-migration-planner-keep-diff-issue-to-ownership-oracle`,
`family-sql-collect-sql-schema-issues-removed`,
`sql-control-target-descriptor-diff-database-schema-removed`,
`migration-tools-aggregate-strategy-rename`,
`target-postgres-diff-postgres-database-schema-removed`,
`schema-issue-vocabulary-retired`, `schema-finding-lists-single-list`, and
`codec-verify-type-hook-returns-schema-diff-issue` entries above for the real
breaking changes this slice makes to the framework SPI.
TML-2976 (native Postgres enums, external Supabase types — this PR): the
`packages/3-extensions/` diff is additive. `@internal/extension-postgres` gains a
`db.nativeEnums` accessor (new `src/runtime/native-enums.ts`; `runtime/postgres.ts`,
`exports/runtime.ts`, and `static/postgres-static.ts` expose it) — a Postgres-only
sibling of `db.enums` for reading external native enum columns.
`@internal/extension-supabase` gains the same accessor and regenerates its bundled
contract (`src/contract/contract.{prisma,json,d.ts}`) to carry the `auth.aal_level`
native enum. A new `sql-orm-client` type test
(`test/native-enum.field-output.test-d.ts`) pins native-enum field-output typing.
All additive — the existing extension-author SPI is unchanged, and re-emit absorbs the
contract shape. No extension-author action required. Incidental substrate diff only.
TML-2962 (extension-aware contract infer, PR #919): `contract infer` now omits DB
elements a stack extension pack's contract space already describes, and resolves an
app table's foreign key into pack-owned space to the qualified cross-space relation
(`<spaceId>:<namespace>.<Model>`, e.g. `supabase:auth.AuthUser`) rather than a bare
local reference. The only `packages/3-extensions/` touch is the `supabase` package
gaining an `infer-cross-space-fk.integration.test.ts` and a `@internal/psl-printer`
devDependency for it — no extension-author API changed. One new behavior worth noting
for pack authors: a pack that declares a table's storage coordinate but no domain
model mapped to it now makes `contract infer` throw (malformed pack); packs normally
ship storage + domain together, so no action for well-formed packs. No extension-author
action required. Incidental substrate diff only.
TML-2965 (native-enum-ts-authoring): a native Postgres enum + `pg.enum` column is now
authorable in the TypeScript DSL, producing a contract byte-identical to the PSL
`native_enum` equivalent (including in a non-`public` schema). The `packages/3-extensions/`
diff is additive: `@internal/extension-postgres` gains `src/contract/native-enum.ts` —
`nativeEnum(name, ...values)` returns a handle whose entity name is `name` and whose
Postgres type name defaults to `name`; chain `.map(typeName)` to override the Postgres
type name only. A column binds the handle through `field.column(pg.enum(handle))` (the
deferred column descriptor resolved at contract-build time). The module exports
`nativeEnum` / `pg` / `NativeEnumHandle` from `src/exports/contract-builder.ts`;
`package.json` gains
`@internal/emitter` and `@internal/sql-contract-emitter` devDependencies for the
new test's `.d.ts` emission assertion. All net-new exports — nothing existing was
changed or removed. No extension-author action required. Incidental substrate diff only.
-->
<!--
TML-2960 (no-emit native-enum column typing): a `field.column(pg.enum(handle))`
column now types as its member-value literal union in `typeof contract` (the
no-emit path), matching what the emit path already produced. The
`packages/3-extensions/` diff is the feature itself plus its type test:
`postgres/src/contract/native-enum.ts` makes `pg.enum()` generic over the
handle's members (returning a descriptor whose `entityRef.entity` is
`PostgresNativeEnum<Members>`), and
`postgres/test/contract-builder/native-enum-typeof.test-d.ts` pins the
resulting `typeof contract` types. Runtime values and emitted
`contract.{json,d.ts}` are byte-identical. The extension-author-facing type
reshape this rides on (`ScalarFieldState`'s first generic) is recorded in the
`scalar-field-state-descriptor-generic` entry above; beyond that, no
extension-author action. Incidental substrate diff only.
-->
<!--
TML-2828 (variant relations on the narrowed accessor, PR #933): the
`packages/3-extensions/` diff is confined to `@internal/sql-orm-client` (itself an
extension). The `.variant('X')`-narrowed predicate accessor now surfaces relations the
variant model declares in the contract, alongside the base model's relations —
`createModelAccessor` resolves a variant-owned relation against the variant's
coordinates (variant table for MTI, base table for STI), and
`VariantAwareModelAccessor` intersects in the variant's relation accessors so
`t.variant('Feature').where(x => x.assignee.some(…))` type-checks and plans a correct
EXISTS. Purely additive to the ORM client's query surface; no extension-author SPI
(`@internal/contract`, `@internal/framework-components`, …) changed. No
extension-author action required. Incidental substrate diff only.
-->
<!--
TML-2883 (rls-ts-authoring): Postgres RLS is now authorable in the TypeScript DSL,
producing contracts wire-name-identical to the PSL `policy_*` / `@@rls` equivalents.
The `packages/3-extensions/` diff is additive: `@internal/postgres` gains
`src/contract/rls.ts` (frozen branded handles from `policySelect` / `policyInsert` /
`policyUpdate` / `policyDelete` / `policyAll` / `rlsEnabled(model)` / `role(name)`;
predicates are opaque strings); its `defineContract` accepts an optional
`entities?: readonly RlsEntityHandle[]` input. The lowering is target-side: the
generic contract build groups `entities` handles by the pack that registered each
`entityKind` and calls the pack's batch hook (`lowerEntityHandles`, a SQL-family
contributions extension exported from
`@internal/sql-contract/entity-handle-lowering-hook`), which target-postgres
implements beside its PSL lowering. A TS-declared `role(name)` lands in
`entries.role` under the `__unbound__` namespace, identical to a PSL `role` block
(roles are cluster-scoped). `@internal/extension-supabase` gains `anon` /
`authenticated` role-handle exports from `/contract`. Supporting additive exports
only elsewhere: `buildContractDefinition` from
`@internal/sql-contract-ts/contract-builder`; `formatRlsPolicyWireName` +
`POLICY_OPERATION_PREDICATES` from `@internal/target-postgres/rls-canonicalize`.
`entities` is the sole public channel for attaching pack entities; the internal
`packEntities` input never had a real author and was removed (test-only, never
documented as user-facing), so no extension-author action is required. Incidental
substrate diff only.
-->
<!--
Dependabot runtime-deps group bump (PR #962): the packages/3-extensions/
diff is package.json dependency version ranges only (arktype ^2.2.2 /
~2.2.2). No extension-facing API, contract shape, or emitted artefact
changes. No user action required. Incidental substrate diff only.
-->
<!--
pg binding resolution by structure, not instanceof (PR #969): the
`packages/3-extensions/` diff is a bug fix plus additive exports. The postgres
extension (`@internal/postgres`) gains two net-new `/runtime` exports —
`isPgPool` / `isPgClient`, structural type guards that identify a `pg`
Pool/Client by shape instead of `instanceof`. `resolvePostgresBinding` and the
`@internal/extension-supabase` `toPool` helper now use them, so a
caller-supplied pool that came from a duplicated `pg` copy in an app bundle
resolves correctly instead of throwing `Unable to determine pg binding type`
at boot. The change only accepts inputs the old `instanceof` check rejected —
nothing that resolved before resolves differently — and the two guards are
additive. No extension-author action required. Incidental substrate diff only.
-->
<!--
TML-2980 (variant-declared SQL ORM includes, PR #976): the `packages/3-extensions/` diff fixes the existing `.variant().include()` API inside `@internal/sql-orm-client` so singleton variant narrowing can include relations declared by that variant, with union-valued narrowing rejecting shadowed ambiguous names. Mutation `RETURNING` rows now also map variant-owned physical columns back to their domain field names, matching reads and the existing static row type. No extension-author SPI, contract shape, syntax, or configuration changed, and existing consumers need no code migration. Incidental substrate diff only.
-->
<!--
postgres-rls project close-out (PR #979): the packages/3-extensions/ diff is
one documentation link in the supabase README — the RLS pointer into the
now-deleted projects/postgres-rls/ directory re-pointed at the promoted
ADR 234 and the Adapters & Targets subsystem doc, with the surface names
updated to the shipped forms. No code, API, contract shape, or emitted
artefact changes. No extension-author action required. Incidental docs-only
diff.
-->
<!--
PR #915 (middleware doc-comment lifecycle fixes): comments-only. The only
`packages/3-extensions/` touch is doc comments in
`packages/3-extensions/middleware-cache/src/cache-middleware.ts`, correcting
stale claims about the cache-hit lifecycle (a hit skips only the driver call
and per-row `onRow` hooks; `beforeExecute` has already run, `afterExecute`
still fires; `decodeRow` still runs). No SPI or behavioural change.
No user action required. Incidental substrate diff only.
-->
upgrading/extension/upgrades/0.15-to-0.16/instructions.md
---
from: "0.15"
to: "0.16"
changes:
- id: extension-supabase-test-utils-export-removed
summary: |
`@internal/extension-supabase` no longer exports the `./test/utils` subpath
(`bootstrapSupabaseShim`), and it is no longer a pattern to copy for extension test
tooling. The import typechecked (types shipped in `dist`), but the subpath never worked
from npm — the shim reads fixture `.sql` files that were never published, so every call
failed with ENOENT before touching a database. Delete any import of
`@internal/extension-supabase/test/utils`; keep hermetic test helpers package-internal
(tests import them by source path) rather than publishing them as subpath exports whose
on-disk fixtures don't ship.
detection:
glob: "**/*.{ts,mts,cts,js,mjs}"
contains:
- "extension-supabase/test/utils"
anyMatch: true
- id: identity-columns-need-explicit-default-under-strict-verify
summary: |
`contract infer` now emits `@default(autoincrement())` for a Postgres `GENERATED ALWAYS AS IDENTITY` / `GENERATED BY DEFAULT AS IDENTITY` column (previously it emitted a bare column with no default, since Postgres reports no `column_default` for an identity column). Symmetrically, `db verify` introspecting a live identity column now resolves its default to `autoincrement()` too (previously it resolved to nothing). This only changes `db verify --strict` — without `--strict`, an undeclared live default is tolerated either way (and it is fully tolerated regardless of strictness under `control: 'external'`, the posture most extension packs declare). If your pack's own tests run `db verify --strict` against a table with an identity column whose contract does not declare `@default(autoincrement())`, verify now reports that default as an unexpected extra. Re-run `contract infer` for the affected table, or add `@default(autoincrement())` by hand.
- id: pluralize-back-relation-names-no-longer-double-pluralize
summary: |
`contract infer`'s back-relation field name generation used a hand-rolled pluralization rule that appended `es` to any table name already ending in `s`/`x`/`z`/`ch`/`sh`, doubling an already-plural table name (`sessions` -> `sessionses`). `contract infer` now uses real inflection (the `pluralize` library) and produces the correct name (`sessions` stays `sessions`; a genuinely singular `status` still becomes `statuses`). This only affects a future `contract infer` run — an already-generated `.prisma` file is untouched, so nothing breaks until you next re-run infer for your pack. If you do re-run `contract infer` against a schema with an already-plural table name, diff the regenerated `.prisma` file for any back-relation field whose name changed — that's a public field name your pack's consumers access via `.include()`/`.select()`/the generated TypeScript types, so a rename is a breaking change to your pack's own published surface, to be versioned and documented like any other.
- id: scalar-type-descriptors-channel-removed
summary: |
`ComponentMetadata.scalarTypeDescriptors` is retired — the unified authoring type namespace
is now the single channel for scalar types. If your extension/adapter descriptor declared
`scalarTypeDescriptors: new Map([['String', 'pg/text@1'], ...])`, move each entry to a
zero-arg type-constructor contribution in the descriptor's `authoring.type` namespace:
`String: { kind: 'typeConstructor', output: { codecId: 'pg/text@1', nativeType: 'text' } }`.
The `nativeType` is now explicit — it was previously derived from the codec's first target
type, so check the codec manifest for the value to inline. Code that read
`ControlStack.scalarTypeDescriptors` / `ContractSourceContext.scalarTypeDescriptors` should
read `stack.scalarTypes` (the scalar type names) or derive the name ->
`{ codecId, nativeType }` map via `collectScalarTypeConstructors(stack.authoringContributions.type)`
from `@internal/framework-components/authoring`. `assembleScalarTypeDescriptors` is
deleted, and `validateScalarTypeCodecIds` now takes the authoring type namespace instead of
a descriptor map.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "scalarTypeDescriptors"
- "assembleScalarTypeDescriptors"
anyMatch: true
- id: postgres-json-rebound-to-native-json
summary: |
On the postgres target the PSL `Json` scalar re-binds from `pg/jsonb@1` / `jsonb` to
`pg/json@1` / `json`; a new bare `Jsonb` scalar carries `pg/jsonb@1` / `jsonb`
(`postgresScalarAuthoringTypes` in `@internal/adapter-postgres`). Extension test
schemas and fixtures that author postgres `Json` fields and mean jsonb storage must
switch those fields to `Jsonb`; assertions that pin the `Json` name's derived binding
(e.g. over `collectScalarTypeConstructors(stack.authoringContributions.type)` or
`stack.scalarTypes`) now expect `Json -> { codecId: 'pg/json@1', nativeType: 'json' }`
plus the new `Jsonb -> { codecId: 'pg/jsonb@1', nativeType: 'jsonb' }` entry. PSL
value-object storage columns still emit jsonb (the interpreter now prefers the target's
`Jsonb` scalar and falls back to `Json`). The legacy `@db.Json` attribute path
(`NATIVE_TYPE_SPECS`) is unchanged, as are sqlite/mongo `Json` bindings and the TS
builder surface (`field.json()`, `jsonbColumn`).
detection:
glob: "**/*.{prisma,ts,mts,cts}"
contains:
- "Json"
anyMatch: true
- id: default-generators-no-longer-set-storage
summary: |
`@default(<generator>)` never mutates a column's storage any more — the type position is
the only storage decider — and the whole generator-storage-override SPI is retired with
it. Removed surfaces: `MutationDefaultGeneratorDescriptor.resolveGeneratedColumnDescriptor`
(`@internal/framework-components/control`) — generator descriptors are now
`{ id, applicableCodecIds?, buildPhases? }` only, and `applicableCodecIds` remains the
validation channel (`PSL_INVALID_DEFAULT_APPLICABILITY` on mismatch); the transitional
`baseScalar` marker on `AuthoringTypeConstructorDescriptor` and
`ScalarTypeConstructorOutput` (`@internal/framework-components/authoring`) — scalar
type-constructor contributions and the derived scalar view are plain
`{ codecId, nativeType, typeParams? }` again; and the `@internal/ids` exports
`resolveBuiltinGeneratedColumnDescriptor` / `GeneratedColumnDescriptor` (the TS spec
helpers `uuidv4()`, `nanoid()`, … still return `GeneratedColumnSpec` bundling their
explicit `sql/char@1` column). Packs that registered a generator descriptor with a
storage-resolution hook must drop the hook; PSL schemas in extension fixtures relying on
`String @default(uuid()/cuid()/nanoid()/ulid())` producing `character(N)` columns must
either accept the target String storage (postgres: `pg/text@1` / `text`) or author the
char storage explicitly in the type position (`Char(36) @default(uuid())`, …), then
re-emit.
detection:
glob: "**/*.{ts,mts,cts,prisma}"
contains:
- "resolveGeneratedColumnDescriptor"
- "resolveBuiltinGeneratedColumnDescriptor"
- "baseScalar"
- "@default(uuid("
- "@default(cuid("
- "@default(nanoid("
- "@default(ulid("
anyMatch: true
---
<!--
Release bump to 0.16.0 (PR #1019): the version bump itself. Every
`packages/3-extensions/*/package.json` advances to 0.16.0 (version field +
`workspace:` specifier lockstep). The bump commit touches only `package.json`
files and `pnpm-lock.yaml` — no SPI, contract shape, or emitted artefact change
beyond the pack version stamp. No extension-author action beyond the normal
dependency upgrade this recipe covers. Incidental substrate diff only.
-->
<!--
TML-3037 (contract infer output round-trips through contract emit): the
`packages/3-extensions/supabase` diff is this repo's own pack regenerating
under the three fixes recorded as `changes[]` entries above, plus two changes
that need no entry because they are purely additive: the postgres target now
registers `btree`/`hash`/`gin`/`gist`/`spgist`/`brin` as built-in index types,
so `auth.one_time_tokens`' two `USING hash` indexes — previously impossible to
emit at all (emit threw `unregistered index type "hash"`) — now come back
declared as `@@index(..., type: "hash")`; and the `Decimal` scalar
(`pg/numeric@1` with no `typeParams`) no longer crashes `ExecutionContext`
construction, a base-scalar fix with no prior working case to break.
The jsonb `dbgenerated` literal defaults on `custom_oauth_providers`
(`attribute_mapping`, `authorization_params`), `webauthn_credentials`
(`transports`), and `iceberg_namespaces` (`metadata`) are also declared again,
each as `@default(dbgenerated("..."))` verbatim — but this is *not* an
authoring-side fix and needs no `changes[]` entry of its own: emit still keeps
the raw expression as `kind: 'function'` exactly as it always did (no
`contract.json` shape change relative to a pack that already declared these
defaults). What changed is `db verify`: the postgres target's `SchemaIR`
construction now resolves the expected side's `kind: 'function'` default
through the same literal-recognizing parser introspection already used, so a
literal-shaped `dbgenerated(...)` default compares equal against the live
column instead of reporting permanent drift. That fix is invisible to
`contract.json` and the contract's `storageHash`, so nothing downstream needs
to regenerate anything on account of it.
`custom_oauth_providers`' `acceptable_client_ids`/`scopes` — also `text[]`
with a `dbgenerated("'{}'::text[]")` default, but on *list* fields — stay
omitted: `contract emit` rejects any function-kind default on a list field
(`PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED`), and `dbgenerated(...)` always
lowers to `kind: 'function'` at authoring time now (a target-owned handler
classifying it as a literal there would need the column's native type,
threaded through the shared `DefaultFunctionLoweringContext` framework type —
exactly the family-vocabulary-in-framework violation this round removed).
This is a known, undocumented-elsewhere limitation, not a regression: it was
never fixed to begin with on the branch this round reviewed.
`generate-contract.ts`'s `DOUBLE_PLURALIZED_FIELD_NAMES` and `INDEX_OMISSIONS`
workaround tables are deleted entirely; `DEFAULT_OMISSIONS` shrinks from 7
entries (across `auth.users`, `auth.custom_oauth_providers`,
`auth.webauthn_credentials`, `storage.iceberg_namespaces`) to 3
(`auth.users.phone`, a `DEFAULT NULL` that round-trips to `@default(null)`, a
different still-open defect; and `auth.custom_oauth_providers`'
`acceptable_client_ids`/`scopes`, the list-default limitation above). No
extension-authoring SPI change — an extension pack's own contract regenerates
automatically the same way this pack's did.
-->
<!--
TML-3027 (foreign keys and indexes are discrete contract entities): emitted
contract-shape change. `contract emit` now materializes the per-FK `constraint`/
`index` authoring booleans into discrete entities — a `foreignKeys[]` entry is the
referential constraint only (no `constraint`/`index` fields), and every backing
index (including one backing a FK) is its own named `indexes[]` entry. The booleans
remain as authoring input (`@relation(index:)`, TS `fk({ constraint, index })`,
`foreignKeyDefaults`). An extension whose contract declares FKs re-emits to the new
shape on the next `contract emit`, with no authoring change. Extension code that
reads `.constraint` / `.index` off a contract's `foreignKeys[]` entry (e.g. custom
migration/verify logic or a hand-built contract fixture) must drop those fields and
read the discrete `indexes[]` entry instead. No SPI or DDL change: the schema-IR the
planner and `db verify` derive is identical. (The `packages/3-extensions/` diff is
pgvector test fixtures updated to the new FK literal shape.)
-->
<!--
Supabase integration close-out (TML-2503): docs-only. The
`packages/3-extensions/` touch is `packages/3-extensions/supabase/README.md` —
links into the deleted `projects/supabase-integration/` workspace re-pointed at
ADR 237 (the service_role secondary-root decision) or inlined as plain text.
No SPI, contract shape, or emitted artefact change. Incidental substrate diff
only.
-->
<!--
TML-3028 (dependency-graph migration ordering; SchemaDiffIssue.reason removed):
the migration-diff internal `SchemaDiffIssue` lost its `reason` field —
discriminate a diff issue via the presence of `expected`/`actual`, or the
exported `issueOutcome(issue): ExpectationFailureReason` helper from
`@internal/framework-components/control`. `ExpectationFailureReason` keeps its
`'not-found' | 'not-expected' | 'not-equal'` values and its export path; it is now
the helper's return type rather than the removed field's type. This is a framework migration-control
internal, not an extension-authoring SPI. The `packages/3-extensions/` diff is
supabase-extension TEST assertions updated from `.reason` to presence — no runtime,
contract, SPI, or DDL change. Incidental test-only diff.
-->
<!--
TML-2783 (explicit MTI selections): `changes: []`. The `packages/3-extensions/sql-orm-client` diff is limited to internal polymorphic projection planning and regression tests; it changes no public API, contract/emitted artifact, extension-authoring surface, adapter API, or downstream source translation.
-->
<!--
Dependabot dev-deps group bump (PR #961): `changes: []`. The
`packages/3-extensions/` diff is biome.jsonc schema-version alignment for the
biome 2.5.2 dev-dependency bump plus the code sites biome 2.5 newly flags
(useOptionalChain in `sql-orm-client/src/collection.ts`); no SPI, contract
shape, emitted artefact, or extension-authoring surface change. Incidental
substrate diff only.
-->
<!--
Dependabot dev-deps group bump (PR #1006): `changes: []`. The
`packages/3-extensions/` diff is test-only — the `sql-orm-client` tests updated
from `(x?.y as T).z` to `(x!.y as T).z` to satisfy biome 2.5.3's stricter
noUnsafeOptionalChaining (biome 2.5.2 → 2.5.3 dev-dependency bump). No SPI,
contract shape, emitted artefact, or extension-authoring surface change.
Incidental substrate diff only.
-->
<!--
TML-2979 (MTI variant predicates in count writes): `changes: []`. The `packages/3-extensions/sql-orm-client` diff fixes internal query planning and adds regression coverage; it changes no public API, contract shape, emitted artefact, extension-authoring surface, adapter API, or downstream source translation. Incidental substrate diff only.
-->
upgrading/extension/upgrades/0.16-to-0.17/instructions.md
---
from: "0.16"
to: "0.17"
changes:
- id: strip-sha256-hash-prefixes
summary: |
Content hashes are bare lowercase hex from 0.17 — the `sha256:` prefix is gone from every
surface (emitted `contract.json` / `contract.d.ts`, migration manifests, refs, CLI output,
and the database marker/ledger), and framework validators (`coreHash()` / `profileHash()`
constructors, manifest and contract loaders) reject the legacy prefixed form. Contract hash
VALUES are unchanged (only the prefix drops; re-emit your pack's committed contract
artefacts), but `migrationHash` VALUES change because the hashed manifest bytes embed the
now-bare `from`/`to` strings. Run the colocated codemod over your extension's checked-in
`migrations/` trees FIRST, before the snapshot-layout migrator in the entries below — the
0.17 layout migrator accepts only bare-hex trees. The codemod handles both layouts: it
strips the prefix from every hash literal (manifests, `ops.json`, pre-store sibling
contract snapshots, store entries under `migrations/snapshots/`, `.d.ts` branded
literals), maps the empty-tree sentinel `sha256:empty` to `empty`, recomputes each
`migrationHash`, and repoints `refs/*.json`. Then drop the prefix from any hash literal
your pack's source, fixtures, or tests hard-code — a prefixed literal now fails validation
instead of round-tripping. Signed databases your extension maintains (acceptance
harnesses, reference instances) whose marker/ledger still hold prefixed values report a
hash mismatch on verify — there is no compatibility shim; re-sign against the regenerated
contract (`prisma-next db sign`).
detection:
glob: "**/*.{json,ts,mts,cts,tsx}"
contains:
- 'sha256:'
anyMatch: true
script: ./strip-sha256-hash-prefixes.ts
- id: migration-contract-snapshots-moved-to-content-addressed-store
summary: |
Committed migration contract snapshots move from per-package sibling files
(`start-contract.json` / `start-contract.d.ts` / `end-contract.json` /
`end-contract.d.ts`) into a single content-addressed store per migrations
root, at `migrations/snapshots/<hex>/contract.json` + `contract.d.ts`,
where `<hex>` is the contract's 64-hex storage hash (bare hex after the
`strip-sha256-hash-prefixes` entry above, which must run first).
Every distinct contract is stored once, however many migrations
reference it. An extension source repo keeps the shallow layout — migration
packages sit directly under `migrations/` (no `app/` segment), so its store
is `migrations/snapshots/` at the same depth, and every emitted
`migration.ts` imports its bookend contracts one level up:
`../snapshots/<hex>/contract.json` / `../snapshots/<hex>/contract.d.ts`
(a consuming project's `app/`-nested migrations import two levels up,
`../../snapshots/...` — do not copy that depth into an extension repo).
This is a clean break: there is no fallback reader for the old sibling-file
layout, so a committed migrations tree that has not been converted fails to
load once you upgrade your extension's tooling — `migration plan` /
`migration new` / `migration check` all read contract snapshots through the
store only, and a missing store entry fails with
`MIGRATION.CONTRACT_SNAPSHOT_MISSING` naming the expected hash and path.
`migration.json` / `ops.json` / `migrationHash` are unaffected — the
contract snapshot was never part of migration identity, so converting the
layout changes no migration's hash, and no extension-authoring SPI changes.
To convert an existing extension repo, run the migrator once from a
checkout of the `prisma/prisma` repository at (or above) the version
you're upgrading to, pointed at your extension's migrations root:
`node scripts/migrate-migrations-layout.mjs <path-to-your-migrations-dir>`.
Per migration package, it reads `migration.json`, write-if-absents the
destination contract (and the source contract, when present) into the
store under the matching hash, rewrites the committed `migration.ts`
import specifiers, and deletes the four sibling files. It asserts every
contract's inner `storage.storageHash` against the hash it's filed under
before writing anything (mismatch aborts the whole run, nothing is
deleted), and re-verifies every `migrationHash` is unchanged after
conversion. Run it, review the diff, then typecheck your extension package
to confirm every rewritten `migration.ts` import resolves.
detection:
glob: "**/migration.ts"
contains:
- "./start-contract.json"
- "./end-contract.json"
- "./start-contract'"
- "./end-contract'"
anyMatch: true
- id: ref-paired-snapshots-moved-to-content-addressed-store
summary: |
Ref-paired contract snapshot files (`refs/<name>.contract.json` /
`refs/<name>.contract.d.ts`) are no longer written or read. A ref is now
only its pointer file, `refs/<name>.json` (`{ hash, invariants }`); the
contract it names resolves through the same content-addressed store as
every migration graph node, `migrations/snapshots/<hex>/contract.json` +
`contract.d.ts`, by that hash. Your extension repo's `migrations/refs/`
normally carries only the system `head.json` pointer, which was never
ref-paired — this only matters if your repo also carries named refs
(e.g. from testing `ref set` against the extension's own migrations
root). A pointer whose store entry is missing now fails with
`MIGRATION.CONTRACT_SNAPSHOT_MISSING` naming the expected hash and path,
rather than silently falling back to the migration graph. The same
one-shot migrator that folds per-package sibling snapshots (see the
entry above) also folds any existing `refs/<name>.contract.json` /
`refs/<name>.contract.d.ts` pairs: it write-if-absents the pair into the
store under the sibling pointer's `hash`, then deletes the pair — the
pointer file itself is read but never written, so it stays
byte-identical. A `.contract.json` with no sibling pointer, or whose
inner `storage.storageHash` disagrees with the pointer's `hash`, aborts
the whole run before anything is written or deleted. Run `node
scripts/migrate-migrations-layout.mjs <path-to-your-migrations-dir>`
(same invocation as above; one run folds both migration-package and
ref-paired snapshots), then review the diff.
detection:
glob: "**/refs/*.contract.json"
anyMatch: true
- id: extension-packs-key-renamed-to-extensions
summary: |
The `extensionPacks` key is renamed to `extensions` across the config
surface, the SPI, and the contract document. In your extension repo:
(1) any `prisma.config.ts` (the extension's own contract space, a
sibling example app, tests) renames `extensionPacks:` to `extensions:` —
the old key fails loudly with "Config.extensionPacks is no longer
supported; rename it to Config.extensions"; (2) the provider-API field
`ContractSourceContext.composedExtensionPacks` is now
`composedExtensions`; (3) the emitted `contract.json` / `contract.d.ts`
top-level key is `extensions`, and because the key sits in the
canonicalized bytes, every contract's `storageHash` / `executionHash` /
`profileHash` changes. Re-run your contract-space build
(`build:contract-space` or `prisma-next contract emit`), re-anchor
`migrations/refs/head.json` and the `migrations/snapshots/<hex>/` store
to the new hashes, and re-emit `ops.json` / `migration.json` for the
head migration (its `to` hash changes). Concept-level SPI type names
(`ExtensionPackRef`, `ControlExtensionDescriptor`,
`validateExtensionPackRefs`) are unchanged. Also renamed in the same
release: `contract.source.sourceFormat` → `format`, and the target
façades' `defineConfig` option `outputPath` → `output`.
detection:
glob: "**/*.{ts,json}"
contains:
- "extensionPacks"
- "composedExtensionPacks"
anyMatch: true
- id: orm-count-only-mutation-terminals-renamed
summary: Replace `createCount(...)`, `updateCount(...)`, and `deleteCount()` with `createAndCount(...)`, `updateAndCount(...)`, and `deleteAndCount()` in ORM call sites; arguments, guards, behavior, and `Promise<number>` results are unchanged, and no compatibility aliases remain.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- ".createCount("
- ".updateCount("
- ".deleteCount("
anyMatch: true
- id: adopt-sql-json-projection-ast-foundations
summary: Migrate relational AST construction and traversal to explicit JSON projection wrappers, expanded scalar-expression variants, grouped function-source aliases, and codec-preserving forwarded projections.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "JsonObjectExpr"
- "JsonArrayAggExpr"
- "ExprVisitor"
- "AnyExpression"
- "FunctionSource.of"
- "ProjectionItem.of"
anyMatch: true
- id: scalar-type-descriptors-channel-removed
summary: |
`ComponentMetadata.scalarTypeDescriptors` is retired — the unified authoring type namespace
is now the single channel for scalar types. If your extension/adapter descriptor declared
`scalarTypeDescriptors: new Map([['String', 'pg/text@1'], ...])`, move each entry to a
zero-arg type-constructor contribution in the descriptor's `authoring.type` namespace:
`String: { kind: 'typeConstructor', output: { codecId: 'pg/text@1', nativeType: 'text' } }`.
The `nativeType` is now explicit — it was previously derived from the codec's first target
type, so check the codec manifest for the value to inline. Code that read
`ControlStack.scalarTypeDescriptors` / `ContractSourceContext.scalarTypeDescriptors` should
read `stack.scalarTypes` (the scalar type names) or derive the name ->
`{ codecId, nativeType }` map via `collectScalarTypeConstructors(stack.authoringContributions.type)`
from `@internal/framework-components/authoring`. `assembleScalarTypeDescriptors` is
deleted, and `validateScalarTypeCodecIds` now takes the authoring type namespace instead of
a descriptor map.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "scalarTypeDescriptors"
- "assembleScalarTypeDescriptors"
anyMatch: true
- id: postgres-native-types-move-to-type-position
summary: |
PostgreSQL native storage types are authored directly in PSL type position, and the legacy `@db.*` attribute channel is removed. Rewrite `BaseType @db.Type` as `Type` and `BaseType @db.Type(args)` as `Type(args)` in extension schemas and test fixtures, then re-run contract emission. Any remaining `@db.X(args)` fails with `@db.X(args) is no longer supported; use X(args) in type position`, preserving the constructor name and arguments in the suggested replacement. The supported translations are `@db.Char` → `Char`, `@db.VarChar` → `VarChar`, `@db.Numeric` → `Numeric`, `@db.Uuid` → `Uuid`, `@db.Inet` → `Inet`, `@db.SmallInt` → `SmallInt`, `@db.Real` → `Real`, `@db.Timestamp` → `Timestamp`, `@db.Timestamptz` → `Timestamptz`, `@db.Date` → `Date`, `@db.Time` → `Time`, and `@db.Timetz` → `Timetz`; preserve constructor arguments. Rewrite the old native-json spelling `Json @db.Json` as bare `Json`. This source migration preserves native types and supplied type parameters. It also preserves codec ids except for `@db.Date` → `Date`, which rebinds `pg/timestamptz@1` to `pg/date@1`, changes the contract storage hash, and requires re-emission plus re-signing; see the `postgres-date-rebound-to-pg-date` entry below. Separately, apply the `postgres-json-rebound-to-native-json` entry below to old bare `Json` fields that meant jsonb storage.
detection:
glob: "**/*.prisma"
contains:
- "@db."
anyMatch: true
- id: postgres-json-rebound-to-native-json
summary: |
On the postgres target the PSL `Json` scalar re-binds from `pg/jsonb@1` / `jsonb` to
`pg/json@1` / `json`; a new bare `Jsonb` scalar carries `pg/jsonb@1` / `jsonb`
(`postgresScalarAuthoringTypes` in `@internal/adapter-postgres`). Extension test
schemas and fixtures that author postgres `Json` fields and mean jsonb storage must
switch those fields to `Jsonb`; assertions that pin the `Json` name's derived binding
(e.g. over `collectScalarTypeConstructors(stack.authoringContributions.type)` or
`stack.scalarTypes`) now expect `Json -> { codecId: 'pg/json@1', nativeType: 'json' }`
plus the new `Jsonb -> { codecId: 'pg/jsonb@1', nativeType: 'jsonb' }` entry. PSL
value-object storage columns still emit jsonb (the interpreter now prefers the target's `Jsonb` scalar and falls back to `Json`). The removed `@db.Json` spelling must be rewritten from `Json @db.Json` to bare `Json`; any remaining use fails with migration guidance to use `Json` in type position. SQLite and Mongo `Json` bindings and the TS builder surface (`field.json()`, `jsonbColumn`) are unchanged.
detection:
glob: "**/*.{prisma,ts,mts,cts}"
contains:
- "Json"
anyMatch: true
- id: default-generators-no-longer-set-storage
summary: |
`@default(<generator>)` never mutates a column's storage any more — the type position is
the only storage decider — and the whole generator-storage-override SPI is retired with
it. Removed surfaces: `MutationDefaultGeneratorDescriptor.resolveGeneratedColumnDescriptor`
(`@internal/framework-components/control`) — generator descriptors are now
`{ id, applicableCodecIds?, buildPhases? }` only, and `applicableCodecIds` remains the
validation channel (`PSL_INVALID_DEFAULT_APPLICABILITY` on mismatch); the transitional
`baseScalar` marker on `AuthoringTypeConstructorDescriptor` and
`ScalarTypeConstructorOutput` (`@internal/framework-components/authoring`) — scalar
type-constructor contributions and the derived scalar view are plain
`{ codecId, nativeType, typeParams? }` again; and the `@internal/ids` exports
`resolveBuiltinGeneratedColumnDescriptor` / `GeneratedColumnDescriptor` (the TS spec
helpers `uuidv4()`, `nanoid()`, … still return `GeneratedColumnSpec` bundling their
explicit `sql/char@1` column). Packs that registered a generator descriptor with a
storage-resolution hook must drop the hook; PSL schemas in extension fixtures relying on
`String @default(uuid()/cuid()/nanoid()/ulid())` producing `character(N)` columns must
either accept the target String storage (postgres: `pg/text@1` / `text`) or author the
char storage explicitly in the type position (`Char(36) @default(uuid())`, …), then
re-emit.
detection:
glob: "**/*.{ts,mts,cts,prisma}"
contains:
- "resolveGeneratedColumnDescriptor"
- "resolveBuiltinGeneratedColumnDescriptor"
- "baseScalar"
- "@default(uuid("
- "@default(cuid("
- "@default(nanoid("
- "@default(ulid("
anyMatch: true
- id: postgres-date-rebound-to-pg-date
summary: |
On the postgres target, the bare `Date` type constructor (`postgresNativeAuthoringTypes` in `@internal/adapter-postgres`) re-binds from `pg/timestamptz@1` to the dedicated `pg/date@1` codec. Rewrite the removed `DateTime @db.Date` spelling as `Date`; leaving it unchanged now fails with migration guidance to use `Date` in type position. The stored native type is unchanged (`date`). Extension assertions over `collectScalarTypeConstructors(stack.authoringContributions.type)` now expect `Date -> { codecId: 'pg/date@1', nativeType: 'date' }`. Extension test schemas and fixtures with date columns produce a different codec ref and contract storage hash on re-emit; regenerate committed contract artefacts and update pinned hash or codec-ref literals. Runtime fixtures change shape too:
`pg/date@1` canonicalizes the JS value as a `Date` at UTC midnight
(`new Date(Date.UTC(y, m, d))`) instead of passing through the driver's local-midnight
`Date`, and its JSON form is the bare `YYYY-MM-DD` string, so relation `.include()`
decode over date columns now succeeds instead of failing with `RUNTIME.DECODE_FAILED`.
Contracts emitted before the upgrade keep working (`pg/timestamptz@1` still exists).
detection:
glob: "**/*.{prisma,ts,mts,cts}"
regex:
- '@db\.Date'
- '\sDate(\s|\?|\[|$)'
anyMatch: true
- id: sql-escape-error-class-removed
summary: |
The `SqlEscapeError` class is deleted from `@internal/target-postgres` and
`@internal/target-sqlite` (including its re-export from the postgres/sqlite
adapter `control` entrypoints). Identifier/literal escaping failures now throw a
structured envelope with code `CONTRACT.IDENTIFIER_INVALID`. Replace
`error instanceof SqlEscapeError` with
`isStructuredError(error) && error.code === 'CONTRACT.IDENTIFIER_INVALID'`
(`isStructuredError` from `@internal/utils/structured-error`). Message text is
unchanged.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "SqlEscapeError"
- id: supabase-error-classes-removed
summary: |
The `SupabaseConfigError` and `InvalidJwtError` classes are deleted from
`@internal/extension-supabase/runtime`. The same failures now throw
structured envelopes with codes `SUPABASE.CONFIG_INVALID` and
`SUPABASE.JWT_INVALID`. Replace `error instanceof SupabaseConfigError` with
`isStructuredError(error) && error.code === 'SUPABASE.CONFIG_INVALID'` and
`error instanceof InvalidJwtError` with
`isStructuredError(error) && error.code === 'SUPABASE.JWT_INVALID'`
(`isStructuredError` from `@internal/utils/structured-error`). Message
text is unchanged.
detection:
glob: "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"
contains:
- "SupabaseConfigError"
- "InvalidJwtError"
anyMatch: true
- id: rls-wire-name-helpers-moved-to-sql-schema-ir-naming
summary: |
The RLS wire-name helpers moved out of the target-postgres RLS surface into the
family-shared naming module, with generalized names — the wire-name scheme now serves
indexes as well as policies. Deleted from
`@internal/target-postgres/rls-canonicalize`: `formatRlsPolicyWireName`,
`parseRlsPolicyWireName`, `normalizePredicate`, and the `RlsPolicyWireName` type. Import
the replacements from `@internal/sql-schema-ir/naming` instead: `formatWireName`,
`parseWireName`, `normalizeSqlBody`, and `WireName`. Behavior is byte-identical (same
`<prefix>_<8hex>` format, same all-prefix-on-no-parse contract, same trim +
whitespace-collapse normalizer). `@internal/target-postgres/rls-canonicalize` still
exports the RLS-specific surface: `computeContentHash`, `ContentHashParts`,
`POLICY_OPERATION_PREDICATES`, `RlsPolicyOperation`. The naming module also gains
`computeIndexContentHash`, `WIRE_NAME_PREFIX_MAX_LENGTH`, and
`assertWireNamePrefixLength` for packs that construct index wire names.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "formatRlsPolicyWireName"
- "parseRlsPolicyWireName"
- "normalizePredicate"
- "RlsPolicyWireName"
anyMatch: true
- id: rls-policy-migration-literal-carries-the-naming-union
summary: |
The Postgres RLS policy migration literal spells its name as one union field from 0.17.
`PostgresRlsPolicyMigrationInput` is deleted from `@internal/target-postgres/types`;
the parameter `Migration#createRlsPolicy` accepts and the renderer writes is
`RenderedRlsPolicyLiteral`, which is `PostgresRlsPolicyInput` with absent-valued keys
omittable — so the flat `name` / `prefix` pair becomes `naming: { kind: "exact", name }`
or `naming: { kind: "wire", prefix, hash }`. The contract-JSON shape is unchanged and
keeps its own type, `SerializedRlsPolicy`, hydrated by `policyInputFromSerialized`
(0.16's `rlsPolicyInputFromFlat`). Pack code constructing `PostgresRlsPolicy` directly
already passed the `naming` union and is unaffected; pack code that built the flat
migration literal, or that imported `PostgresRlsPolicyMigrationInput`, must switch to
`RenderedRlsPolicyLiteral` and the union field. Regenerate any committed migration your
pack ships that calls `createRlsPolicy` — a 0.16 file fails to compile with
`Property 'naming' is missing`.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "PostgresRlsPolicyMigrationInput"
- "rlsPolicyInputFromFlat"
- "createRlsPolicy"
anyMatch: true
- id: sql-index-entities-are-name-identified
summary: |
SQL index entities are name-identified from 0.17, at both layers an extension touches.
Contract IR (`Index` from `@internal/sql-contract/types`): `name` (full physical
name) and `unique` are required, `prefix` marks a wire name (`name` must parse
back to `prefix` + 8-hex suffix), and `columns` became optional — exactly one of
`columns` / `expression` must be set; the constructor and `IndexSchema` validation
throw on the old shape, so any pack code or test fixture building `indexes: [{ columns:
[...] }]` must add a real `name` and `unique`. The `index(...)` factory from
`@internal/sql-contract/factories` is now `index(name, columns, opts?)`. Schema IR
(`SqlIndexIR` from `@internal/sql-schema-ir/types`): the input requires `name` plus
explicit `prefix` / `expression` / `where` keys, the diff-tree id is `index:<name>`
(tuple-derived ids are gone — assertions on `index:<col,col>` ids must switch to the
name), and `isEqualTo` is mode-selected: both modes compare `unique`/`type` strict,
`options` loose, `columns` ordered-strict when both sides carry them; an exact-named
node (no `prefix`) additionally byte-compares `expression`/`where`; a wire-named node
never compares bodies. Construction sites must supply the real physical name — never a
placeholder. Re-emit your pack's committed contract space (`build:contract-space` /
`contract:generate`); storage hashes move for every contract that declares indexes, and
wire-named index physical names gain the `_<8hex>` content-hash suffix (see the user-skill
`indexes-are-name-identified` entry for the database-convergence flow — the first
widening plan is renames-only).
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "SqlIndexIR"
- "indexes: ["
- "indexes:["
anyMatch: true
- id: framework-error-classes-removed
summary: |
Three exported framework error classes are deleted: `ConfigFileNotFoundError`
(from `@internal/config-loader`), `ConfigValidationError` (from
`@internal/config/config-validation`), and `DomainNamespaceResolutionError`
(from `@internal/contract/types`). The same failures now throw structured
envelopes with codes `CONFIG.FILE_NOT_FOUND`, `CONFIG.VALIDATION_FAILED`, and
`CONTRACT.NAMESPACE_INVALID` respectively. Replace each
`error instanceof <Class>` with
`isStructuredError(error) && error.code === '<CODE>'`
(`isStructuredError` from `@internal/utils/structured-error`). Message
text is unchanged.
detection:
glob: "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"
contains:
- "ConfigFileNotFoundError"
- "ConfigValidationError"
- "DomainNamespaceResolutionError"
anyMatch: true
- id: postgres-extension-codecs-require-target-descriptors
summary: Migrate PostgreSQL-bound extension codecs to the target-owned descriptor protocol and contribute one target-typed descriptor set through runtime and control stacks.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "extends CodecDescriptorImpl"
- "CodecDescriptorImpl<"
- "readonly AnyCodecDescriptor[]"
anyMatch: true
- id: rls-policies-gain-exact-names
summary: |
RLS policies adopt the same wire/exact identity split as indexes. On
`PostgresRlsPolicyInput` / `PostgresPolicySchemaNodeInput`, `prefix`, `using`,
`withCheck` (and the node input's `dependsOn`) change from optional keys to REQUIRED
keys typed `| undefined` — a hard compile break for every existing construction site
that omitted them: `new PostgresRlsPolicy({ …, using })` without `withCheck` no longer
compiles. Fix: state the absent keys explicitly (`withCheck: undefined`,
`prefix: undefined`, `dependsOn: undefined`). In the serialized policy contract schema
`prefix` stays optional — its presence means wire-named, absence means
exact-named (a verbatim adopted physical name). Both constructors now ENFORCE that a
declared
`prefix` matches the wire name's parsed prefix: pack code or test fixtures building a
`PostgresRlsPolicy` / `PostgresPolicySchemaNode` whose `name` is not
`<prefix>_<8hex>`-shaped while still passing a `prefix` (e.g. `prefix: name` for a
hand-written legacy name) now throw — omit `prefix` for such names; that is the
exact-named spelling. Exact-named policy nodes compare by content (`operation` /
`permissive` strict, `roles` as a deduplicated sorted set, `using` / `withCheck`
byte-for-byte), so a
body-drifted same-named exact policy now surfaces as a `not-equal` verify issue and a
drop + create plan instead of being invisible. PSL policy blocks newly accept
`@@map("physical name")` to author the exact mode (additive — wire lowering is
byte-unchanged).
detection:
glob: "**/*.{ts,mts,cts,prisma}"
contains:
- "PostgresRlsPolicy"
- "PostgresPolicySchemaNode"
- "policy_select"
- "policy_insert"
- "policy_update"
- "policy_delete"
- "policy_all"
- id: codec-json-projections-must-agree-with-encode-json
summary: |
A target codec descriptor's `jsonProjection` hook is now live rather than a placeholder:
it states, as SQL, the canonical JSON of the codec it belongs to, and the two sides are
required to agree — the parsed projection must equal `encodeJson(value)`, and
`decodeJson` of that must return the application value. If your extension contributes a
PostgreSQL or SQLite codec descriptor whose `jsonProjection` still returns its argument
unchanged, check that the target's own JSON conversion really is your canonical form; if
it is not, the projection has to replace that conversion rather than post-process it.
Where a cast is needed it belongs *inside* the projected expression, so it applies before
the JSON constructor sees the value — a cast applied to the constructor's result is too
late, because the value has already been converted. `encodeJson` / `decodeJson` move with
the projection, in the same change.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "jsonProjection"
- "PostgresCodecDescriptor"
- "SqliteCodecDescriptor"
anyMatch: true
- id: pgvector-json-form-is-a-numeric-array
summary: |
`pg/vector@1` encodes to and decodes from a JSON numeric array — `[1,2,3]` — where it used
the string `"[1,2,3]"`. `decodeJson` rejects the string form, because reading a vector's
text back at double width lands on a different number: a vector's elements are `real`, and
its text form prints the shortest decimal that round-trips *as a real*. If your extension
consumes `pg/vector@1` values out of a contract, or models a codec on it, switch to the
array form. Note the element type: an application value must be exactly representable as a
32-bit float to round-trip at all.
detection:
glob: "**/*.{ts,tsx,json}"
contains:
- "pg/vector@1"
anyMatch: true
- id: default-literal-value-resolves-through-the-codec-json-channel
summary: |
`ExtractCodecTypes` gains a `json` channel beside `input` and `output`, read off a codec's
declared `encodeJson` return type, and the emitted `DefaultLiteralValue` helper resolves a
contract's literal default through that channel rather than through the codec's application
type. A literal default lives in `contract.json`, so it is typed by what the file holds and
not by what the application receives — the two diverge for any codec whose canonical JSON
differs from its application value. Re-run your contract-space build
(`pnpm build:contract-space`, or `prisma-next contract emit`) to regenerate every
`contract.d.ts`; the alias definition changes in each. A codec that narrows its
`encodeJson` return type publishes its JSON type through the new channel; one that does not
keeps the `JsonValue` the base signature promises.
detection:
glob: "**/*.{ts,d.ts}"
contains:
- "DefaultLiteralValue"
- "ExtractCodecTypes"
anyMatch: true
- id: pg-int8-application-values-are-bigint
summary: |
`pg/int8@1` carries `bigint` application values where it carried `number`, and its JSON
form is decimal text. Extension code that reads an `int8` column, resolves an aggregate to
that codec, or hand-writes an `int8` literal default must move to `bigint` and to the
decimal-string JSON spelling. `parsePostgresDefault` changes with it: an introspected
`int8` default now reads as decimal text across the whole signed 64-bit range, where it
previously returned a `number` for a safe integer and a string only past 2^53 — if your
extension compares introspected defaults against contract defaults, that split is gone.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "pg/int8@1"
- "parsePostgresDefault"
anyMatch: true
- id: extension-column-codecs-must-be-declared-as-target-descriptors
summary: |
An extension that contributes a codec used on a *column* must publish its target descriptors
through `types.codecTypes.codecDescriptors` on the runtime (and control) extension
descriptor. The renderers resolve a column's JSON projection out of the assembled descriptor
registry, which is built from that field alone — `codecs()` feeds a different path. A codec
reachable through `codecs()` but absent from `types.codecTypes.codecDescriptors` now fails at
lowering with `RUNTIME.PARAM_REF_MISSING_CODEC` naming the codec id, where the renderer
previously ignored codec identity and emitted the bare column. The failure is deliberate:
emitting the bare column would produce exactly the uncanonical JSON the projection exists to
replace. Publish the same target-typed set in both places.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "codecTypes"
- "codecs: () =>"
- "SqlRuntimeExtensionDescriptor"
anyMatch: true
- id: contract-infer-emits-full-fidelity
summary: |
`contract infer` emits every non-constraint index (expression, partial `where:`,
unique, `type:`/`options:`) and the RLS surface (`@@rls` natively, every policy as a
`policy_<operation>` block with `@@map` and verbatim reprinted bodies, `permissive =
false` for RESTRICTIVE rows). A pack whose contract-space generator runs infer (the
supabase pattern) sees ADDITIVE movement on its next regeneration: previously omitted
partial/expression/unique indexes adopt as exact `map:` entries, and `@@rls` no longer
needs an out-of-band appender — delete any `applyRlsEnablement`-style post-processing
and regenerate through the checked-in generator; re-emit moves the storage hash. The
duplicate-index validation keys exact-mode entries by name, so a reference database's
content-identical twin indexes now validate. An index whose live name is wire-shaped
and whose hash recomputes against the introspected content re-infers as wire-named
`name:` — a heuristic: a name that is not wire-shaped, or whose hash does not
recompute, adopts as exact `map:`.
detection:
glob: "**/*.{ts,mts,cts,prisma}"
contains:
- "inferPslContract"
- "contract infer"
- "@@rls"
anyMatch: true
- id: postgres-packages-now-ship-types-pg
summary: |
`@internal/postgres`, `@internal/extension-supabase`, and
`@internal/driver-postgres` re-export `pg` types from their published
declarations, so each now carries `@types/pg` in `dependencies` instead of
`devDependencies`. Extension authors previously had to add `@types/pg` to their own
devDependencies to compile against those declarations — that workaround is now the
hazard. `pg` ships no types of its own, so a second `@types/pg` copy at a different
version gives `pg.Client` / `pg.Pool` two identities, and handing your own client or
pool to a Prisma 8 API stops compiling with `Argument of type 'Client' is not
assignable to parameter of type 'Client'` (`Type 'Client' is missing the following
properties from type 'Client': connection, setTypeParser, getTypeParser`). Drop
`@types/pg` from your extension and take it transitively, or pin it to the version
`@internal/postgres` depends on.
detection:
glob: "**/package.json"
contains:
- "@types/pg"
anyMatch: true
- id: build-against-published-packages-not-workspace-names
summary: |
The `@internal/*` packages are gone from the registry. Until 0.17 every
workspace package published, so a pack could depend on `@internal/contract`,
`@internal/sql-contract`, `@internal/framework-components` and the rest
directly — and many do. From 0.17 the published surface is 17 `@prisma/*`
packages and everything else carries `"private": true` in its manifest, so
those dependencies no longer resolve anywhere: a stale name fails at install
time rather than resolving to an outdated artifact left on the registry.
Build against the platform packages instead: `@prisma/orm-framework` for contract,
components, errors and the PSL tooling; `@prisma/orm-family-sql` or
`@prisma/orm-family-mongo` for the family surfaces; `@prisma/orm-target-<db>` for the
target, adapter and driver your pack extends; `@prisma/orm-toolchain` for the emitter and
migration tooling. Each internal package became a subpath entrypoint of exactly one of
those, so the module you imported still exists under a new name — the mapping is one hop
and the symbols are unchanged. Declare the target shell your pack extends as a peer
dependency rather than a dependency, so an application cannot end up with two copies of
the target it is registered against.
detection:
glob: "**/package.json"
contains:
- '"@internal/'
anyMatch: true
---
# 0.16 → 0.17 — Extension-author upgrade instructions
## `strip-sha256-hash-prefixes`
Starting at the 0.17 release, every content hash the framework mints or accepts is bare lowercase hex — the `sha256:` prefix is removed across the board: emitted `contract.json` / `contract.d.ts` (including `StorageHashBase<'…'>` / `ProfileHashBase<'…'>` branded type literals), migration manifests, refs, CLI output, and the marker/ledger tables. The prefix carried no information (the algorithm never varied per hash), and the hash **value** — not an in-band tag — signals a format change. The hash constructors and every loader now reject the legacy prefixed form.
Two distinct effects on your pack's checked-in artefacts:
- **Contract hashes keep their value.** `storageHash` / `profileHash` are computed over contract content, which never embedded its own hash — only the textual prefix drops.
- **Migration hash values change.** `migrationHash` is computed over the manifest bytes, which embed the `from` / `to` contract-hash strings; with those now bare, every recomputed `migrationHash` differs from the stored one.
### Migrate checked-in `migrations/` trees — before the layout migrator
Run the colocated codemod from your extension's repository root, **before** `scripts/migrate-migrations-layout.mjs` (the snapshot-layout entries above) — the 0.17 layout migrator accepts only bare-hex trees:
```bash
pnpm exec tsx ./strip-sha256-hash-prefixes.ts
```
For every on-disk migration package (a `migration.json` with a sibling `ops.json`) it strips the prefix from the manifest's `from` / `to`, from hash literals inside `ops.json`, in pre-store sibling contract snapshots (`*-contract.json`, `*.d.ts`, `migration.ts`), and in content-addressed store entries (`migrations/snapshots/<hex>/contract.json` + `contract.d.ts` — the directory name is the hash's hex and does not change), recomputes `migrationHash` over the bare-hex content, and rewrites `refs/*.json` — repointing refs that held old migration hashes at the recomputed ones, and mapping the empty-tree sentinel `sha256:empty` to `empty`. The edit is format-preserving (only hash literals and the recomputed hash value change) and idempotent: re-running over an already-bare tree makes no further changes.
Use `--check` for a dry run that lists files still needing the fix and exits non-zero if any remain:
```bash
pnpm exec tsx ./strip-sha256-hash-prefixes.ts --check
```
### Re-emit committed contract artefacts
If your pack commits emitted contract artefacts (a pack contract under `src/contract/`, test fixtures, example spaces), re-emit them the way your pack generates them (`prisma-next contract emit` or your regeneration script). The regenerated files differ only in hash representation — the hash values themselves are unchanged.
### Update hash literals your pack hard-codes
Sweep your pack's source, fixtures, and tests for `sha256:`-prefixed literals — hand-built contract fixtures, expected `migrationHash` assertions, stub hashes in unit tests. Drop the prefix everywhere; for migration hashes, take the new value from the regenerated manifest, since the value itself changed. Constructing a hash via the framework's `coreHash()` / `profileHash()` constructors with a prefixed string now throws instead of round-tripping.
### Database marker/ledger
There is no compatibility shim: a database whose marker/ledger rows still hold prefixed values reports a hash mismatch on `prisma-next db verify`. This applies to any signed database your extension maintains — acceptance harnesses, reference instances. Re-sign each against its regenerated contract:
```bash
prisma-next db sign
```
### Validation
After the codemod and re-emit, run `pnpm typecheck && pnpm test` in your extension repo, and exercise any flow that loads your migrations — the loader recomputes and verifies each manifest's `migrationHash` on read, so a stale or still-prefixed manifest fails immediately. `git grep -n "sha256:"` over your repository should return no hits in committed artefacts.
## `adopt-sql-json-projection-ast-foundations`
Relational JSON container AST construction now requires an explicit value-projection variant. Import `NativeJsonValueProjection` from `@internal/sql-relational-core/ast` and wrap every expression that 0.16 code passed directly to `JsonObjectExpr.entry(key, expression)` or `JsonArrayAggExpr.of(expression, ...)`: use `JsonObjectExpr.entry(key, new NativeJsonValueProjection(expression))` and `JsonArrayAggExpr.of(new NativeJsonValueProjection(expression), ...)`. `NativeJsonValueProjection` preserves the pre-0.17 target-native JSON conversion. Use `CodecJsonValueProjection` only when the extension deliberately supplies a `CodecRef` for codec-owned JSON conversion, and use `JsonDocumentProjection` only when the wrapped expression already produces a JSON document.
`ExprVisitor<R>` and the `AnyExpression` union now include `FunctionCallExpr` (`kind: 'function-call'`), `CastExpr` (`kind: 'cast'`), and `CaseExpr` (`kind: 'case'`). Add `functionCall`, `cast`, and `case` methods to every visitor object, and add all three discriminants to exhaustive `expr.kind` switches. Binding or rewriting visitors should route these nodes through their normal recursive expression path; restricted visitors such as grouped `HAVING` validators should reject them explicitly when the context does not support them.
`FunctionSource.of(fn, args, alias)` now groups alias state so returned-column aliases cannot exist without a table alias. Replace a string third argument such as `FunctionSource.of(fn, args, 'rows')` with `FunctionSource.of(fn, args, { alias: 'rows' })`; when returned-column names are required, pass `{ alias: 'rows', columnAliases: ['value', 'ordinality'] }`. Calls that omit the alias remain unchanged.
When an extension forwards an existing `ProjectionItem` through a derived-table or row-number wrapper, preserve its known codec in the reconstructed projection: use `ProjectionItem.of(item.alias, ColumnRef.of(wrapperAlias, item.alias), item.codec)`. Leave the codec undefined only for computed or otherwise unknown projected results. After applying the applicable edits, run the extension's typecheck and tests; update AST-shape fixtures to assert the explicit wrapper nodes and preserved codec metadata.
## `rls-wire-name-helpers-moved-to-sql-schema-ir-naming`
The wire-name mechanics are family-shared from 0.17 (they now serve secondary indexes as well as RLS policies), so the helpers moved from the target-postgres RLS module to `@internal/sql-schema-ir/naming` under generalized names. Apply the mechanical rename:
| 0.16 (`@internal/target-postgres/rls-canonicalize`) | 0.17 (`@internal/sql-schema-ir/naming`) |
| --- | --- |
| `formatRlsPolicyWireName(prefix, hash)` | `formatWireName(prefix, hash)` |
| `parseRlsPolicyWireName(name)` | `parseWireName(name)` |
| `normalizePredicate(sql)` | `normalizeSqlBody(sql)` |
| `RlsPolicyWireName` (type) | `WireName` (type) |
Behavior is byte-identical — same `<prefix>_<8hex>` format and parse pattern, same treat-as-all-prefix contract for names that do not parse, same minimal trim-and-collapse normalizer (still a stability commitment: any change would re-suffix every wire name). `@internal/target-postgres/rls-canonicalize` keeps the RLS-specific exports (`computeContentHash`, `ContentHashParts`, `POLICY_OPERATION_PREDICATES`, `RlsPolicyOperation`); there are no re-export shims for the moved names. The naming module additionally exposes `computeIndexContentHash`, `WIRE_NAME_PREFIX_MAX_LENGTH` (54), and `assertWireNamePrefixLength` — the index-side siblings of the RLS hash assembly.
## `sql-index-entities-are-name-identified`
Indexes are name-identified end to end. Two SPI layers change shape; take them in order.
### Contract IR: `Index` / `IndexSchema`
An index entity (`Index` from `@internal/sql-contract/types`; the `indexes: []` array in a `StorageTable`) now requires:
- `name: string` — the full physical name, always present.
- `unique: boolean` — always present.
- `prefix?: string` — present iff the name is wire-named; the constructor enforces that `name` parses back to `prefix` + an 8-hex suffix.
- `columns?` xor `expression?` — exactly one must be set; `where?` carries a partial-index predicate. All body strings are opaque SQL, never parsed or escaped.
Both the class constructor and the arktype `IndexSchema` reject the 0.16 shape. Contract-space fixtures or pack code that load `indexes: [{ columns: ['email'] }]` through validation fail with a `Contract structural validation failed: storage.namespaces.<ns> …` error whose message contains `indexes[0].name must be a string (was missing)` and `indexes[0].unique must be boolean (was missing)`; constructing the entity directly throws `Index: every index carries a full physical name…`. Add the real physical name and `unique: false`. The `index(...)` convenience factory from `@internal/sql-contract/factories` changed signature accordingly: `index(name, columns, opts?)` (opts: `prefix`, `unique`, `type`, `options`). Packs that lower authored index inputs themselves can reuse `lowerAuthoredIndex` from `@internal/sql-contract/index-naming` — it implements the wire/exact naming rules (default prefix, `name:`-as-prefix, `map:`-as-exact) including the 54-character prefix cap.
### Schema IR: `SqlIndexIR`
`SqlIndexIRInput` requires `name` and adds explicit `prefix` / `expression` / `where` keys (the package's every-field-required convention: state absence with `undefined`, never by omission), and `columns` became `readonly string[] | undefined` (xor `expression`). The node's diff-tree `id` is now `index:<name>` — the old tuple-derived `index:<col,col>` ids are gone, so any test asserting child ids or diff paths must use the physical name. `isEqualTo` is mode-selected by the receiver: both modes compare `unique` strict, `type` strict, `options` loosely (`String()`-coerced), and `columns` ordered-strict when both sides carry them; an exact-named receiver (`prefix === undefined`) additionally byte-compares `expression ?? ''` and `where ?? ''`; a wire-named receiver never compares bodies. Introspection now captures expression and partial indexes at full fidelity and preserves same-tuple twin indexes — packs must not assume one index per column tuple.
### Committed contract spaces
Re-emit your pack's contract space with the upgraded toolchain (`build:contract-space`, or your generator script à la `contract:generate`): every contract that declares indexes gets the new entry shape and a new storage hash, and wire-named index physical names gain the `_<8hex>` content-hash suffix. Databases your pack maintains (acceptance harnesses, reference instances) converge via a renames-only widening plan — see the user-skill `indexes-are-name-identified` entry for that flow. Expression and partial indexes are authorable from 0.17 (PSL `@@index(expression:/where:/unique:/type:/name: xor map:)`, TS `constraints.index` with the same matrix); declare them with `name:` for wire names, or `map:` for infer-captured exact names — hand-authoring a body under `map:` warns (`PN_EXACT_NAME_BODY_COMPARISON`) because drift detection byte-compares the authored text against Postgres's reprint. Live indexes you choose not to declare stay tolerated under an `external` control policy. The rules behind all of this — why an index is identified by its name, when the two naming modes apply, and what re-inference does to each — are decided in ADR 243, "Name-identified indexes and exact-name adoption".
## `rls-policy-migration-literal-carries-the-naming-union`
`PostgresRlsPolicyMigrationInput` is gone. The parameter `Migration#createRlsPolicy` accepts, and the shape `CreatePostgresRlsPolicyCall.renderTypeScript` writes into a generated migration, is now `RenderedRlsPolicyLiteral` — `PostgresRlsPolicyInput` with absent-valued keys omittable, which is how a machine-rendered literal spells absence. The practical difference is the name:
| 0.16 | 0.17 |
| --- | --- |
| `name: "post_owner_a1b2c3d4", prefix: "post_owner"` | `naming: { kind: "wire", prefix: "post_owner", hash: "a1b2c3d4" }` |
| `name: "Tenant members can read"` | `naming: { kind: "exact", name: "Tenant members can read" }` |
The two flat fields could disagree; the union cannot be written wrong, which is why the migration authoring surface carries it.
The stored `contract.json` shape does not change — it stays flat and now has its own type: `SerializedRlsPolicy`, hydrated by `policyInputFromSerialized` (0.16's `rlsPolicyInputFromFlat`, renamed for the boundary it serves). Both are exported from `@internal/target-postgres/types`.
Pack code that constructs `PostgresRlsPolicy` directly already passes `naming` and needs no edit. Pack code that builds the migration literal, or that names `PostgresRlsPolicyMigrationInput` in a signature, switches to `RenderedRlsPolicyLiteral`. Regenerate any migration your pack ships that calls `createRlsPolicy`.
## `postgres-extension-codecs-require-target-descriptors`
For every codec descriptor contributed by a PostgreSQL extension, add `@internal/target-postgres` at the same 0.17 version as the extension's other `@internal/*` packages under `dependencies`. Do not leave it only in `devDependencies`: production descriptor modules and runtime/control stack metadata import and expose this protocol. Import the target API from the lean `@internal/target-postgres/codec-descriptor` subpath, and import `ProjectionExpr` from `@internal/sql-relational-core/ast`.
Change a PostgreSQL-bound descriptor that extends `CodecDescriptorImpl<P>` to extend `PostgresCodecDescriptor<P>`. Keep its codec id, traits, target types, `paramsSchema`, factory, output renderer and column helpers unchanged; there is no longer a `meta` / `metaFor` channel to carry alongside, and the descriptor's `nativeTypeFor()` is the only place a native type is declared. Add `protected override nativeType(params: P): string` returning the same trusted PostgreSQL native type spelling the extension already uses, and add `protected override jsonProjection(expression: ProjectionExpr, params: P): ProjectionExpr`.
`return expression` is a claim, not a placeholder: the production JSON renderers call `projectJson()` for every column-valued entry they build, so an identity projection asserts that your codec's stored form already *is* its canonical JSON. Write one only where that holds, and where it does not, see `codec-json-projections-must-agree-with-encode-json` below.
When the extension contributes a reusable target-neutral SQL descriptor instead of owning its descriptor class, keep the generic descriptor unchanged and wrap it with `postgresCodec(genericDescriptor, { nativeType, jsonProjection })`. Supply the native type and the canonical JSON projection as above. The wrapper delegates the generic descriptor's codec id, literals, parameter schema, factory, renderers and target types, and adds the PostgreSQL discriminant and target methods.
Replace broad exported descriptor arrays such as `readonly AnyCodecDescriptor[]` with `definePostgresCodecs([...])`. Use the resulting canonical target-typed set in both runtime and control `types.codecTypes.codecDescriptors`, and return that same set from runtime `codecs()` when the runtime extension SPI requires it. Do not construct independently maintained generic and PostgreSQL descriptor collections. Update exact-type tests that expected `readonly AnyCodecDescriptor[]` to accept `readonly AnyPostgresCodecDescriptor[]`; ordinary descriptors still satisfy `AnyCodecDescriptor` individually.
Review every `CodecRef` construction for a parameterized descriptor, including parameter refs, contract/storage fixtures, and test-created refs. Supply `typeParams` with every field required by the target descriptor `paramsSchema`; for example, a `pg/vector@1` ref for a three-element vector must include `typeParams: { length: 3 }`. Do not make a required descriptor parameter optional merely to preserve an invalid unparameterized ref.
When a parameterized descriptor intentionally supports an unparameterized column or contract reference, make its parameter type and Standard Schema accept the empty validated parameter object used during representative materialization. Express only genuinely absent fields as optional and preserve the existing unparameterized factory behavior; do not add a hidden default or change the codec encoded representation.
After the migration, run the extension package's typecheck, lint, and tests. Verify its public codec ids, factories, column helpers, rendered types, SQL/wire behavior, `encodeJson` / `decodeJson`, runtime/control descriptor membership, and emitted contract behavior are unchanged apart from the descriptor types becoming PostgreSQL-specific.
## `postgres-packages-now-ship-types-pg`
`@internal/postgres`, `@internal/extension-supabase`, and `@internal/driver-postgres` re-export `pg` types from their published `.d.mts` files, so each declares `@types/pg` under `dependencies` from 0.17. Compiling against those declarations no longer requires your extension to supply `@types/pg` itself.
If your extension's `package.json` declares `@types/pg`, act on it. `pg` carries no types of its own, so two `@types/pg` copies in the tree give `pg.Client` and `pg.Pool` two distinct identities. Any call that hands your own client or pool to a Prisma 8 API — `new PostgresControlDriver(client)`, a driver `connect: { pool }` — then fails:
```text
Argument of type 'Client' is not assignable to parameter of type 'Client'.
Type 'Client' is missing the following properties from type 'Client': connection, setTypeParser, getTypeParser
```
The error names the same type on both sides; the two paths under `node_modules/.pnpm/@types+pg@<version>/` in the full message are what identify it.
Prefer dropping `@types/pg` from your extension's `devDependencies` and taking it transitively, so its version tracks Prisma 8's. If you keep the entry — because your own code imports `pg` directly and you want the dependency explicit — pin it to the version `@internal/postgres` depends on rather than a range that can resolve elsewhere.
## Incidental lint-config bumps
Biome `$schema` version alignment in `packages/3-extensions/` (dependabot `dev-deps` group, PR #1058) requires no Prisma 8-specific upgrade action by extension authors.
## `codec-json-projections-must-agree-with-encode-json`
Slice-2 shipped `jsonProjection` as a typed hook that every descriptor implemented as `return expression`. Those hooks now carry real SQL, and a descriptor's two sides are held to agreeing with each other.
The ordering is the part worth internalising, because it is the same in all three shapes it has taken:
- a numeric cast to `text` sits inside the projected expression, so it applies before the JSON constructor can render the value as a JSON number;
- a base64 encoding *replaces* the target's own conversion rather than post-processing it, because the target would otherwise have already emitted its hex form;
- a widening of a narrow float to a wider one happens before any text rendering, because the printing is what discards precision.
In each case a transformation applied to the constructor's output would be too late. If you are authoring a projection, ask what the target does to the value if you do nothing, and whether that is recoverable.
Two properties are worth testing against a real database rather than reasoned about, because both turned out to be contingent: whether your form survives a value at the edge of its representation, and whether it depends on a session setting your consumers may not share.
## `pgvector-json-form-is-a-numeric-array`
The route matters as much as the destination here. Casting a vector's text form to `json` produces an array of numbers and looks correct — but the text prints the shortest decimal that round-trips as a `real`, so reading it at double width yields a different number than the application holds. Widening each element to `float8` before building the array keeps the exact value the `real` denotes.
## `default-literal-value-resolves-through-the-codec-json-channel`
Before this change the emitted type said a literal default had the codec's *application* type. That was true only while the two coincided. For a codec whose application value is a `bigint` and whose canonical JSON is a decimal string, it described a `bigint` sitting in a file that holds `"0"` — which surfaced as an assignability failure rather than as a wrong-but-quiet type.
Regenerating is sufficient; no hand edits to a `contract.d.ts` are needed.
## Incidental release version bump to 0.17.0
The `chore(release): bump to 0.17.0` commit rewrites every workspace manifest's `version` field and `workspace:` pins, which touches `packages/3-extensions/` manifests. No extension-author action beyond the entries above.
upgrading/extension/upgrades/0.16-to-0.17/strip-sha256-hash-prefixes.ts
/**
* Brings checked-in `migrations/` trees onto the 0.17 bare-hex hash
* representation: strips the legacy `sha256:` prefix from every hash literal,
* maps the empty-tree sentinel `sha256:empty` to `empty`, recomputes each
* migration's `migrationHash` over the bare-hex content, and repoints
* `refs/*.json` at the recomputed hashes.
*
* Background: starting at the 0.17 release, content hashes are bare
* lowercase hex — the `sha256:` prefix is gone from every surface (emitted
* `contract.json` / `contract.d.ts`, migration manifests, refs, CLI output,
* and the marker/ledger tables). The algorithm never varied per hash, so the
* prefix carried no information; a format change is signalled by the hash
* value changing, not by an in-band tag. Loaders now reject the legacy
* prefixed form outright.
*
* Two distinct effects on checked-in migration artifacts:
*
* - Contract hashes (`from` / `to` in `migration.json`, `storageHash` /
* `profileHash` in contract snapshots and `.d.ts` branded literals,
* `storageHash` stamps inside `ops.json`) keep their VALUE — only the
* `sha256:` prefix drops.
* - `migrationHash` VALUES change, because the hashed manifest bytes embed
* the (now bare) `from` / `to` strings. Every manifest gets a freshly
* recomputed hash, and every `refs/*.json` that pointed at an old
* migration hash is rewritten to the recomputed one.
*
* Before 0.17 a manifest was:
*
* {
* "from": "sha256:8ee1e7ce…",
* "to": "sha256:059f3f35…",
* "providedInvariants": [],
* "createdAt": "2026-…",
* "migrationHash": "sha256:3c5205d2…"
* }
*
* Starting at 0.17 the same manifest is:
*
* {
* "from": "8ee1e7ce…",
* "to": "059f3f35…",
* "providedInvariants": [],
* "createdAt": "2026-…",
* "migrationHash": "2be2085f…" // recomputed over the bare-hex bytes
* }
*
* Format-preserving edit: hash literals are rewritten in place via a targeted
* pattern (`"sha256:<64 hex>"` / `"sha256:empty"`, single- or double-quoted),
* and the `migrationHash` value is swapped in place. Every other byte (key
* order, indentation, inline-vs-expanded arrays) is left exactly as the
* authoring tool wrote it, so diffs stay minimal.
*
* Confinement: an on-disk migration package is a `migration.json` paired
* with a sibling `ops.json`. The walk keys off that pair; within a package
* directory every `.json` / `.ts` sibling (pre-store contract snapshots,
* `.d.ts` branded types, the executable `migration.ts`) has its hash
* literals stripped. Content-addressed store entries
* (`migrations/snapshots/<hex>/contract.json` + `contract.d.ts`) are also
* covered, so the codemod handles both the pre-store sibling layout and the
* store layout — run it BEFORE `scripts/migrate-migrations-layout.mjs` when
* converting the layout in the same upgrade (the 0.17 migrator accepts only
* bare-hex trees). Store directory names are the hash's hex, which is
* unchanged by the prefix drop, so no directory is renamed. Ref files are
* `refs/*.json` under a directory named `migrations`; each gets old-hash →
* recomputed-hash repointing plus prefix stripping (which also maps the
* `sha256:empty` sentinel to `empty`).
*
* The hash algorithm is replicated inline (canonicalisation rules from
* `@internal/framework-components` `canonicalizeJson` + the
* migration-tools `computeMigrationHash`, which returns bare hex from 0.17)
* so this script stays self-contained — consumers run it via `pnpm exec tsx`
* from their project root with no dependency on any `@internal/*` package
* being resolvable from that root.
*
* The codemod is idempotent: an already-bare tree carries no `sha256:`
* literals and its recomputed hashes match the stored ones, so the edit is a
* no-op and every file is left untouched.
*
* Flags:
* --check dry-run; lists files that still need fixing and exits 1 if
* any remain.
*/
import { createHash } from 'node:crypto';
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { basename, dirname, join, sep } from 'node:path';
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
const dryRun = process.argv.includes('--check');
const projectRoot = process.cwd();
// --- Inline canonicalisation + hash --------------------------------------
// Replicated from `@internal/framework-components` `canonicalizeJson`
// (sortKeys + JSON.stringify) and the migration-tools `computeMigrationHash`.
// Kept inline so the script has no `@internal/*` import — pnpm's strict
// node_modules layout won't resolve transitive framework deps from a
// consumer's project root.
function sortKeys(value: unknown): unknown {
if (value === null || typeof value !== 'object') {
return value;
}
if (Array.isArray(value)) {
return value.map(sortKeys);
}
const sorted: Record<string, unknown> = Object.create(null);
for (const [key, entry] of Object.entries(value).sort(([a], [b]) =>
a < b ? -1 : a > b ? 1 : 0,
)) {
sorted[key] = sortKeys(entry);
}
return sorted;
}
function canonicalizeJson(value: unknown): string {
return JSON.stringify(sortKeys(value));
}
function sha256Hex(input: string): string {
return createHash('sha256').update(input).digest('hex');
}
/**
* Content-addressed migration hash over (metadata envelope, ops), 0.17 form:
* bare hex, no prefix. The `migrationHash` field is stripped before hashing
* so the same function works at write time (no hash yet) and at recompute
* time (rehashing an already-attested record over the bare-hex envelope).
*/
function computeMigrationHash(metadata: Record<string, unknown>, ops: unknown): string {
const { migrationHash: _migrationHash, ...strippedMeta } = metadata;
const partHashes = [canonicalizeJson(strippedMeta), canonicalizeJson(ops)].map(sha256Hex);
return sha256Hex(canonicalizeJson(partHashes));
}
// --- Prefix stripping ------------------------------------------------------
// A legacy hash literal: `sha256:` + 64 lowercase hex chars, or the
// empty-tree sentinel `sha256:empty`, in single or double quotes. Quoting is
// required so prose that merely mentions the prefix is never rewritten.
const LEGACY_HASH_LITERAL = /(["'])sha256:([0-9a-f]{64}|empty)\1/g;
function stripHashPrefixes(text: string): string {
return text.replace(LEGACY_HASH_LITERAL, (_full, quote: string, hash: string) => {
return `${quote}${hash}${quote}`;
});
}
function replaceMigrationHash(text: string, oldHash: string, newHash: string): string {
if (oldHash === newHash) return text;
const re = new RegExp(`("migrationHash"[ \\t]*:[ \\t]*)"${oldHash}"`);
if (re.exec(text) === null) {
throw new Error('could not locate the migrationHash value to replace');
}
return text.replace(re, (_full, prefix: string) => `${prefix}"${newHash}"`);
}
// --- Filesystem walk ------------------------------------------------------
interface WalkResult {
readonly manifests: string[];
readonly refFiles: string[];
readonly snapshotFiles: string[];
}
async function findMigrationArtifacts(root: string): Promise<WalkResult> {
const manifests: string[] = [];
const refFiles: string[] = [];
const snapshotFiles: string[] = [];
async function walk(dir: string): Promise<void> {
let entries: Awaited<ReturnType<typeof readdir>>;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
// Unreadable directory — skip silently. The consumer's project root may
// legitimately contain restricted directories.
return;
}
for (const entry of entries) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
await walk(path);
} else if (entry.isFile() && entry.name === 'migration.json') {
manifests.push(path);
} else if (
entry.isFile() &&
entry.name.endsWith('.json') &&
basename(dir) === 'refs' &&
dirname(dir).split(sep).includes('migrations')
) {
refFiles.push(path);
} else if (
entry.isFile() &&
(entry.name.endsWith('.json') || entry.name.endsWith('.ts')) &&
basename(dirname(dir)) === 'snapshots' &&
dirname(dirname(dir)).split(sep).includes('migrations')
) {
snapshotFiles.push(path);
}
}
}
await walk(root);
return {
manifests: manifests.sort(),
refFiles: refFiles.sort(),
snapshotFiles: snapshotFiles.sort(),
};
}
// --- Per-file transforms ---------------------------------------------------
/** Narrows an arbitrary JSON-parsed value to a plain object (manifest shape). */
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
type Status = 'already-clean' | 'needs-fix' | 'fixed' | 'skipped-no-ops';
interface Result {
readonly path: string;
readonly status: Status;
}
const results: Result[] = [];
/** Old migration hash (as previously stored, prefixed) → recomputed bare hash. */
const migrationHashMap = new Map<string, string>();
async function emit(path: string, before: string, after: string): Promise<Result> {
if (after === before) {
return { path, status: 'already-clean' };
}
if (!dryRun) await writeFile(path, after, 'utf-8');
return { path, status: dryRun ? 'needs-fix' : 'fixed' };
}
async function processSibling(path: string): Promise<Result> {
const raw = await readFile(path, 'utf-8');
return emit(path, raw, stripHashPrefixes(raw));
}
async function processPackage(manifestPath: string): Promise<Result[]> {
const raw = await readFile(manifestPath, 'utf-8');
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(
`${manifestPath}: not valid JSON (${error instanceof Error ? error.message : String(error)})`,
);
}
if (!isJsonObject(parsed)) {
return [{ path: manifestPath, status: 'already-clean' }]; // not a manifest object
}
const packageDir = dirname(manifestPath);
// A complete on-disk migration package pairs `migration.json` with a sibling
// `ops.json` (the operations the hash is computed over); without it we cannot
// recompute the hash, so this is not a package we should touch.
const opsPath = join(packageDir, 'ops.json');
let opsRaw: string;
try {
opsRaw = await readFile(opsPath, 'utf-8');
} catch {
return [{ path: manifestPath, status: 'skipped-no-ops' }];
}
const out: Result[] = [];
// Ops: strip prefixes (e.g. `meta.storageHash` stamps inside operation
// payloads), then parse the stripped text — the recomputed hash covers the
// bare-hex operations exactly as they will sit on disk.
const strippedOpsRaw = stripHashPrefixes(opsRaw);
const ops: unknown = JSON.parse(strippedOpsRaw);
out.push(await emit(opsPath, opsRaw, strippedOpsRaw));
// Manifest: strip prefixes from `from` / `to` (and the sentinel), then
// recompute `migrationHash` over the bare-hex envelope + bare-hex ops.
// Canonicalisation is order/whitespace independent, so parsing the stripped
// text is the right input regardless of on-disk formatting.
const strippedManifestRaw = stripHashPrefixes(raw);
const strippedMeta = JSON.parse(strippedManifestRaw);
if (!isJsonObject(strippedMeta)) {
throw new Error(`${manifestPath}: manifest is not a JSON object`);
}
const newHash = computeMigrationHash(strippedMeta, ops);
const oldStoredHash = parsed['migrationHash'];
if (typeof oldStoredHash !== 'string') {
throw new Error(`${manifestPath}: manifest is missing a string \`migrationHash\` field`);
}
const strippedOldHash = stripHashPrefixes(`"${oldStoredHash}"`).slice(1, -1);
migrationHashMap.set(oldStoredHash, newHash);
migrationHashMap.set(strippedOldHash, newHash);
out.push(
await emit(
manifestPath,
raw,
replaceMigrationHash(strippedManifestRaw, strippedOldHash, newHash),
),
);
// Siblings: contract snapshots (`*-contract.json`), branded-literal type
// files (`*.d.ts`), and the executable `migration.ts` all carry contract
// hash literals whose value is stable — only the prefix drops.
let entries: Awaited<ReturnType<typeof readdir>>;
try {
entries = await readdir(packageDir, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
if (!entry.isFile()) continue;
if (entry.name === 'migration.json' || entry.name === 'ops.json') continue;
if (!entry.name.endsWith('.json') && !entry.name.endsWith('.ts')) continue;
out.push(await processSibling(join(packageDir, entry.name)));
}
return out;
}
async function processRefFile(path: string): Promise<Result> {
const raw = await readFile(path, 'utf-8');
// Repoint at recomputed migration hashes first (a ref stores the migration
// hash of the package it names), then strip any remaining prefixes — which
// covers contract-hash refs and maps `sha256:empty` → `empty`.
let text = raw;
for (const [oldHash, newHash] of migrationHashMap) {
if (oldHash === newHash) continue;
text = text.replaceAll(`"${oldHash}"`, `"${newHash}"`);
}
text = stripHashPrefixes(text);
return emit(path, raw, text);
}
// --- Driver ---------------------------------------------------------------
const { manifests, refFiles, snapshotFiles } = await findMigrationArtifacts(projectRoot);
if (manifests.length === 0 && refFiles.length === 0 && snapshotFiles.length === 0) {
console.error(`No migration artifacts found under ${projectRoot}.`);
process.exit(1);
}
for (const manifestPath of manifests) {
results.push(...(await processPackage(manifestPath)));
}
for (const snapshotPath of snapshotFiles) {
results.push(await processSibling(snapshotPath));
}
for (const refPath of refFiles) {
results.push(await processRefFile(refPath));
}
let changed = 0;
let alreadyClean = 0;
let skipped = 0;
for (const result of results) {
const rel = result.path.slice(projectRoot.length + 1);
if (result.status === 'already-clean') {
alreadyClean += 1;
} else if (result.status === 'skipped-no-ops') {
skipped += 1;
console.log(`SKIP ${rel} (no sibling ops.json — not a migration package)`);
} else {
changed += 1;
const verb = dryRun ? 'WOULD FIX' : 'FIXED';
console.log(`${verb} ${rel}`);
}
}
console.log();
console.log(
`${results.length} file(s) scanned: ${changed} ${dryRun ? 'needing fix' : 'fixed'}, ${alreadyClean} already clean${skipped > 0 ? `, ${skipped} skipped (no ops.json)` : ''}.`,
);
if (dryRun && changed > 0) process.exit(1);
upgrading/extension/upgrades/0.17-to-8.0.0-rc.1/instructions.md
---
from: "0.17"
to: "8.0.0-rc.1"
changes:
- id: codec-conformance-harness-moves-to-testkit-packages
summary: |
The database-backed codec conformance harness moves into two dedicated dev-only packages,
`@internal/postgres-codec-testkit` and `@internal/sqlite-codec-testkit`. Like every
`@internal/*` package they are workspace-private — a published home under the `@prisma/*`
scope is a follow-up — so today's consumers are packs developed in this repository. A pack
that reached the in-repo harness through a relative cross-package import
(`../../../3-targets/6-adapters/postgres/test/codec-conformance/harness`) adds the matching
testkit as a workspace devDependency and imports `runPostgresCodecProjection` /
`runSqliteCodecProjection` from the package instead; a `tsconfig` whose `rootDir` was widened
to reach across that boundary can be narrowed back to the pack. The harness API is unchanged —
caller-supplied connection, one case per codec and value — so the cases themselves move
verbatim. Production adapters take no dependency on either testkit.
detection:
glob: "**/*.{ts,tsx,json}"
contains:
- "codec-conformance"
- "runPostgresCodecProjection"
- "runSqliteCodecProjection"
anyMatch: true
- id: aggregate-result-codecs-are-target-declared
summary: |
Targets declare what each aggregate returns, through `SqlAggregateDescriptor` contributions on
`types.aggregateDescriptors`. A descriptor maps an operation and an input match —
no input, an exact codec id, a codec trait, or input-agnostic — to a result codec and a
declared nullability, and resolution consults exact matches, then traits, then the
input-agnostic entry. Emission and the runtime read the same contributions, so an extension
that contributes an aggregate cannot type one result and decode another. Two consequences for
existing packs. First, aggregate results now carry their declared codec into decoding, so a
pack asserting an aggregate reads back as a `number` updates that expectation — on PostgreSQL
`count` and widened sums are bigints and integer averages are decimal strings; on SQLite `avg`
is a number and integer sums are bigints. Second, a pack that asserts rendered SQL for SQLite
aggregates sees `CAST(… AS text)` around any aggregate whose result is `sqlite/bigint@1`:
SQLite computes those into an INTEGER that `node:sqlite` refuses to hand over past 2^53, so
the target's descriptor renders the cast that keeps the value readable. Regenerate contracts —
the emitted `contract.d.ts` gains an `AggregateTypes` block that the ORM and SQL builder
resolve their result types from.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "aggregateDescriptors"
- "AggregateExpr"
- "codecTypes"
anyMatch: true
- id: rendered-ts-literals-are-double-quoted
summary: |
`renderTsLiteral` (`@internal/framework-components/codec`) now returns a double-quoted
TypeScript literal — `"low"` where it used to return `'low'`. It delegates to a single
shared renderer (`JSON.stringify` plus an explicit U+2028/U+2029 escape), which also
closes escaping gaps the old implementation had: `\t`, `\v`, `\b`, `\f` and the remaining
C0 control characters were previously emitted raw. Calling code needs no change, and the
emitted `contract.d.ts` is byte-identical either way because `contract emit` formats with
prettier at `singleQuote: true`. What does change is any assertion your pack makes on the
*unformatted* return value — a codec unit test pinning `renderValueLiteral` output, or a
test that calls `generateContractDts` directly and greps the result. Update those
expectations to the double-quoted form. If your pack hand-rolls a `renderValueLiteral`
that builds its own quoted literal, it keeps working, but switch it to `renderTsLiteral`
so your pack's escaping matches the framework's.
detection:
glob: "**/*.{test,test-d}.ts"
contains:
- 'renderValueLiteral'
- 'generateContractDts'
anyMatch: true
- id: driver-spi-splits-query-and-execute
summary: |
The relational driver contract (`SqlQueryable` in `@internal/sql-relational-core`) splits
into one streaming path and one statistics path: `query<Row>(request)` returns
`AsyncIterable<Row>`, and `execute(request)` runs a statement without row output and
resolves to `SqlStatementStats` (`{ affectedRows }`). The prepared-specific
`executePrepared` driver method and the buffered `query(sql, params)` convenience (with its
`SqlQueryResult` type) are gone — preparedness travels as the optional
`preparedStatementHandle` on `SqlExecuteRequest`, and `query()` serves ad-hoc and prepared
requests alike. A pack that implements a driver, wraps one, or ships a driver fake for
tests implements the two-method surface, reports its engine's native affected-row count
unnormalized, and surfaces a failed prepared-statement retry as the structural
`DRIVER.PREPARE_FAILED` error envelope.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "SqlQueryable"
- "PreparedExecuteRequest"
- "SqlQueryResult"
anyMatch: true
---
# 0.17 → 8.0.0-rc.1 — Extension-author upgrade instructions
## `codec-conformance-harness-moves-to-testkit-packages`
The conformance harness that measures a codec's JSON projection against a real database lives in dedicated packages, `@internal/postgres-codec-testkit` and `@internal/sqlite-codec-testkit`. They are workspace-private like every `@internal/*` package (a published `@prisma/*` home is a follow-up), so packs developed in this repository that reached the harness by relative path across the adapter's `test/` directory declare the workspace dependency instead:
```jsonc
// package.json
"devDependencies": {
"@internal/postgres-codec-testkit": "workspace:*"
}
```
```ts
import { runPostgresCodecProjection } from '@internal/postgres-codec-testkit';
```
The harness API is the same one the in-repo version had — you supply the connection and the cases; it encodes, stores, projects through your descriptor, executes, and compares the database's JSON against `encodeJson` and back through `decodeJson`. Cases move verbatim. If your `tsconfig.json` widened `rootDir` to make the cross-package relative import resolve, narrow it back to the pack.
## `aggregate-result-codecs-are-target-declared`
Aggregate result typing is now declared rather than inferred. A target (or an extension) contributes `SqlAggregateDescriptor`s on `types.aggregateDescriptors`, a sibling of `codecTypes`:
```ts
import type { SqlAggregateDescriptor } from '@internal/sql-relational-core/aggregate-descriptor-registry';
const sumOverMoney: SqlAggregateDescriptor = {
operation: 'sum',
input: { kind: 'codec', codecId: 'ext/money@1' },
output: { kind: 'codec', codecId: 'ext/money@1' },
nullable: true,
};
```
Each descriptor claims one `(operation, input)` pair, and exactly one component may claim it. The input match is one of four kinds — `none` (an operation over rows), `codec` (an exact codec id), `trait` (any codec carrying that trait), or `any` (a result that does not depend on its input) — and resolution consults them in that order of specificity: exact, then trait, then input-agnostic. The result is either `self` (the matched input's codec, for an aggregate that returns one of its inputs) or a named codec id. Emission reads the same contributions the runtime resolves against, so an emitted result type and a decoded value cannot disagree.
Two things to check in an existing pack:
- **Expectations that an aggregate reads back as a `number`.** On PostgreSQL, `count` and sums that widen to `int8` are bigints, and integer averages are decimal strings; on SQLite, integer sums are bigints while `avg` is a number.
- **Assertions over rendered SQL for SQLite aggregates.** An aggregate whose declared result is `sqlite/bigint@1` renders as `CAST(… AS text)`. SQLite computes such an aggregate into an INTEGER, and `node:sqlite` raises rather than returning one past 2^53; the descriptor's lowering hook renders the cast that keeps the value readable, and the bigint codec reads the text back. If your pack contributes an aggregate whose result outruns a double, declare the same lowering.
Then regenerate contracts (`prisma-next contract emit`): `contract.d.ts` gains an `AggregateTypes` block, and both the ORM client and the SQL builder resolve aggregate result types from it.
## `rendered-ts-literals-are-double-quoted`
The framework had three separate implementations of "escape this string for a TypeScript
literal", each with a different idea of what needed escaping. They are now one function, and the
one that survived is the `JSON.stringify`-based renderer.
For an extension pack, the practical surface is `renderTsLiteral`, which most custom codecs use
to implement `renderValueLiteral`:
```ts
// Before — renderTsLiteral returned a single-quoted literal
expect(codec.renderValueLiteral?.('low', 'output')).toBe("'low'");
// After
expect(codec.renderValueLiteral?.('low', 'output')).toBe('"low"');
```
Two things worth knowing when you update these:
- **The escaping inverted, it did not merely re-quote.** Under `JSON.stringify` a single quote is
no longer escaped and a double quote is. So `renderTsLiteral("it's")` is `"it's"`, not
`'it\'s'`. A mechanical quote swap over your test expectations will get the simple cases right
and the escaping cases wrong — check any assertion whose input contains a quote by hand.
- **Values are unaffected; only the rendering is.** No contract hash changes, no re-emit is
needed, and the artefacts your pack ships (`contract.json`, `contract.d.ts`, migrations) are
unchanged. If your pack's committed contract artefacts do show a diff after upgrading, that is
a different change in this transition, not this one.
## `driver-spi-splits-query-and-execute`
The driver contract used one streaming method for every statement, a prepared-specific variant beside it, and a buffered convenience query. It is now two methods with distinct jobs:
```ts
// Before (0.17)
export interface SqlQueryable {
execute<Row>(request: SqlExecuteRequest): AsyncIterable<Row>;
executePrepared<Row>(request: PreparedExecuteRequest): AsyncIterable<Row>;
explain?(request: SqlExecuteRequest): Promise<SqlExplainResult>;
query<Row>(sql: string, params?: readonly unknown[]): Promise<SqlQueryResult<Row>>;
}
// After (0.18)
export interface SqlQueryable {
query<Row>(request: SqlExecuteRequest): AsyncIterable<Row>;
execute(request: SqlExecuteRequest): Promise<SqlStatementStats>; // { affectedRows }
explain?(request: SqlExecuteRequest): Promise<SqlExplainResult>;
}
```
For a pack that implements a driver, wraps one, or ships a driver fake:
- Move the streaming implementation from `execute(request)` to `query(request)`. The buffered `query(sql, params)` overload and its `SqlQueryResult` type are gone; callers stream and collect instead.
- Implement `execute(request): Promise<SqlStatementStats>` for statements executed without row output. Report your engine's native affected-row count — the in-tree PostgreSQL driver reports `rowCount`, SQLite reports `stmt.run().changes` — and do not normalize semantics across engines.
- Delete `executePrepared`. Preparedness is a property of the request: a prepared plan arrives at `query()` carrying `preparedStatementHandle` on the `SqlExecuteRequest`.
- If your engine cannot return rows from `execute()`, reject `RETURNING`-style statements up front rather than silently dropping their rows (the in-tree SQLite driver does this).
- If your driver retries stale prepared statements, surface a failed retry as the structural `DRIVER.PREPARE_FAILED` error envelope (ADR 239) with the normalized driver error as its `cause`.
<!--
TML-3171 (nested SQL ORM self-relation predicate scopes): `changes: []`. The `packages/3-extensions/sql-orm-client` diff fixes internal query planning and adds regression coverage; it changes no public API, contract shape, emitted artefact, extension-authoring surface, adapter API, or downstream source translation. Incidental substrate diff only.
-->
upgrading/extension/upgrades/0.7-to-0.8/instructions.md
---
from: "0.7"
to: "0.8"
changes: []
---
No extension-side migration actions are required for this transition at this time.
`changes: []` intentionally marks this transition as a no-op.
upgrading/extension/upgrades/0.8-to-0.9/instructions.md
---
from: "0.8"
to: "0.9"
changes:
- id: strip-inline-contracts-from-migration-manifests
summary: Remove the inlined `fromContract` / `toContract` fields from every committed `migration.json` (extension seed migrations included); the destination contract continues to live next door as `end-contract.json`.
detection:
glob: "**/migrations/**/migration.json"
contains:
- '"fromContract"'
- '"toContract"'
anyMatch: true
script: ./strip-inline-contracts.ts
- id: drop-migration-metadata-contract-fields-from-source
summary: Remove `fromContract` / `toContract` from any extension source code that constructs or destructures `MigrationMetadata`; the SPI dropped both fields.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "fromContract"
- "toContract"
anyMatch: true
---
# 0.8 → 0.9 — Extension-author upgrade instructions
## `strip-inline-contracts-from-migration-manifests`
Starting at the 0.9 release, `migration.json` no longer carries `fromContract` or `toContract`. The schema rejects those keys as unknown, so any committed manifest that still inlines them fails to load with `MIGRATION.INVALID_MANIFEST`.
This applies to **seed migrations** shipped inside an extension package (e.g. `migrations/<edge-id>/migration.json` shipped under `packages/<extension>/migrations/`) just as it does to user-app migrations. The destination contract was already being written to disk next door as `end-contract.json` (and the source as `start-contract.json`); the manifest copy was redundant. `migrationHash` is unaffected — it has always been computed without those two fields, so stripping them does not change the stored hash.
### What `strip-inline-contracts.ts` does
The colocated script walks the project root, descends into every directory named `migrations/` (skipping `node_modules`, `.git`, `dist`, `build`), and rewrites every `migration.json` whose JSON object contains either key:
- Manifests that already lack both keys are left untouched (idempotent — safe to re-run).
- Manifests with either key are rewritten with the two key/value spans excised at the text level. The formatting of every surviving field (whitespace, inline-vs-multiline arrays, key ordering, trailing newline) is preserved byte-for-byte; only the removed keys (and their trailing comma+newline) disappear from the diff. The script reparses the result to guard against accidental corruption.
- A `--check` flag turns the script into a dry-run; it lists which manifests would be modified and exits non-zero if any still need fixing.
## `drop-migration-metadata-contract-fields-from-source`
The `MigrationMetadata` type exported by `@internal/migration-tools` no longer declares `fromContract` or `toContract`. Source TypeScript that constructs (or destructures) `MigrationMetadata` from those fields will now fail to compile.
There is no codemod — extension authors construct `MigrationMetadata` in too many shapes for a deterministic transform to be safe. Instead, walk every `.ts` / `.tsx` file matched by the `detection.glob` above and apply these rules locally:
- **Object-literal construction** (e.g. `const meta: MigrationMetadata = { ..., fromContract, toContract }`): drop both properties from the literal. If the value is later needed, read the contract from the sibling `end-contract.json` (or the predecessor's `end-contract.json` for the from-side) instead of carrying it in metadata.
- **Spread-into-existing** (e.g. `const meta = { ...prev, fromContract: ..., toContract: ... }`): drop the two keys from the spread. If `prev` was loaded from disk via `readMigrationPackage`, it already lacks the fields under 0.9 — no further work needed.
- **Destructuring** (e.g. `const { fromContract, toContract, ...rest } = meta`): remove both names from the destructure. If the consuming code used those values, switch the read to the sibling `end-contract.json`.
- **Type-only references** (e.g. `metadata.toContract`, `Pick<MigrationMetadata, 'fromContract'>`, etc.): TypeScript will surface these as compile errors after the bump. Replace with sibling-file reads or remove the field reference entirely.
If your extension also carries seed-migration manifests (the common case for extensions that ship a `migrations/` directory), the `strip-inline-contracts-from-migration-manifests` change above handles those at the JSON layer. Run that script first; the source-code change above only covers TypeScript that produces / consumes `MigrationMetadata` programmatically.
While at it, scan any seed `migration.ts` doc-comments in your extension for stale references to `metadata.toContract` (e.g. *"preserving the full `toContract` so `MigrationCLI.run` re-attests it"*). Those references were accurate under 0.8 and are no longer accurate under 0.9 — the `MigrationCLI.run` re-attestation now reads the destination contract from sibling `end-contract.json`, not from `metadata.toContract`. Update or remove the stale prose. This is documentation hygiene, not a structural break.
### Validation
After running the script and applying the source-level rules above, run `pnpm typecheck && pnpm test` (or your extension's own equivalent). `prisma-8-check-pins` should also pass, since it does not look at `MigrationMetadata` shape.
upgrading/extension/upgrades/0.8-to-0.9/strip-inline-contracts.ts
/**
* Removes the inlined `fromContract` / `toContract` fields from every
* committed `migration.json` manifest reachable from the project root.
*
* Background: starting at the 0.9 release, `migration.json` no longer
* carries `fromContract` / `toContract` (the schema rejects them as
* unknown keys). The destination contract continues to live next door
* as `end-contract.json` (and the source as `start-contract.json`); the
* manifest copy was redundant. `migrationHash` is unaffected — it was
* already computed without those two fields, so stripping them does
* not change the stored hash.
*
* Behaviour:
* - Walks the project root recursively, ignoring `node_modules`, `.git`,
* `dist`, and `build`. Picks up every file named `migration.json`
* whose JSON object has the migration-manifest shape (`from`, `to`,
* and `migrationHash` keys). Other `migration.json` files (e.g.
* unrelated artefacts that happen to share the name) are skipped.
* - Manifests that already lack both removed keys are left untouched.
* - Manifests with either removed key are rewritten with the two key /
* value spans excised at the text level, so the formatting of all
* surviving fields (whitespace, inline-vs-multiline arrays, key
* ordering, trailing newline) is preserved byte-for-byte. Only the
* key being removed and its trailing comma+newline disappear from
* the diff.
* - Idempotent: re-running the script after success is a no-op.
*
* Flags:
* --check dry-run; exit 1 if any manifest still needs fixing.
*/
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
const REMOVED_KEYS = ['fromContract', 'toContract'] as const;
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
const dryRun = process.argv.includes('--check');
const projectRoot = process.cwd();
interface Result {
readonly path: string;
readonly status: 'already-clean' | 'needs-fix' | 'fixed';
readonly removed: readonly string[];
}
async function findManifests(root: string): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
await walk(join(dir, entry.name));
} else if (entry.isFile() && entry.name === 'migration.json') {
const path = join(dir, entry.name);
try {
const parsed: unknown = JSON.parse(await readFile(path, 'utf-8'));
if (looksLikeMigrationManifest(parsed)) out.push(path);
} catch {
// Not valid JSON, or not the manifest shape — skip silently.
}
}
}
}
await walk(root);
return out.sort();
}
function looksLikeMigrationManifest(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null) return false;
const obj = value as Record<string, unknown>;
return 'from' in obj && 'to' in obj && 'migrationHash' in obj;
}
/**
* Find the end index (exclusive) of a JSON value starting at `start`
* inside `text`. Handles strings, numbers, booleans, null, arrays, and
* objects. Brace / bracket nesting is tracked while respecting string
* literals (with escapes). Throws if `text` is not well-formed JSON
* starting at `start`.
*/
function jsonValueEnd(text: string, start: number): number {
const head = text[start];
let depth = 0;
let inString = false;
let inEscape = false;
if (head === '"') {
let i = start + 1;
while (i < text.length) {
const ch = text[i];
if (inEscape) inEscape = false;
else if (ch === '\\') inEscape = true;
else if (ch === '"') return i + 1;
i += 1;
}
throw new Error(`Unterminated string starting at ${start}`);
}
if (head === '{' || head === '[') {
let i = start;
while (i < text.length) {
const ch = text[i];
if (inString) {
if (inEscape) inEscape = false;
else if (ch === '\\') inEscape = true;
else if (ch === '"') inString = false;
} else {
if (ch === '"') inString = true;
else if (ch === '{' || ch === '[') depth += 1;
else if (ch === '}' || ch === ']') {
depth -= 1;
if (depth === 0) return i + 1;
}
}
i += 1;
}
throw new Error(`Unterminated container starting at ${start}`);
}
let i = start;
while (i < text.length && !',}\n\r\t '.includes(text[i] ?? '')) i += 1;
return i;
}
/**
* Remove a top-level key (and its `: value` and trailing comma) from a
* pretty-printed JSON object text. Returns the new text. If the key
* isn't present, returns the input unchanged.
*
* Preserves all surrounding whitespace and the formatting of every
* other field byte-for-byte. Handles both "key in the middle" (eats the
* trailing comma + newline) and "key at the end" (eats the leading
* comma + newline).
*/
function removeTopLevelKey(text: string, key: string): string {
const needle = `"${key}"`;
const keyIndex = text.indexOf(needle);
if (keyIndex < 0) return text;
let cursor = keyIndex + needle.length;
while (cursor < text.length && /\s/.test(text[cursor] ?? '')) cursor += 1;
if (text[cursor] !== ':') {
throw new Error(`Expected ':' after ${needle} at ${cursor}`);
}
cursor += 1;
while (cursor < text.length && /\s/.test(text[cursor] ?? '')) cursor += 1;
const valueEnd = jsonValueEnd(text, cursor);
let removeStart = keyIndex;
let removeEnd = valueEnd;
if (text[removeEnd] === ',') {
removeEnd += 1;
if (text[removeEnd] === '\n') removeEnd += 1;
let lineStart = removeStart;
while (lineStart > 0 && text[lineStart - 1] !== '\n') lineStart -= 1;
if (text.slice(lineStart, removeStart).trim() === '') removeStart = lineStart;
} else {
let back = removeStart - 1;
while (back > 0 && /[ \t]/.test(text[back] ?? '')) back -= 1;
if (text[back] === '\n') {
let prev = back - 1;
while (prev > 0 && /[ \t]/.test(text[prev] ?? '')) prev -= 1;
if (text[prev] === ',') {
removeStart = prev;
if (text[removeEnd] === '\n') removeEnd += 1;
}
}
}
return text.slice(0, removeStart) + text.slice(removeEnd);
}
async function processManifest(path: string): Promise<Result> {
const raw = await readFile(path, 'utf-8');
const data: Record<string, unknown> = JSON.parse(raw);
const removed = REMOVED_KEYS.filter((key) => key in data);
if (removed.length === 0) return { path, status: 'already-clean', removed: [] };
let stripped = raw;
for (const key of removed) stripped = removeTopLevelKey(stripped, key);
// Sanity: stripped output must still be valid JSON and must agree on
// every field except the two we removed.
const reparsed: Record<string, unknown> = JSON.parse(stripped);
for (const key of removed) {
if (key in reparsed) {
throw new Error(`Internal: ${key} survived strip in ${path}`);
}
}
if (!dryRun) await writeFile(path, stripped, 'utf-8');
return { path, status: dryRun ? 'needs-fix' : 'fixed', removed };
}
const manifests = await findManifests(projectRoot);
if (manifests.length === 0) {
console.error(`No migration.json files found under ${projectRoot}.`);
process.exit(1);
}
let changed = 0;
let alreadyClean = 0;
for (const path of manifests) {
const result = await processManifest(path);
const rel = path.slice(projectRoot.length + 1);
if (result.status === 'already-clean') {
alreadyClean += 1;
console.log(`OK ${rel} (already clean)`);
} else {
changed += 1;
const verb = dryRun ? 'WOULD FIX' : 'FIXED';
console.log(`${verb} ${rel} (removed: ${result.removed.join(', ')})`);
}
}
console.log();
console.log(
`${manifests.length} manifest(s) scanned: ${changed} ${dryRun ? 'needing fix' : 'fixed'}, ${alreadyClean} already clean.`,
);
if (dryRun && changed > 0) process.exit(1);
upgrading/extension/upgrades/0.9-to-0.10/instructions.md
---
from: "0.9"
to: "0.10"
changes:
- id: stamp-storage-types-kind-on-contract-snapshots
summary: Stamp the `kind` discriminator (`"codec-instance"` / `"postgres-enum"`) on every entry in `storage.types` inside every committed `start-contract.json` / `end-contract.json` snapshot (extension seed migrations included). The SQL family's contract serializer is now strict — untagged entries fail to load with a deserializer diagnostic naming the offending entry.
detection:
glob: "**/migrations/**/{start,end}-contract.json"
contains:
- '"codecId"'
anyMatch: true
script: ./stamp-storage-types-kind.ts
- id: stamp-storage-types-kind-in-source
summary: Wrap untagged codec-triple inputs to `SqlStorage` (or any builder that materialises `storage.types`) with `toStorageTypeInstance(...)`, and use the target-specific `PostgresEnumType` class for Postgres-enum entries — the `SqlStorage` constructor now throws on untagged entries instead of papering over them.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "storage.types"
- "codecId"
anyMatch: true
---
# 0.9 → 0.10 — Extension-author upgrade instructions
## `stamp-storage-types-kind-on-contract-snapshots`
Starting at the 0.10 release, the SQL family's contract serializer (`familyInstance.validateContract`, the seam every on-disk contract read now crosses) is **strict** about the `storage.types` polymorphic slot. Every entry must carry a `kind` discriminator (`"codec-instance"` for codec triples, `"postgres-enum"` for Postgres enums). The previous silent fallthrough in `normaliseTypeEntry` that quietly accepted untagged codec triples is gone.
This applies to **seed migrations** shipped inside an extension package (e.g. `migrations/<edge-id>/{start,end}-contract.json` shipped under `packages/<extension>/migrations/`) just as it does to user-app migrations. Any extension that ships seed `*-contract.json` snapshots with untagged `storage.types` entries will fail to load under 0.10 with a `Contract validation failed` envelope.
Before 0.10, seed snapshots looked like this:
```jsonc
{
"storage": {
"types": {
"Embedding1536": {
"codecId": "pg/vector@1",
"nativeType": "vector",
"typeParams": { "length": 1536 }
}
}
}
}
```
Starting at 0.10 the same entries must look like this:
```jsonc
{
"storage": {
"types": {
"Embedding1536": {
"kind": "codec-instance",
"codecId": "pg/vector@1",
"nativeType": "vector",
"typeParams": { "length": 1536 }
}
}
}
}
```
Postgres-enum entries undergo a structural change (the `name` is lifted from the entry's map key, `values` is hoisted out of `typeParams`, `typeParams` is dropped). See the user-facing entry (`../../../app/upgrades/0.9-to-0.10/instructions.md`) for the before/after on the enum shape — the on-disk transformation is identical for both audiences.
### What `stamp-storage-types-kind.ts` does
The colocated script walks the project root, descends into every directory named `migrations/` (skipping `node_modules`, `.git`, `dist`, `build`), and rewrites every `start-contract.json` and `end-contract.json` whose `storage.types` slot has at least one untagged entry:
- **Already-stamped entries** are left untouched (idempotent — safe to re-run).
- **`pg/enum@1`** entries are rewritten as the `"postgres-enum"` shape (lifts `name` from the map key, hoists `values` out of `typeParams`, drops `typeParams`).
- **Every other codecId** is rewritten as the `"codec-instance"` shape (`kind` prepended; `codecId`, `nativeType`, `typeParams` preserved verbatim).
- The whole snapshot is re-serialised via a JSON pretty-printer that mirrors the CLI's authoring shape (multi-line objects, inline short arrays of primitives) — the diff outside `storage.types` is zero on CLI-authored snapshots.
- A `--check` flag turns the script into a dry-run; lists affected snapshots and exits non-zero if any still need fixing.
If a snapshot contains a `storage.types` entry that is neither already-stamped nor an untagged codec triple, the script throws and names the offending entry. Hand-edit required.
## `stamp-storage-types-kind-in-source`
The SQL family's `SqlStorage` class now refuses untagged codec triples at construction time. Source code that builds an in-memory `SqlStorage` (or any contract-builder fixture / test that ends up materialising `storage.types`) with raw `{ codecId, nativeType, typeParams }` literals will throw at construction with a diagnostic like:
> `storage.types["Embedding1536"] has missing \`kind\`; expected "codec-instance" or "postgres-enum". Untagged codec triples should be wrapped with toStorageTypeInstance(...) before construction.`
There is no codemod for this — extensions construct `SqlStorage` via too many shapes (builder DSL, direct constructor, test fixtures, ad-hoc literals) for a deterministic transform to be safe. Instead, walk every `.ts` / `.tsx` file matched by the `detection.glob` above and apply these rules locally:
- **Codec-triple literal passed to `new SqlStorage({...})` or a builder that flattens to it** (e.g. `types: { Embedding1536: { codecId: 'pg/vector@1', nativeType: 'vector', typeParams: { length: 1536 } } }`): wrap each value with `toStorageTypeInstance(...)`:
```ts
import { toStorageTypeInstance } from '@internal/sql-contract';
const storage = new SqlStorage({
types: {
Embedding1536: toStorageTypeInstance({
codecId: 'pg/vector@1',
nativeType: 'vector',
typeParams: { length: 1536 },
}),
},
// …
});
```
The helper is idempotent — input already carrying the `kind` field passes through unchanged.
- **Postgres-enum literal** (e.g. `types: { user_type: { codecId: 'pg/enum@1', nativeType: 'user_type', typeParams: { values: ['admin', 'user'] } } }`): the canonical fix is to replace with a `PostgresEnumType` class instance from `@internal/postgres`:
```ts
import { PostgresEnumType } from '@internal/postgres';
const storage = new SqlStorage({
types: {
user_type: new PostgresEnumType({
name: 'user_type',
nativeType: 'user_type',
values: ['admin', 'user'],
}),
},
// …
});
```
The class instance carries `kind: 'postgres-enum'` and the structural shape the family discriminates on. Plain object literals with `kind: 'postgres-enum'` are rejected — the constructor route is mandatory because hydration of raw JSON envelopes is the target-specific serializer's job (cross-domain layering: the SQL family doesn't know about Postgres-enum's concrete class).
**Minimal-fix alternative for fixtures / round-trip tests.** If the code in question only round-trips the codec triple as a plain envelope (no target-specific enum behaviour — e.g. a planner test fixture that never reaches Postgres-enum planning hooks), stamping `kind: 'codec-instance'` on the existing literal is a sufficient and idempotent fix:
```ts
const fixtureStorage: SqlStorageInput = {
types: {
user_type: {
kind: 'codec-instance',
codecId: 'pg/enum@1',
nativeType: 'user_type',
typeParams: { values: ['admin', 'user'] },
},
},
// …
};
```
The SQL family treats the entry as an opaque codec triple and round-trips it unchanged. Use this form only when you do not need `PostgresEnumType`'s enum-specific structural fields (`name`, `values` lifted out of `typeParams`) or its enum-planning behaviour — otherwise prefer the `PostgresEnumType` constructor above.
- **Test fixtures that round-trip a contract through JSON** (e.g. `JSON.parse(JSON.stringify(contract))`): ensure the source-side contract is constructed with stamped entries before the round-trip; the deserializer now refuses to silently re-stamp on the read.
- **Extension-contributed codec catalogues** (extensions that register custom codecs): no source change required for the catalogue itself — codec definitions don't carry `kind`. The change is only at the `SqlStorage.types` slot where catalogue entries materialise per-contract.
### Validation
After running the JSON codemod and applying the source-level rules above, run `pnpm typecheck && pnpm test` (or your extension's equivalent). `prisma-8-check-pins` should also pass — the pin set is unchanged for this transition; the breaking change is in runtime construction behaviour, not the dependency contract.
If your extension ships seed migrations under `packages/<extension>/migrations/`, also run the codemod with `--check` against the project root to confirm the seed snapshots all stamp correctly. The user-facing copy of the same codemod (under `../../../app/upgrades/0.9-to-0.10/`) operates on the user's app-space migrations; this copy operates on your extension's seed migrations.
upgrading/extension/upgrades/0.9-to-0.10/stamp-storage-types-kind.ts
/**
* Stamps the `kind` discriminator on every entry in `storage.types`
* inside every on-disk contract snapshot reachable from the project
* root (`start-contract.json` / `end-contract.json` under any
* `migrations/` directory).
*
* Background: starting at the 0.10 release, the SQL family's
* `SqlStorage.types` polymorphic slot is strictly typed via an
* enumerable `kind` discriminator (`'codec-instance'` for codec
* triples; `'postgres-enum'` for Postgres enums). The family
* `ContractSerializer` rejects untagged entries with a diagnostic
* naming the offending entry — the previous silent fallthrough in
* `normaliseTypeEntry` is gone. See TML-2536.
*
* Before 0.10, the on-disk shape was:
*
* "storage": {
* "types": {
* "Embedding1536": {
* "codecId": "pg/vector@1",
* "nativeType": "vector",
* "typeParams": { "length": 1536 }
* },
* "user_type": {
* "codecId": "pg/enum@1",
* "nativeType": "user_type",
* "typeParams": { "values": ["admin", "user"] }
* }
* }
* }
*
* Starting at 0.10 the same entries are:
*
* "storage": {
* "types": {
* "Embedding1536": {
* "kind": "codec-instance",
* "codecId": "pg/vector@1",
* "nativeType": "vector",
* "typeParams": { "length": 1536 }
* },
* "user_type": {
* "kind": "postgres-enum",
* "name": "user_type",
* "nativeType": "user_type",
* "values": ["admin", "user"],
* "codecId": "pg/enum@1"
* }
* }
* }
*
* Dispatch rules (per entry):
*
* - Already-stamped entry (carries a `kind` field) → left untouched.
* - `codecId === "pg/enum@1"` → rewritten as the `postgres-enum`
* shape: `kind`, `name` (lifted from the entry key), `nativeType`,
* `values` (lifted out of `typeParams.values`), `codecId`.
* `typeParams` is dropped (its only meaningful content was `values`).
* - Any other `codecId` → rewritten as the `codec-instance` shape:
* `kind` prepended; `codecId`, `nativeType`, `typeParams` preserved.
* This is the safe default for unknown codec IDs (including any
* future extension-contributed codecs).
*
* The transformation re-serialises each affected file via
* `JSON.stringify(value, null, 2) + '\n'` — the same formatting the
* CLI uses when authoring snapshots originally, so the diff outside
* `storage.types` is zero on files the CLI generated. Hand-edited
* contract snapshots may experience cosmetic whitespace shifts; this
* is acceptable because on-disk contract snapshots are CLI-authored
* artefacts, not user-edited source.
*
* The codemod is idempotent: running it on already-stamped snapshots
* is a no-op (every entry passes the `kind`-already-present check).
*
* Flags:
* --check dry-run; lists affected files and exits 1 if any still
* need fixing.
*/
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
const CONTRACT_FILES = new Set(['start-contract.json', 'end-contract.json']);
const POSTGRES_ENUM_CODEC_ID = 'pg/enum@1';
const dryRun = process.argv.includes('--check');
const projectRoot = process.cwd();
interface Result {
readonly path: string;
readonly status: 'already-clean' | 'needs-fix' | 'fixed';
readonly stamped: number;
}
async function findContractSnapshots(root: string): Promise<string[]> {
const out: string[] = [];
// The `inMigrations` flag confines snapshot rewrites to the
// `migrations/` subtree the doc promises. Without the flag the walk
// would happily stamp any `start-contract.json` / `end-contract.json`
// found anywhere under the project root, including non-migration
// fixtures (e.g. inline contract test snapshots).
async function walk(dir: string, inMigrations: boolean): Promise<void> {
let entries: Awaited<ReturnType<typeof readdir>>;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
// Unreadable directory — skip silently. Mirrors the predecessor
// 0.8→0.9 codemod's failure-tolerant walk; the user's project
// root may legitimately contain restricted directories.
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
await walk(join(dir, entry.name), inMigrations || entry.name === 'migrations');
} else if (inMigrations && entry.isFile() && CONTRACT_FILES.has(entry.name)) {
out.push(join(dir, entry.name));
}
}
}
await walk(root, false);
return out.sort();
}
interface UntaggedCodecTriple {
readonly codecId: string;
readonly nativeType: string;
readonly typeParams: Record<string, unknown>;
}
interface StampedCodecInstance {
readonly kind: 'codec-instance';
readonly codecId: string;
readonly nativeType: string;
readonly typeParams: Record<string, unknown>;
}
interface StampedPostgresEnum {
readonly kind: 'postgres-enum';
readonly name: string;
readonly nativeType: string;
readonly values: readonly string[];
readonly codecId: string;
}
type StampedEntry = StampedCodecInstance | StampedPostgresEnum;
function isAlreadyStamped(value: unknown): boolean {
if (typeof value !== 'object' || value === null) return false;
const kind = (value as { kind?: unknown }).kind;
return kind === 'codec-instance' || kind === 'postgres-enum';
}
function looksLikeUntaggedCodecTriple(value: unknown): value is UntaggedCodecTriple {
if (typeof value !== 'object' || value === null) return false;
const obj = value as Record<string, unknown>;
if (
typeof obj['codecId'] !== 'string' ||
typeof obj['nativeType'] !== 'string' ||
typeof obj['typeParams'] !== 'object' ||
obj['typeParams'] === null
) {
return false;
}
// A `pg/enum@1` triple is only recognisable as an untagged enum if its
// `typeParams.values` is already a string[]. Without this guard a
// malformed enum entry would slip through the classifier and surface
// a different (more specific) diagnostic from `stampEntry` than the
// outer "neither stamped nor untagged-triple — hand-edit required"
// throw. Folding that case into the predicate gives every malformed
// entry the same single diagnostic shape.
if (obj['codecId'] === POSTGRES_ENUM_CODEC_ID) {
const values = (obj['typeParams'] as { values?: unknown })['values'];
if (!Array.isArray(values) || !values.every((v) => typeof v === 'string')) {
return false;
}
}
return true;
}
function stampEntry(name: string, raw: UntaggedCodecTriple): StampedEntry {
if (raw.codecId === POSTGRES_ENUM_CODEC_ID) {
const values = (raw.typeParams as { values?: unknown })['values'];
// Invariant: `looksLikeUntaggedCodecTriple` already gated this — a
// `pg/enum@1` entry that reaches `stampEntry` has a string[]
// `typeParams.values`. The runtime check stays as a defensive
// marker so a future loosening of the predicate doesn't silently
// produce a malformed StampedPostgresEnum.
if (!Array.isArray(values) || !values.every((v) => typeof v === 'string')) {
throw new Error(
`invariant: storage.types[${JSON.stringify(name)}] reached stampEntry with codecId="${POSTGRES_ENUM_CODEC_ID}" but typeParams.values is not a string[]; the classifier should have rejected this entry`,
);
}
return {
kind: 'postgres-enum',
name,
nativeType: raw.nativeType,
values,
codecId: raw.codecId,
};
}
return {
kind: 'codec-instance',
codecId: raw.codecId,
nativeType: raw.nativeType,
typeParams: raw.typeParams,
};
}
interface ProcessOutcome {
readonly transformed: Record<string, unknown> | null;
readonly stamped: number;
}
function processContract(parsed: unknown, filePath: string): ProcessOutcome {
if (typeof parsed !== 'object' || parsed === null) {
return { transformed: null, stamped: 0 };
}
const root = parsed as Record<string, unknown>;
const storage = root['storage'];
if (typeof storage !== 'object' || storage === null) {
return { transformed: null, stamped: 0 };
}
const storageObj = storage as Record<string, unknown>;
const types = storageObj['types'];
if (typeof types !== 'object' || types === null) {
return { transformed: null, stamped: 0 };
}
const typesObj = types as Record<string, unknown>;
let stamped = 0;
const newTypes: Record<string, unknown> = {};
for (const [name, entry] of Object.entries(typesObj)) {
if (isAlreadyStamped(entry)) {
newTypes[name] = entry;
continue;
}
if (!looksLikeUntaggedCodecTriple(entry)) {
throw new Error(
`${filePath}: storage.types[${JSON.stringify(name)}] is neither a stamped entry nor an untagged codec triple — refusing to guess. Hand-edit required.`,
);
}
newTypes[name] = stampEntry(name, entry);
stamped += 1;
}
if (stamped === 0) return { transformed: null, stamped: 0 };
return {
transformed: { ...root, storage: { ...storageObj, types: newTypes } },
stamped,
};
}
/**
* Pretty-print JSON with multi-line objects/arrays (per
* `JSON.stringify(null, 2)` conventions) but inline arrays of primitives
* (strings, numbers, booleans, null) whose serialised length fits
* within `INLINE_ARRAY_THRESHOLD`. This matches the on-disk shape the
* CLI's contract-snapshot writer produces (e.g. `"columns": ["id"]`,
* `"values": ["admin", "user"]`) — the alternative (Node's default
* `JSON.stringify(null, 2)`) reflows every such array onto multiple
* lines and bloats the diff with hundreds of lines of cosmetic noise.
*
* The non-array, non-object surfaces match `JSON.stringify(null, 2)`
* byte-for-byte (same key ordering — insertion order — and same
* quoting/escaping). The only divergence is inline-primitive-arrays.
*/
const INLINE_ARRAY_THRESHOLD = 80;
function isPrimitive(value: unknown): boolean {
return (
value === null ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
);
}
function formatJson(value: unknown, indentLevel = 0): string {
const indent = ' '.repeat(indentLevel);
const childIndent = ' '.repeat(indentLevel + 1);
if (value === null) return 'null';
if (typeof value === 'string') return JSON.stringify(value);
if (typeof value === 'number' || typeof value === 'boolean') return JSON.stringify(value);
if (Array.isArray(value)) {
if (value.length === 0) return '[]';
if (value.every(isPrimitive)) {
const inline = `[${value.map((v) => JSON.stringify(v)).join(', ')}]`;
if (inline.length <= INLINE_ARRAY_THRESHOLD) return inline;
}
const items = value.map((v) => `${childIndent}${formatJson(v, indentLevel + 1)}`);
return `[\n${items.join(',\n')}\n${indent}]`;
}
if (typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) return '{}';
const lines = entries.map(
([k, v]) => `${childIndent}${JSON.stringify(k)}: ${formatJson(v, indentLevel + 1)}`,
);
return `{\n${lines.join(',\n')}\n${indent}}`;
}
throw new Error(`Unsupported value: ${typeof value}`);
}
async function processFile(path: string): Promise<Result> {
const raw = await readFile(path, 'utf-8');
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(
`${path}: not valid JSON (${error instanceof Error ? error.message : String(error)})`,
);
}
const outcome = processContract(parsed, path);
if (outcome.transformed === null) {
return { path, status: 'already-clean', stamped: 0 };
}
const serialised = `${formatJson(outcome.transformed)}\n`;
if (!dryRun) await writeFile(path, serialised, 'utf-8');
return { path, status: dryRun ? 'needs-fix' : 'fixed', stamped: outcome.stamped };
}
const contracts = await findContractSnapshots(projectRoot);
if (contracts.length === 0) {
console.error(`No start-contract.json / end-contract.json files found under ${projectRoot}.`);
process.exit(1);
}
let changed = 0;
let alreadyClean = 0;
let totalStamped = 0;
for (const path of contracts) {
const result = await processFile(path);
const rel = path.slice(projectRoot.length + 1);
if (result.status === 'already-clean') {
alreadyClean += 1;
console.log(`OK ${rel} (already stamped or no storage.types)`);
} else {
changed += 1;
totalStamped += result.stamped;
const verb = dryRun ? 'WOULD FIX' : 'FIXED';
console.log(`${verb} ${rel} (stamped ${result.stamped} entry/entries)`);
}
}
console.log();
console.log(
`${contracts.length} snapshot(s) scanned: ${changed} ${dryRun ? 'needing fix' : 'fixed'} (${totalStamped} entries), ${alreadyClean} already clean.`,
);
if (dryRun && changed > 0) process.exit(1);
upgrading/extension/upgrades/8.0.0-rc.1-to-8.0.0-rc.2/instructions.md
---
from: "8.0.0-rc.1"
to: "8.0.0-rc.2"
changes:
- id: check-constraint-ir-carries-an-opaque-expression
summary: |
`CheckConstraint` (contract IR) and `SqlCheckConstraintIR` (schema IR) changed from
`{ name, column, valueSet }` / `{ name, column, permittedValues }` to
`{ name, prefix?, expression }`. Both are constructed from an `SqlObjectNaming` rather
than a bare name, exactly like `Index` / `SqlIndexIR`: pass
`{ naming: { kind: 'wire', prefix, hash }, expression }` for a managed check, or
`{ naming: { kind: 'exact', name }, expression }` for one adopted verbatim. Compute the
hash with `computeCheckContentHash(expression)` from `@internal/sql-schema-ir/naming`.
Reading a check off a built node is unchanged (`check.name`), and `check.prefix` tells
you whether it is wire-named. Contract JSON hydrates through
`checkConstraintInputFromSerialized`, which rejects a `prefix` that does not parse out of
the `name`. `resolveValueSetValues` is gone — a check no longer references a value set,
so there is nothing to resolve; the members are already baked into the predicate text.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- 'CheckConstraint'
- 'permittedValues'
- 'resolveValueSetValues'
anyMatch: true
- id: add-check-constraint-call-takes-an-expression
summary: |
`AddCheckConstraintCall` is now constructed as
`(schemaName, tableName, constraintName, expression)` — the `column` and `values`
parameters are gone, and the rendered DDL is
`ALTER TABLE … ADD CONSTRAINT "x" CHECK (<expression>)` with the predicate emitted
verbatim. The matching migration-class method takes
`{ schema, table, constraint, expression }`. `DropCheckConstraintCall` is unchanged.
There is no compatibility overload — update every construction site.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- 'AddCheckConstraintCall'
- 'addCheckConstraint'
anyMatch: true
- id: specifier-default-control-policy-requires-create-namespace
summary: |
The options bag on `typescriptContract` / `typescriptContractFromPath` now requires
`createNamespace` alongside `defaultControlPolicy`. Stamping a specifier default carries
a consequence — derived CHECK constraints are stripped from tables the stamped policy
leaves non-managed — and the strip rebuilds storage namespaces through the target's
factory, so the two options travel together. Pass the same factory the PSL specifier
already takes:
`typescriptContract(contract, output, { defaultControlPolicy: 'external' })` becomes
`typescriptContract(contract, output, { defaultControlPolicy: 'external',
createNamespace: postgresCreateNamespace })`, with `postgresCreateNamespace` imported
from the Postgres target's types entrypoint (`@internal/target-postgres/types`).
Calls without an options bag are unchanged, and `emptyContract` already took
`createNamespace`.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- 'typescriptContract'
- 'defaultControlPolicy'
anyMatch: false
- id: re-emit-extension-contract-spaces
summary: |
Run your extension's `contract emit` (the `build:contract-space` script, if you have one)
to regenerate its committed `contract.json` / `contract.d.ts`. Two things change: any
enum CHECK is re-serialized into the new shape with a wire name, and every list (`many`)
column gains a declared element-non-null CHECK that the Postgres planner used to
synthesize without ever declaring. Prefixes derived from long table and column names are
truncated to 54 UTF-8 bytes so the wire name fits Postgres's 63-byte identifier limit;
identity lives in the hash, so truncated prefixes still yield distinct names.
Postgres introspection also stopped parsing predicates and now captures every CHECK
constraint verbatim, so any hand-written or platform-installed check on a table your
extension manages is visible for the first time: it verifies as an undeclared extra under
`--strict` and becomes a `dropCheckConstraint` under a policy that allows `destructive`.
If your extension installs checks out of band — through a raw-SQL migration step rather
than through the contract — declare them instead: `@@check(expression: "…", map: "<physical
name>")` adopts a constraint under the name it already carries. Until they are declared,
keep the tables carrying them under an additive-only policy — the checks survive, and only
`--strict` verify reports them — or expect the first destructive plan against an upgraded
database to offer to drop them. Declaring them is the durable fix; see
`authored-check-constraints` in this transition.
detection:
glob: "**/contract.json"
contains:
- '"many": true'
- '"valueSet"'
anyMatch: true
- id: stub-execution-contexts-must-carry-an-aggregate-registry
summary: |
A pack that hand-rolls an `ExecutionContext` in its tests must give it an
`aggregateDescriptors` registry whose `values()` yields the descriptors the test exercises.
`orm(...)` and every `Collection` construction enumerate that registry to build their method
sets, so a stub context without the field raises
`TypeError: Cannot read properties of undefined (reading 'values')` at construction, and a
registry whose `values()` yields nothing produces a collection and a `sql()` function bag
with no aggregate methods at all — `TypeError: aggregate.count is not a function` at the
first call. A `resolve()` that answers `count` is no longer sufficient on its own: `resolve`
settles one `(operation, input)` pair, `values` declares the vocabulary. Prefer
`buildSqlAggregateDescriptorRegistry(descriptors, codecDescriptors)` from
`@internal/sql-relational-core/aggregate-descriptor-registry` over a hand-written object —
it settles the same registry the runtime does.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- "aggregateDescriptors"
- "ExecutionContext"
- "SqlAggregateDescriptorRegistry"
anyMatch: true
- id: aggregate-surfaces-derive-from-the-contract-s-operation-map
summary: |
The ORM's `aggregate()` / `groupBy().aggregate()` / `groupBy().having()` builders, the
include reducers on a collection, and the SQL builder's aggregate functions no longer
declare `count` / `sum` / `avg` / `min` / `max` outright. Each surface is a mapped type over
the operation names in the contract's emitted `AggregateTypes` block. Deriving the surface
neither adds nor removes a method by itself, but the block a re-emit produces is not the
list it was: PostgreSQL now declares eight operations and SQLite seven, and the bare results
over integer columns moved — `count`, `sum`, and `avg`. What stayed: `min` / `max`, `sum`
and `avg` over a float, `numeric`, `interval`, or `time` column, and `sum` over an
`UnboundedInt` column, each still in its own family — see
`count-over-a-field-counts-that-field` and `aggregate-defaults-are-js-native-numbers`.
A contract whose block is unknown — an in-code `defineContract(...)` value, or a
contract emitted before `AggregateTypes` existed — resolves every one of those surfaces to
`AggregateOperationsUnavailable`, an empty type, so the call fails with
`Property 'count' does not exist` rather than offering selector types the declaration
cannot supply. This is a compile-time change only: the runtime builds its methods from the
composed registry and dispatches exactly as before. Either emit the contract and type the
client from the emitted `Contract`, or cast the builder to a dynamic record — see the body
for the shape.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- "defineContract"
- ".aggregate("
- "AggregateBuilder"
- "HavingBuilder"
anyMatch: true
- id: count-over-a-field-counts-that-field
summary: |
`count(field)` renders `COUNT(<column>)` in the ORM. It used to drop the argument and render
`COUNT(*)`. PostgreSQL declares `count` with `input: { kind: 'any' }`, which settles into
both a `withoutInput` row and an `anyInput` row, so the derived method carries both arities
honestly: `count()` counts rows, `count(field)` counts that field's non-null values. Typed
call sites are unaffected, because `count` took no argument before and no such call
compiled. Reachable through a call that bypassed the types — a `@ts-expect-error`, an
`as never` argument, or dynamic invocation. Those call sites change result whenever the
column holds NULLs. Sweep them and drop the argument wherever `COUNT(*)` was what you meant.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- "count("
- ".aggregate("
anyMatch: true
- id: contributed-aggregate-operations-carry-a-lowering-hook
summary: |
An aggregate descriptor whose `operation` is not one of `count`, `sum`, `avg`, `min`, `max`
must declare a `lower` hook. `operation` has been a `string` for a release, but a name
outside that alphabet was inert — no consumer surface offered it. It is live now: a
contributed operation surfaces as a method on the ORM and SQL-builder aggregate surfaces
under its own name, so it must be able to build an expression, and the closed AST alphabet
gives it none. Registry assembly rejects a hook-less descriptor with
`RUNTIME.AGGREGATE_LOWERING_MISSING` while the execution context composes. Two further
rules follow. An out-of-alphabet operation is projection-only: HAVING, ORDER BY, and
comparison operands refuse it with `ORM.AGGREGATE_PROJECTION_ONLY`, and its typed HAVING
method does not exist. And its name may not shadow an ORM collection member (`select`,
`where`, `include`, `combine`, `state`, …), because reducers install into that same
namespace; `orm(...)` rejects a collision with `ORM.AGGREGATE_OPERATION_RESERVED`.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- "aggregateDescriptors"
- "SqlAggregateDescriptor"
anyMatch: true
- id: aggregate-defaults-are-js-native-numbers
summary: |
Both built-in targets split their aggregate vocabulary. `count()`, `sum()` over an integer
input, and `avg()` over an integer input read as `number` — where they read as a `bigint`,
a `bigint`-or-decimal-string, and a decimal string. Three new operations carry the lossless
results: `countBigInt` → `bigint`, `sumBigInt` → `bigint` (over `pg/int8@1`,
`pg/int8number@1`, and `pg/unboundedint@1` it resolves to `pg/unboundedint@1`, exact past
2^63), and `avgDecimal` → `pg/numeric@1`, PostgreSQL only. `count`'s empty-input answer is
`0`, not `0n`. `count`, and `sum` over an integer input, raise `RUNTIME.DECODE_FAILED` past
±(2^53 − 1) rather than rounding, on the JSON/include path as well as the wire path; no
other result carries that guard, `avg` included. Unchanged: `min`/`max`,
`sum`/`avg` over float codecs, `sum` over `pg/numeric@1` and `pg/unboundedint@1`, and the
ORM's `having(...)` operands, which its typed surface fixes at `number`. The SQL builder's
comparison operands do move: `fns.gt` types both sides from one codec, so
`fns.gt(fns.count(), 1n)` becomes `fns.gt(fns.count(), 1)`.
Two things to do. Re-run your contract space's `contract emit` —
the `AggregateTypes` block gains the three operations and the changed result codecs. Then
fix pack tests that assert aggregate values or rendered SQL: `2n` and decimal-string
expectations become plain numbers, and PostgreSQL's integer `avg` renders
`CAST(avg(…) AS float8)` where it rendered a plain `avg(…)`. SQLite's transport cast to
text is unchanged, but `sqlite/bigintnumber@1` now carries a JSON projection
(`CAST(… AS INTEGER)`), so a SQLite include aggregate arrives inside `json_object` as a
JSON number rather than a JSON string.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- ".aggregate("
- "aggregateDescriptors"
- "AggregateTypes"
anyMatch: true
- id: non-nullable-aggregate-descriptors-declare-an-empty-result
summary: |
A descriptor with `nullable: false` must also declare `emptyResultJson` — the value the
operation answers with when no result row reaches the client at all. `AggregateResultNullability`
(exported from `@internal/framework-components/components`) is a discriminated union, so
`{ nullable: false }` on its own does not compile, and registry assembly rejects it at
runtime with `RUNTIME.AGGREGATE_DESCRIPTOR_INVALID` (or `CONTRACT.AGGREGATE_DESCRIPTOR_INVALID`
during emit). State the value in the **result codec's canonical JSON**, not as an
application value: `emptyResultJson: 0` under `pg/int8number@1`, `emptyResultJson: '0'`
under `pg/int8@1`. The empty-input answer belongs to the operation rather than to the codec
— `count`'s identity element is zero, an `every()`'s would be `true` — which is why it sits
on the descriptor. `ResolvedSqlAggregate` (`@internal/sql-relational-core/query-lane-context`)
follows the same union, so a consumer that constructs one spreads the nullability rather
than assigning `nullable: boolean`; reading `resolved.nullable` still narrows as before.
Nullable descriptors are unchanged.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- "nullable: false"
- "aggregateDescriptors"
- "ResolvedSqlAggregate"
anyMatch: true
- id: integer-codecs-check-the-js-type-they-are-given
summary: |
`pg/int8@1`, `pg/unboundedint@1`, and `sqlite/bigint@1` refuse a JS `number` on `encode`,
and `pg/int8number@1` and `sqlite/bigintnumber@1` refuse a `bigint`, with
`RUNTIME.ENCODE_FAILED` and a message naming the type that arrived
(`pg/int8@1 value must be a bigint, got number 9`) plus `meta.received`. The bigint codecs
used to accept a number and stringify it, which let a fractional value through to an
integer column. `encodeJson` is deliberately wider on the exact codecs: it accepts a
safe-integer `number`, because a schema language writes no `bigint` literal and
`BigInt @default(0)` arrives as the JSON number `0`; a non-integral or unsafe number raises
`<codec> number literal must be an integer within the safe integer range`. Two consequences
for a pack. Any place you hand a codec a value read out of a contract, JSON, or PSL must
pick the matching method — `encode` takes the application value, `encodeJson` takes and
returns canonical JSON. And if your pack renders DDL literal defaults itself, read the
stored value back first: `await codec.encode(codec.decodeJson(stored), {})`, which is the
two declared conversions in their declared order, rather than passing canonical JSON
straight to `encode`.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- "encodeJson"
- "decodeJson"
- "codec.encode"
anyMatch: true
- id: authored-check-constraints
summary: |
Two things change for packs. `sql.checkConstraint` is a new adapter-reported capability: an
adapter whose target implements CHECK constraint DDL reports it, and the `@@check` authoring
surface is gated on it. And a check is no longer "derived" merely by being wire-named —
user-authored checks are wire-named too. Derivation is now decided by whether the wire prefix
is one derivation would produce for a column of that table. A pack that read
`check.prefix !== undefined` to mean "Prisma 8 generated this" must use the same
prefix-shape test, `derivedCheckPrefixes` from `@internal/sql-schema-ir/naming`. An authored
name that collides with a derived prefix shape is rejected at authoring with
`CONTRACT.CHECK_NAME_RESERVED`.
detection:
glob: "**/*.{ts,tsx}"
contains:
- 'checkConstraint'
- 'derivedCheckPrefixes'
anyMatch: true
- id: runtime-query-execute-hard-cut
summary: |
Runtime and scope implementations expose `query()` for rows, while rc.1 prepared rows use `target.queryPrepared(prepared, params, options?)` and rc.2 uses `prepared.query(target, params, options?)`; statistics-returning `execute()` remains for non-returning statements. Classify callers and helpers by the result they consume; do not globally rename `execute`. Route row plans to `query`, prepared row plans to `prepared.query(target, params, options?)`, and non-returning writes to `execute`, reading `stats.affectedRows` when a count is needed. Preserve the bound connection, row-result laziness, and eager statistics result. Middleware uses `beforeQuery` → `interceptQuery` → driver query → `onRow` → `afterQuery` for rows and `beforeExecute` → `interceptExecute` → driver execute → `afterExecute` for statistics. Query interception returns `{ rows }`; execute interception returns `{ stats }`. There is no operation discriminator, compatibility alias, or generic fallback hook. The Mongo facade keeps static `db.query` and removes row-execution `db.execute`; execute a built row plan through `(await db.runtime()).query(plan)`.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
contains:
- "beforeQuery"
- "interceptExecute"
- ".queryPrepared("
- ".execute("
anyMatch: true
- id: config-file-is-prisma-config-with-an-orm-section
summary: |
The CLI config file is `prisma.config.ts` — the `prisma-next.config.ts` name is
deprecated — and the config value is engine-shaped: `defineConfig` from
`@prisma/cli-engine` wraps the whole ORM config as its `orm` section. Rename the file,
then wrap the existing export: alias the current `defineConfig` import (from the target
facade or CLI config-types) as `ormConfig` and write
`export default defineConfig({ orm: ormConfig({ ...existing config... }) })`, adding
`@prisma/cli-engine` to devDependencies. Both the deprecated filename and the flat shape
still load, each printing a deprecation warning on stderr, so the two steps can land
separately; anything asserting clean stderr around CLI invocations sees the warning
until both are done.
detection:
glob: "**/prisma*.config.*"
contains:
- "defineConfig"
anyMatch: true
- id: published-prisma-next-bin-retired
summary: |
Nothing published ships a `prisma-next` bin anymore: `@prisma/orm-toolchain` publishes
the `orm` command family at `@prisma/orm-toolchain/cli` and no bin, and the database
facades forward no launcher. The only user-facing binary is the unified `prisma` CLI
(the prisma-cli distribution), which mounts the same commands. Replace
`prisma-next <command>` invocations in package scripts and CI with the unified CLI's
equivalent, and drop any dependency that was taken only to put the bin on PATH.
detection:
glob: "**/package.json"
contains:
- "prisma-next"
anyMatch: true
- id: prepared-statements-split-by-declared-result
summary: |
`runtime.prepare()` now returns one of two handles, chosen from the plan the callback
builds: a rows plan gives the `PreparedStatement<Params, Row>` you already have,
consumed with `.query(target, params)`; a plan whose declared result is an affected-row
count gives a `PreparedExecution<Params>`, consumed with `.execute(target, params)` and
resolving `SqlStatementStats`. Two things follow for an extension. A facade that
redeclares `prepare()` changes its return type from
`Promise<PreparedStatement<ParamsFromDeclaration<D, CT>, Row>>` to
`Promise<PreparedFor<ParamsFromDeclaration<D, CT>, Row>>`, importing `PreparedFor` from
`@internal/sql-runtime` — no logic changes. A scope of your own that installs the
prepared-query bridge (`preparedStatementQuery`) must also install the execute bridge
(`preparedStatementExecute`, from `@internal/sql-runtime/internal/prepared-query`),
routing it to your bound queryable exactly as the query bridge does; without it,
`prepared.execute(yourScope, ...)` throws on the bridge invariant.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "preparedStatementQuery"
- "PreparedStatement<ParamsFromDeclaration"
anyMatch: true
- id: prepare-defaults-to-the-contract-codec-map
summary: |
A facade that redeclares `prepare()` should default its codec-map parameter to
`ExtractCodecTypes<TContract>` alone. Intersecting it with `CodecTypesBase` — the shape
the in-tree facades used — collapses `keyof CT` to `string` through that type's index
signature, which costs a caller every codec-id completion in the declaration and lets an
id no registry carries typecheck. Drop the intersection; the `CT extends CodecTypesBase`
constraint stays and nothing else in the signature changes. If your facade exposes a
contract-bound raw tag, its `.returns()` now narrows to the contract's ids the same way,
with no change on your side; a contract-free tag keeps accepting any string.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "ExtractCodecTypes<TContract> & CodecTypesBase"
- "CodecTypesBase ="
anyMatch: true
---
# 8.0.0-rc.1 → 8.0.0-rc.2 — Extension-author upgrade instructions
## `stub-execution-contexts-must-carry-an-aggregate-registry`
The aggregate method sets are built by enumerating the composed registry, once per `Collection` construction and once at `orm(...)`. A test that fabricates an `ExecutionContext` therefore has to supply one:
```ts
import { buildSqlAggregateDescriptorRegistry } from '@internal/sql-relational-core/aggregate-descriptor-registry';
const context = {
contract,
codecDescriptors,
aggregateDescriptors: buildSqlAggregateDescriptorRegistry(descriptors, codecDescriptors),
// …
};
```
Two distinct failures tell you which half is missing:
- **`TypeError: Cannot read properties of undefined (reading 'values')`**, thrown from `new Collection(...)` or `orm(...)` — the stub has no `aggregateDescriptors` field at all.
- **`TypeError: aggregate.count is not a function`** (or `posts.count is not a function`, or `fns.sum is not a function`) — the stub has a registry, but its `values()` yields nothing, so no method was installed. `resolve()` alone no longer describes the surface: `resolve` settles one `(operation, input)` pair on demand, `values` declares which operations exist.
A minimal hand-written stub that keeps `count` available:
```ts
const aggregateDescriptors = {
resolve: (operation: string) =>
operation === 'count'
? {
operation,
output: { codecId: 'pg/int8@1' },
nullable: false as const,
emptyResultJson: '0',
lower: undefined,
}
: undefined,
values: function* () {
yield {
operation: 'count',
input: { kind: 'any' as const },
output: { kind: 'codec' as const, codecId: 'pg/int8@1' },
nullable: false as const,
emptyResultJson: '0',
};
},
};
```
`emptyResultJson` is required on any non-nullable row — see [`non-nullable-aggregate-descriptors-declare-an-empty-result`](#non-nullable-aggregate-descriptors-declare-an-empty-result) for what the value means and which form to state it in.
Prefer the real builder where the test can afford it — it applies the same validation the runtime does, including the lowering rule below.
## `aggregate-surfaces-derive-from-the-contract-s-operation-map`
Every aggregate surface is now a mapped type keyed by the operation names in the contract's emitted `AggregateTypes` block:
| Surface | Type |
| --- | --- |
| `collection.aggregate(fn)` | `AggregateBuilder<TContract, ModelName, NsId>` |
| `groupBy(...).aggregate(fn)` | the same |
| `groupBy(...).having(fn)` | `HavingBuilder<…>` — keyed by the map *intersected with* the SQL alphabet |
| `include('rel', (rel) => …)` | `AggregateIncludeReducers<…>` on the collection |
| `sql().select((f, fns) => …)` | `AggregateOnlyFunctions<QC>` |
For a contract emitted by `prisma-next contract emit` on 8.0.0-rc.1 or later, the derivation itself takes nothing away — the block names whatever the composed stack declares. It does not name the same list it did, though: PostgreSQL now declares eight operations and SQLite seven, and the bare results over integer columns moved — `count`, `sum`, and `avg`. `min` / `max` did not, nor did `sum` and `avg` over a float, `numeric`, `interval`, or `time` column, nor `sum` over an `UnboundedInt` column; each of those stays in its own family. Re-emit, then work the two entries that carry those changes — [`count-over-a-field-counts-that-field`](#count-over-a-field-counts-that-field) and [`aggregate-defaults-are-js-native-numbers`](#aggregate-defaults-are-js-native-numbers).
For a contract whose block is unknown, all five surfaces resolve to `AggregateOperationsUnavailable` — an empty interface carrying one optional symbol-keyed brand that names the reason on hover. Two populations reach it:
- a contract built in code with `defineContract(...)` and used without emission (fixtures, integration tests, the no-emit authoring flow);
- a contract emitted before `AggregateTypes` existed, i.e. before 8.0.0-rc.1.
The symptom is a compile error at the call site:
```text
Property 'count' does not exist on type 'AggregateOperationsUnavailable'.
```
Runtime behaviour is unchanged — the methods are installed from the composed registry either way — so a cast is a legitimate fix where the contract is deliberately un-emitted:
```ts
import type { AggregateSpec } from '@internal/sql-orm-client';
type DynamicAggregates = Record<string, (field?: string) => AggregateSpec[string]>;
const stats = await readings.aggregate((aggregate) => {
const dynamic = aggregate as DynamicAggregates;
return { total: dynamic['sum']!('counter'), peak: dynamic['max']!('counter') };
});
```
The same shape works for an include reducer, whose callback receives the refinement collection:
```ts
const reduceToTotal = (related: unknown): unknown =>
(related as DynamicAggregates)['sum']!('counter');
await readings.select('id').include('samples', (samples) => reduceToTotal(samples) as never).all();
```
Where an argument was previously widened past the types — `aggregate.sum('counter' as never)` compiled because `AggregateFieldNames` was already `never` for such a contract — the cast moves from the argument to the builder, and the field name goes back to being a plain string.
The better fix, wherever the pack can emit, is to emit: run `prisma-next contract emit` and type the client from the emitted `Contract`. That restores full typing — arities, admitted field names, and per-codec result types — instead of erasing it.
## `count-over-a-field-counts-that-field`
`count(field)` counts that field:
```ts
await db.orm.User.aggregate((aggregate) => ({ all: aggregate.count() }));
// SELECT COUNT(*) …
await db.orm.User.aggregate((aggregate) => ({ named: aggregate.count('email') }));
// SELECT COUNT("email") … — rows whose email is NULL are not counted
```
The second call used to render `COUNT(*)`: the argument was accepted and discarded. It is honoured now, because both arities are read off `count`'s rows rather than special-cased — PostgreSQL declares `count` with `input: { kind: 'any' }`, which settles into a `withoutInput` row (the zero-argument call) and an `anyInput` row (the field-taking call).
No typed call site changes meaning, because the field-taking overload did not exist before. What to sweep for is a call that got past the types:
- `// @ts-expect-error` immediately above a `count(...)` call. Where the argument is a field the contract admits, the suppression is now unused and TypeScript reports the unused directive. Where it is not — a relation name, say — the directive still holds and the call still fails.
- `count(x as never)` or `count(x as any)`.
- dynamic dispatch through a `Record<string, …>` cast.
For each, decide which count you meant. `COUNT(*)` is `count()`; `COUNT(col)` skips NULLs.
## `contributed-aggregate-operations-carry-a-lowering-hook`
Aggregate operation names are an open vocabulary, and they now reach the consumer surfaces. The SQL alphabet holds exactly five names (`count`, `sum`, `avg`, `min`, `max`), and a pack that contributes a descriptor named outside it — `bitOr`, say — gets a method under that name on the ORM's `aggregate()`, on `groupBy().aggregate()`, and on the include reducers, plus a matching function on the SQL builder. Three rules come with that.
**Declare a `lower` hook.** The AST's `AggregateExpr` carries only alphabet names, so a novel operation has no default form; the hook builds its whole expression from existing nodes:
```ts
import type { SqlAggregateDescriptor } from '@internal/sql-relational-core/aggregate-descriptor-registry';
import { FunctionCallExpr } from '@internal/sql-relational-core/ast';
const bitOr: SqlAggregateDescriptor = {
operation: 'bitOr',
input: { kind: 'codec', codecId: 'pg/int8@1' },
output: { kind: 'codec', codecId: 'pg/int8@1' },
nullable: true,
lower: ({ expr }) => FunctionCallExpr.of('bit_or', expr === undefined ? [] : [expr]),
};
```
Without it, the execution context refuses to compose:
```text
RUNTIME.AGGREGATE_LOWERING_MISSING: Aggregate descriptor 'bitOr:codec:pg/int8@1' declares
operation 'bitOr', which is outside the SQL aggregate alphabet (count, sum, avg, min, max)
and carries no lowering hook.
```
**Use it in projections only.** The lowered form is a rendering for the driver boundary. HAVING, ORDER BY, and comparison operands compare inside the database, where that rendering would change what the comparison means, so both consumers refuse those positions at authoring time with `ORM.AGGREGATE_PROJECTION_ONLY`. The typed surface agrees: `HavingBuilder` is keyed by the map intersected with the alphabet, so a contributed operation has no HAVING method to call. Project it in a select and filter or order on the projected value.
**Pick a name no collection member owns.** Include reducers install into the ORM collection's own namespace, beside `select`, `where`, `include`, `combine`, `aggregate`, and the collection's instance fields. `orm(...)` rejects a collision with `ORM.AGGREGATE_OPERATION_RESERVED` and names the operation in `meta.operation`; rename the operation.
Reference: [the aggregate descriptor guide](https://github.com/prisma/prisma/blob/main/docs/reference/aggregate-descriptor-guide.md).
## `aggregate-defaults-are-js-native-numbers`
Both built-in targets now split their aggregate vocabulary: the bare operations answer in the JS-native type, three new suffixed operations answer losslessly.
| Operation | Input | Result codec | Was |
| --- | --- | --- | --- |
| `count` | none or any | `pg/int8number@1` / `sqlite/bigintnumber@1` | `pg/int8@1` / `sqlite/bigint@1` |
| `countBigInt` | none or any | `pg/int8@1` / `sqlite/bigint@1` | — (new) |
| `sum` | `pg/int2@1`, `pg/int4@1`, `pg/int@1`, `sql/int@1` | `pg/int8number@1` | `pg/int8@1` |
| `sum` | `pg/int8@1`, `pg/int8number@1` | `pg/int8number@1` | `pg/numeric@1` |
| `sum` | SQLite's integer codecs | `sqlite/bigintnumber@1` | `sqlite/bigint@1` |
| `sumBigInt` | `pg/int2@1`, `pg/int4@1`, `pg/int@1`, `sql/int@1` | `pg/int8@1` | — (new) |
| `sumBigInt` | `pg/int8@1`, `pg/int8number@1`, `pg/unboundedint@1` | `pg/unboundedint@1` | — (new) |
| `sumBigInt` | SQLite's integer codecs | `sqlite/bigint@1` | — (new) |
| `avg` | every PostgreSQL integer codec | `pg/float8@1`, through a result cast | `pg/numeric@1` |
| `avgDecimal` | every PostgreSQL integer codec, plus `pg/numeric@1` | `pg/numeric@1` | — (new) |
Everything else keeps its row: `min` / `max`, `sum` and `avg` over the float codecs, `sum` over `pg/numeric@1` and `pg/unboundedint@1`, `avg` over `pg/numeric@1` and `pg/interval@1`, and SQLite's `avg`, which was already `sqlite/real@1`.
`sumBigInt` over a 64-bit input resolves to `pg/unboundedint@1` rather than `pg/int8@1`, deliberately: PostgreSQL computes that total as a `numeric`, and casting it back to `int8` would raise `bigint out of range` past 2^63. On SQLite, `sumBigInt` is offered inside SQLite's own bound — a 64-bit `SUM` overflow raises `integer overflow` in the database rather than promoting to a float.
### What to do
1. **Re-run your contract space's `contract emit`.** The `AggregateTypes` block in the committed `contract.d.ts` gains `countBigInt`, `sumBigInt`, and `avgDecimal`, and the changed result codecs on `count` / `sum` / `avg`.
2. **Fix value assertions in pack tests.** `expect(stats.total).toBe(2n)` becomes `toBe(2)`; a decimal-string average expectation becomes a number. Where the test was proving exactness, change the *method* to the suffixed variant rather than the expectation.
3. **Fix rendered-SQL assertions on PostgreSQL `avg`.** An integer `avg` renders `CAST(avg("t"."c") AS float8)` where it rendered a plain `avg("t"."c")`. The cast is on the **result**, so the exact `numeric` mean is computed first and rounded once.
4. **Expect a JSON number from a SQLite include aggregate.** `sqlite/bigintnumber@1` carries a JSON projection (`CAST(… AS INTEGER)`), so an included `count` or `sum` arrives inside `json_object` as a JSON number rather than a JSON string. The transport cast to text on the flat path is unchanged.
5. **Retype SQL-builder comparison literals against an aggregate.** `fns.gt(a, b)` types both operands from one codec, so a literal compared against `fns.count()` or an integer `fns.sum(...)` follows the aggregate's new result codec: `fns.gt(fns.count(), 1n)` becomes `fns.gt(fns.count(), 1)`. The ORM's `having(...)` is not this case — its comparand is typed `number` outright.
`count`, and `sum` over an integer input, raise `RUNTIME.DECODE_FAILED` past ±(2^53 − 1) rather than rounding, on the JSON path as well as the wire path — the guard runs after `JSON.parse`, and rounding is monotone, so a value outside the range cannot parse back inside it. They are the two results a guarded integer codec produces; `sum` over a float, `numeric`, or unbounded-integer input keeps that input's own family, and `avg` resolves to a float codec that rounds as any double does.
## `non-nullable-aggregate-descriptors-declare-an-empty-result`
A descriptor that declares `nullable: false` must declare `emptyResultJson` beside it:
```ts
import type { SqlAggregateDescriptor } from '@internal/sql-relational-core/aggregate-descriptor-registry';
const count: SqlAggregateDescriptor = {
operation: 'count',
input: { kind: 'any' },
output: { kind: 'codec', codecId: 'pg/int8number@1' },
nullable: false,
emptyResultJson: 0,
};
```
`AggregateResultNullability` — exported from `@internal/framework-components/components` — is a discriminated union (`{ nullable: true } | { nullable: false; emptyResultJson: JsonValue }`), so `{ nullable: false }` alone is a type error. The runtime check agrees: registry assembly raises `RUNTIME.AGGREGATE_DESCRIPTOR_INVALID`, and `contract emit` raises `CONTRACT.AGGREGATE_DESCRIPTOR_INVALID`.
**State the value in the result codec's canonical JSON, not as an application value.** The client decodes it through the codec the same row declares, so the two must agree — and the wrong form only fails at the one moment a populated table never reaches. `count`'s zero is `0` under `pg/int8number@1` and `'0'` under `pg/int8@1`.
**Why it lives on the descriptor.** The empty-input answer is a property of the operation, not of the type its result carries: `count`'s identity element is zero, an `every()`'s would be `true`, a `product()`'s would be one. A codec has no way to know which.
The value is read only where no result row reaches the client at all — an absent aggregate alias, or an include whose envelope never arrived. SQL answers an ordinary empty input set itself.
If your pack consumes a resolution rather than contributing one, `ResolvedSqlAggregate` (`@internal/sql-relational-core/query-lane-context`) follows the same union. Reading `resolved.nullable` narrows as it always did; constructing one spreads the nullability instead of assigning a boolean:
```ts
const nullability = descriptor.nullable
? ({ nullable: true } as const)
: ({ nullable: false, emptyResultJson: descriptor.emptyResultJson } as const);
return { operation, output, ...nullability, lower };
```
## `integer-codecs-check-the-js-type-they-are-given`
The integer codecs answer for the JS type before the range, so a wrong type reads as a wrong type:
```text
RUNTIME.ENCODE_FAILED: pg/int8@1 value must be a bigint, got number 9
RUNTIME.ENCODE_FAILED: pg/int8number@1 value must be a number, got bigint 9
```
`meta.received` names the type that arrived. `pg/int8@1`, `pg/unboundedint@1`, and `sqlite/bigint@1` read a `bigint`; `pg/int8number@1` and `sqlite/bigintnumber@1` read a `number`. The bigint codecs used to accept a number and stringify it, which meant `1.5` reached an integer column as valid decimal text.
**`encodeJson` is wider on the exact codecs, and only there.** It also accepts a safe-integer `number`, because a schema language writes no `bigint` literal — `BigInt @default(0)` arrives as the JSON number `0`. A non-integral or unsafe number is refused:
```text
RUNTIME.ENCODE_FAILED: pg/int8@1 number literal must be an integer within
the safe integer range, got 9007199254740992
```
Past that range the literal was already rounded before the codec saw it, so its digits no longer name the value that was written.
Two consequences for a pack:
- **Pick the method that matches the value you hold.** `encode` takes the application value and produces a wire value; `encodeJson` takes and returns canonical JSON. Handing canonical JSON to `encode` used to work for codecs whose two forms coincide and now fails loudly for the ones whose forms differ.
- **If your pack renders DDL literal defaults itself, read the stored value back first.** A contract stores a default in the codec's canonical JSON, so the pair is `decodeJson` then `encode`, in that order:
```ts
const value = stored instanceof Date ? stored : codec.decodeJson(stored);
const wire = await codec.encode(value, {});
```
A `Date` is the one authored value JSON has no notation for, so it is the one that arrives as itself.
<!--
PR #30049: `changes: []`. The 8.0.0-rc.2 release cut. The extension-package diff is the lockstep version bump, the `@prisma/cli-engine` peer moving to 0.1.1, and the supabase contract fixtures re-emitted so their embedded extension version matches. The engine peer is a devDependency in every extension package here, so it reaches no downstream extension author; the consumer-facing changes this release ships are the entries above, recorded by the PRs that made them.
PR #29920: `changes: []`. Adds prepared-statement test coverage to the Supabase runtime suite (test-fixture codec registration only) and fixes a postgres direct-driver transaction defect; neither requires downstream extension source translation. The SPI split itself is recorded as `driver-spi-splits-query-and-execute` in the 0.17-to-8.0.0-rc.1 transition.
PR #29902: `changes: []`. Generated contracts gain additive aggregate rows for new opt-in integer representation codecs, but existing extension schemas and source require no migration; extension authors re-emit only when adopting the new target-scoped types.
PR #29940: `changes: []`. Dependabot's weekly dev-dependency bumps, which move `@biomejs/biome` to 2.5.6 and realign every `biome.jsonc` `$schema` URL to match. The extension-package diff is that URL and nothing else, so it requires no downstream extension source translation.
PR #29965: `changes: []`. The same shape again: `@biomejs/biome` moves to 2.5.7 and every `biome.jsonc` `$schema` URL is realigned to match. The extension-package diff is that URL and nothing else, so it requires no downstream extension source translation.
-->
## Why checks stopped being structured
A check is now one opaque SQL string that nothing parses. Postgres reprints predicates in its
own normalized form — a `varchar` membership test comes back as
`((col)::text = ANY ((ARRAY[…])::text[]))` — so any structured reading of a live predicate
drifts against the authored text. Equality for a wire-named check is name equality, because the
hash already commits to the predicate; only an exact-named check compares its body, and then
byte-for-byte.
## If your target pack authors checks
Check emission is driven by a duck-typed `renderCheckExpressions` hook on the pack's
`authoring` contributions, resolved the same way `qualifyColumnType` is. It receives one
column's shape (`tableName`, `columnName`, `many`, and `memberValues` — the last present only
for a value set the toolchain owns) and returns `{ kind, columnName, expression }` candidates,
where `kind` is `'membership'` or `'elementNotNull'`. A pack without the hook emits no checks
at all, which is how SQLite keeps its no-CHECK stance. Nothing in the return value is a name:
the contract builder composes the prefix from the table, the column, and the kind, truncates it
to 54 UTF-8 bytes, and appends the content hash.
## Hand-written checks are visible now
Postgres introspection reads `pg_get_expr(c.conbin, c.conrelid)` and stores the predicate
verbatim; it no longer recognises only the two shapes the old parser could parse. Every CHECK
constraint on a managed table therefore reaches the differ, and one the contract does not
declare is an ordinary undeclared extra: reported by `db verify --strict`, and dropped by a plan
whose control policy allows `destructive`.
For an extension this matters in one specific case — a check your extension installs through a
raw-SQL migration step rather than deriving in its contract space. That constraint used to be
invisible and is now drop-eligible against any database the extension manages. Declare it:
`@@check(expression: "…", map: "<its physical name>")` in the contract space adopts the
constraint under the name it already carries, after which it is owned rather than extra and no
plan drops it — see `authored-check-constraints` in this transition. Until you do, keep the
tables carrying it under an additive-only policy: the check survives, plain `db verify`
tolerates it, and only `--strict` reports it.
## `runtime-query-execute-hard-cut`
The runtime SPI separates row streams from statement statistics. Inspect what each caller consumes rather than applying a global `execute` → `query` replacement: selects, returning writes, Mongo command-result plans, and other iterated or decoded results use `query`; non-returning DML uses eager `execute` and returns `{ affectedRows: number }`. Prepared row callers move from `target.queryPrepared(prepared, params, options?)` to `prepared.query(target, params, options?)`.
Connection, transaction, and role-bound scopes preserve their existing bound resource for both operations. Keep row results lazy and fully consume them inside a scope when required; statistics execution is eager. A count terminal must use `stats.affectedRows` from the write and never derive it from a row array.
Middleware hooks are operation-specific: query uses `beforeQuery`, `interceptQuery`, `onRow`, and `afterQuery`; statistics uses `beforeExecute`, `interceptExecute`, and `afterExecute`; `beforeCompile` remains shared. `interceptQuery` returns `{ rows }`, `interceptExecute` returns `{ stats }`, and hook selection carries the operation distinction, so contexts and results have no operation discriminator. Do not add compatibility aliases or generic fallback hooks. Split row and statistics fakes and spies so tests detect an incorrect route. Behavior intended for both operations assigns one private implementation to both corresponding hook names.
The Mongo facade keeps static `db.query`; it has no row-execution method named `query` and no compatibility `db.execute`. Build with `db.query`, obtain the connected runtime, and call `(await db.runtime()).query(plan)`. Leave genuine statistics calls, migration runners, and unrelated APIs named `execute` unchanged.
## `config-file-is-prisma-config-with-an-orm-section`
Two mechanical steps, in either order:
1. `git mv prisma-next.config.ts prisma.config.ts` (same for `.mts` / `.mjs` variants).
2. Wrap the flat export in the engine shape:
```ts
// before
import { defineConfig } from '@prisma/orm-postgres/config';
export default defineConfig({ ... });
// after
import { defineConfig } from '@prisma/cli-engine';
import { defineConfig as ormConfig } from '@prisma/orm-postgres/config';
export default defineConfig({ orm: ormConfig({ ... }) });
```
Add `@prisma/cli-engine` to `devDependencies`. The inner config is unchanged — only the file
name and the outer wrapper move. The loader still discovers the deprecated filename and still
accepts the flat shape, each with a stderr deprecation warning, so nothing breaks mid-rename;
finish both steps to silence the warnings.
## `published-prisma-next-bin-retired`
`prisma-next ...` in a package script resolved through a bin the database facades forwarded
from the toolchain. That chain is gone: the published toolchain is bin-less and exports the
`orm` command family at `@prisma/orm-toolchain/cli` for the unified `prisma` CLI (the
prisma-cli distribution) to mount. Point scripts and CI at the unified CLI, which serves the
same command paths.
## `prepared-statements-split-by-declared-result`
The prepared surface answers what the plan declared. A rows plan still prepares into a
`PreparedStatement` you consume by querying it; a plan built from a statement that reports an
affected-row count prepares into a `PreparedExecution` you consume by executing it, which
resolves `SqlStatementStats` rather than streaming rows. The two handles share no consumption
method, so neither can be read the wrong way round.
Extensions meet this in two places.
**A facade that redeclares `prepare()`** — as the in-tree database facades do — swaps the
declared return type and nothing else:
```ts
// Before
): Promise<PreparedStatement<ParamsFromDeclaration<D, CT>, Row>> {
// After
): Promise<PreparedFor<ParamsFromDeclaration<D, CT>, Row>> {
```
`PreparedFor` comes from `@internal/sql-runtime` and resolves to whichever handle the plan
earns.
**A scope of your own** — a session, a role context, any object you hand to
`prepared.query(...)` — carries the prepared bridges. If it installs `preparedStatementQuery`,
it must now also install `preparedStatementExecute` beside it, routing to the same queryable:
```ts
[preparedStatementExecute]<Params>(
prepared: PreparedExecution<Params>,
params: Params,
options?: RuntimeExecuteOptions,
): Promise<SqlStatementStats> {
return runPreparedExecuteAgainstYourQueryable(prepared, params, yourQueryable, options);
}
```
Both symbols come from `@internal/sql-runtime/internal/prepared-query`. A scope missing the
execute bridge typechecks but throws on the bridge invariant the first time someone executes a
prepared statement against it.
## `prepare-defaults-to-the-contract-codec-map`
```ts
// Before — the index signature on CodecTypesBase widens keyof CT to string
CT extends CodecTypesBase = ExtractCodecTypes<TContract> & CodecTypesBase,
// After
CT extends CodecTypesBase = ExtractCodecTypes<TContract>,
```
Only the default changes. The constraint, `Declaration<CT>`, `ParamsFromDeclaration`, and the
returned prepared handle are all as they were, and what infers at existing call sites is
unchanged — this is about what the caller is offered and what the compiler refuses. With the
intersection in place, `prepare({ id: 'pg/int4' }, ...)` typechecks and then fails at
execution with `RUNTIME.PARAM_REF_MISSING_CODEC`; without it, the wrong id is a compile
error and the right ones complete.
The same narrowing reaches `.returns()` on a contract-bound raw tag through
`@internal/sql-builder`, so a facade that re-exposes that tag inherits it without doing
anything. Tags built for a contract-free lane are deliberately left open.
upgrading/extension/upgrades/8.0.0-rc.10-to-8.0.0-rc.11/instructions.md
---
from: "8.0.0-rc.10"
to: "8.0.0-rc.11"
changes:
- id: engine-pin-moves-to-0-4-0
summary: |
The toolchain now requires `@prisma/cli-engine@0.4.0` (up from 0.3.0). An extension that
pins `@prisma/cli-engine` in its own manifests must move the pin to `0.4.0`. The new engine
adds a `--format markdown` output format to every command and widens its `Format` type to
`"human" | "json" | "markdown"`; nothing else in its public API changed.
detection:
glob: "**/package.json"
contains:
- '"@prisma/cli-engine": "0.3.0"'
- id: contract-space-restamp
summary: |
The emitted `contract.json` / `contract.d.ts` embed the toolchain version, which moves
to 8.0.0-rc.11. Rebuild the extension's contract space (the package's `build:contract-space`
script) once after upgrading so the emitted artifacts match the installed toolchain.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.10"'
---
# 8.0.0-rc.10 → 8.0.0-rc.11 — Extension author upgrade instructions
## `engine-pin-moves-to-0-4-0`
For every `package.json` matched by `detection`, change the `@prisma/cli-engine` version from `0.3.0` to `0.4.0` and reinstall. Code that names the engine's `Format` type must accept the new `"markdown"` member; an exhaustive `switch` over the format now needs a `markdown` branch. Nothing else changes.
## `contract-space-restamp`
For every `contract.json` matched by `detection`, run the extension package's `build:contract-space` script (or its emit command) once after upgrading. The only expected diff is the embedded `version` moving to `8.0.0-rc.11`.
upgrading/extension/upgrades/8.0.0-rc.11-to-8.0.0-rc.12/instructions.md
---
from: "8.0.0-rc.11"
to: "8.0.0-rc.12"
# The Prisma 7 contract source adds `prisma7Schema` and `contract: ContractConfig` to
# `@prisma/orm-postgres/config`, and `diagnostics` to CliStructuredError. Both additive. It also
# moves the Postgres raw default parser out of the family `psl-infer` subpath (entry below). The
# Postgres default reader and the pinned introspection session change what users see, not any
# extension API; the app skill covers them.
# contract.d.ts now orders every collection the way contract.json does; a re-emit reorders, nothing else.
# Prepared include decoder specialization adds no consumer migration; retain existing entries below.
# The postgres/sqlite/mongo defineConfig wrappers accept a glob-shaped
# `contract:` string (e.g. `./prisma/**/*.prisma`) and derive the default
# output from its static prefix directory. Purely additive: every existing
# single-path `contract:` value keeps deriving its output exactly as before.
# Nothing for an extension author to translate.
changes:
- id: define-config-becomes-define-prisma-config
summary: |
Rename the engine config marker import in `prisma.config.ts` from `defineConfig` to
`definePrismaConfig`. Required: `@prisma/cli-engine@0.6.1` no longer exports the deprecated
`defineConfig` alias.
detection:
glob: "**/prisma.config.ts"
contains:
- "import { defineConfig } from '@prisma/cli-engine'"
- id: engine-pin-moves-to-0-6-1
summary: |
The toolchain now peers `@prisma/cli-engine@0.6.1` (up from 0.4.0). An extension package that pins `@prisma/cli-engine` for its tests or tooling must move the pin to `0.6.1`. A config section's `validate` now receives a second `provenance` argument naming the files that declared the section.
detection:
glob: "**/package.json"
contains:
- '"@prisma/cli-engine": "0.4.0"'
- id: psl-model-names-table-verbatim
summary: |
A PSL `model` with no `@@map` now names its table (or Mongo collection) after the model
verbatim: `model UserProfile` maps to `"UserProfile"`, where it used to map to `"userProfile"`.
Run the colocated codemod over every `.prisma` file, including the `contract.prisma` copies
under `migrations/`, so each unmapped model gets `@@map("<current table name>")` and keeps the
table it already has. Emitted contracts, storage hashes, and migration history are unchanged
after the codemod. Planning without it fails with `MIGRATION.TABLE_NAME_CASE_CHANGED`
instead of dropping and recreating the table.
detection:
glob: "**/*.prisma"
regex:
- '\bmodel\s+[A-Za-z_][A-Za-z0-9_]*\s*\{'
script: ./scripts/psl-verbatim-table-names/add-model-map.mjs
- id: dbgenerated-removed-from-psl
summary: |
`@default(dbgenerated("..."))` is removed from PSL. `contract emit` refuses it with
`PSL_UNKNOWN_DEFAULT_FUNCTION`. Write a raw SQL default as the `sql` tagged literal, and a
value the column's data type writes as that literal.
detection:
glob: "**/*.prisma"
contains:
- "dbgenerated("
- id: default-sql-method-deprecated
summary: |
`.defaultSql('...')` on the TypeScript contract builder is deprecated and is removed at
8.0.0. Rewrite each call to `.default(...)` with `now()`, `autoincrement()`, or the `sql`
template tag.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\.defaultSql\('
- id: every-codec-descriptor-names-a-data-type
summary: |
`CodecDescriptor` gained a required `dataType`: the id of the data type the codec represents.
A descriptor without one does not compile, and a data type no component registers is an
assembly error.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\b(CodecDescriptorImpl|PostgresCodecDescriptor|SqliteCodecDescriptor)\b'
- id: a-pack-registers-its-data-types
summary: |
A pack registers its data types through `dataTypes` on its component metadata — a sibling of
`types`, not a member of it — as an array of `dataType(...)` declarations.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bcodecDescriptors:'
- id: casts-replace-accepted-shape-handling
summary: |
A data type declares, in `casts`, which other types' values it takes and how. Casts replace
every per-codec list of accepted shapes and the conversions that went with them.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bliteralTypes\b'
- '\bLiteralTypeDeclaration\b'
- '\bintegerLiteralTypesUpTo\b'
- id: decode-json-takes-only-the-canonical-form
summary: |
`decodeJson` takes its data type's canonical form and nothing else. Remove every coercion a
codec did to accept another shape; the cast runs before the codec sees the value.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bdecodeJson\('
- id: the-authoring-entry-replaces-the-tag-registry-entry
summary: |
PSL support for a data type is an authoring entry under `authoring.dataTypes`, keyed by the
type's id. It replaces the entry a pack used to put in the default-literal tag registry.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bdefaultLiteralTagRegistry\b'
- '\bControlDefaultLiteralTagEntry\b'
- '\bjsonDefaultLiteralTagEntry\b'
- '\bisDefaultLiteralTagLoweringEntry\b'
- id: map-default-takes-data-types
summary: |
`DefaultMappingOptions` carries `dataTypeEntries`, `dataTypes` and `columnDataType` in place
of `literalTypes`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bmapDefault\('
- '\bDefaultMappingOptions\b'
- id: psl-and-numeral-helpers-live-in-relational-core
summary: |
`escapePslString`, `isNumeralText`, `isNonFiniteText` and `numeralText` moved from
`@internal/framework-components/codec` to `@internal/sql-relational-core/ast`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\b(escapePslString|isNumeralText|isNonFiniteText|numeralText)\b'
- id: the-postgres-target-exposes-its-data-types
summary: |
The Postgres target gained a `./data-types` subpath, forwarded by the `@prisma/orm-postgres`
facade as `./target/data-types`. Import the Postgres types from there to declare a cast from
one.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bdataType\('
- id: a-target-adapted-codec-extends-the-template
summary: |
A codec whose data type depends on the target adapting it extends `CodecDescriptorTemplateImpl`
and leaves `dataType` off; the target names the type when it adapts the template.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bCodecDescriptorTemplateImpl\b'
- id: codec-without-params-has-no-params-schema
summary: A codec that takes no params sets `paramsSchema` to `undefined`; `voidParamsSchema` is removed.
detection:
glob: "**/*.ts"
contains:
- "voidParamsSchema"
- id: psl-infer-raw-default-parser-is-target-owned
summary: |
`parseRawDefault` is no longer exported from the `family/psl-infer` subpath; import `parsePostgresDefault` from `@prisma/orm-postgres/target/default-normalizer` instead.
- id: psl-attribute-specs-are-documented
summary: |
`fieldAttribute`, `modelAttribute` and `blockAttribute` require a `documentation` string, and so does every positional and named parameter. A named parameter is now `{ type, documentation }` instead of a bare argument type.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\b(?:fieldAttribute|modelAttribute|blockAttribute)\('
- id: psl-entity-ref-takes-a-selector
summary: |
`entityRef()` takes a selector such as `entityRef({ kind: 'model' })` and returns the resolved declaration instead of a name. Use `identifier()` for a name that is not checked. The attribute context carries the collected `symbols` table.
detection:
glob: "**/*.{ts,mts,cts}"
contains:
- "entityRef()"
- id: psl-parse-takes-a-file-name
summary: |
`parse(source, filename, options?)` requires the file name, and its result carries `sources` in place of `sourceFile`. The interpreter input takes `documents: [document]` in place of `document`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bparse\([^,()]*\)'
- '\bdocument:\s'
- id: emit-requires-deserialize-contract
summary: Pass the contract family's deserializer to emit(); contract.d.ts is now always generated from the canonical contract.json.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'import\s*\{[^}]*\bemit\b[^}]*\}\s*from\s*[^/\w\s]@(?:prisma/orm-toolchain|internal)/emitter[^/\w-]'
- id: expression-codec-on-return-type
summary: Move custom expression wrapper codec metadata to returnType.codec and remove the separate ExpressionImpl codec argument.
- id: shared-preparable-envelope
summary: Type ORM preparation descriptions with the shared compositional Preparable protocol and pass their contained plan to SQL runtime.
- id: params-only-sql-facade-prepare
summary: Replace injected SQL-builder preparation callbacks with params-only callbacks and lexical facade SQL access.
- id: preserve-prepared-reference-nullability
summary: Preserve declaration nullability when constructing or cloning PreparedParamRef AST nodes.
- id: preserve-orm-pagination-expressions
summary: Preserve expression-valued limit and offset when consuming ORM CollectionState.
- id: preserve-grouped-orm-pagination-expressions
summary: Preserve expression-valued limit and offset when consuming ORM GroupPagingState.
- id: postgres-list-element-codecs-receive-raw-strings
summary: |
PostgreSQL list decoding now parses array frames in the target and passes raw string elements to the scalar element codec; custom PostgreSQL codecs used in lists must accept those raw element spellings.
- id: query-operation-types-move-to-the-postgres-target
summary: |
`QueryOperationTypes` moves from `@internal/adapter-postgres/operation-types` to
`@internal/target-postgres/operation-types`. The adapter subpath is gone, with no
compatibility re-export.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- "adapter-postgres/operation-types"
- "orm-postgres/adapter/operation-types"
- id: native-enum-codec-is-not-textual
summary: |
The native enum codec `pg/enum@1` no longer declares the `textual` trait. `pgEnumDescriptor`
and `PgEnumCodec` declare `['equality', 'order']`, and `CodecTypes['pg/enum@1']['traits']` is
`'equality' | 'order'`.
- id: sql-lowering-spec-drops-strategy
summary: |
`SqlLoweringSpec` from `@internal/sql-operations` no longer has a `strategy` field.
Remove `strategy: 'infix'` and `strategy: 'function'` from every operation descriptor's
`lowering` object; `template` alone describes the lowering.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- "strategy: 'infix'"
- "strategy: 'function'"
- "strategy: \"infix\""
- "strategy: \"function\""
- id: share-create-default-cache-across-inserts
summary: Share query-stable mutation defaults across every insert in one logical create operation.
- id: supabase-contract-declares-nullable-list-columns
summary: The Supabase extension contract now declares storage.buckets.allowed_mime_types and storage.objects.path_tokens as nullable lists, so its storage hash changes; re-sign databases that were signed against the previous Supabase contract.
detection:
glob: "**/package.json"
contains:
- '"@prisma/orm-extension-supabase"'
- id: supabase-contract-regenerated-from-the-reference-fixture
summary: The Supabase extension contract is regenerated and now declares the reference build's 43 check constraints, six native-enum defaults as member literals, four jsonb defaults as JSON literals, and an element-not-null waiver on two more list columns, so its storage hash changes; re-sign databases that were signed against the previous Supabase contract, and check your own Supabase build declares the same constraints.
detection:
glob: "**/package.json"
contains:
- '"@prisma/orm-extension-supabase"'
- id: contract-space-restamp
summary: |
The emitted `contract.json` / `contract.d.ts` embed the toolchain version, which moves
to 8.0.0-rc.12. Rebuild the extension's contract space (the package's `build:contract-space`
script) once after upgrading so the emitted artifacts match the installed toolchain.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.11"'
---
# 8.0.0-rc.11 → 8.0.0-rc.12 — Extension author upgrade instructions
## `define-config-becomes-define-prisma-config`
For every `prisma.config.ts` matched by `detection`, rename the engine import and its call site from `defineConfig` to `definePrismaConfig`:
```ts
// before
import { defineConfig } from '@prisma/cli-engine';
export default defineConfig({ ... });
// after
import { definePrismaConfig } from '@prisma/cli-engine';
export default definePrismaConfig({ ... });
```
`definePrismaConfig` has been the engine's name for the marker since `@prisma/cli-engine@0.2.0`. `defineConfig` was a deprecated alias, and `@prisma/cli-engine@0.6.1` removes it, so this rename is required before the engine pin moves (`engine-pin-moves-to-0-6-1`, next). Leave any `defineConfig` imported from a product package (for example `@prisma/orm-postgres/config`) untouched — those helpers keep their name. The engine function's behaviour is identical; only the name changes.
## `engine-pin-moves-to-0-6-1`
For every `package.json` matched by `detection`, change the `@prisma/cli-engine` version from `0.4.0` to `0.6.1` and reinstall. The engine no longer exports the deprecated `defineConfig` alias; `define-config-becomes-define-prisma-config` (the previous entry) covers the rename.
Code that calls a config section's `validate` directly, for example in a test, must pass the provenance the engine supplies as the second argument:
```ts
// before
section.validate(raw);
// after
section.validate(raw, { files: [configPath], keys: { contract: configPath } });
```
`files` lists the config files that declared the section, nearest first. `keys` maps each top-level key of the section to the file that wrote it.
## `psl-model-names-table-verbatim`
A PSL `model` with no `@@map` used to name its table, or its Mongo collection, after the model with the first letter lowered: `model UserProfile` read and wrote `"userProfile"`. It now uses the model name verbatim, `"UserProfile"`, the same rule every other Prisma 8 authoring surface already followed. Every model without `@@map` therefore points at a table that does not exist yet, so the schema must say which table it means.
From the extension package root, run the codemod that sits next to this guide once over every schema file, including the contract-space `contract.prisma` and the copy inside each migration directory. `<skill>` is the directory of the synced `prisma-8` skill. The script exits with an error if no file matches:
```bash
node <skill>/upgrading/extension/upgrades/8.0.0-rc.11-to-8.0.0-rc.12/scripts/psl-verbatim-table-names/add-model-map.mjs '**/*.prisma'
```
It adds `@@map("<model name with its first letter lowered>")` as the last line of every `model` block that has no `@@map`, keeps the file's indentation and line endings, leaves models that already have `@@map` alone, and leaves a variant with `@@base(...)` and no `@@map` alone because it shares its base's table. It never descends into `node_modules` or `dist`, prints every model it mapped as `<file>: model <Name> -> @@map("<name>")`, and is idempotent. If a `model` block is written in a shape it cannot read it prints `<file>:<line>: model block not understood` and exits 1; add the `@@map` to that block by hand.
The codemod cannot see storage. It is for schemas written against the previous release only, where every unmapped model's table was created with its first letter lowered. Run it once, before you re-run `contract infer`, and never on a schema that was inferred or written after upgrading: such a schema already names its tables verbatim, and the codemod would point each unmapped model at a lowercase table that does not exist. Read the printed list and remove the `@@map` from any model whose table already has the verbatim name.
Then run the package's contract-space build (`build:contract-space`, or `prisma contract emit` for the package) and check that the emitted `contract.json` did not change. An unchanged `contract.json` proves the codemod was run on the right schema: if it changed, the schema was already verbatim, so revert the codemod's edits. Storage hashes, migration history, and refs are unchanged after a correct run, so applications composing the extension see no change.
In this upgrade, other entries in this guide also change the emitted `contract.json` (the `version` stamp, and the defaults some later entries name). Emit also fails while the schema still holds a form this release refuses, such as `dbgenerated(...)`, until a later entry rewrites it. In either case, run this check after those entries, and compare the table names in `contract.json` rather than the whole file.
If you plan a migration (`prisma migration plan`, `prisma db update`, `prisma migrate`) without running the codemod, planning fails instead of dropping the table:
```text
✘ [MIGRATION.PLANNING_FAILED] Migration planning failed
why: MIGRATION.TABLE_NAME_CASE_CHANGED: table "UserProfile" would be created and table "userProfile" dropped. Prisma 8 changed the default table name: a model with no @@map now names its table verbatim, so model UserProfile points at "UserProfile" instead of "userProfile".
→ To keep table "userProfile" and its rows, add @@map("userProfile") to model UserProfile (or run the add-model-map codemod over the schema) and plan again. Prisma 8 has no rename-table operation, so a deliberate rename is done by hand: run ALTER TABLE "userProfile" RENAME TO "UserProfile" (schema-qualified where applicable), after which the plan is empty.
```
The conflict fires for each pair where the table to drop equals the table to create with its first letter lowered, in the same namespace, whatever the columns. It does not fire on an empty database or on tables the contract's control policy marks `external` or `observed`. Mongo has no planner and gives no error: an unmapped model silently reads and writes an empty `UserProfile` collection while the documents stay in `userProfile`, so run the codemod before deploying.
To adopt the verbatim names on purpose instead of mapping, rename the storage by hand and skip the codemod for those models. Postgres and SQLite: `ALTER TABLE "userProfile" RENAME TO "UserProfile"` (`ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"` inside a schema). Mongo: `db.userProfile.renameCollection("UserProfile")`. After the rename the migration plan is empty and `db verify` is clean.
`contract infer` follows the same rule: a table whose name already equals the model name (`"UserProfile"`, `"User"`) infers to a model with no `@@map` and verifies clean, where the previous output pointed the model at a lowercase table that did not exist. A snake_case table still infers with `@@map("user_profile")`. There is nothing to detect for this: the inferred text for such a table is the same as before, it is now correct.
## `dbgenerated-removed-from-psl`
`@default(dbgenerated("<expression>"))` no longer parses. Every use is reported at its span as `PSL_UNKNOWN_DEFAULT_FUNCTION` with the message `` Default function "dbgenerated" was removed. Write the SQL as a tagged literal: @default(sql`<expression>`). Supported functions: ... ``. `prisma contract infer` no longer prints it either: a raw expression prints as a `sql` tagged literal, and a value the column's data type writes prints as that literal.
In PSL, a raw SQL column default is written as a tagged literal, ``@default(sql`...`)`` or `@default(sql"...")`. Rewrite each use by what the expression is:
| You wrote | Write instead |
| --- | --- |
| `@default(dbgenerated("gen_random_uuid()"))` | `` @default(sql`gen_random_uuid()`) `` |
| `@default(dbgenerated("now()"))`, `@default(dbgenerated("CURRENT_TIMESTAMP"))` on Postgres | `@default(now())` |
| `@default(dbgenerated("autoincrement()"))`, `@default(dbgenerated("nextval('<seq>'::regclass)"))` on a serial column | `@default(autoincrement())` |
| `@default(dbgenerated("'<json>'::jsonb"))` on a `Json` or `Jsonb` column | `` @default(json`<json>`) `` |
| `@default(dbgenerated("'<member>'::<enum type>"))` on a column typed by that enum | `@default("<member>")` |
| `@default(dbgenerated("'<text>'::text"))` on a text column | `@default("<text>")` |
| `@default(dbgenerated("<anything else>"))` | `` @default(sql`<anything else>`) `` |
The `now()` and `autoincrement()` rows are required, not a matter of style: `` sql`now()` `` and `` sql`autoincrement()` `` are refused with `PSL_INVALID_DEFAULT_SQL`, because Prisma reads those two expressions as its own default functions. Every other expression, including `NOW()` written in capitals, is used exactly as written.
The mechanical rewrite for the last row is `@default(sql"<expression>")` with the argument text copied unchanged: the double-quote fence uses the same escapes as the `dbgenerated("...")` argument, so the contract cannot change. The backtick fence reads better for SQL; to use it, undo the quoted string's escaping (`\"` becomes `"`, `\n` becomes a line break), then write each backtick in the body as `` \` ``. Inside backticks every other backslash is kept as written, `\$` included, because PSL has no escape for `$`, and `${` is ordinary text. A body that contains a backtick is easiest to keep in the double-quote fence.
The JSON and enum rows change the emitted contract: the default becomes `{ kind: 'literal', value }` instead of `{ kind: 'function', expression }`, so the storage hash moves. Run `prisma contract emit`, then `prisma db verify`: the literal compares equal to the live default, so verify passes and no migration is needed. Every other row emits the same contract as before; the storage hash does not move.
Find the uses with `grep -rn "dbgenerated(" prisma/` (or wherever the schema lives).
### For a pack that ships a contract
A pack's committed `contract.prisma` (for example a reference contract regenerated from a live database with `prisma contract infer`) carries the same rewrites. Regenerate it with the pack's own generation script, or apply the table above by hand, then re-emit `contract.json` and `contract.d.ts` with `prisma contract emit`. Only the JSON and enum rows change the emitted contract and move the storage hash; the pack's verify test against its reference database still passes, because the literal compares equal to the live default.
## `default-sql-method-deprecated`
Rewrite every `.defaultSql('<expression>')` call by its expression:
| Call | Replacement | Import |
| --- | --- | --- |
| `.defaultSql('now()')` | `.default(now())` | `now` from `@internal/sql-contract-ts/contract-builder` |
| `.defaultSql('autoincrement()')` | `.default(autoincrement())` | `autoincrement` from `@internal/sql-contract-ts/contract-builder` |
| `.defaultSql('gen_random_uuid()')` | `` .default(sql`gen_random_uuid()`) `` | `sql` from `@internal/sql-contract-ts/contract-builder` |
| `.defaultSql('<anything else>')` | `` .default(sql`<anything else>`) `` | `sql` from `@internal/sql-contract-ts/contract-builder` |
There is no named helper for other database functions, so they use the `sql` tag, as `gen_random_uuid()` does above. `` sql`now()` `` and `` sql`autoincrement()` `` are refused in the TypeScript `sql` tag as they are in PSL, so those two must use the named form.
Copy the expression's value, not its source string: first undo the TypeScript string's own escaping, so `.defaultSql('it\'s')` contributes `it's`. Then write each backtick as `` \` ``. Write a backslash that precedes a dollar sign as `\\$`, because the tag reads `\$` as the escape for `$`. Every other backslash can be written as it is, or doubled; both give one backslash. An expression that contains `${` is written `\${` inside the `sql` tag, which resolves it back to the two characters; in PSL it is written as it is.
Every form lowers to the same `{ kind: 'function', expression }` default, so emitted contracts do not change.
Find the uses with `grep -rn "defaultSql(" src/` (or wherever the contract is defined).
## `every-codec-descriptor-names-a-data-type`
A **data type** is a stored type made first-class: `pg/int8`, `pg/jsonb`, `postgis/geometry`. Its id is `owner/name` and carries no version, because a type's identity does not change. A **codec** is one representation of a data type, and a codec id carries a version (`pg/int8@1`), so one string never names both.
Every descriptor names the type it represents:
```ts
import { pgvectorVector } from './data-types';
export class PgVectorDescriptor extends PostgresCodecDescriptor<VectorParams> {
override readonly dataType = pgvectorVector.id;
override readonly codecId = VECTOR_CODEC_ID;
override readonly traits = ['equality'] as const;
override readonly targetTypes = ['vector'] as const;
override readonly paramsSchema: StandardSchemaV1<VectorParams> = vectorParamsSchema;
override factory(params: VectorParams): (ctx: CodecInstanceContext) => PgVectorCodec {
return () => new PgVectorCodec(this, params.length);
}
}
```
`dataType` is `abstract readonly dataType: DataTypeId` on `CodecDescriptorImpl`, so a descriptor that leaves it off does not compile. `DataTypeId` is a branded string: the only way to make one is `dataTypeId('owner/name')`, which `dataType()` calls for you, so reference the declaration's `.id` rather than writing the string again.
Several codecs may represent one type. `pg/int8@1` and `pg/int8number@1` both name `pg/int8`; they differ in the in-memory value they produce, and both store the type's one canonical form.
Name the target's type whenever your codec stores what one of the target's columns stores. A codec that keeps a JSON document in a `jsonb` column and validates it against a schema names `pg/jsonb` and registers nothing: `pg/jsonb` already says what the column holds and what it takes, and the schema check is the codec's, at the point the value is read. Register a type of your own only for a database type no pack describes yet, as pgvector does for `vector`.
Assembly checks the ids across packs. A codec naming a type nobody registers fails with `CONTRACT.DATA_TYPE_UNREGISTERED`, naming your component and the id.
## `a-pack-registers-its-data-types`
A pack that introduces a database type of its own declares each type with `dataType(id, spec)` and lists them on the component metadata. A pack whose codecs all represent types the target registers declares none, and has no `dataTypes` at all.
```ts
// data-types.ts
import { type DataType, dataType } from '@internal/framework-components/codec';
import { pgText } from '@internal/target-postgres/data-types';
export const postgisGeometry: DataType = dataType('postgis/geometry', {
casts: { [pgText.id]: (value) => value },
});
export const postgisDataTypes: readonly DataType[] = [postgisGeometry];
```
```ts
// descriptor-meta.ts
const postgisPackMetaBase = {
kind: 'extension',
id: 'postgis',
// …
dataTypes: postgisDataTypes,
types: {
codecTypes: { codecDescriptors: Array.from(postgisCodecRegistry.values()), /* … */ },
},
};
```
`dataTypes` sits beside `types`, not inside it: `types` is copied into an extension's contract space, and a cast is a function, which no contract holds.
Two components registering one id fail assembly with `CONTRACT.DATA_TYPE_DUPLICATE`, naming both.
## `casts-replace-accepted-shape-handling`
A **cast** is a pure function from another type's canonical form into this type's. Casts are declared by the type that receives, never by the source, so there is at most one for any pair and a type's owner is the only one who decides what it takes. A written value is admitted when its type is the column's type or the column's type casts from it; the cast runs before the codec sees anything.
This replaces the per-codec list of accepted shapes. Delete `literalTypes` from every descriptor, delete any import of `LiteralTypeDeclaration` or `integerLiteralTypesUpTo`, and move each conversion into the receiving type's cast:
```ts
const asNumeralText: Cast = (value) =>
typeof value === 'number' ? numeralText(value) : wrongShape(value, 'a number');
export const pgInt8: DataType = dataType('pg/int8', {
casts: { [pgInt2.id]: asNumeralText, [pgInt4.id]: asNumeralText },
});
```
Declaring no cast is a decision, not an omission. `pg/int4` declares none from `pg/int8`, so a number too wide for the column is refused before anything is decoded:
```text
Field "N.count": pg/int4 has no cast from pg/int8; it casts from pg/int2
```
A cast may refuse the value it is handed, with a structured error carrying `why` and `fix`; the refusal surfaces as `PSL_INVALID_DEFAULT_LITERAL` at the written value.
There is no list data type. A type whose single value holds several elements declares a `listCast` instead: `of` is the set of types an element may be, and `cast` receives the elements' canonical forms in written order. This is how a vector column takes `` @default([0.1, 0.2, 0.3]) ``:
```ts
export const pgvectorVector: DataType = dataType('pgvector/vector', {
listCast: {
of: [pgInt2.id, pgInt4.id, pgInt8.id, pgNumeric.id],
cast: (elements) => elements.map(elementNumber),
},
});
```
Assembly refuses a cast whose source type no contract source can write, with `CONTRACT.DATA_TYPE_NOT_WRITABLE`: such a cast could never be exercised.
## `decode-json-takes-only-the-canonical-form`
A data type names one **canonical form**: the single JSON shape `contract.json` stores for its values. `pg/int8` stores digit text, `pg/int4` a JSON number, `pg/jsonb` the document. Every codec of a type stores and reads exactly that form.
So `decodeJson` takes that form and nothing else, and `encodeJson` produces it. Remove every branch a codec had for a shape it does not itself write — the cast has already produced the canonical form by the time the codec is called:
```diff
decodeJson(json: JsonValue): number {
- if (typeof json === 'number') return decodeInt8(json);
if (typeof json !== 'string') {
- throw myError('RUNTIME.DECODE_FAILED', 'value must be decimal text or a whole number');
+ throw myError('RUNTIME.DECODE_FAILED', 'database JSON value must be decimal text');
}
return decodeInt8(json);
}
```
Where two codecs of one type previously stored different shapes, they now share the type's form. `pg/int8number@1` and `sqlite/bigintnumber@1` store digit text like their `bigint`-valued siblings, and refuse text past 2^53 as a limit of their own representation.
A codec still validates what the column's parameters constrain, on the canonical form: `vector(3)` refuses four elements, `numeric(10,2)` refuses a third decimal place. A refusal is reported as `PSL_INVALID_DEFAULT_LITERAL` carrying the codec's own message, and `contract infer` calls the codec on what it is about to print, falling back to the raw expression when it throws.
## `the-authoring-entry-replaces-the-tag-registry-entry`
PSL support for a data type is an **authoring entry**, contributed by the pack that owns the type under `authoring.dataTypes` and keyed by the type's id. It replaces the entry a pack used to register in the default-literal tag registry.
An entry has a **written form**, a `print` that is the reverse of reading it, and `documentation` the language server shows:
```ts
export function postgresDataTypeEntries(): Readonly<Record<string, AuthoringDataTypeEntry>> {
return {
[pgText.id]: {
written: { kind: 'plain', syntax: 'string', parse: (text) => text },
print: (value) => String(value),
documentation: 'Text.',
},
[pgJson.id]: {
written: { kind: 'tag', tag: 'json', parse: parseJsonBody },
print: printJsonBody,
documentation: 'Reads the body as a JSON document and stores it as the default value.',
},
};
}
```
There are four written forms:
- `{ kind: 'tag', tag, parse }` — a qualified name followed by a body in any of PSL's quote styles. A target may register an unprefixed tag; every other pack prefixes, as `postgis.geometry` does.
- `{ kind: 'plain', syntax: 'string', parse }` and `{ kind: 'plain', syntax: 'boolean', parse }` — a quoted string, and `true`/`false`.
- `{ kind: 'plain', syntax: 'number', types, classify }` — a written number. This is the one form that yields several types, so instead of `parse` it carries a classifier that picks the type from the digits and returns the canonical form with it, plus `types`, every type the classifier can return. Naming `types` is how assembly knows those types can be written.
```ts
const classifyPostgresNumber = createNumberClassifier({
integers: [
{ type: pgInt2.id, form: 'number', ...signedRange(16) },
{ type: pgInt4.id, form: 'number', ...signedRange(32) },
{ type: pgInt8.id, form: 'text', ...signedRange(64) },
],
largerWhole: { type: pgNumeric.id, form: 'text' },
fraction: { type: pgNumeric.id, form: 'text' },
words: { type: pgNumeric.id, form: 'text' },
});
```
`createNumberClassifier`, `signedRange`, `parseJsonBody` and `printJsonBody` come from `@internal/sql-relational-core/ast`, so targets share one digit classifier and one JSON reader.
One tag names no data type: `sql` takes an expression in the database's language and lowers its own body. A **lowering entry** sits in the same map under a reserved key, because it has no type id to be keyed by:
```ts
export function createPostgresDataTypeEntries(): Readonly<Record<string, AuthoringDataTypeEntry>> {
return {
...postgresDataTypeEntries(),
[loweringEntryKey('sql')]: sqlDefaultLiteralTagEntry('sql'),
[loweringEntryKey('pg.sql')]: sqlDefaultLiteralTagEntry('pg.sql'),
};
}
```
`loweringEntryKey`, `isLoweringEntryKey` and `isDataTypeLoweringEntry` are exported from `@internal/framework-components/authoring`; `isDataTypeLoweringEntry` is the only place the discriminating key is named, so narrow with it before reaching for `lower`.
These surfaces are gone, with no replacement beyond the above: `ControlMutationDefaults.defaultLiteralTagRegistry`, the `ControlDefaultLiteralTagEntry` and `ControlDefaultLiteralTagRegistry` types, `literalTypes` on codec descriptors, and the framework's literal-types exports (`LiteralTypeName`, `LiteralTypeDeclaration`, `integerLiteralTypesUpTo`, `jsonDefaultLiteralTagEntry`, `isDefaultLiteralTagLoweringEntry`).
Assembly refuses two entries claiming one tag or one plain form with `CONTRACT.DATA_TYPE_WRITTEN_FORM_DUPLICATE`, and an entry keyed by an unregistered id with `CONTRACT.DATA_TYPE_UNREGISTERED`.
## `map-default-takes-data-types`
`mapDefault` (`@internal/family-sql/psl-infer`) classifies the stored value with the same rules a written value uses, confirms the column's type takes it, prints it with the classified type's authoring entry, and reads the text straight back. `DefaultMappingOptions` lost `literalTypes` and gained:
- `dataTypeEntries` — the stack's authoring entries, keyed by data type id;
- `dataTypes` — a `DataTypeLookup` over the stack's types, whose casts say what each one takes;
- `columnDataType` — the data type of this column's codec;
- `list` — whether the column is a list, whose elements each carry the column's own type. A written list on a column that is not a list goes through that type's `listCast` instead.
A target builds the first two once:
```ts
export function createPostgresDefaultMapping(): DefaultMappingOptions {
return {
dataTypeEntries: postgresDataTypeEntries(),
dataTypes: createDataTypeLookup(postgresDataTypes),
};
}
```
and adds the per-column half at each call:
```ts
const result = mapDefault(columnDefault, {
...defaultMapping,
...ifDefined('columnDataType', dataTypeForPrintedType(resolution.pslType.name, isEnumColumn)),
list: column.many === true,
});
```
A value no entry writes, or one that does not read back as the stored value, makes `mapDefault` return `undefined`, which is the signal to fall back to the raw database default: map it again as a function default, which prints as a named function or as `` @default(sql`<expression>`) ``. `DefaultMappingResult` is now `{ attribute }` only; the `{ comment }` result and the `fallbackFunctionAttribute` option are removed along with `dbgenerated`. `formatLiteralValue` and the per-PSL-type formatter table a target printer used to supply (`PslDefaultValueFormat`, `formatPslValue`, `formatPslListLiteralValue`) are gone; delete them.
## `psl-and-numeral-helpers-live-in-relational-core`
Four helpers moved out of `@internal/framework-components/codec`, because they are SQL-family text handling rather than framework surface:
| Helper | What it does | Now imported from |
| --- | --- | --- |
| `escapePslString` | Escapes a string for a PSL double-quoted literal | `@internal/sql-relational-core/ast` |
| `isNumeralText` | Whether text is a number written out | `@internal/sql-relational-core/ast` |
| `isNonFiniteText` | Whether text is `NaN`, `Infinity` or `-Infinity` | `@internal/sql-relational-core/ast` |
| `numeralText` | A JS number as digit text, with no exponent | `@internal/sql-relational-core/ast` |
```diff
-import { escapePslString, numeralText } from '@internal/framework-components/codec';
+import { escapePslString, numeralText } from '@internal/sql-relational-core/ast';
```
Use them rather than a local regex, so a printed value and the reader that parses it back cannot drift.
## `the-postgres-target-exposes-its-data-types`
Declaring a cast means naming the source type by its declaration, so the Postgres target now exports its types and its authoring entries from a `./data-types` subpath:
```ts
import { pgInt2, pgInt4, pgInt8, pgNumeric, pgText } from '@internal/target-postgres/data-types';
```
The `@prisma/orm-postgres` facade forwards it as `@prisma/orm-postgres/target/data-types`, which is the import an out-of-repo extension uses.
The subpath carries every `pg/*` type (`pgText`, `pgBool`, `pgInt2`, `pgInt4`, `pgInt8`, `pgNumeric`, `pgFloat4`, `pgFloat8`, `pgJson`, `pgJsonb`, the text-backed types, and the temporal ones), the `postgresDataTypes` array, and `postgresDataTypeEntries()`.
A cast whose source belongs to another pack only makes sense when that pack is in the stack, which is why assembly, not the extension, checks it: `pgvector/vector` casting from `pg/numeric` is valid only when the Postgres target is composed in.
## `a-target-adapted-codec-extends-the-template`
A codec shared by several targets cannot name its data type itself, because the type differs per target. Such a descriptor extends `CodecDescriptorTemplateImpl`, which has every descriptor field except `dataType`:
```ts
export class SqlTextDescriptor extends CodecDescriptorTemplateImpl<void> {
override readonly codecId = SQL_TEXT_CODEC_ID;
override readonly traits = ['equality', 'order', 'textual'] as const;
override readonly targetTypes = ['text'] as const;
override readonly paramsSchema = undefined;
override factory(): (ctx: CodecInstanceContext) => SqlTextCodec {
return () => new SqlTextCodec(this);
}
}
```
The target names the type when it adapts the template:
```ts
export const postgresSqlTextDescriptor = postgresCodec(sqlTextDescriptor, {
dataType: pgText.id,
nativeType: () => 'text',
jsonProjection: identityJsonProjection,
});
```
`dataType` is required in the adapter's options, so a target cannot adapt a template without deciding which of its types the codec represents. Every other descriptor — one written for a single target — extends `CodecDescriptorImpl` (or a target's subclass of it, such as `PostgresCodecDescriptor`) and declares `dataType` directly.
## `codec-without-params-has-no-params-schema`
`voidParamsSchema` is no longer exported from `@internal/framework-components/codec`. A codec descriptor that takes no params (`P = void`) sets `paramsSchema` to `undefined`, and `isParameterized` is `true` exactly when a descriptor has a `paramsSchema`. A codec without params still rejects any `typeParams` with `RUNTIME.TYPE_PARAMS_INVALID`.
In every file matched by `detection`:
```ts
// before
import { CodecDescriptorImpl, voidParamsSchema } from '@internal/framework-components/codec';
import type { StandardSchemaV1 } from '@standard-schema/spec';
class MyTextDescriptor extends CodecDescriptorImpl<void> {
override readonly paramsSchema: StandardSchemaV1<void> = voidParamsSchema;
}
// after
import { CodecDescriptorImpl } from '@internal/framework-components/codec';
class MyTextDescriptor extends CodecDescriptorImpl<void> {
override readonly paramsSchema = undefined;
}
```
- Remove `voidParamsSchema` from the import. Remove the `StandardSchemaV1` import too if nothing else in the file uses it.
- In a descriptor written as a plain object, replace `paramsSchema: voidParamsSchema` with `paramsSchema: undefined`.
- Code that reads `descriptor.paramsSchema` now sees `StandardSchemaV1<P> | undefined`. Check `descriptor.paramsSchema !== undefined` before calling its `validate`.
Parameterized codecs are unchanged.
## `psl-infer-raw-default-parser-is-target-owned`
Find imports of `parseRawDefault` from `@prisma/orm-family-sql/family/psl-infer`, `@prisma/orm-postgres/family/psl-infer`, or `@prisma/orm-sqlite/family/psl-infer`. The function read Postgres default spellings, so it now lives in the Postgres target. Replace each import with `parsePostgresDefault` from `@prisma/orm-postgres/target/default-normalizer`. The signature is unchanged: `(rawDefault: string, nativeType?: string) => ColumnDefault | undefined`. It reads every spelling the old function read, plus the negative and cast numerals, cross-schema enum literals, zoneless `timestamp` literals, and `ARRAY[...]` lists added in this release, so a value that was `undefined` before may now be a literal.
The `parseRawDefault` option on `PslPrinterOptions` is unchanged; pass `parsePostgresDefault` there where you passed the old function.
## `psl-attribute-specs-are-documented`
The language server now shows signature help for every PSL attribute, built from the attribute's own declaration. For every file matched by `detection`, give each attribute spec a `documentation` string, give each positional parameter one, and wrap each named parameter's type in `{ type, documentation }`:
```ts
// before
const mapAttribute = blockAttribute('map', {
positional: [{ key: 'name', type: str() }],
named: { schema: optional(str()) },
});
// after
const mapAttribute = blockAttribute('map', {
documentation: 'Maps this block to its database name.',
positional: [{ key: 'name', type: str(), documentation: 'The database name.' }],
named: { schema: { type: optional(str()), documentation: 'The schema that holds it.' } },
});
```
An attribute with no parameters still needs its own `documentation`, for example `modelAttribute('rls', { documentation: '...' })`. The documentation is editor help only; parsing, validation and the emitted contract do not change.
## `psl-entity-ref-takes-a-selector`
`entityRef()` now checks the reference against the schema's declarations. For every file matched by `detection`, pass the kind of declaration the argument must name, and read the resolved declaration from the result instead of a string:
```ts
// before
{ key: 'base', type: entityRef(), documentation: 'The base model to inherit from.' }
// parsed.base is the name as written
// after
{ key: 'base', type: entityRef({ kind: 'model' }), documentation: 'The base model to inherit from.' }
// parsed.base is { declaration, namespace }; the name is parsed.base.declaration.name
```
A reference to another block kind uses `entityRef({ kind: 'block', keyword: '<keyword>' })`. An argument that names something the schema does not declare, and that must not be checked, uses `identifier()` instead. A pinned literal name stays `identifier('<name>', { documentation })`. Code that builds an attribute context itself must now pass the collected symbol table as `symbols`, next to `sources`.
## `psl-parse-takes-a-file-name`
A schema can now span several files, so every parse result records which file it came from. For every file matched by `detection`:
```ts
// before
const { document, sourceFile, diagnostics } = parse(source);
interpret({ document, ... });
// after
const { document, sources, diagnostics } = parse(source, 'schema.prisma');
interpret({ documents: [document], ... });
```
Pass the real path when there is one; a synthetic name such as `'<inline>.prisma'` is fine for source that has no file. `sources` maps each syntax root to its file and converts offsets to line and column, which `sourceFile` used to do for the single file.
## `emit-requires-deserialize-contract`
Find calls to `emit(contract, stack, emission, options)` imported from `@prisma/orm-toolchain/emitter`. Add a `deserializeContract` option that reads the canonical JSON object back into a contract through the family instance of the contract being emitted:
```ts
const familyInstance = family.create(stack);
const result = await emit(contract, stack, family.emission, {
serializeContract: (c) => target.contractSerializer.serializeContract(c),
deserializeContract: (json) => familyInstance.deserializeContract(json),
});
```
`emit()` generates `contract.d.ts` from that re-read contract, so models, fields and relations appear in the order `contract.json` lists them. Use the family's own deserializer rather than an identity function: the family builds the objects the declaration generator reads. Direct calls to `generateContractDts` are unchanged.
## `expression-codec-on-return-type`
For custom SQL `Expression` wrappers, move an existing top-level `codec` reference into `returnType.codec`, preserving the complete reference including `typeParams`. Read metadata through `codecOf(expression)` or `expression.returnType.codec`, not `expression.codec`. Keep the existing `returnType.codecId` and nullability; wrappers without an explicit reference continue to use the declared codec id fallback. Do not remove or relocate unrelated codec fields on AST nodes, storage declarations, runtime bindings or scope descriptors.
For direct `ExpressionImpl` construction, change `new ExpressionImpl(ast, returnType, codec, projectionAst)` to `new ExpressionImpl(ast, { ...returnType, codec }, projectionAst)`. When the removed codec argument was `undefined`, keep `returnType` unchanged and move any fourth projection argument to the third position. Preserve projection-only lowering separately from predicate and ordering ASTs.
## `shared-preparable-envelope`
Replace imports of SQL ORM client's `RowQuery` with `Preparable` from `@internal/sql-relational-core/plan`. Supply both type arguments as `Preparable<DbRow, Result>` and return `{ plan, consume }`, where `plan` is a `SqlQueryPlan<DbRow>` and `consume` returns the complete ORM result. Read AST, parameters and metadata through `description.plan`; pass `description.plan` rather than the description to SQL runtime preparation. Keep the consumer's mapping setup outside invocation-time code.
For integrations accepting both SQL and ORM callbacks, constrain the callback result with `Q extends SqlQueryPlan | Preparable<unknown, unknown>` and use SQL ORM client's `prepareQuery` and `PreparedFrom<Params, Q>`. Preserve the concrete `Q` so SQL row/statistics types and ORM all/first result types remain distinct. Do not add identity consumers to plain SQL plans.
## `params-only-sql-facade-prepare`
Find calls to `prepare(declaration, callback)` on clients created by the Postgres or SQLite facade (`@prisma/orm-postgres/runtime`, `@prisma/orm-sqlite/runtime`, or their `@internal/postgres/runtime` and `@internal/sqlite/runtime` counterparts). Resolve the receiver and callback rather than rewriting every method named `prepare`: native SQLite `database.prepare(sql)` and SQL runtime's existing params-only preparation are different APIs and must remain unchanged.
Change callbacks from `(sql, params) => ...` to `(params) => ...`. Replace references bound to the removed `sql` callback argument with the same facade receiver's lexical `.sql` property. Preserve the params argument's name, declaration, SQL chain, row selection, filters and invocation target/options. For extracted callbacks, capture the same client in the enclosing scope; do not capture an invocation target or evaluate the callback twice. Update explicit callback type annotations to accept only the placeholder-params argument.
```ts
// Before
const query = await db.prepare({ id: 'pg/int4@1' }, (sql, params) =>
sql.public.users.select('id').where((f, fns) => fns.eq(f.id, params.id)).build(),
);
// After
const query = await db.prepare({ id: 'pg/int4@1' }, (params) =>
db.sql.public.users.select('id').where((f, fns) => fns.eq(f.id, params.id)).build(),
);
```
Apply the same translation to SQLite's flat SQL facade (`sql.users` becomes `db.sql.users`), retaining its existing codec ids. Keep `.query(target, params, options?)` and SQL statistics `.execute(target, params, options?)` calls unchanged. Do not rewrite historical release notes, applied upgrade recipes, generated contracts or tests as part of this source translation.
## `preserve-prepared-reference-nullability`
Find code that constructs or clones `PreparedParamRef` from SQL relational-core's AST exports. When constructing a reference from a nullable declaration, pass its declared boolean nullability as the third argument to `PreparedParamRef.of(name, codec, nullable)` or `new PreparedParamRef(name, codec, nullable)`. When cloning an existing reference, preserve `ref.nullable`: `PreparedParamRef.of(ref.name, ref.codec, ref.nullable)`. Keep the name and complete codec reference unchanged, and keep constructing frozen class instances rather than spreading nodes into plain objects. Do not derive this flag from a column's nullability or an invocation's bound value.
## `preserve-orm-pagination-expressions`
Update extension code that reads or mirrors ORM `CollectionState.limit` and `offset`: these fields now contain relational-core `LimitOffsetValue | undefined` (`number | AnyExpression | undefined`), not just numbers. Forward them unchanged to the existing `SelectAst.withLimit` and `withOffset` methods. If processing numeric literals separately, narrow with `typeof value === 'number'`; preserve expression nodes rather than coercing, serializing or boxing them as literal parameters. Test presence against `undefined`, not truthiness, so zero limits and offsets survive. Keep grouped post-aggregation paging's separate numeric state unchanged.
## `preserve-grouped-orm-pagination-expressions`
Update extension code that reads or mirrors ORM `GroupPagingState.limit` and `offset`: these fields now contain relational-core `LimitOffsetValue | undefined` (`number | AnyExpression | undefined`), not just numbers. Forward them unchanged to the existing `SelectAst.withLimit` and `withOffset` methods. If processing numeric literals separately, narrow with `typeof value === 'number'`; preserve expression nodes rather than coercing, serializing or boxing them as literal parameters. Test presence against `undefined`, not truthiness, so zero limits and offsets survive. Keep grouped post-aggregation paging separate from pre-group collection paging, preserving expression operands at both stages. This extends the collection-pagination migration in `preserve-orm-pagination-expressions` (the previous entry) to grouped pagination; do not keep grouped paging numeric-only.
## `postgres-list-element-codecs-receive-raw-strings`
Review PostgreSQL extension codecs whose descriptors can be used by `CodecRef.many` list columns. Inbound list framing is now target-owned: the target parses the Postgres array literal and invokes the scalar element codec for each non-null raw string element. Keep scalar direct-query compatibility as needed, but make the element `decode(wire, ctx)` accept the raw text spelling Postgres emits for that scalar value; the built-in numeric, boolean, integer, and float codecs accept both raw strings and native scalar wire values for this reason. Do not add a native-array fallback at the list-frame boundary, and do not add compatibility exports or codec-id aliases. `codecId`, `typeParams`, and emitted `CodecRef.many` shapes are unchanged.
## `query-operation-types-move-to-the-postgres-target`
The Postgres adapter no longer contributes query operations; the target does. Change the import:
```ts
// before
import type { QueryOperationTypes } from '@internal/adapter-postgres/operation-types';
// after
import type { QueryOperationTypes } from '@internal/target-postgres/operation-types';
```
Under the published facade, `@prisma/orm-postgres/adapter/operation-types` becomes `@prisma/orm-postgres/target/operation-types`. Emitted contracts name the target import under the alias `PgTargetQueryOps` instead of `PgAdapterQueryOps`, so a snapshot or fixture that pins emitted contract text needs regenerating.
The type's shape is otherwise unchanged. It gains `fullTextMatches`, `fullTextRank` and `fullTextHeadline` on `textual` columns, whose query argument is a `pg/tsquery@1` value (built with a parser or the `tsquery` tag). It also gains the four parser operations `websearchToTsquery`, `toTsquery`, `plaintoTsquery` and `phrasetoTsquery`, which have no `self`, so they attach to no column. None of the names collide with an existing operation, so an extension that intersects its own `QueryOperationTypes` with the Postgres one needs no other edit.
## `native-enum-codec-is-not-textual`
Postgres has no `LIKE`, `ILIKE` or `to_tsvector` for an enum type, so the native enum codec (`pgEnumDescriptor`, `PgEnumCodec` in `@internal/target-postgres/codecs`) no longer declares `textual`. Its traits are `['equality', 'order']`.
An extension operation whose `self` targets `{ traits: ['textual'] }` no longer attaches to native enum columns. That is intended when the operation passes the column where Postgres expects `text`. If an operation of yours does work on an enum, declare its `self` by codec id (`{ codecId: 'pg/enum@1' }`).
A hand-written type fixture that spells out `pg/enum@1` with `traits: 'equality' | 'order' | 'textual'` should drop `'textual'` to match the real codec.
`min` and `max` over a native enum still resolve to `pg/enum@1`: the Postgres target now lists the enum codec explicitly instead of reaching it through the `textual` fallback. Emitted `AggregateTypes` do not change.
## `sql-lowering-spec-drops-strategy`
Nothing ever read `strategy`; the template already says whether an operation lowers as an infix operator or a function call. The field is gone from `SqlLoweringSpec`, so a descriptor that still sets it fails to typecheck as an excess property. Delete the line:
```ts
// before
lowering: { targetFamily: 'sql', strategy: 'function', template: 'lower({{self}})' },
// after
lowering: { targetFamily: 'sql', template: 'lower({{self}})' },
```
The rendered SQL is unchanged.
## `share-create-default-cache-across-inserts`
If an extension orchestrates multiple `applyMutationDefaults` calls for one logical create, allocate one `Map<string, unknown>` before iterating its rows and pass it as `defaultValueCache` to every call. For multi-table inheritance, pass the same cache to both the base-table and variant-table inserts, including all rows of a bulk create.
Let helpers that apply create defaults accept that cache from their caller, with a fresh map as the default for standalone operations. Do not keep it on a reusable collection, runtime, or transaction: a subsequent create operation needs a fresh cache.
Continue letting the mutation-default registry honor generator stability. Do not cache field-stable generators yourself; generated IDs must remain independent.
## `supabase-contract-declares-nullable-list-columns`
The `@prisma/orm-extension-supabase` contract gains two fields that were previously omitted because the PSL printer could not write a nullable list: `StorageBucket.allowedMimeTypes` (`storage.buckets.allowed_mime_types`) and `StorageObject.pathTokens` (`storage.objects.path_tokens`), both `String[]?`. This is one of several changes to the Supabase contract in this release; `supabase-contract-regenerated-from-the-reference-fixture` (next) gives the storage hash they produce together.
A contract that composes the Supabase space references it by id, so your own `contract.json` and `contract.d.ts` do not change. What changes is the signature: a database that was signed against the previous Supabase contract no longer matches the new hash, so run `prisma db sign` against it once after upgrading; one sign covers this entry and the next. Both columns already exist on every Supabase database, so `prisma db verify` passes without a schema change. `path_tokens` is `GENERATED ALWAYS`: read it, do not write it.
## `supabase-contract-regenerated-from-the-reference-fixture`
The `@prisma/orm-extension-supabase` contract is now exactly what `contract:generate` produces from the reference fixture, which it had drifted away from. The Supabase space's storage hash changes from `409d9a5191d9d7d8e45a795cb55695a79edce9d8f42ff1e456bce6e79f98dff1` (the hash in 8.0.0-rc.11) to `734893bc990ae5ec14dc7ad50a7d8b5873a864f056085a7ffd2f69c866989067`. That new hash also covers the two fields added by `supabase-contract-declares-nullable-list-columns` (the previous entry).
Five things changed in the contract.
**43 check constraints are now declared.** Every `CHECK` that the pack's reference Supabase build declares on an `auth` or `storage` table — for example `users_email_change_confirm_status_check` and `one_time_tokens_token_hash_check` — is now part of the contract. The reference build is supabase/postgres 17.6.1.106 with gotrue 2.188.1 and storage-api 1.54.1. On a database at or near that version the constraints are already present, so `prisma db verify` passes without a schema change. This is the one item that can newly fail for you: the checks used to be a tolerated live extra and are now a declared shape, so if your Supabase build's constraint set differs, verify reports the missing ones. You cannot repair that with a migration, because Prisma emits no DDL against an externally controlled table; report the difference so the pack's reference fixture can be refreshed.
**Six native-enum column defaults are declared as member literals.** `auth.oauth_clients.client_type`, `auth.oauth_authorizations.response_type`, `auth.oauth_authorizations.status`, and the `type` column of `storage.buckets`, `storage.buckets_analytics` and `storage.buckets_vectors` previously carried the raw cast expression as their default, for example `{ "kind": "function", "expression": "'STANDARD'::storage.buckettype" }`. They now carry the enum member itself: `{ "kind": "literal", "value": "STANDARD" }`. This is the same live default read a more precise way, so the live databases need no change; if you read a column's declared default out of the contract, expect a literal rather than an expression.
**Four `jsonb` column defaults are declared as JSON literals.** `auth.custom_oauth_providers.attribute_mapping`, `auth.custom_oauth_providers.authorization_params` and `storage.iceberg_namespaces.metadata` (`'{}'::jsonb`), and `auth.webauthn_credentials.transports` (`'[]'::jsonb`), previously carried the raw cast expression as their default. They now carry the JSON value itself, for example `{ "kind": "literal", "value": {} }`, as the JSON row of `dbgenerated-removed-from-psl` describes. The live defaults need no change.
**Two list columns carry an element-not-null waiver.** `auth.custom_oauth_providers.acceptable_client_ids` and `auth.custom_oauth_providers.scopes` now carry `"noCheck": ["elementNotNull"]`, matching the two `storage` list columns that already did. `contract infer` writes this for any list column with no live check at the derived name, and the committed contract is the generator's output, so it carries it too. This item moves the storage hash and changes nothing else you can observe: the pack is under `external` control, so a derived check is stripped before emit whether the waiver is written or not, and `db verify` demanded no such constraint before and demands none now.
**78 timestamp columns are written as `Timestamptz` instead of `DateTime` in the PSL.** Same codec (`pg/timestamptz-temporal@1`) and same emitted column, so this is a text change only.
A contract that composes the Supabase space references it by id, so your own `contract.json` and `contract.d.ts` do not change. What changes is the signature: a database that was signed against the previous Supabase contract no longer matches the new hash, so run `prisma db sign` against it after upgrading. If you re-emit your own contract, do that first so the composed space is the new one.
## `contract-space-restamp`
For every `contract.json` matched by `detection`, run the extension package's `build:contract-space` script (or its emit command) once after upgrading. This entry accounts for the embedded `version` moving to `8.0.0-rc.12`; any other difference in the emitted files comes from an earlier entry in this guide.
upgrading/extension/upgrades/8.0.0-rc.11-to-8.0.0-rc.12/scripts/psl-verbatim-table-names/add-model-map.mjs
#!/usr/bin/env node
/**
* Adds `@@map("<model name with its first letter lowered>")` to every PSL
* `model` block that has no `@@map`, so a schema written before Prisma 8 made
* model names verbatim keeps the table (or collection) names it already has.
*
* Usage:
* node <path to this file> <file-or-glob> [...more]
*
* Globs never descend into `node_modules` or `dist`.
*
* A model with `@@base(...)` and no `@@map` shares its base's storage, so it
* is left alone: adding `@@map` there would split it into its own table or
* collection.
*
* Files are rewritten in place; every mapped model is printed as
* `<file>: model <Name> -> @@map("<name>")` for review. Running it twice is a
* no-op. Only run it on a schema written against the previous release: a
* schema inferred after upgrading already names its tables verbatim.
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { glob } from 'node:fs/promises';
import { argv, exit, stderr, stdout } from 'node:process';
import { fileURLToPath } from 'node:url';
const MODEL_LINE = /^\s*model\s/;
const MODEL_START = /^(\s*)model\s+([A-Za-z_][A-Za-z0-9_]*)\s*(\{)?(.*)$/;
const OPEN_BRACE_LINE = /^\s*\{\s*(\/\/.*)?$/;
const BLANK_OR_COMMENT = /^\s*(\/\/.*)?$/;
const BLOCK_CLOSE = /^\s*\}\s*(\/\/.*)?$/;
const MAP_ATTRIBUTE = /^\s*@@map\s*\(/;
const BASE_ATTRIBUTE = /^\s*@@base\s*\(/;
const OWN_STORAGE_ATTRIBUTE = /@@(map|base)\s*\(/;
function lowerFirst(name) {
return name.charAt(0).toLowerCase() + name.slice(1);
}
function bodyIndent(lines, start, end, headerIndent) {
for (let i = start; i < end; i += 1) {
const match = /^(\s*)\S/.exec(lines[i]);
if (match) return match[1];
}
return `${headerIndent} `;
}
function stripCr(line) {
return line.replace(/\r$/, '');
}
/**
* Classifies the `model` line at `index`. Returns the header indent, the model
* name, the index of the first body line, any body text that shares the
* header line, and whether the whole block sits on this one line.
*/
function nextNonBlankIndex(lines, index) {
let i = index + 1;
while (i < lines.length && BLANK_OR_COMMENT.test(stripCr(lines[i]))) i += 1;
return i;
}
/**
* A `model` line is a declaration only when the block's `{` follows the model
* name on that line, or opens the next line that is not blank or a comment.
* Any other `model` line is a field named `model` inside some block and is
* left alone.
*/
function isModelDeclaration(lines, index) {
const match = MODEL_START.exec(stripCr(lines[index]));
if (!match) return false;
if (match[3] !== undefined) return true;
const next = lines[nextNonBlankIndex(lines, index)];
return next !== undefined && OPEN_BRACE_LINE.test(stripCr(next));
}
function readModelStart(lines, index) {
const match = MODEL_START.exec(stripCr(lines[index]));
if (!match) return undefined;
const [, indent, modelName, brace, rest] = match;
if (brace === undefined) {
if (!BLANK_OR_COMMENT.test(rest)) return undefined;
const braceAt = nextNonBlankIndex(lines, index);
const next = lines[braceAt];
if (next === undefined || !OPEN_BRACE_LINE.test(stripCr(next))) return undefined;
return { indent, modelName, bodyStart: braceAt + 1, inlineBody: '', singleLine: false };
}
const closeAt = closingBraceIndex(rest);
if (closeAt !== -1) {
return { indent, modelName, bodyStart: index + 1, inlineBody: rest, singleLine: true };
}
return {
indent,
modelName,
bodyStart: index + 1,
inlineBody: BLANK_OR_COMMENT.test(rest) ? '' : rest,
singleLine: false,
};
}
/** The line without a trailing `//` comment; a `//` inside a double-quoted string is content. */
function stripLineComment(text) {
let quoted = false;
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
if (quoted) {
if (char === '\\') i += 1;
else if (char === '"') quoted = false;
} else if (char === '"') {
quoted = true;
} else if (char === '/' && text[i + 1] === '/') {
return text.slice(0, i).trimEnd();
}
}
return text;
}
/** Index of a `}` that ends the block on this line (only whitespace or a `//` comment may follow), else -1. */
function closingBraceIndex(text) {
const code = stripLineComment(text);
const closeAt = code.lastIndexOf('}');
return closeAt !== -1 && /^\s*$/.test(code.slice(closeAt + 1)) ? closeAt : -1;
}
function addMapToSingleLineModel(line, modelName) {
const raw = stripCr(line);
const closeAt = closingBraceIndex(raw);
const body = raw.slice(0, closeAt);
if (OWN_STORAGE_ATTRIBUTE.test(body)) return line;
return `${body.trimEnd()} @@map("${lowerFirst(modelName)}") ${line.slice(closeAt)}`;
}
/** Thrown when a `model` declaration is written in a shape the codemod does not recognise. */
export class UnhandledModelError extends Error {
constructor(lineNumbers) {
super(`model block(s) not understood at line(s) ${lineNumbers.join(', ')}`);
this.lineNumbers = lineNumbers;
}
}
/**
* Rewrites `source` and also returns the models that received an `@@map`, so a
* caller can show the user what was mapped. The codemod cannot see storage:
* it assumes every unmapped model was written against the release whose
* default lowered the first letter.
*/
export function addModelMapsWithReport(source) {
const newline = source.includes('\r\n') ? '\r' : '';
const lines = source.split('\n');
const out = [];
const unhandled = [];
const mapped = [];
let i = 0;
while (i < lines.length) {
if (!MODEL_LINE.test(lines[i]) || !isModelDeclaration(lines, i)) {
out.push(lines[i]);
i += 1;
continue;
}
const start = readModelStart(lines, i);
if (start === undefined) {
unhandled.push(i + 1);
out.push(lines[i]);
i += 1;
continue;
}
if (start.singleLine) {
const rewritten = addMapToSingleLineModel(lines[i], start.modelName);
if (rewritten !== lines[i]) mapped.push(start.modelName);
out.push(rewritten);
i += 1;
continue;
}
let close = start.bodyStart;
while (close < lines.length && !BLOCK_CLOSE.test(lines[close])) close += 1;
if (close >= lines.length) {
unhandled.push(i + 1);
out.push(...lines.slice(i));
break;
}
const body = [start.inlineBody, ...lines.slice(start.bodyStart, close)];
out.push(...lines.slice(i, close));
if (!body.some((line) => MAP_ATTRIBUTE.test(line) || BASE_ATTRIBUTE.test(line))) {
const indent = bodyIndent(lines, start.bodyStart, close, start.indent);
out.push(`${indent}@@map("${lowerFirst(start.modelName)}")${newline}`);
mapped.push(start.modelName);
}
out.push(lines[close]);
i = close + 1;
}
if (unhandled.length > 0) throw new UnhandledModelError(unhandled);
return { source: out.join('\n'), mapped };
}
export function addModelMaps(source) {
return addModelMapsWithReport(source).source;
}
const SKIPPED_DIRECTORIES = /(^|\/)(node_modules|dist)\//;
async function expandPatterns(patterns) {
const files = new Set();
for (const pattern of patterns) {
for await (const match of glob(pattern, {
exclude: (path) => SKIPPED_DIRECTORIES.test(`${path}/`),
})) {
files.add(match);
}
}
return [...files].sort();
}
async function main() {
const patterns = argv.slice(2);
if (patterns.length === 0) {
stderr.write('usage: node add-model-map.mjs <file-or-glob> [...more]\n');
exit(2);
}
const files = await expandPatterns(patterns);
if (files.length === 0) {
stderr.write('no files matched\n');
exit(1);
}
let failed = false;
let mappedCount = 0;
for (const file of files) {
const before = readFileSync(file, 'utf8');
let result;
try {
result = addModelMapsWithReport(before);
} catch (error) {
if (!(error instanceof UnhandledModelError)) throw error;
for (const line of error.lineNumbers)
stderr.write(`${file}:${line}: model block not understood\n`);
failed = true;
continue;
}
if (result.source !== before) {
writeFileSync(file, result.source);
for (const modelName of result.mapped) {
stdout.write(`${file}: model ${modelName} -> @@map("${lowerFirst(modelName)}")\n`);
}
mappedCount += result.mapped.length;
}
}
if (mappedCount > 0) {
stdout.write(
`${mappedCount} model(s) mapped. Review the list: every one must be a model whose table or collection was created under the previous release's lowered-first-letter default. A schema written or inferred after upgrading must not be run through this codemod.\n`,
);
}
if (failed) exit(1);
}
if (argv[1] && fileURLToPath(import.meta.url) === argv[1]) {
await main();
}
upgrading/extension/upgrades/8.0.0-rc.12-to-8.0.0-rc.13/instructions.md
---
from: "8.0.0-rc.12"
to: "8.0.0-rc.13"
changes:
- id: execution-ref-entry-field
summary: |
Each entry in `contract.execution.mutations.defaults` now names its target as
`ref: { namespace, entry, field }` instead of `ref: { namespace, table, column }`. The values
are the same table and column names. Re-emit the contract; code and types that read `.ref.table` or
`.ref.column` read `.ref.entry` and `.ref.field`.
detection:
glob: "**/*.{json,ts,mts,cts}"
matches:
- '"ref"\s*:\s*\{(?![^{}]*"kind")[^{}]*"(?:table|column)"\s*:'
- '\.ref\??\.(?:table|column)\b'
- '\bref\s*:\s*\{(?![^{}]*\bkind\b)[^{}]*\b(?:table|column)\s*:[^{}]*\b(?:table|column)\s*:'
script: ./scripts/execution-ref-entry-field/rename-execution-ref-keys.ts
- id: mutation-default-generator-types-move-to-framework
summary: |
`GeneratorStability` and `RuntimeMutationDefaultGenerator` are no longer exported by the SQL
runtime (`@prisma/orm-family-sql/runtime`, `@prisma/orm-postgres/family-runtime`,
`@prisma/orm-sqlite/family-runtime`). Import them from `@prisma/orm-framework/components/runtime`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bimport\s+(?:type\s+)?\{[^}]*\b(?:GeneratorStability|RuntimeMutationDefaultGenerator)\b[^}]*\}\s*from\s*[''"](?:@internal/sql-runtime|@prisma/orm-family-sql/runtime|@prisma/orm-(?:postgres|sqlite)/family-runtime)[''"]'
- id: mutation-defaults-options-entry-field
summary: |
`applyMutationDefaults` options name the storage entry as `entry` instead of `table`, and each
applied default names its field as `field` instead of `column`. `MutationDefaultsOptions`,
`AppliedMutationDefault` and `MutationDefaultsOp` now come from
`@prisma/orm-framework/components/runtime`, not from the SQL `relational-core/query-lane-context` subpath.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bapplyMutationDefaults\s*\(\s*\{[^{}]*\btable\s*:'
- '\bimport\s+(?:type\s+)?\{[^}]*\b(?:AppliedMutationDefault|MutationDefaultsOptions|MutationDefaultsOp)\b[^}]*\}\s*from\s*[''"](?:@internal/sql-relational-core/query-lane-context|@prisma/orm-(?:family-sql|postgres|sqlite)/relational-core/query-lane-context)[''"]'
- id: temporal-presets-move-to-framework
summary: |
`TIMESTAMP_NOW_GENERATOR_ID`, `temporalAuthoringPresets` and `temporalCodecPreset` moved from
the SQL family's `family/control` subpath to the framework's `components/authoring`, and
`timestampNowControlDescriptor` moved to `components/control`. `temporalCodecPresetWithPrecision`
and `temporalStringAuthoringPresets` stay in `family/control`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\b(?:TIMESTAMP_NOW_GENERATOR_ID|temporalAuthoringPresets|temporalCodecPreset|timestampNowControlDescriptor)\b[^;]*?from\s*[''"](?:@internal/family-sql/control|@prisma/orm-(?:family-sql|postgres|sqlite)/family/control)[''"]'
- id: temporal-preset-builders-take-storage-template
summary: |
`temporalAuthoringPresets`, `temporalStringAuthoringPresets` and `temporalCodecPreset` take the
codec's storage template as one type parameter (`<Storage, GeneratorId>`, or `<Storage>` for
`temporalCodecPreset`) instead of `<CodecId, NativeType, GeneratorId>`. Calls that let
TypeScript infer the type arguments are unchanged.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\b(?:temporalAuthoringPresets|temporalStringAuthoringPresets|temporalCodecPreset)\s*<'
- id: ts-defaults-encoded-by-codec
summary: |
`defineContract` from the Postgres and SQLite packages now encodes every literal `.default(value)` through the column's codec. The literal is the codec's input type. TypeScript checks it for fields built inside the `defineContract` factory, and the build fails with `CONTRACT.DEFAULT_INVALID` for a value the codec refuses. Pass a value of the codec's input type, or choose the field preset whose codec takes the value you have.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\.default\(\s*(?!now\(\)|autoincrement\(\)|sql`)'
- id: contract-source-format-is-psl-or-typescript
summary: Every contract source declares format 'psl' or 'typescript'; format is required, and OpaqueContractSourceProvider is removed.
- id: prisma7-schema-source-declares-psl
summary: The prisma7Schema() contract source declares format 'psl' instead of 'prisma7'.
- id: print-psl-description-option
summary: printPsl() from @internal/psl-printer (@prisma/orm-framework/psl-printer) opens every file with only the // use prisma-8 marker unless the caller passes description.
- id: family-sql-psl-build-export
summary: mapDefault, its option types, the PslTypeMap types and toEnumMemberName moved from @internal/family-sql/psl-infer to @internal/family-sql/psl-build (published as family/psl-infer and family/psl-build of @prisma/orm-family-sql).
- id: cursor-rejects-expression-orders
summary: "cursor() now throws ORM.ARGUMENT_INVALID when an active orderBy item is not a plain column (extension-operation orders such as vector distance were previously dropped from the keyset silently)"
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\.cursor\('
- id: order-by-item-nulls
summary: "OrderByItem from @internal/sql-relational-core/ast (@prisma/orm-family-sql/relational-core/ast) carries a nulls placement: its constructor takes a required third argument, withExpr rebuilds an item around a new expression, and every renderer must emit nulls wherever it emits dir"
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'new OrderByItem\('
- '\.dir\.toUpperCase\('
- id: include-expr-join-column-lists
summary: |
`IncludeExpr.localColumn` and `IncludeExpr.targetColumn` are now `localColumns` and
`targetColumns`: ordered string arrays paired by index, with one entry per column of the
relation's key.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\.(?:localColumn|targetColumn)\b'
- id: mongo-codec-subpaths-move-to-target
summary: |
The Mongo codecs moved from `@internal/adapter-mongo` to `@internal/target-mongo`: the
`codec-types`, `codecs`, `codec-ids` and `data-types` subpaths of the adapter are gone and
live under the target. The same move applies to the published `adapter/*` subpaths of
`@prisma/orm-mongo` and `@prisma/orm-target-mongo`, which are now `target/*`.
detection:
glob: "**/*.{ts,mts,cts,md}"
matches:
- '@internal/adapter-mongo/(?:codec-types|codecs|codec-ids|data-types)(?![\w-])'
- '@prisma/orm-(?:target-)?mongo/adapter/(?:codec-types|codecs|codec-ids|data-types)(?![\w-])'
- id: create-mongo-runner-deps-removed
summary: |
`createMongoRunnerDeps(...)` is removed from `@internal/adapter-mongo/control`
(`@prisma/orm-mongo/adapter/control`). Build the
runner dependencies with `new MongoControlAdapterImpl().createRunnerDependencies(controlDriver)`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bcreateMongoRunnerDeps\b'
- id: mongo-runner-dependency-types-move-to-family
summary: |
`MongoRunnerDependencies` and `MarkerOperations` are exported from
`@internal/family-mongo/control-adapter` (`@prisma/orm-mongo/family/control-adapter`), no
longer from `@internal/adapter-mongo/control` or `@internal/target-mongo/control` (the
`adapter/control` and `target/control` subpaths of `@prisma/orm-mongo`).
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\b(?:MongoRunnerDependencies|MarkerOperations)\b[^;]*?from\s*[''"]@internal/(?:adapter|target)-mongo/control[''"]'
- '\b(?:MongoRunnerDependencies|MarkerOperations)\b[^;]*?from\s*[''"]@prisma/orm-(?:target-)?mongo/(?:adapter|target)/control[''"]'
- id: mongo-control-adapter-creates-runner-dependencies
summary: |
The `MongoControlAdapter` SPI gains `createRunnerDependencies(driver)`, and
`MongoControlFamilyInstance` gains `createRunnerDependencies({ driver })`. A custom
implementation of either must add the method.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bimplements\s+(?:[\w.]+\s*,\s*)*MongoControlAdapter\b'
- ':\s*MongoControlFamilyInstance\s*=\s*\{'
- id: mongo-create-runner-needs-adapter-on-stack
summary: |
`mongoTargetDescriptor.migrations.createRunner(family)` reaches the database through the
control adapter on the family's control stack. A family instance created from an empty stack,
or from a `createControlStack(...)` with no `adapter`, fails with "Mongo family requires an
adapter descriptor in ControlStack" when the runner executes.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'createMongoFamilyInstance\(\s*\{\s*\}'
- 'createControlStack\(\s*\{(?:(?!adapter)[^}])*mongoTargetDescriptor(?:(?!adapter)[^}])*\}\s*\)'
- id: mongo-execution-context-applies-mutation-defaults
summary: |
`MongoExecutionContext` gains a required `applyMutationDefaults(options)` method, which fills
the contract's execution defaults on ORM writes. Contexts built with
`createMongoExecutionContext` have it; an object literal typed as a `MongoExecutionContext`
(usually a test double) must add it.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?:\)|\b\w+)\s*:\s*MongoExecutionContext\b(?:<[^>]*>)?\s*(?:=\s*\{|\{)'
- '\bsatisfies\s+MongoExecutionContext\b'
- id: mongo-field-builder-execution-defaults-parameter
summary: |
The Mongo `FieldBuilder` type gains a fifth type parameter, the field's execution defaults,
defaulting to `undefined`, so a bare `FieldBuilder` constraint rejects preset fields such as
`field.temporal.createdAt()`; widen it to
`FieldBuilder<ContractFieldType, boolean, boolean, EnumTypeHandle | undefined, ExecutionMutationDefaultPhases | undefined>`.
`ContractFieldType` and `ExecutionMutationDefaultPhases` come from
`@prisma/orm-mongo/contract/types`; `FieldBuilder` and `EnumTypeHandle` from
`@prisma/orm-mongo/contract-builder`.
- id: mongo-bson-codec-added
summary: |
The Mongo target gains the codecs `mongo/int64@1`, `mongo/decimal128@1`, `mongo/binary@1`,
`mongo/json@1` (JSON values only) and `mongo/bson@1` (any BSON value, typed `BsonInputValue`
on write and `BsonValue` on read in `CodecTypes`). Collection validators now read a codec's
whole `targetTypes` list, not only its first entry, and an enum's codec must declare exactly
one.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'targetTypes\s*:\s*\[\s*[^\]\s,][^\],]*,\s*[^\]\s]'
---
# 8.0.0-rc.12 → 8.0.0-rc.13 — Extension author upgrade instructions
## `execution-ref-entry-field`
A contract with generated defaults (`temporal.createdAt()`, `temporal.updatedAt()`, `@default(uuid())` and the like) carries them under `execution.mutations.defaults`. Each entry's `ref` changes keys; the values do not:
```json
// before
{ "ref": { "namespace": "public", "table": "user", "column": "updated_at" }, "onUpdate": { "kind": "generator", "id": "timestampNow" } }
// after
{ "ref": { "entry": "user", "field": "updated_at", "namespace": "public" }, "onUpdate": { "kind": "generator", "id": "timestampNow" } }
```
1. Run `prisma contract emit` so `contract.json` and `contract.d.ts` use the new keys. The runtime rejects a contract whose refs still say `table` and `column` with `Contract structural validation failed: execution.mutations.defaults[0].ref.entry must be a string`.
2. Contracts stored under `migrations/` carry the old keys too: the snapshots in `migrations/snapshots/<hash>/contract.json` and `contract.d.ts`, and any intermediate contract a migration imports from its own directory, such as `migrations/<dir>/intermediate.json` and `intermediate.d.ts`. From the extension package root, run the script that sits next to this guide. `<skill>` is the directory of the synced `prisma-8` skill:
```bash
pnpm exec tsx <skill>/upgrading/extension/upgrades/8.0.0-rc.12-to-8.0.0-rc.13/scripts/execution-ref-entry-field/rename-execution-ref-keys.ts
```
The script reads every `.json` and `.d.ts` file under a `migrations/` directory, skipping `node_modules`, `.git` and `dist`, and changes only `execution.mutations.defaults[].ref` entries. In each one it renames `table` to `entry` and `column` to `field`, and writes the keys in the order `entry`, `field`, `namespace`, which is the order `prisma contract emit` writes. The old key order varies between files: older `contract.d.ts` files list `namespace`, `table`, `column`, newer ones `column`, `namespace`, `table`. The script handles any order; a hand edit or a search-and-replace that assumes one order misses some refs. A `contract.json` in canonical form stays canonical, so a snapshot matches a fresh emit apart from `executionHash`; an indented JSON file keeps its indentation. The script leaves every `executionHash` as it is. That hash no longer matches the renamed content, but the snapshot loader re-hashes only the storage section, so nothing checks it. `storageHash` and `profileHash` do not move, so snapshot directory names stay the same. A second run changes nothing. Pass `--check` to list the files it would change without writing them; it exits 1 if any would change. A `.json` file it cannot parse is listed as `NOT JSON` and also makes it exit 1.
To confirm the rewrite, run the script again with `--check`; it exits 0 when no ref is left to rename. No CLI command reads the package's own `migrations/snapshots/`: run from the package root, the CLI looks for migrations in `migrations/app/`, and a contract-space package keeps them directly under `migrations/`. The CLI reads an extension's contract space in an app that installs the extension, from the app's `migrations/<space-id>/` and `migrations/snapshots/`. There, `prisma migration check --space <space-id>` loads the contract at the space's head and fails with `MIGRATION.CHECK_CONTRACT_UNREADABLE` while its refs still say `table` and `column`. The head is the only snapshot of an extension space the CLI loads; `prisma db migrate --to <hash>` selects a snapshot of the app space, not of an extension.
3. Code that reads the section directly changes `.ref.table` to `.ref.entry` and `.ref.column` to `.ref.field`.
`executionHash` changes for every contract with generated defaults, because the canonical JSON changes. Nothing compares it against the database, so no migration or re-sign is needed.
### For extension authors
- The framework type `ExecutionMutationDefault['ref']` from `@internal/contract/types` (`@prisma/orm-framework/contract/types`) is `{ namespace: string; entry: string; field: string }`. A type that matches refs by shape (for example a create-input type that checks whether a column has a generated default) matches `entry` and `field`.
- A pack that ships a contract with generated defaults re-emits it with `prisma contract emit`, and updates its pinned contract-space snapshots as in step 2.
- The options passed to `applyMutationDefaults` change separately: see `mutation-defaults-options-entry-field` below for the rename of `table` to `entry`.
## `mutation-default-generator-types-move-to-framework`
The mutation-default generator runtime now lives in the framework and serves every family. A pack that contributes generators (`mutationDefaultGenerators: () => [...]` on a runtime target, adapter, or extension descriptor) types them with the framework type:
```ts
// before
import type { RuntimeMutationDefaultGenerator } from '@prisma/orm-family-sql/runtime';
// after
import type { RuntimeMutationDefaultGenerator } from '@prisma/orm-framework/components/runtime';
```
Do the same for `GeneratorStability`. The shape is unchanged: `{ id, generate(params?), stability: 'field' | 'row' | 'query' }`.
## `mutation-defaults-options-entry-field`
Code that calls `applyMutationDefaults` on an execution context, or stubs it in tests, renames two keys:
```ts
// before
const applied = context.applyMutationDefaults({ op: 'create', table: tableName, namespace, values });
for (const def of applied) row[def.column] = def.value;
// after
const applied = context.applyMutationDefaults({ op: 'create', entry: tableName, namespace, values });
for (const def of applied) row[def.field] = def.value;
```
1. In every `applyMutationDefaults({ ... })` call, rename the `table` key to `entry`. The value is the same table name.
2. Where the result is read, rename `.column` to `.field`. A stub that returns applied defaults returns `{ field, value }`.
3. Import `MutationDefaultsOptions`, `AppliedMutationDefault` and `MutationDefaultsOp` from `@prisma/orm-framework/components/runtime`; the SQL `relational-core/query-lane-context` subpath no longer exports them.
A key present in `values` counts as explicit whatever its value, `undefined` included, and gets no default. That rule is unchanged; drop `undefined` values before the call if they should be defaulted.
## `temporal-presets-move-to-framework`
The target-neutral temporal preset builders now live in the framework, so a non-SQL target can build `temporal.*` presets too. Change the import source; the functions behave the same.
```ts
// before
import {
TIMESTAMP_NOW_GENERATOR_ID,
temporalAuthoringPresets,
temporalCodecPreset,
timestampNowControlDescriptor,
} from '@prisma/orm-family-sql/family/control';
// after
import {
TIMESTAMP_NOW_GENERATOR_ID,
temporalAuthoringPresets,
temporalCodecPreset,
} from '@prisma/orm-framework/components/authoring';
import { timestampNowControlDescriptor } from '@prisma/orm-framework/components/control';
```
The same applies to the `family/control` subpaths of `@prisma/orm-postgres` and `@prisma/orm-sqlite` (move to their `components/authoring` and `components/control` subpaths), and to `@internal/family-sql/control` (move to `@internal/framework-components/authoring` and `@internal/framework-components/control`, published as the `components/authoring` and `components/control` subpaths of `@prisma/orm-framework`). Keep importing `temporalCodecPresetWithPrecision` and `temporalStringAuthoringPresets` from `family/control`.
## `temporal-preset-builders-take-storage-template`
The builders now carry the codec's whole storage template through, so their type parameters changed. Drop explicit type arguments and let TypeScript infer them:
```ts
// before
temporalAuthoringPresets<'pg/timestamptz@1', 'timestamptz'>({ codecId: 'pg/timestamptz@1', nativeType: 'timestamptz' });
// after
temporalAuthoringPresets({ codecId: 'pg/timestamptz@1', nativeType: 'timestamptz' });
```
If you need to name them, the first type parameter is the storage template object type (`{ readonly codecId: ...; readonly nativeType: ... }`).
## `ts-defaults-encoded-by-codec`
A TypeScript contract used to store the value passed to `.default(value)` as it stood. The column's codec now encodes it, so the value must be the codec's input type. For a field built inside the `defineContract` factory, `.default()` is typed with that input type, so a wrong value is a type error in `contract.ts`. PSL contracts, `.default(now())`, `.default(autoincrement())` and `` .default(sql`...`) `` are not affected.
What now fails, on Postgres:
```typescript
// before: emitted, although the codec of field.dateTime() holds a Temporal.Instant
createdAt: field.dateTime().default('2024-01-01T00:00:00Z'),
```
```text
CONTRACT.DEFAULT_INVALID: Field "Event.createdAt" has a default that its codec refuses: Codec 'pg/timestamptz-temporal@1' encodes a Temporal.Instant, but received a string.
```
1. Search your contract files for `.default(` with a literal argument.
2. Type-check the contract file, then run `prisma contract emit`. TypeScript reports a default of the wrong type; the emit reports each default the codec refuses, with its model and field.
3. For each one, either pass the codec's type or change the preset:
| You have | Write |
| --- | --- |
| an ISO 8601 string | `field.temporal.timestamptzString().default('2024-01-01T00:00:00Z')` |
| a JavaScript `Date` | `field.temporal.timestamptzJsDate().default(new Date('2024-01-01T00:00:00Z'))` |
| a `Temporal.Instant` | `field.dateTime().default(Temporal.Instant.from('2024-01-01T00:00:00Z'))` |
Changing the preset changes the column's codec, and so the type your queries read and write for that field. On Postgres, `field.bigint()` takes a `bigint` and `field.bytes()` takes a `Uint8Array`. On SQLite, `field.temporal.datetime()` takes a `Date`, `field.column(bigintColumn)` takes a `bigint` and `field.column(blobColumn)` takes a `Uint8Array`.
On Node 24 there is no global `Temporal`. A contract file that creates a `Temporal` value must load an implementation itself, for example with `import 'temporal-polyfill/full/global'` as its first import. The type check on a `Temporal` field needs the `Temporal` type declarations in the project, for example from `temporal-polyfill/global`; without them the parameter is unchecked. The error shown above is the one you get with an implementation loaded; without one, the codec reports that the runtime has no global `Temporal` implementation.
`.default(null)` used to store `null`. It is now a type error on a field whose codec input type does not include `null`, which is every built-in codec except the JSON codecs: `field.json().optional().default(null)` still compiles and stores `null`. When the contract is built, the codec receives the `null`: a codec that checks its input, such as `pg/int8@1`, refuses it with `CONTRACT.DEFAULT_INVALID`, and a codec that passes any value through, such as `pg/text@1`, still stores `null`. A column without a default already defaults to `NULL` in the database, so remove the call.
A JavaScript `number` on a `bigint` field (codec `pg/int8@1`), such as `field.bigint().default(1)` inside the `defineContract` factory, is now a type error. Write a `bigint` literal: `field.bigint().default(1n)`. The default is stored as `"1"`, as the table below shows.
A default that gets past the type check, for example from an untyped caller, is stored in a different form than before:
| Default | Stored before | Stored now |
| --- | --- | --- |
| `field.bigint().default(1)` (also SQLite `bigintColumn`) | `1` | `"1"` |
| `field.bytes().default('x')` | `"x"` | `"eA=="` (base64) |
| SQLite `blobColumn` with `.default('x')` | `"x"` | `"78"` (hex) |
A contract with such a default emits a different `contract.json` and a different storage hash. Re-emit the contract and review the diff of `contract.json`.
A literal default on a column whose codec no pack in the contract declares now fails with `CONTRACT.DEFAULT_INVALID`, because nothing can check it. List the pack that owns the codec in the `extensions` of `defineContract`.
### For extension authors
- A pack passed in `extensions` contributes its codecs to this lookup through `types.codecTypes.codecDescriptors`. A default on a column of your codec is now passed to your codec's `encodeJson`, built with the column's `typeParams`. Make `encodeJson` throw for a value it cannot encode; the build reports your message.
- A codec descriptor that the target's codec registry refuses (for Postgres, one that does not extend `PostgresCodecDescriptor` and is not wrapped with `postgresCodec()`), or that reuses a built-in codec id, now fails `defineContract` as it already failed when the control stack was assembled.
- A caller who passes `codecLookup` to `defineContract` keeps that lookup; the facade does not add to it.
- A column helper that is a function, such as `varcharColumn(n)`, `timeTemporalColumn()` or pgvector's `vector(n)`, now declares its literal codec id in its return type. One variable can no longer be reassigned between the results of different helpers; annotate such a variable as `ColumnTypeDescriptor`.
## `contract-source-format-is-psl-or-typescript`
`ContractSourceProvider` from `@internal/config/config-types` (`@prisma/orm-framework/config/config-types`) is now the union of `PslContractSourceProvider` (`format: 'psl'`) and `TypeScriptContractSourceProvider` (`format: 'typescript'`). `format` is required on both. `OpaqueContractSourceProvider` and every other `format` value are gone, and config validation reports a source with no `format`, or any other value, as an issue on `contract.source.format`.
Give every contract source an extension defines a `format`: `'psl'` when its inputs are PSL text, and `'typescript'` when it builds the contract in TypeScript. Replace imports of `OpaqueContractSourceProvider` with `ContractSourceProvider`.
## `prisma7-schema-source-declares-psl`
The source returned by `prisma7Schema()` now declares `format: 'psl'`, because a Prisma 7 schema is PSL text. It still does not implement `interpret`. Code that compared `source.format` with `'prisma7'` must stop doing so; nothing in the framework tells a Prisma 7 schema from a Prisma 8 one by its format.
## `print-psl-description-option`
`printPsl()` from `@internal/psl-printer` (`@prisma/orm-framework/psl-printer`) used to open every file with the `// use prisma-8` marker and a line saying the contract was inferred from the live database. It now writes only the marker, and a second comment line only when the caller passes `description`. Code that prints an inferred contract and wants the old second line passes it:
```ts
printPsl(ast, {
pslBlockDescriptors,
description:
'Contract inferred from the live database schema. Edit as needed, then run `prisma contract emit`.',
});
```
## `family-sql-psl-build-export`
`contract print` uses some of what `@internal/family-sql/psl-infer` exported, so those exports moved to the new subpath `@internal/family-sql/psl-build`: `mapDefault`, `DefaultMappingOptions`, `DefaultMappingResult`, `PslTypeMap`, `PslTypeReference`, `PslTypeResolution` and `toEnumMemberName`. Import them from `@internal/family-sql/psl-build` (`@prisma/orm-family-sql/family/psl-build`, or the same subpath of `@prisma/orm-postgres` and `@prisma/orm-sqlite`). Everything else stays in `@internal/family-sql/psl-infer` (`family/psl-infer`).
## `cursor-rejects-expression-orders`
A SQL ORM `cursor()` builds its keyset from plain model columns. It throws `ORM.ARGUMENT_INVALID`, naming the 1-based `orderBy` position, when an active order is any of these:
- an extension-operation result, such as `(p) => p.embedding.cosineDistance(v).asc()` or `(m) => m.text.fullTextRank(q).desc()`;
- a relation field, such as `(p) => p.author.name.asc()`;
- a relation count, such as `(u) => u.posts.count().desc()`;
- an order with null placement, such as `(p) => p.title.asc({ nulls: 'last' })`.
The check runs when `cursor()` is called and again when the query is planned, so an order added after `cursor()` is refused too. Before this change an extension-operation order was left out of the keyset without an error, which returned wrong pages.
For each `.cursor(` call in a chain that also calls `.orderBy(`, look at every `orderBy` lambda in the chain. If any of them is one of the orders above, do one of these:
- Paginate with `.limit(n).offset(n)` and remove `.cursor(...)`.
- Keep the cursor and order by plain columns only, for example `(p) => p.createdAt.desc()` and `(p) => p.id.desc()`.
`distinctOn()` throws the same error when one of the first N orders, where N is the number of `distinctOn` columns, is not a plain column: Postgres needs those leading orders to match the `DISTINCT ON` columns, so such a query already failed in the database; it now fails earlier, with `ORM.ARGUMENT_INVALID`. Put the `distinctOn` columns first; a relation order, a count or an operation result may follow them. For example, `.orderBy([(post) => post.title.asc(), (post) => post.author.name.asc()]).distinctOn('title')` is accepted.
## `order-by-item-nulls`
`OrderByItem` has a `nulls` field of type `'first' | 'last' | undefined`, and `undefined` means the database default. The constructor refuses any other value, and any direction other than `'asc'` / `'desc'`, with `RUNTIME.AST_INVALID`.
Constructing an item:
- `new OrderByItem(expr, dir)` no longer compiles. Pass the placement as the third argument, `new OrderByItem(expr, dir, undefined)`, or use `OrderByItem.asc(expr, { nulls })` / `OrderByItem.desc(expr, { nulls })`.
- To reorder by a different expression while keeping an existing item's direction and placement, write `item.withExpr(expr)` instead of `new OrderByItem(expr, item.dir)`. The hand-written form drops `nulls`.
Rendering an item: an adapter or renderer that writes `ORDER BY` itself must write the placement after the direction, `NULLS FIRST` for `'first'` and `NULLS LAST` for `'last'`, in every position it renders an `OrderByItem` (query, window and aggregate `ORDER BY`). A renderer that ignores `nulls` compiles and returns rows in the wrong order. Map `dir` and `nulls` through a fixed table rather than interpolating the string. A dialect without `NULLS FIRST` / `NULLS LAST` must emulate the placement, for example with a leading `expr IS NULL` key, and must not drop it.
## `include-expr-join-column-lists`
An ORM include now joins the related rows on every column of the relation's key, not only the first. So `IncludeExpr` (exported from `@prisma/orm-family-sql/orm-client`, and the element type of `Collection.state.includes`) carries the key as two lists instead of two strings:
- `localColumn: string` is now `localColumns: readonly string[]`, the key columns in the parent table.
- `targetColumn: string` is now `targetColumns: readonly string[]`, the matching columns in the related table.
Both lists have the same length, and `localColumns[i]` joins to `targetColumns[i]`. A single-column relation has one entry in each list.
Update code that reads these fields to use the whole lists and pair them by index. Code that builds an `IncludeExpr` by hand passes arrays, for example `{ localColumns: ['id'], targetColumns: ['user_id'] }` instead of `{ localColumn: 'id', targetColumn: 'user_id' }`. Do not keep only the first entry of each list: for a composite key that matches every related row that shares the first key column, which is the bug this change fixes.
## `mongo-codec-subpaths-move-to-target`
Rewrite each specifier. The exported names are unchanged.
| Before | After |
| --- | --- |
| `@internal/adapter-mongo/codec-types` | `@internal/target-mongo/codec-types` |
| `@internal/adapter-mongo/codecs` | `@internal/target-mongo/codecs` |
| `@internal/adapter-mongo/codec-ids` | `@internal/target-mongo/codec-ids` |
| `@internal/adapter-mongo/data-types` | `@internal/target-mongo/data-types` |
| `@prisma/orm-mongo/adapter/<same four>` | `@prisma/orm-mongo/target/<same four>` |
| `@prisma/orm-target-mongo/adapter/<same four>` | `@prisma/orm-target-mongo/target/<same four>` |
An extension outside this repository imports the published forms in the last two rows. Then re-sort the import block with the package's formatter (the new specifier sorts after `@internal/mongo-*`). A package that imports from `@internal/target-mongo` for the first time needs it in `dependencies`. A pack that emits a Mongo `contract.d.ts` re-emits it with `prisma contract emit`, or applies the same rewrite to the committed file; the contract JSON and hashes do not change.
`@internal/target-mongo/codecs` (`@prisma/orm-mongo/target/codecs`) also exports `mongoStandardCodecs` and `buildStandardCodecRegistry`, which the adapter used internally before.
## `create-mongo-runner-deps-removed`
```ts
// before
import { createMongoRunnerDeps, extractDb } from '@prisma/orm-mongo/adapter/control';
import { MongoDriverImpl } from '@prisma/orm-mongo/driver';
const deps = createMongoRunnerDeps(controlDriver, MongoDriverImpl.fromDb(extractDb(controlDriver)), family);
// after
import { MongoControlAdapterImpl } from '@prisma/orm-mongo/adapter/control';
const deps = new MongoControlAdapterImpl().createRunnerDependencies(controlDriver);
```
Inside this repository the same imports are `@internal/adapter-mongo/control` and `@internal/driver-mongo`.
Drop imports that are now unused and any family instance built only to pass as the third argument.
## `mongo-runner-dependency-types-move-to-family`
Change the import of `MongoRunnerDependencies` or `MarkerOperations` to `@prisma/orm-mongo/family/control-adapter` (`@internal/family-mongo/control-adapter` inside this repository). The shapes are unchanged.
## `mongo-control-adapter-creates-runner-dependencies`
Add the method to a custom control adapter. It returns the `MongoRunnerDependencies` the migration runner uses for that driver:
```ts
createRunnerDependencies(driver: ControlDriverInstance<'mongo', 'mongo'>): MongoRunnerDependencies
```
A custom family instance forwards `createRunnerDependencies({ driver })` to the control adapter it resolves from the stack.
## `mongo-create-runner-needs-adapter-on-stack`
Create the family instance from a control stack that includes the Mongo adapter:
```ts
createMongoFamilyInstance(
createControlStack({ family: mongoFamilyDescriptor, target: mongoTargetDescriptor, adapter: mongoAdapterDescriptor }),
);
```
## `mongo-execution-context-applies-mutation-defaults`
Mongo contracts can now carry execution defaults (`temporal.createdAt()`, `temporal.updatedAt()`), and the execution context applies them. The ORM calls `context.applyMutationDefaults(...)` on every create and update, so `MongoExecutionContext` has a new required method.
Build contexts with `createMongoExecutionContext({ contract, stack })` and nothing changes. A hand-written context, typically a test double, adds a method that applies nothing:
```ts
// before
const context: MongoExecutionContext = { contract, codecs, stack };
// after
const context: MongoExecutionContext = { contract, codecs, stack, applyMutationDefaults: () => [] };
```
## `mongo-field-builder-execution-defaults-parameter`
Code that constrains on a bare `FieldBuilder` (for example `Fields extends Record<string, FieldBuilder>`) accepts only builders without execution defaults; pass `ExecutionMutationDefaultPhases | undefined` as the fifth type argument to accept preset fields:
```ts
import type { ContractFieldType, ExecutionMutationDefaultPhases } from '@prisma/orm-mongo/contract/types';
import type { EnumTypeHandle, FieldBuilder } from '@prisma/orm-mongo/contract-builder';
type AnyField = FieldBuilder<
ContractFieldType,
boolean,
boolean,
EnumTypeHandle | undefined,
ExecutionMutationDefaultPhases | undefined
>;
```
There is no detection pattern: a bare `FieldBuilder` reference is too common to tell which uses need the wider type.
## `mongo-bson-codec-added`
The Mongo target adds five codecs, none of which existed in 8.0.0-rc.12: `mongo/int64@1` (PSL `Int64`, `bigint`), `mongo/decimal128@1` (PSL `Decimal128`, a decimal string), `mongo/binary@1` (PSL `Binary`, `Uint8Array`), `mongo/json@1` (PSL `Json`, JSON values only, refused on encode and decode otherwise) and `mongo/bson@1` (PSL `Bson`, `field.bson()`, any BSON value). The `mongo/bson@1` entry of `CodecTypes` reads `BsonValue` and writes `BsonInputValue` (a `BsonValue`, or a `Uint8Array` at any depth), both exported from `@prisma/orm-mongo/target/codec-types` (`@internal/target-mongo/codec-types` inside this repository) and declared in `@prisma/orm-mongo/value` (`@internal/mongo-value`), where the Mongo TypeScript builder reads them. `BsonValue` covers every value the driver returns with its default settings, `Code`, `MinKey`, `MaxKey`, `BSONSymbol` and a native `RegExp` (what a stored regex reads back as) included; `BSONRegExp` appears only with a driver configured with `bsonRegExp: true`. A `DBRef` is never returned, because decode turns it back into its `{ $ref, $id[, $db], ...fields }` document. It declares an empty `targetTypes`, so the validator does not constrain its value: a single field gets `{}`, and a list field still must be an array, `{ bsonType: 'array', items: {} }`. An extension that lists every Mongo codec id, or keys a map by `CodecTypes`, adds the five new ones. An extension that stores arbitrary BSON in a field of its own contract types it `mongo/bson@1`, not `mongo/json@1`, which refuses non-JSON values.
The detection pattern finds a descriptor whose `targetTypes` lists two or more entries, quoted strings or named constants, on one line or several. It does not see a list built elsewhere and referenced by name (`targetTypes: TYPES`); check those descriptors by hand.
The Mongo validator derivation reads every entry of a codec descriptor's `targetTypes`. For a list field the list applies to `items`, and a nullable field prepends `'null'` unless the list already has it. An empty list leaves the value's type unconstrained but not a list field's cardinality: a single field gets `{}`, and a list field gets `{ bsonType: 'array', items: {} }`.
For each Mongo codec descriptor in the extension whose `targetTypes` has more than one entry, check that every entry is a BSON type the codec's `encode` can produce, because each one is now admitted by the validator. Remove any entry the codec does not write. Then re-emit the extension's contracts and any test fixtures; their validators list every entry.
An enum's `@@type` codec must declare exactly one entry: the Mongo enum factory reports `enum "<name>" @@type codec "<id>" declares <n> BSON types; an enum needs exactly one` otherwise.
upgrading/extension/upgrades/8.0.0-rc.12-to-8.0.0-rc.13/scripts/execution-ref-entry-field/rename-execution-ref-keys.ts
// Renames `table`/`column` to `entry`/`field` in execution default refs of contracts under `migrations/`.
import { readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { basename, dirname, join, relative, sep } from 'node:path';
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist']);
const RENAMES = new Map([
['table', 'entry'],
['column', 'field'],
]);
const check = process.argv.includes('--check');
const projectRoot = process.cwd();
function isContractFile(name: string): boolean {
return name.endsWith('.json') || name.endsWith('.d.ts');
}
async function findMigrationFiles(dir: string, found: string[] = []): Promise<string[]> {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
await findMigrationFiles(path, found);
} else if (
entry.isFile() &&
isContractFile(entry.name) &&
relative(projectRoot, dir).split(sep).includes('migrations')
) {
found.push(path);
}
}
return found;
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function sortKeys(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortKeys);
if (!isObject(value)) return value;
const sorted: Record<string, unknown> = {};
for (const key of Object.keys(value).sort()) sorted[key] = sortKeys(value[key]);
return sorted;
}
function parseJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
function serializeLike(original: string, value: unknown): string {
if (original === `${JSON.stringify(sortKeys(parseJson(original)))}\n`) {
return `${JSON.stringify(sortKeys(value))}\n`;
}
const indent = /\n([ \t]+)/.exec(original)?.[1] ?? 2;
return `${JSON.stringify(value, null, indent)}${original.endsWith('\n') ? '\n' : ''}`;
}
function renameInJson(text: string): string | undefined {
const contract = parseJson(text);
if (contract === undefined) return undefined;
if (!isObject(contract) || !isObject(contract['execution'])) return text;
const mutations = contract['execution']['mutations'];
const defaults = isObject(mutations) ? mutations['defaults'] : undefined;
if (!Array.isArray(defaults)) return text;
let changed = false;
for (const entry of defaults) {
if (!isObject(entry) || !isObject(entry['ref'])) continue;
const keys = Object.keys(entry['ref']);
if (!keys.some((key) => RENAMES.has(key))) continue;
entry['ref'] = sortKeys(
Object.fromEntries(
Object.entries(entry['ref']).map(([key, value]) => [RENAMES.get(key) ?? key, value]),
),
);
changed = true;
}
return changed ? serializeLike(text, contract) : text;
}
const DEFAULT_ELEMENT_PATH = ['execution', 'mutations', 'defaults', ''];
const MEMBER_BEFORE_OPENER = /readonly (\w+): (?:readonly )?$/;
const REF_MEMBER = /readonly (\w+): ('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")/g;
interface OpenBlock {
readonly member: string;
readonly start: number;
readonly isDefaultRef: boolean;
}
interface Span {
readonly start: number;
readonly end: number;
}
function closingQuote(text: string, start: number): number {
let i = start + 1;
while (i < text.length && text[i] !== text[start]) i += text[i] === '\\' ? 2 : 1;
return i;
}
function commentEnd(text: string, start: number): number {
const end =
text[start + 1] === '/' ? text.indexOf('\n', start) : text.indexOf('*/', start + 2) + 1;
return end > start ? end : text.length;
}
function isDefaultElement(open: readonly OpenBlock[]): boolean {
const members = open.slice(-DEFAULT_ELEMENT_PATH.length).map((block) => block.member);
return members.join('/') === DEFAULT_ELEMENT_PATH.join('/');
}
function executionDefaultRefs(text: string): Span[] {
const open: OpenBlock[] = [];
const refs: Span[] = [];
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (char === "'" || char === '"' || char === '`') {
i = closingQuote(text, i);
} else if (char === '/' && (text[i + 1] === '/' || text[i + 1] === '*')) {
i = commentEnd(text, i);
} else if (char === '{' || char === '[') {
const member = MEMBER_BEFORE_OPENER.exec(text.slice(Math.max(0, i - 64), i))?.[1] ?? '';
const isDefaultRef = char === '{' && member === 'ref' && isDefaultElement(open);
open.push({ member, start: i, isDefaultRef });
} else if (char === '}' || char === ']') {
const block = open.pop();
if (block?.isDefaultRef) refs.push({ start: block.start, end: i + 1 });
}
}
return refs;
}
function renameRefMembers(block: string): string {
const members = [...block.matchAll(REF_MEMBER)];
if (!members.some(([, key = '']) => RENAMES.has(key))) return block;
const sorted = members
.map(([, key = '', value = '']) => `readonly ${RENAMES.get(key) ?? key}: ${value}`)
.sort();
let i = 0;
return block.replace(REF_MEMBER, () => sorted[i++] ?? '');
}
function renameInDts(text: string): string {
let renamed = text;
for (const { start, end } of executionDefaultRefs(text).reverse()) {
const block = renameRefMembers(renamed.slice(start, end));
renamed = `${renamed.slice(0, start)}${block}${renamed.slice(end)}`;
}
return renamed;
}
async function replaceFile(path: string, content: string): Promise<void> {
const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`);
try {
await writeFile(temporary, content, 'utf-8');
await rename(temporary, path);
} catch (error) {
await rm(temporary, { force: true });
throw error;
}
}
async function main(): Promise<void> {
const files = await findMigrationFiles(projectRoot);
let changed = 0;
let unparseable = 0;
for (const path of files.sort()) {
const before = await readFile(path, 'utf-8');
const after = path.endsWith('.json') ? renameInJson(before) : renameInDts(before);
if (after === undefined) {
unparseable += 1;
console.log(`NOT JSON ${relative(projectRoot, path)}`);
continue;
}
if (after === before) continue;
changed += 1;
console.log(`${check ? 'WOULD FIX' : 'FIXED'} ${relative(projectRoot, path)}`);
if (!check) await replaceFile(path, after);
}
console.log(
`${files.length} file(s) under migrations/ scanned, ${changed} ${check ? 'need' : 'got'} the rename.`,
);
if (unparseable > 0) {
console.log(`${unparseable} .json file(s) could not be parsed; check them by hand.`);
}
if (unparseable > 0 || (check && changed > 0)) process.exit(1);
}
void main();
upgrading/extension/upgrades/8.0.0-rc.13-to-8.0.0-rc.14/instructions.md
---
from: "8.0.0-rc.13"
to: "8.0.0-rc.14"
changes:
- id: engine-pin-moves-to-0-6-2
summary: |
The toolchain now peers `@prisma/cli-engine@0.6.2` (up from 0.6.1). An extension package that pins `@prisma/cli-engine` for its tests or tooling must move the pin to `0.6.2`. With this engine the CLI prints its own name in hints and messages where it used to print a literal `{bin}`.
detection:
glob: "**/package.json"
contains:
- '"@prisma/cli-engine": "0.6.1"'
- id: temporal-polyfill-is-a-peer-dependency
summary: |
`temporal-polyfill` is now a required peer dependency of `@prisma/orm-target-postgres` and `@prisma/orm-postgres`, not a dependency. The target's control entry imports it. An extension pack that peers the target does not declare it; the application supplies it. npm, pnpm and bun install it automatically. An extension package that installs with Yarn and loads the target's control entry in its tests or tooling must add `temporal-polyfill` (`^1.0.4`) to its `devDependencies`.
detection:
glob: "**/yarn.lock"
contains:
- "@prisma/orm-postgres@"
- "@prisma/orm-target-postgres@"
anyMatch: true
- id: mongo-result-shape-includes-and-value-objects
summary: |
`contractModelToMongoResultShape(model, options)` (`@prisma/orm-mongo/query-builder`,
`@internal/mongo-query-builder`) takes `includes`, a map from relation name to the shape of
the included document, instead of `includeRelationNames`, and an optional `valueObjects`
map that makes value-object fields decode as documents. `contractFieldToMongoFieldShape`
takes the same `valueObjects` as an optional second argument.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bincludeRelationNames\b'
- id: mongo-compile-query-value-objects
summary: |
`compileMongoQuery(collection, state, storageHash, model, valueObjects)`
(`@prisma/orm-mongo/orm`, `@internal/mongo-orm`) takes the contract's value objects as a
fifth argument, and each `MongoIncludeExpr` in the state carries the related model as
`targetModel`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bcompileMongoQuery\('
- id: psl-unknown-field-preset-lists-presets
summary: |
`reportUnknownFieldPreset(...)` (`@prisma/orm-framework/psl-parser/interpret`,
`@internal/psl-parser/interpret`) takes the `authoringContributions` it looks the namespace's
presets up in, and its message lists them.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\breportUnknownFieldPreset\('
- id: mongo-double-codec-encodes-double
summary: |
The `mongo/double@1` codec's `encode` (from `buildStandardCodecRegistry()` or
`mongoStandardCodecs` in `@prisma/orm-mongo/target/codecs`, `@internal/target-mongo/codecs`)
returns the driver's `Double` wrapping the number, so a whole number is stored as a BSON
double; it refuses a value that is not a number. The `mongo/int32@1` codec's `encode` refuses
a value that is not an integer in the signed 32-bit range. The `mongo/string@1`,
`mongo/bool@1`, `mongo/date@1`, `mongo/objectId@1` and `mongo/vector@1` codecs' `encode`
refuse a value of the wrong type, and `mongo/binary@1` decodes a `Buffer` or `Uint8Array` as
well as a `Binary`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- "['\"]mongo/(?:double|int32)@1['\"]"
- id: mongo-insert-results-carry-documents
summary: |
`InsertOneResult` and `InsertManyResult` (`@prisma/orm-mongo/query-ast/execution`,
`@internal/mongo-query-ast/execution`) carry the inserted documents as stored: `document` on
one, `documents` on the other, in insert order. A Mongo driver's `insertOne` and `insertMany`
commands must yield them; the ORM returns them, decoded, from `create()` and `createAll()`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'import[^;]*\bInsert(?:One|Many)Result\b[^;]*from\s*[''"]@(?:prisma/orm-mongo|internal/mongo-query-ast)/'
- id: mongo-adapter-passes-bson-values-through
summary: |
The Mongo adapter's `resolveValue` passes an instance of a `bson` class (an object with a
`_bsontype` tag and a class prototype), a `RegExp` and a `Uint8Array` through unchanged
instead of copying their fields into a plain object, at any depth of a command.
- id: mongo-codec-types-carry-traits
summary: |
Each entry of the Mongo `CodecTypes` map (`@prisma/orm-mongo/target/codec-types`,
`@internal/target-mongo/codec-types`) carries `traits`, the union of its codec descriptor's
traits. The ORM's field accessor gives `inc` and `mul` to a field whose codec has the
`numeric` trait, taking the codec's input type, and `FieldExpression` takes that operand
type as an optional second type parameter.
- id: mongo-field-builder-preset-not-optional
summary: |
The Mongo `FieldBuilder` (`@prisma/orm-mongo/contract-builder`,
`@internal/mongo-contract-ts/contract-builder`)'s `optional` and `many` are properties whose type refuses a call when
the builder carries execution defaults: a function whose `this` type is a string that says
why. The widest constraint,
`FieldBuilder<ContractFieldType, boolean, boolean, EnumTypeHandle | undefined, ExecutionMutationDefaultPhases | undefined>`,
still accepts every builder.
---
# 8.0.0-rc.13 → 8.0.0-rc.14 — Extension author upgrade instructions
## `engine-pin-moves-to-0-6-2`
For every `package.json` matched by `detection`, change the `@prisma/cli-engine` version from `0.6.1` to `0.6.2` and reinstall.
Engine 0.6.2 replaces the placeholder `{bin}` with the name of the CLI that was run. A hint that used to print as `{bin} db migrate` now prints as `prisma db migrate`. The replacement applies to next actions, warnings, errors, and summary and list text, in human, `--json`, and `--format markdown` output. Keep writing `{bin}` in the commands and messages your extension produces; the engine replaces it. If a test of yours runs the CLI and matches the literal text `{bin}` in its output, change it to match the CLI name.
## `temporal-polyfill-is-a-peer-dependency`
For each Yarn project that `detection` matches, add `"temporal-polyfill": "^1.0.4"` to `devDependencies` in the `package.json` that installs `@prisma/orm-target-postgres` or `@prisma/orm-postgres`, then reinstall. Without it, a test or script that loads the target's control entry fails because Node.js cannot find the package `temporal-polyfill`.
## `mongo-result-shape-includes-and-value-objects`
Replace `includeRelationNames: ['author']` with `includes: { author: shape }`, where `shape` describes the included document, for example `{ kind: 'document', nullable: true, fields }` for a to-one include with `fields` from `contractModelToMongoResultShape(targetModel, { valueObjects }).fields`, or an `array` of such documents for a to-many include. A shape of `{ kind: 'unknown' }` keeps the old behaviour of passing the included value through undecoded. Pass `valueObjects` (for example `domainValueObjectsAtDefaultNamespace(contract.domain)`) to decode value-object fields.
## `mongo-compile-query-value-objects`
Pass the contract's value objects, `domainValueObjectsAtDefaultNamespace(contract.domain) ?? {}`, as the fifth argument, and add `targetModel` (the related model's definition) to each include expression you build.
## `psl-unknown-field-preset-lists-presets`
Add the `authoringContributions` your interpreter already holds to each call.
## `mongo-double-codec-encodes-double`
Code that compares what the `mongo/double@1` codec's `encode` returns with a number unwraps it with `.valueOf()` or `Number(...)`. A test double or fixture that passed a fraction or an out-of-range number through the `mongo/int32@1` codec's `encode` now gets `RUNTIME.ENCODE_FAILED`; pass an integer. The same holds for a test double that passes a value of the wrong type through the string, boolean, date, ObjectId or vector codec, such as a number for an ObjectId; pass a 24-digit hex string or an `ObjectId`.
## `mongo-insert-results-carry-documents`
A driver of your own yields `{ insertedId, document }` for `insertOne`, where `document` is the command's document with the `_id` the database assigned, as the database stores it: serialise it with the database's BSON options and deserialise it again, as `BSON.deserialize(BSON.serialize(document, options), options)` does. It yields `{ insertedIds, insertedCount, documents }` for `insertMany`. Code that builds these results in a test double adds the same fields.
## `mongo-adapter-passes-bson-values-through`
This change has no detection pattern: it changes what reaches the driver, not how the adapter is called.
A codec or middleware that received a copied plain object in place of a `bson` class instance, and rebuilt the instance from it, now receives the instance. Remove the rebuilding.
## `mongo-codec-types-carry-traits`
This change has no detection pattern: few extensions build a Mongo codec types map themselves.
A type you declare to satisfy the Mongo `CodecTypes` shape, or a codec types map of your own that should get `inc` and `mul`, adds `traits` to each entry, such as `readonly traits: 'equality' | 'order' | 'numeric'` for a numeric codec.
## `mongo-field-builder-preset-not-optional`
This change has no detection pattern: few extensions implement `FieldBuilder` themselves. An object typed as a `FieldBuilder` that implements `optional()` and `many()` as methods assigns them through a cast to the conditional property types, as `createFieldBuilder` does, or builds the field with the contract builder's `field` helpers.
upgrading/extension/upgrades/8.0.0-rc.14-to-8.0.0-rc.15/instructions.md
---
from: "8.0.0-rc.14"
to: "8.0.0-rc.15"
changes:
- id: contract-stores-data-type
summary: |
A SQL contract names each column's data type in `dataType` (for example `pg/int4`) instead of
its database type name in `nativeType`. Run the colocated script on the extension's contract
space (it also writes lists in this release's new form), release the extension against the
framework version that contains this change, and raise its peer dependency floor to that
version: the old framework refuses a rewritten contract space and the new framework refuses
an old one, so your users upgrade the framework and the extension in one step. Publish a
`--data-type` line for each codec the extension owns.
detection:
glob: "**/*.json"
matches:
- '"nativeType"\s*:\s*"'
script: ./scripts/data-type-in-contract/data-type-in-contract.ts
- id: reemit-extension-list-contracts
summary: Re-emit bundled extension contracts and reconcile changed snapshot hashes and references; for SQL contract spaces, `contract-stores-data-type` rewrites the historical snapshots.
- id: consume-nested-list-cardinality
summary: Update contract producers and consumers for explicit cardinality and nested element nullability.
- id: sql-data-type-declares-names
summary: |
A SQL data type now declares how the database writes and reports it. Declare an extension's
SQL data types with `sqlDataType(id, { params, texts, ... })` from
`@internal/sql-contract/data-type` instead of `dataType(id, ...)`. Migrations, schema
verification and PostgreSQL parameter casts read the column type's name from this
declaration only.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\w$.])dataType\(\s*[''"][a-z0-9-]+/'
- id: codec-target-types-removed
summary: |
`targetTypes` is removed from codec descriptors and templates, with `CodecLookup.targetTypesFor`
and `CodecDescriptorRegistry.byTargetType`. A SQL codec's type names move to its data type's
`texts`; a Mongo codec's BSON types move to its data type, declared with
`mongoDataType(id, { bsonTypes })` from `@internal/mongo-contract/data-type`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\btargetTypes\b'
- '\btargetTypesFor\b'
- '\bbyTargetType\b'
- id: native-type-rendering-hooks-removed
summary: |
The rendering hooks are removed: `CodecControlHooks.expandNativeType`, the protected
`nativeType(params)` method of `PostgresCodecDescriptor` and its public `nativeTypeFor(ref)`,
`NativeTypeExpander`, and the control adapter's `normalizeNativeType`. The data type's
`texts` write the column type instead.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bexpandNativeType\b'
- '\bprotected\s+override\s+nativeType\s*\('
- '\bnativeTypeFor\b'
- '\bNativeTypeExpander\b'
- '\bnormalizeNativeType\b'
- id: comments-name-removed-apis
summary: |
Comments that describe `expandNativeType`, `targetTypes` or the `nativeType()` hook describe
code that no longer exists. Delete each such sentence, and say that a codec descriptor
carries its data type where a comment lists target types or a native type among its parts.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?://|\*).*\b(?:expandNativeType|targetTypes)\b'
- '(?://|\*).*\b(?:target types|native type|bare `nativeType)'
- id: postgres-codec-takes-data-type
summary: |
`postgresCodec(template, options)` and `sqliteCodec(template, options)` take the data type
object in `options.dataType`, not its id, and `postgresCodec` no longer takes a `nativeType`
option. The adapted codec's `paramsSchema` is the data type's `params`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\b(?:postgresCodec|sqliteCodec)\s*\([^;]*?\bdataType\s*:\s*[\w$]+\.id\b'
- '\bpostgresCodec\s*\([^;]*?\bnativeType\s*:'
- id: codec-params-schema-is-data-type-params
summary: |
A codec's `paramsSchema` is its data type's `params`, referenced and not restated. Move the
parameter schema and its bounds onto the data type, and point the codec at it.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bparamsSchema\b[^;=]*=\s*(?:arktype|type)\s*\('
- '\bconst\s+[\w$]*[pP]aramsSchema\s*=\s*(?:arktype|type)\s*\('
- id: column-helper-checks-data-type-params
summary: |
A column helper no longer checks its arguments against bounds of its own. Delete the check:
building the contract checks every column's parameters against its data type and raises
`CONTRACT.TYPE_PARAMS_INVALID`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '[''"]CONTRACT\.ARGUMENT_INVALID[''"][^;]*\b(?:must be|non-negative|in the range|in \[)'
- id: type-constructor-templates-lose-native-type
summary: |
A type constructor's or field preset's `output` no longer takes `nativeType`; it is
`{ codecId, typeParams? }`. An argument mapped onto a data type parameter drops its `minimum`
and `maximum`, because the data type's `params` checks it. A constructor may set
`inferred: true`; at most one per data type.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\boutput\s*:\s*\{[^{}]*\bnativeType\s*:'
- id: runtime-descriptor-registers-data-types
summary: |
A SQL runtime extension descriptor lists its `dataTypes`, the same list as its control
descriptor, and `createPostgresAdapter({ codecDescriptors })` takes the matching `dataTypes`.
The runtime writes PostgreSQL parameter casts from the data type a codec represents.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bSqlRuntimeExtensionDescriptor\s*<[^>]*>\s*=\s*\{(?=[^;]*\bcodecDescriptors\b)(?![^;]*\bdataTypes\b)'
- '\bcreatePostgresAdapter\s*\(\s*\{(?=[^}]*\bcodecDescriptors\b)(?![^}]*\bdataTypes\b)'
- id: column-descriptors-drop-native-type
summary: |
`ColumnTypeDescriptor` and authored `storage.types` entries lose `nativeType`, the pack
metadata loses its `types.storage` list and `StorageTypeMetadata` is deleted, `column()`
loses its fourth argument, and a codec descriptor's
`columnFromEntity` returns `{ typeParams }` only. The contract takes a column's data type from
its codec. A `types` constraint over what `type.*` helpers return is
`Record<string, AuthoredStorageTypeInstance>` instead of `Record<string, StorageTypeInstance>`.
`StorageTypeInstanceInput` takes `dataType`, the id of the data type the codec represents,
in place of `nativeType`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\w$])(?<!readonly\s+)nativeType\s*:'
- '(?<![\w$.])column\s*\((?:[^()]|\([^()]*\))*,(?:[^()]|\([^()]*\))*,(?:[^()]|\([^()]*\))*,\s*[\w''"](?:[^()]|\([^()]*\))*\)'
- '\bRecord\s*<\s*string\s*,\s*StorageTypeInstance\s*>'
- '\bStorageTypeInstanceInput\b'
- '\bStorageTypeMetadata\b'
- '\bstorage\s*:\s*\[\s*\{\s*typeId\b'
- id: sqlite-data-types-are-stored-types
summary: |
The SQLite target declares only the types SQLite stores, plus the two character types:
`sqlite/text`, `sqlite/integer`, `sqlite/real`, `sqlite/blob`, `sqlite/character` and
`sqlite/character-varying`. `sqlite/json`, `sqlite/datetime` and `sqlite/bigint` are deleted:
a codec, cast or authoring entry that names one fails assembly. The JSON and date-time codecs
represent `sqlite/text`, the big integer codecs `sqlite/integer`. The date-time canonical form
moves from `sqlite/datetime` to the codec `sqlite/datetime@1`, whose descriptor declares
`toCanonicalForm`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '[''"`]sqlite/(?:json|datetime|bigint)[''"`]'
- '\bsqlite(?:Json|Datetime|Bigint)\.id\b'
- id: canonical-form-of-a-column
summary: |
A column's canonical form comes from one function, `canonicalFormOf(codec, dataTypes)` in
`@internal/framework-components/codec`: the codec's `toCanonicalForm` when it declares one,
else its data type's. `CanonicalDateTimeOptions.dataTypeId` is renamed `ownerId`.
`SqlColumnDefaultIRInput` carries `toCanonicalForm` instead of `dataType`, and
`SqlColumnIRInput` gains `toCanonicalForm`. `DefaultMappingOptions.columnDataType` is
replaced by `columnCodec`, the column's codec descriptor.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bCanonicalDateTimeOptions\b'
- '\bcanonicalDateTime\s*\('
- '\bcolumnDataType\b'
- '\bSqlColumnDefaultIR\b'
- id: authoring-entry-key-checked
summary: |
Assembly refuses an authoring entry filed under the wrong key with
`CONTRACT.DATA_TYPE_ENTRY_KEY_INVALID`: an entry whose tag names the type it yields (`type`)
sits under `tagEntryKey(tag)`, and an entry under a data type's id names no `type`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bkind\s*:\s*[''"]tag[''"]'
- id: the-sql-tag-writes-the-sql-expression-data-type
summary: |
Lowering entries are removed. Every entry in `authoring.dataTypes` is a `DataTypeAuthoringEntry`
keyed by a registered data type id, and `sql` is the tag of the data type `sql/expression`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\b(loweringEntryKey|isLoweringEntryKey|isDataTypeLoweringEntry)\b'
- '\b(AuthoringDataTypeEntry|DataTypeLoweringAuthoringEntry|TaggedLiteralValue)\b'
- '\bsqlDefaultLiteralTagEntry\b'
- '\bcreate(Postgres|Sqlite)DataTypeEntries\b'
- '\bPSL_INVALID_DEFAULT_SQL\b'
- id: prefixed-sql-tags-are-removed
summary: |
The tags `pg.sql` and `sqlite.sql` are removed. Write `sql`.
detection:
glob: "**/*.{prisma,ts}"
matches:
- '(?<![\w./-])(pg|sqlite)\s*\.\s*sql\s*\\?[\x60"'']'
- id: default-diagnostic-codes-changed
summary: |
Four PSL diagnostic codes for written values changed: `PSL_UNKNOWN_DEFAULT_LITERAL_TAG` is now `PSL_UNKNOWN_LITERAL_TAG`, `PSL_INVALID_JSON_LITERAL` is now `PSL_INVALID_LITERAL`, `PSL_DEFAULT_TYPE_INCOMPATIBLE` is now `PSL_VALUE_TYPE_INCOMPATIBLE`, or `PSL_DEFAULT_LIST_EXPECTED` for a single value on a list column, and most cases of `PSL_INVALID_DEFAULT_LITERAL` moved to `PSL_INVALID_LITERAL`.
detection:
glob: "**/*.{ts,mts,cts,js,mjs}"
matches:
- '\bPSL_INVALID_JSON_LITERAL\b'
- '\bPSL_UNKNOWN_DEFAULT_LITERAL_TAG\b'
- '\bPSL_DEFAULT_TYPE_INCOMPATIBLE\b'
- '\bPSL_INVALID_DEFAULT_LITERAL\b'
- id: resolve-identity-value-receives-data-type
summary: |
`CodecControlHooks.resolveIdentityValue` receives `dataType`, the id of the data type the
column's codec represents, instead of `nativeType`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bresolveIdentityValue\s*:\s*\(\s*\{[^}]*\bnativeType\b'
- '\bResolveIdentityValueInput\b[^;]*\bnativeType\b'
- id: validate-scalar-type-codec-ids-removed
summary: |
`validateScalarTypeCodecIds` is removed. Stack assembly now refuses, with an `InternalError`,
a type constructor or field preset that names an unregistered codec, a constructor argument
mapped onto a parameter neither the data type nor the codec declares, and two constructors of
one data type marked `inferred`. The SQL family refuses two SQL data types that claim the same
reported type when it creates its control instance, so CLI commands report it and the
language server does not.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bvalidateScalarTypeCodecIds\b'
- id: assemble-data-types-moved-to-codec
summary: |
`assembleDataTypes` moves from `@internal/framework-components/control` to
`@internal/framework-components/codec`, because the runtime plane assembles data types too.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'import\s*\{[^}]*\bassembleDataTypes\b[^}]*\}\s*from\s*[''"]@internal/framework-components/control[''"]'
- id: number-text-helpers-moved
summary: |
`numeralText` moves from `@internal/sql-relational-core/ast` to
`@internal/sql-contract/data-type`, beside the SQL data type declarations that use it.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bnumeralText\b'
- id: data-type-support-moved
summary: |
The helpers SQL targets use to implement their data types and PSL entries move from
`@internal/sql-relational-core/ast` to `@internal/sql-contract/data-type-support`:
`escapePslString`, `isNumeralText`, `canonicalNumeralText`, `integerTextCanonicalForm`,
`signedRange`, `createNumberClassifier`, `parseJsonBody`, `printJsonBody`,
`canonicalDateTime`, and the types `NumberClassification`, `IntegerStep`,
`NumberClassifierSpec`, `DateTimeShape` and `CanonicalDateTimeOptions`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'import\s+(?:type\s+)?\{[^}]*\b(?:escapePslString|isNumeralText|canonicalNumeralText|integerTextCanonicalForm|signedRange|createNumberClassifier|parseJsonBody|printJsonBody|canonicalDateTime|NumberClassification|IntegerStep|NumberClassifierSpec|DateTimeShape|CanonicalDateTimeOptions)\b[^}]*\}\s*from\s*[''"]@internal/sql-relational-core/ast[''"]'
- id: numeric-limits-removed
summary: |
`NUMERIC_PRECISION_RANGE` and `NUMERIC_SCALE_RANGE` are no longer exported from
`@internal/target-postgres/codecs`. The bounds of `numeric` are written in the `pg/numeric`
data type's parameter schema, `pgNumericParams` from `@internal/target-postgres/data-types`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bNUMERIC_(?:PRECISION|SCALE)_RANGE\b'
- id: postgres-codecs-decode-server-text
summary: The Postgres runtime driver now returns every column as the server's text output. A Postgres codec's `decode` receives that text for its type instead of the value `pg` used to parse, and direct `driver.query` callers receive strings.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$])(?:PostgresCodecDescriptor|definePostgresCodecs)(?![\w$])'
- '@internal/driver-postgres/runtime[\s\S]*\.query\('
- id: codecs-decode-json-reads-stored-forms
summary: |
A built-in codec's `decodeJson` now throws `RUNTIME.DECODE_FAILED` for JSON that is not a stored form of its type. Pass it the stored form. A codec an extension contributes should read the same way: every form the database writes for its type, and nothing else.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bdecodeJson\b'
- id: mongo-codec-requires-decode-json
summary: |
`mongoCodec` now requires `decodeJson` unless the codec's application type is exactly `JsonValue`. Add a `decodeJson` that refuses JSON of another kind with `RUNTIME.DECODE_FAILED`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bmongoCodec\s*\('
- id: sql-float-json-helpers-removed
summary: |
`sqlFloatEncodeJson`, `sqlFloatDecodeJson` and `isNonFiniteText` are no longer exported from `@internal/sql-relational-core/ast`. Use `encodeJsonFloat`, `decodeJsonFloat(codecId, json)` and `isNonFiniteText` from `@internal/framework-components/codec`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bsqlFloat(?:En|De)codeJson\b'
- '\bisNonFiniteText\b'
- id: codec-lookup-has-no-descriptor-for
summary: |
`CodecLookup` no longer has `descriptorFor`. A lookup that builds a column's codec is a `CodecLookupWithDescriptors`, whose `descriptorFor` is required: the `codecLookup` option of `defineContract`, `ContractSourceContext.codecLookup` and `CodecRegistry`. `emptyCodecLookup` is a plain `CodecLookup`. Type such a lookup `CodecLookupWithDescriptors` and give it a `descriptorFor` that answers from the same codecs as `get`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bCodecLookup\b'
- '\bemptyCodecLookup\b'
- '\bcodecLookup\s*:'
- '\bdescriptorFor\?\.'
- id: mongo-codecs-check-json
summary: |
The built-in Mongo codecs now refuse a JSON value that is not the JSON form of their type, where most passed it through: a PSL enum member whose value its `@@type` codec does not take is now refused at `contract emit`, and a TypeScript `enumType` member that `mongo/objectId@1` or `mongo/int32@1` does not hold is refused when the contract is built. Correct the member.
detection:
glob: "**/*.{prisma,ts,mts,cts,tsx}"
matches:
- '@@type\(\s*"mongo/'
- '[''"]mongo/(?:objectId|int32)@1[''"]'
- '\bMONGO_(?:OBJECTID|INT32)_CODEC_ID\b'
- id: text-array-elements-nullable
summary: |
A `textArray()` column's elements are now typed `string | null`, because a `text[]` holds NULL elements, which it reads as `null`. Handle the `null`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\btextArray\s*\('
- '[''"]pg/text-array@1[''"]'
- id: char-reads-drop-only-padding
summary: |
A `char(n)` column now reads the same through `.include()` as through a flat read: without the trailing spaces that pad it, where an include used to return them, and keeping a trailing tab or newline, which a flat read used to drop. Compare `char` values without their padding.
detection:
glob: "**/contract.json"
matches:
- '"codecId"\s*:\s*"(?:sql|pg)/char@1"'
- id: sqlite-nan-parameters-refused
summary: |
On SQLite, NaN written to a float column or used as a filter value now throws `RUNTIME.ENCODE_FAILED` naming the codec, where SQLite stored NULL or matched nothing. Write `null` for no value.
detection:
glob: "**/contract.json"
matches:
- '"target"\s*:\s*"sqlite"'
- id: sqlite-int-include-refuses-inexact-values
summary: |
On SQLite, an `.include()` of a row whose `sql/int@1` column holds an INTEGER past 2^53, or a REAL, now throws `RUNTIME.DECODE_FAILED`, where it read the value rounded or with a fraction. Store such values in a `BigInt` or `Float` column.
detection:
glob: "**/contract.json"
matches:
- '"codecId"\s*:\s*"sql/int@1"'
- id: contract-build-takes-lookups
summary: |
`buildSqlContractFromDefinition` and the `defineContract` of `@internal/sql-contract-ts`
require `codecLookup` and `dataTypeLookup`. The Postgres and SQLite `defineContract` facades
build both from the target and `extensions`, so a TypeScript contract that uses an extension's
codec must list that extension, or pass lookups that hold its codecs and data types.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bbuildSqlContractFromDefinition\s*\('
- '\bdefineContract\s*\(\s*\{\s*\}'
- id: define-contract-wrapper-builds-data-type-lookup
summary: |
A package that wraps `buildBoundContract` in its own `defineContract` must pass a
`dataTypeLookup` built from its target pack and the listed extensions, next to the
`codecLookup` it already passes, and accept both as optional overrides.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bbuildBoundContract\b[^;]*from\s*[''"]@internal/sql-contract-ts/'
- id: postgres-codec-registry-takes-data-type-lookup
summary: |
The Postgres codec registry no longer carries the data types. `assemblePostgresCodecRegistry`,
`assemblePostgresCodecRegistryWithBuiltins`, `createPostgresCodecRegistryWithBuiltins`,
`createPostgresAdapterWithCodecRegistry` and the `PostgresControlAdapter` constructor take a
data type lookup beside the codecs, and refuse a codec whose data type it lacks.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\b(?:assemblePostgresCodecRegistry(?:WithBuiltins)?|createPostgresCodecRegistryWithBuiltins|createPostgresAdapterWithCodecRegistry)\s*\('
- '\bnew\s+PostgresControlAdapter\s*\('
- id: contract-to-schema-takes-components
summary: |
The migrations capability's `contractToSchema(contract, frameworkComponents)` requires
`frameworkComponents`; the codecs and data types in them name each column's type.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bcontractToSchema\s*\(\s*[^,()]+\)'
- id: contract-to-schema-ir-takes-lookups
summary: |
`contractToSchemaIR` options replace `expandNativeType` with the required `dataTypeLookup`
and `codecLookup`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bcontractToSchemaIR\s*\('
- id: authoring-entity-context-takes-data-type-lookup
summary: |
`AuthoringEntityContext` gains a required `dataTypeLookup`, the stack's data types, and its
`codecLookup` becomes a required `CodecLookupWithDescriptors`. Code that builds a context
passes both. The `codecLookup` input of `interpretPslDocumentToMongoContract` becomes a required
`CodecLookupWithDescriptors` too.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- ':\s*AuthoringEntityContext\s*=\s*\{'
- id: mongo-derive-json-schema-takes-lookups
summary: |
`deriveJsonSchema` and `derivePolymorphicJsonSchema` (from `@internal/mongo-contract-psl`)
take a required `lookups: MongoTypeLookups` (from `@internal/mongo-contract/data-type`)
directly after their fields: `{ codecLookup, dataTypeLookup }`. The optional `codecLookup`
argument is removed; the codec lookup moves into `lookups`, and the validator reads each
field's BSON types from the data type its codec represents. Extensions that author Mongo
contracts through `defineContract(...)` or PSL need no change.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bderiveJsonSchema\s*\('
- '\bderivePolymorphicJsonSchema\s*\('
- id: field-type-params-come-from-the-domain-type
summary: |
A field's type parameters now come from its domain type only. `EmissionSpi.resolveFieldTypeParams` is removed, `generateFieldOutputTypesMap` from `@internal/emitter` takes `(models, codecLookup)`, and `buildSqlContractFromDefinition` reads a field's type parameters from its descriptor. Drop the hook and the resolver argument, and give a value-object field its column's descriptor.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bresolveFieldTypeParams\b'
- '\bgenerateFieldOutputTypesMap\s*\('
- '\bbuildSqlContractFromDefinition\s*\('
- id: sql-infer-psl-contract-takes-build-context
summary: |
A SQL target's `inferPslContract` hook now takes the stack's PSL build context as its second parameter, `(schema, context, describedContracts?)`, as `buildPslContract` does. A target that implements it accepts the context and reads type constructors and codecs from it. Code that called the hook on a target descriptor calls it on the SQL family instance instead, which supplies the context.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\binferPslContract\b'
- id: imported-postgres-field-checks-defaults
summary: |
The `field` exported by the Postgres facade's `contract-builder` entry now has the Postgres presets and checks a `.default(...)` value against the Postgres target's column types, as the `defineContract` callback's `field` does; it does not know the column types an extension adds, such as pgvector's, so it does not check those. A default of the wrong type, which compiled before and failed when the contract was built, is now a compile error; give the value the column's type.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'import\s*\{[^}]*\bfield\b[^}]*\}\s*from\s*[''"]@(?:prisma/orm-|internal/)postgres/contract-builder[''"]'
- id: codecs-check-stored-json
summary: |
A TypeScript `.default()` value or `enumType` member that its column's codec does not take is now refused when the contract is built, with `CONTRACT.DEFAULT_INVALID` or `CONTRACT.ENUM_INVALID`. A `contract.json` that holds such a default stops `db init`, `db update` and `migration plan` with `CONTRACT.DEFAULT_INVALID`, and a `migration.ts` that holds one fails when it runs. Correct the value the error names.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\.default\(\s*(?!now\(|sql`|autoincrement\()'
- '\benumType\s*\('
- id: psl-values-checked-by-codecs
summary: |
A PSL schema whose SQL enum member its codec does not take, or whose literal default its column's type does not hold, is now refused at `contract emit`, where it used to load. Correct the member or the default.
detection:
glob: "**/*.prisma"
matches:
- '@@type\(\s*"(?:pg|sql|sqlite)/'
- '^\s*\w+\s*=\s*-?\d{10,}\s*$'
- '@default\(\s*(?:"|-?\d|\[)'
- id: value-object-default-matches-composite-type
summary: |
A literal default on a field typed by a composite type must now match the composite type, with each member value read by the member's codec and each enum member value one of the enum's values, or the schema is refused. Fix the default the diagnostic names.
detection:
glob: "**/*.prisma"
matches:
- '^\s*type\s+\w+\s*\{'
- id: composite-type-attributes-refused
summary: |
An attribute on a composite type or on one of its members is now refused, where it used to be ignored. Remove it.
detection:
glob: "**/*.prisma"
matches:
- '^\s*type\s+\w+\s*\{'
- id: uuid-defaults-stored-as-postgresql-writes
summary: |
A uuid default written in upper case, in braces or without hyphens, in PSL or in a TypeScript `.default()`, is now stored as PostgreSQL writes it, so emitting the contract again changes its storage hash. Earlier versions could not apply such a contract: the command that applied it failed and changed nothing. Emit the contract again, then run that command again. With migrations, first delete the migration package that never applied.
detection:
glob: "**/*.{prisma,ts,mts,cts,tsx}"
matches:
- '\bUuid\b[^\n]*@default\(\s*"(?![0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")\{?[0-9A-Fa-f]{4}'
- '\b(?:uuidNative|pgUuidColumn)\s*\([^\n]*\.default\(\s*[''"`](?![0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}[''"`])\{?[0-9A-Fa-f]{4}'
- '^\s*\.default\(\s*[''"`](?![0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}[''"`])(?=[^''"`\n]*[A-F{-])\{?[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}\}?[''"`]'
- id: domain-types-match-their-columns
summary: |
The domain half of an emitted SQL contract now carries the type parameters and enum value sets the schema declares: on fields typed by a named type, on enum list fields, and on composite type members. In `contract.d.ts`, a composite type member with type parameters now has the parameterized output type. Re-emit the contract. This change leaves the storage half, every hash and migration snapshots unchanged.
detection:
glob: "**/contract.json"
matches:
- '"typeRef"\s*:'
- '"valueObjects"\s*:'
- '"valueSet"\s*:'
- id: default-renderer-receives-data-type
summary: |
`DefaultRenderer` receives a third argument, `{ dataType, baseTypeName }`: the id of the data
type the column's codec represents, and its base name (the written name without
parameters). The Postgres `renderDefaultLiteral` takes `{ many, baseTypeName, dataType }`
instead of `{ many?, nativeType, dataTypeId? }`, and `dataType` is required.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bDefaultRenderer\b'
- '\brenderDefaultLiteral\b'
- '\bDefaultColumn\b'
- id: ddl-column-default-visitor-removed
summary: |
`DdlColumnDefaultVisitor`, `DdlColumnRenderContext` and the `accept` method of
`LiteralColumnDefault` and `FunctionColumnDefault` are removed. Nothing in Prisma Next called
them.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bDdlColumnDefaultVisitor\b'
- '\bDdlColumnRenderContext\b'
- id: ddl-nodes-hold-opaque-sql
summary: |
`FunctionColumnDefault`, `CheckExpressionConstraint`, `PostgresCreatePolicy` and `PostgresCreateIndex` hold their SQL as an `OpaqueSql` value instead of a string, and `DdlIndexElements` changed with them. Wrap the string with `opaqueSql(...)` when you construct one, read `.text` where you read the SQL, and render it with `renderOpaqueSql(...)`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bnew\s+(?:[\w$]+\.)?(FunctionColumnDefault|CheckExpressionConstraint|PostgresCreatePolicy|PostgresCreateIndex)\s*\('
- id: adapter-writes-column-defaults
summary: |
The control adapter writes every column's `DEFAULT …` clause, through a new required method, `renderColumnDefault(column, table)`, on `ExecuteRequestLowerer` and `SqlControlAdapter` (`family/control-adapter`). An adapter, and any fake lowerer in tests, must implement it. `buildColumnDefaultSql` is removed from `target/planner-ddl-builders`: build the column and call `renderColumnDefault`. `SetDefaultCall` (`target/op-factory-call`) takes the column, `new SetDefaultCall(schema, table, column, operationClass)`, instead of its name and `defaultSql`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bbuildColumnDefaultSql\b'
- '\bnew\s+SetDefaultCall\s*\('
- '(?<![.\w])lowerToExecuteRequest\s*[(:]'
- '\bimplements\b[^{]*\b(?:ExecuteRequestLowerer|SqlControlAdapter)\b'
- id: migration-ts-column-defaults
summary: |
In `migration.ts`, the adapter writes every column default, reading it with the column's codec. Postgres `setDefault` takes the column as `col(name, type, { default, codecRef })` instead of `column` (the name) and `defaultSql`. A SQLite `addColumn` or `recreateTable` column carries `default` and `codecRef` instead of `defaultSql`, and a `recreateTable` postcheck for a default is `{ description, columnDefault }`. An earlier `migration.ts` that uses `defaultSql` no longer compiles, and running it with `node migration.ts` stops with `MIGRATION.OPERATION_OPTION_REMOVED`; its `ops.json` still applies.
detection:
glob: "**/migration.ts"
matches:
- '\bdefaultSql\s*:'
- id: rename-check-constraint-call-is-rename-constraint-call
summary: Replace `RenameCheckConstraintCall` with `RenameConstraintCall`, which takes the constraint kind as a new third constructor argument; pass `'checkConstraint'` for a check constraint.
detection:
glob: "**/*.{ts,mts,cts,js,mjs,cjs}"
matches:
- '(?<![\w$])RenameCheckConstraintCall(?![\w$])'
- 'factoryName\s*[!=]==?\s*["'']renameCheckConstraint["'']'
- '["'']renameCheckConstraint["'']\s*[!=]==?\s*[\w$.?]*factoryName'
- 'case\s+["'']renameCheckConstraint["'']\s*:'
- id: control-family-instance-sign-spaces
summary: |
`ControlFamilyInstance` has a new required method, `signSpaces({ driver, spaces })`, which
writes the marker of every contract space it is given. `db sign` calls it once for all
spaces, each with the marker `db sign` read before verifying it (`expected`), and
writes a marker only while it still holds those hashes. `SqlControlAdapter` gains
`lockMarker`. `ControlFamilyInstance.sign`, `ControlClient.sign`, `SignOptions` and
`SignDatabaseResult` are removed.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bControlFamilyInstance\s*<'
- '\bSignDatabaseResult\b'
- '\bSignOptions\b'
- id: db-sign-signs-extension-spaces
summary: |
`db sign` now signs every contract space, the extension's included, and only a space whose
schema verifies. Documentation that describes `db sign` for an extension's space says so.
detection:
glob: "**/*.md"
matches:
- '\bdb sign\b'
- '\bfails verify and cannot repair it\b'
- id: cli-error-from-caught
summary: |
`mapCaughtMigrationError` is removed from `@prisma/orm-toolchain/cli/control-api` (`@internal/cli/control-api`). Use `errorFromCaught(error, why)`, which always returns an error: a CLI error as it is, any error with a structured code as itself, and anything else as `CLI.UNEXPECTED` with `why(message)`. It throws an `InternalError` again.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bmapCaughtMigrationError\b'
- id: adapter-control-loads-temporal-polyfill
summary: |
The adapter's control entry, `adapter/control` of `@prisma/orm-postgres` and `@prisma/orm-target-postgres`, now loads `temporal-polyfill` too, as the target's control entry does since `temporal-polyfill-is-a-peer-dependency`. A Yarn project that added the polyfill for that change needs nothing more. An extension package that installs with Yarn and loads only the adapter's control entry in its tests or tooling must add `temporal-polyfill` (`^1.0.4`) to its `devDependencies`.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs}"
matches:
- '[''"]@prisma/orm-(?:target-)?postgres/adapter/control[''"]'
- id: parameter-casts-use-base-names
summary: |
PostgreSQL parameter casts are written with the data type's base name: `$1::int4` instead of
`$1::integer`, and likewise `int2`, `int8`, `float4`, `float8` and `bool` instead of
`smallint`, `bigint`, `real`, `double precision` and `boolean`. Tests that assert query text
change to match.
detection:
glob: "**/*.{ts,mts,cts,sql,json,snap}"
matches:
- '::(?:integer|smallint|bigint|real|double precision|boolean)\b'
- id: sql-builder-reads-codec-descriptors
summary: The sql-builder lane now reads `codecDescriptors` from the `ExecutionContext` passed to `sql()`. A hand-built test context must provide it.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'as unknown as ExecutionContext'
- id: select-ast-options-carry-locking
summary: "SelectAstOptions has a new required key, locking; when rebuilding a select from an existing SelectAst, carry ast.locking so a row lock is not dropped."
detection:
glob: "**/*.ts"
contains:
- "new SelectAst("
- id: render-lowered-sql-takes-capabilities
summary: "renderLoweredSql from @internal/adapter-postgres/sql-renderer takes two new required arguments after the codec descriptor registry: the data type lookup, and the capability matrix to check locking clauses against; pass postgresAdapterCapabilities from @internal/adapter-postgres/adapter."
detection:
glob: "**/*.ts"
contains:
- "renderLoweredSql("
- id: sql-runtime-close-refusal-option
summary: "The options of SqlRuntimeBase and its subclasses (RuntimeOptions, for example new PostgresRuntimeImpl({ ... })) have a new optional key closeRefusal: 'when-idle' | 'at-once'. Pass 'at-once' for a runtime that many callers share; leaving it out means 'when-idle', which suits a runtime that one request owns."
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'new \w*RuntimeImpl\('
- 'RuntimeOptions'
- id: writes-on-a-conditional-collection-are-refused
summary: |
A write (`update`, `updateAll`, `updateAndCount`, `delete`, `deleteAll`, `deleteAndCount`) on a collection that is filtered on some code paths and not on others no longer compiles. A pattern cannot tell which collections those are: act only where the compiler reports "The 'this' context of type '...' is not assignable to method's 'this' of type 'HasWhere'". Filter on every path, or make the write only where the filter was applied.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\.(?:updateAll|updateAndCount|deleteAll|deleteAndCount)\s*\('
- '\.update\s*\('
- '\.delete\s*\(\s*(?:\)|\()'
- id: writes-refuse-what-they-would-ignore
summary: |
`updateAll`, `updateAndCount`, `deleteAll` and `deleteAndCount` now throw `ORM.ARGUMENT_INVALID` on a collection that has a `limit`, an `offset`, a `cursor`, `distinct` or `distinctOn`. These writes change every row that matches the filter; their statement cannot apply any of these, so they were ignored and more rows changed than the chain asked for. Remove them before the write, or read the rows first and change them by their ids. `update` with a relation callback now throws on a collection with an order, a limit, an offset, a cursor, `distinct` or `distinctOn`, which it ignored; filter it to the one row instead. `update` and `delete` without a relation callback change the row `first()` returns, as before, except after `limit(0)`: they changed one row and now change none and return `null`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\.(?:limit|offset|cursor|distinct|distinctOn)\s*\([^)]*\)[\s\S]{0,300}?\.(?:update|updateAll|updateAndCount|delete|deleteAll|deleteAndCount)\s*\('
- '\.orderBy\s*\([\s\S]{0,300}?\.update\s*\('
- id: cursor-and-distinct-on-check-the-receiver
summary: |
`cursor` and `distinctOn` now require an order on the collection they are called on, checked on the receiver. A cast on the argument, such as `cursor({ id } as never)`, no longer bypasses the check; add the `orderBy`, or cast the collection to `Ordered<C>` where the query is meant to have no order.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\.(?:cursor|distinctOn)\s*\([^)]*\bas\s+never\b'
- id: apply-is-a-collection-member
summary: |
Every collection now has an `apply` method. A custom collection class that declares its own `apply` member with another signature no longer compiles; rename it.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?:^|\n)[ \t]*(?:(?:public|protected|private|readonly|static|async|override)\s+)*apply\s*[(<:=?]'
- id: scope-is-a-collection-member
summary: |
Every collection now has a `scope` method. A custom collection class that declares its own `scope` member with another signature no longer compiles; rename it.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?:^|\n)[ \t]*(?:(?:public|protected|private|readonly|static|async|override|get|set)\s+)*scope\s*[!(<:=?]'
- id: overriding-a-chaining-method
summary: |
In a class that extends `Collection`, an override of a chaining method (`where`, `orderBy`, `limit`, `offset`, `distinct`, `distinctOn`, `cursor`, `include`) or of a method that returns rows (`all`, `first`, `create`, `createAll`, `upsert`, `update`, `updateAll`, `updateAndCount`, `delete`, `deleteAll`, `deleteAndCount`) must use the new signature, which takes a `this` parameter.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?:^|\n)[ \t]*(?:(?:public|protected|override|async)\s+)*(?:where|orderBy|limit|offset|distinct|distinctOn|cursor|include|all|first|create|createAll|upsert|update|updateAll|updateAndCount|delete|deleteAll|deleteAndCount)\s*[(<]'
- id: collection-state-flags-are-boolean
summary: |
In `DefaultCollectionTypeState`, `hasWhere`, `hasOrderBy` and `hasUniqueFilter` are `boolean` (not known) instead of `false`. Code that expects `false` on a collection with no filter or order must expect `boolean`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bhas(?:Where|OrderBy|UniqueFilter)\b[''"]?\]?\s*,\s*false\b'
- '\bhas(?:Where|OrderBy|UniqueFilter)\s*:\s*false\b'
- id: read-collection-state-and-row-with-helpers
summary: |
A collection's type state and row are read with `CollectionTypeStateOf<C>` and `CollectionRowOf<C>`, not by inferring the type arguments of `Collection`. The type arguments keep what the collection started with.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bCollection<[^;]*?\binfer\b'
- id: return-type-of-a-chaining-method
summary: |
`ReturnType` of `where`, `orderBy`, `limit`, `offset`, `distinct`, `distinctOn`, `cursor` or `include` no longer gives a collection. Write `Filtered<C>` after `where`, `Ordered<C>` after `orderBy`, and `C` after the others, or take `typeof` of a value.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bReturnType<[^>]*\[[''"](?:where|orderBy|limit|offset|distinct|distinctOn|cursor|include)[''"]\]'
- '\bReturnType<\s*typeof\s+[\w$.]+\.(?:where|orderBy|limit|offset|distinct|distinctOn|cursor|include)\b'
- id: chaining-methods-take-no-explicit-type-arguments
summary: |
`include`, `distinct` and `distinctOn` with explicit type arguments no longer compile: `posts.include<'user'>('user')` and `posts.distinct<['title']>('title')` fail, and `ReturnType<typeof posts.include<'user'>>` is `never`. Drop the type arguments; they are inferred from the arguments, so `posts.distinct('title')` needs none.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\.(?:include|distinct|distinctOn)<'
- id: custom-collection-methods-chain
summary: |
Optional. Custom collection methods now stay available after the built-in chaining methods. Where code repeats a class method's body inline after a chaining call, it can call the method instead.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bextends\s+Collection<'
- id: variant-takes-discriminator-value
summary: |
`.variant()` on a polymorphic SQL or Mongo ORM collection takes the discriminator value a variant declares instead of the variant's model name: `db.orm.public.Task.variant('bug')` for `@@base(Task, "bug")`, where it used to be `.variant('Bug')`. A value the model does not declare, or a call on a model with no discriminator, now throws `ORM.ARGUMENT_INVALID` instead of returning the collection unchanged. `.variant()` on a collection that already has a variant selected is now rejected: select the variant from the base collection.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\.variant\('
---
# 8.0.0-rc.14 → 8.0.0-rc.15 — Extension author upgrade instructions
Apply the sections in this order:
1. Run the script of `contract-stores-data-type` first, before any step of this release that re-emits a contract. It rewrites only contracts in the old format.
2. Make the code changes in the sections that follow.
3. Before you re-emit, sign each database the extension's tests or tooling keep with `prisma db sign`, as `contract-stores-data-type` describes: `db sign` must see the contract the database was created from before a re-emit changes it again.
4. Re-emit the extension's contracts and test fixtures with `prisma contract emit` (`build:contract-space`) once, after the code changes. Several sections ask for it: `reemit-extension-list-contracts`, `consume-nested-list-cardinality`, `domain-types-match-their-columns` and `uuid-defaults-stored-as-postgresql-writes`. Where the re-emit changes the schema, plan and apply a migration as those sections say.
After the code changes, delete imports and constants that are no longer used, and run the package's formatter so imports are sorted.
## `contract-stores-data-type`
Do this before any other step of this release that re-emits a contract: the script changes only files in the old format, and `prisma db sign` must see the contract the database was created from before a re-emit changes it again. Commit your work first, so the script's changes can be reviewed and undone with git. The script follows links to files and directories, also outside the root, and rewrites the files there; commit or back up those too. Then run the script that sits next to this guide from the extension package's root. `<skill>` is the directory of the synced `prisma-8` skill:
```sh
node <skill>/upgrading/extension/upgrades/8.0.0-rc.14-to-8.0.0-rc.15/scripts/data-type-in-contract/data-type-in-contract.ts
```
Node 24 or later runs the TypeScript script directly; it needs no `tsx`. It reads and writes files only and needs no database. It rewrites every `*.json` file under the root that parses as a SQL contract in the old format (a column or `storage.types` entry that stores `nativeType`), skipping `node_modules`, `.git`, `dist` and `build`. That includes a test fixture of an old-format contract: if you keep such a fixture on purpose, restore it with git afterwards (`git restore <file>`), or keep it outside the project root. In each contract it rewrites, it also writes lists in this release's new form (`consume-nested-list-cardinality`): a list column or field stored as `many: true` becomes `many: { elementNullable: false }`, and `contract.d.ts` declares `many` on every column. This is the SQL part of `reemit-extension-list-contracts`.
On PostgreSQL, a list column of an enum keeps the membership check its database has, because the database still holds that check. Run `prisma db sign` before you emit the contract again. The next `prisma contract emit` writes the check as `array_remove(…) <@ ARRAY[…]` under a new name; `prisma migration plan` then writes a migration that drops the old check and adds the new one, and `prisma db migrate` applies it. The plan warns that dropping the old check may lose data; it does not, because only the check changes, not the column. If you already emitted before signing, `db sign` reports the new check as missing: sign each database with the storage hash the script printed instead (`prisma db sign <new hash>`), then plan and migrate as above.
If the script stops on an error, for example on a full disk, it prints the error and `the upgrade stopped partway, run the script again to finish it`, and exits 1. After an error, Ctrl-C or a crash, run it again: it finishes the upgrade. A file the script was writing at that moment is either unchanged or complete, and it removes its own temporary files (ending in `.data-type-in-contract-tmp`) on the next run.
Run your formatter afterwards. The script replaces text in `migration.ts` and `contract.d.ts`, so the import order in `migration.ts` and the line wrapping in `contract.d.ts` can differ from what a fresh emit and your formatter produce.
When it finishes, it prints how many files it rewrote and how many snapshot directories it renamed, and each storage hash it replaced (`<old> -> <new>`). A contract already in the new format is never changed, even when its stored hash does not match its content, so a project already in the new format is left unchanged, and the script says that nothing changed. If it finds no SQL contract under the root, it says so; run it again from the project root. It prints `<file>: stored hash did not recompute; rehashed from content` for an old-format contract whose stored storage hash does not match its content, and rewrites it anyway. It changes no file and exits 1 when a column uses a codec it does not know (`<file>: unknown codec <id>; name its data type with --data-type <id>=<data type id>`) or when a renamed snapshot directory already exists with different content.
The script knows every codec that Prisma and its own extensions ship. For each codec your extension owns, run the script with `--data-type <codec id>=<data type id>`, naming the data type that codec represents, for example `--data-type acme/shape@1=acme/shape`. Publish those lines in your release notes: your users pass the same options when they run the script on their projects. The option cannot change the data type of a codec the script already knows for a contract's target, but it can name the data type of a shared `sql/*` codec on a target the script does not know.
Release the extension with the rewritten contract space against the framework version that contains this change, and raise the extension's peer dependency floor on the framework to that version. The two versions cannot be mixed: the old framework refuses a contract space that stores `dataType`, and the new framework refuses one that still stores `nativeType`. So a release published early cannot be installed before the framework upgrade. Tell your users to upgrade the framework and your extension in the same step.
## `reemit-extension-list-contracts`
Regenerate bundled `contract.json` and `contract.d.ts` together from the extension's original authoring source and configuration, including each owned contract space. Use the existing contract-space emission command rather than hand-editing generated fields or hashes. For the Supabase extension in this repository, `pnpm --filter @internal/extension-supabase build:contract-space` regenerates `src/contract/contract.json` and `src/contract/contract.d.ts`; its separate `emit` script targets test fixtures, not this bundled contract.
Preserve existing strict-element declarations and explicit `elementNotNull` waivers. In particular, a Supabase native-array column with `noCheck: ['elementNotNull']` remains `many: { elementNullable: false }`, not a nullable-element list. Scalar domain JSON continues to omit `many: false`; list descriptors change, and generated contract hashes may change. Retaining old hash literals is not a valid regeneration. Scalar domain JSON and declarations omit `many: false`; SQL storage-column declarations retain required cardinality.
For SQL contract spaces, the script of `contract-stores-data-type` already writes the list form and refreshes hashes and snapshot names, so historical SQL snapshots need no re-emission; MongoDB contract spaces still do.
For extension-owned MongoDB migration snapshots, regenerate each historical state from its own source, not the latest extension schema. Write the JSON/declaration pair through the snapshot store and regenerate dependent migration metadata in dependency order. If a snapshot hash changes, preserve the old entry while it is referenced, create the new content-addressed entry, and update both JSON/type imports, start/end contract hashes, and derived migration identifiers and parent references consistently. Preserve recorded operations unless intentionally changing the schema. Do not blanket-rename hash paths, drop migration references, or rewrite applied history. Coordinate already-applied migration identities and database contract markers through the consumer project's supported migration procedure and verify the transition on a disposable database. Stop if historical sources or the reference mapping cannot be recovered.
## `consume-nested-list-cardinality`
`ContractField.many` and `StorageColumn.many` are now `false | { elementNullable: boolean }`. `ContractField.many` is optional: omission and `false` both mean scalar. `StorageColumn.many` remains required, with `false` for non-array storage columns. Serialized model and value-object fields may omit `many` for scalar cardinality; deserialization normalizes omission to `many: false`, and canonical emission omits that scalar default again. Replace legacy `many: true` with `many: { elementNullable: false }` for existing strict-element lists, preserving nested `elementNullable: false` during emission. Move any intermediate sibling `elementNullable` property into the list descriptor and remove the sibling. Explicit malformed `many` values, including the old `true` form and descriptors without a boolean `elementNullable`, remain rejected, as does the sibling property.
For domain fields, use `if (field.many)` to detect and narrow a list descriptor before reading `elementNullable`; omitted `many` is scalar. For normalized storage columns, replace `column.many === true` with `column.many !== false`. Update conditional types that matched `{ many: true }` to match the descriptor. Do not broaden this replacement to unrelated booleans or relation cardinality. For example, the included-column decoder in an ORM extension must replace `ref.storageColumn.many === true` with `ref.storageColumn.many !== false` so native arrays still take the element-decoding path.
Preserve the independent meaning of `nullable` (the whole field value) and `many.elementNullable` (each element). Type generators must produce `ReadonlyArray<T | null>` for nullable elements and add an outer `| null` only for a nullable container. Array encoders and decoders must preserve a whole-list `null` without traversal and bypass the element codec for null elements, while processing non-null elements normally. Do not pass null elements into scalar codecs or collapse null lists into empty arrays.
For native SQL arrays, propagate element semantics to both the domain field and storage column. For JSON-backed value-object lists, retain the domain descriptor but do not add a native-array descriptor to storage. Do not add `elementNullable` to schema IR: PostgreSQL migration comparison observes the derived check constraints. Generate `elementNotNull` only for strict-element arrays, then apply explicit `noCheck` waivers. Never infer element nullability from `noCheck`, and reject an `elementNotNull` waiver on a nullable-element list as inapplicable. MongoDB array-item validators must admit null only when the domain descriptor permits it, including enum membership and value-object items.
If exposing list authoring, use `.many({ elementsNullable: true })` to request nullable elements; the option uses plural `elementsNullable`, whereas the emitted descriptor uses singular `elementNullable`. Omitted or literal `false` retains strict elements. Keep whole-list `.nullable()` independent. PSL equivalents are `T[]`, `T?[]`, `T[]?`, and `T?[]?`; this change does not enable nullable relation-list elements or scalar lists on SQLite.
## `sql-data-type-declares-names`
Declare each SQL data type the extension owns with `sqlDataType`, and list the texts the database writes and reports for it. `@internal/sql-contract/data-type` is a new dependency of the package.
```ts
// before
import { type DataType, dataType } from '@internal/framework-components/codec';
export const pgvectorVector: DataType = dataType('pgvector/vector', {
listCast: {
of: [pgInt2.id, pgInt4.id, pgInt8.id, pgNumeric.id],
cast: (elements) => elements.map(elementNumber),
},
});
// after
import type { DataType } from '@internal/framework-components/codec';
import { sqlDataType } from '@internal/sql-contract/data-type';
import { type as arktype } from 'arktype';
import { VECTOR_MAX_DIM } from './constants';
export const pgvectorVectorParams = arktype({
length: `number.integer >= 1 & number.integer <= ${VECTOR_MAX_DIM}` as const,
});
export const pgvectorVector = sqlDataType('pgvector/vector', {
params: pgvectorVectorParams,
texts: [{ text: 'vector({length})', written: true, catalog: true }],
listCast: {
of: [pgInt2.id, pgInt4.id, pgInt8.id, pgNumeric.id],
cast: (elements) => elements.map(elementNumber),
},
});
```
Declare `params` as a named export directly above the data type, so the codec can reference it. Each text is lower case with single spaces, and `{name}` stands for the parameter `name`. Mark the text a migration writes `written: true`, and the text the database catalog prints (`format_type` on PostgreSQL) `catalog: true`. Among texts with the same placeholders, at most one is written and one is catalog. Add `normalize` when two parameter sets name the same database type. Keep `casts` and `listCast` as they are. Drop the explicit `: DataType` annotation on the declaration so the parameter type is kept; the `dataTypes` list stays `readonly DataType[]`.
A type written with and without parameters lists a text for each, and `display` gives the letter case the database writes. The PostGIS geometry type:
```ts
export const postgisGeometryParams = arktype({ 'srid?': 'number.integer >= 1' });
export const postgisGeometry = sqlDataType('postgis/geometry', {
params: postgisGeometryParams,
texts: [
{ text: 'geometry', written: true, catalog: true },
{
text: 'geometry(geometry,{srid})',
written: true,
catalog: true,
display: 'geometry(Geometry,{srid})',
},
],
casts: { [pgText.id]: (value) => value },
});
```
The texts must reproduce what the removed hooks wrote: the written text for the column's parameters must equal the old `expandNativeType` result, or migration SQL changes. The bounds are the type's real ones: PostgreSQL refuses an SRID below 1 in a type modifier.
The section "Declaring a data type" of the Prisma 8 codec authoring guide (`docs/reference/codec-authoring-guide.md` in the Prisma repository) describes every field.
## `codec-target-types-removed`
Delete `targetTypes` from every codec descriptor and template:
```ts
// before
override readonly targetTypes = ['vector'] as const;
// after: the line is gone; the names are the data type's texts
```
Code that read `codecLookup.targetTypesFor(codecId)` or `registry.byTargetType(name)` reads the data type instead: `dataTypeLookup.get(codecLookup.descriptorFor(codecId).dataType)`. For a SQL type, call `sqlBaseName(type, dataTypeParams(type, typeParams))` or `renderSqlTypeName(...)` from `@internal/sql-contract/data-type`. For a Mongo codec, move the list to the data type the codec names and read it with `bsonTypesOfCodec(codecId, { codecLookup, dataTypeLookup })` from `@internal/mongo-contract/data-type`; the declared type keeps the list at `type.mongo.bsonTypes`:
```ts
export const myDecimal = mongoDataType('my/decimal', { bsonTypes: ['decimal'] });
```
## `native-type-rendering-hooks-removed`
Delete the `nativeType` override from each `PostgresCodecDescriptor` subclass, with the constant it returned when nothing else uses it. Delete `expandNativeType` from each entry of `controlPlaneHooks`, and keep the other hooks, such as `resolveIdentityValue`.
```ts
// before
const PG_VECTOR_NATIVE_TYPE = 'vector';
export class PgVectorDescriptor extends PostgresCodecDescriptor<VectorParams> {
protected override nativeType(): string {
return PG_VECTOR_NATIVE_TYPE;
}
// …
}
const vectorControlPlaneHooks: CodecControlHooks = {
expandNativeType: ({ nativeType, typeParams }) => {
// …
},
resolveIdentityValue: ({ typeParams }) => buildVectorIdentityValue(typeParams),
};
// after
export class PgVectorDescriptor extends PostgresCodecDescriptor<VectorParams> {
// …
}
const vectorControlPlaneHooks: CodecControlHooks = {
resolveIdentityValue: ({ typeParams }) => buildVectorIdentityValue(typeParams),
};
```
When a hooks object held only `expandNativeType`, delete the object and the `types` override that registered it:
```ts
// before
const arktypeJsonControlPlaneHooks: CodecControlHooks = {
expandNativeType: ({ nativeType }) => nativeType,
};
export const arktypeJsonExtensionDescriptor: SqlControlExtensionDescriptor<'postgres'> = {
...arktypeJsonPackMeta,
types: {
...arktypeJsonPackMeta.types,
codecTypes: {
...arktypeJsonPackMeta.types.codecTypes,
controlPlaneHooks: {
[ARKTYPE_JSON_CODEC_ID]: arktypeJsonControlPlaneHooks,
},
},
},
create: () => ({ /* … */ }),
};
// after
export const arktypeJsonExtensionDescriptor: SqlControlExtensionDescriptor<'postgres'> = {
...arktypeJsonPackMeta,
create: () => ({ /* … */ }),
};
```
A caller of `descriptor.nativeTypeFor(ref)` uses `sqlBaseName` of the codec's data type. A custom control adapter drops `normalizeNativeType`.
## `comments-name-removed-apis`
In comments, delete each sentence that names `expandNativeType`, `targetTypes` or the `nativeType()` hook, and nothing else. When the deletion empties a paragraph, a list item or a whole comment, delete it with the blank comment line before it. Leave the rest of the comment as it is.
```ts
// before
/**
* Per-codec column helper for `pg/vector@1`. Generic over `N extends number` so the column site preserves the dimension literal in `typeParams` (e.g. `pgVectorColumn(1536)` packs `typeParams: { length: 1536 }`).
*
* Passes the bare `nativeType: 'vector'`; the family-layer `expandNativeType` hook renders the parameterized form (`vector(1536)`) at emit/verify time from `nativeType` + `typeParams`.
*/
// after
/**
* Per-codec column helper for `pg/vector@1`. Generic over `N extends number` so the column site preserves the dimension literal in `typeParams` (e.g. `pgVectorColumn(1536)` packs `typeParams: { length: 1536 }`).
*/
```
A codec module's summary comment that lists what the descriptor carries names the data type and its params schema in place of target types and a native type, and says the data type declares the type's name and the bounds of its parameters. A column helper no longer passes a bare `nativeType`, so that clause goes too. Write each rewritten list item on one line. The pgvector and PostGIS summaries:
```ts
// before
* 2. `PgVectorDescriptor` extends {@link PostgresCodecDescriptor} with the codec id, traits, target types, params schema (`{ length: number }`, validated against {@link VECTOR_MAX_DIM}), the postgres native type `vector`, explicit target behavior, and the emit-path `renderOutputType` producing `Vector<${length}>`.
* 3. `pgVectorColumn(length)` per-codec column helper invoking `descriptor.factory({ length })` directly + passing the bare `nativeType: 'vector'`. The family-layer {@link expandNativeType} hook renders the parameterized form (`vector(1536)`) at emit/verify time from `nativeType` + `typeParams`.
// after
* 2. `PgVectorDescriptor` extends {@link PostgresCodecDescriptor} with the codec id, traits, the `pgvector/vector` data type and its params schema, explicit target behavior, and the emit-path `renderOutputType` producing `Vector<${length}>`. The data type declares the type's name and the bounds of `length`.
* 3. `pgVectorColumn(length)` per-codec column helper invoking `descriptor.factory({ length })` directly.
```
```ts
// before
* 2. `PostgisGeometryDescriptor` extends {@link PostgresCodecDescriptor}
* with the codec id, traits, target types, params schema
* (`{ srid?: number }`, preserving unparameterized geometry while validating supplied SRIDs), explicit target behavior, and
* the emit-path `renderOutputType` producing `Geometry<${srid}>` /
* `Geometry` when no SRID is supplied.
* 3. `pgGeometryColumn({ srid })` per-codec column helper invoking
* `descriptor.factory({ srid })` and passing the bare
* `nativeType: 'geometry'`. The family-layer `expandNativeType`
* hook renders the parameterised form
* (`geometry(Geometry,${srid})`) at emit/verify time from
* `nativeType` + `typeParams`.
// after
* 2. `PostgisGeometryDescriptor` extends {@link PostgresCodecDescriptor} with the codec id, traits, the `postgis/geometry` data type and its params schema (`{ srid?: number }`), explicit target behavior, and the emit-path `renderOutputType` producing `Geometry<${srid}>` / `Geometry` when no SRID is supplied. The data type declares the type's name and the bound of `srid`.
* 3. `pgGeometryColumn({ srid })` per-codec column helper invoking `descriptor.factory({ srid })`.
```
## `postgres-codec-takes-data-type`
```ts
// before
const postgresSqlTextDescriptor = postgresCodec(sqlTextDescriptor, {
dataType: pgText.id,
nativeType: () => 'text',
jsonProjection: (expression) => expression,
});
// after
const postgresSqlTextDescriptor = postgresCodec(sqlTextDescriptor, {
dataType: pgText,
jsonProjection: (expression) => expression,
});
```
Import the data type object if only its id was imported. `sqliteCodec` changes the same way, without a `nativeType` option to remove.
## `codec-params-schema-is-data-type-params`
Delete the codec's own parameter schema, which the data type's `params` now holds (see `sql-data-type-declares-names`), and point `paramsSchema` at the data type's:
```ts
// before, in codecs.ts
import { pgvectorVector } from './data-types';
const vectorParamsSchema = arktype({
length: 'number',
}).narrow((params, ctx) => {
// … integer and range checks …
}) satisfies StandardSchemaV1<VectorParams>;
override readonly paramsSchema: StandardSchemaV1<VectorParams> = vectorParamsSchema;
// after
import { pgvectorVector, pgvectorVectorParams } from './data-types';
override readonly paramsSchema: StandardSchemaV1<VectorParams> = pgvectorVectorParams;
```
A codec with keys of its own sets `paramsSchema` to `dataType.params.and(ownKeys)`. A codec whose data type has no `params` keeps a schema of its own keys only; `arktype/json@1` is such a codec and changes nothing here.
## `column-helper-checks-data-type-params`
Delete the helper's own range check and the `@throws` tag that describes it, with the blank comment line directly before the tag when there is one. The helper returns its descriptor unchecked:
```ts
// before
import { VECTOR_CODEC_ID, VECTOR_MAX_DIM } from '../core/constants';
import { pgVectorError } from '../core/errors';
/**
* …
* @returns A column type descriptor with `typeParams.length` set
* @throws `CONTRACT.ARGUMENT_INVALID` if length is not an integer in the range [1, VECTOR_MAX_DIM]
*/
export function vector<N extends number>(length: N) /* : … */ {
if (!Number.isInteger(length) || length < 1 || length > VECTOR_MAX_DIM) {
throw pgVectorError('CONTRACT.ARGUMENT_INVALID', /* … */);
}
return { /* … */ } as const;
}
// after
import { VECTOR_CODEC_ID } from '../core/constants';
/**
* …
* @returns A column type descriptor with `typeParams.length` set
*/
export function vector<N extends number>(length: N) /* : … */ {
return { /* … */ } as const;
}
```
PostGIS's `geometry({ srid })` and `pgGeometryColumn({ srid })` lose their `srid` checks the same way and keep `const { srid } = options;`. A contract whose column has parameters its data type refuses, such as `vector(0)` or `geometry({ srid: 0 })`, fails when it is built, with `CONTRACT.TYPE_PARAMS_INVALID` and the meta `{ dataType, parameters, modelName, fieldName }`.
## `type-constructor-templates-lose-native-type`
```ts
// before
Vector: {
kind: 'typeConstructor',
args: [{ kind: 'number', name: 'length', integer: true, minimum: 1, maximum: VECTOR_MAX_DIM }],
output: {
codecId: 'pg/vector@1',
nativeType: 'vector',
typeParams: {
length: { kind: 'arg', index: 0 },
},
},
},
// after
Vector: {
kind: 'typeConstructor',
inferred: true,
args: [{ kind: 'number', name: 'length', integer: true }],
output: {
codecId: 'pg/vector@1',
typeParams: {
length: { kind: 'arg', index: 0 },
},
},
},
```
Put `inferred: true` directly after `kind` on the one constructor that `contract infer` should print for the data type. Keep `minimum` and `maximum` on an argument that also feeds something other than a data type parameter. An argument mapped onto a parameter the data type declares optional becomes `optional: true`, as PostGIS's `srid` does:
```ts
args: [{ kind: 'number', name: 'srid', integer: true, optional: true }],
```
## `runtime-descriptor-registers-data-types`
Add `dataTypes` directly after `version`:
```ts
const pgvectorRuntimeDescriptor: SqlRuntimeExtensionDescriptor<'postgres'> = {
kind: 'extension' as const,
id: pgvectorPackMeta.id,
version: pgvectorPackMeta.version,
dataTypes: pgvectorPackMeta.dataTypes,
// …
};
```
Without it, building the runtime adapter fails with `CONTRACT.DATA_TYPE_UNREGISTERED`, because the runtime stack has no data type to write the extension codec's casts from. When building an adapter by hand, pass the same list: `createPostgresAdapter({ codecDescriptors, dataTypes: pgvectorDataTypes })`. An extension whose codecs represent only the target's data types, such as `arktype/json@1` over `pg/jsonb`, has no data types of its own and adds nothing.
## `column-descriptors-drop-native-type`
Delete `nativeType` from every column type descriptor: column type helpers, hand-written descriptors, and enum descriptors.
```ts
// before
export function vector<N extends number>(length: N) {
return { codecId: VECTOR_CODEC_ID, nativeType: 'vector', typeParams: { length } } as const;
}
const pgText = { codecId: 'pg/text@1', nativeType: 'text' } as const;
// after
export function vector<N extends number>(length: N) {
return { codecId: VECTOR_CODEC_ID, typeParams: { length } } as const;
}
const pgText = { codecId: 'pg/text@1' } as const;
```
Delete the fourth argument of `column()`, the type name:
```ts
// before
column(pgVectorDescriptor.factory({ length }), pgVectorDescriptor.codecId, { length }, 'vector');
// after
column(pgVectorDescriptor.factory({ length }), pgVectorDescriptor.codecId, { length });
```
Delete the `storage` list from the pack metadata's `types`: nothing reads it, and `StorageTypeMetadata`, its type, is deleted from `@internal/sql-contract/pack-types`. Delete an import that only the list used, and the part of a doc comment that describes the list. Contracts no longer carry `extensions.<pack>.types.storage`; the script removes it from yours.
```ts
// before
types: {
codecTypes: { import: { package: '@acme/pack/codec-types', named: 'CodecTypes', alias: 'AcmeTypes' } },
storage: [{ typeId: pgvectorTypeId, familyId: 'sql', targetId: 'postgres', nativeType: 'vector' }],
},
// after
types: {
codecTypes: { import: { package: '@acme/pack/codec-types', named: 'CodecTypes', alias: 'AcmeTypes' } },
},
```
Delete `nativeType` from each `storage.types` entry of the contract space's source (`src/contract.ts`):
```ts
// before
[PGVECTOR_NATIVE_TYPE]: { kind: 'codec-instance', codecId: VECTOR_CODEC_ID, nativeType: PGVECTOR_NATIVE_TYPE, typeParams: {} },
// after
[PGVECTOR_NATIVE_TYPE]: { kind: 'codec-instance', codecId: VECTOR_CODEC_ID, typeParams: {} },
```
A codec descriptor's `columnFromEntity` returns `{ typeParams }` and no `nativeType`; code that read `nativeType` from its result stops reading it.
A `defineContract` facade that constrains the contract's `types` to what `type.*` helpers return uses `AuthoredStorageTypeInstance` from `@internal/sql-contract/types`, which has no `dataType`; the contract build adds it:
```ts
// before
type TypesConstraint = Record<string, StorageTypeInstance>;
// after
type TypesConstraint = Record<string, AuthoredStorageTypeInstance>;
```
Code that builds a stored contract's column by hand, for example a test helper, writes the data type id instead of the type name: `{ dataType: 'pg/int4', codecId: 'pg/int4@1' }` instead of `{ nativeType: 'int4', codecId: 'pg/int4@1' }`, and a contract type written by hand declares `readonly dataType: 'pg/int4'` instead of `readonly nativeType: 'int4'`.
Update doc comments and examples that describe what a helper produces: `// Produces: codecId: 'pg/vector@1', typeParams: { length: 1536 }` instead of `// Produces: nativeType: 'vector', typeParams: { length: 1536 }`.
## `sqlite-data-types-are-stored-types`
A codec, cast or authoring entry that named one of the deleted types names the type SQLite stores instead: `sqlite/text` for JSON and date-time values, `sqlite/integer` for big integers. The data types are exported from `@internal/target-sqlite/data-types`.
```ts
// before
override readonly dataType = sqliteJson.id; // 'sqlite/json'
override readonly dataType = sqliteBigint.id; // 'sqlite/bigint'
// after
override readonly dataType = sqliteText.id; // 'sqlite/text'
override readonly dataType = sqliteInteger.id; // 'sqlite/integer'
```
The canonical forms follow the stored type: `sqlite/integer` stores digit text and `sqlite/text` a string. A JSON codec on SQLite stores the JSON text of the document, and a date-time codec its text. `sqlite/text` declares no canonical form, so a codec whose values have several written forms declares one on its descriptor: `sqlite/datetime@1` does, with `toCanonicalForm` (the function that was `sqliteDatetime.toCanonicalForm`). A contract source, `db verify` and DDL use a codec's canonical form before its data type's.
```ts
import type { ToCanonicalForm } from '@internal/framework-components/codec';
// your function: written text in, the one text the contract stores out; it throws
// CONTRACT.CAST_REFUSED for text the codec does not hold
declare const datetimeCanonicalForm: ToCanonicalForm;
export class MyDatetimeDescriptor extends SqliteCodecDescriptor<void> {
override readonly dataType = sqliteText.id;
override readonly toCanonicalForm = datetimeCanonicalForm;
// codecId, traits, the JSON projection and the factory follow
}
```
## `canonical-form-of-a-column`
Read a column's canonical form through `canonicalFormOf`, and pass the renamed options:
```ts
// before
canonicalDateTime(text, { shape: 'instant', dataTypeId: 'acme/instant' });
mapDefault(stored, { dataTypeEntries, dataTypeLookup, columnDataType: codec.dataType });
const toCanonical = codec.toCanonicalForm ?? dataTypes.get(codec.dataType)?.toCanonicalForm;
// after
canonicalDateTime(text, { shape: 'instant', ownerId: 'acme/instant' });
mapDefault(stored, { dataTypeEntries, dataTypeLookup, columnCodec: codec });
const toCanonical = canonicalFormOf(codec, dataTypes);
```
## `authoring-entry-key-checked`
An entry whose tag yields a type that another entry already holds under that type's id, as SQLite's `json` tag yields `sqlite/text`, moves under its tag's key and names the type it yields:
```ts
import { tagEntryKey } from '@internal/framework-components/authoring';
// before
[sqliteJson.id]: {
written: { kind: 'tag', tag: 'json', parse: parseJsonBody },
print: printJsonBody,
documentation: 'Reads the body as a JSON document and stores it as the default value.',
},
// after
[tagEntryKey('json')]: {
written: {
kind: 'tag',
tag: 'json',
type: sqliteText.id,
parse: (text) => canonicalizeJson(parseJsonBody(text)),
},
print: (value) => String(value),
documentation: 'Reads the body as a JSON document and stores its JSON text as the default value.',
},
```
An entry under a data type's id keeps naming no `type`. Any other combination fails assembly with `CONTRACT.DATA_TYPE_ENTRY_KEY_INVALID`.
## `the-sql-tag-writes-the-sql-expression-data-type`
This supersedes the section about lowering entries in the `data-types-column-defaults` extension instructions of the upgrade from 8.0.0-rc.11 to 8.0.0-rc.12.
`sql` used to be a tag that named no data type and lowered its own body. It is now the tag of the data type `sql/expression`, which the SQL family defines and registers. The second kind of authoring entry is gone.
| Removed | Replacement |
| --- | --- |
| `AuthoringDataTypeEntry` | `DataTypeAuthoringEntry`, from `@internal/framework-components/authoring` |
| `DataTypeLoweringAuthoringEntry`, `loweringEntryKey`, `isLoweringEntryKey`, `isDataTypeLoweringEntry` | None. Remove the branch that told the two kinds of entry apart |
| `TaggedLiteralValue` from `@internal/framework-components/control` | None |
| `sqlDefaultLiteralTagEntry` from `@internal/family-sql/control` | Nothing to register. The SQL family descriptor registers `sqlExpressionAuthoringEntry` from `@internal/sql-contract/sql-expression` under `SQL_EXPRESSION_DATA_TYPE_ID` |
| `PSL_INVALID_DEFAULT_SQL` from `@internal/family-sql/control` | The string `'PSL_INVALID_DEFAULT_SQL'`. The code itself is unchanged |
| `createPostgresDataTypeEntries`, `createSqliteDataTypeEntries` in the adapters | `postgresDataTypeEntries()` from `@internal/target-postgres/data-types`, `sqliteDataTypeEntries()` from `@internal/target-sqlite/data-types` |
Every key of `authoring.dataTypes` must now be the id of a data type that a component in the stack registers, or the key `tagEntryKey(tag)` of an entry that names the type its tag yields (see `authoring-entry-key-checked`). A key such as `lowering:sql` fails assembly with `CONTRACT.DATA_TYPE_UNREGISTERED`.
The SQL family registers `sql/expression` and its entry itself. A target or an extension does not register it, and a target that registered it too fails assembly with `CONTRACT.DATA_TYPE_ENTRY_DUPLICATE` (or `CONTRACT.DATA_TYPE_DUPLICATE` if it registers only the type). Remove both from the target. No component's data types may declare a cast or a list cast from `sql/expression`. The SQL family refuses a stack that has one with `CONTRACT.DATA_TYPE_CASTS_FROM_SQL_EXPRESSION` when it creates its control instance.
Two types exported from `@internal/sql-contract-psl/resolution` changed:
- The `WrittenValue` tag arm names its text `text`, not `body`.
- The `unreadable` arm of `DefaultRefusal` has no `json` field. A JSON text that is not a JSON document is an ordinary `unreadable` refusal.
## `prefixed-sql-tags-are-removed`
`pg.sql` and `sqlite.sql` were second names for the `sql` tag. They are removed, and a schema that uses one is refused with `PSL_UNKNOWN_LITERAL_TAG`: `Unknown literal tag "pg.sql". Known tags: sql, json.`
Replace the tag with `sql` and leave the text unchanged:
```diff
- createdAt DateTime @default(pg.sql`(now() + interval '1 hour')`)
+ createdAt DateTime @default(sql`(now() + interval '1 hour')`)
```
The stored default does not change, so no migration follows.
## `default-diagnostic-codes-changed`
This matters only to code that reads PSL diagnostic codes, such as a test that asserts one. The messages did not change, except for a `sql` literal inside a list literal and the list of known tags, both described below the table.
| Refusal | Old code | New code |
| --- | --- | --- |
| A tag no pack registered | `PSL_UNKNOWN_DEFAULT_LITERAL_TAG` | `PSL_UNKNOWN_LITERAL_TAG` |
| A `json` literal whose text is not a JSON document | `PSL_INVALID_JSON_LITERAL` | `PSL_INVALID_LITERAL` |
| Text an authoring entry or a cast refused | `PSL_INVALID_DEFAULT_LITERAL` | `PSL_INVALID_LITERAL` |
| A value whose type the column's type has no cast from, including a list written on a column that holds one value | `PSL_DEFAULT_TYPE_INCOMPATIBLE` | `PSL_VALUE_TYPE_INCOMPATIBLE` |
| A written form the target has no data type for | `PSL_DEFAULT_TYPE_INCOMPATIBLE` | `PSL_VALUE_TYPE_INCOMPATIBLE` |
| A single value on a list column | `PSL_DEFAULT_TYPE_INCOMPATIBLE` | `PSL_DEFAULT_LIST_EXPECTED` |
| A value the column's codec refused | `PSL_INVALID_DEFAULT_LITERAL` | unchanged |
| A `sql` literal inside a list literal | `PSL_INVALID_DEFAULT_LITERAL`, at the element | `PSL_VALUE_TYPE_INCOMPATIBLE`, at the `@default` attribute |
`PSL_INVALID_JSON_LITERAL` no longer exists.
The unknown-tag message lists the known tags in the order the stack registers them. The SQL family registers `sql` before the target registers `json`, so a Postgres or SQLite stack lists `sql, json`, where it used to list `json, sql, pg.sql` or `json, sql, sqlite.sql`. The completion list and the `Expected one of` message of `@default` offer `sql` before `json` for the same reason.
A `sql` literal inside a list literal used to report `Literal tag "sql" produces a default of its own and cannot be an element of a list literal.` It is now refused by the cast rule, like any other value the column's type does not take: `Field "Post.tags" at element 1: pg/text has no cast from sql/expression; it casts from nothing`. Write the whole list as one `sql` literal instead, as in `` @default(sql`'{}'::text[]`) ``.
This supersedes the statement in the `data-types-column-defaults` extension instructions of the upgrade from 8.0.0-rc.11 to 8.0.0-rc.12 that a cast's refusal surfaces as `PSL_INVALID_DEFAULT_LITERAL`. It surfaces as `PSL_INVALID_LITERAL`.
## `resolve-identity-value-receives-data-type`
```ts
// before
resolveIdentityValue: ({ nativeType }) => (nativeType === 'vector' ? "'[0]'" : null),
// after
resolveIdentityValue: ({ dataType }) => (dataType === 'pgvector/vector' ? "'[0]'" : null),
```
A list column now reaches the hook with its element's data type and `typeParams`.
## `validate-scalar-type-codec-ids-removed`
Delete calls to `validateScalarTypeCodecIds`; the control stack checks the same thing when it is assembled. Fix any assembly error the new checks report in the extension's own contributions.
## `assemble-data-types-moved-to-codec`
Import `assembleDataTypes` from `@internal/framework-components/codec`. Remove it from the `@internal/framework-components/control` import, and delete that import if nothing is left in it:
```ts
// before
import { assembleDataTypes } from '@internal/framework-components/control';
// after
import { assembleDataTypes } from '@internal/framework-components/codec';
```
## `number-text-helpers-moved`
Import `numeralText` from `@internal/sql-contract/data-type`, adding it to an existing import from that module. Remove it from the `@internal/sql-relational-core/ast` import, and delete that import if nothing is left in it:
```ts
// before
import { numeralText } from '@internal/sql-relational-core/ast';
// after
import { numeralText } from '@internal/sql-contract/data-type';
```
## `data-type-support-moved`
Import each helper listed in the summary from `@internal/sql-contract/data-type-support`. `CanonicalDateTimeOptions.dataTypeId` is also renamed `ownerId`; see `canonical-form-of-a-column`. Remove it from the `@internal/sql-relational-core/ast` import, and delete that import if nothing is left in it. Add `@internal/sql-contract` to the package's dependencies if it is not there:
```ts
// before
import { escapePslString, createNumberClassifier } from '@internal/sql-relational-core/ast';
// after
import { createNumberClassifier, escapePslString } from '@internal/sql-contract/data-type-support';
```
## `numeric-limits-removed`
Validate `numeric` parameters against `pgNumericParams` instead of reading the two ranges:
```ts
// before
import { NUMERIC_PRECISION_RANGE } from '@internal/target-postgres/codecs';
const fits = precision >= NUMERIC_PRECISION_RANGE.min && precision <= NUMERIC_PRECISION_RANGE.max;
// after
import { pgNumericParams } from '@internal/target-postgres/data-types';
import { type as arktype } from 'arktype';
const fits = !(pgNumericParams({ precision }) instanceof arktype.errors);
```
## `postgres-codecs-decode-server-text`
In a Postgres codec, make `decode` parse the server text for its type. Do not expect a value that `pg` parsed:
- `bool` arrives as `t` or `f`, not a boolean.
- Integer, float and `oid` values arrive as decimal text, such as `42`, `1.5`, `NaN` or `-Infinity`, not numbers. `int8` and `numeric` already arrived as text.
- `bytea` arrives as `\x` hex text, such as `\x0102ff`, not a `Buffer`.
- `interval` arrives as PostgreSQL interval text, such as `1 day 02:03:04`, not an object.
- `point` and `circle` arrive as PostgreSQL geometric text, such as `(1,2)` and `<(1,2),3>`, not objects.
- `json` and `jsonb` arrive as JSON text, so a stored JSON string keeps its quotes (`"hello"`). Parse it with `JSON.parse` before you validate it.
Code that reads rows from the runtime driver's `query` without a codec now receives these strings for every column. Convert each value it uses, for example with `Number(row.count)` or `row.flag === 't'`.
## `codecs-decode-json-reads-stored-forms`
Code that calls a built-in codec's `decodeJson` must pass the stored JSON form of its type; another kind throws `RUNTIME.DECODE_FAILED` with the codec id in `meta`. A codec an extension contributes should follow the same rule, stated on `Codec.decodeJson` in `@internal/framework-components/codec`: read a stored JSON form of its type, including every form the database writes for it, and throw on anything else.
A codec's `decodeJson` reads a value in the stored JSON form of its type: a column's literal default in `contract.json`, a member of a value-object default, and a value inside the JSON the database returns for an included relation. The text codecs (`pg/text@1`, `sql/text@1`, `sql/char@1`, `sql/varchar@1`, `sqlite/text@1`, `pg/enum@1`, `pg/uuid@1`, `pg/inet@1`, `pg/bit@1`, `pg/varbit@1`, `pg/tsquery@1`, `pg/timetz@1`, `pg/text-array@1` and the date and time codecs), the integer codecs `pg/int4@1`, `pg/int2@1` and `sql/int@1`, and `pg/bool@1` used to pass any JSON value through. Each now refuses a value of another kind with `RUNTIME.DECODE_FAILED`, naming the codec: a text codec takes a JSON string, `pg/int4@1` a JSON integer from -2147483648 to 2147483647, `pg/int2@1` one from -32768 to 32767, `sql/int@1` a safe integer, or on PostgreSQL, where its column is an int4, an integer from -2147483648 to 2147483647, `pg/bool@1` `true` or `false`, `pg/uuid@1` a UUID as PostgreSQL writes it, in lower case and hyphenated 8-4-4-4-12, and a bit string only `0` and `1`. `pg/vector@1` refuses JSON that is not an array of the column's number of finite numbers with the same shape, as in `pg/vector@1 JSON value must be an array of 3 finite numbers`, where it said `Vector length mismatch` or `Vector value must contain only numbers`. `pg/int8@1` and `sqlite/bigint@1` take decimal text in the signed 64-bit range. `pg/timestamptz-date@1` refused a bad string with a plain `RangeError`; it now raises `RUNTIME.DECODE_FAILED` like the others. Every form PostgreSQL and SQLite write for a value the application type holds is still read; see `text-array-elements-nullable` and `sqlite-int-include-refuses-inexact-values` for the two stored values that now throw instead of reading wrong.
The float codecs `pg/float8@1`, `pg/float4@1`, `pg/float@1`, `sql/float@1` and `sqlite/real@1` take a finite JSON number or the text `"NaN"`, `"Infinity"` or `"-Infinity"`, which PostgreSQL writes for those values in JSON, and `encodeJson` writes that text for them. SQLite writes an infinity in JSON as `9.0e+999`, so on SQLite the float codecs' JSON projection writes the text instead. `sql/float@1`, `pg/float@1` and `sqlite/real@1` used to refuse NaN and the infinities, so an `.include()` of a row holding one failed with `RUNTIME.DECODE_FAILED`; it now reads the value. SQLite cannot store NaN, so on SQLite `sqlite/real@1` and `sql/float@1` refuse it; see `sqlite-nan-parameters-refused`.
Helpers for following the rule:
- `@internal/framework-components/codec` exports the JSON readers every family's codecs share: `decodeJsonString`, `decodeJsonMatching`, `decodeJsonBoolean`, `decodeJsonInteger` (with an `IntegerRange`), `decodeJsonIntegerText` (decimal text, with an optional `BigIntRange`), `decodeJsonFloat` and `encodeJsonFloat`, and `refuseJsonValue`, which raises the refusal they all raise: `RUNTIME.DECODE_FAILED`, `<codecId> JSON value must be <what it takes>`, with `meta.codecId` and `meta.received`, the value it got as JSON text, cut to 100 characters. It also exports the ranges they take, `INT32_RANGE` and `SAFE_INTEGER_RANGE` (`IntegerRange`, which `isIntegerIn(value, range)` tests a value against) and `INT64_RANGE` and `SAFE_INTEGER_BIGINT_RANGE` (`BigIntRange`), and `isNonFiniteText`, which says whether text is `NaN`, `Infinity` or `-Infinity`. The built-in codecs' refusals no longer say `database JSON value`.
- `@internal/utils/text` exports `counted`, which writes a count and its noun for a refusal, such as `3 characters`, and `withoutTrailing(text, character)`, which drops a trailing run of one character in time linear in the run's length.
## `mongo-codec-requires-decode-json`
`mongoCodec` now requires `decodeJson` when the codec's application type is narrower than `JsonValue`, such as `string` or `number`; only a codec whose application type is exactly `JsonValue` may leave it out. A codec that leaves it out no longer compiles. Supply a `decodeJson` that refuses a JSON value of another kind with `RUNTIME.DECODE_FAILED`, such as `decodeJsonString` or another reader from `@internal/framework-components/codec`.
## `sql-float-json-helpers-removed`
Replace `sqlFloatEncodeJson(value)` with `encodeJsonFloat(value)` and `sqlFloatDecodeJson(json)` with `decodeJsonFloat(codecId, json)`, and import `isNonFiniteText` from `@internal/framework-components/codec` instead of `@internal/sql-relational-core/ast`. An import of `isNonFiniteText` that already names `@internal/framework-components/codec` needs no change.
## `codec-lookup-has-no-descriptor-for`
`codecForRef(lookup, ref)` from `@internal/framework-components/codec` builds a column's codec from its descriptor, with the column's type parameters, so everything that builds one takes a `CodecLookupWithDescriptors`. `CodecLookup` no longer declares `descriptorFor`, not even as optional. Each of these no longer compiles:
- a `codecLookup` for `defineContract` or a `ContractSourceContext` without `descriptorFor`. Add one, as the registry `assemblePostgresCodecRegistryWithBuiltins` returns does, or leave `codecLookup` out of `defineContract` so it assembles the target's registry;
- a stub built as `{ ...emptyCodecLookup, get }` where a `CodecLookupWithDescriptors` is expected, because `emptyCodecLookup` no longer has a `descriptorFor` that answers `undefined`. Add a `descriptorFor` that answers for the same codecs as `get`, so the two never disagree;
- an object literal typed `CodecLookup` that sets `descriptorFor`, or a call `lookup.descriptorFor?.(id)` on a `CodecLookup`. Type the lookup `CodecLookupWithDescriptors` and call `descriptorFor(id)`.
## `mongo-codecs-check-json`
A Mongo codec's `decodeJson` reads the JSON form of its type. The Mongo runtime reads documents through `decode` and never calls it; the PSL reader calls it for each member of an enum. `mongo/string@1`, `mongo/objectId@1`, `mongo/int32@1`, `mongo/double@1`, `mongo/bool@1`, `mongo/vector@1` and `mongo/bson@1` used to return any JSON value as it was, so an enum member of the wrong kind was stored in the contract:
```prisma
enum Priority {
@@type("mongo/int32@1")
Low = "low"
}
```
This is now refused with `PSL_EXTENSION_INVALID_VALUE`, naming the codec's message, `mongo/int32@1 JSON value must be an integer from -2147483648 to 2147483647`. A member written without a value, such as a bare `Low`, under a codec that does not take text is `PSL_ENUM_BARE_MEMBER_NON_STRING_CODEC`. Give each member a value of the codec's type.
Each codec now takes: `mongo/string@1` a string; `mongo/objectId@1` 24 hexadecimal digits; `mongo/int32@1` an integer from -2147483648 to 2147483647; `mongo/double@1` a number, or the text `"NaN"`, `"Infinity"` or `"-Infinity"`, which its `encodeJson` now writes for those values instead of a number JSON cannot hold; `mongo/bool@1` a boolean; `mongo/date@1` the text `Date.toISOString()` writes; `mongo/vector@1` an array of numbers; and `mongo/bson@1` canonical Extended JSON, the form its `encodeJson` writes. Another value throws `RUNTIME.DECODE_FAILED` with the codec id in `meta`. A TypeScript `enumType` member that `mongo/objectId@1` or `mongo/int32@1` does not hold now throws `RUNTIME.ENCODE_FAILED` when the contract is built.
## `text-array-elements-nullable`
`pg/text-array@1`, the codec of a contract-free `textArray()` column, reads a `text[]` column's NULL elements as `null`, so its application type is `readonly (string | null)[]` where it was `readonly string[]`, and so is its entry in the Postgres `CodecTypes`. Code typed by a `textArray()` column, or by `min` or `max` over one, sees `string | null` elements; handle the `null`. Read through an `.include()`, a two-dimensional `text[]` value now throws `RUNTIME.DECODE_FAILED`, where it read as the text `"a,b"`.
## `char-reads-drop-only-padding`
PostgreSQL pads a `char(n)` value with spaces to its length: `'a'` in a `char(3)` column is stored as `'a '`. A flat read dropped every trailing whitespace character, so a stored `'a\t'` also read as `"a"`, while `.include()` returned the padded text, `"a "`. Both reads now return the value without the padding and nothing more: `"a"` for `'a'`, and `"a\t"` for `'a\t'`. On SQLite, which does not pad, both reads drop trailing spaces, as a flat read did. Code that compared an included `char` value with its padding, or relied on a flat read dropping a trailing tab or newline, compares the value without its padding.
## `sqlite-nan-parameters-refused`
SQLite cannot store NaN: bound as a parameter, it becomes NULL. So `create({ value: 0 / 0 })` on an optional `Float` column stored NULL, and `where((p) => p.value.eq(Number.NaN))` matched nothing. On SQLite, `sqlite/real@1` and `sql/float@1` now refuse NaN with `RUNTIME.ENCODE_FAILED`, `<codecId> value must be a number other than NaN, which SQLite cannot store`, with `meta.codecId` and `meta.received`: when they encode a value to write or filter by, and when they encode a TypeScript `.default()`, which is still refused when the contract is built with `CONTRACT.DEFAULT_INVALID`, now with this message. Their `decodeJson` refuses the text `"NaN"`. A NaN parameter no codec encoded, such as one in raw SQL, is refused by the SQLite driver with the same code: `Parameter 2 is NaN, which SQLite cannot store: it would bind it as NULL. Pass null to store no value.`, with `meta.paramIndex`, counted from 0. On a required column SQLite already refused the NULL, so only the error changes. Where a computed value can be NaN, write `null` for no value, and filter with `isNull()` for rows that have none. Infinity and -Infinity are stored and read back as before.
## `sqlite-int-include-refuses-inexact-values`
`sql/int@1` holds a JavaScript safe integer. On SQLite, an INTEGER column can hold a larger integer or a REAL. An `.include()` read such a value rounded or with a fraction; it now throws `RUNTIME.DECODE_FAILED`, naming the codec. A flat read is unchanged. Store an integer past 2^53 in a `BigInt` column and a fraction in a `Float` column.
## `contract-build-takes-lookups`
The column's stored `dataType` is the data type its codec represents (see `contract-stores-data-type`), so the build needs both lookups. Through the facades, list every extension whose codec the contract uses:
```ts
defineContract({ extensions: { pgvector } }, ({ field, model }) => ({ /* … */ }));
```
A contract written with an empty definition (`defineContract({}, …)`) that names an extension's codec passes the lookups itself, as an extension's own contract space does:
```ts
// before
export const contract = defineContract({}, () => ({
types: {
[PGVECTOR_NATIVE_TYPE]: {
kind: 'codec-instance',
codecId: VECTOR_CODEC_ID,
nativeType: PGVECTOR_NATIVE_TYPE,
typeParams: {},
},
},
models: {},
}));
// after
import { assembleDataTypes } from '@internal/framework-components/codec';
import { assemblePostgresCodecRegistryWithBuiltins } from '@internal/target-postgres/codecs';
import { postgresDataTypes } from '@internal/target-postgres/data-types';
import { pgvectorDataTypes } from './core/data-types';
import { pgvectorCodecRegistry } from './core/registry';
const dataTypeLookup = assembleDataTypes([
{ id: 'postgres', dataTypes: postgresDataTypes },
{ id: 'pgvector', dataTypes: pgvectorDataTypes },
]).lookup;
const codecLookup = assemblePostgresCodecRegistryWithBuiltins(
[{ types: { codecTypes: { codecDescriptors: [...pgvectorCodecRegistry.values()] } } }],
dataTypeLookup,
);
export const contract = defineContract(
{ codecLookup, dataTypeLookup },
() => ({
types: {
[PGVECTOR_NATIVE_TYPE]: {
kind: 'codec-instance',
codecId: VECTOR_CODEC_ID,
typeParams: {},
},
},
models: {},
}),
);
```
The `storage.types` entry loses `nativeType` as `column-descriptors-drop-native-type` describes. A column whose codec the lookup lacks fails with `CONTRACT.CODEC_DESCRIPTOR_MISSING`; one whose codec's data type the lookup lacks fails with `CONTRACT.DATA_TYPE_UNREGISTERED`. A direct call of `buildSqlContractFromDefinition(definition, codecLookup, dataTypeLookup)` passes both.
## `define-contract-wrapper-builds-data-type-lookup`
A package that exposes its own `defineContract` over `buildBoundContract`, as the Postgres and SQLite facades do, makes four edits.
1. Directly after the first import, import the lookup types and the data type assembly:
```ts
import {
assembleDataTypes,
type CodecLookupWithDescriptors,
type DataTypeLookup,
} from '@internal/framework-components/codec';
```
2. In the result type, the object passed to `ContractInput`'s build gains both lookups directly after `createNamespace`:
```ts
readonly createNamespace: (input: SqlNamespaceInput) => SqlNamespaceBase;
readonly codecLookup: CodecLookupWithDescriptors;
readonly dataTypeLookup: DataTypeLookup;
```
3. The scaffold type adds `'codecLookup' | 'dataTypeLookup'` at the end of the keys it omits from `ContractInput`, and takes both as optional overrides. When the `Omit<…>` is already intersected with an object type, the two members go at the start of that object; otherwise add `& { … }` with them:
```ts
> & {
/** Overrides the codecs of the target and the extensions. */
readonly codecLookup?: CodecLookupWithDescriptors;
/** Overrides the data types of the target and the extensions. */
readonly dataTypeLookup?: DataTypeLookup;
```
4. In the implementation, assemble the data type lookup once, with the target pack as the first contributor, and build the codec lookup against it. On Postgres, `assemblePostgresCodecRegistryWithBuiltins` now takes the data type lookup as its second argument:
```ts
const extensions: readonly ExtensionPackRef<'sql', string>[] = Object.values(
definition.extensions ?? {},
);
const dataTypeLookup =
definition.dataTypeLookup ?? assembleDataTypes([postgresPack, ...extensions]).lookup;
const bound = {
...definition,
createNamespace: postgresCreateNamespace,
codecLookup:
definition.codecLookup ??
assemblePostgresCodecRegistryWithBuiltins(extensions, dataTypeLookup),
dataTypeLookup,
};
```
On SQLite, the target constant is the first contributor:
```ts
const extensionPacks: readonly ExtensionPackRef<'sql', string>[] = Object.values(
definition.extensions ?? {},
);
const bound = {
...definition,
createNamespace: sqliteCreateNamespace,
codecLookup: definition.codecLookup ?? assembleSqliteCodecRegistry(target, extensionPacks),
dataTypeLookup:
definition.dataTypeLookup ?? assembleDataTypes([target, ...extensionPacks]).lookup,
};
```
Both refuse a data type id that two packs register, with `CONTRACT.DATA_TYPE_DUPLICATE`.
## `postgres-codec-registry-takes-data-type-lookup`
The Postgres codec registry no longer carries the data types. Each function that builds one takes the data type lookup its codecs are checked against, and refuses a codec whose data type the lookup lacks with `CONTRACT.DATA_TYPE_UNREGISTERED`. Build the lookup first, from the same components, with `assembleDataTypes(components).lookup` from `@internal/framework-components/codec`, or take the target's own with `createPostgresBuiltinDataTypeLookup()` from `@internal/target-postgres/data-types`:
```ts
// before
const codecRegistry = assemblePostgresCodecRegistry(components);
const adapter = new PostgresControlAdapter(codecRegistry);
// after
const dataTypeLookup = assembleDataTypes(components).lookup;
const codecRegistry = assemblePostgresCodecRegistry(components, dataTypeLookup);
const adapter = new PostgresControlAdapter(codecRegistry, dataTypeLookup);
```
`assemblePostgresCodecRegistryWithBuiltins(extensions, dataTypeLookup)` and `createPostgresAdapterWithCodecRegistry(codecRegistry, dataTypeLookup)` change the same way. `createPostgresCodecRegistryWithBuiltins(codecDescriptors, dataTypeLookup)` takes the lookup in place of a list of data types, and defaults to the target's own. A built-in adapter in a test is `new PostgresControlAdapter(createPostgresBuiltinCodecLookup(), createPostgresBuiltinDataTypeLookup())`.
## `contract-to-schema-takes-components`
```ts
// before
const fromSchema = migrations.contractToSchema(fromContract);
// after
const fromSchema = migrations.contractToSchema(fromContract, frameworkComponents);
```
Pass the framework components of the stack the contract was built with. A custom target's implementation passes them on to the family's `contractToSchemaIR` as `dataTypeLookup` and `codecLookup`.
## `contract-to-schema-ir-takes-lookups`
```ts
// before
contractToSchemaIR(contract, { annotationNamespace: 'pg', expandNativeType });
// after
contractToSchemaIR(contract, { annotationNamespace: 'pg', dataTypeLookup, codecLookup });
```
`dataTypeLookup` and `codecLookup` come from the assembled stack: `sqlTypeLookupsOf(frameworkComponents)` from `@internal/family-sql/control` returns both. Every field or parameter that holds a `DataTypeLookup` is now named `dataTypeLookup`; `dataTypes` names only a list of data types.
## `authoring-entity-context-takes-data-type-lookup`
```ts
// before
const ctx: AuthoringEntityContext = { family: 'sql', target: 'postgres' };
// after
const ctx: AuthoringEntityContext = {
family: 'sql',
target: 'postgres',
codecLookup: createPostgresBuiltinCodecLookup(),
dataTypeLookup: createDataTypeLookup(postgresDataTypes),
};
```
Pass the lookups of the stack the context serves; `createDataTypeLookup` from `@internal/framework-components/codec` builds a data type lookup from a list of data types, and `{ ...emptyCodecLookup, descriptorFor: () => undefined }`, with `emptyCodecLookup` from the same module, stands in where no codecs apply. A stub codec lookup that answers `descriptorFor` is typed `CodecLookupWithDescriptors`. A call of `interpretPslDocumentToMongoContract` passes `codecLookup` as well: the stack's codec lookup, or that stand-in.
## `mongo-derive-json-schema-takes-lookups`
```ts
// before
deriveJsonSchema(fields, valueObjects, codecLookup, valueSets);
derivePolymorphicJsonSchema(baseFields, discriminator, variants, valueObjects, codecLookup, valueSets);
// after
deriveJsonSchema(fields, { codecLookup, dataTypeLookup }, valueObjects, valueSets);
derivePolymorphicJsonSchema(
baseFields,
discriminator,
variants,
{ codecLookup, dataTypeLookup },
valueObjects,
valueSets,
);
```
`codecLookup` is a `CodecLookupWithDescriptors`, the stack's codec lookup. `dataTypeLookup` holds the stack's data types: `createDataTypeLookup(mongoDataTypes)`, with `createDataTypeLookup` from `@internal/framework-components/codec` and `mongoDataTypes` from `@internal/target-mongo/data-types`, plus any data types the extension registers. A call that passed no codec lookup passes the stack's now; without it the validator left out every scalar field's BSON type.
## `field-type-params-come-from-the-domain-type`
- `buildSqlContractFromDefinition` takes a model field's domain type parameters from its `descriptor.typeParams`, or else from the named storage type its `descriptor.typeRef` names. A value-object model field carries its column's `descriptor` (the target's value-object storage type) instead of the builder assuming `jsonb`. A value-object member has no `columnName` and is typed by a codec and its type parameters only.
- `EmissionSpi.resolveFieldTypeParams` is removed. A family whose domain fields do not carry their type parameters puts them there when it builds the contract.
- `generateFieldOutputTypesMap` from `@internal/emitter` takes `(models, codecLookup)`: its third parameter, the type-parameter resolver, is removed with the hook.
## `sql-infer-psl-contract-takes-build-context`
The SQL family calls a target's `inferPslContract(schema, context, describedContracts?)` with the same `SqlPslBuildContext` it passes `buildPslContract`: the stack's authoring contributions, codec lookup and data types. `contract emit` reads the inferred schema with that stack, so a target reads a written type's codec and type parameters from `context.authoringContributions.type` and `context.codecLookup` instead of a table of its own. A target that implements the hook adds the parameter.
Code that called the hook on a target descriptor, such as a script that infers PSL from an introspected schema, has no context to pass. It calls the SQL family instance's `inferPslContract(schema)`, which builds the context from the control stack it was created with:
```ts
// before
const inferPslContract = postgresTargetDescriptor.inferPslContract;
if (!inferPslContract) {
throw new Error('the postgres target descriptor has no inferPslContract');
}
const ast = inferPslContract(rawSchemaNode);
// after
const ast = sqlFamilyDescriptor.create(controlStack).inferPslContract(rawSchemaNode);
```
`sqlFamilyDescriptor` is the default export of `@internal/family-sql/control`, and `controlStack` is the stack `createControlStack` from `@internal/framework-components/control` builds for the family, target, adapter and driver the script already uses.
## `imported-postgres-field-checks-defaults`
`import { field } from '@prisma/orm-postgres/contract-builder'` now gives the same field builders the `defineContract` callback receives, without the ones an extension adds: `field.text()`, `field.temporal.timestamptz()`, `field.uuidString()`, and `field.column(columnType)` as before. Its `.default(...)` now checks the value against the Postgres target's column types; a column type an extension adds, such as pgvector's `vector`, is not checked. A default of another type no longer compiles:
```diff
- field.column(int8Column).default(1)
+ field.column(int8Column).default(1n)
```
## `codecs-check-stored-json`
The built-in SQL, PostgreSQL and SQLite codecs now refuse a value that is not a stored form of their type, including one its type parameters rule out, where most used to pass it through. A TypeScript `.default()` given such a value is now refused when the contract is built, with `CONTRACT.DEFAULT_INVALID` naming the model and field: a value of another kind, such as a number for a text column, or one the PostgreSQL column rules in `psl-values-checked-by-codecs` refuse, such as `'toolong'` for a `varchar(3)` column. A TypeScript `enumType` member its codec does not take is refused the same way, with `CONTRACT.ENUM_INVALID` naming the enum, the member and the codec: for example a `pg/char@1` or `sql/char@1` member longer than one character on PostgreSQL, since the enum's column is `character`, which holds one. Correct the value the error names.
A `contract.json` with such a default, emitted by an earlier version or edited by hand, still loads. `db init`, `db update` and `migration plan` used to plan the default; they now stop with `CONTRACT.DEFAULT_INVALID`, which names the table, the column, the codec and the value. Emit the contract again with this version, and correct the default in the contract source if emit refuses it. Running a `migration.ts` that an earlier version planned with such a default fails with the same message; correct the default in that file. Every statement that writes a default reads it this way: a new table or column, a changed default, and on SQLite a rebuilt table, which used to write a changed default or a rebuilt table's defaults unread. Each element of a list default is read the same way, and the message names the element's position: `Column "post"."tags" has a default (element 2) its codec pg/text@1 refuses: pg/text@1 JSON value must be a string`. A NULL element stays NULL. The codecs that carry PostgreSQL's own date and time text, those of `DateString`, `TimeString`, `TimestampString`, `TimestamptzString` and `Timetz`, read only a date or time in ISO 8601 or as PostgreSQL writes it, so such a default as `'now'`, which PostgreSQL would read once when it creates the table, is refused the same way. An `.include()` of such a column reads the text PostgreSQL writes in the ISO DateStyle, its default; on a server set to another DateStyle it now fails with `RUNTIME.DECODE_FAILED`, where it passed the text through. Set `DateStyle` to `ISO` on that server.
## `psl-values-checked-by-codecs`
The PSL reader reads each literal default, and each member of a SQL `enum`, with the column's codec, so the stricter codecs refuse schemas that loaded before. A value of another kind is refused: a text codec takes a string, an integer codec an integer in its range, `Boolean` `true` or `false`, and `Uuid` a UUID. On PostgreSQL the codecs also check what the column stores:
- `VarChar(n)` and `Char(n)` take at most n characters, counted by code point, and a `Char` value's trailing spaces do not count. A `Char` without a length is `character(1)`, so it takes one character.
- `Bit(n)` takes exactly n bits and `VarBit(n)` at most n, and a bit column without a length is `bit(1)`.
- `Numeric(p, s)` takes a value it stores without rounding.
- `Int`, `sql/int@1` and an enum whose members are integers take an integer from -2147483648 to 2147483647, and `SmallInt` one from -32768 to 32767.
- `Real` takes a finite number only if float4 holds it, neither overflowing to an infinity nor becoming 0.
Such a default used to load, and the migration planned and applied; the first insert that used the default then failed. A `Char` or bit column without a length, which did not apply on PostgreSQL whatever its default, now applies as `character(1)` or `bit(1)`. SQLite does not enforce a declared length, so on SQLite the char and varchar codecs take text of any length. Each of these is now refused at `contract emit`:
| Schema | Diagnostic |
| --- | --- |
| `enum P { @@type("pg/int4@1") Low = "low" }` | `PSL_EXTENSION_INVALID_VALUE`: `enum "P" member "Low" was rejected by codec "pg/int4@1": pg/int4@1 JSON value must be an integer from -2147483648 to 2147483647` |
| the same enum with a bare `Low` | `PSL_ENUM_BARE_MEMBER_NON_STRING_CODEC`: `enum "P" member "Low" has no value and codec "pg/int4@1" does not accept a bare name as input` |
| `enum P { @@type("pg/text@1") Low = 1 }` | `PSL_EXTENSION_INVALID_VALUE`, `enum "P" member "Low": pg/text has no cast from pg/int2; it casts from nothing` |
| an enum without `@@type` whose integer members include one outside -2147483648 to 2147483647, such as `Low = 3000000000` | `PSL_EXTENSION_INVALID_VALUE`, `enum "P" member "Low": pg/int4 has no cast from pg/int8; it casts from pg/int2` |
| `u Uuid @default("nope")` | `PSL_INVALID_LITERAL`, `"nope" is not a UUID: PostgreSQL reads 32 hexadecimal digits, with a hyphen after any group of four and optionally in braces.` |
| `s VarChar(3) @default("toolong")` | `PSL_INVALID_DEFAULT_LITERAL`, `sql/varchar@1 JSON value must be a string of at most 3 characters` |
| `c Char @default("abc")` on PostgreSQL | `PSL_INVALID_DEFAULT_LITERAL`, `sql/char@1 JSON value must be a string of at most 1 character before any trailing spaces` |
| `enum P { @@type("sql/int@1") Low = 3000000000 }` on PostgreSQL | `PSL_EXTENSION_INVALID_VALUE`, `enum "P" member "Low": pg/int4 has no cast from pg/int8; it casts from pg/int2` |
| `n Numeric(5, 2) @default(1.555)` | `PSL_INVALID_DEFAULT_LITERAL`, `pg/numeric@1 JSON value must be a decimal string that numeric(5, 2) stores without rounding` |
Give each enum member a value its codec takes, and each default a value its column's type holds unchanged. A `Uuid` default is still read in any form PostgreSQL reads; see `uuid-defaults-stored-as-postgresql-writes`.
## `value-object-default-matches-composite-type`
A literal default on a field typed by a composite type used to be stored whatever its shape. It is now checked, naming the path that is wrong, as in `Field "User.home.street"`:
- A single value object takes a JSON object, and a list of them a JSON array: `` homes Address[] @default(json`{"street": "x"}`) `` is refused; write `@default([])` or `` @default(json`[{"street": "x"}]`) ``. JSON `null` is taken when the field is optional. `PSL_VALUE_TYPE_INCOMPATIBLE`.
- A key that is not a member is refused, and so is a missing member that is not optional, and `null` for a member that is not optional. `PSL_VALUE_TYPE_INCOMPATIBLE`.
- The default holds each member in the form its codec stores, so the member's codec must read the value. A `Decimal`, `Numeric(p, s)` or `BigInt` member takes a decimal string, `"1.5"`, and a number is refused; a `String` member takes a JSON string, so `"street": 1` is refused; a `DateTime` member takes a date and time string; a `Json` member takes any JSON value. `PSL_INVALID_DEFAULT_LITERAL`, with the codec's message.
- A member typed by an enum takes only the enum's values: `PSL_INVALID_DEFAULT_LITERAL`, `Expected one of:` the values.
- Nested value objects are checked the same way.
Correct the value the diagnostic names.
## `composite-type-attributes-refused`
An attribute inside a `type` block was ignored: `street String @default("x")` stored no default, and `@@map` mapped nothing. Each is now refused, `PSL_UNSUPPORTED_FIELD_ATTRIBUTE` on a member and `PSL_UNSUPPORTED_COMPOSITE_TYPE_ATTRIBUTE` on the type. Remove the attribute. To give a value object a default, write it on the model field as a whole value, such as `` home Address @default(json`{"street": "x"}`) ``.
## `uuid-defaults-stored-as-postgresql-writes`
A `Uuid` default may be written in any form PostgreSQL reads: either case, with or without a hyphen after any group of four digits, and optionally in braces. The contract now stores it as PostgreSQL writes it, in lower case and hyphenated 8-4-4-4-12, and so does a TypeScript `.default()` on a `pg/uuid@1` column, so the applied default verifies against the database with no difference.
Earlier versions stored such a default as written. The database stores the lower-case form, so the check that runs after the change is applied failed: `db init`, `db update` and `db migrate` stopped with `MIGRATION.RUNNER_FAILED` and rolled the change back. The database has none of the changes that contract adds, and no marker for it. With this version, a `contract.json` that still holds such a default stops `db init`, `db update` and `migration plan` with `CONTRACT.DEFAULT_INVALID`, as `codecs-check-stored-json` describes.
Emit the contract again with this version. The stored default changes, and with it the storage hash. Then:
- For a project kept with `db init` or `db update`, run the command that failed again. It applies the contract, and `db verify` then passes.
- For a project with migrations, delete the migration package that never applied: its directory under `migrations/app/`, and its contract snapshot `migrations/snapshots/<hash>/`, where `<hash>` is the `to` hash in the package's `migration.json`. Then run `prisma migration plan` and `prisma db migrate`. Left in place, the package stays in the migration graph, ending at a contract no database reaches.
## `domain-types-match-their-columns`
Run `prisma contract emit`. `contract.json` and `contract.d.ts` gain these entries in the domain half:
| PSL | Added to the field's domain entry |
| --- | --- |
| `code Short`, with `types { Short = VarChar(10) }` (also `Short[]`) | `"typeParams": { "length": 10 }` on `type` |
| `roles Role[]`, where `Role` is an `enum` | `"valueSet": { "plane": "domain", "entityKind": "enum", "namespaceId": "public", "entityName": "Role" }`, as `role Role` already had |
| composite type member `amount Numeric(10, 2)` (also a list, or a named type) | `"typeParams": { "precision": 10, "scale": 2 }` on `type` |
| composite type member `role Role` or `roles Role[]` | the same `valueSet` as a model field of that enum |
A named type without parameters, such as `Email = String`, adds nothing.
In `contract.d.ts`, a composite type's output type (`AddressOutput`) now gives a member with type parameters the parameterized output type a model field of that type has, such as `Varchar<10>` or `Numeric<10, 2>`, instead of the codec's plain output type. These are branded strings, so code that builds such an output object from plain strings, such as a test fixture or a mock, no longer type-checks. Build the value as the ORM returns it, or type it with the composite type's input type (`AddressInput`), which is unchanged.
Migration snapshots under `migrations/snapshots/<hash>/` need no change for this. Migration commands read only their storage half, which this change leaves unchanged. The script of `contract-stores-data-type` rewrites the storage half and the snapshots separately.
`prisma contract print` now expects a field typed by a parameterized named type, and an enum list field, to carry these domain entries. It refuses a contract emitted before this change that lacks them. Re-emit it first.
A pack that ships a contract with such fields re-emits it with `build:contract-space` (`prisma contract emit`).
## `default-renderer-receives-data-type`
```ts
// before
const renderDefault: DefaultRenderer = (def, column) => render(def);
renderDefaultLiteral(value, { many: true, nativeType: 'text', dataTypeId: 'pg/text' });
// after
const renderDefault: DefaultRenderer = (def, column, type) => render(def, type.baseTypeName);
renderDefaultLiteral(value, { many: { elementNullable: false }, baseTypeName: 'text', dataType: 'pg/text' });
```
`baseTypeName` is the type's written name without parameters, for example `jsonb` or `varchar`. Compare `dataType`, for example `pg/jsonb`, when the decision depends on which type the column stores.
## `ddl-column-default-visitor-removed`
Code that dispatched a column default through `accept` reads its `kind` instead:
```ts
// before
const sql = node.accept({ literal: (n, ctx) => renderLiteral(n, ctx.nativeType), function: (n) => n.expression }, { nativeType: 'jsonb' });
// after
const sql = node.kind === 'literal' ? renderLiteral(node, 'jsonb') : node.expression;
```
## `ddl-nodes-hold-opaque-sql`
SQL that Prisma places inside a larger statement now travels as an `OpaqueSql` value, exported with `opaqueSql` and `renderOpaqueSql` from `@internal/sql-relational-core/ast`. These types changed:
| Type | Field | Was | Is |
| --- | --- | --- | --- |
| `FunctionColumnDefault` | `expression` | `string` | `OpaqueSql` |
| `CheckExpressionConstraint` | `expression` | `string` | `OpaqueSql` |
| `PostgresCreatePolicy` | `using`, `withCheck` | `string \| undefined` | `OpaqueSql \| undefined` |
| `PostgresCreateIndex` | `where` | `string \| undefined` | `OpaqueSql \| undefined` |
| `PostgresCreateIndex` | `elements.expression` | `string` | `OpaqueSql` |
| `DdlIndexElements` (`@internal/target-postgres/ddl`) | `expression` | `string` | `OpaqueSql` |
Code that constructs one of these nodes directly wraps the string:
```diff
- new FunctionColumnDefault('now()')
+ new FunctionColumnDefault(opaqueSql('now()'))
```
The factories `fn` and `checkExpression` still take strings, as do `createPolicy` and `createIndex` inside the Postgres target. Prefer a factory over a constructor where one is exported:
```diff
- new CheckExpressionConstraint({ name: 'chk', expression: 'price > 0' })
+ checkExpression('chk', 'price > 0')
```
`createIndex` now takes its element list as `CreateIndexElements`, which holds strings and is exported from `@internal/target-postgres/ddl` next to `DdlIndexElements`. Code that typed the elements it passes to `createIndex` as `DdlIndexElements` uses `CreateIndexElements` instead.
Code that reads `.expression`, `.using`, `.withCheck` or `.where` of these nodes now reads `.text`. The TypeScript compiler reports a read only where the value goes to a `string`, such as a comparison with a string or a `string` parameter. It does not report a read placed in a template string, which renders `[object Object]`, or passed to a function that accepts any value, such as `JSON.stringify` or a code generator. Search your code for these reads and check each one.
An adapter that renders these nodes into SQL renders the SQL with `renderOpaqueSql(node.expression)`, not `node.expression.text`. `renderOpaqueSql` ends text that contains `--` with a line break, so a line comment on the last line cannot comment out the rest of the statement.
## `adapter-writes-column-defaults`
The control adapter writes every column default that DDL writes, for a new table, a new column, a changed default and a rebuilt SQLite table, through one method on `ExecuteRequestLowerer`, which `SqlControlAdapter` extends (`family/control-adapter` of `@prisma/orm-postgres`, `@prisma/orm-sqlite` and `@prisma/orm-family-sql`):
```typescript
renderColumnDefault(column: DdlColumn, table: string): Promise<string>
```
It returns the `DEFAULT …` clause for the column, or `''` when the column has none or writes it another way, as an autoincrement column does. It reads a literal default, and each element of a list default, with the column's codec first, so a value the codec refuses is `CONTRACT.DEFAULT_INVALID` naming the table and the column.
- **An `ExecuteRequestLowerer` or `SqlControlAdapter` implementation** must add the method, and so must a fake lowerer in tests. An adapter returns the clause its CREATE TABLE writes for the column. To read a literal default with the column's codec, use `encodeLiteralDefault` and `encodeListLiteralDefault` from `relational-core/ast`. A fake that writes no defaults can return `''`:
```typescript
const lowerer: ExecuteRequestLowerer = {
lower: () => ({ sql: '', params: [] }),
lowerToExecuteRequest: async () => ({ sql: '', params: [] }),
renderColumnDefault: async () => '',
};
```
- **`buildColumnDefaultSql`** is removed from `target/planner-ddl-builders` of `@prisma/orm-postgres` and `@prisma/orm-target-postgres`. Build the column with `col`, `lit` and `fn` from `relational-core/contract-free`, and ask the adapter for the clause:
```typescript
// before
const clause = buildColumnDefaultSql({ kind: 'literal', value: 'member' }, { nativeType: 'text' });
// after
const clause = await adapter.renderColumnDefault(
col('role', 'text', { default: lit('member'), codecRef: { codecId: 'pg/text@1' } }),
'user',
);
```
- **`SetDefaultCall`** (`target/op-factory-call` of `@prisma/orm-postgres` and `@prisma/orm-target-postgres`) takes the column instead of its name and the SQL text:
```typescript
// before
new SetDefaultCall('public', 'user', 'role', "DEFAULT 'member'", 'widening');
// after
new SetDefaultCall('public', 'user', col('role', 'text', { default: lit('member'), codecRef: { codecId: 'pg/text@1' } }), 'widening');
```
## `migration-ts-column-defaults`
A `migration.ts` no longer carries a column default as SQL text. The control adapter writes the `DEFAULT …` clause for every statement, the same way for a new table, a new column, a changed default and a rebuilt SQLite table, and it reads a literal default with the column's codec first. A `migration.ts` that sets a default the codec refuses fails when it runs, with `CONTRACT.DEFAULT_INVALID` naming the table and the column.
On PostgreSQL, `setDefault` takes the column and its default:
```typescript
// before
this.setDefault({ table: 'user', column: 'role', defaultSql: "DEFAULT 'member'" })
// after
this.setDefault({ table: 'user', column: col('role', 'text', { default: lit('member'), codecRef: { codecId: 'pg/text@1' } }) })
```
On SQLite, a column in `addColumn` or `recreateTable` carries the default and its codec, and a `recreateTable` postcheck that checks a default names the column:
```typescript
// before
{ name: 'role', typeSql: 'TEXT', defaultSql: "DEFAULT 'member'", nullable: false }
{ description: 'verify "role" default on "user"', sql: "SELECT COUNT(*) > 0 FROM pragma_table_info('user') WHERE ..." }
// after
{ name: 'role', typeSql: 'TEXT', default: { kind: 'literal', value: 'member' }, codecRef: { codecId: 'sqlite/text@1' }, nullable: false }
{ description: 'verify "role" default on "user"', columnDefault: 'role' }
```
An applied migration needs nothing: `db migrate` applies `ops.json`, which holds the SQL. Change a `migration.ts` this way only when you run it again to write `ops.json`. `node migration.ts` does not check types, so an earlier file still runs; `setDefault`, `addColumn` and `recreateTable` then refuse a `defaultSql` with `MIGRATION.OPERATION_OPTION_REMOVED`, naming the table and the column, rather than leave the default out. Rewrite the call as shown, or, if the migration is not applied, delete its package and run `migration plan` again.
The `migration.ts` that `migration plan` writes for a new SQLite table now gives each column its `codecRef`, so running it writes the same `ops.json` as the plan.
## `rename-check-constraint-call-is-rename-constraint-call`
Find references to the class `RenameCheckConstraintCall`, imported from `@prisma/orm-postgres/target/op-factory-call` or `@internal/target-postgres/op-factory-call`. The class is now `RenameConstraintCall`. It also renames primary keys, unique constraints and foreign keys, so it takes the constraint kind as a new third constructor argument: `'primaryKey'`, `'unique'`, `'foreignKey'` or `'checkConstraint'`.
- Rename the import and every reference to `RenameConstraintCall`.
- Change `new RenameCheckConstraintCall(schema, table, from, to)` to `new RenameConstraintCall(schema, table, 'checkConstraint', from, to)`.
- Change a comparison of a call's `factoryName` with `'renameCheckConstraint'`, or a `case 'renameCheckConstraint':` over it, to test `factoryName === 'renameConstraint' && call.kind === 'checkConstraint'`.
The operation a check-constraint rename produces keeps its id, label and SQL. It now renders as `this.renameConstraint({ ..., kind: "checkConstraint", ... })`. Migration files that call `this.renameCheckConstraint({ ... })` keep working; leave them unchanged.
## `control-family-instance-sign-spaces`
A family that implements `ControlFamilyInstance` adds `signSpaces`:
```ts
signSpaces(options: {
readonly driver: ControlDriverInstance<TFamilyId, string>;
readonly spaces: readonly SpaceToSign[];
}): Promise<readonly SpaceSignature[]>;
```
`SpaceToSign` is `{ space, contract, expected }`, where `expected` is the marker `db sign` read before it verified the space. The method writes each space's marker with its contract's hashes and returns one `SpaceSignature` per space: `{ status, space, contract: { storageHash, profileHash } }`, where `status` is `created`, `updated` (with `previous`, the hashes the marker held) or `unchanged`. It writes a space's marker only while the marker still holds `expected`, and returns `{ status: 'conflict', space, contract, expected, found }` for a space whose marker changed, whether it finds that on reading the marker or when its compare-and-swap write fails. It does not verify the schema; `db sign` verifies every space before it calls the method. A SQL family's control adapter implements `lockMarker(driver)`, which takes the one lock the target's migration runner holds while it reads and writes markers. The SQL family writes every marker in one transaction, so a failed write leaves every marker as it was; the Mongo family writes them one by one, and running `db sign` again finishes the job.
`ControlFamilyInstance.sign`, `ControlClient.sign`, `SignOptions` and `SignDatabaseResult` are removed: a family that implemented `sign` deletes it and keeps `signSpaces`, and code that called `client.sign({ contract })` calls `client.dbSign({ contract, migrationsDir })`, which verifies every contract space and signs each one that verified, as `db sign` does.
## `db-sign-signs-extension-spaces`
`db sign` signs the contract space of every extension in the project together with the application's, and signs a space only when its schema verifies; a space that does not verify is reported with its differences and the command exits with code 4. If your extension's documentation explains what happens when a user's database does not match your contract space, add that `db sign` does not sign the space either. The Supabase extension's `src/contract/CONTRACT-FIDELITY.md` adds this sentence at the end of the paragraph that says a database with a different constraint set fails verify:
> `db sign` signs a contract space only when its schema verifies, so such a database cannot be signed for this pack's space either: `db sign` signs the application's space, reports this one with its differences and exits 4.
## `cli-error-from-caught`
`mapCaughtMigrationError(error)`, exported from `@prisma/orm-toolchain/cli/control-api` (`@internal/cli/control-api`), returned a CLI error unchanged and `null` for anything else, which the caller wrapped as `CLI.UNEXPECTED`. `errorFromCaught(error, why)`, exported from the same place, does the whole job: it returns a CLI error unchanged, reports any other error with a structured `NAMESPACE.SUBCODE` code as itself, reports anything else as `CLI.UNEXPECTED` with `why` given the error's message, and throws an `InternalError` again. Replace `mapCaughtMigrationError(error) ?? errorUnexpected(...)` with `errorFromCaught(error, (message) => ...)`. A caller that holds a database connection string passes it as `errorFromCaught(error, why, { connection })`, which removes it from every field of the reported error.
## `adapter-control-loads-temporal-polyfill`
The adapter contributes the PostgreSQL type constructors, which it imports from the target's control entry, and that entry sets up the fallback `Temporal` from `temporal-polyfill`. So the adapter's control entry, `@prisma/orm-postgres/adapter/control` or `@prisma/orm-target-postgres/adapter/control`, now loads `temporal-polyfill` as well. Both packages declare it as a required peer dependency, and npm, pnpm and bun install it automatically.
For each Yarn project whose tests or tooling load the adapter's control entry, `detection` finds the import. If the project's `package.json` does not already list `temporal-polyfill`, add `"temporal-polyfill": "^1.0.4"` to its `devDependencies` and reinstall. Without it, a test or script that loads the adapter's control entry fails because Node.js cannot find the package `temporal-polyfill`.
## `parameter-casts-use-base-names`
A cast written into query text names the data type's base name and never its parameters, because an explicit cast to `varchar(n)` truncates and to `numeric(p,s)` rounds:
| Before | After |
| --- | --- |
| `$1::integer` | `$1::int4` |
| `$1::smallint` | `$1::int2` |
| `$1::bigint` | `$1::int8` |
| `$1::real` | `$1::float4` |
| `$1::double precision` | `$1::float8` |
| `$1::boolean` | `$1::bool` |
| `$1::integer[]` | `$1::int4[]` |
Update test expectations and snapshots that assert such text. Extension types keep their names (`$1::vector`, `$1::geometry`).
## `sql-builder-reads-codec-descriptors`
When a computed projection such as `fns.eq` or `fns.raw` names a codec id but no codec ref, `sql()` asks `context.codecDescriptors.descriptorFor(codecId)` whether the composed stack registers that id. A hand-built `ExecutionContext` that a test passes to `sql()` must therefore carry `codecDescriptors`. Add `codecDescriptors: { descriptorFor: () => undefined }` to the stub, or return a descriptor for the ids the test expects to decode. A context from `createExecutionContext` already has it.
## `select-ast-options-carry-locking`
`SelectAstOptions` now has a required `locking: ReadonlyArray<LockingClause> | undefined`. Where you construct `new SelectAst({ ... })` from an existing select's fields, add `locking: ast.locking` when you rebuild an existing select. Pass `locking: undefined` only for a select you build from nothing. Setting it to `undefined` while rebuilding an existing select removes the caller's `FOR UPDATE` without an error.
## `render-lowered-sql-takes-capabilities`
`renderLoweredSql(ast, contract, codecDescriptorRegistry)` is now `renderLoweredSql(ast, contract, codecDescriptorRegistry, dataTypeLookup, capabilities)`. The renderer writes PostgreSQL parameter casts from the data type lookup (see `parameter-casts-use-base-names`): pass the stack's, or the target's own from `createPostgresBuiltinDataTypeLookup()` in `@internal/target-postgres/data-types`. It refuses a locking clause whose strength or option the given capabilities do not report. To render as the Postgres adapter does, pass its capabilities:
```ts
import { postgresAdapterCapabilities } from '@internal/adapter-postgres/adapter';
import { renderLoweredSql } from '@internal/adapter-postgres/sql-renderer';
renderLoweredSql(ast, contract, codecDescriptorRegistry, dataTypeLookup, postgresAdapterCapabilities);
```
## `sql-runtime-close-refusal-option`
`close()` on a SQL runtime waits for the work already in flight and refuses runtime-scope work that starts later with `DRIVER.NOT_CONNECTED` ("Runtime is closed"). The new `closeRefusal` option says when the refusal begins: `'at-once'` refuses from the call of `close()`, and `'when-idle'` refuses once the runtime has been idle for one turn of the event loop, so work that keeps it busy from the close onward is admitted.
The key is optional, so existing code compiles unchanged. In each place that constructs a runtime class that extends `SqlRuntimeBase`, or builds a `RuntimeOptions` object for one, decide whether to add it:
1. If the runtime belongs to a client or service that many callers share, such as a pooled client, pass `closeRefusal: 'at-once'`.
2. If the runtime belongs to one request or one scope, such as a per-request connection, leave the key out or pass `closeRefusal: 'when-idle'`.
## `writes-on-a-conditional-collection-are-refused`
`where`, `orderBy`, `limit`, `offset`, `distinct`, `distinctOn`, `cursor` and `include` now return the collection they were called on, with what they establish added to its type as a fact. A custom collection class keeps its methods through the chain, so `db.Post.where({ userId }).withTitle('orm')` and `db.Post.include('user').withTitle('orm')` compile.
The facts have names. Write a filtered collection's type as `Filtered<C>` and an ordered one as `Ordered<C>`. `Filtered<C>` is `C & HasWhere`, and `HasWhere` is the name error messages print. Import the names from `@internal/sql-orm-client`, or from the `orm-client` entry of the facade your extension depends on.
The collection changes from here to `custom-collection-methods-chain`, `scope` included, apply to the SQL ORM client only. The MongoDB ORM client did not change; skip matches in code that uses it. `variant-takes-discriminator-value` applies to both.
A write needs a collection that is filtered on every code path. Code that filters only on some paths compiled before and no longer does:
```ts
const posts = search ? db.Post.withTitle(search) : db.Post;
await posts.deleteAll(); // error: The 'this' context of type 'PostCollection' is not assignable to method's 'this' of type 'HasWhere'
```
The same applies to an `if` with an early return, a `switch`, a loop, and a `let` reassigned in an `if`. Make the write only where the filter was applied, or filter on every path:
```diff
- const posts = search ? db.Post.withTitle(search) : db.Post;
- await posts.deleteAll();
+ if (search) {
+ await db.Post.withTitle(search).deleteAll();
+ }
```
If the code relied on deleting or updating every row when there is no filter, that was the unsafe case the check now refuses. State the intent with an explicit filter instead.
## `writes-refuse-what-they-would-ignore`
`updateAll`, `updateAndCount`, `deleteAll` and `deleteAndCount` change every row that matches the filter. They ignored a `limit`, an `offset`, a `cursor`, `distinct` and `distinctOn` on the collection, so a chain such as `db.orm.public.Post.where(...).limit(10).deleteAll()` deleted every matching row, not ten, and one with `.cursor({ id })` deleted the rows before the cursor too. They now throw `ORM.ARGUMENT_INVALID` instead. An order does not change which rows they change, and they still accept it. Remove what the write would ignore, or read the rows and change them by their ids:
```diff
- await db.orm.public.Post.where({ userId }).limit(10).deleteAll();
+ const ids = (await db.orm.public.Post.where({ userId }).select('id').limit(10).all()).map((p) => p.id);
+ await db.orm.public.Post.where((p) => p.id.in(ids)).deleteAll();
```
`update` and `delete` change one row, the one `first()` returns, so the order, the offset, the cursor, `distinct` and `distinctOn` choose it; they are unchanged, except after `limit(0)`. `first()` replaces the limit with 1, so after `limit(0)` they changed one row; they now change none and return `null`. A `limit(0)` that a request can set, such as a page size of zero, no longer writes.
The exception is `update` with a relation callback, such as `posts: (posts) => posts.connect(...)`. It finds its row by the filter alone and ignored the order, the limit, the offset, the cursor, `distinct` and `distinctOn`, so it could change another row than the chain chose. It now throws `ORM.ARGUMENT_INVALID` on a collection with any of them. Find the row first and filter to it:
```diff
- await users.where({ teamId }).orderBy((u) => u.createdAt.asc()).update({ posts: (posts) => posts.connect([{ id: postId }]) });
+ const oldest = await users.where({ teamId }).orderBy((u) => u.createdAt.asc()).select('id').first();
+ if (oldest) await users.where({ id: oldest.id }).update({ posts: (posts) => posts.connect([{ id: postId }]) });
```
## `cursor-and-distinct-on-check-the-receiver`
`cursor` and `distinctOn` need an order on the collection they are called on. The check is on the receiver, so a cast on the argument no longer bypasses it:
```ts
await db.Post.cursor({ id } as never).all(); // error: The 'this' context of type 'PostCollection' is not assignable to method's 'this' of type 'HasOrderBy'
```
Add the order the query needs:
```diff
- await db.Post.cursor({ id } as never).all();
+ await db.Post.orderBy((post) => post.id.asc()).cursor({ id }).all();
```
Where the query is meant to run without an order, cast the collection instead of the argument:
```ts
import type { Ordered } from '@internal/sql-orm-client';
const unordered = db.Post as Ordered<typeof db.Post>;
await unordered.cursor({ id }).all();
```
## `apply-is-a-collection-member`
Collections have a new method, `apply(fn)`, which calls `fn` with the collection and returns the result. A custom collection class that declares its own `apply` with another signature no longer compiles. Rename that member and its call sites:
```diff
class PostCollection extends Collection<Contract, 'Post'> {
- apply(limit: number) { return this.limit(limit); }
+ firstPage(limit: number) { return this.limit(limit); }
}
```
An aggregate operation named `apply` is now refused with `ORM.AGGREGATE_OPERATION_RESERVED` when the client is built; rename the operation.
## `scope-is-a-collection-member`
Collections have a new method, `scope(body)`, which defines a scope for the collection's model: `db.orm.public.Post.scope((posts) => posts.select('id', 'title'))`. A custom collection class that declares its own `scope` with another signature no longer compiles. Rename that member and its call sites:
```diff
class PostCollection extends Collection<Contract, 'Post'> {
- scope(userId: string) { return this.where({ userId }); }
+ ownedBy(userId: string) { return this.where({ userId }); }
}
```
An aggregate operation named `scope` is now refused with `ORM.AGGREGATE_OPERATION_RESERVED` when the client is built; rename the operation.
## `overriding-a-chaining-method`
A class that extends `Collection` and overrides one of the chaining methods, or one of the methods that return rows (`all`, `first`, `create`, `createAll`, `upsert` and the writes), must declare the override with the new signature: a type parameter for the receiver, a `this` parameter of that type, and the result type the base method returns. Call the base method with `call` and explicit type arguments, so that the receiver type passes through:
```diff
class PostCollection extends Collection<Contract, 'Post'> {
- override limit(n: number) {
- return super.limit(Math.min(n, 100));
- }
+ override limit<Self>(this: Self, n: number): Self {
+ return super.limit.call<Self, [number], Self>(this, Math.min(n, 100));
+ }
}
```
Only classes that extend the SQL `Collection` are affected; skip matches in other classes.
## `collection-state-flags-are-boolean`
`DefaultCollectionTypeState` declares `hasWhere`, `hasOrderBy` and `hasUniqueFilter` as `boolean`, meaning not known. A method that establishes a flag sets it to `true`. Where your code expects `false`, expect `boolean`:
```diff
- type Check = Equal<CollectionTypeStateOf<typeof users>['hasOrderBy'], false>;
+ type Check = Equal<CollectionTypeStateOf<typeof users>['hasOrderBy'], boolean>;
```
A type of your own that sets a flag to `false` should set it to `boolean`. The writes still need `hasWhere: true`, and `cursor` and `distinctOn` still need `hasOrderBy: true`.
## `read-collection-state-and-row-with-helpers`
`where`, `orderBy` and `include` record what they establish in two declared properties, not in the type arguments of `Collection`. Inferring the third or fourth type argument gives the row and the state the collection started with. Read them with `CollectionRowOf` and `CollectionTypeStateOf` instead:
```diff
- type RowOf<C> = C extends Collection<infer _C, infer _M, infer Row, infer _S> ? Row : never;
- type UsersRow = RowOf<typeof users>;
+ import type { CollectionRowOf } from '@internal/sql-orm-client';
+ type UsersRow = CollectionRowOf<typeof users>;
```
To keep a helper of your own, constrain its parameter, because both helpers require one:
```ts
import type { CollectionRowOf, CollectionTypeStateOf, HasRow, HasTypeState } from '@internal/sql-orm-client';
type RowOf<C extends HasRow> = CollectionRowOf<C>;
type StateOf<C extends HasTypeState> = CollectionTypeStateOf<C>;
```
## `return-type-of-a-chaining-method`
The chaining methods are generic in their receiver, and `ReturnType` of a generic method uses the constraint of its type parameter. `ReturnType<PostCollection['where']>` is now `HasWhere`, and `ReturnType<PostCollection['limit']>` is `unknown`. Write the type with `Filtered` after `where`, `Ordered` after `orderBy`, and the collection type itself after `limit`, `offset`, `distinct`, `distinctOn` and `cursor`. You can also take `typeof` of a value:
```diff
- type MatchingPosts = ReturnType<PostCollection['where']>;
+ import type { Filtered } from '@internal/sql-orm-client';
+ type MatchingPosts = Filtered<PostCollection>;
```
`ReturnType` of a method of your own class, such as `ReturnType<PostCollection['withTitle']>`, still works.
## `chaining-methods-take-no-explicit-type-arguments`
These methods infer their receiver from the call. With explicit type arguments the receiver is not inferred: `posts.include<'user'>('user')` does not compile, `posts.distinct<['title']>('title')` and `posts.distinctOn<['title']>('title')` fail with "Expected 2 type arguments, but got 1", and `ReturnType<typeof posts.include<'user'>>` is `never`. Drop the type arguments. They are inferred from the arguments, so `distinct<['title']>('title')` becomes `distinct('title')`:
```diff
- const titles = posts.distinct<['title']>('title');
+ const titles = posts.distinct('title');
```
To name the type of an include, call `include` on a value and take its type:
```diff
- type WithUser = ReturnType<typeof posts.include<'user'>>;
+ const withUser = posts.include('user');
+ type WithUser = typeof withUser;
```
## `custom-collection-methods-chain`
Custom collection methods are now available after `where`, `orderBy`, `limit` and the other chaining methods. Where code on a client built with `orm({ collections })` repeats a class method's body inline after a chaining call on that class, it can call the method. A client without custom classes, such as `db.orm`, has no such methods.
```diff
return db.Post.forUser(userId)
- .orderBy((post) => post.createdAt.desc())
+ .newestFirst()
.limit(limit)
.all();
```
Here `newestFirst()` is a method of the application's `PostCollection` whose body is that `orderBy`.
## `variant-takes-discriminator-value`
For every `.variant(...)` call matched by `detection`, replace the variant's model name with the discriminator value that variant declares. Read the value from the contract: in PSL it is the second argument of the variant's `@@base(Base, "<value>")`; in the TypeScript builder it is the `value` under the base model's `discriminator.variants.<VariantName>`; in `contract.json` it is `domain.namespaces.<ns>.models.<Base>.variants.<VariantName>.value`.
```ts
// before
db.orm.public.Task.variant('Bug');
db.orm.events.variant('ViewProductEvent');
// after, for @@base(Task, "bug") and a ViewProductEvent variant declaring "view-product"
db.orm.public.Task.variant('bug');
db.orm.events.variant('view-product');
```
Custom collection methods that call `this.variant('<ModelName>')`, and helpers or closures that call `.variant()`, change the same way. When the argument is a variable or a forwarded parameter, follow it back to where the model name is written and replace it there, including in type annotations that list variant model names. Update code comments and READMEs that show `.variant('<ModelName>')` too.
The type checker catches most old call sites, because the parameter only accepts the base model's declared values. It does not catch a call whose model-name argument happens to equal a declared value, for example a variant model declared with `@@base(Base, "Admin")`. Such a call keeps compiling and now selects the variant declaring that value, so check every call site against the contract rather than relying on type errors.
A call that passes a value through `as never` or another cast also escapes the type checker. At runtime, a value the base model does not declare, or a `variant()` call on a model without a discriminator, throws `ORM.ARGUMENT_INVALID`. The error names the model and lists its declared values. Previously the call returned the collection unchanged, so the query read every variant. Code that relied on that fallback must stop calling `variant()` in that case.
A second `.variant()` call on a variant collection is rejected. It no longer replaces the first selection: it is a type error, and at runtime it throws `ORM.OPERATION_UNSUPPORTED` naming the model and the discriminator value already selected. Select each variant from the base collection instead, and keep a reference to the base collection where code needs more than one variant.
```ts
// before
const bugs = db.orm.public.Task.variant('Bug');
const features = bugs.variant('Feature');
// after
const tasks = db.orm.public.Task;
const bugs = tasks.variant('bug');
const features = tasks.variant('feature');
```
upgrading/extension/upgrades/8.0.0-rc.14-to-8.0.0-rc.15/scripts/data-type-in-contract/data-type-in-contract.ts
#!/usr/bin/env node
/**
* Rewrites every SQL contract in a project from `nativeType` to `dataType`, and a list stored as
* `many: true` to `many: { elementNullable: false }`, then renames the snapshot directories and
* rewrites every migration, ref, `migration.ts` and `contract.d.ts` that names an old storage hash.
*
* Usage: node data-type-in-contract.ts [project-root] [--data-type <codec id>=<data type id>]...
*
* `--data-type` names the data type of a codec the script's table does not know for a contract's
* target, such as an extension's own codec. It cannot change the data type of a codec the table
* knows for that target.
*
* The project root defaults to the working directory. The script reads and writes files only. It
* needs no database, network or configured stack, and a project already in the new format is left
* unchanged. When it stops (an unknown codec, or a snapshot directory that already exists with
* different content) it changes no file, prints one line per case and exits 1. When it finishes it
* prints how many files and snapshot directories it changed and each storage hash it replaced.
*
* The storage hash and migration hash rules are copied from `@internal/contract` and
* `@internal/migration-tools` because a project's strict `node_modules` does not expose them.
*/
import { createHash } from 'node:crypto';
import {
chmodSync,
type Dirent,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
type Json = null | boolean | number | string | Json[] | JsonRecord;
interface JsonRecord {
[key: string]: Json;
}
const DATA_TYPES: Readonly<Record<string, Readonly<Record<string, string>>>> = {
postgres: {
'pg/text@1': 'pg/text',
'pg/text-array@1': 'pg/text-array',
'pg/enum@1': 'pg/enum',
'pg/char@1': 'pg/char',
'pg/varchar@1': 'pg/varchar',
'pg/int@1': 'pg/int4',
'pg/int2@1': 'pg/int2',
'pg/int4@1': 'pg/int4',
'pg/int8@1': 'pg/int8',
'pg/int8number@1': 'pg/int8',
'pg/float@1': 'pg/float8',
'pg/float4@1': 'pg/float4',
'pg/float8@1': 'pg/float8',
'pg/numeric@1': 'pg/numeric',
'pg/unboundedint@1': 'pg/numeric',
'pg/bool@1': 'pg/bool',
'pg/bit@1': 'pg/bit',
'pg/varbit@1': 'pg/varbit',
'pg/bytea@1': 'pg/bytea',
'pg/uuid@1': 'pg/uuid',
'pg/inet@1': 'pg/inet',
'pg/tsquery@1': 'pg/tsquery',
'pg/interval@1': 'pg/interval',
'pg/json@1': 'pg/json',
'pg/jsonb@1': 'pg/jsonb',
'pg/timetz@1': 'pg/timetz',
'pg/date-temporal@1': 'pg/date',
'pg/timestamp-temporal@1': 'pg/timestamp',
'pg/timestamptz-temporal@1': 'pg/timestamptz',
'pg/time-temporal@1': 'pg/time',
'pg/date-string@1': 'pg/date',
'pg/timestamp-string@1': 'pg/timestamp',
'pg/timestamptz-string@1': 'pg/timestamptz',
'pg/time-string@1': 'pg/time',
'pg/timestamptz-date@1': 'pg/timestamptz',
'pg/timestamptz@1': 'pg/timestamptz',
'pg/timestamp@1': 'pg/timestamp',
'pg/time@1': 'pg/time',
'pg/date@1': 'pg/date',
'sql/char@1': 'pg/char',
'sql/varchar@1': 'pg/varchar',
'sql/int@1': 'pg/int4',
'sql/float@1': 'pg/float8',
'sql/text@1': 'pg/text',
'sql/timestamp@1': 'pg/timestamptz',
'pg/vector@1': 'pgvector/vector',
'pg/geometry@1': 'postgis/geometry',
'arktype/json@1': 'pg/jsonb',
},
sqlite: {
'sqlite/text@1': 'sqlite/text',
'sqlite/json@1': 'sqlite/text',
'sqlite/datetime@1': 'sqlite/text',
'sqlite/integer@1': 'sqlite/integer',
'sqlite/bigint@1': 'sqlite/integer',
'sqlite/bigintnumber@1': 'sqlite/integer',
'sql/int@1': 'sqlite/integer',
'sqlite/real@1': 'sqlite/real',
'sql/float@1': 'sqlite/real',
'sqlite/blob@1': 'sqlite/blob',
'sql/char@1': 'sqlite/character',
'sql/varchar@1': 'sqlite/character-varying',
},
};
const SQLITE_INTEGER_CODECS = new Set([
'sqlite/integer@1',
'sql/int@1',
'sqlite/bigint@1',
'sqlite/bigintnumber@1',
]);
const SQLITE_JSON_CODEC = 'sqlite/json@1';
const SKIPPED_DIRECTORIES = new Set(['node_modules', '.git', 'dist', 'build']);
const HASH = /^[0-9a-f]{64}$/;
const TEMPORARY_SUFFIX = '.data-type-in-contract-tmp';
function isRecord(value: unknown): value is JsonRecord {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const prototype: unknown = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function parseJson(text: string): Json | undefined {
try {
const value: Json = JSON.parse(text);
return value;
} catch {
return undefined;
}
}
function sha256(text: string): string {
return createHash('sha256').update(text).digest('hex');
}
function compareCodeUnits(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
function sortKeys(value: Json): Json {
if (Array.isArray(value)) return value.map(sortKeys);
if (!isRecord(value)) return value;
const sorted: JsonRecord = {};
for (const key of Object.keys(value).sort(compareCodeUnits)) {
const child = value[key];
if (child !== undefined) sorted[key] = sortKeys(child);
}
return sorted;
}
function canonicalizeJson(value: Json): string {
return JSON.stringify(sortKeys(value));
}
type PathSegment = string | readonly string[];
function matchesPath(path: readonly string[], pattern: readonly PathSegment[]): boolean {
if (path.length !== pattern.length) return false;
return pattern.every((segment, index) => {
const value = path[index];
if (value === undefined) return false;
if (segment === '*') return true;
return typeof segment === 'string' ? value === segment : segment.includes(value);
});
}
const SQL_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
['storage', 'namespaces', '*', 'entries', 'table'],
['storage', 'namespaces', '*', 'entries', 'table', '*'],
['storage', 'namespaces', '*', 'entries', 'table', '*', ['uniques', 'indexes', 'foreignKeys']],
['storage', 'namespaces', '*', 'entries', 'table', '*', 'indexes', 'unique'],
['storage', 'namespaces', '*', 'entries', 'table', '*', 'columns', '*', 'default', 'value'],
];
const COLUMN_DEFAULT_VALUE: readonly PathSegment[] = [
'storage',
'namespaces',
'*',
'entries',
'table',
'*',
'columns',
'*',
'default',
'value',
];
const COLUMN_PATHS: readonly (readonly PathSegment[])[] = [
['storage', 'namespaces', '*', 'entries', 'table', '*', 'columns', '*'],
['storage', 'namespaces', '*', 'tables', '*', 'columns', '*'],
];
const NATIVE_TYPE_PATHS: readonly (readonly PathSegment[])[] = [
...COLUMN_PATHS,
['storage', 'types', '*'],
];
const LIST_PATHS: readonly (readonly PathSegment[])[] = [
...COLUMN_PATHS,
['domain', 'namespaces', '*', ['models', 'valueObjects'], '*', 'fields', '*'],
];
const FRAMEWORK_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
['domain', 'namespaces'],
['domain', 'namespaces', '*'],
['domain', 'namespaces', '*', 'models'],
['domain', 'namespaces', '*', 'models', '*', 'relations'],
['domain', 'namespaces', '*', 'models', '*', 'storage'],
['storage', 'namespaces'],
['storage', 'namespaces', '*', 'entries'],
['roots'],
['extensions'],
['extensions', '*'],
['capabilities'],
['meta'],
['execution', 'mutations', 'defaults'],
];
const TOP_LEVEL_ORDER = [
'schemaVersion',
'canonicalVersion',
'targetFamily',
'target',
'profileHash',
'roots',
'domain',
'storage',
'execution',
'capabilities',
'extensions',
'defaultControlPolicy',
'meta',
];
function isDefaultValue(value: Json): boolean {
if (value === false) return true;
if (Array.isArray(value)) return value.length === 0;
return isRecord(value) && Object.keys(value).length === 0;
}
function isPreservedEmpty(path: readonly string[], key: string): boolean {
if (key === 'nullable') return true;
if (key === 'elementNullable' && path.at(-2) === 'many') return true;
if (FRAMEWORK_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern))) return true;
if (
path.length >= COLUMN_DEFAULT_VALUE.length &&
matchesPath(path.slice(0, COLUMN_DEFAULT_VALUE.length), COLUMN_DEFAULT_VALUE)
)
return true;
return SQL_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern));
}
function omitDefaults(value: Json, path: readonly string[]): Json {
if (Array.isArray(value)) return value.map((item) => omitDefaults(item, path));
if (!isRecord(value)) return value;
const result: JsonRecord = {};
for (const [key, child] of Object.entries(value)) {
const childPath = [...path, key];
if (key === '_generated') continue;
if (key === 'generated' && child === false) continue;
if ((key === 'onDelete' || key === 'onUpdate') && child === 'noAction') continue;
if (isDefaultValue(child) && !isPreservedEmpty(childPath, key)) continue;
result[key] = omitDefaults(child, childPath);
}
return result;
}
function compareByName(a: Json, b: Json): number {
const nameA = isRecord(a) && typeof a['name'] === 'string' ? a['name'] : '';
const nameB = isRecord(b) && typeof b['name'] === 'string' ? b['name'] : '';
return compareCodeUnits(nameA, nameB);
}
function sortTableArrays(storage: Json): Json {
if (!isRecord(storage)) return storage;
const namespaces = storage['namespaces'];
if (!isRecord(namespaces)) return storage;
const sortedNamespaces: JsonRecord = {};
for (const [namespaceId, namespace] of Object.entries(namespaces)) {
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
const tables = isRecord(entries) ? entries['table'] : undefined;
if (!isRecord(namespace) || !isRecord(entries) || !isRecord(tables)) {
sortedNamespaces[namespaceId] = namespace;
continue;
}
const sortedTables: JsonRecord = {};
for (const [tableName, table] of Object.entries(tables)) {
if (!isRecord(table)) {
sortedTables[tableName] = table;
continue;
}
const sortedTable: JsonRecord = { ...table };
for (const key of ['checks', 'indexes', 'uniques']) {
const list = table[key];
if (Array.isArray(list)) sortedTable[key] = [...list].sort(compareByName);
}
sortedTables[tableName] = sortedTable;
}
sortedNamespaces[namespaceId] = { ...namespace, entries: { ...entries, table: sortedTables } };
}
return { ...storage, namespaces: sortedNamespaces };
}
function orderTopLevel(value: JsonRecord): JsonRecord {
const ordered: JsonRecord = {};
const remaining = new Set(Object.keys(value));
for (const key of TOP_LEVEL_ORDER) {
const child = value[key];
if (child !== undefined) {
ordered[key] = child;
remaining.delete(key);
}
}
for (const key of [...remaining].sort(compareCodeUnits)) {
const child = value[key];
if (child !== undefined) ordered[key] = child;
}
return ordered;
}
function omitNamespaceKinds(storage: JsonRecord): JsonRecord {
const namespaces = storage['namespaces'];
if (!isRecord(namespaces)) return storage;
const stripped: JsonRecord = {};
for (const [namespaceId, namespace] of Object.entries(namespaces)) {
if (isRecord(namespace)) {
const { kind: _kind, ...rest } = namespace;
stripped[namespaceId] = rest;
} else {
stripped[namespaceId] = namespace;
}
}
return { ...storage, namespaces: stripped };
}
function computeStorageHash(contract: JsonRecord): string {
const storage = isRecord(contract['storage']) ? contract['storage'] : {};
const { storageHash: _published, ...withoutHash } = storage;
const hashed: JsonRecord = {
schemaVersion: '1',
targetFamily: typeof contract['targetFamily'] === 'string' ? contract['targetFamily'] : '',
target: typeof contract['target'] === 'string' ? contract['target'] : '',
profileHash: '',
roots: {},
domain: { namespaces: {} },
storage: omitNamespaceKinds(withoutHash),
extensions: {},
capabilities: {},
meta: {},
};
const withoutDefaults = omitDefaults(hashed, []);
if (!isRecord(withoutDefaults)) return '';
const withSortedStorage = {
...withoutDefaults,
storage: sortTableArrays(withoutDefaults['storage'] ?? {}),
};
const sorted = sortKeys(withSortedStorage);
return isRecord(sorted) ? sha256(JSON.stringify(orderTopLevel(sorted), null, 2)) : '';
}
function computeMigrationHash(metadata: JsonRecord, ops: Json): string {
const { migrationHash: _migrationHash, ...withoutHash } = metadata;
const parts = [canonicalizeJson(withoutHash), canonicalizeJson(ops)].map(sha256);
return sha256(canonicalizeJson(parts));
}
interface ProjectFiles {
readonly json: readonly string[];
readonly migrationJson: readonly string[];
readonly refs: readonly string[];
readonly migrationTs: readonly string[];
readonly leftovers: readonly string[];
}
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
/** A file, or a link to one: the script reads and writes through links to files. */
function isFileEntry(dir: string, entry: Dirent): boolean {
return entry.isFile() || (entry.isSymbolicLink() && isFile(join(dir, entry.name)));
}
/** A file the script reads, by the path it was reached through: its own path rather than a link's. */
interface FoundFile {
readonly path: string;
readonly linked: boolean;
readonly inMigrationPackage: boolean;
}
function listFiles(root: string): ProjectFiles {
const leftovers: string[] = [];
const visited = new Set<string>();
const found = new Map<string, FoundFile>();
const visit = (dir: string): void => {
const realDir = realpathSync(dir);
if (visited.has(realDir)) return;
visited.add(realDir);
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
compareCodeUnits(a.name, b.name),
);
const inMigrationPackage = entries.some(
(entry) => entry.name === 'migration.json' && isFileEntry(dir, entry),
);
for (const entry of entries) {
const path = join(dir, entry.name);
if (entry.name.endsWith(TEMPORARY_SUFFIX)) {
leftovers.push(path);
continue;
}
if (entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(path))) {
if (!SKIPPED_DIRECTORIES.has(entry.name)) visit(path);
continue;
}
if (!isFileEntry(dir, entry)) continue;
const realFile = realpathSync(path);
const linked = entry.isSymbolicLink();
const known = found.get(realFile);
if (known === undefined || (known.linked && !linked))
found.set(realFile, { path, linked, inMigrationPackage });
}
};
visit(root);
const files = [...found.values()];
const named = (test: (file: FoundFile, name: string) => boolean): string[] =>
files.filter((file) => test(file, basename(file.path))).map((file) => file.path);
return {
json: named((_file, name) => name.endsWith('.json')),
migrationJson: named((_file, name) => name === 'migration.json'),
refs: named((file, name) => name.endsWith('.json') && basename(dirname(file.path)) === 'refs'),
migrationTs: named((file, name) => name === 'migration.ts' && file.inMigrationPackage),
leftovers,
};
}
function isSnapshotContract(path: string): boolean {
const snapshotDir = dirname(path);
return (
basename(path) === 'contract.json' &&
HASH.test(basename(snapshotDir)) &&
basename(dirname(snapshotDir)) === 'snapshots'
);
}
interface Rewrite {
readonly contract: JsonRecord;
readonly changed: boolean;
readonly unknownCodecs: readonly string[];
readonly defaultRewrites: ReadonlyMap<string, Json>;
readonly integerEnums: ReadonlySet<string>;
readonly jsonEnums: ReadonlyMap<string, JsonEnumValues>;
readonly columnCardinalities: ReadonlyMap<string, Json>;
}
/** The rewritten values of an enum typed by `sqlite/json@1`: its value set and its members by name. */
interface JsonEnumValues {
values: readonly Json[] | undefined;
readonly members: Map<string, Json>;
}
function rewriteDefault(target: string, codecId: string, value: JsonRecord): JsonRecord {
const defaultValue = value['default'];
if (target !== 'sqlite' || !isRecord(defaultValue) || defaultValue['kind'] !== 'literal')
return value;
const literal = defaultValue['value'];
if (literal === undefined) return value;
if (codecId === SQLITE_JSON_CODEC)
return { ...value, default: { ...defaultValue, value: canonicalizeJson(literal) } };
if (
SQLITE_INTEGER_CODECS.has(codecId) &&
typeof literal === 'number' &&
Number.isInteger(literal)
)
return { ...value, default: { ...defaultValue, value: BigInt(literal).toString() } };
return value;
}
function digitText(value: Json): Json {
return typeof value === 'number' && Number.isInteger(value) ? BigInt(value).toString() : value;
}
function mapRecord(value: Json, map: (key: string, child: Json) => Json): Json {
if (!isRecord(value)) return value;
const result: JsonRecord = {};
for (const [key, child] of Object.entries(value)) result[key] = map(key, child);
return result;
}
function mapEntries(
value: Json,
path: readonly string[],
patterns: readonly (readonly PathSegment[])[],
rewrite: (entry: JsonRecord, path: readonly string[]) => JsonRecord,
): Json {
if (!isRecord(value)) return value;
if (patterns.some((pattern) => matchesPath(path, pattern))) return rewrite(value, path);
const leadsToEntry = patterns.some(
(pattern) => path.length < pattern.length && matchesPath(path, pattern.slice(0, path.length)),
);
return leadsToEntry
? mapRecord(value, (key, child) => mapEntries(child, [...path, key], patterns, rewrite))
: value;
}
function mapContractEntries(
contract: JsonRecord,
patterns: readonly (readonly PathSegment[])[],
rewrite: (entry: JsonRecord, path: readonly string[]) => JsonRecord,
): JsonRecord {
const result: JsonRecord = {};
for (const [key, child] of Object.entries(contract))
result[key] = mapEntries(child, [key], patterns, rewrite);
return result;
}
/** Release 8.0.0-rc.14 stored a list column or field as `many: true`; the current form is `many: { elementNullable: false }`. */
function currentListForm(entry: JsonRecord): JsonRecord {
return entry['many'] === true ? { ...entry, many: { elementNullable: false } } : entry;
}
function storageColumnKey(namespaceId: string, table: string, column: string): string {
return `${namespaceId}\u0000${table}\u0000${column}`;
}
/** The `many` of every storage column, `false` for a column that is not a list. */
function columnCardinalities(contract: JsonRecord): ReadonlyMap<string, Json> {
const cardinalities = new Map<string, Json>();
const storage = contract['storage'];
const namespaces = isRecord(storage) ? storage['namespaces'] : undefined;
for (const [namespaceId, namespace] of isRecord(namespaces) ? Object.entries(namespaces) : []) {
if (!isRecord(namespace)) continue;
const entries = namespace['entries'];
const tables = isRecord(entries) ? entries['table'] : namespace['tables'];
for (const [tableName, table] of isRecord(tables) ? Object.entries(tables) : []) {
const columns = isRecord(table) ? table['columns'] : undefined;
for (const [columnName, column] of isRecord(columns) ? Object.entries(columns) : [])
cardinalities.set(
storageColumnKey(namespaceId, tableName, columnName),
isRecord(column) ? (column['many'] ?? false) : false,
);
}
}
return cardinalities;
}
function enumKey(namespaceId: string, name: string): string {
return `${namespaceId}\u0000${name}`;
}
/**
* On SQLite the integer codecs store digit text and `sqlite/json@1` stores the JSON text of a
* document, so the members of an enum typed by one of them, in the domain and in the storage value
* set its columns name, are rewritten to that text too.
*/
function rewriteSqliteEnums(contract: JsonRecord): {
readonly contract: JsonRecord;
readonly integerEnums: ReadonlySet<string>;
readonly jsonEnums: ReadonlyMap<string, JsonEnumValues>;
} {
const integerEnums = new Set<string>();
const jsonEnums = new Map<string, JsonEnumValues>();
const jsonEnum = (key: string): JsonEnumValues => {
const existing = jsonEnums.get(key);
if (existing !== undefined) return existing;
const created: JsonEnumValues = { values: undefined, members: new Map() };
jsonEnums.set(key, created);
return created;
};
const domainCodecs = new Map<string, string>();
const domain = mapRecord(contract['domain'] ?? null, (key, namespaces) =>
key !== 'namespaces'
? namespaces
: mapRecord(namespaces, (namespaceId, namespace) =>
mapRecord(namespace, (kind, enums) =>
kind !== 'enum'
? enums
: mapRecord(enums, (name, enumType) => {
const codecId = isRecord(enumType) ? enumType['codecId'] : undefined;
if (typeof codecId === 'string')
domainCodecs.set(enumKey(namespaceId, name), codecId);
const members = isRecord(enumType) ? enumType['members'] : undefined;
if (!isRecord(enumType) || typeof codecId !== 'string' || !Array.isArray(members))
return enumType;
const key = enumKey(namespaceId, name);
if (SQLITE_INTEGER_CODECS.has(codecId)) {
integerEnums.add(key);
return {
...enumType,
members: members.map((member) =>
isRecord(member) && member['value'] !== undefined
? { ...member, value: digitText(member['value']) }
: member,
),
};
}
if (codecId !== SQLITE_JSON_CODEC) return enumType;
const rewritten = jsonEnum(key);
return {
...enumType,
members: members.map((member) => {
const value = isRecord(member) ? member['value'] : undefined;
if (!isRecord(member) || value === undefined) return member;
const text = canonicalizeJson(value);
if (typeof member['name'] === 'string')
rewritten.members.set(member['name'], text);
return { ...member, value: text };
}),
};
}),
),
),
);
const columnCodecs = new Map<string, string>();
const storage = contract['storage'];
const namespaces = isRecord(storage) ? storage['namespaces'] : undefined;
for (const namespace of isRecord(namespaces) ? Object.values(namespaces) : []) {
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
const tables = isRecord(entries) ? entries['table'] : undefined;
for (const table of isRecord(tables) ? Object.values(tables) : []) {
const columns = isRecord(table) ? table['columns'] : undefined;
for (const column of isRecord(columns) ? Object.values(columns) : []) {
const valueSet = isRecord(column) ? column['valueSet'] : undefined;
const codecId = isRecord(column) ? column['codecId'] : undefined;
if (
isRecord(valueSet) &&
typeof valueSet['namespaceId'] === 'string' &&
typeof valueSet['entityName'] === 'string' &&
typeof codecId === 'string'
)
columnCodecs.set(enumKey(valueSet['namespaceId'], valueSet['entityName']), codecId);
}
}
}
const nextStorage = mapRecord(storage ?? null, (key, storageNamespaces) =>
key !== 'namespaces'
? storageNamespaces
: mapRecord(storageNamespaces, (namespaceId, namespace) =>
mapRecord(namespace, (namespaceKey, entries) =>
namespaceKey !== 'entries'
? entries
: mapRecord(entries, (entryKind, valueSets) =>
entryKind !== 'valueSet'
? valueSets
: mapRecord(valueSets, (name, valueSet) => {
const key = enumKey(namespaceId, name);
const codecId = columnCodecs.get(key) ?? domainCodecs.get(key);
const values = isRecord(valueSet) ? valueSet['values'] : undefined;
if (codecId === undefined || !isRecord(valueSet) || !Array.isArray(values))
return valueSet;
if (SQLITE_INTEGER_CODECS.has(codecId)) {
integerEnums.add(key);
return { ...valueSet, values: values.map(digitText) };
}
if (codecId !== SQLITE_JSON_CODEC) return valueSet;
const texts = values.map(canonicalizeJson);
jsonEnum(key).values = texts;
return { ...valueSet, values: texts };
}),
),
),
),
);
return {
contract: {
...contract,
...(contract['domain'] === undefined ? {} : { domain }),
...(storage === undefined ? {} : { storage: nextStorage }),
},
integerEnums,
jsonEnums,
};
}
function dataTypesFor(
target: string,
extra: ReadonlyMap<string, string>,
): Readonly<Record<string, string>> {
return { ...Object.fromEntries(extra), ...DATA_TYPES[target] };
}
const CODEC_ID = /^[^\s=]+$/;
const DATA_TYPE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/;
interface Options {
readonly root: string;
readonly dataTypes: ReadonlyMap<string, string>;
readonly errors: readonly string[];
}
function parseOptions(args: readonly string[]): Options {
const dataTypes = new Map<string, string>();
const errors: string[] = [];
const positional: string[] = [];
for (let index = 0; index < args.length; index++) {
const arg = args[index] ?? '';
if (arg !== '--data-type' && !arg.startsWith('--data-type=')) {
if (arg.startsWith('--')) errors.push(`${arg}: unknown option`);
else positional.push(arg);
continue;
}
const value = arg === '--data-type' ? (args[++index] ?? '') : arg.slice('--data-type='.length);
const separator = value.indexOf('=');
const codecId = value.slice(0, separator);
const dataType = value.slice(separator + 1);
if (separator === -1 || !CODEC_ID.test(codecId) || !DATA_TYPE_ID.test(dataType)) {
errors.push(
`--data-type ${value}: expected <codec id>=<data type id>, for example acme/shape@1=acme/shape`,
);
continue;
}
dataTypes.set(codecId, dataType);
}
return { root: resolve(positional[0] ?? process.cwd()), dataTypes, errors };
}
function rewriteContract(contract: JsonRecord, extra: ReadonlyMap<string, string>): Rewrite {
const target = typeof contract['target'] === 'string' ? contract['target'] : '';
const table = dataTypesFor(target, extra);
const unknownCodecs = new Set<string>();
const defaultRewrites = new Map<string, Json>();
let changed = false;
const rewriteEntry = (value: JsonRecord, path: readonly string[]): JsonRecord => {
const codecId = value['codecId'];
if (typeof codecId !== 'string' || typeof value['nativeType'] !== 'string') return value;
const dataType = table[codecId];
if (dataType === undefined) {
unknownCodecs.add(codecId);
return value;
}
changed = true;
const { nativeType: _nativeType, ...rest } = value;
const rewritten = rewriteDefault(target, codecId, { ...rest, dataType });
const before = value['default'];
const after = rewritten['default'];
if (isRecord(before) && isRecord(after) && before['value'] !== after['value']) {
const columnKey = path.slice(-3);
const newValue = after['value'];
if (columnKey[1] === 'columns' && newValue !== undefined)
defaultRewrites.set(`${columnKey[0]}\u0000${columnKey[2]}`, newValue);
}
return rewritten;
};
const rewritten = mapContractEntries(contract, NATIVE_TYPE_PATHS, rewriteEntry);
const extensions = contract['extensions'];
for (const pack of isRecord(extensions) ? Object.values(extensions) : []) {
const types = isRecord(pack) ? pack['types'] : undefined;
const storage = isRecord(types) ? types['storage'] : undefined;
if (Array.isArray(storage) && storage.some((entry) => isRecord(entry) && 'nativeType' in entry))
changed = true;
}
if (changed && isRecord(extensions)) {
rewritten['extensions'] = mapRecord(extensions, (_packId, pack) => {
const types = isRecord(pack) ? pack['types'] : undefined;
if (!isRecord(pack) || !isRecord(types) || !('storage' in types)) return pack;
const { storage: _storage, ...rest } = types;
return { ...pack, types: rest };
});
}
const withLists = changed
? mapContractEntries(rewritten, LIST_PATHS, currentListForm)
: rewritten;
const enums =
target === 'sqlite' && changed
? rewriteSqliteEnums(withLists)
: { contract: withLists, integerEnums: new Set<string>(), jsonEnums: new Map() };
return {
contract: enums.contract,
changed,
unknownCodecs: [...unknownCodecs].sort(compareCodeUnits),
defaultRewrites,
integerEnums: enums.integerEnums,
jsonEnums: enums.jsonEnums,
columnCardinalities: columnCardinalities(withLists),
};
}
function withStorageHash(contract: JsonRecord, hash: string): JsonRecord {
const storage = isRecord(contract['storage']) ? contract['storage'] : {};
return { ...contract, storage: { ...storage, storageHash: hash } };
}
function emittedForm(contract: JsonRecord, original: string): string {
const { _generated: generated, ...rest } = contract;
const sorted = sortKeys(rest);
const ordered = isRecord(sorted) ? orderTopLevel(sorted) : {};
if (generated !== undefined) ordered['_generated'] = generated;
return `${JSON.stringify(ordered, null, 2)}${original.endsWith('\n') ? '\n' : ''}`;
}
function snapshotForm(contract: JsonRecord): string {
return `${canonicalizeJson(contract)}\n`;
}
interface Block {
readonly start: number;
end: number;
readonly label: string;
readonly parent: Block | undefined;
}
function scanBlocks(text: string): Block[] {
const blocks: Block[] = [];
const open: Block[] = [];
let quote: string | undefined;
for (let index = 0; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === "'" || char === '"' || char === '`') {
quote = char;
continue;
}
if (char === '{') {
const key =
/(?:readonly\s+)?([\p{ID_Start}_$][\p{ID_Continue}$]*|'[^']*'|"[^"]*")\s*\??\s*:\s*$/u.exec(
text.slice(Math.max(0, index - 200), index),
);
const block: Block = {
start: index,
end: text.length,
label: key?.[1]?.replace(/^['"]|['"]$/g, '') ?? '',
parent: open.at(-1),
};
open.push(block);
blocks.push(block);
} else if (char === '}') {
const block = open.pop();
if (block !== undefined) block.end = index;
}
}
return blocks;
}
function innermostBlock(blocks: readonly Block[], position: number): Block | undefined {
let found: Block | undefined;
for (const block of blocks) {
if (
block.start < position &&
position < block.end &&
(found === undefined || block.start > found.start)
)
found = block;
}
return found;
}
function isColumnBlock(block: Block): boolean {
return block.parent?.label === 'columns';
}
function isStorageTypeBlock(blocks: readonly Block[], block: Block): boolean {
const types = block.parent;
return (
types?.label === 'types' &&
blocks.some((other) => other.parent === types.parent && other.label === 'namespaces')
);
}
function isInColumnDefault(block: Block): boolean {
for (let ancestor = block.parent; ancestor !== undefined; ancestor = ancestor.parent)
if (
ancestor.label === 'default' &&
ancestor.parent !== undefined &&
isColumnBlock(ancestor.parent)
)
return true;
return false;
}
function holdsNativeType(blocks: readonly Block[], block: Block): boolean {
return (isColumnBlock(block) || isStorageTypeBlock(blocks, block)) && !isInColumnDefault(block);
}
/** The key of a storage column block, in the `entries.table` and the older `tables` layout. */
function columnBlockKey(block: Block): string | undefined {
const table = block.parent?.parent;
const tables = table?.parent;
const namespace = tables?.label === 'table' ? tables.parent?.parent : tables?.parent;
if (!isColumnBlock(block) || table === undefined || namespace === undefined) return undefined;
if (isInColumnDefault(block)) return undefined;
return storageColumnKey(namespace.label, table.label, block.label);
}
function manyType(many: Json): string {
return isRecord(many) && typeof many['elementNullable'] === 'boolean'
? `{ readonly elementNullable: ${many['elementNullable']} }`
: 'false';
}
/** Where a member written last in `block` goes, and the text that writes it. */
function lastMember(text: string, block: Block, member: string): { start: number; text: string } {
const lineStart = text.lastIndexOf('\n', block.end - 1) + 1;
const closingIndent = text.slice(lineStart, block.end);
if (closingIndent.trim() === '')
return { start: lineStart, text: `${closingIndent} ${member};\n` };
const contentEnd = block.start + 1 + text.slice(block.start + 1, block.end).trimEnd().length;
return {
start: contentEnd,
text: text[contentEnd - 1] === ';' ? ` ${member};` : `; ${member}`,
};
}
function prettierString(value: string): string {
const singles = (value.match(/'/g) ?? []).length;
const doubles = (value.match(/"/g) ?? []).length;
const quote = singles > doubles ? '"' : "'";
const escaped = JSON.stringify(value)
.slice(1, -1)
.replaceAll(String.fromCharCode(0x2028), '\\u2028')
.replaceAll(String.fromCharCode(0x2029), '\\u2029')
.replace(
/\\(.)|(["'])/gs,
(match: string, escapedChar: string | undefined, bare: string | undefined) => {
if (bare !== undefined) return bare === quote ? `\\${bare}` : bare;
if (escapedChar === quote) return match;
if (escapedChar === '"' || escapedChar === "'") return escapedChar;
return match;
},
);
return `${quote}${escaped}${quote}`;
}
function typeLiteral(value: Json): string {
return typeof value === 'string' ? prettierString(value) : JSON.stringify(value);
}
function genericArgumentEnd(text: string, start: number): number {
let depth = 0;
let quote: string | undefined;
for (let index = start; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === "'" || char === '"' || char === '`') quote = char;
else if (char === '{' || char === '[' || char === '(' || char === '<') depth++;
else if (char === '}' || char === ']' || char === ')') depth--;
else if (char === '>') {
if (depth === 0) return index;
depth--;
}
}
return text.length;
}
function memberOf(
text: string,
blocks: readonly Block[],
block: Block,
name: string,
): string | undefined {
const pattern = new RegExp(`readonly ${name}: (['"])((?:\\\\.|(?!\\1).)*)\\1`, 'g');
for (const member of text.slice(block.start, block.end).matchAll(pattern)) {
if (innermostBlock(blocks, block.start + member.index) === block) return member[2];
}
return undefined;
}
/** The end of the type literal that starts at `start`: the first `;`, `,` or closing bracket outside it. */
function literalEnd(text: string, start: number): number {
let depth = 0;
let quote: string | undefined;
for (let index = start; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === "'" || char === '"' || char === '`') quote = char;
else if (char === '{' || char === '[' || char === '(' || char === '<') depth++;
else if (char === '}' || char === ']' || char === ')' || char === '>') {
if (depth === 0) return index;
depth--;
} else if ((char === ';' || char === ',') && depth === 0) return index;
}
return text.length;
}
function bracketEnd(text: string, open: number): number {
let depth = 0;
let quote: string | undefined;
for (let index = open; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === "'" || char === '"' || char === '`') quote = char;
else if (char === '[' || char === '{' || char === '(') depth++;
else if (char === ']' || char === '}' || char === ')') {
depth--;
if (depth === 0) return index + 1;
}
}
return text.length;
}
function statementRemoval(
text: string,
start: number,
end: number,
): { start: number; end: number; text: string } {
const after = /^;?[ \t]*/.exec(text.slice(end))?.[0].length ?? 0;
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
const ownsLines = text.slice(lineStart, start).trim() === '' && text[end + after] === '\n';
return ownsLines
? { start: lineStart, end: end + after + 1, text: '' }
: { start, end: end + after, text: '' };
}
function quoteIntegers(text: string): string {
return text.replace(/(?<![\w'".-])-?\d+(?![\w'".])/g, (digits: string) => `'${BigInt(digits)}'`);
}
function rewriteDts(
text: string,
target: string,
extra: ReadonlyMap<string, string>,
defaultRewrites: ReadonlyMap<string, Json>,
integerEnums: ReadonlySet<string>,
jsonEnums: ReadonlyMap<string, JsonEnumValues>,
columnCardinalities: ReadonlyMap<string, Json>,
hashes: ReadonlyMap<string, string>,
): string {
const table = dataTypesFor(target, extra);
const blocks = scanBlocks(text);
const edits: { start: number; end: number; text: string }[] = [];
for (const match of text.matchAll(/readonly storage: readonly \[/g)) {
const types = innermostBlock(blocks, match.index);
if (types?.label !== 'types' || types.parent?.parent?.label !== 'extensions') continue;
const end = bracketEnd(text, match.index + match[0].length - 1);
edits.push(statementRemoval(text, match.index, end));
}
for (const match of text.matchAll(/readonly nativeType: (['"])(?:\\.|(?!\1).)*\1/g)) {
const block = innermostBlock(blocks, match.index);
if (block === undefined || !holdsNativeType(blocks, block)) continue;
const codecId = memberOf(text, blocks, block, 'codecId');
const dataType = codecId === undefined ? undefined : table[codecId];
if (dataType === undefined) continue;
edits.push({
start: match.index,
end: match.index + match[0].length,
text: `readonly dataType: ${prettierString(dataType)}`,
});
}
const listMembers = new Set<Block>();
for (const match of text.matchAll(/readonly many: (true|false|\{)/g)) {
const block = innermostBlock(blocks, match.index);
if (block === undefined) continue;
listMembers.add(block);
if (match[1] !== 'true') continue;
const key = columnBlockKey(block);
const many = key === undefined ? undefined : columnCardinalities.get(key);
if (many === undefined && block.parent?.label !== 'fields') continue;
edits.push({
start: match.index,
end: match.index + match[0].length,
text: `readonly many: ${manyType(many ?? { elementNullable: false })}`,
});
}
for (const block of blocks) {
const key = columnBlockKey(block);
const many = key === undefined ? undefined : columnCardinalities.get(key);
if (many === undefined || listMembers.has(block)) continue;
const member = lastMember(text, block, `readonly many: ${manyType(many)}`);
edits.push({ start: member.start, end: member.start, text: member.text });
}
for (const match of text.matchAll(/DefaultLiteralValue<\s*(['"])(?:\\.|(?!\1).)*\1\s*,\s*/g)) {
const argumentStart = match.index + match[0].length;
const defaultBlock = innermostBlock(blocks, match.index);
const column = defaultBlock?.parent;
const tableBlock = column?.parent?.parent;
if (column === undefined || tableBlock === undefined || column.parent?.label !== 'columns')
continue;
const value = defaultRewrites.get(`${tableBlock.label}\u0000${column.label}`);
if (value === undefined) continue;
const argumentEnd = genericArgumentEnd(text, argumentStart);
const trimmedEnd = argumentStart + text.slice(argumentStart, argumentEnd).trimEnd().length;
edits.push({ start: argumentStart, end: trimmedEnd, text: typeLiteral(value) });
}
for (const match of text.matchAll(/readonly values: readonly \[([^\]]*)\]/g)) {
const valueSet = innermostBlock(blocks, match.index);
const kind = valueSet?.parent;
const namespace = kind?.parent?.parent;
if (
valueSet === undefined ||
kind?.label !== 'valueSet' ||
kind.parent?.label !== 'entries' ||
namespace === undefined ||
!integerEnums.has(enumKey(namespace.label, valueSet.label))
)
continue;
const values = match[1] ?? '';
const start = match.index + match[0].length - values.length - 1;
edits.push({ start, end: start + values.length, text: quoteIntegers(values) });
}
for (const match of text.matchAll(/readonly value: (-?\d+)(?=\s*[;},])/g)) {
const member = innermostBlock(blocks, match.index);
const enumType = member?.parent;
const namespace = enumType?.parent?.parent;
const digits = match[1] ?? '';
if (
member === undefined ||
member.label !== '' ||
enumType?.parent?.label !== 'enum' ||
namespace === undefined ||
!integerEnums.has(enumKey(namespace.label, enumType.label))
)
continue;
const start = match.index + match[0].length - digits.length;
edits.push({ start, end: start + digits.length, text: quoteIntegers(digits) });
}
for (const match of text.matchAll(/readonly values: readonly \[/g)) {
const valueSet = innermostBlock(blocks, match.index);
const kind = valueSet?.parent;
const namespace = kind?.parent?.parent;
if (
valueSet === undefined ||
kind?.label !== 'valueSet' ||
kind.parent?.label !== 'entries' ||
namespace === undefined
)
continue;
const values = jsonEnums.get(enumKey(namespace.label, valueSet.label))?.values;
if (values === undefined) continue;
const open = match.index + match[0].length - 1;
edits.push({
start: open + 1,
end: bracketEnd(text, open) - 1,
text: values.map(typeLiteral).join(', '),
});
}
for (const match of text.matchAll(/readonly value: /g)) {
const member = innermostBlock(blocks, match.index);
const enumType = member?.parent;
const namespace = enumType?.parent?.parent;
if (
member === undefined ||
member.label !== '' ||
enumType?.parent?.label !== 'enum' ||
namespace === undefined
)
continue;
const name = memberOf(text, blocks, member, 'name');
const value =
name === undefined
? undefined
: jsonEnums.get(enumKey(namespace.label, enumType.label))?.members.get(name);
if (value === undefined) continue;
const start = match.index + match[0].length;
const end = start + text.slice(start, literalEnd(text, start)).trimEnd().length;
edits.push({ start, end, text: typeLiteral(value) });
}
for (const match of text.matchAll(/(['"])([0-9a-f]{64})\1/g)) {
const hash = match[2];
const next = hash === undefined ? undefined : hashes.get(hash);
if (next === undefined) continue;
edits.push({
start: match.index,
end: match.index + match[0].length,
text: `${match[1]}${next}${match[1]}`,
});
}
let result = text;
for (const edit of edits.sort((a, b) => b.start - a.start))
result = `${result.slice(0, edit.start)}${edit.text}${result.slice(edit.end)}`;
return result;
}
function replaceQuotedHashes(text: string, hashes: ReadonlyMap<string, string>): string {
return text.replace(/"([0-9a-f]{64})"/g, (match: string, hash: string) => {
const next = hashes.get(hash);
return next === undefined ? match : `"${next}"`;
});
}
interface ContractPlan {
readonly path: string;
readonly snapshot: boolean;
readonly oldHash: string;
readonly newHash: string;
readonly content: string;
readonly target: string;
readonly defaultRewrites: ReadonlyMap<string, Json>;
readonly integerEnums: ReadonlySet<string>;
readonly jsonEnums: ReadonlyMap<string, JsonEnumValues>;
readonly columnCardinalities: ReadonlyMap<string, Json>;
}
function readDirectory(dir: string): Map<string, string> {
const files = new Map<string, string>();
if (!existsSync(dir)) return files;
for (const entry of readdirSync(dir, { withFileTypes: true }))
if (isFileEntry(dir, entry) && !entry.name.endsWith(TEMPORARY_SUFFIX))
files.set(entry.name, readFileSync(join(dir, entry.name), 'utf8'));
return files;
}
function sameFiles(a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): boolean {
return a.size === b.size && [...a].every(([name, content]) => b.get(name) === content);
}
function temporaryPath(path: string): string {
return join(dirname(path), `.${basename(path)}${TEMPORARY_SUFFIX}`);
}
function writeWithModeOf(path: string, text: string, modeSource: string): void {
writeFileSync(path, text);
if (existsSync(modeSource)) chmodSync(path, statSync(modeSource).mode & 0o7777);
}
function writeFile(path: string, text: string): void {
const target = realpathSync(path);
const temporary = temporaryPath(target);
writeWithModeOf(temporary, text, target);
renameSync(temporary, target);
}
function writeDirectory(dir: string, files: ReadonlyMap<string, string>, sourceDir: string): void {
const temporary = temporaryPath(dir);
mkdirSync(temporary);
for (const [name, text] of files)
writeWithModeOf(join(temporary, name), text, join(sourceDir, name));
renameSync(temporary, dir);
}
function removeDirectory(dir: string): void {
const temporary = temporaryPath(dir);
renameSync(dir, temporary);
rmSync(temporary, { recursive: true, force: true });
}
function count(amount: number, singular: string, plural: string): string {
return `${amount} ${amount === 1 ? singular : plural}`;
}
function summary(
root: string,
contractsFound: number,
files: number,
directories: number,
hashes: ReadonlyMap<string, string>,
): readonly string[] {
if (contractsFound === 0) return [`No SQL contract was found under ${root}; nothing changed.`];
if (files === 0 && directories === 0)
return ['The project is already in the new format; nothing changed.'];
const renamed = count(directories, 'snapshot directory', 'snapshot directories');
const changes = `Rewrote ${count(files, 'file', 'files')} and renamed ${renamed}.`;
if (hashes.size === 0) return [changes];
return [
`${changes} Storage hashes changed (old -> new):`,
...[...hashes]
.sort(([a], [b]) => compareCodeUnits(a, b))
.map(([oldHash, newHash]) => ` ${oldHash} -> ${newHash}`),
];
}
const JSON_FILES_THAT_MUST_PARSE = new Set(['contract.json', 'migration.json']);
function main({ root, dataTypes, errors }: Options): number {
if (errors.length > 0) {
process.stderr.write(`${errors.join('\n')}\n`);
return 1;
}
const display = (path: string): string => relative(root, path).split(sep).join('/');
const files = listFiles(root);
const plans: ContractPlan[] = [];
const notices: string[] = [];
const stops: string[] = [];
let contractsFound = 0;
for (const path of files.json) {
const text = readFileSync(path, 'utf8');
const contract = parseJson(text);
if (
contract === undefined &&
(JSON_FILES_THAT_MUST_PARSE.has(basename(path)) || files.refs.includes(path))
) {
stops.push(`${display(path)}: not valid JSON`);
continue;
}
if (
!isRecord(contract) ||
contract['targetFamily'] !== 'sql' ||
typeof contract['target'] !== 'string'
)
continue;
const storage = contract['storage'];
if (!isRecord(storage)) continue;
contractsFound++;
const snapshot = isSnapshotContract(path);
const stored = storage['storageHash'];
const oldHash = snapshot ? basename(dirname(path)) : typeof stored === 'string' ? stored : '';
const recomputes = computeStorageHash(contract) === oldHash;
const rewrite = rewriteContract(contract, dataTypes);
for (const codecId of rewrite.unknownCodecs)
stops.push(
`${display(path)}: unknown codec ${codecId}; name its data type with --data-type ${codecId}=<data type id>`,
);
if (!rewrite.changed) continue;
if (!recomputes)
notices.push(`${display(path)}: stored hash did not recompute; rehashed from content`);
const newHash = computeStorageHash(rewrite.contract);
const contractWithHash = withStorageHash(rewrite.contract, newHash);
plans.push({
path,
snapshot,
oldHash,
newHash,
content: snapshot ? snapshotForm(contractWithHash) : emittedForm(contractWithHash, text),
target: contract['target'],
defaultRewrites: rewrite.defaultRewrites,
integerEnums: rewrite.integerEnums,
jsonEnums: rewrite.jsonEnums,
columnCardinalities: rewrite.columnCardinalities,
});
}
for (const target of [...new Set(plans.map((plan) => plan.target))].sort(compareCodeUnits)) {
for (const [codecId, dataType] of dataTypes) {
const known = DATA_TYPES[target]?.[codecId];
if (known !== undefined)
stops.push(
`--data-type ${codecId}=${dataType}: the script already maps ${codecId} to ${known} on target ${target}`,
);
}
}
const hashes = new Map<string, string>();
for (const plan of [...plans].sort((a, b) => Number(b.snapshot) - Number(a.snapshot)))
if (plan.oldHash !== plan.newHash && !hashes.has(plan.oldHash))
hashes.set(plan.oldHash, plan.newHash);
const newDirectories = new Map<string, Map<string, string>>();
const directorySources = new Map<string, string>();
const sourceDirectories = new Set<string>();
const removals = new Set<string>();
const snapshotRewrites: [string, string][] = [];
const referenceWrites = new Map<string, string>();
const contractWrites = new Map<string, string>();
for (const plan of plans) {
const dtsPath = plan.snapshot
? join(dirname(plan.path), 'contract.d.ts')
: `${plan.path.slice(0, -'.json'.length)}.d.ts`;
const dtsHashes = plan.snapshot ? hashes : new Map([...hashes, [plan.oldHash, plan.newHash]]);
const dts = existsSync(dtsPath)
? rewriteDts(
readFileSync(dtsPath, 'utf8'),
plan.target,
dataTypes,
plan.defaultRewrites,
plan.integerEnums,
plan.jsonEnums,
plan.columnCardinalities,
dtsHashes,
)
: undefined;
if (!plan.snapshot) {
if (dts !== undefined) referenceWrites.set(dtsPath, dts);
contractWrites.set(plan.path, plan.content);
continue;
}
const oldDir = dirname(plan.path);
const newDir = join(dirname(oldDir), plan.newHash);
sourceDirectories.add(oldDir);
if (newDir === oldDir) {
if (dts !== undefined) snapshotRewrites.push([dtsPath, dts]);
snapshotRewrites.push([plan.path, plan.content]);
continue;
}
removals.add(oldDir);
const content = readDirectory(oldDir);
content.set('contract.json', plan.content);
if (dts !== undefined) content.set('contract.d.ts', dts);
const planned = newDirectories.get(newDir);
if (planned !== undefined && !sameFiles(planned, content)) {
stops.push(`${display(newDir)}: snapshot directory already exists with different content`);
continue;
}
newDirectories.set(newDir, content);
directorySources.set(newDir, oldDir);
}
for (const [dir, content] of newDirectories) {
if (sourceDirectories.has(dir) || !existsSync(dir)) continue;
if (sameFiles(readDirectory(dir), content)) newDirectories.delete(dir);
else stops.push(`${display(dir)}: snapshot directory already exists with different content`);
}
for (const path of files.migrationJson) {
const text = readFileSync(path, 'utf8');
const metadata = parseJson(text);
if (!isRecord(metadata)) continue;
const from = metadata['from'];
const to = metadata['to'];
const nextFrom = typeof from === 'string' ? (hashes.get(from) ?? from) : from;
const nextTo = typeof to === 'string' ? (hashes.get(to) ?? to) : to;
if (nextFrom === from && nextTo === to) continue;
const opsPath = join(dirname(path), 'ops.json');
const ops = existsSync(opsPath) ? parseJson(readFileSync(opsPath, 'utf8')) : undefined;
if (existsSync(opsPath) && ops === undefined) {
stops.push(`${display(opsPath)}: not valid JSON`);
continue;
}
const oldMigrationHash = metadata['migrationHash'];
let next = replaceQuotedHashes(text, hashes);
if (
ops !== undefined &&
typeof oldMigrationHash === 'string' &&
nextFrom !== undefined &&
nextTo !== undefined
) {
const migrationHash = computeMigrationHash({ ...metadata, from: nextFrom, to: nextTo }, ops);
next = next.replace(`"${oldMigrationHash}"`, `"${migrationHash}"`);
}
referenceWrites.set(path, next);
}
for (const path of files.refs) {
const text = readFileSync(path, 'utf8');
const next = replaceQuotedHashes(text, hashes);
if (next !== text) referenceWrites.set(path, next);
}
for (const path of files.migrationTs) {
const text = readFileSync(path, 'utf8');
const next = text.replace(
/(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])/g,
(hash: string) => hashes.get(hash) ?? hash,
);
if (next !== text) referenceWrites.set(path, next);
}
if (stops.length > 0) {
process.stderr.write(`${[...new Set(stops)].join('\n')}\n`);
return 1;
}
try {
for (const path of files.leftovers) rmSync(path, { recursive: true, force: true });
for (const [dir, content] of newDirectories)
writeDirectory(dir, content, directorySources.get(dir) ?? dir);
for (const [path, text] of snapshotRewrites) writeFile(path, text);
for (const [path, text] of referenceWrites) writeFile(path, text);
for (const [path, text] of contractWrites) writeFile(path, text);
for (const dir of removals) removeDirectory(dir);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(
`${message}; the upgrade stopped partway, run the script again to finish it\n`,
);
return 1;
}
const rewrittenFiles = snapshotRewrites.length + referenceWrites.size + contractWrites.size;
const lines = [
...notices,
...summary(root, contractsFound, rewrittenFiles, removals.size, hashes),
];
process.stdout.write(`${lines.join('\n')}\n`);
return 0;
}
process.exitCode = main(parseOptions(process.argv.slice(2)));
upgrading/extension/upgrades/8.0.0-rc.15-to-8.0.0-rc.16/instructions.md
---
from: "8.0.0-rc.15"
to: "8.0.0-rc.16"
changes:
- id: engine-pin-moves-to-0-6-3
summary: |
The toolchain now peers `@prisma/cli-engine@0.6.3` (up from 0.6.2). An extension package that pins `@prisma/cli-engine` for its tests or tooling must move the pin to `0.6.3`. The engine's only change is that it accepts any ArkType `^2.2.7`, so it shares one ArkType copy with the Prisma ORM packages.
detection:
glob: "**/package.json"
contains:
- '"@prisma/cli-engine": "0.6.2"'
- id: spec-contexts-carry-data-types
summary: |
`ControlDefaultRegistries` loses `dataTypeEntries`. An attribute spec context carries the stack's data types as `dataTypes: DataTypeSupport`, and `createBinder` and the Mongo PSL interpreter take them as `dataTypes`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bcontrolMutationDefaults\s*:\s*\{[^}]*\bdataTypeEntries\s*:'
- '\b(AttributeSpecContext|FieldAttributeSpecContext|ControlDefaultRegistries)\b'
- '\bcreateBinder\s*\('
- '\binterpretPslDocumentToMongoContract\s*\('
- id: stack-and-source-context-carry-data-types
summary: |
`ControlStack` replaces `dataTypeLookup` with `dataTypes: DataTypeSupport`. `ContractSourceContext`, `InterpretPslDocumentToSqlContractInput` and `InterpretPrisma7DocumentsInput` replace `dataTypeLookup` with `dataTypes: DataTypeSupport`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bdataTypeLookup\s*:'
- '\b(context|stack)\.dataTypeLookup\b'
- id: print-path-carries-data-types
summary: |
`SqlPslBuildContext` replaces `dataTypeLookup` and `authoringContributions.dataTypes` with `dataTypes: DataTypeSupport`. `DefaultMappingOptions` replaces `dataTypeEntries` and `dataTypeLookup` with `dataTypes: DataTypeSupport`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\b(SqlPslBuildContext|DefaultMappingOptions)\b'
- '\bdataTypeEntries\s*:'
- '\bcontext\.authoringContributions\.dataTypes\b'
- id: cast-rule-moves-to-the-framework
summary: |
`entryForTag`, `WrittenValue` and `DataTypeSupport` are no longer exported from `@internal/sql-contract-psl/resolution`. Import them from `@internal/framework-components/authoring`, which also exports the cast rule for one written value. `DefaultRefusal` is the framework's refusals plus the default-only ones: its `no-cast` arm names `receivingType`, not `columnType`, and `readDataTypeDefault` takes `dataTypes`, not `support`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'import\s*(type\s*)?\{[^}]*\b(entryForTag|WrittenValue|DataTypeSupport)\b[^}]*\}\s*from\s*[''"]@internal/sql-contract-psl/resolution[''"]'
- '\b(readDataTypeDefault|DefaultRefusal)\b'
- id: tagged-literal-text-renames
summary: |
The canonical value of a tagged literal is its text: `TaggedLiteralCanonicalization` carries `text`, not `body`; `TaggedLiteralExprAst.body()` is `text()`; `parseJsonBody` and `printJsonBody` are `parseJsonText` and `printJsonText`; `checkSqlDefaultBody` and `reservedSqlDefaultBody` are `checkSqlDefaultText` and `reservedSqlDefaultText`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\b(parseJsonBody|printJsonBody)\b'
- '\b(canonicalizeTaggedLiteralBody|TaggedLiteralCanonicalization|TaggedLiteralExprAst)\b'
- '\b(checkSqlDefaultBody|reservedSqlDefaultBody)\b'
- id: one-of-returns-the-claiming-alternative
summary: |
An argument type may claim an argument whose shape is its own (`ArgType.claims`); `funcCall` claims a call to its name. When exactly one `oneOf` alternative claims the argument, `oneOf` returns its result, success or failure, and tries no other alternative.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bfuncCall\s*\('
- id: default-refusals-point-at-the-written-value
summary: |
`@default` reports `PSL_VALUE_TYPE_INCOMPATIBLE` and `PSL_INVALID_LITERAL` at the written value, or at the list element they are about, not at the whole attribute. The `@default` list no longer offers `sql` as an element.
detection:
glob: "**/*.{ts,mts,cts,js,mjs}"
matches:
- '\bPSL_VALUE_TYPE_INCOMPATIBLE\b'
- '\bPSL_INVALID_LITERAL\b'
- 'list of \([^)]*\bsql`\.\.\.`'
- id: default-refusals-say-what-to-write
summary: |
A `@default` refusal from the cast rule starts with what to write instead, as in `Expected a number`, not the list of types the column casts from. It names the column's type and the value's type only when a value of an admitted form is still refused, such as a number too large for the column. `Unknown literal tag` in a `@default` starts with the field it is about, and `this target has no data type for` comes after what to write.
detection:
glob: "**/*.{ts,mts,cts,js,mjs,json}"
matches:
- '; it casts from '
- '(: |[''"`])this target has no data type for a (string|boolean|number) value[''"`]'
- '[''"`]Unknown literal tag '
---
# 8.0.0-rc.15 → 8.0.0-rc.16 — Extension author upgrade instructions
## `engine-pin-moves-to-0-6-3`
For every `package.json` matched by `detection`, change the `@prisma/cli-engine` version from `0.6.2` to `0.6.3` and reinstall.
Engine 0.6.2 required ArkType `2.2.3` exactly, while the Prisma ORM packages accept any `^2.2.2`. With two ArkType copies installed, a type your package exports from an ArkType schema can fail to build its declarations with `TS2742`, because the inferred type names the other copy's path. After moving the pin, make sure your lockfile resolves a single ArkType version, for example with `pnpm dedupe`. Nothing in the engine's commands or output changes.
## `spec-contexts-carry-data-types`
`ControlDefaultRegistries` held the function registry and the stack's data type authoring entries. It now holds only `defaultFunctionRegistry`. The data types move to the attribute spec context, with their lookup:
```ts
import type { DataTypeSupport } from '@internal/framework-components/authoring';
interface AttributeSpecContext {
readonly symbols: SymbolTable;
readonly model: ModelSymbol;
readonly controlMutationDefaults: ControlDefaultRegistries;
readonly dataTypes: DataTypeSupport; // { entries, lookup }
}
```
Where code builds a spec context, a binder or a Mongo interpreter input, move the entries out of `controlMutationDefaults` and pass the stack's data types beside it:
```diff
createBinder({
sources,
symbolTable,
typeConstructors,
attributeSpecs,
- controlMutationDefaults: { defaultFunctionRegistry, dataTypeEntries: context.authoringContributions.dataTypes },
+ controlMutationDefaults: { defaultFunctionRegistry },
+ dataTypes: context.dataTypes,
});
```
- `createBinder` requires `dataTypes`.
- `createSqlBinder` and `createMongoBinder` require `dataTypes`.
- `interpretPslDocumentToMongoContract` and the Prisma 6 MongoDB contract source's `interpretPrisma6Documents` require `dataTypes`.
- A spec context literal (`{ symbols, model, controlMutationDefaults }`) adds `dataTypes`.
- A stack that registers no data types, and a test that needs none, passes `EMPTY_DATA_TYPES` from `@internal/psl-parser`.
- A spec factory that read `ctx.controlMutationDefaults.dataTypeEntries` reads `ctx.dataTypes.entries`.
## `stack-and-source-context-carry-data-types`
`ControlStack` replaces `dataTypeLookup` with `dataTypes: DataTypeSupport`: its registered data types (`lookup`) with their authoring entries (`entries`, the same object as `authoringContributions.dataTypes`). Code that read `stack.dataTypeLookup` reads `stack.dataTypes.lookup`. `ContractSourceContext` replaces `dataTypeLookup` with the same `dataTypes`, and so do the inputs of the SQL and Prisma 7 interpreters. The SQL interpreter no longer reads entries from `authoringContributions.dataTypes`.
```diff
const context: ContractSourceContext = {
authoringContributions: stack.authoringContributions,
- dataTypeLookup: stack.dataTypeLookup,
+ dataTypes: stack.dataTypes,
...
};
interpretPslDocumentToSqlContract({
- dataTypeLookup: lookup,
- authoringContributions: { ...contributions, dataTypes: entries },
+ dataTypes: { entries, lookup },
+ authoringContributions: contributions,
...
});
```
A test that passed a lookup without entries passes `{ entries: {}, lookup }`. Code that read `context.dataTypeLookup` reads `context.dataTypes.lookup`.
## `print-path-carries-data-types`
`SqlPslBuildContext`, which a target's `buildPslContract` receives, carries `dataTypes: DataTypeSupport` from `stack.dataTypes` in place of `dataTypeLookup`, and its `authoringContributions` no longer includes `dataTypes`. `DefaultMappingOptions`, which `mapDefault` takes, had `dataTypeEntries` beside `dataTypeLookup`; it now has one `dataTypes: DataTypeSupport`.
```diff
mapDefault(columnDefault, {
- dataTypeEntries: context.authoringContributions.dataTypes,
- dataTypeLookup: context.dataTypeLookup,
+ dataTypes: context.dataTypes,
columnCodec,
});
```
A hand-built `SqlPslBuildContext` or `DefaultMappingOptions` passes `dataTypes: { entries, lookup }`. Code that read `options.dataTypeLookup.get(...)` reads `options.dataTypes.lookup.get(...)`.
## `one-of-returns-the-claiming-alternative`
`oneOf` used to try its alternatives in order and, when all failed, report `Expected one of: …` at the whole argument. Now an argument type may claim an argument whose shape is its own, through the optional `claims(arg)` on `ArgType`, and when exactly one alternative claims the argument, `oneOf` returns that alternative's result, success or failure. `funcCall` claims a call to its name whose callee is a plain identifier. A wrong argument is then reported inside the call, for example `Expected one of: 4 | 7` at the `5` of `uuid(5)`. A call to a dotted or colon-qualified name, a call no alternative names, and a call two alternatives name still try every alternative in order.
An alternative of your own that accepts call expressions, placed beside a `funcCall` of the same name, is no longer tried for that call. Give the function one alternative, or rename one of them. An argument type of your own may implement `claims` when a malformed argument of its shape should be reported with its own diagnostics. Update an assertion on an `Expected one of` message for such a call to the diagnostic of the function.
## `cast-rule-moves-to-the-framework`
`@internal/framework-components/authoring` exports the ADR 254 cast rule for one written value: `WrittenValue`, `WrittenScalar`, `DataTypeSupport`, `TypedValue`, `ReadRefusal`, `CastRefusal`, `entryForTag`, `entryForPlain`, `knownTags`, `readWrittenValue`, `castTypedValue`, `admittedTags`, `admittedForms`, `tagForm` and `describeAdmittedForms`, and the wording of a refusal: `WrittenForm`, `RefusalGuidance`, `describeRefusal`, which words a refusal as a PSL code and message, `describeExpected`, `describeRefusedValueType` and `exactRewrite`. `@internal/sql-contract-psl/resolution` no longer exports `entryForTag`, `WrittenValue` or `DataTypeSupport`; change those imports to `@internal/framework-components/authoring`.
`readDataTypeDefault`, `DefaultRefusal` and `DefaultColumn` stay in `@internal/sql-contract-psl/resolution`. `readDataTypeDefault` takes the stack's data types as `dataTypes`, not `support`, and a refused result carries `suggestedTypes`, the types whose written forms a message suggests. `DefaultRefusal` is the framework's `ReadRefusal` and `CastRefusal` with `elementIndex`, plus `not-a-list`, `no-list-cast`, `no-element-cast` and `undecodable`. Code that read `refusal.columnType` on a `no-cast` refusal reads `refusal.receivingType`. A list written on a column whose type has no list cast is now `no-list-cast`, where it was a `no-cast` whose `valueType` was `'a list'`. An element of a written list that the column type's list cast does not take is now `no-element-cast`, with `receivingType`, `valueType` and the cast's `elementTypes`, where it was a `no-cast` whose `casts` were the list cast's element types.
`@internal/psl-parser` adds the argument type `dataTypeValue(dataType, support)`, which admits any literal the cast rule admits for `dataType`, and `readWrittenScalar`. No built-in attribute uses `dataTypeValue` yet. Its label is the type's tag, as in ``sql`...` ``, or the forms the type admits, as in `a number`.
## `tagged-literal-text-renames`
The body is what is written between the quotes; the text is the canonical value. These names change:
| Old | New |
| --- | --- |
| `TaggedLiteralCanonicalization` `{ ok: true, body }`, from `canonicalizeTaggedLiteralBody` | `{ ok: true, text }` |
| `TaggedLiteralExprAst.body()` | `TaggedLiteralExprAst.text()` |
| `parseJsonBody`, `printJsonBody` from `@internal/sql-contract/data-type-support` | `parseJsonText`, `printJsonText` from `@internal/sql-contract/data-type-support` |
| `checkSqlDefaultBody`, `reservedSqlDefaultBody` from `@internal/sql-contract/validators` (and `checkSqlDefaultBody` from `@internal/family-sql/control`) | `checkSqlDefaultText`, `reservedSqlDefaultText` |
An entry that registers the `json` tag changes its imports:
```diff
- written: { kind: 'tag', tag: 'json', parse: parseJsonBody },
- print: printJsonBody,
+ written: { kind: 'tag', tag: 'json', parse: parseJsonText },
+ print: printJsonText,
```
The `why` of the `CONTRACT.INVALID_JSON_LITERAL` error `parseJsonText` throws reads `The text is not a JSON document.`
## `default-refusals-point-at-the-written-value`
`PSL_VALUE_TYPE_INCOMPATIBLE` and `PSL_INVALID_LITERAL` from `@default` used to point at the whole `@default(...)` attribute. They now point at the written value, or at the list element the message names. The codes are unchanged; the messages change as the next section shows. `PSL_DEFAULT_LIST_EXPECTED` and `PSL_INVALID_DEFAULT_LITERAL` still point at the attribute. This supersedes the last row of the table in `default-diagnostic-codes-changed` in the 8.0.0-rc.14 to 8.0.0-rc.15 guide: a `sql` literal inside a list literal is reported at the element.
The list arm of `@default` no longer offers `sql` as an element, so its label is `list of (string | number | boolean | json`...`)`, and so is the end of the `Expected one of` message. Update an assertion on the span of one of these diagnostics, or on that message.
## `default-refusals-say-what-to-write`
`@default` and every other position that takes a value of a data type now word a refusal of the cast rule the same way. A refusal starts with what to write instead, the forms the column's type admits, as in `Expected a number`, not the list of types it casts from. It names the column's type and the value's type only when the value has an admitted form and is still refused, such as a number too large for the column. A quoted string on a column whose type has a tag also gets that string as a tagged literal to write, when the column's type takes it, as the `Jsonb` row shows. An element of a list written on a column whose type has a list cast, such as a vector, is refused with the forms of the list cast's element types. The two other refusals change as the table shows. The codes do not change.
| Written | Message before | Message now |
| --- | --- | --- |
| `count Int @default(100000000000000099)` | `Field "N.count": pg/int4 has no cast from pg/int8; it casts from pg/int2` | `Field "N.count": Expected a number that pg/int4 can hold; got pg/int8` |
| `meta Jsonb @default("{}")` | `Field "N.meta": pg/jsonb has no cast from pg/text; it casts from pg/json` | ``Field "N.meta": Expected json`...`; write json`{}` `` |
| `count Int @default([1])` | `Field "N.count": pg/int4 has no cast from a list; it casts from pg/int2` | `Field "N.count": Expected a number; got a list` |
| `embed pgvector.Vector(3) @default([1, "x", 3])` | `Field "N.embed" at element 2: pgvector/vector has no cast from pg/text; it casts from pg/int2, pg/int4, pg/int8, pg/numeric` | `Field "N.embed" at element 2: Expected a number` |
| `active Int @default(true)` on SQLite | `Field "N.active": this target has no data type for a boolean value` | `Field "N.active": Expected a number; this target has no data type for a boolean value` |
| ``v String @default(pg.sql`x`)`` | `Unknown literal tag "pg.sql". Known tags: sql, json.` | `Field "N.v": Unknown literal tag "pg.sql". Known tags: sql, json.` |
| ``tags String[] @default([sql`'a'`])`` | `Field "Post.tags" at element 1: pg/text has no cast from sql/expression; it casts from nothing` | `Field "Post.tags" at element 1: Expected a quoted string` |
| `enum P { @@type("pg/text@1") Low = 1 }` | `enum "P" member "Low": pg/text has no cast from pg/int2; it casts from nothing` | `enum "P" member "Low": Expected a quoted string` |
| `enum P { Low = 3000000000 }`, an enum without `@@type` | `enum "P" member "Low": pg/int4 has no cast from pg/int8; it casts from pg/int2` | `enum "P" member "Low": Expected a number that pg/int4 can hold; got pg/int8` |
Update an assertion on one of these messages to the new text.
upgrading/extension/upgrades/8.0.0-rc.16-to-8.0.0-rc.17/instructions.md
---
from: "8.0.0-rc.16"
to: "8.0.0-rc.17"
changes:
- id: decode-json-integer-text-refuses-other-spellings
summary: |
`decodeJsonIntegerText` from `@internal/framework-components/codec` now refuses digit text with a leading zero or a minus sign on zero, such as "007" or "-0", naming the text to write. An extension codec that reads an integer through it refuses those spellings in `contract.json` and in a PSL enum member. Write its values without leading zeros or a minus sign on zero, as its `encodeJson` should already.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\bdecodeJsonIntegerText\b'
- id: enum-codecs-need-equality
summary: |
Every enum authoring surface, `enumType` in `defineContract` and the PSL enum block in both families, now refuses a codec whose descriptor does not declare the `equality` trait. A codec descriptor can also set the new `enumRefusal` field, a reason an enum cannot use it that ends with what to use instead. Declare `equality` on an extension codec whose values an enum may hold, and set `enumRefusal` on one that declares it but whose values a query reads back never equal a member as the contract stores it.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\btraits\s*[:=]'
- id: enum-accessor-builders-take-codecs
summary: |
`buildNamespacedEnums()` and `buildEnumsMapForNamespace()` from `@internal/contract/enum-accessor` now take a codec lookup, `(codecId) => EnumMemberCodec`, as their last argument and read each member through the codec it returns. The lookup must return a codec for every enum; throw `RUNTIME.CODEC_DESCRIPTOR_MISSING` when the runtime has none. `createEnumAccessor()` takes the codec as an optional second argument. `EnumAccessor` members and values are typed `unknown`, and `has()` on `EnumAccessor` and `ContractEnumAccessor` takes `unknown`. Pass the runtime's codecs.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\b(?:buildNamespacedEnums|buildEnumsMapForNamespace|createEnumAccessor)\b'
- id: mongo-orm-takes-enum-accessors
summary: |
`mongoOrm()` and `createMongoCollection()` from `@internal/mongo-orm` now require the contract's enum accessors, which they check a written enum value against. Code that builds them itself passes `buildMongoEnums(contract, context.codecs)` from `@internal/mongo-runtime` as `enums`; `createMongoCollection()` takes them before the optional `mutationDefaults`.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\b(?:mongoOrm|createMongoCollection)\s*(?:<[^>]*>)?\s*\('
- id: define-contract-carries-enums
summary: |
A target facade whose `defineContract` wraps `buildBoundContract` must carry the contract's enums as an `Enums` generic and export an `enumType` bound to its pack's codec types, as `@internal/postgres` and now `@internal/sqlite` do. Without them `db.enums` types each member as `JsonValue`, while it holds the value its codec reads, such as a bigint or a `Date`.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\bbuildBoundContract\s*\('
- id: contract-dts-enum-member-types
summary: |
An emitted `contract.d.ts` now gives every namespace that declares enums an `enumMemberTypes` entry, which types each member as `db.enums` holds it. Re-emit bundled contracts that declare enums. `contract.json`, every hash and migration snapshots are unchanged.
detection:
glob: "**/contract.d.ts"
matches:
- 'readonly enum: \{'
- id: bundled-contract-foreign-keys-name-their-backing-index
summary: |
Each foreign key in a SQL `contract.json` now states what backs it in a new `index` field: `{ "name": "<index>" }`, `{ "primaryKey": true }` or `{ "unique": ["<column>", …] }`, absent for `index: false`. A bundled contract space with a foreign key gets a new storage hash. Regenerate the extension's bundled `contract.json` and `contract.d.ts` with its existing emission command.
detection:
glob: "**/contract.json"
matches:
- '"foreignKeys"\s*:\s*\[\s*\{'
- id: relations-name-a-leading-key-or-index
summary: |
A relation in a bundled PSL contract that says `index: false` although a primary key, unique constraint or plain index of its model starts with its foreign key's columns now names that object with `index: "<name>"`, as `contract infer` writes it. Otherwise the stored foreign key says nothing backs it.
detection:
glob: "**/*.prisma"
matches:
- '@relation\([^)]*\bindex\s*:\s*false'
- id: foreign-key-materialization-takes-one-input
summary: |
`materializeForeignKeysAndIndexes()` from `@internal/sql-contract/foreign-key-materialization` takes one object, `{ tableName, foreignKeys, declaredIndexes, uniques, primaryKey, warnings }`, where each declared index is `{ index, namedByUser }`, and a foreign key's `index` is `true`, `false` or the name of a declared index, unique constraint or primary key. `backingIndexColumnKeys()`, `isBackedByColumnKeys()` and `BackingIndexCandidates` are removed; `derivedBackingIndexIsRedundant()` answers whether a table already serves a foreign key's lookups, by the rule the build uses.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\b(?:materializeForeignKeysAndIndexes|backingIndexColumnKeys|isBackedByColumnKeys|BackingIndexCandidates)\b'
- id: planner-plan-statements
summary: |
Every call to a migration planner's `plan(...)` passes a new required `statements` list; pass `statements: []` when the call states no renames.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\.plan\(\s*\{(?!(?:[^{}]|\{[^{}]*\})*?(?<![\w$])statements\s*[:,])(?:[^{}]|\{[^{}]*\})*?(?<![\w$])fromContract\s*[:,]'
- id: planner-plan-origin
summary: |
Every call to a migration planner's `plan(...)` passes a new required `origin`: the storage hash the produced plan asserts it starts from. `fromContract` no longer sets the plan's origin.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\.plan\(\s*\{(?!(?:[^{}]|\{[^{}]*\})*?(?<![\w$])origin\s*[:,])(?:[^{}]|\{[^{}]*\})*?(?<![\w$])fromContract\s*[:,]'
- id: planner-success-applied-statements
summary: |
A migration planner's success result gains a required `appliedStatements` list; a planner, or a test double of one, that returns `{ kind: 'success', plan }` adds `appliedStatements`, empty when it applied no statements.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\s\S])(?=[\s\S]*(?<![\w$])MigrationPlanner(?:Result|SuccessResult)?(?![\w$]))(?![\s\S]*(?<![\w$])appliedStatements(?![\w$]))[\s\S]*kind:\s*["'']success["'']'
- id: sql-planner-helpers
summary: |
In `@prisma/orm-family-sql/family/control`, `plannerSuccess(plan, warnings?)` becomes `plannerSuccess(plan, appliedStatements, subjects, warnings?)` (see `sql-planner-success-subjects`), `planFieldEventOperations(...)` takes required `tableRenames` and `columnRenames` lists, and the conflict kind union gains `'statementRefused'`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\w$])plannerSuccess\s*\('
- '(?<![\w$])planFieldEventOperations\s*\('
- '(?<![\w$])SqlPlannerConflictKind(?![\w$])'
- id: aggregate-planner-app-space
summary: |
The aggregate planner's `planMigration(...)` input takes a required `appSpace: { fromContract, statements }`, and a `PerSpacePlan` carries a required `appliedStatements` list.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\w$])planMigration\s*\('
- 'strategy:\s*["''](?:plan-from-diff|resolve-recorded-path|declared-state)["'']'
- id: planner-success-data-loss
summary: |
A migration planner's success result gains required `dataLoss` and `accessWidening` lists of `MigrationOperationSubject` (from `@prisma/orm-framework/components/control`): the operations that lose data, and those that widen who can read or write rows, each by position with its subject. A planner, or a test double of one, that returns `{ kind: 'success', ... }` adds both, empty when it plans neither.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\s\S])(?=[\s\S]*(?<![\w$])MigrationPlanner(?:Result|SuccessResult)?(?![\w$]))(?![\s\S]*(?<![\w$])dataLoss(?![\w$]))[\s\S]*kind:\s*["'']success["'']'
- id: sql-planner-success-subjects
summary: |
In `@prisma/orm-family-sql/family/control`, `plannerSuccess(plan, appliedStatements, warnings?)` becomes `plannerSuccess(plan, appliedStatements, subjects, warnings?)`, where `subjects` is `{ dataLoss, accessWidening }`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\w$])plannerSuccess\s*\('
- id: per-space-plan-subjects
summary: |
A `PerSpacePlan` from `@prisma/orm-toolchain/migration-tools/aggregate` carries required `dataLoss` and `accessWidening` lists, and `planMigration(...)` and `resolveRecordedPath(...)` take a required `storageNameOf(operation)`, which names each destructive operation of a recorded path in `dataLoss`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'strategy:\s*["''](?:plan-from-diff|resolve-recorded-path|declared-state)["'']'
- '(?<![\w$])(?:planMigration|resolveRecordedPath)\s*\('
- id: family-instance-storage-name-of
summary: |
`ControlFamilyInstance` from `@prisma/orm-framework/components/control` requires `storageNameOf(operation)`: the name the database knows the object an operation acts on by. A family instance, or a test double of one, implements it. `TargetMigrationsCapability` gains an optional `renameStatements: { refused: true, keepDataByHand }` for a target whose planner carries out no rename statement.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\w$])(?:Sql|Mongo)?ControlFamilyInstance(?![\w$])'
- id: operation-classes-and-calls
summary: |
Postgres `setNotNull`, MongoDB `dropIndex`, `setValidation` and `collMod`, and the matching op-factory calls are now `widening`. `AlterColumnTypeCall` (`@prisma/orm-target-postgres/target/op-factory-call`) takes an optional `operationClass`, and SQLite's `RecreateTableCall` (`@prisma/orm-target-sqlite/target/op-factory-call`) an optional `lossyColumns`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\w$])(?:AlterColumnTypeCall|RecreateTableCall)(?![\w$])'
- '(?<![\w$.])(?:dropIndex|setValidation|collMod)\('
- '\bsetNotNull\('
- id: control-client-db-update-answer-questions
summary: |
The control client's `dbUpdate(options)` requires an `answerQuestions` callback, which answers every question about an operation that would lose data or widen access, in order, or throws to refuse.
detection:
glob: "**/*.{ts,mts,cts,md}"
matches:
- '(?<![\s\S])(?![\s\S]*(?<![\w$])answerQuestions(?![\w$]))[\s\S]*\.dbUpdate\s*\('
- id: collection-apply-is-now-with
summary: |
The collection method `apply(fn)` is renamed to `with(fn)`. Rename every call on a collection of the SQL ORM client, such as `db.orm.public.Post.apply(notDeleted)` or `posts.apply((p) => p.limit(10))`, to `.with(...)`. The detection matches `.apply(` only when its first argument is a name followed by `)`, `(` or `=>`, or an arrow function, because `Function.prototype.apply` (`fn.apply(this, args)`) and `Reflect.apply` share the name; it can still match a `Function.prototype.apply` call with one argument. Rename only where the receiver is a collection.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\.apply\s*\(\s*(?:\(|[A-Za-z_$][\w$.]*\s*(?:\)|\(|=>))'
- id: with-is-a-collection-member
summary: |
Every collection now has a `with` method instead of `apply`. A custom collection class that declares its own `with` member with another signature no longer compiles; rename it. An aggregate operation named `with` is refused with `ORM.AGGREGATE_OPERATION_RESERVED`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?:^|\n)[ \t]*(?:(?:public|protected|private|readonly|static|async|override)\s+)*with\s*[(<:=?]'
- id: orm-scope-is-now-fragment
summary: |
The SQL ORM client's `scope` methods are renamed to `fragment`: `db.orm.scope(fields, body)` is now `db.orm.fragment(fields, body)`, and `collection.scope(body)`, such as `db.orm.public.Post.scope(...)`, is now `collection.fragment(body)`. Rename each use whose receiver is the ORM client or a collection, including `typeof db.orm.scope` and `const { scope } = db.orm`. The detection matches every `.scope(` call; leave calls on other objects as they are.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\.scope\s*[(<]'
- '\btypeof\s+[\w$.]*\.scope\b'
- '\{[^}]*\bscope\b[^}]*\}\s*=\s*[\w$.]*\borm\b'
- id: orm-scope-types-are-now-fragment-types
summary: |
The types `Scope`, `FieldScope` and `ScopeFacts` exported by the SQL ORM client (`@prisma/orm-postgres/orm-client`, the other facades' `orm-client` entries and `@internal/sql-orm-client`) are renamed to `Fragment`, `FieldFragment` and `FragmentFacts`. The SQL builder's own `Scope` and `ScopeField` types are a different thing and keep their names; the detection matches `Scope` only in an import or re-export from the ORM client, or after a namespace import of it.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\b(?:FieldScope|ScopeFacts)\b'
- '(?:import|export)\s+(?:type\s+)?\{[^}]*\bScope\b[^}]*\}\s*from\s*[''"]@(?:prisma/[\w-]+/orm-client|internal/sql-orm-client)[''"]'
- 'import\s+(?:type\s+)?\*\s+as\s+([\w$]+)\s+from\s*[''"]@(?:prisma/[\w-]+/orm-client|internal/sql-orm-client)[''"][\s\S]*\b\1\.Scope\b'
- id: fragment-is-a-collection-member
summary: |
Every collection now has a `fragment` method instead of `scope`. A custom collection class that declares its own `fragment` member with another signature no longer compiles; rename it. An aggregate operation named `fragment` is refused with `ORM.AGGREGATE_OPERATION_RESERVED`. The name `scope` is free again. The detection matches a member named `fragment` only in a file that extends `Collection`, and an aggregate operation declared as `operation: 'fragment'`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\s\S])(?=[\s\S]*\bextends\s+Collection\b)[\s\S]*\n[ \t]*(?:(?:public|protected|private|readonly|static|async|override|get|set)\s+)*fragment\s*(?:\?\s*)?[(<:=]'
- '\boperation\s*:\s*[''"]fragment[''"]'
- id: namespace-named-fragment-hides-the-client-method
summary: |
A contract namespace named `fragment` now takes the name of the client's `fragment` method, so `db.orm.fragment` is that namespace and the client has no method to make a fragment for any model; code that called `db.orm.scope(fields, body)` with such a contract must make its fragments another way. A namespace named `scope` no longer hides anything.
detection:
glob: "**/*.prisma"
matches:
- '(?:^|\n)[ \t]*namespace\s+fragment\b'
- id: fragment-error-texts
summary: |
Errors and compile errors about these functions say "fragment" where they said "scope", such as `Cannot define the fragment: the body is not a function` and `Pass the fragment to with on a collection: collection.with(fragment).` Update tests that assert on the old text.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'Cannot (?:define|apply) (?:the|a) scope|Pass the scope to with|Run the scope with apply|A scope (?:passed to with|applied with apply)|The scope was (?:made|declared)|the scope could not read the model|declaration in the scope'
- id: collection-state-carries-locking
summary: "CollectionState in @internal/sql-orm-client has a new required key, locking; a state literal built without spreading emptyState() must add it, carrying an existing state's value."
detection:
glob: "**/*.ts"
contains:
- "CollectionState"
---
# 8.0.0-rc.16 → 8.0.0-rc.17 — Extension author upgrade instructions
## `decode-json-integer-text-refuses-other-spellings`
`decodeJsonIntegerText(codecId, json, range?)` reads an integer a codec stores as decimal text. It now refuses any spelling other than the integer's decimal text:
```text
demo/big@1 JSON value must be "7", the integer's decimal text without leading zeros or a minus sign on zero
```
1. Find the codecs that call `decodeJsonIntegerText`. The detection for this change looks for the name.
2. Check that each codec's `encodeJson` writes `value.toString()` of the integer, so it never writes a spelling `decodeJson` now refuses.
3. Re-emit the extension's bundled contracts, if it ships any, and check that they load. A contract that holds such a spelling fails to load with `RUNTIME.DECODE_FAILED`.
## `enum-codecs-need-equality`
An enum compares a value with its members, so an enum cannot use a codec whose values cannot be compared for equality. `enumRefusalOf(descriptor)` from `@internal/framework-components/codec` gives the reason an enum cannot use a codec, or `undefined`. Every enum surface refuses through it, with `CONTRACT.ENUM_INVALID` in TypeScript and `PSL_EXTENSION_INVALID_VALUE` at the `@@type` in PSL.
1. For each codec descriptor the extension contributes, declare `equality` in `traits` when its type compares values for equality, so an enum can use it.
2. Set `enumRefusal` when the codec declares `equality` but its values read back never equal a member as the contract stores it: for example, the database normalises the value's text and the codec stores it as written, or the stored form is not text the database reads as the value. Write one or two sentences that say why and end with what to use instead:
```ts
class GeoHashDescriptor extends CodecDescriptorImpl<void> {
override readonly traits = ['equality'] as const;
override readonly enumRefusal =
'The database normalises a geohash to its shortest form, so a member as written is not the value a query reads back. Use a text enum.';
}
```
A codec without `equality` may set `enumRefusal` too, to replace the generic reason with its own.
## `enum-accessor-builders-take-codecs`
An extension that builds `db.enums` passes the codecs of the runtime it builds them for. On a SQL runtime, resolve each codec through the execution context's codec registry, which throws `RUNTIME.CODEC_DESCRIPTOR_MISSING` for a codec no component registers:
```ts
const enums = Object.freeze(
buildNamespacedEnums<TContract>(context.contract.domain, (codecId) =>
context.contractCodecs.forCodecRef({ codecId }),
),
);
```
On Mongo, build them with `buildMongoEnums` from `@internal/mongo-runtime`, which resolves each codec through the execution context's codec lookup and throws `RUNTIME.CODEC_DESCRIPTOR_MISSING` when it has none:
```ts
const enumsByNamespace = buildMongoEnums(contract, context.codecs);
const enums = enumsByNamespace[UNBOUND_NAMESPACE_ID];
```
A static context that builds enums before it has an execution context must build the context first so it can pass the codecs. Each enum's codec is resolved when the accessors are built; its members are decoded when the enum is first read.
`createEnumAccessor(contractEnum)` without a codec keeps the members in their stored forms, as the native-enum accessor of `@internal/postgres` does.
The accessor's members are the values the codec reads, so code that read `EnumAccessor.values` or `members` as `JsonValue` must accept `unknown`. Members are decoded once, when the enum is first read; a mutable member, such as a `Date` or a `Uint8Array`, is a fresh copy on every read, and an immutable one, such as a Temporal value, is the same value on every read. `has()`, `nameOf()` and `ordinalOf()` find a value equal to a member: a primitive by SameValueZero, an object by its `Object.prototype.toString` kind and the form the codec stores it in. Code that passed a stored form to them must pass the value a query returns.
## `mongo-orm-takes-enum-accessors`
Pass the contract's enum accessors when building the Mongo ORM, the same ones the static context builds for `db.enums`:
```ts
const orm = mongoOrm<TContract>({
contract,
executor,
mutationDefaults: context,
enums: buildMongoEnums(contract, context.codecs),
});
```
`createMongoCollection()` takes the accessors as its fourth argument, before the optional `mutationDefaults`: `createMongoCollection(contract, 'User', executor, enums, mutationDefaults)`. A write of an enum field whose accessor `enums` lacks is refused with `ORM.ARGUMENT_INVALID`.
## `define-contract-carries-enums`
Give the facade's `defineContract` an `Enums` type parameter on both overloads, pass it to `buildBoundContract` through the definition type, and merge the scaffold's and the factory's enums in the factory overload:
```ts
import type {
EnumTypeHandle,
MergeEnums,
} from '@internal/sql-contract-ts/contract-builder';
type EnumsConstraint = Record<string, EnumTypeHandle>;
type Result<Types, Models, Extensions, Enums extends EnumsConstraint> = ReturnType<
typeof buildBoundContract<
SqlFamily,
TargetPack,
{
readonly types?: Types;
readonly models?: Models;
readonly extensions?: Extensions;
readonly enums?: Enums;
readonly createNamespace: (input: SqlNamespaceInput) => SqlNamespaceBase;
}
>
>;
export function defineContract<
const Types extends TypesConstraint = Record<never, never>,
const Models extends ModelsConstraint = Record<never, never>,
const Extensions extends Record<string, ExtensionPackRef<'sql', string>> | undefined = undefined,
const Enums extends EnumsConstraint = Record<never, never>,
>(definition: Definition<Types, Models, Extensions, Enums>): Result<Types, Models, Extensions, Enums>;
export function defineContract<
const Types extends TypesConstraint = Record<never, never>,
const Models extends ModelsConstraint = Record<never, never>,
const Extensions extends Record<string, ExtensionPackRef<'sql', string>> | undefined = undefined,
const ScaffoldEnums extends EnumsConstraint = Record<never, never>,
const FactoryEnums extends EnumsConstraint = Record<never, never>,
>(
scaffold: Scaffold<Extensions, ScaffoldEnums>,
factory: (helpers: ComposedAuthoringHelpers<SqlFamily, TargetPack, Extensions>) => {
readonly types?: Types;
readonly models?: Models;
readonly enums?: FactoryEnums;
},
): Result<Types, Models, Extensions, MergeEnums<ScaffoldEnums, FactoryEnums>>;
```
Add `'enums'` to the keys the base scaffold omits from `ContractInput`, add `readonly enums?: Enums` to the definition and scaffold types, and type the implementation's `enums` as `EnumsConstraint`. Then export an `enumType` bound to the pack's codec types, and `member`, from the facade's contract builder:
```ts
import {
bindEnumType,
type ExtractCodecTypesFromPack,
} from '@internal/sql-contract-ts/contract-builder';
/** `enumType` bound to the pack's codec types, so each `member()` value is checked against the enum codec's input type. */
export const enumType = bindEnumType<ExtractCodecTypesFromPack<typeof targetPack>>();
```
## `contract-dts-enum-member-types`
Regenerate bundled `contract.json` and `contract.d.ts` together from the extension's authoring source with the extension's existing emission command. Only `contract.d.ts` changes, and only for contracts that declare enums.
## `bundled-contract-foreign-keys-name-their-backing-index`
Regenerate the bundled `contract.json` and `contract.d.ts` from the extension's authoring source with its existing emission command. Only contracts with a foreign key change. A database signed with the previous contract space needs `prisma db sign`; say so in the extension's release notes.
## `relations-name-a-leading-key-or-index`
For each `@relation(..., index: false)` in the bundled PSL source, look at the model's `@id`/`@@id`, `@@unique`/`@unique` and `@@index` entries. If one of them has the relation's `fields` as its first fields, in order, and is not partial and has no non-default `type` or `options`, replace `index: false` with `index: "<its map or name>"`. For example, with `@@unique([sessionId, authenticationMethod], map: "mfa_amr_claims_session_id_authentication_method_pkey")`, write `session AuthSession @relation(fields: [sessionId], references: [id], index: "mfa_amr_claims_session_id_authentication_method_pkey")`. Leave `index: false` where nothing qualifies. Then regenerate the bundled contract, as `bundled-contract-foreign-keys-name-their-backing-index` says. If the source comes from `contract infer`, re-running it writes the same.
## `foreign-key-materialization-takes-one-input`
Before:
```ts
const { foreignKeys, indexes } = materializeForeignKeysAndIndexes(
tableName,
authoredForeignKeys,
declaredIndexes,
uniques,
primaryKey,
);
```
After:
```ts
const warnings: AuthoringWarning[] = [];
const { foreignKeys, indexes } = materializeForeignKeysAndIndexes({
tableName,
foreignKeys: authoredForeignKeys,
declaredIndexes: authoredIndexes.map((authored) => ({
index: lowerAuthoredIndex(tableName, authored, warnings),
namedByUser: authored.name !== undefined || authored.map !== undefined,
})),
uniques,
primaryKey,
warnings,
});
flushAuthoringWarnings(warnings);
```
`namedByUser` says whether the source gave the index a `name` or `map`. An index not named by the user is left out when it duplicates another, and foreign keys that pointed at it then name the one that stays. `flushAuthoringWarnings` comes from `@internal/framework-components/authoring`.
Code that called `backingIndexColumnKeys()` or `isBackedByColumnKeys()` to decide whether a foreign key needs a backing index reads the stored foreign key's `index` instead: `{ name }` names an index of the table, `{ primaryKey: true }` says the primary key serves it, and `{ unique: [columns] }` names the unique constraint with those columns; the first columns of each are the foreign key's, and an absent `index` says nothing does. To ask the question of a live table, call `derivedBackingIndexIsRedundant(columns, { indexes, nodeOf, uniques, primaryKey })`, as `contract infer` does.
## `planner-plan-statements`
The options of `MigrationPlanner.plan` (from `@prisma/orm-framework/components/control`), of the SQL family's `SqlMigrationPlannerPlanOptions`, of the Postgres and SQLite planners, and of `MongoMigrationPlanner` (from `@prisma/orm-target-mongo/target/control`) gain a required `statements: readonly ResolvedMigrationStatement[]`: the `--rename` statements the user gave, resolved into namespace, model and field names. For each `plan({ ... })` call, add `statements: []` beside `fromContract`. A planner that forwards its options to another planner forwards `statements` too. Detection finds the calls that write their options inline in `plan({ ... })`; a call that builds its options object elsewhere and passes it in, such as `plan(options)`, is not detected, so check those calls by hand. `MongoMigrationPlanner` refuses a non-empty `statements` with a `statementRefused` conflict in this release.
A planner that cannot carry out statements must refuse them, never ignore them: an ignored statement plans a drop and create, which loses the data the statement was given to keep. When `statements` is not empty, return `{ kind: 'failure', conflicts: [...] }` with one conflict for the first statement it cannot carry out, of kind `statementRefused`, with that statement in `refusedStatement`, a `summary` that names it (`describeMigrationStatement(statement, fromContract, contract)` writes it in domain names), and a `why` that says how to keep the data without it. A planner that carries out some statements reports each one in `appliedStatements` and refuses the first one it cannot.
`ResolvedMigrationStatement`, `ResolvedModelRenameStatement`, `ResolvedFieldRenameStatement`, `ModelCoordinate`, `FieldCoordinate` and `describeMigrationStatement` are imported from `@prisma/orm-framework/components/control`.
## `planner-plan-origin`
The same planner options gain a required `origin: PlanOrigin | null`. It is the origin the produced plan asserts, which the runner checks against the database marker: the plan's `origin` and its `describe().from`. Until now a planner derived it from `fromContract`; now `fromContract` is only the contract the planner reads. To keep a call's behavior, pass `origin: planOriginOf(fromContract)`, with `planOriginOf` and the `PlanOrigin` type from `@prisma/orm-framework/components/control`. A call that plans from whatever state the database is in, as `db init` and `db update` do, passes `origin: null`. A planner implementation stamps `options.origin?.storageHash ?? null` onto its plan instead of reading the hash from `fromContract`. Detection finds the same inline `plan({ ... })` calls as `planner-plan-statements`; check calls that pass a prebuilt options object by hand.
## `planner-success-applied-statements`
`MigrationPlannerSuccessResult` gains a required `appliedStatements: readonly AppliedMigrationStatement[]`, one entry per statement the plan applied, in order, each with `operationIndexes`: the positions, in the plan's `operations`, of the operations it accounts for. `AppliedMigrationStatement` is imported from `@prisma/orm-framework/components/control`. In a planner implementation, or a test double of one, that returns `{ kind: 'success', plan, ... }`, add `appliedStatements: []` when the planner applies no statements.
`MigrationPlannerConflict` also gains an optional `refusedStatement`. A planner sets it only on a conflict that refuses a statement, as described under `planner-plan-statements`; existing conflicts leave it out.
Detection finds files that name `MigrationPlanner`, `MigrationPlannerResult` or `MigrationPlannerSuccessResult` and return `kind: 'success'` without `appliedStatements` anywhere in the file. It misses two cases, so check them by hand: a file that mentions `appliedStatements` once is skipped as a whole, even if another success result in it lacks the field; and a planner or test double that returns `{ kind: 'success', ... }` without naming one of those types, for example one typed through a family type such as `SqlPlannerSuccessResult`, is not found.
## `sql-planner-helpers`
These come from `@prisma/orm-family-sql/family/control`.
- Change `plannerSuccess(plan)` to `plannerSuccess(plan, [], { dataLoss: [], accessWidening: [] })`, and `plannerSuccess(plan, warnings)` to `plannerSuccess(plan, [], { dataLoss: [], accessWidening: [] }, warnings)`. The second argument is `appliedStatements`; the third is described under `sql-planner-success-subjects`.
- Add `tableRenames: []` and `columnRenames: []` to the options of each `planFieldEventOperations({ ... })` call.
- An exhaustive `switch` over `SqlPlannerConflictKind` gains a `case 'statementRefused':`.
## `aggregate-planner-app-space`
`planMigration`, `PerSpacePlan` and `AppSpacePlanningInputs` come from `@prisma/orm-toolchain/migration-tools/aggregate`. Add `appSpace: { fromContract: null, statements: [] }` to each `planMigration({ ... })` input, and `appliedStatements: []` to each `PerSpacePlan` object a test builds by hand.
## `planner-success-data-loss`
`MigrationPlannerSuccessResult` gains:
- `dataLoss: readonly MigrationOperationSubject[]`: one entry per operation, in plan order, that can lose rows or values (dropping a table, a column or a collection, or a type change that can change values).
- `accessWidening: readonly MigrationAccessChange[]`: one entry per operation that changes who can read or write rows, with `widens: true` when it widens access, such as disabling row-level security, and `false` when the change can go either way, such as dropping a row-level-security policy. Leave out the drop half of a policy replacement, a drop of a policy the same plan creates again (by name, or by generated-name prefix on the same table): it changes nothing in the end, and listing it would make every policy edit ask.
A `MigrationOperationSubject` is `{ operationIndex, subject }`: the operation's position in the plan's `operations`, and a `MigrationSubject`, which is `{ kind: 'model', namespaceId, model }` or `{ kind: 'field', namespaceId, model, field }` when the operation is about a model or field of `fromContract`, else `{ kind: 'storage', name }` with the name the database knows it by. The CLI turns each entry into a question the user answers with `--delete`, `--rename` or `--allow` before anything is written or applied, so a planner must list every operation of these kinds; an operation it leaves out is applied without asking.
`MigrationSubject`, `MigrationOperationSubject`, `MigrationAccessChange`, `MigrationSubjectJson` and `migrationSubjectJson` are imported from `@prisma/orm-framework/components/control`. In a planner or a test double that returns `{ kind: 'success', plan, appliedStatements }`, add `dataLoss: []` and `accessWidening: []` when it plans no such operation.
Detection finds files that name `MigrationPlanner`, `MigrationPlannerResult` or `MigrationPlannerSuccessResult` and return `kind: 'success'` without `dataLoss` anywhere in the file. Check by hand a file that mentions `dataLoss` once, and a success result typed through a family type such as `SqlPlannerSuccessResult`.
## `sql-planner-success-subjects`
Change `plannerSuccess(plan, appliedStatements)` to `plannerSuccess(plan, appliedStatements, { dataLoss: [], accessWidening: [] })`, and `plannerSuccess(plan, appliedStatements, warnings)` to `plannerSuccess(plan, appliedStatements, { dataLoss: [], accessWidening: [] }, warnings)`, when the planner plans no operation that loses data or widens access. A SQL planner that does computes the lists with `subjectsOfCalls(calls, context)` from the same module, which takes what each call loses and what access it changes (`CallSubjects`, as the Postgres and SQLite planners build them; each access entry carries `widens`) and names each subject through the origin contract; `planFieldEventCalls(...)` returns the calls of the codec field-event hooks with the column each was returned for (`FieldEventCall`), so their subjects can be named too.
## `per-space-plan-subjects`
Add `dataLoss: []` and `accessWidening: []` to each `PerSpacePlan` object a test builds by hand. `PerSpacePlan` extends `MigrationPlanSubjects` from `@prisma/orm-framework/components/control`, which declares the two lists; `MigrationPlannerSuccessResult` extends it too.
Pass `storageNameOf` to each `planMigration({ ... })` and `resolveRecordedPath({ ... })` call: the family instance's `storageNameOf`, or `(operation) => operation.id` in a test. The aggregate planner fills the lists from the planner's result for a space it plans from a diff, and for a space it applies from recorded migrations it lists each destructive operation in `dataLoss` under its storage name, and no access widening, since a written migration is reviewed before it runs.
## `family-instance-storage-name-of`
A `ControlFamilyInstance` implementation adds `storageNameOf(operation: MigrationPlanOperation): string`. The aggregate planner calls it to name what a destructive operation of a recorded migration loses, since no planner mapped it to a model. A SQL family returns the name from the operation's target details, `schema.table.column` for a column and `schema.name` for anything else; `storageNameOfOperation` from `@prisma/orm-family-sql/family/control` does that. A test double returns any stable name.
A target whose planner carries out no rename statement sets `renameStatements: { refused: true, keepDataByHand(subject, fromContract) }` on its `migrations` capability. The CLI then offers no `--rename` in a data-loss question, and ends the question with the text `keepDataByHand` returns: how to keep the subject's data by hand before running the command again. MongoDB's target sets it in this release, and its text says to rename the collection in `mongosh` before a plan that drops it is applied, and that a migration written by `migration plan` still drops it.
## `operation-classes-and-calls`
An operation is `destructive` only when it can lose rows or values. Postgres `setNotNull`, and MongoDB `dropIndex`, `setValidation` and `collMod` (with no `operationClass` given), are now `widening`, and so are the op-factory calls a planner builds for them. A planner or test that asserts `operationClass: 'destructive'` for them asserts `'widening'`.
`AlterColumnTypeCall` takes an optional last constructor argument, `operationClass: 'widening' | 'destructive'`, default `'destructive'`; pass `'widening'` only when every value of the old type converts to the new type unchanged. `RecreateTableCall` takes an optional second constructor argument, `lossyColumns`: the columns whose values the copy can change because their type changes. A planner that builds a recreate for a type change passes them, so `dataLoss` names those fields; they never reach the operation or `migration.ts`.
## `control-client-db-update-answer-questions`
In code and documentation that call the control client's `dbUpdate({ ... })` without `answerQuestions`, add a callback. Detection finds files that call `dbUpdate(` and never mention `answerQuestions`; a file with one call that has it and another that lacks it is skipped, so check it by hand. Pass the options `dbUpdate` takes: `contract` (the emitted `contract.json`), `mode` and `migrationsDir`. In a README example that has none of them, add `import contract from './src/prisma/contract.json' with { type: 'json' };` after the control client's import, and write the call with a callback that refuses every data loss and access widening, as `dbUpdate` used to without consent:
```typescript
await control.dbUpdate({
contract,
mode: 'apply',
migrationsDir: 'migrations',
answerQuestions: async (questions) => {
if (questions.length > 0) throw new Error('db update would lose data or widen access');
return [];
},
});
```
The callback is called at least once per apply, with an empty list when nothing is in question; return `[]` then. To consent, return one `{ verb, text }` per question, in order: a verb from `question.verbs` and `question.subject` as the text.
## `apply` is now `with`
The collection method that runs a function on a collection is now `with`. A pure filter is still written `where(rowFragment)`; `with` runs a query fragment, a function from a collection to a collection, for what `where` cannot express, such as a shared `select` and `include`, an order, a limit or offset, or a variant.
These changes apply to the SQL ORM client only. The MongoDB ORM client did not change; skip matches in code that uses it.
### Rename `apply` to `with`
Rename each call on a collection, in the extension's code and in the scopes or collection classes it exports:
```diff
- const posts = await db.orm.public.Post.apply(notDeleted).apply(postSummary).all();
+ const posts = await db.orm.public.Post.with(notDeleted).with(postSummary).all();
```
The same holds inside an include refinement and inside a fragment's body:
```diff
- db.orm.public.User.include('posts', (posts) => posts.apply(postSummary));
+ db.orm.public.User.include('posts', (posts) => posts.with(postSummary));
```
Do not rename `Function.prototype.apply` or `Reflect.apply`. A call such as `fn.apply(this, args)`, `fragment.apply(undefined, [collection])` or `Reflect.apply(fn, target, args)` calls a function, not a collection; leave it as it is. When a match is unclear, rename it only if its receiver's type is a collection: `db.orm.<namespace>.<Model>`, a chain on one, a custom class that extends `Collection`, or the collection an include refinement or a fragment's body receives.
Also rename `apply` to `with` in comments and documentation that name it as the collection method, such as "run with `apply` on any collection of posts". The next section renames `scope` to `fragment` in the same places.
`with` is a reserved word in JavaScript, but a valid method name. `collection.with(fragment)` works; destructuring it as `const { with } = collection` does not.
The error texts that named `apply` change as well; the final texts are under "Error texts" in the next section.
### `with` is a member of every collection
A custom collection class that declares its own `with` with another signature no longer compiles. Rename that member and its call sites:
```diff
class PostCollection extends Collection<Contract, 'Post'> {
- with(term: string) { return this.where((post) => post.title.ilike(`%${term}%`)); }
+ withTitle(term: string) { return this.where((post) => post.title.ilike(`%${term}%`)); }
}
```
A class that declared its own `apply` because the name was reserved may keep it under that name or rename it back.
An aggregate operation named `with` is now refused with `ORM.AGGREGATE_OPERATION_RESERVED` when the client is built; rename the operation. An aggregate operation may now be named `apply`.
## The `scope` methods and types are renamed to `fragment`
The general term for a function from a collection to a collection, run with `collection.with(fn)`, is **query fragment**, or **fragment** for short. A **scope** is a fragment that only imposes conditions on the query, such as a soft-delete filter. The methods and types that make fragments were named `scope`, although they also make fragments that select, include or order, so they are renamed to `fragment`. What they do has not changed.
These changes apply to the SQL ORM client only. The MongoDB ORM client did not change; skip matches in code that uses it.
### Rename `scope` to `fragment`
Rename the client's method and the collection method:
```diff
- const notDeleted = db.orm.scope(
+ const notDeleted = db.orm.fragment(
{ deletedAt: field.temporal.timestamptz().optional() },
(rows) => rows.where((r) => r.deletedAt.isNull()),
);
- const postSummary = db.orm.public.Post.scope((posts) => posts.select('id', 'title').include('user'));
+ const postSummary = db.orm.public.Post.fragment((posts) => posts.select('id', 'title').include('user'));
```
The same holds on a chained collection, a custom collection class and `this` inside one, in the extension's code and in the fragments or collection classes it exports, and in a type such as `typeof db.orm.scope` or a destructuring such as `const { scope } = db.orm`. Rename a use only when its receiver is the ORM client (`db.orm`, or the client `orm()` returns) or a collection. Leave `.scope(` calls on other objects as they are.
### Rename the types
```diff
- import type { FieldScope, Scope, ScopeFacts } from '@prisma/orm-postgres/orm-client';
+ import type { FieldFragment, Fragment, FragmentFacts } from '@prisma/orm-postgres/orm-client';
```
Rename every use of these types in the file, including a re-export such as `export type { Scope } from '@prisma/orm-postgres/orm-client'` and a qualified name such as `Orm.Scope` after `import * as Orm from '@prisma/orm-postgres/orm-client'`. Do not rename the SQL builder's `Scope` or `ScopeField`, which describe the tables and columns a builder query can see; they are imported from a `builder` entry or `@internal/sql-builder`, not from the ORM client.
### Rename what you named after scopes
Code that calls a fragment a "scope" still compiles, but rename it to match the new words. Name a module, a variable or a comment for a fragment that selects, includes, orders or limits a fragment, and keep "scope" for a fragment that only imposes conditions on the query. For example, a module `scopes.ts` that holds both a filter and a shared `select` becomes `fragments.ts`, with its imports updated, while a comment that describes a filter in it as a scope stays. Update documentation the same way, such as a README that names `db.orm.scope`, `.scope(...)` or a module you renamed.
### `fragment` is a member of every collection
A custom collection class that declares its own `fragment` with another signature no longer compiles. Rename that member and its call sites:
```diff
class PostCollection extends Collection<Contract, 'Post'> {
- fragment(text: string) { return this.where((post) => post.body.ilike(`%${text}%`)); }
+ containing(text: string) { return this.where((post) => post.body.ilike(`%${text}%`)); }
}
```
A class may now declare a method named `scope`.
An aggregate operation named `fragment` is now refused with `ORM.AGGREGATE_OPERATION_RESERVED` when the client is built; rename the operation. An aggregate operation may now be named `scope`.
### A namespace named `fragment`
A contract namespace takes its name on the client even when the client has a method of that name. If your contract has a namespace named `fragment`, `db.orm.fragment` is that namespace, and the client has no method to make a fragment for any model. If code calls `db.orm.scope(fields, body)` with such a contract, replace each call: a fragment for one model with `collection.fragment(body)` on each model it serves, or a filter with a function of the model accessor (a row fragment) whose parameter is typed with `CodecField`, passed to `where`. A namespace named `scope` no longer hides a method, and `db.orm.scope` still reaches it.
### Error texts
Errors and compile errors about fragments say "fragment" where they said "scope". Before this change, after `apply` was renamed to `with`, they read `Cannot define the scope: the body is not a function`, `Pass the scope to with on a collection: collection.with(scope).` and, for the refused bulk writes, `A scope passed to with can add one without showing it at the call site.` They now read `Cannot define the fragment: the body is not a function`, `Pass the fragment to with on a collection: collection.with(fragment).` and `A fragment passed to with can add one without showing it at the call site.` Every other text that said "the scope" or "a scope" says "the fragment" or "a fragment" in the same place. The compile error for a model that lacks a declared field names the property `the model has no field that matches the declaration in the fragment`. Update tests that assert on the old text.
## `collection-state-carries-locking`
`CollectionState` in `@internal/sql-orm-client` now has a required `locking: ReadonlyArray<LockingClause> | undefined`, the row locks the collection's `forUpdate()`, `forNoKeyUpdate()`, `forShare()` and `forKeyShare()` recorded. Where you build a state literal, start from `{ ...emptyState(), ... }`, or, when you derive it from an existing state, spread that state or copy `locking: state.locking`. Setting `locking: undefined` while deriving from a locked state drops the caller's lock without an error.
upgrading/extension/upgrades/8.0.0-rc.17-to-8.0.0-rc.18/instructions.md
---
from: "8.0.0-rc.17"
to: "8.0.0-rc.18"
changes:
- id: build-symbol-table-takes-default-namespace-id
summary: |
`buildSymbolTable` from `@internal/psl-parser` requires `defaultNamespaceId`. `ContractSourceContext` (`@internal/config/config-types`) and `ControlTargetDescriptor` (`@internal/framework-components/control`) each have a required `defaultNamespaceId`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bbuildSymbolTable\s*\('
- '\bContractSourceContext\b'
- '\bControlTargetDescriptor\b'
- id: symbol-table-has-no-top-level
summary: |
`SymbolTable` is `{ defaultNamespace, namespaces, namedTypes }` with no `topLevel`; `TopLevelScope` is removed. Top-level models, composite types, blocks and mixins are members of `symbolTable.defaultNamespace`. `NamespaceSymbol` has a contract `id` and a PSL `name`, `namespaces` is keyed by `id`, and `declarations` can be empty.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\.topLevel\b'
- '\bTopLevelScope\b'
- '\bNamespaceSymbol\b'
- '\.declarations\[0\]'
- '\bResolvedEntityReference\b'
- id: repeated-extension-block-is-a-duplicate-declaration
summary: |
A test that expects `PSL_DUPLICATE_EXTENSION_ENTITY` for an extension block repeated at the top level and in `namespace public { … }`, or under both `namespace unbound { … }` and `namespace __unbound__ { … }`, now gets `PSL_DUPLICATE_DECLARATION` with the message `Duplicate declaration of "<name>"`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bPSL_DUPLICATE_EXTENSION_ENTITY\b'
- id: psl-default-namespace-schema-rules
summary: |
PSL shipped with or generated by an extension: the top level and `namespace public { … }` (Postgres) share one set of names, and a top-level declaration cannot be named like the target's default namespace. Rename or remove the clashing declaration.
detection:
glob: "**/*.{prisma,psl}"
matches:
- '(^|\n)\s*namespace\s+(public|unbound|__unbound__)\s*\{'
- '(^|\n)[ \t]*(?!namespace[ \t])[A-Za-z_]\w*[ \t]+(public|unbound|__unbound__)[ \t]*\{'
- '(^|\n)\s*(public|unbound|__unbound__)\s*=\s*\S'
- id: block-spec-context-carries-data-types
summary: |
`BlockSpecContext` gains `dataTypes`, and `interpretExtensionBlocks`, `interpretExtensionBlock` and `interpretExtensionBlockAttributes` take a required `dataTypes`. `ControlDefaultRegistries` is deleted: an attribute spec context, `createBinder` and the Mongo PSL interpreter take `defaultFunctionRegistry` directly. Every factory in `sqlAttributeSpecs` takes the spec context.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\b(BlockSpecContext|ControlDefaultRegistries|interpretExtensionBlocks|interpretExtensionBlock|interpretExtensionBlockAttributes)\b'
- 'controlMutationDefaults:\s*\{\s*defaultFunctionRegistry'
- 'sqlAttributeSpecs\.(model|field)\.\w+\(\)'
- id: tagged-literal-text-helpers
summary: |
`canonicalizeTaggedLiteralBody` and `TaggedLiteralCanonicalization` are exported from `@internal/framework-components/authoring` only, beside the new `printedTaggedLiteralReadsBack`. `printSqlExpressionLiteral` throws for a text that would read back as different text; check with `sqlTextsReadBack` first.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bcanonicalizeTaggedLiteralBody\b'
- '\bTaggedLiteralCanonicalization\b'
- '\bprintSqlExpressionLiteral\b'
- id: builder-raw-sql-is-a-sql-value
summary: |
In a TypeScript contract, `index({ where, expression })`, `check({ expression })`, `fullTextIndex({ where })` and the `using` and `withCheck` of `policySelect`, `policyInsert`, `policyUpdate`, `policyDelete` and `policyAll` take a `sql` value. A string there does not compile. Migration files keep strings.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<!readonly |''function'', )\b(where|expression|using|withCheck)\s*:\s*[''"\x60]'
- id: sql-tag-interpolates-sql-values
summary: |
The `sql` tag interpolates other `sql` values: `` sql`${owner} AND deleted_at IS NULL` ``. Compose predicates this way instead of joining strings.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<!\bsql)\x60\$\{[\w.]+\}\s+(AND|OR)\b'
- id: sql-tag-error-codes-renamed
summary: |
`CONTRACT.DEFAULT_SQL_INTERPOLATION` is renamed `CONTRACT.SQL_EXPRESSION_INTERPOLATION`. A NUL character or an oversize text in a `sql` value is `CONTRACT.SQL_EXPRESSION_INVALID`, not `CONTRACT.DEFAULT_INVALID`. `.default()` raises `CONTRACT.DEFAULT_INVALID` for `` sql`now()` ``, `` sql`autoincrement()` `` and unsafe SQL.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bDEFAULT_SQL_INTERPOLATION\b'
- '\bCONTRACT\.DEFAULT_INVALID\b'
- id: ts-storage-hash-may-change-once
summary: |
A raw SQL string with indentation shared by every line, blank lines at the start or end, a whitespace-only line or CRLF line breaks is stored as its canonical text once it is written as a `sql` value, which changes the contract's storage hash once.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<!readonly )\b(where|expression|using|withCheck)\s*:\s*(\x60[ \t]*$|[''"\x60][ \t\r\n])'
- id: policy-handles-hold-sql-values
summary: |
`RlsPolicyHandle`, `RlsUsingPolicyDescriptor`, `RlsWithCheckPolicyDescriptor` and `RlsUsingWithCheckPolicyDescriptor` hold `using` and `withCheck` as `SqlExpression`; `fullTextIndex`'s `where` option, `IndexConstraint.where`, `IndexExpressionInput` and `AuthoredCheckConstraint.expression` are `SqlExpression` too. Read the text with `.text`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\b(RlsPolicyHandle|RlsUsingPolicyDescriptor|RlsWithCheckPolicyDescriptor|RlsUsingWithCheckPolicyDescriptor|IndexConstraint|IndexExpressionInput|AuthoredCheckConstraint)\b'
- id: sql-tag-lives-in-sql-contract
summary: |
The `sql` tag and the `SqlExpression` type live in `@internal/sql-contract/sql-expression`; `@internal/sql-contract-ts/contract-builder` re-exports both. The tag returns a `SqlExpression`, not a `ColumnDefault`. `sql-default-literal.ts` is deleted.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'sql-default-literal'
- '\bsql\x60[^\x60]*\x60\s*\.(kind|expression)\b'
- ':\s*ColumnDefault\s*=\s*sql\x60'
- 'import\s*(type\s*)?\{[^}]*\bsql\b[^}]*\}\s*from\s*[''"]@internal/sql-contract-ts/contract-builder[''"]'
- id: render-full-text-index-document-takes-a-definition
summary: |
`renderFullTextIndexExpression` from `@internal/target-postgres/sql-utils` is now `renderFullTextIndexDocument`, and it takes the index definition `{ weightGroups, language }` instead of a language and one column name.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\brenderFullTextIndexExpression\b'
- id: full-text-indexable-codec-check-replaced
summary: |
`isFullTextIndexableCodec` is no longer exported from `@internal/target-postgres/sql-utils`. Check a full-text index with `fullTextIndexProblems`, or read a codec's traits with `postgresCodecTraitsOf`, both from `@internal/target-postgres/full-text-index-authoring`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bisFullTextIndexableCodec\b'
- id: structured-index-option-values-hash-as-json
summary: |
An index option whose value is an array or an object now enters the index name's hash as JSON rather than through `String()`. A wire-named index with such an option gets a new name; re-emit contracts that declare one.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'constraints\.index\([\s\S]*options:\s*\{[^}]*[\[{]'
- id: collection-state-carries-tables
summary: |
A SQL ORM `CollectionState` has a required `tables` property holding the table scope and the aliased root table and variant tables of the collection, and `emptyState()` takes it as its argument. Build it with `createCollectionTables(contract, namespaceId, modelName)`. This applies to every hand-built state, including `IncludeExpr.nested`, a `combine()` branch state and the state of an include scalar selector.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\bemptyState\('
- '\bCollectionState\b'
- id: state-filters-planned-as-written
summary: |
The SQL ORM planners no longer turn a literal in `CollectionState.filters` into a parameter. A filter put into a hand-built state must carry a `ParamRef` with the column's codec for each value compared with a column; a `LiteralExpr` left there is not encoded by the column's codec. Filters added through `collection.where(...)` are unaffected.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\bfilters\s*:'
- id: create-model-accessor-takes-tables
summary: |
`createModelAccessor` takes the collection's tables as its fourth argument, before the optional variant's discriminator value: `createModelAccessor(context, namespaceId, modelName, tables, variantValue?)`.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\bcreateModelAccessor\('
- id: include-expr-junction-table
summary: |
A hand-built `IncludeExpr` for a many-to-many relation needs a `junction` aliased table next to `through`; compiling a `through` include without one throws. `IncludeExpr.localTableName` is replaced by the optional `localVariantValue`, the discriminator value of the multi-table-inheritance variant whose table holds the relation's local columns.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\bIncludeExpr\b'
- id: read-variants-by-discriminator-value
summary: |
A polymorphic base model's `variants` in a contract is keyed by discriminator value, and each entry is a reference `{ namespace, model }` to the variant model. Code that reads it finds the variant model at `variants[<value>].model`, where it used to find the value at `variants[<Model>].value`. `ContractVariantEntry` is removed; the entry type is `CrossReference`. A variant may be in a different namespace from its base.
detection:
glob: "**/*.{ts,tsx,mts,cts,js,mjs,cjs}"
matches:
- '(?<![\w$])variants(?![\w$])'
- '\bContractVariantEntry\b'
- id: sql-orm-state-names-a-variant-by-value
summary: |
SQL ORM collection state names a variant by its discriminator value, not its model name. `CollectionState.variantName` is `variantValue`, the type-state field `variantName` is `variantValue`, and `CollectionTables.variants` is keyed by discriminator value. `IncludeExpr.localTableName` is replaced by `localVariantValue`, as `include-expr-junction-table` describes.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\bvariantName\b'
- '\bCollectionTables\b'
- id: orm-variant-types-take-a-value
summary: |
The Mongo ORM variant type helpers take a discriminator value where they took a variant model name: `VariantModelRow`, `VariantCreateInput`, and the fourth type parameter of `MongoCollection`. `DiscriminatorValues` is the keys of `variants`. `VariantNames` and `VariantNameForValue` are removed.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\bVariantNames\b'
- '\bVariantNameForValue\b'
- '\bVariantModelRow\b'
- '\bVariantCreateInput\b'
- '\bMongoCollection\s*<'
- id: orm-refuses-names-that-are-not-fields
summary: |
The SQL ORM client refuses any name a caller passes that is not a field of the model, or of the variant the collection is narrowed to, with `ORM.FIELD_UNKNOWN`. That covers `where` shorthand and callbacks, `orderBy`, `select`, `distinct`, `distinctOn`, `groupBy`, `cursor`, aggregates, `having`, create, update and upsert data, `conflictOn`, include nested selects and relation filters. A column name that differs from its field name is no longer accepted, and rows no longer carry a column no field maps. `conflictOn` with a name that is not a field raised `ORM.ARGUMENT_INVALID` and now raises `ORM.FIELD_UNKNOWN`. The detection matches a file that names both `conflictOn` and `ORM.ARGUMENT_INVALID`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\s\S])(?=[\s\S]*\bconflictOn\b)[\s\S]*\bORM\.ARGUMENT_INVALID\b'
- id: fragment-is-made-from-the-root-collection
summary: "The SQL ORM client's collection.fragment(body) refuses a collection with chained calls, such as db.Post.where(...).fragment(body), which used to ignore those calls; chain on the collection directly instead, or define a shared fragment on the model's root collection. A receiver typed by a type parameter, such as this in a custom collection class method, is refused at compile time. The detection matches .fragment( right after a closing parenthesis, as in a chained call, and this.fragment(."
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\)\s*\.fragment\('
- '\bthis\s*\.fragment\('
- id: fragment-types-are-renamed
summary: |
The SQL ORM client's types `Fragment` and `FieldFragment`, which 8.0.0-rc.17 exports, are renamed to `QueryFragment` and `DeclaredFieldsFragment`. Code still on the 8.0.0-rc.16 names renames `Scope` to `QueryFragment`, `FieldScope` to `DeclaredFieldsFragment` and `ScopeFacts` to `FragmentFacts` directly. `FragmentFacts`, `DeclaredField`, `db.orm.fragment` and `collection.fragment` keep their names. The detection matches `Fragment` and `Scope` only in an import or re-export from the ORM client (an `orm-client` entry or `@internal/sql-orm-client`), after a namespace import of it, or in an inline `import(...)` type of it, so React's `Fragment` and the SQL builder's `Scope` do not match.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\b(?:FieldFragment|FieldScope|ScopeFacts)\b'
- '(?:import|export)\s+(?:type\s+)?\{[^}]*\b(?:Fragment|Scope)\b[^}]*\}\s*from\s*[''"]@(?:prisma/[\w-]+/orm-client|internal/sql-orm-client)[''"]'
- 'import\s+(?:type\s+)?\*\s+as\s+([\w$]+)\s+from\s*[''"]@(?:prisma/[\w-]+/orm-client|internal/sql-orm-client)[''"][\s\S]*\b\1\.(?:Fragment|Scope)\b'
- 'import\(\s*[''"]@(?:prisma/[\w-]+/orm-client|internal/sql-orm-client)[''"]\s*\)\.(?:Fragment|FieldFragment|Scope|FieldScope)\b'
- id: unique-criterion-rejects-null
summary: |
`UniqueConstraintCriterion` in the SQL ORM client (`@prisma/orm-postgres/orm-client`, the other facades' `orm-client` entries and `@internal/sql-orm-client`) no longer accepts `null` for a nullable unique column, so the `conflictOn` of `upsert` and the criteria of a relation `connect` or `disconnect` stop compiling when a value is `null` or may be `null`. Pass a non-null value, or handle the null case before the call. The detection matches every `conflictOn`, `.connect(` and `.disconnect(`; leave as they are the calls on other objects, such as `postgres.connect(...)`, and the `conflictOn` of `createAll` and `createAndCount`, which is an array of field names.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\bconflictOn\b|\.(?:connect|disconnect)\s*\(|\bUniqueConstraintCriterion\b'
- id: prepared-all-and-aggregate-need-their-receiver
summary: |
On every collection of the SQL ORM client, `prepared.all` and `prepared.aggregate` no longer compile when called without the `prepared` object as their receiver, such as after `const { all } = collection.prepared`. Call them on the `prepared` object. `prepared.first` and `prepared.firstOrThrow` are not affected, and nothing changes at run time.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\{[^}]*\b(?:all|aggregate)\b[^}]*\}\s*=\s*[^;\n]*\.prepared\b'
- id: build-native-enums-takes-the-domain
summary: |
`buildNamespacedNativeEnums` from `@prisma/orm-postgres/runtime` takes the contract's domain as a second argument, `buildNamespacedNativeEnums(contract.storage, contract.domain)`, and leaves out each value set that a domain enum of the same namespace produced. The accessors it builds hold native enum types only; read a domain enum through the accessor `db.enums` uses.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bbuildNamespacedNativeEnums\s*\('
- id: middleware-hooks-are-activity-hooks
summary: |
The per-statement middleware hooks are replaced. `beforeQuery` and `beforeExecute` become `beforeEncode`; `interceptQuery` and `interceptExecute` become `intercept.onQuery` and `intercept.onExecute`; `afterQuery` and `afterExecute` become `activityEnded` on a statement; `afterTransaction` becomes `activityFinalized` on a statement. `onRow` and `beforeCompile` are unchanged.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\b(beforeQuery|beforeExecute|interceptQuery|interceptExecute|afterQuery|afterExecute|afterTransaction)\b'
- id: middleware-context-is-activity-context
summary: |
`RuntimeMiddlewareContext` is renamed `ActivityContext`. It gains `parent`, `state(key)` and `inTransaction()`, and loses `planExecutionId`; use `activity.id` instead.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bRuntimeMiddlewareContext\b'
- '\bplanExecutionId\b'
- id: after-result-types-removed
summary: |
`AfterQueryResult`, `AfterExecuteResult` and `AfterTransactionResult` are removed. Use `ActivityEndedResult` (narrow it with `result.kind === 'statement'` and `result.returns`) and `ActivityFinalizedResult`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bAfter(Query|Execute|Transaction)Result\b'
- id: aborted-phase-before-encode
summary: |
A `RUNTIME.ABORTED` error raised during the middleware stage before encoding reports `details.phase: 'beforeEncode'`, not `'beforeQuery'` or `'beforeExecute'`. The phases `'afterQuery'` and `'afterExecute'` no longer occur. Mongo's `RUNTIME.CONTENT_HASH_REQUIRES_RESOLVED_COMMAND` also reports `details.phase: 'beforeEncode'`, not `'beforeExecute'`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '[''"`](beforeQuery|beforeExecute|afterQuery|afterExecute)[''"`]'
- id: every-started-statement-is-ended-and-finalized
summary: |
Every statement the runtime accepts now starts an activity, and every started statement ends and is finalized exactly once. A call with an already-aborted signal fires `activityStarted` before it fails. A statement that fails before it runs, in `beforeCompile`, `beforeEncode` or parameter encoding, now ends `failed` and is finalized, where it got no `afterTransaction` before. `activityEnded` also fires for a statement the caller stopped reading (outcome `'stopped'`) and for a statement that failed before it ran (outcome `'failed'`, `latencyMs: 0`, `source: 'driver'`); `afterQuery` and `afterExecute` fired for neither, so a metrics or slow-query middleware now counts them unless it checks `result.outcome`. When a middleware's `activityStarted` throws, the middleware after it receive no `activityEnded` or `activityFinalized` for that activity. A statement still running when its transaction ends, such as a row stream still being read, is finalized when it ends, with the transaction's outcome, and keeps its `ctx.state` until then.
- id: observed-row-budget-runs
summary: |
The `budgets()` middleware now counts the rows a query returns and enforces `maxRows` on them. Before, the observed-row check never ran. With the default row severity `'error'`, or in `strict` mode, a query that returns more than `maxRows` rows fails with `BUDGET.ROWS_EXCEEDED`; with `'warn'` in `permissive` mode it logs one warning for the statement and keeps streaming.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bbudgets\('
- id: statement-runners-replace-middleware-runners
summary: |
`runBeforeQueryChain`, `runBeforeExecuteChain`, `runQueryWithMiddleware`, `runExecuteWithMiddleware`, `runAfterTransaction`, `reportQueryEnding`, `reportExecuteEnding`, `onQueryEndOutsideTransaction` and `QueryEnding` are removed from `@prisma/orm-framework`. A runtime runs a statement with `streamStatement` or `executeStatement`, whose body calls `beforeEncode`, `runQuery` and `runExecute`, and opens a transaction with `startTransactionActivity`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\b(runBeforeQueryChain|runBeforeExecuteChain|runQueryWithMiddleware|runExecuteWithMiddleware|runAfterTransaction|reportQueryEnding|reportExecuteEnding|onQueryEndOutsideTransaction|QueryEnding|afterTransactionStageFor|anyMiddlewareDeclaresAfterTransaction)\b'
- id: runtime-core-context-fields
summary: |
`RuntimeCoreOptions.ctx` is an `ActivityContextFields`: the context fields without `parent`, `state` and `inTransaction`, and without `planExecutionId`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bplanExecutionId\s*:'
- id: begin-transaction-on
summary: |
`SqlRuntimeBase.wrapTransaction(driverTx, connection)` is replaced by `beginTransactionOn(connection, owner)`, which starts the transaction activity, runs `BEGIN` on the connection and returns the wrapped transaction. `owner` is the `RuntimeConnection` the subclass hands to its callers for that connection; when the transaction's `activityStarted` hooks refuse the start before `BEGIN`, `withTransaction` releases `owner`, and after any other start failure it destroys it.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bwrapTransaction\s*\('
- id: middleware-debug-events-renamed
summary: |
The runtime's debug events `middleware.interceptQuery` and `middleware.interceptExecute` are now `middleware.intercept.onQuery` and `middleware.intercept.onExecute`, and the logged error event `middleware.afterTransaction.error` is now `middleware.activityFinalized.error`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'middleware\.(interceptQuery|interceptExecute|afterTransaction\.error)'
- id: rename-statements-capability-removed
summary: |
`TargetMigrationsCapability.renameStatements` (with `refused` and `keepDataByHand`), added in 8.0.0-rc.17, is removed: every target's planner carries out rename statements. The CLI no longer appends a target's text to a data-loss question.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\w$])(?:renameStatements|keepDataByHand)\s*:'
- id: mongo-ddl-rename-collection
summary: |
`MongoDdlCommandVisitor` gains `renameCollection(command: RenameCollectionCommand)`, and `AnyMongoDdlCommand` and `AnyMongoDdlWireCommand` gain `RenameCollectionCommand` and `RenameCollectionWireCommand` (source in `collection`, new name in `to`). A visitor implementation, or a driver's `run`, handles the new kind.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\w$])(?:MongoDdlCommandVisitor|AnyMongoDdlCommand|AnyMongoDdlWireCommand)(?![\w$])'
- id: mongo-dml-operations
summary: |
`MongoDataTransformOperation` is renamed `MongoDmlOperation`, and `MongoDataTransformCheck` `MongoDmlCheck`. Its `operationClass` is any `MigrationOperationClass`, not only `'data'`: the planner's document rewrites (`renameField`, `unsetField`) are DML operations with class `widening` or `destructive`. Tell a DML operation from a DDL operation with `isMongoDmlOperation(operation)` (it has `run`), not with `operationClass === 'data'`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'operationClass\s*===?\s*["'']data["'']'
- '(?<![\w$])MongoDataTransform(?:Operation|Check)(?![\w$])'
- id: mongo-update-many-bypass-validation
summary: |
`RawUpdateManyCommand` takes an optional fourth argument `{ bypassDocumentValidation }`; `UpdateManyWireCommand` takes it as an optional fifth constructor argument and carries `bypassDocumentValidation: boolean`; the lowered `rawUpdateMany` draft (`MongoLoweredDraft`) requires `bypassDocumentValidation: boolean`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'kind:\s*["'']rawUpdateMany["'']'
- id: mongo-op-factory-call-to-op
summary: |
A Mongo `OpFactoryCall`'s `toOp()`, and `renderOps(calls)`, return `AnyMongoMigrationOperation`, since `RenameFieldCall` and `UnsetFieldCall` produce DML operations.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '["'']@(?:prisma/orm-target-mongo|internal/target-mongo)(?:/[^"'']*)?["''][\s\S]*(?:\.toOp\(\)|(?<![\w$])renderOps\s*\()'
---
# 8.0.0-rc.17 → 8.0.0-rc.18 — Extension author upgrade instructions
## The PSL symbol table places top-level declarations in the default namespace
`@prisma/orm-framework` publishes these modules as `@prisma/orm-framework/psl-parser`, `/config/config-types` and `/components/control`.
### `build-symbol-table-takes-default-namespace-id`
Pass the target's default namespace id: `context.defaultNamespaceId` in a contract source, `stack.target.defaultNamespaceId` from a control stack. A `ContractSourceContext` or `ControlTargetDescriptor` built by hand adds the same field (`'public'` for Postgres, `'__unbound__'` for SQLite and MongoDB).
```ts
const { symbolTable } = buildSymbolTable({
documents,
sources,
defaultNamespaceId: context.defaultNamespaceId,
});
```
### `symbol-table-has-no-top-level`
Before:
```ts
for (const model of Object.values(symbolTable.topLevel.models)) visit(model, undefined);
for (const namespace of Object.values(symbolTable.topLevel.namespaces)) {
for (const model of Object.values(namespace.models)) visit(model, namespace.name);
}
```
After:
```ts
for (const namespace of Object.values(symbolTable.namespaces)) {
for (const model of Object.values(namespace.models)) visit(model, namespace.id);
}
```
`symbolTable.namespaces` includes the default namespace, which is also `symbolTable.defaultNamespace` and holds the top-level declarations together with the members of a `namespace public { … }` block. `topLevel.namedTypes` is `symbolTable.namedTypes`. Use `namespace.id` for a contract id; `namespace.name` is the PSL spelling, which differs only for the late-bound namespace (id `__unbound__`, name `unbound`, one symbol for both spellings). `namespace.declarations` lists the written blocks and is empty for a default namespace with none. A model, composite type or block resolution always has `namespace`, so `resolution.namespace?.name ?? fallback` becomes `resolution.namespace.id`.
### `repeated-extension-block-is-a-duplicate-declaration`
The symbol table reports the repeated block before any entity factory runs. Change the expected code for those inputs only; `PSL_DUPLICATE_EXTENSION_ENTITY` is still reported when two different blocks place an entity under one key in one namespace.
### `psl-default-namespace-schema-rules`
A name declared both at the top level and inside `namespace public { … }` (the default namespace on Postgres), or a `types { }` binding or namespace with the same name as a member of that block, is `PSL_DUPLICATE_DECLARATION`: remove or rename one. A top-level declaration named like the target's default namespace (`public` on Postgres; `unbound` or `__unbound__` on SQLite and MongoDB) is rejected with the same code: rename it, and on a model keep the stored name with `@@map`.
## Raw SQL in PSL is a `sql` literal
### Spec contexts carry the stack's data types
A block spec can now read the stack's data types, so a block parameter can receive a data type through `dataTypeValue`:
```ts
function policyUsingParam(ctx: BlockSpecContext) {
return {
type: optional(dataTypeValue(SQL_EXPRESSION_DATA_TYPE_ID, ctx.dataTypes)),
documentation: 'A SQL predicate controlling which rows this policy permits.',
};
}
```
- `BlockSpecContext` is `{ symbols, dataTypes }`. It carries no block: a block attribute reads the block it belongs to from `BlockAttributeCtx.selfBlock` (ADR 262). Code that builds one passes the stack's `DataTypeSupport`, or `EMPTY_DATA_TYPES` from `@internal/psl-parser` when the stack registers none.
- `interpretExtensionBlocks`, `interpretExtensionBlock` and `interpretExtensionBlockAttributes` take a required `dataTypes`. Pass the stack's data types, the same value the attribute spec context carries.
- `ControlDefaultRegistries` is deleted. `AttributeSpecContext`, `CreateBinderOptions` and `InterpretPslDocumentToMongoContractInput` take `defaultFunctionRegistry: ControlMutationDefaultRegistry` in place of `controlMutationDefaults`:
```diff
- controlMutationDefaults: { defaultFunctionRegistry },
+ defaultFunctionRegistry,
```
A spec factory that read `ctx.controlMutationDefaults.defaultFunctionRegistry` reads `ctx.defaultFunctionRegistry`.
`blockSpecContext({ symbols, dataTypes })` from `@internal/psl-parser` builds a `BlockSpecContext`. Every factory in `sqlAttributeSpecs` (from `@internal/sql-contract-psl/attribute-specs`) takes the spec context, including those whose spec does not read it: call `sqlAttributeSpecs.model.map(ctx)`, not `sqlAttributeSpecs.model.map()`.
A spec that takes raw SQL can do what the SQL family and Postgres now do: declare `dataTypeValue(SQL_EXPRESSION_DATA_TYPE_ID, ctx.dataTypes)`, with `SQL_EXPRESSION_DATA_TYPE_ID` from `@internal/sql-contract/sql-expression`. The value it returns holds the canonical text; `sqlTextFromCanonical(value.value)` reads it.
### Tagged literal text helpers
`canonicalizeTaggedLiteralBody` and its result type `TaggedLiteralCanonicalization` are no longer exported from `@internal/framework-components/control`. Import them from `@internal/framework-components/authoring`:
```diff
- import { canonicalizeTaggedLiteralBody } from '@internal/framework-components/control';
- import type { TaggedLiteralCanonicalization } from '@internal/framework-components/control';
+ import { canonicalizeTaggedLiteralBody } from '@internal/framework-components/authoring';
+ import type { TaggedLiteralCanonicalization } from '@internal/framework-components/authoring';
```
`canonicalizeTaggedLiteralBody` now drops every blank line at the start and end of the body, not only the first and the last, so its result is always its own canonical text.
The same entry exports `printedTaggedLiteralReadsBack(text)`, which says whether a tagged literal printed with `text` reads back as the same text. `printSqlExpressionLiteral` from `@internal/sql-contract/sql-expression` now throws an internal error for a text that would read back as different text. Check the texts first with `sqlTextsReadBack(texts)` from the same module. When it returns `false`, `contract infer` skips an object named with `map:`, prints a wire-named index with `canonicalSqlText(text)` (whose result always reads back), and `contract print` refuses the object. Default expressions are not checked: they print with `printTaggedLiteral`, because defaults are compared with case and whitespace ignored.
## Raw SQL in a TypeScript contract is a `sql` value
### `builder-raw-sql-is-a-sql-value`
Every field of the TypeScript contract builder that holds raw SQL takes a `sql` value, as `.default()` already did. Wrap each string in the `sql` tag, imported from the same contract-builder module as the other helpers:
| Place | Before | After |
| --- | --- | --- |
| `index` `where` | `constraints.index([cols.email], { where: '(archived_at IS NULL)', name: 'users_email_active' })` | ``constraints.index([cols.email], { where: sql`(archived_at IS NULL)`, name: 'users_email_active' })`` |
| `index` `expression` | `constraints.index({ expression: 'lower(email)', name: 'users_email_lower' })` | ``constraints.index({ expression: sql`lower(email)`, name: 'users_email_lower' })`` |
| `fullTextIndex` `where` | `fullTextIndex(cols.text, { where: 'archived_at IS NULL', name: 'message_text_search_live' })` | ``fullTextIndex(cols.text, { where: sql`archived_at IS NULL`, name: 'message_text_search_live' })`` |
| `check` `expression` | `check({ expression: 'total > 0', name: 'order_total_positive' })` | ``check({ expression: sql`total > 0`, name: 'order_total_positive' })`` |
| a policy's `using` and `withCheck` | `policySelect(Profile, { name: 'profile_owner_read', roles: [authenticated], using: '"userId"::uuid = auth.uid()' })` | ``policySelect(Profile, { name: 'profile_owner_read', roles: [authenticated], using: sql`"userId"::uuid = auth.uid()` })`` |
```ts
import { check, policySelect, sql } from '@internal/postgres/contract-builder';
```
Copy the string's value, not its source: undo the TypeScript string's own escaping first, so `'"userId"'` and `"\"userId\""` both become `` sql`"userId"` ``. Inside the template, write each backtick as `` \` ``, each `${` as `\${`, and a backslash that precedes a backtick, a dollar sign or another backslash as `\\`; every other backslash is kept as written, so `E'\n'` stays `E'\n'`. An index or check object written by hand in `.sql({ indexes, checks })` takes a `sql` value in the same fields. A shared predicate held in a `const` becomes a `sql` value too: `` const owner = sql`"userId"::uuid = auth.uid()`; ``.
Skip files under a `migrations/` folder and leave them as they are: migration functions take a string or a `sql` value, and a string keeps its exact text, while wrapping it in `sql` canonicalizes it, which can change the SQL that migration runs.
The string form is a type error. JavaScript that is not type-checked and passes a string fails when the contract is built, with `CONTRACT.ARGUMENT_INVALID`, for example ``Index "users_email_active" where must be a sql`...` value.`` The message names the index, check or policy; an index or check with no `name` or `map` is named by its model, as in `Index on "User" where`. `.default('draft')` is not raw SQL: a string there is still a literal default. The `{ fields, render }` form of an index expression still returns a string from `render`, because that text is generated by code.
This supersedes the TypeScript `fullTextIndex(cols.text, { where: 'archived_at IS NULL', … })` example of the `postgres-full-text-search` app instructions in the upgrade from 8.0.0-rc.11 to 8.0.0-rc.12: write it as ``fullTextIndex(cols.text, { where: sql`archived_at IS NULL`, … })``.
### `sql-tag-interpolates-sql-values`
The `sql` tag accepts other `sql` values inside `${…}`. It joins their text into the template and canonicalizes the whole text once. A predicate that was built by joining strings is written as one `sql` template:
```ts
const owner = sql`owner_id = current_setting('app.uid')::int`;
const ownerAlive = sql`${owner} AND deleted_at IS NULL`;
```
Anything else inside `${…}`, such as a string or a number, is a type error, and throws `CONTRACT.SQL_EXPRESSION_INTERPOLATION` from JavaScript that is not type-checked. Prisma never quotes a value into SQL, so write any other text inside the template. Each line of an interpolated value after its first takes the indentation of the template line the `${…}` sits on, so a multi-line value inside an indented template stores the same text as the same SQL written out in one template.
### `sql-tag-error-codes-renamed`
- `CONTRACT.DEFAULT_SQL_INTERPOLATION` is now `CONTRACT.SQL_EXPRESSION_INTERPOLATION`, and its message is ``sql`...` only interpolates other sql`...` values; write any other text inside the template.``
- A `sql` value whose text holds a NUL character or is larger than 65536 bytes throws `CONTRACT.SQL_EXPRESSION_INVALID` when it is made. It was `CONTRACT.DEFAULT_INVALID`.
- `` sql`now()` ``, `` sql`autoincrement()` `` and unsafe SQL (`;`, a comment token, `$$` or `SELECT`) are no longer refused by the tag. `.default()` refuses them with `CONTRACT.DEFAULT_INVALID` and the same messages, so `` const current = sql`now()` `` compiles and runs, and `.default(current)` throws. In every other place such text is passed to the database unchanged.
Update code that matches on these codes. Code that catches `CONTRACT.DEFAULT_INVALID` around a `sql` template that is not passed to `.default()` no longer sees it.
### `ts-storage-hash-may-change-once`
A `sql` value stores its canonical text, as a PSL `sql` literal does: indentation shared by every line, blank lines at the start and end, whitespace-only lines and carriage returns are removed. A raw SQL string whose text had any of these, for example a multi-line template string, is stored differently once it is written as a `sql` value, so the contract's storage hash changes once. Prisma-generated index, check and policy names do not change. Skip files under a `migrations/` folder and leave them as they are: migration functions take a string or a `sql` value, and a string keeps its exact text, while wrapping it in `sql` canonicalizes it, which can change the SQL that migration runs. Emit the contract again, and for a pack with migrations run `prisma migration plan` once and commit the migration it writes. For an index or check named with `map:` whose text changed, `migration plan` stops with a conflict and asks for a migration written with `migration new`, which needs no operations. The TypeScript policy helpers take no exact name, so a policy never needs this. A `{ fields, render }` index expression whose `render` returns text that is not canonical, such as indented multi-line text, also stores different text once, with the same steps; `fullTextIndex` stores its fields and language as data, renders no SQL, and is not affected.
A contract built before this release can hold a column default whose text is not canonical. `contract infer` writes such a default with a note and `contract print` refuses it, for the reason ADR 268 gives ("Column defaults that do not read back"); the app instructions of this release say what to do.
### `policy-handles-hold-sql-values`
The types behind the builder fields changed with them. In code that builds, reads or re-exports them:
- `RlsPolicyHandle.using` and `.withCheck` are `SqlExpression | undefined`. Every `using` and `withCheck` of `RlsUsingPolicyDescriptor`, `RlsWithCheckPolicyDescriptor` and both arms of `RlsUsingWithCheckPolicyDescriptor` is `SqlExpression`, required or optional as before. A function that takes a descriptor and passes its predicates on types them as `SqlExpression` too.
- `fullTextIndex`'s option `where` is `SqlExpression` and is passed to `IndexConstraint.where` unchanged.
- `IndexConstraint.where` is `SqlExpression`, `IndexExpressionInput` is `SqlExpression | DeferredIndexExpression`, and `AuthoredCheckConstraint.expression` is `SqlExpression`. `DeferredIndexExpression.render` still returns a string; lowering canonicalizes the text it returns, so a `render` that returns text that is not canonical stores different text once, as `ts-storage-hash-may-change-once` describes.
- Import the type with `import type { SqlExpression } from '@internal/sql-contract/sql-expression';`, or from `@internal/sql-contract-ts/contract-builder`, which re-exports it.
- Read the text of a value with `.text`. Code that lowers such a value from JavaScript that is not type-checked reads it with `requireSqlExpression(value, 'Policy "p" using').text` from `@internal/sql-contract/sql-expression`, which throws `CONTRACT.ARGUMENT_INVALID` for anything else. The Postgres target lowers a policy handle's predicates this way.
The definition tree (`IndexNode`, `CheckNode`), `buildSqlContractFromDefinition`, `PostgresRlsPolicy` and the emitted contract keep strings.
### `sql-tag-lives-in-sql-contract`
`sql` is defined in `@internal/sql-contract/sql-expression`, with `SqlExpression`, `isSqlExpression`, `readSqlExpression` and `requireSqlExpression`. Read a value through `readSqlExpression` or `requireSqlExpression`, not by checking `isSqlExpression` and reading `.text`: a value made by another installed copy of the package passes `isSqlExpression`, and only those two functions canonicalize its text. `@internal/sql-contract-ts/contract-builder` re-exports `sql` and `type SqlExpression`; `packages/2-sql/2-authoring/contract-ts/src/sql-default-literal.ts` is deleted, so import from one of those modules. A contract-builder facade that re-exports `sql` also re-exports `type SqlExpression` beside it, and never the class as a value.
The tag returns a `SqlExpression`, whose `text` is the canonical text. Code that read `` sql`...`.expression `` or typed the result as `ColumnDefault` reads `.text`, or passes the value to `.default()`, which stores `{ kind: 'function', expression: value.text }`. `describeTaggedLiteralFailure` and `resolveTemplateTagEscapes` are also exported from `@internal/framework-components/authoring`.
This supersedes the sentence of the `sql-default-literal` extension instructions in the upgrade from 8.0.0-rc.11 to 8.0.0-rc.12 that says `` sql`now()` `` and `` sql`autoincrement()` `` are refused by the TypeScript `sql` tag: `.default()` refuses them now.
## A full-text index is stored as data
### `render-full-text-index-document-takes-a-definition`
The renderer now draws the whole search document of a full-text index, which may cover several columns in weight groups, and is named for it. Import `renderFullTextIndexDocument` instead and pass the definition the contract stores in the index's `options`:
```diff
-import { renderFullTextIndexExpression } from '@internal/target-postgres/sql-utils';
+import { renderFullTextIndexDocument } from '@internal/target-postgres/sql-utils';
-renderFullTextIndexExpression('english', 'body')
+renderFullTextIndexDocument({ weightGroups: [['body']], language: 'english' })
```
One field renders exactly what it rendered before, `to_tsvector('english', "body")`. A document of several columns wraps every column in `coalesce(column, '')`. A definition that breaks a rule of a full-text index (no group, more than four groups, an empty group, or a column named twice) is refused with `CONTRACT.INDEX_INVALID`.
A Postgres full-text index in a contract is now an index of type `fullText`, registered in the Postgres index type registry, with `columns` and `options: { weightGroups, language }`, not an expression. `columns` is exactly `weightGroups.flat()`; a contract where they differ is refused with `CONTRACT.INDEX_INVALID` when it is loaded, as is a full-text index over a column whose codec Postgres does not know to be `textual`. Code that reads indexes from a contract recognises a full-text index by `type === 'fullText'` (exported as `FULL_TEXT_INDEX_TYPE` from `@internal/target-postgres/full-text-index-authoring`), and must not treat its `columns` as a plain index over those columns. In the database it is a `gin` index over the rendered document: the `fullText` entry of the Postgres index type registry declares `accessMethod: 'gin'`.
### `full-text-indexable-codec-check-replaced`
The rules of a full-text index, including that it covers text columns only, are now one function. Call it instead of testing each codec:
```diff
-import { isFullTextIndexableCodec } from '@internal/target-postgres/sql-utils';
+import {
+ fullTextIndexProblems,
+ postgresCodecTraitsOf,
+} from '@internal/target-postgres/full-text-index-authoring';
-if (!isFullTextIndexableCodec(codecId)) refuse();
+const problems = fullTextIndexProblems({
+ weightGroups: [[columnName]],
+ codecs: { codecIdOf: () => codecId, traitsOf: postgresCodecTraitsOf },
+});
+if (problems.length > 0) refuse();
```
To test one codec alone, `postgresCodecTraitsOf(codecId)?.includes('textual') === true` is what `isFullTextIndexableCodec(codecId)` returned.
### `structured-index-option-values-hash-as-json`
`normalizeIndexOptionValue` from `@internal/sql-schema-ir/naming` writes an array or object value as JSON, with object keys sorted at every depth, so `[['a', 'b']]` and `[['a'], ['b']]` no longer hash to the same index name and the order in which an object's keys were written does not change it. Scalar values hash as before. If an extension's contract declares a wire-named index whose options hold an array or an object, rebuild its contract space so the emitted index name and storage hash move together.
## SQL ORM collection state carries its tables
These changes concern code that imports from `@prisma/orm-family-sql/orm-client` and builds SQL ORM collection state or model accessors by hand.
### `collection-state-carries-tables`
`CollectionState.tables` is a `CollectionTables` value: the table scope plus the aliased root table and each aliased multi-table-inheritance variant table. `emptyState` takes it as a required argument. Build it from the contract with `createCollectionTables`:
```ts
// before
import { emptyState } from '@prisma/orm-family-sql/orm-client';
const state = { ...emptyState(), limit: 10 };
// after
import { createCollectionTables, emptyState } from '@prisma/orm-family-sql/orm-client';
const tables = createCollectionTables(contract, 'public', 'User');
const state = { ...emptyState(tables), limit: 10 };
```
`createCollectionTables(contract, namespaceId, modelName)` takes the model the state is for. For a polymorphic model pass the base model name; its variant tables are aliased in the same scope.
Every object typed `CollectionState` needs the property, not only top-level states:
- `IncludeExpr.nested`;
- the `state` of a `combine()` rows branch;
- the `state` of an include scalar selector.
A state passed to `new Collection(ctx, modelName, { state })` needs it too. A `Collection` constructed without `state` builds its own tables, so that call needs no change.
Do not build a `CollectionTables` or a table scope by hand.
A state built by hand for an include child must not reuse the parent's table aliases. Prefer building includes through `collection.include(...)` and reading `collection.state`, which aliases the child's tables from the parent's scope. Where a child state is built by hand for a relation whose target table already appears in the parent chain (a self-relation, or an include that returns to an ancestor's table), build it through `include()` instead; `createCollectionTables` starts a fresh scope and would give the child the same alias as its parent.
### `state-filters-planned-as-written`
This affects only code that writes expressions into `CollectionState.filters` itself (a hand-built state, an `IncludeExpr.nested` state, a `combine()` branch state). Such a filter used to have each literal compared with a column replaced by a parameter carrying the column's codec when the statement was planned. The planners now use stored filters unchanged.
Build the filter through the collection, which binds it when `where()` is called, and read the state from the result:
```ts
// before
const state = {
...emptyState(),
filters: [BinaryExpr.eq(ColumnRef.of('users', 'email'), LiteralExpr.of(email))],
};
// after
const state = users.where(BinaryExpr.eq(ColumnRef.of('users', 'email'), LiteralExpr.of(email))).state;
```
A filter that must be written by hand needs a `ParamRef` with the column's codec in place of the literal. A literal left in place is not encoded by the column's codec.
### `create-model-accessor-takes-tables`
Pass the tables as the fourth argument. A variant moves to the fifth, named by its discriminator value:
```ts
// before
createModelAccessor(context, 'public', 'User');
createModelAccessor(context, 'public', 'Task', 'Feature');
// after
createModelAccessor(context, 'public', 'User', createCollectionTables(context.contract, 'public', 'User'));
createModelAccessor(
context,
'public',
'Task',
createCollectionTables(context.contract, 'public', 'Task'),
'feature',
);
```
The accessor takes the aliases for its relation filters and relation orders from `tables.scope` and records them there. Two relation filters over the same table built from one accessor therefore get different aliases. Pass each accessor its own `createCollectionTables(...)` result unless the expressions it builds are meant for one statement.
### `include-expr-junction-table`
This affects only an `IncludeExpr` object written out by hand with a `through` descriptor. An include built with `collection.include('<relation>')` carries the aliased junction table already.
There is no public function that aliases a junction table on its own. Replace the hand-built object with the include the collection builds:
```ts
// before
const include: IncludeExpr = { relationName: 'tags', through: { table: 'user_tags', /* … */ }, /* … */ };
// after
const [include] = users.include('tags').state.includes;
```
`IncludeExpr` no longer has `localTableName`. Remove the property where the relation's local columns are on the parent model's own table, which is every relation of a non-polymorphic model, a base model or a single-table-inheritance variant. Where they are on a multi-table-inheritance variant's table, set `localVariantValue` to that variant's discriminator value:
```ts
// before
const include: IncludeExpr = { relationName: 'assignee', localTableName: 'features', /* … */ };
// after
const include: IncludeExpr = { relationName: 'assignee', localVariantValue: 'feature', /* … */ };
```
## A variant is named by its discriminator value
These changes concern code that reads `variants` from a contract, or that imports from `@prisma/orm-family-sql/orm-client` or `@prisma/orm-family-mongo/orm`. A contract an extension ships must be re-emitted: see the app instructions' `re-emit-contracts-with-polymorphic-models`.
### `read-variants-by-discriminator-value`
```ts
// before
for (const [variantName, { value }] of Object.entries(model.variants ?? {})) {
const variant = models[variantName];
}
// after
for (const [value, variantRef] of Object.entries(model.variants ?? {})) {
const variant = contract.domain.namespaces[variantRef.namespace]?.models[variantRef.model];
}
```
Look the variant model up in the namespace its reference names, not in the base's namespace. A discriminator value is unique in a hierarchy; a variant model name is not, because two namespaces may each have a variant model of the same name.
Replace the type `ContractVariantEntry` with `CrossReference`, exported from the same module.
### `sql-orm-state-names-a-variant-by-value`
Rename the properties, and where a value is written or compared, use the discriminator value the variant declares in place of its model name:
```ts
// before
const narrowed = { ...state, variantName: 'Feature' };
// after, for @@base(Task, "feature")
const narrowed = { ...state, variantValue: 'feature' };
const featureTable = state.tables.variants.get('feature');
```
A type that reads the selected variant from a collection's type state reads `variantValue`, for example `CollectionTypeStateOf<C>['variantValue']`.
### `orm-variant-types-take-a-value`
These types are exported from `@prisma/orm-family-mongo/orm`. Pass the discriminator value where a variant model name was passed:
```ts
// before
type BugRow = VariantModelRow<Contract, 'Task', 'Bug'>;
type BugInput = VariantCreateInput<Contract, 'Task', 'Bug'>;
type Bugs = MongoCollection<Contract, 'Task', NoIncludes, 'Bug'>;
// after, for a Bug variant declaring "bug"
type BugRow = VariantModelRow<Contract, 'Task', 'bug'>;
type BugInput = VariantCreateInput<Contract, 'Task', 'bug'>;
type Bugs = MongoCollection<Contract, 'Task', NoIncludes, 'bug'>;
```
`VariantNameForValue<C, M, V>` mapped a value to a model name for these helpers; pass the value itself. Replace `VariantNames<C, M>` with `DiscriminatorValues<C, M>` where the code lists what `variant()` accepts.
## The SQL ORM client accepts only field names
Every name passed to the SQL ORM client must be the name of a field of the model, or of the variant the collection is narrowed to. A name that is not a field throws `ORM.FIELD_UNKNOWN` with the message `Model "<model>" has no field "<name>"`. A column name is not a field name: pass the field instead.
```diff
- await db.orm.Post.where({ user_id: 1 }).all();
+ await db.orm.Post.where({ userId: 1 }).all();
```
Code that catches the error a `conflictOn` name that is not a field raises (in a create with `onConflict: 'skip'`) matched `ORM.ARGUMENT_INVALID`. Match `ORM.FIELD_UNKNOWN` instead.
```diff
- if (error.code === 'ORM.ARGUMENT_INVALID') {
+ if (error.code === 'ORM.FIELD_UNKNOWN') {
```
Leave other checks of `ORM.ARGUMENT_INVALID` alone; only the `conflictOn` refusal changed code.
## `fragment` is called on the model's root collection
A fragment for one model, made with `collection.fragment(body)`, is built from the model alone. Calls chained before `.fragment(...)`, such as `where`, `orderBy`, `include`, `limit` or `forUpdate`, were ignored without a word. Now `fragment` throws `ORM.ARGUMENT_INVALID` on such a collection, and a `where`, an `orderBy` or an `include` before it is also a compile error. A receiver typed by a type parameter, such as `this` in a custom collection class method or `C` in a generic function, is a compile error too, because it may be a chained collection.
In the extension's code, chain on the collection directly instead of defining a fragment from it. Inside a class method, chain on `this`:
```diff
titles() {
- return this.with(this.fragment((posts) => posts.select('id', 'title')));
+ return this.select('id', 'title');
}
```
If the fragment is shared between places, define it once as a module-level constant on the model's root collection, and move the chained calls that were meant to be part of it into its body:
```diff
- const recentSummary = db.orm.public.Post.orderBy((p) => p.id.desc()).fragment((posts) =>
- posts.select('id', 'title'),
- );
+ const recentSummary = db.orm.public.Post.fragment((posts) =>
+ posts.orderBy((p) => p.id.desc()).select('id', 'title'),
+ );
```
The detection does not find a `.fragment(` call whose receiver is a variable or a parameter holding a chained collection, such as `const published = db.orm.public.Post.where(...)` followed by `published.fragment(...)`, or a generic receiver. Check those `.fragment(` calls by hand.
Skip a match on the fragment for any model, `fragment(fields, body)`, called on a client returned by a call, such as `orm(...).fragment(fields, body)`, which did not change, and a match in code that uses the MongoDB ORM client, which did not change either.
## `Fragment` is now `QueryFragment`, and `FieldFragment` is now `DeclaredFieldsFragment`
A function from a collection to a collection, run with `collection.with(fn)`, is a query fragment, and its type is now named `QueryFragment<In, Out>`. "Fragment" alone also covers a row fragment, a function of the model accessor that `where` and `orderBy` take, and it is the name of React's `Fragment`. What `db.orm.fragment(fields, body)` returns, a fragment declared over the fields it needs, is now typed `DeclaredFieldsFragment`. The types work as before.
These changes apply to the SQL ORM client only. The MongoDB ORM client did not change; skip matches in code that uses it.
Rename `Fragment` to `QueryFragment` and `FieldFragment` to `DeclaredFieldsFragment`. These are the names 8.0.0-rc.17 exports.
```diff
- import type { FieldFragment, Fragment } from '@prisma/orm-postgres/orm-client';
+ import type { DeclaredFieldsFragment, QueryFragment } from '@prisma/orm-postgres/orm-client';
- const newest: Fragment<PostCollection, Ordered<PostCollection>> = (posts) => posts.newestFirst();
+ const newest: QueryFragment<PostCollection, Ordered<PostCollection>> = (posts) => posts.newestFirst();
```
Code that still uses the 8.0.0-rc.16 names renames them to the new names directly: `Scope` to `QueryFragment`, `FieldScope` to `DeclaredFieldsFragment` and `ScopeFacts` to `FragmentFacts`.
Rename every use of these types in the extension's code and in the declarations it exports, including a re-export such as `export type { Fragment } from '@prisma/orm-postgres/orm-client'`, a qualified name such as `Orm.Fragment` after `import * as Orm from '@prisma/orm-postgres/orm-client'`, and an inline type such as `import('@prisma/orm-postgres/orm-client').Fragment`. After renaming, sort the named imports again, since `QueryFragment` sorts in a different place than `Fragment` or `Scope`, and wrap any line the longer names make too long for the formatter.
Do not rename React's `Fragment`, the SQL builder's `Scope` and `ScopeField` (imported from a `builder` entry or `@internal/sql-builder`), or any `Fragment` or `Scope` that is not imported from the ORM client. Do not rename `FragmentFacts` or `DeclaredField`, which keep their names.
## `unique-criterion-rejects-null`
`UniqueConstraintCriterion<TContract, ModelName>`, exported by the SQL ORM client (`@prisma/orm-postgres/orm-client`, the other facades' `orm-client` entries and `@internal/sql-orm-client`), is the object that names one row by its primary key or by one of its unique constraints. Each of its values is now the field's type without `null`. A `null` value cannot identify one row: a unique constraint allows any number of rows whose column is `NULL`.
The type is the argument of three calls, and each of them now refuses `null` and a value whose type includes `null`:
- `conflictOn` in `collection.upsert({ create, update, conflictOn })`
- `mutator.connect(criterion)` and `mutator.connect([criterion, ...])` in a relation callback of `create` or `update`
- `mutator.disconnect([criterion, ...])` in a relation callback of `update`
Only a model with a nullable unique column is affected. Given `Account.handle`, a nullable column with a unique constraint, these no longer compile:
```ts
await db.orm.Account.upsert({ create, update, conflictOn: { handle: null } });
await db.orm.Account.upsert({ create, update, conflictOn: { handle: maybeHandle } }); // maybeHandle: string | null
await db.orm.Owner.where({ id }).update({
accounts: (accounts) => accounts.connect({ handle: maybeHandle }),
});
```
The compiler reports `Type 'null' is not assignable to type 'string'` on the field.
The detection also matches code this change does not affect. Leave as they are: a `.connect(` or `.disconnect(` call on another object, such as `postgres.connect({ url })`; and the `conflictOn` of `createAll` and `createAndCount`, which is an array of field names, such as `conflictOn: ['handle']`.
What to write instead:
- Where the value is known to be present, narrow it before the call, so that its type has no `null`:
```ts
if (maybeHandle === null) {
throw new Error('an account needs a handle to be connected');
}
await db.orm.Owner.where({ id }).update({
accounts: (accounts) => accounts.connect({ handle: maybeHandle }),
});
```
- Where the value can be absent, decide what that case means and write it as its own branch. For `upsert`, create the row with `create(...)` in that branch, or use a constraint whose columns all have values, such as the primary key: `conflictOn: { id }`. For `connect` and `disconnect`, leave the criterion out of the list in that branch, or name the row by another key.
- To read or write the rows whose column is `NULL`, use `where({ handle: null })`. It can match more than one row.
- Where your own code declares a variable, a parameter or a return value as `UniqueConstraintCriterion<...>`, the same rule applies to the values assigned to it.
## `prepared-all-and-aggregate-need-their-receiver`
`all` and `aggregate` on a collection's `prepared` object now declare the object they are called on. This applies to every collection, not only one filtered with `whereUnique`. A call without that receiver no longer compiles:
```ts
const { all, aggregate } = db.orm.Post.prepared;
all(); // error: The 'this' context of type 'void' is not assignable to method's 'this'
aggregate((a) => ({ posts: a.count() })); // the same error
```
Call the method on the `prepared` object instead:
```ts
const prepared = db.orm.Post.prepared;
prepared.all();
prepared.aggregate((a) => ({ posts: a.count() }));
```
These still compile and need no change: a destructured `first` or `firstOrThrow`, as in `const { first, firstOrThrow } = db.orm.Post.prepared; first(); firstOrThrow()`, and `prepared.all` passed as a callback without being called. What the methods do at run time is unchanged.
## `build-native-enums-takes-the-domain`
Pass the contract's domain beside its storage:
```ts
// before
buildNamespacedNativeEnums(contract.storage);
// after
buildNamespacedNativeEnums(contract.storage, contract.domain);
```
The result no longer has an accessor for a domain enum (an `enum` block or `enumType()`), only for native enum types. The type `NamespacedNativeEnums<Contract>` leaves out the same names. Code that read a domain enum from the result reads it from the accessor `db.enums` uses instead.
## Middleware hooks are activity hooks
The runtime now runs every statement and every transaction as an **activity**. A middleware observes an activity through three lifecycle events, `activityStarted`, `activityEnded` and `activityFinalized`, and changes or stops a statement through interceptors: `beforeEncode`, `intercept.onQuery`, `intercept.onExecute` and `onRow`. The per-statement hooks are gone.
### Rename the hooks
| Before | After |
|---|---|
| `beforeQuery`, `beforeExecute` | `beforeEncode` |
| `interceptQuery`, `interceptExecute` | `intercept.onQuery`, `intercept.onExecute` |
| `onRow` | `onRow` |
| `afterQuery`, `afterExecute` | `activityEnded` on a statement |
| `afterTransaction` | `activityFinalized` on a statement |
| `beforeCompile` | `beforeCompile` |
`beforeEncode` keeps the arguments of `beforeQuery` and `beforeExecute`: the lowered plan, the context and the parameter mutator. It runs for both `query()` and `execute()`. A middleware that defined both `beforeQuery` and `beforeExecute` with the same body defines `beforeEncode` once. A middleware that must tell them apart reads `activity.returns` (`'rows'` or `'stats'`) in `activityStarted` and keeps it in `ctx.state`.
The interceptors move under one `intercept` object:
```diff
- async interceptQuery(plan, ctx) {
- return { rows: cached };
- },
+ intercept: {
+ async onQuery(plan, ctx) {
+ return { rows: cached };
+ },
+ },
```
### Read the result from `activityEnded`
`afterQuery(plan, result, ctx)` and `afterExecute(plan, result, ctx)` become one hook, `activityEnded(activity, result, ctx)`. It fires for transactions too, so check the kind first. `result.completed` is replaced by `result.outcome`, which is `'succeeded'`, `'failed'` or `'stopped'` (the caller stopped reading the rows). `latencyMs` and `source` are unchanged; `rowCount` or `stats` is there according to `result.returns`. The plan the caller sent is `activity.plan`; the lowered SQL is not on the activity, so a middleware that logs it keeps it from `beforeEncode` in `ctx.state`:
```diff
- async afterQuery(plan, result, ctx) {
- if (result.latencyMs > 250) ctx.log.warn({ sql: plan.sql, rowCount: result.rowCount });
- },
+ async beforeEncode(plan, ctx) {
+ ctx.state(statementSql)['sql'] = plan.sql;
+ },
+ async activityEnded(activity, result, ctx) {
+ if (result.kind !== 'statement' || result.latencyMs <= 250) return;
+ ctx.log.warn({
+ sql: ctx.state(statementSql)['sql'],
+ ...(result.returns === 'rows' ? { rowCount: result.rowCount } : {}),
+ });
+ },
```
`statementSql` is a module-level `Symbol('…')`, for example `const statementSql = Symbol('slow-query-warning.sql')`. An error thrown from `activityEnded` after a successful statement still fails it and reaches the caller, as an error from `afterQuery` did; every other middleware still receives `activityEnded`, and the caller receives the first error. A transaction also ends: `succeeded` when its `commit()` or `rollback()` resolved, `failed` when it rejected. So a rolled-back transaction ends `succeeded` and is finalized `rolled-back`. `activityEnded` also fires where `afterQuery` and `afterExecute` did not: for a statement the caller stopped reading, with `result.outcome` `'stopped'`, and for a statement that failed before it ran, in `beforeCompile`, `beforeEncode` or parameter encoding, with `result.outcome` `'failed'`, `latencyMs: 0` and `source: 'driver'`. A middleware that counts statements or latencies checks `result.outcome` if it should skip them.
### Replace `afterTransaction` with `activityFinalized`
`afterTransaction(plan, result, ctx)` becomes `activityFinalized(activity, result, ctx)`. A statement still running when its transaction ends is finalized when it ends, after its `activityEnded`, where `afterTransaction` fired at the transaction's end, before the statement's after-hook. `result.outcome` keeps its three values, `'committed'`, `'rolled-back'` and `'unknown'`, and the same rules decide it. An error thrown from it is still logged and swallowed. It also fires for transactions, after their statements, so a middleware that acts on statements checks `activity.kind === 'statement'`. A statement that fails before it runs, in `beforeCompile`, `beforeEncode` or parameter encoding, is now finalized too: `unknown` outside a transaction, and with the transaction's outcome inside one.
### Rename the context type and the execution id
`RuntimeMiddlewareContext` is now `ActivityContext`, and `SqlMiddlewareContext` and `MongoMiddlewareContext` extend it. A SQL middleware imports `SqlMiddleware` and `SqlMiddlewareContext` from `@prisma/orm-postgres/family-runtime` (or `@prisma/orm-sqlite/family-runtime`), a Mongo middleware imports `MongoMiddleware` and `MongoMiddlewareContext` from `@prisma/orm-mongo/family-runtime`, and a cross-family middleware imports `CrossFamilyMiddleware` and `ActivityContext` from `@prisma/orm-framework/components/runtime`. `ctx.planExecutionId` is removed; use `activity.id`, a fresh id for each activity that every hook of the activity sees. A log detail or field that carried `planExecutionId: ctx.planExecutionId` becomes `activityId: activity.id`. Keep per-statement state in `ctx.state(key)` instead of a `WeakMap` keyed by the plan: each activity has its own state, and middleware that use the same key share the object. `AfterQueryResult`, `AfterExecuteResult` and `AfterTransactionResult` are removed; use `ActivityEndedResult` and `ActivityFinalizedResult`.
### Abort phases and already-aborted calls
A `RUNTIME.ABORTED` error raised during the middleware stage before encoding has `details.phase` `'beforeEncode'`. Code that matches `'beforeQuery'` or `'beforeExecute'` matches `'beforeEncode'` instead. The same holds for the Mongo error `RUNTIME.CONTENT_HASH_REQUIRES_RESOLVED_COMMAND`, raised when a Mongo `beforeEncode` hook calls `ctx.contentHash`: its `details.phase` is `'beforeEncode'`. A call whose signal was aborted before it was made now fires `activityStarted`, then ends `failed`, before it rejects with `RUNTIME.ABORTED`.
### The observed-row budget now runs
`budgets()` now counts the rows each query returns. Before, this check never ran, so a query that returned more than `maxRows` rows succeeded. Now, with the default row severity `'error'` or in `strict` mode, such a query fails with `BUDGET.ROWS_EXCEEDED` once the count passes `maxRows`; with `severities: { rowCount: 'warn' }` in `permissive` mode it logs one warning for the statement and keeps streaming. The `postgres()` client runs its runtime in `strict` mode, where every `budgets()` violation throws whatever its severity, so the warn path does not apply there until the client exposes `mode`. Raise `maxRows` for reads that are expected to return more rows. `tableRows` and `defaultTableRows` only change the estimate checked before a read runs; they do not change the limit on rows a read actually returns.
### Move per-statement state from a `WeakMap` to `ctx.state`
A middleware that kept per-statement state in a `WeakMap` keyed by the plan keeps it in `ctx.state` instead. The state object's values are `unknown`, so read them back through a type guard, and delete the entry in `activityEnded` once it is used:
```diff
- const pending = new WeakMap<object, PendingMiss>();
+ const pendingMiss = Symbol('cache.pendingMiss');
+
+ function pendingOf(ctx: ActivityContext): PendingMiss | undefined {
+ const miss = ctx.state(pendingMiss)['miss'];
+ return isPendingMiss(miss) ? miss : undefined;
+ }
// in intercept.onQuery, on a miss
- pending.set(exec, { entry, buffer: [] });
+ ctx.state(pendingMiss)['miss'] = { entry, buffer: [] };
// in onRow
- pending.get(exec)?.buffer.push(row);
+ pendingOf(ctx)?.buffer.push(row);
// in activityEnded, which replaces afterQuery
- const miss = pending.get(exec);
- if (miss === undefined) return;
- pending.delete(exec);
- if (!result.completed || result.source !== 'driver') return;
+ const miss = pendingOf(ctx);
+ if (miss === undefined) return;
+ delete ctx.state(pendingMiss)['miss'];
+ if (result.kind !== 'statement' || result.outcome !== 'succeeded' || result.source !== 'driver') return;
```
`pendingMiss` and `pendingOf` are module-level; `isPendingMiss` is a type guard for the state's shape. The middleware object then lists `intercept: { onQuery }`, `onRow` and `activityEnded` in place of `interceptQuery`, `onRow` and `afterQuery`.
### Run statements through the statement runners
A family runtime no longer composes the middleware runners itself. It runs each statement through one call that starts the activity, runs a body, and ends and finalizes the activity:
```diff
- await runBeforeQueryChain(exec, this.middleware, ctx, mutator);
- yield* reportQueryEnding(onQueryEnd, () =>
- runQueryWithMiddleware(exec, this.middleware, ctx, () => this.runDriver(exec)),
- );
+ yield* streamStatement(
+ { middleware: this.middleware, plan, context: contextFields, parent: undefined },
+ async function* (run) {
+ const exec = await prepare(run); // calls run.beforeEncode(draft, mutator) before encoding
+ yield* run.runQuery(exec, () => self.runDriver(exec));
+ },
+ );
```
`executeStatement` is the same for `execute()`, with `run.runExecute(exec, runDriver)`. `context` is an `ActivityContextFields`: the runtime-wide fields (`contract`, `mode`, `now`, `log`, `contentHash`, `scope`, and `signal` when the caller passed one); the runner adds `parent`, `state` and `inTransaction`. `parent` is the open transaction a statement was sent on, or `undefined`. `RuntimeCoreOptions.ctx` takes the same fields, so remove `planExecutionId` from it.
A runtime that opens transactions calls `startTransactionActivity({ middleware, context, holdFinishedChildren })` before `BEGIN`, where `holdFinishedChildren` is decided once, when the runtime is created: `true` only when some middleware implements `activityFinalized`. It passes the result as the `parent` of every statement sent on the transaction or its connection while it is open, and calls `end({ by: 'commit' | 'rollback', outcome, ... })` when `commit()` or `rollback()` settles. A SQL runtime subclass that wrapped a driver transaction with `wrapTransaction(driverTx, connection)` calls `beginTransactionOn(connection, owner)` instead, which does all of this.
### Renamed log events
A test or log filter that matches the runtime's debug event `middleware.interceptQuery` or `middleware.interceptExecute` matches `middleware.intercept.onQuery` or `middleware.intercept.onExecute`. The error event `middleware.afterTransaction.error` is now `middleware.activityFinalized.error`.
## MongoDB carries out `--rename` and `--delete`
### `rename-statements-capability-removed`
Delete `renameStatements` from a target's `migrations` capability, and any `keepDataByHand` function it pointed at. A test double of the capability that set it drops the member. A data-loss question now offers `rename` for a model or field the destination no longer has on every target; write a planner that carries out the resolved statements it is given, or refuses each with a `statementRefused` conflict that carries it in `refusedStatement`.
### `mongo-ddl-rename-collection`
The DDL command alphabet gains a collection rename:
```ts
import { RenameCollectionCommand } from '@prisma/orm-family-mongo/query-ast/control';
new RenameCollectionCommand('profiles', 'users');
```
A `MongoDdlCommandVisitor<R>` implementation adds `renameCollection(command)`. A Mongo driver's `run(wireCommand)` handles `kind: 'renameCollection'`, as the shipped driver does with `db.renameCollection(collection, to)`, without `dropTarget`. An adapter's DDL lowering maps the command to `new RenameCollectionWireCommand(command.collection, command.to)`.
### `mongo-dml-operations`
Rename the types where code names them:
```diff
- import type { MongoDataTransformCheck, MongoDataTransformOperation } from '@prisma/orm-family-mongo/query-ast/control';
+ import type { MongoDmlCheck, MongoDmlOperation } from '@prisma/orm-family-mongo/query-ast/control';
```
Where code narrows `AnyMongoMigrationOperation` by `operation.operationClass === 'data'`, use the guard instead:
```diff
- if (operation.operationClass === 'data') {
+ if (isMongoDmlOperation(operation)) {
```
`isMongoDmlOperation` comes from `@prisma/orm-family-mongo/query-ast/control`. An operation without `run` is a DDL operation. The `dataTransform` factory keeps its name: it builds a DML operation with class `data`. A hand-written `dataTransform` still has class `data`, so a policy that leaves out `data` (as `db update`'s does) still refuses it, and runs `renameField` and `unsetField`.
### `mongo-update-many-bypass-validation`
A hand-built lowered draft of kind `rawUpdateMany` adds `bypassDocumentValidation: false`. A driver's `updateMany` passes `bypassDocumentValidation: true` to MongoDB when the wire command carries it.
### `mongo-op-factory-call-to-op`
Code typed against the result of `toOp()` or `renderOps(...)` as `MongoMigrationPlanOperation` widens it to `AnyMongoMigrationOperation`, and narrows with `isMongoDmlOperation` where it needs `execute` or `run`.
upgrading/extension/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/instructions.md
---
from: "8.0.0-rc.18"
to: "8.0.0-rc.19"
changes:
- id: client-takes-contract-value
summary: |
Every client and static entry takes the contract only as a value, `{ contract }`. The `contractJson` option is removed, so `postgres<Contract>({ contractJson, url })` no longer compiles, and the client infers its contract type from the value, so the call needs no type argument. Turn the emitted JSON into a typed value once with `contractFromJson<Contract>(contractJson)` from the facade's `/static` entry (`@prisma/orm-postgres/static`, `@prisma/orm-sqlite/static`, `@prisma/orm-mongo/static`; Supabase uses the Postgres one), export it from the module that creates the client, and pass `{ contract }`. This applies to `postgres()`, `postgresServerless()`, `sqlite()`, `supabase()`, `mongo()`, `postgresStatic()`, `sqliteStatic()`, `mongoStatic()` and `mongoQuery()`. Extension packages that build a client, static entry or `mongoQuery` in their code, examples or tests move the same way. The colocated script moves source files; move test files by hand.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '(?<![\w$.])(?:postgres|postgresServerless|sqlite|supabase|mongo|postgresStatic|sqliteStatic|mongoStatic|mongoQuery)(?:<[^<>()]*(?:<[^<>()]*>[^<>()]*)*>)?\(\s*\{(?:(?:[^{}]|\{[^{}]*\})*?,)?\s*contractJson\s*[,}:]'
- '(?<![\s\S])(?:(?!import\s+[\w$]+\s*(?:,\s*\{[^{}]*\}\s*)?from\s*[''"]@prisma/orm-(?:(?:postgres|sqlite|mongo)/(?:runtime|serverless|static|query-builder)|extension-supabase/runtime)[''"])[\s\S])*import\s+([\w$]+)\s*(?:,\s*\{[^{}]*\}\s*)?from\s*[''"]@prisma/orm-(?:(?:postgres|sqlite|mongo)/(?:runtime|serverless|static|query-builder)|extension-supabase/runtime)[''"][\s\S]*(?<![\w$.])\1(?:<[^<>()]*(?:<[^<>()]*>[^<>()]*)*>)?\(\s*\{(?:(?:[^{}]|\{[^{}]*\})*?,)?\s*contractJson\s*[,}:]'
- '(?<![\s\S])(?:(?!import\s*(?:[\w$]+\s*,\s*)?\{[^{}]*\b(?:default|supabase|mongoQuery|postgresStatic|sqliteStatic|mongoStatic)\s+as\s+[\w$]+[^{}]*\}\s*from\s*[''"]@prisma/orm-(?:(?:postgres|sqlite|mongo)/(?:runtime|serverless|static|query-builder)|extension-supabase/runtime)[''"])[\s\S])*import\s*(?:[\w$]+\s*,\s*)?\{[^{}]*\b(?:default|supabase|mongoQuery|postgresStatic|sqliteStatic|mongoStatic)\s+as\s+([\w$]+)[^{}]*\}\s*from\s*[''"]@prisma/orm-(?:(?:postgres|sqlite|mongo)/(?:runtime|serverless|static|query-builder)|extension-supabase/runtime)[''"][\s\S]*(?<![\w$.])\1(?:<[^<>()]*(?:<[^<>()]*>[^<>()]*)*>)?\(\s*\{(?:(?:[^{}]|\{[^{}]*\})*?,)?\s*contractJson\s*[,}:]'
script: ./scripts/client-takes-contract-value/contract-from-json.ts
- id: collections-on-the-client
summary: |
Not a breaking change. Every SQL client takes your custom collection classes as an option, `collections`, and uses them on `db.orm`, on the ORM of every transaction (`tx.orm`), on every connection a serverless client opens, and on every Supabase role-bound client: `postgres({ contract, url, collections: { Post: PostCollection } })`, then `db.orm.public.Post.published()`. Extension code, examples and tests that build their own ORM client with `orm({ runtime, context, collections })` to reach custom classes still work; replace them with the option and use the client's ORM. The detection matches a call to `orm({ ... })`, or to `orm` imported under another name, that passes `collections`.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '(?<![\w$.])orm\s*(?:<[^<>()]*(?:<[^<>()]*>[^<>()]*)*>)?\(\s*\{(?:(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*?,)?\s*collections\s*[,}:]'
- '(?<![\s\S])(?:(?!import\s*\{[^{}]*\borm\s+as\s+[\w$]+[^{}]*\}\s*from\s*[''\"][^''\"]+[''\"])[\s\S])*import\s*\{[^{}]*\borm\s+as\s+([\w$]+)[^{}]*\}\s*from\s*[''\"][^''\"]+[''\"][\s\S]*(?<![\w$.])\1\s*(?:<[^<>()]*(?:<[^<>()]*>[^<>()]*)*>)?\(\s*\{(?:(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*?,)?\s*collections\s*[,}:]'
- id: client-query-and-to-sql
summary: |
Additive for code that only uses these types. Every SQL client gets `query(plan)` and `execute(plan)`, with the types a transaction's have, and `toSql(plan)`, which returns `{ sql, params }` with no database. A serverless connection gets `query` and `execute` too; the serverless client, `postgresStatic()`, `sqliteStatic()` and every transaction get `toSql`. `postgres()`, `postgresServerless()`, `sqlite()` and `supabase()` take `mode: 'strict' | 'permissive'`. `supabase()` creates its pool on the first query instead of when it is called. Extension code, examples and tests can move from `db.runtime().query(plan)` to `db.query(plan)`; code that builds its own object of one of the changed types, such as a test double, must add the new members. The detection matches a file that imports one of the changed types from a Prisma package, calls `runtime().query(` or `runtime().execute(`, or reads the renamed error details or the old message texts.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- 'import\s*(?:type\s*)?(?:[\w$]+\s*,\s*)?\{[^}]*(?<![\w$])(?:PostgresClient|PostgresServerlessClient|PostgresServerlessConnection|PostgresStaticContext|PostgresStaticMembers|SqliteClient|SqliteStaticContext|RoleBoundDb|ServiceRoleDb|SupabaseInternalDb|PostgresTransactionContext|SqliteTransactionContext)(?![\w$])[^}]*\}\s*from\s*[''"]@prisma/orm-'
- '\.runtime\(\)\s*\.(?:query|execute)\('
- '(?<![\w$])(?:runtimeTarget|runtimeStorageHash)\s*:|\.(?:runtimeTarget|runtimeStorageHash)\b|[''"](?:runtimeTarget|runtimeStorageHash)[''"]'
- 'Ad-hoc execute received a bind-site slot|Plan target does not match runtime target|Plan storage hash does not match runtime contract'
- id: transaction-query-members
summary: |
Additive for code that only uses these types: a transaction now offers the client's query members (`sql`, `orm`, `raw`, `prepare`, `query`, `execute`) on every SQL client, a Supabase role-bound client gains `prepare`, and SQLite's `db.orm` gains `fragment`. Code that builds its own object of one of the changed types must add the new members. Changed: `PostgresTransactionContext` (adds `raw` and `prepare`), `SqliteTransactionContext` (adds `raw` and `prepare`; `orm` is now `SqliteOrm`), `RoleBoundDb` (adds `prepare`; `transaction` passes a `SupabaseTransactionContext`). New: `PostgresQueryMembers` and `SqliteQueryMembers` (the query members a client and its transactions share), `SupabaseTransactionContext`, `SqliteOrm`, and from the ORM client `OrmClientRootMembers` and `PrepareMember`. The detection matches a file that imports one of the changed types that 8.0.0-rc.18 had, or `SqliteClient`, whose `orm` changed, from a Prisma package.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- 'import\s*(?:type\s*)?(?:[\w$]+\s*,\s*)?\{[^}]*(?<![\w$])(?:PostgresTransactionContext|SqliteTransactionContext|SqliteClient|RoleBoundDb)(?![\w$])[^}]*\}\s*from\s*[''"]@prisma/orm-'
- id: runtime-activity-is-activity
summary: |
`RuntimeActivity` is renamed `Activity`, with no alias. It is the type of the `activity` parameter of `activityStarted`, `activityEnded` and `activityFinalized`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bRuntimeActivity\b'
- id: activity-includes-client-activities
summary: |
`Activity` is `StatementActivity | TransactionActivity | ClientActivity`, and `ActivityEndedResult` includes `ClientActivityEnded`, in every program. A client activity's kind contains a hyphen, such as `orm-call`. A hook that narrows by ruling out `'transaction'` and then reads statement fields no longer compiles. Narrow by the kind you handle, such as `if (result.kind !== 'statement') return;`, and ignore kinds you do not know.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bif\s*\(\s*[\w$.]+\.kind\s*===\s*[''"]transaction[''"]\s*\)\s*(?:\{\s*)?(?:return|continue)\b'
- id: start-a-client-activity
summary: |
An extension can start an activity of its own kind with `startActivityOn(queryable, { kind, contentHash, annotations? })`, where `contentHash` is a function returning a promise of the digest, for example `() => planContentHashOn(queryable, plan)`; middleware read it with `activity.contentHash()`, which computes it once per activity and only when asked. The extension sends statements through the returned handle and must end every handle with `end({ outcome, result? })`, in a `finally`; an open handle keeps `close()` waiting. A middleware can supply the activity's result through `intercept.onActivity(activity, ctx)`. Registering the kind in `ClientActivityRegistry` types the handle's `intercepted.result` and `end({ result })`; in middleware hooks the result stays `unknown`.
- id: runtime-connections-carry-an-activity-source
summary: |
The SQL runtime's queryables start client activities through an `activitySource` member, keyed by a symbol so the public queryable types do not show it, which `startActivityOn` and `planContentHashOn` read. A connection-like object that a `SqlRuntimeBase` subclass builds over a raw connection adds `[activitySource]: this.connectionActivitySource(connection, () => session)`, and `ActivitySourceTarget` to the object's declared type, so activities can start on it. The public `RuntimeConnection` type does not require it.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\s\S])(?![\s\S]*\[activitySource\])[\s\S]*\[transactionStart\]\s*:'
- id: begin-transaction-on-takes-a-parent
summary: |
`SqlRuntimeBase.beginTransactionOn(connection, owner)` takes a third argument, `parent`: the activity the transaction starts beneath, or `undefined`. Pass `undefined` where you began a transaction before.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bbeginTransactionOn\s*\(\s*[^,()]+,\s*[^,()]+\)'
- id: transaction-activity-takes-a-parent
summary: |
`startTransactionActivity` requires `parent` (the activity the transaction starts beneath, or `undefined`). An `ActivityParent` has `acceptsChildren()`, and its `adopt(child)` returns an `ActivityChildSlot` (`statementFailed()`, `ended(own)`, `transactionAcceptsChildren()`) instead of an end callback; `ActivityParent.activity` is an `ActivityBase`. A transaction stops accepting children with `stopAcceptingChildren()`, and adopting beneath a parent that no longer accepts children throws `RUNTIME.ACTIVITY_NOT_ACCEPTING_CHILDREN` instead of an internal error.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bstartTransactionActivity\s*\(\s*\{(?![^{}]*\bparent\b)'
- '(?<![\s\S])(?![\s\S]*\bActivityChildSlot\b)(?=[\s\S]*\bActivityParent\b)[\s\S]*\badopt\s*\('
- id: orm-runtime-objects-carry-an-activity-source
summary: |
The ORM reaches activities only through `startActivityOn` on the queryable it holds, and every ORM terminal method on a queryable without an `activitySource` member fails with `ORM.ACTIVITY_SOURCE_MISSING` (see `queryable-carries-the-activity-source`). `db.runtime()` passed straight to `orm()` carries one. A facade, such as `postgres()`, that builds its own ORM runtime object, such as one that looks up its runtime on every call, adds `[activitySource]: delegatingActivitySource(() => runtime)` from `@prisma/orm-family-sql/runtime`, for the runtime and for the context of `db.transaction(fn)`. Where the facade's statements run through sessions of its own, such as role sessions, give an activity source whose statements go the same way.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\s\S])(?![\s\S]*\[activitySource\])[\s\S]*(?<![\w$.])orm\s*(?:<[^>()]*>)?\(\s*\{(?:[^{}]|\{[^{}]*\})*?\bruntime\s*:\s*\{'
- id: orm-annotations-on-the-call
summary: |
Every SQL ORM terminal method runs as one `orm-call` client activity, and the annotations a `configure` callback records live on that activity. The statements the call sends carry only the reserved keys the ORM sets, so a middleware that reads an ORM annotation from a statement plan (`handle.read(plan)` or `plan.meta.annotations`) finds nothing. Read it from the call: in `activityStarted(activity)` or `intercept.onActivity(activity)`, `if (activity.kind === 'orm-call') handle.read(activity)`. A middleware can answer the call through `intercept.onActivity`; the value must have the shape the terminal returns, or the ORM rejects with `ORM.INTERCEPTED_RESULT_INVALID`. The detection matches a file with a middleware hook, not a MongoDB one, that reads an annotation from something other than the activity, or reads `meta.annotations`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\s\S])(?![\s\S]*(?:familyId:\s*[''"]mongo[''"]|\bMongoMiddleware\b))(?=[\s\S]*\b(?:beforeEncode|beforeCompile|onQuery|onExecute|onRow|activityStarted)\b)[\s\S]*\.read\(\s*(?!activity\b)[\w$]+(?:\.[\w$]+)*\s*\)'
- '(?<![\s\S])(?![\s\S]*(?:familyId:\s*[''"]mongo[''"]|\bMongoMiddleware\b))(?=[\s\S]*\b(?:beforeEncode|beforeCompile|onQuery|onExecute|onRow|activityStarted)\b)[\s\S]*\bmeta\.annotations\b'
- id: cache-store-holds-cached-values
summary: |
A cache store for `@internal/middleware-cache` holds `CachedValue`, `{ kind: 'statement-rows', rows } | { kind: 'call-result', result }`, not `CachedRows`; `CacheStore` and `CacheEntry` default their value type to `CachedValue`, and `createCacheMiddleware` accepts only a `CacheStore<TMeta, CachedValue>`. Type a custom store with `CachedValue`, and make `get` give back a value equivalent to the one `set` was given, `kind` included. An ORM call's result holds decoded values such as `Date`, `bigint`, Temporal values and extension class instances, so a store that serializes needs a serializer that rebuilds them; JSON does not. `createInMemoryCacheStore` takes only the meta type now and always holds `CachedValue`: drop its second type argument. The detection matches a file that names `CachedRows`, or imports from the cache package and names `CacheStore` or `CacheEntry` but not `CachedValue`, or passes `createInMemoryCacheStore` a second type argument.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bCachedRows\b'
- 'createInMemoryCacheStore<[^>(]*,'
- '(?<![\s\S])(?![\s\S]*\bCachedValue\b)(?=[\s\S]*[''"](?:@prisma/orm-extension-middleware-cache|@internal/middleware-cache)[''"])[\s\S]*(?<![\w$])(?:CacheStore|CacheEntry)(?![\w$])'
- id: queryable-carries-the-activity-source
summary: |
The SQL ORM starts its `orm-call` activity on the queryable it was given, through the queryable's `activitySource` member, and sends every statement through the activity's handle. A wrapper that copies a runtime, connection or transaction to intercept its `query` and `execute` (for example to record plans) also copies the `[activitySource]` member, so ORM statements bypass the wrapper. Wrap the activity source too: return a source whose `startActivity` wraps each handle the same way, including the transaction its `transaction()` returns. A custom queryable passed to the ORM must carry an activity source: without one, every terminal method fails with `ORM.ACTIVITY_SOURCE_MISSING` (streaming terminals throw when called, the others reject). A test stub can use `delegatingActivitySource(() => realRuntime)`, or a source whose handles send their statements to the stub.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\.\.\.\s*[\w$.]+(?:\(\))?\s*,(?:[^{};\[\]]|\{(?:[^{}]|\{[^{}]*\})*\})*?(?<![\w$.])(?:async\s+)?(?:query|execute)\s*(?:[:(<])'
- id: aggregate-operation-using-reserved
summary: |
Every ORM collection has a `using` member, so `orm()` refuses a contributed aggregate operation named `using` with `ORM.AGGREGATE_OPERATION_RESERVED`, as it refuses `with` and `fragment`. An extension that contributes an aggregate operation named `using` renames it. The detection matches a file that names an aggregate operation `using`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\boperation\s*:\s*[''"`]using[''"`]'
- id: codecs-read-wire-and-data-type-values
summary: |
A codec implements `toWire`, `fromWire`, `toDataTypeValue` and `fromDataTypeValue` instead of `encode`, `decode`, `encodeJson` and `decodeJson`. Its constructor names its data type and parameters (`super(descriptor, dataType, params)`), and it builds the value it hands over with `this.dataTypeValueOf(json)`. A Mongo codec made with `mongoCodec()` takes the same four options in place of `encode`, `decode`, `encodeJson` and `decodeJson`, and a required `dataType`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\b(?:encodeJson|decodeJson)\s*\('
- '\basync\s+(?:encode|decode)\s*\('
- '\b(?:encode|decode)\s*:\s*(?:async\s*)?\('
- '\b(?:encodeJson|decodeJson)\s*:'
- id: data-types-own-parameter-limits
summary: |
Every data type declares a `read` function, which reads and checks each stored value under the column's parameters. It replaces the `toCanonicalForm` hook of a data type or codec descriptor, and limits a parameter sets, such as a vector's length or a geometry's SRID, move from the codec to the data type. The detection matches `toCanonicalForm`, and a `sqlDataType`, `mongoDataType` or `dataType` call whose options have no `read` or are passed as a variable.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\btoCanonicalForm\b'
- '(?<![\w$.])(?:sqlDataType|mongoDataType|dataType)\(\s*[^,()]+,\s*\{(?!(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\bread\b)'
- '(?<![\w$.])(?:sqlDataType|mongoDataType|dataType)\(\s*[^,()]+,\s*(?!\{)[\w$]+\s*\)'
- id: json-helpers-renamed
summary: |
The JSON helpers in `@prisma/orm-framework/components/codec` are renamed from `decodeJson*` to `readJson*` (`readJsonString`, `readJsonMatching`, `readJsonInteger`, `readJsonIntegerText`, `readJsonFloat`, `readJsonBoolean`), and `encodeJsonFloat` to `floatToJson`. `ToCanonicalForm` and `canonicalFormOf` are removed.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\b(?:decodeJson(?:String|Matching|Integer|IntegerText|Float|Boolean)|encodeJsonFloat|canonicalFormOf|ToCanonicalForm)\b'
- id: stored-form-helpers-renamed
summary: |
The SQL helpers that read or write a value's stored text say "stored" instead of "canonical": `canonicalDateTime` is `dateTimeStoredText`, `CanonicalDateTimeOptions` is `DateTimeStoredTextOptions`, `canonicalNumeralText` is `storedNumeralText`, `sqlTextFromCanonical` is `sqlTextFromStored`, and `canonicalSqlText` is `storedSqlText`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\b(?:canonicalDateTime|CanonicalDateTimeOptions|canonicalNumeralText|sqlTextFromCanonical|canonicalSqlText)\b'
- id: projections-read-by-from-wire
summary: |
An include and an aggregate read a column's JSON projection with the codec's `fromWire`, as a row is read, so a descriptor's `jsonProjection` writes the value the driver returns for a row, often `CAST(x AS text)`, instead of the codec's JSON form.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bjsonProjection\s*\('
- id: empty-result-json-renamed-empty-result-wire
summary: |
An aggregate descriptor's `emptyResultJson` is renamed `emptyResultWire`, and holds the value the driver would return, which the result codec reads with `fromWire`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bemptyResultJson\b'
- id: target-codec-adapters-take-a-factory
summary: |
`postgresCodec` (`@prisma/orm-postgres/target/codec-descriptor`) and `sqliteCodec` (`@prisma/orm-sqlite/target/codec-descriptor`) require a `factory` option, and it receives the option's `dataType` between the descriptor and the column's parameters: `factory: (descriptor, dataType, params) => () => new MyCodec(descriptor, dataType, params)`. Before, `factory` was optional and took `(descriptor, params)`, and a call without it built the family template's codec. A family template names no data type, so it builds no codec of its own. `sqliteCodec` no longer takes `renderTypes`. The detection matches a call without `factory`, a `factory` whose second parameter is not the data type, and `renderTypes: false`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\b(?:postgresCodec|sqliteCodec)\(\s*[\w$.]+\s*,\s*\{(?!(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\bfactory\b)'
- '\b(?:postgresCodec|sqliteCodec)\(\s*[\w$.]+\s*,\s*\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*?\bfactory\s*:\s*(?:\(\s*(?:[\w$]+\s*(?::\s*[^,()]+)?)?\)|\(\s*[\w$]+\s*(?::\s*[^,()]+)?,(?!\s*_?dataType\b)\s*[^,()]+\)|(?!\()[\w$]+)\s*=>'
- '\brenderTypes\s*:\s*false\b'
- id: family-codec-templates-render-no-literals
summary: |
`postgresCodec` and `sqliteCodec` now throw an `InternalError` when the family codec template they adapt declares `renderValueLiteral`. Earlier versions copied the template's renderer. A family template renders no literal, because only the target knows the form its data type stores a value in. Remove `renderValueLiteral` from the template and pass it in the adapter's options instead; the framework exports `renderTsLiteral`, `floatNumberLiteral`, `decimalTextNumberLiteral` and `decimalTextBigintLiteral` from `@internal/framework-components/codec` for the common stored forms.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\s\S])(?=[\s\S]*\bextends\s+CodecDescriptorTemplateImpl\b)(?=[\s\S]*\brenderValueLiteral\s*\()'
- id: enum-block-written-values
summary: |
A parsed PSL extension block now records each literal entry as it is written in `writtenValues`, a `WrittenScalar` by key, in place of `numberTexts`, which held only the source text of number entries. `AuthoringEntityContext.readWrittenNumber` is replaced by `readWrittenValueForCodec`, which takes a `WrittenScalar` as `written` in place of the number's `text`, and reads any written value, not only a number, by the cast rule. A refused `WrittenValueReading` now carries the diagnostic `code` with its `message`. `enumMemberValue()` yields a tagged member as its `WrittenScalar` in place of `null`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\b(?:numberTexts|readWrittenNumber|WrittenValueReading)\b'
- id: type-lookups-one-framework-type
summary: |
`SqlTypeLookups` (from `@prisma/orm-family-sql/contract/data-type`) and `MongoTypeLookups` (from `@prisma/orm-family-mongo/contract/data-type`) are removed. Use `TypeLookups` from `@prisma/orm-framework/components/codec`, the same pair of `codecLookup` and `dataTypeLookup`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\b(?:SqlTypeLookups|MongoTypeLookups)\b'
- id: type-name-normalisers-removed
summary: |
`normalizeSchemaNativeType` (from `@prisma/orm-postgres/target/native-type-normalizer` and `@prisma/orm-postgres/adapter/control`) and `normalizeSqliteNativeType` (from `@prisma/orm-sqlite/adapter/control`) are removed. Columns are compared by their data type and its parameters, so no type name needs normalising.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\b(?:normalizeSchemaNativeType|normalizeSqliteNativeType)\b'
- id: introspected-type-text
summary: |
In `@prisma/orm-postgres/target/native-type-normalizer`, `introspectedNativeType` is renamed `introspectedTypeText` and `IntrospectedNativeType` is renamed `IntrospectedTypeText`; its result holds `typeText` (the type the database reports) and `many`, and no `resolvedNativeType`. `CatalogColumnType.dataType` is renamed `informationSchemaType`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\b(?:introspectedNativeType|IntrospectedNativeType|CatalogColumnType)\b'
- id: schema-ir-column-type-text
summary: |
`SqlColumnIR` and `SqlColumnIRInput` (`@prisma/orm-family-sql/schema-ir/types`): `nativeType` is renamed `typeText`, and `resolvedNativeType` and `codecBaseNativeType` are removed. `dataType` is removed: a column's type is its `storedType` and `storedTypeParams`, and nothing compares `typeText`. A column built by hand compares equal to another only when it has `storedType` and `storedTypeParams`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\b(?:resolvedNativeType|codecBaseNativeType)\b'
- '(?<![\s\S])(?=[\s\S]*(?:\bSqlColumnIR|\bSqlTableIR|\bSqlSchemaIR|schema-ir))[\s\S]*\bnativeType\b'
- id: default-resolver-takes-the-column
summary: |
A `DefaultResolver` (`@prisma/orm-family-sql/family/control`) receives the column, `{ typeText, many }`, as its second argument instead of the resolved type name. `parsePostgresDefault(raw, nativeType?)` (`@prisma/orm-postgres/target/default-normalizer`, `@prisma/orm-postgres/adapter/control`) is replaced by `readPostgresDefault(raw, many)` from `@prisma/orm-postgres/target/default-normalizer`, which takes whether the column is a list and returns a `PostgresReportedDefault`, `{ kind: 'function', expression } | { kind: 'null' } | { kind: 'literal', literal }`, instead of a `ColumnDefault` or `undefined`. `parseSqliteDefault(raw, nativeType?)` (`@prisma/orm-sqlite/target/default-normalizer`, `@prisma/orm-sqlite/adapter/control`) is replaced the same way by `readSqliteDefault(raw, declaredType)`, which returns a `SqliteReportedDefault`. The optional `normalizeDefault` member of `SqlControlAdapter`, which nothing called, and its type `DefaultNormalizer` are removed.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\b(?:parsePostgresDefault|parseSqliteDefault|DefaultNormalizer|normalizeDefault)\b'
- '(?<![\s\S])(?![\s\S]*\.(?:many|typeText)\b)[\s\S]*\bDefaultResolver\b'
- id: alter-type-postcheck-by-data-type
summary: |
In `@prisma/orm-postgres/target/planner-sql-checks`, `buildExpectedFormatType` and `alterTypeCheckText` are removed. `alterTypeCheck(type, dataTypes, where)` returns how the `ALTER COLUMN TYPE` postcheck recognises the new type, a `ColumnTypeCheck`: `{ kind: 'catalog-text', text }`, the text `format_type` prints, or for an enum `{ kind: 'identity', typeName, many }`, checked with `to_regtype` whatever schema the enum is in. The `checkType` field of `AlterColumnTypeCall` (`@prisma/orm-postgres/target/op-factory-call`) is now a `ColumnTypeCheck` instead of a string. `AddColumnCall`, `AddRequiredColumnToEmptyTableCall` and `AddColumnWithTemporaryDefaultCall` gain a `checkType` of `ColumnTypeCheck | undefined`, `undefined` when no data type of the stack reads the column's type.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\b(?:buildExpectedFormatType|alterTypeCheckText)\b'
- id: control-adapters-take-declared-data-types
summary: |
`PostgresControlAdapter` (`@prisma/orm-postgres/adapter/control`) and `SqliteControlAdapter` (`@prisma/orm-sqlite/adapter/control`) take the stack's declared data types as a new last constructor argument, because introspection resolves each column's reported type over them. Pass the target's `postgresDataTypes` or `sqliteDataTypes` (from `/target/data-types`), or, when the stack has extensions, the stack's types: `stack.declaredDataTypes.map(({ type }) => type)`. Code that leaves it out fails to compile, and untyped JavaScript fails at the first introspection.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs}"
matches:
- '\bnew\s+(?:Postgres|Sqlite)ControlAdapter\s*\('
- id: resolved-sql-type-carries-the-data-type
summary: |
`resolveReportedSqlType` and `resolveWrittenSqlTypes` (`@prisma/orm-family-sql/contract/data-type`) return the data type they found in `dataType`, a `SqlDataType`, instead of its id. Read `resolved.dataType.id` where you read `resolved.dataType`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\s\S])(?=[\s\S]*\bresolve(?:ReportedSqlType|WrittenSqlTypes)\b)[\s\S]*\.dataType\b(?!\s*\.)'
- id: value-arguments-name-a-data-type
summary: |
A type constructor's argument that is a value is declared `{ kind: 'value', dataType }`, naming the data type the argument's value belongs to, such as `pg/int4` for a Postgres length, precision, scale or SRID. The argument kinds `string`, `boolean` and `stringArray` are removed, and a type constructor's `number` argument becomes a `value` argument. Drop `integer`, `minimum` and `maximum`: the data type's parameter schema checks the value. Assembly refuses a type constructor argument of any other kind, `option` and `object` included. A field preset keeps its `number`, `option` and `object` arguments, so a match inside a field preset needs no change.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'kind:\s*[''"](?:number|string|boolean|stringArray)[''"](?=[^}]*\b(?:name|integer|minimum|maximum|optional)\s*:)'
- '\b(?:name|integer|minimum|maximum|optional)\s*:[^{}]*\bkind:\s*[''"](?:number|string|boolean|stringArray)[''"]'
- '\{\s*kind:\s*[''"](?:number|string|boolean|stringArray)[''"]\s*\}'
- id: value-argument-types-in-typescript
summary: |
The TypeScript builder types a `value` argument through `__dataTypeArguments`, a map a pack declares in its type from each data type it registers to `{ argument, parameter }`: the TypeScript value an author passes, and the type parameter it gives. A pack that registers a data type its own type constructors take must declare it, or the argument types as `never`. The TypeScript builder reads value arguments only with the data types and authoring entries the composed packs register, so every data type an argument takes must be registered, with a writable `authoring.dataTypes` entry, by some pack in the stack: the family, the target or an extension. An extension whose arguments take a target's data type, such as `pg/int4`, registers nothing; registering it again is refused as a duplicate.
- id: psl-and-print-helpers-take-data-types
summary: |
`mapPslHelperArgs` and `instantiatePslFieldPreset` take the stack's `dataTypes`, and `parsePslPositionalArgs` is removed. The new `writtenTypeArguments` writes a call's arguments as literals of their data types. `sql.String` is no longer in the SQL family pack: a SQL target contributes it with `sqlTargetTypeConstructors({ sizeType })` from `family/pack`, and `temporalCodecPresetWithPrecision` takes `sizeType` too. `temporalCodecPresetWithPrecision` and `temporalStringAuthoringPresets` move from `family/control` to `family/pack`, so a target pack does not load the family's control entry point. `assembleAuthoringDataTypes` moves from `components/control` to `components/authoring`, beside `assembleDataTypeSupport`, which builds a `DataTypeSupport` from a list of components and checks their value arguments.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bparsePslPositionalArgs\b|\b(?:mapPslHelperArgs|instantiatePslFieldPreset)\(\s*\{(?![^{}]*\bdataTypes\b)'
- 'import\s*(?:type\s*)?\{[^}]*\b(?:assembleAuthoringDataTypes|temporalCodecPresetWithPrecision|temporalStringAuthoringPresets)\b[^}]*\}\s*from\s*[''"][^''"]*/(?:components|family|family-sql)/control[''"]'
- id: composed-helpers-take-no-data-type-lookup
summary: |
`createComposedAuthoringHelpers` no longer takes `dataTypeLookup`. It builds its data types from the family, target and extension packs it is given, with `assembleDataTypeSupport`. Remove the option. Every data type an argument takes must be registered, with an `authoring.dataTypes` entry, by some pack in the stack; an extension whose arguments take a target's data type, such as `pg/int4`, registers nothing.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'createComposedAuthoringHelpers\(\{[^}]*\bdataTypeLookup\b'
- id: helper-codec-invariants
summary: |
`enforceDataTypeInvariants` is renamed `enforceHelperCodecInvariants`, and `DataTypeInvariantInput` is renamed `HelperCodecInvariantInput`. It now checks only type constructors and field presets against the stack's codecs, and takes `lookup`, `helpers` (formerly `constructors`) and `codecDescriptorFor`. `declaredTypes`, `codecs` and `authoringEntries` are removed: `assembleDataTypeSupport` checks data types, their entries, casts and written forms, through `enforceDataTypeAssemblyInvariants`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'enforceDataTypeInvariants|DataTypeInvariantInput'
- id: default-functions-lower-to-database-defaults-only
summary: |
A default function in `controlMutationDefaults.defaultFunctionRegistry` can no longer lower to a client generator: `LoweredDefaultValue` has only its `{ kind: 'storage' }` arm. A function that generated a value in the client, such as `slugid()`, becomes a generator function: contribute a `GeneratorFunctionContribution` in `generatorFunctions` on the pack ref and the control descriptor, with a binding for each codec it fills, and keep the generator descriptor. Users then write `@onCreate(slugid())` instead of `@default(slugid())`. The detection matches files that lower a default function to a generator.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- "kind:\\s*'execution'"
- id: applicable-codec-ids-and-build-phases-removed
summary: |
`MutationDefaultGeneratorDescriptor.applicableCodecIds` and `buildPhases` are removed. A binding of a generator function declares the codecs it fills and the arguments `contract print` writes for each generator it produces. Delete the removed fields. The detection matches files that set them.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'applicableCodecIds\s*:'
- 'buildPhases\s*:'
- id: sql-family-registers-identifier-generators
summary: |
The SQL family now registers the identifier generators `uuidv4`, `uuidv7`, `cuid2`, `ulid`, `nanoid` and `ksuid`. A target, adapter or extension that registers one of them in `generatorDescriptors` must stop: two components that register one generator fail with `CONTRACT.PACK_CONTRIBUTION_INVALID`. An extension that binds `uuid` or another identifier function to its own codec keeps the binding and drops the registration. `builtinGeneratorRegistryMetadata` from `@prisma/orm-framework/ids` is removed; delete its use. The detection matches files that register the built-in identifier generators.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'builtinGeneratorRegistryMetadata'
- "\\{\\s*id:\\s*['\"](?:uuidv4|uuidv7|cuid2|ulid|nanoid|ksuid)['\"]\\s*\\}"
- id: sql-target-declares-integer-argument-generators
summary: |
A SQL target declares the generator functions its columns take, in `generatorFunctions` on both its pack ref and its control descriptor, which 8.0.0-rc.18 did not have. The arguments of `uuid`, `cuid` and `nanoid` are values of the target's integer data type, which only the target knows, so the target spreads `sqlIntegerArgumentGeneratorFunctions({ integer })` from `@prisma/orm-family-sql/family/pack`, and binds the identifier functions to its text codecs with `idGeneratorFunctionBindings`. Without the first, composing a stack fails with `CONTRACT.PACK_CONTRIBUTION_INVALID`: `"sql" binds generator function "uuid", which no component declares.` The detection matches a SQL target descriptor that does not name `sqlIntegerArgumentGeneratorFunctions`; check the file that builds the target's `generatorFunctions`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\s\S])(?![\s\S]*\bsqlIntegerArgumentGeneratorFunctions\b)(?=[\s\S]*\bfamilyId:\s*[''"]sql[''"])[\s\S]*\bkind:\s*[''"]target[''"]'
- id: generator-signature-from-data-types
summary: |
The `signature` of a default function in a `defaultFunctionRegistry` entry may now be a function of the stack's data types that returns the `FuncCallSig`, as may the `signature` of a `GeneratorFunctionContribution`. Code that reads an entry's `signature` as a `FuncCallSig`, such as `funcCall(name, entry.signature as FuncCallSig)`, builds the call with `defaultFunctionCall(name, entry, dataTypes)` from `@prisma/orm-family-sql/contract-psl/attribute-specs` instead. A plain object still works. The detection matches a file that names `defaultFunctionRegistry` and reads a `signature`.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs}"
matches:
- '(?<![\s\S])(?=[\s\S]*\bdefaultFunctionRegistry\b)[\s\S]*\.signature\b'
- id: generator-function-field-length
summary: |
Nothing to change in code written against 8.0.0-rc.18, which has no generator function registry. A binding you write for `default-functions-lower-to-database-defaults-only` receives the column as `GeneratorFunctionField` (`@prisma/orm-framework/components/components`), whose `length` is the column's length in its data type's normal form, so a bare `Char` gives 1, or `undefined` for a column with no length or one whose database does not limit it, such as SQLite's. The built-in identifier bindings refuse a column shorter than what they write, and a binding of your own can read `length` to do the same. Code that calls `resolve` or `callFor` on a generator function registry passes it; for a SQL column, `sqlCodecColumnLength(dataTypes, codecId, typeParams)` from `@prisma/orm-family-sql/contract/data-type` computes it.
- id: build-contract-definition-takes-lookups
summary: |
`buildContractDefinition` (`@prisma/orm-family-sql/contract-ts/contract-builder`) now requires `codecLookup` and `dataTypeLookup`, the same lookups `buildSqlContractFromDefinition` takes, so that each `.onCreate(...)` and `.onUpdate(...)` call is checked against its column's length. Pass them in the definition. The detection matches files that call `buildContractDefinition`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bbuildContractDefinition\('
- id: arg-binding-spec-label
summary: |
`ArgBindingSpec` (`@prisma/orm-framework/psl-parser`), the second argument of `interpretArgs`, names its callee with `label`, the whole phrase such as `Attribute "rel"`, instead of `name`. `funcCall(name, signature, { label })` takes the phrase its binding diagnostics use; without it they say `Attribute "<name>"` as before.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs}"
matches:
- '\binterpretArgs\s*\('
- '\bArgBindingSpec\b'
- id: one-of-refuses-data-type-value
summary: |
`oneOf` (`@prisma/orm-framework/psl-parser`) throws an `InternalError` when one of its arms is a `dataTypeValue`. Give the value its own parameter, or use it as a parameter of a `funcCall` arm.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs}"
matches:
- '\boneOf\([^;]*\bdataTypeValue\('
- id: text-data-types
summary: |
A component's authoring contributions may name `textDataTypes`, the data types whose values are text; a SQL target names its own. A SQL `@@discriminator` field must be stored in one of them. An extension that adds a text type names it there to make it a discriminator.
- id: typescript-contract-pack-type-slots
summary: |
A contract built with `defineContract` reads its types from the packs in it. A third-party SQL pack is typed fully for such a contract only when it declares what an emitted `contract.d.ts` would get from its codecs and descriptors: `__queryOperationTypes` for its query operations, `__parameterizedOutputTypes` for codecs whose output type depends on their type parameters, literal-typed `types.aggregateDescriptors` for its aggregates, and exports from its `/pack` entry for the types an application's exported contract names. Nothing breaks for a pack that does none of this; its operations, refined output types and aggregates are missing from a `defineContract` contract's types, as before. The detection matches SQL pack metadata that declares codec types.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '__codecTypes\?:'
- id: parameterized-output-types-declared-once
summary: |
A codec whose output type depends only on its column's type parameters, such as `Vector<3>` for `vector(3)`, declares that type once, as a type function the pack exports, and no longer implements `renderOutputType`. The emitted `contract.d.ts` applies the type function, and so does a contract built with `defineContract`. A pack must name the function twice: in its codec types' metadata as `types.codecTypes.parameterizedOutputTypes`, an import spec like `types.codecTypes.import`, which the emitter reads, and in its pack metadata's type as `__parameterizedOutputTypes`, which `defineContract` reads; if one is missing, that path types the column by the codec's plain output type without an error. `ParameterizedOutputTypesFunction` comes from `@prisma/orm-framework/contract/types`. A `renderOutputType` that writes such a type still works for the emitted file but is ignored by `defineContract`; remove it, with the `typeImports` entry it needed, once the type function exists. Keep `renderOutputType` only for a type no type function can compute, such as an arktype schema's text. The detection matches codec descriptors that implement `renderOutputType`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'renderOutputType\('
- 'renderOutputType:'
- id: psl-interpreter-contract-capabilities
summary: |
`interpretPslDocumentToSqlContract` now writes the enabled capabilities it is given in `capabilities` into the contract it returns, joined with the target's and the extensions', so a contract read from PSL carries its adapter's capabilities as one built with `defineContract` does. Before, it used them only to check the schema and left them out of the contract. Code that called it directly and compared the contract's `capabilities` to the target's and extensions' alone now also sees the ones it passed in. The detection matches calls of the interpreter.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'interpretPslDocumentToSqlContract\('
- id: field-types-expression-removed
summary: |
Nothing to change in code written against 8.0.0-rc.18: the `fieldTypesExpression` hook of `EmissionSpi` was added and removed between 8.0.0-rc.18 and 8.0.0-rc.19. The emitter writes `FieldOutputTypes` and `FieldInputTypes` for every family as `FieldTypesFromDomain` applied to the emitted contract, and declares `FieldTypeOverrides` and `ParameterizedOutputTypeFunctions` itself.
- id: field-types-generic-moved
summary: |
Nothing to change in code written against 8.0.0-rc.18, which has no `FieldTypesFromDomain`. Code written against a development build after it imports `FieldTypesFromDomain` and `ValueTypeSide` from `@prisma/orm-framework/contract/types` instead of the SQL family contract types entry. The generic types value object and union fields itself, from the namespace's `valueObjects`. Its arguments are `FieldTypesFromDomain<DomainNamespaces, CodecTypes, ParameterizedOutputs, Overrides, Side>`, where `Overrides` is one map keyed like the domain, `[namespace]['models' | 'valueObjects'][owner][field]`, each entry `{ output?, input? }`; `ValueObjectTypesFromDomain` takes the same map. A storage type map can type one entry with `StoredValueType` from the same entry. `ColumnTypesFromStorage` stays in the SQL family.
- id: resolve-field-value-set-renamed
summary: |
`EmissionSpi.resolveFieldValueSet` is renamed `resolveStorageValueSet`. The framework emitter now reads a field's value set itself when the field references a domain enum (`valueSet.plane` is `'domain'`), for model and value object fields alike, and no longer calls the hook for those fields. It calls `resolveStorageValueSet` only for other model fields, whose values the family keeps in its storage, such as a SQL column's value set. Rename the hook in a family emitter, and drop any handling of domain enum references from it. The detection matches implementations of the old hook.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bresolveFieldValueSet\s*[(:?]'
- id: mongo-parameterized-output-types
summary: |
A Mongo-family pack whose codec output type depends only on a field's type parameters, such as `Vector<3>` for a vector of length 3, declares it once as a type function, as SQL packs do: export a `ParameterizedOutputTypesForTypeParams` interface extending `ParameterizedOutputTypesFunction` from its `codec-types` entry, name it in `types.codecTypes.parameterizedOutputTypes` where the codec types import is declared, and add `__parameterizedOutputTypes` to the pack metadata's type. `renderOutputType` is no longer used for such codecs: Mongo's vector codec no longer implements it, and a Mongo `defineContract` contract ignores it. Export the types the pack metadata names from its `/pack` entry, so an application that exports a contract can build declarations. The detection matches files that import a Mongo package and implement `renderOutputType`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\s\S])(?=[\s\S]*@prisma/orm-(mongo|family-mongo|target-mongo)\b)[\s\S]*renderOutputType'
- id: mongo-model-storage-fields
summary: |
A MongoDB model's `fields` are keyed by field name, and `model.storage.fields` records the key each field is stored under: `MongoModelStorage` gains a required `fields: Record<string, { field: string }>`, with an entry for every field. `on.localFields`, `on.targetFields` and `discriminator.field` name fields; indexes, validators and execution default refs name stored names. Code that read `model.fields` keys as document keys reads stored names through the helpers `@internal/mongo-contract` exports: `ownStoredNamesOf`, `ownStoredNameOf`, `ownFieldStoredAs`, `hasOwnFieldStoredAs`, `ownEmbedStoredNamesOf`, `ownDocumentIdFieldOf`, `storedNamesOf`, `modelAndBasesOf`, `valueObjectStoredNamesOf` and `mongoModelsOf`, and at the type level `OwnStoredNameOf` and `OwnFieldStoredAs`. An embed relation is stored under its `storage.relations` entry, or its own name. A hand-built MongoDB contract or model storage type adds `fields`.
detection:
glob: "**/*.{ts,mts,cts,tsx,json}"
matches:
- 'MongoModelStorage|MongoModelDefinition'
- '"storage"\s*:\s*\{\s*(?:"collection"\s*:\s*"[^"]*"\s*)?\}'
- '(?<![\w$])storage\s*:\s*\{\s*(?:readonly\s+)?collection\s*:\s*[''"][^''"]*[''"]\s*;?\s*\}'
- id: mongo-validator-derivation-moves-to-mongo-contract
summary: |
The `FieldValueSets` type moves from `@internal/mongo-contract-psl` (`@prisma/orm-family-mongo/contract-psl`) to `@internal/mongo-contract` (`@prisma/orm-family-mongo/contract`). Extensions that author Mongo contracts through `defineContract(...)` or PSL need no change.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- 'import[^;]*\bFieldValueSets\b[^;]*from\s*[''"](?:@internal/mongo-contract-psl|@prisma/orm-family-mongo/contract-psl)[''"]'
- id: mongo-derive-json-schema-replaced-by-derive-collection-validators
summary: |
`deriveJsonSchema` and `derivePolymorphicJsonSchema` are removed. Call `deriveCollectionValidators` from `@internal/mongo-contract` (`@prisma/orm-family-mongo/contract`) instead. It returns the validator of each collection, keyed by collection name, and it is the validator a contract emits, including embedded models and variants.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '\bderive(?:Polymorphic)?JsonSchema\b'
- id: psl-nocheck-removed
summary: |
The PSL attribute `@noCheck` no longer exists, and writing it in an extension's contract source is an unknown-attribute error. A list that allows null elements is now written `T?[]`. Replace `T[] @noCheck(elementNotNull)` with `T?[]`, delete `@noCheck(membership)`, and replace a bare `@noCheck` with both. Then re-emit the extension's bundled contract. The detection matches PSL files that use the attribute.
detection:
glob: "**/*.prisma"
matches:
- '@noCheck\b'
- id: ts-nocheck-method-removed
summary: |
The TypeScript field builder no longer has a `noCheck()` method. Replace `.many().noCheck('elementNotNull')` with `.many({ elementsNullable: true })`, delete `.noCheck('membership')`, and replace a bare `.noCheck()` with both. Then re-emit the extension's bundled contract. The detection matches TypeScript files that call the method.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\.noCheck\('
- id: stored-contracts-drop-nocheck
summary: |
A stored SQL contract that has a `noCheck` key on a column no longer loads. Run `scripts/remove-nocheck/remove-nocheck.ts` from the extension's package root. It removes the key from every stored contract the extension ships, its migration snapshots included, writes each PostgreSQL default in the form this release stores, and rewrites the hashes and references that change. Then re-emit the bundled contract.
detection:
glob: "**/*.json"
matches:
- '"noCheck"\s*:'
script: ./scripts/remove-nocheck/remove-nocheck.ts
- id: sqlite-contracts-name-datetime-and-json-data-types
summary: |
An extension that ships a SQLite contract space: a column whose codec is `sqlite/datetime@1` or `sqlite/json@1` names its own data type (`sqlite/datetime`, `sqlite/json`) instead of `sqlite/text`, and a datetime default or enum value is stored with exactly three fraction digits. The CLI refuses the old form. Run `scripts/data-type-values/data-type-values.ts` from the extension's package root, as the section of this name below describes. It rewrites the contract and its migration snapshots and their hashes. The storage hash changes, so tell the applications that use the extension to run `prisma db sign` after they upgrade.
detection:
glob: "**/*.json"
matches:
- '"codecId"\s*:\s*"sqlite/(?:datetime|json)@1"\s*,\s*"dataType"\s*:\s*"sqlite/text"'
script: ./scripts/data-type-values/data-type-values.ts
- id: mongo-contracts-record-stored-field-names
summary: |
An extension that ships a MongoDB contract space: a MongoDB contract records each field's stored name in `model.storage.fields`, and a contract without it is refused with `CONTRACT.VALIDATION_FAILED`. Re-emit the bundled contract, then run `scripts/mongo-field-storage-names/mongo-field-storage-names.ts` from the extension's package root to rewrite the migration snapshots, as the section of this name below describes. Storage hashes do not change.
detection:
glob: "**/*.json"
matches:
- '"storage"\s*:\s*\{\s*(?:"collection"\s*:\s*"[^"]*"\s*)?\}'
- '"storage"\s*:\s*\{\s*"collection"\s*:\s*"[^"]*"\s*,\s*"relations"'
script: ./scripts/mongo-field-storage-names/mongo-field-storage-names.ts
- id: date-time-interval-and-bytes-defaults-use-tags
summary: |
In the PSL source of an extension's contract, a date, time, interval or bytes default is written with its type's tag instead of a quoted string, such as `@default(timestamptz`2024-01-01T00:00:00Z`)` or `@default(bytea`\x0102`)`. `contract emit` refuses the quoted form and names the tag to write. The section of this name below lists every tag. The detection matches Prisma 8 schema files with a quoted default that looks like a date or an interval, or that sits on a date, time, interval or bytes column. It skips a quoted time such as `"12:30"` on a column whose type is a type alias, because a `String` default can look the same; `contract emit` names such a default.
detection:
glob: "**/*.prisma"
matches:
- '(?<![\s\S])\s*// *use +(?:prisma-8|prisma-next) *(?!\S)[\s\S]*@default\(\s*"(?:[+-]?\d{4,6}-\d{2}-\d{2}|-?P(?:\d|T\d)|[+-]?infinity")'
- '(?<![\s\S])\s*// *use +(?:prisma-8|prisma-next) *(?!\S)[\s\S]*\b(?:DateTime|Date|Time|Timetz|Timestamp|Timestamptz|Interval)\b[^\n]*@default\(\s*"'
- '(?<![\s\S])\s*// *use +(?:prisma-8|prisma-next) *(?!\S)[\s\S]*\bBytes\??[^\n]*@default\(\s*"'
- id: postgres-defaults-stored-in-another-spelling
summary: |
An extension that ships a PostgreSQL contract space: the CLI refuses a contract that an earlier release emitted when it stores a default in another spelling than its column's parameters give, for example `"1.5"` on `numeric(10,2)`. Run `scripts/remove-nocheck/remove-nocheck.ts` from the extension's package root (`stored-contracts-drop-nocheck`): it writes each stored default in the new form, migration snapshots included. Then re-emit the bundled contract. If the storage hash changes, tell the applications that use the extension to run `prisma db sign` after they upgrade.
script: ./scripts/remove-nocheck/remove-nocheck.ts
- id: client-functions-move-from-default-to-on-create
summary: |
In the PSL source of an extension's contract, `@default(uuid())`, `@default(cuid(2))`, `@default(ulid())` and `@default(nanoid())` are refused with `PSL_GENERATOR_IN_DEFAULT`; write `@onCreate(...)` instead. `scripts/default-generators-to-on-create/rewrite-default-generators.mjs` rewrites the files it is given. The contract does not change. The detection matches Prisma 8 schema files, those with the `// use prisma-8` directive, that write one of these functions in `@default`.
detection:
glob: "**/*.prisma"
matches:
- '(?<![\s\S])\s*// *use +(?:prisma-8|prisma-next) *(?!\S)[\s\S]*@default\(\s*(?:uuid|cuid|ulid|nanoid)\s*\('
script: ./scripts/default-generators-to-on-create/rewrite-default-generators.mjs
- id: setdefault-operation-class-derived
summary: |
On PostgreSQL, `this.setDefault` in `migration.ts` no longer takes `operationClass`. The operation is widening when the migration's start contract gives the column a default, and additive otherwise. `migration plan` no longer writes `operationClass` into the call. A `migration.ts` that passes it no longer type-checks; delete the option.
detection:
glob: "**/migration.ts"
matches:
- '\bsetDefault\(\{(?:(?!\bthis\.|\brawSql\s*\()[\s\S])*?\boperationClass\s*:'
- id: setdefault-has-no-postcheck
summary: |
On PostgreSQL, a `setDefault` operation no longer checks afterwards that the column has a default, so the runner no longer skips it when the column already has a different default. A package planned by 8.0.0-rc.15 or a later release before this one whose `migration.ts` has a hand-written `setDefault` that changes an existing default still carries the old check in `ops.json`; write its `ops.json` again before you apply it.
detection:
glob: "**/ops.json"
matches:
- 'verify column \\"(?:[^"\\]|\\.)*?\\" has a default'
- id: postgres-postcheck-create-table
summary: |
On PostgreSQL, the check that runs after `createTable` checks that the relation is a table, not a view, sequence or index of the same name. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "create table \\"(?:[^"\\]|\\.)*?\\""(?:(?!"postcheck")[\s\S])*?"postcheck": \[\s*\{\s*"description": "verify table \\"(?:[^"\\]|\\.)*?\\" exists",\s*"sql": "SELECT \(?to_regclass\('
- id: postgres-postcheck-add-column
summary: |
On PostgreSQL, the check that runs after `addColumn` checks the column's type, and that it is NOT NULL when the column is declared NOT NULL. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "verify column \\"(?:[^"\\]|\\.)*?\\" exists",[^{}]*\}\s*\]'
- id: postgres-postcheck-add-column-with-temporary-default
summary: |
On PostgreSQL, the check that runs after `addColumnWithTemporaryDefault` checks the column's type. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- 'has no default after temporary default removal",[^{}]*\}\s*\]'
- id: postgres-postcheck-add-required-column-to-empty-table
summary: |
On PostgreSQL, the check that runs after `addRequiredColumnToEmptyTable` checks the column's type. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "verify column \\"(?:[^"\\]|\\.)*?\\" exists",[^{}]*\},\s*\{\s*"description": "verify column \\"(?:[^"\\]|\\.)*?\\" is NOT NULL",[^{}]*\}\s*\]'
- id: postgres-postcheck-create-index
summary: |
On PostgreSQL, the check that runs after `createIndex` checks that the relation is an index on the planned table, with the planned uniqueness, access method and columns in order, and with an expression and a predicate only where the call has them. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "create index \\"(?:[^"\\]|\\.)*?\\"",(?:(?!"postcheck")[\s\S])*?"postcheck": \[\s*\{\s*"description": "verify index \\"(?:[^"\\]|\\.)*?\\" exists",'
- id: postgres-postcheck-rename-index
summary: |
On PostgreSQL, the check that runs after `renameIndex` also checks that no index has the old name any more. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "rename index \\"(?:[^"\\]|\\.)*?\\" to \\"(?:[^"\\]|\\.)*?\\"",(?:(?!"postcheck")[\s\S])*?"postcheck": \[\s*\{\s*"description": "verify index \\"(?:[^"\\]|\\.)*?\\" exists",[^{}]*\}\s*\]'
- id: postgres-postcheck-add-primary-key
summary: |
On PostgreSQL, the check that runs after `addPrimaryKey` checks that the constraint is a primary key on the planned columns in order. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "verify primary key \\"(?:[^"\\]|\\.)*?\\" exists"'
- id: postgres-postcheck-add-unique
summary: |
On PostgreSQL, the check that runs after `addUnique` checks that the constraint is a unique constraint on the planned columns in order. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "add unique constraint \\"(?:[^"\\]|\\.)*?\\"",(?:(?!"postcheck")[\s\S])*?"postcheck": \[\s*\{\s*"description": "verify constraint \\"(?:[^"\\]|\\.)*?\\" exists",'
- id: postgres-postcheck-add-foreign-key
summary: |
On PostgreSQL, the check that runs after `addForeignKey` checks the constraint's columns, referenced table and columns in order, and its delete and update actions. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "verify FK \\"(?:[^"\\]|\\.)*?\\" exists"'
- id: postgres-postcheck-add-check-constraint
summary: |
On PostgreSQL, the check that runs after `addCheckConstraint` checks that the constraint is a check constraint. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "add check constraint \\"(?:[^"\\]|\\.)*?\\"",(?:(?!"postcheck")[\s\S])*?"postcheck": \[\s*\{\s*"description": "verify constraint \\"(?:[^"\\]|\\.)*?\\" exists",'
- id: postgres-postcheck-rename-constraint
summary: |
On PostgreSQL, the check that runs after `renameConstraint` also checks that no constraint has the old name any more. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "rename (?:primary key|unique constraint|foreign key|check constraint) \\"(?:[^"\\]|\\.)*?\\" to \\"(?:[^"\\]|\\.)*?\\"",(?:(?!"postcheck")[\s\S])*?"postcheck": \[\s*\{\s*"description": "verify constraint \\"(?:[^"\\]|\\.)*?\\" exists",[^{}]*\}\s*\]'
- id: postgres-postcheck-create-rls-policy
summary: |
On PostgreSQL, the check that runs after `createRlsPolicy` checks the policy's command, roles and whether it is permissive. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "create RLS policy \\"(?:[^"\\]|\\.)*?\\"",(?:(?!"postcheck")[\s\S])*?"postcheck": \[\s*\{\s*"description": "verify RLS policy \\"(?:[^"\\]|\\.)*?\\" exists",'
- id: postgres-postcheck-rename-rls-policy
summary: |
On PostgreSQL, the check that runs after `renameRlsPolicy` also checks that no policy has the old name any more; its precheck now also refuses a policy that already has the new name. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- '"description": "rename RLS policy \\"(?:[^"\\]|\\.)*?\\" to \\"(?:[^"\\]|\\.)*?\\"",(?:(?!"postcheck")[\s\S])*?"postcheck": \[\s*\{\s*"description": "verify RLS policy \\"(?:[^"\\]|\\.)*?\\" exists",[^{}]*\}\s*\]'
- id: postgres-postcheck-autoincrement-default
summary: |
On PostgreSQL, the check that runs after `setDefault` with `autoincrement()` also checks that the column owns the sequence of the planned name and that the sequence has the column's integer width. An `ops.json` written before this change keeps its old check, which the runner can find already true for an object of the same name with another definition, and then skip the operation. Run `migration.ts` again for each migration that is not applied yet.
detection:
glob: "**/ops.json"
matches:
- 'takes its default from an attached sequence",[^{}]*\}\s*\]'
- id: postgres-alter-column-type-unchanged-refused
summary: |
On PostgreSQL, `this.alterColumnType` with `using` now refuses, with `MIGRATION.ALTER_COLUMN_TYPE_USING_WITHOUT_TYPE_CHANGE`, a column the start contract, after the earlier renames in the migration, already gives the new type. Its check compares only the type, so the runner skipped the conversion. Write the conversion as a `dataTransform`.
detection:
glob: "**/migration.ts"
matches:
- '\balterColumnType\(\{(?:(?!\bthis\.)[\s\S])*?\busing\s*:'
- id: postgres-autoincrement-sequence-follows-column-type
summary: |
On PostgreSQL, changing the integer type of an `autoincrement()` column now also changes the sequence the column owns to the new width, and checks both afterwards. Before, a column widened from `int4` to `int8` kept an `integer` sequence, which stops at 2147483647. An `ops.json` written before this change has an `alterColumnType` without the sequence step, and an `alter type of "<column>"` step that does not name the new type; run `migration.ts` again for each migration that is not applied yet. For a database where such a migration is already applied, run `ALTER SEQUENCE … AS bigint` on the sequence `pg_get_serial_sequence` names yourself.
detection:
glob: "**/ops.json"
matches:
- '"description": "alter type of \\"(?:[^"\\]|\\.)*?\\"",(?:(?!"postcheck")[\s\S])*?"postcheck": \[\s*\{\s*"description": "verify column \\"(?:[^"\\]|\\.)*?\\" has type \\"(?:smallint|integer|bigint)\\""'
- id: sqlite-postchecks-check-full-definition
summary: |
On SQLite, the checks that let the runner skip an operation whose effect is already in place now check the whole definition. `createTable` checks every column's name, type, nullability and primary key position; `addColumn` checks the column's type and nullability; `createIndex` checks the index's table, ordered columns and uniqueness; `recreateTable` checks foreign key actions and `autoincrement()`. An `ops.json` written by an earlier release still carries the old checks; write it again before you apply it.
detection:
glob: "**/ops.json"
matches:
- '(?<![\s\S])(?![\s\S]*has exactly its columns)[\s\S]*"description":\s*"verify table \\"(?:[^"\\]|\\.)*\\" exists",\s*"sql":\s*"(?:[^"\\]|\\.)*sqlite_master'
- '(?<![\s\S])(?![\s\S]*\\" has type )[\s\S]*"description":\s*"verify column \\"(?:[^"\\]|\\.)*\\" exists",\s*"sql":\s*"(?:[^"\\]|\\.)*pragma_table_info'
- '(?<![\s\S])(?![\s\S]*\\" covers \()[\s\S]*"description":\s*"verify index \\"(?:[^"\\]|\\.)*\\" exists",\s*"sql":\s*"(?:[^"\\]|\\.)*sqlite_master'
- 'pragma_foreign_key_list\(''(?:[^''\\]|\\.)*''\) f WHERE f\.\\"table\\" = ''(?:[^''\\]|\\.)*'' GROUP BY'
- id: mongo-postchecks-check-full-definition
summary: |
On MongoDB, the checks that let the runner skip an operation whose effect is already in place now check the whole definition. `createIndex` checks every index option the planner tells indexes apart by, not only the keys and `unique: true`. `collMod` with a validator that is not only a `$jsonSchema` compares the whole validator. An `ops.json` written by an earlier release still carries the old checks; write it again before you apply it.
detection:
glob: "**/ops.json"
matches:
- '(?<![\s\S])(?![\s\S]*"field":\s*"sparse")[\s\S]*"description":\s*"index exists on '
- '(?<![\s\S])(?![\s\S]*"field":\s*"options\.validator)[\s\S]*"description":\s*"validator applied on '
- id: control-client-apply-takes-invocation
summary: |
`dbInit`, `dbUpdate` and `migrate` on a control client take a required `invocation` option: `{ cwd, configPath, dbGiven }`, where the commands an error's advice prints are run from, the `--config` they repeat, and whether they repeat `--db`. Pass `{ cwd: process.cwd(), configPath: undefined, dbGiven: false }` when nothing is run from a terminal. Their failures are now `CliStructuredError`s, so `DbInitFailure`, `DbUpdateFailure` and `MigrateFailure` are gone from `control-api`.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\.(?:dbInit|dbUpdate|migrate)\(\s*\{'
- '\b(?:DbInitFailure|DbUpdateFailure|MigrateFailure)(?:Code)?\b'
- id: runner-failures-report-own-codes
summary: |
A failed migration apply reports the runner's own code, such as `MIGRATION.SCHEMA_VERIFY_FAILED`, `MIGRATION.PRECHECK_FAILED`, `MIGRATION.POSTCHECK_FAILED` or `MIGRATION.EXECUTION_FAILED`, instead of `MIGRATION.RUNNER_FAILED` with `meta.runnerErrorCode`. `MIGRATION.RUNNER_FAILED` no longer exists. The facts move into typed `meta` keys: a failed schema check lists its issues in `meta.schemaIssues` (was `meta.issues`), and a failed check is `meta.failedCheck` (`operationId`, `phase`, `description`; was `operationId`, `phase`, `stepDescription`).
detection:
glob: "**/*.{ts,tsx,mts,cts,js,mjs,cjs,sh}"
matches:
- 'MIGRATION\.RUNNER_FAILED|\brunnerErrorCode\b'
---
# 8.0.0-rc.18 → 8.0.0-rc.19 — Extension author upgrade instructions
Run `client-takes-contract-value` first, on source files. An extension that ships a contract space then rewrites its stored contracts and contract source before it re-emits the bundled contract once; `Contract spaces an extension ships` lists the steps and their order. Make the other changes as their sections say.
## Clients
### `client-takes-contract-value`
A client, static entry or `mongoQuery()` no longer takes `contractJson`, and it needs no type argument. This applies to `postgres()`, `postgresServerless()`, `sqlite()`, `supabase()`, `mongo()`, `postgresStatic()`, `sqliteStatic()`, `mongoStatic()` and `mongoQuery()`. If your extension's code, examples or tests build one from emitted JSON, turn the JSON into a typed value with `contractFromJson<Contract>(json)` from the facade's `/static` entry (`@prisma/orm-postgres/static`, `@prisma/orm-sqlite/static`, `@prisma/orm-mongo/static`; Supabase uses the Postgres one) and pass `{ contract }`:
```ts
// Before
const db = postgres<Contract>({ contractJson, extensions: [myPack] });
// After
import { contractFromJson } from '@prisma/orm-postgres/static';
const db = postgres({ contract: contractFromJson<Contract>(contractJson), extensions: [myPack] });
```
A value is validated once however many clients use it, so build it once per module and share it. A contract written with `defineContract` is already a value: keep passing it as `{ contract }`.
`mongoQuery()` takes `{ contract }`, a value typed as the contract, and infers the type from it: `mongoQuery({ contract }).from('orders')`. A test that only needs the types can declare the value: `declare const contract: Contract;`.
#### Run the script
Run `node <path to this guide>/scripts/client-takes-contract-value/contract-from-json.ts <package root>`. The script reads source files with the TypeScript compiler from the package's `typescript`; if there is none, it changes nothing, says so and exits 1. It skips `node_modules`, `dist`, `build` and every directory whose name starts with a dot, such as `.git`, `.agents` or `.claude`; move a client in such a directory by hand. It moves each source file that passes `contractJson` to a client, static entry or `mongoQuery`: it adds the `contractFromJson` import, declares `export const contract = contractFromJson<Contract>(contractJson);` after the imports (`const contract` without `export` in a module with a default export, with a note), removes the type argument and passes `{ contract }`. Where the call's result is destructured with `contract` in it, as in a `migration.ts`, it removes `contract` from the destructuring. It prints each file it moved. It prints to stderr, with the reason, each file it did not move, and then exits 1: a test file, a file that already has another binding named `contract`, a file whose clients use different contracts, a file whose `contractJson` value uses a binding declared after the imports, a file whose `contractJson` value uses a parameter or local binding of the function that creates the client (pass `contract: contractFromJson<Contract>(<value>)` to that call instead), or a call it cannot read.
#### Then, by hand
1. **Tests.** Move each test that passes `contractJson` the same way: `postgres({ contract: contractFromJson<Contract>(contractJson) })`, or one `const contract = contractFromJson<Contract>(contractJson);` per file when several clients share it.
2. **Types.** The option types `PostgresOptionsWithContract`, `PostgresOptionsWithContractJson`, `PostgresServerlessOptionsWithContract`, `PostgresServerlessOptionsWithContractJson`, `SqliteOptionsWithContract`, `SqliteOptionsWithContractJson`, `MongoOptionsWithContract`, `MongoOptionsWithContractJson`, `SupabaseOptionsWithContract` and `SupabaseOptionsWithContractJson` are removed. Use `PostgresOptions<Contract>`, `PostgresServerlessOptions<Contract>`, `SqliteOptions<Contract>`, `MongoOptions<Contract>` or `SupabaseOptions<Contract>`.
3. **READMEs and JSDoc** that show a client called with `contractJson`: rewrite them to the new form.
### `collections-on-the-client`
This concerns extension packages whose code, examples or tests build a client and then an ORM by hand to reach custom collection classes. The client types (`PostgresClient`, `PostgresTransactionContext`, `PostgresServerlessClient`, `PostgresServerlessConnection`, `SqliteClient`, `SqliteTransactionContext`, `SupabaseDb`, `RoleBoundDb`, `ServiceRoleDb`) gained a second type parameter for the classes. It defaults to no classes, so existing references with one type argument keep their meaning.
`postgres()`, `postgresServerless()`, `sqlite()` and `supabase()` take an option, `collections`, with the same type `orm({ collections })` takes. The client types are inferred from it, and the classes reach every ORM the client offers:
```ts
// Before
import { orm } from '@prisma/orm-postgres/orm-client';
import { db } from './prisma/db';
export const posts = orm({
runtime: db.runtime(),
context: db.context,
collections: { Post: PostCollection },
}).public.Post;
// After, in the module that creates the client
export const db = postgres({ contract, url, collections: { Post: PostCollection } });
await db.orm.public.Post.published().all();
await db.transaction(async (tx) => tx.orm.public.Post.published().forUpdate().all());
```
1. Pass the classes as `collections` where the client is created. If the module that defines the classes imports the client's module, import only the `Contract` type there (`import type { Contract } from './contract.d'`), so the two modules do not import each other.
2. Delete the hand-built ORM client (for example a `createOrmClient(runtime)` helper) and every `orm({ runtime, context, collections })` that only existed to reach the classes. Use `db.orm` instead. On SQLite, `db.orm` is the model collections themselves: `db.orm.Post`.
3. A function that took a `runtime` only to build an ORM no longer needs it. Inside a transaction, pass `tx.orm`, which has the classes too.
4. On a serverless client, the classes are on every connection: `await using db = await postgres.connect({ url }); db.orm.public.Post.published()`.
5. On Supabase, the classes are on `asUser`, `asAnon` and `asServiceRole` clients. Supabase's internal `.supabase` ORM does not take them.
A type written with only the contract, such as `PostgresClient<Contract>`, still describes a client without classes and accepts a client created with them. To name a client with classes, give the second type parameter: `PostgresClient<Contract, { Post: typeof PostCollection }>`.
### `client-query-and-to-sql`
Every SQL client now runs a plan itself and renders the SQL a plan would send:
```ts
const plan = db.sql.public.post.select('id', 'title').limit(10).build();
console.log(db.toSql(plan)); // { sql: 'SELECT … LIMIT 10', params: [] }
for await (const row of db.query(plan)) console.log(row);
await db.execute(db.raw.sql`UPDATE post SET pinned = false`.affectedCount().build());
```
`db.query(plan)` and `db.execute(plan)` behave as `db.runtime().query(plan)` and `db.runtime().execute(plan)` do, and have the types `tx.query` and `tx.execute` have. Replace `db.runtime().query(` with `db.query(` and `db.runtime().execute(` with `db.execute(`. Keep `db.runtime()` where the code needs `connection()`, `close()` or `telemetry()`. On Supabase, call them on a role-bound client, as before.
`toSql(plan)` needs only the adapter and the contract, so it works with no database. `params` are the plan's values before codecs encode them. Middleware rewrites (`beforeCompile`) are not applied. A plan with `prepare` bind sites throws `RUNTIME.PREPARE_BIND_ON_ADHOC`, and a plan built for another contract throws `PLAN.HASH_MISMATCH`, as running it would.
The message of `RUNTIME.PREPARE_BIND_ON_ADHOC` changed; its code did not. Code that matches the old message text ("Ad-hoc execute received a bind-site slot") must match the code instead. The details of `PLAN.TARGET_MISMATCH` and `PLAN.HASH_MISMATCH` rename `runtimeTarget` to `contractTarget` and `runtimeStorageHash` to `contractStorageHash`, and their messages changed; match the codes.
`supabase()` no longer creates its `pg.Pool`, driver or runtimes when it is called. It creates them on the first query, so creating a client with a URL opens nothing. It is still `async` and still requires `jwtSecret` or `jwksUrl`. `close()` now ends only a pool the client created from `url`; a `pg.Pool` passed in with the `pg` option stays open, as with `postgres()`, and the caller ends it.
`postgres()`, `postgresServerless()`, `sqlite()` and `supabase()` take a new `mode` option, typed `RuntimeMode`. It is `'strict'` by default, which is how every client ran before. Under `'permissive'`, a `budgets()` violation whose severity is `'warn'` goes to `log.warn` and the query runs.
### `transaction-query-members`
Inside `db.transaction(async (tx) => …)`, `tx` now has the client's query members with the same types: `sql`, `orm` (with the client's custom collection classes), `raw`, `prepare`, `query`, `execute` and `toSql`. This holds for `postgres()`, every serverless connection, `sqlite()` and every Supabase role-bound client. A Supabase role-bound client gains `prepare`, and SQLite's `db.orm` gains `fragment`. A prepared statement runs on whatever it is given, so `ps.query(tx, params)` runs inside the transaction; `tx.prepare` lets code inside a transaction prepare without the outer client.
### Test doubles of client types
Code that only reads the client types needs no change for `client-query-and-to-sql` and `transaction-query-members`. Code that creates an object of one of them, for example a test double typed `PostgresClient<Contract>` or `PostgresTransactionContext<Contract>`, must add the new members:
| Type | New or changed members |
| --- | --- |
| `PostgresClient`, `PostgresServerlessConnection` (`@prisma/orm-postgres/runtime`, `/serverless`) | `query`, `execute`, `toSql` |
| `PostgresServerlessClient`, `PostgresStaticContext`, `PostgresStaticMembers` | `toSql` |
| `PostgresQueryMembers` | new: the query members a client and its transactions share, including `query`, `execute` and `toSql`; `PostgresClient`, `PostgresServerlessConnection` and `PostgresTransactionContext` extend it |
| `PostgresTransactionContext` (`@prisma/orm-postgres/runtime`) | `raw`, `prepare`, `toSql` |
| `SqliteClient` (`@prisma/orm-sqlite/runtime`) | `query`, `execute`, `toSql`; `orm` is `SqliteOrm`, which adds `fragment` |
| `SqliteStaticContext` | `toSql` |
| `SqliteStaticMembers` (`@prisma/orm-sqlite/runtime`) | new: the static context plus `stack`, which `SqliteClient` now extends |
| `SqliteQueryMembers` | new: the query members a client and its transactions share, including `query`, `execute` and `toSql`; `SqliteClient` and `SqliteTransactionContext` extend it |
| `SqliteTransactionContext` (`@prisma/orm-sqlite/runtime`) | `raw`, `prepare`, `toSql`; `orm` is `SqliteOrm`, the namespace's models plus `fragment` |
| `RoleBoundDb` (`@prisma/orm-extension-supabase/runtime`) | `prepare`, `toSql`, and the two prepared-statement entry points a statement's `query` and `execute` call; `query` and `execute` take the plan type every SQL client shares; it extends `PostgresQueryMembers`; `transaction` passes a `SupabaseTransactionContext` instead of a plain `TransactionContext` |
| `ServiceRoleDb` (`@prisma/orm-extension-supabase/runtime`) | `toSql`; `query` and `execute` take the plan type every SQL client shares |
| `SupabaseTransactionContext` | new; has `toSql` |
| `SupabaseInternalDb` (the service role's `supabase` member) | `toSql`, over the Supabase contract |
| `OrmClientRootMembers` (ORM client) | newly exported: the members of the ORM client beside its namespaces, today `fragment` |
| `PrepareMember` (ORM client) | newly exported: the `prepare` member a client and its transactions offer |
A hand-built `RoleBoundDb`, such as a test double, must also implement the two prepared-statement entry points, keyed by the symbols `preparedStatementQuery` and `preparedStatementExecute`. Import them from `@prisma/orm-postgres/family-runtime/internal/prepared-query` (or `@prisma/orm-family-sql/runtime/internal/prepared-query`). Like every `internal/` entry, it can change without notice.
A callback typed to take a plain `TransactionContext` still compiles where a transaction context is passed, because each new context type extends it.
## Clients start their own activities, and ORM calls are activities
The runtime's middleware tree now has a third kind of activity: one a client starts for an act the runtime would not otherwise see. A client starts it with `startActivityOn(queryable, activity)` and sends statements through the returned handle. A middleware can supply such an activity's result through the new `intercept.onActivity` handler. Each SQL ORM terminal method is one such activity, of kind `orm-call`. Its statements, its own transaction (`handle.transaction()`) and the read-back after a nested write are its children, and the annotations a `configure` callback records are on the call.
### `runtime-activity-is-activity`
Replace every `RuntimeActivity` with `Activity`, imported from the same module. There is no alias:
```ts
// before
import type { RuntimeActivity } from '@prisma/orm-framework/components/runtime';
async activityStarted(activity: RuntimeActivity, ctx) {}
// after
import type { Activity } from '@prisma/orm-framework/components/runtime';
async activityStarted(activity: Activity, ctx) {}
```
### `activity-includes-client-activities`
`Activity` always includes `ClientActivity`, whose kind contains a hyphen. A hook that rules out `'transaction'` and then reads statement fields stops compiling. Test for the kind you handle instead:
```ts
// before
if (result.kind === 'transaction') return;
record(result.latencyMs);
// after
if (result.kind !== 'statement') return;
record(result.latencyMs);
```
A middleware that sees a kind it does not know should ignore it rather than fail.
### `start-a-client-activity`
An extension can start an activity for an act of its own, such as a report that sends several statements, so that middleware observe it, finalize it and can serve it from a cache.
**Dependencies.** `startActivityOn`, `planContentHashOn` and the types their signatures name, such as `StartActivity`, `ClientActivity`, `ClientActivityKind` and `ActivityInterceptResult`, are in `@prisma/orm-family-sql/runtime`. The registry, `ClientActivityRegistry`, is in `@prisma/orm-framework/components/runtime`, which also has the types. An application or extension that uses `postgres()` already depends on `@prisma/orm-postgres`, which re-exports both as `@prisma/orm-postgres/components/runtime` and `@prisma/orm-postgres/family-runtime`. Augment `ClientActivityRegistry` through the entry that exports it, `@prisma/orm-framework/components/runtime` or `@prisma/orm-postgres/components/runtime`. Augmenting it through `@prisma/orm-family-sql/runtime` or `@prisma/orm-postgres/family-runtime` registers nothing, and TypeScript reports no error.
**Register the kind.** A client kind contains a hyphen; a kind without one is rejected with `RUNTIME.ACTIVITY_KIND_RESERVED`. Registering the result type is optional. The file that augments the entry must also import from it, or TypeScript reports that the module cannot be found:
```ts
import type { ClientActivityResult } from '@prisma/orm-postgres/components/runtime';
declare module '@prisma/orm-postgres/components/runtime' {
interface ClientActivityRegistry {
'report-run': { result: { readonly emails: readonly string[] } };
}
}
export type ReportResult = ClientActivityResult<'report-run'>;
```
The registry types the handle side only: `call.intercepted.result` and `call.end({ result })`. In middleware hooks the result of a client activity is `unknown`, even after `result.kind === 'report-run'`, because the hook types must accept unregistered kinds; check or cast the value there.
**Start, use and end it.** Pass a function that derives the content hash from the plan with `planContentHashOn`. The runtime calls it only when a middleware calls `activity.contentHash()`, and once per activity. It equals the digest `ctx.contentHash(exec)` returns for the statement's execution plan only when `beforeCompile` gives the same result for the plan in both places, no `beforeEncode` hook changes the parameters, and the codecs encode deterministically. (For `planContentHashOn`, `beforeCompile` runs with the scope and parent of the queryable it is called on and with fresh state, so on the runtime itself it has no parent. For the statement, it runs beneath the handle, with the state its `activityStarted` hooks wrote.) A cache should key on the activity's own content hash and not rely on the two being equal. End every handle, in a `finally`, or `close()` waits for it forever:
```ts
import { planContentHashOn, startActivityOn } from '@prisma/orm-postgres/family-runtime';
import type { SqlQueryPlan } from '@prisma/orm-postgres/relational-core/plan';
import type { PostgresClient } from '@prisma/orm-postgres/runtime';
async function runReport(db: PostgresClient<Contract>, plan: SqlQueryPlan<{ email: string }>) {
const runtime = db.runtime();
const call = await startActivityOn(runtime, {
kind: 'report-run',
contentHash: () => planContentHashOn(runtime, plan),
});
let outcome: 'succeeded' | 'failed' = 'failed';
let result: { readonly emails: readonly string[] } | undefined;
try {
if (call.intercepted) {
outcome = 'succeeded';
return call.intercepted.result.emails;
}
const rows = await call.query(plan).toArray();
result = { emails: rows.map((row) => row.email) };
outcome = 'succeeded';
return result.emails;
} finally {
await call.end({ outcome, ...(result === undefined ? {} : { result }) });
}
}
```
Statements sent through `call` are the activity's children. `call.transaction()` starts a transaction beneath it, on a connection it owns; it is absent when the handle was opened under a transaction. `startActivity` on the handle nests another activity.
**Serve it from middleware.** `intercept.onActivity(activity: ClientActivity, ctx)` runs after `activityStarted`. The first handler that returns `{ result }` wins, and the handle's `intercepted` is set. The ended result carries `source: 'middleware'` when the client then ends without a result:
```ts
import type { SqlMiddleware } from '@prisma/orm-postgres/family-runtime';
const served = new Map<string, unknown>();
const reportCache: SqlMiddleware = {
name: 'report-cache',
familyId: 'sql',
intercept: {
async onActivity(activity) {
if (activity.kind !== 'report-run') return undefined;
const hit = served.get(await activity.contentHash());
return hit === undefined ? undefined : { result: hit };
},
},
async activityEnded(activity, result) {
if (activity.kind !== 'report-run' || result.kind !== 'report-run') return;
if (result.outcome === 'succeeded' && result.source === 'client' && result.result !== undefined) {
served.set(await activity.contentHash(), result.result);
}
},
};
```
### `orm-annotations-on-the-call`
The statements an ORM call sends carry only the reserved keys the ORM sets. A middleware that reads an ORM annotation from a statement plan (`handle.read(plan)` or `plan.meta.annotations`) finds nothing. Read it from the call, in `activityStarted(activity)` or `intercept.onActivity(activity)`:
```ts
const audit: SqlMiddleware = {
name: 'audit',
familyId: 'sql',
async activityStarted(activity) {
if (activity.kind !== 'orm-call') return;
const actor = auditAnnotation.read(activity);
if (actor) log(actor);
},
};
```
A middleware can answer the call through `intercept.onActivity`. The value must have the shape the terminal method returns, or the ORM rejects with `ORM.INTERCEPTED_RESULT_INVALID`.
### `cache-store-holds-cached-values`
A cache store for `@internal/middleware-cache` holds `CachedValue`, which is `{ kind: 'statement-rows', rows }` or `{ kind: 'call-result', result }`, instead of `CachedRows`. `CacheStore` and `CacheEntry` default their value type to `CachedValue`, and `createCacheMiddleware` accepts only a `CacheStore<TMeta, CachedValue>`:
```ts
import type { CachedValue, CacheStore } from '@internal/middleware-cache';
const store: CacheStore<unknown, CachedValue> = {
// get, set and unset as before; value is now { kind: 'statement-rows', rows } or { kind: 'call-result', result }
};
```
`get` must give back a value equivalent to the one `set` was given, `kind` included. An ORM call's result holds decoded values such as `Date`, `bigint`, Temporal values and extension class instances, so a store that serializes needs a serializer that rebuilds them; JSON does not. `createInMemoryCacheStore` takes only the meta type now and always holds `CachedValue`: drop its second type argument.
### Activity sources on connections, runtimes and queryables
Activities start through an `activitySource` member of a queryable. The member is keyed by a symbol, so the public queryable types do not show it, and `startActivityOn` and `planContentHashOn` read it. The SQL ORM starts its `orm-call` activity on the queryable it holds, through this member only, and sends every statement through the activity's handle. A terminal method on a queryable without an activity source fails with `ORM.ACTIVITY_SOURCE_MISSING`: streaming terminals throw when called, and the others reject.
#### `runtime-connections-carry-an-activity-source`
Inside a `SqlRuntimeBase` subclass, a connection-like object over a raw connection gets its activity members from the base class. Add `ActivitySourceTarget` to the object's declared type. The public `RuntimeConnection` type does not require the member.
```ts
const session: RoleSession & TransactionStartTarget & ActivitySourceTarget = {
[transactionStart]: createTransactionStart(),
[activitySource]: self.connectionActivitySource(conn, () => session),
// query, execute, transaction, release, destroy as before
};
```
`activitySource` and `ActivitySourceTarget` come from `@prisma/orm-family-sql/runtime`. `transactionStart`, `createTransactionStart` and `TransactionStartTarget` still come from `@prisma/orm-family-sql/runtime/internal/prepared-query`.
#### `orm-runtime-objects-carry-an-activity-source`
`db.runtime()` passed straight to `orm()` already carries an activity source. A facade, such as `postgres()`, that builds its own runtime object for the ORM, such as one that looks up its runtime on every call, gives it the member:
```ts
import { activitySource, delegatingActivitySource } from '@prisma/orm-family-sql/runtime';
import { orm } from '@prisma/orm-postgres/orm-client';
const client = orm({
runtime: {
query: (plan) => getRuntime().query(plan),
execute: (plan) => getRuntime().execute(plan),
connection: () => getRuntime().connection(),
[activitySource]: delegatingActivitySource(() => getRuntime()),
},
context,
});
```
Do the same for the ORM object inside `db.transaction(fn)`, with `delegatingActivitySource(() => txCtx)`. Do not add plain `startActivity` or `planContentHash` members instead: `findActivitySource` and `startActivityOn` read only the `activitySource` member, so an object with only the plain members starts no activity.
If the facade sends its statements through sessions of its own, such as role-bound sessions, the activity's statements and transaction must go through them too. Give an activity source that does so. The plain runtime's source would run the activity's statements without the session.
#### `queryable-carries-the-activity-source`
A wrapper that copies a runtime, connection or transaction to intercept its `query` and `execute`, for example to record plans, also copies the `[activitySource]` member, so ORM statements bypass the wrapper. Wrap the activity source too: return a source whose `startActivity` wraps each handle the same way, including the transaction its `transaction()` returns. A custom queryable passed to the ORM must carry an activity source. A test stub can use `delegatingActivitySource(() => realRuntime)`, or a source whose handles send their statements to the stub.
### Pass a parent when beginning or starting a transaction
#### `begin-transaction-on-takes-a-parent`
`SqlRuntimeBase.beginTransactionOn(connection, owner)` takes a third argument, `parent`: the activity the transaction starts beneath, or `undefined`. Pass `undefined` where you began a transaction before:
```ts
// before
return self.beginTransactionOn(conn, session);
// after
return self.beginTransactionOn(conn, session, undefined);
```
#### `transaction-activity-takes-a-parent`
`startTransactionActivity` requires `parent`:
```ts
// before
await startTransactionActivity({ middleware, context, holdFinishedChildren });
// after
await startTransactionActivity({ middleware, context, parent: undefined, holdFinishedChildren });
```
An `ActivityParent` you implement yourself has `acceptsChildren()`, and its `adopt(child)` returns an `ActivityChildSlot` (`statementFailed()`, `ended(own)`, `transactionAcceptsChildren()`) instead of an end callback. `ActivityParent.activity` is an `ActivityBase`. A transaction stops accepting children with `stopAcceptingChildren()`, and adopting beneath a parent that no longer accepts children throws `RUNTIME.ACTIVITY_NOT_ACCEPTING_CHILDREN` instead of an internal error. Most runtimes only pass the parent they were given to `streamStatement`, `executeStatement`, `startTransactionActivity` or `startClientActivity` and need no change.
## `aggregate-operation-using-reserved`
A collection's new `using` member holds the collection methods that packages contribute for the model. Aggregate operations become reducer methods on the same collection, so a contributed aggregate operation named `using` would collide with it. `orm()` refuses such an operation when the client is built, with `ORM.AGGREGATE_OPERATION_RESERVED`, as it refuses `with` and `fragment`. Rename the operation in the extension's aggregate descriptors, and in the operation types the extension's contracts reference.
## Codecs read wire values and data type values
### `codecs-read-wire-and-data-type-values`
A codec converts between three forms of a value: the application's value, the value the database driver sends and returns (the wire value), and the value a contract stores, now a `DataTypeValue` of the codec's data type. Four methods replace the old four:
| Before | After | Converts |
| --- | --- | --- |
| `encode(value, ctx)` | `toWire(value, ctx)` | application value to wire value |
| `decode(wire, ctx)` | `fromWire(wire, ctx)` | wire value to application value |
| `encodeJson(value)` | `toDataTypeValue(value)` | application value to stored value |
| `decodeJson(json)` | `fromDataTypeValue(value)` | stored value to application value |
`toDataTypeValue` returns a `DataTypeValue`, and `fromDataTypeValue` receives one; its `value` is the JSON the contract stores. The data type has already read that JSON with its `read` function under the column's parameters, so `fromDataTypeValue` does not check it again. A codec builds the value it hands over with `this.dataTypeValueOf(json)`, which calls the data type's `valueFrom` and so refuses JSON the type does not hold. To make that possible, the codec's constructor passes its data type and its parameters to `CodecImpl`:
```ts
// before
export class PgVectorCodec extends CodecImpl</* … */> {
constructor(descriptor: AnyCodecDescriptor, length: number) {
super(descriptor);
this.length = length;
}
async encode(value: number[], _ctx: CodecCallContext): Promise<string> {
this.assertVector(value, 'RUNTIME.ENCODE_FAILED');
return `[${value.join(',')}]`;
}
async decode(wire: string, _ctx: CodecCallContext): Promise<number[]> {
return parseVector(wire);
}
encodeJson(value: number[]): JsonValue {
this.assertVector(value, 'RUNTIME.ENCODE_FAILED');
return [...value];
}
decodeJson(json: JsonValue): number[] {
if (!Array.isArray(json) || json.length !== this.length) return this.refuseJson(json);
return json.map(Number);
}
}
// after
import type { DataTypeValue } from '@prisma/orm-framework/components/codec';
import { pgvectorVector } from './data-types';
export class PgVectorCodec extends CodecImpl</* … */> {
constructor(descriptor: AnyCodecDescriptor, length: number) {
super(descriptor, pgvectorVector, { length });
this.length = length;
}
async toWire(value: number[], _ctx: CodecCallContext): Promise<string> {
this.assertVector(value, 'RUNTIME.ENCODE_FAILED');
return `[${value.join(',')}]`;
}
async fromWire(wire: string, _ctx: CodecCallContext): Promise<number[]> {
return parseVector(wire);
}
fromDataTypeValue(value: DataTypeValue<readonly number[]>): number[] {
return [...value.value];
}
toDataTypeValue(input: number[]): DataTypeValue {
this.assertVector(input, 'RUNTIME.ENCODE_FAILED');
return this.dataTypeValueOf([...input]);
}
}
```
A codec without parameters passes only its data type: `super(descriptor, pgJsonb)`. A descriptor whose codec takes parameters passes them from `factory`:
```ts
// before
override factory(_params: GeometryParams) {
return () => new PostgisGeometryCodec(this);
}
// after
override factory(params: GeometryParams) {
return () => new PostgisGeometryCodec(this, params ?? {});
}
```
`DataTypeValue`, `DataTypeReader` and `refuseJsonValue` are exported from `@prisma/orm-framework/components/codec`, beside `CodecImpl`.
A Mongo codec made with `mongoCodec()` from `@prisma/orm-family-mongo/codec` takes the four options under the new names, and names its data type in a required `dataType` option, with the column's parameters in an optional `params`. Its `toDataTypeValue` returns a `DataTypeValue` too, which it builds with `dataType.valueFrom(json, params)`. It may be left out when the application value is JSON, and `fromDataTypeValue` may be left out only when the application type is exactly `JsonValue`. Declare the data type with `mongoDataType` from `@prisma/orm-family-mongo/contract/data-type`, and register it in the pack's `dataTypes` beside the codec:
```ts
// before
import { Long } from 'bson';
import { mongoCodec } from '@prisma/orm-family-mongo/codec';
import { decodeJsonString } from '@prisma/orm-framework/components/codec';
import type { JsonValue } from '@prisma/orm-framework/contract/types';
export const moneyCodec = mongoCodec({
typeId: 'acme/money@1',
encode: (value: bigint) => Long.fromBigInt(value),
decode: (wire: Long) => wire.toBigInt(),
encodeJson: (value: bigint) => value.toString(),
decodeJson: (json: JsonValue) => BigInt(decodeJsonString('acme/money@1', json)),
});
// after
import { Long } from 'bson';
import { mongoCodec } from '@prisma/orm-family-mongo/codec';
import { mongoDataType } from '@prisma/orm-family-mongo/contract/data-type';
import { type DataTypeValue, readJsonMatching } from '@prisma/orm-framework/components/codec';
export const acmeMoney = mongoDataType('acme/money', {
bsonTypes: ['long'],
read: (json) => readJsonMatching('acme/money', json, /^-?\d+$/, 'decimal integer text'),
});
export const moneyCodec = mongoCodec({
typeId: 'acme/money@1',
dataType: acmeMoney,
toWire: (value: bigint) => Long.fromBigInt(value),
fromWire: (wire: Long) => wire.toBigInt(),
toDataTypeValue: (value: bigint) => acmeMoney.valueFrom(value.toString(), {}),
fromDataTypeValue: (value: DataTypeValue<string>) => BigInt(value.value),
});
```
### `data-types-own-parameter-limits`
A data type now reads every value a contract stores, with a `read` function that receives the JSON and the column's parameters. It returns the JSON when the type holds it and refuses it with `refuseJsonValue` when it does not. `read` is required: a data type that declared none before declares one now. It replaces `toCanonicalForm`, on the data type or on a codec descriptor, and the checks a codec made in `decodeJson` against its parameters move into it:
```ts
// before
export const pgvectorVector = sqlDataType('pgvector/vector', {
params: pgvectorVectorParams,
texts: [{ text: 'vector({length})', written: true, catalog: true }],
toCanonicalForm: vectorCanonicalForm,
});
// after
const readVector: DataTypeReader = (json, params) => {
const length = typeof params['length'] === 'number' ? params['length'] : undefined;
const expected =
length === undefined ? 'an array of finite numbers' : `an array of ${length} finite numbers`;
if (!Array.isArray(json) || (length !== undefined && json.length !== length)) {
return refuseJsonValue('pgvector/vector', expected, json);
}
if (!json.every(isFiniteNumber)) return refuseJsonValue('pgvector/vector', expected, json);
return json;
};
export const pgvectorVector = sqlDataType('pgvector/vector', {
params: pgvectorVectorParams,
read: readVector,
texts: [{ text: 'vector({length})', written: true, catalog: true }],
});
```
A data type that had neither `toCanonicalForm` nor parameter checks gets a `read` that accepts the JSON it stores, as PostGIS's geometry now reads HEXEWKB text:
```ts
// before
export const postgisGeometry = sqlDataType('postgis/geometry', { params, texts });
// after
const readGeometry: DataTypeReader = (json, params) => {
const hex = readJsonMatching('postgis/geometry', json, HEX_TEXT, 'a HEXEWKB string');
const srid = params['srid'];
if (typeof srid !== 'number' || sridOf(hex) === srid) return json;
return refuseJsonValue('postgis/geometry', `a HEXEWKB string of a geometry with SRID ${srid}`, json);
};
export const postgisGeometry = sqlDataType('postgis/geometry', { params, texts, read: readGeometry });
```
A parameter that limits values, such as a geometry column's SRID, is checked the same way, in the data type's `read`. A contract that stores a value the type refuses no longer loads, so a stored default that broke the limit is reported when the contract is read, not when a query runs.
### `json-helpers-renamed`
The helpers that read a value from JSON now say so, and the one that writes a float says what it returns. Rename each call:
| Before | After |
| --- | --- |
| `decodeJsonString` | `readJsonString` |
| `decodeJsonMatching` | `readJsonMatching` |
| `decodeJsonInteger` | `readJsonInteger` |
| `decodeJsonIntegerText` | `readJsonIntegerText` |
| `decodeJsonFloat` | `readJsonFloat` |
| `decodeJsonBoolean` | `readJsonBoolean` |
| `encodeJsonFloat` | `floatToJson` |
They take the same arguments. `ToCanonicalForm` and `canonicalFormOf` are removed; a data type's `read` (`data-types-own-parameter-limits`) does their work.
### `stored-form-helpers-renamed`
The text a contract stores for a value is its stored form, and the helpers that read or write it now say so. They are exported from `@prisma/orm-family-sql/contract/data-type-support` and `@prisma/orm-family-sql/contract/sql-expression` as before. Rename each use:
| Before | After |
| --- | --- |
| `canonicalDateTime` | `dateTimeStoredText` |
| `CanonicalDateTimeOptions` | `DateTimeStoredTextOptions` |
| `canonicalNumeralText` | `storedNumeralText` |
| `sqlTextFromCanonical` | `sqlTextFromStored` |
| `canonicalSqlText` | `storedSqlText` |
They take the same arguments and return the same values.
### `projections-read-by-from-wire`
An include and an aggregate now read each column of their JSON result with the codec's `fromWire`, as a plain read does. A descriptor's `jsonProjection` therefore writes the value the driver returns for an ordinary row, not the codec's stored JSON form. For a type the database prints as text, that is usually the text:
```ts
// before
protected override jsonProjection(expression: ProjectionExpr): ProjectionExpr {
return FunctionCallExpr.of('array_to_json', [
CastExpr.as(CastExpr.as(expression, 'real[]'), 'float8[]'),
]);
}
// after
protected override jsonProjection(expression: ProjectionExpr): ProjectionExpr {
return CastExpr.as(expression, 'text');
}
```
A projection that returned the column unchanged may need the cast too: a JSON constructor given a `jsonb` document that is a string, or a PostGIS geometry, writes something other than the text a row carries.
### `empty-result-json-renamed-empty-result-wire`
An aggregate descriptor that declares a value for an empty input names it `emptyResultWire` instead of `emptyResultJson`. It holds the value the driver would return for that result, and the result codec reads it with `fromWire`. Only the name changes where the value was already what the driver returns:
```ts
// before
{ ...sumIntegers, nullable: false as const, emptyResultJson: '0' }
// after
{ ...sumIntegers, nullable: false as const, emptyResultWire: '0' }
```
## Family codec templates and enum blocks
### `target-codec-adapters-take-a-factory`
A family codec template names no data type, so it cannot build a codec: the codec's constructor needs the data type (`codecs-read-wire-and-data-type-values`). The target's adapter builds it, through a `factory` that every `postgresCodec` and `sqliteCodec` call now passes. The factory receives the adapted descriptor, the `dataType` the call names, and the column's parameters:
```ts
// before
import { postgresCodec } from '@prisma/orm-postgres/target/codec-descriptor';
export const myCharDescriptor = postgresCodec(myCharTemplate, {
dataType: pgChar,
jsonProjection: (expression) => expression,
factory: (descriptor, params) => () => new MyCharCodec(descriptor, params.length),
});
export const myTextDescriptor = postgresCodec(myTextTemplate, {
dataType: pgText,
jsonProjection: (expression) => expression,
});
// after
import { postgresCodec } from '@prisma/orm-postgres/target/codec-descriptor';
export const myCharDescriptor = postgresCodec(myCharTemplate, {
dataType: pgChar,
jsonProjection: (expression) => expression,
factory: (descriptor, dataType, params) => () => new MyCharCodec(descriptor, dataType, params),
});
export const myTextDescriptor = postgresCodec(myTextTemplate, {
dataType: pgText,
jsonProjection: (expression) => expression,
factory: (descriptor, dataType) => () => new MyTextCodec(descriptor, dataType),
});
```
A call that passed no `factory` used the template's own codec; pass a factory that builds that codec class with the data type. `sqliteCodec` takes the same `factory`, and no longer takes `renderTypes: false`; delete the option.
### `family-codec-templates-render-no-literals`
```ts
// before: the template renders, and postgresCodec copied it
export class MyTextTemplate extends CodecDescriptorTemplateImpl<void> {
override renderValueLiteral(value: JsonValue): string | undefined {
return renderTsLiteral(value);
}
// ...
}
export const myTextDescriptor = postgresCodec(myTextTemplate, {
dataType: pgText,
jsonProjection: (expression) => expression,
});
// after: the template renders nothing, and the target passes the renderer
export class MyTextTemplate extends CodecDescriptorTemplateImpl<void> {
// ...
}
export const myTextDescriptor = postgresCodec(myTextTemplate, {
dataType: pgText,
renderValueLiteral: renderTsLiteral,
jsonProjection: (expression) => expression,
factory: (descriptor, dataType) => () => new MyTextCodec(descriptor, dataType),
});
```
Without the option, the adapted codec renders no literal, so an enum or value set on it is typed as the codec's whole output type.
### `enum-block-written-values`
1. Read `block.writtenValues?.[key]` in place of `block.numberTexts?.[key]`. A number entry is `{ kind: 'number', text }`, with the text as written.
2. Rename a `readWrittenNumber` hook to `readWrittenValueForCodec`, and read `written` in place of `text`.
3. Give every refused `WrittenValueReading` a `code`, such as `PSL_VALUE_TYPE_INCOMPATIBLE`.
```ts
// before
readWrittenNumber: ({ text, codecId, subject }) => ({ ok: false, message: `${subject}: refused` }),
// after
readWrittenValueForCodec: ({ written, codecId, subject }) => ({
ok: false,
code: 'PSL_VALUE_TYPE_INCOMPATIBLE',
message: `${subject}: refused`,
}),
```
The detection matches every file that names `WrittenValueReading`, including one that already sets `code`. Check each refused reading.
## Columns are compared by their data type
### `type-lookups-one-framework-type`
The pair of a codec lookup and a data type lookup has one type in the framework. Replace `SqlTypeLookups` and `MongoTypeLookups` with `TypeLookups`:
```ts
// Before
import type { SqlTypeLookups } from '@prisma/orm-family-sql/contract/data-type';
function storageTypeOf(codecId: string, lookups: SqlTypeLookups) {}
// After
import type { TypeLookups } from '@prisma/orm-framework/components/codec';
function storageTypeOf(codecId: string, lookups: TypeLookups) {}
```
`TypeLookups` asks only for `codecLookup.descriptorFor` and `dataTypeLookup.get`. Where your code needs the whole lookups, name them as type arguments: `TypeLookups<CodecLookupWithDescriptors, DataTypeLookup>`.
### `type-name-normalisers-removed`
`normalizeSchemaNativeType` and `normalizeSqliteNativeType` are gone. If you used them to decide whether two columns have the same type, compare the columns' data types instead, with `sqlColumnTypesEqual(a, b)` from `@prisma/orm-family-sql/schema-ir/types`: two columns have the same type when both have a `storedType`, the two are one data type, their `storedTypeParams` are equal, and both or neither are lists.
### `introspected-type-text`
```ts
// Before
const { nativeType, many, resolvedNativeType } = introspectedNativeType({
formattedType, dataType: row.data_type, udtName, characterMaximumLength, numericPrecision, numericScale,
});
// After
const { typeText, many } = introspectedTypeText({
formattedType, informationSchemaType: row.data_type, udtName, characterMaximumLength, numericPrecision, numericScale,
});
```
On the database side, `typeText` is the type as introspection reads it, such as `integer` or `timestamp(3) with time zone`, not a normalised name. On Postgres an enum is named as a contract names it (`mood`, or `app.status` outside `public`) and a type off the search path keeps its schema (`extensions.vector(3)`); on SQLite the declared type is lower-cased. On the contract side it is the name a migration writes.
### `schema-ir-column-type-text`
```ts
// Before
new SqlColumnIR({ name: 'id', nativeType: 'int4', nullable: false, resolvedNativeType: 'int4' });
// After
import { pgInt4 } from '@prisma/orm-postgres/target/data-types';
new SqlColumnIR({
name: 'id',
typeText: 'int4',
nullable: false,
storedType: pgInt4,
storedTypeParams: {},
});
```
A column with no `storedType` is one no data type claims, and it never compares equal to another, so a column a test builds by hand to compare needs `storedType` and `storedTypeParams`: the data type the column is stored as (the type its codec represents, unless that type is stored as another), and that type's parameters in normal form, such as `{ precision: 10, scale: 0 }` for `numeric(10)`. `storedTypeParams` is only for comparing; DDL writes the parameters as written, from `codecRef.typeParams`.
Read `column.typeText` where you read `column.nativeType`, and `column.storedType` where you read `column.dataType`; a reader that needs the type the codec represents gets it from `column.codecRef`. Code that compared `resolvedNativeType` compares types with `sqlColumnTypesEqual` instead. Code that read `codecBaseNativeType` to write DDL writes the type from `column.storedType` and the parameters in `column.codecRef.typeParams`, with `renderSqlTypeName` from `@prisma/orm-family-sql/contract/data-type`.
### `default-resolver-takes-the-column`
A resolver reads the column's `many` or `typeText` instead of a type name, and reads a default with the new readers:
```ts
// Before
import type { DefaultResolver } from '@prisma/orm-family-sql/family/control';
import { parsePostgresDefault } from '@prisma/orm-postgres/target/default-normalizer';
const resolve: DefaultResolver = (def, resolvedNativeType) =>
def.kind === 'function' ? (parsePostgresDefault(def.expression, resolvedNativeType) ?? def) : def;
// After
import type { DefaultResolver } from '@prisma/orm-family-sql/family/control';
import { readPostgresDefault } from '@prisma/orm-postgres/target/default-normalizer';
const resolve: DefaultResolver = (def, column) => {
if (def.kind !== 'function') return def;
const read = readPostgresDefault(def.expression, column.many);
return read.kind === 'function' ? read : def;
};
```
`readPostgresDefault` and `readSqliteDefault` always return a value. Where `parsePostgresDefault` or `parseSqliteDefault` returned `undefined` for an expression they could not read, the new readers return `{ kind: 'function', expression }` with the expression as reported. A literal is `{ kind: 'literal', literal }`, which the column's codec reads, not a `ColumnDefault`. `readSqliteDefault` takes the column's declared type, as `column.typeText` gives it.
A control adapter that implements `SqlControlAdapter` deletes its `normalizeDefault` member. Nothing called it.
### `control-adapters-take-declared-data-types`
```ts
// Before
import { PostgresControlAdapter } from '@prisma/orm-postgres/adapter/control';
const adapter = new PostgresControlAdapter(
createPostgresBuiltinCodecLookup(),
createPostgresBuiltinDataTypeLookup(),
);
// After
import { PostgresControlAdapter } from '@prisma/orm-postgres/adapter/control';
import { postgresDataTypes } from '@prisma/orm-postgres/target/data-types';
const adapter = new PostgresControlAdapter(
createPostgresBuiltinCodecLookup(),
createPostgresBuiltinDataTypeLookup(),
postgresDataTypes,
);
```
```ts
// Before
import { SqliteControlAdapter } from '@prisma/orm-sqlite/adapter/control';
const adapter = new SqliteControlAdapter(createSqliteBuiltinCodecLookup());
// After
import { SqliteControlAdapter } from '@prisma/orm-sqlite/adapter/control';
import { sqliteDataTypes } from '@prisma/orm-sqlite/target/data-types';
const adapter = new SqliteControlAdapter(createSqliteBuiltinCodecLookup(), sqliteDataTypes);
```
The adapter resolves each column the database reports to the first of these data types that claims it, in the order given. When your stack has extensions that declare data types, such as pgvector, pass `stack.declaredDataTypes.map(({ type }) => type)`, or build the adapter with the adapter descriptor's `create(stack)`, which does that for you. With only the target's types, a column of an extension's type is reported as one no data type declares.
### `resolved-sql-type-carries-the-data-type`
```ts
// Before
const resolved = resolveReportedSqlType(reported, dataTypes);
const type = dataTypes.find(({ id }) => id === resolved?.dataType);
// After
const resolved = resolveReportedSqlType(reported, dataTypes);
const type = resolved?.dataType;
```
The resolver already found the data type, so there is no second lookup, and no case where the id names no type in the list.
### `alter-type-postcheck-by-data-type`
```ts
// Before
const checkText = alterTypeCheckText('"app"."status"', dataTypes, where); // 'app.status'
// After
const check = alterTypeCheck('"app"."status"', dataTypes, where);
// { kind: 'identity', typeName: '"app"."status"', many: false }
```
A check of kind `catalog-text` holds the text `buildExpectedFormatType` gave for the same type. Code that read `AlterColumnTypeCall`'s `checkType` as a string reads `call.checkType.text` for a `catalog-text` check and `call.checkType.typeName` for an `identity` check.
## Type-constructor arguments are values of data types
### `value-arguments-name-a-data-type`
An argument is read as a value of its data type, through the same read and cast as an attribute argument (ADR 254), in PSL and in the TypeScript builder. Name the data type the argument takes:
```ts
// Before
Vector: {
kind: 'typeConstructor',
args: [{ kind: 'number', name: 'length', integer: true, minimum: 1, maximum: 16000 }],
output: { codecId: 'pg/vector@1', typeParams: { length: { kind: 'arg', index: 0 } } },
},
// After
Vector: {
kind: 'typeConstructor',
args: [{ kind: 'value', dataType: 'pg/int4', name: 'length' }],
output: { codecId: 'pg/vector@1', typeParams: { length: { kind: 'arg', index: 0 } } },
},
```
Move any bound the argument carried to the data type's `params` schema, which the codec's `paramsSchema` references. A string argument becomes a value of the data type it names; an argument that holds SQL, such as a type name, is a value of `sql/expression`, written ``sql`...` ``. The data type must be registered in the stack and written by some authoring entry, itself or through a type it casts from: assembly refuses a value argument otherwise, with `CONTRACT.DATA_TYPE_UNREGISTERED` or `CONTRACT.DATA_TYPE_NOT_WRITABLE`. A type constructor argument still declared with another kind, `option` and `object` included, is refused when the stack is assembled, with `CONTRACT.PACK_CONTRIBUTION_INVALID` naming the constructor and the argument.
A field preset's `number`, `option` and `object` arguments are unchanged; convert only a type constructor's arguments. Detection cannot tell the two apart, so it also reports a field preset's arguments: leave those as they are, including the properties of an `object` argument.
### `value-argument-types-in-typescript`
A target or extension that registers a data type its arguments take declares the TypeScript value in its pack's type, beside `__codecTypes`:
```ts
export const myTargetPack: typeof myTargetPackBase & {
readonly __codecTypes?: CodecTypes;
readonly __dataTypeArguments?: {
readonly 'my/int': { readonly argument: number; readonly parameter: number };
};
} = myTargetPackBase;
```
`argument` is what an author passes to the helper, and `parameter` is the type parameter the data type's reader gives for it, which the column's type is built from. An extension whose arguments take the target's types, such as `pg/int4`, declares nothing.
To type a descriptor of a value argument on its own, use `AuthoringValueArgument` from `components/authoring`; it now includes `name` and `optional`.
### `psl-and-print-helpers-take-data-types`
Pass the stack's `DataTypeSupport` to `mapPslHelperArgs({ ..., dataTypes })` and `instantiatePslFieldPreset({ ..., dataTypes })`. Replace `parsePslPositionalArgs` with the values of the call you built; to write a call's arguments back as PSL text, use `writtenTypeArguments(descriptor, args, dataTypes)`. A SQL target spreads `sqlTargetTypeConstructors({ sizeType: '<its size data type>' })` from `@prisma/orm-family-sql/family/pack` into its type namespace, and passes the same `sizeType` to `temporalCodecPresetWithPrecision`. Import `temporalCodecPresetWithPrecision` and `temporalStringAuthoringPresets` from `family/pack` too; `family/control` no longer exports them. The size type is the data type of a length or a precision: `pg/int4` on Postgres.
Import `assembleAuthoringDataTypes` from `components/authoring` instead of `components/control`. To build the data types a set of components reads values with, call `assembleDataTypeSupport(components, dataTypeOfCodec)`; it refuses a duplicate data type or entry, and a value argument whose data type is unregistered or that nothing writes.
### `composed-helpers-take-no-data-type-lookup`
Drop `dataTypeLookup` from the options of `createComposedAuthoringHelpers`:
```ts
// Before
createComposedAuthoringHelpers({ family, target, extensions, codecLookup, dataTypeLookup });
// After
createComposedAuthoringHelpers({ family, target, extensions, codecLookup });
```
The helpers read value arguments, and build entity helpers, with the data types the packs register. An argument whose data type no pack in the stack registers now fails with `CONTRACT.DATA_TYPE_UNREGISTERED`. Register it, with an `authoring.dataTypes` entry, in the pack that owns it. An extension whose arguments take a target's data type, such as `pg/int4`, registers nothing: the target already does, and a second registration is refused as a duplicate.
### `helper-codec-invariants`
Rename the call and pass the type constructors and field presets as `helpers`:
```ts
// Before
enforceDataTypeInvariants({ lookup, declaredTypes, codecs, authoringEntries, constructors, codecDescriptorFor });
// After
enforceHelperCodecInvariants({ lookup, helpers, codecDescriptorFor });
```
The checks on data types, entries, casts and written forms now run when you build data types with `assembleDataTypeSupport(components, dataTypeOfCodec)` from `components/authoring`; call it with the same components instead of passing `declaredTypes`, `codecs` and `authoringEntries`.
## Client generators are generator functions
`@default(fn())` now means a database default only. A value the client generates is written `@onCreate(fn())` or `@onUpdate(fn())`, and in TypeScript `.onCreate(gen.fn())`. The function resolves through the stack's generator function registry, by the column's codec.
### `default-functions-lower-to-database-defaults-only`
A default function whose `lower` returned `{ kind: 'execution', generated }` no longer compiles. Move it to a generator function on the pack ref (`src/exports/pack.ts`) and the control descriptor of the extension. One component declares the function's `signature`, a `FuncCallSig` as for a default function, or a function of the stack's data types that returns one (`generator-signature-from-data-types`). A binding maps codec ids to a `resolve` that turns the call's arguments and the column into a generator, and to `printArguments`, the reverse that `contract print` uses. `singleGeneratorBinding` from `@prisma/orm-framework/components/components` builds a binding for a function that takes no arguments:
```ts
// Before
const slugidEntry: ControlMutationDefaultEntry = {
signature: { documentation: 'Generates a slug identifier when a field value is omitted.' },
lower: () => ({
ok: true,
value: { kind: 'execution', generated: { kind: 'generator', id: 'slugid' } },
}),
usageSignatures: ['slugid()'],
};
const slugidPack = {
// ...
controlMutationDefaults: {
defaultFunctionRegistry: new Map([['slugid', slugidEntry]]),
generatorDescriptors: [{ id: 'slugid', applicableCodecIds: ['pg/text@1'] }],
},
};
// After
import {
type ExtensionPackRef,
singleGeneratorBinding,
} from '@prisma/orm-framework/components/components';
const slugidPack = {
// ...
controlMutationDefaults: {
defaultFunctionRegistry: new Map(),
generatorDescriptors: [{ id: 'slugid' }],
},
generatorFunctions: [
{
name: 'slugid',
signature: { documentation: 'Generates a slug identifier when a field value is omitted.' },
bindings: [singleGeneratorBinding({ codecIds: ['pg/text@1'], generatorId: 'slugid' })],
},
],
} as const satisfies ExtensionPackRef<'sql', 'postgres'>;
```
Put `generatorFunctions` and the generator descriptors on both the pack ref, which the TypeScript builder reads, and the control descriptor, which `contract emit`, `contract print` and the language server read. Every stack, the TypeScript builders included, checks that each binding produces a generator a descriptor registers. Declare the pack ref with `as const satisfies`, not a type annotation: the `gen` of a `defineContract` callback is built from the packs' declared functions and their signatures, so `gen.slugid()` exists only when the pack ref keeps its literal type. A binding's `resolve` may return only a generator id the binding lists in `generatorIds`; any other id throws `InternalError`. To bind a function another component declares, such as `uuid`, to a codec your extension adds, contribute a binding without a `signature`. Composing a stack fails with `CONTRACT.PACK_CONTRIBUTION_INVALID` when two components declare one function, when a binding names a function no component declares, when two bindings cover one function and codec, when two functions write one generator for one codec, when a binding produces a generator no generator descriptor registers, or when two components register one generator id.
### `sql-family-registers-identifier-generators`
The component that defines a generator registers it, and the SQL family now defines and registers `uuidv4`, `uuidv7`, `cuid2`, `ulid`, `nanoid` and `ksuid`. A SQL target, adapter or extension that registers any of them, for example by spreading `builtinGeneratorRegistryMetadata` into `generatorDescriptors`, must remove those entries; `builtinGeneratorRegistryMetadata` is removed. Two components that register one generator id make every stack fail with `CONTRACT.PACK_CONTRIBUTION_INVALID`: `Generator "uuidv4" is registered by both "sql" and "<your component>". Register each generator once, in the component that defines it.` An extension that binds `uuid` to a codec it adds keeps the binding and registers nothing for it. Register only the generators your component defines, such as `slugid`.
A default function that lowers to a database default, such as one that writes a SQL expression, does not change.
### `applicable-codec-ids-and-build-phases-removed`
`applicableCodecIds` on a generator descriptor is removed: a binding's `codecIds` say which codecs a generator fills, and `contract print` reads the `printArguments` of the binding. `buildPhases` had no caller. Delete both.
## Generator function arguments and column length
### `sql-target-declares-integer-argument-generators`
Add `generatorFunctions` to the target's pack ref and control descriptor, beside `authoring`:
```ts
import {
idGeneratorFunctionBindings,
sqlIntegerArgumentGeneratorFunctions,
} from '@prisma/orm-family-sql/family/pack';
generatorFunctions: [
...sqlIntegerArgumentGeneratorFunctions({ integer: myInt4.id }),
...idGeneratorFunctionBindings({ text: [MY_TEXT_CODEC_ID] }),
],
```
`integer` is the id of your target's 32-bit integer data type. An argument such as the `7` of `uuid(7)` is read and cast as a value of that type, as a column default is, then checked by the function: `uuid` takes 4 or 7, `cuid` takes 2, and `nanoid` takes 2 through 255. The `gen` helpers of a TypeScript contract keep their argument types.
### `generator-signature-from-data-types`
```ts
// Before
const arms = [...defaultFunctionRegistry.entries()].map(([name, entry]) =>
funcCall(name, entry.signature as FuncCallSig),
);
// After
import { defaultFunctionCall } from '@prisma/orm-family-sql/contract-psl/attribute-specs';
const arms = [...defaultFunctionRegistry.entries()].map(([name, entry]) =>
defaultFunctionCall(name, entry, dataTypes),
);
```
A generator function, new in this release (`default-functions-lower-to-database-defaults-only`), is read the same way with `generatorFunctionCall(contribution.name, contribution.signature, dataTypes)` from `@prisma/orm-framework/psl-parser`. A function you declare can keep a plain `FuncCallSig`. Write it as `(dataTypes) => FuncCallSig` when an argument is a value of a data type, read with `dataTypeValue(type, dataTypes)`.
### `generator-function-field-length`
Code that calls the registry passes the column's length:
```ts
import { sqlCodecColumnLength } from '@prisma/orm-family-sql/contract/data-type';
generatorFunctions.resolve(call, {
codecId,
typeParams,
length: sqlCodecColumnLength(dataTypes, codecId, typeParams),
});
```
A binding of your own reads the length:
```ts
const binding: GeneratorFunctionBinding = {
codecIds: ['pg/text@1', 'sql/char@1'],
generatorIds: ['slugid'],
resolve: ({ field }) =>
field.length !== undefined && field.length < 22
? { ok: false, message: `slugid() writes 22 characters, which does not fit a column of ${field.length} characters.` }
: { ok: true, generator: { kind: 'generator', id: 'slugid' } },
printArguments: () => ({ args: {}, text: '' }),
};
```
### `build-contract-definition-takes-lookups`
```ts
// Before
const definition = buildContractDefinition({ family, target, createNamespace, models });
// After
const definition = buildContractDefinition({
family,
target,
createNamespace,
codecLookup,
dataTypeLookup,
models,
});
```
### `arg-binding-spec-label`
```ts
// Before
interpretArgs(args, { name: 'rel', positional, named }, ctx, span, node);
// After
interpretArgs(args, { label: 'Attribute "rel"', positional, named }, ctx, span, node);
```
### `one-of-refuses-data-type-value`
```ts
// Before
type: oneOf(str(), dataTypeValue(pgInt4.id, dataTypes)),
// After: a value of a data type is its own parameter
type: dataTypeValue(pgInt4.id, dataTypes),
```
### `text-data-types`
```ts
authoring: {
textDataTypes: [citext.id],
},
```
## Contract types: one field type generic, and packs typed for `defineContract`
The emitted `contract.d.ts` of every family, and a contract built with `defineContract` in either family, now type their fields with one generic, `FieldTypesFromDomain` from `@prisma/orm-framework/contract/types`. A contract built with `defineContract` now has the same types as the `contract.d.ts` that `prisma contract emit` writes for it. The emitter reads a pack's codecs and descriptors at run time. A contract written in TypeScript has only the pack's types, so a pack states as types what the emitter derives. `QueryOperationTypesFunction` is exported from `@prisma/orm-family-sql/contract/types`, and `ParameterizedOutputTypesFunction` from `@prisma/orm-framework/contract/types`.
### `field-types-expression-removed`
Code written against 8.0.0-rc.18 never had `fieldTypesExpression`, so there is nothing to delete. The framework declares `FieldTypeOverrides` and `ParameterizedOutputTypeFunctions` itself and writes:
```ts
type FieldTypeOverrides = {
readonly public: {
readonly models: { readonly User: { readonly role: { readonly output: 'admin' | 'user'; readonly input: 'admin' | 'user' } } };
readonly valueObjects: { readonly Address: { readonly kind: { readonly output: 'home' | 'work'; readonly input: 'home' | 'work' } } };
};
};
export type FieldOutputTypes = FieldTypesFromDomain<ContractBase['domain']['namespaces'], CodecTypes, ParameterizedOutputTypeFunctions, FieldTypeOverrides, 'output'>;
```
### `field-types-generic-moved`
8.0.0-rc.18 had no `FieldTypesFromDomain`, so code written against it needs no change. Code written against a development build after it imports `FieldTypesFromDomain` and `ValueTypeSide` from `@prisma/orm-framework/contract/types` instead of the SQL family's contract types entry. The generic now types value object and union fields itself, from the namespace's `valueObjects`. Its arguments are `FieldTypesFromDomain<DomainNamespaces, CodecTypes, ParameterizedOutputs, Overrides, Side>`, where `Overrides` is one map keyed like the domain, `[namespace]['models' | 'valueObjects'][owner][field]`, each entry `{ output?, input? }`; `ValueObjectTypesFromDomain` takes the same map. `ColumnTypesFromStorage` stays in the SQL family. For a storage map, apply `StoredValueType` from `@prisma/orm-framework/contract/types` to each entry, as `ColumnTypesFromStorage` does.
### `resolve-field-value-set-renamed`
Rename `EmissionSpi.resolveFieldValueSet` to `resolveStorageValueSet` in a family emitter, and drop any handling of domain enum references from it. The framework emitter now reads a field's value set itself when the field references a domain enum (`valueSet.plane` is `'domain'`), for model and value object fields alike, and no longer calls the hook for those fields. It calls `resolveStorageValueSet` only for other model fields, whose values the family keeps in its storage, such as a SQL column's value set.
### `typescript-contract-pack-type-slots`
A third-party SQL pack is typed fully for a `defineContract` contract only when it declares what an emitted `contract.d.ts` would get from its codecs and descriptors. Nothing breaks for a pack that does none of this; its operations, refined output types and aggregates are missing from a `defineContract` contract's types, as before.
**Query operations: `__queryOperationTypes`.** If your pack contributes query operations and exports a `QueryOperationTypes<CodecTypes>` type for its emitted `contract.d.ts`, declare it as a type-level function of the contract's codec types and add it to the pack metadata's type:
```ts
import type { QueryOperationTypesFunction } from '@prisma/orm-family-sql/contract/types';
export interface QueryOperationTypesForCodecs extends QueryOperationTypesFunction {
readonly queryOperationTypes: QueryOperationTypes<this['codecTypes']>;
}
export const myPackMeta: typeof myPackMetaBase & {
readonly __codecTypes?: CodecTypes;
readonly __queryOperationTypes?: QueryOperationTypesForCodecs;
} = myPackMetaBase;
```
**Aggregates: literal `types.aggregateDescriptors`.** The aggregate result types of a `defineContract` contract are settled from the `types.aggregateDescriptors` of the target, the adapter and every extension, against the contract's codecs, as emission settles them. Only descriptors whose `operation`, input codec ids or traits, output codec id and `nullable` are literal types take part. Declare the list with `as const` and no annotation that widens it:
```ts
export const myAggregateDescriptors = [
{
operation: 'bitOr',
input: { kind: 'codec', codecId: 'pg/int4@1' },
output: { kind: 'codec', codecId: 'pg/int8@1' },
nullable: true,
},
] as const;
```
If you build descriptors with helper functions, make the helpers generic with `const` type parameters and do not use `satisfies` inside them: TypeScript widens a generic helper's result to its constraint when its returned object has a `satisfies` clause. Check the list against the descriptor type where you use it, for example `types: { aggregateDescriptors: myAggregateDescriptors satisfies ReadonlyArray<SqlAggregateDescriptor> }`.
**Index helpers: say what the build writes.** A contract built with `defineContract` types each index from the `IndexConstraint` a helper returns, so code reads it by name and passes it to query operations. `IndexConstraint` takes a third type parameter, the index's `map` name, type and options as the build writes them. If your pack adds an index helper, return `IndexConstraint<FieldNames, Name, { readonly map: Map; readonly type: 'yourType'; readonly options: Options }>` with literal types. When the options name fields that the build turns into column names, give an interface extending `IndexOptionsFunction` from `@prisma/orm-family-sql/contract-ts/contract-builder`, whose `options` reads `this['columnOf']`, the model's field-to-column map. See `fullTextIndex` in the Postgres pack.
**Declaration output: export what a contract names.** An application that exports a contract and builds with `declaration: true` needs TypeScript to name every type the contract's inferred type mentions. Export from your pack's `/pack` entry, as types, the types that end up there: your `CodecTypes`, the output types your type function gives (such as `Vector`), `QueryOperationTypesForCodecs`, `ParameterizedOutputTypesForTypeParams`, and the value types your codec types name (such as a GeoJSON `Geometry`). Naming `CodecTypes` also keeps TypeScript from inlining codec classes with private members, which fails with TS4094. Compile a small application that imports only your published package and the public `@prisma/orm-postgres` with `declaration: true` to check.
### Output types that depend on type parameters: one type function
This covers `parameterized-output-types-declared-once` (SQL packs) and `mongo-parameterized-output-types` (Mongo-family packs). If a codec's output type depends only on the column's type parameters, such as `Vector<3>` for `vector(3)`, write that mapping once, as a type-level function of the type parameters, and export it from your pack's `codec-types` entry:
```ts
import type { ParameterizedOutputTypesFunction } from '@prisma/orm-framework/contract/types';
type OutputTypeMap<TypeParams> = {
readonly 'my/vector@1': TypeParams extends { readonly length: infer N extends number }
? Vector<N>
: never;
};
export interface ParameterizedOutputTypesForTypeParams extends ParameterizedOutputTypesFunction {
readonly outputTypes: OutputTypeMap<this['typeParams']>;
}
```
Name it in two places:
- In the pack metadata's type, as `readonly __parameterizedOutputTypes?: ParameterizedOutputTypesForTypeParams`. A contract built with `defineContract` reads it there.
- In the metadata of the component that contributes the codecs, next to the codec types import, as `types.codecTypes.parameterizedOutputTypes`. The emitted `contract.d.ts` imports it and types each column with type parameters through it:
```ts
types: {
codecTypes: {
import: { package: 'my-pack/codec-types', named: 'CodecTypes', alias: 'MyTypes' },
parameterizedOutputTypes: {
package: 'my-pack/codec-types',
named: 'ParameterizedOutputTypesForTypeParams',
alias: 'MyParameterizedOutputTypes',
},
},
},
```
Declare both. With only `__parameterizedOutputTypes`, the emitted `contract.d.ts` types such a column by the codec's plain output type; with only `types.codecTypes.parameterizedOutputTypes`, a contract built with `defineContract` does. Nothing reports the gap, so check a column on both paths.
Then delete the codec's `renderOutputType`, and the `typeImports` entry that existed only so the emitted file could name its result (such as `Vector`). A codec the map leaves out, or maps to `never` for some type parameters, keeps its plain output type. On SQL, a `renderOutputType` that writes such a type still works for the emitted file but is ignored by `defineContract`; keep `renderOutputType` only for a type that no type function can compute from the type parameters, such as the text of an arktype schema. On Mongo, `renderOutputType` is no longer used for such codecs: Mongo's vector codec no longer implements it, and a Mongo `defineContract` contract ignores it.
```diff
class MyVectorDescriptor extends PostgresCodecDescriptor<{ readonly length: number }> {
override readonly codecId = 'my/vector@1' as const;
- override renderOutputType({ length }: { length: number }) {
- return `Vector<${length}>`;
- }
}
```
A column helper that returns a column spec, built with `column(factory, codecId, typeParams)`, needs no entry: a `defineContract` contract reads the field's output type and codec id from the codec the factory builds.
Export `CodecTypes`, `ParameterizedOutputTypesForTypeParams` and the types they name from the pack's `/pack` entry, so an application that exports a contract can build declarations.
### `psl-interpreter-contract-capabilities`
`interpretPslDocumentToSqlContract` now puts the enabled capabilities you pass in `capabilities`, usually the composed stack's, into the contract's `capabilities`, together with the target's and the extensions'. Before, it used them only to check the schema and left them out of the contract. If you call it directly, expect the adapter's capabilities in the result, and pass the stack's capabilities if you build a contract to compare with one written in TypeScript or emitted by `prisma contract emit`.
## MongoDB fields have stored names
### `mongo-model-storage-fields`
```ts
// before
const keys = Object.keys(model.fields);
const hasId = Object.hasOwn(model.fields, '_id');
// after
import { ownDocumentIdFieldOf, ownStoredNamesOf } from '@internal/mongo-contract';
const keys = Object.values(ownStoredNamesOf(model));
const hasId = ownDocumentIdFieldOf(model) !== undefined;
```
`ownStoredNamesOf(model)` maps each of a model's own fields to its stored name; `valueObjectStoredNamesOf(valueObject)` maps each field of a value object, which has no storage block, to its own name. `storedNamesOf(models, name)` adds the fields a variant inherits from its base, and `modelAndBasesOf(models, name)` walks a model and its bases. A model built by hand, for example in a test, adds `fields` to its storage:
```ts
// before
{ fields: { _id: objectIdField, email: stringField }, relations: {}, storage: { collection: 'users' } }
// after
{
fields: { _id: objectIdField, email: stringField },
relations: {},
storage: { collection: 'users', fields: { _id: { field: '_id' }, email: { field: 'email' } } },
}
```
## MongoDB validator derivation moves to the contract package
### `mongo-validator-derivation-moves-to-mongo-contract`
```ts
// before
import type { FieldValueSets } from '@internal/mongo-contract-psl';
// after
import type { FieldValueSets } from '@internal/mongo-contract';
```
Through the public package, import from `@prisma/orm-family-mongo/contract` instead of `@prisma/orm-family-mongo/contract-psl`.
### `mongo-derive-json-schema-replaced-by-derive-collection-validators`
`deriveJsonSchema` took the fields of one model and ignored its relations, so its validator could differ from the one a contract emits. `deriveCollectionValidators` takes the contract's models and derives the validator of every collection that stores a root model, with each variant as a `oneOf` branch.
```ts
// before
import { deriveJsonSchema } from '@internal/mongo-contract-psl';
const validator = deriveJsonSchema(
ns.models['Account'].fields,
{ codecLookup, dataTypeLookup },
ns.valueObjects,
valueSets,
);
// after
import { collectionControlPoliciesOf, deriveCollectionValidators } from '@internal/mongo-contract';
const validator = deriveCollectionValidators({
models: ns.models,
lookups: { codecLookup, dataTypeLookup },
valueObjects: ns.valueObjects ?? {},
valueSets,
controlPolicies: collectionControlPoliciesOf(contract),
})['accounts'];
```
`ns` is `contract.domain.namespaces[namespaceId]`, and `valueSets` is the storage namespace's `entries.valueSet`, as before. `controlPolicies` is required: only a collection whose control policy is `managed` gets a validator, as in an emitted contract. `collectionControlPoliciesOf(contract)` reads the policies a built contract declares. A call to `derivePolymorphicJsonSchema` becomes the same call: the validator of a root model with variants already has one `oneOf` branch per variant.
## Generated checks can no longer be waived
Prisma 8 adds a check to a list column of a managed table so that its elements cannot be null, and a check to an enum-typed column so that it holds only the enum's values. `@noCheck` in PSL and `.noCheck()` in TypeScript used to skip these checks while the field kept its non-null element type. The client type then said the elements could not be null while the database accepted null. Both are removed, from an extension's contract source as from an application's.
### `psl-nocheck-removed`
Write a list whose elements may be null as `T?[]`:
```prisma
// Before
scopes String[] @default([]) @noCheck(elementNotNull)
// After
scopes String?[] @default([])
```
The database does not change: neither form has a check. The element type in the extension's `contract.d.ts` gains `| null`, so code in the extension and in applications that read the list must handle null elements.
If you want the check instead, delete the attribute and keep `T[]`. An application that installs the extension then gets the check in its next migration of the extension's contract space. Applying it fails if a stored list already holds a null element.
Delete `@noCheck(membership)`. The next migration adds the membership check to the enum column. Applying it fails if a stored value is not one of the enum's values.
A bare `@noCheck` waived every check the column had. Apply both rules above.
On a table the extension does not manage (`@@control(external)` or a contract-level policy other than `managed`), no check is derived either way. Only the element type changes.
Re-emit the bundled contract with the extension's own emit script, and ship the new `contract.json` and `contract.d.ts` together.
### `ts-nocheck-method-removed`
The same rules apply to the TypeScript builder:
```ts
// Before
scopes: field.text().many().noCheck('elementNotNull'),
// After
scopes: field.text().many({ elementsNullable: true }),
```
Delete `.noCheck('membership')` the same way. Re-emit the bundled contract.
### `stored-contracts-drop-nocheck`
Migration snapshots that an extension ships, and any other stored `contract.json` written by an earlier release, may have a `noCheck` key on a column. This release refuses such a contract and says to re-emit it. A historical snapshot cannot be re-emitted from today's source, so run the script instead. Do this before you re-emit the bundled contract, and commit your work first so the script's changes can be reviewed and undone with git. Run it from the extension's package root:
```sh
node <guide>/scripts/remove-nocheck/remove-nocheck.ts
```
`<guide>` is the directory that holds this guide. Node 24 or later runs the TypeScript script directly; it needs no `tsx`. The script reads and writes files only and needs no database. It removes the `noCheck` key from every column of every SQL contract under the root, in any `*.json` file, skipping `node_modules`, `dist`, `build` and every directory whose name starts with a dot, such as `.git`, `.agents` or `.claude`. It follows links to files and directories, also outside the root; commit or back up the files there too. That includes a test fixture of an old contract: if you keep one on purpose, restore it with git afterwards. Where the key waived the element check of a list, the script also makes the column, the field that maps it and their types in `contract.d.ts`, the row types such as `ReadonlyArray<CodecTypes['pg/text@1']['output'] | null>` included, lists whose elements may be null, which is what the source says after `psl-nocheck-removed`. The script does not change which checks a contract declares. In each PostgreSQL contract it also writes each literal default in the form this release stores, for example `"1.5"` on `numeric(10,2)` as `"1.50"`, because this release refuses a contract that stores another form, a snapshot included. The script carries this release's rules for each PostgreSQL built-in data type and for `postgis/geometry` and `pgvector/vector`, so its result does not depend on the installed packages. A geometry default is written as the upper-case little-endian HEXEWKB PostGIS prints, whatever case and byte order it had. It leaves a default of any other data type, such as one the extension defines, as it is. Besides the project root, it takes one option, `--default`, described below.
The storage hash changes, so the script also renames each changed snapshot directory to its new hash. It rewrites every `migration.json`, ref, `migration.ts` and `contract.d.ts` that names an old hash, and gives each changed migration a new `migrationHash`.
When it finishes, it prints how many files it rewrote, how many snapshot directories it renamed, and each storage hash it replaced (`<old> -> <new>`). A package with no `noCheck` key and no default in another form is left unchanged, and the script says so. It changes no file and exits 1, naming the file, when a `contract.json`, `migration.json` or ref is not valid JSON, when the `ops.json` of a migration whose hashes change is not valid JSON, when a contract's stored storage hash or snapshot directory name does not match its content (restore that file from version control, or re-emit it, and run the script again), or when a renamed snapshot directory already exists with different content, or when this release refuses a PostgreSQL default. If it stops partway, for example on a full disk, it says so; run it again to finish.
This release refuses a default that is not a value of its column's type, for example a time with more fraction digits than its column holds, a `pgvector/vector` element that `real` cannot hold exactly (`0.123456789` is stored as `0.12345679`), or a `postgis/geometry` in another SRID than its column's, and a list column's default that is not a list. The script names the file and column of each. Do not edit the stored file: that breaks its storage hash, and the next run refuses it. Choose the value the database stores, and run the script again with that value as JSON:
```sh
node <guide>/scripts/remove-nocheck/remove-nocheck.ts --default 'public.product.opensAt="12:34:00.123457"'
```
`--default` names the column as `<namespace>.<table>.<column>`, as the script's message does, and can be given once for each column. The script stores the value in every stored contract where that column's default is refused, migration snapshots included, checks it by the same rules and refreshes the hashes. It stops when the value is refused too, or when a `--default` names a column whose default it does not refuse. Then change the default in the contract source to the same value, so that re-emitting writes it.
Then re-emit the bundled contract. The extension's storage hash changes, so tell the applications that use it, in your release notes, to run `prisma db sign` against every database after they upgrade, and name the old and new hash. The app upgrade guide for this release has such a note for the Supabase extension, `supabase-contract-lists-allow-null-elements`. Tell them too that `prisma migration status` no longer marks the extension's migrations applied before the upgrade as applied, because each rewritten migration has a new hash and their databases' migration history keeps the old ones. `prisma db migrate` runs none of them again, and `prisma db verify` passes. A database that has not applied every migration yet is refused by a plain `prisma db sign`, and `prisma db migrate` stops on it with `MIGRATION.MARKER_MISMATCH`: tell them to sign it with `prisma db sign --contract <the last migration it applied>` and then run `prisma db migrate`. `--contract` takes the migration's directory name, or the new hash that migration goes to. `prisma migration status` prints the start of the old hash the database's marker holds, and the `<old> -> <new>` list in your release notes gives the new hash to sign with.
## Contract spaces an extension ships
An extension that ships a contract space, with its own `contract.json`, migration snapshots and contract source, upgrades them as an application upgrades its own. The sections of this guide named in the steps below explain each one. The scripts are under `scripts/` next to this guide. Run each script from the extension's package root.
Do the steps in this order, and skip a step that does not apply to the extension's contract space:
1. Commit your work, so every script's changes can be reviewed and undone with git.
2. Rewrite the stored contracts: `stored-contracts-drop-nocheck` above, then, for a SQLite contract space, `sqlite-contracts-name-datetime-and-json-data-types` (`scripts/data-type-values/data-type-values.ts`).
3. Change the contract source: `psl-nocheck-removed` and `ts-nocheck-method-removed` above, `date-time-interval-and-bytes-defaults-use-tags` and `client-functions-move-from-default-to-on-create` (`scripts/default-generators-to-on-create/rewrite-default-generators.mjs`, given the extension's Prisma 8 schema files).
4. Re-emit the bundled contract. That also writes PostgreSQL defaults in the new form (`postgres-defaults-stored-in-another-spelling`); `stored-contracts-drop-nocheck` in step 2 has already brought the migration snapshots to it. For a MongoDB contract space, then run `scripts/mongo-field-storage-names/mongo-field-storage-names.ts` (`mongo-contracts-record-stored-field-names`).
5. For each migration package of the contract space, change its `migration.ts` and write its `ops.json` again as `Migrations` below says.
For example, with `<guide>` the directory that holds this guide:
```sh
node <guide>/scripts/data-type-values/data-type-values.ts
node <guide>/scripts/mongo-field-storage-names/mongo-field-storage-names.ts
```
Each step that changes a storage hash changes the hash of the extension's contract space. Tell the applications that use the extension, in your release notes, to run `prisma db sign` against every database after they upgrade, and name the old and new hash, as `stored-contracts-drop-nocheck` says.
### `sqlite-contracts-name-datetime-and-json-data-types`
A SQLite contract stores the data type of each column. This release gives datetime and JSON values their own data types, and stores a datetime as `toISOString()` writes it, with exactly three fraction digits. A contract emitted by the previous release still names `sqlite/text`, so the CLI of this release refuses it with `CONTRACT.VALIDATION_FAILED`:
```text
storage.namespaces.__unbound__.entries.table.events.columns.at: codec sqlite/datetime@1 represents sqlite/datetime, not sqlite/text
```
Commit your work first, then run the script from the extension's package root:
```sh
node <guide>/scripts/data-type-values/data-type-values.ts
```
Node 24 or later runs the TypeScript script directly. It reads and writes files only. It rewrites every `*.json` file under the root that parses as a SQLite contract in the previous release's form, skipping `node_modules`, `dist`, `build` and every directory whose name starts with a dot, and follows links to files and directories, also outside the root. A test fixture of a contract in the previous release's form is rewritten too; restore it with git afterwards if you keep it on purpose. In each contract it changes:
- the `dataType` of each column and named type whose codec is `sqlite/datetime@1` or `sqlite/json@1`, from `sqlite/text` to `sqlite/datetime` or `sqlite/json`;
- each literal default of a `sqlite/datetime@1` column, and each value of an enum typed by `sqlite/datetime@1`, to three fraction digits: `"2024-01-01T00:00:00Z"` becomes `"2024-01-01T00:00:00.000Z"`. A JSON default keeps its value.
It then writes the storage hash each contract now has, renames the snapshot directory under `migrations/snapshots/` to that hash, and replaces the old hash in every `migration.json` (recomputing its `migrationHash`), every ref, every `migration.ts` and every `contract.d.ts`. The planned SQL does not change.
It prints how many files it rewrote and how many snapshot directories it renamed, and each storage hash it replaced (`<old> -> <new>`); put that list in your release notes. A package already in the new form, or with no SQLite contract, is left unchanged, and the script says so. It prints `<file>: stored hash did not recompute; rehashed from content` for a contract whose stored hash does not match its content, and rewrites it anyway. It changes no file and exits 1 when a `contract.json`, `migration.json` or ref is not valid JSON, when the `ops.json` of a migration whose hashes change is not valid JSON, or when a renamed snapshot directory already exists with different content. If it stops partway, for example on a full disk, run it again to finish. Run your formatter afterwards.
### `date-time-interval-and-bytes-defaults-use-tags`
In a Prisma 8 schema, a default of a date, time, interval or bytes column is written with the tag of the column's type. `contract emit` refuses a quoted string and shows the tag to write:
```text
Field "Event.at": Expected timestamptz`...`; write timestamptz`2024-01-01T00:00:00Z`
```
```prisma
// before
at DateTime @default("2024-01-01T00:00:00Z")
local Timestamp @default("2024-01-01 12:34:56.500")
day Date @default("2024-03-15")
clock Time @default("12:34")
zoned Timetz @default("12:34:56+02")
span Interval @default("P1D")
payload Bytes @default("AQID")
// after
at DateTime @default(timestamptz`2024-01-01T00:00:00Z`)
local Timestamp @default(timestamp`2024-01-01 12:34:56.500`)
day Date @default(date`2024-03-15`)
clock Time @default(time`12:34`)
zoned Timetz @default(timetz`12:34:56+02`)
span Interval @default(interval`P1D`)
payload Bytes @default(bytea`\x010203`)
```
On PostgreSQL the tag is named after the column's type: `timestamptz` for `DateTime`, `timestamp`, `date`, `time`, `timetz` and `interval` for the types of those names, and `bytea` for `Bytes`. A date or time tag takes the text you wrote. A `bytea` tag takes the bytes as hexadecimal after `\x`, not base64: `"AQID"` is the bytes 01 02 03, so write `bytea`\x010203``. For a quoted base64 string, `contract emit` names only the tag, because it cannot tell base64 from hex text; convert the bytes to hex yourself. On SQLite, a `DateTime` default uses the `datetime` tag, and a `Bytes` default the `blob` tag, whose body is the hex digits the quoted string held before.
### `mongo-contracts-record-stored-field-names`
A MongoDB contract now records each field's stored name in `model.storage.fields`, and names fields by their names in the schema: a field `id ObjectId @id @map("_id")` is `id` in the contract's `fields`, with `"storage": { "collection": "users", "fields": { "id": { "field": "_id" } } }`. Relations and a base model's `discriminator` name fields the same way. Indexes, validators and `execution` keep naming stored names, so the storage hash does not change.
1. Commit your work first.
2. Re-emit the bundled contract and commit the regenerated `contract.json` and `contract.d.ts`.
3. Run the script from the extension's package root:
```sh
node <guide>/scripts/mongo-field-storage-names/mongo-field-storage-names.ts
```
It rewrites every `*.json` file under the root that is a MongoDB contract with a model whose `storage` has no `fields`, skipping `node_modules`, `dist`, `build`, every directory whose name starts with a dot and every link it cannot follow, and the `contract.d.ts` next to it. These are the snapshots under `migrations/snapshots/<hash>/`, which no command regenerates. A snapshot cannot be rebuilt as a re-emit would write it, because the old contract does not say what the schema called a field, so the script keeps every field's name and records it as stored under that name: `"_id"` stays the field `_id`, stored as `_id`. Planning from such a snapshot to the re-emitted contract plans nothing. The script prints each file it rewrote, or that nothing changed. It edits the type of `storage` in each `contract.d.ts` on one line; run your formatter afterwards. If it cannot find a model's storage type in a `contract.d.ts`, it says so and exits 1: add `readonly fields: { readonly <field>: { readonly field: '<field>' }; ... }` inside that model's `readonly storage: { ... }` by hand.
## Migrations
### `setdefault-operation-class-derived`
`this.setDefault` on the PostgreSQL `Migration` class used to take `operationClass`, and `migration plan` wrote `operationClass: 'widening'` when a migration changed an existing default. The option is gone. The method reads the migration's start contract: the operation is widening when the start contract gives the column a default, and additive otherwise, including in a migration with no start contract. A `migration.ts` that passes the option no longer type-checks. Delete the option:
```typescript
// before
this.setDefault({ schema: 'public', table: 'user', column: col('role', 'text', { default: lit('member'), codecRef: { codecId: 'pg/text@1' } }), operationClass: 'widening' })
// after
this.setDefault({ schema: 'public', table: 'user', column: col('role', 'text', { default: lit('member'), codecRef: { codecId: 'pg/text@1' } }) })
```
`node migration.ts` does not check types, so a file that still passes `operationClass` runs and ignores it. An applied migration needs nothing: `prisma db migrate` applies `ops.json`.
### Postchecks check the whole definition
The runner checks an operation's postchecks before it runs the operation. When they all hold, it records the operation as already applied and skips it. On MongoDB, which has no transaction, a run that fails part way relies on this skip when it runs again. Several postchecks held for an object of the planned name with another definition, so the runner skipped an operation whose effect was not in place, and the run failed later or left the object as it was. On PostgreSQL, SQLite and MongoDB these checks now check the whole definition.
The checks are written into `ops.json` when the migration package is written, so a package written by an earlier release still carries the old checks. Each detection below matches the old check of one operation. A matched `ops.json` needs action only when its migration is not applied yet: write its `ops.json` again before you apply it. Run its `migration.ts` (`node migration.ts`), or delete the package and run `prisma migration plan` again. An applied migration needs nothing; its operations do not run again. This applies to the migration packages an extension ships for its contract space as well.
#### PostgreSQL: the `postgres-postcheck-*` entries
Each of these operations now checks the object's definition in the catalog: kind, table, columns in order, types, referenced table and actions, access method, command and roles. It never compares text that PostgreSQL prints in its own form, such as expressions, predicates, check and policy bodies, or defaults. Renames now also check that the old name is gone.
- `postgres-postcheck-create-table`: `createTable` checks that the relation is a table, not a view, sequence or index of the same name.
- `postgres-postcheck-add-column`: `addColumn` checks the column's type, and that it is NOT NULL when the column is declared NOT NULL.
- `postgres-postcheck-add-column-with-temporary-default` and `postgres-postcheck-add-required-column-to-empty-table`: `addColumnWithTemporaryDefault` and `addRequiredColumnToEmptyTable` check the column's type.
- `postgres-postcheck-create-index`: `createIndex` checks that the relation is an index on the planned table, with the planned uniqueness, access method and columns in order, and with an expression and a predicate only where the call has them.
- `postgres-postcheck-rename-index`: `renameIndex` also checks that no index has the old name any more.
- `postgres-postcheck-add-primary-key` and `postgres-postcheck-add-unique`: `addPrimaryKey` and `addUnique` check that the constraint is a primary key or a unique constraint on the planned columns in order.
- `postgres-postcheck-add-foreign-key`: `addForeignKey` checks the constraint's columns, referenced table and columns in order, and its delete and update actions.
- `postgres-postcheck-add-check-constraint`: `addCheckConstraint` checks that the constraint is a check constraint.
- `postgres-postcheck-rename-constraint`: `renameConstraint` also checks that no constraint has the old name any more.
- `postgres-postcheck-create-rls-policy`: `createRlsPolicy` checks the policy's command, roles and whether it is permissive.
- `postgres-postcheck-rename-rls-policy`: `renameRlsPolicy` also checks that no policy has the old name any more, and its precheck now also refuses a policy that already has the new name.
- `postgres-postcheck-autoincrement-default`: `setDefault` with `autoincrement()` also checks that the column owns the sequence of the planned name and that the sequence has the column's integer width.
#### `postgres-autoincrement-sequence-follows-column-type`
An `autoincrement()` column takes its values from a sequence it owns. `ALTER COLUMN … TYPE` does not change that sequence, so a column widened from `int4` to `int8` kept a sequence `AS integer`, and inserts failed once it reached 2147483647. An `alterColumnType` on such a column, planned or hand-written in `migration.ts`, now also runs `ALTER SEQUENCE … AS <new width>`. It first refuses, before any step, when the sequence has already handed out a value past a narrower width. Its postcheck asks for both the column type and the sequence width. A column whose `nextval(...)` default reads a sequence it does not own changes type as before, and that sequence is left alone.
The detection matches every integer `alterColumnType` written before this change, and none written after it. Only those on an `autoincrement()` column need action:
- Migration not applied yet: write its `ops.json` again before you apply it, as above.
- Migration already applied: the sequence still has the old width. Change it to the column's width yourself, for example `ALTER SEQUENCE "public"."user_id_seq" AS bigint`. `pg_get_serial_sequence('"public"."user"', 'id')` names the sequence.
#### `setdefault-has-no-postcheck`
A `setDefault` operation without `autoincrement()` checked afterwards that the column has a default. The old default passes that check, so the runner skipped the operation. From 8.0.0-rc.15 until this release, only an additive `setDefault` carried the check, and a hand-written `this.setDefault` was additive unless it passed `operationClass: 'widening'`. Such a call on a column that already had a different default was skipped, and `prisma db migrate` then failed with `MIGRATION.SCHEMA_VERIFY_FAILED`. Such a `setDefault` operation now carries no check afterwards; the final schema verification compares the default's value, and setting a default twice changes nothing. A `setDefault` with `autoincrement()` keeps its checks (`postgres-postcheck-autoincrement-default`).
A matched `ops.json` needs action only when its migration is not applied yet and a hand-written `setDefault` in its `migration.ts` changes a default the column already has. A package the planner wrote for a column with no default needs nothing.
#### `postgres-alter-column-type-unchanged-refused`
`this.alterColumnType({ schema, table, column, type, using })` to the type the migration's start contract, after its earlier renames, already gives the column, used to build an operation whose check, the column's type, already held. The runner skipped it and `using` never ran. `migration.ts` now refuses the call with `MIGRATION.ALTER_COLUMN_TYPE_USING_WITHOUT_TYPE_CHANGE`. Write the conversion as a `dataTransform`, which checks the rows themselves:
```typescript
// before
this.alterColumnType({ schema: 'public', table: 'user', column: 'email', type: 'text', using: 'lower(email)' })
// after
this.dataTransform(endContract, 'Lower-case user emails', {
check: () => /* a query for the users whose email is not lower-case */,
run: () => /* an update that lower-cases those emails */,
})
```
A matched `migration.ts` needs action only when the call converts a column to the type the start contract already gives it.
#### `sqlite-postchecks-check-full-definition`
- `createTable` checked only that a table of that name exists, whatever its columns. It now checks every column's name, type, nullability and primary key position.
- `addColumn` checked only that a column of that name exists. It now checks the column's type and nullability.
- `createIndex` checked only that an index of that name exists. It now checks the index's table, ordered columns and uniqueness.
- `recreateTable` did not check foreign key actions (`onDelete`, `onUpdate`) or `autoincrement()`. A migration that changed only those was skipped, and `prisma db migrate` then failed with `MIGRATION.SCHEMA_VERIFY_FAILED`. It now checks them.
A `recreateTable` written for an `autoincrement()` change has no text to detect; write it again as well when its migration is not applied yet.
#### `mongo-postchecks-check-full-definition`
- `createIndex` checked only the index keys, and `unique: true` when the index is unique. An index on the same keys with other options, such as `sparse`, a TTL, a partial filter, a collation, a wildcard projection, or other text weights or languages, passed the check. It now checks `unique` and `sparse` both ways, `expireAfterSeconds`, `partialFilterExpression`, `wildcardProjection`, the collation fields the call gives (MongoDB fills in the others) or no collation when it gives none, and a text index's fields, `weights`, `default_language` and `language_override`.
- `collMod` with a validator that is not only a `$jsonSchema`, such as `{ status: { $in: [...] } }`, checked only the collection's validation level and action. It now compares the whole validator.
### Migration apply failures report the runner's own code
#### `control-client-apply-takes-invocation`
`dbInit`, `dbUpdate` and `migrate` on a control client write advice that names commands to run, and that advice needs to know where it will be run from. Pass it as the required `invocation` option, `{ cwd, configPath, dbGiven }`:
```ts
// Before
await control.dbUpdate({ contract, mode: 'apply', migrationsDir: 'migrations', answerQuestions });
// After
await control.dbUpdate({
contract,
mode: 'apply',
migrationsDir: 'migrations',
answerQuestions,
invocation: { cwd: process.cwd(), configPath: undefined, dbGiven: false },
});
```
Add the same `invocation` to every `dbInit`, `dbUpdate` and `migrate` call. Set `configPath` to the config path your tool passes to `prisma`, and `dbGiven` to `true` when it passes `--db`. Pass `{ cwd: process.cwd(), configPath: undefined, dbGiven: false }` when nothing is run from a terminal.
A failure of these three operations is a `CliStructuredError`, so `DbInitFailure`, `DbUpdateFailure` and `MigrateFailure` are gone from `control-api`. Replace any use of them or their code types with `CliStructuredError`, and branch on `failure.code`, a dotted code such as `MIGRATION.PLANNING_FAILED`, instead of `'PLANNING_FAILED'`, `'RUNNER_FAILED'` or `'MIGRATION_PATH_NOT_FOUND'`.
The failure's fields change too:
| Before | After |
| --- | --- |
| `failure.summary` | `failure.message` |
| `failure.code === 'PLANNING_FAILED'`, `failure.conflicts` | `failure.code === 'MIGRATION.PLANNING_FAILED'`, `failure.meta.conflicts` |
| `failure.warnings` on a failed `dbInit` or `dbUpdate`, and `value.warnings` on a successful one | the `onPlannerWarnings(warnings)` option, called once per run when planning raised warnings, whether the apply then succeeds or fails |
| `failure.rowsWithoutValue`, `failure.failedTypeChange` | the runner's facts in `failure.meta`: `rowsWithoutValue` and `failedTypeChange`, and `schemaIssues` and `failedCheck` when the runner found them. A field in them is a subject object, not text such as `"User.age"` |
| `failure.failedMigration`, `failure.databaseStorageHash` | removed; the advice in `failure.fix` names the migration directory and the `--from` hash |
In `--json` output, `db init` and `db update` print the planner's warnings of a failed apply as `warn` messages before the error, instead of in the error's `meta.plannerWarnings`.
#### `runner-failures-report-own-codes`
`MIGRATION.RUNNER_FAILED` no longer exists. Code that reads `prisma db migrate`, `db init` or `db update` output with `--json`, or a control client's failure, and matches `MIGRATION.RUNNER_FAILED` or `meta.runnerErrorCode`, now matches the runner's code itself:
```ts
// Before
if (error.code === 'MIGRATION.RUNNER_FAILED' && error.meta.runnerErrorCode === 'MIGRATION.SCHEMA_VERIFY_FAILED') {
report(error.meta.issues);
}
// After
if (error.code === 'MIGRATION.SCHEMA_VERIFY_FAILED') {
report(error.meta.schemaIssues);
}
```
A check that matched `MIGRATION.RUNNER_FAILED` alone, meaning "the apply failed", matches every code the runner reports: `MIGRATION.SCHEMA_VERIFY_FAILED`, `MIGRATION.PRECHECK_FAILED`, `MIGRATION.POSTCHECK_FAILED`, `MIGRATION.EXECUTION_FAILED`, `MIGRATION.POLICY_VIOLATION`, `MIGRATION.MARKER_ORIGIN_MISMATCH`, `MIGRATION.MARKER_CAS_FAILURE`, `MIGRATION.DESTINATION_CONTRACT_MISMATCH`, `MIGRATION.FOREIGN_KEY_VIOLATION` and, on MongoDB, `MIGRATION.DATA_TRANSFORM_CHECK_NOT_READ_ONLY`. `db init` and `db update` also report an extension space they cannot plan as `MIGRATION.PATH_UNREACHABLE` or `MIGRATION.NO_INVARIANT_PATH`, with `meta.spaceId`.
Read the facts from these `meta` keys:
| Before | After |
| --- | --- |
| `meta.issues` | `meta.schemaIssues` |
| `meta.operationId`, `meta.phase`, `meta.stepDescription` of a failed check | `meta.failedCheck.operationId`, `meta.failedCheck.phase`, `meta.failedCheck.description` |
| `meta.runnerErrorCode` | `code` |
`meta.failingSpace` is unchanged. `MIGRATION.PATH_UNREACHABLE` for a database with no marker now has `'empty'`, the empty contract's hash, in `meta.fromHash`, where it had `'<empty>'`. A failure also carries `meta.failedTypeChange` and `meta.rowsWithoutValue` when the runner found them.
upgrading/extension/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/scripts/client-takes-contract-value/contract-from-json.ts
// Moves every Prisma 8 client call that passes `contractJson` to a contract value:
// postgres<Contract>({ contractJson, url })
// becomes
// export const contract = contractFromJson<Contract>(contractJson);
// postgres({ contract, url })
//
// Usage: node contract-from-json.ts [project-root] [--typescript <path to the typescript package>]
//
// The script reads source files with the TypeScript compiler, which it loads from the project.
// It rewrites each source file it can move, and prints it. In a module with a default export, such
// as a Worker entry, it declares `contract` without `export` and says so. It prints to stderr, with the reason,
// each file that passes `contractJson` to a client but that it did not move: a test file, a file
// that already has another binding named `contract`, a file whose clients use different contracts,
// a file whose `contractJson` value is not readable at the top of the file, or a call it cannot
// read. A `contract` destructured from the moved call is dropped from the destructuring. It exits 1 when it printed such a file, and changes nothing and exits 1
// when TypeScript is not installed or an argument is wrong. It skips node_modules, dist, build and every directory whose
// name starts with a dot, such as .git, .agents or .claude, and links that lead nowhere.
import { readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join, relative, resolve } from 'node:path';
import type * as TS from 'typescript';
const SOURCE_FILE = /\.(?:ts|tsx|mts|cts)$/;
const TEST_FILE = /\.(?:test|spec)(?:-d)?\.[cm]?tsx?$/;
const TEST_DIRECTORIES = new Set(['test', 'tests', '__tests__']);
const SKIPPED_DIRECTORIES = new Set(['node_modules', 'dist', 'build']);
const FACADE_ENTRY =
/^@prisma\/orm-(postgres|sqlite|mongo)\/(?:runtime|serverless|static|query-builder)$/;
const SUPABASE_ENTRY = '@prisma/orm-extension-supabase/runtime';
const FACTORY_EXPORTS = new Set(['default', 'supabase', 'mongoQuery']);
const DETECTION =
/(?<![\w$.])(?:postgres|postgresServerless|sqlite|supabase|mongo|postgresStatic|sqliteStatic|mongoStatic|mongoQuery)(?:<[^<>()]*(?:<[^<>()]*>[^<>()]*)*>)?\(\s*\{(?:(?:[^{}]|\{[^{}]*\})*?,)?\s*contractJson\s*[,}:]/;
interface Edit {
readonly start: number;
readonly end: number;
readonly text: string;
}
interface MovedCall {
readonly node: TS.CallExpression;
readonly edits: readonly Edit[];
readonly typeArguments: string;
readonly json: string;
readonly jsonNames: readonly string[];
readonly staticEntry: string;
}
type Outcome =
| { readonly kind: 'unchanged' }
| { readonly kind: 'rewritten'; readonly source: string; readonly exported: boolean }
| { readonly kind: 'refused'; readonly reason: string };
function staticEntryOf(specifier: string): string | undefined {
const facade = FACADE_ENTRY.exec(specifier);
if (facade !== null) return `@prisma/orm-${facade[1]}/static`;
return specifier === SUPABASE_ENTRY ? '@prisma/orm-postgres/static' : undefined;
}
function specifierOf(ts: typeof TS, statement: TS.ImportDeclaration): string {
return ts.isStringLiteral(statement.moduleSpecifier) ? statement.moduleSpecifier.text : '';
}
function compareSpecifiers(left: string, right: string): number {
const rank = (specifier: string) =>
specifier.startsWith('node:') ? 0 : specifier.startsWith('.') ? 2 : 1;
return rank(left) - rank(right) || (left < right ? -1 : left > right ? 1 : 0);
}
function factoriesOf(ts: typeof TS, imports: readonly TS.ImportDeclaration[]) {
const factories = new Map<string, string>();
for (const statement of imports) {
const clause = statement.importClause;
const staticEntry = staticEntryOf(specifierOf(ts, statement));
if (clause === undefined || clause.isTypeOnly || staticEntry === undefined) continue;
if (clause.name !== undefined) factories.set(clause.name.text, staticEntry);
const bindings = clause.namedBindings;
if (bindings === undefined || !ts.isNamedImports(bindings)) continue;
for (const element of bindings.elements) {
const imported = (element.propertyName ?? element.name).text;
if (!element.isTypeOnly && FACTORY_EXPORTS.has(imported)) {
factories.set(element.name.text, staticEntry);
}
}
}
return factories;
}
function topLevelNames(ts: typeof TS, sourceFile: TS.SourceFile) {
const imported = new Set<string>();
const declared = new Set<string>();
for (const statement of sourceFile.statements) {
if (ts.isImportDeclaration(statement)) {
const clause = statement.importClause;
if (clause?.name !== undefined) imported.add(clause.name.text);
const bindings = clause?.namedBindings;
if (bindings !== undefined && ts.isNamespaceImport(bindings))
imported.add(bindings.name.text);
else for (const element of bindings?.elements ?? []) imported.add(element.name.text);
} else if (ts.isVariableStatement(statement)) {
for (const declaration of statement.declarationList.declarations) {
for (const name of bindingNames(ts, declaration.name)) declared.add(name);
}
} else if (
(ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
statement.name !== undefined
) {
declared.add(statement.name.text);
}
}
return { imported, declared };
}
function bindingIdentifiers(ts: typeof TS, name: TS.BindingName): TS.Identifier[] {
if (ts.isIdentifier(name)) return [name];
return name.elements.flatMap((element) =>
ts.isOmittedExpression(element) ? [] : bindingIdentifiers(ts, element.name),
);
}
function bindingNames(ts: typeof TS, name: TS.BindingName): string[] {
return bindingIdentifiers(ts, name).map((identifier) => identifier.text);
}
function bindingsOf(ts: typeof TS, root: TS.Node, wanted: string): TS.Node[] {
const found: TS.Node[] = [];
const visit = (node: TS.Node): void => {
const binding = ownBindingOf(ts, node, wanted);
if (binding !== undefined) found.push(binding);
ts.forEachChild(node, visit);
};
visit(root);
return found;
}
function ownBindingOf(ts: typeof TS, node: TS.Node, wanted: string): TS.Node | undefined {
if (ts.isVariableDeclaration(node) || ts.isParameter(node)) {
return bindingIdentifiers(ts, node.name).find((name) => name.text === wanted);
}
if (
(ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isClassDeclaration(node) ||
ts.isClassExpression(node) ||
ts.isEnumDeclaration(node) ||
ts.isModuleDeclaration(node) ||
ts.isImportEqualsDeclaration(node) ||
ts.isExportSpecifier(node) ||
ts.isNamespaceExport(node) ||
ts.isImportSpecifier(node) ||
ts.isNamespaceImport(node) ||
ts.isImportClause(node)) &&
node.name !== undefined &&
ts.isIdentifier(node.name) &&
node.name.text === wanted
) {
return node.name;
}
return undefined;
}
/** Whether `identifier` names a variable the file declares at its top level. */
function isTopLevelVariable(ts: typeof TS, identifier: TS.Node): boolean {
let node = identifier.parent;
while (
ts.isBindingElement(node) ||
ts.isObjectBindingPattern(node) ||
ts.isArrayBindingPattern(node)
)
node = node.parent;
return (
ts.isVariableDeclaration(node) &&
ts.isVariableDeclarationList(node.parent) &&
ts.isVariableStatement(node.parent.parent) &&
ts.isSourceFile(node.parent.parent.parent)
);
}
function topLevelStatementOf(ts: typeof TS, node: TS.Node): TS.Node | undefined {
let current = node;
while (current.parent !== undefined && !ts.isSourceFile(current.parent)) current = current.parent;
return current.parent === undefined ? undefined : current;
}
/** The edit that drops `contract` from `const { ..., contract } = client(...)`, whose value the moved declaration now holds. */
function destructuredContractEdit(
ts: typeof TS,
sourceFile: TS.SourceFile,
call: TS.CallExpression,
exported: boolean,
): { readonly binding: TS.Node; readonly edit: Edit } | undefined {
const declaration = call.parent;
if (!ts.isVariableDeclaration(declaration) || declaration.initializer !== call) return undefined;
const pattern = declaration.name;
if (!ts.isObjectBindingPattern(pattern)) return undefined;
if (pattern.elements.some((element) => element.dotDotDotToken !== undefined)) return undefined;
const index = pattern.elements.findIndex(
(element) =>
element.propertyName === undefined &&
element.initializer === undefined &&
ts.isIdentifier(element.name) &&
element.name.text === 'contract',
);
const element = pattern.elements[index];
if (element === undefined) return undefined;
const statement = declaration.parent.parent;
const isExported =
ts.isVariableStatement(statement) &&
(ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false);
if (isExported && !exported) return undefined;
if (pattern.elements.length === 1) {
if (
!ts.isVariableStatement(statement) ||
isExported ||
statement.declarationList.declarations.length !== 1
)
return undefined;
return {
binding: element.name,
edit: { start: statement.getStart(sourceFile), end: call.getStart(sourceFile), text: '' },
};
}
const next = pattern.elements[index + 1];
const previous = pattern.elements[index - 1];
const edit =
next !== undefined
? { start: element.getStart(sourceFile), end: next.getStart(sourceFile), text: '' }
: { start: previous?.end ?? element.getStart(sourceFile), end: element.end, text: '' };
return { binding: element.name, edit };
}
function isReference(ts: typeof TS, identifier: TS.Identifier): boolean {
const parent = identifier.parent;
return !(
(ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
(ts.isQualifiedName(parent) && parent.right === identifier) ||
((ts.isPropertyAssignment(parent) ||
ts.isMethodDeclaration(parent) ||
ts.isGetAccessorDeclaration(parent) ||
ts.isSetAccessorDeclaration(parent)) &&
parent.name === identifier)
);
}
function identifiersIn(ts: typeof TS, node: TS.Node): string[] {
const names: string[] = [];
const visit = (child: TS.Node): void => {
if (ts.isIdentifier(child) && isReference(ts, child)) names.push(child.text);
ts.forEachChild(child, visit);
};
visit(node);
return names;
}
function contractJsonProperty(ts: typeof TS, object: TS.ObjectLiteralExpression) {
return object.properties.find(
(property): property is TS.ShorthandPropertyAssignment | TS.PropertyAssignment =>
(ts.isShorthandPropertyAssignment(property) || ts.isPropertyAssignment(property)) &&
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
property.name.text === 'contractJson',
);
}
function movedCalls(
ts: typeof TS,
sourceFile: TS.SourceFile,
factories: ReadonlyMap<string, string>,
): MovedCall[] {
const source = sourceFile.text;
const calls: MovedCall[] = [];
const visit = (node: TS.Node): void => {
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
const staticEntry = factories.get(node.expression.text);
const [options] = node.arguments;
const property =
options !== undefined && ts.isObjectLiteralExpression(options)
? contractJsonProperty(ts, options)
: undefined;
if (staticEntry !== undefined && property !== undefined) {
const edits: Edit[] = [
{ start: property.getStart(sourceFile), end: property.end, text: 'contract' },
];
const typeArguments = node.typeArguments;
if (typeArguments !== undefined) {
edits.push({
start: node.expression.end,
end: source.indexOf('>', typeArguments.end) + 1,
text: '',
});
}
calls.push({
node,
edits,
typeArguments:
typeArguments === undefined
? ''
: `<${typeArguments.map((type) => type.getText(sourceFile)).join(', ')}>`,
json: ts.isPropertyAssignment(property)
? property.initializer.getText(sourceFile)
: 'contractJson',
jsonNames: ts.isPropertyAssignment(property)
? identifiersIn(ts, property.initializer)
: ['contractJson'],
staticEntry,
});
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return calls;
}
function importEdit(
ts: typeof TS,
sourceFile: TS.SourceFile,
imports: readonly TS.ImportDeclaration[],
staticEntry: string,
style: { readonly quote: string; readonly semicolon: string; readonly eol: string },
): Edit | { readonly after: string } {
const existing = imports.find(
(statement) =>
specifierOf(ts, statement) === staticEntry &&
statement.importClause !== undefined &&
!statement.importClause.isTypeOnly,
);
const clause = existing?.importClause;
const bindings = clause?.namedBindings;
if (bindings !== undefined && ts.isNamedImports(bindings)) {
const first = bindings.elements[0];
return first === undefined
? { start: bindings.getStart(sourceFile), end: bindings.end, text: '{ contractFromJson }' }
: {
start: first.getStart(sourceFile),
end: first.getStart(sourceFile),
text: 'contractFromJson, ',
};
}
if (clause?.name !== undefined && bindings === undefined) {
return { start: clause.name.end, end: clause.name.end, text: ', { contractFromJson }' };
}
const line = `import { contractFromJson } from ${style.quote}${staticEntry}${style.quote}${style.semicolon}`;
const before = imports.find(
(statement) =>
statement.importClause !== undefined &&
compareSpecifiers(specifierOf(ts, statement), staticEntry) > 0,
);
if (before === undefined) return { after: `${style.eol}${line}` };
const at = before.getStart(sourceFile);
return { start: at, end: at, text: `${line}${style.eol}` };
}
export function moveFile(ts: typeof TS, fileName: string, source: string): Outcome {
const sourceFile = ts.createSourceFile(
fileName,
source,
ts.ScriptTarget.Latest,
true,
fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
const imports = sourceFile.statements.filter(ts.isImportDeclaration);
const calls = movedCalls(ts, sourceFile, factoriesOf(ts, imports));
const first = calls[0];
if (first === undefined) {
return DETECTION.test(source)
? {
kind: 'refused',
reason: 'it passes contractJson to a client in a form the script cannot read',
}
: { kind: 'unchanged' };
}
if (
calls.some(
(call) =>
call.json !== first.json ||
call.typeArguments !== first.typeArguments ||
call.staticEntry !== first.staticEntry,
)
) {
return {
kind: 'refused',
reason: 'its clients use different contracts; give each its own contractFromJson value',
};
}
const names = topLevelNames(ts, sourceFile);
const topLevelStatement = topLevelStatementOf(ts, first.node);
const inner = first.jsonNames.find(
(name) =>
topLevelStatement !== undefined &&
bindingsOf(ts, topLevelStatement, name).some((found) => !isTopLevelVariable(ts, found)),
);
if (inner !== undefined) {
return {
kind: 'refused',
reason: `its contractJson value uses ${inner}, which is a parameter or local binding where the client is created, not a value the top of the file can read; pass contract: contractFromJson${first.typeArguments}(${first.json}) to the call instead of contractJson, and import contractFromJson from ${first.staticEntry}`,
};
}
const exported = !hasDefaultExport(ts, sourceFile);
const destructured = calls
.map((call) => destructuredContractEdit(ts, sourceFile, call.node, exported))
.filter((found) => found !== undefined);
const binding = bindingsOf(ts, sourceFile, 'contract').find(
(found) => !destructured.some((entry) => entry.binding === found),
);
if (binding !== undefined) {
const line = sourceFile.getLineAndCharacterOfPosition(binding.getStart(sourceFile)).line + 1;
return {
kind: 'refused',
reason: ts.isExportSpecifier(binding.parent)
? `it exports contract in an export statement (line ${line}); remove contract from that statement and run the script again`
: `it already has a binding named contract (line ${line}); rename that binding and run the script again`,
};
}
const local = first.jsonNames.find((name) => names.declared.has(name));
if (local !== undefined) {
return {
kind: 'refused',
reason: `its contractJson value uses ${local}, which the file declares after its imports; declare the contract value after ${local} by hand`,
};
}
const lastImport = imports.at(-1);
if (lastImport === undefined) {
return { kind: 'refused', reason: 'it imports no client' };
}
const firstImport = imports[0];
const firstImportText = firstImport?.getText(sourceFile) ?? '';
const style = {
quote: firstImport?.moduleSpecifier.getText(sourceFile).startsWith('"') ? '"' : "'",
semicolon: firstImportText.endsWith(';') ? ';' : '',
eol: source.includes('\r\n') ? '\r\n' : '\n',
};
const edits: Edit[] = [
...calls.flatMap((call) => call.edits),
...destructured.map((entry) => entry.edit),
];
const staticImport = importEdit(ts, sourceFile, imports, first.staticEntry, style);
const importedAfter = 'after' in staticImport ? staticImport.after : '';
if ('start' in staticImport) edits.push(staticImport);
let afterImports = lastImport.end;
while (source[afterImports] === '\r' || source[afterImports] === '\n') afterImports++;
const declaration = `${exported ? 'export ' : ''}const contract = contractFromJson${first.typeArguments}(${first.json})${style.semicolon}`;
edits.push({
start: lastImport.end,
end: afterImports,
text: `${importedAfter}${style.eol}${style.eol}${declaration}${style.eol}${afterImports < source.length ? style.eol : ''}`,
});
let moved = source;
for (const edit of [...edits].sort((left, right) => right.start - left.start)) {
moved = moved.slice(0, edit.start) + edit.text + moved.slice(edit.end);
}
return { kind: 'rewritten', source: moved, exported };
}
function hasDefaultExport(ts: typeof TS, sourceFile: TS.SourceFile): boolean {
return sourceFile.statements.some(
(statement) =>
(ts.isExportAssignment(statement) && !statement.isExportEquals) ||
(ts.isExportDeclaration(statement) &&
statement.exportClause !== undefined &&
ts.isNamedExports(statement.exportClause) &&
statement.exportClause.elements.some((element) => element.name.text === 'default')) ||
(ts.canHaveModifiers(statement) &&
(ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) ??
false)),
);
}
function isTestPath(path: string): boolean {
return TEST_FILE.test(path) || path.split(/[\\/]/).some((part) => TEST_DIRECTORIES.has(part));
}
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
/** Each source file under `root`, once, by the first path that reaches it; a link to a directory already visited is not followed again. */
function sourceFiles(root: string): string[] {
const visited = new Set<string>();
const found = new Map<string, string>();
const visit = (directory: string): void => {
const realDirectory = realpathSync(directory);
if (visited.has(realDirectory)) return;
visited.add(realDirectory);
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(path))) {
if (!SKIPPED_DIRECTORIES.has(entry.name) && !entry.name.startsWith('.')) visit(path);
} else if (
(entry.isFile() || (entry.isSymbolicLink() && isFile(path))) &&
SOURCE_FILE.test(entry.name) &&
!entry.name.endsWith('.d.ts')
) {
const realFile = realpathSync(path);
if (!found.has(realFile)) found.set(realFile, path);
}
}
};
visit(root);
return [...found.values()];
}
function loadTypeScript(root: string, explicit: string | undefined): typeof TS | undefined {
const require = createRequire(join(root, 'package.json'));
try {
return require(explicit ?? 'typescript');
} catch {
return undefined;
}
}
const USAGE =
'Usage: node contract-from-json.ts [project-root] [--typescript <path to the typescript package>]';
function parseArgs(args: readonly string[]): {
readonly root: string;
readonly typescript: string | undefined;
readonly errors: string[];
} {
const errors: string[] = [];
const positional: string[] = [];
let typescript: string | undefined;
for (let index = 0; index < args.length; index++) {
const arg = args[index] ?? '';
if (arg === '--typescript') {
const path = args[++index];
if (path === undefined || path.startsWith('-'))
errors.push('--typescript: needs the path to the typescript package');
else typescript = resolve(path);
} else if (arg.startsWith('-')) errors.push(`${arg}: unknown option`);
else positional.push(arg);
}
if (positional.length > 1) errors.push(`expected one project root, got ${positional.length}`);
const root = resolve(positional[0] ?? process.cwd());
if (errors.length === 0 && !isDirectory(root)) errors.push(`${root}: not a directory`);
if (errors.length > 0) errors.push(USAGE);
return { root, typescript, errors };
}
function main(): number {
const { root, typescript, errors } = parseArgs(process.argv.slice(2));
if (errors.length > 0) {
process.stderr.write(`${errors.join('\n')}\n`);
return 1;
}
const ts = loadTypeScript(root, typescript);
if (ts === undefined) {
process.stderr.write(
`TypeScript is not installed in ${root}. Install it (for example pnpm add -D typescript) and run the script again, or move each client by hand as the guide describes. No file was changed.\n`,
);
return 1;
}
let refused = 0;
for (const path of sourceFiles(root).sort()) {
const name = relative(root, path).split('\\').join('/');
const source = readFileSync(path, 'utf8');
const outcome: Outcome = isTestPath(name)
? DETECTION.test(source)
? {
kind: 'refused',
reason:
'it is a test file; import the contract value from the module that creates the client',
}
: { kind: 'unchanged' }
: moveFile(ts, path, source);
if (outcome.kind === 'rewritten') {
writeFileSync(path, outcome.source);
process.stdout.write(
outcome.exported
? `${name}: moved to contractFromJson\n`
: `${name}: moved to contractFromJson; contract is not exported, because the module has a default export (a Worker treats every named export of its entry module as an entrypoint); a module that needs the value gets it from a separate module\n`,
);
} else if (outcome.kind === 'refused') {
refused++;
process.stderr.write(`${name}: not moved, because ${outcome.reason}\n`);
}
}
return refused === 0 ? 0 : 1;
}
process.exitCode = main();
upgrading/extension/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/scripts/data-type-values/data-type-values.ts
#!/usr/bin/env node
/**
* Rewrites every stored SQLite contract in a project to this release's form: a column or named type
* of `sqlite/datetime@1` or `sqlite/json@1` names its own data type (`sqlite/datetime`,
* `sqlite/json`) instead of `sqlite/text`, and a datetime default or enum value is written with
* exactly three fraction digits (`2024-01-01T00:00:00.000Z`). It then renames the snapshot
* directories and rewrites every migration, ref, `migration.ts` and `contract.d.ts` that names an
* old storage hash.
*
* Usage: node data-type-values.ts [project-root]
*
* The project root defaults to the working directory. The script reads and writes files only. It
* needs no database, network or configured stack, and a project already in the new form is left
* unchanged. When it stops (a file that must be JSON is not, or a snapshot directory that already
* exists with different content) it changes no file, prints one line per case and exits 1. When it
* finishes it prints how many files and snapshot directories it changed and each storage hash it
* replaced.
*
* The storage hash and migration hash rules are copied from `@internal/contract` and
* `@internal/migration-tools` because a project's strict `node_modules` does not expose them.
*/
import { createHash } from 'node:crypto';
import {
chmodSync,
type Dirent,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
type Json = null | boolean | number | string | Json[] | JsonRecord;
interface JsonRecord {
[key: string]: Json;
}
const DATA_TYPES: Readonly<Record<string, string>> = {
'sqlite/datetime@1': 'sqlite/datetime',
'sqlite/json@1': 'sqlite/json',
};
const DATETIME_CODEC = 'sqlite/datetime@1';
const PREVIOUS_DATA_TYPE = 'sqlite/text';
const UTC_INSTANT = /^(.*:\d{2})(?:\.(\d{1,3}))?Z$/;
const SKIPPED_DIRECTORIES = new Set(['node_modules', 'dist', 'build']);
const HASH = /^[0-9a-f]{64}$/;
const TEMPORARY_SUFFIX = '.data-type-values-tmp';
function isRecord(value: unknown): value is JsonRecord {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const prototype: unknown = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function parseJson(text: string): Json | undefined {
try {
const value: Json = JSON.parse(text);
return value;
} catch {
return undefined;
}
}
function sha256(text: string): string {
return createHash('sha256').update(text).digest('hex');
}
function compareCodeUnits(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
function sortKeys(value: Json): Json {
if (Array.isArray(value)) return value.map(sortKeys);
if (!isRecord(value)) return value;
const sorted: JsonRecord = {};
for (const key of Object.keys(value).sort(compareCodeUnits)) {
const child = value[key];
if (child !== undefined) sorted[key] = sortKeys(child);
}
return sorted;
}
function canonicalizeJson(value: Json): string {
return JSON.stringify(sortKeys(value));
}
type PathSegment = string | readonly string[];
function matchesPath(path: readonly string[], pattern: readonly PathSegment[]): boolean {
if (path.length !== pattern.length) return false;
return pattern.every((segment, index) => {
const value = path[index];
if (value === undefined) return false;
if (segment === '*') return true;
return typeof segment === 'string' ? value === segment : segment.includes(value);
});
}
const SQL_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
['storage', 'namespaces', '*', 'entries', 'table'],
['storage', 'namespaces', '*', 'entries', 'table', '*'],
['storage', 'namespaces', '*', 'entries', 'table', '*', ['uniques', 'indexes', 'foreignKeys']],
['storage', 'namespaces', '*', 'entries', 'table', '*', 'indexes', 'unique'],
['storage', 'namespaces', '*', 'entries', 'table', '*', 'columns', '*', 'default', 'value'],
];
const COLUMN_DEFAULT_VALUE: readonly PathSegment[] = [
'storage',
'namespaces',
'*',
'entries',
'table',
'*',
'columns',
'*',
'default',
'value',
];
const COLUMN_PATHS: readonly (readonly PathSegment[])[] = [
['storage', 'namespaces', '*', 'entries', 'table', '*', 'columns', '*'],
['storage', 'namespaces', '*', 'tables', '*', 'columns', '*'],
];
const DATA_TYPE_PATHS: readonly (readonly PathSegment[])[] = [
...COLUMN_PATHS,
['storage', 'types', '*'],
];
const FRAMEWORK_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
['domain', 'namespaces'],
['domain', 'namespaces', '*'],
['domain', 'namespaces', '*', 'models'],
['domain', 'namespaces', '*', 'models', '*', 'relations'],
['domain', 'namespaces', '*', 'models', '*', 'storage'],
['storage', 'namespaces'],
['storage', 'namespaces', '*', 'entries'],
['roots'],
['extensions'],
['extensions', '*'],
['capabilities'],
['meta'],
['execution', 'mutations', 'defaults'],
];
const TOP_LEVEL_ORDER = [
'schemaVersion',
'canonicalVersion',
'targetFamily',
'target',
'profileHash',
'roots',
'domain',
'storage',
'execution',
'capabilities',
'extensions',
'defaultControlPolicy',
'meta',
];
function isDefaultValue(value: Json): boolean {
if (value === false) return true;
if (Array.isArray(value)) return value.length === 0;
return isRecord(value) && Object.keys(value).length === 0;
}
function isPreservedEmpty(path: readonly string[], key: string): boolean {
if (key === 'nullable') return true;
if (key === 'elementNullable' && path.at(-2) === 'many') return true;
if (FRAMEWORK_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern))) return true;
if (
path.length >= COLUMN_DEFAULT_VALUE.length &&
matchesPath(path.slice(0, COLUMN_DEFAULT_VALUE.length), COLUMN_DEFAULT_VALUE)
)
return true;
return SQL_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern));
}
function omitDefaults(value: Json, path: readonly string[]): Json {
if (Array.isArray(value)) return value.map((item) => omitDefaults(item, path));
if (!isRecord(value)) return value;
const result: JsonRecord = {};
for (const [key, child] of Object.entries(value)) {
const childPath = [...path, key];
if (key === '_generated') continue;
if (key === 'generated' && child === false) continue;
if ((key === 'onDelete' || key === 'onUpdate') && child === 'noAction') continue;
if (isDefaultValue(child) && !isPreservedEmpty(childPath, key)) continue;
result[key] = omitDefaults(child, childPath);
}
return result;
}
function compareByName(a: Json, b: Json): number {
const nameA = isRecord(a) && typeof a['name'] === 'string' ? a['name'] : '';
const nameB = isRecord(b) && typeof b['name'] === 'string' ? b['name'] : '';
return compareCodeUnits(nameA, nameB);
}
function sortTableArrays(storage: Json): Json {
if (!isRecord(storage)) return storage;
const namespaces = storage['namespaces'];
if (!isRecord(namespaces)) return storage;
const sortedNamespaces: JsonRecord = {};
for (const [namespaceId, namespace] of Object.entries(namespaces)) {
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
const tables = isRecord(entries) ? entries['table'] : undefined;
if (!isRecord(namespace) || !isRecord(entries) || !isRecord(tables)) {
sortedNamespaces[namespaceId] = namespace;
continue;
}
const sortedTables: JsonRecord = {};
for (const [tableName, table] of Object.entries(tables)) {
if (!isRecord(table)) {
sortedTables[tableName] = table;
continue;
}
const sortedTable: JsonRecord = { ...table };
for (const key of ['checks', 'indexes', 'uniques']) {
const list = table[key];
if (Array.isArray(list)) sortedTable[key] = [...list].sort(compareByName);
}
sortedTables[tableName] = sortedTable;
}
sortedNamespaces[namespaceId] = { ...namespace, entries: { ...entries, table: sortedTables } };
}
return { ...storage, namespaces: sortedNamespaces };
}
function orderTopLevel(value: JsonRecord): JsonRecord {
const ordered: JsonRecord = {};
const remaining = new Set(Object.keys(value));
for (const key of TOP_LEVEL_ORDER) {
const child = value[key];
if (child !== undefined) {
ordered[key] = child;
remaining.delete(key);
}
}
for (const key of [...remaining].sort(compareCodeUnits)) {
const child = value[key];
if (child !== undefined) ordered[key] = child;
}
return ordered;
}
function omitNamespaceKinds(storage: JsonRecord): JsonRecord {
const namespaces = storage['namespaces'];
if (!isRecord(namespaces)) return storage;
const stripped: JsonRecord = {};
for (const [namespaceId, namespace] of Object.entries(namespaces)) {
if (isRecord(namespace)) {
const { kind: _kind, ...rest } = namespace;
stripped[namespaceId] = rest;
} else {
stripped[namespaceId] = namespace;
}
}
return { ...storage, namespaces: stripped };
}
function computeStorageHash(contract: JsonRecord): string {
const storage = isRecord(contract['storage']) ? contract['storage'] : {};
const { storageHash: _published, ...withoutHash } = storage;
const hashed: JsonRecord = {
schemaVersion: '1',
targetFamily: typeof contract['targetFamily'] === 'string' ? contract['targetFamily'] : '',
target: typeof contract['target'] === 'string' ? contract['target'] : '',
profileHash: '',
roots: {},
domain: { namespaces: {} },
storage: omitNamespaceKinds(withoutHash),
extensions: {},
capabilities: {},
meta: {},
};
const withoutDefaults = omitDefaults(hashed, []);
if (!isRecord(withoutDefaults)) return '';
const withSortedStorage = {
...withoutDefaults,
storage: sortTableArrays(withoutDefaults['storage'] ?? {}),
};
const sorted = sortKeys(withSortedStorage);
return isRecord(sorted) ? sha256(JSON.stringify(orderTopLevel(sorted), null, 2)) : '';
}
function computeMigrationHash(metadata: JsonRecord, ops: Json): string {
const { migrationHash: _migrationHash, ...withoutHash } = metadata;
const parts = [canonicalizeJson(withoutHash), canonicalizeJson(ops)].map(sha256);
return sha256(canonicalizeJson(parts));
}
interface ProjectFiles {
readonly json: readonly string[];
readonly migrationJson: readonly string[];
readonly refs: readonly string[];
readonly migrationTs: readonly string[];
readonly leftovers: readonly string[];
}
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
/** A file, or a link to one: the script reads and writes through links to files. */
function isFileEntry(dir: string, entry: Dirent): boolean {
return entry.isFile() || (entry.isSymbolicLink() && isFile(join(dir, entry.name)));
}
/** A file the script reads, by the path it was reached through: its own path rather than a link's. */
interface FoundFile {
readonly path: string;
readonly linked: boolean;
readonly inMigrationPackage: boolean;
}
function listFiles(root: string): ProjectFiles {
const leftovers: string[] = [];
const visited = new Set<string>();
const found = new Map<string, FoundFile>();
const visit = (dir: string): void => {
const realDir = realpathSync(dir);
if (visited.has(realDir)) return;
visited.add(realDir);
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
compareCodeUnits(a.name, b.name),
);
const inMigrationPackage = entries.some(
(entry) => entry.name === 'migration.json' && isFileEntry(dir, entry),
);
for (const entry of entries) {
const path = join(dir, entry.name);
if (entry.name.endsWith(TEMPORARY_SUFFIX)) {
leftovers.push(path);
continue;
}
if (entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(path))) {
if (!SKIPPED_DIRECTORIES.has(entry.name) && !entry.name.startsWith('.')) visit(path);
continue;
}
if (!isFileEntry(dir, entry)) continue;
const realFile = realpathSync(path);
const linked = entry.isSymbolicLink();
const known = found.get(realFile);
if (known === undefined || (known.linked && !linked))
found.set(realFile, { path, linked, inMigrationPackage });
}
};
visit(root);
const files = [...found.values()];
const named = (test: (file: FoundFile, name: string) => boolean): string[] =>
files.filter((file) => test(file, basename(file.path))).map((file) => file.path);
return {
json: named((_file, name) => name.endsWith('.json')),
migrationJson: named((_file, name) => name === 'migration.json'),
refs: named((file, name) => name.endsWith('.json') && basename(dirname(file.path)) === 'refs'),
migrationTs: named((file, name) => name === 'migration.ts' && file.inMigrationPackage),
leftovers,
};
}
function isSnapshotContract(path: string): boolean {
const snapshotDir = dirname(path);
return (
basename(path) === 'contract.json' &&
HASH.test(basename(snapshotDir)) &&
basename(dirname(snapshotDir)) === 'snapshots'
);
}
function mapRecord(value: Json, map: (key: string, child: Json) => Json): Json {
if (!isRecord(value)) return value;
const result: JsonRecord = {};
for (const [key, child] of Object.entries(value)) result[key] = map(key, child);
return result;
}
function mapEntries(
value: Json,
path: readonly string[],
patterns: readonly (readonly PathSegment[])[],
rewrite: (entry: JsonRecord, path: readonly string[]) => JsonRecord,
): Json {
if (!isRecord(value)) return value;
if (patterns.some((pattern) => matchesPath(path, pattern))) return rewrite(value, path);
const leadsToEntry = patterns.some(
(pattern) => path.length < pattern.length && matchesPath(path, pattern.slice(0, path.length)),
);
return leadsToEntry
? mapRecord(value, (key, child) => mapEntries(child, [...path, key], patterns, rewrite))
: value;
}
function mapContractEntries(
contract: JsonRecord,
patterns: readonly (readonly PathSegment[])[],
rewrite: (entry: JsonRecord, path: readonly string[]) => JsonRecord,
): JsonRecord {
const result: JsonRecord = {};
for (const [key, child] of Object.entries(contract))
result[key] = mapEntries(child, [key], patterns, rewrite);
return result;
}
function enumKey(namespaceId: string, name: string): string {
return `${namespaceId}\u0000${name}`;
}
function withStorageHash(contract: JsonRecord, hash: string): JsonRecord {
const storage = isRecord(contract['storage']) ? contract['storage'] : {};
return { ...contract, storage: { ...storage, storageHash: hash } };
}
function emittedForm(contract: JsonRecord, original: string): string {
const { _generated: generated, ...rest } = contract;
const sorted = sortKeys(rest);
const ordered = isRecord(sorted) ? orderTopLevel(sorted) : {};
if (generated !== undefined) ordered['_generated'] = generated;
return `${JSON.stringify(ordered, null, 2)}${original.endsWith('\n') ? '\n' : ''}`;
}
function snapshotForm(contract: JsonRecord): string {
return `${canonicalizeJson(contract)}\n`;
}
interface Block {
readonly start: number;
end: number;
readonly label: string;
readonly parent: Block | undefined;
}
function scanBlocks(text: string): Block[] {
const blocks: Block[] = [];
const open: Block[] = [];
let quote: string | undefined;
for (let index = 0; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === "'" || char === '"' || char === '`') {
quote = char;
continue;
}
if (char === '{') {
const key =
/(?:readonly\s+)?([\p{ID_Start}_$][\p{ID_Continue}$]*|'[^']*'|"[^"]*")\s*\??\s*:\s*$/u.exec(
text.slice(Math.max(0, index - 200), index),
);
const block: Block = {
start: index,
end: text.length,
label: key?.[1]?.replace(/^['"]|['"]$/g, '') ?? '',
parent: open.at(-1),
};
open.push(block);
blocks.push(block);
} else if (char === '}') {
const block = open.pop();
if (block !== undefined) block.end = index;
}
}
return blocks;
}
function innermostBlock(blocks: readonly Block[], position: number): Block | undefined {
let found: Block | undefined;
for (const block of blocks) {
if (
block.start < position &&
position < block.end &&
(found === undefined || block.start > found.start)
)
found = block;
}
return found;
}
function isColumnBlock(block: Block): boolean {
return block.parent?.label === 'columns';
}
function isStorageTypeBlock(blocks: readonly Block[], block: Block): boolean {
const types = block.parent;
return (
types?.label === 'types' &&
blocks.some((other) => other.parent === types.parent && other.label === 'namespaces')
);
}
function isInColumnDefault(block: Block): boolean {
for (let ancestor = block.parent; ancestor !== undefined; ancestor = ancestor.parent)
if (
ancestor.label === 'default' &&
ancestor.parent !== undefined &&
isColumnBlock(ancestor.parent)
)
return true;
return false;
}
function holdsDataType(blocks: readonly Block[], block: Block): boolean {
return (isColumnBlock(block) || isStorageTypeBlock(blocks, block)) && !isInColumnDefault(block);
}
function genericArgumentEnd(text: string, start: number): number {
let depth = 0;
let quote: string | undefined;
for (let index = start; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === "'" || char === '"' || char === '`') quote = char;
else if (char === '{' || char === '[' || char === '(' || char === '<') depth++;
else if (char === '}' || char === ']' || char === ')') depth--;
else if (char === '>') {
if (depth === 0) return index;
depth--;
}
}
return text.length;
}
function memberOf(
text: string,
blocks: readonly Block[],
block: Block,
name: string,
): string | undefined {
const pattern = new RegExp(`readonly ${name}: (['"])((?:\\\\.|(?!\\1).)*)\\1`, 'g');
for (const member of text.slice(block.start, block.end).matchAll(pattern)) {
if (innermostBlock(blocks, block.start + member.index) === block) return member[2];
}
return undefined;
}
/** The end of the type literal that starts at `start`: the first `;`, `,` or closing bracket outside it. */
function literalEnd(text: string, start: number): number {
let depth = 0;
let quote: string | undefined;
for (let index = start; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === "'" || char === '"' || char === '`') quote = char;
else if (char === '{' || char === '[' || char === '(' || char === '<') depth++;
else if (char === '}' || char === ']' || char === ')' || char === '>') {
if (depth === 0) return index;
depth--;
} else if ((char === ';' || char === ',') && depth === 0) return index;
}
return text.length;
}
function bracketEnd(text: string, open: number): number {
let depth = 0;
let quote: string | undefined;
for (let index = open; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === "'" || char === '"' || char === '`') quote = char;
else if (char === '[' || char === '{' || char === '(') depth++;
else if (char === ']' || char === '}' || char === ')') {
depth--;
if (depth === 0) return index + 1;
}
}
return text.length;
}
function replaceQuotedHashes(text: string, hashes: ReadonlyMap<string, string>): string {
return text.replace(/"([0-9a-f]{64})"/g, (match: string, hash: string) => {
const next = hashes.get(hash);
return next === undefined ? match : `"${next}"`;
});
}
function readDirectory(dir: string): Map<string, string> {
const files = new Map<string, string>();
if (!existsSync(dir)) return files;
for (const entry of readdirSync(dir, { withFileTypes: true }))
if (isFileEntry(dir, entry) && !entry.name.endsWith(TEMPORARY_SUFFIX))
files.set(entry.name, readFileSync(join(dir, entry.name), 'utf8'));
return files;
}
function sameFiles(a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): boolean {
return a.size === b.size && [...a].every(([name, content]) => b.get(name) === content);
}
function temporaryPath(path: string): string {
return join(dirname(path), `.${basename(path)}${TEMPORARY_SUFFIX}`);
}
function writeWithModeOf(path: string, text: string, modeSource: string): void {
writeFileSync(path, text);
if (existsSync(modeSource)) chmodSync(path, statSync(modeSource).mode & 0o7777);
}
function writeFile(path: string, text: string): void {
const target = realpathSync(path);
const temporary = temporaryPath(target);
writeWithModeOf(temporary, text, target);
renameSync(temporary, target);
}
function writeDirectory(dir: string, files: ReadonlyMap<string, string>, sourceDir: string): void {
const temporary = temporaryPath(dir);
mkdirSync(temporary);
for (const [name, text] of files)
writeWithModeOf(join(temporary, name), text, join(sourceDir, name));
renameSync(temporary, dir);
}
function removeDirectory(dir: string): void {
const temporary = temporaryPath(dir);
renameSync(dir, temporary);
rmSync(temporary, { recursive: true, force: true });
}
function count(amount: number, singular: string, plural: string): string {
return `${amount} ${amount === 1 ? singular : plural}`;
}
function summary(
root: string,
contractsFound: number,
files: number,
directories: number,
hashes: ReadonlyMap<string, string>,
): readonly string[] {
if (contractsFound === 0) return [`No SQLite contract was found under ${root}; nothing changed.`];
if (files === 0 && directories === 0)
return ['The project is already in the new form; nothing changed.'];
const renamed = count(directories, 'snapshot directory', 'snapshot directories');
const changes = `Rewrote ${count(files, 'file', 'files')} and renamed ${renamed}.`;
if (hashes.size === 0) return [changes];
return [
`${changes} Storage hashes changed (old -> new):`,
...[...hashes]
.sort(([a], [b]) => compareCodeUnits(a, b))
.map(([oldHash, newHash]) => ` ${oldHash} -> ${newHash}`),
];
}
/** The text `sqlite/datetime` stores for an instant the previous release stored: three fraction digits, always. */
function storedDatetime(value: Json): Json {
if (typeof value !== 'string') return value;
const match = UTC_INSTANT.exec(value);
return match === null ? value : `${match[1]}.${(match[2] ?? '').padEnd(3, '0')}Z`;
}
function storedDatetimes(value: Json): Json {
return Array.isArray(value) ? value.map(storedDatetime) : storedDatetime(value);
}
interface Rewrite {
readonly contract: JsonRecord;
readonly changed: boolean;
readonly datetimeEnums: ReadonlySet<string>;
}
/**
* A `sqlite/datetime@1` enum's members in the domain, and the storage value set its columns name,
* hold instants, so they are written as `sqlite/datetime` stores them too.
*/
function rewriteDatetimeEnums(contract: JsonRecord): Rewrite {
const datetimeEnums = new Set<string>();
let changed = false;
const rewritten = (values: readonly Json[]): Json[] => {
const next = values.map(storedDatetime);
if (canonicalizeJson(next) !== canonicalizeJson([...values])) changed = true;
return next;
};
const domain = mapRecord(contract['domain'] ?? null, (key, namespaces) =>
key !== 'namespaces'
? namespaces
: mapRecord(namespaces, (namespaceId, namespace) =>
mapRecord(namespace, (kind, enums) =>
kind !== 'enum'
? enums
: mapRecord(enums, (name, enumType) => {
const members = isRecord(enumType) ? enumType['members'] : undefined;
if (
!isRecord(enumType) ||
enumType['codecId'] !== DATETIME_CODEC ||
!Array.isArray(members)
)
return enumType;
datetimeEnums.add(enumKey(namespaceId, name));
const values = rewritten(
members.map((member) => (isRecord(member) ? (member['value'] ?? null) : null)),
);
return {
...enumType,
members: members.map((member, index) =>
isRecord(member) && member['value'] !== undefined
? { ...member, value: values[index] ?? null }
: member,
),
};
}),
),
),
);
const storage = contract['storage'];
const namespaces = isRecord(storage) ? storage['namespaces'] : undefined;
for (const namespace of isRecord(namespaces) ? Object.values(namespaces) : []) {
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
const tables = isRecord(entries) ? entries['table'] : undefined;
for (const table of isRecord(tables) ? Object.values(tables) : []) {
const columns = isRecord(table) ? table['columns'] : undefined;
for (const column of isRecord(columns) ? Object.values(columns) : []) {
const valueSet = isRecord(column) ? column['valueSet'] : undefined;
if (
isRecord(column) &&
column['codecId'] === DATETIME_CODEC &&
isRecord(valueSet) &&
typeof valueSet['namespaceId'] === 'string' &&
typeof valueSet['entityName'] === 'string'
)
datetimeEnums.add(enumKey(valueSet['namespaceId'], valueSet['entityName']));
}
}
}
const nextStorage = mapRecord(storage ?? null, (key, storageNamespaces) =>
key !== 'namespaces'
? storageNamespaces
: mapRecord(storageNamespaces, (namespaceId, namespace) =>
mapRecord(namespace, (namespaceKey, entries) =>
namespaceKey !== 'entries'
? entries
: mapRecord(entries, (entryKind, valueSets) =>
entryKind !== 'valueSet'
? valueSets
: mapRecord(valueSets, (name, valueSet) => {
const values = isRecord(valueSet) ? valueSet['values'] : undefined;
if (
!datetimeEnums.has(enumKey(namespaceId, name)) ||
!isRecord(valueSet) ||
!Array.isArray(values)
)
return valueSet;
return { ...valueSet, values: rewritten(values) };
}),
),
),
),
);
return {
contract: {
...contract,
...(contract['domain'] === undefined ? {} : { domain }),
...(storage === undefined ? {} : { storage: nextStorage }),
},
changed,
datetimeEnums,
};
}
function rewriteContract(contract: JsonRecord): Rewrite {
if (contract['target'] !== 'sqlite')
return { contract, changed: false, datetimeEnums: new Set() };
let changed = false;
const rewriteEntry = (entry: JsonRecord): JsonRecord => {
const codecId = entry['codecId'];
const dataType = typeof codecId === 'string' ? DATA_TYPES[codecId] : undefined;
if (dataType === undefined) return entry;
let next = entry;
if (entry['dataType'] === PREVIOUS_DATA_TYPE) {
next = { ...next, dataType };
changed = true;
}
const columnDefault = next['default'];
if (
codecId === DATETIME_CODEC &&
isRecord(columnDefault) &&
columnDefault['kind'] === 'literal' &&
columnDefault['value'] !== undefined
) {
const value = storedDatetimes(columnDefault['value']);
if (canonicalizeJson(value) !== canonicalizeJson(columnDefault['value'])) {
next = { ...next, default: { ...columnDefault, value } };
changed = true;
}
}
return next;
};
const enums = rewriteDatetimeEnums(mapContractEntries(contract, DATA_TYPE_PATHS, rewriteEntry));
return {
contract: enums.contract,
changed: changed || enums.changed,
datetimeEnums: enums.datetimeEnums,
};
}
interface Edit {
readonly start: number;
readonly end: number;
readonly text: string;
}
/** The quoted instants between `start` and `end`, each written as `sqlite/datetime` stores it. */
function datetimeEdits(text: string, start: number, end: number): Edit[] {
const edits: Edit[] = [];
for (const match of text.slice(start, end).matchAll(/(['"])((?:\\.|(?!\1).)*)\1/g)) {
const value = match[2] ?? '';
const stored = storedDatetime(value);
if (stored === value) continue;
const at = start + match.index + 1;
edits.push({ start: at, end: at + value.length, text: String(stored) });
}
return edits;
}
function rewriteDts(
text: string,
datetimeEnums: ReadonlySet<string>,
hashes: ReadonlyMap<string, string>,
): string {
const blocks = scanBlocks(text);
const edits: Edit[] = [];
for (const match of text.matchAll(/readonly dataType: (['"])sqlite\/text\1/g)) {
const block = innermostBlock(blocks, match.index);
if (block === undefined || !holdsDataType(blocks, block)) continue;
const codecId = memberOf(text, blocks, block, 'codecId');
const dataType = codecId === undefined ? undefined : DATA_TYPES[codecId];
if (dataType === undefined) continue;
const quote = match[1] ?? "'";
edits.push({
start: match.index,
end: match.index + match[0].length,
text: `readonly dataType: ${quote}${dataType}${quote}`,
});
}
for (const match of text.matchAll(/DefaultLiteralValue<\s*(['"])sqlite\/datetime@1\1\s*,\s*/g)) {
const start = match.index + match[0].length;
edits.push(...datetimeEdits(text, start, genericArgumentEnd(text, start)));
}
for (const match of text.matchAll(/readonly values: readonly \[/g)) {
const valueSet = innermostBlock(blocks, match.index);
const kind = valueSet?.parent;
const namespace = kind?.parent?.parent;
if (
valueSet === undefined ||
kind?.label !== 'valueSet' ||
kind.parent?.label !== 'entries' ||
namespace === undefined ||
!datetimeEnums.has(enumKey(namespace.label, valueSet.label))
)
continue;
const open = match.index + match[0].length - 1;
edits.push(...datetimeEdits(text, open, bracketEnd(text, open)));
}
for (const match of text.matchAll(/readonly value: /g)) {
const member = innermostBlock(blocks, match.index);
const enumType = member?.parent;
const namespace = enumType?.parent?.parent;
if (
member === undefined ||
member.label !== '' ||
enumType?.parent?.label !== 'enum' ||
namespace === undefined ||
!datetimeEnums.has(enumKey(namespace.label, enumType.label))
)
continue;
const start = match.index + match[0].length;
edits.push(...datetimeEdits(text, start, literalEnd(text, start)));
}
for (const match of text.matchAll(/(['"])([0-9a-f]{64})\1/g)) {
const hash = match[2];
const next = hash === undefined ? undefined : hashes.get(hash);
if (next === undefined) continue;
edits.push({
start: match.index,
end: match.index + match[0].length,
text: `${match[1]}${next}${match[1]}`,
});
}
let result = text;
for (const edit of edits.sort((a, b) => b.start - a.start))
result = `${result.slice(0, edit.start)}${edit.text}${result.slice(edit.end)}`;
return result;
}
interface ContractPlan {
readonly path: string;
readonly snapshot: boolean;
readonly oldHash: string;
readonly newHash: string;
readonly content: string;
readonly datetimeEnums: ReadonlySet<string>;
}
const JSON_FILES_THAT_MUST_PARSE = new Set(['contract.json', 'migration.json']);
function parseRoot(args: readonly string[]): { readonly root: string; readonly errors: string[] } {
const errors = args.filter((arg) => arg.startsWith('-')).map((arg) => `${arg}: unknown option`);
const positional = args.filter((arg) => !arg.startsWith('-'));
if (positional.length > 1) errors.push(`expected one project root, got ${positional.length}`);
const root = resolve(positional[0] ?? process.cwd());
if (errors.length === 0 && !isDirectory(root)) errors.push(`${root}: not a directory`);
if (errors.length > 0) errors.push('Usage: node data-type-values.ts [project-root]');
return { root, errors };
}
function main({
root,
errors,
}: {
readonly root: string;
readonly errors: readonly string[];
}): number {
if (errors.length > 0) {
process.stderr.write(`${errors.join('\n')}\n`);
return 1;
}
const display = (path: string): string => relative(root, path).split(sep).join('/');
const files = listFiles(root);
const plans: ContractPlan[] = [];
const notices: string[] = [];
const stops: string[] = [];
let contractsFound = 0;
for (const path of files.json) {
const text = readFileSync(path, 'utf8');
const contract = parseJson(text);
if (
contract === undefined &&
(JSON_FILES_THAT_MUST_PARSE.has(basename(path)) || files.refs.includes(path))
) {
stops.push(`${display(path)}: not valid JSON`);
continue;
}
if (
!isRecord(contract) ||
contract['targetFamily'] !== 'sql' ||
contract['target'] !== 'sqlite' ||
!isRecord(contract['storage'])
)
continue;
contractsFound++;
const snapshot = isSnapshotContract(path);
const stored = contract['storage']['storageHash'];
const oldHash = snapshot ? basename(dirname(path)) : typeof stored === 'string' ? stored : '';
const rewrite = rewriteContract(contract);
if (!rewrite.changed) continue;
if (computeStorageHash(contract) !== oldHash)
notices.push(`${display(path)}: stored hash did not recompute; rehashed from content`);
const newHash = computeStorageHash(rewrite.contract);
const contractWithHash = withStorageHash(rewrite.contract, newHash);
plans.push({
path,
snapshot,
oldHash,
newHash,
content: snapshot ? snapshotForm(contractWithHash) : emittedForm(contractWithHash, text),
datetimeEnums: rewrite.datetimeEnums,
});
}
const hashes = new Map<string, string>();
for (const plan of [...plans].sort((a, b) => Number(b.snapshot) - Number(a.snapshot)))
if (plan.oldHash !== plan.newHash && !hashes.has(plan.oldHash))
hashes.set(plan.oldHash, plan.newHash);
const newDirectories = new Map<string, Map<string, string>>();
const directorySources = new Map<string, string>();
const sourceDirectories = new Set<string>();
const removals = new Set<string>();
const snapshotRewrites: [string, string][] = [];
const referenceWrites = new Map<string, string>();
const contractWrites = new Map<string, string>();
for (const plan of plans) {
const dtsPath = plan.snapshot
? join(dirname(plan.path), 'contract.d.ts')
: `${plan.path.slice(0, -'.json'.length)}.d.ts`;
const dtsHashes = plan.snapshot ? hashes : new Map([...hashes, [plan.oldHash, plan.newHash]]);
const dts = existsSync(dtsPath)
? rewriteDts(readFileSync(dtsPath, 'utf8'), plan.datetimeEnums, dtsHashes)
: undefined;
if (!plan.snapshot) {
if (dts !== undefined) referenceWrites.set(dtsPath, dts);
contractWrites.set(plan.path, plan.content);
continue;
}
const oldDir = dirname(plan.path);
const newDir = join(dirname(oldDir), plan.newHash);
sourceDirectories.add(oldDir);
if (newDir === oldDir) {
if (dts !== undefined) snapshotRewrites.push([dtsPath, dts]);
snapshotRewrites.push([plan.path, plan.content]);
continue;
}
removals.add(oldDir);
const content = readDirectory(oldDir);
content.set('contract.json', plan.content);
if (dts !== undefined) content.set('contract.d.ts', dts);
const planned = newDirectories.get(newDir);
if (planned !== undefined && !sameFiles(planned, content)) {
stops.push(`${display(newDir)}: snapshot directory already exists with different content`);
continue;
}
newDirectories.set(newDir, content);
directorySources.set(newDir, oldDir);
}
for (const [dir, content] of newDirectories) {
if (sourceDirectories.has(dir) || !existsSync(dir)) continue;
if (sameFiles(readDirectory(dir), content)) newDirectories.delete(dir);
else stops.push(`${display(dir)}: snapshot directory already exists with different content`);
}
for (const path of files.migrationJson) {
const text = readFileSync(path, 'utf8');
const metadata = parseJson(text);
if (!isRecord(metadata)) continue;
const from = metadata['from'];
const to = metadata['to'];
const nextFrom = typeof from === 'string' ? (hashes.get(from) ?? from) : from;
const nextTo = typeof to === 'string' ? (hashes.get(to) ?? to) : to;
if (nextFrom === from && nextTo === to) continue;
const opsPath = join(dirname(path), 'ops.json');
const ops = existsSync(opsPath) ? parseJson(readFileSync(opsPath, 'utf8')) : undefined;
if (existsSync(opsPath) && ops === undefined) {
stops.push(`${display(opsPath)}: not valid JSON`);
continue;
}
const oldMigrationHash = metadata['migrationHash'];
let next = replaceQuotedHashes(text, hashes);
if (
ops !== undefined &&
typeof oldMigrationHash === 'string' &&
nextFrom !== undefined &&
nextTo !== undefined
) {
const migrationHash = computeMigrationHash({ ...metadata, from: nextFrom, to: nextTo }, ops);
next = next.replace(`"${oldMigrationHash}"`, `"${migrationHash}"`);
}
referenceWrites.set(path, next);
}
for (const path of files.refs) {
const text = readFileSync(path, 'utf8');
const next = replaceQuotedHashes(text, hashes);
if (next !== text) referenceWrites.set(path, next);
}
for (const path of files.migrationTs) {
const text = readFileSync(path, 'utf8');
const next = text.replace(
/(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])/g,
(hash: string) => hashes.get(hash) ?? hash,
);
if (next !== text) referenceWrites.set(path, next);
}
if (stops.length > 0) {
process.stderr.write(`${[...new Set(stops)].join('\n')}\n`);
return 1;
}
try {
for (const path of files.leftovers) rmSync(path, { recursive: true, force: true });
for (const [dir, content] of newDirectories)
writeDirectory(dir, content, directorySources.get(dir) ?? dir);
for (const [path, text] of snapshotRewrites) writeFile(path, text);
for (const [path, text] of referenceWrites) writeFile(path, text);
for (const [path, text] of contractWrites) writeFile(path, text);
for (const dir of removals) removeDirectory(dir);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(
`${message}; the upgrade stopped partway, run the script again to finish it\n`,
);
return 1;
}
const rewrittenFiles = snapshotRewrites.length + referenceWrites.size + contractWrites.size;
const lines = [
...notices,
...summary(root, contractsFound, rewrittenFiles, removals.size, hashes),
];
process.stdout.write(`${lines.join('\n')}\n`);
return 0;
}
process.exitCode = main(parseRoot(process.argv.slice(2)));
upgrading/extension/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/scripts/default-generators-to-on-create/rewrite-default-generators.mjs
#!/usr/bin/env node
// Rewrites `@default(uuid(...))`, `@default(cuid(...))`, `@default(ulid())` and `@default(nanoid(...))`
// to `@onCreate(...)` in the Prisma 8 schema files named on the command line. The call is kept as
// written; string literals and `//` comments are left unchanged. A file without the `// use prisma-8` directive (or the
// older `// use prisma-next`) is left unchanged.
import { readFileSync, writeFileSync } from 'node:fs';
import process from 'node:process';
const CLIENT_FUNCTION_IN_DEFAULT = /@default\((\s*(?:uuid|cuid|ulid|nanoid)\s*\([^()]*\)\s*)\)/g;
// The directive rule contract emit uses, copied from isPrismaNextSchema in
// packages/1-framework/2-authoring/psl-parser/src/schema-directive.ts; this script ships standalone.
const PRISMA8_DIRECTIVE = /^(\s*\/\/ *use +)(prisma-8|prisma-next)( *)(?!\S)/;
/** The line cut into code, string literals and a trailing `//` comment, with only the code to rewrite. */
function spansOf(line) {
const spans = [];
let codeStart = 0;
let index = 0;
while (index < line.length) {
if (line[index] === '/' && line[index + 1] === '/') break;
if (line[index] !== '"') {
index += 1;
continue;
}
spans.push({ text: line.slice(codeStart, index), code: true });
let close = index + 1;
while (close < line.length && line[close] !== '"') close += line[close] === '\\' ? 2 : 1;
spans.push({ text: line.slice(index, close + 1), code: false });
index = close + 1;
codeStart = index;
}
spans.push({ text: line.slice(codeStart, index), code: true });
spans.push({ text: line.slice(index), code: false });
return spans;
}
function rewrite(text) {
let count = 0;
const lines = text.split('\n').map((line) =>
spansOf(line)
.map(({ text: span, code }) =>
code
? span.replace(CLIENT_FUNCTION_IN_DEFAULT, (_match, call) => {
count += 1;
return `@onCreate(${call})`;
})
: span,
)
.join(''),
);
return { text: lines.join('\n'), count };
}
const files = process.argv.slice(2);
if (files.length === 0) {
process.stderr.write('Usage: node rewrite-default-generators.mjs <schema.prisma>...\n');
process.exit(1);
}
const texts = new Map();
for (const file of files) {
try {
texts.set(file, readFileSync(file, 'utf8'));
} catch {
process.stderr.write(`${file}: cannot be read; no file was changed.\n`);
process.exit(1);
}
}
for (const [file, text] of texts) {
if (!PRISMA8_DIRECTIVE.test(text)) {
process.stdout.write(
`${file}: has no "// use prisma-8" directive, so it is not a Prisma 8 schema; left unchanged. A Prisma 7 schema keeps @default(uuid()).\n`,
);
continue;
}
const rewritten = rewrite(text);
if (rewritten.count === 0) {
process.stdout.write(`${file}: no client function in @default; left unchanged.\n`);
continue;
}
writeFileSync(file, rewritten.text);
process.stdout.write(
`${file}: rewrote ${rewritten.count} @default ${rewritten.count === 1 ? 'call' : 'calls'} to @onCreate.\n`,
);
}
upgrading/extension/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/scripts/mongo-field-storage-names/mongo-field-storage-names.ts
#!/usr/bin/env node
// Adds `storage.fields` to every MongoDB contract written before models recorded the stored name of each field.
// Each field keeps its name and is recorded as stored under that name, which is what the old contract meant.
// Usage: node mongo-field-storage-names.ts [project-root]
import {
readdirSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { join, relative, resolve } from 'node:path';
const SKIPPED_DIRECTORIES = new Set(['node_modules', 'dist', 'build']);
const TEMPORARY_SUFFIX = '.mongo-field-storage-names-tmp';
type JsonObject = { [key: string]: unknown };
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
/** The JSON files under `root`, each once, by the first path that reaches it. */
function listJsonFiles(root: string): string[] {
const visited = new Set<string>();
const found = new Map<string, string>();
const visit = (dir: string): void => {
const realDir = realpathSync(dir);
if (visited.has(realDir)) return;
visited.add(realDir);
for (const entry of readdirSync(dir).sort()) {
const path = join(dir, entry);
if (entry.endsWith(TEMPORARY_SUFFIX)) {
rmSync(path, { force: true });
continue;
}
if (isDirectory(path)) {
if (!SKIPPED_DIRECTORIES.has(entry) && !entry.startsWith('.')) visit(path);
continue;
}
if (!entry.endsWith('.json') || !isFile(path)) continue;
const realFile = realpathSync(path);
if (!found.has(realFile)) found.set(realFile, path);
}
};
visit(root);
return [...found.values()];
}
function parseRoot(args: readonly string[]): { readonly root: string; readonly errors: string[] } {
const errors = args.filter((arg) => arg.startsWith('-')).map((arg) => `${arg}: unknown option`);
const positional = args.filter((arg) => !arg.startsWith('-'));
if (positional.length > 1) errors.push(`expected one project root, got ${positional.length}`);
const root = resolve(positional[0] ?? process.cwd());
if (errors.length === 0 && !isDirectory(root)) errors.push(`${root}: not a directory`);
if (errors.length > 0) errors.push('Usage: node mongo-field-storage-names.ts [project-root]');
return { root, errors };
}
function modelsOf(contract: JsonObject): [string, JsonObject][] {
const domain = contract['domain'];
if (!isObject(domain) || !isObject(domain['namespaces'])) return [];
return Object.values(domain['namespaces']).flatMap((namespace) =>
isObject(namespace) && isObject(namespace['models'])
? Object.entries(namespace['models']).filter((entry): entry is [string, JsonObject] =>
isObject(entry[1]),
)
: [],
);
}
function needsStorageFields(model: JsonObject): boolean {
return isObject(model['fields']) && isObject(model['storage']) && !('fields' in model['storage']);
}
function withStorageFields(model: JsonObject): JsonObject {
const storage = model['storage'];
const fields = model['fields'];
if (!isObject(storage) || !isObject(fields)) return model;
const storageFields = Object.fromEntries(
Object.keys(fields).map((name) => [name, { field: name }]),
);
const rewritten: JsonObject = {};
if ('collection' in storage) rewritten['collection'] = storage['collection'];
rewritten['fields'] = storageFields;
for (const [key, value] of Object.entries(storage)) {
if (key !== 'collection') rewritten[key] = value;
}
return { ...model, storage: rewritten };
}
function rewriteContract(contract: JsonObject, modelNames: ReadonlySet<string>): JsonObject {
const domain = contract['domain'];
if (!isObject(domain) || !isObject(domain['namespaces'])) return contract;
const namespaces = Object.fromEntries(
Object.entries(domain['namespaces']).map(([id, namespace]) => {
if (!isObject(namespace) || !isObject(namespace['models'])) return [id, namespace];
const models = Object.fromEntries(
Object.entries(namespace['models']).map(([name, model]) => [
name,
isObject(model) && modelNames.has(name) ? withStorageFields(model) : model,
]),
);
return [id, { ...namespace, models }];
}),
);
return { ...contract, domain: { ...domain, namespaces } };
}
function serializeLike(original: string, value: JsonObject): string {
const trailingNewline = original.endsWith('\n') ? '\n' : '';
const singleLine = !original.trimEnd().includes('\n');
return `${singleLine ? JSON.stringify(value) : JSON.stringify(value, null, 2)}${trailingNewline}`;
}
function matchingBrace(text: string, open: number): number {
let depth = 0;
for (let index = open; index < text.length; index++) {
const char = text[index];
if (char === "'" || char === '"' || char === '`') {
index = text.indexOf(char, index + 1);
if (index === -1) return -1;
continue;
}
if (char === '{') depth++;
if (char === '}') {
depth--;
if (depth === 0) return index;
}
}
return -1;
}
function typeKey(name: string, quote: string): string {
return /^[A-Za-z_$][\w$]*$/.test(name) ? name : `${quote}${name}${quote}`;
}
function storageFieldsType(fieldNames: readonly string[], quote: string): string {
if (fieldNames.length === 0) return 'readonly fields: Record<string, never>';
const entries = fieldNames.map(
(name) => `readonly ${typeKey(name, quote)}: { readonly field: ${quote}${name}${quote} }`,
);
return `readonly fields: { ${entries.join('; ')} }`;
}
const STORAGE_KEY = /readonly storage:\s*/g;
/** How many braces are open at `end` in `text`, outside string literals. */
function braceDepth(text: string, end: number): number {
let depth = 0;
for (let index = 0; index < end; index++) {
const char = text[index];
if (char === "'" || char === '"' || char === '`') {
const close = text.indexOf(char, index + 1);
if (close === -1 || close >= end) return depth;
index = close;
continue;
}
if (char === '{') depth++;
if (char === '}') depth--;
}
return depth;
}
/** The model's `readonly storage: ...` type with `readonly fields` added, or undefined when the model's block is not found. */
function rewriteModelType(
declarations: string,
modelName: string,
fieldNames: readonly string[],
): string | undefined {
const quote = declarations.includes("readonly collection: '") ? "'" : '"';
const header = new RegExp(`readonly (?:${modelName}|'${modelName}'|"${modelName}"):\\s*\\{`, 'g');
for (const match of declarations.matchAll(header)) {
const open = match.index + match[0].length - 1;
const close = matchingBrace(declarations, open);
if (close === -1) continue;
const block = declarations.slice(open, close + 1);
STORAGE_KEY.lastIndex = 0;
for (const storageMatch of block.matchAll(STORAGE_KEY)) {
if (braceDepth(block, storageMatch.index) !== 1) continue;
const typeStart = open + storageMatch.index + storageMatch[0].length;
const fields = storageFieldsType(fieldNames, quote);
if (declarations.startsWith('Record<string, never>', typeStart)) {
return `${declarations.slice(0, typeStart)}{ ${fields} }${declarations.slice(typeStart + 'Record<string, never>'.length)}`;
}
if (declarations[typeStart] !== '{') continue;
const typeEnd = matchingBrace(declarations, typeStart);
const inner = declarations
.slice(typeStart + 1, typeEnd)
.trim()
.replace(/;$/, '');
const members = inner.length === 0 ? [] : [inner];
const collectionEnd = inner.match(/^readonly collection:\s*(['"])[^'"]*\1/)?.[0];
const rewritten =
collectionEnd === undefined
? [fields, ...members]
: [collectionEnd, fields, inner.slice(collectionEnd.length).replace(/^;\s*/, '')].filter(
(member) => member.length > 0,
);
return `${declarations.slice(0, typeStart)}{ ${rewritten.join('; ')} }${declarations.slice(typeEnd + 1)}`;
}
}
return undefined;
}
function writeFile(path: string, content: string): void {
const target = realpathSync(path);
const temporary = `${target}${TEMPORARY_SUFFIX}`;
writeFileSync(temporary, content);
renameSync(temporary, target);
}
function main({
root,
errors,
}: {
readonly root: string;
readonly errors: readonly string[];
}): number {
if (errors.length > 0) {
process.stderr.write(`${errors.join('\n')}\n`);
return 1;
}
const files = listJsonFiles(root);
const changed: string[] = [];
const warnings: string[] = [];
let mongoContracts = 0;
for (const path of files.sort()) {
let contract: unknown;
try {
contract = JSON.parse(readFileSync(path, 'utf8'));
} catch {
continue;
}
if (!isObject(contract) || contract['targetFamily'] !== 'mongo') continue;
mongoContracts++;
const oldModels = modelsOf(contract).filter(([, model]) => needsStorageFields(model));
if (oldModels.length === 0) continue;
const original = readFileSync(path, 'utf8');
const modelNames = new Set(oldModels.map(([name]) => name));
writeFile(path, serializeLike(original, rewriteContract(contract, modelNames)));
changed.push(relative(root, path));
const declarationsPath = `${path.slice(0, -'.json'.length)}.d.ts`;
let declarations: string;
try {
declarations = readFileSync(declarationsPath, 'utf8');
} catch {
continue;
}
let rewritten = declarations;
for (const [name, model] of oldModels) {
const fields = isObject(model['fields']) ? Object.keys(model['fields']) : [];
const next = rewriteModelType(rewritten, name, fields);
if (next === undefined) {
warnings.push(
`${relative(root, declarationsPath)}: no storage type found for model ${name}; add readonly fields to it by hand`,
);
continue;
}
rewritten = next;
}
if (rewritten !== declarations) {
writeFile(declarationsPath, rewritten);
changed.push(relative(root, declarationsPath));
}
}
for (const warning of warnings) process.stderr.write(`${warning}\n`);
if (mongoContracts === 0) {
process.stdout.write('No MongoDB contract found under the project root; nothing changed.\n');
return 0;
}
if (changed.length === 0) {
process.stdout.write(
'Every MongoDB contract already records its stored names; nothing changed.\n',
);
return 0;
}
for (const path of changed) process.stdout.write(`${path}\n`);
process.stdout.write(`Rewrote ${changed.length} file${changed.length === 1 ? '' : 's'}.\n`);
return warnings.length > 0 ? 1 : 0;
}
process.exitCode = main(parseRoot(process.argv.slice(2)));
upgrading/extension/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/scripts/remove-nocheck/remove-nocheck.ts
#!/usr/bin/env node
/**
* Removes the `noCheck` key from every column of every SQL contract in a project. Where the key waived the element check of a list, the column and the field that maps it become lists whose elements may be null, in `contract.json` and `contract.d.ts`, because that is what the database enforces. The checks each contract declares are left as they are. In a PostgreSQL contract it also writes each literal default in the contract form this release stores, as its column's data type and parameters give it. The script then renames the snapshot directories and rewrites every migration, ref, `migration.ts` and `contract.d.ts` that names an old storage hash.
*
* Usage: node remove-nocheck.ts [project-root] [--default <namespace>.<table>.<column>=<JSON value>]...
*
* The project root defaults to the working directory. The script reads and writes files only. It needs no database, network or configured stack, and a project already in this release's form is left unchanged. When it stops (a file it must read is not valid JSON, a contract whose stored hash does not match its content, a default that this release refuses, or a snapshot directory that already exists with different content) it changes no file, prints one line per case and exits 1. When it finishes it prints how many files and snapshot directories it changed and each storage hash it replaced.
*
* `--default` gives the value to store in place of a default this release refuses, in every stored contract where that column's default is refused. The script checks the value by the same rules, and stops when a `--default` names a column whose default it does not refuse.
*
* The storage hash and migration hash rules are copied from `@internal/contract` and `@internal/migration-tools`, and the default rules from the data types of 8.0.0-rc.19, PostgreSQL's built-in types, `postgis/geometry` and `pgvector/vector`, so the script gives the same result whatever is installed.
*/
import { createHash } from 'node:crypto';
import {
chmodSync,
type Dirent,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
type Json = null | boolean | number | string | Json[] | JsonRecord;
interface JsonRecord {
[key: string]: Json;
}
const SKIPPED_DIRECTORIES = new Set(['node_modules', 'dist', 'build']);
const HASH = /^[0-9a-f]{64}$/;
const TEMPORARY_SUFFIX = '.remove-nocheck-tmp';
function isRecord(value: unknown): value is JsonRecord {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const prototype: unknown = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function parseJson(text: string): Json | undefined {
try {
const value: Json = JSON.parse(text);
return value;
} catch {
return undefined;
}
}
function sha256(text: string): string {
return createHash('sha256').update(text).digest('hex');
}
function compareCodeUnits(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
function sortKeys(value: Json): Json {
if (Array.isArray(value)) return value.map(sortKeys);
if (!isRecord(value)) return value;
const sorted: JsonRecord = {};
for (const key of Object.keys(value).sort(compareCodeUnits)) {
const child = value[key];
if (child !== undefined) sorted[key] = sortKeys(child);
}
return sorted;
}
function canonicalizeJson(value: Json): string {
return JSON.stringify(sortKeys(value));
}
type PathSegment = string | readonly string[];
function matchesPath(path: readonly string[], pattern: readonly PathSegment[]): boolean {
if (path.length !== pattern.length) return false;
return pattern.every((segment, index) => {
const value = path[index];
if (value === undefined) return false;
if (segment === '*') return true;
return typeof segment === 'string' ? value === segment : segment.includes(value);
});
}
const SQL_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
['storage', 'namespaces', '*', 'entries', 'table'],
['storage', 'namespaces', '*', 'entries', 'table', '*'],
['storage', 'namespaces', '*', 'entries', 'table', '*', ['uniques', 'indexes', 'foreignKeys']],
['storage', 'namespaces', '*', 'entries', 'table', '*', 'indexes', 'unique'],
['storage', 'namespaces', '*', 'entries', 'table', '*', 'columns', '*', 'default', 'value'],
];
const COLUMN_DEFAULT_VALUE: readonly PathSegment[] = [
'storage',
'namespaces',
'*',
'entries',
'table',
'*',
'columns',
'*',
'default',
'value',
];
const FRAMEWORK_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
['domain', 'namespaces'],
['domain', 'namespaces', '*'],
['domain', 'namespaces', '*', 'models'],
['domain', 'namespaces', '*', 'models', '*', 'relations'],
['domain', 'namespaces', '*', 'models', '*', 'storage'],
['storage', 'namespaces'],
['storage', 'namespaces', '*', 'entries'],
['roots'],
['extensions'],
['extensions', '*'],
['capabilities'],
['meta'],
['execution', 'mutations', 'defaults'],
];
const TOP_LEVEL_ORDER = [
'schemaVersion',
'canonicalVersion',
'targetFamily',
'target',
'profileHash',
'roots',
'domain',
'storage',
'execution',
'capabilities',
'extensions',
'defaultControlPolicy',
'meta',
];
function isDefaultValue(value: Json): boolean {
if (value === false) return true;
if (Array.isArray(value)) return value.length === 0;
return isRecord(value) && Object.keys(value).length === 0;
}
function isPreservedEmpty(path: readonly string[], key: string): boolean {
if (key === 'nullable') return true;
if (key === 'elementNullable' && path.at(-2) === 'many') return true;
if (FRAMEWORK_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern))) return true;
if (
path.length >= COLUMN_DEFAULT_VALUE.length &&
matchesPath(path.slice(0, COLUMN_DEFAULT_VALUE.length), COLUMN_DEFAULT_VALUE)
)
return true;
return SQL_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern));
}
function omitDefaults(value: Json, path: readonly string[]): Json {
if (Array.isArray(value)) return value.map((item) => omitDefaults(item, path));
if (!isRecord(value)) return value;
const result: JsonRecord = {};
for (const [key, child] of Object.entries(value)) {
const childPath = [...path, key];
if (key === '_generated') continue;
if (key === 'generated' && child === false) continue;
if ((key === 'onDelete' || key === 'onUpdate') && child === 'noAction') continue;
if (isDefaultValue(child) && !isPreservedEmpty(childPath, key)) continue;
result[key] = omitDefaults(child, childPath);
}
return result;
}
function compareByName(a: Json, b: Json): number {
const nameA = isRecord(a) && typeof a['name'] === 'string' ? a['name'] : '';
const nameB = isRecord(b) && typeof b['name'] === 'string' ? b['name'] : '';
return compareCodeUnits(nameA, nameB);
}
function sortTableArrays(storage: Json): Json {
if (!isRecord(storage)) return storage;
const namespaces = storage['namespaces'];
if (!isRecord(namespaces)) return storage;
const sortedNamespaces: JsonRecord = {};
for (const [namespaceId, namespace] of Object.entries(namespaces)) {
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
const tables = isRecord(entries) ? entries['table'] : undefined;
if (!isRecord(namespace) || !isRecord(entries) || !isRecord(tables)) {
sortedNamespaces[namespaceId] = namespace;
continue;
}
const sortedTables: JsonRecord = {};
for (const [tableName, table] of Object.entries(tables)) {
if (!isRecord(table)) {
sortedTables[tableName] = table;
continue;
}
const sortedTable: JsonRecord = { ...table };
for (const key of ['checks', 'indexes', 'uniques']) {
const list = table[key];
if (Array.isArray(list)) sortedTable[key] = [...list].sort(compareByName);
}
sortedTables[tableName] = sortedTable;
}
sortedNamespaces[namespaceId] = { ...namespace, entries: { ...entries, table: sortedTables } };
}
return { ...storage, namespaces: sortedNamespaces };
}
function orderTopLevel(value: JsonRecord): JsonRecord {
const ordered: JsonRecord = {};
const remaining = new Set(Object.keys(value));
for (const key of TOP_LEVEL_ORDER) {
const child = value[key];
if (child !== undefined) {
ordered[key] = child;
remaining.delete(key);
}
}
for (const key of [...remaining].sort(compareCodeUnits)) {
const child = value[key];
if (child !== undefined) ordered[key] = child;
}
return ordered;
}
function omitNamespaceKinds(storage: JsonRecord): JsonRecord {
const namespaces = storage['namespaces'];
if (!isRecord(namespaces)) return storage;
const stripped: JsonRecord = {};
for (const [namespaceId, namespace] of Object.entries(namespaces)) {
if (isRecord(namespace)) {
const { kind: _kind, ...rest } = namespace;
stripped[namespaceId] = rest;
} else {
stripped[namespaceId] = namespace;
}
}
return { ...storage, namespaces: stripped };
}
function computeStorageHash(contract: JsonRecord): string {
const storage = isRecord(contract['storage']) ? contract['storage'] : {};
const { storageHash: _published, ...withoutHash } = storage;
const hashed: JsonRecord = {
schemaVersion: '1',
targetFamily: typeof contract['targetFamily'] === 'string' ? contract['targetFamily'] : '',
target: typeof contract['target'] === 'string' ? contract['target'] : '',
profileHash: '',
roots: {},
domain: { namespaces: {} },
storage: omitNamespaceKinds(withoutHash),
extensions: {},
capabilities: {},
meta: {},
};
const withoutDefaults = omitDefaults(hashed, []);
if (!isRecord(withoutDefaults)) return '';
const withSortedStorage = {
...withoutDefaults,
storage: sortTableArrays(withoutDefaults['storage'] ?? {}),
};
const sorted = sortKeys(withSortedStorage);
return isRecord(sorted) ? sha256(JSON.stringify(orderTopLevel(sorted), null, 2)) : '';
}
function computeMigrationHash(metadata: JsonRecord, ops: Json): string {
const { migrationHash: _migrationHash, ...withoutHash } = metadata;
const parts = [canonicalizeJson(withoutHash), canonicalizeJson(ops)].map(sha256);
return sha256(canonicalizeJson(parts));
}
interface ProjectFiles {
readonly json: readonly string[];
readonly migrationJson: readonly string[];
readonly refs: readonly string[];
readonly migrationTs: readonly string[];
readonly leftovers: readonly string[];
}
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
/** A file, or a link to one: the script reads and writes through links to files. */
function isFileEntry(dir: string, entry: Dirent): boolean {
return entry.isFile() || (entry.isSymbolicLink() && isFile(join(dir, entry.name)));
}
/** A file the script reads, by the path it was reached through: its own path rather than a link's. */
interface FoundFile {
readonly path: string;
readonly linked: boolean;
readonly inMigrationPackage: boolean;
}
function listFiles(root: string): ProjectFiles {
const leftovers: string[] = [];
const visited = new Set<string>();
const found = new Map<string, FoundFile>();
const visit = (dir: string): void => {
const realDir = realpathSync(dir);
if (visited.has(realDir)) return;
visited.add(realDir);
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
compareCodeUnits(a.name, b.name),
);
const inMigrationPackage = entries.some(
(entry) => entry.name === 'migration.json' && isFileEntry(dir, entry),
);
for (const entry of entries) {
const path = join(dir, entry.name);
if (entry.name.endsWith(TEMPORARY_SUFFIX)) {
leftovers.push(path);
continue;
}
if (entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(path))) {
if (!SKIPPED_DIRECTORIES.has(entry.name) && !entry.name.startsWith('.')) visit(path);
continue;
}
if (!isFileEntry(dir, entry)) continue;
const realFile = realpathSync(path);
const linked = entry.isSymbolicLink();
const known = found.get(realFile);
if (known === undefined || (known.linked && !linked))
found.set(realFile, { path, linked, inMigrationPackage });
}
};
visit(root);
const files = [...found.values()];
const named = (test: (file: FoundFile, name: string) => boolean): string[] =>
files.filter((file) => test(file, basename(file.path))).map((file) => file.path);
return {
json: named((_file, name) => name.endsWith('.json')),
migrationJson: named((_file, name) => name === 'migration.json'),
refs: named((file, name) => name.endsWith('.json') && basename(dirname(file.path)) === 'refs'),
migrationTs: named((file, name) => name === 'migration.ts' && file.inMigrationPackage),
leftovers,
};
}
function isSnapshotContract(path: string): boolean {
const snapshotDir = dirname(path);
return (
basename(path) === 'contract.json' &&
HASH.test(basename(snapshotDir)) &&
basename(dirname(snapshotDir)) === 'snapshots'
);
}
function replaceQuotedHashes(text: string, hashes: ReadonlyMap<string, string>): string {
return text.replace(/"([0-9a-f]{64})"/g, (match: string, hash: string) => {
const next = hashes.get(hash);
return next === undefined ? match : `"${next}"`;
});
}
function readDirectory(dir: string): Map<string, string> {
const files = new Map<string, string>();
if (!existsSync(dir)) return files;
for (const entry of readdirSync(dir, { withFileTypes: true }))
if (isFileEntry(dir, entry) && !entry.name.endsWith(TEMPORARY_SUFFIX))
files.set(entry.name, readFileSync(join(dir, entry.name), 'utf8'));
return files;
}
function sameFiles(a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): boolean {
return a.size === b.size && [...a].every(([name, content]) => b.get(name) === content);
}
function temporaryPath(path: string): string {
return join(dirname(path), `.${basename(path)}${TEMPORARY_SUFFIX}`);
}
function writeWithModeOf(path: string, text: string, modeSource: string): void {
writeFileSync(path, text);
if (existsSync(modeSource)) chmodSync(path, statSync(modeSource).mode & 0o7777);
}
function writeFile(path: string, text: string): void {
const target = realpathSync(path);
const temporary = temporaryPath(target);
writeWithModeOf(temporary, text, target);
renameSync(temporary, target);
}
function writeDirectory(dir: string, files: ReadonlyMap<string, string>, sourceDir: string): void {
const temporary = temporaryPath(dir);
mkdirSync(temporary);
for (const [name, text] of files)
writeWithModeOf(join(temporary, name), text, join(sourceDir, name));
renameSync(temporary, dir);
}
function removeDirectory(dir: string): void {
const temporary = temporaryPath(dir);
renameSync(dir, temporary);
rmSync(temporary, { recursive: true, force: true });
}
function count(amount: number, singular: string, plural: string): string {
return `${amount} ${amount === 1 ? singular : plural}`;
}
const REMOVED_KEY = 'noCheck';
const ELEMENT_KIND = 'elementNotNull';
function listKey(namespace: string, owner: string, member: string): string {
return `${namespace}\u0000${owner}\u0000${member}`;
}
interface Rewrite {
readonly contract: JsonRecord;
readonly changed: boolean;
readonly columns: ReadonlySet<string>;
readonly fields: ReadonlySet<string>;
readonly defaults: ReadonlyMap<string, readonly DefaultRewrite[]>;
readonly notValues: readonly string[];
}
interface DefaultRewrite {
readonly from: string | number;
readonly to: string | number;
}
type DefaultForm =
| { readonly kind: 'unchanged' }
| { readonly kind: 'rewritten'; readonly value: Json }
| { readonly kind: 'notAValue'; readonly reason: string };
type Params = Readonly<Record<string, Json>>;
class NotAValue extends Error {}
class CastRefused extends Error {}
function refuseJsonValue(typeId: string, expected: string): never {
throw new NotAValue(`${typeId} JSON value must be ${expected}`);
}
function withoutTrailing(text: string, character: string): string {
let end = text.length;
while (end > 0 && text[end - 1] === character) end -= 1;
return end === text.length ? text : text.slice(0, end);
}
function counted(amount: number, noun: string): string {
return `${amount} ${amount === 1 ? noun : `${noun}s`}`;
}
function integerParam(params: Params, name: string): number | undefined {
const value = params[name];
return typeof value === 'number' ? value : undefined;
}
function readJsonString(typeId: string, json: Json): string {
return typeof json === 'string' ? json : refuseJsonValue(typeId, 'a string');
}
function readJsonMatching(typeId: string, json: Json, pattern: RegExp, form: string): string {
return typeof json === 'string' && pattern.test(json) ? json : refuseJsonValue(typeId, form);
}
function readJsonInteger(typeId: string, json: Json, min: number, max: number): Json {
return typeof json === 'number' && Number.isInteger(json) && json >= min && json <= max
? json
: refuseJsonValue(typeId, `an integer from ${min} to ${max}`);
}
const INT64_MIN = -(2n ** 63n);
const INT64_MAX = 2n ** 63n - 1n;
function readInt8(json: Json): Json {
const expected = `a decimal integer string from ${INT64_MIN} to ${INT64_MAX}`;
const value = BigInt(readJsonMatching('pg/int8', json, /^-?\d+$/, expected));
if (value < INT64_MIN || value > INT64_MAX) return refuseJsonValue('pg/int8', expected);
const written = value.toString();
if (json !== written)
return refuseJsonValue(
'pg/int8',
`"${written}", the integer's decimal text without leading zeros or a minus sign on zero`,
);
return json;
}
const NON_FINITE_TEXT: ReadonlySet<string> = new Set(['NaN', 'Infinity', '-Infinity']);
function readJsonFloat(typeId: string, json: Json): number {
if (typeof json === 'number' && Number.isFinite(json)) return json;
if (typeof json === 'string' && NON_FINITE_TEXT.has(json)) return Number(json);
return refuseJsonValue(typeId, 'a finite number or the text NaN, Infinity or -Infinity');
}
const FLOAT4_MAX = 3.4028234663852886e38;
function fitsFloat4(value: number): boolean {
const single = Math.fround(value);
return Number.isFinite(single) && (value === 0 || single !== 0);
}
function readFloat4(json: Json): Json {
const value = readJsonFloat('pg/float4', json);
if (Number.isFinite(value) && !fitsFloat4(value))
return refuseJsonValue(
'pg/float4',
`a number float4 holds, at most ${FLOAT4_MAX} in magnitude and not so small that it becomes 0, or the text NaN, Infinity or -Infinity`,
);
return json;
}
const DECIMAL_NUMERAL_TEXT = /^-?\d+(?:\.\d+)?$/;
const STORED_NUMERIC_TEXT = /^(?:(?!-0(?:\.0+)?$)-?(?:0|[1-9]\d*)(?:\.\d+)?|NaN|-?Infinity)$/;
function storedNumeralText(text: string): string {
const numeral = /^([+-]?)0*(\d+)(\.\d+)?$/.exec(text);
if (numeral === null) return text;
const [, sign = '', whole = '', fraction = ''] = numeral;
const digits = `${whole}${fraction}`;
return sign !== '-' || /^[0.]+$/.test(digits) ? digits : `-${digits}`;
}
function fitsNumeric(text: string, precision: number, scale: number): boolean {
if (text === 'NaN') return true;
if (text.endsWith('Infinity')) return false;
const [whole = '', fraction = ''] = text.replace(/^-/, '').split('.');
const significantFraction = withoutTrailing(fraction, '0');
const digits = `${whole}${significantFraction}`.replace(/^0+/, '');
if (digits === '') return true;
const shift = scale - significantFraction.length;
if (shift >= 0) return digits.length + shift <= precision;
const units = withoutTrailing(digits, '0');
const droppedZeros = digits.length - units.length;
if (droppedZeros < -shift) return false;
return digits.length + shift <= precision;
}
function readNumeric(json: Json, params: Params): Json {
if (typeof json !== 'string' || !STORED_NUMERIC_TEXT.test(json)) {
const printed =
typeof json === 'string' && DECIMAL_NUMERAL_TEXT.test(json)
? storedNumeralText(json)
: undefined;
return refuseJsonValue(
'pg/numeric',
printed === undefined ? 'a decimal string' : `"${printed}", as PostgreSQL writes this value`,
);
}
const precision = integerParam(params, 'precision');
if (precision === undefined) return json;
const scale = integerParam(params, 'scale') ?? 0;
if (!fitsNumeric(json, precision, scale))
return refuseJsonValue(
'pg/numeric',
`a decimal string that numeric(${precision}, ${scale}) stores without rounding`,
);
return json;
}
function spellNumeric(json: Json, params: Params): Json {
const precision = integerParam(params, 'precision');
if (precision === undefined || typeof json !== 'string' || json === 'NaN') return json;
const scale = integerParam(params, 'scale') ?? 0;
const [whole = '', fraction = ''] = json.split('.');
return scale <= 0 ? whole : `${whole}.${fraction.slice(0, scale).padEnd(scale, '0')}`;
}
function fitsCharacterLength(text: string, length: number, blankPadded: boolean): boolean {
const significant = blankPadded ? withoutTrailing(text, ' ') : text;
return significant.length <= length || [...significant].length <= length;
}
function readCharacter(json: Json, params: Params): Json {
const length = integerParam(params, 'length') ?? 1;
if (!fitsCharacterLength(readJsonString('pg/char', json), length, true))
return refuseJsonValue(
'pg/char',
`a string of at most ${counted(length, 'character')} before any trailing spaces`,
);
return json;
}
function spellCharacter(json: Json): Json {
return typeof json === 'string' ? withoutTrailing(json, ' ') : json;
}
function readCharacterVarying(json: Json, params: Params): Json {
const length = integerParam(params, 'length');
const text = readJsonString('pg/varchar', json);
if (length !== undefined && !fitsCharacterLength(text, length, false))
return refuseJsonValue('pg/varchar', `a string of at most ${counted(length, 'character')}`);
return json;
}
const BIT_STRING = /^[01]*$/;
function readBit(json: Json, params: Params): Json {
const length = integerParam(params, 'length') ?? 1;
const bits = readJsonMatching('pg/bit', json, BIT_STRING, 'a string of 0 and 1 digits');
if (bits.length !== length)
return refuseJsonValue('pg/bit', `a string of exactly ${counted(length, 'bit')}`);
return json;
}
function readBitVarying(json: Json, params: Params): Json {
const length = integerParam(params, 'length');
const bits = readJsonMatching('pg/varbit', json, BIT_STRING, 'a string of 0 and 1 digits');
if (length !== undefined && bits.length > length)
return refuseJsonValue('pg/varbit', `a string of at most ${counted(length, 'bit')}`);
return json;
}
function readTextArray(json: Json): Json {
const expected = 'an array of strings and nulls';
if (!Array.isArray(json)) return refuseJsonValue('pg/text-array', expected);
for (const entry of json)
if (entry !== null && typeof entry !== 'string')
return refuseJsonValue('pg/text-array', expected);
return json;
}
const UUID_INPUT = /^(?:\{[0-9a-f]{4}(?:-?[0-9a-f]{4}){7}\}|[0-9a-f]{4}(?:-?[0-9a-f]{4}){7})$/i;
function storedUuid(text: string): string | undefined {
if (!UUID_INPUT.test(text)) return undefined;
const hex = text.replace(/[{}-]/g, '').toLowerCase();
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
interface InetValue {
readonly bytes: readonly number[];
readonly bits: number;
}
const isDigit = (ch: string | undefined): ch is string => ch !== undefined && /^[0-9]$/.test(ch);
function storedInet(text: string): string | undefined {
if (text.includes(':')) {
const value = readIpv6(text);
return value === undefined ? undefined : printIpv6(value);
}
const value = readIpv4(text);
return value === undefined ? undefined : printIpv4(value);
}
function readIpv4(text: string): InetValue | undefined {
const octets: number[] = [];
let index = 0;
let ch = text[index++];
while (isDigit(ch)) {
let octet = 0;
do {
octet = octet * 10 + Number(ch);
if (octet > 255) return undefined;
ch = text[index++];
} while (isDigit(ch));
if (octets.length === 4) return undefined;
octets.push(octet);
if (ch === undefined || ch === '/') break;
if (ch !== '.') return undefined;
ch = text[index++];
}
let bits = -1;
if (ch === '/' && isDigit(text[index]) && octets.length > 0) {
ch = text[index++];
bits = 0;
do {
bits = bits * 10 + Number(ch);
ch = text[index++];
} while (isDigit(ch));
if (ch !== undefined || bits > 32) return undefined;
}
if (ch !== undefined) return undefined;
if (bits === -1) {
if (octets.length !== 4) return undefined;
bits = 32;
}
if (octets.length === 0 || Math.floor(bits / 8) > octets.length) return undefined;
return { bytes: [...octets, 0, 0, 0, 0].slice(0, 4), bits };
}
function readBits(text: string): number | undefined {
let bits = 0;
let digits = 0;
for (const ch of text) {
if (!isDigit(ch)) return undefined;
if (digits++ !== 0 && bits === 0) return undefined;
bits = bits * 10 + Number(ch);
if (bits > 128) return undefined;
}
return digits === 0 ? undefined : bits;
}
function readIpv4Tail(
text: string,
): { readonly octets: readonly number[]; readonly bits: number | undefined } | undefined {
const octets: number[] = [];
let octet = 0;
let digits = 0;
for (const [index, ch] of [...text].entries()) {
if (isDigit(ch)) {
if (digits++ !== 0 && octet === 0) return undefined;
octet = octet * 10 + Number(ch);
if (octet > 255) return undefined;
continue;
}
if (ch !== '.' && ch !== '/') return undefined;
if (octets.length > 3) return undefined;
octets.push(octet);
if (ch === '/') {
const bits = readBits(text.slice(index + 1));
return bits === undefined ? undefined : { octets, bits };
}
octet = 0;
digits = 0;
}
if (digits === 0 || octets.length > 3) return undefined;
return { octets: [...octets, octet], bits: undefined };
}
function readIpv6(text: string): InetValue | undefined {
const bytes = new Array<number>(16).fill(0);
let filled = 0;
let compressedAt: number | undefined;
let index = 0;
if (text[0] === ':') {
if (text[1] !== ':') return undefined;
index = 1;
}
let tokenStart = index;
let sawHexDigit = false;
let group = 0;
let groupDigits = 0;
let bits = 128;
while (index < text.length) {
const ch = text[index++] ?? '';
if (/^[0-9a-fA-F]$/.test(ch)) {
group = (group << 4) | Number.parseInt(ch, 16);
if (++groupDigits > 4) return undefined;
sawHexDigit = true;
continue;
}
if (ch === ':') {
tokenStart = index;
if (!sawHexDigit) {
if (compressedAt !== undefined) return undefined;
compressedAt = filled;
continue;
}
if (index >= text.length || filled + 2 > 16) return undefined;
bytes[filled++] = group >> 8;
bytes[filled++] = group & 0xff;
sawHexDigit = false;
group = 0;
groupDigits = 0;
continue;
}
if (ch === '.' && filled + 4 <= 16) {
const tail = readIpv4Tail(text.slice(tokenStart));
if (tail !== undefined) {
tail.octets.forEach((octet, offset) => {
bytes[filled + offset] = octet;
});
filled += 4;
sawHexDigit = false;
bits = tail.bits ?? bits;
break;
}
}
if (ch === '/') {
const prefix = readBits(text.slice(index));
if (prefix !== undefined) {
bits = prefix;
break;
}
}
return undefined;
}
if (sawHexDigit) {
if (filled + 2 > 16) return undefined;
bytes[filled++] = group >> 8;
bytes[filled++] = group & 0xff;
}
if (compressedAt !== undefined) {
if (filled === 16) return undefined;
const shifted = bytes.slice(compressedAt, filled);
bytes.fill(0, compressedAt, filled);
bytes.splice(16 - shifted.length, shifted.length, ...shifted);
filled = 16;
}
return filled === 16 ? { bytes, bits } : undefined;
}
function printIpv4({ bytes, bits }: InetValue): string {
const address = bytes.join('.');
return bits === 32 ? address : `${address}/${bits}`;
}
function printIpv6({ bytes, bits }: InetValue): string {
const groups = Array.from(
{ length: 8 },
(_, i) => ((bytes[2 * i] ?? 0) << 8) | (bytes[2 * i + 1] ?? 0),
);
const zeroRun = longestZeroRun(groups);
let text = '';
for (let i = 0; i < 8; i++) {
if (zeroRun !== undefined && i >= zeroRun.start && i < zeroRun.start + zeroRun.length) {
if (i === zeroRun.start) text += ':';
continue;
}
if (i !== 0) text += ':';
const ipv4Tail =
i === 6 &&
zeroRun?.start === 0 &&
(zeroRun.length === 6 || (zeroRun.length === 5 && groups[5] === 0xffff));
if (ipv4Tail) {
text += bytes.slice(12).join('.');
break;
}
text += (groups[i] ?? 0).toString(16);
}
if (zeroRun !== undefined && zeroRun.start + zeroRun.length === 8) text += ':';
return bits === 128 ? text : `${text}/${bits}`;
}
function longestZeroRun(
groups: readonly number[],
): { readonly start: number; readonly length: number } | undefined {
let best: { start: number; length: number } | undefined;
let current: { start: number; length: number } | undefined;
for (const [i, group] of groups.entries()) {
if (group === 0) {
current =
current === undefined
? { start: i, length: 1 }
: { ...current, length: current.length + 1 };
continue;
}
if (current !== undefined && (best === undefined || current.length > best.length))
best = current;
current = undefined;
}
if (current !== undefined && (best === undefined || current.length > best.length)) best = current;
return best !== undefined && best.length >= 2 ? best : undefined;
}
const BASE64_TEXT = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
function spellBytea(json: Json): Json {
return typeof json === 'string' ? Buffer.from(json, 'base64').toString('base64') : json;
}
const ISO_DURATION =
/^P(?!$)(-?\d+Y)?(-?\d+M)?(-?\d+D)?(?:T(?!$)(-?\d+H)?(-?\d+M)?(-?\d+(?:\.\d+)?S)?)?$/;
const MICROS_PER_SECOND = 1_000_000n;
interface IntervalFields {
readonly months: number;
readonly days: number;
readonly micros: bigint;
}
function microsFromFraction(fraction: string, negative: boolean): bigint {
if (fraction === '') return 0n;
const kept = BigInt(fraction.slice(0, 6).padEnd(6, '0'));
const rest = fraction.slice(6).replace(/0+$/, '');
if (rest === '') return kept;
const half = rest[0] === '5' && rest.length === 1;
const roundsUp = rest > '5' || (half && !negative);
return roundsUp ? kept + 1n : kept;
}
function intervalFieldsOf(match: RegExpExecArray): IntervalFields {
const [, years, months, days, hours, minutes, seconds] = match;
const wholeNumber = (part: string | undefined): number =>
part === undefined ? 0 : Number(part.slice(0, -1));
const secondsLiteral = seconds === undefined ? '0' : seconds.slice(0, -1);
const negative = secondsLiteral.startsWith('-');
const [whole = '0', fraction = ''] = secondsLiteral.replace('-', '').split('.');
const magnitude = BigInt(whole) * MICROS_PER_SECOND + microsFromFraction(fraction, negative);
return {
months: wholeNumber(years) * 12 + wholeNumber(months),
days: wholeNumber(days),
micros:
BigInt(wholeNumber(hours)) * 3_600n * MICROS_PER_SECOND +
BigInt(wholeNumber(minutes)) * 60n * MICROS_PER_SECOND +
(negative ? -magnitude : magnitude),
};
}
function secondsText(micros: bigint): string {
const sign = micros < 0n ? '-' : '';
const magnitude = micros < 0n ? -micros : micros;
const whole = (magnitude / MICROS_PER_SECOND) % 60n;
const fraction = magnitude % MICROS_PER_SECOND;
const fractionText =
fraction === 0n ? '' : `.${fraction.toString().padStart(6, '0').replace(/0+$/, '')}`;
return `${sign}${whole}${fractionText}`;
}
function formatIsoDuration({ months, days, micros }: IntervalFields): string {
const years = Math.trunc(months / 12);
const restMonths = months % 12;
const sign = micros < 0n ? '-' : '';
const magnitude = micros < 0n ? -micros : micros;
const hours = magnitude / (3_600n * MICROS_PER_SECOND);
const minutes = (magnitude / (60n * MICROS_PER_SECOND)) % 60n;
const hasSeconds = magnitude % (60n * MICROS_PER_SECOND) !== 0n;
const parts = [
years === 0 ? '' : `${years}Y`,
restMonths === 0 ? '' : `${restMonths}M`,
days === 0 ? '' : `${days}D`,
];
const time = [
hours === 0n ? '' : `${sign}${hours}H`,
minutes === 0n ? '' : `${sign}${minutes}M`,
hasSeconds ? `${secondsText(micros)}S` : '',
].join('');
const rendered = `P${parts.join('')}${time === '' ? '' : `T${time}`}`;
return rendered === 'P' ? 'PT0S' : rendered;
}
const POSTGRES_INTERVAL =
/^(?:([+-]?\d+) years? ?)?(?:([+-]?\d+) mons? ?)?(?:([+-]?\d+) days? ?)?(?:([+-])?(\d+):(\d{2}):(\d{2})(?:\.(\d+))?)?$/;
function postgresIntervalFields(text: string): IntervalFields | undefined {
const match = POSTGRES_INTERVAL.exec(text);
if (match === null || text === '' || text.endsWith(' ')) return undefined;
const [, years = '0', months = '0', days = '0', sign, hours = '0', minutes = '0', seconds = '0'] =
match;
const fraction = match[8] ?? '';
const magnitude =
(BigInt(hours) * 3_600n + BigInt(minutes) * 60n + BigInt(seconds)) * MICROS_PER_SECOND +
microsFromFraction(fraction, sign === '-');
return {
months: Number(years) * 12 + Number(months),
days: Number(days),
micros: sign === '-' ? -magnitude : magnitude,
};
}
function intervalStoredText(text: string): string {
const fractionDigits = /\.(\d+)/.exec(text)?.[1]?.length ?? 0;
if (fractionDigits > 6)
throw new CastRefused(
`"${text}" has ${fractionDigits} digits after the decimal point, but pg/interval holds microseconds, so at most 6. Round it, as in "PT1.123456S".`,
);
const iso = ISO_DURATION.exec(text);
if (iso !== null) return formatIsoDuration(intervalFieldsOf(iso));
const fields = postgresIntervalFields(text);
if (fields === undefined)
throw new CastRefused(
`pg/interval cannot read "${text}". Write an ISO 8601 duration, as in "P1Y2M3DT4H5M6S".`,
);
return formatIsoDuration(fields);
}
function isoYearText(text: string): string {
const match = /^(\d{4,6})(-.*?)( BC)?$/.exec(text);
if (match === null) return text;
const [, digits = '', rest = '', bc] = match;
const year = Number(digits);
if (bc !== undefined) {
if (year === 0) return text;
return `${yearText(1 - year)}${rest}`;
}
return digits.length > 4 ? `${yearText(year)}${rest}` : text;
}
type DateTimeShape = 'date' | 'time' | 'timeWithOffset' | 'dateTime' | 'instant';
interface DateTimeOptions {
readonly shape: DateTimeShape;
readonly typeId: string;
readonly maxOffsetHours?: number;
readonly range?: { readonly earliest: string; readonly latest: string };
}
interface DateFields {
readonly year: number;
readonly month: number;
readonly day: number;
}
interface TimeFields {
readonly hour: number;
readonly minute: number;
readonly second: number;
readonly fraction: string;
}
interface WrittenDateTime {
readonly date: DateFields | undefined;
readonly time: TimeFields | undefined;
readonly offsetSeconds: number | undefined;
readonly fractionDigits: number;
}
const SHAPES: Readonly<
Record<
DateTimeShape,
{
readonly description: string;
readonly plural: string;
readonly example: string;
readonly date: boolean;
readonly time: boolean;
readonly offset: boolean;
}
>
> = {
date: {
description: 'a date',
plural: 'dates',
example: '2024-01-01',
date: true,
time: false,
offset: false,
},
time: {
description: 'a time of day',
plural: 'times of day',
example: '12:34:56',
date: false,
time: true,
offset: false,
},
timeWithOffset: {
description: 'a time of day with a UTC offset',
plural: 'times of day with a UTC offset',
example: '12:34:56+02:00',
date: false,
time: true,
offset: true,
},
dateTime: {
description: 'a date and time',
plural: 'dates and times',
example: '2024-01-01T12:34:56',
date: true,
time: true,
offset: false,
},
instant: {
description: 'a date and time with a UTC offset',
plural: 'instants',
example: '2024-01-01T12:34:56Z',
date: true,
time: true,
offset: true,
},
};
const DATE_PREFIX = /^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})/;
const TIME_PREFIX = /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?/;
const OFFSET = /^([+-])(\d{2})(?::(\d{2})(?::(\d{2}))?)?$/;
const MAX_FRACTION_DIGITS = 6;
const SECONDS_PER_DAY = 86_400;
const MIDNIGHT: TimeFields = { hour: 0, minute: 0, second: 0, fraction: '' };
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
function daysInMonth(year: number, month: number): number {
if (month === 2) return isLeapYear(year) ? 29 : 28;
return [4, 6, 9, 11].includes(month) ? 30 : 31;
}
function daysFromCivil({ year, month, day }: DateFields): number {
const shifted = month <= 2 ? year - 1 : year;
const era = Math.floor(shifted / 400);
const yearOfEra = shifted - era * 400;
const dayOfYear = Math.floor((153 * (month + (month > 2 ? -3 : 9)) + 2) / 5) + day - 1;
const dayOfEra =
yearOfEra * 365 + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100) + dayOfYear;
return era * 146_097 + dayOfEra - 719_468;
}
function civilFromDays(days: number): DateFields {
const shifted = days + 719_468;
const era = Math.floor(shifted / 146_097);
const dayOfEra = shifted - era * 146_097;
const yearOfEra = Math.floor(
(dayOfEra -
Math.floor(dayOfEra / 1460) +
Math.floor(dayOfEra / 36_524) -
Math.floor(dayOfEra / 146_096)) /
365,
);
const dayOfYear =
dayOfEra - (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
const monthIndex = Math.floor((5 * dayOfYear + 2) / 153);
const day = dayOfYear - Math.floor((153 * monthIndex + 2) / 5) + 1;
const month = monthIndex < 10 ? monthIndex + 3 : monthIndex - 9;
return { year: yearOfEra + era * 400 + (month <= 2 ? 1 : 0), month, day };
}
function pad(value: number, width = 2): string {
return String(value).padStart(width, '0');
}
function yearText(year: number): string {
if (year >= 0 && year <= 9999) return pad(year, 4);
return `${year < 0 ? '-' : '+'}${pad(Math.abs(year), 6)}`;
}
function dateText(date: DateFields): string {
return `${yearText(date.year)}-${pad(date.month)}-${pad(date.day)}`;
}
function timeText(time: TimeFields): string {
const fraction = time.fraction.replace(/0+$/, '');
return `${pad(time.hour)}:${pad(time.minute)}:${pad(time.second)}${fraction === '' ? '' : `.${fraction}`}`;
}
function offsetText(seconds: number): string {
if (seconds === 0) return 'Z';
const magnitude = Math.abs(seconds);
const rest = magnitude % 60;
return `${seconds < 0 ? '-' : '+'}${pad(Math.floor(magnitude / 3600))}:${pad(Math.floor(magnitude / 60) % 60)}${rest === 0 ? '' : `:${pad(rest)}`}`;
}
function readWritten(text: string): WrittenDateTime | undefined {
let rest = text;
let date: DateFields | undefined;
let timeFollows = true;
const dateMatch = DATE_PREFIX.exec(rest);
if (dateMatch !== null) {
const [whole, yearDigits = '', month = '', day = ''] = dateMatch;
if (yearDigits === '-000000') return undefined;
date = { year: Number(yearDigits), month: Number(month), day: Number(day) };
rest = rest.slice(whole.length);
timeFollows = /^[Tt ]/.test(rest);
if (timeFollows) rest = rest.slice(1);
}
let time: TimeFields | undefined;
let fractionDigits = 0;
if (timeFollows) {
const timeMatch = TIME_PREFIX.exec(rest);
if (timeMatch === null) return undefined;
const [whole, hour = '', minute = '', second = '00', fraction = ''] = timeMatch;
time = { hour: Number(hour), minute: Number(minute), second: Number(second), fraction };
fractionDigits = fraction.length;
rest = rest.slice(whole.length);
}
let offsetSeconds: number | undefined;
if (rest === 'Z' || rest === 'z') {
offsetSeconds = 0;
} else if (rest !== '') {
const offset = OFFSET.exec(rest);
if (offset === null) return undefined;
const [, sign, hours = '', minutes = '00', seconds = '00'] = offset;
if (Number(minutes) > 59 || Number(seconds) > 59) return undefined;
const magnitude = Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds);
offsetSeconds = sign === '-' ? -magnitude : magnitude;
}
return { date, time, offsetSeconds, fractionDigits };
}
type Position = readonly [days: number, seconds: number, fraction: string];
interface NormalizedDateTime {
readonly date: DateFields | undefined;
readonly time: TimeFields;
readonly offsetSeconds: number;
}
function normalized(written: WrittenDateTime, shape: DateTimeShape): NormalizedDateTime {
const clock = written.time ?? MIDNIGHT;
const offsetSeconds = written.offsetSeconds ?? 0;
if (written.date === undefined || shape !== 'instant')
return { date: written.date, time: clock, offsetSeconds };
const utc =
daysFromCivil(written.date) * SECONDS_PER_DAY +
clock.hour * 3600 +
clock.minute * 60 +
clock.second -
offsetSeconds;
const days = Math.floor(utc / SECONDS_PER_DAY);
const secondOfDay = utc - days * SECONDS_PER_DAY;
return {
date: civilFromDays(days),
time: {
hour: Math.floor(secondOfDay / 3600),
minute: Math.floor(secondOfDay / 60) % 60,
second: secondOfDay % 60,
fraction: clock.fraction,
},
offsetSeconds: 0,
};
}
function position(value: NormalizedDateTime): Position {
const { time } = value;
return [
value.date === undefined ? 0 : daysFromCivil(value.date),
time.hour * 3600 + time.minute * 60 + time.second,
time.fraction.padEnd(MAX_FRACTION_DIGITS, '0'),
];
}
function isBefore(left: Position, right: Position): boolean {
if (left[0] !== right[0]) return left[0] < right[0];
if (left[1] !== right[1]) return left[1] < right[1];
return left[2] < right[2];
}
function printed(value: NormalizedDateTime, shape: DateTimeShape): string {
const { date, time, offsetSeconds } = value;
if (date === undefined)
return shape === 'timeWithOffset'
? `${timeText(time)}${offsetText(offsetSeconds)}`
: timeText(time);
if (shape === 'date') return dateText(date);
if (shape === 'dateTime') return `${dateText(date)}T${timeText(time)}`;
return `${dateText(date)}T${timeText(time)}Z`;
}
function boundPosition(text: string, shape: DateTimeShape): Position {
const written = readWritten(text);
if (written === undefined)
throw new Error(`The range bound "${text}" is not in its stored form.`);
return position(normalized(written, shape));
}
function dateTimeStoredText(text: string, options: DateTimeOptions, written: string): string {
const shape = SHAPES[options.shape];
const id = options.typeId;
const parts = readWritten(text);
if (parts === undefined)
throw new CastRefused(
`${id} cannot read "${written}". Write ${shape.description}, as in "${shape.example}".`,
);
if (parts.date !== undefined && !shape.date)
throw new CastRefused(
`${id} holds a time of day without a date, but "${written}" has a date. Write the time alone, as in "${shape.example}".`,
);
if (parts.date === undefined && shape.date)
throw new CastRefused(
shape.time
? `${id} holds ${shape.description}, but "${written}" has no date. Write the date too, as in "${shape.example}".`
: `${id} holds a date, and "${written}" is a time of day. Write a date, as in "${shape.example}".`,
);
if (parts.time !== undefined && !shape.time)
throw new CastRefused(
`${id} holds a date without a time of day, but "${written}" has a time. Write the date alone, as in "${shape.example}".`,
);
if (parts.time === undefined && shape.offset)
throw new CastRefused(
`${id} holds ${shape.description}, but "${written}" has no time of day. Write the time too, as in "${shape.example}".`,
);
if (parts.offsetSeconds !== undefined && !shape.offset)
throw new CastRefused(
`${id} holds no UTC offset, but "${written}" has one. Leave it out, as in "${shape.example}".`,
);
if (parts.offsetSeconds === undefined && shape.offset)
throw new CastRefused(
`${id} needs a UTC offset, but "${written}" has none. Add Z for UTC or an offset such as +02:00, as in "${shape.example}".`,
);
if (parts.fractionDigits > MAX_FRACTION_DIGITS)
throw new CastRefused(
`"${written}" has ${parts.fractionDigits} digits after the decimal point, but ${id} holds microseconds, so at most ${MAX_FRACTION_DIGITS}. Round it, as in "${shape.example.replace(/(\d{2}:\d{2}:\d{2})/, '$1.123456')}".`,
);
const { date, time } = parts;
if (
date !== undefined &&
(date.month < 1 ||
date.month > 12 ||
date.day < 1 ||
date.day > daysInMonth(date.year, date.month))
)
throw new CastRefused(
`"${written}" is not a date that exists. Write a real date, as in "${shape.example}".`,
);
if (time !== undefined && (time.hour > 23 || time.minute > 59 || time.second > 59))
throw new CastRefused(
`"${written}" is not a time of day that exists: hours run from 00 to 23, and minutes and seconds from 00 to 59. Write one, as in "${shape.example}".`,
);
const offsetLimit = options.maxOffsetHours ?? 23;
if (
parts.offsetSeconds !== undefined &&
Math.abs(parts.offsetSeconds) >= (offsetLimit + 1) * 3600
)
throw new CastRefused(
`"${written}" has a UTC offset outside -${pad(offsetLimit)}:59 to +${pad(offsetLimit)}:59, which ${id} does not hold. Write a smaller offset, as in "${shape.example}".`,
);
const value = normalized(parts, options.shape);
const { range } = options;
if (range !== undefined) {
const at = position(value);
if (
isBefore(at, boundPosition(range.earliest, options.shape)) ||
isBefore(boundPosition(range.latest, options.shape), at)
)
throw new CastRefused(
`${id} holds ${shape.plural} from ${range.earliest} to ${range.latest}, and "${written}" is outside them.`,
);
}
return printed(value, options.shape);
}
const INFINITIES: ReadonlySet<string> = new Set(['infinity', '-infinity']);
function postgresDateTime(
options: DateTimeOptions,
holdsInfinity: boolean,
): (text: string) => string {
return (text) =>
holdsInfinity && INFINITIES.has(text)
? text
: dateTimeStoredText(isoYearText(text), options, text);
}
function storedForm(
storedText: (text: string) => string,
text: string,
): { readonly stored: string } | { readonly reason: string } {
try {
return { stored: storedText(text) };
} catch (error) {
if (error instanceof CastRefused) return { reason: error.message };
throw error;
}
}
const SECONDS_FRACTION = /:\d{2}\.(\d+)/;
const INTERVAL_SECONDS_FRACTION = /\d\.(\d+)S$/;
function storedTextType(
typeId: string,
storedText: (text: string) => string,
description: string,
fraction: RegExp,
): Pick<PostgresDataType, 'read' | 'spell'> {
return {
read: (json, params) => {
if (typeof json !== 'string') return refuseJsonValue(typeId, description);
const form = storedForm(storedText, json);
if ('reason' in form) return refuseJsonValue(typeId, `${description}: ${form.reason}`);
const precision = integerParam(params, 'precision') ?? MAX_FRACTION_DIGITS;
if (withoutTrailing(fraction.exec(form.stored)?.[1] ?? '', '0').length > precision)
return refuseJsonValue(
typeId,
`a value with at most ${counted(precision, 'fraction digit')} of a second, which precision ${precision} holds without rounding`,
);
return json;
},
spell: (json) => {
if (typeof json !== 'string') return json;
const form = storedForm(storedText, json);
return 'stored' in form ? form.stored : json;
},
};
}
type ParamRule = (value: Json | undefined) => boolean;
const optionalInteger =
(min: number, max: number): ParamRule =>
(value) =>
value === undefined ||
(typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max);
interface PostgresDataType {
readonly params?: Readonly<Record<string, ParamRule>>;
readonly read: (json: Json, params: Params) => Json;
readonly spell?: (json: Json, params: Params) => Json;
}
const NUMERIC_PARAMS = {
precision: optionalInteger(1, 1000),
scale: optionalInteger(-1000, 1000),
};
const CHARACTER_LENGTH_PARAMS = { length: optionalInteger(1, 10485760) };
const BIT_LENGTH_PARAMS = { length: optionalInteger(1, 83886080) };
const PRECISION_PARAMS = { precision: optionalInteger(0, 6) };
const readString =
(typeId: string): PostgresDataType['read'] =>
(json) =>
readJsonString(typeId, json);
const anyJson: PostgresDataType['read'] = (json) => json;
const requiredInteger =
(min: number, max: number): ParamRule =>
(value) =>
typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max;
const HEX_TEXT = /^(?:[0-9A-Fa-f]{2})*$/;
const EWKB_FLAG_Z = 0x80000000;
const EWKB_FLAG_M = 0x40000000;
const EWKB_FLAG_SRID = 0x20000000;
const EWKB_TYPE_MASK = 0x1fffffff;
const EWKB_POINT = 1;
const EWKB_LINESTRING = 2;
const EWKB_POLYGON = 3;
const EWKB_MULTI_PARTS: ReadonlyMap<number, number> = new Map([
[4, EWKB_POINT],
[5, EWKB_LINESTRING],
[6, EWKB_POLYGON],
]);
class NotEwkb extends Error {}
interface EwkbGeometry {
readonly srid: number | undefined;
/** The geometry as the upper-case little-endian HEXEWKB PostGIS prints, or undefined when a coordinate is not finite. */
readonly printed: string | undefined;
}
/** Reads HEXEWKB in either case and byte order, as `postgis/geometry` of 8.0.0-rc.19 does: points, line strings, polygons and their multi forms, without Z or M coordinates. */
function readEwkb(hex: string): EwkbGeometry {
const bytes = Buffer.from(hex, 'hex');
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const out: number[] = [];
let offset = 0;
let finite = true;
const take = (size: number): number => {
if (offset + size > view.byteLength) throw new NotEwkb();
const at = offset;
offset += size;
return at;
};
const writeUint32 = (value: number): void => {
const buffer = Buffer.alloc(4);
buffer.writeUInt32LE(value >>> 0);
out.push(...buffer);
};
const header = (top: boolean): { type: number; little: boolean; srid: number | undefined } => {
const order = view.getUint8(take(1));
if (order !== 0 && order !== 1) throw new NotEwkb();
const little = order === 1;
const code = view.getUint32(take(4), little) >>> 0;
if ((code & EWKB_FLAG_Z) !== 0 || (code & EWKB_FLAG_M) !== 0) throw new NotEwkb();
const type = code & EWKB_TYPE_MASK;
const srid = (code & EWKB_FLAG_SRID) !== 0 ? view.getUint32(take(4), little) >>> 0 : undefined;
out.push(1);
writeUint32(top && srid !== undefined ? type | EWKB_FLAG_SRID : type);
if (top && srid !== undefined) writeUint32(srid);
return { type, little, srid };
};
const count = (little: boolean): number => {
const value = view.getUint32(take(4), little) >>> 0;
writeUint32(value);
return value;
};
const positions = (amount: number, little: boolean): void => {
for (let index = 0; index < amount * 2; index++) {
const value = view.getFloat64(take(8), little);
if (!Number.isFinite(value)) finite = false;
const buffer = Buffer.alloc(8);
buffer.writeDoubleLE(value);
out.push(...buffer);
}
};
const body = (type: number, little: boolean): void => {
if (type === EWKB_POINT) positions(1, little);
else if (type === EWKB_LINESTRING) positions(count(little), little);
else if (type === EWKB_POLYGON)
for (let ring = count(little); ring > 0; ring--) positions(count(little), little);
else throw new NotEwkb();
};
const top = header(true);
const part = EWKB_MULTI_PARTS.get(top.type);
if (part === undefined) body(top.type, top.little);
else
for (let index = count(top.little); index > 0; index--) {
const sub = header(false);
if (sub.type !== part) throw new NotEwkb();
body(sub.type, sub.little);
}
if (offset !== view.byteLength) throw new NotEwkb();
return {
srid: top.srid,
printed: finite ? Buffer.from(out).toString('hex').toUpperCase() : undefined,
};
}
function ewkbOf(json: Json): EwkbGeometry | undefined {
if (typeof json !== 'string') return undefined;
try {
return readEwkb(json);
} catch (error) {
if (error instanceof NotEwkb) return undefined;
throw error;
}
}
function readGeometry(json: Json, params: Params): Json {
readJsonMatching('postgis/geometry', json, HEX_TEXT, 'a HEXEWKB string');
const srid = integerParam(params, 'srid');
const geometry = ewkbOf(json);
if (geometry === undefined || (srid !== undefined && geometry.srid !== srid))
return refuseJsonValue(
'postgis/geometry',
srid !== undefined ? `a HEXEWKB string of a geometry with SRID ${srid}` : 'a HEXEWKB string',
);
return json;
}
function spellGeometry(json: Json): Json {
const geometry = ewkbOf(json);
if (geometry === undefined) return json;
if (geometry.printed === undefined)
throw new NotAValue('Geometry encode: coordinates must be finite numbers');
return geometry.printed;
}
/** The text PostgreSQL prints for `value` stored as a `real`: the shortest decimal that reads back as the same single-precision number. */
function realText(value: number): string {
const single = Math.fround(value);
for (let digits = 1; digits <= 9; digits++) {
const text = String(Number(single.toPrecision(digits)));
if (Math.fround(Number(text)) === single) return text;
}
return String(single);
}
function readVector(json: Json, params: Params): Json {
const length = integerParam(params, 'length');
const expected =
length === undefined
? 'an array of finite numbers'
: `an array of ${counted(length, 'finite number')}`;
if (!Array.isArray(json) || (length !== undefined && json.length !== length))
return refuseJsonValue('pgvector/vector', expected);
for (const element of json)
if (typeof element !== 'number' || !Number.isFinite(element))
return refuseJsonValue('pgvector/vector', expected);
for (const [index, element] of json.entries()) {
if (typeof element !== 'number') continue;
const refuse = (why: string) =>
refuseJsonValue(
'pgvector/vector',
`an array of numbers real holds exactly; element ${index + 1}, ${element}, ${why}`,
);
if (!Number.isFinite(Math.fround(element)))
return refuse('is outside the range real holds, which PostgreSQL refuses');
const stored = realText(element);
if (Number(stored) !== element) return refuse(`would be stored as ${stored}`);
}
return json;
}
const POSTGRES_DATA_TYPES: ReadonlyMap<string, PostgresDataType> = new Map<
string,
PostgresDataType
>([
['pg/text', { read: readString('pg/text') }],
['pg/text-array', { read: readTextArray }],
[
'pg/enum',
{
params: { typeName: (value) => typeof value === 'string' && value.length > 0 },
read: readString('pg/enum'),
},
],
['pg/int2', { read: (json) => readJsonInteger('pg/int2', json, -32768, 32767) }],
[
'pg/bool',
{
read: (json) => (typeof json === 'boolean' ? json : refuseJsonValue('pg/bool', 'a boolean')),
},
],
['pg/json', { read: anyJson }],
['pg/tsquery', { read: readString('pg/tsquery') }],
['pg/int4', { read: (json) => readJsonInteger('pg/int4', json, -(2 ** 31), 2 ** 31 - 1) }],
['pg/int8', { read: readInt8 }],
['pg/numeric', { params: NUMERIC_PARAMS, read: readNumeric, spell: spellNumeric }],
['pg/float4', { read: readFloat4 }],
[
'pg/float8',
{
read: (json) => {
readJsonFloat('pg/float8', json);
return json;
},
},
],
['pg/jsonb', { read: anyJson }],
['pg/char', { params: CHARACTER_LENGTH_PARAMS, read: readCharacter, spell: spellCharacter }],
['pg/varchar', { params: CHARACTER_LENGTH_PARAMS, read: readCharacterVarying }],
[
'pg/uuid',
{
read: (json) =>
typeof json === 'string' && storedUuid(json) !== undefined
? json
: refuseJsonValue(
'pg/uuid',
'a UUID in a spelling PostgreSQL reads: 32 hexadecimal digits in either case, with or without a hyphen after any group of four, optionally in braces',
),
spell: (json) => (typeof json === 'string' ? (storedUuid(json) ?? json) : json),
},
],
[
'pg/inet',
{
read: (json) =>
typeof json === 'string' && storedInet(json) !== undefined
? json
: refuseJsonValue(
'pg/inet',
'an IP address or network in a spelling PostgreSQL reads, as in 10.0.0.1 or 2001:db8::/32',
),
spell: (json) => (typeof json === 'string' ? (storedInet(json) ?? json) : json),
},
],
['pg/bit', { params: BIT_LENGTH_PARAMS, read: readBit }],
['pg/varbit', { params: BIT_LENGTH_PARAMS, read: readBitVarying }],
[
'pg/timetz',
{
params: PRECISION_PARAMS,
...storedTextType(
'pg/timetz',
postgresDateTime(
{ shape: 'timeWithOffset', typeId: 'pg/timetz', maxOffsetHours: 15 },
false,
),
'a time of day with its UTC offset, as in "12:34:56+02:00" or "12:34:56Z"',
SECONDS_FRACTION,
),
},
],
[
'pg/interval',
{
params: PRECISION_PARAMS,
...storedTextType(
'pg/interval',
intervalStoredText,
'an ISO 8601 duration, as in "P1Y2M3DT4H5M6.5S"',
INTERVAL_SECONDS_FRACTION,
),
},
],
[
'pg/bytea',
{
read: (json) => readJsonMatching('pg/bytea', json, BASE64_TEXT, 'a base64 string'),
spell: spellBytea,
},
],
[
'pg/date',
storedTextType(
'pg/date',
postgresDateTime(
{
shape: 'date',
typeId: 'pg/date',
range: { earliest: '-004713-11-24', latest: '+275760-09-13' },
},
true,
),
'a date, as in "2024-01-01", or infinity or -infinity',
SECONDS_FRACTION,
),
],
[
'pg/time',
{
params: PRECISION_PARAMS,
...storedTextType(
'pg/time',
postgresDateTime({ shape: 'time', typeId: 'pg/time' }, false),
'a time of day, as in "12:34:56"',
SECONDS_FRACTION,
),
},
],
[
'pg/timestamp',
{
params: PRECISION_PARAMS,
...storedTextType(
'pg/timestamp',
postgresDateTime(
{
shape: 'dateTime',
typeId: 'pg/timestamp',
range: { earliest: '-004713-11-24T00:00:00', latest: '+275760-09-13T23:59:59.999999' },
},
true,
),
'a date and time of day, as in "2024-01-01T12:34:56", or infinity or -infinity',
SECONDS_FRACTION,
),
},
],
[
'pg/timestamptz',
{
params: PRECISION_PARAMS,
...storedTextType(
'pg/timestamptz',
postgresDateTime(
{
shape: 'instant',
typeId: 'pg/timestamptz',
range: { earliest: '-004713-11-24T00:00:00Z', latest: '+275760-09-13T00:00:00Z' },
},
true,
),
'an instant in UTC, as in "2024-01-01T00:00:00Z", or infinity or -infinity',
SECONDS_FRACTION,
),
},
],
[
'postgis/geometry',
{
params: { srid: optionalInteger(1, Number.POSITIVE_INFINITY) },
read: readGeometry,
spell: spellGeometry,
},
],
['pgvector/vector', { params: { length: requiredInteger(1, 16000) }, read: readVector }],
]);
function resolvedTypeParams(column: JsonRecord, namedTypes: Json | undefined): Json | undefined {
const typeRef = column['typeRef'];
const named =
typeof typeRef === 'string' && isRecord(namedTypes) ? namedTypes[typeRef] : undefined;
const typeParams = column['typeParams'] ?? (isRecord(named) ? named['typeParams'] : undefined);
return isRecord(typeParams) && Object.keys(typeParams).length > 0 ? typeParams : undefined;
}
/** The parameters of the column that its data type declares, or undefined when the type refuses them. */
function declaredParams(type: PostgresDataType, typeParams: Json | undefined): Params | undefined {
const params: Record<string, Json> = {};
for (const [name, accepts] of Object.entries(type.params ?? {})) {
const value = isRecord(typeParams) ? typeParams[name] : undefined;
if (!accepts(value)) return undefined;
if (value !== undefined) params[name] = value;
}
if (params['scale'] !== undefined && params['precision'] === undefined) return undefined;
return params;
}
interface ColumnType {
readonly id: string;
readonly type: PostgresDataType;
readonly params: Params;
readonly list: boolean;
}
/** The column's data type and parameters, or undefined when this release does not register the type or refuses the parameters, and so does not read the default. */
function columnType(column: JsonRecord, namedTypes: Json | undefined): ColumnType | undefined {
const id = column['dataType'];
const type = typeof id === 'string' ? POSTGRES_DATA_TYPES.get(id) : undefined;
if (typeof id !== 'string' || type === undefined) return undefined;
const params = declaredParams(type, resolvedTypeParams(column, namedTypes));
if (params === undefined) return undefined;
const many = column['many'];
return { id, type, params, list: many !== undefined && many !== false };
}
/** A literal default value in the contract form this release stores. */
function contractFormOfDefault(value: Json, { type, params }: ColumnType): DefaultForm {
let stored: Json;
try {
const read = type.read(value, params);
stored = type.spell === undefined ? read : type.spell(read, params);
} catch (error) {
if (error instanceof NotAValue) return { kind: 'notAValue', reason: error.message };
throw error;
}
return canonicalizeJson(stored) === canonicalizeJson(value)
? { kind: 'unchanged' }
: { kind: 'rewritten', value: stored };
}
type StoredDefault =
| { readonly kind: 'stored'; readonly value: Json }
| { readonly kind: 'refused'; readonly reason: string };
/** A column's default in the contract form this release stores, element by element on a list column, or why this release refuses it. */
function storedDefault(value: Json, columnType: ColumnType): StoredDefault {
if (value === null) return { kind: 'stored', value };
if (columnType.list && !Array.isArray(value))
return {
kind: 'refused',
reason: `the default ${JSON.stringify(value)} is not a list, and the column holds a list of ${columnType.id}`,
};
const values = columnType.list && Array.isArray(value) ? value : [value];
const stored: Json[] = [];
for (const element of values) {
const form = element === null ? undefined : contractFormOfDefault(element, columnType);
if (form?.kind === 'notAValue')
return {
kind: 'refused',
reason: `the default ${JSON.stringify(element)} is not a value of ${columnType.id} (${form.reason})`,
};
stored.push(form?.kind === 'rewritten' ? form.value : element);
}
return { kind: 'stored', value: columnType.list ? stored : (stored[0] ?? null) };
}
/** The scalar values that changed between two defaults, pairing list elements by position, for the literal types of `contract.d.ts`. */
function defaultRewrites(from: Json, to: Json): DefaultRewrite[] {
const pairs: [Json, Json][] =
Array.isArray(from) && Array.isArray(to)
? from.length === to.length
? from.map((element, index) => [element, to[index] ?? null])
: []
: [[from, to]];
return pairs.flatMap(([a, b]) =>
(typeof a === 'string' && typeof b === 'string') ||
(typeof a === 'number' && typeof b === 'number')
? a === b
? []
: [{ from: a, to: b }]
: [],
);
}
/** How `--default` names a column: `<namespace>.<table>.<column>`. */
function defaultKey(namespaceId: string, tableName: string, columnName: string): string {
return `${namespaceId}.${tableName}.${columnName}`;
}
interface ColumnEntry {
readonly namespaceId: string;
readonly tableName: string;
readonly columnName: string;
readonly column: JsonRecord;
}
function columnsOf(contract: JsonRecord): ColumnEntry[] {
const storage = contract['storage'];
const namespaces = isRecord(storage) ? storage['namespaces'] : undefined;
return Object.entries(isRecord(namespaces) ? namespaces : {}).flatMap(
([namespaceId, namespace]) => {
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
const tables = isRecord(entries) ? entries['table'] : undefined;
return Object.entries(isRecord(tables) ? tables : {}).flatMap(([tableName, table]) => {
const columns = isRecord(table) ? table['columns'] : undefined;
return Object.entries(isRecord(columns) ? columns : {}).flatMap(([columnName, column]) =>
isRecord(column) ? [{ namespaceId, tableName, columnName, column }] : [],
);
});
},
);
}
/** Mutates `copy`: each literal default in the contract form this release stores, element by element on a list column. A default this release refuses takes the value given for its column with `--default`, and each column whose replacement was taken is added to `used`. */
function rewriteDefaults(
copy: JsonRecord,
replacements: ReadonlyMap<string, Json>,
used: Set<string>,
): {
readonly defaults: Map<string, DefaultRewrite[]>;
readonly notValues: string[];
} {
const defaults = new Map<string, DefaultRewrite[]>();
const notValues: string[] = [];
const storage = copy['storage'];
const namedTypes = isRecord(storage) ? storage['types'] : undefined;
for (const { namespaceId, tableName, columnName, column } of columnsOf(copy)) {
const columnDefault = column['default'];
if (!isRecord(columnDefault) || columnDefault['kind'] !== 'literal') continue;
const original = columnDefault['value'];
const type = columnType(column, namedTypes);
if (original === undefined || type === undefined) continue;
const place = `namespace ${namespaceId}, table ${tableName}, column ${columnName}`;
const key = defaultKey(namespaceId, tableName, columnName);
let stored = storedDefault(original, type);
if (stored.kind === 'refused') {
const replacement = replacements.get(key);
if (replacement === undefined) {
notValues.push(
`${place}: ${stored.reason}; choose the value the database stores and run the script again with --default '${key}=<that value as JSON>'`,
);
continue;
}
used.add(key);
stored = storedDefault(replacement, type);
if (stored.kind === 'refused') {
notValues.push(`${place}: --default ${key}: ${stored.reason}`);
continue;
}
}
if (canonicalizeJson(stored.value) === canonicalizeJson(original)) continue;
columnDefault['value'] = stored.value;
defaults.set(
listKey(namespaceId, tableName, columnName),
defaultRewrites(original, stored.value),
);
}
return { defaults, notValues };
}
function allowNullElements(holder: JsonRecord): void {
const many = holder['many'];
if (isRecord(many)) holder['many'] = { ...many, elementNullable: true };
}
/** Mutates `copy`: the domain fields that map a column whose element check was waived. */
function fieldsMappingColumns(copy: JsonRecord, columns: ReadonlySet<string>): Set<string> {
const fields = new Set<string>();
const domain = copy['domain'];
const namespaces = isRecord(domain) ? domain['namespaces'] : undefined;
for (const [namespaceId, namespace] of isRecord(namespaces) ? Object.entries(namespaces) : []) {
const models = isRecord(namespace) ? namespace['models'] : undefined;
for (const [modelName, model] of isRecord(models) ? Object.entries(models) : []) {
if (!isRecord(model)) continue;
const storage = model['storage'];
const modelFields = model['fields'];
if (!isRecord(storage) || !isRecord(modelFields)) continue;
const table = storage['table'];
const tableNamespace =
typeof storage['namespaceId'] === 'string' ? storage['namespaceId'] : namespaceId;
const mapped = storage['fields'];
for (const [fieldName, mapping] of isRecord(mapped) ? Object.entries(mapped) : []) {
const column = isRecord(mapping) ? mapping['column'] : undefined;
const field = modelFields[fieldName];
if (typeof table !== 'string' || typeof column !== 'string' || !isRecord(field)) continue;
if (!columns.has(listKey(tableNamespace, table, column))) continue;
allowNullElements(field);
fields.add(listKey(namespaceId, modelName, fieldName));
}
}
}
return fields;
}
/** The contract with the key removed from each column, the lists whose element check it waived made element-nullable, and each default in the contract form this release stores. */
function rewriteContract(
contract: JsonRecord,
replacements: ReadonlyMap<string, Json>,
used: Set<string>,
): Rewrite {
const copy = parseJson(JSON.stringify(contract));
if (!isRecord(copy))
return {
contract,
changed: false,
columns: new Set(),
fields: new Set(),
defaults: new Map(),
notValues: [],
};
let changed = false;
const columns = new Set<string>();
const storage = copy['storage'];
const namespaces = isRecord(storage) ? storage['namespaces'] : undefined;
for (const [namespaceId, namespace] of isRecord(namespaces) ? Object.entries(namespaces) : []) {
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
const tables = isRecord(entries) ? entries['table'] : undefined;
for (const [tableName, table] of isRecord(tables) ? Object.entries(tables) : []) {
const tableColumns = isRecord(table) ? table['columns'] : undefined;
for (const [columnName, column] of isRecord(tableColumns)
? Object.entries(tableColumns)
: []) {
if (!isRecord(column) || !Object.hasOwn(column, REMOVED_KEY)) continue;
const kinds = column[REMOVED_KEY];
delete column[REMOVED_KEY];
changed = true;
if (Array.isArray(kinds) && kinds.includes(ELEMENT_KIND) && isRecord(column['many'])) {
allowNullElements(column);
columns.add(listKey(namespaceId, tableName, columnName));
}
}
}
}
const { defaults, notValues } =
contract['target'] === 'postgres'
? rewriteDefaults(copy, replacements, used)
: { defaults: new Map<string, DefaultRewrite[]>(), notValues: [] };
return {
contract: copy,
changed: changed || defaults.size > 0,
columns,
fields: fieldsMappingColumns(copy, columns),
defaults,
notValues,
};
}
function withStorageHash(contract: JsonRecord, hash: string): JsonRecord {
const storage = contract['storage'];
if (!isRecord(storage) || typeof storage['storageHash'] !== 'string') return contract;
return { ...contract, storage: { ...storage, storageHash: hash } };
}
/** The contract written the way the file held it: a snapshot's canonical line, or indented JSON. */
function writtenForm(contract: JsonRecord, original: JsonRecord, text: string): string {
const newline = text.endsWith('\n') ? '\n' : '';
if (text === `${canonicalizeJson(original)}${newline}`)
return `${canonicalizeJson(contract)}${newline}`;
return `${JSON.stringify(contract, null, 2)}${newline}`;
}
interface Block {
readonly start: number;
end: number;
readonly label: string;
readonly parent: Block | undefined;
}
function scanBlocks(text: string): Block[] {
const blocks: Block[] = [];
const open: Block[] = [];
let quote: string | undefined;
for (let index = 0; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === "'" || char === '"' || char === '`') {
quote = char;
continue;
}
if (char === '{') {
const before = text.slice(Math.max(0, index - 200), index);
const key =
/(?:readonly\s+)?([\p{ID_Start}_$][\p{ID_Continue}$]*|'[^']*'|"[^"]*")\s*\??\s*:\s*$/u.exec(
before,
) ?? /\b(?:type|namespace)\s+([\p{ID_Start}_$][\p{ID_Continue}$]*)\s*=?\s*$/u.exec(before);
const block: Block = {
start: index,
end: text.length,
label: key?.[1]?.replace(/^['"]|['"]$/g, '') ?? '',
parent: open.at(-1),
};
open.push(block);
blocks.push(block);
} else if (char === '}') {
const block = open.pop();
if (block !== undefined) block.end = index;
}
}
return blocks;
}
function innermostBlock(blocks: readonly Block[], position: number): Block | undefined {
let found: Block | undefined;
for (const block of blocks)
if (
block.start < position &&
position < block.end &&
(found === undefined || block.start > found.start)
)
found = block;
return found;
}
/** The key of a storage column block (`namespaces.<ns>.entries.table.<table>.columns.<column>`). */
function columnBlockKey(block: Block): string | undefined {
const table = block.parent?.parent;
const namespace = table?.parent?.parent?.parent;
if (block.parent?.label !== 'columns' || table?.parent?.label !== 'table') return undefined;
if (table.parent.parent?.label !== 'entries' || namespace === undefined) return undefined;
return listKey(namespace.label, table.label, block.label);
}
/** The key of a domain field block (`namespaces.<ns>.models.<model>.fields.<field>`). */
function fieldBlockKey(block: Block): string | undefined {
const model = block.parent?.parent;
const namespace = model?.parent?.parent;
if (block.parent?.label !== 'fields' || model?.parent?.label !== 'models') return undefined;
if (namespace === undefined) return undefined;
return listKey(namespace.label, model.label, block.label);
}
const FIELD_TYPE_MAPS = new Set(['FieldOutputTypes', 'FieldInputTypes']);
const COLUMN_TYPE_MAPS = new Set(['StorageColumnTypes', 'StorageColumnInputTypes']);
/** Whether a row type member names a waived list: in the field and column type maps, keyed namespace, model or table, member; or in a `Models` type alias, `<namespace>_<Model>` or `<Model>`. */
function isWaivedRowMember(
block: Block,
member: string,
columns: ReadonlySet<string>,
fields: ReadonlySet<string>,
): boolean {
const namespace = block.parent;
const map = namespace?.parent;
if (map !== undefined && map.parent === undefined) {
const key = listKey(namespace?.label ?? '', block.label, member);
if (FIELD_TYPE_MAPS.has(map.label)) return fields.has(key);
if (COLUMN_TYPE_MAPS.has(map.label)) return columns.has(key);
}
if (namespace?.label !== 'Models' || namespace.parent !== undefined) return false;
return [...fields].some((key) => {
const [fieldNamespace, model, field] = key.split('\u0000');
return (
field === member && (block.label === model || block.label === `${fieldNamespace}_${model}`)
);
});
}
/** The end of the type argument that starts at `start`: the index of its closing `>`. */
function typeArgumentEnd(text: string, start: number): number {
let depth = 1;
let quote: string | undefined;
for (let index = start; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
} else if (char === "'" || char === '"' || char === '`') quote = char;
else if (char === '<') depth++;
else if (char === '>' && text[index - 1] !== '=' && --depth === 0) return index;
}
return -1;
}
function quoted(text: string, quote: string): string {
return `${quote}${text.replaceAll('\\', '\\\\').replaceAll(quote, `\\${quote}`)}${quote}`;
}
const NUMBER_TOKEN = /(?<![\w.$'"])-?\d+(?:\.\d+)?(?:e[+-]?\d+)?(?![\w.$'"])/gi;
/** A default's literal type with each rewritten value replaced: a string in either quote, a number as its own token. */
function withRewrittenLiterals(literal: string, rewrites: readonly DefaultRewrite[]): string {
let result = literal;
const numbers = new Map<number, number>();
for (const { from, to } of rewrites) {
if (typeof from === 'number' && typeof to === 'number') numbers.set(from, to);
if (typeof from === 'string' && typeof to === 'string')
for (const quote of ["'", '"'])
result = result.replaceAll(quoted(from, quote), quoted(to, quote));
}
if (numbers.size === 0) return result;
return result.replace(NUMBER_TOKEN, (token) => String(numbers.get(Number(token)) ?? token));
}
function rewriteDts(
text: string,
columns: ReadonlySet<string>,
fields: ReadonlySet<string>,
defaults: ReadonlyMap<string, readonly DefaultRewrite[]>,
hashes: ReadonlyMap<string, string>,
): string {
const blocks = scanBlocks(text);
const edits: { start: number; end: number; text: string }[] = [];
for (const match of text.matchAll(/DefaultLiteralValue</g)) {
const holder = innermostBlock(blocks, match.index);
const column = holder?.label === 'default' ? holder.parent : undefined;
const key = column === undefined ? undefined : columnBlockKey(column);
const rewrites = key === undefined ? undefined : defaults.get(key);
const end = typeArgumentEnd(text, match.index + match[0].length);
if (rewrites === undefined || end === -1) continue;
const codecEnd = /^\s*(['"])(?:\\.|(?!\1).)*\1/.exec(
text.slice(match.index + match[0].length, end),
);
const start = match.index + match[0].length + (codecEnd?.[0].length ?? 0);
edits.push({ start, end, text: withRewrittenLiterals(text.slice(start, end), rewrites) });
}
const rowMember =
/(?:readonly\s+)?([\p{ID_Start}_$][\p{ID_Continue}$]*|'[^']*'|"[^"]*")\s*\??\s*:\s*ReadonlyArray</gu;
for (const match of text.matchAll(rowMember)) {
const block = innermostBlock(blocks, match.index);
const member = match[1]?.replace(/^['"]|['"]$/g, '') ?? '';
if (block === undefined || !isWaivedRowMember(block, member, columns, fields)) continue;
const elementStart = match.index + match[0].length;
const elementEnd = typeArgumentEnd(text, elementStart);
if (elementEnd === -1 || /\|\s*null\s*$/.test(text.slice(elementStart, elementEnd))) continue;
edits.push({ start: elementEnd, end: elementEnd, text: ' | null' });
}
for (const match of text.matchAll(/readonly elementNullable:\s*false/g)) {
const many = innermostBlock(blocks, match.index);
const holder = many?.label === 'many' ? many.parent : undefined;
if (holder === undefined) continue;
const column = columnBlockKey(holder);
const field = fieldBlockKey(holder);
if (
!(column !== undefined && columns.has(column)) &&
!(field !== undefined && fields.has(field))
)
continue;
edits.push({
start: match.index,
end: match.index + match[0].length,
text: 'readonly elementNullable: true',
});
}
let result = text;
for (const edit of edits.sort((a, b) => b.start - a.start))
result = `${result.slice(0, edit.start)}${edit.text}${result.slice(edit.end)}`;
return replaceHashTokens(result, hashes);
}
function replaceHashTokens(text: string, hashes: ReadonlyMap<string, string>): string {
return text.replace(
/(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])/g,
(hash: string) => hashes.get(hash) ?? hash,
);
}
interface ContractPlan {
readonly path: string;
readonly snapshot: boolean;
readonly oldHash: string;
readonly newHash: string;
readonly content: string;
readonly columns: ReadonlySet<string>;
readonly fields: ReadonlySet<string>;
readonly defaults: ReadonlyMap<string, readonly DefaultRewrite[]>;
}
function summary(
root: string,
contractsFound: number,
files: number,
directories: number,
hashes: ReadonlyMap<string, string>,
): readonly string[] {
if (contractsFound === 0) return [`No SQL contract was found under ${root}; nothing changed.`];
if (files === 0 && directories === 0)
return ['No contract stores a noCheck key or a default in another form; nothing changed.'];
const renamed = count(directories, 'snapshot directory', 'snapshot directories');
const changes = `Rewrote ${count(files, 'file', 'files')} and renamed ${renamed}.`;
if (hashes.size === 0) return [changes];
return [
`${changes} Storage hashes changed (old -> new):`,
...[...hashes]
.sort(([a], [b]) => compareCodeUnits(a, b))
.map(([oldHash, newHash]) => ` ${oldHash} -> ${newHash}`),
];
}
const JSON_FILES_THAT_MUST_PARSE = new Set(['contract.json', 'migration.json']);
const USAGE =
'Usage: node remove-nocheck.ts [project-root] [--default <namespace>.<table>.<column>=<JSON value>]...';
interface Args {
readonly root: string;
readonly replacements: ReadonlyMap<string, Json>;
readonly errors: readonly string[];
}
function parseArgs(args: readonly string[]): Args {
const errors: string[] = [];
const positional: string[] = [];
const replacements = new Map<string, Json>();
for (let index = 0; index < args.length; index++) {
const arg = args[index] ?? '';
const given = arg === '--default' ? args[++index] : undefined;
const option = arg.startsWith('--default=') ? arg.slice('--default='.length) : given;
if (option === undefined && !arg.startsWith('-')) {
positional.push(arg);
continue;
}
if (option === undefined && arg !== '--default') {
errors.push(`${arg}: unknown option`);
continue;
}
const separator = option?.indexOf('=') ?? -1;
const value = option === undefined ? undefined : parseJson(option.slice(separator + 1));
if (option === undefined || separator < 1 || value === undefined) {
const shown = option === undefined ? '--default' : `--default ${option}`;
errors.push(`${shown}: expected <namespace>.<table>.<column>=<JSON value>`);
continue;
}
replacements.set(option.slice(0, separator), value);
}
if (positional.length > 1) errors.push(`expected one project root, got ${positional.length}`);
const root = resolve(positional[0] ?? process.cwd());
if (errors.length === 0 && !isDirectory(root)) errors.push(`${root}: not a directory`);
if (errors.length > 0) errors.push(USAGE);
return { root, replacements, errors };
}
function main({ root, replacements, errors }: Args): number {
if (errors.length > 0) {
process.stderr.write(`${errors.join('\n')}\n`);
return 1;
}
const display = (path: string): string => relative(root, path).split(sep).join('/');
const files = listFiles(root);
const plans: ContractPlan[] = [];
const stops: string[] = [];
let contractsFound = 0;
const used = new Set<string>();
for (const path of files.json) {
const text = readFileSync(path, 'utf8');
const contract = parseJson(text);
if (
contract === undefined &&
(JSON_FILES_THAT_MUST_PARSE.has(basename(path)) || files.refs.includes(path))
) {
stops.push(`${display(path)}: not valid JSON`);
continue;
}
if (
!isRecord(contract) ||
contract['targetFamily'] !== 'sql' ||
typeof contract['target'] !== 'string' ||
!isRecord(contract['storage'])
)
continue;
contractsFound++;
const rewrite = rewriteContract(contract, replacements, used);
for (const problem of rewrite.notValues) stops.push(`${display(path)}: ${problem}`);
if (!rewrite.changed || rewrite.notValues.length > 0) continue;
const snapshot = isSnapshotContract(path);
const stored = contract['storage']['storageHash'];
const oldHash = snapshot ? basename(dirname(path)) : typeof stored === 'string' ? stored : '';
const computed = computeStorageHash(contract);
if (computed !== oldHash || (typeof stored === 'string' && stored !== oldHash)) {
stops.push(
`${display(path)}: the stored storage hash ${oldHash} does not match the contract's content, which hashes to ${computed}; restore the file from version control, or re-emit it, and run the script again`,
);
continue;
}
const newHash = computeStorageHash(rewrite.contract);
plans.push({
path,
snapshot,
oldHash,
newHash,
content: writtenForm(withStorageHash(rewrite.contract, newHash), contract, text),
columns: rewrite.columns,
fields: rewrite.fields,
defaults: rewrite.defaults,
});
}
for (const key of replacements.keys())
if (!used.has(key))
stops.push(
`--default ${key}: no stored contract has a default of this column that this release refuses, so the script replaces nothing`,
);
const hashes = new Map<string, string>();
for (const plan of [...plans].sort((a, b) => Number(b.snapshot) - Number(a.snapshot)))
if (plan.oldHash !== plan.newHash && !hashes.has(plan.oldHash))
hashes.set(plan.oldHash, plan.newHash);
const newDirectories = new Map<string, Map<string, string>>();
const directorySources = new Map<string, string>();
const sourceDirectories = new Set<string>();
const removals = new Set<string>();
const snapshotRewrites: [string, string][] = [];
const referenceWrites = new Map<string, string>();
const contractWrites = new Map<string, string>();
for (const plan of plans) {
const dtsPath = plan.snapshot
? join(dirname(plan.path), 'contract.d.ts')
: `${plan.path.slice(0, -'.json'.length)}.d.ts`;
const dtsHashes = new Map([...hashes, [plan.oldHash, plan.newHash]]);
const dts = existsSync(dtsPath)
? rewriteDts(
readFileSync(dtsPath, 'utf8'),
plan.columns,
plan.fields,
plan.defaults,
dtsHashes,
)
: undefined;
if (!plan.snapshot) {
if (dts !== undefined) referenceWrites.set(dtsPath, dts);
contractWrites.set(plan.path, plan.content);
continue;
}
const oldDir = dirname(plan.path);
const newDir = join(dirname(oldDir), plan.newHash);
sourceDirectories.add(oldDir);
if (newDir === oldDir) {
if (dts !== undefined) snapshotRewrites.push([dtsPath, dts]);
snapshotRewrites.push([plan.path, plan.content]);
continue;
}
removals.add(oldDir);
const content = readDirectory(oldDir);
content.set('contract.json', plan.content);
if (dts !== undefined) content.set('contract.d.ts', dts);
const planned = newDirectories.get(newDir);
if (planned !== undefined && !sameFiles(planned, content)) {
stops.push(`${display(newDir)}: snapshot directory already exists with different content`);
continue;
}
newDirectories.set(newDir, content);
directorySources.set(newDir, oldDir);
}
for (const [dir, content] of newDirectories) {
if (sourceDirectories.has(dir) || !existsSync(dir)) continue;
if (sameFiles(readDirectory(dir), content)) newDirectories.delete(dir);
else stops.push(`${display(dir)}: snapshot directory already exists with different content`);
}
for (const path of files.migrationJson) {
const text = readFileSync(path, 'utf8');
const metadata = parseJson(text);
if (!isRecord(metadata)) continue;
const from = metadata['from'];
const to = metadata['to'];
const nextFrom = typeof from === 'string' ? (hashes.get(from) ?? from) : from;
const nextTo = typeof to === 'string' ? (hashes.get(to) ?? to) : to;
if (nextFrom === from && nextTo === to) continue;
const opsPath = join(dirname(path), 'ops.json');
const ops = existsSync(opsPath) ? parseJson(readFileSync(opsPath, 'utf8')) : undefined;
if (existsSync(opsPath) && ops === undefined) {
stops.push(`${display(opsPath)}: not valid JSON`);
continue;
}
const oldMigrationHash = metadata['migrationHash'];
let next = replaceQuotedHashes(text, hashes);
if (
ops !== undefined &&
typeof oldMigrationHash === 'string' &&
nextFrom !== undefined &&
nextTo !== undefined
) {
const migrationHash = computeMigrationHash({ ...metadata, from: nextFrom, to: nextTo }, ops);
next = next.replace(`"${oldMigrationHash}"`, `"${migrationHash}"`);
}
referenceWrites.set(path, next);
}
for (const path of files.refs) {
const text = readFileSync(path, 'utf8');
const next = replaceQuotedHashes(text, hashes);
if (next !== text) referenceWrites.set(path, next);
}
for (const path of files.migrationTs) {
const text = readFileSync(path, 'utf8');
const next = replaceHashTokens(text, hashes);
if (next !== text) referenceWrites.set(path, next);
}
if (stops.length > 0) {
process.stderr.write(`${[...new Set(stops)].join('\n')}\n`);
return 1;
}
try {
for (const path of files.leftovers) rmSync(path, { recursive: true, force: true });
for (const [dir, content] of newDirectories)
writeDirectory(dir, content, directorySources.get(dir) ?? dir);
for (const [path, text] of snapshotRewrites) writeFile(path, text);
for (const [path, text] of referenceWrites) writeFile(path, text);
for (const [path, text] of contractWrites) writeFile(path, text);
for (const dir of removals) removeDirectory(dir);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(
`${message}; the upgrade stopped partway, run the script again to finish it\n`,
);
return 1;
}
const rewrittenFiles = snapshotRewrites.length + referenceWrites.size + contractWrites.size;
const lines = [...summary(root, contractsFound, rewrittenFiles, removals.size, hashes)];
process.stdout.write(`${lines.join('\n')}\n`);
return 0;
}
process.exitCode = main(parseArgs(process.argv.slice(2)));
upgrading/extension/upgrades/8.0.0-rc.19-to-8.0.0-rc.20/instructions.md
---
from: "8.0.0-rc.19"
to: "8.0.0-rc.20"
changes:
- id: engine-pin-moves-to-0-9-0
summary: |
The toolchain now peers `@prisma/cli-engine@0.9.0` (up from 0.7.0). An extension package whose tests or tooling load `@prisma/cli-engine` alongside the Prisma toolchain must move it to `0.9.0`, in `package.json` or in a `pnpm-workspace.yaml` catalog. A separate CLI that the package builds on the engine is not affected. The detection matches an engine version below 0.9 in either file.
detection:
glob: "**/{package.json,pnpm-workspace.yaml}"
matches:
- '[\x27"]@prisma/cli-engine[\x27"]\s*:\s*[\x27"]?[^\x27"\n#]*(?<![\d.])0\.[0-8](?!\d)'
- id: piped-output-needs-json
summary: |
A piped Prisma ORM command no longer prints JSON. A test that runs the CLI with a non-terminal stdout and parses JSON frames from it must pass `--json`.
- id: stored-contracts-expand-type-aliases
summary: |
A stored SQL contract whose columns name a type alias in `typeRef` no longer loads. Run `scripts/expand-type-aliases/expand-type-aliases.ts` from the extension's package root, then re-emit the bundled contract. If the re-emit drops a type alias that no field uses, add a migration with no operations to the re-emitted hash, move the head ref to it, and add it to the migrations the descriptor ships. The script writes each alias's parameters into the columns typed by it, in every stored contract the extension ships, its migration snapshots included, and rewrites the hashes and references that change.
detection:
glob: "**/contract.json"
matches:
- '"typeRef"\s*:'
script: ./scripts/expand-type-aliases/expand-type-aliases.ts
- id: types-key-is-database-types
summary: |
A TypeScript contract space that registers a database type now writes it under `databaseTypes` instead of `types`. Rename the key and keep its entries. `defineContract` refuses a `types` key with `CONTRACT.TYPES_KEY_REMOVED`. The contract and its storage hash do not change. The detection matches a `types` key whose entries are `kind: 'codec-instance'` literals.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$.\x27"])types\s*:\s*\{\s*(?:\[[^\]]*\]|[\w$]+|[\x27"][^\x27"]*[\x27"])\s*:\s*\{\s*kind\s*:\s*[\x27"]codec-instance[\x27"]'
- id: column-type-ref-removed
summary: |
`ColumnTypeDescriptor.typeRef`, `StorageColumn.typeRef` and `ExecutionContext.types` are removed. Read a column's codec and parameters from its own `codecId` and `typeParams`. The codec instance of a parameterized column is now named `<col:Table.column>`, never after a type alias. The detection matches TypeScript that reads `typeRef` or the execution context's `types`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$])typeRef(?![\w$])'
- '[cC]ontext\.types(?![\w$])'
- id: type-alias-descriptor
summary: |
No action is needed. A pack may now contribute a type alias in its `type` namespace, a descriptor of `kind: 'typeAlias'` whose output names a column type and optional `onCreate` and `onUpdate` generator calls. Assembly refuses, with `CONTRACT.PACK_CONTRIBUTION_INVALID`, fixed type parameters the codec's parameter schema refuses and a generator call that does not resolve for the codec.
- id: entity-context-data-types
summary: |
`AuthoringEntityContext` has a required `dataTypes: DataTypeSupport`, the stack's data types with their authoring entries, in place of `dataTypeLookup`; its `readWrittenValueForCodec` hook and the `WrittenValueReading` type are removed. Where an extension builds an `AuthoringEntityContext`, replace `dataTypeLookup` with `dataTypes` from the contract source context or the assembled stack (`assembleDataTypeSupport`), and read the data type lookup as `ctx.dataTypes.lookup`. To read a written value into a codec, call `readWrittenValueIntoCodec({ support, codecLookup, ref, written, subject })` from `@internal/framework-components/authoring`, which returns the stored value or a refusal with its code and message. The detection matches code that uses the removed hook or type, builds the context by hand, or reads `dataTypeLookup` from a context.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$])readWrittenValueForCodec(?![\w$])'
- '(?<![\w$])WrittenValueReading(?![\w$])'
- ':\s*AuthoringEntityContext\s*=\s*\{'
- '(?<![\w$])ctx\.dataTypeLookup(?![\w$])'
- id: enum-block-members-written-values
summary: |
`readEnumBlockMembers` reads every member through `readWrittenValueIntoCodec` and no longer reads a member's value as JSON. A block member with a value must have an entry in the block's `writtenValues`, which the PSL block interpreter records for every literal and for a list of literals; a member without one throws an `InternalError`, and a bare member is read from its name. `writtenValues` now holds a `WrittenValue`, which may be a list. An extension that builds an enum block itself sets `writtenValues` for each member with a value, as `{ kind: 'string', text }` for text. The detection matches code that calls `readEnumBlockMembers` or reads `writtenValues`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$])readEnumBlockMembers\('
- '\.writtenValues(?![\w$])'
- id: access-changes-rename
summary: |
`accessWidening` is renamed `accessChanges` on `MigrationPlanSubjects` (from `@prisma/orm-framework/components/control`) and on every type that extends it: `MigrationPlannerSuccessResult`, `PerSpacePlan` and `DbUpdateSuccess`. The subjects argument of `plannerSuccess` from `@prisma/orm-family-sql/family/control` takes `accessChanges`, and the control API option `acceptAccessWidening` becomes `acceptAccessChanges`. There is no alias for the old names.
detection:
glob: "**/*.{ts,tsx,mts,cts,js,mjs,cjs}"
matches:
- '(?<![\w$])(?:acceptAccessWidening|accessWidening)(?![\w$])'
- id: statement-planning-target-namespaces
summary: |
In `@prisma/orm-family-sql/family/control`, `SchemaTables` gains `hasNamespace(namespaceId)`, and `StatementPlanningTarget` gains an optional `namespaces` member. A `SchemaTables` or a test double of it adds `hasNamespace`; a target with several namespaces may add `namespaces`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\w$])(?:StatementPlanningTarget|SchemaTables)(?![\w$])'
- id: schema-ownership-namespace-spaces
summary: |
`SchemaOwnership` from `@prisma/orm-framework/components/control` gains `namespaceDeclaringSpaces(namespaceId)`: the ids of the contract spaces that declare the namespace. A test double of it adds the method.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\w$])SchemaOwnership(?![\w$])'
- id: namespace-rename-statement
summary: |
`ResolvedRenameStatement` from `@prisma/orm-framework/components/control` gains `ResolvedNamespaceRenameStatement`, `{ kind: 'rename', entity: 'namespace', from, to }`, whose `from` and `to` are namespace ids. Code that switches over a rename's `entity` handles `'namespace'`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\w$])ResolvedRenameStatement(?![\w$])'
- id: check-expectation
summary: |
A migration check that expects one object to be absent or present can now say so in an optional `expectation` field, and a failed precheck's message then names the object: `That check failed: the database already has index "Item_state_idx".` instead of `That check failed on this database.` Checks written without it keep working and keep the generic sentence.
detection:
glob: "**/ops.json"
matches:
- '(?:\{\s*"description": "(?:[^"\\]|\\.)*",\s*"sql": "(?:SELECT (?:NOT )?EXISTS \(SELECT 1 AS \\"one\\" FROM \\"(?:information_schema\\"\.\\"columns|pg_extension|pg_constraint|pg_class|pg_policies|pg_type|pg_enum|pg_indexes|pg_namespace)\\"(?: AS \\"\w+\\")?(?: INNER JOIN \\"pg_(?:namespace|type|class)\\" AS \\"\w+\\" ON \\"\w+\\"\.\\"\w+\\" = \\"\w+\\"\.\\"\w+\\")* WHERE (?:[^"\\]|\\"(?:table_schema|table_name|column_name|extname|conname|connamespace|conrelid|nspname|schemaname|tablename|policyname|indexname|typname|typnamespace|enumlabel|enumtypid|relname|relkind|oid|type|name|c|n|t|e|i|p)\\")*?\) AS \\"result\\"|SELECT \(to_regclass\(\$\d+\)\) IS (?:NOT )?NULL AS \\"result\\"|SELECT COUNT\(\*\) (?:= 0|> 0) AS \\"result\\" FROM (?:\\"sqlite_master\\"|pragma_table_info\([^)"]*\)|pragma_index_list\([^)"]*\)) WHERE (?:[^"\\]|\\"(?:table_schema|table_name|column_name|extname|conname|connamespace|conrelid|nspname|schemaname|tablename|policyname|indexname|typname|typnamespace|enumlabel|enumtypid|relname|relkind|oid|type|name|c|n|t|e|i|p)\\")*?)"(?:,\s*"params": \[[^\]]*\])?\s*\}|"source": \{\s*"kind": "list(?:Indexes|Collections)"(?:,\s*"collection": "(?:[^"\\]|\\.)*")?\s*\},\s*"filter": \{\s*"kind": "field",(?:[^{}]|\{[^{}]*\})*\},\s*"expect": "(?:exists|notExists)"\s*\})'
- id: check-expectation-raw-sql
summary: |
A precheck or postcheck you write by hand in a `rawSql` operation of a `migration.ts` can carry `expectation`, `{ expects: 'absent' | 'present', object }`, so a failure of it names the object. Add it where the check asks whether one object exists, then run `node migration.ts` for a migration no database has applied yet.
detection:
glob: "**/migration.ts"
matches:
- '\brawSql\(\{[\s\S]*?\b(?:precheck|postcheck):\s*\[(?:\s*\{(?:[^{}]|\{[^{}]*\})*\},)*?\s*\{[^{}]*\}'
- id: sql-builder-insert-required-columns
summary: |
A SQL builder insert, `db.sql.<namespace>.<table>.insert(rows)`, must now include every column that is not nullable and has no default. Leaving one out is a type error that names the column, and so is a value typed `T | undefined` for such a column. Add a real value, narrowed to `T`.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\.insert\('
- id: supabase-for-role-replaces-with-role-methods
summary: |
`SupabaseRuntime` no longer has `queryWithRole`, `executeWithRole`, `preparedQueryWithRole` or `preparedExecuteWithRole`, and `SupabaseRuntimeImpl` no longer has `roleActivitySource`. Call `runtime.forRole(binding)` once and use its members: `runtime.queryWithRole(plan, binding, options)` becomes `runtime.forRole(binding).query(plan, options)`, `executeWithRole` becomes `forRole(binding).execute`, a prepared statement runs on the role with `statement.query(runtime.forRole(binding), params)` or `statement.execute(runtime.forRole(binding), params)`, and the role's activity source is the `activitySource` member of `forRole(binding)`. The detection matches files that call one of the removed methods.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs}"
matches:
- '\.(?:queryWithRole|executeWithRole|preparedQueryWithRole|preparedExecuteWithRole|roleActivitySource)\('
- id: supabase-role-types-gain-members
summary: |
Three public Supabase types require new members, so a test double or hand-built object typed with them stops compiling. `RoleBoundDb` and `ServiceRoleDb` gain `enums` and `nativeEnums`. `SupabaseTransactionContext` is now `PostgresTransactionContext`, so it gains `enums` and `nativeEnums` too. `SupabaseRuntime` requires `forRole(binding)`, which returns a `SupabaseRoleScope` (`query`, `execute`, the prepared-statement operations and `connection`), in place of the four removed `*WithRole` methods. Add the members to each double: take `enums` and `nativeEnums` from `postgresStatic({ contract })` of `@prisma/orm-postgres/static`, or from a role-bound client. The detection matches files that name one of these types.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$])(?:RoleBoundDb|ServiceRoleDb|SupabaseTransactionContext|SupabaseRuntime)(?![\w$])'
- id: raw-connection-obeys-close
summary: |
`SqlRuntimeBase.acquireRawConnection()` now follows the close rule of `connection()`: once the runtime starts refusing new work it rejects with `DRIVER.NOT_CONNECTED` "Runtime is closed" (at once with `closeRefusal: 'at-once'`, after the runtime has been idle for a turn of the event loop with `'when-idle'`), unless you pass `{ activityParent }` of an activity that is still open; the statements and transactions run on the connection are work `close()` waits for, and a connection held between statements is not. A transaction now counts as work from its start until its commit or rollback settles, its commit fails, or its connection is released or destroyed, so `close()` waits for an open transaction on any connection and, while it waits only for transactions still open, logs `runtime.close.waiting-for-activities` with `openTransactions`, their count; it waits for each until it commits or rolls back. A subclass that shares its driver with another runtime builds each runtime with the new `ownsDriver: false` option, closes them all, then closes the driver once. The detection matches files that call `acquireRawConnection`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$])acquireRawConnection\('
- id: supabase-pg-takes-a-pool
summary: |
The `pg` option of `supabase()` takes a `pg.Pool` only. A `pg.Client` is a type error and fails at `supabase()` with `SUPABASE.CONFIG_INVALID`. Replace `pg: new Client(...)` with `pg: new Pool(...)` built from the same settings, or pass `url`. The detection matches every `supabase()` call that passes `pg`; leave a call that already passes a `pg.Pool` as it is.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs}"
matches:
- '(?<![\w$.])supabase(?:<[^>]*>)?\(\s*\{(?:[^{}]|\{[^{}]*\})*?(?<![\w$.])pg\s*[:,}]'
---
# 8.0.0-rc.19 → 8.0.0-rc.20 — Extension author upgrade instructions
One change comes with a script. Run `stored-contracts-expand-type-aliases` before you re-emit the extension's bundled contract. Make the other changes, then re-emit the bundled contract once.
## CLI engine 0.9.0
### `engine-pin-moves-to-0-9-0`
This applies to tests and tooling that load `@prisma/cli-engine` alongside the Prisma toolchain. A separate CLI that the package builds on the engine runs its own engine, so it does not have to move with this upgrade.
For every `package.json` or `pnpm-workspace.yaml` catalog matched by `detection`, change the `@prisma/cli-engine` version (`0.7.0`, `^0.7.0` or any other 0.x below 0.9) to `0.9.0` and reinstall.
### `piped-output-needs-json`
Engine 0.9.0 chooses the output format from flags only. Before, a run whose stdout was not a terminal printed JSON frames; now it prints the human card on stderr and the command's data lines on stdout. The engine's test harness from `@prisma/cli-engine/testing` treats stdout as a pipe unless `isTty.stdout` is set, so it is affected the same way.
Find the tests and tooling that run a Prisma ORM command, through a spawned `prisma` process or `createTestCli`, and read JSON from its output: they parse the `result` frame of its stdout, read `json` from the harness run, or read `presented.presentation.json`. Add `--json` to the argv of each such run. A run in human format still returns the command's document as `presented.data`, so a test that reads only that needs no change.
## Type aliases are written into each column
A type alias is now authoring shorthand only. The contract no longer stores an alias in `storage.types` with a `typeRef` on each column that uses it. Each column stores its own codec and parameters, and `storage.types` holds only the database types a contract space registers, such as pgvector's `vector`.
### `stored-contracts-expand-type-aliases`
Migration snapshots that an extension ships, its bundled `contract.json`, and any other stored contract written by an earlier release may have a column with a `typeRef`. This release refuses such a contract and says to re-emit it. A historical snapshot cannot be re-emitted from today's source, so run the script instead. Do this before you re-emit the bundled contract, and commit your work first so the script's changes can be reviewed and undone with git. Run it from the extension's package root:
```sh
node <guide>/scripts/expand-type-aliases/expand-type-aliases.ts
```
`<guide>` is the directory that holds this guide. Node 24 or later runs the TypeScript script directly; it needs no `tsx`. The script reads and writes files only and needs no database. It rewrites every SQL contract under the root, skipping `node_modules`, `dist`, `build` and every directory whose name starts with `.`, such as `.git` and the worktrees under `.claude/worktrees/`. That includes test fixtures: if you keep an old contract on purpose, restore it with git afterwards.
In each contract it replaces every column's `typeRef` with the parameters of the `storage.types` entry it names, and removes each entry a column named. It makes the same change in the `contract.d.ts` next to the contract. The extension's own contract space is an extension's space, because its `migrations/` directory holds `refs/` directly, so an entry that no column names is kept, as a database type the contract registers, and the script lists it. The script cannot tell such an entry from an alias your source declares but no field uses, so it keeps both. A re-emit drops an unused alias, so the bundled contract then gets another storage hash than the one the script wrote into `migrations/refs/head.json`. Do not change the script's output to match: each application runs the same script on its own copy of your migrations and gets the hash the script wrote. Add a migration to the re-emitted contract instead, as described after the re-emit below.
The storage hash changes, so the script also renames each changed snapshot directory to its new hash. It rewrites every `migration.json`, ref, `migration.ts` and `contract.d.ts` that names an old hash, and gives each changed migration a new `migrationHash`. When it finishes, it prints how many files it rewrote, how many snapshot directories it renamed, each storage hash it replaced (`<old> -> <new>`), each `storage.types` entry it removed, per contract, and each entry it kept. A package with no `typeRef` is left unchanged, and the script says so.
It changes no file and exits 1, naming the file, when a `contract.json`, `migration.json`, `ops.json` or ref is not valid JSON; when a contract's stored storage hash or snapshot directory name does not match its content; when a column names an alias `storage.types` does not declare, stores both `typeRef` and `typeParams`, or has a codec or data type other than its alias's; when a migration with no data operation goes between two snapshots that become the same snapshot once their aliases are written into their columns, because it only introduced or renamed an alias; or when a renamed snapshot directory already exists with different content. In the first three cases, restore that file from version control, or re-emit it, and run the script again. When two snapshots become one, the script names each migration between them that has no data operation, because after the upgrade it would go from a snapshot to itself, which only a migration with a data operation may do. Delete its directory and run the script again. When that migration is your latest, nothing else names its target snapshot, so the script names that snapshot directory too; delete it as well. The script then writes the merged snapshot's hash into every `migration.json`, ref and `migration.ts` that names either old hash, and gives each changed migration a new `migrationHash`. If it stops partway, for example on a full disk, it says so; run it again to finish.
Then re-emit the bundled contract with the extension's own emit script, and ship the new `contract.json` and `contract.d.ts` together. A bundled contract written in PSL may keep its `types {}` aliases.
If the re-emitted `storage.storageHash` differs from the hash in `migrations/refs/head.json`, the re-emit dropped an alias that no field uses. An application would then refuse to load the extension with `MIGRATION.DESCRIPTOR_HEAD_HASH_MISMATCH`, because the head ref must name the bundled contract's hash. Write a migration with no operations from the head ref's hash to the re-emitted hash, in a new directory under `migrations/`. Import `Migration` and `MigrationCLI` from your target's migration module, as your existing `migration.ts` files do. The example uses Postgres's, `@internal/postgres/migration`:
```ts
#!/usr/bin/env -S node
import { Migration, MigrationCLI } from '@internal/postgres/migration';
export default class M extends Migration {
override describe() {
return { from: '<the hash in migrations/refs/head.json>', to: '<the re-emitted storageHash>' };
}
override get operations() {
return [];
}
}
MigrationCLI.run(import.meta.url, M);
```
Run `node migration.ts` in that directory to write its `ops.json` and `migration.json`. Copy the re-emitted `contract.json` and `contract.d.ts` into `migrations/snapshots/<the re-emitted storageHash>/`. Then set `hash` in `migrations/refs/head.json` to the re-emitted hash, and keep its `invariants`. The new migration changes nothing in the database.
Then add the new migration to the migrations your descriptor ships, after the existing ones. An application gets the extension's migrations only from that list. Without it, an application's database that is empty or at the head ref's old hash cannot reach the new head. In pgvector, PostGIS and ParadeDB the list is the `migrations` argument of `contractSpaceFromJson`:
```ts
import removeUnusedAliasMetadata from '../../migrations/<the new directory>/migration.json' with { type: 'json' };
import removeUnusedAliasOps from '../../migrations/<the new directory>/ops.json' with { type: 'json' };
const contractSpace = contractSpaceFromJson<Contract<SqlStorage>>({
contractJson,
migrations: [
{ dirName: BASELINE_DIR_NAME, metadata: baselineMetadata, ops: baselineOps },
{ dirName: '<the new directory>', metadata: removeUnusedAliasMetadata, ops: removeUnusedAliasOps },
],
headRef,
});
```
If the storage hash of the bundled contract changed, tell the applications that use the extension, in your release notes, to run `prisma db sign` against every database after they upgrade, and name the old and new hash. The old hash is the one the bundled contract had before you ran the script, and the new hash is the one in `migrations/refs/head.json` when you publish. The Supabase extension's contract is an example: its hash changes from `292489649ae6d6928de98a32b544bd9caf6b7d49718c10fe2720b83545a4b5a2` to `110ad99e1e466691bdb0a4349a17365561e76ad017f58beb272f4496b05b8e9e`. Tell them too that `prisma migration status` no longer marks the extension's migrations applied before the upgrade as applied, because each rewritten migration has a new hash. `prisma db migrate` runs none of them again, and `prisma db verify` passes. A database that has not applied every migration yet is refused by a plain `prisma db sign`, and `prisma db migrate` stops on it with `MIGRATION.MARKER_MISMATCH`: tell them to sign it with `prisma db sign --contract <the last migration it applied>` and then run `prisma db migrate`, as the app guide's `stored-contracts-expand-type-aliases` says.
### `types-key-is-database-types`
A contract space that registers a database type, because PSL has no syntax for an extension to register one, writes it under `databaseTypes`:
```ts
// Before
export const contract = defineContract({ codecLookup, dataTypeLookup }, () => ({
types: {
[PGVECTOR_NATIVE_TYPE]: { kind: 'codec-instance', codecId: VECTOR_CODEC_ID, typeParams: {} },
},
models: {},
}));
// After
export const contract = defineContract({ codecLookup, dataTypeLookup }, () => ({
databaseTypes: {
[PGVECTOR_NATIVE_TYPE]: { kind: 'codec-instance', codecId: VECTOR_CODEC_ID, typeParams: {} },
},
models: {},
}));
```
The emitted contract is the same, so its storage hash does not change. No column can reference a `databaseTypes` entry: `field.namedType(...)` now takes only an enum handle. If the `types` key held aliases that fields used through `field.namedType(types.X)`, write each as a constant instead, `const X = field.column(type.Y(...))`, and use the constant as the field. Then re-emit the bundled contract.
### `column-type-ref-removed`
Code that reads storage gets the column's type from the column itself:
- `StorageColumn` and `ColumnTypeDescriptor` have no `typeRef`. Read `codecId` and `typeParams` from the column. A validated contract never has a `typeRef`, so there is no alias to look up in `storage.types`.
- `ExecutionContext.types` and the `TypeHelperRegistry` are removed. Get a column's codec from the codec registry, for example through `codecRefForColumn(table, column)`.
- A codec instance's `name` in its `CodecInstanceContext` is `<col:Table.column>` for a parameterized column, the first column with that codec and those parameters. It used to be the alias's name for columns typed by an alias. Columns with the same codec and parameters still share one instance, and its `usedAt` lists them all. A codec that keyed per-instance state by the alias name must key it by its parameters instead.
- `planTypeOperations` and `verifyType` still run for each `storage.types` entry, which is now only a registered database type.
A hit in a generated `contract.d.ts` is rewritten by `stored-contracts-expand-type-aliases`; do not edit it by hand.
### `type-alias-descriptor`
There is nothing to change. A pack's `type` namespace may now contain a type alias:
```ts
CreatedAt: {
kind: 'typeAlias',
documentation: 'A timestamp the client sets to now when a row is created.',
output: { type: { codecId: 'pg/timestamptz-temporal@1' }, onCreate: { name: 'now', args: {} } },
},
```
It takes no arguments. PSL writes it bare as a field type (`temporal.CreatedAt`), and TypeScript as `field.column(type.temporal.CreatedAt())`. Each `onCreate` and `onUpdate` call is resolved by the column's codec, and assembly refuses a call that does not resolve with `CONTRACT.PACK_CONTRIBUTION_INVALID`.
## Written values are read into a codec by one framework function
Enum members and `@@base` values are read by `readWrittenValueIntoCodec` in `@internal/framework-components/authoring`, in every family. It replaces the `readWrittenValueForCodec` hook on `AuthoringEntityContext`, which now carries the stack's `dataTypes` in place of the hook and of `dataTypeLookup`:
```typescript
const context: AuthoringEntityContext = {
family,
target,
codecLookup,
dataTypes,
};
```
A discriminator and its variants' values are decided by `applyDiscriminatorRule`, and its refusals worded for PSL by `describeDiscriminatorRefusal`, both in the same entry point; a target names its text data types in `authoring.textDataTypes`.
## `accessWidening` is renamed `accessChanges`
A migration planner lists each operation that changes who can read or write data. Such an operation does not always widen access, so the list is now `accessChanges`. Each entry is still a `MigrationAccessChange`, with `widens` saying whether it widens access. Rename the property wherever a planner, a test double or a hand-built plan sets or reads it:
```ts
// Before
return { kind: 'success', plan, appliedStatements, dataLoss: [], accessWidening: [], temporaryValues: [] };
plannerSuccess(plan, appliedStatements, { dataLoss: [], accessWidening: [], temporaryValues: [] });
// After
return { kind: 'success', plan, appliedStatements, dataLoss: [], accessChanges: [], temporaryValues: [] };
plannerSuccess(plan, appliedStatements, { dataLoss: [], accessChanges: [], temporaryValues: [] });
```
A caller of the control API's `executeDbUpdate` or `dbUpdate` renames the option `acceptAccessWidening` to `acceptAccessChanges`, and reads `accessChanges` from a `DbUpdateSuccess`.
## Statements can move tables and rename namespaces
### `statement-planning-target-namespaces`
A SQL target's statement planner now moves a table to another namespace and renames a namespace. Add `hasNamespace` to each `SchemaTables` you implement:
```ts
hasNamespace: (namespaceId) => namespaceId === UNBOUND_NAMESPACE_ID,
```
A target with one namespace, as SQLite has, leaves out `namespaces` on its `StatementPlanningTarget`; the planner then refuses a namespace rename or a model move with a `statementRefused` conflict. A target with several namespaces adds `namespaces: { createNamespaceCalls, moveTableCall, namespaceRenameRefusal, renameNamespaceCall }`, returning the calls that create a namespace, move a table and rename a namespace, which it applies to its working schema. `namespaceRenameRefusal` returns `{ reason, fix }` for a namespace it cannot rename for a reason of its own, such as Postgres's `public`; the planner itself refuses a namespace another contract space declares.
### `schema-ownership-namespace-spaces`
Add `namespaceDeclaringSpaces: () => []` to a `SchemaOwnership` test double that declares no namespace, or return the ids of the spaces whose contracts have the namespace in `storage.namespaces`.
### `namespace-rename-statement`
A `switch` or `if` over `statement.entity` of a `ResolvedRenameStatement` gains a `'namespace'` branch. A planner that cannot rename a namespace refuses the statement with a `statementRefused` conflict.
## Checks say which object they expect absent or present
Before an operation runs, the migration runner runs its prechecks. When one fails, the error's `why` named the check and said only that it failed on this database. A check that expects one object to be absent or present can now carry that as data, in `expectation`, and the `why` then says what the database already has or lacks. On MongoDB, a check carries the object's name in `object`, and its `expect` says whether it should exist.
Nothing breaks without it. An `ops.json` that has no `expectation` reads, verifies its hash and runs as before.
### Migrations Prisma writes
The PostgreSQL, SQLite and MongoDB operations now write it on every check that asks only whether one object exists. A matched `ops.json`, or a MongoDB `ops.json` written before this change, gains it when you write it again: run its `migration.ts` (`node migration.ts`). Writing it again changes its hash, so do this only for a migration that no database has applied yet. Leave an applied migration as it is.
Do not write a migration your extension has already published again: the apps that applied it recorded its hash. The rewrite that `stored-contracts-expand-type-aliases` makes is different: every application runs the same script on its own copy of your migrations.
### Checks you write yourself
In your extension's migrations, a check you write in a `rawSql` operation can carry it too. Add `expectation` to the precheck or postcheck step, with `expects: 'absent'` or `'present'` and the object as a reader would name it, then run `node migration.ts`:
```typescript
rawSql({
// ...
precheck: [
{
description: 'verify extension "vector" is not already enabled',
sql: "SELECT NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector')",
expectation: { expects: 'absent', object: 'extension "vector"' },
},
],
})
```
Leave it out of a check about data or about several objects at once, such as a check that a column holds no NULL values.
## Inserts must include the columns the database requires
The SQL builder's `insert` now requires every column that is not nullable and has no default. A column is still optional when it is nullable, has a default (`@default(...)`, including `now()` and `autoincrement()`), or is generated on create (`@onCreate(...)` or a preset that generates on create).
For each insert in your extension's code that stops compiling, add a real value for each column the error names. Do not cast the row. A required property does not accept `undefined`, so a value typed `T | undefined` for one of these columns must be checked before the insert.
## Supabase runs every role operation through one object
`SupabaseRuntime.forRole(binding)` returns a `SupabaseRoleScope`, the runtime operations under one role: `query`, `execute`, the prepared-statement operations and `connection`. Run a transaction under the role with `withTransaction(runtime.forRole(binding), fn)`.
| Type | New required members |
| --- | --- |
| `RoleBoundDb`, `ServiceRoleDb` | `enums`, `nativeEnums` |
| `SupabaseTransactionContext` | `enums`, `nativeEnums` (it is now `PostgresTransactionContext`) |
| `SupabaseRuntime` | `forRole(binding)`, in place of the four `*WithRole` methods |
```ts
// Before
const rows = await runtime.queryWithRole(plan, { role: 'anon' }).toArray();
// After
const rows = await runtime.forRole({ role: 'anon' }).query(plan).toArray();
```
The statements and the open transactions on a raw connection keep `close()` waiting; a connection held between statements does not, so release it when you are done:
```ts
const connection = await this.acquireRawConnection({ activityParent });
try {
await connection.execute({ sql: 'SELECT 1' });
} finally {
await connection.release();
}
```
upgrading/extension/upgrades/8.0.0-rc.19-to-8.0.0-rc.20/scripts/expand-type-aliases/expand-type-aliases.ts
#!/usr/bin/env node
/**
* Writes every type alias a column of a SQL contract is typed by into the column. Contracts emitted before this release store an alias as a `storage.types` entry and each column typed by it as a `typeRef` naming the entry; this release stores the alias's params on the column itself and refuses a stored `typeRef`. In each contract the script replaces every column's `typeRef` with the entry's `typeParams`, removes each entry a column named, and makes the same change in `contract.d.ts`. In a contract of the app's own contract space it also removes every entry no column names, an alias nothing uses, so the result is what a re-emit gives. In a contract of an extension's contract space, where registered database types live, it keeps such an entry and lists it. The script then renames the snapshot directories and rewrites every migration, ref, `migration.ts` and `contract.d.ts` that names an old storage hash.
*
* A contract belongs to an extension's contract space when a ref or migration of an extension space names its hash: one under `migrations/<space>/` other than `migrations/app/`, or one in a package's own single-space `migrations/` directory. It belongs to the app's when only refs and migrations under `migrations/app/` name it. A snapshot that nothing names keeps its unreferenced entries, as the script cannot tell whose it is. For an emitted contract that nothing names, the nearest migrations directory decides: one with an `app/` space directory, or none at all, is the app's; one with `refs/` directly in it is a package's own space.
*
* Usage: node expand-type-aliases.ts [project-root]
*
* The project root defaults to the working directory. The script reads and writes files only. It needs no database, network or configured stack, and a project with no `typeRef` and no unused alias in the app's space is left unchanged. When it stops (a file it must read is not valid JSON, a contract whose stored hash does not match its content, a column whose alias is missing or has another codec or data type, a migration with no data operation between two snapshots that become the same snapshot, or a snapshot directory that already exists with different content) it changes no file, prints one line per case and exits 1. When it finishes it prints how many files and snapshot directories it changed, each storage hash it replaced, and each entry it removed or kept.
*
* The storage hash and migration hash rules are copied from `@internal/contract` and `@internal/migration-tools` because a project's strict `node_modules` does not expose them.
*/
import { createHash } from 'node:crypto';
import {
chmodSync,
type Dirent,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
type Json = null | boolean | number | string | Json[] | JsonRecord;
interface JsonRecord {
[key: string]: Json;
}
const SKIPPED_DIRECTORIES = new Set(['node_modules', 'dist', 'build']);
const APP_SPACE_ID = 'app';
const HASH = /^[0-9a-f]{64}$/;
const TEMPORARY_SUFFIX = '.expand-type-aliases-tmp';
const BYTE_ORDER_MARK = '\uFEFF';
function isRecord(value: unknown): value is JsonRecord {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const prototype: unknown = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function withoutByteOrderMark(text: string): string {
return text.startsWith(BYTE_ORDER_MARK) ? text.slice(BYTE_ORDER_MARK.length) : text;
}
function parseJson(text: string): Json | undefined {
try {
const value: Json = JSON.parse(withoutByteOrderMark(text));
return value;
} catch {
return undefined;
}
}
function sha256(text: string): string {
return createHash('sha256').update(text).digest('hex');
}
function compareCodeUnits(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
function sortKeys(value: Json): Json {
if (Array.isArray(value)) return value.map(sortKeys);
if (!isRecord(value)) return value;
const sorted: JsonRecord = {};
for (const key of Object.keys(value).sort(compareCodeUnits)) {
const child = value[key];
if (child !== undefined) sorted[key] = sortKeys(child);
}
return sorted;
}
function canonicalizeJson(value: Json): string {
return JSON.stringify(sortKeys(value));
}
type PathSegment = string | readonly string[];
function matchesPath(path: readonly string[], pattern: readonly PathSegment[]): boolean {
if (path.length !== pattern.length) return false;
return pattern.every((segment, index) => {
const value = path[index];
if (value === undefined) return false;
if (segment === '*') return true;
return typeof segment === 'string' ? value === segment : segment.includes(value);
});
}
const SQL_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
['storage', 'namespaces', '*', 'entries', 'table'],
['storage', 'namespaces', '*', 'entries', 'table', '*'],
['storage', 'namespaces', '*', 'entries', 'table', '*', ['uniques', 'indexes', 'foreignKeys']],
['storage', 'namespaces', '*', 'entries', 'table', '*', 'indexes', 'unique'],
['storage', 'namespaces', '*', 'entries', 'table', '*', 'columns', '*', 'default', 'value'],
];
const COLUMN_DEFAULT_VALUE: readonly PathSegment[] = [
'storage',
'namespaces',
'*',
'entries',
'table',
'*',
'columns',
'*',
'default',
'value',
];
const FRAMEWORK_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
['domain', 'namespaces'],
['domain', 'namespaces', '*'],
['domain', 'namespaces', '*', 'models'],
['domain', 'namespaces', '*', 'models', '*', 'relations'],
['domain', 'namespaces', '*', 'models', '*', 'storage'],
['storage', 'namespaces'],
['storage', 'namespaces', '*', 'entries'],
['roots'],
['extensions'],
['extensions', '*'],
['capabilities'],
['meta'],
['execution', 'mutations', 'defaults'],
];
const TOP_LEVEL_ORDER = [
'schemaVersion',
'canonicalVersion',
'targetFamily',
'target',
'profileHash',
'roots',
'domain',
'storage',
'execution',
'capabilities',
'extensions',
'defaultControlPolicy',
'meta',
];
function isDefaultValue(value: Json): boolean {
if (value === false) return true;
if (Array.isArray(value)) return value.length === 0;
return isRecord(value) && Object.keys(value).length === 0;
}
function isPreservedEmpty(path: readonly string[], key: string): boolean {
if (key === 'nullable') return true;
if (key === 'elementNullable' && path.at(-2) === 'many') return true;
if (FRAMEWORK_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern))) return true;
if (
path.length >= COLUMN_DEFAULT_VALUE.length &&
matchesPath(path.slice(0, COLUMN_DEFAULT_VALUE.length), COLUMN_DEFAULT_VALUE)
)
return true;
return SQL_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern));
}
function omitDefaults(value: Json, path: readonly string[]): Json {
if (Array.isArray(value)) return value.map((item) => omitDefaults(item, path));
if (!isRecord(value)) return value;
const result: JsonRecord = {};
for (const [key, child] of Object.entries(value)) {
const childPath = [...path, key];
if (key === '_generated') continue;
if (key === 'generated' && child === false) continue;
if ((key === 'onDelete' || key === 'onUpdate') && child === 'noAction') continue;
if (isDefaultValue(child) && !isPreservedEmpty(childPath, key)) continue;
result[key] = omitDefaults(child, childPath);
}
return result;
}
function compareByName(a: Json, b: Json): number {
const nameA = isRecord(a) && typeof a['name'] === 'string' ? a['name'] : '';
const nameB = isRecord(b) && typeof b['name'] === 'string' ? b['name'] : '';
return compareCodeUnits(nameA, nameB);
}
function sortTableArrays(storage: Json): Json {
if (!isRecord(storage)) return storage;
const namespaces = storage['namespaces'];
if (!isRecord(namespaces)) return storage;
const sortedNamespaces: JsonRecord = {};
for (const [namespaceId, namespace] of Object.entries(namespaces)) {
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
const tables = isRecord(entries) ? entries['table'] : undefined;
if (!isRecord(namespace) || !isRecord(entries) || !isRecord(tables)) {
sortedNamespaces[namespaceId] = namespace;
continue;
}
const sortedTables: JsonRecord = {};
for (const [tableName, table] of Object.entries(tables)) {
if (!isRecord(table)) {
sortedTables[tableName] = table;
continue;
}
const sortedTable: JsonRecord = { ...table };
for (const key of ['checks', 'indexes', 'uniques']) {
const list = table[key];
if (Array.isArray(list)) sortedTable[key] = [...list].sort(compareByName);
}
sortedTables[tableName] = sortedTable;
}
sortedNamespaces[namespaceId] = { ...namespace, entries: { ...entries, table: sortedTables } };
}
return { ...storage, namespaces: sortedNamespaces };
}
function orderTopLevel(value: JsonRecord): JsonRecord {
const ordered: JsonRecord = {};
const remaining = new Set(Object.keys(value));
for (const key of TOP_LEVEL_ORDER) {
const child = value[key];
if (child !== undefined) {
ordered[key] = child;
remaining.delete(key);
}
}
for (const key of [...remaining].sort(compareCodeUnits)) {
const child = value[key];
if (child !== undefined) ordered[key] = child;
}
return ordered;
}
function omitNamespaceKinds(storage: JsonRecord): JsonRecord {
const namespaces = storage['namespaces'];
if (!isRecord(namespaces)) return storage;
const stripped: JsonRecord = {};
for (const [namespaceId, namespace] of Object.entries(namespaces)) {
if (isRecord(namespace)) {
const { kind: _kind, ...rest } = namespace;
stripped[namespaceId] = rest;
} else {
stripped[namespaceId] = namespace;
}
}
return { ...storage, namespaces: stripped };
}
function computeStorageHash(contract: JsonRecord): string {
const storage = isRecord(contract['storage']) ? contract['storage'] : {};
const { storageHash: _published, ...withoutHash } = storage;
const hashed: JsonRecord = {
schemaVersion: '1',
targetFamily: typeof contract['targetFamily'] === 'string' ? contract['targetFamily'] : '',
target: typeof contract['target'] === 'string' ? contract['target'] : '',
profileHash: '',
roots: {},
domain: { namespaces: {} },
storage: omitNamespaceKinds(withoutHash),
extensions: {},
capabilities: {},
meta: {},
};
const withoutDefaults = omitDefaults(hashed, []);
if (!isRecord(withoutDefaults)) return '';
const withSortedStorage = {
...withoutDefaults,
storage: sortTableArrays(withoutDefaults['storage'] ?? {}),
};
const sorted = sortKeys(withSortedStorage);
return isRecord(sorted) ? sha256(JSON.stringify(orderTopLevel(sorted), null, 2)) : '';
}
function computeMigrationHash(metadata: JsonRecord, ops: Json): string {
const { migrationHash: _migrationHash, ...withoutHash } = metadata;
const parts = [canonicalizeJson(withoutHash), canonicalizeJson(ops)].map(sha256);
return sha256(canonicalizeJson(parts));
}
interface ProjectFiles {
readonly json: readonly string[];
readonly migrationJson: readonly string[];
readonly refs: readonly string[];
readonly migrationTs: readonly string[];
readonly leftovers: readonly string[];
}
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
/** A file, or a link to one: the script reads and writes through links to files. */
function isFileEntry(dir: string, entry: Dirent): boolean {
return entry.isFile() || (entry.isSymbolicLink() && isFile(join(dir, entry.name)));
}
/** A file the script reads, by the path it was reached through: its own path rather than a link's. */
interface FoundFile {
readonly path: string;
readonly linked: boolean;
readonly inMigrationPackage: boolean;
}
function listFiles(root: string): ProjectFiles {
const leftovers: string[] = [];
const visited = new Set<string>();
const found = new Map<string, FoundFile>();
const visit = (dir: string): void => {
const realDir = realpathSync(dir);
if (visited.has(realDir)) return;
visited.add(realDir);
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
compareCodeUnits(a.name, b.name),
);
const inMigrationPackage = entries.some(
(entry) => entry.name === 'migration.json' && isFileEntry(dir, entry),
);
for (const entry of entries) {
const path = join(dir, entry.name);
if (entry.name.endsWith(TEMPORARY_SUFFIX)) {
leftovers.push(path);
continue;
}
if (entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(path))) {
if (!SKIPPED_DIRECTORIES.has(entry.name) && !entry.name.startsWith('.')) visit(path);
continue;
}
if (!isFileEntry(dir, entry)) continue;
const realFile = realpathSync(path);
const linked = entry.isSymbolicLink();
const known = found.get(realFile);
if (known === undefined || (known.linked && !linked))
found.set(realFile, { path, linked, inMigrationPackage });
}
};
visit(root);
const files = [...found.values()];
const named = (test: (file: FoundFile, name: string) => boolean): string[] =>
files.filter((file) => test(file, basename(file.path))).map((file) => file.path);
return {
json: named((_file, name) => name.endsWith('.json')),
migrationJson: named((_file, name) => name === 'migration.json'),
refs: named((file, name) => name.endsWith('.json') && basename(dirname(file.path)) === 'refs'),
migrationTs: named((file, name) => name === 'migration.ts' && file.inMigrationPackage),
leftovers,
};
}
function isSnapshotContract(path: string): boolean {
const snapshotDir = dirname(path);
return (
basename(path) === 'contract.json' &&
HASH.test(basename(snapshotDir)) &&
basename(dirname(snapshotDir)) === 'snapshots'
);
}
function replaceQuotedHashes(text: string, hashes: ReadonlyMap<string, string>): string {
return text.replace(/"([0-9a-f]{64})"/g, (match: string, hash: string) => {
const next = hashes.get(hash);
return next === undefined ? match : `"${next}"`;
});
}
function readDirectory(dir: string): Map<string, string> {
const files = new Map<string, string>();
if (!existsSync(dir)) return files;
for (const entry of readdirSync(dir, { withFileTypes: true }))
if (isFileEntry(dir, entry) && !entry.name.endsWith(TEMPORARY_SUFFIX))
files.set(entry.name, readFileSync(join(dir, entry.name), 'utf8'));
return files;
}
function sameFiles(a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): boolean {
return a.size === b.size && [...a].every(([name, content]) => b.get(name) === content);
}
function temporaryPath(path: string): string {
return join(dirname(path), `.${basename(path)}${TEMPORARY_SUFFIX}`);
}
function writeWithModeOf(path: string, text: string, modeSource: string): void {
writeFileSync(path, text);
if (existsSync(modeSource)) chmodSync(path, statSync(modeSource).mode & 0o7777);
}
function writeFile(path: string, text: string): void {
const target = realpathSync(path);
const temporary = temporaryPath(target);
writeWithModeOf(temporary, text, target);
renameSync(temporary, target);
}
function writeDirectory(dir: string, files: ReadonlyMap<string, string>, sourceDir: string): void {
const temporary = temporaryPath(dir);
mkdirSync(temporary);
for (const [name, text] of files)
writeWithModeOf(join(temporary, name), text, join(sourceDir, name));
renameSync(temporary, dir);
}
function removeDirectory(dir: string): void {
const temporary = temporaryPath(dir);
renameSync(dir, temporary);
rmSync(temporary, { recursive: true, force: true });
}
function count(amount: number, singular: string, plural: string): string {
return `${amount} ${amount === 1 ? singular : plural}`;
}
const REMOVED_KEY = 'typeRef';
const DTS_LINE_WIDTH = 100;
interface Rewrite {
readonly contract: JsonRecord;
readonly changed: boolean;
/** The `storage.types` entries a column referenced, whose params the rewrite wrote into the columns. */
readonly expanded: ReadonlyMap<string, JsonRecord>;
/** Every `storage.types` entry the rewrite removed: the referenced ones, and in the app's space the unreferenced ones too. */
readonly removed: ReadonlySet<string>;
/** The `storage.types` entries no column referenced, which the rewrite kept. */
readonly kept: readonly string[];
readonly problems: readonly string[];
}
function nonEmptyParams(entry: JsonRecord): JsonRecord | undefined {
const params = entry['typeParams'];
return isRecord(params) && Object.keys(params).length > 0 ? params : undefined;
}
/** The column with its `typeRef` replaced, in place, by the referenced entry's params. */
function expandedColumn(column: JsonRecord, entry: JsonRecord): JsonRecord {
const params = nonEmptyParams(entry);
const result: JsonRecord = {};
for (const [key, value] of Object.entries(column)) {
if (key !== REMOVED_KEY) result[key] = value;
else if (params !== undefined) result['typeParams'] = params;
}
return result;
}
function describeType(record: JsonRecord): string {
return `codec ${String(record['codecId'])} and data type ${String(record['dataType'])}`;
}
/** The contract with every column's type alias written into the column, the aliases it named removed from `storage.types`, and in a contract of the app's space every other entry too. */
function rewriteContract(contract: JsonRecord, space: SpaceKind): Rewrite {
const copy = parseJson(JSON.stringify(contract));
const storage = isRecord(copy) ? copy['storage'] : undefined;
if (!isRecord(copy) || !isRecord(storage))
return {
contract,
changed: false,
expanded: new Map(),
removed: new Set(),
kept: [],
problems: [],
};
const types = isRecord(storage['types']) ? storage['types'] : {};
const expanded = new Map<string, JsonRecord>();
const problems: string[] = [];
let changed = false;
const namespaces = storage['namespaces'];
for (const namespace of isRecord(namespaces) ? Object.values(namespaces) : []) {
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
const tables = isRecord(entries) ? entries['table'] : undefined;
for (const [tableName, table] of isRecord(tables) ? Object.entries(tables) : []) {
const columns = isRecord(table) ? table['columns'] : undefined;
if (!isRecord(columns)) continue;
for (const [columnName, column] of Object.entries(columns)) {
if (!isRecord(column) || !Object.hasOwn(column, REMOVED_KEY)) continue;
changed = true;
const alias = column[REMOVED_KEY];
const entry = typeof alias === 'string' ? types[alias] : undefined;
const where = `column ${tableName}.${columnName}`;
if (typeof alias !== 'string' || !isRecord(entry)) {
problems.push(
`${where} is typed by the type alias ${JSON.stringify(alias)}, which storage.types does not declare`,
);
continue;
}
if (Object.hasOwn(column, 'typeParams')) {
problems.push(`${where} stores both typeRef and typeParams`);
continue;
}
if (column['codecId'] !== entry['codecId'] || column['dataType'] !== entry['dataType']) {
problems.push(
`${where} has ${describeType(column)}, but its type alias ${alias} has ${describeType(entry)}`,
);
continue;
}
columns[columnName] = expandedColumn(column, entry);
expanded.set(alias, entry);
}
}
}
const removed = new Set(space === 'app' ? Object.keys(types) : expanded.keys());
for (const alias of removed) delete types[alias];
if (removed.size > 0 && Object.keys(types).length === 0) delete storage['types'];
const kept = Object.keys(types).sort(compareCodeUnits);
return {
contract: copy,
changed: changed || removed.size > 0,
expanded,
removed,
kept,
problems,
};
}
function withStorageHash(contract: JsonRecord, hash: string): JsonRecord {
const storage = contract['storage'];
if (!isRecord(storage) || typeof storage['storageHash'] !== 'string') return contract;
return { ...contract, storage: { ...storage, storageHash: hash } };
}
function lineEndingOf(text: string): string {
return text.includes('\r\n') ? '\r\n' : '\n';
}
/** The contract written the way the file held it: a snapshot's canonical line, or indented JSON, with the file's byte order mark and line ending. */
function writtenForm(contract: JsonRecord, original: JsonRecord, text: string): string {
const mark = text.startsWith(BYTE_ORDER_MARK) ? BYTE_ORDER_MARK : '';
const body = withoutByteOrderMark(text);
const lineEnding = lineEndingOf(body);
const finalNewline = body.endsWith('\n') ? lineEnding : '';
if (body === `${canonicalizeJson(original)}${finalNewline}`)
return `${mark}${canonicalizeJson(contract)}${finalNewline}`;
const indented = JSON.stringify(contract, null, 2).replaceAll('\n', lineEnding);
return `${mark}${indented}${finalNewline}`;
}
interface Block {
readonly start: number;
end: number;
readonly label: string;
readonly parent: Block | undefined;
}
function scanBlocks(text: string): Block[] {
const blocks: Block[] = [];
const open: Block[] = [];
let quote: string | undefined;
for (let index = 0; index < text.length; index++) {
const char = text[index];
if (quote !== undefined) {
if (char === '\\') index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === '/' && text[index + 1] === '/') {
const end = text.indexOf('\n', index);
index = end === -1 ? text.length : end;
continue;
}
if (char === '/' && text[index + 1] === '*') {
const end = text.indexOf('*/', index + 2);
index = end === -1 ? text.length : end + 1;
continue;
}
if (char === "'" || char === '"' || char === '`') {
quote = char;
continue;
}
if (char === '{') {
const key =
/(?:readonly\s+)?([\p{ID_Start}_$][\p{ID_Continue}$]*|'[^']*'|"[^"]*")\s*\??\s*:\s*$/u.exec(
text.slice(Math.max(0, index - 200), index),
);
const block: Block = {
start: index,
end: text.length,
label: key?.[1]?.replace(/^['"]|['"]$/g, '') ?? '',
parent: open.at(-1),
};
open.push(block);
blocks.push(block);
} else if (char === '}') {
const block = open.pop();
if (block !== undefined) block.end = index;
}
}
return blocks;
}
function innermostBlock(blocks: readonly Block[], position: number): Block | undefined {
let found: Block | undefined;
for (const block of blocks)
if (
block.start < position &&
position < block.end &&
(found === undefined || block.start > found.start)
)
found = block;
return found;
}
/** Whether the block is a storage column (`namespaces.<ns>.entries.table.<table>.columns.<column>`). */
function isColumnBlock(block: Block): boolean {
const table = block.parent?.parent;
return (
block.parent?.label === 'columns' &&
table?.parent?.label === 'table' &&
table.parent.parent?.label === 'entries'
);
}
function childBlocks(blocks: readonly Block[], parent: Block): Block[] {
return blocks.filter((block) => block.parent === parent);
}
/** The `readonly types` block of the storage type: the one whose parent also holds `namespaces`. */
function storageTypesBlock(blocks: readonly Block[]): Block | undefined {
return blocks.find(
(block) =>
block.label === 'types' &&
block.parent !== undefined &&
childBlocks(blocks, block.parent).some((sibling) => sibling.label === 'namespaces'),
);
}
function lineStart(text: string, position: number): number {
return text.lastIndexOf('\n', position - 1) + 1;
}
/** The end of the member a block closes: past its `;` and the line break after it. */
function memberEnd(text: string, block: Block): number {
const rest = /^\s*;?[ \t]*(?:\r?\n)?/.exec(text.slice(block.end + 1));
return block.end + 1 + (rest?.[0].length ?? 0);
}
function indentOf(text: string, position: number): string {
return /^[ \t]*/.exec(text.slice(lineStart(text, position)))?.[0] ?? '';
}
/** Splits `{ a; b }` into its top-level members. */
function topLevelMembers(literal: string): string[] {
const inner = literal.trim().slice(1, -1);
const members: string[] = [];
let depth = 0;
let quote: string | undefined;
let current = '';
for (let index = 0; index < inner.length; index++) {
const char = inner[index] ?? '';
current += char;
if (quote !== undefined) {
if (char === '\\') current += inner[++index] ?? '';
else if (char === quote) quote = undefined;
continue;
}
if (char === "'" || char === '"' || char === '`') quote = char;
else if (char === '{' || char === '[' || char === '(') depth++;
else if (char === '}' || char === ']' || char === ')') depth--;
else if (char === ';' && depth === 0) {
members.push(current.slice(0, -1).trim());
current = '';
}
}
if (current.trim() !== '') members.push(current.trim());
return members;
}
/** The `readonly typeParams` member, laid out as the emitter's formatter lays it out at `indent`. */
function typeParamsMember(literal: string, indent: string, lineEnding: string): string {
const lines = literal.split('\n');
const head = `${indent}readonly typeParams: `;
if (lines.length > 1) {
const literalIndent = /^[ \t]*/.exec(lines.at(-1) ?? '')?.[0] ?? '';
const shifted = lines.map((line, index) =>
index === 0 ? line : `${indent}${line.slice(literalIndent.length)}`,
);
return `${head}${shifted.join('\n')};`;
}
const single = `${head}${literal};`;
if (single.length <= DTS_LINE_WIDTH) return single;
const members = topLevelMembers(literal).map((member) => `${indent} ${member};`);
return `${head}{${lineEnding}${members.join(lineEnding)}${lineEnding}${indent}};`;
}
/** The text of `typeParams` in an entry block of `readonly types`, as the emitter wrote it. */
function entryParamsLiteral(
text: string,
blocks: readonly Block[],
entry: Block,
): string | undefined {
const params = childBlocks(blocks, entry).find((block) => block.label === 'typeParams');
return params === undefined ? undefined : text.slice(params.start, params.end + 1);
}
function jsonParamsLiteral(params: JsonRecord): string {
const members = Object.entries(params).map(
([key, value]) =>
`readonly ${/^[A-Za-z_$][\w$]*$/.test(key) ? key : `'${key}'`}: ${JSON.stringify(value)}`,
);
return `{ ${members.join('; ')} }`;
}
interface Edit {
readonly start: number;
readonly end: number;
readonly text: string;
}
function rewriteDts(
text: string,
expanded: ReadonlyMap<string, JsonRecord>,
removedEntries: ReadonlySet<string>,
hashes: ReadonlyMap<string, string>,
): string {
const lineEnding = lineEndingOf(text);
const blocks = scanBlocks(text);
const typesBlock = storageTypesBlock(blocks);
const entryBlocks = typesBlock === undefined ? [] : childBlocks(blocks, typesBlock);
const literals = new Map<string, string>();
for (const [alias, entry] of expanded) {
const params = nonEmptyParams(entry);
if (params === undefined) continue;
const block = entryBlocks.find((candidate) => candidate.label === alias);
literals.set(
alias,
(block === undefined ? undefined : entryParamsLiteral(text, blocks, block)) ??
jsonParamsLiteral(params),
);
}
const edits: Edit[] = [];
for (const match of text.matchAll(/readonly typeRef:\s*(?:'([^']*)'|"([^"]*)")\s*;?/g)) {
const alias = match[1] ?? match[2] ?? '';
const column = innermostBlock(blocks, match.index);
if (column === undefined || !isColumnBlock(column) || !expanded.has(alias)) continue;
const start = lineStart(text, match.index);
const literal = literals.get(alias);
if (literal === undefined) {
const end = match.index + match[0].length;
const lineBreak = /^\r?\n/.exec(text.slice(end))?.[0] ?? '';
edits.push({ start, end: end + lineBreak.length, text: '' });
} else {
edits.push({
start,
end: match.index + match[0].length,
text: typeParamsMember(literal, indentOf(text, match.index), lineEnding),
});
}
}
if (typesBlock !== undefined) {
const removed = entryBlocks.filter((block) => removedEntries.has(block.label));
if (removed.length > 0 && removed.length === entryBlocks.length) {
edits.push({
start: lineStart(text, typesBlock.start),
end: memberEnd(text, typesBlock),
text: '',
});
} else {
for (const block of removed)
edits.push({ start: lineStart(text, block.start), end: memberEnd(text, block), text: '' });
}
}
let result = text;
for (const edit of edits.sort((a, b) => b.start - a.start))
result = `${result.slice(0, edit.start)}${edit.text}${result.slice(edit.end)}`;
return replaceHashTokens(result, hashes);
}
function replaceHashTokens(text: string, hashes: ReadonlyMap<string, string>): string {
return text.replace(
/(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])/g,
(hash: string) => hashes.get(hash) ?? hash,
);
}
interface ContractPlan {
readonly path: string;
readonly snapshot: boolean;
readonly oldHash: string;
readonly newHash: string;
readonly content: string;
readonly expanded: ReadonlyMap<string, JsonRecord>;
readonly removed: ReadonlySet<string>;
readonly kept: readonly string[];
}
/** Whose contract space a contract is: the app's own; an extension's, which registers database types; or unknown, for a snapshot no migration or ref names. Only a contract of the app's space loses the entries no column references. */
type SpaceKind = 'app' | 'extension' | 'unknown';
/** A hash a migration or ref names, and the space whose directory holds it. */
interface HashReference {
readonly path: string;
readonly space: SpaceKind;
readonly hashes: readonly string[];
/** A migration's `from` and `to`; `undefined` for a ref. */
readonly edge: { readonly from: string; readonly to: string } | undefined;
}
/** The space of the directory that holds a migration package or a `refs` directory: `migrations/app` is the app's; any other space directory, or a package's own single-space `migrations`, an extension's. */
function spaceOfDirectory(spaceDir: string): SpaceKind {
return basename(spaceDir) === APP_SPACE_ID ? 'app' : 'extension';
}
function hashReferences(files: ProjectFiles): HashReference[] {
const references: HashReference[] = [];
for (const path of files.migrationJson) {
const metadata = parseJson(readFileSync(path, 'utf8'));
if (!isRecord(metadata)) continue;
const from = metadata['from'];
const to = metadata['to'];
const hashes = [from, to].filter((hash): hash is string => typeof hash === 'string');
references.push({
path: dirname(path),
space: spaceOfDirectory(dirname(dirname(path))),
hashes,
edge: typeof from === 'string' && typeof to === 'string' ? { from, to } : undefined,
});
}
for (const path of files.refs) {
const ref = parseJson(readFileSync(path, 'utf8'));
const hash = isRecord(ref) ? ref['hash'] : undefined;
if (typeof hash !== 'string') continue;
references.push({
path,
space: spaceOfDirectory(dirname(dirname(path))),
hashes: [hash],
edge: undefined,
});
}
return references;
}
/** The space of a migrations directory, by its layout: an `app` space directory, or no `refs` directly in it, is the app's. */
function spaceOfMigrationsDirectory(dir: string): SpaceKind {
if (isDirectory(join(dir, APP_SPACE_ID))) return 'app';
return isDirectory(join(dir, 'refs')) ? 'extension' : 'app';
}
/** The space of a contract that no ref or migration names: unknown for a snapshot, as every space shares the snapshot store; for an emitted contract, that of the nearest `migrations` directory above it, or the app's when there is none. */
function spaceByLayout(path: string, snapshot: boolean, root: string): SpaceKind {
if (snapshot) return 'unknown';
for (let dir = dirname(path); ; dir = dirname(dir)) {
const migrations = join(dir, 'migrations');
if (isDirectory(migrations)) return spaceOfMigrationsDirectory(migrations);
if (dir === root || dirname(dir) === dir) return 'app';
}
}
function summary(
root: string,
contractsFound: number,
files: number,
directories: number,
hashes: ReadonlyMap<string, string>,
removed: readonly (readonly [string, readonly string[]])[],
kept: readonly (readonly [string, readonly string[]])[],
): readonly string[] {
if (contractsFound === 0) return [`No SQL contract was found under ${root}; nothing changed.`];
if (files === 0 && directories === 0)
return ['No contract stores a type alias; nothing changed.'];
const renamed = count(directories, 'snapshot directory', 'snapshot directories');
const changes = `Rewrote ${count(files, 'file', 'files')} and renamed ${renamed}.`;
const listed = (entries: readonly (readonly [string, readonly string[]])[]) =>
entries.map(([path, names]) => ` ${path}: ${names.join(', ')}`);
return [
...(hashes.size === 0
? [changes]
: [
`${changes} Storage hashes changed (old -> new):`,
...[...hashes]
.sort(([a], [b]) => compareCodeUnits(a, b))
.map(([oldHash, newHash]) => ` ${oldHash} -> ${newHash}`),
]),
...(removed.length === 0 ? [] : ['Removed storage.types entries:', ...listed(removed)]),
...(kept.length === 0
? []
: [
"Kept storage.types entries that no column references, in contracts of an extension's contract space and in snapshots that no migration or ref names. Each is a database type a contract space registers, or a type alias nothing uses:",
...listed(kept),
]),
];
}
/** Each path with the names listed for it, merged and sorted, in path order. */
function byPath(
entries: readonly (readonly [string, readonly string[]])[],
): (readonly [string, readonly string[]])[] {
const merged = new Map<string, Set<string>>();
for (const [path, names] of entries)
merged.set(path, new Set([...(merged.get(path) ?? []), ...names]));
return [...merged]
.map(([path, names]): readonly [string, readonly string[]] => [
path,
[...names].sort(compareCodeUnits),
])
.sort(([a], [b]) => compareCodeUnits(a, b));
}
interface MigrationOps {
readonly path: string;
/** `undefined` when the package has no `ops.json` or it is not valid JSON. */
readonly ops: Json | undefined;
readonly unreadable: boolean;
}
function readOps(migrationDir: string): MigrationOps {
const path = join(migrationDir, 'ops.json');
if (!existsSync(path)) return { path, ops: undefined, unreadable: false };
const ops = parseJson(readFileSync(path, 'utf8'));
return { path, ops, unreadable: ops === undefined };
}
/** Whether the operations hold one of class `data`, the one kind that lets a migration go from a snapshot to itself. The migration checker's rule (`checkIntegrity` in `@internal/migration-tools`). */
function hasDataOperation(ops: Json | undefined): boolean {
return Array.isArray(ops) && ops.some((op) => isRecord(op) && op['operationClass'] === 'data');
}
/**
* The snapshot directories that the rewrite turns two or more snapshots into, and a stop line for each migration between such snapshots that has no data operation, naming too each of its snapshots that nothing else names, or, when its `ops.json` is not valid JSON, for that file. Expanding the aliases can make two contracts equal: one that wrote a type inline and one that wrote it through an alias, or two that named one alias differently. A migration between them then goes from a snapshot to itself, which the migration checker allows only for a migration with a data operation. A snapshot nothing names, such as a copy an interrupted run left, is not counted.
*/
function mergedSnapshots(
files: ProjectFiles,
plans: readonly ContractPlan[],
references: readonly HashReference[],
display: (path: string) => string,
): { readonly lines: readonly string[]; readonly directories: ReadonlySet<string> } {
const planned = new Map(plans.map((plan) => [plan.path, plan.newHash]));
const referenced = new Set(references.flatMap((reference) => reference.hashes));
const stores = new Map<string, Map<string, Set<string>>>();
for (const path of files.json) {
if (!isSnapshotContract(path)) continue;
const hash = basename(dirname(path));
if (!referenced.has(hash)) continue;
const store = dirname(dirname(path));
const merged = stores.get(store) ?? new Map<string, Set<string>>();
stores.set(store, merged);
const newHash = planned.get(path) ?? hash;
merged.set(newHash, new Set([...(merged.get(newHash) ?? []), hash]));
}
const lines: string[] = [];
const directories = new Set<string>();
for (const [store, merged] of stores) {
for (const [newHash, oldHashes] of merged) {
if (oldHashes.size < 2) continue;
directories.add(join(store, newHash));
const names = [...oldHashes].sort(compareCodeUnits).join(' and ');
for (const { path, edge } of references) {
if (
edge === undefined ||
edge.from === edge.to ||
!oldHashes.has(edge.from) ||
!oldHashes.has(edge.to) ||
!path.startsWith(`${dirname(store)}${sep}`)
)
continue;
const ops = readOps(path);
if (ops.unreadable) {
lines.push(`${display(ops.path)}: not valid JSON`);
continue;
}
if (hasDataOperation(ops.ops)) continue;
const orphaned = [edge.from, edge.to].filter(
(hash) => !references.some((other) => other.path !== path && other.hashes.includes(hash)),
);
const snapshots = orphaned.map((hash) => display(join(store, hash)));
const toDelete =
snapshots.length === 0
? `the migration directory ${display(path)}`
: `the migration directory ${display(path)} and the snapshot ${snapshots.length === 1 ? 'directory' : 'directories'} ${snapshots.join(' and ')}, which no other migration or ref names,`;
lines.push(
`${display(store)}: the snapshots ${names} become the same snapshot, ${newHash}, once their type aliases are written into their columns. Migration ${display(path)} goes from ${edge.from} to ${edge.to} and has no data operation, so it would go from ${newHash} to itself, which only a migration with a data operation may do. Delete ${toDelete} and run the script again.`,
);
}
}
}
return { lines, directories };
}
const JSON_FILES_THAT_MUST_PARSE = new Set(['contract.json', 'migration.json']);
function parseRoot(args: readonly string[]): { readonly root: string; readonly errors: string[] } {
const errors = args.filter((arg) => arg.startsWith('-')).map((arg) => `${arg}: unknown option`);
const positional = args.filter((arg) => !arg.startsWith('-'));
if (positional.length > 1) errors.push(`expected one project root, got ${positional.length}`);
const root = resolve(positional[0] ?? process.cwd());
if (errors.length === 0 && !isDirectory(root)) errors.push(`${root}: not a directory`);
if (errors.length > 0) errors.push('Usage: node expand-type-aliases.ts [project-root]');
return { root, errors };
}
function main({
root,
errors,
}: {
readonly root: string;
readonly errors: readonly string[];
}): number {
if (errors.length > 0) {
process.stderr.write(`${errors.join('\n')}\n`);
return 1;
}
const display = (path: string): string => relative(root, path).split(sep).join('/');
const files = listFiles(root);
const references = hashReferences(files);
const spacesByHash = new Map<string, Set<SpaceKind>>();
for (const reference of references)
for (const hash of reference.hashes)
spacesByHash.set(hash, new Set([...(spacesByHash.get(hash) ?? []), reference.space]));
const spaceOf = (path: string, snapshot: boolean, hash: string): SpaceKind => {
const spaces = spacesByHash.get(hash);
if (spaces?.has('extension')) return 'extension';
if (spaces?.has('app')) return 'app';
return spaceByLayout(path, snapshot, root);
};
const plans: ContractPlan[] = [];
const stops: string[] = [];
let contractsFound = 0;
for (const path of files.json) {
const text = readFileSync(path, 'utf8');
const contract = parseJson(text);
if (
contract === undefined &&
(JSON_FILES_THAT_MUST_PARSE.has(basename(path)) || files.refs.includes(path))
) {
stops.push(`${display(path)}: not valid JSON`);
continue;
}
if (
!isRecord(contract) ||
contract['targetFamily'] !== 'sql' ||
typeof contract['target'] !== 'string' ||
!isRecord(contract['storage'])
)
continue;
contractsFound++;
const snapshot = isSnapshotContract(path);
const stored = contract['storage']['storageHash'];
const oldHash = snapshot ? basename(dirname(path)) : typeof stored === 'string' ? stored : '';
const rewrite = rewriteContract(contract, spaceOf(path, snapshot, oldHash));
if (!rewrite.changed) continue;
const computed = computeStorageHash(contract);
if (computed !== oldHash || (typeof stored === 'string' && stored !== oldHash)) {
stops.push(
`${display(path)}: the stored storage hash ${oldHash} does not match the contract's content, which hashes to ${computed}; restore the file from version control, or re-emit it, and run the script again`,
);
continue;
}
if (rewrite.problems.length > 0) {
for (const problem of rewrite.problems)
stops.push(
`${display(path)}: ${problem}; restore the file from version control, or re-emit it, and run the script again`,
);
continue;
}
const newHash = computeStorageHash(rewrite.contract);
plans.push({
path,
snapshot,
oldHash,
newHash,
content: writtenForm(withStorageHash(rewrite.contract, newHash), contract, text),
expanded: rewrite.expanded,
removed: rewrite.removed,
kept: rewrite.kept,
});
}
const hashes = new Map<string, string>();
for (const plan of [...plans].sort((a, b) => Number(b.snapshot) - Number(a.snapshot)))
if (plan.oldHash !== plan.newHash && !hashes.has(plan.oldHash))
hashes.set(plan.oldHash, plan.newHash);
const merged = mergedSnapshots(files, plans, references, display);
stops.push(...merged.lines);
const newDirectories = new Map<string, Map<string, string>>();
const directorySources = new Map<string, string>();
const sourceDirectories = new Set<string>();
const removals = new Set<string>();
const snapshotRewrites: [string, string][] = [];
const referenceWrites = new Map<string, string>();
const contractWrites = new Map<string, string>();
const kept: [string, readonly string[]][] = [];
const removedEntries: [string, readonly string[]][] = [];
for (const plan of plans) {
const dtsPath = plan.snapshot
? join(dirname(plan.path), 'contract.d.ts')
: `${plan.path.slice(0, -'.json'.length)}.d.ts`;
const dtsHashes = new Map([...hashes, [plan.oldHash, plan.newHash]]);
const dts = existsSync(dtsPath)
? rewriteDts(readFileSync(dtsPath, 'utf8'), plan.expanded, plan.removed, dtsHashes)
: undefined;
const writtenPath = plan.snapshot
? join(dirname(dirname(plan.path)), plan.newHash, 'contract.json')
: plan.path;
if (plan.kept.length > 0) kept.push([display(writtenPath), plan.kept]);
if (plan.removed.size > 0) removedEntries.push([display(writtenPath), [...plan.removed]]);
if (!plan.snapshot) {
if (dts !== undefined) referenceWrites.set(dtsPath, dts);
contractWrites.set(plan.path, plan.content);
continue;
}
const oldDir = dirname(plan.path);
const newDir = join(dirname(oldDir), plan.newHash);
sourceDirectories.add(oldDir);
if (newDir === oldDir) {
if (dts !== undefined) snapshotRewrites.push([dtsPath, dts]);
snapshotRewrites.push([plan.path, plan.content]);
continue;
}
removals.add(oldDir);
const content = readDirectory(oldDir);
content.set('contract.json', plan.content);
if (dts !== undefined) content.set('contract.d.ts', dts);
const planned = newDirectories.get(newDir);
if (planned !== undefined && !sameFiles(planned, content)) {
if (!merged.directories.has(newDir))
stops.push(`${display(newDir)}: snapshot directory already exists with different content`);
continue;
}
newDirectories.set(newDir, content);
directorySources.set(newDir, oldDir);
}
for (const [dir, content] of newDirectories) {
if (sourceDirectories.has(dir) || !existsSync(dir)) continue;
if (merged.directories.has(dir) || sameFiles(readDirectory(dir), content))
newDirectories.delete(dir);
else stops.push(`${display(dir)}: snapshot directory already exists with different content`);
}
for (const path of files.migrationJson) {
const text = readFileSync(path, 'utf8');
const metadata = parseJson(text);
if (!isRecord(metadata)) continue;
const from = metadata['from'];
const to = metadata['to'];
const nextFrom = typeof from === 'string' ? (hashes.get(from) ?? from) : from;
const nextTo = typeof to === 'string' ? (hashes.get(to) ?? to) : to;
if (nextFrom === from && nextTo === to) continue;
const { path: opsPath, ops, unreadable } = readOps(dirname(path));
if (unreadable) {
stops.push(`${display(opsPath)}: not valid JSON`);
continue;
}
const oldMigrationHash = metadata['migrationHash'];
let next = replaceQuotedHashes(text, hashes);
if (
ops !== undefined &&
typeof oldMigrationHash === 'string' &&
nextFrom !== undefined &&
nextTo !== undefined
) {
const migrationHash = computeMigrationHash({ ...metadata, from: nextFrom, to: nextTo }, ops);
next = next.replace(`"${oldMigrationHash}"`, `"${migrationHash}"`);
}
referenceWrites.set(path, next);
}
for (const path of files.refs) {
const text = readFileSync(path, 'utf8');
const next = replaceQuotedHashes(text, hashes);
if (next !== text) referenceWrites.set(path, next);
}
for (const path of files.migrationTs) {
const text = readFileSync(path, 'utf8');
const next = replaceHashTokens(text, hashes);
if (next !== text) referenceWrites.set(path, next);
}
if (stops.length > 0) {
process.stderr.write(`${[...new Set(stops)].join('\n')}\n`);
return 1;
}
try {
for (const path of files.leftovers) rmSync(path, { recursive: true, force: true });
for (const [dir, content] of newDirectories)
writeDirectory(dir, content, directorySources.get(dir) ?? dir);
for (const [path, text] of snapshotRewrites) writeFile(path, text);
for (const [path, text] of referenceWrites) writeFile(path, text);
for (const [path, text] of contractWrites) writeFile(path, text);
for (const dir of removals) removeDirectory(dir);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(
`${message}; the upgrade stopped partway, run the script again to finish it\n`,
);
return 1;
}
const rewrittenFiles = snapshotRewrites.length + referenceWrites.size + contractWrites.size;
const lines = summary(
root,
contractsFound,
rewrittenFiles,
removals.size,
hashes,
byPath(removedEntries),
byPath(kept),
);
process.stdout.write(`${lines.join('\n')}\n`);
return 0;
}
process.exitCode = main(parseRoot(process.argv.slice(2)));
upgrading/extension/upgrades/8.0.0-rc.2-to-8.0.0-rc.3/instructions.md
---
from: "8.0.0-rc.2"
to: "8.0.0-rc.3"
changes: []
---
upgrading/extension/upgrades/8.0.0-rc.20-to-8.0.0-rc.21/instructions.md
---
from: "8.0.0-rc.20"
to: "8.0.0-rc.21"
changes:
- id: client-members-reject-instead-of-throwing
summary: |
A client member that returns a promise or a result no longer throws at the call when the client or the transaction is in a failed state. On `postgres()`, `sqlite()`, a `supabase()` role-bound client and a transaction, `query`, `execute`, `prepare`, `transaction`, ORM calls and a prepared statement's `query(target, params)` and `execute(target, params)` now report a closed client, a failed connection or an ended transaction through what they return: the promise rejects, and a result rejects when it is awaited or read. Error codes and messages do not change. Code that wraps such a call in `try`/`catch` without awaiting it no longer catches the error; add `await`, or handle it with `.catch(...)`. `db.runtime()` still throws at the call, and so does a mistake in the call itself, such as an ORM query the database's capabilities do not allow. The detection matches a file that imports an `@prisma/orm-*` package and has a `try` block, one line or many, nested or not, that calls one of these members or an ORM terminal (`all`, `first`, `create`, `update`, `delete`, `upsert`, …) without `await` directly before it, on a receiver shaped like a client: a name ending in `db`, `tx`, a name ending in `client`, `admin`, a prepared statement, or a chain through `.orm`; chains may span lines and take callbacks; leave a call whose result is awaited or returned to an awaiting caller as it is.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs}"
matches:
- '(?<![\s\S])(?=[\s\S]*[\x27\x22]@prisma/orm-)[\s\S]*\btry\s*\{(?:(?!\}\s*(?:catch|finally)\b)[\s\S])*?(?<![\w$.])(?<!\bawait\s+)(?:this\s*\.\s*)?(?:[\w$]*(?:db|Db|DB)|tx|trx|[\w$]*[Cc]lient|admin|[\w$]*[Pp]repared[\w$]*|[\w$]+\s*\.\s*orm)(?:\s*\.\s*[\w$]+(?:\s*\((?:[^()]|\([^()]*\))*\))?)*?\s*\.\s*(?:query|execute|prepare|transaction|all|first|firstOrThrow|create|createAll|createAndCount|update|updateAll|updateAndCount|delete|deleteAll|deleteAndCount|upsert|aggregate)\s*\('
- id: lazy-activity-source-rejects
summary: |
`lazyActivitySource(source)` and `delegatingActivitySource(target)` now reject from `startActivity` and `planContentHash` when `source()` or `target()` throws, instead of throwing at the call. A transaction context from `withTransaction` and its activity handles report `RUNTIME.TRANSACTION_CLOSED` from `query` and from the prepared-statement bridges through the result they return. No code change is needed unless your code caught these errors with a synchronous `try`/`catch`.
- id: control-adapter-marker-location
summary: |
`SqlControlAdapter` (from `@prisma/orm-family-sql/family/control-adapter`) and `MongoControlAdapter` (from `@prisma/orm-family-mongo/family/control-adapter`) have a new required field, `markerLocation`: the name of the table or collection that holds the contract markers, which messages show the user. `ControlFamilyInstance` (from `@prisma/orm-framework/components/control`) has it too; the SQL and Mongo families read it from the control adapter.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '(?<![\s\S])(?![\s\S]*markerLocation)(?=[\s\S]*\b(?:SqlControlAdapter|MongoControlAdapter)\b)[\s\S]*\breadMarker\s*[(:]'
- '(?<![\s\S])(?![\s\S]*markerLocation)(?=[\s\S]*\bControlFamilyInstance\b)[\s\S]*\bstorageNameOf\s*[(:]'
- id: control-adapter-hold-marker-lock
summary: |
`SqlControlAdapter` has a new optional method, `holdMarkerLock(driver, work)`. A target whose DDL ends an open transaction provides it: `db sign` then creates the marker table under that lock, outside the transaction that writes the markers. Without it, `db sign` takes the lock with `lockMarker` and creates the marker table inside that transaction, as before.
- id: verify-contract-spaces-marker-location
summary: |
`verifyContractSpaces` (from the `spaces` subpath of the migration tools) takes a required `markerLocation`, the name of the target's marker table or collection, which the remediation of an orphan marker names.
detection:
glob: "**/*.{ts,tsx,mts,cts,js,mjs,cjs}"
matches:
- '(?<![\w$])verifyContractSpaces\s*\('
- id: plan-target-details-schema-optional
summary: |
`SqlPlanTargetDetails.schema` (from `@prisma/orm-family-sql/family/control`) is optional, and SQLite operations no longer carry `schema: 'main'`. Code that reads `target.details.schema` handles `undefined`.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '\.details\??\.schema(?![\w$])'
- id: enum-value-rename-statement
summary: |
`ResolvedRenameStatement` from `@prisma/orm-framework/components/control` gains `ResolvedEnumValueRenameStatement`, `{ kind: 'rename', entity: 'enumValue', from, to }`, whose sides are `EnumValueCoordinate`s (`namespaceId`, `enum`, `member`). `MigrationSubject` gains `{ kind: 'enumValue' }` with the same coordinate. Code that switches over a rename's `entity` or a subject's `kind` handles `'enumValue'`.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\w$])(?:ResolvedRenameStatement|ResolvedMigrationStatement|MigrationSubject)(?![\w$])'
- id: rows-holding-value-and-keep-data-edit
summary: |
Three optional fields let a target say how to keep data that a removed enum value would lose. `MigrationRunnerFailureMeta.rowsHoldingValue` (`RowsHoldingValue`: `enumValue`, `value`, `rows`, `operationIndex`, `keepDataEdit?`) reports the rows that still hold a stored value a removal check refused to remove. `MigrationDataLoss.keepDataEdit` (`KeepDataEdit`: `remove`, `replaceWith`, `swapsValues?`) names the edit to `migration.ts` that keeps the data of a planned loss. `MigrationPlannerConflict.leaveOut` lists the statements to leave out of a plan whose `migration.ts` the user finishes by hand. All three are optional: a target that sets none of them keeps working, and the CLI then gives only its general advice.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\w$])(?:MigrationRunnerFailureMeta|MigrationDataLoss|MigrationRunnerFailure|MigrationPlannerConflict)(?![\w$])'
- id: contract-source-warnings
summary: |
A contract source's warnings now reach the caller when the source then fails. `loadContractSource` (`@prisma/orm-toolchain/cli/control-api`) no longer takes `onWarning`; it returns `{ contract, warnings }` on success and adds `warnings` to its failure. The control client's `emit` failure (`EmitFailure`) gains `sourceWarnings`, as its success already had. Read `result.value.contract` where you read `result.value`, and report `warnings` instead of passing `onWarning`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bloadContractSource\('
- id: mongo-contract-target-option
summary: |
`mongoContract(schemaPath, options)` now requires `options.target`, the MongoDB target pack, which the contract records. Pass `{ target: mongoTargetPack }` with `mongoTargetPack` imported from `@prisma/orm-target-mongo/target/pack` (or `@prisma/orm-mongo/target/pack`), alongside any options you already pass. A config written with `defineConfig` from `@prisma/orm-mongo/config` needs no change.
detection:
glob: "**/*.{ts,mts,cts,js,mjs}"
matches:
- '(?<![\w$.])mongoContract\(\s*[^,()]*(?:\([^()]*\)[^,()]*)*,?\s*\)'
- '(?<![\w$.])mongoContract\(\s*[^,()]*(?:\([^()]*\)[^,()]*)*,\s*\{(?![^{}]*\btarget\b)[^{}]*\}\s*\)'
- '(?<![\w$.])mongoContract\(\s*[^,()]*(?:\([^()]*\)[^,()]*)*,\s*[A-Za-z_$][\w$.]*\s*\)'
- id: mongo-contract-input
summary: |
The MongoDB type for the object you pass to `defineContract`, `ContractDefinition`, is renamed `ContractInput`. It is the same type under SQL's name for it. `ContractDefinition` now names the plain-data definition every MongoDB contract source builds from, which takes no type parameters, so the detection flags an import from `@prisma/orm-family-mongo/contract-ts` only where the file writes `ContractDefinition<...>`. Import `ContractInput` from `@prisma/orm-mongo/contract-builder` or `@prisma/orm-family-mongo/contract-ts/contract-builder` where you imported `ContractDefinition`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?:import|export)\s+(?:type\s+)?\{[^}]*\bContractDefinition\b[^}]*\}\s*from\s*[\x27"]@prisma/orm-mongo/contract-builder[\x27"]'
- '(?:import|export)\s+(?:type\s+)?\{[^}]*\bContractDefinition\b[^}]*\}\s*from\s*[\x27"]@prisma/orm-family-mongo/contract-ts(?:/contract-builder)?[\x27"][\s\S]*\bContractDefinition<'
- id: mongo-model-definition-removed
summary: |
`MongoModelDefinition` is removed. It was another name for `ContractModel<MongoModelStorage>`. Write `ContractModel<MongoModelStorage>` instead, with `ContractModel` from `@prisma/orm-framework/contract/types` and `MongoModelStorage` from `@prisma/orm-family-mongo/contract`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$])MongoModelDefinition(?![\w$])'
- id: mongo-ts-polymorphism-rules
summary: |
`defineContract` now applies the MongoDB polymorphism rules that PSL applied. It refuses a discriminator that is optional, a list or not text, such as an `Int32` field (`CONTRACT.ARGUMENT_INVALID`); a variant value the discriminator field cannot hold or that is not a member of its enum; a variant listed in `discriminator.variants` that no model declares with that base; a variant stored in another collection than its base (`CONTRACT.COLLECTION_INVALID`); and, in TypeScript and PSL, a variant field stored under a name a field of its base is stored under, such as the discriminator (`CONTRACT.NAME_DUPLICATE`, `PSL_DUPLICATE_STORED_NAME`), which would overwrite that field; and, in TypeScript and PSL, a variant field that restates a base field with a different stored name, type, nullability or list shape (`CONTRACT.ARGUMENT_INVALID`, `PSL_VARIANT_FIELD_DIFFERS_FROM_BASE`), whose documents the collection's validator, written from the base, would refuse. Make the discriminator a required `field.string()` or a string enum, list only declared variants, leave a variant's `collection` out or set it to its base's, give each variant field a stored name its base does not use, and restate a base field on a variant exactly as the base declares it, or not at all.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '@prisma/orm-(?:mongo|family-mongo/contract-ts)/contract-builder[\x27"][\s\S]*\bdiscriminator\s*:\s*\{'
- id: mongo-index-shape-rules
summary: |
Every MongoDB contract source now refuses an index MongoDB would refuse (`CONTRACT.INDEX_INVALID` in TypeScript, `PSL_INVALID_INDEX` in PSL): a unique wildcard index, a wildcard with a hashed, 2d or 2dsphere key, a hashed index on several fields, a TTL wildcard index, a wildcard projection without a wildcard key, a collation without a locale, and a second text index in one collection. PSL also refuses an index that another model on the same collection, or the same model, already declares, such as `@unique` and `@@unique` on one field. Remove the index or change it to a shape MongoDB accepts; remove a duplicate.
detection:
glob: "**/*.{ts,mts,cts,tsx,prisma}"
matches:
- '@prisma/orm-(?:mongo|family-mongo/contract-ts)/contract-builder[\x27"][\s\S]*(?:\$\*\*|\bwildcardProjection\b|[\x27"]hashed[\x27"]|\bcollation\s*:)'
- '\bwildcard\('
- id: mongo-value-object-enum-check
summary: |
`defineContract` now refuses a value object field typed by an enum the contract does not declare (`CONTRACT.ENUM_UNKNOWN`), as it already did for a model field. Add the enum to `defineContract({ enums })`.
- id: mongo-newly-accepted
summary: |
No action is needed. An optional field that a type alias, `@onCreate(...)`/`@onUpdate(...)` or `.onCreate(...)`/`.onUpdate(...)` fills is now accepted, as on SQL; a list that generates a value is still refused. A TypeScript variant may leave its `collection` out and is stored in its base's.
- id: mongo-prisma6-merged-indexes
summary: |
A Prisma 6 schema read by `prisma6Schema()` in which one collection holds two identical indexes, such as `@unique` and `@@unique` on one field, or the same `@@index` on two models stored in one collection, now gives one index, as the database holds one. That contract's storage hash changes. Run `prisma contract emit`, then `prisma db sign` to record the new hash; the database itself needs no change.
- id: psl-temporal-type-renames
summary: |
On PostgreSQL, the PSL types `Timestamptz`, `Timestamp` and `Interval` are renamed to `TimestamptzTemporal`, `TimestampTemporal` and `IntervalParts`, and the old names are errors. Rename each use in your `.prisma` files, keeping its arguments: `Timestamptz(3)` becomes `TimestamptzTemporal(3)`. Each new name reads the same value with the same codec, so the contract and its hashes do not change. `@db.Timestamp` and `@db.Timestamptz` in a Prisma 7 schema are not affected.
detection:
glob: "**/*.prisma"
matches:
- '(?<![\w$.@"\x27`])(?:Timestamptz|Timestamp|Interval)(?![\w$])'
- id: ts-temporal-type-helpers-renamed
summary: |
In a TypeScript contract, `type.Timestamptz(...)`, `type.Timestamp(...)` and `type.Interval(...)` are renamed to `type.TimestamptzTemporal(...)`, `type.TimestampTemporal(...)` and `type.IntervalParts(...)`, and the column helper `intervalColumn(...)` from `@prisma/orm-postgres/adapter/column-types` is renamed to `intervalPartsColumn(...)`. Rename each call; the arguments do not change.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$])type\.(?:Timestamptz|Timestamp|Interval)\('
- '(?<![\w$])intervalColumn(?![\w$])'
- id: interval-with-precision-typed-as-parts
summary: |
An `IntervalParts(p)` column, an interval with a precision, is now typed as the `{ months, days, micros }` object its codec reads, as an interval without a precision already was, instead of a string. Type such a value as `PgInterval` from `@prisma/orm-postgres/target/codec-types`. Code that treated the value as a string was wrong at run time and now fails to type-check.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'import\s+(?:type\s+)?\{[^}]*(?<![\w$])Interval(?![\w$])[^}]*\}\s*from\s*[\x27"]@prisma/orm-postgres/(?:contract-builder|target/codec-types)[\x27"]'
- id: branded-time-types-removed
summary: |
The string types `Timestamp`, `Timestamptz`, `Time` and `Interval` are no longer exported from `@prisma/orm-postgres/target/codec-types` (and `Interval` no longer from `@prisma/orm-postgres/contract-builder`). No column was typed by the first three. Remove each of these names from the import that names it, and delete the import if it names nothing else. A `contract.d.ts` written by an earlier release, such as one in a migration snapshot, imports them without using them, so removing them from its import is the only change it needs.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'import\s+(?:type\s+)?\{[^}]*(?<![\w$])(?:Timestamptz|Timestamp|Time|Interval)(?![\w$])[^}]*\}\s*from\s*[\x27"]@prisma/orm-postgres/(?:contract-builder|target/codec-types)[\x27"]'
- id: temporal-types-from-temporal-spec
summary: |
Temporal fields are now typed with the `Temporal` types of the `temporal-spec` package rather than a global `Temporal` declaration, so they are typed even in a project without the polyfill's global types. The values are the same and assign to and from the global types. An extension's type test that requires a field to be exactly `Temporal.Instant` (or another `Temporal` type) from the global declarations now fails: add `temporal-spec` (`1.0.1`) to your development dependencies and import the type with `import type { Temporal } from 'temporal-spec'` in that test.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\s\S])(?![\s\S]*import\s+(?:type\s+)?\{[^}]*(?<![\w$])Temporal(?![\w$])(?!\s+as(?![\w$]))[^}]*\}\s*from\s*[\x27"]temporal-spec[\x27"])[\s\S]*toEqualTypeOf<\s*Temporal\.'
- id: contract-types-list-new-aggregates
summary: |
Optional. An emitted `contract.d.ts` lists the aggregates of every codec in the stack, so `prisma contract emit` now adds rows for the three new codecs to it, and the contract itself does not change. An extension that commits an emitted `contract.d.ts` re-emits it when it next changes the contract; until then the existing `contract.d.ts` keeps working. A migration snapshot's `contract.d.ts` does not need the new rows; it needs only the import change `branded-time-types-removed` describes.
- id: driver-ensures-target-session-settings
summary: |
A target descriptor may declare `sessionSettings`, the settings its codecs need on every database session; the PostgreSQL target declares `DateStyle=ISO`. A stack passes them to each driver it creates: a runtime driver descriptor's `create(options, sessionSettings)` receives them as a second argument, and a control driver's connect options carry them as `sessionSettings`. A driver for a target that declares them must ensure them on every connection it hands out. A wrapper around a driver descriptor's `create` passes `sessionSettings` on.
detection:
glob: "**/*.{ts,tsx,mts,cts}"
matches:
- '(?<![\w$])(?:RuntimeDriverDescriptor|ControlDriverDescriptor)(?![\w$])'
- id: authoring-field-namespace-removed
summary: |
A component's `authoring.field` namespace and the `fieldPreset` descriptor kind are removed. A component whose `authoring` still has a `field` key is refused with `CONTRACT.PACK_CONTRIBUTION_INVALID` when the stack is assembled. Contribute a type alias (`kind: 'typeAlias'`) or a type constructor in `authoring.type` instead. The detection matches files that declare a field preset or read or set `authoring.field`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'kind:\s*[\x27"]fieldPreset[\x27"]'
- '(?<![\w$])authoring\??\.field(?![\w$])'
- '(?<![\w$.])field\s*:\s*\w*[Ff]ieldPresets(?![\w$])'
- id: argument-kinds-removed
summary: |
An authoring argument has one kind, `value`. The `number`, `object` and `option` argument kinds, the `select` template node, and the `path` and `default` of an `arg` template reference are removed, with the types `AuthoringOption`, `AuthoringValueArgument` and `AuthoringSelectRef`. Write each argument as `{ kind: 'value', dataType }` and use `AuthoringArgumentDescriptor` where you used `AuthoringValueArgument`. The detection matches authoring code that uses one of the removed kinds or types.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\s\S])(?=[\s\S]*\b[Aa]uthoring)[\s\S]*kind:\s*[\x27"](?:option|number|object|select)[\x27"]'
- '(?<![\w$])Authoring(?:Option|ValueArgument|SelectRef)(?![\w$])'
- '(?<![\w$])isAuthoringSelectRef(?![\w$])'
- id: preset-builders-removed
summary: |
The helpers that built and read field presets are removed: `instantiateAuthoringFieldPreset`, `isAuthoringFieldPresetDescriptor`, `getAuthoringFieldPreset`, `fieldPresetSpellings`, `instantiatePslFieldPreset`, `reportPresetNotCalled`, `temporalAuthoringPresets`, `temporalCodecPreset`, `temporalCodecPresetWithPrecision`, `temporalStringAuthoringPresets`, `temporalPhaseTemplate`, `temporalPhaseTemplateWithParams`, `TEMPORAL_ON_CREATE_ARG`, `TEMPORAL_ON_UPDATE_ARG`, `createFieldHelpersFromNamespace`, the types `PresetStorageTemplate`, `AuthoringFieldPresetDescriptor`, `AuthoringFieldPresetOutput`, `AuthoringFieldNamespace`, `AuthoringColumnDefaultTemplate`, `ObjectArgumentType`, `OptionalObjectArgumentKeys` and `GeneratedColumnSpec`, and the spec helpers `uuidv4`, `uuidv7`, `ulid`, `nanoid`, `cuid2` and `ksuid` of `@prisma/orm-framework/ids`. `assertNoCrossRegistryCollisions` no longer takes a field namespace. The detection matches files that use one of them.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$])(?:instantiateAuthoringFieldPreset|isAuthoringFieldPresetDescriptor|getAuthoringFieldPreset|fieldPresetSpellings|instantiatePslFieldPreset|reportPresetNotCalled|temporalAuthoringPresets|temporalCodecPreset|temporalCodecPresetWithPrecision|temporalStringAuthoringPresets|temporalPhaseTemplate|temporalPhaseTemplateWithParams|TEMPORAL_ON_CREATE_ARG|TEMPORAL_ON_UPDATE_ARG|createFieldHelpersFromNamespace|PresetStorageTemplate|AuthoringFieldPresetDescriptor|AuthoringFieldPresetOutput|AuthoringFieldNamespace|AuthoringColumnDefaultTemplate|ObjectArgumentType|OptionalObjectArgumentKeys|GeneratedColumnSpec)(?![\w$])'
- 'import\s*(?:type\s*)?\{[^}]*(?<![\w$])(?:uuidv4|uuidv7|ulid|nanoid|cuid2|ksuid)(?![\w$])[^}]*\}\s*from\s*[\x27"]@prisma/orm-framework/ids[\x27"]'
- '(?<![\w$])assertNoCrossRegistryCollisions\('
- id: preset-diagnostics-removed
summary: |
The PSL diagnostics `PSL_PRESET_NOT_CALLED`, `PSL_PRESET_NOT_LIST`, `PSL_PRESET_NOT_OPTIONAL`, `PSL_PRESET_AND_ID_CONFLICT`, `PSL_PRESET_AND_DEFAULT_CONFLICT` and `PSL_PRESET_WITHOUT_EFFECT` are removed, and MongoDB's `PSL_PRESET_ON_VARIANT_FIELD` and `PSL_PRESET_CONFLICT` are renamed `PSL_GENERATOR_ON_VARIANT_FIELD` and `PSL_GENERATOR_CONFLICT`. A preset call is now reported as `PSL_UNRESOLVED_REFERENCE`. The detection matches files that name a `PSL_PRESET_` code.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'PSL_PRESET_'
- id: field-presets-in-extension-code
summary: |
Code and documentation in an extension that calls a field preset, such as `field.id.uuidv4String()` in a test contract, a README or a message, uses the replacement of the app guide's tables: `field.column(type.UuidString()).id().onCreate(gen.uuid())`. A package that exports `field` and `gen` built by `createComposedAuthoringHelpers` for use outside a contract also exports `type` from the same call. The detection matches files that call a preset.
detection:
glob: "**/*.{ts,mts,cts,tsx,md}"
matches:
- '(?<![\w$.])field\.(?:id|temporal)\.'
- '(?<![\w$.])field\.(?:text|int|bigint|float|decimal|boolean|bytes|dateTime|uuidString|uuidNative|ulid|nanoid|cuid2|ksuid|generated)\('
---
# 8.0.0-rc.20 → 8.0.0-rc.21 — Extension author upgrade instructions
No change in this release comes with a script. Make the changes below in any order.
## A client reports failures through what its members return
### `client-members-reject-instead-of-throwing`
A member that returns a promise or a result now reports a closed client, a failed connection or an ended transaction through what it returns. Code that relied on the call itself throwing must await the result or attach `.catch(...)`:
```ts
await db.close();
// Before: throws at the call, so .catch never runs
db.query(plan).toArray().catch(handle);
// After: the result rejects, so handle runs
db.query(plan).toArray().catch(handle);
// A synchronous try/catch around an unawaited call stops catching
try {
db.transaction(work);
} catch (error) {
handle(error);
}
// Await it instead
try {
await db.transaction(work);
} catch (error) {
handle(error);
}
```
For each file the detection matches, check each `try` block it names. Leave a call whose result is awaited or returned to an awaiting caller as it is.
### `lazy-activity-source-rejects`
No code change is needed unless your code caught errors from `lazyActivitySource`, `delegatingActivitySource` or a `withTransaction` context with a synchronous `try`/`catch`. Await the call inside the `try` block instead.
## A control adapter names where it keeps its markers
### `control-adapter-marker-location`
Add `markerLocation` to your control adapter, so messages about the contract markers name the right table or collection:
```ts
// Before
export class MyControlAdapter implements SqlControlAdapter<'my-target'> {
readonly familyId = 'sql' as const;
readonly targetId = 'my-target' as const;
…
}
// After
export class MyControlAdapter implements SqlControlAdapter<'my-target'> {
readonly familyId = 'sql' as const;
readonly targetId = 'my-target' as const;
readonly markerLocation = 'my_schema.marker';
…
}
```
The same applies to an adapter written as an object literal or returned by a factory: add `markerLocation: 'my_schema.marker'` to the object.
A family that implements `ControlFamilyInstance` itself adds `markerLocation` too, usually read from its control adapter:
```ts
return {
familyId: 'my-family',
get markerLocation() {
return getControlAdapter().markerLocation;
},
…
};
```
A test double that builds a control adapter or a `ControlFamilyInstance` sets it too.
### `control-adapter-hold-marker-lock`
If your database commits a transaction when it runs DDL, as MySQL does, give your SQL control adapter `holdMarkerLock(driver, work)`: take the marker lock, run `work`, and release the lock when `work` ends. `db sign` then creates the marker table inside `work`, under the lock, and writes the markers in a transaction after it. A target whose DDL is transactional needs nothing: without `holdMarkerLock`, `db sign` keeps the lock, the marker table and the writes in one transaction.
### `verify-contract-spaces-marker-location`
A call to `verifyContractSpaces` (from `@prisma/orm-toolchain/migration-tools/spaces`) passes the marker location of the target it verifies, such as `markerLocation: 'prisma_contract.marker'` for Postgres or `'_prisma_marker'` for SQLite.
## A SQL operation's schema is optional
### `plan-target-details-schema-optional`
Code that reads `target.details.schema` from a SQL migration operation treats it as `string | undefined`: SQLite and MySQL operations name no schema.
## Statements and subjects can name an enum value
### `enum-value-rename-statement`
`--rename Status.ARCHIVED:Status.RETIRED` resolves to a rename statement with `entity: 'enumValue'`, and a data-loss subject can be `{ kind: 'enumValue', namespaceId, enum, member }`, named by the member's name in the contract source. A `switch` or `if` over `statement.entity` or `subject.kind` gains an `'enumValue'` branch. A planner that does not plan enum value renames refuses the statement with a `statementRefused` conflict, as it refuses a namespace rename it cannot plan.
### `rows-holding-value-and-keep-data-edit`
A target that plans the removal of a stored value can tell the user how to keep the data it would lose. Nothing is required: `rowsHoldingValue`, `keepDataEdit` and `leaveOut` are all optional.
- In the runner, when the check before a removal fails because rows still hold the value, count them and set `meta.rowsHoldingValue` on the failure: `enumValue` is the enum value in its JSON form, `{ namespaceId?, enum, member }`, with `namespaceId` left out for the unbound namespace; `value` is the stored value as the database stores it; `rows` is the count; `operationIndex` is the position of the failed operation in its plan; and `keepDataEdit`, when the target can say it, is the edit below. If the count fails, return the plain check failure without the field.
- In the planner, set `keepDataEdit` on a `MigrationDataLoss` entry when an edit to the `migration.ts` the plan writes keeps the data: `remove` is the call as the file writes it, and `replaceWith` is the text that replaces it, which may be several calls separated by `, `. Set `swapsValues: true` when the edit moves values between members that trade them, so the CLI offers no way that renames them in a cycle. The CLI prints the edit beside the question, and leads its next actions with it.
- In the planner, when a refused statement's change can be written by hand, set `leaveOut` on the conflict to the statements to leave out. The CLI then prints the plan without them as the next action, and the conflict's `why` says what to add to its `migration.ts`.
## Contract source warnings reach the caller
### `contract-source-warnings`
This change applies to every family. The detection matches every call to `loadContractSource`.
```typescript
// Before
const result = await loadContractSource(config, { onWarning: (warning) => warnings.push(warning) });
if (result.ok) use(result.value);
// After
const result = await loadContractSource(config);
const { warnings } = result.ok ? result.value : result.failure;
if (result.ok) use(result.value.contract);
```
## MongoDB contract sources share one builder
Every MongoDB contract source, `defineContract`, Prisma 8 PSL and the Prisma 6 reader, now builds through one builder, so each MongoDB rule applies to every source in the same way. The builder records the stack's target and extension packs. Two MongoDB type names change to match SQL's, and the sections after them list where a source now refuses or accepts what it did not before. A contract that every source accepted before builds to the same `contract.json`.
### `mongo-contract-target-option`
Pass the MongoDB target pack as `target`:
```typescript
import { mongoContract } from '@prisma/orm-family-mongo/contract-psl/provider';
import mongoTargetPack from '@prisma/orm-target-mongo/target/pack';
const contract = mongoContract('./prisma/schema.prisma', { target: mongoTargetPack });
```
Keep any other options you pass, such as `output`, in the same object. A config that names its contract through `defineConfig({ contract: './prisma/schema.prisma' })` from `@prisma/orm-mongo/config` already passes the pack. A call without `target` is refused with `CONTRACT.ARGUMENT_INVALID` (`reason: 'target-pack-missing'`).
### `mongo-contract-input`
The type of the object you pass to `defineContract` is now `ContractInput`, as it is in SQL. Rename the import and each use:
```typescript
import type { ContractInput } from '@prisma/orm-family-mongo/contract-ts/contract-builder';
```
### `mongo-model-definition-removed`
`MongoModelDefinition` was an alias for a model of a built contract. SQL has no such alias. Write the type it stood for:
```typescript
import type { ContractModel } from '@prisma/orm-framework/contract/types';
import type { MongoModelStorage } from '@prisma/orm-family-mongo/contract';
function collectionOf(model: ContractModel<MongoModelStorage>): string | undefined {
return model.storage.collection;
}
```
### `mongo-ts-polymorphism-rules`
The detection matches a file that imports the MongoDB contract builder and declares a discriminator; an emitted `contract.d.ts` imports no builder, so it is not matched.
A discriminator must be a required text field, and each variant must be declared and stored in its base's collection:
```typescript
const Task = model('Task', {
collection: 'tasks',
fields: { _id: field.objectId(), type: field.string() },
discriminator: { field: 'type', variants: { Bug: { value: 'bug' } } },
});
const Bug = model('Bug', { base: Task, fields: { severity: field.int32() } });
```
### `mongo-index-shape-rules`
MongoDB refuses these indexes when it creates them, so the contract now refuses them first. A matched file uses a wildcard, a hashed key, a wildcard projection or a collation; check each such index against the list in the summary.
### `mongo-value-object-enum-check`
Declare every enum a value object field uses in `defineContract({ enums })`.
### `mongo-newly-accepted`
Nothing to change.
### `mongo-prisma6-merged-indexes`
Emit the contract again and sign the database, so `db verify` compares against the new storage hash.
## Explicit date and time types carry the kind of value they read
On PostgreSQL, a column type that several codecs read now names the kind of value in its name. The `Temporal` types gain the suffix `Temporal`, and the interval type that reads `{ months, days, micros }` gains the suffix `Parts`:
| Before | After | Reads |
| --- | --- | --- |
| `Timestamptz(p?)` | `TimestamptzTemporal(p?)` | `Temporal.Instant` |
| `Timestamp(p?)` | `TimestampTemporal(p?)` | `Temporal.PlainDateTime` |
| `Interval(p?)` | `IntervalParts(p?)` | `{ months, days, micros }` |
This change leaves `DateTime`, `Date` and `Time` as they are, and adds `DateTemporal` and `TimeTemporal` as the explicit names of what `Date` and `Time` read today. A later release gives `DateTime`, `Date` and `Time` new meanings, as convenience date and time types, and brings its own instructions. To keep what a field reads today through that change, you can already write the explicit name: `TimestamptzTemporal` for `DateTime`, `DateTemporal` for `Date` and `TimeTemporal` for `Time`.
Three explicit types are new, and need no change to existing schemas: `TimestampJsDate(p?)`, a `timestamp` column read as a JavaScript `Date`; `TimestampInstant(p?)`, a `timestamp` column read as a `Temporal.Instant`; and `IntervalString(p?)`, an `interval` column read as an ISO 8601 duration such as `P1Y2M`. Both timestamp types read and write the stored date and time as UTC.
### `psl-temporal-type-renames`
Rename each field type in your extension's `.prisma` contract source using the table above, keeping its arguments and its attributes:
```prisma
model Event {
id Int @id
at TimestamptzTemporal(3) // was Timestamptz(3)
local TimestampTemporal // was Timestamp
span IntervalParts // was Interval
}
```
A type alias written with one of the names, such as `types { Stamp = Timestamp(3) }`, is renamed the same way. The detection can match one of the words in a comment or a model name; rename only a field type or an alias's type.
### `ts-temporal-type-helpers-renamed`
```ts
field.column(type.TimestamptzTemporal(3)); // was type.Timestamptz(3)
field.column(intervalPartsColumn(6)); // was intervalColumn(6)
```
### `interval-with-precision-typed-as-parts`
```ts
import type { PgInterval } from '@prisma/orm-postgres/target/codec-types';
const span: PgInterval = row.span; // an IntervalParts(6) column
```
### `branded-time-types-removed`
Remove the four names from the import, and leave the rest of the file as it is:
```ts
import type {
CodecTypes as PgTypes,
JsonValue,
} from '@prisma/orm-postgres/target/codec-types'; // was also importing Interval, Time, Timestamp and Timestamptz
```
### `temporal-types-from-temporal-spec`
```ts
import type { Temporal } from 'temporal-spec';
expectTypeOf<Row['createdAt']>().toEqualTypeOf<Temporal.Instant>();
```
If the test file also creates `Temporal` values, import the type under another name, such as `import type { Temporal as TemporalTypes } from 'temporal-spec'`, so the runtime `Temporal` stays in scope.
### `contract-types-list-new-aggregates`
No change is needed now. The next `prisma contract emit` adds rows for the three new codecs to `contract.d.ts`.
## A driver ensures the session settings its target declares
### `driver-ensures-target-session-settings`
A target can now state the settings every database session needs, as data on its descriptor:
```ts
const target = { ...meta, sessionSettings: { DateStyle: 'ISO' } };
```
`createExecutionStack` and `createControlStack` pass them to the driver. A runtime driver descriptor receives them as the second argument of `create`, after its own options; a control driver receives them in its connect options:
```ts
create(options?: MyDriverOptions, sessionSettings?: SessionSettings): MyDriver
create(url: string, options?: ControlDriverConnectOptions): Promise<MyControlDriver> // options.sessionSettings
```
A driver for a target that declares settings applies them to every connection it hands out, before the first statement, and again whenever a connection's session may have been reset. A driver for a target that declares none needs no change. Code that wraps a driver descriptor to curry its options passes the second argument on, or the driver never receives the settings:
```ts
const driverWithCursor = {
...postgresDriverDescriptor,
create(_options, sessionSettings) {
return postgresDriverDescriptor.create({ cursor }, sessionSettings);
},
};
```
A codec whose application value is the stored form of its data type builds its values with the new `CodecImpl.storedDataTypeValueOf`, which takes only the stored spelling, instead of `dataTypeValueOf`.
## Field presets are removed
This section replaces two pieces of advice from the 8.0.0-rc.19 upgrade guide. That guide said that a field preset keeps its `number`, `option` and `object` arguments, and showed field presets built with `instantiatePslFieldPreset` and `temporalCodecPresetWithPrecision`. Neither holds any more: this release removes field presets, the `authoring.field` namespace and every argument kind except `value`.
A pack now contributes what a schema writes in a field's type position only through `authoring.type`: a type constructor, such as `Char(36)`, or a type alias, such as `temporal.CreatedAt`. Generated values and the identity are written by the user as attributes (`@id`, `@onCreate(...)`, `@onUpdate(...)` in PSL; `.id()`, `.onCreate(gen.fn())`, `.onUpdate(gen.fn())` in TypeScript), or set by a type alias. The upgrade guide for apps lists the replacement of every preset Prisma contributed.
### `authoring-field-namespace-removed`
Move each field preset out of `authoring.field`. A preset that stored a column with fixed type parameters and generated values becomes a type alias in `authoring.type`, as the targets' `temporal.CreatedAt` is:
```ts
// Before
authoring: {
field: {
temporal: {
createdAt: {
kind: 'fieldPreset',
output: {
codecId: 'pg/timestamptz-temporal@1',
executionDefaults: { onCreate: { kind: 'generator', id: 'instantNow' } },
},
},
},
},
},
// After
authoring: {
type: {
temporal: {
CreatedAt: {
kind: 'typeAlias',
documentation: 'A timestamp the client sets to now when a row is created.',
output: {
type: { codecId: 'pg/timestamptz-temporal@1' },
onCreate: { name: 'now', args: {} },
},
},
},
},
},
```
`temporalTypeAliases(type, value)` from `@prisma/orm-framework/components/authoring` builds the `CreatedAt` and `UpdatedAt` pair for a timestamp type. A type alias calls a generator function by name, as `@onCreate(now())` does, and the stack's bindings pick the generator for the alias's codec. A preset that took arguments becomes a type constructor in `authoring.type`, and the user writes the generated values and `@id` as attributes. A preset whose only job was `@id` plus a generator, such as `id.uuidv4String()`, has no replacement in the pack: the user writes the SQL family's identifier alias with the attributes, `UuidString @id @onCreate(uuid())`.
Remove the `field` key from `authoring` on both the pack ref and the control descriptor. Then assemble the stack: if any `field` key is left, the control stack and the TypeScript builders throw `CONTRACT.PACK_CONTRIBUTION_INVALID` with `Component "<id>" contributes authoring.field, which Prisma 8 removed with field presets. Contribute a type alias (kind: 'typeAlias') or a type constructor in authoring.type.`
### `argument-kinds-removed`
`AuthoringArgumentDescriptor` is now `{ kind: 'value'; dataType: string; name?: string; optional?: boolean }`. A type constructor already took only `value` arguments; the other kinds were left only for field presets. Replace `AuthoringValueArgument` with `AuthoringArgumentDescriptor`, and delete code that builds or reads `option`, `number`, `object` or `select` descriptors and templates, and the `path` and `default` of a `{ kind: 'arg' }` template reference. A template refers to a whole argument by its index: `{ kind: 'arg', index: 0 }`.
### `preset-builders-removed`
Delete each use of the removed helpers and types with the preset it built or read. `temporalTypeAliases`, `TIMESTAMP_NOW_GENERATOR_ID` and `timestampNowControlDescriptor` stay. `generatedLength` stays in `@prisma/orm-framework/ids`; the spec helpers `uuidv4()` and the others fed only `field.generated(...)`, which is removed too.
`assertNoCrossRegistryCollisions` takes the type namespace, then the entity type, PSL block and model attribute namespaces. Remove the field namespace argument you passed second:
```ts
// Before
assertNoCrossRegistryCollisions(typeNamespace, fieldNamespace, entityTypeNamespace);
// After
assertNoCrossRegistryCollisions(typeNamespace, entityTypeNamespace);
```
### `preset-diagnostics-removed`
Code that matches a diagnostic by code: a preset call is now `PSL_UNRESOLVED_REFERENCE`, with the message `<call> was a field preset, and Prisma 8 removed field presets. Write <replacement>.` The other `PSL_PRESET_*` codes cannot occur, so remove the code that handles them. Rename `PSL_PRESET_ON_VARIANT_FIELD` to `PSL_GENERATOR_ON_VARIANT_FIELD` and `PSL_PRESET_CONFLICT` to `PSL_GENERATOR_CONFLICT`. Their messages now start `Generated value on`, and they are reported for `@onCreate(...)`, `@onUpdate(...)` and type aliases that generate a value.
### `field-presets-in-extension-code`
Rewrite every preset call in the extension's code, test contracts, messages, doc comments and documentation with the tables of the app upgrade guide. In a `defineContract` callback, take `gen` and `type` from the callback's argument where the rewritten fields use them, adding each missing name at the end of the destructuring, `gen` before `type`. A preset named in a message or doc comment as an example, such as `field.text()` or `field.temporal.timestamptz()`, becomes its replacement, such as `field.column(type.String())` or `field.column(type.TimestamptzTemporal())`. A sentence that describes the presets themselves, such as one saying a `field` export has the same presets as the callback, says instead that the export gives the same `field` and `type` as the callback. If a callback renames `field`, as in `({ field: f })`, the detection does not see `f.id.uuidv4String()`; search for it yourself.
If your package exports `field` and `gen` from a `createComposedAuthoringHelpers(...)` call so that code outside a contract, such as a query fragment, can declare fields, export that call's `type` too, with a doc comment that names an example such as `type.TimestamptzTemporal()`:
```ts
export const type: Helpers['type'] = helpers.type;
```
Add it wherever the package re-exports `field` and `gen`, and replace the preset examples in the doc comment of `field` with `field.column(type.X())` examples. The app upgrade guide's section `ts-field-presets-removed` covers the rest of a `defineContract` callback: a name `type` that is already bound, code that does not know its target, and a `field` imported outside a callback.
upgrading/extension/upgrades/8.0.0-rc.21-to-8.0.0-rc.22/instructions.md
---
from: "8.0.0-rc.21"
to: "8.0.0-rc.22"
changes:
- id: mongo-result-shapes-read-storage
summary: |
`contractModelToMongoResultShape` and `contractFieldToMongoFieldShape` from `@prisma/orm-mongo/query-builder` take the contract's storage, `{ storage: contract.storage }` and `contract.storage`, instead of its value objects. `compileMongoQuery` from `@prisma/orm-mongo/orm` takes the contract's storage as its fifth argument. `MongoValueObjects` is removed. This replaces the rc.14 advice to pass `valueObjects`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bMongoValueObjects\b'
- '\bcontractModelToMongoResultShape\s*\([\s\S]*?\bvalueObjects\b'
- '\bcontractFieldToMongoFieldShape\s*\('
- '\bcompileMongoQuery\s*\('
- id: postgres-check-expression-types-move
summary: |
`PostgresCheckKind`, `PostgresCheckExpressionInput` and `PostgresCheckExpressionCandidate` are removed from `@prisma/orm-postgres/target/types`. Use `CheckExpressionInput` and `CheckExpressionCandidate` from `@prisma/orm-postgres/family-contract/check-expressions` (also exported at the same path by `@prisma/orm-mysql` and `@prisma/orm-sqlite`). A candidate's `kind` is `'membership'`, `'elementNotNull'` or the new `'shape'`, the check of a value object column's shape.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bPostgresCheck(?:Kind|ExpressionInput|ExpressionCandidate)\b'
- id: field-types-read-storage-value-objects
summary: |
In `@prisma/orm-framework/contract/types`, `ValueObjectTypesFromDomain` is now `ValueObjectTypesFromStorage`, which takes the contract's storage namespaces. `FieldTypesFromDomain` takes the storage namespaces as a new, required sixth argument, `Contract['storage']['namespaces']`, since it reads both planes. An override for a value object field is keyed by the value object's storage namespace, at `[storage namespace]['valueObjects'][valueObject][field]`, while a model field's override stays keyed by its domain namespace.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bValueObjectTypesFromDomain\b'
- '\bFieldTypesFromDomain\s*<'
- id: value-object-reference-type
summary: |
In `@prisma/orm-framework/contract/types`, `ValueObjectFieldType` is renamed `ValueObjectReference`: the type of a field that refers to a value object, which is now the value object's storage coordinate. `ContractValueObjectField` stays the type of a value object's own field.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bValueObjectFieldType\b'
- id: postgres-codec-json-type
summary: |
A PostgreSQL codec descriptor declares `jsonType`: the JSON type the codec's value is written as inside a JSON document, one of `'string'`, `'number'`, `'boolean'`, `'object'`, `'array'` or `'any'`. `'any'` means any JSON value, including `null`, as for a JSON codec. The derived check of a value object column requires a member to be of its codec's JSON type, and a required member is never JSON `null` unless its codec's type is `'any'`. A class that extends `PostgresCodecDescriptor`, or an object that implements `AnyPostgresCodecDescriptor`, declares it as a property. `postgresCodec(template, options)` reads it from the template, so a family codec template declares it. A registry accepts a descriptor without it, but a contract build that derives the check of a value object whose member uses such a codec refuses it with `CONTRACT.CODEC_JSON_TYPE_MISSING`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bextends\s+PostgresCodecDescriptor\b'
- '\bAnyPostgresCodecDescriptor\b'
- '\bpostgresCodec\s*\('
- id: sql-stack-with-driver-requires-driver
summary: |
`SqlExecutionStackWithDriver` now requires its `driver`, and `createSqlExecutionStack(...)` returns it only when given a driver. Without a driver it returns `SqlExecutionStack`, which has no `driver` member. `createToSql(stack, contract)` takes `SqlExecutionStack`. Type a stack built without a driver as `SqlExecutionStack`, and remove checks such as `if (!stack.driver) throw ...` on a stack typed `SqlExecutionStackWithDriver`, which can no longer fail. The framework `ExecutionStack` now declares `driver` optional. The detection matches files that name `SqlExecutionStackWithDriver` or read `driver` from a stack, such as `stack.driver`, `clientStack.driver` or `const { driver } = createSqlExecutionStack(...)`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$])SqlExecutionStackWithDriver(?![\w$])'
- '(?<![\w$.])(?:this\.)?[\w$]*[sS]tack\.driver(?![\w$])'
- '\{(?:[^{}]*,)?\s*driver(?![\w$])[^{}]*\}\s*=\s*(?:createSqlExecutionStack\s*\(|(?:this\.)?[\w$]*[sS]tack(?![\w$]))'
- id: runtime-mode-from-framework-components
summary: |
No action is needed. `RuntimeMode` is now declared in `@internal/framework-components/runtime` and still exported from `@internal/sql-runtime`.
- id: declare-postgres-extension-in-pack-contract
summary: |
A Postgres pack whose migration installs an extension with `installExtension(...)` and an invariant cannot be adopted by `prisma db sign`, which refuses a space whose head ref requires an invariant its marker does not record. Declare the extension in the pack's contract instead, with `extension pg_trgm {}` in PSL or `entities: [extension('pg_trgm')]` in a TypeScript contract, re-run `prisma contract emit` for the pack, and plan two migrations with `prisma migration plan`: one from the old head (`--from <old head hash>`), whose one operation creates the extension if it is missing, and one from the empty contract (`--from @empty`). Wire both into the descriptor's `contractSpace.migrations`, keep the old install migration, and set `migrations/refs/head.json` to the new storage hash with no invariants.
detection:
glob: "**/*.{ts,mts,cts}"
matches:
- '(?<![\w$])installExtension\s*\('
- id: mongo-driver-from-runtime-descriptor
summary: |
`@internal/driver-mongo` has no root export any more, so `createMongoDriver` and `MongoDriverImpl` are gone, as `@internal/driver-postgres` has none. Create a runtime driver from the descriptor that `@prisma/orm-target-mongo/driver/runtime` exports by default, then connect it. `@prisma/orm-mongo/driver` and `@prisma/orm-target-mongo/driver` now export what `driver/control` and `driver/runtime` export. The detection matches files that name `createMongoDriver` or `MongoDriverImpl`, or import the root `@internal/driver-mongo`.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx}"
matches:
- '(?<![\w$])(?:createMongoDriver|MongoDriverImpl)(?![\w$])'
- '[''"]@internal/driver-mongo[''"]'
- id: mongo-driver-connect
summary: |
`@internal/mongo-lowering` adds `MongoQueryable`, with `execute` and `run`, as `SqlQueryable` is for SQL. `MongoDriver<TBinding = void>` extends it and adds `connect(binding)` and `close()`, as `SqlDriver<TBinding>` does. `MongoControlDriverInstance` extends `MongoQueryable`, not `MongoDriver`, so a control driver has no `connect`. `executeDdl`, `MongoRunnerDependencies.driver` and the migration runner now take `MongoQueryable`; `createMongoRuntime` takes `MongoDriver<unknown>`. Type code that only sends commands, or that receives a control driver, as `MongoQueryable`. A runtime driver you implement as `MongoDriver` adds `connect`. The detection matches classes that implement `MongoDriver`, interfaces and classes that extend it, type aliases that name it, and values, parameters and return types annotated or checked as `MongoDriver`, also inside a generic such as `Promise<MongoDriver>`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'implements\s[^{]*(?<![\w$])MongoDriver(?![\w$])'
- '[\w$)]\??\s*:\s*MongoDriver(?![\w$])'
- '[\w$)]\??\s*:\s*[\w$.]+<[^;{=]*(?<![\w$])MongoDriver(?![\w$])'
- 'satisfies\s+MongoDriver(?![\w$])'
- '(?:interface|class)\s[^{]*extends\s[^{]*(?<![\w$])MongoDriver(?![\w$])'
- 'type\s+[\w$]+(?:<[^>]*>)?\s*=[^;]*(?<![\w$])MongoDriver(?![\w$])'
- id: mongo-query-takes-context
summary: |
`mongoQuery` (`@internal/mongo-query-builder`, published as `@prisma/orm-family-mongo/query-builder` and `@prisma/orm-mongo/query-builder`) takes `{ context }`, a `MongoExecutionContext`, instead of `{ contract }`, and reads the contract from it. Code that builds the builder itself passes the execution context it already has, for example `mongoQuery({ context })` after `createMongoExecutionContext({ contract, stack })`; code that has a client or a static context uses `db.query` or `mongoStatic({ contract }).query`. The detection matches files that call `mongoQuery` with a `contract` option, import it under another name, or call it through a namespace import.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx}"
matches:
- '(?<![\w$.])mongoQuery\s*(?:<[^>()]*>)?\s*\(\s*\{\s*contract\b'
- '(?<![\w$.])mongoQuery\s+as\s+[\w$]+'
- '(?<![\s\S])(?=[\s\S]*\bimport\s*\*\s*as\s+([\w$]+)\s+from\s*[\x27\x22](?:@prisma/orm-(?:family-)?mongo/query-builder|@internal/mongo-query-builder)[\x27\x22])[\s\S]*(?<![\w$.])\1\s*\.\s*mongoQuery\s*\('
- id: mongo-orm-takes-context
summary: |
`mongoOrm()` (`@internal/mongo-orm`, published as `@prisma/orm-family-mongo/orm` and `@prisma/orm-mongo/orm`) takes `{ context, runtime }`, as the SQL `orm({ context, runtime })` does. `MongoOrmOptions` loses `contract`, `executor`, `enums` and `mutationDefaults`: the ORM reads the contract, the codecs it builds enum accessors from, and the execution defaults from the context, and `runtime` is the executor. `mongoRaw()` takes `{ context }`. `createMongoCollection()` takes `({ context, runtime }, modelName)` (`MongoCollectionContext`, as the SQL ORM's `CollectionContext`) and always fills execution defaults. `MongoOrmEnums` and `ORM.MUTATION_DEFAULTS_MISSING` are removed, and the ORM no longer raises `ORM.ARGUMENT_INVALID` for missing enum accessors. The detection matches files that call `mongoOrm` or `mongoRaw` with a `contract` option, call `mongoOrm` with `executor` or `enums`, call `createMongoCollection` without a context object, pass `mutationDefaults`, or name `MongoOrmEnums` or `ORM.MUTATION_DEFAULTS_MISSING`.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx}"
matches:
- '(?<![\w$.])mongo(?:Orm|Raw)\s*(?:<[^>()]*>)?\s*\(\s*\{\s*contract\b'
- '(?<![\w$.])mongoOrm\s*(?:<[^>()]*>)?\s*\(\s*\{(?:[^{}]|\{[^{}]*\})*\b(?:executor|enums)\b'
- '(?<![\w$.])createMongoCollection\s*(?:<[^>()]*>)?\s*\(\s*(?!\{)'
- '(?<![\w$])mutationDefaults\s*:'
- '(?<![\w$])MongoOrmEnums(?![\w$])'
- 'MUTATION_DEFAULTS_MISSING'
- id: mongo-runtime-takes-adapter
summary: |
`MongoRuntimeOptions` is `{ context, adapter, driver, middleware?, mode? }`, as the SQL runtime's options are: the caller creates the adapter with `stack.adapter.create(stack)`, and the runtime checks middleware against `context.contract.target`, so `context` is a `MongoExecutionContext<MongoContract>`. `MongoExecutionContext` is declared in `@prisma/orm-family-mongo/query-ast/execution` (`@internal/mongo-query-ast/execution`), still exported from the runtime entry; it has no `stack` member and no second type parameter (`TTargetId`), and its type parameter is constrained as SQL's `ExecutionContext` is: `MongoExecutionContext<TContract extends MongoContract = MongoContract>`, and `createMongoExecutionContext` takes the same constraint. `createMongoExecutionStack()` still takes an optional `driver`; given one, it returns `MongoExecutionStackWithDriver`, whose `driver` is always set, and without one it returns `MongoExecutionStack`, which has no `driver` member, as `createSqlExecutionStack()` does. `buildMongoEnums()` and `MongoEnumAccessors` are removed, and `MongoExecutionContext` has no `enums` member, as SQL's `ExecutionContext` has none: the static context builds `db.enums` with the framework's `buildNamespacedEnums`, as `postgresStatic()` does, and the ORM builds the accessors it checks writes against from the context, once per client. The detection matches files that call `createMongoRuntime(`, read `.stack` from a context, give `MongoExecutionContext` two type arguments or a non-contract type argument, or name `buildMongoEnums` or `MongoEnumAccessors`.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx}"
matches:
- '(?<![\w$.])createMongoRuntime\s*\('
- '(?<![\w$])[\w$]*[cC]ontext\s*\.\s*stack(?![\w$])'
- '(?<![\w$])MongoExecutionContext\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*,'
- '(?<![\w$])MongoExecutionContext\s*<\s*(?:unknown|\{)'
- '(?<![\w$.])(?:buildMongoEnums|MongoEnumAccessors)(?![\w$])'
- id: mongo-build-refusal-index-duplicate-removed
summary: |
The MongoDB contract builder's `BuildRefusal` type, exported from `@prisma/orm-family-mongo/contract-ts/contract-builder`, no longer has the kind `indexDuplicate`. The builder now merges identical and redundant indexes instead of refusing them. Remove any `case 'indexDuplicate'` and any other code that reads that kind.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bindexDuplicate\b'
- id: mongo-index-lookup-key-moved
summary: |
`indexLookupKey`, `MONGO_INDEX_OPTION_KEYS`, `MongoIndexOptionKey` and `canonicalize` are no longer exported from `@prisma/orm-family-mongo/schema-ir`. Import them from `@prisma/orm-family-mongo/contract`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'import\s+(?:type\s+)?\{[^}]*\b(?:indexLookupKey|MONGO_INDEX_OPTION_KEYS|MongoIndexOptionKey|canonicalize)\b[^}]*\}\s*from\s*[\x27"]@prisma/orm-family-mongo/schema-ir[\x27"]'
- id: client-connect-safe-to-call-again
summary: |
`connect()` without options on a `postgres()`, `sqlite()`, `mysql()` or `mongo()` client no longer rejects when the client is connecting or connected. It waits for the connection and resolves with the runtime. `connect(options)` on such a client still rejects with `DRIVER.ALREADY_CONNECTED`, but its message is now `<Target> client is already bound` (for example `Postgres client is already bound`), it has a `why`, and its `fix` changed. Update code or tests that branch on `DRIVER.ALREADY_CONNECTED` after a `connect()` without options, or that match the old message `<Target> client already connected` or the old `fix` "Call connect() at most once per client.". Code that avoided a second `connect()` for fear of this error may call it freely. A `connect()` that is waiting when `close()` is called now rejects with `DRIVER.NOT_CONNECTED`.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs}"
matches:
- 'DRIVER\.ALREADY_CONNECTED'
- '(?:Postgres|SQLite|MySQL|Mongo) client already connected'
- 'Call connect\(\) at most once per client'
---
# 8.0.0-rc.21 → 8.0.0-rc.22 — Extension author upgrade instructions
No change in this guide comes with a script. Make the changes below in any order.
## Value objects are storage entities
A contract keeps its value objects in storage, at `storage.namespaces.<ns>.entries.valueObject`, and a domain field names its value object by a storage coordinate. Code that read `domain.namespaces.<ns>.valueObjects` reads the storage namespaces instead.
### `mongo-result-shapes-read-storage`
```ts
// before
contractModelToMongoResultShape(model, { includes: { author: authorShape }, valueObjects });
contractFieldToMongoFieldShape(field, valueObjects);
compileMongoQuery(collection, state, storageHash, model, valueObjects);
// after
contractModelToMongoResultShape(model, { includes: { author: authorShape }, storage: contract.storage });
contractFieldToMongoFieldShape(field, contract.storage);
compileMongoQuery(collection, state, storageHash, model, contract.storage);
```
A value object field's shape is a document of its fields when `storage` holds the value object the field's coordinate names. Drop any `MongoValueObjects` type annotation; each parameter takes an object with the contract's storage `namespaces`.
### `postgres-check-expression-types-move`
```ts
// before
import type { PostgresCheckExpressionInput } from '@prisma/orm-postgres/target/types';
// after
import type { CheckExpressionInput } from '@prisma/orm-postgres/family-contract/check-expressions';
```
`postgresRenderCheckExpressions` stays in `@prisma/orm-postgres/target/types`. Its input also takes `valueObject`, a `ResolvedColumnValueObject` (the column's nullability, whether its JSON value is an array, and the value object), or `undefined`.
### `field-types-read-storage-value-objects`
```ts
// before
import type { FieldTypesFromDomain, ValueObjectTypesFromDomain } from '@prisma/orm-framework/contract/types';
type Fields = FieldTypesFromDomain<Contract['domain']['namespaces'], CodecTypes, Parameterized, Overrides, 'output'>;
type ValueObjects = ValueObjectTypesFromDomain<Contract['domain']['namespaces'], CodecTypes, Parameterized, Overrides, 'output'>;
// after
import type { FieldTypesFromDomain, ValueObjectTypesFromStorage } from '@prisma/orm-framework/contract/types';
type Fields = FieldTypesFromDomain<Contract['domain']['namespaces'], CodecTypes, Parameterized, Overrides, 'output', Contract['storage']['namespaces']>;
type ValueObjects = ValueObjectTypesFromStorage<Contract['storage']['namespaces'], CodecTypes, Parameterized, Overrides, 'output'>;
```
Pass the sixth argument for every contract, with value objects or without. `FieldTypeOverrides` has two kinds of key at its top level. Under `models`, the key is the domain namespace of the model. Under `valueObjects`, the key is the storage namespace that holds the value object. These can differ. On SQL every value object is stored in the default storage namespace, such as `public` on Postgres, whatever the namespace of the model that holds it. On MongoDB it is `__unbound__`.
```ts
// a model `User` in the PSL namespace `billing`, with an `Address` field, on Postgres
type FieldTypeOverrides = {
readonly billing: { readonly models: { readonly User: { readonly status: { readonly output: 'active' | 'closed' } } } };
readonly public: { readonly valueObjects: { readonly Address: { readonly kind: { readonly output: 'home' | 'work' } } } };
};
```
### `value-object-reference-type`
```ts
// before
import type { ValueObjectFieldType } from '@prisma/orm-framework/contract/types';
const reference: ValueObjectFieldType = { kind: 'valueObject', name: 'Address' };
// after
import type { ValueObjectReference } from '@prisma/orm-framework/contract/types';
const reference: ValueObjectReference = {
kind: 'valueObject',
plane: 'storage',
namespaceId: 'public',
entityKind: 'valueObject',
entityName: 'Address',
};
```
### `postgres-codec-json-type`
```ts
// before
export class CitextDescriptor extends PostgresCodecDescriptor<void> {
override readonly codecId = 'citext/text@1';
// ...
}
// after
export class CitextDescriptor extends PostgresCodecDescriptor<void> {
override readonly jsonType = 'string' as const;
override readonly codecId = 'citext/text@1';
// ...
}
```
Declare the type the codec's value is written as when the application value is turned into JSON: a vector is `'array'`, a geometry and bytes are `'object'`, and a JSON codec is `'any'`. A template passed to `postgresCodec(template, options)` declares `jsonType` itself, as the `sql/*` family codecs do.
## A SQL stack with a driver always has one
```ts
// Before
const stack: SqlExecutionStackWithDriver<'postgres'> = createSqlExecutionStack({ target, adapter });
if (!stack.driver) throw new Error('Driver descriptor missing from execution stack');
// After
const staticStack: SqlExecutionStack<'postgres'> = createSqlExecutionStack({ target, adapter });
const clientStack = createSqlExecutionStack({ target, adapter, driver });
const driverInstance = clientStack.driver.create();
```
## Declare a pack's Postgres extension in its contract
```ts
// Before: src/contract.ts
export const contract = defineContract(config, () => ({ models: {} }));
// After
import { defineContract, extension } from '@prisma/orm-postgres/contract-builder';
export const contract = defineContract(config, () => ({
models: {},
entities: [extension('vector')],
}));
```
After emitting the pack's contract, plan from the old head and from the empty contract, keep the old install migration in the descriptor, and list the two new migrations beside it. The head ref lists no invariants.
## Create a Mongo runtime driver from its descriptor
Put the descriptor on the execution stack, create the driver from the stack, and connect it to a binding. A `{ kind: 'url', url, dbName }` binding makes the driver open a `MongoClient` and close it on `close()`. A `{ kind: 'mongoClient', client, dbName }` binding uses your client and leaves it open.
```ts
// Before
import { createMongoDriver } from '@prisma/orm-target-mongo/driver';
const stack = createMongoExecutionStack({ target: mongoRuntimeTarget, adapter: mongoRuntimeAdapter });
const driver = await createMongoDriver(url, dbName);
// After
import mongoRuntimeDriver from '@prisma/orm-target-mongo/driver/runtime';
const stack = createMongoExecutionStack({
target: mongoRuntimeTarget,
adapter: mongoRuntimeAdapter,
driver: mongoRuntimeDriver,
});
const driver = stack.driver.create();
await driver.connect({ kind: 'url', url, dbName });
```
Replace `MongoDriverImpl.fromDb(client.db(dbName))` with `driver.connect({ kind: 'mongoClient', client, dbName })` on a driver created the same way. Code that only sends DDL through a control adapter can use the control driver instead: `await mongoControlDriver.create(url)`, with `mongoControlDriver` the default export of `@prisma/orm-target-mongo/driver/control`. The control driver uses the database the URL names, so put `dbName` in the URL's path, for example `mongodb://localhost:27017/app`.
`createMongoRuntime(...)` closes the driver when the runtime closes, so keep calling `runtime.close()` and drop any separate `driver.close()`.
## Send commands through `MongoQueryable`, connect through `MongoDriver`
Code that only sends commands takes `MongoQueryable`, so it accepts a control driver and a runtime driver alike:
```ts
// Before
async function seed(driver: MongoDriver): Promise<void> {
await driver.run(new CreateCollectionWireCommand('users', {}));
}
// After
async function seed(driver: MongoQueryable): Promise<void> {
await driver.run(new CreateCollectionWireCommand('users', {}));
}
```
A stub that stands in for a control driver or a runner's driver implements `MongoQueryable`. A runtime driver that implements `MongoDriver` adds `connect`:
```ts
// Before
class StubMongoDriver implements MongoDriver {
execute<Row>(command: AnyMongoDmlWireCommand): AsyncIterable<Row> { ... }
async run(command: AnyMongoDdlWireCommand): Promise<void> { ... }
async close(): Promise<void> {}
}
// After, when it only sends commands
class StubMongoDriver implements MongoQueryable {
execute<Row>(command: AnyMongoDmlWireCommand): AsyncIterable<Row> { ... }
async run(command: AnyMongoDdlWireCommand): Promise<void> { ... }
}
// After, when it is a runtime driver
class StubMongoDriver implements MongoDriver {
async connect(): Promise<void> {}
execute<Row>(command: AnyMongoDmlWireCommand): AsyncIterable<Row> { ... }
async run(command: AnyMongoDdlWireCommand): Promise<void> { ... }
async close(): Promise<void> {}
}
```
## The MongoDB query surfaces and runtime take an execution context
`mongoQuery`, `mongoRaw` and `mongoOrm` take `{ context }`, a MongoDB execution context, instead of `{ contract }`, as `sql({ context })` and the SQL ORM do. The context comes from `mongoStatic({ contract })` or `mongo({ contract })`, which validate the contract once.
```ts
// Before
import { mongoQuery } from '@prisma/orm-mongo/query-builder';
const query = mongoQuery({ contract });
const raw = mongoRaw({ contract });
// After
import mongoStatic from '@prisma/orm-mongo/static';
const { query, raw } = mongoStatic({ contract });
// Or, with a client
const db = mongo({ contract, url });
db.query.from('orders');
```
Code that builds the ORM itself passes the context, which also fills generated fields:
```ts
// Before
const orm = mongoOrm({ contract, executor, mutationDefaults: context, enums });
const users = createMongoCollection(contract, 'User', executor, enums, context);
// After, as the SQL orm({ context, runtime })
const orm = mongoOrm({ context, runtime: executor });
const users = createMongoCollection({ context, runtime: executor }, 'User');
```
The runtime takes an adapter beside the context, as the SQL runtime does, and reads the target from the context's contract:
```ts
// Before
const runtime = createMongoRuntime({ context, stack, driver });
// After
const stack = createMongoExecutionStack({ target, adapter });
const context = createMongoExecutionContext({ contract, stack });
const runtime = createMongoRuntime({ context, adapter: stack.adapter.create(stack), driver });
```
`MongoExecutionContext` takes one type parameter, a MongoDB contract: `MongoExecutionContext<Contract>`.
## MongoDB index identity: one refusal gone, four names moved
### `mongo-build-refusal-index-duplicate-removed`
Code that switches over `refusal.kind` no longer compiles while it names `indexDuplicate`. Delete that case:
```typescript
switch (refusal.kind) {
case 'indexShape':
return describeShape(refusal);
// remove: case 'indexDuplicate': ...
}
```
### `mongo-index-lookup-key-moved`
The contract builder, the planner and the verifier now identify an index by one function in the contract package. Move these names to the `contract` import:
```typescript
import { MongoSchemaIndex } from '@prisma/orm-family-mongo/schema-ir';
import { indexLookupKey } from '@prisma/orm-family-mongo/contract';
```
## `connect()` on a client is safe to call again
```ts
// Before
await db.orm.public.User.first();
await db.connect(); // rejected with DRIVER.ALREADY_CONNECTED
// After
await db.orm.public.User.first();
await db.connect(); // resolves with the same runtime
await db.connect({ url: other }); // rejects: "Postgres client is already bound"
```
Replace an assertion on the old message with the new one:
```ts
// Before
await expect(db.connect({ url })).rejects.toThrow('Postgres client already connected');
// After
await expect(db.connect({ url })).rejects.toThrow('Postgres client is already bound');
```
upgrading/extension/upgrades/8.0.0-rc.22-to-8.0.0-rc.23/instructions.md
---
from: "8.0.0-rc.22"
to: "8.0.0-rc.23"
changes:
- id: temporal-polyfill-is-a-peer-dependency-of-mysql
summary: |
`temporal-polyfill` is now a required peer dependency of `@prisma/orm-target-mysql` and `@prisma/orm-mysql`. The target's control entry imports it. An extension pack that peers the target does not declare it; the application supplies it. npm, pnpm and bun install it automatically. An extension package that installs with Yarn and loads the target's control entry in its tests or tooling must add `temporal-polyfill` (`^1.0.4`) to its `devDependencies`.
detection:
glob: "**/yarn.lock"
contains:
- "@prisma/orm-mysql@"
- "@prisma/orm-target-mysql@"
anyMatch: true
- id: mysql-create-namespace-moved-to-target-types
summary: |
`mysqlCreateNamespace` and `MysqlDatabase` moved from the MySQL target's `/target/control` entry to its `/target/types` entry, as PostgreSQL's and SQLite's are in theirs. The control entry loads `temporal-polyfill`, so code that is not control-plane code no longer has to load it to name a namespace. An import of either from `/target/control` fails to compile.
detection:
glob: "**/*.ts"
contains:
- "mysqlCreateNamespace"
- "MysqlDatabase"
anyMatch: true
- id: temporal-polyfill-is-a-peer-dependency-of-sqlite
summary: |
`temporal-polyfill` is now a required peer dependency of `@prisma/orm-target-sqlite` and `@prisma/orm-sqlite`. The target's control entry imports it. An extension pack that peers the target does not declare it; the application supplies it. npm, pnpm and bun install it automatically. An extension package that installs with Yarn and loads the target's control entry in its tests or tooling must add `temporal-polyfill` (`^1.0.4`) to its `devDependencies`.
detection:
glob: "**/yarn.lock"
contains:
- "@prisma/orm-sqlite@"
- "@prisma/orm-target-sqlite@"
anyMatch: true
- id: sqlite-create-namespace-moved-to-target-types
summary: |
`sqliteCreateNamespace` and `SqliteUnboundDatabase` moved from the SQLite target's `/target/control` entry to its `/target/types` entry, as PostgreSQL's `postgresCreateNamespace` is in its `/target/types`. The control entry loads `temporal-polyfill`, so code that is not control-plane code no longer has to load it to name a namespace. An import of either from `/target/control` fails to compile.
detection:
glob: "**/*.ts"
contains:
- "sqliteCreateNamespace"
- "SqliteUnboundDatabase"
anyMatch: true
- id: psl-renamed-postgres-scalar-types
summary: |
In a Prisma 8 schema on PostgreSQL, `SmallInt`, `BigIntNumber`, `Numeric`, `UnboundedInt`, `Real`, `VarChar` and `VarBit` are deprecated and report `PSL_DEPRECATED_SCALAR_NAME` as a warning. Write the convenience call that gives the same type instead: `Int(storage: int2)`, `BigInt(as: number)`, `Decimal(precision: p, scale: s)`, `BigInt(storage: numeric)`, `Float(storage: float4)` and `String(storage: varchar, length: n)`; `VarBit` has no convenience type and becomes `Varbit`. The contract does not change. A Prisma 7 schema, which has a `datasource` block, is not affected.
detection:
glob: "**/*.prisma"
matches:
- '(?<![\s\S])(?![\s\S]*(?<![\w$])datasource[ \t]+\w+[ \t]*\{)[\s\S]*\n[ \t]+[A-Za-z_]\w*[ \t]+(?:=[ \t]*)?(?:SmallInt|BigIntNumber|Numeric|UnboundedInt|Real|VarChar|VarBit)(?![\w$])'
- id: ts-renamed-postgres-scalar-helpers
summary: |
In a TypeScript contract on PostgreSQL, `type.SmallInt()`, `type.BigIntNumber()`, `type.Numeric(...)`, `type.UnboundedInt()`, `type.Real()`, `type.VarChar(...)` and `type.VarBit(...)` are deprecated. They still compile and give no warning, and they are removed together with the deprecated schema names. Call the convenience helper that gives the same type instead, such as `type.String({ storage: 'varchar', length: 64 })` for `type.VarChar(64)`, or `type.Varbit(n)` for `type.VarBit(n)`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$])type\.(?:SmallInt|BigIntNumber|Numeric|UnboundedInt|Real|VarChar|VarBit)\('
- id: ts-renamed-postgres-column-helpers
summary: |
The PostgreSQL column helpers `int8Column` and `numericColumn` are renamed to `int8BigIntColumn` and `numericStringColumn`, after the explicit types they give (`Int8BigInt` and `NumericString`). The old names are removed. The contract does not change.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- 'import[ \t]+(?:type[ \t]+)?\{[^}]*(?<![\w$])(?:int8Column|numericColumn)(?![\w$])[^}]*\}[ \t]*from[ \t]*[''"](?:@prisma/orm-postgres/adapter/column-types|@internal/adapter-postgres/column-types)[''"]'
- id: ts-renamed-postgres-type-groupings
summary: |
`@internal/target-postgres/control` exports `postgresExplicitTypes`, every explicit PostgreSQL type, and `postgresScalarTypes`, which holds the explicit types, the convenience types such as `Int` and `String`, and the deprecated aliases. They replace `postgresScalarAuthoringTypes`, `postgresNativeAuthoringTypes` and `postgresScalarAndNativeTypeConstructors`, which are removed.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '(?<![\w$])(?:postgresScalarAuthoringTypes|postgresNativeAuthoringTypes|postgresScalarAndNativeTypeConstructors)(?![\w$])'
- id: pack-declares-extension-unbound
summary: |
A pack that declares a Postgres extension must declare it in the unbound namespace, and no two packs may declare the same extension. A composed pack that declares an extension in a named namespace, such as `public`, is refused when a command loads the configuration, with `CONTRACT.PACK_CONTRIBUTION_INVALID`. Declare it with `extension('<name>', { namespace: unbound })` from the Postgres contract builder, or `namespace unbound { extension <name> {} }` in PSL, re-emit the pack's contract, and plan two migrations: one from the old head to the new one, which has no operations, and one from the empty contract to the new head, which creates the extension with no schema named.
detection:
glob: "**/*.{ts,mts,cts,prisma}"
matches:
- '\bextension\((?![^)]*namespace:\s*unbound\b)[^)]*\)'
- '^(?:(?:"(?:[^"\\\n]|\\.)*"|//[^\n]*(?:\n|$)|[^{}"/]|/(?!/))|\{(?:(?:"(?:[^"\\\n]|\\.)*"|//[^\n]*(?:\n|$)|[^{}"/]|/(?!/))|\{(?:"(?:[^"\\\n]|\\.)*"|//[^\n]*(?:\n|$)|[^{}"/]|/(?!/))*\})*\})*?\bextension\s+[A-Za-z_][\w-]*\s*\{'
- '\bnamespace\s+(?!unbound\b)[A-Za-z_][\w-]*\s*\{(?:(?:"(?:[^"\\\n]|\\.)*"|//[^\n]*(?:\n|$)|[^{}"/]|/(?!/))|\{(?:"(?:[^"\\\n]|\\.)*"|//[^\n]*(?:\n|$)|[^{}"/]|/(?!/))*\})*?\bextension\s+[A-Za-z_][\w-]*\s*\{'
- id: mongo-index-name-required
summary: |
`MongoIndex` and `MongoIndexInput` (from `@prisma/orm-family-mongo/contract`) and `MongoSchemaIndex` and `MongoSchemaIndexOptions` (from `@prisma/orm-family-mongo/schema-ir`) have a required `name`, the name MongoDB stores the index under. `MongoSchemaIndex.id` is the name, where it was the keys. `MONGO_INDEX_OPTION_KEYS` and `indexLookupKey` leave the name out. `defaultMongoIndexName` moved from `@prisma/orm-family-mongo/query-ast/control` to `@prisma/orm-family-mongo/contract`, and `buildIndexOpId(verb, collection, name)` takes the index name in place of the keys.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bnew\s+(?:MongoIndex|MongoSchemaIndex)\s*\('
- '\b(?:MongoIndexInput|MongoSchemaIndexOptions)\b'
- '\b(?:defaultMongoIndexName|buildIndexOpId)\b'
- id: mongo-index-calls-take-name
summary: |
`CreateIndexCall` and `DropIndexCall` (from `@prisma/orm-mongo/target/control`) take a third argument, `IndexCallOptions`: the index's `name` and its `createIndex` options. `schemaIndexToCreateIndexOptions` returns them with the name. `createIndex` and `dropIndex` (from `@prisma/orm-mongo/target/migration`) create and drop the index named `options.name`, or the name after the keys when it is missing. `createIndex` has two prechecks: no index with that name, and none with the same keys and options. `dropIndex` checks the name, keys and options before, and the name after. Labels name the index: `Create index email_1 on users (email:1)`.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bnew\s+(?:CreateIndexCall|DropIndexCall)\s*\('
- '\bschemaIndexToCreateIndexOptions\b'
- id: mongo-build-refusal-index-name-duplicate
summary: |
The MongoDB contract builder's `BuildRefusal` type, from `@prisma/orm-family-mongo/contract-ts/contract-builder`, has the kind `indexNameDuplicate`, for two indexes of one collection with one name. The builder takes every index's name from its `options.name` and throws for an index without one. Call `nameIndexes(definition)`, from the same entry point, before `mergeRedundantIndexes` and the builder, to name each unnamed index after its stored keys, as Prisma 8 PSL and TypeScript do. `mergeRedundantIndexes` now merges only indexes with the same name.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bBuildRefusal\b'
- '\bbuildMongoContractFromDefinition\b'
- '\bmergeRedundantIndexes\b'
- id: mongo-build-refusal-index-duplicate-restored
summary: |
The MongoDB contract builder's `BuildRefusal` type, exported from `@prisma/orm-family-mongo/contract-ts/contract-builder`, has the kind `indexDuplicate` again. The builder no longer merges indexes; it refuses two identical indexes in one collection. To merge identical and redundant indexes before building, as Prisma 8 PSL and TypeScript do, call `mergeRedundantIndexes` from the same entry point.
detection:
glob: "**/*.{ts,mts,cts,tsx}"
matches:
- '\bBuildRefusal\b'
- '\bbuildMongoContractFromDefinition\b'
- id: mongo-execution-context-checks-contract-requirements
summary: |
`createMongoExecutionContext` from `@prisma/orm-mongo/family-runtime` refuses a contract its stack cannot serve: `RUNTIME.CONTRACT_FAMILY_MISMATCH` for a contract of another family, `RUNTIME.CONTRACT_TARGET_MISMATCH` for another target, and `RUNTIME.MISSING_EXTENSION_PACK` for a contract that requires an extension pack the stack does not hold. The MongoDB adapter's runtime descriptor now encodes with the codecs of every extension in the stack, so an extension's codec `toWire` runs on writes.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx}"
matches:
- '\bcreateMongoExecutionContext\b'
- '\bMongoRuntimeExtensionDescriptor\b'
- id: mongo-codecs-declared-once
summary: |
A MongoDB target, adapter or extension descriptor declares each codec once, as a codec descriptor in `types.codecTypes.codecDescriptors`, as a Postgres extension does. The `codecs()` slot that returned a `MongoCodecRegistry` is gone from `MongoStaticContributions`, `MongoRuntimeTargetDescriptor`, `MongoRuntimeAdapterDescriptor` and `MongoRuntimeExtensionDescriptor`. The runtime, the adapter and the migration runner take each codec from its descriptor with `assembleMongoCodecRegistry`. Build a descriptor from a codec with `mongoCodecDescriptor` from `@prisma/orm-family-mongo/codec`.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx}"
matches:
- '\bMongoRuntime(?:Extension|Target|Adapter)Descriptor\b(?:<[^<>]{0,200}>)?\s{0,20}(?:=>?|\{\s{0,20}return)\s{0,20}\(?\s{0,20}\{(?:[^{}]|\{(?:[^{}]|\{(?:[^{}]|\{[^{}]{0,500}\}){0,500}\}){0,500}\}){0,500}?\bcodecs\s{0,20}[:(]'
- '\bfamilyId\s{0,20}:\s{0,20}[''"]mongo[''"](?:[^{}]|\{(?:[^{}]|\{(?:[^{}]|\{[^{}]{0,500}\}){0,500}\}){0,500}\}){0,500}?\bcodecs\s{0,20}[:(]'
- '\bcodecs\s{0,20}[:(](?:[^{}]|\{(?:[^{}]|\{(?:[^{}]|\{[^{}]{0,500}\}){0,500}\}){0,500}\}){0,500}?\bfamilyId\s{0,20}:\s{0,20}[''"]mongo[''"]'
- '(?<![\s\S])(?=[\s\S]*?\b(?:newMongoCodecRegistry|MongoCodecRegistry)\b)[\s\S]*?\{(?:(?!\bfamilyId\s{0,20}:\s{0,20}[''"](?!mongo[''"]))(?:[^{}]|\{(?:[^{}]|\{(?:[^{}]|\{[^{}]{0,500}\}){0,500}\}){0,500}\})){0,500}?(?:\.\.\.(?:(?!\bfamilyId\s{0,20}:\s{0,20}[''"](?!mongo[''"]))(?:[^{}]|\{(?:[^{}]|\{(?:[^{}]|\{[^{}]{0,500}\}){0,500}\}){0,500}\})){0,500}?\bcodecs\s{0,20}(?::\s{0,20}(?:(?:async\s{1,20})?(?:\([^()]{0,200}\)|[\w$]{1,100})\s{0,20}=>|function\b|newMongoCodecRegistry\b(?!\s{0,20}\())|\([^()]{0,200}\)\s{0,20}(?::[^{};]{0,200})?\{(?:[^{}]|\{(?:[^{}]|\{[^{}]{0,500}\}){0,500}\}){0,500}\})|\bcodecs\s{0,20}(?::\s{0,20}(?:(?:async\s{1,20})?(?:\([^()]{0,200}\)|[\w$]{1,100})\s{0,20}=>|function\b|newMongoCodecRegistry\b(?!\s{0,20}\())|\([^()]{0,200}\)\s{0,20}(?::[^{};]{0,200})?\{(?:[^{}]|\{(?:[^{}]|\{[^{}]{0,500}\}){0,500}\}){0,500}\})(?:(?!\bfamilyId\s{0,20}:\s{0,20}[''"](?!mongo[''"]))(?:[^{}]|\{(?:[^{}]|\{(?:[^{}]|\{[^{}]{0,500}\}){0,500}\}){0,500}\})){0,500}?\.\.\.)(?:(?!\bfamilyId\s{0,20}:\s{0,20}[''"](?!mongo[''"]))(?:[^{}]|\{(?:[^{}]|\{(?:[^{}]|\{[^{}]{0,500}\}){0,500}\}){0,500}\})){0,500}?\}'
- id: mongo-standard-codecs-removed
summary: |
`mongoStandardCodecs` and `buildStandardCodecRegistry` are gone from `@prisma/orm-mongo/target/codecs`. The target declares its codecs once, in `mongoCodecDescriptors`. A registry of them is `assembleMongoCodecRegistry([mongoRuntimeTarget])`, with `assembleMongoCodecRegistry` from `@prisma/orm-family-mongo/codec` and the target from `@prisma/orm-mongo/target/runtime`.
detection:
glob: "**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx}"
matches:
- '\b(?:mongoStandardCodecs|buildStandardCodecRegistry)\b'
---
# 8.0.0-rc.22 → 8.0.0-rc.23 — Extension author upgrade instructions
## Order of the steps
No change in this guide comes with a script. Make the changes below in any order, with two exceptions:
1. A pack that declares a Postgres extension declares it in the unbound namespace first, then re-emits its contract, then plans its two new migrations (`pack-declares-extension-unbound`).
2. Code that builds a MongoDB contract from a definition names its indexes, then merges them, then builds (`mongo-build-refusal-index-name-duplicate`, `mongo-build-refusal-index-duplicate-restored`).
## MySQL: `temporal-polyfill` is a peer dependency, and `mysqlCreateNamespace` moved
### `temporal-polyfill-is-a-peer-dependency-of-mysql`
For each Yarn project that `detection` matches, add `"temporal-polyfill": "^1.0.4"` to `devDependencies` in the `package.json` that installs `@prisma/orm-target-mysql` or `@prisma/orm-mysql`, then reinstall. Without it, a test or script that loads the target's control entry fails because Node.js cannot find the package `temporal-polyfill`.
### `mysql-create-namespace-moved-to-target-types`
In each file that `detection` matches, change the module of an import of `mysqlCreateNamespace` or `MysqlDatabase` from the target's control entry to its types entry, keeping the rest of the import:
| Before | After |
| --- | --- |
| `@prisma/orm-target-mysql/target/control` | `@prisma/orm-target-mysql/target/types` |
| `@prisma/orm-mysql/target/control` | `@prisma/orm-mysql/target/types` |
If the import also names other symbols from the control entry, split it into two imports. Nothing else changes: the function and the class are the same.
## SQLite: `temporal-polyfill` is a peer dependency, and `sqliteCreateNamespace` moved
### `temporal-polyfill-is-a-peer-dependency-of-sqlite`
For each Yarn project that `detection` matches, add `"temporal-polyfill": "^1.0.4"` to `devDependencies` in the `package.json` that installs `@prisma/orm-target-sqlite` or `@prisma/orm-sqlite`, then reinstall. Without it, a test or script that loads the target's control entry fails because Node.js cannot find the package `temporal-polyfill`.
### `sqlite-create-namespace-moved-to-target-types`
In each file that `detection` matches, change the module of an import of `sqliteCreateNamespace` or `SqliteUnboundDatabase` from the target's control entry to its types entry, keeping the rest of the import:
| Before | After |
| --- | --- |
| `@prisma/orm-target-sqlite/target/control` | `@prisma/orm-target-sqlite/target/types` |
| `@prisma/orm-sqlite/target/control` | `@prisma/orm-sqlite/target/types` |
If the import also names other symbols from the control entry, split it into two imports. Nothing else changes: the function and the type are the same.
## PostgreSQL's integer, decimal, float and text types are convenience types
On PostgreSQL, `Int`, `BigInt`, `Float`, `Decimal`, `String`, `Boolean` and `Bytes` are now convenience types, as `DateTime` already is. Each is named for what a field means, and its optional `storage` argument (and `as`, for `BigInt`) chooses an explicit type named after the database type. Written bare, each gives the same type as before, so schemas that use these names need no change.
The explicit types took the database's names. Seven old names stay for one release line as deprecated aliases: they give the same codec and the same contract, and `prisma contract emit` and the editor report a warning where one is written.
### `psl-renamed-postgres-scalar-types`
Rewrite each field or `types {}` alias written with an old name, keeping its other arguments and attributes:
| Before | After | Explicit type it gives |
| --- | --- | --- |
| `SmallInt` | `Int(storage: int2)` | `Int2` |
| `BigIntNumber` | `BigInt(as: number)` | `Int8Number` |
| `Numeric` | `Decimal` | `NumericString` |
| `Numeric(p)` | `Decimal(precision: p)` | `NumericString(p)` |
| `Numeric(p, s)` | `Decimal(precision: p, scale: s)` | `NumericString(p, s)` |
| `UnboundedInt` | `BigInt(storage: numeric)` | `NumericBigInt` |
| `Real` | `Float(storage: float4)` | `Float4` |
| `VarChar` | `String(storage: varchar)` | `Varchar` |
| `VarChar(n)` | `String(storage: varchar, length: n)` | `Varchar(n)` |
| `VarBit` | `Varbit` | `Varbit` |
| `VarBit(n)` | `Varbit(n)` | `Varbit(n)` |
`VarBit` has no convenience type, so its replacement is the explicit type. For the others, writing the explicit type in the last column instead is equally correct; it is the same type, and both emit the same contract. The warning names the explicit type because it is the exact replacement for the old name.
```prisma
model Account {
id Int @id
visits Int(storage: int2) // was SmallInt
rank BigInt(as: number) // was BigIntNumber
price Decimal(precision: 10, scale: 2) // was Numeric(10, 2)
code String(storage: varchar, length: 12) // was VarChar(12)
}
```
Rewrite only Prisma 8 schemas whose config uses the PostgreSQL target, including the `contract.prisma` copies in migration folders. The detection cannot tell which target a schema is for, so it also matches SQLite's `BigIntNumber` and MySQL's `VarChar`; a match in a project whose `prisma.config.ts` uses the SQLite or MySQL target needs no change. Update comments and documentation that name the old types the same way. Re-emit the contract with `prisma contract emit`: it is byte for byte the same as before.
### `ts-renamed-postgres-scalar-helpers`
Rewrite only in contracts on PostgreSQL:
```ts
field.column(type.Int({ storage: 'int2' })); // was type.SmallInt()
field.column(type.BigInt({ as: 'number' })); // was type.BigIntNumber()
field.column(type.Decimal({ precision: 10, scale: 2 })); // was type.Numeric(10, 2)
field.column(type.BigInt({ storage: 'numeric' })); // was type.UnboundedInt()
field.column(type.Float({ storage: 'float4' })); // was type.Real()
field.column(type.String({ storage: 'varchar', length: 64 })); // was type.VarChar(64)
field.column(type.Varbit(16)); // was type.VarBit(16)
```
The explicit helpers `type.Int2()`, `type.Int8Number()`, `type.NumericString(10, 2)`, `type.NumericBigInt()`, `type.Float4()` and `type.Varchar(64)` give the same types.
The old helpers still compile, and nothing warns where they are called. They are removed in the same release as the deprecated schema names, so rewrite them now.
### `ts-renamed-postgres-column-helpers`
The detection matches a file that imports `int8Column` or `numericColumn` from `@prisma/orm-postgres/adapter/column-types` or `@internal/adapter-postgres/column-types`. In that file, rename the import and every use of the imported name. Each column helper is named after the explicit type it gives:
```ts
import { int8BigIntColumn, numericStringColumn } from '@prisma/orm-postgres/adapter/column-types';
field.column(int8BigIntColumn); // was int8Column
field.column(numericStringColumn(10, 2)); // was numericColumn(10, 2)
```
### `ts-renamed-postgres-type-groupings`
Replace the removed exports of `@internal/target-postgres/control`:
| Before | After |
| --- | --- |
| `postgresScalarAndNativeTypeConstructors` | `postgresScalarTypes` |
| `postgresScalarAuthoringTypes` | `postgresScalarTypes` |
| `postgresNativeAuthoringTypes` | `postgresExplicitTypes` |
`postgresScalarTypes` holds every name `postgresScalarAuthoringTypes` held, including `Int`, `String`, `Decimal` and the other convenience types. It is not the target's whole `type` namespace: the namespaced `sql.*`, `temporal.*` and `pg.enum`, and the generated-ID aliases such as `UuidString`, are not in it. A test that needs them uses `assembleAuthoringContributions([...]).type`, which already holds every PostgreSQL type; a test that spread a removed export under that namespace can drop the spread.
`postgresExplicitTypes` holds only explicit types. Code that looked up `VarChar`, `Numeric`, `SmallInt` or `Real` in `postgresNativeAuthoringTypes` uses `Varchar`, `NumericString`, `Int2` or `Float4` in it.
After the rewrite, check that a schema that uses `Int` and `String` still reads with the namespace the code builds.
## A pack declares its extension in the unbound namespace
```typescript
import { defineContract, extension, unbound } from '@prisma/orm-postgres/contract-builder';
export const contract = defineContract({
entities: [extension('vector', { namespace: unbound })],
});
```
### `pack-declares-extension-unbound`
1. In the pack's contract source, pass `{ namespace: unbound }` to `extension(...)`, importing `unbound` from `@prisma/orm-postgres/contract-builder`. In a PSL contract, move the `extension` block into `namespace unbound { ... }`.
2. Run `prisma contract emit` in the pack.
3. Plan two migrations. The first goes from the old head to the new one and has no operations, because the extension a database already has is found through the search path. The second goes from the empty contract to the new head and creates the extension without `WITH SCHEMA`, so a new database takes it in one step. Today `prisma migration plan` reads and writes the migrations of a project's own space under `migrations/app/`, while a pack keeps its migrations directly under `migrations/`, so plan through a temporary copy. Issue TML-3473 tracks removing these steps:
1. Copy the pack's migration directories and `migrations/refs/` into a new `migrations/app/` directory.
2. Run `prisma migration plan --from <old head hash> --name <name>`, where the old head hash is the `hash` in `migrations/refs/head.json`. It writes the new migration to `migrations/app/<timestamp>_<name>/` and the new contract snapshot to `migrations/snapshots/<new hash>/`.
3. Run `prisma migration plan --from @empty --name <other name>`. It writes the migration from the empty contract to `migrations/app/<timestamp>_<other name>/`.
4. Move both new directories from `migrations/app/` to `migrations/`, then delete `migrations/app/`.
5. In each moved `migration.ts`, change each import of `../../snapshots/` to `../snapshots/`.
6. Set `hash` in `migrations/refs/head.json` to the new contract hash, the `to` of the new migrations' `migration.json`, and keep its `invariants`.
4. Add both new migrations' `migration.json` and `ops.json` to the migrations the pack's control descriptor lists.
## MongoDB indexes carry their names
Every MongoDB index has a required name, migrations create and drop each index by that name, and the contract builder no longer merges indexes on its own.
### `mongo-index-name-required`
Give every index you construct a name:
```ts
// before
new MongoIndex({ keys: [{ field: 'email', direction: 1 }], unique: true });
new MongoSchemaIndex({ keys: [{ field: 'email', direction: 1 }], unique: true });
// after
new MongoIndex({ name: 'email_1', keys: [{ field: 'email', direction: 1 }], unique: true });
new MongoSchemaIndex({ name: 'email_1', keys: [{ field: 'email', direction: 1 }], unique: true });
```
`defaultMongoIndexName(keys)` gives the name MongoDB gives an index created without one. Import it from `@prisma/orm-family-mongo/contract`:
```ts
// before
import { defaultMongoIndexName } from '@prisma/orm-family-mongo/query-ast/control';
// after
import { defaultMongoIndexName } from '@prisma/orm-family-mongo/contract';
```
Code that looked up an index of the schema IR by `id` with its keys, such as `'email:1'`, looks it up by name. Code that builds an operation id calls `buildIndexOpId('create', 'users', 'email_1')`.
### `mongo-index-calls-take-name`
```ts
// before
new CreateIndexCall('users', keys, { unique: true });
new DropIndexCall('users', keys);
// after
new CreateIndexCall('users', keys, { name: 'email_1', unique: true });
new DropIndexCall('users', keys, { name: 'email_1', unique: true });
```
Give `DropIndexCall` every option the index has, not only its name: its precheck requires an index with that name, those keys and exactly those options. Code that matches an operation's checks by position reads `createIndex`'s content check at `precheck[1]`; `precheck[0]` checks the name.
### `mongo-build-refusal-index-name-duplicate`
Code that switches over every `refusal.kind` handles `indexNameDuplicate`:
```typescript
switch (refusal.kind) {
case 'indexNameDuplicate':
return `${refusal.key.model}: another index of collection ${refusal.collection} is named ${refusal.name}`;
// ...
}
```
Code that calls `buildMongoContractFromDefinition` names its indexes first:
```ts
const named = nameIndexes(definition);
const { definition: merged } = mergeRedundantIndexes(named, codecLookup, dataTypes);
const built = buildMongoContractFromDefinition(merged, codecLookup, dataTypes);
```
A source that reads a schema with its own naming rule, such as the Prisma 6 reader, sets `options.name` on each index itself.
### `mongo-build-refusal-index-duplicate-restored`
Code that switches over every `refusal.kind` must handle `indexDuplicate`:
```typescript
switch (refusal.kind) {
case 'indexDuplicate':
return `${refusal.key.model}: an identical index already exists`;
// ...
}
```
Code that builds a definition with `buildMongoContractFromDefinition` and relied on the builder merging indexes calls `mergeRedundantIndexes(definition, codecLookup, dataTypes)` first and builds its `definition`. Call `nameIndexes` before `mergeRedundantIndexes`, as the example under `mongo-build-refusal-index-name-duplicate` shows.
## MongoDB execution contexts check the contract against the stack
### `mongo-execution-context-checks-contract-requirements`
1. Where code calls `createMongoExecutionContext({ contract, stack })` with a contract that requires extension packs, build the stack with those packs: `createMongoExecutionStack({ target, adapter, extensions: [myPack] })`.
2. A MongoDB extension's codecs now encode values on writes as well as decode them on reads. Check that each codec's `toWire` returns the form the database stores, and that `fromWire` reads that form back.
### `mongo-codecs-declared-once`
Replace the `codecs()` slot of a MongoDB descriptor with one codec descriptor per codec, shared by the pack and the runtime descriptor:
```typescript
import { mongoCodec, mongoCodecDescriptor } from '@prisma/orm-family-mongo/codec';
const shoutCodec = mongoCodec({ typeId: 'shout/text@1', dataType: shoutText, toWire, fromWire, fromDataTypeValue });
const shoutMeta = {
kind: 'extension',
id: 'shout',
familyId: 'mongo',
targetId: 'mongo',
version: '0.0.1',
dataTypes: [shoutText],
types: { codecTypes: { codecDescriptors: [mongoCodecDescriptor(shoutCodec, { traits: ['equality'] })] } },
} as const;
export const shoutRuntime: MongoRuntimeExtensionDescriptor<'mongo'> = {
...shoutMeta,
create: () => ({ familyId: 'mongo', targetId: 'mongo' }),
};
```
1. For each codec the `codecs()` registry held, add `mongoCodecDescriptor(codec, { traits })` to `types.codecTypes.codecDescriptors`, with the traits the pack declared for it. If the pack already lists a hand-written descriptor for the codec, replace it with this one.
2. Delete `codecs` from the descriptor.
3. Make the runtime and control descriptors spread the same metadata as the pack, so all three list the same descriptors and `dataTypes`.
### `mongo-standard-codecs-removed`
1. Replace `buildStandardCodecRegistry()` with `assembleMongoCodecRegistry([mongoRuntimeTarget])`.
2. Replace a read of `mongoStandardCodecs` with the registry: `registry.get(codecId)` for one codec, `[...registry]` for all of them, or with `mongoCodecDescriptors` where only the codec ids or metadata are needed.
upgrading/extension/upgrades/8.0.0-rc.3-to-8.0.0-rc.4/instructions.md
---
from: "8.0.0-rc.3"
to: "8.0.0-rc.4"
changes:
- id: prisma-config-hard-cut-and-top-level-commands
summary: |
The deprecated fallbacks are gone: the CLI no longer reads
`prisma-next.config.ts`, no longer accepts the flat (un-nested) config shape, and the
`prisma-next` command no longer exists. The unified CLI (`@prisma/cli`, installed from
the `next` dist-tag; its binary is currently `prisma-cli`) runs the ORM commands at the
top level — `contract emit`, `db init`, `migration plan`, `migrate` — with only `init`
under the `orm` group (`orm init`), and the only config it reads is `prisma.config.ts`
in the engine envelope shape.
1. Rename `prisma-next.config.ts` to `prisma.config.ts` if you have not already.
2. Rewrite the export to the envelope shape. Old flat shape:
`import { defineConfig } from '@prisma/orm-postgres/config';`
`export default defineConfig({ contract: '…', db: { connection: … } });`
New shape:
`import { definePrismaConfig } from '@prisma/cli-engine';`
`import { defineConfig as ormConfig } from '@prisma/orm-postgres/config';`
`export default definePrismaConfig({ orm: ormConfig({ contract: '…', db: { connection: … } }) });`
The options object moves into the target helper unchanged. The same pattern applies
to `@prisma/orm-sqlite/config` and `@prisma/orm-mongo/config`.
3. If the config reads `process.env`, keep (or add) `import 'dotenv/config';` as the
first import — the loader does not read `.env` for you.
4. In `package.json`, replace the `prisma-next` devDependency with `prisma@latest`
plus `@prisma/cli-engine` at the exact version that `@prisma/cli` names in its own
dependencies, and update your extension's contract-space
build and emit scripts from `prisma-next contract emit` to `prisma-cli contract emit`.
5. Run `prisma-cli contract emit` to confirm the config loads and to regenerate the
artifacts (their generated-file headers change with this release).
detection:
glob: "**/prisma-next.config.ts"
- id: facades-compose-the-raw-lane
summary: |
A facade no longer gets the whole-query raw tag from the builder. `Db<C>` is a pure
namespace map now, so the `raw` key it used to answer is gone, and the tag is composed at
client build instead.
Build it with `createRawLane({ context, rawCodecInferer })` from
`@internal/sql-builder/runtime`, typed `RawLane<TContract>` from
`@internal/sql-builder/types`, and expose it as your client's `raw`. Callers then write
``client.raw.sql`SELECT ...` ``. A client that binds per role or per scope builds one lane
per bound context, the way it already builds one `sql` per context. A static context —
the no-runtime shape that returns `context`, `contract` and `sql` — builds one too and
returns it as `raw`.
If your `raw` property was the contract-free expression tag (`createRawSql(inferer)`), it
changes shape from a callable to `{ sql }`. That breaks your own surface, so note it in
your release.
detection:
glob: "**/*.{ts,mts,cts}"
regex:
- 'createRawSql\('
- 'RawSqlTag'
# `fns.raw` is a fragment call site and is deliberately excluded:
# fragments are unchanged by this release.
- '(?<!(?<![\w$])fns)\.raw`'
anyMatch: true
- id: reserved-raw-namespace-check-removed
summary: |
`sql()` no longer refuses a contract whose storage declares a namespace named `raw`, and
`ORM.NAMESPACE_RESERVED` leaves the error catalogue. Nothing raises the code now, so drop
any branch that matched it: a test asserting the refusal, a doc listing the code, an
error mapping of your own.
detection:
glob: "**/*.{ts,mts,cts,md}"
contains:
- "ORM.NAMESPACE_RESERVED"
anyMatch: true
- id: contract-fixture-restamp
summary: |
Committed contract artifacts (`contract.json` / `contract.d.ts`, including test
fixtures) embed the toolchain version, which moves to 8.0.0-rc.4. Regenerate them
with your emit script (`build:contract-space` or equivalent) after upgrading, or
fixture comparisons fail on the version stamp alone.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.3"'
---
# 8.0.0-rc.3 → 8.0.0-rc.4 — Extension-author upgrade instructions
## `facades-compose-the-raw-lane`
`Db<C>` is a namespace map and nothing else, so a facade composes the whole-query raw tag itself
and exposes it as the raw lane:
```ts
import { createRawLane, sql } from '@internal/sql-builder/runtime';
import type { Db, RawLane } from '@internal/sql-builder/types';
const sqlDb: Db<TContract> = sql<TContract>({ context, rawCodecInferer });
const raw: RawLane<TContract> = createRawLane<TContract>({ context, rawCodecInferer });
```
Callers reach the tag at `client.raw.sql`. A client that binds per role or per scope builds one
lane per bound context, exactly as it already builds one `sql` per context.
A static context does the same. If your facade ships a no-runtime surface — the shape that
returns `context`, `contract`, `sql` and friends without opening a connection — build the lane
there too and return it as `raw`:
```ts
export interface YourStaticContext<TContract extends Contract<SqlStorage>> {
readonly sql: Db<TContract>;
readonly raw: RawLane<TContract>;
// …context, contract, enums
}
const raw: RawLane<TContract> = createRawLane<TContract>({ context, rawCodecInferer });
```
Its `raw` property changes type from the contract-free tag to `RawLane<TContract>`, the same
change the connected client makes, so a consumer reads both surfaces the same way.
Two shapes change for your consumers. Anyone who wrote ``client.sql.raw`...` `` writes
``client.raw.sql`...` ``. Anyone who called `client.raw` as an expression tag calls
`client.raw.sql`...`.returns(codecId)` instead, or `fns.raw` inside a builder callback. Both are
breaking changes to your own surface, so note them in your release.
The detector looks for `createRawSql(`, `RawSqlTag`, and `raw` used as a tag. It skips the
receiver `fns` exactly, including `x.fns.raw`, because that is a fragment call site and needs no
change. A receiver that merely ends in those letters, such as `myfns.raw`, still matches, as
does a functions object aliased to another name.
## `reserved-raw-namespace-check-removed`
`sql()` used to refuse a contract whose storage declared a namespace named `raw`, raising
`ORM.NAMESPACE_RESERVED` at client construction. The check is gone with the constraint it
enforced: the lane is composed by the client, not answered by the namespace map, so no contract
can shadow it.
Drop any branch that matched the code — a test asserting the refusal, an error mapping, a doc
that lists it. The code no longer exists in the catalogue, and nothing raises it.
upgrading/extension/upgrades/8.0.0-rc.4-to-8.0.0-rc.5/instructions.md
---
from: "8.0.0-rc.4"
to: "8.0.0-rc.5"
changes:
- id: wrap-pg-constructions-with-suppress-idle-connection-errors
summary: |
Wrap every pg `Pool` or `Client` your extension constructs with `suppressIdleConnectionErrors`, newly exported from `@internal/driver-postgres/runtime` (canonical home `@internal/utils/suppress-idle-connection-errors`). node-postgres emits 'error' on the pool or client when an idle connection drops; with no listener Node kills the host process. The helper attaches a no-op listener, is idempotent per emitter, and returns the same instance. Bindings handed to the driver (`pgPool`/`pgClient`/`url`) are wrapped by the driver itself since rc.5, so this applies to pg handles your extension uses outside a driver binding.
detection:
glob: "**/*.ts"
contains:
- "new Pool("
- "new Client("
- "new pg.Pool("
- "new pg.Client("
anyMatch: true
- id: distinct-on-requires-postgres-capability
summary: |
`Collection#distinctOn(...)` now requires the contract to declare the `postgres.distinctOn`
capability, mirroring the sql-builder lane's existing gate. A contract without it (e.g.
SQLite-only) makes the call a compile error — the parameter type narrows to `never` — where
it used to compile and silently produce undeduped rows at runtime, since the target's
renderer never rendered `DISTINCT ON` for a target that cannot express it.
Every `.distinctOn(...)` call your code makes on a `Collection` was already wrong on any
target lacking `postgres.distinctOn`; the type error is the fix surfacing at compile time
instead of a silently wrong result set at runtime. Move the call to a contract that
declares `postgres.distinctOn`, or remove it — there is no runtime opt-out.
detection:
glob: "**/*.{ts,mts,cts}"
regex:
- '\.distinctOn\('
anyMatch: true
- id: groupby-pre-group-pagination-now-scopes-rows
summary: |
`take()`, `skip()`, `cursor()`, `distinct()`, `distinctOn()`, and `orderBy()` written before
`.groupBy(...)` on a `Collection` now scope the rows that get grouped, instead of being
silently dropped. `db.orm.<Model>.take(10).groupBy('x').aggregate(...)` used to group every
matching row; it now groups only the first 10 (by whatever `orderBy()` is active).
There is no reliable static pattern that separates a call site whose answer just became
correct from one whose answer is now different from before — both look identical in source.
Any test asserting values on a `.groupBy(...)` chain with a pre-group pagination clause
needs re-checking against the new (correct) numbers by hand.
- id: groupby-post-group-pagination-requires-order-by
summary: |
`GroupedCollection` gained its own `take()` / `skip()` / `orderBy()`, which page the grouped
rows themselves when written *after* `.groupBy(...)`. Post-group `take()` / `skip()` require
a prior post-group `orderBy()` — without one they are a compile error, the parameter type
narrows to `never`, because a database may return groups in any order and "the first n
groups" is undefined without one.
This is not a rote find-and-replace: `db.orm.<Model>.groupBy('x').take(10)` needs a caller
to pick what "first" means for their groups, which is a decision only they can make. Add an
`.orderBy(...)` naming one of the fields passed to `groupBy(...)` before the `take()` /
`skip()` call.
detection:
glob: "**/*.{ts,mts,cts}"
regex:
- '\.groupBy\('
anyMatch: true
---
# 8.0.0-rc.4 → 8.0.0-rc.5 — Extension author upgrade instructions
## `wrap-pg-constructions-with-suppress-idle-connection-errors`
Walk every file matched by `detection.glob`. For each pg `Pool` or `Client` the extension constructs, wrap the construction:
```ts
import { suppressIdleConnectionErrors } from '@internal/driver-postgres/runtime';
const pool = suppressIdleConnectionErrors(
new Pool({ connectionString: options.url }),
);
```
This is the same translation applied to the in-repo `@internal/postgres` and `@internal/extension-supabase` runtimes in this transition. The helper only attaches a no-op `'error'` listener (connect/query failures still reject their own promises), so behavior is otherwise unchanged; without it, a dropped idle connection crashes the process that hosts the extension.
If your extension's test suite fakes the `pg` module, the fakes need an `on` method (`on = vi.fn().mockReturnThis()` on a class fake, or `on: vi.fn()` on an object literal) — the runtime now calls `.on('error', ...)` on every pool, client, and checked-out pool client.
## `distinct-on-requires-postgres-capability`
`Collection#distinctOn(...)` used to compile and run on any target, but only Postgres ever
rendered its `DISTINCT ON` clause — a call on any other target (SQLite) compiled clean and
silently returned undeduped rows at runtime. The method now carries the same capability gate the
sql-builder lane already enforces: its parameter type narrows to `never` unless the contract
declares `postgres.distinctOn`, so the same call is a compile error on a contract that lacks it,
and a runtime error carrying `ORM.CAPABILITY_MISSING` if reached dynamically (e.g. through a
hand-built `CollectionState`).
Find every `.distinctOn(...)` call your code makes on a `Collection` and check whether the
contract it runs against declares `postgres.distinctOn`. If it does, nothing changes — the call
already worked correctly and keeps compiling. If it does not, the call was already producing the
wrong result set; either move the collection onto a Postgres-capable contract, or remove the
`.distinctOn(...)` call and accept the undeduped rows it was silently returning before.
`Collection#distinct(...)` is unaffected — it lowers to a portable `ROW_NUMBER` dedup and needs
no capability, on any target.
## `groupby-pre-group-pagination-now-scopes-rows`
Any `.take(...)`, `.skip(...)`, `.cursor(...)`, `.distinct(...)`, `.distinctOn(...)`, or
`.orderBy(...)` your extension calls *before* `.groupBy(...)` on a `Collection` used to be
silently dropped once `.groupBy(...)` joined the chain — the aggregate reduced over every
matching row, ignoring the pagination clause entirely. It now scopes the rows that get grouped,
the same way root `.aggregate()` scopes its rows (see the sibling entry for that fix, already
shipped in `8.0.0-rc.4` → `8.0.0-rc.5`'s predecessor window).
There is no detection regex for this one worth writing: the call sites that need re-checking
look identical, in source, to the call sites that already worked correctly (a chain built with
this scoping in mind, versus one that assumed the pagination clause was a no-op). Grep for
`.groupBy(` and read every match with a pre-group pagination clause; if the test asserting its
result seeds fewer distinct groups than pagination scope allows, or asserts totals computed over
every row rather than the paginated window, the expected values need updating to match the now-
correct behavior.
## `groupby-post-group-pagination-requires-order-by`
`GroupedCollection` (what `.groupBy(...)` returns) gained `take()`, `skip()`, and `orderBy()`,
which page the *grouped* rows when written after `.groupBy(...)` — previously `.groupBy(...)` had
no chain of its own past `.having(...)`. Calling post-group `take()` or `skip()` without a prior
post-group `orderBy()` is a compile error: the parameter type narrows to `never`, because a
database may return groups in any order and "the first n groups" has no defined meaning without
one.
If your extension's own code (or its test suite) calls `.groupBy(...).take(...)` or
`.groupBy(...).skip(...)` with no `.orderBy(...)` between them, it will fail to compile after this
upgrade. There is no default ordering to insert automatically — add an `.orderBy(...)` naming one
of the fields you passed to `groupBy(...)` (ascending or descending is your call; whichever
matches what "the first n groups" should mean for that query) before the `take()` / `skip()` call.
upgrading/extension/upgrades/8.0.0-rc.5-to-8.0.0-rc.6/instructions.md
---
from: "8.0.0-rc.5"
to: "8.0.0-rc.6"
changes:
- id: postgres-temporal-codec-ids-retired
summary: |
Five PostgreSQL temporal codec ids were removed with no compatibility aliases. Each
native type now has two representation-explicit codecs — one whose application value is a
`Temporal.*`, one that passes PostgreSQL's own text through unchanged:
| Retired | Temporal replacement | Text replacement |
| --- | --- | --- |
| `pg/date@1` | `pg/date-temporal@1` (`Temporal.PlainDate`) | `pg/date-string@1` |
| `pg/timestamp@1` | `pg/timestamp-temporal@1` (`Temporal.PlainDateTime`) | `pg/timestamp-string@1` |
| `pg/timestamptz@1` | `pg/timestamptz-temporal@1` (`Temporal.Instant`) | `pg/timestamptz-string@1` |
| `pg/time@1` | `pg/time-temporal@1` (`Temporal.PlainTime`) | `pg/time-string@1` |
| `sql/timestamp@1` | `pg/timestamptz-temporal@1` | `pg/timestamptz-string@1` |
An extension names these ids in more places than a user does. Sweep all of them:
1. **Column descriptors in an extension contract.** A pack that declares its own tables
(`extensionModel(...)` with `{ codecId, nativeType }` column literals) picks the
representation on its consumers' behalf. Choose the Temporal id where application code
reads the column as a value, and the `*String` id where it should stay text — the
Supabase pack's `auth` tables took the Temporal id for exactly that reason.
2. **`descriptor-meta` registrations, control-plane hooks and aggregate matrices.** Any
table keyed by codec id gains two entries where it had one, or moves its single entry.
A parity or coverage table that enumerates ids needs the four new `*-string@1` ids as
well as the four `*-temporal@1` ones.
3. **Hand-built contracts and test doubles.** Deserializing a contract literal that names
a retired id now fails validation rather than resolving to something plausible.
4. **Introspection maps.** `date`, `timestamp`, `timestamptz` and `time` map to the bare
PSL names (`Date`, `Timestamp(p)`, `Timestamptz(p)`, `Time(p)`), which resolve to the
Temporal codecs. The `*String` names are authoring-only and must claim no
`targetTypes`, or they compete for introspection ownership.
5. **Re-emit any contract your package commits.** `build:contract-space` (or
`prisma contract emit`) rewrites `contract.json` and `contract.d.ts`; commit both.
detection:
glob: "**/*.{ts,mts,cts,json}"
regex:
- "pg/(date|timestamp|timestamptz|time)@1"
- "sql/timestamp@1"
anyMatch: true
- id: temporal-codecs-require-a-global-and-refuse-a-date
summary: |
A Temporal-backed codec reads the application's global `Temporal` implementation. Prisma
neither bundles nor imports a polyfill, and the check is lazy: registering a pack,
validating a contract, resolving a descriptor and constructing a codec instance all
succeed with no `Temporal` in scope. Only invoking one fails, with
`RUNTIME.TEMPORAL_UNAVAILABLE`.
Two consequences for an extension:
1. **Your test suites need the global.** If your package exercises a Temporal-backed
column, install a polyfill in a vitest `setupFiles` entry
(`import 'temporal-polyfill/full/global';`) and add `temporal-polyfill` as a
devDependency. For TypeScript to see the same global, add a `.d.ts` under your test
directory containing `/// <reference types="temporal-polyfill/types/global" />` — the
package's own `temporal-polyfill/global` types resolve to `export {}` and declare
nothing.
2. **Encode is nominally typed now.** These codecs check `Symbol.toStringTag` and refuse
anything that is not their own Temporal type, including a `Date`, with
`RUNTIME.ENCODE_FAILED` naming the codec. If your pack contributes a mutation-default
generator or any other value that lands in a Temporal-backed column, it must produce
the matching `Temporal.*` value — a `Date` no longer slips through to be serialized as
`Date.prototype.toString()`.
detection:
glob: "**/*.{ts,mts,cts}"
regex:
- "pg/(date|timestamp|timestamptz|time)-temporal@1"
anyMatch: true
- id: contract-space-restamp
summary: |
The emitted contract artifacts embed the toolchain version, which moves to 8.0.0-rc.6.
Rebuild the extension's contract space (the package's `build:contract-space` script)
once after upgrading so the emitted artifacts match the installed toolchain. The
toolchain also re-released against `@prisma/cli-engine@0.2.2` (a CLI-side fix with no
extension-facing surface).
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.5"'
---
# 8.0.0-rc.5 → 8.0.0-rc.6 — Extension author upgrade instructions
# PostgreSQL temporal representations, for extension authors
There is no codemod: the retired ids map to *two* replacements each, and which one an extension
should name is a judgement about what its consumers do with the column. Sweep by id, decide per
site, then re-emit any committed contract artifact.
Four behaviours bear on an extension's own codecs. Writes serialize at full precision and let
PostgreSQL round to the column's declared precision, carries included. A Temporal codec rejects
`infinity`, years beyond roughly ±271821, and non-ISO `DateStyle` output, naming the `*String` type
that reads them losslessly. The driver hands temporal OIDs through as server text rather than
building a `Date`. And temporal expressions are cast to `text` before PostgreSQL builds JSON, so a
nested read returns the same text a flat one does.
## `contract-space-restamp`
For every `contract.json` matched by `detection`, run the extension package's `build:contract-space` script (or its emit command) once after upgrading. Beyond the temporal changes above, the only expected diff is the embedded `version` moving to `8.0.0-rc.6`. The toolchain also re-released against `@prisma/cli-engine@0.2.2`; that change has no extension-facing surface.
upgrading/extension/upgrades/8.0.0-rc.6-to-8.0.0-rc.7/instructions.md
---
from: "8.0.0-rc.6"
to: "8.0.0-rc.7"
changes:
- id: rename-orm-pagination-methods
summary: |
Rename ORM collection pagination calls from `.take(n)` to `.limit(n)` and from `.skip(n)` to `.offset(n)`. This applies to SQL and Mongo ORM collections, including relation refinements and grouped SQL collections. Do not rename Mongo query-builder `.skip(n)` calls: that lower-level API continues to mirror the `$skip` pipeline stage.
- id: contract-space-restamp
summary: |
The emitted `contract.json` / `contract.d.ts` embed the toolchain version, which moves
to 8.0.0-rc.7. Rebuild the extension's contract space (the package's `build:contract-space`
script) once after upgrading so the emitted artifacts match the installed toolchain. The toolchain also re-released against
`@prisma/cli-engine@0.2.3` (a CLI-side change with no ORM-facing surface).
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.6"'
---
# 8.0.0-rc.6 → 8.0.0-rc.7 — Extension author upgrade instructions
## `rename-orm-pagination-methods`
Find calls on Prisma 8 ORM collections in extension source and tests, then apply these translations:
- `.take(n)` → `.limit(n)`
- `.skip(n)` → `.offset(n)`
Apply the same translation inside `include(...)` refinement callbacks, `combine(...)` branches, and after SQL ORM `groupBy(...)`. Leave Mongo query-builder chains that start from `mongoQuery(...).from(...)` or an equivalent query-builder factory unchanged: their `.limit(...)` and `.skip(...)` methods name Mongo aggregation pipeline stages rather than the ORM collection API.
## `contract-space-restamp`
For every `contract.json` matched by `detection`, run the extension package's `build:contract-space` script (or its emit command) once after upgrading. The only expected diff beyond the pagination rename is the embedded `version` moving to `8.0.0-rc.7`.
upgrading/extension/upgrades/8.0.0-rc.7-to-8.0.0-rc.8/instructions.md
---
from: "8.0.0-rc.7"
to: "8.0.0-rc.8"
changes:
- id: engine-pin-moves-to-0-3-0
summary: |
The toolchain now peers `@prisma/cli-engine@0.3.0` (up from 0.2.3). An extension that
pins `@prisma/cli-engine` in its own manifests must move the pin to `0.3.0`. The engine
now declares `@prisma/management-api-sdk` as a peer dependency (`^1.55.0`) instead of a
regular dependency; the `prisma` CLI shell supplies it at runtime, so only tooling that
runs the engine outside the CLI shell needs to install the SDK itself.
detection:
glob: "**/package.json"
contains:
- '"@prisma/cli-engine": "0.2.3"'
- id: contract-space-restamp
summary: |
The emitted `contract.json` / `contract.d.ts` embed the toolchain version, which moves
to 8.0.0-rc.8. Rebuild the extension's contract space (the package's `build:contract-space`
script) once after upgrading so the emitted artifacts match the installed toolchain.
detection:
glob: "**/contract.json"
contains:
- '"version": "8.0.0-rc.7"'
---
# 8.0.0-rc.7 → 8.0.0-rc.8 — Extension author upgrade instructions
## `engine-pin-moves-to-0-3-0`
For every `package.json` matched by `detection`, change the `@prisma/cli-engine` version from `0.2.3` to `0.3.0` and reinstall. If the extension's tooling runs the engine outside the unified `prisma` CLI shell (rare), also install `@prisma/management-api-sdk` at a version satisfying `^1.55.0` — the engine now declares it as a peer dependency and no longer bundles it.
## `contract-space-restamp`
For every `contract.json` matched by `detection`, run the extension package's `build:contract-space` script (or its emit command) once after upgrading. The only expected diff is the embedded `version` moving to `8.0.0-rc.8`.
upgrading/extension/upgrades/8.0.0-rc.8-to-8.0.0-rc.9/instructions.md
---
from: "8.0.0-rc.8"
to: "8.0.0-rc.9"
changes:
- id: remove-nested-relations-from-sql-orm-upsert-and-batch-create
summary: |
SQL ORM `upsert({ create })`, `createAll()`, and `createAndCount()` payloads no longer accept nested relation mutation callbacks, which these operations cannot execute. Remove the callbacks and create related records separately, or use ordinary `create()` when the records must be created as one nested relation operation.
- id: namespace-qualify-sql-orm-filter-types
summary: |
SQL ORM reusable filter types now require the domain namespace before the model name: `<Contract, Namespace, Model>`.
- id: add-attributes-to-psl-extension-block-literals
summary: |
`PslExtensionBlock` gained a required `attributes` record (attribute name → `{ args, span }`, the kit-parsed values of the block's `@@` attributes). Every hand-built block node — synthesised blocks in scripts, inference builders, and test fixtures — must set `attributes` next to `blockAttributes` (`{}` when the block carries no attributes).
detection:
glob: "**/*.{ts,tsx}"
contains:
- "blockAttributes:"
anyMatch: true
- id: read-native-enum-map-failures-from-the-kit
summary: |
`PSL_NATIVE_ENUM_INVALID_MAP` no longer exists. A malformed `@@map` on a `native_enum` block — and every other malformed block attribute — is reported at symbol-table time as `PSL_INVALID_ATTRIBUTE_SYNTAX`; only the policy `@@map("")` empty-name rule keeps its own code (`PSL_POLICY_INVALID_MAP`). Replace references to the removed code and assert those diagnostics on the `buildSymbolTable` result rather than on the interpretation result.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "PSL_NATIVE_ENUM_INVALID_MAP"
anyMatch: true
- id: arg-type-parse-is-a-property
summary: |
`ArgType.parse` is now a property function type carrying a `Ctx` parameter, so the ctx an argument type needs is checked contravariantly. A class that implements `ArgType` with a `parse(...)` method, or an object typed against `ArgType<T>` and used inside `blockAttribute()`, must declare `parse` as a function-typed property over the ctx it actually reads.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "implements ArgType"
- "ArgType<"
anyMatch: true
- id: state-attribute-spec-contexts-explicitly
summary: |
The attribute-spec interpret contexts were reshaped. `BlockInterpretCtx` and `InterpretCtx` are gone, replaced by `AttributeCtx` (`sourceId` + `sourceFile`), `ModelAttributeCtx` (adds `selfModel`), and `FieldAttributeCtx` (adds a required `field` and `resolveReferencedModel()`). Contexts no longer carry `level`. `ArgType`, `OptionalArgType`, `Param`, `PositionalParam`, and `AttributeSpec` lost their default type arguments, so every use site must name its context. `fieldRef('self')` / `fieldRef('referenced')` became `fieldRef()` / `referencedFieldRef()`, and `FieldRefScope`, `FieldRefArgType`, and the `scope` property are removed. `oneOf` is one generic signature over a single context shared by every alternative, so a mixed alternation must be given that context by an annotation or a contextual type.
detection:
glob: "**/*.{ts,tsx}"
contains:
- "InterpretCtx"
- "fieldRef("
- "FieldRefScope"
- "FieldRefArgType"
- "ArgType<"
- "AttributeSpec<"
- "PositionalParam"
anyMatch: true
---
# 8.0.0-rc.8 → 8.0.0-rc.9 — Extension author upgrade instructions
## `remove-nested-relations-from-sql-orm-upsert-and-batch-create`
Find SQL ORM calls to `upsert()`, `createAll()`, and `createAndCount()` whose create payloads contain relation fields assigned callback functions. Remove those callbacks and create the related records separately. When the operation requires nested relation creation, replace it with ordinary `create()`, which continues to accept and execute relation mutation callbacks.
## `namespace-qualify-sql-orm-filter-types`
Find TypeScript references to `ShorthandWhereFilter`, `RelationPredicate`, `RelationPredicateInput`, and `RelationFilterAccessor`. Add the model's domain namespace as the second generic argument and place the model name third. Rewrite `ShorthandWhereFilter<Contract, Model>` as `ShorthandWhereFilter<Contract, Namespace, Model>` and `ShorthandWhereFilter<Contract, Model, Namespace>` as `ShorthandWhereFilter<Contract, Namespace, Model>`. Rewrite the relation types from `<Contract, Model>` to `<Contract, Namespace, Model>`. For predicates targeting a relation, use the namespace declared by that relation's `to.namespace` coordinate.
## `add-attributes-to-psl-extension-block-literals`
Find every object literal typed as `PslExtensionBlock` (they carry `kind`, `keyword`, `name`, `parameters`, `blockAttributes`, `span`). Add `attributes` beside `blockAttributes`. A block with no `@@` attributes takes `attributes: {}`. A block synthesised with a `blockAttributes` entry takes the parsed shape of that entry, keyed by attribute name with the spec's positional keys as `args` — for example a synthesised `@@map("x")` on a `policy_*` or `native_enum` block becomes `attributes: { map: { args: { name: 'x' }, span } }`. Consumers that read a block attribute read `block.attributes[name]?.args`, never `block.blockAttributes`.
## `read-native-enum-map-failures-from-the-kit`
Delete every reference to `PSL_NATIVE_ENUM_INVALID_MAP`. Where a test asserted that code after interpreting a document, parse the document and assert `PSL_INVALID_ATTRIBUTE_SYNTAX` on the diagnostics `buildSymbolTable` returns instead; interpretation no longer sees a malformed block attribute. A `@@map(foo)` argument reports `Expected a string literal`; a missing argument reports `Attribute "map" is missing required argument "name"`.
## `arg-type-parse-is-a-property`
Find classes declaring `implements ArgType<…>` with a `parse(arg, ctx)` method and object literals typed against `ArgType<T>`. Declare `parse` as a property whose type is `(arg: ExpressionAst, ctx: Ctx) => Result<T, readonly PslDiagnostic[]>`. Pick the narrowest `Ctx` the implementation actually reads, as the next entry describes. Dispatch on the syntax node with `XAst.cast(arg.syntax)` rather than `arg instanceof XAst` so the argument type keeps working when the spec and the parser come from different module copies.
## `state-attribute-spec-contexts-explicitly`
**Rename the context types.** `BlockInterpretCtx` becomes `AttributeCtx`. `InterpretCtx` splits: use `ModelAttributeCtx` where the code reads `selfModel` and nothing else, and `FieldAttributeCtx` where it reads `field` or `resolveReferencedModel()`. Both are exported from `@internal/psl-parser`.
**Drop `level` from every context value.** A hand-built ctx object that set `level: 'field' | 'model' | 'block'` must delete that property; contexts no longer declare it. `AttributeSpec.level` is a different field and is unchanged — keep setting and reading it.
**Move `resolveReferencedModel` down to the field level.** A model-level ctx must no longer supply it. The `resolveReferencedModel: () => undefined` stub that model-level ctx builders carried is now a type error; delete it. A field-level ctx must supply both `field` (previously optional, now required) and `resolveReferencedModel()`.
**Name a context at every use site.** `ArgType<T>`, `OptionalArgType<T>`, `Param<T>`, `PositionalParam<T>`, and `AttributeSpec<Out>` no longer default their second type argument. Rewrite each as `ArgType<T, AttributeCtx>` when the combinator reads only `sourceId` / `sourceFile`, `ArgType<T, ModelAttributeCtx>` when it reads `selfModel`, and `ArgType<T, FieldAttributeCtx>` when it reads `field` or `resolveReferencedModel()`; the same choice applies to the other four. `PositionalParam` also lost its `T = unknown` default, so a bare `PositionalParam` becomes `PositionalParam<unknown, Ctx>`. Prefer the widest context that still typechecks: a spec parameter over `AttributeCtx` is usable inside `blockAttribute()`, `modelAttribute()`, and `fieldAttribute()` alike.
**Split the field reference combinator.** Replace `fieldRef('self')` with `fieldRef()` and `fieldRef('referenced')` with `referencedFieldRef()` (imported from `@internal/psl-parser`). `fieldRef()` is typed over `ModelAttributeCtx` and stays usable in model attributes such as `@@index`; `referencedFieldRef()` is typed over `FieldAttributeCtx` and is accepted only in field attributes. The `FieldRefScope` and `FieldRefArgType` types and the `scope` property on the returned combinator are removed — a test asserting `fieldRef('self').scope` has no replacement; assert on the parse behaviour or on `label` instead.
**Give `oneOf` one context for all of its alternatives.** `oneOf` is now a single generic signature: the output is the union of the alternatives' outputs, and every alternative parses over the same context. An alternation whose alternatives all read only `sourceId` / `sourceFile` — `str()`, `num()`, `bool()`, `identifier()`, `json()`, `entityRef()`, `funcCall()` — needs nothing; its context is `AttributeCtx` and it stays usable at every level. An alternation that mixes those with a model-scoped or field-scoped alternative such as `fieldRef()` must be told which context it parses over, because `oneOf` no longer computes one from the alternatives. Supply it from the surrounding code: annotate the result (`const arm: ArgType<string, ModelAttributeCtx> = oneOf(str(), fieldRef())`), annotate the alternatives tuple before spreading it into `oneOf(...arms)`, or let the enclosing function's return type provide it. Such an alternation remains rejected inside `blockAttribute()`.
upgrading/extension/upgrades/8.0.0-rc.9-to-8.0.0-rc.10/instructions.md
---
from: "8.0.0-rc.9"
to: "8.0.0-rc.10"
# sql-orm-client doc-comment sweep: reviewed, no entry required
# postgres shell dependency ownership: reviewed, no extension-author action required; bundled packages now declare the catalog Node/pg type dependencies that public shell manifests mirror
changes:
- id: schema-header-use-prisma-8
summary: |
The schema header that marks a Prisma 8 schema is now `// use prisma-8`. The language server
still serves the old header and its Format action rewrites it; new schemas and the
inferred-schema printer write the new form. Replace `// use prisma-next`
at the top of every `.prisma` file the extension ships or tests against.
detection:
glob: "**/*.prisma"
contains:
- "// use prisma-next"
- id: env-vars-drop-next-infix
summary: |
The CLI environment variables lost their `NEXT_` infix: `PRISMA_NEXT_DISABLE_TELEMETRY`,
`PRISMA_NEXT_TELEMETRY_ENDPOINT`, `PRISMA_NEXT_DEBUG`, and the rest are now
`PRISMA_DISABLE_TELEMETRY`, `PRISMA_TELEMETRY_ENDPOINT`, `PRISMA_DEBUG`, and so on. The old
`PRISMA_NEXT_DISABLE_TELEMETRY` opt-out is still honoured; the others are not. Rename them
in the extension's test setup and CI configuration.
detection:
glob: "**/*"
contains:
- "PRISMA_NEXT_"
- id: to-one-relations-record-nullable
summary: |
`ContractNonJunctionRelation`'s `'1:1'` and `'N:1'` members now require `nullable: boolean`,
and contract validation rejects a `contract.json` whose to-one relations lack it. Set
`nullable` on every to-one relation the extension constructs, and rebuild the extension's
contract space so its emitted `contract.json` / `contract.d.ts` carry the flag.
detection:
glob: "**/*.ts"
matches:
- '(?<!\bnullable\b(?:[^{}]|\{[^{}]*\})*)(?:(?<=\bon\s*:(?:[^{}]|\{[^{}]*\})*)|(?=(?:[^{}]|\{[^{}]*\})*\bon\s*:))\bcardinality:\s*[''"](?:N:1|1:1)[''"](?!(?:[^{}]|\{[^{}]*\})*\bnullable\b)'
- id: contract-space-re-emit-nullable
summary: |
The extension's emitted `contract.json` must carry `nullable` on every `1:1` and `N:1`
relation. Rebuild the contract space (the package's `build:contract-space` script) once
after upgrading.
detection:
glob: "**/contract.json"
matches:
- '"cardinality":\s*"(?:N:1|1:1)",\s*"on":'
---
## `to-one-relations-record-nullable`
For every TypeScript file matched by `detection`, find each object literal that builds a to-one contract relation (`cardinality: 'N:1'` or `'1:1'` together with an `on` join) and add `nullable: <boolean>` to it: `true` when the relation field is optional (the local foreign-key columns are nullable), `false` when it is required. The side of a one-to-one relation that does not own the foreign key is always `nullable: true`. Contract builders and PSL authoring set the flag from the field's `?`, so only code that assembles `ContractRelation` values by hand needs the edit.
## `contract-space-re-emit-nullable`
For every `contract.json` matched by `detection`, run the extension package's `build:contract-space` script (or its emit command) once after upgrading. The expected diff is one `"nullable"` boolean per to-one relation in `contract.json`, plus the `Models` namespace, `models` constant, and `RelationKeys` import in `contract.d.ts`.
## `schema-header-use-prisma-8`
For every `.prisma` file matched by `detection`, replace the first-line header `// use prisma-next` with `// use prisma-8`. Nothing else in the file changes.
## `env-vars-drop-next-infix`
For every file matched by `detection`, replace the `PRISMA_NEXT_` prefix with `PRISMA_` on each environment variable name.