@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 +196 -26
- package/index.d.ts +2 -1
- package/index.d.ts.map +1 -1
- package/index.js +3 -1
- package/lib/csv-folder-to-db.d.ts.map +1 -1
- package/lib/csv-folder-to-db.js +19 -13
- package/lib/db-to-csv-folder.d.ts.map +1 -1
- package/lib/db-to-csv-folder.js +32 -9
- package/lib/test-helpers.d.ts +12 -0
- package/lib/test-helpers.d.ts.map +1 -1
- package/lib/test-helpers.js +125 -9
- package/lib/utils.d.ts +5 -3
- package/lib/utils.d.ts.map +1 -1
- package/lib/utils.js +30 -26
- package/package.json +5 -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,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
|
|
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":"
|
|
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"}
|
package/lib/csv-folder-to-db.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
131
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
132
|
+
const csvContent = createReadStream(file);
|
|
130
133
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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":"
|
|
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"}
|
package/lib/db-to-csv-folder.js
CHANGED
|
@@ -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
|
|
51
|
+
const escapedOrderBy = isColumnIndex ? orderBy : pg.escapeIdentifier(orderBy);
|
|
40
52
|
|
|
41
|
-
const
|
|
53
|
+
const ownPool = !isObjectWithKey(connection, 'connect');
|
|
54
|
+
const pool = ownPool ? createPgPool(connection) : connection;
|
|
42
55
|
|
|
43
56
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
76
|
+
if (ownPool) await pool.end();
|
|
54
77
|
}
|
|
55
78
|
}
|
package/lib/test-helpers.d.ts
CHANGED
|
@@ -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":"
|
|
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"}
|
package/lib/test-helpers.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
|
3
|
-
|
|
4
|
-
|
|
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
|
}
|
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;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
|
-
*
|
|
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
|
}
|
|
@@ -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
|
+
"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": "
|
|
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,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.
|
|
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",
|