@onlineapps/mq-client-core 2.0.1 → 3.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.
package/src/BaseClient.js CHANGED
@@ -7,32 +7,288 @@
7
7
  */
8
8
 
9
9
  const Ajv = require('ajv');
10
- const merge = require('lodash.merge');
10
+ const { assertLogger } = require('@onlineapps/logger-contract');
11
11
 
12
12
  const configSchema = require('./config/configSchema');
13
13
  const defaultConfig = require('./config/defaultConfig');
14
+ const composeConfig = require('./config/composeConfig');
14
15
  const runtimeCfg = require('./config');
15
16
  const transportFactory = require('./transports/transportFactory');
16
17
  const serializer = require('./utils/serializer');
18
+ const { redactConnectionTarget } = require('./utils/redactCredentials');
19
+ const { nameUnknownKeys } = require('./utils/nearestKey');
20
+ // The one rail for monitoring publishes in this package; a dead-letter event
21
+ // travels it like every other monitoring event, never on a second path.
22
+ const { publishToMonitoringWorkflow } = require('./monitoring-publish');
17
23
  const {
18
24
  ConnectionError,
19
25
  PublishError,
20
26
  ConsumeError,
21
27
  ValidationError,
22
28
  SerializationError,
29
+ CONSUMER_QUEUE_MISSING,
30
+ CONSUMER_DEAD_LETTER_ROUTE_MISSING,
23
31
  } = require('./utils/errorHandler');
24
32
 
