@open-mercato/events 0.6.6-develop.5586.1.c9ed1d68a8 → 0.6.6-develop.5594.1.30cd738303

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.
@@ -1,2 +1,2 @@
1
- [build:events] found 8 entry points
1
+ [build:events] found 9 entry points
2
2
  [build:events] built successfully
package/AGENTS.md CHANGED
@@ -76,15 +76,18 @@ 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`)
79
+ ### Persistent delivery: single-delivery (`OM_EVENTS_SINGLE_DELIVERY`, default ON)
80
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.
81
+ Single-delivery is the default. A persistent emit is delivered on exactly one path:
82
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.
83
+ - the bus skips inline delivery of **persistent-marked** subscribers on a persistent emit (ephemeral subscribers still run inline — read-your-writes paths like `query_index.upsert_one` are `persistent: false` and are unaffected);
84
+ - the events worker dispatches **persistent** subscribers via `matchEventPattern`, so wildcard (`event: '*'`) persistent subscribers (workflow triggers, business-rules CRUD trigger, webhook outbound dispatch) are reached.
86
85
 
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.
86
+ This avoids the legacy dual-dispatch (set `OM_EVENTS_SINGLE_DELIVERY=false` to opt back in) where persistent emits ran inline **and** in the worker double-running exact-match persistent subscribers (duplicate notifications/emails) and never reaching wildcard persistent subscribers in the worker. Both halves read the same env var and MUST agree within a process.
87
+
88
+ **Worker guard (silent-loss protection).** With single-delivery on, persistent subscribers run ONLY in the worker. The server bootstrap (`mercato server`/`start`) reconciles the flag against worker availability: if a process auto-spawns no events worker (`AUTO_SPAWN_WORKERS=off`) and `OM_EVENTS_EXTERNAL_WORKER` is not set, it logs a loud warning and falls back to inline dual-dispatch so persistent side effects are never silently dropped. Run an events worker out-of-process and set `OM_EVENTS_EXTERNAL_WORKER=true` to keep single-delivery without auto-spawn. Transient worker downtime is not a concern — the durable queue holds jobs until a worker returns; the guard only catches the "no worker at all" misconfiguration. Reconcile logic: `reconcileSingleDelivery` in `@open-mercato/events/single-delivery` (mirrored for the CLI in `packages/cli/src/lib/events-single-delivery.ts`).
89
+
90
+ **Enqueue-only emits.** Pass `{ persistent: true, deliverInline: false }` to hand a heavy persistent job (e.g. a full query-index rebuild) to the durable queue without ANY inline delivery, independent of the single-delivery flag. Only use it when every subscriber to the event is `persistent: true`. This is the "Ask First: changing persistent delivery semantics" surface — coordinate before altering these defaults.
88
91
 
89
92
  ## Queue Integration
90
93
 
package/dist/bus.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createQueue } from "@open-mercato/queue";
2
2
  import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
3
+ import { isSingleDeliveryRequested } from "./single-delivery.js";
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";
@@ -7,7 +8,7 @@ import { registerCrossProcessEventListener } from "./bridge.js";
7
8
  import { publishCrossProcessEvent } from "./bridge.js";
8
9
  const EVENTS_QUEUE_NAME = "events";
9
10
  function isSingleDeliveryEnabled() {
10
- return parseBooleanWithDefault(process.env.OM_EVENTS_SINGLE_DELIVERY, false);
11
+ return isSingleDeliveryRequested();
11
12
  }
12
13
  const GLOBAL_EVENT_TAPS_KEY = "__openMercatoEventBusGlobalTaps__";
13
14
  function hasTenantScope(payload) {
@@ -126,8 +127,11 @@ function createEventBus(opts) {
126
127
  console.error(`[events] Global tap error for "${event}":`, error);
127
128
  }
128
129
  }
129
- const skipPersistentInline = Boolean(options?.persistent) && isSingleDeliveryEnabled();
130
- await deliver(event, payload, options, skipPersistentInline);
130
+ const enqueueOnly = Boolean(options?.persistent) && options?.deliverInline === false;
131
+ if (!enqueueOnly) {
132
+ const skipPersistentInline = Boolean(options?.persistent) && isSingleDeliveryEnabled();
133
+ await deliver(event, payload, options, skipPersistentInline);
134
+ }
131
135
  if (isBroadcastEvent(event) && hasTenantScope(payload)) {
132
136
  try {
133
137
  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 { 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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",
4
+ "sourcesContent": ["import { createQueue } from '@open-mercato/queue'\nimport type { Queue } from '@open-mercato/queue'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { isSingleDeliveryRequested } from './single-delivery'\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. Defaults ON; the server bootstrap\n * reconciles the env against worker availability (and may rewrite it to `false`\n * for a worker-less process). Both the bus and the events worker read the same\n * (possibly reconciled) env var, so they always agree within a process.\n */\nfunction isSingleDeliveryEnabled(): boolean {\n return isSingleDeliveryRequested()\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 //\n // `deliverInline: false` on a persistent emit is enqueue-only: skip inline\n // delivery entirely so a heavy persistent job runs solely in the events\n // worker, off the caller's request path (the queued enqueue below still\n // happens). Without this, a persistent emit dual-dispatches by default and\n // runs the subscriber inline in the caller's request too.\n const enqueueOnly = Boolean(options?.persistent) && options?.deliverInline === false\n if (!enqueueOnly) {\n const skipPersistentInline = Boolean(options?.persistent) && isSingleDeliveryEnabled()\n await deliver(event, payload, options, skipPersistentInline)\n }\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": "AAAA,SAAS,mBAAmB;AAE5B,SAAS,+BAA+B;AACxC,SAAS,iCAAiC;AAC1C,SAAS,yBAAyB;AAClC,SAAS,0BAA0B;AACnC,SAAS,wBAAwB;AACjC,SAAS,yCAAyC;AAClD,SAAS,gCAAgC;AAWzC,MAAM,oBAAoB;AAW1B,SAAS,0BAAmC;AAC1C,SAAO,0BAA0B;AACnC;AAGA,MAAM,wBAAwB;AAE9B,SAAS,eAAe,SAAgC;AACtD,SAAO,OAAQ,SAAqC,aAAa,YAC5D,OAAQ,QAAoC,QAAQ,EAAE,KAAK,EAAE,SAAS;AAC7E;AAEA,SAAS,qBAA0C;AACjD,QAAM,WAAY,WAAuC,qBAAqB;AAC9E,MAAI,oBAAoB,KAAK;AAC3B,WAAO;AAAA,EACT;AACA,QAAM,UAAU,oBAAI,IAAoB;AACvC,EAAC,WAAuC,qBAAqB,IAAI;AAClE,SAAO;AACT;AAEO,SAAS,uBAAuB,SAAqC;AAC1E,QAAM,OAAO,mBAAmB;AAChC,OAAK,IAAI,OAAO;AAChB,SAAO,MAAM;AACX,SAAK,OAAO,OAAO;AAAA,EACrB;AACF;AAkBA,MAAM,4BAA4B;AAClC,MAAM,+BAA+B;AAErC,SAAS,0BAAmC;AAC1C,SAAO,wBAAwB,QAAQ,IAAI,2BAA2B,IAAI;AAC5E;AAEA,SAAS,2BAA6D;AACpE,QAAM,WAAY,WAAuC,yBAAyB;AAClF,MAAI,oBAAoB,KAAK;AAC3B,WAAO;AAAA,EACT;AACA,QAAM,UAAU,oBAAI,IAAiC;AACpD,EAAC,WAAuC,yBAAyB,IAAI;AACtE,SAAO;AACT;AAEA,SAAS,+BAAqC;AAC5C,MAAK,WAAuC,4BAA4B,EAAG;AAC3E,QAAM,WAAW,MAAM;AACrB,UAAM,WAAW,yBAAyB;AAC1C,eAAW,eAAe,SAAS,OAAO,GAAG;AAC3C,cAAQ,QAAQ,YAAY,MAAM,CAAC,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AAAA,IACrD;AACA,aAAS,MAAM;AAAA,EACjB;AACA,UAAQ,KAAK,WAAW,QAAQ;AAChC,UAAQ,KAAK,UAAU,QAAQ;AAC9B,EAAC,WAAuC,4BAA4B,IAAI;AAC3E;AAgCO,SAAS,eAAe,MAAkC;AAE/D,QAAM,YAAY,oBAAI,IAAoC;AAG1D,QAAM,qBAAqB,oBAAI,IAAuB;AAGtD,QAAM,gBAAgB,KAAK,kBACxB,QAAQ,IAAI,mBAAmB,UAAU,UAAU;AAGtD,MAAI,QAAoC;AAUxC,WAAS,WAAgC;AACvC,QAAI,kBAAkB,WAAW,CAAC,wBAAwB,GAAG;AAC3D,UAAI,CAAC,OAAO;AACV,gBAAQ,kBAAkB,UACtB,YAA0B,mBAAmB,SAAS;AAAA,UACpD,YAAY,EAAE,KAAK,mBAAmB,OAAO,EAAE;AAAA,QACjD,CAAC,IACD,YAA0B,mBAAmB,OAAO;AAAA,MAC1D;AACA,aAAO;AAAA,IACT;AAEA,UAAM,WAAW,mBAAmB,OAAO;AAC3C,UAAM,WAAW,yBAAyB;AAC1C,UAAM,WAAW,SAAS,QAAQ;AAClC,QAAI,SAAS,SAAS,IAAI,QAAQ;AAClC,QAAI,CAAC,QAAQ;AACX,eAAS,YAA0B,mBAAmB,SAAS;AAAA,QAC7D,YAAY,EAAE,KAAK,SAAS;AAAA,MAC9B,CAAC;AACD,eAAS,IAAI,UAAU,MAAM;AAC7B,mCAA6B;AAAA,IAC/B;AACA,WAAO;AAAA,EACT;AAMA,iBAAe,QACb,OACA,SACA,SACA,iBAAiB,OACF;AAEf,eAAW,CAAC,SAAS,QAAQ,KAAK,WAAW;AAC3C,UAAI,CAAC,kBAAkB,OAAO,OAAO,EAAG;AACxC,UAAI,CAAC,YAAY,SAAS,SAAS,EAAG;AAEtC,iBAAW,WAAW,UAAU;AAG9B,YAAI,kBAAkB,mBAAmB,IAAI,OAAO,EAAG;AACvD,YAAI;AAEF,gBAAM,QAAQ,QAAQ,QAAQ,SAAS;AAAA,YACrC,SAAS,KAAK;AAAA,YACd,WAAW;AAAA,YACX,UAAU,SAAS,YAAY;AAAA,YAC/B,gBAAgB,SAAS,kBAAkB;AAAA,UAC7C,CAAC,CAAC;AAAA,QACJ,SAAS,OAAO;AACd,kBAAQ,MAAM,+BAA+B,KAAK,gBAAgB,OAAO,OAAO,KAAK;AAAA,QACvF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAKA,WAAS,GAAG,OAAe,SAA4B,SAA0C;AAC/F,QAAI,CAAC,UAAU,IAAI,KAAK,GAAG;AACzB,gBAAU,IAAI,OAAO,oBAAI,IAAI,CAAC;AAAA,IAChC;AACA,cAAU,IAAI,KAAK,EAAG,IAAI,OAAO;AACjC,QAAI,SAAS,YAAY;AACvB,yBAAmB,IAAI,OAAO;AAAA,IAChC;AAAA,EACF;AAKA,WAAS,0BAA0B,MAAoC;AACrE,eAAW,OAAO,MAAM;AACtB,SAAG,IAAI,OAAO,IAAI,SAAS,EAAE,YAAY,IAAI,WAAW,CAAC;AAAA,IAC3D;AAAA,EACF;AAOA,iBAAe,KACb,OACA,SACA,SACe;AACf,UAAM,OAAO,mBAAmB;AAChC,eAAW,OAAO,MAAM;AACtB,UAAI;AACF,cAAM,QAAQ,QAAQ,IAAI,OAAO,SAAS,OAAO,CAAC;AAAA,MACpD,SAAS,OAAO;AACd,gBAAQ,MAAM,kCAAkC,KAAK,MAAM,KAAK;AAAA,MAClE;AAAA,IACF;AAWA,UAAM,cAAc,QAAQ,SAAS,UAAU,KAAK,SAAS,kBAAkB;AAC/E,QAAI,CAAC,aAAa;AAChB,YAAM,uBAAuB,QAAQ,SAAS,UAAU,KAAK,wBAAwB;AACrF,YAAM,QAAQ,OAAO,SAAS,SAAS,oBAAoB;AAAA,IAC7D;AAEA,QAAI,iBAAiB,KAAK,KAAK,eAAe,OAAO,GAAG;AACtD,UAAI;AACF,cAAM,yBAAyB,OAAO,SAAS,OAAO;AAAA,MACxD,SAAS,OAAO;AACd,gBAAQ,MAAM,6CAA6C,KAAK,MAAM,KAAK;AAAA,MAC7E;AAAA,IACF;AAGA,QAAI,SAAS,YAAY;AACvB,YAAM,IAAI,SAAS;AACnB,YAAM,EAAE,QAAQ,EAAE,OAAO,SAAS,QAAQ,CAAC;AAAA,IAC7C;AAAA,EACF;AAKA,iBAAe,aAA2C;AACxD,UAAM,IAAI,SAAS;AACnB,WAAO,EAAE,MAAM;AAAA,EACjB;AAGA,QAAM,YAAY;AAElB,SAAO;AAAA,IACL;AAAA,IACA;AAAA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
@@ -1,6 +1,6 @@
1
1
  import { getCliModules } from "@open-mercato/shared/modules/registry";
2
2
  import { matchEventPattern } from "@open-mercato/shared/lib/events/patterns";
3
- import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
3
+ import { isSingleDeliveryRequested } from "../../../single-delivery.js";
4
4
  const EVENTS_QUEUE_NAME = "events";
5
5
  const DEFAULT_CONCURRENCY = 1;
6
6
  const envConcurrency = process.env.WORKERS_EVENTS_CONCURRENCY;
@@ -9,7 +9,7 @@ const metadata = {
9
9
  concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY
10
10
  };
11
11
  function isSingleDeliveryEnabled() {
12
- return parseBooleanWithDefault(process.env.OM_EVENTS_SINGLE_DELIVERY, false);
12
+ return isSingleDeliveryRequested();
13
13
  }
14
14
  function resolveSubscribers(listeners, event) {
15
15
  if (!isSingleDeliveryEnabled()) {
@@ -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'\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;",
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 { isSingleDeliveryRequested } from '../../../single-delivery'\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. Defaults ON;\n * reconciled by the server bootstrap against worker availability and read from\n * the same env var as the bus so the two always agree within a process.\n */\nfunction isSingleDeliveryEnabled(): boolean {\n return isSingleDeliveryRequested()\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,iCAAiC;AAEnC,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;AA+BA,SAAS,0BAAmC;AAC1C,SAAO,0BAA0B;AACnC;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
  }
@@ -0,0 +1,25 @@
1
+ import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
2
+ const EVENTS_SINGLE_DELIVERY_ENV = "OM_EVENTS_SINGLE_DELIVERY";
3
+ const EVENTS_EXTERNAL_WORKER_ENV = "OM_EVENTS_EXTERNAL_WORKER";
4
+ function isSingleDeliveryRequested(env = process.env) {
5
+ return parseBooleanWithDefault(env[EVENTS_SINGLE_DELIVERY_ENV], true);
6
+ }
7
+ function isExternalWorkerAcknowledged(env = process.env) {
8
+ return parseBooleanWithDefault(env[EVENTS_EXTERNAL_WORKER_ENV], false);
9
+ }
10
+ function reconcileSingleDelivery(input) {
11
+ if (!input.requested) return { effective: false };
12
+ if (input.workersAvailable) return { effective: true };
13
+ return {
14
+ effective: false,
15
+ warning: `[events] ${EVENTS_SINGLE_DELIVERY_ENV} is on (default) but this process auto-spawns no events worker (AUTO_SPAWN_WORKERS=off) and ${EVENTS_EXTERNAL_WORKER_ENV} is not set. Persistent subscribers would be skipped inline with nothing to drain the queue, silently dropping notifications, queued emails, and indexing. Falling back to legacy inline dual-dispatch for safety. To keep single-delivery, run an events worker (\`mercato queue worker events\`) and set ${EVENTS_EXTERNAL_WORKER_ENV}=true, or enable AUTO_SPAWN_WORKERS.`
16
+ };
17
+ }
18
+ export {
19
+ EVENTS_EXTERNAL_WORKER_ENV,
20
+ EVENTS_SINGLE_DELIVERY_ENV,
21
+ isExternalWorkerAcknowledged,
22
+ isSingleDeliveryRequested,
23
+ reconcileSingleDelivery
24
+ };
25
+ //# sourceMappingURL=single-delivery.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/single-delivery.ts"],
4
+ "sourcesContent": ["import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\n\n/** Env var that toggles single-path persistent delivery. Defaults ON (see below). */\nexport const EVENTS_SINGLE_DELIVERY_ENV = 'OM_EVENTS_SINGLE_DELIVERY'\n/**\n * Operator acknowledgment that an events worker runs OUT OF PROCESS (separate\n * `mercato queue worker` container/process), so the server bootstrap must not\n * disable single-delivery just because it does not auto-spawn workers itself.\n */\nexport const EVENTS_EXTERNAL_WORKER_ENV = 'OM_EVENTS_EXTERNAL_WORKER'\n\ntype EnvSource = Record<string, string | undefined>\n\n/**\n * Whether single-path persistent delivery is requested.\n *\n * Default ON: a persistent emit is delivered on exactly one path \u2014 persistent\n * subscribers run in the events worker (matched by pattern, so wildcard\n * persistent subscribers are reached), ephemeral subscribers still run inline.\n * This is the correct behavior; the legacy dual-dispatch (inline AND worker)\n * double-ran exact-match persistent subscribers and never reached wildcard\n * persistent subscribers in the worker. Set the env to a false token to opt back\n * into legacy dual-dispatch.\n *\n * The server bootstrap reconciles this against worker availability (see\n * {@link reconcileSingleDelivery}) and may rewrite the env to `false` for a\n * process that would otherwise skip inline delivery with no worker to drain the\n * queue. The bus and the events worker both read the (possibly reconciled) env,\n * so they always agree within a process.\n */\nexport function isSingleDeliveryRequested(env: EnvSource = process.env): boolean {\n return parseBooleanWithDefault(env[EVENTS_SINGLE_DELIVERY_ENV], true)\n}\n\nexport function isExternalWorkerAcknowledged(env: EnvSource = process.env): boolean {\n return parseBooleanWithDefault(env[EVENTS_EXTERNAL_WORKER_ENV], false)\n}\n\nexport type SingleDeliveryReconciliation = {\n /** The value the process should actually use. */\n effective: boolean\n /** Set when single-delivery was requested but disabled for safety. */\n warning?: string\n}\n\n/**\n * Guards the default-on single-delivery against the silent-loss failure mode:\n * with single-delivery on, persistent subscribers are skipped inline and ONLY\n * the events worker dispatches them. If no worker drains the queue, those\n * persistent side effects (notifications, queued emails, indexing) never run.\n *\n * The durable queue already covers transient worker downtime \u2014 a job persists\n * and drains when a worker returns. The dangerous case is a process configured\n * to run with NO events worker at all (auto-spawn off and no external worker).\n * For that case this fails safe: it disables single-delivery so persistent\n * subscribers run inline (dual-dispatch) and side effects are never dropped,\n * and surfaces a loud warning telling the operator how to opt back in.\n */\nexport function reconcileSingleDelivery(input: {\n requested: boolean\n workersAvailable: boolean\n}): SingleDeliveryReconciliation {\n if (!input.requested) return { effective: false }\n if (input.workersAvailable) return { effective: true }\n return {\n effective: false,\n warning:\n `[events] ${EVENTS_SINGLE_DELIVERY_ENV} is on (default) but this process auto-spawns no events worker ` +\n `(AUTO_SPAWN_WORKERS=off) and ${EVENTS_EXTERNAL_WORKER_ENV} is not set. Persistent subscribers would be ` +\n `skipped inline with nothing to drain the queue, silently dropping notifications, queued emails, and ` +\n `indexing. Falling back to legacy inline dual-dispatch for safety. To keep single-delivery, run an events ` +\n `worker (\\`mercato queue worker events\\`) and set ${EVENTS_EXTERNAL_WORKER_ENV}=true, or enable ` +\n `AUTO_SPAWN_WORKERS.`,\n }\n}\n"],
5
+ "mappings": "AAAA,SAAS,+BAA+B;AAGjC,MAAM,6BAA6B;AAMnC,MAAM,6BAA6B;AAqBnC,SAAS,0BAA0B,MAAiB,QAAQ,KAAc;AAC/E,SAAO,wBAAwB,IAAI,0BAA0B,GAAG,IAAI;AACtE;AAEO,SAAS,6BAA6B,MAAiB,QAAQ,KAAc;AAClF,SAAO,wBAAwB,IAAI,0BAA0B,GAAG,KAAK;AACvE;AAsBO,SAAS,wBAAwB,OAGP;AAC/B,MAAI,CAAC,MAAM,UAAW,QAAO,EAAE,WAAW,MAAM;AAChD,MAAI,MAAM,iBAAkB,QAAO,EAAE,WAAW,KAAK;AACrD,SAAO;AAAA,IACL,WAAW;AAAA,IACX,SACE,YAAY,0BAA0B,+FACN,0BAA0B,8SAGN,0BAA0B;AAAA,EAElF;AACF;",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/events",
3
- "version": "0.6.6-develop.5586.1.c9ed1d68a8",
3
+ "version": "0.6.6-develop.5594.1.30cd738303",
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.6-develop.5586.1.c9ed1d68a8",
36
- "@open-mercato/shared": "0.6.6-develop.5586.1.c9ed1d68a8",
35
+ "@open-mercato/queue": "0.6.6-develop.5594.1.30cd738303",
36
+ "@open-mercato/shared": "0.6.6-develop.5594.1.30cd738303",
37
37
  "pg": "8.21.0"
