Skip to content

Commit 1ca33d0

Browse files
committed
feat: add native Oracle transactions
1 parent a414b3c commit 1ca33d0

14 files changed

Lines changed: 335 additions & 108 deletions

docs/ARCHITECTURE.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,12 @@ place, in one language.
5757
### `Driver` vs `Database` vs `Connection`
5858

5959
- `Driver` is the low-level connection layer (`PgDriver`, `PgliteDriver`,
60-
`SqliteDriver`, `DoSqliteDriver`). It exposes `execute(sql)`,
61-
`runInSingleConnection(fn)`, `close()`, and a `dialect`. Each lives at its
62-
own entry point (`typegres/drivers/*`) so a bundle only ever resolves the
63-
optional peer it actually imports.
60+
`SqliteDriver`, `DoSqliteDriver`, `OracleDriver`). It exposes `execute(sql)`,
61+
`runInTransaction(options, fn)`, `close()`, and a `dialect`. Each driver owns
62+
connection pinning plus its database's transaction protocol and supplies the
63+
transaction-bound executor to `fn`. Drivers live at separate entry points
64+
(`typegres/drivers/*`) so a bundle only ever resolves the optional peer it
65+
actually imports.
6466
- `Database` is the schema handle: provenance identity and the `Table`
6567
factory, no driver of its own. `typegres()` constructs one synchronously,
6668
so table classes can be declared at module load without a top-level await.

src/database.ts

Lines changed: 43 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1-
import { type Driver, isSyncDriver, type QueryResult } from "./drivers/types";
1+
import {
2+
type Driver,
3+
isSyncDriver,
4+
type QueryResult,
5+
type TransactionIsolation,
6+
type TransactionOptions,
7+
} from "./drivers/types";
28
import type { Fromable, RowType, RowTypeToTsType } from "./builder/query";
39
import { QueryBuilder, hydrateRows } from "./builder/query";
410
import { deserializeRows } from "./util";
511
import type { Sql } from "./builder/sql";
6-
import { compile, sql, Ident } from "./builder/sql";
12+
import { Ident } from "./builder/sql";
713
import { Table, type TableBase, type TableOptions } from "./table";
814
import { Values } from "./builder/values";
915
import { InsertBuilder } from "./builder/insert";
@@ -15,10 +21,7 @@ import { PgExecutor } from "./live/pg/executor";
1521
import { StatementExecutor, type Executor } from "./executor";
1622
import type { DialectName } from "./builder/sql";
1723

18-
export type TransactionIsolation = "read committed" | "repeatable read" | "serializable";
19-
export type TransactionOptions = {
20-
isolation?: TransactionIsolation;
21-
};
24+
export type { TransactionIsolation, TransactionOptions } from "./drivers/types";
2225

