@onlineapps/mq-client-core 4.0.1 → 4.2.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,226 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [4.2.0] — 2026-09-25
8
+
9
+ ### Added — služba deklaruje své rekurence a mluví s plánovačem jednou kolejí (d.833, d.840)
10
+
11
+ `docs/architecture/scheduler.md` §7 dává klientské knihovně tvar jedné rekurence
12
+ a wrapperu úkol při každém bootu ten stav znovu ohlásit. Odkud služba rekurence bere,
13
+ je její věc — odpovídá jedním hookem (`scheduler: { recurrences: async () => [...] }`),
14
+ platformní konstantním seznamem, tenantové odvozené z vlastních dat (conf `scheduler` 011).
15
+ Knihovna nese tvar a cestu ke zprávě:
16
+ `scheduler.validateRecurrences(document, source)`,
17
+ `scheduler.buildRecurringRequest({ declaration, tenantId, workspaceId, requestedBy })`,
18
+ `scheduler.publishSchedulerRequest(mqClient, request)` a jméno fronty
19
+ `scheduler.requestQueue` (složené z deklarace v `queueConfig`, ne opsané).
20
+
21
+ Záznam rekurence má u knihovny dvě podoby, obě jsou jejím vstupem:
22
+
23
+ - **záznam deklarace** pro `validateRecurrences()` a `buildRecurringRequest({ declaration, … })`
24
+ ve snake_case, jak ho drží schéma `src/config/recurrencesSchema.js`:
25
+ `{ id, cookbook_ref, cron, timezone, input?, catch_up? }` (první čtyři povinné,
26
+ `additionalProperties: false`); tenancy v něm není — stavitel ji bere zvlášť jako
27
+ `tenantId` a `workspaceId`;
28
+ - **záznam portu** `createSchedulerPublisher({ mqClient, requestedBy }).registerRecurring(…)`
29
+ v camelCase: `{ id, cookbookRef, cron, timezone, tenantId, workspaceId, input?, catchUp? }`.
30
+ Port ho převede na záznam deklarace a tenancy předá staviteli.
31
+
32
+ `id` cestuje jako `idempotency_key`, takže re-registrace je upsert a každý boot smí
33
+ deklarovaný stav zopakovat. Dvě stejná `id` v jedné odpovědi jsou odmítnuta — tiše by se
34
+ přepisovala. `source` u `validateRecurrences` je povinný: každá hláška ho cituje, takže
35
+ výchozí hodnota by psala jméno jednoho volajícího k chybě druhého.
36
+
37
+ **Tenant a workspace dodává VOLAJÍCÍ u každé rekurence — nikdy prostředí, nikdy výchozí
38
+ hodnota.** Obojí je povinné a ≥ 1 (vlastník zamítl `0/0` jako platformní scope —
39
+ konfirmace `scheduler` 002); chybí-li, stavitel hází jménem klíče. Rekurence služba odvozuje
40
+ ze svých dat (conf `scheduler` 010) a hlásí je jedinou kolejí (011), takže tenancy nese
41
+ každý záznam: boot hook wrapperu (FÁZE 0.7b) ji u každého vráceného záznamu VYŽADUJE, protože
42
+ při bootu žádná obálka invokace není; port `ctx.scheduler` v handleru ji naopak doplní
43
+ z obálky invokace a od volajícího ji odmítne. `catch_up` je klíč záznamu portu a požadavku,
44
+ ne deklarace, kterou si služba drží ve svých datech (conf `scheduler` 012 a 018).
45
+
46
+ **Fixed — hlášky o chybějící tenancy posílaly čtenáře na špatné místo (d.864).** Stavitel
47
+ rekurence i události odmítal chybějící `tenantId` s Fix „pass the tenant the environment
48
+ declares for this service" a chybějící `workspaceId` s Fix „pass the workspace the cookbook
49
+ declares (defaults.workspace_id)". Ani jedno není zdroj, ze kterého hodnota na téhle cestě
50
+ jde: Fix teď říká, že se obě hodnoty podávají s každým požadavkem — rekurence je nese
51
+ v záznamu, který služba vrací ze svých dat, a `ctx.scheduler` je doplní z obálky invokace;
52
+ odjinud se nečtou. Chování se nemění, mění se jen to, kam hláška posílá. Totéž u odmítnutí
53
+ záznamu v `validateRecurrences()`: poznámka „tenant a workspace sem nepatří" citovala conf
54
+ `tier1-runner-workspace` 001, která mluví o jiném běhu; teď cituje conf `scheduler` 010 a 011
55
+ (d.864b).
56
+
57
+ **Prázdná odpověď je odpověď**, ne chyba: služba bez rozvrhů hook nemá, nebo vrátí prázdný
58
+ seznam, a wrapper svůj krok přeskočí. Odpověď, která NEOBSTOJÍ, hází — nezaregistrovaný
59
+ rozvrh je práce, která se nikdy nestane, a nic jiného na to neupozorní. Ze stejného důvodu
60
+ je odeslání **fail-fast**, na rozdíl od sousedních pomocníků monitoringu.
61
+
62
+ Zpráva se staví přesně podle kontraktu plánovače
63
+ (`api/infra/api_scheduler/contracts/scheduler.request.schema.json`, `additionalProperties:
64
+ false`) a sada to proti TOMU kontraktu ověřuje Ajvem, ne proti kopii schématu.
65
+
66
+ **d.840 — zbylé dva druhy a jedno odeslání.** Vedle `buildRecurringRequest()` přibyly
67
+ `scheduler.buildEventRequest({ event: { service, name }, idempotencyKey, tenantId,
68
+ workspaceId, requestedBy, input? })` a `scheduler.buildCancelRequest({ idempotencyKey,
69
+ requestedBy })`, a všechny tři druhy odesílá JEDNA funkce
70
+ `scheduler.publishSchedulerRequest(mqClient, request)` — druh je pole zprávy, ne druhá
71
+ funkce. (Nevydané `publishRecurringRequest` tím zaniká; čtenáře mimo balíček nemělo,
72
+ takže přechodový alias nevzniká — pravidlo 11.)
73
+
74
+ Událost NEJMENUJE recept: plánovač spustí každý recept, jehož `triggers` tu událost
75
+ deklarují (conf `scheduler` 004), takže producent, který by recept jmenoval, by mapoval
76
+ práci na něco, co nemusí vlastnit (conf `ingest-poll-trigger-rail` 001). `cancel` nenese
77
+ tenanta ani workspace — klíč jmenuje položku, která si kontext nese od registrace, a
78
+ zrušení neexistujícího klíče je výsledek (`cancel-nothing`), ne chyba.
79
+
80
+ **Klíč je vždy volajícího.** `idempotency_key` vyžaduje kořen kontraktu u všech druhů a
81
+ plánovač podle něj jednorázovou položku rozhodne JEDNOU (druhá událost s týmž klíčem =
82
+ `duplicate`), takže klíč vyrobený knihovnou by z každého opakování jednoho faktu udělal
83
+ druhý běh. Žádný stavitel ho proto nevymýšlí.
84
+
85
+ **Port `scheduler.createSchedulerPublisher({ mqClient, requestedBy })`** vrací tři slovesa
86
+ (`registerRecurring`, `publishEvent`, `cancel`) se jménem služby už rozhodnutým. Klient
87
+ i jméno fronty se ověřují PŘI KONSTRUKCI — služba, která ohlašuje fakty, má spadnout při
88
+ bootu, ne u prvního dokladu — a každé odmítnutí jmenuje slot a Fix, aby vada wiringu
89
+ neodpověděla místo operace. Tenancy zůstává na každém volání výslovná, protože port drží
90
+ dva různí volající: služba pod wrapperem (doplní ji z obálky běhu) a operátorský skript,
91
+ který wrapper vůbec nebootuje a pracuje řádek po řádku (měření BIZ-ingest 23. 9.).
92
+
93
+ Při té příležitosti se rozšířil tvar `id` v záznamu rekurence: schéma ho dosud omezovalo
94
+ na malá písmena a pomlčky, což je přísnější než konzument (jakýkoli řetězec 1–255 znaků) a
95
+ odmítalo by to konvenci prvního volajícího `<source_uuid>:<cookbook_ref>`. Nově se odmítá
96
+ jen bílé místo, které by z klíče udělalo větu.
97
+ ### Changed — port `registerRecurring()` klíč, který požadavek nedeklaruje, odmítne (d.864b)
98
+
99
+ `createSchedulerPublisher(…).registerRecurring()` bral jen pojmenované klíče a cokoli
100
+ navíc tiše zahodil — takže překlep (`catch_up` místo `catchUp`) prošel a rozvrh se
101
+ zaregistroval bez toho, co volající chtěl. Port je teď stejně přísný jako záznam
102
+ deklarace, na který se převádí (`additionalProperties: false`): klíč mimo
103
+ `{ id, cookbookRef, cron, timezone, tenantId, workspaceId, input?, catchUp? }` je odmítnutí,
104
+ které ho jmenuje. Port je ve vydání nový, takže to žádného volajícího vydané verze nerozbije;
105
+ změřeno, že dnešní volající (boot hook a `ctx.scheduler` wrapperu, `declaredRecurrences`
106
+ ingestu) klíč navíc neposílají.
107
+
108
+ ### Added — výměna `dlx` je v `queueConfig` deklarovaná i s typem a trvanlivostí (d.902)
109
+
110
+ `deadLetterExchange()` vracel jen jméno `dlx`; typ `direct` a `durable: true` žily jen
111
+ u zakladatele (`@onlineapps/conn-infra-mq` `QueueManager.setupServiceQueues()`), takže
112
+ CI nástroj INFRA `scripts/ci/declare-broker-exchanges.mjs` je nesl jako opsanou druhou
113
+ kopii (TODO INFRA 349). Nová sekce `deadLetter` deklaruje `dlx` stejným tvarem jako každá
114
+ jiná výměna (`type: 'exchange'`, `exchangeType: 'direct'`, `durable: true`), a protože
115
+ jméno na drátě prefix nemá, `SECTION_PREFIXES.deadLetter` je `''` — deklarovaný případ,
116
+ který `queueName()` čte výslovně (`queueName('deadLetter', 'dlx') === 'dlx'`); sekce bez
117
+ deklarovaného prefixu je dál odmítnuta toutéž hláškou. `deadLetterExchange()` jméno skládá
118
+ z téže položky. Seznam front (`infrastructurePrefixes()`, vstupy `initInfrastructureQueues`)
119
+ se nemění — změřeno před/po; počet deklarovaných položek 36 → 37, výměn 6 → 7, front 30.
120
+
121
+ ### Fixed — handshake, který doběhne po `connectTimeout`, se zavře; nezůstane spojení bez vlastníka (INFRA 359, d.917)
122
+
123
+ `connect()` i `_reconnectWithBackoff()` (`src/transports/rabbitmqClient.js`) závodily
124
+ `amqp.connect()` s časovačem `connectTimeout`. Když vyhrál časovač, volající dostal chybu
125
+ timeoutu, ale handshake běžel dál a mohl doběhnout do úplného AMQP spojení, na které
126
+ žádné pole klienta neukazovalo — `disconnect()` ho tedy nezavřel, spojení viselo na
127
+ brokeru a proces neskončil (INFRA 359, změřeno mimo jest: mlčící TCP server a proxy
128
+ zdržující handshake o 11 s proti `connectTimeout` 10 000 ms). Obě místa teď závodí
129
+ jedním pomocníkem `_raceHandshake()`: opuštěný handshake se po doběhnutí zavře (varování
130
+ v logu), handshake, který po timeoutu selže, nic neotevřel. Kontrakt se nemění —
131
+ `connect()` odmítne timeoutem stejnou chybou jako dřív a včas doběhlé spojení zůstává
132
+ spojením klienta.
133
+
134
+ ### Changed — TTL telemetrie 60 s → 300 s (conf telemetry-queue-ttl 001, INFRA 356)
135
+
136
+ `x-message-ttl` tří front `telemetry.logs.queue`, `telemetry.metrics.queue` a
137
+ `telemetry.traces.queue` (`src/config/queueConfig.js`, sekce `telemetry`) je 300000 místo
138
+ 60000 — rozhodnutí vlastníka, varianta B (conf `telemetry-queue-ttl` 001). Proč: konzument
139
+ telemetrie stojí v okně deploye (stop před migracemi, `up -d` až po nich); při prvním
140
+ produkčním deployi `f03fad7c` bez něj byl 159 s a každá zpráva starší než 60 s expirovala
141
+ do `telemetry.dlq` (1414, všechny `x-death.reason = expired`), takže brána 9b dala NO-GO na
142
+ `dlqMaxMessages` 100 za ztrátu, kterou způsobil sám deploy. 300 s pokryje okno deploye.
143
+ Nemění se: `x-max-length` 100000 (paměťový strop), `telemetry.dlq` (`x-max-length` 50000,
144
+ bez TTL), dead-letter cesta ani práh brány 9b; fronty `delivery.*` a další s TTL 60 s
145
+ zůstávají. Provozní důsledek: fronta existující na brokeru s `x-message-ttl` 60000 dá při
146
+ bootu konzumenta na 4.2.0 406 `PRECONDITION_FAILED` (`initInfrastructureQueues` hází,
147
+ nikdy nemaže) — smazání a znovuvytvoření tří front v okně deploye je krok deploye INFRA
148
+ (conf `mq-consumer-contract` bod 4 a conf `telemetry-queue-ttl` 001 Conditions/limits).
149
+
150
+ ## [4.1.0] — 2026-09-23
151
+
152
+ ### Added — plánovač se dozví KONEC běhu z vlastní fronty (d.818)
153
+
154
+ `queueConfig.scheduler` nese třetí položku, `workflow.events`. Je to fronta,
155
+ kterou si plánovač sám naváže na existující výměnu `monitoring.workflow.fanout`
156
+ a čte si z ní terminální událost běhu (`completed` / `failed` / `discarded`) —
157
+ místo aby se ptal pozorovatele přes HTTP nebo četl databázi, která není jeho
158
+ (rozhodnutí vlastníka, `api/docs/governance/confirmations/scheduler.md` 007).
159
+ Žádný producent se nemění; od knihovny je jen deklarace.
160
+
161
+ TTL je **vyslovená, ne zděděná**: `x-message-ttl: 300000`, stejně jako u
162
+ sourozence `monitoring.workflow` na téže výměně. Fanout neumí filtrovat, takže
163
+ fronta dostane všechny workflow události platformy a plánovač většinu zahodí; co
164
+ stojí výpadek delší než TTL, je zapsané v 007 (události se ztratí a běh uzavře
165
+ per-schedule timeout, který zůstává pojistkou). Dál `x-max-length: 10000` a
166
+ dead-letter přes VÝCHOZÍ výměnu do `scheduler.dlq` — bez deklarované cesty by
167
+ `consume()` frontu vůbec nepřipojil (conf `mq-consumer-contract` 002).
168
+
169
+ Import: `queueConfig.queueName('scheduler', 'workflow.events')` složí jméno,
170
+ `queueConfig.declarationOptions('scheduler.workflow.events')` vrátí argumenty,
171
+ kterými ji služba asserteuje — druhá kopie těch čísel u služby by se s touhle
172
+ potkala jako 406 v den, kdy se rozejdou.
173
+
174
+ ### Added — `workflow_initiated` má jednu kolej: stavitel události vedle jejího přenosu (d.819)
175
+
176
+ `monitoring.buildWorkflowInitiatedEvent()` a `monitoring.publishWorkflowInitiated()`
177
+ — jediný stavitel události, ze které se monitoring dozví, že běh existuje (čte
178
+ z ní tenanta, štítky i spouštěč a teprve jí označí běh za `active`). Dosud ji
179
+ stavěly dvě služby, každá po svém; rozhodnutí vlastníka
180
+ (`api/docs/governance/confirmations/scheduler.md` 009) dalo kolej sem, do
181
+ knihovny, která už vlastní přenosovou půlku téhož drátu.
182
+
183
+ Vstup: `{ workflowId, redactedCookbook, serviceName, timestamp }`. Modul
184
+ **neredaguje** — co je v receptu citlivé, ví jedině spec služby v registru, a
185
+ číst ho by knihovně přidalo závislost na specech operací; volající předává
186
+ observability kopii, kterou si sám postavil. A **nic nevymýšlí**: každý klíč
187
+ `_system` musí existovat (`hasOwnProperty`, ne pravdivost), takže
188
+ `tenant_uuid: null` projde jako výslovná hodnota (conf
189
+ `run-envelope-tenant-uuid` 001) a chybějící klíč je hlášená chyba, ne tiché
190
+ `null`.
191
+
192
+ Publikace jde stávající `publishToMonitoringWorkflow`, tedy fail-safe: ztracená
193
+ kopie se hlásí, běh se kvůli ní neodmítá. Jediná výjimka je chybějící logger —
194
+ bez něj by slib „každé selhání se ohlásí" neplatil.
195
+
196
+ Import: `const { monitoring } = require('@onlineapps/mq-client-core');`
197
+
198
+ ### Added — jméno `monitoring.resource` má vlastníka v jádru (d.619)
199
+
200
+ Topic exchange, po které jezdí události `resource.changed.*`, byla poslední
201
+ stranou téhle linky bez vlastníka jména: publikující strana ji nesla jako
202
+ konstantu modulu (`api/infra/api_monitoring/src/consumer/watchers/
203
+ resourceChangedPublisher.js`, `EXCHANGE_NAME`), bindující strana jako **povinný
204
+ env klíč** (`MONITORING_RESOURCE_EXCHANGE` v delivery-endpointu), zatímco fronta
205
+ na ni navázaná (`delivery.resource.events`) má vlastníka tady od d.270/d.278.
206
+ Jméno, na kterém se musí shodnout tři strany jedné linky, má mít jednoho
207
+ vlastníka — jinak jich má tolik, kolik je míst, která ho píší, a překlep nebo
208
+ override na jedné straně ztrácí události tiše (týž důvod jako u fanout výměny
209
+ `infrastructure.health.events`, d.270).
210
+
211
+ Nově: `queueConfig.monitoring.resource` deklaruje `type: 'exchange'`,
212
+ `exchangeType: 'topic'`, `durable: true` — tvar, který publikující strana
213
+ asserteuje a který vrací i běžící broker — a `topology.monitoringResourceExchange`
214
+ to jméno publikuje, složené z té deklarace (`queueConfig.queueName()`), ne
215
+ přepsané. Import: `const { topology } = require('@onlineapps/mq-client-core');`
216
+
217
+ Doklad proti realitě, ne proti sobě: integrační tier jméno ověřuje na živém
218
+ brokeru (`checkExchange`) a deklarovaný typ `assertExchange`em, s kontrolním
219
+ případem, že tatáž výměna asserteovaná jako `fanout` končí 406 — typ je tedy
220
+ změřený, ne předpokládaný.
221
+
222
+ Beze změny na drátě: žádné jméno se nepřejmenovává, výměna se nezakládá (
223
+ deklarace není pokyn k vytvoření — zakládá ji dál vlastník prefixu). Pro INFRA
224
+ z toho plyne, že povinný klíč `MONITORING_RESOURCE_EXCHANGE` může po re-pinu
225
+ zmizet stejně jako pět jmen před ním.
226
+
7
227
  ## [4.0.1] — 2026-09-17
