@open-mercato/events 0.6.6-develop.6460.1.f94c74174c → 0.6.6-develop.6471.1.9ab5bdd79a

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/dist/bridge.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { Client, Pool } from "pg";
2
2
  import { getSslConfig } from "@open-mercato/shared/lib/db/ssl";
3
+ import { createLogger } from "@open-mercato/shared/lib/logger";
3
4
  const BRIDGE_CHANNEL = "om_event_bridge";
4
5
  const MAX_MESSAGE_BYTES = 7e3;
5
6
  const RECONNECT_DELAY_MS = 1e3;
7
+ const logger = createLogger("events");
6
8
  let publisherPool;
7
9
  let listenerClient = null;
8
10
  let listenerConnectPromise = null;
@@ -45,7 +47,7 @@ async function dispatchEnvelope(envelope) {
45
47
  try {
46
48
  await Promise.resolve(listener(envelope));
47
49
  } catch (error) {
48
- console.error(`[events] Cross-process listener error for "${envelope.event}":`, error);
50
+ logger.error("Cross-process listener error", { event: envelope.event, err: error });
49
51
  }
50
52
  }
51
53
  }
@@ -90,7 +92,7 @@ async function ensureCrossProcessListener() {
90
92
  if (!parsed || typeof parsed.event !== "string") return;
91
93
  void dispatchEnvelope(parsed);
92
94
  } catch (error) {
93
- console.warn("[events] Failed to parse cross-process bridge payload:", error);
95
+ logger.warn("Failed to parse cross-process bridge payload", { err: error });
94
96
  }
95
97
  });
96
98
  const handleDisconnect = () => {
@@ -105,7 +107,7 @@ async function ensureCrossProcessListener() {
105
107
  await client.query(`LISTEN ${BRIDGE_CHANNEL}`);
106
108
  listenerClient = client;
107
109
  })().catch((error) => {
108
- console.warn("[events] Cross-process event bridge listener failed:", error);
110
+ logger.warn("Cross-process event bridge listener failed", { err: error });
109
111
  scheduleReconnect();
110
112
  }).finally(() => {
111
113
  listenerConnectPromise = null;
@@ -123,7 +125,7 @@ async function publishCrossProcessEvent(event, payload, options) {
123
125
  };
124
126
  const serialized = JSON.stringify(envelope);
125
127
  if (Buffer.byteLength(serialized, "utf8") > MAX_MESSAGE_BYTES) {
126
- console.warn(`[events] Cross-process event "${event}" dropped: payload exceeds ${MAX_MESSAGE_BYTES} bytes`);
128
+ logger.warn("Cross-process event dropped: payload exceeds size limit", { event, maxBytes: MAX_MESSAGE_BYTES });
127
129
  return;
128
130
  }
129
131
  await pool.query("SELECT pg_notify($1, $2)", [BRIDGE_CHANNEL, serialized]);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/bridge.ts"],
4
- "sourcesContent": ["import { Client, Pool } from 'pg'\nimport { getSslConfig } from '@open-mercato/shared/lib/db/ssl'\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\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 console.error(`[events] Cross-process listener error for \"${envelope.event}\":`, 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 console.warn('[events] Failed to parse cross-process bridge payload:', 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 console.warn('[events] Cross-process event bridge listener failed:', 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 envelope: BridgeEnvelope = {\n event,\n payload,\n options,\n originPid: process.pid,\n }\n\n const serialized = JSON.stringify(envelope)\n if (Buffer.byteLength(serialized, 'utf8') > MAX_MESSAGE_BYTES) {\n console.warn(`[events] Cross-process event \"${event}\" dropped: payload exceeds ${MAX_MESSAGE_BYTES} 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,QAAQ,YAAY;AAC7B,SAAS,oBAAoB;AAG7B,MAAM,iBAAiB;AACvB,MAAM,oBAAoB;AAC1B,MAAM,qBAAqB;AAe3B,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,cAAQ,MAAM,8CAA8C,SAAS,KAAK,MAAM,KAAK;AAAA,IACvF;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,gBAAQ,KAAK,0DAA0D,KAAK;AAAA,MAC9E;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,YAAQ,KAAK,wDAAwD,KAAK;AAC1E,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,WAA2B;AAAA,IAC/B;AAAA,IACA;AAAA,IACA;AAAA,IACA,WAAW,QAAQ;AAAA,EACrB;AAEA,QAAM,aAAa,KAAK,UAAU,QAAQ;AAC1C,MAAI,OAAO,WAAW,YAAY,MAAM,IAAI,mBAAmB;AAC7D,YAAQ,KAAK,iCAAiC,KAAK,8BAA8B,iBAAiB,QAAQ;AAC1G;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 envelope: BridgeEnvelope = {\n event,\n payload,\n options,\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": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,oBAAoB;AAC7B,SAAS,oBAAoB;AAG7B,MAAM,iBAAiB;AACvB,MAAM,oBAAoB;AAC1B,MAAM,qBAAqB;AAE3B,MAAM,SAAS,aAAa,QAAQ;AAepC,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,WAA2B;AAAA,IAC/B;AAAA,IACA;AAAA,IACA;AAAA,IACA,WAAW,QAAQ;AAAA,EACrB;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;",
6
6
  "names": []
7
7
  }
package/dist/bus.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createQueue } from "@open-mercato/queue";
2
2
  import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
3
+ import { createLogger } from "@open-mercato/shared/lib/logger";
3
4
  import { isSingleDeliveryRequested } from "./single-delivery.js";
4
5
  import { matchEventPattern } from "@open-mercato/shared/lib/events/patterns";
5
6
  import { getRedisUrlOrThrow } from "@open-mercato/shared/lib/redis/connection";
@@ -11,6 +12,7 @@ import {
11
12
  import { registerCrossProcessEventListener } from "./bridge.js";
12
13
  import { publishCrossProcessEvent } from "./bridge.js";
13
14
  const EVENTS_QUEUE_NAME = "events";
15
+ const logger = createLogger("events");
14
16
  function isSingleDeliveryEnabled() {
15
17
  return isSingleDeliveryRequested();
16
18
  }
@@ -112,7 +114,7 @@ function createEventBus(opts) {
112
114
  })
113
115
  );
114
116
  } catch (error) {
115
- console.error(`[events] Handler error for "${event}" (pattern: "${pattern}"):`, error);
117
+ logger.error("Handler error", { event, pattern, err: error });
116
118
  }
117
119
  }
118
120
  }
@@ -144,7 +146,7 @@ function createEventBus(opts) {
144
146
  try {
145
147
  await Promise.resolve(tap(event, payload, options));
146
148
  } catch (error) {
147
- console.error(`[events] Global tap error for "${event}":`, error);
149
+ logger.error("Global tap error", { event, err: error });
148
150
  }
149
151
  }
