@onlineapps/mq-client-core 4.1.0 → 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,149 @@ 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
+
7
150
  ## [4.1.0] — 2026-09-23
8
151
 
9
152
  ### Added — plánovač se dozví KONEC běhu z vlastní fronty (d.818)
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
@@ -720,6 +728,125 @@ trade a real effect for a record of it — a lost copy is reported through the l
720
728
  answers `false`. The one exception is a missing logger, which is the transport half's rule:
721
729
  without it the promise "every failure is reported" cannot hold.
722
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
+
723
850
  ### redactUrl — a connection URL that is safe to log
724
851
 
725
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.1.0",
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"
@@ -61,7 +61,12 @@ const SECTION_PREFIXES = Object.freeze({
61
61
  telemetry: 'telemetry',
62
62
  alerts: 'alert',
63
63
  registry: 'registry',
64
- scheduler: 'scheduler'
64
+ scheduler: 'scheduler',
65
+ // The one section whose names travel WITHOUT a prefix: `dlx` is the wire name every
66
+ // business queue dead-letters to (`x-dead-letter-exchange`), and renaming it would
67
+ // re-declare live topology. The empty prefix is a declared case, read explicitly by
68
+ // `queueName()`; a section absent from this map is still refused (d.902).
69
+ deadLetter: ''
65
70
  });
66
71
 
67
72
  /**
@@ -629,10 +634,17 @@ module.exports = {
629
634
  /**
630
635
  * monitoring.workflow - Unified workflow lifecycle monitoring
631
636
  * Bound to monitoring.workflow.fanout exchange (fanout).
632
- * Delivery Dispatcher publishes 'completed'/'failed' events after processing workflow.completed/workflow.failed
633
- * Business services publish 'progress' events during workflow step processing
634
- * Message format includes event_type: 'completed' | 'failed' | 'progress'
635
- * Includes full cookbook and context snapshots for trace storage
637
+ *
638
+ * WHO publishes onto that fanout: the Delivery Dispatcher after it processes a terminal
639
+ * workflow message, the in-process orchestrator during a run, and this client's own
640
+ * dead-letter hook. WHICH event_type each of them sends is not written here - the
641
+ * vocabulary has one owner and it grows, so a list copied into this comment would be a
642
+ * hand-written descriptive fact that ages in silence
643
+ * (`.claude/rules/doc-code-binding.md` §1).
644
+ *
645
+ * The message carries the cookbook and the context snapshot the trace store keeps.
646
+ *
647
+ * @see api/docs/biz/70-contracts/monitoring-events.md
636
648
  */
