@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.
@@ -11,6 +11,56 @@ export interface PostgresDbConfig {
11
11
  DATABASE_POSTGRES_PASSWORD: string;
12
12
  DATABASE_POSTGRES_DATABASE: string;
13
13
  }
14
+ /**
15
+ * Class-based PostgreSQL utility that manages a schema-specific pool (used
16
+ * during migrations) and an optional shared runtime pool (used for
17
+ * application queries).
18
+ *
19
+ * Multiple instances can coexist, each bound to a different PostgreSQL
20
+ * schema, while sharing a single runtime pool that has all schemas in its
21
+ * `search_path`.
22
+ */
23
+ export declare class PostgresSchemaDbUtils {
24
+ private schemaPool;
25
+ private runtimePool;
26
+ private readonly schemaName;
27
+ private readonly moduleLogger;
28
+ constructor(schemaName: string);
29
+ /**
30
+ * Create the schema-specific pool, ensure the schema exists, and apply
31
+ * any pending migration files from `sqlDir`.
32
+ */
33
+ initSchema(context: Span, config: PostgresDbConfig, sqlDir: string): Promise<void>;
34
+ /**
35
+ * Initialise (or replace) the shared runtime pool.
36
+ * Typically called once with a pool whose `search_path` includes all
37
+ * application schemas.
38
+ */
39
+ initRuntimePool(config: PostgresDbConfig, searchPath?: string): void;
40
+ /**
41
+ * Execute a write SQL statement with OTel tracing.
42
+ * @param useSchemaPool When `true` use the schema-specific pool;
43
+ * otherwise use the runtime pool (default).
44
+ * @returns Number of rows changed.
45
+ */
46
+ execSQL(context: Span, sql: string, params?: any[], useSchemaPool?: boolean): Promise<number>;
47
+ /** Execute an entire SQL file (used for migrations). */
48
+ execSQLFile(context: Span, filename: string, useSchemaPool?: boolean): Promise<void>;
49
+ /**
50
+ * Execute a read SQL query with OTel tracing.
51
+ * @returns Array of row objects.
52
+ */
53
+ querySQL(context: Span, sql: string, params?: any[], useSchemaPool?: boolean): Promise<any[]>;
54
+ /**
55
+ * Run a callback inside a transaction.
56
+ */
57
+ transaction(context: Span, callback: (client: any) => Promise<void>, useSchemaPool?: boolean): Promise<void>;
58
+ /** Close both the schema pool and the runtime pool. */
59
+ closeAll(): Promise<void>;
60
+ private execSQLForSchema;
61
+ private execSQLFileForSchema;
62
+ private querySQLForSchema;
63
+ }
14
64
  /**
15
65
  * Injects the OTel tracer and logger instances used by all Postgres operations.
16
66
  * Must be called once at startup, before {@link PostgresDbUtilsInit}.
@@ -19,17 +69,6 @@ export declare function PostgresDbUtilsSetOTel(tracerIn: StandardTracer, loggerI
19
69
  /**
20
70
  * Creates the Postgres connection pool and applies pending migration files
21
71
  * from `sqlDir`.
22
- *
23
- * Migration files must follow the naming convention `init-NNNN.sql` and are
24
- * applied in lexicographic order. A `metadata` table tracks which migrations
25
- * have already been applied so they are idempotent.
26
- *
27
- * @param context Parent OTel span.
28
- * @param config Configuration with Postgres connection fields.
29
- * @param sqlDir Absolute path to the directory containing SQL migration files
30
- * written for Postgres (with `$1,$2...` placeholders).
31
- * If migrations are SQLite-first, use `convertToPostgresPlaceholders`
32
- * before passing them.
33
72
  */
34
73
  export declare function PostgresDbUtilsInit(context: Span, config: PostgresDbConfig, sqlDir: string): Promise<void>;
35
74
  /** Returns the underlying `pg.Pool` instance. */
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.PostgresSchemaDbUtils = void 0;
36
37
  exports.PostgresDbUtilsSetOTel = PostgresDbUtilsSetOTel;
37
38
  exports.PostgresDbUtilsInit = PostgresDbUtilsInit;
38
39
  exports.PostgresDbUtilsGetPool = PostgresDbUtilsGetPool;
@@ -44,35 +45,347 @@ exports.PostgresDbUtilsTransactionCommit = PostgresDbUtilsTransactionCommit;
44
45
  const pg_1 = require("pg");
