@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 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;IA0Eb,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;;sBA3Ia,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;
@@ -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): 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.1",
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": {