@voxpelli/pg-utils 3.1.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,11 +13,7 @@ My personal database utils / helpers for Postgres
13
13
  ## Usage
14
14
 
15
15
  ```javascript
16
- import {
17
- csvFromFolderToDb,
18
- dbToCsvFolder,
19
- PgTestHelpers,
20
- } from '@voxpelli/pg-utils';
16
+ import { PgTestHelpers } from '@voxpelli/pg-utils';
21
17
 
22
18
  const pgHelpers = new PgTestHelpers({
23
19
  connectionString: 'postgres://user:pass@localhost/example',
@@ -31,18 +27,47 @@ const pgHelpers = new PgTestHelpers({
31
27
  });
32
28
 
33
29
  try {
34
- // The helper automatically acquires an exclusive database lock
35
- // on the first call to initTables(), insertFixtures(), or removeTables()
36
- // to prevent concurrent access between tests during test operations.
37
30
  await pgHelpers.initTables();
38
31
  await pgHelpers.insertFixtures();
39
32
  } finally {
40
- // Always release the lock and close connections,
41
- // even if a test or setup step throws an error
42
33
  await pgHelpers.end();
43
34
  }
44
35
  ```
45
36
 
37
+ Or use `pgTestSetup` / `pgTestSetupFor` for one-step setup with automatic cleanup:
38
+
39
+ ```javascript
40
+ import { pgTestSetupFor } from '@voxpelli/pg-utils';
41
+
42
+ // With node:test — cleanup registered via t.after()
43
+ it('inserts a record', async (t) => {
44
+ const helpers = await pgTestSetupFor({
45
+ connectionString: 'postgres://user:pass@localhost/example',
46
+ schema: new URL('./create-tables.sql', import.meta.url),
47
+ fixtureFolder: new URL('./fixtures', import.meta.url),
48
+ }, t);
49
+
50
+ // Tables created, fixtures loaded.
51
+ // helpers.end() called automatically after test via t.after()
52
+ });
53
+ ```
54
+
55
+ ```javascript
56
+ import { pgTestSetup } from '@voxpelli/pg-utils';
57
+
58
+ // With await using — cleanup via Symbol.asyncDispose
59
+ it('inserts a record', async () => {
60
+ await using helpers = await pgTestSetup({
61
+ connectionString: 'postgres://user:pass@localhost/example',
62
+ schema: new URL('./create-tables.sql', import.meta.url),
63
+ fixtureFolder: new URL('./fixtures', import.meta.url),
64
+ });
65
+
66
+ // Tables created, fixtures loaded.
67
+ // helpers.end() called automatically when scope exits.
68
+ });
69
+ ```
70
+
46
71
  ## PgTestHelpers
47
72
 
48
73
  Class that creates a helpers instance
