@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.
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * decodeMessage.js
5
+ *
6
+ * The one rail from "the broker delivered bytes" to "here is the message".
7
+ *
8
+ * `consume()` hands its handler the RAW amqplib delivery and decodes nothing for it, so
9
+ * every consumer on the platform wrote that translation itself. Measured in d.788: FOUR
10
+ * copies — `api_delivery_dispatcher` (`DeliveryDispatcher._parseMessage`), `api_scheduler`
11
+ * (`consumer/parseRequestMessage.js`), `@onlineapps/service-wrapper`
12
+ * (`_processWorkflowMessage`) and `api_services_registry` (`mqManager.consume`) — which
13
+ * disagree about the only thing that matters, what happens to a BAD message: one
14
+ * dead-letters it, one files it on a loss rail, one answers `INVALID_JSON`, and one
15
+ * requeues it for ever. Two of them also sniff the shape: a delivery with no `content` is
16
+ * passed on AS the body, so a message the strict one refuses by name travels through them
17
+ * as a valid one.
18
+ *
19
+ * The decoding itself was never missing — `./serializer.js` `deserialize()` has done it
20
+ * since the package was extracted. What was missing is the DOOR: `serializer` is not in
21
+ * `src/index.js`, and `src/…` is an internal path, not a contract. This module is that
22
+ * door plus the contract around it; it holds no second `JSON.parse`.
23
+ *
24
+ * ADDITIVE ONLY. Nothing in this package calls it yet and `consume()` is unchanged:
25
+ * converting the four consumers changes what happens to a malformed message, which is a
26
+ * BREAKING change that goes with the major wave — and, for the registry, only after its
27
+ * queue has a dead-letter route at all (d.784).
28
+ *
29
+ * @see ./serializer.js
30
+ * @see ../index.js — where it is exported
31
+ * @see api/docs/biz/10-invocation/mq-envelope.md — the envelope this decodes
32
+ */
33
+
34
+ const { ValidationError } = require('./errorHandler');
35
+ const serializer = require('./serializer');
36
+
37
+ /** The one content type the platform's envelope travels as. */
38
+ const JSON_CONTENT_TYPE = 'application/json';
39
+
40
+ /**
41
+ * Translate one delivery into the message it carries.
42
+ *
43
+ * WHAT IT REFUSES, and with which class — both already this package's own, so a listener
44
+ * needs no new vocabulary:
45
+ *
46
+ * - a delivery that is not a message, carries no readable `content`, declares a content
47
+ * type this decoder does not read, or carries an empty body → `ValidationError`, the
48
+ * class this package uses for a caller's mistake, naming what arrived
49
+ * (`architecture-principles.md` §4 fail-fast, §5 message shape);
50
+ * - a body that is not JSON → the `SerializationError` `deserialize()` throws, passed
51
+ * through UNCHANGED, so the bytes it refused (`payload`) and the parser's own reason
52
+ * (`cause`) survive. Rewrapping it would be a second rail over one failure.
53
+ *
54
+ * NO SHAPE-SNIFFING: an object without a body is not a body. That fallback is what lets a
55
+ * message nobody can read travel on as if it were the message (d.788).
56
+ *
57
+ * NO `retryable` FIELD, measured rather than forgotten: on the consume path nothing in
58
+ * this package reads `error.retryable` — the fate of a message is decided by the caller's
59
+ * injected `classify(error)`, and without one every error is `transient`
60
+ * (`../config/deliveryPolicy.js` § `defaultClassification`). A field no consumer reads is
61
+ * a declaration without one. What a caller keys on is the error's CLASS, which is
62
+ * exported and stable.
63
+ *
64
+ * The content type is checked only when the message DECLARES one: the envelope contract
65
+ * does not require the property, so demanding it here would be a change of that contract
66
+ * rather than one rail for the four parsers.
67
+ *
68
+ * @param {Object} raw - The delivery as `consume()` hands it over.
69
+ * @param {Buffer|string} raw.content - The body.
70
+ * @param {Object} [raw.properties] - AMQP properties, `headers` and `contentType` included.
71
+ * @param {Object} [raw.fields] - AMQP delivery fields (`deliveryTag`, `redelivered`, …).
72
+ * @returns {{body: Object, headers: Object|undefined, properties: Object|undefined, fields: Object|undefined}}
73
+ * The decoded body, and everything else exactly as it arrived.
74
+ * @throws {ValidationError} When the delivery cannot be read at all.
75
+ * @throws {SerializationError} When the body is not JSON — thrown by `./serializer.js`.
76
+ */
77
+ function decodeMessage(raw) {
78
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
79
+ throw new ValidationError(
80
+ `[mq-client-core] decodeMessage: the delivery is not a message - got ${Array.isArray(raw) ? 'array' : typeof raw}. `
81
+ + 'Expected: the AMQP delivery consume() hands a handler, carrying its body in `content`. '
82
+ + 'Fix: pass the message the handler received, unchanged; this function reads no other shape.'
83
+ );
84
+ }
85
+
86
+ const { content, properties, fields } = raw;
87
+
88
+ if (!Buffer.isBuffer(content) && typeof content !== 'string') {
89
+ throw new ValidationError(
90
+ '[mq-client-core] decodeMessage: the delivery carries no readable content - '
91
+ + `Expected: \`content\` as a Buffer or a string, got ${content === undefined ? 'nothing' : typeof content}. `
92
+ + 'Fix: publish the message with a JSON body; a delivery without one is not decoded into '
93
+ + 'itself, because an object that is not a body must not travel on as one.'
94
+ );
95
+ }
96
+
97
+ if (content.length === 0) {
98
+ throw new ValidationError(
99
+ '[mq-client-core] decodeMessage: the delivery carries an empty body - '
100
+ + 'Expected: bytes holding a JSON message. '
101
+ + 'Fix: correct the producer; an empty body names no message and is not decoded into an empty one.'
102
+ );
103
+ }
104
+
105
+ const declaredType = properties && properties.contentType;
106
+
107
+ if (declaredType !== undefined && declaredType !== null && declaredType !== JSON_CONTENT_TYPE) {
108
+ throw new ValidationError(
109
+ `[mq-client-core] decodeMessage: the message declares content type "${declaredType}" - `
110
+ + `Expected: "${JSON_CONTENT_TYPE}", the type the platform envelope travels as, or no `
111
+ + 'declaration at all. '
112
+ + 'Fix: publish the body as JSON, or decode this message with the reader its own type needs.'
113
+ );
114
+ }
115
+
116
+ // The one decoding rail of this package. A second JSON.parse here would be the copy
117
+ // this module exists to end.
118
+ const body = serializer.deserialize(content);
119
+
120
+ return { body, headers: properties && properties.headers, properties, fields };
121
+ }
122
+
123
+ module.exports = { decodeMessage, JSON_CONTENT_TYPE };
@@ -5,6 +5,10 @@
5
5
  * These errors umožní čisté oddělení publish vrstvy a recovery workeru.
