@voxpelli/pg-utils 3.1.1 → 4.1.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,5 @@
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 type PgTestHelpersOptions = import("./lib/test-helpers.js").PgTestHelpersOptions;
4
+ export { PgTestHelpers, pgTestSetup, pgTestSetupFor } from "./lib/test-helpers.js";
4
5
  //# sourceMappingURL=index.d.ts.map
package/index.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.js"],"names":[],"mappings":""}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.js"],"names":[],"mappings":";;mCAIc,OAAO,uBAAuB,EAAE,oBAAoB"}
package/index.js CHANGED
@@ -1,3 +1,5 @@
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
+
5
+ /** @typedef {import('./lib/test-helpers.js').PgTestHelpersOptions} PgTestHelpersOptions */
@@ -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":"AAmHA,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"}
@@ -4,6 +4,7 @@ import pathModule from 'node:path';
4
4
  import { pipeline as promisedPipeline } from 'node:stream/promises';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
+ import { isObjectWithKey } from '@voxpelli/typed-utils';
7
8
  import { from as copyFrom } from 'pg-copy-streams';
8
9
  import pg from 'pg';
9
10
 
@@ -116,25 +117,30 @@ export async function csvFromFolderToDb (connection, path, options) {
116
117
  const tablesWithDependencies = resolveTableOrder(options);
117
118
  const files = await getFilesOrderedByDependencies(path, tablesWithDependencies);
118
119
 
119
- const pool = (typeof connection === 'object' && 'connect' in connection) ? connection : createPgPool(connection);
120
-
121
- const client = await pool.connect();
120
+ const ownPool = !isObjectWithKey(connection, 'connect');
121
+ const pool = ownPool ? createPgPool(connection) : connection;
122
122
 
123
123
  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)`));
124
+ const client = await pool.connect();
125
+
126
+ try {
127
+ for (const file of files) {
128
+ const name = pathModule.basename(file, '.csv');
129
+ const dbCopy = client.query(copyFrom(`COPY ${pg.escapeIdentifier(name)} FROM STDIN WITH (FORMAT csv, HEADER MATCH)`));
127
130
 
128
- // eslint-disable-next-line security/detect-non-literal-fs-filename
129
- const csvContent = createReadStream(file);
131
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
132
+ const csvContent = createReadStream(file);
130
133
 
131
- try {
132
- await promisedPipeline(csvContent, dbCopy);
133
- } catch (cause) {
134
- throw new Error(`Failed inserting data into "${name}"`, { cause });
134
+ try {
135
+ await promisedPipeline(csvContent, dbCopy);
136
+ } catch (cause) {
137
+ throw new Error(`Failed inserting data into "${name}"`, { cause });
138
+ }
135
139
  }
140
+ } finally {
141
+ client.release();
136
142
  }
137
143
  } finally {
138
- client.release();
144
+ if (ownPool) await pool.end();
139
145
  }
140
146
  }
@@ -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":"AA+BA,0CANW,MAAM,GAAG,IAAI,cACb,MAAM,GAAG,GAAG,UACZ,MAAM,EAAE,YACR,oBAAoB,GAClB,OAAO,CAAC,IAAI,CAAC,CAgDzB;;cA7Da,MAAM;;0BAJM,IAAI"}
@@ -4,6 +4,7 @@ import pathModule from 'node:path';
4
4
  import { pipeline } from 'node:stream/promises';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
+ import { isObjectWithKey } from '@voxpelli/typed-utils';
7
8
  import pg from 'pg';
8
9
  import { to as copyTo } from 'pg-copy-streams';
9
10
 
@@ -31,25 +32,47 @@ import { createPgPool } from './utils.js';
31
32
  export async function dbToCsvFolder (connection, outputPath, tables, options = {}) {
32
33
  const { orderBy = '1' } = options;
33
34
 
35
+ // Security: orderBy is interpolated into SQL — validate to prevent injection
36
+ const isColumnIndex = /^[1-9]\d*$/.test(orderBy);
37
+ const isIdentifier = /^[a-z_]\w*$/i.test(orderBy);
38
+
39
+ if (!isColumnIndex && !isIdentifier) {
40
+ throw new Error(
41
+ `Invalid orderBy value: ${JSON.stringify(orderBy)}. ` +
42
+ 'Must be a positive column index (e.g. "1") or a simple identifier (e.g. "created_at").'
43
+ );
44
+ }
45
+
34
46
  const dirPath = typeof outputPath === 'string' ? outputPath : fileURLToPath(outputPath);
35
47
 
36
48
  // eslint-disable-next-line security/detect-non-literal-fs-filename
37
49
  await mkdir(dirPath, { recursive: true });
38
50
 
39
- const pool = (typeof connection === 'object' && 'connect' in connection) ? connection : createPgPool(connection);
51
+ const escapedOrderBy = isColumnIndex ? orderBy : pg.escapeIdentifier(orderBy);
40
52
 
41
- const client = await pool.connect();
53
+ const ownPool = !isObjectWithKey(connection, 'connect');
54
+ const pool = ownPool ? createPgPool(connection) : connection;
42
55
 
43
56
  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`);