38
38
  },
39
39
  "devDependencies": {
@@ -0,0 +1,44 @@
1
+ import {
2
+ isSingleDeliveryRequested,
3
+ isExternalWorkerAcknowledged,
4
+ reconcileSingleDelivery,
5
+ } from '@open-mercato/events/single-delivery'
6
+
7
+ describe('isSingleDeliveryRequested', () => {
8
+ it('defaults ON when unset', () => {
9
+ expect(isSingleDeliveryRequested({})).toBe(true)
10
+ })
11
+
12
+ it('honors an explicit false token', () => {
13
+ expect(isSingleDeliveryRequested({ OM_EVENTS_SINGLE_DELIVERY: 'false' })).toBe(false)
14
+ expect(isSingleDeliveryRequested({ OM_EVENTS_SINGLE_DELIVERY: '0' })).toBe(false)
15
+ })
16
+ })
17
+
18
+ describe('isExternalWorkerAcknowledged', () => {
19
+ it('defaults off and reads truthy tokens', () => {
20
+ expect(isExternalWorkerAcknowledged({})).toBe(false)
21
+ expect(isExternalWorkerAcknowledged({ OM_EVENTS_EXTERNAL_WORKER: 'true' })).toBe(true)
22
+ })
23
+ })
24
+
25
+ describe('reconcileSingleDelivery', () => {
26
+ it('stays on when a worker is available', () => {
27
+ expect(reconcileSingleDelivery({ requested: true, workersAvailable: true })).toEqual({
28
+ effective: true,
29
+ })
30
+ })
31
+
32
+ it('falls back to inline with a warning when no worker is available', () => {
33
+ const result = reconcileSingleDelivery({ requested: true, workersAvailable: false })
34
+ expect(result.effective).toBe(false)
35
+ expect(result.warning).toContain('OM_EVENTS_SINGLE_DELIVERY')
36
+ expect(result.warning).toContain('OM_EVENTS_EXTERNAL_WORKER')
37
+ })
38
+
39
+ it('stays off (no warning) when not requested', () => {
40
+ expect(reconcileSingleDelivery({ requested: false, workersAvailable: false })).toEqual({
41
+ effective: false,
42
+ })
43
+ })
44
+ })
@@ -38,15 +38,31 @@ describe('Event bus single-delivery (OM_EVENTS_SINGLE_DELIVERY)', () => {
38
38
  return { id, event, persistent, handler: () => { sink.push(id) } }
39
39
  }
40
40
 
41
- test('flag OFF (default): a persistent subscriber still runs inline on a persistent emit', async () => {
41
+ test('default (unset): a persistent subscriber is skipped inline (single-delivery is default-on)', async () => {
42
42
  delete process.env.OM_EVENTS_SINGLE_DELIVERY
43
43
  const calls: string[] = []
44
44
  const bus = createEventBus({ resolve: ((name: string) => name) as never })
45
+ bus.registerModuleSubscribers([
46
+ makeSub('persistent-sub', 'demo', true, calls),
47
+ makeSub('ephemeral-sub', 'demo', false, calls),
48
+ ])
49
+
50
+ await bus.emit('demo', { a: 1 }, { persistent: true })
51
+
52
+ // Default-on: the persistent subscriber is deferred to the worker; only the
53
+ // ephemeral subscriber runs inline.
54
+ expect(calls).toEqual(['ephemeral-sub'])
55
+ })
56
+
57
+ test('flag explicitly OFF (legacy opt-out): a persistent subscriber still runs inline', async () => {
58
+ process.env.OM_EVENTS_SINGLE_DELIVERY = 'false'
59
+ const calls: string[] = []
60
+ const bus = createEventBus({ resolve: ((name: string) => name) as never })
45
61
  bus.registerModuleSubscribers([makeSub('persistent-sub', 'demo', true, calls)])
46
62
 
47
63
  await bus.emit('demo', { a: 1 }, { persistent: true })
48
64
 
49
- // Legacy dual-dispatch: inline delivery is preserved when the flag is off.
65
+ // Legacy dual-dispatch: inline delivery is preserved when explicitly opted out.
50
66
  expect(calls).toEqual(['persistent-sub'])
51
67
  })
52
68
 
@@ -93,3 +109,79 @@ describe('Event bus single-delivery (OM_EVENTS_SINGLE_DELIVERY)', () => {
93
109
  expect(list.length).toBeGreaterThanOrEqual(1)
94
110
  })
95
111
  })
112
+
113
+ /**
114
+ * Regression coverage for the onboarding stall: `enqueueQueryIndexRebuild` emits
115
+ * a persistent `query_index.reindex` with `deliverInline: false` so the heavy
116
+ * rebuild runs solely in the events worker, never inline in the onboarding
117
+ * request. A bare `{ persistent: true }` dual-dispatches and ran the reindex
118
+ * inline (reusing the request's committed em), which is exactly the bug.
119
+ */
120
+ describe('Event bus enqueue-only persistent emit (deliverInline: false)', () => {
121
+ const origCwd = process.cwd()
122
+ const origFlag = process.env.OM_EVENTS_SINGLE_DELIVERY
123
+ let tmp: string
124
+
125
+ beforeEach(() => {
126
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'events-enqueue-only-'))
127
+ process.chdir(tmp)
128
+ delete process.env.QUEUE_STRATEGY
129
+ delete process.env.EVENTS_STRATEGY
130
+ // Prove the skip is driven by deliverInline, not the single-delivery flag.
131
+ delete process.env.OM_EVENTS_SINGLE_DELIVERY
132
+ })
133
+
134
+ afterEach(() => {
135
+ process.chdir(origCwd)
136
+ if (origFlag === undefined) delete process.env.OM_EVENTS_SINGLE_DELIVERY
137
+ else process.env.OM_EVENTS_SINGLE_DELIVERY = origFlag
138
+ try { fs.rmSync(tmp, { recursive: true, force: true }) } catch {}
139
+ })
140
+
141
+ function pushHandler(id: string, event: string, persistent: boolean, sink: string[]): SubscriberDescriptor {
142
+ return { id, event, persistent, handler: () => { sink.push(id) } }
143
+ }
144
+
145
+ test('skips inline delivery of every subscriber but still enqueues for the worker', async () => {
146
+ const queuePath = path.join(path.resolve('.mercato/queue', 'events'), 'queue.json')
147
+ const calls: string[] = []
148
+ const bus = createEventBus({ resolve: ((name: string) => name) as never })
149
+ bus.registerModuleSubscribers([
150
+ pushHandler('persistent-sub', 'query_index.reindex', true, calls),
151
+ pushHandler('ephemeral-sub', 'query_index.reindex', false, calls),
152
+ ])
153
+
154
+ await bus.emit('query_index.reindex', { entityType: 'catalog:product' }, { persistent: true, deliverInline: false })
155
+
156
+ // Nothing ran inline — the worker is the sole dispatcher.
157
+ expect(calls).toEqual([])
158
+ const list = JSON.parse(fs.readFileSync(queuePath, 'utf8'))
159
+ expect(Array.isArray(list)).toBe(true)
160
+ expect(list.length).toBeGreaterThanOrEqual(1)
161
+ })
162
+
163
+ test('deliverInline: false has no effect on a non-persistent emit', async () => {
164
+ const calls: string[] = []
165
+ const bus = createEventBus({ resolve: ((name: string) => name) as never })
166
+ bus.registerModuleSubscribers([pushHandler('sub', 'demo', false, calls)])
167
+
168
+ // Not persistent → nothing is enqueued, so inline is the only path and runs.
169
+ await bus.emit('demo', { a: 1 }, { deliverInline: false })
170
+
171
+ expect(calls).toEqual(['sub'])
172
+ })
173
+
174
+ test('deliverInline unset under legacy opt-out preserves inline dual-dispatch', async () => {
175
+ // Isolate the deliverInline default from the single-delivery default by
176
+ // explicitly opting out: a persistent emit with deliverInline unset still
177
+ // runs inline (legacy dual-dispatch).
178
+ process.env.OM_EVENTS_SINGLE_DELIVERY = 'false'
179
+ const calls: string[] = []
180
+ const bus = createEventBus({ resolve: ((name: string) => name) as never })
181
+ bus.registerModuleSubscribers([pushHandler('persistent-sub', 'demo', true, calls)])
182
+
183
+ await bus.emit('demo', { a: 1 }, { persistent: true })
184
+
185
+ expect(calls).toEqual(['persistent-sub'])
186
+ })
187
+ })
package/src/bus.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createQueue } from '@open-mercato/queue'
2
2
  import type { Queue } from '@open-mercato/queue'
