@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,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/webhooks/lib/subscription-cache.ts"],
4
+ "sourcesContent": ["import type { CacheStrategy } from '@open-mercato/cache'\nimport { runWithCacheTenant } from '@open-mercato/cache'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('webhooks').child({ component: 'subscription-cache' })\n\nconst DEFAULT_TTL_MS = 60_000\nconst GLOBAL_SCOPE_SEGMENT = 'global'\n\nexport type CachedActiveWebhook = {\n id: string\n tenantId: string\n organizationId: string | null\n subscribedEvents: string[]\n}\n\nexport type WebhookSubscriptionCache = Pick<CacheStrategy, 'get' | 'set' | 'deleteByTags'>\n\nexport function isWebhookSubscriptionCache(value: unknown): value is WebhookSubscriptionCache {\n if (!value || typeof value !== 'object') return false\n const candidate = value as Partial<WebhookSubscriptionCache>\n return (\n typeof candidate.get === 'function'\n && typeof candidate.set === 'function'\n && typeof candidate.deleteByTags === 'function'\n )\n}\n\nexport type WebhookCacheDependencyResolver =\n | ((token: string) => unknown)\n | { resolve: (token: string) => unknown }\n\nexport function resolveWebhookSubscriptionCache(resolve: (token: string) => unknown): WebhookSubscriptionCache | null {\n try {\n const cache = resolve('cache')\n return isWebhookSubscriptionCache(cache) ? cache : null\n } catch {\n return null\n }\n}\n\nfunction toResolveFunction(source: WebhookCacheDependencyResolver | null | undefined): ((token: string) => unknown) | null {\n if (typeof source === 'function') return source\n if (source && typeof source.resolve === 'function') return source.resolve.bind(source)\n return null\n}\n\nexport function getWebhookSubscriptionCacheTtlMs(): number {\n const raw = process.env.OM_WEBHOOKS_SUBSCRIPTION_CACHE_TTL_MS\n if (raw === undefined || raw.trim() === '') return DEFAULT_TTL_MS\n const parsed = Number.parseInt(raw, 10)\n if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_TTL_MS\n return parsed\n}\n\nfunction normalizeScopeSegment(organizationId: string | null | undefined): string {\n return organizationId ?? GLOBAL_SCOPE_SEGMENT\n}\n\nexport function getWebhookSubscriptionCacheKey(tenantId: string, organizationId: string | null | undefined): string {\n return `webhooks:subscriptions:${tenantId}:${normalizeScopeSegment(organizationId)}`\n}\n\nexport function getWebhookSubscriptionCacheTag(tenantId: string): string {\n return `webhooks:subscriptions:tenant:${tenantId}`\n}\n\nfunction isCachedActiveWebhookList(value: unknown): value is CachedActiveWebhook[] {\n return Array.isArray(value) && value.every((entry) => (\n entry !== null\n && typeof entry === 'object'\n && typeof (entry as CachedActiveWebhook).id === 'string'\n && typeof (entry as CachedActiveWebhook).tenantId === 'string'\n && Array.isArray((entry as CachedActiveWebhook).subscribedEvents)\n ))\n}\n\nexport async function getCachedActiveWebhooks(\n cache: WebhookSubscriptionCache | null,\n tenantId: string,\n organizationId: string | null | undefined,\n): Promise<CachedActiveWebhook[] | null> {\n if (!cache) return null\n try {\n const value = await runWithCacheTenant(tenantId, () => cache.get(getWebhookSubscriptionCacheKey(tenantId, organizationId)))\n return isCachedActiveWebhookList(value) ? value : null\n } catch (error) {\n logger.warn('Failed to read webhook subscription cache', { err: error })\n return null\n }\n}\n\nexport async function setCachedActiveWebhooks(\n cache: WebhookSubscriptionCache | null,\n tenantId: string,\n organizationId: string | null | undefined,\n webhooks: CachedActiveWebhook[],\n ttlMs: number,\n): Promise<void> {\n if (!cache || ttlMs <= 0) return\n try {\n await runWithCacheTenant(tenantId, () => cache.set(\n getWebhookSubscriptionCacheKey(tenantId, organizationId),\n webhooks,\n { ttl: ttlMs, tags: [getWebhookSubscriptionCacheTag(tenantId)] },\n ))\n } catch (error) {\n logger.warn('Failed to write webhook subscription cache', { err: error })\n }\n}\n\nexport async function invalidateWebhookSubscriptionCache(\n cache: WebhookSubscriptionCache | null,\n tenantId: string | null | undefined,\n): Promise<void> {\n if (!cache) return\n const normalizedTenantId = tenantId?.trim()\n if (!normalizedTenantId) return\n try {\n await runWithCacheTenant(normalizedTenantId, () => cache.deleteByTags([getWebhookSubscriptionCacheTag(normalizedTenantId)]))\n } catch (error) {\n logger.warn('Failed to invalidate webhook subscription cache', { err: error })\n }\n}\n\nexport async function invalidateWebhookSubscriptionCacheFor(\n source: WebhookCacheDependencyResolver | null | undefined,\n tenantId: string | null | undefined,\n): Promise<void> {\n const normalizedTenantId = tenantId?.trim()\n if (!normalizedTenantId) return\n const resolve = toResolveFunction(source)\n if (!resolve) return\n await invalidateWebhookSubscriptionCache(resolveWebhookSubscriptionCache(resolve), normalizedTenantId)\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -6,6 +6,13 @@ import { createLogger } from "@open-mercato/shared/lib/logger";
6
6
  import { createWebhookDelivery } from "../lib/delivery.js";
7
7
  import { enqueueWebhookDelivery } from "../lib/queue.js";
8
8
  import { isWebhookIntegrationEnabled } from "../lib/integration-state.js";
9
+ import {
10
+ getCachedActiveWebhooks,
11
+ getWebhookSubscriptionCacheTtlMs,
12
+ invalidateWebhookSubscriptionCacheFor,
13
+ resolveWebhookSubscriptionCache,
14
+ setCachedActiveWebhooks
15
+ } from "../lib/subscription-cache.js";
9
16
  const logger = createLogger("webhooks");
10
17
  const metadata = {
11
18
  event: "*",
@@ -28,14 +35,29 @@ function integrationScopeKey(tenantId, organizationId) {
28
35
  async function handler(payload, ctx) {
29
36
  const eventId = ctx.eventId ?? ctx.eventName ?? payload.eventId ?? payload.type;
30
37
  if (!eventId) return;
38
+ const resolve = "resolve" in ctx && typeof ctx.resolve === "function" ? ctx.resolve : "container" in ctx && ctx.container && typeof ctx.container.resolve === "function" ? ctx.container.resolve.bind(ctx.container) : null;
39
+ if (eventId.startsWith("webhooks.webhook.")) {
40
+ const changedTenantId = payload.tenantId;
41
+ if (resolve && changedTenantId) {
42
+ await invalidateWebhookSubscriptionCacheFor(resolve, changedTenantId);
43
+ }
44
+ return;
45
+ }
31
46
  if (shouldSkipOutboundDispatch(eventId)) return;
32
47
  const tenantId = payload.tenantId;
33
48
  const organizationId = payload.organizationId;
34
49
  if (!tenantId) return;
35
50
  if (eventId.startsWith("webhooks.")) return;
36
51
  if (eventId.startsWith("query_index.")) return;
37
- const resolve = "resolve" in ctx && typeof ctx.resolve === "function" ? ctx.resolve : "container" in ctx && ctx.container && typeof ctx.container.resolve === "function" ? ctx.container.resolve.bind(ctx.container) : null;
38
52
  if (!resolve) return;
53
+ const cacheTtlMs = getWebhookSubscriptionCacheTtlMs();
54
+ const subscriptionCache = cacheTtlMs > 0 ? resolveWebhookSubscriptionCache(resolve) : null;
55
+ const cachedActiveWebhooks = subscriptionCache ? await getCachedActiveWebhooks(subscriptionCache, tenantId, organizationId ?? null) : null;
56
+ let matchingIds = null;
57
+ if (cachedActiveWebhooks) {
58
+ matchingIds = cachedActiveWebhooks.filter((entry) => matchAnyWebhookEventPattern(eventId, entry.subscribedEvents)).map((entry) => entry.id);
59
+ if (!matchingIds.length) return;
60
+ }
39
61
  const em = resolve("em").fork();
40
62
  const webhooks = await findWithDecryption(
41
63
  em,
@@ -44,11 +66,21 @@ async function handler(payload, ctx) {
44
66
  isActive: true,
45
67
  deletedAt: null,
46
68
  tenantId,
47
- ...organizationId ? { organizationId } : {}
69
+ ...organizationId ? { organizationId } : {},
70
+ ...matchingIds ? { id: { $in: matchingIds } } : {}
48
71
  },
49
72
  {},
50
73
  { tenantId, organizationId: organizationId ?? null }
51
74
  );
75
+ if (!matchingIds && subscriptionCache) {
76
+ const cacheable = webhooks.map((webhook) => ({
77
+ id: webhook.id,
78
+ tenantId: webhook.tenantId,
79
+ organizationId: webhook.organizationId ?? null,
80
+ subscribedEvents: webhook.subscribedEvents
81
+ }));
82
+ await setCachedActiveWebhooks(subscriptionCache, tenantId, organizationId ?? null, cacheable, cacheTtlMs);
83
+ }
52
84
  if (!webhooks.length) return;
53
85
  const matchingWebhooks = webhooks.filter(
54
86
  (webhook) => matchAnyWebhookEventPattern(eventId, webhook.subscribedEvents)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/webhooks/subscribers/outbound-dispatch.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { SubscriberContext } from '@open-mercato/events/types'\nimport { WebhookDeliveryEntity, WebhookEntity } from '../data/entities'\nimport { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { matchAnyWebhookEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { getDeclaredEvents } from '@open-mercato/shared/modules/events'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { createWebhookDelivery } from '../lib/delivery'\nimport { enqueueWebhookDelivery } from '../lib/queue'\nimport { isWebhookIntegrationEnabled } from '../lib/integration-state'\n\nconst logger = createLogger('webhooks')\n\nexport const metadata = {\n event: '*',\n persistent: true,\n id: 'webhooks:outbound-dispatch',\n}\n\nfunction shouldSkipOutboundDispatch(eventId: string): boolean {\n if (eventId.startsWith('webhooks.') || eventId.startsWith('application.')) return true\n\n const declaredEvent = getDeclaredEvents().find((event) => event.id === eventId)\n return declaredEvent?.excludeFromTriggers === true\n}\n\nfunction forkOutboundEntityManager(em: EntityManager): EntityManager {\n const fork = (em as unknown as { fork?: (options?: Record<string, unknown>) => EntityManager }).fork\n if (typeof fork !== 'function') return em\n return fork.call(em, { clear: true, useContext: false })\n}\n\nfunction integrationScopeKey(tenantId: string, organizationId: string): string {\n return `${tenantId}:${organizationId}`\n}\n\nexport default async function handler(\n payload: Record<string, unknown>,\n ctx: (SubscriberContext & { eventId?: string }) | { container?: { resolve: <T = unknown>(name: string) => T }; eventId?: string; eventName?: string; resolve?: <T = unknown>(name: string) => T },\n) {\n const eventId = ctx.eventId ?? ctx.eventName ?? (payload.eventId as string) ?? (payload.type as string)\n if (!eventId) return\n if (shouldSkipOutboundDispatch(eventId)) return\n\n const tenantId = payload.tenantId as string | undefined\n const organizationId = payload.organizationId as string | undefined\n if (!tenantId) return\n\n if (eventId.startsWith('webhooks.')) return\n if (eventId.startsWith('query_index.')) return\n\n\n const resolve = ('resolve' in ctx && typeof ctx.resolve === 'function')\n ? ctx.resolve\n : ('container' in ctx && ctx.container && typeof ctx.container.resolve === 'function')\n ? ctx.container.resolve.bind(ctx.container)\n : null\n\n if (!resolve) return\n\n const em = (resolve('em') as EntityManager).fork()\n\n const webhooks = await findWithDecryption(\n em,\n WebhookEntity,\n {\n isActive: true,\n deletedAt: null,\n tenantId,\n ...(organizationId ? { organizationId } : {}),\n },\n {},\n { tenantId, organizationId: organizationId ?? null },\n )\n\n if (!webhooks.length) return\n\n const matchingWebhooks = webhooks.filter((webhook) =>\n matchAnyWebhookEventPattern(eventId, webhook.subscribedEvents),\n )\n\n if (!matchingWebhooks.length) return\n\n const integrationEnabledByScope = new Map<string, Promise<boolean>>()\n\n const resolveIntegrationEnabled = (webhook: WebhookEntity): Promise<boolean> => {\n const key = integrationScopeKey(webhook.tenantId, webhook.organizationId)\n let pending = integrationEnabledByScope.get(key)\n if (!pending) {\n pending = isWebhookIntegrationEnabled(em, {\n tenantId: webhook.tenantId,\n organizationId: webhook.organizationId,\n })\n integrationEnabledByScope.set(key, pending)\n }\n return pending\n }\n\n await Promise.all(\n matchingWebhooks.map(async (webhook) => {\n if (!(await resolveIntegrationEnabled(webhook))) return\n\n const webhookEm = forkOutboundEntityManager(em)\n let createdDeliveryId: string | null = null\n try {\n const delivery = await createWebhookDelivery({\n em: webhookEm,\n webhook,\n eventId,\n payload,\n })\n createdDeliveryId = delivery.id\n\n await enqueueWebhookDelivery({\n deliveryId: delivery.id,\n tenantId: delivery.tenantId,\n organizationId: delivery.organizationId,\n })\n } catch (error) {\n if (createdDeliveryId) {\n const failedDelivery = await findOneWithDecryption(\n webhookEm,\n WebhookDeliveryEntity,\n { id: createdDeliveryId, tenantId: webhook.tenantId, organizationId: webhook.organizationId },\n undefined,\n { tenantId: webhook.tenantId, organizationId: webhook.organizationId },\n )\n if (failedDelivery) {\n failedDelivery.status = 'failed'\n failedDelivery.errorMessage = error instanceof Error ? `Queue enqueue failed: ${error.message}` : 'Queue enqueue failed'\n failedDelivery.nextRetryAt = null\n await webhookEm.flush()\n }\n }\n logger.error('Failed to enqueue outbound delivery', {\n webhookId: webhook.id,\n eventId,\n tenantId,\n organizationId: organizationId ?? webhook.organizationId,\n err: error,\n })\n }\n }),\n )\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { SubscriberContext } from '@open-mercato/events/types'\nimport { WebhookDeliveryEntity, WebhookEntity } from '../data/entities'\nimport { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { matchAnyWebhookEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { getDeclaredEvents } from '@open-mercato/shared/modules/events'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { createWebhookDelivery } from '../lib/delivery'\nimport { enqueueWebhookDelivery } from '../lib/queue'\nimport { isWebhookIntegrationEnabled } from '../lib/integration-state'\nimport {\n getCachedActiveWebhooks,\n getWebhookSubscriptionCacheTtlMs,\n invalidateWebhookSubscriptionCacheFor,\n resolveWebhookSubscriptionCache,\n setCachedActiveWebhooks,\n type CachedActiveWebhook,\n} from '../lib/subscription-cache'\n\nconst logger = createLogger('webhooks')\n\nexport const metadata = {\n event: '*',\n persistent: true,\n id: 'webhooks:outbound-dispatch',\n}\n\nfunction shouldSkipOutboundDispatch(eventId: string): boolean {\n if (eventId.startsWith('webhooks.') || eventId.startsWith('application.')) return true\n\n const declaredEvent = getDeclaredEvents().find((event) => event.id === eventId)\n return declaredEvent?.excludeFromTriggers === true\n}\n\nfunction forkOutboundEntityManager(em: EntityManager): EntityManager {\n const fork = (em as unknown as { fork?: (options?: Record<string, unknown>) => EntityManager }).fork\n if (typeof fork !== 'function') return em\n return fork.call(em, { clear: true, useContext: false })\n}\n\nfunction integrationScopeKey(tenantId: string, organizationId: string): string {\n return `${tenantId}:${organizationId}`\n}\n\nexport default async function handler(\n payload: Record<string, unknown>,\n ctx: (SubscriberContext & { eventId?: string }) | { container?: { resolve: <T = unknown>(name: string) => T }; eventId?: string; eventName?: string; resolve?: <T = unknown>(name: string) => T },\n) {\n const eventId = ctx.eventId ?? ctx.eventName ?? (payload.eventId as string) ?? (payload.type as string)\n if (!eventId) return\n\n const resolve = ('resolve' in ctx && typeof ctx.resolve === 'function')\n ? ctx.resolve\n : ('container' in ctx && ctx.container && typeof ctx.container.resolve === 'function')\n ? ctx.container.resolve.bind(ctx.container)\n : null\n\n if (eventId.startsWith('webhooks.webhook.')) {\n const changedTenantId = payload.tenantId as string | undefined\n if (resolve && changedTenantId) {\n await invalidateWebhookSubscriptionCacheFor(resolve, changedTenantId)\n }\n return\n }\n\n if (shouldSkipOutboundDispatch(eventId)) return\n\n const tenantId = payload.tenantId as string | undefined\n const organizationId = payload.organizationId as string | undefined\n if (!tenantId) return\n\n if (eventId.startsWith('webhooks.')) return\n if (eventId.startsWith('query_index.')) return\n\n if (!resolve) return\n\n const cacheTtlMs = getWebhookSubscriptionCacheTtlMs()\n const subscriptionCache = cacheTtlMs > 0 ? resolveWebhookSubscriptionCache(resolve) : null\n const cachedActiveWebhooks = subscriptionCache\n ? await getCachedActiveWebhooks(subscriptionCache, tenantId, organizationId ?? null)\n : null\n\n let matchingIds: string[] | null = null\n\n if (cachedActiveWebhooks) {\n matchingIds = cachedActiveWebhooks\n .filter((entry) => matchAnyWebhookEventPattern(eventId, entry.subscribedEvents))\n .map((entry) => entry.id)\n\n if (!matchingIds.length) return\n }\n\n const em = (resolve('em') as EntityManager).fork()\n\n const webhooks = await findWithDecryption(\n em,\n WebhookEntity,\n {\n isActive: true,\n deletedAt: null,\n tenantId,\n ...(organizationId ? { organizationId } : {}),\n ...(matchingIds ? { id: { $in: matchingIds } } : {}),\n },\n {},\n { tenantId, organizationId: organizationId ?? null },\n )\n\n if (!matchingIds && subscriptionCache) {\n const cacheable: CachedActiveWebhook[] = webhooks.map((webhook) => ({\n id: webhook.id,\n tenantId: webhook.tenantId,\n organizationId: webhook.organizationId ?? null,\n subscribedEvents: webhook.subscribedEvents,\n }))\n await setCachedActiveWebhooks(subscriptionCache, tenantId, organizationId ?? null, cacheable, cacheTtlMs)\n }\n\n if (!webhooks.length) return\n\n const matchingWebhooks = webhooks.filter((webhook) =>\n matchAnyWebhookEventPattern(eventId, webhook.subscribedEvents),\n )\n\n if (!matchingWebhooks.length) return\n\n const integrationEnabledByScope = new Map<string, Promise<boolean>>()\n\n const resolveIntegrationEnabled = (webhook: WebhookEntity): Promise<boolean> => {\n const key = integrationScopeKey(webhook.tenantId, webhook.organizationId)\n let pending = integrationEnabledByScope.get(key)\n if (!pending) {\n pending = isWebhookIntegrationEnabled(em, {\n tenantId: webhook.tenantId,\n organizationId: webhook.organizationId,\n })\n integrationEnabledByScope.set(key, pending)\n }\n return pending\n }\n\n await Promise.all(\n matchingWebhooks.map(async (webhook) => {\n if (!(await resolveIntegrationEnabled(webhook))) return\n\n const webhookEm = forkOutboundEntityManager(em)\n let createdDeliveryId: string | null = null\n try {\n const delivery = await createWebhookDelivery({\n em: webhookEm,\n webhook,\n eventId,\n payload,\n })\n createdDeliveryId = delivery.id\n\n await enqueueWebhookDelivery({\n deliveryId: delivery.id,\n tenantId: delivery.tenantId,\n organizationId: delivery.organizationId,\n })\n } catch (error) {\n if (createdDeliveryId) {\n const failedDelivery = await findOneWithDecryption(\n webhookEm,\n WebhookDeliveryEntity,\n { id: createdDeliveryId, tenantId: webhook.tenantId, organizationId: webhook.organizationId },\n undefined,\n { tenantId: webhook.tenantId, organizationId: webhook.organizationId },\n )\n if (failedDelivery) {\n failedDelivery.status = 'failed'\n failedDelivery.errorMessage = error instanceof Error ? `Queue enqueue failed: ${error.message}` : 'Queue enqueue failed'\n failedDelivery.nextRetryAt = null\n await webhookEm.flush()\n }\n }\n logger.error('Failed to enqueue outbound delivery', {\n webhookId: webhook.id,\n eventId,\n tenantId,\n organizationId: organizationId ?? webhook.organizationId,\n err: error,\n })\n }\n }),\n )\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -9,7 +9,7 @@ const metadata = {
9
9
  async function handler(job, ctx) {
10
10
  const em = ctx.resolve("em").fork();
11
11
  try {
12
- await processWebhookDeliveryJob(em, job.payload);
12
+ await processWebhookDeliveryJob(em, job.payload, { resolver: ctx.resolve });
13
13
  } catch (error) {
14
14
  logger.error("Job processing failed", {
15
15
  deliveryId: job.payload?.deliveryId,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/webhooks/workers/webhook-delivery.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { processWebhookDeliveryJob, type WebhookDeliveryJob } from '../lib/delivery'\n\nconst logger = createLogger('webhooks').child({ component: 'delivery' })\n\nexport const metadata = {\n queue: 'webhook-deliveries',\n id: 'webhooks:delivery-worker',\n concurrency: 10,\n}\n\nexport default async function handler(\n job: { payload: WebhookDeliveryJob },\n ctx: { resolve: <T = unknown>(name: string) => T },\n) {\n const em = (ctx.resolve('em') as EntityManager).fork()\n try {\n await processWebhookDeliveryJob(em, job.payload)\n } catch (error) {\n logger.error('Job processing failed', {\n deliveryId: job.payload?.deliveryId,\n tenantId: job.payload?.tenantId,\n err: error,\n })\n throw error\n }\n}\n"],
5
- "mappings": "AACA,SAAS,oBAAoB;AAC7B,SAAS,iCAA0D;AAEnE,MAAM,SAAS,aAAa,UAAU,EAAE,MAAM,EAAE,WAAW,WAAW,CAAC;AAEhE,MAAM,WAAW;AAAA,EACtB,OAAO;AAAA,EACP,IAAI;AAAA,EACJ,aAAa;AACf;AAEA,eAAO,QACL,KACA,KACA;AACA,QAAM,KAAM,IAAI,QAAQ,IAAI,EAAoB,KAAK;AACrD,MAAI;AACF,UAAM,0BAA0B,IAAI,IAAI,OAAO;AAAA,EACjD,SAAS,OAAO;AACd,WAAO,MAAM,yBAAyB;AAAA,MACpC,YAAY,IAAI,SAAS;AAAA,MACzB,UAAU,IAAI,SAAS;AAAA,MACvB,KAAK;AAAA,IACP,CAAC;AACD,UAAM;AAAA,EACR;AACF;",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { processWebhookDeliveryJob, type WebhookDeliveryJob } from '../lib/delivery'\n\nconst logger = createLogger('webhooks').child({ component: 'delivery' })\n\nexport const metadata = {\n queue: 'webhook-deliveries',\n id: 'webhooks:delivery-worker',\n concurrency: 10,\n}\n\nexport default async function handler(\n job: { payload: WebhookDeliveryJob },\n ctx: { resolve: <T = unknown>(name: string) => T },\n) {\n const em = (ctx.resolve('em') as EntityManager).fork()\n try {\n await processWebhookDeliveryJob(em, job.payload, { resolver: ctx.resolve })\n } catch (error) {\n logger.error('Job processing failed', {\n deliveryId: job.payload?.deliveryId,\n tenantId: job.payload?.tenantId,\n err: error,\n })\n throw error\n }\n}\n"],
5
+ "mappings": "AACA,SAAS,oBAAoB;AAC7B,SAAS,iCAA0D;AAEnE,MAAM,SAAS,aAAa,UAAU,EAAE,MAAM,EAAE,WAAW,WAAW,CAAC;AAEhE,MAAM,WAAW;AAAA,EACtB,OAAO;AAAA,EACP,IAAI;AAAA,EACJ,aAAa;AACf;AAEA,eAAO,QACL,KACA,KACA;AACA,QAAM,KAAM,IAAI,QAAQ,IAAI,EAAoB,KAAK;AACrD,MAAI;AACF,UAAM,0BAA0B,IAAI,IAAI,SAAS,EAAE,UAAU,IAAI,QAAQ,CAAC;AAAA,EAC5E,SAAS,OAAO;AACd,WAAO,MAAM,yBAAyB;AAAA,MACpC,YAAY,IAAI,SAAS;AAAA,MACzB,UAAU,IAAI,SAAS;AAAA,MACvB,KAAK;AAAA,IACP,CAAC;AACD,UAAM;AAAA,EACR;AACF;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/webhooks",
3
- "version": "0.8.1-develop.7228.1.b7ea09d2d3",
3
+ "version": "0.8.1-develop.7237.1.635faf2c7a",
4
4
  "license": "MIT",
5
5
  "description": "Webhooks module for Open Mercato — Standard Webhooks compliant outbound/inbound delivery",
6
6
  "type": "module",
@@ -70,19 +70,20 @@
70
70
  }
71
71
  },
72
72
  "dependencies": {
73
- "@open-mercato/core": "0.8.1-develop.7228.1.b7ea09d2d3",
74
- "@open-mercato/queue": "0.8.1-develop.7228.1.b7ea09d2d3",
75
- "@open-mercato/ui": "0.8.1-develop.7228.1.b7ea09d2d3",
73
+ "@open-mercato/cache": "0.8.1-develop.7237.1.635faf2c7a",
74
+ "@open-mercato/core": "0.8.1-develop.7237.1.635faf2c7a",
75
+ "@open-mercato/queue": "0.8.1-develop.7237.1.635faf2c7a",
76
+ "@open-mercato/ui": "0.8.1-develop.7237.1.635faf2c7a",
76
77
  "svix": "^1.99.1"
77
78
  },
78
79
  "peerDependencies": {
79
80
  "@mikro-orm/postgresql": "^7.0.14",
80
- "@open-mercato/shared": "0.8.1-develop.7228.1.b7ea09d2d3",
81
+ "@open-mercato/shared": "0.8.1-develop.7237.1.635faf2c7a",
81
82
  "react": "^19.0.0",
82
83
  "react-dom": "^19.0.0"
83
84
  },
84
85
  "devDependencies": {
85
- "@open-mercato/shared": "0.8.1-develop.7228.1.b7ea09d2d3",
86
+ "@open-mercato/shared": "0.8.1-develop.7237.1.635faf2c7a",
86
87
  "@types/jest": "^30.0.0",
87
88
  "@types/react": "^19.2.18",
88
89
  "@types/react-dom": "^19.2.5",
@@ -0,0 +1,107 @@
1
+ /** @jest-environment node */
2
+
3
+ import { createCacheService, type CacheStrategy } from '@open-mercato/cache'
4
+ import {
5
+ getCachedActiveWebhooks,
6
+ setCachedActiveWebhooks,
7
+ } from '../../../../lib/subscription-cache'
8
+
9
+ const WEBHOOK_ID = '123e4567-e89b-12d3-a456-426614174071'
10
+
11
+ const webhookRecord = {
12
+ id: WEBHOOK_ID,
13
+ name: 'Hook',
14
+ description: null,
15
+ url: 'https://example.com/hook',
16
+ subscribedEvents: ['catalog.product.created'],
17
+ httpMethod: 'POST',
18
+ isActive: true,
19
+ organizationId: 'org-1',
20
+ tenantId: 'tenant-1',
21
+ updatedAt: new Date('2026-06-01T10:00:00.000Z'),
22
+ deletedAt: null as Date | null,
23
+ }
24
+
25
+ const mockEm = {
26
+ fork: jest.fn(() => mockEm),
27
+ flush: jest.fn(async () => undefined),
28
+ }
29
+
30
+ let cache: CacheStrategy
31
+
32
+ const mockContainer = {
33
+ resolve: (token: string) => {
34
+ if (token === 'em') return mockEm
35
+ if (token === 'cache') return cache
36
+ if (token === 'commandOptimisticLockGuardService') throw new Error('not registered')
37
+ return null
38
+ },
39
+ }
40
+
41
+ jest.mock('../../../../events', () => ({
42
+ emitWebhooksEvent: jest.fn(async () => undefined),
43
+ }))
44
+
45
+ jest.mock('../../../helpers', () => ({
46
+ json: (payload: unknown, init: ResponseInit = { status: 200 }) =>
47
+ new Response(JSON.stringify(payload), {
48
+ ...init,
49
+ headers: { 'content-type': 'application/json' },
50
+ }),
51
+ resolveWebhookRequestScope: jest.fn(async () => ({
52
+ container: mockContainer,
53
+ em: mockEm,
54
+ tenantId: 'tenant-1',
55
+ organizationId: 'org-1',
56
+ })),
57
+ findScopedWebhook: jest.fn(async () => webhookRecord),
58
+ serializeWebhookDetail: (item: { id: string }) => ({ id: item.id }),
59
+ }))
60
+
61
+ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
62
+ resolveTranslations: jest.fn(async () => ({ translate: (_key: string, fallback?: string) => fallback ?? '' })),
63
+ }))
64
+
65
+ import { PUT, DELETE } from '../route'
66
+
67
+ function request(method: string, body?: unknown) {
68
+ return new Request(`http://localhost/api/webhooks/${WEBHOOK_ID}`, {
69
+ method,
70
+ headers: { 'content-type': 'application/json' },
71
+ body: body !== undefined ? JSON.stringify(body) : undefined,
72
+ })
73
+ }
74
+
75
+ const context = { params: Promise.resolve({ id: WEBHOOK_ID }) }
76
+
77
+ async function seedCache(): Promise<void> {
78
+ await setCachedActiveWebhooks(
79
+ cache,
80
+ 'tenant-1',
81
+ 'org-1',
82
+ [{ id: WEBHOOK_ID, tenantId: 'tenant-1', organizationId: 'org-1', subscribedEvents: ['catalog.product.created'] }],
83
+ 60_000,
84
+ )
85
+ }
86
+
87
+ describe('webhook endpoint PUT/DELETE inline subscription-cache invalidation', () => {
88
+ beforeEach(async () => {
89
+ jest.clearAllMocks()
90
+ webhookRecord.deletedAt = null
91
+ cache = createCacheService({ strategy: 'memory' })
92
+ await seedCache()
93
+ expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).not.toBeNull()
94
+ })
95
+
96
+ it('PUT drops the tenant subscription cache synchronously with the write', async () => {
97
+ const res = await PUT(request('PUT', { isActive: false }), context)
98
+ expect(res.status).toBe(200)
99
+ expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).toBeNull()
100
+ })
101
+
102
+ it('DELETE drops the tenant subscription cache synchronously with the soft delete', async () => {
103
+ const res = await DELETE(request('DELETE'), context)
104
+ expect(res.status).toBe(200)
105
+ expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).toBeNull()
106
+ })
107
+ })
@@ -6,6 +6,7 @@ import { findScopedWebhook, json, resolveWebhookRequestScope, serializeWebhookDe
6
6
  import { webhookUpdateSchema } from '../../../data/validators'
7
7
  import { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
8
8
  import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
9
+ import { invalidateWebhookSubscriptionCacheFor } from '../../../lib/subscription-cache'
9
10
 
10
11
  export const metadata = {
11
12
  GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },
@@ -106,6 +107,8 @@ export async function PUT(request: Request, context: RouteContext): Promise<Resp
106
107
 
107
108
  await em.flush()
108
109
 
110
+ await invalidateWebhookSubscriptionCacheFor(scope.container, webhook.tenantId)
111
+
109
112
  await emitWebhooksEvent('webhooks.webhook.updated', {
110
113
  webhookId: webhook.id,
111
114
  organizationId: webhook.organizationId,
@@ -143,6 +146,8 @@ export async function DELETE(request: Request, context: RouteContext): Promise<R
143
146
  webhook.deletedAt = new Date()
144
147
  await em.flush()
145
148
 
149
+ await invalidateWebhookSubscriptionCacheFor(scope.container, webhook.tenantId)
150
+
146
151
  await emitWebhooksEvent('webhooks.webhook.deleted', {
147
152
  webhookId: webhook.id,
148
153
  organizationId: webhook.organizationId,
@@ -85,7 +85,7 @@ export async function POST(request: Request, context: RouteContext): Promise<Res
85
85
  deliveryId: delivery.id,
86
86
  tenantId: delivery.tenantId,
87
87
  organizationId: delivery.organizationId,
88
- }, { scheduleRetries: false })
88
+ }, { scheduleRetries: false, resolver: scope.container })
89
89
 
90
90
  await em.refresh(delivery)
91
91
 
@@ -0,0 +1,93 @@
1
+ /** @jest-environment node */
2
+
3
+ import { createCacheService, type CacheStrategy } from '@open-mercato/cache'
4
+ import {
5
+ getCachedActiveWebhooks,
6
+ setCachedActiveWebhooks,
7
+ } from '../../../lib/subscription-cache'
8
+
9
+ type CapturedHooks = {
10
+ afterCreate?: (entity: unknown, ctx: unknown) => Promise<void> | void
11
+ afterUpdate?: (entity: unknown, ctx: unknown) => Promise<void> | void
12
+ afterDelete?: (id: string, ctx: unknown) => Promise<void> | void
13
+ }
14
+
15
+ const captured: { hooks: CapturedHooks | null } = { hooks: null }
16
+
17
+ jest.mock('@open-mercato/shared/lib/crud/factory', () => ({
18
+ makeCrudRoute: jest.fn((options: { hooks?: CapturedHooks }) => {
19
+ captured.hooks = options.hooks ?? null
20
+ return {
21
+ metadata: {},
22
+ GET: jest.fn(),
23
+ POST: jest.fn(),
24
+ PUT: jest.fn(),
25
+ DELETE: jest.fn(),
26
+ }
27
+ }),
28
+ }))
29
+
30
+ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
31
+ resolveTranslations: jest.fn(async () => ({ translate: (_key: string, fallback?: string) => fallback ?? '' })),
32
+ }))
33
+
34
+ import '../route'
35
+
36
+ const WEBHOOK_ID = '123e4567-e89b-12d3-a456-426614174072'
37
+
38
+ describe('webhooks CRUD route inline subscription-cache invalidation', () => {
39
+ let cache: CacheStrategy
40
+ let container: { resolve: (token: string) => unknown }
41
+
42
+ beforeEach(async () => {
43
+ cache = createCacheService({ strategy: 'memory' })
44
+ container = { resolve: (token: string) => (token === 'cache' ? cache : null) }
45
+ await setCachedActiveWebhooks(
46
+ cache,
47
+ 'tenant-1',
48
+ 'org-1',
49
+ [{ id: WEBHOOK_ID, tenantId: 'tenant-1', organizationId: 'org-1', subscribedEvents: ['catalog.product.created'] }],
50
+ 60_000,
51
+ )
52
+ expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).not.toBeNull()
53
+ })
54
+
55
+ it('registers afterCreate, afterUpdate and afterDelete hooks', () => {
56
+ expect(typeof captured.hooks?.afterCreate).toBe('function')
57
+ expect(typeof captured.hooks?.afterUpdate).toBe('function')
58
+ expect(typeof captured.hooks?.afterDelete).toBe('function')
59
+ })
60
+
61
+ it('afterCreate invalidates the tenant subscription cache', async () => {
62
+ await captured.hooks?.afterCreate?.(
63
+ { id: WEBHOOK_ID, tenantId: 'tenant-1' },
64
+ { container, auth: { tenantId: 'tenant-1', orgId: 'org-1' } },
65
+ )
66
+ expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).toBeNull()
67
+ })
68
+
69
+ it('afterUpdate invalidates the tenant subscription cache', async () => {
70
+ await captured.hooks?.afterUpdate?.(
71
+ { id: WEBHOOK_ID, tenantId: 'tenant-1' },
72
+ { container, auth: { tenantId: 'tenant-1', orgId: 'org-1' } },
73
+ )
74
+ expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).toBeNull()
75
+ })
76
+
77
+ it('afterDelete invalidates the tenant subscription cache from the auth scope', async () => {
78
+ await captured.hooks?.afterDelete?.(
79
+ WEBHOOK_ID,
80
+ { container, auth: { tenantId: 'tenant-1', orgId: 'org-1' } },
81
+ )
82
+ expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).toBeNull()
83
+ })
84
+
85
+ it('leaves another tenant cache entry intact', async () => {
86
+ await setCachedActiveWebhooks(cache, 'tenant-2', 'org-1', [], 60_000)
87
+ await captured.hooks?.afterUpdate?.(
88
+ { id: WEBHOOK_ID, tenantId: 'tenant-1' },
89
+ { container, auth: { tenantId: 'tenant-1', orgId: 'org-1' } },
90
+ )
91
+ expect(await getCachedActiveWebhooks(cache, 'tenant-2', 'org-1')).not.toBeNull()
92
+ })
93
+ })
@@ -10,6 +10,7 @@ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
10
10
  import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
11
11
  import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
12
12
  import type { WebhookCreateInput, WebhookUpdateInput } from '../../data/validators'
13
+ import { invalidateWebhookSubscriptionCacheFor } from '../../lib/subscription-cache'
13
14
 
14
15
  type WebhookCrudCtx = CrudCtx & {
15
16
  __webhookSecret?: string
@@ -68,6 +69,12 @@ const webhookDetailResponseSchema = webhookListItemSchema.extend({
68
69
  const deleteResponseSchema = z.object({ success: z.literal(true) })
69
70
  const errorSchema = z.object({ error: z.string() })
70
71
 
72
+ function resolveTenantIdForInvalidation(entity: unknown, ctx: CrudCtx): string | null {
73
+ const entityTenantId = (entity as { tenantId?: unknown } | null)?.tenantId
74
+ if (typeof entityTenantId === 'string' && entityTenantId.length > 0) return entityTenantId
75
+ return ctx.auth?.tenantId ?? null
76
+ }
77
+
71
78
  const crud = makeCrudRoute<WebhookCreateInput, WebhookUpdateInput, z.infer<typeof webhookListQuerySchema>>({
72
79
  metadata: {
73
80
  GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },
@@ -220,6 +227,13 @@ const crud = makeCrudRoute<WebhookCreateInput, WebhookUpdateInput, z.infer<typeo
220
227
  if (scopedCtx.__webhookSecret) {
221
228
  ;(entity as WebhookEntity & { __revealSecret?: string }).__revealSecret = scopedCtx.__webhookSecret
222
229
  }
230
+ await invalidateWebhookSubscriptionCacheFor(ctx.container, resolveTenantIdForInvalidation(entity, ctx))
231
+ },
232
+ afterUpdate: async (entity, ctx) => {
233
+ await invalidateWebhookSubscriptionCacheFor(ctx.container, resolveTenantIdForInvalidation(entity, ctx))
234
+ },
235
+ afterDelete: async (_id, ctx) => {
236
+ await invalidateWebhookSubscriptionCacheFor(ctx.container, ctx.auth?.tenantId ?? null)
223
237
  },
224
238
  beforeDelete: async (id, ctx) => {
225
239
  const auth = ctx.auth