@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 +55 -0
- package/package.json +5 -4
- package/src/defaults.js +10 -0
- package/src/index.js +4 -9
- package/src/infrastructure/waitForHealthCheckQueueReady.js +1 -1
- package/src/infrastructure/waitForInfrastructureReady.js +1 -1
- package/src/registryReader.js +355 -0
- package/onlineapps-service-common-1.2.0.tgz +0 -0
- package/src/postgresClient.js +0 -112
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": "
|
|
4
|
-
"description": "Common utilities for both infrastructure services and business services (JWT auth, Redis
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
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}
|
|
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}
|
|
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
|
package/src/postgresClient.js
DELETED
|
@@ -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
|
-
|