@onlineapps/service-common 1.2.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -102,6 +102,61 @@ All helpers fail fast: a missing or non-integer `tenant_id`/`workspace_id`
102
102
  throws rather than degrading into "system rows only", which would silently hide
103
103
  a workspace's own definitions.
104
104
 
105
+ ### Registry reader — `createRegistryReader(options)`
106
+
107
+ The single Redis-backed reader of Service Registry specifications. The Registry
108
+ (`api_services_registry`) is the only writer; every consumer — infrastructure or
109
+ business — reads the same two key shapes:
110
+
111
+ | Key | Type | Content |
112
+ |---|---|---|
113
+ | `<keyPrefix>services` | HASH | per-service summary |
114
+ | `<keyPrefix>service:<name>:spec` | STRING | full service specification |
115
+
116
+ Normative contract: [`api/docs/biz/30-operations/registration-wire.md`](../../docs/biz/30-operations/registration-wire.md)
117
+ §4 (storage model) and §7 (consumer contract); namespace per
118
+ [`api/docs/standards/redis-key-contract.md`](../../docs/standards/redis-key-contract.md).
119
+
120
+ ```js
121
+ const { createRegistryReader } = require('@onlineapps/service-common');
122
+
123
+ const reader = createRegistryReader({
124
+ keyPrefix: requireEnv('REDIS_REGISTRY_KEY_PREFIX'), // REQUIRED — never assumed
125
+ redisUrl: requireEnv('REDIS_URL'), // or: client: <connected node-redis v4 client>
126
+ logger // REQUIRED — no console fallback
127
+ });
128
+ await reader.connect();
129
+
130
+ const op = await reader.getOperation('biz-invoicing', 'create-invoice');
131
+ ```
132
+
133
+ | Export | Purpose |
134
+ |---|---|
135
+ | `connect()` | opens the owned connection (hard `connectTimeoutMs` ceiling), or validates an injected client |
136
+ | `getServiceSpec(name)` | full spec, or `null` when the service is not registered |
137
+ | `getOperation(name, key)` | one operation record, or `null` |
138
+ | `listSummary()` | parsed `<keyPrefix>services` hash |
139
+ | `listAll()` | `[{ name, summary, spec }]` for every summarised service |
140
+ | `listWorkspaceScopedServices()` | `[{ name, version, description }]` for `workspaceScoped === true`, sorted by name; `version`/`description` come from `spec.metadata` (§4: `metadata` is required) |
141
+ | `invalidateCache(name?)` | drop one service or the whole cache — for `registry.changes` listeners |
142
+ | `close()` | quits the connection **only** when the reader created it |
143
+
144
+ **Connection ownership:** pass exactly one of `redisUrl` (the reader owns and
145
+ closes the client) or `client` (the caller owns it; `close()` never quits it).
146
+
147
+ **Failure model — an outage is not a cache miss:**
148
+
149
+ | Situation | Answer |
150
+ |---|---|
151
+ | key absent | `null`, remembered for `negativeTtlMs` (default 30 s) |
152
+ | Redis transport error | the error **propagates** — never `null` |
153
+ | unparseable JSON | throws, naming the key |
154
+ | Redis unreachable at startup | `connect()` rejects after `connectTimeoutMs` (default 15 s) |
155
+
156
+ `keyPrefix` and `logger` are mandatory and `ttlMs` / `negativeTtlMs` /
157
+ `connectTimeoutMs` are validated: an invalid value throws instead of being
158
+ silently replaced by the module default (`src/defaults.js` owns the defaults).
159
+
105
160
  ## Architecture
106
161
 
107
162
  This library is used by:
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@onlineapps/service-common",
3
- "version": "1.2.0",
4
- "description": "Common utilities for both infrastructure services and business services (JWT auth, Redis/Postgres clients, business errors, runtime config)",
3
+ "version": "2.0.1",
4
+ "description": "Common utilities for both infrastructure services and business services (JWT auth, Redis client, business errors, runtime config)",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
7
7
  "test": "jest",
