@onlineapps/mq-client-core 4.2.0 → 5.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
@@ -36,17 +36,21 @@ const BaseClient = require('@onlineapps/mq-client-core');
36
36
  const mqClient = new BaseClient({
37
37
  type: 'rabbitmq',
38
38
  host: 'amqp://localhost:5672',
39
+ serviceName: 'api_gateway', // REQUIRED unless ENV SERVICE_NAME is set
39
40
  logger // REQUIRED: info/warn/error/debug
40
41
  });
41
42
 
42
43
  await mqClient.connect();
43
44
  await mqClient.publish('workflow.init', { workflowId: '123', data: '...' });
44
- await mqClient.publish('workflow.control', { workflowId: '123', data: '...' });
45
+ await mqClient.publish('workflow.completed', { workflowId: '123', data: '...' });
45
46
  ```
46
47
 
47
48
  ### Configuration
48
49
 
49
- **Required:** `type`, `host`, `logger`. Every other key is optional — but it has to
50
+ **Required:** `type`, `host`, `logger`, and `serviceName` — the explicit key, or ENV
51
+ `SERVICE_NAME`. The library invents no name: a client with neither is refused at
52
+ construction (d.770), because a default would file every monitoring event, and name every
53
+ queue and connection, under a service that does not exist. Every other key is optional — but it has to
50
54
  be a key this client declares: a name nothing declares is **refused at construction**,
51
55
  named, together with the declared key it is closest to. There is no "anything else"
52
56
  door (`src/config/configSchema.js`, `additionalProperties: false`). A key the client
@@ -59,6 +63,7 @@ package.
59
63
  type: 'rabbitmq', // Required
60
64
  host: 'amqp://...', // Required
61
65
  logger, // Required: info/warn/error/debug
66
+ serviceName: 'billing', // Required unless ENV SERVICE_NAME; names the connection and every event
62
67
  exchange: '', // Optional exchange
63
68
  durable: true, // Optional (default: true)
64
69
  prefetch: 1, // Optional (default: 1)
@@ -114,7 +119,7 @@ The library owns NO logger. `logger` is required and validated in the
114
119
  logger fails the constructor, it does not fall back to `console`.
115
120
 
116
121
  The same object is forwarded to everything the client builds — transport,
117
- publish layer, recovery worker, buffers — so one service configures one
122
+ publish layer, buffers — so one service configures one
118
123
  channel and the whole client writes there. Levels: `info` for lifecycle
119
124
  (connected, consumer registered, channel recovered), `debug` for
120
125
  per-message and setup detail, `warn`/`error` for the rest. An error
@@ -130,6 +135,11 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
130
135
  - `connect(options?)` - Connect to RabbitMQ
131
136
  - `disconnect()` - Disconnect from RabbitMQ
132
137
  - `publish(queue, message, options?)` - Publish message to queue
138
+ - `publishTelemetry(queue, message, options?)` - Publish the client's OWN event (the
139
+ monitoring helpers below use it) on a confirm channel of its own, opened with the
140
+ first such publish. A refusal there closes that channel only, never the one a
141
+ business message is waiting on (d.724). The channel is chosen by this call, never by
142
+ the queue name.
133
143
  - `consume(queue, handler, options?)` - Consume messages from queue; resolves with
134
144
  the broker's consumer tag. A failing handler is answered by the delivery policy,
135
145
  never by a bare requeue — see § `consume()` — the delivery contract.
@@ -184,7 +194,10 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
184
194
  `flushed`, plus a per-queue breakdown; the second renders the same counters as
185
195
  Prometheus text.
186
196
  - `getChannelState()` / `getConsumerState()` / `getBufferState()` - Liveness of the
187
- three channels, the consumers this client holds, and what is waiting in the
197
+ channels (`publisher`, `queue`, `consumer` and `telemetry`, each `{ exists, closed,
198
+ ready }`; `telemetry` opens with the first telemetry publish and reads
199
+ `{ exists: false, closed: true, ready: false }` until then — d.1007), the consumers
200
+ this client holds, and what is waiting in the
188
201
  publish buffer (`{ size, inMemory }` — there is one buffer). All five refuse
189
202
  before `connect()` has built a transport, rather than answering with an invented
190
203
  zero. `getConsumerState()` answers `{ tracked, active }`: `tracked` counts the
@@ -193,7 +206,9 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
193
206
  this client takes no message off some queue it registered.
194
207
  - `disconnectAll()` (static, on the class and on the package export) - Close every
195
208
  client this module holds; see § Shutdown.
196
- - `onError(callback)` - Register error handler
209
+ - `onError(callback)` - Register error handler for connection and channel faults. A
210
+ failed publish is NOT reported here: its verdict is the rejection of `publish()`
211
+ (d.811b).
197
212
  - `onFatal(callback)` - Register handler for the permanent loss of the
198
213
  connection (see § Connection loss)
199
214
 
@@ -299,6 +314,13 @@ const { deliveryPolicy } = require('@onlineapps/mq-client-core');
299
314
  deliveryPolicy.ATTEMPTS_HEADER; // 'x-oa-delivery-attempts' — read it, never retype it
300
315
  ```
