@devopsplaybook.io/common-utils 1.10.1-beta.23.b1906d7 → 1.11.0-beta.24.d8f9aef

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.
Files changed (69) hide show
  1. package/README.md +73 -27
  2. package/dist/src/ConfigBase.d.ts +31 -0
  3. package/dist/src/ConfigBase.js +58 -16
  4. package/dist/src/DbUtils.d.ts +20 -3
  5. package/dist/src/DbUtils.js +93 -2
  6. package/dist/src/DbUtilsNoTelemetry.d.ts +4 -1
  7. package/dist/src/DbUtilsNoTelemetry.js +62 -6
  8. package/dist/src/PostgresDbUtils.d.ts +41 -10
  9. package/dist/src/PostgresDbUtils.js +357 -304
  10. package/dist/src/SqlDbUtils.d.ts +12 -4
  11. package/dist/src/SqlDbUtils.js +76 -30
  12. package/dist/src/users/Auth.d.ts +11 -1
  13. package/dist/src/users/Auth.js +160 -44
  14. package/dist/src/users/User.d.ts +10 -0
  15. package/dist/src/users/User.js +30 -10
  16. package/dist/src/users/UserApiToken.d.ts +4 -0
  17. package/dist/src/users/UserApiToken.js +11 -0
  18. package/dist/src/users/UsersApiTokensData.d.ts +12 -0
  19. package/dist/src/users/UsersApiTokensData.js +130 -33
  20. package/dist/src/users/UsersData.d.ts +20 -1
  21. package/dist/src/users/UsersData.js +150 -52
  22. package/dist/src/users/UsersRoutes.js +178 -61
  23. package/dist/src/users/index.d.ts +8 -0
  24. package/dist/src/users/index.js +24 -0
  25. package/package.json +58 -1
  26. package/.github/workflows/main-build.yml +0 -18
  27. package/.github/workflows/pr-check.yml +0 -27
  28. package/.github/workflows/reusable-merge-build.yml +0 -197
  29. package/.github/workflows/reusable-npm-merge.yml +0 -135
  30. package/.github/workflows/reusable-npm-pr.yml +0 -183
  31. package/.github/workflows/reusable-npm-upgrade.yml +0 -92
  32. package/.github/workflows/reusable-pr-verify.yml +0 -181
  33. package/AGENTS.md +0 -105
  34. package/index.ts +0 -18
  35. package/jest.config.js +0 -17
  36. package/prettierrc.json +0 -5
  37. package/src/ConfigBase.spec.ts +0 -108
  38. package/src/ConfigBase.ts +0 -297
  39. package/src/DbUtils.spec.ts +0 -23
  40. package/src/DbUtils.ts +0 -116
  41. package/src/DbUtilsNoTelemetry.spec.ts +0 -168
  42. package/src/DbUtilsNoTelemetry.ts +0 -117
  43. package/src/LLM.spec.ts +0 -303
  44. package/src/LLM.ts +0 -204
  45. package/src/Notifications.spec.ts +0 -265
  46. package/src/Notifications.ts +0 -201
  47. package/src/OTelContext.spec.ts +0 -58
  48. package/src/OTelContext.ts +0 -63
  49. package/src/PostgresDbUtils.spec.ts +0 -153
  50. package/src/PostgresDbUtils.ts +0 -666
  51. package/src/SqlDbUtils.spec.ts +0 -108
  52. package/src/SqlDbUtils.ts +0 -152
  53. package/src/SystemCommand.spec.ts +0 -18
  54. package/src/SystemCommand.ts +0 -23
  55. package/src/Timeout.spec.ts +0 -18
  56. package/src/Timeout.ts +0 -12
  57. package/src/users/Auth.spec.ts +0 -268
  58. package/src/users/Auth.ts +0 -202
  59. package/src/users/User.ts +0 -75
  60. package/src/users/UserApiToken.ts +0 -55
  61. package/src/users/UserPassword.spec.ts +0 -28
  62. package/src/users/UserPassword.ts +0 -20
  63. package/src/users/UserSession.ts +0 -9
  64. package/src/users/UsersApiTokensData.spec.ts +0 -158
  65. package/src/users/UsersApiTokensData.ts +0 -125
  66. package/src/users/UsersData.ts +0 -141
  67. package/src/users/UsersRoutes.ts +0 -374
  68. package/tsconfig.json +0 -15
  69. package/tsconfig.spec.json +0 -8
@@ -33,9 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.PostgresSchemaDbUtils = void 0;
36
+ exports.PostgresSchemaDbUtils = exports.POSTGRES_LOCK_KEYS = void 0;
37
37
  exports.PostgresDbUtilsSetOTel = PostgresDbUtilsSetOTel;
38
38
  exports.PostgresDbUtilsInit = PostgresDbUtilsInit;
39
+ exports.PostgresDbUtilsWithAdvisoryLock = PostgresDbUtilsWithAdvisoryLock;
39
40
  exports.PostgresDbUtilsGetPool = PostgresDbUtilsGetPool;
