@onlineapps/mq-client-core 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.0.1] — 2026-09-14
8
+
9
+ - **Fixed: `consume()` accepts a TERMINAL queue — one this configuration dead-letters TO — instead of demanding a dead-letter route it must not have.** The d.259 gate refuses a queue for which `queueConfig` declares no `x-dead-letter-exchange`/`-routing-key`, because the policy ends a spent message with `nack(requeue=false)` and the broker drops what it cannot move. True of every working queue — and false of the queues that RECEIVE those moves: `workflow.failed` is named in `x-dead-letter-routing-key` by `workflow.init` and `workflow.control`, `<service>.dlq` by every business template, `workflow.dlq` and `delivery.dlq` by their own families, and the confirmed topology leaves exactly those without a route of their own (`api/docs/governance/confirmations/mq-consumer-contract.md` 002 point 2, 003 point 2 — "workflow.failed and workflow.dlq themselves keep no route"). A dead-letter route on a dead-letter queue is a loop or a chain with no end. So `workflow.failed` — the operator's queue, read by the delivery dispatcher — was unconsumable: `[BaseClient] Cannot consume from queue "workflow.failed": queueConfig declares no dead-letter route`. The gate now asks a second question, `queueConfig.isDeadLetterTarget(queue)`, **derived from the templates** (the set of every declared `x-dead-letter-routing-key`, `{service}` placeholder read through the same `templateShape()` the business templates use) rather than from a list of names that would have to be edited in step with them — the defect d.278 removed from `matchBusinessTemplate()`. The `.dlq` family needs no rule of its own: every `.dlq` this configuration declares is already the target of a declared routing key (measured 2026-09-14). Every other queue with no route is refused exactly as before, with the same sentence. A message that spends its budget on a terminal queue is rejected the same way and then DROPPED by the broker — the end of the chain is the end — never requeued for ever, and the loss is now logged at error level (`Message discarded on terminal queue "<queue>"`, with the queue, the attempts and the error) before the `message_dlq` event is published. Tests: new unit `consume-terminal-queue.test.js` (10 — the derivation from the declared keys, the terminal names and the non-terminal control, `consume('workflow.failed')` and `consume('<svc>.dlq')` attaching, the three refusals that must not change with the refusal verbatim, the discard with its log line and meta, and a routed queue as the control that says nothing about a discard); integration `dead-letter-route-required.integration.test.js` (+2 against the live broker: a message rejected from `<svc>.queue` reaches `<svc>.dlq` and is DELIVERED to a consumer there with `x-death[0].reason = 'rejected'`, and a handler failing on that terminal queue leaves the queue empty with no second delivery after 2 s — the drop no mock can measure). Three tests that encoded the old rule follow the new one: the `<svc>.dlq` refusal leg of `consume-dead-letter-route-required.test.js` (its subject moved to the new file), and the two integration control queues that used a dead-letter queue as "the queue with no route" (`delivery-events-lookup` → `monitoring.workflow`, `queue-declaration-options-one-rail` → `<svc>.events`).
10
+
11
+ ## [3.0.0] — 2026-09-14
12
+
7
13
  - **Changed (BREAKING for a reader of the log, not for a caller): the credential redaction removes the WHOLE userinfo, not just the password.** `utils/redactCredentials.js` masked the password (`amqp://oa_dev:***@queuer:5672`) and kept the account name, while the platform's two other redactors — `shared/service-common/src/redactUrl.js` (d.245/d.245b) and `shared/connector/conn-orch-registry/src/redactUrl.js` (d.446, whose header says plainly that it stayed local because this package masked the password only) — drop the userinfo entirely. One concern, two semantics, which is two rails (`.claude/rules/change-discipline.md` § One rail per concern). The platform one wins: `amqp://queuer:5672/vhost?heartbeat=30`, the account name gone with the password, because a name is the other half of the same credential and a log line needs neither. A value that is not a parseable URL is answered with `<unparseable-url>` instead of being echoed — previously a non-URL string came back verbatim (`api_services_queuer`) or with a `***@` prefix, and an unparseable value is exactly the case where nobody can say whether it holds a credential. `redactConnectionTarget()` still THROWS on a non-string: it guards a declared config key, where a wrong type is a boot-time defect. Tests: unit `redact-credentials.test.js` (rewritten to the new semantics, +6 over the package: the userinfo cases, the transport's `?heartbeat` query as a control, the placeholder cases and the export identity), `connect-credentials-not-logged.test.js` and `one-owner-a-floor-down.test.js` (both now assert the account name is absent too), integration `credential-redaction.integration.test.js` (+2: the LIVE broker accepts the real credential while no logger argument carries either half of it, and a real 403 refusal leaks nothing into the error, the stack or the log) (d.448).