637
649
  'workflow': {
638
650
  durable: true,
@@ -646,15 +658,20 @@ module.exports = {
646
658
 
647
659
  /**
648
660
  * monitoring.services - Service lifecycle events monitoring
649
- * Registry publishes service lifecycle events (registered, validation.completed, version.changed, status.changed)
650
- * InfrastructureHealthTracker publishes service status changes
651
- * Message format includes event_type: 'service.registered' | 'service.validation.completed' |
652
- * 'service.version.changed' | 'service.deregistered' | 'service.status.changed'
661
+ *
662
+ * WHO publishes here: the Registry, which owns the service lifecycle, and the
663
+ * InfrastructureHealthTracker with service status changes. WHICH event_type each of them
664
+ * sends is not written here — that vocabulary has one owner and it grows, and a list
665
+ * copied into this comment would be a hand-written descriptive fact that ages in silence
666
+ * (`.claude/rules/doc-code-binding.md` §1).
667
+ *
653
668
  * NOTE: Heartbeats are NOT published here - they are too frequent. The cadence
654
669
  * is not a number this file owns: see the `heartbeatInterval` constructor option
655
670
  * of RegistryClient (@onlineapps/conn-orch-registry), which ServiceWrapper feeds
656
671
  * from BIZ_HEARTBEAT_INTERVAL_MS.
657
672
  * NOTE: Healthcheck messages go to infrastructure.health.events exchange
673
+ *
674
+ * @see api/docs/biz/70-contracts/monitoring-events.md
658
675
  */
659
676
  'services': {
660
677
  durable: true,
@@ -830,7 +847,7 @@ module.exports = {
830
847
  'logs.queue': {
831
848
  durable: true,
832
849
  arguments: {
833
- 'x-message-ttl': 60000, // 1 minute TTL for unprocessed messages
850
+ 'x-message-ttl': 300000, // 5 min — okno deploye bez konzumenta; conf telemetry-queue-ttl 001
834
851
  'x-max-length': 100000, // Max 100k messages in queue
835
852
  'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
836
853
  'x-dead-letter-routing-key': 'telemetry.dlq'
@@ -841,7 +858,7 @@ module.exports = {
841
858
  'metrics.queue': {
842
859
  durable: true,
843
860
  arguments: {
844
- 'x-message-ttl': 60000,
861
+ 'x-message-ttl': 300000, // 5 min — okno deploye bez konzumenta; conf telemetry-queue-ttl 001
845
862
  'x-max-length': 100000,
846
863
  'x-dead-letter-exchange': '',
847
864
  'x-dead-letter-routing-key': 'telemetry.dlq'
@@ -852,7 +869,7 @@ module.exports = {
852
869
  'traces.queue': {
853
870
  durable: true,
854
871
  arguments: {
855
- 'x-message-ttl': 60000,
872
+ 'x-message-ttl': 300000, // 5 min — okno deploye bez konzumenta; conf telemetry-queue-ttl 001
856
873
  'x-max-length': 100000,
857
874
  'x-dead-letter-exchange': '',
858
875
  'x-dead-letter-routing-key': 'telemetry.dlq'
@@ -1075,12 +1092,16 @@ module.exports = {
1075
1092
  *
1076
1093
  * The third queue bound to `monitoring.workflow.fanout`, beside `monitoring.workflow`
1077
1094
  * and `delivery.workflow.events`. The owner settled it in
1078
- * `docs/governance/confirmations/scheduler.md` 007: the terminal event of a run
1079
- * (`completed` / `failed` / `discarded`) already travels on that exchange, so the
1080
- * scheduler reads it there rather than asking the observer over HTTP or reading a
1081
- * database that is not its own. No producer changes; the binding is the scheduler's own
1082
- * act at boot (the pattern `infra/api_delivery_endpoint/src/index.js:164` already uses),
1083
- * and this file only DECLARES what that queue is.
1095
+ * `docs/governance/confirmations/scheduler.md` 007: the terminal event of a run already
1096
+ * travels on that exchange, so the scheduler reads it there rather than asking the
1097
+ * observer over HTTP or reading a database that is not its own. No producer changes; the
1098
+ * binding is the scheduler's own act at boot (the pattern
1099
+ * `infra/api_delivery_endpoint/src/index.js:164` already uses), and this file only
1100
+ * DECLARES what that queue is.
1101
+ *
1102
+ * WHICH event_type values those are is NOT written here: the vocabulary has one owner and
1103
+ * it grows. A list copied into this comment would be a hand-written descriptive fact that
1104
+ * ages in silence (`.claude/rules/doc-code-binding.md` §1).
1084
1105
  *
1085
1106
  * THE TTL IS STATED, NOT INHERITED, and it is a property of that decision rather than a
1086
1107
  * default: a fanout cannot filter, so this queue receives EVERY workflow event of the
@@ -1096,6 +1117,7 @@ module.exports = {
1096
1117
  * `CONSUMER_DEAD_LETTER_ROUTE_MISSING`; `mq-consumer-contract` 002).
1097
1118
  *
1098
1119
  * @see api/docs/governance/confirmations/scheduler.md 007
1120
+ * @see api/docs/biz/70-contracts/monitoring-events.md
1099
1121
  */
1100
1122
  'workflow.events': {
1101
1123
  durable: true,
@@ -1123,6 +1145,31 @@ module.exports = {
1123
1145
  }
1124
1146
  },
1125
1147
 
1148
+ /**
1149
+ * The platform's dead-letter exchange, DECLARED — name, type and durability.
1150
+ *
1151
+ * Every business template dead-letters here (`x-dead-letter-exchange`), and
1152
+ * `@onlineapps/conn-infra-mq` `QueueManager.setupServiceQueues()` brings it into
1153
+ * being as a durable `direct` exchange before binding `{service}.dlq` to it. The
1154
+ * broker compares type and durability at EVERY `assertExchange`, so they belong to
1155
+ * the declaration, where a second reader — `api/scripts/ci/declare-broker-exchanges.mjs`,
1156
+ * which declares exchanges on a CI broker nobody's services booted against — finds
1157
+ * them through the same loop as every other exchange of this file, instead of keeping
1158
+ * a copy of the founder's line (TODO INFRA 349, d.902).
1159
+ *
1160
+ * Its wire name carries no prefix (`SECTION_PREFIXES.deadLetter` is `''`), and the key
1161
+ * is the one constant at the top of this file, so the name has one spelling.
1162
+ * An exchange holds nothing to dead-letter, so — like every other exchange here — it
1163
+ * carries no route.
1164
+ */
1165
+ deadLetter: {
1166
+ [DEAD_LETTER_EXCHANGE]: {
1167
+ type: 'exchange',
1168
+ exchangeType: 'direct',
1169
+ durable: true
1170
+ }
1171
+ },
1172
+
1126
1173
  /**
1127
1174
  * The full wire name of a declared entry: the section's prefix + the entry's key.
1128
1175
  *
@@ -1132,9 +1179,13 @@ module.exports = {
1132
1179
  * a name this configuration does not declare would be asserted against the broker
1133
1180
  * by whoever imported it, which is how a typo becomes a second queue.
1134
1181
  *
1182
+ * A section whose declared prefix is `''` (`deadLetter`) composes the bare key: its
1183
+ * names travel without a prefix. That is a value of the map, not a default — a
1184
+ * section missing from it is refused below.
1185
+ *
1135
1186
  * @param {string} section - Section holding the entry (e.g. 'deliveryEvents').
1136
1187
  * @param {string} key - The entry's key within that section (e.g. 'health.events').
1137
- * @returns {string} The full name (e.g. 'delivery.health.events').
1188
+ * @returns {string} The full name (e.g. 'delivery.health.events'; the bare key for 'deadLetter').
1138
1189
  * @throws {ValidationError} If the section composes no wire name, or the entry is undeclared.
1139
1190
  */
1140
1191
  queueName(section, key) {
@@ -1156,7 +1207,7 @@ module.exports = {
1156
1207
  );
1157
1208
  }
1158
1209
 
1159
- return `${prefix}.${key}`;
1210
+ return prefix === '' ? key : `${prefix}.${key}`;
1160
1211
  },
1161
1212
 
1162
1213
  /**
@@ -1627,11 +1678,12 @@ module.exports = {
1627
1678
  * Every business template routes here (`x-dead-letter-exchange`), and so does
1628
1679
  * `getDeadLetterRoute()` for a business queue. `@onlineapps/conn-infra-mq` binds
1629
1680
  * `<service>.dlq` to it and had to read the name back out of a queue's arguments
1630
- * for want of an export.
1681
+ * for want of an export. Composed from the `deadLetter` entry, which also declares
1682
+ * the exchange's type and durability — one declaration, not a name beside it.
1631
1683
  *
1632
1684
  * @returns {string}
1633
1685
  */
1634
1686
  deadLetterExchange() {
1635
- return DEAD_LETTER_EXCHANGE;
1687
+ return this.queueName('deadLetter', DEAD_LETTER_EXCHANGE);
1636
1688
  }
1637
1689
  };
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * recurrencesSchema.js
5
+ *
6
+ * JSON Schema used by Ajv (in `src/scheduler-request.js`) to validate the recurrences a service
7
+ * declares about itself.
8
+ *
9
+ * WHY THE SHAPE LIVES HERE. `docs/architecture/scheduler.md` §7 has the wrapper register a
10
+ * service's recurrences at boot — "idempotent, so every boot re-asserts the declared state" —
11
+ * and the service answers with them through ONE hook, platform ones as a constant list and
12
+ * tenant ones derived from its own data (confirmation `scheduler` 011). The shape of a record
13
+ * is the library's; where the record comes from is not. One owner of the shape means every service's file is the
14
+ * same file, and the wrapper reading it never has to guess.
15
+ *
16
+ * WHAT THE FILE DOES NOT CARRY, and why that is a rule rather than an omission:
17
+ *
18
+ * - **`tenant_id` and `workspace_id`.** They travel beside the record, not in it: the
19
+ * builder takes them as `tenantId` and `workspaceId`, and the caller supplies them with
20
+ * every recurrence — the service knows them from its own data, and nothing reads them from
21
+ * the environment or defaults them.
22
+ * @see api/docs/governance/confirmations/scheduler.md 002, 010
23
+ * - **`requested_by`.** It is the service that declared the record; the caller passes the name
24
+ * it already knows, so a record cannot disagree with the service that answered it.
25
+ *
26
+ * `additionalProperties: false` at both levels, deliberately: the consumer's own contract
27
+ * (`api/infra/api_scheduler/contracts/scheduler.request.schema.json`) closes its keys the same
28
+ * way, so a key accepted here and refused there would turn a boot into a dead-lettered
29
+ * request nobody reads.
30
+ *
31
+ * @see ../scheduler-request.js — the shape check, the builders and the send
32
+ * @see api/docs/architecture/scheduler.md §4, §7
33
+ */
34
+
35
+ /**
36
+ * Five whitespace-separated fields — the same expression the scheduler's contract takes
37
+ * (`recurrence.cron`, pattern `^\S+( \S+){4}$`). Whether those fields describe a reachable
38
+ * instant is the tick engine's answer, not this file's.
39
+ */
40
+ const CRON_PATTERN = '^\\S+( \\S+){4}$';
41
+
42
+ /**
43
+ * The id of one schedule: it becomes `idempotency_key`, so it is a name, not a sentence.
44
+ *
45
+ * As WIDE as the consumer's own rule and no wider: the scheduler takes any string of 1–255
46
+ * characters for that key, so this file refuses only what would make the key a sentence —
47
+ * whitespace. The narrower spelling this schema first carried (lower-case letters and dashes)
48
+ * was the library being stricter than the contract for no reason, and it refused the very
49
+ * convention the first caller uses: `<source_uuid>:<cookbook_ref>` (BIZ-ingest, 2026-09-23).
50
+ */
51
+ const ID_PATTERN = '^\\S+$';
52
+
53
+ const recurrencesSchema = {
54
+ $schema: 'http://json-schema.org/draft-07/schema#',
55
+ $id: 'https://onlineapps.cz/schemas/mq-client-core/recurrences.v1.schema.json',
56
+ title: 'Service recurrence declaration',
57
+ description:
58
+ 'What a service declares about its own recurring runs. The wrapper re-asserts every record '
59
+ + 'at boot as a `scheduler.request` of kind `recurring`.',
60
+ type: 'object',
61
+ additionalProperties: false,
62
+ required: ['recurrences'],
63
+ properties: {
64
+ recurrences: {
65
+ description:
66
+ 'Every recurrence this service owns. An empty list is a valid declaration of nothing, '
67
+ + 'and a service with no recurrences may answer one.',
68
+ type: 'array',
69
+ items: {
70
+ type: 'object',
71
+ additionalProperties: false,
72
+ required: ['id', 'cookbook_ref', 'cron', 'timezone'],
73
+ properties: {
74
+ id: {
75
+ description:
76
+ 'Stable id of THIS schedule; it travels as `idempotency_key`, so re-registration '
77
+ + 'upserts instead of creating a second schedule.',
78
+ type: 'string',
79
+ minLength: 1,
80
+ maxLength: 255,
81
+ pattern: ID_PATTERN
82
+ },
83
+ cookbook_ref: {
84
+ description:
85
+ 'Id of the cookbook to run. Its `triggers` must name this service and the kind '
86
+ + '`recurring`, or the scheduler refuses the request (confirmation scheduler 004).',
87
+ type: 'string',
88
+ minLength: 1,
89
+ maxLength: 255
90
+ },
91
+ cron: {
92
+ description: 'Cron expression, five fields.',
93
+ type: 'string',
94
+ pattern: CRON_PATTERN
95
+ },
96
+ timezone: {
97
+ description: 'IANA zone, explicit. No implicit zone exists (scheduler.md §4).',
98
+ type: 'string',
99
+ minLength: 1,
100
+ maxLength: 64
101
+ },
102
+ input: {
103
+ description:
104
+ 'Input handed to the cookbook unchanged. Present only where the run needs one — '
105
+ + 'a scheduled import that writes says so here, while the same cookbook run by hand '
106
+ + 'can be given a different input.',
107
+ type: 'object'
108
+ },
109
+ catch_up: {
110
+ description:
111
+ 'Whether a missed window runs once when the scheduler comes back. Absent means '
112
+ + 'false, and the scheduler materialises that default — this file does not.',
113
+ type: 'boolean'
114
+ }
115
+ }
116
+ }
117
+ }
118
+ }
119
+ };
120
+
121
+ module.exports = { recurrencesSchema, CRON_PATTERN, ID_PATTERN };
package/src/index.js CHANGED
@@ -40,6 +40,15 @@ const {
40
40
  buildWorkflowInitiatedEvent,
41
41
  publishWorkflowInitiated,
42
42
  } = require('./monitoring-initiated');
43
+ const {
44
+ schedulerRequestQueue,
45
+ validateRecurrences,
46
+ buildRecurringRequest,
47
+ buildEventRequest,
48
+ buildCancelRequest,
49
+ publishSchedulerRequest,
50
+ createSchedulerPublisher,
51
+ } = require('./scheduler-request');
43
52
  const queueConfig = require('./config/queueConfig');
44
53
  const deliveryPolicy = require('./config/deliveryPolicy');
45
54
  const { redactUrl, UNPARSEABLE_PLACEHOLDER } = require('@onlineapps/logger-contract');
@@ -94,6 +103,23 @@ module.exports.monitoring = {
94
103
  buildWorkflowInitiatedEvent,
95
104
  publishWorkflowInitiated,
96
105
  };
106
+ /**
107
+ * The recurrences a service declares, and how one of them is registered (d.833).
108
+ *
109
+ * `docs/architecture/scheduler.md` §7 gives this library the shape of one declared recurrence
110
+ * and the wrapper the job of re-asserting what the service answers at every boot (one hook,
111
+ * confirmation `scheduler` 011). The queue name travels with them, so no caller composes
112
+ * `scheduler.request` itself.
113
+ */
114
+ module.exports.scheduler = {
115
+ requestQueue: schedulerRequestQueue,
116
+ validateRecurrences,
117
+ buildRecurringRequest,
118
+ buildEventRequest,
119
+ buildCancelRequest,
120
+ publishSchedulerRequest,
121
+ createSchedulerPublisher,
122
+ };
97
123
  /** The only public way to reach the central queue classification; `src/config/queueConfig` is an internal path, not a contract. */
98
124
  module.exports.queueConfig = queueConfig;
99
125
  /**
@@ -0,0 +1,481 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The recurrences a service declares, and the request that registers ONE of them.
5
+ *
6
+ * `docs/architecture/scheduler.md` §7: a service declares the recurrences it owns and the
7
+ * wrapper re-asserts them at every boot as `scheduler.request` messages of kind `recurring`.
8
+ * The service answers with them through ONE hook (`scheduler: { recurrences: async () => [...] }`,
9
+ * d.834) - platform ones as a constant list, tenant ones derived from its own data - and this
10
+ * library fixes the SHAPE of a record (`validateRecurrences()`), never where it comes from
11
+ * (confirmation `scheduler` 011). The scheduler treats a
12
+ * repeated registration as an upsert — `idempotency_key` for a recurring request IS the
13
+ * schedule id — so re-asserting the declared state costs nothing and drifts nowhere
14
+ * (`api/infra/api_scheduler/src/consumer/decideOnRequest.js`, `contracts/scheduler.request.schema.json`).
15
+ *
16
+ * ONE RAIL FOR EVERY THING A SERVICE SAYS TO THE SCHEDULER. The queue takes one message with
17
+ * four branches, and three of them are a service's to send: `recurring` (a schedule, built from
18
+ * a record the service declared), `event` (a fact that happened, naming no
19
+ * cookbook - the scheduler starts every recipe whose `triggers` declare it, confirmation
20
+ * `scheduler` 004) and `cancel` (an item by its key). Each has a pure builder anybody can ask
21
+ * without a broker, and ONE `publishSchedulerRequest()` sends them all: the kind is a field of
22
+ * the message, never a second function - the split `./monitoring-initiated.js` uses for
23
+ * `workflow_initiated`, with one send instead of three.
24
+ *
25
+ * THE KEY IS ALWAYS THE CALLER'S. `idempotency_key` sits in the contract's root `required`, and
26
+ * the consumer decides a one-shot item ONCE by it (a second `event` of a known key answers
27
+ * `duplicate`). A key invented here would make every retry of one fact a second run, so no
28
+ * builder makes one up - it is the identity of the thing, and only the caller knows it.
29
+ *
30
+ * WHERE IT DIFFERS FROM THAT SIBLING, and why: sending is **fail-fast**. A lost monitoring
31
+ * copy is a missing record of work that already happened, so that rail returns `false` and
32
+ * reports. A lost registration is work that will never happen, and nothing rings when a run
33
+ * does not start — so every refusal here is thrown and the boot stops on it
34
+ * (`architecture-principles.md` §4).
35
+ *
36
+ * WHAT IT NEVER INVENTS: `tenant_id` and `workspace_id`. Both are mandatory and ≥ 1, and the
37
+ * caller passes them with every request — nothing here reads them from the environment or
38
+ * supplies a default. For a recurrence the service knows them from its own data, so the records
39
+ * its boot hook returns carry them; inside a handler `ctx.scheduler` takes them from the
40
+ * invocation envelope.
41
+ *
42
+ * @see api/docs/governance/confirmations/scheduler.md 002
43
+ * @see api/docs/governance/confirmations/scheduler.md 010
44
+ * @see api/docs/governance/confirmations/scheduler.md 011
45
+ *
46
+ * @see ./config/recurrencesSchema.js — the shape of one declared recurrence
47
+ * @see ./monitoring-initiated.js — the same split for `workflow_initiated`
48
+ * @see api/docs/architecture/scheduler.md §4, §7
49
+ */
50
+
51
+ const Ajv = require('ajv');
52
+
53
+ const queueConfig = require('./config/queueConfig');
54
+ const { recurrencesSchema } = require('./config/recurrencesSchema');
55
+
56
+ /** The queue the scheduler consumes, composed from the declaration rather than typed. */
57
+ const schedulerRequestQueue = queueConfig.queueName('scheduler', 'request');
58
+
59
+ const ajv = new Ajv({ allErrors: true });
60
+ const validateDeclaration = ajv.compile(recurrencesSchema);
61
+
62
+ /** Ajv's findings as one sentence a human can act on. */
63
+ const describeErrors = (errors) => (errors || [])
64
+ .map((error) => {
65
+ const where = error.instancePath === '' ? 'the document' : error.instancePath;
66
+ const what = error.params && error.params.additionalProperty
67
+ ? `${error.message} ("${error.params.additionalProperty}")`
68
+ : error.message;
69
+ return `${where} ${what}`;
70
+ })
71
+ .join('; ');
72
+
73
+ /**
74
+ * Check a parsed declaration document and hand back its records.
75
+ *
76
+ * @param {Object} document - `{ recurrences: [...] }`, as the service declared it.
77
+ * @param {string} source - Where it came from, named in every sentence this function throws.
78
+ * Required: a default here would put one caller's name on another caller's mistake.
79
+ * @returns {Array<Object>} The declared recurrences, in the order they were given.
80
+ * @throws {Error} When the document does not hold, naming every rule it broke.
81
+ */
82
+ function validateRecurrences(document, source) {
83
+ if (typeof source !== 'string' || source.trim() === '') {
84
+ throw new Error(
85
+ '[mq-client-core/scheduler-request] Missing source - Expected: a name for what is being '
86
+ + 'checked, because every refusal below quotes it and a wrong name sends the reader to the '
87
+ + 'wrong place. Fix: pass what the caller calls this document (e.g. "the declaration").'
88
+ );
89
+ }
90
+
91
+ if (!document || typeof document !== 'object' || Array.isArray(document)) {
92
+ throw new Error(
93
+ `[mq-client-core/scheduler-request] ${source} is not a declaration - Expected: an object with a "recurrences" list, `
94
+ + `got ${Array.isArray(document) ? 'an array' : typeof document}. `
95
+ + 'Fix: write { "recurrences": [ … ] }, or remove the file if the service declares none.'
96
+ );
97
+ }
98
+
99
+ if (!validateDeclaration(document)) {
100
+ throw new Error(
101
+ `[mq-client-core/scheduler-request] ${source} does not hold: ${describeErrors(validateDeclaration.errors)} - `
102
+ + 'Expected: every record carries id, cookbook_ref, cron (five fields) and an explicit '
103
+ + 'IANA timezone, and nothing this shape does not declare. '
104
+ + 'Fix: correct the record the message names; the shape is src/config/recurrencesSchema.js. '
105
+ + 'Note: tenant and workspace do NOT belong here - the caller passes them '
106
+ + '(confirmations scheduler 010, 011; the tenancy rule itself is scheduler 002).'
107
+ );
108
+ }
109
+
110
+ const seen = new Set();
111
+ for (const recurrence of document.recurrences) {
112
+ if (seen.has(recurrence.id)) {
113
+ throw new Error(
114
+ `[mq-client-core/scheduler-request] ${source} declares the id "${recurrence.id}" twice - Expected: one record per `
115
+ + 'id, because the id travels as the scheduler\'s idempotency_key and a second record '
116
+ + 'would silently overwrite the first at every boot. '
117
+ + 'Fix: give each schedule its own id, or remove the duplicate.'
118
+ );
119
+ }
120
+ seen.add(recurrence.id);
121
+ }
122
+
123
+ return document.recurrences;
124
+ }
125
+
126
+ /** A whole number a tenant or workspace id may be: ≥ 1, never 0 (confirmation scheduler 002). */
127
+ const isContextId = (value) => Number.isInteger(value) && value >= 1;
128
+
129
+ /**
130
+ * Turn ONE declared recurrence into the message the scheduler takes.
131
+ *
132
+ * @param {Object} args
133
+ * @param {Object} args.declaration - One record the service declared.
134
+ * @param {number} args.tenantId - The tenant the runs execute under; supplied by the caller with
135
+ * this recurrence (the service's own data), never a default.
136
+ * @param {number} args.workspaceId - The workspace the runs execute under; supplied the same way.
137
+ * @param {string} args.requestedBy - The service registering this schedule.
138
+ * @returns {Object} The `scheduler.request` message, exactly the keys the contract declares.
139
+ * @throws {Error} When anything is missing, zero, or not a whole number.
140
+ */
141
+ function buildRecurringRequest({ declaration, tenantId, workspaceId, requestedBy } = {}) {
142
+ const [recurrence] = validateRecurrences({ recurrences: [declaration] }, 'the declaration');
143
+
144
+ requireTenancy(tenantId, workspaceId);
145
+ const by = requireRequestedBy(requestedBy);
146
+
147
+ const request = {
148
+ kind: 'recurring',
149
+ idempotency_key: recurrence.id,
150
+ cookbook_ref: recurrence.cookbook_ref,
151
+ recurrence: { cron: recurrence.cron, timezone: recurrence.timezone },
152
+ tenant_id: tenantId,
153
+ workspace_id: workspaceId,
154
+ requested_by: by
155
+ };
156
+
157
+ // Written only when the declaration states them: an absent `input` is no input, and an absent
158
+ // `catch_up` is the scheduler's own default (§5) - a value materialised here would be a second
159
+ // owner of it.
160
+ if (Object.prototype.hasOwnProperty.call(recurrence, 'input')) request.input = recurrence.input;
161
+ if (Object.prototype.hasOwnProperty.call(recurrence, 'catch_up')) request.catch_up = recurrence.catch_up;
162
+
163
+ return request;
164
+ }
165
+
166
+ /** The platform spelling of a service or an event name, as the consumer's contract defines it. */
167
+ const NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
168
+
169
+ /** The key that identifies the item, whatever kind it is - required by the contract's root. */
170
+ function requireIdempotencyKey(idempotencyKey) {
171
+ if (typeof idempotencyKey !== 'string' || idempotencyKey.trim() === '') {
172
+ throw new Error(
173
+ '[mq-client-core/scheduler-request] Missing idempotency_key - Expected: the stable identity '
174
+ + 'of this item, chosen by the caller. The scheduler decides a one-shot item ONCE by this '
175
+ + 'key, so a key invented here would turn every retry of one fact into a second run. '
176
+ + 'Fix: pass the key that names the thing (for an event, what it happened to).'
177
+ );
178
+ }
179
+
180
+ return idempotencyKey.trim();
181
+ }
182
+
183
+ /** The service registering, cancelling or reporting - checked by the scheduler against services.json. */
184
+ function requireRequestedBy(requestedBy) {
185
+ if (typeof requestedBy !== 'string' || requestedBy.trim() === '') {
186
+ throw new Error(
187
+ '[mq-client-core/scheduler-request] Missing requested_by - Expected: the platform name of the '
188
+ + 'service sending this request; the scheduler checks it against api/config/services.json '
189
+ + "and against the cookbook's own trigger declaration. "
190
+ + 'Fix: pass the service name the wrapper already holds.'
191
+ );
192
+ }
193
+
194
+ return requestedBy.trim();
195
+ }
196
+
197
+ /** Both context ids, under the one rule confirmation `scheduler` 002 fixed. */
198
+ function requireTenancy(tenantId, workspaceId) {
199
+ if (!isContextId(tenantId)) {
200
+ throw new Error(
201
+ `[mq-client-core/scheduler-request] Invalid tenant_id ${JSON.stringify(tenantId)} - Expected: `
202
+ + 'a whole number of at least 1. The owner rejected a reserved value: 0 is not a platform '
203
+ + 'scope, and an absent tenant is an error rather than a default (confirmation scheduler 002). '
204
+ + 'Fix: pass tenantId with every request - a recurrence carries it in the record the service '
205
+ + 'returns from its own data, and ctx.scheduler fills it from the invocation envelope; it is '
206
+ + 'never read from anywhere else.'
207
+ );
208
+ }
209
+
210
+ if (!isContextId(workspaceId)) {
211
+ throw new Error(
212
+ `[mq-client-core/scheduler-request] Invalid workspace_id ${JSON.stringify(workspaceId)} - `
213
+ + 'Expected: a whole number of at least 1, under the same rule as the tenant (confirmation '
214
+ + 'scheduler 002). Fix: pass workspaceId with every request - a recurrence carries it in the '
215
+ + 'record the service returns from its own data, and ctx.scheduler fills it from the invocation '
216
+ + 'envelope; it is never read from anywhere else.'
217
+ );
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Turn a fact into the message that reports it.
223
+ *
224
+ * It names NO cookbook, and that is the point: the scheduler starts every recipe whose
225
+ * `triggers` declare this event (confirmation `scheduler` 004), so a producer naming a recipe
226
+ * would be mapping work to something it may not own (`ingest-poll-trigger-rail` 001: which
227
+ * cookbook runs is a platform decision, not the producer's).
228
+ *
229
+ * @param {Object} args
230
+ * @param {{service: string, name: string}} args.event - Who it happened to, and what happened.
231
+ * @param {number} args.tenantId
232
+ * @param {number} args.workspaceId
233
+ * @param {string} args.requestedBy
234
+ * @param {string} args.idempotencyKey - The identity of this fact; see the module note.
235
+ * @param {Object} [args.input] - Handed to every cookbook the event starts, unchanged.
236
+ * @returns {Object} The `scheduler.request` message of kind `event`.
237
+ * @throws {Error} When anything is missing or not the shape the contract takes.
238
+ */
239
+ function buildEventRequest({ event, tenantId, workspaceId, requestedBy, idempotencyKey, input } = {}) {
240
+ const key = requireIdempotencyKey(idempotencyKey);
241
+ const by = requireRequestedBy(requestedBy);
242
+ requireTenancy(tenantId, workspaceId);
243
+
244
+ if (!event || typeof event !== 'object' || Array.isArray(event)) {
245
+ throw new Error(
246
+ `[mq-client-core/scheduler-request] Missing event - Expected: { service, name } - who it `
247
+ + 'happened to and what happened, both of them. '
248
+ + 'Fix: pass the fact this service publishes, never a cookbook name.'
249
+ );
250
+ }
251
+
252
+ for (const [field, value] of [['service', event.service], ['name', event.name]]) {
253
+ if (typeof value !== 'string' || !NAME_PATTERN.test(value)) {
254
+ throw new Error(
255
+ `[mq-client-core/scheduler-request] Invalid event.${field} ${JSON.stringify(value)} - `
256
+ + 'Expected: the platform spelling, lower case letters, digits and dashes '
257
+ + '(the pattern the scheduler\'s own contract declares). '
258
+ + `Fix: pass the ${field} as api/config/services.json spells it.`
259
+ );
260
+ }
261
+ }
262
+
263
+ const request = {
264
+ kind: 'event',
265
+ idempotency_key: key,
266
+ event: { service: event.service, name: event.name },
267
+ tenant_id: tenantId,
268
+ workspace_id: workspaceId,
269
+ requested_by: by
270
+ };
271
+
272
+ if (input !== undefined) request.input = input;
273
+
274
+ return request;
275
+ }
276
+
277
+ /**
278
+ * Take an item back by its key.
279
+ *
280
+ * Idempotent by contract: a key that names nothing is answered `cancel-nothing` by the
281
+ * scheduler, not refused - so this builder judges nothing about the key, it only requires one.
282
+ * The item carries the context it was registered with, which is why no tenancy travels here.
283
+ *
284
+ * @param {Object} args
285
+ * @param {string} args.idempotencyKey - The key of the schedule or pending item to remove.
286
+ * @param {string} args.requestedBy - Only the service that registered an item may cancel it.
287
+ * @returns {Object} The `scheduler.request` message of kind `cancel`.
288
+ * @throws {Error} When the key or the requester is missing.
289
+ */
290
+ function buildCancelRequest({ idempotencyKey, requestedBy } = {}) {
291
+ return {
292
+ kind: 'cancel',
293
+ idempotency_key: requireIdempotencyKey(idempotencyKey),
294
+ requested_by: requireRequestedBy(requestedBy)
295
+ };
296
+ }
297
+
298
+ /** Every key the port's `registerRecurring()` takes — anything else is refused, never dropped. */
299
+ const RECURRING_PORT_KEYS = Object.freeze([
300
+ 'id', 'cookbookRef', 'cron', 'timezone', 'tenantId', 'workspaceId', 'input', 'catchUp'
301
+ ]);
302
+
303
+ /** The kinds this library builds, and the only ones it will send. */
304
+ const SENDABLE_KINDS = Object.freeze(['recurring', 'event', 'cancel']);
305
+
306
+ /**
307
+ * Send one request of any kind this library builds. Throws on every refusal — see the module
308
+ * note on fail-fast.
309
+ *
310
+ * @param {Object} mqClient - A connected `BaseClient`.
311
+ * @param {Object} request - What one of the builders above produced.
312
+ * @returns {Promise<void>}
313
+ * @throws {Error} When the client is not connected, the request is not one of ours, or the
314
+ * publish fails.
315
+ */
316
+ async function publishSchedulerRequest(mqClient, request) {
317
+ if (!mqClient || typeof mqClient.publish !== 'function' || typeof mqClient.isConnected !== 'function') {
318
+ throw new Error(
319
+ `[mq-client-core/scheduler-request] Missing client - Expected: a connected @onlineapps/mq-client-core BaseClient, so `
320
+ + `the registration reaches ${schedulerRequestQueue}. `
321
+ + 'Fix: pass the client the composition root connected.'
322
+ );
323
+ }
324
+
325
+ if (!mqClient.isConnected()) {
326
+ throw new Error(
327
+ `[mq-client-core/scheduler-request] The client is not connected - Expected: a registration reaches `
328
+ + `${schedulerRequestQueue} before the service starts taking work; a schedule nobody `
329
+ + 'registered is a run that never happens. Fix: connect the client first.'
330
+ );
331
+ }
332
+
333
+ if (!request || !SENDABLE_KINDS.includes(request.kind) || typeof request.idempotency_key !== 'string') {
334
+ throw new Error(
335
+ '[mq-client-core/scheduler-request] Not a request this library built - Expected: one of '
336
+ + `${SENDABLE_KINDS.join(', ')}, got kind ${JSON.stringify(request && request.kind)}. `
337
+ + 'Fix: build the message with buildRecurringRequest(), buildEventRequest() or '
338
+ + 'buildCancelRequest() before sending it.'
339
+ );
340
+ }
341
+
342
+ try {
343
+ await mqClient.publish(schedulerRequestQueue, request);
344
+ } catch (error) {
345
+ throw new Error(
346
+ `[mq-client-core/scheduler-request] Could not send "${request.idempotency_key}" (${request.kind}) `
347
+ + `on ${schedulerRequestQueue}: ${error.message} - Expected: the request to reach the `
348
+ + 'scheduler; a request nobody received is work that will not happen. '
349
+ + 'Fix: act on the reason above - the boot stops here rather than running a service whose '
350
+ + 'schedules were silently not registered.',
351
+ { cause: error }
352
+ );
353
+ }
354
+ }
355
+
356
+ /**
357
+ * A port one service holds: the three things it says, with WHO is speaking already decided.
358
+ *
359
+ * TWO CALLERS, TWO DOORS. The wrapper builds `ctx.scheduler` over this factory at boot and fills
360
+ * the tenancy of each call from the envelope of the run. The operator CLIs of biz-ingest
361
+ * (`scripts/run-{poll,fetch-source-item,retry-pending-items}.js`) do NOT boot the wrapper - a
362
+ * bootstrap there would start consumers, the registry and a heartbeat - and today they are the
363
+ * only way a document of the pilot comes into being, so they call this factory directly with the
364
+ * service identity and the tenancy of the row they are working on. That is why tenancy is
365
+ * explicit on every call rather than held by the port: one port, two kinds of caller, and no
366
+ * hidden context that the CLI would have to fake (BIZ-ingest measurement, 2026-09-23).
367
+ *
368
+ * EAGER ON PURPOSE. The client and the name of the queue are decided HERE, when the port is
369
+ * made, so a service that announces facts falls over at boot rather than at its first document.
370
+ * (On a pin whose `queueConfig` declares no `scheduler` section this module already refuses at
371
+ * require time, which is earlier still.) And because a defect of the wiring must never answer in
372
+ * place of the operation, every refusal names the slot it is about and how to fix it.
373
+ *
374
+ * @param {Object} args
375
+ * @param {Object} args.mqClient - A `BaseClient` (connected by the time a verb is called).
376
+ * @param {string} args.requestedBy - The service this port speaks for; it becomes `requested_by`
377
+ * on every request and `event.service` on every fact.
378
+ * @returns {{registerRecurring: Function, publishEvent: Function, cancel: Function}}
379
+ * @throws {Error} When a slot is missing or unusable - at construction, never later.
380
+ */
381
+ function createSchedulerPublisher({ mqClient, requestedBy } = {}) {
382
+ if (!mqClient || typeof mqClient.publish !== 'function' || typeof mqClient.isConnected !== 'function') {
383
+ throw new Error(
384
+ '[mq-client-core/scheduler-request] Cannot build the scheduler port: slot "mqClient" is '
385
+ + 'missing or cannot publish - Expected: the @onlineapps/mq-client-core BaseClient this '
386
+ + `service connects, so a request reaches ${schedulerRequestQueue}. This is decided now, at `
387
+ + 'construction, because a service that announces facts must fail at boot rather than at '
388
+ + 'its first document. Fix: pass the client the composition root built.'
389
+ );
390
+ }
391
+
392
+ const by = (() => {
393
+ try {
394
+ return requireRequestedBy(requestedBy);
395
+ } catch (error) {
396
+ throw new Error(
397
+ '[mq-client-core/scheduler-request] Cannot build the scheduler port: slot "requestedBy" is '
398
+ + `missing - ${error.message} `
399
+ + 'Fix: pass the platform name of the service this port speaks for (config.serviceName).'
400
+ );
401
+ }
402
+ })();
403
+
404
+ return {
405
+ /**
406
+ * Re-assert one recurrence. The id is the schedule's identity, so a repeat upserts.
407
+ *
408
+ * @param {Object} args - `{ id, cookbookRef, cron, timezone, tenantId, workspaceId, input?, catchUp? }`
409
+ * @returns {Promise<void>}
410
+ */
411
+ async registerRecurring(record = {}) {
412
+ // As strict as the declaration it becomes (`additionalProperties: false` in
413
+ // `./config/recurrencesSchema.js`): a key nobody reads is a typo or a second definition of
414
+ // something, and dropping it in silence hides both (`architecture-principles.md` §3).
415
+ for (const key of Object.keys(record)) {
416
+ if (!RECURRING_PORT_KEYS.includes(key)) {
417
+ throw new Error(
418
+ `[mq-client-core/scheduler-request] registerRecurring got a key the request does not declare ("${key}") - `
419
+ + 'Expected: exactly { id, cookbookRef, cron, timezone, tenantId, workspaceId, input?, catchUp? }. '
420
+ + 'Fix: remove the key from the record the service returns / the ctx.scheduler call.'
421
+ );
422
+ }
423
+ }
424
+
425
+ const { id, cookbookRef, cron, timezone, tenantId, workspaceId, input, catchUp } = record;
426
+ const declaration = { id, cookbook_ref: cookbookRef, cron, timezone };
427
+ if (input !== undefined) declaration.input = input;
428
+ if (catchUp !== undefined) declaration.catch_up = catchUp;
429
+
430
+ await publishSchedulerRequest(mqClient, buildRecurringRequest({
431
+ declaration,
432
+ tenantId,
433
+ workspaceId,
434
+ requestedBy: by
435
+ }));
436
+ },
437
+
438
+ /**
439
+ * Announce one fact. The service is this port's; the key is the caller's (the identity of the
440
+ * thing that happened - a job's uuid, not a clock reading), because the scheduler decides a
441
+ * one-shot item once by it.
442
+ *
443
+ * @param {Object} args - `{ name, idempotencyKey, tenantId, workspaceId, input? }`
444
+ * @returns {Promise<void>}
445
+ */
446
+ async publishEvent({ name, idempotencyKey, tenantId, workspaceId, input } = {}) {
447
+ await publishSchedulerRequest(mqClient, buildEventRequest({
448
+ event: { service: by, name },
449
+ idempotencyKey,
450
+ tenantId,
451
+ workspaceId,
452
+ requestedBy: by,
453
+ input
454
+ }));
455
+ },
456
+
457
+ /**
458
+ * Take back an item this service registered, by its id.
459
+ *
460
+ * @param {Object} args - `{ id }`
461
+ * @returns {Promise<void>}
462
+ */
463
+ async cancel({ id } = {}) {
464
+ await publishSchedulerRequest(mqClient, buildCancelRequest({
465
+ idempotencyKey: id,
466
+ requestedBy: by
467
+ }));
468
+ }
469
+ };
470
+ }
471
+
472
+ module.exports = {
473
+ schedulerRequestQueue,
474
+ SENDABLE_KINDS,
475
+ validateRecurrences,
476
+ buildRecurringRequest,
477
+ buildEventRequest,
478
+ buildCancelRequest,
479
+ publishSchedulerRequest,
480
+ createSchedulerPublisher
481
+ };
@@ -1416,6 +1416,70 @@ class RabbitMQClient extends EventEmitter {
1416
1416
  }
1417
1417
  }
1418
1418
 
1419
+ /**
1420
+ * Race an `amqp.connect()` handshake against its `connectTimeout`, and own the
1421
+ * handshake the timeout abandons.
1422
+ *
1423
+ * When the timeout wins, the race rejects but the handshake does not stop: it
1424
+ * can still complete into a full AMQP connection that no field of this client
1425
+ * points at, so `disconnect()` never closes it and it keeps the process alive
1426
+ * (INFRA 359, measured with a proxy delaying the handshake past the timeout).
1427
+ * The rejection the caller receives is unchanged; what changes is that the
1428
+ * abandoned handshake is closed the moment it completes.
1429
+ *
1430
+ * @private
1431
+ * @param {Promise<Object>} connectPromise - the handshake from `amqp.connect()`
1432
+ * @param {Promise<never>} timeoutPromise - rejects with the timeout error
1433
+ * @param {string} phase - 'connect' or 'reconnect', named in the log lines
1434
+ * @returns {Promise<Object>} the connection, when the handshake won the race
1435
+ */
1436
+ async _raceHandshake(connectPromise, timeoutPromise, phase) {
1437
+ let abandoned = false;
1438
+ const timeoutLeg = timeoutPromise.catch((err) => {
1439
+ abandoned = true;
1440
+ throw err;
1441
+ });
1442
+ try {
1443
+ return await Promise.race([connectPromise, timeoutLeg]);
1444
+ } catch (err) {
1445
+ if (abandoned) {
1446
+ this._closeAbandonedHandshake(connectPromise, phase);
1447
+ }
1448
+ throw err;
1449
+ }
1450
+ }
1451
+
1452
+ /**
1453
+ * Close the connection an abandoned handshake produces, whenever it completes.
1454
+ * A handshake that fails after its timeout opened nothing; its error adds
1455
+ * nothing to the timeout the caller already received.
1456
+ * @private
1457
+ * @param {Promise<Object>} connectPromise - the handshake the timeout abandoned
1458
+ * @param {string} phase - 'connect' or 'reconnect'
1459
+ */
1460
+ _closeAbandonedHandshake(connectPromise, phase) {
1461
+ connectPromise.then(
1462
+ async (late) => {
1463
+ this._logger.warn(
1464
+ `[RabbitMQClient] Handshake completed after the ${phase} timeout (${this._connectTimeout} ms) - `
1465
+ + 'closing that connection, nothing in this client owns it'
1466
+ );
1467
+ try {
1468
+ await late.close();
1469
+ } catch (closeErr) {
1470
+ this._logger.warn(
1471
+ `[RabbitMQClient] Error closing the connection whose handshake outlived the ${phase} timeout: ${closeErr.message}`
1472
+ );
1473
+ }
1474
+ },
1475
+ (lateErr) => {
1476
+ this._logger.debug(
1477
+ `[RabbitMQClient] Handshake abandoned by the ${phase} timeout failed afterwards: ${lateErr.message}`
1478
+ );
1479
+ }
1480
+ );
1481
+ }
1482
+
1419
1483
  /**
1420
1484
  * Clear all active timers
1421
1485
  * @private
@@ -1459,7 +1523,7 @@ class RabbitMQClient extends EventEmitter {
1459
1523
  }
1460
1524
  });
1461
1525
  this._logger.debug('[RabbitMQClient] Starting connection race...');
1462
- this._connection = await Promise.race([connectPromise, timeoutPromise]);
1526
+ this._connection = await this._raceHandshake(connectPromise, timeoutPromise, 'connect');
1463
1527
  if (connectTimeoutTimer) {
1464
1528
  this._clearTimeout(connectTimeoutTimer);
1465
1529
  connectTimeoutTimer = null;
@@ -3941,7 +4005,7 @@ class RabbitMQClient extends EventEmitter {
3941
4005
  )), this._connectTimeout);
3942
4006
  });
3943
4007
 
3944
- const revived = await Promise.race([connectPromise, timeoutPromise]);
4008
+ const revived = await this._raceHandshake(connectPromise, timeoutPromise, 'reconnect');
3945
4009
 
3946
4010
  // The caller may have disposed of this client while that handshake was
3947
4011
  // in flight. The socket is up, and it belongs to nobody: keeping it