8
8
  "test:unit": "jest tests/unit",
9
- "test:integration": "jest tests/integration"
9
+ "test:integration": "jest --config=jest.integration.config.js",
10
+ "test:all": "npm run test && npm run test:integration"
10
11
  },
11
12
  "keywords": [
12
13
  "microservices",
@@ -17,7 +18,7 @@
17
18
  "author": "OA Drive Team",
18
19
  "license": "MIT",
19
20
  "dependencies": {
20
- "@onlineapps/runtime-config": "1.0.2",
21
+ "@onlineapps/runtime-config": "1.0.3",
21
22
  "jsonwebtoken": "^9.0.3",
22
23
  "nodemailer": "^6.9.8",
23
24
  "redis": "^4.6.0"
package/src/defaults.js CHANGED
@@ -20,6 +20,16 @@ module.exports = {
20
20
 
21
21
  infrastructureHealthQueueWaitMaxTimeMs: 60000,
22
22
  infrastructureHealthQueueWaitCheckIntervalMs: 2000,
23
+
24
+ // registryReader in-memory cache (see ./registryReader.js).
25
+ // Positive entries live 5 minutes; a "service not registered" answer is
26
+ // remembered for 30 s only, so a fresh registration becomes visible quickly.
27
+ registryReaderTtlMs: 300000,
28
+ registryReaderNegativeTtlMs: 30000,
29
+ // Hard ceiling for the initial connect when the reader owns the client, so a
30
+ // dead Redis fails startup instead of retrying forever (same 15 s ceiling as
31
+ // connectRedis in ./redisClient.js).
32
+ registryReaderConnectTimeoutMs: 15000,
23
33
  };
24
34
 
25
35
 
package/src/index.js CHANGED
@@ -17,11 +17,7 @@ const {
17
17
  createRedisClient,
18
18
  connectRedis
19
19
  } = require('./redisClient');
20
- const {
21
- buildPostgresUrl,
22
- createPostgresPool,
23
- connectPostgres
24
- } = require('./postgresClient');
20
+ const { createRegistryReader } = require('./registryReader');
25
21
  const {
26
22
  requireEnv,
27
23
  requireNumberEnv,
@@ -79,10 +75,9 @@ module.exports = {
79
75
  createRedisClient,
80
76
  connectRedis,
81
77
 
82
- // PostgreSQL utilities (shared between services and tests)
83
- buildPostgresUrl,
84
- createPostgresPool,
85
- connectPostgres,
78
+ // Service Registry reader (the one Redis-backed reader of registry specs)
79
+ // See: docs/biz/30-operations/registration-wire.md §4/§7
80
+ createRegistryReader,
86
81
 
87
82
  // Configuration helpers (NO FALLBACKS for critical infrastructure)
88
83
  requireEnv,
@@ -29,7 +29,7 @@ const runtimeCfg = require('../config');
29
29
  * @param {string} [options.redisUrl] - Redis URL (default: REDIS_URL env or redis://api_node_cache:6379)
30
30
  * @param {number} [options.maxWait] - Maximum wait time in ms (default: 60000 = 1 minute)
31
31
  * @param {number} [options.checkInterval] - Check interval in ms (default: 2000 = 2 seconds)
32
- * @param {Object} [options.logger] - Logger instance (default: console)
32
+ * @param {Object} options.logger - Logger instance (required; no console fallback)
33
33
  * @returns {Promise<boolean>} - True if queue is ready
34
34
  * @throws {Error} - If timeout is reached
35
35
  */
@@ -37,7 +37,7 @@ const runtimeCfg = require('../config');
37
37
  * @param {string} [options.redisUrl] - Redis URL (default: REDIS_URL env or redis://api_node_cache:6379)
38
38
  * @param {number} [options.maxWait] - Maximum wait time in ms (default: INFRASTRUCTURE_HEALTH_WAIT_MAX_TIME env or 60000 = 1 minute)
39
39
  * @param {number} [options.checkInterval] - Check interval in ms (default: INFRASTRUCTURE_HEALTH_WAIT_CHECK_INTERVAL env or 2000 = 2 seconds)
40
- * @param {Object} [options.logger] - Logger instance (default: console)
40
+ * @param {Object} options.logger - Logger instance (required; no console fallback)
41
41
  * @returns {Promise<boolean>} - True if all infrastructure services are ready
42
42
  * @throws {Error} - If timeout is reached
43
43
  *
@@ -0,0 +1,355 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * registryReader
5
+ * --------------
6
+ * The one Redis-backed reader of the Service Registry specifications.
7
+ *
8
+ * The Registry (`api_services_registry`) is the single writer; every consumer —
9
+ * infrastructure or business — reads the same two key shapes:
10
+ *
11
+ * `<keyPrefix>services` HASH — per-service summary
12
+ * `<keyPrefix>service:<name>:spec` STRING — full service specification
13
+ *
14
+ * The prefix is NEVER assumed. It is injected by the caller (the platform value
15
+ * lives in REDIS_REGISTRY_KEY_PREFIX), because a reader that hardcodes a
16
+ * namespace cannot be pointed at a second one — not in a test, not in a second
17
+ * environment.
18
+ *
19
+ * Failure model (fail-fast, architecture-principles §3/§4):
20
+ * - key absent → `null`, remembered for `negativeTtlMs`
21
+ * - Redis transport error → the error PROPAGATES (never `null`)
22
+ * - unparseable JSON → throws, naming the key
23
+ *
24
+ * An infrastructure outage and "this service is not registered" are different
25
+ * facts and must not collapse into the same answer.
26
+ *
27
+ * @see api/docs/biz/30-operations/registration-wire.md §4 Redis storage model
28
+ * @see api/docs/biz/30-operations/registration-wire.md §7 Consumer contract
29
+ * @see api/docs/standards/redis-key-contract.md — registry namespace
30
+ */
31
+
32
+ const { createClient } = require('redis');
33
+ const DEFAULTS = require('./defaults');
34
+
35
+ const CONTEXT = '[service-common][registryReader]';
36
+
37
+ function requireKeyPrefix(keyPrefix) {
38
+ if (typeof keyPrefix !== 'string' || keyPrefix.trim().length === 0) {
39
+ throw new Error(
40
+ `${CONTEXT} Missing required option - keyPrefix must be a non-empty string. `
41
+ + 'Fix: pass the registry key namespace explicitly, e.g. the value of REDIS_REGISTRY_KEY_PREFIX '
42
+ + `(got ${JSON.stringify(keyPrefix)}).`
43
+ );
44
+ }
45
+ return keyPrefix;
46
+ }
47
+
48
+ function requireLogger(logger) {
49
+ const missing = ['info', 'warn', 'error'].filter(
50
+ (method) => !logger || typeof logger[method] !== 'function'
51
+ );
52
+ if (missing.length > 0) {
53
+ throw new Error(
54
+ `${CONTEXT} Missing dependency - logger is required and must implement ${missing.join(', ')} `
55
+ + '(no console fallback). Fix: inject the service logger.'
56
+ );
57
+ }
58
+ return logger;
59
+ }
60
+
61
+ function requireTtl(value, name, fallbackDefault) {
62
+ if (value === undefined) return fallbackDefault;
63
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
64
+ throw new Error(
65
+ `${CONTEXT} Invalid option - ${name} must be a finite positive number of milliseconds `
66
+ + `(got ${JSON.stringify(value)}). Fix: omit it to use the module default, or pass a number > 0.`
67
+ );
68
+ }
69
+ return value;
70
+ }
71
+
72
+ function requireClientContract(client) {
73
+ const missing = ['get', 'hGetAll'].filter((method) => typeof client[method] !== 'function');
74
+ if (missing.length > 0) {
75
+ throw new Error(
76
+ `${CONTEXT} Redis client contract mismatch - injected client is missing: ${missing.join(', ')}. `
77
+ + 'Fix: inject a connected node-redis v4 client.'
78
+ );
79
+ }
80
+ return client;
81
+ }
82
+
83
+ function requireNonEmptyString(value, label) {
84
+ if (typeof value !== 'string' || value.length === 0) {
85
+ throw new Error(
86
+ `${CONTEXT} Invalid argument - ${label} must be a non-empty string (got ${JSON.stringify(value)}).`
87
+ );
88
+ }
89
+ return value;
90
+ }
91
+
92
+ function parseOrThrow(raw, key, what) {
93
+ try {
94
+ return JSON.parse(raw);
95
+ } catch (err) {
96
+ throw new Error(
97
+ `${CONTEXT} Corrupt registry data - ${what} at ${key} is not valid JSON (${err.message}). `
98
+ + 'Fix: the Registry is the single writer of this key — re-register the service.'
99
+ );
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Create a registry reader.
105
+ *
106
+ * Exactly one connection source must be given:
107
+ * - `redisUrl` — the reader creates and owns the client (closed by `close()`)
108
+ * - `client` — an already connected node-redis v4 client owned by the caller
109
+ * (`close()` never quits it)
110
+ *
111
+ * @param {object} options
112
+ * @param {string} options.keyPrefix Registry key namespace (REQUIRED, no default)
113
+ * @param {string} [options.redisUrl] Redis connection URL
114
+ * @param {object} [options.client] Connected node-redis v4 client
115
+ * @param {object} options.logger Logger implementing info/warn/error (REQUIRED)
116
+ * @param {number} [options.ttlMs] TTL of a cached spec (default: defaults.registryReaderTtlMs)
117
+ * @param {number} [options.negativeTtlMs] TTL of a cached "not registered" (default: defaults.registryReaderNegativeTtlMs)
118
+ * @returns {object} reader
119
+ */
120
+ function createRegistryReader(options) {
121
+ if (!options || typeof options !== 'object') {
122
+ throw new Error(
123
+ `${CONTEXT} Missing options - createRegistryReader({ keyPrefix, redisUrl|client, logger }) is required.`
124
+ );
125
+ }
126
+
127
+ const { keyPrefix, redisUrl, client, logger, ttlMs, negativeTtlMs, connectTimeoutMs } = options;
128
+
129
+ requireKeyPrefix(keyPrefix);
130
+
131
+ const hasUrl = redisUrl !== undefined;
132
+ const hasClient = client !== undefined;
133
+ if (hasUrl === hasClient) {
134
+ throw new Error(
135
+ `${CONTEXT} Invalid connection source - pass exactly one of redisUrl or client `
136
+ + `(redisUrl: ${hasUrl ? 'set' : 'absent'}, client: ${hasClient ? 'set' : 'absent'}). `
137
+ + 'Fix: let the reader own the connection (redisUrl) or inject an existing one (client).'
138
+ );
139
+ }
140
+ if (hasUrl && (typeof redisUrl !== 'string' || redisUrl.trim().length === 0)) {
141
+ throw new Error(
142
+ `${CONTEXT} Invalid option - redisUrl must be a non-empty string (got ${JSON.stringify(redisUrl)}).`
143
+ );
144
+ }
145
+ if (hasClient) {
146
+ if (!client || typeof client !== 'object') {
147
+ throw new Error(`${CONTEXT} Invalid option - client must be a connected node-redis v4 client object.`);
148
+ }
149
+ requireClientContract(client);
150
+ }
151
+
152
+ const log = requireLogger(logger);
153
+ const positiveTtl = requireTtl(ttlMs, 'ttlMs', DEFAULTS.registryReaderTtlMs);
154
+ const negativeTtl = requireTtl(negativeTtlMs, 'negativeTtlMs', DEFAULTS.registryReaderNegativeTtlMs);
155
+ const connectTimeout = requireTtl(
156
+ connectTimeoutMs, 'connectTimeoutMs', DEFAULTS.registryReaderConnectTimeoutMs
157
+ );
158
+
159
+ const ownsClient = hasUrl;
160
+ let redis = hasClient ? client : null;
161
+ let connected = false;
162
+
163
+ /** @type {Map<string, {spec: object|null, expiresAt: number}>} */
164
+ const specCache = new Map();
165
+ let summaryCache = null;
166
+
167
+ const summaryKey = () => `${keyPrefix}services`;
168
+ const specKey = (serviceName) => `${keyPrefix}service:${serviceName}:spec`;
169
+
170
+ function assertConnected(method) {
171
+ if (!connected) {
172
+ throw new Error(
173
+ `${CONTEXT} Reader is not connected - ${method} was called before connect(). `
174
+ + 'Fix: await reader.connect() during service startup.'
175
+ );
176
+ }
177
+ }
178
+
179
+ async function connect() {
180
+ if (connected) return;
181
+
182
+ if (ownsClient) {
183
+ const created = createClient({ url: redisUrl, socket: { connectTimeout } });
184
+ created.on('error', (err) => {
185
+ log.error(`${CONTEXT} Redis client error: ${err.message}`);
186
+ });
187
+
188
+ // node-redis keeps retrying a refused connection forever, so the initial
189
+ // connect gets its own hard ceiling — startup must fail, not hang.
190
+ let timeoutHandle = null;
191
+ const deadline = new Promise((_resolve, reject) => {
192
+ timeoutHandle = setTimeout(() => reject(new Error(
193
+ `${CONTEXT} Redis connection timeout - no connection to ${redisUrl} within ${connectTimeout}ms. `
194
+ + 'Fix: check the Redis address and that the instance is running.'
195
+ )), connectTimeout);
196
+ });
197
+
198
+ try {
199
+ await Promise.race([created.connect(), deadline]);
200
+ } catch (err) {
201
+ try {
202
+ await created.disconnect();
203
+ } catch (_ignored) {
204
+ // The client never opened; nothing to release.
205
+ }
206
+ throw err;
207
+ } finally {
208
+ clearTimeout(timeoutHandle);
209
+ }
210
+ redis = created;
211
+ }
212
+
213
+ connected = true;
214
+ log.info(`${CONTEXT} Ready (keyPrefix="${keyPrefix}", ttl=${positiveTtl}ms, negativeTtl=${negativeTtl}ms)`);
215
+ }
216
+
217
+ async function getServiceSpec(serviceName) {
218
+ assertConnected('getServiceSpec()');
219
+ requireNonEmptyString(serviceName, 'serviceName');
220
+
221
+ const now = Date.now();
222
+ const cached = specCache.get(serviceName);
223
+ if (cached && cached.expiresAt > now) {
224
+ return cached.spec;
225
+ }
226
+
227
+ const key = specKey(serviceName);
228
+ // Transport errors propagate on purpose — see the failure model above.
229
+ const raw = await redis.get(key);
230
+
231
+ if (raw === null || raw === undefined) {
232
+ specCache.set(serviceName, { spec: null, expiresAt: now + negativeTtl });
233
+ return null;
234
+ }
235
+
236
+ const spec = parseOrThrow(raw, key, 'service spec');
237
+ specCache.set(serviceName, { spec, expiresAt: now + positiveTtl });
238
+ return spec;
239
+ }
240
+
241
+ async function getOperation(serviceName, operationKey) {
242
+ assertConnected('getOperation()');
243
+ requireNonEmptyString(serviceName, 'serviceName');
244
+ requireNonEmptyString(operationKey, 'operationKey');
245
+
246
+ const spec = await getServiceSpec(serviceName);
247
+ const operations = spec && spec.operations;
248
+ if (!operations || typeof operations !== 'object') {
249
+ return null;
250
+ }
251
+ if (!Object.prototype.hasOwnProperty.call(operations, operationKey)) {
252
+ return null;
253
+ }
254
+ return operations[operationKey];
255
+ }
256
+
257
+ async function listSummary() {
258
+ assertConnected('listSummary()');
259
+
260
+ const now = Date.now();
261
+ if (summaryCache && summaryCache.expiresAt > now) {
262
+ return summaryCache.services;
263
+ }
264
+
265
+ const key = summaryKey();
266
+ const raw = await redis.hGetAll(key);
267
+
268
+ const services = {};
269
+ for (const [name, json] of Object.entries(raw || {})) {
270
+ services[name] = parseOrThrow(json, key, `summary entry "${name}"`);
271
+ }
272
+
273
+ summaryCache = { services, expiresAt: now + positiveTtl };
274
+ return services;
275
+ }
276
+
277
+ async function listAll() {
278
+ assertConnected('listAll()');
279
+ const summary = await listSummary();
280
+ return Promise.all(
281
+ Object.keys(summary).map(async (name) => ({
282
+ name,
283
+ summary: summary[name],
284
+ spec: await getServiceSpec(name)
285
+ }))
286
+ );
287
+ }
288
+
289
+ async function listWorkspaceScopedServices() {
290
+ assertConnected('listWorkspaceScopedServices()');
291
+ const all = await listAll();
292
+
293
+ const scoped = [];
294
+ for (const { name, spec } of all) {
295
+ if (!spec || spec.workspaceScoped !== true) continue;
296
+
297
+ const metadata = spec.metadata;
298
+ const version = metadata && metadata.version;
299
+ const description = metadata && metadata.description;
300
+ if (typeof version !== 'string' || version.length === 0
301
+ || typeof description !== 'string' || description.length === 0) {
302
+ throw new Error(
303
+ `${CONTEXT} Invalid service spec - service "${name}" declares workspaceScoped: true but its `
304
+ + 'metadata block does not carry both version and description '
305
+ + '(registration-wire.md §4: metadata is required). Fix: correct config.json and re-register.'
306
+ );
307
+ }
308
+ scoped.push({ name, version, description });
309
+ }
310
+
311
+ scoped.sort((a, b) => a.name.localeCompare(b.name));
312
+ return scoped;
313
+ }
314
+
315
+ function invalidateCache(serviceName) {
316
+ if (serviceName) {
317
+ specCache.delete(serviceName);
318
+ return;
319
+ }
320
+ specCache.clear();
321
+ summaryCache = null;
322
+ }
323
+
324
+ async function close() {
325
+ if (ownsClient && redis && connected) {
326
+ try {
327
+ await redis.quit();
328
+ } catch (err) {
329
+ log.warn(`${CONTEXT} Redis quit error: ${err.message}`);
330
+ }
331
+ redis = null;
332
+ }
333
+ connected = false;
334
+ specCache.clear();
335
+ summaryCache = null;
336
+ }
337
+
338
+ return {
339
+ connect,
340
+ getServiceSpec,
341
+ getOperation,
342
+ listSummary,
343
+ listAll,
344
+ listWorkspaceScopedServices,
345
+ invalidateCache,
346
+ close,
347
+ get keyPrefix() { return keyPrefix; },
348
+ get ttlMs() { return positiveTtl; },
349
+ get negativeTtlMs() { return negativeTtl; },
350
+ get isConnected() { return connected; },
351
+ get cacheSize() { return specCache.size; }
352
+ };
353
+ }
354
+
355
+ module.exports = { createRegistryReader };
Binary file
@@ -1,112 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * PostgreSQL Client Utilities
5
- *
6
- * Provides helper functions for building PostgreSQL connection URLs.
7
- *
8
- * NO FALLBACKS: Topology must be explicit via ENV/config. This module must not
9
- * embed docker-compose hostnames or credentials.
10
- */
11
-
12
- /**
13
- * Build a PostgreSQL connection URL from environment.
14
- *
15
- * @param {Object} options - Options
16
- * @param {Object} [options.env=process.env] - Environment variables
17
- * @returns {string} PostgreSQL connection URL
18
- */
19
- function buildPostgresUrl(options = {}) {
20
- const env = options.env || process.env || {};
21
-
22
- const url = env.POSTGRES_URL && typeof env.POSTGRES_URL === 'string' ? env.POSTGRES_URL.trim() : '';
23
- if (url) return url;
24
-
25
- const host = env.POSTGRES_HOST && typeof env.POSTGRES_HOST === 'string' ? env.POSTGRES_HOST.trim() : '';
26
- const port = env.POSTGRES_PORT && typeof env.POSTGRES_PORT === 'string' ? env.POSTGRES_PORT.trim() : '';
27
- const user = env.POSTGRES_USER && typeof env.POSTGRES_USER === 'string' ? env.POSTGRES_USER.trim() : '';
28
- const password = env.POSTGRES_PASSWORD && typeof env.POSTGRES_PASSWORD === 'string' ? env.POSTGRES_PASSWORD.trim() : '';
29
- const database = env.POSTGRES_DB && typeof env.POSTGRES_DB === 'string' ? env.POSTGRES_DB.trim() : '';
30
-
31
- const anyPartsProvided = Boolean(host || port || user || password || database);
32
- if (!anyPartsProvided) {
33
- throw new Error(
34
- '[service-common][postgres] Missing configuration - POSTGRES_URL is required, or provide all: POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB.'
35
- );
36
- }
37
-
38
- const missing = [];
39
- if (!host) missing.push('POSTGRES_HOST');
40
- if (!port) missing.push('POSTGRES_PORT');
41
- if (!user) missing.push('POSTGRES_USER');
42
- if (!password) missing.push('POSTGRES_PASSWORD');
43
- if (!database) missing.push('POSTGRES_DB');
44
- if (missing.length > 0) {
45
- throw new Error(
46
- `[service-common][postgres] Missing configuration - Partial PostgreSQL config provided. Missing: ${missing.join(', ')}. ` +
47
- 'Fix: set POSTGRES_URL or set all POSTGRES_* parts.'
48
- );
49
- }
50
-
51
- // Do not attempt to do smart encoding here; POSTGRES_URL is the recommended option.
52
- return `postgres://${user}:${password}@${host}:${port}/${database}`;
53
- }
54
-
55
- /**
56
- * Create a PostgreSQL connection pool
57
- *
58
- * @param {Object} options - Options
59
- * @param {Object} [options.env=process.env] - Environment variables
60
- * @param {Object} [options.defaults] - Default connection parameters
61
- * @param {Object} [options.poolConfig] - Additional pool configuration (max, idleTimeoutMillis, etc.)
62
- * @returns {Object} PostgreSQL Pool instance
63
- */
64
- function createPostgresPool(options = {}) {
65
- const { Pool } = require('pg');
66
-
67
- const connectionString = buildPostgresUrl({
68
- env: options.env
69
- });
70
-
71
- const poolConfig = {
72
- connectionString,
73
- max: options.poolConfig?.max || 10,
74
- idleTimeoutMillis: options.poolConfig?.idleTimeoutMillis || 30000,
75
- connectionTimeoutMillis: options.poolConfig?.connectionTimeoutMillis || 2000,
76
- ...options.poolConfig
77
- };
78
-
79
- return new Pool(poolConfig);
80
- }
81
-
82
- /**
83
- * Connect to PostgreSQL and test connection
84
- *
85
- * @param {Object} pool - PostgreSQL Pool instance
86
- * @param {Object} logger - Logger object with info/error methods
87
- * @returns {Promise<void>}
88
- */
89
- async function connectPostgres(pool, logger) {
90
- if (!logger) {
91
- throw new Error('[service-common][postgres] Missing dependency - logger is required (no console fallback).');
92
- }
93
- try {
94
- await pool.query('SELECT NOW()');
95
- if (logger && logger.info) {
96
- logger.info('[PostgreSQL] Connected');
97
- }
98
- return true;
99
- } catch (error) {
100
- if (logger && logger.error) {
101
- logger.error('[PostgreSQL] Connection failed:', error);
102
- }
103
- throw error;
104
- }
105
- }
106
-
107
- module.exports = {
108
- buildPostgresUrl,
109
- createPostgresPool,
110
- connectPostgres
111
- };
112
-