@onlineapps/mq-client-core 4.1.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.
@@ -51,16 +51,22 @@
51
51
  * environment form goes through numeric coercion, because an environment
52
52
  * variable has no other shape (`../config.js`, `../defaults.js`).
53
53
  *
54
- * Both refusals are raised at the throw site itself
55
- * (`transports/rabbitmqClient.js` `consume()`), not here: a message this library
56
- * hands a caller must open with the context that produced it
57
- * (`architecture-principles.md` §5), and a context passed in as a parameter is
58
- * invisible to the guard that checks that
59
- * (`tests/unit/error-message-contract.test.js`).
54
+ * The option rules — `requeueOnError` a boolean, `requeueOnError: false` meaning
55
+ * one attempt and contradicting any other `maxAttempts`, the budget an integer
56
+ * >= 1, `classify` a function — live in `validateConsumeOptions` below, the one
57
+ * owner the transport's `consume()` calls and the package exports (d.1005), so a
58
+ * consumer's test double asks the same function instead of copying the rules.
59
+ * Every refusal opens with the literal context of the call it guards,
60
+ * `[RabbitMQClient]`, written at the throw site: a context passed in as a
61
+ * parameter would be invisible to the guard that checks it
62
+ * (`tests/unit/error-message-contract.test.js`), and the texts are the ones
63
+ * `consume()` has always thrown.
60
64
  *
61
65
  * @see ../../README.md § consume() — the delivery contract
62
66
  */
63
67
 
68
+ const { ValidationError } = require('../utils/errorHandler');
69
+
64
70
  /** Header carrying how many attempts a message has already cost. Never a literal elsewhere. */
65
71
  const ATTEMPTS_HEADER = 'x-oa-delivery-attempts';
66
72
 
@@ -153,6 +159,79 @@ function classifyError(classify, error) {
153
159
  return { classification: answer, failure: null };
154
160
  }
155
161
 
