diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index f3d2b1e..5dfb61d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -73,7 +73,13 @@ jobs:
echo "----- Installing PostgreSQL ${{ matrix.postgres }} -----"
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
- sudo apt-get update
+ # The runner image ships a Google Chrome apt source we never use here;
+ # when its upstream Release/Packages files briefly disagree (a known,
+ # intermittent issue on their end), `apt-get update` fails outright and
+ # takes the postgresql-org repo update down with it. Drop it first so a
+ # repo we don't need can't block one we do.
+ sudo rm -f /etc/apt/sources.list.d/google-chrome.list
+ sudo apt-get update
sudo apt-get -y install postgresql-${{ matrix.postgres }} postgresql-client-${{ matrix.postgres }}
echo "----- Configuring -----"
sudo cp ./test/_support/pg_hba.conf /etc/postgresql/${{ matrix.postgres }}/main/pg_hba.conf
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 43e95ec..f8b6490 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,17 @@
## Changelog
-### [v3.0.4](https://github.com/panates/postgrejs/compare/v3.0.3...v3.0.4) -
+### [v3.1.0](https://github.com/panates/postgrejs/compare/v3.0.4...v3.1.0) -
+
+#### π New Features
+
+- feat: implement the legacy Function Call sub-protocol ('F'/'V') @Eray HanoΔlu
+- feat: support protocol 3.2's longer cancellation key (PostgreSQL 18+) @Eray HanoΔlu
+
+#### πͺ² Fixes
+
+- fix: correctly parse NegotiateProtocolVersion and expose it on Connection @Eray HanoΔlu
+
+### [v3.0.4](https://github.com/panates/postgrejs/compare/v3.0.3...v3.0.4) - 9 September 2026
### [v3.0.3](https://github.com/panates/postgrejs/compare/v3.0.2...v3.0.3) - 8 September 2026
diff --git a/README.md b/README.md
index 0912526..eb4ae4c 100644
--- a/README.md
+++ b/README.md
@@ -100,12 +100,18 @@ usage.
- **Resource Management:** Auto disposal of resources with the "using" syntax
([TC39 Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management)), ensuring
efficient resource cleanup.
+- **Long Cancel Key:** Opt in to protocol 3.2 (PostgreSQL 18+) with `longCancelKey`, so a `cancel()` in progress can't
+ be forged by an attacker guessing a short secret key.
+- **Legacy Function Call Protocol:** `callFunction()` calls a function by OID directly over the wire, bypassing SQL
+ entirely - kept for protocol completeness even though `SELECT func(...)` covers the same ground.
+- **Graceful Protocol Renegotiation:** A server that doesn't recognize a requested protocol version or startup option
+ reports back instead of erroring out, surfaced on `Connection.protocolNegotiation`.
## Feature Comparison
How PostgreJS compares to [`pg`](https://github.com/brianc/node-postgres) (node-postgres) and
[`postgres`](https://github.com/porsager/postgres) (postgres.js). Every row was checked against the libraries' own
-source rather than their documentation β versions compared: **PostgreJS 2.23.1, pg 8.23.0, postgres.js 3.4.9**. β
built
+source rather than their documentation β versions compared: **PostgreJS 3.1.0, pg 8.23.0, postgres.js 3.4.9**. β
built
in Β· π‘ partial or needs a separate package Β· β not supported.
| Feature | PostgreJS | pg | postgres.js |
@@ -115,11 +121,15 @@ in Β· π‘ partial or needs a separate package Β· β not supported.
| Module system | ESM | ESM/CJS | ESM/CJS |
| Language | TS | JS 2 | JS 3 |
| ***Wire protocol*** | | | |
+| Protocol version | 3.2 | 3.0 | 3.0 |
| Simple Query protocol | β
| β
| β
|
| Extended Query protocol | β
| β
| β
|
| Text wire format | β
| β
| β
|
| Binary wire format | β
| π‘ 4 | β 5 |
| Per-column format selection | β
| β | β |
+| Long cancel key (opt-in) | β
| β | β |
+| Legacy Function Call protocol | β
| β | β |
+| Graceful protocol renegotiation | β
| β | β |
| ***High-level API*** | | | |
| Object and array row modes | β
| β
| β
|
| Dynamic SQL helpers | β
`sql` tag | β | β
|
diff --git a/package-lock.json b/package-lock.json
index b93006d..4800415 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "postgrejs",
- "version": "3.0.4",
+ "version": "3.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "postgrejs",
- "version": "3.0.4",
+ "version": "3.1.0",
"license": "BSD-3-Clause",
"dependencies": {
"@jsopen/objects": "^2.2.3",
diff --git a/package.json b/package.json
index e542452..6fc839e 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "postgrejs",
"description": "Professional PostgreSQL client NodeJS",
- "version": "3.0.4",
+ "version": "3.1.0",
"author": "Panates",
"license": "BSD-3-Clause",
"private": true,
diff --git a/src/connection/connection.ts b/src/connection/connection.ts
index 62917aa..54f6b18 100644
--- a/src/connection/connection.ts
+++ b/src/connection/connection.ts
@@ -1,6 +1,8 @@
import { ConnectionState, DataTypeOIDs } from '../constants.js';
import { GlobalTypeMap } from '../data-type-map.js';
import type { ConnectionConfiguration } from '../interfaces/database-connection-params.js';
+import type { FunctionCallOptions } from '../interfaces/function-call-options.js';
+import type { FunctionCallResult } from '../interfaces/function-call-result.js';
import type { QueryOptions } from '../interfaces/query-options.js';
import type { QueryResult } from '../interfaces/query-result.js';
import type { ScriptExecuteOptions } from '../interfaces/script-execute-options.js';
@@ -82,10 +84,23 @@ export class Connection extends SafeEventEmitter implements AsyncDisposable {
return this._intlCon.sessionParameters;
}
+ /**
+ * The server's NegotiateProtocolVersion reply, if it sent one during
+ * connect() - undefined means the server fully recognized everything
+ * this client's startup packet asked for (protocol minor version, any
+ * `_pq_.*` options). Present only when the server is older/stricter
+ * than what was requested, or didn't recognize one of the options -
+ * check it after connect() if a feature gated behind such an option
+ * doesn't seem to have taken effect.
+ */
+ get protocolNegotiation(): Maybe {
+ return this._intlCon.protocolNegotiation;
+ }
+
/**
* Returns the secret key of the current session
*/
- get secretKey(): Maybe {
+ get secretKey(): Maybe {
return this._intlCon.secretKey;
}
@@ -193,6 +208,36 @@ export class Connection extends SafeEventEmitter implements AsyncDisposable {
);
}
+ /**
+ * The legacy Function Call sub-protocol - calls a function by OID
+ * directly, bypassing SQL entirely. Superseded by `SELECT func(...)`
+ * over the Simple/Extended Query protocols (what `execute()`/`query()`
+ * use, and what every current PostgreSQL client uses exclusively) -
+ * kept only for wire-protocol completeness. Arguments and the result
+ * travel as raw wire-format bytes, not JS values: the caller is
+ * responsible for encoding/decoding them (see a `DataType`'s own
+ * `encodeBinary`/`decodeBinary` for the format a given OID expects).
+ *
+ * @param functionId - OID of the function to call
+ * @param args - Each argument's own already-encoded wire bytes, or
+ * `null` for SQL NULL
+ */
+ callFunction(
+ functionId: OID,
+ args: Maybe[],
+ options?: FunctionCallOptions & { signal?: AbortSignal },
+ ): Promise {
+ return withAbortSignal(
+ options?.signal,
+ () => this._intlCon.cancel(),
+ () =>
+ this._captureErrorStack(
+ this._intlCon.callFunction(functionId, args, options),
+ this.callFunction,
+ ),
+ );
+ }
+
async query(
sql: string | QueryRequest,
options?: QueryOptions,
diff --git a/src/connection/intl-connection.ts b/src/connection/intl-connection.ts
index c6243b4..1770684 100644
--- a/src/connection/intl-connection.ts
+++ b/src/connection/intl-connection.ts
@@ -5,6 +5,8 @@ import { GlobalTypeMap } from '../data-type-map.js';
import type { CommandResult } from '../interfaces/command-result.js';
import type { ConnectionConfiguration } from '../interfaces/database-connection-params.js';
import type { FieldInfo } from '../interfaces/field-info.js';
+import type { FunctionCallOptions } from '../interfaces/function-call-options.js';
+import type { FunctionCallResult } from '../interfaces/function-call-result.js';
import type { QueryOptions } from '../interfaces/query-options.js';
import type { QueryResult } from '../interfaces/query-result.js';
import type { ScriptExecuteOptions } from '../interfaces/script-execute-options.js';
@@ -91,10 +93,15 @@ export class IntlConnection extends SafeEventEmitter {
return this.socket.processID;
}
- get secretKey(): Maybe {
+ get secretKey(): Maybe {
return this.socket.secretKey;
}
+ /** See `PgSocket.protocolNegotiation`. */
+ get protocolNegotiation(): Maybe {
+ return this.socket.protocolNegotiation;
+ }
+
get sessionParameters(): Record {
return this.socket.sessionParameters;
}
@@ -195,6 +202,60 @@ export class IntlConnection extends SafeEventEmitter {
}
}
+ /**
+ * The legacy Function Call sub-protocol: calls a function by OID
+ * directly, bypassing SQL entirely. PostgreSQL itself calls this
+ * superseded by `SELECT func(...)` over the Simple/Extended Query
+ * protocols (what this driver uses everywhere else, and what every
+ * other current client uses exclusively) - kept only for wire-protocol
+ * completeness. Arguments and the result travel as raw wire-format
+ * bytes rather than through this driver's usual typed encode/decode
+ * pipeline - the caller is responsible for both (see a `DataType`'s own
+ * `encodeBinary`/`decodeBinary` for the format a given OID expects).
+ */
+ async callFunction(
+ functionId: OID,
+ args: Maybe[],
+ options: FunctionCallOptions = {},
+ ): Promise {
+ this.assertConnected();
+ this.ref();
+ try {
+ let result: FunctionCallResult | undefined;
+ let error: Error | undefined;
+ return await this.socket.sendFunctionCallMessage(
+ {
+ functionId,
+ args,
+ argFormats: options.argFormats,
+ resultFormat: options.resultFormat,
+ },
+ (code, msg, done) => {
+ switch (code) {
+ case Protocol.BackendMessageCode.ErrorResponse:
+ error = msg;
+ break;
+ case Protocol.BackendMessageCode.FunctionCallResponse:
+ result = msg as FunctionCallResult;
+ break;
+ case Protocol.BackendMessageCode.ReadyForQuery:
+ this.transactionStatus = msg.status;
+ if (error) {
+ done(error);
+ break;
+ }
+ done(undefined, result);
+ break;
+ default:
+ break;
+ }
+ },
+ );
+ } finally {
+ this.unref();
+ }
+ }
+
/**
* Starts a transaction, or - if one is already running - marks a nested
* level of it. Each call increments `_transactionDepth`; only the
diff --git a/src/index.ts b/src/index.ts
index 38eedd5..85d6b36 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -13,6 +13,8 @@ export * from './interfaces/data-mapping-options.js';
export * from './interfaces/data-type.js';
export * from './interfaces/database-connection-params.js';
export * from './interfaces/field-info.js';
+export * from './interfaces/function-call-options.js';
+export * from './interfaces/function-call-result.js';
export * from './interfaces/query-options.js';
export * from './interfaces/query-result.js';
export * from './interfaces/script-execute-options.js';
diff --git a/src/interfaces/database-connection-params.ts b/src/interfaces/database-connection-params.ts
index 7e6a8bd..d292bee 100644
--- a/src/interfaces/database-connection-params.ts
+++ b/src/interfaces/database-connection-params.ts
@@ -59,6 +59,18 @@ export interface DatabaseConnectionParams {
* usual case with `sslmode=require` or a self-signed certificate.
*/
channelBinding?: 'prefer' | 'require' | 'disable';
+ /**
+ * Requests protocol 3.2 (PostgreSQL 18+) instead of 3.0, so a
+ * `cancel()` in progress on this connection can't be forged by an
+ * attacker guessing a 4-byte secret key - 3.2 lets the server hand out
+ * one up to 256 bytes instead. Off by default: an older server simply
+ * reports back that it doesn't support 3.2 (see
+ * `Connection.protocolNegotiation`) and the session proceeds at 3.0
+ * exactly as if this had never been set, so turning it on is never a
+ * connection-breaking choice - just not something every server rewards
+ * yet.
+ */
+ longCancelKey?: boolean;
requireSSL?: boolean;
ssl?: TlsConnectionOptions;
timezone?: string;
diff --git a/src/interfaces/function-call-options.ts b/src/interfaces/function-call-options.ts
new file mode 100644
index 0000000..ec2f5d1
--- /dev/null
+++ b/src/interfaces/function-call-options.ts
@@ -0,0 +1,15 @@
+import type { DataFormat } from '../constants.js';
+
+export interface FunctionCallOptions {
+ /**
+ * Format of each argument, applied positionally - omit for text (the
+ * default) applied to every argument, a single entry to apply it to all
+ * of them, or one entry per argument.
+ */
+ argFormats?: DataFormat[];
+ /**
+ * Format the function's return value comes back in.
+ * @default DataFormat.text
+ */
+ resultFormat?: DataFormat;
+}
diff --git a/src/interfaces/function-call-result.ts b/src/interfaces/function-call-result.ts
new file mode 100644
index 0000000..c2a8865
--- /dev/null
+++ b/src/interfaces/function-call-result.ts
@@ -0,0 +1,7 @@
+export interface FunctionCallResult {
+ /**
+ * The function's return value, in the wire format requested via
+ * `FunctionCallOptions.resultFormat` - `null` if it returned SQL NULL.
+ */
+ result: Buffer | null;
+}
diff --git a/src/protocol/backend.ts b/src/protocol/backend.ts
index 79d4ea4..b4460d5 100644
--- a/src/protocol/backend.ts
+++ b/src/protocol/backend.ts
@@ -197,10 +197,13 @@ function parseAuthentication(
}
function parseBackendKeyData(io: BufferReader): Protocol.BackendKeyDataMessage {
- return {
- processID: io.readUInt32BE(),
- secretKey: io.readUInt32BE(),
- } as Protocol.BackendKeyDataMessage;
+ const processID = io.readUInt32BE();
+ // No length prefix of its own: this BufferReader is scoped to exactly
+ // this message's body (see Backend.parse()), so the secret key is
+ // simply whatever bytes remain - 4 of them before protocol 3.2, up to
+ // 256 with it (see VERSION_MINOR_LONG_CANCEL_KEY).
+ const secretKey = io.readBuffer();
+ return { processID, secretKey };
}
function parseCommandComplete(
@@ -279,22 +282,23 @@ function parseNotificationResponse(
function parseFunctionCallResponse(
io: BufferReader,
- code: Protocol.BackendMessageCode,
- len: number,
): Protocol.FunctionCallResponseMessage {
- return {
- result: io.readBuffer(len - 4),
- } as Protocol.FunctionCallResponseMessage;
+ // A length prefix ahead of the value itself (-1 for SQL NULL, with no
+ // bytes following) - not deducible from the outer message length alone,
+ // unlike most fixed-shape messages this codebase parses.
+ const len = io.readInt32BE();
+ return { result: len < 0 ? null : io.readBuffer(len) };
}
function parseNegotiateProtocolVersion(
io: BufferReader,
): Protocol.NegotiateProtocolVersionMessage {
- return {
- supportedVersionMinor: io.readUInt32BE(),
- numberOfNotSupportedVersions: io.readUInt32BE(),
- option: io.readCString('utf8'),
- } as Protocol.NegotiateProtocolVersionMessage;
+ const supportedVersionMinor = io.readUInt32BE();
+ const count = io.readUInt32BE();
+ const unrecognizedOptions: string[] = new Array(count);
+ let i: number;
+ for (i = 0; i < count; i++) unrecognizedOptions[i] = io.readCString('utf8');
+ return { supportedVersionMinor, unrecognizedOptions };
}
function parseParameterDescription(
diff --git a/src/protocol/frontend.ts b/src/protocol/frontend.ts
index 9b0f050..d54485e 100644
--- a/src/protocol/frontend.ts
+++ b/src/protocol/frontend.ts
@@ -79,6 +79,30 @@ export namespace Frontend {
type: 'P' | 'S';
name?: string;
}
+
+ /**
+ * The legacy Function Call sub-protocol - calls a function by OID
+ * directly, bypassing SQL entirely. Superseded by `SELECT func(...)`
+ * over the Simple/Extended Query protocols (which every current
+ * PostgreSQL client, this one included for everything else, uses
+ * exclusively) - kept only for wire-protocol completeness. Arguments
+ * and the result travel as raw wire-format bytes, not JS values: the
+ * caller is responsible for encoding/decoding them (see a `DataType`'s
+ * own `encodeBinary`/`decodeBinary` for the format a given OID expects).
+ */
+ export interface FunctionCallMessageArgs {
+ functionId: OID;
+ /**
+ * Format of each argument in `args`, applied positionally - empty
+ * means text for all of them, a single entry applies to all, or it
+ * must have exactly one entry per argument.
+ */
+ argFormats?: Protocol.DataFormat[];
+ /** `null` for an SQL NULL argument. */
+ args: Maybe[];
+ /** Format the function's return value comes back in. @default text */
+ resultFormat?: Protocol.DataFormat;
+ }
}
export class Frontend {
@@ -97,12 +121,15 @@ export class Frontend {
.flush();
}
- getStartupMessage(args: Frontend.StartupMessageArgs): Buffer {
+ getStartupMessage(
+ args: Frontend.StartupMessageArgs,
+ minorVersion: number = Protocol.VERSION_MINOR,
+ ): Buffer {
const io = this._io
.start()
.writeInt32BE(0) // Preserve length
.writeInt16BE(Protocol.VERSION_MAJOR)
- .writeInt16BE(Protocol.VERSION_MINOR);
+ .writeInt16BE(minorVersion);
const entries = Object.entries(args);
const l = entries.length;
let k: string;
@@ -124,14 +151,14 @@ export class Frontend {
* is not reading its own socket, which is the whole reason this cannot go
* down the normal one.
*/
- getCancelRequestMessage(processID: number, secretKey: number): Buffer {
+ getCancelRequestMessage(processID: number, secretKey: Buffer): Buffer {
return this._io
.start()
- .writeUInt32BE(16) // Length of message contents in bytes, including self.
+ .writeUInt32BE(12 + secretKey.length) // Length of message contents in bytes, including self.
.writeUInt16BE(1234)
.writeUInt16BE(5678)
.writeUInt32BE(processID)
- .writeUInt32BE(secretKey)
+ .writeBuffer(secretKey)
.flush();
}
@@ -356,6 +383,30 @@ export class Frontend {
return setLengthAndFlush(io, 1);
}
+ getFunctionCallMessage(args: Frontend.FunctionCallMessageArgs): Buffer {
+ const { functionId, args: values, argFormats, resultFormat } = args;
+ const formats = argFormats || [];
+ const fl = formats.length;
+ const l = values.length;
+ let i: number;
+ const io = this._io
+ .start()
+ .writeInt8(Protocol.FrontendMessageCode.FunctionCall)
+ .writeInt32BE(0) // Preserve header
+ .writeInt32BE(functionId)
+ .writeInt16BE(fl);
+ for (i = 0; i < fl; i++) io.writeInt16BE(formats[i]);
+ io.writeInt16BE(l);
+ let v: Maybe;
+ for (i = 0; i < l; i++) {
+ v = values[i];
+ if (v == null) io.writeInt32BE(-1);
+ else io.writeInt32BE(v.length).writeBuffer(v);
+ }
+ io.writeInt16BE(resultFormat ?? DataFormat.text);
+ return setLengthAndFlush(io, 1);
+ }
+
/**
* Returns the CopyData header and the caller's payload as two buffers
* rather than one, so a bulk import never copies the caller's bytes.
diff --git a/src/protocol/pg-socket.ts b/src/protocol/pg-socket.ts
index 8b28c18..ff79007 100644
--- a/src/protocol/pg-socket.ts
+++ b/src/protocol/pg-socket.ts
@@ -42,7 +42,8 @@ export class PgSocket extends SafeEventEmitter {
private _sessionParameters: Record = {};
private _saslSession?: SASL.Session;
private _processID?: number;
- private _secretKey?: number;
+ private _secretKey?: Buffer;
+ private _protocolNegotiation?: Protocol.NegotiateProtocolVersionMessage;
private _captureQueue = new DoublyLinked();
private _pendingWrites: { data: Buffer; cb?: Callback }[] = [];
private _flushScheduled = false;
@@ -67,10 +68,21 @@ export class PgSocket extends SafeEventEmitter {
return this._processID;
}
- get secretKey(): Maybe {
+ get secretKey(): Maybe {
return this._secretKey;
}
+ /**
+ * The server's NegotiateProtocolVersion reply, if it sent one -
+ * undefined means everything this client asked for in its startup
+ * packet (protocol minor version, any `_pq_.*` options) was fully
+ * recognized. Present only when the server is older/stricter than what
+ * was requested, or didn't recognize one of the startup options.
+ */
+ get protocolNegotiation(): Maybe {
+ return this._protocolNegotiation;
+ }
+
get sessionParameters(): Record {
return this._sessionParameters;
}
@@ -277,6 +289,19 @@ export class PgSocket extends SafeEventEmitter {
);
}
+ /** The legacy Function Call sub-protocol - see `Frontend.FunctionCallMessageArgs`. */
+ sendFunctionCallMessage(
+ args: Frontend.FunctionCallMessageArgs,
+ cb: CaptureCallback,
+ ): Promise {
+ return this._sendAndCapture(
+ this._frontend.getFunctionCallMessage(args),
+ cb,
+ 'sendFunctionCallMessage',
+ args,
+ );
+ }
+
/**
* Sends Parse+Bind+Describe+Execute+Sync as a single write with a single
* FIFO capture entry, instead of 5 separate sendXMessage() round trips -
@@ -555,14 +580,19 @@ export class PgSocket extends SafeEventEmitter {
socket.on('error', (err: SocketError) => this._handleError(err));
socket.on('close', () => this._handleClose());
this._send(
- this._frontend.getStartupMessage({
- user: this.options.user || 'postgres',
- database: this.options.database || '',
- application_name: this.options.applicationName || '',
- ...(this.options.replication
- ? { replication: this.options.replication }
- : undefined),
- }),
+ this._frontend.getStartupMessage(
+ {
+ user: this.options.user || 'postgres',
+ database: this.options.database || '',
+ application_name: this.options.applicationName || '',
+ ...(this.options.replication
+ ? { replication: this.options.replication }
+ : undefined),
+ },
+ this.options.longCancelKey
+ ? Protocol.VERSION_MINOR_LONG_CANCEL_KEY
+ : undefined,
+ ),
);
}
@@ -702,6 +732,11 @@ export class PgSocket extends SafeEventEmitter {
payload as Protocol.ParameterStatusMessage,
);
break;
+ case Protocol.BackendMessageCode.NegotiateProtocolVersion:
+ this._handleNegotiateProtocolVersion(
+ payload as Protocol.NegotiateProtocolVersionMessage,
+ );
+ break;
case Protocol.BackendMessageCode.BackendKeyData:
this._handleBackendKeyData(
payload as Protocol.BackendKeyDataMessage,
@@ -876,6 +911,25 @@ export class PgSocket extends SafeEventEmitter {
this._sessionParameters[msg.name] = msg.value;
}
+ protected _handleNegotiateProtocolVersion(
+ msg: Protocol.NegotiateProtocolVersionMessage,
+ ): void {
+ this._protocolNegotiation = msg;
+ /* c8 ignore start */
+ if (this.listenerCount('debug')) {
+ this.emit('debug', {
+ location: 'PgSocket._handleNegotiateProtocolVersion',
+ message:
+ `server supports protocol 3.${msg.supportedVersionMinor}` +
+ (msg.unrecognizedOptions.length
+ ? `; did not recognize startup option(s): ${msg.unrecognizedOptions.join(', ')}`
+ : ''),
+ ...msg,
+ });
+ }
+ /* c8 ignore stop */
+ }
+
protected _handleBackendKeyData(msg: Protocol.BackendKeyDataMessage): void {
this._processID = msg.processID;
this._secretKey = msg.secretKey;
diff --git a/src/protocol/protocol.ts b/src/protocol/protocol.ts
index 55e6d23..160e504 100644
--- a/src/protocol/protocol.ts
+++ b/src/protocol/protocol.ts
@@ -1,6 +1,16 @@
export namespace Protocol {
export const VERSION_MAJOR = 3;
export const VERSION_MINOR = 0;
+ /**
+ * Minor version 3.2 (PostgreSQL 18+) differs from 3.0 only in that
+ * BackendKeyData's secret key - and so CancelRequest's - can be up to
+ * 256 bytes instead of always exactly 4. A server that doesn't support
+ * 3.2 replies with NegotiateProtocolVersion naming the highest minor
+ * version it does support (see `PgSocket.protocolNegotiation`) and the
+ * session simply proceeds at that version instead - requesting 3.2 is
+ * never a connection-breaking choice, only ever a possible no-op.
+ */
+ export const VERSION_MINOR_LONG_CANCEL_KEY = 2;
// https://www.postgresql.org/docs/9.3/protocol-message-formats.html
export enum BackendMessageCode {
@@ -116,7 +126,14 @@ export namespace Protocol {
export interface BackendKeyDataMessage {
processID: number;
- secretKey: number;
+ /**
+ * Always 4 bytes before protocol 3.2, up to 256 with it - see
+ * `VERSION_MINOR_LONG_CANCEL_KEY`. Not a fixed-width int, unlike
+ * before: the field carries whatever bytes the server sent, in
+ * whatever order it sent them, to be relayed back to CancelRequest
+ * unchanged rather than interpreted as a number.
+ */
+ secretKey: Buffer;
}
export interface CommandCompleteMessage {
@@ -169,13 +186,19 @@ export namespace Protocol {
}
export interface FunctionCallResponseMessage {
- result: Buffer;
+ /** `null` when the function returned SQL NULL. */
+ result: Buffer | null;
}
export interface NegotiateProtocolVersionMessage {
+ /** Newest minor protocol version the server supports. */
supportedVersionMinor: number;
- numberOfNotSupportedVersions: number;
- option: string;
+ /**
+ * Startup packet options this client sent that the server didn't
+ * recognize - empty when the only mismatch is the protocol minor
+ * version itself.
+ */
+ unrecognizedOptions: string[];
}
export interface ParameterDescriptionMessage {
diff --git a/test/A-common/backend.spec.ts b/test/A-common/backend.spec.ts
index 1e529a7..2c7b203 100644
--- a/test/A-common/backend.spec.ts
+++ b/test/A-common/backend.spec.ts
@@ -109,32 +109,102 @@ describe('Backend (wire message parsing)', () => {
});
});
+ describe('BackendKeyData', () => {
+ it('should read a legacy 4-byte secret key (protocol 3.0)', () => {
+ const body = Buffer.alloc(8);
+ body.writeUInt32BE(1234, 0); // processID
+ body.write('\xd5\xd4\xb4\x4f', 4, 'binary'); // secretKey, 4 bytes
+ const { msg } = parseOne(
+ message(Protocol.BackendMessageCode.BackendKeyData, body),
+ );
+ expect(msg.processID).toStrictEqual(1234);
+ expect(Buffer.isBuffer(msg.secretKey)).toBe(true);
+ expect(msg.secretKey).toStrictEqual(
+ Buffer.from([0xd5, 0xd4, 0xb4, 0x4f]),
+ );
+ });
+
+ it('should read a longer secret key (protocol 3.2) as whatever bytes remain', () => {
+ const key = Buffer.from(Array.from({ length: 32 }, (_, i) => i));
+ const body = Buffer.alloc(4 + key.length);
+ body.writeUInt32BE(1, 0);
+ key.copy(body, 4);
+ const { msg } = parseOne(
+ message(Protocol.BackendMessageCode.BackendKeyData, body),
+ );
+ expect(msg.secretKey).toStrictEqual(key);
+ });
+ });
+
describe('FunctionCallResponse', () => {
- it('should read the result as a raw buffer', () => {
- const result = Buffer.from([0xde, 0xad, 0xbe, 0xef]);
+ it('should read the result value behind its own length prefix', () => {
+ const value = Buffer.from([0xde, 0xad, 0xbe, 0xef]);
+ const body = Buffer.alloc(4 + value.length);
+ body.writeInt32BE(value.length, 0);
+ value.copy(body, 4);
const { code, msg } = parseOne(
- message(Protocol.BackendMessageCode.FunctionCallResponse, result),
+ message(Protocol.BackendMessageCode.FunctionCallResponse, body),
);
expect(code).toStrictEqual(
Protocol.BackendMessageCode.FunctionCallResponse,
);
- expect(msg.result).toStrictEqual(result);
+ expect(msg.result).toStrictEqual(value);
+ });
+
+ it('should read a -1 length prefix as a null result, with no bytes following', () => {
+ const body = Buffer.alloc(4);
+ body.writeInt32BE(-1, 0);
+ const { msg } = parseOne(
+ message(Protocol.BackendMessageCode.FunctionCallResponse, body),
+ );
+ expect(msg.result).toBeNull();
});
});
describe('NegotiateProtocolVersion', () => {
- it('should read the minor version, unsupported-option count and name', () => {
+ it('should read the minor version and one unrecognized option name', () => {
const body = Buffer.alloc(12);
body.writeUInt32BE(2, 0); // supportedVersionMinor
- body.writeUInt32BE(1, 4); // numberOfNotSupportedVersions
- body.write('foo\0', 8, 'utf8'); // option
+ body.writeUInt32BE(1, 4); // count
+ body.write('foo\0', 8, 'utf8');
+ const { msg } = parseOne(
+ message(Protocol.BackendMessageCode.NegotiateProtocolVersion, body),
+ );
+ expect(msg).toStrictEqual({
+ supportedVersionMinor: 2,
+ unrecognizedOptions: ['foo'],
+ });
+ });
+
+ it('should read zero unrecognized options without consuming any string', () => {
+ // A mismatched protocol minor version alone, with every startup
+ // option otherwise recognized, reports a count of 0 and no strings
+ // follow - reading one anyway (the bug this guards against) would
+ // desync every message parsed after this one.
+ const body = Buffer.alloc(8);
+ body.writeUInt32BE(0, 0); // supportedVersionMinor
+ body.writeUInt32BE(0, 4); // count
+ const { msg } = parseOne(
+ message(Protocol.BackendMessageCode.NegotiateProtocolVersion, body),
+ );
+ expect(msg).toStrictEqual({
+ supportedVersionMinor: 0,
+ unrecognizedOptions: [],
+ });
+ });
+
+ it('should read multiple unrecognized option names in order', () => {
+ const body = Buffer.alloc(8 + 4 + 4);
+ body.writeUInt32BE(2, 0); // supportedVersionMinor
+ body.writeUInt32BE(2, 4); // count
+ body.write('foo\0', 8, 'utf8');
+ body.write('bar\0', 12, 'utf8');
const { msg } = parseOne(
message(Protocol.BackendMessageCode.NegotiateProtocolVersion, body),
);
expect(msg).toStrictEqual({
supportedVersionMinor: 2,
- numberOfNotSupportedVersions: 1,
- option: 'foo',
+ unrecognizedOptions: ['foo', 'bar'],
});
});
});
diff --git a/test/A-common/frontend.spec.ts b/test/A-common/frontend.spec.ts
index 9095ba9..8134e5d 100644
--- a/test/A-common/frontend.spec.ts
+++ b/test/A-common/frontend.spec.ts
@@ -348,6 +348,41 @@ describe('Frontend', () => {
});
});
+ describe('getFunctionCallMessage()', () => {
+ it('should write function id, arg formats, each arg (or -1 for null), and the result format', () => {
+ const frontend = new Frontend({});
+ const arg0 = Buffer.from([0x01, 0x02]);
+ const buf = frontend.getFunctionCallMessage({
+ functionId: 1234,
+ argFormats: [DataFormat.binary],
+ args: [arg0, null],
+ resultFormat: DataFormat.binary,
+ });
+ const io = reader(buf);
+ expect(io.readInt32BE()).toStrictEqual(1234);
+ expect(io.readInt16BE()).toStrictEqual(1); // argFormats.length
+ expect(io.readInt16BE()).toStrictEqual(DataFormat.binary);
+ expect(io.readInt16BE()).toStrictEqual(2); // args.length
+ expect(io.readInt32BE()).toStrictEqual(arg0.length);
+ expect(io.readBuffer(arg0.length)).toStrictEqual(arg0);
+ expect(io.readInt32BE()).toStrictEqual(-1); // null arg
+ expect(io.readInt16BE()).toStrictEqual(DataFormat.binary); // result format
+ });
+
+ it('should default to an empty argFormats and text result format', () => {
+ const frontend = new Frontend({});
+ const buf = frontend.getFunctionCallMessage({
+ functionId: 1,
+ args: [],
+ });
+ const io = reader(buf);
+ expect(io.readInt32BE()).toStrictEqual(1);
+ expect(io.readInt16BE()).toStrictEqual(0); // argFormats.length
+ expect(io.readInt16BE()).toStrictEqual(0); // args.length
+ expect(io.readInt16BE()).toStrictEqual(DataFormat.text);
+ });
+ });
+
describe('getCopyFailMessage()', () => {
it('should default a falsy message to the empty string', () => {
const frontend = new Frontend({});
@@ -356,4 +391,43 @@ describe('Frontend', () => {
expect(io.readCString()).toStrictEqual('');
});
});
+
+ describe('getStartupMessage()', () => {
+ // No leading message code byte at all, unlike every other message
+ // here - StartupMessage/CancelRequest/SSLRequest are identified by
+ // their own magic number instead, so there is no 5-byte header for
+ // reader() to skip.
+ it('should default to protocol 3.0 when no minor version is given', () => {
+ const frontend = new Frontend({});
+ const buf = frontend.getStartupMessage({ user: 'u', database: 'd' });
+ expect(buf.readInt16BE(4)).toStrictEqual(3); // major
+ expect(buf.readInt16BE(6)).toStrictEqual(0); // minor
+ });
+
+ it('should write the given minor version', () => {
+ const frontend = new Frontend({});
+ const buf = frontend.getStartupMessage({ user: 'u', database: 'd' }, 2);
+ expect(buf.readInt16BE(6)).toStrictEqual(2);
+ });
+ });
+
+ describe('getCancelRequestMessage()', () => {
+ it('should write a 4-byte legacy secret key unchanged', () => {
+ const frontend = new Frontend({});
+ const secretKey = Buffer.from([0x01, 0x02, 0x03, 0x04]);
+ const buf = frontend.getCancelRequestMessage(99, secretKey);
+ expect(buf.readUInt32BE(0)).toStrictEqual(16); // length, incl. self
+ expect(buf.readUInt32BE(4)).toStrictEqual(80877102); // cancel code
+ expect(buf.readUInt32BE(8)).toStrictEqual(99); // processID
+ expect(buf.subarray(12)).toStrictEqual(secretKey);
+ });
+
+ it('should write a longer secret key with a correspondingly longer length', () => {
+ const frontend = new Frontend({});
+ const secretKey = Buffer.alloc(32, 0xab);
+ const buf = frontend.getCancelRequestMessage(1, secretKey);
+ expect(buf.readUInt32BE(0)).toStrictEqual(12 + secretKey.length);
+ expect(buf.subarray(12)).toStrictEqual(secretKey);
+ });
+ });
});
diff --git a/test/A-common/pg-socket.spec.ts b/test/A-common/pg-socket.spec.ts
index 4d582e3..7e362cf 100644
--- a/test/A-common/pg-socket.spec.ts
+++ b/test/A-common/pg-socket.spec.ts
@@ -11,6 +11,20 @@ describe('PgSocket', () => {
});
});
+ describe('protocolNegotiation', () => {
+ it('should start out undefined before any NegotiateProtocolVersion arrives', () => {
+ const socket = new PgSocket({});
+ expect(socket.protocolNegotiation).toBeUndefined();
+ });
+
+ it('should store the message once _handleNegotiateProtocolVersion runs', () => {
+ const socket: any = new PgSocket({});
+ const msg = { supportedVersionMinor: 0, unrecognizedOptions: ['foo'] };
+ socket._handleNegotiateProtocolVersion(msg);
+ expect(socket.protocolNegotiation).toStrictEqual(msg);
+ });
+ });
+
describe('_handleAuthenticationMessage()', () => {
it('should throw on an authentication method it does not support', () => {
const socket: any = new PgSocket({});
diff --git a/test/B-connection/01-connection.spec.ts b/test/B-connection/01-connection.spec.ts
index 61ad6a4..525ea7f 100644
--- a/test/B-connection/01-connection.spec.ts
+++ b/test/B-connection/01-connection.spec.ts
@@ -70,7 +70,10 @@ describe('Connection', () => {
it('should get secret key', async () => {
connection = new Connection();
await connection.connect();
- expect(connection.secretKey).toBeGreaterThan(0);
+ // 4 bytes by default (protocol 3.0) - see 17-long-cancel-key.spec.ts
+ // for the longer key protocol 3.2 (longCancelKey: true) hands back.
+ expect(Buffer.isBuffer(connection.secretKey)).toBe(true);
+ expect(connection.secretKey?.length).toStrictEqual(4);
});
it('should set application_name', async () => {
diff --git a/test/B-connection/16-function-call.spec.ts b/test/B-connection/16-function-call.spec.ts
new file mode 100644
index 0000000..69a04d7
--- /dev/null
+++ b/test/B-connection/16-function-call.spec.ts
@@ -0,0 +1,61 @@
+import { expect } from 'expect';
+import { Connection, DataFormat } from 'postgrejs';
+
+describe('callFunction() (legacy Function Call sub-protocol)', () => {
+ let connection: Connection;
+
+ before(async () => {
+ connection = new Connection();
+ await connection.connect();
+ });
+
+ after(async () => {
+ await connection.close();
+ });
+
+ async function findOid(proname: string, pronargs: number): Promise {
+ const result = await connection.query(
+ 'select oid from pg_proc where proname = $1 and pronargs = $2',
+ { params: [proname, pronargs] },
+ );
+ return Number((result.rows as any[])[0][0]);
+ }
+
+ it('should call a built-in function by OID with text arguments/result', async () => {
+ // md5, not upper: the latter needs a collation, which the executor
+ // can only resolve from a parsed SQL expression - a raw FunctionCall
+ // argument has no such context, so it fails with "could not
+ // determine which collation to use". md5 doesn't care about
+ // collation at all, so it works the same through either protocol.
+ const oid = await findOid('md5', 1);
+ const result = await connection.callFunction(oid, [
+ Buffer.from('hello', 'utf8'),
+ ]);
+ expect(result.result?.toString('utf8')).toStrictEqual(
+ '5d41402abc4b2a76b9719d911017c592',
+ );
+ });
+
+ it('should call a built-in function with binary arguments/result', async () => {
+ const oid = await findOid('int4pl', 2);
+ const a = Buffer.alloc(4);
+ a.writeInt32BE(2);
+ const b = Buffer.alloc(4);
+ b.writeInt32BE(3);
+ const result = await connection.callFunction(oid, [a, b], {
+ argFormats: [DataFormat.binary],
+ resultFormat: DataFormat.binary,
+ });
+ expect(result.result?.readInt32BE()).toStrictEqual(5);
+ });
+
+ it('should return a null result for a strict function called with a null argument', async () => {
+ const oid = await findOid('md5', 1);
+ const result = await connection.callFunction(oid, [null]);
+ expect(result.result).toBeNull();
+ });
+
+ it('should reject with the server error for an unknown function OID', async () => {
+ await expect(connection.callFunction(0, [])).rejects.toThrow();
+ });
+});
diff --git a/test/B-connection/17-long-cancel-key.spec.ts b/test/B-connection/17-long-cancel-key.spec.ts
new file mode 100644
index 0000000..46bcc20
--- /dev/null
+++ b/test/B-connection/17-long-cancel-key.spec.ts
@@ -0,0 +1,53 @@
+import { expect } from 'expect';
+import { Connection } from 'postgrejs';
+
+describe('longCancelKey (protocol 3.2)', () => {
+ let connection: Connection;
+
+ afterEach(async () => {
+ if (connection) await connection.close(0);
+ });
+
+ it('should hand back a longer secret key than the 4-byte default', async function () {
+ connection = new Connection({ longCancelKey: true });
+ await connection.connect();
+ // Protocol 3.2 (and its longer cancel key) only exists on PostgreSQL
+ // 18+; older servers gracefully negotiate back down to 3.0 and hand
+ // back the legacy 4-byte key instead - by design, not a bug.
+ const serverVersion = parseInt(
+ connection.sessionParameters.server_version,
+ 10,
+ );
+ if (serverVersion < 18) return this.skip();
+ // PostgreSQL sends up to 32 bytes today; the wire format itself
+ // allows up to 256 - either way, strictly more than the legacy 4.
+ expect(connection.secretKey?.length).toBeGreaterThan(4);
+ // Our own test server (PostgreSQL 18) fully supports 3.2, so nothing
+ // here should have needed negotiating down.
+ expect(connection.protocolNegotiation).toBeUndefined();
+ });
+
+ it('should still cancel a running query with the longer key', async () => {
+ connection = new Connection({ longCancelKey: true });
+ await connection.connect();
+ const ac = new AbortController();
+ const started = Date.now();
+ setTimeout(() => ac.abort(), 200);
+ let error: any;
+ try {
+ await connection.query('select pg_sleep(10)', { signal: ac.signal });
+ } catch (e) {
+ error = e;
+ }
+ expect(error).toBeDefined();
+ expect(error.name).toStrictEqual('AbortError');
+ expect(Date.now() - started).toBeLessThan(5000);
+ expect(error.cause?.code).toStrictEqual('57014');
+ });
+
+ it('should default to the legacy 4-byte key when not set', async () => {
+ connection = new Connection();
+ await connection.connect();
+ expect(connection.secretKey?.length).toStrictEqual(4);
+ });
+});