6
6
  */
7
7
 
8
+ const { IllegalOperationError } = require('amqplib');
9
+
10
+ const { ConnectionError, PublishError } = require('./errorHandler');
11
+
8
12
  class TransientPublishError extends Error {
9
13
  /**
10
14
  * @param {string} message - Human readable message
@@ -154,15 +158,254 @@ function missingQueueDefinitionMessage(queueName, isInfrastructure) {
154
158
  + 'Fix: add the template to src/config/queueConfig.js, or consume the name the owning service declares.';
155
159
  }
156
160
 
161
+
162
+ /**
163
+ * amqplib's channel-level `NOT_FOUND`, as a NUMBER — that is how the library sets it
164
+ * (`err.code === 404`, measured against the dev broker beside `classId`/`methodId`).
165
+ *
166
+ * Exported since d.685a, because the publish path asks the same question in three places:
167
+ * the queue-existence pre-check types it as `QueueNotFoundError`, the consumer's
168
+ * infrastructure-queue check as `ConsumeError`, and a SEND the broker answered with it
169
+ * means the exchange was never declared (d.693). One literal, one owner
170
+ * (`change-discipline.md` § One rail per concern) — the number was spelled three more
171
+ * times as a bare `404` in `transports/rabbitmqClient.js` until d.693.
172
+ */
173
+ const AMQP_NOT_FOUND = 404;
174
+
175
+ /**
176
+ * amqplib's channel-level `PRECONDITION_FAILED`, as a NUMBER — the same shape
177
+ * `AMQP_NOT_FOUND` has, and set the same way (`err.code === 406` on the close frame the
178
+ * broker puts on the channel's `error` event).
179
+ *
180
+ * Exported since d.698, when the publisher channel's `error` handler stopped deciding
181
+ * from the frame's SENTENCE. What that handler really meets is measured, live:
182
+ *
183
+ * Channel closed by server: 406 (PRECONDITION-FAILED) with message
184
+ * "PRECONDITION_FAILED - unknown delivery tag 999999" → code 406, classId 60
185
+ *
186
+ * The number is what says "the broker answered about THIS request"; the wording around it
187
+ * belongs to the broker and can be re-worded without warning. It lives here beside its
188
+ * sibling because a second spelling of a reply code somewhere else is a second owner of
189
+ * the same fact (`change-discipline.md` § One rail per concern).
190
+ */
191
+ const AMQP_PRECONDITION_FAILED = 406;
192
+
157
193
  /**
158
- * Best-effort klasifikace chyb z publishu do specializovaných typů.
159
- * Vrací původní chybu, pokud neodpovídá žádnému známému patternu.
194
+ * The message a publisher gets when the channel it was sending on was closed by SOMEBODY
195
+ * ELSE'S refusal.
196
+ *
197
+ * A ConfirmChannel carries more than one unconfirmed publish at a time, and a numbered
198
+ * close frame is an answer about exactly ONE of them. amqplib answers all of them with
199
+ * the same close error (the `Channel` constructor's `'close'` listener, `lib/channel.js`, through `toClosed`), so the frame reaches
200
+ * sends the broker never said a word about — and until d.723 each of them was read as its
201
+ * own refusal. Measured 2026-09-20 in a vhost of its own, two publishes on one channel:
202
+ * one to an exchange nobody declared (the real refusal) and one to `good.fanout`, which
203
+ * the same client had declared a moment earlier. Both came back
204
+ * `PermanentPublishError: … Cannot publish to exchange "good.fanout": … 404 NOT-FOUND -
205
+ * nothing declares it`, while the broker's own sentence underneath named
206
+ * `no exchange 'no.such.exchange'`. The second message was dropped on a refusal that was
207
+ * not about it, and its publisher was sent to declare an exchange that existed.
208
+ *
209
+ * So this sentence says what happened instead: the send was not answered, the channel it
210
+ * was on went away under it, and the message is to be published again — which is what
211
+ * `PublishLayer` does with a `TransientPublishError`. `cause` carries the frame, so the
212
+ * refusal that DID happen stays readable.
213
+ *
214
+ * @param {'queue'|'exchange'} kind - What `target` is.
215
+ * @param {string} target - The queue or exchange THIS publish was addressed to.
216
+ * @param {number} code - The broker's reply code on the frame that closed the channel.
217
+ * @returns {string}
218
+ */
219
+ function collateralCloseMessage(kind, target, code) {
220
+ if (kind !== 'queue' && kind !== 'exchange') {
221
+ throw new Error(
222
+ '[publishErrors] collateralCloseMessage needs to know what it is naming - '
223
+ + `Expected: 'queue' or 'exchange', got ${JSON.stringify(kind)}. `
224
+ + 'Fix: pass the leg the publish took — sendToQueue names a queue, publish names an exchange.'
225
+ );
226
+ }
227
+
228
+ if (typeof target !== 'string' || target === '') {
229
+ throw new Error(
230
+ '[publishErrors] collateralCloseMessage requires the name the publish was addressed to - '
231
+ + `Expected: a non-empty ${kind} name, got ${JSON.stringify(target)}. `
232
+ + 'Fix: pass the name the caller published to.'
233
+ );
234
+ }
235
+
236
+ if (typeof code !== 'number') {
237
+ throw new Error(
238
+ '[publishErrors] collateralCloseMessage requires the broker reply code as a number - '
239
+ + `Expected: the numeric code amqplib puts on the close frame, got ${JSON.stringify(code)}. `
240
+ + 'Fix: read it from the channel\'s recorded close error (_closeError.code); without a number '
241
+ + 'there is no refusal to be collateral of.'
242
+ );
243
+ }
244
+
245
+ return `[RabbitMQClient] The publish to ${kind} "${target}" was collateral of a channel close caused `
246
+ + `by another publish - the broker answered ${code} about a different send on the same channel, and `
247
+ + `said nothing about this ${kind}. `
248
+ + 'Expected: a refusal is an answer about ONE message; the others on that channel were never '
249
+ + 'answered at all, so they were not refused and were not stored either. '
250
+ + "Fix: nothing here — the message is republished on the channel's successor (PublishLayer retries "
251
+ + 'a transient publish and buffers it meanwhile). Read error.cause for the refusal that really '
252
+ + 'happened, and fix THAT one.';
253
+ }
254
+
255
+ /**
256
+ * The message a publisher gets when the broker REFUSED the send and closed the channel
257
+ * with a numbered frame.
258
+ *
259
+ * ONE producer for both statements, because they differ in exactly one thing — what the
260
+ * number means — and writing that difference as a ternary at the construction site puts
261
+ * the whole message where no test can reach it. It is the shape
262
+ * `missingQueueDefinitionMessage()` above already has, and the runtime half of
263
+ * `tests/unit/error-message-contract.test.js` asserts these strings beside the others.
264
+ *
265
+ * - `404 NOT-FOUND` (d.693) says nobody DECLARED what was published to. A publisher
266
+ * declares nothing — exchanges come into being from `config/queueConfig.js`, asserted
267
+ * by the service that owns them — so this is the same answer `QueueNotFoundError`'s
268
+ * `unowned` branch gives for a queue name nothing owns, about the other object;
269
+ * - any other number (`403 ACCESS-REFUSED`, `406 PRECONDITION-FAILED`, measured) says
270
+ * the broker refused THIS message.
271
+ *
272
+ * Both are permanent for one reason: the refusal is an answer about this request, and
273
+ * repeating the request repeats it.
274
+ *
275
+ * @param {'queue'|'exchange'} kind - What `target` is, so the sentence names it correctly.
276
+ * Required and checked: a publish names one or the other, never "something".
277
+ * @param {string} target - The queue or exchange the publish was addressed to.
278
+ * @param {number} code - The broker's reply code, as amqplib sets it (a NUMBER).
279
+ * @returns {string}
280
+ */
281
+ function refusedSendMessage(kind, target, code) {
282
+ if (kind !== 'queue' && kind !== 'exchange') {
283
+ throw new Error(
284
+ '[publishErrors] refusedSendMessage needs to know what it is naming - '
285
+ + `Expected: 'queue' or 'exchange', got ${JSON.stringify(kind)}. `
286
+ + 'Fix: pass the leg the publish took — sendToQueue names a queue, publish names an exchange.'
287
+ );
288
+ }
289
+
290
+ if (typeof target !== 'string' || target === '') {
291
+ throw new Error(
292
+ '[publishErrors] refusedSendMessage requires the name the publish was addressed to - '
293
+ + `Expected: a non-empty ${kind} name, got ${JSON.stringify(target)}. `
294
+ + 'Fix: pass the name the caller published to.'
295
+ );
296
+ }
297
+
298
+ if (typeof code !== 'number') {
299
+ throw new Error(
300
+ '[publishErrors] refusedSendMessage requires the broker reply code as a number - '
301
+ + `Expected: the numeric code amqplib puts on the close frame, got ${JSON.stringify(code)}. `
302
+ + 'Fix: read it from the channel\'s recorded close error (_closeError.code); a refusal with no '
303
+ + 'number is not a refusal, it is a channel death, and that one is transient.'
304
+ );
305
+ }
306
+
307
+ if (code === AMQP_NOT_FOUND) {
308
+ return `[RabbitMQClient] Cannot publish to ${kind} "${target}": the broker closed the channel with `
309
+ + '404 NOT-FOUND - nothing declares it. '
310
+ + `Expected: every ${kind} to come into being from a declaration — `
311
+ + 'src/config/queueConfig.js declares it and the service that owns it asserts exactly that '
312
+ + '(assertExchange() for an exchange, setupServiceQueues() for a queue). A publisher declares '
313
+ + 'nothing, so a name nobody declared has nothing to route with and the message was NOT stored. '
314
+ + `Fix: add the ${kind} to src/config/queueConfig.js (confirmation mq-consumer-contract 003) and `
315
+ + `let its owner declare it, or publish to a ${kind} that already has one. No reconnect creates it, `
316
+ + 'so this does not clear by retrying.';
317
+ }
318
+
319
+ return `[RabbitMQClient] The broker refused the publish to ${kind} "${target}" with ${code} and closed `
320
+ + 'the channel - Expected: a refusal is an answer about THIS message, so repeating the publish '
321
+ + 'repeats it; the message was NOT stored. '
322
+ + "Fix: read error.cause for the broker's own sentence — 403 means the account behind RABBITMQ_URL "
323
+ + 'has no write permission in this vhost, 406 means the message itself was refused (a user_id '
324
+ + 'property that is not the authenticated user, for instance). Neither clears by retrying.';
325
+ }
326
+
327
+ /**
328
+ * Klasifikace chyb z publishu do specializovaných typů — PODLE TŘÍDY A KÓDU,
329
+ * nikdy podle textu hlášky.
330
+ *
331
+ * Its verdict is a decision about the message, not a label: `PublishLayer` retries a
332
+ * `TransientPublishError` and fails a `PermanentPublishError` or a
333
+ * `QueueNotFoundError` immediately (`PublishLayer._publishWithRetry()`). Until d.677
334
+ * that decision was read out of substrings of the message — `'queue does not exist'`,
335
+ * `'no queue'`, `'connection closing'`, `'connection closed'`, `'connection ended'`,
336
+ * `'channel closed'`, `'channel is closed'`, `'channel ended'`, `'timeout'`,
337
+ * `'confirmation timeout'`, `'reconnecting'` — so a reworded sentence moved a message
338
+ * from "retried and buffered" to "dropped", and nothing reported the move. The
339
+ * contract says the opposite: `docs/biz/70-contracts/error-handling.md` §6
340
+ * ("Message-based routing; breaks on any wording change · Instead: Branch on
341
+ * `err.code`").
342
+ *
343
+ * WHAT DECIDES, and what each carrier stands for (measured d.677 against the dev
344
+ * broker, each scenario in its own vhost; the live table is
345
+ * `tests/integration/publish-classification-carriers.integration.test.js`):
346
+ * - an error this package already typed — handed back untouched, because the publish
347
+ * path decided it with facts this function does not have (which section of
348
+ * `queueConfig` owns the name, above all);
349
+ * - `code === 404` — amqplib's `NOT_FOUND`: the queue is not there;
350
+ * - `IllegalOperationError` — amqplib's own class for a channel or connection used
351
+ * after it died, exported from amqplib's entry point;
352
+ * - `ConnectionError` / `PublishError` — what the publish path itself builds when the
353
+ * channel is gone at send time (`ConnectionError`), and when the confirm never
354
+ * arrives, when the channel closed mid-publish and when the delivery tag no longer
355
+ * exists (`PublishError`, its five sites). Every one of them means "this attempt
356
+ * did not get an answer", which is what a retry is for;
357
+ * - everything else — permanent. Its measured members are a broker refusal OTHER
358
+ * than 404 that the existence pre-check re-throws (`405 RESOURCE_LOCKED`,
359
+ * `Operation failed: QueueDeclare; 405 …`, `code: 405`), a serialization failure,
360
+ * and a broker nack (`'message nacked'`). A refusal is an answer about THIS
361
+ * request, and repeating the request repeats the answer — which is also the
362
+ * verdict every one of them has today.
363
+ *
364
+ * NOT ONE BRANCH READS THE SENTENCE — since d.683, not even by exact equality. Until
365
+ * then two carriers had to be matched by their text, because they carry nothing else:
366
+ * amqplib answers every unconfirmed publish of a channel that has just closed with
367
+ * `new Error('channel closed')` (the `Channel` constructor's `'close'` listener, `node_modules/amqplib/lib/channel.js`) and every
368
+ * RPC queued on a channel that ended with
369
+ * `new Error('Channel ended, no reply will be forthcoming')` (`_rejectPending()`, `lib/channel.js`) —
370
+ * own properties `stack` and `message`, no class, no code. They are now typed where
371
+ * they ARRIVE, by the two sites that know which channel died: `_publishOnce()`'s
372
+ * exchange callback answers with this package's `PublishError` and the queue-existence
373
+ * pre-check with `_publishPathTransient()`, each keeping amqplib's object in `cause`.
374
+ * So a bare sentence reaching THIS function means it got past a typing site, which is
375
+ * a defect to find rather than a message to retry — and it is answered like any other
376
+ * untyped error.
377
+ *
378
+ * WHAT DOES NOT ARRIVE, AND WHY IT NO LONGER MATTERS: the broker's numbered channel error
379
+ * for a refused SEND never reaches this function first-hand. That object goes to the
380
+ * channel's `error` event, while the publish callback is answered by amqplib's bare
381
+ * sentinel. Until d.685a the send legs answered it with their own `PublishError`, so a
382
+ * 403 or a 406 — a refusal of THIS message — was classified transient, retried
383
+ * `publishMaxRetries` times and then buffered, to be re-offered on every reconnect. Since
384
+ * d.685a both send legs read the close frame their own channel recorded
385
+ * (`_refusedSendPermanent()`, `src/transports/rabbitmqClient.js`) and hand over a
386
+ * `PermanentPublishError` with the broker's object in `cause` — which arrives here
387
+ * already typed and is passed through by the first rule. Since d.693 that includes the
388
+ * send's own `404` (an exchange nobody declared, carrier B1): it is permanent for the
389
+ * same reason, because no reconnect declares an exchange. What it is NOT is a
390
+ * `QueueNotFoundError` — that class belongs to the queue-existence pre-check, carries a
391
+ * `queueName` and an ownership `kind`, and still hands over the coded 404 and 405 it
392
+ * always did.
393
+ *
394
+ * NO SOCKET CODES HERE. `ECONNREFUSED`/`ENOTFOUND`/`ETIMEDOUT` never reach this
395
+ * function: `_ensurePublisherChannel()` runs BEFORE the `try` that calls it
396
+ * (`src/transports/rabbitmqClient.js`), so a publish with no connection fails above.
397
+ * A branch nothing reaches is a false guarantee (`automation-gates.md` §5).
398
+ *
399
+ * The cause chain is deliberately NOT walked: this function sits at the BOTTOM of the
400
+ * publish path — it is what builds the chain that `BaseClient.publish()` and
401
+ * `isQueueUnavailableError()` later read — and every carrier measured at this point is
402
+ * first-hand.
160
403
  *
161
404
  * The queue name comes from the CALLER, and is required. The missing-queue branch
162
405
  * below used to invent it — `new QueueNotFoundError('unknown', …)` — and the
163
- * invented name does not stay in the message: the error reaches `RecoveryWorker`
164
- * through the client's `error` channel, which reports it by `error.queueName`
165
- * ("Cannot create queue 'unknown'"), naming nothing anybody can act on. Every
406
+ * invented name does not stay in the error: `BaseClient.publish()` rejects the caller
407
+ * with a `PublishError` whose `cause` is this error, so the caller would read
408
+ * `error.cause.queueName === 'unknown'`, naming nothing anybody can act on. Every
166
409
  * caller has the real name in hand (`_publishOnce(queue, …)`,
167
410
  * `publishToMonitoringResilient(…, queueName, …)`), so it is asked for at entry
168
411
  * (`architecture-principles.md` §4) rather than guessed in one branch (§3, and the
@@ -195,10 +438,8 @@ function classifyPublishError(err, queueName) {
195
438
  return err;
196
439
  }
197
440
 
198
- const message = (err.message || String(err)).toLowerCase();
199
-
200
441
  // Queue neexistuje – trvalý problém, nerozbitný retry
201
- if (message.includes('queue does not exist') || message.includes('no queue') || err.code === 404) {
442
+ if (err.code === AMQP_NOT_FOUND) {
202
443
  // Which section of queueConfig owns the name is not known here — that lookup
203
444
  // belongs to the publish path, which asks it before this classification is ever
204
445
  // reached — so the error carries the name and `kind: 'unknown'`, whose message
@@ -208,15 +449,9 @@ function classifyPublishError(err, queueName) {
208
449
 
209
450
  // Typicky transientní problémy connection/channel
210
451
  if (
211
- message.includes('connection closing') ||
212
- message.includes('connection closed') ||
213
- message.includes('connection ended') ||
214
- message.includes('channel closed') ||
215
- message.includes('channel is closed') ||
216
- message.includes('channel ended') ||
217
- message.includes('timeout') ||
218
- message.includes('confirmation timeout') ||
219
- message.includes('reconnecting')
452
+ err instanceof IllegalOperationError ||
453
+ err instanceof ConnectionError ||
454
+ err instanceof PublishError
220
455
  ) {
221
456
  return new TransientPublishError(err.message || String(err), err);
222
457
  }
@@ -226,8 +461,12 @@ function classifyPublishError(err, queueName) {
226
461
  }
227
462
 
228
463
  module.exports = {
464
+ AMQP_NOT_FOUND,
465
+ AMQP_PRECONDITION_FAILED,
466
+ collateralCloseMessage,
229
467
  missingInfrastructureQueueMessage,
230
468
  missingQueueDefinitionMessage,
469
+ refusedSendMessage,
231
470
  TransientPublishError,
232
471
  PermanentPublishError,
233
472
  QueueNotFoundError,
package/jest.config.js DELETED
@@ -1,15 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * Default tier: unit + component only.
5
- *
6
- * `tests/integration` is excluded here and served by `jest.integration.config.js`
7
- * instead, because that tier fails fast on a missing `RABBITMQ_URL` — folding it
8
- * into the default run would make `npm test` unrunnable without a broker, and the
9
- * usual "fix" for that is a skip, which is how a suite quietly leaves the
10
- * regression loop (`.claude/rules/service-refactoring.md` #4).
11
- */
12
- module.exports = {
13
- testEnvironment: 'node',
14
- testPathIgnorePatterns: ['/node_modules/', '/tests/integration/']
15
- };
@@ -1,16 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * Integration tier: runs against the live broker, never skipped.
5
- *
6
- * `globalSetup` aborts the whole run before Jest builds a describe tree when
7
- * `RABBITMQ_URL` is missing or the broker is unreachable — see
8
- * `tests/integration/setup.js`.
9
- */
10
- module.exports = {
11
- testEnvironment: 'node',
12
- testMatch: ['**/tests/integration/**/*.test.js'],
13
- testTimeout: 30000,
14
- globalSetup: './tests/integration/setup.js',
15
- verbose: true
16
- };
@@ -1,130 +0,0 @@
1
- 'use strict';
2
-
3
- const { QueueNotFoundError } = require('../utils/publishErrors');
4
- const { ValidationError } = require('../utils/errorHandler');
5
- const { assertLogger } = require('@onlineapps/logger-contract');
6
-
7
- /**
8
- * RecoveryWorker — odpovídá na problémy publish cesty (connection recovery,
9
- * hlášení chybějící fronty).
10
- *
11
- * Zakládání front — NEZAKLÁDÁ ŽÁDNOU. Fronta vzniká výhradně z deklarace:
12
- * infrastrukturní ji zakládá vlastnící infra služba při bootu, business ji
13
- * zakládá vlastnící služba přes `setupServiceQueues()` nad šablonou z
14
- * `queueConfig` po registraci, a jméno, které `queueConfig` neklasifikuje ani
15
- * jako jedno, nezakládá nikdo. Rozhodnutí vlastníka
16
- * (`docs/governance/confirmations/mq-consumer-contract.md` 006) říká obojí
17
- * větou: *„queues come into being only from declarations"* a *„A future family
18
- * of queues that a service must create ad hoc gets a `queueConfig` template
19
- * first, never a generic create-anything method."*
20
- *
21
- * Do d.419 tady byly DRUHÉ dveře k těm, které publish cesta v témže kroku
22
- * zavírala: `createQueue()` a delegující `queueCreationCallback` zakládaly
23
- * frontu bez vlastníka s `durable=true, arguments={}` — bez TTL a bez cesty pro
24
- * nedoručitelné zprávy. Taková fronta je od d.259 nekonzumovatelná
25
- * (`consume()` odmítne frontu, které konfigurace žádnou cestu nedeklaruje), takže
26
- * ty dveře vyráběly fronty, do kterých lze publikovat a nelze z nich číst.
27
- * Čtyři otázky (`change-discipline.md` § Removing something removes its
28
- * declaration) jsou zodpovězené v CHANGELOGu dávky.
29
- *
30
- * `scope` tedy už o zakládání front nerozhodoval; od d.419 neříkal nic a od d.514
31
- * tu není. Co rozhoduje o tvaru hlášení, je třída vlastnictví, kterou odpověděla
32
- * CENTRÁLNÍ deklarace — `error.kind` z `QueueNotFoundError` — takže dva klienti
33
- * namířené na tutéž frontu nemohou dostat různou odpověď podle toho, kdo je
34
- * postavil. Čtyři otázky k odstranění jsou zodpovězené v CHANGELOGu dávky.
35
- */
36
- class RecoveryWorker {
37
- /**
38
- * @param {Object} options
39
- * @param {Object} options.client - RabbitMQClient instance
40
- * @param {Object} options.logger - Logger s info/warn/error/debug (povinny)
41
- */
42
- constructor(options = {}) {
43
- if (!options.client) {
44
- throw new ValidationError(
45
- '[RecoveryWorker] options.client is required - Expected: the RabbitMQClient whose publish failures the worker answers. '
46
- + 'Fix: construct it as new RecoveryWorker({ client, logger }).'
47
- );
48
- }
49
-
50
- this._client = options.client;
51
- this._logger = assertLogger(
52
- 'RecoveryWorker',
53
- options.logger,
54
- 'the worker reports every recovery decision it makes about a queue that does not exist',
55
- 'pass options.logger (the transport forwards the one your service built)'
56
- );
57
- }
58
-
59
- /**
60
- * Handle transient publish error
61
- * @param {Error} error - TransientPublishError
62
- * @param {Object} context - { queue, buffer, options }
63
- */
64
- async handleTransientError(error, context) {
65
- // Transient errors jsou už bufferované v PublishLayer
66
- // Recovery worker jen loguje a může triggerovat další akce
67
- this._logger.warn(`[RecoveryWorker] Transient error handled (buffered): ${error.message}`, context);
68
- }
69
-
70
- /**
71
- * Report a queue that does not exist, and rethrow.
72
- *
73
- * Every class of name ends the same way — the worker creates nothing — and the
74
- * three branches differ only in which rule the reader has to act on: who owns
75
- * the queue, and how it is meant to come into being.
76
- *
77
- * @param {QueueNotFoundError} error
78
- * @param {Object} context - { queue, buffer, options }
79
- */
80
- async handleQueueNotFound(error, context) {
81
- if (!(error instanceof QueueNotFoundError)) {
82
- return;
83
- }
84
-
85
- // Infrastructure queues must exist - cannot create
86
- if (error.isInfrastructure) {
87
- this._logger.error(`[RecoveryWorker] Cannot create infrastructure queue '${error.queueName}' - must exist before publishing`);
88
- throw error;
89
- }
90
-
91
- // Business queues ({service}.{workflow|queue|dlq}) must exist too — they are
92
- // created by their owning service via setupServiceQueues() AFTER registration,
93
- // with the arguments queueConfig prescribes.
94
- //
95
- // Measured 2026-08-28: without this branch the ownership guard in
96
- // `_publishOnce` was defeated by its own error path. The publish was refused
97
- // correctly, the caller saw PublishError — and then this worker, listening for
98
- // QueueNotFoundError on the client's error event, created the very queue the
99
- // guard had just refused ("[RecoveryWorker] ✓ Created queue '<q>' (direct)"),
100
- // with default arguments and no TTL. Refusing at one door while opening the
101
- // other is not a guard.
102
- //
103
- // `recoveryScope: 'business'` said the CLIENT belongs to a business service.
104
- // It never meant "may create business queues", and `ConnectorMQClient` set it
105
- // for every biz service, so that reading made the rule vacuous. The key is
106
- // gone with the door it guarded (d.514); what decides here is `error.kind`,
107
- // which the central declaration answers.
108
- if (error.kind === 'business') {
109
- this._logger.error(`[RecoveryWorker] Cannot create business queue '${error.queueName}' - it is created by its owning service via setupServiceQueues() after registration`);
110
- throw error;
111
- }
112
-
113
- // Everything else — a name `queueConfig` classifies as neither. Until d.419
114
- // this was the one branch that DID create, and it is the same "one door
115
- // refuses while the other opens" the business branch above records, one class
116
- // of name further out: the publish path refused the name and this handler,
117
- // answering the very error that refusal raised, declared it anyway. There is
118
- // no owner to start and no template to call, so the report names the only
119
- // thing the reader can act on — the missing declaration.
120
- this._logger.error(
121
- `[RecoveryWorker] Cannot create queue '${error.queueName}' - no section of queueConfig declares it, `
122
- + 'and a queue is only ever brought into being from a declaration. Fix: add a template for this '
123
- + 'name to queueConfig and let its owner declare it (confirmation mq-consumer-contract 003), or '
124
- + 'publish to a queue that already has one.'
125
- );
126
- throw error;
127
- }
128
- }
129
-
130
- module.exports = RecoveryWorker;