2326
// Postgres isolation levels are totally ordered. A nested call asking for
2427
// weaker-or-equal isolation than the active txn flattens harmlessly (caller
@@ -30,10 +33,10 @@ export type TransactionOptions = {
3033
// `default_transaction_isolation`). We can't prove what level we got, so
3134
// any *explicit* nested request inside an ambient txn must throw — the
3235
// alternative would silently downgrade the caller's expectation.
33-
const ISOLATION: { [K in TransactionIsolation]: { rank: number; begin: Sql } } = {
34-
"read committed": { rank: 0, begin: sql`BEGIN ISOLATION LEVEL READ COMMITTED` },
35-
"repeatable read": { rank: 1, begin: sql`BEGIN ISOLATION LEVEL REPEATABLE READ` },
36-
"serializable": { rank: 2, begin: sql`BEGIN ISOLATION LEVEL SERIALIZABLE` },
36+
const ISOLATION: { [K in TransactionIsolation]: { rank: number } } = {
37+
"read committed": { rank: 0 },
38+
"repeatable read": { rank: 1 },
39+
"serializable": { rank: 2 },
3740
};
3841

3942
// Provenance identity, no driver and no dialect of its own. Construction
@@ -323,61 +326,38 @@ export class Connection<C = undefined> {
323326
}
324327
return fn(this);
325328
}
329+
const driver = this.driver;
326330
const bus = this.#bus;
327-
return this.driver.runInSingleConnection(async (execute) => {
328-
const driver = this.driver;
329-
let txExecutor: Executor;
330-
if (this.database.dialect === "postgres") {
331-
txExecutor = new PgExecutor(this.database, execute, true);
332-
} else if (this.database.dialect === "sqlite") {
333-
if (!isSyncDriver(driver)) {
334-
throw new Error("unreachable: sqlite Connection without a SyncDriver");
335-
}
336-
// Bound and pooled are the same channel on sqlite's one handle —
337-
// checked, not assumed; the bound executor differs only in event
338-
// timing (commit-deferred flush).
339-
if (execute !== driver.executeSync) {
340-
throw new Error(
341-
"sync driver must pass its executeSync to runInSingleConnection — one handle, one channel",
342-
);
343-
}
344-
if (!bus) {
345-
throw new Error("sqlite Connection is missing its live bus");
346-
}
347-
txExecutor = new SqliteLiveExecutor(this.database, driver, bus, true);
348-
} else {
349-
txExecutor = new StatementExecutor(this.database, execute, true);
350-
}
351-
const tx = new Connection<C>(this.database, this.driver, txExecutor, opts?.isolation);
352-
// Drivers with a native transaction protocol (Durable Objects) own
353-
// commit/rollback; everyone else gets BEGIN/COMMIT/ROLLBACK SQL.
354-
if (driver.runInTransaction) {
355-
try {
356-
const result = await driver.runInTransaction(() => fn(tx));
357-
txExecutor.onCommit();
358-
return result;
359-
} catch (e) {
360-
txExecutor.onRollback();
361-
throw e;
362-
}
363-
}
364-
const runSql = async (s: Sql) => execute(compile(s, { database: this.database }));
365-
await runSql(opts?.isolation ? ISOLATION[opts.isolation].begin : sql`BEGIN`);
366-
try {
367-
const result = await fn(tx);
368-
await runSql(sql`COMMIT`);
369-
txExecutor.onCommit();
370-
return result;
371-
} catch (e) {
372-
try {
373-
await runSql(sql`ROLLBACK`);
374-
} catch (rollbackErr) {
375-
console.error("ROLLBACK failed after transaction error:", rollbackErr);
331+
let txExecutor: Executor | undefined;
332+
try {
333+
const result = await driver.runInTransaction(opts ?? {}, async (execute) => {
334+
if (this.database.dialect === "postgres") {
335+
txExecutor = new PgExecutor(this.database, execute, true);
336+
} else if (this.database.dialect === "sqlite") {
337+
if (!isSyncDriver(driver)) {
338+
throw new Error("unreachable: sqlite Connection without a SyncDriver");
339+
}
340+
if (execute !== driver.executeSync) {
341+
throw new Error(
342+
"sync driver must pass its executeSync to the transaction — one handle, one channel",
343+
);
344+
}
345+
if (!bus) {
346+
throw new Error("sqlite Connection is missing its live bus");
347+
}
348+
txExecutor = new SqliteLiveExecutor(this.database, driver, bus, true);
349+
} else {
350+
txExecutor = new StatementExecutor(this.database, execute, true);
376351
}
377-
txExecutor.onRollback();
378-
throw e;
379-
}
380-
});
352+
const tx = new Connection<C>(this.database, driver, txExecutor, opts?.isolation);
353+
return fn(tx);
354+
});
355+
txExecutor?.onCommit();
356+
return result;
357+
} catch (e) {
358+
txExecutor?.onRollback();
359+
throw e;
360+
}
381361
}
382362

383363
async close(): Promise<void> {

src/drivers/do.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { CompiledSql } from "../builder/sql";
2-
import type { ExecuteFn, ExecuteSyncFn, QueryResult, SyncDriver } from "./types";
2+
import type { ExecuteFn, ExecuteSyncFn, QueryResult, SyncDriver, TransactionOptions } from "./types";
33
import { normalizeRow } from "./shared-sqlite";
44
import { stripMatchedOuterParens } from "./shared";
55

@@ -51,12 +51,10 @@ export class DoSqliteDriver implements SyncDriver {
5151
};
5252

5353
// storage.transaction() commits on resolution, rolls back on throw.
54-
runInTransaction = <T>(cb: () => Promise<T>): Promise<T> => this.storage.transaction(cb);
55-
56-
// One handle: the single-connection execute IS executeSync (callers
57-
// assert this identity — see Connection.transaction).
58-
runInSingleConnection = <T>(cb: (execute: ExecuteSyncFn) => Promise<T>): Promise<T> =>
59-
cb(this.executeSync);
54+
runInTransaction = <T>(
55+
_opts: TransactionOptions,
56+
cb: (execute: ExecuteSyncFn) => Promise<T>,
57+
): Promise<T> => this.storage.transaction(() => cb(this.executeSync));
6058

6159
close = (): Promise<void> => Promise.resolve();
6260
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
2+
import { sql } from "../builder/sql";
3+
import { Database, type Connection } from "../database";
4+
import { Varchar2 } from "../types/oracle";
5+
import { OracleDriver } from "./oracle";
6+
import { requireOraclePoolAttributes } from "./oracle-url";
7+
8+
const enabled = process.env["ORACLE_URL"] !== undefined;
9+
const db = new Database();
10+
11+
class TransactionRows extends db.Table("oracle_transaction_rows") {
12+
id = Varchar2.column({ nonNull: true });
13+
value = Varchar2.column({ nonNull: true });
14+
}
15+
16+
describe.skipIf(!enabled)("Oracle transactions", () => {
17+
let conn: Connection;
18+
19+
beforeAll(async () => {
20+
conn = db.connect(await OracleDriver.create(requireOraclePoolAttributes()));
21+
try {
22+
await conn.execute(sql`DROP TABLE ${db.scopedIdent("oracle_transaction_rows")} PURGE`);
23+
} catch {
24+
// The table does not exist on the first run.
25+
}
26+
await conn.execute(sql`
27+
CREATE TABLE ${db.scopedIdent("oracle_transaction_rows")} (
28+
${db.scopedIdent("id")} VARCHAR2(36) PRIMARY KEY,
29+
${db.scopedIdent("value")} VARCHAR2(100) NOT NULL
30+
)
31+
`);
32+
});
33+
34+
afterAll(async () => {
35+
await conn.execute(sql`DROP TABLE ${db.scopedIdent("oracle_transaction_rows")} PURGE`);
36+
await conn.close();
37+
});
38+
39+
test("commits successful transactions", async () => {
40+
const result = await conn.transaction(async (tx) => {
41+
await TransactionRows.insert({ id: "commit", value: "visible" }).execute(tx);
42+
return "committed";
43+
});
44+
45+
expect(result).toBe("committed");
46+
expect(await TransactionRows.from()
47+
.where(({ oracle_transaction_rows: row }) => row.id.eq("commit"))
48+
.select(({ oracle_transaction_rows: row }) => ({ value: row.value }))
49+
.execute()).toEqual([{ value: "visible" }]);
50+
});
51+
52+
test("rolls back failed transactions", async () => {
53+
await expect(conn.transaction(async (tx) => {
54+
await TransactionRows.insert({ id: "rollback", value: "hidden" }).execute(tx);
55+
throw new Error("rollback requested");
56+
})).rejects.toThrow("rollback requested");
57+
58+
expect(await TransactionRows.from()
59+
.where(({ oracle_transaction_rows: row }) => row.id.eq("rollback"))
60+
.select(({ oracle_transaction_rows: row }) => ({ id: row.id }))
61+
.execute()).toEqual([]);
62+
});
63+
64+
test("pins one Oracle session and flattens nested transactions", async () => {
65+
await conn.transaction(async (tx) => {
66+
const first = await tx.execute(sql`
67+
SELECT SYS_CONTEXT('USERENV', 'SID') AS ${db.scopedIdent("sid")} FROM DUAL
68+
`);
69+
await tx.transaction(async (nested) => {
70+
const second = await nested.execute(sql`
71+
SELECT SYS_CONTEXT('USERENV', 'SID') AS ${db.scopedIdent("sid")} FROM DUAL
72+
`);
73+
expect(second.rows[0]?.["sid"]).toBe(first.rows[0]?.["sid"]);
74+
await TransactionRows.insert({ id: "nested", value: "committed" }).execute(nested);
75+
});
76+
});
77+
78+
expect(await TransactionRows.from()
79+
.where(({ oracle_transaction_rows: row }) => row.id.eq("nested"))
80+
.select(({ oracle_transaction_rows: row }) => ({ value: row.value }))
81+
.execute()).toEqual([{ value: "committed" }]);
82+
});
83+
});

src/drivers/oracle.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ test("oracle Connection constructs without a live engine", async () => {
2020
const conn = db.connect({
2121
dialect: "oracle",
2222
execute: async () => ({ rows: [{ v: "1" }] }),
23-
runInSingleConnection: async () => {
23+
runInTransaction: async () => {
2424
throw new Error("unused");
2525
},
2626
close: async () => {},

src/drivers/oracle.ts

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type { CompiledSql } from "../builder/sql";
22
import type { DialectName } from "../builder/sql";
33
import oracledb from "oracledb";
4-
import type { Driver, ExecuteFn, QueryResult } from "./types";
4+
import type { Driver, ExecuteFn, QueryResult, TransactionOptions } from "./types";
55
import { stripMatchedOuterParens } from "./shared";
6+
import { runTransaction } from "./transaction";
67

78
// node-oracledb adapter (thin mode — no Instant Client). Optional peer,
89
// imported statically because this module only loads when the caller
@@ -50,29 +51,35 @@ export class OracleDriver implements Driver {
5051

5152
private constructor(private pool: oracledb.Pool) {}
5253

53-
async execute({ text, values }: CompiledSql): Promise<QueryResult> {
54-
const conn = await this.pool.getConnection();
55-
try {
54+
private executor(conn: oracledb.Connection, autoCommit: boolean): ExecuteFn {
55+
return async ({ text, values }) => {
5656
const result = await conn.execute(stripMatchedOuterParens(text), oracleBinds(values), {
5757
outFormat: oracledb.OUT_FORMAT_OBJECT,
58-
autoCommit: true,
58+
autoCommit,
5959
});
6060
return { rows: normalizeRows(result.rows) };
61+
};
62+
}
63+
64+
async execute(compiled: CompiledSql): Promise<QueryResult> {
65+
const conn = await this.pool.getConnection();
66+
try {
67+
return await this.executor(conn, true)(compiled);
6168
} finally {
6269
await conn.close();
6370
}
6471
}
6572

66-
async runInSingleConnection<T>(cb: (execute: ExecuteFn) => Promise<T>): Promise<T> {
73+
async runInTransaction<T>(
74+
_opts: TransactionOptions,
75+
cb: (execute: ExecuteFn) => Promise<T>,
76+
): Promise<T> {
6777
const conn = await this.pool.getConnection();
6878
try {
69-
return await cb(async ({ text, values }) => {
70-
const result = await conn.execute(stripMatchedOuterParens(text), oracleBinds(values), {
71-
outFormat: oracledb.OUT_FORMAT_OBJECT,
72-
autoCommit: false,
73-
});
74-
return { rows: normalizeRows(result.rows) };
75-
});
79+
return await runTransaction({
80+
commit: () => conn.commit(),
81+
rollback: () => conn.rollback(),
82+
}, () => cb(this.executor(conn, false)));
7683
} finally {
7784
await conn.close();
7885
}

src/drivers/pg.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { CompiledSql } from "../builder/sql";
22
import type { DialectName } from "../builder/sql";
33
import pgLib from "pg";
4-
import type { Driver, ExecuteFn, QueryResult } from "./types";
4+
import type { Driver, ExecuteFn, QueryResult, TransactionOptions } from "./types";
5+
import { postgresBeginSql, runSqlTransaction } from "./transaction";
56

67
// pg adapter — returns raw text strings (no driver-side deserialization).
78
// `pg` is an *optional* peer dep (see package.json#peerDependenciesMeta),
@@ -31,10 +32,14 @@ export class PgDriver implements Driver {
3132
return this.pool.query(text, values as unknown[]);
3233
}
3334

34-
async runInSingleConnection<T>(cb: (execute: ExecuteFn) => Promise<T>): Promise<T> {
35+
async runInTransaction<T>(
36+
opts: TransactionOptions,
37+
cb: (execute: ExecuteFn) => Promise<T>,
38+
): Promise<T> {
3539
const client = await this.pool.connect();
40+
const execute: ExecuteFn = ({ text, values }) => client.query(text, values as unknown[]);
3641
try {
37-
return await cb(({ text, values }) => client.query(text, values as unknown[]));
42+
return await runSqlTransaction(execute, postgresBeginSql(opts), () => cb(execute));
3843
} finally {
3944
client.release();
4045
}

src/drivers/pglite.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { PGlite } from "@electric-sql/pglite";
22
import type { CompiledSql } from "../builder/sql";
33
import type { DialectName } from "../builder/sql";
4-
import type { Driver, ExecuteFn, QueryResult } from "./types";
4+
import type { Driver, ExecuteFn, QueryResult, TransactionOptions } from "./types";
5+
import { postgresBeginSql, runSqlTransaction } from "./transaction";
56

67
// pglite adapter — returns raw text strings (no driver-side deserialization).
78
// `@electric-sql/pglite` is an optional peer, imported statically because
@@ -35,8 +36,12 @@ export class PgliteDriver implements Driver {
3536
return this.db.query(text, values as unknown[], { parsers: this.parsers }) as Promise<QueryResult>;
3637
}
3738

38-
async runInSingleConnection<T>(cb: (execute: ExecuteFn) => Promise<T>): Promise<T> {
39-
return cb(this.execute.bind(this));
39+
async runInTransaction<T>(
40+
opts: TransactionOptions,
41+
cb: (execute: ExecuteFn) => Promise<T>,
42+
): Promise<T> {
43+
const execute = this.execute.bind(this);
44+
return runSqlTransaction(execute, postgresBeginSql(opts), () => cb(execute));
4045
}
4146

4247
async close(): Promise<void> {

0 commit comments

Comments
 (0)