Skip to content

Commit bbd8207

Browse files
committed
feat(postgres,sqlite): add typed temp table creation in transactions
Signed-off-by: paulwer <paul@wer-ner.de>
1 parent 9ccebe0 commit bbd8207

17 files changed

Lines changed: 2451 additions & 268 deletions

File tree

packages/2-sql/5-runtime/src/exports/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,12 @@ export {
5858
createSqlExecutionStack,
5959
} from '../sql-context';
6060
export type {
61+
ConnectionContext,
6162
ConnectionProvider,
6263
Runtime,
6364
RuntimeConnection,
6465
RuntimeQueryable,
6566
RuntimeTransaction,
6667
TransactionContext,
6768
} from '../sql-runtime';
68-
export { SqlRuntimeBase, withTransaction } from '../sql-runtime';
69+
export { SqlRuntimeBase, withConnection, withTransaction } from '../sql-runtime';

packages/2-sql/5-runtime/src/sql-runtime.ts

Lines changed: 100 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,22 +99,53 @@ export interface Runtime extends RuntimeQueryable {
9999
export interface RuntimeConnection extends RuntimeQueryable {
100100
transaction(): Promise<RuntimeTransaction>;
101101
/**
102-
* 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.
102+
* Register a hook to run immediately before the connection is released back to the pool.
103+
* Hooks are invoked in registration order and awaited sequentially. If any hook throws,
104+
* the connection is destroyed rather than returned to the pool, and the error propagates.
105+
* Not invoked when `destroy()` is called directly.
106+
*/
107+
registerReleaseHook(hook: () => Promise<void>): void;
108+
/**
109+
* Returns the connection to the pool for reuse. Runs all registered release hooks first;
110+
* if any hook throws the connection is destroyed and the error propagates. Only call this
111+
* when the connection is known to be in a clean state. If a transaction commit/rollback
112+
* failed or the connection is otherwise suspect, call `destroy(reason)` instead.
103113
*/
104114
release(): Promise<void>;
105115
/**
106-
* Evicts the connection so it is never reused. Call this when the connection may be in an indeterminate state (e.g. a failed rollback leaving an open transaction, or a broken socket).
116+
* Evicts the connection so it is never reused. Call this when the connection may be in an
117+
* indeterminate state (e.g. a failed rollback leaving an open transaction, or a broken socket).
107118
*
108-
* If teardown fails the error is propagated and the connection remains retryable, so the caller can decide whether to swallow the failure or retry cleanup. Calling destroy() or release() more than once after a successful teardown is caller error.
119+
* If teardown fails the error is propagated and the connection remains retryable, so the caller
120+
* can decide whether to swallow the failure or retry cleanup. Calling destroy() or release() more
121+
* than once after a successful teardown is caller error.
109122
*
110-
* `reason` is advisory context only. It may be surfaced to driver-level observability hooks (e.g. pg-pool's `'release'` event) but does not influence eviction behavior and is not rethrown.
123+
* `reason` is advisory context only. It may be surfaced to driver-level observability hooks
124+
* (e.g. pg-pool's `'release'` event) but does not influence eviction behavior and is not rethrown.
111125
*/
112126
destroy(reason?: unknown): Promise<void>;
113127
}
114128

129+
/**
130+
* Restricted view of a {@link RuntimeConnection} passed to a {@link withConnection} callback.
131+
* Exposes query execution and release-hook registration, but not the raw `release()` / `destroy()`
132+
* lifecycle methods — those are managed by `withConnection` itself.
133+
*/
134+
export interface ConnectionContext extends RuntimeQueryable {
135+
registerReleaseHook(hook: () => Promise<void>): void;
136+
}
137+
115138
export interface RuntimeTransaction extends RuntimeQueryable {
116139
commit(): Promise<void>;
117140
rollback(): Promise<void>;
141+
/**
142+
* Register a hook to run immediately before the transaction is committed.
143+
* Hooks are invoked in registration order and awaited sequentially. If any
144+
* hook throws, the commit is aborted and the error propagates to the caller.
145+
* Not invoked on rollback.
146+
*/
147+
registerPreCommitHook(hook: () => Promise<void>): void;
148+
runPreCommitHooks(): Promise<void>;
118149
}
119150

120151
export interface RuntimeQueryable extends RuntimeScope {
@@ -133,6 +164,13 @@ export interface RuntimeQueryable extends RuntimeScope {
133164

134165
export interface TransactionContext extends RuntimeQueryable {
135166
readonly invalidated: boolean;
167+
/**
168+
* Register a hook to run immediately before the transaction is committed.
169+
* Hooks are invoked in registration order and awaited sequentially. If any
170+
* hook throws, the commit is aborted and the error propagates to the caller.
171+
* Not invoked on rollback.
172+
*/
173+
registerPreCommitHook(hook: () => Promise<void>): void;
136174
}
137175

138176
export type { RuntimeTelemetryEvent, TelemetryOutcome, VerifyMarkerOption };
@@ -611,14 +649,28 @@ export abstract class SqlRuntimeBase<TContract extends Contract<SqlStorage> = Co
611649
async connection(): Promise<RuntimeConnection> {
612650
const driverConn = await this.driver.acquireConnection();
613651
const self = this;
652+
const releaseHooks: Array<() => Promise<void>> = [];
614653

615654
const wrappedConnection: RuntimeConnection = {
616655
async transaction(): Promise<RuntimeTransaction> {
617656
const driverTx = await driverConn.beginTransaction();
618657
return self.wrapTransaction(driverTx);
619658
},
659+
registerReleaseHook(hook: () => Promise<void>): void {
660+
releaseHooks.push(hook);
661+
},
620662
async release(): Promise<void> {
621-
await driverConn.release();
663+
try {
664+
let hook = releaseHooks.shift();
665+
while (hook !== undefined) {
666+
await hook();
667+
hook = releaseHooks.shift();
668+
}
669+
await driverConn.release();
670+
} catch (err) {
671+
await driverConn.destroy(err);
672+
throw err;
673+
}
622674
},
623675
async destroy(reason?: unknown): Promise<void> {
624676
await driverConn.destroy(reason);
@@ -649,10 +701,26 @@ export abstract class SqlRuntimeBase<TContract extends Contract<SqlStorage> = Co
649701
return wrappedConnection;
650702
}
651703

652-
private wrapTransaction(driverTx: SqlTransaction): RuntimeTransaction {
704+
protected wrapTransaction(driverTx: SqlTransaction): RuntimeTransaction {
653705
const self = this;
706+
const preCommitHooks: Array<() => Promise<void>> = [];
654707
return {
708+
registerPreCommitHook(hook: () => Promise<void>): void {
709+
preCommitHooks.push(hook);
710+
},
711+
async runPreCommitHooks(): Promise<void> {
712+
let hook = preCommitHooks.shift();
713+
while (hook !== undefined) {
714+
await hook();
715+
hook = preCommitHooks.shift();
716+
}
717+
},
655718
async commit(): Promise<void> {
719+
let hook = preCommitHooks.shift();
720+
while (hook !== undefined) {
721+
await hook();
722+
hook = preCommitHooks.shift();
723+
}
656724
await driverTx.commit();
657725
},
658726
async rollback(): Promise<void> {
@@ -808,6 +876,12 @@ export async function withTransaction<R>(
808876
guardedStream(transaction.executePrepared(ps, params, options)),
809877
);
810878
},
879+
registerPreCommitHook(hook: () => Promise<void>): void {
880+
if (invalidated) {
881+
throw transactionClosedError();
882+
}
883+
transaction.registerPreCommitHook(hook);
884+
},
811885
};
812886

813887
let connectionDisposed = false;
@@ -822,6 +896,7 @@ export async function withTransaction<R>(
822896
let result: R;
823897
try {
824898
result = await fn(txContext);
899+
await transaction.runPreCommitHooks();
825900
} catch (error) {
826901
try {
827902
await transaction.rollback();
@@ -866,3 +941,22 @@ export async function withTransaction<R>(
866941
}
867942
}
868943
}
944+
945+
export async function withConnection<R>(
946+
runtime: ConnectionProvider,
947+
fn: (conn: ConnectionContext) => PromiseLike<R>,
948+
): Promise<R> {
949+
const connection = await runtime.connection();
950+
let released = false;
951+
try {
952+
const result = await fn(connection);
953+
released = true;
954+
await connection.release();
955+
return result;
956+
} catch (err) {
957+
if (!released) {
958+
await connection.destroy(err).catch(() => undefined);
959+
}
960+
throw err;
961+
}
962+
}

packages/2-sql/5-runtime/test/sql-runtime.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -942,3 +942,110 @@ describe('withTransaction', () => {
942942
expect(driver.__spies.transactionRollback).toHaveBeenCalledTimes(1);
943943
});
944944
});
945+
946+
describe('RuntimeTransaction.registerPreCommitHook', () => {
947+
function createRuntimeForHooks() {
948+
const { stackInstance, context, driver } = createTestSetup();
949+
const runtime = createRuntime({ stackInstance, context, driver, verifyMarker: false });
950+
return { runtime, driver };
951+
}
952+
953+
it('runs the hook before driverTx.commit()', async () => {
954+
const { runtime, driver } = createRuntimeForHooks();
955+
const order: string[] = [];
956+
driver.__spies.transactionCommit.mockImplementation(async () => {
957+
order.push('commit');
958+
});
959+
960+
const conn = await runtime.connection();
961+
const tx = await conn.transaction();
962+
tx.registerPreCommitHook(async () => {
963+
order.push('hook');
964+
});
965+
await tx.commit();
966+
await conn.release();
967+
968+
expect(order).toEqual(['hook', 'commit']);
969+
});
970+
971+
it('runs multiple hooks in registration order before commit', async () => {
972+
const { runtime, driver } = createRuntimeForHooks();
973+
const order: string[] = [];
974+
driver.__spies.transactionCommit.mockImplementation(async () => {
975+
order.push('commit');
976+
});
977+
978+
const conn = await runtime.connection();
979+
const tx = await conn.transaction();
980+
tx.registerPreCommitHook(async () => {
981+
order.push('hook-1');
982+
});
983+
tx.registerPreCommitHook(async () => {
984+
order.push('hook-2');
985+
});
986+
tx.registerPreCommitHook(async () => {
987+
order.push('hook-3');
988+
});
989+
await tx.commit();
990+
await conn.release();
991+
992+
expect(order).toEqual(['hook-1', 'hook-2', 'hook-3', 'commit']);
993+
});
994+
995+
it('aborts commit and propagates the error when a hook throws', async () => {
996+
const { runtime, driver } = createRuntimeForHooks();
997+
const hookError = new Error('hook failed');
998+
999+
const conn = await runtime.connection();
1000+
const tx = await conn.transaction();
1001+
tx.registerPreCommitHook(async () => {
1002+
throw hookError;
1003+
});
1004+
1005+
await expect(tx.commit()).rejects.toBe(hookError);
1006+
expect(driver.__spies.transactionCommit).not.toHaveBeenCalled();
1007+
await conn.release();
1008+
});
1009+
1010+
it('does not invoke subsequent hooks after an earlier hook throws', async () => {
1011+
const { runtime, driver } = createRuntimeForHooks();
1012+
const hook2 = vi.fn();
1013+
1014+
const conn = await runtime.connection();
1015+
const tx = await conn.transaction();
1016+
tx.registerPreCommitHook(async () => {
1017+
throw new Error('hook-1 failed');
1018+
});
1019+
tx.registerPreCommitHook(hook2);
1020+
1021+
await tx.commit().catch(() => {});
1022+
1023+
expect(hook2).not.toHaveBeenCalled();
1024+
expect(driver.__spies.transactionCommit).not.toHaveBeenCalled();
1025+
await conn.release();
1026+
});
1027+
1028+
it('does not invoke hooks on rollback', async () => {
1029+
const { runtime } = createRuntimeForHooks();
1030+
const hook = vi.fn();
1031+
1032+
const conn = await runtime.connection();
1033+
const tx = await conn.transaction();
1034+
tx.registerPreCommitHook(hook);
1035+
await tx.rollback();
1036+
await conn.release();
1037+
1038+
expect(hook).not.toHaveBeenCalled();
1039+
});
1040+
1041+
it('no hooks registered — commit proceeds normally', async () => {
1042+
const { runtime, driver } = createRuntimeForHooks();
1043+
1044+
const conn = await runtime.connection();
1045+
const tx = await conn.transaction();
1046+
await tx.commit();
1047+
await conn.release();
1048+
1049+
expect(driver.__spies.transactionCommit).toHaveBeenCalledOnce();
1050+
});
1051+
});

0 commit comments

Comments
 (0)