@devopsplaybook.io/common-utils 1.0.0-beta.5.e74e0d9 → 1.1.0-beta.6.9ec503a

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.
@@ -19,9 +19,466 @@ export interface PostgresDbConfig {
19
19
  DATABASE_POSTGRES_DATABASE: string;
20
20
  }
21
21
 
22
+ // ---------------------------------------------------------------------------
23
+ // Module-level state
24
+ // ---------------------------------------------------------------------------
25
+
22
26
  let pool: Pool;
23
27
  let tracer: StandardTracer;
24
28
  let logger: ModuleLogger;
29
+ let standardLogger: StandardLogger;
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Class-based API – supports per-schema pools + shared runtime pool
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Class-based PostgreSQL utility that manages a schema-specific pool (used
37
+ * during migrations) and an optional shared runtime pool (used for
38
+ * application queries).
39
+ *
40
+ * Multiple instances can coexist, each bound to a different PostgreSQL
41
+ * schema, while sharing a single runtime pool that has all schemas in its
42
+ * `search_path`.
43
+ */
44
+ export class PostgresSchemaDbUtils {
45
+ private schemaPool: Pool | null = null;
46
+ private runtimePool: Pool | null = null;
47
+ private readonly schemaName: string;
48
+ private readonly moduleLogger: ModuleLogger;
49
+
50
+ constructor(schemaName: string) {
51
+ this.schemaName = schemaName;
52
+ this.moduleLogger = standardLogger.createModuleLogger(
53
+ `PostgresSchemaDbUtils[${schemaName}]`,
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Create the schema-specific pool, ensure the schema exists, and apply
59
+ * any pending migration files from `sqlDir`.
60
+ */
61
+ async initSchema(
62
+ context: Span,
63
+ config: PostgresDbConfig,
64
+ sqlDir: string,
65
+ ): Promise<void> {
66
+ const span = tracer.startSpan("PostgresSchemaDbUtilsInit", context);
67
+
68
+ const poolOptions = {
69
+ host: config.DATABASE_POSTGRES_HOST,
70
+ port: config.DATABASE_POSTGRES_PORT || 5432,
71
+ user: config.DATABASE_POSTGRES_USER,
72
+ password: config.DATABASE_POSTGRES_PASSWORD,
73
+ database: config.DATABASE_POSTGRES_DATABASE,
74
+ options: `-c search_path=${this.schemaName}`,
75
+ max: 5,
76
+ idleTimeoutMillis: 30000,
77
+ connectionTimeoutMillis: 10000,
78
+ };
79
+
80
+ if (this.schemaPool) {
81
+ this.moduleLogger.info("Closing existing schema pool");
82
+ await this.schemaPool.end().catch(() => {
83
+ // Ignore errors on close
84
+ });
85
+ }
86
+ this.schemaPool = new Pool(poolOptions);
87
+ this.moduleLogger.info(
88
+ `Schema pool initialized with search_path: ${this.schemaName}`,
89
+ );
90
+
91
+ // Create schema if not exists
92
+ await this.execSQLForSchema(
93
+ span,
94
+ `CREATE SCHEMA IF NOT EXISTS ${this.schemaName};`,
95
+ );
96
+ await this.execSQLForSchema(
97
+ span,
98
+ `SET search_path TO ${this.schemaName};`,
99
+ );
100
+
101
+ // Run init SQL files
102
+ await this.execSQLFileForSchema(span, `${sqlDir}/init-0000.sql`);
103
+ const initFiles = (await fs.readdir(sqlDir)).sort();
104
+ let dbVersionApplied = 0;
105
+
106
+ try {
107
+ const dbVersionQuery = await this.querySQLForSchema(
108
+ span,
109
+ "SELECT MAX(value) as version FROM metadata WHERE type='db_version'",
110
+ );
111
+ if (
112
+ (dbVersionQuery[0] as Record<string, unknown>).version
113
+ ) {
114
+ dbVersionApplied = Number(
115
+ (dbVersionQuery[0] as Record<string, unknown>).version,
116
+ );
117
+ }
118
+ } catch {
119
+ // Table might not exist yet
120
+ }
121
+
122
+ this.moduleLogger.info(`Current DB Version: ${dbVersionApplied}`);
123
+
124
+ for (const initFile of initFiles) {
125
+ const regex = /init-(\d+)\.sql/g;
126
+ const match = regex.exec(initFile);
127
+ if (match) {
128
+ const dbVersionInitFile = Number(match[1]);
129
+ if (dbVersionInitFile > dbVersionApplied) {
130
+ this.moduleLogger.info(`Applying migration: ${initFile}`);
131
+ await this.execSQLFileForSchema(span, `${sqlDir}/${initFile}`);
132
+ await this.querySQLForSchema(
133
+ span,
134
+ 'INSERT INTO metadata ("type", "value", "dateCreated") VALUES ($1, $2, $3)',
135
+ ["db_version", dbVersionInitFile, new Date().toISOString()],
136
+ );
137
+ }
138
+ }
139
+ }
140
+
141
+ span.end();
142
+ }
143
+
144
+ /**
145
+ * Initialise (or replace) the shared runtime pool.
146
+ * Typically called once with a pool whose `search_path` includes all
147
+ * application schemas.
148
+ */
149
+ initRuntimePool(config: PostgresDbConfig, searchPath?: string): void {
150
+ if (this.runtimePool) {
151
+ this.runtimePool.end().catch(() => {
152
+ // Ignore errors on close
153
+ });
154
+ }
155
+ this.runtimePool = new Pool({
156
+ host: config.DATABASE_POSTGRES_HOST,
157
+ port: config.DATABASE_POSTGRES_PORT || 5432,
158
+ user: config.DATABASE_POSTGRES_USER,
159
+ password: config.DATABASE_POSTGRES_PASSWORD,
160
+ database: config.DATABASE_POSTGRES_DATABASE,
161
+ options: searchPath
162
+ ? `-c search_path=${searchPath}`
163
+ : `-c search_path=${this.schemaName}`,
164
+ max: 20,
165
+ idleTimeoutMillis: 30000,
166
+ connectionTimeoutMillis: 10000,
167
+ keepAlive: true,
168
+ });
169
+ this.moduleLogger.info(
170
+ `Runtime pool initialized (search_path: ${searchPath || this.schemaName})`,
171
+ );
172
+ }
173
+
174
+ /**
175
+ * Execute a write SQL statement with OTel tracing.
176
+ * @param useSchemaPool When `true` use the schema-specific pool;
177
+ * otherwise use the runtime pool (default).
178
+ * @returns Number of rows changed.
179
+ */
180
+ execSQL(
181
+ context: Span,
182
+ sql: string,
183
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
184
+ params: any[] = [],
185
+ useSchemaPool = false,
186
+ ): Promise<number> {
187
+ const span = tracer.startSpan("PostgresSchemaDbUtilsExecSQL", context);
188
+ const pool = useSchemaPool ? this.schemaPool : this.runtimePool;
189
+
190
+ if (!pool) {
191
+ throw new Error(
192
+ `Pool not initialized${useSchemaPool ? ` for schema: ${this.schemaName}` : ""}`,
193
+ );
194
+ }
195
+
196
+ return new Promise((resolve, reject) => {
197
+ pool.query(
198
+ sql,
199
+ params,
200
+ (error: Error | null, result: { rowCount: number | null }) => {
201
+ span.end();
202
+ if (error) {
203
+ span.setStatus({
204
+ code: SpanStatusCode.ERROR,
205
+ message: error.message,
206
+ });
207
+ this.moduleLogger.error(
208
+ `[${useSchemaPool ? this.schemaName : "RUNTIME"}] SQL EXEC ERROR: ${sql}`,
209
+ error,
210
+ );
211
+ reject(error);
212
+ } else {
213
+ resolve(result.rowCount || 0);
214
+ }
215
+ },
216
+ );
217
+ });
218
+ }
219
+
220
+ /** Execute an entire SQL file (used for migrations). */
221
+ async execSQLFile(
222
+ context: Span,
223
+ filename: string,
224
+ useSchemaPool = false,
225
+ ): Promise<void> {
226
+ const span = tracer.startSpan(
227
+ "PostgresSchemaDbUtilsExecSQLFile",
228
+ context,
229
+ );
230
+ const sql = (await fs.readFile(filename)).toString();
231
+ const pool = useSchemaPool ? this.schemaPool : this.runtimePool;
232
+
233
+ if (!pool) {
234
+ throw new Error(
235
+ `Pool not initialized${useSchemaPool ? ` for schema: ${this.schemaName}` : ""}`,
236
+ );
237
+ }
238
+
239
+ return new Promise((resolve, reject) => {
240
+ pool.query(sql, (error: Error | null) => {
241
+ span.end();
242
+ if (error) {
243
+ span.setStatus({
244
+ code: SpanStatusCode.ERROR,
245
+ message: error.message,
246
+ });
247
+ reject(error);
248
+ } else {
249
+ resolve();
250
+ }
251
+ });
252
+ });
253
+ }
254
+
255
+ /**
256
+ * Execute a read SQL query with OTel tracing.
257
+ * @returns Array of row objects.
258
+ */
259
+ querySQL(
260
+ context: Span,
261
+ sql: string,
262
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
263
+ params: any[] = [],
264
+ useSchemaPool = false,
265
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
266
+ ): Promise<any[]> {
267
+ const span = tracer.startSpan("PostgresSchemaDbUtilsQuerySQL", context);
268
+ const pool = useSchemaPool ? this.schemaPool : this.runtimePool;
269
+
270
+ if (!pool) {
271
+ throw new Error(
272
+ `Pool not initialized${useSchemaPool ? ` for schema: ${this.schemaName}` : ""}`,
273
+ );
274
+ }
275
+
276
+ return new Promise((resolve, reject) => {
277
+ pool.query(
278
+ sql,
279
+ params,
280
+ (error: Error | null, result: { rows: unknown[] }) => {
281
+ span.end();
282
+ if (error) {
283
+ span.setStatus({
284
+ code: SpanStatusCode.ERROR,
285
+ message: error.message,
286
+ });
287
+ this.moduleLogger.error(
288
+ `[${useSchemaPool ? this.schemaName : "RUNTIME"}] SQL QUERY ERROR: ${sql}`,
289
+ error,
290
+ );
291
+ reject(error);
292
+ } else {
293
+ resolve(result.rows);
294
+ }
295
+ },
296
+ );
297
+ });
298
+ }
299
+
300
+ /**
301
+ * Run a callback inside a transaction.
302
+ */
303
+ async transaction(
304
+ context: Span,
305
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
306
+ callback: (client: any) => Promise<void>,
307
+ useSchemaPool = false,
308
+ ): Promise<void> {
309
+ const span = tracer.startSpan(
310
+ "PostgresSchemaDbUtilsTransaction",
311
+ context,
312
+ );
313
+ const pool = useSchemaPool ? this.schemaPool : this.runtimePool;
314
+
315
+ if (!pool) {
316
+ throw new Error(
317
+ `Pool not initialized${useSchemaPool ? ` for schema: ${this.schemaName}` : ""}`,
318
+ );
319
+ }
320
+
321
+ this.moduleLogger.info(
322
+ `[${useSchemaPool ? this.schemaName : "RUNTIME"}] Starting transaction`,
323
+ );
324
+ const client = await pool.connect();
325
+ try {
326
+ await client.query("BEGIN");
327
+ await callback(client);
328
+ await client.query("COMMIT");
329
+ this.moduleLogger.info(
330
+ `[${useSchemaPool ? this.schemaName : "RUNTIME"}] Transaction committed`,
331
+ );
332
+ } catch (error) {
333
+ await client.query("ROLLBACK");
334
+ this.moduleLogger.error(
335
+ `[${useSchemaPool ? this.schemaName : "RUNTIME"}] Transaction rolled back`,
336
+ error as Error,
337
+ );
338
+ throw error;
339
+ } finally {
340
+ client.release();
341
+ span.end();
342
+ }
343
+ }
344
+
345
+ /** Close both the schema pool and the runtime pool. */
346
+ async closeAll(): Promise<void> {
347
+ const promises: Promise<void>[] = [];
348
+
349
+ if (this.schemaPool) {
350
+ this.moduleLogger.info("Closing schema pool");
351
+ promises.push(
352
+ this.schemaPool.end().catch(() => {
353
+ this.moduleLogger.warn("Error closing schema pool");
354
+ }),
355
+ );
356
+ this.schemaPool = null;
357
+ }
358
+
359
+ if (this.runtimePool) {
360
+ this.moduleLogger.info("Closing runtime pool");
361
+ promises.push(
362
+ this.runtimePool.end().catch(() => {
363
+ this.moduleLogger.warn("Error closing runtime pool");
364
+ }),
365
+ );
366
+ this.runtimePool = null;
367
+ }
368
+
369
+ await Promise.all(promises);
370
+ this.moduleLogger.info("All database pools closed");
371
+ }
372
+
373
+ // -- Internal helpers (schema pool only) ----------------------------------
374
+
375
+ private execSQLForSchema(
376
+ context: Span,
377
+ sql: string,
378
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
379
+ params: any[] = [],
380
+ ): Promise<void> {
381
+ const span = tracer.startSpan(
382
+ "PostgresSchemaDbUtilsExecSQLForSchema",
383
+ context,
384
+ );
385
+
386
+ if (!this.schemaPool) {
387
+ throw new Error(`Pool not initialized for schema: ${this.schemaName}`);
388
+ }
389
+
390
+ return new Promise((resolve, reject) => {
391
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
392
+ this.schemaPool!.query(sql, params, (error: Error | null) => {
393
+ span.end();
394
+ if (error) {
395
+ reject(error);
396
+ } else {
397
+ resolve();
398
+ }
399
+ });
400
+ });
401
+ }
402
+
403
+ private async execSQLFileForSchema(
404
+ context: Span,
405
+ filename: string,
406
+ ): Promise<void> {
407
+ try {
408
+ const span = tracer.startSpan(
409
+ "PostgresSchemaDbUtilsExecSQLFileForSchema",
410
+ context,
411
+ );
412
+ const sql = (await fs.readFile(filename)).toString();
413
+
414
+ if (!this.schemaPool) {
415
+ throw new Error(`Pool not initialized for schema: ${this.schemaName}`);
416
+ }
417
+
418
+ return new Promise((resolve, reject) => {
419
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
420
+ this.schemaPool!.query(sql, (error: Error | null) => {
421
+ span.end();
422
+ if (error) {
423
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
424
+ if ((error as any).code === "ENOENT") {
425
+ resolve();
426
+ } else {
427
+ reject(error);
428
+ }
429
+ } else {
430
+ resolve();
431
+ }
432
+ });
433
+ });
434
+ } catch (error) {
435
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
436
+ if ((error as any).code === "ENOENT") {
437
+ return;
438
+ }
439
+ throw error;
440
+ }
441
+ }
442
+
443
+ private querySQLForSchema(
444
+ context: Span,
445
+ sql: string,
446
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
447
+ params: any[] = [],
448
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
449
+ ): Promise<any[]> {
450
+ const span = tracer.startSpan(
451
+ "PostgresSchemaDbUtilsQuerySQLForSchema",
452
+ context,
453
+ );
454
+
455
+ if (!this.schemaPool) {
456
+ throw new Error(`Pool not initialized for schema: ${this.schemaName}`);
457
+ }
458
+
459
+ return new Promise((resolve, reject) => {
460
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
461
+ this.schemaPool!.query(
462
+ sql,
463
+ params,
464
+ (error: Error | null, result: { rows: unknown[] }) => {
465
+ span.end();
466
+ if (error) {
467
+ reject(error);
468
+ } else {
469
+ resolve(result.rows);
470
+ }
471
+ },
472
+ );
473
+ });
474
+ }
475
+ }
476
+
477
+ // ---------------------------------------------------------------------------
478
+ // Functional API – single-pool mode used by the DbUtils facade.
479
+ // An internal PostgresSchemaDbUtils instance backs these functions so the
480
+ // behaviour is identical to before.
481
+ // ---------------------------------------------------------------------------
25
482
 
26
483
  /**
27
484
  * Injects the OTel tracer and logger instances used by all Postgres operations.
@@ -32,23 +489,13 @@ export function PostgresDbUtilsSetOTel(
32
489
  loggerIn: StandardLogger,
33
490
  ): void {
34
491
  tracer = tracerIn;
492
+ standardLogger = loggerIn;
35
493
  logger = loggerIn.createModuleLogger("PostgresDbUtils");
36
494
  }
37
495
 
38
496
  /**
39
497
  * Creates the Postgres connection pool and applies pending migration files
40
498
  * from `sqlDir`.
41
- *
42
- * Migration files must follow the naming convention `init-NNNN.sql` and are
43
- * applied in lexicographic order. A `metadata` table tracks which migrations
44
- * have already been applied so they are idempotent.
45
- *
46
- * @param context Parent OTel span.
47
- * @param config Configuration with Postgres connection fields.
48
- * @param sqlDir Absolute path to the directory containing SQL migration files
49
- * written for Postgres (with `$1,$2...` placeholders).
50
- * If migrations are SQLite-first, use `convertToPostgresPlaceholders`
51
- * before passing them.
52
499
  */
53
500
  export async function PostgresDbUtilsInit(
54
501
  context: Span,
@@ -57,7 +504,8 @@ export async function PostgresDbUtilsInit(
57
504
  ): Promise<void> {
58
505
  const span = tracer.startSpan("PostgresDbUtilsInit", context);
59
506
 
60
- pool = new Pool({
507
+ // Use the schema-level init but without schema creation (single-pool mode)
508
+ const poolOptions = {
61
509
  host: config.DATABASE_POSTGRES_HOST,
62
510
  port: config.DATABASE_POSTGRES_PORT || 5432,
63
511
  user: config.DATABASE_POSTGRES_USER,
@@ -67,7 +515,10 @@ export async function PostgresDbUtilsInit(
67
515
  idleTimeoutMillis: 30000,
68
516
  connectionTimeoutMillis: 10000,
69
517
  keepAlive: true,
70
- });
518
+ };
519
+
520
+ // Create a simple pool directly for the functional API
521
+ pool = new Pool(poolOptions);
71
522
 
72
523
  pool.on("error", (err: Error) => {
73
524
  logger.error("PostgreSQL pool connection error", err);