@onlineapps/conn-orch-registry 3.0.0 → 4.0.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.
@@ -1,25 +1,41 @@
1
1
  /**
2
2
  * queueManager.js
3
3
  *
4
- * Responsible for connecting to RabbitMQ and asserting/confirming the existence of queues
5
- * used by the microservice connector.
4
+ * The one MQ connection of the registry connector, and the only surface through
5
+ * which this package talks to the broker.
6
6
  *
7
7
  * Overview:
8
- * - Queues managed by the microservice: 'workflow' and '<serviceName>.registry'
9
- * - On startup: assertQueue for all required queues
10
- * - Provides access to the channel for further operations (send, consume)
8
+ * - Owns a single `@onlineapps/mq-client-core` client: connect, declare,
9
+ * publish, consume, close.
10
+ * - Queues managed by the microservice: `<serviceName>.registry` and
11
+ * `<serviceName>.registry.events`. Their names are business-queue names and
12
+ * their DECLARATION (durability, TTL, dead-letter route) belongs to
13
+ * `queueConfig` in the core — never to an options object written here.
14
+ * - Infrastructure queues (`registry.register`, …) are CHECKED, never
15
+ * asserted: they are created by the infrastructure service that owns them
16
+ * (`api/docs/standards/queue-ownership.md`).
17
+ *
18
+ * Why there is no `channel` on this object any more (d.198b-3): until 2026-09-14
19
+ * this class handed out a raw amqplib channel, and both consumers of the package
20
+ * settled every delivery on it by hand — `channel.ack(msg)` / `channel.nack(msg,
21
+ * false, false)`. That is a second implementation of the delivery contract
22
+ * (`.claude/rules/change-discipline.md` § One rail per concern) and the half that
23
+ * cannot count: a hand-written nack knows nothing about the attempt budget, the
24
+ * `x-oa-delivery-attempts` header or the `message_dlq` event the core publishes.
25
+ * `consume()` below forwards to the core's, which owns all three.
11
26
  *
12
27
  * Usage:
13
- * const qm = new QueueManager(amqpUrl, serviceName);
28
+ * const qm = new QueueManager(amqpUrl, serviceName, logger);
14
29
  * await qm.init();
15
- * await qm.ensureQueues();
16
- * const { channel } = qm;
30
+ * await qm.consume(`${serviceName}.registry`, handler, { requeueOnError: false });
17
31
  *
18
- * @module @onlineapps/connector-registry-client/src/queueManager
32
+ * @see node_modules/@onlineapps/mq-client-core/README.md § `consume()` — the delivery contract
33
+ * @module @onlineapps/conn-orch-registry/src/queueManager
19
34
  */
20
35
 
21
- const amqp = require('amqplib');
22
- const queueConfig = require('@onlineapps/mq-client-core/src/config/queueConfig');
36
+ const BaseClient = require('@onlineapps/mq-client-core');
37
+ const { queueConfig, redactUrl } = require('@onlineapps/mq-client-core');
38
+ const { assertLogger } = require('@onlineapps/logger-contract');
23
39
 
24
40
  /**
25
41
  * Queue manager for the microservice connector.
@@ -28,148 +44,177 @@ class QueueManager {
28
44
  /**
29
45
  * @param {string} amqpUrl - RabbitMQ server URL (AMQP URI)
30
46
  * @param {string} serviceName - Name of the microservice (e.g. 'invoicing')
47
+ * @param {Object} logger - Logger with info/warn/error/debug; required, validated here
48
+ * (docs/governance/confirmations/connector-logger-contract.md 001)
31
49
  */
