Skip to content

feat(sql-runtime): transaction isolation level option - #30333

Draft
kristof-siket wants to merge 1 commit into
transaction-marker-before-beginfrom
transaction-isolation-level
Draft

kristof-siket wants to merge 1 commit into
transaction-marker-before-beginfrom
transaction-isolation-level

Conversation

@kristof-siket

Copy link
Copy Markdown
Contributor

Linked issue

n/a — no issue exists yet. CONTRIBUTING.md asks for an issue before a feature, and the runtime subsystem doc lists "default isolation level only" as a deferred non-goal, so this is a draft to get direction-fit feedback on a concrete shape. It is stacked on #30332 (the marker-before-BEGIN fix) and targets that branch.

Summary

Users who need REPEATABLE READ or SERIALIZABLE have to hand-write SET TRANSACTION ISOLATION LEVEL ... as the first statement of a transaction, because no layer takes an isolation level. That raw SET is what exposed the bug fixed in #30332, and it stays fragile: it only works while nothing else runs first in the transaction. This PR adds an isolationLevel option from the driver interface up to db.transaction, and the Postgres driver applies it in the BEGIN statement itself.

await db.transaction(
  async (tx) => {
    const user = await tx.orm.public.User.where({ id }).first();
    if (user) await tx.orm.public.Post.where({ userId: user.id }).update({ status: 'archived' });
  },
  { isolationLevel: 'serializable' },
);

The change is additive and non-breaking. Every new parameter is optional, omitting it sends the same plain BEGIN as before, and a driver or RuntimeConnection written against the old zero-argument signatures still type-checks.

API added

// @internal/sql-relational-core/ast (driver-types.ts), re-exported from @internal/sql-runtime
type SqlIsolationLevel = 'readUncommitted' | 'readCommitted' | 'repeatableRead' | 'serializable';
interface SqlTransactionOptions { readonly isolationLevel?: SqlIsolationLevel }

interface SqlConnection   { beginTransaction(options?: SqlTransactionOptions): Promise<SqlTransaction> }
interface RuntimeConnection { transaction(options?: SqlTransactionOptions): Promise<RuntimeTransaction> }
function withTransaction<R>(runtime: ConnectionProvider, fn: (tx: TransactionContext) => PromiseLike<R>, options?: SqlTransactionOptions): Promise<R>

// Postgres client and Supabase RoleBoundDb
transaction<R>(fn, options?: SqlTransactionOptions): Promise<R>

New error code: DRIVER.ISOLATION_LEVEL_UNSUPPORTED (payload target, isolationLevel).

Behaviour per target

  • Postgres driver (one connection class serves the pooled and the single-client binding): BEGIN ISOLATION LEVEL <LEVEL> as one statement when a level is given, plain BEGIN otherwise. The level name comes from a fixed map looked up with Object.hasOwn; an unknown value from an untyped caller rejects with DRIVER.ISOLATION_LEVEL_UNSUPPORTED and sends nothing.
  • SQLite driver: rejects any requested level with DRIVER.ISOLATION_LEVEL_UNSUPPORTED before BEGIN. Every SQLite transaction is serializable, so there is no level to select. The SQLite client's db.transaction(fn) keeps its one-argument signature, so asking for a level there is a compile error; the runtime error covers withTransaction(runtime, fn, options).
  • Supabase: the role session's transaction(options) and RoleBoundDb.transaction(fn, options) forward to the Postgres driver.
  • Mongo: untouched. It does not share SqlConnection or RuntimeConnection.
  • withTransaction now releases the connection and skips the callback when connection.transaction(options) rejects. Before this PR a failed BEGIN left the connection checked out; an unsupported level makes that path easy to reach, so it is fixed here.

Testing performed