8
14
  - **Added:** `redactUrl` and `UNPARSEABLE_PLACEHOLDER` are exports of `src/index.js`. The dependants that render an AMQP URL into a log line or an error message (`conn-orch-registry`, `conn-infra-mq`) each carry a local copy of the same function; a copy can only be replaced by an import if the package declares one (d.448).
9
15
  - **Fixed:** a reconnect wait ends only on an error that carries NO publish classification. d.435 taught `_waitForReconnection()` to skip a RETRYABLE publish failure, which left the same defect standing one class of error further out: a `PermanentPublishError` or a `QueueNotFoundError` from a concurrent publish still rejected every waiter, although "no retry will help this message" says nothing about whether the link is coming back. The condition is now the PRESENCE of the classification, not its verdict — `utils/publishErrors.js` sets `retryable` on every error it produces, so an error that never went through that rail carries none, and that absence is the whole test (plus the transport's own typed close, which never ended the wait). No second list of codes. Tests: unit `wait-for-reconnection-not-ended-by-a-foreign-publish.test.js` (+4, both permanent classifications built by the real rail, controls for an unclassified error and for the typed close), integration `…integration.test.js` (+1: the live broker refuses a publish to a name no declaration owns with a real 404, and that real `QueueNotFoundError` is put on the channel during a real outage — a permanent classification cannot arise while the link is down, where everything classifies transient) (d.435b).
package/README.md CHANGED
@@ -216,6 +216,24 @@ broker cannot be asked, because `checkQueue()` answers with `queue.declare-ok`,
216
216
  carries `{ queue, messageCount, consumerCount }` and no arguments (measured, 2026-09-12).
217
217
  `queueConfig.getDeadLetterRoute(queue)` is the exported form of the same answer.
218
218
 
219
+ **A TERMINAL queue is consumed, and it is not an exception to that rule but its other
220
+ half.** A queue something else dead-letters TO — `<service>.dlq`, `workflow.failed`,
221
+ `workflow.dlq`, `delivery.dlq` — declares no onward route because the topology says it
222
+ must not have one: a dead-letter route on a dead-letter queue closes a loop or starts a
223
+ chain with no end (`api/docs/governance/confirmations/mq-consumer-contract.md` 002
224
+ point 2, 003 point 2). So the gate asks a second question, `queueConfig.isDeadLetterTarget(queue)`,
225
+ derived from the templates themselves — the set of every `x-dead-letter-routing-key` they
226
+ declare, `{service}` placeholder and all — never from a list of names. Every OTHER queue
227
+ with no route is refused exactly as before, with the same sentence.
228
+
229
+ What a terminal queue does NOT get is a different delivery policy: the budget is counted
230
+ the same way, and a message that spends it is rejected with `nack(requeue=false)` the same
231
+ way. The difference is what the broker then does with it — there is nowhere to move it to,
232
+ so it is **dropped**. That loss is deliberate (the alternative is the endless requeue this
233
+ policy exists to end) and it is never silent: the client logs it at error level, naming
234
+ the queue, the attempts and the error, and the `message_dlq` event is published as for any
235
+ other rejection.
236
+
219
237
  ```javascript
220
238
  await client.consume(queue, async (msg, delivery) => {
221
239
  // delivery = { attempt, maxAttempts, isFinalAttempt }
@@ -527,12 +545,18 @@ const { queueConfig } = require('@onlineapps/mq-client-core');
527
545
  queueConfig.isInfrastructureQueue('workflow.init'); // true
528
546
  queueConfig.isBusinessQueue('emailer.workflow'); // true
529
547
  queueConfig.getInfrastructureQueueConfig('workflow.init'); // { durable, arguments }
548
+ queueConfig.getDeadLetterRoute('workflow.init'); // { exchange: '', routingKey: 'workflow.failed' }
549
+ queueConfig.isDeadLetterTarget('workflow.failed'); // true — the end of that chain
530
550
  ```
531
551
 
532
552
  - `workflow.failed` carries no `x-message-ttl`. It is read by an operator, not by a
533
553
  service, and it has no dead-letter exchange, so a TTL there does not move a message
534
554
  anywhere — it deletes it. See
535
555
  `api/docs/governance/confirmations/mq-consumer-contract.md` 002.
556
+ - `isDeadLetterTarget()` answers "does this configuration dead-letter TO this name",
557
+ derived from the declared routing keys rather than from a list. It is what lets
558
+ `consume()` attach to `workflow.failed` and to any `<service>.dlq` while every other
559
+ queue with no declared route stays refused — see § `consume()` — the delivery contract.
536
560
 
537
561
  **That require is the contract.** Reaching into
538
562
  `@onlineapps/mq-client-core/src/config/queueConfig` is an internal path the package
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/mq-client-core",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
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
@@ -502,6 +502,14 @@ class BaseClient {
502
502
  * `message_dlq` event would report a destination nothing routed to. The fix is
503
503
  * always to declare the route, never to consume without one.
504
504
  *
505
+ * The one queue that demand cannot be made of is the END of a chain — a queue
506
+ * this configuration dead-letters TO (`<service>.dlq`, `workflow.failed`). The
507
+ * topology withholds its route deliberately, so the gate reads a second,
508
+ * derived answer (`queueConfig.isDeadLetterTarget()`) and lets its consumer
509
+ * attach (d.452). A message that spends its budget there is rejected as
510
+ * anywhere else and then dropped by the broker, with an error line naming the
511
+ * loss — never an endless requeue. README § `consume()` — the delivery contract.
512
+ *
505
513
  * @param {string} queue - Name of the queue to consume from.
506
514
  * @param {function(Object, {attempt: number, maxAttempts: number, isFinalAttempt: boolean}): Promise<void>} messageHandler -
507
515
  * Async function to process each message. Its second argument says which
@@ -100,6 +100,10 @@ const TEMPLATE_PLACEHOLDER = /^\{[^.{}]+\}$/;
100
100
  * service name, with `null` where the template accepts anything (`registry.events`
101
101
  * -> `['registry', 'events']`; a key writing `registry.{kind}` would give
102
102
  * `['registry', null]`).
103
+ *
104
+ * A declared dead-letter routing key is written in the same notation
105
+ * (`{service}.dlq`), so `isDeadLetterTarget()` reads it through this one function
106
+ * rather than through a second placeholder rule.
103
107
  */
104
108
  const templateShape = (template) =>
105
109
  template.split('.').map((part) => (TEMPLATE_PLACEHOLDER.test(part) ? null : part));
@@ -169,6 +173,41 @@ const declaredInfrastructureNames = (config) => {
169
173
  return names;
170
174
  };
171
175
 
176
+ /**
177
+ * Every name this configuration dead-letters TO — the END of a chain.
178
+ *
179
+ * Derived from the declarations themselves: the value of every
180
+ * `x-dead-letter-routing-key` any section writes, placeholders and all
181
+ * (`{service}.dlq`, `workflow.failed`, `workflow.dlq`, `delivery.dlq`). A list of
182
+ * names typed here would have to be edited in step with the templates and could
183
+ * therefore disagree with them — the defect d.278 removed from
184
+ * `matchBusinessTemplate()`, and the reason `declaredInfrastructureNames()` above
185
+ * derives its set the same way.
186
+ *
187
+ * The `.dlq` family needs no rule of its own: every `.dlq` this configuration
188
+ * declares — `workflow.dlq`, `delivery.dlq`, `{service}.dlq` — is already the
189
+ * target of a declared routing key (measured 2026-09-14), so the set covers it.
190
+ * A `.dlq` nothing routes to would be a queue with no writer, which is a defect
191
+ * in the declaration, not a case to paper over here.
192
+ *
193
+ * @param {Object} config - The queueConfig object (its sections are read from `this`).
194
+ * @returns {Set<string>} The routing keys, e.g. `workflow.failed`, `{service}.dlq`.
195
+ */
196
+ const declaredDeadLetterTargets = (config) => {
197
+ const targets = new Set();
198
+
199
+ for (const section of Object.values(config)) {
200
+ if (section === null || typeof section !== 'object') continue;
201
+ for (const entry of Object.values(section)) {
202
+ if (entry === null || typeof entry !== 'object') continue;
203
+ const routingKey = entry.arguments && entry.arguments['x-dead-letter-routing-key'];
204
+ if (typeof routingKey === 'string' && routingKey !== '') targets.add(routingKey);
205
+ }
206
+ }
207
+
208
+ return targets;
209
+ };
210
+
172
211
  /**
173
212
  * The two ways out of "this configuration declares no such queue", named in the
174
213
  * refusal itself.
@@ -1222,6 +1261,51 @@ module.exports = {
1222
1261
  return { exchange, routingKey };
1223
1262
  },
1224
1263
 
1264
+ /**
1265
+ * Is this queue the END of a dead-letter chain — a queue something
1266
+ * dead-letters TO?
1267
+ *
1268
+ * The counterpart of `getDeadLetterRoute()`, and the reason a queue may have no
1269
+ * route without that being a defect. `workflow.failed` is named in
1270
+ * `x-dead-letter-routing-key` by `workflow.init` and `workflow.control`;
1271
+ * `{service}.dlq` by every business template; `workflow.dlq` and `delivery.dlq`
1272
+ * by their own families. Giving such a queue a route of its own would either
1273
+ * close a loop or start an endless chain, which is why the confirmed topology
1274
+ * leaves them without one (`docs/governance/confirmations/mq-consumer-contract.md`
1275
+ * 002 point 2, 003 point 2 — "workflow.failed and workflow.dlq themselves keep
1276
+ * no route").
1277
+ *
1278
+ * The consumer's dead-letter gate reads this second question, so a terminal
1279
+ * queue is consumable while every OTHER queue with no declared route stays
1280
+ * refused (`transports/rabbitmqClient.js`, d.452). What happens to a message
1281
+ * that fails there is unchanged and stated in the README: the budget is spent,
1282
+ * `nack(requeue=false)` is issued, the broker DROPS the message — the end of the
1283
+ * chain is the end — and the loss is logged as an error rather than turned into
1284
+ * an endless requeue.
1285
+ *
1286
+ * Derived from the templates (`declaredDeadLetterTargets()`), never from a list
1287
+ * of names: a `{placeholder}` segment matches any one part, exactly as a
1288
+ * business template key does.
1289
+ *
1290
+ * @param {string} queueName - Full queue name.
1291
+ * @returns {boolean} True if this configuration dead-letters to this name.
1292
+ */
1293
+ isDeadLetterTarget(queueName) {
1294
+ if (typeof queueName !== 'string' || queueName === '') return false;
1295
+
1296
+ const parts = queueName.split('.');
1297
+
1298
+ for (const target of declaredDeadLetterTargets(this)) {
1299
+ const shape = templateShape(target);
1300
+ if (shape.length !== parts.length) continue;
1301
+ if (shape.every((part, index) => (part === null ? parts[index] !== '' : part === parts[index]))) {
1302
+ return true;
1303
+ }
1304
+ }
1305
+
1306
+ return false;
1307
+ },
1308
+
1225
1309
  /**
1226
1310
  * Get infrastructure queue configuration by queue name (auto-detect type)
1227
1311
  *
@@ -3073,7 +3073,26 @@ class RabbitMQClient extends EventEmitter {
3073
3073
  // arguments (measured, 2026-09-12 — see queueConfig.getDeadLetterRoute()).
3074
3074
  // The caller's own `queueOptions` do not count either; a route supplied at
3075
3075
  // the call site would be a second declaration of one queue's topology.
3076
- if (queueConfig.getDeadLetterRoute(queue) === null) {
3076
+ //
3077
+ // The ONE queue this demand cannot be made of is the one at the END of a
3078
+ // chain: a queue something else dead-letters TO. `workflow.failed` is named
3079
+ // in `x-dead-letter-routing-key` by `workflow.init` and `workflow.control`,
3080
+ // `<service>.dlq` by every business template — and the confirmed topology
3081
+ // leaves exactly those without a route of their own, because a dead-letter
3082
+ // route on a dead-letter queue is a loop or a chain with no end
3083
+ // (`mq-consumer-contract` 002 point 2, 003 point 2). Until d.452 the gate
3084
+ // asked only the first question, so the operator's own queue was
3085
+ // unconsumable and its reader — the delivery dispatcher — could not attach
3086
+ // at all. The second question is derived from the templates
3087
+ // (`queueConfig.isDeadLetterTarget()`), never from a list of names, so it
3088
+ // cannot fall behind them; every other queue with no route is refused
3089
+ // exactly as before, with the same sentence.
3090
+ //
3091
+ // A message that spends its budget on a terminal queue is REJECTED as
3092
+ // anywhere else and then dropped by the broker, which is the end of the
3093
+ // chain doing its job. It is never requeued for ever, and the loss is said
3094
+ // out loud in `_rejectDelivery()`.
3095
+ if (queueConfig.getDeadLetterRoute(queue) === null && !queueConfig.isDeadLetterTarget(queue)) {
3077
3096
  throw new ConsumeError(
3078
3097
  `[RabbitMQClient] consume(${queue}): the dead-letter policy needs a dead-letter route and `
3079
3098
  + 'queueConfig declares none for this queue - '
@@ -4136,13 +4155,21 @@ class RabbitMQClient extends EventEmitter {
4136
4155
  * `status`.
4137
4156
  *
4138
4157
  * The `onDeadLetter` hook — and therefore the `message_dlq` event it publishes —
4139
- * is only ever reached for a message that CAN be dead-lettered: `consume()`
4140
- * refuses to register a consumer on a queue for which `queueConfig` declares no
4158
+ * is reached for a message that CAN be dead-lettered: `consume()` refuses to
4159
+ * register a consumer on a queue for which `queueConfig` declares no
4141
4160
  * `x-dead-letter-exchange`/`-routing-key` (d.259), so by the time a delivery
4142
4161
  * arrives here the route exists by declaration. Without that gate the same
4143
4162
  * `nack(requeue=false)` made the broker DROP the message while this event
4144
4163
  * announced it had reached `.dlq` — a report of a destination nothing routed to.
4145
4164
  *
4165
+ * ONE class of queue reaches this method with no route, and it is not that
4166
+ * defect: the END of a chain, a queue something else dead-letters TO
4167
+ * (`<service>.dlq`, `workflow.failed`), whose consumer the gate admits on
4168
+ * purpose (d.452). There the rejection does not move the message anywhere — the
4169
+ * broker drops it, because the end of the chain is the end — so the loss is
4170
+ * logged at error level below before monitoring is told. What is NOT done is
4171
+ * inventing a requeue for it: that is the unbounded loop d.198b ended.
4172
+ *
4146
4173
  * @param {Object} args
4147
4174
  * @private
4148
4175
  */
@@ -4178,6 +4205,29 @@ class RabbitMQClient extends EventEmitter {
4178
4205
  return;
4179
4206
  }
4180
4207
 
4208
+ // A TERMINAL queue — one this configuration dead-letters TO — declares no
4209
+ // onward route by design, so the rejection just issued does not MOVE the
4210
+ // message: the broker drops it. That is the end of the chain working as
4211
+ // intended, and the alternative would be the unbounded requeue d.198b ended.
4212
+ // It is still a message the platform has lost, so it is said out loud, at
4213
+ // error level, exactly once, before monitoring is told (d.452).
4214
+ if (queueConfig.getDeadLetterRoute(queue) === null) {
4215
+ this._logger.error(
4216
+ `[RabbitMQClient] [mq-client-core] [CONSUMER] Message discarded on terminal queue "${queue}" - `
4217
+ + 'Expected: a terminal queue is the end of the dead-letter chain, so it declares no onward '
4218
+ + 'route and the broker DROPS what nack(requeue=false) rejects instead of moving it. '
4219
+ + 'Fix: nothing to declare — the delivery budget is spent and the message is deliberately gone '
4220
+ + 'rather than circling for ever; read the error and fix what made the handler fail.',
4221
+ {
4222
+ queue,
4223
+ attempts,
4224
+ maxAttempts,
4225
+ classification,
4226
+ error: error && error.message ? error.message : null
4227
+ }
4228
+ );
4229
+ }
4230
+
4181
4231
  if (onDeadLetter === undefined) {
4182
4232
  // Driving this transport directly is allowed (it is an export of this
4183
4233
  // package), but then nothing owns the monitoring rail, and a message