33
+ /**
34
+ * Names that are NOT keys of this client.
35
+ *
36
+ * An entry either names the key the value belongs under (`key`), or says the
37
+ * concept itself is gone (`key: null`). Both are refused where the caller wrote
38
+ * them, by one loop, because it is one concern — a name this client does not
39
+ * read, answered before its value can be lost in silence.
40
+ *
41
+ * `url` is `@onlineapps/conn-infra-mq`'s name for the broker URL (its
42
+ * `ConnectorMQClient` JSDoc documents `config.url`); this client's name is `host`.
43
+ * Until 2026-09-14 the transport read the two keys as `host || url`, and the
44
+ * right-hand side was unreachable: `host` is required by the schema and
45
+ * resolved from `RABBITMQ_URL` when the caller omits it (`./config.js`). So a
46
+ * caller who wrote `url` connected to whatever the environment said, and their
47
+ * value was dropped without a word.
48
+ *
49
+ * The key is not accepted as an alias — one concern, one rail
50
+ * (`change-discipline.md`). Leaving it undeclared would refuse it since d.291b
51
+ * (`config/configSchema.js` ends with `additionalProperties: false`), but it
52
+ * would refuse it as an unknown NAME: the schema can say the key does not exist,
53
+ * it cannot say which key the value belongs under. That sentence is the whole
54
+ * point of this list, so the list stays, ahead of the schema rather than beside
55
+ * it — every name it does not know falls through and is answered by Ajv
56
+ * (`architecture-principles.md` §3, §4, §5). It is checked BEFORE the schema and
57
+ * before the runtime resolution of `host`, so the answer is about the config the
58
+ * caller wrote rather than about the environment the process happens to run in.
59
+ */
60
+ const NOT_A_KEY_OF_THIS_CLIENT = Object.freeze({
61
+ url: Object.freeze({
62
+ key: 'host',
63
+ what: 'the broker URL',
64
+ consequence:
65
+ 'This client never reads "url", so the value written under it would be discarded and the '
66
+ + 'connection made with RABBITMQ_URL instead.',
67
+ }),
68
+ /**
69
+ * Renamed in d.299, and refused by name rather than accepted as an alias: the
70
+ * number bounds every ANSWER this client waits for from the broker — the publish
71
+ * confirm AND the close-ok of each handle `disconnect()` closes (d.283) — so a
72
+ * name owned by one of its two consumers made the other invisible. An alias
73
+ * would be the transition shim `architecture-principles.md` §11 forbids; a
74
+ * silent drop would repeat exactly what `url` did.
75
+ */
76
+ publishConfirmationTimeout: Object.freeze({
77
+ key: 'brokerAnswerTimeout',
78
+ what: 'the budget for an answer from the broker',
79
+ consequence:
80
+ 'The key was renamed: it bounds the publish confirm AND the close-ok of every handle '
81
+ + 'disconnect() closes, so it no longer carries the name of one of the two. A value written '
82
+ + 'under the old name would be ignored and the default used instead.',
83
+ }),
84
+ /**
85
+ * Removed in d.343 with the mechanism they declared, and refused by name
86
+ * rather than quietly ignored. `buffer/RedisBuffer.js` was a stub: its `add()`
87
+ * and `flush()` stored and returned nothing, so `persistentBufferEnabled:
88
+ * true` sent every critical-priority message that failed transiently into it
89
+ * and the message was DROPPED — the opposite of what the key promised. That is
90
+ * a declaration with no mechanism (`automation-gates.md` §5).
91
+ * @see docs/architecture/mq-publish-reliability.md — the node that owns the
92
+ * publish-reliability model, and the buffering it describes. Nothing in the
93
+ * platform ever set either key outside a test (measured across `api`,
94
+ * `api_biz`, `infra`, `fe_adminui`, 2026-09-14). A silent drop would leave a
95
+ * caller believing in persistence that never existed, which is exactly the
96
+ * failure `url` taught this list.
97
+ */
98
+ persistentBufferEnabled: Object.freeze({
99
+ key: null,
100
+ what: 'a publish buffer that survives a process restart',
101
+ consequence:
102
+ 'This client has ONE buffer, the in-memory one (publishBufferMaxSize / publishBufferTtlMs). '
103
+ + 'The Redis buffer this key switched on stored nothing, so the critical messages it was '
104
+ + 'meant to protect were dropped instead of buffered.',
105
+ }),
106
+ persistentRedisClient: Object.freeze({
107
+ key: null,
108
+ what: 'the Redis client of a persistent publish buffer',
109
+ consequence:
110
+ 'The persistent buffer it fed is gone (see persistentBufferEnabled): it never wrote to '
111
+ + 'Redis at all, so the client injected here was held and never used.',
112
+ }),
113
+ });
114
+
115
+ /**
116
+ * The whole sentence for a name this client does not read.
117
+ *
118
+ * A helper rather than two branches at the throw site: the message contract
119
+ * (`tests/unit/error-message-contract.test.js`) reads every caller-facing
120
+ * construction and can only see that it opens with `[Context]` when the sentence
121
+ * is one literal or one named helper. Two facts, two sentences — the value
122
+ * belongs under another name, or the thing it configured no longer exists — and
123
+ * both open the same way (`architecture-principles.md` §5).
124
+ *
125
+ * @param {string} written - the key the caller wrote
126
+ * @param {{key: string|null, what: string, consequence: string}} meant
127
+ * @returns {string}
128
+ */
129
+ function notAKeyOfThisClientMessage(written, meant) {
130
+ if (meant.key === null) {
131
+ return `[BaseClient] Configuration key "${written}" does not exist - Expected: no such key; `
132
+ + `${meant.what} is not something this client has. `
133
+ + `Fix: remove "${written}" from the configuration. ${meant.consequence}`;
134
+ }
135
+ return `[BaseClient] Configuration key "${written}" does not exist - Expected: ${meant.what} under "${meant.key}". `
136
+ + `Fix: rename "${written}" to "${meant.key}". ${meant.consequence}`;
137
+ }
138
+
139
+ /**
140
+ * The whole sentence for a config Ajv rejected.
141
+ *
142
+ * One helper and one throw site, for the reason `notAKeyOfThisClientMessage()`
143
+ * exists: the message contract (`tests/unit/error-message-contract.test.js`) can
144
+ * only see that a construction opens with `[Context]` when its argument is one
145
+ * literal or one named helper.
146
+ *
147
+ * Two shapes, because the two failures are read by different people. A wrong
148
+ * TYPE on a declared key is a value problem — the paths in `error.details` are
149
+ * what the caller acts on. An UNDECLARED key is a name problem, and the name has
150
+ * to be in the sentence: the caller believes they configured something, and
151
+ * nothing in `error.details` tells them that the key was never read
152
+ * (`architecture-principles.md` §5).
153
+ *
154
+ * @param {string[]} unknownKeys - Keys no side of the schema declares; may be empty.
155
+ * @param {string[]} declaredKeys - Every key the schema declares, subclass keys included.
156
+ * @returns {string}
157
+ */
158
+ function invalidConfigurationMessage(unknownKeys, declaredKeys) {
159
+ if (unknownKeys.length === 0) {
160
+ return '[BaseClient] Invalid configuration - Expected: the constructor config to satisfy the client schema. '
161
+ + 'Fix: correct the fields listed in error.details, each of which names its path and the reason it was rejected.';
162
+ }
163
+
164
+ const named = nameUnknownKeys(unknownKeys, declaredKeys);
165
+
166
+ return `[BaseClient] Configuration key not declared by this client: ${named} - `
167
+ + `Expected: only the keys src/config/configSchema.js declares and the ones a subclass adds through `
168
+ + `super(config, extraProperties) - ${declaredKeys.length} in total for this client. `
169
+ + 'Fix: correct the spelling, or remove the key - a key this client does not read configures nothing, '
170
+ + 'and the value written under it is lost in silence. A key that belongs to a subclass is declared in '
171
+ + 'that subclass schema, never here.';
172
+ }
173
+
174
+ /**
175
+ * The schema this construction validates against: the library's own, plus the
176
+ * properties a SUBCLASS declares.
177
+ *
178
+ * The one place a subclass extends the configuration contract. `ConnectorMQClient`
179
+ * (`@onlineapps/conn-infra-mq`) hands the caller's config straight to `super()` and
180
+ * then reads four keys of its own off the same `this._config`; the library cannot
181
+ * declare those — they configure mechanisms that live there — and that package must
182
+ * not declare the library's, or one type and one description would exist twice and
183
+ * be free to drift (`change-discipline.md` § One rail per concern). So each side
184
+ * declares what it owns, and the subclass passes its half here.
185
+ *
186
+ * A subclass REDECLARING a library key is refused rather than merged: silently
187
+ * letting the subclass win would be exactly the second rail this argument exists to
188
+ * avoid, and silently letting the library win would leave the subclass author
189
+ * believing a type they wrote is in force.
190
+ *
191
+ * The library schema is never mutated — a subclass must not change what every other
192
+ * caller of this client is validated against.
193
+ *
194
+ * @param {Object|null} extraProperties - JSON Schema `properties` of the subclass.
195
+ * @returns {Object} The schema to compile.
196
+ * @throws {ValidationError} If the argument is not a plain object of properties, or
197
+ * redeclares a key the library already owns.
198
+ */
199
+ function schemaWithSubclassProperties(extraProperties) {
200
+ if (extraProperties === null || extraProperties === undefined) {
201
+ return configSchema;
202
+ }
203
+
204
+ if (typeof extraProperties !== 'object' || Array.isArray(extraProperties)) {
205
+ throw new ValidationError(
206
+ '[BaseClient] Subclass configuration properties must be an object - '
207
+ + 'Expected: the `properties` object of the subclass JSON Schema as the second constructor argument, '
208
+ + 'or nothing at all. '
209
+ + `Fix: pass \`super(config, mySchema.properties)\`; received ${Array.isArray(extraProperties) ? 'an array' : typeof extraProperties}.`
210
+ );
211
+ }
212
+
213
+ for (const key of Object.keys(extraProperties)) {
214
+ if (Object.prototype.hasOwnProperty.call(configSchema.properties, key)) {
215
+ throw new ValidationError(
216
+ `[BaseClient] Subclass configuration property "${key}" is already declared by this client - `
217
+ + 'Expected: a subclass declares only the keys it reads itself, so one key keeps one type, one '
218
+ + 'description and one owner. '
219
+ + `Fix: remove "${key}" from the subclass schema and read it from src/config/configSchema.js, `
220
+ + 'or rename the subclass key if it means something else.'
221
+ );
222
+ }
223
+ }
224
+
225
+ return {
226
+ ...configSchema,
227
+ properties: { ...configSchema.properties, ...extraProperties }
228
+ };
229
+ }
230
+
25
231
  class BaseClient {
26
232
  /**
27
233
  * @param {Object} config - User-supplied configuration.
28
- * @throws {ValidationError} If required fields are missing or invalid.
234
+ * @param {Object} [extraProperties] - JSON Schema `properties` a SUBCLASS declares
235
+ * for the keys IT reads off the same config object. The one place the
236
+ * configuration contract is extended; see `schemaWithSubclassProperties()`.
237
+ * A direct caller of this client passes nothing.
238
+ * @throws {ValidationError} If required fields are missing or invalid, if a key
239
+ * neither side declares is present, or if the subclass declaration is malformed.
29
240
  */
30
- constructor(config) {
31
- const ajv = new Ajv({ allErrors: true, useDefaults: true });
32
- const validate = ajv.compile(configSchema);
241
+ constructor(config, extraProperties = null) {
242
+ // The client reports its whole connection lifecycle and every consumer it
243
+ // registers. Until 2026-09-07 it wrote those to `console` while `config.logger`
244
+ // sat in the schema unused — an ambient channel no service configures and no
245
+ // collector attributes. Owner confirmation
246
+ // `docs/governance/confirmations/connector-logger-contract.md` 001/003.
247
+ // Validated BEFORE the schema, so the caller gets the actionable contract
248
+ // message rather than a generic Ajv complaint about a missing property.
249
+ this._logger = assertLogger(
250
+ 'BaseClient',
251
+ config && config.logger,
252
+ 'the client reports its connection lifecycle and every consumer it registers',
253
+ 'pass config.logger (the logger your service already built)'
254
+ );
255
+
256
+ // A name this client does not read, refused before its value can be lost
257
+ // (see NOT_A_KEY_OF_THIS_CLIENT above).
258
+ for (const [written, meant] of Object.entries(NOT_A_KEY_OF_THIS_CLIENT)) {
259
+ if (config && Object.prototype.hasOwnProperty.call(config, written)) {
260
+ throw new ValidationError(
261
+ notAKeyOfThisClientMessage(written, meant),
262
+ [{
263
+ path: `/${written}`,
264
+ message: meant.key === null
265
+ ? `does not exist; ${meant.what} was removed`
266
+ : `does not exist; ${meant.what} is "${meant.key}"`
267
+ }]
268
+ );
269
+ }
270
+ }
33
271
 
34
- // Merge user config with defaults
35
- this._config = merge({}, defaultConfig, config || {});
272
+ // `allErrors` so a wrong config hears about every offending key at once
273
+ // rather than one per attempt. NO `useDefaults`: it arrived with the library's
274
+ // extraction (`7a47b2fa`, 2025-11-18) and never had anything to write —
275
+ // `config/configSchema.js` declares no `default` on any property, on purpose
276
+ // and under a test, because every default has an owner in
277
+ // `config/defaultConfig.js`, composed BEFORE this validation runs. A switch
278
+ // that writes nothing is not harmless: it is a standing invitation to answer
279
+ // "where do I put this default?" with a second rail (`automation-gates.md` §5,
280
+ // `change-discipline.md` § One rail per concern).
281
+ // The library's own declaration, plus the subclass's if there is one. Compiled
282
+ // per construction, like the schema it replaced: a subclass's properties are an
283
+ // argument, so there is no one schema to compile once.
284
+ const schema = schemaWithSubclassProperties(extraProperties);
285
+ const ajv = new Ajv({ allErrors: true });
286
+ const validate = ajv.compile(schema);
287
+
288
+ // The module defaults, then what the caller wrote. Composed SHALLOWLY and
289
+ // by reference: an injected dependency has to arrive as the object it was
290
+ // injected as, not as a deep copy of it (`config/composeConfig.js`).
291
+ this._config = composeConfig(defaultConfig, config);
36
292
 
37
293
  // Resolve runtime config (FAIL-FAST for host)
38
294
  const resolved = runtimeCfg.resolve({
@@ -51,16 +307,33 @@ class BaseClient {
51
307
  // Validate merged config
52
308
  const valid = validate(this._config);
53
309
  if (!valid) {
54
- const details = validate.errors.map((err) => ({
55
- path: err.instancePath,
56
- message: err.message,
57
- }));
58
- throw new ValidationError('Invalid configuration', details);
310
+ // An undeclared key is reported by Ajv against the OBJECT (`instancePath: ''`)
311
+ // with the offending name in `params`, so it is unpacked here: a caller acting
312
+ // on `error.details` needs the path of the key they wrote, exactly as for a
313
+ // wrong type one line below.
314
+ const details = validate.errors.map((err) => (
315
+ err.keyword === 'additionalProperties'
316
+ ? {
317
+ path: `/${err.params.additionalProperty}`,
318
+ message: 'is not a configuration key of this client',
319
+ }
320
+ : { path: err.instancePath, message: err.message }
321
+ ));
322
+ const unknownKeys = validate.errors
323
+ .filter((err) => err.keyword === 'additionalProperties')
324
+ .map((err) => err.params.additionalProperty);
325
+
326
+ throw new ValidationError(
327
+ invalidConfigurationMessage(unknownKeys, Object.keys(schema.properties)),
328
+ details
329
+ );
59
330
  }
60
331
 
61
332
  this._transport = null;
62
333
  this._connected = false;
63
334
  this._errorHandlers = [];
335
+ this._fatalHandlers = [];
336
+ this._fatalError = null;
64
337
 
65
338
  BaseClient._registerInstance(this);
66
339
  }
@@ -74,9 +347,14 @@ class BaseClient {
74
347
  async connect(options = {}) {
75
348
  if (this._connected) return;
76
349
 
77
- console.log(`[BaseClient] Starting connect() with config:`, JSON.stringify({ type: this._config.type, host: this._config.host }));
78
- // Merge overrides into existing config
79
- this._config = merge({}, this._config, options);
350
+ // `host` is the broker URL and carries the account password. It is redacted
351
+ // on the way to the log — the only rail for that is utils/redactCredentials.
352
+ this._logger.debug('[BaseClient] Starting connect()', {
353
+ type: this._config.type,
354
+ host: redactConnectionTarget(this._config.host),
355
+ });
356
+ // Overrides compose the same way as the constructor's sources do.
357
+ this._config = composeConfig(this._config, options);
80
358
  // Ensure runtime-derived values are applied (options can override explicitly)
81
359
  const resolved = runtimeCfg.resolve({
82
360
  host: this._config.host,
@@ -90,20 +368,38 @@ class BaseClient {
90
368
  `${resolved.serviceName}:${process.pid}`;
91
369
 
92
370
  try {
93
- console.log(`[BaseClient] Creating transport...`);
94
- // Instantiate appropriate transport: RabbitMQClient
371
+ this._logger.debug('[BaseClient] Creating transport');
372
+ // Instantiate appropriate transport: RabbitMQClient. `this._config` carries
373
+ // the validated logger, so the transport logs through the same channel —
374
+ // it builds none of its own.
95
375
  this._transport = transportFactory.create(this._config);
96
- console.log(`[BaseClient] Transport created:`, this._transport.constructor.name);
376
+ this._logger.debug('[BaseClient] Transport created', {
377
+ transport: this._transport.constructor.name,
378
+ });
97
379
 
98
380
  // Register internal error propagation
99
381
  this._transport.on('error', (err) => this._handleError(err));
100
382
 
101
- console.log(`[BaseClient] Calling transport.connect()...`);
383
+ // The connection is permanently lost: the transport spent its whole
384
+ // recovery budget and will not try again. Flip the connected flag FIRST —
385
+ // publish/consume must refuse rather than block on a recovery that is not
386
+ // coming — and only then tell the owner.
387
+ this._transport.on('connection:fatal', (err) => {
388
+ this._connected = false;
389
+ this._fatalError = err;
390
+ this._handleFatal(err);
391
+ });
392
+
393
+ this._logger.debug('[BaseClient] Calling transport.connect()');
102
394
  await this._transport.connect(this._config);
103
- console.log(`[BaseClient] Transport connected successfully`);
395
+ this._logger.info('[BaseClient] Transport connected successfully');
104
396
  this._connected = true;
105
397
  } catch (err) {
106
- throw new ConnectionError('Failed to connect to broker', err);
398
+ throw new ConnectionError(
399
+ '[BaseClient] Failed to connect to broker - Expected: a reachable broker at the configured host. '
400
+ + 'Fix: read error.cause for the transport reason, then check the MQ url, credentials and network the service was started with.',
401
+ err
402
+ );
107
403
  }
108
404
  }
109
405
 
@@ -113,14 +409,27 @@ class BaseClient {
113
409
  * @throws {Error} If disconnecting fails unexpectedly.
114
410
  */
115
411
  async disconnect() {
116
- if (!this._connected || !this._transport) return;
412
+ // Keyed on the TRANSPORT, not on `_connected`: since the fatal signal
413
+ // clears `_connected`, a client whose connection is permanently lost still
414
+ // owns a transport holding timers and listeners, and must still be
415
+ // tearable-down. Keying on `_connected` would have made that teardown a
416
+ // silent no-op.
417
+ if (!this._transport) {
418
+ this._connected = false;
419
+ BaseClient._unregisterInstance(this);
420
+ return;
421
+ }
117
422
  try {
118
423
  await this._transport.disconnect();
119
424
  this._connected = false;
120
425
  this._transport = null;
121
426
  BaseClient._unregisterInstance(this);
122
427
  } catch (err) {
123
- throw new Error(`Error during disconnect: ${err.message}`);
428
+ throw new ConnectionError(
429
+ '[BaseClient] Failed to disconnect from broker - Expected: the transport to close its connection cleanly. '
430
+ + 'Fix: read error.cause for the transport reason; the client stays marked connected, so call disconnect() again before reusing it.',
431
+ err
432
+ );
124
433
  }
125
434
  }
126
435
 
@@ -128,14 +437,21 @@ class BaseClient {
128
437
  * Publishes a message to the specified queue.
129
438
  * @param {string} queue - Target queue name.
130
439
  * @param {Object|Buffer|string} message - Payload to send.
131
- * @param {Object} [options] - RabbitMQ-specific overrides (routingKey, persistent, headers, queueOptions).
440
+ * @param {Object} [options] - RabbitMQ-specific overrides (routingKey, persistent, headers).
441
+ * Not `queueOptions`: a publisher never declares a queue's arguments. An
442
+ * infrastructure or business queue is created by its owner before anything
443
+ * publishes to it, and an ownerless name is declared with the arguments the
444
+ * central `queueConfig` resolves (`transports/rabbitmqClient.js`, the 404 branch).
132
445
  * @returns {Promise<void>}
133
446
  * @throws {ConnectionError} If not connected.
134
447
  * @throws {PublishError} If publish fails.
135
448
  */
136
449
  async publish(queue, message, options = {}) {
137
450
  if (!this._connected || !this._transport) {
138
- throw new ConnectionError('Cannot publish: client is not connected');
451
+ throw new ConnectionError(
452
+ '[BaseClient] Cannot publish: client is not connected - Expected: connect() to have completed. '
453
+ + 'Fix: await client.connect() before publish().'
454
+ );
139
455
  }
140
456
 
141
457
  let buffer;
@@ -149,28 +465,69 @@ class BaseClient {
149
465
  buffer = Buffer.from(json, 'utf8');
150
466
  }
151
467
  } catch (err) {
152
- throw new SerializationError('Failed to serialize message', message, err);
468
+ throw new SerializationError(
469
+ '[BaseClient] Failed to serialize message - Expected: a JSON-serializable payload, a string or a Buffer. '
470
+ + 'Fix: read error.cause for the reason (circular reference, BigInt, …) and error.payload for the value that was rejected.',
471
+ message,
472
+ err
473
+ );
153
474
  }
154
475
 
155
476
  try {
156
477
  await this._transport.publish(queue, buffer, options);
157
478
  } catch (err) {
158
- throw new PublishError(`Failed to publish to queue "${queue}"`, queue, err);
479
+ throw new PublishError(
480
+ `[BaseClient] Failed to publish to queue "${queue}" - Expected: the queue to exist and the channel to be open. `
481
+ + 'Fix: read error.cause for the broker reason; error.queue names the target queue.',
482
+ queue,
483
+ err
484
+ );
159
485
  }
160
486
  }
161
487
 
162
488
  /**
163
489
  * Begins consuming messages from the specified queue.
490
+ *
491
+ * A handler that throws does NOT get an unconditional requeue: the delivery
492
+ * policy counts the attempt, gives the message one more if the error is
493
+ * transient and the budget allows, and otherwise rejects it into the
494
+ * dead-letter queue the broker routes for this queue (`<svc>.dlq`), publishing
495
+ * a `message_dlq` event. The full contract is in the README
496
+ * (§ `consume()` — the delivery contract) and in `config/deliveryPolicy.js`.
497
+ *
498
+ * That policy needs somewhere to reject TO, so `consume()` REFUSES a queue for
499
+ * which `queueConfig` declares no `x-dead-letter-exchange`/`-routing-key` — at
500
+ * registration, before a consumer is attached, with no opt-out (d.259). On such
501
+ * a queue the broker drops a rejected message instead of moving it, so the
502
+ * `message_dlq` event would report a destination nothing routed to. The fix is
503
+ * always to declare the route, never to consume without one.
504
+ *
164
505
  * @param {string} queue - Name of the queue to consume from.
165
- * @param {function(Object): Promise<void>} messageHandler - Async function to process each message.
506
+ * @param {function(Object, {attempt: number, maxAttempts: number, isFinalAttempt: boolean}): Promise<void>} messageHandler -
507
+ * Async function to process each message. Its second argument says which
508
+ * attempt this delivery is, so a side effect that must happen once — an RPC
509
+ * error reply above all — happens on the final attempt only, not on every
510
+ * redelivery. A handler declaring one parameter simply ignores it.
166
511
  * @param {Object} [options] - RabbitMQ-specific overrides (prefetch, noAck, queueOptions).
512
+ * @param {number} [options.maxAttempts] - How many times the handler may run for ONE
513
+ * message before it is rejected into the dead-letter queue. Omitted → the module
514
+ * default (`RABBITMQ_MAX_DELIVERY_ATTEMPTS`, then `defaults.js maxDeliveryAttempts`).
515
+ * An integer >= 1, refused at entry otherwise.
516
+ * @param {function(Error): ('transient'|'permanent')} [options.classify] - Error
517
+ * classifier. Omitted → every error is transient, so a permanent failure still
518
+ * reaches the dead-letter queue, after `maxAttempts` attempts instead of one.
519
+ * `permanent` rejects on the first failure.
167
520
  * @returns {Promise<void>}
168
521
  * @throws {ConnectionError} If not connected.
522
+ * @throws {ValidationError} If `maxAttempts` or `classify` cannot bound a delivery.
169
523
  * @throws {ConsumeError} If consumer setup fails.
170
524
  */
171
525
  async consume(queue, messageHandler, options = {}) {
172
526
  if (!this._connected || !this._transport) {
173
- throw new ConnectionError('Cannot consume: client is not connected');
527
+ throw new ConnectionError(
528
+ '[BaseClient] Cannot consume: client is not connected - Expected: connect() to have completed. '
529
+ + 'Fix: await client.connect() before consume().'
530
+ );
174
531
  }
175
532
 
176
533
  // Apply prefetch and noAck overrides if provided
@@ -179,26 +536,204 @@ class BaseClient {
179
536
  if (typeof prefetch === 'number') consumeOptions.prefetch = prefetch;
180
537
  if (typeof noAck === 'boolean') consumeOptions.noAck = noAck;
181
538
  if (options.queueOptions) consumeOptions.queueOptions = options.queueOptions;
539
+ // Forwarded untouched, including their absence: the transport owns the
540
+ // validation and the module default, so there is exactly one place where a
541
+ // delivery budget is decided (`config/deliveryPolicy.js`).
542
+ if (options.maxAttempts !== undefined) consumeOptions.maxAttempts = options.maxAttempts;
543
+ if (options.classify !== undefined) consumeOptions.classify = options.classify;
544
+ // Same rule, same reason: `requeueOnError` is a statement ABOUT the delivery
545
+ // budget, so it is resolved where the budget is (`config/deliveryPolicy.js`,
546
+ // applied in the transport), not folded into a second number here.
547
+ if (options.requeueOnError !== undefined) consumeOptions.requeueOnError = options.requeueOnError;
548
+ // The monitoring rail belongs to this layer, not to the transport: the
549
+ // transport knows the broker, this client knows the service it speaks for
550
+ // and the queue monitoring events travel on.
551
+ consumeOptions.onDeadLetter = (event) => this._emitDeadLetterEvent(event);
182
552
 
183
553
  // CRITICAL: Log all consume() calls for transparency
184
- console.log(`[BaseClient] [CONSUMER] Starting consume() for queue: ${queue} at ${new Date().toISOString()}`);
185
- console.log(`[BaseClient] [CONSUMER] Options:`, JSON.stringify(consumeOptions, null, 2));
186
- console.log(`[BaseClient] [CONSUMER] Transport type: ${this._transport?.constructor?.name || 'unknown'}`);
187
- console.log(`[BaseClient] [CONSUMER] Connected: ${this._connected}`);
554
+ this._logger.info(`[BaseClient] [CONSUMER] Starting consume() for queue: ${queue}`, {
555
+ queue,
556
+ });
557
+ this._logger.debug(`[BaseClient] [CONSUMER] Consumer setup for queue: ${queue}`, {
558
+ queue,
559
+ options: consumeOptions,
560
+ transport: this._transport?.constructor?.name || 'unknown',
561
+ connected: this._connected,
562
+ });
188
563
 
189
564
  try {
190
565
  const consumeStartTime = Date.now();
191
566
  // Delegate message lifecycle (ack/nack/requeue) to the transport layer.
192
567
  // BaseClient only provides a broker-agnostic surface; transport decides how to handle handler errors.
193
- await this._transport.consume(queue, async (msg) => messageHandler(msg), consumeOptions);
568
+ const consumerTag = await this._transport.consume(
569
+ queue,
570
+ async (msg, delivery) => messageHandler(msg, delivery),
571
+ consumeOptions
572
+ );
194
573
  const consumeEndTime = Date.now();
195
- console.log(`[BaseClient] [CONSUMER] ✓ consume() completed for queue: ${queue} (took ${consumeEndTime - consumeStartTime}ms)`);
574
+ this._logger.info(`[BaseClient] [CONSUMER] ✓ consume() completed for queue: ${queue}`, {
575
+ queue,
576
+ durationMs: consumeEndTime - consumeStartTime,
577
+ });
578
+ // The broker's name for this consumer, handed back so the caller has
579
+ // something to pass to `cancelConsumer()`. Without it the tag existed
580
+ // inside the transport alone and the L8 cancel-by-tag half of the contract
581
+ // had no way to be used.
582
+ return consumerTag;
196
583
  } catch (err) {
197
- console.error(`[BaseClient] [CONSUMER] consume() failed for queue: ${queue}`, err.message);
198
- throw new ConsumeError(`Failed to start consumer for queue "${queue}"`, queue, err);
584
+ // The failure is not logged here: it is rethrown as a ConsumeError carrying
585
+ // the cause, and a rethrown error is logged by whoever stops handling it
586
+ // (confirmation connector-logger-contract 003).
587
+ throw this._consumerRefusal(queue, err);
199
588
  }
200
589
  }
201
590
 
591
+ /**
592
+ * Turn whatever stopped a consumer from starting into a refusal that says WHY.
593
+ *
594
+ * Until d.297 this was one sentence for every failure: "Expected: the queue to
595
+ * exist before consume() attaches to it … a missing queue is created by the
596
+ * service that owns it, never by the consumer." For a queue that genuinely does
597
+ * not exist it is true. For the OTHER refusal the same call makes — d.259, a
598
+ * queue for which `queueConfig` declares no dead-letter route — it is false in
599
+ * every clause: the queue exists, creating it changes nothing, and the owning
600
+ * service is not who has to act. The true sentence sat in `error.cause`, where
601
+ * a log line does not look, so the reader was sent to fix a thing that was not
602
+ * broken.
603
+ *
604
+ * Two classes, two sentences, each recognised by the transport's `code` rather
605
+ * than by its wording (`utils/errorHandler.js`, README § errors and errorCodes).
606
+ * Everything else gets a third sentence that claims NO cause and points at the
607
+ * one that knows: an invented likeliest-sounding reason is worse than an honest
608
+ * "read the cause", because it is acted upon (`architecture-principles.md` §5).
609
+ *
610
+ * @param {string} queue
611
+ * @param {Error} err - what the transport threw
612
+ * @returns {ConsumeError}
613
+ * @private
614
+ */
615
+ _consumerRefusal(queue, err) {
616
+ const code = err && err.code;
617
+
618
+ if (code === CONSUMER_DEAD_LETTER_ROUTE_MISSING) {
619
+ return new ConsumeError(
620
+ `[BaseClient] Cannot consume from queue "${queue}": queueConfig declares no dead-letter route for it - `
621
+ + 'Expected: the queue to declare x-dead-letter-exchange and x-dead-letter-routing-key, because the '
622
+ + 'delivery policy ends a spent message with nack(requeue=false) and the broker drops it where there '
623
+ + 'is nowhere to move it to. '
624
+ + 'Fix: declare the route in the section of src/config/queueConfig.js that owns this queue, and bind the queue that '
625
+ + 'receives it, or consume a queue that already declares one. The queue itself is fine — creating or '
626
+ + 'recreating it changes nothing.',
627
+ queue,
628
+ err,
629
+ CONSUMER_DEAD_LETTER_ROUTE_MISSING
630
+ );
631
+ }
632
+
633
+ if (code === CONSUMER_QUEUE_MISSING) {
634
+ return new ConsumeError(
635
+ `[BaseClient] Cannot consume from queue "${queue}": the queue does not exist - `
636
+ + 'Expected: it to have been created by the service that owns it, before any consumer starts. '
637
+ + 'Fix: start the owning service; a consumer never creates the queue it attaches to, because it would '
638
+ + 'declare it with default arguments and every later declaration would fail with 406. '
639
+ + 'error.cause carries the broker reason.',
640
+ queue,
641
+ err,
642
+ CONSUMER_QUEUE_MISSING
643
+ );
644
+ }
645
+
646
+ return new ConsumeError(
647
+ `[BaseClient] Failed to start consumer for queue "${queue}" - `
648
+ + 'Expected: consume() to attach a consumer to the queue. '
649
+ + 'Fix: read error.cause, which carries the reason and the action for it — this layer does not know '
650
+ + 'which one it was, and naming the likeliest would send you to fix something that may not be broken.',
651
+ queue,
652
+ err
653
+ );
654
+ }
655
+
656
+ /**
657
+ * Report one message rejected into a dead-letter queue.
658
+ *
659
+ * Published to `monitoring.workflow` ONLY when the envelope carries a
660
+ * `workflow_id`. Without one there is nothing to publish and nothing is
661
+ * invented: `monitoring.workflow` keys every trace on that id, and an event
662
+ * naming no workflow is either refused by the intake as faulty telemetry or —
663
+ * worse, when a marker like `'unknown'` is put in its place — written as a row
664
+ * claiming a workflow by that name (`docs/governance/confirmations/dlq-purge.md`
665
+ * 002; lead decision 2026-09-11 answering INFRA-monitoring). The absence is
666
+ * reported through the injected logger, naming the queue and the service, and
667
+ * the message itself stays readable in the dead-letter queue.
668
+ *
669
+ * `status` carries the broker's own word for the death — `'rejected'`, what
670
+ * RabbitMQ stamps into `x-death[0].reason` for exactly this move (measured on
671
+ * `api_services_queuer`, 2026-09-11). One vocabulary for both paths into a
672
+ * DLQ, as the monitoring intake requires (`api/shared/TODO.md`, INFRA-monitoring
673
+ * 2026-09-11); an event with no `status` is refused there under its own name.
674
+ *
675
+ * @param {Object} event - As the transport reports it.
676
+ * @returns {Promise<void>}
677
+ * @private
678
+ */
679
+ async _emitDeadLetterEvent(event) {
680
+ const { queue, content, attempts, maxAttempts, classification, error } = event;
681
+ const serviceName = runtimeCfg.get('serviceName', this._config.serviceName);
682
+
683
+ let workflowId = null;
684
+ try {
685
+ const envelope = serializer.deserialize(content);
686
+ if (envelope !== null && typeof envelope === 'object' && typeof envelope.workflow_id === 'string'
687
+ && envelope.workflow_id !== '') {
688
+ workflowId = envelope.workflow_id;
689
+ }
690
+ } catch (parseErr) {
691
+ // A payload that is not JSON carries no envelope, so it carries no
692
+ // workflow_id either. Same outcome as an envelope without one.
693
+ workflowId = null;
694
+ }
695
+
696
+ if (workflowId === null) {
697
+ this._logger.error(
698
+ '[BaseClient] Dead-lettered message carries no workflow_id - Expected: the envelope of a '
699
+ + 'workflow message to carry workflow_id, which monitoring.workflow keys every trace on. '
700
+ + 'Fix: the message is in the dead-letter queue and readable through the DLQ dashboard; '
701
+ + 'no message_dlq event was published, because an event without a workflow_id names no workflow.',
702
+ {
703
+ queue,
704
+ service_name: serviceName,
705
+ attempts,
706
+ max_attempts: maxAttempts,
707
+ classification,
708
+ error: error && error.message
709
+ }
710
+ );
711
+ return;
712
+ }
713
+
714
+ await publishToMonitoringWorkflow(
715
+ this,
716
+ {
717
+ event_type: 'message_dlq',
718
+ workflow_id: workflowId,
719
+ service_name: serviceName,
720
+ // The broker's word for this death; see the method comment.
721
+ status: 'rejected',
722
+ queue,
723
+ attempts,
724
+ max_attempts: maxAttempts,
725
+ classification,
726
+ error: {
727
+ message: error && error.message,
728
+ name: error && error.name
729
+ },
730
+ timestamp: new Date().toISOString()
731
+ },
732
+ this._logger,
733
+ { queue }
734
+ );
735
+ }
736
+
202
737
  /**
203
738
  * Assert queue existence (delegates to transport).
204
739
  * @param {string} queue - Queue name
@@ -207,14 +742,90 @@ class BaseClient {
207
742
  */
208
743
  async assertQueue(queue, options = {}) {
209
744
  if (!this._connected || !this._transport) {
210
- throw new ConnectionError('Cannot assertQueue: client is not connected');
745
+ throw new ConnectionError(
746
+ '[BaseClient] Cannot assertQueue: client is not connected - Expected: connect() to have completed. '
747
+ + 'Fix: await client.connect() before assertQueue().'
748
+ );
211
749
  }
212
750
  if (typeof this._transport.assertQueue !== 'function') {
213
- throw new Error('[BaseClient] Transport does not support assertQueue(queue, options)');
751
+ throw new Error(
752
+ '[BaseClient] Transport does not support assertQueue(queue, options) - Expected: a transport implementing assertQueue. '
753
+ + 'Fix: use the rabbitmq transport, or drop the assertQueue() call for this transport type.'
754
+ );
214
755
  }
215
756
  return await this._transport.assertQueue(queue, options);
216
757
  }
217
758
 
759
+ /**
760
+ * Declare an exchange (delegates to transport).
761
+ *
762
+ * One of the four operations `mq-client-lifecycle-contract` 001 point 4
763
+ * ("L8") requires this client to expose. Until d.302 the only `assertExchange`
764
+ * in the package was a private line on the publish path, so a service that had
765
+ * to declare an exchange reached around the library for a raw amqplib channel.
766
+ *
767
+ * @param {string} exchange - Exchange name.
768
+ * @param {string} type - `direct`, `topic`, `fanout` or `headers`; required,
769
+ * because an exchange's type is the caller's topology decision.
770
+ * @param {Object} [options] - amqplib exchange options; `durable` defaults to
771
+ * the client's configured value.
772
+ * @returns {Promise<Object>}
773
+ * @throws {ConnectionError} If not connected.
774
+ * @throws {ValidationError} If the name or type cannot declare an exchange.
775
+ */
776
+ async assertExchange(exchange, type, options = {}) {
777
+ if (!this._connected || !this._transport) {
778
+ throw new ConnectionError(
779
+ '[BaseClient] Cannot assertExchange: client is not connected - Expected: connect() to have completed. '
780
+ + 'Fix: await client.connect() before assertExchange().'
781
+ );
782
+ }
783
+ return await this._transport.assertExchange(exchange, type, options);
784
+ }
785
+
786
+ /**
787
+ * Bind a queue to an exchange under a routing pattern (delegates to transport).
788
+ *
789
+ * @param {string} queue - Queue to bind.
790
+ * @param {string} exchange - Exchange to bind it to.
791
+ * @param {string} pattern - Routing pattern; required, and `''` is legal (a
792
+ * fanout exchange ignores the key).
793
+ * @returns {Promise<Object>}
794
+ * @throws {ConnectionError} If not connected.
795
+ * @throws {ValidationError} If a name or the pattern cannot form a binding.
796
+ */
797
+ async bindQueue(queue, exchange, pattern) {
798
+ if (!this._connected || !this._transport) {
799
+ throw new ConnectionError(
800
+ '[BaseClient] Cannot bindQueue: client is not connected - Expected: connect() to have completed. '
801
+ + 'Fix: await client.connect() before bindQueue().'
802
+ );
803
+ }
804
+ return await this._transport.bindQueue(queue, exchange, pattern);
805
+ }
806
+
807
+ /**
808
+ * Stop one consumer this client registered (delegates to transport).
809
+ *
810
+ * Accepts the consumer tag `consume()` returns, or the queue name. The
811
+ * consumer is dropped from the client's registry as well as cancelled on the
812
+ * broker, so connection-level recovery does not bring it back.
813
+ *
814
+ * @param {string} consumerTagOrQueue - The tag, or the queue name.
815
+ * @returns {Promise<Object>}
816
+ * @throws {ConnectionError} If not connected.
817
+ * @throws {ValidationError} If this client holds no such consumer.
818
+ */
819
+ async cancelConsumer(consumerTagOrQueue) {
820
+ if (!this._connected || !this._transport) {
821
+ throw new ConnectionError(
822
+ '[BaseClient] Cannot cancelConsumer: client is not connected - Expected: connect() to have completed. '
823
+ + 'Fix: await client.connect() before cancelConsumer().'
824
+ );
825
+ }
826
+ return await this._transport.cancelConsumer(consumerTagOrQueue);
827
+ }
828
+
218
829
  /**
219
830
  * Check queue existence (delegates to transport).
220
831
  * @param {string} queue - Queue name
@@ -222,10 +833,16 @@ class BaseClient {
222
833
  */
223
834
  async checkQueue(queue) {
224
835
  if (!this._connected || !this._transport) {
225
- throw new ConnectionError('Cannot checkQueue: client is not connected');
836
+ throw new ConnectionError(
837
+ '[BaseClient] Cannot checkQueue: client is not connected - Expected: connect() to have completed. '
838
+ + 'Fix: await client.connect() before checkQueue().'
839
+ );
226
840
  }
227
841
  if (typeof this._transport.checkQueue !== 'function') {
228
- throw new Error('[BaseClient] Transport does not support checkQueue(queue)');
842
+ throw new Error(
843
+ '[BaseClient] Transport does not support checkQueue(queue) - Expected: a transport implementing checkQueue. '
844
+ + 'Fix: use the rabbitmq transport, or drop the checkQueue() call for this transport type.'
845
+ );
229
846
  }
230
847
  return await this._transport.checkQueue(queue);
231
848
  }
@@ -237,10 +854,16 @@ class BaseClient {
237
854
  */
238
855
  async purgeQueue(queue) {
239
856
  if (!this._connected || !this._transport) {
240
- throw new ConnectionError('Cannot purgeQueue: client is not connected');
857
+ throw new ConnectionError(
858
+ '[BaseClient] Cannot purgeQueue: client is not connected - Expected: connect() to have completed. '
859
+ + 'Fix: await client.connect() before purgeQueue().'
860
+ );
241
861
  }
242
862
  if (typeof this._transport.purgeQueue !== 'function') {
243
- throw new Error('[BaseClient] Transport does not support purgeQueue(queue)');
863
+ throw new Error(
864
+ '[BaseClient] Transport does not support purgeQueue(queue) - Expected: a transport implementing purgeQueue. '
865
+ + 'Fix: use the rabbitmq transport, or drop the purgeQueue() call for this transport type.'
866
+ );
244
867
  }
245
868
  return await this._transport.purgeQueue(queue);
246
869
  }
@@ -253,10 +876,16 @@ class BaseClient {
253
876
  */
254
877
  async deleteQueue(queue, options = {}) {
255
878
  if (!this._connected || !this._transport) {
256
- throw new ConnectionError('Cannot deleteQueue: client is not connected');
879
+ throw new ConnectionError(
880
+ '[BaseClient] Cannot deleteQueue: client is not connected - Expected: connect() to have completed. '
881
+ + 'Fix: await client.connect() before deleteQueue().'
882
+ );
257
883
  }
258
884
  if (typeof this._transport.deleteQueue !== 'function') {
259
- throw new Error('[BaseClient] Transport does not support deleteQueue(queue, options)');
885
+ throw new Error(
886
+ '[BaseClient] Transport does not support deleteQueue(queue, options) - Expected: a transport implementing deleteQueue. '
887
+ + 'Fix: use the rabbitmq transport, or drop the deleteQueue() call for this transport type.'
888
+ );
260
889
  }
261
890
  return await this._transport.deleteQueue(queue, options);
262
891
  }
@@ -281,10 +910,16 @@ class BaseClient {
281
910
  */
282
911
  async ack(msg) {
283
912
  if (!this._connected || !this._transport) {
284
- throw new ConnectionError('Cannot ack: client is not connected');
913
+ throw new ConnectionError(
914
+ '[BaseClient] Cannot ack: client is not connected - Expected: connect() to have completed. '
915
+ + 'Fix: await client.connect() before ack(); a message received before a reconnect can no longer be acked.'
916
+ );
285
917
  }
286
918
  if (typeof this._transport.ack !== 'function') {
287
- throw new Error('[BaseClient] Transport does not support ack(msg)');
919
+ throw new Error(
920
+ '[BaseClient] Transport does not support ack(msg) - Expected: a transport implementing ack. '
921
+ + 'Fix: use the rabbitmq transport, or consume with noAck for this transport type.'
922
+ );
288
923
  }
289
924
  return await this._transport.ack(msg);
290
925
  }
@@ -292,15 +927,26 @@ class BaseClient {
292
927
  /**
293
928
  * Negative-acknowledge a raw broker message (delegates to transport).
294
929
  * @param {Object} msg - Broker message object
295
- * @param {Object} [options] - { requeue: boolean }
930
+ * @param {Object} [options] - { requeue: boolean }; `requeue` defaults to
931
+ * `true` in the transport, so a caller that wants the message discarded
932
+ * must pass `{ requeue: false }`. This is an options OBJECT, never
933
+ * amqplib's positional `(msg, allUpTo, requeue)` — see
934
+ * transports/rabbitmqClient.js nack() and
935
+ * tests/unit/nack-options-signature.test.js.
296
936
  * @returns {Promise<void>}
297
937
  */
298
938
  async nack(msg, options = {}) {
299
939
  if (!this._connected || !this._transport) {
300
- throw new ConnectionError('Cannot nack: client is not connected');
940
+ throw new ConnectionError(
941
+ '[BaseClient] Cannot nack: client is not connected - Expected: connect() to have completed. '
942
+ + 'Fix: await client.connect() before nack(); a message received before a reconnect can no longer be nacked.'
943
+ );
301
944
  }
302
945
  if (typeof this._transport.nack !== 'function') {
303
- throw new Error('[BaseClient] Transport does not support nack(msg, options)');
946
+ throw new Error(
947
+ '[BaseClient] Transport does not support nack(msg, options) - Expected: a transport implementing nack. '
948
+ + 'Fix: use the rabbitmq transport, or consume with noAck for this transport type.'
949
+ );
304
950
  }
305
951
  return await this._transport.nack(msg, options);
306
952
  }
@@ -331,54 +977,281 @@ class BaseClient {
331
977
  }
332
978
 
333
979
  /**
334
- * Check if client is connected
980
+ * Registers a handler for the permanent loss of the broker connection.
981
+ *
982
+ * It fires at most once per client, after connection-level recovery has spent
983
+ * its whole budget (`RABBITMQ_MAX_RECONNECT_ATTEMPTS`). At that point the
984
+ * client is dead until the process restarts: it makes no further attempts,
985
+ * `isConnected()` returns false, and publish/consume refuse.
986
+ *
987
+ * The library does not end the process — that decision belongs to the owner
988
+ * of the lifecycle. A service typically logs and exits non-zero here, so its
989
+ * restart policy boots it again and boot re-verifies every dependency.
990
+ *
991
+ * @param {function(Error): void} callback - Receives the fatal error
992
+ * (`code: 'MQ_CONNECTION_FATAL'`, `attempts: <budget>`).
993
+ */
994
+ onFatal(callback) {
995
+ if (typeof callback === 'function') {
996
+ this._fatalHandlers.push(callback);
997
+ }
998
+ }
999
+
1000
+ /**
1001
+ * Internal helper to invoke all registered fatal handlers.
1002
+ * @param {Error} error
1003
+ * @private
1004
+ */
1005
+ _handleFatal(error) {
1006
+ this._fatalHandlers.forEach((cb) => {
1007
+ try {
1008
+ cb(error);
1009
+ } catch (err) {
1010
+ this._logger.error(`[BaseClient] onFatal handler threw: ${err.message}`, {
1011
+ message: err.message,
1012
+ });
1013
+ }
1014
+ });
1015
+ }
1016
+
1017
+ /**
1018
+ * What this client's publishes have done so far — the counters
1019
+ * `monitoring/PublishMonitor.js` keeps: `attempts`, `successes`, `failures`,
1020
+ * `retries`, `buffered`, `flushed`, plus a per-queue breakdown.
1021
+ *
1022
+ * Delegated, never recomputed: the transport owns the counting, this class
1023
+ * owns the surface a service can reach. Until 2026-09-14 it owned neither —
1024
+ * the five reporting methods were on the transport alone, and
1025
+ * `new RabbitMQClient` is called in exactly one place in the workspace
1026
+ * (`transports/transportFactory.js`, inside this library), so the counters
1027
+ * were documented (`docs/architecture/mq-publish-reliability.md`) and readable
1028
+ * by nobody.
1029
+ *
1030
+ * @returns {Object} `{ attempts, successes, failures, retries, buffered, flushed, queues }`
1031
+ * @throws {ConnectionError} Before `connect()` has built a transport: there is
1032
+ * nothing to report, and a zeroed object would be an invented answer
1033
+ * (`architecture-principles.md` §3).
1034
+ */
1035
+ getPublishMetrics() {
1036
+ return this._reportFromTransport('getPublishMetrics');
1037
+ }
1038
+
1039
+ /**
1040
+ * The same counters as Prometheus text exposition (`mq_publish_attempts`,
1041
+ * `mq_publish_successes`, `mq_publish_failures`, `mq_publish_retries`,
1042
+ * `mq_publish_buffered`, `mq_publish_flushed`).
1043
+ *
1044
+ * @returns {string}
1045
+ * @throws {ConnectionError} Before `connect()` has built a transport.
1046
+ */
1047
+ getPrometheusMetrics() {
1048
+ return this._reportFromTransport('getPrometheusMetrics');
1049
+ }
1050
+
1051
+ /**
1052
+ * Liveness of the three channels this client runs on, each as
1053
+ * `{ exists, closed, ready }` — the publisher's ConfirmChannel, the queue
1054
+ * channel and the consumer channel (`docs/architecture/rabbitmq-channel-lifecycle.md`).
1055
+ *
1056
+ * @returns {Object} `{ publisher, queue, consumer }`
1057
+ * @throws {ConnectionError} Before `connect()` has built a transport.
1058
+ */
1059
+ getChannelState() {
1060
+ return this._reportFromTransport('getChannelState');
1061
+ }
1062
+
1063
+ /**
1064
+ * The consumers this client currently holds: how many, and on which queues.
1065
+ *
1066
+ * @returns {Object} `{ tracked: <count>, active: [<queue names>] }`
1067
+ * @throws {ConnectionError} Before `connect()` has built a transport.
1068
+ */
1069
+ getConsumerState() {
1070
+ return this._reportFromTransport('getConsumerState');
1071
+ }
1072
+
1073
+ /**
1074
+ * How much is waiting in the publish buffer, in memory and in Redis.
1075
+ *
1076
+ * @returns {Object} `{ size, inMemory, persistent }`
1077
+ * @throws {ConnectionError} Before `connect()` has built a transport.
1078
+ */
1079
+ getBufferState() {
1080
+ return this._reportFromTransport('getBufferState');
1081
+ }
1082
+
1083
+ /**
1084
+ * The ONE rail for the reporting methods above: ask the transport, or say why
1085
+ * there is no answer.
1086
+ *
1087
+ * Keyed on the TRANSPORT, not on `_connected` — the same distinction
1088
+ * `disconnect()` makes. A client whose connection has just died still owns a
1089
+ * transport whose counters are real, and that is exactly the moment somebody
1090
+ * asks. What there is no answer to is a client that has never connected: it
1091
+ * holds no transport, so it has counted nothing, and a zeroed object would be
1092
+ * an invented value rather than a measurement.
1093
+ *
1094
+ * @param {string} method - The transport method to report from.
1095
+ * @returns {*} Whatever that method answers.
1096
+ * @throws {ConnectionError} If no transport has been built yet.
1097
+ * @private
1098
+ */
1099
+ _reportFromTransport(method) {
1100
+ if (!this._transport) {
1101
+ throw new ConnectionError(
1102
+ `[BaseClient] Cannot ${method}(): the client has no transport - Expected: connect() to have built one. `
1103
+ + `Fix: await client.connect() before ${method}(); a client that never connected has counted nothing, `
1104
+ + 'and a zeroed answer would be invented rather than measured.'
1105
+ );
1106
+ }
1107
+
1108
+ return this._transport[method]();
1109
+ }
1110
+
1111
+ /**
1112
+ * Is this client's connection permanently lost?
1113
+ *
1114
+ * The difference between "the broker is away" and "this connection is over" is
1115
+ * what a healthcheck and a restart decision turn on, and until d.341 only the
1116
+ * TRANSPORT could answer it — while `new RabbitMQClient` is called in exactly
1117
+ * one place in the whole workspace, inside this library. A service holding a
1118
+ * `BaseClient` had `isConnected() === false` and no way to learn which of the
1119
+ * two it was: wait, or restart.
1120
+ *
1121
+ * Delegated, never recomputed — the transport owns the state, this class owns
1122
+ * the surface. It does NOT follow the rule of the five reporting methods
1123
+ * (`_reportFromTransport`, which refuses before `connect()`): a counter has
1124
+ * nothing to report before the first connection, but "is this client's
1125
+ * connection permanently lost" has a true answer for a client that never had
1126
+ * one — no. A healthcheck asks this question, and it must not be answered by
1127
+ * an exception.
1128
+ *
1129
+ * @returns {boolean} true once the broker refused the connection, or every
1130
+ * recovery cycle has been spent (`README.md` § Connection loss).
1131
+ */
1132
+ isConnectionFatal() {
1133
+ if (!this._transport || typeof this._transport.isConnectionFatal !== 'function') {
1134
+ return false;
1135
+ }
1136
+ return this._transport.isConnectionFatal();
1137
+ }
1138
+
1139
+ /**
1140
+ * Ask the client to check itself now: connection, the three channels, and the
1141
+ * queues its tracked consumers run on.
1142
+ *
1143
+ * The same probe the internal health monitor runs on its interval, exposed for
1144
+ * the caller who wants an answer at a moment of its own choosing (before a
1145
+ * workflow step, from a readiness endpoint) — documented as callable in
1146
+ * `docs/architecture/rabbitmq-channel-lifecycle.md` and, until d.341, callable
1147
+ * only on a transport no service holds.
1148
+ *
1149
+ * Since d.339 it is also a USE: a client that stood down after a spent recovery
1150
+ * cycle starts the next one here. It does not wait for it — the answer is the
1151
+ * state now, "not connected", and the next call says whether the cycle worked.
1152
+ *
1153
+ * @returns {Promise<Object>} `{ timestamp, connection, channels, consumers, queues, healthy, issues }`
1154
+ * @throws {ConnectionError} Before `connect()` has built a transport: there is
1155
+ * nothing to check, and a green verdict would be an invented answer.
1156
+ */
1157
+ async performHealthCheck() {
1158
+ return this._reportFromTransport('performHealthCheck');
1159
+ }
1160
+
1161
+ /**
1162
+ * Check if client is connected.
1163
+ *
1164
+ * BREAKING since 2026-09-07: the answer comes from the transport's live
1165
+ * connection state, not from a flag set once in `connect()`. The old version
1166
+ * returned `true` for the entire outage AND after the recovery budget was
1167
+ * spent — measured on 2026-09-07 as `true` for 100+ s against a broker that
1168
+ * was down and then back up, with the client permanently dead. Every
1169
+ * healthcheck built on this method (including `conn-infra-mq`'s
1170
+ * `getHealth().connected`) reported a zombie process as healthy.
1171
+ *
335
1172
  * @returns {boolean} Connection status
336
1173
  */
337
1174
  isConnected() {
338
- return this._connected === true;
1175
+ if (this._connected !== true) return false;
1176
+ if (this._transport && typeof this._transport.isConnected === 'function') {
1177
+ return this._transport.isConnected();
1178
+ }
1179
+ return true;
339
1180
  }
340
1181
  }
341
1182
 
342
1183
  module.exports = BaseClient;
343
1184
 
1185
+ /**
1186
+ * The clients this module currently holds — every instance that has been
1187
+ * constructed and not yet disconnected.
1188
+ *
1189
+ * It exists for exactly one reason: `disconnectAll()` has to know what "all"
1190
+ * means. One `BaseClient` owns one transport, so closing one client is already
1191
+ * `disconnect()`; "all" can only be the set of live instances, and that set has
1192
+ * to be kept where the instances are created.
1193
+ */
344
1194
  BaseClient._instances = new Set();
345
- BaseClient._cleanupRegistered = false;
346
- BaseClient._cleanupRunning = false;
347
1195
 
348
1196
  BaseClient._registerInstance = function registerInstance(instance) {
349
1197
  BaseClient._instances.add(instance);
350
- if (!BaseClient._cleanupRegistered) {
351
- BaseClient._registerCleanupHooks();
352
- }
353
1198
  };
354
1199
 
355
1200
  BaseClient._unregisterInstance = function unregisterInstance(instance) {
356
1201
  BaseClient._instances.delete(instance);
357
1202
  };
358
1203
 
359
- BaseClient._registerCleanupHooks = function registerCleanupHooks() {
360
- const handler = async (signal) => {
361
- if (BaseClient._cleanupRunning) return;
362
- BaseClient._cleanupRunning = true;
363
- const disconnects = Array.from(BaseClient._instances).map((instance) =>
364
- instance
365
- .disconnect()
366
- .catch((err) =>
367
- console.warn('[BaseClient] Failed to disconnect during shutdown:', err.message)
368
- )
369
- );
370
- await Promise.allSettled(disconnects);
371
- BaseClient._instances.clear();
372
- BaseClient._cleanupRunning = false;
373
- if (signal && signal !== 'beforeExit') {
374
- process.exit(0); // eslint-disable-line no-process-exit
375
- }
376
- };
1204
+ /**
1205
+ * Close every client this module holds. The service calls it; nothing in this
1206
+ * library calls it for them.
1207
+ *
1208
+ * This is the whole of the library's side of the shutdown contract
1209
+ * (`docs/governance/confirmations/mq-client-lifecycle-contract.md` 001 point 1):
1210
+ * the library registers no signal handler and never ends the process, and in
1211
+ * exchange it offers one named call the lifecycle owner can put wherever its own
1212
+ * sequence needs it — before the Redis clients, after the HTTP server, in the
1213
+ * order THAT process decided.
1214
+ *
1215
+ * What it replaced, and why the replacement is not a smaller version of it:
1216
+ * until this batch the library installed `SIGINT`/`SIGTERM`/`beforeExit`
1217
+ * listeners on the first instance anyone constructed, and that listener ended in
1218
+ * `process.exit(0)`. The registry service traced the result live
1219
+ * (`infra/api_services_registry/src/shutdown.js`, batch 140, dev stack
1220
+ * 2026-08-31): `TRACE_END mq t=12` → `TRACE_PROCESS_EXIT code=0 t=16`. Node
1221
+ * copies the listener array before it emits, so the service could not remove the
1222
+ * library's listener in time; the library's exit won the race after the MQ
1223
+ * connection had closed and BEFORE four Redis clients, the HTTP server and six
1224
+ * timers were handed back. The service had written a complete shutdown sequence
1225
+ * and got a truncated one, with nothing in its own repository to explain it.
1226
+ *
1227
+ * Failure is reported, never swallowed. The deleted hook logged a warning per
1228
+ * failed client and exited 0 anyway — a shutdown that could not close a client
1229
+ * looked exactly like one that did. Here every client gets its turn (one dead
1230
+ * socket must not leave the others open), and if any refused, the call rejects
1231
+ * with a `ConnectionError` naming how many of how many failed, carrying the
1232
+ * first reason as its cause. The clients that failed stay in the set, because
1233
+ * `disconnect()` removes an instance only when it actually closed — so a second
1234
+ * `disconnectAll()` reaches them again, where the hook's blanket `clear()` made
1235
+ * the retry a silent no-op.
1236
+ *
1237
+ * @returns {Promise<void>} Resolves when every client held by this module has
1238
+ * closed — including when there were none.
1239
+ * @throws {ConnectionError} If one or more clients could not close. `error.cause`
1240
+ * is the first underlying failure.
1241
+ */
1242
+ BaseClient.disconnectAll = async function disconnectAll() {
1243
+ const instances = Array.from(BaseClient._instances);
1244
+ const results = await Promise.allSettled(instances.map((instance) => instance.disconnect()));
1245
+ const failures = results.filter((result) => result.status === 'rejected');
377
1246
 
378
- ['SIGINT', 'SIGTERM'].forEach((sig) => {
379
- process.on(sig, () => handler(sig));
380
- });
381
- process.on('beforeExit', () => handler('beforeExit'));
382
- BaseClient._cleanupRegistered = true;
1247
+ if (failures.length > 0) {
1248
+ throw new ConnectionError(
1249
+ `[BaseClient] Failed to disconnect ${failures.length} of ${instances.length} clients - `
1250
+ + 'Expected: every client this module holds to close its transport. '
1251
+ + 'Fix: read error.cause for the first transport reason; the clients that failed are still '
1252
+ + 'held, so a repeated disconnectAll() reaches them again.',
1253
+ failures[0].reason
1254
+ );
1255
+ }
383
1256
  };
384
1257