162
+ /**
163
+ * Validate the options of `consume()` and resolve the delivery policy they ask for.
164
+ *
165
+ * `requeueOnError` is the SAME policy as `maxAttempts` said in one word, never a
166
+ * second one: `false` means a budget of one attempt, `true` or absent leaves the
167
+ * budget to `maxAttempts` or the default. `requeueOnError: false` together with
168
+ * any `maxAttempts` other than 1 is refused rather than resolved by preferring
169
+ * one of the two. An explicit `maxAttempts` is taken exactly as written — never
170
+ * coerced. `classify` defaults to `defaultClassification`.
171
+ *
172
+ * @param {Object} options the options passed to `consume()`
173
+ * @param {function(): number} resolveDefaultMaxAttempts returns the budget when
174
+ * the caller names none — the transport resolves its configuration there
175
+ * (explicit → env RABBITMQ_MAX_DELIVERY_ATTEMPTS → defaults.js). Called ONLY
176
+ * when the options leave the budget open: resolving it reads configuration,
177
+ * which can refuse on its own, and an explicit `maxAttempts` or
178
+ * `requeueOnError: false` never needed it. Its value is validated like an
179
+ * explicit one.
180
+ * @returns {{maxAttempts: number, classify: function(Error): string}}
181
+ * @throws {ValidationError} the first rule the options break, in the words
182
+ * `consume()` refuses them with
183
+ */
184
+ function validateConsumeOptions(options, resolveDefaultMaxAttempts) {
185
+ if (options.requeueOnError !== undefined && typeof options.requeueOnError !== 'boolean') {
186
+ throw new ValidationError(
187
+ '[RabbitMQClient] Invalid requeueOnError - Expected: true or false, got '
188
+ + `${JSON.stringify(options.requeueOnError)}. Fix: pass consume(queue, handler, `
189
+ + '{ requeueOnError: false }) to dead-letter on the first failure, or leave it out and '
190
+ + 'let the delivery budget decide.'
191
+ );
192
+ }
193
+
194
+ const noRequeue = options.requeueOnError === false;
195
+
196
+ if (noRequeue && options.maxAttempts !== undefined && options.maxAttempts !== 1) {
197
+ throw new ValidationError(
198
+ '[RabbitMQClient] Contradictory delivery policy - requeueOnError: false means one attempt, '
199
+ + `but maxAttempts says ${JSON.stringify(options.maxAttempts)}. Expected: one statement about `
200
+ + 'how many times a handler may run. Fix: drop requeueOnError to keep the budget, or drop '
201
+ + 'maxAttempts to dead-letter on the first failure.'
202
+ );
203
+ }
204
+
205
+ const maxAttempts = noRequeue
206
+ ? 1
207
+ : options.maxAttempts === undefined
208
+ ? resolveDefaultMaxAttempts()
209
+ : options.maxAttempts;
210
+
211
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
212
+ throw new ValidationError(
213
+ '[RabbitMQClient] Invalid delivery budget - maxAttempts must be an integer >= 1, got '
214
+ + `${JSON.stringify(maxAttempts)}. Fix: pass consume(queue, handler, { maxAttempts: <integer >= 1> }), `
215
+ + 'or leave it out and let the module default (defaults.js maxDeliveryAttempts, '
216
+ + 'env RABBITMQ_MAX_DELIVERY_ATTEMPTS) decide.'
217
+ );
218
+ }
219
+
220
+ const classify = options.classify === undefined
221
+ ? defaultClassification
222
+ : options.classify;
223
+
224
+ if (typeof classify !== 'function') {
225
+ throw new ValidationError(
226
+ '[RabbitMQClient] Invalid error classifier - classify must be a function returning '
227
+ + `'transient' or 'permanent', got ${typeof options.classify}. Fix: pass consume(queue, handler, `
228
+ + '{ classify: (error) => … }), or leave it out and every error is treated as transient.'
229
+ );
230
+ }
231
+
232
+ return { maxAttempts, classify };
233
+ }
234
+
156
235
  module.exports = {
157
236
  ATTEMPTS_HEADER,
158
237
  TRANSIENT,
@@ -162,4 +241,5 @@ module.exports = {
162
241
  defaultClassification,
163
242
  readAttemptsMade,
164
243
  classifyError,
244
+ validateConsumeOptions,
165
245
  };
@@ -61,7 +61,12 @@ const SECTION_PREFIXES = Object.freeze({
61
61
  telemetry: 'telemetry',
62
62
  alerts: 'alert',
63
63
  registry: 'registry',
64
- scheduler: 'scheduler'
64
+ scheduler: 'scheduler',
65
+ // The one section whose names travel WITHOUT a prefix: `dlx` is the wire name every
66
+ // business queue dead-letters to (`x-dead-letter-exchange`), and renaming it would
67
+ // re-declare live topology. The empty prefix is a declared case, read explicitly by
68
+ // `queueName()`; a section absent from this map is still refused (d.902).
69
+ deadLetter: ''
65
70
  });
66
71
 
67
72
  /**
@@ -124,8 +129,11 @@ const templateShape = (template) =>
124
129
  * @returns {{serviceName: string, queueType: string}|null} The service and the
125
130
  * template key that declares the queue's arguments, or `null` for a name no
126
131
  * template matches.
127
- * @throws {ValidationError} If two templates match one name - a defect in this file,
128
- * never a runtime condition, and one that would otherwise be resolved by key order.
132
+ * @throws {ValidationError} If two templates of the same kind match one name - a defect in
133
+ * this file, never a runtime condition, and one that would otherwise be resolved by key
134
+ * order. A literal shape and a placeholder shape matching the same name is NOT that case:
135
+ * the literal wins, because it declares the name while the placeholder only fits it
136
+ * (d.817b, `registry.events` against `registry.{instance_id}`).
129
137
  */
130
138
  const matchBusinessTemplate = (business, queueName) => {
131
139
  const parts = queueName.split('.');
@@ -140,15 +148,24 @@ const matchBusinessTemplate = (business, queueName) => {
140
148
  );
141
149
  });
142
150
 
143
- if (matched.length > 1) {
151
+ // A LITERAL BEATS A PLACEHOLDER (d.817b). `registry.events` names a queue;
152
+ // `registry.{instance_id}` names a shape — so `<svc>.registry.events` fits both, and a
153
+ // wildcard must not claim a name another template DECLARES. It is the same tie-break this
154
+ // file makes one level up, where a name an infrastructure section declares is never a
155
+ // business queue however well it fits a template (d.279). Ambiguity between two templates
156
+ // of the SAME kind is still a defect and still refused below.
157
+ const literals = matched.filter((template) => !templateShape(template).includes(null));
158
+ const decided = literals.length > 0 ? literals : matched;
159
+
160
+ if (decided.length > 1) {
144
161
  throw new ValidationError(
145
- `[queueConfig] Business queue ${queueName} matches more than one template: ${matched.join(', ')} - `
162
+ `[queueConfig] Business queue ${queueName} matches more than one template: ${decided.join(', ')} - `
146
163
  + 'Expected: exactly one template declares a name, so one queue has one definition. '
147
164
  + 'Fix: make the overlapping keys in the `business` section of src/config/queueConfig.js distinct.'
148
165
  );
149
166
  }
150
167
 
151
- return matched.length === 1 ? { serviceName: parts[0], queueType: matched[0] } : null;
168
+ return decided.length === 1 ? { serviceName: parts[0], queueType: decided[0] } : null;
152
169
  };