45
46
  const fs = __importStar(require("fs-extra"));
46
47
  const api_1 = require("@opentelemetry/api");
48
+ // ---------------------------------------------------------------------------
49
+ // Module-level state
50
+ // ---------------------------------------------------------------------------
47
51
  let pool;
48
52
  let tracer;
49
53
  let logger;
54
+ let standardLogger;
55
+ // ---------------------------------------------------------------------------
56
+ // Class-based API – supports per-schema pools + shared runtime pool
57
+ // ---------------------------------------------------------------------------
58
+ /**
59
+ * Class-based PostgreSQL utility that manages a schema-specific pool (used
60
+ * during migrations) and an optional shared runtime pool (used for
61
+ * application queries).
62
+ *
63
+ * Multiple instances can coexist, each bound to a different PostgreSQL
64
+ * schema, while sharing a single runtime pool that has all schemas in its
65
+ * `search_path`.
66
+ */
67
+ class PostgresSchemaDbUtils {
68
+ constructor(schemaName) {
69
+ this.schemaPool = null;
70
+ this.runtimePool = null;
71
+ this.schemaName = schemaName;
72
+ this.moduleLogger = standardLogger.createModuleLogger(`PostgresSchemaDbUtils[${schemaName}]`);
73
+ }
74
+ /**
75
+ * Create the schema-specific pool, ensure the schema exists, and apply
76
+ * any pending migration files from `sqlDir`.
77
+ */
78
+ async initSchema(context, config, sqlDir) {
79
+ const span = tracer.startSpan("PostgresSchemaDbUtilsInit", context);
80
+ const poolOptions = {
81
+ host: config.DATABASE_POSTGRES_HOST,
82
+ port: config.DATABASE_POSTGRES_PORT || 5432,
83
+ user: config.DATABASE_POSTGRES_USER,
84
+ password: config.DATABASE_POSTGRES_PASSWORD,
85
+ database: config.DATABASE_POSTGRES_DATABASE,
86
+ options: `-c search_path=${this.schemaName}`,
87
+ max: 5,
88
+ idleTimeoutMillis: 30000,
89
+ connectionTimeoutMillis: 10000,
90
+ };
91
+ if (this.schemaPool) {
92
+ this.moduleLogger.info("Closing existing schema pool");
93
+ await this.schemaPool.end().catch(() => {
94
+ // Ignore errors on close
95
+ });
96
+ }
97
+ this.schemaPool = new pg_1.Pool(poolOptions);
98
+ this.moduleLogger.info(`Schema pool initialized with search_path: ${this.schemaName}`);
99
+ // Create schema if not exists
100
+ await this.execSQLForSchema(span, `CREATE SCHEMA IF NOT EXISTS ${this.schemaName};`);
101
+ await this.execSQLForSchema(span, `SET search_path TO ${this.schemaName};`);
102
+ // Run init SQL files
103
+ await this.execSQLFileForSchema(span, `${sqlDir}/init-0000.sql`);
104
+ const initFiles = (await fs.readdir(sqlDir)).sort();
105
+ let dbVersionApplied = 0;
106
+ try {
107
+ const dbVersionQuery = await this.querySQLForSchema(span, "SELECT MAX(value) as version FROM metadata WHERE type='db_version'");
108
+ if (dbVersionQuery[0].version) {
109
+ dbVersionApplied = Number(dbVersionQuery[0].version);
110
+ }
111
+ }
112
+ catch {
113
+ // Table might not exist yet
114
+ }
115
+ this.moduleLogger.info(`Current DB Version: ${dbVersionApplied}`);
116
+ for (const initFile of initFiles) {
117
+ const regex = /init-(\d+)\.sql/g;
118
+ const match = regex.exec(initFile);
119
+ if (match) {
120
+ const dbVersionInitFile = Number(match[1]);
121
+ if (dbVersionInitFile > dbVersionApplied) {
122
+ this.moduleLogger.info(`Applying migration: ${initFile}`);
123
+ await this.execSQLFileForSchema(span, `${sqlDir}/${initFile}`);
124
+ await this.querySQLForSchema(span, 'INSERT INTO metadata ("type", "value", "dateCreated") VALUES ($1, $2, $3)', ["db_version", dbVersionInitFile, new Date().toISOString()]);
125
+ }
126
+ }
127
+ }
128
+ span.end();
129
+ }
130
+ /**
131
+ * Initialise (or replace) the shared runtime pool.
132
+ * Typically called once with a pool whose `search_path` includes all
133
+ * application schemas.
134
+ */
135
+ initRuntimePool(config, searchPath) {
136
+ if (this.runtimePool) {
137
+ this.runtimePool.end().catch(() => {
138
+ // Ignore errors on close
139
+ });
140
+ }
141
+ this.runtimePool = new pg_1.Pool({
142
+ host: config.DATABASE_POSTGRES_HOST,
143
+ port: config.DATABASE_POSTGRES_PORT || 5432,
144
+ user: config.DATABASE_POSTGRES_USER,
145
+ password: config.DATABASE_POSTGRES_PASSWORD,
146
+ database: config.DATABASE_POSTGRES_DATABASE,
147
+ options: searchPath
148
+ ? `-c search_path=${searchPath}`
149
+ : `-c search_path=${this.schemaName}`,
150
+ max: 20,
151
+ idleTimeoutMillis: 30000,
152
+ connectionTimeoutMillis: 10000,
153
+ keepAlive: true,
154
+ });
155
+ this.moduleLogger.info(`Runtime pool initialized (search_path: ${searchPath || this.schemaName})`);
156
+ }
157
+ /**
158
+ * Execute a write SQL statement with OTel tracing.
159
+ * @param useSchemaPool When `true` use the schema-specific pool;
160
+ * otherwise use the runtime pool (default).
161
+ * @returns Number of rows changed.
162
+ */
163
+ execSQL(context, sql,
164
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
165
+ params = [], useSchemaPool = false) {
166
+ const span = tracer.startSpan("PostgresSchemaDbUtilsExecSQL", context);
167
+ const pool = useSchemaPool ? this.schemaPool : this.runtimePool;
168
+ if (!pool) {
169
+ throw new Error(`Pool not initialized${useSchemaPool ? ` for schema: ${this.schemaName}` : ""}`);
170
+ }
171
+ return new Promise((resolve, reject) => {
172
+ pool.query(sql, params, (error, result) => {
173
+ span.end();
174
+ if (error) {
175
+ span.setStatus({
176
+ code: api_1.SpanStatusCode.ERROR,
177
+ message: error.message,
178
+ });
179
+ this.moduleLogger.error(`[${useSchemaPool ? this.schemaName : "RUNTIME"}] SQL EXEC ERROR: ${sql}`, error);
180
+ reject(error);
181
+ }
182
+ else {
183
+ resolve(result.rowCount || 0);
184
+ }
185
+ });
186
+ });
187
+ }
188
+ /** Execute an entire SQL file (used for migrations). */
189
+ async execSQLFile(context, filename, useSchemaPool = false) {
190
+ const span = tracer.startSpan("PostgresSchemaDbUtilsExecSQLFile", context);
191
+ const sql = (await fs.readFile(filename)).toString();
192
+ const pool = useSchemaPool ? this.schemaPool : this.runtimePool;
193
+ if (!pool) {
194
+ throw new Error(`Pool not initialized${useSchemaPool ? ` for schema: ${this.schemaName}` : ""}`);
195
+ }
196
+ return new Promise((resolve, reject) => {
197
+ pool.query(sql, (error) => {
198
+ span.end();
199
+ if (error) {
200
+ span.setStatus({
201
+ code: api_1.SpanStatusCode.ERROR,
202
+ message: error.message,
203
+ });
204
+ reject(error);
205
+ }
206
+ else {
207
+ resolve();
208
+ }
209
+ });
210
+ });
211
+ }
212
+ /**
213
+ * Execute a read SQL query with OTel tracing.
214
+ * @returns Array of row objects.
215
+ */
216
+ querySQL(context, sql,
217
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
218
+ params = [], useSchemaPool = false) {
219
+ const span = tracer.startSpan("PostgresSchemaDbUtilsQuerySQL", context);
220
+ const pool = useSchemaPool ? this.schemaPool : this.runtimePool;
221
+ if (!pool) {
222
+ throw new Error(`Pool not initialized${useSchemaPool ? ` for schema: ${this.schemaName}` : ""}`);
223
+ }
224
+ return new Promise((resolve, reject) => {
225
+ pool.query(sql, params, (error, result) => {
226
+ span.end();
227
+ if (error) {
228
+ span.setStatus({
229
+ code: api_1.SpanStatusCode.ERROR,
230
+ message: error.message,
231
+ });
232
+ this.moduleLogger.error(`[${useSchemaPool ? this.schemaName : "RUNTIME"}] SQL QUERY ERROR: ${sql}`, error);
233
+ reject(error);
234
+ }
235
+ else {
236
+ resolve(result.rows);
237
+ }
238
+ });
239
+ });
240
+ }
241
+ /**
242
+ * Run a callback inside a transaction.
243
+ */
244
+ async transaction(context,
245
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
246
+ callback, useSchemaPool = false) {
247
+ 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();
254
+ try {
255
+ await client.query("BEGIN");
256
+ await callback(client);
257
+ await client.query("COMMIT");
258
+ this.moduleLogger.info(`[${useSchemaPool ? this.schemaName : "RUNTIME"}] Transaction committed`);
259
+ }
260
+ catch (error) {
261
+ await client.query("ROLLBACK");
262
+ this.moduleLogger.error(`[${useSchemaPool ? this.schemaName : "RUNTIME"}] Transaction rolled back`, error);
263
+ throw error;
264
+ }
265
+ finally {
266
+ client.release();
267
+ span.end();
268
+ }
269
+ }
270
+ /** Close both the schema pool and the runtime pool. */
271
+ async closeAll() {
272
+ const promises = [];
273
+ if (this.schemaPool) {
274
+ this.moduleLogger.info("Closing schema pool");
275
+ promises.push(this.schemaPool.end().catch(() => {
276
+ this.moduleLogger.warn("Error closing schema pool");
277
+ }));
278
+ this.schemaPool = null;
279
+ }
280
+ if (this.runtimePool) {
281
+ this.moduleLogger.info("Closing runtime pool");
282
+ promises.push(this.runtimePool.end().catch(() => {
283
+ this.moduleLogger.warn("Error closing runtime pool");
284
+ }));
285
+ this.runtimePool = null;
286
+ }
287
+ await Promise.all(promises);
288
+ this.moduleLogger.info("All database pools closed");
289
+ }
290
+ // -- Internal helpers (schema pool only) ----------------------------------
291
+ execSQLForSchema(context, sql,
292
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
293
+ params = []) {
294
+ const span = tracer.startSpan("PostgresSchemaDbUtilsExecSQLForSchema", context);
295
+ if (!this.schemaPool) {
296
+ throw new Error(`Pool not initialized for schema: ${this.schemaName}`);
297
+ }
298
+ return new Promise((resolve, reject) => {
299
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
300
+ this.schemaPool.query(sql, params, (error) => {
301
+ span.end();
302
+ if (error) {
303
+ reject(error);
304
+ }
305
+ else {
306
+ resolve();
307
+ }
308
+ });
309
+ });
310
+ }
311
+ async execSQLFileForSchema(context, filename) {
312
+ try {
313
+ const span = tracer.startSpan("PostgresSchemaDbUtilsExecSQLFileForSchema", context);
314
+ const sql = (await fs.readFile(filename)).toString();
315
+ if (!this.schemaPool) {
316
+ throw new Error(`Pool not initialized for schema: ${this.schemaName}`);
317
+ }
318
+ return new Promise((resolve, reject) => {
319
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
320
+ this.schemaPool.query(sql, (error) => {
321
+ span.end();
322
+ if (error) {
323
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
324
+ if (error.code === "ENOENT") {
325
+ resolve();
326
+ }
327
+ else {
328
+ reject(error);
329
+ }
330
+ }
331
+ else {
332
+ resolve();
333
+ }
334
+ });
335
+ });
336
+ }
337
+ catch (error) {
338
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
339
+ if (error.code === "ENOENT") {
340
+ return;
341
+ }
342
+ throw error;
343
+ }
344
+ }
345
+ querySQLForSchema(context, sql,
346
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
347
+ params = []) {
348
+ const span = tracer.startSpan("PostgresSchemaDbUtilsQuerySQLForSchema", context);
349
+ if (!this.schemaPool) {
350
+ throw new Error(`Pool not initialized for schema: ${this.schemaName}`);
351
+ }
352
+ return new Promise((resolve, reject) => {
353
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
354
+ this.schemaPool.query(sql, params, (error, result) => {
355
+ span.end();
356
+ if (error) {
357
+ reject(error);
358
+ }
359
+ else {
360
+ resolve(result.rows);
361
+ }
362
+ });
363
+ });
364
+ }
365
+ }
366
+ exports.PostgresSchemaDbUtils = PostgresSchemaDbUtils;
367
+ // ---------------------------------------------------------------------------
368
+ // Functional API – single-pool mode used by the DbUtils facade.
369
+ // An internal PostgresSchemaDbUtils instance backs these functions so the
370
+ // behaviour is identical to before.
371
+ // ---------------------------------------------------------------------------
50
372
  /**
51
373
  * Injects the OTel tracer and logger instances used by all Postgres operations.
52
374
  * Must be called once at startup, before {@link PostgresDbUtilsInit}.
53
375
  */
