@onlineapps/mq-client-core 2.0.1 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,15 @@
1
+ > Status: current
2
+ > Owns: the base RabbitMQ client that infrastructure services and the MQ connector both build on
3
+
4
+ <!-- BEGIN GENERATED: library-uniform — regenerate: npx oa-sync-template readme-uniform --all -->
5
+ Uniform: [library/connector](../connector/conn-orch-validator/manifests/library.manifest.json)
6
+
7
+ Duty sections that apply:
8
+
9
+ - `all`: L-MAIN, L-ENGINES, L-TESTS, L-TEST-SCRIPT, L-PACK-TESTS, L-PINS, L-NO-FILE-RANGE, L-CHANGELOG, L-README, L-README-REGION, L-CONSUMER
10
+ - `connector`: L-CONNECTOR-ENV
11
+ <!-- END GENERATED: library-uniform -->
12
+
1
13
  # @onlineapps/mq-client-core
2
14
 
3
15
  Core MQ client library for RabbitMQ - shared by infrastructure services and connectors.
@@ -24,7 +36,7 @@ const BaseClient = require('@onlineapps/mq-client-core');
24
36
  const mqClient = new BaseClient({
25
37
  type: 'rabbitmq',
26
38
  host: 'amqp://localhost:5672',
27
- queue: 'workflow.init' // Optional default queue
39
+ logger // REQUIRED: info/warn/error/debug
28
40
  });
29
41
 
30
42
  await mqClient.connect();
@@ -34,21 +46,83 @@ await mqClient.publish('workflow.control', { workflowId: '123', data: '...' });
34
46
 
35
47
  ### Configuration
36
48
 
37
- **Flexible Schema** - Only `type` and `host` are required:
49
+ **Required:** `type`, `host`, `logger`. Every other key is optional — but it has to
50
+ be a key this client declares: a name nothing declares is **refused at construction**,
51
+ named, together with the declared key it is closest to. There is no "anything else"
52
+ door (`src/config/configSchema.js`, `additionalProperties: false`). A key the client
53
+ does not read configures nothing, and until 2026-09-14 the value written under it was
54
+ lost in silence — `queue` rode that door from four callers for the lifetime of the
55
+ package.
38
56
 
39
57
  ```javascript
40
58
  {
41
59
  type: 'rabbitmq', // Required
42
60
  host: 'amqp://...', // Required
43
- queue: 'optional', // Optional default queue
61
+ logger, // Required: info/warn/error/debug
44
62
  exchange: '', // Optional exchange
45
63
  durable: true, // Optional (default: true)
46
64
  prefetch: 1, // Optional (default: 1)
47
65
  noAck: false, // Optional (default: false)
48
- logger: null // Optional custom logger
66
+ maxReconnectAttempts: 10, // Optional; env RABBITMQ_MAX_RECONNECT_ATTEMPTS — attempts per cycle
67
+ maxReconnectCycles: 3, // Optional; env RABBITMQ_MAX_RECONNECT_CYCLES — cycles before it is over
68
+ heartbeat: 30, // Optional; env RABBITMQ_HEARTBEAT — SECONDS
69
+ connectTimeout: 10000, // Optional; how long one handshake may take (ms)
70
+ brokerAnswerTimeout: 5000,// Optional; how long to wait for an answer from the broker (ms)
71
+ onFatal: null // Optional callback, see § Connection loss
49
72
  }
50
73
  ```
51
74
 
