@voxpelli/pg-utils 2.3.1 → 3.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 +21 -4
- package/lib/test-helpers.d.ts.map +1 -1
- package/lib/test-helpers.js +55 -2
- package/lib/utils.d.ts +5 -1
- package/lib/utils.d.ts.map +1 -1
- package/lib/utils.js +56 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,6 +27,18 @@ const pgHelpers = new PgTestHelpers({
|
|
|
27
27
|
['foo', 'bar'],
|
|
28
28
|
]
|
|
29
29
|
});
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
// The helper automatically acquires an exclusive database lock
|
|
33
|
+
// on the first call to initTables(), insertFixtures(), or removeTables()
|
|
34
|
+
// to prevent concurrent access between tests during test operations.
|
|
35
|
+
await pgHelpers.initTables();
|
|
36
|
+
await pgHelpers.insertFixtures();
|
|
37
|
+
} finally {
|
|
38
|
+
// Always release the lock and close connections,
|
|
39
|
+
// even if a test or setup step throws an error
|
|
40
|
+
await pgHelpers.end();
|
|
41
|
+
}
|
|
30
42
|
```
|
|
31
43
|
|
|
32
44
|
## PgTestHelpers
|
|
@@ -55,7 +67,7 @@ new PgTestHelpers({
|
|
|
55
67
|
|
|
56
68
|
### PgTestHelpersOptions
|
|
57
69
|
|
|
58
|
-
* `connectionString` – _`string
|
|
70
|
+
* `connectionString` – _`string`_ – a connection string for the postgres database
|
|
59
71
|
* `fixtureFolder` – _`[string | URL]`_ – _optional_ – the path to a folder of `.csv`-file fixtures named by their respective table
|
|
60
72
|
* `ignoreTables` – _`[string[]]`_ – _optional_ – names of tables to ignore when dropping
|
|
61
73
|
* `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
|
|
@@ -63,9 +75,14 @@ new PgTestHelpers({
|
|
|
63
75
|
|
|
64
76
|
### Methods
|
|
65
77
|
|
|
66
|
-
* `initTables() => Promise<void>` – sets up all of the tables
|
|
67
|
-
* `insertFixtures() => Promise<void>` – inserts all the fixtures data into the tables (only usable if `fixtureFolder` has been set)
|
|
68
|
-
* `removeTables() => Promise<void>` – removes all of the tables (starting with `tablesWithDependencies`)
|
|
78
|
+
* `initTables() => Promise<void>` – sets up all of the tables. Automatically acquires an exclusive database lock on first call.
|
|
79
|
+
* `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.
|
|
80
|
+
* `removeTables() => Promise<void>` – removes all of the tables (starting with `tablesWithDependencies`). Automatically acquires an exclusive database lock on first call.
|
|
81
|
+
* `end() => Promise<void>` – releases the database lock (if acquired) and closes all database connections. **Always call this when done** to properly clean up resources.
|
|
82
|
+
|
|
83
|
+
#### Database Locking
|
|
84
|
+
|
|
85
|
+
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.
|
|
69
86
|
|
|
70
87
|
## csvFromFolderToDb()
|
|
71
88
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"test-helpers.d.ts","sourceRoot":"","sources":["test-helpers.js"],"names":[],"mappings":"AAqBA;
|
|
1
|
+
{"version":3,"file":"test-helpers.d.ts","sourceRoot":"","sources":["test-helpers.js"],"names":[],"mappings":"AAqBA;IAqBE,qBADY,oBAAoB,EAuC/B;IAzCD,cADW,IAAI,CAAC,OAAO,CAAC,CACX;IA8Gb,cADc,OAAO,CAAC,IAAI,CAAC,CAmB1B;IAGD,kBADc,OAAO,CAAC,IAAI,CAAC,CAS1B;IAGD,gBADc,OAAO,CAAC,IAAI,CAAC,CAgB1B;IAGD,OADc,OAAO,CAAC,IAAI,CAAC,CAO1B;;CACF;;sBAhMa,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;6BAC9I,KAAK,CAAC,MAAM,EAAE,GAAG,MAAM,CAAC;;0BARJ,IAAI"}
|
package/lib/test-helpers.js
CHANGED
|
@@ -6,9 +6,9 @@ import {
|
|
|
6
6
|
} from 'umzeption';
|
|
7
7
|
|
|
8
8
|
import { csvFromFolderToDb } from './csv-folder-to-db.js';
|
|
9
|
-
import { createPgPool, isStringArray, TypeNeverError } from './utils.js';
|
|
9
|
+
import { createPgPool, createAndLockConnection, releaseLock, isStringArray, TypeNeverError } from './utils.js';
|
|
10
10
|
|
|
11
|
-
/** @import { Pool } from 'pg' */
|
|
11
|
+
/** @import { Pool, Client } from 'pg' */
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* @typedef PgTestHelpersOptions
|
|
@@ -20,12 +20,18 @@ import { createPgPool, isStringArray, TypeNeverError } from './utils.js';
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
export class PgTestHelpers {
|
|
23
|
+
/** @type {string} */
|
|
24
|
+
#connectionString;
|
|
25
|
+
/** @type {boolean} */
|
|
26
|
+
#ended = false;
|
|
23
27
|
/** @type {string | URL | undefined} */
|
|
24
28
|
#fixtureFolder;
|
|
25
29
|
/** @type {string[] | undefined} */
|
|
26
30
|
#ignoreTables;
|
|
27
31
|
/** @type {Pool} */
|
|
28
32
|
#pool;
|
|
33
|
+
/** @type {Promise<Client> | Client | undefined} */
|
|
34
|
+
#lockClient;
|
|
29
35
|
/** @type {PgTestHelpersOptions['schema']} */
|
|
30
36
|
#schema;
|
|
31
37
|
/** @type {Array<string[] | string> | undefined} */
|
|
@@ -65,6 +71,7 @@ export class PgTestHelpers {
|
|
|
65
71
|
|
|
66
72
|
const pool = createPgPool(connectionString);
|
|
67
73
|
|
|
74
|
+
this.#connectionString = connectionString;
|
|
68
75
|
this.#fixtureFolder = fixtureFolder;
|
|
69
76
|
this.#ignoreTables = ignoreTables;
|
|
70
77
|
this.#tablesWithDependencies = tablesWithDependencies;
|
|
@@ -104,8 +111,45 @@ export class PgTestHelpers {
|
|
|
104
111
|
}
|
|
105
112
|
}
|
|
106
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Acquire an advisory lock used by this helper to serialize access with other
|
|
116
|
+
* code that uses the same locking mechanism.
|
|
117
|
+
*
|
|
118
|
+
* This does not block all other database operations: only code paths that
|
|
119
|
+
* acquire the same advisory lock ID via {@link createAndLockConnection} will
|
|
120
|
+
* be prevented from proceeding concurrently. The lock is held until
|
|
121
|
+
* {@link end} is called.
|
|
122
|
+
*
|
|
123
|
+
* @returns {Promise<Client>}
|
|
124
|
+
*/
|
|
125
|
+
async #ensureLocked () {
|
|
126
|
+
if (this.#ended) {
|
|
127
|
+
throw new Error('This PgTestHelpers instance has been ended through .end() and can not be used any more.');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!this.#lockClient) {
|
|
131
|
+
// eslint-disable-next-line promise/prefer-await-to-then
|
|
132
|
+
this.#lockClient = createAndLockConnection(this.#connectionString).then(result => {
|
|
133
|
+
this.#lockClient = result;
|
|
134
|
+
return result;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return this.#lockClient;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** @returns {Promise<void>} */
|
|
142
|
+
async #releaseLocked () {
|
|
143
|
+
if (this.#lockClient) {
|
|
144
|
+
await releaseLock(this.#lockClient);
|
|
145
|
+
this.#lockClient = undefined;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
107
149
|
/** @returns {Promise<void>} */
|
|
108
150
|
async initTables () {
|
|
151
|
+
await this.#ensureLocked();
|
|
152
|
+
|
|
109
153
|
try {
|
|
110
154
|
if (typeof this.#schema === 'function') {
|
|
111
155
|
await this.#schema(this.#pool).up();
|
|
@@ -128,11 +172,16 @@ export class PgTestHelpers {
|
|
|
128
172
|
if (!this.#fixtureFolder) {
|
|
129
173
|
throw new Error('No fixture folder defined');
|
|
130
174
|
}
|
|
175
|
+
|
|
176
|
+
await this.#ensureLocked();
|
|
177
|
+
|
|
131
178
|
return csvFromFolderToDb(this.#pool, this.#fixtureFolder, this.#tablesWithDependencies?.flat());
|
|
132
179
|
}
|
|
133
180
|
|
|
134
181
|
/** @returns {Promise<void>} */
|
|
135
182
|
async removeTables () {
|
|
183
|
+
await this.#ensureLocked();
|
|
184
|
+
|
|
136
185
|
if (this.#tablesWithDependencies) {
|
|
137
186
|
await this.#removeTablesByName(this.#tablesWithDependencies);
|
|
138
187
|
}
|
|
@@ -149,6 +198,10 @@ export class PgTestHelpers {
|
|
|
149
198
|
|
|
150
199
|
/** @returns {Promise<void>} */
|
|
151
200
|
async end () {
|
|
201
|
+
if (this.#ended) return;
|
|
202
|
+
|
|
203
|
+
this.#ended = true;
|
|
204
|
+
await this.#releaseLocked();
|
|
152
205
|
await this.#pool.end();
|
|
153
206
|
}
|
|
154
207
|
}
|
package/lib/utils.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
export function createPgPool(connectionString: string):
|
|
1
|
+
export function createPgPool(connectionString: string): Pool;
|
|
2
|
+
export function createAndLockConnection(connectionString: string): Promise<Client>;
|
|
3
|
+
export function releaseLock(lockClient: Client | Promise<Client>): Promise<void>;
|
|
2
4
|
export function isStringArray(value: unknown): value is string[];
|
|
3
5
|
export class TypeNeverError extends TypeError {
|
|
4
6
|
constructor(value: never, message: string, options?: ErrorOptions);
|
|
5
7
|
}
|
|
8
|
+
import type { Pool } from 'pg';
|
|
9
|
+
import type { Client } from 'pg';
|
|
6
10
|
//# sourceMappingURL=utils.d.ts.map
|
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":"AAsBA,+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;0BA5EiC,IAAI;4BAAJ,IAAI"}
|
package/lib/utils.js
CHANGED
|
@@ -1,8 +1,24 @@
|
|
|
1
1
|
import pg from 'pg';
|
|
2
2
|
|
|
3
|
+
/** @import { Pool, Client } from 'pg' */
|
|
4
|
+
|
|
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
|
|
8
|
+
* must not run concurrently (such as schema migrations or fixture loading), preventing race
|
|
9
|
+
* conditions and deadlocks in test environments.
|
|
10
|
+
*
|
|
11
|
+
* The value 42 is arbitrary but must be consistent across all test helpers using the same database.
|
|
12
|
+
*
|
|
13
|
+
* @see {@link https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS|PostgreSQL Advisory Lock Functions}
|
|
14
|
+
* @see {@link https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS|Advisory Locks Overview}
|
|
15
|
+
|
|
16
|
+
*/
|
|
17
|
+
const LOCK_ID = 42;
|
|
18
|
+
|
|
3
19
|
/**
|
|
4
20
|
* @param {string} connectionString
|
|
5
|
-
* @returns {
|
|
21
|
+
* @returns {Pool}
|
|
6
22
|
*/
|
|
7
23
|
export function createPgPool (connectionString) {
|
|
8
24
|
return new pg.Pool({
|
|
@@ -11,6 +27,45 @@ export function createPgPool (connectionString) {
|
|
|
11
27
|
});
|
|
12
28
|
}
|
|
13
29
|
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} connectionString
|
|
32
|
+
* @returns {Promise<Client>}
|
|
33
|
+
*/
|
|
34
|
+
export async function createAndLockConnection (connectionString) {
|
|
35
|
+
const client = new pg.Client({
|
|
36
|
+
connectionString,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
let connected = false;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await client.connect();
|
|
43
|
+
connected = true;
|
|
44
|
+
await client.query('SELECT pg_advisory_lock($1)', [LOCK_ID]);
|
|
45
|
+
} catch (cause) {
|
|
46
|
+
if (connected) {
|
|
47
|
+
await client.end();
|
|
48
|
+
}
|
|
49
|
+
throw new Error('Failed to acquire database lock', { cause });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return client;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {Client | Promise<Client>} lockClient
|
|
57
|
+
* @returns {Promise<void>}
|
|
58
|
+
*/
|
|
59
|
+
export async function releaseLock (lockClient) {
|
|
60
|
+
const client = await lockClient;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await client.query('SELECT pg_advisory_unlock($1)', [LOCK_ID]);
|
|
64
|
+
} finally {
|
|
65
|
+
await client.end();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
14
69
|
// TODO: Export to typed-utils maybe?
|
|
15
70
|
export class TypeNeverError extends TypeError {
|
|
16
71
|
/**
|