From a4e42996e97116ab4b37c65e5f68ae3122d99664 Mon Sep 17 00:00:00 2001 From: AbhilashG12 Date: Fri, 25 Sep 2026 12:22:36 +0530 Subject: [PATCH] fix(adapter-pg): destroy pg connections after transaction database errors to prevent response leaking Signed-off-by: AbhilashG12 --- packages/adapter-pg/src/__tests__/pg.test.ts | 66 ++++++++++++++++++++ packages/adapter-pg/src/pg.ts | 8 ++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/packages/adapter-pg/src/__tests__/pg.test.ts b/packages/adapter-pg/src/__tests__/pg.test.ts index 451005039372..6ad59304c44d 100644 --- a/packages/adapter-pg/src/__tests__/pg.test.ts +++ b/packages/adapter-pg/src/__tests__/pg.test.ts @@ -152,4 +152,70 @@ describe('PrismaPgAdapterFactory', () => { await adapter.dispose() }) + + it('should destroy connection on rollback if transaction was poisoned by a database error', async () => { + const config: pg.PoolConfig = { user: 'test', password: 'test', database: 'test', port: 5432, host: 'localhost' } + const factory = new PrismaPgAdapterFactory(config) + const adapter = await factory.connect() + + const mockConnection = { + on: vi.fn(), + removeListener: vi.fn(), + query: vi.fn(), + release: vi.fn(), + } + + adapter['client'].connect = vi.fn().mockResolvedValue(mockConnection) + + // 1. Start Transaction (BEGIN) + mockConnection.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }) + const transaction = await adapter.startTransaction() + + // 2. Simulate a fatal Database Error (e.g., Duplicate Key) + const dbError = new Error('duplicate key value violates unique constraint') + mockConnection.query.mockRejectedValueOnce(dbError) + + await expect(transaction.executeRaw({ sql: 'INSERT INTO "User"...', args: [], argTypes: [] })).rejects.toThrow() + + // 3. Rollback the transaction + mockConnection.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // mock the ROLLBACK query success + await transaction.rollback() + + // 4. VERIFY: Ensure release was called with an Error, destroying the socket + expect(mockConnection.release).toHaveBeenCalledWith(expect.any(Error)) + expect(mockConnection.release.mock.calls[0][0].message).toMatch(/poisoned transaction connection/i) + + await adapter.dispose() + }) + + it('should release connection normally on rollback if transaction was healthy', async () => { + const config: pg.PoolConfig = { user: 'test', password: 'test', database: 'test', port: 5432, host: 'localhost' } + const factory = new PrismaPgAdapterFactory(config) + const adapter = await factory.connect() + + const mockConnection = { + on: vi.fn(), + removeListener: vi.fn(), + query: vi.fn(), + release: vi.fn(), + } + + adapter['client'].connect = vi.fn().mockResolvedValue(mockConnection) + + mockConnection.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }) + const transaction = await adapter.startTransaction() + + // Execute a successful query + mockConnection.query.mockResolvedValueOnce({ rows: [], rowCount: 1 }) + await transaction.executeRaw({ sql: 'SELECT 1', args: [], argTypes: [] }) + + // User triggers a manual rollback + mockConnection.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }) + await transaction.rollback() + + // VERIFY: Ensure release was called cleanly with undefined (no error) + expect(mockConnection.release).toHaveBeenCalledWith(undefined) + + await adapter.dispose() + }) }) diff --git a/packages/adapter-pg/src/pg.ts b/packages/adapter-pg/src/pg.ts index 6a677bdad614..c86871e1a998 100644 --- a/packages/adapter-pg/src/pg.ts +++ b/packages/adapter-pg/src/pg.ts @@ -31,6 +31,7 @@ type TransactionClient = pg.PoolClient class PgQueryable implements SqlQueryable { readonly provider = 'postgres' readonly adapterName = packageName + protected isPoisoned = false constructor( protected readonly client: ClientT, @@ -126,6 +127,7 @@ class PgQueryable implements SqlQ } protected onError(error: unknown): never { + this.isPoisoned = true debug('Error in performIO: %O', error) throw new DriverAdapterError(convertDriverError(error)) } @@ -145,14 +147,16 @@ class PgTransaction extends PgQueryable implements Transactio debug(`[js::commit]`) this.cleanup?.() - this.client.release() + const err = this.isPoisoned ? new Error('Prisma: Destroying poisoned transaction connection') : undefined + this.client.release(err) } async rollback(): Promise { debug(`[js::rollback]`) this.cleanup?.() - this.client.release() + const err = this.isPoisoned ? new Error('Prisma: Destroying poisoned transaction connection') : undefined + this.client.release(err) } async createSavepoint(name: string): Promise {