75
+ **The heartbeat is composed into the connection URL**, because that is the only
76
+ place amqplib 0.10 reads it (`lib/connect.js`: `intOrDefault(query.heartbeat, 0)`).
77
+ A `host` that already carries `?heartbeat=` is therefore **refused**: one fact, one
78
+ declaration. Until 2026-09-14 the value was handed to amqplib's socket options
79
+ instead, where it is not read, so the handshake asked for `0` — "whatever the peer
80
+ says" — and every connection on the platform ran on the broker's number rather than
81
+ the configured one (measured on the dev broker: 39 live connections, `timeout: 60`
82
+ on all of them).
83
+
84
+ `brokerAnswerTimeout` bounds **both** things this client waits for the broker to
85
+ answer: the confirm of one publish attempt, and the `close-ok` of each handle
86
+ `disconnect()` closes. It was called `publishConfirmationTimeout` until
87
+ 2026-09-14 — the name of one of its two consumers — and writing that name now is
88
+ refused, with the new one named.
89
+
90
+ `maxReconnectAttempts` and `maxReconnectCycles` resolve the usual way — explicit
91
+ config → env (`RABBITMQ_MAX_RECONNECT_ATTEMPTS`, `RABBITMQ_MAX_RECONNECT_CYCLES`)
92
+ → module default in `src/defaults.js`. They are client *behaviour*, like
93
+ `heartbeat`, so this module owns their defaults; broker topology
94
+ (`host`/`RABBITMQ_URL`) stays required and is never defaulted. The first is the
95
+ attempts inside ONE recovery cycle, the second how many cycles there may be — see
96
+ § Connection loss.
97
+
98
+ ### A subclass declares its own keys
99
+
100
+ `new BaseClient(config, extraProperties?)`. The second argument is the JSON Schema
101
+ `properties` of a SUBCLASS, for the keys it reads off the same config object — the one
102
+ place the configuration contract is extended. `ConnectorMQClient`
103
+ ([`@onlineapps/conn-infra-mq`](../connector/conn-infra-mq)) passes its own four there:
104
+ each side declares what it owns, so one key keeps one type, one description and one
105
+ owner. A subclass that redeclares a key of this library is refused, naming the key; a
106
+ key neither side declares is refused like any other unknown name.
107
+
108
+ ### Logging — one channel, injected
109
+
110
+ The library owns NO logger. `logger` is required and validated in the
111
+ `BaseClient` constructor by `assertLogger()` from
112
+ [`@onlineapps/logger-contract`](../logger-contract): an object with
113
+ `info`, `warn`, `error` and `debug`. A missing or incomplete
114
+ logger fails the constructor, it does not fall back to `console`.
115
+
116
+ The same object is forwarded to everything the client builds — transport,
117
+ publish layer, recovery worker, buffers — so one service configures one
118
+ channel and the whole client writes there. Levels: `info` for lifecycle
119
+ (connected, consumer registered, channel recovered), `debug` for
120
+ per-message and setup detail, `warn`/`error` for the rest. An error
121
+ that is thrown is not also logged.
122
+
123
+ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
124
+ 001-004.
125
+
52
126
  ## API
53
127
 
54
128
  ### BaseClient
@@ -56,15 +130,317 @@ await mqClient.publish('workflow.control', { workflowId: '123', data: '...' });
56
130
  - `connect(options?)` - Connect to RabbitMQ
57
131
  - `disconnect()` - Disconnect from RabbitMQ
58
132
  - `publish(queue, message, options?)` - Publish message to queue
59
- - `consume(queue, handler, options?)` - Consume messages from queue
133
+ - `consume(queue, handler, options?)` - Consume messages from queue; resolves with
134
+ the broker's consumer tag. A failing handler is answered by the delivery policy,
135
+ never by a bare requeue — see § `consume()` — the delivery contract.
136
+ - `cancelConsumer(consumerTagOrQueue)` - Stop one consumer this client registered,
137
+ by the tag `consume()` returned or by the queue name. The consumer is dropped
138
+ from the client's registry too, so a later reconnect does not bring it back.
139
+ A consumer that is **tracked but not attached** — its re-registration was
140
+ refused, so the tag it held names nothing — is refused by name instead: there
141
+ is nothing on the broker to cancel, and it stays tracked so the next channel
142
+ recreate can re-attach it. Listen for `consumer:re-registration:failed` to
143
+ learn why it could not.
144
+ - `isConnectionFatal()` - Is this connection permanently lost (the broker refused
145
+ it, or every recovery cycle is spent)? The difference between "wait" and
146
+ "restart me" — see § Connection loss. `false` on a client that never connected:
147
+ a healthcheck asks this, so it answers rather than throwing.
148
+ - `performHealthCheck()` - Run the client's own check now and return its verdict
149
+ (`{ timestamp, connection, channels, consumers, queues, healthy, issues }`).
150
+ Also counts as a use, so a client that stood down starts its next recovery
151
+ cycle here. A client that TRACKS consumers and has none attached is **not
152
+ healthy** — `healthy: false`, with an issue naming how many it tracks — because
153
+ such a client takes no message off any queue. A client that registered no
154
+ consumer at all is a publisher and stays healthy; the check's own log line says
155
+ which of the two it is.
156
+ - `assertQueue(queue, options?)` / `checkQueue(queue)` / `purgeQueue(queue)` /
157
+ `deleteQueue(queue, options?)` - Queue operations, on the queue channel.
158
+ `assertQueue()` reads exactly four options — `durable`, `arguments`,
159
+ `exclusive`, `autoDelete` — and refuses any other key by name, together with
160
+ the declared key it is closest to. An option nobody reads declares nothing, and
161
+ the caller acts on a guarantee nobody gave. `exclusive` (the queue dies with
162
+ the connection that declared it) and `autoDelete` (it dies with its last
163
+ consumer) are the only way to say a queue is temporary; a key left out is left
164
+ out of the declaration rather than sent as `false`. For an INFRASTRUCTURE or
165
+ BUSINESS name the central `queueConfig` decides `durable` and `arguments` as
166
+ always, and a lifetime asked for on such a name is refused — those queues
167
+ outlive their declarer by design (see § Queue ownership).
168
+ - `assertExchange(exchange, type, options?)` - Declare an exchange. `type` is
169
+ required (`direct`, `topic`, `fanout`, `headers`): an exchange's type is a
170
+ topology decision, so the library does not choose one. `durable` defaults to
171
+ the client's configured value. This is the ONLY place this client declares an
172
+ exchange — `publish()` names one, it never declares it (see § Queue ownership).
173
+ - `bindQueue(queue, exchange, pattern)` - Bind a queue to an exchange. `pattern`
174
+ is required and `''` is legal — a fanout exchange ignores the key, but which
175
+ key a binding carries is never guessed.
60
176
  - `ack(msg)` - Acknowledge message
