@open-mercato/events 0.4.2-canary-c02407ff85

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/build.mjs ADDED
@@ -0,0 +1,61 @@
1
+ import * as esbuild from 'esbuild'
2
+ import { glob } from 'glob'
3
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+
6
+ const entryPoints = await glob('src/**/*.ts', {
7
+ ignore: ['**/__tests__/**', '**/*.test.ts']
8
+ })
9
+
10
+ // Plugin to add .js extension to relative imports
11
+ const addJsExtension = {
12
+ name: 'add-js-extension',
13
+ setup(build) {
14
+ build.onEnd(async (result) => {
15
+ if (result.errors.length > 0) return
16
+ const outputFiles = await glob('dist/**/*.js')
17
+ for (const file of outputFiles) {
18
+ const fileDir = dirname(file)
19
+ let content = readFileSync(file, 'utf-8')
20
+ // Add .js to relative imports that don't have an extension
21
+ content = content.replace(
22
+ /from\s+["'](\.[^"']+)["']/g,
23
+ (match, path) => {
24
+ if (path.endsWith('.js') || path.endsWith('.json')) return match
25
+ // Check if it's a directory with index.js
26
+ const resolvedPath = join(fileDir, path)
27
+ if (existsSync(resolvedPath) && existsSync(join(resolvedPath, 'index.js'))) {
28
+ return `from "${path}/index.js"`
29
+ }
30
+ return `from "${path}.js"`
31
+ }
32
+ )
33
+ content = content.replace(
34
+ /import\s*\(\s*["'](\.[^"']+)["']\s*\)/g,
35
+ (match, path) => {
36
+ if (path.endsWith('.js') || path.endsWith('.json')) return match
37
+ // Check if it's a directory with index.js
38
+ const resolvedPath = join(fileDir, path)
39
+ if (existsSync(resolvedPath) && existsSync(join(resolvedPath, 'index.js'))) {
40
+ return `import("${path}/index.js")`
41
+ }
42
+ return `import("${path}.js")`
43
+ }
44
+ )
45
+ writeFileSync(file, content)
46
+ }
47
+ })
48
+ }
49
+ }
50
+
51
+ await esbuild.build({
52
+ entryPoints,
53
+ outdir: 'dist',
54
+ format: 'esm',
55
+ platform: 'node',
56
+ target: 'node18',
57
+ sourcemap: true,
58
+ plugins: [addJsExtension],
59
+ })
60
+
61
+ console.log('events built successfully')
package/dist/bus.js ADDED
@@ -0,0 +1,69 @@
1
+ import { createQueue } from "@open-mercato/queue";
2
+ const EVENTS_QUEUE_NAME = "events";
3
+ function createEventBus(opts) {
4
+ const listeners = /* @__PURE__ */ new Map();
5
+ const queueStrategy = opts.queueStrategy ?? (process.env.QUEUE_STRATEGY === "async" ? "async" : "local");
6
+ let queue = null;
7
+ function getQueue() {
8
+ if (!queue) {
9
+ if (queueStrategy === "async") {
10
+ const redisUrl = process.env.REDIS_URL || process.env.QUEUE_REDIS_URL;
11
+ if (!redisUrl) {
12
+ console.warn("[events] No REDIS_URL configured, falling back to localhost:6379");
13
+ }
14
+ queue = createQueue(EVENTS_QUEUE_NAME, "async", {
15
+ connection: { url: redisUrl }
16
+ });
17
+ } else {
18
+ queue = createQueue(EVENTS_QUEUE_NAME, "local");
19
+ }
20
+ }
21
+ return queue;
22
+ }
23
+ async function deliver(event, payload) {
24
+ const handlers = listeners.get(event);
25
+ if (!handlers || handlers.size === 0) return;
26
+ for (const handler of handlers) {
27
+ try {
28
+ await Promise.resolve(handler(payload, { resolve: opts.resolve }));
29
+ } catch (error) {
30
+ console.error(`[events] Handler error for "${event}":`, error);
31
+ }
32
+ }
33
+ }
34
+ function on(event, handler) {
35
+ if (!listeners.has(event)) {
36
+ listeners.set(event, /* @__PURE__ */ new Set());
37
+ }
38
+ listeners.get(event).add(handler);
39
+ }
40
+ function registerModuleSubscribers(subs) {
41
+ for (const sub of subs) {
42
+ on(sub.event, sub.handler);
43
+ }
44
+ }
45
+ async function emit(event, payload, options) {
46
+ await deliver(event, payload);
47
+ if (options?.persistent) {
48
+ const q = getQueue();
49
+ await q.enqueue({ event, payload });
50
+ }
51
+ }
52
+ async function clearQueue() {
53
+ const q = getQueue();
54
+ return q.clear();
55
+ }
56
+ const emitEvent = emit;
57
+ return {
58
+ emit,
59
+ emitEvent,
60
+ // Alias for backward compatibility
61
+ on,
62
+ registerModuleSubscribers,
63
+ clearQueue
64
+ };
65
+ }
66
+ export {
67
+ createEventBus
68
+ };
69
+ //# sourceMappingURL=bus.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/bus.ts"],
4
+ "sourcesContent": ["import { createQueue } from '@open-mercato/queue'\nimport type { Queue } from '@open-mercato/queue'\nimport type {\n EventBus,\n CreateBusOptions,\n SubscriberHandler,\n SubscriberDescriptor,\n EventPayload,\n EmitOptions,\n} from './types'\n\n/** Queue name for persistent events */\nconst EVENTS_QUEUE_NAME = 'events'\n\n/** Job data structure for queued events */\ntype EventJobData = {\n event: string\n payload: EventPayload\n}\n\n/**\n * Creates an event bus instance.\n *\n * The event bus provides:\n * - In-memory event delivery to registered handlers\n * - Optional persistence via the queue package when `persistent: true`\n *\n * @param opts - Configuration options\n * @returns An EventBus instance\n *\n * @example\n * ```typescript\n * const bus = createEventBus({\n * resolve: container.resolve.bind(container),\n * queueStrategy: 'local', // or 'async' for BullMQ\n * })\n *\n * // Register a handler\n * bus.on('user.created', async (payload, ctx) => {\n * const userService = ctx.resolve('userService')\n * await userService.sendWelcomeEmail(payload.userId)\n * })\n *\n * // Emit an event (immediate delivery)\n * await bus.emit('user.created', { userId: '123' })\n *\n * // Emit with persistence (for async worker processing)\n * await bus.emit('order.placed', { orderId: '456' }, { persistent: true })\n * ```\n */\nexport function createEventBus(opts: CreateBusOptions): EventBus {\n // In-memory listeners for immediate event delivery\n const listeners = new Map<string, Set<SubscriberHandler>>()\n\n // Determine queue strategy from options or environment\n const queueStrategy = opts.queueStrategy ??\n (process.env.QUEUE_STRATEGY === 'async' ? 'async' : 'local')\n\n // Lazy-initialized queue for persistent events\n let queue: Queue<EventJobData> | null = null\n\n /**\n * Gets or creates the queue instance for persistent events.\n */\n function getQueue(): Queue<EventJobData> {\n if (!queue) {\n if (queueStrategy === 'async') {\n const redisUrl = process.env.REDIS_URL || process.env.QUEUE_REDIS_URL\n if (!redisUrl) {\n console.warn('[events] No REDIS_URL configured, falling back to localhost:6379')\n }\n queue = createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: redisUrl }\n })\n } else {\n queue = createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'local')\n }\n }\n return queue\n }\n\n /**\n * Delivers an event to all registered in-memory handlers.\n */\n async function deliver(event: string, payload: EventPayload): Promise<void> {\n const handlers = listeners.get(event)\n if (!handlers || handlers.size === 0) return\n\n for (const handler of handlers) {\n try {\n await Promise.resolve(handler(payload, { resolve: opts.resolve }))\n } catch (error) {\n console.error(`[events] Handler error for \"${event}\":`, error)\n }\n }\n }\n\n /**\n * Registers a handler for an event.\n */\n function on(event: string, handler: SubscriberHandler): void {\n if (!listeners.has(event)) {\n listeners.set(event, new Set())\n }\n listeners.get(event)!.add(handler)\n }\n\n /**\n * Registers multiple module subscribers at once.\n */\n function registerModuleSubscribers(subs: SubscriberDescriptor[]): void {\n for (const sub of subs) {\n on(sub.event, sub.handler)\n }\n }\n\n /**\n * Emits an event to all registered handlers.\n *\n * If `persistent: true`, also enqueues the event for async processing.\n */\n async function emit(\n event: string,\n payload: EventPayload,\n options?: EmitOptions\n ): Promise<void> {\n // Always deliver to in-memory handlers first\n await deliver(event, payload)\n\n // If persistent, also enqueue for async processing\n if (options?.persistent) {\n const q = getQueue()\n await q.enqueue({ event, payload })\n }\n }\n\n /**\n * Clears all events from the persistent queue.\n */\n async function clearQueue(): Promise<{ removed: number }> {\n const q = getQueue()\n return q.clear()\n }\n\n // Backward compatibility alias\n const emitEvent = emit\n\n return {\n emit,\n emitEvent, // Alias for backward compatibility\n on,\n registerModuleSubscribers,\n clearQueue,\n }\n}\n"],
5
+ "mappings": "AAAA,SAAS,mBAAmB;AAY5B,MAAM,oBAAoB;AAsCnB,SAAS,eAAe,MAAkC;AAE/D,QAAM,YAAY,oBAAI,IAAoC;AAG1D,QAAM,gBAAgB,KAAK,kBACxB,QAAQ,IAAI,mBAAmB,UAAU,UAAU;AAGtD,MAAI,QAAoC;AAKxC,WAAS,WAAgC;AACvC,QAAI,CAAC,OAAO;AACV,UAAI,kBAAkB,SAAS;AAC7B,cAAM,WAAW,QAAQ,IAAI,aAAa,QAAQ,IAAI;AACtD,YAAI,CAAC,UAAU;AACb,kBAAQ,KAAK,kEAAkE;AAAA,QACjF;AACA,gBAAQ,YAA0B,mBAAmB,SAAS;AAAA,UAC5D,YAAY,EAAE,KAAK,SAAS;AAAA,QAC9B,CAAC;AAAA,MACH,OAAO;AACL,gBAAQ,YAA0B,mBAAmB,OAAO;AAAA,MAC9D;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAKA,iBAAe,QAAQ,OAAe,SAAsC;AAC1E,UAAM,WAAW,UAAU,IAAI,KAAK;AACpC,QAAI,CAAC,YAAY,SAAS,SAAS,EAAG;AAEtC,eAAW,WAAW,UAAU;AAC9B,UAAI;AACF,cAAM,QAAQ,QAAQ,QAAQ,SAAS,EAAE,SAAS,KAAK,QAAQ,CAAC,CAAC;AAAA,MACnE,SAAS,OAAO;AACd,gBAAQ,MAAM,+BAA+B,KAAK,MAAM,KAAK;AAAA,MAC/D;AAAA,IACF;AAAA,EACF;AAKA,WAAS,GAAG,OAAe,SAAkC;AAC3D,QAAI,CAAC,UAAU,IAAI,KAAK,GAAG;AACzB,gBAAU,IAAI,OAAO,oBAAI,IAAI,CAAC;AAAA,IAChC;AACA,cAAU,IAAI,KAAK,EAAG,IAAI,OAAO;AAAA,EACnC;AAKA,WAAS,0BAA0B,MAAoC;AACrE,eAAW,OAAO,MAAM;AACtB,SAAG,IAAI,OAAO,IAAI,OAAO;AAAA,IAC3B;AAAA,EACF;AAOA,iBAAe,KACb,OACA,SACA,SACe;AAEf,UAAM,QAAQ,OAAO,OAAO;AAG5B,QAAI,SAAS,YAAY;AACvB,YAAM,IAAI,SAAS;AACnB,YAAM,EAAE,QAAQ,EAAE,OAAO,QAAQ,CAAC;AAAA,IACpC;AAAA,EACF;AAKA,iBAAe,aAA2C;AACxD,UAAM,IAAI,SAAS;AACnB,WAAO,EAAE,MAAM;AAAA,EACjB;AAGA,QAAM,YAAY;AAElB,SAAO;AAAA,IACL;AAAA,IACA;AAAA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;",
6
+ "names": []
7
+ }
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./types.js";
2
+ export * from "./bus.js";
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/index.ts"],
4
+ "sourcesContent": ["export * from './types'\nexport * from './bus'\n"],
5
+ "mappings": "AAAA,cAAc;AACd,cAAc;",
6
+ "names": []
7
+ }
@@ -0,0 +1,9 @@
1
+ const metadata = {
2
+ id: "events",
3
+ name: "Events",
4
+ description: "Event bus and subscriber dispatch"
5
+ };
6
+ export {
7
+ metadata
8
+ };
9
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/modules/events/index.ts"],
4
+ "sourcesContent": ["export const metadata = {\n id: 'events',\n name: 'Events',\n description: 'Event bus and subscriber dispatch',\n}\n"],
5
+ "mappings": "AAAO,MAAM,WAAW;AAAA,EACtB,IAAI;AAAA,EACJ,MAAM;AAAA,EACN,aAAa;AACf;",
6
+ "names": []
7
+ }
@@ -0,0 +1,58 @@
1
+ import { getCliModules } from "@open-mercato/shared/modules/registry";
2
+ const EVENTS_QUEUE_NAME = "events";
3
+ const DEFAULT_CONCURRENCY = 1;
4
+ const envConcurrency = process.env.WORKERS_EVENTS_CONCURRENCY;
5
+ const metadata = {
6
+ queue: EVENTS_QUEUE_NAME,
7
+ concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY
8
+ };
9
+ let cachedListenerMap = null;
10
+ function clearListenerCache() {
11
+ cachedListenerMap = null;
12
+ }
13
+ function buildListenerMap() {
14
+ const listeners = /* @__PURE__ */ new Map();
15
+ for (const mod of getCliModules()) {
16
+ const subs = mod.subscribers;
17
+ if (!subs) continue;
18
+ for (const sub of subs) {
19
+ if (!listeners.has(sub.event)) listeners.set(sub.event, []);
20
+ listeners.get(sub.event).push(sub);
21
+ }
22
+ }
23
+ return listeners;
24
+ }
25
+ function getListenerMap() {
26
+ if (!cachedListenerMap) {
27
+ cachedListenerMap = buildListenerMap();
28
+ }
29
+ return cachedListenerMap;
30
+ }
31
+ async function handle(job, ctx) {
32
+ const { event, payload } = job.payload;
33
+ const listeners = getListenerMap();
34
+ const subscribers = listeners.get(event);
35
+ if (!subscribers || subscribers.length === 0) return;
36
+ const errors = [];
37
+ for (const sub of subscribers) {
38
+ try {
39
+ await sub.handler(payload, { resolve: ctx.resolve });
40
+ } catch (error) {
41
+ console.error(`[events] Subscriber "${sub.id}" failed for event "${event}":`, error);
42
+ errors.push({ subscriberId: sub.id, error });
43
+ }
44
+ }
45
+ if (errors.length === subscribers.length) {
46
+ throw new Error(`All ${errors.length} subscriber(s) failed for event "${event}"`);
47
+ }
48
+ if (errors.length > 0) {
49
+ console.warn(`[events] ${errors.length}/${subscribers.length} subscriber(s) failed for event "${event}"`);
50
+ }
51
+ }
52
+ export {
53
+ EVENTS_QUEUE_NAME,
54
+ clearListenerCache,
55
+ handle as default,
56
+ metadata
57
+ };
58
+ //# sourceMappingURL=events.worker.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/events/workers/events.worker.ts"],
4
+ "sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { getCliModules } from '@open-mercato/shared/modules/registry'\n\nexport const EVENTS_QUEUE_NAME = 'events'\n\nconst DEFAULT_CONCURRENCY = 1\nconst envConcurrency = process.env.WORKERS_EVENTS_CONCURRENCY\n\nexport const metadata: WorkerMeta = {\n queue: EVENTS_QUEUE_NAME,\n concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,\n}\n\ntype EventJobPayload = {\n event: string\n payload: unknown\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\ntype SubscriberEntry = {\n id: string\n event: string\n handler: (payload: unknown, ctx: unknown) => Promise<void> | void\n}\n\n// Cached listener map - built once on first use\nlet cachedListenerMap: Map<string, SubscriberEntry[]> | null = null\n\n/**\n * Clear the cached listener map (for testing purposes).\n */\nexport function clearListenerCache(): void {\n cachedListenerMap = null\n}\n\n// Build listener map from module subscribers\nfunction buildListenerMap(): Map<string, SubscriberEntry[]> {\n const listeners = new Map<string, SubscriberEntry[]>()\n for (const mod of getCliModules()) {\n const subs = (mod as { subscribers?: SubscriberEntry[] }).subscribers\n if (!subs) continue\n for (const sub of subs) {\n if (!listeners.has(sub.event)) listeners.set(sub.event, [])\n listeners.get(sub.event)!.push(sub)\n }\n }\n return listeners\n}\n\n// Get cached listener map, building on first access\nfunction getListenerMap(): Map<string, SubscriberEntry[]> {\n if (!cachedListenerMap) {\n cachedListenerMap = buildListenerMap()\n }\n return cachedListenerMap\n}\n\n/**\n * Events worker handler.\n * Dispatches queued events to registered module subscribers.\n * Each subscriber is isolated - failures in one don't affect others.\n */\nexport default async function handle(\n job: QueuedJob<EventJobPayload>,\n ctx: JobContext & HandlerContext\n): Promise<void> {\n const { event, payload } = job.payload\n const listeners = getListenerMap()\n const subscribers = listeners.get(event)\n\n if (!subscribers || subscribers.length === 0) return\n\n const errors: Array<{ subscriberId: string; error: unknown }> = []\n\n for (const sub of subscribers) {\n try {\n await sub.handler(payload, { resolve: ctx.resolve })\n } catch (error) {\n // Log error but continue processing other subscribers\n console.error(`[events] Subscriber \"${sub.id}\" failed for event \"${event}\":`, error)\n errors.push({ subscriberId: sub.id, error })\n }\n }\n\n // If all subscribers failed, throw to trigger retry\n if (errors.length === subscribers.length) {\n throw new Error(`All ${errors.length} subscriber(s) failed for event \"${event}\"`)\n }\n\n // Log partial failures but don't fail the job\n if (errors.length > 0) {\n console.warn(`[events] ${errors.length}/${subscribers.length} subscriber(s) failed for event \"${event}\"`)\n }\n}\n"],
5
+ "mappings": "AACA,SAAS,qBAAqB;AAEvB,MAAM,oBAAoB;AAEjC,MAAM,sBAAsB;AAC5B,MAAM,iBAAiB,QAAQ,IAAI;AAE5B,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,aAAa,iBAAiB,SAAS,gBAAgB,EAAE,IAAI;AAC/D;AAgBA,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,QAAQ,IAAI,IAAI;AAC/B,QAAM,YAAY,eAAe;AACjC,QAAM,cAAc,UAAU,IAAI,KAAK;AAEvC,MAAI,CAAC,eAAe,YAAY,WAAW,EAAG;AAE9C,QAAM,SAA0D,CAAC;AAEjE,aAAW,OAAO,aAAa;AAC7B,QAAI;AACF,YAAM,IAAI,QAAQ,SAAS,EAAE,SAAS,IAAI,QAAQ,CAAC;AAAA,IACrD,SAAS,OAAO;AAEd,cAAQ,MAAM,wBAAwB,IAAI,EAAE,uBAAuB,KAAK,MAAM,KAAK;AACnF,aAAO,KAAK,EAAE,cAAc,IAAI,IAAI,MAAM,CAAC;AAAA,IAC7C;AAAA,EACF;AAGA,MAAI,OAAO,WAAW,YAAY,QAAQ;AACxC,UAAM,IAAI,MAAM,OAAO,OAAO,MAAM,oCAAoC,KAAK,GAAG;AAAA,EAClF;AAGA,MAAI,OAAO,SAAS,GAAG;AACrB,YAAQ,KAAK,YAAY,OAAO,MAAM,IAAI,YAAY,MAAM,oCAAoC,KAAK,GAAG;AAAA,EAC1G;AACF;",
6
+ "names": []
7
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": [],
4
+ "sourcesContent": [],
5
+ "mappings": "",
6
+ "names": []
7
+ }
@@ -0,0 +1,19 @@
1
+ /** @type {import('jest').Config} */
2
+ module.exports = {
3
+ preset: 'ts-jest',
4
+ testEnvironment: 'node',
5
+ rootDir: '.',
6
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
7
+ transform: {
8
+ '^.+\\.(t|j)sx?$': [
9
+ 'ts-jest',
10
+ {
11
+ tsconfig: {
12
+ jsx: 'react-jsx',
13
+ },
14
+ },
15
+ ],
16
+ },
17
+ testMatch: ['<rootDir>/src/**/__tests__/**/*.test.(ts|tsx)'],
18
+ passWithNoTests: true,
19
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@open-mercato/events",
3
+ "version": "0.4.2-canary-c02407ff85",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "scripts": {
7
+ "build": "node build.mjs",
8
+ "watch": "node watch.mjs",
9
+ "test": "jest --config jest.config.cjs",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./src/index.ts",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "./*.ts": {
18
+ "types": "./src/*.ts",
19
+ "default": "./dist/*.js"
20
+ },
21
+ "./*": {
22
+ "types": [
23
+ "./src/*.ts"
24
+ ],
25
+ "default": "./dist/*.js"
26
+ },
27
+ "./*/*": {
28
+ "types": [
29
+ "./src/*/*.ts"
30
+ ],
31
+ "default": "./dist/*/*.js"
32
+ }
33
+ },
34
+ "dependencies": {
35
+ "@open-mercato/queue": "0.4.2-canary-c02407ff85"
36
+ },
37
+ "devDependencies": {
38
+ "@types/jest": "^30.0.0",
39
+ "jest": "^30.2.0",
40
+ "ts-jest": "^29.4.6"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "stableVersion": "0.4.1"
46
+ }
@@ -0,0 +1,110 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ import { createEventBus } from '@open-mercato/events/index'
6
+
7
+ function readJson(p: string) { return JSON.parse(fs.readFileSync(p, 'utf8')) }
8
+
9
+ describe('Event bus', () => {
10
+ const origCwd = process.cwd()
11
+ let tmp: string
12
+ beforeEach(() => {
13
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'events-test-'))
14
+ process.chdir(tmp)
15
+ delete process.env.QUEUE_STRATEGY
16
+ delete process.env.EVENTS_STRATEGY
17
+ })
18
+ afterEach(() => {
19
+ process.chdir(origCwd)
20
+ try { fs.rmSync(tmp, { recursive: true, force: true }) } catch {}
21
+ })
22
+
23
+ test('online delivery via on + emit', async () => {
24
+ const calls: any[] = []
25
+ const bus = createEventBus({ resolve: ((name: string) => name) as any })
26
+ bus.on('demo', async (payload, ctx) => { calls.push({ payload, resolved: ctx.resolve('em') }) })
27
+ await bus.emit('demo', { a: 1 })
28
+ expect(calls).toHaveLength(1)
29
+ expect(calls[0].payload).toEqual({ a: 1 })
30
+ expect(calls[0].resolved).toEqual('em')
31
+ })
32
+
33
+ test('emitEvent alias works for backward compatibility', async () => {
34
+ const calls: any[] = []
35
+ const bus = createEventBus({ resolve: ((name: string) => name) as any })
36
+ bus.on('demo', async (payload) => { calls.push(payload) })
37
+ await bus.emitEvent('demo', { a: 1 })
38
+ expect(calls).toHaveLength(1)
39
+ expect(calls[0]).toEqual({ a: 1 })
40
+ })
41
+
42
+ test('persistent events are recorded to queue', async () => {
43
+ const queueDir = path.resolve('.queue', 'events')
44
+ const queuePath = path.join(queueDir, 'queue.json')
45
+ const recv: any[] = []
46
+ const bus = createEventBus({ resolve: ((name: string) => name) as any })
47
+ bus.on('queued', (payload) => { recv.push(payload) })
48
+
49
+ // Emit persistent events
50
+ await bus.emit('queued', { id: 1 }, { persistent: true })
51
+ await bus.emit('queued', { id: 2 }, { persistent: true })
52
+
53
+ // Events should be delivered immediately
54
+ expect(recv).toHaveLength(2)
55
+
56
+ // And also persisted to queue
57
+ const list = readJson(queuePath)
58
+ expect(Array.isArray(list)).toBe(true)
59
+ expect(list.length).toBeGreaterThanOrEqual(2)
60
+ })
61
+
62
+ test('clearQueue removes all queued events', async () => {
63
+ const queueDir = path.resolve('.queue', 'events')
64
+ const queuePath = path.join(queueDir, 'queue.json')
65
+ const bus = createEventBus({ resolve: ((name: string) => name) as any })
66
+
67
+ await bus.emit('q', { n: 1 }, { persistent: true })
68
+ await bus.emit('q', { n: 2 }, { persistent: true })
69
+
70
+ const before = readJson(queuePath)
71
+ expect(before.length).toBeGreaterThanOrEqual(2)
72
+
73
+ const result = await bus.clearQueue()
74
+ expect(result.removed).toBeGreaterThanOrEqual(0)
75
+
76
+ const after = readJson(queuePath)
77
+ expect(after.length).toBe(0)
78
+ })
79
+
80
+ test('registerModuleSubscribers registers handlers', async () => {
81
+ const calls: any[] = []
82
+ const bus = createEventBus({ resolve: ((name: string) => name) as any })
83
+
84
+ bus.registerModuleSubscribers([
85
+ { id: 'sub1', event: 'test.event', handler: (p) => { calls.push(p) } },
86
+ { id: 'sub2', event: 'other.event', handler: (p) => { calls.push(p) } },
87
+ ])
88
+
89
+ await bus.emit('test.event', { value: 1 })
90
+ await bus.emit('other.event', { value: 2 })
91
+
92
+ expect(calls).toHaveLength(2)
93
+ expect(calls[0]).toEqual({ value: 1 })
94
+ expect(calls[1]).toEqual({ value: 2 })
95
+ })
96
+
97
+ test('non-persistent events are not queued', async () => {
98
+ const queueDir = path.resolve('.queue', 'events')
99
+ const queuePath = path.join(queueDir, 'queue.json')
100
+ const bus = createEventBus({ resolve: ((name: string) => name) as any })
101
+
102
+ await bus.emit('demo', { id: 1 }) // Non-persistent
103
+
104
+ // Queue file should not exist or be empty
105
+ if (fs.existsSync(queuePath)) {
106
+ const list = readJson(queuePath)
107
+ expect(list.length).toBe(0)
108
+ }
109
+ })
110
+ })
package/src/bus.ts ADDED
@@ -0,0 +1,155 @@
1
+ import { createQueue } from '@open-mercato/queue'
2
+ import type { Queue } from '@open-mercato/queue'
3
+ import type {
4
+ EventBus,
5
+ CreateBusOptions,
6
+ SubscriberHandler,
7
+ SubscriberDescriptor,
8
+ EventPayload,
9
+ EmitOptions,
10
+ } from './types'
11
+
12
+ /** Queue name for persistent events */
13
+ const EVENTS_QUEUE_NAME = 'events'
14
+
15
+ /** Job data structure for queued events */
16
+ type EventJobData = {
17
+ event: string
18
+ payload: EventPayload
19
+ }
20
+
21
+ /**
22
+ * Creates an event bus instance.
23
+ *
24
+ * The event bus provides:
25
+ * - In-memory event delivery to registered handlers
26
+ * - Optional persistence via the queue package when `persistent: true`
27
+ *
28
+ * @param opts - Configuration options
29
+ * @returns An EventBus instance
30
+ *
31
+ * @example
32
+ * ```typescript
33
+ * const bus = createEventBus({
34
+ * resolve: container.resolve.bind(container),
35
+ * queueStrategy: 'local', // or 'async' for BullMQ
36
+ * })
37
+ *
38
+ * // Register a handler
39
+ * bus.on('user.created', async (payload, ctx) => {
40
+ * const userService = ctx.resolve('userService')
41
+ * await userService.sendWelcomeEmail(payload.userId)
42
+ * })
43
+ *
44
+ * // Emit an event (immediate delivery)
45
+ * await bus.emit('user.created', { userId: '123' })
46
+ *
47
+ * // Emit with persistence (for async worker processing)
48
+ * await bus.emit('order.placed', { orderId: '456' }, { persistent: true })
49
+ * ```
50
+ */
51
+ export function createEventBus(opts: CreateBusOptions): EventBus {
52
+ // In-memory listeners for immediate event delivery
53
+ const listeners = new Map<string, Set<SubscriberHandler>>()
54
+
55
+ // Determine queue strategy from options or environment
56
+ const queueStrategy = opts.queueStrategy ??
57
+ (process.env.QUEUE_STRATEGY === 'async' ? 'async' : 'local')
58
+
59
+ // Lazy-initialized queue for persistent events
60
+ let queue: Queue<EventJobData> | null = null
61
+
62
+ /**
63
+ * Gets or creates the queue instance for persistent events.
64
+ */
65
+ function getQueue(): Queue<EventJobData> {
66
+ if (!queue) {
67
+ if (queueStrategy === 'async') {
68
+ const redisUrl = process.env.REDIS_URL || process.env.QUEUE_REDIS_URL
69
+ if (!redisUrl) {
70
+ console.warn('[events] No REDIS_URL configured, falling back to localhost:6379')
71
+ }
72
+ queue = createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {
73
+ connection: { url: redisUrl }
74
+ })
75
+ } else {
76
+ queue = createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'local')
77
+ }
78
+ }
79
+ return queue
80
+ }
81
+
82
+ /**
83
+ * Delivers an event to all registered in-memory handlers.
84
+ */
85
+ async function deliver(event: string, payload: EventPayload): Promise<void> {
86
+ const handlers = listeners.get(event)
87
+ if (!handlers || handlers.size === 0) return
88
+
89
+ for (const handler of handlers) {
90
+ try {
91
+ await Promise.resolve(handler(payload, { resolve: opts.resolve }))
92
+ } catch (error) {
93
+ console.error(`[events] Handler error for "${event}":`, error)
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Registers a handler for an event.
100
+ */
101
+ function on(event: string, handler: SubscriberHandler): void {
102
+ if (!listeners.has(event)) {
103
+ listeners.set(event, new Set())
104
+ }
105
+ listeners.get(event)!.add(handler)
106
+ }
107
+
108
+ /**
109
+ * Registers multiple module subscribers at once.
110
+ */
111
+ function registerModuleSubscribers(subs: SubscriberDescriptor[]): void {
112
+ for (const sub of subs) {
113
+ on(sub.event, sub.handler)
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Emits an event to all registered handlers.
119
+ *
120
+ * If `persistent: true`, also enqueues the event for async processing.
121
+ */
122
+ async function emit(
123
+ event: string,
124
+ payload: EventPayload,
125
+ options?: EmitOptions
126
+ ): Promise<void> {
127
+ // Always deliver to in-memory handlers first
128
+ await deliver(event, payload)
129
+
130
+ // If persistent, also enqueue for async processing
131
+ if (options?.persistent) {
132
+ const q = getQueue()
133
+ await q.enqueue({ event, payload })
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Clears all events from the persistent queue.
139
+ */
140
+ async function clearQueue(): Promise<{ removed: number }> {
141
+ const q = getQueue()
142
+ return q.clear()
143
+ }
144
+
145
+ // Backward compatibility alias
146
+ const emitEvent = emit
147
+
148
+ return {
149
+ emit,
150
+ emitEvent, // Alias for backward compatibility
151
+ on,
152
+ registerModuleSubscribers,
153
+ clearQueue,
154
+ }
155
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './types'
2
+ export * from './bus'
@@ -0,0 +1,5 @@
1
+ export const metadata = {
2
+ id: 'events',
3
+ name: 'Events',
4
+ description: 'Event bus and subscriber dispatch',
5
+ }
@@ -0,0 +1,311 @@
1
+ import { registerCliModules, getCliModules } from '@open-mercato/shared/modules/registry'
2
+ import type { Module } from '@open-mercato/shared/modules/registry'
3
+ import type { QueuedJob, JobContext } from '@open-mercato/queue'
4
+ import handle, { metadata, EVENTS_QUEUE_NAME, clearListenerCache } from '../events.worker'
5
+
6
+ // Clear modules and listener cache before each test
7
+ function clearModules() {
8
+ // Re-register with empty array to clear
9
+ registerCliModules([])
10
+ // Clear the listener cache so tests don't affect each other
11
+ clearListenerCache()
12
+ }
13
+
14
+ describe('Events Worker', () => {
15
+ beforeEach(() => {
16
+ clearModules()
17
+ })
18
+
19
+ afterEach(() => {
20
+ clearModules()
21
+ })
22
+
23
+ describe('metadata', () => {
24
+ it('should export correct queue name', () => {
25
+ expect(metadata.queue).toBe('events')
26
+ expect(EVENTS_QUEUE_NAME).toBe('events')
27
+ })
28
+
29
+ it('should have default concurrency of 1', () => {
30
+ // When no env var is set, should default to 1
31
+ expect(metadata.concurrency).toBe(1)
32
+ })
33
+ })
34
+
35
+ describe('handle', () => {
36
+ const createMockJob = (event: string, payload: unknown): QueuedJob<{ event: string; payload: unknown }> => ({
37
+ id: 'test-job-id',
38
+ payload: { event, payload },
39
+ createdAt: new Date().toISOString(),
40
+ })
41
+
42
+ const createMockContext = (): JobContext & { resolve: <T = unknown>(name: string) => T } => ({
43
+ jobId: 'test-job-id',
44
+ attemptNumber: 1,
45
+ queueName: 'events',
46
+ resolve: <T = unknown>(name: string): T => {
47
+ throw new Error(`No mock for ${name}`)
48
+ },
49
+ })
50
+
51
+ it('should do nothing when no subscribers are registered', async () => {
52
+ const job = createMockJob('test.event', { data: 'test' })
53
+ const ctx = createMockContext()
54
+
55
+ // Should not throw
56
+ await expect(handle(job, ctx)).resolves.toBeUndefined()
57
+ })
58
+
59
+ it('should dispatch event to matching subscribers', async () => {
60
+ const receivedPayloads: unknown[] = []
61
+
62
+ const mockModule: Module = {
63
+ id: 'test-module',
64
+ subscribers: [
65
+ {
66
+ id: 'test:subscriber1',
67
+ event: 'user.created',
68
+ handler: async (payload: unknown) => {
69
+ receivedPayloads.push(payload)
70
+ },
71
+ },
72
+ ],
73
+ }
74
+
75
+ registerCliModules([mockModule])
76
+
77
+ const job = createMockJob('user.created', { userId: '123', name: 'Test User' })
78
+ const ctx = createMockContext()
79
+
80
+ await handle(job, ctx)
81
+
82
+ expect(receivedPayloads.length).toBe(1)
83
+ expect(receivedPayloads[0]).toEqual({ userId: '123', name: 'Test User' })
84
+ })
85
+
86
+ it('should dispatch to multiple subscribers for same event', async () => {
87
+ const subscriber1Calls: unknown[] = []
88
+ const subscriber2Calls: unknown[] = []
89
+
90
+ const mockModules: Module[] = [
91
+ {
92
+ id: 'module-a',
93
+ subscribers: [
94
+ {
95
+ id: 'a:subscriber',
96
+ event: 'order.placed',
97
+ handler: async (payload: unknown) => {
98
+ subscriber1Calls.push(payload)
99
+ },
100
+ },
101
+ ],
102
+ },
103
+ {
104
+ id: 'module-b',
105
+ subscribers: [
106
+ {
107
+ id: 'b:subscriber',
108
+ event: 'order.placed',
109
+ handler: async (payload: unknown) => {
110
+ subscriber2Calls.push(payload)
111
+ },
112
+ },
113
+ ],
114
+ },
115
+ ]
116
+
117
+ registerCliModules(mockModules)
118
+
119
+ const job = createMockJob('order.placed', { orderId: '456' })
120
+ const ctx = createMockContext()
121
+
122
+ await handle(job, ctx)
123
+
124
+ expect(subscriber1Calls.length).toBe(1)
125
+ expect(subscriber2Calls.length).toBe(1)
126
+ expect(subscriber1Calls[0]).toEqual({ orderId: '456' })
127
+ expect(subscriber2Calls[0]).toEqual({ orderId: '456' })
128
+ })
129
+
130
+ it('should not dispatch to non-matching event subscribers', async () => {
131
+ const receivedPayloads: unknown[] = []
132
+
133
+ const mockModule: Module = {
134
+ id: 'test-module',
135
+ subscribers: [
136
+ {
137
+ id: 'test:subscriber',
138
+ event: 'user.created',
139
+ handler: async (payload: unknown) => {
140
+ receivedPayloads.push(payload)
141
+ },
142
+ },
143
+ ],
144
+ }
145
+
146
+ registerCliModules([mockModule])
147
+
148
+ const job = createMockJob('user.deleted', { userId: '123' })
149
+ const ctx = createMockContext()
150
+
151
+ await handle(job, ctx)
152
+
153
+ expect(receivedPayloads.length).toBe(0)
154
+ })
155
+
156
+ it('should pass resolve function to subscriber context', async () => {
157
+ let capturedContext: unknown = null
158
+
159
+ const mockModule: Module = {
160
+ id: 'test-module',
161
+ subscribers: [
162
+ {
163
+ id: 'test:subscriber',
164
+ event: 'test.event',
165
+ handler: async (_payload: unknown, ctx: unknown) => {
166
+ capturedContext = ctx
167
+ },
168
+ },
169
+ ],
170
+ }
171
+
172
+ registerCliModules([mockModule])
173
+
174
+ const mockResolve = jest.fn().mockReturnValue('resolved-service')
175
+ const job = createMockJob('test.event', {})
176
+ const ctx = {
177
+ ...createMockContext(),
178
+ resolve: mockResolve,
179
+ }
180
+
181
+ await handle(job, ctx)
182
+
183
+ expect(capturedContext).toBeDefined()
184
+ expect((capturedContext as { resolve: unknown }).resolve).toBeDefined()
185
+ })
186
+
187
+ it('should handle modules without subscribers', async () => {
188
+ const mockModule: Module = {
189
+ id: 'module-without-subscribers',
190
+ // No subscribers property
191
+ }
192
+
193
+ registerCliModules([mockModule])
194
+
195
+ const job = createMockJob('any.event', {})
196
+ const ctx = createMockContext()
197
+
198
+ // Should not throw
199
+ await expect(handle(job, ctx)).resolves.toBeUndefined()
200
+ })
201
+
202
+ it('should handle synchronous handlers', async () => {
203
+ let called = false
204
+
205
+ const mockModule: Module = {
206
+ id: 'test-module',
207
+ subscribers: [
208
+ {
209
+ id: 'test:sync-subscriber',
210
+ event: 'sync.event',
211
+ handler: () => {
212
+ called = true
213
+ },
214
+ },
215
+ ],
216
+ }
217
+
218
+ registerCliModules([mockModule])
219
+
220
+ const job = createMockJob('sync.event', {})
221
+ const ctx = createMockContext()
222
+
223
+ await handle(job, ctx)
224
+
225
+ expect(called).toBe(true)
226
+ })
227
+
228
+ it('should isolate errors - continue processing other subscribers when one fails', async () => {
229
+ const subscriber1Calls: unknown[] = []
230
+ const subscriber2Calls: unknown[] = []
231
+
232
+ const mockModules: Module[] = [
233
+ {
234
+ id: 'module-a',
235
+ subscribers: [
236
+ {
237
+ id: 'a:failing-subscriber',
238
+ event: 'test.event',
239
+ handler: async () => {
240
+ subscriber1Calls.push('called')
241
+ throw new Error('Subscriber A failed')
242
+ },
243
+ },
244
+ ],
245
+ },
246
+ {
247
+ id: 'module-b',
248
+ subscribers: [
249
+ {
250
+ id: 'b:working-subscriber',
251
+ event: 'test.event',
252
+ handler: async (payload: unknown) => {
253
+ subscriber2Calls.push(payload)
254
+ },
255
+ },
256
+ ],
257
+ },
258
+ ]
259
+
260
+ registerCliModules(mockModules)
261
+
262
+ const job = createMockJob('test.event', { data: 'test' })
263
+ const ctx = createMockContext()
264
+
265
+ // Should not throw - partial failures are logged but don't fail the job
266
+ await expect(handle(job, ctx)).resolves.toBeUndefined()
267
+
268
+ // Both subscribers should have been called
269
+ expect(subscriber1Calls.length).toBe(1)
270
+ expect(subscriber2Calls.length).toBe(1)
271
+ expect(subscriber2Calls[0]).toEqual({ data: 'test' })
272
+ })
273
+
274
+ it('should throw when all subscribers fail', async () => {
275
+ const mockModules: Module[] = [
276
+ {
277
+ id: 'module-a',
278
+ subscribers: [
279
+ {
280
+ id: 'a:failing-subscriber',
281
+ event: 'test.event',
282
+ handler: async () => {
283
+ throw new Error('Subscriber A failed')
284
+ },
285
+ },
286
+ ],
287
+ },
288
+ {
289
+ id: 'module-b',
290
+ subscribers: [
291
+ {
292
+ id: 'b:failing-subscriber',
293
+ event: 'test.event',
294
+ handler: async () => {
295
+ throw new Error('Subscriber B failed')
296
+ },
297
+ },
298
+ ],
299
+ },
300
+ ]
301
+
302
+ registerCliModules(mockModules)
303
+
304
+ const job = createMockJob('test.event', { data: 'test' })
305
+ const ctx = createMockContext()
306
+
307
+ // Should throw when all subscribers fail
308
+ await expect(handle(job, ctx)).rejects.toThrow('All 2 subscriber(s) failed for event "test.event"')
309
+ })
310
+ })
311
+ })
@@ -0,0 +1,95 @@
1
+ import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'
2
+ import { getCliModules } from '@open-mercato/shared/modules/registry'
3
+
4
+ export const EVENTS_QUEUE_NAME = 'events'
5
+
6
+ const DEFAULT_CONCURRENCY = 1
7
+ const envConcurrency = process.env.WORKERS_EVENTS_CONCURRENCY
8
+
9
+ export const metadata: WorkerMeta = {
10
+ queue: EVENTS_QUEUE_NAME,
11
+ concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,
12
+ }
13
+
14
+ type EventJobPayload = {
15
+ event: string
16
+ payload: unknown
17
+ }
18
+
19
+ type HandlerContext = { resolve: <T = unknown>(name: string) => T }
20
+
21
+ type SubscriberEntry = {
22
+ id: string
23
+ event: string
24
+ handler: (payload: unknown, ctx: unknown) => Promise<void> | void
25
+ }
26
+
27
+ // Cached listener map - built once on first use
28
+ let cachedListenerMap: Map<string, SubscriberEntry[]> | null = null
29
+
30
+ /**
31
+ * Clear the cached listener map (for testing purposes).
32
+ */
33
+ export function clearListenerCache(): void {
34
+ cachedListenerMap = null
35
+ }
36
+
37
+ // Build listener map from module subscribers
38
+ function buildListenerMap(): Map<string, SubscriberEntry[]> {
39
+ const listeners = new Map<string, SubscriberEntry[]>()
40
+ for (const mod of getCliModules()) {
41
+ const subs = (mod as { subscribers?: SubscriberEntry[] }).subscribers
42
+ if (!subs) continue
43
+ for (const sub of subs) {
44
+ if (!listeners.has(sub.event)) listeners.set(sub.event, [])
45
+ listeners.get(sub.event)!.push(sub)
46
+ }
47
+ }
48
+ return listeners
49
+ }
50
+
51
+ // Get cached listener map, building on first access
52
+ function getListenerMap(): Map<string, SubscriberEntry[]> {
53
+ if (!cachedListenerMap) {
54
+ cachedListenerMap = buildListenerMap()
55
+ }
56
+ return cachedListenerMap
57
+ }
58
+
59
+ /**
60
+ * Events worker handler.
61
+ * Dispatches queued events to registered module subscribers.
62
+ * Each subscriber is isolated - failures in one don't affect others.
63
+ */
64
+ export default async function handle(
65
+ job: QueuedJob<EventJobPayload>,
66
+ ctx: JobContext & HandlerContext
67
+ ): Promise<void> {
68
+ const { event, payload } = job.payload
69
+ const listeners = getListenerMap()
70
+ const subscribers = listeners.get(event)
71
+
72
+ if (!subscribers || subscribers.length === 0) return
73
+
74
+ const errors: Array<{ subscriberId: string; error: unknown }> = []
75
+
76
+ for (const sub of subscribers) {
77
+ try {
78
+ await sub.handler(payload, { resolve: ctx.resolve })
79
+ } catch (error) {
80
+ // Log error but continue processing other subscribers
81
+ console.error(`[events] Subscriber "${sub.id}" failed for event "${event}":`, error)
82
+ errors.push({ subscriberId: sub.id, error })
83
+ }
84
+ }
85
+
86
+ // If all subscribers failed, throw to trigger retry
87
+ if (errors.length === subscribers.length) {
88
+ throw new Error(`All ${errors.length} subscriber(s) failed for event "${event}"`)
89
+ }
90
+
91
+ // Log partial failures but don't fail the job
92
+ if (errors.length > 0) {
93
+ console.warn(`[events] ${errors.length}/${subscribers.length} subscriber(s) failed for event "${event}"`)
94
+ }
95
+ }
package/src/types.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Events Package Type Definitions
3
+ *
4
+ * Provides type-safe abstractions for the event bus system.
5
+ */
6
+
7
+ // ============================================================================
8
+ // Core Types
9
+ // ============================================================================
10
+
11
+ /** Payload type for events - can be any value */
12
+ export type EventPayload = unknown
13
+
14
+ /** Metadata for subscriber definitions */
15
+ export type SubscriberMeta = {
16
+ /** Event name to subscribe to */
17
+ event: string
18
+ /** Optional unique identifier for the subscriber */
19
+ id?: string
20
+ }
21
+
22
+ /** Context passed to event handlers */
23
+ export type SubscriberContext = {
24
+ /** DI container resolve function */
25
+ resolve: <T = unknown>(name: string) => T
26
+ }
27
+
28
+ /** Event handler function signature */
29
+ export type SubscriberHandler = (
30
+ payload: EventPayload,
31
+ ctx: SubscriberContext
32
+ ) => Promise<void> | void
33
+
34
+ /** Full descriptor for a module subscriber */
35
+ export type SubscriberDescriptor = {
36
+ /** Unique identifier for this subscriber */
37
+ id: string
38
+ /** Event name to subscribe to */
39
+ event: string
40
+ /** Handler function */
41
+ handler: SubscriberHandler
42
+ }
43
+
44
+ // ============================================================================
45
+ // Event Bus Types
46
+ // ============================================================================
47
+
48
+ /** Options for emitting events */
49
+ export type EmitOptions = {
50
+ /** If true, the event will be persisted to a queue for async processing */
51
+ persistent?: boolean
52
+ }
53
+
54
+ /** Options for creating an event bus */
55
+ export type CreateBusOptions = {
56
+ /** DI container resolve function */
57
+ resolve: <T = unknown>(name: string) => T
58
+ /** Queue strategy for persistent events: 'local' (file-based) or 'async' (BullMQ) */
59
+ queueStrategy?: 'local' | 'async'
60
+ }
61
+
62
+ /**
63
+ * Main EventBus interface.
64
+ *
65
+ * The event bus handles:
66
+ * - In-memory event delivery to registered handlers
67
+ * - Optional persistence of events to a queue for async processing
68
+ */
69
+ export interface EventBus {
70
+ /**
71
+ * Emit an event to all registered handlers.
72
+ *
73
+ * @param event - Event name
74
+ * @param payload - Event payload data
75
+ * @param options - Emit options
76
+ *
77
+ * @example
78
+ * ```typescript
79
+ * // Immediate delivery only
80
+ * await bus.emit('user.created', { userId: '123' })
81
+ *
82
+ * // Immediate delivery + queue for async processing
83
+ * await bus.emit('order.placed', { orderId: '456' }, { persistent: true })
84
+ * ```
85
+ */
86
+ emit(event: string, payload: EventPayload, options?: EmitOptions): Promise<void>
87
+
88
+ /**
89
+ * Register a handler for an event.
90
+ *
91
+ * @param event - Event name to listen for
92
+ * @param handler - Handler function
93
+ */
94
+ on(event: string, handler: SubscriberHandler): void
95
+
96
+ /**
97
+ * Register multiple module subscribers at once.
98
+ *
99
+ * @param subs - Array of subscriber descriptors
100
+ */
101
+ registerModuleSubscribers(subs: SubscriberDescriptor[]): void
102
+
103
+ /**
104
+ * Clear all events from the persistent queue.
105
+ *
106
+ * @returns Count of removed events
107
+ */
108
+ clearQueue(): Promise<{ removed: number }>
109
+
110
+ /**
111
+ * Alias for emit() for backward compatibility.
112
+ * @deprecated Use emit() instead
113
+ */
114
+ emitEvent(event: string, payload: EventPayload, options?: EmitOptions): Promise<void>
115
+ }
116
+
117
+ // ============================================================================
118
+ // Legacy Types (for backwards compatibility)
119
+ // ============================================================================
120
+
121
+ /** @deprecated Use QueuedJob from @open-mercato/queue instead */
122
+ export type QueuedEvent = {
123
+ id: number
124
+ event: string
125
+ payload: EventPayload
126
+ persistent?: boolean
127
+ createdAt: string
128
+ }
129
+
130
+ /** @deprecated Use EventBus interface instead */
131
+ export type EventStrategy = {
132
+ emit: (evt: Omit<QueuedEvent, 'id' | 'createdAt'> & { createdAt?: string }) => Promise<void>
133
+ on: (event: string, handler: SubscriberHandler) => void
134
+ registerModuleSubscribers: (subs: SubscriberDescriptor[]) => void
135
+ processOffline: (opts?: { limit?: number }) => Promise<{ processed: number; lastId?: number }>
136
+ clearQueue: () => Promise<{ removed: number }>
137
+ clearProcessed: () => Promise<{ removed: number; lastId?: number }>
138
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "../../tsconfig.json",
4
+ "compilerOptions": {
5
+ "noEmit": false,
6
+ "declaration": false,
7
+ "outDir": "./dist",
8
+ "rootDir": "./src"
9
+ },
10
+ "include": ["src/**/*"]
11
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "../../tsconfig.base.json",
4
+ "compilerOptions": {
5
+ "noEmit": true
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": ["node_modules", "dist", "**/__tests__/**"]
9
+ }
package/watch.mjs ADDED
@@ -0,0 +1,6 @@
1
+ import { watch } from '../../scripts/watch.mjs'
2
+ import { dirname } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url))
6
+ watch(__dirname)