8
228
 
9
229
  ### Fixed — zrušení konzumenta brokerem přestalo být tiché (d.605)
package/README.md CHANGED
@@ -619,8 +619,16 @@ queueConfig.isBusinessQueue('emailer.workflow'); // true
619
619
  queueConfig.getInfrastructureQueueConfig('workflow.init'); // { durable, arguments }
620
620
  queueConfig.getDeadLetterRoute('workflow.init'); // { exchange: '', routingKey: 'workflow.failed' }
621
621
  queueConfig.isDeadLetterTarget('workflow.failed'); // true — the end of that chain
622
+ queueConfig.deadLetterExchange(); // 'dlx' — composed from queueConfig.deadLetter
622
623
  ```
623
624
 
625
+ - The platform's dead-letter exchange is declared like every other exchange here —
626
+ `queueConfig.deadLetter.dlx` is `{ type: 'exchange', exchangeType: 'direct', durable: true }`,
627
+ the shape its founder (`@onlineapps/conn-infra-mq` `setupServiceQueues()`) asserts. Its wire
628
+ name carries no prefix: the `deadLetter` section declares an empty one, so
629
+ `queueName('deadLetter', 'dlx')` is `'dlx'`, and a reader walking the `type: 'exchange'`
630
+ entries finds it with the rest.
631
+
624
632
  - `workflow.failed` carries no `x-message-ttl`. It is read by an operator, not by a
625
633
  service, and it has no dead-letter exchange, so a TTL there does not move a message
626
634
  anywhere — it deletes it. See
@@ -673,6 +681,172 @@ topology.telemetryLogsQueue = 'mine'; // TypeError — the object is froz
673
681
  - A service that still carries one of these names as a private literal is carrying the
674
682
  spelling of a fact declared here; importing it changes the wire by nothing at all.
675
683
 
684
+ ### monitoring — `workflow_initiated`, the event a run is known by
685
+
686
+ Monitoring learns that a run exists from ONE event and from nowhere else: it reads the run's
687
+ tenancy, its labels and its trigger out of `workflow_initiated` and marks the run active on it.
688
+ A service that starts runs and does not file it starts runs that are invisible for their whole
689
+ life. So the event has one builder, here, beside the transport half that sends it
690
+ ([`scheduler` 009](/docs/governance/confirmations/scheduler.md)).
691
+
692
+ ```javascript
693
+ const { monitoring } = require('@onlineapps/mq-client-core');
694
+
695
+ await monitoring.publishWorkflowInitiated({
696
+ mqClient,
697
+ logger,
698
+ workflowId,
699
+ redactedCookbook, // already redacted by the caller
700
+ serviceName: 'api_gateway', // who files the event
701
+ timestamp: startedAt, // the run's instant, not a second reading of the clock
702
+ context: { correlationId } // optional, for the log lines
703
+ });
704
+ ```
705
+
706
+ `monitoring.buildWorkflowInitiatedEvent({ workflowId, redactedCookbook, serviceName, timestamp })`
707
+ composes the body alone, so a suite can state it without a broker. It carries exactly these ten
708
+ keys: `event_type` (`initiated`), `workflow_id`, `service_name`, `step_index` (`-1`), `step_id`
709
+ (`workflow_start`), `cookbook`, `status` (`initiated`), `tenant_uuid`, `tenant_id`, `timestamp`.
710
+
711
+ What it does NOT do, and both halves are rules rather than omissions:
712
+
713
+ - **It does not redact.** What is sensitive in a cookbook is known only to the service spec in
714
+ the registry, and reading that spec would give this package a dependency on the operation
715
+ specs. The caller hands over the observability copy it built; a path that cannot redact does
716
+ not file the event at all, because an empty set of sensitive fields is silent consent to an
717
+ unredacted copy.
718
+ - **It invents no value.** `service_name` and the instant are the caller's, and every key it
719
+ reads out of `_system` must EXIST — `hasOwnProperty`, not truthiness. `tenant_uuid: null`
720
+ therefore passes through as the explicit value it is (a webhook run proves itself by
721
+ signature and has no personal identity), while a MISSING `_system.tenant_id` or
722
+ `_system.tenant_uuid` is a reported error. A `|| null` here would make the two cases
723
+ identical and hide a broken envelope.
724
+
725
+ `publishWorkflowInitiated()` is **fail-safe**: it never throws. It is observability about a run
726
+ that has already happened, so refusing the run because the observer could not be told would
727
+ trade a real effect for a record of it — a lost copy is reported through the logger and the call
728
+ answers `false`. The one exception is a missing logger, which is the transport half's rule:
729
+ without it the promise "every failure is reported" cannot hold.
730
+
731
+ **The two builders of this package fail differently, on purpose.** This one answers `false` and
732
+ reports; `scheduler.publishSchedulerRequest()` below throws. A lost monitoring copy is a missing
733
+ record of work that happened; a lost registration is work that will never happen, and nothing
734
+ rings for it.
735
+
736
+ ### scheduler — what a service says to the platform's owner of time
737
+
738
+ A service that runs work on a schedule ANSWERS with the recurrences it owns, through one hook
739
+ the wrapper asks at boot, and the wrapper re-asserts each of them as a `scheduler.request` of
740
+ kind `recurring` ([scheduler.md §7](/docs/architecture/scheduler.md)). Re-registration is an
741
+ upsert — a recurring request's `idempotency_key` IS the schedule id — so a boot that re-asserts
742
+ the declared state costs nothing and drifts nowhere.
743
+
744
+ ONE RAIL FOR BOTH KINDS (confirmation `scheduler` 011): the hook answers a constant list for
745
+ platform recurrences and a list derived from the service's own data for tenant ones. This
746
+ package owns the SHAPE of a record and the message built from it — never where the record comes
747
+ from, which is the service's business.
748
+
749
+ ```javascript
750
+ // In the service, the hook the wrapper asks at boot (FÁZE 0.7b). Every record carries its own
751
+ // tenancy, derived from the service's data — at boot there is no invocation envelope to take
752
+ // it from (confirmations `scheduler` 002, 010):
753
+ new ServiceWrapper({
754
+ scheduler: {
755
+ recurrences: async () => ([
756
+ { id: '31bf8bac:servant-poll', cookbookRef: 'servant-poll', cron: '0 * * * *',
757
+ timezone: 'Europe/Prague', tenantId: 101, workspaceId: 1, input: { source_uuid: '31bf8bac' } }
758
+ ])
759
+ }
760
+ });
761
+
762
+ // What the wrapper does with the answer — the port this package builds:
763
+ const { scheduler } = require('@onlineapps/mq-client-core');
764
+ const port = scheduler.createSchedulerPublisher({ mqClient, requestedBy: serviceName });
765
+ for (const record of answered) await port.registerRecurring(record);
766
+ ```
767
+
768
+ The port record is camelCase — `{ id, cookbookRef, cron, timezone, tenantId, workspaceId,
769
+ input?, catchUp? }` — and the port turns it into the declaration record below, which is what
770
+ `validateRecurrences()` and `buildRecurringRequest({ declaration, tenantId, workspaceId,
771
+ requestedBy })` take. One declaration record, and nothing else:
772
+
773
+ | Key | Required | Meaning |
774
+ |---|---|---|
775
+ | `id` | ✅ | The schedule's stable id; it travels as `idempotency_key`, so two records of one id are refused |
776
+ | `cookbook_ref` | ✅ | The cookbook to run. Its `triggers` must name this service and the kind `recurring`, or the scheduler refuses the request |
777
+ | `cron` | ✅ | Five fields, the same expression the scheduler's contract takes |
778
+ | `timezone` | ✅ | An explicit IANA zone; there is no implicit one |
779
+ | `input` | – | Handed to the cookbook unchanged — a scheduled run that writes says so in the record, while the same cookbook run by hand can be given a different input |
780
+ | `catch_up` | – | Absent means false, and the scheduler materialises that default |
781
+
782
+ - **Tenancy is the caller's, on every recurrence — never the environment, never a default.**
783
+ Both ids are mandatory and ≥ 1 (the owner rejected `0/0` as a platform scope, confirmation
784
+ `scheduler` 002), and a missing one is refused by name. The declaration record above does not
785
+ carry them; the builder takes them beside it, and the port record carries them per entry.
786
+ Who fills them depends on where the call comes from: the boot hook's records must carry
787
+ them (no envelope exists at boot), while `ctx.scheduler` inside a handler fills them from the
788
+ invocation envelope and refuses them from the caller.
789
+ - **An empty answer is an answer.** A service with no recurrences has no hook, or answers an
790
+ empty list, and the wrapper skips its step. An answer that does NOT hold throws — a
791
+ recurrence nobody registered is work that never happens, and nothing else rings for it.
792
+ - **`validateRecurrences(document, source)` names its source**, and the name is required: every
793
+ refusal quotes it, so a default here would put one caller's name on another caller's mistake.
794
+ - **Sending is fail-fast**, unlike the monitoring helpers beside it. A lost monitoring copy
795
+ is a missing record of work that happened; a lost registration is work that will not happen.
796
+ - `scheduler.requestQueue` is the queue name, composed from the declaration in `queueConfig`
797
+ — a caller never types `scheduler.request`.
798
+
799
+ Two more things a service says on that queue, and one send for all of them:
800
+
801
+ ```javascript
802
+ // THAT something happened. It names no cookbook: the scheduler starts every recipe whose
803
+ // `triggers` declare this event, so a producer naming a recipe would map work to something it
804
+ // may not own.
805
+ await scheduler.publishSchedulerRequest(mqClient, scheduler.buildEventRequest({
806
+ event: { service: 'biz-ingest', name: 'job-persisted' },
807
+ idempotencyKey: `biz-ingest:job-persisted:${jobId}`, // the identity of the fact
808
+ tenantId, workspaceId, requestedBy: serviceName,
809
+ input: { source_uuid } // optional, handed to every cookbook it starts
810
+ }));
811
+
812
+ // An item back, by the key it was registered with.
813
+ await scheduler.publishSchedulerRequest(mqClient, scheduler.buildCancelRequest({
814
+ idempotencyKey: 'servant-poll',
815
+ requestedBy: serviceName
816
+ }));
817
+ ```
818
+
819
+ - **The key is always the caller's.** `idempotency_key` is required for every kind, and the
820
+ scheduler decides a one-shot item ONCE by it — a second `event` carrying a known key is
821
+ answered `duplicate`. A key this package invented per call would turn every retry of one fact
822
+ into a second run, so no builder makes one up.
823
+ - **`cancel` carries no tenancy**, and needs none: the key names an item that already holds the
824
+ context it was registered with. Cancelling a key that names nothing is a result rather than an
825
+ error — the scheduler answers `cancel-nothing` — so the builder judges nothing about it.
826
+ - **One send, `publishSchedulerRequest()`**, for every kind: the kind is a field of the message,
827
+ never a second function. It is fail-fast like the registration above, and it refuses a message
828
+ none of these builders produced.
829
+
830
+ A service that speaks to the scheduler more than once holds a port instead of repeating its own
831
+ name and the queue:
832
+
833
+ ```javascript
834
+ const port = scheduler.createSchedulerPublisher({ mqClient, requestedBy: serviceName });
835
+
836
+ await port.registerRecurring({ id, cookbookRef, cron, timezone, tenantId, workspaceId, input, catchUp });
837
+ await port.publishEvent({ name: 'job-persisted', idempotencyKey: jobUuid, tenantId, workspaceId, input });
838
+ await port.cancel({ id });
839
+ ```
840
+
841
+ - **The client and the queue are decided at construction**, not at the first call: a service that
842
+ announces facts has to fall over at boot rather than at its first document. On a pin whose
843
+ `queueConfig` declares no `scheduler` section this module refuses when it is required, earlier
844
+ still. Every refusal names the slot it is about and how to fix it, so a defect of the wiring can
845
+ never be read as the operation's own error.
846
+ - **Tenancy stays explicit on every call**, because two kinds of caller hold this port: a service
847
+ under the wrapper, which fills it from the envelope of the run, and an operator script that does
848
+ not boot the wrapper at all and works row by row.
849
+
676
850
  ### redactUrl — a connection URL that is safe to log
677
851
 
678
852
  `RABBITMQ_URL` carries the broker account, so anything that renders it verbatim
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/mq-client-core",
3
- "version": "4.0.1",
3
+ "version": "4.2.0",
4
4
  "description": "Core MQ client library for RabbitMQ - shared by infrastructure services and connectors",
5
5
  "oa": {
6
6
  "category": "connector"