@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -0
- package/dist/modules/webhooks/api/webhooks/[id]/route.js +3 -0
- package/dist/modules/webhooks/api/webhooks/[id]/route.js.map +2 -2
- package/dist/modules/webhooks/api/webhooks/[id]/test/route.js +1 -1
- package/dist/modules/webhooks/api/webhooks/[id]/test/route.js.map +2 -2
- package/dist/modules/webhooks/api/webhooks/route.js +13 -0
- package/dist/modules/webhooks/api/webhooks/route.js.map +2 -2
- package/dist/modules/webhooks/lib/delivery.js +8 -1
- package/dist/modules/webhooks/lib/delivery.js.map +2 -2
- package/dist/modules/webhooks/lib/queue.js +1 -1
- package/dist/modules/webhooks/lib/queue.js.map +2 -2
- package/dist/modules/webhooks/lib/subscription-cache.js +93 -0
- package/dist/modules/webhooks/lib/subscription-cache.js.map +7 -0
- package/dist/modules/webhooks/subscribers/outbound-dispatch.js +34 -2
- package/dist/modules/webhooks/subscribers/outbound-dispatch.js.map +2 -2
- package/dist/modules/webhooks/workers/webhook-delivery.js +1 -1
- package/dist/modules/webhooks/workers/webhook-delivery.js.map +2 -2
- package/package.json +7 -6
- package/src/modules/webhooks/api/webhooks/[id]/__tests__/subscription-cache-invalidation.test.ts +107 -0
- package/src/modules/webhooks/api/webhooks/[id]/route.ts +5 -0
- package/src/modules/webhooks/api/webhooks/[id]/test/route.ts +1 -1
- package/src/modules/webhooks/api/webhooks/__tests__/subscription-cache-invalidation.test.ts +93 -0
- package/src/modules/webhooks/api/webhooks/route.ts +14 -0
- package/src/modules/webhooks/lib/__tests__/subscription-cache-invalidation.test.ts +207 -0
- package/src/modules/webhooks/lib/delivery.ts +12 -2
- package/src/modules/webhooks/lib/queue.ts +1 -1
- package/src/modules/webhooks/lib/subscription-cache.ts +135 -0
- package/src/modules/webhooks/subscribers/__tests__/outbound-dispatch.subscription-cache.test.ts +289 -0
- package/src/modules/webhooks/subscribers/outbound-dispatch.ts +49 -6
- package/src/modules/webhooks/workers/__tests__/webhook-delivery.test.ts +5 -1
- package/src/modules/webhooks/workers/webhook-delivery.ts +1 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { createCacheService, type CacheStrategy } from '@open-mercato/cache'
|
|
3
|
+
import {
|
|
4
|
+
getCachedActiveWebhooks,
|
|
5
|
+
invalidateWebhookSubscriptionCacheFor,
|
|
6
|
+
setCachedActiveWebhooks,
|
|
7
|
+
} from '../subscription-cache'
|
|
8
|
+
import { processWebhookDeliveryJob } from '../delivery'
|
|
9
|
+
|
|
10
|
+
jest.mock('../../events', () => ({
|
|
11
|
+
emitWebhooksEvent: jest.fn(async () => undefined),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
|
|
15
|
+
findOneWithDecryption: jest.fn(),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
jest.mock('../integration-state', () => ({
|
|
19
|
+
isWebhookIntegrationEnabled: jest.fn(async () => true),
|
|
20
|
+
WEBHOOK_INTEGRATION_DISABLED_MESSAGE: 'disabled',
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
jest.mock('@open-mercato/shared/lib/webhooks', () => ({
|
|
24
|
+
buildWebhookHeaders: jest.fn(() => ({})),
|
|
25
|
+
generateMessageId: jest.fn(() => 'msg-1'),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
29
|
+
|
|
30
|
+
const findOneWithDecryptionMock = findOneWithDecryption as jest.MockedFunction<typeof findOneWithDecryption>
|
|
31
|
+
|
|
32
|
+
async function seed(cache: CacheStrategy): Promise<void> {
|
|
33
|
+
await setCachedActiveWebhooks(
|
|
34
|
+
cache,
|
|
35
|
+
'tenant-1',
|
|
36
|
+
'org-1',
|
|
37
|
+
[{ id: 'w-1', tenantId: 'tenant-1', organizationId: 'org-1', subscribedEvents: ['a.b.c'] }],
|
|
38
|
+
60_000,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('invalidateWebhookSubscriptionCacheFor', () => {
|
|
43
|
+
it('accepts a bare resolve function', async () => {
|
|
44
|
+
const cache = createCacheService({ strategy: 'memory' })
|
|
45
|
+
await seed(cache)
|
|
46
|
+
await invalidateWebhookSubscriptionCacheFor((token: string) => (token === 'cache' ? cache : null), 'tenant-1')
|
|
47
|
+
expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).toBeNull()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('accepts a container', async () => {
|
|
51
|
+
const cache = createCacheService({ strategy: 'memory' })
|
|
52
|
+
await seed(cache)
|
|
53
|
+
await invalidateWebhookSubscriptionCacheFor({ resolve: (token: string) => (token === 'cache' ? cache : null) }, 'tenant-1')
|
|
54
|
+
expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('is a no-op when no cache resolves, when the resolver throws, and when the tenant is missing', async () => {
|
|
58
|
+
const cache = createCacheService({ strategy: 'memory' })
|
|
59
|
+
await seed(cache)
|
|
60
|
+
|
|
61
|
+
await expect(invalidateWebhookSubscriptionCacheFor(undefined, 'tenant-1')).resolves.toBeUndefined()
|
|
62
|
+
await expect(invalidateWebhookSubscriptionCacheFor(() => null, 'tenant-1')).resolves.toBeUndefined()
|
|
63
|
+
await expect(invalidateWebhookSubscriptionCacheFor(() => { throw new Error('unavailable') }, 'tenant-1')).resolves.toBeUndefined()
|
|
64
|
+
await expect(invalidateWebhookSubscriptionCacheFor({ resolve: () => cache }, ' ')).resolves.toBeUndefined()
|
|
65
|
+
|
|
66
|
+
expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).not.toBeNull()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('swallows a cache backend failure instead of throwing', async () => {
|
|
70
|
+
const brokenCache = {
|
|
71
|
+
get: jest.fn(async () => null),
|
|
72
|
+
set: jest.fn(async () => undefined),
|
|
73
|
+
deleteByTags: jest.fn(async () => { throw new Error('redis down') }),
|
|
74
|
+
}
|
|
75
|
+
await expect(invalidateWebhookSubscriptionCacheFor(() => brokenCache, 'tenant-1')).resolves.toBeUndefined()
|
|
76
|
+
expect(brokenCache.deleteByTags).toHaveBeenCalled()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('processWebhookDeliveryJob auto-disable invalidates the subscription cache', () => {
|
|
81
|
+
const originalFetch = globalThis.fetch
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
jest.clearAllMocks()
|
|
85
|
+
process.env.OM_WEBHOOKS_ALLOW_PRIVATE_URLS = '1'
|
|
86
|
+
globalThis.fetch = jest.fn(async () => new Response('nope', { status: 500 })) as unknown as typeof fetch
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
globalThis.fetch = originalFetch
|
|
91
|
+
delete process.env.OM_WEBHOOKS_ALLOW_PRIVATE_URLS
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
function buildDelivery() {
|
|
95
|
+
return {
|
|
96
|
+
id: 'delivery-1',
|
|
97
|
+
tenantId: 'tenant-1',
|
|
98
|
+
organizationId: 'org-1',
|
|
99
|
+
webhookId: 'w-1',
|
|
100
|
+
status: 'pending',
|
|
101
|
+
payload: { type: 'a.b.c', timestamp: new Date().toISOString(), data: {} },
|
|
102
|
+
enqueuedAt: new Date(),
|
|
103
|
+
eventType: 'a.b.c',
|
|
104
|
+
maxAttempts: 1,
|
|
105
|
+
attemptNumber: 0,
|
|
106
|
+
messageId: 'msg-1',
|
|
107
|
+
responseBody: null,
|
|
108
|
+
responseHeaders: null,
|
|
109
|
+
responseStatus: null,
|
|
110
|
+
nextRetryAt: null,
|
|
111
|
+
errorMessage: null,
|
|
112
|
+
lastAttemptAt: null,
|
|
113
|
+
deliveredAt: null,
|
|
114
|
+
durationMs: null,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function buildWebhook(autoDisableThreshold: number) {
|
|
119
|
+
return {
|
|
120
|
+
id: 'w-1',
|
|
121
|
+
url: 'http://127.0.0.1:3001/hook',
|
|
122
|
+
isActive: true,
|
|
123
|
+
timeoutMs: 1000,
|
|
124
|
+
httpMethod: 'POST',
|
|
125
|
+
customHeaders: null,
|
|
126
|
+
consecutiveFailures: 0,
|
|
127
|
+
lastFailureAt: null,
|
|
128
|
+
lastSuccessAt: null,
|
|
129
|
+
autoDisableThreshold,
|
|
130
|
+
maxRetries: 1,
|
|
131
|
+
secret: 'secret',
|
|
132
|
+
previousSecret: null,
|
|
133
|
+
tenantId: 'tenant-1',
|
|
134
|
+
organizationId: 'org-1',
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function buildEm(delivery: Record<string, unknown>) {
|
|
139
|
+
return {
|
|
140
|
+
findOne: jest.fn(async () => delivery),
|
|
141
|
+
flush: jest.fn(async () => undefined),
|
|
142
|
+
} as unknown as EntityManager
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
it('drops the cached tenant list when the webhook is auto-disabled', async () => {
|
|
146
|
+
const cache = createCacheService({ strategy: 'memory' })
|
|
147
|
+
await seed(cache)
|
|
148
|
+
|
|
149
|
+
const webhook = buildWebhook(1)
|
|
150
|
+
findOneWithDecryptionMock.mockResolvedValueOnce(webhook as never)
|
|
151
|
+
|
|
152
|
+
await processWebhookDeliveryJob(buildEm(buildDelivery()), {
|
|
153
|
+
deliveryId: 'delivery-1',
|
|
154
|
+
tenantId: 'tenant-1',
|
|
155
|
+
organizationId: 'org-1',
|
|
156
|
+
}, { scheduleRetries: false, resolver: () => cache })
|
|
157
|
+
|
|
158
|
+
expect(webhook.isActive).toBe(false)
|
|
159
|
+
expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).toBeNull()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('commits isActive=false before emitting and invalidating, so no concurrent read can repopulate a stale entry', async () => {
|
|
163
|
+
const webhook = buildWebhook(1)
|
|
164
|
+
const delivery = buildDelivery()
|
|
165
|
+
const calls: string[] = []
|
|
166
|
+
|
|
167
|
+
const cache = {
|
|
168
|
+
get: jest.fn(async () => null),
|
|
169
|
+
set: jest.fn(async () => undefined),
|
|
170
|
+
deleteByTags: jest.fn(async () => { calls.push('deleteByTags') }),
|
|
171
|
+
}
|
|
172
|
+
const em = {
|
|
173
|
+
findOne: jest.fn(async () => delivery),
|
|
174
|
+
flush: jest.fn(async () => { calls.push(`flush:isActive=${webhook.isActive}`) }),
|
|
175
|
+
} as unknown as EntityManager
|
|
176
|
+
|
|
177
|
+
findOneWithDecryptionMock.mockResolvedValueOnce(webhook as never)
|
|
178
|
+
|
|
179
|
+
await processWebhookDeliveryJob(em, {
|
|
180
|
+
deliveryId: 'delivery-1',
|
|
181
|
+
tenantId: 'tenant-1',
|
|
182
|
+
organizationId: 'org-1',
|
|
183
|
+
}, { scheduleRetries: false, resolver: () => cache })
|
|
184
|
+
|
|
185
|
+
const invalidateIndex = calls.indexOf('deleteByTags')
|
|
186
|
+
expect(invalidateIndex).toBeGreaterThan(-1)
|
|
187
|
+
expect(calls.slice(0, invalidateIndex)).toContain('flush:isActive=false')
|
|
188
|
+
expect(calls.slice(invalidateIndex)).not.toContain('flush:isActive=true')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('keeps the cached tenant list when the failure does not trip auto-disable', async () => {
|
|
192
|
+
const cache = createCacheService({ strategy: 'memory' })
|
|
193
|
+
await seed(cache)
|
|
194
|
+
|
|
195
|
+
const webhook = buildWebhook(0)
|
|
196
|
+
findOneWithDecryptionMock.mockResolvedValueOnce(webhook as never)
|
|
197
|
+
|
|
198
|
+
await processWebhookDeliveryJob(buildEm(buildDelivery()), {
|
|
199
|
+
deliveryId: 'delivery-1',
|
|
200
|
+
tenantId: 'tenant-1',
|
|
201
|
+
organizationId: 'org-1',
|
|
202
|
+
}, { scheduleRetries: false, resolver: () => cache })
|
|
203
|
+
|
|
204
|
+
expect(webhook.isActive).toBe(true)
|
|
205
|
+
expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).not.toBeNull()
|
|
206
|
+
})
|
|
207
|
+
})
|
|
@@ -11,6 +11,10 @@ import {
|
|
|
11
11
|
safeWebhookFetch,
|
|
12
12
|
UnsafeWebhookUrlError,
|
|
13
13
|
} from './url-safety'
|
|
14
|
+
import {
|
|
15
|
+
invalidateWebhookSubscriptionCacheFor,
|
|
16
|
+
type WebhookCacheDependencyResolver,
|
|
17
|
+
} from './subscription-cache'
|
|
14
18
|
|
|
15
19
|
export interface WebhookDeliveryJob {
|
|
16
20
|
deliveryId: string
|
|
@@ -27,6 +31,7 @@ export interface CreateWebhookDeliveryInput {
|
|
|
27
31
|
|
|
28
32
|
type ProcessWebhookDeliveryOptions = {
|
|
29
33
|
scheduleRetries?: boolean
|
|
34
|
+
resolver?: WebhookCacheDependencyResolver
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
type WebhookBody = {
|
|
@@ -211,6 +216,7 @@ export async function processWebhookDeliveryJob(
|
|
|
211
216
|
delivery,
|
|
212
217
|
canRetry: shouldRetryStatus(response.status),
|
|
213
218
|
scheduleRetries: options.scheduleRetries !== false,
|
|
219
|
+
resolver: options.resolver,
|
|
214
220
|
fallbackMessage: `HTTP ${response.status}`,
|
|
215
221
|
})
|
|
216
222
|
|
|
@@ -254,6 +260,7 @@ export async function processWebhookDeliveryJob(
|
|
|
254
260
|
delivery,
|
|
255
261
|
canRetry: true,
|
|
256
262
|
scheduleRetries: options.scheduleRetries !== false,
|
|
263
|
+
resolver: options.resolver,
|
|
257
264
|
fallbackMessage: delivery.errorMessage,
|
|
258
265
|
})
|
|
259
266
|
|
|
@@ -318,6 +325,7 @@ type HandleFailedDeliveryInput = {
|
|
|
318
325
|
canRetry: boolean
|
|
319
326
|
scheduleRetries: boolean
|
|
320
327
|
fallbackMessage: string
|
|
328
|
+
resolver?: WebhookCacheDependencyResolver
|
|
321
329
|
}
|
|
322
330
|
|
|
323
331
|
async function handleFailedDelivery(input: HandleFailedDeliveryInput): Promise<void> {
|
|
@@ -364,17 +372,19 @@ async function handleFailedDelivery(input: HandleFailedDeliveryInput): Promise<v
|
|
|
364
372
|
})
|
|
365
373
|
}
|
|
366
374
|
|
|
375
|
+
await input.em.flush()
|
|
376
|
+
|
|
367
377
|
if (webhook.autoDisableThreshold > 0 && webhook.consecutiveFailures >= webhook.autoDisableThreshold) {
|
|
368
378
|
webhook.isActive = false
|
|
379
|
+
await input.em.flush()
|
|
369
380
|
await emitWebhooksEvent('webhooks.webhook.disabled', {
|
|
370
381
|
webhookId: webhook.id,
|
|
371
382
|
organizationId: webhook.organizationId,
|
|
372
383
|
tenantId: webhook.tenantId,
|
|
373
384
|
consecutiveFailures: webhook.consecutiveFailures,
|
|
374
385
|
})
|
|
386
|
+
await invalidateWebhookSubscriptionCacheFor(input.resolver, webhook.tenantId)
|
|
375
387
|
}
|
|
376
|
-
|
|
377
|
-
await input.em.flush()
|
|
378
388
|
}
|
|
379
389
|
|
|
380
390
|
function normalizeWebhookBody(eventType: string, payload: Record<string, unknown>): WebhookBody {
|
|
@@ -48,7 +48,7 @@ async function ensureLocalWebhookQueueWorkerStarted(): Promise<void> {
|
|
|
48
48
|
|
|
49
49
|
const container = await createRequestContainer()
|
|
50
50
|
const em = (container.resolve('em') as EntityManager).fork()
|
|
51
|
-
await processWebhookDeliveryJob(em, job.payload)
|
|
51
|
+
await processWebhookDeliveryJob(em, job.payload, { resolver: container })
|
|
52
52
|
})
|
|
53
53
|
})().catch((error) => {
|
|
54
54
|
delete globalStore[LOCAL_WORKER_PROMISE_KEY]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { CacheStrategy } from '@open-mercato/cache'
|
|
2
|
+
import { runWithCacheTenant } from '@open-mercato/cache'
|
|
3
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
4
|
+
|
|
5
|
+
const logger = createLogger('webhooks').child({ component: 'subscription-cache' })
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TTL_MS = 60_000
|
|
8
|
+
const GLOBAL_SCOPE_SEGMENT = 'global'
|
|
9
|
+
|
|
10
|
+
export type CachedActiveWebhook = {
|
|
11
|
+
id: string
|
|
12
|
+
tenantId: string
|
|
13
|
+
organizationId: string | null
|
|
14
|
+
subscribedEvents: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type WebhookSubscriptionCache = Pick<CacheStrategy, 'get' | 'set' | 'deleteByTags'>
|
|
18
|
+
|
|
19
|
+
export function isWebhookSubscriptionCache(value: unknown): value is WebhookSubscriptionCache {
|
|
20
|
+
if (!value || typeof value !== 'object') return false
|
|
21
|
+
const candidate = value as Partial<WebhookSubscriptionCache>
|
|
22
|
+
return (
|
|
23
|
+
typeof candidate.get === 'function'
|
|
24
|
+
&& typeof candidate.set === 'function'
|
|
25
|
+
&& typeof candidate.deleteByTags === 'function'
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type WebhookCacheDependencyResolver =
|
|
30
|
+
| ((token: string) => unknown)
|
|
31
|
+
| { resolve: (token: string) => unknown }
|
|
32
|
+
|
|
33
|
+
export function resolveWebhookSubscriptionCache(resolve: (token: string) => unknown): WebhookSubscriptionCache | null {
|
|
34
|
+
try {
|
|
35
|
+
const cache = resolve('cache')
|
|
36
|
+
return isWebhookSubscriptionCache(cache) ? cache : null
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toResolveFunction(source: WebhookCacheDependencyResolver | null | undefined): ((token: string) => unknown) | null {
|
|
43
|
+
if (typeof source === 'function') return source
|
|
44
|
+
if (source && typeof source.resolve === 'function') return source.resolve.bind(source)
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getWebhookSubscriptionCacheTtlMs(): number {
|
|
49
|
+
const raw = process.env.OM_WEBHOOKS_SUBSCRIPTION_CACHE_TTL_MS
|
|
50
|
+
if (raw === undefined || raw.trim() === '') return DEFAULT_TTL_MS
|
|
51
|
+
const parsed = Number.parseInt(raw, 10)
|
|
52
|
+
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_TTL_MS
|
|
53
|
+
return parsed
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeScopeSegment(organizationId: string | null | undefined): string {
|
|
57
|
+
return organizationId ?? GLOBAL_SCOPE_SEGMENT
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getWebhookSubscriptionCacheKey(tenantId: string, organizationId: string | null | undefined): string {
|
|
61
|
+
return `webhooks:subscriptions:${tenantId}:${normalizeScopeSegment(organizationId)}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getWebhookSubscriptionCacheTag(tenantId: string): string {
|
|
65
|
+
return `webhooks:subscriptions:tenant:${tenantId}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isCachedActiveWebhookList(value: unknown): value is CachedActiveWebhook[] {
|
|
69
|
+
return Array.isArray(value) && value.every((entry) => (
|
|
70
|
+
entry !== null
|
|
71
|
+
&& typeof entry === 'object'
|
|
72
|
+
&& typeof (entry as CachedActiveWebhook).id === 'string'
|
|
73
|
+
&& typeof (entry as CachedActiveWebhook).tenantId === 'string'
|
|
74
|
+
&& Array.isArray((entry as CachedActiveWebhook).subscribedEvents)
|
|
75
|
+
))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function getCachedActiveWebhooks(
|
|
79
|
+
cache: WebhookSubscriptionCache | null,
|
|
80
|
+
tenantId: string,
|
|
81
|
+
organizationId: string | null | undefined,
|
|
82
|
+
): Promise<CachedActiveWebhook[] | null> {
|
|
83
|
+
if (!cache) return null
|
|
84
|
+
try {
|
|
85
|
+
const value = await runWithCacheTenant(tenantId, () => cache.get(getWebhookSubscriptionCacheKey(tenantId, organizationId)))
|
|
86
|
+
return isCachedActiveWebhookList(value) ? value : null
|
|
87
|
+
} catch (error) {
|
|
88
|
+
logger.warn('Failed to read webhook subscription cache', { err: error })
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function setCachedActiveWebhooks(
|
|
94
|
+
cache: WebhookSubscriptionCache | null,
|
|
95
|
+
tenantId: string,
|
|
96
|
+
organizationId: string | null | undefined,
|
|
97
|
+
webhooks: CachedActiveWebhook[],
|
|
98
|
+
ttlMs: number,
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
if (!cache || ttlMs <= 0) return
|
|
101
|
+
try {
|
|
102
|
+
await runWithCacheTenant(tenantId, () => cache.set(
|
|
103
|
+
getWebhookSubscriptionCacheKey(tenantId, organizationId),
|
|
104
|
+
webhooks,
|
|
105
|
+
{ ttl: ttlMs, tags: [getWebhookSubscriptionCacheTag(tenantId)] },
|
|
106
|
+
))
|
|
107
|
+
} catch (error) {
|
|
108
|
+
logger.warn('Failed to write webhook subscription cache', { err: error })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function invalidateWebhookSubscriptionCache(
|
|
113
|
+
cache: WebhookSubscriptionCache | null,
|
|
114
|
+
tenantId: string | null | undefined,
|
|
115
|
+
): Promise<void> {
|
|
116
|
+
if (!cache) return
|
|
117
|
+
const normalizedTenantId = tenantId?.trim()
|
|
118
|
+
if (!normalizedTenantId) return
|
|
119
|
+
try {
|
|
120
|
+
await runWithCacheTenant(normalizedTenantId, () => cache.deleteByTags([getWebhookSubscriptionCacheTag(normalizedTenantId)]))
|
|
121
|
+
} catch (error) {
|
|
122
|
+
logger.warn('Failed to invalidate webhook subscription cache', { err: error })
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function invalidateWebhookSubscriptionCacheFor(
|
|
127
|
+
source: WebhookCacheDependencyResolver | null | undefined,
|
|
128
|
+
tenantId: string | null | undefined,
|
|
129
|
+
): Promise<void> {
|
|
130
|
+
const normalizedTenantId = tenantId?.trim()
|
|
131
|
+
if (!normalizedTenantId) return
|
|
132
|
+
const resolve = toResolveFunction(source)
|
|
133
|
+
if (!resolve) return
|
|
134
|
+
await invalidateWebhookSubscriptionCache(resolveWebhookSubscriptionCache(resolve), normalizedTenantId)
|
|
135
|
+
}
|