301
316
 
317
+ **The option rules have one owner, and it is exported.** `consume()` validates its options
318
+ through `deliveryPolicy.validateConsumeOptions(options, resolveDefaultMaxAttempts)`, which
319
+ returns `{ maxAttempts, classify }` or throws the `ValidationError` `consume()` throws, in
320
+ the same words. A consumer's test double of `consume()` calls it instead of copying the
321
+ rules. `resolveDefaultMaxAttempts` is a function, called only when the options leave the
322
+ budget open (d.1005).
323
+
302
324
  **The `message_dlq` event** goes to `monitoring.workflow` and carries `status: 'rejected'`
303
325
  — the broker's own word for this death, what it stamps into `x-death[0].reason`. It is
304
326
  published **only when the envelope carries a `workflow_id`**. Without one nothing is
@@ -308,6 +330,11 @@ queue through the DLQ dashboard. `monitoring.workflow` keys every trace on that
308
330
  event naming no workflow is either refused as faulty telemetry or — with a marker like
309
331
  `'unknown'` in its place — written as a row claiming a workflow by that name.
310
332
 
333
+ It also names WHAT died: `operation`, written **only when the envelope carried one** — the
334
+ invocation envelope has the field, the workflow message never does — and left out entirely
335
+ otherwise, with no `unknown` and no `null` standing in for it. The run's identity stays one
336
+ key, `workflow_id`; no event on `monitoring.workflow` carries a `correlation_id`.
337
+
311
338
  **`x-message-ttl` and the budget do not cooperate.** The service queues carry a TTL
312
339
  (`<service>.queue` 30 s, `<service>.workflow` 5 min) and each attempt re-publishes the
313
340
  message, which restarts that clock. So the TTL bounds ONE attempt, never the whole budget,
@@ -380,7 +407,8 @@ exponential backoff, up to `maxReconnectAttempts` times. That whole budget is on
380
407
  where the socket is already back.** "Connected" means the client can be used, and a
381
408
  re-established connection whose channels are still being recreated cannot publish or
382
409
  consume. The two answers therefore agree at every instant: the moment `isConnected()`
383
- turns `true`, `getChannelState()` reports all three channels ready.
410
+ turns `true`, `getChannelState()` reports the publisher, queue and consumer channels ready
411
+ (the telemetry channel is opened on demand, so it may still read as not opened).
384
412
 
385
413
  **Standing down is not dying, and the difference is the point.** An outage the
386
414
  broker will come back from must not turn a client into a corpse
@@ -507,9 +535,12 @@ A missing queue is answered by who owns the name, not by the client's scope:
507
535
  Publishing must never create an owned queue: `sendToQueue()`/`assertQueue()` would
508
536
  declare it with default arguments — no TTL, no DLQ — and the owner's later
509
537
  `setupServiceQueues()` then fails with 406 `PRECONDITION_FAILED` because the
510
- arguments no longer match. The rule is enforced in two places, and both are
511
- required: the 404 branch of `_publishOnce()` refuses the publish, and
512
- `RecoveryWorker.handleQueueNotFound()` refuses to create the queue behind it.
538
+ arguments no longer match. The rule is enforced in ONE place: the 404 branch of
539
+ `_publishOnce()` refuses the publish and declares nothing, and the caller gets the
540
+ verdict as the rejection — `PublishError` with the `QueueNotFoundError` as its
541
+ `cause` (`queueName`, `kind`, and per class the owner and the Fix). Nothing else
542
+ answers a missing queue: the worker that once did, from the client's `error`
543
+ channel, is gone (d.971).
513
544
 
514
545
  Which of the three sentences the refusal carries is decided by the ownership class
515
546
  in the error (`kind`), which the central declaration answers — never by how the
@@ -530,7 +561,9 @@ configured publishers declared one exchange two ways, and a client whose
530
561
  that was itself faultless. Measured against the live broker: `inequivalent arg
531
562
  'type' … received 'direct' but current is 'fanout'`. An exchange nobody declared
