@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.
@@ -0,0 +1,187 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The `workflow_initiated` event — ONE rail for every service that starts a run.
5
+ *
6
+ * Monitoring learns that a run exists from this event and from nothing else: its intake
7
+ * reads the run's tenancy, labels and trigger out of `workflow_initiated` and marks the
8
+ * run `active` on it (`api/infra/api_monitoring/src/consumer/index.js`). A service that
9
+ * does not file it starts runs that are invisible for their whole life.
10
+ *
11
+ * Until now two services built that event, each in its own words: the gateway
12
+ * (`infra/api_gateway/shared/monitoringInitiatedCopy.js`, with a redacted cookbook) and
13
+ * the scheduler (`infra/api_scheduler/src/publisher/workflowPublisher.js`, inline, the
14
+ * cookbook verbatim and no `tenant_uuid` at all). Two implementations of one concern are
15
+ * a defect by default (`change-discipline.md` § One rail per concern), and the owner
16
+ * settled which rail survives: this one, in the library that already owns the transport
17
+ * half of the same wire (`docs/governance/confirmations/scheduler.md` 009). Both copies
18
+ * are retired by their own services in the change that adopts this module — no period
19
+ * with both (`architecture-principles.md` §11).
20
+ *
21
+ * WHAT THIS MODULE DOES NOT DO, deliberately:
22
+ *
23
+ * 1. **It does not redact.** What is sensitive in a cookbook is known only to the
24
+ * service spec in the registry, and reading that spec would give this library a
25
+ * dependency on the operation specs and on `infrastructure-tools`. The caller passes
26
+ * the observability copy it already built (`cookbookRedaction.js` in the gateway); a
27
+ * path that cannot redact does not file the event at all, because an empty set of
28
+ * sensitive fields is silent consent to an unredacted copy.
29
+ * 2. **It invents no value.** `service_name` and the instant are the caller's, and every
30
+ * key it reads out of `_system` must EXIST — `hasOwnProperty`, not truthiness, so
31
+ * `tenant_uuid: null` passes through as the explicit value it is (a webhook run has
32
+ * no personal identity, and a scheduled run of the pilot writes `null` on purpose —
33
+ * `docs/governance/confirmations/run-envelope-tenant-uuid.md` 001). A `|| null` here
34
+ * would hide a broken envelope instead of reporting it (`architecture-principles.md`
35
+ * §3).
36
+ *
37
+ * @see api/docs/governance/confirmations/scheduler.md 009
38
+ * @see api/docs/biz/70-contracts/monitoring-events.md
39
+ * @see ./monitoring-publish.js — the transport half: the queue, the fanout, the fail-safe
40
+ */
41
+
42
+ const { assertLogger } = require('@onlineapps/logger-contract');
43
+ const { publishToMonitoringWorkflow } = require('./monitoring-publish');
44
+
45
+ /** Starting a run is not a step of the cookbook; it has its own constant coordinates. */
46
+ const STEP_ID = 'workflow_start';
47
+ const STEP_INDEX = -1;
48
+ /** The event's own two words, which the monitoring intake matches on. */
49
+ const EVENT_TYPE = 'initiated';
50
+ const STATUS = 'initiated';
51
+
52
+ /** The keys of `_system` this event carries, each of which must exist on the envelope. */
53
+ const REQUIRED_SYSTEM_KEYS = Object.freeze(['tenant_id', 'tenant_uuid']);
54
+
55
+ /**
56
+ * The body of the `initiated` event — exactly these keys and no others.
57
+ *
58
+ * @param {Object} args
59
+ * @param {string} args.workflowId - the id of the run this event reports.
60
+ * @param {Object} args.redactedCookbook - the observability copy of the unified cookbook,
61
+ * ALREADY redacted by the caller; it travels as it arrived.
62
+ * @param {string} args.serviceName - the platform name of the service filing the event;
63
+ * the monitoring intake refuses an event that names no publisher (`service_name_missing`).
64
+ * @param {string} args.timestamp - the instant the run started, ISO-8601. The caller's,
65
+ * not a second reading of the clock here: the run and the row observing it then say the
66
+ * same thing.
67
+ * @returns {Object} the message for `monitoring.workflow`.
68
+ * @throws {Error} when an argument is missing, or the envelope does not carry what this
69
+ * event states about it.
70
+ */
71
+ function buildWorkflowInitiatedEvent({ workflowId, redactedCookbook, serviceName, timestamp } = {}) {
72
+ if (typeof workflowId !== 'string' || workflowId.trim() === '') {
73
+ throw new Error(
74
+ '[monitoring-initiated] Missing workflow_id - Expected: the id of the run this event '
75
+ + 'reports, as a non-empty string. Fix: pass workflowId of the run being published.'
76
+ );
77
+ }
78
+ if (typeof serviceName !== 'string' || serviceName.trim() === '') {
79
+ throw new Error(
80
+ '[monitoring-initiated] Missing service_name - Expected: the platform name of the service '
81
+ + 'filing this event; the monitoring intake refuses an event that names no publisher '
82
+ + '(service_name_missing). Fix: pass config.serviceName.'
83
+ );
84
+ }
85
+ if (typeof timestamp !== 'string' || timestamp.trim() === '') {
86
+ throw new Error(
87
+ '[monitoring-initiated] Missing timestamp - Expected: the instant the run started, as an '
88
+ + 'ISO-8601 string taken from the run itself, so the run and the row observing it say the '
89
+ + 'same thing. Fix: pass the envelope\'s own instant, not a new Date() read here.'
90
+ );
91
+ }
92
+ if (!redactedCookbook || typeof redactedCookbook !== 'object') {
93
+ throw new Error(
94
+ '[monitoring-initiated] Missing cookbook - Expected: the redacted observability copy of the '
95
+ + 'unified cookbook. Fix: pass the copy the caller built with its own redaction '
96
+ + '(a path that cannot redact does not file this event at all).'
97
+ );
98
+ }
99
+
100
+ const system = redactedCookbook._system;
101
+ if (!system || typeof system !== 'object') {
102
+ throw new Error(
103
+ '[monitoring-initiated] Cookbook carries no _system - Expected: the unified cookbook with '
104
+ + 'its system metadata, which is where the intake reads the run\'s tenancy, labels and '
105
+ + 'trigger. Fix: build the copy from the unified cookbook the run was published with.'
106
+ );
107
+ }
108
+
109
+ for (const key of REQUIRED_SYSTEM_KEYS) {
110
+ if (!Object.prototype.hasOwnProperty.call(system, key)) {
111
+ throw new Error(
112
+ `[monitoring-initiated] Cookbook _system carries no ${key} - Expected: every entry path `
113
+ + 'writes it explicitly (a value, or null where the path has none - a webhook run proves '
114
+ + 'itself by signature, a scheduled run of the pilot has no personal identity). '
115
+ + `Fix: set _system.${key} where the unified cookbook is built.`
116
+ );
117
+ }
118
+ }
119
+
120
+ return {
121
+ event_type: EVENT_TYPE,
122
+ workflow_id: workflowId,
123
+ service_name: serviceName,
124
+ step_index: STEP_INDEX,
125
+ step_id: STEP_ID,
126
+ cookbook: redactedCookbook,
127
+ status: STATUS,
128
+ tenant_uuid: system.tenant_uuid,
129
+ tenant_id: system.tenant_id,
130
+ timestamp
131
+ };
132
+ }
133
+
134
+ /**
135
+ * File the `initiated` event. NEVER throws — a lost copy is reported, not raised.
136
+ *
137
+ * It is observability about a run that has already happened: refusing the run because the
138
+ * observer could not be told would trade a real effect for a record of it. Both services
139
+ * that build this event today made the same decision in their own words, and it moves here
140
+ * with the event. A missing logger is the one exception, and it is the transport half's
141
+ * rule (`monitoring-publish.js`): without a logger the promise "every failure is reported"
142
+ * cannot hold, so the call refuses instead of dropping events in silence.
143
+ *
144
+ * @param {Object} args
145
+ * @param {Object} args.mqClient - the connected `BaseClient`.
146
+ * @param {Object} args.logger - the service logger; REQUIRED.
147
+ * @param {string} args.workflowId
148
+ * @param {Object} args.redactedCookbook
149
+ * @param {string} args.serviceName
150
+ * @param {string} args.timestamp
151
+ * @param {Object} [args.context] - extra context for the log lines.
152
+ * @returns {Promise<boolean>} whether the event reached the broker.
153
+ */
154
+ async function publishWorkflowInitiated({
155
+ mqClient,
156
+ logger,
157
+ workflowId,
158
+ redactedCookbook,
159
+ serviceName,
160
+ timestamp,
161
+ context = {}
162
+ } = {}) {
163
+ assertLogger(
164
+ 'publishWorkflowInitiated',
165
+ logger,
166
+ 'a copy monitoring never received is reported instead of silently returning false',
167
+ 'pass the logger your service already built'
168
+ );
169
+
170
+ try {
171
+ const event = buildWorkflowInitiatedEvent({ workflowId, redactedCookbook, serviceName, timestamp });
172
+ return await publishToMonitoringWorkflow(mqClient, event, logger, { workflowId, ...context });
173
+ } catch (error) {
174
+ logger.warn('[monitoring-initiated] Failed to publish workflow_initiated to monitoring', {
175
+ ...context,
176
+ workflow_id: workflowId,
177
+ service_name: serviceName,
178
+ error: error.message
179
+ });
180
+ return false;
181
+ }
182
+ }
183
+
184
+ module.exports = {
185
+ buildWorkflowInitiatedEvent,
186
+ publishWorkflowInitiated
187
+ };
@@ -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
+ };