@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.
package/src/bus.ts CHANGED
@@ -7,9 +7,16 @@ import { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'
7
7
  import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
8
8
  import {
9
9
  isBroadcastEvent,
10
+ isCoalescedBroadcastEvent,
10
11
  isCrossProcessBroadcastEvent,
11
12
  isPrivateCrossProcessEventEmitter,
12
13
  } from '@open-mercato/shared/modules/events'
14
+ import {
15
+ BROADCAST_FLUSH_DEADLINE_MS,
16
+ flushPendingBroadcasts,
17
+ resolveBroadcastCoalesceIntervalMs,
18
+ submitBroadcast,
19
+ } from './broadcast-coalescer'
13
20
  import {
14
21
  inferModuleIdFromResourceId,
15
22
  withModuleResourceUsage,
@@ -95,6 +102,95 @@ function resolveCrossProcessEmitOptions(
95
102
  }
96
103
  }
97
104
 
105
+ function collectStringScopes(input: unknown): string[] {
106
+ if (!Array.isArray(input)) return []
107
+ const values: string[] = []
108
+ for (const value of input) {
109
+ if (typeof value !== 'string') continue
110
+ const trimmed = value.trim()
111
+ if (!trimmed) continue
112
+ values.push(trimmed)
113
+ }
114
+ return values
115
+ }
116
+
117
+ // Sorted so an audience keys identically however the caller ordered it, and
118
+ // explicitly comparator-ed per the #3620 guard.
119
+ function sortScopes(scopes: Set<string>): string[] {
120
+ return Array.from(scopes).sort((left, right) => left.localeCompare(right))
121
+ }
122
+
123
+ /**
124
+ * Coalescing key for the browser dispatch of one event.
125
+ *
126
+ * Built from the raw union of every dimension the SSE audience filter
127
+ * (`matchesAudience` in `events/api/stream/route.ts`) reads — tenant,
128
+ * organization, recipient user and recipient role — taken from BOTH the
129
+ * trusted `options` and the payload, without calling `resolveCrossProcessEmitOptions`
130
+ * or any other resolver. Those resolvers pick one source to trust for delivery
131
+ * semantics; the key instead has to key on every dimension a connection can be
132
+ * filtered by, or one audience's emit would silently replace another
133
+ * audience's pending delivery — recipient/role targeting and a portal-only
134
+ * event (which carries no trusted `options` scope at all) included.
135
+ *
136
+ * Options and payload scope stay in separate segments, never merged: the three
137
+ * filters each read a different source, so `options` org A + payload org B must
138
+ * not key like the reverse. The options tenant segment encodes PRESENCE, not
139
+ * just value — the backoffice `hasTrustedScope` is a presence test, so `{}` and
140
+ * `{ tenantId: undefined }` take different filter branches and must not share
141
+ * a key.
142
+ *
143
+ * Scope is part of the key by necessity, not for granularity: a key with no
144
+ * scope segments would let a burst in one tenant suppress another tenant's
145
+ * delivery and then deliver the first tenant's payload in its place.
146
+ */
147
+ function buildBroadcastCoalesceKey(event: string, payload: EventPayload, options?: EmitOptions): string {
148
+ const data = (payload ?? {}) as Record<string, unknown>
149
+
150
+ const payloadTenantId = normalizePayloadScope(data.tenantId) ?? ''
151
+ const optionsTenantToken = options && Object.prototype.hasOwnProperty.call(options, 'tenantId')
152
+ ? `=${normalizePayloadScope(options.tenantId) ?? ''}`
153
+ : 'absent'
154
+
155
+ const optionsOrganizationScopes = new Set<string>()
156
+ const optionsOrganizationId = normalizePayloadScope(options?.organizationId)
157
+ if (optionsOrganizationId) optionsOrganizationScopes.add(optionsOrganizationId)
158
+ for (const organizationId of collectStringScopes(options?.organizationIds)) {
159
+ optionsOrganizationScopes.add(organizationId)
160
+ }
161
+
162
+ const payloadOrganizationScopes = new Set<string>()
163
+ const payloadOrganizationId = normalizePayloadScope(data.organizationId)
164
+ if (payloadOrganizationId) payloadOrganizationScopes.add(payloadOrganizationId)
165
+ for (const organizationId of collectStringScopes(data.organizationIds)) {
166
+ payloadOrganizationScopes.add(organizationId)
167
+ }
168
+
169
+ const recipientUserScopes = new Set<string>()
170
+ const recipientUserId = normalizePayloadScope(data.recipientUserId)
171
+ if (recipientUserId) recipientUserScopes.add(recipientUserId)
172
+ for (const userId of collectStringScopes(data.recipientUserIds)) {
173
+ recipientUserScopes.add(userId)
174
+ }
175
+
176
+ const recipientRoleScopes = new Set<string>()
177
+ const recipientRoleId = normalizePayloadScope(data.recipientRoleId)
178
+ if (recipientRoleId) recipientRoleScopes.add(recipientRoleId)
179
+ for (const roleId of collectStringScopes(data.recipientRoleIds)) {
180
+ recipientRoleScopes.add(roleId)
181
+ }
182
+
183
+ return [
184
+ event,
185
+ payloadTenantId,
186
+ optionsTenantToken,
187
+ sortScopes(payloadOrganizationScopes).join(','),
188
+ sortScopes(optionsOrganizationScopes).join(','),
189
+ sortScopes(recipientUserScopes).join(','),
190
+ sortScopes(recipientRoleScopes).join(','),
191
+ ].join('::')
192
+ }
193
+
98
194
  function getGlobalEventTaps(): Set<GlobalEventTap> {
99
195
  const existing = (globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY]
100
196
  if (existing instanceof Set) {
@@ -139,6 +235,7 @@ type EventJobData = {
139
235
  // base dir is cwd-relative, so it stays per-bus.
140
236
  const EVENTS_PRODUCER_QUEUE_KEY = '__openMercatoEventsProducerQueues__'
141
237
  const EVENTS_PRODUCER_SHUTDOWN_KEY = '__openMercatoEventsProducerShutdown__'
238
+ const BROADCAST_COALESCER_SHUTDOWN_KEY = '__openMercatoBroadcastCoalescerShutdown__'
142
239
 
143
240
  function isSharedProducerEnabled(): boolean {
144
241
  return parseBooleanWithDefault(process.env.OM_EVENTS_SHARED_PRODUCER, true)
@@ -168,6 +265,52 @@ function registerProducerShutdownHook(): void {
168
265
  ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY] = true
169
266
  }
170
267
 
268
+ /**
269
+ * Deliver the tail of any in-flight coalesced burst on the way out, so stopping
270
+ * a worker mid-import does not leave browsers a window behind. Registered
271
+ * independently of the producer hook: coalescing is in play whether or not this
272
+ * process ever opened an async producer queue.
273
+ *
274
+ * `beforeExit` matters as much as the signals here. The trailing timer is
275
+ * unref'd so a pending browser delivery never holds a process open, which means
276
+ * a short-lived emitter — a CLI import, a one-shot script — reaches a natural
277
+ * exit with the tail still queued and no signal ever fired. `beforeExit` runs
278
+ * exactly when the loop has drained, which is precisely that case.
279
+ *
280
+ * `createEventBus` calls this in every process that bootstraps DI — the app,
281
+ * every worker, and every `mercato` CLI command — so a bare `process.once`
282
+ * listener here would take away Node's default SIGINT/SIGTERM exit process-wide:
283
+ * once ANY listener is registered for a signal, Node stops exiting on it, and
284
+ * this listener only flushes, never exits. A one-shot CLI command (seed,
285
+ * import, reindex) with no graceful-shutdown handler of its own would then
286
+ * swallow the first Ctrl+C or SIGTERM and keep running until SIGKILL. The
287
+ * per-signal handler below flushes, then restores the default behavior itself
288
+ * — by re-raising the signal — but only when it was the last listener
289
+ * (`once` already removed itself before this body runs): a process with its
290
+ * own graceful-shutdown handler (the queue worker, `mercato server`) keeps
291
+ * that handler in charge of the actual exit, and this hook just adds the flush
292
+ * alongside it. Every flush here is capped at BROADCAST_FLUSH_DEADLINE_MS, so a
293
+ * hung dispatch cannot hold a stop until SIGKILL.
294
+ */
295
+ function registerBroadcastCoalescerShutdownHook(): void {
296
+ if ((globalThis as Record<string, unknown>)[BROADCAST_COALESCER_SHUTDOWN_KEY]) return
297
+ const handleSignal = (signal: NodeJS.Signals) => {
298
+ void flushPendingBroadcasts({ deadlineMs: BROADCAST_FLUSH_DEADLINE_MS })
299
+ .catch(() => {})
300
+ .finally(() => {
301
+ if (process.listenerCount(signal) === 0) {
302
+ process.kill(process.pid, signal)
303
+ }
304
+ })
305
+ }
306
+ process.once('SIGTERM', () => handleSignal('SIGTERM'))
307
+ process.once('SIGINT', () => handleSignal('SIGINT'))
308
+ process.once('beforeExit', () => {
309
+ void flushPendingBroadcasts({ deadlineMs: BROADCAST_FLUSH_DEADLINE_MS }).catch(() => {})
310
+ })
311
+ ;(globalThis as Record<string, unknown>)[BROADCAST_COALESCER_SHUTDOWN_KEY] = true
312
+ }
313
+
171
314
  /**
172
315
  * Creates an event bus instance.
173
316
  *
@@ -199,6 +342,8 @@ function registerProducerShutdownHook(): void {
199
342
  * ```
200
343
  */
201
344
  export function createEventBus(opts: CreateBusOptions): EventBus {
345
+ registerBroadcastCoalescerShutdownHook()
346
+
202
347
  // In-memory listeners for immediate event delivery
203
348
  const listeners = new Map<string, Set<RegisteredSubscriber>>()
204
349
  // Subscribers registered as persistent (worker-dispatched). Used by the
@@ -439,15 +584,61 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
439
584
  payload: EventPayload,
440
585
  options?: EmitOptions
441
586
  ): Promise<void> {
442
- const taps = getGlobalEventTaps()
443
- for (const tap of taps) {
587
+ const runGlobalTaps = async (): Promise<void> => {
588
+ const taps = getGlobalEventTaps()
589
+ for (const tap of taps) {
590
+ try {
591
+ await Promise.resolve(tap(event, payload, options))
592
+ } catch (error) {
593
+ logger.error('Global tap error', { event, err: error })
594
+ }
595
+ }
596
+ }
597
+
598
+ // Resolved at call time, never hoisted. Private coordination always requires
599
+ // trusted envelope scope and module provenance. Legacy declared browser events
600
+ // may promote their typed scope for raw EventBus compatibility; tenant-managed
601
+ // workflow adapters pass authoritative scope in options, which always wins over
602
+ // payload values. Computing it lazily keeps the non-coalesced path resolving
603
+ // scope at exactly the point it always did — after inline delivery — so an
604
+ // event that did not opt in cannot observe this change at all.
605
+ const publishToOtherProcesses = async (): Promise<void> => {
606
+ const crossProcessOptions = resolveCrossProcessEmitOptions(event, payload, options)
607
+ if (
608
+ !isCrossProcessBroadcastEvent(event)
609
+ || !hasTrustedTenantScope(crossProcessOptions)
610
+ || !isPrivateCrossProcessEventEmitter(event, crossProcessOptions?.emitterModuleId)
611
+ ) return
444
612
  try {
445
- await Promise.resolve(tap(event, payload, options))
613
+ await publishCrossProcessEvent(event, payload, crossProcessOptions)
446
614
  } catch (error) {
447
- logger.error('Global tap error', { event, err: error })
615
+ logger.error('Cross-process publish error', { event, err: error })
448
616
  }
449
617
  }
450
618
 
619
+ // Global taps are not a browser-only sink — `registerGlobalEventTap` is a
620
+ // public export that any process-local listener can use (the create-app
621
+ // template's qa-events route captures events for integration tests through
622
+ // it, and third-party modules can too), not just the two SSE endpoints. What
623
+ // both taps and the SSE bridges have in common is that neither carries domain
624
+ // meaning on its own, so an opted-in event may coalesce this dispatch — and
625
+ // with it every tap's delivery — without touching subscriber, webhook or
626
+ // queue semantics below. A tap on a coalesced event sees the same suppressed,
627
+ // trailing-flush-guaranteed stream the browser does, not one call per record.
628
+ const coalesceBrowserDelivery = isCoalescedBroadcastEvent(event)
629
+ && resolveBroadcastCoalesceIntervalMs() > 0
630
+ if (coalesceBrowserDelivery) {
631
+ await submitBroadcast(
632
+ buildBroadcastCoalesceKey(event, payload, options),
633
+ async () => {
634
+ await runGlobalTaps()
635
+ await publishToOtherProcesses()
636
+ },
637
+ )
638
+ } else {
639
+ await runGlobalTaps()
640
+ }
641
+
451
642
  // Deliver to in-memory handlers first. Under single-delivery, persistent
452
643
  // subscribers are skipped inline on a persistent emit because the events
453
644
  // worker will dispatch them from the queue.
@@ -468,21 +659,10 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
468
659
  inlinePersistentFailed = delivered.persistentFailures > 0
469
660
  }
470
661
 
471
- // Private coordination always requires trusted envelope scope and module
472
- // provenance. Legacy declared browser events may promote their typed scope
473
- // for raw EventBus compatibility; tenant-managed workflow adapters pass
474
- // authoritative scope in options, which always wins over payload values.
475
- const crossProcessOptions = resolveCrossProcessEmitOptions(event, payload, options)
476
- if (
477
- isCrossProcessBroadcastEvent(event)
478
- && hasTrustedTenantScope(crossProcessOptions)
479
- && isPrivateCrossProcessEventEmitter(event, crossProcessOptions?.emitterModuleId)
480
- ) {
481
- try {
482
- await publishCrossProcessEvent(event, payload, crossProcessOptions)
483
- } catch (error) {
484
- logger.error('Cross-process publish error', { event, err: error })
485
- }
662
+ // Already dispatched with the taps when this event coalesces its browser
663
+ // delivery; otherwise it publishes here, exactly where it always has.
664
+ if (!coalesceBrowserDelivery) {
665
+ await publishToOtherProcesses()
486
666
  }
487
667
 
488
668
  // If persistent, also enqueue for async processing
package/src/index.ts CHANGED
@@ -1,2 +1,12 @@
1
1
  export * from './types'
2
2
  export * from './bus'
3
+ // Named export list, not `export *`: `resetBroadcastCoalescerForTests` is a
4
+ // test-only helper the `./broadcast-coalescer` subpath export already covers;
5
+ // re-exporting it from the package root would make it a supported surface
6
+ // under BACKWARD_COMPATIBILITY.md.
7
+ export {
8
+ BROADCAST_FLUSH_DEADLINE_MS,
9
+ flushPendingBroadcasts,
10
+ resolveBroadcastCoalesceIntervalMs,
11
+ submitBroadcast,
12
+ } from './broadcast-coalescer'
@@ -21,6 +21,15 @@ jest.mock('@open-mercato/shared/modules/events', () => ({
21
21
  label: 'Order Placed',
22
22
  category: 'crud',
23
23
  },
24
+ {
25
+ id: 'catalog.product.created',
26
+ module: 'catalog',
27
+ entity: 'product',
28
+ label: 'Product Created',
29
+ category: 'crud',
30
+ clientBroadcast: true,
31
+ broadcastCoalescing: true,
32
+ },
24
33
  {
25
34
  id: 'webhooks.delivery.lifecycle',
26
35
  module: 'webhooks',
@@ -53,20 +62,24 @@ describe('GET /api/events (core events module route)', () => {
53
62
  const res = await GET(makeReq())
54
63
  expect(res.status).toBe(200)
55
64
  const body = (await res.json()) as { data: Array<{ id: string }>; total: number }
56
- expect(body.total).toBe(2)
57
- expect(body.data.map((e) => e.id)).toEqual(['customers.person.created', 'sales.order.placed'])
65
+ expect(body.total).toBe(3)
66
+ expect(body.data.map((e) => e.id)).toEqual([
67
+ 'customers.person.created',
68
+ 'sales.order.placed',
69
+ 'catalog.product.created',
70
+ ])
58
71
  })
59
72
 
60
73
  it('respects excludeTriggerExcluded=false and returns trigger-excluded events too', async () => {
61
74
  const res = await GET(makeReq('http://localhost/api/events?excludeTriggerExcluded=false'))
62
75
  const body = (await res.json()) as { total: number }
63
- expect(body.total).toBe(3)
76
+ expect(body.total).toBe(4)
64
77
  })
65
78
 
66
79
  it('filters by category', async () => {
67
80
  const res = await GET(makeReq('http://localhost/api/events?category=crud'))
68
81
  const body = (await res.json()) as { total: number; data: Array<{ category: string }> }
69
- expect(body.total).toBe(2)
82
+ expect(body.total).toBe(3)
70
83
  expect(body.data.every((e) => e.category === 'crud')).toBe(true)
71
84
  })
72
85
 
@@ -94,4 +107,14 @@ describe('GET /api/events (core events module route)', () => {
94
107
  expect(untyped).toBeDefined()
95
108
  expect(untyped?.payloadSchema).toBeUndefined()
96
109
  })
110
+
111
+ it('round-trips broadcastCoalescing for events that declare it and omits it otherwise', async () => {
112
+ const res = await GET(makeReq())
113
+ const body = (await res.json()) as { data: Array<{ id: string; broadcastCoalescing?: boolean }> }
114
+ const coalesced = body.data.find((e) => e.id === 'catalog.product.created')
115
+ expect(coalesced?.broadcastCoalescing).toBe(true)
116
+ const notCoalesced = body.data.find((e) => e.id === 'sales.order.placed')
117
+ expect(notCoalesced).toBeDefined()
118
+ expect(notCoalesced?.broadcastCoalescing).toBeUndefined()
119
+ })
97
120
  })
@@ -40,6 +40,7 @@ const eventDefinitionSchema = z.object({
40
40
  clientBroadcast: z.boolean().optional(),
41
41
  crossProcessBroadcast: z.boolean().optional(),
42
42
  portalBroadcast: z.boolean().optional(),
43
+ broadcastCoalescing: z.boolean().optional(),
43
44
  payloadSchema: eventPayloadSchemaSchema.optional(),
44
45
  })
45
46