outbox-event-bus 1.0.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/README.md +510 -0
- package/dist/index.cjs +574 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +238 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +238 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +554 -0
- package/dist/index.mjs.map +1 -0
- package/docs/API_REFERENCE.md +717 -0
- package/docs/CONTRIBUTING.md +43 -0
- package/docs/PUBLISHING.md +144 -0
- package/docs/docs/API_REFERENCE.md +717 -0
- package/docs/docs/CONTRIBUTING.md +43 -0
- package/docs/docs/PUBLISHING.md +144 -0
- package/docs/docs/images/choose_publisher.png +0 -0
- package/docs/docs/images/event_life_cycle.png +0 -0
- package/docs/docs/images/problem.png +0 -0
- package/docs/docs/images/solution.png +0 -0
- package/docs/images/choose_publisher.png +0 -0
- package/docs/images/event_life_cycle.png +0 -0
- package/docs/images/problem.png +0 -0
- package/docs/images/solution.png +0 -0
- package/package.json +49 -0
- package/src/bus/outbox-event-bus.test.ts +305 -0
- package/src/bus/outbox-event-bus.ts +167 -0
- package/src/errors/error-reporting.ts +20 -0
- package/src/errors/error-utils.ts +3 -0
- package/src/errors/errors.test.ts +142 -0
- package/src/errors/errors.ts +153 -0
- package/src/errors/index.ts +2 -0
- package/src/in-memory-outbox/in-memory-outbox.ts +129 -0
- package/src/index.ts +10 -0
- package/src/outboxes/in-memory/failed-events.test.ts +64 -0
- package/src/outboxes/in-memory/in-memory-outbox-unit.test.ts +150 -0
- package/src/outboxes/in-memory/in-memory-outbox.test.ts +380 -0
- package/src/outboxes/in-memory/in-memory-outbox.ts +128 -0
- package/src/services/event-publisher.test.ts +93 -0
- package/src/services/event-publisher.ts +75 -0
- package/src/services/polling-service.test.ts +80 -0
- package/src/services/polling-service.ts +92 -0
- package/src/types/interfaces.ts +80 -0
- package/src/types/types.ts +57 -0
- package/src/utils/batcher.ts +64 -0
- package/src/utils/promise-utils.ts +13 -0
- package/src/utils/retry.ts +23 -0
- package/src/utils/time-utils.test.ts +97 -0
- package/src/utils/time-utils.ts +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
# outbox-event-bus
|
|
2
|
+
|
|
3
|
+
<div align="center">
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
> **Never Lose an Event Again**
|
|
13
|
+
>
|
|
14
|
+
> Transactional outbox pattern made simple. Persist events atomically with your data. Guaranteed delivery with your database.
|
|
15
|
+
|
|
16
|
+
**The Problem**: You save data to your database and attempt to emit a relevant event. If your process crashes or the network fails before the event is sent, your system becomes inconsistent.
|
|
17
|
+
|
|
18
|
+

|
|
19
|
+
|
|
20
|
+
**The Solution**: `outbox-event-bus` stores events in your database *within the same transaction* as your data. A background worker then reliably delivers them.
|
|
21
|
+
|
|
22
|
+

