@open-mercato/events 0.8.1-develop.7296.1.2111d779db → 0.9.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.
@@ -1,2 +1,2 @@
1
- [build:events] found 9 entry points
1
+ [build:events] found 10 entry points
2
2
  [build:events] built successfully
package/AGENTS.md CHANGED
@@ -180,11 +180,29 @@ useAppEvent('mymod.entity.created', (event) => {
180
180
  - Recipient role: `recipientRoleId` or `recipientRoleIds` must intersect connection roles
181
181
  - Missing `tenantId` in event payload means no delivery
182
182
  - SSE sends heartbeats every 30s; client auto-reconnects if no heartbeat within 45s
183
+ - The staff DOM bridge accepts cookie and Bearer authentication only. It rejects `x-api-key` and `Authorization: ApiKey` before opening, and the UI reconnects immediately after the shared organization-scope change signal so the new request carries current selection cookies.
184
+ - SSE re-resolves canonical auth and organization scope from the original request credentials every 30s through the shared `organizationScopeService.resolveForRequest({ auth, request })` DI contract. It closes fail-closed when the scope is rejected or no longer allowed, validation fails, or its user, tenant, selected organization, or roles change. A successful check replaces the connection identity snapshot.
185
+ - Every server stream closes after 5 minutes even when validation succeeds, forcing native `EventSource` reconnect through fresh request authorization. Operators may lower or raise these positive millisecond bounds with `OM_EVENTS_SSE_AUTH_REVALIDATION_INTERVAL_MS` and `OM_EVENTS_SSE_CONNECTION_MAX_AGE_MS`; invalid values and values below 1000ms fall back to the defaults, and values above Node's safe timer maximum (`2^31-1`ms) are capped there.
183
186
  - Max payload size is 4096 bytes per event
184
187
  - Client deduplicates events within a 500ms window
185
188
  - `isBroadcastEvent(eventId)` checks if an event has `clientBroadcast: true`
186
189
  - The `useEventBridge()` hook must be mounted once in the app shell to start receiving events
187
190
 
191
+ ### Coalescing browser deliveries (bulk writers)
192
+
193
+ Every emit of a `clientBroadcast: true` event costs a serialized `pg_notify` roundtrip plus a tenant-wide SSE fan-out, so a bulk writer pays both once per record. Declare `broadcastCoalescing: true` to bound that:
194
+
195
+ ```typescript
196
+ { id: 'mymod.entity.created', label: 'Created', clientBroadcast: true, broadcastCoalescing: true },
197
+ ```
198
+
199
+ - Only the **browser** half coalesces. Inline subscribers, webhooks, workflow triggers and the queue still receive one event per record — the domain event is untouched.
200
+ - Within `OM_BROADCAST_COALESCE_INTERVAL_MS` (default 250, `0` disables process-wide) only the newest payload per audience (event, tenant, organizations, recipient users/roles) reaches the SSE bridges. The first emit of a burst goes out immediately, and a trailing flush is **always** armed, so the last emit is delivered and a DataTable never ends a burst stale.
201
+ - **When to declare it:** only when browser consumers react to the fact that something changed (a list refetch), never to each occurrence. A consumer that must see every record — a per-record toast, a running tally — MUST NOT have its event opted in; suppressed payloads are dropped, not merged.
202
+ - MUST NOT combine with `crossProcessBroadcast` — delaying private coordination would let another process serve stale data. `createModuleEvents` throws on that declaration, and on `broadcastCoalescing` without a browser sink.
203
+ - `isCoalescedBroadcastEvent(eventId)` reports the resolved decision. `progress.job.updated` keeps its own service-local throttle (`OM_PROGRESS_BROADCAST_MIN_INTERVAL_MS`) and is not affected.
204
+ - `registerGlobalEventTap` is not a browser-only sink — it is a public export that any process-local listener can use (integration-test event capture included), not just the two SSE endpoints. For an opted-in event, every tap sees the same coalesced, trailing-flush-guaranteed stream the browser does, never a call per record. Do not rely on a global tap to observe every occurrence of an event that declares `broadcastCoalescing: true`.
205
+
188
206
  ### Private cross-process coordination
189
207
 
190
208
  Use `crossProcessBroadcast: true` for server-to-server invalidation or coordination that must cross process boundaries but must not be exposed through browser SSE. The event bus publishes both `clientBroadcast` and `crossProcessBroadcast` events to the cross-process bridge, while the DOM Event Bridge delivers only `clientBroadcast` events. Do not use `clientBroadcast` merely to reach another server process when the payload contains record-scoped identifiers or activity that the organization-level SSE audience cannot authorize.
@@ -0,0 +1,124 @@
1
+ import { parseNumberWithDefault } from "@open-mercato/shared/lib/number";
2
+ import { createLogger } from "@open-mercato/shared/lib/logger";
3
+ const DEFAULT_COALESCE_INTERVAL_MS = 250;
4
+ const PENDING_BROADCASTS_KEY = "__openMercatoPendingBroadcasts__";
5
+ const logger = createLogger("events").child({ component: "broadcast-coalescer" });
6
+ function getPendingBroadcasts() {
7
+ const existing = globalThis[PENDING_BROADCASTS_KEY];
8
+ if (existing instanceof Map) {
9
+ return existing;
10
+ }
11
+ const created = /* @__PURE__ */ new Map();
12
+ globalThis[PENDING_BROADCASTS_KEY] = created;
13
+ return created;
14
+ }
15
+ function resolveBroadcastCoalesceIntervalMs() {
16
+ return parseNumberWithDefault(
17
+ process.env.OM_BROADCAST_COALESCE_INTERVAL_MS,
18
+ DEFAULT_COALESCE_INTERVAL_MS,
19
+ { min: 0, integer: true }
20
+ );
21
+ }
22
+ function armTimer(key, intervalMs) {
23
+ const timer = setTimeout(() => {
24
+ void onWindowClosed(key, intervalMs);
25
+ }, intervalMs);
26
+ if (typeof timer.unref === "function") {
27
+ ;
28
+ timer.unref();
29
+ }
30
+ return timer;
31
+ }
32
+ async function onWindowClosed(key, intervalMs) {
33
+ const entries = getPendingBroadcasts();
34
+ const entry = entries.get(key);
35
+ if (!entry) return;
36
+ const pending = entry.pending;
37
+ if (!pending) {
38
+ entry.timer = null;
39
+ entries.delete(key);
40
+ return;
41
+ }
42
+ const suppressed = entry.suppressed;
43
+ entry.pending = null;
44
+ entry.suppressed = 0;
45
+ entry.timer = null;
46
+ await runDispatch(pending, key);
47
+ if (suppressed > 0) {
48
+ logger.debug("Coalesced browser deliveries", { key, suppressed });
49
+ }
50
+ const current = entries.get(key);
51
+ if (!current) return;
52
+ if (current.pending) current.timer = armTimer(key, intervalMs);
53
+ else entries.delete(key);
54
+ }
55
+ async function runDispatch(dispatch, key) {
56
+ try {
57
+ await dispatch();
58
+ } catch (error) {
59
+ logger.error("Coalesced broadcast dispatch failed", { key, err: error });
60
+ }
61
+ }
62
+ async function submitBroadcast(key, dispatch, options) {
63
+ const intervalMs = options?.intervalMs ?? resolveBroadcastCoalesceIntervalMs();
64
+ if (intervalMs <= 0) {
65
+ await dispatch();
66
+ return;
67
+ }
68
+ const entries = getPendingBroadcasts();
69
+ const entry = entries.get(key);
70
+ if (!entry) {
71
+ entries.set(key, { pending: null, timer: armTimer(key, intervalMs), suppressed: 0 });
72
+ await dispatch();
73
+ return;
74
+ }
75
+ if (entry.pending) entry.suppressed += 1;
76
+ entry.pending = dispatch;
77
+ }
78
+ const BROADCAST_FLUSH_DEADLINE_MS = 3e3;
79
+ async function flushPendingBroadcasts(options) {
80
+ const entries = getPendingBroadcasts();
81
+ const drained = [];
82
+ for (const [key, entry] of entries) {
83
+ if (entry.timer) clearTimeout(entry.timer);
84
+ const pending = entry.pending;
85
+ entries.delete(key);
86
+ if (pending) drained.push(runDispatch(pending, key));
87
+ }
88
+ const settled = Promise.allSettled(drained).then(() => void 0);
89
+ const deadlineMs = options?.deadlineMs;
90
+ if (deadlineMs === void 0) {
91
+ await settled;
92
+ return;
93
+ }
94
+ let expire = () => {
95
+ };
96
+ const expired = new Promise((resolve) => {
97
+ expire = resolve;
98
+ });
99
+ const deadline = setTimeout(() => expire(), Math.max(0, deadlineMs));
100
+ if (typeof deadline.unref === "function") {
101
+ ;
102
+ deadline.unref();
103
+ }
104
+ try {
105
+ await Promise.race([settled, expired]);
106
+ } finally {
107
+ clearTimeout(deadline);
108
+ }
109
+ }
110
+ function resetBroadcastCoalescerForTests() {
111
+ const entries = getPendingBroadcasts();
112
+ for (const entry of entries.values()) {
113
+ if (entry.timer) clearTimeout(entry.timer);
114
+ }
115
+ entries.clear();
116
+ }
117
+ export {
118
+ BROADCAST_FLUSH_DEADLINE_MS,
119
+ flushPendingBroadcasts,
120
+ resetBroadcastCoalescerForTests,
121
+ resolveBroadcastCoalesceIntervalMs,
122
+ submitBroadcast
123
+ };
124
+ //# sourceMappingURL=broadcast-coalescer.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/broadcast-coalescer.ts"],
4
+ "sourcesContent": ["import { parseNumberWithDefault } from '@open-mercato/shared/lib/number'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\n/**\n * Coalesces the BROWSER half of an event emit.\n *\n * A `clientBroadcast: true` event costs a serialized `pg_notify` roundtrip plus a\n * tenant-wide SSE fan-out on every emit, so a bulk writer pays both once per\n * record even though its browser consumers only refresh a list. Events that opt\n * in with `broadcastCoalescing: true` route their browser dispatch through here:\n * the first emit of a burst goes out immediately (leading edge) and later emits\n * sharing a key replace the pending payload instead of being delivered.\n *\n * A trailing flush is ALWAYS armed, so the last emit of a burst is delivered and\n * a DataTable never ends the burst stale. This is where the mechanism goes past\n * the progress service's private throttle, which can drop its tail only because\n * its terminal transitions emit through a separate unthrottled path.\n *\n * Inline subscribers, webhooks and the queue never reach this module \u2014 the domain\n * event still fires once per record.\n */\n\nconst DEFAULT_COALESCE_INTERVAL_MS = 250\nconst PENDING_BROADCASTS_KEY = '__openMercatoPendingBroadcasts__'\n\nconst logger = createLogger('events').child({ component: 'broadcast-coalescer' })\n\ntype BroadcastDispatch = () => Promise<void>\n\ntype PendingBroadcast = {\n /** The newest superseding dispatch awaiting the trailing flush, if any. */\n pending: BroadcastDispatch | null\n timer: ReturnType<typeof setTimeout> | null\n suppressed: number\n}\n\n/**\n * Held on `globalThis` for the same reason as the bus's global taps and producer\n * queue: a fresh event bus is built per request, so per-bus state would coalesce\n * nothing across the emits of a single bulk job.\n */\nfunction getPendingBroadcasts(): Map<string, PendingBroadcast> {\n const existing = (globalThis as Record<string, unknown>)[PENDING_BROADCASTS_KEY]\n if (existing instanceof Map) {\n return existing as Map<string, PendingBroadcast>\n }\n const created = new Map<string, PendingBroadcast>()\n ;(globalThis as Record<string, unknown>)[PENDING_BROADCASTS_KEY] = created\n return created\n}\n\n/**\n * Minimum elapsed time between coalesced browser deliveries of one key. `0`\n * disables coalescing process-wide, restoring per-record delivery \u2014 the runtime\n * escape hatch, mirroring `OM_PROGRESS_BROADCAST_MIN_INTERVAL_MS=0`.\n */\nexport function resolveBroadcastCoalesceIntervalMs(): number {\n return parseNumberWithDefault(\n process.env.OM_BROADCAST_COALESCE_INTERVAL_MS,\n DEFAULT_COALESCE_INTERVAL_MS,\n { min: 0, integer: true },\n )\n}\n\nfunction armTimer(key: string, intervalMs: number): ReturnType<typeof setTimeout> {\n const timer = setTimeout(() => {\n void onWindowClosed(key, intervalMs)\n }, intervalMs)\n // Never keep a CLI or worker process alive just to flush a browser delivery.\n // The shutdown hook flushes what is still pending on the way out.\n if (typeof (timer as { unref?: () => void }).unref === 'function') {\n ;(timer as { unref: () => void }).unref()\n }\n return timer\n}\n\nasync function onWindowClosed(key: string, intervalMs: number): Promise<void> {\n const entries = getPendingBroadcasts()\n const entry = entries.get(key)\n if (!entry) return\n\n const pending = entry.pending\n if (!pending) {\n // The window closed with nothing superseded: the burst is over.\n entry.timer = null\n entries.delete(key)\n return\n }\n\n const suppressed = entry.suppressed\n entry.pending = null\n entry.suppressed = 0\n entry.timer = null\n\n await runDispatch(pending, key)\n if (suppressed > 0) {\n logger.debug('Coalesced browser deliveries', { key, suppressed })\n }\n\n // Re-arm only AFTER the dispatch settles, so one key never has two deliveries\n // in flight. Re-arming first would let a slow pg_notify roundtrip overlap the\n // next window and land an older payload last \u2014 the exact staleness coalescing\n // exists to prevent. As a side effect the window self-extends under load,\n // which is the right behavior for a throttle. Submissions arriving during the\n // dispatch still queue on the entry, which is still in the map.\n const current = entries.get(key)\n if (!current) return\n if (current.pending) current.timer = armTimer(key, intervalMs)\n else entries.delete(key)\n}\n\n/**\n * A deferred dispatch has no caller awaiting it, so its failure must be\n * contained here \u2014 `bus.emit` already logs-and-continues on a synchronous\n * publish failure, and one failed flush must not stop the next window.\n */\nasync function runDispatch(dispatch: BroadcastDispatch, key: string): Promise<void> {\n try {\n await dispatch()\n } catch (error) {\n logger.error('Coalesced broadcast dispatch failed', { key, err: error })\n }\n}\n\n/**\n * Submit a browser dispatch for `key`. Resolves once the caller's obligation is\n * discharged: immediately-dispatched submissions resolve after delivery,\n * superseded ones resolve as soon as they are queued.\n */\nexport async function submitBroadcast(\n key: string,\n dispatch: BroadcastDispatch,\n options?: { intervalMs?: number },\n): Promise<void> {\n const intervalMs = options?.intervalMs ?? resolveBroadcastCoalesceIntervalMs()\n if (intervalMs <= 0) {\n await dispatch()\n return\n }\n\n const entries = getPendingBroadcasts()\n const entry = entries.get(key)\n if (!entry) {\n entries.set(key, { pending: null, timer: armTimer(key, intervalMs), suppressed: 0 })\n await dispatch()\n return\n }\n\n if (entry.pending) entry.suppressed += 1\n entry.pending = dispatch\n}\n\n/**\n * Upper bound for a flush on the way out of a process. Fixed, and independent of\n * and shorter than OM_BROADCAST_COALESCE_INTERVAL_MS's own window, so a stalled\n * Postgres connection at exit degrades to the already-accepted dropped-tail case\n * instead of hanging the exit.\n */\nexport const BROADCAST_FLUSH_DEADLINE_MS = 3000\n\n/**\n * Deliver every survivor still awaiting its trailing flush. Wired into the\n * process shutdown hook and the CLI exit path so a stop does not swallow the tail\n * of a burst. Never rejects: every entry is settled on its own. With\n * `deadlineMs`, resolves no later than that even if a dispatch hangs.\n */\nexport async function flushPendingBroadcasts(options?: { deadlineMs?: number }): Promise<void> {\n const entries = getPendingBroadcasts()\n const drained: Array<Promise<void>> = []\n for (const [key, entry] of entries) {\n if (entry.timer) clearTimeout(entry.timer)\n const pending = entry.pending\n entries.delete(key)\n if (pending) drained.push(runDispatch(pending, key))\n }\n const settled = Promise.allSettled(drained).then(() => undefined)\n const deadlineMs = options?.deadlineMs\n if (deadlineMs === undefined) {\n await settled\n return\n }\n let expire: () => void = () => {}\n const expired = new Promise<void>((resolve) => {\n expire = resolve\n })\n const deadline = setTimeout(() => expire(), Math.max(0, deadlineMs))\n if (typeof (deadline as { unref?: () => void }).unref === 'function') {\n ;(deadline as { unref: () => void }).unref()\n }\n try {\n await Promise.race([settled, expired])\n } finally {\n clearTimeout(deadline)\n }\n}\n\n/** Drop all pending state without dispatching. Tests only. */\nexport function resetBroadcastCoalescerForTests(): void {\n const entries = getPendingBroadcasts()\n for (const entry of entries.values()) {\n if (entry.timer) clearTimeout(entry.timer)\n }\n entries.clear()\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
package/dist/bus.js CHANGED
@@ -6,9 +6,16 @@ import { matchEventPattern } from "@open-mercato/shared/lib/events/patterns";
6
6
  import { getRedisUrlOrThrow } from "@open-mercato/shared/lib/redis/connection";
7
7
  import {
8
8
  isBroadcastEvent,
9
+ isCoalescedBroadcastEvent,
9
10
  isCrossProcessBroadcastEvent,
10
11
  isPrivateCrossProcessEventEmitter
11
12
  } from "@open-mercato/shared/modules/events";
13
+ import {
14
+ BROADCAST_FLUSH_DEADLINE_MS,
15
+ flushPendingBroadcasts,
16
+ resolveBroadcastCoalesceIntervalMs,
17
+ submitBroadcast
18
+ } from "./broadcast-coalescer.js";
12
19
  import {
13
20
  inferModuleIdFromResourceId,
14
21
  withModuleResourceUsage
@@ -42,6 +49,58 @@ function resolveCrossProcessEmitOptions(event, payload, options) {
42
49
  ...organizationIds.length > 0 ? { organizationIds } : {}
43
50
  };
44
51
  }
52
+ function collectStringScopes(input) {
53
+ if (!Array.isArray(input)) return [];
54
+ const values = [];
55
+ for (const value of input) {
56
+ if (typeof value !== "string") continue;
57
+ const trimmed = value.trim();
58
+ if (!trimmed) continue;
59
+ values.push(trimmed);
60
+ }
61
+ return values;
62
+ }
63
+ function sortScopes(scopes) {
64
+ return Array.from(scopes).sort((left, right) => left.localeCompare(right));
65
+ }
66
+ function buildBroadcastCoalesceKey(event, payload, options) {
67
+ const data = payload ?? {};
68
+ const payloadTenantId = normalizePayloadScope(data.tenantId) ?? "";
69
+ const optionsTenantToken = options && Object.prototype.hasOwnProperty.call(options, "tenantId") ? `=${normalizePayloadScope(options.tenantId) ?? ""}` : "absent";
70
+ const optionsOrganizationScopes = /* @__PURE__ */ new Set();
71
+ const optionsOrganizationId = normalizePayloadScope(options?.organizationId);
72
+ if (optionsOrganizationId) optionsOrganizationScopes.add(optionsOrganizationId);
73
+ for (const organizationId of collectStringScopes(options?.organizationIds)) {
74
+ optionsOrganizationScopes.add(organizationId);
75
+ }
76
+ const payloadOrganizationScopes = /* @__PURE__ */ new Set();
77
+ const payloadOrganizationId = normalizePayloadScope(data.organizationId);
78
+ if (payloadOrganizationId) payloadOrganizationScopes.add(payloadOrganizationId);
79
+ for (const organizationId of collectStringScopes(data.organizationIds)) {
80
+ payloadOrganizationScopes.add(organizationId);
81
+ }
82
+ const recipientUserScopes = /* @__PURE__ */ new Set();
83
+ const recipientUserId = normalizePayloadScope(data.recipientUserId);
84
+ if (recipientUserId) recipientUserScopes.add(recipientUserId);
85
+ for (const userId of collectStringScopes(data.recipientUserIds)) {
86
+ recipientUserScopes.add(userId);
87
+ }
88
+ const recipientRoleScopes = /* @__PURE__ */ new Set();
89
+ const recipientRoleId = normalizePayloadScope(data.recipientRoleId);
90
+ if (recipientRoleId) recipientRoleScopes.add(recipientRoleId);
91
+ for (const roleId of collectStringScopes(data.recipientRoleIds)) {
92
+ recipientRoleScopes.add(roleId);
93
+ }
94
+ return [
95
+ event,
96
+ payloadTenantId,
97
+ optionsTenantToken,
98
+ sortScopes(payloadOrganizationScopes).join(","),
99
+ sortScopes(optionsOrganizationScopes).join(","),
100
+ sortScopes(recipientUserScopes).join(","),
101
+ sortScopes(recipientRoleScopes).join(",")
102
+ ].join("::");
103
+ }
45
104
  function getGlobalEventTaps() {
46
105
  const existing = globalThis[GLOBAL_EVENT_TAPS_KEY];
47
106
  if (existing instanceof Set) {
@@ -60,6 +119,7 @@ function registerGlobalEventTap(handler) {
60
119
  }
61
120
  const EVENTS_PRODUCER_QUEUE_KEY = "__openMercatoEventsProducerQueues__";
62
121
  const EVENTS_PRODUCER_SHUTDOWN_KEY = "__openMercatoEventsProducerShutdown__";
122
+ const BROADCAST_COALESCER_SHUTDOWN_KEY = "__openMercatoBroadcastCoalescerShutdown__";
63
123
  function isSharedProducerEnabled() {
64
124
  return parseBooleanWithDefault(process.env.OM_EVENTS_SHARED_PRODUCER, true);
65
125
  }
@@ -86,7 +146,26 @@ function registerProducerShutdownHook() {
86
146
  process.once("SIGINT", shutdown);
87
147
  globalThis[EVENTS_PRODUCER_SHUTDOWN_KEY] = true;
88
148
  }
149
+ function registerBroadcastCoalescerShutdownHook() {
150
+ if (globalThis[BROADCAST_COALESCER_SHUTDOWN_KEY]) return;
151
+ const handleSignal = (signal) => {
152
+ void flushPendingBroadcasts({ deadlineMs: BROADCAST_FLUSH_DEADLINE_MS }).catch(() => {
153
+ }).finally(() => {
154
+ if (process.listenerCount(signal) === 0) {
155
+ process.kill(process.pid, signal);
156
+ }
157
+ });
158
+ };
159
+ process.once("SIGTERM", () => handleSignal("SIGTERM"));
160
+ process.once("SIGINT", () => handleSignal("SIGINT"));
161
+ process.once("beforeExit", () => {
162
+ void flushPendingBroadcasts({ deadlineMs: BROADCAST_FLUSH_DEADLINE_MS }).catch(() => {
163
+ });
164
+ });
165
+ globalThis[BROADCAST_COALESCER_SHUTDOWN_KEY] = true;
166
+ }
89
167
  function createEventBus(opts) {
168
+ registerBroadcastCoalescerShutdownHook();
90
169
  const listeners = /* @__PURE__ */ new Map();
91
170
  const persistentSubscribers = /* @__PURE__ */ new Set();
92
171
  const queueStrategy = opts.queueStrategy ?? (process.env.QUEUE_STRATEGY === "async" ? "async" : "local");
@@ -214,13 +293,36 @@ function createEventBus(opts) {
214
293
  }
215
294
  }
216
295
  async function emit(event, payload, options) {
217
- const taps = getGlobalEventTaps();
218
- for (const tap of taps) {
296
+ const runGlobalTaps = async () => {
297
+ const taps = getGlobalEventTaps();
298
+ for (const tap of taps) {
299
+ try {
300
+ await Promise.resolve(tap(event, payload, options));
301
+ } catch (error) {
302
+ logger.error("Global tap error", { event, err: error });
303
+ }
304
+ }
305
+ };
306
+ const publishToOtherProcesses = async () => {
307
+ const crossProcessOptions = resolveCrossProcessEmitOptions(event, payload, options);
308
+ if (!isCrossProcessBroadcastEvent(event) || !hasTrustedTenantScope(crossProcessOptions) || !isPrivateCrossProcessEventEmitter(event, crossProcessOptions?.emitterModuleId)) return;
219
309
  try {
220
- await Promise.resolve(tap(event, payload, options));
310
+ await publishCrossProcessEvent(event, payload, crossProcessOptions);
221
311
  } catch (error) {
222
- logger.error("Global tap error", { event, err: error });
312
+ logger.error("Cross-process publish error", { event, err: error });
223
313
  }
314
+ };
315
+ const coalesceBrowserDelivery = isCoalescedBroadcastEvent(event) && resolveBroadcastCoalesceIntervalMs() > 0;
316
+ if (coalesceBrowserDelivery) {
317
+ await submitBroadcast(
318
+ buildBroadcastCoalesceKey(event, payload, options),
319
+ async () => {
320
+ await runGlobalTaps();
321
+ await publishToOtherProcesses();
322
+ }
323
+ );
324
+ } else {
325
+ await runGlobalTaps();
224
326
  }
225
327
  const enqueueOnly = Boolean(options?.persistent) && options?.deliverInline === false;
226
328
  const singleDelivery = isSingleDeliveryEnabled();
@@ -230,13 +332,8 @@ function createEventBus(opts) {
230
332
  const delivered = await deliver(event, payload, options, skipPersistentInline);
231
333
  inlinePersistentFailed = delivered.persistentFailures > 0;
232
334
  }
233
- const crossProcessOptions = resolveCrossProcessEmitOptions(event, payload, options);
234
- if (isCrossProcessBroadcastEvent(event) && hasTrustedTenantScope(crossProcessOptions) && isPrivateCrossProcessEventEmitter(event, crossProcessOptions?.emitterModuleId)) {
235
- try {
236
- await publishCrossProcessEvent(event, payload, crossProcessOptions);
237
- } catch (error) {
238
- logger.error("Cross-process publish error", { event, err: error });
239
- }
335
+ if (!coalesceBrowserDelivery) {
336
+ await publishToOtherProcesses();
240
337
  }
241
338
  if (options?.persistent) {
242
339
  const q = getQueue();
package/dist/bus.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/bus.ts"],
4
- "sourcesContent": ["import { createQueue } from '@open-mercato/queue'\nimport type { Queue } from '@open-mercato/queue'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { isSingleDeliveryRequested } from './single-delivery'\nimport { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport {\n isBroadcastEvent,\n isCrossProcessBroadcastEvent,\n isPrivateCrossProcessEventEmitter,\n} from '@open-mercato/shared/modules/events'\nimport {\n inferModuleIdFromResourceId,\n withModuleResourceUsage,\n} from '@open-mercato/shared/lib/modules/resource-usage'\nexport { registerCrossProcessEventListener, CROSS_PROCESS_EVENT_INSTANCE_ID } from './bridge'\nimport { publishCrossProcessEvent } from './bridge'\nimport type {\n EventBus,\n CreateBusOptions,\n SubscriberHandler,\n SubscriberDescriptor,\n EventPayload,\n EmitOptions,\n QueuedDispatchResult,\n} from './types'\n\n/** Queue name for persistent events */\nconst EVENTS_QUEUE_NAME = 'events'\n\nconst logger = createLogger('events')\n\ntype RegisteredSubscriber = {\n id?: string\n moduleId?: string\n event: string\n handler: SubscriberHandler\n persistent?: boolean\n}\n\n/**\n * When enabled, a persistent emit delivers each subscriber on exactly one path:\n * persistent-marked subscribers are skipped inline (the events worker dispatches\n * them via pattern match, so wildcard persistent subscribers are reached), while\n * ephemeral subscribers keep running inline. Defaults ON.\n *\n * The bus never second-guesses this against \"is a worker running\": the queue is\n * durable, so a persistent emit with no worker yet is delayed, not lost, and a\n * worker started later drains the backlog. Delivering inline instead would move\n * the work back onto the caller's request path \u2014 exactly what a split app/worker\n * deployment sets `AUTO_SPAWN_WORKERS=false` to avoid.\n *\n * The events worker asks this same bus for its subscribers via `dispatchQueued`,\n * so both halves of single-delivery read one decision.\n */\nfunction isSingleDeliveryEnabled(): boolean {\n return isSingleDeliveryRequested()\n}\n\ntype GlobalEventTap = (event: string, payload: EventPayload, options?: EmitOptions) => void | Promise<void>\nconst GLOBAL_EVENT_TAPS_KEY = '__openMercatoEventBusGlobalTaps__'\n\nfunction hasTrustedTenantScope(options?: EmitOptions): boolean {\n return typeof options?.tenantId === 'string' && options.tenantId.trim().length > 0\n}\n\nfunction normalizePayloadScope(value: unknown): string | null {\n return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null\n}\n\nfunction resolveCrossProcessEmitOptions(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n): EmitOptions | undefined {\n if (hasTrustedTenantScope(options)) return options\n // Preserve the public raw EventBus contract for legacy clientBroadcast\n // emitters. These callers are trusted server code and historically supplied\n // their scope in the typed payload. Tenant-managed workflow execution passes\n // scope in options, while private cross-process events never take this path.\n if (!isBroadcastEvent(event)) return options\n const tenantId = normalizePayloadScope((payload as Record<string, unknown>)?.tenantId)\n if (!tenantId) return options\n const organizationId = normalizePayloadScope((payload as Record<string, unknown>)?.organizationId)\n const organizationIdsValue = (payload as Record<string, unknown>)?.organizationIds\n const organizationIds = Array.isArray(organizationIdsValue)\n ? organizationIdsValue.filter((value): value is string => typeof value === 'string')\n : []\n return {\n ...options,\n tenantId,\n ...(organizationId ? { organizationId } : {}),\n ...(organizationIds.length > 0 ? { organizationIds } : {}),\n }\n}\n\nfunction getGlobalEventTaps(): Set<GlobalEventTap> {\n const existing = (globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY]\n if (existing instanceof Set) {\n return existing as Set<GlobalEventTap>\n }\n const created = new Set<GlobalEventTap>()\n ;(globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY] = created\n return created\n}\n\nexport function registerGlobalEventTap(handler: GlobalEventTap): () => void {\n const taps = getGlobalEventTaps()\n taps.add(handler)\n return () => {\n taps.delete(handler)\n }\n}\n\n/** Job data structure for queued events */\ntype EventJobData = {\n event: string\n payload: EventPayload\n options?: EmitOptions\n /**\n * Set by the producer when it already ran the persistent subscribers inline\n * (single-delivery reconciled off). The worker skips such jobs, so the app and\n * the worker staying exactly-once no longer depends on both processes agreeing\n * about `OM_EVENTS_SINGLE_DELIVERY`. Absent on jobs queued before this field\n * existed, which keeps their behavior unchanged.\n */\n persistentDeliveredInline?: boolean\n}\n\n// Process-wide cache of the async (BullMQ) persistent-events producer queue.\n// Each authenticated request builds a fresh DI container and event bus, so a\n// per-bus producer queue opened a new ioredis connection per write request that\n// was never closed \u2014 leaking one Redis connection per request until maxclients\n// exhaustion. Memoizing the producer on `globalThis` (keyed by Redis URL, so a\n// reconfigured URL still gets its own queue) keeps it at one connection per\n// process, mirroring the `GLOBAL_EVENT_TAPS_KEY` and `getCachedRateLimiterService`\n// patterns. The local (file-based) strategy holds no pooled connection and its\n// base dir is cwd-relative, so it stays per-bus.\nconst EVENTS_PRODUCER_QUEUE_KEY = '__openMercatoEventsProducerQueues__'\nconst EVENTS_PRODUCER_SHUTDOWN_KEY = '__openMercatoEventsProducerShutdown__'\n\nfunction isSharedProducerEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_EVENTS_SHARED_PRODUCER, true)\n}\n\nfunction getProducerQueueRegistry(): Map<string, Queue<EventJobData>> {\n const existing = (globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY]\n if (existing instanceof Map) {\n return existing as Map<string, Queue<EventJobData>>\n }\n const created = new Map<string, Queue<EventJobData>>()\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY] = created\n return created\n}\n\nfunction registerProducerShutdownHook(): void {\n if ((globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY]) return\n const shutdown = () => {\n const registry = getProducerQueueRegistry()\n for (const sharedQueue of registry.values()) {\n Promise.resolve(sharedQueue.close()).catch(() => {})\n }\n registry.clear()\n }\n process.once('SIGTERM', shutdown)\n process.once('SIGINT', shutdown)\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY] = true\n}\n\n/**\n * Creates an event bus instance.\n *\n * The event bus provides:\n * - In-memory event delivery to registered handlers\n * - Optional persistence via the queue package when `persistent: true`\n *\n * @param opts - Configuration options\n * @returns An EventBus instance\n *\n * @example\n * ```typescript\n * const bus = createEventBus({\n * resolve: container.resolve.bind(container),\n * queueStrategy: 'local', // or 'async' for BullMQ\n * })\n *\n * // Register a handler\n * bus.on('user.created', async (payload, ctx) => {\n * const userService = ctx.resolve('userService')\n * await userService.sendWelcomeEmail(payload.userId)\n * })\n *\n * // Emit an event (immediate delivery)\n * await bus.emit('user.created', { userId: '123' })\n *\n * // Emit with persistence (for async worker processing)\n * await bus.emit('order.placed', { orderId: '456' }, { persistent: true })\n * ```\n */\nexport function createEventBus(opts: CreateBusOptions): EventBus {\n // In-memory listeners for immediate event delivery\n const listeners = new Map<string, Set<RegisteredSubscriber>>()\n // Subscribers registered as persistent (worker-dispatched). Used by the\n // single-delivery path to skip them inline on a persistent emit.\n const persistentSubscribers = new Set<RegisteredSubscriber>()\n\n // Determine queue strategy from options or environment\n const queueStrategy = opts.queueStrategy ??\n (process.env.QUEUE_STRATEGY === 'async' ? 'async' : 'local')\n\n // Lazy-initialized queue for persistent events\n let queue: Queue<EventJobData> | null = null\n\n /**\n * Gets or creates the queue instance for persistent events.\n *\n * The async (BullMQ) producer is memoized process-wide so the per-request\n * event bus reuses one Redis connection instead of leaking one per write\n * request. The local strategy stays per-bus (no pooled connection, cwd-relative\n * base dir). Set `OM_EVENTS_SHARED_PRODUCER=0` to fall back to per-bus producers.\n */\n function getQueue(): Queue<EventJobData> {\n if (queueStrategy !== 'async' || !isSharedProducerEnabled()) {\n if (!queue) {\n queue = queueStrategy === 'async'\n ? createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n : createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'local')\n }\n return queue\n }\n\n const redisUrl = getRedisUrlOrThrow('QUEUE')\n const registry = getProducerQueueRegistry()\n const cacheKey = `async:${redisUrl}`\n let shared = registry.get(cacheKey)\n if (!shared) {\n shared = createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: redisUrl },\n })\n registry.set(cacheKey, shared)\n registerProducerShutdownHook()\n }\n return shared\n }\n\n /**\n * Delivers an event to all registered in-memory handlers.\n * Supports wildcard pattern matching for event patterns.\n *\n * Handler errors are logged and delivery continues - one bad subscriber must\n * not stop the others. The count of failed PERSISTENT handlers is returned so\n * the caller can decide whether the inline run may be recorded as complete:\n * a persistent subscriber that threw inline has no retry of its own, so the\n * queued job must stay unstamped and let the worker run it again.\n */\n async function deliver(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n skipPersistent = false,\n ): Promise<{ persistentFailures: number }> {\n let persistentFailures = 0\n // Check all registered patterns (including wildcards)\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) continue\n if (!handlers || handlers.size === 0) continue\n\n for (const subscriber of handlers) {\n // Single-delivery: persistent subscribers are dispatched by the worker,\n // so skip them inline to avoid double execution.\n if (skipPersistent && persistentSubscribers.has(subscriber)) continue\n try {\n // Pass eventName in context for wildcard handlers\n const moduleId = subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id)\n await withModuleResourceUsage(\n {\n moduleId,\n surface: 'subscriber',\n operation: `${event} -> ${subscriber.id ?? subscriber.event}`,\n resourceId: subscriber.id ?? subscriber.event,\n },\n () => subscriber.handler(payload, {\n resolve: opts.resolve,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }),\n )\n } catch (error) {\n if (persistentSubscribers.has(subscriber)) {\n persistentFailures += 1\n }\n logger.error('Handler error', { event, pattern, err: error })\n if (options?.rethrowHandlerErrors) {\n throw error\n }\n }\n }\n }\n return { persistentFailures }\n }\n\n /**\n * Selects the subscribers the events worker owns for a queued event: every\n * pattern matching `event`, keeping persistent subscribers only, so wildcard\n * (`event: '*'`) persistent subscribers are reached exactly once here.\n *\n * Deliberately does NOT read `OM_EVENTS_SINGLE_DELIVERY`. Whether inline\n * delivery already happened is carried by the job's `persistentDeliveredInline`\n * stamp, so the producer owns that decision entirely and the worker cannot\n * disagree with it across processes. Reading the flag here would reintroduce\n * exactly that skew: a worker whose env has the flag off would fall back to\n * exact-match, re-running ephemeral subscribers the producer already ran inline\n * and never reaching wildcard persistent subscribers - the failure this whole\n * change exists to remove.\n *\n * Every job that reaches this point wants persistent-pattern selection: a job\n * is only dispatched when its stamp is false, which means either the producer\n * had single-delivery on, or the emit was enqueue-only (`deliverInline: false`,\n * documented as \"every subscriber to the event is persistent\").\n */\n function selectQueuedSubscribers(event: string): RegisteredSubscriber[] {\n const matched: RegisteredSubscriber[] = []\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) {\n continue\n }\n for (const subscriber of handlers) {\n if (subscriber.persistent) {\n matched.push(subscriber)\n }\n }\n }\n return matched\n }\n\n /**\n * Dispatches a queued event on behalf of the events worker.\n *\n * Handler failures are returned instead of logged-and-swallowed the way\n * `deliver()` does them: the worker needs them to propagate so the queue retries\n * the job and eventually dead-letters it.\n *\n * Subscribers run against the caller's `resolve` when one is given, falling back\n * to the bus's own. The events worker passes its per-job `ctx.resolve`, which is\n * the container `createPerJobWorkerHandler` built for that job - the handle the\n * queue contract already hands it. Defaulting to `opts.resolve` instead would\n * bind subscribers to whichever container built the bus; that is the same object\n * on the default configuration, but not under `OM_BOOTSTRAP_CACHE`, where the\n * cached bus is replayed into later containers while its captured resolver stays\n * bound to the first.\n */\n async function dispatchQueued(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n resolve?: <T = unknown>(name: string) => T,\n ): Promise<QueuedDispatchResult[]> {\n const subscribers = selectQueuedSubscribers(event)\n if (subscribers.length === 0) {\n return []\n }\n\n const resolver = resolve ?? opts.resolve\n\n const settled = await Promise.allSettled(\n subscribers.map((subscriber) =>\n withModuleResourceUsage(\n {\n moduleId: subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id),\n surface: 'subscriber',\n operation: `${event} -> ${subscriber.id ?? subscriber.event}`,\n resourceId: subscriber.id ?? subscriber.event,\n },\n () => subscriber.handler(payload, {\n resolve: resolver,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }),\n ),\n ),\n )\n\n return settled.map((result, index) => {\n const subscriberId = subscribers[index].id ?? subscribers[index].event\n return result.status === 'rejected'\n ? { subscriberId, ok: false, error: result.reason }\n : { subscriberId, ok: true }\n })\n }\n\n /**\n * Registers a handler for an event. Pass `moduleId` whenever the subscribing\n * module is known (e.g. a module reacting to another module's event) so\n * resource-usage telemetry attributes the handler correctly instead of\n * guessing a module id from the event name.\n */\n function on(\n event: string,\n handler: SubscriberHandler,\n options?: { persistent?: boolean; moduleId?: string; id?: string },\n ): void {\n if (!listeners.has(event)) {\n listeners.set(event, new Set())\n }\n const subscriber: RegisteredSubscriber = {\n id: options?.id,\n moduleId: options?.moduleId,\n event,\n handler,\n persistent: options?.persistent,\n }\n listeners.get(event)!.add(subscriber)\n if (options?.persistent) {\n persistentSubscribers.add(subscriber)\n }\n }\n\n /**\n * Registers multiple module subscribers at once.\n */\n function registerModuleSubscribers(subs: SubscriberDescriptor[]): void {\n for (const sub of subs) {\n on(sub.event, sub.handler, { persistent: sub.persistent, moduleId: sub.moduleId, id: sub.id })\n }\n }\n\n /**\n * Emits an event to all registered handlers.\n *\n * If `persistent: true`, also enqueues the event for async processing.\n */\n async function emit(\n event: string,\n payload: EventPayload,\n options?: EmitOptions\n ): Promise<void> {\n const taps = getGlobalEventTaps()\n for (const tap of taps) {\n try {\n await Promise.resolve(tap(event, payload, options))\n } catch (error) {\n logger.error('Global tap error', { event, err: error })\n }\n }\n\n // Deliver to in-memory handlers first. Under single-delivery, persistent\n // subscribers are skipped inline on a persistent emit because the events\n // worker will dispatch them from the queue.\n //\n // `deliverInline: false` on a persistent emit is enqueue-only: skip inline\n // delivery entirely so a heavy persistent job runs solely in the events\n // worker, off the caller's request path (the queued enqueue below still\n // happens). Without this, a persistent emit dual-dispatches by default and\n // runs the subscriber inline in the caller's request too.\n const enqueueOnly = Boolean(options?.persistent) && options?.deliverInline === false\n // Read the mode once so the inline skip and the stamp below cannot disagree.\n const singleDelivery = isSingleDeliveryEnabled()\n const skipPersistentInline = options?.skipPersistentSubscribersInline === true\n || (Boolean(options?.persistent) && singleDelivery)\n let inlinePersistentFailed = false\n if (!enqueueOnly) {\n const delivered = await deliver(event, payload, options, skipPersistentInline)\n inlinePersistentFailed = delivered.persistentFailures > 0\n }\n\n // Private coordination always requires trusted envelope scope and module\n // provenance. Legacy declared browser events may promote their typed scope\n // for raw EventBus compatibility; tenant-managed workflow adapters pass\n // authoritative scope in options, which always wins over payload values.\n const crossProcessOptions = resolveCrossProcessEmitOptions(event, payload, options)\n if (\n isCrossProcessBroadcastEvent(event)\n && hasTrustedTenantScope(crossProcessOptions)\n && isPrivateCrossProcessEventEmitter(event, crossProcessOptions?.emitterModuleId)\n ) {\n try {\n await publishCrossProcessEvent(event, payload, crossProcessOptions)\n } catch (error) {\n logger.error('Cross-process publish error', { event, err: error })\n }\n }\n\n // If persistent, also enqueue for async processing\n if (options?.persistent) {\n const q = getQueue()\n await q.enqueue({\n event,\n payload,\n options,\n // Only record the inline run as complete when every persistent\n // subscriber actually succeeded. Inline delivery logs-and-continues, so a\n // handler that threw has no retry of its own; leaving the job unstamped\n // hands it to the worker, where a failure gets queue retry and\n // dead-lettering. Without this, flipping the flag off (which the\n // `mercato server` guard can do automatically) silently downgrades\n // persistent delivery to at-most-once.\n persistentDeliveredInline: !enqueueOnly && !skipPersistentInline && !inlinePersistentFailed,\n })\n }\n }\n\n /**\n * Clears all events from the persistent queue.\n */\n async function clearQueue(): Promise<{ removed: number }> {\n const q = getQueue()\n return q.clear()\n }\n\n // Backward compatibility alias\n const emitEvent = emit\n\n return {\n emit,\n emitEvent, // Alias for backward compatibility\n on,\n registerModuleSubscribers,\n dispatchQueued,\n clearQueue,\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { createQueue } from '@open-mercato/queue'\nimport type { Queue } from '@open-mercato/queue'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { isSingleDeliveryRequested } from './single-delivery'\nimport { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport {\n isBroadcastEvent,\n isCoalescedBroadcastEvent,\n isCrossProcessBroadcastEvent,\n isPrivateCrossProcessEventEmitter,\n} from '@open-mercato/shared/modules/events'\nimport {\n BROADCAST_FLUSH_DEADLINE_MS,\n flushPendingBroadcasts,\n resolveBroadcastCoalesceIntervalMs,\n submitBroadcast,\n} from './broadcast-coalescer'\nimport {\n inferModuleIdFromResourceId,\n withModuleResourceUsage,\n} from '@open-mercato/shared/lib/modules/resource-usage'\nexport { registerCrossProcessEventListener, CROSS_PROCESS_EVENT_INSTANCE_ID } from './bridge'\nimport { publishCrossProcessEvent } from './bridge'\nimport type {\n EventBus,\n CreateBusOptions,\n SubscriberHandler,\n SubscriberDescriptor,\n EventPayload,\n EmitOptions,\n QueuedDispatchResult,\n} from './types'\n\n/** Queue name for persistent events */\nconst EVENTS_QUEUE_NAME = 'events'\n\nconst logger = createLogger('events')\n\ntype RegisteredSubscriber = {\n id?: string\n moduleId?: string\n event: string\n handler: SubscriberHandler\n persistent?: boolean\n}\n\n/**\n * When enabled, a persistent emit delivers each subscriber on exactly one path:\n * persistent-marked subscribers are skipped inline (the events worker dispatches\n * them via pattern match, so wildcard persistent subscribers are reached), while\n * ephemeral subscribers keep running inline. Defaults ON.\n *\n * The bus never second-guesses this against \"is a worker running\": the queue is\n * durable, so a persistent emit with no worker yet is delayed, not lost, and a\n * worker started later drains the backlog. Delivering inline instead would move\n * the work back onto the caller's request path \u2014 exactly what a split app/worker\n * deployment sets `AUTO_SPAWN_WORKERS=false` to avoid.\n *\n * The events worker asks this same bus for its subscribers via `dispatchQueued`,\n * so both halves of single-delivery read one decision.\n */\nfunction isSingleDeliveryEnabled(): boolean {\n return isSingleDeliveryRequested()\n}\n\ntype GlobalEventTap = (event: string, payload: EventPayload, options?: EmitOptions) => void | Promise<void>\nconst GLOBAL_EVENT_TAPS_KEY = '__openMercatoEventBusGlobalTaps__'\n\nfunction hasTrustedTenantScope(options?: EmitOptions): boolean {\n return typeof options?.tenantId === 'string' && options.tenantId.trim().length > 0\n}\n\nfunction normalizePayloadScope(value: unknown): string | null {\n return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null\n}\n\nfunction resolveCrossProcessEmitOptions(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n): EmitOptions | undefined {\n if (hasTrustedTenantScope(options)) return options\n // Preserve the public raw EventBus contract for legacy clientBroadcast\n // emitters. These callers are trusted server code and historically supplied\n // their scope in the typed payload. Tenant-managed workflow execution passes\n // scope in options, while private cross-process events never take this path.\n if (!isBroadcastEvent(event)) return options\n const tenantId = normalizePayloadScope((payload as Record<string, unknown>)?.tenantId)\n if (!tenantId) return options\n const organizationId = normalizePayloadScope((payload as Record<string, unknown>)?.organizationId)\n const organizationIdsValue = (payload as Record<string, unknown>)?.organizationIds\n const organizationIds = Array.isArray(organizationIdsValue)\n ? organizationIdsValue.filter((value): value is string => typeof value === 'string')\n : []\n return {\n ...options,\n tenantId,\n ...(organizationId ? { organizationId } : {}),\n ...(organizationIds.length > 0 ? { organizationIds } : {}),\n }\n}\n\nfunction collectStringScopes(input: unknown): string[] {\n if (!Array.isArray(input)) return []\n const values: string[] = []\n for (const value of input) {\n if (typeof value !== 'string') continue\n const trimmed = value.trim()\n if (!trimmed) continue\n values.push(trimmed)\n }\n return values\n}\n\n// Sorted so an audience keys identically however the caller ordered it, and\n// explicitly comparator-ed per the #3620 guard.\nfunction sortScopes(scopes: Set<string>): string[] {\n return Array.from(scopes).sort((left, right) => left.localeCompare(right))\n}\n\n/**\n * Coalescing key for the browser dispatch of one event.\n *\n * Built from the raw union of every dimension the SSE audience filter\n * (`matchesAudience` in `events/api/stream/route.ts`) reads \u2014 tenant,\n * organization, recipient user and recipient role \u2014 taken from BOTH the\n * trusted `options` and the payload, without calling `resolveCrossProcessEmitOptions`\n * or any other resolver. Those resolvers pick one source to trust for delivery\n * semantics; the key instead has to key on every dimension a connection can be\n * filtered by, or one audience's emit would silently replace another\n * audience's pending delivery \u2014 recipient/role targeting and a portal-only\n * event (which carries no trusted `options` scope at all) included.\n *\n * Options and payload scope stay in separate segments, never merged: the three\n * filters each read a different source, so `options` org A + payload org B must\n * not key like the reverse. The options tenant segment encodes PRESENCE, not\n * just value \u2014 the backoffice `hasTrustedScope` is a presence test, so `{}` and\n * `{ tenantId: undefined }` take different filter branches and must not share\n * a key.\n *\n * Scope is part of the key by necessity, not for granularity: a key with no\n * scope segments would let a burst in one tenant suppress another tenant's\n * delivery and then deliver the first tenant's payload in its place.\n */\nfunction buildBroadcastCoalesceKey(event: string, payload: EventPayload, options?: EmitOptions): string {\n const data = (payload ?? {}) as Record<string, unknown>\n\n const payloadTenantId = normalizePayloadScope(data.tenantId) ?? ''\n const optionsTenantToken = options && Object.prototype.hasOwnProperty.call(options, 'tenantId')\n ? `=${normalizePayloadScope(options.tenantId) ?? ''}`\n : 'absent'\n\n const optionsOrganizationScopes = new Set<string>()\n const optionsOrganizationId = normalizePayloadScope(options?.organizationId)\n if (optionsOrganizationId) optionsOrganizationScopes.add(optionsOrganizationId)\n for (const organizationId of collectStringScopes(options?.organizationIds)) {\n optionsOrganizationScopes.add(organizationId)\n }\n\n const payloadOrganizationScopes = new Set<string>()\n const payloadOrganizationId = normalizePayloadScope(data.organizationId)\n if (payloadOrganizationId) payloadOrganizationScopes.add(payloadOrganizationId)\n for (const organizationId of collectStringScopes(data.organizationIds)) {\n payloadOrganizationScopes.add(organizationId)\n }\n\n const recipientUserScopes = new Set<string>()\n const recipientUserId = normalizePayloadScope(data.recipientUserId)\n if (recipientUserId) recipientUserScopes.add(recipientUserId)\n for (const userId of collectStringScopes(data.recipientUserIds)) {\n recipientUserScopes.add(userId)\n }\n\n const recipientRoleScopes = new Set<string>()\n const recipientRoleId = normalizePayloadScope(data.recipientRoleId)\n if (recipientRoleId) recipientRoleScopes.add(recipientRoleId)\n for (const roleId of collectStringScopes(data.recipientRoleIds)) {\n recipientRoleScopes.add(roleId)\n }\n\n return [\n event,\n payloadTenantId,\n optionsTenantToken,\n sortScopes(payloadOrganizationScopes).join(','),\n sortScopes(optionsOrganizationScopes).join(','),\n sortScopes(recipientUserScopes).join(','),\n sortScopes(recipientRoleScopes).join(','),\n ].join('::')\n}\n\nfunction getGlobalEventTaps(): Set<GlobalEventTap> {\n const existing = (globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY]\n if (existing instanceof Set) {\n return existing as Set<GlobalEventTap>\n }\n const created = new Set<GlobalEventTap>()\n ;(globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY] = created\n return created\n}\n\nexport function registerGlobalEventTap(handler: GlobalEventTap): () => void {\n const taps = getGlobalEventTaps()\n taps.add(handler)\n return () => {\n taps.delete(handler)\n }\n}\n\n/** Job data structure for queued events */\ntype EventJobData = {\n event: string\n payload: EventPayload\n options?: EmitOptions\n /**\n * Set by the producer when it already ran the persistent subscribers inline\n * (single-delivery reconciled off). The worker skips such jobs, so the app and\n * the worker staying exactly-once no longer depends on both processes agreeing\n * about `OM_EVENTS_SINGLE_DELIVERY`. Absent on jobs queued before this field\n * existed, which keeps their behavior unchanged.\n */\n persistentDeliveredInline?: boolean\n}\n\n// Process-wide cache of the async (BullMQ) persistent-events producer queue.\n// Each authenticated request builds a fresh DI container and event bus, so a\n// per-bus producer queue opened a new ioredis connection per write request that\n// was never closed \u2014 leaking one Redis connection per request until maxclients\n// exhaustion. Memoizing the producer on `globalThis` (keyed by Redis URL, so a\n// reconfigured URL still gets its own queue) keeps it at one connection per\n// process, mirroring the `GLOBAL_EVENT_TAPS_KEY` and `getCachedRateLimiterService`\n// patterns. The local (file-based) strategy holds no pooled connection and its\n// base dir is cwd-relative, so it stays per-bus.\nconst EVENTS_PRODUCER_QUEUE_KEY = '__openMercatoEventsProducerQueues__'\nconst EVENTS_PRODUCER_SHUTDOWN_KEY = '__openMercatoEventsProducerShutdown__'\nconst BROADCAST_COALESCER_SHUTDOWN_KEY = '__openMercatoBroadcastCoalescerShutdown__'\n\nfunction isSharedProducerEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_EVENTS_SHARED_PRODUCER, true)\n}\n\nfunction getProducerQueueRegistry(): Map<string, Queue<EventJobData>> {\n const existing = (globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY]\n if (existing instanceof Map) {\n return existing as Map<string, Queue<EventJobData>>\n }\n const created = new Map<string, Queue<EventJobData>>()\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY] = created\n return created\n}\n\nfunction registerProducerShutdownHook(): void {\n if ((globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY]) return\n const shutdown = () => {\n const registry = getProducerQueueRegistry()\n for (const sharedQueue of registry.values()) {\n Promise.resolve(sharedQueue.close()).catch(() => {})\n }\n registry.clear()\n }\n process.once('SIGTERM', shutdown)\n process.once('SIGINT', shutdown)\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY] = true\n}\n\n/**\n * Deliver the tail of any in-flight coalesced burst on the way out, so stopping\n * a worker mid-import does not leave browsers a window behind. Registered\n * independently of the producer hook: coalescing is in play whether or not this\n * process ever opened an async producer queue.\n *\n * `beforeExit` matters as much as the signals here. The trailing timer is\n * unref'd so a pending browser delivery never holds a process open, which means\n * a short-lived emitter \u2014 a CLI import, a one-shot script \u2014 reaches a natural\n * exit with the tail still queued and no signal ever fired. `beforeExit` runs\n * exactly when the loop has drained, which is precisely that case.\n *\n * `createEventBus` calls this in every process that bootstraps DI \u2014 the app,\n * every worker, and every `mercato` CLI command \u2014 so a bare `process.once`\n * listener here would take away Node's default SIGINT/SIGTERM exit process-wide:\n * once ANY listener is registered for a signal, Node stops exiting on it, and\n * this listener only flushes, never exits. A one-shot CLI command (seed,\n * import, reindex) with no graceful-shutdown handler of its own would then\n * swallow the first Ctrl+C or SIGTERM and keep running until SIGKILL. The\n * per-signal handler below flushes, then restores the default behavior itself\n * \u2014 by re-raising the signal \u2014 but only when it was the last listener\n * (`once` already removed itself before this body runs): a process with its\n * own graceful-shutdown handler (the queue worker, `mercato server`) keeps\n * that handler in charge of the actual exit, and this hook just adds the flush\n * alongside it. Every flush here is capped at BROADCAST_FLUSH_DEADLINE_MS, so a\n * hung dispatch cannot hold a stop until SIGKILL.\n */\nfunction registerBroadcastCoalescerShutdownHook(): void {\n if ((globalThis as Record<string, unknown>)[BROADCAST_COALESCER_SHUTDOWN_KEY]) return\n const handleSignal = (signal: NodeJS.Signals) => {\n void flushPendingBroadcasts({ deadlineMs: BROADCAST_FLUSH_DEADLINE_MS })\n .catch(() => {})\n .finally(() => {\n if (process.listenerCount(signal) === 0) {\n process.kill(process.pid, signal)\n }\n })\n }\n process.once('SIGTERM', () => handleSignal('SIGTERM'))\n process.once('SIGINT', () => handleSignal('SIGINT'))\n process.once('beforeExit', () => {\n void flushPendingBroadcasts({ deadlineMs: BROADCAST_FLUSH_DEADLINE_MS }).catch(() => {})\n })\n ;(globalThis as Record<string, unknown>)[BROADCAST_COALESCER_SHUTDOWN_KEY] = true\n}\n\n/**\n * Creates an event bus instance.\n *\n * The event bus provides:\n * - In-memory event delivery to registered handlers\n * - Optional persistence via the queue package when `persistent: true`\n *\n * @param opts - Configuration options\n * @returns An EventBus instance\n *\n * @example\n * ```typescript\n * const bus = createEventBus({\n * resolve: container.resolve.bind(container),\n * queueStrategy: 'local', // or 'async' for BullMQ\n * })\n *\n * // Register a handler\n * bus.on('user.created', async (payload, ctx) => {\n * const userService = ctx.resolve('userService')\n * await userService.sendWelcomeEmail(payload.userId)\n * })\n *\n * // Emit an event (immediate delivery)\n * await bus.emit('user.created', { userId: '123' })\n *\n * // Emit with persistence (for async worker processing)\n * await bus.emit('order.placed', { orderId: '456' }, { persistent: true })\n * ```\n */\nexport function createEventBus(opts: CreateBusOptions): EventBus {\n registerBroadcastCoalescerShutdownHook()\n\n // In-memory listeners for immediate event delivery\n const listeners = new Map<string, Set<RegisteredSubscriber>>()\n // Subscribers registered as persistent (worker-dispatched). Used by the\n // single-delivery path to skip them inline on a persistent emit.\n const persistentSubscribers = new Set<RegisteredSubscriber>()\n\n // Determine queue strategy from options or environment\n const queueStrategy = opts.queueStrategy ??\n (process.env.QUEUE_STRATEGY === 'async' ? 'async' : 'local')\n\n // Lazy-initialized queue for persistent events\n let queue: Queue<EventJobData> | null = null\n\n /**\n * Gets or creates the queue instance for persistent events.\n *\n * The async (BullMQ) producer is memoized process-wide so the per-request\n * event bus reuses one Redis connection instead of leaking one per write\n * request. The local strategy stays per-bus (no pooled connection, cwd-relative\n * base dir). Set `OM_EVENTS_SHARED_PRODUCER=0` to fall back to per-bus producers.\n */\n function getQueue(): Queue<EventJobData> {\n if (queueStrategy !== 'async' || !isSharedProducerEnabled()) {\n if (!queue) {\n queue = queueStrategy === 'async'\n ? createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n : createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'local')\n }\n return queue\n }\n\n const redisUrl = getRedisUrlOrThrow('QUEUE')\n const registry = getProducerQueueRegistry()\n const cacheKey = `async:${redisUrl}`\n let shared = registry.get(cacheKey)\n if (!shared) {\n shared = createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: redisUrl },\n })\n registry.set(cacheKey, shared)\n registerProducerShutdownHook()\n }\n return shared\n }\n\n /**\n * Delivers an event to all registered in-memory handlers.\n * Supports wildcard pattern matching for event patterns.\n *\n * Handler errors are logged and delivery continues - one bad subscriber must\n * not stop the others. The count of failed PERSISTENT handlers is returned so\n * the caller can decide whether the inline run may be recorded as complete:\n * a persistent subscriber that threw inline has no retry of its own, so the\n * queued job must stay unstamped and let the worker run it again.\n */\n async function deliver(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n skipPersistent = false,\n ): Promise<{ persistentFailures: number }> {\n let persistentFailures = 0\n // Check all registered patterns (including wildcards)\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) continue\n if (!handlers || handlers.size === 0) continue\n\n for (const subscriber of handlers) {\n // Single-delivery: persistent subscribers are dispatched by the worker,\n // so skip them inline to avoid double execution.\n if (skipPersistent && persistentSubscribers.has(subscriber)) continue\n try {\n // Pass eventName in context for wildcard handlers\n const moduleId = subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id)\n await withModuleResourceUsage(\n {\n moduleId,\n surface: 'subscriber',\n operation: `${event} -> ${subscriber.id ?? subscriber.event}`,\n resourceId: subscriber.id ?? subscriber.event,\n },\n () => subscriber.handler(payload, {\n resolve: opts.resolve,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }),\n )\n } catch (error) {\n if (persistentSubscribers.has(subscriber)) {\n persistentFailures += 1\n }\n logger.error('Handler error', { event, pattern, err: error })\n if (options?.rethrowHandlerErrors) {\n throw error\n }\n }\n }\n }\n return { persistentFailures }\n }\n\n /**\n * Selects the subscribers the events worker owns for a queued event: every\n * pattern matching `event`, keeping persistent subscribers only, so wildcard\n * (`event: '*'`) persistent subscribers are reached exactly once here.\n *\n * Deliberately does NOT read `OM_EVENTS_SINGLE_DELIVERY`. Whether inline\n * delivery already happened is carried by the job's `persistentDeliveredInline`\n * stamp, so the producer owns that decision entirely and the worker cannot\n * disagree with it across processes. Reading the flag here would reintroduce\n * exactly that skew: a worker whose env has the flag off would fall back to\n * exact-match, re-running ephemeral subscribers the producer already ran inline\n * and never reaching wildcard persistent subscribers - the failure this whole\n * change exists to remove.\n *\n * Every job that reaches this point wants persistent-pattern selection: a job\n * is only dispatched when its stamp is false, which means either the producer\n * had single-delivery on, or the emit was enqueue-only (`deliverInline: false`,\n * documented as \"every subscriber to the event is persistent\").\n */\n function selectQueuedSubscribers(event: string): RegisteredSubscriber[] {\n const matched: RegisteredSubscriber[] = []\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) {\n continue\n }\n for (const subscriber of handlers) {\n if (subscriber.persistent) {\n matched.push(subscriber)\n }\n }\n }\n return matched\n }\n\n /**\n * Dispatches a queued event on behalf of the events worker.\n *\n * Handler failures are returned instead of logged-and-swallowed the way\n * `deliver()` does them: the worker needs them to propagate so the queue retries\n * the job and eventually dead-letters it.\n *\n * Subscribers run against the caller's `resolve` when one is given, falling back\n * to the bus's own. The events worker passes its per-job `ctx.resolve`, which is\n * the container `createPerJobWorkerHandler` built for that job - the handle the\n * queue contract already hands it. Defaulting to `opts.resolve` instead would\n * bind subscribers to whichever container built the bus; that is the same object\n * on the default configuration, but not under `OM_BOOTSTRAP_CACHE`, where the\n * cached bus is replayed into later containers while its captured resolver stays\n * bound to the first.\n */\n async function dispatchQueued(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n resolve?: <T = unknown>(name: string) => T,\n ): Promise<QueuedDispatchResult[]> {\n const subscribers = selectQueuedSubscribers(event)\n if (subscribers.length === 0) {\n return []\n }\n\n const resolver = resolve ?? opts.resolve\n\n const settled = await Promise.allSettled(\n subscribers.map((subscriber) =>\n withModuleResourceUsage(\n {\n moduleId: subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id),\n surface: 'subscriber',\n operation: `${event} -> ${subscriber.id ?? subscriber.event}`,\n resourceId: subscriber.id ?? subscriber.event,\n },\n () => subscriber.handler(payload, {\n resolve: resolver,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }),\n ),\n ),\n )\n\n return settled.map((result, index) => {\n const subscriberId = subscribers[index].id ?? subscribers[index].event\n return result.status === 'rejected'\n ? { subscriberId, ok: false, error: result.reason }\n : { subscriberId, ok: true }\n })\n }\n\n /**\n * Registers a handler for an event. Pass `moduleId` whenever the subscribing\n * module is known (e.g. a module reacting to another module's event) so\n * resource-usage telemetry attributes the handler correctly instead of\n * guessing a module id from the event name.\n */\n function on(\n event: string,\n handler: SubscriberHandler,\n options?: { persistent?: boolean; moduleId?: string; id?: string },\n ): void {\n if (!listeners.has(event)) {\n listeners.set(event, new Set())\n }\n const subscriber: RegisteredSubscriber = {\n id: options?.id,\n moduleId: options?.moduleId,\n event,\n handler,\n persistent: options?.persistent,\n }\n listeners.get(event)!.add(subscriber)\n if (options?.persistent) {\n persistentSubscribers.add(subscriber)\n }\n }\n\n /**\n * Registers multiple module subscribers at once.\n */\n function registerModuleSubscribers(subs: SubscriberDescriptor[]): void {\n for (const sub of subs) {\n on(sub.event, sub.handler, { persistent: sub.persistent, moduleId: sub.moduleId, id: sub.id })\n }\n }\n\n /**\n * Emits an event to all registered handlers.\n *\n * If `persistent: true`, also enqueues the event for async processing.\n */\n async function emit(\n event: string,\n payload: EventPayload,\n options?: EmitOptions\n ): Promise<void> {\n const runGlobalTaps = async (): Promise<void> => {\n const taps = getGlobalEventTaps()\n for (const tap of taps) {\n try {\n await Promise.resolve(tap(event, payload, options))\n } catch (error) {\n logger.error('Global tap error', { event, err: error })\n }\n }\n }\n\n // Resolved at call time, never hoisted. Private coordination always requires\n // trusted envelope scope and module provenance. Legacy declared browser events\n // may promote their typed scope for raw EventBus compatibility; tenant-managed\n // workflow adapters pass authoritative scope in options, which always wins over\n // payload values. Computing it lazily keeps the non-coalesced path resolving\n // scope at exactly the point it always did \u2014 after inline delivery \u2014 so an\n // event that did not opt in cannot observe this change at all.\n const publishToOtherProcesses = async (): Promise<void> => {\n const crossProcessOptions = resolveCrossProcessEmitOptions(event, payload, options)\n if (\n !isCrossProcessBroadcastEvent(event)\n || !hasTrustedTenantScope(crossProcessOptions)\n || !isPrivateCrossProcessEventEmitter(event, crossProcessOptions?.emitterModuleId)\n ) return\n try {\n await publishCrossProcessEvent(event, payload, crossProcessOptions)\n } catch (error) {\n logger.error('Cross-process publish error', { event, err: error })\n }\n }\n\n // Global taps are not a browser-only sink \u2014 `registerGlobalEventTap` is a\n // public export that any process-local listener can use (the create-app\n // template's qa-events route captures events for integration tests through\n // it, and third-party modules can too), not just the two SSE endpoints. What\n // both taps and the SSE bridges have in common is that neither carries domain\n // meaning on its own, so an opted-in event may coalesce this dispatch \u2014 and\n // with it every tap's delivery \u2014 without touching subscriber, webhook or\n // queue semantics below. A tap on a coalesced event sees the same suppressed,\n // trailing-flush-guaranteed stream the browser does, not one call per record.\n const coalesceBrowserDelivery = isCoalescedBroadcastEvent(event)\n && resolveBroadcastCoalesceIntervalMs() > 0\n if (coalesceBrowserDelivery) {\n await submitBroadcast(\n buildBroadcastCoalesceKey(event, payload, options),\n async () => {\n await runGlobalTaps()\n await publishToOtherProcesses()\n },\n )\n } else {\n await runGlobalTaps()\n }\n\n // Deliver to in-memory handlers first. Under single-delivery, persistent\n // subscribers are skipped inline on a persistent emit because the events\n // worker will dispatch them from the queue.\n //\n // `deliverInline: false` on a persistent emit is enqueue-only: skip inline\n // delivery entirely so a heavy persistent job runs solely in the events\n // worker, off the caller's request path (the queued enqueue below still\n // happens). Without this, a persistent emit dual-dispatches by default and\n // runs the subscriber inline in the caller's request too.\n const enqueueOnly = Boolean(options?.persistent) && options?.deliverInline === false\n // Read the mode once so the inline skip and the stamp below cannot disagree.\n const singleDelivery = isSingleDeliveryEnabled()\n const skipPersistentInline = options?.skipPersistentSubscribersInline === true\n || (Boolean(options?.persistent) && singleDelivery)\n let inlinePersistentFailed = false\n if (!enqueueOnly) {\n const delivered = await deliver(event, payload, options, skipPersistentInline)\n inlinePersistentFailed = delivered.persistentFailures > 0\n }\n\n // Already dispatched with the taps when this event coalesces its browser\n // delivery; otherwise it publishes here, exactly where it always has.\n if (!coalesceBrowserDelivery) {\n await publishToOtherProcesses()\n }\n\n // If persistent, also enqueue for async processing\n if (options?.persistent) {\n const q = getQueue()\n await q.enqueue({\n event,\n payload,\n options,\n // Only record the inline run as complete when every persistent\n // subscriber actually succeeded. Inline delivery logs-and-continues, so a\n // handler that threw has no retry of its own; leaving the job unstamped\n // hands it to the worker, where a failure gets queue retry and\n // dead-lettering. Without this, flipping the flag off (which the\n // `mercato server` guard can do automatically) silently downgrades\n // persistent delivery to at-most-once.\n persistentDeliveredInline: !enqueueOnly && !skipPersistentInline && !inlinePersistentFailed,\n })\n }\n }\n\n /**\n * Clears all events from the persistent queue.\n */\n async function clearQueue(): Promise<{ removed: number }> {\n const q = getQueue()\n return q.clear()\n }\n\n // Backward compatibility alias\n const emitEvent = emit\n\n return {\n emit,\n emitEvent, // Alias for backward compatibility\n on,\n registerModuleSubscribers,\n dispatchQueued,\n clearQueue,\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/dist/index.js CHANGED
@@ -1,3 +1,15 @@
1
1
  export * from "./types.js";
2
2
  export * from "./bus.js";
3
+ import {
4
+ BROADCAST_FLUSH_DEADLINE_MS,
5
+ flushPendingBroadcasts,
6
+ resolveBroadcastCoalesceIntervalMs,
7
+ submitBroadcast
8
+ } from "./broadcast-coalescer.js";
9
+ export {
10
+ BROADCAST_FLUSH_DEADLINE_MS,
11
+ flushPendingBroadcasts,
12
+ resolveBroadcastCoalesceIntervalMs,
13
+ submitBroadcast
14
+ };
3
15
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/index.ts"],
4
- "sourcesContent": ["export * from './types'\nexport * from './bus'\n"],
5
- "mappings": "AAAA,cAAc;AACd,cAAc;",
4
+ "sourcesContent": ["export * from './types'\nexport * from './bus'\n// Named export list, not `export *`: `resetBroadcastCoalescerForTests` is a\n// test-only helper the `./broadcast-coalescer` subpath export already covers;\n// re-exporting it from the package root would make it a supported surface\n// under BACKWARD_COMPATIBILITY.md.\nexport {\n BROADCAST_FLUSH_DEADLINE_MS,\n flushPendingBroadcasts,\n resolveBroadcastCoalesceIntervalMs,\n submitBroadcast,\n} from './broadcast-coalescer'\n"],
5
+ "mappings": "AAAA,cAAc;AACd,cAAc;AAKd;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;",
6
6
  "names": []
7
7
  }
@@ -24,6 +24,7 @@ const eventDefinitionSchema = z.object({
24
24
  clientBroadcast: z.boolean().optional(),
25
25
  crossProcessBroadcast: z.boolean().optional(),
26
26
  portalBroadcast: z.boolean().optional(),
27
+ broadcastCoalescing: z.boolean().optional(),
27
28
  payloadSchema: eventPayloadSchemaSchema.optional()
28
29
  });
29
30
  const eventsResponseSchema = z.object({
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/events/api/route.ts"],
4
- "sourcesContent": ["/**\n * Events registry API \u2014 returns declared events from module `events.ts` files.\n *\n * Uses the globally registered event configs (populated during bootstrap). Data\n * leaked here (event ids, module ids, entity ids, broadcast flags) is\n * recon-grade and MUST NOT be served to anonymous callers; the route\n * is protected by the standard `[...slug]` dispatcher with `requireAuth: true`.\n * Consumer: `packages/ui/src/backend/inputs/EventSelect.tsx` (workflow triggers,\n * business rules, webhook config).\n */\n\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getDeclaredEvents } from '@open-mercato/shared/modules/events'\n\nexport const metadata = {\n path: '/events',\n GET: { requireAuth: true, requireFeatures: ['workflows.view'] },\n}\n\nconst eventPayloadFieldSchema = z.object({\n path: z.string(),\n type: z.enum(['text', 'number', 'boolean', 'select', 'date', 'object', 'unknown']),\n label: z.string().optional(),\n optional: z.boolean().optional(),\n})\n\nconst eventPayloadSchemaSchema = z.object({\n fields: z.array(eventPayloadFieldSchema),\n})\n\nconst eventDefinitionSchema = z.object({\n id: z.string(),\n label: z.string(),\n description: z.string().optional(),\n category: z.enum(['crud', 'lifecycle', 'system', 'custom']).optional(),\n module: z.string().optional(),\n entity: z.string().optional(),\n excludeFromTriggers: z.boolean().optional(),\n clientBroadcast: z.boolean().optional(),\n crossProcessBroadcast: z.boolean().optional(),\n portalBroadcast: z.boolean().optional(),\n payloadSchema: eventPayloadSchemaSchema.optional(),\n})\n\nconst eventsResponseSchema = z.object({\n data: z.array(eventDefinitionSchema),\n total: z.number().int().nonnegative(),\n})\n\nexport async function GET(req: Request): Promise<Response> {\n const { searchParams } = new URL(req.url)\n const category = searchParams.get('category')\n const moduleId = searchParams.get('module')\n const excludeTriggerExcluded = searchParams.get('excludeTriggerExcluded') !== 'false'\n\n let filteredEvents = getDeclaredEvents()\n\n if (excludeTriggerExcluded) {\n filteredEvents = filteredEvents.filter((e) => !e.excludeFromTriggers)\n }\n if (category) {\n filteredEvents = filteredEvents.filter((e) => e.category === category)\n }\n if (moduleId) {\n filteredEvents = filteredEvents.filter((e) => e.module === moduleId)\n }\n\n return Response.json({\n data: filteredEvents,\n total: filteredEvents.length,\n })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'List declared platform events',\n description: 'Returns all declared platform events from every enabled module. Supports optional category and module filters.',\n methods: {\n GET: {\n summary: 'List declared events',\n description: 'Returns every declared event, including its declared payloadSchema when the module (or the generated CRUD default) provides one. Filters: category, module, excludeTriggerExcluded (default true).',\n responses: [{ status: 200, description: 'Declared events', schema: eventsResponseSchema }],\n },\n },\n}\n"],
5
- "mappings": "AAWA,SAAS,SAAS;AAElB,SAAS,yBAAyB;AAE3B,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,gBAAgB,EAAE;AAChE;AAEA,MAAM,0BAA0B,EAAE,OAAO;AAAA,EACvC,MAAM,EAAE,OAAO;AAAA,EACf,MAAM,EAAE,KAAK,CAAC,QAAQ,UAAU,WAAW,UAAU,QAAQ,UAAU,SAAS,CAAC;AAAA,EACjF,OAAO,EAAE,OAAO,EAAE,SAAS;AAAA,EAC3B,UAAU,EAAE,QAAQ,EAAE,SAAS;AACjC,CAAC;AAED,MAAM,2BAA2B,EAAE,OAAO;AAAA,EACxC,QAAQ,EAAE,MAAM,uBAAuB;AACzC,CAAC;AAED,MAAM,wBAAwB,EAAE,OAAO;AAAA,EACrC,IAAI,EAAE,OAAO;AAAA,EACb,OAAO,EAAE,OAAO;AAAA,EAChB,aAAa,EAAE,OAAO,EAAE,SAAS;AAAA,EACjC,UAAU,EAAE,KAAK,CAAC,QAAQ,aAAa,UAAU,QAAQ,CAAC,EAAE,SAAS;AAAA,EACrE,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,qBAAqB,EAAE,QAAQ,EAAE,SAAS;AAAA,EAC1C,iBAAiB,EAAE,QAAQ,EAAE,SAAS;AAAA,EACtC,uBAAuB,EAAE,QAAQ,EAAE,SAAS;AAAA,EAC5C,iBAAiB,EAAE,QAAQ,EAAE,SAAS;AAAA,EACtC,eAAe,yBAAyB,SAAS;AACnD,CAAC;AAED,MAAM,uBAAuB,EAAE,OAAO;AAAA,EACpC,MAAM,EAAE,MAAM,qBAAqB;AAAA,EACnC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,YAAY;AACtC,CAAC;AAED,eAAsB,IAAI,KAAiC;AACzD,QAAM,EAAE,aAAa,IAAI,IAAI,IAAI,IAAI,GAAG;AACxC,QAAM,WAAW,aAAa,IAAI,UAAU;AAC5C,QAAM,WAAW,aAAa,IAAI,QAAQ;AAC1C,QAAM,yBAAyB,aAAa,IAAI,wBAAwB,MAAM;AAE9E,MAAI,iBAAiB,kBAAkB;AAEvC,MAAI,wBAAwB;AAC1B,qBAAiB,eAAe,OAAO,CAAC,MAAM,CAAC,EAAE,mBAAmB;AAAA,EACtE;AACA,MAAI,UAAU;AACZ,qBAAiB,eAAe,OAAO,CAAC,MAAM,EAAE,aAAa,QAAQ;AAAA,EACvE;AACA,MAAI,UAAU;AACZ,qBAAiB,eAAe,OAAO,CAAC,MAAM,EAAE,WAAW,QAAQ;AAAA,EACrE;AAEA,SAAO,SAAS,KAAK;AAAA,IACnB,MAAM;AAAA,IACN,OAAO,eAAe;AAAA,EACxB,CAAC;AACH;AAEO,MAAM,UAA2B;AAAA,EACtC,SAAS;AAAA,EACT,aAAa;AAAA,EACb,SAAS;AAAA,IACP,KAAK;AAAA,MACH,SAAS;AAAA,MACT,aAAa;AAAA,MACb,WAAW,CAAC,EAAE,QAAQ,KAAK,aAAa,mBAAmB,QAAQ,qBAAqB,CAAC;AAAA,IAC3F;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["/**\n * Events registry API \u2014 returns declared events from module `events.ts` files.\n *\n * Uses the globally registered event configs (populated during bootstrap). Data\n * leaked here (event ids, module ids, entity ids, broadcast flags) is\n * recon-grade and MUST NOT be served to anonymous callers; the route\n * is protected by the standard `[...slug]` dispatcher with `requireAuth: true`.\n * Consumer: `packages/ui/src/backend/inputs/EventSelect.tsx` (workflow triggers,\n * business rules, webhook config).\n */\n\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getDeclaredEvents } from '@open-mercato/shared/modules/events'\n\nexport const metadata = {\n path: '/events',\n GET: { requireAuth: true, requireFeatures: ['workflows.view'] },\n}\n\nconst eventPayloadFieldSchema = z.object({\n path: z.string(),\n type: z.enum(['text', 'number', 'boolean', 'select', 'date', 'object', 'unknown']),\n label: z.string().optional(),\n optional: z.boolean().optional(),\n})\n\nconst eventPayloadSchemaSchema = z.object({\n fields: z.array(eventPayloadFieldSchema),\n})\n\nconst eventDefinitionSchema = z.object({\n id: z.string(),\n label: z.string(),\n description: z.string().optional(),\n category: z.enum(['crud', 'lifecycle', 'system', 'custom']).optional(),\n module: z.string().optional(),\n entity: z.string().optional(),\n excludeFromTriggers: z.boolean().optional(),\n clientBroadcast: z.boolean().optional(),\n crossProcessBroadcast: z.boolean().optional(),\n portalBroadcast: z.boolean().optional(),\n broadcastCoalescing: z.boolean().optional(),\n payloadSchema: eventPayloadSchemaSchema.optional(),\n})\n\nconst eventsResponseSchema = z.object({\n data: z.array(eventDefinitionSchema),\n total: z.number().int().nonnegative(),\n})\n\nexport async function GET(req: Request): Promise<Response> {\n const { searchParams } = new URL(req.url)\n const category = searchParams.get('category')\n const moduleId = searchParams.get('module')\n const excludeTriggerExcluded = searchParams.get('excludeTriggerExcluded') !== 'false'\n\n let filteredEvents = getDeclaredEvents()\n\n if (excludeTriggerExcluded) {\n filteredEvents = filteredEvents.filter((e) => !e.excludeFromTriggers)\n }\n if (category) {\n filteredEvents = filteredEvents.filter((e) => e.category === category)\n }\n if (moduleId) {\n filteredEvents = filteredEvents.filter((e) => e.module === moduleId)\n }\n\n return Response.json({\n data: filteredEvents,\n total: filteredEvents.length,\n })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'List declared platform events',\n description: 'Returns all declared platform events from every enabled module. Supports optional category and module filters.',\n methods: {\n GET: {\n summary: 'List declared events',\n description: 'Returns every declared event, including its declared payloadSchema when the module (or the generated CRUD default) provides one. Filters: category, module, excludeTriggerExcluded (default true).',\n responses: [{ status: 200, description: 'Declared events', schema: eventsResponseSchema }],\n },\n },\n}\n"],
5
+ "mappings": "AAWA,SAAS,SAAS;AAElB,SAAS,yBAAyB;AAE3B,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,gBAAgB,EAAE;AAChE;AAEA,MAAM,0BAA0B,EAAE,OAAO;AAAA,EACvC,MAAM,EAAE,OAAO;AAAA,EACf,MAAM,EAAE,KAAK,CAAC,QAAQ,UAAU,WAAW,UAAU,QAAQ,UAAU,SAAS,CAAC;AAAA,EACjF,OAAO,EAAE,OAAO,EAAE,SAAS;AAAA,EAC3B,UAAU,EAAE,QAAQ,EAAE,SAAS;AACjC,CAAC;AAED,MAAM,2BAA2B,EAAE,OAAO;AAAA,EACxC,QAAQ,EAAE,MAAM,uBAAuB;AACzC,CAAC;AAED,MAAM,wBAAwB,EAAE,OAAO;AAAA,EACrC,IAAI,EAAE,OAAO;AAAA,EACb,OAAO,EAAE,OAAO;AAAA,EAChB,aAAa,EAAE,OAAO,EAAE,SAAS;AAAA,EACjC,UAAU,EAAE,KAAK,CAAC,QAAQ,aAAa,UAAU,QAAQ,CAAC,EAAE,SAAS;AAAA,EACrE,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,qBAAqB,EAAE,QAAQ,EAAE,SAAS;AAAA,EAC1C,iBAAiB,EAAE,QAAQ,EAAE,SAAS;AAAA,EACtC,uBAAuB,EAAE,QAAQ,EAAE,SAAS;AAAA,EAC5C,iBAAiB,EAAE,QAAQ,EAAE,SAAS;AAAA,EACtC,qBAAqB,EAAE,QAAQ,EAAE,SAAS;AAAA,EAC1C,eAAe,yBAAyB,SAAS;AACnD,CAAC;AAED,MAAM,uBAAuB,EAAE,OAAO;AAAA,EACpC,MAAM,EAAE,MAAM,qBAAqB;AAAA,EACnC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,YAAY;AACtC,CAAC;AAED,eAAsB,IAAI,KAAiC;AACzD,QAAM,EAAE,aAAa,IAAI,IAAI,IAAI,IAAI,GAAG;AACxC,QAAM,WAAW,aAAa,IAAI,UAAU;AAC5C,QAAM,WAAW,aAAa,IAAI,QAAQ;AAC1C,QAAM,yBAAyB,aAAa,IAAI,wBAAwB,MAAM;AAE9E,MAAI,iBAAiB,kBAAkB;AAEvC,MAAI,wBAAwB;AAC1B,qBAAiB,eAAe,OAAO,CAAC,MAAM,CAAC,EAAE,mBAAmB;AAAA,EACtE;AACA,MAAI,UAAU;AACZ,qBAAiB,eAAe,OAAO,CAAC,MAAM,EAAE,aAAa,QAAQ;AAAA,EACvE;AACA,MAAI,UAAU;AACZ,qBAAiB,eAAe,OAAO,CAAC,MAAM,EAAE,WAAW,QAAQ;AAAA,EACrE;AAEA,SAAO,SAAS,KAAK;AAAA,IACnB,MAAM;AAAA,IACN,OAAO,eAAe;AAAA,EACxB,CAAC;AACH;AAEO,MAAM,UAA2B;AAAA,EACtC,SAAS;AAAA,EACT,aAAa;AAAA,EACb,SAAS;AAAA,IACP,KAAK;AAAA,MACH,SAAS;AAAA,MACT,aAAa;AAAA,MACb,WAAW,CAAC,EAAE,QAAQ,KAAK,aAAa,mBAAmB,QAAQ,qBAAqB,CAAC;AAAA,IAC3F;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }