Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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:
Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions docs/reference/error-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 17 additions & 1 deletion packages/2-sql/4-lanes/relational-core/src/ast/driver-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,24 @@ export interface SqlDriver<TBinding = void> extends SqlQueryable {
close(): Promise<void>;
}

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<SqlTransaction>;
beginTransaction(options?: SqlTransactionOptions): Promise<SqlTransaction>;
/**
* 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
SqlExecuteRequest,
SqlStatementStats,
SqlTransaction,
SqlTransactionOptions,
} from '../../src/ast/driver-types';

type PoolBinding = { pool: { connect: () => Promise<unknown> } };
Expand Down Expand Up @@ -63,3 +64,16 @@ test('mock driver implementing SqlDriver<TestBinding> compiles and accepts bindi
expectTypeOf(driver.connect).toBeFunction();
expectTypeOf(driver.connect).parameter(0).toEqualTypeOf<TestBinding>();
});

test('beginTransaction takes an optional isolation level', () => {
expectTypeOf<SqlConnection['beginTransaction']>()
.parameter(0)
.toEqualTypeOf<SqlTransactionOptions | undefined>();
expectTypeOf<SqlTransactionOptions['isolationLevel']>().toEqualTypeOf<
'readUncommitted' | 'readCommitted' | 'repeatableRead' | 'serializable' | undefined
>();
});

test('a driver written without the options parameter still satisfies SqlConnection', () => {
expectTypeOf<() => Promise<SqlTransaction>>().toExtend<SqlConnection['beginTransaction']>();
});
3 changes: 2 additions & 1 deletion packages/2-sql/5-runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ const db = postgres<Contract>({

- `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

Expand Down
6 changes: 5 additions & 1 deletion packages/2-sql/5-runtime/src/exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 12 additions & 4 deletions packages/2-sql/5-runtime/src/sql-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -120,7 +121,7 @@ export interface Runtime extends RuntimeQueryable {
}

export interface RuntimeConnection extends RuntimeQueryable {
transaction(): Promise<RuntimeTransaction>;
transaction(options?: SqlTransactionOptions): Promise<RuntimeTransaction>;
/**
* 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.
*/
Expand Down Expand Up @@ -779,8 +780,8 @@ export abstract class SqlRuntimeBase<TContract extends Contract<SqlStorage> = Co
const wrappedConnection: RuntimeConnection &
PreparedStatementQueryTarget &
PreparedStatementExecuteTarget = {
async transaction(): Promise<RuntimeTransaction> {
const driverTx = await driverConn.beginTransaction();
async transaction(options?: SqlTransactionOptions): Promise<RuntimeTransaction> {
const driverTx = await driverConn.beginTransaction(options);
return self.wrapTransaction(driverTx);
},
async release(): Promise<void> {
Expand Down Expand Up @@ -986,9 +987,16 @@ export interface ConnectionProvider {
export async function withTransaction<R>(
runtime: ConnectionProvider,
fn: (tx: TransactionContext) => PromiseLike<R>,
options?: SqlTransactionOptions,
): Promise<R> {
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;

Expand Down
46 changes: 46 additions & 0 deletions packages/2-sql/5-runtime/test/sql-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ interface DriverMockSpies {
transactionStats: ReturnType<typeof vi.fn>;
connectionRelease: ReturnType<typeof vi.fn>;
connectionDestroy: ReturnType<typeof vi.fn>;
connectionBeginTransaction: ReturnType<typeof vi.fn>;
transactionCommit: ReturnType<typeof vi.fn>;
transactionRollback: ReturnType<typeof vi.fn>;
driverClose: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -160,6 +161,7 @@ function createMockDriver(): MockSqlDriver {
transactionStats,
connectionRelease: connection.release,
connectionDestroy: connection.destroy,
connectionBeginTransaction: connection.beginTransaction,
transactionCommit: transaction.commit,
transactionRollback: transaction.rollback,
driverClose,
Expand Down Expand Up @@ -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();

Expand Down
73 changes: 42 additions & 31 deletions packages/3-extensions/postgres/src/runtime/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -64,7 +65,10 @@ export interface PostgresClient<TContract extends Contract<SqlStorage>> {
readonly stack: SqlExecutionStackWithDriver<PostgresTargetId>;
connect(bindingInput?: PostgresBindingInput): Promise<Runtime>;
runtime(): Runtime;
transaction<R>(fn: (tx: PostgresTransactionContext<TContract>) => PromiseLike<R>): Promise<R>;
transaction<R>(
fn: (tx: PostgresTransactionContext<TContract>) => PromiseLike<R>,
options?: SqlTransactionOptions,
): Promise<R>;
prepare<
D extends Declaration<CT>,
Q extends SqlQueryPlan | Preparable<unknown, unknown>,
Expand Down Expand Up @@ -347,37 +351,44 @@ export default function postgres<TContract extends Contract<SqlStorage>>(

prepare,

transaction<R>(fn: (tx: PostgresTransactionContext<TContract>) => PromiseLike<R>): Promise<R> {
return withTransaction(getRuntime(), (txCtx) => {
const rawCodecInferer = stack.adapter.rawCodecInferer;
const txSql: Db<TContract> = sqlBuilder<TContract>({
context,
rawCodecInferer,
});

const txOrm: OrmClient<TContract> = ormBuilder({
runtime: {
query(plan) {
return txCtx.query(plan);
transaction<R>(
fn: (tx: PostgresTransactionContext<TContract>) => PromiseLike<R>,
options?: SqlTransactionOptions,
): Promise<R> {
return withTransaction(
getRuntime(),
(txCtx) => {
const rawCodecInferer = stack.adapter.rawCodecInferer;
const txSql: Db<TContract> = sqlBuilder<TContract>({
context,
rawCodecInferer,
});

const txOrm: OrmClient<TContract> = 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<TContract> = Object.assign(
castAs<TransactionContext>(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<TContract> = Object.assign(
castAs<TransactionContext>(Object.create(txCtx)),
{ sql: txSql, orm: txOrm, enums, nativeEnums },
);

return fn(tx);
},
options,
);
},

async close(): Promise<void> {
Expand Down
19 changes: 19 additions & 0 deletions packages/3-extensions/postgres/test/postgres.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
14 changes: 14 additions & 0 deletions packages/3-extensions/postgres/test/transaction.types.test-d.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -33,3 +34,16 @@ test('tx.orm has the same type as db.orm', () => {
type TxOrm = PostgresTransactionContext<TestContract>['orm'];
expectTypeOf<TxOrm>().toEqualTypeOf<DbOrm>();
});

test('db.transaction accepts an optional isolation level', () => {
const db = {} as PostgresClient<TestContract>;

expectTypeOf(db.transaction)
.parameter(1)
.toEqualTypeOf<{ readonly isolationLevel?: SqlIsolationLevel } | undefined>();
expectTypeOf(db.transaction(async (_tx) => 42, { isolationLevel: 'serializable' })).toEqualTypeOf<
Promise<number>
>();
// @ts-expect-error snapshot is not a PostgreSQL isolation level
db.transaction(async (_tx) => 42, { isolationLevel: 'snapshot' });
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,10 @@ test('tx.orm has the same type as db.orm', () => {
type TxOrm = SqliteTransactionContext<TestContract>['orm'];
expectTypeOf<TxOrm>().toEqualTypeOf<DbOrm>();
});

test('db.transaction takes no isolation level', () => {
const db = {} as SqliteClient<TestContract>;

// @ts-expect-error SQLite has no per-transaction isolation level to request
db.transaction(async (_tx) => 42, { isolationLevel: 'serializable' });
});
Loading
Loading