@@ -71,22 +96,180 @@ new PgTestHelpers({
71
96
 
72
97
  * `connectionString` – _`string`_ – a connection string for the postgres database
73
98
  * `fixtureFolder` – _`[string | URL]`_ – _optional_ – the path to a folder of `.csv`-file fixtures named by their respective table
99
+ * `idleInTransactionTimeoutMs` – _`[number]`_ – _optional_ – idle-in-transaction session timeout in milliseconds, applied to pool connections. Auto-kills transactions left open by crashed tests.
74
100
  * `ignoreTables` – _`[string[]]`_ – _optional_ – names of tables to ignore when dropping
75
- * `schema` – _`string | URL | Umzug`_ an umzug instance that can be used to initialize tables or the schema itself or a `URL` to a text file containing the schema
101
+ * `lockId` – _`[number]`_ _optional_ advisory lock ID (default: `42`). All instances with the same `lockId` on the same PostgreSQL cluster serialize against each other — this is the intended isolation behavior that prevents concurrent test operations from interfering. Since `removeTables()` drops all public tables (not just the ones defined in `schema`), using different lock IDs only makes sense when test files target entirely separate databases (see [Parallel Test Runners](#parallel-test-runners)).
102
+ * `lockTimeoutMs` – _`[number]`_ – _optional_ – lock acquisition timeout in milliseconds. When set, a `SET lock_timeout` is issued before acquiring the advisory lock. If another process holds the lock longer than this, the acquisition fails with a descriptive error instead of waiting indefinitely.
103
+ * `schema` – _`string | URL | ((pool: pg.Pool) => Umzug)`_ – a factory function that receives the pool and returns an Umzug instance, or the schema itself as a string, or a `URL` to a text file containing the schema
104
+ * `statementTimeoutMs` – _`[number]`_ – _optional_ – per-statement query timeout in milliseconds, applied to pool connections. Prevents any single query from hanging indefinitely.
76
105
  * `tableLoadOrder` – _`[Array<string[] | string>]`_ – _optional_ – tables in parent-first insertion order: the first item is loaded first and dropped last. Use nested arrays to group tables that can be dropped in parallel. Mutually exclusive with `tablesWithDependencies`.
77
106
  * `tablesWithDependencies` – _`[Array<string[] | string>]`_ – _optional_ – **Deprecated:** use `tableLoadOrder` instead. Tables in leaf-first deletion order: the first item is dropped first and loaded last.
78
107
 
79
108
  ### Methods
80
109
 
110
+ * `setup() => Promise<this>` – convenience method that runs the standard sequence: `removeTables()`, `initTables()`, and `insertFixtures()` (when `fixtureFolder` is set). Returns `this` so it can be chained with `await using`. Calls `end()` internally on failure to prevent pool leaks.
81
111
  * `initTables() => Promise<void>` – sets up all of the tables. Automatically acquires an exclusive database lock on first call.
82
112
  * `insertFixtures() => Promise<void>` – inserts all the fixtures data into the tables (only usable if `fixtureFolder` has been set). Automatically acquires an exclusive database lock on first call.
83
- * `removeTables() => Promise<void>` – removes all of the tables (respecting `tableLoadOrder` / `tablesWithDependencies` ordering). Automatically acquires an exclusive database lock on first call.
113
+ * `removeTables() => Promise<void>` – removes all of the tables (respecting `tableLoadOrder` / `tablesWithDependencies` ordering). Automatically acquires an exclusive database lock on first call. **Note:** this drops all tables in the `public` schema, not just those defined in `schema`.
84
114
  * `end() => Promise<void>` – releases the database lock (if acquired) and closes all database connections. **Always call this when done** to properly clean up resources.
115
+ * `queryPromise` – _`Pool['query']`_ – bound reference to the underlying `pg.Pool.query()` method for ad-hoc SQL queries in tests.
116
+ * `[Symbol.asyncDispose]() => Promise<void>` – alias for `end()`. Enables `await using` syntax for automatic cleanup.
85
117
 
86
118
  #### Database Locking
87
119
 
88
120
  The `PgTestHelpers` class uses [PostgreSQL advisory locks](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS) to ensure exclusive database access during test operations. The lock is automatically acquired on the first call to `initTables()`, `insertFixtures()`, or `removeTables()`, and is held until `end()` is called. This prevents multiple test suites from interfering with each other when using the same database.
89
121
 
122
+ Advisory locks are **cluster-scoped** — all connections to the same PostgreSQL cluster (even to different databases) that use the same `lockId` will serialize against each other. The default `lockId` of `42` ensures all `PgTestHelpers` instances coordinate, which is normally what you want.
123
+
124
+ #### Parallel Test Runners
125
+
126
+ When using `node:test` or other parallel test runners, all test files share the same database and the same advisory lock (ID `42`). This means they serialize — which is correct, since `removeTables()` drops all public tables and concurrent drops would corrupt each other's state.
127
+
128
+ If your test files truly need parallel execution, consider using separate databases per test file and setting a unique `lockId` per database.
129
+
130
+ ## pgTestSetup()
131
+
132
+ Creates and sets up a `PgTestHelpers` instance in one step. Use with `await using` for automatic cleanup.
133
+
134
+ ### Syntax
135
+
136
+ ```ts
137
+ pgTestSetup(options) => Promise<PgTestHelpers>
138
+ ```
139
+
140
+ ### Arguments
141
+
142
+ * `options` – _[`PgTestHelpersOptions`](#pgtesthelpersoptions)_ – same options as the `PgTestHelpers` constructor
143
+
144
+ ## pgTestSetupFor()
145
+
146
+ Creates and sets up a `PgTestHelpers` instance, registering cleanup via `t.after()`. No `await using` or `afterEach` needed.
147
+
148
+ ### Syntax
149
+
150
+ ```ts
151
+ pgTestSetupFor(options, t) => Promise<PgTestHelpers>
152
+ ```
153
+
154
+ ### Arguments
155
+
156
+ * `options` – _[`PgTestHelpersOptions`](#pgtesthelpersoptions)_ – same options as the `PgTestHelpers` constructor
157
+ * `t` – _`{ after?: Function }`_ – a test context with an `after()` method (e.g., node:test's `TestContext`). Cleanup is registered via `t.after(() => helpers.end())`. Throws `TypeError` if `after` is missing.
158
+
159
+ ## Using with node:test
160
+
161
+ ### Per-test with `pgTestSetupFor` (recommended)
162
+
163
+ The simplest pattern. `pgTestSetupFor` creates and sets up the helpers, then registers cleanup via `t.after()` — no `afterEach` or `await using` needed:
164
+
165
+ ```javascript
166
+ import { describe, it } from 'node:test';
167
+ import { pgTestSetupFor } from '@voxpelli/pg-utils';
168
+
169
+ describe('my feature', () => {
170
+ it('inserts a record', async (t) => {
171
+ const helpers = await pgTestSetupFor({
172
+ connectionString: process.env.DATABASE_URL,
173
+ schema: new URL('./schema.sql', import.meta.url),
174
+ }, t);
175
+
176
+ // Tables are ready. helpers.end() called automatically via t.after()
177
+ });
178
+ });
179
+ ```
180
+
181
+ ### Per-test with `await using`
182
+
183
+ When you prefer scope-based cleanup or your test framework doesn't expose a test context:
184
+
185
+ ```javascript
186
+ import { describe, it } from 'node:test';
187
+ import { pgTestSetup } from '@voxpelli/pg-utils';
188
+
189
+ describe('my feature', () => {
190
+ it('inserts a record', async () => {
191
+ await using helpers = await pgTestSetup({
192
+ connectionString: process.env.DATABASE_URL,
193
+ schema: new URL('./schema.sql', import.meta.url),
194
+ });
195
+
196
+ // Tables are ready. helpers.end() called automatically on scope exit.
197
+ });
198
+ });
199
+ ```
200
+
201
+ **Trade-off:** each test pays the full setup cost (drop + create + load fixtures). For suites with many tests against the same fixture data, use `beforeEach` with `pgTestSetupFor` instead.
202
+
203
+ ### Suite-level with `beforeEach` + `pgTestSetupFor`
204
+
205
+ Use `pgTestSetupFor` inside `beforeEach` when the `helpers` reference is needed across multiple tests. The `t.after()` registration eliminates the need for a separate `afterEach` for helpers cleanup:
206
+
207
+ ```javascript
208
+ import assert from 'node:assert/strict';
209
+ import { beforeEach, describe, it } from 'node:test';
210
+ import { pgTestSetupFor } from '@voxpelli/pg-utils';
211
+
212
+ describe('my feature', () => {
213
+ /** @type {import('@voxpelli/pg-utils').PgTestHelpers} */
214
+ let helpers;
215
+
216
+ beforeEach(async (t) => {
217
+ helpers = await pgTestSetupFor({
218
+ connectionString: process.env.DATABASE_URL,
219
+ schema: new URL('./schema.sql', import.meta.url),
220
+ fixtureFolder: new URL('./fixtures', import.meta.url),
221
+ }, t);
222
+ });
223
+
224
+ it('reads a record', async () => {
225
+ const { rows } = await helpers.queryPromise('SELECT 1 AS val');
226
+ assert.strictEqual(rows[0]?.val, 1);
227
+ });
228
+ });
229
+ ```
230
+
231
+ `setup()` calls `end()` internally on failure, so no `try/catch` is needed in `beforeEach`. If you need additional cleanup beyond helpers (e.g., `app.close()`), add an `afterEach` for that.
232
+
233
+ ## Factory pattern
234
+
235
+ For projects with multiple test files sharing the same database configuration, centralise setup in a factory function:
236
+
237
+ ```javascript
238
+ // tools/test-helpers.js
239
+ import { pgTestSetupFor } from '@voxpelli/pg-utils';
240
+
241
+ /** @param {{ after?: (fn: () => Promise<void>) => void }} t */
242
+ export const testSetup = (t) => pgTestSetupFor({
243
+ connectionString: process.env.DATABASE_URL,
244
+ schema: new URL('../schema.sql', import.meta.url),
245
+ fixtureFolder: new URL('../test/fixtures', import.meta.url),
246
+ tableLoadOrder: [
247
+ 'accounts',
248
+ ['posts', 'comments'],
249
+ ],
250
+ }, t);
251
+ ```
252
+
253
+ Test files become minimal:
254
+
255
+ ```javascript
256
+ import { beforeEach, describe, it } from 'node:test';
257
+ import { testSetup } from '../tools/test-helpers.js';
258
+
259
+ describe('posts', () => {
260
+ /** @type {import('@voxpelli/pg-utils').PgTestHelpers} */
261
+ let helpers;
262
+
263
+ beforeEach(async (t) => {
264
+ helpers = await testSetup(t);
265
+ });
266
+
267
+ // No afterEach needed — cleanup registered via t.after()
268
+
269
+ // ... tests using helpers.queryPromise ...
270
+ });
271
+ ```
272
+
90
273
  ## csvFromFolderToDb()
91
274
 
92
275
  Imports data into tables from a folder of CSV files. All files will be imported and they should named by their table names + `.csv`.
@@ -125,21 +308,8 @@ dbToCsvFolder(connection, outputPath, tables, [options]) => Promise<void>
125
308
  * `outputPath` – _`string | URL`_ – the directory to write CSV files into (created if it does not exist)
126
309
  * `tables` – _`string[]`_ – explicit list of table names to export
127
310
  * `options` – _`object`_ – _optional_
128
- * `orderBy` – _`string`_ – SQL `ORDER BY` expression for deterministic output (default: `'1'`, i.e. the first column)
311
+ * `orderBy` – _`string`_ – column index (e.g. `'1'`) or simple identifier (e.g. `'created_at'`) for deterministic output ordering (default: `'1'`, i.e. the first column). Expressions, `ASC`/`DESC`, and multi-column values are not accepted.
129
312
 
130
313
  ### Returns
131
314
 
132
315
  `Promise` that resolves on completion
133
-
134
- <!-- ## Used by
135
-
136
- * [`example`](https://example.com/) – used by this one to do X and Y
137
-
138
- ## Similar modules
139
-
140
- * [`example`](https://example.com/) – is similar in this way
141
-
142
- ## See also
143
-
144
- * [Announcement blog post](#)
145
- * [Announcement tweet](#) -->
package/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { csvFromFolderToDb } from "./lib/csv-folder-to-db.js";
2
2
  export { dbToCsvFolder } from "./lib/db-to-csv-folder.js";
3
- export { PgTestHelpers } from "./lib/test-helpers.js";
3
+ export { PgTestHelpers, pgTestSetup, pgTestSetupFor } from "./lib/test-helpers.js";
4
4
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  export { csvFromFolderToDb } from './lib/csv-folder-to-db.js';
2
2
  export { dbToCsvFolder } from './lib/db-to-csv-folder.js';
3
- export { PgTestHelpers } from './lib/test-helpers.js';
3
+ export { PgTestHelpers, pgTestSetup, pgTestSetupFor } from './lib/test-helpers.js';
@@ -1 +1 @@
1
- {"version":3,"file":"csv-folder-to-db.d.ts","sourceRoot":"","sources":["csv-folder-to-db.js"],"names":[],"mappings":"AAkHA,8CALW,MAAM,GAAG,IAAI,QACb,MAAM,GAAG,GAAG,YACZ,MAAM,EAAE,GAAG,wBAAwB,GACjC,OAAO,CAAC,IAAI,CAAC,CA2BzB;;qBA1Ea,MAAM,EAAE;6BACR,MAAM,EAAE;;0BAvDI,IAAI"}
1
+ {"version":3,"file":"csv-folder-to-db.d.ts","sourceRoot":"","sources":["csv-folder-to-db.js"],"names":[],"mappings":"AAkHA,8CALW,MAAM,GAAG,IAAI,QACb,MAAM,GAAG,GAAG,YACZ,MAAM,EAAE,GAAG,wBAAwB,GACjC,OAAO,CAAC,IAAI,CAAC,CAgCzB;;qBA/Ea,MAAM,EAAE;6BACR,MAAM,EAAE;;0BAvDI,IAAI"}
@@ -116,25 +116,30 @@ export async function csvFromFolderToDb (connection, path, options) {
116
116
  const tablesWithDependencies = resolveTableOrder(options);
117
117
  const files = await getFilesOrderedByDependencies(path, tablesWithDependencies);
118
118
 
119
- const pool = (typeof connection === 'object' && 'connect' in connection) ? connection : createPgPool(connection);
120
-
121
- const client = await pool.connect();
119
+ const ownPool = typeof connection !== 'object' || !('connect' in connection);
120
+ const pool = ownPool ? createPgPool(connection) : connection;
122
121
 
123
122
  try {
124
- for (const file of files) {
125
- const name = pathModule.basename(file, '.csv');
126
- const dbCopy = client.query(copyFrom(`COPY ${pg.escapeIdentifier(name)} FROM STDIN WITH (FORMAT csv, HEADER MATCH)`));
123
+ const client = await pool.connect();
124
+
125
+ try {
126
+ for (const file of files) {
127
+ const name = pathModule.basename(file, '.csv');
128
+ const dbCopy = client.query(copyFrom(`COPY ${pg.escapeIdentifier(name)} FROM STDIN WITH (FORMAT csv, HEADER MATCH)`));
127
129
 
128
- // eslint-disable-next-line security/detect-non-literal-fs-filename
129
- const csvContent = createReadStream(file);
130
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
131
+ const csvContent = createReadStream(file);
130
132
 
131
- try {
132
- await promisedPipeline(csvContent, dbCopy);
133
- } catch (cause) {
134
- throw new Error(`Failed inserting data into "${name}"`, { cause });
133
+ try {
134
+ await promisedPipeline(csvContent, dbCopy);
135
+ } catch (cause) {
136
+ throw new Error(`Failed inserting data into "${name}"`, { cause });
137
+ }
135
138
  }
139
+ } finally {
140
+ client.release();
136
141
  }
137
142
  } finally {
138
- client.release();
143
+ if (ownPool) await pool.end();
139
144
  }
140
145
  }
@@ -1 +1 @@
1
- {"version":3,"file":"db-to-csv-folder.d.ts","sourceRoot":"","sources":["db-to-csv-folder.js"],"names":[],"mappings":"AA8BA,0CANW,MAAM,GAAG,IAAI,cACb,MAAM,GAAG,GAAG,UACZ,MAAM,EAAE,YACR,oBAAoB,GAClB,OAAO,CAAC,IAAI,CAAC,CA0BzB;;cAvCa,MAAM;;0BAJM,IAAI"}
1
+ {"version":3,"file":"db-to-csv-folder.d.ts","sourceRoot":"","sources":["db-to-csv-folder.js"],"names":[],"mappings":"AA8BA,0CANW,MAAM,GAAG,IAAI,cACb,MAAM,GAAG,GAAG,UACZ,MAAM,EAAE,YACR,oBAAoB,GAClB,OAAO,CAAC,IAAI,CAAC,CAgDzB;;cA7Da,MAAM;;0BAJM,IAAI"}
@@ -31,25 +31,47 @@ import { createPgPool } from './utils.js';
31
31
  export async function dbToCsvFolder (connection, outputPath, tables, options = {}) {
32
32
  const { orderBy = '1' } = options;
33
33
 
34
+ // Security: orderBy is interpolated into SQL — validate to prevent injection
35
+ const isColumnIndex = /^[1-9]\d*$/.test(orderBy);
36
+ const isIdentifier = /^[a-z_]\w*$/i.test(orderBy);
37
+
38
+ if (!isColumnIndex && !isIdentifier) {
39
+ throw new Error(
40
+ `Invalid orderBy value: ${JSON.stringify(orderBy)}. ` +
41
+ 'Must be a positive column index (e.g. "1") or a simple identifier (e.g. "created_at").'
42
+ );
43
+ }
44
+
34
45
  const dirPath = typeof outputPath === 'string' ? outputPath : fileURLToPath(outputPath);
35
46
 
36
47
  // eslint-disable-next-line security/detect-non-literal-fs-filename
37
48
  await mkdir(dirPath, { recursive: true });
38
49
 
39
- const pool = (typeof connection === 'object' && 'connect' in connection) ? connection : createPgPool(connection);
50
+ const escapedOrderBy = isColumnIndex ? orderBy : pg.escapeIdentifier(orderBy);
40
51
 
41
- const client = await pool.connect();
52
+ const ownPool = typeof connection !== 'object' || !('connect' in connection);
53
+ const pool = ownPool ? createPgPool(connection) : connection;
42
54
 
43
55
  try {
44
- for (const table of tables) {
45
- const sql = `COPY (SELECT * FROM ${pg.escapeIdentifier(table)} ORDER BY ${orderBy}) TO STDOUT WITH (FORMAT csv, HEADER true)`;
46
- const copyToStream = client.query(copyTo(sql));
47
- const filePath = pathModule.join(dirPath, `${table}.csv`);
56
+ const client = await pool.connect();
57
+
58
+ try {
59
+ for (const table of tables) {
60
+ const sql = `COPY (SELECT * FROM ${pg.escapeIdentifier(table)} ORDER BY ${escapedOrderBy}) TO STDOUT WITH (FORMAT csv, HEADER true)`;
61
+ const copyToStream = client.query(copyTo(sql));
62
+ const filePath = pathModule.join(dirPath, `${table}.csv`);
48
63
 
49
- // eslint-disable-next-line security/detect-non-literal-fs-filename
50
- await pipeline(copyToStream, createWriteStream(filePath));
64
+ try {
65
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
66
+ await pipeline(copyToStream, createWriteStream(filePath));
67
+ } catch (cause) {
68
+ throw new Error(`Failed exporting data from "${table}"`, { cause });
69
+ }
70
+ }
71
+ } finally {
72
+ client.release();
51
73
  }
52
74
  } finally {
53
- client.release();
75
+ if (ownPool) await pool.end();
54
76
  }
55
77
  }
@@ -1,16 +1,26 @@
1
+ export function pgTestSetup(options: PgTestHelpersOptions): Promise<PgTestHelpers>;
2
+ export function pgTestSetupFor(options: PgTestHelpersOptions, t: {
3
+ after?: (fn: () => Promise<void>) => void;
4
+ }): Promise<PgTestHelpers>;
1
5
  export class PgTestHelpers {
2
6
  constructor(options: PgTestHelpersOptions);
3
7
  queryPromise: Pool["query"];
4
8
  initTables(): Promise<void>;
5
9
  insertFixtures(): Promise<void>;
10
+ setup(): Promise<this>;
6
11
  removeTables(): Promise<void>;
7
12
  end(): Promise<void>;
13
+ [Symbol.asyncDispose](): Promise<void>;
8
14
  #private;
9
15
  }
10
16
  export type PgTestHelpersOptions = {
11
17
  connectionString: string;
12
18
  fixtureFolder?: string | URL;
13
19
  ignoreTables?: string[];
20
+ lockId?: number;
21
+ lockTimeoutMs?: number;
22
+ statementTimeoutMs?: number;
23
+ idleInTransactionTimeoutMs?: number;
14
24
  schema: string | URL | ((pool: Pool) => import("umzug").Umzug<import("umzeption").UmzeptionContext<"pg", import("umzeption").FastifyPostgresStyleDb>>);
15
25
  tableLoadOrder?: Array<string[] | string>;
16
26
  tablesWithDependencies?: Array<string[] | string>;
@@ -1 +1 @@
1
- {"version":3,"file":"test-helpers.d.ts","sourceRoot":"","sources":["test-helpers.js"],"names":[],"mappings":"AAuBA;IAuBE,qBADY,oBAAoB,EAoD/B;IAtDD,cADW,IAAI,CAAC,OAAO,CAAC,CACX;IA8Hb,cADc,OAAO,CAAC,IAAI,CAAC,CAmB1B;IAGD,kBADc,OAAO,CAAC,IAAI,CAAC,CAkB1B;IAGD,gBADc,OAAO,CAAC,IAAI,CAAC,CAgB1B;IAGD,OADc,OAAO,CAAC,IAAI,CAAC,CAO1B;;CACF;;sBA5Na,MAAM;oBACN,MAAM,GAAG,GAAG;mBACZ,MAAM,EAAE;YACR,MAAM,GAAG,GAAG,GAAG,CAAC,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,OAAO,EAAE,KAAK,CAAC,OAAO,WAAW,EAAE,gBAAgB,CAAC,IAAI,EAAE,OAAO,WAAW,EAAE,sBAAsB,CAAC,CAAC,CAAC;qBAC9I,KAAK,CAAC,MAAM,EAAE,GAAG,MAAM,CAAC;6BACxB,KAAK,CAAC,MAAM,EAAE,GAAG,MAAM,CAAC;;0BATJ,IAAI"}
1
+ {"version":3,"file":"test-helpers.d.ts","sourceRoot":"","sources":["test-helpers.js"],"names":[],"mappings":"AA8UA,qCAHW,oBAAoB,GAClB,OAAO,CAAC,aAAa,CAAC,CAIlC;AAgBD,wCAJW,oBAAoB,KACpB;IAAE,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,KAAK,IAAI,CAAA;CAAE,GAC3C,OAAO,CAAC,aAAa,CAAC,CAUlC;AA7UD;IA2BE,qBADY,oBAAoB,EAsF/B;IAxFD,cADW,IAAI,CAAC,OAAO,CAAC,CACX;IAqKb,cADc,OAAO,CAAC,IAAI,CAAC,CAoB1B;IAGD,kBADc,OAAO,CAAC,IAAI,CAAC,CAuB1B;IASD,SAFa,OAAO,CAAC,IAAI,CAAC,CAgBzB;IAGD,gBADc,OAAO,CAAC,IAAI,CAAC,CAqB1B;IAGD,OADc,OAAO,CAAC,IAAI,CAAC,CAW1B;IAGD,yBADc,OAAO,CAAC,IAAI,CAAC,CAG1B;;CACF;;sBAtTa,MAAM;oBACN,MAAM,GAAG,GAAG;mBACZ,MAAM,EAAE;aACR,MAAM;oBACN,MAAM;yBACN,MAAM;iCACN,MAAM;YACN,MAAM,GAAG,GAAG,GAAG,CAAC,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,OAAO,EAAE,KAAK,CAAC,OAAO,WAAW,EAAE,gBAAgB,CAAC,IAAI,EAAE,OAAO,WAAW,EAAE,sBAAsB,CAAC,CAAC,CAAC;qBAC9I,KAAK,CAAC,MAAM,EAAE,GAAG,MAAM,CAAC;6BACxB,KAAK,CAAC,MAAM,EAAE,GAAG,MAAM,CAAC;;0BAbJ,IAAI"}
@@ -16,6 +16,10 @@ import { createPgPool, createAndLockConnection, releaseLock, isStringArray, Type
16
16
  * @property {string} connectionString
17
17
  * @property {string | URL} [fixtureFolder]
18
18
  * @property {string[]} [ignoreTables]
19
+ * @property {number} [lockId] Advisory lock ID. Default: 42. Use unique IDs per test file for parallel runners (advisory locks are cluster-scoped).
20
+ * @property {number} [lockTimeoutMs] Lock acquisition timeout in milliseconds. No default (waits indefinitely).
21
+ * @property {number} [statementTimeoutMs] Per-statement query timeout in milliseconds on the pool. No default.
22
+ * @property {number} [idleInTransactionTimeoutMs] Idle-in-transaction timeout in milliseconds. No default.
19
23
  * @property {string | URL | ((pool: Pool) => import('umzug').Umzug<import('umzeption').UmzeptionContext<'pg', import('umzeption').FastifyPostgresStyleDb>>)} schema
20
24
  * @property {Array<string[] | string>} [tableLoadOrder] Tables in parent-first insertion order. First item loaded first, dropped last. Mutually exclusive with `tablesWithDependencies`.
21
25
  * @property {Array<string[] | string>} [tablesWithDependencies] Deprecated: use `tableLoadOrder` instead. Tables in leaf-first deletion order. First item dropped first, loaded last.
@@ -30,6 +34,10 @@ export class PgTestHelpers {
30
34
  #fixtureFolder;
31
35
  /** @type {string[] | undefined} */
32
36
  #ignoreTables;
37
+ /** @type {number} */
38
+ #lockId;
39
+ /** @type {number | undefined} */
40
+ #lockTimeoutMs;
33
41
  /** @type {Pool} */
34
42
  #pool;
35
43
  /** @type {Promise<Client> | Client | undefined} */
@@ -52,8 +60,12 @@ export class PgTestHelpers {
52
60
  const {
53
61
  connectionString,
54
62
  fixtureFolder,
63
+ idleInTransactionTimeoutMs,
55
64
  ignoreTables,
65
+ lockId = 42,
66
+ lockTimeoutMs,
56
67
  schema,
68
+ statementTimeoutMs,
57
69
  tableLoadOrder,
58
70
  tablesWithDependencies,
59
71
  } = options;
@@ -79,9 +91,37 @@ export class PgTestHelpers {
79
91
  if (tablesWithDependencies && !Array.isArray(tablesWithDependencies)) {
80
92
  throw new TypeNeverError(tablesWithDependencies, 'Invalid tablesWithDependencies, expected an array');
81
93
  }
94
+ if (lockId !== undefined && (typeof lockId !== 'number' || !Number.isSafeInteger(lockId))) {
95
+ throw new TypeError('Invalid lockId, expected a safe integer');
96
+ }
97
+ if (lockTimeoutMs !== undefined && (typeof lockTimeoutMs !== 'number' || !Number.isSafeInteger(lockTimeoutMs) || lockTimeoutMs < 0)) {
98
+ throw new TypeError('Invalid lockTimeoutMs, expected a non-negative safe integer');
99
+ }
100
+ if (statementTimeoutMs !== undefined && (typeof statementTimeoutMs !== 'number' || !Number.isSafeInteger(statementTimeoutMs) || statementTimeoutMs < 0)) {
101
+ throw new TypeError('Invalid statementTimeoutMs, expected a non-negative safe integer');
102
+ }
103
+ if (idleInTransactionTimeoutMs !== undefined && (typeof idleInTransactionTimeoutMs !== 'number' || !Number.isSafeInteger(idleInTransactionTimeoutMs) || idleInTransactionTimeoutMs < 0)) {
104
+ throw new TypeError('Invalid idleInTransactionTimeoutMs, expected a non-negative safe integer');
105
+ }
82
106
 
83
107
  const pool = createPgPool(connectionString);
84
108
 
109
+ // Set pool-level timeouts via 'connect' event. pg.Client serializes queries
110
+ // internally, so the SET completes before any caller query even though the
111
+ // callback return value is not awaited by pg-pool.
112
+ if (statementTimeoutMs !== undefined) {
113
+ pool.on('connect', (/** @type {import('pg').PoolClient} */ client) => {
114
+ // eslint-disable-next-line promise/prefer-await-to-then
115
+ client.query(`SET statement_timeout = ${Number(statementTimeoutMs)}`).catch(() => {});
116
+ });
117
+ }
118
+ if (idleInTransactionTimeoutMs !== undefined) {
119
+ pool.on('connect', (/** @type {import('pg').PoolClient} */ client) => {
120
+ // eslint-disable-next-line promise/prefer-await-to-then
121
+ client.query(`SET idle_in_transaction_session_timeout = ${Number(idleInTransactionTimeoutMs)}`).catch(() => {});
122
+ });
123
+ }
124
+
85
125
  // tableLoadOrder is parent-first; reverse to get the leaf-first
86
126
  // order that internal methods expect (drop first item first, load last)
87
127
  this.#tablesWithDependencies = tableLoadOrder
@@ -92,6 +132,8 @@ export class PgTestHelpers {
92
132
  this.#connectionString = connectionString;
93
133
  this.#fixtureFolder = fixtureFolder;
94
134
  this.#ignoreTables = ignoreTables;
135
+ this.#lockId = lockId;
136
+ this.#lockTimeoutMs = lockTimeoutMs;
95
137
  this.#pool = pool;
96
138
  this.#schema = schema;
97
139
  this.queryPromise = pool.query.bind(pool);
@@ -145,8 +187,11 @@ export class PgTestHelpers {
145
187
  }
146
188
 
147
189
  if (!this.#lockClient) {
190
+ this.#lockClient = createAndLockConnection(this.#connectionString, {
191
+ lockId: this.#lockId,
192
+ ...this.#lockTimeoutMs !== undefined && { lockTimeoutMs: this.#lockTimeoutMs },
148
193
  // eslint-disable-next-line promise/prefer-await-to-then
149
- this.#lockClient = createAndLockConnection(this.#connectionString).then(result => {
194
+ }).then(result => {
150
195
  this.#lockClient = result;
151
196
  return result;
152
197
  }, (err) => {
@@ -160,9 +205,11 @@ export class PgTestHelpers {
160
205
 
161
206
  /** @returns {Promise<void>} */
162
207
  async #releaseLocked () {
163
- if (this.#lockClient) {
164
- await releaseLock(this.#lockClient);
208
+ const lockClient = this.#lockClient;
209
+
210
+ if (lockClient) {
165
211
  this.#lockClient = undefined;
212
+ await releaseLock(lockClient, this.#lockId);
166
213
  }
167
214
  }
168
215
 
@@ -183,6 +230,7 @@ export class PgTestHelpers {
183
230
 
184
231
  return pgInstallSchemaFromString(createUmzeptionPgContext(this.#pool), schema);
185
232
  } catch (cause) {
233
+ try { await this.#releaseLocked(); } catch {}
186
234
  throw new Error('Failed to create tables', { cause });
187
235
  }
188
236
  }
@@ -204,25 +252,58 @@ export class PgTestHelpers {
204
252
  options = { tablesWithDependencies: this.#tablesWithDependencies.flat() };
205
253
  }
206
254
 
207
- return csvFromFolderToDb(this.#pool, this.#fixtureFolder, options);
255
+ try {
256
+ await csvFromFolderToDb(this.#pool, this.#fixtureFolder, options);
257
+ } catch (cause) {
258
+ try { await this.#releaseLocked(); } catch {}
259
+ throw new Error('Failed to import fixtures', { cause });
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Convenience method for the standard test setup sequence: remove existing
265
+ * tables, create the schema, and optionally load fixtures (when `fixtureFolder`
266
+ * is configured). Returns `this` so the result can be used with `await using`.
267
+ *
268
+ * @returns {Promise<this>}
269
+ */
270
+ async setup () {
271
+ try {
272
+ await this.removeTables();
273
+ await this.initTables();
274
+
275
+ if (this.#fixtureFolder) {
276
+ await this.insertFixtures();
277
+ }
278
+ } catch (cause) {
279
+ try { await this.end(); } catch {}
280
+ throw cause;
281
+ }
282
+
283
+ return this;
208
284
  }
209
285
 
210
286
  /** @returns {Promise<void>} */
211
287
  async removeTables () {
212
288
  await this.#ensureLocked();
213
289
 
214
- if (this.#tablesWithDependencies) {
215
- await this.#removeTablesByName(this.#tablesWithDependencies);
216
- }
290
+ try {
291
+ if (this.#tablesWithDependencies) {
292
+ await this.#removeTablesByName(this.#tablesWithDependencies);
293
+ }
217
294
 
218
- let tableNames = await this.#getTableNames();
219
- const ignoreTables = this.#ignoreTables;
295
+ let tableNames = await this.#getTableNames();
296
+ const ignoreTables = this.#ignoreTables;
220
297
 
221
- if (ignoreTables) {
222
- tableNames = tableNames.filter(name => !ignoreTables.includes(name));
223
- }
298
+ if (ignoreTables) {
299
+ tableNames = tableNames.filter(name => !ignoreTables.includes(name));
300
+ }
224
301
 
225
- await this.#removeTablesByName(tableNames);
302
+ await this.#removeTablesByName(tableNames);
303
+ } catch (cause) {
304
+ try { await this.#releaseLocked(); } catch {}
305
+ throw new Error('Failed to remove tables', { cause });
306
+ }
226
307
  }
227
308
 
228
309
  /** @returns {Promise<void>} */
@@ -230,7 +311,51 @@ export class PgTestHelpers {
230
311
  if (this.#ended) return;
231
312
 
232
313
  this.#ended = true;
233
- await this.#releaseLocked();
234
- await this.#pool.end();
314
+
315
+ try {
316
+ await this.#releaseLocked();
317
+ } finally {
318
+ await this.#pool.end();
319
+ }
235
320
  }
321
+
322
+ /** @returns {Promise<void>} */
323
+ async [Symbol.asyncDispose] () {
324
+ await this.end();
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Create and set up a {@link PgTestHelpers} instance for use with `await using`.
330
+ * Cleanup happens automatically via `Symbol.asyncDispose` on scope exit.
331
+ *
332
+ * @param {PgTestHelpersOptions} options
333
+ * @returns {Promise<PgTestHelpers>}
334
+ */
335
+ export async function pgTestSetup (options) {
336
+ return new PgTestHelpers(options).setup();
337
+ }
338
+
339
+ /**
340
+ * Create and set up a {@link PgTestHelpers} instance with cleanup registered
341
+ * on a test context via `t.after()`. No `await using` or `afterEach` needed.
342
+ *
343
+ * Useful in `node:test` `it()` bodies and `beforeEach()` hooks where `t`
344
+ * provides the test context. The `t` parameter uses duck-typing with `after`
345
+ * optional so it accepts node:test's `TestContext | SuiteContext` union from
346
+ * `beforeEach` hooks. At runtime, `beforeEach` always passes `TestContext`
347
+ * (which has `after`). A clear error is thrown if `after` is missing.
348
+ *
349
+ * @param {PgTestHelpersOptions} options
350
+ * @param {{ after?: (fn: () => Promise<void>) => void }} t Test context.
351
+ * @returns {Promise<PgTestHelpers>}
352
+ */
353
+ export async function pgTestSetupFor (options, t) {
354
+ if (typeof t?.after !== 'function') {
355
+ throw new TypeError('pgTestSetupFor requires a test context with an after() method');
356
+ }
357
+
358
+ const helpers = await new PgTestHelpers(options).setup();
359
+ t.after(() => helpers.end());
360
+ return helpers;
236
361
  }
package/lib/utils.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  export function createPgPool(connectionString: string): Pool;
2
- export function createAndLockConnection(connectionString: string): Promise<Client>;
3
- export function releaseLock(lockClient: Client | Promise<Client>): Promise<void>;
2
+ export function createAndLockConnection(connectionString: string, options?: {
3
+ lockId?: number;
4
+ lockTimeoutMs?: number;
5
+ }): Promise<Client>;
6
+ export function releaseLock(lockClient: Client | Promise<Client>, lockId?: number): Promise<void>;
4
7
  export function isStringArray(value: unknown): value is string[];
5
8
  export class TypeNeverError extends TypeError {
6
9
  constructor(value: never, message: string, options?: ErrorOptions);
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["utils.js"],"names":[],"mappings":"AAqBA,+CAHW,MAAM,GACJ,IAAI,CAOhB;AAMD,0DAHW,MAAM,GACJ,OAAO,CAAC,MAAM,CAAC,CAqB3B;AAMD,wCAHW,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,GACtB,OAAO,CAAC,IAAI,CAAC,CAUzB;AA4BD,qCAHW,OAAO,GACL,KAAK,IAAI,MAAM,EAAE,CAK7B;AA5BD;IAME,mBAJW,KAAK,WACL,MAAM,YACN,YAAY,EAItB;CACF;0BA3EiC,IAAI;4BAAJ,IAAI"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["utils.js"],"names":[],"mappings":"AAuBA,+CAHW,MAAM,GACJ,IAAI,CAOhB;AAOD,0DAJW,MAAM,YACN;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,CAAA;CAAE,GACzC,OAAO,CAAC,MAAM,CAAC,CAwC3B;AAOD,wCAJW,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,WACxB,MAAM,GACJ,OAAO,CAAC,IAAI,CAAC,CAUzB;AA4BD,qCAHW,OAAO,GACL,KAAK,IAAI,MAAM,EAAE,CAK7B;AA5BD;IAME,mBAJW,KAAK,WACL,MAAM,YACN,YAAY,EAItB;CACF;0BAlGiC,IAAI;4BAAJ,IAAI"}
package/lib/utils.js CHANGED
@@ -3,12 +3,14 @@ import pg from 'pg';
3
3
  /** @import { Pool, Client } from 'pg' */
4
4
 
5
5
  /**
6
- * Fixed advisory lock ID used for database locking (test isolation) during test runs.
7
- * By using a fixed ID (42), we serialize operations that
6
+ * Default advisory lock ID used for database locking (test isolation) during test runs.
7
+ * By using a fixed default ID (42), we serialize operations that
8
8
  * must not run concurrently (such as schema migrations or fixture loading), preventing race
9
9
  * conditions and deadlocks in test environments.
10
10
  *
11
- * The value 42 is arbitrary but must be consistent across all test helpers using the same database.
11
+ * This can be overridden via the `lockId` option in {@link createAndLockConnection} or
12
+ * `PgTestHelpersOptions` to avoid contention in parallel test runners (advisory locks are
13
+ * cluster-scoped, not database-scoped).
12
14
  *
13
15
  * @see {@link https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS|PostgreSQL Advisory Lock Functions}
14
16
  * @see {@link https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS|Advisory Locks Overview}
@@ -28,9 +30,19 @@ export function createPgPool (connectionString) {
28
30
 
29
31
  /**
30
32
  * @param {string} connectionString
33
+ * @param {{ lockId?: number, lockTimeoutMs?: number }} [options]
31
34
  * @returns {Promise<Client>}
32
35
  */
33
- export async function createAndLockConnection (connectionString) {
36
+ export async function createAndLockConnection (connectionString, options = {}) {
37
+ const { lockId = LOCK_ID, lockTimeoutMs } = options;
38
+
39
+ if (lockId !== undefined && (typeof lockId !== 'number' || !Number.isSafeInteger(lockId))) {
40
+ throw new TypeError('Invalid lockId, expected a safe integer');
41
+ }
42
+ if (lockTimeoutMs !== undefined && (typeof lockTimeoutMs !== 'number' || !Number.isSafeInteger(lockTimeoutMs) || lockTimeoutMs < 0)) {
43
+ throw new TypeError('Invalid lockTimeoutMs, expected a non-negative safe integer');
44
+ }
45
+
34
46
  const client = new pg.Client({
35
47
  connectionString,
36
48
  });
@@ -40,7 +52,10 @@ export async function createAndLockConnection (connectionString) {
40
52
  try {
41
53
  await client.connect();
42
54
  connected = true;
43
- await client.query('SELECT pg_advisory_lock($1)', [LOCK_ID]);
55
+ if (lockTimeoutMs !== undefined) {
56
+ await client.query(`SET lock_timeout = ${lockTimeoutMs}`);
57
+ }
58
+ await client.query('SELECT pg_advisory_lock($1)', [lockId]);
44
59
  } catch (cause) {
45
60
  if (connected) {
46
61
  await client.end();
@@ -48,18 +63,26 @@ export async function createAndLockConnection (connectionString) {
48
63
  throw new Error('Failed to acquire database lock', { cause });
49
64
  }
50
65
 
66
+ // Prevent uncaught 'error' event crash if the connection drops while holding
67
+ // the lock. PostgreSQL auto-releases session-level locks on disconnect, so
68
+ // the lock is already lost — end() will handle pool cleanup. Operations use
69
+ // the pool (not the lock client), so they continue to work; only the
70
+ // serialization guarantee is gone.
71
+ client.on('error', () => {});
72
+
51
73
  return client;
52
74
  }
53
75
 
54
76
  /**
55
77
  * @param {Client | Promise<Client>} lockClient
78
+ * @param {number} [lockId]
56
79
  * @returns {Promise<void>}
57
80
  */
58
- export async function releaseLock (lockClient) {
81
+ export async function releaseLock (lockClient, lockId = LOCK_ID) {
59
82
  const client = await lockClient;
60
83
 
61
84
  try {
62
- await client.query('SELECT pg_advisory_unlock($1)', [LOCK_ID]);
85
+ await client.query('SELECT pg_advisory_unlock($1)', [lockId]);
63
86
  } finally {
64
87
  await client.end();
65
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voxpelli/pg-utils",
3
- "version": "3.1.0",
3
+ "version": "4.0.0",
4
4
  "description": " My personal database utils / helpers for Postgres",
5
5
  "homepage": "http://github.com/voxpelli/pg-utils",
6
6
  "repository": {
@@ -11,7 +11,7 @@
11
11
  "author": "Pelle Wessman <pelle@kodfabrik.se> (http://kodfabrik.se/)",
12
12
  "license": "MIT",
13
13
  "engines": {
14
- "node": "^20.9.0 || >=22.0.0"
14
+ "node": ">=22.0.0"
15
15
  },
16
16
  "type": "module",
17
17
  "exports": "./index.js",
@@ -48,7 +48,7 @@
48
48
  "@types/chai": "^4.3.20",
49
49
  "@types/chai-as-promised": "^7.1.8",
50
50
  "@types/mocha": "^10.0.10",
51
- "@types/node": "^20.19.33",
51
+ "@types/node": "^22.19.15",
52
52
  "@types/pg-copy-streams": "^1.2.5",
53
53
  "@types/sinon": "^21.0.0",
54
54
  "@voxpelli/eslint-config": "^23.0.0",
@@ -57,13 +57,14 @@
57
57
  "chai": "^4.5.0",
58
58
  "chai-as-promised": "^7.1.2",
59
59
  "dotenv": "^17.3.1",
60
- "eslint": "^9.39.2",
60
+ "eslint": "^9.39.4",
61
61
  "husky": "^9.1.7",
62
- "installed-check": "^10.0.0",
63
- "knip": "^5.84.0",
62
+ "installed-check": "^10.0.1",
63
+ "knip": "^5.86.0",
64
64
  "mocha": "^11.7.5",
65
65
  "npm-run-all2": "^8.0.4",
66
- "sinon": "^21.0.1",
66
+ "pony-cause": "^2.1.11",
67
+ "sinon": "^21.0.2",
67
68
  "type-coverage": "^2.29.7",
68
69
  "typescript": "~5.9.3",
69
70
  "validate-conventional-commit": "^1.0.4"