@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.
@@ -0,0 +1,409 @@
1
+ const publishCrossProcessEventMock = jest.fn(async () => undefined)
2
+
3
+ jest.mock('../bridge', () => ({
4
+ publishCrossProcessEvent: (...args: unknown[]) => publishCrossProcessEventMock(...args),
5
+ registerCrossProcessEventListener: jest.fn(),
6
+ CROSS_PROCESS_EVENT_INSTANCE_ID: 'test-instance',
7
+ }))
8
+
9
+ import { createModuleEvents } from '@open-mercato/shared/modules/events'
10
+ import { createEventBus, registerGlobalEventTap } from '@open-mercato/events/bus'
11
+ import { resetBroadcastCoalescerForTests } from '@open-mercato/events/broadcast-coalescer'
12
+
13
+ const INTERVAL_MS = 50
14
+
15
+ createModuleEvents({
16
+ moduleId: 'coalesce_test',
17
+ events: [
18
+ { id: 'coalesce_test.bulk.created', label: 'Bulk Created', clientBroadcast: true, broadcastCoalescing: true },
19
+ { id: 'coalesce_test.plain.created', label: 'Plain Created', clientBroadcast: true },
20
+ { id: 'coalesce_test.recipient.created', label: 'Recipient Created', clientBroadcast: true, broadcastCoalescing: true },
21
+ { id: 'coalesce_test.portal.created', label: 'Portal Created', portalBroadcast: true, broadcastCoalescing: true },
22
+ ] as const,
23
+ })
24
+
25
+ function wait(ms: number): Promise<void> {
26
+ return new Promise((resolve) => setTimeout(resolve, ms))
27
+ }
28
+
29
+ describe('event bus browser-delivery coalescing', () => {
30
+ const resolve = ((name: string) => name) as never
31
+ const originalInterval = process.env.OM_BROADCAST_COALESCE_INTERVAL_MS
32
+ let unregisterTap: (() => void) | null = null
33
+
34
+ beforeEach(() => {
35
+ publishCrossProcessEventMock.mockClear()
36
+ process.env.OM_BROADCAST_COALESCE_INTERVAL_MS = String(INTERVAL_MS)
37
+ })
38
+
39
+ afterEach(() => {
40
+ unregisterTap?.()
41
+ unregisterTap = null
42
+ resetBroadcastCoalescerForTests()
43
+ if (originalInterval === undefined) delete process.env.OM_BROADCAST_COALESCE_INTERVAL_MS
44
+ else process.env.OM_BROADCAST_COALESCE_INTERVAL_MS = originalInterval
45
+ })
46
+
47
+ it('keeps domain delivery per record while collapsing the browser dispatch', async () => {
48
+ const bus = createEventBus({ resolve, queueStrategy: 'local' })
49
+ const subscriberCalls: string[] = []
50
+ const tappedIds: string[] = []
51
+
52
+ bus.on('coalesce_test.bulk.created', async (payload) => {
53
+ subscriberCalls.push(String((payload as { id: string }).id))
54
+ })
55
+ unregisterTap = registerGlobalEventTap((_event, payload) => {
56
+ tappedIds.push(String((payload as { id: string }).id))
57
+ })
58
+
59
+ for (let index = 0; index < 100; index += 1) {
60
+ await bus.emit(
61
+ 'coalesce_test.bulk.created',
62
+ { id: `product-${index}`, tenantId: 'tenant-1', organizationId: 'org-1' },
63
+ { tenantId: 'tenant-1', organizationId: 'org-1' },
64
+ )
65
+ }
66
+ await wait(INTERVAL_MS * 2)
67
+
68
+ // The hard requirement from the issue: the domain event still fires once per
69
+ // record, so webhooks, notification handlers and indexers are untouched.
70
+ expect(subscriberCalls).toHaveLength(100)
71
+ expect(subscriberCalls[0]).toBe('product-0')
72
+ expect(subscriberCalls[99]).toBe('product-99')
73
+
74
+ // The browser half collapses, and the burst still ends on the final record.
75
+ expect(tappedIds.length).toBeLessThan(100)
76
+ expect(tappedIds[0]).toBe('product-0')
77
+ expect(tappedIds[tappedIds.length - 1]).toBe('product-99')
78
+
79
+ // The pg_notify roundtrip collapses with it — half the cost the issue names.
80
+ expect(publishCrossProcessEventMock.mock.calls.length).toBe(tappedIds.length)
81
+ })
82
+
83
+ it('leaves an event that did not opt in on the per-record path', async () => {
84
+ const bus = createEventBus({ resolve, queueStrategy: 'local' })
85
+ const tappedIds: string[] = []
86
+ unregisterTap = registerGlobalEventTap((_event, payload) => {
87
+ tappedIds.push(String((payload as { id: string }).id))
88
+ })
89
+
90
+ for (let index = 0; index < 10; index += 1) {
91
+ await bus.emit(
92
+ 'coalesce_test.plain.created',
93
+ { id: `product-${index}`, tenantId: 'tenant-1', organizationId: 'org-1' },
94
+ { tenantId: 'tenant-1', organizationId: 'org-1' },
95
+ )
96
+ }
97
+
98
+ expect(tappedIds).toHaveLength(10)
99
+ expect(publishCrossProcessEventMock).toHaveBeenCalledTimes(10)
100
+ })
101
+
102
+ it('never delivers one tenant or organization payload under another scope', async () => {
103
+ const bus = createEventBus({ resolve, queueStrategy: 'local' })
104
+ const seen: Array<{ tenantId: string; organizationId: string; id: string }> = []
105
+ unregisterTap = registerGlobalEventTap((_event, payload) => {
106
+ const data = payload as { id: string; tenantId: string; organizationId: string }
107
+ seen.push({ tenantId: data.tenantId, organizationId: data.organizationId, id: data.id })
108
+ })
109
+
110
+ const scopes = [
111
+ { tenantId: 'tenant-a', organizationId: 'org-1' },
112
+ { tenantId: 'tenant-b', organizationId: 'org-1' },
113
+ { tenantId: 'tenant-a', organizationId: 'org-2' },
114
+ ]
115
+
116
+ for (let round = 0; round < 5; round += 1) {
117
+ for (const scope of scopes) {
118
+ await bus.emit(
119
+ 'coalesce_test.bulk.created',
120
+ { id: `${scope.tenantId}-${scope.organizationId}-${round}`, ...scope },
121
+ scope,
122
+ )
123
+ }
124
+ }
125
+ await wait(INTERVAL_MS * 2)
126
+
127
+ // Every scope gets its own leading edge and its own trailing flush, and no
128
+ // delivered payload ever carries a scope other than the one it was keyed by.
129
+ for (const scope of scopes) {
130
+ const forScope = seen.filter((entry) => entry.tenantId === scope.tenantId && entry.organizationId === scope.organizationId)
131
+ expect(forScope.length).toBeGreaterThanOrEqual(2)
132
+ expect(forScope[forScope.length - 1].id).toBe(`${scope.tenantId}-${scope.organizationId}-4`)
133
+ for (const entry of forScope) {
134
+ expect(entry.id.startsWith(`${scope.tenantId}-${scope.organizationId}-`)).toBe(true)
135
+ }
136
+ }
137
+ })
138
+
139
+ it('flushes the tail on a natural exit as well as on a signal', async () => {
140
+ const registered: string[] = []
141
+ const onceSpy = jest.spyOn(process, 'once').mockImplementation(function (this: NodeJS.Process, event: string) {
142
+ registered.push(event)
143
+ return this
144
+ } as never)
145
+
146
+ try {
147
+ delete (globalThis as Record<string, unknown>).__openMercatoBroadcastCoalescerShutdown__
148
+ createEventBus({ resolve, queueStrategy: 'local' })
149
+ } finally {
150
+ onceSpy.mockRestore()
151
+ }
152
+
153
+ // The trailing timer is unref'd, so a CLI that emits and exits normally never
154
+ // sees a signal — beforeExit is the hook that saves its tail.
155
+ expect(registered).toEqual(expect.arrayContaining(['SIGTERM', 'SIGINT', 'beforeExit']))
156
+ })
157
+
158
+ it('re-raises a signal after the flush only when no other listener owns the exit', async () => {
159
+ const handlers = new Map<string, () => void>()
160
+ const onceSpy = jest.spyOn(process, 'once').mockImplementation(function (this: NodeJS.Process, event: string, handler: () => void) {
161
+ handlers.set(event, handler)
162
+ return this
163
+ } as never)
164
+ try {
165
+ delete (globalThis as Record<string, unknown>).__openMercatoBroadcastCoalescerShutdown__
166
+ createEventBus({ resolve, queueStrategy: 'local' })
167
+ } finally {
168
+ onceSpy.mockRestore()
169
+ }
170
+
171
+ const killSpy = jest.spyOn(process, 'kill').mockImplementation(() => true)
172
+ const listenerCountSpy = jest.spyOn(process, 'listenerCount')
173
+ const bus = createEventBus({ resolve, queueStrategy: 'local' })
174
+ const tappedIds: string[] = []
175
+ unregisterTap = registerGlobalEventTap((_event, payload) => {
176
+ tappedIds.push(String((payload as { id: string }).id))
177
+ })
178
+ try {
179
+ for (const id of ['first', 'tail']) {
180
+ await bus.emit(
181
+ 'coalesce_test.bulk.created',
182
+ { id, tenantId: 'tenant-1', organizationId: 'org-1' },
183
+ { tenantId: 'tenant-1', organizationId: 'org-1' },
184
+ )
185
+ }
186
+
187
+ listenerCountSpy.mockReturnValue(0)
188
+ handlers.get('SIGINT')?.()
189
+ await wait(INTERVAL_MS / 5)
190
+ expect(tappedIds).toEqual(['first', 'tail'])
191
+ expect(killSpy).toHaveBeenCalledWith(process.pid, 'SIGINT')
192
+
193
+ killSpy.mockClear()
194
+ listenerCountSpy.mockReturnValue(1)
195
+ handlers.get('SIGTERM')?.()
196
+ await wait(INTERVAL_MS / 5)
197
+ expect(killSpy).not.toHaveBeenCalled()
198
+ } finally {
199
+ killSpy.mockRestore()
200
+ listenerCountSpy.mockRestore()
201
+ }
202
+ })
203
+
204
+ it('keys a present-but-empty options tenant per payload tenant, so no tenant suppresses another', async () => {
205
+ const bus = createEventBus({ resolve, queueStrategy: 'local' })
206
+ const tenants = ['tenant-a', 'tenant-b']
207
+
208
+ for (let round = 0; round < 3; round += 1) {
209
+ for (const tenantId of tenants) {
210
+ await bus.emit(
211
+ 'coalesce_test.bulk.created',
212
+ { id: `${tenantId}-${round}`, tenantId, organizationId: 'org-1' },
213
+ { tenantId: undefined },
214
+ )
215
+ }
216
+ }
217
+ await wait(INTERVAL_MS * 2)
218
+
219
+ const published = publishCrossProcessEventMock.mock.calls.map((call) => (call as unknown[])[1] as { id: string; tenantId: string })
220
+ for (const tenantId of tenants) {
221
+ const forTenant = published.filter((entry) => entry.tenantId === tenantId)
222
+ expect(forTenant.length).toBeGreaterThanOrEqual(2)
223
+ expect(forTenant[forTenant.length - 1].id).toBe(`${tenantId}-2`)
224
+ }
225
+ })
226
+
227
+ it('keys portal organizations from the payload even when options carry a trusted tenant', async () => {
228
+ const bus = createEventBus({ resolve, queueStrategy: 'local' })
229
+ const seen: Array<{ organizationId: string; id: string }> = []
230
+ unregisterTap = registerGlobalEventTap((_event, payload) => {
231
+ const data = payload as { id: string; organizationId: string }
232
+ seen.push({ organizationId: data.organizationId, id: data.id })
233
+ })
234
+ const organizations = ['org-1', 'org-2']
235
+
236
+ for (let round = 0; round < 3; round += 1) {
237
+ for (const organizationId of organizations) {
238
+ await bus.emit(
239
+ 'coalesce_test.portal.created',
240
+ { id: `${organizationId}-${round}`, tenantId: 'tenant-1', organizationId },
241
+ { tenantId: 'tenant-1' },
242
+ )
243
+ }
244
+ }
245
+ await wait(INTERVAL_MS * 2)
246
+
247
+ for (const organizationId of organizations) {
248
+ const forOrganization = seen.filter((entry) => entry.organizationId === organizationId)
249
+ expect(forOrganization.length).toBeGreaterThanOrEqual(2)
250
+ expect(forOrganization[forOrganization.length - 1].id).toBe(`${organizationId}-2`)
251
+ }
252
+ })
253
+
254
+ it('does not share a key between absent and present-but-empty options tenants', async () => {
255
+ const bus = createEventBus({ resolve, queueStrategy: 'local' })
256
+ const tappedIds: string[] = []
257
+ unregisterTap = registerGlobalEventTap((_event, payload) => {
258
+ tappedIds.push(String((payload as { id: string }).id))
259
+ })
260
+ const payload = { tenantId: 'tenant-1', organizationId: 'org-1' }
261
+
262
+ await bus.emit('coalesce_test.bulk.created', { ...payload, id: 'absent' }, {})
263
+ await bus.emit('coalesce_test.bulk.created', { ...payload, id: 'present-empty' }, { tenantId: undefined })
264
+
265
+ expect(tappedIds).toEqual(['absent', 'present-empty'])
266
+ })
267
+
268
+ it('never delivers one recipient user\'s payload under another recipient user\'s key', async () => {
269
+ const bus = createEventBus({ resolve, queueStrategy: 'local' })
270
+ const seen: Array<{ recipientUserId: string; id: string }> = []
271
+ unregisterTap = registerGlobalEventTap((_event, payload) => {
272
+ const data = payload as { id: string; recipientUserId: string }
273
+ seen.push({ recipientUserId: data.recipientUserId, id: data.id })
274
+ })
275
+
276
+ // Same tenant/org for both users — options carry no recipient dimension at
277
+ // all, so only the payload-derived union in the coalescing key can keep
278
+ // these two audiences from suppressing each other.
279
+ const users = ['user-a', 'user-b']
280
+ for (let round = 0; round < 5; round += 1) {
281
+ for (const recipientUserId of users) {
282
+ await bus.emit(
283
+ 'coalesce_test.recipient.created',
284
+ { id: `${recipientUserId}-${round}`, tenantId: 'tenant-1', organizationId: 'org-1', recipientUserId },
285
+ { tenantId: 'tenant-1', organizationId: 'org-1' },
286
+ )
287
+ }
288
+ }
289
+ await wait(INTERVAL_MS * 2)
290
+
291
+ for (const recipientUserId of users) {
292
+ const forUser = seen.filter((entry) => entry.recipientUserId === recipientUserId)
293
+ expect(forUser.length).toBeGreaterThanOrEqual(2)
294
+ expect(forUser[forUser.length - 1].id).toBe(`${recipientUserId}-4`)
295
+ for (const entry of forUser) {
296
+ expect(entry.id.startsWith(`${recipientUserId}-`)).toBe(true)
297
+ }
298
+ }
299
+ })
300
+
301
+ it('never delivers one recipient role\'s payload under another recipient role\'s key', async () => {
302
+ const bus = createEventBus({ resolve, queueStrategy: 'local' })
303
+ const seen: Array<{ recipientRoleId: string; id: string }> = []
304
+ unregisterTap = registerGlobalEventTap((_event, payload) => {
305
+ const data = payload as { id: string; recipientRoleId: string }
306
+ seen.push({ recipientRoleId: data.recipientRoleId, id: data.id })
307
+ })
308
+
309
+ const roles = ['role-a', 'role-b']
310
+ for (let round = 0; round < 5; round += 1) {
311
+ for (const recipientRoleId of roles) {
312
+ await bus.emit(
313
+ 'coalesce_test.recipient.created',
314
+ { id: `${recipientRoleId}-${round}`, tenantId: 'tenant-1', organizationId: 'org-1', recipientRoleId },
315
+ { tenantId: 'tenant-1', organizationId: 'org-1' },
316
+ )
317
+ }
318
+ }
319
+ await wait(INTERVAL_MS * 2)
320
+
321
+ for (const recipientRoleId of roles) {
322
+ const forRole = seen.filter((entry) => entry.recipientRoleId === recipientRoleId)
323
+ expect(forRole.length).toBeGreaterThanOrEqual(2)
324
+ expect(forRole[forRole.length - 1].id).toBe(`${recipientRoleId}-4`)
325
+ for (const entry of forRole) {
326
+ expect(entry.id.startsWith(`${recipientRoleId}-`)).toBe(true)
327
+ }
328
+ }
329
+ })
330
+
331
+ it('never delivers one tenant\'s payload under another tenant\'s key for a portal-only event', async () => {
332
+ const bus = createEventBus({ resolve, queueStrategy: 'local' })
333
+ const seen: Array<{ tenantId: string; id: string }> = []
334
+ unregisterTap = registerGlobalEventTap((_event, payload) => {
335
+ const data = payload as { id: string; tenantId: string }
336
+ seen.push({ tenantId: data.tenantId, id: data.id })
337
+ })
338
+
339
+ // A portal-only broadcast has no `clientBroadcast`, so `resolveCrossProcessEmitOptions`
340
+ // never promotes payload scope into options for it — the coalescing key can
341
+ // only be correct here if it reads tenantId straight off the payload, the
342
+ // way this test never passes it in `options` at all.
343
+ const tenants = ['tenant-a', 'tenant-b']
344
+ for (let round = 0; round < 5; round += 1) {
345
+ for (const tenantId of tenants) {
346
+ await bus.emit(
347
+ 'coalesce_test.portal.created',
348
+ { id: `${tenantId}-${round}`, tenantId },
349
+ )
350
+ }
351
+ }
352
+ await wait(INTERVAL_MS * 2)
353
+
354
+ for (const tenantId of tenants) {
355
+ const forTenant = seen.filter((entry) => entry.tenantId === tenantId)
356
+ expect(forTenant.length).toBeGreaterThanOrEqual(2)
357
+ expect(forTenant[forTenant.length - 1].id).toBe(`${tenantId}-4`)
358
+ for (const entry of forTenant) {
359
+ expect(entry.id.startsWith(`${tenantId}-`)).toBe(true)
360
+ }
361
+ }
362
+ })
363
+
364
+ it('restores per-record browser delivery when the interval is disabled', async () => {
365
+ process.env.OM_BROADCAST_COALESCE_INTERVAL_MS = '0'
366
+ const bus = createEventBus({ resolve, queueStrategy: 'local' })
367
+ const tappedIds: string[] = []
368
+ unregisterTap = registerGlobalEventTap((_event, payload) => {
369
+ tappedIds.push(String((payload as { id: string }).id))
370
+ })
371
+
372
+ for (let index = 0; index < 10; index += 1) {
373
+ await bus.emit(
374
+ 'coalesce_test.bulk.created',
375
+ { id: `product-${index}`, tenantId: 'tenant-1', organizationId: 'org-1' },
376
+ { tenantId: 'tenant-1', organizationId: 'org-1' },
377
+ )
378
+ }
379
+
380
+ expect(tappedIds).toHaveLength(10)
381
+ expect(publishCrossProcessEventMock).toHaveBeenCalledTimes(10)
382
+ })
383
+
384
+ it('restores the develop dispatch order (pg_notify publish after inline) when the interval is disabled', async () => {
385
+ process.env.OM_BROADCAST_COALESCE_INTERVAL_MS = '0'
386
+ const bus = createEventBus({ resolve, queueStrategy: 'local' })
387
+ const order: string[] = []
388
+ bus.on('coalesce_test.bulk.created', async () => {
389
+ order.push('inline')
390
+ })
391
+ publishCrossProcessEventMock.mockImplementationOnce(async () => {
392
+ order.push('publish')
393
+ })
394
+
395
+ // On the non-coalesced path (and on develop before this feature existed),
396
+ // the pg_notify publish that reaches other processes always ran AFTER inline
397
+ // in-memory delivery. The coalesced path bundles it with the tap dispatch
398
+ // instead, ahead of inline delivery — `OM_BROADCAST_COALESCE_INTERVAL_MS=0`
399
+ // promises to fully restore the develop order, not merely the per-record
400
+ // delivery count.
401
+ await bus.emit(
402
+ 'coalesce_test.bulk.created',
403
+ { id: 'product-0', tenantId: 'tenant-1', organizationId: 'org-1' },
404
+ { tenantId: 'tenant-1', organizationId: 'org-1' },
405
+ )
406
+
407
+ expect(order).toEqual(['inline', 'publish'])
408
+ })
409
+ })
@@ -0,0 +1,204 @@
1
+ import { parseNumberWithDefault } from '@open-mercato/shared/lib/number'
2
+ import { createLogger } from '@open-mercato/shared/lib/logger'
3
+
4
+ /**
5
+ * Coalesces the BROWSER half of an event emit.
6
+ *
7
+ * A `clientBroadcast: true` event costs a serialized `pg_notify` roundtrip plus a
8
+ * tenant-wide SSE fan-out on every emit, so a bulk writer pays both once per
9
+ * record even though its browser consumers only refresh a list. Events that opt
10
+ * in with `broadcastCoalescing: true` route their browser dispatch through here:
11
+ * the first emit of a burst goes out immediately (leading edge) and later emits
12
+ * sharing a key replace the pending payload instead of being delivered.
13
+ *
14
+ * A trailing flush is ALWAYS armed, so the last emit of a burst is delivered and
15
+ * a DataTable never ends the burst stale. This is where the mechanism goes past
16
+ * the progress service's private throttle, which can drop its tail only because
17
+ * its terminal transitions emit through a separate unthrottled path.
18
+ *
19
+ * Inline subscribers, webhooks and the queue never reach this module — the domain
20
+ * event still fires once per record.
21
+ */
22
+
23
+ const DEFAULT_COALESCE_INTERVAL_MS = 250
24
+ const PENDING_BROADCASTS_KEY = '__openMercatoPendingBroadcasts__'
25
+
26
+ const logger = createLogger('events').child({ component: 'broadcast-coalescer' })
27
+
28
+ type BroadcastDispatch = () => Promise<void>
29
+
30
+ type PendingBroadcast = {
31
+ /** The newest superseding dispatch awaiting the trailing flush, if any. */
32
+ pending: BroadcastDispatch | null
33
+ timer: ReturnType<typeof setTimeout> | null
34
+ suppressed: number
35
+ }
36
+
37
+ /**
38
+ * Held on `globalThis` for the same reason as the bus's global taps and producer
39
+ * queue: a fresh event bus is built per request, so per-bus state would coalesce
40
+ * nothing across the emits of a single bulk job.
41
+ */
42
+ function getPendingBroadcasts(): Map<string, PendingBroadcast> {
43
+ const existing = (globalThis as Record<string, unknown>)[PENDING_BROADCASTS_KEY]
44
+ if (existing instanceof Map) {
45
+ return existing as Map<string, PendingBroadcast>
46
+ }
47
+ const created = new Map<string, PendingBroadcast>()
48
+ ;(globalThis as Record<string, unknown>)[PENDING_BROADCASTS_KEY] = created
49
+ return created
50
+ }
51
+
52
+ /**
53
+ * Minimum elapsed time between coalesced browser deliveries of one key. `0`
54
+ * disables coalescing process-wide, restoring per-record delivery — the runtime
55
+ * escape hatch, mirroring `OM_PROGRESS_BROADCAST_MIN_INTERVAL_MS=0`.
56
+ */
57
+ export function resolveBroadcastCoalesceIntervalMs(): number {
58
+ return parseNumberWithDefault(
59
+ process.env.OM_BROADCAST_COALESCE_INTERVAL_MS,
60
+ DEFAULT_COALESCE_INTERVAL_MS,
61
+ { min: 0, integer: true },
62
+ )
63
+ }
64
+
65
+ function armTimer(key: string, intervalMs: number): ReturnType<typeof setTimeout> {
66
+ const timer = setTimeout(() => {
67
+ void onWindowClosed(key, intervalMs)
68
+ }, intervalMs)
69
+ // Never keep a CLI or worker process alive just to flush a browser delivery.
70
+ // The shutdown hook flushes what is still pending on the way out.
71
+ if (typeof (timer as { unref?: () => void }).unref === 'function') {
72
+ ;(timer as { unref: () => void }).unref()
73
+ }
74
+ return timer
75
+ }
76
+
77
+ async function onWindowClosed(key: string, intervalMs: number): Promise<void> {
78
+ const entries = getPendingBroadcasts()
79
+ const entry = entries.get(key)
80
+ if (!entry) return
81
+
82
+ const pending = entry.pending
83
+ if (!pending) {
84
+ // The window closed with nothing superseded: the burst is over.
85
+ entry.timer = null
86
+ entries.delete(key)
87
+ return
88
+ }
89
+
90
+ const suppressed = entry.suppressed
91
+ entry.pending = null
92
+ entry.suppressed = 0
93
+ entry.timer = null
94
+
95
+ await runDispatch(pending, key)
96
+ if (suppressed > 0) {
97
+ logger.debug('Coalesced browser deliveries', { key, suppressed })
98
+ }
99
+
100
+ // Re-arm only AFTER the dispatch settles, so one key never has two deliveries
101
+ // in flight. Re-arming first would let a slow pg_notify roundtrip overlap the
102
+ // next window and land an older payload last — the exact staleness coalescing
103
+ // exists to prevent. As a side effect the window self-extends under load,
104
+ // which is the right behavior for a throttle. Submissions arriving during the
105
+ // dispatch still queue on the entry, which is still in the map.
106
+ const current = entries.get(key)
107
+ if (!current) return
108
+ if (current.pending) current.timer = armTimer(key, intervalMs)
109
+ else entries.delete(key)
110
+ }
111
+
112
+ /**
113
+ * A deferred dispatch has no caller awaiting it, so its failure must be
114
+ * contained here — `bus.emit` already logs-and-continues on a synchronous
115
+ * publish failure, and one failed flush must not stop the next window.
116
+ */
117
+ async function runDispatch(dispatch: BroadcastDispatch, key: string): Promise<void> {
118
+ try {
119
+ await dispatch()
120
+ } catch (error) {
121
+ logger.error('Coalesced broadcast dispatch failed', { key, err: error })
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Submit a browser dispatch for `key`. Resolves once the caller's obligation is
127
+ * discharged: immediately-dispatched submissions resolve after delivery,
128
+ * superseded ones resolve as soon as they are queued.
129
+ */
130
+ export async function submitBroadcast(
131
+ key: string,
132
+ dispatch: BroadcastDispatch,
133
+ options?: { intervalMs?: number },
134
+ ): Promise<void> {
135
+ const intervalMs = options?.intervalMs ?? resolveBroadcastCoalesceIntervalMs()
136
+ if (intervalMs <= 0) {
137
+ await dispatch()
138
+ return
139
+ }
140
+
141
+ const entries = getPendingBroadcasts()
142
+ const entry = entries.get(key)
143
+ if (!entry) {
144
+ entries.set(key, { pending: null, timer: armTimer(key, intervalMs), suppressed: 0 })
145
+ await dispatch()
146
+ return
147
+ }
148
+
149
+ if (entry.pending) entry.suppressed += 1
150
+ entry.pending = dispatch
151
+ }
152
+
153
+ /**
154
+ * Upper bound for a flush on the way out of a process. Fixed, and independent of
155
+ * and shorter than OM_BROADCAST_COALESCE_INTERVAL_MS's own window, so a stalled
156
+ * Postgres connection at exit degrades to the already-accepted dropped-tail case
157
+ * instead of hanging the exit.
158
+ */
159
+ export const BROADCAST_FLUSH_DEADLINE_MS = 3000
160
+
161
+ /**
162
+ * Deliver every survivor still awaiting its trailing flush. Wired into the
163
+ * process shutdown hook and the CLI exit path so a stop does not swallow the tail
164
+ * of a burst. Never rejects: every entry is settled on its own. With
165
+ * `deadlineMs`, resolves no later than that even if a dispatch hangs.
166
+ */
167
+ export async function flushPendingBroadcasts(options?: { deadlineMs?: number }): Promise<void> {
168
+ const entries = getPendingBroadcasts()
169
+ const drained: Array<Promise<void>> = []
170
+ for (const [key, entry] of entries) {
171
+ if (entry.timer) clearTimeout(entry.timer)
172
+ const pending = entry.pending
173
+ entries.delete(key)
174
+ if (pending) drained.push(runDispatch(pending, key))
175
+ }
176
+ const settled = Promise.allSettled(drained).then(() => undefined)
177
+ const deadlineMs = options?.deadlineMs
178
+ if (deadlineMs === undefined) {
179
+ await settled
180
+ return
181
+ }
182
+ let expire: () => void = () => {}
183
+ const expired = new Promise<void>((resolve) => {
184
+ expire = resolve
185
+ })
186
+ const deadline = setTimeout(() => expire(), Math.max(0, deadlineMs))
187
+ if (typeof (deadline as { unref?: () => void }).unref === 'function') {
188
+ ;(deadline as { unref: () => void }).unref()
189
+ }
190
+ try {
191
+ await Promise.race([settled, expired])
192
+ } finally {
193
+ clearTimeout(deadline)
194
+ }
195
+ }
196
+
197
+ /** Drop all pending state without dispatching. Tests only. */
198
+ export function resetBroadcastCoalescerForTests(): void {
199
+ const entries = getPendingBroadcasts()
200
+ for (const entry of entries.values()) {
201
+ if (entry.timer) clearTimeout(entry.timer)
202
+ }
203
+ entries.clear()
204
+ }