@maestro-js/agent-skills 1.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/choosing-an-npm-package/SKILL.md +72 -0
- package/dist/maestro-cli/SKILL.md +257 -0
- package/dist/maestro-packages/SKILL.md +42 -0
- package/dist/maestro-packages/broadcast.md +306 -0
- package/dist/maestro-packages/cache-control.md +337 -0
- package/dist/maestro-packages/cache.md +397 -0
- package/dist/maestro-packages/config.md +203 -0
- package/dist/maestro-packages/context.md +365 -0
- package/dist/maestro-packages/crypt.md +253 -0
- package/dist/maestro-packages/db.md +391 -0
- package/dist/maestro-packages/events.md +295 -0
- package/dist/maestro-packages/exceptions.md +231 -0
- package/dist/maestro-packages/form.md +391 -0
- package/dist/maestro-packages/hash.md +248 -0
- package/dist/maestro-packages/helpers.md +249 -0
- package/dist/maestro-packages/log.md +321 -0
- package/dist/maestro-packages/mail.md +365 -0
- package/dist/maestro-packages/metrics.md +236 -0
- package/dist/maestro-packages/queue.md +284 -0
- package/dist/maestro-packages/rate-limiting.md +206 -0
- package/dist/maestro-packages/react-router-file-routes.md +337 -0
- package/dist/maestro-packages/recurring-jobs.md +353 -0
- package/dist/maestro-packages/sql.md +316 -0
- package/dist/maestro-packages/tracing.md +240 -0
- package/package.json +33 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: db
|
|
3
|
+
description: "Use when working with @maestro-js/db, the database access package for Maestro. Covers MySQL driver setup, parameterized SQL queries (select, insert, update, delete, scalar, statement), transactions with automatic commit/rollback, streaming results via selectIterable, connection pooling, query formatting, and the provider pattern for multiple database instances. Maestro uses raw parameterized SQL with no ORM. Depends on service-registry and log. Used by queue for message storage. Works with the sql package for query builders."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @maestro-js/db
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
The `@maestro-js/db` package provides typed database access through the Maestro provider pattern. It wraps `mysql2` connection pools with parameterized query methods, automatic transactions, streaming result sets with backpressure, and structured logging. All queries use raw parameterized SQL -- there is no ORM.
|
|
11
|
+
|
|
12
|
+
## Quick Setup
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { Db } from '@maestro-js/db'
|
|
16
|
+
import { Log } from '@maestro-js/log'
|
|
17
|
+
|
|
18
|
+
Db.Provider.register('default', Db.Provider.create({
|
|
19
|
+
driver: Db.drivers.mysql({
|
|
20
|
+
host: process.env.DB_HOST!,
|
|
21
|
+
user: process.env.DB_USER!,
|
|
22
|
+
password: process.env.DB_PASSWORD!,
|
|
23
|
+
database: process.env.DB_DATABASE!,
|
|
24
|
+
port: Number(process.env.DB_PORT ?? 3306)
|
|
25
|
+
})
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
// Query immediately
|
|
29
|
+
const users = await Db.select<User>('SELECT * FROM users WHERE active = ?', [true])
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Driver: MySQL (mysql2)
|
|
33
|
+
|
|
34
|
+
The only built-in driver. Created via `Db.drivers.mysql(config)`.
|
|
35
|
+
|
|
36
|
+
### Configuration
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
Db.drivers.mysql({
|
|
40
|
+
// Required
|
|
41
|
+
host: 'localhost',
|
|
42
|
+
user: 'root',
|
|
43
|
+
password: '', // empty string is allowed, but the field is required
|
|
44
|
+
database: 'myapp',
|
|
45
|
+
port: 3306,
|
|
46
|
+
|
|
47
|
+
// Optional
|
|
48
|
+
connectionLimit: 10, // max pool connections (default: 10)
|
|
49
|
+
debug: false,
|
|
50
|
+
multipleStatements: false,
|
|
51
|
+
ssl: undefined, // string or tls.SecureContextOptions
|
|
52
|
+
|
|
53
|
+
// Optional loggers (default: Log.create())
|
|
54
|
+
connectionLogger: Log.create(), // logs connection lifecycle events
|
|
55
|
+
queryLogger: Log.create() // logs query start/complete/fail and transaction events
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Type Casting
|
|
60
|
+
|
|
61
|
+
The MySQL driver automatically casts:
|
|
62
|
+
- `DATE` columns to ISO date strings
|
|
63
|
+
- `TIME` columns to `iso-fns` time objects
|
|
64
|
+
- `DATETIME` columns to ISO 8601 strings (UTC)
|
|
65
|
+
- `TINYINT(1)` columns to booleans (`true`/`false`), with `null` preserved for nullable columns
|
|
66
|
+
- JSON columns returned as strings (`jsonStrings: true`)
|
|
67
|
+
- Decimal columns returned as numbers (`decimalNumbers: true`)
|
|
68
|
+
|
|
69
|
+
### Connection Behavior
|
|
70
|
+
|
|
71
|
+
- Pool uses `utf8mb4_0900_ai_ci` charset (MySQL 8.0+ default)
|
|
72
|
+
- Connect timeout: 60 minutes
|
|
73
|
+
- Connections are acquired with retry (up to 10 attempts, 1s delay)
|
|
74
|
+
- Deadlock and lock-timeout errors are retried automatically (up to 3 attempts) for non-transaction queries
|
|
75
|
+
|
|
76
|
+
## API Reference
|
|
77
|
+
|
|
78
|
+
### select
|
|
79
|
+
|
|
80
|
+
Execute a SELECT query and return typed results as an array.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
select<T = Record<string, unknown>>(query: string, parameters?: any[]): Promise<T[]>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
interface User { id: number; name: string; active: boolean }
|
|
88
|
+
|
|
89
|
+
const admins = await Db.select<User>('SELECT * FROM users WHERE role = ?', ['admin'])
|
|
90
|
+
// [{ id: 1, name: 'Alice', active: true }, { id: 2, name: 'Bob', active: true }]
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### selectIterable
|
|
94
|
+
|
|
95
|
+
Execute a SELECT query and return a streaming async iterable with backpressure support. Uses `Helpers.AsyncIterableCollection` which provides `map`, `filter`, and `toArray` chainable methods.
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
selectIterable<T = Record<string, unknown>>(query: string, parameters?: any[]): Helpers.AsyncIterableCollection<T>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
const orders = Db.selectIterable<Order>('SELECT * FROM orders WHERE status = ?', ['pending'])
|
|
103
|
+
|
|
104
|
+
for await (const order of orders) {
|
|
105
|
+
await processOrder(order)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Or with chaining
|
|
109
|
+
const names = await Db.selectIterable<User>('SELECT * FROM users')
|
|
110
|
+
.filter(user => user.active)
|
|
111
|
+
.map(user => user.name)
|
|
112
|
+
.toArray()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Backpressure: the driver pauses the MySQL connection when the internal row queue exceeds 3000 rows and resumes when it drains below that threshold.
|
|
116
|
+
|
|
117
|
+
### insert
|
|
118
|
+
|
|
119
|
+
Execute an INSERT query and return the affected row count and auto-increment ID.
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
insert(query: string, parameters?: any[]): Promise<{ affectedRows: number; insertId: number }>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
const { insertId } = await Db.insert(
|
|
127
|
+
'INSERT INTO users (name, email) VALUES (?, ?)',
|
|
128
|
+
['Alice', 'alice@example.com']
|
|
129
|
+
)
|
|
130
|
+
// insertId: 42
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### update
|
|
134
|
+
|
|
135
|
+
Execute an UPDATE query and return matched and changed row counts.
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
update(query: string, parameters?: any[]): Promise<{ affectedRows: number; changedRows: number }>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
const { changedRows } = await Db.update(
|
|
143
|
+
'UPDATE users SET active = ? WHERE last_login < ?',
|
|
144
|
+
[false, '2024-01-01']
|
|
145
|
+
)
|
|
146
|
+
// changedRows: 15
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### delete
|
|
150
|
+
|
|
151
|
+
Execute a DELETE query and return the number of removed rows.
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
delete(query: string, parameters?: any[]): Promise<{ affectedRows: number }>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
const { affectedRows } = await Db.delete(
|
|
159
|
+
'DELETE FROM sessions WHERE expires_at < ?',
|
|
160
|
+
[new Date().toISOString()]
|
|
161
|
+
)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### scalar
|
|
165
|
+
|
|
166
|
+
Return the first column of the first row, or `null` if no results. Returns `null` both when the query returns zero rows and when the value itself is SQL NULL.
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
scalar<T = unknown>(query: string, parameters?: any[]): Promise<T | null>
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
const count = await Db.scalar<number>('SELECT COUNT(*) FROM users WHERE active = ?', [true])
|
|
174
|
+
// 42
|
|
175
|
+
|
|
176
|
+
const name = await Db.scalar<string>('SELECT name FROM users WHERE id = ?', [999])
|
|
177
|
+
// null (no matching row)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### statement
|
|
181
|
+
|
|
182
|
+
Execute an arbitrary SQL statement and return the raw result. Use for DDL or other statements that do not fit the typed methods.
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
statement<T = unknown>(query: string, parameters?: any[]): Promise<T>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
await Db.statement('CREATE INDEX idx_users_email ON users (email)')
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### transaction
|
|
193
|
+
|
|
194
|
+
Wrap a callback in BEGIN/COMMIT/ROLLBACK. All `Db` calls awaited inside the callback share the same connection via `AsyncLocalStorage`. Nested transaction calls reuse the outer transaction (no savepoints).
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
transaction<T>(callback: () => Promise<T>): Promise<T>
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
await Db.transaction(async () => {
|
|
202
|
+
await Db.update('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, fromId])
|
|
203
|
+
await Db.update('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, toId])
|
|
204
|
+
})
|
|
205
|
+
// Both updates commit together; if either throws, both roll back
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### format
|
|
209
|
+
|
|
210
|
+
Interpolate parameters into a query string for debugging. Does not execute the query.
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
format(query: string, params?: any[]): string
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
const sql = Db.format('SELECT * FROM users WHERE id = ?', [42])
|
|
218
|
+
// "SELECT * FROM users WHERE id = 42"
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### destroy
|
|
222
|
+
|
|
223
|
+
Close all connections in the pool and prevent new ones from being created.
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
destroy(): Promise<void>
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Common Patterns
|
|
230
|
+
|
|
231
|
+
### Parameterized Queries
|
|
232
|
+
|
|
233
|
+
Always use `?` placeholders. Never interpolate values into SQL strings.
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
// Correct
|
|
237
|
+
const users = await Db.select<User>('SELECT * FROM users WHERE name = ?', ['Alice'])
|
|
238
|
+
|
|
239
|
+
// WRONG - SQL injection risk
|
|
240
|
+
const users = await Db.select<User>(`SELECT * FROM users WHERE name = '${name}'`)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Avoid Table Name Aliases
|
|
244
|
+
|
|
245
|
+
Write full table names in SQL queries instead of aliases when possible.
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
// Preferred
|
|
249
|
+
await Db.select('SELECT users.name, orders.total FROM users JOIN orders ON orders.user_id = users.id')
|
|
250
|
+
|
|
251
|
+
// Avoid
|
|
252
|
+
await Db.select('SELECT u.name, o.total FROM users u JOIN orders o ON o.user_id = u.id')
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Transactions with Error Handling
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
try {
|
|
259
|
+
const orderId = await Db.transaction(async () => {
|
|
260
|
+
const { insertId } = await Db.insert(
|
|
261
|
+
'INSERT INTO orders (user_id, total) VALUES (?, ?)',
|
|
262
|
+
[userId, total]
|
|
263
|
+
)
|
|
264
|
+
await Db.insert(
|
|
265
|
+
'INSERT INTO order_items (order_id, product_id, quantity) VALUES (?, ?, ?)',
|
|
266
|
+
[insertId, productId, quantity]
|
|
267
|
+
)
|
|
268
|
+
await Db.update(
|
|
269
|
+
'UPDATE products SET stock = stock - ? WHERE id = ?',
|
|
270
|
+
[quantity, productId]
|
|
271
|
+
)
|
|
272
|
+
return insertId
|
|
273
|
+
})
|
|
274
|
+
} catch (e) {
|
|
275
|
+
// All three statements rolled back
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Streaming Large Result Sets
|
|
280
|
+
|
|
281
|
+
Use `selectIterable` for queries returning many rows to avoid loading everything into memory.
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
const rows = Db.selectIterable<AuditLog>('SELECT * FROM audit_logs WHERE created_at > ?', ['2024-01-01'])
|
|
285
|
+
|
|
286
|
+
let count = 0
|
|
287
|
+
for await (const row of rows) {
|
|
288
|
+
await archiveToS3(row)
|
|
289
|
+
count++
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### Multiple Providers
|
|
294
|
+
|
|
295
|
+
Register named providers for separate databases.
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
Db.Provider.register('default', Db.Provider.create({
|
|
299
|
+
driver: Db.drivers.mysql({ host: 'primary-db', user: 'app', password: '', database: 'myapp', port: 3306 })
|
|
300
|
+
}))
|
|
301
|
+
|
|
302
|
+
Db.Provider.register('analytics', Db.Provider.create({
|
|
303
|
+
driver: Db.drivers.mysql({ host: 'analytics-db', user: 'app', password: '', database: 'analytics', port: 3306 })
|
|
304
|
+
}))
|
|
305
|
+
|
|
306
|
+
// Use the default provider
|
|
307
|
+
const users = await Db.select<User>('SELECT * FROM users')
|
|
308
|
+
|
|
309
|
+
// Use a named provider
|
|
310
|
+
const events = await Db.provider('analytics').select<Event>('SELECT * FROM events')
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
## Query Logging
|
|
314
|
+
|
|
315
|
+
The MySQL driver emits structured log events via the `queryLogger` and `connectionLogger` options.
|
|
316
|
+
|
|
317
|
+
Query logger events: `queryStarted`, `queryCompleted`, `queryFailed`, `transactionStarted`, `transactionCompleted`, `transactionFailed`. Each event includes the query ID, formatted query, duration (on completion/failure), and transaction ID when applicable.
|
|
318
|
+
|
|
319
|
+
Connection logger events: `newConnection`, `connectionAcquisitionStarted`, `connectionAcquisitionCompleted`, `connectionAcquisitionFailed`.
|
|
320
|
+
|
|
321
|
+
The log message type is exported as `Db.drivers.mysql.MysqlDbDriverLogMessageData`.
|
|
322
|
+
|
|
323
|
+
## Testing
|
|
324
|
+
|
|
325
|
+
The `@maestro-js/db` package does not currently have a mock driver or built-in tests. To test code that uses `Db`:
|
|
326
|
+
|
|
327
|
+
1. **Use a real test database** -- spin up a MySQL instance (e.g., via Docker) and register a provider pointing to it.
|
|
328
|
+
2. **Wrap tests in transactions** -- roll back after each test to keep the database clean.
|
|
329
|
+
3. **Abstract database calls** -- write functions that accept a `Db.DbService` so you can substitute a test double.
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
// Test setup with a real database
|
|
333
|
+
import { Db } from '@maestro-js/db'
|
|
334
|
+
|
|
335
|
+
Db.Provider.register('default', Db.Provider.create({
|
|
336
|
+
driver: Db.drivers.mysql({
|
|
337
|
+
host: 'localhost',
|
|
338
|
+
user: 'test',
|
|
339
|
+
password: '',
|
|
340
|
+
database: 'myapp_test',
|
|
341
|
+
port: 3306
|
|
342
|
+
})
|
|
343
|
+
}))
|
|
344
|
+
|
|
345
|
+
// Clean up after tests
|
|
346
|
+
afterAll(async () => {
|
|
347
|
+
await Db.destroy()
|
|
348
|
+
})
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
## Cross-Package Integration
|
|
352
|
+
|
|
353
|
+
### Depends On
|
|
354
|
+
|
|
355
|
+
- **@maestro-js/service-registry** -- provider pattern (create, register, resolve)
|
|
356
|
+
- **@maestro-js/log** -- structured logging for queries and connections
|
|
357
|
+
- **@maestro-js/context** -- `AsyncLocalStorage`-based request context for scoping log data
|
|
358
|
+
- **@maestro-js/helpers** -- retry logic, `AsyncIterableCollection` for streaming
|
|
359
|
+
|
|
360
|
+
### Used By
|
|
361
|
+
|
|
362
|
+
- **@maestro-js/queue** -- stores queue messages and job state in MySQL tables via `Db`
|
|
363
|
+
|
|
364
|
+
### Works With
|
|
365
|
+
|
|
366
|
+
- **@maestro-js/sql** -- provides query builder utilities (`Sql.where`, CTEs) that produce parameterized SQL strings compatible with `Db.select`, `Db.update`, etc.
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
import { Db } from '@maestro-js/db'
|
|
370
|
+
import { Sql } from '@maestro-js/sql'
|
|
371
|
+
|
|
372
|
+
const where = Sql.where({ active: true, role: 'admin' })
|
|
373
|
+
const users = await Db.select<User>(`SELECT * FROM users ${where.sql}`, where.params)
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## DbDriver Interface
|
|
377
|
+
|
|
378
|
+
Implement this interface to create a custom driver.
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
interface DbDriver {
|
|
382
|
+
selectIterable(query: string, parameters?: any[]): AsyncIterable<Record<string, unknown>>
|
|
383
|
+
execute(
|
|
384
|
+
query: string,
|
|
385
|
+
parameters?: any[]
|
|
386
|
+
): Promise<Record<string, unknown>[] | { affectedRows: number; changedRows: number; insertId: number }>
|
|
387
|
+
format(query: string, parameters: any[]): string
|
|
388
|
+
transaction<T>(callback: () => Promise<T>): Promise<T>
|
|
389
|
+
destroy(): Promise<void>
|
|
390
|
+
}
|
|
391
|
+
```
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: events
|
|
3
|
+
description: "Guide for @maestro-js/events — a pub/sub messaging package with pluggable drivers (Redis, WebSocket, Local). Use when working with @maestro-js/events, adding event-driven communication, creating typed event handles, configuring Redis pub/sub, setting up IPC via WebSocket, using in-process local events, or integrating with the Broadcast package. Covers the Provider pattern, driver configuration, typed Event handles, listener lifecycle, and cross-package usage with service-registry and broadcast."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @maestro-js/events
|
|
7
|
+
|
|
8
|
+
Pub/sub messaging layer with pluggable drivers for Redis (cross-server), WebSocket (IPC between Node processes), and Local (in-process). Follows the standard Maestro Provider pattern built on `@maestro-js/service-registry`.
|
|
9
|
+
|
|
10
|
+
## Quick Setup
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { Events } from '@maestro-js/events'
|
|
14
|
+
|
|
15
|
+
// Create and register with a driver
|
|
16
|
+
const service = Events.Provider.create({ driver: Events.drivers.local() })
|
|
17
|
+
Events.Provider.register('default', service)
|
|
18
|
+
|
|
19
|
+
// Use via the default facade
|
|
20
|
+
Events.emit('order-placed', { orderId: 'order-789', total: 59.99 })
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Drivers
|
|
24
|
+
|
|
25
|
+
### Local (In-Process)
|
|
26
|
+
|
|
27
|
+
Use `Events.drivers.local()` for in-process events. Returns a Node.js `EventEmitter`. Best for testing and single-process applications.
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
const service = Events.Provider.create({
|
|
31
|
+
driver: Events.drivers.local()
|
|
32
|
+
})
|
|
33
|
+
Events.Provider.register('default', service)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Redis (Cross-Server)
|
|
37
|
+
|
|
38
|
+
Use `Events.drivers.redis(config)` for cross-server pub/sub via Redis. Creates separate publisher and subscriber connections with lazy initialization. Messages are JSON-serialized over Redis channels prefixed with `maestro.events.`.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
const service = Events.Provider.create({
|
|
42
|
+
driver: Events.drivers.redis({
|
|
43
|
+
url: 'redis-host.example.com',
|
|
44
|
+
port: 6379,
|
|
45
|
+
operationTimeout: 5000, // optional, default 5000ms
|
|
46
|
+
connectTimeout: 10000, // optional, default 10000ms
|
|
47
|
+
keyPrefix: 'myapp:', // optional, default ''
|
|
48
|
+
logger: customLogger // optional, defaults to Log.create()
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
Events.Provider.register('default', service)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Redis driver config interface:
|
|
55
|
+
|
|
56
|
+
| Option | Type | Default | Description |
|
|
57
|
+
|--------|------|---------|-------------|
|
|
58
|
+
| `url` | `string` | required | Redis host URL |
|
|
59
|
+
| `port` | `number` | required | Redis port |
|
|
60
|
+
| `operationTimeout` | `number` | `5000` | Timeout for publish operations (ms) |
|
|
61
|
+
| `connectTimeout` | `number` | `10000` | Connection timeout (ms) |
|
|
62
|
+
| `keyPrefix` | `string` | `''` | Prefix prepended to all channel names |
|
|
63
|
+
| `logger` | `Log.LogFunctions` | `Log.create()` | Logger instance for connection/publish/subscribe logs |
|
|
64
|
+
|
|
65
|
+
Redis channel naming: channels are formatted as `${keyPrefix}maestro.events.${eventName}`.
|
|
66
|
+
|
|
67
|
+
### WebSocket (IPC Between Processes)
|
|
68
|
+
|
|
69
|
+
Use `Events.drivers.socket(config)` for IPC between Node.js processes via TCP sockets. Automatically creates a server if none exists and connects as a client. Reconnects on socket close.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const service = Events.Provider.create({
|
|
73
|
+
driver: Events.drivers.socket({ port: 8444 })
|
|
74
|
+
})
|
|
75
|
+
Events.Provider.register('default', service)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The socket driver serializes messages as newline-delimited JSON (`{ eventName, data }\n`). One process acts as the server (created automatically if the port is available), and all processes connect as clients.
|
|
79
|
+
|
|
80
|
+
## API Reference
|
|
81
|
+
|
|
82
|
+
### Provider Pattern
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// Create a service instance with a driver
|
|
86
|
+
Events.Provider.create(config: { driver: Events.EventsDriver }): Events.EventsService
|
|
87
|
+
|
|
88
|
+
// Register under a named key
|
|
89
|
+
Events.Provider.register(name: string, service: Events.EventsService): void
|
|
90
|
+
|
|
91
|
+
// Resolve a named instance (deferred via Proxy)
|
|
92
|
+
Events.provider(key: string): Events.EventsService
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Direct API (Untyped)
|
|
96
|
+
|
|
97
|
+
Operate on the default facade without typed handles.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
// Emit an event with a payload
|
|
101
|
+
Events.emit(name: string, message: Record<string, any>): void
|
|
102
|
+
|
|
103
|
+
// Subscribe to an event, returns a handle with remove()
|
|
104
|
+
Events.addListener<T>(event: string, listener: (message: T) => unknown): { remove(): void }
|
|
105
|
+
|
|
106
|
+
// Remove a specific listener by reference
|
|
107
|
+
Events.removeListener(event: string, listener: (message: Record<string, any>) => unknown): void
|
|
108
|
+
|
|
109
|
+
// Remove all listeners and tear down driver connections
|
|
110
|
+
Events.removeAllListeners(): void
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Typed Event Handles
|
|
114
|
+
|
|
115
|
+
Create a typed event scoped to a single name. All methods enforce the payload type `T` at compile time.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
// Create a typed event handle
|
|
119
|
+
Events.create<T extends Record<string, any>>(eventName: string): Events.Event<T>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The returned `Event<T>` interface:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
interface Event<T extends Record<string, any>> {
|
|
126
|
+
emit(message: T): void
|
|
127
|
+
addListener(listener: (message: T) => unknown): { remove(): void }
|
|
128
|
+
removeListener(listener: (message: T) => unknown): void
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### EventsDriver Interface
|
|
133
|
+
|
|
134
|
+
All drivers implement this interface:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
type EventsDriver = {
|
|
138
|
+
addListener(event: string, listener: (message: Record<string, any>) => void): unknown
|
|
139
|
+
removeListener(event: string, listener: (message: Record<string, any>) => void): unknown
|
|
140
|
+
emit(name: string, message: Record<string, any>): unknown
|
|
141
|
+
removeAllListeners(): unknown
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Type Augmentation for Provider Keys
|
|
146
|
+
|
|
147
|
+
Extend provider keys for type-safe multi-instance resolution:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
declare namespace Events {
|
|
151
|
+
namespace Provider {
|
|
152
|
+
interface Keys {
|
|
153
|
+
default: unknown
|
|
154
|
+
analytics: unknown
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Common Patterns
|
|
161
|
+
|
|
162
|
+
### Typed Event Handles
|
|
163
|
+
|
|
164
|
+
Define typed events for compile-time payload safety:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const orderPlaced = Events.create<{ orderId: string; total: number }>('order-placed')
|
|
168
|
+
|
|
169
|
+
const sub = orderPlaced.addListener((msg) => {
|
|
170
|
+
console.log(`Order ${msg.orderId} placed for $${msg.total}`)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
orderPlaced.emit({ orderId: 'order-1', total: 42.5 })
|
|
174
|
+
|
|
175
|
+
// Unsubscribe
|
|
176
|
+
sub.remove()
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Listener Removal by Reference
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
const listener = (msg: Record<string, any>) => console.log(msg)
|
|
183
|
+
Events.addListener('my-event', listener)
|
|
184
|
+
|
|
185
|
+
// Later, remove by reference
|
|
186
|
+
Events.removeListener('my-event', listener)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Multiple Listeners on One Event
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
Events.addListener('user-signup', (msg) => sendWelcomeEmail(msg))
|
|
193
|
+
Events.addListener('user-signup', (msg) => trackAnalytics(msg))
|
|
194
|
+
|
|
195
|
+
Events.emit('user-signup', { userId: 'u-123', email: 'alice@example.com' })
|
|
196
|
+
// Both listeners fire
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Multiple Named Instances
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
const redisEvents = Events.Provider.create({
|
|
203
|
+
driver: Events.drivers.redis({ url: 'redis-host', port: 6379 })
|
|
204
|
+
})
|
|
205
|
+
Events.Provider.register('default', redisEvents)
|
|
206
|
+
|
|
207
|
+
const localEvents = Events.Provider.create({
|
|
208
|
+
driver: Events.drivers.local()
|
|
209
|
+
})
|
|
210
|
+
Events.Provider.register('internal', localEvents)
|
|
211
|
+
|
|
212
|
+
// Use each instance
|
|
213
|
+
Events.provider('default').emit('cross-server-event', { data: 1 })
|
|
214
|
+
Events.provider('internal').emit('local-only-event', { data: 2 })
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Cleanup on Shutdown
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
// Tears down driver connections (Redis disconnect, socket close) and removes all listeners
|
|
221
|
+
Events.removeAllListeners()
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Testing
|
|
225
|
+
|
|
226
|
+
Use the Local driver for tests. It is synchronous and requires no external services.
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
import { Events } from '@maestro-js/events'
|
|
230
|
+
import { test } from 'beartest-js'
|
|
231
|
+
import { expect } from 'expect'
|
|
232
|
+
|
|
233
|
+
const service = Events.Provider.create({ driver: Events.drivers.local() })
|
|
234
|
+
|
|
235
|
+
test('emit + addListener fires listener with correct payload', () => {
|
|
236
|
+
const received: any[] = []
|
|
237
|
+
service.addListener('test-event', (msg) => received.push(msg))
|
|
238
|
+
service.emit('test-event', { hello: 'world' })
|
|
239
|
+
expect(received).toEqual([{ hello: 'world' }])
|
|
240
|
+
service.removeAllListeners()
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('typed events via create()', () => {
|
|
244
|
+
const orderPlaced = service.create<{ orderId: string; total: number }>('order-placed')
|
|
245
|
+
const received: any[] = []
|
|
246
|
+
orderPlaced.addListener((msg) => received.push(msg))
|
|
247
|
+
orderPlaced.emit({ orderId: 'order-1', total: 42.5 })
|
|
248
|
+
expect(received).toEqual([{ orderId: 'order-1', total: 42.5 }])
|
|
249
|
+
service.removeAllListeners()
|
|
250
|
+
})
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Always call `service.removeAllListeners()` after each test to prevent listener leaks.
|
|
254
|
+
|
|
255
|
+
Run tests:
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
pnpm --filter @maestro-js/events test
|
|
259
|
+
cd packages/events && npx beartest ./tests/events.test.ts
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Cross-Package Integration
|
|
263
|
+
|
|
264
|
+
### Dependencies
|
|
265
|
+
|
|
266
|
+
- **@maestro-js/service-registry** -- foundation for the Provider pattern (create, register, resolve)
|
|
267
|
+
- **@maestro-js/log** -- used by the Redis driver for structured logging of connections, publishes, and subscribes
|
|
268
|
+
|
|
269
|
+
### Used By: Broadcast
|
|
270
|
+
|
|
271
|
+
The `@maestro-js/broadcast` package accepts an Events service instance as its driver. The Events service satisfies the `BroadcastingDriver` interface (`emit`, `addListener`, `removeListener`, `removeAllListeners`).
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
import { Events } from '@maestro-js/events'
|
|
275
|
+
import { Broadcast } from '@maestro-js/broadcast'
|
|
276
|
+
|
|
277
|
+
const users = Broadcast.channel('/user/:id').create<{ name: string }>()
|
|
278
|
+
|
|
279
|
+
const broadcast = Broadcast.Provider.create({
|
|
280
|
+
channels: [users],
|
|
281
|
+
driver: Events // Events facade satisfies BroadcastingDriver
|
|
282
|
+
})
|
|
283
|
+
Broadcast.Provider.register('default', broadcast)
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
## Source Files
|
|
287
|
+
|
|
288
|
+
| File | Purpose |
|
|
289
|
+
|------|---------|
|
|
290
|
+
| `packages/events/src/index.ts` | Main export, Provider pattern, facade, driver registry |
|
|
291
|
+
| `packages/events/src/events-types.ts` | `EventsDriver` interface definition |
|
|
292
|
+
| `packages/events/src/redis-events-driver.ts` | Redis pub/sub driver using ioredis |
|
|
293
|
+
| `packages/events/src/sockets-events-driver.ts` | TCP socket IPC driver for inter-process events |
|
|
294
|
+
| `packages/events/tests/events.test.ts` | Unit tests using Local driver |
|
|
295
|
+
| `packages/events/tests/socket-events-driver.test.ts` | Socket driver integration test (skipped by default) |
|