32
- constructor(amqpUrl, serviceName) {
33
- if (!amqpUrl) throw new Error('amqpUrl is required');
34
- if (!serviceName) throw new Error('serviceName is required');
50
+ constructor(amqpUrl, serviceName, logger) {
51
+ if (!amqpUrl) {
52
+ throw new Error(
53
+ '[QueueManager] Missing constructor argument - amqpUrl is required. '
54
+ + 'Fix: pass the AMQP URI as the first argument, e.g. new QueueManager(process.env.RABBITMQ_URL, serviceName, logger).'
55
+ );
56
+ }
57
+ if (!serviceName) {
58
+ throw new Error(
59
+ '[QueueManager] Missing constructor argument - serviceName is required. '
60
+ + 'Fix: pass the registering service name as the second argument.'
61
+ );
62
+ }
63
+ this.logger = assertLogger('QueueManager', logger, 'queue lifecycle events reach the service log');
35
64
  this.amqpUrl = amqpUrl;
65
+ // The URL carries the broker credential, so nothing that reaches a message or
66
+ // a log line uses `this.amqpUrl` — only this redacted form. The redactor is
67
+ // the platform's one (`@onlineapps/mq-client-core`, d.448); a local copy stood
68
+ // here until d.446b, from the days when that package masked the password alone.
69
+ this.safeAmqpUrl = redactUrl(amqpUrl);
36
70
  this.serviceName = serviceName;
37
- this.conn = null;
38
- this.channel = null;
71
+ this.client = new BaseClient({
72
+ type: 'rabbitmq',
73
+ host: amqpUrl,
74
+ serviceName,
75
+ logger: this.logger
76
+ });
39
77
  }
40
78
 
41
79
  /**
42
- * Initializes the connection and channel to RabbitMQ.
43
- * @returns {Promise<void>}
44
- * @throws {Error} If connection or channel creation fails or times out
80
+ * Opens the connection to RabbitMQ.
81
+ * @returns {Promise<void>}
82
+ * @throws {Error} If connecting fails; the message names the broker without its credential.
45
83
  */
46
84
  async init() {
47
- const CONNECT_TIMEOUT = 10000; // 10 seconds
48
- let connectTimeout;
49
- const connectPromise = amqp.connect(this.amqpUrl);
50
- const connectTimeoutPromise = new Promise((_, reject) => {
51
- connectTimeout = setTimeout(() => {
52
- reject(new Error(`RabbitMQ connection timeout after ${CONNECT_TIMEOUT}ms. RabbitMQ may be unavailable at ${this.amqpUrl}`));
53
- }, CONNECT_TIMEOUT);
54
- });
55
-
56
85
  try {
57
- this.conn = await Promise.race([connectPromise, connectTimeoutPromise]);
86
+ await this.client.connect();
58
87
  } catch (error) {
59
- throw new Error(`[QueueManager] Failed to connect to RabbitMQ: ${error.message}`);
60
- } finally {
61
- if (connectTimeout) clearTimeout(connectTimeout);
88
+ // The core's refusal says "the configured host" without naming it — which
89
+ // host is exactly what a reader of a boot failure needs, so it is added
90
+ // here, redacted, with the core's error as the cause.
91
+ throw new Error(
92
+ `[QueueManager] Failed to connect to RabbitMQ at ${this.safeAmqpUrl} - ${error.message}. `
93
+ + 'Fix: check broker health and the credentials in the AMQP URI.',
94
+ { cause: error }
95
+ );
62
96
  }
97
+ }
63
98
 
64
- // Use regular channel instead of ConfirmChannel to avoid RPC reply queue issues
65
- // ConfirmChannel uses RPC pattern which requires reply queues that may not exist
66
- const CHANNEL_TIMEOUT = 5000; // 5 seconds
67
- let channelTimeout;
68
- const channelPromise = this.conn.createChannel();
69
- const channelTimeoutPromise = new Promise((_, reject) => {
70
- channelTimeout = setTimeout(() => {
71
- reject(new Error(`Channel creation timeout after ${CHANNEL_TIMEOUT}ms`));
72
- }, CHANNEL_TIMEOUT);
73
- });
99
+ /**
100
+ * Is the broker connection open? The one question `register()` asks before it
101
+ * publishes; a closed client is not reusable.
102
+ * @returns {boolean}
103
+ */
104
+ isConnected() {
105
+ return this.client.isConnected();
106
+ }
74
107
 
75
- try {
76
- this.channel = await Promise.race([channelPromise, channelTimeoutPromise]);
77
- } catch (error) {
78
- // Clean up connection on channel creation failure
108
+ /**
109
+ * Declares the business queues this service owns.
110
+ *
111
+ * The declaration — durability, TTL, cap, dead-letter route — comes from
112
+ * `queueConfig` inside the core and is never written here: a name the central
113
+ * config declares carries no caller options, and asserting one with arguments
114
+ * of our own is how a queue ends up declared two different ways and the broker
115
+ * answers the second declarer `406 PRECONDITION-FAILED`.
116
+ *
117
+ * @param {Array<string>} [queues=[]] - Business queue names owned by this service
118
+ * @returns {Promise<void>}
119
+ * @throws {Error} If a name is an infrastructure queue, or the broker refuses the declaration.
120
+ */
121
+ async ensureQueues(queues = []) {
122
+ for (const q of queues) {
123
+ if (queueConfig.isInfrastructureQueue(q)) {
124
+ // Not logged before throwing: the thrown error carries the queue name and the
125
+ // reason, and whoever handles it decides whether that is worth a log line.
126
+ throw new Error(
127
+ `[QueueManager] [REGISTRY] Refusing to assert infrastructure queue '${q}' - `
128
+ + 'these queues are owned by infrastructure services and must exist before a business service starts. '
129
+ + 'Fix: let the owning infrastructure service create it, or pass a business queue name.'
130
+ );
131
+ }
132
+
133
+ const assertStartTime = Date.now();
79
134
  try {
80
- await this.conn.close();
81
- } catch (closeErr) {
82
- // Ignore close errors
135
+ await this.client.assertQueue(q);
136
+ } catch (assertErr) {
137
+ const errorMsg = assertErr.message || String(assertErr);
138
+ throw new Error(
139
+ `[QueueManager] Failed to assert queue ${q} - ${errorMsg} (code ${assertErr.code || 'N/A'}). `
140
+ + 'Fix: a 406 PRECONDITION-FAILED means the queue exists with different arguments; '
141
+ + 'align them or let its owner declare it.',
142
+ { cause: assertErr }
143
+ );
83
144
  }
84
- throw new Error(`[QueueManager] Failed to create channel: ${error.message}`);
85
- } finally {
86
- if (channelTimeout) clearTimeout(channelTimeout);
145
+ this.logger.debug('[QueueManager] Queue asserted', {
146
+ serviceName: this.serviceName,
147
+ queue: q,
148
+ durationMs: Date.now() - assertStartTime
149
+ });
87
150
  }
88
151
  }
89
152
 
90
153
  /**
91
- * Ensures all required queues exist. Creates them if they don't.
92
- * @param {Array<string>} [additionalQueues=[]] - Any additional custom queues
93
- * @returns {Promise<void>}
154
+ * Confirms an infrastructure queue exists without creating it.
155
+ * @param {string} queue - Queue name
156
+ * @returns {Promise<Object>} The broker's `queue.declare-ok`
94
157
  */
95
- async ensureQueues(additionalQueues = []) {
96
- if (!this.channel) {
97
- throw new Error('Channel is not initialized. Call init() first.');
98
- }
158
+ async checkQueue(queue) {
159
+ return this.client.checkQueue(queue);
160
+ }
99
161
 
100
- // Default queues for the registry client
101
- const baseQueues = [];
162
+ /**
163
+ * Declares an exchange this package binds to.
164
+ * @param {string} exchange - Exchange name
165
+ * @param {string} type - Exchange type; required, never guessed
166
+ * @param {Object} [options] - Exchange options
167
+ * @returns {Promise<Object>}
168
+ */
169
+ async assertExchange(exchange, type, options = {}) {
170
+ return this.client.assertExchange(exchange, type, options);
171
+ }
102
172
 
103
- const queuesToCreate = baseQueues.concat(additionalQueues);
173
+ /**
174
+ * Binds one of this service's queues to an exchange.
175
+ * @param {string} queue
176
+ * @param {string} exchange
177
+ * @param {string} pattern - Routing pattern; `''` is legal for a fanout
178
+ * @returns {Promise<Object>}
179
+ */
180
+ async bindQueue(queue, exchange, pattern) {
181
+ return this.client.bindQueue(queue, exchange, pattern);
182
+ }
104
183
 
105
- for (const q of queuesToCreate) {
106
- console.log(`[QueueManager] [REGISTRY] [QUEUE] Ensuring queue: ${q} at ${new Date().toISOString()}`);
107
-
108
- // CRITICAL: Use queueConfig.js to get correct parameters (TTL, max-length, etc.)
109
- // This prevents 406 PRECONDITION-FAILED errors from TTL mismatches
110
- let queueOptions = { durable: true };
111
- const isInfrastructureQueue = queueConfig?.isInfrastructureQueue
112
- ? queueConfig.isInfrastructureQueue(q)
113
- : ['workflow.', 'registry.', 'infrastructure.', 'validation.', 'monitoring.', 'delivery.'].some(prefix => q.startsWith(prefix));
184
+ /**
185
+ * Publishes one message.
186
+ * @param {string} queue - Target queue (or the exchange's name when `options.exchange` is set)
187
+ * @param {Object} message - Payload; serialized by the core
188
+ * @param {Object} [options] - `persistent`, `exchange`, `routingKey`, `headers`
189
+ * @returns {Promise<void>} Resolves when published; THROWS on failure — there is no falsy return
190
+ */
191
+ async publish(queue, message, options = {}) {
192
+ return this.client.publish(queue, message, options);
193
+ }
114
194
 
115
- if (isInfrastructureQueue) {
116
- const message = `[QueueManager] [REGISTRY] Refusing to assert infrastructure queue '${q}'. ` +
117
- 'These queues are owned by infrastructure services and must be created before business services start.';
118
- console.error(message);
119
- throw new Error(message);
120
- }
121
-
122
- if (queueConfig) {
123
- try {
124
- console.warn(`[QueueManager] [REGISTRY] [QUEUE] Queue ${q} is not an infrastructure queue, using default options`);
125
- } catch (configErr) {
126
- console.warn(`[QueueManager] [REGISTRY] [QUEUE] Failed to get config for ${q}, using defaults:`, configErr.message);
127
- }
128
- } else {
129
- console.warn(`[QueueManager] [REGISTRY] [QUEUE] queueConfig not available, using default options for ${q}`);
130
- }
131
-
132
- // Directly assert queue with canonical configuration (idempotent)
133
- try {
134
- const assertStartTime = Date.now();
135
- const ASSERT_TIMEOUT = 5000; // 5 seconds
136
- let assertTimeout;
137
- const assertPromise = this.channel.assertQueue(q, queueOptions);
138
- const assertTimeoutPromise = new Promise((_, reject) => {
139
- assertTimeout = setTimeout(() => {
140
- reject(new Error(`assertQueue timeout after ${ASSERT_TIMEOUT}ms`));
141
- }, ASSERT_TIMEOUT);
142
- });
143
-
144
- try {
145
- await Promise.race([assertPromise, assertTimeoutPromise]);
146
- } finally {
147
- if (assertTimeout) clearTimeout(assertTimeout);
148
- }
149
- const assertEndTime = Date.now();
150
- console.log(`[QueueManager] [REGISTRY] [QUEUE] ✓ Queue ${q} asserted (took ${assertEndTime - assertStartTime}ms)`);
151
- } catch (assertErr) {
152
- const errorMsg = assertErr.message || String(assertErr);
153
- console.error(`[QueueManager] [REGISTRY] [QUEUE] ✗ Failed to assert queue ${q}: ${errorMsg}`);
154
- console.error(`[QueueManager] [REGISTRY] [QUEUE] Error code: ${assertErr.code || 'N/A'}`);
155
- throw new Error(`[QueueManager] Failed to assert queue ${q}: ${errorMsg}`);
156
- }
157
- }
195
+ /**
196
+ * Attaches a consumer on the core's delivery rail.
197
+ *
198
+ * The handler reports failure by THROWING. Settling is the core's: a handler
199
+ * that returns is acked once, a handler that throws is counted against the
200
+ * delivery budget and, when the budget is spent, rejected into `<service>.dlq`
201
+ * with a `message_dlq` event.
202
+ *
203
+ * @param {string} queue
204
+ * @param {function(Object, Object): Promise<void>} handler
205
+ * @param {Object} [options] - `requeueOnError`, `maxAttempts`, `classify`, `prefetch`
206
+ * @returns {Promise<string>} The broker's consumer tag
207
+ */
208
+ async consume(queue, handler, options = {}) {
209
+ return this.client.consume(queue, handler, options);
158
210
  }
159
211
 
160
212
  /**
161
- * Closes the channel and connection.
213
+ * Closes the connection.
162
214
  * @returns {Promise<void>}
163
215
  */
164
216
  async close() {
165
- if (this.channel) {
166
- await this.channel.close();
167
- this.channel = null;
168
- }
169
- if (this.conn) {
170
- await this.conn.close();
171
- this.conn = null;
172
- }
217
+ await this.client.disconnect();
173
218
  }
174
219
  }
175
220