@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 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_ it takes `createRouter`
13
- (WorkflowOrchestrator.js_71) and calls `routeToService` on the result
14
- (_78,233,241,1223,1230). The second routing rail, the retry rail and the
12
+ is also the only caller_ its constructor takes `createRouter` (through
13
+ `cookbook.createRouter`), and `processWorkflowMessage` and the method that
14
+ hands the next 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: 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
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&#39;s only caller: <code>processWorkflowMessage()</code> and
63
+ the method that hands the next step&#39;s message to the router.</p>
64
+ </dd>
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&#39;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_ 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
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: 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
+ 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.&lt;void&gt;</code>
204
+ Route message directly to a specific service
205
+
206
+ **Kind**: instance method of [<code>CookbookRouter</code>](#CookbookRouter)
207
+ **Throws**:
208
+
209
+ - <code>Error</code> when `publishOptions` is given and is not a plain object —
210
+ before the registry is asked and before anything is published
211
+
212
+
213
+ | Param | Type | Default | Description |
214
+ | --- | --- | --- | --- |
215
+ | serviceName | <code>string</code> | | Target service name |
216
+ | message | <code>Object</code> | | Workflow message to send |
217
+ | [publishOptions] | <code>Object</code> | <code>{}</code> | Publish options for this one message, handed unchanged as the third argument of `QueueManager.publish()`, which merges them over its message defaults and passes them to the MQ client (e.g. `{ bufferOnFailure: false }`). The router neither reads nor completes them: a key the MQ client does not know is the client's to ignore. Must be a plain object when given. |
218
+
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.&lt;boolean&gt;</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.&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
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.js:71` takes. |
40
- | `CookbookRouter` | One method: `routeToService(serviceName, message)`. |
39
+ | `createRouter(mqClient, registryClient, options)` | Factory returning a `CookbookRouter`. This is what the `WorkflowOrchestrator` constructor takes (through `cookbook.createRouter`). |
40
+ | `CookbookRouter` | One method: `routeToService(serviceName, message, publishOptions)`. |
41
41
  | `ServiceDiscovery` | One method: `isServiceAvailable(serviceName)`, with a TTL cache. |
42
42
  | `QueueManager` | Two methods: `publish(queue, message, options)` and `ensureQueue(queue, options)`. |
43
43
  | `VERSION` | String constant. |
@@ -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. A rejection is logged and read as "not
143
- available" — `isServiceAvailable` returns `false`, it does not throw.
151
+ is `'active'` when the service is up, or to `null` when the registry does not
152
+ hold the service — both are answers, and `isServiceAvailable` returns `false`
153
+ for anything but `'active'`. A rejection is not an answer: it is logged and
154
+ thrown as `code: 'SERVICE_DISCOVERY_FAILED'` with `serviceName` and the
155
+ rejection as `cause`, and `routeToService` lets it through (d.1086) — never
156
+ "Service not available". A failed lookup is not cached.
144
157
  - `mqClient.assertQueue(queue, options)` and `mqClient.publish(queue, message,
145
- options)`. `publish` retries exactly once when the error message contains
146
- `Connection lost`; every other error propagates.
158
+ options)`. `publish` is called once per message; a rejection propagates
159
+ unchanged. Retry and buffering are the client's
160
+ (`docs/architecture/mq-publish-reliability.md` § The one rule for services).
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": "4.0.0",
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: it takes `createRouter`
13
- * (WorkflowOrchestrator.js:71) and calls `routeToService` on the result
14
- * (:78,233,241,1223,1230). The second routing rail, the retry rail and the
12
+ * is also the only caller: its constructor takes `createRouter` (through
13
+ * `cookbook.createRouter`), and `processWorkflowMessage` and the method that
14
+ * hands the next step's message to the router call `routeToService` on the
15
+ * result. The second routing rail, the retry rail and the
15
16
  * queue/registry administration this package used to carry had zero callers
16
17
  * outside it and were removed on 2026-09-02.
17
18
  *
@@ -94,18 +94,15 @@ class QueueManager {
94
94
 
95
95
  logger.debug(`Publishing to ${queueName}`);
96
96
 
97
- // Handle connection errors with retry
98
- try {
99
- await this.mqClient.publish(queueName, messageWithTimestamp, publishOptions);
100
- return true;
101
- } catch (error) {
102
- // If connection lost, retry once
103
- if (error.message.includes('Connection lost')) {
104
- await this.mqClient.publish(queueName, messageWithTimestamp, publishOptions);
105
- return true;
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
- * `WorkflowOrchestrator.js:78,233,241,1223,1230`.
27
+ * 001), which is also this class's only caller: `processWorkflowMessage()` and
28
+ * the method that hands the next step's message to the router.
11
29
  *
12
30
  * @see /api/shared/cookbook/cookbook-router/README.md
13
31
  */
14
-
15
- const ServiceDiscovery = require('./serviceDiscovery');
16
- const QueueManager = require('./queueManager');
17
- 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
 
@@ -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
- try {
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';
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
- return service && service.status === 'active';
53
+ let service;
54
+ try {
55
+ service = await this.registryClient.getService(serviceName);
51
56
  } catch (error) {
52
- // Handle specific error codes
53
- if (error.code === 'ECONNREFUSED') {
54
- this.options.logger.error('Registry connection failed', { serviceName, error });
55
- } else {
56
- this.options.logger.error(`Service discovery failed for ${serviceName}:`, error);
57
+ this.options.logger.error(`Service discovery failed for ${serviceName}:`, error);
58
+ // The cause is quoted as a sentence. The registry's own refusals are whole
59
+ // `[Context] Problem - Expected/Fix` sentences that already end with a
60
+ // full stop (conn-orch-registry 8.0.0), so the stop is added only to a
61
+ // cause that does not close its own sentence (d.1202).
62
+ const cause = /[.!?]$/.test(error.message) ? error.message : `${error.message}.`;
63
+ const failure = new Error(
64
+ `[ServiceDiscovery] Registry lookup for service '${serviceName}' failed - ${cause} `
65
+ + 'Expected: the registry answers with the service summary or null. '
66
+ + 'Fix: make the registry projection reachable to this service (the cause names what failed); '
67
+ + 'an unanswered lookup is never read as an unavailable service.',
68
+ { cause: error }
69
+ );
70
+ failure.code = 'SERVICE_DISCOVERY_FAILED';
71
+ failure.serviceName = serviceName;
72
+ // The verdict is made where the failure is born, never here (d.1093): the
73
+ // registry client types a lookup the projection could not answer, and the
74
+ // L1 classifier reads `type` without knowing what a registry or Redis is.
75
+ // Carried only when the cause has one — no type is invented for a cause
76
+ // that declared none.
77
+ if (error.type !== undefined) {
78
+ failure.type = error.type;
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
  /**