3
3
  import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
4
+ import { isSingleDeliveryRequested } from './single-delivery'
4
5
  import { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'
5
6
  import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
6
7
  import { isBroadcastEvent } from '@open-mercato/shared/modules/events'
@@ -22,12 +23,13 @@ const EVENTS_QUEUE_NAME = 'events'
22
23
  * When enabled, a persistent emit delivers each subscriber on exactly one path:
23
24
  * persistent-marked subscribers are skipped inline (the events worker dispatches
24
25
  * 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.
26
+ * ephemeral subscribers keep running inline. Defaults ON; the server bootstrap
27
+ * reconciles the env against worker availability (and may rewrite it to `false`
28
+ * for a worker-less process). Both the bus and the events worker read the same
29
+ * (possibly reconciled) env var, so they always agree within a process.
28
30
  */
29
31
  function isSingleDeliveryEnabled(): boolean {
30
- return parseBooleanWithDefault(process.env.OM_EVENTS_SINGLE_DELIVERY, false)
32
+ return isSingleDeliveryRequested()
31
33
  }
32
34
 
33
35
  type GlobalEventTap = (event: string, payload: EventPayload, options?: EmitOptions) => void | Promise<void>
@@ -259,8 +261,17 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
259
261
  // Deliver to in-memory handlers first. Under single-delivery, persistent
260
262
  // subscribers are skipped inline on a persistent emit because the events
261
263
  // worker will dispatch them from the queue.
262
- const skipPersistentInline = Boolean(options?.persistent) && isSingleDeliveryEnabled()
263
- await deliver(event, payload, options, skipPersistentInline)
264
+ //
265
+ // `deliverInline: false` on a persistent emit is enqueue-only: skip inline
266
+ // delivery entirely so a heavy persistent job runs solely in the events
267
+ // worker, off the caller's request path (the queued enqueue below still
268
+ // happens). Without this, a persistent emit dual-dispatches by default and
269
+ // runs the subscriber inline in the caller's request too.
270
+ const enqueueOnly = Boolean(options?.persistent) && options?.deliverInline === false
271
+ if (!enqueueOnly) {
272
+ const skipPersistentInline = Boolean(options?.persistent) && isSingleDeliveryEnabled()
273
+ await deliver(event, payload, options, skipPersistentInline)
274
+ }
264
275
 
265
276
  if (isBroadcastEvent(event) && hasTenantScope(payload)) {
266
277
  try {
@@ -33,6 +33,19 @@ describe('Events Worker', () => {
33
33
  })
34
34
 
35
35
  describe('handle', () => {
36
+ // These tests exercise the legacy exact-match dispatch (the worker runs every
37
+ // matching subscriber, persistent or not). Single-delivery (now default-on)
38
+ // dispatches only persistent subscribers in the worker, so pin the legacy
39
+ // path here; single-delivery semantics are covered in the sibling describe.
40
+ const ORIG_SINGLE_DELIVERY = process.env.OM_EVENTS_SINGLE_DELIVERY
41
+ beforeEach(() => {
42
+ process.env.OM_EVENTS_SINGLE_DELIVERY = 'false'
43
+ })
44
+ afterEach(() => {
45
+ if (ORIG_SINGLE_DELIVERY === undefined) delete process.env.OM_EVENTS_SINGLE_DELIVERY
46
+ else process.env.OM_EVENTS_SINGLE_DELIVERY = ORIG_SINGLE_DELIVERY
47
+ })
48
+
36
49
  const createMockJob = (
37
50
  event: string,
38
51
  payload: unknown,
@@ -486,7 +499,7 @@ describe('Events Worker', () => {
486
499
  expect(calls).toEqual(['p'])
487
500
  })
488
501
 
489
- it('flag OFF (default): preserves legacy exact-match dispatch and never reaches wildcards', async () => {
502
+ it('default (unset): dispatches wildcard persistent subscribers (single-delivery is default-on)', async () => {
490
503
  delete process.env.OM_EVENTS_SINGLE_DELIVERY
491
504
  clearListenerCache()
492
505
  const calls: string[] = []
@@ -500,6 +513,25 @@ describe('Events Worker', () => {
500
513
 
501
514
  await handle(createMockJob('user.created', {}), createMockContext())
502
515
 
516
+ // Default-on: pattern dispatch reaches both the exact-match and the wildcard
517
+ // persistent subscriber.
518
+ expect(calls.sort()).toEqual(['p', 'w'])
519
+ })
520
+
521
+ it('flag explicitly OFF (legacy opt-out): preserves exact-match dispatch and never reaches wildcards', async () => {
522
+ process.env.OM_EVENTS_SINGLE_DELIVERY = 'false'
523
+ clearListenerCache()
524
+ const calls: string[] = []
525
+ registerCliModules([{
526
+ id: 'm',
527
+ subscribers: [
528
+ { id: 'p', event: 'user.created', persistent: true, handler: () => { calls.push('p') } },
529
+ { id: 'w', event: '*', persistent: true, handler: () => { calls.push('w') } },
530
+ ],
531
+ }])
532
+
533
+ await handle(createMockJob('user.created', {}), createMockContext())
534
+
503
535
  // Legacy behavior: exact-match only, so the wildcard subscriber is not reached here.
504
536
  expect(calls).toEqual(['p'])
505
537
  })
@@ -1,7 +1,7 @@
1
1
  import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'
2
2
  import { getCliModules } from '@open-mercato/shared/modules/registry'
3
3
  import { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'
4
- import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
4
+ import { isSingleDeliveryRequested } from '../../../single-delivery'
5
5
 
6
6
  export const EVENTS_QUEUE_NAME = 'events'
7
7
 
@@ -38,11 +38,12 @@ type SubscriberEntry = {
38
38
  /**
39
39
  * Mirror of the event bus single-delivery flag. When enabled, the worker owns
40
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.
41
+ * (`event: '*'`) persistent subscribers are finally reached. Defaults ON;
42
+ * reconciled by the server bootstrap against worker availability and read from
43
+ * the same env var as the bus so the two always agree within a process.
43
44
  */
44
45
  function isSingleDeliveryEnabled(): boolean {
45
- return parseBooleanWithDefault(process.env.OM_EVENTS_SINGLE_DELIVERY, false)
46
+ return isSingleDeliveryRequested()
46
47
  }
47
48
 
48
49
  /**
@@ -0,0 +1,75 @@
1
+ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
2
+
3
+ /** Env var that toggles single-path persistent delivery. Defaults ON (see below). */
4
+ export const EVENTS_SINGLE_DELIVERY_ENV = 'OM_EVENTS_SINGLE_DELIVERY'
5
+ /**
6
+ * Operator acknowledgment that an events worker runs OUT OF PROCESS (separate
7
+ * `mercato queue worker` container/process), so the server bootstrap must not
8
+ * disable single-delivery just because it does not auto-spawn workers itself.
9
+ */
10
+ export const EVENTS_EXTERNAL_WORKER_ENV = 'OM_EVENTS_EXTERNAL_WORKER'
11
+
12
+ type EnvSource = Record<string, string | undefined>
13
+
14
+ /**
15
+ * Whether single-path persistent delivery is requested.
16
+ *
17
+ * Default ON: a persistent emit is delivered on exactly one path — persistent
18
+ * subscribers run in the events worker (matched by pattern, so wildcard
19
+ * persistent subscribers are reached), ephemeral subscribers still run inline.
20
+ * This is the correct behavior; the legacy dual-dispatch (inline AND worker)
21
+ * double-ran exact-match persistent subscribers and never reached wildcard
22
+ * persistent subscribers in the worker. Set the env to a false token to opt back
23
+ * into legacy dual-dispatch.
24
+ *
25
+ * The server bootstrap reconciles this against worker availability (see
26
+ * {@link reconcileSingleDelivery}) and may rewrite the env to `false` for a
27
+ * process that would otherwise skip inline delivery with no worker to drain the
28
+ * queue. The bus and the events worker both read the (possibly reconciled) env,
29
+ * so they always agree within a process.
30
+ */
31
+ export function isSingleDeliveryRequested(env: EnvSource = process.env): boolean {
32
+ return parseBooleanWithDefault(env[EVENTS_SINGLE_DELIVERY_ENV], true)
33
+ }
34
+
35
+ export function isExternalWorkerAcknowledged(env: EnvSource = process.env): boolean {
36
+ return parseBooleanWithDefault(env[EVENTS_EXTERNAL_WORKER_ENV], false)
37
+ }
38
+
39
+ export type SingleDeliveryReconciliation = {
40
+ /** The value the process should actually use. */
41
+ effective: boolean
42
+ /** Set when single-delivery was requested but disabled for safety. */
43
+ warning?: string
44
+ }
45
+
46
+ /**
47
+ * Guards the default-on single-delivery against the silent-loss failure mode:
48
+ * with single-delivery on, persistent subscribers are skipped inline and ONLY
49
+ * the events worker dispatches them. If no worker drains the queue, those
50
+ * persistent side effects (notifications, queued emails, indexing) never run.
51
+ *
52
+ * The durable queue already covers transient worker downtime — a job persists
53
+ * and drains when a worker returns. The dangerous case is a process configured
54
+ * to run with NO events worker at all (auto-spawn off and no external worker).
55
+ * For that case this fails safe: it disables single-delivery so persistent
56
+ * subscribers run inline (dual-dispatch) and side effects are never dropped,
57
+ * and surfaces a loud warning telling the operator how to opt back in.
58
+ */
59
+ export function reconcileSingleDelivery(input: {
60
+ requested: boolean
61
+ workersAvailable: boolean
62
+ }): SingleDeliveryReconciliation {
63
+ if (!input.requested) return { effective: false }
64
+ if (input.workersAvailable) return { effective: true }
65
+ return {
66
+ effective: false,
67
+ warning:
68
+ `[events] ${EVENTS_SINGLE_DELIVERY_ENV} is on (default) but this process auto-spawns no events worker ` +
69
+ `(AUTO_SPAWN_WORKERS=off) and ${EVENTS_EXTERNAL_WORKER_ENV} is not set. Persistent subscribers would be ` +
70
+ `skipped inline with nothing to drain the queue, silently dropping notifications, queued emails, and ` +
71
+ `indexing. Falling back to legacy inline dual-dispatch for safety. To keep single-delivery, run an events ` +
72
+ `worker (\`mercato queue worker events\`) and set ${EVENTS_EXTERNAL_WORKER_ENV}=true, or enable ` +
73
+ `AUTO_SPAWN_WORKERS.`,
74
+ }
75
+ }
package/src/types.ts CHANGED
@@ -61,6 +61,19 @@ export type SubscriberDescriptor = {
61
61
  export type EmitOptions = {
62
62
  /** If true, the event will be persisted to a queue for async processing */
63
63
  persistent?: boolean
64
+ /**
65
+ * Controls inline in-memory delivery on a persistent emit. Defaults to `true`,
66
+ * which preserves the legacy dual-dispatch behavior (subscribers run inline AND
67
+ * the event is enqueued for the worker). Set to `false` to ENQUEUE-ONLY: inline
68
+ * delivery is skipped entirely and the events worker is the sole dispatcher.
69
+ *
70
+ * Use this to hand a heavy persistent job (e.g. a full query-index rebuild) to
71
+ * the durable queue without running it on the caller's request path. Has no
72
+ * effect unless `persistent: true`. Ephemeral subscribers to the event will not
73
+ * run inline either, so only use it for events whose subscribers are all
74
+ * worker-dispatched (`persistent: true`).
75
+ */
76
+ deliverInline?: boolean
64
77
  /** Trusted tenant scope for subscribers that must not rely on payload scope */
65
78
  tenantId?: string | null
66
79
  /** Trusted organization scope for subscribers that must not rely on payload scope */