@open-mercato/events 0.6.8-develop.7099.1.7ba2771d06 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -184,7 +184,3 @@ useAppEvent('mymod.entity.created', (event) => {
184
184
  - Client deduplicates events within a 500ms window
185
185
  - `isBroadcastEvent(eventId)` checks if an event has `clientBroadcast: true`
186
186
  - The `useEventBridge()` hook must be mounted once in the app shell to start receiving events
187
-
188
- ### Private cross-process coordination
189
-
190
- Use `crossProcessBroadcast: true` for server-to-server invalidation or coordination that must cross process boundaries but must not be exposed through browser SSE. The event bus publishes both `clientBroadcast` and `crossProcessBroadcast` events to the cross-process bridge, while the DOM Event Bridge delivers only `clientBroadcast` events. Do not use `clientBroadcast` merely to reach another server process when the payload contains record-scoped identifiers or activity that the organization-level SSE audience cannot authorize.
package/dist/bridge.js CHANGED
@@ -1,21 +1,10 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { Client, Pool } from "pg";
3
2
  import { getSslConfig } from "@open-mercato/shared/lib/db/ssl";
4
3
  import { createLogger } from "@open-mercato/shared/lib/logger";
5
4
  const BRIDGE_CHANNEL = "om_event_bridge";
6
5
  const MAX_MESSAGE_BYTES = 7e3;
7
6
  const RECONNECT_DELAY_MS = 1e3;
8
- const CROSS_PROCESS_EVENT_INSTANCE_ID_KEY = "__openMercatoCrossProcessEventInstanceId__";
9
7
  const logger = createLogger("events");
10
- function getCrossProcessEventInstanceId() {
11
- const globalScope = globalThis;
12
- const existing = globalScope[CROSS_PROCESS_EVENT_INSTANCE_ID_KEY];
13
- if (typeof existing === "string" && existing.length > 0) return existing;
14
- const created = randomUUID();
15
- globalScope[CROSS_PROCESS_EVENT_INSTANCE_ID_KEY] = created;
16
- return created;
17
- }
18
- const CROSS_PROCESS_EVENT_INSTANCE_ID = getCrossProcessEventInstanceId();
19
8
  let publisherPool;
20
9
  let listenerClient = null;
21
10
  let listenerConnectPromise = null;
@@ -133,8 +122,7 @@ async function publishCrossProcessEvent(event, payload, options) {
133
122
  event,
134
123
  payload,
135
124
  options: bridgeOptions,
136
- originPid: process.pid,
137
- originInstanceId: CROSS_PROCESS_EVENT_INSTANCE_ID
125
+ originPid: process.pid
138
126
  };
139
127
  const serialized = JSON.stringify(envelope);
140
128
  if (Buffer.byteLength(serialized, "utf8") > MAX_MESSAGE_BYTES) {
@@ -155,7 +143,6 @@ function registerCrossProcessEventListener(listener) {
155
143
  };
156
144
  }
157
145
  export {
158
- CROSS_PROCESS_EVENT_INSTANCE_ID,
159
146
  publishCrossProcessEvent,
160
147
  registerCrossProcessEventListener
161
148
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/bridge.ts"],
4
- "sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport { Client, Pool } from 'pg'\nimport { getSslConfig } from '@open-mercato/shared/lib/db/ssl'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { EmitOptions, EventPayload } from './types'\n\nconst BRIDGE_CHANNEL = 'om_event_bridge'\nconst MAX_MESSAGE_BYTES = 7_000\nconst RECONNECT_DELAY_MS = 1_000\nconst CROSS_PROCESS_EVENT_INSTANCE_ID_KEY = '__openMercatoCrossProcessEventInstanceId__'\n\nconst logger = createLogger('events')\n\n/**\n * PIDs can collide across containers, so self-echo suppression must not rely\n * on originPid alone. Every publisher stamps envelopes with this random\n * per-process instance id; consumers prefer it and fall back to originPid\n * only for envelopes published by older processes during a rolling deploy.\n */\nfunction getCrossProcessEventInstanceId(): string {\n const globalScope = globalThis as Record<string, unknown>\n const existing = globalScope[CROSS_PROCESS_EVENT_INSTANCE_ID_KEY]\n if (typeof existing === 'string' && existing.length > 0) return existing\n const created = randomUUID()\n globalScope[CROSS_PROCESS_EVENT_INSTANCE_ID_KEY] = created\n return created\n}\n\nexport const CROSS_PROCESS_EVENT_INSTANCE_ID = getCrossProcessEventInstanceId()\n\ntype BridgeEnvelope = {\n event: string\n payload: EventPayload\n options?: EmitOptions\n originPid: number\n originInstanceId?: string\n}\n\ntype CrossProcessEventListener = (envelope: BridgeEnvelope) => void | Promise<void>\ntype PgNotificationMessage = {\n channel: string\n payload?: string\n}\n\nlet publisherPool: InstanceType<typeof Pool> | null | undefined\nlet listenerClient: InstanceType<typeof Client> | null = null\nlet listenerConnectPromise: Promise<void> | null = null\nlet listenerReconnectTimer: ReturnType<typeof setTimeout> | null = null\nconst listeners = new Set<CrossProcessEventListener>()\n\nfunction getDatabaseUrl(): string | null {\n const value = process.env.DATABASE_URL?.trim()\n return value && value.length > 0 ? value : null\n}\n\nfunction getPgBridgeConnectionString(): string | null {\n const connectionString = getDatabaseUrl()\n if (!connectionString) return null\n\n try {\n const url = new URL(connectionString)\n for (const key of ['sslmode', 'sslcert', 'sslkey', 'sslrootcert', 'sslcrl']) {\n url.searchParams.delete(key)\n }\n return url.toString()\n } catch {\n return connectionString\n }\n}\n\nfunction getPublisherPool(): InstanceType<typeof Pool> | null {\n if (publisherPool !== undefined) return publisherPool\n const connectionString = getPgBridgeConnectionString()\n if (!connectionString) {\n publisherPool = null\n return null\n }\n const ssl = getSslConfig()\n publisherPool = new Pool({\n connectionString,\n max: 2,\n ...(ssl ? { ssl } : {}),\n })\n return publisherPool\n}\n\nasync function dispatchEnvelope(envelope: BridgeEnvelope): Promise<void> {\n for (const listener of listeners) {\n try {\n await Promise.resolve(listener(envelope))\n } catch (error) {\n logger.error('Cross-process listener error', { event: envelope.event, err: error })\n }\n }\n}\n\nfunction clearReconnectTimer(): void {\n if (!listenerReconnectTimer) return\n clearTimeout(listenerReconnectTimer)\n listenerReconnectTimer = null\n}\n\nasync function closeListenerClient(): Promise<void> {\n if (!listenerClient) return\n const client = listenerClient\n listenerClient = null\n try {\n client.removeAllListeners('notification')\n client.removeAllListeners('error')\n client.removeAllListeners('end')\n await client.end()\n } catch {\n // Ignore shutdown errors.\n }\n}\n\nfunction scheduleReconnect(): void {\n if (listenerReconnectTimer || listeners.size === 0) return\n listenerReconnectTimer = setTimeout(() => {\n listenerReconnectTimer = null\n void ensureCrossProcessListener()\n }, RECONNECT_DELAY_MS)\n}\n\nasync function ensureCrossProcessListener(): Promise<void> {\n if (listenerClient || listenerConnectPromise || listeners.size === 0) return\n const connectionString = getPgBridgeConnectionString()\n if (!connectionString) return\n\n listenerConnectPromise = (async () => {\n const ssl = getSslConfig()\n const client = new Client({\n connectionString,\n ...(ssl ? { ssl } : {}),\n })\n\n client.on('notification', (message: PgNotificationMessage) => {\n if (message.channel !== BRIDGE_CHANNEL || !message.payload) return\n try {\n const parsed = JSON.parse(message.payload) as BridgeEnvelope\n if (!parsed || typeof parsed.event !== 'string') return\n void dispatchEnvelope(parsed)\n } catch (error) {\n logger.warn('Failed to parse cross-process bridge payload', { err: error })\n }\n })\n\n const handleDisconnect = () => {\n if (listenerClient === client) {\n listenerClient = null\n }\n scheduleReconnect()\n }\n\n client.on('error', handleDisconnect)\n client.on('end', handleDisconnect)\n\n await client.connect()\n await client.query(`LISTEN ${BRIDGE_CHANNEL}`)\n listenerClient = client\n })()\n .catch((error) => {\n logger.warn('Cross-process event bridge listener failed', { err: error })\n scheduleReconnect()\n })\n .finally(() => {\n listenerConnectPromise = null\n })\n\n await listenerConnectPromise\n}\n\nexport async function publishCrossProcessEvent(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n): Promise<void> {\n const pool = getPublisherPool()\n if (!pool) return\n\n const bridgeOptions = options\n && Object.prototype.hasOwnProperty.call(options, 'tenantId')\n && options.tenantId === undefined\n ? { ...options, tenantId: null }\n : options\n\n const envelope: BridgeEnvelope = {\n event,\n payload,\n options: bridgeOptions,\n originPid: process.pid,\n originInstanceId: CROSS_PROCESS_EVENT_INSTANCE_ID,\n }\n\n const serialized = JSON.stringify(envelope)\n if (Buffer.byteLength(serialized, 'utf8') > MAX_MESSAGE_BYTES) {\n logger.warn('Cross-process event dropped: payload exceeds size limit', { event, maxBytes: MAX_MESSAGE_BYTES })\n return\n }\n\n await pool.query('SELECT pg_notify($1, $2)', [BRIDGE_CHANNEL, serialized])\n}\n\nexport function registerCrossProcessEventListener(listener: CrossProcessEventListener): () => void {\n listeners.add(listener)\n void ensureCrossProcessListener()\n\n return () => {\n listeners.delete(listener)\n if (listeners.size === 0) {\n clearReconnectTimer()\n void closeListenerClient()\n }\n }\n}\n"],
5
- "mappings": "AAAA,SAAS,kBAAkB;AAC3B,SAAS,QAAQ,YAAY;AAC7B,SAAS,oBAAoB;AAC7B,SAAS,oBAAoB;AAG7B,MAAM,iBAAiB;AACvB,MAAM,oBAAoB;AAC1B,MAAM,qBAAqB;AAC3B,MAAM,sCAAsC;AAE5C,MAAM,SAAS,aAAa,QAAQ;AAQpC,SAAS,iCAAyC;AAChD,QAAM,cAAc;AACpB,QAAM,WAAW,YAAY,mCAAmC;AAChE,MAAI,OAAO,aAAa,YAAY,SAAS,SAAS,EAAG,QAAO;AAChE,QAAM,UAAU,WAAW;AAC3B,cAAY,mCAAmC,IAAI;AACnD,SAAO;AACT;AAEO,MAAM,kCAAkC,+BAA+B;AAgB9E,IAAI;AACJ,IAAI,iBAAqD;AACzD,IAAI,yBAA+C;AACnD,IAAI,yBAA+D;AACnE,MAAM,YAAY,oBAAI,IAA+B;AAErD,SAAS,iBAAgC;AACvC,QAAM,QAAQ,QAAQ,IAAI,cAAc,KAAK;AAC7C,SAAO,SAAS,MAAM,SAAS,IAAI,QAAQ;AAC7C;AAEA,SAAS,8BAA6C;AACpD,QAAM,mBAAmB,eAAe;AACxC,MAAI,CAAC,iBAAkB,QAAO;AAE9B,MAAI;AACF,UAAM,MAAM,IAAI,IAAI,gBAAgB;AACpC,eAAW,OAAO,CAAC,WAAW,WAAW,UAAU,eAAe,QAAQ,GAAG;AAC3E,UAAI,aAAa,OAAO,GAAG;AAAA,IAC7B;AACA,WAAO,IAAI,SAAS;AAAA,EACtB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,mBAAqD;AAC5D,MAAI,kBAAkB,OAAW,QAAO;AACxC,QAAM,mBAAmB,4BAA4B;AACrD,MAAI,CAAC,kBAAkB;AACrB,oBAAgB;AAChB,WAAO;AAAA,EACT;AACA,QAAM,MAAM,aAAa;AACzB,kBAAgB,IAAI,KAAK;AAAA,IACvB;AAAA,IACA,KAAK;AAAA,IACL,GAAI,MAAM,EAAE,IAAI,IAAI,CAAC;AAAA,EACvB,CAAC;AACD,SAAO;AACT;AAEA,eAAe,iBAAiB,UAAyC;AACvE,aAAW,YAAY,WAAW;AAChC,QAAI;AACF,YAAM,QAAQ,QAAQ,SAAS,QAAQ,CAAC;AAAA,IAC1C,SAAS,OAAO;AACd,aAAO,MAAM,gCAAgC,EAAE,OAAO,SAAS,OAAO,KAAK,MAAM,CAAC;AAAA,IACpF;AAAA,EACF;AACF;AAEA,SAAS,sBAA4B;AACnC,MAAI,CAAC,uBAAwB;AAC7B,eAAa,sBAAsB;AACnC,2BAAyB;AAC3B;AAEA,eAAe,sBAAqC;AAClD,MAAI,CAAC,eAAgB;AACrB,QAAM,SAAS;AACf,mBAAiB;AACjB,MAAI;AACF,WAAO,mBAAmB,cAAc;AACxC,WAAO,mBAAmB,OAAO;AACjC,WAAO,mBAAmB,KAAK;AAC/B,UAAM,OAAO,IAAI;AAAA,EACnB,QAAQ;AAAA,EAER;AACF;AAEA,SAAS,oBAA0B;AACjC,MAAI,0BAA0B,UAAU,SAAS,EAAG;AACpD,2BAAyB,WAAW,MAAM;AACxC,6BAAyB;AACzB,SAAK,2BAA2B;AAAA,EAClC,GAAG,kBAAkB;AACvB;AAEA,eAAe,6BAA4C;AACzD,MAAI,kBAAkB,0BAA0B,UAAU,SAAS,EAAG;AACtE,QAAM,mBAAmB,4BAA4B;AACrD,MAAI,CAAC,iBAAkB;AAEvB,4BAA0B,YAAY;AACpC,UAAM,MAAM,aAAa;AACzB,UAAM,SAAS,IAAI,OAAO;AAAA,MACxB;AAAA,MACA,GAAI,MAAM,EAAE,IAAI,IAAI,CAAC;AAAA,IACvB,CAAC;AAED,WAAO,GAAG,gBAAgB,CAAC,YAAmC;AAC5D,UAAI,QAAQ,YAAY,kBAAkB,CAAC,QAAQ,QAAS;AAC5D,UAAI;AACF,cAAM,SAAS,KAAK,MAAM,QAAQ,OAAO;AACzC,YAAI,CAAC,UAAU,OAAO,OAAO,UAAU,SAAU;AACjD,aAAK,iBAAiB,MAAM;AAAA,MAC9B,SAAS,OAAO;AACd,eAAO,KAAK,gDAAgD,EAAE,KAAK,MAAM,CAAC;AAAA,MAC5E;AAAA,IACF,CAAC;AAED,UAAM,mBAAmB,MAAM;AAC7B,UAAI,mBAAmB,QAAQ;AAC7B,yBAAiB;AAAA,MACnB;AACA,wBAAkB;AAAA,IACpB;AAEA,WAAO,GAAG,SAAS,gBAAgB;AACnC,WAAO,GAAG,OAAO,gBAAgB;AAEjC,UAAM,OAAO,QAAQ;AACrB,UAAM,OAAO,MAAM,UAAU,cAAc,EAAE;AAC7C,qBAAiB;AAAA,EACnB,GAAG,EACA,MAAM,CAAC,UAAU;AAChB,WAAO,KAAK,8CAA8C,EAAE,KAAK,MAAM,CAAC;AACxE,sBAAkB;AAAA,EACpB,CAAC,EACA,QAAQ,MAAM;AACb,6BAAyB;AAAA,EAC3B,CAAC;AAEH,QAAM;AACR;AAEA,eAAsB,yBACpB,OACA,SACA,SACe;AACf,QAAM,OAAO,iBAAiB;AAC9B,MAAI,CAAC,KAAM;AAEX,QAAM,gBAAgB,WACjB,OAAO,UAAU,eAAe,KAAK,SAAS,UAAU,KACxD,QAAQ,aAAa,SACtB,EAAE,GAAG,SAAS,UAAU,KAAK,IAC7B;AAEJ,QAAM,WAA2B;AAAA,IAC/B;AAAA,IACA;AAAA,IACA,SAAS;AAAA,IACT,WAAW,QAAQ;AAAA,IACnB,kBAAkB;AAAA,EACpB;AAEA,QAAM,aAAa,KAAK,UAAU,QAAQ;AAC1C,MAAI,OAAO,WAAW,YAAY,MAAM,IAAI,mBAAmB;AAC7D,WAAO,KAAK,2DAA2D,EAAE,OAAO,UAAU,kBAAkB,CAAC;AAC7G;AAAA,EACF;AAEA,QAAM,KAAK,MAAM,4BAA4B,CAAC,gBAAgB,UAAU,CAAC;AAC3E;AAEO,SAAS,kCAAkC,UAAiD;AACjG,YAAU,IAAI,QAAQ;AACtB,OAAK,2BAA2B;AAEhC,SAAO,MAAM;AACX,cAAU,OAAO,QAAQ;AACzB,QAAI,UAAU,SAAS,GAAG;AACxB,0BAAoB;AACpB,WAAK,oBAAoB;AAAA,IAC3B;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import { Client, Pool } from 'pg'\nimport { getSslConfig } from '@open-mercato/shared/lib/db/ssl'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { EmitOptions, EventPayload } from './types'\n\nconst BRIDGE_CHANNEL = 'om_event_bridge'\nconst MAX_MESSAGE_BYTES = 7_000\nconst RECONNECT_DELAY_MS = 1_000\n\nconst logger = createLogger('events')\n\ntype BridgeEnvelope = {\n event: string\n payload: EventPayload\n options?: EmitOptions\n originPid: number\n}\n\ntype CrossProcessEventListener = (envelope: BridgeEnvelope) => void | Promise<void>\ntype PgNotificationMessage = {\n channel: string\n payload?: string\n}\n\nlet publisherPool: InstanceType<typeof Pool> | null | undefined\nlet listenerClient: InstanceType<typeof Client> | null = null\nlet listenerConnectPromise: Promise<void> | null = null\nlet listenerReconnectTimer: ReturnType<typeof setTimeout> | null = null\nconst listeners = new Set<CrossProcessEventListener>()\n\nfunction getDatabaseUrl(): string | null {\n const value = process.env.DATABASE_URL?.trim()\n return value && value.length > 0 ? value : null\n}\n\nfunction getPgBridgeConnectionString(): string | null {\n const connectionString = getDatabaseUrl()\n if (!connectionString) return null\n\n try {\n const url = new URL(connectionString)\n for (const key of ['sslmode', 'sslcert', 'sslkey', 'sslrootcert', 'sslcrl']) {\n url.searchParams.delete(key)\n }\n return url.toString()\n } catch {\n return connectionString\n }\n}\n\nfunction getPublisherPool(): InstanceType<typeof Pool> | null {\n if (publisherPool !== undefined) return publisherPool\n const connectionString = getPgBridgeConnectionString()\n if (!connectionString) {\n publisherPool = null\n return null\n }\n const ssl = getSslConfig()\n publisherPool = new Pool({\n connectionString,\n max: 2,\n ...(ssl ? { ssl } : {}),\n })\n return publisherPool\n}\n\nasync function dispatchEnvelope(envelope: BridgeEnvelope): Promise<void> {\n for (const listener of listeners) {\n try {\n await Promise.resolve(listener(envelope))\n } catch (error) {\n logger.error('Cross-process listener error', { event: envelope.event, err: error })\n }\n }\n}\n\nfunction clearReconnectTimer(): void {\n if (!listenerReconnectTimer) return\n clearTimeout(listenerReconnectTimer)\n listenerReconnectTimer = null\n}\n\nasync function closeListenerClient(): Promise<void> {\n if (!listenerClient) return\n const client = listenerClient\n listenerClient = null\n try {\n client.removeAllListeners('notification')\n client.removeAllListeners('error')\n client.removeAllListeners('end')\n await client.end()\n } catch {\n // Ignore shutdown errors.\n }\n}\n\nfunction scheduleReconnect(): void {\n if (listenerReconnectTimer || listeners.size === 0) return\n listenerReconnectTimer = setTimeout(() => {\n listenerReconnectTimer = null\n void ensureCrossProcessListener()\n }, RECONNECT_DELAY_MS)\n}\n\nasync function ensureCrossProcessListener(): Promise<void> {\n if (listenerClient || listenerConnectPromise || listeners.size === 0) return\n const connectionString = getPgBridgeConnectionString()\n if (!connectionString) return\n\n listenerConnectPromise = (async () => {\n const ssl = getSslConfig()\n const client = new Client({\n connectionString,\n ...(ssl ? { ssl } : {}),\n })\n\n client.on('notification', (message: PgNotificationMessage) => {\n if (message.channel !== BRIDGE_CHANNEL || !message.payload) return\n try {\n const parsed = JSON.parse(message.payload) as BridgeEnvelope\n if (!parsed || typeof parsed.event !== 'string') return\n void dispatchEnvelope(parsed)\n } catch (error) {\n logger.warn('Failed to parse cross-process bridge payload', { err: error })\n }\n })\n\n const handleDisconnect = () => {\n if (listenerClient === client) {\n listenerClient = null\n }\n scheduleReconnect()\n }\n\n client.on('error', handleDisconnect)\n client.on('end', handleDisconnect)\n\n await client.connect()\n await client.query(`LISTEN ${BRIDGE_CHANNEL}`)\n listenerClient = client\n })()\n .catch((error) => {\n logger.warn('Cross-process event bridge listener failed', { err: error })\n scheduleReconnect()\n })\n .finally(() => {\n listenerConnectPromise = null\n })\n\n await listenerConnectPromise\n}\n\nexport async function publishCrossProcessEvent(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n): Promise<void> {\n const pool = getPublisherPool()\n if (!pool) return\n\n const bridgeOptions = options\n && Object.prototype.hasOwnProperty.call(options, 'tenantId')\n && options.tenantId === undefined\n ? { ...options, tenantId: null }\n : options\n\n const envelope: BridgeEnvelope = {\n event,\n payload,\n options: bridgeOptions,\n originPid: process.pid,\n }\n\n const serialized = JSON.stringify(envelope)\n if (Buffer.byteLength(serialized, 'utf8') > MAX_MESSAGE_BYTES) {\n logger.warn('Cross-process event dropped: payload exceeds size limit', { event, maxBytes: MAX_MESSAGE_BYTES })\n return\n }\n\n await pool.query('SELECT pg_notify($1, $2)', [BRIDGE_CHANNEL, serialized])\n}\n\nexport function registerCrossProcessEventListener(listener: CrossProcessEventListener): () => void {\n listeners.add(listener)\n void ensureCrossProcessListener()\n\n return () => {\n listeners.delete(listener)\n if (listeners.size === 0) {\n clearReconnectTimer()\n void closeListenerClient()\n }\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/dist/bus.js CHANGED
@@ -4,16 +4,12 @@ import { createLogger } from "@open-mercato/shared/lib/logger";
4
4
  import { isSingleDeliveryRequested } from "./single-delivery.js";
5
5
  import { matchEventPattern } from "@open-mercato/shared/lib/events/patterns";
6
6
  import { getRedisUrlOrThrow } from "@open-mercato/shared/lib/redis/connection";
7
- import {
8
- isBroadcastEvent,
9
- isCrossProcessBroadcastEvent,
10
- isPrivateCrossProcessEventEmitter
11
- } from "@open-mercato/shared/modules/events";
7
+ import { isBroadcastEvent } from "@open-mercato/shared/modules/events";
12
8
  import {
13
9
  inferModuleIdFromResourceId,
14
10
  withModuleResourceUsage
15
11
  } from "@open-mercato/shared/lib/modules/resource-usage";
16
- import { registerCrossProcessEventListener, CROSS_PROCESS_EVENT_INSTANCE_ID } from "./bridge.js";
12
+ import { registerCrossProcessEventListener } from "./bridge.js";
17
13
  import { publishCrossProcessEvent } from "./bridge.js";
18
14
  const EVENTS_QUEUE_NAME = "events";
19
15
  const logger = createLogger("events");
@@ -21,26 +17,8 @@ function isSingleDeliveryEnabled() {
21
17
  return isSingleDeliveryRequested();
22
18
  }
23
19
  const GLOBAL_EVENT_TAPS_KEY = "__openMercatoEventBusGlobalTaps__";
24
- function hasTrustedTenantScope(options) {
25
- return typeof options?.tenantId === "string" && options.tenantId.trim().length > 0;
26
- }
27
- function normalizePayloadScope(value) {
28
- return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
29
- }
30
- function resolveCrossProcessEmitOptions(event, payload, options) {
31
- if (hasTrustedTenantScope(options)) return options;
32
- if (!isBroadcastEvent(event)) return options;
33
- const tenantId = normalizePayloadScope(payload?.tenantId);
34
- if (!tenantId) return options;
35
- const organizationId = normalizePayloadScope(payload?.organizationId);
36
- const organizationIdsValue = payload?.organizationIds;
37
- const organizationIds = Array.isArray(organizationIdsValue) ? organizationIdsValue.filter((value) => typeof value === "string") : [];
38
- return {
39
- ...options,
40
- tenantId,
41
- ...organizationId ? { organizationId } : {},
42
- ...organizationIds.length > 0 ? { organizationIds } : {}
43
- };
20
+ function hasTenantScope(payload) {
21
+ return typeof payload?.tenantId === "string" && String(payload.tenantId).trim().length > 0;
44
22
  }
45
23
  function getGlobalEventTaps() {
46
24
  const existing = globalThis[GLOBAL_EVENT_TAPS_KEY];
@@ -230,10 +208,9 @@ function createEventBus(opts) {
230
208
  const delivered = await deliver(event, payload, options, skipPersistentInline);
231
209
  inlinePersistentFailed = delivered.persistentFailures > 0;
232
210
  }
233
- const crossProcessOptions = resolveCrossProcessEmitOptions(event, payload, options);
234
- if (isCrossProcessBroadcastEvent(event) && hasTrustedTenantScope(crossProcessOptions) && isPrivateCrossProcessEventEmitter(event, crossProcessOptions?.emitterModuleId)) {
211
+ if (isBroadcastEvent(event) && hasTenantScope(payload)) {
235
212
  try {
236
- await publishCrossProcessEvent(event, payload, crossProcessOptions);
213
+ await publishCrossProcessEvent(event, payload, options);
237
214
  } catch (error) {
238
215
  logger.error("Cross-process publish error", { event, err: error });
239
216
  }
@@ -271,7 +248,6 @@ function createEventBus(opts) {
271
248
  };
272
249
  }
273
250
  export {
274
- CROSS_PROCESS_EVENT_INSTANCE_ID,
275
251
  createEventBus,
276
252
  registerCrossProcessEventListener,
277
253
  registerGlobalEventTap
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 { createLogger } from '@open-mercato/shared/lib/logger'\nimport { isSingleDeliveryRequested } from './single-delivery'\nimport { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport {\n isBroadcastEvent,\n isCrossProcessBroadcastEvent,\n isPrivateCrossProcessEventEmitter,\n} from '@open-mercato/shared/modules/events'\nimport {\n inferModuleIdFromResourceId,\n withModuleResourceUsage,\n} from '@open-mercato/shared/lib/modules/resource-usage'\nexport { registerCrossProcessEventListener, CROSS_PROCESS_EVENT_INSTANCE_ID } from './bridge'\nimport { publishCrossProcessEvent } from './bridge'\nimport type {\n EventBus,\n CreateBusOptions,\n SubscriberHandler,\n SubscriberDescriptor,\n EventPayload,\n EmitOptions,\n QueuedDispatchResult,\n} from './types'\n\n/** Queue name for persistent events */\nconst EVENTS_QUEUE_NAME = 'events'\n\nconst logger = createLogger('events')\n\ntype RegisteredSubscriber = {\n id?: string\n moduleId?: string\n event: string\n handler: SubscriberHandler\n persistent?: boolean\n}\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.\n *\n * The bus never second-guesses this against \"is a worker running\": the queue is\n * durable, so a persistent emit with no worker yet is delayed, not lost, and a\n * worker started later drains the backlog. Delivering inline instead would move\n * the work back onto the caller's request path \u2014 exactly what a split app/worker\n * deployment sets `AUTO_SPAWN_WORKERS=false` to avoid.\n *\n * The events worker asks this same bus for its subscribers via `dispatchQueued`,\n * so both halves of single-delivery read one decision.\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 hasTrustedTenantScope(options?: EmitOptions): boolean {\n return typeof options?.tenantId === 'string' && options.tenantId.trim().length > 0\n}\n\nfunction normalizePayloadScope(value: unknown): string | null {\n return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null\n}\n\nfunction resolveCrossProcessEmitOptions(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n): EmitOptions | undefined {\n if (hasTrustedTenantScope(options)) return options\n // Preserve the public raw EventBus contract for legacy clientBroadcast\n // emitters. These callers are trusted server code and historically supplied\n // their scope in the typed payload. Tenant-managed workflow execution passes\n // scope in options, while private cross-process events never take this path.\n if (!isBroadcastEvent(event)) return options\n const tenantId = normalizePayloadScope((payload as Record<string, unknown>)?.tenantId)\n if (!tenantId) return options\n const organizationId = normalizePayloadScope((payload as Record<string, unknown>)?.organizationId)\n const organizationIdsValue = (payload as Record<string, unknown>)?.organizationIds\n const organizationIds = Array.isArray(organizationIdsValue)\n ? organizationIdsValue.filter((value): value is string => typeof value === 'string')\n : []\n return {\n ...options,\n tenantId,\n ...(organizationId ? { organizationId } : {}),\n ...(organizationIds.length > 0 ? { organizationIds } : {}),\n }\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 * Set by the producer when it already ran the persistent subscribers inline\n * (single-delivery reconciled off). The worker skips such jobs, so the app and\n * the worker staying exactly-once no longer depends on both processes agreeing\n * about `OM_EVENTS_SINGLE_DELIVERY`. Absent on jobs queued before this field\n * existed, which keeps their behavior unchanged.\n */\n persistentDeliveredInline?: boolean\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<RegisteredSubscriber>>()\n // Subscribers registered as persistent (worker-dispatched). Used by the\n // single-delivery path to skip them inline on a persistent emit.\n const persistentSubscribers = new Set<RegisteredSubscriber>()\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 * Handler errors are logged and delivery continues - one bad subscriber must\n * not stop the others. The count of failed PERSISTENT handlers is returned so\n * the caller can decide whether the inline run may be recorded as complete:\n * a persistent subscriber that threw inline has no retry of its own, so the\n * queued job must stay unstamped and let the worker run it again.\n */\n async function deliver(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n skipPersistent = false,\n ): Promise<{ persistentFailures: number }> {\n let persistentFailures = 0\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 subscriber of handlers) {\n // Single-delivery: persistent subscribers are dispatched by the worker,\n // so skip them inline to avoid double execution.\n if (skipPersistent && persistentSubscribers.has(subscriber)) continue\n try {\n // Pass eventName in context for wildcard handlers\n const moduleId = subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id)\n await withModuleResourceUsage(\n {\n moduleId,\n surface: 'subscriber',\n operation: `${event} -> ${subscriber.id ?? subscriber.event}`,\n resourceId: subscriber.id ?? subscriber.event,\n },\n () => subscriber.handler(payload, {\n resolve: opts.resolve,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }),\n )\n } catch (error) {\n if (persistentSubscribers.has(subscriber)) {\n persistentFailures += 1\n }\n logger.error('Handler error', { event, pattern, err: error })\n if (options?.rethrowHandlerErrors) {\n throw error\n }\n }\n }\n }\n return { persistentFailures }\n }\n\n /**\n * Selects the subscribers the events worker owns for a queued event: every\n * pattern matching `event`, keeping persistent subscribers only, so wildcard\n * (`event: '*'`) persistent subscribers are reached exactly once here.\n *\n * Deliberately does NOT read `OM_EVENTS_SINGLE_DELIVERY`. Whether inline\n * delivery already happened is carried by the job's `persistentDeliveredInline`\n * stamp, so the producer owns that decision entirely and the worker cannot\n * disagree with it across processes. Reading the flag here would reintroduce\n * exactly that skew: a worker whose env has the flag off would fall back to\n * exact-match, re-running ephemeral subscribers the producer already ran inline\n * and never reaching wildcard persistent subscribers - the failure this whole\n * change exists to remove.\n *\n * Every job that reaches this point wants persistent-pattern selection: a job\n * is only dispatched when its stamp is false, which means either the producer\n * had single-delivery on, or the emit was enqueue-only (`deliverInline: false`,\n * documented as \"every subscriber to the event is persistent\").\n */\n function selectQueuedSubscribers(event: string): RegisteredSubscriber[] {\n const matched: RegisteredSubscriber[] = []\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) {\n continue\n }\n for (const subscriber of handlers) {\n if (subscriber.persistent) {\n matched.push(subscriber)\n }\n }\n }\n return matched\n }\n\n /**\n * Dispatches a queued event on behalf of the events worker.\n *\n * Handler failures are returned instead of logged-and-swallowed the way\n * `deliver()` does them: the worker needs them to propagate so the queue retries\n * the job and eventually dead-letters it.\n *\n * Subscribers run against the caller's `resolve` when one is given, falling back\n * to the bus's own. The events worker passes its per-job `ctx.resolve`, which is\n * the container `createPerJobWorkerHandler` built for that job - the handle the\n * queue contract already hands it. Defaulting to `opts.resolve` instead would\n * bind subscribers to whichever container built the bus; that is the same object\n * on the default configuration, but not under `OM_BOOTSTRAP_CACHE`, where the\n * cached bus is replayed into later containers while its captured resolver stays\n * bound to the first.\n */\n async function dispatchQueued(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n resolve?: <T = unknown>(name: string) => T,\n ): Promise<QueuedDispatchResult[]> {\n const subscribers = selectQueuedSubscribers(event)\n if (subscribers.length === 0) {\n return []\n }\n\n const resolver = resolve ?? opts.resolve\n\n const settled = await Promise.allSettled(\n subscribers.map((subscriber) =>\n withModuleResourceUsage(\n {\n moduleId: subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id),\n surface: 'subscriber',\n operation: `${event} -> ${subscriber.id ?? subscriber.event}`,\n resourceId: subscriber.id ?? subscriber.event,\n },\n () => subscriber.handler(payload, {\n resolve: resolver,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }),\n ),\n ),\n )\n\n return settled.map((result, index) => {\n const subscriberId = subscribers[index].id ?? subscribers[index].event\n return result.status === 'rejected'\n ? { subscriberId, ok: false, error: result.reason }\n : { subscriberId, ok: true }\n })\n }\n\n /**\n * Registers a handler for an event. Pass `moduleId` whenever the subscribing\n * module is known (e.g. a module reacting to another module's event) so\n * resource-usage telemetry attributes the handler correctly instead of\n * guessing a module id from the event name.\n */\n function on(\n event: string,\n handler: SubscriberHandler,\n options?: { persistent?: boolean; moduleId?: string; id?: string },\n ): void {\n if (!listeners.has(event)) {\n listeners.set(event, new Set())\n }\n const subscriber: RegisteredSubscriber = {\n id: options?.id,\n moduleId: options?.moduleId,\n event,\n handler,\n persistent: options?.persistent,\n }\n listeners.get(event)!.add(subscriber)\n if (options?.persistent) {\n persistentSubscribers.add(subscriber)\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, moduleId: sub.moduleId, id: sub.id })\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 logger.error('Global tap error', { event, err: 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 // Read the mode once so the inline skip and the stamp below cannot disagree.\n const singleDelivery = isSingleDeliveryEnabled()\n const skipPersistentInline = options?.skipPersistentSubscribersInline === true\n || (Boolean(options?.persistent) && singleDelivery)\n let inlinePersistentFailed = false\n if (!enqueueOnly) {\n const delivered = await deliver(event, payload, options, skipPersistentInline)\n inlinePersistentFailed = delivered.persistentFailures > 0\n }\n\n // Private coordination always requires trusted envelope scope and module\n // provenance. Legacy declared browser events may promote their typed scope\n // for raw EventBus compatibility; tenant-managed workflow adapters pass\n // authoritative scope in options, which always wins over payload values.\n const crossProcessOptions = resolveCrossProcessEmitOptions(event, payload, options)\n if (\n isCrossProcessBroadcastEvent(event)\n && hasTrustedTenantScope(crossProcessOptions)\n && isPrivateCrossProcessEventEmitter(event, crossProcessOptions?.emitterModuleId)\n ) {\n try {\n await publishCrossProcessEvent(event, payload, crossProcessOptions)\n } catch (error) {\n logger.error('Cross-process publish error', { event, err: error })\n }\n }\n\n // If persistent, also enqueue for async processing\n if (options?.persistent) {\n const q = getQueue()\n await q.enqueue({\n event,\n payload,\n options,\n // Only record the inline run as complete when every persistent\n // subscriber actually succeeded. Inline delivery logs-and-continues, so a\n // handler that threw has no retry of its own; leaving the job unstamped\n // hands it to the worker, where a failure gets queue retry and\n // dead-lettering. Without this, flipping the flag off (which the\n // `mercato server` guard can do automatically) silently downgrades\n // persistent delivery to at-most-once.\n persistentDeliveredInline: !enqueueOnly && !skipPersistentInline && !inlinePersistentFailed,\n })\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 dispatchQueued,\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 { createLogger } from '@open-mercato/shared/lib/logger'\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'\nimport {\n inferModuleIdFromResourceId,\n withModuleResourceUsage,\n} from '@open-mercato/shared/lib/modules/resource-usage'\nexport { registerCrossProcessEventListener } from './bridge'\nimport { publishCrossProcessEvent } from './bridge'\nimport type {\n EventBus,\n CreateBusOptions,\n SubscriberHandler,\n SubscriberDescriptor,\n EventPayload,\n EmitOptions,\n QueuedDispatchResult,\n} from './types'\n\n/** Queue name for persistent events */\nconst EVENTS_QUEUE_NAME = 'events'\n\nconst logger = createLogger('events')\n\ntype RegisteredSubscriber = {\n id?: string\n moduleId?: string\n event: string\n handler: SubscriberHandler\n persistent?: boolean\n}\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.\n *\n * The bus never second-guesses this against \"is a worker running\": the queue is\n * durable, so a persistent emit with no worker yet is delayed, not lost, and a\n * worker started later drains the backlog. Delivering inline instead would move\n * the work back onto the caller's request path \u2014 exactly what a split app/worker\n * deployment sets `AUTO_SPAWN_WORKERS=false` to avoid.\n *\n * The events worker asks this same bus for its subscribers via `dispatchQueued`,\n * so both halves of single-delivery read one decision.\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 * Set by the producer when it already ran the persistent subscribers inline\n * (single-delivery reconciled off). The worker skips such jobs, so the app and\n * the worker staying exactly-once no longer depends on both processes agreeing\n * about `OM_EVENTS_SINGLE_DELIVERY`. Absent on jobs queued before this field\n * existed, which keeps their behavior unchanged.\n */\n persistentDeliveredInline?: boolean\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<RegisteredSubscriber>>()\n // Subscribers registered as persistent (worker-dispatched). Used by the\n // single-delivery path to skip them inline on a persistent emit.\n const persistentSubscribers = new Set<RegisteredSubscriber>()\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 * Handler errors are logged and delivery continues - one bad subscriber must\n * not stop the others. The count of failed PERSISTENT handlers is returned so\n * the caller can decide whether the inline run may be recorded as complete:\n * a persistent subscriber that threw inline has no retry of its own, so the\n * queued job must stay unstamped and let the worker run it again.\n */\n async function deliver(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n skipPersistent = false,\n ): Promise<{ persistentFailures: number }> {\n let persistentFailures = 0\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 subscriber of handlers) {\n // Single-delivery: persistent subscribers are dispatched by the worker,\n // so skip them inline to avoid double execution.\n if (skipPersistent && persistentSubscribers.has(subscriber)) continue\n try {\n // Pass eventName in context for wildcard handlers\n const moduleId = subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id)\n await withModuleResourceUsage(\n {\n moduleId,\n surface: 'subscriber',\n operation: `${event} -> ${subscriber.id ?? subscriber.event}`,\n resourceId: subscriber.id ?? subscriber.event,\n },\n () => subscriber.handler(payload, {\n resolve: opts.resolve,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }),\n )\n } catch (error) {\n if (persistentSubscribers.has(subscriber)) {\n persistentFailures += 1\n }\n logger.error('Handler error', { event, pattern, err: error })\n if (options?.rethrowHandlerErrors) {\n throw error\n }\n }\n }\n }\n return { persistentFailures }\n }\n\n /**\n * Selects the subscribers the events worker owns for a queued event: every\n * pattern matching `event`, keeping persistent subscribers only, so wildcard\n * (`event: '*'`) persistent subscribers are reached exactly once here.\n *\n * Deliberately does NOT read `OM_EVENTS_SINGLE_DELIVERY`. Whether inline\n * delivery already happened is carried by the job's `persistentDeliveredInline`\n * stamp, so the producer owns that decision entirely and the worker cannot\n * disagree with it across processes. Reading the flag here would reintroduce\n * exactly that skew: a worker whose env has the flag off would fall back to\n * exact-match, re-running ephemeral subscribers the producer already ran inline\n * and never reaching wildcard persistent subscribers - the failure this whole\n * change exists to remove.\n *\n * Every job that reaches this point wants persistent-pattern selection: a job\n * is only dispatched when its stamp is false, which means either the producer\n * had single-delivery on, or the emit was enqueue-only (`deliverInline: false`,\n * documented as \"every subscriber to the event is persistent\").\n */\n function selectQueuedSubscribers(event: string): RegisteredSubscriber[] {\n const matched: RegisteredSubscriber[] = []\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) {\n continue\n }\n for (const subscriber of handlers) {\n if (subscriber.persistent) {\n matched.push(subscriber)\n }\n }\n }\n return matched\n }\n\n /**\n * Dispatches a queued event on behalf of the events worker.\n *\n * Handler failures are returned instead of logged-and-swallowed the way\n * `deliver()` does them: the worker needs them to propagate so the queue retries\n * the job and eventually dead-letters it.\n *\n * Subscribers run against the caller's `resolve` when one is given, falling back\n * to the bus's own. The events worker passes its per-job `ctx.resolve`, which is\n * the container `createPerJobWorkerHandler` built for that job - the handle the\n * queue contract already hands it. Defaulting to `opts.resolve` instead would\n * bind subscribers to whichever container built the bus; that is the same object\n * on the default configuration, but not under `OM_BOOTSTRAP_CACHE`, where the\n * cached bus is replayed into later containers while its captured resolver stays\n * bound to the first.\n */\n async function dispatchQueued(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n resolve?: <T = unknown>(name: string) => T,\n ): Promise<QueuedDispatchResult[]> {\n const subscribers = selectQueuedSubscribers(event)\n if (subscribers.length === 0) {\n return []\n }\n\n const resolver = resolve ?? opts.resolve\n\n const settled = await Promise.allSettled(\n subscribers.map((subscriber) =>\n withModuleResourceUsage(\n {\n moduleId: subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id),\n surface: 'subscriber',\n operation: `${event} -> ${subscriber.id ?? subscriber.event}`,\n resourceId: subscriber.id ?? subscriber.event,\n },\n () => subscriber.handler(payload, {\n resolve: resolver,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }),\n ),\n ),\n )\n\n return settled.map((result, index) => {\n const subscriberId = subscribers[index].id ?? subscribers[index].event\n return result.status === 'rejected'\n ? { subscriberId, ok: false, error: result.reason }\n : { subscriberId, ok: true }\n })\n }\n\n /**\n * Registers a handler for an event. Pass `moduleId` whenever the subscribing\n * module is known (e.g. a module reacting to another module's event) so\n * resource-usage telemetry attributes the handler correctly instead of\n * guessing a module id from the event name.\n */\n function on(\n event: string,\n handler: SubscriberHandler,\n options?: { persistent?: boolean; moduleId?: string; id?: string },\n ): void {\n if (!listeners.has(event)) {\n listeners.set(event, new Set())\n }\n const subscriber: RegisteredSubscriber = {\n id: options?.id,\n moduleId: options?.moduleId,\n event,\n handler,\n persistent: options?.persistent,\n }\n listeners.get(event)!.add(subscriber)\n if (options?.persistent) {\n persistentSubscribers.add(subscriber)\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, moduleId: sub.moduleId, id: sub.id })\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 logger.error('Global tap error', { event, err: 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 // Read the mode once so the inline skip and the stamp below cannot disagree.\n const singleDelivery = isSingleDeliveryEnabled()\n const skipPersistentInline = options?.skipPersistentSubscribersInline === true\n || (Boolean(options?.persistent) && singleDelivery)\n let inlinePersistentFailed = false\n if (!enqueueOnly) {\n const delivered = await deliver(event, payload, options, skipPersistentInline)\n inlinePersistentFailed = delivered.persistentFailures > 0\n }\n\n if (isBroadcastEvent(event) && hasTenantScope(payload)) {\n try {\n await publishCrossProcessEvent(event, payload, options)\n } catch (error) {\n logger.error('Cross-process publish error', { event, err: error })\n }\n }\n\n // If persistent, also enqueue for async processing\n if (options?.persistent) {\n const q = getQueue()\n await q.enqueue({\n event,\n payload,\n options,\n // Only record the inline run as complete when every persistent\n // subscriber actually succeeded. Inline delivery logs-and-continues, so a\n // handler that threw has no retry of its own; leaving the job unstamped\n // hands it to the worker, where a failure gets queue retry and\n // dead-lettering. Without this, flipping the flag off (which the\n // `mercato server` guard can do automatically) silently downgrades\n // persistent delivery to at-most-once.\n persistentDeliveredInline: !enqueueOnly && !skipPersistentInline && !inlinePersistentFailed,\n })\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 dispatchQueued,\n clearQueue,\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -13,7 +13,6 @@ const eventDefinitionSchema = z.object({
13
13
  entity: z.string().optional(),
14
14
  excludeFromTriggers: z.boolean().optional(),
15
15
  clientBroadcast: z.boolean().optional(),
16
- crossProcessBroadcast: z.boolean().optional(),
17
16
  portalBroadcast: z.boolean().optional()
18
17
  });
19
18
  const eventsResponseSchema = z.object({
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/events/api/route.ts"],
4
- "sourcesContent": ["/**\n * Events registry API \u2014 returns declared events from module `events.ts` files.\n *\n * Uses the globally registered event configs (populated during bootstrap). Data\n * leaked here (event ids, module ids, entity ids, broadcast flags) is\n * recon-grade and MUST NOT be served to anonymous callers; the route\n * is protected by the standard `[...slug]` dispatcher with `requireAuth: true`.\n * Consumer: `packages/ui/src/backend/inputs/EventSelect.tsx` (workflow triggers,\n * business rules, webhook config).\n */\n\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getDeclaredEvents } from '@open-mercato/shared/modules/events'\n\nexport const metadata = {\n path: '/events',\n GET: { requireAuth: true, requireFeatures: ['workflows.view'] },\n}\n\nconst eventDefinitionSchema = z.object({\n id: z.string(),\n label: z.string(),\n description: z.string().optional(),\n category: z.enum(['crud', 'lifecycle', 'system', 'custom']).optional(),\n module: z.string().optional(),\n entity: z.string().optional(),\n excludeFromTriggers: z.boolean().optional(),\n clientBroadcast: z.boolean().optional(),\n crossProcessBroadcast: z.boolean().optional(),\n portalBroadcast: z.boolean().optional(),\n})\n\nconst eventsResponseSchema = z.object({\n data: z.array(eventDefinitionSchema),\n total: z.number().int().nonnegative(),\n})\n\nexport async function GET(req: Request): Promise<Response> {\n const { searchParams } = new URL(req.url)\n const category = searchParams.get('category')\n const moduleId = searchParams.get('module')\n const excludeTriggerExcluded = searchParams.get('excludeTriggerExcluded') !== 'false'\n\n let filteredEvents = getDeclaredEvents()\n\n if (excludeTriggerExcluded) {\n filteredEvents = filteredEvents.filter((e) => !e.excludeFromTriggers)\n }\n if (category) {\n filteredEvents = filteredEvents.filter((e) => e.category === category)\n }\n if (moduleId) {\n filteredEvents = filteredEvents.filter((e) => e.module === moduleId)\n }\n\n return Response.json({\n data: filteredEvents,\n total: filteredEvents.length,\n })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'List declared platform events',\n description: 'Returns all declared platform events from every enabled module. Supports optional category and module filters.',\n methods: {\n GET: {\n summary: 'List declared events',\n description: 'Returns every declared event. Filters: category, module, excludeTriggerExcluded (default true).',\n responses: [{ status: 200, description: 'Declared events', schema: eventsResponseSchema }],\n },\n },\n}\n"],
5
- "mappings": "AAWA,SAAS,SAAS;AAElB,SAAS,yBAAyB;AAE3B,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,gBAAgB,EAAE;AAChE;AAEA,MAAM,wBAAwB,EAAE,OAAO;AAAA,EACrC,IAAI,EAAE,OAAO;AAAA,EACb,OAAO,EAAE,OAAO;AAAA,EAChB,aAAa,EAAE,OAAO,EAAE,SAAS;AAAA,EACjC,UAAU,EAAE,KAAK,CAAC,QAAQ,aAAa,UAAU,QAAQ,CAAC,EAAE,SAAS;AAAA,EACrE,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,qBAAqB,EAAE,QAAQ,EAAE,SAAS;AAAA,EAC1C,iBAAiB,EAAE,QAAQ,EAAE,SAAS;AAAA,EACtC,uBAAuB,EAAE,QAAQ,EAAE,SAAS;AAAA,EAC5C,iBAAiB,EAAE,QAAQ,EAAE,SAAS;AACxC,CAAC;AAED,MAAM,uBAAuB,EAAE,OAAO;AAAA,EACpC,MAAM,EAAE,MAAM,qBAAqB;AAAA,EACnC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,YAAY;AACtC,CAAC;AAED,eAAsB,IAAI,KAAiC;AACzD,QAAM,EAAE,aAAa,IAAI,IAAI,IAAI,IAAI,GAAG;AACxC,QAAM,WAAW,aAAa,IAAI,UAAU;AAC5C,QAAM,WAAW,aAAa,IAAI,QAAQ;AAC1C,QAAM,yBAAyB,aAAa,IAAI,wBAAwB,MAAM;AAE9E,MAAI,iBAAiB,kBAAkB;AAEvC,MAAI,wBAAwB;AAC1B,qBAAiB,eAAe,OAAO,CAAC,MAAM,CAAC,EAAE,mBAAmB;AAAA,EACtE;AACA,MAAI,UAAU;AACZ,qBAAiB,eAAe,OAAO,CAAC,MAAM,EAAE,aAAa,QAAQ;AAAA,EACvE;AACA,MAAI,UAAU;AACZ,qBAAiB,eAAe,OAAO,CAAC,MAAM,EAAE,WAAW,QAAQ;AAAA,EACrE;AAEA,SAAO,SAAS,KAAK;AAAA,IACnB,MAAM;AAAA,IACN,OAAO,eAAe;AAAA,EACxB,CAAC;AACH;AAEO,MAAM,UAA2B;AAAA,EACtC,SAAS;AAAA,EACT,aAAa;AAAA,EACb,SAAS;AAAA,IACP,KAAK;AAAA,MACH,SAAS;AAAA,MACT,aAAa;AAAA,MACb,WAAW,CAAC,EAAE,QAAQ,KAAK,aAAa,mBAAmB,QAAQ,qBAAqB,CAAC;AAAA,IAC3F;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["/**\n * Events registry API \u2014 returns declared events from module `events.ts` files.\n *\n * Uses the globally registered event configs (populated during bootstrap). Data\n * leaked here (event ids, module ids, entity ids, clientBroadcast/portalBroadcast\n * flags) is recon-grade and MUST NOT be served to anonymous callers; the route\n * is protected by the standard `[...slug]` dispatcher with `requireAuth: true`.\n * Consumer: `packages/ui/src/backend/inputs/EventSelect.tsx` (workflow triggers,\n * business rules, webhook config).\n */\n\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getDeclaredEvents } from '@open-mercato/shared/modules/events'\n\nexport const metadata = {\n path: '/events',\n GET: { requireAuth: true, requireFeatures: ['workflows.view'] },\n}\n\nconst eventDefinitionSchema = z.object({\n id: z.string(),\n label: z.string(),\n description: z.string().optional(),\n category: z.enum(['crud', 'lifecycle', 'system', 'custom']).optional(),\n module: z.string().optional(),\n entity: z.string().optional(),\n excludeFromTriggers: z.boolean().optional(),\n clientBroadcast: z.boolean().optional(),\n portalBroadcast: z.boolean().optional(),\n})\n\nconst eventsResponseSchema = z.object({\n data: z.array(eventDefinitionSchema),\n total: z.number().int().nonnegative(),\n})\n\nexport async function GET(req: Request): Promise<Response> {\n const { searchParams } = new URL(req.url)\n const category = searchParams.get('category')\n const moduleId = searchParams.get('module')\n const excludeTriggerExcluded = searchParams.get('excludeTriggerExcluded') !== 'false'\n\n let filteredEvents = getDeclaredEvents()\n\n if (excludeTriggerExcluded) {\n filteredEvents = filteredEvents.filter((e) => !e.excludeFromTriggers)\n }\n if (category) {\n filteredEvents = filteredEvents.filter((e) => e.category === category)\n }\n if (moduleId) {\n filteredEvents = filteredEvents.filter((e) => e.module === moduleId)\n }\n\n return Response.json({\n data: filteredEvents,\n total: filteredEvents.length,\n })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'List declared platform events',\n description: 'Returns all declared platform events from every enabled module. Supports optional category and module filters.',\n methods: {\n GET: {\n summary: 'List declared events',\n description: 'Returns every declared event. Filters: category, module, excludeTriggerExcluded (default true).',\n responses: [{ status: 200, description: 'Declared events', schema: eventsResponseSchema }],\n },\n },\n}\n"],
5
+ "mappings": "AAWA,SAAS,SAAS;AAElB,SAAS,yBAAyB;AAE3B,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,gBAAgB,EAAE;AAChE;AAEA,MAAM,wBAAwB,EAAE,OAAO;AAAA,EACrC,IAAI,EAAE,OAAO;AAAA,EACb,OAAO,EAAE,OAAO;AAAA,EAChB,aAAa,EAAE,OAAO,EAAE,SAAS;AAAA,EACjC,UAAU,EAAE,KAAK,CAAC,QAAQ,aAAa,UAAU,QAAQ,CAAC,EAAE,SAAS;AAAA,EACrE,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,qBAAqB,EAAE,QAAQ,EAAE,SAAS;AAAA,EAC1C,iBAAiB,EAAE,QAAQ,EAAE,SAAS;AAAA,EACtC,iBAAiB,EAAE,QAAQ,EAAE,SAAS;AACxC,CAAC;AAED,MAAM,uBAAuB,EAAE,OAAO;AAAA,EACpC,MAAM,EAAE,MAAM,qBAAqB;AAAA,EACnC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,YAAY;AACtC,CAAC;AAED,eAAsB,IAAI,KAAiC;AACzD,QAAM,EAAE,aAAa,IAAI,IAAI,IAAI,IAAI,GAAG;AACxC,QAAM,WAAW,aAAa,IAAI,UAAU;AAC5C,QAAM,WAAW,aAAa,IAAI,QAAQ;AAC1C,QAAM,yBAAyB,aAAa,IAAI,wBAAwB,MAAM;AAE9E,MAAI,iBAAiB,kBAAkB;AAEvC,MAAI,wBAAwB;AAC1B,qBAAiB,eAAe,OAAO,CAAC,MAAM,CAAC,EAAE,mBAAmB;AAAA,EACtE;AACA,MAAI,UAAU;AACZ,qBAAiB,eAAe,OAAO,CAAC,MAAM,EAAE,aAAa,QAAQ;AAAA,EACvE;AACA,MAAI,UAAU;AACZ,qBAAiB,eAAe,OAAO,CAAC,MAAM,EAAE,WAAW,QAAQ;AAAA,EACrE;AAEA,SAAO,SAAS,KAAK;AAAA,IACnB,MAAM;AAAA,IACN,OAAO,eAAe;AAAA,EACxB,CAAC;AACH;AAEO,MAAM,UAA2B;AAAA,EACtC,SAAS;AAAA,EACT,aAAa;AAAA,EACb,SAAS;AAAA,IACP,KAAK;AAAA,MACH,SAAS;AAAA,MACT,aAAa;AAAA,MACb,WAAW,CAAC,EAAE,QAAQ,KAAK,aAAa,mBAAmB,QAAQ,qBAAqB,CAAC;AAAA,IAC3F;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
@@ -1,11 +1,7 @@
1
1
  import { resolveRequestContext } from "@open-mercato/shared/lib/api/context";
2
2
  import { createLogger } from "@open-mercato/shared/lib/logger";
3
3
  import { isBroadcastEvent } from "@open-mercato/shared/modules/events";
4
- import {
5
- CROSS_PROCESS_EVENT_INSTANCE_ID,
6
- registerCrossProcessEventListener,
7
- registerGlobalEventTap
8
- } from "../../../../bus.js";
4
+ import { registerCrossProcessEventListener, registerGlobalEventTap } from "../../../../bus.js";
9
5
  const metadata = {
10
6
  GET: { requireAuth: true }
11
7
  };
@@ -31,9 +27,6 @@ function normalizeAudience(data, options) {
31
27
  const trustedOrganizationId = typeof options?.organizationId === "string" && options.organizationId.trim().length > 0 ? options.organizationId.trim() : null;
32
28
  if (hasTrustedScope) {
33
29
  if (trustedOrganizationId) organizationScopes.add(trustedOrganizationId);
34
- for (const organizationId of collectStringValues(options?.organizationIds)) {
35
- organizationScopes.add(organizationId);
36
- }
37
30
  } else {
38
31
  if (typeof data.organizationId === "string" && data.organizationId.trim().length > 0) {
39
32
  organizationScopes.add(data.organizationId.trim());
@@ -138,7 +131,7 @@ function ensureGlobalTapSubscription() {
138
131
  await broadcastEventToConnections(eventName, payload ?? {}, options);
139
132
  });
140
133
  registerCrossProcessEventListener(async (envelope) => {
141
- if (envelope.originInstanceId === CROSS_PROCESS_EVENT_INSTANCE_ID) return;
134
+ if (envelope.originPid === process.pid) return;
142
135
  await broadcastEventToConnections(
143
136
  envelope.event,
144
137
  envelope.payload ?? {},
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/events/api/stream/route.ts"],
4
- "sourcesContent": ["/**\n * SSE Event Stream \u2014 DOM Event Bridge\n *\n * Server-Sent Events endpoint that bridges server-side events to the browser.\n * Only events with `clientBroadcast: true` in their EventDefinition are sent.\n * Events are scoped to the authenticated user's tenant.\n *\n * Client consumer: `packages/ui/src/backend/injection/eventBridge.ts`\n */\n\nimport { resolveRequestContext } from '@open-mercato/shared/lib/api/context'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { isBroadcastEvent } from '@open-mercato/shared/modules/events'\nimport {\n CROSS_PROCESS_EVENT_INSTANCE_ID,\n registerCrossProcessEventListener,\n registerGlobalEventTap,\n} from '../../../../bus'\nimport type { EmitOptions } from '../../../../types'\n\nexport const metadata = {\n GET: { requireAuth: true },\n}\n\nconst HEARTBEAT_INTERVAL_MS = 30_000\nconst MAX_PAYLOAD_BYTES = 4096\n\nconst logger = createLogger('events').child({ component: 'stream' })\n\ntype SseConnection = {\n tenantId: string\n organizationId: string | null\n userId: string\n roleIds: string[]\n send: (data: string) => void\n close: () => void\n}\n\nfunction collectStringValues(input: unknown): string[] {\n if (!Array.isArray(input)) return []\n const values: string[] = []\n for (const value of input) {\n if (typeof value !== 'string') continue\n const trimmed = value.trim()\n if (!trimmed) continue\n values.push(trimmed)\n }\n return values\n}\n\nfunction normalizeAudience(data: Record<string, unknown>, options?: EmitOptions): {\n tenantId: string | null\n organizationScopes: string[]\n recipientUserScopes: string[]\n recipientRoleScopes: string[]\n} {\n const hasTrustedScope = options != null\n && Object.prototype.hasOwnProperty.call(options, 'tenantId')\n const trustedTenantId = typeof options?.tenantId === 'string' && options.tenantId.trim().length > 0\n ? options.tenantId.trim()\n : null\n const tenantId = hasTrustedScope\n ? trustedTenantId\n : (typeof data.tenantId === 'string' ? data.tenantId : null)\n const organizationScopes = new Set<string>()\n const trustedOrganizationId = typeof options?.organizationId === 'string' && options.organizationId.trim().length > 0\n ? options.organizationId.trim()\n : null\n if (hasTrustedScope) {\n if (trustedOrganizationId) organizationScopes.add(trustedOrganizationId)\n for (const organizationId of collectStringValues(options?.organizationIds)) {\n organizationScopes.add(organizationId)\n }\n } else {\n if (typeof data.organizationId === 'string' && data.organizationId.trim().length > 0) {\n organizationScopes.add(data.organizationId.trim())\n }\n for (const organizationId of collectStringValues(data.organizationIds)) {\n organizationScopes.add(organizationId)\n }\n }\n\n const recipientUserScopes = new Set<string>()\n if (typeof data.recipientUserId === 'string' && data.recipientUserId.trim().length > 0) {\n recipientUserScopes.add(data.recipientUserId.trim())\n }\n for (const userId of collectStringValues(data.recipientUserIds)) {\n recipientUserScopes.add(userId)\n }\n\n const recipientRoleScopes = new Set<string>()\n if (typeof data.recipientRoleId === 'string' && data.recipientRoleId.trim().length > 0) {\n recipientRoleScopes.add(data.recipientRoleId.trim())\n }\n for (const roleId of collectStringValues(data.recipientRoleIds)) {\n recipientRoleScopes.add(roleId)\n }\n\n return {\n tenantId,\n organizationScopes: Array.from(organizationScopes),\n recipientUserScopes: Array.from(recipientUserScopes),\n recipientRoleScopes: Array.from(recipientRoleScopes),\n }\n}\n\nfunction matchesAudience(conn: SseConnection, audience: ReturnType<typeof normalizeAudience>): boolean {\n if (!audience.tenantId) return false\n if (conn.tenantId !== audience.tenantId) return false\n\n if (audience.organizationScopes.length > 0) {\n if (!conn.organizationId) return false\n if (!audience.organizationScopes.includes(conn.organizationId)) return false\n }\n\n if (audience.recipientUserScopes.length > 0 && !audience.recipientUserScopes.includes(conn.userId)) {\n return false\n }\n\n if (audience.recipientRoleScopes.length > 0) {\n const roleMatched = conn.roleIds.some((roleId) => audience.recipientRoleScopes.includes(roleId))\n if (!roleMatched) return false\n }\n\n return true\n}\n\n/**\n * Global connection registry.\n * All active SSE connections are tracked here.\n * The event bus subscriber iterates this set on each broadcast event.\n */\nconst connections = new Set<SseConnection>()\n\nlet globalTapRegistered = false\n\nasync function broadcastEventToConnections(\n eventName: string,\n payload: Record<string, unknown>,\n options?: EmitOptions,\n): Promise<void> {\n if (!eventName || connections.size === 0) return\n\n if (!isBroadcastEvent(eventName)) return\n\n const data = payload ?? {}\n const audience = normalizeAudience(data, options)\n\n const organizationId = audience.organizationScopes[0] ?? ''\n let ssePayload = buildSsePayload(eventName, data, organizationId)\n\n if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {\n ssePayload = buildTruncatedPayload(eventName, data, organizationId)\n if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {\n logger.warn('Event payload exceeds size limit, skipping', { event: eventName, maxBytes: MAX_PAYLOAD_BYTES })\n return\n }\n }\n\n for (const conn of connections) {\n if (!matchesAudience(conn, audience)) continue\n\n try {\n conn.send(ssePayload)\n } catch {\n // Connection may have been closed; cleanup happens via abort handler\n }\n }\n}\n\nfunction buildSsePayload(eventName: string, data: Record<string, unknown>, organizationId: string): string {\n return JSON.stringify({\n id: eventName,\n payload: data,\n timestamp: Date.now(),\n organizationId,\n })\n}\n\nfunction buildTruncatedPayload(eventName: string, data: Record<string, unknown>, organizationId: string): string {\n const entityRef: Record<string, unknown> = {\n truncated: true,\n }\n if (typeof data.id === 'string' && data.id.trim().length > 0) {\n entityRef.id = data.id.trim()\n }\n if (typeof data.entityId === 'string' && data.entityId.trim().length > 0) {\n entityRef.entityId = data.entityId.trim()\n }\n if (typeof data.entityType === 'string' && data.entityType.trim().length > 0) {\n entityRef.entityType = data.entityType.trim()\n }\n return JSON.stringify({\n id: eventName,\n payload: entityRef,\n timestamp: Date.now(),\n organizationId,\n })\n}\n\n/**\n * Ensure a process-wide event tap is registered (once).\n * This captures emits from all request-scoped EventBus instances.\n */\nfunction ensureGlobalTapSubscription(): void {\n if (globalTapRegistered) return\n globalTapRegistered = true\n\n registerGlobalEventTap(async (eventName, payload, options) => {\n await broadcastEventToConnections(eventName, (payload ?? {}) as Record<string, unknown>, options)\n })\n\n registerCrossProcessEventListener(async (envelope) => {\n // Every envelope this process publishes carries originInstanceId, so an\n // envelope without one can only come from an older process during a\n // rolling deploy and is always foreign. Falling back to originPid would\n // discard it whenever both containers run under the same pid \u2014 commonly\n // pid 1 \u2014 silently dropping browser events across the upgrade window.\n if (envelope.originInstanceId === CROSS_PROCESS_EVENT_INSTANCE_ID) return\n await broadcastEventToConnections(\n envelope.event,\n (envelope.payload ?? {}) as Record<string, unknown>,\n envelope.options,\n )\n })\n}\n\nexport async function GET(req: Request): Promise<Response> {\n const { ctx } = await resolveRequestContext(req)\n\n if (!ctx.auth?.tenantId || !ctx.auth?.sub) {\n return new Response('Unauthorized', { status: 401 })\n }\n\n const tenantId = ctx.auth.tenantId\n const organizationId = (ctx.selectedOrganizationId as string) ?? ctx.auth.orgId ?? null\n const userId = ctx.auth.sub\n const roleIds = Array.isArray(ctx.auth.roles)\n ? ctx.auth.roles.filter((role): role is string => typeof role === 'string' && role.trim().length > 0)\n : []\n\n ensureGlobalTapSubscription()\n\n const encoder = new TextEncoder()\n let heartbeatTimer: ReturnType<typeof setInterval> | null = null\n let connection: SseConnection | null = null\n const onAbort = () => cleanup()\n\n const stream = new ReadableStream({\n start(controller) {\n const send = (data: string) => {\n controller.enqueue(encoder.encode(`data: ${data}\\n\\n`))\n }\n\n connection = {\n tenantId,\n organizationId,\n userId,\n roleIds,\n send,\n close: () => controller.close(),\n }\n connections.add(connection)\n\n // Flush an initial comment so the runtime sends the response headers and\n // first body byte immediately. Without it, the streamed Response stays\n // unflushed until the first heartbeat (30s) or matching event, which\n // delays the browser EventSource `open` event \u2014 clients that gate work on\n // a \"connected\" signal would otherwise stall for up to 30s after mount.\n // Comment lines (`:` prefix) are ignored by EventSource message parsing.\n controller.enqueue(encoder.encode(': connected\\n\\n'))\n\n // Start heartbeat to keep connection alive\n heartbeatTimer = setInterval(() => {\n try {\n controller.enqueue(encoder.encode(':heartbeat\\n\\n'))\n } catch {\n // Stream may have been closed\n }\n }, HEARTBEAT_INTERVAL_MS)\n },\n cancel() {\n cleanup()\n },\n })\n\n function cleanup() {\n if (heartbeatTimer) {\n clearInterval(heartbeatTimer)\n heartbeatTimer = null\n }\n if (connection) {\n connections.delete(connection)\n connection = null\n }\n // Detach from the request signal so reconnect churn does not accumulate\n // listeners and closures on long-lived AbortSignals.\n req.signal.removeEventListener('abort', onAbort)\n }\n\n req.signal.addEventListener('abort', onAbort, { once: true })\n\n return new Response(stream, {\n status: 200,\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache, no-transform',\n 'Connection': 'keep-alive',\n 'X-Accel-Buffering': 'no',\n },\n })\n}\n\nexport const openApi = {\n GET: {\n summary: 'Subscribe to server events via SSE (DOM Event Bridge)',\n description: 'Long-lived SSE connection that receives server-side events marked with clientBroadcast: true. Events are server-filtered by tenant, organization, recipient user, and recipient role.',\n tags: ['Events'],\n responses: {\n 200: {\n description: 'Event stream (text/event-stream)',\n content: {\n 'text/event-stream': {\n schema: {\n type: 'object',\n properties: {\n id: { type: 'string', description: 'Event identifier (e.g., example.todo.created)' },\n payload: { type: 'object', description: 'Event-specific data' },\n timestamp: { type: 'number', description: 'Server timestamp (ms since epoch)' },\n organizationId: { type: 'string', description: 'Organization scope' },\n },\n },\n },\n },\n },\n 401: { description: 'Not authenticated' },\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * SSE Event Stream \u2014 DOM Event Bridge\n *\n * Server-Sent Events endpoint that bridges server-side events to the browser.\n * Only events with `clientBroadcast: true` in their EventDefinition are sent.\n * Events are scoped to the authenticated user's tenant.\n *\n * Client consumer: `packages/ui/src/backend/injection/eventBridge.ts`\n */\n\nimport { resolveRequestContext } from '@open-mercato/shared/lib/api/context'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { isBroadcastEvent } from '@open-mercato/shared/modules/events'\nimport { registerCrossProcessEventListener, registerGlobalEventTap } from '../../../../bus'\nimport type { EmitOptions } from '../../../../types'\n\nexport const metadata = {\n GET: { requireAuth: true },\n}\n\nconst HEARTBEAT_INTERVAL_MS = 30_000\nconst MAX_PAYLOAD_BYTES = 4096\n\nconst logger = createLogger('events').child({ component: 'stream' })\n\ntype SseConnection = {\n tenantId: string\n organizationId: string | null\n userId: string\n roleIds: string[]\n send: (data: string) => void\n close: () => void\n}\n\nfunction collectStringValues(input: unknown): string[] {\n if (!Array.isArray(input)) return []\n const values: string[] = []\n for (const value of input) {\n if (typeof value !== 'string') continue\n const trimmed = value.trim()\n if (!trimmed) continue\n values.push(trimmed)\n }\n return values\n}\n\nfunction normalizeAudience(data: Record<string, unknown>, options?: EmitOptions): {\n tenantId: string | null\n organizationScopes: string[]\n recipientUserScopes: string[]\n recipientRoleScopes: string[]\n} {\n const hasTrustedScope = options != null\n && Object.prototype.hasOwnProperty.call(options, 'tenantId')\n const trustedTenantId = typeof options?.tenantId === 'string' && options.tenantId.trim().length > 0\n ? options.tenantId.trim()\n : null\n const tenantId = hasTrustedScope\n ? trustedTenantId\n : (typeof data.tenantId === 'string' ? data.tenantId : null)\n const organizationScopes = new Set<string>()\n const trustedOrganizationId = typeof options?.organizationId === 'string' && options.organizationId.trim().length > 0\n ? options.organizationId.trim()\n : null\n if (hasTrustedScope) {\n if (trustedOrganizationId) organizationScopes.add(trustedOrganizationId)\n } else {\n if (typeof data.organizationId === 'string' && data.organizationId.trim().length > 0) {\n organizationScopes.add(data.organizationId.trim())\n }\n for (const organizationId of collectStringValues(data.organizationIds)) {\n organizationScopes.add(organizationId)\n }\n }\n\n const recipientUserScopes = new Set<string>()\n if (typeof data.recipientUserId === 'string' && data.recipientUserId.trim().length > 0) {\n recipientUserScopes.add(data.recipientUserId.trim())\n }\n for (const userId of collectStringValues(data.recipientUserIds)) {\n recipientUserScopes.add(userId)\n }\n\n const recipientRoleScopes = new Set<string>()\n if (typeof data.recipientRoleId === 'string' && data.recipientRoleId.trim().length > 0) {\n recipientRoleScopes.add(data.recipientRoleId.trim())\n }\n for (const roleId of collectStringValues(data.recipientRoleIds)) {\n recipientRoleScopes.add(roleId)\n }\n\n return {\n tenantId,\n organizationScopes: Array.from(organizationScopes),\n recipientUserScopes: Array.from(recipientUserScopes),\n recipientRoleScopes: Array.from(recipientRoleScopes),\n }\n}\n\nfunction matchesAudience(conn: SseConnection, audience: ReturnType<typeof normalizeAudience>): boolean {\n if (!audience.tenantId) return false\n if (conn.tenantId !== audience.tenantId) return false\n\n if (audience.organizationScopes.length > 0) {\n if (!conn.organizationId) return false\n if (!audience.organizationScopes.includes(conn.organizationId)) return false\n }\n\n if (audience.recipientUserScopes.length > 0 && !audience.recipientUserScopes.includes(conn.userId)) {\n return false\n }\n\n if (audience.recipientRoleScopes.length > 0) {\n const roleMatched = conn.roleIds.some((roleId) => audience.recipientRoleScopes.includes(roleId))\n if (!roleMatched) return false\n }\n\n return true\n}\n\n/**\n * Global connection registry.\n * All active SSE connections are tracked here.\n * The event bus subscriber iterates this set on each broadcast event.\n */\nconst connections = new Set<SseConnection>()\n\nlet globalTapRegistered = false\n\nasync function broadcastEventToConnections(\n eventName: string,\n payload: Record<string, unknown>,\n options?: EmitOptions,\n): Promise<void> {\n if (!eventName || connections.size === 0) return\n\n if (!isBroadcastEvent(eventName)) return\n\n const data = payload ?? {}\n const audience = normalizeAudience(data, options)\n\n const organizationId = audience.organizationScopes[0] ?? ''\n let ssePayload = buildSsePayload(eventName, data, organizationId)\n\n if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {\n ssePayload = buildTruncatedPayload(eventName, data, organizationId)\n if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {\n logger.warn('Event payload exceeds size limit, skipping', { event: eventName, maxBytes: MAX_PAYLOAD_BYTES })\n return\n }\n }\n\n for (const conn of connections) {\n if (!matchesAudience(conn, audience)) continue\n\n try {\n conn.send(ssePayload)\n } catch {\n // Connection may have been closed; cleanup happens via abort handler\n }\n }\n}\n\nfunction buildSsePayload(eventName: string, data: Record<string, unknown>, organizationId: string): string {\n return JSON.stringify({\n id: eventName,\n payload: data,\n timestamp: Date.now(),\n organizationId,\n })\n}\n\nfunction buildTruncatedPayload(eventName: string, data: Record<string, unknown>, organizationId: string): string {\n const entityRef: Record<string, unknown> = {\n truncated: true,\n }\n if (typeof data.id === 'string' && data.id.trim().length > 0) {\n entityRef.id = data.id.trim()\n }\n if (typeof data.entityId === 'string' && data.entityId.trim().length > 0) {\n entityRef.entityId = data.entityId.trim()\n }\n if (typeof data.entityType === 'string' && data.entityType.trim().length > 0) {\n entityRef.entityType = data.entityType.trim()\n }\n return JSON.stringify({\n id: eventName,\n payload: entityRef,\n timestamp: Date.now(),\n organizationId,\n })\n}\n\n/**\n * Ensure a process-wide event tap is registered (once).\n * This captures emits from all request-scoped EventBus instances.\n */\nfunction ensureGlobalTapSubscription(): void {\n if (globalTapRegistered) return\n globalTapRegistered = true\n\n registerGlobalEventTap(async (eventName, payload, options) => {\n await broadcastEventToConnections(eventName, (payload ?? {}) as Record<string, unknown>, options)\n })\n\n registerCrossProcessEventListener(async (envelope) => {\n if (envelope.originPid === process.pid) return\n await broadcastEventToConnections(\n envelope.event,\n (envelope.payload ?? {}) as Record<string, unknown>,\n envelope.options,\n )\n })\n}\n\nexport async function GET(req: Request): Promise<Response> {\n const { ctx } = await resolveRequestContext(req)\n\n if (!ctx.auth?.tenantId || !ctx.auth?.sub) {\n return new Response('Unauthorized', { status: 401 })\n }\n\n const tenantId = ctx.auth.tenantId\n const organizationId = (ctx.selectedOrganizationId as string) ?? ctx.auth.orgId ?? null\n const userId = ctx.auth.sub\n const roleIds = Array.isArray(ctx.auth.roles)\n ? ctx.auth.roles.filter((role): role is string => typeof role === 'string' && role.trim().length > 0)\n : []\n\n ensureGlobalTapSubscription()\n\n const encoder = new TextEncoder()\n let heartbeatTimer: ReturnType<typeof setInterval> | null = null\n let connection: SseConnection | null = null\n const onAbort = () => cleanup()\n\n const stream = new ReadableStream({\n start(controller) {\n const send = (data: string) => {\n controller.enqueue(encoder.encode(`data: ${data}\\n\\n`))\n }\n\n connection = {\n tenantId,\n organizationId,\n userId,\n roleIds,\n send,\n close: () => controller.close(),\n }\n connections.add(connection)\n\n // Flush an initial comment so the runtime sends the response headers and\n // first body byte immediately. Without it, the streamed Response stays\n // unflushed until the first heartbeat (30s) or matching event, which\n // delays the browser EventSource `open` event \u2014 clients that gate work on\n // a \"connected\" signal would otherwise stall for up to 30s after mount.\n // Comment lines (`:` prefix) are ignored by EventSource message parsing.\n controller.enqueue(encoder.encode(': connected\\n\\n'))\n\n // Start heartbeat to keep connection alive\n heartbeatTimer = setInterval(() => {\n try {\n controller.enqueue(encoder.encode(':heartbeat\\n\\n'))\n } catch {\n // Stream may have been closed\n }\n }, HEARTBEAT_INTERVAL_MS)\n },\n cancel() {\n cleanup()\n },\n })\n\n function cleanup() {\n if (heartbeatTimer) {\n clearInterval(heartbeatTimer)\n heartbeatTimer = null\n }\n if (connection) {\n connections.delete(connection)\n connection = null\n }\n // Detach from the request signal so reconnect churn does not accumulate\n // listeners and closures on long-lived AbortSignals.\n req.signal.removeEventListener('abort', onAbort)\n }\n\n req.signal.addEventListener('abort', onAbort, { once: true })\n\n return new Response(stream, {\n status: 200,\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache, no-transform',\n 'Connection': 'keep-alive',\n 'X-Accel-Buffering': 'no',\n },\n })\n}\n\nexport const openApi = {\n GET: {\n summary: 'Subscribe to server events via SSE (DOM Event Bridge)',\n description: 'Long-lived SSE connection that receives server-side events marked with clientBroadcast: true. Events are server-filtered by tenant, organization, recipient user, and recipient role.',\n tags: ['Events'],\n responses: {\n 200: {\n description: 'Event stream (text/event-stream)',\n content: {\n 'text/event-stream': {\n schema: {\n type: 'object',\n properties: {\n id: { type: 'string', description: 'Event identifier (e.g., example.todo.created)' },\n payload: { type: 'object', description: 'Event-specific data' },\n timestamp: { type: 'number', description: 'Server timestamp (ms since epoch)' },\n organizationId: { type: 'string', description: 'Organization scope' },\n },\n },\n },\n },\n },\n 401: { description: 'Not authenticated' },\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/events",
3
- "version": "0.6.8-develop.7099.1.7ba2771d06",
3
+ "version": "0.7.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -33,8 +33,8 @@
33
33
  }
34
34
  },
35
35
  "dependencies": {
36
- "@open-mercato/queue": "0.6.8-develop.7099.1.7ba2771d06",
37
- "@open-mercato/shared": "0.6.8-develop.7099.1.7ba2771d06",
36
+ "@open-mercato/queue": "0.7.0",
37
+ "@open-mercato/shared": "0.7.0",
38
38
  "pg": "8.22.0"
39
39
  },
40
40
  "devDependencies": {
@@ -50,6 +50,5 @@
50
50
  "type": "git",
51
51
  "url": "https://github.com/open-mercato/open-mercato",
52
52
  "directory": "packages/events"
53
- },
54
- "stableVersion": "0.6.7"
53
+ }
55
54
  }
package/src/bridge.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { randomUUID } from 'node:crypto'
2
1
  import { Client, Pool } from 'pg'
3
2
  import { getSslConfig } from '@open-mercato/shared/lib/db/ssl'
4
3
  import { createLogger } from '@open-mercato/shared/lib/logger'
@@ -7,33 +6,14 @@ import type { EmitOptions, EventPayload } from './types'
7
6
  const BRIDGE_CHANNEL = 'om_event_bridge'
8
7
  const MAX_MESSAGE_BYTES = 7_000
9
8
  const RECONNECT_DELAY_MS = 1_000
10
- const CROSS_PROCESS_EVENT_INSTANCE_ID_KEY = '__openMercatoCrossProcessEventInstanceId__'
11
9
 
12
10
  const logger = createLogger('events')
13
11
 
14
- /**
15
- * PIDs can collide across containers, so self-echo suppression must not rely
16
- * on originPid alone. Every publisher stamps envelopes with this random
17
- * per-process instance id; consumers prefer it and fall back to originPid
18
- * only for envelopes published by older processes during a rolling deploy.
19
- */
20
- function getCrossProcessEventInstanceId(): string {
21
- const globalScope = globalThis as Record<string, unknown>
22
- const existing = globalScope[CROSS_PROCESS_EVENT_INSTANCE_ID_KEY]
23
- if (typeof existing === 'string' && existing.length > 0) return existing
24
- const created = randomUUID()
25
- globalScope[CROSS_PROCESS_EVENT_INSTANCE_ID_KEY] = created
26
- return created
27
- }
28
-
29
- export const CROSS_PROCESS_EVENT_INSTANCE_ID = getCrossProcessEventInstanceId()
30
-
31
12
  type BridgeEnvelope = {
32
13
  event: string
33
14
  payload: EventPayload
34
15
  options?: EmitOptions
35
16
  originPid: number
36
- originInstanceId?: string
37
17
  }
38
18
 
39
19
  type CrossProcessEventListener = (envelope: BridgeEnvelope) => void | Promise<void>
@@ -189,7 +169,6 @@ export async function publishCrossProcessEvent(
189
169
  payload,
190
170
  options: bridgeOptions,
191
171
  originPid: process.pid,
192
- originInstanceId: CROSS_PROCESS_EVENT_INSTANCE_ID,
193
172
  }
194
173
 
195
174
  const serialized = JSON.stringify(envelope)
package/src/bus.ts CHANGED
@@ -5,16 +5,12 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
5
5
  import { isSingleDeliveryRequested } from './single-delivery'
6
6
  import { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'
7
7
  import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
8
- import {
9
- isBroadcastEvent,
10
- isCrossProcessBroadcastEvent,
11
- isPrivateCrossProcessEventEmitter,
12
- } from '@open-mercato/shared/modules/events'
8
+ import { isBroadcastEvent } from '@open-mercato/shared/modules/events'
13
9
  import {
14
10
  inferModuleIdFromResourceId,
15
11
  withModuleResourceUsage,
16
12
  } from '@open-mercato/shared/lib/modules/resource-usage'
17
- export { registerCrossProcessEventListener, CROSS_PROCESS_EVENT_INSTANCE_ID } from './bridge'
13
+ export { registerCrossProcessEventListener } from './bridge'
18
14
  import { publishCrossProcessEvent } from './bridge'
19
15
  import type {
20
16
  EventBus,
@@ -61,38 +57,9 @@ function isSingleDeliveryEnabled(): boolean {
61
57
  type GlobalEventTap = (event: string, payload: EventPayload, options?: EmitOptions) => void | Promise<void>
62
58
  const GLOBAL_EVENT_TAPS_KEY = '__openMercatoEventBusGlobalTaps__'
63
59
 
64
- function hasTrustedTenantScope(options?: EmitOptions): boolean {
65
- return typeof options?.tenantId === 'string' && options.tenantId.trim().length > 0
66
- }
67
-
68
- function normalizePayloadScope(value: unknown): string | null {
69
- return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
70
- }
71
-
72
- function resolveCrossProcessEmitOptions(
73
- event: string,
74
- payload: EventPayload,
75
- options?: EmitOptions,
76
- ): EmitOptions | undefined {
77
- if (hasTrustedTenantScope(options)) return options
78
- // Preserve the public raw EventBus contract for legacy clientBroadcast
79
- // emitters. These callers are trusted server code and historically supplied
80
- // their scope in the typed payload. Tenant-managed workflow execution passes
81
- // scope in options, while private cross-process events never take this path.
82
- if (!isBroadcastEvent(event)) return options
83
- const tenantId = normalizePayloadScope((payload as Record<string, unknown>)?.tenantId)
84
- if (!tenantId) return options
85
- const organizationId = normalizePayloadScope((payload as Record<string, unknown>)?.organizationId)
86
- const organizationIdsValue = (payload as Record<string, unknown>)?.organizationIds
87
- const organizationIds = Array.isArray(organizationIdsValue)
88
- ? organizationIdsValue.filter((value): value is string => typeof value === 'string')
89
- : []
90
- return {
91
- ...options,
92
- tenantId,
93
- ...(organizationId ? { organizationId } : {}),
94
- ...(organizationIds.length > 0 ? { organizationIds } : {}),
95
- }
60
+ function hasTenantScope(payload: EventPayload): boolean {
61
+ return typeof (payload as Record<string, unknown>)?.tenantId === 'string'
62
+ && String((payload as Record<string, unknown>).tenantId).trim().length > 0
96
63
  }
97
64
 
98
65
  function getGlobalEventTaps(): Set<GlobalEventTap> {
@@ -468,18 +435,9 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
468
435
  inlinePersistentFailed = delivered.persistentFailures > 0
469
436
  }
470
437
 
471
- // Private coordination always requires trusted envelope scope and module
472
- // provenance. Legacy declared browser events may promote their typed scope
473
- // for raw EventBus compatibility; tenant-managed workflow adapters pass
474
- // authoritative scope in options, which always wins over payload values.
475
- const crossProcessOptions = resolveCrossProcessEmitOptions(event, payload, options)
476
- if (
477
- isCrossProcessBroadcastEvent(event)
478
- && hasTrustedTenantScope(crossProcessOptions)
479
- && isPrivateCrossProcessEventEmitter(event, crossProcessOptions?.emitterModuleId)
480
- ) {
438
+ if (isBroadcastEvent(event) && hasTenantScope(payload)) {
481
439
  try {
482
- await publishCrossProcessEvent(event, payload, crossProcessOptions)
440
+ await publishCrossProcessEvent(event, payload, options)
483
441
  } catch (error) {
484
442
  logger.error('Cross-process publish error', { event, err: error })
485
443
  }
@@ -2,8 +2,8 @@
2
2
  * Events registry API — returns declared events from module `events.ts` files.
3
3
  *
4
4
  * Uses the globally registered event configs (populated during bootstrap). Data
5
- * leaked here (event ids, module ids, entity ids, broadcast flags) is
6
- * recon-grade and MUST NOT be served to anonymous callers; the route
5
+ * leaked here (event ids, module ids, entity ids, clientBroadcast/portalBroadcast
6
+ * flags) is recon-grade and MUST NOT be served to anonymous callers; the route
7
7
  * is protected by the standard `[...slug]` dispatcher with `requireAuth: true`.
8
8
  * Consumer: `packages/ui/src/backend/inputs/EventSelect.tsx` (workflow triggers,
9
9
  * business rules, webhook config).
@@ -27,7 +27,6 @@ const eventDefinitionSchema = z.object({
27
27
  entity: z.string().optional(),
28
28
  excludeFromTriggers: z.boolean().optional(),
29
29
  clientBroadcast: z.boolean().optional(),
30
- crossProcessBroadcast: z.boolean().optional(),
31
30
  portalBroadcast: z.boolean().optional(),
32
31
  })
33
32
 
@@ -11,7 +11,6 @@ jest.mock('@open-mercato/shared/lib/api/context', () => ({
11
11
  type EmitOptions = {
12
12
  tenantId?: string | null
13
13
  organizationId?: string | null
14
- organizationIds?: string[] | null
15
14
  }
16
15
 
17
16
  type GlobalEventTap = (
@@ -22,35 +21,18 @@ type GlobalEventTap = (
22
21
 
23
22
  let mockGlobalEventTap: GlobalEventTap | undefined
24
23
 
25
- const registerGlobalEventTapMock = jest.fn((handler: GlobalEventTap) => {
26
- mockGlobalEventTap = handler
27
- })
28
- const registerCrossProcessEventListenerMock = jest.fn()
29
-
30
24
  jest.mock('../../../../../bus', () => ({
31
- registerGlobalEventTap: (handler: GlobalEventTap) => registerGlobalEventTapMock(handler),
32
- registerCrossProcessEventListener: (...args: unknown[]) => registerCrossProcessEventListenerMock(...args),
33
- CROSS_PROCESS_EVENT_INSTANCE_ID: 'web-instance',
25
+ registerGlobalEventTap: jest.fn((handler: GlobalEventTap) => {
26
+ mockGlobalEventTap = handler
27
+ }),
28
+ registerCrossProcessEventListener: jest.fn(),
34
29
  }))
35
30
 
36
- import { createModuleEvents } from '@open-mercato/shared/modules/events'
37
- import { GET } from '@open-mercato/events/modules/events/api/stream/route'
31
+ jest.mock('@open-mercato/shared/modules/events', () => ({
32
+ isBroadcastEvent: jest.fn(() => true),
33
+ }))
38
34
 
39
- createModuleEvents({
40
- moduleId: 'stream_privacy_test',
41
- events: [
42
- {
43
- id: 'stream_privacy_test.browser',
44
- label: 'Browser event',
45
- clientBroadcast: true,
46
- },
47
- {
48
- id: 'stream_privacy_test.private',
49
- label: 'Private cross-process invalidation',
50
- crossProcessBroadcast: true,
51
- },
52
- ] as const,
53
- })
35
+ import { GET } from '@open-mercato/events/modules/events/api/stream/route'
54
36
 
55
37
  // req.signal is a linked/derived signal in Node, so we spy AFTER the
56
38
  // Request is constructed to intercept the handler's real calls.
@@ -132,12 +114,12 @@ describe('SSE event stream — abort listener hygiene', () => {
132
114
 
133
115
  expect(mockGlobalEventTap).toBeDefined()
134
116
  await mockGlobalEventTap?.(
135
- 'stream_privacy_test.browser',
117
+ 'progress.job.updated',
136
118
  { tenantId: 't1', marker: 'must-not-arrive' },
137
119
  { tenantId: 't1', organizationId: 'o2' },
138
120
  )
139
121
  await mockGlobalEventTap?.(
140
- 'stream_privacy_test.browser',
122
+ 'progress.job.updated',
141
123
  { tenantId: 't1', marker: 'expected' },
142
124
  { tenantId: 't1', organizationId: 'o1' },
143
125
  )
@@ -149,124 +131,6 @@ describe('SSE event stream — abort listener hygiene', () => {
149
131
  try { await reader.cancel() } catch {}
150
132
  })
151
133
 
152
- it('honors a trusted multi-organization audience for a clientBroadcast event', async () => {
153
- const { req } = makeTrackedRequest()
154
- const res = await GET(req)
155
- expect(res.status).toBe(200)
156
-
157
- const reader = (res.body as ReadableStream<Uint8Array>).getReader()
158
- await reader.read()
159
-
160
- expect(mockGlobalEventTap).toBeDefined()
161
- // Connection is scoped to org o1; an audience array that omits it must not deliver.
162
- await mockGlobalEventTap?.(
163
- 'stream_privacy_test.browser',
164
- { tenantId: 't1', marker: 'must-not-arrive' },
165
- { tenantId: 't1', organizationIds: ['o2', 'o3'] },
166
- )
167
- // An audience array that includes o1 must deliver, even without a singular organizationId.
168
- await mockGlobalEventTap?.(
169
- 'stream_privacy_test.browser',
170
- { tenantId: 't1', marker: 'expected' },
171
- { tenantId: 't1', organizationIds: ['o1', 'o2'] },
172
- )
173
-
174
- const { value, done } = await reader.read()
175
- expect(done).toBe(false)
176
- expect(new TextDecoder().decode(value)).toContain('"marker":"expected"')
177
-
178
- try { await reader.cancel() } catch {}
179
- })
180
-
181
- it('does not deliver a private cross-process event to a same-organization browser', async () => {
182
- const { req } = makeTrackedRequest()
183
- const res = await GET(req)
184
- const reader = (res.body as ReadableStream<Uint8Array>).getReader()
185
- await reader.read()
186
-
187
- const listener = registerCrossProcessEventListenerMock.mock.calls[0]?.[0] as
188
- | ((envelope: Record<string, unknown>) => Promise<void>)
189
- | undefined
190
- expect(listener).toBeDefined()
191
-
192
- await listener?.({
193
- event: 'stream_privacy_test.private',
194
- payload: {
195
- id: 'private-record',
196
- tenantId: 't1',
197
- organizationId: 'o1',
198
- },
199
- originPid: process.pid + 1,
200
- originInstanceId: 'other-instance',
201
- })
202
-
203
- const pendingRead = reader.read().then(() => 'delivered')
204
- const result = await Promise.race([
205
- pendingRead,
206
- new Promise<'not-delivered'>((resolve) => setTimeout(() => resolve('not-delivered'), 10)),
207
- ])
208
- expect(result).toBe('not-delivered')
209
-
210
- try { await reader.cancel() } catch {}
211
- })
212
-
213
- it('does not let a forged global-tap payload override trusted tenant and organization scope', async () => {
214
- const { req } = makeTrackedRequest()
215
- const res = await GET(req)
216
- const reader = (res.body as ReadableStream<Uint8Array>).getReader()
217
- await reader.read()
218
-
219
- const tap = registerGlobalEventTapMock.mock.calls[0]?.[0] as
220
- | ((
221
- event: string,
222
- payload: Record<string, unknown>,
223
- options?: { tenantId?: string | null; organizationId?: string | null },
224
- ) => Promise<void>)
225
- | undefined
226
- expect(tap).toBeDefined()
227
-
228
- await tap?.(
229
- 'stream_privacy_test.browser',
230
- { tenantId: 't1', organizationId: 'o1', marker: 'forged-payload' },
231
- { tenantId: 'attacker-tenant', organizationId: 'attacker-org' },
232
- )
233
-
234
- const pendingRead = reader.read().then(() => 'delivered')
235
- const result = await Promise.race([
236
- pendingRead,
237
- new Promise<'not-delivered'>((resolve) => setTimeout(() => resolve('not-delivered'), 10)),
238
- ])
239
- expect(result).toBe('not-delivered')
240
-
241
- try { await reader.cancel() } catch {}
242
- })
243
-
244
- it('delivers a global-tap event when trusted scope matches despite forged payload scope', async () => {
245
- const { req } = makeTrackedRequest()
246
- const res = await GET(req)
247
- const reader = (res.body as ReadableStream<Uint8Array>).getReader()
248
- await reader.read()
249
-
250
- const tap = registerGlobalEventTapMock.mock.calls[0]?.[0] as
251
- | ((
252
- event: string,
253
- payload: Record<string, unknown>,
254
- options?: { tenantId?: string | null; organizationId?: string | null },
255
- ) => Promise<void>)
256
- | undefined
257
-
258
- await tap?.(
259
- 'stream_privacy_test.browser',
260
- { tenantId: 'forged-tenant', organizationId: 'forged-org', marker: 'trusted-delivery' },
261
- { tenantId: 't1', organizationId: 'o1' },
262
- )
263
-
264
- const delivered = await reader.read()
265
- expect(new TextDecoder().decode(delivered.value)).toContain('trusted-delivery')
266
-
267
- try { await reader.cancel() } catch {}
268
- })
269
-
270
134
  it('ignores conflicting payload scope when trusted scope matches the connection', async () => {
271
135
  const { req } = makeTrackedRequest()
272
136
  const res = await GET(req)
@@ -277,7 +141,7 @@ describe('SSE event stream — abort listener hygiene', () => {
277
141
 
278
142
  expect(mockGlobalEventTap).toBeDefined()
279
143
  await mockGlobalEventTap?.(
280
- 'stream_privacy_test.browser',
144
+ 'progress.job.updated',
281
145
  { tenantId: 'forged-tenant', organizationId: 'forged-organization', marker: 'expected' },
282
146
  { tenantId: 't1', organizationId: 'o1' },
283
147
  )
@@ -289,35 +153,6 @@ describe('SSE event stream — abort listener hygiene', () => {
289
153
  try { await reader.cancel() } catch {}
290
154
  })
291
155
 
292
- it('does not let a forged cross-process payload override trusted envelope scope', async () => {
293
- const { req } = makeTrackedRequest()
294
- const res = await GET(req)
295
- const reader = (res.body as ReadableStream<Uint8Array>).getReader()
296
- await reader.read()
297
-
298
- const listener = registerCrossProcessEventListenerMock.mock.calls[0]?.[0] as
299
- | ((envelope: Record<string, unknown>) => Promise<void>)
300
- | undefined
301
- expect(listener).toBeDefined()
302
-
303
- await listener?.({
304
- event: 'stream_privacy_test.browser',
305
- payload: { tenantId: 't1', organizationId: 'o1', marker: 'forged-payload' },
306
- options: { tenantId: 'attacker-tenant', organizationId: 'attacker-org' },
307
- originPid: process.pid + 1,
308
- originInstanceId: 'other-instance',
309
- })
310
-
311
- const pendingRead = reader.read().then(() => 'delivered')
312
- const result = await Promise.race([
313
- pendingRead,
314
- new Promise<'not-delivered'>((resolve) => setTimeout(() => resolve('not-delivered'), 10)),
315
- ])
316
- expect(result).toBe('not-delivered')
317
-
318
- try { await reader.cancel() } catch {}
319
- })
320
-
321
156
  it('preserves payload-authored scope for legacy emitters without a trusted scope marker', async () => {
322
157
  const { req } = makeTrackedRequest()
323
158
  const res = await GET(req)
@@ -328,7 +163,7 @@ describe('SSE event stream — abort listener hygiene', () => {
328
163
 
329
164
  expect(mockGlobalEventTap).toBeDefined()
330
165
  await mockGlobalEventTap?.(
331
- 'stream_privacy_test.browser',
166
+ 'progress.job.updated',
332
167
  { tenantId: 't1', organizationId: 'o1', marker: 'legacy-expected' },
333
168
  )
334
169
 
@@ -339,88 +174,6 @@ describe('SSE event stream — abort listener hygiene', () => {
339
174
  try { await reader.cancel() } catch {}
340
175
  })
341
176
 
342
- it('delivers a cross-process event when trusted envelope scope matches despite forged payload scope', async () => {
343
- const { req } = makeTrackedRequest()
344
- const res = await GET(req)
345
- const reader = (res.body as ReadableStream<Uint8Array>).getReader()
346
- await reader.read()
347
-
348
- const listener = registerCrossProcessEventListenerMock.mock.calls[0]?.[0] as
349
- | ((envelope: Record<string, unknown>) => Promise<void>)
350
- | undefined
351
-
352
- await listener?.({
353
- event: 'stream_privacy_test.browser',
354
- payload: { tenantId: 'forged-tenant', organizationId: 'forged-org', marker: 'trusted-envelope' },
355
- options: { tenantId: 't1', organizationId: 'o1' },
356
- originPid: process.pid + 1,
357
- originInstanceId: 'other-instance',
358
- })
359
-
360
- const delivered = await reader.read()
361
- expect(new TextDecoder().decode(delivered.value)).toContain('trusted-envelope')
362
-
363
- try { await reader.cancel() } catch {}
364
- })
365
-
366
- it('delivers a rolling-deploy envelope that omits the instance id and shares this pid', async () => {
367
- const { req } = makeTrackedRequest()
368
- const res = await GET(req)
369
- const reader = (res.body as ReadableStream<Uint8Array>).getReader()
370
- await reader.read()
371
-
372
- const listener = registerCrossProcessEventListenerMock.mock.calls[0]?.[0] as
373
- | ((envelope: Record<string, unknown>) => Promise<void>)
374
- | undefined
375
- expect(listener).toBeDefined()
376
-
377
- // Containers commonly run as pid 1, so an older replica publishing without
378
- // an instance id must not be mistaken for this process.
379
- await listener?.({
380
- event: 'stream_privacy_test.browser',
381
- payload: { marker: 'legacy-replica' },
382
- options: { tenantId: 't1', organizationId: 'o1' },
383
- originPid: process.pid,
384
- })
385
-
386
- const pendingRead = reader.read().then((chunk) => new TextDecoder().decode(chunk.value))
387
- const result = await Promise.race([
388
- pendingRead,
389
- new Promise<'dropped'>((resolve) => setTimeout(() => resolve('dropped'), 50)),
390
- ])
391
- expect(result).toContain('legacy-replica')
392
-
393
- try { await reader.cancel() } catch {}
394
- })
395
-
396
- it('suppresses an envelope published by this instance even when the pid differs', async () => {
397
- const { req } = makeTrackedRequest()
398
- const res = await GET(req)
399
- const reader = (res.body as ReadableStream<Uint8Array>).getReader()
400
- await reader.read()
401
-
402
- const listener = registerCrossProcessEventListenerMock.mock.calls[0]?.[0] as
403
- | ((envelope: Record<string, unknown>) => Promise<void>)
404
- | undefined
405
-
406
- await listener?.({
407
- event: 'stream_privacy_test.browser',
408
- payload: { marker: 'self-echo' },
409
- options: { tenantId: 't1', organizationId: 'o1' },
410
- originPid: process.pid + 1,
411
- originInstanceId: 'web-instance',
412
- })
413
-
414
- const pendingRead = reader.read().then(() => 'delivered')
415
- const result = await Promise.race([
416
- pendingRead,
417
- new Promise<'not-delivered'>((resolve) => setTimeout(() => resolve('not-delivered'), 10)),
418
- ])
419
- expect(result).toBe('not-delivered')
420
-
421
- try { await reader.cancel() } catch {}
422
- })
423
-
424
177
  it('does not fall back to payload scope when the trusted tenant marker is empty', async () => {
425
178
  const { req } = makeTrackedRequest()
426
179
  const res = await GET(req)
@@ -431,12 +184,12 @@ describe('SSE event stream — abort listener hygiene', () => {
431
184
 
432
185
  expect(mockGlobalEventTap).toBeDefined()
433
186
  await mockGlobalEventTap?.(
434
- 'stream_privacy_test.browser',
187
+ 'progress.job.updated',
435
188
  { tenantId: 't1', organizationId: 'o1', marker: 'must-not-arrive' },
436
189
  { tenantId: null, organizationId: null },
437
190
  )
438
191
  await mockGlobalEventTap?.(
439
- 'stream_privacy_test.browser',
192
+ 'progress.job.updated',
440
193
  { marker: 'expected' },
441
194
  { tenantId: 't1', organizationId: 'o1' },
442
195
  )
@@ -11,11 +11,7 @@
11
11
  import { resolveRequestContext } from '@open-mercato/shared/lib/api/context'
12
12
  import { createLogger } from '@open-mercato/shared/lib/logger'
13
13
  import { isBroadcastEvent } from '@open-mercato/shared/modules/events'
14
- import {
15
- CROSS_PROCESS_EVENT_INSTANCE_ID,
16
- registerCrossProcessEventListener,
17
- registerGlobalEventTap,
18
- } from '../../../../bus'
14
+ import { registerCrossProcessEventListener, registerGlobalEventTap } from '../../../../bus'
19
15
  import type { EmitOptions } from '../../../../types'
20
16
 
21
17
  export const metadata = {
@@ -68,9 +64,6 @@ function normalizeAudience(data: Record<string, unknown>, options?: EmitOptions)
68
64
  : null
69
65
  if (hasTrustedScope) {
70
66
  if (trustedOrganizationId) organizationScopes.add(trustedOrganizationId)
71
- for (const organizationId of collectStringValues(options?.organizationIds)) {
72
- organizationScopes.add(organizationId)
73
- }
74
67
  } else {
75
68
  if (typeof data.organizationId === 'string' && data.organizationId.trim().length > 0) {
76
69
  organizationScopes.add(data.organizationId.trim())
@@ -211,12 +204,7 @@ function ensureGlobalTapSubscription(): void {
211
204
  })
212
205
 
213
206
  registerCrossProcessEventListener(async (envelope) => {
214
- // Every envelope this process publishes carries originInstanceId, so an
215
- // envelope without one can only come from an older process during a
216
- // rolling deploy and is always foreign. Falling back to originPid would
217
- // discard it whenever both containers run under the same pid — commonly
218
- // pid 1 — silently dropping browser events across the upgrade window.
219
- if (envelope.originInstanceId === CROSS_PROCESS_EVENT_INSTANCE_ID) return
207
+ if (envelope.originPid === process.pid) return
220
208
  await broadcastEventToConnections(
221
209
  envelope.event,
222
210
  (envelope.payload ?? {}) as Record<string, unknown>,
package/src/types.ts CHANGED
@@ -100,18 +100,6 @@ export type EmitOptions = {
100
100
  tenantId?: string | null
101
101
  /** Trusted organization scope for subscribers that must not rely on payload scope */
102
102
  organizationId?: string | null
103
- /**
104
- * Trusted multi-organization audience for subscribers that must not rely on
105
- * payload scope. Mirrors the SSE audience contract where a clientBroadcast
106
- * event may target several organizations at once.
107
- */
108
- organizationIds?: string[] | null
109
- /**
110
- * Module provenance stamped by `createModuleEvents` for private
111
- * cross-process coordination events. Never derive this from payload data.
112
- * @internal
113
- */
114
- emitterModuleId?: string
115
103
  /** Opt-in path for callers that need inline subscriber failures to reject the emit. */
116
104
  rethrowHandlerErrors?: boolean
117
105
  }
@@ -1,170 +0,0 @@
1
- const publishCrossProcessEventMock = jest.fn(async () => undefined)
2
-
3
- jest.mock('../bridge', () => ({
4
- publishCrossProcessEvent: (...args: unknown[]) => publishCrossProcessEventMock(...args),
5
- registerCrossProcessEventListener: jest.fn(),
6
- CROSS_PROCESS_EVENT_INSTANCE_ID: 'test-instance',
7
- }))
8
-
9
- import { createModuleEvents, setGlobalEventBus } from '@open-mercato/shared/modules/events'
10
- import { createEventBus } from '@open-mercato/events/index'
11
-
12
- const testEventsConfig = createModuleEvents({
13
- moduleId: 'cross_process_test',
14
- events: [
15
- { id: 'cross_process_test.browser', label: 'Browser', clientBroadcast: true },
16
- { id: 'cross_process_test.private', label: 'Private', crossProcessBroadcast: true },
17
- { id: 'cross_process_test.local', label: 'Local' },
18
- ] as const,
19
- })
20
-
21
- describe('cross-process event publication', () => {
22
- const resolve = ((name: string) => name) as never
23
-
24
- beforeEach(() => {
25
- publishCrossProcessEventMock.mockClear()
26
- })
27
-
28
- it('publishes browser and private cross-process events without changing local events', async () => {
29
- const bus = createEventBus({ resolve, queueStrategy: 'local' })
30
- const payload = { id: 'record-1', tenantId: 'tenant-1', organizationId: 'org-1' }
31
- const trustedScope = { tenantId: 'tenant-1', organizationId: 'org-1' }
32
- const privateTrustedScope = {
33
- ...trustedScope,
34
- emitterModuleId: 'cross_process_test',
35
- }
36
-
37
- await bus.emit('cross_process_test.browser', payload, trustedScope)
38
- await bus.emit('cross_process_test.private', payload, privateTrustedScope)
39
- await bus.emit('cross_process_test.local', payload)
40
-
41
- expect(publishCrossProcessEventMock).toHaveBeenCalledTimes(2)
42
- expect(publishCrossProcessEventMock).toHaveBeenNthCalledWith(
43
- 1,
44
- 'cross_process_test.browser',
45
- payload,
46
- trustedScope,
47
- )
48
- expect(publishCrossProcessEventMock).toHaveBeenNthCalledWith(
49
- 2,
50
- 'cross_process_test.private',
51
- payload,
52
- privateTrustedScope,
53
- )
54
- })
55
-
56
- it('does not treat caller-controlled payload scope as trusted bridge scope', async () => {
57
- const bus = createEventBus({ resolve, queueStrategy: 'local' })
58
-
59
- await bus.emit('cross_process_test.private', {
60
- id: 'record-1',
61
- tenantId: 'payload-tenant',
62
- organizationId: 'payload-org',
63
- })
64
-
65
- expect(publishCrossProcessEventMock).not.toHaveBeenCalled()
66
- })
67
-
68
- it('preserves raw EventBus cross-process delivery for declared browser events', async () => {
69
- const bus = createEventBus({ resolve, queueStrategy: 'local' })
70
- const payload = {
71
- id: 'record-1',
72
- tenantId: ' legacy-tenant ',
73
- organizationId: ' legacy-org ',
74
- }
75
-
76
- await bus.emit('cross_process_test.browser', payload)
77
-
78
- expect(publishCrossProcessEventMock).toHaveBeenCalledWith(
79
- 'cross_process_test.browser',
80
- payload,
81
- { tenantId: 'legacy-tenant', organizationId: 'legacy-org' },
82
- )
83
- })
84
-
85
- it('prefers trusted workflow scope over browser-event payload scope', async () => {
86
- const bus = createEventBus({ resolve, queueStrategy: 'local' })
87
- const trustedScope = { tenantId: 'workflow-tenant', organizationId: 'workflow-org' }
88
- const payload = {
89
- id: 'record-1',
90
- tenantId: 'forged-tenant',
91
- organizationId: 'forged-org',
92
- }
93
-
94
- await bus.emit('cross_process_test.browser', payload, trustedScope)
95
-
96
- expect(publishCrossProcessEventMock).toHaveBeenCalledWith(
97
- 'cross_process_test.browser',
98
- payload,
99
- trustedScope,
100
- )
101
- })
102
-
103
- it('publishes from trusted options even when the payload has no scope', async () => {
104
- const bus = createEventBus({ resolve, queueStrategy: 'local' })
105
-
106
- await bus.emit(
107
- 'cross_process_test.private',
108
- { id: 'record-1' },
109
- {
110
- tenantId: 'trusted-tenant',
111
- organizationId: 'trusted-org',
112
- emitterModuleId: 'cross_process_test',
113
- },
114
- )
115
-
116
- expect(publishCrossProcessEventMock).toHaveBeenCalledWith(
117
- 'cross_process_test.private',
118
- { id: 'record-1' },
119
- {
120
- tenantId: 'trusted-tenant',
121
- organizationId: 'trusted-org',
122
- emitterModuleId: 'cross_process_test',
123
- },
124
- )
125
- })
126
-
127
- it('rejects private-event scope without declaring-module provenance', async () => {
128
- const bus = createEventBus({ resolve, queueStrategy: 'local' })
129
- const scope = { tenantId: 'tenant-1', organizationId: 'org-1' }
130
-
131
- await bus.emit('cross_process_test.private', { id: 'record-1' }, scope)
132
- await bus.emit('cross_process_test.private', { id: 'record-1' }, {
133
- ...scope,
134
- emitterModuleId: 'workflows',
135
- })
136
-
137
- expect(publishCrossProcessEventMock).not.toHaveBeenCalled()
138
- })
139
-
140
- it('preserves typed browser-event scope at the trusted module boundary', async () => {
141
- const bus = createEventBus({ resolve, queueStrategy: 'local' })
142
- setGlobalEventBus(bus)
143
- const payload = { id: 'record-1', tenantId: 'tenant-1', organizationId: 'org-1' }
144
-
145
- await testEventsConfig.emit('cross_process_test.browser', payload)
146
-
147
- expect(publishCrossProcessEventMock).toHaveBeenCalledWith(
148
- 'cross_process_test.browser',
149
- payload,
150
- {
151
- tenantId: 'tenant-1',
152
- organizationId: 'org-1',
153
- emitterModuleId: 'cross_process_test',
154
- },
155
- )
156
- })
157
-
158
- it('does not promote private-event payload scope at the module boundary', async () => {
159
- const bus = createEventBus({ resolve, queueStrategy: 'local' })
160
- setGlobalEventBus(bus)
161
-
162
- await testEventsConfig.emit('cross_process_test.private', {
163
- id: 'record-1',
164
- tenantId: 'payload-tenant',
165
- organizationId: 'payload-org',
166
- })
167
-
168
- expect(publishCrossProcessEventMock).not.toHaveBeenCalled()
169
- })
170
- })
@@ -1,31 +0,0 @@
1
- const INSTANCE_ID_KEY = '__openMercatoCrossProcessEventInstanceId__'
2
-
3
- describe('cross-process bridge instance identity', () => {
4
- const globalScope = globalThis as Record<string, unknown>
5
- const originalInstanceId = globalScope[INSTANCE_ID_KEY]
6
-
7
- beforeEach(() => {
8
- delete globalScope[INSTANCE_ID_KEY]
9
- jest.resetModules()
10
- })
11
-
12
- afterAll(() => {
13
- if (originalInstanceId === undefined) delete globalScope[INSTANCE_ID_KEY]
14
- else globalScope[INSTANCE_ID_KEY] = originalInstanceId
15
- })
16
-
17
- it('reuses one process identity across isolated module copies', () => {
18
- let first: string | undefined
19
- let second: string | undefined
20
-
21
- jest.isolateModules(() => {
22
- first = require('../bridge').CROSS_PROCESS_EVENT_INSTANCE_ID as string
23
- })
24
- jest.isolateModules(() => {
25
- second = require('../bridge').CROSS_PROCESS_EVENT_INSTANCE_ID as string
26
- })
27
-
28
- expect(first).toEqual(expect.any(String))
29
- expect(second).toBe(first)
30
- })
31
- })