-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Add pg-transaction module #3518
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 1 commit
966f19b
64a9aa2
0815f05
0328cb3
1d9c68e
c489cfe
fbc1889
bc627be
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
{ | ||
"name": "pg-transaction", | ||
"version": "1.0.0", | ||
"main": "dist/index.js", | ||
"type": "module", | ||
"license": "MIT", | ||
"scripts": { | ||
"build": "tsc", | ||
"pretest": "yarn build", | ||
"test": "node dist/index.test.js" | ||
}, | ||
"dependencies": {}, | ||
"engines": { | ||
"node": ">=16.0.0" | ||
}, | ||
"devDependencies": { | ||
"@types/pg": "^8.10.9", | ||
"@types/node": "^24.0.14", | ||
"pg": "^8.11.3", | ||
"typescript": "^5.8.3" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,135 @@ | ||
import { describe, it, before, beforeEach, after } from 'node:test' | ||
import { strict as assert } from 'assert' | ||
import { Client, Pool } from 'pg' | ||
import { transaction } from './index.js' | ||
|
||
const withClient = async (cb: (client: Client) => Promise<void>): Promise<void> => { | ||
const client = new Client() | ||
await client.connect() | ||
try { | ||
await cb(client) | ||
} finally { | ||
await client.end() | ||
} | ||
} | ||
|
||
describe('Transaction', () => { | ||
before(async () => { | ||
// Ensure the test table is created before running tests | ||
await withClient(async (client) => { | ||
await client.query('CREATE TABLE IF NOT EXISTS test_table (id SERIAL PRIMARY KEY, name TEXT)') | ||
}) | ||
}) | ||
|
||
beforeEach(async () => { | ||
await withClient(async (client) => { | ||
await client.query('TRUNCATE test_table') | ||
}) | ||
}) | ||
|
||
after(async () => { | ||
// Clean up the test table after running tests | ||
await withClient(async (client) => { | ||
await client.query('DROP TABLE IF EXISTS test_table') | ||
}) | ||
}) | ||
|
||
it('should create a client with an empty temp table', async () => { | ||
await withClient(async (client) => { | ||
const { rowCount } = await client.query('SELECT * FROM test_table') | ||
assert.equal(rowCount, 0, 'Temp table should be empty on creation') | ||
}) | ||
}) | ||
|
||
it('should auto-commit at end of callback', async () => { | ||
await withClient(async (client) => { | ||
await transaction(client, async (client) => { | ||
await client.query('INSERT INTO test_table (name) VALUES ($1)', ['AutoCommit']) | ||
// row should be visible within transaction | ||
const { rows } = await client.query('SELECT * FROM test_table') | ||
assert.equal(rows.length, 1, 'Row should be inserted within transaction') | ||
|
||
// while inside this transaction, the changes are not visible outside | ||
await withClient(async (innerClient) => { | ||
const { rowCount } = await innerClient.query('SELECT * FROM test_table') | ||
assert.equal(rowCount, 0, 'Temp table should still be empty inside transaction') | ||
}) | ||
}) | ||
}) | ||
}) | ||
|
||
it('should rollback on error', async () => { | ||
await withClient(async (client) => { | ||
try { | ||
await transaction(client, async (client) => { | ||
await client.query('INSERT INTO test_table (name) VALUES ($1)', ['RollbackTest']) | ||
throw new Error('Intentional Error to trigger rollback') | ||
}) | ||
} catch (error) { | ||
// Expected error, do nothing | ||
} | ||
|
||
// After rollback, the table should still be empty | ||
const { rowCount } = await client.query('SELECT * FROM test_table') | ||
assert.equal(rowCount, 0, 'Temp table should be empty after rollback') | ||
}) | ||
}) | ||
|
||
it('works with Pool', async () => { | ||
const pool = new Pool() | ||
try { | ||
await transaction(pool, async (client) => { | ||
await client.query('INSERT INTO test_table (name) VALUES ($1)', ['PoolTransaction']) | ||
const { rows } = await client.query('SELECT * FROM test_table') | ||
assert.equal(rows.length, 1, 'Row should be inserted in pool transaction') | ||
}) | ||
|
||
assert.equal(pool.idleCount, 1, 'Pool should have idle clients after transaction') | ||
brianc marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// Verify the row is visible outside the transaction | ||
const { rows } = await pool.query('SELECT * FROM test_table') | ||
assert.equal(rows.length, 1, 'Row should be visible after pool transaction') | ||
} finally { | ||
await pool.end() | ||
} | ||
}) | ||
|
||
it('rollsback errors with pool', async () => { | ||
const pool = new Pool() | ||
try { | ||
try { | ||
await transaction(pool, async (client) => { | ||
await client.query('INSERT INTO test_table (name) VALUES ($1)', ['PoolRollbackTest']) | ||
throw new Error('Intentional Error to trigger rollback') | ||
}) | ||
} catch (error) { | ||
// Expected error, do nothing | ||
} | ||
|
||
// After rollback, the table should still be empty | ||
const { rowCount } = await pool.query('SELECT * FROM test_table') | ||
assert.equal(rowCount, 0, 'Temp table should be empty after pool rollback') | ||
} finally { | ||
await pool.end() | ||
} | ||
}) | ||
|
||
it('can be bound to first argument', async () => { | ||
const pool = new Pool() | ||
try { | ||
const txn = transaction.bind(null, pool) | ||
|
||
await txn(async (client) => { | ||
await client.query('INSERT INTO test_table (name) VALUES ($1)', ['BoundTransaction']) | ||
const { rows } = await client.query('SELECT * FROM test_table') | ||
assert.equal(rows.length, 1, 'Row should be inserted in bound transaction') | ||
}) | ||
|
||
// Verify the row is visible outside the transaction | ||
const { rows } = await pool.query('SELECT * FROM test_table') | ||
assert.equal(rows.length, 1, 'Row should be visible after bound transaction') | ||
} finally { | ||
await pool.end() | ||
} | ||
}) | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import type { Client, Pool, PoolClient } from 'pg' | ||
|
||
function isPoolClient(clientOrPool: Client | PoolClient): clientOrPool is PoolClient { | ||
return 'release' in clientOrPool | ||
} | ||
|
||
function isPool(clientOrPool: Client | Pool): clientOrPool is Pool { | ||
return 'idleCount' in clientOrPool | ||
brianc marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
async function transaction<T>(clientOrPool: Client | Pool, cb: (client: Client) => Promise<T>): Promise<T> { | ||
let client: Client | PoolClient | ||
if (isPool(clientOrPool)) { | ||
// It's a Pool | ||
client = await clientOrPool.connect() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This doesn’t do the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Sorry I'm not following! What do you mean by error listener dance? I thought There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
yeah, honestly myself included....for yeras, in multiple apps. 😬 the whole async/evented error stuff is so so tricky to get "really just right" - would love to see an example if you have one. I'm totally cool working on some kind of "buffer the error(s) if no default Honestly it could potentially be a lot to take on and needs some thinking about where the right place to handle these errors is (because legacy reasons we do pool -> client -> connection -> pg-protocol) callstacks, but its probably worth spending more time there myself. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I already did the error replay part in #1503; |
||
} else { | ||
// It's a Client | ||
client = clientOrPool as Client | ||
} | ||
await client.query('BEGIN') | ||
try { | ||
const result = await cb(client as Client) | ||
brianc marked this conversation as resolved.
Show resolved
Hide resolved
|
||
await client.query('COMMIT') | ||
return result | ||
} catch (error) { | ||
await client.query('ROLLBACK') | ||
throw error | ||
} finally { | ||
if (isPoolClient(client)) { | ||
client.release() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the rollback failed, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oh that's a good point. I'll take stab at this...I think I can repro by |
||
} | ||
} | ||
} | ||
|
||
export { transaction } |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
{ | ||
"compilerOptions": { | ||
"target": "esnext", | ||
"moduleResolution": "node", | ||
"outDir": "./dist", | ||
"rootDir": "./src", | ||
"declaration": true, | ||
"esModuleInterop": true, | ||
"forceConsistentCasingInFileNames": true, | ||
"strict": true, | ||
"skipLibCheck": false | ||
}, | ||
"include": ["src/**/*"], | ||
"exclude": ["node_modules", "dist", "test"] | ||
} |
Uh oh!
There was an error while loading. Please reload this page.