@open-mercato/events 0.6.8-develop.6917.1.af45bc96e2 → 0.6.8-develop.6924.1.a8d208fcdc
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/AGENTS.md +7 -3
- package/agentic/standalone-guide.md +8 -1
- package/dist/bus.js +67 -3
- package/dist/bus.js.map +2 -2
- package/dist/modules/events/workers/events.worker.js +52 -60
- package/dist/modules/events/workers/events.worker.js.map +2 -2
- package/dist/single-delivery.js +1 -1
- package/dist/single-delivery.js.map +2 -2
- package/package.json +3 -3
- package/src/__tests__/dispatch-queued.test.ts +191 -0
- package/src/__tests__/queued-delivery-roundtrip.test.ts +178 -0
- package/src/__tests__/single-delivery.test.ts +63 -8
- package/src/bus.ts +141 -8
- package/src/modules/events/workers/__tests__/events.worker.test.ts +360 -391
- package/src/modules/events/workers/events.worker.ts +104 -92
- package/src/single-delivery.ts +29 -13
- package/src/types.ts +53 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { registerCliModules, getCliModules } from '@open-mercato/shared/modules/registry'
|
|
2
|
-
import type { Module } from '@open-mercato/shared/modules/registry'
|
|
3
1
|
import type { QueuedJob, JobContext } from '@open-mercato/queue'
|
|
4
2
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
5
|
-
import
|
|
3
|
+
import { createEventBus } from '../../../../bus'
|
|
4
|
+
import type { EventBus, SubscriberDescriptor } from '../../../../types'
|
|
5
|
+
import handle, { metadata, EVENTS_QUEUE_NAME } from '../events.worker'
|
|
6
6
|
|
|
7
7
|
jest.mock('@open-mercato/shared/lib/logger', () => {
|
|
8
8
|
const mocked = {
|
|
@@ -17,23 +17,76 @@ jest.mock('@open-mercato/shared/lib/logger', () => {
|
|
|
17
17
|
})
|
|
18
18
|
|
|
19
19
|
const workerLoggerError = createLogger('events').error as jest.Mock
|
|
20
|
+
const workerLoggerWarn = createLogger('events').warn as jest.Mock
|
|
21
|
+
const workerLoggerDebug = createLogger('events').debug as jest.Mock
|
|
22
|
+
|
|
23
|
+
type WorkerJobPayload = {
|
|
24
|
+
event: string
|
|
25
|
+
payload: unknown
|
|
26
|
+
options?: { tenantId?: string | null; organizationId?: string | null }
|
|
27
|
+
persistentDeliveredInline?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type WorkerContext = JobContext & { resolve: <T = unknown>(name: string) => T }
|
|
31
|
+
|
|
32
|
+
const createMockJob = (
|
|
33
|
+
event: string,
|
|
34
|
+
payload: unknown,
|
|
35
|
+
options?: { tenantId?: string | null; organizationId?: string | null },
|
|
36
|
+
extra?: { persistentDeliveredInline?: boolean },
|
|
37
|
+
): QueuedJob<WorkerJobPayload> => ({
|
|
38
|
+
id: 'test-job-id',
|
|
39
|
+
payload: { event, payload, options, ...extra },
|
|
40
|
+
createdAt: new Date().toISOString(),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The worker resolves its subscribers from the DI event bus, so the fixture is a
|
|
45
|
+
* real bus with subscribers registered on it - exactly what
|
|
46
|
+
* `createRequestContainer` hands a worker job. The CLI module registry is never
|
|
47
|
+
* touched here; that it is not needed IS the regression this suite pins.
|
|
48
|
+
*/
|
|
49
|
+
function createBusWith(subs: SubscriberDescriptor[]): EventBus {
|
|
50
|
+
const bus = createEventBus({ resolve: <T = unknown>(name: string): T => {
|
|
51
|
+
throw new Error(`No mock for ${name}`)
|
|
52
|
+
} })
|
|
53
|
+
bus.registerModuleSubscribers(subs)
|
|
54
|
+
return bus
|
|
55
|
+
}
|
|
20
56
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
57
|
+
function createMockContext(bus?: EventBus): WorkerContext {
|
|
58
|
+
return {
|
|
59
|
+
jobId: 'test-job-id',
|
|
60
|
+
attemptNumber: 1,
|
|
61
|
+
queueName: 'events',
|
|
62
|
+
resolve: <T = unknown>(name: string): T => {
|
|
63
|
+
if (name === 'eventBus' && bus) {
|
|
64
|
+
return bus as unknown as T
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`No mock for ${name}`)
|
|
67
|
+
},
|
|
68
|
+
}
|
|
27
69
|
}
|
|
28
70
|
|
|
29
71
|
describe('Events Worker', () => {
|
|
72
|
+
const ORIG_SINGLE_DELIVERY = process.env.OM_EVENTS_SINGLE_DELIVERY
|
|
73
|
+
|
|
30
74
|
beforeEach(() => {
|
|
31
|
-
clearModules()
|
|
32
75
|
workerLoggerError.mockClear()
|
|
76
|
+
workerLoggerWarn.mockClear()
|
|
77
|
+
workerLoggerDebug.mockClear()
|
|
33
78
|
})
|
|
34
79
|
|
|
80
|
+
function restoreEnv(name: string, original: string | undefined): void {
|
|
81
|
+
if (original === undefined) {
|
|
82
|
+
delete process.env[name]
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
process.env[name] = original
|
|
86
|
+
}
|
|
87
|
+
|
|
35
88
|
afterEach(() => {
|
|
36
|
-
|
|
89
|
+
restoreEnv('OM_EVENTS_SINGLE_DELIVERY', ORIG_SINGLE_DELIVERY)
|
|
37
90
|
})
|
|
38
91
|
|
|
39
92
|
describe('metadata', () => {
|
|
@@ -43,277 +96,258 @@ describe('Events Worker', () => {
|
|
|
43
96
|
})
|
|
44
97
|
|
|
45
98
|
it('should have default concurrency of 1', () => {
|
|
46
|
-
// When no env var is set, should default to 1
|
|
47
99
|
expect(metadata.concurrency).toBe(1)
|
|
48
100
|
})
|
|
49
101
|
})
|
|
50
102
|
|
|
51
|
-
describe('
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
103
|
+
describe('subscriber registry resolution', () => {
|
|
104
|
+
it('dispatches subscribers registered only on the bus, with no CLI module registry', async () => {
|
|
105
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
|
|
106
|
+
const calls: unknown[] = []
|
|
107
|
+
const bus = createBusWith([
|
|
108
|
+
{
|
|
109
|
+
id: 'webhooks:outbound-dispatch',
|
|
110
|
+
event: '*',
|
|
111
|
+
persistent: true,
|
|
112
|
+
handler: (payload) => { calls.push(payload) },
|
|
113
|
+
},
|
|
114
|
+
])
|
|
115
|
+
|
|
116
|
+
await handle(createMockJob('customers.deal.won', { id: 'deal-1' }), createMockContext(bus))
|
|
117
|
+
|
|
118
|
+
expect(calls).toEqual([{ id: 'deal-1' }])
|
|
59
119
|
})
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
120
|
+
|
|
121
|
+
it('throws an actionable error when the job container has no event bus', async () => {
|
|
122
|
+
const job = createMockJob('user.created', {})
|
|
123
|
+
|
|
124
|
+
await expect(handle(job, createMockContext())).rejects.toThrow(
|
|
125
|
+
/no "eventBus" in the job container/,
|
|
126
|
+
)
|
|
127
|
+
await expect(handle(job, createMockContext())).rejects.toThrow(
|
|
128
|
+
/mercato queue worker events/,
|
|
129
|
+
)
|
|
63
130
|
})
|
|
64
131
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
132
|
+
it('throws when the resolved bus predates dispatchQueued', async () => {
|
|
133
|
+
const staleBus = { emit: jest.fn(), on: jest.fn() } as unknown as EventBus
|
|
134
|
+
|
|
135
|
+
await expect(handle(createMockJob('user.created', {}), createMockContext(staleBus))).rejects.toThrow(
|
|
136
|
+
/has no dispatchQueued/,
|
|
137
|
+
)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('picks up subscribers registered after an earlier job was dispatched', async () => {
|
|
141
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
|
|
142
|
+
const calls: string[] = []
|
|
143
|
+
const bus = createBusWith([])
|
|
144
|
+
|
|
145
|
+
await handle(createMockJob('user.created', {}), createMockContext(bus))
|
|
146
|
+
expect(calls).toEqual([])
|
|
147
|
+
|
|
148
|
+
bus.registerModuleSubscribers([
|
|
149
|
+
{ id: 'late', event: 'user.created', persistent: true, handler: () => { calls.push('late') } },
|
|
150
|
+
])
|
|
151
|
+
|
|
152
|
+
await handle(createMockJob('user.created', {}), createMockContext(bus))
|
|
153
|
+
expect(calls).toEqual(['late'])
|
|
73
154
|
})
|
|
155
|
+
})
|
|
74
156
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
157
|
+
describe('producer stamp (persistentDeliveredInline)', () => {
|
|
158
|
+
it.each(['true', 'false'])(
|
|
159
|
+
'dispatches nothing when the producer already delivered inline (flag=%s)',
|
|
160
|
+
async (flag) => {
|
|
161
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = flag
|
|
162
|
+
const calls: string[] = []
|
|
163
|
+
const bus = createBusWith([
|
|
164
|
+
{ id: 'p', event: 'user.created', persistent: true, handler: () => { calls.push('p') } },
|
|
165
|
+
{ id: 'e', event: 'user.created', persistent: false, handler: () => { calls.push('e') } },
|
|
166
|
+
])
|
|
167
|
+
|
|
168
|
+
await handle(
|
|
169
|
+
createMockJob('user.created', {}, undefined, { persistentDeliveredInline: true }),
|
|
170
|
+
createMockContext(bus),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
expect(calls).toEqual([])
|
|
81
174
|
},
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
it('dispatches normally when the stamp is absent', async () => {
|
|
178
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
|
|
179
|
+
const calls: string[] = []
|
|
180
|
+
const bus = createBusWith([
|
|
181
|
+
{ id: 'p', event: 'user.created', persistent: true, handler: () => { calls.push('p') } },
|
|
182
|
+
])
|
|
183
|
+
|
|
184
|
+
await handle(createMockJob('user.created', {}), createMockContext(bus))
|
|
185
|
+
|
|
186
|
+
expect(calls).toEqual(['p'])
|
|
82
187
|
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('handle', () => {
|
|
191
|
+
// The worker only ever dispatches `persistent` subscribers, by pattern, and
|
|
192
|
+
// that selection does not depend on OM_EVENTS_SINGLE_DELIVERY - the job's
|
|
193
|
+
// stamp carries the producer's decision instead. These fixtures are therefore
|
|
194
|
+
// all persistent.
|
|
83
195
|
|
|
84
196
|
it('should do nothing when no subscribers are registered', async () => {
|
|
85
197
|
const job = createMockJob('test.event', { data: 'test' })
|
|
86
|
-
const ctx = createMockContext()
|
|
87
198
|
|
|
88
|
-
|
|
89
|
-
await expect(handle(job, ctx)).resolves.toBeUndefined()
|
|
199
|
+
await expect(handle(job, createMockContext(createBusWith([])))).resolves.toBeUndefined()
|
|
90
200
|
})
|
|
91
201
|
|
|
92
202
|
it('should dispatch event to matching subscribers', async () => {
|
|
93
203
|
const receivedPayloads: unknown[] = []
|
|
204
|
+
const bus = createBusWith([
|
|
205
|
+
{
|
|
206
|
+
id: 'test:subscriber1',
|
|
207
|
+
event: 'user.created',
|
|
208
|
+
persistent: true,
|
|
209
|
+
handler: (payload) => { receivedPayloads.push(payload) },
|
|
210
|
+
},
|
|
211
|
+
])
|
|
94
212
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
id: 'test:subscriber1',
|
|
100
|
-
event: 'user.created',
|
|
101
|
-
handler: async (payload: unknown) => {
|
|
102
|
-
receivedPayloads.push(payload)
|
|
103
|
-
},
|
|
104
|
-
},
|
|
105
|
-
],
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
registerCliModules([mockModule])
|
|
109
|
-
|
|
110
|
-
const job = createMockJob('user.created', { userId: '123', name: 'Test User' })
|
|
111
|
-
const ctx = createMockContext()
|
|
112
|
-
|
|
113
|
-
await handle(job, ctx)
|
|
213
|
+
await handle(
|
|
214
|
+
createMockJob('user.created', { userId: '123', name: 'Test User' }),
|
|
215
|
+
createMockContext(bus),
|
|
216
|
+
)
|
|
114
217
|
|
|
115
|
-
expect(receivedPayloads
|
|
116
|
-
expect(receivedPayloads[0]).toEqual({ userId: '123', name: 'Test User' })
|
|
218
|
+
expect(receivedPayloads).toEqual([{ userId: '123', name: 'Test User' }])
|
|
117
219
|
})
|
|
118
220
|
|
|
119
221
|
it('should dispatch to multiple subscribers for same event', async () => {
|
|
120
222
|
const subscriber1Calls: unknown[] = []
|
|
121
223
|
const subscriber2Calls: unknown[] = []
|
|
224
|
+
const bus = createBusWith([
|
|
225
|
+
{ id: 'a:subscriber', event: 'order.placed', persistent: true, handler: (p) => { subscriber1Calls.push(p) } },
|
|
226
|
+
{ id: 'b:subscriber', event: 'order.placed', persistent: true, handler: (p) => { subscriber2Calls.push(p) } },
|
|
227
|
+
])
|
|
122
228
|
|
|
123
|
-
|
|
124
|
-
{
|
|
125
|
-
id: 'module-a',
|
|
126
|
-
subscribers: [
|
|
127
|
-
{
|
|
128
|
-
id: 'a:subscriber',
|
|
129
|
-
event: 'order.placed',
|
|
130
|
-
handler: async (payload: unknown) => {
|
|
131
|
-
subscriber1Calls.push(payload)
|
|
132
|
-
},
|
|
133
|
-
},
|
|
134
|
-
],
|
|
135
|
-
},
|
|
136
|
-
{
|
|
137
|
-
id: 'module-b',
|
|
138
|
-
subscribers: [
|
|
139
|
-
{
|
|
140
|
-
id: 'b:subscriber',
|
|
141
|
-
event: 'order.placed',
|
|
142
|
-
handler: async (payload: unknown) => {
|
|
143
|
-
subscriber2Calls.push(payload)
|
|
144
|
-
},
|
|
145
|
-
},
|
|
146
|
-
],
|
|
147
|
-
},
|
|
148
|
-
]
|
|
149
|
-
|
|
150
|
-
registerCliModules(mockModules)
|
|
151
|
-
|
|
152
|
-
const job = createMockJob('order.placed', { orderId: '456' })
|
|
153
|
-
const ctx = createMockContext()
|
|
154
|
-
|
|
155
|
-
await handle(job, ctx)
|
|
229
|
+
await handle(createMockJob('order.placed', { orderId: '456' }), createMockContext(bus))
|
|
156
230
|
|
|
157
|
-
expect(subscriber1Calls
|
|
158
|
-
expect(subscriber2Calls
|
|
159
|
-
expect(subscriber1Calls[0]).toEqual({ orderId: '456' })
|
|
160
|
-
expect(subscriber2Calls[0]).toEqual({ orderId: '456' })
|
|
231
|
+
expect(subscriber1Calls).toEqual([{ orderId: '456' }])
|
|
232
|
+
expect(subscriber2Calls).toEqual([{ orderId: '456' }])
|
|
161
233
|
})
|
|
162
234
|
|
|
163
235
|
it('should not dispatch to non-matching event subscribers', async () => {
|
|
164
236
|
const receivedPayloads: unknown[] = []
|
|
237
|
+
const bus = createBusWith([
|
|
238
|
+
{ id: 'test:subscriber', event: 'user.created', persistent: true, handler: (p) => { receivedPayloads.push(p) } },
|
|
239
|
+
])
|
|
165
240
|
|
|
166
|
-
|
|
167
|
-
id: 'test-module',
|
|
168
|
-
subscribers: [
|
|
169
|
-
{
|
|
170
|
-
id: 'test:subscriber',
|
|
171
|
-
event: 'user.created',
|
|
172
|
-
handler: async (payload: unknown) => {
|
|
173
|
-
receivedPayloads.push(payload)
|
|
174
|
-
},
|
|
175
|
-
},
|
|
176
|
-
],
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
registerCliModules([mockModule])
|
|
241
|
+
await handle(createMockJob('user.deleted', { userId: '123' }), createMockContext(bus))
|
|
180
242
|
|
|
181
|
-
|
|
182
|
-
const ctx = createMockContext()
|
|
183
|
-
|
|
184
|
-
await handle(job, ctx)
|
|
185
|
-
|
|
186
|
-
expect(receivedPayloads.length).toBe(0)
|
|
243
|
+
expect(receivedPayloads).toEqual([])
|
|
187
244
|
})
|
|
188
245
|
|
|
189
246
|
it('should pass resolve function to subscriber context', async () => {
|
|
190
247
|
let capturedContext: unknown = null
|
|
248
|
+
const bus = createBusWith([
|
|
249
|
+
{ id: 'test:subscriber', event: 'test.event', persistent: true, handler: (_p, ctx) => { capturedContext = ctx } },
|
|
250
|
+
])
|
|
191
251
|
|
|
192
|
-
|
|
193
|
-
id: 'test-module',
|
|
194
|
-
subscribers: [
|
|
195
|
-
{
|
|
196
|
-
id: 'test:subscriber',
|
|
197
|
-
event: 'test.event',
|
|
198
|
-
handler: async (_payload: unknown, ctx: unknown) => {
|
|
199
|
-
capturedContext = ctx
|
|
200
|
-
},
|
|
201
|
-
},
|
|
202
|
-
],
|
|
203
|
-
}
|
|
252
|
+
await handle(createMockJob('test.event', {}), createMockContext(bus))
|
|
204
253
|
|
|
205
|
-
|
|
254
|
+
expect(capturedContext).toBeDefined()
|
|
255
|
+
expect((capturedContext as { resolve: unknown }).resolve).toBeDefined()
|
|
256
|
+
})
|
|
206
257
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
258
|
+
it('should hand subscribers the JOB container resolver, not the bus creation one', async () => {
|
|
259
|
+
// `toBeDefined()` above passes whichever resolver is threaded, so it cannot
|
|
260
|
+
// catch the two being swapped. Pin the identity with sentinels that differ:
|
|
261
|
+
// the worker must pass its own ctx.resolve, which is the container
|
|
262
|
+
// createPerJobWorkerHandler built for this job. Under OM_BOOTSTRAP_CACHE the
|
|
263
|
+
// bus is replayed across jobs while its captured resolver stays bound to the
|
|
264
|
+
// first container, so resolving `em` from it would share one EntityManager
|
|
265
|
+
// across concurrent jobs - the interleaving of issue #2970.
|
|
266
|
+
const busEm = { id: 'em-from-the-container-that-built-the-bus' }
|
|
267
|
+
const perJobEm = { id: 'em-for-this-job' }
|
|
268
|
+
let resolvedEm: unknown = null
|
|
269
|
+
|
|
270
|
+
const bus = createEventBus({
|
|
271
|
+
resolve: (<T = unknown>(name: string): T => {
|
|
272
|
+
if (name === 'em') {
|
|
273
|
+
return busEm as unknown as T
|
|
274
|
+
}
|
|
275
|
+
throw new Error(`No mock for ${name}`)
|
|
276
|
+
}),
|
|
277
|
+
})
|
|
278
|
+
bus.registerModuleSubscribers([
|
|
279
|
+
{ id: 'test:subscriber', event: 'test.event', persistent: true, handler: (_p, ctx) => { resolvedEm = ctx.resolve('em') } },
|
|
280
|
+
])
|
|
281
|
+
|
|
282
|
+
const ctx: WorkerContext = {
|
|
283
|
+
jobId: 'test-job-id',
|
|
284
|
+
attemptNumber: 1,
|
|
285
|
+
queueName: 'events',
|
|
286
|
+
resolve: <T = unknown>(name: string): T => {
|
|
287
|
+
if (name === 'eventBus') {
|
|
288
|
+
return bus as unknown as T
|
|
289
|
+
}
|
|
290
|
+
if (name === 'em') {
|
|
291
|
+
return perJobEm as unknown as T
|
|
292
|
+
}
|
|
293
|
+
throw new Error(`No mock for ${name}`)
|
|
294
|
+
},
|
|
212
295
|
}
|
|
213
296
|
|
|
214
|
-
await handle(
|
|
297
|
+
await handle(createMockJob('test.event', {}), ctx)
|
|
215
298
|
|
|
216
|
-
expect(
|
|
217
|
-
expect((capturedContext as { resolve: unknown }).resolve).toBeDefined()
|
|
299
|
+
expect(resolvedEm).toBe(perJobEm)
|
|
218
300
|
})
|
|
219
301
|
|
|
220
302
|
it('should pass trusted tenant and organization scope to subscriber context', async () => {
|
|
221
303
|
let capturedContext: { tenantId?: string | null; organizationId?: string | null } | null = null
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
handler: async (_payload: unknown, ctx: unknown) => {
|
|
230
|
-
const typed = ctx as { tenantId?: string | null; organizationId?: string | null }
|
|
231
|
-
capturedContext = {
|
|
232
|
-
tenantId: typed.tenantId,
|
|
233
|
-
organizationId: typed.organizationId,
|
|
234
|
-
}
|
|
235
|
-
},
|
|
304
|
+
const bus = createBusWith([
|
|
305
|
+
{
|
|
306
|
+
id: 'test:subscriber',
|
|
307
|
+
event: 'test.event',
|
|
308
|
+
persistent: true,
|
|
309
|
+
handler: (_p, ctx) => {
|
|
310
|
+
capturedContext = { tenantId: ctx.tenantId, organizationId: ctx.organizationId }
|
|
236
311
|
},
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
registerCliModules([mockModule])
|
|
241
|
-
|
|
242
|
-
const job = createMockJob('test.event', {}, { tenantId: 'tenant-1', organizationId: 'org-1' })
|
|
243
|
-
const ctx = createMockContext()
|
|
312
|
+
},
|
|
313
|
+
])
|
|
244
314
|
|
|
245
|
-
await handle(
|
|
315
|
+
await handle(
|
|
316
|
+
createMockJob('test.event', {}, { tenantId: 'tenant-1', organizationId: 'org-1' }),
|
|
317
|
+
createMockContext(bus),
|
|
318
|
+
)
|
|
246
319
|
|
|
247
320
|
expect(capturedContext).toEqual({ tenantId: 'tenant-1', organizationId: 'org-1' })
|
|
248
321
|
})
|
|
249
322
|
|
|
250
323
|
it('should not trust payload scope when trusted scope is omitted', async () => {
|
|
251
324
|
let capturedContext: { tenantId?: string | null; organizationId?: string | null } | null = null
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
handler: async (_payload: unknown, ctx: unknown) => {
|
|
260
|
-
const typed = ctx as { tenantId?: string | null; organizationId?: string | null }
|
|
261
|
-
capturedContext = {
|
|
262
|
-
tenantId: typed.tenantId,
|
|
263
|
-
organizationId: typed.organizationId,
|
|
264
|
-
}
|
|
265
|
-
},
|
|
325
|
+
const bus = createBusWith([
|
|
326
|
+
{
|
|
327
|
+
id: 'test:subscriber',
|
|
328
|
+
event: 'test.event',
|
|
329
|
+
persistent: true,
|
|
330
|
+
handler: (_p, ctx) => {
|
|
331
|
+
capturedContext = { tenantId: ctx.tenantId, organizationId: ctx.organizationId }
|
|
266
332
|
},
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
registerCliModules([mockModule])
|
|
271
|
-
|
|
272
|
-
const job = createMockJob('test.event', { tenantId: 'payload-tenant', organizationId: 'payload-org' })
|
|
273
|
-
const ctx = createMockContext()
|
|
333
|
+
},
|
|
334
|
+
])
|
|
274
335
|
|
|
275
|
-
await handle(
|
|
336
|
+
await handle(
|
|
337
|
+
createMockJob('test.event', { tenantId: 'payload-tenant', organizationId: 'payload-org' }),
|
|
338
|
+
createMockContext(bus),
|
|
339
|
+
)
|
|
276
340
|
|
|
277
341
|
expect(capturedContext).toEqual({ tenantId: null, organizationId: null })
|
|
278
342
|
})
|
|
279
343
|
|
|
280
|
-
it('should handle modules without subscribers', async () => {
|
|
281
|
-
const mockModule: Module = {
|
|
282
|
-
id: 'module-without-subscribers',
|
|
283
|
-
// No subscribers property
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
registerCliModules([mockModule])
|
|
287
|
-
|
|
288
|
-
const job = createMockJob('any.event', {})
|
|
289
|
-
const ctx = createMockContext()
|
|
290
|
-
|
|
291
|
-
// Should not throw
|
|
292
|
-
await expect(handle(job, ctx)).resolves.toBeUndefined()
|
|
293
|
-
})
|
|
294
|
-
|
|
295
344
|
it('should handle synchronous handlers', async () => {
|
|
296
345
|
let called = false
|
|
346
|
+
const bus = createBusWith([
|
|
347
|
+
{ id: 'test:sync-subscriber', event: 'sync.event', persistent: true, handler: () => { called = true } },
|
|
348
|
+
])
|
|
297
349
|
|
|
298
|
-
|
|
299
|
-
id: 'test-module',
|
|
300
|
-
subscribers: [
|
|
301
|
-
{
|
|
302
|
-
id: 'test:sync-subscriber',
|
|
303
|
-
event: 'sync.event',
|
|
304
|
-
handler: () => {
|
|
305
|
-
called = true
|
|
306
|
-
},
|
|
307
|
-
},
|
|
308
|
-
],
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
registerCliModules([mockModule])
|
|
312
|
-
|
|
313
|
-
const job = createMockJob('sync.event', {})
|
|
314
|
-
const ctx = createMockContext()
|
|
315
|
-
|
|
316
|
-
await handle(job, ctx)
|
|
350
|
+
await handle(createMockJob('sync.event', {}), createMockContext(bus))
|
|
317
351
|
|
|
318
352
|
expect(called).toBe(true)
|
|
319
353
|
})
|
|
@@ -321,47 +355,30 @@ describe('Events Worker', () => {
|
|
|
321
355
|
it('should run all subscribers even when one fails, then throw to trigger retry', async () => {
|
|
322
356
|
const subscriber1Calls: unknown[] = []
|
|
323
357
|
const subscriber2Calls: unknown[] = []
|
|
324
|
-
|
|
325
|
-
const mockModules: Module[] = [
|
|
358
|
+
const bus = createBusWith([
|
|
326
359
|
{
|
|
327
|
-
id: '
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
throw new Error('Subscriber A failed')
|
|
335
|
-
},
|
|
336
|
-
},
|
|
337
|
-
],
|
|
360
|
+
id: 'a:failing-subscriber',
|
|
361
|
+
event: 'test.event',
|
|
362
|
+
persistent: true,
|
|
363
|
+
handler: async () => {
|
|
364
|
+
subscriber1Calls.push('called')
|
|
365
|
+
throw new Error('Subscriber A failed')
|
|
366
|
+
},
|
|
338
367
|
},
|
|
339
368
|
{
|
|
340
|
-
id: '
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
event: 'test.event',
|
|
345
|
-
handler: async (payload: unknown) => {
|
|
346
|
-
subscriber2Calls.push(payload)
|
|
347
|
-
},
|
|
348
|
-
},
|
|
349
|
-
],
|
|
369
|
+
id: 'b:working-subscriber',
|
|
370
|
+
event: 'test.event',
|
|
371
|
+
persistent: true,
|
|
372
|
+
handler: async (payload) => { subscriber2Calls.push(payload) },
|
|
350
373
|
},
|
|
351
|
-
]
|
|
352
|
-
|
|
353
|
-
registerCliModules(mockModules)
|
|
374
|
+
])
|
|
354
375
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
await expect(handle(job, ctx)).rejects.toThrow(
|
|
359
|
-
'1/2 subscriber(s) failed for event "test.event": a:failing-subscriber'
|
|
376
|
+
await expect(handle(createMockJob('test.event', { data: 'test' }), createMockContext(bus))).rejects.toThrow(
|
|
377
|
+
'[internal] 1/2 subscriber(s) failed for event "test.event": a:failing-subscriber'
|
|
360
378
|
)
|
|
361
379
|
|
|
362
380
|
expect(subscriber1Calls.length).toBe(1)
|
|
363
|
-
expect(subscriber2Calls
|
|
364
|
-
expect(subscriber2Calls[0]).toEqual({ data: 'test' })
|
|
381
|
+
expect(subscriber2Calls).toEqual([{ data: 'test' }])
|
|
365
382
|
|
|
366
383
|
expect(workerLoggerError).toHaveBeenCalledTimes(1)
|
|
367
384
|
expect(workerLoggerError).toHaveBeenCalledWith('Subscriber failed for event', {
|
|
@@ -380,33 +397,13 @@ describe('Events Worker', () => {
|
|
|
380
397
|
executionLog.push({ id, phase: 'end', time: Date.now() })
|
|
381
398
|
}
|
|
382
399
|
|
|
383
|
-
const
|
|
384
|
-
{
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
],
|
|
389
|
-
},
|
|
390
|
-
{
|
|
391
|
-
id: 'module-b',
|
|
392
|
-
subscribers: [
|
|
393
|
-
{ id: 'b:slow', event: 'test.parallel', handler: createDelayedHandler('b:slow', 100) },
|
|
394
|
-
],
|
|
395
|
-
},
|
|
396
|
-
{
|
|
397
|
-
id: 'module-c',
|
|
398
|
-
subscribers: [
|
|
399
|
-
{ id: 'c:slow', event: 'test.parallel', handler: createDelayedHandler('c:slow', 100) },
|
|
400
|
-
],
|
|
401
|
-
},
|
|
402
|
-
]
|
|
403
|
-
|
|
404
|
-
registerCliModules(mockModules)
|
|
400
|
+
const bus = createBusWith([
|
|
401
|
+
{ id: 'a:slow', event: 'test.parallel', persistent: true, handler: createDelayedHandler('a:slow', 100) },
|
|
402
|
+
{ id: 'b:slow', event: 'test.parallel', persistent: true, handler: createDelayedHandler('b:slow', 100) },
|
|
403
|
+
{ id: 'c:slow', event: 'test.parallel', persistent: true, handler: createDelayedHandler('c:slow', 100) },
|
|
404
|
+
])
|
|
405
405
|
|
|
406
|
-
|
|
407
|
-
const ctx = createMockContext()
|
|
408
|
-
|
|
409
|
-
await handle(job, ctx)
|
|
406
|
+
await handle(createMockJob('test.parallel', {}), createMockContext(bus))
|
|
410
407
|
|
|
411
408
|
const starts = executionLog.filter((e) => e.phase === 'start')
|
|
412
409
|
const ends = executionLog.filter((e) => e.phase === 'end')
|
|
@@ -422,40 +419,23 @@ describe('Events Worker', () => {
|
|
|
422
419
|
})
|
|
423
420
|
|
|
424
421
|
it('should throw when all subscribers fail', async () => {
|
|
425
|
-
const
|
|
422
|
+
const bus = createBusWith([
|
|
426
423
|
{
|
|
427
|
-
id: '
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
event: 'test.event',
|
|
432
|
-
handler: async () => {
|
|
433
|
-
throw new Error('Subscriber A failed')
|
|
434
|
-
},
|
|
435
|
-
},
|
|
436
|
-
],
|
|
424
|
+
id: 'a:failing-subscriber',
|
|
425
|
+
event: 'test.event',
|
|
426
|
+
persistent: true,
|
|
427
|
+
handler: async () => { throw new Error('Subscriber A failed') },
|
|
437
428
|
},
|
|
438
429
|
{
|
|
439
|
-
id: '
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
event: 'test.event',
|
|
444
|
-
handler: async () => {
|
|
445
|
-
throw new Error('Subscriber B failed')
|
|
446
|
-
},
|
|
447
|
-
},
|
|
448
|
-
],
|
|
430
|
+
id: 'b:failing-subscriber',
|
|
431
|
+
event: 'test.event',
|
|
432
|
+
persistent: true,
|
|
433
|
+
handler: async () => { throw new Error('Subscriber B failed') },
|
|
449
434
|
},
|
|
450
|
-
]
|
|
451
|
-
|
|
452
|
-
registerCliModules(mockModules)
|
|
453
|
-
|
|
454
|
-
const job = createMockJob('test.event', { data: 'test' })
|
|
455
|
-
const ctx = createMockContext()
|
|
435
|
+
])
|
|
456
436
|
|
|
457
|
-
await expect(handle(
|
|
458
|
-
'2/2 subscriber(s) failed for event "test.event": a:failing-subscriber, b:failing-subscriber'
|
|
437
|
+
await expect(handle(createMockJob('test.event', { data: 'test' }), createMockContext(bus))).rejects.toThrow(
|
|
438
|
+
'[internal] 2/2 subscriber(s) failed for event "test.event": a:failing-subscriber, b:failing-subscriber'
|
|
459
439
|
)
|
|
460
440
|
|
|
461
441
|
expect(workerLoggerError).toHaveBeenCalledTimes(2)
|
|
@@ -463,111 +443,67 @@ describe('Events Worker', () => {
|
|
|
463
443
|
})
|
|
464
444
|
|
|
465
445
|
describe('single-delivery dispatch (OM_EVENTS_SINGLE_DELIVERY) — issue #2960', () => {
|
|
466
|
-
const origFlag = process.env.OM_EVENTS_SINGLE_DELIVERY
|
|
467
|
-
|
|
468
|
-
afterEach(() => {
|
|
469
|
-
if (origFlag === undefined) delete process.env.OM_EVENTS_SINGLE_DELIVERY
|
|
470
|
-
else process.env.OM_EVENTS_SINGLE_DELIVERY = origFlag
|
|
471
|
-
})
|
|
472
|
-
|
|
473
|
-
const createMockJob = (
|
|
474
|
-
event: string,
|
|
475
|
-
payload: unknown,
|
|
476
|
-
options?: { tenantId?: string | null; organizationId?: string | null },
|
|
477
|
-
): QueuedJob<{
|
|
478
|
-
event: string
|
|
479
|
-
payload: unknown
|
|
480
|
-
options?: { tenantId?: string | null; organizationId?: string | null }
|
|
481
|
-
}> => ({
|
|
482
|
-
id: 'test-job-id',
|
|
483
|
-
payload: { event, payload, options },
|
|
484
|
-
createdAt: new Date().toISOString(),
|
|
485
|
-
})
|
|
486
|
-
|
|
487
|
-
const createMockContext = (): JobContext & { resolve: <T = unknown>(name: string) => T } => ({
|
|
488
|
-
jobId: 'test-job-id',
|
|
489
|
-
attemptNumber: 1,
|
|
490
|
-
queueName: 'events',
|
|
491
|
-
resolve: <T = unknown>(name: string): T => { throw new Error(`No mock for ${name}`) },
|
|
492
|
-
})
|
|
493
|
-
|
|
494
446
|
it('flag ON: dispatches wildcard persistent subscribers that exact-match never reached', async () => {
|
|
495
447
|
process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
|
|
496
|
-
clearListenerCache()
|
|
497
448
|
const calls: string[] = []
|
|
498
|
-
|
|
499
|
-
id: '
|
|
500
|
-
|
|
501
|
-
{ id: 'wildcard:persistent', event: '*', persistent: true, handler: () => { calls.push('wild') } },
|
|
502
|
-
],
|
|
503
|
-
}])
|
|
449
|
+
const bus = createBusWith([
|
|
450
|
+
{ id: 'wildcard:persistent', event: '*', persistent: true, handler: () => { calls.push('wild') } },
|
|
451
|
+
])
|
|
504
452
|
|
|
505
|
-
await handle(createMockJob('any.event', {}), createMockContext())
|
|
453
|
+
await handle(createMockJob('any.event', {}), createMockContext(bus))
|
|
506
454
|
|
|
507
455
|
expect(calls).toEqual(['wild'])
|
|
508
456
|
})
|
|
509
457
|
|
|
510
458
|
it('flag ON: excludes non-persistent subscribers from worker dispatch', async () => {
|
|
511
459
|
process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
|
|
512
|
-
clearListenerCache()
|
|
513
460
|
const calls: string[] = []
|
|
514
|
-
|
|
515
|
-
id: '
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
{ id: 'e', event: 'user.created', persistent: false, handler: () => { calls.push('e') } },
|
|
519
|
-
],
|
|
520
|
-
}])
|
|
461
|
+
const bus = createBusWith([
|
|
462
|
+
{ id: 'p', event: 'user.created', persistent: true, handler: () => { calls.push('p') } },
|
|
463
|
+
{ id: 'e', event: 'user.created', persistent: false, handler: () => { calls.push('e') } },
|
|
464
|
+
])
|
|
521
465
|
|
|
522
|
-
await handle(createMockJob('user.created', {}), createMockContext())
|
|
466
|
+
await handle(createMockJob('user.created', {}), createMockContext(bus))
|
|
523
467
|
|
|
524
468
|
expect(calls).toEqual(['p'])
|
|
525
469
|
})
|
|
526
470
|
|
|
527
|
-
it('
|
|
471
|
+
it('worker selection ignores the flag: wildcard persistent subscribers are always reached', async () => {
|
|
528
472
|
delete process.env.OM_EVENTS_SINGLE_DELIVERY
|
|
529
|
-
clearListenerCache()
|
|
530
473
|
const calls: string[] = []
|
|
531
|
-
|
|
532
|
-
id: '
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
{ id: 'w', event: '*', persistent: true, handler: () => { calls.push('w') } },
|
|
536
|
-
],
|
|
537
|
-
}])
|
|
474
|
+
const bus = createBusWith([
|
|
475
|
+
{ id: 'p', event: 'user.created', persistent: true, handler: () => { calls.push('p') } },
|
|
476
|
+
{ id: 'w', event: '*', persistent: true, handler: () => { calls.push('w') } },
|
|
477
|
+
])
|
|
538
478
|
|
|
539
|
-
await handle(createMockJob('user.created', {}), createMockContext())
|
|
479
|
+
await handle(createMockJob('user.created', {}), createMockContext(bus))
|
|
540
480
|
|
|
541
481
|
// Default-on: pattern dispatch reaches both the exact-match and the wildcard
|
|
542
482
|
// persistent subscriber.
|
|
543
483
|
expect(calls.sort()).toEqual(['p', 'w'])
|
|
544
484
|
})
|
|
545
485
|
|
|
546
|
-
it('
|
|
486
|
+
it('forwards eventName and trusted scope to persistent wildcard subscribers', async () => {
|
|
547
487
|
delete process.env.OM_EVENTS_SINGLE_DELIVERY
|
|
548
|
-
clearListenerCache()
|
|
549
488
|
const contexts: Array<{
|
|
550
489
|
eventName?: string
|
|
551
490
|
tenantId?: string | null
|
|
552
491
|
organizationId?: string | null
|
|
553
492
|
}> = []
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
organizationId: ctx.organizationId,
|
|
566
|
-
})
|
|
567
|
-
},
|
|
493
|
+
const bus = createBusWith([
|
|
494
|
+
{
|
|
495
|
+
id: 'workflow:event-trigger',
|
|
496
|
+
event: '*',
|
|
497
|
+
persistent: true,
|
|
498
|
+
handler: (_payload, ctx) => {
|
|
499
|
+
contexts.push({
|
|
500
|
+
eventName: ctx.eventName,
|
|
501
|
+
tenantId: ctx.tenantId,
|
|
502
|
+
organizationId: ctx.organizationId,
|
|
503
|
+
})
|
|
568
504
|
},
|
|
569
|
-
|
|
570
|
-
|
|
505
|
+
},
|
|
506
|
+
])
|
|
571
507
|
|
|
572
508
|
await handle(
|
|
573
509
|
createMockJob(
|
|
@@ -575,7 +511,7 @@ describe('Events Worker', () => {
|
|
|
575
511
|
{ id: 'deal-1', tenantId: 'payload-tenant', organizationId: 'payload-org' },
|
|
576
512
|
{ tenantId: 'trusted-tenant', organizationId: 'trusted-org' },
|
|
577
513
|
),
|
|
578
|
-
createMockContext(),
|
|
514
|
+
createMockContext(bus),
|
|
579
515
|
)
|
|
580
516
|
|
|
581
517
|
expect(contexts).toEqual([{
|
|
@@ -585,22 +521,55 @@ describe('Events Worker', () => {
|
|
|
585
521
|
}])
|
|
586
522
|
})
|
|
587
523
|
|
|
588
|
-
|
|
524
|
+
// Producer/worker env skew: the worker's own flag must not change selection.
|
|
525
|
+
// A stamp-less job reaching a worker with the flag OFF used to fall back to
|
|
526
|
+
// exact-match, re-running ephemerals and missing wildcards.
|
|
527
|
+
it('flag explicitly OFF on the worker still selects persistent subscribers by pattern', async () => {
|
|
589
528
|
process.env.OM_EVENTS_SINGLE_DELIVERY = 'false'
|
|
590
|
-
clearListenerCache()
|
|
591
529
|
const calls: string[] = []
|
|
592
|
-
|
|
593
|
-
id: '
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
{ id: 'w', event: '*', persistent: true, handler: () => { calls.push('w') } },
|
|
597
|
-
],
|
|
598
|
-
}])
|
|
530
|
+
const bus = createBusWith([
|
|
531
|
+
{ id: 'p', event: 'user.created', persistent: true, handler: () => { calls.push('p') } },
|
|
532
|
+
{ id: 'w', event: '*', persistent: true, handler: () => { calls.push('w') } },
|
|
533
|
+
])
|
|
599
534
|
|
|
600
|
-
await handle(createMockJob('user.created', {}), createMockContext())
|
|
535
|
+
await handle(createMockJob('user.created', {}), createMockContext(bus))
|
|
601
536
|
|
|
602
|
-
|
|
603
|
-
|
|
537
|
+
expect(calls.sort()).toEqual(['p', 'w'])
|
|
538
|
+
})
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
describe('zero-subscriber reporting', () => {
|
|
542
|
+
it('warns once per event name, then drops to debug', async () => {
|
|
543
|
+
// This line is the last remaining visibility into the silent-loss path the
|
|
544
|
+
// whole change exists to close, so it has to stay findable - but an install
|
|
545
|
+
// carrying none of the wildcard persistent subscribers would otherwise get
|
|
546
|
+
// one `warn` per queued event forever, and a warning that fires in steady
|
|
547
|
+
// state is one operators learn to skip past.
|
|
548
|
+
//
|
|
549
|
+
// The event name is unique to this test on purpose: the suppression set is
|
|
550
|
+
// module state shared across the whole file, so a name another test already
|
|
551
|
+
// dispatched would have consumed the first-call `warn`.
|
|
552
|
+
const bus = createBusWith([
|
|
553
|
+
{ id: 'unrelated', event: 'other.event', persistent: true, handler: () => {} },
|
|
554
|
+
])
|
|
555
|
+
|
|
556
|
+
await handle(createMockJob('nobody.listens.here', {}), createMockContext(bus))
|
|
557
|
+
|
|
558
|
+
expect(workerLoggerWarn).toHaveBeenCalledTimes(1)
|
|
559
|
+
expect(workerLoggerWarn).toHaveBeenCalledWith('Queued event dispatched to zero subscribers', {
|
|
560
|
+
event: 'nobody.listens.here',
|
|
561
|
+
jobId: 'test-job-id',
|
|
562
|
+
})
|
|
563
|
+
expect(workerLoggerDebug).not.toHaveBeenCalled()
|
|
564
|
+
|
|
565
|
+
await handle(createMockJob('nobody.listens.here', {}), createMockContext(bus))
|
|
566
|
+
|
|
567
|
+
expect(workerLoggerWarn).toHaveBeenCalledTimes(1)
|
|
568
|
+
expect(workerLoggerDebug).toHaveBeenCalledTimes(1)
|
|
569
|
+
expect(workerLoggerDebug).toHaveBeenCalledWith('Queued event dispatched to zero subscribers', {
|
|
570
|
+
event: 'nobody.listens.here',
|
|
571
|
+
jobId: 'test-job-id',
|
|
572
|
+
})
|
|
604
573
|
})
|
|
605
574
|
})
|
|
606
575
|
})
|