150
152
  const enqueueOnly = Boolean(options?.persistent) && options?.deliverInline === false;
@@ -156,7 +158,7 @@ function createEventBus(opts) {
156
158
  try {
157
159
  await publishCrossProcessEvent(event, payload, options);
158
160
  } catch (error) {
159
- console.error(`[events] Cross-process publish error for "${event}":`, error);
161
+ logger.error("Cross-process publish error", { event, err: error });
160
162
  }
161
163
  }
162
164
  if (options?.persistent) {
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 { 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} from './types'\n\n/** Queue name for persistent events */\nconst EVENTS_QUEUE_NAME = '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; the server bootstrap\n * reconciles the env against worker availability (and may rewrite it to `false`\n * for a worker-less process). Both the bus and the events worker read the same\n * (possibly reconciled) env var, so they always agree within a process.\n */\nfunction isSingleDeliveryEnabled(): boolean {\n return isSingleDeliveryRequested()\n}\n\ntype GlobalEventTap = (event: string, payload: EventPayload, options?: EmitOptions) => void | Promise<void>\nconst GLOBAL_EVENT_TAPS_KEY = '__openMercatoEventBusGlobalTaps__'\n\nfunction hasTenantScope(payload: EventPayload): boolean {\n return typeof (payload as Record<string, unknown>)?.tenantId === 'string'\n && String((payload as Record<string, unknown>).tenantId).trim().length > 0\n}\n\nfunction getGlobalEventTaps(): Set<GlobalEventTap> {\n const existing = (globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY]\n if (existing instanceof Set) {\n return existing as Set<GlobalEventTap>\n }\n const created = new Set<GlobalEventTap>()\n ;(globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY] = created\n return created\n}\n\nexport function registerGlobalEventTap(handler: GlobalEventTap): () => void {\n const taps = getGlobalEventTaps()\n taps.add(handler)\n return () => {\n taps.delete(handler)\n }\n}\n\n/** Job data structure for queued events */\ntype EventJobData = {\n event: string\n payload: EventPayload\n options?: EmitOptions\n}\n\n// Process-wide cache of the async (BullMQ) persistent-events producer queue.\n// Each authenticated request builds a fresh DI container and event bus, so a\n// per-bus producer queue opened a new ioredis connection per write request that\n// was never closed \u2014 leaking one Redis connection per request until maxclients\n// exhaustion. Memoizing the producer on `globalThis` (keyed by Redis URL, so a\n// reconfigured URL still gets its own queue) keeps it at one connection per\n// process, mirroring the `GLOBAL_EVENT_TAPS_KEY` and `getCachedRateLimiterService`\n// patterns. The local (file-based) strategy holds no pooled connection and its\n// base dir is cwd-relative, so it stays per-bus.\nconst EVENTS_PRODUCER_QUEUE_KEY = '__openMercatoEventsProducerQueues__'\nconst EVENTS_PRODUCER_SHUTDOWN_KEY = '__openMercatoEventsProducerShutdown__'\n\nfunction isSharedProducerEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_EVENTS_SHARED_PRODUCER, true)\n}\n\nfunction getProducerQueueRegistry(): Map<string, Queue<EventJobData>> {\n const existing = (globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY]\n if (existing instanceof Map) {\n return existing as Map<string, Queue<EventJobData>>\n }\n const created = new Map<string, Queue<EventJobData>>()\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY] = created\n return created\n}\n\nfunction registerProducerShutdownHook(): void {\n if ((globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY]) return\n const shutdown = () => {\n const registry = getProducerQueueRegistry()\n for (const sharedQueue of registry.values()) {\n Promise.resolve(sharedQueue.close()).catch(() => {})\n }\n registry.clear()\n }\n process.once('SIGTERM', shutdown)\n process.once('SIGINT', shutdown)\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY] = true\n}\n\n/**\n * Creates an event bus instance.\n *\n * The event bus provides:\n * - In-memory event delivery to registered handlers\n * - Optional persistence via the queue package when `persistent: true`\n *\n * @param opts - Configuration options\n * @returns An EventBus instance\n *\n * @example\n * ```typescript\n * const bus = createEventBus({\n * resolve: container.resolve.bind(container),\n * queueStrategy: 'local', // or 'async' for BullMQ\n * })\n *\n * // Register a handler\n * bus.on('user.created', async (payload, ctx) => {\n * const userService = ctx.resolve('userService')\n * await userService.sendWelcomeEmail(payload.userId)\n * })\n *\n * // Emit an event (immediate delivery)\n * await bus.emit('user.created', { userId: '123' })\n *\n * // Emit with persistence (for async worker processing)\n * await bus.emit('order.placed', { orderId: '456' }, { persistent: true })\n * ```\n */\nexport function createEventBus(opts: CreateBusOptions): EventBus {\n // In-memory listeners for immediate event delivery\n const listeners = new Map<string, Set<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 async function deliver(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n skipPersistent = false,\n ): Promise<void> {\n // Check all registered patterns (including wildcards)\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) continue\n if (!handlers || handlers.size === 0) continue\n\n for (const 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 console.error(`[events] Handler error for \"${event}\" (pattern: \"${pattern}\"):`, error)\n }\n }\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 console.error(`[events] Global tap error for \"${event}\":`, error)\n }\n }\n\n // Deliver to in-memory handlers first. Under single-delivery, persistent\n // subscribers are skipped inline on a persistent emit because the events\n // worker will dispatch them from the queue.\n //\n // `deliverInline: false` on a persistent emit is enqueue-only: skip inline\n // delivery entirely so a heavy persistent job runs solely in the events\n // worker, off the caller's request path (the queued enqueue below still\n // happens). Without this, a persistent emit dual-dispatches by default and\n // runs the subscriber inline in the caller's request too.\n const enqueueOnly = Boolean(options?.persistent) && options?.deliverInline === false\n if (!enqueueOnly) {\n const skipPersistentInline = Boolean(options?.persistent) && isSingleDeliveryEnabled()\n await deliver(event, payload, options, skipPersistentInline)\n }\n\n if (isBroadcastEvent(event) && hasTenantScope(payload)) {\n try {\n await publishCrossProcessEvent(event, payload, options)\n } catch (error) {\n console.error(`[events] Cross-process publish error for \"${event}\":`, error)\n }\n }\n\n // If persistent, also enqueue for async processing\n if (options?.persistent) {\n const q = getQueue()\n await q.enqueue({ event, payload, options })\n }\n }\n\n /**\n * Clears all events from the persistent queue.\n */\n async function clearQueue(): Promise<{ removed: number }> {\n const q = getQueue()\n return q.clear()\n }\n\n // Backward compatibility alias\n const emitEvent = emit\n\n return {\n emit,\n emitEvent, // Alias for backward compatibility\n on,\n registerModuleSubscribers,\n clearQueue,\n }\n}\n"],
5
- "mappings": "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",
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} 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; the server bootstrap\n * reconciles the env against worker availability (and may rewrite it to `false`\n * for a worker-less process). Both the bus and the events worker read the same\n * (possibly reconciled) env var, so they always agree within a process.\n */\nfunction isSingleDeliveryEnabled(): boolean {\n return isSingleDeliveryRequested()\n}\n\ntype GlobalEventTap = (event: string, payload: EventPayload, options?: EmitOptions) => void | Promise<void>\nconst GLOBAL_EVENT_TAPS_KEY = '__openMercatoEventBusGlobalTaps__'\n\nfunction hasTenantScope(payload: EventPayload): boolean {\n return typeof (payload as Record<string, unknown>)?.tenantId === 'string'\n && String((payload as Record<string, unknown>).tenantId).trim().length > 0\n}\n\nfunction getGlobalEventTaps(): Set<GlobalEventTap> {\n const existing = (globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY]\n if (existing instanceof Set) {\n return existing as Set<GlobalEventTap>\n }\n const created = new Set<GlobalEventTap>()\n ;(globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY] = created\n return created\n}\n\nexport function registerGlobalEventTap(handler: GlobalEventTap): () => void {\n const taps = getGlobalEventTaps()\n taps.add(handler)\n return () => {\n taps.delete(handler)\n }\n}\n\n/** Job data structure for queued events */\ntype EventJobData = {\n event: string\n payload: EventPayload\n options?: EmitOptions\n}\n\n// Process-wide cache of the async (BullMQ) persistent-events producer queue.\n// Each authenticated request builds a fresh DI container and event bus, so a\n// per-bus producer queue opened a new ioredis connection per write request that\n// was never closed \u2014 leaking one Redis connection per request until maxclients\n// exhaustion. Memoizing the producer on `globalThis` (keyed by Redis URL, so a\n// reconfigured URL still gets its own queue) keeps it at one connection per\n// process, mirroring the `GLOBAL_EVENT_TAPS_KEY` and `getCachedRateLimiterService`\n// patterns. The local (file-based) strategy holds no pooled connection and its\n// base dir is cwd-relative, so it stays per-bus.\nconst EVENTS_PRODUCER_QUEUE_KEY = '__openMercatoEventsProducerQueues__'\nconst EVENTS_PRODUCER_SHUTDOWN_KEY = '__openMercatoEventsProducerShutdown__'\n\nfunction isSharedProducerEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_EVENTS_SHARED_PRODUCER, true)\n}\n\nfunction getProducerQueueRegistry(): Map<string, Queue<EventJobData>> {\n const existing = (globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY]\n if (existing instanceof Map) {\n return existing as Map<string, Queue<EventJobData>>\n }\n const created = new Map<string, Queue<EventJobData>>()\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY] = created\n return created\n}\n\nfunction registerProducerShutdownHook(): void {\n if ((globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY]) return\n const shutdown = () => {\n const registry = getProducerQueueRegistry()\n for (const sharedQueue of registry.values()) {\n Promise.resolve(sharedQueue.close()).catch(() => {})\n }\n registry.clear()\n }\n process.once('SIGTERM', shutdown)\n process.once('SIGINT', shutdown)\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY] = true\n}\n\n/**\n * Creates an event bus instance.\n *\n * The event bus provides:\n * - In-memory event delivery to registered handlers\n * - Optional persistence via the queue package when `persistent: true`\n *\n * @param opts - Configuration options\n * @returns An EventBus instance\n *\n * @example\n * ```typescript\n * const bus = createEventBus({\n * resolve: container.resolve.bind(container),\n * queueStrategy: 'local', // or 'async' for BullMQ\n * })\n *\n * // Register a handler\n * bus.on('user.created', async (payload, ctx) => {\n * const userService = ctx.resolve('userService')\n * await userService.sendWelcomeEmail(payload.userId)\n * })\n *\n * // Emit an event (immediate delivery)\n * await bus.emit('user.created', { userId: '123' })\n *\n * // Emit with persistence (for async worker processing)\n * await bus.emit('order.placed', { orderId: '456' }, { persistent: true })\n * ```\n */\nexport function createEventBus(opts: CreateBusOptions): EventBus {\n // In-memory listeners for immediate event delivery\n const listeners = new Map<string, Set<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 async function deliver(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n skipPersistent = false,\n ): Promise<void> {\n // Check all registered patterns (including wildcards)\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) continue\n if (!handlers || handlers.size === 0) continue\n\n for (const 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 logger.error('Handler error', { event, pattern, err: error })\n }\n }\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 if (!enqueueOnly) {\n const skipPersistentInline = Boolean(options?.persistent) && isSingleDeliveryEnabled()\n await deliver(event, payload, options, skipPersistentInline)\n }\n\n if (isBroadcastEvent(event) && hasTenantScope(payload)) {\n try {\n await publishCrossProcessEvent(event, payload, options)\n } catch (error) {\n 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({ event, payload, options })\n }\n }\n\n /**\n * Clears all events from the persistent queue.\n */\n async function clearQueue(): Promise<{ removed: number }> {\n const q = getQueue()\n return q.clear()\n }\n\n // Backward compatibility alias\n const emitEvent = emit\n\n return {\n emit,\n emitEvent, // Alias for backward compatibility\n on,\n registerModuleSubscribers,\n clearQueue,\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,4 +1,5 @@
1
1
  import { resolveRequestContext } from "@open-mercato/shared/lib/api/context";
2
+ import { createLogger } from "@open-mercato/shared/lib/logger";
2
3
  import { isBroadcastEvent } from "@open-mercato/shared/modules/events";
3
4
  import { registerCrossProcessEventListener, registerGlobalEventTap } from "../../../../bus.js";
4
5
  const metadata = {
@@ -6,6 +7,7 @@ const metadata = {
6
7
  };
7
8
  const HEARTBEAT_INTERVAL_MS = 3e4;
8
9
  const MAX_PAYLOAD_BYTES = 4096;
10
+ const logger = createLogger("events").child({ component: "stream" });
9
11
  function collectStringValues(input) {
10
12
  if (!Array.isArray(input)) return [];
11
13
  const values = [];
@@ -75,7 +77,7 @@ async function broadcastEventToConnections(eventName, payload) {
75
77
  if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {
76
78
  ssePayload = buildTruncatedPayload(eventName, data, organizationId);
77
79
  if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {
78
- console.warn(`[events:stream] Event ${eventName} payload exceeds ${MAX_PAYLOAD_BYTES} bytes, skipping`);
80
+ logger.warn("Event payload exceeds size limit, skipping", { event: eventName, maxBytes: MAX_PAYLOAD_BYTES });
79
81
  return;
80
82
  }
81
83
  }
@@ -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 { isBroadcastEvent } from '@open-mercato/shared/modules/events'\nimport { registerCrossProcessEventListener, registerGlobalEventTap } from '../../../../bus'\n\nexport const metadata = {\n GET: { requireAuth: true },\n}\n\nconst HEARTBEAT_INTERVAL_MS = 30_000\nconst MAX_PAYLOAD_BYTES = 4096\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>): {\n tenantId: string | null\n organizationScopes: string[]\n recipientUserScopes: string[]\n recipientRoleScopes: string[]\n} {\n const tenantId = typeof data.tenantId === 'string' ? data.tenantId : null\n const organizationScopes = new Set<string>()\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 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(eventName: string, payload: Record<string, unknown>): 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)\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 console.warn(`[events:stream] Event ${eventName} payload exceeds ${MAX_PAYLOAD_BYTES} bytes, skipping`)\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) => {\n await broadcastEventToConnections(eventName, (payload ?? {}) as Record<string, unknown>)\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 )\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'\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>): {\n tenantId: string | null\n organizationScopes: string[]\n recipientUserScopes: string[]\n recipientRoleScopes: string[]\n} {\n const tenantId = typeof data.tenantId === 'string' ? data.tenantId : null\n const organizationScopes = new Set<string>()\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 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(eventName: string, payload: Record<string, unknown>): 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)\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) => {\n await broadcastEventToConnections(eventName, (payload ?? {}) as Record<string, unknown>)\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 )\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
  }
@@ -1,7 +1,9 @@
1
1
  import { getCliModules } from "@open-mercato/shared/modules/registry";
2
2
  import { matchEventPattern } from "@open-mercato/shared/lib/events/patterns";
3
+ import { createLogger } from "@open-mercato/shared/lib/logger";
3
4
  import { isSingleDeliveryRequested } from "../../../single-delivery.js";
4
5
  const EVENTS_QUEUE_NAME = "events";
6
+ const logger = createLogger("events");
5
7
  const DEFAULT_CONCURRENCY = 1;
6
8
  const envConcurrency = process.env.WORKERS_EVENTS_CONCURRENCY;
7
9
  const metadata = {
@@ -65,7 +67,7 @@ async function handle(job, ctx) {
65
67
  const result = results[i];
66
68
  if (result.status === "rejected") {
67
69
  const sub = subscribers[i];
68
- console.error(`[events] Subscriber "${sub.id}" failed for event "${event}":`, result.reason);
70
+ logger.error("Subscriber failed for event", { event, subscriberId: sub.id, err: result.reason });
69
71
  errors.push({ subscriberId: sub.id, error: result.reason });
70
72
  }
71
73
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/events/workers/events.worker.ts"],
4
- "sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { getCliModules } from '@open-mercato/shared/modules/registry'\nimport { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { isSingleDeliveryRequested } from '../../../single-delivery'\n\nexport const EVENTS_QUEUE_NAME = 'events'\n\nconst DEFAULT_CONCURRENCY = 1\nconst envConcurrency = process.env.WORKERS_EVENTS_CONCURRENCY\n\nexport const metadata: WorkerMeta = {\n queue: EVENTS_QUEUE_NAME,\n concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,\n}\n\ntype EventJobPayload = {\n event: string\n payload: unknown\n options?: {\n tenantId?: string | null\n organizationId?: string | null\n }\n}\n\ntype HandlerContext = {\n resolve: <T = unknown>(name: string) => T\n eventName?: string\n tenantId?: string | null\n organizationId?: string | null\n}\n\ntype SubscriberEntry = {\n id: string\n event: string\n persistent?: boolean\n handler: (payload: unknown, ctx: unknown) => Promise<void> | void\n}\n\n/**\n * Mirror of the event bus single-delivery flag. When enabled, the worker owns\n * dispatch of every persistent subscriber and matches by pattern so wildcard\n * (`event: '*'`) persistent subscribers are finally reached. Defaults ON;\n * reconciled by the server bootstrap against worker availability and read from\n * the same env var as the bus so the two always agree within a process.\n */\nfunction isSingleDeliveryEnabled(): boolean {\n return isSingleDeliveryRequested()\n}\n\n/**\n * Resolves the subscribers to run for a queued event.\n * - Legacy (flag off): exact-match lookup of every subscriber for the event.\n * - Single-delivery (flag on): persistent subscribers whose pattern matches the\n * event, including wildcards, so they run exactly once here instead of inline.\n */\nfunction resolveSubscribers(\n listeners: Map<string, SubscriberEntry[]>,\n event: string,\n): SubscriberEntry[] {\n if (!isSingleDeliveryEnabled()) {\n return listeners.get(event) ?? []\n }\n const matched: SubscriberEntry[] = []\n for (const [pattern, subs] of listeners) {\n if (!matchEventPattern(event, pattern)) continue\n for (const sub of subs) {\n if (sub.persistent) matched.push(sub)\n }\n }\n return matched\n}\n\n// Cached listener map - built once on first use\nlet cachedListenerMap: Map<string, SubscriberEntry[]> | null = null\n\n/**\n * Clear the cached listener map (for testing purposes).\n */\nexport function clearListenerCache(): void {\n cachedListenerMap = null\n}\n\n// Build listener map from module subscribers\nfunction buildListenerMap(): Map<string, SubscriberEntry[]> {\n const listeners = new Map<string, SubscriberEntry[]>()\n for (const mod of getCliModules()) {\n const subs = (mod as { subscribers?: SubscriberEntry[] }).subscribers\n if (!subs) continue\n for (const sub of subs) {\n if (!listeners.has(sub.event)) listeners.set(sub.event, [])\n listeners.get(sub.event)!.push(sub)\n }\n }\n return listeners\n}\n\n// Get cached listener map, building on first access\nfunction getListenerMap(): Map<string, SubscriberEntry[]> {\n if (!cachedListenerMap) {\n cachedListenerMap = buildListenerMap()\n }\n return cachedListenerMap\n}\n\n/**\n * Events worker handler.\n * Dispatches queued events to registered module subscribers.\n * Each subscriber is isolated - failures in one don't affect others.\n */\nexport default async function handle(\n job: QueuedJob<EventJobPayload>,\n ctx: JobContext & HandlerContext\n): Promise<void> {\n const { event, payload, options } = job.payload\n const listeners = getListenerMap()\n const subscribers = resolveSubscribers(listeners, event)\n\n if (!subscribers || subscribers.length === 0) return\n\n const handlerCtx = {\n resolve: ctx.resolve,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }\n\n const results = await Promise.allSettled(\n subscribers.map((sub) => Promise.resolve(sub.handler(payload, handlerCtx)))\n )\n\n const errors: Array<{ subscriberId: string; error: unknown }> = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const sub = subscribers[i]\n console.error(`[events] Subscriber \"${sub.id}\" failed for event \"${event}\":`, result.reason)\n errors.push({ subscriberId: sub.id, error: result.reason })\n }\n }\n\n if (errors.length > 0) {\n const failedIds = errors.map((e) => e.subscriberId).join(', ')\n throw new Error(\n `${errors.length}/${subscribers.length} subscriber(s) failed for event \"${event}\": ${failedIds}`\n )\n }\n}\n"],
5
- "mappings": "AACA,SAAS,qBAAqB;AAC9B,SAAS,yBAAyB;AAClC,SAAS,iCAAiC;AAEnC,MAAM,oBAAoB;AAEjC,MAAM,sBAAsB;AAC5B,MAAM,iBAAiB,QAAQ,IAAI;AAE5B,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,aAAa,iBAAiB,SAAS,gBAAgB,EAAE,IAAI;AAC/D;AAgCA,SAAS,0BAAmC;AAC1C,SAAO,0BAA0B;AACnC;AAQA,SAAS,mBACP,WACA,OACmB;AACnB,MAAI,CAAC,wBAAwB,GAAG;AAC9B,WAAO,UAAU,IAAI,KAAK,KAAK,CAAC;AAAA,EAClC;AACA,QAAM,UAA6B,CAAC;AACpC,aAAW,CAAC,SAAS,IAAI,KAAK,WAAW;AACvC,QAAI,CAAC,kBAAkB,OAAO,OAAO,EAAG;AACxC,eAAW,OAAO,MAAM;AACtB,UAAI,IAAI,WAAY,SAAQ,KAAK,GAAG;AAAA,IACtC;AAAA,EACF;AACA,SAAO;AACT;AAGA,IAAI,oBAA2D;AAKxD,SAAS,qBAA2B;AACzC,sBAAoB;AACtB;AAGA,SAAS,mBAAmD;AAC1D,QAAM,YAAY,oBAAI,IAA+B;AACrD,aAAW,OAAO,cAAc,GAAG;AACjC,UAAM,OAAQ,IAA4C;AAC1D,QAAI,CAAC,KAAM;AACX,eAAW,OAAO,MAAM;AACtB,UAAI,CAAC,UAAU,IAAI,IAAI,KAAK,EAAG,WAAU,IAAI,IAAI,OAAO,CAAC,CAAC;AAC1D,gBAAU,IAAI,IAAI,KAAK,EAAG,KAAK,GAAG;AAAA,IACpC;AAAA,EACF;AACA,SAAO;AACT;AAGA,SAAS,iBAAiD;AACxD,MAAI,CAAC,mBAAmB;AACtB,wBAAoB,iBAAiB;AAAA,EACvC;AACA,SAAO;AACT;AAOA,eAAO,OACL,KACA,KACe;AACf,QAAM,EAAE,OAAO,SAAS,QAAQ,IAAI,IAAI;AACxC,QAAM,YAAY,eAAe;AACjC,QAAM,cAAc,mBAAmB,WAAW,KAAK;AAEvD,MAAI,CAAC,eAAe,YAAY,WAAW,EAAG;AAE9C,QAAM,aAAa;AAAA,IACjB,SAAS,IAAI;AAAA,IACb,WAAW;AAAA,IACX,UAAU,SAAS,YAAY;AAAA,IAC/B,gBAAgB,SAAS,kBAAkB;AAAA,EAC7C;AAEA,QAAM,UAAU,MAAM,QAAQ;AAAA,IAC5B,YAAY,IAAI,CAAC,QAAQ,QAAQ,QAAQ,IAAI,QAAQ,SAAS,UAAU,CAAC,CAAC;AAAA,EAC5E;AAEA,QAAM,SAA0D,CAAC;AACjE,WAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;AACvC,UAAM,SAAS,QAAQ,CAAC;AACxB,QAAI,OAAO,WAAW,YAAY;AAChC,YAAM,MAAM,YAAY,CAAC;AACzB,cAAQ,MAAM,wBAAwB,IAAI,EAAE,uBAAuB,KAAK,MAAM,OAAO,MAAM;AAC3F,aAAO,KAAK,EAAE,cAAc,IAAI,IAAI,OAAO,OAAO,OAAO,CAAC;AAAA,IAC5D;AAAA,EACF;AAEA,MAAI,OAAO,SAAS,GAAG;AACrB,UAAM,YAAY,OAAO,IAAI,CAAC,MAAM,EAAE,YAAY,EAAE,KAAK,IAAI;AAC7D,UAAM,IAAI;AAAA,MACR,GAAG,OAAO,MAAM,IAAI,YAAY,MAAM,oCAAoC,KAAK,MAAM,SAAS;AAAA,IAChG;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { getCliModules } from '@open-mercato/shared/modules/registry'\nimport { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { isSingleDeliveryRequested } from '../../../single-delivery'\n\nexport const EVENTS_QUEUE_NAME = 'events'\n\nconst logger = createLogger('events')\n\nconst DEFAULT_CONCURRENCY = 1\nconst envConcurrency = process.env.WORKERS_EVENTS_CONCURRENCY\n\nexport const metadata: WorkerMeta = {\n queue: EVENTS_QUEUE_NAME,\n concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,\n}\n\ntype EventJobPayload = {\n event: string\n payload: unknown\n options?: {\n tenantId?: string | null\n organizationId?: string | null\n }\n}\n\ntype HandlerContext = {\n resolve: <T = unknown>(name: string) => T\n eventName?: string\n tenantId?: string | null\n organizationId?: string | null\n}\n\ntype SubscriberEntry = {\n id: string\n event: string\n persistent?: boolean\n handler: (payload: unknown, ctx: unknown) => Promise<void> | void\n}\n\n/**\n * Mirror of the event bus single-delivery flag. When enabled, the worker owns\n * dispatch of every persistent subscriber and matches by pattern so wildcard\n * (`event: '*'`) persistent subscribers are finally reached. Defaults ON;\n * reconciled by the server bootstrap against worker availability and read from\n * the same env var as the bus so the two always agree within a process.\n */\nfunction isSingleDeliveryEnabled(): boolean {\n return isSingleDeliveryRequested()\n}\n\n/**\n * Resolves the subscribers to run for a queued event.\n * - Legacy (flag off): exact-match lookup of every subscriber for the event.\n * - Single-delivery (flag on): persistent subscribers whose pattern matches the\n * event, including wildcards, so they run exactly once here instead of inline.\n */\nfunction resolveSubscribers(\n listeners: Map<string, SubscriberEntry[]>,\n event: string,\n): SubscriberEntry[] {\n if (!isSingleDeliveryEnabled()) {\n return listeners.get(event) ?? []\n }\n const matched: SubscriberEntry[] = []\n for (const [pattern, subs] of listeners) {\n if (!matchEventPattern(event, pattern)) continue\n for (const sub of subs) {\n if (sub.persistent) matched.push(sub)\n }\n }\n return matched\n}\n\n// Cached listener map - built once on first use\nlet cachedListenerMap: Map<string, SubscriberEntry[]> | null = null\n\n/**\n * Clear the cached listener map (for testing purposes).\n */\nexport function clearListenerCache(): void {\n cachedListenerMap = null\n}\n\n// Build listener map from module subscribers\nfunction buildListenerMap(): Map<string, SubscriberEntry[]> {\n const listeners = new Map<string, SubscriberEntry[]>()\n for (const mod of getCliModules()) {\n const subs = (mod as { subscribers?: SubscriberEntry[] }).subscribers\n if (!subs) continue\n for (const sub of subs) {\n if (!listeners.has(sub.event)) listeners.set(sub.event, [])\n listeners.get(sub.event)!.push(sub)\n }\n }\n return listeners\n}\n\n// Get cached listener map, building on first access\nfunction getListenerMap(): Map<string, SubscriberEntry[]> {\n if (!cachedListenerMap) {\n cachedListenerMap = buildListenerMap()\n }\n return cachedListenerMap\n}\n\n/**\n * Events worker handler.\n * Dispatches queued events to registered module subscribers.\n * Each subscriber is isolated - failures in one don't affect others.\n */\nexport default async function handle(\n job: QueuedJob<EventJobPayload>,\n ctx: JobContext & HandlerContext\n): Promise<void> {\n const { event, payload, options } = job.payload\n const listeners = getListenerMap()\n const subscribers = resolveSubscribers(listeners, event)\n\n if (!subscribers || subscribers.length === 0) return\n\n const handlerCtx = {\n resolve: ctx.resolve,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }\n\n const results = await Promise.allSettled(\n subscribers.map((sub) => Promise.resolve(sub.handler(payload, handlerCtx)))\n )\n\n const errors: Array<{ subscriberId: string; error: unknown }> = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const sub = subscribers[i]\n logger.error('Subscriber failed for event', { event, subscriberId: sub.id, err: result.reason })\n errors.push({ subscriberId: sub.id, error: result.reason })\n }\n }\n\n if (errors.length > 0) {\n const failedIds = errors.map((e) => e.subscriberId).join(', ')\n throw new Error(\n `${errors.length}/${subscribers.length} subscriber(s) failed for event \"${event}\": ${failedIds}`\n )\n }\n}\n"],
5
+ "mappings": "AACA,SAAS,qBAAqB;AAC9B,SAAS,yBAAyB;AAClC,SAAS,oBAAoB;AAC7B,SAAS,iCAAiC;AAEnC,MAAM,oBAAoB;AAEjC,MAAM,SAAS,aAAa,QAAQ;AAEpC,MAAM,sBAAsB;AAC5B,MAAM,iBAAiB,QAAQ,IAAI;AAE5B,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,aAAa,iBAAiB,SAAS,gBAAgB,EAAE,IAAI;AAC/D;AAgCA,SAAS,0BAAmC;AAC1C,SAAO,0BAA0B;AACnC;AAQA,SAAS,mBACP,WACA,OACmB;AACnB,MAAI,CAAC,wBAAwB,GAAG;AAC9B,WAAO,UAAU,IAAI,KAAK,KAAK,CAAC;AAAA,EAClC;AACA,QAAM,UAA6B,CAAC;AACpC,aAAW,CAAC,SAAS,IAAI,KAAK,WAAW;AACvC,QAAI,CAAC,kBAAkB,OAAO,OAAO,EAAG;AACxC,eAAW,OAAO,MAAM;AACtB,UAAI,IAAI,WAAY,SAAQ,KAAK,GAAG;AAAA,IACtC;AAAA,EACF;AACA,SAAO;AACT;AAGA,IAAI,oBAA2D;AAKxD,SAAS,qBAA2B;AACzC,sBAAoB;AACtB;AAGA,SAAS,mBAAmD;AAC1D,QAAM,YAAY,oBAAI,IAA+B;AACrD,aAAW,OAAO,cAAc,GAAG;AACjC,UAAM,OAAQ,IAA4C;AAC1D,QAAI,CAAC,KAAM;AACX,eAAW,OAAO,MAAM;AACtB,UAAI,CAAC,UAAU,IAAI,IAAI,KAAK,EAAG,WAAU,IAAI,IAAI,OAAO,CAAC,CAAC;AAC1D,gBAAU,IAAI,IAAI,KAAK,EAAG,KAAK,GAAG;AAAA,IACpC;AAAA,EACF;AACA,SAAO;AACT;AAGA,SAAS,iBAAiD;AACxD,MAAI,CAAC,mBAAmB;AACtB,wBAAoB,iBAAiB;AAAA,EACvC;AACA,SAAO;AACT;AAOA,eAAO,OACL,KACA,KACe;AACf,QAAM,EAAE,OAAO,SAAS,QAAQ,IAAI,IAAI;AACxC,QAAM,YAAY,eAAe;AACjC,QAAM,cAAc,mBAAmB,WAAW,KAAK;AAEvD,MAAI,CAAC,eAAe,YAAY,WAAW,EAAG;AAE9C,QAAM,aAAa;AAAA,IACjB,SAAS,IAAI;AAAA,IACb,WAAW;AAAA,IACX,UAAU,SAAS,YAAY;AAAA,IAC/B,gBAAgB,SAAS,kBAAkB;AAAA,EAC7C;AAEA,QAAM,UAAU,MAAM,QAAQ;AAAA,IAC5B,YAAY,IAAI,CAAC,QAAQ,QAAQ,QAAQ,IAAI,QAAQ,SAAS,UAAU,CAAC,CAAC;AAAA,EAC5E;AAEA,QAAM,SAA0D,CAAC;AACjE,WAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;AACvC,UAAM,SAAS,QAAQ,CAAC;AACxB,QAAI,OAAO,WAAW,YAAY;AAChC,YAAM,MAAM,YAAY,CAAC;AACzB,aAAO,MAAM,+BAA+B,EAAE,OAAO,cAAc,IAAI,IAAI,KAAK,OAAO,OAAO,CAAC;AAC/F,aAAO,KAAK,EAAE,cAAc,IAAI,IAAI,OAAO,OAAO,OAAO,CAAC;AAAA,IAC5D;AAAA,EACF;AAEA,MAAI,OAAO,SAAS,GAAG;AACrB,UAAM,YAAY,OAAO,IAAI,CAAC,MAAM,EAAE,YAAY,EAAE,KAAK,IAAI;AAC7D,UAAM,IAAI;AAAA,MACR,GAAG,OAAO,MAAM,IAAI,YAAY,MAAM,oCAAoC,KAAK,MAAM,SAAS;AAAA,IAChG;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/events",
3
- "version": "0.6.6-develop.6460.1.f94c74174c",
3
+ "version": "0.6.6-develop.6471.1.9ab5bdd79a",
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.6-develop.6460.1.f94c74174c",
37
- "@open-mercato/shared": "0.6.6-develop.6460.1.f94c74174c",
36
+ "@open-mercato/queue": "0.6.6-develop.6471.1.9ab5bdd79a",
37
+ "@open-mercato/shared": "0.6.6-develop.6471.1.9ab5bdd79a",
38
38
  "pg": "8.22.0"
39
39
  },