57
+ const client = await pool.connect();
58
+
59
+ try {
60
+ for (const table of tables) {
61
+ const sql = `COPY (SELECT * FROM ${pg.escapeIdentifier(table)} ORDER BY ${escapedOrderBy}) TO STDOUT WITH (FORMAT csv, HEADER true)`;
62
+ const copyToStream = client.query(copyTo(sql));
63
+ const filePath = pathModule.join(dirPath, `${table}.csv`);
48
64
 
49
- // eslint-disable-next-line security/detect-non-literal-fs-filename
50
- await pipeline(copyToStream, createWriteStream(filePath));
65
+ try {
66
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
67
+ await pipeline(copyToStream, createWriteStream(filePath));
68
+ } catch (cause) {
69
+ throw new Error(`Failed exporting data from "${table}"`, { cause });
70
+ }
71
+ }
72
+ } finally {
73
+ client.release();
51
74
  }
52
75
  } finally {
53
- client.release();
76
+ if (ownPool) await pool.end();
54
77
  }
55
78
  }
@@ -1,19 +1,31 @@
1
+ export function pgTestSetup(options: PgTestHelpersOptions): Promise<PgTestHelpers>;
2
+ export function pgTestSetupFor(options: PgTestHelpersOptions, t: TestContext | SuiteContext | {
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>;
17
27
  };
28
+ import type { TestContext } from 'node:test';
29
+ import type { SuiteContext } from 'node:test';
18
30
  import type { Pool } from 'pg';
19
31
  //# sourceMappingURL=test-helpers.d.ts.map
@@ -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,CAoB1B;IAGD,kBADc,OAAO,CAAC,IAAI,CAAC,CAuB1B;IAGD,gBADc,OAAO,CAAC,IAAI,CAAC,CAqB1B;IAGD,OADc,OAAO,CAAC,IAAI,CAAC,CAO1B;;CACF;;sBAvOa,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":"AAgVA,qCAHW,oBAAoB,GAClB,OAAO,CAAC,aAAa,CAAC,CAIlC;AAgBD,wCAJW,oBAAoB,KACpB,WAAW,GAAG,YAAY,GAAG;IAAE,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,KAAK,IAAI,CAAA;CAAE,GACxE,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;;iCAbS,WAAW;kCAAX,WAAW;0BADxB,IAAI"}
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
 
3
+ import { isKeyWithType, isStringArray } from '@voxpelli/typed-utils';
3
4
  import pg from 'pg';
4
5
  import {
5
6
  createUmzeptionPgContext,
@@ -7,15 +8,20 @@ import {
7
8
  } from 'umzeption';
8
9
 
9
10
  import { csvFromFolderToDb } from './csv-folder-to-db.js';
10
- import { createPgPool, createAndLockConnection, releaseLock, isStringArray, TypeNeverError } from './utils.js';
11
+ import { createPgPool, createAndLockConnection, releaseLock, TypeNeverError } from './utils.js';
11
12
 
12
13
  /** @import { Pool, Client } from 'pg' */
14
+ /** @import { TestContext, SuiteContext } from 'node:test' */
13
15
 
14
16
  /**
15
17
  * @typedef PgTestHelpersOptions
16
18
  * @property {string} connectionString
17
19
  * @property {string | URL} [fixtureFolder]
18
20
  * @property {string[]} [ignoreTables]
21
+ * @property {number} [lockId] Advisory lock ID. Default: 42. Use unique IDs per test file for parallel runners (advisory locks are cluster-scoped).
22
+ * @property {number} [lockTimeoutMs] Lock acquisition timeout in milliseconds. No default (waits indefinitely).
23
+ * @property {number} [statementTimeoutMs] Per-statement query timeout in milliseconds on the pool. No default.
24
+ * @property {number} [idleInTransactionTimeoutMs] Idle-in-transaction timeout in milliseconds. No default.
19
25
  * @property {string | URL | ((pool: Pool) => import('umzug').Umzug<import('umzeption').UmzeptionContext<'pg', import('umzeption').FastifyPostgresStyleDb>>)} schema
20
26
  * @property {Array<string[] | string>} [tableLoadOrder] Tables in parent-first insertion order. First item loaded first, dropped last. Mutually exclusive with `tablesWithDependencies`.
21
27
  * @property {Array<string[] | string>} [tablesWithDependencies] Deprecated: use `tableLoadOrder` instead. Tables in leaf-first deletion order. First item dropped first, loaded last.
@@ -30,6 +36,10 @@ export class PgTestHelpers {
30
36
  #fixtureFolder;
31
37
  /** @type {string[] | undefined} */
32
38
  #ignoreTables;
39
+ /** @type {number} */
40
+ #lockId;
41
+ /** @type {number | undefined} */
42
+ #lockTimeoutMs;
33
43
  /** @type {Pool} */
34
44
  #pool;
35
45
  /** @type {Promise<Client> | Client | undefined} */
@@ -52,8 +62,12 @@ export class PgTestHelpers {
52
62
  const {
53
63
  connectionString,
54
64
  fixtureFolder,
65
+ idleInTransactionTimeoutMs,
55
66
  ignoreTables,
67
+ lockId = 42,
68
+ lockTimeoutMs,
56
69
  schema,
70
+ statementTimeoutMs,
57
71
  tableLoadOrder,
58
72
  tablesWithDependencies,
59
73
  } = options;
@@ -79,9 +93,37 @@ export class PgTestHelpers {
79
93
  if (tablesWithDependencies && !Array.isArray(tablesWithDependencies)) {
80
94
  throw new TypeNeverError(tablesWithDependencies, 'Invalid tablesWithDependencies, expected an array');
81
95
  }
96
+ if (lockId !== undefined && (typeof lockId !== 'number' || !Number.isSafeInteger(lockId))) {
97
+ throw new TypeError('Invalid lockId, expected a safe integer');
98
+ }
99
+ if (lockTimeoutMs !== undefined && (typeof lockTimeoutMs !== 'number' || !Number.isSafeInteger(lockTimeoutMs) || lockTimeoutMs < 0)) {
100
+ throw new TypeError('Invalid lockTimeoutMs, expected a non-negative safe integer');
101
+ }
102
+ if (statementTimeoutMs !== undefined && (typeof statementTimeoutMs !== 'number' || !Number.isSafeInteger(statementTimeoutMs) || statementTimeoutMs < 0)) {
103
+ throw new TypeError('Invalid statementTimeoutMs, expected a non-negative safe integer');
104
+ }
105
+ if (idleInTransactionTimeoutMs !== undefined && (typeof idleInTransactionTimeoutMs !== 'number' || !Number.isSafeInteger(idleInTransactionTimeoutMs) || idleInTransactionTimeoutMs < 0)) {
106
+ throw new TypeError('Invalid idleInTransactionTimeoutMs, expected a non-negative safe integer');
107
+ }
82
108
 
83
109
  const pool = createPgPool(connectionString);
84
110
 
111
+ // Set pool-level timeouts via 'connect' event. pg.Client serializes queries
112
+ // internally, so the SET completes before any caller query even though the
113
+ // callback return value is not awaited by pg-pool.
114
+ if (statementTimeoutMs !== undefined) {
115
+ pool.on('connect', (/** @type {import('pg').PoolClient} */ client) => {
116
+ // eslint-disable-next-line promise/prefer-await-to-then
117
+ client.query(`SET statement_timeout = ${Number(statementTimeoutMs)}`).catch(() => {});
118
+ });
119
+ }
120
+ if (idleInTransactionTimeoutMs !== undefined) {
121
+ pool.on('connect', (/** @type {import('pg').PoolClient} */ client) => {
122
+ // eslint-disable-next-line promise/prefer-await-to-then
123
+ client.query(`SET idle_in_transaction_session_timeout = ${Number(idleInTransactionTimeoutMs)}`).catch(() => {});
124
+ });
125
+ }
126
+
85
127
  // tableLoadOrder is parent-first; reverse to get the leaf-first
86
128
  // order that internal methods expect (drop first item first, load last)
87
129
  this.#tablesWithDependencies = tableLoadOrder
@@ -92,6 +134,8 @@ export class PgTestHelpers {
92
134
  this.#connectionString = connectionString;
93
135
  this.#fixtureFolder = fixtureFolder;
94
136
  this.#ignoreTables = ignoreTables;
137
+ this.#lockId = lockId;
138
+ this.#lockTimeoutMs = lockTimeoutMs;
95
139
  this.#pool = pool;
96
140
  this.#schema = schema;
97
141
  this.queryPromise = pool.query.bind(pool);
@@ -145,8 +189,11 @@ export class PgTestHelpers {
145
189
  }
146
190
 
147
191
  if (!this.#lockClient) {
192
+ this.#lockClient = createAndLockConnection(this.#connectionString, {
193
+ lockId: this.#lockId,
194
+ ...this.#lockTimeoutMs !== undefined && { lockTimeoutMs: this.#lockTimeoutMs },
148
195
  // eslint-disable-next-line promise/prefer-await-to-then
149
- this.#lockClient = createAndLockConnection(this.#connectionString).then(result => {
196
+ }).then(result => {
150
197
  this.#lockClient = result;
151
198
  return result;
152
199
  }, (err) => {
@@ -160,9 +207,11 @@ export class PgTestHelpers {
160
207
 
161
208
  /** @returns {Promise<void>} */
162
209
  async #releaseLocked () {
163
- if (this.#lockClient) {
164
- await releaseLock(this.#lockClient);
210
+ const lockClient = this.#lockClient;
211
+
212
+ if (lockClient) {
165
213
  this.#lockClient = undefined;
214
+ await releaseLock(lockClient, this.#lockId);
166
215
  }
167
216
  }
168
217
 
@@ -183,7 +232,7 @@ export class PgTestHelpers {
183
232
 
184
233
  return pgInstallSchemaFromString(createUmzeptionPgContext(this.#pool), schema);
185
234
  } catch (cause) {
186
- await this.#releaseLocked();
235
+ try { await this.#releaseLocked(); } catch {}
187
236
  throw new Error('Failed to create tables', { cause });
188
237
  }
189
238
  }
@@ -208,11 +257,34 @@ export class PgTestHelpers {
208
257
  try {
209
258
  await csvFromFolderToDb(this.#pool, this.#fixtureFolder, options);
210
259
  } catch (cause) {
211
- await this.#releaseLocked();
260
+ try { await this.#releaseLocked(); } catch {}
212
261
  throw new Error('Failed to import fixtures', { cause });
213
262
  }
214
263
  }
215
264
 
265
+ /**
266
+ * Convenience method for the standard test setup sequence: remove existing
267
+ * tables, create the schema, and optionally load fixtures (when `fixtureFolder`
268
+ * is configured). Returns `this` so the result can be used with `await using`.
269
+ *
270
+ * @returns {Promise<this>}
271
+ */
272
+ async setup () {
273
+ try {
274
+ await this.removeTables();
275
+ await this.initTables();
276
+
277
+ if (this.#fixtureFolder) {
278
+ await this.insertFixtures();
279
+ }
280
+ } catch (cause) {
281
+ try { await this.end(); } catch {}
282
+ throw cause;
283
+ }
284
+
285
+ return this;
286
+ }
287
+
216
288
  /** @returns {Promise<void>} */
217
289
  async removeTables () {
218
290
  await this.#ensureLocked();
@@ -231,7 +303,7 @@ export class PgTestHelpers {
231
303
 
232
304
  await this.#removeTablesByName(tableNames);
233
305
  } catch (cause) {
234
- await this.#releaseLocked();
306
+ try { await this.#releaseLocked(); } catch {}
235
307
  throw new Error('Failed to remove tables', { cause });
236
308
  }
237
309
  }
@@ -241,7 +313,51 @@ export class PgTestHelpers {
241
313
  if (this.#ended) return;
242
314
 
243
315
  this.#ended = true;
244
- await this.#releaseLocked();
245
- await this.#pool.end();
316
+
317
+ try {
318
+ await this.#releaseLocked();
319
+ } finally {
320
+ await this.#pool.end();
321
+ }
322
+ }
323
+
324
+ /** @returns {Promise<void>} */
325
+ async [Symbol.asyncDispose] () {
326
+ await this.end();
246
327
  }
247
328
  }
329
+
330
+ /**
331
+ * Create and set up a {@link PgTestHelpers} instance for use with `await using`.
332
+ * Cleanup happens automatically via `Symbol.asyncDispose` on scope exit.
333
+ *
334
+ * @param {PgTestHelpersOptions} options
335
+ * @returns {Promise<PgTestHelpers>}
336
+ */
337
+ export async function pgTestSetup (options) {
338
+ return new PgTestHelpers(options).setup();
339
+ }
340
+
341
+ /**
342
+ * Create and set up a {@link PgTestHelpers} instance with cleanup registered
343
+ * on a test context via `t.after()`. No `await using` or `afterEach` needed.
344
+ *
345
+ * Useful in `node:test` `it()` bodies and `beforeEach()` hooks where `t`
346
+ * provides the test context. The `t` parameter uses duck-typing with `after`
347
+ * optional so it accepts node:test's `TestContext | SuiteContext` union from
348
+ * `beforeEach` hooks. At runtime, `beforeEach` always passes `TestContext`
349
+ * (which has `after`). A clear error is thrown if `after` is missing.
350
+ *
351
+ * @param {PgTestHelpersOptions} options
352
+ * @param {TestContext | SuiteContext | { after?: (fn: () => Promise<void>) => void }} t Test context.
353
+ * @returns {Promise<PgTestHelpers>}
354
+ */
355
+ export async function pgTestSetupFor (options, t) {
356
+ if (!isKeyWithType(t, 'after', 'function')) {
357
+ throw new TypeError('pgTestSetupFor requires a test context with an after() method');
358
+ }
359
+
360
+ const helpers = await new PgTestHelpers(options).setup();
361
+ t.after(() => helpers.end());
362
+ return helpers;
363
+ }
package/lib/utils.d.ts CHANGED
@@ -1,7 +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>;
4
- export function isStringArray(value: unknown): value is string[];
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>;
5
7
  export class TypeNeverError extends TypeError {
6
8
  constructor(value: never, message: string, options?: ErrorOptions);
7
9
  }
@@ -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;AAGD;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
  }
@@ -76,22 +99,3 @@ export class TypeNeverError extends TypeError {
76
99
  super(`${message}. Got: ${typeof value}`, options);
77
100
  }
78
101
  }
79
-
80
- /**
81
- * Array.isArray() on its own give type any[]
82
- *
83
- * @param {unknown} value
84
- * @returns {value is unknown[]}
85
- */
86
- function typesafeIsArray (value) {
87
- return Array.isArray(value);
88
- }
89
-
90
- /**
91
- * @param {unknown} value
92
- * @returns {value is string[]}
93
- */
94
- export function isStringArray (value) {
95
- if (!typesafeIsArray(value)) return false;
96
- return value.every(item => typeof item === 'string');
97
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voxpelli/pg-utils",
3
- "version": "3.1.1",
3
+ "version": "4.1.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.37",
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",
@@ -64,13 +64,14 @@
64
64
  "mocha": "^11.7.5",
65
65
  "npm-run-all2": "^8.0.4",
66
66
  "pony-cause": "^2.1.11",
67
- "sinon": "^21.0.1",
67
+ "sinon": "^21.0.2",
68
68
  "type-coverage": "^2.29.7",
69
69
  "typescript": "~5.9.3",
70
70
  "validate-conventional-commit": "^1.0.4"
71
71
  },
72
72
  "dependencies": {
73
73
  "@types/pg": "^8.16.0",
74
+ "@voxpelli/typed-utils": "^4.1.0",
74
75
  "pg": "^8.18.0",
75
76
  "pg-copy-streams": "^7.0.0",
76
77
  "umzeption": "^0.4.1",