@veloxts/events 0.8.0 → 0.8.2

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.
@@ -0,0 +1,87 @@
1
+ /**
2
+ * DomainEventEmitter
3
+ *
4
+ * Typed in-process event emitter for domain events. Used internally by the
5
+ * procedure builder's `.emits()` method and by `ctx.events.emit()` for
6
+ * imperative usage.
7
+ *
8
+ * Listeners receive `event.data` (the typed payload), not the event instance.
9
+ * Sequential listeners run first (in registration order), then concurrent
10
+ * listeners run via Promise.allSettled. Listener errors are caught and logged
11
+ * — they must never propagate to the caller, since the mutation already succeeded.
12
+ */
13
+ import type { DomainEvent, DomainEventClass } from './event.js';
14
+ export interface ListenerOptions {
15
+ /**
16
+ * When true, this listener executes in registration order, awaiting
17
+ * completion before the next sequential listener starts.
18
+ *
19
+ * Sequential listeners all complete before concurrent listeners begin.
20
+ *
21
+ * @default false
22
+ */
23
+ sequential?: boolean;
24
+ /**
25
+ * Placeholder — stored but not yet implemented.
26
+ *
27
+ * TODO (v1.1): Implement automatic retry logic for retryable listeners
28
+ * using an exponential back-off strategy with configurable attempts.
29
+ *
30
+ * @default false
31
+ */
32
+ retryable?: boolean;
33
+ }
34
+ /**
35
+ * Typed in-process domain event emitter.
36
+ *
37
+ * @example
38
+ * ```typescript
39
+ * const emitter = new DomainEventEmitter();
40
+ *
41
+ * emitter.on(OrderCreatedEvent, async (data) => {
42
+ * await sendConfirmationEmail(data.orderId);
43
+ * });
44
+ *
45
+ * // In a mutation:
46
+ * await emitter.emit(new OrderCreatedEvent({ orderId: 'order-123', total: 99.99 }));
47
+ * ```
48
+ */
49
+ export declare class DomainEventEmitter {
50
+ /**
51
+ * Registry keyed by `name`. Each value is an array of listener entries
52
+ * typed as `ListenerEntry<Record<string, unknown>>` so they can be stored
53
+ * together in one map — we narrow the type at registration/retrieval time.
54
+ */
55
+ private readonly _listeners;
56
+ /**
57
+ * Register a typed listener for a domain event class.
58
+ *
59
+ * The handler receives `event.data` — the typed payload — not the event
60
+ * instance itself.
61
+ *
62
+ * @param eventClass - The domain event class (used as the key via `.name`).
63
+ * @param handler - Called with the event's `data` payload on each emit.
64
+ * @param options - `{ sequential?, retryable? }`
65
+ */
66
+ on<TData extends Record<string, unknown>>(eventClass: DomainEventClass<TData>, handler: (data: TData) => void | Promise<void>, options?: ListenerOptions): void;
67
+ /**
68
+ * Remove a previously registered listener.
69
+ *
70
+ * @param eventClass - The domain event class the listener was registered for.
71
+ * @param handler - The exact handler reference passed to `on()`.
72
+ */
73
+ off<TData extends Record<string, unknown>>(eventClass: DomainEventClass<TData>, handler: (data: TData) => void | Promise<void>): void;
74
+ /**
75
+ * Fire a domain event to all registered listeners.
76
+ *
77
+ * Execution order:
78
+ * 1. Sequential listeners run first, in registration order (each awaited).
79
+ * 2. Concurrent listeners run together via Promise.allSettled.
80
+ *
81
+ * Errors from any listener are caught and logged — they never propagate
82
+ * to the caller. The mutation that triggered the event already succeeded.
83
+ *
84
+ * @param event - The domain event instance to dispatch.
85
+ */
86
+ emit<TData extends Record<string, unknown>>(event: DomainEvent<TData>): Promise<void>;
87
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * DomainEventEmitter
3
+ *
4
+ * Typed in-process event emitter for domain events. Used internally by the
5
+ * procedure builder's `.emits()` method and by `ctx.events.emit()` for
6
+ * imperative usage.
7
+ *
8
+ * Listeners receive `event.data` (the typed payload), not the event instance.
9
+ * Sequential listeners run first (in registration order), then concurrent
10
+ * listeners run via Promise.allSettled. Listener errors are caught and logged
11
+ * — they must never propagate to the caller, since the mutation already succeeded.
12
+ */
13
+ import { createLogger } from '@veloxts/core';
14
+ const log = createLogger('events');
15
+ // =============================================================================
16
+ // DomainEventEmitter
17
+ // =============================================================================
18
+ /**
19
+ * Typed in-process domain event emitter.
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * const emitter = new DomainEventEmitter();
24
+ *
25
+ * emitter.on(OrderCreatedEvent, async (data) => {
26
+ * await sendConfirmationEmail(data.orderId);
27
+ * });
28
+ *
29
+ * // In a mutation:
30
+ * await emitter.emit(new OrderCreatedEvent({ orderId: 'order-123', total: 99.99 }));
31
+ * ```
32
+ */
33
+ export class DomainEventEmitter {
34
+ /**
35
+ * Registry keyed by `name`. Each value is an array of listener entries
36
+ * typed as `ListenerEntry<Record<string, unknown>>` so they can be stored
37
+ * together in one map — we narrow the type at registration/retrieval time.
38
+ */
39
+ _listeners = new Map();
40
+ // ---------------------------------------------------------------------------
41
+ // on()
42
+ // ---------------------------------------------------------------------------
43
+ /**
44
+ * Register a typed listener for a domain event class.
45
+ *
46
+ * The handler receives `event.data` — the typed payload — not the event
47
+ * instance itself.
48
+ *
49
+ * @param eventClass - The domain event class (used as the key via `.name`).
50
+ * @param handler - Called with the event's `data` payload on each emit.
51
+ * @param options - `{ sequential?, retryable? }`
52
+ */
53
+ on(eventClass, handler, options = {}) {
54
+ const { sequential = false, retryable = false } = options;
55
+ const key = eventClass.name;
56
+ if (!this._listeners.has(key)) {
57
+ this._listeners.set(key, []);
58
+ }
59
+ // Cast: we store all handlers as (data: Record<string, unknown>) => … but
60
+ // they are only invoked with the correctly-typed payload for their key.
61
+ const entry = {
62
+ handler: handler,
63
+ sequential,
64
+ retryable,
65
+ };
66
+ this._listeners.get(key)?.push(entry);
67
+ }
68
+ // ---------------------------------------------------------------------------
69
+ // off()
70
+ // ---------------------------------------------------------------------------
71
+ /**
72
+ * Remove a previously registered listener.
73
+ *
74
+ * @param eventClass - The domain event class the listener was registered for.
75
+ * @param handler - The exact handler reference passed to `on()`.
76
+ */
77
+ off(eventClass, handler) {
78
+ const key = eventClass.name;
79
+ const entries = this._listeners.get(key);
80
+ if (!entries)
81
+ return;
82
+ const filtered = entries.filter((entry) => entry.handler !== handler);
83
+ if (filtered.length === 0) {
84
+ this._listeners.delete(key);
85
+ }
86
+ else {
87
+ this._listeners.set(key, filtered);
88
+ }
89
+ }
90
+ // ---------------------------------------------------------------------------
91
+ // emit()
92
+ // ---------------------------------------------------------------------------
93
+ /**
94
+ * Fire a domain event to all registered listeners.
95
+ *
96
+ * Execution order:
97
+ * 1. Sequential listeners run first, in registration order (each awaited).
98
+ * 2. Concurrent listeners run together via Promise.allSettled.
99
+ *
100
+ * Errors from any listener are caught and logged — they never propagate
101
+ * to the caller. The mutation that triggered the event already succeeded.
102
+ *
103
+ * @param event - The domain event instance to dispatch.
104
+ */
105
+ async emit(event) {
106
+ const key = event.constructor.name;
107
+ const entries = this._listeners.get(key);
108
+ if (!entries || entries.length === 0)
109
+ return;
110
+ const sequential = entries.filter((e) => e.sequential);
111
+ const concurrent = entries.filter((e) => !e.sequential);
112
+ // --- Sequential listeners (in registration order) ---
113
+ for (const entry of sequential) {
114
+ try {
115
+ await entry.handler(event.data);
116
+ }
117
+ catch (err) {
118
+ // Listener errors must never propagate — the mutation already succeeded.
119
+ // Log the error and continue to the next listener.
120
+ log.error(`Sequential listener for "${key}" threw an error:`, err);
121
+ }
122
+ }
123
+ // --- Concurrent listeners (all at once) ---
124
+ if (concurrent.length > 0) {
125
+ const results = await Promise.allSettled(concurrent.map((entry) => entry.handler(event.data)));
126
+ for (const result of results) {
127
+ if (result.status === 'rejected') {
128
+ log.error(`Concurrent listener for "${key}" threw an error:`, result.reason);
129
+ }
130
+ }
131
+ }
132
+ }
133
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * DomainEvent Base Class
3
+ *
4
+ * Abstract base class for internal server-side domain events used in
5
+ * business logic (e.g., OrderCreated, UserRegistered). These are distinct
6
+ * from broadcast events (WebSocket/SSE) which are client-facing.
7
+ */
8
+ /**
9
+ * Abstract base class for all domain events.
10
+ *
11
+ * Domain events represent meaningful occurrences within the business domain
12
+ * (e.g., "OrderCreated", "OrderFulfilled"). They carry typed payloads and
13
+ * support optional correlation IDs for distributed tracing.
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * interface OrderCreatedData {
18
+ * orderId: string;
19
+ * customerId: string;
20
+ * total: number;
21
+ * }
22
+ *
23
+ * class OrderCreatedEvent extends DomainEvent<OrderCreatedData> {}
24
+ *
25
+ * const event = new OrderCreatedEvent(
26
+ * { orderId: 'order-123', customerId: 'cust-456', total: 99.99 },
27
+ * { correlationId: 'req-abc' }
28
+ * );
29
+ *
30
+ * console.log(OrderCreatedEvent.name); // 'OrderCreatedEvent'
31
+ * ```
32
+ */
33
+ export declare abstract class DomainEvent<TData extends Record<string, unknown> = Record<string, unknown>> {
34
+ /** The typed payload carried by this event. */
35
+ readonly data: TData;
36
+ /** The date/time at which this event was instantiated. */
37
+ readonly timestamp: Date;
38
+ /** Optional correlation ID for distributed tracing across services. */
39
+ readonly correlationId?: string;
40
+ constructor(data: TData, options?: {
41
+ correlationId?: string;
42
+ });
43
+ }
44
+ /**
45
+ * Type representing the constructor signature of a concrete DomainEvent subclass.
46
+ *
47
+ * Use this when you need to hold a reference to an event class itself
48
+ * (e.g., to register listeners or dispatch by class reference).
49
+ *
50
+ * @example
51
+ * ```typescript
52
+ * function on<TData extends Record<string, unknown>>(
53
+ * EventClass: DomainEventClass<TData>,
54
+ * handler: (event: DomainEvent<TData>) => void,
55
+ * ): void {
56
+ * // register handler keyed by EventClass.name
57
+ * }
58
+ * ```
59
+ */
60
+ export type DomainEventClass<TData extends Record<string, unknown> = Record<string, unknown>> = {
61
+ new (data: TData, options?: {
62
+ correlationId?: string;
63
+ }): DomainEvent<TData>;
64
+ readonly name: string;
65
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * DomainEvent Base Class
3
+ *
4
+ * Abstract base class for internal server-side domain events used in
5
+ * business logic (e.g., OrderCreated, UserRegistered). These are distinct
6
+ * from broadcast events (WebSocket/SSE) which are client-facing.
7
+ */
8
+ // =============================================================================
9
+ // DomainEvent
10
+ // =============================================================================
11
+ /**
12
+ * Abstract base class for all domain events.
13
+ *
14
+ * Domain events represent meaningful occurrences within the business domain
15
+ * (e.g., "OrderCreated", "OrderFulfilled"). They carry typed payloads and
16
+ * support optional correlation IDs for distributed tracing.
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * interface OrderCreatedData {
21
+ * orderId: string;
22
+ * customerId: string;
23
+ * total: number;
24
+ * }
25
+ *
26
+ * class OrderCreatedEvent extends DomainEvent<OrderCreatedData> {}
27
+ *
28
+ * const event = new OrderCreatedEvent(
29
+ * { orderId: 'order-123', customerId: 'cust-456', total: 99.99 },
30
+ * { correlationId: 'req-abc' }
31
+ * );
32
+ *
33
+ * console.log(OrderCreatedEvent.name); // 'OrderCreatedEvent'
34
+ * ```
35
+ */
36
+ export class DomainEvent {
37
+ /** The typed payload carried by this event. */
38
+ data;
39
+ /** The date/time at which this event was instantiated. */
40
+ timestamp;
41
+ /** Optional correlation ID for distributed tracing across services. */
42
+ correlationId;
43
+ constructor(data, options) {
44
+ this.data = data;
45
+ this.timestamp = new Date();
46
+ this.correlationId = options?.correlationId;
47
+ }
48
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Domain Events — barrel export
3
+ *
4
+ * Re-exports the DomainEvent base class, its class type, and the
5
+ * DomainEventEmitter with its listener options.
6
+ */
7
+ export { DomainEventEmitter, type ListenerOptions as DomainListenerOptions } from './emitter.js';
8
+ export { DomainEvent, type DomainEventClass } from './event.js';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Domain Events — barrel export
3
+ *
4
+ * Re-exports the DomainEvent base class, its class type, and the
5
+ * DomainEventEmitter with its listener options.
6
+ */
7
+ export { DomainEventEmitter } from './emitter.js';
8
+ export { DomainEvent } from './event.js';
package/dist/index.d.ts CHANGED
@@ -37,6 +37,7 @@
37
37
  * @packageDocumentation
38
38
  */
39
39
  export { type ChannelAuthSigner, createChannelAuthSigner } from './auth.js';
40
+ export { DomainEvent, type DomainEventClass, DomainEventEmitter, type DomainListenerOptions, } from './domain/index.js';
40
41
  export { createSseDriver, DRIVER_NAME as SSE_DRIVER } from './drivers/sse.js';
41
42
  export { createWsDriver, DRIVER_NAME as WS_DRIVER } from './drivers/ws.js';
42
43
  export { createEventsManager, createManagerFromDriver, type EventsManager, events, } from './manager.js';
package/dist/index.js CHANGED
@@ -38,6 +38,8 @@
38
38
  */
39
39
  // Auth
40
40
  export { createChannelAuthSigner } from './auth.js';
41
+ // Domain Events
42
+ export { DomainEvent, DomainEventEmitter, } from './domain/index.js';
41
43
  // Drivers
42
44
  export { createSseDriver, DRIVER_NAME as SSE_DRIVER } from './drivers/sse.js';
43
45
  export { createWsDriver, DRIVER_NAME as WS_DRIVER } from './drivers/ws.js';
package/dist/manager.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * Wraps the underlying driver with a clean, Laravel-inspired interface.
6
6
  */
7
7
  import { createLogger } from '@veloxts/core';
8
+ import { DomainEventEmitter } from './domain/emitter.js';
8
9
  const log = createLogger('events');
9
10
  /**
10
11
  * Create an events manager with the specified driver.
@@ -55,6 +56,7 @@ export async function createEventsManager(options = {}) {
55
56
  * @returns Events manager instance
56
57
  */
57
58
  export function createManagerFromDriver(driver) {
59
+ const domainEmitter = new DomainEventEmitter();
58
60
  const manager = {
59
61
  driver,
60
62
  async broadcast(channel, event, data, except) {
@@ -104,6 +106,18 @@ export function createManagerFromDriver(driver) {
104
106
  async channels() {
105
107
  return driver.getChannels();
106
108
  },
109
+ // -------------------------------------------------------------------------
110
+ // Domain Event Methods
111
+ // -------------------------------------------------------------------------
112
+ async emit(event) {
113
+ await domainEmitter.emit(event);
114
+ },
115
+ on(eventClass, handler, options) {
116
+ domainEmitter.on(eventClass, handler, options);
117
+ },
118
+ off(eventClass, handler) {
119
+ domainEmitter.off(eventClass, handler);
120
+ },
107
121
  async close() {
108
122
  await driver.close();
109
123
  },
package/dist/types.d.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  * Uses discriminated unions for type-safe driver configuration.
6
6
  */
7
7
  import type { FastifyRequest } from 'fastify';
8
+ import type { ListenerOptions as DomainListenerOptions } from './domain/emitter.js';
9
+ import type { DomainEvent, DomainEventClass } from './domain/event.js';
8
10
  /**
9
11
  * Channel type determines access control.
10
12
  * - public: Anyone can subscribe
@@ -285,4 +287,45 @@ export interface EventsManager {
285
287
  * Close the manager and clean up resources.
286
288
  */
287
289
  close(): Promise<void>;
290
+ /**
291
+ * Emit a domain event to all registered listeners.
292
+ *
293
+ * Domain events are in-process events for business logic (e.g., OrderCreated,
294
+ * UserRegistered). They are distinct from broadcast events (WebSocket/SSE)
295
+ * which are client-facing.
296
+ *
297
+ * @example
298
+ * ```typescript
299
+ * await ctx.events.emit(new OrderCreatedEvent({
300
+ * orderId: '123',
301
+ * total: 99.99,
302
+ * }));
303
+ * ```
304
+ */
305
+ emit<TData extends Record<string, unknown>>(event: DomainEvent<TData>): Promise<void>;
306
+ /**
307
+ * Register a typed listener for a domain event class.
308
+ *
309
+ * The handler receives `event.data` — the typed payload — not the event
310
+ * instance itself.
311
+ *
312
+ * @param eventClass - The domain event class to listen for.
313
+ * @param handler - Called with the event's `data` payload on each emit.
314
+ * @param options - `{ sequential?, retryable? }`
315
+ *
316
+ * @example
317
+ * ```typescript
318
+ * events.on(OrderCreatedEvent, async (data) => {
319
+ * await sendConfirmationEmail(data.orderId);
320
+ * });
321
+ * ```
322
+ */
323
+ on<TData extends Record<string, unknown>>(eventClass: DomainEventClass<TData>, handler: (data: TData) => void | Promise<void>, options?: DomainListenerOptions): void;
324
+ /**
325
+ * Remove a previously registered domain event listener.
326
+ *
327
+ * @param eventClass - The domain event class the listener was registered for.
328
+ * @param handler - The exact handler reference passed to `on()`.
329
+ */
330
+ off<TData extends Record<string, unknown>>(eventClass: DomainEventClass<TData>, handler: (data: TData) => void | Promise<void>): void;
288
331
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veloxts/events",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Real-time event broadcasting for VeloxTS framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -30,7 +30,7 @@
30
30
  "superjson": "2.2.6",
31
31
  "ws": "8.19.0",
32
32
  "zod": "4.3.6",
33
- "@veloxts/core": "0.8.0"
33
+ "@veloxts/core": "0.8.2"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "fastify": "^5.7.4",
@@ -42,14 +42,14 @@
42
42
  }
43
43
  },
44
44
  "devDependencies": {
45
- "@types/node": "25.2.3",
45
+ "@types/node": "25.5.0",
46
46
  "@types/ws": "8.18.1",
47
- "@vitest/coverage-v8": "4.0.18",
48
- "fastify": "5.7.4",
49
- "ioredis": "5.9.3",
47
+ "@vitest/coverage-v8": "4.1.0",
48
+ "fastify": "5.8.2",
49
+ "ioredis": "5.10.0",
50
50
  "typescript": "5.9.3",
51
- "vitest": "4.0.18",
52
- "@veloxts/testing": "0.8.0"
51
+ "vitest": "4.1.0",
52
+ "@veloxts/testing": "0.8.2"
53
53
  },
54
54
  "publishConfig": {
55
55
  "access": "public"