532
563
  is now a broker-side 404 on the publish, not an exchange invented by whoever
533
- published first — the same rule as for queues, one object up.
564
+ published first — the same rule as for queues, one object up. The publish answers it
565
+ as a `PermanentPublishError` with the broker's frame in `cause`: one attempt, nothing
566
+ buffered, because no retry and no reconnect declares the exchange (d.693).
534
567
 
535
568
  ### The publish buffer — one buffer, every priority
536
569
 
@@ -605,6 +638,26 @@ it did — every refusal wore the sentence "Expected: the queue to exist before
605
638
  attaches to it", which for a queue with no declared route is false in every clause and
606
639
  sent the reader to fix something that was not broken.
607
640
 
641
+ **The monitoring publish helper asks the same question.**
642
+ `publishToMonitoringResilient()` never throws: a broker that is down, or a monitoring
643
+ queue that does not exist yet, is reported through the logger and answered `false`.
644
+ Which of its two sentences the operator reads — "queue … not available" or "failed to
645
+ publish" — is decided by `isQueueUnavailableError(error, queueName)`, and since d.676
646
+ that decision walks the error's `cause` chain and reads **classes and codes only**:
647
+ `QueueNotFoundError`, `ConnectionError`, `TransientPublishError`, amqplib's numeric
648
+ `code === 404`, and the socket codes `ECONNREFUSED` / `ENOTFOUND` / `ETIMEDOUT`. It
649
+ walks the chain because `BaseClient.publish()` wraps every transport failure in a
650
+ `PublishError` and puts the real class in `cause` — so the classes were never the object
651
+ the function was handed, and until d.676 only its message substrings ever fired. Measured
652
+ consequence of that: a `QueueNotFoundError` with `kind: 'unowned'` answered "not a queue
653
+ problem", and an error whose text merely mentioned `404` answered that it was one.
654
+
655
+ The helper publishes through `mqClient.publishTelemetry()` (d.724), so a refused
656
+ monitoring event cannot close the channel a business message is waiting on. A client
657
+ without that method — anything that is not a `BaseClient` or a subclass of it — is
658
+ reported with a `warn` and answered `false`; the event is not sent on the business
659
+ channel instead.
660
+
608
661
  ### queueConfig — the classification, as a declared export
609
662
 
610
663
  The table above is not a copy: the names, their arguments and the two predicates
@@ -667,9 +720,10 @@ topology.telemetryLogsQueue = 'mine'; // TypeError — the object is froz
667
720
  | `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`). |
668
721
  | `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, …)`). |
669
722
  | `deliveryResourceEventsQueue` | `delivery.resource.events` | Bound to the `monitoring.resource` exchange by `resource.changed.#`; published by the monitoring watcher `resourceChangedPublisher.js`, drained by `ResourceChangedConsumer.js` in the delivery endpoint. |
670
- | `telemetryLogsQueue` | `telemetry.logs.queue` | Bound to `telemetry.exchange`, which every service publishes to with its own name as the routing key; drained by the monitoring consumer (`api/infra/api_monitoring/src/consumer/index.js`, `topology.TELEMETRY_LOGS_QUEUE`). |
723
+ | `telemetryLogsQueue` | `telemetry.logs.queue` | Bound to `telemetry.exchange` by `telemetry.logs.*`; every telemetry producer publishes there with a routing key of the shape `telemetry.<signal>.<service_name>` (logs: `telemetry.logs.<service_name>`); drained by the monitoring consumer (`api/infra/api_monitoring/src/consumer/index.js`, `topology.TELEMETRY_LOGS_QUEUE`). |
671
724
  | `telemetryMetricsQueue` | `telemetry.metrics.queue` | The same exchange, the same consumer — metric samples. |
672
725
  | `telemetryTracesQueue` | `telemetry.traces.queue` | The same exchange, the same consumer — spans. |
726
+ | `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). |
673
727
 
674
728
  - **Frozen on purpose.** An importer must not be able to rewrite platform topology: a
675
729
  name overridden on one side of a fanout loses the events silently, which is why batch
@@ -795,6 +849,12 @@ requestedBy })` take. One declaration record, and nothing else:
795
849
  is a missing record of work that happened; a lost registration is work that will not happen.
796
850
  - `scheduler.requestQueue` is the queue name, composed from the declaration in `queueConfig`
797
851
  — a caller never types `scheduler.request`.
