@onlineapps/cookbook-router 3.0.0 → 4.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,274 @@
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_ 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
15
+ queue/registry administration this package used to carry had zero callers
16
+ outside it and were removed on 2026-09-02.module_">@onlineapps/cookbook-router
17
+
18
+ Message routing for cookbook workflows: given a service name and a workflow
19
+ message, publish it to that services workflow queue.
20
+
21
+ The package does NOT execute cookbooks, drive flow control, retry, complete
22
+ or dead-letter them. Those belong to `WorkflowOrchestrator` — confirmation
23
+ `api/docs/governance/confirmations/cookbook-execution-owner.md` 001 — which
24
+ is also the only caller: it takes `createRouter`
25
+ (WorkflowOrchestrator.js:71) and calls `routeToService` on the result
26
+ (:78,233,241,1223,1230). The second routing rail, the retry rail and the
27
+ queue/registry administration this package used to carry had zero callers
28
+ outside it and were removed on 2026-09-02.</a></dt>
29
+ <dd></dd>
30
+ </dl>
31
+
32
+ ## Classes
33
+
34
+ <dl>
35
+ <dt><a href="#QueueManager">QueueManager</a></dt>
36
+ <dd><p>QueueManager - Queue operations and management</p>
37
+ <p>A QUEUE and a MESSAGE are two different things, and neither one&#39;s defaults
38
+ belong to the other. Until d.396d this class held ONE object for both —
39
+ <code>defaultOptions: { durable: true, persistent: true }</code> — and spread it into the
40
+ publish AND into the queue declaration, so each call site was handed a key it
41
+ has no use for. Measured in both directions on the unit tier:</p>
42
+ <pre><code>assertQueue(&#39;split.queue&#39;, { durable: true, persistent: true })
43
+ publish(&#39;control.publish&#39;, …, { durable: true, persistent: true })
44
+ </code></pre>
45
+ <p><code>persistent</code> is a message property (amqplib <code>Options.Publish</code>) and means
46
+ nothing to a queue; <code>durable</code> is a queue property and means nothing to a
47
+ message. Nothing objected, because <code>@onlineapps/mq-client-core</code> read the two
48
+ keys it knew and dropped the rest in silence — and since d.396c it no longer
49
+ does: <code>assertQueue()</code> refuses an option it does not read, by name. What was an
50
+ invisible confusion becomes a throw the moment the pin moves, and the cure is
51
+ not to catch it but to stop sending a message property to a queue.</p>
52
+ <p>So there are two defaults with two owners, <code>queueOptions</code> and <code>publishOptions</code>,
53
+ and each call site merges only its own.</p>
54
+ </dd>
55
+ <dt><a href="#ServiceDiscovery">ServiceDiscovery</a></dt>
56
+ <dd><p>ServiceDiscovery - Service discovery and health checking</p>
57
+ </dd>
58
+ </dl>
59
+
60
+ ## Constants
61
+
62
+ <dl>
63
+ <dt><a href="#DEFAULT_QUEUE_OPTIONS">DEFAULT_QUEUE_OPTIONS</a></dt>
64
+ <dd><p>What describes the QUEUE. Handed to <code>mqClient.assertQueue()</code>, whose declared
65
+ option set is <code>durable</code>, <code>arguments</code>, <code>exclusive</code>, <code>autoDelete</code>.</p>
66
+ </dd>
67
+ <dt><a href="#DEFAULT_PUBLISH_OPTIONS">DEFAULT_PUBLISH_OPTIONS</a></dt>
68
+ <dd><p>What describes the MESSAGE. Handed to <code>mqClient.publish()</code>.</p>
69
+ </dd>
70
+ <dt><a href="#ServiceDiscovery">ServiceDiscovery</a></dt>
71
+ <dd><p>CookbookRouter - routes a workflow message to a service queue.</p>
72
+ <p>ONE responsibility, ONE method: <code>routeToService</code>. Execution, flow control,
73
+ retry, DLQ and completion belong to <code>WorkflowOrchestrator</code>
74
+ (confirmation <code>api/docs/governance/confirmations/cookbook-execution-owner.md</code>
75
+ 001), which is also this class&#39;s only caller —
76
+ <code>WorkflowOrchestrator.js:78,233,241,1223,1230</code>.</p>
77
+ </dd>
78
+ </dl>
79
+
80
+ ## Functions
81
+
82
+ <dl>
83
+ <dt><a href="#describeValue">describeValue(value)</a> ⇒ <code>string</code></dt>
84
+ <dd><p>Renders a refused value for an error message without ever printing it as a
85
+ bare word: <code>&quot;300000&quot;</code> and <code>300000</code> look identical otherwise, and the whole
86
+ point of the refusal is that they are not the same thing.</p>
87
+ </dd>
88
+ <dt><a href="#readCacheTTL">readCacheTTL(context, value)</a> ⇒ <code>number</code></dt>
89
+ <dd><p>Reads the discovery cache lifetime: a required, non-negative integer of
90
+ milliseconds, where <code>0</code> means &quot;no cache — every lookup reaches the registry&quot;.</p>
91
+ <p><code>options.cacheTTL || 300000</code> is what this replaces, and <code>0</code> was the value it
92
+ destroyed: a caller switching the cache off was given five minutes of cached
93
+ <code>status</code> instead, with nothing said. A default that inverts the one override
94
+ it is asked for is not a default (<code>architecture-principles.md</code> §3, §8), and
95
+ the absent case is not a value at all — it is a missing decision, so it
96
+ throws (§4).</p>
97
+ </dd>
98
+ </dl>
99
+
100
+ <a name="@onlineapps/cookbook-router
101
+
102
+ Message routing for cookbook workflows_ given a service name and a workflow
103
+ message, publish it to that services workflow queue.
104
+
105
+ The package does NOT execute cookbooks, drive flow control, retry, complete
106
+ or dead-letter them. Those belong to `WorkflowOrchestrator` — confirmation
107
+ `api/docs/governance/confirmations/cookbook-execution-owner.md` 001 — which
108
+ is also the only caller_ it takes `createRouter`
109
+ (WorkflowOrchestrator.js_71) and calls `routeToService` on the result
110
+ (_78,233,241,1223,1230). The second routing rail, the retry rail and the
111
+ queue/registry administration this package used to carry had zero callers
112
+ outside it and were removed on 2026-09-02.module_"></a>
113
+
114
+ ## @onlineapps/cookbook-router
115
+
116
+ Message routing for cookbook workflows: given a service name and a workflow
117
+ message, publish it to that services workflow queue.
118
+
119
+ The package does NOT execute cookbooks, drive flow control, retry, complete
120
+ or dead-letter them. Those belong to `WorkflowOrchestrator` — confirmation
121
+ `api/docs/governance/confirmations/cookbook-execution-owner.md` 001 — which
122
+ is also the only caller: it takes `createRouter`
123
+ (WorkflowOrchestrator.js:71) and calls `routeToService` on the result
124
+ (:78,233,241,1223,1230). The second routing rail, the retry rail and the
125
+ queue/registry administration this package used to carry had zero callers
126
+ outside it and were removed on 2026-09-02.
127
+ **See**: /api/shared/cookbook/cookbook-router/README.md
128
+ <a name="QueueManager"></a>
129
+
130
+ ## QueueManager
131
+ QueueManager - Queue operations and management
132
+
133
+ A QUEUE and a MESSAGE are two different things, and neither one's defaults
134
+ belong to the other. Until d.396d this class held ONE object for both —
135
+ `defaultOptions: { durable: true, persistent: true }` — and spread it into the
136
+ publish AND into the queue declaration, so each call site was handed a key it
137
+ has no use for. Measured in both directions on the unit tier:
138
+
139
+ assertQueue('split.queue', { durable: true, persistent: true })
140
+ publish('control.publish', …, { durable: true, persistent: true })
141
+
142
+ `persistent` is a message property (amqplib `Options.Publish`) and means
143
+ nothing to a queue; `durable` is a queue property and means nothing to a
144
+ message. Nothing objected, because `@onlineapps/mq-client-core` read the two
145
+ keys it knew and dropped the rest in silence — and since d.396c it no longer
146
+ does: `assertQueue()` refuses an option it does not read, by name. What was an
147
+ invisible confusion becomes a throw the moment the pin moves, and the cure is
148
+ not to catch it but to stop sending a message property to a queue.
149
+
150
+ So there are two defaults with two owners, `queueOptions` and `publishOptions`,
151
+ and each call site merges only its own.
152
+
153
+ **Kind**: global class
154
+
155
+ * [QueueManager](#QueueManager)
156
+ * [.publish(queueName, message, options)](#QueueManager+publish) ⇒ <code>Promise.&lt;boolean&gt;</code>
157
+ * [.ensureQueue(queueName, options)](#QueueManager+ensureQueue) ⇒ <code>Promise.&lt;boolean&gt;</code>
158
+
159
+ <a name="QueueManager+publish"></a>
160
+
161
+ ### queueManager.publish(queueName, message, options) ⇒ <code>Promise.&lt;boolean&gt;</code>
162
+ Publish message to queue
163
+
164
+ **Kind**: instance method of [<code>QueueManager</code>](#QueueManager)
165
+
166
+ | Param | Type | Description |
167
+ | --- | --- | --- |
168
+ | queueName | <code>string</code> | Target queue name |
169
+ | message | <code>Object</code> | Message to publish |
170
+ | options | <code>Object</code> | Publishing options (amqplib `Options.Publish`) |
171
+
172
+ <a name="QueueManager+ensureQueue"></a>
173
+
174
+ ### queueManager.ensureQueue(queueName, options) ⇒ <code>Promise.&lt;boolean&gt;</code>
175
+ Ensure queue exists
176
+
177
+ **Kind**: instance method of [<code>QueueManager</code>](#QueueManager)
178
+
179
+ | Param | Type | Description |
180
+ | --- | --- | --- |
181
+ | queueName | <code>string</code> | Queue name |
182
+ | 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. |
183
+
184
+ <a name="ServiceDiscovery"></a>
185
+
186
+ ## ServiceDiscovery
187
+ ServiceDiscovery - Service discovery and health checking
188
+
189
+ **Kind**: global class
190
+ <a name="ServiceDiscovery+isServiceAvailable"></a>
191
+
192
+ ### serviceDiscovery.isServiceAvailable(serviceName) ⇒ <code>Promise.&lt;boolean&gt;</code>
193
+ Check if a service is available
194
+
195
+ **Kind**: instance method of [<code>ServiceDiscovery</code>](#ServiceDiscovery)
196
+
197
+ | Param | Type | Description |
198
+ | --- | --- | --- |
199
+ | serviceName | <code>string</code> | Service name |
200
+
201
+ <a name="DEFAULT_QUEUE_OPTIONS"></a>
202
+
203
+ ## DEFAULT\_QUEUE\_OPTIONS
204
+ What describes the QUEUE. Handed to `mqClient.assertQueue()`, whose declared
205
+ option set is `durable`, `arguments`, `exclusive`, `autoDelete`.
206
+
207
+ **Kind**: global constant
208
+ <a name="DEFAULT_PUBLISH_OPTIONS"></a>
209
+
210
+ ## DEFAULT\_PUBLISH\_OPTIONS
211
+ What describes the MESSAGE. Handed to `mqClient.publish()`.
212
+
213
+ **Kind**: global constant
214
+ <a name="ServiceDiscovery"></a>
215
+
216
+ ## ServiceDiscovery
217
+ CookbookRouter - routes a workflow message to a service queue.
218
+
219
+ ONE responsibility, ONE method: `routeToService`. Execution, flow control,
220
+ retry, DLQ and completion belong to `WorkflowOrchestrator`
221
+ (confirmation `api/docs/governance/confirmations/cookbook-execution-owner.md`
222
+ 001), which is also this class's only caller —
223
+ `WorkflowOrchestrator.js:78,233,241,1223,1230`.
224
+
225
+ **Kind**: global constant
226
+ **See**: /api/shared/cookbook/cookbook-router/README.md
227
+ <a name="ServiceDiscovery+isServiceAvailable"></a>
228
+
229
+ ### serviceDiscovery.isServiceAvailable(serviceName) ⇒ <code>Promise.&lt;boolean&gt;</code>
230
+ Check if a service is available
231
+
232
+ **Kind**: instance method of [<code>ServiceDiscovery</code>](#ServiceDiscovery)
233
+
234
+ | Param | Type | Description |
235
+ | --- | --- | --- |
236
+ | serviceName | <code>string</code> | Service name |
237
+
238
+ <a name="describeValue"></a>
239
+
240
+ ## describeValue(value) ⇒ <code>string</code>
241
+ Renders a refused value for an error message without ever printing it as a
242
+ bare word: `"300000"` and `300000` look identical otherwise, and the whole
243
+ point of the refusal is that they are not the same thing.
244
+
245
+ **Kind**: global function
246
+
247
+ | Param | Type |
248
+ | --- | --- |
249
+ | value | <code>\*</code> |
250
+
251
+ <a name="readCacheTTL"></a>
252
+
253
+ ## readCacheTTL(context, value) ⇒ <code>number</code>
254
+ Reads the discovery cache lifetime: a required, non-negative integer of
255
+ milliseconds, where `0` means "no cache — every lookup reaches the registry".
256
+
257
+ `options.cacheTTL || 300000` is what this replaces, and `0` was the value it
258
+ destroyed: a caller switching the cache off was given five minutes of cached
259
+ `status` instead, with nothing said. A default that inverts the one override
260
+ it is asked for is not a default (`architecture-principles.md` §3, §8), and
261
+ the absent case is not a value at all — it is a missing decision, so it
262
+ throws (§4).
263
+
264
+ **Kind**: global function
265
+ **Throws**:
266
+
267
+ - <code>Error</code> when the value is absent, or is not an integer >= 0
268
+
269
+
270
+ | Param | Type | Description |
271
+ | --- | --- | --- |
272
+ | context | <code>string</code> | Name of the caller for the message |
273
+ | value | <code>\*</code> | The value the caller passed as `options.cacheTTL` |
274
+
package/CHANGELOG.md ADDED
@@ -0,0 +1,242 @@
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
+ ## [4.0.0] — 2026-09-27
8
+
9
+ ### Changed — BREAKING: `logger` and `cacheTTL` are required, and `0` is a value (d.615)
10
+
11
+ Three constructors read their options with `||`:
12
+
13
+ logger: options.logger || console (router.js, serviceDiscovery.js, queueManager.js)
14
+ cacheTTL: options.cacheTTL || 300000 (serviceDiscovery.js)
15
+
16
+ Both are the shape `.claude/rules/architecture-principles.md` §3 bans by name,
17
+ and the second did measurable harm beyond the principle. `cacheTTL: 0` means
18
+ "ask the registry every time"; `||` is false for `0`, so that request became
19
+ five minutes of cached `status` instead — the exact opposite of what the caller
20
+ asked for, decided in silence. A caller who had switched the cache off was
21
+ served a service's liveness from memory for 300 seconds and had no way to see
22
+ it. `logger || console` is the quieter half: a component nobody handed a logger
23
+ wrote to a stream no collector reads, on a platform where every neighbouring
24
+ package refuses the same omission by name.
25
+
26
+ So both keys are now **required** and checked in the constructor (§4):
27
+
28
+ - `logger` must be an object with `info`, `warn`, `error` and `debug` as
29
+ functions. Absent → `[<class>] logger is required - Expected: a logger with
30
+ info/warn/error/debug, so <reason>. Fix: pass options.logger …`; incomplete →
31
+ the same message naming the missing methods. `console` satisfies the shape and
32
+ is still accepted — when a caller passes it deliberately.
33
+ - `cacheTTL` must be an integer `>= 0`, in milliseconds. Absent (or `null`, the
34
+ way `WorkflowOrchestrator.readNumericOption` reads an omitted number) →
35
+ `cacheTTL is required`; `NaN`, negative, fractional, `Infinity`, a numeric
36
+ string or a boolean → `cacheTTL is invalid - … got <value>`, with the value
37
+ rendered so `"300000"` and `300000` cannot be confused.
38
+ - `cacheTTL: 0` is accepted and means **no cache**: `getCached` now compares
39
+ `age >= cacheTTL` rather than `age > cacheTTL`, so an entry whose age has
40
+ reached its lifetime is spent and every lookup reaches the registry. With `>`
41
+ two lookups inside one millisecond would still have hit the cache, and "no
42
+ cache" would have held only most of the time.
43
+
44
+ The checks live in one new module, `src/options.js`, because all three
45
+ constructors read `logger` with the same meaning and a second copy inside one
46
+ package is two rails for one concern (`change-discipline.md` § One rail per
47
+ concern). The logger contract itself comes from `@onlineapps/logger-contract`,
48
+ which owns it platform-wide: `src/options.js` re-exports its `assertLogger`
49
+ rather than restating it, now that the publication wave has pinned the package
50
+ (`f60f24c3`, `@onlineapps/logger-contract 2.0.0`). Until that pin existed the
51
+ check was a hand-written copy here, word for word identical — so the swap cost
52
+ one import and no re-test: every message assertion in the suite passed
53
+ unchanged. `readCacheTTL` stays this package's own, because `cacheTTL` is its
54
+ own option and no library owns it.
55
+
56
+ **For the caller:** `@onlineapps/conn-orch-orchestrator` built the router with
57
+ `createRouter(this.mqClient, this.registryClient, { logger: this.logger })` in
58
+ `WorkflowOrchestrator` — a logger but no `cacheTTL`. It passes one since d.615b
59
+ (`config.serviceDiscoveryCacheTTL`, default 300000, `0` honoured), so this major
60
+ has its caller ready before it is published. Nothing else in the workspace
61
+ constructs these classes: `conn-orch-cookbook` only destructures and re-exports
62
+ them.
63
+
64
+ Tests: `tests/unit/required-options.test.js` (26), covering each refusal by its
65
+ message, `cacheTTL: 0` sending three lookups to the registry, the control that a
66
+ real TTL still serves the second from cache, the control that the whole chain
67
+ still routes end to end with the real collaborators, and — since the pin — that
68
+ `options.assertLogger` IS the library's function (identity, not an equal copy)
69
+ and that the module exports no second `LOGGER_METHODS`.
70
+
71
+ ### Removed — BREAKING: `cacheEnabled`, druhá kolej pro „necachuj" (d.615b)
72
+
73
+ `ServiceDiscovery` odpovídal na otázku „mám si pamatovat odpověď registru?"
74
+ dvěma klíči: `cacheEnabled` (`options.cacheEnabled !== false`) a — od d.615 —
75
+ `cacheTTL`, kde `0` znamená bez cache. Obě odpovědi šly napsat proti sobě:
76
+ `{ cacheEnabled: true, cacheTTL: 0 }` i `{ cacheEnabled: false, cacheTTL: 300000 }`
77
+ byly platné a v každé z nich jeden z těch dvou klíčů lhal. Jedna starost, dvě
78
+ koleje (`change-discipline.md` § One rail per concern) — a `cacheEnabled` navíc
79
+ implicitní default rozhodovaný z `!== false` (§8).
80
+
81
+ Zůstává `cacheTTL`: jedna hodnota říká obojí — jestli cachovat a jak dlouho.
82
+ Kdo nechce cachovat, napíše `0`; od téhle dávky se při něm do `Map` ani nic
83
+ nezapisuje. Klíč `cacheEnabled` se nečte vůbec (nepřekládá se, nevaruje,
84
+ neodmítá) — volající, který ho ještě pošle, dostane to, co říká životnost.
85
+
86
+ Čtyři otázky `change-discipline.md` § Removing something removes its declaration:
87
+
88
+ 1. **Proč vznikl.** Přišel s balíkem do `shared/` (`d05bb7be`, restrukturalizace)
89
+ jako přepínač „tenhle volající nechce discovery-cache". `git log -S cacheEnabled
90
+ -- shared/cookbook/cookbook-router` zná od té doby tři commity (`d05bb7be`,
91
+ `144db203`, `7fa728e8`) a ani jeden z nich ten klíč nikde nenastavuje.
92
+ 2. **Která část koncepce ho nesla.** Žádná: nemá ADR, kontrakt ani model, jen
93
+ řádek v README § Options. Koncept „necachuj" vlastní od d.615 `cacheTTL`
94
+ (`0` = bez cache, `getCached` porovnává `age >= cacheTTL`).
95
+ 3. **Proč ho dnes nikdo nečte.** Mimo balík ho nikdy nikdo nenastavil. Jediný
96
+ stavitel routeru je `WorkflowOrchestrator` a ten posílá `{ logger, cacheTTL }`
97
+ (d.615b). Změřeno napříč workspace (`api`, `api_biz/*`, `fe_adminui`, mimo
98
+ `node_modules`): jediné další výskyty `cacheEnabled` patří `conn-base-storage`
99
+ a `conn-orch-registry` — vlastní, nesouvisející volby jejich vlastních tříd.
100
+ 4. **Je náhrada koncepčnější.** Ano. Jedna hodnota nemůže sama se sebou být
101
+ v rozporu, zatímco dvě klidně ano; a `0` je hodnota, kterou volající vysloví,
102
+ místo default odvozeného z `!== false`.
103
+
104
+ Testy: `tests/unit/serviceDiscovery.test.js` — `cacheTTL: 0` je jediná cesta
105
+ k „necachuj" (dva lookupy = dvě volání registru, `cache.size === 0`),
106
+ `cacheEnabled: false` s reálnou životností cache NEvypne (druhý lookup jde z ní),
107
+ a konstruktor po sobě nenechá `options.cacheEnabled`.
108
+
109
+ ### Changed — tarball nese CHANGELOG, README a API.md (d.1021)
110
+
111
+ `files` v `package.json` jmenoval jen `src`, takže vydaný balíček nenesl `CHANGELOG.md`
112
+ ani generované `API.md` — kdo měl router v `node_modules`, neviděl, co se mezi verzemi
113
+ změnilo, ani referenci exportů. `files` je teď `src`, `CHANGELOG.md`, `README.md`,
114
+ `API.md`. `README.md` npm přibaluje vždy; v seznamu stojí proto, aby obsah tarballu
115
+ říkal jeden seznam, ne seznam plus pravidlo npm. Ze `src` nic neubylo
116
+ (`npm pack --dry-run`: přibyly `API.md` a `CHANGELOG.md`).
117
+
118
+ ## [3.0.1] — 2026-09-16
119
+
120
+ ### Fixed — the unit tier claimed a queue declaration the client refuses (d.520)
121
+
122
+ `should apply custom queue options` asserted that `ensureQueue('custom.queue',
123
+ { maxLength: 1000, messageTtl: 60000 })` reaches the declaration with those two
124
+ keys. Neither half held: this package composes no queue argument — it merges its
125
+ defaults with the caller's options and hands the result to
126
+ `mqClient.assertQueue()` — and since d.396c of `@onlineapps/mq-client-core` that
127
+ client refuses, by name, a key `assertQueue()` does not read:
128
+
129
+ [RabbitMQClient] Queue option not declared by assertQueue(): "maxLength",
130
+ "messageTtl" - Expected: only durable, arguments, exclusive, autoDelete.
131
+
132
+ The test passed only because its client stub accepted anything. The stub now
133
+ refuses exactly what the real client refuses, which makes it the guard of the
134
+ file: every declaration any test here produces runs through it, so a key the
135
+ client does not read cannot pass unnoticed. The claim itself is corrected to the
136
+ broker vocabulary — `arguments: { 'x-max-length': 1000, 'x-message-ttl': 60000 }`
137
+ — with the value asserted, not its presence.
138
+
139
+ Added with it: the failure path (a friendlier spelling is refused and the refusal
140
+ propagates — the package neither translates nor catches, and the queue is not
141
+ remembered as ensured), the control case (a queue asked for without limits
142
+ carries no `arguments` key at all), and a test that every declaration this
143
+ package composes on its own — constructor defaults, the `publish()` path, a
144
+ per-call merge — carries only keys the client reads.
145
+
146
+ No behaviour change: `src/queueManager.js` already forwarded the declared option
147
+ set, and its `ensureQueue()` doc now says where a limit belongs. `README.md`
148
+ § Options documents the shape.
149
+
150
+ ## [3.0.0] — 2026-09-14
151
+
152
+ ### BREAKING — `defaultOptions` is two keys, `queueOptions` and `publishOptions` (d.396d)
153
+
154
+ A queue declaration and a message are two different things, and neither one's
155
+ defaults belong to the other. One object fed both — `{ durable: true,
156
+ persistent: true }`, spread into the publish AND into the queue declaration — so
157
+ each call site was handed a key it has no use for. Measured in both directions
158
+ on the unit tier:
159
+
160
+ assertQueue('split.queue', { durable: true, persistent: true })
161
+ publish('control.publish', …, { durable: true, persistent: true })
162
+
163
+ `persistent` is a message property (amqplib `Options.Publish`) and means nothing
164
+ to a queue; `durable` is a queue property and means nothing to a message.
165
+ Nothing objected, because `@onlineapps/mq-client-core` read the two keys it knew
166
+ and dropped the rest in silence — and since its d.396c it no longer does:
167
+ `assertQueue()` refuses an option it does not read, by name. What was an
168
+ invisible confusion becomes a throw the moment the pin moves, and the cure is
169
+ not to catch it but to stop sending a message property to a queue.
170
+
171
+ - `queueOptions` (default `{ durable: true }`) is merged into every queue
172
+ declaration and reaches `mqClient.assertQueue()`, whose declared option set is
173
+ `durable`, `arguments`, `exclusive`, `autoDelete`.
174
+ - `publishOptions` (default `{ persistent: true }`) is merged into every publish
175
+ and reaches `mqClient.publish()` as amqplib message properties.
176
+ - Per-call options are unchanged: `ensureQueue(queue, options)` and
177
+ `publish(queue, message, options)` still override their own defaults.
178
+
179
+ **Fixed in the same expression:** the constructor spread `...options` AFTER the
180
+ computed block, which undid the merge it was written to extend — a caller
181
+ passing `{ durable: false }` replaced the whole defaults object instead of
182
+ overriding one key of it, and lost the other default with it. `...options` now
183
+ comes first, so every key this class does not compute is still forwarded
184
+ untouched (`router.js` hands its whole options object to both collaborators) and
185
+ the computed keys survive.
186
+
187
+ **Callers:** none. `defaultOptions` was measured across `api`, `api_biz` and
188
+ `fe_adminui` (no `node_modules`) on 2026-09-14 — nothing outside this package
189
+ constructs `QueueManager` or writes the key; `config/libraries.json` pins the
190
+ package, and `api/jest.config.unit.js` lists it, neither of which passes options.
191
+
192
+ RED (unit): 8 of 24, with the leak named in both directions —
193
+ `+ "persistent": true` inside the `assertQueue` call and `+ "durable": true`
194
+ inside the publish options. GREEN: 24/24 in the file, 63/63 for the package.
195
+
196
+ ### BREAKING — the package routes, and does nothing else (DÁVKA 83)
197
+
198
+ Execution, flow control, retry, completion and dead-lettering have exactly one
199
+ owner, `WorkflowOrchestrator` — confirmation
200
+ [`cookbook-execution-owner.md`](/api/docs/governance/confirmations/cookbook-execution-owner.md)
201
+ 001. This package carried a second copy of most of it. Measured on 2026-09-02
202
+ across `api/shared`, `api/infra`, `api_biz` and `fe_adminui` (no
203
+ `node_modules`), every removed member had **zero callers outside this package**;
204
+ the only live chain is `createRouter` → `routeToService` →
205
+ `isServiceAvailable` → `publish` → `ensureQueue`, which
206
+ `WorkflowOrchestrator.js:71,78,233,241,1223,1230` uses and nothing else does.
207
+
208
+ Removed:
209
+
210
+ - `CookbookRouter`: `routeWorkflow`, `routeToNextService`, `routeToCompleted`,
211
+ `routeToDLQ`, `determineTargetService`, `buildWorkflowMessage`, `handleRetry`.
212
+ `routeToService` and the constructor stay.
213
+ - `RetryHandler` — the whole class and its module, reachable only from
214
+ `handleRetry`. It is no longer exported here, nor re-exported by
215
+ `@onlineapps/conn-orch-cookbook`. Retry with backoff lives in
216
+ `WorkflowOrchestrator.js:495`, and the exhausted-retry publish to
217
+ `workflow.failed` at `:574`.
218
+ - `QueueManager`: `consume`, `getQueueInfo`, `purgeQueue`, `deleteQueue`,
219
+ `resetConnection`. Consumption belongs to `@onlineapps/mq-client-core` through
220
+ `ServiceWrapper`. Removing `consume` also closes the open ack/nack question
221
+ recorded against `queueManager.js` in `api/shared/TODO.md`: the code it asked
222
+ about is gone.
223
+ - `ServiceDiscovery`: `getServiceInfo`, `listAvailableServices`,
224
+ `getServiceQueue`, `invalidateCache`. `isServiceAvailable` and its TTL cache
225
+ stay.
226
+ - `CookbookRouter` option defaults `defaultQueue`, `completedQueue`, `dlqSuffix`,
227
+ `maxRetries`, `retryDelay`. The first three fed the removed routing rail; the
228
+ last two had no reader even before it — `RetryHandler` keyed on `maxAttempts`
229
+ and `baseDelay`, never on these. `logger` is the only key the class reads, and
230
+ the caller's `options` still reach `ServiceDiscovery` and `QueueManager`
231
+ whole. A declaration nothing reads is dead
232
+ (`.claude/rules/change-discipline.md` § Removing).
233
+
234
+ Also in this change: the package gained a `jest.config.js` and a line in
235
+ `api/jest.config.unit.js`, so its suites run in the api unit tier. They ran
236
+ under no root npm script before — a whole sada outside the regression loop
237
+ (`.claude/rules/service-refactoring.md` § forbidden action #4).
238
+
239
+ The DÁVKA 78 fix that made `routeToNextService` and `handleRetry` find steps by
240
+ `step_id` instead of `id` is superseded: both methods are gone, and so is the
241
+ `id`-vs-`step_id` question in this package. `routeToService` takes a service
242
+ name and an opaque message and reads no step identifier at all.
package/README.md CHANGED
@@ -47,7 +47,7 @@ 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);
@@ -64,18 +64,45 @@ await router.routeToService('biz-invoicing', workflowMessage);
64
64
 
65
65
  ## Options
66
66
 
67
- The single caller passes `{ logger }` and nothing else. `options` is forwarded
68
- verbatim to both collaborators, so their keys travel through it:
67
+ The caller passes `{ logger, cacheTTL }`; everything else has a default.
68
+ `options` is forwarded verbatim to both collaborators, so their keys travel
69
+ through it:
69
70
 
70
71
  | Key | Read by | Default |
71
72
  |---|---|---|
72
- | `logger` | `CookbookRouter`, `ServiceDiscovery`, `QueueManager` | `console` |
73
- | `cacheEnabled` | `ServiceDiscovery` | `true` |
74
- | `cacheTTL` | `ServiceDiscovery` (ms) | `300000` |
73
+ | `logger` | `CookbookRouter`, `ServiceDiscovery`, `QueueManager` | **required** |
74
+ | `cacheTTL` | `ServiceDiscovery` (ms) | **required** |
75
75
  | `ensureQueues` | `QueueManager` — assert the queue before the first publish | `true` |
76
76
  | `queueOptions` | `QueueManager` — merged into every queue declaration | `{ durable: true }` |
77
77
  | `publishOptions` | `QueueManager` — merged into every publish | `{ persistent: true }` |
78
78
 
79
+ What a `logger` IS comes from `@onlineapps/logger-contract` (pinned in
80
+ `f60f24c3`): `src/options.js` re-exports its `assertLogger`, so the four methods
81
+ and the wording below are the platform's, not this package's.
82
+
83
+ The two required keys are checked in the constructor, and each refusal names
84
+ the key:
85
+
86
+ | Condition | Result |
87
+ |---|---|
88
+ | `logger` absent | throws `[<class>] logger is required - Expected: a logger with info/warn/error/debug, …` |
89
+ | `logger` missing a method | throws `[<class>] logger is incomplete - … missing: debug. Fix: pass a logger implementing all four.` |
90
+ | `cacheTTL` absent or `null` | throws `[ServiceDiscovery] cacheTTL is required - Expected: an integer >= 0, milliseconds …` |
91
+ | `cacheTTL` not an integer `>= 0` | throws `[ServiceDiscovery] cacheTTL is invalid - … got <value>.` |
92
+
93
+ `cacheTTL: 0` is a value, not an absence: it means **no cache** — every
94
+ `isServiceAvailable` lookup reaches the registry, and nothing is written to the
95
+ Map on the way. Until d.615 it was read as `options.cacheTTL || 300000`, so that
96
+ request silently became five minutes of cached `status`, and `logger` was read as
97
+ `options.logger || console`, so a caller who passed none had its output written
98
+ where nothing collects it.
99
+
100
+ It is also the **only** way to say it. `cacheEnabled` was a second key for the
101
+ same question until d.615b: with both, `{ cacheEnabled: true, cacheTTL: 0 }` and
102
+ `{ cacheEnabled: false, cacheTTL: 300000 }` were sayable, and in each of them one
103
+ of the two keys was a lie. The key is no longer read at all — a caller who still
104
+ passes it gets what the lifetime says.
105
+
79
106
  A queue declaration and a message are two different things, so their defaults
80
107
  are two objects with two owners. `queueOptions` reaches
81
108
  `mqClient.assertQueue()`, whose declared option set is `durable`, `arguments`,
@@ -84,6 +111,23 @@ amqplib message properties. One object fed both until d.396d, which meant the
84
111
  queue was declared with `persistent` and the message published with `durable` —
85
112
  neither of which the receiving side reads.
86
113
 
114
+ A queue limit — how long a message lives, how many the queue holds — is one
115
+ entry of `arguments`, written in the broker's own vocabulary: `x-message-ttl`,
116
+ `x-max-length`. This package translates nothing: `queueOptions` and the per-call
117
+ `options` of `ensureQueue()` are merged and handed to `assertQueue()` as they
118
+ are, so a friendlier spelling such as `maxLength` arrives at the client as a key
119
+ it does not read, and the client refuses it by name rather than dropping it:
120
+
121
+ ensureQueue('quotes.workflow', {
122
+ arguments: { 'x-max-length': 1000, 'x-message-ttl': 60000 }
123
+ })
124
+ → assertQueue('quotes.workflow', {
125
+ durable: true,
126
+ arguments: { 'x-max-length': 1000, 'x-message-ttl': 60000 }
127
+ })
128
+
129
+ A queue asked for without limits carries no `arguments` key at all.
130
+
87
131
  A key not in this table is not read by anything here.
88
132
 
89
133
  ## Queue naming
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/cookbook-router",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "Message routing for cookbook workflows - handles service discovery and queue routing",
5
5
  "oa": {
6
6
  "category": "orchestration"
@@ -22,7 +22,9 @@
22
22
  ],
23
23
  "author": "OnlineApps",
24
24
  "license": "PROPRIETARY",
25
- "dependencies": {},
25
+ "dependencies": {
26
+ "@onlineapps/logger-contract": "2.0.0"
27
+ },
26
28
  "devDependencies": {
27
29
  "jest": "^29.7.0",
28
30
  "jsdoc-to-markdown": "^8.0.0"
@@ -31,7 +33,10 @@
31
33
  "node": ">=24.0.0 <25"
32
34
  },
33
35
  "files": [
34
- "src"
36
+ "src",
37
+ "CHANGELOG.md",
38
+ "README.md",
39
+ "API.md"
35
40
  ],
36
41
  "publishConfig": {
37
42
  "access": "public"
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();
@@ -111,7 +117,11 @@ class QueueManager {
111
117
  * Ensure queue exists
112
118
  * @param {string} queueName - Queue name
113
119
  * @param {Object} options - Queue options, as `mqClient.assertQueue()` declares
114
- * them (`durable`, `arguments`, `exclusive`, `autoDelete`)
120
+ * them (`durable`, `arguments`, `exclusive`, `autoDelete`). A limit is one
121
+ * entry of `arguments`, in the broker's own vocabulary — `x-max-length`,
122
+ * `x-message-ttl`. This class translates no friendlier spelling into one:
123
+ * `maxLength` is a key the client does not read, and since d.396c it says so
124
+ * by name instead of dropping it.
115
125
  * @returns {Promise<boolean>}
116
126
  */
117
127
  async ensureQueue(queueName, options = {}) {
package/src/router.js CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  const ServiceDiscovery = require('./serviceDiscovery');
16
16
  const QueueManager = require('./queueManager');
17
+ const { assertLogger } = require('./options');
17
18
 
18
19
  class CookbookRouter {
19
20
  constructor(mqClient, registryClient, options = {}) {
@@ -26,11 +27,20 @@ class CookbookRouter {
26
27
  // keyed on `maxAttempts`/`baseDelay`, never on these. A declaration nothing
27
28
  // reads is dead (`.claude/rules/change-discipline.md` § Removing).
28
29
  // `options` is still forwarded whole to the two collaborators below, so
29
- // their own keys (`cacheEnabled`, `cacheTTL`, `ensureQueues`,
30
- // `queueOptions`, `publishOptions`) reach them unchanged.
30
+ // their own keys (`cacheTTL`, `ensureQueues`, `queueOptions`,
31
+ // `publishOptions`) reach them unchanged.
32
+ // `logger` used to default to `console` here. It no longer defaults at all:
33
+ // a router that logs where nothing collects is a router whose routing
34
+ // decisions are unobservable, and `||`/an implicit default is exactly what
35
+ // §3 bans. The check runs BEFORE the collaborators are built so the caller
36
+ // is told which object refused, in this class's own name.
31
37
  this.options = {
32
- logger: console,
33
- ...options
38
+ ...options,
39
+ logger: assertLogger(
40
+ 'CookbookRouter',
41
+ options.logger,
42
+ 'every routing decision is reported somewhere that collects it'
43
+ )
34
44
  };
35
45
 
36
46
  this.serviceDiscovery = new ServiceDiscovery(registryClient, options);
@@ -1,17 +1,24 @@
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();
@@ -24,17 +31,19 @@ class ServiceDiscovery {
24
31
  */
25
32
  async isServiceAvailable(serviceName) {
26
33
  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
- }
34
+ // Whether there is anything to serve is `getCached`'s answer, and it reads
35
+ // the ONE value that decides it — the lifetime. Until d.615b a second key,
36
+ // `cacheEnabled`, guarded these two branches as well, so "do not cache"
37
+ // had two spellings that could contradict each other
38
+ // (`.claude/rules/change-discipline.md` § One rail per concern).
39
+ const cached = this.getCached(serviceName);
40
+ if (cached !== null) {
41
+ return cached.status === 'active';
33
42
  }
34
43
 
35
44
  const service = await this.registryClient.getService(serviceName);
36
45
 
37
- if (service && this.options.cacheEnabled) {
46
+ if (service) {
38
47
  this.setCached(serviceName, service);
39
48
  }
40
49
 
@@ -55,18 +64,19 @@ class ServiceDiscovery {
55
64
  * @private
56
65
  */
57
66
  getCached(serviceName) {
58
- if (!this.options.cacheEnabled) {
59
- return null;
60
- }
61
-
62
67
  const cached = this.cache.get(serviceName);
63
68
 
64
69
  if (!cached) {
65
70
  return null;
66
71
  }
67
72
 
73
+ // `>=`, not `>`: an entry whose age has REACHED the lifetime is spent, and
74
+ // that is what makes `cacheTTL: 0` mean what it says — every age is `>= 0`,
75
+ // so no entry is ever served and each lookup reaches the registry. With
76
+ // `>` the two lookups inside one millisecond would have hit the cache, and
77
+ // "no cache" would have been true only most of the time.
68
78
  const age = Date.now() - cached.timestamp;
69
- if (age > this.options.cacheTTL) {
79
+ if (age >= this.options.cacheTTL) {
70
80
  this.cache.delete(serviceName);
71
81
  return null;
72
82
  }
@@ -79,6 +89,14 @@ class ServiceDiscovery {
79
89
  * @private
80
90
  */
81
91
  setCached(serviceName, data) {
92
+ // `cacheTTL: 0` means no cache, so there is nothing to remember: `getCached`
93
+ // compares `age >= cacheTTL`, so an entry written with a zero lifetime is
94
+ // spent the moment it exists and the Map would grow with entries no lookup
95
+ // can ever be served from. Same single value decides here as there.
96
+ if (this.options.cacheTTL === 0) {
97
+ return;
98
+ }
99
+
82
100
  this.cache.set(serviceName, {
83
101
  data,
84
102
  timestamp: Date.now()