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
3 changes: 2 additions & 1 deletion src/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import pg from 'pg';
import type { SqlMiddleware } from '@prisma/orm-postgres/family-runtime';
import type { AnyQueryAst } from '@prisma/orm-postgres/relational-core/ast';
import type { StampTables } from './derive.js';
import { survivesLostConnections } from './pool.js';

/** The three write actions the trail records. */
export const AuditAction = {
Expand Down Expand Up @@ -148,7 +149,7 @@ export function audit({
stamps = {},
getPrincipal,
}: AuditOptions): SqlMiddleware & { close(): Promise<void> } {
const pool = new pg.Pool({ connectionString });
const pool = survivesLostConnections(new pg.Pool({ connectionString }));
const table = config.table ?? DEFAULTS.table;
const col = { ...DEFAULTS.columns, ...config.columns };
const pending = new WeakMap<object, Batch>();
Expand Down
23 changes: 22 additions & 1 deletion src/pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@
import pg from 'pg';
import type { Pool, PoolConfig, PoolClient } from 'pg';

/**
* Keep a pool's lost idle connection from ending the process.
*
* @remarks
* When the server closes a connection the pool is holding idle — a
* `pg_terminate_backend`, a failover, a maintenance restart — `pg` emits
* `error` on the pool itself. With no listener, that is an unhandled `error`
* event and Node exits. The pool has already discarded the client by then and
* the next checkout opens a fresh one, so the only thing left to do is say so.
*
* @param pool - The pool to guard.
* @returns The same pool.
*/
export function survivesLostConnections<P extends Pool>(pool: P): P {
pool.on('error', error => {
console.error(`pg pool: idle connection lost: ${error.message}`);
});

return pool;
}

/** `text[]`, whose wire format an array of enums shares exactly. */
const TEXT_ARRAY = 1009;

Expand Down Expand Up @@ -107,7 +128,7 @@ export function dataPool(
config: PoolConfig,
parsers: TypeParsers = pg.types as unknown as TypeParsers,
): Pool {
const pool = new pg.Pool(config);
const pool = survivesLostConnections(new pg.Pool(config));

// Bound before the override, so neither the lookup below nor the callback
// form calls itself.
Expand Down
1 change: 1 addition & 0 deletions test/unit/barrel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const EXPORTS = [
'sql',
'sqlRunner',
'stamp',
'survivesLostConnections',
'toOrdering',
'toPredicate',
'toProjection',
Expand Down
63 changes: 63 additions & 0 deletions test/unit/pool.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*!
* @imqueue/pg-prisma — pool tests
*
* I'm Queue Software Project
* Copyright (C) 2026 imqueue.com <support@imqueue.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* If you want to use this code in a closed source (commercial) project, you can
* purchase a proprietary commercial license. Please contact us at
* <support@imqueue.com> to get commercial licensing options.
*/

import assert from 'node:assert/strict';
import { mock, test } from 'node:test';
import pg from 'pg';
import { dataPool, survivesLostConnections } from '../../index.js';

const terminated = new Error(
'terminating connection due to administrator command',
);

test('an unguarded pool throws on a lost idle connection', async () => {
const pool = new pg.Pool();

assert.throws(() => pool.emit('error', terminated), terminated);
await pool.end();
});

test('a guarded pool reports a lost idle connection and carries on', async t => {
const report = mock.method(console, 'error', () => undefined);
const pool = survivesLostConnections(new pg.Pool());

t.after(() => report.mock.restore());

assert.equal(pool.emit('error', terminated), true);
assert.match(
String(report.mock.calls[0]?.arguments[0]),
/administrator command/,
);
await pool.end();
});

test('dataPool is guarded', async t => {
const report = mock.method(console, 'error', () => undefined);
const pool = dataPool({});

t.after(() => report.mock.restore());

assert.doesNotThrow(() => pool.emit('error', terminated));
await pool.end();
});
Loading