Skip to content

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
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 255 additions & 2 deletions docs/pages/features/transactions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand All @@ -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.
22 changes: 22 additions & 0 deletions packages/pg-transaction/package.json
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"
}
}
Loading