@onlineapps/mq-client-core 2.0.1-rc.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/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,299 @@ 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
+ ```javascript
220
+ await client.consume(queue, async (msg, delivery) => {
221
+ // delivery = { attempt, maxAttempts, isFinalAttempt }
222
+ await handle(msg);
223
+ }, {
224
+ maxAttempts: 3, // optional; module default otherwise
225
+ classify: (error) => error.name === 'ValidationError' ? 'permanent' : 'transient'
226
+ });
227
+ ```
228
+
229
+ | the handler | what happens to the message |
230
+ |---|---|
231
+ | returns | acked, once |
232
+ | throws, error `transient`, attempts left | a copy carrying the advanced counter is published to the SAME queue, then the original is acked |
233
+ | throws, error `transient`, `maxAttempts` reached | `nack(requeue=false)` → the broker dead-letters it into `<service>.dlq`, and a `message_dlq` event is published |
234
+ | throws, error `permanent` | the same rejection, on the FIRST failure |
235
+ | acks or nacks the message itself | untouched — the transport's `_mqProcessed` sentinel still wins |
236
+
237
+ **`maxAttempts`** is how many times the handler may run for ONE message, not how many
238
+ retries follow the first run; `maxAttempts: 1` means "no second attempt". Resolution is
239
+ explicit → `RABBITMQ_MAX_DELIVERY_ATTEMPTS` → `defaults.js maxDeliveryAttempts` (3, the
240
+ value `@onlineapps/error-handler-core` already declares for the same decision). An
241
+ explicit value is taken exactly as written, so `'3'` and `2.5` are refused at `consume()`
242
+ rather than coerced; only the environment form is parsed as a number.
243
+
244
+ **`classify`** is optional. Without it every error is treated as transient — stated, not
245
+ implied: a permanently failing message still reaches `<service>.dlq`, after `maxAttempts`
246
+ attempts instead of after one. A classifier that throws, or answers with anything but
247
+ `'transient'` / `'permanent'`, is reported through the logger and its message is rejected
248
+ into the dead-letter queue: the library never guesses a classification.
249
+
250
+ **Why an attempt costs a re-publish and not a requeue.** Measured on the live broker
251
+ (2026-09-11): after `nack(requeue=true)` the redelivery carries `headers: {}` — the
252
+ message returns byte-for-byte, and `fields.redelivered` is a boolean, not a count. A
253
+ requeue is not a dead-letter, so the broker stamps no `x-death` to count from either.
254
+ The counter therefore has to travel in a header this library writes, and only a NEW
255
+ message can carry an advanced one. The copy is published first and the original acked
256
+ second, so a failed publish can duplicate a message but never lose one; when the publish
257
+ fails the original is requeued and the attempt is repeated rather than spent.
258
+
259
+ ```javascript
260
+ const { deliveryPolicy } = require('@onlineapps/mq-client-core');
261
+ deliveryPolicy.ATTEMPTS_HEADER; // 'x-oa-delivery-attempts' — read it, never retype it
262
+ ```
263
+
264
+ **The `message_dlq` event** goes to `monitoring.workflow` and carries `status: 'rejected'`
265
+ — the broker's own word for this death, what it stamps into `x-death[0].reason`. It is
266
+ published **only when the envelope carries a `workflow_id`**. Without one nothing is
267
+ published and nothing is invented: the absence is reported through the injected logger
268
+ with the queue and the service name, and the message stays readable in the dead-letter
269
+ queue through the DLQ dashboard. `monitoring.workflow` keys every trace on that id, so an
270
+ event naming no workflow is either refused as faulty telemetry or — with a marker like
271
+ `'unknown'` in its place — written as a row claiming a workflow by that name.
272
+
273
+ **`x-message-ttl` and the budget do not cooperate.** The service queues carry a TTL
274
+ (`<service>.queue` 30 s, `<service>.workflow` 5 min) and each attempt re-publishes the
275
+ message, which restarts that clock. So the TTL bounds ONE attempt, never the whole budget,
276
+ and `maxAttempts` is the only bound on the total. A message that does expire mid-budget is
277
+ dead-lettered by the broker to the same `<service>.dlq` with `x-death[0].reason: 'expired'`
278
+ — and no `message_dlq` event, because the client is not involved in that move. Both
279
+ reasons are visible on the dead message itself; the DLQ dashboard reads them
280
+ (`infra/api_monitoring/src/consumer/deadLetterPeek.js`).
281
+
282
+ #### `requeueOnError` — the same policy, said in one word
283
+
284
+ `consume(queue, handler, { requeueOnError })` is folded ONTO the budget above,
285
+ never placed beside it:
286
+
287
+ | value | meaning |
288
+ |---|---|
289
+ | `false` | the budget is **one** attempt — the first failure rejects into `<svc>.dlq`, exactly as a `permanent` classification does |
290
+ | `true` or absent | the budget is whatever configuration says (`maxAttempts` → `RABBITMQ_MAX_DELIVERY_ATTEMPTS` → `defaults.js`) |
291
+
292
+ There is no third meaning. `requeueOnError: false` together with `maxAttempts`
293
+ above 1 is a contradiction and is **refused where it was written**, rather than
294
+ one of the two being silently ignored.
295
+
296
+ ### Connection loss — the contract
297
+
298
+ When the broker goes away, the transport runs connection-level recovery with
299
+ exponential backoff, up to `maxReconnectAttempts` times. That whole budget is one
300
+ **cycle**. Three outcomes, no fourth:
301
+
302
+ | Outcome | What the client does |
303
+ |---|---|
304
+ | a reconnect succeeds | channels are recreated, consumers re-registered, buffered messages flushed, `reconnected` is emitted; the attempt and cycle counters reset to 0 |
305
+ | 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 |
306
+ | the broker REFUSED, or every cycle is spent | the connection is declared **permanently lost** |
307
+
308
+ **While a recovery is running, `isConnected()` is `false` — including the part of it
309
+ where the socket is already back.** "Connected" means the client can be used, and a
310
+ re-established connection whose channels are still being recreated cannot publish or
311
+ consume. The two answers therefore agree at every instant: the moment `isConnected()`
312
+ turns `true`, `getChannelState()` reports all three channels ready.
313
+
314
+ **Standing down is not dying, and the difference is the point.** An outage the
315
+ broker will come back from must not turn a client into a corpse
316
+ (`docs/governance/confirmations/mq-client-lifecycle-contract.md` 001 point 3 —
317
+ "give up forever" is forbidden). So a spent cycle means:
318
+
319
+ - `connection:standby` is emitted with `{ cycle, cyclesMax, attempts, lastError,
320
+ timestamp }`, and the client holds no timer and no socket;
321
+ - `isConnected()` is `false`, `isConnectionFatal()` is `false`;
322
+ - **nothing polls.** The retry is lazy: the next `publish()`, `consume()` or
323
+ `performHealthCheck()` starts the next cycle, and a client nobody uses costs
324
+ nothing while the broker is away;
325
+ - a cycle that succeeds resets the counter, so the next outage starts again at
326
+ cycle 1.
327
+
328
+ `maxReconnectCycles` bounds how many cycles this client will ever run (explicit
329
+ config → `RABBITMQ_MAX_RECONNECT_CYCLES` → module default). It is what keeps the
330
+ lazy retry finite: when the last cycle is spent, the connection is permanently
331
+ lost.
332
+
333
+ **A refusal is not an outage.** If the broker ANSWERED and said no — 403
334
+ `ACCESS-REFUSED`, 530 `NOT-ALLOWED`, 406 `PRECONDITION-FAILED` — retrying cannot
335
+ change the answer, so not one further attempt is spent on it and the connection is
336
+ permanently lost immediately. (One refusal this client cannot recognise: amqplib
337
+ renders a wrong vhost as `Expected ConnectionOpenOk; got <ConnectionClose
338
+ channel:0>`, with the broker's 530 dropped before the error is built. That one is
339
+ treated as an outage and ends at the cycle cap instead.)
340
+
341
+ Permanently lost means all of this, together:
342
+
343
+ - `connection:fatal` is emitted on the transport, and the injected `onFatal`
344
+ callback is called — once — with an `Error` carrying
345
+ `code: 'MQ_CONNECTION_FATAL'`, `reason: 'broker-refused' | 'cycles-spent'`,
346
+ `attempts: <budget per cycle>` and `cycles: <cycles spent>`;
347
+ - `isConnected()` returns `false` — on the transport, on `BaseClient`, and
348
+ therefore in `conn-infra-mq`'s `getHealth().connected` and in any healthcheck
349
+ built on them;
350
+ - no further reconnect attempts are made, by a use or otherwise — the client is
351
+ dead until the process restarts, or until an explicit `connect()`;
352
+ - `publish()`/`consume()` refuse immediately instead of blocking on a recovery
353
+ that is not coming.
354
+
355
+ **The library never ends the process, and never listens for a signal.** Ending
356
+ it is the lifecycle owner's decision: a service typically logs and exits
357
+ non-zero from `onFatal`, so its restart policy boots it again and boot
358
+ re-verifies every dependency. What the library owes the owner is the truth,
359
+ once, on a channel that cannot be confused with an ordinary transient error —
360
+ and one call to close what it holds, `BaseClient.disconnectAll()`.
361
+
362
+ ```javascript
363
+ const client = new BaseClient({
364
+ type: 'rabbitmq',
365
+ host: process.env.RABBITMQ_URL,
366
+ onFatal: (err) => {
367
+ logger.error(err.message, { code: err.code, reason: err.reason, cycles: err.cycles });
368
+ process.exit(1); // the SERVICE decides this, not the library
369
+ }
370
+ });
371
+ ```
372
+
373
+ ### Shutdown — `BaseClient.disconnectAll()`
374
+
375
+ The process signal belongs to the service. This library registers no
376
+ `SIGTERM`/`SIGINT`/`beforeExit` handler and calls `process.exit()` nowhere; what
377
+ it offers instead is one call the service puts in its own sequence, in its own
378
+ order:
379
+
380
+ ```javascript
381
+ const { BaseClient } = require('@onlineapps/mq-client-core');
382
+
383
+ process.on('SIGTERM', async () => { // the SERVICE registers this
384
+ await httpServer.close();
385
+ await BaseClient.disconnectAll(); // every MQ client this process holds
386
+ await redis.quit();
387
+ process.exit(0); // the SERVICE decides this
388
+ });
389
+ ```
390
+
391
+ - it closes every client constructed and not yet disconnected — **only** those:
392
+ a raw amqplib connection the service opened itself is not touched;
393
+ - every client gets its turn even if one fails; it then rejects with a
394
+ `ConnectionError` naming how many of how many failed, `error.cause` carrying
395
+ the first reason;
396
+ - a client that failed to close stays held, so a repeated call reaches it again;
397
+ - with nothing live it resolves and does nothing.
398
+
399
+ **BREAKING (2026-09-14).** Until this release the library installed the signal
400
+ handlers itself, on the first `BaseClient` anyone constructed, and ended them
401
+ with `process.exit(0)`. A service with its own shutdown sequence got a truncated
402
+ one: measured in the registry (`infra/api_services_registry/src/shutdown.js`,
403
+ dev stack 2026-08-31) as `TRACE_END mq t=12` → `TRACE_PROCESS_EXIT code=0 t=16`,
404
+ with four Redis clients, the HTTP server and six timers never handed back. A
405
+ service that relied on the library closing its clients on `SIGTERM` must now
406
+ call `BaseClient.disconnectAll()` from its own handler.
407
+
408
+ **BREAKING (2026-09-14).** A spent attempt budget used to be the end: the client
409
+ set its fatal flag on the first exhausted cycle and refused every operation for
410
+ the life of the process, which is what this section used to document ("the client
411
+ is dead until the process restarts"). A service whose broker was restarted for
412
+ longer than `maxReconnectAttempts × reconnectMaxDelay` therefore needed a process
413
+ restart to come back, even though the broker was healthy again. A caller that
414
+ treated `connection:fatal` as "the first outage that outlasted the budget" must
415
+ now read it as what it says — refused, or every cycle spent — and may see
416
+ `connection:standby` in between.
417
+
418
+ **BREAKING (2026-09-07).** `isConnected()` used to return a flag set once in
419
+ `connect()`, so it answered `true` throughout an outage and kept answering
420
+ `true` after the recovery budget was spent — measured that day as `true` for
421
+ 100+ s against a broker that was down and then back up, with the client
422
+ permanently dead and every healthcheck green. A caller that used
423
+ `isConnected()` as "was connect() ever called" must now read it as "is the
424
+ connection up right now".
425
+
68
426
  ### Queue ownership — publishing never creates an owned queue
69
427
 
70
428
  A missing queue is answered by who owns the name, not by the client's scope:
@@ -73,7 +431,7 @@ A missing queue is answered by who owns the name, not by the client's scope:
73
431
  |---|---|---|
74
432
  | `workflow.*`, `registry.*`, `infrastructure.*`, `validation.*`, `monitoring.*`, `telemetry.*`, `delivery.*` | the infrastructure service that declares it | `QueueNotFoundError` (`kind: 'infrastructure'`) |
75
433
  | `{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 |
434
+ | anything else | nobody | `QueueNotFoundError` (`kind: 'unowned'`) nothing is created (d.419) |
77
435
 
78
436
  Publishing must never create an owned queue: `sendToQueue()`/`assertQueue()` would
79
437
  declare it with default arguments — no TTL, no DLQ — and the owner's later
@@ -86,6 +444,131 @@ required: the 404 branch of `_publishOnce()` refuses the publish, and
86
444
  client belongs to a business service. It has never meant "may create business
87
445
  queues".
88
446
 
447
+ **An exchange is named by a publisher, never declared by one.** `publish(queue,
448
+ message, { exchange, routingKey })` sends to the exchange the caller names and
449
+ declares nothing: an exchange's type and durability are topology, they are
450
+ declared once in `config/queueConfig.js`, and the owning service asserts them
451
+ through `assertExchange()`. Until 2026-09-14 the publish path re-declared the
452
+ exchange on every call, with the type from `options.exchangeType || 'direct'` and
453
+ the durability from the PUBLISHING client's config — so two differently
454
+ configured publishers declared one exchange two ways, and a client whose
455
+ `durable` disagreed with the owner's got 406 `PRECONDITION_FAILED` from a publish
456
+ that was itself faultless. Measured against the live broker: `inequivalent arg
457
+ 'type' … received 'direct' but current is 'fanout'`. An exchange nobody declared
458
+ is now a broker-side 404 on the publish, not an exchange invented by whoever
459
+ published first — the same rule as for queues, one object up.
460
+
461
+ ### The publish buffer — one buffer, every priority
462
+
463
+ A publish that fails transiently (the connection is closing, a channel is being
464
+ recreated, the broker did not confirm in time) is held in memory — bounded by
465
+ `publishBufferMaxSize` messages and `publishBufferTtlMs` each — and replayed after
466
+ the reconnect, critical priority first. `getBufferState()` reports it and
467
+ `publish:buffered` / `buffer:flush:failed` announce it.
468
+
469
+ **There is no persistent buffer, and `persistentBufferEnabled` /
470
+ `persistentRedisClient` are refused by name.** They declared one until
471
+ 2026-09-14: `buffer/RedisBuffer.js` was a placeholder whose `add()` stored nothing
472
+ and whose `flush()` returned 0, and the buffer returned after that branch without
473
+ falling back — so switching it on **dropped** exactly the critical-priority
474
+ messages it claimed to protect, while the same message at normal priority was
475
+ safely held. A key that inverts its own promise is worse than no key
476
+ (`automation-gates.md` §5), so the stub, both keys and the branch went out
477
+ together, and writing either name now fails the constructor with a sentence that
478
+ says the mechanism is gone rather than a name to rename it to.
479
+
480
+ ### errors and errorCodes — recognise a state by its code, never by its wording
481
+
482
+ `errors` hands out the error classes; `errorCodes` hands out the machine-readable
483
+ classification a listener decides on. A service listening on the `error` channel gets
484
+ the transport's own connection-close notification there, and it is a STATE — the
485
+ connection dropped while a reconnect is already running — not a failure of whatever
486
+ observed it.
487
+
488
+ ```javascript
489
+ const { errors, errorCodes } = require('@onlineapps/mq-client-core');
490
+
491
+ client.onError((err) => {
492
+ if (err.code === errorCodes.CONNECTION_CLOSED_UNEXPECTEDLY) {
493
+ return; // the client is already reconnecting; onFatal reports the permanent loss
494
+ }
495
+ logger.error(err.message, { code: err.code });
496
+ });
497
+ ```
498
+
499
+ **Compare the code, not the message.** A comparison against the sentence breaks the day
500
+ somebody rewords it, and nothing reports the break: the client's own reconnect wait asked
501
+ for `'Connection closed unexpectedly'` while the transport emitted `'RabbitMQ connection
502
+ closed unexpectedly'`, one capital letter apart, so the branch written to ignore that
503
+ error had never once run (fixed 2026-09-08, `errors.ConnectionError` now carries `code`).
504
+
505
+ `consume()` refuses for two named reasons, and they ask for OPPOSITE actions:
506
+
507
+ | `error.code` | what happened | what fixes it |
508
+ |---|---|---|
509
+ | `CONSUMER_QUEUE_MISSING` | the queue does not exist | start the service that owns the queue — a consumer never creates one |
510
+ | `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 |
511
+
512
+ Any other failure carries **no** code and its `ConsumeError` says so: the reason is in
513
+ `error.cause`, and this layer does not name the likeliest-sounding one. Until 2026-09-14
514
+ it did — every refusal wore the sentence "Expected: the queue to exist before consume()
515
+ attaches to it", which for a queue with no declared route is false in every clause and
516
+ sent the reader to fix something that was not broken.
517
+
518
+ ### queueConfig — the classification, as a declared export
519
+
520
+ The table above is not a copy: the names, their arguments and the two predicates
521
+ that decide them live in `src/config/queueConfig.js`, and the package exports that
522
+ module.
523
+
524
+ ```javascript
525
+ const { queueConfig } = require('@onlineapps/mq-client-core');
526
+
527
+ queueConfig.isInfrastructureQueue('workflow.init'); // true
528
+ queueConfig.isBusinessQueue('emailer.workflow'); // true
529
+ queueConfig.getInfrastructureQueueConfig('workflow.init'); // { durable, arguments }
530
+ ```
531
+
532
+ - `workflow.failed` carries no `x-message-ttl`. It is read by an operator, not by a
533
+ service, and it has no dead-letter exchange, so a TTL there does not move a message
534
+ anywhere — it deletes it. See
535
+ `api/docs/governance/confirmations/mq-consumer-contract.md` 002.
536
+
537
+ **That require is the contract.** Reaching into
538
+ `@onlineapps/mq-client-core/src/config/queueConfig` is an internal path the package
539
+ never promised — it happens to resolve today, and it breaks silently the day the
540
+ file moves. Callers holding the deep path switch to the named export; callers that
541
+ receive `queueConfig` by injection (`initInfrastructureQueues` in
542
+ `@onlineapps/infrastructure-tools`) pass it from here.
543
+
544
+ ### redactUrl — a connection URL that is safe to log
545
+
546
+ `RABBITMQ_URL` carries the broker account, so anything that renders it verbatim
547
+ puts that account in the service log and, through the monitoring consumer, in
548
+ Loki. `redactUrl` removes the WHOLE userinfo — the account name is the other half
549
+ of the credential, and the reader of a log line came for the host and the port.
550
+
551
+ ```javascript
552
+ const { redactUrl, UNPARSEABLE_PLACEHOLDER } = require('@onlineapps/mq-client-core');
553
+
554
+ redactUrl('amqp://oa_dev:secret@queuer:5672/oa_vhost'); // 'amqp://queuer:5672/oa_vhost'
555
+ redactUrl('amqp://queuer:5672'); // unchanged — nothing to remove
556
+ redactUrl('queuer:5672'); // '<unparseable-url>'
557
+ redactUrl(undefined); // '<unparseable-url>'
558
+ ```
559
+
560
+ - A value that cannot be read as a URL is answered with `UNPARSEABLE_PLACEHOLDER`,
561
+ never echoed: that is precisely the case where nobody can say whether the string
562
+ holds a credential.
563
+ - It never throws — its callers are log lines and error messages, where a throw
564
+ replaces the report with a second incident. The client's own connection target
565
+ goes through `redactConnectionTarget()` instead, which refuses a non-string
566
+ outright, because `host` is a declared config key and a wrong type there is a
567
+ boot-time defect.
568
+ - It is the SAME function this package uses internally, and the same semantics as
569
+ `shared/service-common/src/redactUrl.js`. A dependant that renders a broker URL
570
+ imports it from here rather than keeping a copy.
571
+
89
572
  ## Architecture
90
573
 
91
574
  ```
package/package.json CHANGED
@@ -1,12 +1,17 @@
1
1
  {
2
2
  "name": "@onlineapps/mq-client-core",
3
- "version": "2.0.1-rc.1",
3
+ "version": "3.0.0",
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"