@open-mercato/events 0.6.6-develop.5588.1.a8f6c51d1f → 0.6.6-develop.5598.1.5e7d48d297
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +9 -6
- package/dist/bus.js +7 -3
- package/dist/bus.js.map +2 -2
- package/dist/modules/events/workers/events.worker.js +2 -2
- package/dist/modules/events/workers/events.worker.js.map +2 -2
- package/dist/single-delivery.js +25 -0
- package/dist/single-delivery.js.map +7 -0
- package/package.json +3 -3
- package/src/__tests__/single-delivery-reconcile.test.ts +44 -0
- package/src/__tests__/single-delivery.test.ts +94 -2
- package/src/bus.ts +17 -6
- package/src/modules/events/workers/__tests__/events.worker.test.ts +33 -1
- package/src/modules/events/workers/events.worker.ts +5 -4
- package/src/single-delivery.ts +75 -0
- package/src/types.ts +13 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:events] found
|
|
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:
|
|
79
|
+
### Persistent delivery: single-delivery (`OM_EVENTS_SINGLE_DELIVERY`, default ON)
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
Single-delivery is the default. A persistent emit is delivered on exactly one path:
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
- the
|
|
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
|
-
|
|
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
|
|
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
|
|
130
|
-
|
|
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": "AAAA,SAAS,mBAAmB;AAE5B,SAAS,+BAA+B;AACxC,SAAS,yBAAyB;AAClC,SAAS,0BAA0B;AACnC,SAAS,wBAAwB;AACjC,SAAS,yCAAyC;AAClD,SAAS,gCAAgC;AAWzC,MAAM,oBAAoB;
|
|
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": "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",
|
|
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 {
|
|
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
|
|
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 {
|
|
5
|
-
"mappings": "AACA,SAAS,qBAAqB;AAC9B,SAAS,yBAAyB;AAClC,SAAS
|
|
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.
|
|
3
|
+
"version": "0.6.6-develop.5598.1.5e7d48d297",
|
|
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.
|
|
36
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
35
|
+
"@open-mercato/queue": "0.6.6-develop.5598.1.5e7d48d297",
|
|
36
|
+
"@open-mercato/shared": "0.6.6-develop.5598.1.5e7d48d297",
|
|
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('
|
|
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
|
|
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.
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
|
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
|
-
|
|
263
|
-
|
|
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('
|
|
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 {
|
|
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.
|
|
42
|
-
* the
|
|
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
|
|
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 */
|