@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 +19 -0
- package/package.json +69 -0
- package/src/Dispatcher.ts +151 -0
- package/src/EventServiceProvider.ts +79 -0
- package/src/Subscriber.ts +23 -0
- package/src/broadcast/BroadcastManager.ts +103 -0
- package/src/broadcast/Broadcaster.ts +12 -0
- package/src/broadcast/LogBroadcaster.ts +10 -0
- package/src/broadcast/NullBroadcaster.ts +10 -0
- package/src/contracts/ShouldBroadcast.ts +43 -0
- package/src/errors/EventError.ts +29 -0
- package/src/helpers/broadcast.ts +24 -0
- package/src/helpers/emit.ts +32 -0
- package/src/index.ts +48 -0
- package/src/model/HasEvents.ts +107 -0
- package/src/model/ModelEventDispatcher.ts +98 -0
- package/src/model/ModelEvents.ts +38 -0
- package/src/model/Observer.ts +41 -0
- package/src/testing/BroadcastFake.ts +107 -0
- package/src/testing/EventFake.ts +158 -0
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
|
+
}
|