@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 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 | _ – a connection string for the postgres database
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;IAeE,qBADY,oBAAoB,EAsC/B;IAxCD,cADW,IAAI,CAAC,OAAO,CAAC,CACX;IAyEb,cADc,OAAO,CAAC,IAAI,CAAC,CAiB1B;IAGD,kBADc,OAAO,CAAC,IAAI,CAAC,CAM1B;IAGD,gBADc,OAAO,CAAC,IAAI,CAAC,CAc1B;IAGD,OADc,OAAO,CAAC,IAAI,CAAC,CAG1B;;CACF;;sBA1Ia,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;;0BARZ,IAAI"}
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"}
@@ -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): import("pg").Pool;
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
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["utils.js"],"names":[],"mappings":"AAMA,+CAHW,MAAM,GACJ,OAAO,IAAI,EAAE,IAAI,CAO7B;AA4BD,qCAHW,OAAO,GACL,KAAK,IAAI,MAAM,EAAE,CAK7B;AA5BD;IAME,mBAJW,KAAK,WACL,MAAM,YACN,YAAY,EAItB;CACF"}
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 {import('pg').Pool}
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": "2.3.0",
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
- "prepare": "husky",
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.17.31",
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": "^15.1.2",
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": "^16.5.0",
58
- "eslint": "^9.25.1",
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.50.5",
62
- "mocha": "^11.1.0",
63
- "npm-run-all2": "^7.0.2",
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.8.3",
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": "^6.0.6",
74
+ "pg-copy-streams": "^7.0.0",
72
75
  "umzeption": "^0.4.1",
73
76
  "umzug": "^3.8.2"
74
77
  }