@voxpelli/pg-utils 3.1.1 → 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 +196 -26
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/lib/csv-folder-to-db.d.ts.map +1 -1
- package/lib/csv-folder-to-db.js +18 -13
- package/lib/db-to-csv-folder.d.ts.map +1 -1
- package/lib/db-to-csv-folder.js +31 -9
- package/lib/test-helpers.d.ts +10 -0
- package/lib/test-helpers.d.ts.map +1 -1
- package/lib/test-helpers.js +122 -8
- package/lib/utils.d.ts +5 -2
- package/lib/utils.d.ts.map +1 -1
- package/lib/utils.js +30 -7
- package/package.json +4 -4
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
|
-
* `
|
|
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`_ –
|
|
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 +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,
|
|
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"}
|
package/lib/csv-folder-to-db.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
131
|
+
const csvContent = createReadStream(file);
|
|
130
132
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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,
|
|
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"}
|
package/lib/db-to-csv-folder.js
CHANGED
|
@@ -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
|
|
50
|
+
const escapedOrderBy = isColumnIndex ? orderBy : pg.escapeIdentifier(orderBy);
|
|
40
51
|
|
|
41
|
-
const
|
|
52
|
+
const ownPool = typeof connection !== 'object' || !('connect' in connection);
|
|
53
|
+
const pool = ownPool ? createPgPool(connection) : connection;
|
|
42
54
|
|
|
43
55
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
75
|
+
if (ownPool) await pool.end();
|
|
54
76
|
}
|
|
55
77
|
}
|
package/lib/test-helpers.d.ts
CHANGED
|
@@ -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":"
|
|
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"}
|
package/lib/test-helpers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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,7 +230,7 @@ export class PgTestHelpers {
|
|
|
183
230
|
|
|
184
231
|
return pgInstallSchemaFromString(createUmzeptionPgContext(this.#pool), schema);
|
|
185
232
|
} catch (cause) {
|
|
186
|
-
await this.#releaseLocked();
|
|
233
|
+
try { await this.#releaseLocked(); } catch {}
|
|
187
234
|
throw new Error('Failed to create tables', { cause });
|
|
188
235
|
}
|
|
189
236
|
}
|
|
@@ -208,11 +255,34 @@ export class PgTestHelpers {
|
|
|
208
255
|
try {
|
|
209
256
|
await csvFromFolderToDb(this.#pool, this.#fixtureFolder, options);
|
|
210
257
|
} catch (cause) {
|
|
211
|
-
await this.#releaseLocked();
|
|
258
|
+
try { await this.#releaseLocked(); } catch {}
|
|
212
259
|
throw new Error('Failed to import fixtures', { cause });
|
|
213
260
|
}
|
|
214
261
|
}
|
|
215
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;
|
|
284
|
+
}
|
|
285
|
+
|
|
216
286
|
/** @returns {Promise<void>} */
|
|
217
287
|
async removeTables () {
|
|
218
288
|
await this.#ensureLocked();
|
|
@@ -231,7 +301,7 @@ export class PgTestHelpers {
|
|
|
231
301
|
|
|
232
302
|
await this.#removeTablesByName(tableNames);
|
|
233
303
|
} catch (cause) {
|
|
234
|
-
await this.#releaseLocked();
|
|
304
|
+
try { await this.#releaseLocked(); } catch {}
|
|
235
305
|
throw new Error('Failed to remove tables', { cause });
|
|
236
306
|
}
|
|
237
307
|
}
|
|
@@ -241,7 +311,51 @@ export class PgTestHelpers {
|
|
|
241
311
|
if (this.#ended) return;
|
|
242
312
|
|
|
243
313
|
this.#ended = true;
|
|
244
|
-
|
|
245
|
-
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
await this.#releaseLocked();
|
|
317
|
+
} finally {
|
|
318
|
+
await this.#pool.end();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** @returns {Promise<void>} */
|
|
323
|
+
async [Symbol.asyncDispose] () {
|
|
324
|
+
await this.end();
|
|
246
325
|
}
|
|
247
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;
|
|
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
|
|
3
|
-
|
|
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);
|
package/lib/utils.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["utils.js"],"names":[],"mappings":"
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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)', [
|
|
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
|
+
"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": "
|
|
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": "^
|
|
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,7 +64,7 @@
|
|
|
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.
|
|
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"
|