Skip to content

Commit 64a9aa2

Browse files
author
Brian Carlson
committed
Write docs, add test
1 parent 966f19b commit 64a9aa2

File tree

2 files changed

+270
-2
lines changed

2 files changed

+270
-2
lines changed

docs/pages/features/transactions.mdx

Lines changed: 255 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,213 @@ title: Transactions
44

55
import { Alert } from '/components/alert.tsx'
66

7-
To execute a transaction with node-postgres you simply execute `BEGIN / COMMIT / ROLLBACK` queries yourself through a client. Because node-postgres strives to be low level and un-opinionated, it doesn't provide any higher level abstractions specifically around transactions.
7+
PostgreSQL transactions ensure that a series of database operations either all succeed or all fail together, maintaining data consistency. Node-postgres provides two approaches for handling transactions: manual transaction management and the very slightly higher-level `pg-transaction` module.
8+
9+
## pg-transaction Module
10+
11+
The `pg-transaction` module provides a tiny level of abstraction for handling transactions, automatically managing `BEGIN`, `COMMIT`, and `ROLLBACK` operations while ensuring proper client lifecycle management.
12+
13+
The motivation for this module was I pretty much write the same exact thing in every project I start. Sounds like a good thing to just publish widely.
14+
15+
### Installation
16+
17+
The `pg-transaction` module is included as part of the node-postgres monorepo:
18+
19+
```bash
20+
npm install pg-transaction
21+
```
22+
23+
### Basic Usage
24+
25+
The `transaction` function accepts either a `Client` or `Pool` instance and a callback function:
26+
27+
```js
28+
import { Pool } from 'pg'
29+
import { transaction } from 'pg-transaction'
30+
31+
const pool = new Pool()
32+
33+
// Using with a Pool (recommended)
34+
const result = await transaction(pool, async (client) => {
35+
const userResult = await client.query(
36+
'INSERT INTO users(name) VALUES($1) RETURNING id',
37+
['Alice']
38+
)
39+
40+
await client.query(
41+
'INSERT INTO photos(user_id, photo_url) VALUES ($1, $2)',
42+
[userResult.rows[0].id, 's3.bucket.foo']
43+
)
44+
45+
return userResult.rows[0]
46+
})
47+
48+
console.log('User created:', result)
49+
```
50+
51+
### API Reference
52+
53+
#### `transaction(clientOrPool, callback)`
54+
55+
**Parameters:**
56+
- `clientOrPool`: A `pg.Client` or `pg.Pool` instance
57+
- `callback`: An async function that receives a client and returns a promise
58+
59+
**Returns:** A promise that resolves to the return value of the callback
60+
61+
**Behavior:**
62+
- Automatically executes `BEGIN` before the callback
63+
- Executes `COMMIT` if the callback completes successfully
64+
- Executes `ROLLBACK` if the callback throws an error, then re-throws the error for you to handle
65+
- When using a Pool, automatically acquires and releases a client
66+
- When using a Client, uses the provided client directly. The client __must__ be connected already.
67+
68+
### Usage Examples
69+
70+
#### With Pool (Recommended)
71+
72+
```js
73+
import { Pool } from 'pg'
74+
import { transaction } from 'pg-transaction'
75+
76+
const pool = new Pool()
77+
78+
try {
79+
const userId = await transaction(pool, async (client) => {
80+
// All queries within this callback are part of the same transaction
81+
const userResult = await client.query(
82+
'INSERT INTO users(name, email) VALUES($1, $2) RETURNING id',
83+
['John Doe', '[email protected]']
84+
)
85+
86+
const profileResult = await client.query(
87+
'INSERT INTO user_profiles(user_id, bio) VALUES($1, $2)',
88+
[userResult.rows[0].id, 'Software developer']
89+
)
90+
91+
// Return the user ID
92+
return userResult.rows[0].id
93+
})
94+
95+
console.log('Created user with ID:', userId)
96+
} catch (error) {
97+
console.error('Transaction failed:', error)
98+
// All changes have been automatically rolled back
99+
}
100+
```
101+
102+
#### With Client
103+
104+
```js
105+
import { Client } from 'pg'
106+
import { transaction } from 'pg-transaction'
107+
108+
const client = new Client()
109+
await client.connect()
110+
111+
try {
112+
await transaction(client, async (client) => {
113+
await client.query('UPDATE accounts SET balance = balance - 100 WHERE id = $1', [1])
114+
await client.query('UPDATE accounts SET balance = balance + 100 WHERE id = $1', [2])
115+
})
116+
console.log('Transfer completed successfully')
117+
} catch (error) {
118+
console.error('Transfer failed:', error)
119+
} finally {
120+
await client.end()
121+
}
122+
```
123+
124+
#### Binding for Reuse
125+
126+
You can bind the transaction function to a specific pool or client for convenient reuse. I usually do this as a module level singleton I export after I define my pool.
127+
128+
```js
129+
import { Pool } from 'pg'
130+
import { transaction } from 'pg-transaction'
131+
132+
const pool = new Pool()
133+
const txn = transaction.bind(null, pool)
134+
135+
// Now you can use txn directly
136+
await txn(async (client) => {
137+
await client.query('INSERT INTO logs(message) VALUES($1)', ['Operation 1'])
138+
})
139+
140+
await txn(async (client) => {
141+
await client.query('INSERT INTO logs(message) VALUES($1)', ['Operation 2'])
142+
})
143+
```
144+
145+
#### Error Handling and Rollback
146+
147+
The transaction function automatically handles rollbacks when errors occur:
148+
149+
```js
150+
import { transaction } from 'pg-transaction'
151+
152+
try {
153+
await transaction(pool, async (client) => {
154+
await client.query('INSERT INTO orders(user_id, total) VALUES($1, $2)', [userId, 100])
155+
156+
// This will cause the transaction to rollback
157+
if (Math.random() > 0.5) {
158+
throw new Error('Payment processing failed')
159+
}
160+
161+
await client.query('UPDATE inventory SET quantity = quantity - 1 WHERE product_id = $1', [productId])
162+
})
163+
} catch (error) {
164+
// The transaction has been automatically rolled back
165+
console.error('Order creation failed:', error.message)
166+
}
167+
```
168+
169+
### Best Practices
170+
171+
1. **Use with Pools**: When possible, use the transaction function with a `Pool` rather than a `Client` for better resource management.
172+
173+
2. **Keep Transactions Short**: Minimize the time spent in transactions to reduce lock contention.
174+
175+
3. **Handle Errors Appropriately**: Let the transaction function handle rollbacks, but ensure your application logic handles the errors appropriately.
176+
177+
4. **Avoid Nested Transactions**: PostgreSQL doesn't support true nested transactions. Use savepoints if you need nested behavior.
178+
179+
5. **Return Values**: Use the callback's return value to pass data out of the transaction.
180+
181+
### Migration from Manual Transactions
182+
183+
If you're currently using manual transaction handling, migrating to `pg-transaction` is straightforward:
184+
185+
**Before (Manual):**
186+
```js
187+
const client = await pool.connect()
188+
try {
189+
await client.query('BEGIN')
190+
const result = await client.query('INSERT INTO users(name) VALUES($1) RETURNING id', ['Alice'])
191+
await client.query('INSERT INTO profiles(user_id) VALUES($1)', [result.rows[0].id])
192+
await client.query('COMMIT')
193+
return result.rows[0]
194+
} catch (error) {
195+
await client.query('ROLLBACK')
196+
throw error
197+
} finally {
198+
client.release()
199+
}
200+
```
201+
202+
**After (pg-transaction):**
203+
```js
204+
return await transaction(pool, async (client) => {
205+
const result = await client.query('INSERT INTO users(name) VALUES($1) RETURNING id', ['Alice'])
206+
await client.query('INSERT INTO profiles(user_id) VALUES($1)', [result.rows[0].id])
207+
return result.rows[0]
208+
})
209+
```
210+
211+
## Manual Transaction Handling
212+
213+
For cases where you need more control or prefer to handle transactions manually, you can execute `BEGIN`, `COMMIT`, and `ROLLBACK` queries directly.
8214

