@open-mercato/events 0.6.8-develop.6918.1.7b3f32b0c8 → 0.6.8-develop.6925.1.cefa200fa4
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
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { createEventBus } from '@open-mercato/events/index'
|
|
2
|
+
import type { SubscriberDescriptor } from '@open-mercato/events/types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `dispatchQueued` is the events worker's only way to reach subscribers. The
|
|
6
|
+
* worker used to keep its own registry built from `getCliModules()`, which is
|
|
7
|
+
* populated exclusively by the `mercato` bin - so a worker started any other way
|
|
8
|
+
* dispatched nothing and completed the job silently. Selection now lives here, on
|
|
9
|
+
* the bus that already holds every registered subscriber, which also guarantees
|
|
10
|
+
* the inline-skip and the worker dispatch read one single-delivery decision.
|
|
11
|
+
*/
|
|
12
|
+
describe('EventBus.dispatchQueued', () => {
|
|
13
|
+
const origFlag = process.env.OM_EVENTS_SINGLE_DELIVERY
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
if (origFlag === undefined) {
|
|
17
|
+
delete process.env.OM_EVENTS_SINGLE_DELIVERY
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = origFlag
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
function makeBus(subs: SubscriberDescriptor[]) {
|
|
25
|
+
const bus = createEventBus({ resolve: ((name: string) => name) as never })
|
|
26
|
+
bus.registerModuleSubscribers(subs)
|
|
27
|
+
return bus
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeSub(
|
|
31
|
+
id: string,
|
|
32
|
+
event: string,
|
|
33
|
+
persistent: boolean,
|
|
34
|
+
sink: string[],
|
|
35
|
+
): SubscriberDescriptor {
|
|
36
|
+
return { id, event, persistent, handler: () => { sink.push(id) } }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('single-delivery on: pattern-matches and keeps persistent subscribers only', async () => {
|
|
40
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
|
|
41
|
+
const calls: string[] = []
|
|
42
|
+
const bus = makeBus([
|
|
43
|
+
makeSub('exact-persistent', 'user.created', true, calls),
|
|
44
|
+
makeSub('exact-ephemeral', 'user.created', false, calls),
|
|
45
|
+
makeSub('wildcard-persistent', '*', true, calls),
|
|
46
|
+
makeSub('wildcard-ephemeral', '*', false, calls),
|
|
47
|
+
makeSub('other-event', 'user.deleted', true, calls),
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
const results = await bus.dispatchQueued('user.created', { id: '1' })
|
|
51
|
+
|
|
52
|
+
expect(calls.sort()).toEqual(['exact-persistent', 'wildcard-persistent'])
|
|
53
|
+
expect(results.map((r) => r.subscriberId).sort()).toEqual(['exact-persistent', 'wildcard-persistent'])
|
|
54
|
+
expect(results.every((r) => r.error === undefined)).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('selection ignores OM_EVENTS_SINGLE_DELIVERY (producer/worker env skew)', async () => {
|
|
58
|
+
// A worker whose env has the flag OFF must still select persistent
|
|
59
|
+
// subscribers by pattern. Reading the flag here used to fall back to
|
|
60
|
+
// exact-match, which re-ran ephemeral subscribers the producer had already
|
|
61
|
+
// run inline and never reached wildcard persistent subscribers - the failure
|
|
62
|
+
// this change exists to remove, reintroduced under env skew.
|
|
63
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = 'false'
|
|
64
|
+
const calls: string[] = []
|
|
65
|
+
const bus = makeBus([
|
|
66
|
+
makeSub('exact-persistent', 'user.created', true, calls),
|
|
67
|
+
makeSub('exact-ephemeral', 'user.created', false, calls),
|
|
68
|
+
makeSub('wildcard-persistent', '*', true, calls),
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
await bus.dispatchQueued('user.created', {})
|
|
72
|
+
|
|
73
|
+
expect(calls.sort()).toEqual(['exact-persistent', 'wildcard-persistent'])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('returns handler failures instead of swallowing them', async () => {
|
|
77
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
|
|
78
|
+
const bus = makeBus([
|
|
79
|
+
{
|
|
80
|
+
id: 'boom',
|
|
81
|
+
event: 'user.created',
|
|
82
|
+
persistent: true,
|
|
83
|
+
handler: () => { throw new Error('handler exploded') },
|
|
84
|
+
},
|
|
85
|
+
{ id: 'ok', event: 'user.created', persistent: true, handler: () => {} },
|
|
86
|
+
])
|
|
87
|
+
|
|
88
|
+
const results = await bus.dispatchQueued('user.created', {})
|
|
89
|
+
|
|
90
|
+
const failed = results.find((r) => r.subscriberId === 'boom')
|
|
91
|
+
const succeeded = results.find((r) => r.subscriberId === 'ok')
|
|
92
|
+
expect((failed?.error as Error).message).toBe('handler exploded')
|
|
93
|
+
expect(succeeded?.error).toBeUndefined()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('reports a rejection with an undefined reason as a failure', async () => {
|
|
97
|
+
// `Promise.allSettled` sets `reason: undefined` for `throw undefined` and
|
|
98
|
+
// `Promise.reject()`, so an outcome inferred from `error !== undefined` would
|
|
99
|
+
// score this as a success and let the worker complete the job.
|
|
100
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
|
|
101
|
+
const bus = makeBus([
|
|
102
|
+
{
|
|
103
|
+
id: 'throws-undefined',
|
|
104
|
+
event: 'user.created',
|
|
105
|
+
persistent: true,
|
|
106
|
+
handler: () => { throw undefined },
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'rejects-undefined',
|
|
110
|
+
event: 'user.created',
|
|
111
|
+
persistent: true,
|
|
112
|
+
handler: () => Promise.reject(),
|
|
113
|
+
},
|
|
114
|
+
])
|
|
115
|
+
|
|
116
|
+
const results = await bus.dispatchQueued('user.created', {})
|
|
117
|
+
|
|
118
|
+
expect(results.map((r) => ({ id: r.subscriberId, ok: r.ok })).sort((a, b) => a.id.localeCompare(b.id)))
|
|
119
|
+
.toEqual([
|
|
120
|
+
{ id: 'rejects-undefined', ok: false },
|
|
121
|
+
{ id: 'throws-undefined', ok: false },
|
|
122
|
+
])
|
|
123
|
+
expect(results.every((r) => r.error === undefined)).toBe(true)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('forwards trusted scope and the concrete event name to wildcard subscribers', async () => {
|
|
127
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
|
|
128
|
+
const seen: Array<Record<string, unknown>> = []
|
|
129
|
+
const bus = makeBus([
|
|
130
|
+
{
|
|
131
|
+
id: 'wildcard',
|
|
132
|
+
event: '*',
|
|
133
|
+
persistent: true,
|
|
134
|
+
handler: (_payload, ctx) => {
|
|
135
|
+
seen.push({
|
|
136
|
+
eventName: ctx.eventName,
|
|
137
|
+
tenantId: ctx.tenantId,
|
|
138
|
+
organizationId: ctx.organizationId,
|
|
139
|
+
})
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
])
|
|
143
|
+
|
|
144
|
+
await bus.dispatchQueued('customers.deal.won', {}, { tenantId: 't1', organizationId: 'o1' })
|
|
145
|
+
|
|
146
|
+
expect(seen).toEqual([{ eventName: 'customers.deal.won', tenantId: 't1', organizationId: 'o1' }])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('falls back to the registered pattern when a subscriber has no id', async () => {
|
|
150
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
|
|
151
|
+
const bus = createEventBus({ resolve: ((name: string) => name) as never })
|
|
152
|
+
bus.on('user.created', () => {}, { persistent: true })
|
|
153
|
+
|
|
154
|
+
const results = await bus.dispatchQueued('user.created', {})
|
|
155
|
+
|
|
156
|
+
expect(results).toEqual([{ subscriberId: 'user.created', ok: true }])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('prefers the caller-supplied resolver over the bus creation one', async () => {
|
|
160
|
+
// The events worker passes its per-job `ctx.resolve` so subscribers bind to
|
|
161
|
+
// that job's container. Under `OM_BOOTSTRAP_CACHE` the bus may be a cached
|
|
162
|
+
// instance whose captured resolver still points at the container that
|
|
163
|
+
// bootstrapped first, which would share one `em` across concurrent jobs.
|
|
164
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
|
|
165
|
+
let resolved: unknown = null
|
|
166
|
+
const bus = createEventBus({ resolve: ((name: string) => `bus:${name}`) as never })
|
|
167
|
+
bus.on('user.created', (_payload, ctx) => { resolved = ctx.resolve('em') }, { persistent: true })
|
|
168
|
+
|
|
169
|
+
await bus.dispatchQueued('user.created', {}, undefined, ((name: string) => `job:${name}`) as never)
|
|
170
|
+
|
|
171
|
+
expect(resolved).toBe('job:em')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('falls back to the bus resolver when the caller supplies none', async () => {
|
|
175
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
|
|
176
|
+
let resolved: unknown = null
|
|
177
|
+
const bus = createEventBus({ resolve: ((name: string) => `bus:${name}`) as never })
|
|
178
|
+
bus.on('user.created', (_payload, ctx) => { resolved = ctx.resolve('em') }, { persistent: true })
|
|
179
|
+
|
|
180
|
+
await bus.dispatchQueued('user.created', {})
|
|
181
|
+
|
|
182
|
+
expect(resolved).toBe('bus:em')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('returns an empty result set when nothing matches', async () => {
|
|
186
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
|
|
187
|
+
const bus = makeBus([makeSub('other', 'user.deleted', true, [])])
|
|
188
|
+
|
|
189
|
+
expect(await bus.dispatchQueued('user.created', {})).toEqual([])
|
|
190
|
+
})
|
|
191
|
+
})
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { createEventBus } from '@open-mercato/events/index'
|
|
6
|
+
import handle from '@open-mercato/events/modules/events/workers/events.worker'
|
|
7
|
+
import type { EventBus } from '@open-mercato/events/types'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Full round trip: persistent emit -> local file queue -> events worker -> subscriber,
|
|
11
|
+
* in a process where the CLI module registry was NEVER registered.
|
|
12
|
+
*
|
|
13
|
+
* That is the exact shape of the bug this suite pins. The worker used to build its
|
|
14
|
+
* subscriber map from `getCliModules()`, which only the `mercato` bin populates, so
|
|
15
|
+
* this round trip ended with the job marked COMPLETED and the subscriber never run -
|
|
16
|
+
* silently, because the bus had already skipped it inline under single-delivery.
|
|
17
|
+
*/
|
|
18
|
+
describe('queued persistent delivery round trip (no CLI module registry)', () => {
|
|
19
|
+
const origCwd = process.cwd()
|
|
20
|
+
const origExternalWorker = process.env.OM_EVENTS_EXTERNAL_WORKER
|
|
21
|
+
const origFlag = process.env.OM_EVENTS_SINGLE_DELIVERY
|
|
22
|
+
const origStrategy = process.env.QUEUE_STRATEGY
|
|
23
|
+
let tmp: string
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'events-roundtrip-'))
|
|
27
|
+
process.chdir(tmp)
|
|
28
|
+
process.env.OM_EVENTS_EXTERNAL_WORKER = 'true'
|
|
29
|
+
delete process.env.OM_EVENTS_SINGLE_DELIVERY
|
|
30
|
+
delete process.env.QUEUE_STRATEGY
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
process.chdir(origCwd)
|
|
35
|
+
if (origExternalWorker === undefined) {
|
|
36
|
+
delete process.env.OM_EVENTS_EXTERNAL_WORKER
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
process.env.OM_EVENTS_EXTERNAL_WORKER = origExternalWorker
|
|
40
|
+
}
|
|
41
|
+
if (origFlag === undefined) {
|
|
42
|
+
delete process.env.OM_EVENTS_SINGLE_DELIVERY
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = origFlag
|
|
46
|
+
}
|
|
47
|
+
if (origStrategy === undefined) {
|
|
48
|
+
delete process.env.QUEUE_STRATEGY
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
process.env.QUEUE_STRATEGY = origStrategy
|
|
52
|
+
}
|
|
53
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }) } catch {}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
function readQueuedJobs(): Array<{ id: string; payload: unknown }> {
|
|
57
|
+
const queueFile = path.join(path.resolve('.mercato/queue', 'events'), 'queue.json')
|
|
58
|
+
return JSON.parse(fs.readFileSync(queueFile, 'utf8'))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Mirrors the context `createPerJobWorkerHandler` builds: `resolve` bound to a
|
|
62
|
+
// per-job request container that carries the bootstrapped event bus.
|
|
63
|
+
function workerContext(bus: EventBus) {
|
|
64
|
+
return {
|
|
65
|
+
jobId: 'job-1',
|
|
66
|
+
attemptNumber: 1,
|
|
67
|
+
queueName: 'events',
|
|
68
|
+
resolve: <T = unknown>(name: string): T => {
|
|
69
|
+
if (name === 'eventBus') {
|
|
70
|
+
return bus as unknown as T
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`No mock for ${name}`)
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
it('delivers a wildcard persistent subscriber that inline delivery deliberately skipped', async () => {
|
|
78
|
+
const ran: Array<{ event?: string; payload: unknown; tenantId?: string | null }> = []
|
|
79
|
+
const bus = createEventBus({ resolve: ((name: string) => name) as never })
|
|
80
|
+
bus.registerModuleSubscribers([
|
|
81
|
+
{
|
|
82
|
+
id: 'webhooks:outbound-dispatch',
|
|
83
|
+
event: '*',
|
|
84
|
+
persistent: true,
|
|
85
|
+
handler: (payload, ctx) => {
|
|
86
|
+
ran.push({ event: ctx.eventName, payload, tenantId: ctx.tenantId })
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
])
|
|
90
|
+
|
|
91
|
+
await bus.emit('customers.deal.won', { id: 'deal-9' }, { persistent: true, tenantId: 't1' })
|
|
92
|
+
|
|
93
|
+
// Single-delivery intends the inline skip; the worker is the sole dispatcher.
|
|
94
|
+
expect(ran).toEqual([])
|
|
95
|
+
|
|
96
|
+
const jobs = readQueuedJobs()
|
|
97
|
+
expect(jobs).toHaveLength(1)
|
|
98
|
+
|
|
99
|
+
await handle(jobs[0] as never, workerContext(bus) as never)
|
|
100
|
+
|
|
101
|
+
expect(ran).toEqual([
|
|
102
|
+
{ event: 'customers.deal.won', payload: { id: 'deal-9' }, tenantId: 't1' },
|
|
103
|
+
])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('fails the job loudly when the worker container carries no event bus', async () => {
|
|
107
|
+
const bus = createEventBus({ resolve: ((name: string) => name) as never })
|
|
108
|
+
bus.registerModuleSubscribers([
|
|
109
|
+
{ id: 'sub', event: '*', persistent: true, handler: () => {} },
|
|
110
|
+
])
|
|
111
|
+
|
|
112
|
+
await bus.emit('customers.deal.won', { id: 'deal-9' }, { persistent: true })
|
|
113
|
+
const [job] = readQueuedJobs()
|
|
114
|
+
|
|
115
|
+
const contextWithoutBus = {
|
|
116
|
+
jobId: 'job-1',
|
|
117
|
+
attemptNumber: 1,
|
|
118
|
+
queueName: 'events',
|
|
119
|
+
resolve: <T = unknown>(name: string): T => { throw new Error(`No mock for ${name}`) },
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await expect(handle(job as never, contextWithoutBus as never)).rejects.toThrow(
|
|
123
|
+
/no "eventBus" in the job container/,
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('does not re-run subscribers the emitting process already delivered inline', async () => {
|
|
128
|
+
// Explicit opt-out: the producer delivers inline and stamps the job, so a
|
|
129
|
+
// worker draining the queue must skip it.
|
|
130
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = 'false'
|
|
131
|
+
const ran: string[] = []
|
|
132
|
+
const bus = createEventBus({ resolve: ((name: string) => name) as never })
|
|
133
|
+
bus.registerModuleSubscribers([
|
|
134
|
+
{ id: 'sub', event: 'customers.deal.won', persistent: true, handler: () => { ran.push('sub') } },
|
|
135
|
+
])
|
|
136
|
+
|
|
137
|
+
await bus.emit('customers.deal.won', { id: 'deal-9' }, { persistent: true })
|
|
138
|
+
expect(ran).toEqual(['sub'])
|
|
139
|
+
|
|
140
|
+
const [job] = readQueuedJobs()
|
|
141
|
+
await handle(job as never, workerContext(bus) as never)
|
|
142
|
+
|
|
143
|
+
expect(ran).toEqual(['sub'])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('fails the job when a subscriber rejects with an undefined reason', async () => {
|
|
147
|
+
const bus = createEventBus({ resolve: ((name: string) => name) as never })
|
|
148
|
+
bus.registerModuleSubscribers([
|
|
149
|
+
{ id: 'silent', event: '*', persistent: true, handler: () => { throw undefined } },
|
|
150
|
+
])
|
|
151
|
+
|
|
152
|
+
await bus.emit('customers.deal.won', { id: 'deal-9' }, { persistent: true })
|
|
153
|
+
const [job] = readQueuedJobs()
|
|
154
|
+
|
|
155
|
+
await expect(handle(job as never, workerContext(bus) as never)).rejects.toThrow(
|
|
156
|
+
'[internal] 1/1 subscriber(s) failed for event "customers.deal.won": silent',
|
|
157
|
+
)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('surfaces a failing subscriber so the queue retries the job', async () => {
|
|
161
|
+
const bus = createEventBus({ resolve: ((name: string) => name) as never })
|
|
162
|
+
bus.registerModuleSubscribers([
|
|
163
|
+
{
|
|
164
|
+
id: 'flaky',
|
|
165
|
+
event: '*',
|
|
166
|
+
persistent: true,
|
|
167
|
+
handler: () => { throw new Error('downstream unavailable') },
|
|
168
|
+
},
|
|
169
|
+
])
|
|
170
|
+
|
|
171
|
+
await bus.emit('customers.deal.won', { id: 'deal-9' }, { persistent: true })
|
|
172
|
+
const [job] = readQueuedJobs()
|
|
173
|
+
|
|
174
|
+
await expect(handle(job as never, workerContext(bus) as never)).rejects.toThrow(
|
|
175
|
+
'[internal] 1/1 subscriber(s) failed for event "customers.deal.won": flaky',
|
|
176
|
+
)
|
|
177
|
+
})
|
|
178
|
+
})
|
|
@@ -13,6 +13,7 @@ import type { SubscriberDescriptor } from '@open-mercato/events/types'
|
|
|
13
13
|
describe('Event bus single-delivery (OM_EVENTS_SINGLE_DELIVERY)', () => {
|
|
14
14
|
const origCwd = process.cwd()
|
|
15
15
|
const origFlag = process.env.OM_EVENTS_SINGLE_DELIVERY
|
|
16
|
+
const origAutoSpawn = process.env.AUTO_SPAWN_WORKERS
|
|
16
17
|
let tmp: string
|
|
17
18
|
|
|
18
19
|
beforeEach(() => {
|
|
@@ -20,12 +21,21 @@ describe('Event bus single-delivery (OM_EVENTS_SINGLE_DELIVERY)', () => {
|
|
|
20
21
|
process.chdir(tmp)
|
|
21
22
|
delete process.env.QUEUE_STRATEGY
|
|
22
23
|
delete process.env.EVENTS_STRATEGY
|
|
24
|
+
delete process.env.AUTO_SPAWN_WORKERS
|
|
23
25
|
})
|
|
24
26
|
|
|
27
|
+
function restoreEnv(name: string, original: string | undefined): void {
|
|
28
|
+
if (original === undefined) {
|
|
29
|
+
delete process.env[name]
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
process.env[name] = original
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
afterEach(() => {
|
|
26
36
|
process.chdir(origCwd)
|
|
27
|
-
|
|
28
|
-
|
|
37
|
+
restoreEnv('OM_EVENTS_SINGLE_DELIVERY', origFlag)
|
|
38
|
+
restoreEnv('AUTO_SPAWN_WORKERS', origAutoSpawn)
|
|
29
39
|
try { fs.rmSync(tmp, { recursive: true, force: true }) } catch {}
|
|
30
40
|
})
|
|
31
41
|
|
|
@@ -38,7 +48,12 @@ describe('Event bus single-delivery (OM_EVENTS_SINGLE_DELIVERY)', () => {
|
|
|
38
48
|
return { id, event, persistent, handler: () => { sink.push(id) } }
|
|
39
49
|
}
|
|
40
50
|
|
|
41
|
-
|
|
51
|
+
function readQueuedJobs(): Array<Record<string, unknown>> {
|
|
52
|
+
const queuePath = path.join(path.resolve('.mercato/queue', 'events'), 'queue.json')
|
|
53
|
+
return JSON.parse(fs.readFileSync(queuePath, 'utf8')) as Array<Record<string, unknown>>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test('flag unset (default on): a persistent subscriber is skipped inline', async () => {
|
|
42
57
|
delete process.env.OM_EVENTS_SINGLE_DELIVERY
|
|
43
58
|
const calls: string[] = []
|
|
44
59
|
const bus = createEventBus({ resolve: ((name: string) => name) as never })
|
|
@@ -52,6 +67,7 @@ describe('Event bus single-delivery (OM_EVENTS_SINGLE_DELIVERY)', () => {
|
|
|
52
67
|
// Default-on: the persistent subscriber is deferred to the worker; only the
|
|
53
68
|
// ephemeral subscriber runs inline.
|
|
54
69
|
expect(calls).toEqual(['ephemeral-sub'])
|
|
70
|
+
expect(readQueuedJobs()[0].payload).toMatchObject({ persistentDeliveredInline: false })
|
|
55
71
|
})
|
|
56
72
|
|
|
57
73
|
test('flag explicitly OFF (legacy opt-out): a persistent subscriber still runs inline', async () => {
|
|
@@ -62,8 +78,44 @@ describe('Event bus single-delivery (OM_EVENTS_SINGLE_DELIVERY)', () => {
|
|
|
62
78
|
|
|
63
79
|
await bus.emit('demo', { a: 1 }, { persistent: true })
|
|
64
80
|
|
|
65
|
-
//
|
|
81
|
+
// Explicit opt-out: subscribers run inline. Not dual-dispatch - the queued job
|
|
82
|
+
// carries the stamp, so a worker draining the queue skips it.
|
|
66
83
|
expect(calls).toEqual(['persistent-sub'])
|
|
84
|
+
expect(readQueuedJobs()[0].payload).toMatchObject({ persistentDeliveredInline: true })
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('flag OFF: a failed inline persistent subscriber leaves the job unstamped for the worker', async () => {
|
|
88
|
+
// Inline delivery logs-and-continues, so a persistent subscriber that throws
|
|
89
|
+
// there gets no retry of its own. Stamping the job would strand it: the
|
|
90
|
+
// worker would skip a job whose only run failed, silently downgrading
|
|
91
|
+
// persistent delivery to at-most-once. Leaving it unstamped hands the retry
|
|
92
|
+
// back to the queue.
|
|
93
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = 'false'
|
|
94
|
+
const bus = createEventBus({ resolve: ((name: string) => name) as never })
|
|
95
|
+
bus.registerModuleSubscribers([
|
|
96
|
+
{ id: 'boom', event: 'demo', persistent: true, handler: () => { throw new Error('downstream down') } },
|
|
97
|
+
])
|
|
98
|
+
|
|
99
|
+
await bus.emit('demo', { a: 1 }, { persistent: true })
|
|
100
|
+
|
|
101
|
+
expect(readQueuedJobs()[0].payload).toMatchObject({ persistentDeliveredInline: false })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('flag OFF: a failed EPHEMERAL subscriber does not unstamp the job', async () => {
|
|
105
|
+
// Ephemeral subscribers are never worker-dispatched, so their failure must
|
|
106
|
+
// not hand the job to the worker - that would re-run the persistent ones.
|
|
107
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = 'false'
|
|
108
|
+
const calls: string[] = []
|
|
109
|
+
const bus = createEventBus({ resolve: ((name: string) => name) as never })
|
|
110
|
+
bus.registerModuleSubscribers([
|
|
111
|
+
{ id: 'ephemeral-boom', event: 'demo', persistent: false, handler: () => { throw new Error('nope') } },
|
|
112
|
+
makeSub('persistent-ok', 'demo', true, calls),
|
|
113
|
+
])
|
|
114
|
+
|
|
115
|
+
await bus.emit('demo', { a: 1 }, { persistent: true })
|
|
116
|
+
|
|
117
|
+
expect(calls).toEqual(['persistent-ok'])
|
|
118
|
+
expect(readQueuedJobs()[0].payload).toMatchObject({ persistentDeliveredInline: true })
|
|
67
119
|
})
|
|
68
120
|
|
|
69
121
|
test('flag ON: a persistent subscriber is skipped inline (deferred to the worker)', async () => {
|
|
@@ -96,7 +148,6 @@ describe('Event bus single-delivery (OM_EVENTS_SINGLE_DELIVERY)', () => {
|
|
|
96
148
|
|
|
97
149
|
test('flag ON: persistent emit is still enqueued for the worker', async () => {
|
|
98
150
|
process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
|
|
99
|
-
const queuePath = path.join(path.resolve('.mercato/queue', 'events'), 'queue.json')
|
|
100
151
|
const calls: string[] = []
|
|
101
152
|
const bus = createEventBus({ resolve: ((name: string) => name) as never })
|
|
102
153
|
bus.registerModuleSubscribers([makeSub('persistent-sub', 'demo', true, calls)])
|
|
@@ -104,9 +155,10 @@ describe('Event bus single-delivery (OM_EVENTS_SINGLE_DELIVERY)', () => {
|
|
|
104
155
|
await bus.emit('demo', { a: 1 }, { persistent: true })
|
|
105
156
|
|
|
106
157
|
expect(calls).toEqual([])
|
|
107
|
-
const list =
|
|
158
|
+
const list = readQueuedJobs()
|
|
108
159
|
expect(Array.isArray(list)).toBe(true)
|
|
109
160
|
expect(list.length).toBeGreaterThanOrEqual(1)
|
|
161
|
+
expect(list[0].payload).toMatchObject({ persistentDeliveredInline: false })
|
|
110
162
|
})
|
|
111
163
|
|
|
112
164
|
test('flag ON: the queued job keeps the trusted scope from the emit options', async () => {
|
|
@@ -178,6 +230,9 @@ describe('Event bus enqueue-only persistent emit (deliverInline: false)', () =>
|
|
|
178
230
|
const list = JSON.parse(fs.readFileSync(queuePath, 'utf8'))
|
|
179
231
|
expect(Array.isArray(list)).toBe(true)
|
|
180
232
|
expect(list.length).toBeGreaterThanOrEqual(1)
|
|
233
|
+
// Enqueue-only never delivers inline, so the worker must still dispatch it
|
|
234
|
+
// even though this process has no worker-availability signal.
|
|
235
|
+
expect(list[0].payload).toMatchObject({ persistentDeliveredInline: false })
|
|
181
236
|
})
|
|
182
237
|
|
|
183
238
|
test('deliverInline: false has no effect on a non-persistent emit', async () => {
|
|
@@ -191,10 +246,10 @@ describe('Event bus enqueue-only persistent emit (deliverInline: false)', () =>
|
|
|
191
246
|
expect(calls).toEqual(['sub'])
|
|
192
247
|
})
|
|
193
248
|
|
|
194
|
-
test('deliverInline unset under
|
|
249
|
+
test('deliverInline unset under explicit opt-out preserves inline delivery', async () => {
|
|
195
250
|
// Isolate the deliverInline default from the single-delivery default by
|
|
196
251
|
// explicitly opting out: a persistent emit with deliverInline unset still
|
|
197
|
-
// runs inline
|
|
252
|
+
// runs inline.
|
|
198
253
|
process.env.OM_EVENTS_SINGLE_DELIVERY = 'false'
|
|
199
254
|
const calls: string[] = []
|
|
200
255
|
const bus = createEventBus({ resolve: ((name: string) => name) as never })
|