
Prisma Next Extension Upgrade
- 723 installs
- 418 repo stars
- Updated August 3, 2026
- prisma/prisma-next
>-.
About
>-. This skill upgrades a project that **is** a Prisma Next extension — a package that consumes the framework SPI (`@prisma-next/contract`, `@prisma-next/framework-components`, `@prisma-next/migration-tools`, etc.) and exposes contract / middleware / codec / migration surfaces that downstream apps install via `prisma-next.config.ts`.
- # Upgrade Prisma Next (extension)
- ## Step 0 — Ensure the skill is up to date
- Concretely: if the agent runtime supports an in-session refresh, perform it now. Otherwise, exit and ask the user to re-
- pnpm dlx skills add prisma/prisma-next/skills/extension-author --all
- Then re-invoke this skill before proceeding.
Prisma Next Extension Upgrade by the numbers
- 723 all-time installs (skills.sh)
- +171 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #567 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
prisma-next-extension-upgrade capabilities & compatibility
- Capabilities
- # upgrade prisma next (extension) · ## step 0 — ensure the skill is up to date · concretely: if the agent runtime supports an in · pnpm dlx skills add prisma/prisma next/skills/ex
- Use cases
- documentation
What prisma-next-extension-upgrade says it does
>-
npx skills add https://github.com/prisma/prisma-next --skill prisma-next-extension-upgradeAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 723 |
|---|---|
| repo stars | ★ 418 |
| Last updated | August 3, 2026 |
| Repository | prisma/prisma-next ↗ |
How do I apply prisma-next-extension-upgrade using the workflow in its SKILL.md?
>-
Who is it for?
Developers following the prisma-next-extension-upgrade skill for the tasks it documents.
Skip if: Tasks outside the prisma-next-extension-upgrade scope described in SKILL.md.
When should I use this skill?
User mentions prisma-next-extension-upgrade or related triggers from the skill description.
What you get
Working prisma-next-extension-upgrade setup aligned with the documented patterns and constraints.
Files
Upgrade Prisma Next (extension)
This skill upgrades a project that is a Prisma Next extension — a package that consumes the framework SPI (@prisma-next/contract, @prisma-next/framework-components, @prisma-next/migration-tools, etc.) and exposes contract / middleware / codec / migration surfaces that downstream apps install via prisma-next.config.ts.
If the project you are upgrading is a consumer app (it imports @prisma-next/postgres or @prisma-next/mongo from its application code), use the prisma-next-upgrade skill instead — or both, if the repo contains both a consumer app and an extension package, in which case run the user flow first then the extension flow in the same session.
Step 0 — Ensure the skill is up to date
Before doing 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.
Concretely: 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/extension-author --allThe extension-author 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.
Then re-invoke this skill before proceeding.
Role detection
This skill applies when the project is a Prisma Next extension. Heuristics:
package.jsondeclares@prisma-next/contract(or another SPI package) underdependenciesorpeerDependencies, and- the package's
namematches^@.*/extension-(the in-tree convention used by@prisma-next/extension-pgvector, etc.), or - the package is referenced as an
extensionPacksentry from a sibling app'sprisma-next.config.tsin the same monorepo.
If the project additionally consumes Prisma Next from its own app code, install the prisma-next-upgrade skill (pnpm dlx skills add prisma/prisma-next/skills/upgrade --all) and run the user flow first, then this flow in the same session.
If detection is ambiguous, ask the user which role to operate under.
Version detection
- From-version. Read the currently-installed Prisma Next version from
pnpm-lock.yaml(orpackage-lock.json/yarn.lock) by inspecting the resolved version of any@prisma-next/*entry. If the lockfile shows multiple@prisma-next/*packages at different minors, the lowest minor is the from-version. - To-version. Either the version the user specified, or the latest stable from
npm view @prisma-next/contract 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, check pins, validate, commit — before moving to the next. Halt the chain on the first failed step.
Per-step flow
This flow assumes you are an external extension author — your extension lives in its own repo and consumes @prisma-next/* from npm. (Extensions inside the prisma/prisma-next monorepo itself are bumped via pnpm bump-minor / scripts/set-version.ts, which rewrites every workspace:<X.Y.Z> spec in lockstep with the root version; they do not run this skill.)
For each (from, to) step in the chain:
1. *Bump `@prisma-next/ deps.** Rewrite every @prisma-next/* entry in the extension's package.json to the exact <to> version (e.g. "0.8.0" — no caret, no tilde, no range, no workspace: specifier; the exact-pin rule below details why). All entries advance to the same version. Cover whichever dep field(s) the extension uses today — dependencies and/or peerDependencies — and any optionalDependencies. The extension-upgrade skill itself ships via pnpm dlx skills add (see Step 0); there is no @prisma-next/extension-upgrade-skill npm entry to bump. The companion CLI tool is @prisma-next/extension-author-tools` — leave its pin at the version the extension's CI is currently using; bumping it is independent of the framework upgrade and is normally a no-op.
2. Install. Run pnpm install (or the project's lockfile-managing command). The extension's source is now broken against the new SPI — the upgrade instructions for <from> → <to> exist to fix it.
3. Check pins. Run pnpm exec prisma-next-check-pins (shipped by @prisma-next/extension-author-tools). This sanity check asserts that every @prisma-next/* entry across dependencies, peerDependencies, and optionalDependencies is a single exact-version string and that all entries share the same version. If the check fails, the bump step did not rewrite every spec — fix the offending entries and re-run before proceeding.
4. 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.
5. Apply each change. For each entry in changes[]:
- If the entry has a
detectionblock (a glob + content predicate), run it. If no files match, skip this change. - If the entry has no
detection, apply unconditionally. - If the entry names a
script:(a relative path next toinstructions.md), invoke it from the project root: *.ts→pnpm exec tsx <skill>/upgrades/<from>-to-<to>/<script>*.sh→bash <skill>/upgrades/<from>-to-<to>/<script>- codemods → invoke per the script's own
instructions.mdprose. - If the entry has no
script, follow the prose body ininstructions.mddirectly.
If changes[] is empty (the placeholder shape for transitions with no extension-side breaking changes), this sub-step is a no-op — proceed to validation.
6. Validate. Run pnpm build && pnpm test (or the project's equivalent — the scripts field of the extension's package.json is the discovery surface). If anything is red, halt the chain. Do not auto-roll-back; surface the failure to the user with the failing change's id (from the frontmatter), the file paths the change operated on, and the inferred remediation.
7. Commit. Create one commit containing this step's changes: the package.json bump, the lockfile churn from pnpm install, and any source-file rewrites from the applied changes. Use the message:
chore: upgrade @prisma-next/* to <to-version>(Or the extension's own commit-message convention, if it has one.) One commit per step — never squash steps.
Move on to the next step. Repeat.
Exact-pin rule
Prisma Next extensions pin every @prisma-next/* dependency to a single exact version (no ^, no ~, no range, no wildcard, no workspace: specifier in the published package.json). All @prisma-next/* entries share the same version. The pin advances only after a successful upgrade run against the new minor.
prisma-next-check-pins (shipped by @prisma-next/extension-author-tools — install with pnpm add -D @prisma-next/extension-author-tools) enforces the rule. Run it locally with:
pnpm exec prisma-next-check-pinsWire it into the extension's CI alongside the build/test step so an accidental range pin fails the PR before it lands.
When the chain is done
Report back to the user: the number of steps applied, the SHAs of the commits you made, and any open follow-ups.
Failure surfaces
When a step fails:
- Surface a structured error with code
PN-UPGRADE-NNNN, the failing change'sid, the file paths the change touched (or the lockfile, or the pin check, or the validation command), and the inferred remediation. - Do not retry automatically.
- Do not auto-roll-back the commit. The user can revert if they want a clean slate.
prisma-next-extension-upgrade
An agent skill that upgrades a Prisma Next extension package from one minor version to the next. The skill carries the per-step bump-install-instructions-check-pins-validate-commit flow plus the cumulative set of per-transition upgrade instructions (one directory per (from-minor, to-minor) pair).
The companion CLI prisma-next-check-pins ships separately from `@prisma-next/extension-author-tools` — extension authors install that as a normal devDependency and wire it into CI.
Audience
This skill is for authors of Prisma Next extensions — packages that consume the framework SPI and expose contract / middleware / codec / migration surfaces to downstream apps.
If you are a user of Prisma Next (your project imports @prisma-next/postgres, @prisma-next/mongo, etc. from your application code), install the `prisma-next-upgrade` skill instead. If your repo contains both an app and an extension, install both.
Installation
The skill (always-latest)
npx skills add prisma/prisma-next/skills/extension-author --all--all skips the per-agent selection prompt and installs to every agent runtime the skills CLI detects. For a single-agent install, swap --all for -a <agent> (e.g. -a claude-code).
The extension-author 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.
The CLI tool (normal devDependency)
pnpm add -D @prisma-next/extension-author-toolsThen wire pnpm exec prisma-next-check-pins into your CI.
Usage
Upgrade
Once installed, an agent in your extension repo 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.
prisma-next-check-pins CLI
The CLI enforces the exact-pin rule for Prisma Next extensions: every @prisma-next/* entry across dependencies, peerDependencies, and optionalDependencies must be a single exact-version string (no ^, no ~, no range, no wildcard, no workspace: specifier), and every entry must resolve to the same version.
pnpm exec prisma-next-check-pinsExits with status 0 and no output on success; on any failure, prints a structured error naming every offending entry and exits non-zero.
Wire into your CI alongside your build/test step:
- run: pnpm exec prisma-next-check-pinsWhat the skill does
See `SKILL.md` for the full flow. In short:
1. Ensure the skill itself is at @latest. 2. Detect from-version (from the lockfile) and to-version (user-supplied or npm latest). 3. Build the transition chain (one minor at a time). 4. For each step: bump deps to the exact next minor, pnpm install, run prisma-next-check-pins, apply the per-transition upgrade instructions, run build + tests, commit. 5. Halt at the first failed step with a structured error.
0.10 → 0.11 — Extension-author upgrade instructions
namespace-kind-required-on-handcrafted-contract-literals
Starting at the 0.11 release, the framework Namespace interface tightens kind from optional (inherited from IRNode) to required. Any handcrafted Contract<{ namespaces: { ... } }> type literal — typically used in extension test-d files and inline-typed fixtures — must add kind: 'sql-namespace' (or 'mongo-namespace') to each namespace literal or TypeScript will reject the literal as not structurally assignable to Namespace.
This does not affect:
- Real
contract.d.tsfiles emitted byprisma-next contract emit. The emitter printskindautomatically. - Real
contract.jsonenvelopes. The runtimekindfield is non-enumerable on namespace class instances, so it doesn't surface in the JSON on-disk envelope and the byte hash is unchanged. - Plain-literal
Contractvalues constructed at runtime (the framework only tightens the type declaration ofkind; the runtime walks tolerate missing values).
It does affect:
.test-d.tsfiles in extension packages that handcraftContract<{ namespaces: { __unbound__: { id, tables, ... } } }>literals to assert generic-inference shapes. Each namespace literal needskind: 'sql-namespace'(SQL namespaces) orkind: 'mongo-namespace'(Mongo namespaces) added alongsideid.- Any inline-typed fixture file (
test/fixtures/*.d.ts, etc.) that mirrors what the emitter would produce for testing.
Before 0.11
type ContractUnderTest = Contract<
SqlStorage<string> & {
readonly namespaces: {
readonly __unbound__: {
readonly id: '__unbound__';
readonly tables: {
readonly user: {
// ... columns, primaryKey, etc.
};
};
};
};
readonly storageHash: StorageHash;
},
// ...
>;Starting at 0.11
type ContractUnderTest = Contract<
SqlStorage<string> & {
readonly namespaces: {
readonly __unbound__: {
readonly id: '__unbound__';
readonly kind: 'sql-namespace'; // ← new: required
readonly tables: {
readonly user: {
// ... columns, primaryKey, etc.
};
};
};
};
readonly storageHash: StorageHash;
},
// ...
>;Mapping table
| Namespace family | Discriminator literal | Where it surfaces in handcrafted types |
|---|---|---|
SQL (SqlNamespace, SqlUnboundNamespace) | 'sql-namespace' | Anywhere you write Contract<{ namespaces: { … } }> with a SQL-style namespace |
Mongo (MongoNamespace, MongoUnboundNamespace) | 'mongo-namespace' | Anywhere you write Contract<{ namespaces: { … } }> with a Mongo-style namespace |
Postgres (PostgresSchema, when handcrafted) | 'postgres-schema' or 'postgres-unbound-schema' | Rare in extension code; only needed if you handcraft a literal with a Postgres-specific namespace class |
Detection
Run the matcher's grep over your extension's source:
rg --files-with-matches -t ts -e 'Contract<' -e 'namespaces' -g '**/*.test-d.ts'For each match, open the file and add the kind literal to every namespace entry under namespaces:. If you have many such literals, a regex-driven mechanical pass works (e.g. find every readonly id: '<ns-id>'; inside a namespaces: block and inject readonly kind: 'sql-namespace'; immediately after).
Why the change
The framework needs every namespace IR node to carry its family discriminator at the type level so that cross-family namespace walks (the new elementCoordinates(storage) surface in @prisma-next/framework-components/ir) can dispatch on a known-present kind, not an optional one.
The runtime invariant has always held — every concrete namespace class sets kind non-enumerably via Object.defineProperty(this, 'kind', { value, enumerable: false }) in its constructor. The type tightening makes the invariant honest at the consumer surface.
What you do not need to change
- No
.d.tsregeneration is needed. Runprisma-next contract emitonly as part of your normal authoring flow; the emitter handles thekindfield for you. - No
contract.jsonsnapshot changes. The hash inputs are unchanged becausekindis non-enumerable on namespace class instances. - No runtime API changes. Extension factories that construct namespaces via
new SqlStorage(...),new PostgresSchema(...), etc. are unchanged — the constructors continue to materialisekindnon-enumerably.
facade-add-close-and-async-dispose
Starting at the 0.11 release the three official facades (postgres(), sqlite(), mongo()) expose two new methods:
interface ClientFacade {
// ...existing surface...
close(): Promise<void>;
[Symbol.asyncDispose](): Promise<void>;
}This is the surface that lets a short-lived script (tsx my-script.ts) release facade-owned connection resources and exit cleanly. Without it, a pg.Pool (or analogous keep-alive in SQLite / Mongo) keeps Node's event loop alive and the script hangs after its last query prints.
If your extension exposes a facade in the same shape (e.g. you publish your own postgresServerless() or someDriver() factory that returns the same client object), add the equivalent surface. Three load-bearing properties:
1. Ownership rule. close() releases only the resources the facade itself constructed. A { url } (or similar opaque-string) binding means the facade opened the connection — facade owns it, close() disposes it. A { pool } / { client } / { mongoClient } (caller-supplied opaque-handle) binding means the caller owns it — close() leaves it untouched. The facade must capture this ownership decision at construction time and remember it.
2. Idempotence. close() can be called multiple times in a row without throwing. The second and later calls are no-ops.
3. Terminal closed state. After close() resolves, the facade is permanently locked. Any subsequent db.orm.X.<op>(...), db.runtime(), db.connect() call rejects with Error('<target> client is closed'). This catches use-after-close bugs cleanly instead of silently re-opening resources.
Reference implementations in this repo:
packages/3-extensions/postgres/src/runtime/postgres.ts(full pattern;{ url }vs{ pool }ownership)packages/3-extensions/sqlite/src/runtime/sqlite.ts(only-{ path }shape; the facade always owns)packages/3-extensions/mongo/src/runtime/mongo.ts({ url }vs{ mongoClient })
The [Symbol.asyncDispose] alias is one line — delegate to close() — and enables the TS 5.2+ await using db = yourFacade(...) syntax. There is no good reason not to add both methods together.
mongo-close-ownership-rule
Before 0.11, @prisma-next/mongo's db.close() looked like this:
async close() {
try {
await runtimePromise;
await runtime.close(); // unconditional — closed any MongoClient, owned or not
} catch { /* swallow */ }
closed = true;
}From 0.11 it captures the ownership decision at construction time and only closes the MongoClient if the facade owns it:
let ownedDispose: (() => Promise<void>) | undefined;
if (resolvedBinding.kind === 'url') {
ownedDispose = () => driver.close();
}
// ...
async close() {
try {
await runtimePromise;
await ownedDispose?.(); // no-op when the caller supplied the MongoClient
} catch { /* swallow */ }
closed = true;
}If your extension uses `mongo({ mongoClient: someClient })` and previously called db.close() expecting someClient to be closed, your test suite will now show the MongoClient outliving the facade. Two ways to migrate:
- Switch to a `{ url }` binding. If your extension constructs the
MongoClientpurely to hand it tomongo(), drop the manual construction — pass the connection string in{ url }and let the facade own the client. - Close the `MongoClient` explicitly. If your extension genuinely needs to share a
MongoClientacross multiple consumers (e.g. one client backs severalmongo()facades, or the client is held in a higher-level connection-management seam), keep the{ mongoClient }binding and addawait someClient.close()after your last facade is disposed.
No script — the right migration depends on why your extension was sharing the client in the first place. Walk the call sites by hand.
insert-single-row-wrap-in-array
Starting at the 0.11 release, the .insert() method on the SQL builder accepts only an array of row objects. The single-object overload that previously allowed .insert({ field: value }) is removed.
Before 0.11:
const ast = InsertAst.into(TableSource.named(tableName))
.insert({ field: value });
// or via the query builder:
db.sql.table.insert({ field: value }).build();Starting at 0.11:
const ast = InsertAst.into(TableSource.named(tableName))
.insert([{ field: value }]);
// or via the query builder:
db.sql.table.insert([{ field: value }]).build();Walk every .ts / .tsx file matched by the detection.glob above. For each call site that passes a plain object directly to .insert(...), wrap the argument in an array:
.insert(row)→.insert([row]).insert({ field: value })→.insert([{ field: value }])
Variable references to a row object are safe to wrap directly:
// Before
await runtime.execute(db.sql.table.insert(row).build());
// After
await runtime.execute(db.sql.table.insert([row]).build());If a call site already passes an array (.insert([row1, row2])), it is already correct — leave it unchanged.
TypeScript will report bare-object call sites as type errors after the bump, which is a reliable compile-time signal for every affected site.
insert-ast-with-values-to-with-rows
Starting at the 0.11 release, the InsertAst.withValues(assignments) method is removed. Extensions that build InsertAst directly via the AST layer must switch to InsertAst.withRows([assignments]).
Before 0.11:
const ast = InsertAst.into(TableSource.named(tableName))
.withValues(createAssignments.assignments)
.withOnConflict(onConflict);Starting at 0.11:
const ast = InsertAst.into(TableSource.named(tableName))
.withRows([createAssignments.assignments])
.withOnConflict(onConflict);The change is:
- Replace
.withValues(expr)with.withRows([expr])— the single-row overload is removed;withRowsaccepts an array of assignment maps.
Walk every .ts / .tsx file matched by the detection.glob above. For each call site matching .withValues(, apply the replacement. The argument remains a single expression; it just needs to be wrapped in an array.
Validation
After applying the rules above, run pnpm typecheck && pnpm test (or your extension's equivalent). prisma-next-check-pins should also pass — the pin set is unchanged for this transition; the breaking change is in the InsertAst builder method surface, not the dependency contract.
Validation by execution
These entries are prose-only (no scripts). The substrate diff on packages/3-extensions/ is additive (new methods on the three official facades) plus the Mongo behaviour change documented above; the namespace-kind-required-on-handcrafted-contract-literals entry covers a type-only tightening with no runtime substrate transform. There is no codemod to apply against a reverted substrate — the framework changes are the new surfaces, and these instructions describe the consumer-side translation, not a substrate transform.
The release-pipeline gate (pnpm check:upgrade-coverage) is satisfied by this directory existing with at least one entry. The substantive verification of the consumer-facing translation lives in the published skill's per-step bump-install-instructions-validate-commit loop, which runs in extension authors' own CI.
0.11 → 0.12 — Extension-author upgrade instructions
expr-visitor-add-window-func-method
Starting at the 0.12 release, the framework ExprVisitor<R> interface in @prisma-next/sql-relational-core/ast gained a required method:
windowFunc(expr: WindowFuncExpr): R;This method was added to support WindowFuncExpr — the new AST node for window functions, currently lowering ROW_NUMBER() OVER (PARTITION BY … ORDER BY …) used by .distinct(cols) (and reserved for RANK / DENSE_RANK as future additions).
Every ExprVisitor<R> implementation needs to add the new method. The natural body depends on what the visitor does:
- Binding / encoding / transforming visitors — usually treat
WindowFuncExprthe same way they treatAggregateExpr(recurse intoargs,partitionBy, andorderBy). - Validating visitors that restrict which expression kinds are allowed in a given context (e.g. grouped
HAVINGclauses) — typically reject window functions just like they reject aggregates in unrelated contexts.
Before 0.12
expr.accept<AnyExpression>({
columnRef: (e) => bindExpression(contract, e),
identifierRef: (e) => e,
subquery: (e) => bindExpression(contract, e),
operation: (e) => bindExpression(contract, e),
aggregate: (e) => bindExpression(contract, e),
// … other methods …
});Starting at 0.12
expr.accept<AnyExpression>({
columnRef: (e) => bindExpression(contract, e),
identifierRef: (e) => e,
subquery: (e) => bindExpression(contract, e),
operation: (e) => bindExpression(contract, e),
aggregate: (e) => bindExpression(contract, e),
windowFunc: (e) => bindExpression(contract, e), // ← new: required
// … other methods …
});Or, for a context that rejects unsupported kinds
expr.accept<AnyExpression>({
// …
aggregate: rejectInThisContext,
windowFunc: rejectInThisContext, // ← new: required
// …
});TypeScript will report missing-property errors on every visitor literal after the bump; that's a reliable compile-time signal for every affected site. No automated codemod — the right body depends on what your visitor does, so author each one by hand.
any-expression-exhaustive-switch-add-window-func-case
Starting at the 0.12 release, the AnyExpression discriminated union in @prisma-next/sql-relational-core/ast gained WindowFuncExpr (kind: 'window-func'). Exhaustive switches over expr.kind that use the satisfies never exhaustiveness pattern will fail to compile until they add a matching arm.
The most common case is in SQL renderers — Postgres and SQLite both render WindowFuncExpr as fn() OVER (PARTITION BY … ORDER BY …) (the syntax is identical across the two targets we ship).
Before 0.12
function renderExpr(expr: AnyExpression): string {
switch (expr.kind) {
case 'column-ref':
return renderColumn(expr);
case 'aggregate':
return renderAggregate(expr);
// … other cases …
// v8 ignore next 4
default:
throw new Error(
`Unsupported expression node kind: ${(expr satisfies never as { kind: string }).kind}`,
);
}
}Starting at 0.12
function renderExpr(expr: AnyExpression): string {
switch (expr.kind) {
case 'column-ref':
return renderColumn(expr);
case 'aggregate':
return renderAggregate(expr);
case 'window-func':
return renderWindowFunc(expr); // ← new: required
// … other cases …
default:
throw new Error(
`Unsupported expression node kind: ${(expr satisfies never as { kind: string }).kind}`,
);
}
}
function renderWindowFunc(expr: WindowFuncExpr): string {
const fn = expr.fn.toUpperCase();
const args = expr.args.map(renderExpr).join(', ');
const partition =
expr.partitionBy && expr.partitionBy.length > 0
? `PARTITION BY ${expr.partitionBy.map(renderExpr).join(', ')}`
: '';
const order =
expr.orderBy && expr.orderBy.length > 0
? `ORDER BY ${expr.orderBy.map((o) => `${renderExpr(o.expr)} ${o.dir.toUpperCase()}`).join(', ')}`
: '';
const over = [partition, order].filter((s) => s.length > 0).join(' ');
return `${fn}(${args}) OVER (${over})`;
}If your switch builds an isAtomicExpressionKind predicate or anything similar (used to decide whether the rendered expression needs surrounding parentheses), treat 'window-func' as atomic — fn() OVER (…) is self-delimited by its own parentheses.
No automated codemod — the body of the new arm depends on what the switch does. TypeScript pinpoints every site at compile time.
distinct-cols-now-collapses-by-specified-columns
Starting at the 0.12 release, .distinct(cols) on the @prisma-next/sql-orm-client Collection API — at the top level (db.Post.distinct('title')), on leaf includes (include('posts', p => p.distinct('title'))), and on non-leaf includes (include('posts', p => p.distinct('title').include('comments'))) — keeps one representative row per (cols) group, matching Prisma's documented semantics.
Prior to 0.12, .distinct(cols) did not actually collapse rows on the specified columns: when the projection contained any other distinguishing column (typically id), rows that differed only in those other columns were all returned. From 0.12 onwards, .distinct(cols) keeps one representative row per (cols) group, matching the way Prisma documents distinct.
No code change for consumer call sites
// Both 0.11 and 0.12 — same call site, different runtime behaviour:
const posts = await db.Post
.orderBy([(p) => p.title.asc(), (p) => p.id.asc()])
.distinct('title')
.all();
// 0.11: returns every post (if seed has 3 posts including two sharing title='A',
// you get 3 back).
// 0.12: returns one post per title (you get 2 back — title='A' picks the
// lower-id row per the orderBy; title='B' is unaffected).The API surface is unchanged. Type-level signatures are unchanged. Only the SQL produced and the rows returned differ.
Tests and fixtures that assert pre-0.12 output
Any extension test that exercises .distinct(cols) and asserts the result set will fail under 0.12. Updates needed:
- Seed data with duplicates on every column passed to
.distinct(...)so the test actually exercises dedup (a test with no duplicates is a no-op assertion in either era). - Pair `.distinct(...)` with an `.orderBy(...)` that fully orders rows within each partition (e.g.
[distinctCol.asc(), id.asc()]) so the picked representative is deterministic. When the orderBy doesn't fully order a partition the choice is implementation-defined — matches Prisma's behaviour, but makes assertions flaky. - Update `expect(rows).toEqual([…])` shapes to match the post-collapse output. The dropped row's grandchildren (where
.distinct(cols).include(grandchild)is in play) do not appear in the output either.
Representative-selection behaviour
The user's .orderBy(…) drives the OVER ORDER BY of the underlying ROW_NUMBER() — the row with rank 1 in each partition wins. When the orderBy doesn't fully order rows within a partition, the choice between tied rows is implementation-defined (Postgres and SQLite are each entitled to pick any row in the tie). This matches Prisma's documented behaviour; if your extension needs deterministic picks across partition ties, add a primary-key tiebreaker to the orderBy.
Validation
After updating fixture / test data, run your extension's standard pnpm test (or pnpm test:integration for tests that exercise live SQL). No type-level changes — TypeScript will not pinpoint sites; runtime assertions are the signal.
replace-runtime-verify-options-with-verify-marker
Starting at the 0.12 release, @prisma-next/sql-runtime simplifies marker verification. The previous RuntimeVerifyOptions type and the verify: { mode; requireMarker } field on RuntimeOptions are removed; replaced by a single optional field verifyMarker?: VerifyMarkerOption where VerifyMarkerOption = 'onFirstUse' | false and 'onFirstUse' is the runtime default.
If your extension ships a convenience wrapper around createRuntime(...) — the pattern used by @prisma-next/sqlite, @prisma-next/postgres, and @prisma-next/postgres/serverless — you need four mechanical edits in the wrapper source:
1. Rename the type import from RuntimeVerifyOptions to VerifyMarkerOption. 2. Rename the option on your *OptionsBase interface from verify? to verifyMarker?. 3. Drop the hard-coded default literal in the createRuntime(...) call. 4. Thread the caller's value through with ifDefined so omitted options defer to the runtime default.
The runtime's read-side behaviour also changes: it no longer throws CONTRACT.MARKER_MISMATCH or CONTRACT.MARKER_MISSING when the database marker is absent or drifted. Instead, on the first execute() call per runtime instance, it emits one structured warn-level log line (payload includes code, scope, expected, actual, message) and proceeds with the query. Extension authors do not need to implement this behaviour — it lives inside @prisma-next/sql-runtime — but tests that previously asserted thrown errors need retargeting (see Tests and fixtures below).
Before 0.12 — type import and options interface
import type {
ExecutionContext,
Runtime,
RuntimeVerifyOptions,
SqlExecutionStackWithDriver,
SqlMiddleware,
SqlRuntimeExtensionDescriptor,
} from '@prisma-next/sql-runtime';
export interface MyTargetOptionsBase {
readonly extensions?: readonly SqlRuntimeExtensionDescriptor<MyTargetId>[];
readonly middleware?: readonly SqlMiddleware[];
readonly verify?: RuntimeVerifyOptions;
}Starting at 0.12 — type import and options interface
import type {
ExecutionContext,
Runtime,
SqlExecutionStackWithDriver,
SqlMiddleware,
SqlRuntimeExtensionDescriptor,
VerifyMarkerOption,
} from '@prisma-next/sql-runtime';
import { ifDefined } from '@prisma-next/utils/defined';
export interface MyTargetOptionsBase {
readonly extensions?: readonly SqlRuntimeExtensionDescriptor<MyTargetId>[];
readonly middleware?: readonly SqlMiddleware[];
readonly verifyMarker?: VerifyMarkerOption;
}Import ifDefined from @prisma-next/utils/defined if your wrapper does not already use it for other optional fields.
Before 0.12 — createRuntime(...) call inside the wrapper
const runtime = createRuntime({
stackInstance,
context,
driver,
verify: options.verify ?? { mode: 'onFirstUse', requireMarker: false },
...ifDefined('middleware', options.middleware),
});The hard-coded { mode: 'onFirstUse', requireMarker: false } default duplicated what the runtime already applied when verify was omitted. From 0.12 the wrapper should not inject a default — let the runtime's 'onFirstUse' default stand.
Starting at 0.12 — createRuntime(...) call inside the wrapper
const runtime = createRuntime({
stackInstance,
context,
driver,
...ifDefined('verifyMarker', options.verifyMarker),
...ifDefined('middleware', options.middleware),
});When the caller omits verifyMarker, the spread adds nothing and the runtime default ('onFirstUse') applies. When the caller passes verifyMarker: false, verification is skipped entirely.
Semantics mapping for callers of your wrapper
Before 0.12 (verify) | Starting at 0.12 (verifyMarker) |
|---|---|
{ mode: 'onFirstUse', requireMarker: false } (or omitted — your wrapper defaulted to this) | omit verifyMarker (runtime default 'onFirstUse') |
{ mode: 'onFirstUse', requireMarker: true } | verifyMarker: 'onFirstUse' — but the throw-on-missing-marker semantics are removed; use the db-verify CLI for fail-fast deploy checks |
{ mode: 'always', requireMarker: ... } | verifyMarker: 'onFirstUse' — 'always' mode is dropped; verification is once-per-runtime |
{ mode: 'startup', requireMarker: ... } | verifyMarker: 'onFirstUse' — 'startup' mode is dropped for the same reason |
| Explicit skip | verifyMarker: false |
Tests and fixtures
Extension wrapper tests that exercised the old surface need two kinds of updates:
Option-forwarding tests — rename the option and adjust assertions about defaults:
// Before 0.12
it('forwards verify option to createRuntime', async () => {
const verify = { mode: 'always', requireMarker: true } as const;
const db = myTarget({ contract, verify });
await db.connect(/* … */);
expect(mocks.createRuntime).toHaveBeenCalledWith(expect.objectContaining({ verify }));
});
it('defaults verify to onFirstUse without requireMarker', async () => {
const db = myTarget({ contract });
await db.connect(/* … */);
expect(mocks.createRuntime).toHaveBeenCalledWith(
expect.objectContaining({ verify: { mode: 'onFirstUse', requireMarker: false } }),
);
});
// Starting at 0.12
it('forwards verifyMarker option to createRuntime', async () => {
const db = myTarget({ contract, verifyMarker: false });
await db.connect(/* … */);
expect(mocks.createRuntime).toHaveBeenCalledWith(
expect.objectContaining({ verifyMarker: false }),
);
});
it('omits verifyMarker from createRuntime when not provided (runtime default applies)', async () => {
const db = myTarget({ contract });
await db.connect(/* … */);
expect(mocks.createRuntime).toHaveBeenCalledTimes(1);
const callArg = mocks.createRuntime.mock.calls[0]?.[0] as Record<string, unknown>;
expect(callArg).not.toHaveProperty('verifyMarker');
});Drift / missing-marker integration tests — grep for rejects.toMatchObject({ code: 'CONTRACT.MARKER_MISSING' }) or rejects.toMatchObject({ code: 'CONTRACT.MARKER_MISMATCH' }). These patterns no longer apply: the runtime logs instead of throwing. Retarget to assert on the Log.warn sink:
// Before 0.12
await expect(runtime.execute(plan).toArray()).rejects.toMatchObject({
code: 'CONTRACT.MARKER_MISSING',
});
// Starting at 0.12
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() } satisfies Log;
const runtime = createRuntime({ stackInstance, context, driver, log });
const rows = await runtime.execute(plan).toArray();
expect(rows).toEqual(/* expected rows — query proceeds */);
expect(log.warn).toHaveBeenCalledOnce();
expect(log.warn).toHaveBeenCalledWith({
code: 'CONTRACT.MARKER_MISSING',
scope: 'marker-verification',
expected: { storageHash: contract.storage.storageHash, profileHash: contract.profileHash ?? null },
actual: null,
message: 'Contract marker not found in database',
});Pass a log object into createRuntime(...) (or through your wrapper if you expose a log option) so tests can spy on warn without touching stdout.
Validation
After applying the edits above, run pnpm typecheck on your extension package. TypeScript flags every remaining RuntimeVerifyOptions import and every verify?: field on your options interface. Then run your extension's test suite — option-forwarding unit tests and any marker-drift integration tests are the sites most likely to need the retargeting described above.
define-contract-drop-capabilities-generic
Starting at the 0.12 release, the framework baseDefineContract factory in @prisma-next/contract drops its Capabilities type parameter, and the ContractInput<Family, Target, Types, Models, ExtensionPacks, Capabilities> shape loses its trailing argument. Capabilities are no longer declared at authoring time — they are contributed automatically by target components and extension packs, and flow into the emitted contract.json / contract.d.ts from there.
There are two kinds of impact on an extension, depending on what your extension ships:
- Extensions that ship their own target-facade `defineContract` (the pattern used by
@prisma-next/postgres,@prisma-next/sqlite, and any third-party adapter that pre-bindsfamily+targetfor its consumers): you need to drop theCapabilitiesgeneric from every facade type alias and overload signature. TypeScript will pinpoint every site once you bump. - Extensions that only contribute pack metadata + emit fixtures (the more common shape —
@prisma-next/pgvector,@prisma-next/paradedb, etc.): no source change. Re-emit your contract fixtures (pnpm fixtures:emitor the equivalent script for your package) so the regeneratedcontract.json/contract.d.tspicks up the new auto-contributed capability keys — in the 0.12 line,postgres.distinctOn: trueandsql.lateral: trueappear in every SQL-target fixture that loads the relevant adapter.
Facade-style extensions — drop the generic
If your extension ships a defineContract that wraps baseDefineContract with family / target pre-bound, walk every type alias and every overload signature in your facade and remove the Capabilities parameter.
Before 0.12
import { defineContract as baseDefineContract } from '@prisma-next/contract';
import type { ContractInput, ExtensionPackRef } from '@prisma-next/contract';
type MyTargetResult<
Types extends TypesConstraint,
Models extends ModelsConstraint,
ExtensionPacks extends Record<string, ExtensionPackRef<'sql', string>> | undefined,
Capabilities extends Record<string, Record<string, boolean>> | undefined,
> = Omit<
ReturnType<
typeof baseDefineContract<
MyFamily,
MyTargetPack,
Types,
Models,
ExtensionPacks,
Capabilities
>
>,
'target' | 'targetFamily'
> & {
readonly target: MyTargetPack['targetId'];
readonly targetFamily: MyFamily['familyId'];
};
type MyTargetBaseScaffold<
ExtensionPacks extends Record<string, ExtensionPackRef<'sql', string>> | undefined,
Capabilities extends Record<string, Record<string, boolean>> | undefined,
> = Omit<
ContractInput<
MyFamily,
MyTargetPack,
Record<never, never>,
Record<never, never>,
ExtensionPacks,
Capabilities
>,
'family' | 'target' | 'types' | 'models'
>;
export function defineContract<
const Types extends TypesConstraint = Record<never, never>,
const Models extends ModelsConstraint = Record<never, never>,
const ExtensionPacks extends
| Record<string, ExtensionPackRef<'sql', string>>
| undefined = undefined,
const Capabilities extends Record<string, Record<string, boolean>> | undefined = undefined,
>(
definition: MyTargetDefinition<Types, Models, ExtensionPacks, Capabilities>,
): MyTargetResult<Types, Models, ExtensionPacks, Capabilities>;Starting at 0.12
import { defineContract as baseDefineContract } from '@prisma-next/contract';
import type { ContractInput, ExtensionPackRef } from '@prisma-next/contract';
type MyTargetResult<
Types extends TypesConstraint,
Models extends ModelsConstraint,
ExtensionPacks extends Record<string, ExtensionPackRef<'sql', string>> | undefined,
> = Omit<
ReturnType<
typeof baseDefineContract<MyFamily, MyTargetPack, Types, Models, ExtensionPacks>
>,
'target' | 'targetFamily'
> & {
readonly target: MyTargetPack['targetId'];
readonly targetFamily: MyFamily['familyId'];
};
type MyTargetBaseScaffold<
ExtensionPacks extends Record<string, ExtensionPackRef<'sql', string>> | undefined,
> = Omit<
ContractInput<
MyFamily,
MyTargetPack,
Record<never, never>,
Record<never, never>,
ExtensionPacks
>,
'family' | 'target' | 'types' | 'models'
>;
export function defineContract<
const Types extends TypesConstraint = Record<never, never>,
const Models extends ModelsConstraint = Record<never, never>,
const ExtensionPacks extends
| Record<string, ExtensionPackRef<'sql', string>>
| undefined = undefined,
>(
definition: MyTargetDefinition<Types, Models, ExtensionPacks>,
): MyTargetResult<Types, Models, ExtensionPacks>;Drop the same parameter from every other overload signature (the factory-form overload, any convenience overload). Drop the matching entry from the type alias for *Definition and *Scaffold shapes. Drop the Capabilities argument from every internal baseDefineContract<…, Capabilities> instantiation and every internal ContractInput<…, Capabilities> instantiation. TypeScript will flag any remaining occurrence after the bump.
Type tests that asserted authoring-time capability literals
If your facade ships a define-contract.test-d.ts (or similar) that asserts a capabilities literal is acceptable as an input to your defineContract — flip the assertion. The literal is now refused at the type level:
// Starting at 0.12
// @ts-expect-error — capabilities are contributed by components, not authoring input
defineContract({ capabilities: { sql: { lateral: true } } });Extensions that only emit fixtures — re-emit
If your extension does not ship a facade, you have no source change. The contract fixtures your extension emits as part of its test suite will, however, gain new capability keys after the bump. Re-run your fixture-emit script (commonly pnpm fixtures:emit or pnpm test:fixtures:emit) and commit the regenerated contract.json / contract.d.ts. Expect to see:
postgres.distinctOn: true(added when a Postgres adapter is in the component graph)sql.lateral: true(added when the SQL family + a supporting adapter is in the component graph)
No fixture-shape changes other than capability additions; if your re-emit produces diffs in other sections of contract.json, that's a separate framework change, not this entry.
Validation
After applying the edits, run pnpm typecheck and the matching test suite for your extension package. For facade-style extensions, the typecheck pinpoints every remaining occurrence of the Capabilities generic at compile time. For fixture-only extensions, the regenerated contract.json / contract.d.ts diff is the signal — review it to confirm the new capability keys landed where you expect.
strip-migration-labels-hints
Starting at the 0.12 release, the migration manifest schema is closed ('+': 'reject') and the metadata model no longer carries labels or hints. If your extension ships on-disk migration packages — for example an install-extension migration (migrations/<timestamp>_install_…/migration.json) that provisions your extension's database objects — any manifest that still holds either key fails to load: the loader rejects it with INVALID_MANIFEST, naming the first offending key (labels or hints). The two fields are also removed from the content-addressed migration identity — migrationHash is now computed over { from, to, providedInvariants, createdAt } plus the sibling ops.json — so every migrated manifest additionally needs its hash recomputed over the slimmed envelope, or it fails hash verification on the next load.
Run the colocated codemod from your extension's package root:
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 committed 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 your extension's CI alongside prisma-next-check-pins. A fully migrated tree reports 0 needing fix and exits 0.
Validation
After running the codemod, run your extension's migration-loading tests (the integration suite that applies your install migration, or whatever exercises the on-disk packages). The loader recomputes and verifies each manifest's migrationHash on read: a manifest that still carried labels/hints would have thrown INVALID_MANIFEST, and a manifest with a stale hash would fail verification. Once the codemod has run, every manifest loads cleanly and its recomputed hash verifies against the slimmed envelope.
extension-public-default-baseline
Starting at the 0.12 release, Postgres extension packs whose contract-space declares only storage types (no tables) emit their empty default namespace under public / postgres-schema instead of __unbound__ / postgres-unbound-schema. The on-disk migration ops (CREATE EXTENSION …, invariant registration, etc.) are unchanged — only the contract hash envelope moves. Expect diffs in:
src/contract.json/src/contract.d.ts— newstorageHashand namespace keysmigrations/<baseline>/migration.json— updatedtoandmigrationHashmigrations/<baseline>/end-contract.json/end-contract.d.ts— regenerated snapshotsmigrations/<baseline>/migration.ts— updateddescribe().tostorage hash literalmigrations/refs/head.json— updatedhash
Regenerate contract-space and baseline
Run the colocated script from your extension package root (or monorepo root if it hosts multiple extension packs):
pnpm exec tsx ./regenerate-extension-public-baseline.tsFor each extension root whose src/contract.json still carries "kind": "postgres-unbound-schema", the script runs pnpm build:contract-space, copies src/contract.{json,d.ts} into each baseline migration directory as end-contract.{json,d.ts}, patches the baseline migration.ts to hash, self-emits the migration (pnpm exec tsx migrations/.../migration.ts), and updates migrations/refs/head.json.
Use --check to list packs that still need regeneration:
pnpm exec tsx ./regenerate-extension-public-baseline.ts --checkPath B baselines (hand-authored install migrations with no planner scaffold) follow the same loop documented in your extension README: edit describe().to, then self-emit.
Validation
Run your extension's test suite and any migration-loading integration tests. Confirm migrations/refs/head.json hash matches src/contract.json storage.storageHash, and that the baseline migration.json to field matches as well.
domain-plane-spi-and-testing-subpath
Starting at the 0.12 release, two SPI changes affect extension authors:
1. Namespaced domain plane — stop reading flat contract.models / contract.valueObjects. Models and value objects live under contract.domain.namespaces.<ns>. Use domainModelsAtDefaultNamespace(contract.domain) (reads the contract's sole namespace; throws on a multi-namespace contract — select explicitly per TML-2550) and ContractModelDefinitions<C> from @prisma-next/contract/types for typed access. Storage remains under contract.storage.namespaces.<ns> (unchanged shape).
2. Removed `@prisma-next/contract/testing` subpath — test factories moved to @prisma-next/test-utils. Add @prisma-next/test-utils to your extension's devDependencies at the same version pin as your other @prisma-next/* packages if it is not already present.
Migrate test imports
Run the colocated codemod from your extension root:
pnpm exec tsx ./migrate-contract-testing-imports.tsIt rewrites every @prisma-next/contract/testing import to @prisma-next/test-utils. Use --check for a dry-run:
pnpm exec tsx ./migrate-contract-testing-imports.ts --checkExports are unchanged — only the package path moves:
-import { createContract, createSqlContract } from '@prisma-next/contract/testing';
+import { createContract, createSqlContract } from '@prisma-next/test-utils';Subpath imports such as @prisma-next/test-utils/typed-expectations were already on @prisma-next/test-utils and are unaffected.
Update SPI reads to the namespaced domain shape
Walk extension source that constructs or reads contracts directly (tests, control adapters, planners). TypeScript will flag most stale reads after the bump; the mechanical rewrites are:
Reading models — resolve through the target's default domain namespace:
-const models = contract.models;
+import { domainModelsAtDefaultNamespace } from '@prisma-next/contract/types';
+
+const models = domainModelsAtDefaultNamespace(contract.domain);Patching models in tests — nest under the domain namespace:
return {
...contract,
- models: patch({ ...contract.models }),
+ domain: {
+ namespaces: {
+ ...contract.domain.namespaces,
+ [namespaceId]: {
+ ...namespace,
+ models: patch({ ...domainModelsAtDefaultNamespace(contract.domain) }),
+ },
+ },
+ },
};Hard-coded `__unbound__` namespace lookups for table resolution — scan all storage namespaces (a table name is unique within the contract's default resolution path):
-const table = contract.storage.namespaces['__unbound__']?.tables[tableName];
+const table = Object.values(contract.storage.namespaces).find(
+ (ns) => ns.tables[tableName] !== undefined,
+)?.tables[tableName];After source updates, re-emit fixture contracts (pnpm fixtures:emit or your package's equivalent) so committed contract.json / contract.d.ts under test/ pick up domain.namespaces.
Validation
Run pnpm typecheck && pnpm test on your extension package. The import codemod is deterministic; remaining errors indicate hand-edits for namespaced domain reads. Regenerated fixture diffs should show domain.namespaces and ContractModelDefinitions (or the emitted Models infer alias) in types.
default-namespace-domain-access-retire-projection-helpers
Starting at the 0.12 release (runtime qualification, ADR 223), the foundation contract package retires the transitional projection helpers introduced during the symmetric domain-plane migration. Extension code that still calls them will fail to compile after the bump.
The default namespace a bare name resolves through is inferred from the contract (sole namespace, else insertion order) — there are no …ForSqlTarget / …ForMongo helpers to import. A target's default namespace is declared on its descriptor (defaultNamespaceId) and consumed only by authoring; runtime code resolves target-agnostically.
Removed exports (old → new)
| Removed | Replacement |
|---|---|
contractModels(contract) | domainModelsAtDefaultNamespace(contract.domain) (reads the sole namespace; throws on multi-namespace) |
contractValueObjects(contract) | domainValueObjectsAtDefaultNamespace(contract.domain) |
resolveSingleDomainNamespaceId(domain) | soleDomainNamespaceId(domain) (same fail-loud single-namespace behaviour) |
ContractModelsMap<C> | ContractModelDefinitions<C> |
ContractValueObjectsMap<C> | Read contract.domain.namespaces[ns].valueObjects for a specific namespace, or domainValueObjectsAtDefaultNamespace(contract.domain) for the default slot |
Import the replacements from @prisma-next/contract/types.
qualifyTable on SQL namespace concretions
Storage namespace envelopes in SQL-family contracts must carry a qualifyTable(tableName: string): string method. The Postgres and SQLite packs in this repo already implement it on bound/unbound namespace concretions; custom serializers or hand-built namespace objects in tests must include it or rendering falls back incorrectly.
Hydrating contracts in tests
Do not structuredClone hydrated contracts — it strips functions such as qualifyTable. Round-trip through the target serializer instead:
import { PostgresContractSerializer } from '@prisma-next/target-postgres/runtime';
const serializer = new PostgresContractSerializer();
const hydrated = serializer.deserializeContract(serializer.serializeContract(rawContract));Namespace-qualified runtime SQL
Postgres query renderers now emit "<schema>"."<table>" (default schema public). Update extension integration tests that assert raw SQL strings (FROM "user" → FROM "public"."user"). SQLite remains unqualified. Application/extension call sites for db.sql.* / db.* are unchanged.
Emitter guard (unchanged for multi-namespace extensions)
assertSingleDomainNamespaceForEmission still fails when emitting contract.d.ts for contracts with multiple domain namespaces (TML-2550). Runtime execution does not throw for multi-namespace contracts; only emission stays fail-loud.
Validation
Run pnpm typecheck && pnpm test on your extension package. Grep for the removed symbol names should return no hits outside historical upgrade prose.
Validation by execution
Apart from strip-migration-labels-hints (which ships the colocated codemod described above, validated against the migration manifests under packages/3-extensions/), these entries are prose-only (no codemod scripts). The substrate diffs inside packages/3-extensions/ in this transition are the same code translations downstream extension authors will replicate by hand:
- The
windowFuncmethod literally added tobindWhereExprNode'sExprVisitorliteral inwhere-binding.ts. - The
windowFunc: rejectHavingExprliterally added tovalidateGroupedHavingExpr'sExprVisitorliteral inquery-plan-aggregate.ts. - The
case 'window-func':arms in the Postgres and SQLite adapter renderers. - Flipped fixture row counts in the distinct integration tests.
- The
RuntimeVerifyOptions→VerifyMarkerOptionimport rename,verify?→verifyMarker?on*OptionsBase, and...ifDefined('verifyMarker', options.verifyMarker)thread-through inpackages/3-extensions/sqlite/src/runtime/sqlite.ts,packages/3-extensions/postgres/src/runtime/postgres.ts, andpackages/3-extensions/postgres/src/runtime/postgres-serverless.ts. - Retargeted option-forwarding and marker-drift tests in
packages/3-extensions/postgres/test/postgres-serverless.test.ts.
There is no scriptable transform — the right body for the ExprVisitor method and the right arm for the exhaustive switch depend on what the consumer's visitor / switch does; the right test retargeting for marker drift depends on whether the test asserted throws or option forwarding. The release-pipeline gate (pnpm check:upgrade-coverage) is satisfied by this directory existing with at least one entry; the substantive verification of the consumer-facing translation lives in the published extension-upgrade skill's per-step bump-install-instructions-validate-commit loop, which runs in extension authors' own CI.
/**
* Rewrites test-only imports from the removed `@prisma-next/contract/testing`
* subpath to `@prisma-next/test-utils`.
*
* Background: starting at 0.12 the contract test factories (`createContract`,
* `createSqlContract`, `DUMMY_HASH`, `applicationDomainOf`, …) live in
* `@prisma-next/test-utils`. The `@prisma-next/contract/testing` export was
* removed from `@prisma-next/contract`.
*
* Behaviour:
* - Walks the project root recursively, ignoring `node_modules`, `.git`,
* `dist`, and `build`.
* - Rewrites every `.ts` / `.tsx` file whose source contains
* `@prisma-next/contract/testing`.
* - Idempotent: files already importing from `@prisma-next/test-utils` are
* left untouched.
*
* Flags:
* --check dry-run; lists files that still need rewriting and exits 1 if
* any remain.
*/
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
const FROM = '@prisma-next/contract/testing';
const TO = '@prisma-next/test-utils';
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
const dryRun = process.argv.includes('--check');
const projectRoot = process.cwd();
async function findSourceFiles(root: string): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
let entries: Awaited<ReturnType<typeof readdir>>;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
await walk(join(dir, entry.name));
} else if (
entry.isFile() &&
(entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) &&
entry.name !== 'migrate-contract-testing-imports.ts'
) {
out.push(join(dir, entry.name));
}
}
}
await walk(root);
return out.sort();
}
const files = await findSourceFiles(projectRoot);
const targets: string[] = [];
for (const path of files) {
const raw = await readFile(path, 'utf-8');
if (raw.includes(FROM)) targets.push(path);
}
if (targets.length === 0) {
console.log('No @prisma-next/contract/testing imports found.');
process.exit(0);
}
let needsFix = 0;
let fixed = 0;
for (const path of targets) {
const rel = path.slice(projectRoot.length + 1);
const raw = await readFile(path, 'utf-8');
const next = raw.replaceAll(FROM, TO);
if (next === raw) continue;
needsFix += 1;
if (dryRun) {
console.log(`WOULD REWRITE ${rel}`);
continue;
}
await writeFile(path, next);
fixed += 1;
console.log(`REWRITE ${rel}`);
}
console.log();
console.log(
`${targets.length} file(s) with legacy import: ${dryRun ? needsFix : fixed} ${dryRun ? 'needing rewrite' : 'rewritten'}.`,
);
if (dryRun && needsFix > 0) process.exit(1);
/**
* Re-emits a Postgres extension pack's contract-space and regenerates its
* install migration baseline after the 0.12 public-by-default flip
* (`__unbound__`/`postgres-unbound-schema` → `public`/`postgres-schema`).
*
* The migration ops are unchanged — only the contract hash envelope moves.
* This script:
* 1. Finds extension package roots (nearest `package.json` with a
* `build:contract-space` script) whose `src/contract.json` still
* carries `postgres-unbound-schema`.
* 2. Runs `pnpm build:contract-space`.
* 3. Patches each baseline `migrations/<dir>/migration.ts` `describe().to`
* hash to match the new `storageHash`.
* 4. Self-emits each migration (`pnpm exec tsx <migration.ts>`).
* 5. Updates `migrations/refs/head.json` `hash` to the new storage hash
* (preserves `invariants`).
*
* Flags:
* --check dry-run; lists extension roots that still need regeneration
* and exits 1 if any remain.
*/
import { execFile } from 'node:child_process';
import { access, copyFile, readdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
const dryRun = process.argv.includes('--check');
const projectRoot = process.cwd();
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
async function findPackageJsonFiles(root: string): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
let entries: Awaited<ReturnType<typeof readdir>>;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
await walk(join(dir, entry.name));
} else if (entry.isFile() && entry.name === 'package.json') {
out.push(join(dir, entry.name));
}
}
}
await walk(root);
return out.sort();
}
function contractNeedsPublicDefaultMigration(raw: string): boolean {
return (
raw.includes('"kind": "postgres-unbound-schema"') ||
raw.includes('"kind":"postgres-unbound-schema"')
);
}
async function packageHasBuildContractSpace(pkgPath: string): Promise<boolean> {
const raw = await readFile(pkgPath, 'utf-8');
try {
const parsed: unknown = JSON.parse(raw);
if (!isJsonObject(parsed)) return false;
const scripts = parsed['scripts'];
if (!isJsonObject(scripts)) return false;
return (
typeof scripts['build:contract-space'] === 'string' &&
scripts['build:contract-space'].length > 0
);
} catch {
return false;
}
}
async function findMigrationDirs(migrationsDir: string): Promise<string[]> {
const out: string[] = [];
let entries: Awaited<ReturnType<typeof readdir>>;
try {
entries = await readdir(migrationsDir, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const migrationDir = join(migrationsDir, entry.name);
if (await pathExists(join(migrationDir, 'migration.ts'))) out.push(migrationDir);
}
return out.sort();
}
async function readStorageHash(contractPath: string): Promise<string | null> {
const raw = await readFile(contractPath, 'utf-8');
try {
const parsed: unknown = JSON.parse(raw);
if (!isJsonObject(parsed)) return null;
const storage = parsed['storage'];
if (!isJsonObject(storage)) return null;
const storageHash = storage['storageHash'];
return typeof storageHash === 'string' ? storageHash : null;
} catch {
return null;
}
}
async function patchMigrationToHash(
migrationTsPath: string,
storageHash: string,
): Promise<boolean> {
const raw = await readFile(migrationTsPath, 'utf-8');
const patched = raw.replace(/(\bto:\s*['"])sha256:[0-9a-f]{64}(['"])/, `$1${storageHash}$2`);
if (patched === raw) return false;
await writeFile(migrationTsPath, patched);
return true;
}
async function patchHeadRef(headPath: string, storageHash: string): Promise<boolean> {
const raw = await readFile(headPath, 'utf-8');
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return false;
}
if (!isJsonObject(parsed)) return false;
if (parsed['hash'] === storageHash) return false;
parsed['hash'] = storageHash;
await writeFile(headPath, `${JSON.stringify(parsed, null, 2)}\n`);
return true;
}
interface ExtensionRoot {
readonly dir: string;
readonly contractPath: string;
}
const extensionRoots: ExtensionRoot[] = [];
for (const pkgPath of await findPackageJsonFiles(projectRoot)) {
if (!(await packageHasBuildContractSpace(pkgPath))) continue;
const dir = join(pkgPath, '..');
const contractPath = join(dir, 'src', 'contract.json');
if (!(await pathExists(contractPath))) continue;
const raw = await readFile(contractPath, 'utf-8');
if (!contractNeedsPublicDefaultMigration(raw)) continue;
extensionRoots.push({ dir, contractPath });
}
if (extensionRoots.length === 0) {
console.error(`No extension public-default migration candidates under ${projectRoot}.`);
process.exit(dryRun ? 0 : 1);
}
let needsFix = 0;
let alreadyClean = 0;
for (const { dir, contractPath } of extensionRoots) {
const rel = dir.slice(projectRoot.length + 1) || '.';
const raw = await readFile(contractPath, 'utf-8');
if (!contractNeedsPublicDefaultMigration(raw)) {
alreadyClean += 1;
console.log(`OK ${rel}`);
continue;
}
needsFix += 1;
if (dryRun) {
console.log(`WOULD REGENERATE ${rel}`);
continue;
}
console.log(`REGENERATE ${rel}`);
await execFileAsync('pnpm', ['build:contract-space'], { cwd: dir, env: process.env });
const storageHash = await readStorageHash(contractPath);
if (storageHash === null) {
throw new Error(`Could not read storageHash from ${contractPath}`);
}
const migrationsDir = join(dir, 'migrations');
const srcContractJson = join(dir, 'src', 'contract.json');
const srcContractDts = join(dir, 'src', 'contract.d.ts');
for (const migrationDir of await findMigrationDirs(migrationsDir)) {
await copyFile(srcContractJson, join(migrationDir, 'end-contract.json'));
if (await pathExists(srcContractDts)) {
await copyFile(srcContractDts, join(migrationDir, 'end-contract.d.ts'));
}
const migrationTs = join(migrationDir, 'migration.ts');
await patchMigrationToHash(migrationTs, storageHash);
await execFileAsync('pnpm', ['exec', 'tsx', migrationTs], { cwd: dir, env: process.env });
}
const headPath = join(migrationsDir, 'refs', 'head.json');
if (await pathExists(headPath)) {
await patchHeadRef(headPath, storageHash);
}
}
console.log();
console.log(
`${extensionRoots.length} extension pack(s): ${needsFix} ${dryRun ? 'needing regeneration' : 'regenerated'}, ${alreadyClean} already on public default.`,
);
if (dryRun && needsFix > 0) process.exit(1);
/**
* 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-2843: @prisma-next/sqlite gained a facade-level transaction API (SqliteClient.transaction() + SqliteTransactionContext), mirroring the existing Postgres facade. Purely additive public surface backed by the unchanged SQL runtime withTransaction helper; existing extension code is unaffected. Incidental substrate diff only.
TML-2838: vitest configs in packages/3-extensions/postgres and packages/3-extensions/supabase now pass --no-memory-protection-keys to the test worker forks to stop a V8 WASM-teardown crash on Linux CI. Test-harness only — no runtime, contract, or public-API change. Incidental substrate diff only.
TML-2500 M4: packages/3-extensions/supabase/README.md link updated from the old project spec to the canonical ecosystem-extensions doc and ADR 226. Docs-only; no runtime, contract, or public-API change. Incidental substrate diff only.
TML-2784: many-to-many became a first-class, validatable contract shape. ContractReferenceRelation is now a cardinality-discriminated union — the 'N:M' variant requires a through junction descriptor ({ table, namespaceId, parentColumns, childColumns, targetColumns }); the non-junction variant carries through?: never. Purely additive: N:M contracts did not validate before this change, so no working extension constructs them, and existing 1:1 / 1:N / N:1 relation values match the non-junction variant unchanged. No codemod required.
Bug fix in @prisma-next/sql-orm-client — orderBy on a variant-narrowed collection now resolves MTI variant-owned fields (previously threw), mirroring the existing variant-aware where/first treatment. Additive: the no-variant orderBy path and its types are unchanged; no extension API change. No codemod required.
Release bump 0.13.0 (#789): version-number changes across all workspace package.json files and pnpm-lock.yaml specifiers. Incidental substrate diff — no extension-author action required. -->
0.12 → 0.13 — Extension-author upgrade instructions
sqlite-create-table-method
Starting at this release, createTable is no longer a free function exported from @prisma-next/sqlite/migration. It is now a protected method on the Migration base class — call it as this.createTable({...}) inside get operations().
If your extension ships SQLite migration files, update them to use this.createTable(...) and remove createTable from the import list.
If your extension has a facade re-export parity test that asserts createTable is defined, remove that assertion; add assertions for col, lit, fn, primaryKey, foreignKey, and unique if your test also checks that the column builders are exported.
The col(), lit(), fn(), primaryKey(), foreignKey(), and unique() builder helpers are now exported from @prisma-next/sqlite/migration directly.
See the user-skill entry sqlite-create-table-method for the full before/after migration steps — the authoring-surface change is identical for both user and extension migration files.
regen-extension-contracts-strip-empty-type-params
The contract canonicalizer now omits typeParams from storage.types entries when the value is an empty object. Previously, emitting a named-type alias like:
types {
Uuid = String @db.Uuid
}produced a contract.json entry such as:
"types": {
"Uuid": {
"codecId": "pg/text@1",
"kind": "codec-instance",
"nativeType": "uuid",
"typeParams": {}
}
}From this release the canonicalizer strips typeParams when it is empty, so the emitted form is:
"types": {
"Uuid": {
"codecId": "pg/text@1",
"kind": "codec-instance",
"nativeType": "uuid"
}
}Empty and absent typeParams are treated as equivalent at every comparison boundary, so the runtime behaviour is unchanged. The only visible effect is that re-emitting produces a different storageHash — the hash now reflects a contract.json without the empty key.
Re-emit your extension contract
If your extension's contract.json carries "typeParams": {} on any storage.types entry, re-emit to pick up the canonical form:
pnpm fixtures:emit
# or, for a single package:
pnpm --filter <your-extension-package> build:contract-spaceRe-pin migration baselines
Because the storageHash changes, re-generate the migration baselines so migrations/refs/head.json, end-contract.json, end-contract.d.ts, migration.json, migration.ts, and ops.json all reflect the new hash.
Note: scripts/regen-extension-migrations.mjs is a monorepo-internal tool thathard-codes packages/3-extensions/ paths. It does not exist in external extensionrepos. Follow the manual steps below.
1. Copy the freshly-emitted src/contract.json → migrations/refs/end-contract.json and src/contract.d.ts → migrations/refs/end-contract.d.ts. 2. Open your HEAD migration's migration.ts and update the to literal to the new storageHash from src/contract.json. 3. Run pnpm exec tsx migrations/<head-migration>/migration.ts (from the extension package root) to re-emit ops.json and migration.json. 4. Update migrations/refs/head.json — set "hash" to the new storageHash, preserving the existing "invariants" array unchanged.
Validation
After re-emitting and re-pinning, run pnpm typecheck && pnpm test --filter <your-extension-package>, then confirm prisma-next migration check passes. The contract.json diff should show "typeParams": {} removed from every storage.types entry.
thread-namespace-id-through-codec-ref-resolver-spi
Starting at the 0.13 release, every model/table sits in an explicit namespace, and the column-bound codec-resolution SPI in @prisma-next/sql-relational-core carries that namespace as a leading, required coordinate. If your extension stamps codec: CodecRef onto AST nodes at build time (the "CodecRef invariant for AST authors" path — descriptors.codecRefForColumn(...)), or calls the free codecRefForStorageColumn(...) against SqlStorage directly, you must thread the namespace coordinate through.
CodecDescriptorRegistry.codecRefForColumn
The registry method exported from @prisma-next/sql-relational-core/query-lane-context (the CodecDescriptorRegistry interface) and built by buildCodecDescriptorRegistry (@prisma-next/sql-relational-core/codec-descriptor-registry) gained a leading namespaceId parameter.
// Before 0.13
const ref = descriptors.codecRefForColumn('document', 'embedding');
// Starting at 0.13 — namespaceId leads the coordinate args
const ref = descriptors.codecRefForColumn('public', 'document', 'embedding');The namespace is whatever namespace the model/table you are building the ref for lives in — read it from the resolved table coordinate you already hold at the construction site, not a hard-coded literal. The table is now resolved strictly within that namespace, so two same-bare-named tables in different namespaces resolve to their own per-namespace column codecs without colliding.
codecRefForStorageColumn
The free function exported from @prisma-next/sql-relational-core/codec-descriptor-registry gained the same leading coordinate, inserted between storage and tableName.
// Before 0.13
const ref = codecRefForStorageColumn(storage, 'document', 'embedding');
// Starting at 0.13
const ref = codecRefForStorageColumn(storage, 'public', 'document', 'embedding');It now resolves the table via resolveStorageTable(storage, tableName, namespaceId) rather than scanning every namespace for the first bare-name match, so a name that is ambiguous across namespaces is no longer silently bound to whichever namespace happened to enumerate first.
Validation
This is a type-level signature change — pnpm typecheck (or pnpm build) pinpoints every call site that still passes the pre-0.13 argument list. Fix each one by inserting the namespace coordinate, then run your extension's standard pnpm test.
Validation by execution
This entry is prose-only — there is no colocated codemod, so no execution-replay applies. The right namespace coordinate is call-site-specific (it depends on which model/table the AST node is bound to), so the translation is per-site agent reasoning rather than a deterministic transform. The substrate diff inside packages/3-extensions/ in this transition is the same translation downstream extension authors replicate by hand: the namespace coordinate threaded through every column-bound codec-ref construction site. The release-pipeline gate (pnpm check:upgrade-coverage) is satisfied by this directory carrying at least one entry; the substantive verification of the consumer-facing translation lives in the published extension-upgrade skill's per-step bump-install-instructions-validate-commit loop, which runs in extension authors' own CI.
storage-namespace-envelope-re-emit
The storage IR inside contract.json moved to a namespace envelope in 0.13. Every table and type entry that was previously at the top level of storage now lives under storage.namespaces.<ns>.entries.<kind>. Cross-references that were bare strings are now { namespace, model } objects in domain. The emitter handles the shape change automatically — no source change is needed.
Because the shape change affects storageHash, every extension contract must be re-emitted and migration baselines re-pinned.
Re-emit your extension contract
pnpm --filter <your-extension-package> build:contract-spaceRe-pin migration baselines
Follow the manual re-pin steps described in the `regen-extension-contracts-strip-empty-type-params` section above: copy src/contract.{json,d.ts} to migrations/refs/end-contract.*, update migration.ts with the new storageHash, re-run tsx migration.ts to re-emit ops.json + migration.json, then update migrations/refs/head.json.
Validation
After re-emitting and re-pinning, run pnpm typecheck && pnpm test --filter <your-extension-package>, then confirm prisma-next migration check passes.
Declarative PSL-block SPI (additive)
Informational — no action required.
This release adds a declarative SPI for extension-contributed top-level PSL blocks. Register an AuthoringPslBlockDescriptor under AuthoringContributions.pslBlockDescriptors (exported from @prisma-next/framework-components) and the framework's generic PSL parser, validator, and printer handle the block round-trip through contract infer without any per-block parsing code. Each descriptor claims a PSL keyword and supplies the argument schema; a matching entityTypes entry lowers the parsed node to an IR class instance.
This is purely additive — existing extensions that use hand-written PSL-block parsers are unaffected. Adopt pslBlockDescriptors when you want the framework to own the parse/print cycle for a new top-level block your extension introduces.
Many-to-many contracts (additive)
No extension-author action required for the many-to-many change: M:N relations became a first-class, validatable contract shape this release ('N:M' cardinality with a required through junction descriptor). It is additive — existing non-junction relations and the public framework factories (crossRef, the contract-builder) are unchanged.
/**
* Removes bare migration op factory imports and rewrites call-sites to use
* the method form on `this` (0.13 → 0.14):
*
* dropColumn(schema, table, col) → this.dropColumn({ schema, table, column: col })
* setNotNull(schema, table, col) → this.setNotNull({ schema, table, column: col })
* setDefault(schema, table, col, sql)
* → this.setDefault({ schema, table, column: col, defaultSql: sql })
* addPrimaryKey(schema, table, name, cols)
* → this.addPrimaryKey({ schema, table, constraint: name, columns: cols })
* addForeignKey(schema, table, { name, columns, references, onDelete })
* → this.addForeignKey({ schema, table, foreignKey: { name, columns, references, onDelete } })
* addCheckConstraint(schema, table, name, col, vals)
* → this.addCheckConstraint({ schema, table, constraint: name, column: col, values: vals })
* createIndex(schema, table, idx, cols)
* → this.createIndex({ schema, table, index: idx, columns: cols })
* installExtension({ ... }) → this.installExtension({ ... })
*
* Applies to files importing from '@prisma-next/postgres/migration',
* '@prisma-next/target-postgres/migration', '@prisma-next/sqlite/migration', or
* '@prisma-next/target-sqlite/migration'. Handles only call-sites where all
* arguments are simple literals or identifiers on a single logical token (no
* multi-line positional calls). For complex cases the type-checker will flag
* remaining sites.
*
* Run from the project root:
* pnpm exec tsx <path-to-this-file>
*/
import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'pathe';
const FACTORY_NAMES = [
'dropColumn',
'setNotNull',
'setDefault',
'addPrimaryKey',
'addForeignKey',
'addCheckConstraint',
'createIndex',
'installExtension',
];
/**
* Strip bare factory names from import declarations (handles both single-line
* and multi-line `import { ... } from '...'` forms). Removes the whole import
* line (including trailing newline) when all names are factories.
*/
function stripFactoriesFromImports(src: string): string {
const importRe =
/^[^\S\n]*import\s*\{([^}]+)\}\s*from\s*'@prisma-next\/(?:postgres|target-postgres|sqlite|target-sqlite)\/migration'[^\S\n]*;?[^\S\n]*\n?/gms;
return src.replace(importRe, (full, nameBlock) => {
const names = nameBlock
.split(',')
.map((n: string) => n.trim())
.filter((n: string) => n.length > 0 && !FACTORY_NAMES.includes(n));
if (names.length === 0) return '';
const fromClause = full.slice(full.indexOf('}') + 1);
return `import { ${names.join(', ')} }${fromClause}`;
});
}
/** Reads a quoted string or bare identifier/bracket-balanced token starting at offset. */
function readToken(src: string, offset: number): { value: string; end: number } | null {
let i = offset;
while (i < src.length && src[i] === ' ') i++;
if (i >= src.length) return null;
if (src[i] === "'" || src[i] === '"' || src[i] === '`') {
const q = src[i];
let end = i + 1;
while (end < src.length && src[end] !== q) end++;
return { value: src.slice(i, end + 1), end: end + 1 };
}
let depth = 0;
let end = i;
while (end < src.length) {
const c = src[end];
if (c === '(' || c === '[' || c === '{') depth++;
else if (c === ')' || c === ']' || c === '}') {
if (depth === 0) break;
depth--;
} else if ((c === ',' || c === '\n') && depth === 0) break;
end++;
}
return { value: src.slice(i, end).trim(), end };
}
type Rewrite = {
pattern: RegExp;
rewrite: (m: RegExpExecArray) => string | null;
};
const rewrites: Rewrite[] = [
// dropColumn(schema, table, column)
{
pattern: /\bdropColumn\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const c = readToken(rest, t.end + 1);
if (!c) return null;
return `this.dropColumn({ schema: ${s.value}, table: ${t.value}, column: ${c.value} })`;
},
},
// setNotNull(schema, table, column)
{
pattern: /\bsetNotNull\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const c = readToken(rest, t.end + 1);
if (!c) return null;
return `this.setNotNull({ schema: ${s.value}, table: ${t.value}, column: ${c.value} })`;
},
},
// setDefault(schema, table, column, defaultSql)
{
pattern: /\bsetDefault\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const c = readToken(rest, t.end + 1);
if (!c) return null;
const d = readToken(rest, c.end + 1);
if (!d) return null;
return `this.setDefault({ schema: ${s.value}, table: ${t.value}, column: ${c.value}, defaultSql: ${d.value} })`;
},
},
// addPrimaryKey(schema, table, constraintName, columns)
{
pattern: /\baddPrimaryKey\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const n = readToken(rest, t.end + 1);
if (!n) return null;
const c = readToken(rest, n.end + 1);
if (!c) return null;
return `this.addPrimaryKey({ schema: ${s.value}, table: ${t.value}, constraint: ${n.value}, columns: ${c.value} })`;
},
},
// addCheckConstraint(schema, table, constraintName, column, values)
{
pattern: /\baddCheckConstraint\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const n = readToken(rest, t.end + 1);
if (!n) return null;
const c = readToken(rest, n.end + 1);
if (!c) return null;
const v = readToken(rest, c.end + 1);
if (!v) return null;
return `this.addCheckConstraint({ schema: ${s.value}, table: ${t.value}, constraint: ${n.value}, column: ${c.value}, values: ${v.value} })`;
},
},
// createIndex(schema, table, indexName, columns)
{
pattern: /\bcreateIndex\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const idx = readToken(rest, t.end + 1);
if (!idx) return null;
const c = readToken(rest, idx.end + 1);
if (!c) return null;
return `this.createIndex({ schema: ${s.value}, table: ${t.value}, index: ${idx.value}, columns: ${c.value} })`;
},
},
// addForeignKey(schema, table, { ... }) — wraps opts in `foreignKey:`
{
pattern: /\baddForeignKey\(/g,
rewrite(m) {
const rest = m.input.slice(m.index + m[0].length);
const s = readToken(rest, 0);
if (!s) return null;
const t = readToken(rest, s.end + 1);
if (!t) return null;
const opts = readToken(rest, t.end + 1);
if (!opts) return null;
return `this.addForeignKey({ schema: ${s.value}, table: ${t.value}, foreignKey: ${opts.value} })`;
},
},
];
function applyRewrites(src: string): string {
// installExtension already takes an object — just prepend `this.`
let out = src.replace(/(?<!this\.)(?<!\.)\binstallExtension\(/g, 'this.installExtension(');
for (const { pattern, rewrite } of rewrites) {
pattern.lastIndex = 0;
let result = '';
let last = 0;
let match = pattern.exec(out);
while (match !== null) {
const before = out.slice(Math.max(0, match.index - 5), match.index);
if (before.endsWith('this.')) {
result += out.slice(last, match.index + match[0].length);
last = match.index + match[0].length;
match = pattern.exec(out);
continue;
}
const replacement = rewrite(match);
if (replacement === null) {
result += out.slice(last, match.index + match[0].length);
last = match.index + match[0].length;
match = pattern.exec(out);
continue;
}
// Find the matching closing paren for the original call
let depth = 1;
let end = match.index + match[0].length;
while (end < out.length && depth > 0) {
if (out[end] === '(') depth++;
else if (out[end] === ')') depth--;
end++;
}
result += out.slice(last, match.index) + replacement;
last = end;
pattern.lastIndex = last;
match = pattern.exec(out);
}
out = result + out.slice(last);
}
return out;
}
function processFile(src: string): string {
const MIGRATION_IMPORT_RE =
/import\s*\{[^}]+\}\s*from\s*'@prisma-next\/(?:postgres|target-postgres|sqlite|target-sqlite)\/migration'/s;
if (!MIGRATION_IMPORT_RE.test(src)) return src;
const withImports = stripFactoriesFromImports(src);
return applyRewrites(withImports);
}
const raw = execSync(
'git ls-files --cached --others --exclude-standard -- "**migration.ts" "migration.ts"',
{ encoding: 'utf-8' },
).trim();
const files = raw
.split('\n')
.filter(Boolean)
.filter((f) => f.endsWith('migration.ts'));
let changed = 0;
for (const file of files) {
const abs = join(process.cwd(), file);
let content: string;
try {
content = readFileSync(abs, 'utf-8');
} catch {
continue;
}
const relevant =
content.includes("from '@prisma-next/postgres/migration'") ||
content.includes("from '@prisma-next/target-postgres/migration'") ||
content.includes("from '@prisma-next/sqlite/migration'") ||
content.includes("from '@prisma-next/target-sqlite/migration'");
if (!relevant) continue;
const updated = processFile(content);
if (updated !== content) {
writeFileSync(abs, updated, 'utf-8');
console.log(`updated ${file}`);
changed++;
}
}
console.log(`done — ${changed} file(s) updated`);
/**
* 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-2787 (M:N slice 3): namespace-scoped execution-default refs land in @prisma-next/sql-orm-client (nested writes through a junction, the required-payload gate, and the namespace-keyed ExecutionMutationDefault.ref). The changes are internal to the ORM client and its emitted-contract consumption; the extension-author surface is unchanged. No extension-author action — re-emit picks up the new contract ref shape. Incidental substrate diff only.
TML-2929 (replace legacy PSL parser with CST symbol table): the SQL/Mongo PSL interpreters now consume a symbol table built from the CST parser instead of the legacy parsePslDocument AST. The only packages/3-extensions/ touch is a test-file call-shape rewire in postgres/test/psl-namespace-qualifier-routing.test.ts ({ document } → the symbol-table interpreter input); no extension-author API changed. No extension-author action. Incidental substrate diff only. -->
No extension-side migration actions are required for this transition at this time. changes: [] intentionally marks this transition as a no-op.
0.8 → 0.9 — Extension-author upgrade instructions
strip-inline-contracts-from-migration-manifests
Starting at the 0.9 release, migration.json no longer carries fromContract or toContract. The schema rejects those keys as unknown, so any committed manifest that still inlines them fails to load with MIGRATION.INVALID_MANIFEST.
This applies to seed migrations shipped inside an extension package (e.g. migrations/<edge-id>/migration.json shipped under packages/<extension>/migrations/) just as it does to user-app migrations. The destination contract was already being written to disk next door as end-contract.json (and the source as start-contract.json); the manifest copy was redundant. migrationHash is unaffected — it has always been computed without those two fields, so stripping them does not change the stored hash.
What strip-inline-contracts.ts does
The colocated script walks the project root, descends into every directory named migrations/ (skipping node_modules, .git, dist, build), and rewrites every migration.json whose JSON object contains either key:
- Manifests that already lack both keys are left untouched (idempotent — safe to re-run).
- Manifests with either key are rewritten with the two key/value spans excised at the text level. The formatting of every surviving field (whitespace, inline-vs-multiline arrays, key ordering, trailing newline) is preserved byte-for-byte; only the removed keys (and their trailing comma+newline) disappear from the diff. The script reparses the result to guard against accidental corruption.
- A
--checkflag turns the script into a dry-run; it lists which manifests would be modified and exits non-zero if any still need fixing.
drop-migration-metadata-contract-fields-from-source
The MigrationMetadata type exported by @prisma-next/migration-tools no longer declares fromContract or toContract. Source TypeScript that constructs (or destructures) MigrationMetadata from those fields will now fail to compile.
There is no codemod — extension authors construct MigrationMetadata in too many shapes for a deterministic transform to be safe. Instead, walk every .ts / .tsx file matched by the detection.glob above and apply these rules locally:
- Object-literal construction (e.g.
const meta: MigrationMetadata = { ..., fromContract, toContract }): drop both properties from the literal. If the value is later needed, read the contract from the siblingend-contract.json(or the predecessor'send-contract.jsonfor the from-side) instead of carrying it in metadata. - Spread-into-existing (e.g.
const meta = { ...prev, fromContract: ..., toContract: ... }): drop the two keys from the spread. Ifprevwas loaded from disk viareadMigrationPackage, it already lacks the fields under 0.9 — no further work needed. - Destructuring (e.g.
const { fromContract, toContract, ...rest } = meta): remove both names from the destructure. If the consuming code used those values, switch the read to the siblingend-contract.json. - Type-only references (e.g.
metadata.toContract,Pick<MigrationMetadata, 'fromContract'>, etc.): TypeScript will surface these as compile errors after the bump. Replace with sibling-file reads or remove the field reference entirely.
If your extension also carries seed-migration manifests (the common case for extensions that ship a migrations/ directory), the strip-inline-contracts-from-migration-manifests change above handles those at the JSON layer. Run that script first; the source-code change above only covers TypeScript that produces / consumes MigrationMetadata programmatically.
While at it, scan any seed migration.ts doc-comments in your extension for stale references to metadata.toContract (e.g. "preserving the full `toContract` so `MigrationCLI.run` re-attests it"). Those references were accurate under 0.8 and are no longer accurate under 0.9 — the MigrationCLI.run re-attestation now reads the destination contract from sibling end-contract.json, not from metadata.toContract. Update or remove the stale prose. This is documentation hygiene, not a structural break.
Validation
After running the script and applying the source-level rules above, run pnpm typecheck && pnpm test (or your extension's own equivalent). prisma-next-check-pins should also pass, since it does not look at MigrationMetadata shape.
Related skills
FAQ
What does prisma-next-extension-upgrade do?
>-
When should I use prisma-next-extension-upgrade?
Invoke when >-.
Is prisma-next-extension-upgrade safe to install?
Review the Security Audits panel on this page before installing in production.