@open-mercato/events 0.6.5-develop.5382.1.f542de69af → 0.6.5

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 CHANGED
@@ -76,6 +76,16 @@ export default async function handler(payload, ctx) { /* ... */ }
76
76
  - When `QUEUE_STRATEGY=local`, persistent events process from `.mercato/queue/` (or `QUEUE_BASE_DIR`)
77
77
  - Ephemeral subscribers always run in-process regardless of queue strategy
78
78
 
79
+ ### Persistent delivery: legacy vs single-delivery (`OM_EVENTS_SINGLE_DELIVERY`)
80
+
81
+ By default (`OM_EVENTS_SINGLE_DELIVERY` unset/false) a persistent emit is delivered on **both** paths: inline to every matching in-memory subscriber **and** through the events worker (exact-match). This double-dispatches exact-match subscribers and never reaches wildcard (`event: '*'`) persistent subscribers in the worker.
82
+
83
+ Set `OM_EVENTS_SINGLE_DELIVERY=true` to make persistent delivery single-path:
84
+ - the bus skips inline delivery of **persistent-marked** subscribers on a persistent emit (ephemeral subscribers still run inline);
85
+ - the events worker dispatches **persistent** subscribers via `matchEventPattern`, so wildcard persistent subscribers are finally reached.
86
+
87
+ Both halves are gated by the same flag and MUST move together. When enabling it, ensure the events worker runs (default `AUTO_SPAWN_WORKERS=true`) and validate under both `QUEUE_STRATEGY=local` and `=async` so no persistent subscriber is dropped. This is the "Ask First: changing persistent delivery semantics" gate — the flag defaults off to preserve today's behavior.
88
+
79
89
  ## Queue Integration
80
90
 
81
91
  | Queue strategy | Ephemeral events | Persistent events |
package/dist/bus.js CHANGED
@@ -1,10 +1,14 @@
1
1
  import { createQueue } from "@open-mercato/queue";
2
+ import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
2
3
  import { matchEventPattern } from "@open-mercato/shared/lib/events/patterns";
3
4
  import { getRedisUrlOrThrow } from "@open-mercato/shared/lib/redis/connection";
4
5
  import { isBroadcastEvent } from "@open-mercato/shared/modules/events";
5
6
  import { registerCrossProcessEventListener } from "./bridge.js";
6
7
  import { publishCrossProcessEvent } from "./bridge.js";
7
8
  const EVENTS_QUEUE_NAME = "events";
9
+ function isSingleDeliveryEnabled() {
10
+ return parseBooleanWithDefault(process.env.OM_EVENTS_SINGLE_DELIVERY, false);
11
+ }
8
12
  const GLOBAL_EVENT_TAPS_KEY = "__openMercatoEventBusGlobalTaps__";
9
13
  function hasTenantScope(payload) {
10
14
  return typeof payload?.tenantId === "string" && String(payload.tenantId).trim().length > 0;
@@ -25,23 +29,67 @@ function registerGlobalEventTap(handler) {
25
29
  taps.delete(handler);
26
30
  };
27
31
  }
