@classytic/arc 2.11.2 → 2.11.3
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/LICENSE +21 -21
- package/README.md +8 -14
- package/bin/arc.js +2 -2
- package/dist/{BaseController-JNV08qOT.mjs → BaseController-swXruJ2_.mjs} +2 -2
- package/dist/{actionPermissions-C8YYU92K.mjs → actionPermissions-sUUKDhtP.mjs} +4 -2
- package/dist/auth/index.mjs +1 -1
- package/dist/cli/commands/docs.mjs +1 -1
- package/dist/cli/commands/generate.d.mts +0 -2
- package/dist/cli/commands/generate.mjs +15 -15
- package/dist/cli/commands/init.mjs +24 -22
- package/dist/context/index.mjs +1 -1
- package/dist/core/index.mjs +3 -3
- package/dist/{core-DXdSSFW-.mjs → core-DnUsRpuX.mjs} +20 -8
- package/dist/{createActionRouter-BwaSM0No.mjs → createActionRouter-u3ql2EDo.mjs} +73 -13
- package/dist/{createApp-P1d6rjPy.mjs → createApp-BFxtdKy6.mjs} +1 -1
- package/dist/docs/index.mjs +1 -1
- package/dist/{eventPlugin--5HIkdPU.mjs → eventPlugin-KrFIQ097.mjs} +1 -1
- package/dist/events/index.mjs +11 -3
- package/dist/factory/index.mjs +1 -1
- package/dist/index.mjs +6 -6
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/{openapi-C0L9ar7m.mjs → openapi-BGUn7Ki1.mjs} +2 -2
- package/dist/permissions/index.mjs +1 -1
- package/dist/{permissions-B4vU9L0Q.mjs → permissions-gd_aUWrR.mjs} +42 -0
- package/dist/plugins/index.mjs +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/filesUpload.mjs +1 -1
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-k604Lj99.mjs → presets-Z7P5w4gF.mjs} +1 -1
- package/dist/{requestContext-CfRkaxwf.mjs → requestContext-C5XeK3VA.mjs} +15 -0
- package/dist/{resourceToTools--okX6QBr.mjs → resourceToTools-ByZpgjeH.mjs} +5 -4
- package/dist/{routerShared-DeESFp4a.mjs → routerShared-BqLRb5l7.mjs} +60 -3
- package/dist/testing/index.mjs +1 -1
- package/dist/utils/index.mjs +1 -1
- package/dist/{utils-D3Yxnrwr.mjs → utils-CcYTj09l.mjs} +1 -1
- package/package.json +3 -1
- package/skills/arc/references/events.md +489 -489
|
@@ -1,489 +1,489 @@
|
|
|
1
|
-
# Arc Events System
|
|
2
|
-
|
|
3
|
-
Domain event pub/sub with pluggable transports. Auto-emits events on CRUD operations.
|
|
4
|
-
|
|
5
|
-
## Hooks vs Events
|
|
6
|
-
|
|
7
|
-
| Aspect | Hooks | Events |
|
|
8
|
-
|--------|-------|--------|
|
|
9
|
-
| Purpose | Internal lifecycle callbacks | External integration |
|
|
10
|
-
| Scope | Same process, synchronous flow | Cross-service, async |
|
|
11
|
-
| Use when | Validating, transforming, auditing | Notifying services, event-driven systems |
|
|
12
|
-
| Transport | In-process only | Pluggable (Memory → Redis → Kafka) |
|
|
13
|
-
| Pattern | `beforeCreate`, `afterUpdate` | `product.created`, `order.updated` |
|
|
14
|
-
|
|
15
|
-
## Setup
|
|
16
|
-
|
|
17
|
-
The `createApp()` factory auto-registers `eventPlugin` — no manual registration needed:
|
|
18
|
-
|
|
19
|
-
```typescript
|
|
20
|
-
import { createApp } from '@classytic/arc/factory';
|
|
21
|
-
|
|
22
|
-
// Development (in-memory, default — zero config)
|
|
23
|
-
const app = await createApp({ preset: 'development' });
|
|
24
|
-
// app.events is ready to use
|
|
25
|
-
|
|
26
|
-
// Production (Redis transport, with retry)
|
|
27
|
-
import { RedisEventTransport } from '@classytic/arc/events/redis';
|
|
28
|
-
|
|
29
|
-
const app = await createApp({
|
|
30
|
-
stores: { events: new RedisEventTransport(redis) },
|
|
31
|
-
arcPlugins: {
|
|
32
|
-
events: {
|
|
33
|
-
logEvents: true,
|
|
34
|
-
failOpen: true, // default: suppress transport failures
|
|
35
|
-
retry: { maxRetries: 3, backoffMs: 1000 },
|
|
36
|
-
},
|
|
37
|
-
},
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
// Disable event plugin entirely
|
|
41
|
-
const app = await createApp({ arcPlugins: { events: false } });
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
**Manual registration** (for apps not using `createApp`):
|
|
45
|
-
|
|
46
|
-
```typescript
|
|
47
|
-
import { eventPlugin } from '@classytic/arc/events';
|
|
48
|
-
|
|
49
|
-
await fastify.register(eventPlugin); // Memory transport
|
|
50
|
-
|
|
51
|
-
// Redis Pub/Sub
|
|
52
|
-
import { RedisEventTransport } from '@classytic/arc/events/redis';
|
|
53
|
-
await fastify.register(eventPlugin, {
|
|
54
|
-
transport: new RedisEventTransport(redisClient, { channel: 'arc-events' }),
|
|
55
|
-
logEvents: true,
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
// Redis Streams (ordered, persistent, consumer groups)
|
|
59
|
-
import { RedisStreamTransport } from '@classytic/arc/events/redis-stream';
|
|
60
|
-
await fastify.register(eventPlugin, {
|
|
61
|
-
transport: new RedisStreamTransport(redisClient, {
|
|
62
|
-
stream: 'arc:events',
|
|
63
|
-
group: 'api-service',
|
|
64
|
-
consumer: 'worker-1',
|
|
65
|
-
}),
|
|
66
|
-
});
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
`failOpen` behavior:
|
|
70
|
-
- `true` (default): publish/subscribe/close transport errors are logged and suppressed.
|
|
71
|
-
- `false`: transport errors are thrown to caller.
|
|
72
|
-
|
|
73
|
-
## Auto-Emitted Events
|
|
74
|
-
|
|
75
|
-
BaseController automatically emits events when eventPlugin is registered:
|
|
76
|
-
|
|
77
|
-
| Operation | Event Type | Payload |
|
|
78
|
-
|-----------|------------|---------|
|
|
79
|
-
| `create()` | `{resource}.created` | Created document |
|
|
80
|
-
| `update()` | `{resource}.updated` | Updated document |
|
|
81
|
-
| `delete()` | `{resource}.deleted` | Deleted document |
|
|
82
|
-
| `restore()` | `{resource}.restored` | Restored document |
|
|
83
|
-
|
|
84
|
-
Disable per-controller: `super({ disableEvents: true })`
|
|
85
|
-
|
|
86
|
-
## Publishing & Subscribing
|
|
87
|
-
|
|
88
|
-
```typescript
|
|
89
|
-
// Publish
|
|
90
|
-
await fastify.events.publish('order.created', {
|
|
91
|
-
orderId: 'order-123',
|
|
92
|
-
total: 99.99,
|
|
93
|
-
}, {
|
|
94
|
-
userId: request.user._id,
|
|
95
|
-
organizationId: getOrgId(request.scope),
|
|
96
|
-
correlationId: request.id,
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
// Subscribe to specific event
|
|
100
|
-
await fastify.events.subscribe('order.created', async (event) => {
|
|
101
|
-
await sendConfirmation(event.payload);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
// Subscribe to pattern (wildcard)
|
|
105
|
-
await fastify.events.subscribe('order.*', async (event) => {
|
|
106
|
-
await updateAnalytics(event.type, event.payload);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
// Subscribe to all
|
|
110
|
-
await fastify.events.subscribe('*', async (event) => {
|
|
111
|
-
await auditLog.create(event);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
// Unsubscribe
|
|
115
|
-
const unsub = await fastify.events.subscribe('order.created', handler);
|
|
116
|
-
unsub();
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
## Event Structure (v2.9)
|
|
120
|
-
|
|
121
|
-
```typescript
|
|
122
|
-
interface EventMeta {
|
|
123
|
-
id: string; // UUID v4 (fresh per emit; a retry gets a new id)
|
|
124
|
-
timestamp: Date;
|
|
125
|
-
schemaVersion?: number; // bump on payload breaking change
|
|
126
|
-
correlationId?: string; // stable across causal chain
|
|
127
|
-
causationId?: string; // direct parent event id
|
|
128
|
-
partitionKey?: string; // ordering hint (Kafka/Kinesis/Streams)
|
|
129
|
-
source?: string; // originating service/package ('commerce', 'billing')
|
|
130
|
-
idempotencyKey?: string; // cross-transport dedupe hint — stable per operation
|
|
131
|
-
resource?: string;
|
|
132
|
-
resourceId?: string;
|
|
133
|
-
userId?: string;
|
|
134
|
-
organizationId?: string;
|
|
135
|
-
aggregate?: { type: string; id: string }; // DDD aggregate marker
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
interface DomainEvent<T> {
|
|
139
|
-
type: string; // e.g., 'order.created'
|
|
140
|
-
payload: T;
|
|
141
|
-
meta: EventMeta;
|
|
142
|
-
}
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
**Arc is source of truth** — `@classytic/primitives/events` mirrors these shapes. Downstream packages import from primitives; arc owns evolution.
|
|
146
|
-
|
|
147
|
-
### DDD aggregate narrowing
|
|
148
|
-
|
|
149
|
-
`aggregate.type` is `string` in arc's base contract so it stays framework-neutral. Domain packages narrow it to a closed union via interface extension:
|
|
150
|
-
|
|
151
|
-
```typescript
|
|
152
|
-
// @classytic/cart
|
|
153
|
-
type CartAggregateType = 'cart' | 'cart-item';
|
|
154
|
-
|
|
155
|
-
interface CartEventMeta extends EventMeta {
|
|
156
|
-
aggregate?: { type: CartAggregateType; id: string };
|
|
157
|
-
}
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
Unlike `correlationId` / `causationId`, `aggregate` is **not inherited** by `createChildEvent`. Child events usually belong to a different aggregate (e.g. an `order.placed` event emitted by the order aggregate spawns `inventory.reserved` owned by the inventory aggregate). Each event names its own aggregate explicitly.
|
|
161
|
-
|
|
162
|
-
### Causation chains
|
|
163
|
-
|
|
164
|
-
```typescript
|
|
165
|
-
import { createEvent, createChildEvent } from '@classytic/arc/events';
|
|
166
|
-
|
|
167
|
-
const placed = createEvent('order.placed', { orderId: 'o1' }, {
|
|
168
|
-
correlationId: req.id, userId: user.id,
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
// Downstream handler emits child — causation linked, correlation inherited:
|
|
172
|
-
const reserved = createChildEvent(placed, 'inventory.reserved', { sku: 'a' });
|
|
173
|
-
// reserved.meta.causationId === placed.meta.id
|
|
174
|
-
// reserved.meta.correlationId === placed.meta.correlationId
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
### Dead-letter contract
|
|
178
|
-
|
|
179
|
-
```typescript
|
|
180
|
-
import type { DeadLetteredEvent, EventTransport } from '@classytic/arc/events';
|
|
181
|
-
|
|
182
|
-
class KafkaTransport implements EventTransport {
|
|
183
|
-
async deadLetter(dlq: DeadLetteredEvent) {
|
|
184
|
-
await producer.send({ topic: `${dlq.event.type}.DLQ`, messages: [{ value: JSON.stringify(dlq) }] });
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
## Custom Transport
|
|
190
|
-
|
|
191
|
-
Implement `EventTransport` for RabbitMQ, Kafka, etc.:
|
|
192
|
-
|
|
193
|
-
```typescript
|
|
194
|
-
import type { EventTransport, DomainEvent } from '@classytic/arc/events';
|
|
195
|
-
|
|
196
|
-
class KafkaTransport implements EventTransport {
|
|
197
|
-
readonly name = 'kafka';
|
|
198
|
-
|
|
199
|
-
async publish(event: DomainEvent): Promise<void> {
|
|
200
|
-
await this.producer.send({ topic: event.type, messages: [{ value: JSON.stringify(event) }] });
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
async subscribe(pattern: string, handler: EventHandler): Promise<() => void> {
|
|
204
|
-
// Subscribe to Kafka topic matching pattern
|
|
205
|
-
return () => { /* unsubscribe */ };
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
async close(): Promise<void> {
|
|
209
|
-
await this.producer.disconnect();
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
## Built-in Transports
|
|
215
|
-
|
|
216
|
-
| Transport | Import | Use Case |
|
|
217
|
-
|-----------|--------|----------|
|
|
218
|
-
| Memory | `@classytic/arc/events` (default) | Development, testing, single-instance |
|
|
219
|
-
| Redis Pub/Sub | `@classytic/arc/events/redis` | Multi-instance, real-time |
|
|
220
|
-
| Redis Streams | `@classytic/arc/events/redis-stream` | Ordered, persistent, consumer groups |
|
|
221
|
-
|
|
222
|
-
### Streams vs Pub/Sub — pick the right one
|
|
223
|
-
|
|
224
|
-
Choosing wrong loses messages silently. Default to **Streams** for anything business-critical.
|
|
225
|
-
|
|
226
|
-
| Requirement | Use |
|
|
227
|
-
|---|---|
|
|
228
|
-
| Message MUST NOT be lost (billing, payments, audit) | **Streams** |
|
|
229
|
-
| Real-time notifications, OK to miss when no subscriber is up | Pub/Sub |
|
|
230
|
-
| Need to replay/reprocess past events | **Streams** |
|
|
231
|
-
| Multiple workers processing the same queue | **Streams** (consumer groups) |
|
|
232
|
-
| Simple broadcast to live WebSocket clients | Pub/Sub |
|
|
233
|
-
| Event sourcing or audit trail | **Streams** |
|
|
234
|
-
| Single-instance dev | Memory |
|
|
235
|
-
| At-least-once delivery with durable WAL | **Streams** + outbox pattern |
|
|
236
|
-
|
|
237
|
-
**Why it matters:** Pub/Sub is fire-and-forget. If no subscriber is connected when you publish, the message is gone. Streams persist until every consumer group acknowledges them — crashes, restarts, and network blips are survivable.
|
|
238
|
-
|
|
239
|
-
**Defense-in-depth:** pair `eventPlugin` with the transactional outbox (`EventOutbox` + `MemoryOutboxStore` or your own persistent store) for guaranteed delivery even if Redis is unreachable at publish time.
|
|
240
|
-
|
|
241
|
-
### Redis eviction policy — required for queues and idempotency
|
|
242
|
-
|
|
243
|
-
When you back events (Streams), jobs (BullMQ), idempotency, or cache with Redis, your Redis instance **must** be configured with `maxmemory-policy: noeviction`. Any other policy can silently evict in-flight stream entries or pending jobs.
|
|
244
|
-
|
|
245
|
-
- **Self-hosted Redis:** `redis-cli CONFIG SET maxmemory-policy noeviction` (or set in `redis.conf`).
|
|
246
|
-
- **Upstash:** free/paid DBs default to `optimistic-volatile`. You'll see `IMPORTANT! Eviction policy is optimistic-volatile. It should be "noeviction"` in BullMQ logs. **Do one of:** open a support ticket to request `noeviction`, use a dedicated DB for queues, or accept that long-idle jobs may be evicted.
|
|
247
|
-
- **ElastiCache / Redis Cloud:** set the parameter group's `maxmemory-policy` to `noeviction` before pointing arc at it.
|
|
248
|
-
|
|
249
|
-
For a pure cache DB (no queues, no idempotency), `allkeys-lru` is correct and what you want.
|
|
250
|
-
|
|
251
|
-
## Injectable Logger
|
|
252
|
-
|
|
253
|
-
All transports and retry accept a `logger` option — defaults to `console`, compatible with pino/fastify.log:
|
|
254
|
-
|
|
255
|
-
```typescript
|
|
256
|
-
import type { EventLogger } from '@classytic/arc/events';
|
|
257
|
-
|
|
258
|
-
// Interface: { warn(msg, ...args): void; error(msg, ...args): void }
|
|
259
|
-
|
|
260
|
-
// Use Fastify's logger
|
|
261
|
-
await fastify.register(eventPlugin, {
|
|
262
|
-
transport: new RedisEventTransport(redisClient, {
|
|
263
|
-
logger: fastify.log, // pino logger
|
|
264
|
-
}),
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
// Use with Memory transport
|
|
268
|
-
new MemoryEventTransport({ logger: fastify.log });
|
|
269
|
-
|
|
270
|
-
// Use with Redis Streams
|
|
271
|
-
new RedisStreamTransport(redisClient, { logger: fastify.log });
|
|
272
|
-
|
|
273
|
-
// Use with retry wrapper
|
|
274
|
-
import { withRetry } from '@classytic/arc/events';
|
|
275
|
-
const retried = withRetry(handler, { maxRetries: 3, logger: fastify.log });
|
|
276
|
-
```
|
|
277
|
-
|
|
278
|
-
| Component | File | `logger` option |
|
|
279
|
-
|-----------|------|-----------------|
|
|
280
|
-
| `MemoryEventTransport` | `EventTransport.ts` | `MemoryEventTransportOptions.logger` |
|
|
281
|
-
| `withRetry()` | `retry.ts` | `RetryOptions.logger` |
|
|
282
|
-
| `RedisEventTransport` | `transports/redis.ts` | `RedisEventTransportOptions.logger` |
|
|
283
|
-
| `RedisStreamTransport` | `transports/redis-stream.ts` | `RedisStreamTransportOptions.logger` |
|
|
284
|
-
|
|
285
|
-
## Typed Events — defineEvent & Event Registry
|
|
286
|
-
|
|
287
|
-
Declare events with schemas for runtime validation and introspection:
|
|
288
|
-
|
|
289
|
-
```typescript
|
|
290
|
-
import { defineEvent, createEventRegistry } from '@classytic/arc/events';
|
|
291
|
-
|
|
292
|
-
const OrderCreated = defineEvent({
|
|
293
|
-
name: 'order.created',
|
|
294
|
-
version: 1,
|
|
295
|
-
description: 'Emitted when an order is placed',
|
|
296
|
-
schema: {
|
|
297
|
-
type: 'object',
|
|
298
|
-
properties: {
|
|
299
|
-
orderId: { type: 'string' },
|
|
300
|
-
total: { type: 'number' },
|
|
301
|
-
},
|
|
302
|
-
required: ['orderId', 'total'],
|
|
303
|
-
},
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
// Type-safe event creation
|
|
307
|
-
const event = OrderCreated.create({ orderId: 'o-1', total: 100 }, { userId: 'user-1' });
|
|
308
|
-
await app.events.publish(event.type, event.payload, event.meta);
|
|
309
|
-
```
|
|
310
|
-
|
|
311
|
-
**Event Registry** — catalog + auto-validation on publish:
|
|
312
|
-
|
|
313
|
-
```typescript
|
|
314
|
-
const registry = createEventRegistry();
|
|
315
|
-
registry.register(OrderCreated);
|
|
316
|
-
|
|
317
|
-
const app = await createApp({
|
|
318
|
-
arcPlugins: {
|
|
319
|
-
events: { registry, validateMode: 'warn' },
|
|
320
|
-
// 'warn' (default): log warning, still publish
|
|
321
|
-
// 'reject': throw error, do NOT publish
|
|
322
|
-
// 'off': registry is introspection-only
|
|
323
|
-
},
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
// Introspect at runtime
|
|
327
|
-
app.events.registry?.catalog();
|
|
328
|
-
// → [{ name: 'order.created', version: 1, schema: {...} }, ...]
|
|
329
|
-
```
|
|
330
|
-
|
|
331
|
-
## QueryCache Integration
|
|
332
|
-
|
|
333
|
-
QueryCache uses events for auto-invalidation. When `arcPlugins.queryCache` is enabled, all CRUD events automatically bump resource versions, invalidating cached queries — zero config required.
|
|
334
|
-
|
|
335
|
-
## Retry Logic
|
|
336
|
-
|
|
337
|
-
Events module includes retry with exponential backoff for failed handlers:
|
|
338
|
-
|
|
339
|
-
```typescript
|
|
340
|
-
import { withRetry } from '@classytic/arc/events';
|
|
341
|
-
|
|
342
|
-
const retriedHandler = withRetry(async (event) => {
|
|
343
|
-
await processEvent(event);
|
|
344
|
-
}, {
|
|
345
|
-
maxRetries: 3, // default: 3
|
|
346
|
-
backoffMs: 1000, // initial delay, doubles each retry (default: 1000)
|
|
347
|
-
maxBackoffMs: 30000, // cap (default: 30000)
|
|
348
|
-
logger: fastify.log, // default: console
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
await fastify.events.subscribe('order.created', retriedHandler);
|
|
352
|
-
```
|
|
353
|
-
|
|
354
|
-
### Auto-route exhausted events to transport.deadLetter() (v2.9)
|
|
355
|
-
|
|
356
|
-
For transports with a native DLQ (Kafka DLQ topic, SQS DLQ queue, etc.), pass
|
|
357
|
-
`transport` to skip custom `$deadLetter` plumbing:
|
|
358
|
-
|
|
359
|
-
```typescript
|
|
360
|
-
await fastify.events.subscribe(
|
|
361
|
-
'order.created',
|
|
362
|
-
withRetry(handler, {
|
|
363
|
-
maxRetries: 3,
|
|
364
|
-
transport: fastify.events.transport, // any EventTransport with deadLetter()
|
|
365
|
-
name: 'emailProcessor', // populates DeadLetteredEvent.handlerName
|
|
366
|
-
}),
|
|
367
|
-
);
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
On exhaustion, a typed `DeadLetteredEvent` envelope (original event + error +
|
|
371
|
-
attempts + first/last failure timestamps) is handed to `transport.deadLetter()`.
|
|
372
|
-
`onDead` still works — both fire when both are configured.
|
|
373
|
-
|
|
374
|
-
## Transactional Outbox (v2.9)
|
|
375
|
-
|
|
376
|
-
**Why the outbox exists:** you write a row + publish an event in the same user
|
|
377
|
-
request. If the transport (Redis/Kafka) is down at that moment, the row
|
|
378
|
-
commits but the event vanishes — silent data divergence. The outbox persists
|
|
379
|
-
the event in the **same DB transaction** as the row, then a background relayer
|
|
380
|
-
guarantees at-least-once delivery to the transport. Multi-worker claim +
|
|
381
|
-
retry/DLQ policy + dedupe make it scale.
|
|
382
|
-
|
|
383
|
-
`EventOutbox` now offers centralised retry/DLQ and typed DLQ query:
|
|
384
|
-
|
|
385
|
-
```typescript
|
|
386
|
-
import { EventOutbox, MemoryOutboxStore, exponentialBackoff } from '@classytic/arc/events';
|
|
387
|
-
|
|
388
|
-
const outbox = new EventOutbox({
|
|
389
|
-
store: new MemoryOutboxStore(), // swap for durable store in prod
|
|
390
|
-
transport: fastify.events.transport,
|
|
391
|
-
|
|
392
|
-
// Centralised retry/DLQ — no more hand-rolled exponentialBackoff at every fail site
|
|
393
|
-
failurePolicy: ({ attempts, error }) => {
|
|
394
|
-
if (attempts >= 5) return { deadLetter: true };
|
|
395
|
-
return { retryAt: exponentialBackoff({ attempt: attempts }) };
|
|
396
|
-
},
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
// meta.idempotencyKey auto-maps to OutboxWriteOptions.dedupeKey — duplicate
|
|
400
|
-
// saves with the same key are silently absorbed.
|
|
401
|
-
await outbox.store(
|
|
402
|
-
createEvent('order.placed', payload, { idempotencyKey: `order:${id}:placed` }),
|
|
403
|
-
);
|
|
404
|
-
|
|
405
|
-
// Rich per-batch outcome — deadLettered is new in v2.9
|
|
406
|
-
const result = await outbox.relayBatch();
|
|
407
|
-
// { relayed, attempted, publishFailed, ackFailed, ownershipMismatches,
|
|
408
|
-
// malformed, failHookErrors, deadLettered, usedPublishMany }
|
|
409
|
-
|
|
410
|
-
// Read DLQ state as typed DeadLetteredEvent[]
|
|
411
|
-
const dlq = await outbox.getDeadLettered(100);
|
|
412
|
-
for (const envelope of dlq) {
|
|
413
|
-
await alertOps(envelope); // event, error, attempts, firstFailedAt, lastFailedAt
|
|
414
|
-
}
|
|
415
|
-
```
|
|
416
|
-
|
|
417
|
-
**Store capability tiers:**
|
|
418
|
-
|
|
419
|
-
| Method | Required | What you lose without it |
|
|
420
|
-
|---|---|---|
|
|
421
|
-
| `save`, `getPending`, `acknowledge` | ✅ | — |
|
|
422
|
-
| `claimPending` | — | Multi-worker relay safety |
|
|
423
|
-
| `fail` | — | Retry / DLQ / per-event failure reporting |
|
|
424
|
-
| `getDeadLettered` | — | `outbox.getDeadLettered()` returns `[]` |
|
|
425
|
-
| `purge` | — | App owns retention (TTL index, cron DELETE, etc.) |
|
|
426
|
-
|
|
427
|
-
`MemoryOutboxStore` implements all capabilities — use it as a reference when
|
|
428
|
-
writing a durable store for Postgres / DynamoDB / your DB of choice.
|
|
429
|
-
|
|
430
|
-
### Durable store — pass a `RepositoryLike`
|
|
431
|
-
|
|
432
|
-
Arc adapts any `Repository` (mongokit / prismakit / your own kit) to the
|
|
433
|
-
`OutboxStore` contract — no dedicated subpath, no store class to
|
|
434
|
-
instantiate:
|
|
435
|
-
|
|
436
|
-
```typescript
|
|
437
|
-
import mongoose from 'mongoose';
|
|
438
|
-
import { Repository } from '@classytic/mongokit';
|
|
439
|
-
import { EventOutbox, exponentialBackoff, createEvent } from '@classytic/arc/events';
|
|
440
|
-
|
|
441
|
-
const OutboxModel = mongoose.model('ArcOutbox', OutboxSchema, 'arc_outbox_events');
|
|
442
|
-
|
|
443
|
-
const outbox = new EventOutbox({
|
|
444
|
-
repository: new Repository(OutboxModel),
|
|
445
|
-
transport: redisTransport,
|
|
446
|
-
failurePolicy: ({ attempts }) =>
|
|
447
|
-
attempts >= 5 ? { deadLetter: true } : { retryAt: exponentialBackoff({ attempt: attempts }) },
|
|
448
|
-
});
|
|
449
|
-
|
|
450
|
-
// Persist event in the same DB transaction as the row
|
|
451
|
-
await mongoose.connection.transaction(async (session) => {
|
|
452
|
-
await Order.create([orderDoc], { session });
|
|
453
|
-
await outbox.store(
|
|
454
|
-
createEvent('order.placed', { orderId }, { idempotencyKey: `order:${orderId}:placed` }),
|
|
455
|
-
{ session },
|
|
456
|
-
);
|
|
457
|
-
});
|
|
458
|
-
|
|
459
|
-
// Background relayer
|
|
460
|
-
setInterval(async () => {
|
|
461
|
-
const r = await outbox.relayBatch();
|
|
462
|
-
metrics.gauge('outbox.deadLettered', r.deadLettered);
|
|
463
|
-
}, 1000);
|
|
464
|
-
|
|
465
|
-
// Ops: read dead-letter envelopes for alerting / replay
|
|
466
|
-
const dlq = await outbox.getDeadLettered(100);
|
|
467
|
-
```
|
|
468
|
-
|
|
469
|
-
**What you get:**
|
|
470
|
-
|
|
471
|
-
- **Atomic multi-worker claim** — arc's adapter uses `findOneAndUpdate`
|
|
472
|
-
on `{ status: 'pending', visibleAt ≤ now, lease free }`. Two racing
|
|
473
|
-
relayers never see the same event; expired leases auto-recover.
|
|
474
|
-
- **Session threading** — `outbox.store(event, { session })` flows
|
|
475
|
-
through `Repository.create(doc, { session })` so the event commits
|
|
476
|
-
with your business write.
|
|
477
|
-
- **Dedupe** — `meta.idempotencyKey` maps to `dedupeKey`; your kit's
|
|
478
|
-
unique index (or equivalent) enforces idempotency.
|
|
479
|
-
- **DLQ** — `getDeadLettered(limit)` returns typed `DeadLetteredEvent[]`.
|
|
480
|
-
`RelayResult.deadLettered` counts per-batch transitions.
|
|
481
|
-
- **Purge** — `outbox.purge(olderThanMs)` deletes delivered rows; define
|
|
482
|
-
retention via a TTL index (`deliveredAt`), a cron, or a scheduler —
|
|
483
|
-
your kit's choice.
|
|
484
|
-
|
|
485
|
-
You own the schema and indexes. Recommended shape:
|
|
486
|
-
`{ eventId (unique), type, payload, meta, status, attempts, leaseOwner,
|
|
487
|
-
leaseExpiresAt, visibleAt, dedupeKey (unique sparse), lastError,
|
|
488
|
-
createdAt, deliveredAt }` with indexes on `{ status, visibleAt }` and
|
|
489
|
-
`{ deliveredAt }` (TTL).
|
|
1
|
+
# Arc Events System
|
|
2
|
+
|
|
3
|
+
Domain event pub/sub with pluggable transports. Auto-emits events on CRUD operations.
|
|
4
|
+
|
|
5
|
+
## Hooks vs Events
|
|
6
|
+
|
|
7
|
+
| Aspect | Hooks | Events |
|
|
8
|
+
|--------|-------|--------|
|
|
9
|
+
| Purpose | Internal lifecycle callbacks | External integration |
|
|
10
|
+
| Scope | Same process, synchronous flow | Cross-service, async |
|
|
11
|
+
| Use when | Validating, transforming, auditing | Notifying services, event-driven systems |
|
|
12
|
+
| Transport | In-process only | Pluggable (Memory → Redis → Kafka) |
|
|
13
|
+
| Pattern | `beforeCreate`, `afterUpdate` | `product.created`, `order.updated` |
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
The `createApp()` factory auto-registers `eventPlugin` — no manual registration needed:
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { createApp } from '@classytic/arc/factory';
|
|
21
|
+
|
|
22
|
+
// Development (in-memory, default — zero config)
|
|
23
|
+
const app = await createApp({ preset: 'development' });
|
|
24
|
+
// app.events is ready to use
|
|
25
|
+
|
|
26
|
+
// Production (Redis transport, with retry)
|
|
27
|
+
import { RedisEventTransport } from '@classytic/arc/events/redis';
|
|
28
|
+
|
|
29
|
+
const app = await createApp({
|
|
30
|
+
stores: { events: new RedisEventTransport(redis) },
|
|
31
|
+
arcPlugins: {
|
|
32
|
+
events: {
|
|
33
|
+
logEvents: true,
|
|
34
|
+
failOpen: true, // default: suppress transport failures
|
|
35
|
+
retry: { maxRetries: 3, backoffMs: 1000 },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Disable event plugin entirely
|
|
41
|
+
const app = await createApp({ arcPlugins: { events: false } });
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Manual registration** (for apps not using `createApp`):
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
import { eventPlugin } from '@classytic/arc/events';
|
|
48
|
+
|
|
49
|
+
await fastify.register(eventPlugin); // Memory transport
|
|
50
|
+
|
|
51
|
+
// Redis Pub/Sub
|
|
52
|
+
import { RedisEventTransport } from '@classytic/arc/events/redis';
|
|
53
|
+
await fastify.register(eventPlugin, {
|
|
54
|
+
transport: new RedisEventTransport(redisClient, { channel: 'arc-events' }),
|
|
55
|
+
logEvents: true,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Redis Streams (ordered, persistent, consumer groups)
|
|
59
|
+
import { RedisStreamTransport } from '@classytic/arc/events/redis-stream';
|
|
60
|
+
await fastify.register(eventPlugin, {
|
|
61
|
+
transport: new RedisStreamTransport(redisClient, {
|
|
62
|
+
stream: 'arc:events',
|
|
63
|
+
group: 'api-service',
|
|
64
|
+
consumer: 'worker-1',
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`failOpen` behavior:
|
|
70
|
+
- `true` (default): publish/subscribe/close transport errors are logged and suppressed.
|
|
71
|
+
- `false`: transport errors are thrown to caller.
|
|
72
|
+
|
|
73
|
+
## Auto-Emitted Events
|
|
74
|
+
|
|
75
|
+
BaseController automatically emits events when eventPlugin is registered:
|
|
76
|
+
|
|
77
|
+
| Operation | Event Type | Payload |
|
|
78
|
+
|-----------|------------|---------|
|
|
79
|
+
| `create()` | `{resource}.created` | Created document |
|
|
80
|
+
| `update()` | `{resource}.updated` | Updated document |
|
|
81
|
+
| `delete()` | `{resource}.deleted` | Deleted document |
|
|
82
|
+
| `restore()` | `{resource}.restored` | Restored document |
|
|
83
|
+
|
|
84
|
+
Disable per-controller: `super({ disableEvents: true })`
|
|
85
|
+
|
|
86
|
+
## Publishing & Subscribing
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
// Publish
|
|
90
|
+
await fastify.events.publish('order.created', {
|
|
91
|
+
orderId: 'order-123',
|
|
92
|
+
total: 99.99,
|
|
93
|
+
}, {
|
|
94
|
+
userId: request.user._id,
|
|
95
|
+
organizationId: getOrgId(request.scope),
|
|
96
|
+
correlationId: request.id,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Subscribe to specific event
|
|
100
|
+
await fastify.events.subscribe('order.created', async (event) => {
|
|
101
|
+
await sendConfirmation(event.payload);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Subscribe to pattern (wildcard)
|
|
105
|
+
await fastify.events.subscribe('order.*', async (event) => {
|
|
106
|
+
await updateAnalytics(event.type, event.payload);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Subscribe to all
|
|
110
|
+
await fastify.events.subscribe('*', async (event) => {
|
|
111
|
+
await auditLog.create(event);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Unsubscribe
|
|
115
|
+
const unsub = await fastify.events.subscribe('order.created', handler);
|
|
116
|
+
unsub();
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Event Structure (v2.9)
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
interface EventMeta {
|
|
123
|
+
id: string; // UUID v4 (fresh per emit; a retry gets a new id)
|
|
124
|
+
timestamp: Date;
|
|
125
|
+
schemaVersion?: number; // bump on payload breaking change
|
|
126
|
+
correlationId?: string; // stable across causal chain
|
|
127
|
+
causationId?: string; // direct parent event id
|
|
128
|
+
partitionKey?: string; // ordering hint (Kafka/Kinesis/Streams)
|
|
129
|
+
source?: string; // originating service/package ('commerce', 'billing')
|
|
130
|
+
idempotencyKey?: string; // cross-transport dedupe hint — stable per operation
|
|
131
|
+
resource?: string;
|
|
132
|
+
resourceId?: string;
|
|
133
|
+
userId?: string;
|
|
134
|
+
organizationId?: string;
|
|
135
|
+
aggregate?: { type: string; id: string }; // DDD aggregate marker
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface DomainEvent<T> {
|
|
139
|
+
type: string; // e.g., 'order.created'
|
|
140
|
+
payload: T;
|
|
141
|
+
meta: EventMeta;
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Arc is source of truth** — `@classytic/primitives/events` mirrors these shapes. Downstream packages import from primitives; arc owns evolution.
|
|
146
|
+
|
|
147
|
+
### DDD aggregate narrowing
|
|
148
|
+
|
|
149
|
+
`aggregate.type` is `string` in arc's base contract so it stays framework-neutral. Domain packages narrow it to a closed union via interface extension:
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
// @classytic/cart
|
|
153
|
+
type CartAggregateType = 'cart' | 'cart-item';
|
|
154
|
+
|
|
155
|
+
interface CartEventMeta extends EventMeta {
|
|
156
|
+
aggregate?: { type: CartAggregateType; id: string };
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Unlike `correlationId` / `causationId`, `aggregate` is **not inherited** by `createChildEvent`. Child events usually belong to a different aggregate (e.g. an `order.placed` event emitted by the order aggregate spawns `inventory.reserved` owned by the inventory aggregate). Each event names its own aggregate explicitly.
|
|
161
|
+
|
|
162
|
+
### Causation chains
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
import { createEvent, createChildEvent } from '@classytic/arc/events';
|
|
166
|
+
|
|
167
|
+
const placed = createEvent('order.placed', { orderId: 'o1' }, {
|
|
168
|
+
correlationId: req.id, userId: user.id,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Downstream handler emits child — causation linked, correlation inherited:
|
|
172
|
+
const reserved = createChildEvent(placed, 'inventory.reserved', { sku: 'a' });
|
|
173
|
+
// reserved.meta.causationId === placed.meta.id
|
|
174
|
+
// reserved.meta.correlationId === placed.meta.correlationId
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Dead-letter contract
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
import type { DeadLetteredEvent, EventTransport } from '@classytic/arc/events';
|
|
181
|
+
|
|
182
|
+
class KafkaTransport implements EventTransport {
|
|
183
|
+
async deadLetter(dlq: DeadLetteredEvent) {
|
|
184
|
+
await producer.send({ topic: `${dlq.event.type}.DLQ`, messages: [{ value: JSON.stringify(dlq) }] });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Custom Transport
|
|
190
|
+
|
|
191
|
+
Implement `EventTransport` for RabbitMQ, Kafka, etc.:
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
import type { EventTransport, DomainEvent } from '@classytic/arc/events';
|
|
195
|
+
|
|
196
|
+
class KafkaTransport implements EventTransport {
|
|
197
|
+
readonly name = 'kafka';
|
|
198
|
+
|
|
199
|
+
async publish(event: DomainEvent): Promise<void> {
|
|
200
|
+
await this.producer.send({ topic: event.type, messages: [{ value: JSON.stringify(event) }] });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async subscribe(pattern: string, handler: EventHandler): Promise<() => void> {
|
|
204
|
+
// Subscribe to Kafka topic matching pattern
|
|
205
|
+
return () => { /* unsubscribe */ };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async close(): Promise<void> {
|
|
209
|
+
await this.producer.disconnect();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Built-in Transports
|
|
215
|
+
|
|
216
|
+
| Transport | Import | Use Case |
|
|
217
|
+
|-----------|--------|----------|
|
|
218
|
+
| Memory | `@classytic/arc/events` (default) | Development, testing, single-instance |
|
|
219
|
+
| Redis Pub/Sub | `@classytic/arc/events/redis` | Multi-instance, real-time |
|
|
220
|
+
| Redis Streams | `@classytic/arc/events/redis-stream` | Ordered, persistent, consumer groups |
|
|
221
|
+
|
|
222
|
+
### Streams vs Pub/Sub — pick the right one
|
|
223
|
+
|
|
224
|
+
Choosing wrong loses messages silently. Default to **Streams** for anything business-critical.
|
|
225
|
+
|
|
226
|
+
| Requirement | Use |
|
|
227
|
+
|---|---|
|
|
228
|
+
| Message MUST NOT be lost (billing, payments, audit) | **Streams** |
|
|
229
|
+
| Real-time notifications, OK to miss when no subscriber is up | Pub/Sub |
|
|
230
|
+
| Need to replay/reprocess past events | **Streams** |
|
|
231
|
+
| Multiple workers processing the same queue | **Streams** (consumer groups) |
|
|
232
|
+
| Simple broadcast to live WebSocket clients | Pub/Sub |
|
|
233
|
+
| Event sourcing or audit trail | **Streams** |
|
|
234
|
+
| Single-instance dev | Memory |
|
|
235
|
+
| At-least-once delivery with durable WAL | **Streams** + outbox pattern |
|
|
236
|
+
|
|
237
|
+
**Why it matters:** Pub/Sub is fire-and-forget. If no subscriber is connected when you publish, the message is gone. Streams persist until every consumer group acknowledges them — crashes, restarts, and network blips are survivable.
|
|
238
|
+
|
|
239
|
+
**Defense-in-depth:** pair `eventPlugin` with the transactional outbox (`EventOutbox` + `MemoryOutboxStore` or your own persistent store) for guaranteed delivery even if Redis is unreachable at publish time.
|
|
240
|
+
|
|
241
|
+
### Redis eviction policy — required for queues and idempotency
|
|
242
|
+
|
|
243
|
+
When you back events (Streams), jobs (BullMQ), idempotency, or cache with Redis, your Redis instance **must** be configured with `maxmemory-policy: noeviction`. Any other policy can silently evict in-flight stream entries or pending jobs.
|
|
244
|
+
|
|
245
|
+
- **Self-hosted Redis:** `redis-cli CONFIG SET maxmemory-policy noeviction` (or set in `redis.conf`).
|
|
246
|
+
- **Upstash:** free/paid DBs default to `optimistic-volatile`. You'll see `IMPORTANT! Eviction policy is optimistic-volatile. It should be "noeviction"` in BullMQ logs. **Do one of:** open a support ticket to request `noeviction`, use a dedicated DB for queues, or accept that long-idle jobs may be evicted.
|
|
247
|
+
- **ElastiCache / Redis Cloud:** set the parameter group's `maxmemory-policy` to `noeviction` before pointing arc at it.
|
|
248
|
+
|
|
249
|
+
For a pure cache DB (no queues, no idempotency), `allkeys-lru` is correct and what you want.
|
|
250
|
+
|
|
251
|
+
## Injectable Logger
|
|
252
|
+
|
|
253
|
+
All transports and retry accept a `logger` option — defaults to `console`, compatible with pino/fastify.log:
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
import type { EventLogger } from '@classytic/arc/events';
|
|
257
|
+
|
|
258
|
+
// Interface: { warn(msg, ...args): void; error(msg, ...args): void }
|
|
259
|
+
|
|
260
|
+
// Use Fastify's logger
|
|
261
|
+
await fastify.register(eventPlugin, {
|
|
262
|
+
transport: new RedisEventTransport(redisClient, {
|
|
263
|
+
logger: fastify.log, // pino logger
|
|
264
|
+
}),
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Use with Memory transport
|
|
268
|
+
new MemoryEventTransport({ logger: fastify.log });
|
|
269
|
+
|
|
270
|
+
// Use with Redis Streams
|
|
271
|
+
new RedisStreamTransport(redisClient, { logger: fastify.log });
|
|
272
|
+
|
|
273
|
+
// Use with retry wrapper
|
|
274
|
+
import { withRetry } from '@classytic/arc/events';
|
|
275
|
+
const retried = withRetry(handler, { maxRetries: 3, logger: fastify.log });
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
| Component | File | `logger` option |
|
|
279
|
+
|-----------|------|-----------------|
|
|
280
|
+
| `MemoryEventTransport` | `EventTransport.ts` | `MemoryEventTransportOptions.logger` |
|
|
281
|
+
| `withRetry()` | `retry.ts` | `RetryOptions.logger` |
|
|
282
|
+
| `RedisEventTransport` | `transports/redis.ts` | `RedisEventTransportOptions.logger` |
|
|
283
|
+
| `RedisStreamTransport` | `transports/redis-stream.ts` | `RedisStreamTransportOptions.logger` |
|
|
284
|
+
|
|
285
|
+
## Typed Events — defineEvent & Event Registry
|
|
286
|
+
|
|
287
|
+
Declare events with schemas for runtime validation and introspection:
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
import { defineEvent, createEventRegistry } from '@classytic/arc/events';
|
|
291
|
+
|
|
292
|
+
const OrderCreated = defineEvent({
|
|
293
|
+
name: 'order.created',
|
|
294
|
+
version: 1,
|
|
295
|
+
description: 'Emitted when an order is placed',
|
|
296
|
+
schema: {
|
|
297
|
+
type: 'object',
|
|
298
|
+
properties: {
|
|
299
|
+
orderId: { type: 'string' },
|
|
300
|
+
total: { type: 'number' },
|
|
301
|
+
},
|
|
302
|
+
required: ['orderId', 'total'],
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Type-safe event creation
|
|
307
|
+
const event = OrderCreated.create({ orderId: 'o-1', total: 100 }, { userId: 'user-1' });
|
|
308
|
+
await app.events.publish(event.type, event.payload, event.meta);
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
**Event Registry** — catalog + auto-validation on publish:
|
|
312
|
+
|
|
313
|
+
```typescript
|
|
314
|
+
const registry = createEventRegistry();
|
|
315
|
+
registry.register(OrderCreated);
|
|
316
|
+
|
|
317
|
+
const app = await createApp({
|
|
318
|
+
arcPlugins: {
|
|
319
|
+
events: { registry, validateMode: 'warn' },
|
|
320
|
+
// 'warn' (default): log warning, still publish
|
|
321
|
+
// 'reject': throw error, do NOT publish
|
|
322
|
+
// 'off': registry is introspection-only
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Introspect at runtime
|
|
327
|
+
app.events.registry?.catalog();
|
|
328
|
+
// → [{ name: 'order.created', version: 1, schema: {...} }, ...]
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
## QueryCache Integration
|
|
332
|
+
|
|
333
|
+
QueryCache uses events for auto-invalidation. When `arcPlugins.queryCache` is enabled, all CRUD events automatically bump resource versions, invalidating cached queries — zero config required.
|
|
334
|
+
|
|
335
|
+
## Retry Logic
|
|
336
|
+
|
|
337
|
+
Events module includes retry with exponential backoff for failed handlers:
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
import { withRetry } from '@classytic/arc/events';
|
|
341
|
+
|
|
342
|
+
const retriedHandler = withRetry(async (event) => {
|
|
343
|
+
await processEvent(event);
|
|
344
|
+
}, {
|
|
345
|
+
maxRetries: 3, // default: 3
|
|
346
|
+
backoffMs: 1000, // initial delay, doubles each retry (default: 1000)
|
|
347
|
+
maxBackoffMs: 30000, // cap (default: 30000)
|
|
348
|
+
logger: fastify.log, // default: console
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
await fastify.events.subscribe('order.created', retriedHandler);
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
### Auto-route exhausted events to transport.deadLetter() (v2.9)
|
|
355
|
+
|
|
356
|
+
For transports with a native DLQ (Kafka DLQ topic, SQS DLQ queue, etc.), pass
|
|
357
|
+
`transport` to skip custom `$deadLetter` plumbing:
|
|
358
|
+
|
|
359
|
+
```typescript
|
|
360
|
+
await fastify.events.subscribe(
|
|
361
|
+
'order.created',
|
|
362
|
+
withRetry(handler, {
|
|
363
|
+
maxRetries: 3,
|
|
364
|
+
transport: fastify.events.transport, // any EventTransport with deadLetter()
|
|
365
|
+
name: 'emailProcessor', // populates DeadLetteredEvent.handlerName
|
|
366
|
+
}),
|
|
367
|
+
);
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
On exhaustion, a typed `DeadLetteredEvent` envelope (original event + error +
|
|
371
|
+
attempts + first/last failure timestamps) is handed to `transport.deadLetter()`.
|
|
372
|
+
`onDead` still works — both fire when both are configured.
|
|
373
|
+
|
|
374
|
+
## Transactional Outbox (v2.9)
|
|
375
|
+
|
|
376
|
+
**Why the outbox exists:** you write a row + publish an event in the same user
|
|
377
|
+
request. If the transport (Redis/Kafka) is down at that moment, the row
|
|
378
|
+
commits but the event vanishes — silent data divergence. The outbox persists
|
|
379
|
+
the event in the **same DB transaction** as the row, then a background relayer
|
|
380
|
+
guarantees at-least-once delivery to the transport. Multi-worker claim +
|
|
381
|
+
retry/DLQ policy + dedupe make it scale.
|
|
382
|
+
|
|
383
|
+
`EventOutbox` now offers centralised retry/DLQ and typed DLQ query:
|
|
384
|
+
|
|
385
|
+
```typescript
|
|
386
|
+
import { EventOutbox, MemoryOutboxStore, exponentialBackoff } from '@classytic/arc/events';
|
|
387
|
+
|
|
388
|
+
const outbox = new EventOutbox({
|
|
389
|
+
store: new MemoryOutboxStore(), // swap for durable store in prod
|
|
390
|
+
transport: fastify.events.transport,
|
|
391
|
+
|
|
392
|
+
// Centralised retry/DLQ — no more hand-rolled exponentialBackoff at every fail site
|
|
393
|
+
failurePolicy: ({ attempts, error }) => {
|
|
394
|
+
if (attempts >= 5) return { deadLetter: true };
|
|
395
|
+
return { retryAt: exponentialBackoff({ attempt: attempts }) };
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// meta.idempotencyKey auto-maps to OutboxWriteOptions.dedupeKey — duplicate
|
|
400
|
+
// saves with the same key are silently absorbed.
|
|
401
|
+
await outbox.store(
|
|
402
|
+
createEvent('order.placed', payload, { idempotencyKey: `order:${id}:placed` }),
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
// Rich per-batch outcome — deadLettered is new in v2.9
|
|
406
|
+
const result = await outbox.relayBatch();
|
|
407
|
+
// { relayed, attempted, publishFailed, ackFailed, ownershipMismatches,
|
|
408
|
+
// malformed, failHookErrors, deadLettered, usedPublishMany }
|
|
409
|
+
|
|
410
|
+
// Read DLQ state as typed DeadLetteredEvent[]
|
|
411
|
+
const dlq = await outbox.getDeadLettered(100);
|
|
412
|
+
for (const envelope of dlq) {
|
|
413
|
+
await alertOps(envelope); // event, error, attempts, firstFailedAt, lastFailedAt
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
**Store capability tiers:**
|
|
418
|
+
|
|
419
|
+
| Method | Required | What you lose without it |
|
|
420
|
+
|---|---|---|
|
|
421
|
+
| `save`, `getPending`, `acknowledge` | ✅ | — |
|
|
422
|
+
| `claimPending` | — | Multi-worker relay safety |
|
|
423
|
+
| `fail` | — | Retry / DLQ / per-event failure reporting |
|
|
424
|
+
| `getDeadLettered` | — | `outbox.getDeadLettered()` returns `[]` |
|
|
425
|
+
| `purge` | — | App owns retention (TTL index, cron DELETE, etc.) |
|
|
426
|
+
|
|
427
|
+
`MemoryOutboxStore` implements all capabilities — use it as a reference when
|
|
428
|
+
writing a durable store for Postgres / DynamoDB / your DB of choice.
|
|
429
|
+
|
|
430
|
+
### Durable store — pass a `RepositoryLike`
|
|
431
|
+
|
|
432
|
+
Arc adapts any `Repository` (mongokit / prismakit / your own kit) to the
|
|
433
|
+
`OutboxStore` contract — no dedicated subpath, no store class to
|
|
434
|
+
instantiate:
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
import mongoose from 'mongoose';
|
|
438
|
+
import { Repository } from '@classytic/mongokit';
|
|
439
|
+
import { EventOutbox, exponentialBackoff, createEvent } from '@classytic/arc/events';
|
|
440
|
+
|
|
441
|
+
const OutboxModel = mongoose.model('ArcOutbox', OutboxSchema, 'arc_outbox_events');
|
|
442
|
+
|
|
443
|
+
const outbox = new EventOutbox({
|
|
444
|
+
repository: new Repository(OutboxModel),
|
|
445
|
+
transport: redisTransport,
|
|
446
|
+
failurePolicy: ({ attempts }) =>
|
|
447
|
+
attempts >= 5 ? { deadLetter: true } : { retryAt: exponentialBackoff({ attempt: attempts }) },
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// Persist event in the same DB transaction as the row
|
|
451
|
+
await mongoose.connection.transaction(async (session) => {
|
|
452
|
+
await Order.create([orderDoc], { session });
|
|
453
|
+
await outbox.store(
|
|
454
|
+
createEvent('order.placed', { orderId }, { idempotencyKey: `order:${orderId}:placed` }),
|
|
455
|
+
{ session },
|
|
456
|
+
);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// Background relayer
|
|
460
|
+
setInterval(async () => {
|
|
461
|
+
const r = await outbox.relayBatch();
|
|
462
|
+
metrics.gauge('outbox.deadLettered', r.deadLettered);
|
|
463
|
+
}, 1000);
|
|
464
|
+
|
|
465
|
+
// Ops: read dead-letter envelopes for alerting / replay
|
|
466
|
+
const dlq = await outbox.getDeadLettered(100);
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
**What you get:**
|
|
470
|
+
|
|
471
|
+
- **Atomic multi-worker claim** — arc's adapter uses `findOneAndUpdate`
|
|
472
|
+
on `{ status: 'pending', visibleAt ≤ now, lease free }`. Two racing
|
|
473
|
+
relayers never see the same event; expired leases auto-recover.
|
|
474
|
+
- **Session threading** — `outbox.store(event, { session })` flows
|
|
475
|
+
through `Repository.create(doc, { session })` so the event commits
|
|
476
|
+
with your business write.
|
|
477
|
+
- **Dedupe** — `meta.idempotencyKey` maps to `dedupeKey`; your kit's
|
|
478
|
+
unique index (or equivalent) enforces idempotency.
|
|
479
|
+
- **DLQ** — `getDeadLettered(limit)` returns typed `DeadLetteredEvent[]`.
|
|
480
|
+
`RelayResult.deadLettered` counts per-batch transitions.
|
|
481
|
+
- **Purge** — `outbox.purge(olderThanMs)` deletes delivered rows; define
|
|
482
|
+
retention via a TTL index (`deliveredAt`), a cron, or a scheduler —
|
|
483
|
+
your kit's choice.
|
|
484
|
+
|
|
485
|
+
You own the schema and indexes. Recommended shape:
|
|
486
|
+
`{ eventId (unique), type, payload, meta, status, attempts, leaseOwner,
|
|
487
|
+
leaseExpiresAt, visibleAt, dedupeKey (unique sparse), lastError,
|
|
488
|
+
createdAt, deliveredAt }` with indexes on `{ status, visibleAt }` and
|
|
489
|
+
`{ deliveredAt }` (TTL).
|