40
40
  "devDependencies": {
@@ -0,0 +1,59 @@
1
+ import { createLogger } from '@open-mercato/shared/lib/logger'
2
+ import { createEventBus, registerGlobalEventTap } from '../bus'
3
+ import type { CreateBusOptions } from '../types'
4
+
5
+ jest.mock('@open-mercato/shared/lib/logger', () => {
6
+ const mocked = {
7
+ debug: jest.fn(),
8
+ info: jest.fn(),
9
+ warn: jest.fn(),
10
+ error: jest.fn(),
11
+ child: jest.fn(),
12
+ }
13
+ mocked.child.mockImplementation(() => mocked)
14
+ return { createLogger: jest.fn(() => mocked) }
15
+ })
16
+
17
+ const busLoggerError = createLogger('events').error as jest.Mock
18
+
19
+ const busOptions: CreateBusOptions = {
20
+ resolve: ((name: string) => name) as CreateBusOptions['resolve'],
21
+ }
22
+
23
+ describe('events bus logger migration (structured logging facade)', () => {
24
+ beforeEach(() => {
25
+ busLoggerError.mockClear()
26
+ })
27
+
28
+ it('still emits when a subscriber handler throws, now with structured fields', async () => {
29
+ const bus = createEventBus(busOptions)
30
+ bus.on('test.logger.event', () => {
31
+ throw new Error('handler boom')
32
+ })
33
+
34
+ await bus.emit('test.logger.event', { id: 'record-1' })
35
+
36
+ expect(busLoggerError).toHaveBeenCalledWith('Handler error', {
37
+ event: 'test.logger.event',
38
+ pattern: 'test.logger.event',
39
+ err: expect.any(Error),
40
+ })
41
+ })
42
+
43
+ it('still emits when a global tap throws, now with structured fields', async () => {
44
+ const unregister = registerGlobalEventTap(() => {
45
+ throw new Error('tap boom')
46
+ })
47
+ try {
48
+ const bus = createEventBus(busOptions)
49
+ await bus.emit('test.logger.tap', { id: 'record-2' })
50
+
51
+ expect(busLoggerError).toHaveBeenCalledWith('Global tap error', {
52
+ event: 'test.logger.tap',
53
+ err: expect.any(Error),
54
+ })
55
+ } finally {
56
+ unregister()
57
+ }
58
+ })
59
+ })
package/src/bridge.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  import { Client, Pool } from 'pg'
2
2
  import { getSslConfig } from '@open-mercato/shared/lib/db/ssl'
3
+ import { createLogger } from '@open-mercato/shared/lib/logger'
3
4
  import type { EmitOptions, EventPayload } from './types'
4
5
 
5
6
  const BRIDGE_CHANNEL = 'om_event_bridge'
6
7
  const MAX_MESSAGE_BYTES = 7_000
7
8
  const RECONNECT_DELAY_MS = 1_000
8
9
 
10
+ const logger = createLogger('events')
11
+
9
12
  type BridgeEnvelope = {
10
13
  event: string
11
14
  payload: EventPayload
@@ -66,7 +69,7 @@ async function dispatchEnvelope(envelope: BridgeEnvelope): Promise<void> {
66
69
  try {
67
70
  await Promise.resolve(listener(envelope))
68
71
  } catch (error) {
69
- console.error(`[events] Cross-process listener error for "${envelope.event}":`, error)
72
+ logger.error('Cross-process listener error', { event: envelope.event, err: error })
70
73
  }
71
74
  }
72
75
  }
@@ -118,7 +121,7 @@ async function ensureCrossProcessListener(): Promise<void> {
118
121
  if (!parsed || typeof parsed.event !== 'string') return
119
122
  void dispatchEnvelope(parsed)
120
123
  } catch (error) {
121
- console.warn('[events] Failed to parse cross-process bridge payload:', error)
124
+ logger.warn('Failed to parse cross-process bridge payload', { err: error })
122
125
  }
123
126
  })
