@devopsplaybook.io/common-utils 1.0.0 → 1.1.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 +53 -0
- package/dist/src/PostgresDbUtils.d.ts +50 -11
- package/dist/src/PostgresDbUtils.js +328 -13
- package/package.json +6 -6
- package/src/PostgresDbUtils.ts +464 -13
package/README.md
CHANGED
|
@@ -169,6 +169,59 @@ Pool defaults: `max: 20`, `idleTimeoutMillis: 30000`, `connectionTimeoutMillis:
|
|
|
169
169
|
|
|
170
170
|
---
|
|
171
171
|
|
|
172
|
+
#### `PostgresSchemaDbUtils` -- Multi-Schema PostgreSQL Access
|
|
173
|
+
|
|
174
|
+
Class-based PostgreSQL utility that manages per-schema connection pools (used during migrations) and an optional shared runtime pool (used for application queries). Ideal for multi-schema setups where each module owns its own schema.
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { PostgresSchemaDbUtils } from "@devopsplaybook.io/common-utils";
|
|
178
|
+
|
|
179
|
+
// Create one instance per schema
|
|
180
|
+
const AuthDb = new PostgresSchemaDbUtils("AUTH");
|
|
181
|
+
const DictionaryDb = new PostgresSchemaDbUtils("DICTIONARY");
|
|
182
|
+
|
|
183
|
+
// Initialize OTel (once, shared across all instances)
|
|
184
|
+
AuthDb.initOTel(tracer, logger);
|
|
185
|
+
|
|
186
|
+
// Init schema pool (creates schema + runs migrations)
|
|
187
|
+
await AuthDb.initSchema(span, config, path.resolve(__dirname, "../sql/auth"));
|
|
188
|
+
await DictionaryDb.initSchema(span, config, path.resolve(__dirname, "../sql/dictionary"));
|
|
189
|
+
|
|
190
|
+
// Init shared runtime pool (call on any instance)
|
|
191
|
+
AuthDb.initRuntimePool(config);
|
|
192
|
+
|
|
193
|
+
// Query using the schema-specific pool
|
|
194
|
+
const users = await AuthDb.querySQL(span, "SELECT * FROM users WHERE id = $1", [userId]);
|
|
195
|
+
|
|
196
|
+
// Or use the runtime pool for non-migration queries
|
|
197
|
+
const rows = await AuthDb.querySQL(span, "SELECT ...", [], false);
|
|
198
|
+
|
|
199
|
+
// Transactions
|
|
200
|
+
await AuthDb.transaction(span, async (client) => {
|
|
201
|
+
await client.query("INSERT INTO ...", [...]);
|
|
202
|
+
await client.query("UPDATE ...", [...]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Cleanup
|
|
206
|
+
await AuthDb.closeAll();
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
| Method | Description |
|
|
210
|
+
| --------------------------------------------------- | --------------------------------------------------- |
|
|
211
|
+
| `new PostgresSchemaDbUtils(schemaName)` | Create instance for a specific schema |
|
|
212
|
+
| `initOTel(tracer, logger)` | Inject OTel instances (shared across all instances) |
|
|
213
|
+
| `initSchema(context, config, sqlDir)` | Create schema pool and run migrations |
|
|
214
|
+
| `initRuntimePool(config)` | Create shared runtime pool |
|
|
215
|
+
| `execSQL(context, sql, params?, useSchemaPool?)` | Execute write, returns `Promise<number>` |
|
|
216
|
+
| `execSQLFile(context, filename, useSchemaPool?)` | Execute an entire SQL file |
|
|
217
|
+
| `querySQL(context, sql, params?, useSchemaPool?)` | Execute read, returns `Promise<any[]>` |
|
|
218
|
+
| `transaction(context, callback, useSchemaPool?)` | Run callback inside a transaction |
|
|
219
|
+
| `closeAll()` | Close all pools managed by this instance |
|
|
220
|
+
|
|
221
|
+
`useSchemaPool` (default `true`) selects between the schema-specific pool (migrations) and the shared runtime pool (application queries).
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
172
225
|
#### `DbUtils` -- Unified Database Facade
|
|
173
226
|
|
|
174
227
|
Dispatches to SQLite or Postgres based on `config.DATABASE_TYPE`. Write SQL using SQLite-style `?` placeholders; they are automatically converted to `$1, $2, ...` for Postgres.
|
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
"version": "1.1.0",
|
|
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.
|
|
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.
|
|
37
|
+
"@types/node": "^25.9.2",
|
|
38
38
|
"@types/pg": "^8.20.0",
|
|
39
|
-
"@types/uuid": "^
|
|
40
|
-
"eslint": "^10.4.
|
|
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.
|
|
45
|
+
"typescript-eslint": "^8.60.1"
|
|
46
46
|
},
|
|
47
47
|
"publishConfig": {
|
|
48
48
|
"access": "public"
|
package/src/PostgresDbUtils.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|