852
+ - **A request that cannot be built is told apart by code, never by wording.** Every refusal of
853
+ a builder — the port's key check, the declaration rules, tenancy, the key, the requester, the
854
+ event — carries `error.code === scheduler.SCHEDULER_REQUEST_INVALID`; a failed send (no
855
+ connection, a publish the broker refused) never does. The wrapper's boot uses exactly this to
856
+ isolate one bad record as a failed source while a broker that is gone still stops the boot
857
+ ([`scheduler` 020](/docs/governance/confirmations/scheduler.md)).
798
858
 
799
859
  Two more things a service says on that queue, and one send for all of them:
800
860
 
@@ -847,6 +907,34 @@ await port.cancel({ id });
847
907
  under the wrapper, which fills it from the envelope of the run, and an operator script that does
848
908
  not boot the wrapper at all and works row by row.
849
909
 
910
+ ### decodeMessage — a delivery, decoded by one rail
911
+
912
+ `consume()` hands its handler the RAW amqplib delivery. `decodeMessage(raw)` is the one
913
+ public way from that delivery to the message it carries (d.789), so a consumer does not
914
+ keep its own copy of the translation.
915
+
916
+ ```javascript
917
+ const { decodeMessage } = require('@onlineapps/mq-client-core');
918
+
919
+ await client.consume(queue, async (raw) => {
920
+ const { body, headers, properties, fields } = decodeMessage(raw);
921
+ // …
922
+ });
923
+ ```
924
+
925
+ - It returns the decoded body, and `headers`, `properties` and `fields` exactly as they
926
+ arrived.
927
+ - It refuses with `ValidationError`, naming what arrived: a delivery that is not an
928
+ object, `content` that is neither a `Buffer` nor a string, an empty body, or a
929
+ DECLARED content type other than `application/json`. No declaration is fine — the
930
+ platform envelope does not require one.
931
+ - A body that is not JSON is the `SerializationError` the serializer throws, passed on
932
+ unchanged — the refused bytes in `payload`, the parser's reason in `cause`.
933
+ - It carries no `retryable` flag: what happens to the message is decided by the
934
+ caller's `classify(error)` (§ `consume()` — the delivery contract), on the error's
935
+ class.
936
+ - It is additive: `consume()` still hands over the raw delivery.
937
+
850
938
  ### redactUrl — a connection URL that is safe to log
851
939
 
852
940
  `RABBITMQ_URL` carries the broker account, so anything that renders it verbatim
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/mq-client-core",
3
- "version": "4.2.0",
3
+ "version": "5.0.0",
4
4
  "description": "Core MQ client library for RabbitMQ - shared by infrastructure services and connectors",