|
|
23
|
+
|
|
24
|
+
## Quick Start (Postgres + Drizzle ORM + SQS Example)
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install outbox-event-bus @outbox-event-bus/postgres-drizzle-outbox drizzle-orm @outbox-event-bus/sqs-publisher
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
import { OutboxEventBus } from 'outbox-event-bus';
|
|
32
|
+
import { PostgresDrizzleOutbox } from '@outbox-event-bus/postgres-drizzle-outbox';
|
|
33
|
+
import { SQSPublisher } from '@outbox-event-bus/sqs-publisher';
|
|
34
|
+
|
|
35
|
+
// 1. Setup
|
|
36
|
+
const outbox = new PostgresDrizzleOutbox({ db });
|
|
37
|
+
const bus = new OutboxEventBus(outbox, (error: OutboxError) => console.error(error));
|
|
38
|
+
|
|
39
|
+
// Forward messages to SQS
|
|
40
|
+
const sqsClient = new SQSClient({ region: 'us-east-1' });
|
|
41
|
+
const publisher = new SQSPublisher(bus, { queueUrl: '...', sqsClient });
|
|
42
|
+
publisher.subscribe(['sync-to-sqs']);
|
|
43
|
+
|
|
44
|
+
// 2. Register Handlers
|
|
45
|
+
bus.on('user.created', async (event) => {
|
|
46
|
+
// Fan Out as needed
|
|
47
|
+
await bus.emitMany([
|
|
48
|
+
{ type: 'send.welcome', payload: event.payload },
|
|
49
|
+
{ type: 'sync-to-sqs', payload: event.payload }
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
bus.on('send.welcome', async (event) => {
|
|
54
|
+
await emailService.sendWelcome(event.payload.email);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
// 3. Start the Bus
|
|
59
|
+
bus.start();
|
|
60
|
+
|
|
61
|
+
// 4. Emit Transactionally
|
|
62
|
+
await db.transaction(async (transaction) => {
|
|
63
|
+
const [user] = await transaction.insert(users).values(newUser).returning();
|
|
64
|
+
|
|
65
|
+
// Both operations commit together or rollback together
|
|
66
|
+
await bus.emit({ type: 'user.created', payload: user }, transaction);
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Why outbox-event-bus?
|
|
71
|
+
|
|
72
|
+
- **Zero Event Loss**: Events persist atomically with your data using database transactions.
|
|
73
|
+
- **Storage Agnostic**: Works with any database. **Use our built-in adapters** for Postgres, MongoDB, DynamoDB, Redis, and SQLite, or create your own.
|
|
74
|
+
- **Guaranteed Delivery**: At-least-once semantics with exponential backoff and dead letter handling.
|
|
75
|
+
- **Safe Retries**: Strict 1:1 command bus pattern prevents duplicate side-effects.
|
|
76
|
+
- **Built-in Publishers**: Comes with optional publishers for SQS, SNS, Kafka, RabbitMQ, Redis Streams, and EventBridge
|
|
77
|
+
- **Typed Error Handling**: Comprehensive typed errors for precise control over failure scenarios and recovery strategies.
|
|
78
|
+
- **TypeScript First**: Full type safety with generics for events, payloads, and transactions.
|
|
79
|
+
|
|
80
|
+
## Contents
|
|
81
|
+
|
|
82
|
+
- [Concepts](#concepts)
|
|
83
|
+
- [How-To Guides](#how-to-guides)
|
|
84
|
+
- [API Reference](./docs/API_REFERENCE.md)
|
|
85
|
+
- [Adapters & Publishers](#adapters--publishers)
|
|
86
|
+
- [Production Guide](#production-guide)
|
|
87
|
+
- [Contributing](./docs/CONTRIBUTING.md)
|
|
88
|
+
- [License](#license)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
## Concepts
|
|
93
|
+
|
|
94
|
+
### Strict 1:1 Command Bus Pattern
|
|
95
|
+
|
|
96
|
+
> [!IMPORTANT]
|
|
97
|
+
> This library enforces a **Command Bus pattern**: Each event type can have exactly **one** handler.
|
|
98
|
+
|
|
99
|
+
**Why?**
|
|
100
|
+
- If you have 2 handlers and one fails, retrying the event would re-run the successful handler too (double side-effects)
|
|
101
|
+
- Example: `user.created` triggers "Send Welcome Email", "Send Analytics" and "Sync to SQS". If "Send Analytics" fails and retries, "Send Welcome Email" runs again → duplicate emails
|
|
102
|
+
|
|
103
|
+
**Solution:** Strict 1:1 binding ensures that if a handler fails, only that specific logic is retried.
|
|
104
|
+
|
|
105
|
+
**Fan-Out Pattern:** If you need multiple actions, publish new "intent events" from your handler:
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
// Main handler
|
|
109
|
+
bus.on('user.created', async (event) => {
|
|
110
|
+
// Fan-out: Publish intent events back to the outbox
|
|
111
|
+
await bus.emitMany([
|
|
112
|
+
{ type: 'send.welcome', payload: event.payload },
|
|
113
|
+
{ type: 'send.analytics', payload: event.payload },
|
|
114
|
+
{ type: 'sync-to-sqs', payload: event.payload }
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Specialized handlers (1:1)
|
|
119
|
+
bus.on('send.welcome', async (event) => {
|
|
120
|
+
await emailService.sendWelcome(event.payload.email);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
bus.on('send.analytics', async (event) => {
|
|
124
|
+
await analyticsService.track(event.payload);
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Event Lifecycle
|
|
129
|
+
|
|
130
|
+
Events flow through several states from creation to completion:
|
|
131
|
+
|
|
132
|
+

|
|
133
|
+
|
|
134
|
+
**State Descriptions:**
|
|
135
|
+
|
|
136
|
+
| State | Description | Next States |
|
|
137
|
+
|:---|:---|:---|
|
|
138
|
+
| `created` | Event saved to outbox, waiting to be processed | `active` (claimed by worker) |
|
|
139
|
+
| `active` | Event claimed by worker, handler executing | `completed` (success), `failed` (error), `active` (timeout) |
|
|
140
|
+
| `completed` | Handler succeeded, event ready for archival | `archived` (maintenance) |
|
|
141
|
+
| `failed` | Error occurred (retry pending or max retries exceeded) | `active` (retry), Manual intervention (max retries) |
|
|
142
|
+
|
|
143
|
+
**Stuck Event Recovery:**
|
|
144
|
+
|
|
145
|
+
If a worker crashes while processing an event (status: `active`), the event becomes "stuck". The outbox adapter automatically reclaims stuck events after `processingTimeoutMs` (default: 30s).
|
|
146
|
+
|
|
147
|
+
**SQL Example (Postgres/SQLite):**
|
|
148
|
+
```sql
|
|
149
|
+
-- Events stuck for more than 30 seconds are reclaimed
|
|
150
|
+
SELECT * FROM outbox_events
|
|
151
|
+
WHERE status = 'active'
|
|
152
|
+
AND keep_alive < NOW() - INTERVAL '30 seconds';
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
> **Note:** Non-SQL adapters (DynamoDB, Redis, Mongo) implement equivalent recovery mechanisms using their native features (TTL, Sorted Sets, etc).
|
|
156
|
+
|
|
157
|
+
## How-To Guides
|
|
158
|
+
|
|
159
|
+
### Working with Transactions (Prisma + Postgres Example)
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
import { PrismaClient } from '@prisma/client';
|
|
163
|
+
import { PostgresPrismaOutbox } from '@outbox-event-bus/postgres-prisma-outbox';
|
|
164
|
+
import { OutboxEventBus } from 'outbox-event-bus';
|
|
165
|
+
|
|
166
|
+
const prisma = new PrismaClient();
|
|
167
|
+
const outbox = new PostgresPrismaOutbox({ prisma });
|
|
168
|
+
const bus = new OutboxEventBus(outbox, (error) => console.error(error));
|
|
169
|
+
|
|
170
|
+
bus.start();
|
|
171
|
+
|
|
172
|
+
// Register handler
|
|
173
|
+
bus.on('user.created', async (event) => {
|
|
174
|
+
await emailService.sendWelcome(event.payload.email);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Emit within a transaction
|
|
178
|
+
async function createUser(userData: any) {
|
|
179
|
+
await prisma.$transaction(async (transaction) => {
|
|
180
|
+
// 1. Create user
|
|
181
|
+
const user = await transaction.user.create({ data: userData });
|
|
182
|
+
|
|
183
|
+
// 2. Emit event (will commit with the user creation)
|
|
184
|
+
await bus.emit({ type: 'user.created', payload: user }, transaction);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Async Transactions (SQLite + better-sqlite3 Example)
|
|
190
|
+
|
|
191
|
+
SQLite transactions are synchronous by default. To use `await` with the event bus, use the `withBetterSqlite3Transaction` helper which manages the transaction scope for you.
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
import Database from 'better-sqlite3';
|
|
195
|
+
import { SqliteBetterSqlite3Outbox, withBetterSqlite3Transaction, getBetterSqlite3Transaction } from '@outbox-event-bus/sqlite-better-sqlite3-outbox';
|
|
196
|
+
import { OutboxEventBus } from 'outbox-event-bus';
|
|
197
|
+
|
|
198
|
+
const db = new Database('app.db');
|
|
199
|
+
const outbox = new SqliteBetterSqlite3Outbox({
|
|
200
|
+
dbPath: 'app.db',
|
|
201
|
+
getTransaction: getBetterSqlite3Transaction()
|
|
202
|
+
});
|
|
203
|
+
const bus = new OutboxEventBus(outbox, (error) => console.error(error));
|
|
204
|
+
|
|
205
|
+
bus.start();
|
|
206
|
+
|
|
207
|
+
async function createUser(userData: any) {
|
|
208
|
+
return withBetterSqlite3Transaction(db, async (transaction) => {
|
|
209
|
+
const stmt = transaction.prepare('INSERT INTO users (name) VALUES (?)');
|
|
210
|
+
const info = stmt.run(userData.name);
|
|
211
|
+
|
|
212
|
+
await bus.emit({
|
|
213
|
+
type: 'user.created',
|
|
214
|
+
payload: { id: info.lastInsertRowid, ...userData }
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
return info;
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
> **Note:** Similar helpers (`withPrismaTransaction`, `withDrizzleTransaction`, `withMongodbTransaction`, etc.) are available in other adapters to simplify transaction management and avoid passing transaction objects manually.
|
|
223
|
+
|
|
224
|
+
### Environment-Specific Adapters
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
import { InMemoryOutbox } from 'outbox-event-bus';
|
|
228
|
+
import { PostgresPrismaOutbox } from '@outbox-event-bus/postgres-prisma-outbox';
|
|
229
|
+
|
|
230
|
+
const outbox = process.env.NODE_ENV === 'production'
|
|
231
|
+
? new PostgresPrismaOutbox({ prisma })
|
|
232
|
+
: new InMemoryOutbox();
|
|
233
|
+
|
|
234
|
+
const bus = new OutboxEventBus(outbox, (error) => console.error(error));
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Testing Event Handlers
|
|
238
|
+
|
|
239
|
+
**Problem:** How do I test event-driven code without a real database?
|
|
240
|
+
|
|
241
|
+
**Solution:** Use `InMemoryOutbox` and `waitFor`:
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
import { describe, it, expect } from 'vitest';
|
|
245
|
+
import { OutboxEventBus, InMemoryOutbox } from 'outbox-event-bus';
|
|
246
|
+
|
|
247
|
+
describe('User Creation', () => {
|
|
248
|
+
it('sends welcome email when user is created', async () => {
|
|
249
|
+
const outbox = new InMemoryOutbox();
|
|
250
|
+
const bus = new OutboxEventBus(outbox, (error) => console.error(error));
|
|
251
|
+
|
|
252
|
+
let emailSent = false;
|
|
253
|
+
bus.on('user.created', async (event) => {
|
|
254
|
+
emailSent = true;
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
bus.start();
|
|
258
|
+
|
|
259
|
+
await bus.emit({ type: 'user.created', payload: { email: 'test@example.com' } });
|
|
260
|
+
await bus.waitFor('user.created');
|
|
261
|
+
|
|
262
|
+
expect(emailSent).toBe(true);
|
|
263
|
+
|
|
264
|
+
await bus.stop();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Error Handling
|
|
270
|
+
|
|
271
|
+
The library provides typed errors to help you handle specific failure scenarios programmatically. All errors extend the base `OutboxError` class.
|
|
272
|
+
|
|
273
|
+
- **Configuration Errors**: `DuplicateListenerError`, `UnsupportedOperationError`
|
|
274
|
+
- **Validation Errors**: `BatchSizeLimitError`
|
|
275
|
+
- **Operational Errors**: `TimeoutError`, `BackpressureError`, `MaxRetriesExceededError`, `HandlerError`
|
|
276
|
+
|
|
277
|
+
#### Example
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
import { OutboxEventBus, MaxRetriesExceededError } from 'outbox-event-bus';
|
|
281
|
+
|
|
282
|
+
const bus = new OutboxEventBus(outbox, (error: OutboxError) => {
|
|
283
|
+
// error is always an OutboxError instance
|
|
284
|
+
const event = error.context?.event;
|
|
285
|
+
|
|
286
|
+
if (error instanceof MaxRetriesExceededError) {
|
|
287
|
+
console.error(`Event ${event?.id} permanently failed after ${error.retryCount} attempts`);
|
|
288
|
+
console.error('Original error:', error.cause);
|
|
289
|
+
} else {
|
|
290
|
+
console.error(`Event ${event?.id} failed:`, error.message);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
For a complete list and usage examples, see the [API Reference](./docs/API_REFERENCE.md).
|
|
296
|
+
|
|
297
|
+
### Monitoring & Debugging
|
|
298
|
+
|
|
299
|
+
**Problem:** How do I monitor event processing errors in production?
|
|
300
|
+
|
|
301
|
+
**Solution:** Use the `onError` callback for infrastructure errors and query the outbox table for failed events (DLQ).
|
|
302
|
+
|
|
303
|
+
> **Note:** The `onError` callback captures unexpected errors (e.g., database connection loss, handler crashes). Events that simply fail processing and are retried are not considered "errors" until they exceed max retries, at which point they are marked as `failed` in the database.
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
const bus = new OutboxEventBus(outbox, (error: OutboxError) => {
|
|
307
|
+
const event = error.context?.event;
|
|
308
|
+
|
|
309
|
+
// Send to monitoring service
|
|
310
|
+
logger.error('Event processing failed', {
|
|
311
|
+
eventId: event?.id,
|
|
312
|
+
eventType: event?.type,
|
|
313
|
+
errorMessage: error.message,
|
|
314
|
+
errorName: error.name
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Send to error tracking
|
|
318
|
+
if (error instanceof MaxRetriesExceededError) {
|
|
319
|
+
Sentry.captureException(error, {
|
|
320
|
+
tags: {
|
|
321
|
+
eventType: event?.type,
|
|
322
|
+
retryCount: error.retryCount
|
|
323
|
+
},
|
|
324
|
+
extra: error.context
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
**Query failed events:**
|
|
331
|
+
```typescript
|
|
332
|
+
// Get failed events (if supported by adapter)
|
|
333
|
+
const failedEvents = await bus.getFailedEvents();
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Or via SQL:
|
|
337
|
+
```sql
|
|
338
|
+
SELECT * FROM outbox_events
|
|
339
|
+
WHERE status = 'failed'
|
|
340
|
+
ORDER BY created_on DESC
|
|
341
|
+
LIMIT 10;
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### Handling Failures
|
|
345
|
+
|
|
346
|
+
**Problem:** An event failed after max retries. How do I retry it?
|
|
347
|
+
|
|
348
|
+
**Solution:** Use `retryEvents` or reset via SQL.
|
|
349
|
+
|
|
350
|
+
**Using API:**
|
|
351
|
+
```typescript
|
|
352
|
+
const failedEvents = await bus.getFailedEvents();
|
|
353
|
+
const idsToRetry = failedEvents.map(e => e.id);
|
|
354
|
+
await bus.retryEvents(idsToRetry);
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
**Using SQL:**
|
|
358
|
+
```sql
|
|
359
|
+
-- Reset a specific event
|
|
360
|
+
UPDATE outbox_events
|
|
361
|
+
SET status = 'created', retry_count = 0, last_error = NULL
|
|
362
|
+
WHERE id = 'event-id-here';
|
|
363
|
+
|
|
364
|
+
-- Reset all failed events of a type
|
|
365
|
+
UPDATE outbox_events
|
|
366
|
+
SET status = 'created', retry_count = 0, last_error = NULL
|
|
367
|
+
WHERE status = 'failed' AND type = 'user.created';
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### Schema Evolution
|
|
371
|
+
|
|
372
|
+
**Problem:** I need to change my event payload structure. How do I handle old events?
|
|
373
|
+
|
|
374
|
+
**Solution:** Use versioned event types and handlers:
|
|
375
|
+
|
|
376
|
+
```typescript
|
|
377
|
+
// Old handler (still processes legacy events)
|
|
378
|
+
bus.on('user.created.v1', async (event) => {
|
|
379
|
+
const { firstName, lastName } = event.payload;
|
|
380
|
+
await emailService.send({ name: `${firstName} ${lastName}` });
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// New handler (processes new events)
|
|
384
|
+
bus.on('user.created.v2', async (event) => {
|
|
385
|
+
const { fullName } = event.payload;
|
|
386
|
+
await emailService.send({ name: fullName });
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Emit new version
|
|
390
|
+
await bus.emit({ type: 'user.created.v2', payload: { fullName: 'John Doe' } });
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
## Adapters & Publishers
|
|
394
|
+
|
|
395
|
+
Mix and match any storage adapter with any publisher.
|
|
396
|
+
|
|
397
|
+
### Storage Adapters (The "Outbox")
|
|
398
|
+
|
|
399
|
+
These store your events. Choose one that matches your primary database.
|
|
400
|
+
|
|
401
|
+
| Database | Adapters | Transaction Support | Concurrency |
|
|
402
|
+
|:---|:---|:---:|:---|
|
|
403
|
+
| **Postgres** | [Prisma](./adapters/postgres-prisma/README.md), [Drizzle](./adapters/postgres-drizzle/README.md) | Full (ACID) | `SKIP LOCKED` |
|
|
404
|
+
| **MongoDB** | [Native Driver](./adapters/mongo-mongodb/README.md) | Full (Replica Set) | Optimistic Locking |
|
|
405
|
+
| **DynamoDB** | [AWS SDK](./adapters/dynamodb-aws-sdk/README.md) | Full (TransactWrite) | Optimistic Locking |
|
|
406
|
+
| **Redis** | [ioredis](./adapters/redis-ioredis/README.md) | Atomic (Multi/Exec) | Distributed Lock |
|
|
407
|
+
| **SQLite** | [better-sqlite3](./adapters/sqlite-better-sqlite3/README.md) | Full (ACID) | Serialized |
|
|
408
|
+
|
|
409
|
+
**Legend:**
|
|
410
|
+
|
|
411
|
+
- **Full**: ACID transactions with atomicity guarantees
|
|
412
|
+
- **Limited**: Single-document transactions or optimistic locking
|
|
413
|
+
- **None**: No transaction support (events saved separately)
|
|
414
|
+
- **SKIP LOCKED**: High-performance non-blocking reads for multiple workers
|
|
415
|
+
|
|
416
|
+
### Publishers
|
|
417
|
+
|
|
418
|
+
These send your events to the world.
|
|
419
|
+
|
|
420
|
+
| Publisher | Target | Batching | Package |
|
|
421
|
+
|:---|:---|:---:|:---|
|
|
422
|
+
| **[AWS SQS](./publishers/sqs/README.md)** | Amazon SQS Queues | Yes (10) | `@outbox-event-bus/sqs-publisher` |
|
|
423
|
+
| **[AWS SNS](./publishers/sns/README.md)** | Amazon SNS Topics | Yes (10) | `@outbox-event-bus/sns-publisher` |
|
|
424
|
+
| **[EventBridge](./publishers/eventbridge/README.md)** | AWS Event Bus | Yes (10) | `@outbox-event-bus/eventbridge-publisher` |
|
|
425
|
+
| **[RabbitMQ](./publishers/rabbitmq/README.md)** | AMQP Brokers | Yes (Configurable) | `@outbox-event-bus/rabbitmq-publisher` |
|
|
426
|
+
| **[Kafka](./publishers/kafka/README.md)** | Streaming | Yes (Configurable) | `@outbox-event-bus/kafka-publisher` |
|
|
427
|
+
| **[Redis Streams](./publishers/redis-streams/README.md)** | Lightweight Stream | Yes (Configurable) | `@outbox-event-bus/redis-streams-publisher` |
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
### Choosing the Right Publisher
|
|
431
|
+
|
|
432
|
+

|
|
433
|
+
|
|
434
|
+
## Production Guide
|
|
435
|
+
|
|
436
|
+
> [!TIP]
|
|
437
|
+
> Start with conservative settings and tune based on your metrics. It's easier to increase throughput than to debug overload issues.
|
|
438
|
+
|
|
439
|
+
### Deployment Checklist
|
|
440
|
+
|
|
441
|
+
- [ ] **Database Schema**: Ensure outbox tables are created and migrated
|
|
442
|
+
- [ ] **Connection Pooling**: Size your connection pool for concurrent workers
|
|
443
|
+
- [ ] **Error Monitoring**: Set up error tracking (Sentry, Datadog, etc.)
|
|
444
|
+
- [ ] **Metrics**: Track event processing rates, retry counts, failure rates
|
|
445
|
+
- [ ] **Archiving**: Configure automatic archiving of completed events
|
|
446
|
+
- [ ] **Scaling**: Plan for horizontal scaling (multiple workers)
|
|
447
|
+
|
|
448
|
+
### Monitoring
|
|
449
|
+
|
|
450
|
+
**Key Metrics to Track:**
|
|
451
|
+
|
|
452
|
+
1. **Event Processing Rate**: Events/second processed
|
|
453
|
+
2. **Retry Rate**: Percentage of events requiring retries
|
|
454
|
+
3. **Failure Rate**: Percentage of events failing after max retries
|
|
455
|
+
4. **Processing Latency**: Time from event creation to successful delivery
|
|
456
|
+
5. **Queue Depth**: Number of pending events in the outbox
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
### Scaling
|
|
460
|
+
|
|
461
|
+
**Horizontal Scaling:**
|
|
462
|
+
|
|
463
|
+
Run multiple instances of your application. Each instance runs its own poller. The outbox adapter handles coordination using:
|
|
464
|
+
|
|
465
|
+
- **Row-level locking** (Postgres, SQLite): `FOR UPDATE SKIP LOCKED`
|
|
466
|
+
- **Optimistic locking** (MongoDB, DynamoDB): Version fields
|
|
467
|
+
- **Distributed locks** (Redis): Redlock algorithm
|
|
468
|
+
|
|
469
|
+
**Vertical Scaling:**
|
|
470
|
+
|
|
471
|
+
Increase `batchSize` and reduce `pollIntervalMs` for higher throughput:
|
|
472
|
+
|
|
473
|
+
```typescript
|
|
474
|
+
const outbox = new PostgresPrismaOutbox({
|
|
475
|
+
prisma,
|
|
476
|
+
batchSize: 100, // Process 100 events per poll
|
|
477
|
+
pollIntervalMs: 500 // Poll every 500ms
|
|
478
|
+
});
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
### Security
|
|
482
|
+
|
|
483
|
+
**Best Practices:**
|
|
484
|
+
|
|
485
|
+
1. **Encrypt Sensitive Payloads**: Use application-level encryption for PII
|
|
486
|
+
2. **IAM Permissions**: Grant minimal permissions to publishers (e.g., `sqs:SendMessage` only)
|
|
487
|
+
3. **Network Security**: Use VPC endpoints for AWS services
|
|
488
|
+
4. **Audit Logging**: Log all event emissions and processing
|
|
489
|
+
|
|
490
|
+
**Example: Encrypting Payloads**
|
|
491
|
+
|
|
492
|
+
Essential when forwarding events to external systems (SQS, Kafka) or to protect PII stored in the `outbox_events` table.
|
|
493
|
+
|
|
494
|
+
```typescript
|
|
495
|
+
import { encrypt, decrypt } from './crypto';
|
|
496
|
+
|
|
497
|
+
await bus.emit({
|
|
498
|
+
type: 'user.created',
|
|
499
|
+
payload: encrypt(user) // Encrypt before saving
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
bus.on('user.created', async (event) => {
|
|
503
|
+
const user = decrypt(event.payload); // Decrypt in handler
|
|
504
|
+
await emailService.send(user.email);
|
|
505
|
+
});
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
## License
|
|
509
|
+
|
|
510
|
+
MIT © [Dunika](https://github.com/dunika)
|