New tests:

  • packages/3-targets/7-drivers/postgres/test/driver.isolation-level.integration.test.ts (real PGlite database): each of the four levels is applied on the single-client driver (show transaction_isolation inside the transaction); serializable is applied on the pooled driver; no level keeps read committed, including after a serializable transaction on the same connection; BEGIN is one statement (BEGIN, BEGIN ISOLATION LEVEL REPEATABLE READ); an unknown level rejects and sends nothing.
  • packages/3-targets/7-drivers/sqlite/test/sqlite-driver.test.ts: a requested level rejects with the structured error, no transaction is begun, and the connection still works.
  • packages/2-sql/5-runtime/test/sql-runtime.test.ts: options reach the driver from withTransaction and from connection.transaction(); no options means beginTransaction(undefined); a failed begin releases the connection and skips the callback.
  • packages/3-extensions/postgres/test/postgres.test.ts and transaction.types.test-d.ts: db.transaction(fn, { isolationLevel }) sends BEGIN ISOLATION LEVEL SERIALIZABLE; the option is typed, and an unknown level is a type error.
  • packages/3-extensions/sqlite/test/transaction.types.test-d.ts: the SQLite client rejects the option at compile time.
  • packages/3-extensions/supabase/test/supabase-runtime.test.ts: the role session forwards the option.
  • packages/2-sql/4-lanes/relational-core/test/ast/driver-types.types.test-d.ts: the options type, and that a zero-argument beginTransaction still satisfies SqlConnection.
  • e2e: test/e2e/framework/test/transaction.test.ts (fresh runtime, withTransaction with serializable, then default read committed), transaction-orm.test.ts (db.transaction(fn, { isolationLevel: 'repeatableRead' }) with an ORM write inside), sqlite/transaction.test.ts (rejects, database stays usable).

Suites run on the final commit:

  • pnpm build — Tasks: 86 successful, 86 total
  • pnpm typecheck — exit 0 (Tasks: 169 successful, 169 total)
  • pnpm lint — exit 0 (Tasks: 101 successful, 101 total)
  • pnpm lint:deps — exit 0 (no dependency violations found)
  • pnpm test:packages — Test Files 1287 passed | 1 skipped (1288), Tests 17272 passed | 3 expected fail | 1 skipped (17276)
  • pnpm test:e2e — Test Files 22 passed (22), Tests 123 passed (123)
  • pnpm test:integration — Test Files 395 passed (395), Tests 2161 passed | 52 expected fail (2213)
  • pnpm check:error-reference — error-reference lists all 321 known codes.
  • pnpm lint:skills — All skills passed validation (skills-contrib, skills).
  • pnpm check:upgrade-coverage --mode pr --prev <#30332 head> --head HEAD — exit 0
  • Upgrade fragment validated by execution per record-upgrade-instructions: restored packages/3-extensions/ to the fix(sql-runtime): verify contract marker before BEGIN #30332 head, applied the fragment, git status --porcelain outside test directories printed nothing, test directories stayed at base, and pnpm test --filter='./packages/3-extensions/*' passed (Tasks: 67 successful, 67 total).

Skill update

skills/prisma-8/references/queries-postgres.md (Workflow — Transactions) teaches db.transaction(fn, { isolationLevel }), tells the agent not to hand-write SET TRANSACTION, and notes that serialization failures (SQLSTATE 40001) are not retried. references/runtime.md states the Postgres / SQLite difference and references/supabase.md the RoleBoundDb.transaction(fn, options?) signature.

Checklist

  • All commits are signed off (git commit -s) per the DCO. The DCO status check will block merge if any commit is missing a Signed-off-by: trailer.
  • I read CONTRIBUTING.md and the change is scoped to one logical concern.
  • Tests are updated (or n/a if the change is doc-only / refactor with no behavioural delta).
  • The PR title is in TML-NNNN: <sentence-case title> form (Linear ticket prefix + concise title naming the concrete deliverable). See .claude/skills/create-pr/SKILL.md for the full convention. — No Linear ticket exists for this change, so the title uses the conventional-commit form from CONTRIBUTING.md. Happy to retitle once a ticket exists.
  • The Skill update section above is filled in (or stated n/a — internal only).

