@open-mercato/webhooks 0.8.1-develop.7228.1.b7ea09d2d3 → 0.8.1-develop.7237.1.635faf2c7a

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.
Files changed (32) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -0
  3. package/dist/modules/webhooks/api/webhooks/[id]/route.js +3 -0
  4. package/dist/modules/webhooks/api/webhooks/[id]/route.js.map +2 -2
  5. package/dist/modules/webhooks/api/webhooks/[id]/test/route.js +1 -1
  6. package/dist/modules/webhooks/api/webhooks/[id]/test/route.js.map +2 -2
  7. package/dist/modules/webhooks/api/webhooks/route.js +13 -0
  8. package/dist/modules/webhooks/api/webhooks/route.js.map +2 -2
  9. package/dist/modules/webhooks/lib/delivery.js +8 -1
  10. package/dist/modules/webhooks/lib/delivery.js.map +2 -2
  11. package/dist/modules/webhooks/lib/queue.js +1 -1
  12. package/dist/modules/webhooks/lib/queue.js.map +2 -2
  13. package/dist/modules/webhooks/lib/subscription-cache.js +93 -0
  14. package/dist/modules/webhooks/lib/subscription-cache.js.map +7 -0
  15. package/dist/modules/webhooks/subscribers/outbound-dispatch.js +34 -2
  16. package/dist/modules/webhooks/subscribers/outbound-dispatch.js.map +2 -2
  17. package/dist/modules/webhooks/workers/webhook-delivery.js +1 -1
  18. package/dist/modules/webhooks/workers/webhook-delivery.js.map +2 -2
  19. package/package.json +7 -6
  20. package/src/modules/webhooks/api/webhooks/[id]/__tests__/subscription-cache-invalidation.test.ts +107 -0
  21. package/src/modules/webhooks/api/webhooks/[id]/route.ts +5 -0
  22. package/src/modules/webhooks/api/webhooks/[id]/test/route.ts +1 -1
  23. package/src/modules/webhooks/api/webhooks/__tests__/subscription-cache-invalidation.test.ts +93 -0
  24. package/src/modules/webhooks/api/webhooks/route.ts +14 -0
  25. package/src/modules/webhooks/lib/__tests__/subscription-cache-invalidation.test.ts +207 -0
  26. package/src/modules/webhooks/lib/delivery.ts +12 -2
  27. package/src/modules/webhooks/lib/queue.ts +1 -1
  28. package/src/modules/webhooks/lib/subscription-cache.ts +135 -0
  29. package/src/modules/webhooks/subscribers/__tests__/outbound-dispatch.subscription-cache.test.ts +289 -0
  30. package/src/modules/webhooks/subscribers/outbound-dispatch.ts +49 -6
  31. package/src/modules/webhooks/workers/__tests__/webhook-delivery.test.ts +5 -1
  32. package/src/modules/webhooks/workers/webhook-delivery.ts +1 -1