153
170
 
154
171
  /**
@@ -272,33 +289,11 @@ module.exports = {
272
289
  }
273
290
  },
274
291
 
275
- /**
276
- * workflow.control - Shared queue for service-agnostic control-flow steps
277
- * All business services listen as competing consumers (same pattern as workflow.init).
278
- *
279
- * Purpose:
280
- * - Execute control-flow steps that are not pinned to a specific service
281
- * - Keep workflow.init as a clear "gateway entrypoint" queue (no semantic overload)
282
- *
283
- * TTL: 30 seconds - if no business service processes message within 30s, it expires
284
- * DLQ routing: Expired messages go to workflow.failed
285
- */
286
- control: {
287
- durable: true,
288
- arguments: {
289
- 'x-message-ttl': 30000,
290
- 'x-max-length': 10000,
291
- 'x-dead-letter-exchange': '',
292
- 'x-dead-letter-routing-key': 'workflow.failed'
293
- }
294
- },
295
-
296
292
  /**
297
293
  * workflow.completed - Completed workflows
298
294
  *
299
295
  * Dead-letters to workflow.dlq over the DEFAULT exchange, where the routing key
300
- * is the destination queue name — the shape workflow.init/workflow.control
301
- * already use. The dispatcher rejects a message here on an invalid payload, a
296
+ * is the destination queue name — the shape workflow.init already uses. The dispatcher rejects a message here on an invalid payload, a
302
297
  * schema violation and a delivery failure; until d.278 the broker dropped each
303
298
  * one, because the queue declared nowhere to put it.
304
299
  *
@@ -487,6 +482,41 @@ module.exports = {
487
482
  'x-dead-letter-exchange': DEAD_LETTER_EXCHANGE,
488
483
  'x-dead-letter-routing-key': '{service}.dlq'
489
484
  }
485
+ },
486
+
487
+ /**
488
+ * {service}.registry.{instance_id} - the queue ONE INSTANCE gets its registry answers
489
+ * on, beside the shared `{service}.registry` the registry uses for its own prompts.
490
+ *
491
+ * The owner settled the name and the split (`registry-instance-identity` 001,
492
+ * Conditions): answers travel to `<svc>.registry.<instanceId>` through the existing
493
+ * `responseQueue`, the shared queue carries the registry's prompts. Measured by the
494
+ * registry thread in d.817: without a template here `getDeadLetterRoute()` answered
495
+ * null, `consume()` refuses a queue with no declared route (`mq-consumer-contract`
496
+ * 002), and the client had to go on answering on the shared queue — the state the
497
+ * confirmation moved away from.
498
+ *
499
+ * ITS LIFETIME IS `x-expires` (question 14, variant A; `registry-instance-identity`
500
+ * 003). Every boot of a copy names a new queue (the
501
+ * instance id is a UUID per process unless `SERVICE_INSTANCE_ID` names one), and
502
+ * nothing deletes the old one — not `deregister()`, not the wrapper's shutdown, not the
503
+ * registry, which only checks the queue exists before it answers. A durable queue with
504
+ * no lifetime would therefore stay behind after every restart, crash or clean stop
505
+ * alike. The broker deletes a queue with `x-expires` once it has gone that long with no
506
+ * consumer, so a live copy (its consumer attached) keeps it and a gone copy loses it.
507
+ * 60 s: longer than a reconnect of a live copy, shorter than the monitoring orphan
508
+ * window (`queue_orphan_alert.min_orphan_minutes`, seed 5), so a dead copy's queue
509
+ * never opens a `no_owner` episode. One rail: the lifetime lives in this declaration
510
+ * only — the transport refuses a caller's `autoDelete`/`exclusive` on a classified
511
+ * queue, and no caller deletes it.
512
+ */
513
+ 'registry.{instance_id}': {
514
+ durable: true,
515
+ arguments: {
516
+ 'x-expires': 60000, // 60 s with no consumer, then the broker deletes it (question 14, A)
517
+ 'x-dead-letter-exchange': DEAD_LETTER_EXCHANGE,
518
+ 'x-dead-letter-routing-key': '{service}.dlq' // Placeholder, replaced with actual service name
519
+ }
490
520
  }
