Skip to content

Commit 966f19b

Browse files
committed
Add transaction helper
1 parent 27a2754 commit 966f19b

File tree

6 files changed

+946
-29
lines changed

6 files changed

+946
-29
lines changed

packages/pg-transaction/package.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "pg-transaction",
3+
"version": "1.0.0",
4+
"main": "dist/index.js",
5+
"type": "module",
6+
"license": "MIT",
7+
"scripts": {
8+
"build": "tsc",
9+
"pretest": "yarn build",
10+
"test": "node dist/index.test.js"
11+
},
12+
"dependencies": {},
13+
"engines": {
14+
"node": ">=16.0.0"
15+
},
16+
"devDependencies": {
17+
"@types/pg": "^8.10.9",
18+
"@types/node": "^24.0.14",
19+
"pg": "^8.11.3",
20+
"typescript": "^5.8.3"
21+
}
22+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { describe, it, before, beforeEach, after } from 'node:test'
2+
import { strict as assert } from 'assert'
3+
import { Client, Pool } from 'pg'
4+
import { transaction } from './index.js'
5+
6+
const withClient = async (cb: (client: Client) => Promise<void>): Promise<void> => {
7+
const client = new Client()
8+
await client.connect()
9+
try {
10+
await cb(client)
11+
} finally {
12+
await client.end()
13+
}
14+
}
15+
16+
describe('Transaction', () => {
17+
before(async () => {
18+
// Ensure the test table is created before running tests
19+
await withClient(async (client) => {
20+
await client.query('CREATE TABLE IF NOT EXISTS test_table (id SERIAL PRIMARY KEY, name TEXT)')
21+
})
22+
})
23+
24+
beforeEach(async () => {
25+
await withClient(async (client) => {
26+
await client.query('TRUNCATE test_table')
27+
})
28+
})
29+
30+
after(async () => {
31+
// Clean up the test table after running tests
32+
await withClient(async (client) => {
33+
await client.query('DROP TABLE IF EXISTS test_table')
34+
})
35+
})
36+
37+
it('should create a client with an empty temp table', async () => {
38+
await withClient(async (client) => {
39+
const { rowCount } = await client.query('SELECT * FROM test_table')
40+
assert.equal(rowCount, 0, 'Temp table should be empty on creation')
41+
})
42+
})
43+
44+
it('should auto-commit at end of callback', async () => {
45+
await withClient(async (client) => {
46+
await transaction(client, async (client) => {
47+
await client.query('INSERT INTO test_table (name) VALUES ($1)', ['AutoCommit'])
48+
// row should be visible within transaction
49+
const { rows } = await client.query('SELECT * FROM test_table')
50+
assert.equal(rows.length, 1, 'Row should be inserted within transaction')
51+
52+
// while inside this transaction, the changes are not visible outside
53+
await withClient(async (innerClient) => {
54+
const { rowCount } = await innerClient.query('SELECT * FROM test_table')
55+
assert.equal(rowCount, 0, 'Temp table should still be empty inside transaction')
56+
})
57+
})
58+
})
59+
})
60+
61+
it('should rollback on error', async () => {
62+
await withClient(async (client) => {
63+
try {
64+
await transaction(client, async (client) => {
65+
await client.query('INSERT INTO test_table (name) VALUES ($1)', ['RollbackTest'])
66+
throw new Error('Intentional Error to trigger rollback')
67+
})
68+
} catch (error) {
69+
// Expected error, do nothing
70+
}
71+
72+
// After rollback, the table should still be empty
73+
const { rowCount } = await client.query('SELECT * FROM test_table')
74+
assert.equal(rowCount, 0, 'Temp table should be empty after rollback')
75+
})
76+
})
77+
78+
it('works with Pool', async () => {
79+
const pool = new Pool()
80+
try {
81+
await transaction(pool, async (client) => {
82+
await client.query('INSERT INTO test_table (name) VALUES ($1)', ['PoolTransaction'])
83+
const { rows } = await client.query('SELECT * FROM test_table')
84+
assert.equal(rows.length, 1, 'Row should be inserted in pool transaction')
85+
})
86+
87+
assert.equal(pool.idleCount, 1, 'Pool should have idle clients after transaction')
88+
89+
// Verify the row is visible outside the transaction
90+
const { rows } = await pool.query('SELECT * FROM test_table')
91+
assert.equal(rows.length, 1, 'Row should be visible after pool transaction')
92+
} finally {
93+
await pool.end()
94+
}
95+
})
96+
97+
it('rollsback errors with pool', async () => {
98+
const pool = new Pool()
99+
try {
100+
try {
101+
await transaction(pool, async (client) => {
102+
await client.query('INSERT INTO test_table (name) VALUES ($1)', ['PoolRollbackTest'])
103+
throw new Error('Intentional Error to trigger rollback')
104+
})
105+
} catch (error) {
106+
// Expected error, do nothing
107+
}
108+
109+
// After rollback, the table should still be empty
110+
const { rowCount } = await pool.query('SELECT * FROM test_table')
111+
assert.equal(rowCount, 0, 'Temp table should be empty after pool rollback')
112+
} finally {
113+
await pool.end()
114+
}
115+
})
116+
117+
it('can be bound to first argument', async () => {
118+
const pool = new Pool()
119+
try {
120+
const txn = transaction.bind(null, pool)
121+
122+
await txn(async (client) => {
123+
await client.query('INSERT INTO test_table (name) VALUES ($1)', ['BoundTransaction'])
124+
const { rows } = await client.query('SELECT * FROM test_table')
125+
assert.equal(rows.length, 1, 'Row should be inserted in bound transaction')
126+
})
127+
128+
// Verify the row is visible outside the transaction
129+
const { rows } = await pool.query('SELECT * FROM test_table')
130+
assert.equal(rows.length, 1, 'Row should be visible after bound transaction')
131+
} finally {
132+
await pool.end()
133+
}
134+
})
135+
})

packages/pg-transaction/src/index.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { Client, Pool, PoolClient } from 'pg'
2+
3+
function isPoolClient(clientOrPool: Client | PoolClient): clientOrPool is PoolClient {
4+
return 'release' in clientOrPool
5+
}
6+
7+
function isPool(clientOrPool: Client | Pool): clientOrPool is Pool {
8+
return 'idleCount' in clientOrPool
9+
}
10+
11+
async function transaction<T>(clientOrPool: Client | Pool, cb: (client: Client) => Promise<T>): Promise<T> {
12+
let client: Client | PoolClient
13+
if (isPool(clientOrPool)) {
14+
// It's a Pool
15+
client = await clientOrPool.connect()
16+
} else {
17+
// It's a Client
18+
client = clientOrPool as Client
19+
}
20+
await client.query('BEGIN')
21+
try {
22+
const result = await cb(client as Client)
23+
await client.query('COMMIT')
24+
return result
25+
} catch (error) {
26+
await client.query('ROLLBACK')
27+
throw error
28+
} finally {
29+
if (isPoolClient(client)) {
30+
client.release()
31+
}
32+
}
33+
}
34+
35+
export { transaction }

packages/pg-transaction/tsconfig.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"compilerOptions": {
3+
"target": "esnext",
4+
"moduleResolution": "node",
5+
"outDir": "./dist",
6+
"rootDir": "./src",
7+
"declaration": true,
8+
"esModuleInterop": true,
9+
"forceConsistentCasingInFileNames": true,
10+
"strict": true,
11+
"skipLibCheck": false
12+
},
13+
"include": ["src/**/*"],
14+
"exclude": ["node_modules", "dist", "test"]
15+
}

0 commit comments

Comments
 (0)