40
41
  exports.PostgresDbUtilsExecSQL = PostgresDbUtilsExecSQL;
41
42
  exports.PostgresDbUtilsExecSQLFile = PostgresDbUtilsExecSQLFile;
@@ -45,6 +46,11 @@ exports.PostgresDbUtilsTransactionCommit = PostgresDbUtilsTransactionCommit;
45
46
  const pg_1 = require("pg");
46
47
  const fs = __importStar(require("fs-extra"));
47
48
  const api_1 = require("@opentelemetry/api");
49
+ exports.POSTGRES_LOCK_KEYS = {
50
+ migration: 0x6d696772, // "migr"
51
+ auth_token: 0x61757468, // "auth"
52
+ users_bootstrap: 0x75736572, // "user"
53
+ };
48
54
  // ---------------------------------------------------------------------------
49
55
  // Module-level state
50
56
  // ---------------------------------------------------------------------------
@@ -53,6 +59,115 @@ let tracer;
53
59
  let logger;
54
60
  let standardLogger;
55
61
  // ---------------------------------------------------------------------------
62
+ // Internal helpers
63
+ // ---------------------------------------------------------------------------
64
+ /** Build the connection options shared by every pool. */
65
+ function buildPoolConfig(config, searchPath, max, keepAlive) {
66
+ const poolConfig = {
67
+ host: config.DATABASE_POSTGRES_HOST,
68
+ port: config.DATABASE_POSTGRES_PORT || 5432,
69
+ user: config.DATABASE_POSTGRES_USER,
70
+ password: config.DATABASE_POSTGRES_PASSWORD,
71
+ database: config.DATABASE_POSTGRES_DATABASE,
72
+ max,
73
+ idleTimeoutMillis: 30000,
74
+ connectionTimeoutMillis: 10000,
75
+ keepAlive,
76
+ };
77
+ if (searchPath) {
78
+ poolConfig.options = `-c search_path=${searchPath}`;
79
+ }
80
+ if (config.DATABASE_POSTGRES_STATEMENT_TIMEOUT_MS) {
81
+ poolConfig.statement_timeout = config.DATABASE_POSTGRES_STATEMENT_TIMEOUT_MS;
82
+ }
83
+ if (config.DATABASE_POSTGRES_IDLE_IN_TRANSACTION_TIMEOUT_MS) {
84
+ poolConfig.idle_in_transaction_session_timeout =
85
+ config.DATABASE_POSTGRES_IDLE_IN_TRANSACTION_TIMEOUT_MS;
86
+ }
87
+ return poolConfig;
88
+ }
89
+ /**
90
+ * Run a callback while holding a session-level advisory lock on the given
91
+ * client. Replicas booting concurrently serialise on the lock instead of
92
+ * applying the same migration twice.
93
+ */
94
+ async function withAdvisoryLockOnClient(client, lockKey, callback) {
95
+ await client.query("SELECT pg_advisory_lock($1)", [lockKey]);
96
+ try {
97
+ return await callback();
98
+ }
99
+ finally {
100
+ await client.query("SELECT pg_advisory_unlock($1)", [lockKey]).catch(() => {
101
+ // The connection may already be broken; the lock dies with it.
102
+ });
103
+ }
104
+ }
105
+ /** Run one query on a dedicated client with its own span. */
106
+ async function queryOnClient(client, context, spanName, sql, params = []) {
107
+ const span = tracer.startSpan(spanName, context);
108
+ try {
109
+ const result = await client.query(sql, params);
110
+ return result.rows;
111
+ }
112
+ catch (error) {
113
+ span.setStatus({
114
+ code: api_1.SpanStatusCode.ERROR,
115
+ message: error.message,
116
+ });
117
+ throw error;
118
+ }
119
+ finally {
120
+ span.end();
121
+ }
122
+ }
123
+ /**
124
+ * Apply one SQL file on a dedicated client inside a transaction, together
125
+ * with its `metadata` version row when a version is given. On failure the
126
+ * transaction is rolled back: no partial state, no version row.
127
+ *
128
+ * Migration files must not contain their own transaction control statements.
129
+ */
130
+ async function applyMigrationOnClient(client, context, filename, version) {
131
+ const span = tracer.startSpan("PostgresDbUtilsExecSQLFile", context);
132
+ try {
133
+ const sql = (await fs.readFile(filename)).toString();
134
+ await client.query("BEGIN");
135
+ try {
136
+ await client.query(sql);
137
+ if (version !== null) {
138
+ await client.query('INSERT INTO metadata ("type", "value", "dateCreated") VALUES ($1, $2, $3)', ["db_version", version, new Date().toISOString()]);
139
+ }
140
+ await client.query("COMMIT");
141
+ }
142
+ catch (error) {
143
+ await client.query("ROLLBACK").catch(() => {
144
+ // Connection failure: nothing to roll back.
145
+ });
146
+ throw error;
147
+ }
148
+ }
149
+ catch (error) {
150
+ span.setStatus({
151
+ code: api_1.SpanStatusCode.ERROR,
152
+ message: error.message,
153
+ });
154
+ throw error;
155
+ }
156
+ finally {
157
+ span.end();
158
+ }
159
+ }
160
+ /** Read the highest applied migration version (numeric, not lexicographic). */
161
+ async function readAppliedVersion(client, context, spanName) {
162
+ // `metadata.value` has text affinity: a plain MAX(value) is lexicographic and
163
+ // ranks "9" above "10", which re-applies init-0010.sql on every boot.
164
+ const rows = await queryOnClient(client, context, spanName, "SELECT MAX(CAST(value AS INTEGER)) as version FROM metadata WHERE \"type\" = 'db_version'");
165
+ if (rows.length > 0 && rows[0].version !== null && rows[0].version !== undefined) {
166
+ return Number(rows[0].version);
167
+ }
168
+ return 0;
169
+ }
170
+ // ---------------------------------------------------------------------------
56
171
  // Class-based API – supports per-schema pools + shared runtime pool
