@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.
Files changed (39) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +8 -14
  3. package/bin/arc.js +2 -2
  4. package/dist/{BaseController-JNV08qOT.mjs → BaseController-swXruJ2_.mjs} +2 -2
  5. package/dist/{actionPermissions-C8YYU92K.mjs → actionPermissions-sUUKDhtP.mjs} +4 -2
  6. package/dist/auth/index.mjs +1 -1
  7. package/dist/cli/commands/docs.mjs +1 -1
  8. package/dist/cli/commands/generate.d.mts +0 -2
  9. package/dist/cli/commands/generate.mjs +15 -15
  10. package/dist/cli/commands/init.mjs +24 -22
  11. package/dist/context/index.mjs +1 -1
  12. package/dist/core/index.mjs +3 -3
  13. package/dist/{core-DXdSSFW-.mjs → core-DnUsRpuX.mjs} +20 -8
  14. package/dist/{createActionRouter-BwaSM0No.mjs → createActionRouter-u3ql2EDo.mjs} +73 -13
  15. package/dist/{createApp-P1d6rjPy.mjs → createApp-BFxtdKy6.mjs} +1 -1
  16. package/dist/docs/index.mjs +1 -1
  17. package/dist/{eventPlugin--5HIkdPU.mjs → eventPlugin-KrFIQ097.mjs} +1 -1
  18. package/dist/events/index.mjs +11 -3
  19. package/dist/factory/index.mjs +1 -1
  20. package/dist/index.mjs +6 -6
  21. package/dist/integrations/mcp/index.mjs +1 -1
  22. package/dist/integrations/mcp/testing.mjs +1 -1
  23. package/dist/{openapi-C0L9ar7m.mjs → openapi-BGUn7Ki1.mjs} +2 -2
  24. package/dist/permissions/index.mjs +1 -1
  25. package/dist/{permissions-B4vU9L0Q.mjs → permissions-gd_aUWrR.mjs} +42 -0
  26. package/dist/plugins/index.mjs +1 -1
  27. package/dist/plugins/tracing-entry.mjs +1 -1
  28. package/dist/presets/filesUpload.mjs +1 -1
  29. package/dist/presets/index.mjs +1 -1
  30. package/dist/presets/search.mjs +1 -1
  31. package/dist/{presets-k604Lj99.mjs → presets-Z7P5w4gF.mjs} +1 -1
  32. package/dist/{requestContext-CfRkaxwf.mjs → requestContext-C5XeK3VA.mjs} +15 -0
  33. package/dist/{resourceToTools--okX6QBr.mjs → resourceToTools-ByZpgjeH.mjs} +5 -4
  34. package/dist/{routerShared-DeESFp4a.mjs → routerShared-BqLRb5l7.mjs} +60 -3
  35. package/dist/testing/index.mjs +1 -1
  36. package/dist/utils/index.mjs +1 -1
  37. package/dist/{utils-D3Yxnrwr.mjs → utils-CcYTj09l.mjs} +1 -1
  38. package/package.json +3 -1
  39. 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).