54
376
  function PostgresDbUtilsSetOTel(tracerIn, loggerIn) {
55
377
  tracer = tracerIn;
378
+ standardLogger = loggerIn;
56
379
  logger = loggerIn.createModuleLogger("PostgresDbUtils");
57
380
  }
58
381
  /**
59
382
  * Creates the Postgres connection pool and applies pending migration files
60
383
  * from `sqlDir`.
61
- *
62
- * Migration files must follow the naming convention `init-NNNN.sql` and are
63
- * applied in lexicographic order. A `metadata` table tracks which migrations
64
- * have already been applied so they are idempotent.
65
- *
66
- * @param context Parent OTel span.
67
- * @param config Configuration with Postgres connection fields.
68
- * @param sqlDir Absolute path to the directory containing SQL migration files
69
- * written for Postgres (with `$1,$2...` placeholders).
70
- * If migrations are SQLite-first, use `convertToPostgresPlaceholders`
71
- * before passing them.
72
384
  */
73
385
  async function PostgresDbUtilsInit(context, config, sqlDir) {
74
386
  const span = tracer.startSpan("PostgresDbUtilsInit", context);
75
- pool = new pg_1.Pool({
387
+ // Use the schema-level init but without schema creation (single-pool mode)
388
+ const poolOptions = {
76
389
  host: config.DATABASE_POSTGRES_HOST,
77
390
  port: config.DATABASE_POSTGRES_PORT || 5432,
78
391
  user: config.DATABASE_POSTGRES_USER,
@@ -82,7 +395,9 @@ async function PostgresDbUtilsInit(context, config, sqlDir) {
82
395
  idleTimeoutMillis: 30000,
83
396
  connectionTimeoutMillis: 10000,
84
397
  keepAlive: true,
85
- });
398
+ };
399
+ // Create a simple pool directly for the functional API
400
+ pool = new pg_1.Pool(poolOptions);
86
401
  pool.on("error", (err) => {
87
402
  logger.error("PostgreSQL pool connection error", err);
88
403
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devopsplaybook.io/common-utils",
3
- "version": "1.0.0-beta.5.e74e0d9",
3
+ "version": "1.1.0-beta.6.9ec503a",
4
4
  "description": "Shared utility modules for devopsplaybook.io projects (DB, Config, OTel context, system helpers)",
5
5
  "keywords": [
6
6
  "Open Telemetry",
@@ -24,7 +24,7 @@
24
24
  "@devopsplaybook.io/otel-utils": "^1.1.0",
25
25
  "@opentelemetry/api": "^1.9.1",
26
26
  "@opentelemetry/sdk-trace-base": "^2.7.1",
27
- "better-sqlite3": "^12.9.0",
27
+ "better-sqlite3": "^12.10.0",
28
28
  "fs-extra": "^11.3.5",
29
29
  "pg": "^8.21.0",
30
30
  "uuid": "^14.0.0"
@@ -34,15 +34,15 @@
34
34
  "@types/better-sqlite3": "^7.6.13",
35
35
  "@types/fs-extra": "^11.0.4",
36
36
  "@types/jest": "^30.0.0",
37
- "@types/node": "^25.9.1",
37
+ "@types/node": "^25.9.2",
38
38
  "@types/pg": "^8.20.0",
39
- "@types/uuid": "^10.0.0",
40
- "eslint": "^10.4.0",
39
+ "@types/uuid": "^11.0.0",
40
+ "eslint": "^10.4.1",
41
41
  "jest": "^30.4.2",
42
42
  "ts-jest": "^29.4.11",
43
43
  "ts-node": "^10.9.2",
44
44
  "typescript": "^6.0.3",
45
- "typescript-eslint": "^8.59.4"
45
+ "typescript-eslint": "^8.60.1"
46
46
  },
47
47
  "publishConfig": {
48
48
  "access": "public"