
Prisma Next Upgrade
- 739 installs
- 418 repo stars
- Updated August 3, 2026
- prisma/prisma-next
>-.
About
>-. This skill upgrades a project that **consumes** Prisma Next via the public package API (`@prisma-next/postgres`, `@prisma-next/mongo`, the contract files in `prisma/`, etc.). If the project is itself a Prisma Next *extension*, use the `prisma-next-extension-upgrade` skill instead — or both, if the repo contains both an app and an extension package.
- # Upgrade Prisma Next (user app)
- ## Step 0 — Ensure the skill is up to date
- ## Pre-flight — extension compatibility
- **Compute the lowest pinned version across all extensions.** That is the highest Prisma Next version reachable by this a
- (a) Wait for the lagging extension to publish a compatible release, then re-run.
Prisma Next Upgrade by the numbers
- 739 all-time installs (skills.sh)
- +176 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #566 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
prisma-next-upgrade capabilities & compatibility
- Capabilities
- # upgrade prisma next (user app) · ## step 0 — ensure the skill is up to date · ## pre flight — extension compatibility · **compute the lowest pinned version across all e
- Use cases
- documentation
What prisma-next-upgrade says it does
>-
npx skills add https://github.com/prisma/prisma-next --skill prisma-next-upgradeAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 739 |
|---|---|
| repo stars | ★ 418 |
| Last updated | August 3, 2026 |
| Repository | prisma/prisma-next ↗ |
How do I apply prisma-next-upgrade using the workflow in its SKILL.md?
>-
Who is it for?
Developers following the prisma-next-upgrade skill for the tasks it documents.
Skip if: Tasks outside the prisma-next-upgrade scope described in SKILL.md.
When should I use this skill?
User mentions prisma-next-upgrade or related triggers from the skill description.
What you get
Working prisma-next-upgrade setup aligned with the documented patterns and constraints.
Files
Upgrade Prisma Next (user app)
This skill upgrades a project that consumes Prisma Next via the public package API (@prisma-next/postgres, @prisma-next/mongo, the contract files in prisma/, etc.). If the project is itself a Prisma Next extension, use the prisma-next-extension-upgrade skill instead — or both, if the repo contains both an app and an extension package.
Step 0 — Ensure the skill is up to date
Before anything else, ensure this skill is installed at @latest and reload it. Bug fixes to old per-transition upgrade instructions ship in the latest skill release as part of its cumulative set; running against a stale skill can apply a known-broken translation.
If the agent runtime supports an in-session refresh, perform it now. Otherwise, exit and ask the user to re-install (pnpm dlx skills add prisma/prisma-next/skills/upgrade --all), then re-invoke. The upgrade-skill subpath is intentionally unpinned (always main) — the cumulative instruction set is the source of truth, and the latest release fixes apply to every prior transition.
Pre-flight — extension compatibility
Before changing any code, refuse to upgrade past any installed extension's pinned Prisma Next version. Extensions in Prisma Next pin every @prisma-next/* 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-next.config.ts` (or its TS-discoverable equivalent at the project root) and enumerate the list of extension packages it imports. Each extensionPacks: [...] 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 any @prisma-next/* entry under dependencies, peerDependencies, or optionalDependencies. By construction those entries are exact-version pins (e.g. "0.7.0"), set when the extension author last ran their own upgrade. 3. Compute the lowest pinned version across all extensions. That is the highest Prisma Next 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-next.config.ts is absent or names no extensions, skip the pre-flight.
Role detection
This skill applies when the project consumes Prisma Next:
package.jsondeclares one or more@prisma-next/*packages underdependencies/devDependencies, and- the package is not itself an extension (no
@prisma-next/contract(or other SPI) underdependencies/peerDependencies; name does not match^@.*/extension-; not referenced from a sibling app'sprisma-next.config.ts).
If the project also matches the extension-author role, install the prisma-next-extension-upgrade skill (pnpm dlx skills add prisma/prisma-next/skills/extension-author --all) and run this flow first, then that one in the same session. If detection is ambiguous, ask the user.
Version detection
- From-version. Read the currently-installed Prisma Next version from
pnpm-lock.yaml(orpackage-lock.json/yarn.lock) by inspecting the resolved version of any@prisma-next/*package. If the lockfile shows multiple@prisma-next/*packages at different minors (already broken), the lowest minor is the from-version. - To-version. Either the version the user specified, or the latest stable from
npm view @prisma-next/postgres dist-tags.latest.
Report both back to the user before continuing.
Transition chain
If the from-to delta spans multiple minor versions (e.g. 0.6 → 0.8), build the chain of one-minor steps:
0.6 → 0.7 → 0.8Apply 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 `@prisma-next/ deps.** Rewrite every @prisma-next/* 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. The upgrade skill itself is delivered through pnpm dlx skills add and lives under .agents/skills/prisma-next-upgrade/ (or the equivalent CLI-managed directory) — there is no @prisma-next/upgrade-skill` npm entry 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. Load upgrades/<from>-to-<to>/instructions.md from this skill package. Parse the YAML frontmatter and pay particular attention to its changes[] array.
4. Apply each change. For each entry in changes[]:
- If the entry has a
detectionblock (glob + content predicate), run it; skip the change if no files match. Nodetection→ apply unconditionally. - If the entry names a
script:(relative path next toinstructions.md), invoke it from the project root:*.tsviapnpm exec tsx <path>,*.shviabash <path>, codemods per the script's own prose. Noscript→ 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:
chore: upgrade @prisma-next/* 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.
prisma-next-upgrade
An agent skill that upgrades a project consuming Prisma Next from one minor version to the next. The skill carries the per-step bump-install-instructions-validate-commit flow plus the cumulative set of per-transition upgrade instructions (one directory per (from-minor, to-minor) pair).
Audience
This skill is for users of Prisma Next — projects that depend on the public package API (@prisma-next/postgres, @prisma-next/mongo, the contract files in prisma/, etc.).
If you are an extension author, install the `prisma-next-extension-upgrade` skill instead. If your repo contains both an app and an extension, install both.
Installation
pnpm dlx skills add prisma/prisma-next/skills/upgrade --all--all skips the per-agent selection prompt and installs to every agent runtime the skills CLI detects on this machine. For a single-agent install, swap --all for -a <agent> (e.g. -a claude-code).
The upgrade-skill subpath is intentionally unpinned (always tracks main). Bug fixes to older per-transition upgrade instructions ship as part of the cumulative latest skill content; pinning to an older revision can apply a known-broken translation. This is the only Prisma Next skill cluster that is unpinned by design — the user-facing usage skills under skills/* install pinned to the project's installed Prisma Next version (see `prisma-next init` for the canonical wiring).
Usage
Once installed, an agent in your project picks up the skill from a prompt like:
Please upgrade Prisma Next to the latest version.The agent reads SKILL.md, detects the current and target versions, applies one transition at a time, and commits each transition step separately.
What the skill does
See `SKILL.md` for the full flow. In short:
1. Ensure the skill itself is at @latest. 2. Pre-flight: refuse to upgrade past any installed extension's pin. 3. Detect from-version (from the lockfile) and to-version (user-supplied or npm latest). 4. Build the transition chain (one minor at a time). 5. For each step: bump deps to the exact next minor, pnpm install, apply the per-transition upgrade instructions, run typecheck + tests, commit. 6. Halt at the first failed step with a structured error.
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:
await runtime.execute(db.sql.user.insert({ email: 'alice@example.com' }).build());Starting at 0.11:
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:
// 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.
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'. TherequireMarker: truesemantics (throw on absent marker) is removed; if you need fail-fast verification, use thedb-verifyCLI 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 CLIdb-verifycommand 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 firstexecute()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
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
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 @prisma-next/sql-runtime exports; replaced by VerifyMarkerOption = 'onFirstUse' | false. Any consumer code that imports RuntimeVerifyOptions will fail to compile after the bump.
-import type { RuntimeVerifyOptions } from '@prisma-next/sql-runtime';
+import type { VerifyMarkerOption } from '@prisma-next/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 everydefineContractcall site. - Emitted artefacts: the regenerated
contract.json/contract.d.tswill pick up the contributor-declared capabilities. In the 0.12 line, two new capability keys land automatically —postgres.distinctOnandsql.lateral— when the matching adapter / target component is in the contract's component graph.
Before 0.12
import { defineContract } from '@prisma-next/postgres/contract-builder';
import { pgvector } from '@prisma-next/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
import { defineContract } from '@prisma-next/postgres/contract-builder';
import { pgvector } from '@prisma-next/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:
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:
pnpm exec tsx ./strip-migration-labels-hints.tsIt 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:
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.jsonis required. - Non-variant models need an `objectId` `_id`. The new interpret-time rule
PSL_MONGO_ID_REQUIREDrejects any non-variant Mongo model whose_idfield does not resolve toobjectId. Fix the PSL or TS contract source first — for example, ensure@idis present and typed as MongoDB's defaultObjectId— then re-emit.
Re-emit your Mongo contracts
Run the colocated script from your project root:
pnpm exec tsx ./re-emit-closed-mongo-contracts.tsIt finds every directory with a prisma-next.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:
pnpm exec tsx ./re-emit-closed-mongo-contracts.ts --checkApply 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:
pnpm prisma-next db update --plan-only
# or: prisma-next migration planThen apply with explicit confirmation:
pnpm prisma-next db update -yWire -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:
pnpm exec tsx ./re-emit-postgres-public-default.tsIt 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:
pnpm exec tsx ./re-emit-postgres-public-default.ts --checkAfter 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:
pnpm exec tsx ./re-emit-domain-namespaced-contracts.tsIt 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:
pnpm exec tsx ./re-emit-domain-namespaced-contracts.ts --checkIf 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). 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:
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:
-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 @prisma-next/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.
/**
* 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-next.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-next.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);
/**
* 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-next.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-next.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-next.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);
/**
* 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-next.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-next.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-next.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);
/**
* 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
* `@prisma-next/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
* `@prisma-next/*` 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 `@prisma-next/framework-components` `canonicalizeJson`
// (sortKeys + JSON.stringify) and the migration-tools `computeMigrationHash`.
// Kept inline so the script has no `@prisma-next/*` 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);
<!-- 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 @prisma-next/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-next-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: @prisma-next/sqlite gained an additive facade transaction API (db.transaction(async (tx) => …)) demonstrated in examples/prisma-next-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 @prisma-next/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 @prisma-next/sqlite/migration directly, so you do not need an additional import.
Before 0.13
import { Migration, MigrationCLI, createTable, col, primaryKey } from '@prisma-next/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
import { Migration, MigrationCLI, col, primaryKey } from '@prisma-next/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 @prisma-next/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:
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:
pnpm exec tsx ./re-emit-mti-variant-link-columns.tsIt walks the project for prisma-next.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:
pnpm exec tsx ./re-emit-mti-variant-link-columns.ts --checkThe 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:
prisma-next migration plan --name mti-variant-link-columns
prisma-next migrateThe 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:
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:
// 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.Uuidentry understoragefor the named-type alias. - The
userIdcolumn withtypeRef: "Uuid"on the storage table. - A cross-space
foreignKeyentry 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
prisma-next contract emitMigrate your database
prisma-next migration plan --name storage-namespace-envelope
prisma-next migrateThe 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:
PRISMA_NEXT_DISABLE_TELEMETRY=1
# or
DO_NOT_TRACK=1Either variable takes effect immediately — no config file change needed. See Telemetry for what is collected and how to opt out permanently.
/**
* 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-next.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-next.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-next.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);
<!-- 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-next-demo, prisma-next-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-next-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 @prisma-next/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):
// 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:
// 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) → nowSqlRuntimeBase(abstract family base)PostgresRuntime(previously a class) → now an interface (the type to depend on); the concrete class isPostgresRuntimeImplSqliteRuntime(previously a class) → now an interface (the type to depend on); the concrete class isSqliteRuntimeImpl
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:
// Before — referencing the class as a type
import { PostgresRuntime } from '@prisma-next/postgres/runtime';
function takesRuntime(r: PostgresRuntime) { ... }
// After — use the interface (same import path)
import type { PostgresRuntime } from '@prisma-next/postgres/runtime';
function takesRuntime(r: PostgresRuntime) { ... }
// Before — subclassing
import { PostgresRuntime } from '@prisma-next/postgres/runtime';
class MyRuntime extends PostgresRuntime { ... }
// After — subclass the Impl
import { PostgresRuntimeImpl } from '@prisma-next/postgres/runtime';
class MyRuntime extends PostgresRuntimeImpl { ... }create-runtime-removed
createRuntime is removed from @prisma-next/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:
// Before
import { createRuntime } from '@prisma-next/sql-runtime';
const runtime = createRuntime({ stackInstance, context, driver, ...opts });
// After — use the target factory (recommended for app code)
import { postgres } from '@prisma-next/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 '@prisma-next/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 @prisma-next/postgres/migration (and the deprecated @prisma-next/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:
// Before
import { addForeignKey, createIndex, dropColumn } from '@prisma-next/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 '@prisma-next/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:
pnpm exec tsx node_modules/.skills/prisma-next-upgrade/upgrades/0.13-to-0.14/migration-op-factories-to-methods.tspostgres-contract-serializer
SqlContractSerializer (from @prisma-next/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:
// Before
import { SqlContractSerializer } from '@prisma-next/family-sql/ir';
const contract = new SqlContractSerializer().deserializeContract(contractJson) as Contract;
// After
import { PostgresContractSerializer } from '@prisma-next/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
// 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")becomesmember = "dbvalue"—@mapon enum members is removed; the member value is the mapping. - If your schema uses the transitional
enum2keyword (added in 0.13), renameenum2→enum. Nothing else changes — that block shape is exactly whatenumnow means.
If you author contracts in TypeScript instead of PSL: the native enumType(name, values[]) and enumColumn(...) helpers from @prisma-next/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:
import { defineContract, enumType, member } from '@prisma-next/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 Next repo: examples/prisma-next-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:
// 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. -->
<!-- #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-next-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-next-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. -->
/**
* 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`);
<!-- 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. -->
No user-side migration actions are required for this transition at this time. changes: [] intentionally marks this transition as a no-op.
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
--checkflag 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).
Related skills
FAQ
What does prisma-next-upgrade do?
>-
When should I use prisma-next-upgrade?
Invoke when >-.
Is prisma-next-upgrade safe to install?
Review the Security Audits panel on this page before installing in production.