@onlineapps/mq-client-core 2.0.1-rc.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +153 -0
- package/README.md +491 -8
- package/package.json +12 -8
- package/src/BaseClient.js +953 -80
- package/src/buffer/InMemoryBuffer.js +50 -9
- package/src/buffer/MessageBuffer.js +20 -52
- package/src/config/composeConfig.js +60 -0
- package/src/config/configSchema.js +398 -17
- package/src/config/defaultConfig.js +169 -15
- package/src/config/deliveryPolicy.js +165 -0
- package/src/config/queueConfig.js +738 -64
- package/src/config.js +29 -0
- package/src/defaults.js +43 -0
- package/src/index.js +91 -2
- package/src/layers/PublishLayer.js +83 -37
- package/src/monitoring/PublishMonitor.js +11 -4
- package/src/monitoring-publish.js +81 -54
- package/src/transports/rabbitmqClient.js +2698 -738
- package/src/transports/transportFactory.js +9 -2
- package/src/utils/errorHandler.js +83 -4
- package/src/utils/nearestKey.js +101 -0
- package/src/utils/publishErrors.js +95 -10
- package/src/utils/redactCredentials.js +106 -0
- package/src/utils/serializer.js +12 -2
- package/src/workers/RecoveryWorker.js +58 -81
- package/src/buffer/RedisBuffer.js +0 -57
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Changelog — @onlineapps/mq-client-core
|
|
2
|
+
|
|
3
|
+
All notable changes to this package. Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
- **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
|
+
- **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
|
+
- **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).
|
|
10
|
+
- **Fixed (docs):** the queue-ownership table in `README.md` said a name nobody owns is "created with default options, if `recoveryScope` allows it". d.419 removed that door: the publish path refuses the name with `QueueNotFoundError` (`kind: 'unowned'`) and `RecoveryWorker` refuses it again behind the error. The row now says what the code does (d.435b).
|
|
11
|
+
- **Fixed:** a reconnect wait ends on the CONNECTION's outcome, never on somebody else's publish. `_publishOnce()` emits every classified publish failure on the client's `error` channel, and `_waitForReconnection()` listened there for anything that was not the transport's own typed close — so ONE concurrent publish that failed transiently while the link was coming back rejected EVERY waiter: `PublishLayer` buffered their messages and reported `publish:failed` with `reconnectFailed: true`, while the recovery it declared failed was still running and about to succeed. The wait now ignores a RETRYABLE publish classification, read from the one classification rail (`utils/publishErrors.js`) by the `retryable` flag that rail sets — no second list of codes. Everything else on that channel still ends the wait, with itself as the reason.
|
|
12
|
+
- **Fixed (BREAKING: `classifyPublishError(err, queue)` and `isQueueUnavailableError(err, queue)` require the queue name and refuse a call without it):** a missing-queue error carries a real queue name or is not built. The classification turned a bare 404 into `new QueueNotFoundError('unknown', …)`, and the invented name does not stay in the message: the error reaches `RecoveryWorker` through the client's `error` channel, which reports it by `error.queueName` — "Cannot create queue 'unknown'", naming nothing anybody can act on. Both callers had the real name in hand (`_publishOnce(queue, …)`, `publishToMonitoringResilient(…, queueName, …)`), so it is now an argument validated at entry — the same defect this package retired for the workflow id in d.416: a missing fact is reported as missing, and `'unknown'` is a value nobody measured.
|
|
13
|
+
- **Removed (BREAKING):** a publisher brings NO queue into being — not even one nobody owns. `publish()` to a name `queueConfig.declarationOptions()` answers `null` for is refused by name (`QueueNotFoundError`, `kind: 'unowned'`, `[RabbitMQClient] Queue <q> has no owner in queueConfig - Expected: … Fix: …`) and nothing is declared. With it go `config.queueCreationFilter`, `config.queueCreationCallback`, `RecoveryWorker.createQueue()` and `RecoveryWorker.queueCreationRefusal()`. The concept was already written and this package kept one door open against it: `docs/governance/confirmations/mq-consumer-contract.md` 006 — *"queues come into being only from declarations (`queueConfig`, `setupServiceQueues()` from the business templates)"*, and *"A future family of queues that a service must create ad hoc gets a `queueConfig` template first (entry 003), never a generic create-anything method."* Measured live before this batch: the publish path's 404 branch declared such a name `durable=true, arguments={}` — no TTL, no dead-letter route — and `consume()` refuses exactly such a queue since d.259, so the door produced queues that could be published into and never consumed from. Two doors led there and both are shut: the publish path's own declaration, and `RecoveryWorker.handleQueueNotFound()`, which answered the very `QueueNotFoundError` that refusal raised by declaring the queue anyway — the "refuse at one door while opening the other" this worker's business branch already records, one class of name further out. The worker now reports which rule the reader must act on and rethrows, for all three classes.
|
|
14
|
+
**The four questions** (`.claude/rules/change-discipline.md` § Removing something removes its declaration): **(1) why it existed** — both callbacks and `createQueue()` arrived with the library extraction (`7a47b2fa`, 2025-11-18, transport and worker written in one go) and the config plumbing with `3588b148` (2025-12-01); they were the operator's say over which ad-hoc names a client might declare for itself, from a time when no central template existed to declare them from. **(2) which part of the concept carried it** — `RecoveryWorker`'s own header, § Queue Creation: *"ostatní jména … smí založit, pokud to scope a `queueCreationFilter` dovolují"*. That part does **not** still stand: conf `mq-consumer-contract` 006 replaced it, in the same decision that deleted `ConnectorMQClient.ensureQueue()` and `QueueManager.ensureQueue()` — *"deleted together with the `RecoveryWorker` callback that fed them (replaced by fail-fast on an undeclared name)"*. **(3) why nothing reads it today** — it gave way to a decision, and to d.259 before it: a queue born this way has no declared dead-letter route, so `consume()` refuses it; the only in-tree consumer of the callback, `conn-infra-mq`, had already turned its own into a refusal that throws (d.351), i.e. the mechanism was being used to say "no" rather than to create. **(4) is the replacement more conceptual** — yes: a queue now has exactly one way into existence, its declaration, and exactly one owner, so the answer to "may this be created" is no longer split between a scope, a filter, a callback and two call sites that could disagree.
|
|
15
|
+
**Not narrowed:** `assertQueue()` still declares an unclassified name for the caller that owns it — an RPC reply queue, a temporary test queue, with the lifetime keys of d.396c. What this batch removes is the PUBLISHER's door, not the declaration rail. Tests: `tests/unit/publish-refuses-a-queue-without-an-owner.test.js` (10) and `tests/integration/publish-refuses-a-queue-without-an-owner.integration.test.js` (3), the live one reading the absence from the management API and re-proving it after a queue-channel recreate. Retired with their subject: `ownerless-queue-creation-one-rail` (unit + integration), `ownerless-queue-creation-needs-a-verdict`, `config-schema-queue-creation-keys`, `recoveryWorker-client-public-api`, `publish-queue-declaration-one-rail` (unit + integration); `recoveryWorker-ownership` and the live `publish-queue-ownership` control case now pin the refusal instead of the creation (d.419).
|
|
16
|
+
**For the caller:** `conn-infra-mq` as published still passes `queueCreationCallback` (it pins `@onlineapps/mq-client-core` 2.0.1, which still declares the key). The schema is CLOSED, so when that pin moves the key is refused at construction **by name** rather than ignored — pinned by `tests/unit/config-schema-declares-every-key-src-reads.test.js`. The connector's own refusing callback (`src/ConnectorMQClient.js`) and its two suites (`recoveryQueueCallback`, `undeclaredQueueRefusal`) go in the batch that moves the pin, not in this one.
|
|
17
|
+
|
|
18
|
+
- **Changed:** the transport reads `workflow_id` in ONE spelling, and reports an absent one as absent. The three structured log lines that carry the workflow id — `PUBLISH_START`, `PUBLISH_CONFIRMED` and `MSG_RECEIVED`/`MSG_ACKED` — each read it as `headers.workflowId || headers.workflow_id || 'unknown'`; all three now read it through one module-level function, `workflowIdOf(headers)`, which returns the non-empty `workflow_id` string or `null`. Two defects in one expression. **The camelCase alias:** the envelope contract admits one spelling and forbids *writing a reader that accepts the other at all*, not merely a producer that emits it (`docs/biz/70-contracts/workflow-message.md` §2.2, owner decision `docs/governance/confirmations/workflow-message-aliases.md` 001 — "Do not write a reader or a test that accepts a camelCase identifier"). `docs/standards/JSON_NAMING_CONVENTION.md` § alias read names this exact read as one of the three live ones and states the rule the pair moves by: the reader and the producer of an alias leave together. The producer is the gateway's webhook submit, which publishes a snake_case body under camelCase AMQP headers (`infra/api_gateway/routes/webhooks.js`); it is owned by INFRA, recorded in `infra/TODO.md`, and is cited here rather than touched. **The `'unknown'` marker:** a missing id is a fact, `'unknown'` is a fabricated one, and a line carrying it claims a workflow by that name. This package already held that decision one floor up — `BaseClient._emitDeadLetterEvent()` resolves an absent id to `null` and reports the absence, "worse, when a marker like `'unknown'` is put in its place — written as a row claiming a workflow by that name" (`docs/governance/confirmations/dlq-purge.md` 002) — so the same answer applies to the same package's logging path. All three sites are LOGGING sites and none of them gains a refusal: the transport does not validate the envelope, an envelope with no `workflow_id` is refused where the envelope is read (the wrapper's `InvalidEnvelopeError`, d.383), and a second refusal here would be a second rail for one concern (`change-discipline.md` § One rail per concern). What a caller sends is untouched: the alias is dropped from the READ, so a camelCase header the caller passes still reaches the broker verbatim — pinned as a control case. Tests: `tests/unit/workflow-id-one-spelling.test.js` (8) and `tests/integration/workflow-id-one-spelling.integration.test.js` (3), the live one proving that AMQP really does carry the camelCase key through to the consumer, so the assertion about ignoring it means something (d.416).
|
|
19
|
+
|
|
20
|
+
- **Changed:** a queue's declaration is computed in ONE place — `queueConfig.declarationOptions(name)` — and every path that declares a queue reads it there. **Who owns a queue's declaration is unchanged, and is the concept's answer, not this package's invention:** an infrastructure queue is declared by the infrastructure service that owns it, at boot (`docs/standards/queue-ownership.md` § Core Principle and the creation matrix); a business queue by its owning service through `setupServiceQueues()` after registration (§ Business Queue Isolation; `docs/governance/confirmations/mq-consumer-contract.md` 006 — "queues come into being only from declarations"); a **publisher declares neither** and refuses with `QueueNotFoundError` naming the owner, so publishing before the owner has booted cannot leave a queue with library defaults behind for the owner's own declaration to collide with; a **consumer** checks an infrastructure queue, declares the business queue it owns, and refuses any queue the config declares no dead-letter route for (d.259, conf 002/003). What was NOT in one place was the ARGUMENTS: `assertQueue()` — the public rail the publish path's 404 branch (d.281) and `RecoveryWorker.createQueue()` (d.277b) already go through — and `_prepareQueueForConsume()` each rebuilt `{ durable: cfg.durable !== false, arguments: { ...cfg.arguments } }` from its own lookup, and the consumer then declared on the RAW queue channel instead of the public rail: one concern, two implementations (`change-discipline.md` § One rail per concern). The two agreed on today's configuration, so nothing failed — which is precisely the cost: the guarantee rested on two copies agreeing, and `assertQueue` is idempotent only while the arguments match, so one edit to either copy answers whichever declarer the broker hears second with `406 PRECONDITION_FAILED` — met at the boot of the service whose consumer attaches second. The consumer now declares through the public `assertQueue()`, both paths read `declarationOptions()`, and the ordering of publisher and consumer is proved irrelevant on the live broker. Behaviour is otherwise unchanged: the ownership refusals, the CHECK (never assert) of an infrastructure queue, the dead-letter gate running BEFORE any declaration, and the 406 → `ConsumeError` wording all stand. **Added** with it: `missingQueueDefinitionMessage(queue, isInfrastructure)` in `utils/publishErrors.js` — the two "no queue definition" sentences the consumer hands out have one producer, checked at runtime beside its siblings, instead of a ternary at the `new ConsumeError` site where `tests/unit/error-message-contract.test.js` cannot reach it. RED (unit) 8 of 12 — `consume() declares its business queue through the public assertQueue() rail: Expected number of calls: 1, Received number of calls: 0`, and `TypeError: queueConfig.declarationOptions is not a function`. GREEN: unit 626/627 (the one red is the wave's foreign `logger-contract.test.js`), integration 161/161 on the live broker (d.410).
|
|
21
|
+
- **Fixed (BREAKING for a caller writing an option `assertQueue()` does not read):** `assertQueue(queue, options)` reads every option it is given, or refuses it by name. It rebuilt `queueOptions` from `durable` and `arguments` alone and dropped every other key without a word, so a caller asking for `{ durable: false, exclusive: true }` got a queue the broker reports as `"exclusive": false` — measured on the live broker (d.396), where the root integration suite believed it had declared a queue that dies with its connection, and an interrupted run left it standing with 0 consumers. A silently ignored option is worse than a rejected one: the caller acts on a guarantee nobody gave (`architecture-principles.md` §4, §8). The declared set is now `durable`, `arguments`, `exclusive`, `autoDelete`; anything else is refused in the same shape the config schema has used since d.291b — the key by name and the declared key it is closest to, or "no declared key is close to it". **Added** with it: `exclusive` and `autoDelete` are how a caller states a queue's LIFETIME, and no other rail offered it; a temporary queue that does not die with the thing that made it is residue on a shared broker. A lifetime key left out is left OUT of the declaration rather than sent as `false`, so re-declaring an existing queue carries nothing its first declaration did not. The central `queueConfig` keeps its ownership undiminished: it still decides `durable` and `arguments` for every infrastructure and business name, and a lifetime asked for on such a name is now REFUSED rather than overruled in silence — those queues outlive their declarer by design. The "did you mean" pair moved out of `BaseClient.js` into `src/utils/nearestKey.js` so both refusals share one implementation (`change-discipline.md` § One rail per concern); behaviour unchanged. **Cascade:** `@onlineapps/cookbook-router` `src/queueManager.js` `ensureQueue()` feeds its PUBLISH defaults into the queue declaration (`{ durable: true, persistent: true }`, `queueManager.js:12-16` and `:85-88`) — `persistent` is a publish option that this operation never read and now refuses. It must stop reaching `assertQueue()` before this version is pinned. RED (unit) 7 of 10; RED (integration, live broker) 5 of 6 — `Expected: true, Received: false` on the broker's own `exclusive`. GREEN: unit 611/612 (the one red is the foreign `logger-contract.test.js` of the wave), integration 157/157 (d.396c).
|
|
22
|
+
- **Added — queue `workflow.delivery_requested` (d.387, `34e650dd`):** the hand-over of a run to delivery used to ride on `workflow.completed`, the terminal message — so for a cookbook with `delivery.on_result`, `completed` meant "not finished yet". The owner gave the hand-over its own name (`delivery-receipt-chain` 002, confirmed by 003). `workflow.delivery_requested` is `durable`, TTL 300 000 ms and `x-max-length` 10 000 like `workflow.completed`, and dead-letters to `workflow.dlq` through the DEFAULT exchange (the mechanical rule of `mq-consumer-contract` 003). The name is composed by `queueConfig.queueName('workflow', 'delivery_requested')`; `initInfrastructureQueues()` derives the queue from the sections, so it is declared without another line anywhere. RED (unit): `Infrastructure queue config not found: workflow.delivery_requested`; RED (integration, live broker): the same in the topology suite. GREEN: unit 601/602 (the one red is the foreign `logger-contract.test.js` of the wave), integration 151/151.
|
|
23
|
+
- **Fixed (BREAKING for a caller that cancelled a consumer after a refused re-registration):** a consumer's tag dies with the channel that issued it. After a refused re-registration the registry kept the tag from a channel that no longer existed, and two readers took it for a live attachment: the health check counts `active` by whether the tag is set — so a client whose consumer the broker had just refused (406) reported `healthy: true`, `2/2 consumers active` and an empty `issues` (measured on the live broker) — and `cancelConsumer()` sent `basic.cancel` for a consumer the broker did not have (measured: it passed without error). The tag is now cleared on failure and `cancelConsumer()` refuses a consumer that is not attached with a message instead of a frame; the consumer STAYS tracked — the d.262 rule is unchanged, only "tracked" and "attached" are now two different facts. The meaning of `active` is unchanged (the tag is still what answers; it is now set only when it is true). The d.262 integration assertion read `'tag-before'` — it described the defect, not the rule — and is rewritten to `null` with the reason beside it (d.386b, `dc1c94d3`).
|
|
24
|
+
- **Fixed (BREAKING for a caller treating `healthy: true` as unchanged for a consumer client):** a client that TRACKS consumers and has none attached fails its health check, and says so in its own sentence — `No consumer of this client is attached - N consumer(s) tracked, 0 attached`. The snapshot already carried the two numbers, but the verdict was left to be inferred from the per-queue findings beside them, and those do not always exist: when the probe loop does not run at all — no connection, or a dead queue channel — every issue in the object names a channel, and nothing says the service has stopped consuming. A client that registered NO consumer is a different client, not a lesser failure, and stays healthy: the `OK` line now reads `no consumers registered - this client only publishes` instead of `0/0 consumers active`, the one sentence a reader got for both cases (measured 2026-09-08, INFRA-monitoring). Proved on the live broker in `tests/integration/health-verdict-names-the-consumers.integration.test.js`, where the BROKER closes the connection of a client whose consumer had really attached and really received a message (d.386).
|
|
25
|
+
- **Changed (BREAKING for any caller writing a key this client does not declare):** the configuration schema ends with `additionalProperties: false`. A name no side declares is refused in the `BaseClient` constructor, naming the key and the declared key it is closest to (edit distance, budget = a third of the longer name), or saying plainly that no declared key is close to it — a guess four edits away steers a reader away from the real answer, which is that the key is not a key. `error.details` carries `/<key>` for it, like any wrong type. The door stood open through d.286 for one measured reason, and the reason is now answered rather than waived: `ConnectorMQClient` (`@onlineapps/conn-infra-mq`) is a SUBCLASS that hands its own keys through the same object, so `BaseClient` takes a second constructor argument — `new BaseClient(config, extraProperties)` — through which a subclass declares the properties IT reads. Each side declares what it owns; a subclass redeclaring a library key is refused, naming it, and the library schema is never mutated. **Cascade:** four callers still write `queue`, a name read nowhere in this package since it was extracted (removed from the schema in d.292) — `api/infra/api_gateway/index.js`, `api/infra/api_delivery_dispatcher/src/services/DeliveryDispatcher.js`, `api/infra/api_meta_reader/src/index.js` and `@onlineapps/service-wrapper` `_initializeMQ()`. They must drop it before this version is pinned, and `@onlineapps/conn-infra-mq` must hand its `connectorConfigSchema.properties` to `super()`, or the client refuses to construct (d.291b).
|
|
26
|
+
- **Added:** the queue classification is swept EXHAUSTIVELY, not by example. `tests/unit/queue-name-collision.test.js` derives its input from `queueConfig` itself — every declared entry, and the cross product of every infrastructure prefix with every business template (`monitoring.workflow`, `workflow.dlq`, `delivery.queue`, …) — and holds that no name is claimed by both `isInfrastructureQueue()` and `isBusinessQueue()`, that no declared entry is ever claimed as business, and that a colliding name NOBODY declares still belongs to the service its template names. A new section, entry or template joins the sweep the moment it is declared. The classification itself is unchanged: d.279 already gave the declaration precedence over the prefix, and `monitoring.workflow` measures as infrastructure-only — the queue the monitoring consumer owns, asserted and bound in `api/infra/api_monitoring/src/consumer/topology.js` (d.374).
|
|
27
|
+
|
|
28
|
+
- **Fixed (BREAKING for a caller that wrote `routingKey: ''` and relied on getting the queue name):** the routing key a caller writes is the routing key the broker routes by — `''` included. `_publishOnce()` resolved it as `options.routingKey || queue`, so an explicitly written empty key (the one a fanout publish writes, and what `monitoring-publish.js` passes) was replaced by the QUEUE NAME. Measured on the live broker through a DIRECT exchange, which matches the key exactly: the message matched no binding and was discarded while the publish reported success — `messageCount: 0` where 1 was expected. Now asked with `=== undefined`, like the exchange one line above it and for the same reason; an unwritten key is still the queue name (d.369).
|
|
29
|
+
- **Fixed:** a wait for a reconnect ends WITH the `reconnected` event, not half a second after it. `_waitForReconnection()` scheduled its `resolve` through `this._setTimeout(resolve, 500)`, commented "wait a bit for channels to be fully recreated" — a guarantee that has been in the event's own meaning since d.340, which emits `reconnected` only once the three channels are back. Two costs, both now gone: every operation waiting out an outage paid a fixed 500 ms after the client was already usable, and — measured — a `disconnect()` inside that window cancelled the pending `resolve` along with every other tracked timer, leaving the awaiting publish unsettled for ever. The four questions of `change-discipline.md` § Removing are answered in `tests/unit/wait-for-reconnection-transient-close.test.js` (d.369).
|
|
30
|
+
- **Removed (BREAKING: `persistentBufferEnabled` and `persistentRedisClient` are refused by name):** the persistent publish buffer, which never existed. `buffer/RedisBuffer.js` was a placeholder — `add()` stored nothing, `flush()` returned 0 — and `MessageBuffer.add()` returned after that branch without falling back (the stub did not throw, so there was nothing to fall back from). With `persistentBufferEnabled: true` a critical-priority message that failed transiently was therefore DROPPED, while the same message at normal priority was held in memory and replayed after the reconnect: the key inverted its own promise. The package's own concept node already said so in writing (`docs/architecture/mq-publish-reliability.md` § Buffering: "RedisBuffer is a stub … Leave persistentBufferEnabled at its default") — a mechanism documented as not existing (`automation-gates.md` §5). The four questions of `change-discipline.md` § Removing are answered in the commit and in `tests/unit/persistent-buffer-removed.test.js`: it arrived with the publish-reliability layer (`3588b148`) as a hook for a future extension; no part of the concept requires persistence; nothing ever set either key outside one test (measured across `api`, `api_biz`, `infra`, `fe_adminui`, 2026-09-14); and what stands in its place — one in-memory buffer holding every priority — is more conceptual than a switch that loses messages. `getBufferState()` is therefore `{ size, inMemory }` (the `persistent` count could only ever be 0), `MessageBuffer.flush()` returns the number replayed instead of `{ inMemory, persistent }`, and both key names now fail the constructor with a sentence saying the mechanism is gone rather than a name to rename them to (d.343).
|
|
31
|
+
- **Fixed (BREAKING: `publish()` no longer creates the exchange it publishes to, and `options.exchangeType` is gone):** an exchange is NAMED by a publisher, never declared by one. `_publishOnce()` asserted the exchange before EVERY publish, with arguments it made up on the spot — the type from `options.exchangeType || 'direct'` and the durability from the PUBLISHING client's own config — so the publisher decided the topology and two differently configured publishers declared one exchange two ways. Measured on the live broker: a client configured `durable: false` publishing to a fanout its owner declared durable is refused with `406 PRECONDITION-FAILED … inequivalent arg 'type' … received 'direct' but current is 'fanout'`, on a message that had nothing wrong with it. The owner's decision names this exact defect one object down — "the endpoint's own `{ durable: true }` assert is a second declaration and already yields 406 against the library's" (`docs/governance/confirmations/mq-consumer-contract.md` 003, point 4). Exchanges are declared once in `config/queueConfig.js` and asserted by their owners (measured: every exchange the platform publishes to already is), so an exchange nobody declared is now a broker-side 404 on the publish rather than an exchange invented by whoever published first — the same rule as `README.md` § Queue ownership states for queues. `options.exchangeType` existed for that one assert and left with it, including its only caller inside the library, `monitoring-publish.js` (d.342).
|
|
32
|
+
- **Added:** `BaseClient.isConnectionFatal()` and `BaseClient.performHealthCheck()` — the last two methods a service could not reach. Same hole d.296 closed for the five counters, different question: both were methods of the TRANSPORT alone, and `new RabbitMQClient` is called in exactly one place in the whole workspace (`transports/transportFactory.js`, inside this library), so a service holding a `BaseClient` saw `isConnected() === false` with no way to learn whether to wait or to restart, and the manual probe `docs/architecture/rabbitmq-channel-lifecycle.md` documents was callable on nothing it holds. The transport keeps the computation; the client delegates. `performHealthCheck()` follows the five counters and refuses before `connect()` built a transport; `isConnectionFatal()` deliberately does not — a client that never connected has a true answer (no), and a healthcheck must not be answered by an exception. `onChannelClose` stays the transport's: it is an internal hook on a channel's lifetime, not a question a service asks. The d.296 gate now covers both new counterparts (d.341).
|
|
33
|
+
- **Removed (BREAKING for a caller importing the transport):** `require('@onlineapps/mq-client-core').RabbitMQClient`. The four questions, answered before deleting: it came in with the library's extraction (`7a47b2fa`), as part of publishing the module's surface; no part of the concept ever carried it — `docs/architecture/mq-publish-reliability.md` describes the transport as what `BaseClient` selects, never as what a caller constructs; nothing reads it because `transportFactory` has been the one constructor since that same commit (measured across `api_biz/*`, `infra/*`, `shared/*`: zero uses outside this package); and what stands in its place is more conceptual, not less — the factory picks the transport from `type` (Configuration Over Convention), while constructing it by hand bypasses config composition, schema validation and the instance registry `disconnectAll()` is built on (d.341).
|
|
34
|
+
- **Fixed (BREAKING: a spent reconnect budget is no longer the end of the client):** after the attempt budget of one recovery cycle runs out against a broker that never answered, the client **stands down** — `connection:standby` with `{ cycle, cyclesMax, attempts, lastError, timestamp }`, no timer, no socket, `isConnected()` false and `isConnectionFatal()` FALSE — and the next `publish()`, `consume()` or `performHealthCheck()` starts the next cycle. Nothing polls in between, so a client nobody uses costs nothing while the broker is away. Until now one spent budget set the fatal flag and the client refused every operation for the life of the process, which the README taught as "dead until the process restarts"; that is the "give up forever" the owner forbade (`docs/governance/confirmations/mq-client-lifecycle-contract.md` 001 point 3), and it meant a broker restart longer than `maxReconnectAttempts × reconnectMaxDelay` needed a process restart to undo. One rail: the lazy retry re-enters the same `_reconnectWithBackoff()`, there is no second loop (d.339).
|
|
35
|
+
- **Added:** `maxReconnectCycles` (default 3, env `RABBITMQ_MAX_RECONNECT_CYCLES`) — how many recovery cycles the client runs before the connection IS permanently lost. It is what keeps the lazy retry finite; the value is the one `maxDeliveryAttempts` already answers the same question with (how many whole retries one thing gets), and two answers to one question must not differ (d.339).
|
|
36
|
+
- **Changed (BREAKING for a caller reading the fatal error):** `connection:fatal` says WHICH of the two endings it is — `reason: 'broker-refused'` or `'cycles-spent'` — and carries `cycles` beside `attempts`, with a different sentence for each. A broker that ANSWERED and refused (403 `ACCESS-REFUSED`, 530 `NOT-ALLOWED`, 406 `PRECONDITION-FAILED`) is fatal on the spot, with no further attempt spent on an answer that cannot change: measured against amqplib 0.10.9 on the live broker (2026-09-14), such a refusal arrives as a plain `Error` with `code` undefined and the AMQP reply code rendered into the message, so the reply code is what is read — the broker's protocol constant, never wording invented here. Not recognised, and said rather than implied: a wrong vhost reaches amqplib as `Expected ConnectionOpenOk; got <ConnectionClose channel:0>`, so it is treated as an outage and ends at the cycle cap (d.339).
|
|
37
|
+
- **Fixed:** a recovery cycle's ending is announced ONCE, on the channel that belongs to it. `connection:standby` and `connection:fatal` are emitted the moment they happen; the recovery's `catch` no longer re-emits the same error on the generic `error` channel a turn later — late enough to land inside the NEXT cycle's `_waitForReconnection()` and reject it with the previous cycle's obituary (measured while writing d.339: a publish that had just started a healthy cycle was rejected by the stand-down before it). `_waitForReconnection()` now ends on the three real outcomes of a cycle — `reconnected`, `connection:standby`, `connection:fatal` — plus its own timeout (d.339).
|
|
38
|
+
- **Fixed (BREAKING for a caller who read `isConnected()` as "the socket is up"):** `isConnected()` answers "this client can be used right now" — the connection is alive AND its channels are back. Connection-level recovery marks the connection alive the instant `amqp.connect()` resolves and recreates the publisher, queue and consumer channels only after that, so for the width of that restore the client answered `true` while `getChannelState()` reported all three channels missing: the same split answer d.260 removed from the outage, reappearing at the far end of it. Measured as a race in `tests/integration/connection-liveness-single-source.integration.test.js` ("when the broker is reachable again"), where the poll on `isConnected()` returned before the channels existed; with the flag alone the case fails on its 30 s timeout, with the fix it passes in 368 ms. No new liveness flag: the answer reads the recovery's own `_reconnecting`, which is already cleared exactly when the channels are back, when the budget is spent, or when the caller ended the client mid-round (d.340).
|
|
39
|
+
- **Fixed (BREAKING for a caller who wrote a publish or buffer knob as `0`, which now takes effect):** the layers BELOW the transport carry no second copy of a default either. `layers/PublishLayer.js` resolved four knobs as `options.X || client._Y || <literal>` and `buffer/InMemoryBuffer.js` two as `options.X || <literal>`, so `publishMaxRetries: 0` ("do not retry a publish") and `publishBufferMaxSize: 0` ("hold nothing") survived the transport — which d.292 §5 fixed — and died one floor lower, where nothing said a word. Both layers now ask with `=== undefined`, and `InMemoryBuffer` REFUSES a missing `maxSize`/`ttlMs` by name instead of inventing one: the values are declared in `config/defaultConfig.js` and handed down (d.311).
|
|
40
|
+
- **Fixed:** `publish()` resolves its exchange with `=== undefined` as well — the empty string is a value there (it names the default exchange), and the third leg was a third copy of a default `defaultConfig.js` already owns (d.311).
|
|
41
|
+
- **Added:** `queueConfig.deadLetterExchange()` — the platform's dead-letter exchange as the one thing to import. The name was typed four times in `queueConfig.js`, and `@onlineapps/conn-infra-mq` read it back out of a queue's arguments for want of an export. A test holds the literal to one occurrence: the declaration (d.311).
|
|
42
|
+
- **Removed:** `redactOptions()` and the options-object branch of `redactConnectionTarget()`. d.292 §2/§3 narrowed this client to ONE target shape — a non-empty URL string, refused at construction if it is anything else — so the branch could not run, and its refusal advised `config.url`, the one name this client refuses. The message now names `config.host` (d.311).
|
|
43
|
+
- **Fixed (docs):** the README no longer documents the `queue` config key, removed in d.292; it lists the five reporting methods added in d.296 and `disconnectAll()`; `defaults.js` names all three legs of `maxDeliveryAttempts` instead of two (d.311).
|
|
44
|
+
- **Fixed (BREAKING: the configured heartbeat now actually applies):** the heartbeat is composed into the connection URL, where amqplib 0.10 reads it (`lib/connect.js`: `intOrDefault(query.heartbeat, 0)`), instead of being handed to `socketOptions`, where it is not read at all. `config.heartbeat` and `RABBITMQ_HEARTBEAT` were resolved, documented as "handed to amqplib at connect" and then discarded: the handshake asked for `0`, which means "whatever the peer says", so every connection on the platform ran on the BROKER's number — measured on the dev broker 2026-09-14: 39 live connections, `timeout: 60` on every one, whatever their configuration said. A `host` that already carries `?heartbeat=` is refused rather than overwritten (d.299).
|
|
45
|
+
- **Changed (BREAKING):** `publishConfirmationTimeout` is `brokerAnswerTimeout`. The number bounds both things this client waits for the broker to answer — the confirm of one publish attempt, and the `close-ok` of every handle `disconnect()` closes (d.283 chose deliberately not to invent a second number) — so it no longer carries the name of one of its two consumers. The old name is refused by name, naming the new one; it is not accepted as an alias (d.299).
|
|
46
|
+
- **Added:** `connectTimeout` (default 10000) and `publishConfirmWatchdogDelay` (default 1000) are declared keys. They were the literals `10000`, twice, and `1000` — and the two connect-timeout messages said "after 10 seconds" whatever the number was; they now render the configured value (d.299).
|
|
47
|
+
- **Fixed:** "how this client reaches the broker" has one rail — `_connectionTarget()` composes the URL and `_connectionSocketOptions()` the client properties, for the first connect and every reconnect alike; `_attachConnectionHandlers()` registers the `error` and `close` pair that used to be written out twice (d.299).
|
|
48
|
+
- **Changed (BREAKING for a caller matching the old `consume()` failure text):** a refusal to start a consumer says WHY it refused. `BaseClient.consume()` rewrote every transport failure as one sentence — "Expected: the queue to exist before consume() attaches to it … a missing queue is created by the service that owns it" — which for the OTHER refusal the same call makes (d.259: `queueConfig` declares no dead-letter route for the queue) is false in every clause: the queue exists, creating it changes nothing, and the owning service is not who has to act. The true sentence was in `error.cause`, where a log line does not look. Two classes now carry two messages and, above all, two codes — `errorCodes.CONSUMER_QUEUE_MISSING` and `errorCodes.CONSUMER_DEAD_LETTER_ROUTE_MISSING`, both exported — because a caller deciding on wording breaks the day somebody rewords it. Every other failure keeps a third message that claims NO cause and points at `error.cause`, and carries no code. The transport's missing-infrastructure-queue refusal is a typed `ConsumeError` with the queue and the code instead of a bare `Error` (d.297).
|
|
49
|
+
- **Fixed (tests):** the `queueState()` probe of `dead-letter-topology.integration.test.js` reports a missing queue as a state (`{ exists: false, messageCount: null }`) instead of letting the 404 become an unhandled error that killed the run — `Unhandled error. (Error: Channel closed by server: 404 …)`, measured 2026-09-14, two tests down and the suite reported as failed to run. `queueExists()` beside it is now the same probe read for its verdict, not a second implementation (d.297).
|
|
50
|
+
- **Added:** `BaseClient.assertExchange(name, type, options)`, `bindQueue(queue, exchange, pattern)` and `cancelConsumer(consumerTagOrQueue)` — three of the four operations `mq-client-lifecycle-contract` 001 point 4 ("L8") requires this client to expose. `assertExchange` existed only as a private line on the publish path; `bindQueue` and `cancelConsumer` did not exist at all, so a service that had to bind a queue to an exchange reached around the library for a raw amqplib channel (the drift `mq-consumer-contract` 001 records as DL-007). All three run on the queue channel, like `assertQueue()`. An exchange's `type` and a binding's `pattern` are required — they are the caller's topology decisions, and `''` is a legal pattern (a fanout exchange ignores the key). `cancelConsumer` also DROPS the consumer from the client's registry, so connection-level recovery does not re-attach a consumer the caller stopped (d.302).
|
|
51
|
+
- **Changed:** `BaseClient.consume()` returns the broker's consumer tag (it returned `undefined`; the transport had it all along). Without it the cancel-by-tag half of the contract had no way to be used (d.302).
|
|
52
|
+
- **Added:** `consume(queue, handler, { requeueOnError })` — the fourth L8 operation, defined ONTO the existing delivery policy rather than beside it (`change-discipline.md` § One rail per concern). `false` means the budget is one attempt, so the first failure rejects into `<svc>.dlq` exactly as a `permanent` classification does; `true` or absent leaves the configured budget alone. The one combination that would need a third meaning — `requeueOnError: false` with a budget above 1 — is refused where the caller wrote it instead of one of the two being silently ignored (d.302).
|
|
53
|
+
- **Changed:** the publish path declares its exchange through the public `assertExchange()` instead of its own `channel.assertExchange` line — one rail for the arguments and the refusals of an exchange declaration (d.302).
|
|
54
|
+
- **Removed (BREAKING for a service that relied on the library closing its clients on SIGTERM):** the library registers no `SIGINT`/`SIGTERM`/`beforeExit` handler and calls `process.exit()` nowhere. It installed them on the first `BaseClient` anyone constructed and ended them with `process.exit(0)`, which truncated the shutdown sequence of every service that had written one — traced live in the registry (`infra/api_services_registry/src/shutdown.js`, dev stack 2026-08-31): `TRACE_END mq t=12` → `TRACE_PROCESS_EXIT code=0 t=16`, with four Redis clients, the HTTP server and six timers never handed back, and nothing in that repository to explain why (`mq-client-lifecycle-contract` 001 point 1) (d.301).
|
|
55
|
+
- **Added:** `BaseClient.disconnectAll()` — the library's whole side of the shutdown contract: closes every client this module holds, **only** those (a raw amqplib connection the service opened itself is untouched), gives every client its turn even when one fails, and then rejects with a `ConnectionError` naming how many of how many failed, `error.cause` carrying the first reason. A client that failed to close stays held, so a repeated call reaches it again — where the deleted hook's blanket `clear()` made the retry a silent no-op, and its per-client warning let a shutdown that closed nothing look like one that worked. Reachable as `BaseClient.disconnectAll()` and `require('@onlineapps/mq-client-core').disconnectAll()` (d.301).
|
|
56
|
+
- **Changed (integration tier):** `RABBITMQ_MGMT_PORT` is required alongside `RABBITMQ_URL` — the broker-side proof of `disconnectAll()` asks the management API how many connections the broker still holds, and a claim about the broker's side of a socket cannot be made from the client's belief about it. Missing or unreachable → named failure before the first describe tree is built (d.301).
|
|
57
|
+
- **Added:** `BaseClient.getPublishMetrics()`, `getPrometheusMetrics()`, `getChannelState()`, `getConsumerState()` and `getBufferState()` — the five reporting methods a service is documented to be able to call (`docs/architecture/mq-publish-reliability.md`). They were methods of the transport alone, and `new RabbitMQClient` is called in exactly one place in the workspace (`transports/transportFactory.js`, inside this library), so `PublishMonitor` counted attempts, successes, failures, retries, buffered and flushed for a reader that could not exist. The transport stays the owner of the computation; the client delegates, and refuses with a named `ConnectionError` before `connect()` has built a transport rather than inventing a zeroed answer (d.296).
|
|
58
|
+
- **Fixed (BREAKING for a client that set `maxDeliveryAttempts` and got 3):** the per-message delivery budget has the three legs `config.js` declares — explicit → `RABBITMQ_MAX_DELIVERY_ATTEMPTS` → module default. `consume()` asked `runtimeCfg.get('maxDeliveryAttempts')` with no explicit value, so a client configured with 2 ran its handler three times before dead-lettering (measured on the live broker: `[1, 2, 3]`). `maxDeliveryAttempts` is now a declared schema key (`minimum: 1`, since a budget below one means the handler never runs); `consume(queue, handler, { maxAttempts })` remains the narrowest override (d.292 §6).
|
|
59
|
+
- **Fixed (BREAKING for a caller who wrote a knob as `0`, which now takes effect):** every constant default has ONE owner, `config/defaultConfig.js`, composed before validation; the transport constructor's twenty-two `this._config.<key> || <literal>` are gone. `||` means "if falsy", so a legally written `0` (`minimum: 0` in the schema) was replaced by its opposite in twelve knobs — `reconnectBaseDelay: 0` became 1000, `publishMaxRetries: 0` became 3, `thrashingThreshold: 0` became 5. The one derived default (`reconnectWaitTimeout`, a function of the recovery budget and the backoff ceiling) stays derived, asked with `=== undefined`. The layers below the transport (`layers/PublishLayer.js`, `buffer/InMemoryBuffer.js`) still carry their own copies of the same literals — recorded, not fixed here (d.292 §5).
|
|
60
|
+
- **Removed:** `useDefaults` is no longer passed to Ajv. It arrived with the library's extraction (`7a47b2fa`) and never had anything to write — no schema property carries a `default`, on purpose and under a test — so it was a standing invitation to a second rail of defaults rather than a mechanism (d.292 §7).
|
|
61
|
+
- **Fixed (BREAKING for a caller relying on a deep merge of nested config):** the configuration is COMPOSED shallowly and by reference (`config/composeConfig.js`), so an injected dependency arrives as the object that was injected. `lodash.merge` deep-copied every plain object it was handed (measured: `out.plainOne === plain` → false), so a `persistentRedisClient` written as an object literal reached `RedisBuffer` as a copy — the same shape, none of the identity. A key written as `undefined` still means "not provided". `lodash.merge` is no longer a dependency of this package (d.292 §4).
|
|
62
|
+
- **Removed (BREAKING):** `url` is not a configuration key of this client — a config carrying it is refused at construction, naming `host` and the value that would have been lost. The transport read `host || url` while `host` was required and resolved from `RABBITMQ_URL`, so the second half was unreachable: whoever wrote `url` (the name `@onlineapps/conn-infra-mq` documents) connected to whatever the environment said, silently. Nine files of this package's own test suite did exactly that (d.292 §2).
|
|
63
|
+
- **Removed (BREAKING):** the connection target has one shape — a non-empty URL string. The `typeof rawTarget === 'string'` branch that also accepted amqplib's options object is gone (the schema declares `host` as a string, so it was unreachable through `BaseClient`, and nothing in the workspace passes an object), and the transport refuses a target that is not a string at construction, naming the key and its TYPE — never the value, which carries the broker password (d.292 §3).
|
|
64
|
+
- **Removed (BREAKING):** the `queue` default (`''`) and its schema declaration — nothing has read the client's "default queue" since the library was extracted (`7a47b2fa`); every `publish()`/`consume()` names its queue in the call. Writing the key changes nothing, as it never did; `@onlineapps/service-wrapper`, the meta reader and the delivery dispatcher drop it in the same wave (d.292 §1).
|
|
65
|
+
- **Fixed:** a settled publish attempt leaves no armed timer behind — the safety timer in `_publishOnce()` is disarmed on every settlement path and never armed over an attempt already settled; an expired confirmation deadline no longer leaves the safety timer running, and the catch path no longer clears a timer without removing it from `_activeTimers` (the jest "worker process has failed to exit gracefully" leak) (d.285).
|
|
66
|
+
- **Changed (BREAKING for a caller reading the debug probe):** `_prepareQueueForConsume()` no longer probes queue existence with `checkQueue()` before consuming — the probe's 404 closed the queue channel on every new business queue and its result reached only a debug log (`queue.declare-ok` carries no arguments). 406 is a `ConsumeError`, 404 fails fast (d.287).
|
|
67
|
+
- **Fixed:** "the connection died" has one rail — `_onConnectionDied()` — called by both `connection.on('close')` registrations (initial connect and reconnect) (d.287).
|
|
68
|
+
- **Fixed (BREAKING for a caller expecting `disconnect()` to wait for ever):** `disconnect()` finishes within one broker-answer budget (`publishConfirmationTimeout`): after it, the socket and amqplib's heartbeater are dropped, references are cleared even when `close()` was refused; measured 335 s+ (unbounded) → ~1 s over a black-holed socket (d.287).
|
|
69
|
+
- **Removed (BREAKING):** the business template `fork.{workflow}.{step}` added by d.278 is out again — the owner decided fork/join over MQ is removed rather than renamed (`mq-consumer-contract` 004). A name of that shape is an unknown business pattern once more: `isBusinessQueue()` false, `parseBusinessQueue()` null, `getDeadLetterRoute()` null, and `consume()` refuses such a queue at registration. The other d.278 routes (`workflow.dlq`, `delivery.dlq`, both registry templates) are unchanged (d.290b).
|
|
70
|
+
- **Changed (BREAKING):** `config/configSchema.js` declares all 44 keys the client reads (was 10). A key with a wrong type is refused at construction instead of silently misread — `reconnectEnabled: 'no'` used to be accepted and read as "recovery ON". `additionalProperties` stays `true` for now: `@onlineapps/conn-infra-mq` extends the config in a subclass and would stop booting; the flip lands with that package's compliance (`automation-gates.md` §3) (d.286).
|
|
71
|
+
- **Fixed (BREAKING for a caller relying on reconnect after `disconnect()`):** `disconnect()` is terminal — after it the connection is never re-established, the client holds no timer, socket or channel, `isConnected()` answers `false`, a connection established around the recovery loop after `disconnect()` is closed, and a second `disconnect()` is a no-op. Only an explicit `connect()` clears the flag (d.283).
|
|
72
|
+
- **Fixed:** an ownerless queue is never declared on the publish path without the verdict of `RecoveryWorker.queueCreationRefusal()`; the unreachable "no recovery worker" guard is gone (d.284).
|
|
73
|
+
- **Fixed:** a consumer over a dead queue channel gets `ConnectionError` naming the operation and the queue instead of amqplib's bare "Channel closed" (d.284).
|
|
74
|
+
- **Changed:** the publish and consume paths obtain the queue channel through `_requireQueueChannel()`; the translation to `TransientPublishError` has one place. One refusal text changed: `Cannot create queue <q>` → `Cannot assertQueue <q>` (d.284).
|
|
75
|
+
- **Changed:** `configSchema` declares `queueCreationFilter` and `queueCreationCallback`; a value that is neither a function nor `null` is refused at construction (d.284).
|
|
76
|
+
- **Added:** `RecoveryWorker.queueCreationRefusal(queueName)` — the ONE decision whether a queue nobody owns may be declared (reason string, or `null`). The publish path asks it instead of reading the worker's private `_queueCreationEnabled`/`_queueCreationFilter`; both callers can no longer disagree (d.282).
|
|
77
|
+
- **Changed (BREAKING for a caller matching the old message text):** `checkQueue()`, `purgeQueue()` and `deleteQueue()` refuse a queue channel that is not alive, the way `assertQueue()` already did — one private rail ensures the channel, refuses a corpse with `ConnectionError` naming the operation and the queue (`Cannot checkQueue <queue>: queue channel is not available`, was `Cannot checkQueue: queue channel is not initialized`) and records `channel._lastOperation` for all four (d.282).
|
|
78
|
+
- **Changed:** `isInfrastructureQueue()`/`isBusinessQueue()`/`parseBusinessQueue()` decide ownership by the DECLARED name, not by prefix — order: a declared infrastructure name → a business template match → infrastructure territory by prefix (a publisher never creates there). `registry.queue`, `registry.workflow` and `registry.dlq` therefore belong to the service named `registry` and `getDeadLetterRoute()` answers `dlx → registry.dlq` instead of throwing; `workflow.dlq`, `delivery.dlq` and `monitoring.workflow` no longer answer `true` to both classifiers (d.279).
|
|
79
|
+
- **Fixed:** the "Infrastructure queue config not found" message names both ways out — declare the queue, or use a name a service of that name declares itself — rendering the candidates from the `business` templates (d.279).
|
|
80
|
+
- **Changed (BREAKING for a caller passing `queueOptions` to `publish()`):** a queue with no owner is declared by the public `assertQueue()` with arguments from `queueConfig`; `options.queueOptions` on the publish path is gone — the caller never decides a queue's declaration. Infrastructure and business queues stay `QueueNotFoundError`: a publisher does not create them (d.281).
|
|
81
|
+
- **Changed (BREAKING for a caller relying on the fallback):** the `queueChannel` getter answers with the queue channel or throws `ConnectionError` — never with the publisher's ConfirmChannel; the fallback is removed (three-channel isolation, `architecture-principles` §3) (d.281).
|
|
82
|
+
- **Fixed:** a consumer never settles a delivery on a channel the broker killed — no ack, no retry copy, no rejection goes to a dead channel; the situation is logged and the broker returns the message to the queue. Before, `channelForMsg.closed` was always `undefined`, so ack/nack went to a dead object and the retry path produced a duplicate (d.277b).
|
|
83
|
+
- **Fixed:** `assertQueue()` refuses a queue channel that is not alive instead of surfacing amqplib's bare "Channel closed" (d.277b).
|
|
84
|
+
- **Changed:** `RecoveryWorker` declares a queue through the public `client.assertQueue()`; the queue's arguments come from `queueConfig` alone (`durable` no longer from the client config) (d.277b).
|
|
85
|
+
- **Added (BREAKING):** `delivery.dlq` and `delivery.resource.events`; the whole `delivery.*` family and `workflow.completed`/`workflow.discarded` carry a declared dead-letter route (`mq-consumer-contract` 003, variant A). An infrastructure family routes to `<prefix>.dlq` over the default exchange; a queue a biz service owns routes over `dlx` to `{service}.dlq` (d.278).
|
|
86
|
+
- **Added (BREAKING):** business templates `registry`, `registry.events` and `fork.{workflow}.{step}`; `isBusinessQueue()`/`parseBusinessQueue()` derive from the `business` section (the template key is the pattern behind the service name, `{placeholder}` matches one segment) and `parseBusinessQueue()` returns the template key instead of the second name segment. Two templates matching one name are refused (d.278).
|
|
87
|
+
- **Added:** `topology.deliveryResourceEventsQueue` (d.278).
|
|
88
|
+
- **Fixed:** the health check (`_performHealthCheck()`) reports liveness from the one source the channel/connection handlers set first-hand, not from `closed` — a property amqplib does not define; a channel the broker killed now reads `closed: true` and makes the check unhealthy instead of green, and the tracked-queue probe inside it no longer runs `checkQueue` over a dead queue channel (d.277).
|
|
89
|
+
- **Fixed:** `publish()` never hands a payload to a channel that died during the queue-existence pre-check, and a confirm callback arriving on a dead channel is no longer accepted as delivery — the message is re-sent on a live channel (d.277).
|
|
90
|
+
- **Fixed:** `RecoveryWorker.createQueue()` asks the client through the public `getChannelState()` instead of reading the private `_queueChannel.closed`, so it no longer asserts a queue on a dead channel (d.277).
|
|
91
|
+
- **Fixed:** channel close is announced ONCE per closure, not once per amqplib event (`error` + `close`); channel thrashing is therefore declared at `thrashingThreshold`, not at half of it (d.264).
|
|
92
|
+
- **Fixed (BREAKING for a caller reading `getChannelState()`):** connection and channel liveness have one source of truth, set by the handlers that learn of a death first-hand. amqplib carries no `closed` property (measured, 0.10.9), so `isConnected()` used to report TRUE for a dead connection and `_ensure*Channel()` opened channels on it. `getChannelState()` now answers with booleans (d.260).
|
|
93
|
+
- **Fixed:** channel recovery runs on one rail (the `close` handler): one death opens exactly one replacement channel, and a channel the broker already closed is never closed again — doing so tore down the whole connection (d.260, d.261).
|
|
94
|
+
- **Fixed:** consumer re-registration runs the same rail as the first `consume()` (`_prepareQueueForConsume`): it never declares a queue the consumer does not own, and a 406 no longer evicts the consumer permanently — it stays tracked and the next recreate retries (d.262).
|
|
95
|
+
- **Fixed:** prefetch is re-applied on every consumer (re)registration, so a recreated channel keeps the consumer's window and the utilisation alarm measures against a limit that is actually set (d.263).
|
|
96
|
+
- **Fixed (BREAKING):** a `delivery.*` name now resolves from either section that declares it (`delivery`, `deliveryEvents`); `delivery.workflow.events`, `delivery.health.events` and `delivery.alert.events` stopped answering "config not found", and `getDeadLetterRoute()` answers `null` for them instead of throwing. A name declared in both sections is refused (d.267).
|
|
97
|
+
- **Added (BREAKING):** section `telemetry` with `logs.queue`, `metrics.queue`, `traces.queue`, plus the matching lookup. Classification, dispatch and the refusal message now read one prefix map, and `queueConfig.infrastructurePrefixes()` publishes that list (d.269).
|
|
98
|
+
- **Added (BREAKING):** `queueConfig.queueName(section, key)` and the frozen `topology` export — `infrastructureHealthEventsExchange`, `monitoringInfrastructureHealthEventsQueue`, `deliveryHealthEventsQueue`; the `infrastructure.health.events` fanout exchange and the `monitoring.infrastructure.health.events` queue are now declared as data (d.270).
|
|
99
|
+
- **Added (BREAKING for a consumer of a queue with no dead-letter route):** `consume()`
|
|
100
|
+
refuses, at registration, any queue for which `queueConfig` declares no
|
|
101
|
+
`x-dead-letter-exchange` + `x-dead-letter-routing-key`. The policy above ends in
|
|
102
|
+
`nack(requeue=false)`, and a broker with nowhere to route the rejection DROPS the
|
|
103
|
+
message — measured on the live broker: one message published to a queue with no route,
|
|
104
|
+
one permanent failure, `messageCount: 0` afterwards — while `_rejectDelivery()` published
|
|
105
|
+
a `message_dlq` event saying it had reached `.dlq`. Before the policy the same message
|
|
106
|
+
circled for ever instead, so nothing was lost; the policy turned an unbounded loop into a
|
|
107
|
+
silent loss on every such queue. There is no opt-out and no fallback to requeue-for-ever:
|
|
108
|
+
whoever consumes a queue declares its route (`automation-gates.md` §1 requirement 5,
|
|
109
|
+
`docs/governance/confirmations/mq-consumer-contract.md` 002 — one rail for business and
|
|
110
|
+
infrastructure consumers alike). Reply queues (`rpc.reply.*`) are refused too: they keep
|
|
111
|
+
skipping the queue-classification block, but not this gate.
|
|
112
|
+
- **Added:** `queueConfig.getDeadLetterRoute(queue)` — the declared route
|
|
113
|
+
(`{ exchange, routingKey }`) or `null`. The declaration is the only source of truth on
|
|
114
|
+
the consumer path: AMQP answers `checkQueue()` with `queue.declare-ok`, which carries
|
|
115
|
+
`{ queue, messageCount, consumerCount }` and no arguments at all (measured, 2026-09-12),
|
|
116
|
+
so a running queue's `x-dead-letter-*` cannot be read back over the channel. A route
|
|
117
|
+
supplied by the caller in `queueOptions` does not satisfy the gate either — that would be
|
|
118
|
+
a second declaration of one queue's topology.
|
|
119
|
+
|
|
120
|
+
- **Added (BREAKING for a failing handler):** `consume()` carries a dead-letter policy —
|
|
121
|
+
the single rail for business and infrastructure consumers alike
|
|
122
|
+
(`api/docs/governance/confirmations/mq-consumer-contract.md` 001, 002 point 3). A handler
|
|
123
|
+
that throws used to get an unconditional `nack(requeue=true)` with no counter, so a
|
|
124
|
+
permanently failing message circled for ever and `<service>.dlq` was never reached from
|
|
125
|
+
the client side. Now: the attempt is counted in the `x-oa-delivery-attempts` header,
|
|
126
|
+
a transient error with budget left costs one re-publish of the message plus an ack of the
|
|
127
|
+
original, and the `maxAttempts`-th failure (or a `permanent` classification, on the first)
|
|
128
|
+
rejects the message with `nack(requeue=false)` so the broker dead-letters it.
|
|
129
|
+
- **Added:** `consume(queue, handler, { maxAttempts, classify })`. `maxAttempts` is how many
|
|
130
|
+
times the handler may run for ONE message; resolution is explicit →
|
|
131
|
+
`RABBITMQ_MAX_DELIVERY_ATTEMPTS` → `defaults.js maxDeliveryAttempts` (3, the value
|
|
132
|
+
`@onlineapps/error-handler-core` already declares for the same decision). Both are refused
|
|
133
|
+
at `consume()` when they cannot bound a delivery. `classify` is optional; without it every
|
|
134
|
+
error is transient, which still ends at the dead-letter queue, only later.
|
|
135
|
+
- **Added:** the handler receives a second argument, `{ attempt, maxAttempts, isFinalAttempt }`,
|
|
136
|
+
so a side effect that must happen once — an RPC error reply above all — happens on the final
|
|
137
|
+
attempt instead of on every redelivery. A handler declaring one parameter ignores it.
|
|
138
|
+
- **Added:** a `message_dlq` event on `monitoring.workflow` for every message the client
|
|
139
|
+
rejects, carrying `status: 'rejected'` (the broker's own `x-death[0].reason` for that move).
|
|
140
|
+
Published ONLY when the envelope carries a `workflow_id`; without one the absence is logged
|
|
141
|
+
with the queue and the service name and nothing is published — no `'unknown'`, no invented
|
|
142
|
+
marker.
|
|
143
|
+
- **Added:** `deliveryPolicy` is a named export. `ATTEMPTS_HEADER` travels on the wire, so
|
|
144
|
+
every reader of a dead-lettered message takes the name from here instead of retyping it.
|
|
145
|
+
|
|
146
|
+
- **Fixed:** `queueConfig` declares `workflow.failed` without `x-message-ttl`. The queue is
|
|
147
|
+
the operator's — it holds a failed workflow until a human decides — and it has no
|
|
148
|
+
dead-letter exchange, so the five-minute TTL deleted the evidence it existed to keep. All
|
|
149
|
+
other queue arguments are unchanged (`api/docs/governance/confirmations/mq-consumer-contract.md` 002).
|
|
150
|
+
|
|
151
|
+
## [2.0.1] — 2026-09-03
|
|
152
|
+
|
|
153
|
+
- Changelog started with this release; the history before it is in git (`git log -- shared/mq-client-core`).
|