61
- - `nack(msg, options?)` - Negative acknowledge message
62
- - `isConnected()` - Check connection status
177
+ - `nack(msg, options?)` - Negative acknowledge message. `options` is an OBJECT
178
+ `{ requeue: boolean }`, never amqplib's positional `(msg, allUpTo, requeue)`;
179
+ `requeue` defaults to `true`, so discarding a message needs an explicit
180
+ `{ requeue: false }`.
181
+ - `isConnected()` - Live connection status, read from the transport
182
+ - `getPublishMetrics()` / `getPrometheusMetrics()` - What this client's publishes
183
+ have done so far: `attempts`, `successes`, `failures`, `retries`, `buffered`,
184
+ `flushed`, plus a per-queue breakdown; the second renders the same counters as
185
+ Prometheus text.
186
+ - `getChannelState()` / `getConsumerState()` / `getBufferState()` - Liveness of the
187
+ three channels, the consumers this client holds, and what is waiting in the
188
+ publish buffer (`{ size, inMemory }` — there is one buffer). All five refuse
189
+ before `connect()` has built a transport, rather than answering with an invented
190
+ zero.
191
+ - `disconnectAll()` (static, on the class and on the package export) - Close every
192
+ client this module holds; see § Shutdown.
63
193
  - `onError(callback)` - Register error handler
194
+ - `onFatal(callback)` - Register handler for the permanent loss of the
195
+ connection (see § Connection loss)
64
196
 
65
197
  `publish()` and the workflow helpers built on it resolve to `undefined`; failure is
66
198
  signalled by a thrown `PublishError`, never by a falsy return.
67
199
 
