-
-
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
Open
brianc
wants to merge
8
commits into
master
Choose a base branch
from
bmc/pg-transaction-2
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 3 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
966f19b
Add transaction helper
brianc 64a9aa2
Write docs, add test
0815f05
Slightly better types
0328cb3
Remove slop
brianc 1d9c68e
Update packages/pg-transaction/src/index.test.ts
brianc c489cfe
Add test for connection failure rejecting properly
brianc fbc1889
Merge branch 'bmc/pg-transaction-2' of github.com:brianc/node-postgre…
brianc bc627be
Fix auto-merge conflict
brianc File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,7 +4,213 @@ title: Transactions | |
|
||
import { Alert } from '/components/alert.tsx' | ||
|
||
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. | ||
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. | ||
|
||
## pg-transaction Module | ||
|
||
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. | ||
|
||
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. | ||
|
||
### Installation | ||
|
||
The `pg-transaction` module is included as part of the node-postgres monorepo: | ||
|
||
```bash | ||
npm install pg-transaction | ||
``` | ||
|
||
### Basic Usage | ||
|
||
The `transaction` function accepts either a `Client` or `Pool` instance and a callback function: | ||
|
||
```js | ||
import { Pool } from 'pg' | ||
import { transaction } from 'pg-transaction' | ||
|
||
const pool = new Pool() | ||
|
||
// Using with a Pool (recommended) | ||
const result = await transaction(pool, async (client) => { | ||
const userResult = await client.query( | ||
'INSERT INTO users(name) VALUES($1) RETURNING id', | ||
['Alice'] | ||
) | ||
|
||
await client.query( | ||
'INSERT INTO photos(user_id, photo_url) VALUES ($1, $2)', | ||
[userResult.rows[0].id, 's3.bucket.foo'] | ||
) | ||
|
||
return userResult.rows[0] | ||
}) | ||
|
||
console.log('User created:', result) | ||
``` | ||
|
||
### API Reference | ||
|
||
#### `transaction(clientOrPool, callback)` | ||
|
||
**Parameters:** | ||
- `clientOrPool`: A `pg.Client` or `pg.Pool` instance | ||
- `callback`: An async function that receives a client and returns a promise | ||
|
||
**Returns:** A promise that resolves to the return value of the callback | ||
|
||
**Behavior:** | ||
- Automatically executes `BEGIN` before the callback | ||
- Executes `COMMIT` if the callback completes successfully | ||
- Executes `ROLLBACK` if the callback throws an error, then re-throws the error for you to handle | ||
- When using a Pool, automatically acquires and releases a client | ||
- When using a Client, uses the provided client directly. The client __must__ be connected already. | ||
|
||
### Usage Examples | ||
|
||
#### With Pool (Recommended) | ||
|
||
```js | ||
import { Pool } from 'pg' | ||
import { transaction } from 'pg-transaction' | ||
|
||
const pool = new Pool() | ||
|
||
try { | ||
const userId = await transaction(pool, async (client) => { | ||
// All queries within this callback are part of the same transaction | ||
const userResult = await client.query( | ||
'INSERT INTO users(name, email) VALUES($1, $2) RETURNING id', | ||
['John Doe', '[email protected]'] | ||
) | ||
|
||
const profileResult = await client.query( | ||
'INSERT INTO user_profiles(user_id, bio) VALUES($1, $2)', | ||
[userResult.rows[0].id, 'Software developer'] | ||
) | ||
|
||
// Return the user ID | ||
return userResult.rows[0].id | ||
}) | ||
|
||
console.log('Created user with ID:', userId) | ||
} catch (error) { | ||
console.error('Transaction failed:', error) | ||
// All changes have been automatically rolled back | ||
} | ||
``` | ||
|
||
#### With Client | ||
|
||
```js | ||
import { Client } from 'pg' | ||
import { transaction } from 'pg-transaction' | ||
|
||
const client = new Client() | ||
await client.connect() | ||
|
||
try { | ||
await transaction(client, async (client) => { | ||
await client.query('UPDATE accounts SET balance = balance - 100 WHERE id = $1', [1]) | ||
await client.query('UPDATE accounts SET balance = balance + 100 WHERE id = $1', [2]) | ||
}) | ||
console.log('Transfer completed successfully') | ||
} catch (error) { | ||
console.error('Transfer failed:', error) | ||
} finally { | ||
await client.end() | ||
} | ||
``` | ||
|
||
#### Binding for Reuse | ||
|
||
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. | ||
|
||
```js | ||
import { Pool } from 'pg' | ||
import { transaction } from 'pg-transaction' | ||
|
||
const pool = new Pool() | ||
const txn = transaction.bind(null, pool) | ||
|
||
// Now you can use txn directly | ||
await txn(async (client) => { | ||
await client.query('INSERT INTO logs(message) VALUES($1)', ['Operation 1']) | ||
}) | ||
|
||
await txn(async (client) => { | ||
await client.query('INSERT INTO logs(message) VALUES($1)', ['Operation 2']) | ||
}) | ||
``` | ||
|
||
#### Error Handling and Rollback | ||
|
||
The transaction function automatically handles rollbacks when errors occur: | ||
|
||
```js | ||
import { transaction } from 'pg-transaction' | ||
|
||
try { | ||
await transaction(pool, async (client) => { | ||
await client.query('INSERT INTO orders(user_id, total) VALUES($1, $2)', [userId, 100]) | ||
|
||
// This will cause the transaction to rollback | ||
if (Math.random() > 0.5) { | ||
throw new Error('Payment processing failed') | ||
} | ||
|
||
await client.query('UPDATE inventory SET quantity = quantity - 1 WHERE product_id = $1', [productId]) | ||
}) | ||
} catch (error) { | ||
// The transaction has been automatically rolled back | ||
console.error('Order creation failed:', error.message) | ||
} | ||
``` | ||
|
||
### Best Practices | ||
|
||
1. **Use with Pools**: When possible, use the transaction function with a `Pool` rather than a `Client` for better resource management. | ||
|
||
2. **Keep Transactions Short**: Minimize the time spent in transactions to reduce lock contention. | ||
|
||
3. **Handle Errors Appropriately**: Let the transaction function handle rollbacks, but ensure your application logic handles the errors appropriately. | ||
|
||
4. **Avoid Nested Transactions**: PostgreSQL doesn't support true nested transactions. Use savepoints if you need nested behavior. | ||
|
||
5. **Return Values**: Use the callback's return value to pass data out of the transaction. | ||
|
||
### Migration from Manual Transactions | ||
|
||
If you're currently using manual transaction handling, migrating to `pg-transaction` is straightforward: | ||
|
||
**Before (Manual):** | ||
```js | ||
const client = await pool.connect() | ||
try { | ||
await client.query('BEGIN') | ||
const result = await client.query('INSERT INTO users(name) VALUES($1) RETURNING id', ['Alice']) | ||
await client.query('INSERT INTO profiles(user_id) VALUES($1)', [result.rows[0].id]) | ||
await client.query('COMMIT') | ||
return result.rows[0] | ||
} catch (error) { | ||
await client.query('ROLLBACK') | ||
throw error | ||
} finally { | ||
client.release() | ||
} | ||
``` | ||
|
||
**After (pg-transaction):** | ||
```js | ||
return await transaction(pool, async (client) => { | ||
const result = await client.query('INSERT INTO users(name) VALUES($1) RETURNING id', ['Alice']) | ||
await client.query('INSERT INTO profiles(user_id) VALUES($1)', [result.rows[0].id]) | ||
return result.rows[0] | ||
}) | ||
``` | ||
|
||
## Manual Transaction Handling | ||
|
||
For cases where you need more control or prefer to handle transactions manually, you can execute `BEGIN`, `COMMIT`, and `ROLLBACK` queries directly. | ||
|
||
<Alert> | ||
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 / | |
the <span className="code">pool.query</span> method. | ||
</Alert> | ||
|
||
## Examples | ||
|
||
### Manual Transaction Example | ||
|
||
```js | ||
import { Pool } from 'pg' | ||
|
@@ -37,3 +244,49 @@ try { | |
client.release() | ||
} | ||
``` | ||
|
||
### When to Use Manual Transactions | ||
|
||
Consider manual transaction handling when you need: | ||
|
||
- **Savepoints**: Creating intermediate rollback points within a transaction | ||
- **Custom Transaction Isolation Levels**: Setting specific isolation levels | ||
- **Complex Transaction Logic**: Conditional commits or rollbacks based on business logic | ||
- **Performance Optimization**: Fine-grained control over transaction boundaries | ||
|
||
### Savepoints Example | ||
|
||
```js | ||
const client = await pool.connect() | ||
|
||
try { | ||
await client.query('BEGIN') | ||
|
||
// First operation | ||
await client.query('INSERT INTO orders(user_id, total) VALUES($1, $2)', [userId, total]) | ||
|
||
// Create a savepoint | ||
await client.query('SAVEPOINT order_items') | ||
|
||
try { | ||
// Attempt to insert order items | ||
for (const item of items) { | ||
await client.query('INSERT INTO order_items(order_id, product_id, quantity) VALUES($1, $2, $3)', | ||
[orderId, item.productId, item.quantity]) | ||
} | ||
} catch (error) { | ||
// Rollback to savepoint, keeping the order | ||
await client.query('ROLLBACK TO SAVEPOINT order_items') | ||
console.log('Order items failed, but order preserved') | ||
} | ||
|
||
await client.query('COMMIT') | ||
} catch (error) { | ||
await client.query('ROLLBACK') | ||
throw error | ||
} finally { | ||
client.release() | ||
} | ||
``` | ||
|
||
**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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.