@mantiq/events 0.0.1

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/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # @mantiq/events
2
+
3
+ Event dispatcher, broadcasting, and model observers for MantiqJS.
4
+
5
+ Part of [MantiqJS](https://github.com/abdullahkhan/mantiq) — a batteries-included TypeScript web framework for Bun.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ bun add @mantiq/events
11
+ ```
12
+
13
+ ## Documentation
14
+
15
+ See the [MantiqJS repository](https://github.com/abdullahkhan/mantiq) for full documentation.
16
+
17
+ ## License
18
+
19
+ MIT
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@mantiq/events",
3
+ "version": "0.0.1",
4
+ "description": "Event dispatcher, listeners, observers, and broadcasting for MantiqJS",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Abdullah Khan",
8
+ "homepage": "https://github.com/abdullahkhan/mantiq/tree/main/packages/events",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/abdullahkhan/mantiq.git",
12
+ "directory": "packages/events"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/abdullahkhan/mantiq/issues"
16
+ },
17
+ "keywords": [
18
+ "mantiq",
19
+ "mantiqjs",
20
+ "bun",
21
+ "typescript",
22
+ "framework",
23
+ "events"
24
+ ],
25
+ "engines": {
26
+ "bun": ">=1.1.0"
27
+ },
28
+ "main": "./src/index.ts",
29
+ "types": "./src/index.ts",
30
+ "exports": {
31
+ ".": {
32
+ "bun": "./src/index.ts",
33
+ "default": "./src/index.ts"
34
+ }
35
+ },
36
+ "files": [
37
+ "src/",
38
+ "package.json",
39
+ "README.md",
40
+ "LICENSE"
41
+ ],
42
+ "scripts": {
43
+ "build": "bun build ./src/index.ts --outdir ./dist --target bun",
44
+ "test": "bun test",
45
+ "typecheck": "tsc --noEmit",
46
+ "clean": "rm -rf dist"
47
+ },
48
+ "dependencies": {
49
+ "@mantiq/core": "workspace:*"
50
+ },
51
+ "devDependencies": {
52
+ "@mantiq/database": "workspace:*",
53
+ "@mantiq/auth": "workspace:*",
54
+ "bun-types": "latest",
55
+ "typescript": "^5.7.0"
56
+ },
57
+ "peerDependencies": {
58
+ "@mantiq/database": "workspace:*",
59
+ "@mantiq/auth": "workspace:*"
60
+ },
61
+ "peerDependenciesMeta": {
62
+ "@mantiq/database": {
63
+ "optional": true
64
+ },
65
+ "@mantiq/auth": {
66
+ "optional": true
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,151 @@
1
+ import type { Constructor, EventDispatcher, EventHandler } from '@mantiq/core'
2
+ import { Event, Listener } from '@mantiq/core'
3
+ import type { Subscriber } from './Subscriber.ts'
4
+ import type { ShouldBroadcast } from './contracts/ShouldBroadcast.ts'
5
+ import type { BroadcastManager } from './broadcast/BroadcastManager.ts'
6
+ import { ListenerError } from './errors/EventError.ts'
7
+
8
+ type RegisteredListener = Constructor<Listener> | EventHandler
9
+
10
+ /**
11
+ * Concrete implementation of the EventDispatcher contract.
12
+ *
13
+ * Supports class-based listeners, closure listeners, subscribers,
14
+ * wildcard listeners, and broadcasting integration.
15
+ */
16
+ export class Dispatcher implements EventDispatcher {
17
+ private readonly listeners = new Map<Constructor<Event>, RegisteredListener[]>()
18
+ private readonly wildcardListeners: EventHandler[] = []
19
+ private broadcaster: BroadcastManager | null = null
20
+
21
+ // ── Registration ─────────────────────────────────────────────────────
22
+
23
+ on(eventClass: Constructor<Event>, listener: RegisteredListener): void {
24
+ const existing = this.listeners.get(eventClass) ?? []
25
+ existing.push(listener)
26
+ this.listeners.set(eventClass, existing)
27
+ }
28
+
29
+ /**
30
+ * Register a listener that fires for every event.
31
+ */
32
+ onAny(handler: EventHandler): void {
33
+ this.wildcardListeners.push(handler)
34
+ }
35
+
36
+ /**
37
+ * Register a listener that fires only once, then auto-removes.
38
+ */
39
+ once(eventClass: Constructor<Event>, handler: EventHandler): void {
40
+ const wrapper: EventHandler = async (event) => {
41
+ this.off(eventClass, wrapper)
42
+ await handler(event)
43
+ }
44
+ this.on(eventClass, wrapper)
45
+ }
46
+
47
+ /**
48
+ * Remove a specific listener for an event class.
49
+ */
50
+ off(eventClass: Constructor<Event>, listener: RegisteredListener): void {
51
+ const existing = this.listeners.get(eventClass)
52
+ if (!existing) return
53
+ const index = existing.indexOf(listener)
54
+ if (index !== -1) existing.splice(index, 1)
55
+ }
56
+
57
+ /**
58
+ * Remove all listeners for an event class.
59
+ */
60
+ forget(eventClass: Constructor<Event>): void {
61
+ this.listeners.delete(eventClass)
62
+ }
63
+
64
+ /**
65
+ * Remove all listeners for all events.
66
+ */
67
+ flush(): void {
68
+ this.listeners.clear()
69
+ this.wildcardListeners.length = 0
70
+ }
71
+
72
+ /**
73
+ * Register a subscriber that can listen to multiple events.
74
+ */
75
+ subscribe(subscriber: Subscriber): void {
76
+ subscriber.subscribe(this)
77
+ }
78
+
79
+ /**
80
+ * Check whether an event class has any listeners registered.
81
+ */
82
+ hasListeners(eventClass: Constructor<Event>): boolean {
83
+ const registered = this.listeners.get(eventClass)
84
+ return (registered !== undefined && registered.length > 0) || this.wildcardListeners.length > 0
85
+ }
86
+
87
+ /**
88
+ * Get all listeners for an event class.
89
+ */
90
+ getListeners(eventClass: Constructor<Event>): RegisteredListener[] {
91
+ return [...(this.listeners.get(eventClass) ?? []), ...this.wildcardListeners]
92
+ }
93
+
94
+ // ── Broadcasting ─────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Set the broadcast manager for broadcasting ShouldBroadcast events.
98
+ */
99
+ setBroadcaster(broadcaster: BroadcastManager): void {
100
+ this.broadcaster = broadcaster
101
+ }
102
+
103
+ // ── Dispatch ─────────────────────────────────────────────────────────
104
+
105
+ async emit(event: Event): Promise<void> {
106
+ const eventClass = event.constructor as Constructor<Event>
107
+ const registered = this.listeners.get(eventClass) ?? []
108
+
109
+ // Fire class-based and closure listeners
110
+ for (const listener of registered) {
111
+ await this.callListener(listener, event)
112
+ }
113
+
114
+ // Fire wildcard listeners
115
+ for (const handler of this.wildcardListeners) {
116
+ await handler(event)
117
+ }
118
+
119
+ // Broadcast if the event implements ShouldBroadcast
120
+ if (this.broadcaster && isBroadcastable(event)) {
121
+ await this.broadcaster.broadcast(event as Event & ShouldBroadcast)
122
+ }
123
+ }
124
+
125
+ // ── Private ──────────────────────────────────────────────────────────
126
+
127
+ private async callListener(listener: RegisteredListener, event: Event): Promise<void> {
128
+ try {
129
+ if (isListenerClass(listener)) {
130
+ const instance = new listener()
131
+ await instance.handle(event)
132
+ } else {
133
+ await (listener as EventHandler)(event)
134
+ }
135
+ } catch (error) {
136
+ const eventName = event.constructor.name
137
+ const listenerName = isListenerClass(listener) ? listener.name : listener.name || '(anonymous)'
138
+ throw new ListenerError(eventName, listenerName, error instanceof Error ? error : new Error(String(error)))
139
+ }
140
+ }
141
+ }
142
+
143
+ // ── Type guards ────────────────────────────────────────────────────────────
144
+
145
+ function isListenerClass(listener: RegisteredListener): listener is Constructor<Listener> {
146
+ return typeof listener === 'function' && listener.prototype instanceof Listener
147
+ }
148
+
149
+ function isBroadcastable(event: Event): event is Event & ShouldBroadcast {
150
+ return typeof (event as any).broadcastOn === 'function'
151
+ }
@@ -0,0 +1,79 @@
1
+ import { ServiceProvider, ConfigRepository, CacheManager, RouterImpl } from '@mantiq/core'
2
+ import { Dispatcher } from './Dispatcher.ts'
3
+ import { BroadcastManager } from './broadcast/BroadcastManager.ts'
4
+ import type { BroadcastConfig } from './broadcast/BroadcastManager.ts'
5
+ import { EVENT_DISPATCHER } from './helpers/emit.ts'
6
+ import { BROADCAST_MANAGER } from './helpers/broadcast.ts'
7
+ import { fireModelEvent, bootModelEvents } from './model/HasEvents.ts'
8
+
9
+ const DEFAULT_BROADCAST_CONFIG: BroadcastConfig = {
10
+ default: 'null',
11
+ connections: {
12
+ null: { driver: 'null' },
13
+ },
14
+ }
15
+
16
+ /**
17
+ * Registers the event dispatcher and broadcast manager in the container.
18
+ *
19
+ * Also hooks events into other framework packages when they are installed:
20
+ * - @mantiq/database — Model lifecycle events, query events, migration events
21
+ * - @mantiq/auth — Authentication events (Login, Logout, Failed, etc.)
22
+ * - @mantiq/core — Cache events, RouteMatched event
23
+ */
24
+ export class EventServiceProvider extends ServiceProvider {
25
+ override register(): void {
26
+ // Register the event dispatcher as a singleton
27
+ this.app.singleton(Dispatcher, () => new Dispatcher())
28
+ this.app.alias(Dispatcher, EVENT_DISPATCHER)
29
+
30
+ // Register the broadcast manager as a singleton
31
+ this.app.singleton(BroadcastManager, (c) => {
32
+ let config = DEFAULT_BROADCAST_CONFIG
33
+ try {
34
+ config = c.make(ConfigRepository).get<BroadcastConfig>('broadcasting', DEFAULT_BROADCAST_CONFIG)
35
+ } catch {
36
+ // ConfigRepository not yet registered — use defaults
37
+ }
38
+ return new BroadcastManager(config)
39
+ })
40
+ this.app.alias(BroadcastManager, BROADCAST_MANAGER)
41
+ }
42
+
43
+ override boot(): void {
44
+ const dispatcher = this.app.make<Dispatcher>(EVENT_DISPATCHER)
45
+ const broadcaster = this.app.make<BroadcastManager>(BROADCAST_MANAGER)
46
+ dispatcher.setBroadcaster(broadcaster)
47
+
48
+ // ── Hook into @mantiq/database ─────────────────────────────────────
49
+ try {
50
+ const db = require('@mantiq/database') as any
51
+ if (db.Model) {
52
+ db.Model._fireEvent = fireModelEvent
53
+ bootModelEvents(db.Model)
54
+ }
55
+ if (db.SQLiteConnection) {
56
+ db.SQLiteConnection._dispatcher = dispatcher
57
+ }
58
+ if (db.Migrator) {
59
+ db.Migrator._dispatcher = dispatcher
60
+ }
61
+ } catch {
62
+ // @mantiq/database not installed
63
+ }
64
+
65
+ // ── Hook into @mantiq/auth ─────────────────────────────────────────
66
+ try {
67
+ const auth = require('@mantiq/auth') as any
68
+ if (auth.SessionGuard) {
69
+ auth.SessionGuard._dispatcher = dispatcher
70
+ }
71
+ } catch {
72
+ // @mantiq/auth not installed
73
+ }
74
+
75
+ // ── Hook into @mantiq/core (cache + routing) ────────────────────────
76
+ CacheManager._dispatcher = dispatcher
77
+ RouterImpl._dispatcher = dispatcher
78
+ }
79
+ }
@@ -0,0 +1,23 @@
1
+ import type { EventDispatcher } from '@mantiq/core'
2
+
3
+ /**
4
+ * Base class for event subscribers.
5
+ *
6
+ * A subscriber can listen to multiple events by registering them
7
+ * inside the `subscribe()` method.
8
+ *
9
+ * ```typescript
10
+ * class UserEventSubscriber extends Subscriber {
11
+ * override subscribe(events: EventDispatcher): void {
12
+ * events.on(UserRegistered, this.onRegistered.bind(this))
13
+ * events.on(UserDeleted, this.onDeleted.bind(this))
14
+ * }
15
+ *
16
+ * private async onRegistered(event: UserRegistered) { ... }
17
+ * private async onDeleted(event: UserDeleted) { ... }
18
+ * }
19
+ * ```
20
+ */
21
+ export abstract class Subscriber {
22
+ abstract subscribe(events: EventDispatcher): void
23
+ }
@@ -0,0 +1,103 @@
1
+ import type { Event } from '@mantiq/core'
2
+ import type { ShouldBroadcast } from '../contracts/ShouldBroadcast.ts'
3
+ import type { Broadcaster } from './Broadcaster.ts'
4
+ import { NullBroadcaster } from './NullBroadcaster.ts'
5
+ import { LogBroadcaster } from './LogBroadcaster.ts'
6
+ import { BroadcastError } from '../errors/EventError.ts'
7
+
8
+ export interface BroadcastConfig {
9
+ default: string
10
+ connections: Record<string, { driver: string; [key: string]: any }>
11
+ }
12
+
13
+ /**
14
+ * Manages broadcast drivers and dispatches broadcastable events.
15
+ *
16
+ * Supports multiple named connections with pluggable drivers.
17
+ */
18
+ export class BroadcastManager {
19
+ private readonly drivers = new Map<string, Broadcaster>()
20
+ private readonly customCreators = new Map<string, (config: any) => Broadcaster>()
21
+ private readonly defaultDriver: string
22
+
23
+ constructor(private readonly config: BroadcastConfig) {
24
+ this.defaultDriver = config.default ?? 'null'
25
+ }
26
+
27
+ // ── Driver resolution ────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Get a broadcaster by connection name.
31
+ */
32
+ connection(name?: string): Broadcaster {
33
+ const driverName = name ?? this.defaultDriver
34
+ if (!this.drivers.has(driverName)) {
35
+ this.drivers.set(driverName, this.resolve(driverName))
36
+ }
37
+ return this.drivers.get(driverName)!
38
+ }
39
+
40
+ /**
41
+ * Register a custom broadcast driver creator.
42
+ */
43
+ extend(name: string, creator: (config: any) => Broadcaster): void {
44
+ this.customCreators.set(name, creator)
45
+ }
46
+
47
+ // ── Broadcasting ─────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Broadcast a ShouldBroadcast event through the configured driver.
51
+ */
52
+ async broadcast(event: Event & ShouldBroadcast): Promise<void> {
53
+ const channels = normalizeChannels(event.broadcastOn())
54
+ const eventName = event.broadcastAs?.() ?? event.constructor.name
55
+ const data = event.broadcastWith?.() ?? extractPublicProperties(event)
56
+ const driver = this.connection()
57
+ await driver.broadcast(channels, eventName, data)
58
+ }
59
+
60
+ /**
61
+ * Broadcast directly to channels without an event class.
62
+ */
63
+ async send(channels: string | string[], event: string, data: Record<string, any>): Promise<void> {
64
+ const driver = this.connection()
65
+ await driver.broadcast(normalizeChannels(channels), event, data)
66
+ }
67
+
68
+ // ── Private ──────────────────────────────────────────────────────────
69
+
70
+ private resolve(name: string): Broadcaster {
71
+ const connectionConfig = this.config.connections?.[name]
72
+ const driverName = connectionConfig?.driver ?? name
73
+
74
+ // Check for custom creator
75
+ const creator = this.customCreators.get(driverName)
76
+ if (creator) return creator(connectionConfig ?? {})
77
+
78
+ // Built-in drivers
79
+ switch (driverName) {
80
+ case 'null':
81
+ return new NullBroadcaster()
82
+ case 'log':
83
+ return new LogBroadcaster()
84
+ default:
85
+ throw new BroadcastError(`Broadcast driver "${driverName}" is not supported.`, { driver: driverName })
86
+ }
87
+ }
88
+ }
89
+
90
+ // ── Helpers ──────────────────────────────────────────────────────────────────
91
+
92
+ function normalizeChannels(channels: string | string[]): string[] {
93
+ return Array.isArray(channels) ? channels : [channels]
94
+ }
95
+
96
+ function extractPublicProperties(event: any): Record<string, any> {
97
+ const data: Record<string, any> = {}
98
+ for (const key of Object.keys(event)) {
99
+ if (key === 'timestamp') continue
100
+ data[key] = event[key]
101
+ }
102
+ return data
103
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Contract for broadcast drivers.
3
+ *
4
+ * Each driver handles pushing event data to connected clients
5
+ * through a specific transport (WebSocket, SSE, log, etc.).
6
+ */
7
+ export interface Broadcaster {
8
+ /**
9
+ * Broadcast an event to the given channels.
10
+ */
11
+ broadcast(channels: string[], event: string, data: Record<string, any>): Promise<void>
12
+ }
@@ -0,0 +1,10 @@
1
+ import type { Broadcaster } from './Broadcaster.ts'
2
+
3
+ /**
4
+ * Logs broadcasts to the console. Useful for development and debugging.
5
+ */
6
+ export class LogBroadcaster implements Broadcaster {
7
+ async broadcast(channels: string[], event: string, data: Record<string, any>): Promise<void> {
8
+ console.log(`[broadcast] ${event} → ${channels.join(', ')}`, data)
9
+ }
10
+ }
@@ -0,0 +1,10 @@
1
+ import type { Broadcaster } from './Broadcaster.ts'
2
+
3
+ /**
4
+ * No-op broadcaster. Used when broadcasting is not configured.
5
+ */
6
+ export class NullBroadcaster implements Broadcaster {
7
+ async broadcast(_channels: string[], _event: string, _data: Record<string, any>): Promise<void> {
8
+ // Intentionally empty — events are silently discarded.
9
+ }
10
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Implement this interface on an Event class to mark it as broadcastable.
3
+ *
4
+ * When the event is dispatched via `emit()`, the broadcast manager
5
+ * will push the event data to all clients subscribed to the channels
6
+ * returned by `broadcastOn()`.
7
+ *
8
+ * ```typescript
9
+ * class OrderShipped extends Event implements ShouldBroadcast {
10
+ * constructor(public order: Order) { super() }
11
+ *
12
+ * broadcastOn() {
13
+ * return ['private:orders.' + this.order.id]
14
+ * }
15
+ *
16
+ * broadcastWith() {
17
+ * return { orderId: this.order.id, status: 'shipped' }
18
+ * }
19
+ * }
20
+ * ```
21
+ */
22
+ export interface ShouldBroadcast {
23
+ /**
24
+ * The channel(s) the event should broadcast on.
25
+ */
26
+ broadcastOn(): string | string[]
27
+
28
+ /**
29
+ * Custom event name for the broadcast. Defaults to the class name.
30
+ */
31
+ broadcastAs?(): string
32
+
33
+ /**
34
+ * Custom payload for the broadcast. Defaults to all public properties.
35
+ */
36
+ broadcastWith?(): Record<string, any>
37
+ }
38
+
39
+ /**
40
+ * Implement this interface to broadcast synchronously (skip the queue).
41
+ * Identical to ShouldBroadcast but signals the system to bypass queuing.
42
+ */
43
+ export interface ShouldBroadcastNow extends ShouldBroadcast {}
@@ -0,0 +1,29 @@
1
+ import { MantiqError } from '@mantiq/core'
2
+
3
+ export class EventError extends MantiqError {
4
+ constructor(message: string, context?: Record<string, any>) {
5
+ super(message, context)
6
+ this.name = 'EventError'
7
+ }
8
+ }
9
+
10
+ export class ListenerError extends MantiqError {
11
+ constructor(
12
+ public readonly eventName: string,
13
+ public readonly listenerName: string,
14
+ public override readonly cause?: Error,
15
+ ) {
16
+ super(`Listener "${listenerName}" failed while handling "${eventName}": ${cause?.message ?? 'unknown error'}`, {
17
+ event: eventName,
18
+ listener: listenerName,
19
+ })
20
+ this.name = 'ListenerError'
21
+ }
22
+ }
23
+
24
+ export class BroadcastError extends MantiqError {
25
+ constructor(message: string, context?: Record<string, any>) {
26
+ super(message, context)
27
+ this.name = 'BroadcastError'
28
+ }
29
+ }
@@ -0,0 +1,24 @@
1
+ import { Application } from '@mantiq/core'
2
+ import type { BroadcastManager } from '../broadcast/BroadcastManager.ts'
3
+
4
+ export const BROADCAST_MANAGER = Symbol('BroadcastManager')
5
+
6
+ /**
7
+ * Broadcast data directly to channels without an event class.
8
+ *
9
+ * ```typescript
10
+ * import { broadcast } from '@mantiq/events'
11
+ *
12
+ * await broadcast('private:orders.' + order.id, 'status-updated', {
13
+ * status: 'shipped',
14
+ * })
15
+ * ```
16
+ */
17
+ export async function broadcast(
18
+ channels: string | string[],
19
+ event: string,
20
+ data: Record<string, any>,
21
+ ): Promise<void> {
22
+ const manager = Application.getInstance().make<BroadcastManager>(BROADCAST_MANAGER)
23
+ return manager.send(channels, event, data)
24
+ }
@@ -0,0 +1,32 @@
1
+ import { Application, Event } from '@mantiq/core'
2
+ import type { Dispatcher } from '../Dispatcher.ts'
3
+
4
+ export const EVENT_DISPATCHER = Symbol('EventDispatcher')
5
+
6
+ /**
7
+ * Dispatch an event through the global event dispatcher.
8
+ *
9
+ * ```typescript
10
+ * import { emit } from '@mantiq/events'
11
+ *
12
+ * await emit(new UserRegistered(user))
13
+ * ```
14
+ */
15
+ export async function emit(event: Event): Promise<void> {
16
+ const dispatcher = Application.getInstance().make<Dispatcher>(EVENT_DISPATCHER)
17
+ return dispatcher.emit(event)
18
+ }
19
+
20
+ /**
21
+ * Get the event dispatcher instance.
22
+ *
23
+ * ```typescript
24
+ * import { events } from '@mantiq/events'
25
+ *
26
+ * const dispatcher = events()
27
+ * dispatcher.on(UserRegistered, SendWelcomeEmail)
28
+ * ```
29
+ */
30
+ export function events(): Dispatcher {
31
+ return Application.getInstance().make<Dispatcher>(EVENT_DISPATCHER)
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,48 @@
1
+ // @mantiq/events — public API exports
2
+
3
+ // ── Re-exports from @mantiq/core (convenience) ────────────────────────────
4
+ export { Event, Listener } from '@mantiq/core'
5
+ export type { EventDispatcher, EventHandler } from '@mantiq/core'
6
+
7
+ // ── Dispatcher ────────────────────────────────────────────────────────────
8
+ export { Dispatcher } from './Dispatcher.ts'
9
+
10
+ // ── Subscriber ────────────────────────────────────────────────────────────
11
+ export { Subscriber } from './Subscriber.ts'
12
+
13
+ // ── Contracts ─────────────────────────────────────────────────────────────
14
+ export type { ShouldBroadcast, ShouldBroadcastNow } from './contracts/ShouldBroadcast.ts'
15
+ export type { Broadcaster } from './broadcast/Broadcaster.ts'
16
+
17
+ // ── Broadcasting ──────────────────────────────────────────────────────────
18
+ export { BroadcastManager } from './broadcast/BroadcastManager.ts'
19
+ export type { BroadcastConfig } from './broadcast/BroadcastManager.ts'
20
+ export { NullBroadcaster } from './broadcast/NullBroadcaster.ts'
21
+ export { LogBroadcaster } from './broadcast/LogBroadcaster.ts'
22
+
23
+ // ── Model Events ──────────────────────────────────────────────────────────
24
+ export type { ModelObserver } from './model/Observer.ts'
25
+ export type { ModelEventName } from './model/ModelEvents.ts'
26
+ export { ModelEventDispatcher } from './model/ModelEventDispatcher.ts'
27
+ export {
28
+ fireModelEvent,
29
+ observe,
30
+ onModelEvent,
31
+ flushModelEvents,
32
+ bootModelEvents,
33
+ getModelDispatcher,
34
+ } from './model/HasEvents.ts'
35
+
36
+ // ── Errors ────────────────────────────────────────────────────────────────
37
+ export { EventError, ListenerError, BroadcastError } from './errors/EventError.ts'
38
+
39
+ // ── Service Provider ──────────────────────────────────────────────────────
40
+ export { EventServiceProvider } from './EventServiceProvider.ts'
41
+
42
+ // ── Helpers ───────────────────────────────────────────────────────────────
43
+ export { emit, events, EVENT_DISPATCHER } from './helpers/emit.ts'
44
+ export { broadcast, BROADCAST_MANAGER } from './helpers/broadcast.ts'
45
+
46
+ // ── Testing ───────────────────────────────────────────────────────────────
47
+ export { EventFake } from './testing/EventFake.ts'
48
+ export { BroadcastFake } from './testing/BroadcastFake.ts'
@@ -0,0 +1,107 @@
1
+ import { ModelEventDispatcher } from './ModelEventDispatcher.ts'
2
+ import type { ModelObserver } from './Observer.ts'
3
+ import type { ModelEventName } from './ModelEvents.ts'
4
+
5
+ type ModelEventCallback = (model: any) => boolean | void | Promise<boolean | void>
6
+
7
+ /**
8
+ * Adds model event support to a Model class.
9
+ *
10
+ * Call `bootModelEvents(ModelClass)` to set up event methods on a model constructor.
11
+ * This adds `observe()`, `creating()`, `created()`, etc. as static methods,
12
+ * and provides `fireModelEvent()` as an instance method.
13
+ *
14
+ * This is designed to be called from the EventServiceProvider's boot()
15
+ * to augment the Model class from `@mantiq/database`.
16
+ */
17
+
18
+ const dispatchers = new WeakMap<Function, ModelEventDispatcher>()
19
+
20
+ /**
21
+ * Get or create the event dispatcher for a model constructor.
22
+ */
23
+ export function getModelDispatcher(modelClass: Function): ModelEventDispatcher {
24
+ if (!dispatchers.has(modelClass)) {
25
+ dispatchers.set(modelClass, new ModelEventDispatcher())
26
+ }
27
+ return dispatchers.get(modelClass)!
28
+ }
29
+
30
+ /**
31
+ * Fire a model event. Returns `false` if a cancellable event was cancelled.
32
+ */
33
+ export async function fireModelEvent(model: any, event: ModelEventName): Promise<boolean> {
34
+ const ctor = model.constructor
35
+ const dispatcher = dispatchers.get(ctor)
36
+ if (!dispatcher) return true
37
+ return dispatcher.fire(event, model)
38
+ }
39
+
40
+ /**
41
+ * Register an observer for a model class.
42
+ */
43
+ export function observe(modelClass: Function, observer: ModelObserver | (new () => ModelObserver)): void {
44
+ const instance = typeof observer === 'function' ? new observer() : observer
45
+ getModelDispatcher(modelClass).addObserver(instance)
46
+ }
47
+
48
+ /**
49
+ * Register a callback for a model event.
50
+ */
51
+ export function onModelEvent(modelClass: Function, event: ModelEventName, callback: ModelEventCallback): void {
52
+ getModelDispatcher(modelClass).addCallback(event, callback)
53
+ }
54
+
55
+ /**
56
+ * Flush all event listeners for a model class.
57
+ */
58
+ export function flushModelEvents(modelClass: Function): void {
59
+ const dispatcher = dispatchers.get(modelClass)
60
+ if (dispatcher) dispatcher.flush()
61
+ }
62
+
63
+ /**
64
+ * Boot model event support on a Model class by adding static methods.
65
+ *
66
+ * After calling this, the model class gains:
67
+ * - `Model.observe(ObserverClass)`
68
+ * - `Model.creating(callback)`
69
+ * - `Model.created(callback)`
70
+ * - `Model.updating(callback)` / `Model.updated(callback)`
71
+ * - `Model.saving(callback)` / `Model.saved(callback)`
72
+ * - `Model.deleting(callback)` / `Model.deleted(callback)`
73
+ * - `Model.restoring(callback)` / `Model.restored(callback)`
74
+ * - `Model.forceDeleting(callback)` / `Model.forceDeleted(callback)`
75
+ * - `Model.trashed(callback)`
76
+ * - `Model.retrieved(callback)`
77
+ * - `Model.flushEventListeners()`
78
+ */
79
+ export function bootModelEvents(ModelClass: any): void {
80
+ // observe()
81
+ ModelClass.observe = function (observer: ModelObserver | (new () => ModelObserver)): void {
82
+ observe(this, observer)
83
+ }
84
+
85
+ // flushEventListeners()
86
+ ModelClass.flushEventListeners = function (): void {
87
+ flushModelEvents(this)
88
+ }
89
+
90
+ // Event shortcut methods
91
+ const events: ModelEventName[] = [
92
+ 'retrieved',
93
+ 'creating', 'created',
94
+ 'updating', 'updated',
95
+ 'saving', 'saved',
96
+ 'deleting', 'deleted',
97
+ 'forceDeleting', 'forceDeleted',
98
+ 'restoring', 'restored',
99
+ 'trashed',
100
+ ]
101
+
102
+ for (const event of events) {
103
+ ModelClass[event] = function (callback: ModelEventCallback): void {
104
+ onModelEvent(this, event, callback)
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,98 @@
1
+ import type { ModelObserver } from './Observer.ts'
2
+ import type { ModelEventName } from './ModelEvents.ts'
3
+ import { isCancellable } from './ModelEvents.ts'
4
+
5
+ type ModelEventCallback = (model: any) => boolean | void | Promise<boolean | void>
6
+
7
+ /**
8
+ * Per-model event dispatcher.
9
+ *
10
+ * Manages observers and callbacks for a single model class.
11
+ * Each model class gets its own instance stored on the model constructor.
12
+ *
13
+ * ```typescript
14
+ * // Register an observer
15
+ * User.observe(UserObserver)
16
+ *
17
+ * // Register an inline callback
18
+ * User.creating((user) => {
19
+ * user.set('slug', slugify(user.get('name')))
20
+ * })
21
+ * ```
22
+ */
23
+ export class ModelEventDispatcher {
24
+ private readonly observers: ModelObserver[] = []
25
+ private readonly callbacks = new Map<ModelEventName, ModelEventCallback[]>()
26
+
27
+ // ── Registration ─────────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Register an observer class instance.
31
+ */
32
+ addObserver(observer: ModelObserver): void {
33
+ this.observers.push(observer)
34
+ }
35
+
36
+ /**
37
+ * Register a callback for a specific event.
38
+ */
39
+ addCallback(event: ModelEventName, callback: ModelEventCallback): void {
40
+ const existing = this.callbacks.get(event) ?? []
41
+ existing.push(callback)
42
+ this.callbacks.set(event, existing)
43
+ }
44
+
45
+ /**
46
+ * Check if any observers or callbacks are registered for an event.
47
+ */
48
+ hasListeners(event: ModelEventName): boolean {
49
+ const hasCallbacks = (this.callbacks.get(event)?.length ?? 0) > 0
50
+ const hasObservers = this.observers.some((o) => typeof (o as any)[event] === 'function')
51
+ return hasCallbacks || hasObservers
52
+ }
53
+
54
+ // ── Dispatching ──────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Fire a model event. For cancellable events, returns `false` if
58
+ * any listener returns `false` (which cancels the operation).
59
+ */
60
+ async fire(event: ModelEventName, model: any): Promise<boolean> {
61
+ const cancellable = isCancellable(event)
62
+
63
+ // Fire observer methods
64
+ for (const observer of this.observers) {
65
+ const method = (observer as any)[event]
66
+ if (typeof method === 'function') {
67
+ const result = await method.call(observer, model)
68
+ if (cancellable && result === false) return false
69
+ }
70
+ }
71
+
72
+ // Fire registered callbacks
73
+ const callbacks = this.callbacks.get(event) ?? []
74
+ for (const callback of callbacks) {
75
+ const result = await callback(model)
76
+ if (cancellable && result === false) return false
77
+ }
78
+
79
+ return true
80
+ }
81
+
82
+ // ── Cleanup ──────────────────────────────────────────────────────────
83
+
84
+ /**
85
+ * Remove all observers and callbacks.
86
+ */
87
+ flush(): void {
88
+ this.observers.length = 0
89
+ this.callbacks.clear()
90
+ }
91
+
92
+ /**
93
+ * Remove callbacks for a specific event.
94
+ */
95
+ forgetEvent(event: ModelEventName): void {
96
+ this.callbacks.delete(event)
97
+ }
98
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * All supported model lifecycle event names.
3
+ *
4
+ * "Before" events (creating, updating, saving, deleting, restoring)
5
+ * can return `false` from a listener to cancel the operation.
6
+ *
7
+ * "After" events (created, updated, saved, deleted, restored, retrieved)
8
+ * are informational and cannot cancel anything.
9
+ */
10
+ export type ModelEventName =
11
+ | 'retrieved'
12
+ | 'creating'
13
+ | 'created'
14
+ | 'updating'
15
+ | 'updated'
16
+ | 'saving'
17
+ | 'saved'
18
+ | 'deleting'
19
+ | 'deleted'
20
+ | 'forceDeleting'
21
+ | 'forceDeleted'
22
+ | 'restoring'
23
+ | 'restored'
24
+ | 'trashed'
25
+
26
+ /** "Before" events that can be cancelled by returning false. */
27
+ export const CANCELLABLE_EVENTS: ModelEventName[] = [
28
+ 'creating',
29
+ 'updating',
30
+ 'saving',
31
+ 'deleting',
32
+ 'forceDeleting',
33
+ 'restoring',
34
+ ]
35
+
36
+ export function isCancellable(event: ModelEventName): boolean {
37
+ return CANCELLABLE_EVENTS.includes(event)
38
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * A model observer handles lifecycle events for a model.
3
+ *
4
+ * Implement any subset of these methods. Methods matching "before" events
5
+ * (creating, updating, saving, deleting, restoring, forceDeleting) can
6
+ * return `false` to cancel the operation.
7
+ *
8
+ * ```typescript
9
+ * class UserObserver implements ModelObserver {
10
+ * creating(model: User): boolean | void {
11
+ * // Normalize email before saving
12
+ * model.set('email', model.get('email').toLowerCase())
13
+ * }
14
+ *
15
+ * created(model: User): void {
16
+ * // Send welcome email
17
+ * }
18
+ *
19
+ * deleting(model: User): boolean | void {
20
+ * // Prevent deletion of admin users
21
+ * if (model.get('role') === 'admin') return false
22
+ * }
23
+ * }
24
+ * ```
25
+ */
26
+ export interface ModelObserver {
27
+ retrieved?(model: any): void | Promise<void>
28
+ creating?(model: any): boolean | void | Promise<boolean | void>
29
+ created?(model: any): void | Promise<void>
30
+ updating?(model: any): boolean | void | Promise<boolean | void>
31
+ updated?(model: any): void | Promise<void>
32
+ saving?(model: any): boolean | void | Promise<boolean | void>
33
+ saved?(model: any): void | Promise<void>
34
+ deleting?(model: any): boolean | void | Promise<boolean | void>
35
+ deleted?(model: any): void | Promise<void>
36
+ forceDeleting?(model: any): boolean | void | Promise<boolean | void>
37
+ forceDeleted?(model: any): void | Promise<void>
38
+ restoring?(model: any): boolean | void | Promise<boolean | void>
39
+ restored?(model: any): void | Promise<void>
40
+ trashed?(model: any): void | Promise<void>
41
+ }
@@ -0,0 +1,107 @@
1
+ import type { Event } from '@mantiq/core'
2
+ import type { ShouldBroadcast } from '../contracts/ShouldBroadcast.ts'
3
+ import type { BroadcastManager } from '../broadcast/BroadcastManager.ts'
4
+
5
+ interface BroadcastRecord {
6
+ channels: string[]
7
+ event: string
8
+ data: Record<string, any>
9
+ }
10
+
11
+ /**
12
+ * A fake broadcast manager for testing.
13
+ *
14
+ * Records all broadcasts without actually sending them.
15
+ * Provides assertion methods for verifying broadcasts.
16
+ *
17
+ * ```typescript
18
+ * const fake = new BroadcastFake()
19
+ * dispatcher.setBroadcaster(fake as any)
20
+ *
21
+ * await emit(new OrderShipped(order))
22
+ *
23
+ * fake.assertBroadcast('OrderShipped')
24
+ * fake.assertBroadcastOn('private:orders.1', 'OrderShipped')
25
+ * ```
26
+ */
27
+ export class BroadcastFake {
28
+ private readonly broadcasts: BroadcastRecord[] = []
29
+
30
+ // ── BroadcastManager-compatible methods ──────────────────────────────
31
+
32
+ async broadcast(event: Event & ShouldBroadcast): Promise<void> {
33
+ const channels = normalizeChannels(event.broadcastOn())
34
+ const eventName = event.broadcastAs?.() ?? event.constructor.name
35
+ const data = event.broadcastWith?.() ?? extractPublicProperties(event)
36
+ this.broadcasts.push({ channels, event: eventName, data })
37
+ }
38
+
39
+ async send(channels: string | string[], event: string, data: Record<string, any>): Promise<void> {
40
+ this.broadcasts.push({ channels: normalizeChannels(channels), event, data })
41
+ }
42
+
43
+ // ── Assertions ───────────────────────────────────────────────────────
44
+
45
+ assertBroadcast(eventName: string, predicate?: (data: Record<string, any>) => boolean): void {
46
+ const matched = this.broadcasts.filter(
47
+ (b) => b.event === eventName && (!predicate || predicate(b.data)),
48
+ )
49
+ if (matched.length === 0) {
50
+ throw new Error(`Expected [${eventName}] to be broadcast, but it was not.`)
51
+ }
52
+ }
53
+
54
+ assertBroadcastOn(channel: string, eventName: string): void {
55
+ const matched = this.broadcasts.filter(
56
+ (b) => b.event === eventName && b.channels.includes(channel),
57
+ )
58
+ if (matched.length === 0) {
59
+ throw new Error(
60
+ `Expected [${eventName}] to be broadcast on channel "${channel}", but it was not.`,
61
+ )
62
+ }
63
+ }
64
+
65
+ assertNotBroadcast(eventName: string): void {
66
+ const matched = this.broadcasts.filter((b) => b.event === eventName)
67
+ if (matched.length > 0) {
68
+ throw new Error(`Unexpected [${eventName}] was broadcast.`)
69
+ }
70
+ }
71
+
72
+ assertNothingBroadcast(): void {
73
+ if (this.broadcasts.length > 0) {
74
+ const names = [...new Set(this.broadcasts.map((b) => b.event))]
75
+ throw new Error(`Expected no broadcasts, but the following were broadcast: ${names.join(', ')}`)
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Get all recorded broadcasts.
81
+ */
82
+ all(): BroadcastRecord[] {
83
+ return [...this.broadcasts]
84
+ }
85
+
86
+ /**
87
+ * Clear the recorded broadcasts.
88
+ */
89
+ reset(): void {
90
+ this.broadcasts.length = 0
91
+ }
92
+ }
93
+
94
+ // ── Helpers ──────────────────────────────────────────────────────────────────
95
+
96
+ function normalizeChannels(channels: string | string[]): string[] {
97
+ return Array.isArray(channels) ? channels : [channels]
98
+ }
99
+
100
+ function extractPublicProperties(event: any): Record<string, any> {
101
+ const data: Record<string, any> = {}
102
+ for (const key of Object.keys(event)) {
103
+ if (key === 'timestamp') continue
104
+ data[key] = event[key]
105
+ }
106
+ return data
107
+ }
@@ -0,0 +1,158 @@
1
+ import type { Constructor, EventDispatcher, EventHandler } from '@mantiq/core'
2
+ import { Event, Listener } from '@mantiq/core'
3
+
4
+ type RegisteredListener = Constructor<Listener> | EventHandler
5
+
6
+ /**
7
+ * A fake event dispatcher for testing.
8
+ *
9
+ * Replaces the real dispatcher to record all emitted events
10
+ * without executing any listeners. Provides assertion methods
11
+ * for verifying that events were (or were not) dispatched.
12
+ *
13
+ * ```typescript
14
+ * const fake = EventFake.create()
15
+ *
16
+ * await someService.doWork()
17
+ *
18
+ * fake.assertEmitted(UserCreated)
19
+ * fake.assertEmittedTimes(UserCreated, 1)
20
+ * fake.assertNotEmitted(UserDeleted)
21
+ * ```
22
+ */
23
+ export class EventFake implements EventDispatcher {
24
+ private readonly emitted: Event[] = []
25
+ private readonly eventsToFake: Set<Constructor<Event>> | null
26
+ private readonly original: EventDispatcher | null
27
+
28
+ constructor(original?: EventDispatcher, eventsToFake?: Constructor<Event>[]) {
29
+ this.original = original ?? null
30
+ this.eventsToFake = eventsToFake ? new Set(eventsToFake) : null
31
+ }
32
+
33
+ /**
34
+ * Create a new EventFake, optionally wrapping an existing dispatcher.
35
+ * If `eventsToFake` is provided, only those events are faked — all
36
+ * others pass through to the original dispatcher.
37
+ */
38
+ static create(original?: EventDispatcher, eventsToFake?: Constructor<Event>[]): EventFake {
39
+ return new EventFake(original, eventsToFake)
40
+ }
41
+
42
+ // ── EventDispatcher interface ────────────────────────────────────────
43
+
44
+ on(eventClass: Constructor<Event>, listener: RegisteredListener): void {
45
+ // When faking, listener registration is ignored for faked events.
46
+ if (this.shouldFake(eventClass)) return
47
+ this.original?.on(eventClass, listener)
48
+ }
49
+
50
+ forget(eventClass: Constructor<Event>): void {
51
+ this.original?.forget(eventClass)
52
+ }
53
+
54
+ async emit(event: Event): Promise<void> {
55
+ const eventClass = event.constructor as Constructor<Event>
56
+
57
+ if (this.shouldFake(eventClass)) {
58
+ this.emitted.push(event)
59
+ return
60
+ }
61
+
62
+ // Pass through to original if not faking this event
63
+ if (this.original) {
64
+ await this.original.emit(event)
65
+ }
66
+ }
67
+
68
+ // ── Assertions ───────────────────────────────────────────────────────
69
+
70
+ /**
71
+ * Assert that an event was emitted at least once.
72
+ * Optionally pass a predicate to check specific event data.
73
+ */
74
+ assertEmitted<T extends Event>(
75
+ eventClass: Constructor<T>,
76
+ predicate?: (event: T) => boolean,
77
+ ): void {
78
+ const matched = this.getEmitted(eventClass).filter((e) => !predicate || predicate(e))
79
+ if (matched.length === 0) {
80
+ const msg = predicate
81
+ ? `Expected [${eventClass.name}] to be emitted matching the given predicate, but it was not.`
82
+ : `Expected [${eventClass.name}] to be emitted, but it was not.`
83
+ throw new Error(msg)
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Assert that an event was emitted exactly N times.
89
+ */
90
+ assertEmittedTimes<T extends Event>(eventClass: Constructor<T>, count: number): void {
91
+ const actual = this.getEmitted(eventClass).length
92
+ if (actual !== count) {
93
+ throw new Error(
94
+ `Expected [${eventClass.name}] to be emitted ${count} time(s), but it was emitted ${actual} time(s).`,
95
+ )
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Assert that an event was NOT emitted.
101
+ */
102
+ assertNotEmitted<T extends Event>(
103
+ eventClass: Constructor<T>,
104
+ predicate?: (event: T) => boolean,
105
+ ): void {
106
+ const matched = this.getEmitted(eventClass).filter((e) => !predicate || predicate(e))
107
+ if (matched.length > 0) {
108
+ throw new Error(`Unexpected [${eventClass.name}] was emitted.`)
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Assert that no events were emitted at all.
114
+ */
115
+ assertNothingEmitted(): void {
116
+ if (this.emitted.length > 0) {
117
+ const names = [...new Set(this.emitted.map((e) => e.constructor.name))]
118
+ throw new Error(`Expected no events to be emitted, but the following were: ${names.join(', ')}`)
119
+ }
120
+ }
121
+
122
+ // ── Querying ─────────────────────────────────────────────────────────
123
+
124
+ /**
125
+ * Get all emitted instances of a given event class.
126
+ */
127
+ getEmitted<T extends Event>(eventClass: Constructor<T>): T[] {
128
+ return this.emitted.filter((e) => e instanceof eventClass) as T[]
129
+ }
130
+
131
+ /**
132
+ * Check if an event was emitted.
133
+ */
134
+ hasEmitted<T extends Event>(eventClass: Constructor<T>): boolean {
135
+ return this.getEmitted(eventClass).length > 0
136
+ }
137
+
138
+ /**
139
+ * Get all emitted events.
140
+ */
141
+ all(): Event[] {
142
+ return [...this.emitted]
143
+ }
144
+
145
+ /**
146
+ * Clear the recorded events.
147
+ */
148
+ reset(): void {
149
+ this.emitted.length = 0
150
+ }
151
+
152
+ // ── Private ──────────────────────────────────────────────────────────
153
+
154
+ private shouldFake(eventClass: Constructor<Event>): boolean {
155
+ if (this.eventsToFake === null) return true
156
+ return this.eventsToFake.has(eventClass)
157
+ }
158
+ }