@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 ADDED
@@ -0,0 +1,446 @@
1
+ # PostgreSQL (Drizzle) Outbox
2
+
3
+ ![npm version](https://img.shields.io/npm/v/@outbox-event-bus/postgres-drizzle-outbox?style=flat-square&color=2563eb)
4
+ ![npm downloads](https://img.shields.io/npm/dm/@outbox-event-bus/postgres-drizzle-outbox?style=flat-square&color=2563eb)
5
+ ![license](https://img.shields.io/npm/l/@outbox-event-bus/postgres-drizzle-outbox?style=flat-square&color=2563eb)
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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"}