32
+ const EVENTS_PRODUCER_QUEUE_KEY = "__openMercatoEventsProducerQueues__";
33
+ const EVENTS_PRODUCER_SHUTDOWN_KEY = "__openMercatoEventsProducerShutdown__";
34
+ function isSharedProducerEnabled() {
35
+ return parseBooleanWithDefault(process.env.OM_EVENTS_SHARED_PRODUCER, true);
36
+ }
37
+ function getProducerQueueRegistry() {
38
+ const existing = globalThis[EVENTS_PRODUCER_QUEUE_KEY];
39
+ if (existing instanceof Map) {
40
+ return existing;
41
+ }
42
+ const created = /* @__PURE__ */ new Map();
43
+ globalThis[EVENTS_PRODUCER_QUEUE_KEY] = created;
44
+ return created;
45
+ }
46
+ function registerProducerShutdownHook() {
47
+ if (globalThis[EVENTS_PRODUCER_SHUTDOWN_KEY]) return;
48
+ const shutdown = () => {
49
+ const registry = getProducerQueueRegistry();
50
+ for (const sharedQueue of registry.values()) {
51
+ Promise.resolve(sharedQueue.close()).catch(() => {
52
+ });
53
+ }
54
+ registry.clear();
55
+ };
56
+ process.once("SIGTERM", shutdown);
57
+ process.once("SIGINT", shutdown);
58
+ globalThis[EVENTS_PRODUCER_SHUTDOWN_KEY] = true;
59
+ }
28
60
  function createEventBus(opts) {
29
61
  const listeners = /* @__PURE__ */ new Map();
62
+ const persistentHandlers = /* @__PURE__ */ new Set();
30
63
  const queueStrategy = opts.queueStrategy ?? (process.env.QUEUE_STRATEGY === "async" ? "async" : "local");
31
64
  let queue = null;
32
65
  function getQueue() {
33
- if (!queue) {
34
- queue = queueStrategy === "async" ? createQueue(EVENTS_QUEUE_NAME, "async", {
35
- connection: { url: getRedisUrlOrThrow("QUEUE") }
36
- }) : createQueue(EVENTS_QUEUE_NAME, "local");
66
+ if (queueStrategy !== "async" || !isSharedProducerEnabled()) {
67
+ if (!queue) {
68
+ queue = queueStrategy === "async" ? createQueue(EVENTS_QUEUE_NAME, "async", {
69
+ connection: { url: getRedisUrlOrThrow("QUEUE") }
70
+ }) : createQueue(EVENTS_QUEUE_NAME, "local");
71
+ }
72
+ return queue;
37
73
  }
38
- return queue;
74
+ const redisUrl = getRedisUrlOrThrow("QUEUE");
75
+ const registry = getProducerQueueRegistry();
76
+ const cacheKey = `async:${redisUrl}`;
77
+ let shared = registry.get(cacheKey);
78
+ if (!shared) {
79
+ shared = createQueue(EVENTS_QUEUE_NAME, "async", {
80
+ connection: { url: redisUrl }
81
+ });
82
+ registry.set(cacheKey, shared);
83
+ registerProducerShutdownHook();
84
+ }
85
+ return shared;
39
86
  }
40
- async function deliver(event, payload, options) {
87
+ async function deliver(event, payload, options, skipPersistent = false) {
41
88
  for (const [pattern, handlers] of listeners) {
42
89
  if (!matchEventPattern(event, pattern)) continue;
43
90
  if (!handlers || handlers.size === 0) continue;
44
91
  for (const handler of handlers) {
92
+ if (skipPersistent && persistentHandlers.has(handler)) continue;
45
93
  try {
46
94
  await Promise.resolve(handler(payload, {
47
95
  resolve: opts.resolve,
@@ -55,15 +103,18 @@ function createEventBus(opts) {
55
103
  }
56
104
  }
57
105
  }
58
- function on(event, handler) {
106
+ function on(event, handler, options) {
59
107
  if (!listeners.has(event)) {
60
108
  listeners.set(event, /* @__PURE__ */ new Set());
61
109
  }
62
110
  listeners.get(event).add(handler);
111
+ if (options?.persistent) {
112
+ persistentHandlers.add(handler);
113
+ }
63
114
  }
64
115
  function registerModuleSubscribers(subs) {
65
116
  for (const sub of subs) {
66
- on(sub.event, sub.handler);
117
+ on(sub.event, sub.handler, { persistent: sub.persistent });
67
118
  }
68
119
  }
69
120
  async function emit(event, payload, options) {
@@ -75,7 +126,8 @@ function createEventBus(opts) {
75
126
  console.error(`[events] Global tap error for "${event}":`, error);
76
127
  }
77
128
  }
78
- await deliver(event, payload, options);
129
+ const skipPersistentInline = Boolean(options?.persistent) && isSingleDeliveryEnabled();
130
+ await deliver(event, payload, options, skipPersistentInline);
79
131
  if (isBroadcastEvent(event) && hasTenantScope(payload)) {
80
132
  try {
81
133
  await publishCrossProcessEvent(event, payload, options);
package/dist/bus.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/bus.ts"],
4
- "sourcesContent": ["import { createQueue } from '@open-mercato/queue'\nimport type { Queue } from '@open-mercato/queue'\nimport { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport { isBroadcastEvent } from '@open-mercato/shared/modules/events'\nexport { registerCrossProcessEventListener } from './bridge'\nimport { publishCrossProcessEvent } from './bridge'\nimport type {\n EventBus,\n CreateBusOptions,\n SubscriberHandler,\n SubscriberDescriptor,\n EventPayload,\n EmitOptions,\n} from './types'\n\n/** Queue name for persistent events */\nconst EVENTS_QUEUE_NAME = 'events'\n\ntype GlobalEventTap = (event: string, payload: EventPayload, options?: EmitOptions) => void | Promise<void>\nconst GLOBAL_EVENT_TAPS_KEY = '__openMercatoEventBusGlobalTaps__'\n\nfunction hasTenantScope(payload: EventPayload): boolean {\n return typeof (payload as Record<string, unknown>)?.tenantId === 'string'\n && String((payload as Record<string, unknown>).tenantId).trim().length > 0\n}\n\nfunction getGlobalEventTaps(): Set<GlobalEventTap> {\n const existing = (globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY]\n if (existing instanceof Set) {\n return existing as Set<GlobalEventTap>\n }\n const created = new Set<GlobalEventTap>()\n ;(globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY] = created\n return created\n}\n\nexport function registerGlobalEventTap(handler: GlobalEventTap): () => void {\n const taps = getGlobalEventTaps()\n taps.add(handler)\n return () => {\n taps.delete(handler)\n }\n}\n\n/** Job data structure for queued events */\ntype EventJobData = {\n event: string\n payload: EventPayload\n options?: EmitOptions\n}\n\n/**\n * Creates an event bus instance.\n *\n * The event bus provides:\n * - In-memory event delivery to registered handlers\n * - Optional persistence via the queue package when `persistent: true`\n *\n * @param opts - Configuration options\n * @returns An EventBus instance\n *\n * @example\n * ```typescript\n * const bus = createEventBus({\n * resolve: container.resolve.bind(container),\n * queueStrategy: 'local', // or 'async' for BullMQ\n * })\n *\n * // Register a handler\n * bus.on('user.created', async (payload, ctx) => {\n * const userService = ctx.resolve('userService')\n * await userService.sendWelcomeEmail(payload.userId)\n * })\n *\n * // Emit an event (immediate delivery)\n * await bus.emit('user.created', { userId: '123' })\n *\n * // Emit with persistence (for async worker processing)\n * await bus.emit('order.placed', { orderId: '456' }, { persistent: true })\n * ```\n */\nexport function createEventBus(opts: CreateBusOptions): EventBus {\n // In-memory listeners for immediate event delivery\n const listeners = new Map<string, Set<SubscriberHandler>>()\n\n // Determine queue strategy from options or environment\n const queueStrategy = opts.queueStrategy ??\n (process.env.QUEUE_STRATEGY === 'async' ? 'async' : 'local')\n\n // Lazy-initialized queue for persistent events\n let queue: Queue<EventJobData> | null = null\n\n /**\n * Gets or creates the queue instance for persistent events.\n */\n function getQueue(): Queue<EventJobData> {\n if (!queue) {\n queue = queueStrategy === 'async'\n ? createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n : createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'local')\n }\n return queue\n }\n\n /**\n * Delivers an event to all registered in-memory handlers.\n * Supports wildcard pattern matching for event patterns.\n */\n async function deliver(event: string, payload: EventPayload, options?: EmitOptions): Promise<void> {\n // Check all registered patterns (including wildcards)\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) continue\n if (!handlers || handlers.size === 0) continue\n\n for (const handler of handlers) {\n try {\n // Pass eventName in context for wildcard handlers\n await Promise.resolve(handler(payload, {\n resolve: opts.resolve,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }))\n } catch (error) {\n console.error(`[events] Handler error for \"${event}\" (pattern: \"${pattern}\"):`, error)\n }\n }\n }\n }\n\n /**\n * Registers a handler for an event.\n */\n function on(event: string, handler: SubscriberHandler): void {\n if (!listeners.has(event)) {\n listeners.set(event, new Set())\n }\n listeners.get(event)!.add(handler)\n }\n\n /**\n * Registers multiple module subscribers at once.\n */\n function registerModuleSubscribers(subs: SubscriberDescriptor[]): void {\n for (const sub of subs) {\n on(sub.event, sub.handler)\n }\n }\n\n /**\n * Emits an event to all registered handlers.\n *\n * If `persistent: true`, also enqueues the event for async processing.\n */\n async function emit(\n event: string,\n payload: EventPayload,\n options?: EmitOptions\n ): Promise<void> {\n const taps = getGlobalEventTaps()\n for (const tap of taps) {\n try {\n await Promise.resolve(tap(event, payload, options))\n } catch (error) {\n console.error(`[events] Global tap error for \"${event}\":`, error)\n }\n }\n\n // Always deliver to in-memory handlers first\n await deliver(event, payload, options)\n\n if (isBroadcastEvent(event) && hasTenantScope(payload)) {\n try {\n await publishCrossProcessEvent(event, payload, options)\n } catch (error) {\n console.error(`[events] Cross-process publish error for \"${event}\":`, error)\n }\n }\n\n // If persistent, also enqueue for async processing\n if (options?.persistent) {\n const q = getQueue()\n await q.enqueue({ event, payload, options })\n }\n }\n\n /**\n * Clears all events from the persistent queue.\n */\n async function clearQueue(): Promise<{ removed: number }> {\n const q = getQueue()\n return q.clear()\n }\n\n // Backward compatibility alias\n const emitEvent = emit\n\n return {\n emit,\n emitEvent, // Alias for backward compatibility\n on,\n registerModuleSubscribers,\n clearQueue,\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { createQueue } from '@open-mercato/queue'\nimport type { Queue } from '@open-mercato/queue'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport { isBroadcastEvent } from '@open-mercato/shared/modules/events'\nexport { registerCrossProcessEventListener } from './bridge'\nimport { publishCrossProcessEvent } from './bridge'\nimport type {\n EventBus,\n CreateBusOptions,\n SubscriberHandler,\n SubscriberDescriptor,\n EventPayload,\n EmitOptions,\n} from './types'\n\n/** Queue name for persistent events */\nconst EVENTS_QUEUE_NAME = 'events'\n\n/**\n * When enabled, a persistent emit delivers each subscriber on exactly one path:\n * persistent-marked subscribers are skipped inline (the events worker dispatches\n * them via pattern match, so wildcard persistent subscribers are reached), while\n * ephemeral subscribers keep running inline. Default off preserves the legacy\n * dual-dispatch behavior. Both this flag and the worker MUST agree, so the worker\n * reads the same env var.\n */\nfunction isSingleDeliveryEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_EVENTS_SINGLE_DELIVERY, false)\n}\n\ntype GlobalEventTap = (event: string, payload: EventPayload, options?: EmitOptions) => void | Promise<void>\nconst GLOBAL_EVENT_TAPS_KEY = '__openMercatoEventBusGlobalTaps__'\n\nfunction hasTenantScope(payload: EventPayload): boolean {\n return typeof (payload as Record<string, unknown>)?.tenantId === 'string'\n && String((payload as Record<string, unknown>).tenantId).trim().length > 0\n}\n\nfunction getGlobalEventTaps(): Set<GlobalEventTap> {\n const existing = (globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY]\n if (existing instanceof Set) {\n return existing as Set<GlobalEventTap>\n }\n const created = new Set<GlobalEventTap>()\n ;(globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY] = created\n return created\n}\n\nexport function registerGlobalEventTap(handler: GlobalEventTap): () => void {\n const taps = getGlobalEventTaps()\n taps.add(handler)\n return () => {\n taps.delete(handler)\n }\n}\n\n/** Job data structure for queued events */\ntype EventJobData = {\n event: string\n payload: EventPayload\n options?: EmitOptions\n}\n\n// Process-wide cache of the async (BullMQ) persistent-events producer queue.\n// Each authenticated request builds a fresh DI container and event bus, so a\n// per-bus producer queue opened a new ioredis connection per write request that\n// was never closed \u2014 leaking one Redis connection per request until maxclients\n// exhaustion. Memoizing the producer on `globalThis` (keyed by Redis URL, so a\n// reconfigured URL still gets its own queue) keeps it at one connection per\n// process, mirroring the `GLOBAL_EVENT_TAPS_KEY` and `getCachedRateLimiterService`\n// patterns. The local (file-based) strategy holds no pooled connection and its\n// base dir is cwd-relative, so it stays per-bus.\nconst EVENTS_PRODUCER_QUEUE_KEY = '__openMercatoEventsProducerQueues__'\nconst EVENTS_PRODUCER_SHUTDOWN_KEY = '__openMercatoEventsProducerShutdown__'\n\nfunction isSharedProducerEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_EVENTS_SHARED_PRODUCER, true)\n}\n\nfunction getProducerQueueRegistry(): Map<string, Queue<EventJobData>> {\n const existing = (globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY]\n if (existing instanceof Map) {\n return existing as Map<string, Queue<EventJobData>>\n }\n const created = new Map<string, Queue<EventJobData>>()\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY] = created\n return created\n}\n\nfunction registerProducerShutdownHook(): void {\n if ((globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY]) return\n const shutdown = () => {\n const registry = getProducerQueueRegistry()\n for (const sharedQueue of registry.values()) {\n Promise.resolve(sharedQueue.close()).catch(() => {})\n }\n registry.clear()\n }\n process.once('SIGTERM', shutdown)\n process.once('SIGINT', shutdown)\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY] = true\n}\n\n/**\n * Creates an event bus instance.\n *\n * The event bus provides:\n * - In-memory event delivery to registered handlers\n * - Optional persistence via the queue package when `persistent: true`\n *\n * @param opts - Configuration options\n * @returns An EventBus instance\n *\n * @example\n * ```typescript\n * const bus = createEventBus({\n * resolve: container.resolve.bind(container),\n * queueStrategy: 'local', // or 'async' for BullMQ\n * })\n *\n * // Register a handler\n * bus.on('user.created', async (payload, ctx) => {\n * const userService = ctx.resolve('userService')\n * await userService.sendWelcomeEmail(payload.userId)\n * })\n *\n * // Emit an event (immediate delivery)\n * await bus.emit('user.created', { userId: '123' })\n *\n * // Emit with persistence (for async worker processing)\n * await bus.emit('order.placed', { orderId: '456' }, { persistent: true })\n * ```\n */\nexport function createEventBus(opts: CreateBusOptions): EventBus {\n // In-memory listeners for immediate event delivery\n const listeners = new Map<string, Set<SubscriberHandler>>()\n // Handlers registered as persistent (worker-dispatched). Used by the\n // single-delivery path to skip them inline on a persistent emit.\n const persistentHandlers = new Set<SubscriberHandler>()\n\n // Determine queue strategy from options or environment\n const queueStrategy = opts.queueStrategy ??\n (process.env.QUEUE_STRATEGY === 'async' ? 'async' : 'local')\n\n // Lazy-initialized queue for persistent events\n let queue: Queue<EventJobData> | null = null\n\n /**\n * Gets or creates the queue instance for persistent events.\n *\n * The async (BullMQ) producer is memoized process-wide so the per-request\n * event bus reuses one Redis connection instead of leaking one per write\n * request. The local strategy stays per-bus (no pooled connection, cwd-relative\n * base dir). Set `OM_EVENTS_SHARED_PRODUCER=0` to fall back to per-bus producers.\n */\n function getQueue(): Queue<EventJobData> {\n if (queueStrategy !== 'async' || !isSharedProducerEnabled()) {\n if (!queue) {\n queue = queueStrategy === 'async'\n ? createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n : createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'local')\n }\n return queue\n }\n\n const redisUrl = getRedisUrlOrThrow('QUEUE')\n const registry = getProducerQueueRegistry()\n const cacheKey = `async:${redisUrl}`\n let shared = registry.get(cacheKey)\n if (!shared) {\n shared = createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: redisUrl },\n })\n registry.set(cacheKey, shared)\n registerProducerShutdownHook()\n }\n return shared\n }\n\n /**\n * Delivers an event to all registered in-memory handlers.\n * Supports wildcard pattern matching for event patterns.\n */\n async function deliver(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n skipPersistent = false,\n ): Promise<void> {\n // Check all registered patterns (including wildcards)\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) continue\n if (!handlers || handlers.size === 0) continue\n\n for (const handler of handlers) {\n // Single-delivery: persistent subscribers are dispatched by the worker,\n // so skip them inline to avoid double execution.\n if (skipPersistent && persistentHandlers.has(handler)) continue\n try {\n // Pass eventName in context for wildcard handlers\n await Promise.resolve(handler(payload, {\n resolve: opts.resolve,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }))\n } catch (error) {\n console.error(`[events] Handler error for \"${event}\" (pattern: \"${pattern}\"):`, error)\n }\n }\n }\n }\n\n /**\n * Registers a handler for an event.\n */\n function on(event: string, handler: SubscriberHandler, options?: { persistent?: boolean }): void {\n if (!listeners.has(event)) {\n listeners.set(event, new Set())\n }\n listeners.get(event)!.add(handler)\n if (options?.persistent) {\n persistentHandlers.add(handler)\n }\n }\n\n /**\n * Registers multiple module subscribers at once.\n */\n function registerModuleSubscribers(subs: SubscriberDescriptor[]): void {\n for (const sub of subs) {\n on(sub.event, sub.handler, { persistent: sub.persistent })\n }\n }\n\n /**\n * Emits an event to all registered handlers.\n *\n * If `persistent: true`, also enqueues the event for async processing.\n */\n async function emit(\n event: string,\n payload: EventPayload,\n options?: EmitOptions\n ): Promise<void> {\n const taps = getGlobalEventTaps()\n for (const tap of taps) {\n try {\n await Promise.resolve(tap(event, payload, options))\n } catch (error) {\n console.error(`[events] Global tap error for \"${event}\":`, error)\n }\n }\n\n // Deliver to in-memory handlers first. Under single-delivery, persistent\n // subscribers are skipped inline on a persistent emit because the events\n // worker will dispatch them from the queue.\n const skipPersistentInline = Boolean(options?.persistent) && isSingleDeliveryEnabled()\n await deliver(event, payload, options, skipPersistentInline)\n\n if (isBroadcastEvent(event) && hasTenantScope(payload)) {\n try {\n await publishCrossProcessEvent(event, payload, options)\n } catch (error) {\n console.error(`[events] Cross-process publish error for \"${event}\":`, error)\n }\n }\n\n // If persistent, also enqueue for async processing\n if (options?.persistent) {\n const q = getQueue()\n await q.enqueue({ event, payload, options })\n }\n }\n\n /**\n * Clears all events from the persistent queue.\n */\n async function clearQueue(): Promise<{ removed: number }> {\n const q = getQueue()\n return q.clear()\n }\n\n // Backward compatibility alias\n const emitEvent = emit\n\n return {\n emit,\n emitEvent, // Alias for backward compatibility\n on,\n registerModuleSubscribers,\n clearQueue,\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,4 +1,6 @@
1
1
  import { getCliModules } from "@open-mercato/shared/modules/registry";
2
+ import { matchEventPattern } from "@open-mercato/shared/lib/events/patterns";
3
+ import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
2
4
  const EVENTS_QUEUE_NAME = "events";
3
5
  const DEFAULT_CONCURRENCY = 1;
4
6
  const envConcurrency = process.env.WORKERS_EVENTS_CONCURRENCY;
@@ -6,6 +8,22 @@ const metadata = {
6
8
  queue: EVENTS_QUEUE_NAME,
7
9
  concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY
8
10
  };
11
+ function isSingleDeliveryEnabled() {
12
+ return parseBooleanWithDefault(process.env.OM_EVENTS_SINGLE_DELIVERY, false);
13
+ }
14
+ function resolveSubscribers(listeners, event) {
15
+ if (!isSingleDeliveryEnabled()) {
16
+ return listeners.get(event) ?? [];
17
+ }
18
+ const matched = [];
19
+ for (const [pattern, subs] of listeners) {
20
+ if (!matchEventPattern(event, pattern)) continue;
21
+ for (const sub of subs) {
22
+ if (sub.persistent) matched.push(sub);
23
+ }
24
+ }
25
+ return matched;
26
+ }
9
27
  let cachedListenerMap = null;
10
28
  function clearListenerCache() {
11
29
  cachedListenerMap = null;
@@ -31,7 +49,7 @@ function getListenerMap() {
31
49
  async function handle(job, ctx) {
32
50
  const { event, payload, options } = job.payload;
33
51
  const listeners = getListenerMap();
34
- const subscribers = listeners.get(event);
52
+ const subscribers = resolveSubscribers(listeners, event);
35
53
  if (!subscribers || subscribers.length === 0) return;
36
54
  const handlerCtx = {
37
55
  resolve: ctx.resolve,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/events/workers/events.worker.ts"],
4
- "sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { getCliModules } from '@open-mercato/shared/modules/registry'\n\nexport const EVENTS_QUEUE_NAME = 'events'\n\nconst DEFAULT_CONCURRENCY = 1\nconst envConcurrency = process.env.WORKERS_EVENTS_CONCURRENCY\n\nexport const metadata: WorkerMeta = {\n queue: EVENTS_QUEUE_NAME,\n concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,\n}\n\ntype EventJobPayload = {\n event: string\n payload: unknown\n options?: {\n tenantId?: string | null\n organizationId?: string | null\n }\n}\n\ntype HandlerContext = {\n resolve: <T = unknown>(name: string) => T\n tenantId?: string | null\n organizationId?: string | null\n}\n\ntype SubscriberEntry = {\n id: string\n event: string\n handler: (payload: unknown, ctx: unknown) => Promise<void> | void\n}\n\n// Cached listener map - built once on first use\nlet cachedListenerMap: Map<string, SubscriberEntry[]> | null = null\n\n/**\n * Clear the cached listener map (for testing purposes).\n */\nexport function clearListenerCache(): void {\n cachedListenerMap = null\n}\n\n// Build listener map from module subscribers\nfunction buildListenerMap(): Map<string, SubscriberEntry[]> {\n const listeners = new Map<string, SubscriberEntry[]>()\n for (const mod of getCliModules()) {\n const subs = (mod as { subscribers?: SubscriberEntry[] }).subscribers\n if (!subs) continue\n for (const sub of subs) {\n if (!listeners.has(sub.event)) listeners.set(sub.event, [])\n listeners.get(sub.event)!.push(sub)\n }\n }\n return listeners\n}\n\n// Get cached listener map, building on first access\nfunction getListenerMap(): Map<string, SubscriberEntry[]> {\n if (!cachedListenerMap) {\n cachedListenerMap = buildListenerMap()\n }\n return cachedListenerMap\n}\n\n/**\n * Events worker handler.\n * Dispatches queued events to registered module subscribers.\n * Each subscriber is isolated - failures in one don't affect others.\n */\nexport default async function handle(\n job: QueuedJob<EventJobPayload>,\n ctx: JobContext & HandlerContext\n): Promise<void> {\n const { event, payload, options } = job.payload\n const listeners = getListenerMap()\n const subscribers = listeners.get(event)\n\n if (!subscribers || subscribers.length === 0) return\n\n const handlerCtx = {\n resolve: ctx.resolve,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }\n\n const results = await Promise.allSettled(\n subscribers.map((sub) => Promise.resolve(sub.handler(payload, handlerCtx)))\n )\n\n const errors: Array<{ subscriberId: string; error: unknown }> = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const sub = subscribers[i]\n console.error(`[events] Subscriber \"${sub.id}\" failed for event \"${event}\":`, result.reason)\n errors.push({ subscriberId: sub.id, error: result.reason })\n }\n }\n\n if (errors.length > 0) {\n const failedIds = errors.map((e) => e.subscriberId).join(', ')\n throw new Error(\n `${errors.length}/${subscribers.length} subscriber(s) failed for event \"${event}\": ${failedIds}`\n )\n }\n}\n"],
5
- "mappings": "AACA,SAAS,qBAAqB;AAEvB,MAAM,oBAAoB;AAEjC,MAAM,sBAAsB;AAC5B,MAAM,iBAAiB,QAAQ,IAAI;AAE5B,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,aAAa,iBAAiB,SAAS,gBAAgB,EAAE,IAAI;AAC/D;AAwBA,IAAI,oBAA2D;AAKxD,SAAS,qBAA2B;AACzC,sBAAoB;AACtB;AAGA,SAAS,mBAAmD;AAC1D,QAAM,YAAY,oBAAI,IAA+B;AACrD,aAAW,OAAO,cAAc,GAAG;AACjC,UAAM,OAAQ,IAA4C;AAC1D,QAAI,CAAC,KAAM;AACX,eAAW,OAAO,MAAM;AACtB,UAAI,CAAC,UAAU,IAAI,IAAI,KAAK,EAAG,WAAU,IAAI,IAAI,OAAO,CAAC,CAAC;AAC1D,gBAAU,IAAI,IAAI,KAAK,EAAG,KAAK,GAAG;AAAA,IACpC;AAAA,EACF;AACA,SAAO;AACT;AAGA,SAAS,iBAAiD;AACxD,MAAI,CAAC,mBAAmB;AACtB,wBAAoB,iBAAiB;AAAA,EACvC;AACA,SAAO;AACT;AAOA,eAAO,OACL,KACA,KACe;AACf,QAAM,EAAE,OAAO,SAAS,QAAQ,IAAI,IAAI;AACxC,QAAM,YAAY,eAAe;AACjC,QAAM,cAAc,UAAU,IAAI,KAAK;AAEvC,MAAI,CAAC,eAAe,YAAY,WAAW,EAAG;AAE9C,QAAM,aAAa;AAAA,IACjB,SAAS,IAAI;AAAA,IACb,UAAU,SAAS,YAAY;AAAA,IAC/B,gBAAgB,SAAS,kBAAkB;AAAA,EAC7C;AAEA,QAAM,UAAU,MAAM,QAAQ;AAAA,IAC5B,YAAY,IAAI,CAAC,QAAQ,QAAQ,QAAQ,IAAI,QAAQ,SAAS,UAAU,CAAC,CAAC;AAAA,EAC5E;AAEA,QAAM,SAA0D,CAAC;AACjE,WAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;AACvC,UAAM,SAAS,QAAQ,CAAC;AACxB,QAAI,OAAO,WAAW,YAAY;AAChC,YAAM,MAAM,YAAY,CAAC;AACzB,cAAQ,MAAM,wBAAwB,IAAI,EAAE,uBAAuB,KAAK,MAAM,OAAO,MAAM;AAC3F,aAAO,KAAK,EAAE,cAAc,IAAI,IAAI,OAAO,OAAO,OAAO,CAAC;AAAA,IAC5D;AAAA,EACF;AAEA,MAAI,OAAO,SAAS,GAAG;AACrB,UAAM,YAAY,OAAO,IAAI,CAAC,MAAM,EAAE,YAAY,EAAE,KAAK,IAAI;AAC7D,UAAM,IAAI;AAAA,MACR,GAAG,OAAO,MAAM,IAAI,YAAY,MAAM,oCAAoC,KAAK,MAAM,SAAS;AAAA,IAChG;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { getCliModules } from '@open-mercato/shared/modules/registry'\nimport { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\n\nexport const EVENTS_QUEUE_NAME = 'events'\n\nconst DEFAULT_CONCURRENCY = 1\nconst envConcurrency = process.env.WORKERS_EVENTS_CONCURRENCY\n\nexport const metadata: WorkerMeta = {\n queue: EVENTS_QUEUE_NAME,\n concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,\n}\n\ntype EventJobPayload = {\n event: string\n payload: unknown\n options?: {\n tenantId?: string | null\n organizationId?: string | null\n }\n}\n\ntype HandlerContext = {\n resolve: <T = unknown>(name: string) => T\n tenantId?: string | null\n organizationId?: string | null\n}\n\ntype SubscriberEntry = {\n id: string\n event: string\n persistent?: boolean\n handler: (payload: unknown, ctx: unknown) => Promise<void> | void\n}\n\n/**\n * Mirror of the event bus single-delivery flag. When enabled, the worker owns\n * dispatch of every persistent subscriber and matches by pattern so wildcard\n * (`event: '*'`) persistent subscribers are finally reached. Default off keeps\n * the legacy exact-match dispatch of all subscribers.\n */\nfunction isSingleDeliveryEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_EVENTS_SINGLE_DELIVERY, false)\n}\n\n/**\n * Resolves the subscribers to run for a queued event.\n * - Legacy (flag off): exact-match lookup of every subscriber for the event.\n * - Single-delivery (flag on): persistent subscribers whose pattern matches the\n * event, including wildcards, so they run exactly once here instead of inline.\n */\nfunction resolveSubscribers(\n listeners: Map<string, SubscriberEntry[]>,\n event: string,\n): SubscriberEntry[] {\n if (!isSingleDeliveryEnabled()) {\n return listeners.get(event) ?? []\n }\n const matched: SubscriberEntry[] = []\n for (const [pattern, subs] of listeners) {\n if (!matchEventPattern(event, pattern)) continue\n for (const sub of subs) {\n if (sub.persistent) matched.push(sub)\n }\n }\n return matched\n}\n\n// Cached listener map - built once on first use\nlet cachedListenerMap: Map<string, SubscriberEntry[]> | null = null\n\n/**\n * Clear the cached listener map (for testing purposes).\n */\nexport function clearListenerCache(): void {\n cachedListenerMap = null\n}\n\n// Build listener map from module subscribers\nfunction buildListenerMap(): Map<string, SubscriberEntry[]> {\n const listeners = new Map<string, SubscriberEntry[]>()\n for (const mod of getCliModules()) {\n const subs = (mod as { subscribers?: SubscriberEntry[] }).subscribers\n if (!subs) continue\n for (const sub of subs) {\n if (!listeners.has(sub.event)) listeners.set(sub.event, [])\n listeners.get(sub.event)!.push(sub)\n }\n }\n return listeners\n}\n\n// Get cached listener map, building on first access\nfunction getListenerMap(): Map<string, SubscriberEntry[]> {\n if (!cachedListenerMap) {\n cachedListenerMap = buildListenerMap()\n }\n return cachedListenerMap\n}\n\n/**\n * Events worker handler.\n * Dispatches queued events to registered module subscribers.\n * Each subscriber is isolated - failures in one don't affect others.\n */\nexport default async function handle(\n job: QueuedJob<EventJobPayload>,\n ctx: JobContext & HandlerContext\n): Promise<void> {\n const { event, payload, options } = job.payload\n const listeners = getListenerMap()\n const subscribers = resolveSubscribers(listeners, event)\n\n if (!subscribers || subscribers.length === 0) return\n\n const handlerCtx = {\n resolve: ctx.resolve,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }\n\n const results = await Promise.allSettled(\n subscribers.map((sub) => Promise.resolve(sub.handler(payload, handlerCtx)))\n )\n\n const errors: Array<{ subscriberId: string; error: unknown }> = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const sub = subscribers[i]\n console.error(`[events] Subscriber \"${sub.id}\" failed for event \"${event}\":`, result.reason)\n errors.push({ subscriberId: sub.id, error: result.reason })\n }\n }\n\n if (errors.length > 0) {\n const failedIds = errors.map((e) => e.subscriberId).join(', ')\n throw new Error(\n `${errors.length}/${subscribers.length} subscriber(s) failed for event \"${event}\": ${failedIds}`\n )\n }\n}\n"],
5
+ "mappings": "AACA,SAAS,qBAAqB;AAC9B,SAAS,yBAAyB;AAClC,SAAS,+BAA+B;AAEjC,MAAM,oBAAoB;AAEjC,MAAM,sBAAsB;AAC5B,MAAM,iBAAiB,QAAQ,IAAI;AAE5B,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,aAAa,iBAAiB,SAAS,gBAAgB,EAAE,IAAI;AAC/D;AA8BA,SAAS,0BAAmC;AAC1C,SAAO,wBAAwB,QAAQ,IAAI,2BAA2B,KAAK;AAC7E;AAQA,SAAS,mBACP,WACA,OACmB;AACnB,MAAI,CAAC,wBAAwB,GAAG;AAC9B,WAAO,UAAU,IAAI,KAAK,KAAK,CAAC;AAAA,EAClC;AACA,QAAM,UAA6B,CAAC;AACpC,aAAW,CAAC,SAAS,IAAI,KAAK,WAAW;AACvC,QAAI,CAAC,kBAAkB,OAAO,OAAO,EAAG;AACxC,eAAW,OAAO,MAAM;AACtB,UAAI,IAAI,WAAY,SAAQ,KAAK,GAAG;AAAA,IACtC;AAAA,EACF;AACA,SAAO;AACT;AAGA,IAAI,oBAA2D;AAKxD,SAAS,qBAA2B;AACzC,sBAAoB;AACtB;AAGA,SAAS,mBAAmD;AAC1D,QAAM,YAAY,oBAAI,IAA+B;AACrD,aAAW,OAAO,cAAc,GAAG;AACjC,UAAM,OAAQ,IAA4C;AAC1D,QAAI,CAAC,KAAM;AACX,eAAW,OAAO,MAAM;AACtB,UAAI,CAAC,UAAU,IAAI,IAAI,KAAK,EAAG,WAAU,IAAI,IAAI,OAAO,CAAC,CAAC;AAC1D,gBAAU,IAAI,IAAI,KAAK,EAAG,KAAK,GAAG;AAAA,IACpC;AAAA,EACF;AACA,SAAO;AACT;AAGA,SAAS,iBAAiD;AACxD,MAAI,CAAC,mBAAmB;AACtB,wBAAoB,iBAAiB;AAAA,EACvC;AACA,SAAO;AACT;AAOA,eAAO,OACL,KACA,KACe;AACf,QAAM,EAAE,OAAO,SAAS,QAAQ,IAAI,IAAI;AACxC,QAAM,YAAY,eAAe;AACjC,QAAM,cAAc,mBAAmB,WAAW,KAAK;AAEvD,MAAI,CAAC,eAAe,YAAY,WAAW,EAAG;AAE9C,QAAM,aAAa;AAAA,IACjB,SAAS,IAAI;AAAA,IACb,UAAU,SAAS,YAAY;AAAA,IAC/B,gBAAgB,SAAS,kBAAkB;AAAA,EAC7C;AAEA,QAAM,UAAU,MAAM,QAAQ;AAAA,IAC5B,YAAY,IAAI,CAAC,QAAQ,QAAQ,QAAQ,IAAI,QAAQ,SAAS,UAAU,CAAC,CAAC;AAAA,EAC5E;AAEA,QAAM,SAA0D,CAAC;AACjE,WAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;AACvC,UAAM,SAAS,QAAQ,CAAC;AACxB,QAAI,OAAO,WAAW,YAAY;AAChC,YAAM,MAAM,YAAY,CAAC;AACzB,cAAQ,MAAM,wBAAwB,IAAI,EAAE,uBAAuB,KAAK,MAAM,OAAO,MAAM;AAC3F,aAAO,KAAK,EAAE,cAAc,IAAI,IAAI,OAAO,OAAO,OAAO,CAAC;AAAA,IAC5D;AAAA,EACF;AAEA,MAAI,OAAO,SAAS,GAAG;AACrB,UAAM,YAAY,OAAO,IAAI,CAAC,MAAM,EAAE,YAAY,EAAE,KAAK,IAAI;AAC7D,UAAM,IAAI;AAAA,MACR,GAAG,OAAO,MAAM,IAAI,YAAY,MAAM,oCAAoC,KAAK,MAAM,SAAS;AAAA,IAChG;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/events",
3
- "version": "0.6.5-develop.5382.1.f542de69af",
3
+ "version": "0.6.5",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -32,8 +32,8 @@
32
32
  }
33
33
  },
34
34
  "dependencies": {
35
- "@open-mercato/queue": "0.6.5-develop.5382.1.f542de69af",
36
- "@open-mercato/shared": "0.6.5-develop.5382.1.f542de69af",
35
+ "@open-mercato/queue": "0.6.5",
36
+ "@open-mercato/shared": "0.6.5",
37
37
  "pg": "8.21.0"
38
38
  },
39
39
  "devDependencies": {
@@ -48,6 +48,5 @@
48
48
  "type": "git",
49
49
  "url": "https://github.com/open-mercato/open-mercato",
50
50
  "directory": "packages/events"
51
- },
52
- "stableVersion": "0.6.4"
51
+ }
53
52
  }
@@ -0,0 +1,97 @@
1
+ const createQueueMock = jest.fn()
2
+
3
+ jest.mock('@open-mercato/queue', () => ({
4
+ createQueue: (...args: unknown[]) => createQueueMock(...args),
5
+ }))
6
+
7
+ import { createEventBus } from '@open-mercato/events/index'
8
+
9
+ const PRODUCER_QUEUE_KEY = '__openMercatoEventsProducerQueues__'
10
+ const PRODUCER_SHUTDOWN_KEY = '__openMercatoEventsProducerShutdown__'
11
+
12
+ function makeFakeQueue(id: number) {
13
+ return {
14
+ id,
15
+ name: 'events',
16
+ strategy: 'async' as const,
17
+ enqueue: jest.fn(async () => `job-${id}`),
18
+ clear: jest.fn(async () => ({ removed: 0 })),
19
+ close: jest.fn(async () => {}),
20
+ process: jest.fn(),
21
+ getJobCounts: jest.fn(),
22
+ }
23
+ }
24
+
25
+ describe('persistent-events producer memoization (#2959)', () => {
26
+ const resolve = ((name: string) => name) as never
27
+
28
+ beforeEach(() => {
29
+ createQueueMock.mockReset()
30
+ let created = 0
31
+ createQueueMock.mockImplementation(() => makeFakeQueue(created++))
32
+ delete (globalThis as Record<string, unknown>)[PRODUCER_QUEUE_KEY]
33
+ delete (globalThis as Record<string, unknown>)[PRODUCER_SHUTDOWN_KEY]
34
+ process.env.REDIS_URL = 'redis://localhost:6379'
35
+ delete process.env.OM_EVENTS_SHARED_PRODUCER
36
+ })
37
+
38
+ afterEach(() => {
39
+ delete process.env.REDIS_URL
40
+ delete process.env.OM_EVENTS_SHARED_PRODUCER
41
+ delete (globalThis as Record<string, unknown>)[PRODUCER_QUEUE_KEY]
42
+ delete (globalThis as Record<string, unknown>)[PRODUCER_SHUTDOWN_KEY]
43
+ })
44
+
45
+ test('async strategy reuses a single process-wide producer across buses', async () => {
46
+ const busA = createEventBus({ resolve, queueStrategy: 'async' })
47
+ const busB = createEventBus({ resolve, queueStrategy: 'async' })
48
+
49
+ await busA.emit('demo.event.happened', { id: 1 }, { persistent: true })
50
+ await busB.emit('demo.event.happened', { id: 2 }, { persistent: true })
51
+
52
+ // The leak fix: only one producer queue (one Redis connection) is created
53
+ // even though two separate request-scoped buses emitted persistent events.
54
+ expect(createQueueMock).toHaveBeenCalledTimes(1)
55
+
56
+ const registry = (globalThis as Record<string, unknown>)[PRODUCER_QUEUE_KEY] as Map<string, { enqueue: jest.Mock }>
57
+ expect(registry.size).toBe(1)
58
+ const shared = [...registry.values()][0]
59
+ expect(shared.enqueue).toHaveBeenCalledTimes(2)
60
+ })
61
+
62
+ test('kill switch OM_EVENTS_SHARED_PRODUCER=0 restores per-bus producers', async () => {
63
+ process.env.OM_EVENTS_SHARED_PRODUCER = '0'
64
+ const busA = createEventBus({ resolve, queueStrategy: 'async' })
65
+ const busB = createEventBus({ resolve, queueStrategy: 'async' })
66
+
67
+ await busA.emit('demo.event.happened', { id: 1 }, { persistent: true })
68
+ await busB.emit('demo.event.happened', { id: 2 }, { persistent: true })
69
+
70
+ expect(createQueueMock).toHaveBeenCalledTimes(2)
71
+ expect((globalThis as Record<string, unknown>)[PRODUCER_QUEUE_KEY]).toBeUndefined()
72
+ })
73
+
74
+ test('separate Redis URLs get separate producers', async () => {
75
+ const busA = createEventBus({ resolve, queueStrategy: 'async' })
76
+ await busA.emit('demo.event.happened', { id: 1 }, { persistent: true })
77
+
78
+ process.env.REDIS_URL = 'redis://other-host:6379'
79
+ const busB = createEventBus({ resolve, queueStrategy: 'async' })
80
+ await busB.emit('demo.event.happened', { id: 2 }, { persistent: true })
81
+
82
+ expect(createQueueMock).toHaveBeenCalledTimes(2)
83
+ const registry = (globalThis as Record<string, unknown>)[PRODUCER_QUEUE_KEY] as Map<string, unknown>
84
+ expect(registry.size).toBe(2)
85
+ })
86
+
87
+ test('local strategy is not memoized (no pooled connection to share)', async () => {
88
+ const busA = createEventBus({ resolve, queueStrategy: 'local' })
89
+ const busB = createEventBus({ resolve, queueStrategy: 'local' })
90
+
91
+ await busA.emit('demo.event.happened', { id: 1 }, { persistent: true })
92
+ await busB.emit('demo.event.happened', { id: 2 }, { persistent: true })
93
+
94
+ expect(createQueueMock).toHaveBeenCalledTimes(2)
95
+ expect((globalThis as Record<string, unknown>)[PRODUCER_QUEUE_KEY]).toBeUndefined()
96
+ })
97
+ })
@@ -0,0 +1,95 @@
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 type { SubscriberDescriptor } from '@open-mercato/events/types'
7
+
8
+ /**
9
+ * Regression coverage for issue #2960: persistent subscribers must run on
10
+ * exactly one path under the OM_EVENTS_SINGLE_DELIVERY flag, and the default-off
11
+ * behavior must be preserved byte-for-byte.
12
+ */
13
+ describe('Event bus single-delivery (OM_EVENTS_SINGLE_DELIVERY)', () => {
14
+ const origCwd = process.cwd()
15
+ const origFlag = process.env.OM_EVENTS_SINGLE_DELIVERY
16
+ let tmp: string
17
+
18
+ beforeEach(() => {
19
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'events-single-delivery-'))
20
+ process.chdir(tmp)
21
+ delete process.env.QUEUE_STRATEGY
22
+ delete process.env.EVENTS_STRATEGY
23
+ })
24
+
25
+ afterEach(() => {
26
+ process.chdir(origCwd)
27
+ if (origFlag === undefined) delete process.env.OM_EVENTS_SINGLE_DELIVERY
28
+ else process.env.OM_EVENTS_SINGLE_DELIVERY = origFlag
29
+ try { fs.rmSync(tmp, { recursive: true, force: true }) } catch {}
30
+ })
31
+
32
+ function makeSub(
33
+ id: string,
34
+ event: string,
35
+ persistent: boolean,
36
+ sink: string[],
37
+ ): SubscriberDescriptor {
38
+ return { id, event, persistent, handler: () => { sink.push(id) } }
39
+ }
40
+
41
+ test('flag OFF (default): a persistent subscriber still runs inline on a persistent emit', async () => {
42
+ delete process.env.OM_EVENTS_SINGLE_DELIVERY
43
+ const calls: string[] = []
44
+ const bus = createEventBus({ resolve: ((name: string) => name) as never })
45
+ bus.registerModuleSubscribers([makeSub('persistent-sub', 'demo', true, calls)])
46
+
47
+ await bus.emit('demo', { a: 1 }, { persistent: true })
48
+
49
+ // Legacy dual-dispatch: inline delivery is preserved when the flag is off.
50
+ expect(calls).toEqual(['persistent-sub'])
51
+ })
52
+
53
+ test('flag ON: a persistent subscriber is skipped inline (deferred to the worker)', async () => {
54
+ process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
55
+ const calls: string[] = []
56
+ const bus = createEventBus({ resolve: ((name: string) => name) as never })
57
+ bus.registerModuleSubscribers([
58
+ makeSub('persistent-sub', 'demo', true, calls),
59
+ makeSub('ephemeral-sub', 'demo', false, calls),
60
+ ])
61
+
62
+ await bus.emit('demo', { a: 1 }, { persistent: true })
63
+
64
+ // Only the ephemeral subscriber runs inline; the persistent one is dispatched
65
+ // by the events worker from the queue, so it is skipped here.
66
+ expect(calls).toEqual(['ephemeral-sub'])
67
+ })
68
+
69
+ test('flag ON: a non-persistent emit still delivers persistent subscribers inline', async () => {
70
+ process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
71
+ const calls: string[] = []
72
+ const bus = createEventBus({ resolve: ((name: string) => name) as never })
73
+ bus.registerModuleSubscribers([makeSub('persistent-sub', 'demo', true, calls)])
74
+
75
+ // Non-persistent emit is never enqueued, so inline delivery is the only path.
76
+ await bus.emit('demo', { a: 1 })
77
+
78
+ expect(calls).toEqual(['persistent-sub'])
79
+ })
80
+
81
+ test('flag ON: persistent emit is still enqueued for the worker', async () => {
82
+ process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
83
+ const queuePath = path.join(path.resolve('.mercato/queue', 'events'), 'queue.json')
84
+ const calls: string[] = []
85
+ const bus = createEventBus({ resolve: ((name: string) => name) as never })
86
+ bus.registerModuleSubscribers([makeSub('persistent-sub', 'demo', true, calls)])
87
+
88
+ await bus.emit('demo', { a: 1 }, { persistent: true })
89
+
90
+ expect(calls).toEqual([])
91
+ const list = JSON.parse(fs.readFileSync(queuePath, 'utf8'))
92
+ expect(Array.isArray(list)).toBe(true)
93
+ expect(list.length).toBeGreaterThanOrEqual(1)
94
+ })
95
+ })
package/src/bus.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createQueue } from '@open-mercato/queue'
2
2
  import type { Queue } from '@open-mercato/queue'
3
+ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
3
4
  import { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'
4
5
  import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
5
6
  import { isBroadcastEvent } from '@open-mercato/shared/modules/events'
@@ -17,6 +18,18 @@ import type {
17
18
  /** Queue name for persistent events */
18
19
  const EVENTS_QUEUE_NAME = 'events'
19
20
 
21
+ /**
22
+ * When enabled, a persistent emit delivers each subscriber on exactly one path:
23
+ * persistent-marked subscribers are skipped inline (the events worker dispatches
24
+ * them via pattern match, so wildcard persistent subscribers are reached), while
25
+ * ephemeral subscribers keep running inline. Default off preserves the legacy
26
+ * dual-dispatch behavior. Both this flag and the worker MUST agree, so the worker
27
+ * reads the same env var.
28
+ */
29
+ function isSingleDeliveryEnabled(): boolean {
30
+ return parseBooleanWithDefault(process.env.OM_EVENTS_SINGLE_DELIVERY, false)
31
+ }
32
+
20
33
  type GlobalEventTap = (event: string, payload: EventPayload, options?: EmitOptions) => void | Promise<void>
21
34
  const GLOBAL_EVENT_TAPS_KEY = '__openMercatoEventBusGlobalTaps__'
22
35
 
@@ -50,6 +63,46 @@ type EventJobData = {
50
63
  options?: EmitOptions
51
64
  }
52
65
 
66
+ // Process-wide cache of the async (BullMQ) persistent-events producer queue.
67
+ // Each authenticated request builds a fresh DI container and event bus, so a
68
+ // per-bus producer queue opened a new ioredis connection per write request that
69
+ // was never closed — leaking one Redis connection per request until maxclients
70
+ // exhaustion. Memoizing the producer on `globalThis` (keyed by Redis URL, so a
71
+ // reconfigured URL still gets its own queue) keeps it at one connection per
72
+ // process, mirroring the `GLOBAL_EVENT_TAPS_KEY` and `getCachedRateLimiterService`
73
+ // patterns. The local (file-based) strategy holds no pooled connection and its
74
+ // base dir is cwd-relative, so it stays per-bus.
75
+ const EVENTS_PRODUCER_QUEUE_KEY = '__openMercatoEventsProducerQueues__'
76
+ const EVENTS_PRODUCER_SHUTDOWN_KEY = '__openMercatoEventsProducerShutdown__'
77
+
78
+ function isSharedProducerEnabled(): boolean {
79
+ return parseBooleanWithDefault(process.env.OM_EVENTS_SHARED_PRODUCER, true)
80
+ }
81
+
82
+ function getProducerQueueRegistry(): Map<string, Queue<EventJobData>> {
83
+ const existing = (globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY]
84
+ if (existing instanceof Map) {
85
+ return existing as Map<string, Queue<EventJobData>>
86
+ }
87
+ const created = new Map<string, Queue<EventJobData>>()
88
+ ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY] = created
89
+ return created
90
+ }
91
+
92
+ function registerProducerShutdownHook(): void {
93
+ if ((globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY]) return
94
+ const shutdown = () => {
95
+ const registry = getProducerQueueRegistry()
96
+ for (const sharedQueue of registry.values()) {
97
+ Promise.resolve(sharedQueue.close()).catch(() => {})
98
+ }
99
+ registry.clear()
100
+ }
101
+ process.once('SIGTERM', shutdown)
102
+ process.once('SIGINT', shutdown)
103
+ ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY] = true
104
+ }
105
+
53
106
  /**
54
107
  * Creates an event bus instance.
55
108
  *
@@ -83,6 +136,9 @@ type EventJobData = {
83
136
  export function createEventBus(opts: CreateBusOptions): EventBus {
84
137
  // In-memory listeners for immediate event delivery
85
138
  const listeners = new Map<string, Set<SubscriberHandler>>()
139
+ // Handlers registered as persistent (worker-dispatched). Used by the
140
+ // single-delivery path to skip them inline on a persistent emit.
141
+ const persistentHandlers = new Set<SubscriberHandler>()
86
142
 
87
143
  // Determine queue strategy from options or environment
88
144
  const queueStrategy = opts.queueStrategy ??
@@ -93,29 +149,57 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
93
149
 
94
150
  /**
95
151
  * Gets or creates the queue instance for persistent events.
152
+ *
153
+ * The async (BullMQ) producer is memoized process-wide so the per-request
154
+ * event bus reuses one Redis connection instead of leaking one per write
155
+ * request. The local strategy stays per-bus (no pooled connection, cwd-relative
156
+ * base dir). Set `OM_EVENTS_SHARED_PRODUCER=0` to fall back to per-bus producers.
96
157
  */
97
158
  function getQueue(): Queue<EventJobData> {
98
- if (!queue) {
99
- queue = queueStrategy === 'async'
100
- ? createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {
101
- connection: { url: getRedisUrlOrThrow('QUEUE') },
102
- })
103
- : createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'local')
159
+ if (queueStrategy !== 'async' || !isSharedProducerEnabled()) {
160
+ if (!queue) {
161
+ queue = queueStrategy === 'async'
162
+ ? createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {
163
+ connection: { url: getRedisUrlOrThrow('QUEUE') },
164
+ })
165
+ : createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'local')
166
+ }
167
+ return queue
168
+ }
169
+
170
+ const redisUrl = getRedisUrlOrThrow('QUEUE')
171
+ const registry = getProducerQueueRegistry()
172
+ const cacheKey = `async:${redisUrl}`
173
+ let shared = registry.get(cacheKey)
174
+ if (!shared) {
175
+ shared = createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {
176
+ connection: { url: redisUrl },
177
+ })
178
+ registry.set(cacheKey, shared)
179
+ registerProducerShutdownHook()
104
180
  }
105
- return queue
181
+ return shared
106
182
  }
107
183
 
108
184
  /**
109
185
  * Delivers an event to all registered in-memory handlers.
110
186
  * Supports wildcard pattern matching for event patterns.
111
187
  */
112
- async function deliver(event: string, payload: EventPayload, options?: EmitOptions): Promise<void> {
188
+ async function deliver(
189
+ event: string,
190
+ payload: EventPayload,
191
+ options?: EmitOptions,
192
+ skipPersistent = false,
193
+ ): Promise<void> {
113
194
  // Check all registered patterns (including wildcards)
114
195
  for (const [pattern, handlers] of listeners) {
115
196
  if (!matchEventPattern(event, pattern)) continue
116
197
  if (!handlers || handlers.size === 0) continue
117
198
 
118
199
  for (const handler of handlers) {
200
+ // Single-delivery: persistent subscribers are dispatched by the worker,
201
+ // so skip them inline to avoid double execution.
202
+ if (skipPersistent && persistentHandlers.has(handler)) continue
119
203
  try {
120
204
  // Pass eventName in context for wildcard handlers
121
205
  await Promise.resolve(handler(payload, {
@@ -134,11 +218,14 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
134
218
  /**
135
219
  * Registers a handler for an event.
136
220
  */
137
- function on(event: string, handler: SubscriberHandler): void {
221
+ function on(event: string, handler: SubscriberHandler, options?: { persistent?: boolean }): void {
138
222
  if (!listeners.has(event)) {
139
223
  listeners.set(event, new Set())
140
224
  }
141
225
  listeners.get(event)!.add(handler)
226
+ if (options?.persistent) {
227
+ persistentHandlers.add(handler)
228
+ }
142
229
  }
143
230
 
144
231
  /**
@@ -146,7 +233,7 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
146
233
  */
147
234
  function registerModuleSubscribers(subs: SubscriberDescriptor[]): void {
148
235
  for (const sub of subs) {
149
- on(sub.event, sub.handler)
236
+ on(sub.event, sub.handler, { persistent: sub.persistent })
150
237
  }
151
238
  }
152
239
 
@@ -169,8 +256,11 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
169
256
  }
170
257
  }
171
258
 
172
- // Always deliver to in-memory handlers first
173
- await deliver(event, payload, options)
259
+ // Deliver to in-memory handlers first. Under single-delivery, persistent
260
+ // subscribers are skipped inline on a persistent emit because the events
261
+ // worker will dispatch them from the queue.
262
+ const skipPersistentInline = Boolean(options?.persistent) && isSingleDeliveryEnabled()
263
+ await deliver(event, payload, options, skipPersistentInline)
174
264
 
175
265
  if (isBroadcastEvent(event) && hasTenantScope(payload)) {
176
266
  try {
@@ -431,4 +431,77 @@ describe('Events Worker', () => {
431
431
  errorSpy.mockRestore()
432
432
  })
433
433
  })
434
+
435
+ describe('single-delivery dispatch (OM_EVENTS_SINGLE_DELIVERY) — issue #2960', () => {
436
+ const origFlag = process.env.OM_EVENTS_SINGLE_DELIVERY
437
+
438
+ afterEach(() => {
439
+ if (origFlag === undefined) delete process.env.OM_EVENTS_SINGLE_DELIVERY
440
+ else process.env.OM_EVENTS_SINGLE_DELIVERY = origFlag
441
+ })
442
+
443
+ const createMockJob = (event: string, payload: unknown): QueuedJob<{ event: string; payload: unknown }> => ({
444
+ id: 'test-job-id',
445
+ payload: { event, payload },
446
+ createdAt: new Date().toISOString(),
447
+ })
448
+
449
+ const createMockContext = (): JobContext & { resolve: <T = unknown>(name: string) => T } => ({
450
+ jobId: 'test-job-id',
451
+ attemptNumber: 1,
452
+ queueName: 'events',
453
+ resolve: <T = unknown>(name: string): T => { throw new Error(`No mock for ${name}`) },
454
+ })
455
+
456
+ it('flag ON: dispatches wildcard persistent subscribers that exact-match never reached', async () => {
457
+ process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
458
+ clearListenerCache()
459
+ const calls: string[] = []
460
+ registerCliModules([{
461
+ id: 'm',
462
+ subscribers: [
463
+ { id: 'wildcard:persistent', event: '*', persistent: true, handler: () => { calls.push('wild') } },
464
+ ],
465
+ }])
466
+
467
+ await handle(createMockJob('any.event', {}), createMockContext())
468
+
469
+ expect(calls).toEqual(['wild'])
470
+ })
471
+
472
+ it('flag ON: excludes non-persistent subscribers from worker dispatch', async () => {
473
+ process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
474
+ clearListenerCache()
475
+ const calls: string[] = []
476
+ registerCliModules([{
477
+ id: 'm',
478
+ subscribers: [
479
+ { id: 'p', event: 'user.created', persistent: true, handler: () => { calls.push('p') } },
480
+ { id: 'e', event: 'user.created', persistent: false, handler: () => { calls.push('e') } },
481
+ ],
482
+ }])
483
+
484
+ await handle(createMockJob('user.created', {}), createMockContext())
485
+
486
+ expect(calls).toEqual(['p'])
487
+ })
488
+
489
+ it('flag OFF (default): preserves legacy exact-match dispatch and never reaches wildcards', async () => {
490
+ delete process.env.OM_EVENTS_SINGLE_DELIVERY
491
+ clearListenerCache()
492
+ const calls: string[] = []
493
+ registerCliModules([{
494
+ id: 'm',
495
+ subscribers: [
496
+ { id: 'p', event: 'user.created', persistent: true, handler: () => { calls.push('p') } },
497
+ { id: 'w', event: '*', persistent: true, handler: () => { calls.push('w') } },
498
+ ],
499
+ }])
500
+
501
+ await handle(createMockJob('user.created', {}), createMockContext())
502
+
503
+ // Legacy behavior: exact-match only, so the wildcard subscriber is not reached here.
504
+ expect(calls).toEqual(['p'])
505
+ })
506
+ })
434
507
  })
@@ -1,5 +1,7 @@
1
1
  import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'
2
2
  import { getCliModules } from '@open-mercato/shared/modules/registry'
3
+ import { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'
4
+ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
3
5
 
4
6
  export const EVENTS_QUEUE_NAME = 'events'
5
7
 
@@ -29,9 +31,43 @@ type HandlerContext = {
29
31
  type SubscriberEntry = {
30
32
  id: string
31
33
  event: string
34
+ persistent?: boolean
32
35
  handler: (payload: unknown, ctx: unknown) => Promise<void> | void
33
36
  }
34
37
 
38
+ /**
39
+ * Mirror of the event bus single-delivery flag. When enabled, the worker owns
40
+ * dispatch of every persistent subscriber and matches by pattern so wildcard
41
+ * (`event: '*'`) persistent subscribers are finally reached. Default off keeps
42
+ * the legacy exact-match dispatch of all subscribers.
43
+ */
44
+ function isSingleDeliveryEnabled(): boolean {
45
+ return parseBooleanWithDefault(process.env.OM_EVENTS_SINGLE_DELIVERY, false)
46
+ }
47
+
48
+ /**
49
+ * Resolves the subscribers to run for a queued event.
50
+ * - Legacy (flag off): exact-match lookup of every subscriber for the event.
51
+ * - Single-delivery (flag on): persistent subscribers whose pattern matches the
52
+ * event, including wildcards, so they run exactly once here instead of inline.
53
+ */
54
+ function resolveSubscribers(
55
+ listeners: Map<string, SubscriberEntry[]>,
56
+ event: string,
57
+ ): SubscriberEntry[] {
58
+ if (!isSingleDeliveryEnabled()) {
59
+ return listeners.get(event) ?? []
60
+ }
61
+ const matched: SubscriberEntry[] = []
62
+ for (const [pattern, subs] of listeners) {
63
+ if (!matchEventPattern(event, pattern)) continue
64
+ for (const sub of subs) {
65
+ if (sub.persistent) matched.push(sub)
66
+ }
67
+ }
68
+ return matched
69
+ }
70
+
35
71
  // Cached listener map - built once on first use
36
72
  let cachedListenerMap: Map<string, SubscriberEntry[]> | null = null
37
73
 
@@ -75,7 +111,7 @@ export default async function handle(
75
111
  ): Promise<void> {
76
112
  const { event, payload, options } = job.payload
77
113
  const listeners = getListenerMap()
78
- const subscribers = listeners.get(event)
114
+ const subscribers = resolveSubscribers(listeners, event)
79
115
 
80
116
  if (!subscribers || subscribers.length === 0) return
81
117
 
package/src/types.ts CHANGED
@@ -43,6 +43,12 @@ export type SubscriberDescriptor = {
43
43
  id: string
44
44
  /** Event name to subscribe to */
45
45
  event: string
46
+ /**
47
+ * Whether this subscriber is dispatched through the persistent events queue
48
+ * (events worker) rather than inline. Consulted by the `OM_EVENTS_SINGLE_DELIVERY`
49
+ * single-delivery path to decide which subscribers run inline vs in the worker.
50
+ */
51
+ persistent?: boolean
46
52
  /** Handler function */
47
53
  handler: SubscriberHandler
48
54
  }
@@ -100,8 +106,10 @@ export interface EventBus {
100
106
  *
101
107
  * @param event - Event name to listen for
102
108
  * @param handler - Handler function
109
+ * @param options - Optional registration options. `persistent: true` marks the
110
+ * handler as worker-dispatched so the single-delivery path skips it inline.
103
111
  */
104
- on(event: string, handler: SubscriberHandler): void
112
+ on(event: string, handler: SubscriberHandler, options?: { persistent?: boolean }): void
105
113
 
106
114
  /**
107
115
  * Register multiple module subscribers at once.