124
127
 
@@ -137,7 +140,7 @@ async function ensureCrossProcessListener(): Promise<void> {
137
140
  listenerClient = client
138
141
  })()
139
142
  .catch((error) => {
140
- console.warn('[events] Cross-process event bridge listener failed:', error)
143
+ logger.warn('Cross-process event bridge listener failed', { err: error })
141
144
  scheduleReconnect()
142
145
  })
143
146
  .finally(() => {
@@ -164,7 +167,7 @@ export async function publishCrossProcessEvent(
164
167
 
165
168
  const serialized = JSON.stringify(envelope)
166
169
  if (Buffer.byteLength(serialized, 'utf8') > MAX_MESSAGE_BYTES) {
167
- console.warn(`[events] Cross-process event "${event}" dropped: payload exceeds ${MAX_MESSAGE_BYTES} bytes`)
170
+ logger.warn('Cross-process event dropped: payload exceeds size limit', { event, maxBytes: MAX_MESSAGE_BYTES })
168
171
  return
169
172
  }
170
173
 
package/src/bus.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createQueue } from '@open-mercato/queue'
2
2
  import type { Queue } from '@open-mercato/queue'
3
3
  import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
4
+ import { createLogger } from '@open-mercato/shared/lib/logger'
4
5
  import { isSingleDeliveryRequested } from './single-delivery'
5
6
  import { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'
6
7
  import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
@@ -23,6 +24,8 @@ import type {
23
24
  /** Queue name for persistent events */
24
25
  const EVENTS_QUEUE_NAME = 'events'
25
26
 
27
+ const logger = createLogger('events')
28
+
26
29
  type RegisteredSubscriber = {
27
30
  id?: string
28
31
  moduleId?: string
@@ -232,7 +235,7 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
232
235
  }),
233
236
  )
234
237
  } catch (error) {
235
- console.error(`[events] Handler error for "${event}" (pattern: "${pattern}"):`, error)
238
+ logger.error('Handler error', { event, pattern, err: error })
236
239
  }
237
240
  }