@@ -0,0 +1,289 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { createCacheService, type CacheStrategy } from '@open-mercato/cache'
3
+ import handler from '../outbound-dispatch'
4
+
5
+ jest.mock('@open-mercato/shared/lib/logger', () => {
6
+ const mocked = {
7
+ debug: jest.fn(),
8
+ info: jest.fn(),
9
+ warn: jest.fn(),
10
+ error: jest.fn(),
11
+ child: jest.fn(),
12
+ }
13
+ mocked.child.mockImplementation(() => mocked)
14
+ return { createLogger: jest.fn(() => mocked) }
15
+ })
16
+
17
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
18
+ findWithDecryption: jest.fn(),
19
+ findOneWithDecryption: jest.fn(),
20
+ }))
21
+ jest.mock('@open-mercato/shared/modules/events', () => ({
22
+ getDeclaredEvents: jest.fn(() => []),
23
+ }))
24
+
25
+ jest.mock('../../lib/delivery', () => ({
26
+ createWebhookDelivery: jest.fn(),
27
+ }))
28
+ jest.mock('../../lib/queue', () => ({
29
+ enqueueWebhookDelivery: jest.fn(),
30
+ }))
31
+ jest.mock('../../lib/integration-state', () => ({
32
+ isWebhookIntegrationEnabled: jest.fn(),
33
+ }))
34
+
35
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
36
+ import { createWebhookDelivery } from '../../lib/delivery'
37
+ import { enqueueWebhookDelivery } from '../../lib/queue'
38
+ import { isWebhookIntegrationEnabled } from '../../lib/integration-state'
39
+
40
+ function createForkableEntityManager(): EntityManager {
41
+ const em = {
42
+ fork: jest.fn(function fork() {
43
+ return em
44
+ }),
45
+ flush: jest.fn(async () => undefined),
46
+ } as unknown as EntityManager
47
+ return em
48
+ }
49
+
50
+ function createContext(em: EntityManager, cache?: CacheStrategy | null, eventName = 'catalog.product.deleted') {
51
+ const resolve = jest.fn(<T,>(name: string): T => {
52
+ if (name === 'em') return em as T
53
+ if (name === 'cache') {
54
+ if (cache === null) throw new Error('cache unavailable')
55
+ return cache as T
56
+ }
57
+ throw new Error(`Unexpected dependency: ${name}`)
58
+ })
59
+ return { eventName, resolve }
60
+ }
61
+
62
+ describe('webhooks outbound dispatch subscriber - subscription cache', () => {
63
+ beforeEach(() => {
64
+ jest.clearAllMocks()
65
+ delete process.env.OM_WEBHOOKS_SUBSCRIPTION_CACHE_TTL_MS
66
+ })
67
+
68
+ it('queries the database once for zero active webhooks and reuses the cached empty list', async () => {
69
+ const em = createForkableEntityManager()
70
+ const cache = createCacheService({ strategy: 'memory' })
71
+ ;(findWithDecryption as jest.Mock).mockResolvedValue([])
72
+
73
+ const payload = { id: 'p1', tenantId: 'tenant-1', organizationId: 'org-1' }
74
+ await handler(payload, createContext(em, cache))
75
+ await handler(payload, createContext(em, cache))
76
+
77
+ expect(findWithDecryption).toHaveBeenCalledTimes(1)
78
+ })
79
+
80
+ it('does not resolve or fork an entity manager on the cached zero-webhook path', async () => {
81
+ const em = createForkableEntityManager()
82
+ const cache = createCacheService({ strategy: 'memory' })
83
+ ;(findWithDecryption as jest.Mock).mockResolvedValue([])
84
+
85
+ const payload = { id: 'p1', tenantId: 'tenant-1', organizationId: 'org-1' }
86
+ await handler(payload, createContext(em, cache))
87
+ expect(em.fork).toHaveBeenCalledTimes(1)
88
+
89
+ const secondContext = createContext(em, cache)
90
+ await handler(payload, secondContext)
91
+
92
+ expect(em.fork).toHaveBeenCalledTimes(1)
93
+ expect(secondContext.resolve).not.toHaveBeenCalledWith('em')
94
+ })
95
+
96
+ it('does not resolve or fork an entity manager when the cached list has no matching pattern', async () => {
97
+ const em = createForkableEntityManager()
98
+ const cache = createCacheService({ strategy: 'memory' })
99
+
100
+ ;(findWithDecryption as jest.Mock).mockResolvedValueOnce([
101
+ { id: 'webhook-1', tenantId: 'tenant-1', organizationId: 'org-1', subscribedEvents: ['catalog.product.created'] },
102
+ ])
103
+
104
+ await handler(
105
+ { id: 'p1', tenantId: 'tenant-1', organizationId: 'org-1' },
106
+ createContext(em, cache, 'catalog.product.created'),
107
+ )
108
+ expect(em.fork).toHaveBeenCalledTimes(1)
109
+
110
+ const secondContext = createContext(em, cache, 'catalog.product.deleted')
111
+ await handler({ id: 'p2', tenantId: 'tenant-1', organizationId: 'org-1' }, secondContext)
112
+
113
+ expect(em.fork).toHaveBeenCalledTimes(1)
114
+ expect(secondContext.resolve).not.toHaveBeenCalledWith('em')
115
+ })
116
+
117
+ it('does not hit the database for a cached non-matching subscribedEvents pattern', async () => {
118
+ const em = createForkableEntityManager()
119
+ const cache = createCacheService({ strategy: 'memory' })
120
+
121
+ ;(findWithDecryption as jest.Mock).mockResolvedValueOnce([
122
+ { id: 'webhook-1', tenantId: 'tenant-1', organizationId: 'org-1', subscribedEvents: ['catalog.product.created'] },
123
+ ])
124
+
125
+ await handler(
126
+ { id: 'p1', tenantId: 'tenant-1', organizationId: 'org-1' },
127
+ createContext(em, cache, 'catalog.product.created'),
128
+ )
129
+ expect(findWithDecryption).toHaveBeenCalledTimes(1)
130
+
131
+ await handler(
132
+ { id: 'p2', tenantId: 'tenant-1', organizationId: 'org-1' },
133
+ createContext(em, cache, 'catalog.product.deleted'),
134
+ )
135
+
136
+ expect(findWithDecryption).toHaveBeenCalledTimes(1)
137
+ expect(createWebhookDelivery).not.toHaveBeenCalled()
138
+ })
139
+
140
+ it('loads matching webhooks by id and creates a delivery on a cache hit', async () => {
141
+ const em = createForkableEntityManager()
142
+ const cache = createCacheService({ strategy: 'memory' })
143
+
144
+ const fullWebhook = {
145
+ id: 'webhook-1',
146
+ tenantId: 'tenant-1',
147
+ organizationId: 'org-1',
148
+ subscribedEvents: ['catalog.product.deleted'],
149
+ }
150
+
151
+ ;(findWithDecryption as jest.Mock)
152
+ .mockResolvedValueOnce([fullWebhook])
153
+ .mockResolvedValueOnce([fullWebhook])
154
+ ;(isWebhookIntegrationEnabled as jest.Mock).mockResolvedValue(true)
155
+ ;(createWebhookDelivery as jest.Mock).mockResolvedValue({ id: 'delivery-1', tenantId: 'tenant-1', organizationId: 'org-1' })
156
+ ;(enqueueWebhookDelivery as jest.Mock).mockResolvedValue('job-1')
157
+
158
+ await handler(
159
+ { id: 'p1', tenantId: 'tenant-1', organizationId: 'org-1' },
160
+ createContext(em, cache, 'catalog.product.deleted'),
161
+ )
162
+ await handler(
163
+ { id: 'p2', tenantId: 'tenant-1', organizationId: 'org-1' },
164
+ createContext(em, cache, 'catalog.product.deleted'),
165
+ )
166
+
167
+ expect(findWithDecryption).toHaveBeenCalledTimes(2)
168
+ expect(findWithDecryption).toHaveBeenNthCalledWith(
169
+ 2,
170
+ em,
171
+ expect.anything(),
172
+ expect.objectContaining({ id: { $in: ['webhook-1'] } }),
173
+ {},
174
+ expect.objectContaining({ tenantId: 'tenant-1' }),
175
+ )
176
+ expect(createWebhookDelivery).toHaveBeenCalledTimes(2)
177
+ expect(enqueueWebhookDelivery).toHaveBeenCalledTimes(2)
178
+ })
179
+
180
+ it('re-queries the database after the invalidation event runs', async () => {
181
+ const em = createForkableEntityManager()
182
+ const cache = createCacheService({ strategy: 'memory' })
183
+
184
+ ;(findWithDecryption as jest.Mock).mockResolvedValue([])
185
+
186
+ const payload = { id: 'p1', tenantId: 'tenant-1', organizationId: 'org-1' }
187
+ await handler(payload, createContext(em, cache))
188
+ expect(findWithDecryption).toHaveBeenCalledTimes(1)
189
+
190
+ await handler(payload, createContext(em, cache))
191
+ expect(findWithDecryption).toHaveBeenCalledTimes(1)
192
+
193
+ await handler(
194
+ { id: 'webhook-1', tenantId: 'tenant-1', organizationId: 'org-1' },
195
+ createContext(em, cache, 'webhooks.webhook.created'),
196
+ )
197
+
198
+ await handler(payload, createContext(em, cache))
199
+ expect(findWithDecryption).toHaveBeenCalledTimes(2)
200
+ })
201
+
202
+ it('never serves one tenant cached list to another tenant', async () => {
203
+ const em = createForkableEntityManager()
204
+ const cache = createCacheService({ strategy: 'memory' })
205
+
206
+ ;(findWithDecryption as jest.Mock)
207
+ .mockResolvedValueOnce([
208
+ { id: 'webhook-1', tenantId: 'tenant-1', organizationId: 'org-1', subscribedEvents: ['catalog.product.deleted'] },
209
+ ])
210
+ .mockResolvedValueOnce([])
211
+
212
+ await handler(
213
+ { id: 'p1', tenantId: 'tenant-1', organizationId: 'org-1' },
214
+ createContext(em, cache, 'catalog.product.deleted'),
215
+ )
216
+ await handler(
217
+ { id: 'p2', tenantId: 'tenant-2', organizationId: 'org-1' },
218
+ createContext(em, cache, 'catalog.product.deleted'),
219
+ )
220
+
221
+ expect(findWithDecryption).toHaveBeenCalledTimes(2)
222
+ expect((findWithDecryption as jest.Mock).mock.calls[1][2]).toEqual(
223
+ expect.objectContaining({ tenantId: 'tenant-2' }),
224
+ )
225
+ })
226
+
227
+ it('never reads or writes the cache when OM_WEBHOOKS_SUBSCRIPTION_CACHE_TTL_MS=0', async () => {
228
+ process.env.OM_WEBHOOKS_SUBSCRIPTION_CACHE_TTL_MS = '0'
229
+
230
+ const em = createForkableEntityManager()
231
+ const cache = createCacheService({ strategy: 'memory' })
232
+ const getSpy = jest.spyOn(cache, 'get')
233
+ const setSpy = jest.spyOn(cache, 'set')
234
+
235
+ const fullWebhook = {
236
+ id: 'webhook-1',
237
+ tenantId: 'tenant-1',
238
+ organizationId: 'org-1',
239
+ subscribedEvents: ['catalog.product.deleted'],
240
+ }
241
+
242
+ ;(findWithDecryption as jest.Mock).mockResolvedValue([fullWebhook])
243
+ ;(isWebhookIntegrationEnabled as jest.Mock).mockResolvedValue(true)
244
+ ;(createWebhookDelivery as jest.Mock).mockResolvedValue({ id: 'delivery-1', tenantId: 'tenant-1', organizationId: 'org-1' })
245
+ ;(enqueueWebhookDelivery as jest.Mock).mockResolvedValue('job-1')
246
+
247
+ await handler(
248
+ { id: 'p1', tenantId: 'tenant-1', organizationId: 'org-1' },
249
+ createContext(em, cache, 'catalog.product.deleted'),
250
+ )
251
+ await handler(
252
+ { id: 'p2', tenantId: 'tenant-1', organizationId: 'org-1' },
253
+ createContext(em, cache, 'catalog.product.deleted'),
254
+ )
255
+
256
+ expect(getSpy).not.toHaveBeenCalled()
257
+ expect(setSpy).not.toHaveBeenCalled()
258
+ expect(findWithDecryption).toHaveBeenCalledTimes(2)
259
+ })
260
+
261
+ it('falls back to the uncached query and still dispatches when the cache is unavailable', async () => {
262
+ const em = createForkableEntityManager()
263
+
264
+ const fullWebhook = {
265
+ id: 'webhook-1',
266
+ tenantId: 'tenant-1',
267
+ organizationId: 'org-1',
268
+ subscribedEvents: ['catalog.product.deleted'],
269
+ }
270
+
271
+ ;(findWithDecryption as jest.Mock).mockResolvedValue([fullWebhook])
272
+ ;(isWebhookIntegrationEnabled as jest.Mock).mockResolvedValue(true)
273
+ ;(createWebhookDelivery as jest.Mock).mockResolvedValue({ id: 'delivery-1', tenantId: 'tenant-1', organizationId: 'org-1' })
274
+ ;(enqueueWebhookDelivery as jest.Mock).mockResolvedValue('job-1')
275
+
276
+ await handler(
277
+ { id: 'p1', tenantId: 'tenant-1', organizationId: 'org-1' },
278
+ createContext(em, null, 'catalog.product.deleted'),
279
+ )
280
+ await handler(
281
+ { id: 'p2', tenantId: 'tenant-1', organizationId: 'org-1' },
282
+ createContext(em, null, 'catalog.product.deleted'),
283
+ )
284
+
285
+ expect(findWithDecryption).toHaveBeenCalledTimes(2)
286
+ expect(createWebhookDelivery).toHaveBeenCalledTimes(2)
287
+ expect(enqueueWebhookDelivery).toHaveBeenCalledTimes(2)
288
+ })
289
+ })
@@ -8,6 +8,14 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
8
8
  import { createWebhookDelivery } from '../lib/delivery'
