@outbox-event-bus/postgres-drizzle-outbox 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 +446 -0
- package/dist/index.cjs +162 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +482 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +482 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +159 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +56 -0
- package/src/index.ts +2 -0
- package/src/integration.e2e.ts +461 -0
- package/src/postgres-drizzle-outbox.test.ts +156 -0
- package/src/postgres-drizzle-outbox.ts +195 -0
- package/src/schema.ts +37 -0
- package/src/transaction-storage.ts +21 -0
- package/src/transactions.e2e.ts +142 -0
package/README.md
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
# PostgreSQL (Drizzle) Outbox
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
> **Type-Safe PostgreSQL Outbox with Drizzle ORM**
|
|
8
|
+
>
|
|
9
|
+
> Zero-config setup with automatic schema inference. Custom table support for existing databases.
|
|
10
|
+
|
|
11
|
+
PostgreSQL adapter for [outbox-event-bus](https://github.com/dunika/outbox-event-bus#readme) using [Drizzle ORM](https://orm.drizzle.team/). Provides robust event storage with `SELECT FOR UPDATE SKIP LOCKED` for safe distributed processing.
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
15
|
+
import postgres from 'postgres';
|
|
16
|
+
import { PostgresDrizzleOutbox } from '@outbox-event-bus/postgres-drizzle-outbox';
|
|
17
|
+
|
|
18
|
+
const client = postgres(process.env.DATABASE_URL!);
|
|
19
|
+
const db = drizzle(client);
|
|
20
|
+
|
|
21
|
+
const outbox = new PostgresDrizzleOutbox({
|
|
22
|
+
db
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Contents
|
|
27
|
+
|
|
28
|
+
- [When to Use](#when-to-use)
|
|
29
|
+
- [Installation](#installation)
|
|
30
|
+
- [Database Schema](#database-schema)
|
|
31
|
+
- [Configuration](#configuration)
|
|
32
|
+
- [Usage](#usage)
|
|
33
|
+
- [Custom Table Schemas](#custom-table-schemas)
|
|
34
|
+
- [Advanced Patterns](#advanced-patterns)
|
|
35
|
+
- [Features](#features)
|
|
36
|
+
- [Troubleshooting](#troubleshooting)
|
|
37
|
+
|
|
38
|
+
## When to Use
|
|
39
|
+
|
|
40
|
+
**Choose Postgres Drizzle Outbox when:**
|
|
41
|
+
- You are using **PostgreSQL** as your primary database
|
|
42
|
+
- You prefer **SQL-first** development with type safety
|
|
43
|
+
- You need **custom table names** or schemas (multi-tenant apps)
|
|
44
|
+
- You want **zero magic** - explicit queries you can inspect
|
|
45
|
+
|
|
46
|
+
**Choose Postgres Prisma Outbox instead if:**
|
|
47
|
+
- You're already using Prisma Client
|
|
48
|
+
- You prefer schema-first development with migrations
|
|
49
|
+
- You need Prisma's advanced features (middleware, extensions)
|
|
50
|
+
|
|
51
|
+
### Performance Characteristics
|
|
52
|
+
|
|
53
|
+
- **Concurrency**: Uses `SELECT FOR UPDATE SKIP LOCKED` for lock-free event claiming
|
|
54
|
+
- **Throughput**: ~1000-5000 events/sec (single instance, depends on handler complexity)
|
|
55
|
+
- **Latency**: Sub-100ms from emit to handler execution (default polling: 1s)
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm install @outbox-event-bus/postgres-drizzle-outbox drizzle-orm postgres
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Database Schema
|
|
64
|
+
|
|
65
|
+
Run the following SQL to create the required tables:
|
|
66
|
+
|
|
67
|
+
```sql
|
|
68
|
+
CREATE TABLE outbox_events (
|
|
69
|
+
id TEXT PRIMARY KEY,
|
|
70
|
+
type TEXT NOT NULL,
|
|
71
|
+
payload JSONB NOT NULL,
|
|
72
|
+
occurred_at TIMESTAMP NOT NULL,
|
|
73
|
+
status TEXT NOT NULL DEFAULT 'created',
|
|
74
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
75
|
+
last_error TEXT,
|
|
76
|
+
next_retry_at TIMESTAMP,
|
|
77
|
+
created_on TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
78
|
+
started_on TIMESTAMP,
|
|
79
|
+
keep_alive TIMESTAMP,
|
|
80
|
+
expire_in_seconds INTEGER NOT NULL DEFAULT 300
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
CREATE TABLE outbox_events_archive (
|
|
84
|
+
id TEXT PRIMARY KEY,
|
|
85
|
+
type TEXT NOT NULL,
|
|
86
|
+
payload JSONB NOT NULL,
|
|
87
|
+
occurred_at TIMESTAMP NOT NULL,
|
|
88
|
+
status TEXT NOT NULL,
|
|
89
|
+
retry_count INTEGER NOT NULL,
|
|
90
|
+
last_error TEXT,
|
|
91
|
+
created_on TIMESTAMP NOT NULL,
|
|
92
|
+
started_on TIMESTAMP,
|
|
93
|
+
completed_on TIMESTAMP NOT NULL
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
CREATE INDEX idx_outbox_events_status_retry ON outbox_events (status, next_retry_at);
|
|
97
|
+
CREATE INDEX idx_outbox_events_keepalive ON outbox_events (status, keep_alive);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Concurrency & Locking
|
|
101
|
+
|
|
102
|
+
This adapter uses **Row-Level Locking** (`SELECT ... FOR UPDATE SKIP LOCKED`) to ensure safe concurrent processing.
|
|
103
|
+
|
|
104
|
+
- **Multiple Workers**: You can run multiple instances of your application.
|
|
105
|
+
- **No Duplicates**: The database ensures that only one worker picks up a specific event at a time.
|
|
106
|
+
- **Performance**: `SKIP LOCKED` allows workers to skip locked rows and process the next available event immediately, preventing contention.
|
|
107
|
+
|
|
108
|
+
## Configuration
|
|
109
|
+
|
|
110
|
+
### PostgresDrizzleOutboxConfig
|
|
111
|
+
|
|
112
|
+
| Parameter | Type | Default | Description |
|
|
113
|
+
|:---|:---|:---:|:---|
|
|
114
|
+
| `db` | `PostgresJsDatabase` | **Required** | Drizzle database instance (from `drizzle-orm/postgres-js`) |
|
|
115
|
+
| `getTransaction` | `() => PostgresJsDatabase \| undefined` | `undefined` | Function to retrieve active transaction from AsyncLocalStorage or context |
|
|
116
|
+
| `tables` | `{ outboxEvents, outboxEventsArchive }` | Default schema | Custom Drizzle table definitions (see [Custom Schemas](#custom-table-schemas)) |
|
|
117
|
+
| `maxRetries` | `number` | `5` | Maximum retry attempts before marking event as failed |
|
|
118
|
+
| `baseBackoffMs` | `number` | `1000` | Base delay for exponential backoff (1s, 2s, 4s, 8s, 16s) |
|
|
119
|
+
| `processingTimeoutMs` | `number` | `30000` | Time before reclaiming stuck events (30s) |
|
|
120
|
+
| `pollIntervalMs` | `number` | `1000` | Polling frequency for new events (1s) |
|
|
121
|
+
| `batchSize` | `number` | `50` | Events claimed per poll cycle |
|
|
122
|
+
| `maxErrorBackoffMs` | `number` | `30000` | Maximum backoff delay for polling errors |
|
|
123
|
+
|
|
124
|
+
> [!TIP]
|
|
125
|
+
> Start with defaults and tune based on metrics. Increase `batchSize` and decrease `pollIntervalMs` for higher throughput.
|
|
126
|
+
|
|
127
|
+
> [!WARNING]
|
|
128
|
+
> Setting `pollIntervalMs` too low (<100ms) can cause excessive database load. Monitor CPU and connection pool usage.
|
|
129
|
+
|
|
130
|
+
## Usage
|
|
131
|
+
|
|
132
|
+
### 1. Basic Setup (No Transactions)
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
136
|
+
import postgres from 'postgres';
|
|
137
|
+
import { PostgresDrizzleOutbox } from '@outbox-event-bus/postgres-drizzle-outbox';
|
|
138
|
+
import { OutboxEventBus } from 'outbox-event-bus';
|
|
139
|
+
|
|
140
|
+
const client = postgres(process.env.DATABASE_URL!);
|
|
141
|
+
const db = drizzle(client);
|
|
142
|
+
|
|
143
|
+
const outbox = new PostgresDrizzleOutbox({ db });
|
|
144
|
+
const bus = new OutboxEventBus(outbox, (error) => console.error(error));
|
|
145
|
+
|
|
146
|
+
bus.on('user.created', async (event) => {
|
|
147
|
+
await emailService.sendWelcome(event.payload.email);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
bus.start();
|
|
151
|
+
|
|
152
|
+
// Emit without transaction (event saved separately)
|
|
153
|
+
await bus.emit({
|
|
154
|
+
type: 'user.created',
|
|
155
|
+
payload: { email: 'user@example.com' }
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
> [!WARNING]
|
|
160
|
+
> Without transactions, events are **not atomic** with your data changes. Use transactions for critical workflows.
|
|
161
|
+
|
|
162
|
+
### 2. Explicit Transactions
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
await db.transaction(async (tx) => {
|
|
166
|
+
await tx.insert(users).values(newUser);
|
|
167
|
+
|
|
168
|
+
// Pass transaction explicitly
|
|
169
|
+
await bus.emit({
|
|
170
|
+
type: 'user.created',
|
|
171
|
+
payload: newUser
|
|
172
|
+
}, tx);
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### 3. AsyncLocalStorage (Recommended)
|
|
177
|
+
|
|
178
|
+
Avoid passing transactions manually by using AsyncLocalStorage:
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
182
|
+
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
|
183
|
+
|
|
184
|
+
const als = new AsyncLocalStorage<PostgresJsDatabase<Record<string, unknown>>>();
|
|
185
|
+
|
|
186
|
+
const outbox = new PostgresDrizzleOutbox({
|
|
187
|
+
db,
|
|
188
|
+
getTransaction: () => als.getStore()
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const bus = new OutboxEventBus(outbox, (error) => console.error(error));
|
|
192
|
+
|
|
193
|
+
// In your service
|
|
194
|
+
async function createUser(user: any) {
|
|
195
|
+
return await db.transaction(async (tx) => {
|
|
196
|
+
return await als.run(tx, async () => {
|
|
197
|
+
await tx.insert(users).values(user);
|
|
198
|
+
|
|
199
|
+
// Bus automatically uses transaction from ALS
|
|
200
|
+
await bus.emit({
|
|
201
|
+
type: 'user.created',
|
|
202
|
+
payload: user
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
return user;
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
> [!TIP]
|
|
212
|
+
> AsyncLocalStorage eliminates the need to pass transactions through your call stack, improving code clarity.
|
|
213
|
+
|
|
214
|
+
## Custom Table Schemas
|
|
215
|
+
|
|
216
|
+
The adapter supports custom table definitions, enabling:
|
|
217
|
+
- **Multi-tenancy**: Separate outbox tables per tenant
|
|
218
|
+
- **Legacy databases**: Integrate with existing table structures
|
|
219
|
+
- **Custom columns**: Add application-specific metadata
|
|
220
|
+
|
|
221
|
+
### Example: Custom Table Names
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
import { pgTable, text, jsonb, timestamp, integer, uuid } from 'drizzle-orm/pg-core';
|
|
225
|
+
|
|
226
|
+
// Define custom tables
|
|
227
|
+
const myCustomOutbox = pgTable('tenant_a_outbox', {
|
|
228
|
+
id: uuid('id').primaryKey(),
|
|
229
|
+
type: text('type').notNull(),
|
|
230
|
+
payload: jsonb('payload').notNull(),
|
|
231
|
+
occurredAt: timestamp('occurred_at').notNull(),
|
|
232
|
+
status: text('status').notNull().default('created'),
|
|
233
|
+
retryCount: integer('retry_count').notNull().default(0),
|
|
234
|
+
lastError: text('last_error'),
|
|
235
|
+
nextRetryAt: timestamp('next_retry_at'),
|
|
236
|
+
createdOn: timestamp('created_on').notNull().defaultNow(),
|
|
237
|
+
startedOn: timestamp('started_on'),
|
|
238
|
+
keepAlive: timestamp('keep_alive'),
|
|
239
|
+
expireInSeconds: integer('expire_in_seconds').notNull().default(30),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const myCustomArchive = pgTable('tenant_a_archive', {
|
|
243
|
+
id: uuid('id').primaryKey(),
|
|
244
|
+
type: text('type').notNull(),
|
|
245
|
+
payload: jsonb('payload').notNull(),
|
|
246
|
+
occurredAt: timestamp('occurred_at').notNull(),
|
|
247
|
+
status: text('status').notNull(),
|
|
248
|
+
retryCount: integer('retry_count').notNull(),
|
|
249
|
+
lastError: text('last_error'),
|
|
250
|
+
createdOn: timestamp('created_on').notNull(),
|
|
251
|
+
startedOn: timestamp('started_on'),
|
|
252
|
+
completedOn: timestamp('completed_on').notNull(),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Use custom tables
|
|
256
|
+
const outbox = new PostgresDrizzleOutbox({
|
|
257
|
+
db,
|
|
258
|
+
tables: {
|
|
259
|
+
outboxEvents: myCustomOutbox,
|
|
260
|
+
outboxEventsArchive: myCustomArchive
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
> [!IMPORTANT]
|
|
266
|
+
> Custom tables **must** include all required columns with compatible types. Missing columns will cause runtime errors.
|
|
267
|
+
|
|
268
|
+
### Required Schema Fields
|
|
269
|
+
|
|
270
|
+
| Column | Type | Constraints | Purpose |
|
|
271
|
+
|:---|:---|:---|:---|
|
|
272
|
+
| `id` | `uuid` or `text` | Primary Key | Unique event identifier |
|
|
273
|
+
| `type` | `text` | Not Null | Event type for routing |
|
|
274
|
+
| `payload` | `jsonb` | Not Null | Event data |
|
|
275
|
+
| `occurredAt` | `timestamp` | Not Null | Event timestamp |
|
|
276
|
+
| `status` | `text` | Not Null, Default: 'created' | Lifecycle state |
|
|
277
|
+
| `retryCount` | `integer` | Not Null, Default: 0 | Retry attempts |
|
|
278
|
+
| `lastError` | `text` | Nullable | Error message from last failure |
|
|
279
|
+
| `nextRetryAt` | `timestamp` | Nullable | Scheduled retry time |
|
|
280
|
+
| `createdOn` | `timestamp` | Not Null | Record creation time |
|
|
281
|
+
| `startedOn` | `timestamp` | Nullable | Processing start time |
|
|
282
|
+
| `keepAlive` | `timestamp` | Nullable | Last heartbeat (stuck detection) |
|
|
283
|
+
| `expireInSeconds` | `integer` | Not Null, Default: 30 | Timeout threshold |
|
|
284
|
+
|
|
285
|
+
## Advanced Patterns
|
|
286
|
+
|
|
287
|
+
### Monitoring Event Processing
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
import { OutboxEventBus, MaxRetriesExceededError } from 'outbox-event-bus';
|
|
291
|
+
|
|
292
|
+
const bus = new OutboxEventBus(outbox, (error: OutboxError) => {
|
|
293
|
+
// Log to monitoring service
|
|
294
|
+
const event = error.context?.event;
|
|
295
|
+
logger.error('Event processing failed', {
|
|
296
|
+
eventId: event?.id,
|
|
297
|
+
eventType: event?.type,
|
|
298
|
+
retryCount: error.context?.retryCount,
|
|
299
|
+
error: error.message
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Send to Sentry/Datadog
|
|
303
|
+
if (error instanceof MaxRetriesExceededError) {
|
|
304
|
+
Sentry.captureException(error, {
|
|
305
|
+
tags: { eventType: event?.type },
|
|
306
|
+
extra: { event }
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### Querying Failed Events
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
import { eq } from 'drizzle-orm';
|
|
316
|
+
import { outboxEvents } from '@outbox-event-bus/postgres-drizzle-outbox';
|
|
317
|
+
|
|
318
|
+
// Get all failed events
|
|
319
|
+
const failedEvents = await db
|
|
320
|
+
.select()
|
|
321
|
+
.from(outboxEvents)
|
|
322
|
+
.where(eq(outboxEvents.status, 'failed'))
|
|
323
|
+
.orderBy(outboxEvents.occurredAt);
|
|
324
|
+
|
|
325
|
+
// Retry specific events
|
|
326
|
+
const idsToRetry = failedEvents.map(e => e.id);
|
|
327
|
+
await bus.retryEvents(idsToRetry);
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Archive Cleanup (Production)
|
|
331
|
+
|
|
332
|
+
The adapter automatically archives completed events. For long-running systems, periodically clean old archives:
|
|
333
|
+
|
|
334
|
+
```sql
|
|
335
|
+
-- Delete archives older than 30 days
|
|
336
|
+
DELETE FROM outbox_events_archive
|
|
337
|
+
WHERE completed_on < NOW() - INTERVAL '30 days';
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Or use a cron job:
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
import { lt } from 'drizzle-orm';
|
|
344
|
+
import { outboxEventsArchive } from '@outbox-event-bus/postgres-drizzle-outbox';
|
|
345
|
+
|
|
346
|
+
async function cleanupArchives() {
|
|
347
|
+
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
348
|
+
|
|
349
|
+
await db
|
|
350
|
+
.delete(outboxEventsArchive)
|
|
351
|
+
.where(lt(outboxEventsArchive.completedOn, thirtyDaysAgo));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Run daily
|
|
355
|
+
setInterval(cleanupArchives, 24 * 60 * 60 * 1000);
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
> [!CAUTION]
|
|
359
|
+
> Deleting archives removes audit history. Consider exporting to cold storage (S3, data warehouse) before deletion.
|
|
360
|
+
|
|
361
|
+
## Features
|
|
362
|
+
|
|
363
|
+
- **SKIP LOCKED**: Uses `SELECT ... FOR UPDATE SKIP LOCKED` to efficiently claim events without blocking other workers.
|
|
364
|
+
- **Transactional Integrity**: Supports emitting events within the same transaction as your data changes (Atomic Phase 1).
|
|
365
|
+
- **Archiving**: Automatically moves processed events to an archive table to keep the active table small and fast.
|
|
366
|
+
- **Stuck Event Recovery**: Reclaims events that have timed out (stalled workers) based on `keep_alive` + `expire_in_seconds`.
|
|
367
|
+
|
|
368
|
+
## Troubleshooting
|
|
369
|
+
|
|
370
|
+
### Events not appearing
|
|
371
|
+
|
|
372
|
+
**Symptom**: Events emitted but handlers never execute
|
|
373
|
+
|
|
374
|
+
**Causes**:
|
|
375
|
+
1. **Transaction rollback**: Event was emitted inside a transaction that rolled back
|
|
376
|
+
2. **Bus not started**: Forgot to call `bus.start()`
|
|
377
|
+
3. **No handler registered**: No `bus.on()` for the event type
|
|
378
|
+
|
|
379
|
+
**Solution**:
|
|
380
|
+
```typescript
|
|
381
|
+
// Ensure bus is started
|
|
382
|
+
bus.start();
|
|
383
|
+
|
|
384
|
+
// Verify handler is registered
|
|
385
|
+
bus.on('user.created', async (event) => {
|
|
386
|
+
console.log('Handler called:', event);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Check transaction commits
|
|
390
|
+
await db.transaction(async (tx) => {
|
|
391
|
+
await tx.insert(users).values(user);
|
|
392
|
+
await bus.emit({ type: 'user.created', payload: user }, tx);
|
|
393
|
+
// Transaction must commit (no throw)
|
|
394
|
+
});
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
### SerializationFailure / Deadlocks
|
|
398
|
+
|
|
399
|
+
**Symptom**: `SerializationFailure` errors in logs
|
|
400
|
+
|
|
401
|
+
**Cause**: High contention on outbox table (multiple workers claiming same events)
|
|
402
|
+
|
|
403
|
+
**Solution**:
|
|
404
|
+
- The `SKIP LOCKED` clause minimizes this
|
|
405
|
+
- Reduce `pollIntervalMs` to spread out polling
|
|
406
|
+
- Increase `batchSize` to reduce lock frequency
|
|
407
|
+
- Add jitter to polling intervals in multi-worker setups
|
|
408
|
+
|
|
409
|
+
### High Database Load
|
|
410
|
+
|
|
411
|
+
**Symptom**: CPU spikes, slow queries
|
|
412
|
+
|
|
413
|
+
**Cause**: Aggressive polling settings
|
|
414
|
+
|
|
415
|
+
**Solution**:
|
|
416
|
+
```typescript
|
|
417
|
+
const outbox = new PostgresDrizzleOutbox({
|
|
418
|
+
db,
|
|
419
|
+
pollIntervalMs: 2000, // Increase from 1000ms
|
|
420
|
+
batchSize: 100 // Process more per poll
|
|
421
|
+
});
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
### Custom Table Errors
|
|
425
|
+
|
|
426
|
+
**Symptom**: `column "xyz" does not exist` errors
|
|
427
|
+
|
|
428
|
+
**Cause**: Custom table schema missing required columns
|
|
429
|
+
|
|
430
|
+
**Solution**: Ensure your custom table includes all fields from [Required Schema Fields](#required-schema-fields)
|
|
431
|
+
|
|
432
|
+
### TypeScript Errors with Custom Tables
|
|
433
|
+
|
|
434
|
+
**Symptom**: Type errors when using custom tables
|
|
435
|
+
|
|
436
|
+
**Cause**: Table types don't match expected schema
|
|
437
|
+
|
|
438
|
+
**Solution**:
|
|
439
|
+
```typescript
|
|
440
|
+
// Ensure your table matches the expected structure
|
|
441
|
+
import type { outboxEvents } from '@outbox-event-bus/postgres-drizzle-outbox';
|
|
442
|
+
|
|
443
|
+
const myTable: typeof outboxEvents = pgTable('custom', {
|
|
444
|
+
// ... must match outboxEvents structure
|
|
445
|
+
});
|
|
446
|
+
```
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
let drizzle_orm = require("drizzle-orm");
|
|
2
|
+
let outbox_event_bus = require("outbox-event-bus");
|
|
3
|
+
let drizzle_orm_pg_core = require("drizzle-orm/pg-core");
|
|
4
|
+
let node_async_hooks = require("node:async_hooks");
|
|
5
|
+
|
|
6
|
+
//#region src/schema.ts
|
|
7
|
+
const outboxStatusEnum = (0, drizzle_orm_pg_core.pgEnum)("outbox_status", [
|
|
8
|
+
"created",
|
|
9
|
+
"active",
|
|
10
|
+
"completed",
|
|
11
|
+
"failed"
|
|
12
|
+
]);
|
|
13
|
+
const outboxEvents = (0, drizzle_orm_pg_core.pgTable)("outbox_events", {
|
|
14
|
+
id: (0, drizzle_orm_pg_core.uuid)("id").primaryKey(),
|
|
15
|
+
type: (0, drizzle_orm_pg_core.text)("type").notNull(),
|
|
16
|
+
payload: (0, drizzle_orm_pg_core.jsonb)("payload").notNull(),
|
|
17
|
+
occurredAt: (0, drizzle_orm_pg_core.timestamp)("occurred_at").notNull(),
|
|
18
|
+
status: outboxStatusEnum("status").notNull().default("created"),
|
|
19
|
+
retryCount: (0, drizzle_orm_pg_core.integer)("retry_count").notNull().default(0),
|
|
20
|
+
lastError: (0, drizzle_orm_pg_core.text)("last_error"),
|
|
21
|
+
nextRetryAt: (0, drizzle_orm_pg_core.timestamp)("next_retry_at"),
|
|
22
|
+
createdOn: (0, drizzle_orm_pg_core.timestamp)("created_on").notNull().defaultNow(),
|
|
23
|
+
startedOn: (0, drizzle_orm_pg_core.timestamp)("started_on"),
|
|
24
|
+
completedOn: (0, drizzle_orm_pg_core.timestamp)("completed_on"),
|
|
25
|
+
keepAlive: (0, drizzle_orm_pg_core.timestamp)("keep_alive"),
|
|
26
|
+
expireInSeconds: (0, drizzle_orm_pg_core.integer)("expire_in_seconds").notNull().default(300)
|
|
27
|
+
});
|
|
28
|
+
const outboxEventsArchive = (0, drizzle_orm_pg_core.pgTable)("outbox_events_archive", {
|
|
29
|
+
id: (0, drizzle_orm_pg_core.uuid)("id").primaryKey(),
|
|
30
|
+
type: (0, drizzle_orm_pg_core.text)("type").notNull(),
|
|
31
|
+
payload: (0, drizzle_orm_pg_core.jsonb)("payload").notNull(),
|
|
32
|
+
occurredAt: (0, drizzle_orm_pg_core.timestamp)("occurred_at").notNull(),
|
|
33
|
+
status: outboxStatusEnum("status").notNull(),
|
|
34
|
+
retryCount: (0, drizzle_orm_pg_core.integer)("retry_count").notNull(),
|
|
35
|
+
lastError: (0, drizzle_orm_pg_core.text)("last_error"),
|
|
36
|
+
createdOn: (0, drizzle_orm_pg_core.timestamp)("created_on").notNull(),
|
|
37
|
+
startedOn: (0, drizzle_orm_pg_core.timestamp)("started_on"),
|
|
38
|
+
completedOn: (0, drizzle_orm_pg_core.timestamp)("completed_on").notNull()
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/postgres-drizzle-outbox.ts
|
|
43
|
+
var PostgresDrizzleOutbox = class {
|
|
44
|
+
config;
|
|
45
|
+
poller;
|
|
46
|
+
constructor(config) {
|
|
47
|
+
this.config = {
|
|
48
|
+
batchSize: config.batchSize ?? 50,
|
|
49
|
+
pollIntervalMs: config.pollIntervalMs ?? 1e3,
|
|
50
|
+
maxRetries: config.maxRetries ?? 5,
|
|
51
|
+
baseBackoffMs: config.baseBackoffMs ?? 1e3,
|
|
52
|
+
processingTimeoutMs: config.processingTimeoutMs ?? 3e4,
|
|
53
|
+
maxErrorBackoffMs: config.maxErrorBackoffMs ?? 3e4,
|
|
54
|
+
db: config.db,
|
|
55
|
+
getTransaction: config.getTransaction,
|
|
56
|
+
tables: config.tables ?? {
|
|
57
|
+
outboxEvents,
|
|
58
|
+
outboxEventsArchive
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
this.poller = new outbox_event_bus.PollingService({
|
|
62
|
+
pollIntervalMs: this.config.pollIntervalMs,
|
|
63
|
+
baseBackoffMs: this.config.baseBackoffMs,
|
|
64
|
+
maxErrorBackoffMs: this.config.maxErrorBackoffMs,
|
|
65
|
+
processBatch: (handler) => this.processBatch(handler)
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
async publish(events, transaction) {
|
|
69
|
+
await (transaction ?? this.config.getTransaction?.() ?? this.config.db).insert(this.config.tables.outboxEvents).values(events.map((event) => ({
|
|
70
|
+
id: event.id,
|
|
71
|
+
type: event.type,
|
|
72
|
+
payload: event.payload,
|
|
73
|
+
occurredAt: event.occurredAt,
|
|
74
|
+
status: outbox_event_bus.EventStatus.CREATED
|
|
75
|
+
})));
|
|
76
|
+
}
|
|
77
|
+
async getFailedEvents() {
|
|
78
|
+
return (await this.config.db.select().from(this.config.tables.outboxEvents).where((0, drizzle_orm.eq)(this.config.tables.outboxEvents.status, outbox_event_bus.EventStatus.FAILED)).orderBy(drizzle_orm.sql`${this.config.tables.outboxEvents.occurredAt} DESC`).limit(100)).map((event) => {
|
|
79
|
+
const failedEvent = {
|
|
80
|
+
id: event.id,
|
|
81
|
+
type: event.type,
|
|
82
|
+
payload: event.payload,
|
|
83
|
+
occurredAt: event.occurredAt,
|
|
84
|
+
retryCount: event.retryCount
|
|
85
|
+
};
|
|
86
|
+
if (event.lastError) failedEvent.error = event.lastError;
|
|
87
|
+
if (event.startedOn) failedEvent.lastAttemptAt = event.startedOn;
|
|
88
|
+
return failedEvent;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
async retryEvents(eventIds) {
|
|
92
|
+
await this.config.db.update(this.config.tables.outboxEvents).set({
|
|
93
|
+
status: outbox_event_bus.EventStatus.CREATED,
|
|
94
|
+
retryCount: 0,
|
|
95
|
+
nextRetryAt: null,
|
|
96
|
+
lastError: null
|
|
97
|
+
}).where((0, drizzle_orm.inArray)(this.config.tables.outboxEvents.id, eventIds));
|
|
98
|
+
}
|
|
99
|
+
start(handler, onError) {
|
|
100
|
+
this.poller.start(handler, onError);
|
|
101
|
+
}
|
|
102
|
+
async stop() {
|
|
103
|
+
await this.poller.stop();
|
|
104
|
+
}
|
|
105
|
+
async processBatch(handler) {
|
|
106
|
+
await this.config.db.transaction(async (transaction) => {
|
|
107
|
+
const now = /* @__PURE__ */ new Date();
|
|
108
|
+
const events = await transaction.select().from(this.config.tables.outboxEvents).where((0, drizzle_orm.or)((0, drizzle_orm.eq)(this.config.tables.outboxEvents.status, outbox_event_bus.EventStatus.CREATED), (0, drizzle_orm.and)((0, drizzle_orm.eq)(this.config.tables.outboxEvents.status, outbox_event_bus.EventStatus.FAILED), (0, drizzle_orm.lt)(this.config.tables.outboxEvents.retryCount, this.config.maxRetries), (0, drizzle_orm.lt)(this.config.tables.outboxEvents.nextRetryAt, now)), (0, drizzle_orm.and)((0, drizzle_orm.eq)(this.config.tables.outboxEvents.status, outbox_event_bus.EventStatus.ACTIVE), (0, drizzle_orm.lt)(this.config.tables.outboxEvents.keepAlive, drizzle_orm.sql`${now.toISOString()}::timestamp - make_interval(secs => ${this.config.tables.outboxEvents.expireInSeconds})`)))).limit(this.config.batchSize).for("update", { skipLocked: true });
|
|
109
|
+
if (events.length === 0) return;
|
|
110
|
+
const eventIds = events.map((event) => event.id);
|
|
111
|
+
await transaction.update(this.config.tables.outboxEvents).set({
|
|
112
|
+
status: outbox_event_bus.EventStatus.ACTIVE,
|
|
113
|
+
startedOn: now,
|
|
114
|
+
keepAlive: now
|
|
115
|
+
}).where((0, drizzle_orm.inArray)(this.config.tables.outboxEvents.id, eventIds));
|
|
116
|
+
for (const event of events) try {
|
|
117
|
+
await handler(event);
|
|
118
|
+
await transaction.insert(this.config.tables.outboxEventsArchive).values({
|
|
119
|
+
id: event.id,
|
|
120
|
+
type: event.type,
|
|
121
|
+
payload: event.payload,
|
|
122
|
+
occurredAt: event.occurredAt,
|
|
123
|
+
status: outbox_event_bus.EventStatus.COMPLETED,
|
|
124
|
+
retryCount: event.retryCount,
|
|
125
|
+
createdOn: event.createdOn,
|
|
126
|
+
startedOn: now,
|
|
127
|
+
completedOn: /* @__PURE__ */ new Date()
|
|
128
|
+
});
|
|
129
|
+
await transaction.delete(this.config.tables.outboxEvents).where((0, drizzle_orm.eq)(this.config.tables.outboxEvents.id, event.id));
|
|
130
|
+
} catch (error) {
|
|
131
|
+
const retryCount = event.retryCount + 1;
|
|
132
|
+
(0, outbox_event_bus.reportEventError)(this.poller.onError, error, event, retryCount, this.config.maxRetries);
|
|
133
|
+
const delay = this.poller.calculateBackoff(retryCount);
|
|
134
|
+
await transaction.update(this.config.tables.outboxEvents).set({
|
|
135
|
+
status: outbox_event_bus.EventStatus.FAILED,
|
|
136
|
+
retryCount,
|
|
137
|
+
lastError: (0, outbox_event_bus.formatErrorMessage)(error),
|
|
138
|
+
nextRetryAt: new Date(Date.now() + delay)
|
|
139
|
+
}).where((0, drizzle_orm.eq)(this.config.tables.outboxEvents.id, event.id));
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
//#endregion
|
|
146
|
+
//#region src/transaction-storage.ts
|
|
147
|
+
const drizzleTransactionStorage = new node_async_hooks.AsyncLocalStorage();
|
|
148
|
+
async function withDrizzleTransaction(db, fn) {
|
|
149
|
+
return db.transaction(async (tx) => {
|
|
150
|
+
return drizzleTransactionStorage.run(tx, () => fn(tx));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function getDrizzleTransaction() {
|
|
154
|
+
return () => drizzleTransactionStorage.getStore();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
//#endregion
|
|
158
|
+
exports.PostgresDrizzleOutbox = PostgresDrizzleOutbox;
|
|
159
|
+
exports.drizzleTransactionStorage = drizzleTransactionStorage;
|
|
160
|
+
exports.getDrizzleTransaction = getDrizzleTransaction;
|
|
161
|
+
exports.withDrizzleTransaction = withDrizzleTransaction;
|
|
162
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs","names":["PollingService","EventStatus","failedEvent: FailedBusEvent","error: unknown","AsyncLocalStorage"],"sources":["../src/schema.ts","../src/postgres-drizzle-outbox.ts","../src/transaction-storage.ts"],"sourcesContent":["import { integer, jsonb, pgEnum, pgTable, text, timestamp, uuid } from \"drizzle-orm/pg-core\"\n\nexport const outboxStatusEnum = pgEnum(\"outbox_status\", [\n \"created\",\n \"active\",\n \"completed\",\n \"failed\",\n])\n\nexport const outboxEvents = pgTable(\"outbox_events\", {\n id: uuid(\"id\").primaryKey(),\n type: text(\"type\").notNull(),\n payload: jsonb(\"payload\").notNull(),\n occurredAt: timestamp(\"occurred_at\").notNull(),\n status: outboxStatusEnum(\"status\").notNull().default(\"created\"),\n retryCount: integer(\"retry_count\").notNull().default(0),\n lastError: text(\"last_error\"),\n nextRetryAt: timestamp(\"next_retry_at\"),\n createdOn: timestamp(\"created_on\").notNull().defaultNow(),\n startedOn: timestamp(\"started_on\"),\n completedOn: timestamp(\"completed_on\"),\n keepAlive: timestamp(\"keep_alive\"),\n expireInSeconds: integer(\"expire_in_seconds\").notNull().default(300),\n})\n\nexport const outboxEventsArchive = pgTable(\"outbox_events_archive\", {\n id: uuid(\"id\").primaryKey(),\n type: text(\"type\").notNull(),\n payload: jsonb(\"payload\").notNull(),\n occurredAt: timestamp(\"occurred_at\").notNull(),\n status: outboxStatusEnum(\"status\").notNull(),\n retryCount: integer(\"retry_count\").notNull(),\n lastError: text(\"last_error\"),\n createdOn: timestamp(\"created_on\").notNull(),\n startedOn: timestamp(\"started_on\"),\n completedOn: timestamp(\"completed_on\").notNull(),\n})\n","import { and, eq, inArray, lt, or, sql } from \"drizzle-orm\"\nimport type { PostgresJsDatabase } from \"drizzle-orm/postgres-js\"\nimport {\n type BusEvent,\n type ErrorHandler,\n EventStatus,\n type FailedBusEvent,\n formatErrorMessage,\n type IOutbox,\n type OutboxConfig,\n PollingService,\n reportEventError,\n} from \"outbox-event-bus\"\nimport { outboxEvents, outboxEventsArchive } from \"./schema\"\n\nexport interface PostgresDrizzleOutboxConfig extends OutboxConfig {\n db: PostgresJsDatabase<Record<string, unknown>>\n getTransaction?: (() => PostgresJsDatabase<Record<string, unknown>> | undefined) | undefined\n tables?: {\n outboxEvents: typeof outboxEvents\n outboxEventsArchive: typeof outboxEventsArchive\n }\n}\n\nexport class PostgresDrizzleOutbox implements IOutbox<PostgresJsDatabase<Record<string, unknown>>> {\n private readonly config: Required<PostgresDrizzleOutboxConfig>\n private readonly poller: PollingService\n\n constructor(config: PostgresDrizzleOutboxConfig) {\n this.config = {\n batchSize: config.batchSize ?? 50,\n pollIntervalMs: config.pollIntervalMs ?? 1000,\n maxRetries: config.maxRetries ?? 5,\n baseBackoffMs: config.baseBackoffMs ?? 1000,\n processingTimeoutMs: config.processingTimeoutMs ?? 30000,\n maxErrorBackoffMs: config.maxErrorBackoffMs ?? 30000,\n db: config.db,\n getTransaction: config.getTransaction,\n tables: config.tables ?? {\n outboxEvents,\n outboxEventsArchive,\n },\n }\n\n this.poller = new PollingService({\n pollIntervalMs: this.config.pollIntervalMs,\n baseBackoffMs: this.config.baseBackoffMs,\n maxErrorBackoffMs: this.config.maxErrorBackoffMs,\n processBatch: (handler) => this.processBatch(handler),\n })\n }\n\n async publish(\n events: BusEvent[],\n transaction?: PostgresJsDatabase<Record<string, unknown>>\n ): Promise<void> {\n const executor = transaction ?? this.config.getTransaction?.() ?? this.config.db\n\n await executor.insert(this.config.tables.outboxEvents).values(\n events.map((event) => ({\n id: event.id,\n type: event.type,\n payload: event.payload,\n occurredAt: event.occurredAt,\n status: EventStatus.CREATED,\n }))\n )\n }\n\n async getFailedEvents(): Promise<FailedBusEvent[]> {\n const events = await this.config.db\n .select()\n .from(this.config.tables.outboxEvents)\n .where(eq(this.config.tables.outboxEvents.status, EventStatus.FAILED))\n .orderBy(sql`${this.config.tables.outboxEvents.occurredAt} DESC`)\n .limit(100)\n\n return events.map((event) => {\n const failedEvent: FailedBusEvent = {\n id: event.id,\n type: event.type,\n payload: event.payload as any,\n occurredAt: event.occurredAt,\n retryCount: event.retryCount,\n }\n if (event.lastError) failedEvent.error = event.lastError\n if (event.startedOn) failedEvent.lastAttemptAt = event.startedOn\n return failedEvent\n })\n }\n\n async retryEvents(eventIds: string[]): Promise<void> {\n await this.config.db\n .update(this.config.tables.outboxEvents)\n .set({\n status: EventStatus.CREATED,\n retryCount: 0,\n nextRetryAt: null,\n lastError: null,\n })\n .where(inArray(this.config.tables.outboxEvents.id, eventIds))\n }\n\n start(handler: (event: BusEvent) => Promise<void>, onError: ErrorHandler): void {\n this.poller.start(handler, onError)\n }\n\n async stop(): Promise<void> {\n await this.poller.stop()\n }\n\n private async processBatch(handler: (event: BusEvent) => Promise<void>) {\n await this.config.db.transaction(async (transaction) => {\n const now = new Date()\n\n // Select events that are:\n // 1. New (status = created)\n // 2. Failed but can be retried (retry count < max AND retry time has passed)\n // 3. Active but stuck/timed out (keepAlive is older than now - expireInSeconds)\n const events = await transaction\n .select()\n .from(this.config.tables.outboxEvents)\n .where(\n or(\n eq(this.config.tables.outboxEvents.status, EventStatus.CREATED),\n and(\n eq(this.config.tables.outboxEvents.status, EventStatus.FAILED),\n lt(this.config.tables.outboxEvents.retryCount, this.config.maxRetries),\n lt(this.config.tables.outboxEvents.nextRetryAt, now)\n ),\n and(\n eq(this.config.tables.outboxEvents.status, EventStatus.ACTIVE),\n // Check if event is stuck: keepAlive is older than (now - expireInSeconds)\n // This uses PostgreSQL's make_interval to subtract expireInSeconds from current timestamp\n lt(\n this.config.tables.outboxEvents.keepAlive,\n sql`${now.toISOString()}::timestamp - make_interval(secs => ${this.config.tables.outboxEvents.expireInSeconds})`\n )\n )\n )\n )\n .limit(this.config.batchSize)\n .for(\"update\", { skipLocked: true })\n\n if (events.length === 0) return\n\n const eventIds = events.map((event) => event.id)\n\n await transaction\n .update(this.config.tables.outboxEvents)\n .set({\n status: EventStatus.ACTIVE,\n startedOn: now,\n keepAlive: now,\n })\n .where(inArray(this.config.tables.outboxEvents.id, eventIds))\n\n for (const event of events) {\n try {\n await handler(event)\n // Archive successful event immediately\n await transaction.insert(this.config.tables.outboxEventsArchive).values({\n id: event.id,\n type: event.type,\n payload: event.payload,\n occurredAt: event.occurredAt,\n status: EventStatus.COMPLETED,\n retryCount: event.retryCount,\n createdOn: event.createdOn,\n startedOn: now,\n completedOn: new Date(),\n })\n await transaction\n .delete(this.config.tables.outboxEvents)\n .where(eq(this.config.tables.outboxEvents.id, event.id))\n } catch (error: unknown) {\n const retryCount = event.retryCount + 1\n reportEventError(this.poller.onError, error, event, retryCount, this.config.maxRetries)\n\n // Mark this specific event as failed\n const delay = this.poller.calculateBackoff(retryCount)\n await transaction\n .update(this.config.tables.outboxEvents)\n .set({\n status: EventStatus.FAILED,\n retryCount,\n lastError: formatErrorMessage(error),\n nextRetryAt: new Date(Date.now() + delay),\n })\n .where(eq(this.config.tables.outboxEvents.id, event.id))\n }\n }\n })\n }\n}\n","import { AsyncLocalStorage } from \"node:async_hooks\"\nimport type { PostgresJsDatabase } from \"drizzle-orm/postgres-js\"\n\nexport const drizzleTransactionStorage = new AsyncLocalStorage<\n PostgresJsDatabase<Record<string, unknown>>\n>()\n\nexport async function withDrizzleTransaction<T>(\n db: PostgresJsDatabase<Record<string, unknown>>,\n fn: (tx: PostgresJsDatabase<Record<string, unknown>>) => Promise<T>\n): Promise<T> {\n return db.transaction(async (tx) => {\n return drizzleTransactionStorage.run(tx, () => fn(tx))\n })\n}\n\nexport function getDrizzleTransaction(): () =>\n | PostgresJsDatabase<Record<string, unknown>>\n | undefined {\n return () => drizzleTransactionStorage.getStore()\n}\n"],"mappings":"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"}
|