238
241
  }
@@ -289,7 +292,7 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
289
292
  try {
290
293
  await Promise.resolve(tap(event, payload, options))
291
294
  } catch (error) {
292
- console.error(`[events] Global tap error for "${event}":`, error)
295
+ logger.error('Global tap error', { event, err: error })
293
296
  }
294
297
  }
295
298
 
@@ -312,7 +315,7 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
312
315
  try {
313
316
  await publishCrossProcessEvent(event, payload, options)
314
317
  } catch (error) {
315
- console.error(`[events] Cross-process publish error for "${event}":`, error)
318
+ logger.error('Cross-process publish error', { event, err: error })
316
319
  }
317
320
  }
318
321
 
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { resolveRequestContext } from '@open-mercato/shared/lib/api/context'
12
+ import { createLogger } from '@open-mercato/shared/lib/logger'
12
13
  import { isBroadcastEvent } from '@open-mercato/shared/modules/events'
13
14
  import { registerCrossProcessEventListener, registerGlobalEventTap } from '../../../../bus'
14
15
 
@@ -19,6 +20,8 @@ export const metadata = {
19
20
  const HEARTBEAT_INTERVAL_MS = 30_000
20
21
  const MAX_PAYLOAD_BYTES = 4096
21
22
 
23
+ const logger = createLogger('events').child({ component: 'stream' })
24
+
22
25
  type SseConnection = {
23
26
  tenantId: string
24
27
  organizationId: string | null
@@ -123,7 +126,7 @@ async function broadcastEventToConnections(eventName: string, payload: Record<st
123
126
  if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {
124
127
  ssePayload = buildTruncatedPayload(eventName, data, organizationId)
125
128
  if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {
126
- console.warn(`[events:stream] Event ${eventName} payload exceeds ${MAX_PAYLOAD_BYTES} bytes, skipping`)
129
+ logger.warn('Event payload exceeds size limit, skipping', { event: eventName, maxBytes: MAX_PAYLOAD_BYTES })
127
130
  return
128
131
  }
129
132
  }
@@ -1,8 +1,23 @@
1
1
  import { registerCliModules, getCliModules } from '@open-mercato/shared/modules/registry'
2
2
  import type { Module } from '@open-mercato/shared/modules/registry'
3
3
  import type { QueuedJob, JobContext } from '@open-mercato/queue'
4
+ import { createLogger } from '@open-mercato/shared/lib/logger'
4
5
  import handle, { metadata, EVENTS_QUEUE_NAME, clearListenerCache } from '../events.worker'
5
6
 
7
+ jest.mock('@open-mercato/shared/lib/logger', () => {
8
+ const mocked = {
9
+ debug: jest.fn(),
10
+ info: jest.fn(),
11
+ warn: jest.fn(),
12
+ error: jest.fn(),
13
+ child: jest.fn(),
14
+ }
15
+ mocked.child.mockImplementation(() => mocked)
16
+ return { createLogger: jest.fn(() => mocked) }
17
+ })
18
+
19
+ const workerLoggerError = createLogger('events').error as jest.Mock
20
+
6
21
  // Clear modules and listener cache before each test
7
22
  function clearModules() {
8
23
  // Re-register with empty array to clear
@@ -14,6 +29,7 @@ function clearModules() {
14
29
  describe('Events Worker', () => {
15
30
  beforeEach(() => {
16
31
  clearModules()
32
+ workerLoggerError.mockClear()
17
33
  })
18
34
 
19
35
  afterEach(() => {
@@ -339,8 +355,6 @@ describe('Events Worker', () => {
339
355
  const job = createMockJob('test.event', { data: 'test' })
340
356
  const ctx = createMockContext()
341
357
 
342
- const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
343
-
344
358
  await expect(handle(job, ctx)).rejects.toThrow(
345
359
  '1/2 subscriber(s) failed for event "test.event": a:failing-subscriber'
346
360
  )
@@ -349,7 +363,12 @@ describe('Events Worker', () => {
349
363
  expect(subscriber2Calls.length).toBe(1)
350
364
  expect(subscriber2Calls[0]).toEqual({ data: 'test' })
351
365
 
352
- errorSpy.mockRestore()
366
+ expect(workerLoggerError).toHaveBeenCalledTimes(1)
367
+ expect(workerLoggerError).toHaveBeenCalledWith('Subscriber failed for event', {
368
+ event: 'test.event',
369
+ subscriberId: 'a:failing-subscriber',
370
+ err: expect.any(Error),
371
+ })
353
372
  })
354
373
 
355
374
  it('should dispatch subscribers in parallel, not sequentially', async () => {
@@ -435,13 +454,11 @@ describe('Events Worker', () => {
435
454
  const job = createMockJob('test.event', { data: 'test' })
436
455
  const ctx = createMockContext()
437
456
 
438
- const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
439
-
440
457
  await expect(handle(job, ctx)).rejects.toThrow(
441
458
  '2/2 subscriber(s) failed for event "test.event": a:failing-subscriber, b:failing-subscriber'
442
459
  )
443
460
 
444
- errorSpy.mockRestore()
461
+ expect(workerLoggerError).toHaveBeenCalledTimes(2)
445
462
  })
446
463
  })
447
464
 
@@ -1,10 +1,13 @@
1
1
  import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'
2
2
  import { getCliModules } from '@open-mercato/shared/modules/registry'
3
3
  import { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'
4
+ import { createLogger } from '@open-mercato/shared/lib/logger'
4
5
  import { isSingleDeliveryRequested } from '../../../single-delivery'
5
6
 
6
7
  export const EVENTS_QUEUE_NAME = 'events'
7
8
 
9
+ const logger = createLogger('events')
10
+
8
11
  const DEFAULT_CONCURRENCY = 1
9
12
  const envConcurrency = process.env.WORKERS_EVENTS_CONCURRENCY
10
13
 
@@ -133,7 +136,7 @@ export default async function handle(
133
136
  const result = results[i]
134
137
  if (result.status === 'rejected') {
135
138
  const sub = subscribers[i]
136
- console.error(`[events] Subscriber "${sub.id}" failed for event "${event}":`, result.reason)
139
+ logger.error('Subscriber failed for event', { event, subscriberId: sub.id, err: result.reason })
137
140
  errors.push({ subscriberId: sub.id, error: result.reason })
138
141
  }
139
142
  }