@onlineapps/cookbook-router 3.0.1 → 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.
package/API.md ADDED
@@ -0,0 +1,297 @@
1
+ ## Modules
2
+
3
+ <dl>
4
+ <dt><a href="#@onlineapps/cookbook-router
5
+
6
+ Message routing for cookbook workflows_ given a service name and a workflow
7
+ message, publish it to that services workflow queue.
8
+
9
+ The package does NOT execute cookbooks, drive flow control, retry, complete
10
+ or dead-letter them. Those belong to `WorkflowOrchestrator` — confirmation
11
+ `api/docs/governance/confirmations/cookbook-execution-owner.md` 001 — which
12
+ is also the only caller_ its constructor takes `createRouter` (through
13
+ `cookbook.createRouter`), and `processWorkflowMessage` and the method that
14
+ hands the next steps message to the router call `routeToService` on the
15
+ result. The second routing rail, the retry rail and the
16
+ queue/registry administration this package used to carry had zero callers
17
+ outside it and were removed on 2026-09-02.module_">@onlineapps/cookbook-router
18
+
19
+ Message routing for cookbook workflows: given a service name and a workflow
20
+ message, publish it to that services workflow queue.
21
+
22
+ The package does NOT execute cookbooks, drive flow control, retry, complete
23
+ or dead-letter them. Those belong to `WorkflowOrchestrator` — confirmation
24
+ `api/docs/governance/confirmations/cookbook-execution-owner.md` 001 — which
25
+ is also the only caller: its constructor takes `createRouter` (through
26
+ `cookbook.createRouter`), and `processWorkflowMessage` and the method that
27
+ hands the next steps message to the router call `routeToService` on the
28
+ result. The second routing rail, the retry rail and the
29
+ queue/registry administration this package used to carry had zero callers
30
+ outside it and were removed on 2026-09-02.</a></dt>
31
+ <dd></dd>
32
+ </dl>
33
+
34
+ ## Classes
35
+
36
+ <dl>
37
+ <dt><a href="#QueueManager">QueueManager</a></dt>
38
+ <dd><p>QueueManager - Queue operations and management</p>
39
+ <p>A QUEUE and a MESSAGE are two different things, and neither one&#39;s defaults
40
+ belong to the other. Until d.396d this class held ONE object for both —
41
+ <code>defaultOptions: { durable: true, persistent: true }</code> — and spread it into the
42
+ publish AND into the queue declaration, so each call site was handed a key it
43
+ has no use for. Measured in both directions on the unit tier:</p>
44
+ <pre><code>assertQueue(&#39;split.queue&#39;, { durable: true, persistent: true })
45
+ publish(&#39;control.publish&#39;, …, { durable: true, persistent: true })
46
+ </code></pre>
47
+ <p><code>persistent</code> is a message property (amqplib <code>Options.Publish</code>) and means
48
+ nothing to a queue; <code>durable</code> is a queue property and means nothing to a
49
+ message. Nothing objected, because <code>@onlineapps/mq-client-core</code> read the two
50
+ keys it knew and dropped the rest in silence — and since d.396c it no longer
51
+ does: <code>assertQueue()</code> refuses an option it does not read, by name. What was an
52
+ invisible confusion becomes a throw the moment the pin moves, and the cure is
53
+ not to catch it but to stop sending a message property to a queue.</p>
54
+ <p>So there are two defaults with two owners, <code>queueOptions</code> and <code>publishOptions</code>,
55
+ and each call site merges only its own.</p>
56
+ </dd>
57
+ <dt><a href="#CookbookRouter">CookbookRouter</a></dt>
58
+ <dd><p>CookbookRouter - routes a workflow message to a service queue.</p>
59
+ <p>ONE responsibility, ONE method: <code>routeToService</code>. Execution, flow control,
60
+ retry, DLQ and completion belong to <code>WorkflowOrchestrator</code>
61
+ (confirmation <code>api/docs/governance/confirmations/cookbook-execution-owner.md</code>
62
+ 001), which is also this class&#39;s only caller: <code>processWorkflowMessage()</code> and
63
+ the method that hands the next step&#39;s message to the router.</p>
64
+ </dd>
65
+ <dt><a href="#ServiceDiscovery">ServiceDiscovery</a></dt>
66
+ <dd><p>ServiceDiscovery - Service discovery and health checking</p>
67
+ </dd>
68
+ </dl>
69
+
70
+ ## Constants
71
+
72
+ <dl>
73
+ <dt><a href="#DEFAULT_QUEUE_OPTIONS">DEFAULT_QUEUE_OPTIONS</a></dt>
74
+ <dd><p>What describes the QUEUE. Handed to <code>mqClient.assertQueue()</code>, whose declared
75
+ option set is <code>durable</code>, <code>arguments</code>, <code>exclusive</code>, <code>autoDelete</code>.</p>
76
+ </dd>
77
+ <dt><a href="#DEFAULT_PUBLISH_OPTIONS">DEFAULT_PUBLISH_OPTIONS</a></dt>
78
+ <dd><p>What describes the MESSAGE. Handed to <code>mqClient.publish()</code>.</p>
79
+ </dd>
80
+ </dl>
81
+
82
+ ## Functions
83
+
84
+ <dl>
85
+ <dt><a href="#describeValue">describeValue(value)</a> ⇒ <code>string</code></dt>
86
+ <dd><p>Renders a refused value for an error message without ever printing it as a
87
+ bare word: <code>&quot;300000&quot;</code> and <code>300000</code> look identical otherwise, and the whole
88
+ point of the refusal is that they are not the same thing.</p>
89
+ </dd>
90
+ <dt><a href="#readCacheTTL">readCacheTTL(context, value)</a> ⇒ <code>number</code></dt>
91
+ <dd><p>Reads the discovery cache lifetime: a required, non-negative integer of
92
+ milliseconds, where <code>0</code> means &quot;no cache — every lookup reaches the registry&quot;.</p>
93
+ <p><code>options.cacheTTL || 300000</code> is what this replaces, and <code>0</code> was the value it
94
+ destroyed: a caller switching the cache off was given five minutes of cached
95
+ <code>status</code> instead, with nothing said. A default that inverts the one override
96
+ it is asked for is not a default (<code>architecture-principles.md</code> §3, §8), and
97
+ the absent case is not a value at all — it is a missing decision, so it
98
+ throws (§4).</p>
99
+ </dd>
100
+ </dl>
101
+
102
+ <a name="@onlineapps/cookbook-router
103
+
104
+ Message routing for cookbook workflows_ given a service name and a workflow
105
+ message, publish it to that services workflow queue.
106
+
107
+ The package does NOT execute cookbooks, drive flow control, retry, complete
108
+ or dead-letter them. Those belong to `WorkflowOrchestrator` — confirmation
109
+ `api/docs/governance/confirmations/cookbook-execution-owner.md` 001 — which
110
+ is also the only caller_ its constructor takes `createRouter` (through
111
+ `cookbook.createRouter`), and `processWorkflowMessage` and the method that
112
+ hands the next steps message to the router call `routeToService` on the
113
+ result. The second routing rail, the retry rail and the
114
+ queue/registry administration this package used to carry had zero callers
115
+ outside it and were removed on 2026-09-02.module_"></a>
116
+
117
+ ## @onlineapps/cookbook-router
118
+
119
+ Message routing for cookbook workflows: given a service name and a workflow
120
+ message, publish it to that services workflow queue.
121
+
122
+ The package does NOT execute cookbooks, drive flow control, retry, complete
123
+ or dead-letter them. Those belong to `WorkflowOrchestrator` — confirmation
124
+ `api/docs/governance/confirmations/cookbook-execution-owner.md` 001 — which
125
+ is also the only caller: its constructor takes `createRouter` (through
126
+ `cookbook.createRouter`), and `processWorkflowMessage` and the method that
127
+ hands the next steps message to the router call `routeToService` on the
128
+ result. The second routing rail, the retry rail and the
129
+ queue/registry administration this package used to carry had zero callers
130
+ outside it and were removed on 2026-09-02.
131
+ **See**: /api/shared/cookbook/cookbook-router/README.md
132
+ <a name="QueueManager"></a>
133
+
134
+ ## QueueManager
135
+ QueueManager - Queue operations and management
136
+
137
+ A QUEUE and a MESSAGE are two different things, and neither one's defaults
138
+ belong to the other. Until d.396d this class held ONE object for both —
139
+ `defaultOptions: { durable: true, persistent: true }` — and spread it into the
140
+ publish AND into the queue declaration, so each call site was handed a key it
141
+ has no use for. Measured in both directions on the unit tier:
142
+
143
+ assertQueue('split.queue', { durable: true, persistent: true })
144
+ publish('control.publish', …, { durable: true, persistent: true })
145
+
146
+ `persistent` is a message property (amqplib `Options.Publish`) and means
147
+ nothing to a queue; `durable` is a queue property and means nothing to a
148
+ message. Nothing objected, because `@onlineapps/mq-client-core` read the two
149
+ keys it knew and dropped the rest in silence — and since d.396c it no longer
150
+ does: `assertQueue()` refuses an option it does not read, by name. What was an
151
+ invisible confusion becomes a throw the moment the pin moves, and the cure is
152
+ not to catch it but to stop sending a message property to a queue.
153
+
154
+ So there are two defaults with two owners, `queueOptions` and `publishOptions`,
155
+ and each call site merges only its own.
156
+
157
+ **Kind**: global class
158
+
159
+ * [QueueManager](#QueueManager)
160
+ * [.publish(queueName, message, options)](#QueueManager+publish) ⇒ <code>Promise.&lt;boolean&gt;</code>
161
+ * [.ensureQueue(queueName, options)](#QueueManager+ensureQueue) ⇒ <code>Promise.&lt;boolean&gt;</code>
162
+
163
+ <a name="QueueManager+publish"></a>
164
+
165
+ ### queueManager.publish(queueName, message, options) ⇒ <code>Promise.&lt;boolean&gt;</code>
166
+ Publish message to queue
167
+
168
+ **Kind**: instance method of [<code>QueueManager</code>](#QueueManager)
169
+
170
+ | Param | Type | Description |
171
+ | --- | --- | --- |
172
+ | queueName | <code>string</code> | Target queue name |
173
+ | message | <code>Object</code> | Message to publish |
174
+ | options | <code>Object</code> | Publishing options (amqplib `Options.Publish`) |
175
+
176
+ <a name="QueueManager+ensureQueue"></a>
177
+
178
+ ### queueManager.ensureQueue(queueName, options) ⇒ <code>Promise.&lt;boolean&gt;</code>
179
+ Ensure queue exists
180
+
181
+ **Kind**: instance method of [<code>QueueManager</code>](#QueueManager)
182
+
183
+ | Param | Type | Description |
184
+ | --- | --- | --- |
185
+ | queueName | <code>string</code> | Queue name |
186
+ | options | <code>Object</code> | Queue options, as `mqClient.assertQueue()` declares them (`durable`, `arguments`, `exclusive`, `autoDelete`). A limit is one entry of `arguments`, in the broker's own vocabulary — `x-max-length`, `x-message-ttl`. This class translates no friendlier spelling into one: `maxLength` is a key the client does not read, and since d.396c it says so by name instead of dropping it. |
187
+
188
+ <a name="CookbookRouter"></a>
189
+
190
+ ## CookbookRouter
191
+ CookbookRouter - routes a workflow message to a service queue.
192
+
193
+ ONE responsibility, ONE method: `routeToService`. Execution, flow control,
194
+ retry, DLQ and completion belong to `WorkflowOrchestrator`
195
+ (confirmation `api/docs/governance/confirmations/cookbook-execution-owner.md`
196
+ 001), which is also this class's only caller: `processWorkflowMessage()` and
197
+ the method that hands the next step's message to the router.
198
+
199
+ **Kind**: global class
200
+ **See**: /api/shared/cookbook/cookbook-router/README.md
201
+ <a name="CookbookRouter+routeToService"></a>
202
+
203
+ ### cookbookRouter.routeToService(serviceName, message, [publishOptions]) ⇒ <code>Promise.&lt;void&gt;</code>
204
+ Route message directly to a specific service
205
+
206
+ **Kind**: instance method of [<code>CookbookRouter</code>](#CookbookRouter)
207
+ **Throws**:
208
+
209
+ - <code>Error</code> when `publishOptions` is given and is not a plain object —
210
+ before the registry is asked and before anything is published
211
+
212
+
213
+ | Param | Type | Default | Description |
214
+ | --- | --- | --- | --- |
215
+ | serviceName | <code>string</code> | | Target service name |
216
+ | message | <code>Object</code> | | Workflow message to send |
217
+ | [publishOptions] | <code>Object</code> | <code>{}</code> | Publish options for this one message, handed unchanged as the third argument of `QueueManager.publish()`, which merges them over its message defaults and passes them to the MQ client (e.g. `{ bufferOnFailure: false }`). The router neither reads nor completes them: a key the MQ client does not know is the client's to ignore. Must be a plain object when given. |
218
+
219
+ <a name="ServiceDiscovery"></a>
220
+
221
+ ## ServiceDiscovery
222
+ ServiceDiscovery - Service discovery and health checking
223
+
224
+ **Kind**: global class
225
+ <a name="ServiceDiscovery+isServiceAvailable"></a>
226
+
227
+ ### serviceDiscovery.isServiceAvailable(serviceName) ⇒ <code>Promise.&lt;boolean&gt;</code>
228
+ Check if a service is available.
229
+
230
+ `false` is the registry's answer: the service is there and not active, or the
231
+ projection does not hold it (`getService()` resolved to `null`). A lookup the
232
+ registry could not answer is not that answer, so it is thrown with its cause
233
+ rather than read as "not available" (d.1086; architecture-principles.md §3–§5).
234
+ A failed lookup is never cached.
235
+
236
+ **Kind**: instance method of [<code>ServiceDiscovery</code>](#ServiceDiscovery)
237
+ **Throws**:
238
+
239
+ - <code>Error</code> `code: 'SERVICE_DISCOVERY_FAILED'`, `serviceName`, and the
240
+ registry's rejection as `cause`, when `registryClient.getService()` rejects;
241
+ `type` copied from the cause when the cause declares one (d.1093).
242
+
243
+
244
+ | Param | Type | Description |
245
+ | --- | --- | --- |
246
+ | serviceName | <code>string</code> | Service name |
247
+
248
+ <a name="DEFAULT_QUEUE_OPTIONS"></a>
249
+
250
+ ## DEFAULT\_QUEUE\_OPTIONS
251
+ What describes the QUEUE. Handed to `mqClient.assertQueue()`, whose declared
252
+ option set is `durable`, `arguments`, `exclusive`, `autoDelete`.
253
+
254
+ **Kind**: global constant
255
+ <a name="DEFAULT_PUBLISH_OPTIONS"></a>
256
+
257
+ ## DEFAULT\_PUBLISH\_OPTIONS
258
+ What describes the MESSAGE. Handed to `mqClient.publish()`.
259
+
260
+ **Kind**: global constant
261
+ <a name="describeValue"></a>
262
+
263
+ ## describeValue(value) ⇒ <code>string</code>
264
+ Renders a refused value for an error message without ever printing it as a
265
+ bare word: `"300000"` and `300000` look identical otherwise, and the whole
266
+ point of the refusal is that they are not the same thing.
267
+
268
+ **Kind**: global function
269
+
270
+ | Param | Type |
271
+ | --- | --- |
272
+ | value | <code>\*</code> |
273
+
274
+ <a name="readCacheTTL"></a>
275
+
276
+ ## readCacheTTL(context, value) ⇒ <code>number</code>
277
+ Reads the discovery cache lifetime: a required, non-negative integer of
278
+ milliseconds, where `0` means "no cache — every lookup reaches the registry".
279
+
280
+ `options.cacheTTL || 300000` is what this replaces, and `0` was the value it
281
+ destroyed: a caller switching the cache off was given five minutes of cached
282
+ `status` instead, with nothing said. A default that inverts the one override
283
+ it is asked for is not a default (`architecture-principles.md` §3, §8), and
284
+ the absent case is not a value at all — it is a missing decision, so it
285
+ throws (§4).
286
+
287
+ **Kind**: global function
288
+ **Throws**:
289
+
290
+ - <code>Error</code> when the value is absent, or is not an integer >= 0
291
+
292
+
293
+ | Param | Type | Description |
294
+ | --- | --- | --- |
295
+ | context | <code>string</code> | Name of the caller for the message |
296
+ | value | <code>\*</code> | The value the caller passed as `options.cacheTTL` |
297
+
package/CHANGELOG.md ADDED
@@ -0,0 +1,360 @@
1
+ # Changelog — @onlineapps/cookbook-router
2
+
3
+ All notable changes to this package. Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [5.0.0] — 2026-10-03
8
+
9
+ pin: `@onlineapps/conn-orch-registry` 7.0.0 → 8.0.0
10
+
11
+ ### Changed — BREAKING: selhání registru už není „Service not available" (d.1086)
12
+
13
+ Pro volajícího `isServiceAvailable()` a `routeToService()` se mění kontrakt: při odmítnutí
14
+ `registryClient.getService()` dřív dostal `false` (a `routeToService` chybu
15
+ `Service not available`), teď dostane odmítnutí s `code: 'SERVICE_DISCOVERY_FAILED'`.
16
+
17
+ - `ServiceDiscovery.isServiceAvailable()` spolkla odmítnutí `registryClient.getService()`:
18
+ zalogovala ho a vrátila `false`, takže `routeToService` hlásil
19
+ `[CookbookRouter] Service not available: <služba>` a příčina (nedostupný Redis, poškozený
20
+ záznam projekce, chyba zapojení) do neúspěšného běhu nedošla (architecture-principles §3–§5).
21
+ Nově se odmítnutí dál loguje a **vyhodí** jako `Error` s `code: 'SERVICE_DISCOVERY_FAILED'`,
22
+ `serviceName` a původní chybou v `cause`; `routeToService` ji nechá projít. Neúspěšný dotaz
23
+ se necachuje.
24
+ - `false` zůstává jen odpovědí registru: služba není `active`, nebo ji projekce nedrží
25
+ (`getService()` → `null`). Pro neznámou službu metoda dřív vracela `null` místo slíbeného
26
+ `boolean`; nyní `false`.
27
+ - Klasifikace pro retry se nemění: `SERVICE_DISCOVERY_FAILED` klasifikátor
28
+ (`error-handler-core`) nezná → `UNKNOWN`, stejně jako dřívější „Service not available".
29
+
30
+ ### Changed — jedna cesta chyby discovery, mrtvá větev `ECONNREFUSED` pryč (d.1092)
31
+
32
+ - `ServiceDiscovery.isServiceAvailable()` logovala odmítnutí s `code === 'ECONNREFUSED'`
33
+ jinou řádkou (`Registry connection failed`). Větev zbyla z HTTP discovery; dotaz dnes čte
34
+ Redis projekci (conf `biz-discovery-redis` 001, ADR 0005) a jeho klient takový `code`
35
+ nedává, takže nebyla dosažitelná. Každé odmítnutí se teď loguje jednou řádkou
36
+ `Service discovery failed for <služba>:` a hází stejně jako dřív
37
+ (`SERVICE_DISCOVERY_FAILED`, `cause` = původní chyba).
38
+
39
+ ### Added — `SERVICE_DISCOVERY_FAILED` nese `type` příčiny (d.1093)
40
+
41
+ - Odmítnutí z `ServiceDiscovery.isServiceAvailable()` převezme `type` z `cause`, pokud ho
42
+ příčina má; jinak `type` nemá. Verdikt vzniká tam, kde vzniká chyba: `@onlineapps/conn-orch-registry`
43
+ `getService()` otypuje dotaz, na který projekce registru neodpověděla (termín / Redis
44
+ nedostupný), jako `TRANSIENT`, a klasifikátor L1 (`error-handler-core`) čte `type`, aniž by
45
+ znal Redis — krok tak jde do retry místo `non_retryable_unknown`. Příčina bez `type` se
46
+ klasifikuje jako dřív (`UNKNOWN`).
47
+
48
+ ### Added — integrační tier: discovery nad skutečným registry klientem a Redisem (d.1097)
49
+
50
+ - Nový tier `npm run test:integration` (`jest.integration.config.js`, `tests/integration/`);
51
+ `npm test` = unit && integration. `globalSetup` ověří živý Redis (`REDIS_URL`, jediný klíč,
52
+ hodnota se nikdy nevypisuje) a bez něj běh odmítne s opravou; `jest.config.js` tier
53
+ vylučuje, takže do unit tieru api nevstoupí.
54
+ - `ServiceDiscovery` se skládá s **nainstalovaným** `@onlineapps/conn-orch-registry` a `redis`
55
+ (devDependencies, přesné piny): služba v projekci `registry:services` jako `active` → `true`,
56
+ jiný stav nebo chybějící záznam → `false` (s kontrolou souseda v témže hashi), nečitelný
57
+ záznam, odpojený Redis, zavřený socket a chybová odpověď → `SERVICE_DISCOVERY_FAILED`
58
+ s původní chybou v `cause`. Živý Redis v databázi tieru, jen pole vlastního běhu.
59
+ - Pojmenovaná mez: nainstalovaný registry klient chybu netypuje, proto sada tvrdí
60
+ `type === undefined`; strážní test verze zčervená při posunu pinu, kdy se tvrzení mění na
61
+ `TRANSIENT` a přibude scénář polootevřeného Redisu (conf `biz-discovery-redis` 003).
62
+
63
+ ### Fixed — `QueueManager.publish()` už publish neopakuje (d.1121)
64
+
65
+ - Při odmítnutí, jehož text obsahoval `Connection lost`, volal `publish()` klienta podruhé.
66
+ Retry a buffer publishe vlastní `@onlineapps/mq-client-core` uvnitř každého `publish()`
67
+ (`docs/architecture/mq-publish-reliability.md` § The one rule for services); vyhozená
68
+ chyba znamená „nepotvrzeno" — zpráva je v bufferu klienta, nebo odmítnuta natrvalo — a druhé
69
+ volání by odeslalo druhou kopii. Rozhodovalo se navíc podle textu hlášky, ne podle typu.
70
+ - Nově jedno volání na zprávu; odmítnutí se zaloguje a propadne volajícímu beze změny (týž
71
+ objekt). Měřeno nad vydaným `mq-client-core` 5.0.0 (skutečné `BaseClient`, `RabbitMQClient`,
72
+ `PublishLayer`, dvojník jen `amqplib.connect`): ztracené spojení bez obnovy, odmítnutá obnova
73
+ i kanál mrtvý pod potvrzením hodí `PublishError`/`ConnectionError`, žádná s `Connection lost`
74
+ v textu — větev na této verzi nebyla dosažitelná.
75
+
76
+ ### Added — `routeToService` nese volby publishe (d.1124)
77
+
78
+ - `routeToService(serviceName, message, publishOptions = {})`: třetí argument se předá
79
+ beze změny jako třetí argument `QueueManager.publish()`, který ho sloučí přes výchozí volby
80
+ zprávy (`{ persistent: true }`) a pošle MQ klientovi. Router volby nečte ani nedoplňuje —
81
+ klíč, který klient nezná, je klientův k ignorování. Do d.1124 router třetí argument zahodil,
82
+ takže `{ bufferOnFailure: false }` orchestrátoru k MQ klientovi nedošel a klient zprávu
83
+ dalšího kroku po vyčerpaném publishi uložil do bufferu a po reconnectu odeslal podruhé
84
+ (změřeno d.1119).
85
+ - Volby, jsou-li dány, musí být plain object; jinak `routeToService` hodí
86
+ `[CookbookRouter] routeToService - publishOptions must be an object - …` dřív, než se zeptá
87
+ registru, a nic nepublikuje. Bez třetího argumentu se chování nemění (`publish()` dostane `{}`).
88
+ - `API.md` nově nese `CookbookRouter` a jeho `routeToService`: docblock třídy stál nad
89
+ `require('./serviceDiscovery')`, takže ho jsdoc2md vykreslil jako druhý záznam
90
+ „ServiceDiscovery" (konstanta) a třídu s metodou vynechal. Docblock je teď nad
91
+ `class CookbookRouter`; citace volajících jsou jmény metod `WorkflowOrchestrator`, ne
92
+ ručně psanými čísly řádků.
93
+
94
+ ### Tests — integrační helper staví registry klienta s `discoveryTimeoutMs` (d.1200)
95
+
96
+ - Test, ne chování: `registryClientOver()` (`tests/integration/helpers.js`) stavěl klienta bez
97
+ `discoveryTimeoutMs`, který je od kontraktu registry 8.0.0 povinný (d.1093); se zdrojem
98
+ registry z `release/w4` proto padalo 11 z 12 testů integračního tieru hláškou
99
+ `Missing constructor option - discoveryTimeoutMs is required`. Helper teď předává hodnotu
100
+ z deklarace `REGISTRY_DISCOVERY_TIMEOUT_MS` v `config/shared-env.json` — tutéž, kterou wrapper
101
+ čte a klientovi předává — nikdy literál; chybějící nebo nečíselná deklarace skončí
102
+ pojmenovanou chybou. Tvrzení beze změny; `src/**` se nemění.
103
+
104
+ ### Changed — hláška `SERVICE_DISCOVERY_FAILED` bez dvojité tečky (d.1202)
105
+
106
+ - `ServiceDiscovery.isServiceAvailable()` uzavíral citovanou příčinu vždy tečkou. Odmítnutí
107
+ registry klienta 8.0.0 jsou celé věty `[Context] Problem - Expected/Fix`, které tečkou už
108
+ končí, takže hláška četla `…retried as TRANSIENT.. Expected: …` (architecture-principles §5).
109
+ Příčina zakončená `.`, `!` nebo `?` se teď cituje beze změny; příčině bez koncové interpunkce
110
+ se tečka doplní jako dřív. Kód, `serviceName`, `cause` a `type` beze změny.
111
+
112
+ ### Tests — aserce objevování na kontrakt registry 8.0.0 (K13; d.1201)
113
+
114
+ - Test, ne chování: `tests/integration/serviceDiscovery.integration.test.js` popisoval registry
115
+ klienta 7.0.0, který selhání dotazu netypuje (dva případy „NAMED LIMIT: untyped"). Registry
116
+ 8.0.0 při `isReady === false` (offline s `disableOfflineQueue`, socket zavřený pod příkazem)
117
+ odmítne vlastní chybou `REGISTRY_DISCOVERY_UNAVAILABLE`, `type: 'TRANSIENT'`, s chybou
118
+ node-redis v její `cause` (d.1093). Oba případy teď tvrdí `SERVICE_DISCOVERY_FAILED` s
119
+ `type: 'TRANSIENT'`, v `cause` chybu registru (`code`, `type`) a pod ní původní třídu
120
+ node-redis (`ClientOfflineError`, `SocketClosedUnexpectedlyError`); offline případ dál tvrdí
121
+ celou hlášku. Strážní `INSTALLED_REGISTRY_VERSION` je `'8.0.0'`; devDependency
122
+ `@onlineapps/conn-orch-registry` přechází na 8.0.0 v témže commitu (K13). Chybová
123
+ odpověď připojeného Redisu a poškozený záznam projekce zůstávají netypované. `src/**` se nemění.
124
+
125
+ ## [4.0.0] — 2026-09-27
126
+
127
+ ### Changed — BREAKING: `logger` and `cacheTTL` are required, and `0` is a value (d.615)
128
+
129
+ Three constructors read their options with `||`:
130
+
131
+ logger: options.logger || console (router.js, serviceDiscovery.js, queueManager.js)
132
+ cacheTTL: options.cacheTTL || 300000 (serviceDiscovery.js)
133
+
134
+ Both are the shape `.claude/rules/architecture-principles.md` §3 bans by name,
135
+ and the second did measurable harm beyond the principle. `cacheTTL: 0` means
136
+ "ask the registry every time"; `||` is false for `0`, so that request became
137
+ five minutes of cached `status` instead — the exact opposite of what the caller
138
+ asked for, decided in silence. A caller who had switched the cache off was
139
+ served a service's liveness from memory for 300 seconds and had no way to see
140
+ it. `logger || console` is the quieter half: a component nobody handed a logger
141
+ wrote to a stream no collector reads, on a platform where every neighbouring
142
+ package refuses the same omission by name.
143
+
144
+ So both keys are now **required** and checked in the constructor (§4):
145
+
146
+ - `logger` must be an object with `info`, `warn`, `error` and `debug` as
147
+ functions. Absent → `[<class>] logger is required - Expected: a logger with
148
+ info/warn/error/debug, so <reason>. Fix: pass options.logger …`; incomplete →
149
+ the same message naming the missing methods. `console` satisfies the shape and
150
+ is still accepted — when a caller passes it deliberately.
151
+ - `cacheTTL` must be an integer `>= 0`, in milliseconds. Absent (or `null`, the
152
+ way `WorkflowOrchestrator.readNumericOption` reads an omitted number) →
153
+ `cacheTTL is required`; `NaN`, negative, fractional, `Infinity`, a numeric
154
+ string or a boolean → `cacheTTL is invalid - … got <value>`, with the value
155
+ rendered so `"300000"` and `300000` cannot be confused.
156
+ - `cacheTTL: 0` is accepted and means **no cache**: `getCached` now compares
157
+ `age >= cacheTTL` rather than `age > cacheTTL`, so an entry whose age has
158
+ reached its lifetime is spent and every lookup reaches the registry. With `>`
159
+ two lookups inside one millisecond would still have hit the cache, and "no
160
+ cache" would have held only most of the time.
161
+
162
+ The checks live in one new module, `src/options.js`, because all three
163
+ constructors read `logger` with the same meaning and a second copy inside one
164
+ package is two rails for one concern (`change-discipline.md` § One rail per
165
+ concern). The logger contract itself comes from `@onlineapps/logger-contract`,
166
+ which owns it platform-wide: `src/options.js` re-exports its `assertLogger`
167
+ rather than restating it, now that the publication wave has pinned the package
168
+ (`f60f24c3`, `@onlineapps/logger-contract 2.0.0`). Until that pin existed the
169
+ check was a hand-written copy here, word for word identical — so the swap cost
170
+ one import and no re-test: every message assertion in the suite passed
171
+ unchanged. `readCacheTTL` stays this package's own, because `cacheTTL` is its
172
+ own option and no library owns it.
173
+
174
+ **For the caller:** `@onlineapps/conn-orch-orchestrator` built the router with
175
+ `createRouter(this.mqClient, this.registryClient, { logger: this.logger })` in
176
+ `WorkflowOrchestrator` — a logger but no `cacheTTL`. It passes one since d.615b
177
+ (`config.serviceDiscoveryCacheTTL`, default 300000, `0` honoured), so this major
178
+ has its caller ready before it is published. Nothing else in the workspace
179
+ constructs these classes: `conn-orch-cookbook` only destructures and re-exports
180
+ them.
181
+
182
+ Tests: `tests/unit/required-options.test.js` (26), covering each refusal by its
183
+ message, `cacheTTL: 0` sending three lookups to the registry, the control that a
184
+ real TTL still serves the second from cache, the control that the whole chain
185
+ still routes end to end with the real collaborators, and — since the pin — that
186
+ `options.assertLogger` IS the library's function (identity, not an equal copy)
187
+ and that the module exports no second `LOGGER_METHODS`.
188
+
189
+ ### Removed — BREAKING: `cacheEnabled`, druhá kolej pro „necachuj" (d.615b)
190
+
191
+ `ServiceDiscovery` odpovídal na otázku „mám si pamatovat odpověď registru?"
192
+ dvěma klíči: `cacheEnabled` (`options.cacheEnabled !== false`) a — od d.615 —
193
+ `cacheTTL`, kde `0` znamená bez cache. Obě odpovědi šly napsat proti sobě:
194
+ `{ cacheEnabled: true, cacheTTL: 0 }` i `{ cacheEnabled: false, cacheTTL: 300000 }`
195
+ byly platné a v každé z nich jeden z těch dvou klíčů lhal. Jedna starost, dvě
196
+ koleje (`change-discipline.md` § One rail per concern) — a `cacheEnabled` navíc
197
+ implicitní default rozhodovaný z `!== false` (§8).
198
+
199
+ Zůstává `cacheTTL`: jedna hodnota říká obojí — jestli cachovat a jak dlouho.
200
+ Kdo nechce cachovat, napíše `0`; od téhle dávky se při něm do `Map` ani nic
201
+ nezapisuje. Klíč `cacheEnabled` se nečte vůbec (nepřekládá se, nevaruje,
202
+ neodmítá) — volající, který ho ještě pošle, dostane to, co říká životnost.
203
+
204
+ Čtyři otázky `change-discipline.md` § Removing something removes its declaration:
205
+
206
+ 1. **Proč vznikl.** Přišel s balíkem do `shared/` (`d05bb7be`, restrukturalizace)
207
+ jako přepínač „tenhle volající nechce discovery-cache". `git log -S cacheEnabled
208
+ -- shared/cookbook/cookbook-router` zná od té doby tři commity (`d05bb7be`,
209
+ `144db203`, `7fa728e8`) a ani jeden z nich ten klíč nikde nenastavuje.
210
+ 2. **Která část koncepce ho nesla.** Žádná: nemá ADR, kontrakt ani model, jen
211
+ řádek v README § Options. Koncept „necachuj" vlastní od d.615 `cacheTTL`
212
+ (`0` = bez cache, `getCached` porovnává `age >= cacheTTL`).
213
+ 3. **Proč ho dnes nikdo nečte.** Mimo balík ho nikdy nikdo nenastavil. Jediný
214
+ stavitel routeru je `WorkflowOrchestrator` a ten posílá `{ logger, cacheTTL }`
215
+ (d.615b). Změřeno napříč workspace (`api`, `api_biz/*`, `fe_adminui`, mimo
216
+ `node_modules`): jediné další výskyty `cacheEnabled` patří `conn-base-storage`
217
+ a `conn-orch-registry` — vlastní, nesouvisející volby jejich vlastních tříd.
218
+ 4. **Je náhrada koncepčnější.** Ano. Jedna hodnota nemůže sama se sebou být
219
+ v rozporu, zatímco dvě klidně ano; a `0` je hodnota, kterou volající vysloví,
220
+ místo default odvozeného z `!== false`.
221
+
222
+ Testy: `tests/unit/serviceDiscovery.test.js` — `cacheTTL: 0` je jediná cesta
223
+ k „necachuj" (dva lookupy = dvě volání registru, `cache.size === 0`),
224
+ `cacheEnabled: false` s reálnou životností cache NEvypne (druhý lookup jde z ní),
225
+ a konstruktor po sobě nenechá `options.cacheEnabled`.
226
+
227
+ ### Changed — tarball nese CHANGELOG, README a API.md (d.1021)
228
+
229
+ `files` v `package.json` jmenoval jen `src`, takže vydaný balíček nenesl `CHANGELOG.md`
230
+ ani generované `API.md` — kdo měl router v `node_modules`, neviděl, co se mezi verzemi
231
+ změnilo, ani referenci exportů. `files` je teď `src`, `CHANGELOG.md`, `README.md`,
232
+ `API.md`. `README.md` npm přibaluje vždy; v seznamu stojí proto, aby obsah tarballu
233
+ říkal jeden seznam, ne seznam plus pravidlo npm. Ze `src` nic neubylo
234
+ (`npm pack --dry-run`: přibyly `API.md` a `CHANGELOG.md`).
235
+
236
+ ## [3.0.1] — 2026-09-16
237
+
238
+ ### Fixed — the unit tier claimed a queue declaration the client refuses (d.520)
239
+
240
+ `should apply custom queue options` asserted that `ensureQueue('custom.queue',
241
+ { maxLength: 1000, messageTtl: 60000 })` reaches the declaration with those two
242
+ keys. Neither half held: this package composes no queue argument — it merges its
243
+ defaults with the caller's options and hands the result to
244
+ `mqClient.assertQueue()` — and since d.396c of `@onlineapps/mq-client-core` that
245
+ client refuses, by name, a key `assertQueue()` does not read:
246
+
247
+ [RabbitMQClient] Queue option not declared by assertQueue(): "maxLength",
248
+ "messageTtl" - Expected: only durable, arguments, exclusive, autoDelete.
249
+
250
+ The test passed only because its client stub accepted anything. The stub now
251
+ refuses exactly what the real client refuses, which makes it the guard of the
252
+ file: every declaration any test here produces runs through it, so a key the
253
+ client does not read cannot pass unnoticed. The claim itself is corrected to the
254
+ broker vocabulary — `arguments: { 'x-max-length': 1000, 'x-message-ttl': 60000 }`
255
+ — with the value asserted, not its presence.
256
+
257
+ Added with it: the failure path (a friendlier spelling is refused and the refusal
258
+ propagates — the package neither translates nor catches, and the queue is not
259
+ remembered as ensured), the control case (a queue asked for without limits
260
+ carries no `arguments` key at all), and a test that every declaration this
261
+ package composes on its own — constructor defaults, the `publish()` path, a
262
+ per-call merge — carries only keys the client reads.
263
+
264
+ No behaviour change: `src/queueManager.js` already forwarded the declared option
265
+ set, and its `ensureQueue()` doc now says where a limit belongs. `README.md`
266
+ § Options documents the shape.
267
+
268
+ ## [3.0.0] — 2026-09-14
269
+
270
+ ### BREAKING — `defaultOptions` is two keys, `queueOptions` and `publishOptions` (d.396d)
271
+
272
+ A queue declaration and a message are two different things, and neither one's
273
+ defaults belong to the other. One object fed both — `{ durable: true,
274
+ persistent: true }`, spread into the publish AND into the queue declaration — so
275
+ each call site was handed a key it has no use for. Measured in both directions
276
+ on the unit tier:
277
+
278
+ assertQueue('split.queue', { durable: true, persistent: true })
279
+ publish('control.publish', …, { durable: true, persistent: true })
280
+
281
+ `persistent` is a message property (amqplib `Options.Publish`) and means nothing
282
+ to a queue; `durable` is a queue property and means nothing to a message.
283
+ Nothing objected, because `@onlineapps/mq-client-core` read the two keys it knew
284
+ and dropped the rest in silence — and since its d.396c it no longer does:
285
+ `assertQueue()` refuses an option it does not read, by name. What was an
286
+ invisible confusion becomes a throw the moment the pin moves, and the cure is
287
+ not to catch it but to stop sending a message property to a queue.
288
+
289
+ - `queueOptions` (default `{ durable: true }`) is merged into every queue
290
+ declaration and reaches `mqClient.assertQueue()`, whose declared option set is
291
+ `durable`, `arguments`, `exclusive`, `autoDelete`.
292
+ - `publishOptions` (default `{ persistent: true }`) is merged into every publish
293
+ and reaches `mqClient.publish()` as amqplib message properties.
294
+ - Per-call options are unchanged: `ensureQueue(queue, options)` and
295
+ `publish(queue, message, options)` still override their own defaults.
296
+
297
+ **Fixed in the same expression:** the constructor spread `...options` AFTER the
298
+ computed block, which undid the merge it was written to extend — a caller
299
+ passing `{ durable: false }` replaced the whole defaults object instead of
300
+ overriding one key of it, and lost the other default with it. `...options` now
301
+ comes first, so every key this class does not compute is still forwarded
302
+ untouched (`router.js` hands its whole options object to both collaborators) and
303
+ the computed keys survive.
304
+
305
+ **Callers:** none. `defaultOptions` was measured across `api`, `api_biz` and
306
+ `fe_adminui` (no `node_modules`) on 2026-09-14 — nothing outside this package
307
+ constructs `QueueManager` or writes the key; `config/libraries.json` pins the
308
+ package, and `api/jest.config.unit.js` lists it, neither of which passes options.
309
+
310
+ RED (unit): 8 of 24, with the leak named in both directions —
311
+ `+ "persistent": true` inside the `assertQueue` call and `+ "durable": true`
312
+ inside the publish options. GREEN: 24/24 in the file, 63/63 for the package.
313
+
314
+ ### BREAKING — the package routes, and does nothing else (DÁVKA 83)
315
+
316
+ Execution, flow control, retry, completion and dead-lettering have exactly one
317
+ owner, `WorkflowOrchestrator` — confirmation
318
+ [`cookbook-execution-owner.md`](/api/docs/governance/confirmations/cookbook-execution-owner.md)
319
+ 001. This package carried a second copy of most of it. Measured on 2026-09-02
320
+ across `api/shared`, `api/infra`, `api_biz` and `fe_adminui` (no
321
+ `node_modules`), every removed member had **zero callers outside this package**;
322
+ the only live chain is `createRouter` → `routeToService` →
323
+ `isServiceAvailable` → `publish` → `ensureQueue`, which
324
+ `WorkflowOrchestrator.js:71,78,233,241,1223,1230` uses and nothing else does.
325
+
326
+ Removed:
327
+
328
+ - `CookbookRouter`: `routeWorkflow`, `routeToNextService`, `routeToCompleted`,
329
+ `routeToDLQ`, `determineTargetService`, `buildWorkflowMessage`, `handleRetry`.
330
+ `routeToService` and the constructor stay.
331
+ - `RetryHandler` — the whole class and its module, reachable only from
332
+ `handleRetry`. It is no longer exported here, nor re-exported by
333
+ `@onlineapps/conn-orch-cookbook`. Retry with backoff lives in
334
+ `WorkflowOrchestrator.js:495`, and the exhausted-retry publish to
335
+ `workflow.failed` at `:574`.
336
+ - `QueueManager`: `consume`, `getQueueInfo`, `purgeQueue`, `deleteQueue`,
337
+ `resetConnection`. Consumption belongs to `@onlineapps/mq-client-core` through
338
+ `ServiceWrapper`. Removing `consume` also closes the open ack/nack question
339
+ recorded against `queueManager.js` in `api/shared/TODO.md`: the code it asked
340
+ about is gone.
341
+ - `ServiceDiscovery`: `getServiceInfo`, `listAvailableServices`,
342
+ `getServiceQueue`, `invalidateCache`. `isServiceAvailable` and its TTL cache
343
+ stay.
344
+ - `CookbookRouter` option defaults `defaultQueue`, `completedQueue`, `dlqSuffix`,
345
+ `maxRetries`, `retryDelay`. The first three fed the removed routing rail; the
346
+ last two had no reader even before it — `RetryHandler` keyed on `maxAttempts`
347
+ and `baseDelay`, never on these. `logger` is the only key the class reads, and
348
+ the caller's `options` still reach `ServiceDiscovery` and `QueueManager`
349
+ whole. A declaration nothing reads is dead
350
+ (`.claude/rules/change-discipline.md` § Removing).
351
+
352
+ Also in this change: the package gained a `jest.config.js` and a line in
353
+ `api/jest.config.unit.js`, so its suites run in the api unit tier. They ran
354
+ under no root npm script before — a whole sada outside the regression loop
355
+ (`.claude/rules/service-refactoring.md` § forbidden action #4).
356
+
357
+ The DÁVKA 78 fix that made `routeToNextService` and `handleRetry` find steps by
358
+ `step_id` instead of `id` is superseded: both methods are gone, and so is the
359
+ `id`-vs-`step_id` question in this package. `routeToService` takes a service
360
+ name and an opaque message and reads no step identifier at all.
package/README.md CHANGED
@@ -36,8 +36,8 @@ const { createRouter, CookbookRouter, ServiceDiscovery, QueueManager } =
36
36
 
37
37
  | Export | What it is |
38
38
  |---|---|
39
- | `createRouter(mqClient, registryClient, options)` | Factory returning a `CookbookRouter`. This is what `WorkflowOrchestrator.js:71` takes. |
40
- | `CookbookRouter` | One method: `routeToService(serviceName, message)`. |
39
+ | `createRouter(mqClient, registryClient, options)` | Factory returning a `CookbookRouter`. This is what the `WorkflowOrchestrator` constructor takes (through `cookbook.createRouter`). |
40
+ | `CookbookRouter` | One method: `routeToService(serviceName, message, publishOptions)`. |
41
41
  | `ServiceDiscovery` | One method: `isServiceAvailable(serviceName)`, with a TTL cache. |
42
42
  | `QueueManager` | Two methods: `publish(queue, message, options)` and `ensureQueue(queue, options)`. |
43
43
  | `VERSION` | String constant. |
@@ -47,35 +47,71 @@ const { createRouter, CookbookRouter, ServiceDiscovery, QueueManager } =
47
47
  ```javascript
48
48
  const { createRouter } = require('@onlineapps/cookbook-router');
49
49
 
50
- const router = createRouter(mqClient, registryClient, { logger });
50
+ const router = createRouter(mqClient, registryClient, { logger, cacheTTL: 300000 });
51
51
 
52
52
  // Publishes to `biz-invoicing.workflow`.
53
53
  await router.routeToService('biz-invoicing', workflowMessage);
54
+
55
+ // Publish options for this one message travel to the MQ client unchanged.
56
+ await router.routeToService('biz-invoicing', workflowMessage, { bufferOnFailure: false });
54
57
  ```
55
58
 
59
+ The optional third argument, `publishOptions`, is handed as-is to
60
+ `QueueManager.publish()`, which merges it over the message defaults and passes it
61
+ to the MQ client. The router neither reads nor completes it; without it the
62
+ client gets the message defaults alone.
63
+
56
64
  `routeToService` fails fast and never publishes a message it could not place:
57
65
 
58
66
  | Condition | Result |
59
67
  |---|---|
60
68
  | `serviceName` missing or not a string | throws `[CookbookRouter] routeToService - serviceName is required and must be a string` |
61
69
  | `message` missing or not an object | throws `[CookbookRouter] routeToService - message is required and must be an object` |
70
+ | `publishOptions` given and not a plain object | throws `[CookbookRouter] routeToService - publishOptions must be an object - …` |
62
71
  | registry says the service is not `active` | throws `[CookbookRouter] Service not available: <name>` |
63
72
  | the publish itself fails | the underlying error propagates unchanged |
64
73
 
65
74
  ## Options
66
75
 
67
- The single caller passes `{ logger }` and nothing else. `options` is forwarded
68
- verbatim to both collaborators, so their keys travel through it:
76
+ The caller passes `{ logger, cacheTTL }`; everything else has a default.
77
+ `options` is forwarded verbatim to both collaborators, so their keys travel
78
+ through it:
69
79
 
70
80
  | Key | Read by | Default |
71
81
  |---|---|---|
72
- | `logger` | `CookbookRouter`, `ServiceDiscovery`, `QueueManager` | `console` |
73
- | `cacheEnabled` | `ServiceDiscovery` | `true` |
74
- | `cacheTTL` | `ServiceDiscovery` (ms) | `300000` |
82
+ | `logger` | `CookbookRouter`, `ServiceDiscovery`, `QueueManager` | **required** |
83
+ | `cacheTTL` | `ServiceDiscovery` (ms) | **required** |
75
84
  | `ensureQueues` | `QueueManager` — assert the queue before the first publish | `true` |
76
85
  | `queueOptions` | `QueueManager` — merged into every queue declaration | `{ durable: true }` |
77
86
  | `publishOptions` | `QueueManager` — merged into every publish | `{ persistent: true }` |
78
87
 
88
+ What a `logger` IS comes from `@onlineapps/logger-contract` (pinned in
89
+ `f60f24c3`): `src/options.js` re-exports its `assertLogger`, so the four methods
90
+ and the wording below are the platform's, not this package's.
91
+
92
+ The two required keys are checked in the constructor, and each refusal names
93
+ the key:
94
+
95
+ | Condition | Result |
96
+ |---|---|
97
+ | `logger` absent | throws `[<class>] logger is required - Expected: a logger with info/warn/error/debug, …` |
98
+ | `logger` missing a method | throws `[<class>] logger is incomplete - … missing: debug. Fix: pass a logger implementing all four.` |
99
+ | `cacheTTL` absent or `null` | throws `[ServiceDiscovery] cacheTTL is required - Expected: an integer >= 0, milliseconds …` |
100
+ | `cacheTTL` not an integer `>= 0` | throws `[ServiceDiscovery] cacheTTL is invalid - … got <value>.` |
101
+
102
+ `cacheTTL: 0` is a value, not an absence: it means **no cache** — every
103
+ `isServiceAvailable` lookup reaches the registry, and nothing is written to the
104
+ Map on the way. Until d.615 it was read as `options.cacheTTL || 300000`, so that
105
+ request silently became five minutes of cached `status`, and `logger` was read as
106
+ `options.logger || console`, so a caller who passed none had its output written
107
+ where nothing collects it.
108
+
109
+ It is also the **only** way to say it. `cacheEnabled` was a second key for the
110
+ same question until d.615b: with both, `{ cacheEnabled: true, cacheTTL: 0 }` and
111
+ `{ cacheEnabled: false, cacheTTL: 300000 }` were sayable, and in each of them one
112
+ of the two keys was a lie. The key is no longer read at all — a caller who still
113
+ passes it gets what the lifetime says.
114
+
79
115
  A queue declaration and a message are two different things, so their defaults
80
116
  are two objects with two owners. `queueOptions` reaches
81
117
  `mqClient.assertQueue()`, whose declared option set is `durable`, `arguments`,
@@ -112,11 +148,16 @@ orchestrator's, not this package's.
112
148
  ## Collaborator contracts
113
149
 
114
150
  - `registryClient.getService(serviceName)` resolves to an object whose `status`
115
- is `'active'` when the service is up. A rejection is logged and read as "not
116
- available" — `isServiceAvailable` returns `false`, it does not throw.
151
+ is `'active'` when the service is up, or to `null` when the registry does not
152
+ hold the service — both are answers, and `isServiceAvailable` returns `false`
153
+ for anything but `'active'`. A rejection is not an answer: it is logged and
154
+ thrown as `code: 'SERVICE_DISCOVERY_FAILED'` with `serviceName` and the
155
+ rejection as `cause`, and `routeToService` lets it through (d.1086) — never
156
+ "Service not available". A failed lookup is not cached.
117
157
  - `mqClient.assertQueue(queue, options)` and `mqClient.publish(queue, message,
118
- options)`. `publish` retries exactly once when the error message contains
119
- `Connection lost`; every other error propagates.
158
+ options)`. `publish` is called once per message; a rejection propagates
159
+ unchanged. Retry and buffering are the client's
160
+ (`docs/architecture/mq-publish-reliability.md` § The one rule for services).
120
161
 
121
162
  ## Related packages
122
163
 
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "@onlineapps/cookbook-router",
3
- "version": "3.0.1",
3
+ "version": "5.0.0",
4
4
  "description": "Message routing for cookbook workflows - handles service discovery and queue routing",
5
5
  "oa": {
6
6
  "category": "orchestration"
7
7
  },
8
8
  "main": "src/index.js",
9
9
  "scripts": {
10
- "test": "npm run test:unit",
10
+ "test": "npm run test:unit && npm run test:integration",
11
11
  "test:unit": "jest tests/unit",
12
+ "test:integration": "jest --config=jest.integration.config.js",
12
13
  "test:watch": "jest --watch",
13
14
  "test:coverage": "jest --coverage",
14
15
  "docs": "jsdoc2md --files 'src/**/*.js' > API.md.tmp && mv API.md.tmp API.md || (rm -f API.md.tmp; exit 1)"
@@ -22,16 +23,23 @@
22
23
  ],
23
24
  "author": "OnlineApps",
24
25
  "license": "PROPRIETARY",
25
- "dependencies": {},
26
+ "dependencies": {
27
+ "@onlineapps/logger-contract": "2.0.0"
28
+ },
26
29
  "devDependencies": {
30
+ "@onlineapps/conn-orch-registry": "8.0.0",
27
31
  "jest": "^29.7.0",
28
- "jsdoc-to-markdown": "^8.0.0"
32
+ "jsdoc-to-markdown": "^8.0.0",
33
+ "redis": "4.7.1"
29
34
  },
30
35
  "engines": {
31
36
  "node": ">=24.0.0 <25"
32
37
  },
33
38
  "files": [
34
- "src"
39
+ "src",
40
+ "CHANGELOG.md",
41
+ "README.md",
42
+ "API.md"
35
43
  ],
36
44
  "publishConfig": {
37
45
  "access": "public"
package/src/index.js CHANGED
@@ -9,9 +9,10 @@
9
9
  * The package does NOT execute cookbooks, drive flow control, retry, complete
10
10
  * or dead-letter them. Those belong to `WorkflowOrchestrator` — confirmation
11
11
  * `api/docs/governance/confirmations/cookbook-execution-owner.md` 001 — which
12
- * is also the only caller: it takes `createRouter`
13
- * (WorkflowOrchestrator.js:71) and calls `routeToService` on the result
14
- * (:78,233,241,1223,1230). The second routing rail, the retry rail and the
12
+ * is also the only caller: its constructor takes `createRouter` (through
13
+ * `cookbook.createRouter`), and `processWorkflowMessage` and the method that
14
+ * hands the next step's message to the router call `routeToService` on the
15
+ * result. The second routing rail, the retry rail and the
15
16
  * queue/registry administration this package used to carry had zero callers
16
17
  * outside it and were removed on 2026-09-02.
17
18
  *
package/src/options.js ADDED
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The option checks this package's three constructors share.
5
+ *
6
+ * ONE place, because all three read the same key with the same meaning: a
7
+ * second copy of "what a logger is" inside one package would be two rails for
8
+ * one concern (`.claude/rules/change-discipline.md` § One rail per concern).
9
+ *
10
+ * For `logger` that one place is not here at all: the platform owns the concern
11
+ * in `@onlineapps/logger-contract`, and this module re-exports `assertLogger`
12
+ * from it. d.615 carried a hand-written copy, word for word identical, only
13
+ * because the package declared no dependencies; the publication wave pinned
14
+ * `@onlineapps/logger-contract 2.0.0` (`f60f24c3`), and a declared dependency
15
+ * nothing requires is what `change-discipline.md` § Removing something removes
16
+ * its declaration calls a defect. So the copy is gone — including its own
17
+ * `LOGGER_METHODS`, which this module no longer exports: whoever needs the list
18
+ * reads it from the package that defines it.
19
+ *
20
+ * `readCacheTTL` stays here. `cacheTTL` is this package's own option, read by
21
+ * `ServiceDiscovery` alone; no library owns it.
22
+ *
23
+ * @see /api/shared/logger-contract/src/index.js
24
+ */
25
+
26
+ const { assertLogger } = require('@onlineapps/logger-contract');
27
+
28
+ /**
29
+ * Renders a refused value for an error message without ever printing it as a
30
+ * bare word: `"300000"` and `300000` look identical otherwise, and the whole
31
+ * point of the refusal is that they are not the same thing.
32
+ *
33
+ * @param {*} value
34
+ * @returns {string}
35
+ */
36
+ function describeValue(value) {
37
+ return typeof value === 'string' ? JSON.stringify(value) : String(value);
38
+ }
39
+
40
+ /**
41
+ * Reads the discovery cache lifetime: a required, non-negative integer of
42
+ * milliseconds, where `0` means "no cache — every lookup reaches the registry".
43
+ *
44
+ * `options.cacheTTL || 300000` is what this replaces, and `0` was the value it
45
+ * destroyed: a caller switching the cache off was given five minutes of cached
46
+ * `status` instead, with nothing said. A default that inverts the one override
47
+ * it is asked for is not a default (`architecture-principles.md` §3, §8), and
48
+ * the absent case is not a value at all — it is a missing decision, so it
49
+ * throws (§4).
50
+ *
51
+ * @param {string} context - Name of the caller for the message
52
+ * @param {*} value - The value the caller passed as `options.cacheTTL`
53
+ * @returns {number}
54
+ * @throws {Error} when the value is absent, or is not an integer >= 0
55
+ */
56
+ function readCacheTTL(context, value) {
57
+ if (value === undefined || value === null) {
58
+ throw new Error(
59
+ `[${context}] cacheTTL is required - Expected: an integer >= 0, milliseconds `
60
+ + '(0 = no cache, every lookup reaches the registry). '
61
+ + 'Fix: pass options.cacheTTL, e.g. 300000 for five minutes.'
62
+ );
63
+ }
64
+
65
+ if (!Number.isInteger(value) || value < 0) {
66
+ throw new Error(
67
+ `[${context}] cacheTTL is invalid - Expected: an integer >= 0, milliseconds `
68
+ + `(0 = no cache); got ${describeValue(value)}. `
69
+ + 'Fix: pass options.cacheTTL as a non-negative integer, or 0 to switch the cache off.'
70
+ );
71
+ }
72
+
73
+ return value;
74
+ }
75
+
76
+ module.exports = {
77
+ assertLogger,
78
+ readCacheTTL
79
+ };
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { assertLogger } = require('./options');
4
+
3
5
  /**
4
6
  * What describes the QUEUE. Handed to `mqClient.assertQueue()`, whose declared
5
7
  * option set is `durable`, `arguments`, `exclusive`, `autoDelete`.
@@ -52,7 +54,11 @@ class QueueManager {
52
54
  ...DEFAULT_PUBLISH_OPTIONS,
53
55
  ...options.publishOptions
54
56
  },
55
- logger: options.logger || console
57
+ logger: assertLogger(
58
+ 'QueueManager',
59
+ options.logger,
60
+ 'a publish and its failure are reported somewhere that collects it'
61
+ )
56
62
  };
57
63
 
58
64
  this.ensuredQueues = new Set();
@@ -88,18 +94,15 @@ class QueueManager {
88
94
 
89
95
  logger.debug(`Publishing to ${queueName}`);
90
96
 
91
- // Handle connection errors with retry
92
- try {
93
- await this.mqClient.publish(queueName, messageWithTimestamp, publishOptions);
94
- return true;
95
- } catch (error) {
96
- // If connection lost, retry once
97
- if (error.message.includes('Connection lost')) {
98
- await this.mqClient.publish(queueName, messageWithTimestamp, publishOptions);
99
- return true;
100
- }
101
- throw error;
102
- }
97
+ // ONE call. Retry, buffering and reconnect coordination live inside the
98
+ // client's `publish()` (`@onlineapps/mq-client-core`), so a rejection is its
99
+ // answer — the message was buffered for replay or refused for good — and a
100
+ // second call from here would be a second copy of it. Until d.1121 this
101
+ // published again whenever the rejection's text contained `Connection lost`:
102
+ // a service-side retry, decided on a sentence no client release produces.
103
+ // @see ../../../../docs/architecture/mq-publish-reliability.md § The one rule for services
104
+ await this.mqClient.publish(queueName, messageWithTimestamp, publishOptions);
105
+ return true;
103
106
 
104
107
  } catch (error) {
105
108
  logger.error(`Failed to publish to ${queueName}:`, error);
package/src/router.js CHANGED
@@ -1,20 +1,34 @@
1
1
  'use strict';
2
2
 
3
+ const ServiceDiscovery = require('./serviceDiscovery');
4
+ const QueueManager = require('./queueManager');
5
+ const { assertLogger } = require('./options');
6
+
7
+ /**
8
+ * A plain object: `{}` or `Object.create(null)` — not `null`, an array, or an
9
+ * instance of another class (a `Map` of options would reach the MQ client as an
10
+ * object with no own keys).
11
+ * @private
12
+ * @param {*} value
13
+ * @returns {boolean}
14
+ */
15
+ function isPlainObject(value) {
16
+ if (value === null || typeof value !== 'object') return false;
17
+ const proto = Object.getPrototypeOf(value);
18
+ return proto === Object.prototype || proto === null;
19
+ }
20
+
3
21
  /**
4
22
  * CookbookRouter - routes a workflow message to a service queue.
5
23
  *
6
24
  * ONE responsibility, ONE method: `routeToService`. Execution, flow control,
7
25
  * retry, DLQ and completion belong to `WorkflowOrchestrator`
8
26
  * (confirmation `api/docs/governance/confirmations/cookbook-execution-owner.md`
9
- * 001), which is also this class's only caller —
10
- * `WorkflowOrchestrator.js:78,233,241,1223,1230`.
27
+ * 001), which is also this class's only caller: `processWorkflowMessage()` and
28
+ * the method that hands the next step's message to the router.
11
29
  *
12
30
  * @see /api/shared/cookbook/cookbook-router/README.md
13
31
  */
14
-
15
- const ServiceDiscovery = require('./serviceDiscovery');
16
- const QueueManager = require('./queueManager');
17
-
18
32
  class CookbookRouter {
19
33
  constructor(mqClient, registryClient, options = {}) {
20
34
  this.mqClient = mqClient;
@@ -26,11 +40,20 @@ class CookbookRouter {
26
40
  // keyed on `maxAttempts`/`baseDelay`, never on these. A declaration nothing
27
41
  // reads is dead (`.claude/rules/change-discipline.md` § Removing).
28
42
  // `options` is still forwarded whole to the two collaborators below, so
29
- // their own keys (`cacheEnabled`, `cacheTTL`, `ensureQueues`,
30
- // `queueOptions`, `publishOptions`) reach them unchanged.
43
+ // their own keys (`cacheTTL`, `ensureQueues`, `queueOptions`,
44
+ // `publishOptions`) reach them unchanged.
45
+ // `logger` used to default to `console` here. It no longer defaults at all:
46
+ // a router that logs where nothing collects is a router whose routing
47
+ // decisions are unobservable, and `||`/an implicit default is exactly what
48
+ // §3 bans. The check runs BEFORE the collaborators are built so the caller
49
+ // is told which object refused, in this class's own name.
31
50
  this.options = {
32
- logger: console,
33
- ...options
51
+ ...options,
52
+ logger: assertLogger(
53
+ 'CookbookRouter',
54
+ options.logger,
55
+ 'every routing decision is reported somewhere that collects it'
56
+ )
34
57
  };
35
58
 
36
59
  this.serviceDiscovery = new ServiceDiscovery(registryClient, options);
@@ -41,9 +64,17 @@ class CookbookRouter {
41
64
  * Route message directly to a specific service
42
65
  * @param {string} serviceName - Target service name
43
66
  * @param {Object} message - Workflow message to send
67
+ * @param {Object} [publishOptions={}] - Publish options for this one message,
68
+ * handed unchanged as the third argument of `QueueManager.publish()`, which
69
+ * merges them over its message defaults and passes them to the MQ client
70
+ * (e.g. `{ bufferOnFailure: false }`). The router neither reads nor completes
71
+ * them: a key the MQ client does not know is the client's to ignore. Must be
72
+ * a plain object when given.
44
73
  * @returns {Promise<void>}
74
+ * @throws {Error} when `publishOptions` is given and is not a plain object —
75
+ * before the registry is asked and before anything is published
45
76
  */
46
- async routeToService(serviceName, message) {
77
+ async routeToService(serviceName, message, publishOptions = {}) {
47
78
  const { logger } = this.options;
48
79
 
49
80
  if (!serviceName || typeof serviceName !== 'string') {
@@ -52,6 +83,13 @@ class CookbookRouter {
52
83
  if (!message || typeof message !== 'object') {
53
84
  throw new Error('[CookbookRouter] routeToService - message is required and must be an object');
54
85
  }
86
+ if (!isPlainObject(publishOptions)) {
87
+ throw new Error(
88
+ '[CookbookRouter] routeToService - publishOptions must be an object - '
89
+ + 'Expected: a plain object of publish options (e.g. { bufferOnFailure: false }) or no third argument. '
90
+ + 'Fix: pass the options QueueManager.publish() hands to the MQ client, or omit the argument'
91
+ );
92
+ }
55
93
 
56
94
  // Verify service is available
57
95
  const isAvailable = await this.serviceDiscovery.isServiceAvailable(serviceName);
@@ -63,7 +101,7 @@ class CookbookRouter {
63
101
  const queueName = `${serviceName}.workflow`;
64
102
  logger.info(`[CookbookRouter] Routing to service: ${queueName}`);
65
103
 
66
- await this.queueManager.publish(queueName, message);
104
+ await this.queueManager.publish(queueName, message, publishOptions);
67
105
  }
68
106
  }
69
107
 
@@ -1,53 +1,91 @@
1
1
  'use strict';
2
2
 
3
+ const { assertLogger, readCacheTTL } = require('./options');
4
+
3
5
  /**
4
6
  * ServiceDiscovery - Service discovery and health checking
5
7
  */
6
-
7
8
  class ServiceDiscovery {
8
9
  constructor(registryClient, options = {}) {
9
10
  this.registryClient = registryClient;
11
+ // `...options` comes FIRST so the two checked keys below survive it — the
12
+ // same order `QueueManager` uses, and for the same reason: a spread placed
13
+ // last undoes the block it was written to extend.
10
14
  this.options = {
11
- cacheEnabled: options.cacheEnabled !== false,
12
- cacheTTL: options.cacheTTL || 300000, // 5 minutes default
13
- logger: options.logger || console,
14
- ...options
15
+ ...options,
16
+ cacheTTL: readCacheTTL('ServiceDiscovery', options.cacheTTL),
17
+ logger: assertLogger(
18
+ 'ServiceDiscovery',
19
+ options.logger,
20
+ 'a failed registry lookup is reported somewhere that collects it'
21
+ )
15
22
  };
16
23
 
17
24
  this.cache = new Map();
18
25
  }
19
26
 
20
27
  /**
21
- * Check if a service is available
28
+ * Check if a service is available.
29
+ *
30
+ * `false` is the registry's answer: the service is there and not active, or the
31
+ * projection does not hold it (`getService()` resolved to `null`). A lookup the
32
+ * registry could not answer is not that answer, so it is thrown with its cause
33
+ * rather than read as "not available" (d.1086; architecture-principles.md §3–§5).
34
+ * A failed lookup is never cached.
35
+ *
22
36
  * @param {string} serviceName - Service name
23
37
  * @returns {Promise<boolean>}
38
+ * @throws {Error} `code: 'SERVICE_DISCOVERY_FAILED'`, `serviceName`, and the
39
+ * registry's rejection as `cause`, when `registryClient.getService()` rejects;
40
+ * `type` copied from the cause when the cause declares one (d.1093).
24
41
  */
25
42
  async isServiceAvailable(serviceName) {
26
- try {
27
- // Check cache first if enabled
28
- if (this.options.cacheEnabled) {
29
- const cached = this.getCached(serviceName);
30
- if (cached !== null) {
31
- return cached.status === 'active';
32
- }
33
- }
34
-
35
- const service = await this.registryClient.getService(serviceName);
36
-
37
- if (service && this.options.cacheEnabled) {
38
- this.setCached(serviceName, service);
39
- }
43
+ // Whether there is anything to serve is `getCached`'s answer, and it reads
44
+ // the ONE value that decides it — the lifetime. Until d.615b a second key,
45
+ // `cacheEnabled`, guarded these two branches as well, so "do not cache"
46
+ // had two spellings that could contradict each other
47
+ // (`.claude/rules/change-discipline.md` § One rail per concern).
48
+ const cached = this.getCached(serviceName);
49
+ if (cached !== null) {
50
+ return cached.status === 'active';
51
+ }
40
52
 
41
- return service && service.status === 'active';
53
+ let service;
54
+ try {
55
+ service = await this.registryClient.getService(serviceName);
42
56
  } catch (error) {
43
- // Handle specific error codes
44
- if (error.code === 'ECONNREFUSED') {
45
- this.options.logger.error('Registry connection failed', { serviceName, error });
46
- } else {
47
- this.options.logger.error(`Service discovery failed for ${serviceName}:`, error);
57
+ this.options.logger.error(`Service discovery failed for ${serviceName}:`, error);
58
+ // The cause is quoted as a sentence. The registry's own refusals are whole
59
+ // `[Context] Problem - Expected/Fix` sentences that already end with a
60
+ // full stop (conn-orch-registry 8.0.0), so the stop is added only to a
61
+ // cause that does not close its own sentence (d.1202).
62
+ const cause = /[.!?]$/.test(error.message) ? error.message : `${error.message}.`;
63
+ const failure = new Error(
64
+ `[ServiceDiscovery] Registry lookup for service '${serviceName}' failed - ${cause} `
65
+ + 'Expected: the registry answers with the service summary or null. '
66
+ + 'Fix: make the registry projection reachable to this service (the cause names what failed); '
67
+ + 'an unanswered lookup is never read as an unavailable service.',
68
+ { cause: error }
69
+ );
70
+ failure.code = 'SERVICE_DISCOVERY_FAILED';
71
+ failure.serviceName = serviceName;
72
+ // The verdict is made where the failure is born, never here (d.1093): the
73
+ // registry client types a lookup the projection could not answer, and the
74
+ // L1 classifier reads `type` without knowing what a registry or Redis is.
75
+ // Carried only when the cause has one — no type is invented for a cause
76
+ // that declared none.
77
+ if (error.type !== undefined) {
78
+ failure.type = error.type;
48
79
  }
80
+ throw failure;
81
+ }
82
+
83
+ if (service === null) {
49
84
  return false;
50
85
  }
86
+
87
+ this.setCached(serviceName, service);
88
+ return service.status === 'active';
51
89
  }
52
90
 
53
91
  /**
@@ -55,18 +93,19 @@ class ServiceDiscovery {
55
93
  * @private
56
94
  */
57
95
  getCached(serviceName) {
58
- if (!this.options.cacheEnabled) {
59
- return null;
60
- }
61
-
62
96
  const cached = this.cache.get(serviceName);
63
97
 
64
98
  if (!cached) {
65
99
  return null;
66
100
  }
67
101
 
102
+ // `>=`, not `>`: an entry whose age has REACHED the lifetime is spent, and
103
+ // that is what makes `cacheTTL: 0` mean what it says — every age is `>= 0`,
104
+ // so no entry is ever served and each lookup reaches the registry. With
105
+ // `>` the two lookups inside one millisecond would have hit the cache, and
106
+ // "no cache" would have been true only most of the time.
68
107
  const age = Date.now() - cached.timestamp;
69
- if (age > this.options.cacheTTL) {
108
+ if (age >= this.options.cacheTTL) {
70
109
  this.cache.delete(serviceName);
71
110
  return null;
72
111
  }
@@ -79,6 +118,14 @@ class ServiceDiscovery {
79
118
  * @private
80
119
  */
81
120
  setCached(serviceName, data) {
121
+ // `cacheTTL: 0` means no cache, so there is nothing to remember: `getCached`
122
+ // compares `age >= cacheTTL`, so an entry written with a zero lifetime is
123
+ // spent the moment it exists and the Map would grow with entries no lookup
124
+ // can ever be served from. Same single value decides here as there.
125
+ if (this.options.cacheTTL === 0) {
126
+ return;
127
+ }
128
+
82
129
  this.cache.set(serviceName, {
83
130
  data,
84
131
  timestamp: Date.now()