491
521
  },
492
522
 
@@ -629,10 +659,17 @@ module.exports = {
629
659
  /**
630
660
  * monitoring.workflow - Unified workflow lifecycle monitoring
631
661
  * Bound to monitoring.workflow.fanout exchange (fanout).
632
- * Delivery Dispatcher publishes 'completed'/'failed' events after processing workflow.completed/workflow.failed
633
- * Business services publish 'progress' events during workflow step processing
634
- * Message format includes event_type: 'completed' | 'failed' | 'progress'
635
- * Includes full cookbook and context snapshots for trace storage
662
+ *
663
+ * WHO publishes onto that fanout: the Delivery Dispatcher after it processes a terminal
664
+ * workflow message, the in-process orchestrator during a run, and this client's own
665
+ * dead-letter hook. WHICH event_type each of them sends is not written here - the
666
+ * vocabulary has one owner and it grows, so a list copied into this comment would be a
667
+ * hand-written descriptive fact that ages in silence
668
+ * (`.claude/rules/doc-code-binding.md` §1).
669
+ *
670
+ * The message carries the cookbook and the context snapshot the trace store keeps.
671
+ *
672
+ * @see api/docs/biz/70-contracts/monitoring-events.md
636
673
  */
637
674
  'workflow': {
638
675
  durable: true,
@@ -646,15 +683,20 @@ module.exports = {
646
683
 
647
684
  /**
648
685
  * monitoring.services - Service lifecycle events monitoring
649
- * Registry publishes service lifecycle events (registered, validation.completed, version.changed, status.changed)
650
- * InfrastructureHealthTracker publishes service status changes
651
- * Message format includes event_type: 'service.registered' | 'service.validation.completed' |
652
- * 'service.version.changed' | 'service.deregistered' | 'service.status.changed'
686
+ *
687
+ * WHO publishes here: the Registry, which owns the service lifecycle, and the
688
+ * InfrastructureHealthTracker with service status changes. WHICH event_type each of them
689
+ * sends is not written here — that vocabulary has one owner and it grows, and a list
690
+ * copied into this comment would be a hand-written descriptive fact that ages in silence
691
+ * (`.claude/rules/doc-code-binding.md` §1).
692
+ *
653
693
  * NOTE: Heartbeats are NOT published here - they are too frequent. The cadence
654
694
  * is not a number this file owns: see the `heartbeatInterval` constructor option
655
695
  * of RegistryClient (@onlineapps/conn-orch-registry), which ServiceWrapper feeds
656
696
  * from BIZ_HEARTBEAT_INTERVAL_MS.
657
697
  * NOTE: Healthcheck messages go to infrastructure.health.events exchange
698
+ *
699
+ * @see api/docs/biz/70-contracts/monitoring-events.md
658
700
  */
659
701
  'services': {
660
702
  durable: true,
@@ -749,11 +791,12 @@ module.exports = {
749
791
  * `mq-consumer-contract` 002 point 2, 003 point 1).
750
792
  *
751
793
  * **A declaration is not a creation instruction. The terminal is brought into being
752
- * by the owner of the prefix that NAMES it.** `initInfrastructureQueues()` derives
753
- * the whole list from these sections only when its caller passes no `queues` list;
754
- * every caller passes one, and the gateway's names exactly the five `workflow.*`
755
- * queues it owns (`infra/api_gateway/index.js`). `monitoring.dlq` therefore belongs
756
- * to the monitoring service's own call, beside the queues it consumes. Until that
794
+ * by the owner of the prefix that NAMES it.** `initInfrastructureQueues()` creates
795
+ * exactly the names its caller passes in `queues`, a list that is required since
796
+ * d.668 — it derives nothing from these sections, and the gateway's list names
797
+ * exactly the five `workflow.*` queues it owns (`infra/api_gateway/index.js`).
798
+ * `monitoring.dlq` therefore belongs to the monitoring service's own call, beside
799
+ * the queues it consumes. Until that
757
800
  * call names it, a `nack(requeue=false)` on a queue of this family dead-letters onto
758
801
  * a routing key no queue carries and the broker DROPS the message — measured
759
802
  * 2026-09-16 on the dev broker: 78 queues, and the only `.dlq` among them
@@ -830,7 +873,7 @@ module.exports = {
830
873
  'logs.queue': {
831
874
  durable: true,
832
875
  arguments: {
833
- 'x-message-ttl': 60000, // 1 minute TTL for unprocessed messages
876
+ 'x-message-ttl': 300000, // 5 min — okno deploye bez konzumenta; conf telemetry-queue-ttl 001
834
877
  'x-max-length': 100000, // Max 100k messages in queue
835
878
  'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
836
879
  'x-dead-letter-routing-key': 'telemetry.dlq'
@@ -841,7 +884,7 @@ module.exports = {
841
884
  'metrics.queue': {
842
885
  durable: true,
843
886
  arguments: {
844
- 'x-message-ttl': 60000,
887
+ 'x-message-ttl': 300000, // 5 min — okno deploye bez konzumenta; conf telemetry-queue-ttl 001
845
888
  'x-max-length': 100000,
846
889
  'x-dead-letter-exchange': '',
847
890
  'x-dead-letter-routing-key': 'telemetry.dlq'
@@ -852,7 +895,7 @@ module.exports = {
852
895
  'traces.queue': {
853
896
  durable: true,
854
897
  arguments: {
855
- 'x-message-ttl': 60000,
898
+ 'x-message-ttl': 300000, // 5 min — okno deploye bez konzumenta; conf telemetry-queue-ttl 001
856
899
  'x-max-length': 100000,
857
900
  'x-dead-letter-exchange': '',
858
901
  'x-dead-letter-routing-key': 'telemetry.dlq'
@@ -1075,12 +1118,17 @@ module.exports = {
1075
1118
  *
1076
1119
  * The third queue bound to `monitoring.workflow.fanout`, beside `monitoring.workflow`
1077
1120
  * and `delivery.workflow.events`. The owner settled it in
1078
- * `docs/governance/confirmations/scheduler.md` 007: the terminal event of a run
1079
- * (`completed` / `failed` / `discarded`) already travels on that exchange, so the
1080
- * scheduler reads it there rather than asking the observer over HTTP or reading a
1081
- * database that is not its own. No producer changes; the binding is the scheduler's own
1082
- * act at boot (the pattern `infra/api_delivery_endpoint/src/index.js:164` already uses),
1083
- * and this file only DECLARES what that queue is.
1121
+ * `docs/governance/confirmations/scheduler.md` 007: the terminal event of a run already
1122
+ * travels on that exchange, so the scheduler reads it there rather than asking the
1123
+ * observer over HTTP or reading a database that is not its own. No producer changes; the
1124
+ * binding is the scheduler's own act at boot (the pattern
1125
+ * `infra/api_delivery_endpoint/src/index.js` already uses with its
1126
+ * `mq.bindQueueToFanout(…)` calls), and this file only
1127
+ * DECLARES what that queue is.
1128
+ *
1129
+ * WHICH event_type values those are is NOT written here: the vocabulary has one owner and
1130
+ * it grows. A list copied into this comment would be a hand-written descriptive fact that
1131
+ * ages in silence (`.claude/rules/doc-code-binding.md` §1).
1084
1132
  *
1085
1133
  * THE TTL IS STATED, NOT INHERITED, and it is a property of that decision rather than a
1086
1134
  * default: a fanout cannot filter, so this queue receives EVERY workflow event of the
@@ -1096,6 +1144,7 @@ module.exports = {
1096
1144
  * `CONSUMER_DEAD_LETTER_ROUTE_MISSING`; `mq-consumer-contract` 002).
1097
1145
  *
1098
1146
  * @see api/docs/governance/confirmations/scheduler.md 007
1147
+ * @see api/docs/biz/70-contracts/monitoring-events.md
1099
1148
  */
1100
1149
  'workflow.events': {
1101
1150
  durable: true,
@@ -1123,6 +1172,31 @@ module.exports = {
1123
1172
  }
1124
1173
  },
1125
1174
 
1175
+ /**
1176
+ * The platform's dead-letter exchange, DECLARED — name, type and durability.
1177
+ *
1178
+ * Every business template dead-letters here (`x-dead-letter-exchange`), and
1179
+ * `@onlineapps/conn-infra-mq` `QueueManager.setupServiceQueues()` brings it into
1180
+ * being as a durable `direct` exchange before binding `{service}.dlq` to it. The
1181
+ * broker compares type and durability at EVERY `assertExchange`, so they belong to
1182
+ * the declaration, where a second reader — `api/scripts/ci/declare-broker-exchanges.mjs`,
1183
+ * which declares exchanges on a CI broker nobody's services booted against — finds
1184
+ * them through the same loop as every other exchange of this file, instead of keeping
1185
+ * a copy of the founder's line (TODO INFRA 349, d.902).
1186
+ *
1187
+ * Its wire name carries no prefix (`SECTION_PREFIXES.deadLetter` is `''`), and the key
1188
+ * is the one constant at the top of this file, so the name has one spelling.
1189
+ * An exchange holds nothing to dead-letter, so — like every other exchange here — it
1190
+ * carries no route.
1191
+ */
1192
+ deadLetter: {
1193
+ [DEAD_LETTER_EXCHANGE]: {
1194
+ type: 'exchange',
1195
+ exchangeType: 'direct',
1196
+ durable: true
1197
+ }
1198
+ },
1199
+
1126
1200
  /**
1127
1201
  * The full wire name of a declared entry: the section's prefix + the entry's key.
1128
1202
  *
@@ -1132,9 +1206,13 @@ module.exports = {
1132
1206
  * a name this configuration does not declare would be asserted against the broker
1133
1207
  * by whoever imported it, which is how a typo becomes a second queue.
1134
1208
  *
1209
+ * A section whose declared prefix is `''` (`deadLetter`) composes the bare key: its
1210
+ * names travel without a prefix. That is a value of the map, not a default — a
1211
+ * section missing from it is refused below.
1212
+ *
1135
1213
  * @param {string} section - Section holding the entry (e.g. 'deliveryEvents').
1136
1214
  * @param {string} key - The entry's key within that section (e.g. 'health.events').
1137
- * @returns {string} The full name (e.g. 'delivery.health.events').
1215
+ * @returns {string} The full name (e.g. 'delivery.health.events'; the bare key for 'deadLetter').
1138
1216
  * @throws {ValidationError} If the section composes no wire name, or the entry is undeclared.
1139
1217
  */
1140
1218
  queueName(section, key) {
@@ -1156,7 +1234,7 @@ module.exports = {
1156
1234
  );
1157
1235
  }
1158
1236
 
1159
- return `${prefix}.${key}`;
1237
+ return prefix === '' ? key : `${prefix}.${key}`;
1160
1238
  },
1161
1239
 
1162
1240
  /**
@@ -1213,8 +1291,11 @@ module.exports = {
1213
1291
 
1214
1292
  /**
1215
1293
  * The infrastructure prefixes this configuration classifies by, in declaration
1216
- * order. Published so a caller — `initInfrastructureQueues()`, a queue-ownership
1217
- * probe, a document generator — reads the list instead of keeping a copy of it.
1294
+ * order. Published so a caller reads the list instead of keeping a copy of it.
1295
+ * It named `initInfrastructureQueues()` as such a caller until d.668, when that
1296
+ * function stopped deriving a queue list at all and started requiring `queues`;
1297
+ * what reads it today is this package's own coverage of the classification
1298
+ * (`tests/unit/queue-name-collision.test.js`, `queueConfig-telemetry.test.js`).
1218
1299
  * @returns {ReadonlyArray<string>} Prefixes WITHOUT the trailing dot.
1219
1300
  */
1220
1301
  infrastructurePrefixes() {
@@ -1456,8 +1537,7 @@ module.exports = {
1456
1537
  *
1457
1538
  * ONE owner of the mapping name → options. Every path that declares a queue
1458
1539
  * reads it here and nowhere else: the public `assertQueue()` of the transport
1459
- * (and through it `RecoveryWorker.createQueue()`, the cookbook-router's
1460
- * QueueManager and the publish path's 404 branch), and the consumer's
1540
+ * (and through it the cookbook-router's QueueManager), and the consumer's
1461
1541
  * `_prepareQueueForConsume()`.
1462
1542
  *
1463
1543
  * Until d.410 those two worked the same three lines out separately —
@@ -1558,7 +1638,7 @@ module.exports = {
1558
1638
  *
1559
1639
  * The counterpart of `getDeadLetterRoute()`, and the reason a queue may have no
1560
1640
  * route without that being a defect. `workflow.failed` is named in
1561
- * `x-dead-letter-routing-key` by `workflow.init` and `workflow.control`;
1641
+ * `x-dead-letter-routing-key` by `workflow.init`;
1562
1642
  * `{service}.dlq` by every business template; `workflow.dlq` and `delivery.dlq`
1563
1643
  * by their own families. Giving such a queue a route of its own would either
1564
1644
  * close a loop or start an endless chain, which is why the confirmed topology
@@ -1627,11 +1707,12 @@ module.exports = {
1627
1707
  * Every business template routes here (`x-dead-letter-exchange`), and so does
1628
1708
  * `getDeadLetterRoute()` for a business queue. `@onlineapps/conn-infra-mq` binds
1629
1709
  * `<service>.dlq` to it and had to read the name back out of a queue's arguments
1630
- * for want of an export.
1710
+ * for want of an export. Composed from the `deadLetter` entry, which also declares
1711
+ * the exchange's type and durability — one declaration, not a name beside it.
1631
1712
  *
1632
1713
  * @returns {string}
1633
1714
  */
1634
1715
  deadLetterExchange() {
1635
- return DEAD_LETTER_EXCHANGE;
1716
+ return this.queueName('deadLetter', DEAD_LETTER_EXCHANGE);
1636
1717
  }
1637
1718
  };
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * recurrencesSchema.js
5
+ *
6
+ * JSON Schema used by Ajv (in `src/scheduler-request.js`) to validate the recurrences a service
7
+ * declares about itself.
8
+ *
9
+ * WHY THE SHAPE LIVES HERE. `docs/architecture/scheduler.md` §7 has the wrapper register a
10
+ * service's recurrences at boot — "idempotent, so every boot re-asserts the declared state" —
11
+ * and the service answers with them through ONE hook, platform ones as a constant list and
12
+ * tenant ones derived from its own data (confirmation `scheduler` 011). The shape of a record
13
+ * is the library's; where the record comes from is not. One owner of the shape means every service's file is the
14
+ * same file, and the wrapper reading it never has to guess.
15
+ *
16
+ * WHAT THE FILE DOES NOT CARRY, and why that is a rule rather than an omission:
17
+ *
18
+ * - **`tenant_id` and `workspace_id`.** They travel beside the record, not in it: the
19
+ * builder takes them as `tenantId` and `workspaceId`, and the caller supplies them with
20
+ * every recurrence — the service knows them from its own data, and nothing reads them from
21
+ * the environment or defaults them.
22
+ * @see api/docs/governance/confirmations/scheduler.md 002, 010
23
+ * - **`requested_by`.** It is the service that declared the record; the caller passes the name
24
+ * it already knows, so a record cannot disagree with the service that answered it.
25
+ *
26
+ * `additionalProperties: false` at both levels, deliberately: the consumer's own contract
27
+ * (`api/infra/api_scheduler/contracts/scheduler.request.schema.json`) closes its keys the same
28
+ * way, so a key accepted here and refused there would turn a boot into a dead-lettered
29
+ * request nobody reads.
30
+ *
31
+ * @see ../scheduler-request.js — the shape check, the builders and the send
32
+ * @see api/docs/architecture/scheduler.md §4, §7
33
+ */
34
+
35
+ /**
36
+ * Five whitespace-separated fields — the same expression the scheduler's contract takes
37
+ * (`recurrence.cron`, pattern `^\S+( \S+){4}$`). Whether those fields describe a reachable
38
+ * instant is the tick engine's answer, not this file's.
39
+ */
40
+ const CRON_PATTERN = '^\\S+( \\S+){4}$';
41
+
42
+ /**
43
+ * The id of one schedule: it becomes `idempotency_key`, so it is a name, not a sentence.
44
+ *
45
+ * As WIDE as the consumer's own rule and no wider: the scheduler takes any string of 1–255
46
+ * characters for that key, so this file refuses only what would make the key a sentence —
47
+ * whitespace. The narrower spelling this schema first carried (lower-case letters and dashes)
48
+ * was the library being stricter than the contract for no reason, and it refused the very
49
+ * convention the first caller uses: `<source_uuid>:<cookbook_ref>` (BIZ-ingest, 2026-09-23).
50
+ */
51
+ const ID_PATTERN = '^\\S+$';
52
+
53
+ const recurrencesSchema = {
54
+ $schema: 'http://json-schema.org/draft-07/schema#',
55
+ $id: 'https://onlineapps.cz/schemas/mq-client-core/recurrences.v1.schema.json',
56
+ title: 'Service recurrence declaration',
57
+ description:
58
+ 'What a service declares about its own recurring runs. The wrapper re-asserts every record '
59
+ + 'at boot as a `scheduler.request` of kind `recurring`.',
60
+ type: 'object',
61
+ additionalProperties: false,
62
+ required: ['recurrences'],
63
+ properties: {
64
+ recurrences: {
65
+ description:
66
+ 'Every recurrence this service owns. An empty list is a valid declaration of nothing, '
67
+ + 'and a service with no recurrences may answer one.',
68
+ type: 'array',
69
+ items: {
70
+ type: 'object',
71
+ additionalProperties: false,
72
+ required: ['id', 'cookbook_ref', 'cron', 'timezone'],
73
+ properties: {
74
+ id: {
75
+ description:
76
+ 'Stable id of THIS schedule; it travels as `idempotency_key`, so re-registration '
77
+ + 'upserts instead of creating a second schedule.',
78
+ type: 'string',
79
+ minLength: 1,
80
+ maxLength: 255,
81
+ pattern: ID_PATTERN
82
+ },
83
+ cookbook_ref: {
84
+ description:
85
+ 'Id of the cookbook to run. Its `triggers` must name this service and the kind '
86
+ + '`recurring`, or the scheduler refuses the request (confirmation scheduler 004).',
87
+ type: 'string',
88
+ minLength: 1,
89
+ maxLength: 255
90
+ },
91
+ cron: {
92
+ description: 'Cron expression, five fields.',
93
+ type: 'string',
94
+ pattern: CRON_PATTERN
95
+ },
96
+ timezone: {
97
+ description: 'IANA zone, explicit. No implicit zone exists (scheduler.md §4).',
98
+ type: 'string',
99
+ minLength: 1,
100
+ maxLength: 64
101
+ },
102
+ input: {
103
+ description:
104
+ 'Input handed to the cookbook unchanged. Present only where the run needs one — '
105
+ + 'a scheduled import that writes says so here, while the same cookbook run by hand '
106
+ + 'can be given a different input.',
107
+ type: 'object'
108
+ },
109
+ catch_up: {
110
+ description:
111
+ 'Whether a missed window runs once when the scheduler comes back. Absent means '
112
+ + 'false, and the scheduler materialises that default — this file does not.',
113
+ type: 'boolean'
114
+ }
115
+ }
116
+ }
117
+ }
118
+ }
119
+ };
120
+
121
+ module.exports = { recurrencesSchema, CRON_PATTERN, ID_PATTERN };
package/src/config.js CHANGED
@@ -20,7 +20,11 @@ const runtimeCfg = createRuntimeConfig({
20
20
 
21
21
  // Client behavior
22
22
  heartbeat: { env: 'RABBITMQ_HEARTBEAT', defaultKey: 'heartbeatSeconds', type: 'number' },
23
- serviceName: { env: 'SERVICE_NAME', defaultKey: 'serviceName' },
23
+ // The caller's own identity — required, never defaulted (d.770). Two legs, both the
24
+ // caller's: the explicit key, or ENV SERVICE_NAME. There is no third, because a
25
+ // library that invents a service name makes every row it writes name the wrong
26
+ // service (`architecture-principles.md` §3, and `./defaults.js` for the measurement).
27
+ serviceName: { env: 'SERVICE_NAME', required: true },
24
28
 
25
29
  // Connection-level recovery budget. Resolved here (explicit → env →
26
30
  // module default) instead of an inline `|| 10` in the transport, so the
package/src/defaults.js CHANGED
@@ -7,11 +7,17 @@
7
7
  * - This module owns defaults for MQ client behavior (heartbeat, connectionName prefix).
8
8
  * - Infrastructure topology (RabbitMQ URL) is NOT defaulted; it must be provided via
9
9
  * explicit config.host or ENV RABBITMQ_URL (fail-fast).
10
+ * - The CALLER's own identity is not defaulted either. `serviceName` used to be
11
+ * `'oa-service'` here until d.770: a name this module cannot know and must not invent
12
+ * (`architecture-principles.md` §3). What it bought was measured, and it was a lie —
13
+ * every client built without the key published its monitoring events, and named its
14
+ * queues, under a service that does not exist. It is now required, with the same two
15
+ * legs every caller already uses: explicit `serviceName`, or ENV `SERVICE_NAME`
16
+ * (`../config.js`).
10
17
  */
11
18
 
12
19
  module.exports = {
13
20
  heartbeatSeconds: 30,
14
- serviceName: 'oa-service',
15
21
 
16
22
  // How many times connection-level recovery retries before the connection is
17
23
  // declared permanently lost. Client BEHAVIOUR, so it is owned here (the same