Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion codemod/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Requires Node.js 18 or newer to run. The fork declares Node.js 22 or newer; olde
Every finding has a file and line, grouped by what to do about it:

- **Change before switching:** the project would break or behave differently. Examples: Node.js older than 22 in `engines`, `.nvmrc` or a Dockerfile; a `postgres@3.4.9` patch; a package your code imports that brings its own Postgres.js; `PGSSL=disable`, which 3.4.9 read as "require TLS" and the fork reads as "no TLS".
- **Review:** code whose behaviour changes in a way that may matter. Examples: a reserved handle used after `release()`; `fetch_types: false`, which now decodes arrays; code that checks for `CONNECTION_ENDED`; a CI job on Node.js older than 22.
- **Review:** code whose behaviour changes in a way that may matter. Examples: a reserved handle used after `release()`; `fetch_types: false`, which now decodes arrays; a CI job on Node.js older than 22.
- **Worth knowing:** changes that need no action, or workarounds the fork makes unnecessary. Examples: `prepare: false` behind a pooler; `Promise.race` timeouts; callbacks that throw, which now surface as uncaught exceptions.

`--json` prints the findings and the facts behind them (clients, ORMs, tracers, lockfiles) instead.
Expand Down
8 changes: 4 additions & 4 deletions codemod/src/changes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,13 @@ export const changes = {
level: 'review',
since: '4.0.0',
title: 'A reserved connection is used after release()',
advice: 'Queries on a released handle reject with CONNECTION_ENDED instead of running on whatever session the connection has next.'
advice: 'Queries on a released handle reject with CONNECTION_RELEASED (CONNECTION_ENDED before 4.7.0) instead of running on whatever session the connection has next.'
},
'transaction-handle-escapes': {
level: 'review',
since: '4.0.0',
title: 'A transaction handle may be used after sql.begin() returns',
advice: 'Statements on a finished transaction handle reject with CONNECTION_ENDED instead of running outside the transaction. Keep them inside the callback and await them.'
advice: 'Statements on a finished transaction handle reject with TRANSACTION_ENDED (CONNECTION_ENDED before 4.7.0) instead of running outside the transaction. Keep them inside the callback and await them.'
},
'transaction-prepare-name': {
level: 'review',
Expand Down Expand Up @@ -86,10 +86,10 @@ export const changes = {
advice: 'Postgres.js still logged them with console.log.'
},
'connection-ended-check': {
level: 'review',
level: 'info',
since: '4.0.0',
title: 'Code checks for CONNECTION_ENDED',
advice: 'On 3.4.9 this code meant sql.end() was called. The fork also raises it for a query on a reserved connection after release(), on a transaction handle after the transaction ended, and for work sent while COMMIT runs. Code that reads it as "the pool is shutting down", or retries it as transient, now also sees these programming errors.'
advice: 'On the fork this still means only that sql.end() was called, as on 3.4.9. A reserved connection used after release() rejects with CONNECTION_RELEASED and a transaction handle used after its transaction ended with TRANSACTION_ENDED. Those are bugs to fix, so keep them out of any retry set.'
},
'postgres-via-dependency': {
level: 'act',
Expand Down
16 changes: 12 additions & 4 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,7 @@ Use `sql.begin` to start a new transaction. Postgres.js will reserve a connectio

`BEGIN` is automatically sent with the optional options, such as `'read write'` or `'isolation level serializable'`; characters other than letters and spaces are removed from them. If the callback throws or a query in it fails, `ROLLBACK` is sent and `sql.begin` rejects with that error, including when the query failed before reaching the server, such as one with an `undefined` value. If the server answers the `COMMIT` with `ROLLBACK`, for example after a failed query that was not awaited or a caught savepoint failure, `sql.begin` rejects with [`TRANSACTION_ROLLED_BACK`](#transaction_rolled_back) and nothing is committed.

The scoped `sql` only works inside the callback. Statements on it after the transaction has finished, including statements sent while its `COMMIT` or `ROLLBACK` is in flight, reject with `CONNECTION_ENDED`, and statements after its connection closed reject with `CONNECTION_CLOSED`.
The scoped `sql` only works inside the callback. Statements on it after the transaction has finished, including statements sent while its `COMMIT` or `ROLLBACK` is in flight, reject with [`TRANSACTION_ENDED`](#transaction_ended), and statements after its connection closed reject with `CONNECTION_CLOSED` for as long as the callback is still running.

```js
const [user, account] = await sql.begin(async sql => {
Expand Down Expand Up @@ -1507,7 +1507,7 @@ using reserved = await sql.reserve()
await reserved`select * from users`
```

After release, queries through that reserved handle reject with [`CONNECTION_ENDED`](#connection_ended). Call `sql.reserve()` again to acquire a new handle. A reserved handle whose connection has closed rejects with `CONNECTION_CLOSED` and cannot be reused after reconnect.
After release, queries through that reserved handle reject with [`CONNECTION_RELEASED`](#connection_released). Call `sql.reserve()` again to acquire a new handle. A reserved handle whose connection has closed rejects with `CONNECTION_CLOSED` until it is released, and cannot be reused after reconnect.

## Error handling

Expand Down Expand Up @@ -1582,9 +1582,17 @@ This error is thrown if the connection was closed, or could not be opened, witho
### CONNECTION_ENDED
> write CONNECTION_ENDED host:port

This error is thrown if the user has called [`sql.end()`](#teardown--cleanup) and performed a query afterward.
This error is thrown if the user has called [`sql.end()`](#teardown--cleanup) and then starts a query, `sql.reserve()` or `sql.begin()`, or one of the last two was still waiting for a connection when `sql.end()` was called. A reserved handle or transaction handle that is already held is not affected: it keeps working until it is released or its transaction finishes. If `sql.end({ timeout })` closes its connection first, a statement in flight and `sql.begin()` reject with `CONNECTION_DESTROYED`, and statements sent afterwards reject with `CONNECTION_CLOSED`. Before 4.7.0 it was also raised for the handle misuse described under [`TRANSACTION_ENDED`](#transaction_ended) and [`CONNECTION_RELEASED`](#connection_released).

A reserved `sql` used after `release()`, or a transaction `sql` used after `begin()` has committed, rolled back or prepared, or while its `COMMIT` or `ROLLBACK` is in flight, also rejects with `CONNECTION_ENDED`.
### TRANSACTION_ENDED
> TRANSACTION_ENDED: Transaction handle used after its transaction ended

A statement was sent on a `sql.begin()` handle, or a savepoint handle in it, after the transaction had committed, rolled back or prepared, or while its `COMMIT`, `ROLLBACK` or `PREPARE TRANSACTION` is in flight. The statement is not sent. A savepoint whose callback was still running when the outer transaction ended rejects with it too, though no statement was sent. This also holds after the callback finished on a transaction whose connection had closed. This is a bug in the calling code, for example a handle that escaped the callback or a query that was not awaited, so fix it rather than retry it. Since 4.7.0; before, `CONNECTION_ENDED`, or `CONNECTION_CLOSED` when the connection had closed before the callback finished.

### CONNECTION_RELEASED
> CONNECTION_RELEASED: Reserved connection used after release()

A statement was sent on a [`sql.reserve()`](#reserving-connections) handle after `release()`, or after it was disposed. Statements sent before `release()` that were still waiting behind an earlier one reject with it as well. This holds even if the connection had closed before `release()`. The statement is not sent, because the connection may already belong to someone else. This is a bug in the calling code, so fix it rather than retry it; call `sql.reserve()` again for a new handle. Since 4.7.0; before, `CONNECTION_ENDED`, or `CONNECTION_CLOSED` when the connection had closed before `release()`.

### CONNECTION_DESTROYED
> write CONNECTION_DESTROYED host:port
Expand Down
44 changes: 18 additions & 26 deletions docs/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,37 +140,29 @@ An asynchronous result can arrive after the phase or attempt that started it is

## Leases

`reserve()` and `begin()` take a connection out of the pool through a lease. The lease is the only owner of that connection until it settles.
`reserve()` and `begin()` take a connection out of the pool through a lease. The lease's state says what the handle is doing now.

| State | Entered by | Meaning |
| State | The handle is | A statement on it |
|---|---|---|
| Active | `own(c, l)`, before `reserve()`/`begin()` resolve | The connection is owned; statements run, or wait in the lease's queue under backpressure. |
| Settling | `begin()` sending COMMIT, ROLLBACK or PREPARE TRANSACTION | Only that statement may run. |
| Released | `settle(l, Released)` | Terminal. The connection returns to the pool. |
| Discarded | `settle(l, Discarded)` | Terminal. The connection is terminated, so the server rolls back anything open. |
| Closed | `settle(l, Closed, err)` from the pool's `onclose` | Terminal. The connection is already gone; the lease only records why. |
| Active | in use | Runs, or waits in the lease's queue while the connection is `full` or not accepting; rejects with `COPY_IN_PROGRESS` while the connection's COPY stream is open |
| Closed | in use, but its connection closed | `CONNECTION_CLOSED` |
| Settling | a transaction finishing its COMMIT, ROLLBACK or PREPARE TRANSACTION; only that statement runs | `TRANSACTION_ENDED` |
| Ended | a transaction that is over | `TRANSACTION_ENDED` |
| Released | a reservation that has been released | `CONNECTION_RELEASED` |

`settle` does nothing for a lease that is already terminal. Otherwise it clears the connection's owner, rejects every statement still waiting in the lease's queue, and then releases the connection, terminates it, or records the error.
A reservation ends at `release()` or dispose. A transaction ends when its callback has finished, even if `begin()` already rejected because the connection closed. Statements still waiting in the lease's queue when it ends reject with its code, and so does a savepoint callback still running when its transaction ended.

Who decides the outcome:
`own(c, l)` makes a lease Active before `reserve()` or `begin()` resolve. `finish()` makes an Active transaction lease Settling. When a connection closes, the pool's `onclose` marks an Active lease Closed, records the error, clears the connection's owner, rejects the statements waiting in the lease's queue with `CONNECTION_CLOSED` and tells `begin()`, which rejects with that error: `CONNECTION_CLOSED` with the FATAL or socket error as `cause`, or `CONNECTION_DESTROYED` after a forced `end({ timeout })` with a statement in flight.

| Situation | Outcome |
|---|---|
| `sql.release()` on a reserved handle | Released |
| `begin()`: COMMIT, ROLLBACK or PREPARE TRANSACTION succeeded | Released |
| `begin()`: the server refused BEGIN with a `PostgresError` | Released; the connection is healthy |
| `begin()`: the settle statement failed | Discarded, and the error is rethrown |
| `begin()`: any other way out of the callback | Discarded (no effect if the lease is already terminal) |
| The connection closed while owned | Closed; `begin()` rejects with `CONNECTION_CLOSED`, whose `cause` is the FATAL or socket error |

What a statement sent on a lease handle gets:
Ending a lease goes through `stop(l, state)`, which sets Ended or Released, rejects the statements still waiting in its queue, and returns the connection only if the lease still holds it. The caller then returns the connection to the pool with `c.release()` or closes it with `c.terminate()`:

| Lease state | Result |
|---|---|
| Active | Runs, or waits in the lease's queue while the connection is `full` or not accepting; rejects with `COPY_IN_PROGRESS` while the connection's COPY stream is open |
| Settling | `CONNECTION_ENDED`, except the settle statement itself |
| Released, Discarded | `CONNECTION_ENDED` |
| Closed | `CONNECTION_CLOSED` |
| Situation | State | Connection |
|---|---|---|
| `sql.release()` or dispose on a reserved handle | Released | returned to the pool |
| `begin()`: COMMIT, ROLLBACK or PREPARE TRANSACTION succeeded | Ended | returned to the pool |
| `begin()`: the server refused BEGIN with a `PostgresError` | Ended | returned to the pool; the session is healthy |
| `begin()`: the settle statement failed | Ended | closed, and the error is rethrown |
| `begin()`: the callback finished in any other way | Ended | closed, so the server rolls back anything open |

A query records its owner when it is sent. `UNSAFE_TRANSACTION` is decided by that owner, so a raw `BEGIN` sent through the pool rejects even if a lease takes the same connection before it completes. A prepared statement is retried after a plan-invalidation error only under the same owner it was sent with.

Expand Down Expand Up @@ -201,7 +193,7 @@ The connection drives its own queue through four callbacks:
| `onopen(c, request)` | hand-off; a ReadyForQuery with nothing pipelined and no owner; a released lease; socket `drain` | hands `c` to a waiting reservation, files it in `open`, or runs the request and up to its share of queued queries and files it `busy` or `full` |
| `onend(c)` | the connection starts ending | files it in `ended` |
| `ondrain(c)` | a Draining connection has gone idle | if the pool is ending, runs queued plain queries on it (rejecting queued reservations with `CONNECTION_ENDED`) and keeps it Draining; otherwise lets it close |
| `onclose(c, err)` | `enterClosed` | files it in `closed`, settles its lease as Closed, and either starts the next queued request on it or, while ending, handles the queue as described below |
| `onclose(c, err)` | `enterClosed` | files it in `closed`, marks its lease Closed, and either starts the next queued request on it or, while ending, handles the queue as described below |

**User callbacks.** Filing a connection in `open` starts its idle timer and calls the user's `onidle(c.id)`. The internal listen and subscribe pools do not inherit `onidle`. These user callbacks (`onidle`, `onclose`, `onnotice`, `onparameter`, `onnotify`, `debug`, and the `sql.listen()` and `sql.subscribe()` callbacks and `onlisten`, `onsubscribe` and `onerror`) go through the `callback` helper in `src/errors.js`, which calls them synchronously and rethrows anything they throw in a microtask, so a user error cannot reach the phase table. A transform or type parser that throws while `src/subscribe.js` decodes a change is caught there: the change is skipped and the error goes to the matching subscribers' `onerror`, so it does not reach `protocolError`, unless the change's relation is unknown or the message is not an insert, update or delete, in which case the stream restarts as before. The error is rethrown as an uncaught exception when a matching subscription has no `onerror`.

Expand Down
2 changes: 1 addition & 1 deletion docs/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ This covers Sentry's runtime hook, not its bundler plugins. With pnpm, add `@sen
What changes for code written against Postgres.js 3.4 or `@steadybrew/postgresjs` 3.x. Version 4.0.0 holds the breaking changes; the later items are behaviour changes in 4.x releases.

- **Node.js compatibility.** Tested on Node.js 22, 24 and 26. Older versions may work but aren't tested; the package declares `engines.node: >=22`, so package managers may warn or reject installation on older versions. With TypeScript, the declarations need `@types/node` 22 or newer, or `skipLibCheck`.
- **Handles stay with their owner.** A reserved handle used after `release()`, and a transaction handle used after its transaction finished or while its `COMMIT` is in flight, rejects with `CONNECTION_ENDED`. A handle whose connection closed rejects with `CONNECTION_CLOSED` instead of running on a reconnected session.
- **Handles stay with their owner.** A reserved handle used after `release()`, and a transaction handle used after its transaction finished or while its `COMMIT` is in flight, rejects with `CONNECTION_RELEASED` for a reservation and `TRANSACTION_ENDED` for a transaction. 4.0 to 4.6 used `CONNECTION_ENDED` for both, so if you retry on `CONNECTION_ENDED`, these now fall outside that set, which is right: they are bugs to fix, not to retry. `CONNECTION_ENDED` now means only that `sql.end()` was called. Since 4.7.0. A handle whose connection closed rejects with `CONNECTION_CLOSED` instead of running on a reconnected session, until its reservation is released or its transaction callback has finished; 4.0 to 4.6 kept `CONNECTION_CLOSED` after that, and 4.7.0 gives the codes above.
- **`sql.prepare(name)`** in a transaction takes a non-empty string without NUL characters; other names throw `INVALID_TRANSACTION_NAME`.
- **`fetch_types: false`** now decodes built-in arrays as JavaScript arrays instead of raw strings. SQL `NULL` elements become `null`; quoted `"NULL"` text remains a string.
- **Callbacks that throw** (`onnotice`, `onparameter`, `onnotify`, `onclose`, `onidle`, `debug`, `backoff`, and the `sql.listen()` and `sql.subscribe()` callbacks) surface as uncaught exceptions, which end the process under Node's default handling. Since 4.4.0.
Expand Down
Loading
Loading