@onlineapps/cookbook-router 4.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +68 -45
- package/CHANGELOG.md +118 -0
- package/README.md +20 -6
- package/package.json +6 -3
- package/src/index.js +4 -3
- package/src/queueManager.js +9 -12
- package/src/router.js +37 -9
- package/src/serviceDiscovery.js +52 -23
package/API.md
CHANGED
|
@@ -9,9 +9,10 @@ message, publish it to that services workflow queue.
|
|
|
9
9
|
The package does NOT execute cookbooks, drive flow control, retry, complete
|
|
10
10
|
or dead-letter them. Those belong to `WorkflowOrchestrator` — confirmation
|
|
11
11
|
`api/docs/governance/confirmations/cookbook-execution-owner.md` 001 — which
|
|
12
|
-
is also the only caller_
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
is also the only caller_ its constructor takes `createRouter` (through
|
|
13
|
+
`cookbook.createRouter`), and `processWorkflowMessage` and the method that
|
|
14
|
+
hands the next steps message to the router call `routeToService` on the
|
|
15
|
+
result. The second routing rail, the retry rail and the
|
|
15
16
|
queue/registry administration this package used to carry had zero callers
|
|
16
17
|
outside it and were removed on 2026-09-02.module_">@onlineapps/cookbook-router
|
|
17
18
|
|
|
@@ -21,9 +22,10 @@ message, publish it to that services workflow queue.
|
|
|
21
22
|
The package does NOT execute cookbooks, drive flow control, retry, complete
|
|
22
23
|
or dead-letter them. Those belong to `WorkflowOrchestrator` — confirmation
|
|
23
24
|
`api/docs/governance/confirmations/cookbook-execution-owner.md` 001 — which
|
|
24
|
-
is also the only caller:
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
is also the only caller: its constructor takes `createRouter` (through
|
|
26
|
+
`cookbook.createRouter`), and `processWorkflowMessage` and the method that
|
|
27
|
+
hands the next steps message to the router call `routeToService` on the
|
|
28
|
+
result. The second routing rail, the retry rail and the
|
|
27
29
|
queue/registry administration this package used to carry had zero callers
|
|
28
30
|
outside it and were removed on 2026-09-02.</a></dt>
|
|
29
31
|
<dd></dd>
|
|
@@ -52,6 +54,14 @@ not to catch it but to stop sending a message property to a queue.</p>
|
|
|
52
54
|
<p>So there are two defaults with two owners, <code>queueOptions</code> and <code>publishOptions</code>,
|
|
53
55
|
and each call site merges only its own.</p>
|
|
54
56
|
</dd>
|
|
57
|
+
<dt><a href="#CookbookRouter">CookbookRouter</a></dt>
|
|
58
|
+
<dd><p>CookbookRouter - routes a workflow message to a service queue.</p>
|
|
59
|
+
<p>ONE responsibility, ONE method: <code>routeToService</code>. Execution, flow control,
|
|
60
|
+
retry, DLQ and completion belong to <code>WorkflowOrchestrator</code>
|
|
61
|
+
(confirmation <code>api/docs/governance/confirmations/cookbook-execution-owner.md</code>
|
|
62
|
+
001), which is also this class's only caller: <code>processWorkflowMessage()</code> and
|
|
63
|
+
the method that hands the next step's message to the router.</p>
|
|
64
|
+
</dd>
|
|
55
65
|
<dt><a href="#ServiceDiscovery">ServiceDiscovery</a></dt>
|
|
56
66
|
<dd><p>ServiceDiscovery - Service discovery and health checking</p>
|
|
57
67
|
</dd>
|
|
@@ -67,14 +77,6 @@ option set is <code>durable</code>, <code>arguments</code>, <code>exclusive</cod
|
|
|
67
77
|
<dt><a href="#DEFAULT_PUBLISH_OPTIONS">DEFAULT_PUBLISH_OPTIONS</a></dt>
|
|
68
78
|
<dd><p>What describes the MESSAGE. Handed to <code>mqClient.publish()</code>.</p>
|
|
69
79
|
</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
80
|
</dl>
|
|
79
81
|
|
|
80
82
|
## Functions
|
|
@@ -105,9 +107,10 @@ message, publish it to that services workflow queue.
|
|
|
105
107
|
The package does NOT execute cookbooks, drive flow control, retry, complete
|
|
106
108
|
or dead-letter them. Those belong to `WorkflowOrchestrator` — confirmation
|
|
107
109
|
`api/docs/governance/confirmations/cookbook-execution-owner.md` 001 — which
|
|
108
|
-
is also the only caller_
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
is also the only caller_ its constructor takes `createRouter` (through
|
|
111
|
+
`cookbook.createRouter`), and `processWorkflowMessage` and the method that
|
|
112
|
+
hands the next steps message to the router call `routeToService` on the
|
|
113
|
+
result. The second routing rail, the retry rail and the
|
|
111
114
|
queue/registry administration this package used to carry had zero callers
|
|
112
115
|
outside it and were removed on 2026-09-02.module_"></a>
|
|
113
116
|
|
|
@@ -119,9 +122,10 @@ message, publish it to that services workflow queue.
|
|
|
119
122
|
The package does NOT execute cookbooks, drive flow control, retry, complete
|
|
120
123
|
or dead-letter them. Those belong to `WorkflowOrchestrator` — confirmation
|
|
121
124
|
`api/docs/governance/confirmations/cookbook-execution-owner.md` 001 — which
|
|
122
|
-
is also the only caller:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
+
is also the only caller: its constructor takes `createRouter` (through
|
|
126
|
+
`cookbook.createRouter`), and `processWorkflowMessage` and the method that
|
|
127
|
+
hands the next steps message to the router call `routeToService` on the
|
|
128
|
+
result. The second routing rail, the retry rail and the
|
|
125
129
|
queue/registry administration this package used to carry had zero callers
|
|
126
130
|
outside it and were removed on 2026-09-02.
|
|
127
131
|
**See**: /api/shared/cookbook/cookbook-router/README.md
|
|
@@ -181,6 +185,37 @@ Ensure queue exists
|
|
|
181
185
|
| queueName | <code>string</code> | Queue name |
|
|
182
186
|
| options | <code>Object</code> | Queue options, as `mqClient.assertQueue()` declares them (`durable`, `arguments`, `exclusive`, `autoDelete`). A limit is one entry of `arguments`, in the broker's own vocabulary — `x-max-length`, `x-message-ttl`. This class translates no friendlier spelling into one: `maxLength` is a key the client does not read, and since d.396c it says so by name instead of dropping it. |
|
|
183
187
|
|
|
188
|
+
<a name="CookbookRouter"></a>
|
|
189
|
+
|
|
190
|
+
## CookbookRouter
|
|
191
|
+
CookbookRouter - routes a workflow message to a service queue.
|
|
192
|
+
|
|
193
|
+
ONE responsibility, ONE method: `routeToService`. Execution, flow control,
|
|
194
|
+
retry, DLQ and completion belong to `WorkflowOrchestrator`
|
|
195
|
+
(confirmation `api/docs/governance/confirmations/cookbook-execution-owner.md`
|
|
196
|
+
001), which is also this class's only caller: `processWorkflowMessage()` and
|
|
197
|
+
the method that hands the next step's message to the router.
|
|
198
|
+
|
|
199
|
+
**Kind**: global class
|
|
200
|
+
**See**: /api/shared/cookbook/cookbook-router/README.md
|
|
201
|
+
<a name="CookbookRouter+routeToService"></a>
|
|
202
|
+
|
|
203
|
+
### cookbookRouter.routeToService(serviceName, message, [publishOptions]) ⇒ <code>Promise.<void></code>
|
|
204
|
+
Route message directly to a specific service
|
|
205
|
+
|
|
206
|
+
**Kind**: instance method of [<code>CookbookRouter</code>](#CookbookRouter)
|
|
207
|
+
**Throws**:
|
|
208
|
+
|
|
209
|
+
- <code>Error</code> when `publishOptions` is given and is not a plain object —
|
|
210
|
+
before the registry is asked and before anything is published
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
| Param | Type | Default | Description |
|
|
214
|
+
| --- | --- | --- | --- |
|
|
215
|
+
| serviceName | <code>string</code> | | Target service name |
|
|
216
|
+
| message | <code>Object</code> | | Workflow message to send |
|
|
217
|
+
| [publishOptions] | <code>Object</code> | <code>{}</code> | Publish options for this one message, handed unchanged as the third argument of `QueueManager.publish()`, which merges them over its message defaults and passes them to the MQ client (e.g. `{ bufferOnFailure: false }`). The router neither reads nor completes them: a key the MQ client does not know is the client's to ignore. Must be a plain object when given. |
|
|
218
|
+
|
|
184
219
|
<a name="ServiceDiscovery"></a>
|
|
185
220
|
|
|
186
221
|
## ServiceDiscovery
|
|
@@ -190,9 +225,21 @@ ServiceDiscovery - Service discovery and health checking
|
|
|
190
225
|
<a name="ServiceDiscovery+isServiceAvailable"></a>
|
|
191
226
|
|
|
192
227
|
### serviceDiscovery.isServiceAvailable(serviceName) ⇒ <code>Promise.<boolean></code>
|
|
193
|
-
Check if a service is available
|
|
228
|
+
Check if a service is available.
|
|
229
|
+
|
|
230
|
+
`false` is the registry's answer: the service is there and not active, or the
|
|
231
|
+
projection does not hold it (`getService()` resolved to `null`). A lookup the
|
|
232
|
+
registry could not answer is not that answer, so it is thrown with its cause
|
|
233
|
+
rather than read as "not available" (d.1086; architecture-principles.md §3–§5).
|
|
234
|
+
A failed lookup is never cached.
|
|
194
235
|
|
|
195
236
|
**Kind**: instance method of [<code>ServiceDiscovery</code>](#ServiceDiscovery)
|
|
237
|
+
**Throws**:
|
|
238
|
+
|
|
239
|
+
- <code>Error</code> `code: 'SERVICE_DISCOVERY_FAILED'`, `serviceName`, and the
|
|
240
|
+
registry's rejection as `cause`, when `registryClient.getService()` rejects;
|
|
241
|
+
`type` copied from the cause when the cause declares one (d.1093).
|
|
242
|
+
|
|
196
243
|
|
|
197
244
|
| Param | Type | Description |
|
|
198
245
|
| --- | --- | --- |
|
|
@@ -211,30 +258,6 @@ option set is `durable`, `arguments`, `exclusive`, `autoDelete`.
|
|
|
211
258
|
What describes the MESSAGE. Handed to `mqClient.publish()`.
|
|
212
259
|
|
|
213
260
|
**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
261
|
<a name="describeValue"></a>
|
|
239
262
|
|
|
240
263
|
## describeValue(value) ⇒ <code>string</code>
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,124 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [5.0.0] — 2026-10-03
|
|
8
|
+
|
|
9
|
+
pin: `@onlineapps/conn-orch-registry` 7.0.0 → 8.0.0
|
|
10
|
+
|
|
11
|
+
### Changed — BREAKING: selhání registru už není „Service not available" (d.1086)
|
|
12
|
+
|
|
13
|
+
Pro volajícího `isServiceAvailable()` a `routeToService()` se mění kontrakt: při odmítnutí
|
|
14
|
+
`registryClient.getService()` dřív dostal `false` (a `routeToService` chybu
|
|
15
|
+
`Service not available`), teď dostane odmítnutí s `code: 'SERVICE_DISCOVERY_FAILED'`.
|
|
16
|
+
|
|
17
|
+
- `ServiceDiscovery.isServiceAvailable()` spolkla odmítnutí `registryClient.getService()`:
|
|
18
|
+
zalogovala ho a vrátila `false`, takže `routeToService` hlásil
|
|
19
|
+
`[CookbookRouter] Service not available: <služba>` a příčina (nedostupný Redis, poškozený
|
|
20
|
+
záznam projekce, chyba zapojení) do neúspěšného běhu nedošla (architecture-principles §3–§5).
|
|
21
|
+
Nově se odmítnutí dál loguje a **vyhodí** jako `Error` s `code: 'SERVICE_DISCOVERY_FAILED'`,
|
|
22
|
+
`serviceName` a původní chybou v `cause`; `routeToService` ji nechá projít. Neúspěšný dotaz
|
|
23
|
+
se necachuje.
|
|
24
|
+
- `false` zůstává jen odpovědí registru: služba není `active`, nebo ji projekce nedrží
|
|
25
|
+
(`getService()` → `null`). Pro neznámou službu metoda dřív vracela `null` místo slíbeného
|
|
26
|
+
`boolean`; nyní `false`.
|
|
27
|
+
- Klasifikace pro retry se nemění: `SERVICE_DISCOVERY_FAILED` klasifikátor
|
|
28
|
+
(`error-handler-core`) nezná → `UNKNOWN`, stejně jako dřívější „Service not available".
|
|
29
|
+
|
|
30
|
+
### Changed — jedna cesta chyby discovery, mrtvá větev `ECONNREFUSED` pryč (d.1092)
|
|
31
|
+
|
|
32
|
+
- `ServiceDiscovery.isServiceAvailable()` logovala odmítnutí s `code === 'ECONNREFUSED'`
|
|
33
|
+
jinou řádkou (`Registry connection failed`). Větev zbyla z HTTP discovery; dotaz dnes čte
|
|
34
|
+
Redis projekci (conf `biz-discovery-redis` 001, ADR 0005) a jeho klient takový `code`
|
|
35
|
+
nedává, takže nebyla dosažitelná. Každé odmítnutí se teď loguje jednou řádkou
|
|
36
|
+
`Service discovery failed for <služba>:` a hází stejně jako dřív
|
|
37
|
+
(`SERVICE_DISCOVERY_FAILED`, `cause` = původní chyba).
|
|
38
|
+
|
|
39
|
+
### Added — `SERVICE_DISCOVERY_FAILED` nese `type` příčiny (d.1093)
|
|
40
|
+
|
|
41
|
+
- Odmítnutí z `ServiceDiscovery.isServiceAvailable()` převezme `type` z `cause`, pokud ho
|
|
42
|
+
příčina má; jinak `type` nemá. Verdikt vzniká tam, kde vzniká chyba: `@onlineapps/conn-orch-registry`
|
|
43
|
+
`getService()` otypuje dotaz, na který projekce registru neodpověděla (termín / Redis
|
|
44
|
+
nedostupný), jako `TRANSIENT`, a klasifikátor L1 (`error-handler-core`) čte `type`, aniž by
|
|
45
|
+
znal Redis — krok tak jde do retry místo `non_retryable_unknown`. Příčina bez `type` se
|
|
46
|
+
klasifikuje jako dřív (`UNKNOWN`).
|
|
47
|
+
|
|
48
|
+
### Added — integrační tier: discovery nad skutečným registry klientem a Redisem (d.1097)
|
|
49
|
+
|
|
50
|
+
- Nový tier `npm run test:integration` (`jest.integration.config.js`, `tests/integration/`);
|
|
51
|
+
`npm test` = unit && integration. `globalSetup` ověří živý Redis (`REDIS_URL`, jediný klíč,
|
|
52
|
+
hodnota se nikdy nevypisuje) a bez něj běh odmítne s opravou; `jest.config.js` tier
|
|
53
|
+
vylučuje, takže do unit tieru api nevstoupí.
|
|
54
|
+
- `ServiceDiscovery` se skládá s **nainstalovaným** `@onlineapps/conn-orch-registry` a `redis`
|
|
55
|
+
(devDependencies, přesné piny): služba v projekci `registry:services` jako `active` → `true`,
|
|
56
|
+
jiný stav nebo chybějící záznam → `false` (s kontrolou souseda v témže hashi), nečitelný
|
|
57
|
+
záznam, odpojený Redis, zavřený socket a chybová odpověď → `SERVICE_DISCOVERY_FAILED`
|
|
58
|
+
s původní chybou v `cause`. Živý Redis v databázi tieru, jen pole vlastního běhu.
|
|
59
|
+
- Pojmenovaná mez: nainstalovaný registry klient chybu netypuje, proto sada tvrdí
|
|
60
|
+
`type === undefined`; strážní test verze zčervená při posunu pinu, kdy se tvrzení mění na
|
|
61
|
+
`TRANSIENT` a přibude scénář polootevřeného Redisu (conf `biz-discovery-redis` 003).
|
|
62
|
+
|
|
63
|
+
### Fixed — `QueueManager.publish()` už publish neopakuje (d.1121)
|
|
64
|
+
|
|
65
|
+
- Při odmítnutí, jehož text obsahoval `Connection lost`, volal `publish()` klienta podruhé.
|
|
66
|
+
Retry a buffer publishe vlastní `@onlineapps/mq-client-core` uvnitř každého `publish()`
|
|
67
|
+
(`docs/architecture/mq-publish-reliability.md` § The one rule for services); vyhozená
|
|
68
|
+
chyba znamená „nepotvrzeno" — zpráva je v bufferu klienta, nebo odmítnuta natrvalo — a druhé
|
|
69
|
+
volání by odeslalo druhou kopii. Rozhodovalo se navíc podle textu hlášky, ne podle typu.
|
|
70
|
+
- Nově jedno volání na zprávu; odmítnutí se zaloguje a propadne volajícímu beze změny (týž
|
|
71
|
+
objekt). Měřeno nad vydaným `mq-client-core` 5.0.0 (skutečné `BaseClient`, `RabbitMQClient`,
|
|
72
|
+
`PublishLayer`, dvojník jen `amqplib.connect`): ztracené spojení bez obnovy, odmítnutá obnova
|
|
73
|
+
i kanál mrtvý pod potvrzením hodí `PublishError`/`ConnectionError`, žádná s `Connection lost`
|
|
74
|
+
v textu — větev na této verzi nebyla dosažitelná.
|
|
75
|
+
|
|
76
|
+
### Added — `routeToService` nese volby publishe (d.1124)
|
|
77
|
+
|
|
78
|
+
- `routeToService(serviceName, message, publishOptions = {})`: třetí argument se předá
|
|
79
|
+
beze změny jako třetí argument `QueueManager.publish()`, který ho sloučí přes výchozí volby
|
|
80
|
+
zprávy (`{ persistent: true }`) a pošle MQ klientovi. Router volby nečte ani nedoplňuje —
|
|
81
|
+
klíč, který klient nezná, je klientův k ignorování. Do d.1124 router třetí argument zahodil,
|
|
82
|
+
takže `{ bufferOnFailure: false }` orchestrátoru k MQ klientovi nedošel a klient zprávu
|
|
83
|
+
dalšího kroku po vyčerpaném publishi uložil do bufferu a po reconnectu odeslal podruhé
|
|
84
|
+
(změřeno d.1119).
|
|
85
|
+
- Volby, jsou-li dány, musí být plain object; jinak `routeToService` hodí
|
|
86
|
+
`[CookbookRouter] routeToService - publishOptions must be an object - …` dřív, než se zeptá
|
|
87
|
+
registru, a nic nepublikuje. Bez třetího argumentu se chování nemění (`publish()` dostane `{}`).
|
|
88
|
+
- `API.md` nově nese `CookbookRouter` a jeho `routeToService`: docblock třídy stál nad
|
|
89
|
+
`require('./serviceDiscovery')`, takže ho jsdoc2md vykreslil jako druhý záznam
|
|
90
|
+
„ServiceDiscovery" (konstanta) a třídu s metodou vynechal. Docblock je teď nad
|
|
91
|
+
`class CookbookRouter`; citace volajících jsou jmény metod `WorkflowOrchestrator`, ne
|
|
92
|
+
ručně psanými čísly řádků.
|
|
93
|
+
|
|
94
|
+
### Tests — integrační helper staví registry klienta s `discoveryTimeoutMs` (d.1200)
|
|
95
|
+
|
|
96
|
+
- Test, ne chování: `registryClientOver()` (`tests/integration/helpers.js`) stavěl klienta bez
|
|
97
|
+
`discoveryTimeoutMs`, který je od kontraktu registry 8.0.0 povinný (d.1093); se zdrojem
|
|
98
|
+
registry z `release/w4` proto padalo 11 z 12 testů integračního tieru hláškou
|
|
99
|
+
`Missing constructor option - discoveryTimeoutMs is required`. Helper teď předává hodnotu
|
|
100
|
+
z deklarace `REGISTRY_DISCOVERY_TIMEOUT_MS` v `config/shared-env.json` — tutéž, kterou wrapper
|
|
101
|
+
čte a klientovi předává — nikdy literál; chybějící nebo nečíselná deklarace skončí
|
|
102
|
+
pojmenovanou chybou. Tvrzení beze změny; `src/**` se nemění.
|
|
103
|
+
|
|
104
|
+
### Changed — hláška `SERVICE_DISCOVERY_FAILED` bez dvojité tečky (d.1202)
|
|
105
|
+
|
|
106
|
+
- `ServiceDiscovery.isServiceAvailable()` uzavíral citovanou příčinu vždy tečkou. Odmítnutí
|
|
107
|
+
registry klienta 8.0.0 jsou celé věty `[Context] Problem - Expected/Fix`, které tečkou už
|
|
108
|
+
končí, takže hláška četla `…retried as TRANSIENT.. Expected: …` (architecture-principles §5).
|
|
109
|
+
Příčina zakončená `.`, `!` nebo `?` se teď cituje beze změny; příčině bez koncové interpunkce
|
|
110
|
+
se tečka doplní jako dřív. Kód, `serviceName`, `cause` a `type` beze změny.
|
|
111
|
+
|
|
112
|
+
### Tests — aserce objevování na kontrakt registry 8.0.0 (K13; d.1201)
|
|
113
|
+
|
|
114
|
+
- Test, ne chování: `tests/integration/serviceDiscovery.integration.test.js` popisoval registry
|
|
115
|
+
klienta 7.0.0, který selhání dotazu netypuje (dva případy „NAMED LIMIT: untyped"). Registry
|
|
116
|
+
8.0.0 při `isReady === false` (offline s `disableOfflineQueue`, socket zavřený pod příkazem)
|
|
117
|
+
odmítne vlastní chybou `REGISTRY_DISCOVERY_UNAVAILABLE`, `type: 'TRANSIENT'`, s chybou
|
|
118
|
+
node-redis v její `cause` (d.1093). Oba případy teď tvrdí `SERVICE_DISCOVERY_FAILED` s
|
|
119
|
+
`type: 'TRANSIENT'`, v `cause` chybu registru (`code`, `type`) a pod ní původní třídu
|
|
120
|
+
node-redis (`ClientOfflineError`, `SocketClosedUnexpectedlyError`); offline případ dál tvrdí
|
|
121
|
+
celou hlášku. Strážní `INSTALLED_REGISTRY_VERSION` je `'8.0.0'`; devDependency
|
|
122
|
+
`@onlineapps/conn-orch-registry` přechází na 8.0.0 v témže commitu (K13). Chybová
|
|
123
|
+
odpověď připojeného Redisu a poškozený záznam projekce zůstávají netypované. `src/**` se nemění.
|
|
124
|
+
|
|
7
125
|
## [4.0.0] — 2026-09-27
|
|
8
126
|
|
|
9
127
|
### Changed — BREAKING: `logger` and `cacheTTL` are required, and `0` is a value (d.615)
|
package/README.md
CHANGED
|
@@ -36,8 +36,8 @@ const { createRouter, CookbookRouter, ServiceDiscovery, QueueManager } =
|
|
|
36
36
|
|
|
37
37
|
| Export | What it is |
|
|
38
38
|
|---|---|
|
|
39
|
-
| `createRouter(mqClient, registryClient, options)` | Factory returning a `CookbookRouter`. This is what `WorkflowOrchestrator
|
|
40
|
-
| `CookbookRouter` | One method: `routeToService(serviceName, message)`. |
|
|
39
|
+
| `createRouter(mqClient, registryClient, options)` | Factory returning a `CookbookRouter`. This is what the `WorkflowOrchestrator` constructor takes (through `cookbook.createRouter`). |
|
|
40
|
+
| `CookbookRouter` | One method: `routeToService(serviceName, message, publishOptions)`. |
|
|
41
41
|
| `ServiceDiscovery` | One method: `isServiceAvailable(serviceName)`, with a TTL cache. |
|
|
42
42
|
| `QueueManager` | Two methods: `publish(queue, message, options)` and `ensureQueue(queue, options)`. |
|
|
43
43
|
| `VERSION` | String constant. |
|
|
@@ -51,14 +51,23 @@ const router = createRouter(mqClient, registryClient, { logger, cacheTTL: 300000
|
|
|
51
51
|
|
|
52
52
|
// Publishes to `biz-invoicing.workflow`.
|
|
53
53
|
await router.routeToService('biz-invoicing', workflowMessage);
|
|
54
|
+
|
|
55
|
+
// Publish options for this one message travel to the MQ client unchanged.
|
|
56
|
+
await router.routeToService('biz-invoicing', workflowMessage, { bufferOnFailure: false });
|
|
54
57
|
```
|
|
55
58
|
|
|
59
|
+
The optional third argument, `publishOptions`, is handed as-is to
|
|
60
|
+
`QueueManager.publish()`, which merges it over the message defaults and passes it
|
|
61
|
+
to the MQ client. The router neither reads nor completes it; without it the
|
|
62
|
+
client gets the message defaults alone.
|
|
63
|
+
|
|
56
64
|
`routeToService` fails fast and never publishes a message it could not place:
|
|
57
65
|
|
|
58
66
|
| Condition | Result |
|
|
59
67
|
|---|---|
|
|
60
68
|
| `serviceName` missing or not a string | throws `[CookbookRouter] routeToService - serviceName is required and must be a string` |
|
|
61
69
|
| `message` missing or not an object | throws `[CookbookRouter] routeToService - message is required and must be an object` |
|
|
70
|
+
| `publishOptions` given and not a plain object | throws `[CookbookRouter] routeToService - publishOptions must be an object - …` |
|
|
62
71
|
| registry says the service is not `active` | throws `[CookbookRouter] Service not available: <name>` |
|
|
63
72
|
| the publish itself fails | the underlying error propagates unchanged |
|
|
64
73
|
|
|
@@ -139,11 +148,16 @@ orchestrator's, not this package's.
|
|
|
139
148
|
## Collaborator contracts
|
|
140
149
|
|
|
141
150
|
- `registryClient.getService(serviceName)` resolves to an object whose `status`
|
|
142
|
-
is `'active'` when the service is up
|
|
143
|
-
|
|
151
|
+
is `'active'` when the service is up, or to `null` when the registry does not
|
|
152
|
+
hold the service — both are answers, and `isServiceAvailable` returns `false`
|
|
153
|
+
for anything but `'active'`. A rejection is not an answer: it is logged and
|
|
154
|
+
thrown as `code: 'SERVICE_DISCOVERY_FAILED'` with `serviceName` and the
|
|
155
|
+
rejection as `cause`, and `routeToService` lets it through (d.1086) — never
|
|
156
|
+
"Service not available". A failed lookup is not cached.
|
|
144
157
|
- `mqClient.assertQueue(queue, options)` and `mqClient.publish(queue, message,
|
|
145
|
-
options)`. `publish`
|
|
146
|
-
|
|
158
|
+
options)`. `publish` is called once per message; a rejection propagates
|
|
159
|
+
unchanged. Retry and buffering are the client's
|
|
160
|
+
(`docs/architecture/mq-publish-reliability.md` § The one rule for services).
|
|
147
161
|
|
|
148
162
|
## Related packages
|
|
149
163
|
|
package/package.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/cookbook-router",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "Message routing for cookbook workflows - handles service discovery and queue routing",
|
|
5
5
|
"oa": {
|
|
6
6
|
"category": "orchestration"
|
|
7
7
|
},
|
|
8
8
|
"main": "src/index.js",
|
|
9
9
|
"scripts": {
|
|
10
|
-
"test": "npm run test:unit",
|
|
10
|
+
"test": "npm run test:unit && npm run test:integration",
|
|
11
11
|
"test:unit": "jest tests/unit",
|
|
12
|
+
"test:integration": "jest --config=jest.integration.config.js",
|
|
12
13
|
"test:watch": "jest --watch",
|
|
13
14
|
"test:coverage": "jest --coverage",
|
|
14
15
|
"docs": "jsdoc2md --files 'src/**/*.js' > API.md.tmp && mv API.md.tmp API.md || (rm -f API.md.tmp; exit 1)"
|
|
@@ -26,8 +27,10 @@
|
|
|
26
27
|
"@onlineapps/logger-contract": "2.0.0"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
30
|
+
"@onlineapps/conn-orch-registry": "8.0.0",
|
|
29
31
|
"jest": "^29.7.0",
|
|
30
|
-
"jsdoc-to-markdown": "^8.0.0"
|
|
32
|
+
"jsdoc-to-markdown": "^8.0.0",
|
|
33
|
+
"redis": "4.7.1"
|
|
31
34
|
},
|
|
32
35
|
"engines": {
|
|
33
36
|
"node": ">=24.0.0 <25"
|
package/src/index.js
CHANGED
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* The package does NOT execute cookbooks, drive flow control, retry, complete
|
|
10
10
|
* or dead-letter them. Those belong to `WorkflowOrchestrator` — confirmation
|
|
11
11
|
* `api/docs/governance/confirmations/cookbook-execution-owner.md` 001 — which
|
|
12
|
-
* is also the only caller:
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* is also the only caller: its constructor takes `createRouter` (through
|
|
13
|
+
* `cookbook.createRouter`), and `processWorkflowMessage` and the method that
|
|
14
|
+
* hands the next step's message to the router call `routeToService` on the
|
|
15
|
+
* result. The second routing rail, the retry rail and the
|
|
15
16
|
* queue/registry administration this package used to carry had zero callers
|
|
16
17
|
* outside it and were removed on 2026-09-02.
|
|
17
18
|
*
|
package/src/queueManager.js
CHANGED
|
@@ -94,18 +94,15 @@ class QueueManager {
|
|
|
94
94
|
|
|
95
95
|
logger.debug(`Publishing to ${queueName}`);
|
|
96
96
|
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
throw error;
|
|
108
|
-
}
|
|
97
|
+
// ONE call. Retry, buffering and reconnect coordination live inside the
|
|
98
|
+
// client's `publish()` (`@onlineapps/mq-client-core`), so a rejection is its
|
|
99
|
+
// answer — the message was buffered for replay or refused for good — and a
|
|
100
|
+
// second call from here would be a second copy of it. Until d.1121 this
|
|
101
|
+
// published again whenever the rejection's text contained `Connection lost`:
|
|
102
|
+
// a service-side retry, decided on a sentence no client release produces.
|
|
103
|
+
// @see ../../../../docs/architecture/mq-publish-reliability.md § The one rule for services
|
|
104
|
+
await this.mqClient.publish(queueName, messageWithTimestamp, publishOptions);
|
|
105
|
+
return true;
|
|
109
106
|
|
|
110
107
|
} catch (error) {
|
|
111
108
|
logger.error(`Failed to publish to ${queueName}:`, error);
|
package/src/router.js
CHANGED
|
@@ -1,21 +1,34 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const ServiceDiscovery = require('./serviceDiscovery');
|
|
4
|
+
const QueueManager = require('./queueManager');
|
|
5
|
+
const { assertLogger } = require('./options');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A plain object: `{}` or `Object.create(null)` — not `null`, an array, or an
|
|
9
|
+
* instance of another class (a `Map` of options would reach the MQ client as an
|
|
10
|
+
* object with no own keys).
|
|
11
|
+
* @private
|
|
12
|
+
* @param {*} value
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
function isPlainObject(value) {
|
|
16
|
+
if (value === null || typeof value !== 'object') return false;
|
|
17
|
+
const proto = Object.getPrototypeOf(value);
|
|
18
|
+
return proto === Object.prototype || proto === null;
|
|
19
|
+
}
|
|
20
|
+
|
|
3
21
|
/**
|
|
4
22
|
* CookbookRouter - routes a workflow message to a service queue.
|
|
5
23
|
*
|
|
6
24
|
* ONE responsibility, ONE method: `routeToService`. Execution, flow control,
|
|
7
25
|
* retry, DLQ and completion belong to `WorkflowOrchestrator`
|
|
8
26
|
* (confirmation `api/docs/governance/confirmations/cookbook-execution-owner.md`
|
|
9
|
-
* 001), which is also this class's only caller
|
|
10
|
-
*
|
|
27
|
+
* 001), which is also this class's only caller: `processWorkflowMessage()` and
|
|
28
|
+
* the method that hands the next step's message to the router.
|
|
11
29
|
*
|
|
12
30
|
* @see /api/shared/cookbook/cookbook-router/README.md
|
|
13
31
|
*/
|
|
14
|
-
|
|
15
|
-
const ServiceDiscovery = require('./serviceDiscovery');
|
|
16
|
-
const QueueManager = require('./queueManager');
|
|
17
|
-
const { assertLogger } = require('./options');
|
|
18
|
-
|
|
19
32
|
class CookbookRouter {
|
|
20
33
|
constructor(mqClient, registryClient, options = {}) {
|
|
21
34
|
this.mqClient = mqClient;
|
|
@@ -51,9 +64,17 @@ class CookbookRouter {
|
|
|
51
64
|
* Route message directly to a specific service
|
|
52
65
|
* @param {string} serviceName - Target service name
|
|
53
66
|
* @param {Object} message - Workflow message to send
|
|
67
|
+
* @param {Object} [publishOptions={}] - Publish options for this one message,
|
|
68
|
+
* handed unchanged as the third argument of `QueueManager.publish()`, which
|
|
69
|
+
* merges them over its message defaults and passes them to the MQ client
|
|
70
|
+
* (e.g. `{ bufferOnFailure: false }`). The router neither reads nor completes
|
|
71
|
+
* them: a key the MQ client does not know is the client's to ignore. Must be
|
|
72
|
+
* a plain object when given.
|
|
54
73
|
* @returns {Promise<void>}
|
|
74
|
+
* @throws {Error} when `publishOptions` is given and is not a plain object —
|
|
75
|
+
* before the registry is asked and before anything is published
|
|
55
76
|
*/
|
|
56
|
-
async routeToService(serviceName, message) {
|
|
77
|
+
async routeToService(serviceName, message, publishOptions = {}) {
|
|
57
78
|
const { logger } = this.options;
|
|
58
79
|
|
|
59
80
|
if (!serviceName || typeof serviceName !== 'string') {
|
|
@@ -62,6 +83,13 @@ class CookbookRouter {
|
|
|
62
83
|
if (!message || typeof message !== 'object') {
|
|
63
84
|
throw new Error('[CookbookRouter] routeToService - message is required and must be an object');
|
|
64
85
|
}
|
|
86
|
+
if (!isPlainObject(publishOptions)) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
'[CookbookRouter] routeToService - publishOptions must be an object - '
|
|
89
|
+
+ 'Expected: a plain object of publish options (e.g. { bufferOnFailure: false }) or no third argument. '
|
|
90
|
+
+ 'Fix: pass the options QueueManager.publish() hands to the MQ client, or omit the argument'
|
|
91
|
+
);
|
|
92
|
+
}
|
|
65
93
|
|
|
66
94
|
// Verify service is available
|
|
67
95
|
const isAvailable = await this.serviceDiscovery.isServiceAvailable(serviceName);
|
|
@@ -73,7 +101,7 @@ class CookbookRouter {
|
|
|
73
101
|
const queueName = `${serviceName}.workflow`;
|
|
74
102
|
logger.info(`[CookbookRouter] Routing to service: ${queueName}`);
|
|
75
103
|
|
|
76
|
-
await this.queueManager.publish(queueName, message);
|
|
104
|
+
await this.queueManager.publish(queueName, message, publishOptions);
|
|
77
105
|
}
|
|
78
106
|
}
|
|
79
107
|
|
package/src/serviceDiscovery.js
CHANGED
|
@@ -25,38 +25,67 @@ class ServiceDiscovery {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* Check if a service is available
|
|
28
|
+
* Check if a service is available.
|
|
29
|
+
*
|
|
30
|
+
* `false` is the registry's answer: the service is there and not active, or the
|
|
31
|
+
* projection does not hold it (`getService()` resolved to `null`). A lookup the
|
|
32
|
+
* registry could not answer is not that answer, so it is thrown with its cause
|
|
33
|
+
* rather than read as "not available" (d.1086; architecture-principles.md §3–§5).
|
|
34
|
+
* A failed lookup is never cached.
|
|
35
|
+
*
|
|
29
36
|
* @param {string} serviceName - Service name
|
|
30
37
|
* @returns {Promise<boolean>}
|
|
38
|
+
* @throws {Error} `code: 'SERVICE_DISCOVERY_FAILED'`, `serviceName`, and the
|
|
39
|
+
* registry's rejection as `cause`, when `registryClient.getService()` rejects;
|
|
40
|
+
* `type` copied from the cause when the cause declares one (d.1093).
|
|
31
41
|
*/
|
|
32
42
|
async isServiceAvailable(serviceName) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const service = await this.registryClient.getService(serviceName);
|
|
45
|
-
|
|
46
|
-
if (service) {
|
|
47
|
-
this.setCached(serviceName, service);
|
|
48
|
-
}
|
|
43
|
+
// Whether there is anything to serve is `getCached`'s answer, and it reads
|
|
44
|
+
// the ONE value that decides it — the lifetime. Until d.615b a second key,
|
|
45
|
+
// `cacheEnabled`, guarded these two branches as well, so "do not cache"
|
|
46
|
+
// had two spellings that could contradict each other
|
|
47
|
+
// (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
48
|
+
const cached = this.getCached(serviceName);
|
|
49
|
+
if (cached !== null) {
|
|
50
|
+
return cached.status === 'active';
|
|
51
|
+
}
|
|
49
52
|
|
|
50
|
-
|
|
53
|
+
let service;
|
|
54
|
+
try {
|
|
55
|
+
service = await this.registryClient.getService(serviceName);
|
|
51
56
|
} catch (error) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
this.options.logger.error(`Service discovery failed for ${serviceName}:`, error);
|
|
58
|
+
// The cause is quoted as a sentence. The registry's own refusals are whole
|
|
59
|
+
// `[Context] Problem - Expected/Fix` sentences that already end with a
|
|
60
|
+
// full stop (conn-orch-registry 8.0.0), so the stop is added only to a
|
|
61
|
+
// cause that does not close its own sentence (d.1202).
|
|
62
|
+
const cause = /[.!?]$/.test(error.message) ? error.message : `${error.message}.`;
|
|
63
|
+
const failure = new Error(
|
|
64
|
+
`[ServiceDiscovery] Registry lookup for service '${serviceName}' failed - ${cause} `
|
|
65
|
+
+ 'Expected: the registry answers with the service summary or null. '
|
|
66
|
+
+ 'Fix: make the registry projection reachable to this service (the cause names what failed); '
|
|
67
|
+
+ 'an unanswered lookup is never read as an unavailable service.',
|
|
68
|
+
{ cause: error }
|
|
69
|
+
);
|
|
70
|
+
failure.code = 'SERVICE_DISCOVERY_FAILED';
|
|
71
|
+
failure.serviceName = serviceName;
|
|
72
|
+
// The verdict is made where the failure is born, never here (d.1093): the
|
|
73
|
+
// registry client types a lookup the projection could not answer, and the
|
|
74
|
+
// L1 classifier reads `type` without knowing what a registry or Redis is.
|
|
75
|
+
// Carried only when the cause has one — no type is invented for a cause
|
|
76
|
+
// that declared none.
|
|
77
|
+
if (error.type !== undefined) {
|
|
78
|
+
failure.type = error.type;
|
|
57
79
|
}
|
|
80
|
+
throw failure;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (service === null) {
|
|
58
84
|
return false;
|
|
59
85
|
}
|
|
86
|
+
|
|
87
|
+
this.setCached(serviceName, service);
|
|
88
|
+
return service.status === 'active';
|
|
60
89
|
}
|
|
61
90
|
|
|
62
91
|
/**
|