200
+ ### `consume()` — the delivery contract
201
+
202
+ A handler that throws does **not** get an unconditional requeue. Every failure is
203
+ counted, and a message that cannot succeed ends in the dead-letter queue the broker
204
+ routes for its queue (`x-dead-letter-routing-key`, i.e. `<service>.dlq`) instead of
205
+ circling for ever. One rail for business and infrastructure consumers alike
206
+ (`api/docs/governance/confirmations/mq-consumer-contract.md` 001 and 002 point 3).
207
+
208
+ **A queue with no declared dead-letter route is refused, at registration.** The policy
209
+ ends in `nack(requeue=false)`, and the broker moves that message ONLY if the queue
210
+ declares where to (`x-dead-letter-exchange` + `x-dead-letter-routing-key`). Where it does
211
+ not, the broker drops the message instead — so `consume()` throws a `ConsumeError` naming
212
+ the queue before any consumer is attached, rather than losing messages later. There is no
213
+ opt-out: a consumer that wants a queue declares that queue's route in
214
+ `src/config/queueConfig.js`, which is also the only source of truth the check reads — the
215
+ broker cannot be asked, because `checkQueue()` answers with `queue.declare-ok`, which
216
+ carries `{ queue, messageCount, consumerCount }` and no arguments (measured, 2026-09-12).
217
+ `queueConfig.getDeadLetterRoute(queue)` is the exported form of the same answer.
218
+
219
+ **A TERMINAL queue is consumed, and it is not an exception to that rule but its other
220
+ half.** A queue something else dead-letters TO — `<service>.dlq`, `workflow.failed`,
221
+ `workflow.dlq`, `delivery.dlq` — declares no onward route because the topology says it
222
+ must not have one: a dead-letter route on a dead-letter queue closes a loop or starts a
223
+ chain with no end (`api/docs/governance/confirmations/mq-consumer-contract.md` 002
224
+ point 2, 003 point 2). So the gate asks a second question, `queueConfig.isDeadLetterTarget(queue)`,
225
+ derived from the templates themselves — the set of every `x-dead-letter-routing-key` they
226
+ declare, `{service}` placeholder and all — never from a list of names. Every OTHER queue
227
+ with no route is refused exactly as before, with the same sentence.
228
+
229
+ What a terminal queue does NOT get is a different delivery policy: the budget is counted
230
+ the same way, and a message that spends it is rejected with `nack(requeue=false)` the same
231
+ way. The difference is what the broker then does with it — there is nowhere to move it to,
232
+ so it is **dropped**. That loss is deliberate (the alternative is the endless requeue this
233
+ policy exists to end) and it is never silent: the client logs it at error level, naming
234
+ the queue, the attempts and the error, and the `message_dlq` event is published as for any
235
+ other rejection.
236
+
237
+ ```javascript
238
+ await client.consume(queue, async (msg, delivery) => {
239
+ // delivery = { attempt, maxAttempts, isFinalAttempt }
240
+ await handle(msg);
241
+ }, {
242
+ maxAttempts: 3, // optional; module default otherwise
243
+ classify: (error) => error.name === 'ValidationError' ? 'permanent' : 'transient'
244
+ });
245
+ ```
246
+
247
+ | the handler | what happens to the message |
248
+ |---|---|
249
+ | returns | acked, once |
250
+ | throws, error `transient`, attempts left | a copy carrying the advanced counter is published to the SAME queue, then the original is acked |
251
+ | throws, error `transient`, `maxAttempts` reached | `nack(requeue=false)` → the broker dead-letters it into `<service>.dlq`, and a `message_dlq` event is published |
252
+ | throws, error `permanent` | the same rejection, on the FIRST failure |
253
+ | acks or nacks the message itself | untouched — the transport's `_mqProcessed` sentinel still wins |
254
+
255
+ **`maxAttempts`** is how many times the handler may run for ONE message, not how many
256
+ retries follow the first run; `maxAttempts: 1` means "no second attempt". Resolution is
257
+ explicit → `RABBITMQ_MAX_DELIVERY_ATTEMPTS` → `defaults.js maxDeliveryAttempts` (3, the
258
+ value `@onlineapps/error-handler-core` already declares for the same decision). An
259
+ explicit value is taken exactly as written, so `'3'` and `2.5` are refused at `consume()`
260
+ rather than coerced; only the environment form is parsed as a number.
261
+
262
+ **`classify`** is optional. Without it every error is treated as transient — stated, not
263
+ implied: a permanently failing message still reaches `<service>.dlq`, after `maxAttempts`
264
+ attempts instead of after one. A classifier that throws, or answers with anything but
265
+ `'transient'` / `'permanent'`, is reported through the logger and its message is rejected
266
+ into the dead-letter queue: the library never guesses a classification.
267
+
268
+ **Why an attempt costs a re-publish and not a requeue.** Measured on the live broker
269
+ (2026-09-11): after `nack(requeue=true)` the redelivery carries `headers: {}` — the
270
+ message returns byte-for-byte, and `fields.redelivered` is a boolean, not a count. A
271
+ requeue is not a dead-letter, so the broker stamps no `x-death` to count from either.
272
+ The counter therefore has to travel in a header this library writes, and only a NEW
273
+ message can carry an advanced one. The copy is published first and the original acked
274
+ second, so a failed publish can duplicate a message but never lose one; when the publish
275
+ fails the original is requeued and the attempt is repeated rather than spent.
276
+
277
+ ```javascript
278
+ const { deliveryPolicy } = require('@onlineapps/mq-client-core');
279
+ deliveryPolicy.ATTEMPTS_HEADER; // 'x-oa-delivery-attempts' — read it, never retype it
280
+ ```
281
+
282
+ **The `message_dlq` event** goes to `monitoring.workflow` and carries `status: 'rejected'`
283
+ — the broker's own word for this death, what it stamps into `x-death[0].reason`. It is
284
+ published **only when the envelope carries a `workflow_id`**. Without one nothing is
285
+ published and nothing is invented: the absence is reported through the injected logger
286
+ with the queue and the service name, and the message stays readable in the dead-letter
287
+ queue through the DLQ dashboard. `monitoring.workflow` keys every trace on that id, so an
288
+ event naming no workflow is either refused as faulty telemetry or — with a marker like
289
+ `'unknown'` in its place — written as a row claiming a workflow by that name.
290
+
291
+ **`x-message-ttl` and the budget do not cooperate.** The service queues carry a TTL
292
+ (`<service>.queue` 30 s, `<service>.workflow` 5 min) and each attempt re-publishes the
293
+ message, which restarts that clock. So the TTL bounds ONE attempt, never the whole budget,
294
+ and `maxAttempts` is the only bound on the total. A message that does expire mid-budget is
295
+ dead-lettered by the broker to the same `<service>.dlq` with `x-death[0].reason: 'expired'`
296
+ — and no `message_dlq` event, because the client is not involved in that move. Both
297
+ reasons are visible on the dead message itself; the DLQ dashboard reads them
298
+ (`infra/api_monitoring/src/consumer/deadLetterPeek.js`).
299
+
300
+ #### `requeueOnError` — the same policy, said in one word
301
+
302
+ `consume(queue, handler, { requeueOnError })` is folded ONTO the budget above,
303
+ never placed beside it:
304
+
305
+ | value | meaning |
306
+ |---|---|
307
+ | `false` | the budget is **one** attempt — the first failure rejects into `<svc>.dlq`, exactly as a `permanent` classification does |
308
+ | `true` or absent | the budget is whatever configuration says (`maxAttempts` → `RABBITMQ_MAX_DELIVERY_ATTEMPTS` → `defaults.js`) |
309
+
310
+ There is no third meaning. `requeueOnError: false` together with `maxAttempts`
311
+ above 1 is a contradiction and is **refused where it was written**, rather than
312
+ one of the two being silently ignored.
313
+
314
+ ### Connection loss — the contract
315
+
316
+ When the broker goes away, the transport runs connection-level recovery with
317
+ exponential backoff, up to `maxReconnectAttempts` times. That whole budget is one
318
+ **cycle**. Three outcomes, no fourth:
319
+
320
+ | Outcome | What the client does |
321
+ |---|---|
322
+ | a reconnect succeeds | channels are recreated, consumers re-registered, buffered messages flushed, `reconnected` is emitted; the attempt and cycle counters reset to 0 |
323
+ | the cycle's attempts are spent, and the broker never answered | the client **stands down**: `connection:standby` is emitted, and the NEXT use starts the next cycle |
324
+ | the broker REFUSED, or every cycle is spent | the connection is declared **permanently lost** |
325
+
326
+ **While a recovery is running, `isConnected()` is `false` — including the part of it
327
+ where the socket is already back.** "Connected" means the client can be used, and a
328
+ re-established connection whose channels are still being recreated cannot publish or
329
+ consume. The two answers therefore agree at every instant: the moment `isConnected()`
330
+ turns `true`, `getChannelState()` reports all three channels ready.
331
+
332
+ **Standing down is not dying, and the difference is the point.** An outage the
333
+ broker will come back from must not turn a client into a corpse
334
+ (`docs/governance/confirmations/mq-client-lifecycle-contract.md` 001 point 3 —
335
+ "give up forever" is forbidden). So a spent cycle means:
336
+
337
+ - `connection:standby` is emitted with `{ cycle, cyclesMax, attempts, lastError,
338
+ timestamp }`, and the client holds no timer and no socket;
339
+ - `isConnected()` is `false`, `isConnectionFatal()` is `false`;
340
+ - **nothing polls.** The retry is lazy: the next `publish()`, `consume()` or
341
+ `performHealthCheck()` starts the next cycle, and a client nobody uses costs
342
+ nothing while the broker is away;
343
+ - a cycle that succeeds resets the counter, so the next outage starts again at
344
+ cycle 1.
345
+
346
+ `maxReconnectCycles` bounds how many cycles this client will ever run (explicit
347
+ config → `RABBITMQ_MAX_RECONNECT_CYCLES` → module default). It is what keeps the
348
+ lazy retry finite: when the last cycle is spent, the connection is permanently
349
+ lost.
350
+
351
+ **A refusal is not an outage.** If the broker ANSWERED and said no — 403
352
+ `ACCESS-REFUSED`, 530 `NOT-ALLOWED`, 406 `PRECONDITION-FAILED` — retrying cannot
353
+ change the answer, so not one further attempt is spent on it and the connection is
354
+ permanently lost immediately. (One refusal this client cannot recognise: amqplib
355
+ renders a wrong vhost as `Expected ConnectionOpenOk; got <ConnectionClose
356
+ channel:0>`, with the broker's 530 dropped before the error is built. That one is
357
+ treated as an outage and ends at the cycle cap instead.)
358
+
359
+ Permanently lost means all of this, together:
360
+
361
+ - `connection:fatal` is emitted on the transport, and the injected `onFatal`
362
+ callback is called — once — with an `Error` carrying
363
+ `code: 'MQ_CONNECTION_FATAL'`, `reason: 'broker-refused' | 'cycles-spent'`,
364
+ `attempts: <budget per cycle>` and `cycles: <cycles spent>`;
365
+ - `isConnected()` returns `false` — on the transport, on `BaseClient`, and
366
+ therefore in `conn-infra-mq`'s `getHealth().connected` and in any healthcheck
367
+ built on them;
368
+ - no further reconnect attempts are made, by a use or otherwise — the client is
369
+ dead until the process restarts, or until an explicit `connect()`;
370
+ - `publish()`/`consume()` refuse immediately instead of blocking on a recovery
371
+ that is not coming.
372
+
373
+ **The library never ends the process, and never listens for a signal.** Ending
374
+ it is the lifecycle owner's decision: a service typically logs and exits
375
+ non-zero from `onFatal`, so its restart policy boots it again and boot
376
+ re-verifies every dependency. What the library owes the owner is the truth,
377
+ once, on a channel that cannot be confused with an ordinary transient error —
378
+ and one call to close what it holds, `BaseClient.disconnectAll()`.
379
+
380
+ ```javascript
381
+ const client = new BaseClient({
382
+ type: 'rabbitmq',
383
+ host: process.env.RABBITMQ_URL,
384
+ onFatal: (err) => {
385
+ logger.error(err.message, { code: err.code, reason: err.reason, cycles: err.cycles });
386
+ process.exit(1); // the SERVICE decides this, not the library
387
+ }
388
+ });
389
+ ```
390
+
391
+ ### Shutdown — `BaseClient.disconnectAll()`
392
+
393
+ The process signal belongs to the service. This library registers no
394
+ `SIGTERM`/`SIGINT`/`beforeExit` handler and calls `process.exit()` nowhere; what
395
+ it offers instead is one call the service puts in its own sequence, in its own
396
+ order:
397
+
398
+ ```javascript
399
+ const { BaseClient } = require('@onlineapps/mq-client-core');
400
+
401
+ process.on('SIGTERM', async () => { // the SERVICE registers this
402
+ await httpServer.close();
403
+ await BaseClient.disconnectAll(); // every MQ client this process holds
404
+ await redis.quit();
405
+ process.exit(0); // the SERVICE decides this
406
+ });
407
+ ```
408
+
409
+ - it closes every client constructed and not yet disconnected — **only** those:
410
+ a raw amqplib connection the service opened itself is not touched;
411
+ - every client gets its turn even if one fails; it then rejects with a
412
+ `ConnectionError` naming how many of how many failed, `error.cause` carrying
413
+ the first reason;
414
+ - a client that failed to close stays held, so a repeated call reaches it again;
415
+ - with nothing live it resolves and does nothing.
416
+
417
+ **BREAKING (2026-09-14).** Until this release the library installed the signal
418
+ handlers itself, on the first `BaseClient` anyone constructed, and ended them
419
+ with `process.exit(0)`. A service with its own shutdown sequence got a truncated
420
+ one: measured in the registry (`infra/api_services_registry/src/shutdown.js`,
421
+ dev stack 2026-08-31) as `TRACE_END mq t=12` → `TRACE_PROCESS_EXIT code=0 t=16`,
422
+ with four Redis clients, the HTTP server and six timers never handed back. A
423
+ service that relied on the library closing its clients on `SIGTERM` must now
424
+ call `BaseClient.disconnectAll()` from its own handler.
425
+
426
+ **BREAKING (2026-09-14).** A spent attempt budget used to be the end: the client
427
+ set its fatal flag on the first exhausted cycle and refused every operation for
428
+ the life of the process, which is what this section used to document ("the client
429
+ is dead until the process restarts"). A service whose broker was restarted for
430
+ longer than `maxReconnectAttempts × reconnectMaxDelay` therefore needed a process
431
+ restart to come back, even though the broker was healthy again. A caller that
432
+ treated `connection:fatal` as "the first outage that outlasted the budget" must
433
+ now read it as what it says — refused, or every cycle spent — and may see
434
+ `connection:standby` in between.
435
+
436
+ **BREAKING (2026-09-07).** `isConnected()` used to return a flag set once in
437
+ `connect()`, so it answered `true` throughout an outage and kept answering
438
+ `true` after the recovery budget was spent — measured that day as `true` for
439
+ 100+ s against a broker that was down and then back up, with the client
440
+ permanently dead and every healthcheck green. A caller that used
441
+ `isConnected()` as "was connect() ever called" must now read it as "is the
442
+ connection up right now".
443
+
68
444
  ### Queue ownership — publishing never creates an owned queue
69
445
 
70
446
  A missing queue is answered by who owns the name, not by the client's scope:
@@ -73,7 +449,7 @@ A missing queue is answered by who owns the name, not by the client's scope:
73
449
  |---|---|---|
74
450
  | `workflow.*`, `registry.*`, `infrastructure.*`, `validation.*`, `monitoring.*`, `telemetry.*`, `delivery.*` | the infrastructure service that declares it | `QueueNotFoundError` (`kind: 'infrastructure'`) |
75
451
  | `{service}.workflow`, `{service}.queue`, `{service}.dlq` | the owning service, via `setupServiceQueues()` **after** registration | `QueueNotFoundError` (`kind: 'business'`) |
76
- | anything else | nobody | created with default options, if `recoveryScope` allows it |
452
+ | anything else | nobody | `QueueNotFoundError` (`kind: 'unowned'`) nothing is created (d.419) |
77
453
 
78
454
  Publishing must never create an owned queue: `sendToQueue()`/`assertQueue()` would
79
455
  declare it with default arguments — no TTL, no DLQ — and the owner's later
@@ -86,6 +462,137 @@ required: the 404 branch of `_publishOnce()` refuses the publish, and
86
462
  client belongs to a business service. It has never meant "may create business
87
463
  queues".
88
464
 
465
+ **An exchange is named by a publisher, never declared by one.** `publish(queue,
466
+ message, { exchange, routingKey })` sends to the exchange the caller names and
467
+ declares nothing: an exchange's type and durability are topology, they are
468
+ declared once in `config/queueConfig.js`, and the owning service asserts them
469
+ through `assertExchange()`. Until 2026-09-14 the publish path re-declared the
470
+ exchange on every call, with the type from `options.exchangeType || 'direct'` and
471
+ the durability from the PUBLISHING client's config — so two differently
472
+ configured publishers declared one exchange two ways, and a client whose
473
+ `durable` disagreed with the owner's got 406 `PRECONDITION_FAILED` from a publish
474
+ that was itself faultless. Measured against the live broker: `inequivalent arg
475
+ 'type' … received 'direct' but current is 'fanout'`. An exchange nobody declared
476
+ is now a broker-side 404 on the publish, not an exchange invented by whoever
477
+ published first — the same rule as for queues, one object up.
478
+
479
+ ### The publish buffer — one buffer, every priority
480
+
481
+ A publish that fails transiently (the connection is closing, a channel is being
482
+ recreated, the broker did not confirm in time) is held in memory — bounded by
483
+ `publishBufferMaxSize` messages and `publishBufferTtlMs` each — and replayed after
484
+ the reconnect, critical priority first. `getBufferState()` reports it and
485
+ `publish:buffered` / `buffer:flush:failed` announce it.
486
+
487
+ **There is no persistent buffer, and `persistentBufferEnabled` /
488
+ `persistentRedisClient` are refused by name.** They declared one until
489
+ 2026-09-14: `buffer/RedisBuffer.js` was a placeholder whose `add()` stored nothing
490
+ and whose `flush()` returned 0, and the buffer returned after that branch without
491
+ falling back — so switching it on **dropped** exactly the critical-priority
492
+ messages it claimed to protect, while the same message at normal priority was
493
+ safely held. A key that inverts its own promise is worse than no key
494
+ (`automation-gates.md` §5), so the stub, both keys and the branch went out
495
+ together, and writing either name now fails the constructor with a sentence that
496
+ says the mechanism is gone rather than a name to rename it to.
497
+
498
+ ### errors and errorCodes — recognise a state by its code, never by its wording
499
+
500
+ `errors` hands out the error classes; `errorCodes` hands out the machine-readable
501
+ classification a listener decides on. A service listening on the `error` channel gets
502
+ the transport's own connection-close notification there, and it is a STATE — the
503
+ connection dropped while a reconnect is already running — not a failure of whatever
504
+ observed it.
505
+
506
+ ```javascript
507
+ const { errors, errorCodes } = require('@onlineapps/mq-client-core');
508
+
509
+ client.onError((err) => {
510
+ if (err.code === errorCodes.CONNECTION_CLOSED_UNEXPECTEDLY) {
511
+ return; // the client is already reconnecting; onFatal reports the permanent loss
512
+ }
513
+ logger.error(err.message, { code: err.code });
514
+ });
515
+ ```
516
+
517
+ **Compare the code, not the message.** A comparison against the sentence breaks the day
518
+ somebody rewords it, and nothing reports the break: the client's own reconnect wait asked
519
+ for `'Connection closed unexpectedly'` while the transport emitted `'RabbitMQ connection
520
+ closed unexpectedly'`, one capital letter apart, so the branch written to ignore that
521
+ error had never once run (fixed 2026-09-08, `errors.ConnectionError` now carries `code`).
522
+
523
+ `consume()` refuses for two named reasons, and they ask for OPPOSITE actions:
524
+
525
+ | `error.code` | what happened | what fixes it |
526
+ |---|---|---|
527
+ | `CONSUMER_QUEUE_MISSING` | the queue does not exist | start the service that owns the queue — a consumer never creates one |
528
+ | `CONSUMER_DEAD_LETTER_ROUTE_MISSING` | the queue exists, `queueConfig` declares no dead-letter route for it | declare `x-dead-letter-exchange`/`-routing-key` for that queue and bind the destination; the queue itself is fine |
529
+
530
+ Any other failure carries **no** code and its `ConsumeError` says so: the reason is in
531
+ `error.cause`, and this layer does not name the likeliest-sounding one. Until 2026-09-14
532
+ it did — every refusal wore the sentence "Expected: the queue to exist before consume()
533
+ attaches to it", which for a queue with no declared route is false in every clause and
534
+ sent the reader to fix something that was not broken.
535
+
536
+ ### queueConfig — the classification, as a declared export
537
+
538
+ The table above is not a copy: the names, their arguments and the two predicates
539
+ that decide them live in `src/config/queueConfig.js`, and the package exports that
540
+ module.
541
+
542
+ ```javascript
543
+ const { queueConfig } = require('@onlineapps/mq-client-core');
544
+
545
+ queueConfig.isInfrastructureQueue('workflow.init'); // true
546
+ queueConfig.isBusinessQueue('emailer.workflow'); // true
547
+ queueConfig.getInfrastructureQueueConfig('workflow.init'); // { durable, arguments }
548
+ queueConfig.getDeadLetterRoute('workflow.init'); // { exchange: '', routingKey: 'workflow.failed' }
549
+ queueConfig.isDeadLetterTarget('workflow.failed'); // true — the end of that chain
550
+ ```
551
+
552
+ - `workflow.failed` carries no `x-message-ttl`. It is read by an operator, not by a
553
+ service, and it has no dead-letter exchange, so a TTL there does not move a message
554
+ anywhere — it deletes it. See
555
+ `api/docs/governance/confirmations/mq-consumer-contract.md` 002.
556
+ - `isDeadLetterTarget()` answers "does this configuration dead-letter TO this name",
557
+ derived from the declared routing keys rather than from a list. It is what lets
558
+ `consume()` attach to `workflow.failed` and to any `<service>.dlq` while every other
559
+ queue with no declared route stays refused — see § `consume()` — the delivery contract.
560
+
561
+ **That require is the contract.** Reaching into
562
+ `@onlineapps/mq-client-core/src/config/queueConfig` is an internal path the package
563
+ never promised — it happens to resolve today, and it breaks silently the day the
564
+ file moves. Callers holding the deep path switch to the named export; callers that
565
+ receive `queueConfig` by injection (`initInfrastructureQueues` in
566
+ `@onlineapps/infrastructure-tools`) pass it from here.
567
+
568
+ ### redactUrl — a connection URL that is safe to log
569
+
570
+ `RABBITMQ_URL` carries the broker account, so anything that renders it verbatim
571
+ puts that account in the service log and, through the monitoring consumer, in
572
+ Loki. `redactUrl` removes the WHOLE userinfo — the account name is the other half
573
+ of the credential, and the reader of a log line came for the host and the port.
574
+
575
+ ```javascript
576
+ const { redactUrl, UNPARSEABLE_PLACEHOLDER } = require('@onlineapps/mq-client-core');
577
+
578
+ redactUrl('amqp://oa_dev:secret@queuer:5672/oa_vhost'); // 'amqp://queuer:5672/oa_vhost'
579
+ redactUrl('amqp://queuer:5672'); // unchanged — nothing to remove
580
+ redactUrl('queuer:5672'); // '<unparseable-url>'
581
+ redactUrl(undefined); // '<unparseable-url>'
582
+ ```
583
+
584
+ - A value that cannot be read as a URL is answered with `UNPARSEABLE_PLACEHOLDER`,
585
+ never echoed: that is precisely the case where nobody can say whether the string
586
+ holds a credential.
587
+ - It never throws — its callers are log lines and error messages, where a throw
588
+ replaces the report with a second incident. The client's own connection target
589
+ goes through `redactConnectionTarget()` instead, which refuses a non-string
590
+ outright, because `host` is a declared config key and a wrong type there is a
591
+ boot-time defect.
592
+ - It is the SAME function this package uses internally, and the same semantics as
593
+ `shared/service-common/src/redactUrl.js`. A dependant that renders a broker URL
594
+ imports it from here rather than keeping a copy.
595
+
89
596
  ## Architecture
90
597
 
91
598
  ```
package/package.json CHANGED
@@ -1,12 +1,17 @@
1
1
  {
2
2
  "name": "@onlineapps/mq-client-core",
3
- "version": "2.0.1",
3
+ "version": "3.0.1",
4
4
  "description": "Core MQ client library for RabbitMQ - shared by infrastructure services and connectors",
5
+ "oa": {
6
+ "category": "connector"
7
+ },
8
+ "engines": {
9
+ "node": ">=24.0.0 <25"
10
+ },
5
11
  "main": "src/index.js",
6
12
  "scripts": {
7
- "test": "jest",
8
- "test:unit": "jest --testPathPattern=tests/unit",
9
- "test:component": "jest --testPathPattern=tests/component",
13
+ "test": "npm run test:unit && npm run test:integration",
14
+ "test:unit": "jest tests/unit",
10
15
  "test:integration": "jest --config=jest.integration.config.js"
11
16
  },
12
17
  "keywords": [
@@ -18,11 +23,10 @@
18
23
  "author": "OnlineApps",
19
24
  "license": "MIT",
20
25
  "dependencies": {
21
- "@onlineapps/infra-logger": "2.0.0",
22
- "@onlineapps/runtime-config": "1.0.3",
26
+ "@onlineapps/logger-contract": "1.1.0",
27
+ "@onlineapps/runtime-config": "1.1.0",
23
28
  "ajv": "^8.12.0",
24
- "amqplib": "^0.10.3",
25
- "lodash.merge": "^4.6.2"
29
+ "amqplib": "^0.10.3"
26
30
  },
27
31
  "devDependencies": {
28
32
  "jest": "^29.7.0"