9
9
  import { enqueueWebhookDelivery } from '../lib/queue'
10
10
  import { isWebhookIntegrationEnabled } from '../lib/integration-state'
11
+ import {
12
+ getCachedActiveWebhooks,
13
+ getWebhookSubscriptionCacheTtlMs,
14
+ invalidateWebhookSubscriptionCacheFor,
15
+ resolveWebhookSubscriptionCache,
16
+ setCachedActiveWebhooks,
17
+ type CachedActiveWebhook,
18
+ } from '../lib/subscription-cache'
11
19
 
12
20
  const logger = createLogger('webhooks')
13
21
 
@@ -40,6 +48,21 @@ export default async function handler(
40
48
  ) {
41
49
  const eventId = ctx.eventId ?? ctx.eventName ?? (payload.eventId as string) ?? (payload.type as string)
42
50
  if (!eventId) return
51
+
52
+ const resolve = ('resolve' in ctx && typeof ctx.resolve === 'function')
53
+ ? ctx.resolve
54
+ : ('container' in ctx && ctx.container && typeof ctx.container.resolve === 'function')
55
+ ? ctx.container.resolve.bind(ctx.container)
56
+ : null
57
+
58
+ if (eventId.startsWith('webhooks.webhook.')) {
59
+ const changedTenantId = payload.tenantId as string | undefined
60
+ if (resolve && changedTenantId) {
61
+ await invalidateWebhookSubscriptionCacheFor(resolve, changedTenantId)
62
+ }
63
+ return
64
+ }
65
+
43
66
  if (shouldSkipOutboundDispatch(eventId)) return
44
67
 
45
68
  const tenantId = payload.tenantId as string | undefined
@@ -49,14 +72,23 @@ export default async function handler(
49
72
  if (eventId.startsWith('webhooks.')) return
50
73
  if (eventId.startsWith('query_index.')) return
51
74
 
75
+ if (!resolve) return
76
+
77
+ const cacheTtlMs = getWebhookSubscriptionCacheTtlMs()
78
+ const subscriptionCache = cacheTtlMs > 0 ? resolveWebhookSubscriptionCache(resolve) : null
79
+ const cachedActiveWebhooks = subscriptionCache
80
+ ? await getCachedActiveWebhooks(subscriptionCache, tenantId, organizationId ?? null)
81
+ : null
52
82
 
53
- const resolve = ('resolve' in ctx && typeof ctx.resolve === 'function')
54
- ? ctx.resolve
55
- : ('container' in ctx && ctx.container && typeof ctx.container.resolve === 'function')
56
- ? ctx.container.resolve.bind(ctx.container)
57
- : null
83
+ let matchingIds: string[] | null = null
58
84
 
59
- if (!resolve) return
85
+ if (cachedActiveWebhooks) {
86
+ matchingIds = cachedActiveWebhooks
87
+ .filter((entry) => matchAnyWebhookEventPattern(eventId, entry.subscribedEvents))
88
+ .map((entry) => entry.id)
89
+
90
+ if (!matchingIds.length) return
91
+ }
60
92
 
61
93
  const em = (resolve('em') as EntityManager).fork()
62
94
 
@@ -68,11 +100,22 @@ export default async function handler(
68
100
  deletedAt: null,
69
101
  tenantId,
70
102
  ...(organizationId ? { organizationId } : {}),
103
+ ...(matchingIds ? { id: { $in: matchingIds } } : {}),
71
104
  },
72
105
  {},
73
106
  { tenantId, organizationId: organizationId ?? null },
74
107
  )
75
108
 
109
+ if (!matchingIds && subscriptionCache) {
110
+ const cacheable: CachedActiveWebhook[] = webhooks.map((webhook) => ({
111
+ id: webhook.id,
112
+ tenantId: webhook.tenantId,
113
+ organizationId: webhook.organizationId ?? null,
114
+ subscribedEvents: webhook.subscribedEvents,
115
+ }))
116
+ await setCachedActiveWebhooks(subscriptionCache, tenantId, organizationId ?? null, cacheable, cacheTtlMs)
117
+ }
118
+
76
119
  if (!webhooks.length) return
77
120
 
78
121
  const matchingWebhooks = webhooks.filter((webhook) =>
@@ -29,7 +29,11 @@ describe('webhooks delivery worker', () => {
29
29
 
30
30
  await handler({ payload: jobData }, makeCtx(em))
31
31
 
32
- expect(mockProcessWebhookDeliveryJob).toHaveBeenCalledWith(em, jobData)
32
+ expect(mockProcessWebhookDeliveryJob).toHaveBeenCalledWith(
33
+ em,
34
+ jobData,
35
+ expect.objectContaining({ resolver: expect.any(Function) }),
36
+ )
33
37
  })
34
38
 
35
39
  it('re-throws on job failure so the queue can retry', async () => {
@@ -16,7 +16,7 @@ export default async function handler(
16
16
  ) {
17
17
  const em = (ctx.resolve('em') as EntityManager).fork()
18
18
  try {
19
- await processWebhookDeliveryJob(em, job.payload)
19
+ await processWebhookDeliveryJob(em, job.payload, { resolver: ctx.resolve })
20
20
  } catch (error) {
21
21
  logger.error('Job processing failed', {
22
22
  deliveryId: job.payload?.deliveryId,