9215
<Alert>
10216
You <strong>must</strong> use the <em>same</em> client instance for all statements within a transaction. PostgreSQL
@@ -13,7 +219,8 @@ To execute a transaction with node-postgres you simply execute `BEGIN / COMMIT /
13219
the <span className="code">pool.query</span> method.
14220
</Alert>
15221

16-
## Examples
222+
223+
### Manual Transaction Example
17224

18225
```js
19226
import { Pool } from 'pg'
@@ -37,3 +244,49 @@ try {
37244
client.release()
38245
}
39246
```
247+
248+
### When to Use Manual Transactions
249+
250+
Consider manual transaction handling when you need:
251+
252+
- **Savepoints**: Creating intermediate rollback points within a transaction
253+
- **Custom Transaction Isolation Levels**: Setting specific isolation levels
254+
- **Complex Transaction Logic**: Conditional commits or rollbacks based on business logic
255+
- **Performance Optimization**: Fine-grained control over transaction boundaries
256+
257+
### Savepoints Example
258+
259+
```js
260+
const client = await pool.connect()
261+
262+
try {
263+
await client.query('BEGIN')
264+
265+
// First operation
266+
await client.query('INSERT INTO orders(user_id, total) VALUES($1, $2)', [userId, total])
267+
268+
// Create a savepoint
269+
await client.query('SAVEPOINT order_items')
270+
271+
try {
272+
// Attempt to insert order items
273+
for (const item of items) {
274+
await client.query('INSERT INTO order_items(order_id, product_id, quantity) VALUES($1, $2, $3)',
275+
[orderId, item.productId, item.quantity])
276+
}
277+
} catch (error) {
278+
// Rollback to savepoint, keeping the order
279+
await client.query('ROLLBACK TO SAVEPOINT order_items')
280+
console.log('Order items failed, but order preserved')
281+
}
282+
283+
await client.query('COMMIT')
284+
} catch (error) {
285+
await client.query('ROLLBACK')
286+
throw error
287+
} finally {
288+
client.release()
289+
}
290+
```
291+
292+
**Recommendation**: Use `pg-transaction` for most use cases, and fall back to manual transaction handling only when you need advanced features like savepoints or custom isolation levels. Note: the number of times I've done this in production apps is _nearly_ zero.

packages/pg-transaction/src/index.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,4 +132,19 @@ describe('Transaction', () => {
132132
await pool.end()
133133
}
134134
})
135+
136+
it('can return something from the transaction callback', async () => {
137+
const pool = new Pool()
138+
const result = await transaction(pool, async (client) => {
139+
await client.query('INSERT INTO test_table (name) VALUES ($1)', ['ReturnValueTest'])
140+
return 'Transaction Result'
141+
})
142+
143+
assert.equal(result, 'Transaction Result', 'Should return value from transaction callback')
144+
145+
// Verify the row is visible outside the transaction
146+
const { rows } = await pool.query('SELECT * FROM test_table')
147+
assert.equal(rows.length, 1, 'Row should be visible after transaction with return value')
148+
pool.end()
149+
})
135150
})

0 commit comments

Comments
 (0)