Notes for the reviewer

  • This reverses a documented non-goal. docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md listed "default isolation level only" as deferred, and scorecard/14-transactions.md marked isolationLevel as not in 8.0. Both are updated here. If the deferral should stand for 8.0, close this PR; fix(sql-runtime): verify contract marker before BEGIN #30332 does not depend on it.
  • Unsupported targets reject in the driver, with no capability flag. Contract capabilities gate query authoring; an isolation level is a property of one BEGIN, known only at run time, and the driver is the layer that owns that statement. A driver-side structured error keeps the runtime free of target branches.
  • SQLite rejects 'serializable' too, although that is the level SQLite always gives. Accepting one value and rejecting three would make the option look supported; the error message says the transaction is already serializable and to remove the option.
  • Names are camelCase to match the repo's other literal unions ('noAction' | 'setNull', 'onFirstUse'), mapped to the PostgreSQL keywords in the driver.
  • Not included: retry on SQLSTATE 40001, READ ONLY / DEFERRABLE, client-level default options, timeouts. The options object leaves room for them.
  • Upgrade fragment: upgrade-instructions/pending/transaction-isolation-level/extension/ — extension authors who implement SqlConnection or build their own RuntimeConnection forward the new argument. No app fragment: examples/ is unchanged and app code needs no edit.
  • Docs touched: the runtime subsystem doc (new Isolation level section, non-goals), docs/reference/error-reference.md, the scorecard row, and the sql-runtime / Postgres driver / SQLite driver READMEs.

Users who need REPEATABLE READ or SERIALIZABLE had to hand-write
SET TRANSACTION ISOLATION LEVEL as the first statement of a transaction,
because no layer took an isolation level.

SqlConnection.beginTransaction, RuntimeConnection.transaction,
withTransaction, and the Postgres and Supabase db.transaction take an
optional { isolationLevel }. The Postgres driver sends
BEGIN ISOLATION LEVEL <LEVEL> as one statement, and plain BEGIN when no
level is given. The SQLite driver rejects a requested level with
DRIVER.ISOLATION_LEVEL_UNSUPPORTED, because every SQLite transaction is
serializable; the SQLite client keeps its one-argument transaction(fn).

withTransaction releases the connection when the transaction cannot
begin, so a rejected level does not leave a connection checked out.

The change is additive: every new parameter is optional and existing
zero-argument implementations still type-check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Kristof Siket <siket@prisma.io>
@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Sep 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

@prisma/orm-extension-arktype-json

npm i https://pkg.pr.new/@prisma/orm-extension-arktype-json@30333

@prisma/orm-extension-middleware-cache

npm i https://pkg.pr.new/@prisma/orm-extension-middleware-cache@30333

@prisma/orm-extension-paradedb

npm i https://pkg.pr.new/@prisma/orm-extension-paradedb@30333

@prisma/orm-extension-pgvector

npm i https://pkg.pr.new/@prisma/orm-extension-pgvector@30333

@prisma/orm-extension-postgis

npm i https://pkg.pr.new/@prisma/orm-extension-postgis@30333

@prisma/orm-extension-supabase

npm i https://pkg.pr.new/@prisma/orm-extension-supabase@30333

@prisma/orm-family-mongo

npm i https://pkg.pr.new/@prisma/orm-family-mongo@30333

@prisma/orm-family-sql

npm i https://pkg.pr.new/@prisma/orm-family-sql@30333

@prisma/orm-framework

npm i https://pkg.pr.new/@prisma/orm-framework@30333

@prisma/orm-mongo

npm i https://pkg.pr.new/@prisma/orm-mongo@30333

@prisma/orm-postgres

npm i https://pkg.pr.new/@prisma/orm-postgres@30333

@prisma/orm-sqlite

npm i https://pkg.pr.new/@prisma/orm-sqlite@30333

@prisma/orm-target-mongo

npm i https://pkg.pr.new/@prisma/orm-target-mongo@30333

@prisma/orm-target-postgres

npm i https://pkg.pr.new/@prisma/orm-target-postgres@30333

@prisma/orm-target-sqlite

npm i https://pkg.pr.new/@prisma/orm-target-sqlite@30333

@prisma/orm-toolchain

npm i https://pkg.pr.new/@prisma/orm-toolchain@30333

commit: a5c6f5f

@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
postgres / no-emit 189.54 KB (+0.12% 🔺)
postgres / emit 160.06 KB (+0.12% 🔺)
mongo / no-emit 109.31 KB (0%)
mongo / emit 91.8 KB (0%)
cf-worker / no-emit 212.59 KB (+0.1% 🔺)
cf-worker / emit 180.1 KB (+0.12% 🔺)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant