Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ dist
.vscode/
manually-test-on-heroku.js
tsconfig.tsbuildinfo
.history
5 changes: 3 additions & 2 deletions docs/pages/apis/client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ Every field of the `config` object is entirely optional. A `Client` instance wil
type Config = {
user?: string, // default process.env.PGUSER || process.env.USER
password?: string or function, //default process.env.PGPASSWORD
host?: string, // default process.env.PGHOST
port?: number, // default process.env.PGPORT
host?: string | string[], // default process.env.PGHOST; array enables multi-host failover
port?: number | number[], // default process.env.PGPORT; one value or one per host
database?: string, // default process.env.PGDATABASE || user
connectionString?: string, // e.g. postgres://user:password@host:5432/database
ssl?: any, // passed directly to node.TLSSocket, supports all tls.connect options
Expand All @@ -31,6 +31,7 @@ type Config = {
fallback_application_name?: string, // provide an application name to use if application_name is not set
options?: string, // command-line options to be sent to the server
pipeline?: boolean // when true, enables query pipelining. See /features/pipelining for details. Default false.
targetSessionAttrs?: 'any' | 'read-write' | 'read-only' | 'primary' | 'standby' | 'prefer-standby', // default 'any'
}
```

Expand Down
74 changes: 74 additions & 0 deletions docs/pages/features/connecting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,80 @@ client = new Client({
})
```

## Multiple hosts

The JavaScript driver supports connecting to multiple PostgreSQL hosts. Pass an array to `host` to try each host in order. `port` can be a single port or an array of ports.

```js
import { Client } from 'pg'

const client = new Client({
host: ['primary.db.com', 'replica1.db.com', 'replica2.db.com'],
port: 5432, // single port reused for all hosts
database: 'mydb',
user: 'dbuser',
password: 'secretpassword',
})

await client.connect() // tries hosts left to right until one succeeds
```

Connection errors before TCP connects advance to the next host. Authentication and TLS errors stop the attempt. Once connected, the client stays on the selected host; it does not reconnect automatically if that connection is lost. `client.host` and `client.port` identify the selected endpoint.

`connectionTimeoutMillis` limits the whole connection attempt, including all hosts and session checks. If you provide a custom `stream`, use a factory function so each attempt gets a fresh stream.

You can also specify a different port for each host:

```js
const client = new Client({
host: ['host-a.db.com', 'host-b.db.com'],
port: [5432, 5433],
database: 'mydb',
})
```

Host lists may mix TCP hosts and Unix socket directories. Each entry is interpreted independently:

```js
const client = new Client({
host: ['/var/run/postgresql', 'db.example.com'],
port: 5432,
database: 'mydb',
})
```

For an absolute host path, the port is used as the Unix socket filename extension (`.s.PGSQL.5432`). Other host values use TCP.

Port rules (same as libpq):
- **one port** — reused for every host
- **one port per host** — each port is paired with the corresponding host by index
- any other combination throws at construction time

### target_session_attrs

Use `targetSessionAttrs` to control which host is accepted based on its role. This mirrors the [libpq `target_session_attrs`](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-TARGET-SESSION-ATTRS) option.

```js
const client = new Client({
host: ['primary.db.com', 'replica.db.com'],
port: 5432,
targetSessionAttrs: 'read-write', // only connect to a writable primary
})
```

| Value | Accepted server |
|---|---|
| `any` (default) | any server |
| `read-write` | server where `transaction_read_only = off` |
| `read-only` | server where `transaction_read_only = on` |
| `primary` | server that is not in hot standby |
| `standby` | server that is in hot standby |
| `prefer-standby` | standby if available, otherwise any |

When all hosts are exhausted without finding a matching server, the client emits an error.

Hosts that fail the session check are skipped. `prefer-standby` first tries every host for a standby, then retries from the beginning accepting any server if none matched.

## Connection URI

You can initialize both a pool and a client with a connection string URI as well. This is common in environments like Heroku where the database connection string is supplied to your application dyno through an environment variable. Connection string parsing brought to you by [pg-connection-string](https://github.com/brianc/node-postgres/tree/master/packages/pg-connection-string).
Expand Down
134 changes: 82 additions & 52 deletions packages/pg/lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const ConnectionParameters = require('./connection-parameters')
const Query = require('./query')
const defaults = require('./defaults')
const Connection = require('./connection')
const connectMultiHost = require('./multihost')
const crypto = require('./crypto/utils')

const activeQueryDeprecationNotice = nodeUtils.deprecate(
Expand Down Expand Up @@ -86,16 +87,22 @@ class Client extends EventEmitter {

this.enableChannelBinding = Boolean(c.enableChannelBinding) // set true to use SCRAM-SHA-256-PLUS when offered
this.scramMaxIterations = coerceNumberOrDefault(c.scramMaxIterations, sasl.DEFAULT_MAX_SCRAM_ITERATIONS)
this.connection =
c.connection ||
new Connection({
stream: c.stream,
ssl: this.connectionParameters.ssl,
sslNegotiation: this.connectionParameters.sslnegotiation,
keepAlive: c.keepAlive || false,
keepAliveInitialDelayMillis: c.keepAliveInitialDelayMillis || 0,
encoding: this.connectionParameters.client_encoding || 'utf8',
})
const targetSessionAttrs = this.connectionParameters.targetSessionAttrs
const connectionConfig = {
stream: c.stream,
ssl: this.connectionParameters.ssl,
sslNegotiation: this.connectionParameters.sslnegotiation,
keepAlive: c.keepAlive || false,
keepAliveInitialDelayMillis: c.keepAliveInitialDelayMillis || 0,
encoding: this.connectionParameters.client_encoding || 'utf8',
}
const needsMultiHost =
Array.isArray(this.host) ||
Array.isArray(this.port) ||
Boolean(targetSessionAttrs && targetSessionAttrs !== 'any')

this.connection = c.connection || new Connection(connectionConfig)
this._multiHostConfig = !c.connection && needsMultiHost ? connectionConfig : null
this._queryQueue = []
this._sentQueryQueue = []
this.pipeline = Boolean(c.pipeline)
Expand Down Expand Up @@ -151,8 +158,6 @@ class Client extends EventEmitter {
}

_connect(callback) {
const self = this
const con = this.connection
this._connectionCallback = callback

if (this._connecting || this._connected) {
Expand All @@ -166,19 +171,42 @@ class Client extends EventEmitter {

if (this._connectionTimeoutMillis > 0) {
this.connectionTimeoutHandle = setTimeout(() => {
con._ending = true
con.stream.destroy(new Error('timeout expired'))
this.connection._ending = true
this.connection.stream.destroy(new Error('timeout expired'))
}, this._connectionTimeoutMillis)

if (this.connectionTimeoutHandle.unref) {
this.connectionTimeoutHandle.unref()
}
}

if (this.host && this.host.indexOf('/') === 0) {
con.connect(this.host + '/.s.PGSQL.' + this.port)
if (this._multiHostConfig) {
connectMultiHost(this, this._multiHostConfig).then(
(message) => {
if (!this._connectionError && !this._ended) {
this._handleReadyForQuery(message)
}
},
(err) => {
this._handleErrorWhileConnecting(err)
this._handleConnectionEnd()
}
)
return
}

this._connectHost(this.port, this.host)
this._attachListeners(this.connection)
}

_connectHost(port, host) {
const self = this
const con = this.connection

if (typeof host === 'string' && host.startsWith('/')) {
con.connect(host.replace(/\/+$/, '') + '/.s.PGSQL.' + port)
} else {
con.connect(this.port, this.host)
con.connect(port, host)
}

// once connection is established send startup message
Expand All @@ -198,34 +226,43 @@ class Client extends EventEmitter {
con.startup(self.getStartupConf())
})

this._attachListeners(con)
// password request handling
con.on('authenticationCleartextPassword', this._handleAuthCleartextPassword.bind(this))
// password request handling
con.on('authenticationMD5Password', this._handleAuthMD5Password.bind(this))
// password request handling (SASL)
con.on('authenticationSASL', this._handleAuthSASL.bind(this))
con.on('authenticationSASLContinue', this._handleAuthSASLContinue.bind(this))
con.on('authenticationSASLFinal', this._handleAuthSASLFinal.bind(this))
con.on('backendKeyData', this._handleBackendKeyData.bind(this))
con.on('notice', this._handleNotice.bind(this))
}

con.once('end', () => {
const error = this._ending ? new Error('Connection terminated') : new Error('Connection terminated unexpectedly')
_handleConnectionEnd() {
const error = this._ending ? new Error('Connection terminated') : new Error('Connection terminated unexpectedly')

clearTimeout(this.connectionTimeoutHandle)
this._errorAllQueries(error)
this._ended = true

if (!this._ending) {
// if the connection is ended without us calling .end()
// on this client then we have an unexpected disconnection
// treat this as an error unless we've already emitted an error
// during connection.
if (this._connecting && !this._connectionError) {
if (this._connectionCallback) {
this._connectionCallback(error)
} else {
this._handleErrorEvent(error)
}
} else if (!this._connectionError) {
clearTimeout(this.connectionTimeoutHandle)
this._errorAllQueries(error)
this._ended = true

if (!this._ending) {
// if the connection is ended without us calling .end()
// on this client then we have an unexpected disconnection
// treat this as an error unless we've already emitted an error
// during connection.
if (this._connecting && !this._connectionError) {
if (this._connectionCallback) {
this._connectionCallback(error)
} else {
this._handleErrorEvent(error)
}
} else if (!this._connectionError) {
this._handleErrorEvent(error)
}
}

process.nextTick(() => {
this.emit('end')
})
process.nextTick(() => {
this.emit('end')
})
}

Expand All @@ -247,19 +284,10 @@ class Client extends EventEmitter {
}

_attachListeners(con) {
// password request handling
con.on('authenticationCleartextPassword', this._handleAuthCleartextPassword.bind(this))
// password request handling
con.on('authenticationMD5Password', this._handleAuthMD5Password.bind(this))
// password request handling (SASL)
con.on('authenticationSASL', this._handleAuthSASL.bind(this))
con.on('authenticationSASLContinue', this._handleAuthSASLContinue.bind(this))
con.on('authenticationSASLFinal', this._handleAuthSASLFinal.bind(this))
con.on('backendKeyData', this._handleBackendKeyData.bind(this))
con.once('end', this._handleConnectionEnd.bind(this))
con.on('error', this._handleErrorEvent.bind(this))
con.on('errorMessage', this._handleErrorMessage.bind(this))
con.on('readyForQuery', this._handleReadyForQuery.bind(this))
con.on('notice', this._handleNotice.bind(this))
con.on('rowDescription', this._handleRowDescription.bind(this))
con.on('dataRow', this._handleDataRow.bind(this))
con.on('portalSuspended', this._handlePortalSuspended.bind(this))
Expand Down Expand Up @@ -574,11 +602,13 @@ class Client extends EventEmitter {
cancel(client, query) {
if (client.activeQuery === query) {
const con = this.connection
const host = client._multiHostConfig ? client.host : this.host
const port = client._multiHostConfig ? client.port : this.port

if (this.host && this.host.indexOf('/') === 0) {
con.connect(this.host + '/.s.PGSQL.' + this.port)
if (typeof host === 'string' && host.startsWith('/')) {
con.connect(host.replace(/\/+$/, '') + '/.s.PGSQL.' + port)
} else {
con.connect(this.port, this.host)
con.connect(port, host)
}

// once connection is established send cancel message
Expand Down
23 changes: 22 additions & 1 deletion packages/pg/lib/connection-parameters.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,19 @@ class ConnectionParameters {
this.database = this.user
}

this.port = parseInt(val('port', config), 10)
const rawPort = val('port', config)
this.port = Array.isArray(rawPort) ? rawPort.map((p) => parseInt(p, 10)) : parseInt(rawPort, 10)
this.host = val('host', config)

const hosts = Array.isArray(this.host) ? this.host : [this.host]
const ports = Array.isArray(this.port) ? this.port : [this.port]
if (hosts.length === 0) {
throw new Error('host must contain at least one entry')
}
if (ports.length !== 1 && ports.length !== hosts.length) {
throw new Error(`ports must have either 1 entry or the same number of entries as hosts (${hosts.length})`)
}

// "hiding" the password so it doesn't show up in stack traces
// or if the client is console.logged
Object.defineProperty(this, 'password', {
Expand Down Expand Up @@ -123,6 +133,17 @@ class ConnectionParameters {
this.idle_in_transaction_session_timeout = val('idle_in_transaction_session_timeout', config, false)
this.query_timeout = val('query_timeout', config, false)

this.targetSessionAttrs = val('targetSessionAttrs', config)

const validTargetSessionAttrs = ['any', 'read-write', 'read-only', 'primary', 'standby', 'prefer-standby']
if (this.targetSessionAttrs && !validTargetSessionAttrs.includes(this.targetSessionAttrs)) {
throw new Error(
`invalid targetSessionAttrs value: "${this.targetSessionAttrs}". Must be one of: ${validTargetSessionAttrs.join(
', '
)}`
)
}

if (config.connectionTimeoutMillis === undefined) {
this.connect_timeout = process.env.PGCONNECT_TIMEOUT || 0
} else {
Expand Down
Loading
Loading