@voxpelli/pg-utils 2.3.0 → 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 +59 -5
- package/lib/utils.d.ts +5 -1
- package/lib/utils.d.ts.map +1 -1
- package/lib/utils.js +56 -1
- package/package.json +14 -11
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;
|
|
@@ -81,10 +88,11 @@ export class PgTestHelpers {
|
|
|
81
88
|
|
|
82
89
|
/**
|
|
83
90
|
* @param {Array<string[] | string>} tables
|
|
91
|
+
* @param {boolean} [allowParallelRemoval]
|
|
84
92
|
* @returns {Promise<void>}
|
|
85
93
|
*/
|
|
86
|
-
async #removeTablesByName (tables) {
|
|
87
|
-
if (isStringArray(tables)) {
|
|
94
|
+
async #removeTablesByName (tables, allowParallelRemoval = false) {
|
|
95
|
+
if (allowParallelRemoval && isStringArray(tables)) {
|
|
88
96
|
await Promise.all(
|
|
89
97
|
tables.map(name => this.queryPromise('DROP TABLE IF EXISTS ' + name + ' CASCADE'))
|
|
90
98
|
).catch(cause => {
|
|
@@ -94,7 +102,7 @@ export class PgTestHelpers {
|
|
|
94
102
|
for (const name of tables) {
|
|
95
103
|
await (
|
|
96
104
|
Array.isArray(name)
|
|
97
|
-
? this.#removeTablesByName(name)
|
|
105
|
+
? this.#removeTablesByName(name, true)
|
|
98
106
|
: this.queryPromise('DROP TABLE IF EXISTS ' + name + ' CASCADE').catch(cause => {
|
|
99
107
|
throw new Error(`Failed to drop table: ${name}`, { cause });
|
|
100
108
|
})
|
|
@@ -103,8 +111,45 @@ export class PgTestHelpers {
|
|
|
103
111
|
}
|
|
104
112
|
}
|
|
105
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
|
+
|
|
106
149
|
/** @returns {Promise<void>} */
|
|
107
150
|
async initTables () {
|
|
151
|
+
await this.#ensureLocked();
|
|
152
|
+
|
|
108
153
|
try {
|
|
109
154
|
if (typeof this.#schema === 'function') {
|
|
110
155
|
await this.#schema(this.#pool).up();
|
|
@@ -127,11 +172,16 @@ export class PgTestHelpers {
|
|
|
127
172
|
if (!this.#fixtureFolder) {
|
|
128
173
|
throw new Error('No fixture folder defined');
|
|
129
174
|
}
|
|
175
|
+
|
|
176
|
+
await this.#ensureLocked();
|
|
177
|
+
|
|
130
178
|
return csvFromFolderToDb(this.#pool, this.#fixtureFolder, this.#tablesWithDependencies?.flat());
|
|
131
179
|
}
|
|
132
180
|
|
|
133
181
|
/** @returns {Promise<void>} */
|
|
134
182
|
async removeTables () {
|
|
183
|
+
await this.#ensureLocked();
|
|
184
|
+
|
|
135
185
|
if (this.#tablesWithDependencies) {
|
|
136
186
|
await this.#removeTablesByName(this.#tablesWithDependencies);
|
|
137
187
|
}
|
|
@@ -148,6 +198,10 @@ export class PgTestHelpers {
|
|
|
148
198
|
|
|
149
199
|
/** @returns {Promise<void>} */
|
|
150
200
|
async end () {
|
|
201
|
+
if (this.#ended) return;
|
|
202
|
+
|
|
203
|
+
this.#ended = true;
|
|
204
|
+
await this.#releaseLocked();
|
|
151
205
|
await this.#pool.end();
|
|
152
206
|
}
|
|
153
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
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voxpelli/pg-utils",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": " My personal database utils / helpers for Postgres",
|
|
5
5
|
"homepage": "http://github.com/voxpelli/pg-utils",
|
|
6
6
|
"repository": {
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"clean:declarations-top": "rm -rf $(find . -maxdepth 1 -type f -name '*.d.ts*' ! -name 'index.d.ts')",
|
|
38
38
|
"clean:declarations-lib": "rm -rf $(find lib -type f -name '*.d.ts*' ! -name '*-types.d.ts')",
|
|
39
39
|
"clean": "run-p clean:*",
|
|
40
|
-
"
|
|
40
|
+
"husky-enable": "husky",
|
|
41
|
+
"husky-disable": "git config --unset core.hooksPath",
|
|
41
42
|
"prepublishOnly": "run-s build",
|
|
42
43
|
"test:mocha": "c8 --reporter=lcov --reporter=text mocha 'test/**/*.spec.js'",
|
|
43
44
|
"test-ci": "run-s test:*",
|
|
@@ -47,28 +48,30 @@
|
|
|
47
48
|
"@types/chai": "^4.3.20",
|
|
48
49
|
"@types/chai-as-promised": "^7.1.8",
|
|
49
50
|
"@types/mocha": "^10.0.10",
|
|
50
|
-
"@types/node": "^20.
|
|
51
|
+
"@types/node": "^20.19.30",
|
|
51
52
|
"@types/pg-copy-streams": "^1.2.5",
|
|
53
|
+
"@types/sinon": "^21.0.0",
|
|
52
54
|
"@voxpelli/eslint-config": "^23.0.0",
|
|
53
|
-
"@voxpelli/tsconfig": "^
|
|
55
|
+
"@voxpelli/tsconfig": "^16.1.0",
|
|
54
56
|
"c8": "^10.1.3",
|
|
55
57
|
"chai": "^4.5.0",
|
|
56
58
|
"chai-as-promised": "^7.1.2",
|
|
57
|
-
"dotenv": "^
|
|
58
|
-
"eslint": "^9.
|
|
59
|
+
"dotenv": "^17.2.3",
|
|
60
|
+
"eslint": "^9.39.2",
|
|
59
61
|
"husky": "^9.1.7",
|
|
60
62
|
"installed-check": "^9.3.0",
|
|
61
|
-
"knip": "^5.
|
|
62
|
-
"mocha": "^11.
|
|
63
|
-
"npm-run-all2": "^
|
|
63
|
+
"knip": "^5.82.1",
|
|
64
|
+
"mocha": "^11.7.5",
|
|
65
|
+
"npm-run-all2": "^8.0.4",
|
|
66
|
+
"sinon": "^21.0.1",
|
|
64
67
|
"type-coverage": "^2.29.7",
|
|
65
|
-
"typescript": "~5.
|
|
68
|
+
"typescript": "~5.9.3",
|
|
66
69
|
"validate-conventional-commit": "^1.0.4"
|
|
67
70
|
},
|
|
68
71
|
"dependencies": {
|
|
69
72
|
"@types/pg": "^8.11.10",
|
|
70
73
|
"pg": "^8.13.1",
|
|
71
|
-
"pg-copy-streams": "^
|
|
74
|
+
"pg-copy-streams": "^7.0.0",
|
|
72
75
|
"umzeption": "^0.4.1",
|
|
73
76
|
"umzug": "^3.8.2"
|
|
74
77
|
}
|