@onlineapps/mq-client-core 2.0.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.
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  const RabbitMQClient = require('./rabbitmqClient');
9
+ const { ValidationError } = require('../utils/errorHandler');
9
10
 
10
11
  /**
11
12
  * Factory method: returns a transport instance based on config.type.
@@ -16,7 +17,10 @@ const RabbitMQClient = require('./rabbitmqClient');
16
17
  */
17
18
  function create(config) {
18
19
  if (!config || !config.type) {
19
- throw new Error('Transport type is required in configuration');
20
+ throw new ValidationError(
21
+ '[transportFactory] Transport type is required in configuration - Expected: config.type naming a supported transport. '
22
+ + "Fix: pass { type: 'rabbitmq', … } to the client constructor."
23
+ );
20
24
  }
21
25
 
22
26
  switch (config.type.toLowerCase()) {
@@ -24,7 +28,10 @@ function create(config) {
24
28
  return new RabbitMQClient(config);
25
29
 
26
30
  default:
27
- throw new Error(`Unsupported transport type: ${config.type}`);
31
+ throw new ValidationError(
32
+ `[transportFactory] Unsupported transport type: ${config.type} - Expected: one of the implemented transports ('rabbitmq'). `
33
+ + "Fix: set config.type to 'rabbitmq'."
34
+ );
28
35
  }
29
36
  }
30
37
 
@@ -31,20 +31,61 @@ class ValidationError extends Error {
31
31
  * Thrown when client cannot connect to broker.
32
32
  * - message: description of what went wrong
33
33
  * - cause: original error object
34
+ * - code: machine-readable classification, for the receivers that must decide what an
35
+ * error MEANS. A decision taken on the message text breaks the moment somebody
36
+ * rewords the sentence, and nothing reports it (`architecture-principles.md` §3).
34
37
  */
35
38
  class ConnectionError extends Error {
36
39
  /**
37
40
  * @param {string} message
38
- * @param {Error} cause
41
+ * @param {Error} [cause]
42
+ * @param {string} [code] one of the codes below
39
43
  */
40
- constructor(message, cause) {
44
+ constructor(message, cause, code) {
41
45
  super(message);
42
46
  this.name = 'ConnectionError';
43
47
  this.cause = cause;
48
+ this.code = code;
44
49
  Error.captureStackTrace(this, ConnectionError);
45
50
  }
46
51
  }
47
52
 
53
+ /**
54
+ * The broker connection dropped while the client still wanted it.
55
+ *
56
+ * It is a STATE, not the failure of whatever observed it: while a reconnect is in
57
+ * flight this is the normal condition of the very thing being waited for, so
58
+ * `RabbitMQClient._waitForReconnection()` skips it and keeps waiting. The wait also
59
+ * skips anything carrying a publish classification (`utils/publishErrors.js` sets
60
+ * `retryable` on every error it produces, retryable or not): that reports somebody
61
+ * else's message, not this connection (d.435, widened to the whole rail in d.435b).
62
+ * Everything else — an error with no classification at all — ends that wait.
63
+ */
64
+ const CONNECTION_CLOSED_UNEXPECTEDLY = 'CONNECTION_CLOSED_UNEXPECTEDLY';
65
+
66
+ /**
67
+ * The single definition of that error — both connection close handlers emit exactly
68
+ * this, and the reconnect wait recognises it by `error.code`, never by its wording.
69
+ *
70
+ * Measured defect it closes (d.153a → d.164): the handlers emitted
71
+ * `new Error('RabbitMQ connection closed unexpectedly')` while the wait asked
72
+ * `message.includes('Connection closed unexpectedly')` — a capital `C` apart, so the
73
+ * substring never matched and the branch that was written to ignore this error had
74
+ * never once run.
75
+ *
76
+ * @returns {ConnectionError}
77
+ */
78
+ function connectionClosedUnexpectedly() {
79
+ return new ConnectionError(
80
+ '[RabbitMQClient] Connection closed unexpectedly - '
81
+ + 'Expected: the broker connection to stay open until disconnect() closes it. '
82
+ + 'Fix: check the broker is up and reachable; the client reconnects on its own until '
83
+ + 'RABBITMQ_MAX_RECONNECT_ATTEMPTS is spent and then emits connection:fatal.',
84
+ undefined,
85
+ CONNECTION_CLOSED_UNEXPECTEDLY
86
+ );
87
+ }
88
+
48
89
  /**
49
90
  * PublishError
50
91
  * Thrown when publishing a message fails.
@@ -73,22 +114,56 @@ class PublishError extends Error {
73
114
  * - message: description
74
115
  * - queue: queue name associated with the consumer
75
116
  * - cause: original error
117
+ * - code: machine-readable class of the refusal, for the caller that must decide
118
+ * what to DO about it. Absent when the failure belongs to no named class —
119
+ * never a catch-all value, because "I do not know which of these it is" and
120
+ * "it is this one" are different answers.
76
121
  */
77
122
  class ConsumeError extends Error {
78
123
  /**
79
124
  * @param {string} message
80
125
  * @param {string} queue
81
- * @param {Error} cause
126
+ * @param {Error} [cause]
127
+ * @param {string} [code] one of the consumer codes below
82
128
  */
83
- constructor(message, queue, cause) {
129
+ constructor(message, queue, cause, code) {
84
130
  super(message);
85
131
  this.name = 'ConsumeError';
86
132
  this.queue = queue;
87
133
  this.cause = cause;
134
+ if (code !== undefined) this.code = code;
88
135
  Error.captureStackTrace(this, ConsumeError);
89
136
  }
90
137
  }
91
138
 
139
+ /**
140
+ * The two classes of refusal `consume()` makes before a consumer is attached,
141
+ * as codes rather than as sentences.
142
+ *
143
+ * They exist because those two refusals ask the reader for OPPOSITE actions, and
144
+ * until 2026-09-14 they arrived wearing the same sentence: `BaseClient.consume()`
145
+ * rewrote every failure as "the queue to exist before consume() attaches to it",
146
+ * which for a queue with no declared dead-letter route is false in every clause —
147
+ * the queue exists, creating it changes nothing, and its owning service is not
148
+ * who has to act. The true sentence survived only in `error.cause`, where a log
149
+ * line does not look.
150
+ *
151
+ * A caller decides on the CODE. Deciding on the wording breaks the moment somebody
152
+ * rewords the sentence, and nothing reports the break (`architecture-principles.md`
153
+ * §3) — the defect d.164 measured, where a reconnect wait compared strings and its
154
+ * branch had never once run.
155
+ */
156
+
157
+ /** The queue does not exist. Its owner creates it; the consumer never does. */
158
+ const CONSUMER_QUEUE_MISSING = 'CONSUMER_QUEUE_MISSING';
159
+
160
+ /**
161
+ * The queue exists, and `queueConfig` declares no dead-letter route for it, so
162
+ * the delivery policy has nowhere to reject a spent message to (d.259). The fix
163
+ * is a declaration, not a queue.
164
+ */
165
+ const CONSUMER_DEAD_LETTER_ROUTE_MISSING = 'CONSUMER_DEAD_LETTER_ROUTE_MISSING';
166
+
92
167
  /**
93
168
  * SerializationError
94
169
  * Thrown when JSON serialization or deserialization fails.
@@ -114,8 +189,12 @@ class SerializationError extends Error {
114
189
  module.exports = {
115
190
  ValidationError,
116
191
  ConnectionError,
192
+ CONNECTION_CLOSED_UNEXPECTEDLY,
193
+ connectionClosedUnexpectedly,
117
194
  PublishError,
118
195
  ConsumeError,
196
+ CONSUMER_QUEUE_MISSING,
197
+ CONSUMER_DEAD_LETTER_ROUTE_MISSING,
119
198
  SerializationError,
120
199
  };
121
200
 
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * "Did you mean this one" — the one implementation of it in this package.
5
+ *
6
+ * It lived inside `BaseClient.js` from d.291b, where the config schema first
7
+ * refused an undeclared key by name. d.396c gave `assertQueue()` the same duty
8
+ * for its own option set, and a second copy of an edit distance would be a
9
+ * second rail for one concern (`change-discipline.md` § One rail per concern) —
10
+ * so the pair moved here and both callers require it. The behaviour is
11
+ * unchanged; the tests that pinned it (`tests/unit/config-schema-refuses-unknown-key.test.js`)
12
+ * still read it through `BaseClient`.
13
+ */
14
+
15
+ /**
16
+ * Edit distance between two names, compared case-insensitively.
17
+ *
18
+ * The classic Levenshtein recurrence over one rolling row. It exists for ONE
19
+ * purpose — turning "this key is not declared" into "did you mean this one" — so
20
+ * it is deliberately the plainest metric there is: same inputs, same answer, no
21
+ * dictionary, no heuristics to explain (`automation-gates.md` §1 requirement 1).
22
+ *
23
+ * @param {string} a
24
+ * @param {string} b
25
+ * @returns {number} insertions + deletions + substitutions between the two.
26
+ */
27
+ function editDistance(a, b) {
28
+ const from = a.toLowerCase();
29
+ const to = b.toLowerCase();
30
+ let previous = Array.from({ length: to.length + 1 }, (_, j) => j);
31
+
32
+ for (let i = 1; i <= from.length; i += 1) {
33
+ const row = [i];
34
+ for (let j = 1; j <= to.length; j += 1) {
35
+ row[j] = Math.min(
36
+ previous[j] + 1,
37
+ row[j - 1] + 1,
38
+ previous[j - 1] + (from[i - 1] === to[j - 1] ? 0 : 1)
39
+ );
40
+ }
41
+ previous = row;
42
+ }
43
+
44
+ return previous[to.length];
45
+ }
46
+
47
+ /**
48
+ * The declared key a written name is closest to, or `null` when nothing is close
49
+ * enough to name.
50
+ *
51
+ * The budget is a third of the longer of the two names, rounded up: `prefech` ->
52
+ * `prefetch` (distance 1) is a typo worth pointing at, `queue` -> `type`
53
+ * (distance 4 over a 5-character name) is not, and a guess that far off steers a
54
+ * reader away from the real answer, which is that the key is not a key at all.
55
+ * Ties keep the FIRST declared key, so the sentence does not change with the
56
+ * iteration order of an object (`automation-gates.md` §1 requirement 1).
57
+ *
58
+ * @param {string} written - The key the caller wrote.
59
+ * @param {string[]} declaredKeys - Every key the schema declares, subclass keys included.
60
+ * @returns {string|null}
61
+ */
62
+ function nearestDeclaredKey(written, declaredKeys) {
63
+ let nearest = null;
64
+ let nearestDistance = Infinity;
65
+
66
+ for (const candidate of declaredKeys) {
67
+ const distance = editDistance(written, candidate);
68
+ if (distance < nearestDistance) {
69
+ nearestDistance = distance;
70
+ nearest = candidate;
71
+ }
72
+ }
73
+
74
+ if (nearest === null) {
75
+ return null;
76
+ }
77
+
78
+ const budget = Math.ceil(Math.max(written.length, nearest.length) / 3);
79
+ return nearestDistance <= budget ? nearest : null;
80
+ }
81
+
82
+ /**
83
+ * The undeclared keys of an object, each named together with the declared key it
84
+ * is closest to — the half of the refusal sentence both callers share.
85
+ *
86
+ * @param {string[]} unknownKeys - Keys the declared set does not contain.
87
+ * @param {string[]} declaredKeys - The declared set.
88
+ * @returns {string} e.g. `"persistent" (no declared key is close to it)`
89
+ */
90
+ function nameUnknownKeys(unknownKeys, declaredKeys) {
91
+ return unknownKeys
92
+ .map((key) => {
93
+ const nearest = nearestDeclaredKey(key, declaredKeys);
94
+ return nearest === null
95
+ ? `"${key}" (no declared key is close to it)`
96
+ : `"${key}" (the closest declared key is "${nearest}")`;
97
+ })
98
+ .join(', ');
99
+ }
100
+
101
+ module.exports = { editDistance, nearestDeclaredKey, nameUnknownKeys };
@@ -36,8 +36,8 @@ class QueueNotFoundError extends PermanentPublishError {
36
36
  * @param {string} queueName
37
37
  * @param {boolean} isInfrastructure
38
38
  * @param {Error} [cause]
39
- * @param {('infrastructure'|'business'|'unknown')} [kind] - Which ownership rule
40
- * was violated. Defaults to `'infrastructure'`/`'unknown'` from
39
+ * @param {('infrastructure'|'business'|'unowned'|'unknown')} [kind] - Which ownership
40
+ * rule was violated. Defaults to `'infrastructure'`/`'unknown'` from
41
41
  * `isInfrastructure`, so existing two- and three-argument callers are unchanged.
42
42
  */
43
43
  constructor(queueName, isInfrastructure, cause, kind) {
@@ -45,19 +45,42 @@ class QueueNotFoundError extends PermanentPublishError {
45
45
  let baseMessage;
46
46
  if (resolvedKind === 'infrastructure') {
47
47
  baseMessage =
48
- `Cannot publish to infrastructure queue ${queueName}: queue does not exist. ` +
49
- 'Infrastructure queues must be created explicitly with correct arguments ' +
50
- '(TTL, max-length, etc.) before publishing.';
48
+ `[RabbitMQClient] Cannot publish to infrastructure queue ${queueName}: queue does not exist. ` +
49
+ 'Expected: infrastructure queues are created explicitly, with the arguments queueConfig ' +
50
+ 'prescribes (TTL, max-length, ), before anything publishes to them. ' +
51
+ 'Fix: start the infrastructure service that owns the queue — it calls ' +
52
+ 'initInfrastructureQueues() from @onlineapps/infrastructure-tools — and publish afterwards.';
51
53
  } else if (resolvedKind === 'business') {
52
54
  baseMessage =
53
- `Cannot publish to business queue ${queueName}: queue does not exist. ` +
55
+ `[RabbitMQClient] Cannot publish to business queue ${queueName}: queue does not exist. ` +
54
56
  'Expected: business queues are created by their owning service via ' +
55
57
  'setupServiceQueues() AFTER successful registration, with the arguments ' +
56
58
  'queueConfig prescribes (TTL, DLQ). Fix: call setupServiceQueues() before ' +
57
59
  'publishing — publishing must never create the queue, because sendToQueue() ' +
58
60
  'would create it with default arguments (no TTL, no DLQ).';
61
+ } else if (resolvedKind === 'unowned') {
62
+ // A name the central config classifies as NEITHER infrastructure nor
63
+ // business. Nothing declares it, so nothing may bring it into being: a
64
+ // queue created here would carry no TTL and no dead-letter route, and
65
+ // `consume()` refuses a queue with no declared route (d.259) — publishable
66
+ // and unconsumable, which is not a queue anybody can use. The owner's
67
+ // decision says how a genuinely new family of queues arrives instead:
68
+ // a template in queueConfig first, "never a generic create-anything
69
+ // method" (`docs/governance/confirmations/mq-consumer-contract.md` 006).
70
+ baseMessage =
71
+ `[RabbitMQClient] Queue ${queueName} has no owner in queueConfig - ` +
72
+ 'Expected: every queue to come into being from a declaration — an infrastructure queue from ' +
73
+ 'the service that owns it at boot, a business queue from setupServiceQueues() over the ' +
74
+ 'queueConfig template. A publisher declares neither, and this name matches neither. ' +
75
+ 'Fix: add a template for this name to src/config/queueConfig.js (confirmation ' +
76
+ 'mq-consumer-contract 003) and let its owner declare it, or publish to a queue that ' +
77
+ 'already has one.';
59
78
  } else {
60
- baseMessage = `Queue ${queueName} does not exist`;
79
+ baseMessage =
80
+ `[RabbitMQClient] Queue ${queueName} does not exist - Expected: the queue to have been created by whoever owns it. ` +
81
+ 'Fix: the owner is not known at this point, so identify it from the queue name — an infrastructure prefix ' +
82
+ '(workflow./registry./infrastructure./monitoring.) means an infrastructure service, anything else means the ' +
83
+ 'business service the first name segment belongs to.';
61
84
  }
62
85
  super(baseMessage, cause);
63
86
  this.name = 'QueueNotFoundError';
@@ -93,14 +116,72 @@ function missingInfrastructureQueueMessage(queueName) {
93
116
  + '@onlineapps/infrastructure-tools.';
94
117
  }
95
118
 
119
+ /**
120
+ * The message a CONSUMER gets when the central config holds no DEFINITION for the
121
+ * queue it must attach to — a configuration defect, never a runtime condition.
122
+ *
123
+ * ONE producer for both classes of name. The sentence differs in exactly two
124
+ * places — which part of `queueConfig` must declare the name, and what a reader
125
+ * adds there — and writing that difference as a ternary at the `new ConsumeError`
126
+ * call site puts the whole message where no test can reach it
127
+ * (`tests/unit/error-message-contract.test.js`: a non-literal argument must be a
128
+ * named `…Message()` producer, checked at runtime beside its siblings).
129
+ *
130
+ * @param {string} queueName - The queue whose definition is missing.
131
+ * @param {boolean} isInfrastructure - Whether the name is an infrastructure one.
132
+ * Required, and required to be a boolean: a missing third state would be
133
+ * rendered as "business" by a falsy value, which is the wrong instruction.
134
+ * @returns {string}
135
+ */
136
+ function missingQueueDefinitionMessage(queueName, isInfrastructure) {
137
+ if (!queueName) {
138
+ throw new Error('[publishErrors] queueName is required - Expected the name of the queue whose definition is missing');
139
+ }
140
+
141
+ if (typeof isInfrastructure !== 'boolean') {
142
+ throw new Error(
143
+ '[publishErrors] isInfrastructure is required - Expected: a boolean saying which part of queueConfig '
144
+ + 'must declare the name. Fix: pass queueConfig.isInfrastructureQueue(queueName).'
145
+ );
146
+ }
147
+
148
+ return isInfrastructure
149
+ ? `[RabbitMQClient] No queue definition for infrastructure queue "${queueName}" - `
150
+ + 'Expected: queueConfig defines every infrastructure queue that is consumed. '
151
+ + 'Fix: add the queue to src/config/queueConfig.js, or consume the name the owning service declares.'
152
+ : `[RabbitMQClient] No queue definition for business queue "${queueName}" - `
153
+ + 'Expected: queueConfig defines a template for every business queue type (workflow, queue, dlq). '
154
+ + 'Fix: add the template to src/config/queueConfig.js, or consume the name the owning service declares.';
155
+ }
156
+
96
157
  /**
97
158
  * Best-effort klasifikace chyb z publishu do specializovaných typů.
98
159
  * Vrací původní chybu, pokud neodpovídá žádnému známému patternu.
99
160
  *
161
+ * The queue name comes from the CALLER, and is required. The missing-queue branch
162
+ * 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
166
+ * caller has the real name in hand (`_publishOnce(queue, …)`,
167
+ * `publishToMonitoringResilient(…, queueName, …)`), so it is asked for at entry
168
+ * (`architecture-principles.md` §4) rather than guessed in one branch (§3, and the
169
+ * same defect this package retired for the workflow id in d.416).
170
+ *
100
171
  * @param {Error} err
172
+ * @param {string} queueName - The queue whose publish failed.
101
173
  * @returns {Error} - buď speciální error, nebo původní err
102
174
  */
103
- function classifyPublishError(err) {
175
+ function classifyPublishError(err, queueName) {
176
+ if (typeof queueName !== 'string' || queueName === '') {
177
+ throw new Error(
178
+ '[publishErrors] classifyPublishError requires the queue name - '
179
+ + 'Expected: the caller names the queue whose publish failed, so a missing-queue error '
180
+ + 'carries a real name instead of an invented one. '
181
+ + 'Fix: pass it as the second argument, e.g. classifyPublishError(err, queue).'
182
+ );
183
+ }
184
+
104
185
  if (!err) {
105
186
  return err;
106
187
  }
@@ -118,8 +199,11 @@ function classifyPublishError(err) {
118
199
 
119
200
  // Queue neexistuje – trvalý problém, nerozbitný retry
120
201
  if (message.includes('queue does not exist') || message.includes('no queue') || err.code === 404) {
121
- // Nevíme, zda je to infra/biz necháme isInfrastructure=false, publish vrstva si může dovodit sama
122
- return new QueueNotFoundError('unknown', false, err);
202
+ // Which section of queueConfig owns the name is not known here that lookup
203
+ // belongs to the publish path, which asks it before this classification is ever
204
+ // reached — so the error carries the name and `kind: 'unknown'`, whose message
205
+ // tells the reader how to identify the owner from it.
206
+ return new QueueNotFoundError(queueName, false, err);
123
207
  }
124
208
 
125
209
  // Typicky transientní problémy connection/channel
@@ -143,6 +227,7 @@ function classifyPublishError(err) {
143
227
 
144
228
  module.exports = {
145
229
  missingInfrastructureQueueMessage,
230
+ missingQueueDefinitionMessage,
146
231
  TransientPublishError,
147
232
  PermanentPublishError,
148
233
  QueueNotFoundError,
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * redactCredentials.js
5
+ *
6
+ * ONE rail for making a broker connection target safe to log. Both connect
7
+ * paths (BaseClient and the RabbitMQ transport) pass their target through this
8
+ * function before it reaches stdout; nothing else in this package renders a
9
+ * host, a URL or a config object into a log line.
10
+ *
11
+ * Why it exists: the full AMQP URL — password included — was printed on every
12
+ * boot, so the broker account was readable in container stdout and, through the
13
+ * monitoring consumer, in Loki (measured 2026-09-07 on api_service_hello).
14
+ *
15
+ * `redactUrl` returns the URL with the WHOLE userinfo removed: what stays is
16
+ * scheme, host, port, vhost and query — the part a reader of the log actually
17
+ * came for. The account NAME goes with the password, because it is the other
18
+ * half of the same credential, and because this is the redaction semantics the
19
+ * platform already writes: `shared/service-common/src/redactUrl.js`
20
+ * (d.245/d.245b, Redis) and `shared/connector/conn-orch-registry/src/redactUrl.js`
21
+ * (d.446, AMQP). Until d.448 this module masked the password only, which made
22
+ * two semantics for one concern — two rails (`change-discipline.md` § One rail
23
+ * per concern). `src/index.js` exports the function so the dependants import it
24
+ * instead of keeping a local copy.
25
+ *
26
+ * A value that is not a parseable URL is answered with a fixed placeholder
27
+ * rather than echoed: an unparseable string is exactly the case where nobody can
28
+ * say whether it holds a credential, and echoing it would be the leak this
29
+ * helper exists to prevent.
30
+ */
31
+
32
+ const UNPARSEABLE_PLACEHOLDER = '<unparseable-url>';
33
+
34
+ /**
35
+ * Strip userinfo (`user:password@`) from a connection URL.
36
+ *
37
+ * @param {string} url - connection URL, e.g. `amqp://oa_dev:secret@queuer:5672/vhost`
38
+ * @returns {string} the URL without userinfo, e.g. `amqp://queuer:5672/vhost`,
39
+ * or `<unparseable-url>` when the input is not a URL.
40
+ */
41
+ function redactUrl(url) {
42
+ if (typeof url !== 'string' || url.length === 0) {
43
+ return UNPARSEABLE_PLACEHOLDER;
44
+ }
45
+ let parsed;
46
+ try {
47
+ parsed = new URL(url);
48
+ } catch (_) {
49
+ return UNPARSEABLE_PLACEHOLDER;
50
+ }
51
+ // `new URL('api_services_queuer:5672')` SUCCEEDS — it reads `api_services_queuer:`
52
+ // as the scheme and `5672` as an opaque path, so the value comes back with an
53
+ // empty host and no userinfo to strip. Without this guard such a value would be
54
+ // echoed verbatim, which is the leak this helper exists to prevent.
55
+ if (!parsed.host) {
56
+ return UNPARSEABLE_PLACEHOLDER;
57
+ }
58
+ parsed.username = '';
59
+ parsed.password = '';
60
+ return parsed.href;
61
+ }
62
+
63
+ /**
64
+ * Makes a connection target safe to log.
65
+ *
66
+ * ONE shape, because this client has one: `host` is a non-empty URL STRING,
67
+ * declared so by `config/configSchema.js`, resolved from `RABBITMQ_URL` when the
68
+ * caller omits it, and refused by the transport constructor when it is anything
69
+ * else (d.292 §2/§3). Until d.311 this module also carried `redactOptions()` for
70
+ * amqplib's options-object target — a branch no input could reach any more, kept
71
+ * alive only by its own tests. The four questions of `change-discipline.md`
72
+ * § Removing something removes its declaration:
73
+ *
74
+ * 1. it arrived with the redactor, when amqplib's two target shapes were both
75
+ * accepted here;
76
+ * 2. the concept that carried it — "a connection target must be safe to log,
77
+ * whatever shape it has" — was narrowed by d.292 to one shape, on purpose:
78
+ * the second one silently discarded the caller's value;
79
+ * 3. nothing reads it because that shape cannot arrive, not because anybody
80
+ * stopped caring;
81
+ * 4. what stands in its place is more conceptual — one declared shape, refused
82
+ * loudly if it is anything else — so the answer is "delete", and a branch
83
+ * that can never run is the silence `automation-gates.md` §5 calls a defect.
84
+ *
85
+ * It throws where `redactUrl` returns a placeholder, and that difference is
86
+ * deliberate: this function guards a DECLARED config key, where a wrong type is
87
+ * a boot-time defect the service must die on (§4 fail-fast), while `redactUrl`
88
+ * serves log lines and error messages, where a throw would replace the report
89
+ * with a second incident.
90
+ *
91
+ * @param {string} target - AMQP URL string.
92
+ * @returns {string} The same URL without its userinfo.
93
+ * @throws {TypeError} If the target is not a string. The message names the key to
94
+ * write and NEVER the rejected value, which carries the broker password.
95
+ */
96
+ function redactConnectionTarget(target) {
97
+ if (typeof target === 'string') return redactUrl(target);
98
+
99
+ throw new TypeError(
100
+ `[redactCredentials] Unsupported connection target - expected an AMQP URL string, got ` +
101
+ `"${target === null ? 'null' : typeof target}". ` +
102
+ `Fix: set config.host to the broker URL.`
103
+ );
104
+ }
105
+
106
+ module.exports = { redactConnectionTarget, redactUrl, UNPARSEABLE_PLACEHOLDER };
@@ -19,7 +19,12 @@ function serialize(obj) {
19
19
  try {
20
20
  return JSON.stringify(obj);
21
21
  } catch (err) {
22
- throw new SerializationError('Failed to serialize object', obj, err);
22
+ throw new SerializationError(
23
+ '[serializer] Failed to serialize object - Expected: a value JSON.stringify accepts. '
24
+ + 'Fix: read error.cause for the reason (circular reference, BigInt, …) and error.payload for the value that was rejected.',
25
+ obj,
26
+ err
27
+ );
23
28
  }
24
29
  }
25
30
 
@@ -34,7 +39,12 @@ function deserialize(buffer) {
34
39
  const str = Buffer.isBuffer(buffer) ? buffer.toString('utf8') : buffer;
35
40
  return JSON.parse(str);
36
41
  } catch (err) {
37
- throw new SerializationError('Failed to deserialize payload', buffer, err);
42
+ throw new SerializationError(
43
+ '[serializer] Failed to deserialize payload - Expected: a Buffer or string holding valid JSON. '
44
+ + 'Fix: read error.cause for the parse reason and error.payload for the bytes that were rejected.',
45
+ buffer,
46
+ err
47
+ );
38
48
  }
39
49
  }
40
50