@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.
@@ -60,7 +60,13 @@ const SECTION_PREFIXES = Object.freeze({
60
60
  monitoring: 'monitoring',
61
61
  telemetry: 'telemetry',
62
62
  alerts: 'alert',
63
- registry: 'registry'
63
+ registry: 'registry',
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: ''
64
70
  });
65
71
 
66
72
  /**
@@ -81,7 +87,8 @@ const INFRASTRUCTURE_PREFIX_LOOKUPS = Object.freeze({
81
87
  validation: 'getValidationQueueConfig',
82
88
  monitoring: 'getMonitoringQueueConfig',
83
89
  telemetry: 'getTelemetryQueueConfig',
84
- delivery: 'getDeliveryQueueConfig'
90
+ delivery: 'getDeliveryQueueConfig',
91
+ scheduler: 'getSchedulerQueueConfig'
85
92
  });
86
93
 
87
94
  const INFRASTRUCTURE_PREFIXES = Object.freeze(Object.keys(INFRASTRUCTURE_PREFIX_LOOKUPS));
@@ -627,10 +634,17 @@ module.exports = {
627
634
  /**
628
635
  * monitoring.workflow - Unified workflow lifecycle monitoring
629
636
  * Bound to monitoring.workflow.fanout exchange (fanout).
630
- * Delivery Dispatcher publishes 'completed'/'failed' events after processing workflow.completed/workflow.failed
631
- * Business services publish 'progress' events during workflow step processing
632
- * Message format includes event_type: 'completed' | 'failed' | 'progress'
633
- * 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
634
648
  */
