@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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +18 -0
- package/dist/broadcast-coalescer.js +124 -0
- package/dist/broadcast-coalescer.js.map +7 -0
- package/dist/bus.js +108 -11
- package/dist/bus.js.map +2 -2
- package/dist/index.js +12 -0
- package/dist/index.js.map +2 -2
- package/dist/modules/events/api/route.js +1 -0
- package/dist/modules/events/api/route.js.map +2 -2
- package/dist/modules/events/api/stream/route.js +161 -20
- package/dist/modules/events/api/stream/route.js.map +2 -2
- package/package.json +4 -5
- package/src/__tests__/broadcast-coalescer.test.ts +172 -0
- package/src/__tests__/broadcast-coalescing-bus.test.ts +409 -0
- package/src/broadcast-coalescer.ts +204 -0
- package/src/bus.ts +199 -19
- package/src/index.ts +10 -0
- package/src/modules/events/api/__tests__/route.test.ts +27 -4
- package/src/modules/events/api/route.ts +1 -0
- package/src/modules/events/api/stream/__tests__/route.test.ts +650 -8
- package/src/modules/events/api/stream/route.ts +200 -26
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
|
|
443
|
-
|
|
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
|
|
613
|
+
await publishCrossProcessEvent(event, payload, crossProcessOptions)
|
|
446
614
|
} catch (error) {
|
|
447
|
-
logger.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
|
-
//
|
|
472
|
-
//
|
|
473
|
-
|
|
474
|
-
|
|
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(
|
|
57
|
-
expect(body.data.map((e) => e.id)).toEqual([
|
|
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(
|
|
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(
|
|
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
|
|