@onlineapps/mq-client-core 5.0.0 → 6.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
@@ -6,7 +6,7 @@ Uniform: [library/connector](../connector/conn-orch-validator/manifests/library.
6
6
 
7
7
  Duty sections that apply:
8
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
9
+ - `all`: L-MAIN, L-ENGINES, L-TESTS, L-TEST-SCRIPT, L-PACK-TESTS, L-PINS, L-NO-FILE-RANGE, L-CHANGELOG, L-PACK-CHANGELOG, L-README, L-README-REGION, L-CONSUMER
10
10
  - `connector`: L-CONNECTOR-ENV
11
11
  <!-- END GENERATED: library-uniform -->
12
12
 
@@ -86,11 +86,18 @@ says" — and every connection on the platform ran on the broker's number rather
86
86
  the configured one (measured on the dev broker: 39 live connections, `timeout: 60`
87
87
  on all of them).
88
88
 
89
- `brokerAnswerTimeout` bounds **both** things this client waits for the broker to
90
- answer: the confirm of one publish attempt, and the `close-ok` of each handle
91
- `disconnect()` closes. It was called `publishConfirmationTimeout` until
92
- 2026-09-14 — the name of one of its two consumers — and writing that name now is
93
- refused, with the new one named.
89
+ `brokerAnswerTimeout` bounds **every** wait on the broker's answer — the confirm of
90
+ one publish attempt, the `close-ok` of each handle `disconnect()` closes, and every
91
+ request a channel sends and waits on: `assertQueue`, `checkQueue` (also the check before a
92
+ queue publish), `bindQueue`, `assertExchange`, `purgeQueue`, `deleteQueue`, `consume`,
93
+ `cancelConsumer`, the prefetch, and every channel open (conf `mq-broker-answer-budget` 001).
94
+ A request left unanswered for that long discards the channel that carried it (the next
95
+ operation opens a fresh one) and ends in `ConnectionError` naming the operation, its target
96
+ and the budget; on the publish path it is `TransientPublishError`, so retry, the buffer and
97
+ `bufferOnFailure: false` apply as to any transient failure. This is what ends the wait on a
98
+ broker in a memory alarm, which keeps the connection and its heartbeats alive while it
99
+ answers nothing. It was called `publishConfirmationTimeout` until 2026-09-14 — the name of
100
+ one of its consumers — and writing that name now is refused, with the new one named.
94
101
 
95
102
  `maxReconnectAttempts` and `maxReconnectCycles` resolve the usual way — explicit
96
103
  config → env (`RABBITMQ_MAX_RECONNECT_ATTEMPTS`, `RABBITMQ_MAX_RECONNECT_CYCLES`)
@@ -100,6 +107,14 @@ config → env (`RABBITMQ_MAX_RECONNECT_ATTEMPTS`, `RABBITMQ_MAX_RECONNECT_CYCLE
100
107
  attempts inside ONE recovery cycle, the second how many cycles there may be — see
101
108
  § Connection loss.
102
109
 
110
+ **`criticalHealthShutdown` and `criticalHealthShutdownDelay` are refused by name** (removed
111
+ 2026-10-06, d.1275, on the owner's word). They made the transport, after a minute
112
+ of critical health, emit `health:shutdown` and log that it was "triggering shutdown" — but this
113
+ library never ends the process (see § Connection loss), and nothing on the platform listened
114
+ for the event, so the line promised an action that never came. The event went with the keys.
115
+ Critical health is reported — `health:critical` and `healthCriticalCallback` on every failing
116
+ check — and what to do about the process is the lifecycle owner's decision (`onFatal`).
117
+
103
118
  ### A subclass declares its own keys
104
119
 
105
120
  `new BaseClient(config, extraProperties?)`. The second argument is the JSON Schema
@@ -134,7 +149,10 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
134
149
 
135
150
  - `connect(options?)` - Connect to RabbitMQ
136
151
  - `disconnect()` - Disconnect from RabbitMQ
137
- - `publish(queue, message, options?)` - Publish message to queue
152
+ - `publish(queue, message, options?)` - Publish message to queue. `options.bufferOnFailure`
153
+ (boolean, default `true`): `false` leaves the publish buffer for this one call — a
154
+ transiently failed message is NOT held and no reconnect replays it; the thrown
155
+ `PublishError` then carries `buffered: false` (see § The publish buffer).
138
156
  - `publishTelemetry(queue, message, options?)` - Publish the client's OWN event (the
139
157
  monitoring helpers below use it) on a confirm channel of its own, opened with the
140
158
  first such publish. A refusal there closes that channel only, never the one a
@@ -162,7 +180,17 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
162
180
  healthy** — `healthy: false`, with an issue naming how many it tracks — because
163
181
  such a client takes no message off any queue. A client that registered no
164
182
  consumer at all is a publisher and stays healthy; the check's own log line says
165
- which of the two it is.
183
+ which of the two it is. `consumers.active` / `consumers.inactive` are this
184
+ client's own facts — a consumer is attached while the consumer channel is alive
185
+ and its entry holds the broker's tag (the definition `getConsumerState()` reads)
186
+ — so a dead QUEUE channel or an unanswered queue probe is reported as what it is,
187
+ a channel or probe issue, never as "no consumer" (d.1275). The probe adds what
188
+ only the broker knows: `queues.consumers` maps each tracked queue to the broker's
189
+ `consumerCount` (every consumer on the queue, not only this client's), or to
190
+ `'unknown'` where the probe did not run or got no answer — never `0` for "not
191
+ asked". A failing check emits `health:critical` and calls
192
+ `healthCriticalCallback`; it decides nothing about the process (there is no
193
+ `health:shutdown`, see § Configuration).
166
194
  - `assertQueue(queue, options?)` / `checkQueue(queue)` / `purgeQueue(queue)` /
167
195
  `deleteQueue(queue, options?)` - Queue operations, on the queue channel.
168
196
  `assertQueue()` reads exactly four options — `durable`, `arguments`,
@@ -448,8 +476,21 @@ Permanently lost means all of this, together:
448
476
  built on them;
449
477
  - no further reconnect attempts are made, by a use or otherwise — the client is
450
478
  dead until the process restarts, or until an explicit `connect()`;
451
- - `publish()`/`consume()` refuse immediately instead of blocking on a recovery
452
- that is not coming.
479
+ - `publish()`/`consume()` — and every other operation on the broker:
480
+ `assertQueue`, `checkQueue`, `purgeQueue`, `deleteQueue`, `assertExchange`,
481
+ `bindQueue`, `cancelConsumer`, `ack`, `nack` — refuse immediately instead of
482
+ blocking on a recovery that is not coming, with the verdict on the error
483
+ (`code: 'MQ_CONNECTION_FATAL'`, `reason`, the verdict as `cause`); through
484
+ `BaseClient` too, which after the verdict lets the lost client answer for
485
+ itself instead of advising `connect()`. The state readers (`isConnected()`,
486
+ `isConnectionFatal()`, `getConsumerState()`, `getChannelState()`,
487
+ `performHealthCheck()`, the metrics) do not throw: they describe the state. A `consume()`
488
+ that was waiting when the verdict fell, or that is asked of the lost client
489
+ afterwards, is refused with that verdict readable on the error (`code`,
490
+ `reason`) and the verdict itself in `cause`. A `publish()` in the same position
491
+ is refused with a `PublishError` that carries the same `code` and `reason` and
492
+ `buffered: false`: nothing is held, because no `reconnected` will ever come to
493
+ replay it (§ *The publish buffer*) — the caller holds the only copy.
453
494
 
454
495
  **The library never ends the process, and never listens for a signal.** Ending
455
496
  it is the lifecycle owner's decision: a service typically logs and exits
@@ -570,8 +611,65 @@ buffered, because no retry and no reconnect declares the exchange (d.693).
570
611
  A publish that fails transiently (the connection is closing, a channel is being
571
612
  recreated, the broker did not confirm in time) is held in memory — bounded by
572
613
  `publishBufferMaxSize` messages and `publishBufferTtlMs` each — and replayed after
573
- the reconnect, critical priority first. `getBufferState()` reports it and
574
- `publish:buffered` / `buffer:flush:failed` announce it.
614
+ the reconnect or after the broker unblocks the connection (below), critical priority first. `getBufferState()` reports it and
615
+ `publish:buffered` / `buffer:flush:failed` announce it. That includes a publish whose
616
+ confirm was still pending when the channel or the connection died under it (the broker
617
+ closed it, restarted or stopped): the broker said nothing about that message, so it is
618
+ transient, never a refusal — only a `nack` or a numbered close frame naming this send
619
+ is permanent (d.1134).
620
+
621
+ **One call can leave the buffer: `publish(queue, message, { bufferOnFailure: false })`.**
622
+ It is for a caller that keeps the message durably elsewhere and must not have a second
623
+ copy replayed behind its back — the orchestrator writing a `publish_pending` record
624
+ (`docs/governance/confirmations/step-publish-failure.md` 001). On each of the four
625
+ transient paths (retries exhausted, `publishRetryEnabled: false`, the reconnect a retry
626
+ waited for failed, and a send channel the attempt could not get because the connection stayed
627
+ down — the recovery it waited for ended in standby or fatal, or none may start: the client is
628
+ permanently lost, closed by `disconnect()`, or has `reconnectEnabled: false`; B1, the owner's
629
+ decision of 2026-10-06) the error `publish()` throws carries `buffered` (boolean): `true` only
630
+ when the buffer holds the message for the next reconnect or `unblocked`, `false` when
631
+ nothing will replay it. A permanent refusal never reaches the buffer and carries no `buffered`. A
632
+ value that is not a boolean is refused before any attempt (`ValidationError`). Retry
633
+ stays the library's either way — the option decides only what happens after it.
634
+
635
+ The fourth path is its own class on the transport, `RecoveryEndedPublishError` (a
636
+ `TransientPublishError`, exported under `publishErrors`): `PublishLayer` holds the message at
637
+ once instead of retrying, because the next use of the client is what starts the next recovery
638
+ cycle. It holds it **only where a `reconnected` can still come** — the class says so in
639
+ `replayable`: after a spent cycle (standby) the message is held; once the client is permanently
640
+ lost, closed by `disconnect()`, or has `reconnectEnabled: false`, nothing will ever replay it, so
641
+ nothing is held and the error says `buffered: false` with the reason. When the recovery ended
642
+ fatally the `PublishError` carries the verdict on top — `code: 'MQ_CONNECTION_FATAL'`, `reason` —
643
+ and the verdict itself at the end of `cause`; a spent cycle carries no code.
644
+
645
+ **Every failed publish says whether its frame left the client: `error.sent`** (boolean, on
646
+ the `PublishError` from `publish()` and on its cause; conf
647
+ `docs/governance/confirmations/mq-publish-buffer.md` 001). `true` when any attempt wrote the
648
+ frame to the channel — the broker may hold it, and after a memory alarm deliver it, although
649
+ the call failed; `false` when no attempt got that far. It says nothing about whether the
650
+ broker stored the message. The buffer entry records the same fact, and it decides what
651
+ happens when the outage ends:
652
+
653
+ | The outage ends with | A held message whose frame was never sent | … whose frame was sent |
654
+ |---|---|---|
655
+ | a reconnect (`reconnected`) | re-sent | re-sent — the old socket and what the broker had not read are gone |
656
+ | the broker unblocking the connection (`connection.unblocked`, end of a memory or disk alarm) | re-sent | dropped with a `warn` — the broker delivers the frames it held by itself |
657
+
658
+ `connection.blocked` is logged as a `warn` with the broker's reason. **Nothing else flushes
659
+ the buffer**: an outage of the publisher CHANNEL alone, on a connection that stays up and is
660
+ not blocked, ends with neither event, so a message held during it is not replayed when the
661
+ next publish recreates the channel. Whether a channel recovery should flush too is an open
662
+ decision (a sent entry re-sent then may be a second copy), and the error `publish()` throws
663
+ says the gap in as many words: buffered for up to `publishBufferTtlMs`, replayed only after a
664
+ reconnect or `unblocked`, reported as LOST at expiry.
665
+
666
+ A held message that outlives `publishBufferTtlMs` is dropped **at its TTL, on the buffer's own
667
+ timer** — not whenever the buffer is next touched (until d.1275 the expiry ran only inside the
668
+ next `add()`/`flush()`/`getBufferState()`, and was measured on the dev scheduler up to 88
669
+ minutes late; d.1275). The timer runs on the client's timer rail, so `disconnect()` ends it. The
670
+ drop is logged as an `error` naming its queue or exchange, the count, the age and `sent` —
671
+ `sent=false` reads `LOST after <ms>` and is a message lost for good; `sent=true` reads `expired
672
+ after <ms>`, because the broker may have delivered it on its own.
575
673
 
576
674
  **There is no persistent buffer, and `persistentBufferEnabled` /
577
675
  `persistentRedisClient` are refused by name.** They declared one until
@@ -616,11 +714,15 @@ error had never once run (fixed 2026-09-08, `errors.ConnectionError` now carries
616
714
  | `CONSUMER_QUEUE_MISSING` | the queue does not exist | start the service that owns the queue — a consumer never creates one |
617
715
  | `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 |
618
716
  | `MQ_CONNECTION_FATAL` (with `reason: 'broker-refused'`) | the BROKER refused this queue — 406 PRECONDITION-FAILED for a queue that exists with other arguments, 403/530 for one this account may not use | correct the topology or the permission the broker named in `error.cause`, then restart; retrying gets the same answer |
717
+ | `MQ_CONNECTION_FATAL` (with `reason: 'cycles-spent' \| 'broker-refused'`, and `isConnectionFatal()` now `true`) | the CONNECTION is permanently lost — the recovery this `consume()` waited for ended the client, or the client was already lost; `error.cause` is the very verdict `onFatal` received (§ *permanently lost*) | the lifecycle owner stops the process; the restart policy boots a new one against a verified broker |
619
718
 
620
719
  The third one is the SAME verdict a refused connection carries (§ *permanently lost*),
621
720
  because it asks the lifecycle owner for the same thing: stop retrying. It refuses the
622
721
  QUEUE, not the connection — the client stays connected and `isConnectionFatal()` stays
623
- `false`. `@onlineapps/service-wrapper` reads `code` to end FÁZE 0.7 permanently; before
722
+ `false`. The fourth is that connection verdict itself, carried onto the `consume()` that met
723
+ it (one rail, `_carryVerdict()`): the code is on the error, not only in its `cause`, so
724
+ `BaseClient` lets it through unchanged as it does every refusal with a verdict. A recovery
725
+ cycle that merely ran out (standby) is no verdict and carries no code. `@onlineapps/service-wrapper` reads `code` to end FÁZE 0.7 permanently; before
624
726
  2026-09-16 a 406 on a queue carried no code at all, so the service kept booting against a
625
727
  queue it will never be allowed to declare.
626
728
 
@@ -716,6 +818,7 @@ topology.telemetryLogsQueue = 'mine'; // TypeError — the object is froz
716
818
 
717
819
  | Key | Name on the wire | Who publishes / who drains it |
718
820
  |---|---|---|
821
+ | `infrastructureHealthChecksQueue` | `infrastructure.health.checks` | Every infrastructure service publishes its health checks there (`@onlineapps/infrastructure-tools` `healthPublisher.js`); the registry drains it (`api/infra/api_services_registry/src/listeners/infrastructureHealth.listener.js`). Composed here since d.1307; no reader cites it yet — `@onlineapps/service-common` still carries the literal (`src/defaults.js`), and which side switches is the owner's decision (d.1306). |
719
822
  | `infrastructureHealthEventsExchange` | `infrastructure.health.events` | Fanout. The registry publishes (`api/infra/api_services_registry/src/services/infrastructureEventPublisher.js`); the two queues below are bound to it. |
720
823
  | `monitoringInfrastructureHealthEventsQueue` | `monitoring.infrastructure.health.events` | The monitoring consumer's queue on that fanout (`api/infra/api_monitoring/src/consumer/topology.js`, `MONITORING_INFRA_EVENT_QUEUE`). |
721
824
  | `deliveryHealthEventsQueue` | `delivery.health.events` | The delivery endpoint binds it to the fanout and consumes it for WS push (`api/infra/api_delivery_endpoint/src/index.js`, `bindQueueToFanout(config.healthEventsQueue, …)`). |
@@ -724,6 +827,7 @@ topology.telemetryLogsQueue = 'mine'; // TypeError — the object is froz
724
827
  | `telemetryMetricsQueue` | `telemetry.metrics.queue` | The same exchange, the same consumer — metric samples. |
725
828
  | `telemetryTracesQueue` | `telemetry.traces.queue` | The same exchange, the same consumer — spans. |
726
829
  | `telemetryExchange` | `telemetry.exchange` | Topic exchange the three queues above are bound to; every telemetry producer publishes to it, and the monitoring consumer asserts it (d.708b). |
830
+ | `workflowObserverCopyQueues` | `['monitoring.workflow', 'delivery.workflow.events', 'scheduler.workflow.events']` (frozen array) | The observers' copies of a run: the queues bound to `monitoring.workflow.fanout` for a reader that watches the run and is not part of it (the monitoring consumer, the delivery endpoint's WS feed, the scheduler's end-of-run watch). A dead letter from one of them is a lost copy of the run's telemetry, not a lost run (INFRA 559). Declared once in `queueConfig` (`WORKFLOW_OBSERVER_COPIES`) — membership cannot be read off an entry, because the binding is each consumer's own act at boot — and composed by `queueName()` (d.1336). |
727
831
 
728
832
  - **Frozen on purpose.** An importer must not be able to rewrite platform topology: a
729
833
  name overridden on one side of a fanout loses the events silently, which is why batch
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/mq-client-core",
3
- "version": "5.0.0",
3
+ "version": "6.0.0",
4
4
  "description": "Core MQ client library for RabbitMQ - shared by infrastructure services and connectors",
5
5
  "oa": {
6
6
  "category": "connector"
@@ -23,7 +23,7 @@
23
23
  "author": "OnlineApps",
24
24
  "license": "MIT",
25
25
  "dependencies": {
26
- "@onlineapps/logger-contract": "2.0.0",
26
+ "@onlineapps/logger-contract": "2.1.0",
27
27
  "@onlineapps/runtime-config": "1.2.0",
28
28
  "ajv": "^8.12.0",
29
29
  "amqplib": "0.10.9"
package/src/BaseClient.js CHANGED
@@ -28,6 +28,7 @@ const {
28
28
  SerializationError,
29
29
  CONSUMER_QUEUE_MISSING,
30
30
  CONSUMER_DEAD_LETTER_ROUTE_MISSING,
31
+ carryVerdict,
31
32
  } = require('./utils/errorHandler');
32
33
 
33
34
  /**
@@ -110,6 +111,32 @@ const NOT_A_KEY_OF_THIS_CLIENT = Object.freeze({
110
111
  'The persistent buffer it fed is gone (see persistentBufferEnabled): it never wrote to '
111
112
  + 'Redis at all, so the client injected here was held and never used.',
112
113
  }),
114
+ /**
115
+ * Removed in d.1275 with the `health:shutdown` event they armed, on the owner's word
116
+ * (2026-10-06): "volbu i událost ZRUŠIT, žádný slib". After `criticalHealthShutdownDelay`
117
+ * of critical health the transport emitted `health:shutdown` and logged that it was
118
+ * "triggering shutdown" — but the library never ends the process (`_failFatally()` says
119
+ * why), and nothing in the repository listened: `@onlineapps/service-wrapper` dropped its
120
+ * listener on 2026-09-07, no infra service ever had one (measured 2026-10-06). The four
121
+ * questions of `change-discipline.md` § Removing are answered in CHANGELOG.md, in the release that removed them.
122
+ * Refused by name, not ignored: a caller writing `false` to silence the promise needs to
123
+ * hear that there is no promise left to silence.
124
+ */
125
+ criticalHealthShutdown: Object.freeze({
126
+ key: null,
127
+ what: 'a shutdown on sustained critical health',
128
+ consequence:
129
+ 'The client reports critical health (health:critical, healthCriticalCallback) and decides '
130
+ + 'nothing about the process; the health:shutdown event this key armed reached no listener, '
131
+ + 'so it was removed. Ending the process is the lifecycle owner\'s decision (onFatal).',
132
+ }),
133
+ criticalHealthShutdownDelay: Object.freeze({
134
+ key: null,
135
+ what: 'the delay before a shutdown on sustained critical health',
136
+ consequence:
137
+ 'It timed the health:shutdown event, which is gone (see criticalHealthShutdown): the client '
138
+ + 'reports critical health on every failing check and decides nothing about the process.',
139
+ }),
113
140
  });
114
141
 
115
142
  /**
@@ -370,6 +397,9 @@ class BaseClient {
370
397
  */
371
398
  async connect(options = {}) {
372
399
  if (this._connected) return;
400
+ // An explicit `connect()` builds a NEW transport below, so the verdict of the old one
401
+ // ends here — like `_closedByCaller` on the transport, it is undone by nothing else (d.1301).
402
+ this._fatalError = null;
373
403
 
374
404
  // `host` is the broker URL and carries the account password. It is redacted
375
405
  // on the way to the log — the only rail for that is utils/redactCredentials.
@@ -485,6 +515,30 @@ class BaseClient {
485
515
  }
486
516
  }
487
517
 
518
+ /**
519
+ * The one guard every operation on the broker runs first (d.1314; the predicate is
520
+ * d.1301's). It refuses only the two states where "connect()" is true advice — the client
521
+ * never connected, or the caller closed it with `disconnect()`. After `connection:fatal`
522
+ * it lets the call through to the transport, which knows that state and answers it with
523
+ * the verdict on top (`code: 'MQ_CONNECTION_FATAL'`, `reason`, the verdict as `cause`), so
524
+ * no second sentence about a lost connection is written here. Until d.1314 each of the
525
+ * eleven operations carried its own copy of this guard, nine of them still advising
526
+ * `connect()` after the verdict.
527
+ *
528
+ * @param {string} op - the public method asking, used in the sentence
529
+ * @param {string} [tail='.'] - what ends the Fix sentence
530
+ * @throws {ConnectionError}
531
+ * @private
532
+ */
533
+ _requireUsable(op, tail = '.') {
534
+ if (!this._transport || (!this._connected && !this._fatalError)) {
535
+ throw new ConnectionError(
536
+ `[BaseClient] Cannot ${op}: client is not connected - Expected: connect() to have completed. `
537
+ + `Fix: await client.connect() before ${op}()${tail}`
538
+ );
539
+ }
540
+ }
541
+
488
542
  /**
489
543
  * Publishes a message to the specified queue.
490
544
  * @param {string} queue - Target queue name.
@@ -494,17 +548,23 @@ class BaseClient {
494
548
  * infrastructure or business queue is created by its owner before anything
495
549
  * publishes to it, and an ownerless name is declared with the arguments the
496
550
  * central `queueConfig` resolves (`transports/rabbitmqClient.js`, the 404 branch).
551
+ * @param {boolean} [options.bufferOnFailure=true] - `false` = a transiently failed message
552
+ * is NOT held in the library's buffer, so no reconnect replays it; for a caller that keeps
553
+ * the message durably elsewhere. Owned by `layers/PublishLayer.js`; a non-boolean is refused.
497
554
  * @returns {Promise<void>}
498
555
  * @throws {ConnectionError} If not connected.
499
- * @throws {PublishError} If publish fails.
556
+ * @throws {PublishError} If publish fails. After a transient failure (retries exhausted,
557
+ * retry disabled, the awaited reconnect failed) it carries `buffered` (boolean): `true`
558
+ * only when the buffer holds the message for the next reconnect.
500
559
  */
501
560
  async publish(queue, message, options = {}) {
502
- if (!this._connected || !this._transport) {
503
- throw new ConnectionError(
504
- '[BaseClient] Cannot publish: client is not connected - Expected: connect() to have completed. '
505
- + 'Fix: await client.connect() before publish().'
506
- );
507
- }
561
+ // A client whose connection was declared LOST is not "not connected": `connect()` is
562
+ // exactly what its lifecycle owner must not be told to do (d.1301). The transport still
563
+ // exists and answers that state itself — a `RecoveryEndedPublishError` with the verdict
564
+ // on top and `buffered: false` (`_ensurePublisherChannel()`, d.1300/d.1301) — so the call
565
+ // is let through to it; this guard keeps only the two states where the advice is true:
566
+ // never connected, and closed by `disconnect()`.
567
+ this._requireUsable('publish');
508
568
 
509
569
  let buffer;
510
570
  try {
@@ -539,7 +599,7 @@ class BaseClient {
539
599
  // answer as d.297 one call over (`_consumerRefusal()`): name no likeliest
540
600
  // cause, carry the one actually caught. `cause` is unchanged, so a caller
541
601
  // deciding on the class still reads the object.
542
- throw new PublishError(
602
+ const failure = new PublishError(
543
603
  `[BaseClient] Failed to publish to queue "${queue}": ${describeThrown(err)} - `
544
604
  + 'Expected: publish() to hand the message to the broker. '
545
605
  + 'Fix: act on the reason above — this layer does not know which failure it was, and naming the '
@@ -548,6 +608,25 @@ class BaseClient {
548
608
  queue,
549
609
  err
550
610
  );
611
+ // Whether the library's buffer holds the message is decided and written by
612
+ // `PublishLayer` (options.bufferOnFailure, d.1122); it is carried up here unchanged so
613
+ // the caller reads it on the error it catches. Absent when the failure never reached
614
+ // the buffer decision — "not asked" is not "false".
615
+ if (err && typeof err.buffered === 'boolean') {
616
+ failure.buffered = err.buffered;
617
+ }
618
+ // Whether any attempt wrote the frame (d.1145, conf `mq-publish-buffer` 001): carried
619
+ // like `buffered`, so the caller — the orchestrator's `publish_pending` record
620
+ // (`step-publish-failure` 003) — reads it on the error it catches.
621
+ if (err && typeof err.sent === 'boolean') {
622
+ failure.sent = err.sent;
623
+ }
624
+ // The verdict of a lost connection, carried like `buffered` and `sent` (d.1300, the
625
+ // publish half of d.1297): a publish whose recovery ended fatally reaches the caller
626
+ // with `code: 'MQ_CONNECTION_FATAL'` and `reason` on the error it catches — where the
627
+ // lifecycle owner reads them — not two `cause` down.
628
+ carryVerdict(failure, err);
629
+ throw failure;
551
630
  }
552
631
  }
553
632
 
@@ -597,12 +676,11 @@ class BaseClient {
597
676
  * @throws {ConsumeError} If consumer setup fails.
598
677
  */
599
678
  async consume(queue, messageHandler, options = {}) {
600
- if (!this._connected || !this._transport) {
601
- throw new ConnectionError(
602
- '[BaseClient] Cannot consume: client is not connected - Expected: connect() to have completed. '
603
- + 'Fix: await client.connect() before consume().'
604
- );
605
- }
679
+ // Same rule as `publish()` (d.1301): after `connection:fatal` the transport answers with
680
+ // the verdict on top (`_consumerChannelWithoutRecovery()`, d.1281/d.1297) and
681
+ // `_consumerRefusal()` lets it through unchanged; the "not connected" advice is kept
682
+ // for the two states where it is true.
683
+ this._requireUsable('consume');
606
684
 
607
685
  // Apply prefetch and noAck overrides if provided
608
686
  const { prefetch, noAck } = options;
@@ -880,12 +958,7 @@ class BaseClient {
880
958
  * @returns {Promise<Object>}
881
959
  */
882
960
  async assertQueue(queue, options = {}) {
883
- if (!this._connected || !this._transport) {
884
- throw new ConnectionError(
885
- '[BaseClient] Cannot assertQueue: client is not connected - Expected: connect() to have completed. '
886
- + 'Fix: await client.connect() before assertQueue().'
887
- );
888
- }
961
+ this._requireUsable('assertQueue');
889
962
  if (typeof this._transport.assertQueue !== 'function') {
890
963
  throw new Error(
891
964
  '[BaseClient] Transport does not support assertQueue(queue, options) - Expected: a transport implementing assertQueue. '
@@ -913,12 +986,7 @@ class BaseClient {
913
986
  * @throws {ValidationError} If the name or type cannot declare an exchange.
914
987
  */
915
988
  async assertExchange(exchange, type, options = {}) {
916
- if (!this._connected || !this._transport) {
917
- throw new ConnectionError(
918
- '[BaseClient] Cannot assertExchange: client is not connected - Expected: connect() to have completed. '
919
- + 'Fix: await client.connect() before assertExchange().'
920
- );
921
- }
989
+ this._requireUsable('assertExchange');
922
990
  return await this._transport.assertExchange(exchange, type, options);
923
991
  }
924
992
 
@@ -934,12 +1002,7 @@ class BaseClient {
934
1002
  * @throws {ValidationError} If a name or the pattern cannot form a binding.
935
1003
  */
936
1004
  async bindQueue(queue, exchange, pattern) {
937
- if (!this._connected || !this._transport) {
938
- throw new ConnectionError(
939
- '[BaseClient] Cannot bindQueue: client is not connected - Expected: connect() to have completed. '
940
- + 'Fix: await client.connect() before bindQueue().'
941
- );
942
- }
1005
+ this._requireUsable('bindQueue');
943
1006
  return await this._transport.bindQueue(queue, exchange, pattern);
944
1007
  }
945
1008
 
@@ -956,12 +1019,7 @@ class BaseClient {
956
1019
  * @throws {ValidationError} If this client holds no such consumer.
957
1020
  */
958
1021
  async cancelConsumer(consumerTagOrQueue) {
959
- if (!this._connected || !this._transport) {
960
- throw new ConnectionError(
961
- '[BaseClient] Cannot cancelConsumer: client is not connected - Expected: connect() to have completed. '
962
- + 'Fix: await client.connect() before cancelConsumer().'
963
- );
964
- }
1022
+ this._requireUsable('cancelConsumer');
965
1023
  return await this._transport.cancelConsumer(consumerTagOrQueue);
966
1024
  }
967
1025
 
@@ -971,12 +1029,7 @@ class BaseClient {
971
1029
  * @returns {Promise<Object>}
972
1030
  */
973
1031
  async checkQueue(queue) {
974
- if (!this._connected || !this._transport) {
975
- throw new ConnectionError(
976
- '[BaseClient] Cannot checkQueue: client is not connected - Expected: connect() to have completed. '
977
- + 'Fix: await client.connect() before checkQueue().'
978
- );
979
- }
1032
+ this._requireUsable('checkQueue');
980
1033
  if (typeof this._transport.checkQueue !== 'function') {
981
1034
  throw new Error(
982
1035
  '[BaseClient] Transport does not support checkQueue(queue) - Expected: a transport implementing checkQueue. '
@@ -992,12 +1045,7 @@ class BaseClient {
992
1045
  * @returns {Promise<Object>}
993
1046
  */
994
1047
  async purgeQueue(queue) {
995
- if (!this._connected || !this._transport) {
996
- throw new ConnectionError(
997
- '[BaseClient] Cannot purgeQueue: client is not connected - Expected: connect() to have completed. '
998
- + 'Fix: await client.connect() before purgeQueue().'
999
- );
1000
- }
1048
+ this._requireUsable('purgeQueue');
1001
1049
  if (typeof this._transport.purgeQueue !== 'function') {
1002
1050
  throw new Error(
1003
1051
  '[BaseClient] Transport does not support purgeQueue(queue) - Expected: a transport implementing purgeQueue. '
@@ -1014,12 +1062,7 @@ class BaseClient {
1014
1062
  * @returns {Promise<Object>}
1015
1063
  */
1016
1064
  async deleteQueue(queue, options = {}) {
1017
- if (!this._connected || !this._transport) {
1018
- throw new ConnectionError(
1019
- '[BaseClient] Cannot deleteQueue: client is not connected - Expected: connect() to have completed. '
1020
- + 'Fix: await client.connect() before deleteQueue().'
1021
- );
1022
- }
1065
+ this._requireUsable('deleteQueue');
1023
1066
  if (typeof this._transport.deleteQueue !== 'function') {
1024
1067
  throw new Error(
1025
1068
  '[BaseClient] Transport does not support deleteQueue(queue, options) - Expected: a transport implementing deleteQueue. '
@@ -1049,12 +1092,7 @@ class BaseClient {
1049
1092
  * @returns {Promise<void>}
1050
1093
  */
1051
1094
  async ack(msg) {
1052
- if (!this._connected || !this._transport) {
1053
- throw new ConnectionError(
1054
- '[BaseClient] Cannot ack: client is not connected - Expected: connect() to have completed. '
1055
- + 'Fix: await client.connect() before ack(); a message received before a reconnect can no longer be acked.'
1056
- );
1057
- }
1095
+ this._requireUsable('ack', '; a message received before a reconnect can no longer be acked.');
1058
1096
  if (typeof this._transport.ack !== 'function') {
1059
1097
  throw new Error(
1060
1098
  '[BaseClient] Transport does not support ack(msg) - Expected: a transport implementing ack. '
@@ -1076,12 +1114,7 @@ class BaseClient {
1076
1114
  * @returns {Promise<void>}
1077
1115
  */
1078
1116
  async nack(msg, options = {}) {
1079
- if (!this._connected || !this._transport) {
1080
- throw new ConnectionError(
1081
- '[BaseClient] Cannot nack: client is not connected - Expected: connect() to have completed. '
1082
- + 'Fix: await client.connect() before nack(); a message received before a reconnect can no longer be nacked.'
1083
- );
1084
- }
1117
+ this._requireUsable('nack', '; a message received before a reconnect can no longer be nacked.');
1085
1118
  if (typeof this._transport.nack !== 'function') {
1086
1119
  throw new Error(
1087
1120
  '[BaseClient] Transport does not support nack(msg, options) - Expected: a transport implementing nack. '