5
5
  "oa": {
6
6
  "category": "connector"
package/src/BaseClient.js CHANGED
@@ -318,6 +318,12 @@ class BaseClient {
318
318
  // Apply resolved runtime config
319
319
  this._config.host = resolved.host;
320
320
  this._config.heartbeat = this._config.heartbeat ?? resolved.heartbeat;
321
+ // The resolved identity is WRITTEN BACK, so the config carries what the client runs
322
+ // under whichever leg produced it — the explicit key or ENV SERVICE_NAME. Until d.770
323
+ // only the explicit key reached `_config.serviceName`, and everything downstream that
324
+ // needed the name asked the runtime config again; two readers of one fact, and the
325
+ // transport's copy answered the module default nobody wrote.
326
+ this._config.serviceName = resolved.serviceName;
321
327
  this._config.connectionName =
322
328
  this._config.connectionName ||
323
329
  `${resolved.serviceName}:${process.pid}`;
@@ -381,20 +387,36 @@ class BaseClient {
381
387
  });
382
388
  this._config.host = resolved.host;
383
389
  this._config.heartbeat = this._config.heartbeat ?? resolved.heartbeat;
390
+ this._config.serviceName = resolved.serviceName;
384
391
  this._config.connectionName =
385
392
  this._config.connectionName ||
386
393
  `${resolved.serviceName}:${process.pid}`;
387
394
 
388
- try {
389
- this._logger.debug('[BaseClient] Creating transport');
390
- // Instantiate appropriate transport: RabbitMQClient. `this._config` carries
391
- // the validated logger, so the transport logs through the same channel —
392
- // it builds none of its own.
393
- this._transport = transportFactory.create(this._config);
394
- this._logger.debug('[BaseClient] Transport created', {
395
- transport: this._transport.constructor.name,
396
- });
395
+ this._logger.debug('[BaseClient] Creating transport');
396
+ // Instantiate appropriate transport: RabbitMQClient. `this._config` carries
397
+ // the validated logger, so the transport logs through the same channel —
398
+ // it builds none of its own.
399
+ //
400
+ // OUTSIDE THE `try` ON PURPOSE (d.736b). What this line can throw is a
401
+ // refusal of the CONFIGURATION — a target that is not a non-empty string, a
402
+ // URL declaring its own heartbeat, an `onFatal` that is not a function, a
403
+ // `type` no transport implements. Nothing has been asked of the broker yet,
404
+ // and nothing about the answer will change on a second attempt. Until this
405
+ // batch the `catch` below turned every one of them into `ConnectionError`
406
+ // ("a reachable broker … check the MQ url, credentials and network"), which
407
+ // is a transient fault by name — and `ServiceWrapper._initializeMQ()` reads
408
+ // it as one: it retries `connect()` `wrapper.mq.connectionRetries` times and
409
+ // stands down only for `code: 'MQ_CONNECTION_FATAL'` (10 attempts, 2 s base,
410
+ // 30 s cap — `service-wrapper/config/runtime-defaults.json`), so a typo in a
411
+ // URL cost ~200 s of backoff and then sent the operator to look at the
412
+ // network. The refusal already names the key and the fix
413
+ // (`architecture-principles.md` §5); it is passed on as it is.
414
+ this._transport = transportFactory.create(this._config);
415
+ this._logger.debug('[BaseClient] Transport created', {
416
+ transport: this._transport.constructor.name,
417
+ });
397
418
 
419
+ try {
398
420
  // Register internal error propagation
399
421
  this._transport.on('error', (err) => this._handleError(err));
400
422
 
@@ -736,6 +758,18 @@ class BaseClient {
736
758
  * DLQ, as the monitoring intake requires (`api/shared/TODO.md`, INFRA-monitoring
737
759
  * 2026-09-11); an event with no `status` is refused there under its own name.
738
760
  *
761
+ * `operation` says WHAT died, and it is written ONLY when the envelope carried
762
+ * one: the invocation envelope has the field
763
+ * (`docs/biz/10-invocation/mq-envelope.md` § Inbound envelope), the workflow
764
+ * message of `docs/biz/70-contracts/workflow-message.md` §3.2 never does, and
765
+ * this event reports what it found rather than a marker. It has a reader
766
+ * already — `readReported(data.operation)` in the monitoring consumer
767
+ * (`infra/api_monitoring/src/consumer/apiflow-extension.js`), from where the
768
+ * value reaches the Loki line as `operation`; until it was written here, every
769
+ * dead letter arrived there as `null`. The run's identity stays ONE key,
770
+ * `workflow_id`: no event on this queue carries a `correlation_id`
771
+ * (`docs/biz/70-contracts/monitoring-events.md` § Rules).
772
+ *
739
773
  * @param {Object} event - As the transport reports it.
740
774
  * @returns {Promise<void>}
741
775
  * @private
@@ -745,16 +779,22 @@ class BaseClient {
745
779
  const serviceName = runtimeCfg.get('serviceName', this._config.serviceName);
746
780
 
747
781
  let workflowId = null;
782
+ let operation = null;
748
783
  try {
749
784
  const envelope = serializer.deserialize(content);
750
- if (envelope !== null && typeof envelope === 'object' && typeof envelope.workflow_id === 'string'
751
- && envelope.workflow_id !== '') {
752
- workflowId = envelope.workflow_id;
785
+ if (envelope !== null && typeof envelope === 'object') {
786
+ if (typeof envelope.workflow_id === 'string' && envelope.workflow_id !== '') {
787
+ workflowId = envelope.workflow_id;
788
+ }
789
+ if (typeof envelope.operation === 'string' && envelope.operation !== '') {
790
+ operation = envelope.operation;
791
+ }
753
792
  }
754
793
  } catch (parseErr) {
755
794
  // A payload that is not JSON carries no envelope, so it carries no
756
795
  // workflow_id either. Same outcome as an envelope without one.
757
796
  workflowId = null;
797
+ operation = null;
758
798
  }
759
799
 
760
800
  if (workflowId === null) {
@@ -775,29 +815,64 @@ class BaseClient {
775
815
  return;
776
816
  }
777
817
 
818
+ const payload = {
819
+ event_type: 'message_dlq',
820
+ workflow_id: workflowId,
821
+ service_name: serviceName,
822
+ // The broker's word for this death; see the method comment.
823
+ status: 'rejected',
824
+ queue,
825
+ attempts,
826
+ max_attempts: maxAttempts,
827
+ classification,
828
+ error: {
829
+ message: error && error.message,
830
+ name: error && error.name
831
+ },
832
+ timestamp: new Date().toISOString()
833
+ };
834
+
835
+ // …and WHAT died, when the envelope says so. The key is written only when
836
+ // the envelope carried one, in the one spelling the invocation envelope
837
+ // uses; an envelope without it leaves the event without the key rather than
838
+ // with an invented marker, exactly as workflow_id is handled above.
839
+ if (operation !== null) {
840
+ payload.operation = operation;
841
+ }
842
+
778
843
  await publishToMonitoringWorkflow(
779
844
  this,
780
- {
781
- event_type: 'message_dlq',
782
- workflow_id: workflowId,
783
- service_name: serviceName,
784
- // The broker's word for this death; see the method comment.
785
- status: 'rejected',
786
- queue,
787
- attempts,
788
- max_attempts: maxAttempts,
789
- classification,
790
- error: {
791
- message: error && error.message,
792
- name: error && error.name
793
- },
794
- timestamp: new Date().toISOString()
795
- },
845
+ payload,
796
846
  this._logger,
797
847
  { queue }
798
848
  );
799
849
  }
800
850
 
851
+ /**
852
+ * Publish one of the CLIENT'S OWN events — the messages nobody asked it to send.
853
+ *
854
+ * Same path as `publish()` in every respect but one: the send goes out on a channel of
855
+ * its own (`transports/rabbitmqClient.js` § _ensurePublisherChannel, role
856
+ * `'telemetry'`), so a refusal of an event can never close the channel a caller's
857
+ * message is waiting for a confirm on.
858
+ *
859
+ * WHY IT IS A SEPARATE METHOD and not a flag on `publish()`: the caller of `publish()`
860
+ * is sending its own message and has no business choosing a channel; the only code that
861
+ * may say "this is the client talking about itself" is the code that IS the client
862
+ * talking about itself (`src/monitoring-publish.js`). Measured cost of the shared
863
+ * channel, d.711: in a vhost where `monitoring.workflow.fanout` was not declared, the
864
+ * `404` for a dead-letter event took the business publish beside it down with it — 3 of
865
+ * 7 runs of a suite that had nothing to do with telemetry.
866
+ *
867
+ * @param {string} queue - Target queue name.
868
+ * @param {Object|Buffer|string} message - Payload to send.
869
+ * @param {Object} [options] - The same overrides `publish()` takes.
870
+ * @returns {Promise<void>}
871
+ */
872
+ async publishTelemetry(queue, message, options = {}) {
873
+ return this.publish(queue, message, { ...options, channelRole: 'telemetry' });
874
+ }
875
+
801
876
  /**
802
877
  * Assert queue existence (delegates to transport).
803
878
  * @param {string} queue - Queue name
@@ -959,7 +1034,8 @@ class BaseClient {
959
1034
  *
960
1035
  * Settling is idempotent: the transport marks the delivery via `msg._mqProcessed`
961
1036
  * and `consume()` skips its own auto-ack for a message the handler already
962
- * settled (`transports/rabbitmqClient.js:1769` and `:2268`). Calling this from
1037
+ * settled (`RabbitMQClient.consume()` and `_applyDeliveryPolicy()` in
1038
+ * `transports/rabbitmqClient.js`). Calling this from
963
1039
  * inside a consume handler is therefore supported, not a double-settle.
964
1040
  *
965
1041
  * Until 2026-08-28 this method was declared TWICE in this class; the second,
@@ -143,8 +143,9 @@ module.exports = {
143
143
  type: 'string',
144
144
  minLength: 1,
145
145
  description:
146
- 'Name this client publishes under and names its queues with. Explicit value, else '
147
- + 'ENV SERVICE_NAME, else the module default (../defaults.js serviceName).',
146
+ 'Name this client publishes under and names its queues with. Required: the explicit '
147
+ + 'value, else ENV SERVICE_NAME — there is no module default, because a name this '
148
+ + 'library invented would name a service that does not exist (d.770).',
148
149
  },
149
150
  connectionName: {
150
151
  type: 'string',
@@ -248,8 +249,24 @@ module.exports = {
248
249
  },
249
250
  publishMaxRetries: {
250
251
  type: 'integer',
251
- minimum: 0,
252
- description: 'How many publish attempts one message gets before the publish fails.',
252
+ // ONE, not zero (d.685b). The key counts ATTEMPTS, the first one included, so `0`
253
+ // is not a smaller amount of retrying — it is a publish that never happens: the
254
+ // loop `while (attempt < this._maxRetries)` does not run its body, the message is
255
+ // never offered to the broker and is not buffered on the way out either (measured,
256
+ // d.684). `PublishLayer`'s constructor has refused it by name since that batch,
257
+ // while this declaration still said it was legal — one key with two answers, and
258
+ // the one a service author READS said the wrong one. The declaration is the
259
+ // prescriptive half (`doc-code-binding.md` §1), so it is the half that moved.
260
+ //
261
+ // Both doors stay: this one refuses the value at CONSTRUCTION of a `BaseClient`,
262
+ // the layer's refuses it wherever a transport is built past the schema
263
+ // (`transports/transportFactory.js` validates nothing) or the caller hands the
264
+ // layer its own `options.maxRetries`.
265
+ //
266
+ // "Do not retry" is `publishMaxRetries: 1`; leaving the retry rail is
267
+ // `publishRetryEnabled: false`. Both are explicit (`architecture-principles.md` §8).
268
+ minimum: 1,
269
+ description: 'How many publish attempts one message gets before the publish fails; at least 1, because the first attempt is one of them.',
253
270
  },
254
271
  publishRetryBaseDelay: {
255
272
  type: 'integer',
@@ -16,9 +16,15 @@
16
16
  * (`change-discipline.md`), and the `||` was worse than the duplication: it means
17
17
  * "if falsy", not "if absent", and the schema declares `minimum: 0` for these
18
18
  * knobs. A caller who wrote `reconnectBaseDelay: 0` ("retry immediately"),
19
- * `publishMaxRetries: 0` ("do not retry") or `thrashingThreshold: 0` ("alert on
20
- * the first close") had the value replaced by its opposite, without a word.
21
- * Measured on 2026-09-14: twelve knobs swallowed a written `0`.
19
+ * `publishRetryBaseDelay: 0` ("no wait between attempts") or `thrashingThreshold: 0`
20
+ * ("alert on the first close") had the value replaced by its opposite, without a
21
+ * word. Measured on 2026-09-14: twelve knobs swallowed a written `0`.
22
+ *
23
+ * `publishMaxRetries: 0` was named among those examples until d.684, glossed as "do
24
+ * not retry". It never meant that: the key counts ATTEMPTS, the first one included
25
+ * (see it below, and `configSchema.js`), so `0` says "never offer this message to the
26
+ * broker" — which is what the loop did. It is now refused by name at construction
27
+ * (`layers/PublishLayer.js`); "one attempt, no retry" is `1`.
22
28
  *
23
29
  * `??` was not the answer either. It fixes the falsy half and leaves the value
24
30
  * written in two places, which is the half that rots.
@@ -100,7 +106,11 @@ module.exports = {
100
106
 
101
107
  publishRetryEnabled: true,
102
108
 
103
- // How many attempts one publish gets before it is buffered or reported.
109
+ // How many attempts one publish gets before it is buffered or reported — the first
110
+ // attempt included, so `1` is "one attempt, no retry". At least 1: `0` would mean the
111
+ // message is never offered to the broker, and it is refused by name rather than
112
+ // obeyed (`layers/PublishLayer.js`, d.684). To leave the retry rail entirely, write
113
+ // `publishRetryEnabled: false`.
104
114
  publishMaxRetries: 3,
105
115
 
106
116
  // First wait between two attempts, in ms, multiplied by
@@ -51,16 +51,22 @@
51
51
  * environment form goes through numeric coercion, because an environment
52
52
  * variable has no other shape (`../config.js`, `../defaults.js`).
53
53
  *
54
- * Both refusals are raised at the throw site itself
55
- * (`transports/rabbitmqClient.js` `consume()`), not here: a message this library
56
- * hands a caller must open with the context that produced it
57
- * (`architecture-principles.md` §5), and a context passed in as a parameter is
58
- * invisible to the guard that checks that
59
- * (`tests/unit/error-message-contract.test.js`).
54
+ * The option rules — `requeueOnError` a boolean, `requeueOnError: false` meaning
55
+ * one attempt and contradicting any other `maxAttempts`, the budget an integer
56
+ * >= 1, `classify` a function — live in `validateConsumeOptions` below, the one
57
+ * owner the transport's `consume()` calls and the package exports (d.1005), so a
58
+ * consumer's test double asks the same function instead of copying the rules.
59
+ * Every refusal opens with the literal context of the call it guards,
60
+ * `[RabbitMQClient]`, written at the throw site: a context passed in as a
61
+ * parameter would be invisible to the guard that checks it
62
+ * (`tests/unit/error-message-contract.test.js`), and the texts are the ones
63
+ * `consume()` has always thrown.
60
64
  *
61
65
  * @see ../../README.md § consume() — the delivery contract
62
66
  */
63
67
 
68
+ const { ValidationError } = require('../utils/errorHandler');
69
+
64
70
  /** Header carrying how many attempts a message has already cost. Never a literal elsewhere. */
65
71
  const ATTEMPTS_HEADER = 'x-oa-delivery-attempts';
66
72
 
@@ -153,6 +159,79 @@ function classifyError(classify, error) {
153
159
  return { classification: answer, failure: null };
154
160
  }
155
161
 
162
+ /**
163
+ * Validate the options of `consume()` and resolve the delivery policy they ask for.
164
+ *
165
+ * `requeueOnError` is the SAME policy as `maxAttempts` said in one word, never a
166
+ * second one: `false` means a budget of one attempt, `true` or absent leaves the
167
+ * budget to `maxAttempts` or the default. `requeueOnError: false` together with
168
+ * any `maxAttempts` other than 1 is refused rather than resolved by preferring
169
+ * one of the two. An explicit `maxAttempts` is taken exactly as written — never
170
+ * coerced. `classify` defaults to `defaultClassification`.
171
+ *
172
+ * @param {Object} options the options passed to `consume()`
173
+ * @param {function(): number} resolveDefaultMaxAttempts returns the budget when
174
+ * the caller names none — the transport resolves its configuration there
175
+ * (explicit → env RABBITMQ_MAX_DELIVERY_ATTEMPTS → defaults.js). Called ONLY
176
+ * when the options leave the budget open: resolving it reads configuration,
177
+ * which can refuse on its own, and an explicit `maxAttempts` or
178
+ * `requeueOnError: false` never needed it. Its value is validated like an
179
+ * explicit one.
180
+ * @returns {{maxAttempts: number, classify: function(Error): string}}
181
+ * @throws {ValidationError} the first rule the options break, in the words
182
+ * `consume()` refuses them with
183
+ */
184
+ function validateConsumeOptions(options, resolveDefaultMaxAttempts) {
185
+ if (options.requeueOnError !== undefined && typeof options.requeueOnError !== 'boolean') {
186
+ throw new ValidationError(
187
+ '[RabbitMQClient] Invalid requeueOnError - Expected: true or false, got '
188
+ + `${JSON.stringify(options.requeueOnError)}. Fix: pass consume(queue, handler, `
189
+ + '{ requeueOnError: false }) to dead-letter on the first failure, or leave it out and '
190
+ + 'let the delivery budget decide.'
191
+ );
192
+ }
193
+
194
+ const noRequeue = options.requeueOnError === false;
195
+
196
+ if (noRequeue && options.maxAttempts !== undefined && options.maxAttempts !== 1) {
197
+ throw new ValidationError(
198
+ '[RabbitMQClient] Contradictory delivery policy - requeueOnError: false means one attempt, '
199
+ + `but maxAttempts says ${JSON.stringify(options.maxAttempts)}. Expected: one statement about `
200
+ + 'how many times a handler may run. Fix: drop requeueOnError to keep the budget, or drop '
201
+ + 'maxAttempts to dead-letter on the first failure.'
202
+ );
203
+ }
204
+
205
+ const maxAttempts = noRequeue
206
+ ? 1
207
+ : options.maxAttempts === undefined
208
+ ? resolveDefaultMaxAttempts()
209
+ : options.maxAttempts;
210
+
211
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
212
+ throw new ValidationError(
213
+ '[RabbitMQClient] Invalid delivery budget - maxAttempts must be an integer >= 1, got '
214
+ + `${JSON.stringify(maxAttempts)}. Fix: pass consume(queue, handler, { maxAttempts: <integer >= 1> }), `
215
+ + 'or leave it out and let the module default (defaults.js maxDeliveryAttempts, '
216
+ + 'env RABBITMQ_MAX_DELIVERY_ATTEMPTS) decide.'
217
+ );
218
+ }
219
+
220
+ const classify = options.classify === undefined
221
+ ? defaultClassification
222
+ : options.classify;
223
+
224
+ if (typeof classify !== 'function') {
225
+ throw new ValidationError(
226
+ '[RabbitMQClient] Invalid error classifier - classify must be a function returning '
227
+ + `'transient' or 'permanent', got ${typeof options.classify}. Fix: pass consume(queue, handler, `
228
+ + '{ classify: (error) => … }), or leave it out and every error is treated as transient.'
229
+ );
230
+ }
231
+
232
+ return { maxAttempts, classify };
233
+ }
234
+
156
235
  module.exports = {
157
236
  ATTEMPTS_HEADER,
158
237
  TRANSIENT,
@@ -162,4 +241,5 @@ module.exports = {
162
241
  defaultClassification,
163
242
  readAttemptsMade,
164
243
  classifyError,
244
+ validateConsumeOptions,
165
245
  };