635
649
  'workflow': {
636
650
  durable: true,
@@ -644,15 +658,20 @@ module.exports = {
644
658
 
645
659
  /**
646
660
  * monitoring.services - Service lifecycle events monitoring
647
- * Registry publishes service lifecycle events (registered, validation.completed, version.changed, status.changed)
648
- * InfrastructureHealthTracker publishes service status changes
649
- * Message format includes event_type: 'service.registered' | 'service.validation.completed' |
650
- * '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
+ *
651
668
  * NOTE: Heartbeats are NOT published here - they are too frequent. The cadence
652
669
  * is not a number this file owns: see the `heartbeatInterval` constructor option
653
670
  * of RegistryClient (@onlineapps/conn-orch-registry), which ServiceWrapper feeds
654
671
  * from BIZ_HEARTBEAT_INTERVAL_MS.
655
672
  * NOTE: Healthcheck messages go to infrastructure.health.events exchange
673
+ *
674
+ * @see api/docs/biz/70-contracts/monitoring-events.md
656
675
  */
657
676
  'services': {
658
677
  durable: true,
@@ -689,6 +708,33 @@ module.exports = {
689
708
  durable: true
690
709
  },
691
710
 
711
+ /**
712
+ * monitoring.resource - Topic exchange for resource-changed events
713
+ *
714
+ * The monitoring consumer's resource-changed watcher publishes here with a routing
715
+ * key of the shape `resource.changed.<resource_type>`; the delivery endpoint binds
716
+ * `delivery.resource.events` to it by the pattern `resource.changed.#` for WS push.
717
+ * Three sides of ONE wire — the publisher, this exchange and the bound queue — and
718
+ * until d.619 the name had an owner on none of them: a module constant on the
719
+ * publishing side and a required env key on the binding side, so an override or a
720
+ * typo on either side loses every event silently. That is the same reasoning d.270
721
+ * settled for the health-events fanout, and the reason the name lives here and is
722
+ * published by `src/index.js` § topology.
723
+ *
724
+ * `topic`, `durable`, no arguments: what the publisher asserts, and what the running
725
+ * broker answers for it. An exchange holds nothing to dead-letter, so — like every
726
+ * other exchange in this file — it carries no route, even though every QUEUE of this
727
+ * section routes to `monitoring.dlq`.
728
+ *
729
+ * @see api/infra/api_monitoring/src/consumer/watchers/resourceChangedPublisher.js
730
+ * @see api/docs/biz/70-contracts/resource-changed.md
731
+ */
732
+ resource: {
733
+ type: 'exchange',
734
+ exchangeType: 'topic',
735
+ durable: true
736
+ },
737
+
692
738
  /**
693
739
  * monitoring.infrastructure.health.events - the monitoring consumer's own copy of
694
740
  * the infrastructure health events, bound to the infrastructure.health.events
@@ -743,8 +789,8 @@ module.exports = {
743
789
  * Telemetry queue configurations
744
790
  *
745
791
  * The OpenTelemetry intake of the monitoring consumer: every service publishes
746
- * logs, metrics and spans to `telemetry.exchange` with a routing key carrying its
747
- * own name, and these three queues are bound to it
792
+ * logs, metrics and spans to the `telemetry.exchange` declared below, with a routing
793
+ * key carrying its own name, and these three queues are bound to it
748
794
  * (`docs/standards/monitoring-queues.md`, `docs/architecture/monitoring.md`).
749
795
  *
750
796
  * The TTL and the cap are the ones the queues are created with today; this config is
@@ -758,11 +804,50 @@ module.exports = {
758
804
  * @see api/docs/governance/confirmations/mq-consumer-contract.md 003
759
805
  */
760
806
  telemetry: {
807
+ /**
808
+ * telemetry.exchange - the topic exchange every telemetry producer on the platform
809
+ * publishes to, and the one the three queues below are bound to.
810
+ *
811
+ * The widest wire there is: every biz service through `@onlineapps/service-wrapper`,
812
+ * every infrastructure service through `@onlineapps/monitoring-core` (its logger and
813
+ * its OpenTelemetry exporter), and the monitoring consumer, which asserts the
814
+ * exchange, binds the three queues and drains them — and since I16 publishes its own
815
+ * lines onto it as well.
816
+ *
817
+ * Until d.708 the name appeared HERE only in the prose above, while the other three
818
+ * platform exchanges of this file (`infrastructure.health.events`,
819
+ * `monitoring.workflow.fanout`, `monitoring.resource`) each had a template. So one
820
+ * name had three owners — a module constant in the monitoring consumer
821
+ * (`infra/api_monitoring/src/consumer/topology.js`) and a config default in each of
822
+ * monitoring-core's two publishers — which is exactly the shape d.270 gave an owner
823
+ * for the health-events fanout and d.619 for the resource-changed topic
824
+ * (`change-discipline.md` § One rail per concern).
825
+ *
826
+ * `topic`, `durable`, no arguments: what all three of those sides assert today
827
+ * (`assertExchange(<name>, 'topic', { durable: true })`), measured before this entry
828
+ * was written. The broker compares an exchange's type and durability at EVERY
829
+ * declaration, so a value invented here would be `406 PRECONDITION-FAILED` for
830
+ * whichever side booted second. An exchange holds nothing to dead-letter, so — like
831
+ * every other exchange in this file — it carries no route, even though every QUEUE
832
+ * of this section routes to `telemetry.dlq`.
833
+ *
834
+ * Declaring it is not creating it: the exchange is brought into being by the service
835
+ * that asserts it, the same rule as `telemetry.dlq` below.
836
+ *
837
+ * @see api/infra/api_monitoring/src/consumer/index.js — assertMonitoringTopology()
838
+ * @see api/shared/monitoring/monitoring-core/src/exporters/rabbitmq.js
839
+ */
840
+ exchange: {
841
+ type: 'exchange',
842
+ exchangeType: 'topic',
843
+ durable: true
844
+ },
845
+
761
846
  /** telemetry.logs.queue - OpenTelemetry log records, drained into Loki. */
762
847
  'logs.queue': {
763
848
  durable: true,
764
849
  arguments: {
765
- '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
766
851
  'x-max-length': 100000, // Max 100k messages in queue
767
852
  'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
768
853
  'x-dead-letter-routing-key': 'telemetry.dlq'
@@ -773,7 +858,7 @@ module.exports = {
773
858
  'metrics.queue': {
774
859
  durable: true,
775
860
  arguments: {
776
- 'x-message-ttl': 60000,
861
+ 'x-message-ttl': 300000, // 5 min — okno deploye bez konzumenta; conf telemetry-queue-ttl 001
777
862
  'x-max-length': 100000,
778
863
  'x-dead-letter-exchange': '',
779
864
  'x-dead-letter-routing-key': 'telemetry.dlq'
@@ -784,7 +869,7 @@ module.exports = {
784
869
  'traces.queue': {
785
870
  durable: true,
786
871
  arguments: {
787
- 'x-message-ttl': 60000,
872
+ 'x-message-ttl': 300000, // 5 min — okno deploye bez konzumenta; conf telemetry-queue-ttl 001
788
873
  'x-max-length': 100000,
789
874
  'x-dead-letter-exchange': '',
790
875
  'x-dead-letter-routing-key': 'telemetry.dlq'
@@ -932,11 +1017,155 @@ module.exports = {
932
1017
  },
933
1018
 
934
1019
  /**
935
- * registry.changes - Registry event fanout exchange
936
- * (This is an exchange, not a queue, but included for reference)
1020
+ * registry.changes - the fanout exchange registry index changes travel on.
1021
+ *
1022
+ * One module is every side of this wire: `@onlineapps/conn-orch-registry`,
1023
+ * `src/registryEventConsumer.js`. It holds the only spelling of the name (`:92`),
1024
+ * it is the only party that ASSERTS the exchange (`:117`,
1025
+ * `assertExchange(name, 'fanout', { durable: true })`), it binds each service's
1026
+ * `{service}.registry.events` to it with the pattern `''` (`:133`), and it publishes
1027
+ * the snapshot request onto it (`:331`, `routingKey: ''`). The publish path declares
1028
+ * nothing since d.342, so there is no second founder.
1029
+ *
1030
+ * Until d.712 this was the ONE exchange of this file with no `exchangeType`, so the
1031
+ * founder had to decide the kind on its own while the template only said "an
1032
+ * exchange, durable". `type` is one of the two properties the broker compares at
1033
+ * every declaration, which makes it exactly the half that cannot live somewhere
1034
+ * else (`change-discipline.md` § One rail per concern; the same step d.619 made for
1035
+ * `monitoring.resource` and d.708 for `telemetry.exchange`).
1036
+ *
1037
+ * `fanout`, `durable`, no arguments: measured on that founder, not chosen. The
1038
+ * binding pattern `''` only routes on a fanout, so the type is load-bearing rather
1039
+ * than decorative. An exchange holds nothing to dead-letter, so — like every other
1040
+ * exchange in this file — it carries no route.
1041
+ *
1042
+ * Declaring it is not creating it: the exchange is brought into being by the service
1043
+ * whose registry client subscribes to changes.
1044
+ *
1045
+ * @see api/shared/connector/conn-orch-registry/src/registryEventConsumer.js
937
1046
  */
938
1047
  changes: {
939
1048
  type: 'exchange',
1049
+ exchangeType: 'fanout',
1050
+ durable: true
1051
+ }
1052
+ },
1053
+
1054
+ /**
1055
+ * Scheduler queue configurations — the platform's one holder of time.
1056
+ *
1057
+ * The scheduler consumes requests for a run: a recurrence that came due, or a deferred
1058
+ * request somebody filed. Both are published by other services and both are the ONLY
1059
+ * record that the run was owed, so the family is shaped for loss-aversion rather than
1060
+ * for freshness (`docs/governance/confirmations/scheduler.md` 001, 004).
1061
+ *
1062
+ * @see api/infra/api_scheduler/README.md
1063
+ */
1064
+ scheduler: {
1065
+ /**
1066
+ * scheduler.request - every request for a run, from every producer.
1067
+ *
1068
+ * NO `x-message-ttl`, deliberately, and it is the one property that differs from the
1069
+ * shape of a workflow queue: a request that waited out a consumer outage is still the
1070
+ * run that was due, and a TTL would turn that outage into runs which silently never
1071
+ * happened. What bounds the queue is its length, and what catches a request the
1072
+ * scheduler could not process is the family's dead-letter queue below — over the
1073
+ * DEFAULT exchange, where the routing key IS the destination name, the same way every
1074
+ * other infrastructure family here routes (the `dlx` exchange belongs to the business
1075
+ * templates, whose queues one service owns).
1076
+ *
1077
+ * Without this declaration `consume()` refuses to attach a consumer at all — a queue
1078
+ * with no declared route would have the broker DROP what the policy rejects
1079
+ * (`docs/governance/confirmations/mq-consumer-contract.md` 002).
1080
+ */
1081
+ request: {
1082
+ durable: true,
1083
+ arguments: {
1084
+ 'x-max-length': 10000,
1085
+ 'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
1086
+ 'x-dead-letter-routing-key': 'scheduler.dlq'
1087
+ }
1088
+ },
1089
+
1090
+ /**
1091
+ * scheduler.workflow.events - where the scheduler learns that a run it started ENDED.
1092
+ *
1093
+ * The third queue bound to `monitoring.workflow.fanout`, beside `monitoring.workflow`
1094
+ * and `delivery.workflow.events`. The owner settled it in
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).
1105
+ *
1106
+ * THE TTL IS STATED, NOT INHERITED, and it is a property of that decision rather than a
1107
+ * default: a fanout cannot filter, so this queue receives EVERY workflow event of the
1108
+ * platform and the scheduler discards most of them. 300 000 ms is what
1109
+ * `monitoring.workflow` carries on the same exchange - the sibling whose readings this
1110
+ * one follows. What an outage longer than the TTL costs is on record: those events are
1111
+ * lost and the run is closed by the per-schedule timeout, which 007 keeps as the
1112
+ * backstop rather than replacing it.
1113
+ *
1114
+ * The route goes over the DEFAULT exchange, like every other infrastructure family
1115
+ * here, and ends in this family's own `scheduler.dlq`. Without a declared route
1116
+ * `consume()` refuses to attach at all (`BaseClient.js`,
1117
+ * `CONSUMER_DEAD_LETTER_ROUTE_MISSING`; `mq-consumer-contract` 002).
1118
+ *
1119
+ * @see api/docs/governance/confirmations/scheduler.md 007
1120
+ * @see api/docs/biz/70-contracts/monitoring-events.md
1121
+ */
1122
+ 'workflow.events': {
1123
+ durable: true,
1124
+ arguments: {
1125
+ 'x-message-ttl': 300000, // Stated by confirmation 007, like monitoring.workflow
1126
+ 'x-max-length': 10000,
1127
+ 'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
1128
+ 'x-dead-letter-routing-key': 'scheduler.dlq'
1129
+ }
1130
+ },
1131
+
1132
+ /**
1133
+ * scheduler.dlq - the END of this family's chain.
1134
+ *
1135
+ * Durable, capped higher, no TTL and no onward route — the three properties every
1136
+ * dead-letter queue in this file carries, and the reason the operator's `*.dlq`
1137
+ * dashboard finds it (`mq-consumer-contract` 003).
1138
+ */
1139
+ dlq: {
1140
+ durable: true,
1141
+ arguments: {
1142
+ // No TTL for DLQ - messages should persist
1143
+ 'x-max-length': 50000 // Higher limit for DLQ
1144
+ }
1145
+ }
1146
+ },
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',
940
1169
  durable: true
941
1170
  }
942
1171
  },
@@ -950,9 +1179,13 @@ module.exports = {
950
1179
  * a name this configuration does not declare would be asserted against the broker
951
1180
  * by whoever imported it, which is how a typo becomes a second queue.
952
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
+ *
953
1186
  * @param {string} section - Section holding the entry (e.g. 'deliveryEvents').
954
1187
  * @param {string} key - The entry's key within that section (e.g. 'health.events').
955
- * @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').
956
1189
  * @throws {ValidationError} If the section composes no wire name, or the entry is undeclared.
957
1190
  */
958
1191
  queueName(section, key) {
@@ -974,7 +1207,7 @@ module.exports = {
974
1207
  );
975
1208
  }
976
1209
 
977
- return `${prefix}.${key}`;
1210
+ return prefix === '' ? key : `${prefix}.${key}`;
978
1211
  },
979
1212
 
980
1213
  /**
@@ -1219,6 +1452,23 @@ module.exports = {
1219
1452
  return this.getQueueConfig('infrastructure', name);
1220
1453
  },
1221
1454
 
1455
+ /**
1456
+ * Get scheduler queue configuration
1457
+ * @param {string} queueName - Queue name (e.g., 'scheduler.request')
1458
+ * @returns {Object} Queue configuration
1459
+ * @throws {ValidationError} If the name carries another prefix, or nothing declares it.
1460
+ */
1461
+ getSchedulerQueueConfig(queueName) {
1462
+ if (!queueName.startsWith('scheduler.')) {
1463
+ throw new ValidationError(
1464
+ `[queueConfig] Queue ${queueName} is not a scheduler queue - Expected: a name starting with "scheduler.". `
1465
+ + 'Fix: call getInfrastructureQueueConfig(), which routes each prefix to the right lookup.'
1466
+ );
1467
+ }
1468
+ const name = queueName.slice('scheduler.'.length);
1469
+ return this.getQueueConfig('scheduler', name);
1470
+ },
1471
+
1222
1472
  /**
1223
1473
  * Get monitoring queue configuration
1224
1474
  * @param {string} queueName - Queue name (e.g., 'monitoring.workflow')
@@ -1428,11 +1678,12 @@ module.exports = {
1428
1678
  * Every business template routes here (`x-dead-letter-exchange`), and so does
1429
1679
  * `getDeadLetterRoute()` for a business queue. `@onlineapps/conn-infra-mq` binds
1430
1680
  * `<service>.dlq` to it and had to read the name back out of a queue's arguments
1431
- * 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.
1432
1683
  *
1433
1684
  * @returns {string}
1434
1685
  */
1435
1686
  deadLetterExchange() {
1436
- return DEAD_LETTER_EXCHANGE;
1687
+ return this.queueName('deadLetter', DEAD_LETTER_EXCHANGE);
1437
1688
  }
1438
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
@@ -36,6 +36,19 @@ const {
36
36
  publishToMonitoringServices,
37
37
  isQueueUnavailableError,
38
38
  } = require('./monitoring-publish');
39
+ const {
40
+ buildWorkflowInitiatedEvent,
41
+ publishWorkflowInitiated,
42
+ } = require('./monitoring-initiated');
43
+ const {
44
+ schedulerRequestQueue,
45
+ validateRecurrences,
46
+ buildRecurringRequest,
47
+ buildEventRequest,
48
+ buildCancelRequest,
49
+ publishSchedulerRequest,
50
+ createSchedulerPublisher,
51
+ } = require('./scheduler-request');
39
52
  const queueConfig = require('./config/queueConfig');
40
53
  const deliveryPolicy = require('./config/deliveryPolicy');
41
54
  const { redactUrl, UNPARSEABLE_PLACEHOLDER } = require('@onlineapps/logger-contract');
@@ -84,6 +97,28 @@ module.exports.monitoring = {
84
97
  publishToMonitoringWorkflow,
85
98
  publishToMonitoringServices,
86
99
  isQueueUnavailableError,
100
+ // d.819 - the one builder of `workflow_initiated`, beside the transport half it uses
101
+ // (`docs/governance/confirmations/scheduler.md` 009). The gateway and the scheduler each
102
+ // built that event in their own words; two rails over one event is a defect by default.
103
+ buildWorkflowInitiatedEvent,
104
+ publishWorkflowInitiated,
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,
87
122
  };
88
123
  /** The only public way to reach the central queue classification; `src/config/queueConfig` is an internal path, not a contract. */
89
124
  module.exports.queueConfig = queueConfig;
@@ -109,6 +144,18 @@ module.exports.topology = Object.freeze({
109
144
  monitoringInfrastructureHealthEventsQueue: queueConfig.queueName('monitoring', 'infrastructure.health.events'),
110
145
  /** The delivery endpoint's queue bound to that exchange, for WS push. */
111
146
  deliveryHealthEventsQueue: queueConfig.queueName('deliveryEvents', 'health.events'),
147
+ /**
148
+ * Topic exchange: the monitoring consumer's resource-changed watcher publishes here,
149
+ * with a routing key of the shape `resource.changed.<resource_type>`.
150
+ *
151
+ * Declared in `queueConfig` since d.619. Until then the name was a module constant on
152
+ * the publishing side and a required env key on the binding side
153
+ * (`MONITORING_RESOURCE_EXCHANGE`) — the third side of this wire with no owner, while
154
+ * the queue bound to it had one here since d.270.
155
+ *
156
+ * @see api/infra/api_monitoring/src/consumer/watchers/resourceChangedPublisher.js
157
+ */
158
+ monitoringResourceExchange: queueConfig.queueName('monitoring', 'resource'),
112
159
  /**
113
160
  * The delivery endpoint's queue for resource-changed events, bound to the
114
161
  * `monitoring.resource` exchange by the routing pattern `resource.changed.#`.