From a5c6f5f36cd2bd38b8aa72f7117798b460ee5d41 Mon Sep 17 00:00:00 2001 From: Kristof Siket Date: Thu, 17 Sep 2026 15:51:11 +0200 Subject: [PATCH] feat(sql-runtime): transaction isolation level option 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 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 Signed-off-by: Kristof Siket --- .../4. Runtime & Middleware Framework.md | 12 +- docs/reference/error-reference.md | 4 + .../relational-core/src/ast/driver-types.ts | 18 ++- .../test/ast/driver-types.types.test-d.ts | 14 ++ packages/2-sql/5-runtime/README.md | 3 +- packages/2-sql/5-runtime/src/exports/index.ts | 6 +- packages/2-sql/5-runtime/src/sql-runtime.ts | 16 +- .../2-sql/5-runtime/test/sql-runtime.test.ts | 46 ++++++ .../postgres/src/runtime/postgres.ts | 73 +++++---- .../postgres/test/postgres.test.ts | 19 +++ .../postgres/test/transaction.types.test-d.ts | 14 ++ .../sqlite/test/transaction.types.test-d.ts | 7 + .../supabase/src/runtime/supabase-runtime.ts | 10 +- .../supabase/src/runtime/supabase.ts | 18 ++- .../supabase/test/supabase-runtime.test.ts | 20 ++- .../3-targets/7-drivers/postgres/README.md | 1 + .../7-drivers/postgres/src/driver-error.ts | 6 +- .../7-drivers/postgres/src/postgres-driver.ts | 28 +++- ...driver.isolation-level.integration.test.ts | 145 ++++++++++++++++++ packages/3-targets/7-drivers/sqlite/README.md | 2 +- .../7-drivers/sqlite/src/sqlite-driver.ts | 15 +- .../sqlite/test/sqlite-driver.test.ts | 21 +++ scorecard/14-transactions.md | 2 +- .../prisma-8/references/queries-postgres.md | 12 ++ skills/prisma-8/references/runtime.md | 2 + skills/prisma-8/references/supabase.md | 2 +- .../framework/test/sqlite/transaction.test.ts | 16 ++ .../framework/test/transaction-orm.test.ts | 18 +++ test/e2e/framework/test/transaction.test.ts | 16 ++ .../extension/instructions.md | 29 ++++ 30 files changed, 539 insertions(+), 56 deletions(-) create mode 100644 packages/3-targets/7-drivers/postgres/test/driver.isolation-level.integration.test.ts create mode 100644 upgrade-instructions/pending/transaction-isolation-level/extension/instructions.md diff --git a/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md b/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md index 468db24a9468..a8c96b5fea72 100644 --- a/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md +++ b/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md @@ -535,6 +535,16 @@ The marker read always finishes before the connection is acquired, so it never r ORM nested mutations (`withMutationScope` in the SQL ORM mutation executor) detect that they are running inside a transaction context and reuse the transaction's `RuntimeQueryable` instead of acquiring a new connection. This is why `RuntimeConnection` and `RuntimeTransaction` extend the same `RuntimeScope` slice that `RuntimeQueryable` does — both compose against one canonical surface (see [ORM Client Integration](#orm-client-integration) above). +### Isolation level + +`db.transaction(fn, options)` on the Postgres and Supabase clients, `withTransaction(runtime, fn, options)`, and `RuntimeConnection.transaction(options)` take an optional `isolationLevel`: `'readUncommitted'`, `'readCommitted'`, `'repeatableRead'` or `'serializable'`. The runtime passes it unchanged to the driver's `SqlConnection.beginTransaction(options)`. Omitting it begins the transaction at the database default, exactly as before. + +```typescript +await db.transaction(async (tx) => { /* ... */ }, { isolationLevel: 'serializable' }) +``` + +The driver owns the SQL. The Postgres driver begins with one statement, `BEGIN ISOLATION LEVEL `, so no separate `SET TRANSACTION` has to win the race to be the transaction's first statement. A driver whose database cannot apply a requested level rejects with `DRIVER.ISOLATION_LEVEL_UNSUPPORTED` before it begins anything; it never ignores the option. The SQLite driver rejects every requested level, because every SQLite transaction is already serializable and there is nothing to select, and the SQLite client's `db.transaction(fn)` takes no options. When `transaction(options)` rejects, `withTransaction` releases the connection and does not call the callback. + ### `AsyncIterableResult` and transaction scope Query results are lazy and must be consumed while the transaction connection is valid. Statistics execution is eager: @@ -548,7 +558,7 @@ The transaction invalidates its queryable on commit or rollback. A later pull fr ### Non-goals -Default isolation level only (typically `READ COMMITTED` on Postgres); savepoints / nested transactions; automatic retry on serialization failures; an interactive `db.begin()` API; built-in transaction timeouts; and Mongo-family transactions. Each is an explicit deferral with its own follow-up. Users who need any of these wrap `transaction(...)` themselves or use the lower-level `RuntimeTransaction` SPI directly. +Savepoints / nested transactions; automatic retry on serialization failures; an interactive `db.begin()` API; built-in transaction timeouts; and Mongo-family transactions. Each is an explicit deferral with its own follow-up. Users who need any of these wrap `transaction(...)` themselves or use the lower-level `RuntimeTransaction` SPI directly. ## Middleware API diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 20cf3a0510eb..8de4ed51e35c 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -928,6 +928,10 @@ Calling `connect(binding)` on a driver — or `connect()` on a target facade cli A control-plane driver could not establish a database connection (`driver.create(url)` in the SQLite, Postgres, and Mongo control drivers). The `why` carries the underlying driver message and the original error is attached as `cause`; connection URLs in meta are redacted. Payload: `path` (SQLite); `sqlState` plus redacted URL fields (Postgres); redacted URL fields (Mongo). +### DRIVER.ISOLATION_LEVEL_UNSUPPORTED + +A transaction was begun with an `isolationLevel` the target database cannot apply. The SQLite driver raises it for every requested level: each SQLite transaction is already serializable, so remove the option. The Postgres driver raises it for a value outside `readUncommitted`, `readCommitted`, `repeatableRead` and `serializable`, which only untyped callers can pass. The driver rejects before it begins the transaction, so the connection stays usable and `db.transaction(...)` / `withTransaction(...)` release it without calling the callback. Payload: `target`, `isolationLevel`. + ### DRIVER.NOT_CONNECTED Using a driver, a target facade client, or the CLI control client before `connect(...)` has been called (or after it was closed) — surfaces from runtime `query` / `execute`, a prepared statement's `query(target, params, options?)`, `acquireConnection`, or `explain`, including lazily when iterating a query result. diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/driver-types.ts b/packages/2-sql/4-lanes/relational-core/src/ast/driver-types.ts index c662b02947e1..59fbea3261dd 100644 --- a/packages/2-sql/4-lanes/relational-core/src/ast/driver-types.ts +++ b/packages/2-sql/4-lanes/relational-core/src/ast/driver-types.ts @@ -38,8 +38,24 @@ export interface SqlDriver extends SqlQueryable { close(): Promise; } +export type SqlIsolationLevel = + | 'readUncommitted' + | 'readCommitted' + | 'repeatableRead' + | 'serializable'; + +export interface SqlTransactionOptions { + /** + * Isolation level for the transaction. Omit it to use the database default. A driver whose + * database cannot apply the requested level MUST reject with + * `DRIVER.ISOLATION_LEVEL_UNSUPPORTED` before it begins the transaction; it MUST NOT ignore + * the option. + */ + readonly isolationLevel?: SqlIsolationLevel; +} + export interface SqlConnection extends SqlQueryable { - beginTransaction(): Promise; + beginTransaction(options?: SqlTransactionOptions): Promise; /** * Returns the connection to the pool for reuse. Must only be called when the * connection is known to be in a clean, reusable state. If a transaction diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/driver-types.types.test-d.ts b/packages/2-sql/4-lanes/relational-core/test/ast/driver-types.types.test-d.ts index bd9bd1f7d4e5..e2db93e96315 100644 --- a/packages/2-sql/4-lanes/relational-core/test/ast/driver-types.types.test-d.ts +++ b/packages/2-sql/4-lanes/relational-core/test/ast/driver-types.types.test-d.ts @@ -5,6 +5,7 @@ import type { SqlExecuteRequest, SqlStatementStats, SqlTransaction, + SqlTransactionOptions, } from '../../src/ast/driver-types'; type PoolBinding = { pool: { connect: () => Promise } }; @@ -63,3 +64,16 @@ test('mock driver implementing SqlDriver compiles and accepts bindi expectTypeOf(driver.connect).toBeFunction(); expectTypeOf(driver.connect).parameter(0).toEqualTypeOf(); }); + +test('beginTransaction takes an optional isolation level', () => { + expectTypeOf() + .parameter(0) + .toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + 'readUncommitted' | 'readCommitted' | 'repeatableRead' | 'serializable' | undefined + >(); +}); + +test('a driver written without the options parameter still satisfies SqlConnection', () => { + expectTypeOf<() => Promise>().toExtend(); +}); diff --git a/packages/2-sql/5-runtime/README.md b/packages/2-sql/5-runtime/README.md index 00e504297fb8..34905debe42f 100644 --- a/packages/2-sql/5-runtime/README.md +++ b/packages/2-sql/5-runtime/README.md @@ -71,7 +71,8 @@ const db = postgres({ - `SqlRuntime` - Abstract family-layer base class; subclass to build a target runtime (construction happens via target factories — `postgres()` from `@internal/postgres`, `sqlite()` from `@internal/sqlite`) - `Runtime` - Runtime instance interface -- `withTransaction` - Helper to run a callback inside a transaction against any `Runtime` +- `withTransaction` - Helper to run a callback inside a transaction against any `Runtime`; the optional third argument carries `SqlTransactionOptions` (`isolationLevel`) to the driver +- `SqlTransactionOptions`, `SqlIsolationLevel` - Options accepted by `withTransaction` and `RuntimeConnection.transaction` - `VerifyMarkerOption` - Marker-verification option (`'onFirstUse'` default; `false` to skip) - `RuntimeTelemetryEvent`, `TelemetryOutcome` - Telemetry event types diff --git a/packages/2-sql/5-runtime/src/exports/index.ts b/packages/2-sql/5-runtime/src/exports/index.ts index 85483386fe2e..3c6361293e3e 100644 --- a/packages/2-sql/5-runtime/src/exports/index.ts +++ b/packages/2-sql/5-runtime/src/exports/index.ts @@ -5,7 +5,11 @@ export type { QueryInterceptResult, RuntimeLog as Log, } from '@internal/framework-components/runtime'; -export type { MarkerReadResult } from '@internal/sql-relational-core/ast'; +export type { + MarkerReadResult, + SqlIsolationLevel, + SqlTransactionOptions, +} from '@internal/sql-relational-core/ast'; export { createAstCodecRegistry } from '../codecs/ast-codec-registry'; export { deriveParamMetadata, encodeParamsWithMetadata } from '../codecs/encoding'; export { diff --git a/packages/2-sql/5-runtime/src/sql-runtime.ts b/packages/2-sql/5-runtime/src/sql-runtime.ts index 77eae03f2263..698996c4b170 100644 --- a/packages/2-sql/5-runtime/src/sql-runtime.ts +++ b/packages/2-sql/5-runtime/src/sql-runtime.ts @@ -26,6 +26,7 @@ import type { SqlQueryable, SqlStatementStats, SqlTransaction, + SqlTransactionOptions, } from '@internal/sql-relational-core/ast'; import { collectOrderedParamRefs } from '@internal/sql-relational-core/ast'; import type { CodecTypesBase } from '@internal/sql-relational-core/expression'; @@ -120,7 +121,7 @@ export interface Runtime extends RuntimeQueryable { } export interface RuntimeConnection extends RuntimeQueryable { - transaction(): Promise; + transaction(options?: SqlTransactionOptions): Promise; /** * Returns the connection to the pool for reuse. Only call this when the connection is known to be in a clean state. If a transaction commit/rollback failed or the connection is otherwise suspect, call `destroy(reason)` instead. */ @@ -779,8 +780,8 @@ export abstract class SqlRuntimeBase = Co const wrappedConnection: RuntimeConnection & PreparedStatementQueryTarget & PreparedStatementExecuteTarget = { - async transaction(): Promise { - const driverTx = await driverConn.beginTransaction(); + async transaction(options?: SqlTransactionOptions): Promise { + const driverTx = await driverConn.beginTransaction(options); return self.wrapTransaction(driverTx); }, async release(): Promise { @@ -986,9 +987,16 @@ export interface ConnectionProvider { export async function withTransaction( runtime: ConnectionProvider, fn: (tx: TransactionContext) => PromiseLike, + options?: SqlTransactionOptions, ): Promise { const connection = await runtime.connection(); - const transaction = await connection.transaction(); + let transaction: RuntimeTransaction; + try { + transaction = await connection.transaction(options); + } catch (error) { + await connection.release().catch(() => undefined); + throw error; + } let invalidated = false; diff --git a/packages/2-sql/5-runtime/test/sql-runtime.test.ts b/packages/2-sql/5-runtime/test/sql-runtime.test.ts index 8f9e860e5888..02fc80384e9b 100644 --- a/packages/2-sql/5-runtime/test/sql-runtime.test.ts +++ b/packages/2-sql/5-runtime/test/sql-runtime.test.ts @@ -62,6 +62,7 @@ interface DriverMockSpies { transactionStats: ReturnType; connectionRelease: ReturnType; connectionDestroy: ReturnType; + connectionBeginTransaction: ReturnType; transactionCommit: ReturnType; transactionRollback: ReturnType; driverClose: ReturnType; @@ -160,6 +161,7 @@ function createMockDriver(): MockSqlDriver { transactionStats, connectionRelease: connection.release, connectionDestroy: connection.destroy, + connectionBeginTransaction: connection.beginTransaction, transactionCommit: transaction.commit, transactionRollback: transaction.rollback, driverClose, @@ -1118,6 +1120,50 @@ describe('withTransaction', () => { expect(txRef!.invalidated).toBe(true); }); + it('begins with no options by default', async () => { + const { runtime, driver } = createRuntimeForTransaction(); + + await withTransaction(runtime, async () => undefined); + + expect(driver.__spies.connectionBeginTransaction).toHaveBeenCalledExactlyOnceWith(undefined); + }); + + it('forwards the isolation level to the driver', async () => { + const { runtime, driver } = createRuntimeForTransaction(); + + await withTransaction(runtime, async () => undefined, { isolationLevel: 'serializable' }); + + expect(driver.__spies.connectionBeginTransaction).toHaveBeenCalledExactlyOnceWith({ + isolationLevel: 'serializable', + }); + }); + + it('forwards the isolation level from connection.transaction()', async () => { + const { runtime, driver } = createRuntimeForTransaction(); + const connection = await runtime.connection(); + + await connection.transaction({ isolationLevel: 'repeatableRead' }); + + expect(driver.__spies.connectionBeginTransaction).toHaveBeenCalledExactlyOnceWith({ + isolationLevel: 'repeatableRead', + }); + }); + + it('releases the connection and skips the callback when the transaction cannot begin', async () => { + const { runtime, driver } = createRuntimeForTransaction(); + const beginError = new Error('isolation level unsupported'); + driver.__spies.connectionBeginTransaction.mockRejectedValueOnce(beginError); + const callback = vi.fn(); + + await expect( + withTransaction(runtime, callback, { isolationLevel: 'serializable' }), + ).rejects.toBe(beginError); + + expect(callback).not.toHaveBeenCalled(); + expect(driver.__spies.connectionRelease).toHaveBeenCalledOnce(); + expect(driver.__spies.connectionDestroy).not.toHaveBeenCalled(); + }); + it('releases connection independently across sequential transactions', async () => { const { runtime, driver } = createRuntimeForTransaction(); diff --git a/packages/3-extensions/postgres/src/runtime/postgres.ts b/packages/3-extensions/postgres/src/runtime/postgres.ts index f08cc9748222..3da65e3733f0 100644 --- a/packages/3-extensions/postgres/src/runtime/postgres.ts +++ b/packages/3-extensions/postgres/src/runtime/postgres.ts @@ -7,6 +7,7 @@ import { sql as sqlBuilder } from '@internal/sql-builder/runtime'; import type { Db, RawLane } from '@internal/sql-builder/types'; import type { ExtractCodecTypes, SqlStorage } from '@internal/sql-contract/types'; import { orm as ormBuilder, type PreparedFrom, prepareQuery } from '@internal/sql-orm-client'; +import type { SqlTransactionOptions } from '@internal/sql-relational-core/ast'; import type { CodecTypesBase } from '@internal/sql-relational-core/expression'; import type { Preparable, SqlQueryPlan } from '@internal/sql-relational-core/plan'; import type { @@ -64,7 +65,10 @@ export interface PostgresClient> { readonly stack: SqlExecutionStackWithDriver; connect(bindingInput?: PostgresBindingInput): Promise; runtime(): Runtime; - transaction(fn: (tx: PostgresTransactionContext) => PromiseLike): Promise; + transaction( + fn: (tx: PostgresTransactionContext) => PromiseLike, + options?: SqlTransactionOptions, + ): Promise; prepare< D extends Declaration, Q extends SqlQueryPlan | Preparable, @@ -347,37 +351,44 @@ export default function postgres>( prepare, - transaction(fn: (tx: PostgresTransactionContext) => PromiseLike): Promise { - return withTransaction(getRuntime(), (txCtx) => { - const rawCodecInferer = stack.adapter.rawCodecInferer; - const txSql: Db = sqlBuilder({ - context, - rawCodecInferer, - }); - - const txOrm: OrmClient = ormBuilder({ - runtime: { - query(plan) { - return txCtx.query(plan); + transaction( + fn: (tx: PostgresTransactionContext) => PromiseLike, + options?: SqlTransactionOptions, + ): Promise { + return withTransaction( + getRuntime(), + (txCtx) => { + const rawCodecInferer = stack.adapter.rawCodecInferer; + const txSql: Db = sqlBuilder({ + context, + rawCodecInferer, + }); + + const txOrm: OrmClient = ormBuilder({ + runtime: { + query(plan) { + return txCtx.query(plan); + }, + execute(plan) { + return txCtx.execute(plan); + }, }, - execute(plan) { - return txCtx.execute(plan); - }, - }, - context, - }); - - // Use `txCtx` as the prototype instead of spreading it so that live - // accessors (notably the `invalidated` getter, which reads a closure - // variable in `withTransaction`) remain wired to the original object. - // Spreading would evaluate the getter once and freeze its value. - const tx: PostgresTransactionContext = Object.assign( - castAs(Object.create(txCtx)), - { sql: txSql, orm: txOrm, enums, nativeEnums }, - ); - - return fn(tx); - }); + context, + }); + + // Use `txCtx` as the prototype instead of spreading it so that live + // accessors (notably the `invalidated` getter, which reads a closure + // variable in `withTransaction`) remain wired to the original object. + // Spreading would evaluate the getter once and freeze its value. + const tx: PostgresTransactionContext = Object.assign( + castAs(Object.create(txCtx)), + { sql: txSql, orm: txOrm, enums, nativeEnums }, + ); + + return fn(tx); + }, + options, + ); }, async close(): Promise { diff --git a/packages/3-extensions/postgres/test/postgres.test.ts b/packages/3-extensions/postgres/test/postgres.test.ts index 96247767751c..745a6302ed38 100644 --- a/packages/3-extensions/postgres/test/postgres.test.ts +++ b/packages/3-extensions/postgres/test/postgres.test.ts @@ -369,6 +369,25 @@ describe('postgres', () => { expect(fakeClient.query).toHaveBeenCalledWith(expect.stringContaining('BEGIN')); }); + it('transaction() begins at the requested isolation level', async () => { + const pool = new Pool({ connectionString: 'postgres://localhost:5432/db' }); + const fakeClient = { + query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + release: vi.fn(), + on: vi.fn(), + }; + (pool as unknown as { connect: typeof vi.fn }).connect = vi.fn().mockResolvedValue(fakeClient); + const db = postgres({ contract, pg: pool }); + await db.connect(); + + await db.transaction(async () => undefined, { isolationLevel: 'serializable' }); + + const transactionControl = fakeClient.query.mock.calls + .map(([statement]) => statement) + .filter((statement) => typeof statement === 'string'); + expect(transactionControl).toEqual(['BEGIN ISOLATION LEVEL SERIALIZABLE', 'COMMIT']); + }); + it('transaction() provides sql on the transaction context', async () => { const pool = new Pool({ connectionString: 'postgres://localhost:5432/db' }); const fakeClient = { diff --git a/packages/3-extensions/postgres/test/transaction.types.test-d.ts b/packages/3-extensions/postgres/test/transaction.types.test-d.ts index 10ed0d5c250c..51bcdc1b74a3 100644 --- a/packages/3-extensions/postgres/test/transaction.types.test-d.ts +++ b/packages/3-extensions/postgres/test/transaction.types.test-d.ts @@ -1,5 +1,6 @@ import type { Contract } from '@internal/contract/types'; import type { SqlStorage } from '@internal/sql-contract/types'; +import type { SqlIsolationLevel } from '@internal/sql-relational-core/ast'; import { expectTypeOf, test } from 'vitest'; import type { PostgresClient, PostgresTransactionContext } from '../src/runtime/postgres'; @@ -33,3 +34,16 @@ test('tx.orm has the same type as db.orm', () => { type TxOrm = PostgresTransactionContext['orm']; expectTypeOf().toEqualTypeOf(); }); + +test('db.transaction accepts an optional isolation level', () => { + const db = {} as PostgresClient; + + expectTypeOf(db.transaction) + .parameter(1) + .toEqualTypeOf<{ readonly isolationLevel?: SqlIsolationLevel } | undefined>(); + expectTypeOf(db.transaction(async (_tx) => 42, { isolationLevel: 'serializable' })).toEqualTypeOf< + Promise + >(); + // @ts-expect-error snapshot is not a PostgreSQL isolation level + db.transaction(async (_tx) => 42, { isolationLevel: 'snapshot' }); +}); diff --git a/packages/3-extensions/sqlite/test/transaction.types.test-d.ts b/packages/3-extensions/sqlite/test/transaction.types.test-d.ts index b83e4cf25884..5d20d44f64a9 100644 --- a/packages/3-extensions/sqlite/test/transaction.types.test-d.ts +++ b/packages/3-extensions/sqlite/test/transaction.types.test-d.ts @@ -33,3 +33,10 @@ test('tx.orm has the same type as db.orm', () => { type TxOrm = SqliteTransactionContext['orm']; expectTypeOf().toEqualTypeOf(); }); + +test('db.transaction takes no isolation level', () => { + const db = {} as SqliteClient; + + // @ts-expect-error SQLite has no per-transaction isolation level to request + db.transaction(async (_tx) => 42, { isolationLevel: 'serializable' }); +}); diff --git a/packages/3-extensions/supabase/src/runtime/supabase-runtime.ts b/packages/3-extensions/supabase/src/runtime/supabase-runtime.ts index f39068a965b9..597a23ef2148 100644 --- a/packages/3-extensions/supabase/src/runtime/supabase-runtime.ts +++ b/packages/3-extensions/supabase/src/runtime/supabase-runtime.ts @@ -3,7 +3,11 @@ import type { RuntimeExecuteOptions } from '@internal/framework-components/runti import { AsyncIterableResult } from '@internal/framework-components/runtime'; import { type PostgresRuntime, PostgresRuntimeImpl } from '@internal/postgres/runtime'; import type { SqlStorage } from '@internal/sql-contract/types'; -import type { SqlQueryable, SqlStatementStats } from '@internal/sql-relational-core/ast'; +import type { + SqlQueryable, + SqlStatementStats, + SqlTransactionOptions, +} from '@internal/sql-relational-core/ast'; import type { SqlExecutionPlan, SqlQueryPlan } from '@internal/sql-relational-core/plan'; import type { PreparedExecution, @@ -118,8 +122,8 @@ export class SupabaseRuntimeImpl< ); }, - async transaction(): Promise { - const tx = await conn.beginTransaction(); + async transaction(options?: SqlTransactionOptions): Promise { + const tx = await conn.beginTransaction(options); const roleTransaction: RuntimeTransaction & PreparedStatementQueryTarget & PreparedStatementExecuteTarget = { diff --git a/packages/3-extensions/supabase/src/runtime/supabase.ts b/packages/3-extensions/supabase/src/runtime/supabase.ts index cdb54c8fb641..079267e2363c 100644 --- a/packages/3-extensions/supabase/src/runtime/supabase.ts +++ b/packages/3-extensions/supabase/src/runtime/supabase.ts @@ -15,7 +15,7 @@ import { createRawLane, sql } from '@internal/sql-builder/runtime'; import type { Db, RawLane } from '@internal/sql-builder/types'; import type { SqlStorage } from '@internal/sql-contract/types'; import { orm } from '@internal/sql-orm-client'; -import type { SqlStatementStats } from '@internal/sql-relational-core/ast'; +import type { SqlStatementStats, SqlTransactionOptions } from '@internal/sql-relational-core/ast'; import type { SqlExecutionPlan, SqlQueryPlan } from '@internal/sql-relational-core/plan'; import type { ExecutionContext, @@ -65,7 +65,10 @@ export interface RoleBoundDb> { plan: SqlExecutionPlan | SqlQueryPlan, options?: RuntimeExecuteOptions, ): Promise; - transaction(fn: (tx: TransactionContext) => PromiseLike): Promise; + transaction( + fn: (tx: TransactionContext) => PromiseLike, + options?: SqlTransactionOptions, + ): Promise; } /** @@ -387,8 +390,15 @@ export default async function supabase>( ): Promise { return roleRuntime.executeWithRole(plan, binding, execOptions); }, - transaction(fn: (tx: TransactionContext) => PromiseLike): Promise { - return withTransaction({ connection: () => roleRuntime.openRoleSession(binding) }, fn); + transaction( + fn: (tx: TransactionContext) => PromiseLike, + options?: SqlTransactionOptions, + ): Promise { + return withTransaction( + { connection: () => roleRuntime.openRoleSession(binding) }, + fn, + options, + ); }, }; } diff --git a/packages/3-extensions/supabase/test/supabase-runtime.test.ts b/packages/3-extensions/supabase/test/supabase-runtime.test.ts index 68c320a03369..286f7b9c50bf 100644 --- a/packages/3-extensions/supabase/test/supabase-runtime.test.ts +++ b/packages/3-extensions/supabase/test/supabase-runtime.test.ts @@ -12,6 +12,7 @@ import type { PreparedStatementHandle, SqlDriver, SqlExecuteRequest, + SqlTransactionOptions, } from '@internal/sql-relational-core/ast'; import { BinaryExpr, @@ -102,7 +103,7 @@ interface RecordingConnection { query: ReturnType; release: ReturnType; destroy: ReturnType; - beginTransaction(): Promise; + beginTransaction(options?: SqlTransactionOptions): Promise; readonly transaction: RecordingTransaction; } @@ -198,7 +199,7 @@ function createRecordingDriver( }), release: vi.fn().mockResolvedValue(undefined), destroy: vi.fn().mockResolvedValue(undefined), - beginTransaction: () => beginTransactionSpy(), + beginTransaction: (options) => beginTransactionSpy(options), }; const acquireConnectionSpy = vi.fn().mockResolvedValue(connection); @@ -517,6 +518,21 @@ describe('SupabaseRuntimeImpl', () => { }); }); + describe('openRoleSession — transaction options', () => { + it('forwards the isolation level to the driver connection', async () => { + const { runtime, driver } = createTestSetup(); + const session = await runtime.openRoleSession({ role: 'authenticated' }); + + const tx = await session.transaction({ isolationLevel: 'serializable' }); + await tx.commit(); + await session.release(); + + expect(driver.connection.beginTransactionSpy).toHaveBeenCalledExactlyOnceWith({ + isolationLevel: 'serializable', + }); + }); + }); + describe('openRoleSession — marker verification', () => { it('reads the marker before the role session transaction begins', async () => { const events: string[] = []; diff --git a/packages/3-targets/7-drivers/postgres/README.md b/packages/3-targets/7-drivers/postgres/README.md index 4b5519b23c3b..400a7ea17c05 100644 --- a/packages/3-targets/7-drivers/postgres/README.md +++ b/packages/3-targets/7-drivers/postgres/README.md @@ -27,6 +27,7 @@ Provide PostgreSQL transport and connection management. Execute SQL statements a - **Connection Management**: Acquire and release database connections - **Statement Execution**: Execute SQL statements with parameters +- **Transactions**: `beginTransaction()` sends `BEGIN`; `beginTransaction({ isolationLevel })` sends `BEGIN ISOLATION LEVEL ` as one statement, on pooled and single-client bindings alike - **Query Result Parser Policy**: Configure `pg` so query rows expose temporal scalars and registered array OIDs as raw server text where the runtime or adapter owns decoding - **Query Explanation**: Execute EXPLAIN queries for query analysis - **Connection Pooling**: Manage connection pools (when applicable) diff --git a/packages/3-targets/7-drivers/postgres/src/driver-error.ts b/packages/3-targets/7-drivers/postgres/src/driver-error.ts index 4172c8d14e60..2b026b7ec289 100644 --- a/packages/3-targets/7-drivers/postgres/src/driver-error.ts +++ b/packages/3-targets/7-drivers/postgres/src/driver-error.ts @@ -1,5 +1,9 @@ export interface DriverRuntimeError extends Error { - readonly code: 'DRIVER.NOT_CONNECTED' | 'DRIVER.ALREADY_CONNECTED' | 'DRIVER.PREPARE_FAILED'; + readonly code: + | 'DRIVER.NOT_CONNECTED' + | 'DRIVER.ALREADY_CONNECTED' + | 'DRIVER.PREPARE_FAILED' + | 'DRIVER.ISOLATION_LEVEL_UNSUPPORTED'; readonly category: 'DRIVER'; readonly severity: 'error'; readonly details?: Record; diff --git a/packages/3-targets/7-drivers/postgres/src/postgres-driver.ts b/packages/3-targets/7-drivers/postgres/src/postgres-driver.ts index 9d52caf53167..a6e32ba1baae 100644 --- a/packages/3-targets/7-drivers/postgres/src/postgres-driver.ts +++ b/packages/3-targets/7-drivers/postgres/src/postgres-driver.ts @@ -5,9 +5,11 @@ import type { SqlDriverState, SqlExecuteRequest, SqlExplainResult, + SqlIsolationLevel, SqlQueryable, SqlStatementStats, SqlTransaction, + SqlTransactionOptions, } from '@internal/sql-relational-core/ast'; import { blindCast } from '@internal/utils/casts'; import { suppressIdleConnectionErrors } from '@internal/utils/suppress-idle-connection-errors'; @@ -521,10 +523,11 @@ class PostgresConnectionImpl extends PostgresQueryable implements SqlConnection return this.#txState.open; } - async beginTransaction(): Promise { + async beginTransaction(options?: SqlTransactionOptions): Promise { + const beginSql = beginStatement(options?.isolationLevel); const releaseLock = await acquireClientQueryLock(this.#connection); try { - await this.#connection.query('BEGIN').catch(rethrowNormalizedError); + await this.#connection.query(beginSql).catch(rethrowNormalizedError); } finally { releaseLock(); } @@ -572,6 +575,27 @@ class PostgresConnectionImpl extends PostgresQueryable implements SqlConnection } } +const ISOLATION_LEVEL_SQL: Record = { + readUncommitted: 'READ UNCOMMITTED', + readCommitted: 'READ COMMITTED', + repeatableRead: 'REPEATABLE READ', + serializable: 'SERIALIZABLE', +}; + +function beginStatement(isolationLevel: SqlIsolationLevel | undefined): string { + if (isolationLevel === undefined) { + return 'BEGIN'; + } + if (!Object.hasOwn(ISOLATION_LEVEL_SQL, isolationLevel)) { + throw driverError( + 'DRIVER.ISOLATION_LEVEL_UNSUPPORTED', + `PostgreSQL has no isolation level "${String(isolationLevel)}". Use one of: ${Object.keys(ISOLATION_LEVEL_SQL).join(', ')}.`, + { target: 'postgres', isolationLevel }, + ); + } + return `BEGIN ISOLATION LEVEL ${ISOLATION_LEVEL_SQL[isolationLevel]}`; +} + class PostgresTransactionImpl extends PostgresQueryable implements SqlTransaction { #connection: PoolClient | Client; #onSettled: (() => void) | undefined; diff --git a/packages/3-targets/7-drivers/postgres/test/driver.isolation-level.integration.test.ts b/packages/3-targets/7-drivers/postgres/test/driver.isolation-level.integration.test.ts new file mode 100644 index 000000000000..958c70fd0015 --- /dev/null +++ b/packages/3-targets/7-drivers/postgres/test/driver.isolation-level.integration.test.ts @@ -0,0 +1,145 @@ +import type { SqlDriver, SqlIsolationLevel } from '@internal/sql-relational-core/ast'; +import { createDevDatabase, timeouts } from '@repo/test-utils'; +import { Client, Pool } from 'pg'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createBoundDriverFromBinding } from '../src/postgres-driver'; +import { queryRows } from './sql-queryable-test-utils'; + +const levels: ReadonlyArray = [ + ['readUncommitted', 'read uncommitted'], + ['readCommitted', 'read committed'], + ['repeatableRead', 'repeatable read'], + ['serializable', 'serializable'], +]; + +describe('@internal/driver-postgres transaction isolation level', () => { + const cleanups: Array<() => Promise> = []; + + afterEach(async () => { + while (cleanups.length > 0) { + await cleanups.pop()?.(); + } + }, timeouts.spinUpPpgDev); + + async function createDirectDriver() { + const database = await createDevDatabase(); + const client = new Client({ connectionString: database.connectionString }); + const driver = createBoundDriverFromBinding({ kind: 'pgClient', client }, undefined); + cleanups.push(async () => { + await driver.close(); + await database.close(); + }); + return { driver, client }; + } + + async function createPoolDriver() { + const database = await createDevDatabase(); + const pool = new Pool({ connectionString: database.connectionString }); + const driver = createBoundDriverFromBinding({ kind: 'pgPool', pool }, undefined); + cleanups.push(async () => { + await driver.close(); + await database.close(); + }); + return { driver }; + } + + async function isolationInsideTransaction( + driver: SqlDriver, + isolationLevel?: SqlIsolationLevel, + ): Promise { + const connection = await driver.acquireConnection(); + try { + const transaction = await connection.beginTransaction( + isolationLevel === undefined ? undefined : { isolationLevel }, + ); + const rows = await queryRows<{ transaction_isolation: string }>( + transaction, + 'show transaction_isolation', + ); + await transaction.commit(); + return rows[0]?.transaction_isolation; + } finally { + await connection.release(); + } + } + + it( + 'applies each requested level on a single-connection driver', + async () => { + const { driver } = await createDirectDriver(); + + const applied: Array<[SqlIsolationLevel, string | undefined]> = []; + for (const [level] of levels) { + applied.push([level, await isolationInsideTransaction(driver, level)]); + } + + expect(applied).toEqual(levels); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'applies the requested level on a pooled driver', + async () => { + const { driver } = await createPoolDriver(); + + expect(await isolationInsideTransaction(driver, 'serializable')).toBe('serializable'); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'keeps the database default when no level is requested', + async () => { + const { driver } = await createDirectDriver(); + + expect(await isolationInsideTransaction(driver)).toBe('read committed'); + expect(await isolationInsideTransaction(driver, 'serializable')).toBe('serializable'); + expect(await isolationInsideTransaction(driver)).toBe('read committed'); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'begins with one statement', + async () => { + const { driver, client } = await createDirectDriver(); + const connection = await driver.acquireConnection(); + const query = vi.spyOn(client, 'query'); + + const plain = await connection.beginTransaction(); + await plain.rollback(); + const leveled = await connection.beginTransaction({ isolationLevel: 'repeatableRead' }); + await leveled.rollback(); + await connection.release(); + + expect(query.mock.calls.map(([text]) => text)).toEqual([ + 'BEGIN', + 'ROLLBACK', + 'BEGIN ISOLATION LEVEL REPEATABLE READ', + 'ROLLBACK', + ]); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'rejects a level PostgreSQL does not have and sends nothing', + async () => { + const { driver, client } = await createDirectDriver(); + const connection = await driver.acquireConnection(); + const query = vi.spyOn(client, 'query'); + + await expect( + connection.beginTransaction({ isolationLevel: 'constructor' as SqlIsolationLevel }), + ).rejects.toMatchObject({ + code: 'DRIVER.ISOLATION_LEVEL_UNSUPPORTED', + details: { target: 'postgres', isolationLevel: 'constructor' }, + }); + expect(query).not.toHaveBeenCalled(); + + await connection.release(); + }, + timeouts.spinUpPpgDev, + ); +}); diff --git a/packages/3-targets/7-drivers/sqlite/README.md b/packages/3-targets/7-drivers/sqlite/README.md index 04cb3f1d8516..a996963c4c70 100644 --- a/packages/3-targets/7-drivers/sqlite/README.md +++ b/packages/3-targets/7-drivers/sqlite/README.md @@ -29,7 +29,7 @@ Provide SQLite transport and connection management. Execute SQL statements and m - **Query Explanation**: Execute `EXPLAIN QUERY PLAN` queries for query analysis - **Persistent Connection**: Top-level `execute()`/`query()`/`explain()` reuse a persistent connection opened at `connect()` time - **Scoped Connections**: `acquireConnection()` opens fresh `DatabaseSync` handles for isolated scopes (transactions) -- **Transaction Support**: `BEGIN`/`COMMIT`/`ROLLBACK` via `SqliteTransactionImpl` +- **Transaction Support**: `BEGIN`/`COMMIT`/`ROLLBACK` via `SqliteTransactionImpl`. `beginTransaction({ isolationLevel })` rejects with `DRIVER.ISOLATION_LEVEL_UNSUPPORTED`: every SQLite transaction is serializable, so there is no level to select - **PRAGMA Configuration**: Enables `PRAGMA foreign_keys = ON` and `PRAGMA busy_timeout = 5000` on every opened connection - **Error Normalization**: Maps SQLite extended error codes to SQL state codes (23505 unique, 23503 FK, 23502 NOT NULL) and distinguishes transient (BUSY/LOCKED) from permanent errors diff --git a/packages/3-targets/7-drivers/sqlite/src/sqlite-driver.ts b/packages/3-targets/7-drivers/sqlite/src/sqlite-driver.ts index 64aa10cb6b5f..8cd08fad4b9a 100644 --- a/packages/3-targets/7-drivers/sqlite/src/sqlite-driver.ts +++ b/packages/3-targets/7-drivers/sqlite/src/sqlite-driver.ts @@ -10,6 +10,7 @@ import type { SqlQueryable, SqlStatementStats, SqlTransaction, + SqlTransactionOptions, } from '@internal/sql-relational-core/ast'; import { InternalError } from '@internal/utils/internal-error'; import { normalizeSqliteError } from './normalize-error'; @@ -19,7 +20,10 @@ export type SqliteBinding = { readonly kind: 'path'; readonly path: string }; export type SqliteRuntimeDriver = RuntimeDriverInstance<'sql', 'sqlite'> & SqlDriver; interface DriverRuntimeError extends Error { - readonly code: 'DRIVER.NOT_CONNECTED' | 'DRIVER.ALREADY_CONNECTED'; + readonly code: + | 'DRIVER.NOT_CONNECTED' + | 'DRIVER.ALREADY_CONNECTED' + | 'DRIVER.ISOLATION_LEVEL_UNSUPPORTED'; readonly category: 'DRIVER'; readonly severity: 'error'; readonly details?: Record; @@ -108,7 +112,14 @@ abstract class SqliteQueryable implements SqlQueryable { } export class SqliteConnectionImpl extends SqliteQueryable implements SqlConnection { - async beginTransaction(): Promise { + async beginTransaction(options?: SqlTransactionOptions): Promise { + if (options?.isolationLevel !== undefined) { + throw driverError( + 'DRIVER.ISOLATION_LEVEL_UNSUPPORTED', + 'SQLite has no per-transaction isolation level: every SQLite transaction is serializable. Remove the isolationLevel option.', + { target: 'sqlite', isolationLevel: options.isolationLevel }, + ); + } try { this.db.exec('BEGIN'); return new SqliteTransactionImpl(this.db); diff --git a/packages/3-targets/7-drivers/sqlite/test/sqlite-driver.test.ts b/packages/3-targets/7-drivers/sqlite/test/sqlite-driver.test.ts index 7b8ea7e8807d..3c0671a297c2 100644 --- a/packages/3-targets/7-drivers/sqlite/test/sqlite-driver.test.ts +++ b/packages/3-targets/7-drivers/sqlite/test/sqlite-driver.test.ts @@ -303,3 +303,24 @@ describe('SqliteConnectionImpl cleanup retries', () => { expect(db.isOpen).toBe(false); }); }); + +describe('SqliteConnection isolation level', () => { + it('rejects a requested isolation level without beginning a transaction', async () => { + const driver = createDriver(); + await executeSql(driver, 'CREATE TABLE t(id INTEGER PRIMARY KEY)'); + const conn = await driver.acquireConnection(); + + await expect(conn.beginTransaction({ isolationLevel: 'serializable' })).rejects.toMatchObject({ + code: 'DRIVER.ISOLATION_LEVEL_UNSUPPORTED', + details: { target: 'sqlite', isolationLevel: 'serializable' }, + }); + + const tx = await conn.beginTransaction(); + await executeSql(tx, 'INSERT INTO t VALUES (?)', [1]); + await tx.commit(); + await conn.release(); + + expect(await queryRows<{ id: number }>(driver, 'SELECT id FROM t')).toEqual([{ id: 1 }]); + await driver.close(); + }); +}); diff --git a/scorecard/14-transactions.md b/scorecard/14-transactions.md index a5fac63c2bff..23a37f3bcd4e 100644 --- a/scorecard/14-transactions.md +++ b/scorecard/14-transactions.md @@ -15,7 +15,7 @@ Legend: | `transaction(fn)` (atomic commit/rollback) | ✅ | ✅ | — | `test/e2e/framework/test/transaction.test.ts` (`withTransaction`, the runtime primitive `transaction(fn)` delegates to); `test/e2e/framework/test/transaction-orm.test.ts` (`db.transaction` commit/rollback); `test/e2e/framework/test/sqlite/transaction.test.ts` (`db.transaction`) | | ORM operations inside a transaction (read-your-own-write) | ✅ | ✅ | — | `test/e2e/framework/test/transaction-orm.test.ts`; `test/e2e/framework/test/sqlite/transaction.test.ts` (`read-your-own-write`) | | Prepared statements (`prepare(decl, cb)`) | ✅ | ✅ | — | `test/e2e/framework/test/runtime.prepared.test.ts`; `test/e2e/framework/test/sqlite/prepared.test.ts` | -| `isolationLevel` (batch + interactive) | ❌ | ❌ | — | | +| `isolationLevel` on `transaction(fn, options)` | ✅ | — | — | `test/e2e/framework/test/transaction.test.ts` (`withTransaction` with `isolationLevel`, default unchanged); `test/e2e/framework/test/transaction-orm.test.ts` (`db.transaction(fn, { isolationLevel })`); `test/e2e/framework/test/sqlite/transaction.test.ts` (SQLite rejects a requested level with `DRIVER.ISOLATION_LEVEL_UNSUPPORTED`: every SQLite transaction is serializable) | | `timeout` (+ `P2028`) | ❌ | ❌ | — | | | `maxWait` (+ `P2028`) | ❌ | ❌ | — | | | Client-level `transactionOptions` defaults | ❌ | ❌ | — | | diff --git a/skills/prisma-8/references/queries-postgres.md b/skills/prisma-8/references/queries-postgres.md index 1f0354751a93..c1f32c77e557 100644 --- a/skills/prisma-8/references/queries-postgres.md +++ b/skills/prisma-8/references/queries-postgres.md @@ -360,6 +360,18 @@ await db.transaction(async (tx) => { The callback's return value passes through `db.transaction(...)`. Capture inserted ids out of the callback and use them downstream after commit. +**Isolation level.** Pass it as the second argument: `db.transaction(fn, { isolationLevel })`, one of `'readUncommitted'`, `'readCommitted'`, `'repeatableRead'`, `'serializable'`. Omit it for the database default (`READ COMMITTED` unless the server is configured otherwise). The driver sends `BEGIN ISOLATION LEVEL ` as one statement — do not hand-write `SET TRANSACTION ISOLATION LEVEL ...` through `db.raw.sql`. `withTransaction(runtime, fn, options)` and a Supabase `RoleBoundDb.transaction(fn, options)` take the same option. PostgreSQL aborts a `repeatableRead` or `serializable` transaction that conflicts with a concurrent one (SQLSTATE `40001`); Prisma 8 does not retry it, so catch it and re-run the whole `db.transaction(...)` call. + +```typescript +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' }, +); +``` + ## Namespace-aware accessors On Postgres both `db.sql` and `db.orm` are keyed by storage namespace (the Postgres schema) — always, not only when a contract declares more than one. A model outside any `namespace { }` block is in `public`: diff --git a/skills/prisma-8/references/runtime.md b/skills/prisma-8/references/runtime.md index 140ef006170b..f00e5d047c78 100644 --- a/skills/prisma-8/references/runtime.md +++ b/skills/prisma-8/references/runtime.md @@ -289,6 +289,8 @@ await db.transaction(async (tx) => { The callback returns whatever you return from it — the transaction wrapper passes it through. The `tx` object exposes `query(plan)` (rows) and `execute(plan)` (affected count) for SQL-builder plans inside the transaction. +On Postgres, `db.transaction(fn, { isolationLevel })` begins the transaction at the requested level (`'readUncommitted'`, `'readCommitted'`, `'repeatableRead'`, `'serializable'`); see *Transactions* in [`queries-postgres.md`](./queries-postgres.md). The SQLite façade's `db.transaction(fn)` takes no options: every SQLite transaction is serializable, and the SQLite driver rejects a requested level with `DRIVER.ISOLATION_LEVEL_UNSUPPORTED`. + ## Workflow — Switch between Postgres, SQLite, and Mongo The concept: the façade selection is baked into `db.ts` (`@internal/postgres` or `@internal/mongo`) and `prisma.config.ts` (which target's `config` subpath `ormConfig` is imported from). To switch a project's target, re-run `prisma orm init` in the same directory and pick the other target — the init flow detects the existing scaffold and prompts to reinit (non-interactive runs grant the consent with `--confirm `). PN re-scaffolds `prisma.config.ts` and `db.ts` for the new façade. The contract source needs to be re-authored for the new target's idioms (Mongo expresses nested documents; Postgres expresses relations). diff --git a/skills/prisma-8/references/supabase.md b/skills/prisma-8/references/supabase.md index 488d6c94dec4..0a98a921274c 100644 --- a/skills/prisma-8/references/supabase.md +++ b/skills/prisma-8/references/supabase.md @@ -143,7 +143,7 @@ const updated = await userDb.orm.public.Profile .updateAndCount({ username: 'new-name' }); ``` -Notes: `asAnon()` / `asServiceRole()` are sync; only `asUser` is async. Multi-namespace contracts address models by coordinate (`orm.public.Profile`, `sql.public.profile`) — see `references/queries.md` § *Namespace-aware accessors*. `RoleBoundDb.transaction(fn)` wraps work in a transaction on the role-bound session. +Notes: `asAnon()` / `asServiceRole()` are sync; only `asUser` is async. Multi-namespace contracts address models by coordinate (`orm.public.Profile`, `sql.public.profile`) — see `references/queries.md` § *Namespace-aware accessors*. `RoleBoundDb.transaction(fn, options?)` wraps work in a transaction on the role-bound session; `options.isolationLevel` selects the PostgreSQL isolation level (see *Transactions* in `references/queries-postgres.md`). ## Workflow — Admin reads of `auth.*` / `storage.*` diff --git a/test/e2e/framework/test/sqlite/transaction.test.ts b/test/e2e/framework/test/sqlite/transaction.test.ts index fd010a3aecce..294dd80bcb5d 100644 --- a/test/e2e/framework/test/sqlite/transaction.test.ts +++ b/test/e2e/framework/test/sqlite/transaction.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { fileURLToPath } from 'node:url'; +import { withTransaction } from '@prisma/orm-sqlite/family-runtime'; import sqlite from '@prisma/orm-sqlite/runtime'; import { timeouts } from '@repo/test-utils'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -121,6 +122,21 @@ describe('transaction e2e via sqlite() facade', { timeout: timeouts.databaseOper expect(rows).toHaveLength(1); }); + it('rejects a requested isolation level and leaves the database usable', async () => { + const { db, rawDb } = handle; + + await expect( + withTransaction(db.runtime(), async () => undefined, { isolationLevel: 'serializable' }), + ).rejects.toMatchObject({ code: 'DRIVER.ISOLATION_LEVEL_UNSUPPORTED' }); + + await db.transaction(async (tx) => { + await tx.orm.User.create({ id: 300, name: 'AfterReject', email: 'after@example.com' }); + }); + expect(rawDb.prepare('SELECT name FROM users WHERE id = 300').all()).toEqual([ + { name: 'AfterReject' }, + ]); + }); + it('escaped AsyncIterableResult rejects with TRANSACTION_CLOSED after the transaction has ended', async () => { const { db } = handle; diff --git a/test/e2e/framework/test/transaction-orm.test.ts b/test/e2e/framework/test/transaction-orm.test.ts index 11003009e041..80f6e45db93b 100644 --- a/test/e2e/framework/test/transaction-orm.test.ts +++ b/test/e2e/framework/test/transaction-orm.test.ts @@ -68,6 +68,24 @@ describe('transaction ORM integration', { timeout: timeouts.spinUpPpgDev }, () = }); }); + it('db.transaction runs the callback at the requested isolation level', async () => { + await withPostgresClient(async (db) => { + const showIsolation = db.raw.sql`SHOW transaction_isolation` + .returnsRow({ transaction_isolation: 'pg/text@1' }) + .build(); + + const level = await db.transaction( + async (tx) => { + await tx.orm.public.User.create({ email: v('isolation-level@example.com') }); + return tx.query(showIsolation); + }, + { isolationLevel: 'repeatableRead' }, + ); + + expect(level).toEqual([{ transaction_isolation: 'repeatable read' }]); + }); + }); + it('ORM create with nested relation mutation rolls back everything on throw', async () => { await withPostgresClient(async (db) => { await expect( diff --git a/test/e2e/framework/test/transaction.test.ts b/test/e2e/framework/test/transaction.test.ts index 066804a596fd..e4e6796e5e87 100644 --- a/test/e2e/framework/test/transaction.test.ts +++ b/test/e2e/framework/test/transaction.test.ts @@ -44,6 +44,22 @@ describe('transaction E2E', { timeout: 30000 }, () => { }); }); + it('applies the requested isolation level on a fresh runtime', async () => { + await withTestRuntime(contractJsonPath, async ({ raw, runtime }) => { + const showIsolation = raw.sql`SHOW transaction_isolation` + .returnsRow({ transaction_isolation: 'pg/text@1' }) + .build(); + + const requested = await withTransaction(runtime, (tx) => tx.query(showIsolation), { + isolationLevel: 'serializable', + }); + const unspecified = await withTransaction(runtime, (tx) => tx.query(showIsolation)); + + expect(requested).toEqual([{ transaction_isolation: 'serializable' }]); + expect(unspecified).toEqual([{ transaction_isolation: 'read committed' }]); + }); + }); + it('rolls back all writes on error', async () => { await withTestRuntime(contractJsonPath, async ({ db, runtime, client }) => { await expect( diff --git a/upgrade-instructions/pending/transaction-isolation-level/extension/instructions.md b/upgrade-instructions/pending/transaction-isolation-level/extension/instructions.md new file mode 100644 index 000000000000..23797ee73fbd --- /dev/null +++ b/upgrade-instructions/pending/transaction-isolation-level/extension/instructions.md @@ -0,0 +1,29 @@ +--- +changes: + - id: forward-transaction-options + summary: beginTransaction, RuntimeConnection.transaction and withTransaction take an optional options argument carrying isolationLevel; code that implements or wraps them forwards it, or rejects a level it cannot apply. + detection: + glob: "**/*.ts" + contains: + - "beginTransaction(" +--- + +## `forward-transaction-options` + +`SqlConnection.beginTransaction`, `RuntimeConnection.transaction` and `withTransaction` gain an optional `options?: SqlTransactionOptions` argument (`{ isolationLevel?: 'readUncommitted' | 'readCommitted' | 'repeatableRead' | 'serializable' }`, exported from `@internal/sql-relational-core/ast` and `@internal/sql-runtime`). Existing calls and existing zero-argument implementations still compile. Update the places that sit between a caller and the driver, so a requested level is never dropped on the way: + +- **A `RuntimeConnection` you build yourself** (for example a session object returned by a runtime subclass): accept the argument and pass it to the driver connection. + + ```ts + // before + async transaction(): Promise { + const tx = await conn.beginTransaction(); + + // after + async transaction(options?: SqlTransactionOptions): Promise { + const tx = await conn.beginTransaction(options); + ``` + +- **A client method that wraps `withTransaction`**: add `options?: SqlTransactionOptions` as the second parameter of your `transaction(fn, options)` method, in its interface declaration and its implementation, and pass it as the third argument: `withTransaction(runtime, fn, options)`. + +- **A `SqlConnection` you implement yourself** (a custom driver): apply `options.isolationLevel` when you begin the transaction, in the same statement as `BEGIN` where the database allows it. If the database cannot apply the requested level, reject before beginning with a structured error whose `code` is `DRIVER.ISOLATION_LEVEL_UNSUPPORTED`. Do not ignore the option.