@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 +274 -0
- package/CHANGELOG.md +242 -0
- package/README.md +50 -6
- package/package.json +8 -3
- package/src/options.js +79 -0
- package/src/queueManager.js +12 -2
- package/src/router.js +14 -4
- package/src/serviceDiscovery.js +35 -17
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'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('split.queue', { durable: true, persistent: true })
|
|
43
|
+
publish('control.publish', …, { 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'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>"300000"</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 "no cache — every lookup reaches the registry".</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.<boolean></code>
|
|
157
|
+
* [.ensureQueue(queueName, options)](#QueueManager+ensureQueue) ⇒ <code>Promise.<boolean></code>
|
|
158
|
+
|
|
159
|
+
<a name="QueueManager+publish"></a>
|
|
160
|
+
|
|
161
|
+
### queueManager.publish(queueName, message, options) ⇒ <code>Promise.<boolean></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.<boolean></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.<boolean></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.<boolean></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
|
|
68
|
-
verbatim to both collaborators, so their keys travel
|
|
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` |
|
|
73
|
-
| `
|
|
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
|
+
"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
|
+
};
|
package/src/queueManager.js
CHANGED
|
@@ -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:
|
|
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 (`
|
|
30
|
-
// `
|
|
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
|
-
|
|
33
|
-
|
|
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);
|
package/src/serviceDiscovery.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
cacheTTL: options.cacheTTL
|
|
13
|
-
logger:
|
|
14
|
-
|
|
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
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
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()
|