57
172
  // ---------------------------------------------------------------------------
58
173
  /**
@@ -83,55 +198,50 @@ class PostgresSchemaDbUtils {
83
198
  */
84
199
  async initSchema(context, config, sqlDir) {
85
200
  const span = tracer.startSpan("PostgresSchemaDbUtilsInit", context);
86
- const poolOptions = {
87
- host: config.DATABASE_POSTGRES_HOST,
88
- port: config.DATABASE_POSTGRES_PORT || 5432,
89
- user: config.DATABASE_POSTGRES_USER,
90
- password: config.DATABASE_POSTGRES_PASSWORD,
91
- database: config.DATABASE_POSTGRES_DATABASE,
92
- options: `-c search_path=${this.schemaName}`,
93
- max: 5,
94
- idleTimeoutMillis: 30000,
95
- connectionTimeoutMillis: 10000,
96
- };
97
- if (this.schemaPool) {
98
- this.moduleLogger.info("Closing existing schema pool");
99
- await this.schemaPool.end().catch(() => {
100
- // Ignore errors on close
101
- });
102
- }
103
- this.schemaPool = new pg_1.Pool(poolOptions);
104
- this.moduleLogger.info(`Schema pool initialized with search_path: ${this.schemaName}`);
105
- // Create schema if not exists
106
- await this.execSQLForSchema(span, `CREATE SCHEMA IF NOT EXISTS ${this.schemaName};`);
107
- await this.execSQLForSchema(span, `SET search_path TO ${this.schemaName};`);
108
- // Run init SQL files
109
- await this.execSQLFileForSchema(span, `${sqlDir}/init-0000.sql`);
110
- const initFiles = (await fs.readdir(sqlDir)).sort();
111
- let dbVersionApplied = 0;
112
201
  try {
113
- const dbVersionQuery = await this.querySQLForSchema(span, "SELECT MAX(value) as version FROM metadata WHERE type='db_version'");
114
- if (dbVersionQuery[0].version) {
115
- dbVersionApplied = Number(dbVersionQuery[0].version);
202
+ if (this.schemaPool) {
203
+ this.moduleLogger.info("Closing existing schema pool");
204
+ await this.schemaPool.end().catch(() => {
205
+ // Ignore errors on close
206
+ });
116
207
  }
117
- }
118
- catch {
119
- // Table might not exist yet
120
- }
121
- this.moduleLogger.info(`Current DB Version: ${dbVersionApplied}`);
122
- for (const initFile of initFiles) {
123
- const regex = /init-(\d+)\.sql/g;
124
- const match = regex.exec(initFile);
125
- if (match) {
126
- const dbVersionInitFile = Number(match[1]);
127
- if (dbVersionInitFile > dbVersionApplied) {
128
- this.moduleLogger.info(`Applying migration: ${initFile}`);
129
- await this.execSQLFileForSchema(span, `${sqlDir}/${initFile}`);
130
- await this.querySQLForSchema(span, 'INSERT INTO metadata ("type", "value", "dateCreated") VALUES ($1, $2, $3)', ["db_version", dbVersionInitFile, new Date().toISOString()]);
131
- }
208
+ const schemaPool = new pg_1.Pool(buildPoolConfig(config, this.schemaName, 5, false));
209
+ this.schemaPool = schemaPool;
210
+ this.moduleLogger.info(`Schema pool initialized with search_path: ${this.schemaName}`);
211
+ const client = await schemaPool.connect();
212
+ try {
213
+ await withAdvisoryLockOnClient(client, exports.POSTGRES_LOCK_KEYS.migration, async () => {
214
+ await queryOnClient(client, span, "PostgresSchemaDbUtilsExecSQLForSchema", `CREATE SCHEMA IF NOT EXISTS ${this.schemaName};`);
215
+ await queryOnClient(client, span, "PostgresSchemaDbUtilsExecSQLForSchema", `SET search_path TO ${this.schemaName};`);
216
+ await applyMigrationOnClient(client, span, `${sqlDir}/init-0000.sql`, null);
217
+ let dbVersionApplied = 0;
218
+ try {
219
+ dbVersionApplied = await readAppliedVersion(client, span, "PostgresSchemaDbUtilsQuerySQLForSchema");
220
+ }
221
+ catch {
222
+ // The metadata table might not exist yet
223
+ }
224
+ this.moduleLogger.info(`Current DB Version: ${dbVersionApplied}`);
225
+ const initFiles = (await fs.readdir(sqlDir)).sort();
226
+ for (const initFile of initFiles) {
227
+ const match = /init-(\d+)\.sql/.exec(initFile);
228
+ if (match) {
229
+ const dbVersionInitFile = Number(match[1]);
230
+ if (dbVersionInitFile > dbVersionApplied) {
231
+ this.moduleLogger.info(`Applying migration: ${initFile}`);
232
+ await applyMigrationOnClient(client, span, `${sqlDir}/${initFile}`, dbVersionInitFile);
233
+ }
234
+ }
235
+ }
236
+ });
237
+ }
238
+ finally {
239
+ client.release();
132
240
  }
133
241
  }
134
- span.end();
242
+ finally {
243
+ span.end();
244
+ }
135
245
  }
136
246
  /**
137
247
  * Initialise (or replace) the shared runtime pool.
@@ -144,20 +254,7 @@ class PostgresSchemaDbUtils {
144
254
  // Ignore errors on close
145
255
  });
146
256
  }
147
- this.runtimePool = new pg_1.Pool({
148
- host: config.DATABASE_POSTGRES_HOST,
149
- port: config.DATABASE_POSTGRES_PORT || 5432,
150
- user: config.DATABASE_POSTGRES_USER,
151
- password: config.DATABASE_POSTGRES_PASSWORD,
152
- database: config.DATABASE_POSTGRES_DATABASE,
153
- options: searchPath
154
- ? `-c search_path=${searchPath}`
155
- : `-c search_path=${this.schemaName}`,
156
- max: 20,
157
- idleTimeoutMillis: 30000,
158
- connectionTimeoutMillis: 10000,
159
- keepAlive: true,
160
- });
257
+ this.runtimePool = new pg_1.Pool(buildPoolConfig(config, searchPath || this.schemaName, 20, true));
161
258
  this.moduleLogger.info(`Runtime pool initialized (search_path: ${searchPath || this.schemaName})`);
162
259
  }
163
260
  /**
@@ -166,104 +263,117 @@ class PostgresSchemaDbUtils {
166
263
  * otherwise use the runtime pool (default).
167
264
  * @returns Number of rows changed.
168
265
  */
169
- execSQL(context, sql, params = [], useSchemaPool = false) {
266
+ async execSQL(context, sql, params = [], useSchemaPool = false) {
170
267
  const span = tracer.startSpan("PostgresSchemaDbUtilsExecSQL", context);
171
- const pool = useSchemaPool ? this.schemaPool : this.runtimePool;
172
- if (!pool) {
173
- throw new Error(`Pool not initialized${useSchemaPool ? ` for schema: ${this.schemaName}` : ""}`);
268
+ let targetPool = null;
269
+ try {
270
+ targetPool = useSchemaPool ? this.schemaPool : this.runtimePool;
271
+ if (!targetPool) {
272
+ throw new Error(`Pool not initialized${useSchemaPool ? ` for schema: ${this.schemaName}` : ""}`);
273
+ }
274
+ const result = await targetPool.query(sql, params);
275
+ return result.rowCount || 0;
174
276
  }
175
- return new Promise((resolve, reject) => {
176
- pool.query(sql, params, (error, result) => {
177
- span.end();
178
- if (error) {
179
- span.setStatus({
180
- code: api_1.SpanStatusCode.ERROR,
181
- message: error.message,
182
- });
183
- this.moduleLogger.error(`[${useSchemaPool ? this.schemaName : "RUNTIME"}] SQL EXEC ERROR: ${sql}`, error);
184
- reject(error);
185
- }
186
- else {
187
- resolve(result.rowCount || 0);
188
- }
277
+ catch (error) {
278
+ span.setStatus({
279
+ code: api_1.SpanStatusCode.ERROR,
280
+ message: error.message,
189
281
  });
190
- });
282
+ if (targetPool) {
283
+ this.moduleLogger.error(`[${useSchemaPool ? this.schemaName : "RUNTIME"}] SQL EXEC ERROR: ${sql}`, error);
284
+ }
285
+ throw error;
286
+ }
287
+ finally {
288
+ span.end();
289
+ }
191
290
  }
192
- /** Execute an entire SQL file (used for migrations). */
291
+ /**
292
+ * Execute an entire SQL file (used for migrations).
293
+ * Migration files must not contain their own transaction control statements.
294
+ */
193
295
  async execSQLFile(context, filename, useSchemaPool = false) {
194
296
  const span = tracer.startSpan("PostgresSchemaDbUtilsExecSQLFile", context);
195
- const sql = (await fs.readFile(filename)).toString();
196
- const pool = useSchemaPool ? this.schemaPool : this.runtimePool;
197
- if (!pool) {
198
- throw new Error(`Pool not initialized${useSchemaPool ? ` for schema: ${this.schemaName}` : ""}`);
297
+ let targetPool = null;
298
+ try {
299
+ targetPool = useSchemaPool ? this.schemaPool : this.runtimePool;
300
+ if (!targetPool) {
301
+ throw new Error(`Pool not initialized${useSchemaPool ? ` for schema: ${this.schemaName}` : ""}`);
302
+ }
303
+ const sql = (await fs.readFile(filename)).toString();
304
+ await targetPool.query(sql);
199
305
  }
200
- return new Promise((resolve, reject) => {
201
- pool.query(sql, (error) => {
202
- span.end();
203
- if (error) {
204
- span.setStatus({
205
- code: api_1.SpanStatusCode.ERROR,
206
- message: error.message,
207
- });
208
- reject(error);
209
- }
210
- else {
211
- resolve();
212
- }
306
+ catch (error) {
307
+ span.setStatus({
308
+ code: api_1.SpanStatusCode.ERROR,
309
+ message: error.message,
213
310
  });
214
- });
311
+ throw error;
312
+ }
313
+ finally {
314
+ span.end();
315
+ }
215
316
  }
216
317
  /**
217
318
  * Execute a read SQL query with OTel tracing.
218
319
  * @returns Array of row objects.
219
320
  */
220
- querySQL(context, sql, params = [], useSchemaPool = false) {
321
+ async querySQL(context, sql, params = [], useSchemaPool = false) {
221
322
  const span = tracer.startSpan("PostgresSchemaDbUtilsQuerySQL", context);
222
- const pool = useSchemaPool ? this.schemaPool : this.runtimePool;
223
- if (!pool) {
224
- throw new Error(`Pool not initialized${useSchemaPool ? ` for schema: ${this.schemaName}` : ""}`);
323
+ let targetPool = null;
324
+ try {
325
+ targetPool = useSchemaPool ? this.schemaPool : this.runtimePool;
326
+ if (!targetPool) {
327
+ throw new Error(`Pool not initialized${useSchemaPool ? ` for schema: ${this.schemaName}` : ""}`);
328
+ }
329
+ const result = await targetPool.query(sql, params);
330
+ return result.rows;
225
331
  }
226
- return new Promise((resolve, reject) => {
227
- pool.query(sql, params, (error, result) => {
228
- span.end();
229
- if (error) {
230
- span.setStatus({
231
- code: api_1.SpanStatusCode.ERROR,
232
- message: error.message,
233
- });
234
- this.moduleLogger.error(`[${useSchemaPool ? this.schemaName : "RUNTIME"}] SQL QUERY ERROR: ${sql}`, error);
235
- reject(error);
236
- }
237
- else {
238
- resolve(result.rows);
239
- }
332
+ catch (error) {
333
+ span.setStatus({
334
+ code: api_1.SpanStatusCode.ERROR,
335
+ message: error.message,
240
336
  });
241
- });
337
+ if (targetPool) {
338
+ this.moduleLogger.error(`[${useSchemaPool ? this.schemaName : "RUNTIME"}] SQL QUERY ERROR: ${sql}`, error);
339
+ }
340
+ throw error;
341
+ }
342
+ finally {
343
+ span.end();
344
+ }
242
345
  }
243
346
  /**
244
347
  * Run a callback inside a transaction.
245
348
  */
246
349
  async transaction(context, callback, useSchemaPool = false) {
247
350
  const span = tracer.startSpan("PostgresSchemaDbUtilsTransaction", context);
248
- const pool = useSchemaPool ? this.schemaPool : this.runtimePool;
249
- if (!pool) {
250
- throw new Error(`Pool not initialized${useSchemaPool ? ` for schema: ${this.schemaName}` : ""}`);
251
- }
252
- this.moduleLogger.info(`[${useSchemaPool ? this.schemaName : "RUNTIME"}] Starting transaction`);
253
- const client = await pool.connect();
351
+ let client = null;
254
352
  try {
353
+ const pool = useSchemaPool ? this.schemaPool : this.runtimePool;
354
+ if (!pool) {
355
+ throw new Error(`Pool not initialized${useSchemaPool ? ` for schema: ${this.schemaName}` : ""}`);
356
+ }
357
+ this.moduleLogger.info(`[${useSchemaPool ? this.schemaName : "RUNTIME"}] Starting transaction`);
358
+ client = await pool.connect();
255
359
  await client.query("BEGIN");
256
360
  await callback(client);
257
361
  await client.query("COMMIT");
258
362
  this.moduleLogger.info(`[${useSchemaPool ? this.schemaName : "RUNTIME"}] Transaction committed`);
259
363
  }
260
364
  catch (error) {
261
- await client.query("ROLLBACK");
262
- this.moduleLogger.error(`[${useSchemaPool ? this.schemaName : "RUNTIME"}] Transaction rolled back`, error);
365
+ if (client) {
366
+ await client.query("ROLLBACK").catch(() => {
367
+ // Connection failure: nothing to roll back.
368
+ });
369
+ this.moduleLogger.error(`[${useSchemaPool ? this.schemaName : "RUNTIME"}] Transaction rolled back`, error);
370
+ }
263
371
  throw error;
264
372
  }
265
373
  finally {
266
- client.release();
374
+ if (client) {
375
+ client.release();
376
+ }
267
377
  span.end();
268
378
  }
269
379
  }
@@ -287,78 +397,10 @@ class PostgresSchemaDbUtils {
287
397
  await Promise.all(promises);
288
398
  this.moduleLogger.info("All database pools closed");
289
399
  }
290
- // -- Internal helpers (schema pool only) ----------------------------------
291
- execSQLForSchema(context, sql, params = []) {
292
- const span = tracer.startSpan("PostgresSchemaDbUtilsExecSQLForSchema", context);
293
- if (!this.schemaPool) {
294
- throw new Error(`Pool not initialized for schema: ${this.schemaName}`);
295
- }
296
- return new Promise((resolve, reject) => {
297
- this.schemaPool.query(sql, params, (error) => {
298
- span.end();
299
- if (error) {
300
- reject(error);
301
- }
302
- else {
303
- resolve();
304
- }
305
- });
306
- });
307
- }
308
- async execSQLFileForSchema(context, filename) {
309
- try {
310
- const span = tracer.startSpan("PostgresSchemaDbUtilsExecSQLFileForSchema", context);
311
- const sql = (await fs.readFile(filename)).toString();
312
- if (!this.schemaPool) {
313
- throw new Error(`Pool not initialized for schema: ${this.schemaName}`);
314
- }
315
- return new Promise((resolve, reject) => {
316
- this.schemaPool.query(sql, (error) => {
317
- span.end();
318
- if (error) {
319
- if (error.code === "ENOENT") {
320
- resolve();
321
- }
322
- else {
323
- reject(error);
324
- }
325
- }
326
- else {
327
- resolve();
328
- }
329
- });
330
- });
331
- }
332
- catch (error) {
333
- if (error.code === "ENOENT") {
334
- return;
335
- }
336
- throw error;
337
- }
338
- }
339
- querySQLForSchema(context, sql, params = []) {
340
- const span = tracer.startSpan("PostgresSchemaDbUtilsQuerySQLForSchema", context);
341
- if (!this.schemaPool) {
342
- throw new Error(`Pool not initialized for schema: ${this.schemaName}`);
343
- }
344
- return new Promise((resolve, reject) => {
345
- this.schemaPool.query(sql, params, (error, result) => {
346
- span.end();
347
- if (error) {
348
- reject(error);
349
- }
350
- else {
351
- resolve(result.rows);
352
- }
353
- });
354
- });
355
- }
356
400
  }
357
401
  exports.PostgresSchemaDbUtils = PostgresSchemaDbUtils;
358
402
  // ---------------------------------------------------------------------------
359
403
  // Functional API – single-pool mode used by the DbUtils facade.
360
- // An internal PostgresSchemaDbUtils instance backs these functions so the
361
- // behaviour is identical to before.
362
404
  // ---------------------------------------------------------------------------
363
405
  /**
364
406
  * Injects the OTel tracer and logger instances used by all Postgres operations.
@@ -372,47 +414,59 @@ function PostgresDbUtilsSetOTel(tracerIn, loggerIn) {
372
414
  /**
373
415
  * Creates the Postgres connection pool and applies pending migration files
374
416
  * from `sqlDir`.
417
+ *
418
+ * Migrations are applied under a session-level advisory lock (concurrently
419
+ * booting replicas serialise instead of double-applying) and each file plus
420
+ * its `db_version` row runs in its own transaction: a failing migration is
421
+ * rolled back and never recorded.
375
422
  */
376
423
  async function PostgresDbUtilsInit(context, config, sqlDir) {
377
424
  const span = tracer.startSpan("PostgresDbUtilsInit", context);
378
- // Use the schema-level init but without schema creation (single-pool mode)
379
- const poolOptions = {
380
- host: config.DATABASE_POSTGRES_HOST,
381
- port: config.DATABASE_POSTGRES_PORT || 5432,
382
- user: config.DATABASE_POSTGRES_USER,
383
- password: config.DATABASE_POSTGRES_PASSWORD,
384
- database: config.DATABASE_POSTGRES_DATABASE,
385
- max: 20,
386
- idleTimeoutMillis: 30000,
387
- connectionTimeoutMillis: 10000,
388
- keepAlive: true,
389
- };
390
- // Create a simple pool directly for the functional API
391
- pool = new pg_1.Pool(poolOptions);
392
- pool.on("error", (err) => {
393
- logger.error("PostgreSQL pool connection error", err);
394
- });
395
- await PostgresDbUtilsExecSQLFile(span, `${sqlDir}/init-0000.sql`);
396
- const initFiles = (await fs.readdir(sqlDir)).sort();
397
- let dbVersionApplied = 0;
398
- const dbVersionQuery = await PostgresDbUtilsQuerySQL(span, "SELECT MAX(value) as version FROM metadata WHERE \"type\" = 'db_version'");
399
- if (dbVersionQuery.length > 0 && dbVersionQuery[0].version) {
400
- dbVersionApplied = Number(dbVersionQuery[0].version);
401
- }
402
- logger.info(`Current DB Version: ${dbVersionApplied}`, span);
403
- for (const initFile of initFiles) {
404
- const regex = /init-(\d+).sql/g;
405
- const match = regex.exec(initFile);
406
- if (match) {
407
- const dbVersionInitFile = Number(match[1]);
408
- if (dbVersionInitFile > dbVersionApplied) {
409
- logger.info(`Loading init file: ${initFile}`, span);
410
- await PostgresDbUtilsExecSQLFile(span, `${sqlDir}/${initFile}`);
411
- await PostgresDbUtilsQuerySQL(span, 'INSERT INTO metadata ("type", "value", "dateCreated") VALUES ($1, $2, $3)', ["db_version", dbVersionInitFile, new Date().toISOString()]);
412
- }
425
+ try {
426
+ pool = new pg_1.Pool(buildPoolConfig(config, undefined, 20, true));
427
+ pool.on("error", (err) => {
428
+ logger.error("PostgreSQL pool connection error", err);
429
+ });
430
+ const client = await pool.connect();
431
+ try {
432
+ await withAdvisoryLockOnClient(client, exports.POSTGRES_LOCK_KEYS.migration, async () => {
433
+ await applyMigrationOnClient(client, span, `${sqlDir}/init-0000.sql`, null);
434
+ const dbVersionApplied = await readAppliedVersion(client, span, "PostgresDbUtilsQuerySQL");
435
+ logger.info(`Current DB Version: ${dbVersionApplied}`, span);
436
+ const initFiles = (await fs.readdir(sqlDir)).sort();
437
+ for (const initFile of initFiles) {
438
+ const match = /init-(\d+)\.sql/.exec(initFile);
439
+ if (match) {
440
+ const dbVersionInitFile = Number(match[1]);
441
+ if (dbVersionInitFile > dbVersionApplied) {
442
+ logger.info(`Loading init file: ${initFile}`, span);
443
+ await applyMigrationOnClient(client, span, `${sqlDir}/${initFile}`, dbVersionInitFile);
444
+ }
445
+ }
446
+ }
447
+ });
413
448
  }
449
+ finally {
450
+ client.release();
451
+ }
452
+ }
453
+ finally {
454
+ span.end();
455
+ }
456
+ }
457
+ /**
458
+ * Run a callback while holding a Postgres advisory lock, serialising the
459
+ * callback across every replica of every service booting against the same
460
+ * database (used for the `auth_token` and first-user bootstrap races).
461
+ */
462
+ async function PostgresDbUtilsWithAdvisoryLock(lock, callback) {
463
+ const client = await pool.connect();
464
+ try {
465
+ return await withAdvisoryLockOnClient(client, exports.POSTGRES_LOCK_KEYS[lock], callback);
466
+ }
467
+ finally {
468
+ client.release();
414
469
  }
415
- span.end();
416
470
  }
417
471
  /** Returns the underlying `pg.Pool` instance. */
418
472
  function PostgresDbUtilsGetPool() {
@@ -422,102 +476,101 @@ function PostgresDbUtilsGetPool() {
422
476
  * Execute a write SQL statement with OTel tracing.
423
477
  * @returns Number of rows changed.
424
478
  */
425
- function PostgresDbUtilsExecSQL(context, sql, params = []) {
479
+ async function PostgresDbUtilsExecSQL(context, sql, params = []) {
426
480
  const span = tracer.startSpan("PostgresDbUtilsExecSQL", context);
427
- return new Promise((resolve, reject) => {
428
- pool.query(sql, params, (error, result) => {
429
- if (error) {
430
- span.setStatus({
431
- code: api_1.SpanStatusCode.ERROR,
432
- message: error.message,
433
- });
434
- span.end();
435
- reject(error);
436
- }
437
- else {
438
- span.addEvent(`Impacted Rows: ${result.rowCount || 0}`);
439
- span.end();
440
- resolve(result.rowCount || 0);
441
- }
481
+ try {
482
+ const result = await pool.query(sql, params);
483
+ span.addEvent(`Impacted Rows: ${result.rowCount || 0}`);
484
+ return result.rowCount || 0;
485
+ }
486
+ catch (error) {
487
+ span.setStatus({
488
+ code: api_1.SpanStatusCode.ERROR,
489
+ message: error.message,
442
490
  });
443
- });
491
+ throw error;
492
+ }
493
+ finally {
494
+ span.end();
495
+ }
444
496
  }
445
- /** Execute an entire SQL file (used for migrations). */
497
+ /**
498
+ * Execute an entire SQL file (used for migrations).
499
+ * Migration files must not contain their own transaction control statements.
500
+ */
446
501
  async function PostgresDbUtilsExecSQLFile(context, filename) {
447
502
  const span = tracer.startSpan("PostgresDbUtilsExecSQLFile", context);
448
- const sql = (await fs.readFile(filename)).toString();
449
- return new Promise((resolve, reject) => {
450
- pool.query(sql, (error) => {
451
- if (error) {
452
- span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: error.message });
453
- span.end();
454
- reject(error);
455
- }
456
- else {
457
- span.end();
458
- resolve();
459
- }
503
+ try {
504
+ const sql = (await fs.readFile(filename)).toString();
505
+ await pool.query(sql);
506
+ }
507
+ catch (error) {
508
+ span.setStatus({
509
+ code: api_1.SpanStatusCode.ERROR,
510
+ message: error.message,
460
511
  });
461
- });
512
+ throw error;
513
+ }
514
+ finally {
515
+ span.end();
516
+ }
462
517
  }
463
518
  /**
464
519
  * Execute a read SQL query with OTel tracing.
465
520
  * @returns Array of row objects.
466
521
  */
467
- function PostgresDbUtilsQuerySQL(context, sql, params = [], debug = false) {
522
+ async function PostgresDbUtilsQuerySQL(context, sql, params = [], debug = false) {
468
523
  const span = tracer.startSpan("PostgresDbUtilsQuerySQL", context);
469
524
  if (debug) {
470
525
  console.log(sql);
471
526
  }
472
- return new Promise((resolve, reject) => {
473
- pool.query(sql, params, (error, result) => {
474
- if (error) {
475
- span.setStatus({
476
- code: api_1.SpanStatusCode.ERROR,
477
- message: error.message,
478
- });
479
- logger.error(`SQL ERROR: ${sql}`, error, span);
480
- span.end();
481
- reject(error);
482
- }
483
- else {
484
- span.end();
485
- resolve(result.rows);
486
- }
527
+ try {
528
+ const result = await pool.query(sql, params);
529
+ return result.rows;
530
+ }
531
+ catch (error) {
532
+ span.setStatus({
533
+ code: api_1.SpanStatusCode.ERROR,
534
+ message: error.message,
487
535
  });
488
- });
536
+ logger.error(`SQL ERROR: ${sql}`, error, span);
537
+ throw error;
538
+ }
539
+ finally {
540
+ span.end();
541
+ }
489
542
  }
490
543
  /** Start a transaction. */
491
- function PostgresDbUtilsTransactionStart(context) {
544
+ async function PostgresDbUtilsTransactionStart(context) {
492
545
  const span = tracer.startSpan("PostgresDbUtilsTransactionStart", context);
493
- return new Promise((resolve, reject) => {
494
- pool.query("BEGIN", (error) => {
495
- if (error) {
496
- span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: error.message });
497
- span.end();
498
- reject(error);
499
- }
500
- else {
501
- span.end();
502
- resolve();
503
- }
546
+ try {
547
+ await pool.query("BEGIN");
548
+ }
549
+ catch (error) {
550
+ span.setStatus({
551
+ code: api_1.SpanStatusCode.ERROR,
552
+ message: error.message,
504
553
  });
505
- });
554
+ throw error;
555
+ }
556
+ finally {
557
+ span.end();
558
+ }
506
559
  }
507
560
  /** Commit a transaction. */
508
- function PostgresDbUtilsTransactionCommit(context) {
561
+ async function PostgresDbUtilsTransactionCommit(context) {
509
562
  const span = tracer.startSpan("PostgresDbUtilsTransactionCommit", context);
510
- return new Promise((resolve, reject) => {
511
- pool.query("COMMIT", (error) => {
512
- if (error) {
513
- span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: error.message });
514
- span.end();
515
- reject(error);
516
- }
517
- else {
518
- span.end();
519
- resolve();
520
- }
563
+ try {
564
+ await pool.query("COMMIT");
565
+ }
566
+ catch (error) {
567
+ span.setStatus({
568
+ code: api_1.SpanStatusCode.ERROR,
569
+ message: error.message,
521
570
  });
522
- });
571
+ throw error;
572
+ }
573
+ finally {
574
+ span.end();
575
+ }
523
576
  }