Skip to content
Merged

Dev #67

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
8 changes: 7 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -115,11 +121,15 @@ in · 🟡 partial or needs a separate package · ❌ not supported.
| Module system | ESM | ESM/CJS | ESM/CJS |
| Language | TS | JS <sup>2</sup> | JS <sup>3</sup> |
| ***Wire protocol*** | | | |
| Protocol version | 3.2 | 3.0 | 3.0 |
| Simple Query protocol | ✅ | ✅ | ✅ |
| Extended Query protocol | ✅ | ✅ | ✅ |
| Text wire format | ✅ | ✅ | ✅ |
| Binary wire format | ✅ | 🟡 <sup>4</sup> | ❌ <sup>5</sup> |
| 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 | ❌ | ✅ |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
47 changes: 46 additions & 1 deletion src/connection/connection.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<Protocol.NegotiateProtocolVersionMessage> {
return this._intlCon.protocolNegotiation;
}

/**
* Returns the secret key of the current session
*/
get secretKey(): Maybe<number> {
get secretKey(): Maybe<Buffer> {
return this._intlCon.secretKey;
}

Expand Down Expand Up @@ -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<Buffer>[],
options?: FunctionCallOptions & { signal?: AbortSignal },
): Promise<FunctionCallResult> {
return withAbortSignal(
options?.signal,
() => this._intlCon.cancel(),
() =>
this._captureErrorStack(
this._intlCon.callFunction(functionId, args, options),
this.callFunction,
),
);
}

async query(
sql: string | QueryRequest,
options?: QueryOptions,
Expand Down
63 changes: 62 additions & 1 deletion src/connection/intl-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -91,10 +93,15 @@ export class IntlConnection extends SafeEventEmitter {
return this.socket.processID;
}

get secretKey(): Maybe<number> {
get secretKey(): Maybe<Buffer> {
return this.socket.secretKey;
}

/** See `PgSocket.protocolNegotiation`. */
get protocolNegotiation(): Maybe<Protocol.NegotiateProtocolVersionMessage> {
return this.socket.protocolNegotiation;
}

get sessionParameters(): Record<string, string> {
return this.socket.sessionParameters;
}
Expand Down Expand Up @@ -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<Buffer>[],
options: FunctionCallOptions = {},
): Promise<FunctionCallResult> {
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
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
12 changes: 12 additions & 0 deletions src/interfaces/database-connection-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions src/interfaces/function-call-options.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 7 additions & 0 deletions src/interfaces/function-call-result.ts
Original file line number Diff line number Diff line change
@@ -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;
}
32 changes: 18 additions & 14 deletions src/protocol/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Loading