@onlineapps/mq-client-core 4.2.0 → 5.1.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/CHANGELOG.md CHANGED
@@ -4,6 +4,751 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [5.1.0] — 2026-10-03
8
+
9
+ ### Added
10
+
11
+ - `publish(queue, message, { bufferOnFailure: false })` — volba pro jedno volání: zpráva, jejíž
12
+ publikace selhala přechodně (vyčerpané pokusy, `publishRetryEnabled: false`, selhaný reconnect,
13
+ na který retry čekal), se NEuloží do bufferu, takže ji žádný reconnect znovu neodešle. Výchozí
14
+ `true` = chování beze změny. Pro volajícího, který zprávu drží trvale jinde (orchestrátor,
15
+ `publish_pending`, conf `step-publish-failure` 001; d.1122). Hodnota jiná než boolean se
16
+ odmítne `ValidationError` před prvním pokusem.
17
+ - Chyba ze tří přechodných cest nese `buffered` (boolean) — na chybě `PublishLayer` i na obalu
18
+ `PublishError` z `BaseClient.publish()`: `true` jen když buffer zprávu opravdu drží. Trvalé
19
+ odmítnutí `buffered` nenese. Věta `Fix:` chyby už neslibuje „is buffered“ tam, kde zpráva
20
+ v bufferu není (volba `false`, nebo buffer zprávu nepřijal).
21
+ - Chyba publishe nese `sent` (boolean) — zda rámec zprávy OPUSTIL klienta (`sendToQueue()` /
22
+ `publish()` kanálu vrátil bez výjimky), vedle `buffered` z d.1122: na obalu `PublishError`
23
+ z `BaseClient.publish()` i na chybě `PublishLayer` (`true`, když ho odeslal KTERÝKOLI pokus
24
+ volání); chyba jednoho pokusu transportu (`_publishOnce()`) nese `sent` toho pokusu. Záznam
25
+ bufferu nese totéž. Neříká nic o tom, zda broker zprávu uložil: při memory alarmu broker
26
+ odeslané rámce doručí sám po jeho zrušení (d.1143: 3 kopie publishe na exchange, 1 kopie
27
+ prvního publishe do fronty, 0 u neodeslaného). Rozhodnutí vlastníka
28
+ `docs/governance/confirmations/mq-publish-buffer.md` 001 (D); orchestrátor ho kopíruje do
29
+ záznamu `publish_pending` (`step-publish-failure` 003). Chyba transportu bez booleovského
30
+ `sent` je vada souseda: `PublishLayer` ji odmítne jménem a zprávu nebufferuje (d.1145).
31
+
32
+ ### Changed
33
+
34
+ - Každé čekání na odpověď brokera má limit `brokerAnswerTimeout` — jedna kolej, žádný nový klíč
35
+ (rozhodnutí vlastníka, `docs/governance/confirmations/mq-broker-answer-budget.md` 001, verdikt A;
36
+ d.1135). Dosud ho mělo jen potvrzení publishe a `close-ok` při `disconnect()`; `assertQueue`,
37
+ `checkQueue` (veřejné i kontrola před publishem do fronty), `bindQueue`, `assertExchange`,
38
+ `purgeQueue`, `deleteQueue`, `consume`, `cancelConsumer`, prefetch a otevření kanálu
39
+ (`createChannel` / `createConfirmChannel`, při `connect()`, obnově i reconnectu) čekaly bez
40
+ limitu. Změřeno d.1126 na izolovaném brokeru platformního obrazu: při alarmu paměti (spojení
41
+ žije, heartbeat teče) visela po 300 s, u zmrazeného brokeru je ukončil až heartbeat po ~90 s.
42
+ Co se mění pro volajícího — hodnotami:
43
+ - volání, na které broker v limitu neodpoví, skončí po `brokerAnswerTimeout` chybou
44
+ `ConnectionError` s větou `The broker did not answer <operace> <cíl> within
45
+ brokerAnswerTimeout (<N>ms)`; kanál, který požadavek nesl, se vyřadí a příští operace otevře
46
+ nový (kanál, který broker otevře až po limitu, se hned zavře);
47
+ - publish do fronty, jehož kontrola `checkQueue` (nebo obnova kanálu) nedostala odpověď, je
48
+ přechodné selhání: `TransientPublishError` → retry → buffer, s `bufferOnFailure: false`
49
+ `PublishError(cause=TransientPublishError)` a `buffered: false` (D2, conf
50
+ `step-publish-failure` 001);
51
+ - holé `Channel ended, no reply will be forthcoming` z amqplibu (kanál nebo spojení skončilo
52
+ dřív, než broker odpověděl) se na těchto voláních vrací jako `ConnectionError` s původní
53
+ chybou v `cause`; číslované odmítnutí brokera (404, 406, …) zůstává beze změny;
54
+ - `connect()`, jemuž broker neodpoví na otevření kanálu, skončí do limitu a zavře spojení
55
+ v témže limitu (neodpovězené zavření zahodí socket jako `disconnect()`).
56
+ Heartbeat, potvrzení publishe a teardown beze změny.
57
+ - Klient poslouchá `blocked`/`unblocked` svého spojení (broker v memory/disk alarmu blokuje
58
+ spojení, které publikuje; d.1143 změřil na obrazu platformy). `blocked` = `warn` s důvodem
59
+ brokera; `unblocked` = `warn` a flush bufferu jednou kolejí `PublishLayer.flushBuffered('unblocked')`:
60
+ znovu se pošle JEN záznam, jehož rámec nikdy neopustil klienta; záznam s `sent: true` se vyřadí
61
+ s `warn` (fronta/exchange, stáří v bufferu), protože broker podržené rámce po alarmu doručí sám.
62
+ Flush po reconnectu beze změny — `flushBuffered('reconnected')` pošle všechno. Změřeno na
63
+ izolovaném brokeru d1145-rmq: po uvolnění alarmu u brokera 3 kopie odeslaného publishe na
64
+ exchange (beze změny, žádná navíc), 1 kopie odeslaného prvního publishe do fronty (beze změny)
65
+ a nově 1 kopie publishe, který se neodeslal (dřív 0 a tichá expirace); buffer po uvolnění
66
+ prázdný (dřív 2 a 1). Rozhodnutí vlastníka `docs/governance/confirmations/mq-publish-buffer.md`
67
+ 001 (D), d.1145.
68
+ - `PublishLayer.flushBuffered(trigger)` — jediný argument je nově POVINNÝ spouštěč
69
+ `'reconnected'` | `'unblocked'` (rozhoduje, co se stane se záznamem, jehož rámec už odešel);
70
+ jiná hodnota se odmítne jménem. `InMemoryBuffer.flush(flushFn, { sentEntries })` totéž
71
+ (`'resend'` | `'drop'`). Uvnitř balíčku jediný volající je transport (d.1145).
72
+ Druhý argument `flushOptions` (slučoval se do voleb každé znovu poslané zprávy) je PRYČ:
73
+ nikdo ho nepředával (0 volajících v `shared/**`, `infra/**`, `api_biz/*/src`); znovu poslaná
74
+ zpráva nese přesně volby, se kterými ji buffer držel (d.1145).
75
+ - Vypršení zprávy v bufferu je `error`, ne `info`: jeden řádek na cíl (fronta nebo exchange)
76
+ a hodnotu `sent`, s počtem, stářím nejstarší a TTL; `sent=false` = zpráva je ZTRACENÁ,
77
+ `sent=true` = broker ji mohl doručit sám. Dřív „Removed N expired buffered messages“ bez
78
+ fronty i stáří. Rozhodnutí vlastníka `docs/governance/confirmations/mq-publish-buffer.md`
79
+ 001 (D), d.1145.
80
+ - Publish, jehož potvrzení ještě čekalo, když broker zavřel spojení nebo kanál (`320
81
+ CONNECTION_FORCED`, restart či stop brokeru, výpadek spojení), končil `PermanentPublishError`
82
+ bez bufferu a bez retry — zpráva byla ztracena, ač o ní broker nic neřekl (měřeno d.1126).
83
+ Příčina: amqplib odpovídá nepotvrzeným publishům `Error('channel closed')` ze svého `close`
84
+ posluchače registrovaného v konstruktoru kanálu, tedy DŘÍV, než klient kanál označil za
85
+ mrtvý; callback pak četl živý kanál. Klientův `close` posluchač send kanálu se nyní
86
+ registruje `prependListener` a běží první, takže tahle cesta je přechodná
87
+ (`TransientPublishError`) a jde dnešní cestou retry/buffer, včetně `bufferOnFailure: false`
88
+ (`buffered: false`). `nack` brokeru a číslovaný close frame této zprávy zůstávají trvalé
89
+ (d.1134).
90
+ - Hláška `PublishError` větve fronty při zavření kanálu s čekajícím potvrzením tvrdila „and not
91
+ reconnecting“ — v S3 nepravda: spojení v tu chvíli ještě neohlásilo `close` a reconnect
92
+ následuje. Nově: „Channel closed during publish to queue "<q>" before the broker confirmed the
93
+ message …“, bez tvrzení o reconnectu, `Fix:` odkazuje na `error.buffered` (d.1134).
94
+ - Re-registrace konzumentů po obnově kanálu se ptá na živost konzumentského kanálu před KAŽDÝM
95
+ konzumentem (a znovu po kroku fronty), ne jednou před smyčkou. Kanál, který zemřel uprostřed
96
+ smyčky (zavřel ho broker, nebo ho vyřadil `brokerAnswerTimeout`, d.1135), dřív vedl
97
+ u dalšího konzumenta na `TypeError: Cannot read properties of null (reading 'consume')`
98
+ a na zbytečnou deklaraci jeho fronty. Nově `ConnectionError` „Cannot re-register the consumer
99
+ of <q>: consumer channel is not available …“ v `consumer:re-registration:failed`; konzument
100
+ zůstává sledovaný bez tagu a obnoví ho příští obnova, jako dosud (d.1138).
101
+ - Hláška `_waitForReconnection()` po vypršení jmenovala env `RABBITMQ_RECONNECT_WAIT_TIMEOUT`,
102
+ který nic nečte. Nově jmenuje, co se čte: klíč konfigurace `reconnectWaitTimeout` („set in the
103
+ client configuration“), nebo odvození `max(60000, maxReconnectAttempts × reconnectMaxDelay)`,
104
+ když klíč zadaný není; `Fix:` odkazuje na tentýž klíč. Chování beze změny (d.1138).
105
+ - Přechodná chyba cesty publishe (`_publishPathTransient()`) tvrdila „queue channel is not
106
+ available (connection may be closed)“ i při ŽIVÉM spojení blokovaném memory alarmem (d.1143,
107
+ nález 3; od d.1135 končí touto chybou i neodpovězená kontrola `checkQueue`). Věta se nově
108
+ volí ze stavu klienta: zavřené spojení → „the connection is closed“; blokované → „the broker
109
+ has blocked this connection (reason: …)“; jinak „the queue channel is not available on a
110
+ live, unblocked connection“ s příčinou v `error.cause`. Třída beze změny (d.1145).
111
+
112
+ ### Fixed
113
+
114
+ - Spojení, které obnova navázala až po `disconnect()` volajícího, se zavírá v rozpočtu
115
+ `brokerAnswerTimeout` (`_closeWithinTeardownBudget()`, jedna kolej, conf
116
+ `mq-broker-answer-budget` 001). Dřív `await revived.close()` bez limitu: broker, který v tu
117
+ chvíli neodpovídal, držel kolo obnovy (`_reconnecting: true`) a socket otevřené navždy.
118
+ Neodpovězené zavření nově zahodí socket jako teardown (d.1138).
119
+
120
+ ### Documentation — JSDoc a popis klíče na pravdu, docblock jedné koleje `workflow_initiated` bez smazané cesty (d.1134, d.1138, d.1176)
121
+
122
+ - JSDoc `workflowIdOf()` tvrdil, že alias `workflowId` dosud píše webhook submit gatewaye.
123
+ Nepravda: žádný producent v `shared/**` ani `infra/**` alias nepíše (hlavičky gatewaye nesou
124
+ jen `workflow_id`, `infra/api_gateway/shared/workflowMessageHeaders.js`); čtení aliasu
125
+ v transportu neexistuje od d.416, chování beze změny (d.1134).
126
+ - Popis klíče `heartbeat` v `configSchema.js` na pravdu: hodnota se navrhuje brokeru v URL
127
+ spojení (ne „předává se amqplibu“) a `0` heartbeat NEVYPNE — vyjednávání amqplibu vezme
128
+ stranu, která limit stanoví, tedy hodnotu brokera (změřeno d.1126: pauza zjištěna po 179 s).
129
+ Chování beze změny (d.1138).
130
+ - Docblock `src/monitoring-initiated.js` popisuje rozhodnutí o jedné koleji události
131
+ `workflow_initiated` (conf `scheduler` 009 a 024) bez cesty na smazaný soubor gatewaye
132
+ a bez vyprávění o kopiích, které to rozhodnutí rušilo; chování beze změny (d.1176).
133
+
134
+ ### Tests — sady bez nástěnného času a s pravdivým popisem bloku (d.1174, d.1138)
135
+
136
+ - Test, ne chování: sada `connect-timeout-closes-late-handshake.test.js` čekala nástěnný čas
137
+ (`connectTimeout` 20 ms, pak `setTimeout` 80 ms na druhý pokus reconnectu) a pod zátěží runneru
138
+ `oa-staging` (pipeline 2904670335) četla `amqp.connect` 2× místo 3×, proto hodiny teď řídí
139
+ test (`jest.useFakeTimers()` + `advanceTimersByTimeAsync`) a výsledek nezávisí na zátěži
140
+ stroje (d.1174).
141
+ - Popis bloku testu `connection-parameters-reach-the-broker.test.js` („…one name for its two
142
+ consumers“) srovnán s d.1135: budget `brokerAnswerTimeout` platí pro každé čekání na odpověď
143
+ brokera, ne pro dva konzumenty (d.1138).
144
+ - Test, ne chování: akceptační sada čekací koleje `waiting-helper.test.js` měřila vzorkování
145
+ `waitForValue`/`waitForEvent` na nástěnných hodinách a pod loadem 17–30 (pre-K sweep
146
+ `release/w4`) vzorek rastru 20 ms zpožděný o víc než 40 ms minul okno 150–190 ms, takže
147
+ „s `pollMs: 20` tentýž stav VIDÍ" četl `true`; hodiny teď řídí test (`jest.useFakeTimers()`
148
+ + `advanceTimersByTimeAsync`, čas čte `jest.now()`); hodnoty a počty v tvrzeních beze změny,
149
+ podlahy pauzy přesně podle pravidla, bez rezervy na plánovač; kolej `tests/waiting.js`
150
+ nedotčena (d.1192).
151
+
152
+ ## [5.0.0] — 2026-09-27
153
+
154
+ ### Removed — BREAKING — fronta `workflow.control` (d.1061)
155
+
156
+ Sekce `workflow` v `src/config/queueConfig.js` už nedeklaruje položku `control` (TTL 30 s,
157
+ max 10 000, DLX → `workflow.failed`). Rozhodnutí vlastníka:
158
+ `docs/governance/confirmations/cookbook-execution-owner.md` 002.
159
+
160
+ Co to znamená pro volajícího — hodnotami, ne větou:
161
+
162
+ - `queueConfig.getInfrastructureQueueConfig('workflow.control')` a
163
+ `queueConfig.getDeadLetterRoute('workflow.control')` hází
164
+ `[queueConfig] Infrastructure queue config not found: workflow.control …`;
165
+ - `queueConfig.queueName('workflow', 'control')` hází
166
+ `[queueConfig] Cannot compose a name: queueConfig declares no "control" under "workflow" …`,
167
+ takže publikace na to jméno se odmítne dřív, než cokoli odejde;
168
+ - konzument, který frontu dosud kontroloval při startu, ji v deklaraci nenajde.
169
+
170
+ Co se **nemění**: `workflow.init` (deklarace, jméno, trasa do `workflow.failed`) a
171
+ `workflow.failed` jako konec řetězu, který `workflow.init` jmenuje. Počty deklarací klesly
172
+ o jednu frontu (36 položek / 29 front / 7 exchange); křížový součin prefix × šablona se
173
+ nemění, prefix `workflow` zůstává.
174
+
175
+ Odebrání deklarace frontu z běžícího brokeru nesmaže (fronta je durable) — to je krok
176
+ nasazení, ne knihovny.
177
+
178
+ ### Tests — `workflow.control` není deklarovaná (d.1061)
179
+
180
+ Nová sada `tests/unit/workflow-control-queue-not-declared.test.js` tvrdí nepřítomnost
181
+ hodnotami (žádný klíč `control`, tři odmítnutí se jménem fronty) a kontrolní případy
182
+ `workflow.init` a `workflow.failed`. Případy, které `workflow.control` používaly jako
183
+ deklarovanou frontu (`queueConfig-workflow-failed`, `dead-letter-topology`,
184
+ `consume-dead-letter-route-required`, `consume-terminal-queue`), ji už nejmenují; počty
185
+ deklarací v `queue-name-collision` a `d712-registry-changes-exchange-type` jsou srovnané.
186
+
187
+ ### Documentation — README: příklad publikace na `workflow.control` nahrazen `workflow.completed` (d.1061)
188
+
189
+ ### Fixed — `getChannelState()` hlásí i telemetrický kanál (d.1007)
190
+
191
+ `getChannelState()` hlásil `publisher`, `queue` a `consumer`, ale telemetrický kanál z d.724
192
+ (`_telemetryChannel`, vlastní události klienta) vynechával, přestože klíče téhle zprávy jsou
193
+ dokumentované jako seznam kanálů klienta. Teď nese čtvrtý klíč `telemetry` ve stejném tvaru
194
+ `{ exists, closed, ready }`. Kanál se otevírá prvním telemetrickým publishem; do té doby čte
195
+ `{ exists: false, closed: true, ready: false }`, stejně jako každý dosud neotevřený kanál.
196
+
197
+ Aditivní klíč. Čtenář, který tvrdí přesný tvar tří klíčů (`toEqual`), se musí srovnat: v balíčku
198
+ `connection-liveness-single-source` (unit i IT) je srovnaný v téže změně, kořenové
199
+ `api/tests/integration/channel-recovery.test.js` se srovná s re-pinem kořene na tuto verzi.
200
+
201
+ ### Added — `deliveryPolicy.validateConsumeOptions`: pravidla voleb `consume()` mají jednoho exportovaného vlastníka (d.1005)
202
+
203
+ Čtyři pravidla voleb `consume()` dosud žila inline v transportu (`rabbitmqClient.js` `consume()`):
204
+ - `requeueOnError` je boolean;
205
+ - `requeueOnError: false` znamená jeden pokus a jiné `maxAttempts` s ním je rozpor;
206
+ - rozpočet je celé číslo ≥ 1;
207
+ - `classify` je funkce.
208
+
209
+ Dvojník konzumenta (service-wrapper `MockMQConnector`) je proto mohl jen opsat, tedy druhá kolej
210
+ (nález d.998c). Teď bydlí v `src/config/deliveryPolicy.js` `validateConsumeOptions(options,
211
+ resolveDefaultMaxAttempts)`, transport ji volá a balíček ji exportuje jako
212
+ `deliveryPolicy.validateConsumeOptions`. Vrací `{ maxAttempts, classify }`, nebo hází `ValidationError`
213
+ se stejnými texty jako dosud `consume()`: `[RabbitMQClient] Invalid requeueOnError …`,
214
+ `… Contradictory delivery policy …`, `… Invalid delivery budget …`, `… Invalid error classifier …`.
215
+
216
+ Výchozí rozpočet se předává funkcí a volá se jen tehdy, když ho volby nechávají otevřený. Jeho
217
+ čtení sahá do konfigurace (env `RABBITMQ_MAX_DELIVERY_ATTEMPTS`), která může sama odmítnout, a
218
+ explicitní `maxAttempts` ani `requeueOnError: false` ho nikdy nepotřebovaly. Chování `consume()` se
219
+ nemění. Pro dvojníky konzumentů: volat `deliveryPolicy.validateConsumeOptions(options, () =>
220
+ <rozpočet dvojníka>)` místo vlastní kopie pravidel.
221
+
222
+ ### Removed — BREAKING — `RecoveryWorker` a posluchač kanálu `error`, který ho krmil (d.971)
223
+
224
+ Pryč je `src/workers/RecoveryWorker.js` (`handleQueueNotFound()`, `handleTransientError()`)
225
+ a v `src/transports/rabbitmqClient.js` jeho instance `_recoveryWorker` i vlastní posluchač
226
+ transportu `this.on('error', …)` („Recovery worker - handle QueueNotFoundError via error
227
+ event“). Čerstvě postavený transport na svém kanálu `error` neposlouchá nic.
228
+
229
+ BREAKING pro každého, kdo si worker bral hlubokou cestou
230
+ `require('@onlineapps/mq-client-core/src/workers/RecoveryWorker')` — z `src/index.js` se
231
+ nikdy neexportoval; ve workspace to dělaly jen dvě sady `conn-infra-mq`, přepsané v téže
232
+ vlně na veřejný kontrakt.
233
+
234
+ Co zmizí z logu: při publikaci do chybějící fronty už nevznikne řádek
235
+ `[RecoveryWorker] Cannot create infrastructure queue '<q>' …`,
236
+ `[RecoveryWorker] Cannot create business queue '<q>' …` ani
237
+ `[RecoveryWorker] Cannot create queue '<q>' - no section of queueConfig declares it …`.
238
+ Po d.811b (výše) už ho stejně nic nespouštělo.
239
+
240
+ Kde volající verdikt dostane (beze změny): `publish()` odmítne `PublishError`
241
+ (`error.queue` = cílová fronta) s `cause` = `QueueNotFoundError` — `queueName`,
242
+ `isInfrastructure`, `kind` (`infrastructure` / `business` / `unowned`) a pro každou třídu
243
+ věta s vlastníkem fronty a `Fix:`. Nic se nedeklaruje.
244
+
245
+ Čtyři otázky (`change-discipline.md` § Removing): (1) vznikl v `3588b148` jako zakladač
246
+ chybějící fronty pro business scope; (2) nesla ho hlavička workeru § Queue Creation, kterou
247
+ nahradila conf `mq-consumer-contract` 006 („queues come into being only from
248
+ declarations“) — od d.419 nic nezakládal, jen logoval a znovu házel do posluchače, který
249
+ chybu spolkl; (3) funkci mu vzal d.419, vstup d.811b; (4) náhrada je konceptuálnější —
250
+ jeden verdikt jednou kolejí, k volajícímu, místo druhé kopie téže věty do logu.
251
+ Sada `tests/unit/recovery-worker-removed.test.js` (kontrolní případ: `publish()` do
252
+ chybějící business fronty dál odmítá `PublishError` s `QueueNotFoundError` v `cause`).
253
+
254
+ ### Changed — BREAKING — verdikt o odmítnuté publikaci jde jen odmítnutím `publish()`, ne kanálem `error` (d.811b)
255
+
256
+ `RabbitMQClient._publishOnce()` končil každé selhání pokusu dvěma tvrzeními jednoho
257
+ verdiktu: `this.emit('error', classifiedErr)` a `throw classifiedErr`. Emit je pryč,
258
+ zůstává `throw classifyPublishError(err, queue)` (`src/transports/rabbitmqClient.js`,
259
+ větev `catch` v `_publishOnce()`). Kanál `error` zůstává tomu, k čemu ho používá zbytek
260
+ klienta — spojení a kanálům.
261
+
262
+ Co konzument vidí jinak:
263
+
264
+ - **`BaseClient.onError(callback)`** (jediná veřejná cesta ke kanálu `error` transportu,
265
+ `BaseClient.connect()` na něj věší `_handleError`) už nedostává `TransientPublishError`
266
+ (`retryable: true`), `PermanentPublishError` (`retryable: false`) ani `QueueNotFoundError`
267
+ (`kind`, `queueName`, `isInfrastructure`). Dřív je dostával za KAŽDÝ neúspěšný pokus,
268
+ tedy i za transientní pokusy, které `PublishLayer` následně zopakoval nebo zabufferoval.
269
+ Ostatní emity na `error` se nemění: spojení (`CONNECTION_CLOSED_UNEXPECTEDLY`), konec
270
+ cyklu obnovy spojení, chyby kanálu vydavatele (406 dál ne — tu odpovídá odesílateli
271
+ publikace) i konzumenta, odmítnutí `consume()`, selhání `ack`/`nack` a odmítnuté zavření
272
+ kanálu či spojení v `disconnect()`.
273
+ - **`publish()` se nemění:** odmítá stejně jako dřív — `PublishError` z `BaseClient.publish()`
274
+ s klasifikovanou chybou v `error.cause`. Čísla o publikacích hlásí dál
275
+ `getPublishMetrics()` / `getPrometheusMetrics()` (plní je události `publish:retry`,
276
+ `publish:success`, `publish:failed`, `publish:buffered`, beze změny).
277
+ - **`RecoveryWorker.handleQueueNotFound()` už na cestě publikace neběží.** Transport ho volal
278
+ z vlastního posluchače kanálu `error` (`_setupPublishMonitoring()`, „Recovery worker -
279
+ handle QueueNotFoundError via error event") a žádné jiné místo v `src/**` ho nevolá; žádný
280
+ zbývající emit na `error` `QueueNotFoundError` nenese. Při publikaci do chybějící fronty
281
+ proto z logu mizí řádek `[RecoveryWorker] Cannot create infrastructure queue '<q>' …` /
282
+ `Cannot create business queue '<q>' …` / `Cannot create queue '<q>' - no section of
283
+ queueConfig declares it …`. Fronta se nezakládá ani dál (nezakládal ji ani ten řádek) a
284
+ volající dostane odmítnutí jako dosud; ubyla jen druhá, logová zpráva o téže události.
285
+ - Filtr v `_waitForReconnection()` (`if (error.retryable !== undefined) return;`) zůstává:
286
+ knihovna do kanálu klasifikovanou chybu už nedává, ale kanál je veřejný a kdo drží
287
+ transport, emitovat na něj může.
288
+
289
+ Dopad ve workspace (změřeno 2026-09-26 nad `api/infra/*`, `api/shared/**` mimo tento balíček
290
+ a `api_biz/*/src`): v produkčním kódu nevolá `onError()` nikdo a nikdo neposlouchá `error` na
291
+ MQ klientovi ani na jeho transportu — pět míst, která sahají na `mqClient._transport`
292
+ (gateway, dispatcher, scheduler, wrapper, `conn-infra-mq` `QueueManager`), berou z něj
293
+ `queueChannel`, `connection` nebo `onChannelClose`, ne kanál `error`. Integrační sady v
294
+ `api/tests/integration` a `api/infra/api_gateway/tests` registrují `onError(() => {})`, který
295
+ nic netvrdí. Proto BREAKING podle kontraktu `onError()`, bez čtenáře, kterého by to zlomilo.
296
+
297
+ ### Changed — BREAKING — `serviceName` je povinný, knihovna si ho nevyrábí (d.770)
298
+
299
+ `src/defaults.js` už nenese `serviceName: 'oa-service'` a `src/config.js` deklaruje klíč
300
+ jako `required: true`. Jméno má dvě nohy, obě volajícího: explicitní `serviceName`
301
+ v konfiguraci klienta, nebo ENV `SERVICE_NAME`. Třetí není. Chybí-li obě,
302
+ `new BaseClient(…)` se odmítne postavit hláškou, jejíž znění vlastní
303
+ `@onlineapps/runtime-config` (`[RuntimeConfig] Missing environment variable - SERVICE_NAME
304
+ is required for config key "serviceName". Fix: …`). Dřív klient postavený bez klíče
305
+ publikoval události monitoringu a pojmenovával fronty pod službou `oa-service`, která
306
+ neexistuje — v `workflow_traces.service_name` pak stálo jméno nikoho.
307
+
308
+ Druhý čtenář téhož faktu je srovnaný: `BaseClient` rozřešené jméno (ať přišlo kteroukoli
309
+ nohou) zapisuje zpět do `config.serviceName` a transport z něj skládá jméno spojení
310
+ `oa-client:<serviceName>:<pid>`, když volající nedal `connectionName` ani `clientName`.
311
+ Dřív ho transport bral z runtime konfigurace, takže explicitní `serviceName` volajícího se
312
+ tam vůbec nečetl. Transport postavený bez jména se odmítne `ValidationError` místo spojení
313
+ pojmenovaného `oa-client:undefined`.
314
+
315
+ Migrace: předejte `serviceName` v konfiguraci klienta, nebo nastavte `SERVICE_NAME`
316
+ v prostředí služby. Změřeno v dávce: service-wrapper, conn-orch-registry,
317
+ api_delivery_dispatcher a api_meta_reader jméno předávají; api_delivery_endpoint stojí na
318
+ noze `SERVICE_NAME`.
319
+
320
+ ### Changed — BREAKING — zprávu, kterou broker pozdě odmítl, vrstva publishe už neposílá znovu (d.801c)
321
+
322
+ `PublishLayer` po dobu jednoho publishe poslouchá událost `publish:refused-late` (d.801b,
323
+ níže) a před každým dalším pokusem se ptá, zda pro tuto frontu nedorazil pozdní
324
+ PERMANENTNÍ verdikt. Pokud ano, smyčka končí: `warn` `[PublishLayer] The broker refused
325
+ this message while attempt <n> was in flight, so attempt <n+1> of <max> is not made …`,
326
+ událost `publish:failed` s `retryable: false` a `refusedLate: true`, a volající dostane ten
327
+ verdikt — `PermanentPublishError` s brokerovým rámcem v `cause`. Žádný další pokus, nic do
328
+ bufferu. Posluchač se odhlašuje v `finally`; nevzniká žádný nový časovač ani evidence. Číslo
329
+ pokusu jde transportu v options jako `attempt` (do AMQP vlastností se nedostane).
330
+
331
+ Co se mění pro konzumenta: zpráva, kterou broker odmítl až poté, co `brokerAnswerTimeout`
332
+ odpověděl volajícímu, se dřív opakovala `publishMaxRetries`krát a skončila v bufferu,
333
+ odkud ji `InMemoryBuffer.flush()` nabízel při každém reconnectu do vypršení TTL. Nově
334
+ skončí jedním pokusem a trvalou chybou. Dopad popsaný v dávce: api_delivery_dispatcher
335
+ takovou zásilku přestane opakovat a pošle ji deklarovanou dead-letter cestou; RPC odpověď
336
+ service-wrapperu změní třídu z transientní na permanentní; kdo počítá pokusy z
337
+ `publish:retry`, uvidí u takové zprávy jeden místo N. Zpráva, u níž potvrzení nedorazí
338
+ vůbec a žádný rámec není, se opakuje beze změny.
339
+
340
+ ### Changed — BREAKING — očíslované odmítnutí odeslání je trvalý verdikt o TÉTO zprávě (d.685a, d.693, d.696, d.723)
341
+
342
+ Broker, který odmítne `basic.publish`, zavře kanál očíslovaným rámcem (`classId 60`,
343
+ `methodId 40`). amqplib ho předá události `error` kanálu, kde ho handler publisher kanálu
344
+ zapíše do `channel._closeError`; callback publishe dostane jen holé `'channel closed'`.
345
+ `_publishOnce()` ten rámec nově čte na obou nohách, frontové i výměnové:
346
+
347
+ - **Číselný kód je odpověď** (d.685a, d.693): 403 ACCESS-REFUSED, 406 PRECONDITION-FAILED
348
+ i 404 NOT-FOUND za publish přes výměnu, kterou nikdo nedeklaroval, končí
349
+ `PermanentPublishError` s rámcem v `cause` — jeden pokus, nic do bufferu. Dřív
350
+ `TransientPublishError`: `publishMaxRetries` pokusů, pak buffer a nabídka při každém
351
+ reconnectu do TTL. Věty má jeden producent, `refusedSendMessage(kind, target, code)`
352
+ v `utils/publishErrors.js`; u 404 nad výměnou říká, že ji nikdo nedeklaroval, a `Fix:`
353
+ posílá do `queueConfig`. Chybějící FRONTA se nemění — dál `QueueNotFoundError`
354
+ z předkontroly.
355
+ - **Rámec patří jedné publikaci** (d.723): nese-li kanál v okamžiku rámce víc
356
+ nepotvrzených publikací, trvalý verdikt dostane jen ta, jejíž výměnu či frontu rámec
357
+ jmenuje; ostatní dostanou `TransientPublishError` s rámcem v `cause`
358
+ (`collateralCloseMessage()`), takže je vrstva zopakuje a mezitím bufferuje. Jméno, které
359
+ se v rámci nenajde, znamená „nepřisouzeno" — přechodný verdikt, tedy nanejvýš duplicitní
360
+ zpráva, nikdy zahozená.
361
+ - **Důvod se zapisuje dřív, než handler větví** (d.696): i rámec `406` „unknown delivery
362
+ tag" handler publisher kanálu zapíše do `channel._closeError`, takže publish na kanálu
363
+ zabitém tímto rámcem je trvalý stejně jako každé jiné očíslované odmítnutí. Na kanál
364
+ `error` klienta rámec dál nejde.
365
+ - Smrt kanálu BEZ číselného kódu (výpadek spojení, soketový `code` jako `'ECONNRESET'`)
366
+ zůstává přechodná: o osudu zprávy se nic neví.
367
+
368
+ Migrace: kdo spoléhal na to, že vrstva odmítnutou zprávu zopakuje nebo zabufferuje,
369
+ dostane ji nově hned jako odmítnutí `publish()`. Oprava je u příčiny, kterou rámec
370
+ v `error.cause` jmenuje — právo účtu ve vhostu, `user_id` zprávy, deklarace výměny.
371
+
372
+ ### Changed — BREAKING — `publishMaxRetries: 0` se odmítá jménem (d.684, d.685b)
373
+
374
+ Klíč počítá POKUSY včetně prvního. `0` proto nebylo „bez opakování", ale publish, ke
375
+ kterému nedojde: smyčka `_publishWithRetry()` neproběhla ani jednou, zpráva se brokeru
376
+ nenabídla, cestou ven se nezabufferovala a závěrečná hláška svalovala vinu na
377
+ `PublishLayer`. Tři komentáře, které `0` vykládaly jako „do not retry", jsou opravené.
378
+
379
+ Nově: `configSchema.js` deklaruje `minimum: 1`, takže `new BaseClient({ …,
380
+ publishMaxRetries: 0 })` padne už při stavbě klienta (`/publishMaxRetries: must be >= 1`
381
+ s `Fix:`); konstruktor `PublishLayer` hodnotu pod 1 odmítá i tam, kam schéma nedosáhne
382
+ (transport postavený přes `transportFactory`, vlastní `options.maxRetries` volajícího),
383
+ `ValidationError` `[PublishLayer] publishMaxRetries must be at least 1 - …`.
384
+
385
+ Migrace: jeden pokus bez opakování je `publishMaxRetries: 1`, opuštění retry koleje
386
+ `publishRetryEnabled: false`. Změřeno v dávce d.685b: jediný zápis klíče mimo balíček je
387
+ `api/tests/integration/message-buffer.test.js` s hodnotou 2.
388
+
389
+ ### Changed — vytíženost prefetch okna se hlásí na přechodu, oběma směry (d.736a)
390
+
391
+ `_checkPrefetchUtilization()` dřív mluvil pokaždé, když podmínku našel platnou (produkce
392
+ 18. 9.: tři řádky za 68 ms o jedné stojící podmínce), a návrat pod práh neřekl nikdy. Nově
393
+ padá verdikt jen na přechodu: nad práh jeden `warn` `Prefetch utilization high: …` a s ním
394
+ jednou událost `prefetch:high-utilization` a jednou `prefetchAlertCallback`; pod práh
395
+ jeden `info` `Prefetch utilization back below threshold: …`; mezi tím ticho, ať se čítač
396
+ nad prahem hýbe jakkoli. Stav drží `tracking.utilizationHigh` a verdikt se bere i při
397
+ snížení čítače. Kdo počítal volání callbacku nebo události, dostane jedno za epizodu místo
398
+ jednoho za každou kontrolu nad prahem. Práh se nemění, hystereze ani druhý callback nevznikají.
399
+
400
+ ### Changed — fronta instance registru vyprší 60 s po posledním konzumentovi (`x-expires`, d.1036)
401
+
402
+ Šablona `registry.{instance_id}` (`src/config/queueConfig.js`) nese `'x-expires': 60000`.
403
+ Každý start kopie služby pojmenuje novou frontu `<svc>.registry.<instance_id>` (UUID na proces,
404
+ pokud `SERVICE_INSTANCE_ID` neurčí jinak) a nic starou nemaže — `deregister()`, shutdown
405
+ wrapperu ani registr, který frontu před odpovědí jen ověří (měřeno d.1032). Bez životnosti by
406
+ po každém restartu, pádu i čistém stopu zůstala trvalá fronta bez konzumenta, kterou monitoring
407
+ hlásí jako `no_owner`. Živá kopie má konzumenta, takže jí fronta nevyprší; po odchodu kopie ji
408
+ broker smaže do 60 s — pod oknem sirotka monitoringu (`min_orphan_minutes`, seed 5), takže
409
+ epizodu neotevře. Sdílená `<svc>.registry` životnost nemá a nemění se. Rozhodnutí vlastníka:
410
+ otázka 14, varianta A (`registry-instance-identity` 003).
411
+
412
+ Není to BREAKING vůči vydané 4.2.0: ta šablonu `registry.{instance_id}` nemá vůbec, takže
413
+ žádný vydaný klient frontu tohoto tvaru nezaložil (konzumaci by odmítl bez dead-letter cesty).
414
+ Změřeno na brokeru (`tests/integration/registry-instance-queue-expires.integration.test.js`):
415
+ fronta tohoto tvaru, kterou dřív kdokoli založil BEZ `x-expires`, se teď při `consume()` odmítne
416
+ `406 PRECONDITION-FAILED` („inequivalent arg 'x-expires'") — týká se jen front z nevydaného
417
+ buildu (na dev brokeru jich je 0, d.1032 bod 5); oprava = frontu smazat, klient ji při
418
+ konzumaci založí znovu.
419
+
420
+ ### Added — fronta jedné instance registru `{service}.registry.{instance_id}` je deklarovaná (d.817b)
421
+
422
+ Odpovědi registru na registraci jdou instanci na `<svc>.registry.<instanceId>` (conf
423
+ `registry-instance-identity` 001); sdílená `<svc>.registry` nese jen podněty registru. Pro
424
+ takové jméno dosud `queueConfig.getDeadLetterRoute()` i `parseBusinessQueue()` vracely `null`
425
+ a `consume()` frontu bez deklarované dead-letter cesty odmítá (conf `mq-consumer-contract`
426
+ 002).
427
+
428
+ Nová šablona v sekci `business` (`src/config/queueConfig.js`):
429
+
430
+ 'registry.{instance_id}': {
431
+ durable: true,
432
+ arguments: {
433
+ 'x-dead-letter-exchange': DEAD_LETTER_EXCHANGE, // 'dlx'
434
+ 'x-dead-letter-routing-key': '{service}.dlq'
435
+ }
436
+ }
437
+
438
+ tedy stejná dead-letter cesta jako u sdílené `{service}.registry`:
439
+ `getDeadLetterRoute('biz-hello.registry.7f3a9c21')` = `{ exchange: 'dlx', routingKey:
440
+ 'biz-hello.dlq' }`. Životnost fronty doplnila d.1036 (`x-expires`, viz Changed níž).
441
+
442
+ Rozhodčí pravidlo `matchBusinessTemplate()`: `<svc>.registry.events` sedí na doslovnou
443
+ šablonu `registry.events` i na zástupnou `registry.{instance_id}`. Doslovná šablona nově
444
+ vyhrává nad zástupnou; dvě shody šablon TÉHOŽ druhu jsou dál vada a jméno se odmítá
445
+ `ValidationError` („matches more than one template"). Stávající šablony, jejich argumenty ani
446
+ cesty se nemění (`registry.events` si drží TTL i strop, sdílená `registry` je beze změny,
447
+ `registry.register` zůstává platformní); jméno, na které nesedí žádná šablona, je dál
448
+ odmítnuto.
449
+
450
+ ### Added — `scheduler.SCHEDULER_REQUEST_INVALID`: odmítnutí builderu se pozná podle kódu, ne podle textu (d.943)
451
+
452
+ Boot fáze 0.7b wrapperu má podle conf `scheduler` 020 izolovat záznam, který builder odmítne
453
+ (selhaný zdroj), a dál zastavit boot, když se požadavek nepodaří ODESLAT (selhání služby). Obě
454
+ cesty dosud házely holé `Error`, takže je šlo rozlišit jen podle znění hlášky. Nově každé
455
+ odmítnutí builderu nese `error.code = 'SCHEDULER_REQUEST_INVALID'` (konstanta pod jmenným
456
+ prostorem `scheduler`, jediný vlastník). Kód nesou: kontrola klíčů v `registerRecurring`,
457
+ `validateRecurrences` (chybějící `source`, dokument není objekt, dokument nedrží tvar, dvakrát
458
+ stejné `id`), `requireIdempotencyKey`, `requireRequestedBy`, `requireTenancy` (tenant i
459
+ workspace) a `buildEventRequest` (chybí `event`, `event.service`/`event.name` mimo vzor) —
460
+ tedy i `buildRecurringRequest` a `buildCancelRequest`, které je volají. NEnesou ho:
461
+ `publishSchedulerRequest` (chybí klient, klient není připojen, cizí druh požadavku, publikace
462
+ odmítnutá brokerem) a stavba portu v `createSchedulerPublisher`. Znění hlášek beze změny.
463
+ Sada `tests/unit/scheduler-request-invalid-code.test.js` (kontrolní případy: selhaná publikace
464
+ a nepřipojený klient kód nemají).
465
+
466
+ ### Added — událost `message_dlq` jmenuje operaci, která umřela (d.641)
467
+
468
+ Mrtvý dopis dosud říkal, ve KTERÉ frontě zemřel (`queue`, `attempts`,
469
+ `classification`, `error`), ale ne CO umřelo. Operátor tak u běhu s víc kroky
470
+ neměl z události jak poznat, která operace to byla; jméno bylo přitom celou dobu
471
+ v obálce, kterou `_emitDeadLetterEvent()` už rozbaluje kvůli `workflow_id`.
472
+
473
+ Nově událost nese `operation` — **jen když ho obálka nesla**, v jediném
474
+ pravopisu invokační obálky (`docs/biz/10-invocation/mq-envelope.md` § Inbound
475
+ envelope). Workflow zpráva (`docs/biz/70-contracts/workflow-message.md` §3.2)
476
+ `operation` nikdy nenese, takže u ní klíč v události prostě chybí; žádný
477
+ `unknown`, žádný `null` — stejně jako se zachází s chybějícím `workflow_id`.
478
+ Hodnota, která není neprázdný řetězec (`''`, číslo, objekt), se bere jako
479
+ nepřítomnost.
480
+
481
+ Konzument existuje už dnes a nový klíč čte beze změny: monitoring bere
482
+ `readReported(data.operation)` (`api/infra/api_monitoring/src/consumer/
483
+ apiflow-extension.js`) a posílá ji do řádku v Loki jako `operation` — u události
484
+ `message_dlq` tam do téhle dávky chodilo `null`. Identita běhu zůstává **jeden
485
+ klíč**, `workflow_id`: `correlation_id` na `monitoring.workflow` nejezdí
486
+ (`docs/biz/70-contracts/monitoring-events.md` § Rules) a tahle dávka ho tam
487
+ nezavádí.
488
+
489
+ Doklad proti realitě: integrační tier posílá přes živý broker obálku s
490
+ `operation`, nechá ji dojít do `<service>.dlq` a čte událost z vlastní kopie
491
+ fanoutu `monitoring.workflow.fanout`; kontrolní případ je tatáž cesta s
492
+ workflow zprávou bez `operation` — klíč v události chybí.
493
+
494
+ ### Added — `decodeMessage()`: jedna veřejná kolej z doručení na zprávu (d.789)
495
+
496
+ Nový export z kořene balíčku (`src/utils/decodeMessage.js`). `consume()` předává handleru
497
+ syrovou zásilku amqplib, takže si překlad psal každý konzument sám — dávka d.788 změřila
498
+ čtyři kopie (dispatcher, plánovač, service-wrapper, registr), které se neshodnou právě
499
+ v tom, co se stane s vadnou zprávou. Dekódování samo dělá `utils/serializer.js`
500
+ `deserialize()` odjakživa; chyběly veřejné dveře.
501
+
502
+ `decodeMessage(raw)` vrací `{ body, headers, properties, fields }` — dekódované tělo
503
+ a všechno ostatní tak, jak přišlo. Odmítá `ValidationError`em, jménem toho, co přišlo:
504
+ zásilku, která není objekt; `content`, který není `Buffer` ani řetězec; prázdné tělo;
505
+ deklarovaný `contentType` jiný než `application/json` (typ se kontroluje, jen když ho
506
+ zpráva deklaruje — obálka platformy ho nevyžaduje). Tělo, které není JSON, propouští
507
+ `SerializationError` z `deserialize()` beze změny (`payload`, `cause`). Pole `retryable`
508
+ nenese: na cestě konzumace ho nic nečte, osud zprávy rozhoduje `classify(error)`
509
+ volajícího podle třídy chyby.
510
+
511
+ Aditivní: `consume()` se nemění a v balíčku funkci nic nevolá. Převod čtyř konzumentů
512
+ mění osud vadné zprávy a jde vlastní dávkou.
513
+
514
+ ### Added — `topology.telemetryExchange` (d.708b)
515
+
516
+ Zmrazený export `topology` nese `telemetryExchange: 'telemetry.exchange'`, složený
517
+ `queueConfig.queueName('telemetry', 'exchange')` ze šablony, kterou výměna má
518
+ v `queueConfig` (d.708) — stejným tvarem jako tři `telemetry*Queue` nad ním. Monitoring
519
+ konzument řeší každé platformní jméno, které sám nevlastní, přes `topology`, a
520
+ `src/config/queueConfig` je interní cesta, ne kontrakt; deklarované, ale nevyexportované
521
+ jméno si proto dosud musel držet jako literál. Přejmenování v `queueConfig` export posune
522
+ s sebou, chybějící záznam selže hned.
523
+
524
+ ### Added — `BaseClient.publishTelemetry()`: vlastní události klienta mají vlastní kanál (d.724)
525
+
526
+ Kanál umírá na první odmítnutí, které potká, a bere s sebou každou nepotvrzenou publikaci.
527
+ Klient přitom vlastní události (`message_dlq` na `monitoring.workflow.fanout`) posílal po
528
+ tomtéž ConfirmChannelu, na kterém čekaly zprávy volajícího — ve vhostu bez toho fanoutu
529
+ tak 404 kvůli události shodil obchodní zprávu (změřeno d.711: tři ze sedmi běhů
530
+ nesouvisející sady).
531
+
532
+ - `publishTelemetry(queue, message, options?)` publikuje stejně jako `publish()`, ale po
533
+ druhém CONFIRM kanálu s rolí `telemetry`. Ten vzniká až s první takovou publikací (u
534
+ klienta, který žádnou neposílá, nikdy) a `disconnect()` ho zavírá spolu s ostatními.
535
+ `getChannelState()` ho hlásí jako čtvrtý klíč `telemetry` (d.1007, výše).
536
+ - Kanál se vybírá výslovně, nikdy podle jména fronty.
537
+ - `publishToMonitoringResilient()` (a `publishToMonitoringWorkflow()` /
538
+ `publishToMonitoringServices()`, které ho volají) publikuje přes
539
+ `mqClient.publishTelemetry()`. Klienta, který tu metodu nemá, ohlásí `warn`
540
+ (`Monitoring publish skipped: the client has no publishTelemetry() …`) a vrátí `false`,
541
+ místo aby publikoval po obchodním kanálu. `BaseClient` i jeho podtřídy (např.
542
+ `ConnectorMQClient` z `@onlineapps/conn-infra-mq`) ji dědí.
543
+
544
+ ### Added — pozdní odmítnutí od brokeru se ohlásí: `warn` a událost `publish:refused-late` (d.801b)
545
+
546
+ Když `brokerAnswerTimeout` odpoví volajícímu dřív než broker, callback publishe běží dál
547
+ a sestaví správný verdikt — a dřív ho předal už vyřízené promise, kde zmizel. Nově se
548
+ takový verdikt ohlásí: `warn` v tvaru `[Context] Problem - Expected/Fix` (cíl, kód brokeru,
549
+ třída verdiktu; `Fix:` říká, že vrstva publishe zprávu brala jako nepotvrzenou, a nabízí
550
+ zvednout strop nad změřenou latenci) a událost `publish:refused-late` s `queue`,
551
+ `exchange`, `target`, `kind`, `code`, `verdict`, `error` (verdikt jako objekt), `attempt`
552
+ a `budgetMs`. Strop ani výsledek promise se tím nemění; podle události jedná `PublishLayer`
553
+ (d.801c, výše).
554
+
555
+ ### Fixed — `_ensureConsumerChannel()` hlásí změřené počty a obnova s nepřipojeným konzumentem není „✓" (d.1003d)
556
+
557
+ - **Počty**: nad kanálem, který už žil (i po obnově, která ho vytvořila), vracela metoda
558
+ `{ reRegistered: <počet sledovaných>, failed: 0 }` — konzument, jehož re-registraci broker
559
+ odmítl (zůstává sledovaný bez tagu, d.262 a d.386b), se počítal jako re-registrovaný.
560
+ Nově se počítá podle `_isConsumerAttached()`: `reRegistered` = připojení, `failed` =
561
+ sledovaní bez připojení. Tvar výsledku beze změny; v produkčním kódu ho nikdo nečte
562
+ (`consume()` a handler zavření kanálu ho zahazují), čtou ho testy.
563
+ - **Souhrn obnovy**: `✓ Consumer channel recreated (re-registered: N, failed: M)` stál na
564
+ INFO se značkou ✓ i při `failed > 0`. Nově je to `warn` bez ✓, který jmenuje fronty bez
565
+ konzumenta; úspěšná obnova má dál ✓ na INFO. Odmítnutí jednotlivé fronty zůstává řádkem
566
+ `error` jako dosud.
567
+ - **Souhrn obnovy spojení** (d.1003e): reconnect logoval `✓ Consumer channel recreated` na INFO
568
+ bez počtů i při `failed > 0`. Totéž pravidlo jako výš — při `failed > 0` `warn` bez ✓ se jmény
569
+ front, jinak ✓ na INFO nově i s počty.
570
+
571
+ ### Fixed — handler zavření frontového kanálu trvalé selhání obnovy nespolkne (d.1003c)
572
+
573
+ Handler `'close'` frontového kanálu logoval selhání okamžité obnovy vždy jen jako `warn`,
574
+ přechodné i trvalé. Nově čte totéž pravidlo jako send a consumer kanál
575
+ (`_connectionIsGoingAway()` nad `cause` chyby z `_ensureQueueChannel()`): spojení, které
576
+ odchází, zůstává `warn`; cokoli jiného jde řádkem `error` do logu a na kanál `error` klienta.
577
+ Líná obnova se nemění — kanál zůstane prázdný a příští operace nad frontou
578
+ (`_requireQueueChannel()`) ho vytvoří jako dosud. Nová událost ani nový tvar chyby nevznikl.
579
+
580
+ ### Fixed — handler zavření obnovuje kanál té role, která zemřela, a consumer kanál už trvalé selhání obnovy nespolkne (d.1003b)
581
+
582
+ - **Role send kanálu**: handler zavření visí na publisher i telemetry kanálu, ale obnovu
583
+ volal bez role — po smrti telemetry kanálu „obnovil" živý publisher (nic) a telemetry
584
+ zůstal prázdný až do příští události. Handler teď nese roli kanálu, na kterém visí, a
585
+ obnoví tu. Smrt publisher kanálu se chová jako dosud.
586
+ - **Consumer kanál**: jeho handler zavření polykal každé selhání obnovy jako `warn`.
587
+ Přechodné (spojení odchází) zůstává `warn`; jakékoli jiné jde na kanál `error` klienta
588
+ a řádkem `error` do logu — konzumenti už tiše nepřestanou dostávat zprávy.
589
+ - **Jedno pravidlo** „odmítnuté otevření kanálu je přechodné" (`IllegalOperationError`,
590
+ nebo spojení, které už ohlásilo `'close'`) má jedno tělo, `_connectionIsGoingAway()`,
591
+ a čtou ho obnova send kanálu i handler consumer kanálu. Nová událost ani nový tvar chyby
592
+ nevznikl.
593
+
594
+ ### Fixed — obnova send kanálu: spojení, které spadlo uprostřed otevírání, je přechodné; trvalé selhání obnovy už handler zavření nespolkne (d.1003)
595
+
596
+ - **`_ensurePublisherChannel()`**: spadne-li spojení UPROSTŘED `createConfirmChannel()`,
597
+ amqplib odmítne čekající RPC holým `Error('Channel ended, no reply will be forthcoming')` —
598
+ bez třídy i `code` — a obnova z něj dělala `PermanentPublishError`, takže PublishLayer
599
+ zprávu zahodil místo aby ji podržel do reconnectu. Nově rozhoduje i STAV: odmítnutí, které
600
+ dorazí, když `_connectionAlive` už říká, že spojení je pryč, je `TransientPublishError`.
601
+ Změřeno nad živým brokerem: zavření spojení brokerem během otevírání dalo tuto chybu 8× z 12,
602
+ pokaždé až po ohlášeném `'close'`. Táž věta nad živým spojením zůstává trvalá — text dál
603
+ nerozhoduje.
604
+ - **Handler zavření publisher kanálu**: selhání obnovy kanálu polykal celé jako `warn`.
605
+ Přechodné (`TransientPublishError`) zůstává `warn` — příští publish kanál po reconnectu
606
+ obnoví; jakékoli jiné jde na kanál `error` klienta (kolej nehlášeného selhání, jako
607
+ v `_reportRecoveryFailure()`) a řádkem `error` do logu. Nová událost ani nový tvar chyby
608
+ nevznikl.
609
+
610
+ ### Fixed — `getBufferState()` hlásí změřené číslo, ne vymyšlenou nulu (d.645)
611
+
612
+ Buffer je jeden a `MessageBuffer.size()` JE `_inMemory.size()` (o dva řádky
613
+ vedle, `src/buffer/MessageBuffer.js`). Hlásící strana přesto brala druhé číslo
614
+ ze soukromého členu toho soukromého členu, za dvěma operátory, které dohromady
615
+ uměly jen zakrývat:
616
+
617
+ inMemory: this._publishLayer._buffer._inMemory?.size() || 0
618
+
619
+ `?.` nemůže zafungovat — konstruktor `MessageBuffer` staví `_inMemory` vždy a
620
+ řádek nad tím volá `_buffer.size()` bez ochrany, takže chybějící buffer spadne
621
+ dřív, než se k výrazu dojde. Co ta dvojice ale uměla vyrobit pro buffer, jehož
622
+ vnitřek není ten předpokládaný, je `inMemory: 0` vedle nenulového `size`:
623
+ „drženo, ale nikde" — stav, ve kterém žádný skutečný buffer být nemůže, ohlášený
624
+ bez hlásky a pro čtenáře nerozeznatelný od prázdného bufferu
625
+ (`architecture-principles.md` §3 No Fallbacks, §8 Explicit Over Implicit;
626
+ `automation-gates.md` §5 mlčení je vada).
627
+
628
+ Nově obě čísla pocházejí z jednoho čtení veřejného `size()`. Tvar odpovědi ani
629
+ hodnoty pro skutečný buffer se nemění — `{ size, inMemory }` dál, stejná čísla —
630
+ takže žádný čtenář se nepřepisuje.
631
+
632
+ Doklad: jednotkový tier RED→GREEN nad bufferem, který na `size()` odpoví 2 a
633
+ `_inMemory` nemá (dřív `{ size: 2, inMemory: 0 }`, nově `{ size: 2, inMemory: 2 }`),
634
+ s kontrolními případy nad skutečným bufferem (držená zpráva 1, prázdný 0).
635
+ Integrační tier drží zprávu ve skutečném `InMemoryBuffer` na klientu živého
636
+ brokeru a hlášené číslo měří proti tomu, co broker po flushi opravdu převzal; je
637
+ zelený na obou stranách téhle dávky a říká to ve své hlavičce — živý běh tenhle
638
+ rozdíl vyrobit neumí, protože skutečný `MessageBuffer` `_inMemory` vždycky má.
639
+
640
+ ### Fixed — o chybě rozhoduje třída a kód, ne text hlášky (d.676, d.677, d.683, d.685c, d.696, d.698, d.711, d.1000)
641
+
642
+ Kontrakt `docs/biz/70-contracts/error-handling.md` §6: větvit podle `err.code`, nikdy podle
643
+ znění. Přeformulovaná věta dosud tiše měnila osud zprávy. Tyto větve nově rozhodují třídou
644
+ a kódem:
645
+
646
+ - **`classifyPublishError()`** (d.677, d.683): už otypovaná chyba projde beze změny;
647
+ `code === 404` → `QueueNotFoundError` se jménem od volajícího; `IllegalOperationError`
648
+ z amqplib, `ConnectionError` a `PublishError` → `TransientPublishError`; všechno ostatní
649
+ `PermanentPublishError`. Textová větev nezbyla ani jedna: holé chyby amqplib
650
+ (`'channel closed'`, `'Channel ended, no reply will be forthcoming'`) typuje místo, kde
651
+ přijdou — výměnová noha `_publishOnce()` odpoví na mrtvém kanálu vlastním `PublishError`
652
+ s původní chybou v `cause`, předkontrola fronty odliší očíslované odmítnutí (např. 405
653
+ RESOURCE_LOCKED — trvalé) od bezkódové chyby na mrtvém kanálu (přechodná). Mění se dva
654
+ tvary, které dřív rozhodovala náhoda: chyba se správnou třídou a jiným zněním dostane
655
+ verdikt podle třídy; cizí chyba, jejíž věta obsahuje některé z dřívějších klíčových slov,
656
+ je trvalá.
657
+ - **`isQueueUnavailableError()`** (d.676): prochází řetěz `cause` (nejvýš 8 článků,
658
+ smyčka na sebe sama se zastaví) a ptá se jen na `QueueNotFoundError`, `ConnectionError`,
659
+ `TransientPublishError`, `code === 404` a soketové kódy `ECONNREFUSED` / `ENOTFOUND` /
660
+ `ETIMEDOUT`. Mění se tři vstupy: fronta bez vlastníka (`kind: 'unowned'`) a publikace po
661
+ vyčerpání pokusů jsou nově `true`, chyba, která `404`/`NOT_FOUND` jen zmiňuje v textu, je
662
+ nově `false`.
663
+ - **Synchronní výjimka odeslání** v `_publishOnce()` (d.685c): o tom, zda počkat na
664
+ reconnect a zprávu poslat znovu, rozhoduje `_isChannelAlive()` kanálu, který odesílal,
665
+ ne slova `'Channel ended'` / `'Channel closed'`. Chyba, která ta slova jen obsahuje,
666
+ vyhozená na živém kanálu, se už znovu neposílá, ale vrací volajícímu.
667
+ - **Vypořádání a zavírání** (d.696, d.698): automatický ack v `consume()`, veřejné `ack()`
668
+ a `nack()` poznají zavřený či zavírající se kanál podle `instanceof IllegalOperationError`;
669
+ handler publisher kanálu pozná odmítnutí podle `code === AMQP_PRECONDITION_FAILED` (406,
670
+ nová konstanta v `utils/publishErrors.js` vedle `AMQP_NOT_FOUND`); `disconnect()` bere
671
+ odmítnuté zavření spojení za očekávané jen při `IllegalOperationError` BEZ zapsané
672
+ poruchy (`connection._closeError`, nově zapisované stejně jako u kanálů); obnova
673
+ frontového kanálu čte `err.cause instanceof IllegalOperationError`. Chyba, která větu
674
+ `'Connection closed (by client)'` jen cituje, už se nespolkne jako čekaná. Nedosažitelná
675
+ větev confirm callbacku („unknown delivery tag" / `PRECONDITION_FAILED` → čekat na
676
+ reconnect a publikovat znovu) je smazaná. `_isBrokerRefusal()` textovou půlku drží
677
+ a komentář říká proč: odmítnutý handshake (403 při špatném hesle) přijde jako holý
678
+ `Error` bez `code`.
679
+ - **Kód 404 má jednoho vlastníka** (d.711): `monitoring-publish.js` a
680
+ `transports/rabbitmqClient.js` čtou `AMQP_NOT_FOUND` z `utils/publishErrors.js` místo
681
+ vlastní konstanty a holé `404`. Chování beze změny.
682
+ - **Obnova send kanálu** (d.1000) — poslední textová větev publikační klasifikace:
683
+ `_ensurePublisherChannel()` dávala `TransientPublishError` podle slov `'Connection
684
+ closing'` / `'Connection closed'`, nově podle `instanceof IllegalOperationError`
685
+ (amqplib 0.10.9 tu třídu vydá z `createConfirmChannel()` nad zavírajícím i zavřeným
686
+ spojením, bez `code` — změřeno nad živým brokerem). Mění se dva tvary: jiná chyba, jejíž
687
+ věta ta slova jen obsahuje, je nově `PermanentPublishError`; `IllegalOperationError`
688
+ s jiným zněním (`'Channel closed'` nového kanálu, který zavřelo odcházející spojení) je
689
+ nově přechodná. Text v rozhodování zůstal jen v `_isBrokerRefusal()`, z důvodu, který
690
+ tam stojí (odmítnutý handshake nemá `code`).
691
+
692
+ ### Fixed — odmítnutá konfigurace transportu není nedostupný broker (d.736b)
693
+
694
+ `BaseClient.connect()` stavěl transport uvnitř `try`, jehož `catch` balil všechno do
695
+ `ConnectionError` „a reachable broker … check the MQ url, credentials and network" —
696
+ i odmítnutí, která vydal konstruktor transportu, tedy vady konfigurace (cíl, který není
697
+ neprázdný řetězec, URL s vlastním `heartbeat`, `onFatal`, který není funkce, nepodporovaný
698
+ `type`). `ServiceWrapper._initializeMQ()` takovou chybu četl jako přechodnou a opakoval
699
+ `connect()` podle `wrapper.mq.connectionRetries` (~200 s čekání). Stavba transportu je nově
700
+ před `try` a odmítnutí projde tak, jak je — se svou třídou a hláškou, která jmenuje klíč
701
+ i opravu. `ConnectionError` zůstává tomu, co se stalo na spojení (ECONNREFUSED; odmítnutí
702
+ brokerem dál nese `MQ_CONNECTION_FATAL` / `broker-refused`).
703
+
704
+ ### Fixed — čítač zpráv v letu je svázaný s doručením (d.680)
705
+
706
+ Produkce 20. 9. (dispatcher): `Prefetch utilization high: queue 'workflow.completed' has
707
+ 43/5 messages in-flight (860%)` a číslo rostlo s každým workflow. Snížení čítače sedělo ve
708
+ větvích, které zprávu vypořádají přímo v `consume()`; handler, který si zprávu vypořádá
709
+ sám (`ack()`/`nack()`), konzument s `noAck` a předčasné návraty po smrti kanálu nechaly
710
+ zvýšení stát. Nově jedno zvýšení na začátku callbacku doručení a jedno snížení v jeho
711
+ `finally`. Veřejné API ani tvar události `prefetch:high-utilization` se nemění.
712
+
713
+ ### Fixed — každý časovač klienta je v jedné evidenci `_activeTimers` (d.759e)
714
+
715
+ Opakující se časovače health a prefetch monitoringu vznikaly holým `setInterval`, mimo
716
+ evidenci, kterou `_clearAllTimers()` při teardownu smete a kterou sady tvrdí
717
+ (`_activeTimers.size`). Nově jdou přes `_setInterval()` / `_clearInterval()`;
718
+ `_clearAllTimers()` nejdřív zastaví oba monitory (aby jejich reference nezůstaly ukazovat
719
+ na mrtvý handle a příští start je neodbyl jako „already started"). `connect()` po
720
+ neúspěšném spojení ruší svůj časovač `_clearTimeout()`, takže v evidenci nezůstane handle,
721
+ který už nic nehlídá.
722
+
723
+ ### Tests — komentáře v `src/**` k sadám publishe a prefetch (d.738, d.766)
724
+
725
+ `layers/PublishLayer.js` říká, proč spánek mezi pokusy publishe je holý `setTimeout`
726
+ a ne `_setTimeout()` transportu (čeká `publish()` volajícího, ne smyčka klienta, a
727
+ teardown ho ukončit nemá), se strážní sadou (d.766); `transports/rabbitmqClient.js`
728
+ popisuje, co po d.736a zbývá říct periodické kontrole prefetch (d.738, spolu s převodem tří
729
+ integračních sad na čekací kolej tieru). Chování beze změny.
730
+
731
+ ### Documentation — komentáře v `src/**` bez čísel řádků a ručních verzí (d.646, d.668, d.981)
732
+
733
+ Hlavička `src/index.js` už neuvádí verzi ručně (d.646); komentáře `config/queueConfig.js`
734
+ už netvrdí, že `initInfrastructureQueues()` odvozuje seznam front ze sekcí (od d.668
735
+ vyžaduje `queues`); čísla řádků v komentářích `src/**` nahradily symboly (d.981). Chování
736
+ beze změny.
737
+
738
+ ### Documentation — README: routing key telemetrie má tvar `telemetry.<signal>.<service_name>` (d.994b)
739
+
740
+ Řádek `telemetryLogsQueue` v tabulce `topology` tvrdil, že služby publikují na
741
+ `telemetry.exchange` „se svým jménem jako routing key". Změřeno: `monitoring-core` publikuje
742
+ logy pod `telemetry.logs.<serviceName>` (`src/logger.js`) a exportér pod
743
+ `telemetry.<type>.<service_name>` (`src/exporters/rabbitmq.js`), monitoring konzument váže
744
+ fronty vzorem `telemetry.<type>.*`. Pravdu měl komentář exportu v `src/index.js`; opravena je
745
+ věta v README.
746
+
747
+ ### Changed — tarball už nenese `jest.config.js` ani `jest.integration.config.js` (d.1020)
748
+
749
+ - `.npmignore` je vylučuje. Spouštějí jen sady balíčku (`tests/` se nebalí) a po instalaci je nic nečte;
750
+ `npm pack --dry-run` ubral právě tyto dva soubory.
751
+
7
752
  ## [4.2.0] — 2026-09-25
8
753
 
9
754
  ### Added — služba deklaruje své rekurence a mluví s plánovačem jednou kolejí (d.833, d.840)
@@ -94,6 +839,7 @@ Při té příležitosti se rozšířil tvar `id` v záznamu rekurence: schéma
94
839
  na malá písmena a pomlčky, což je přísnější než konzument (jakýkoli řetězec 1–255 znaků) a
95
840
  odmítalo by to konvenci prvního volajícího `<source_uuid>:<cookbook_ref>`. Nově se odmítá
96
841
  jen bílé místo, které by z klíče udělalo větu.
842
+
97
843
  ### Changed — port `registerRecurring()` klíč, který požadavek nedeklaruje, odmítne (d.864b)
98
844
 
99
845
  `createSchedulerPublisher(…).registerRecurring()` bral jen pojmenované klíče a cokoli
@@ -224,6 +970,28 @@ deklarace není pokyn k vytvoření — zakládá ji dál vlastník prefixu). Pr
224
970
  z toho plyne, že povinný klíč `MONITORING_RESOURCE_EXCHANGE` může po re-pinu
225
971
  zmizet stejně jako pět jmen před ním.
226
972
 
973
+ ### Added — výměna `telemetry.exchange` má šablonu v `queueConfig` (d.708) — doplněno 27. 9. 2026, změna vyšla bez záznamu
974
+
975
+ `telemetry.exchange` žila v `src/config/queueConfig.js` jen v komentáři, zatímco jméno mělo
976
+ tři vlastníky: konstantu v monitoring konzumentu (`infra/api_monitoring/src/consumer/topology.js`)
977
+ a výchozí hodnotu v obou publikátorech `@onlineapps/monitoring-core`. Sekce `telemetry` nese
978
+ nově záznam `exchange` = `{ type: 'exchange', exchangeType: 'topic', durable: true }`, bez
979
+ argumentů a bez dead-letter cesty; jméno se skládá `queueConfig.queueName('telemetry',
980
+ 'exchange')`. Typ a trvanlivost jsou změřené na všech zakládajících stranách, které volaly
981
+ `assertExchange(<jméno>, 'topic', { durable: true })` — broker obě vlastnosti porovnává při
982
+ každé deklaraci, jiná hodnota by byla 406 PRECONDITION-FAILED. Klasifikace prefixu
983
+ `telemetry.` se nemění; producenty ani konzumenta tahle změna nepřepíná. Veřejný export
984
+ jména (`topology.telemetryExchange`) přišel až po vydání — viz `[Unreleased]`, d.708b.
985
+
986
+ ### Changed — šablona výměny `registry.changes` nese typ `fanout` (d.712) — doplněno 27. 9. 2026, změna vyšla bez záznamu
987
+
988
+ `registry.changes` byla jediná výměna v `queueConfig` bez `exchangeType`, takže z ní
989
+ zakladatel deklarovat nemohl — typ je jedna ze dvou vlastností, které broker porovnává při
990
+ každém `assertExchange`. Šablona nově nese `exchangeType: 'fanout'`, hodnotu změřenou
991
+ u jediného zakladatele (`@onlineapps/conn-orch-registry`, `src/registryEventConsumer.js`:
992
+ `assertExchange(<jméno>, 'fanout', { durable: true })`, binding vzorem `''`). Argumenty ani
993
+ dead-letter cestu výměna nezískává; zakladatele změna nepřepíná.
994
+
227
995
  ## [4.0.1] — 2026-09-17
228
996
 
229
997
  ### Fixed — zrušení konzumenta brokerem přestalo být tiché (d.605)