@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.
@@ -0,0 +1,195 @@
1
+ import { and, eq, inArray, lt, or, sql } from "drizzle-orm"
2
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"
3
+ import {
4
+ type BusEvent,
5
+ type ErrorHandler,
6
+ EventStatus,
7
+ type FailedBusEvent,
8
+ formatErrorMessage,
9
+ type IOutbox,
10
+ type OutboxConfig,
11
+ PollingService,
12
+ reportEventError,
13
+ } from "outbox-event-bus"
14
+ import { outboxEvents, outboxEventsArchive } from "./schema"
15
+
16
+ export interface PostgresDrizzleOutboxConfig extends OutboxConfig {
17
+ db: PostgresJsDatabase<Record<string, unknown>>
18
+ getTransaction?: (() => PostgresJsDatabase<Record<string, unknown>> | undefined) | undefined
19
+ tables?: {
20
+ outboxEvents: typeof outboxEvents
21
+ outboxEventsArchive: typeof outboxEventsArchive
22
+ }
23
+ }
24
+
25
+ export class PostgresDrizzleOutbox implements IOutbox<PostgresJsDatabase<Record<string, unknown>>> {
26
+ private readonly config: Required<PostgresDrizzleOutboxConfig>
27
+ private readonly poller: PollingService
28
+
29
+ constructor(config: PostgresDrizzleOutboxConfig) {
30
+ this.config = {
31
+ batchSize: config.batchSize ?? 50,
32
+ pollIntervalMs: config.pollIntervalMs ?? 1000,
33
+ maxRetries: config.maxRetries ?? 5,
34
+ baseBackoffMs: config.baseBackoffMs ?? 1000,
35
+ processingTimeoutMs: config.processingTimeoutMs ?? 30000,
36
+ maxErrorBackoffMs: config.maxErrorBackoffMs ?? 30000,
37
+ db: config.db,
38
+ getTransaction: config.getTransaction,
39
+ tables: config.tables ?? {
40
+ outboxEvents,
41
+ outboxEventsArchive,
42
+ },
43
+ }
44
+
45
+ this.poller = new PollingService({
46
+ pollIntervalMs: this.config.pollIntervalMs,
47
+ baseBackoffMs: this.config.baseBackoffMs,
48
+ maxErrorBackoffMs: this.config.maxErrorBackoffMs,
49
+ processBatch: (handler) => this.processBatch(handler),
50
+ })
51
+ }
52
+
53
+ async publish(
54
+ events: BusEvent[],
55
+ transaction?: PostgresJsDatabase<Record<string, unknown>>
56
+ ): Promise<void> {
57
+ const executor = transaction ?? this.config.getTransaction?.() ?? this.config.db
58
+
59
+ await executor.insert(this.config.tables.outboxEvents).values(
60
+ events.map((event) => ({
61
+ id: event.id,
62
+ type: event.type,
63
+ payload: event.payload,
64
+ occurredAt: event.occurredAt,
65
+ status: EventStatus.CREATED,
66
+ }))
67
+ )
68
+ }
69
+
70
+ async getFailedEvents(): Promise<FailedBusEvent[]> {
71
+ const events = await this.config.db
72
+ .select()
73
+ .from(this.config.tables.outboxEvents)
74
+ .where(eq(this.config.tables.outboxEvents.status, EventStatus.FAILED))
75
+ .orderBy(sql`${this.config.tables.outboxEvents.occurredAt} DESC`)
76
+ .limit(100)
77
+
78
+ return events.map((event) => {
79
+ const failedEvent: FailedBusEvent = {
80
+ id: event.id,
81
+ type: event.type,
82
+ payload: event.payload as any,
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
+
92
+ async retryEvents(eventIds: string[]): Promise<void> {
93
+ await this.config.db
94
+ .update(this.config.tables.outboxEvents)
95
+ .set({
96
+ status: EventStatus.CREATED,
97
+ retryCount: 0,
98
+ nextRetryAt: null,
99
+ lastError: null,
100
+ })
101
+ .where(inArray(this.config.tables.outboxEvents.id, eventIds))
102
+ }
103
+
104
+ start(handler: (event: BusEvent) => Promise<void>, onError: ErrorHandler): void {
105
+ this.poller.start(handler, onError)
106
+ }
107
+
108
+ async stop(): Promise<void> {
109
+ await this.poller.stop()
110
+ }
111
+
112
+ private async processBatch(handler: (event: BusEvent) => Promise<void>) {
113
+ await this.config.db.transaction(async (transaction) => {
114
+ const now = new Date()
115
+
116
+ // Select events that are:
117
+ // 1. New (status = created)
118
+ // 2. Failed but can be retried (retry count < max AND retry time has passed)
119
+ // 3. Active but stuck/timed out (keepAlive is older than now - expireInSeconds)
120
+ const events = await transaction
121
+ .select()
122
+ .from(this.config.tables.outboxEvents)
123
+ .where(
124
+ or(
125
+ eq(this.config.tables.outboxEvents.status, EventStatus.CREATED),
126
+ and(
127
+ eq(this.config.tables.outboxEvents.status, EventStatus.FAILED),
128
+ lt(this.config.tables.outboxEvents.retryCount, this.config.maxRetries),
129
+ lt(this.config.tables.outboxEvents.nextRetryAt, now)
130
+ ),
131
+ and(
132
+ eq(this.config.tables.outboxEvents.status, EventStatus.ACTIVE),
133
+ // Check if event is stuck: keepAlive is older than (now - expireInSeconds)
134
+ // This uses PostgreSQL's make_interval to subtract expireInSeconds from current timestamp
135
+ lt(
136
+ this.config.tables.outboxEvents.keepAlive,
137
+ sql`${now.toISOString()}::timestamp - make_interval(secs => ${this.config.tables.outboxEvents.expireInSeconds})`
138
+ )
139
+ )
140
+ )
141
+ )
142
+ .limit(this.config.batchSize)
143
+ .for("update", { skipLocked: true })
144
+
145
+ if (events.length === 0) return
146
+
147
+ const eventIds = events.map((event) => event.id)
148
+
149
+ await transaction
150
+ .update(this.config.tables.outboxEvents)
151
+ .set({
152
+ status: EventStatus.ACTIVE,
153
+ startedOn: now,
154
+ keepAlive: now,
155
+ })
156
+ .where(inArray(this.config.tables.outboxEvents.id, eventIds))
157
+
158
+ for (const event of events) {
159
+ try {
160
+ await handler(event)
161
+ // Archive successful event immediately
162
+ await transaction.insert(this.config.tables.outboxEventsArchive).values({
163
+ id: event.id,
164
+ type: event.type,
165
+ payload: event.payload,
166
+ occurredAt: event.occurredAt,
167
+ status: EventStatus.COMPLETED,
168
+ retryCount: event.retryCount,
169
+ createdOn: event.createdOn,
170
+ startedOn: now,
171
+ completedOn: new Date(),
172
+ })
173
+ await transaction
174
+ .delete(this.config.tables.outboxEvents)
175
+ .where(eq(this.config.tables.outboxEvents.id, event.id))
176
+ } catch (error: unknown) {
177
+ const retryCount = event.retryCount + 1
178
+ reportEventError(this.poller.onError, error, event, retryCount, this.config.maxRetries)
179
+
180
+ // Mark this specific event as failed
181
+ const delay = this.poller.calculateBackoff(retryCount)
182
+ await transaction
183
+ .update(this.config.tables.outboxEvents)
184
+ .set({
185
+ status: EventStatus.FAILED,
186
+ retryCount,
187
+ lastError: formatErrorMessage(error),
188
+ nextRetryAt: new Date(Date.now() + delay),
189
+ })
190
+ .where(eq(this.config.tables.outboxEvents.id, event.id))
191
+ }
192
+ }
193
+ })
194
+ }
195
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { integer, jsonb, pgEnum, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"
2
+
3
+ export const outboxStatusEnum = pgEnum("outbox_status", [
4
+ "created",
5
+ "active",
6
+ "completed",
7
+ "failed",
8
+ ])
9
+
10
+ export const outboxEvents = pgTable("outbox_events", {
11
+ id: uuid("id").primaryKey(),
12
+ type: text("type").notNull(),
13
+ payload: jsonb("payload").notNull(),
14
+ occurredAt: timestamp("occurred_at").notNull(),
15
+ status: outboxStatusEnum("status").notNull().default("created"),
16
+ retryCount: integer("retry_count").notNull().default(0),
17
+ lastError: text("last_error"),
18
+ nextRetryAt: timestamp("next_retry_at"),
19
+ createdOn: timestamp("created_on").notNull().defaultNow(),
20
+ startedOn: timestamp("started_on"),
21
+ completedOn: timestamp("completed_on"),
22
+ keepAlive: timestamp("keep_alive"),
23
+ expireInSeconds: integer("expire_in_seconds").notNull().default(300),
24
+ })
25
+
26
+ export const outboxEventsArchive = pgTable("outbox_events_archive", {
27
+ id: uuid("id").primaryKey(),
28
+ type: text("type").notNull(),
29
+ payload: jsonb("payload").notNull(),
30
+ occurredAt: timestamp("occurred_at").notNull(),
31
+ status: outboxStatusEnum("status").notNull(),
32
+ retryCount: integer("retry_count").notNull(),
33
+ lastError: text("last_error"),
34
+ createdOn: timestamp("created_on").notNull(),
35
+ startedOn: timestamp("started_on"),
36
+ completedOn: timestamp("completed_on").notNull(),
37
+ })
@@ -0,0 +1,21 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks"
2
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"
3
+
4
+ export const drizzleTransactionStorage = new AsyncLocalStorage<
5
+ PostgresJsDatabase<Record<string, unknown>>
6
+ >()
7
+
8
+ export async function withDrizzleTransaction<T>(
9
+ db: PostgresJsDatabase<Record<string, unknown>>,
10
+ fn: (tx: PostgresJsDatabase<Record<string, unknown>>) => Promise<T>
11
+ ): Promise<T> {
12
+ return db.transaction(async (tx) => {
13
+ return drizzleTransactionStorage.run(tx, () => fn(tx))
14
+ })
15
+ }
16
+
17
+ export function getDrizzleTransaction(): () =>
18
+ | PostgresJsDatabase<Record<string, unknown>>
19
+ | undefined {
20
+ return () => drizzleTransactionStorage.getStore()
21
+ }
@@ -0,0 +1,142 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks"
2
+ import { eq } from "drizzle-orm"
3
+ import { pgTable, text, timestamp } from "drizzle-orm/pg-core"
4
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"
5
+ import { drizzle } from "drizzle-orm/postgres-js"
6
+ import { OutboxEventBus } from "outbox-event-bus"
7
+ import postgres from "postgres"
8
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
9
+ import { PostgresDrizzleOutbox } from "./index"
10
+ import { outboxEvents } from "./schema"
11
+
12
+ // Define a dummy business table for the test
13
+ const users = pgTable("users", {
14
+ id: text("id").primaryKey(),
15
+ name: text("name").notNull(),
16
+ createdAt: timestamp("created_at").defaultNow().notNull(),
17
+ })
18
+
19
+ type Db = PostgresJsDatabase<Record<string, never>>
20
+
21
+ describe("PostgresDrizzle Outbox Transactions with AsyncLocalStorage", () => {
22
+ const sql = postgres("postgres://test_user:test_password@localhost:5433/outbox_test")
23
+ const db = drizzle(sql)
24
+
25
+ const als = new AsyncLocalStorage<Db>()
26
+
27
+ // A helper that acts as a sqlExecutor, grabbing the transaction from ALS if it exists
28
+ const _sqlExecutorProxy = new Proxy(db, {
29
+ get(target, prop, receiver) {
30
+ const transaction = als.getStore()
31
+ return Reflect.get(transaction ?? target, prop, receiver)
32
+ },
33
+ }) as unknown as Db
34
+
35
+ const outbox = new PostgresDrizzleOutbox({
36
+ db,
37
+ getTransaction: () => als.getStore(),
38
+ pollIntervalMs: 50,
39
+ })
40
+
41
+ // We use the proxy as the context for the event bus
42
+ const eventBus = new OutboxEventBus(
43
+ outbox,
44
+ (_bus, type, count) => console.warn(`Max listeners for ${type}: ${count}`),
45
+ (err) => console.error("Bus error:", err)
46
+ )
47
+
48
+ beforeAll(async () => {
49
+ // Schema setup (in a real app this would be migrations)
50
+ await sql`DROP TABLE IF EXISTS users`
51
+ await sql`DROP TABLE IF EXISTS outbox_events`
52
+ await sql`DROP TABLE IF EXISTS outbox_events_archive`
53
+ await sql`DROP TYPE IF EXISTS outbox_status`
54
+
55
+ await sql`CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT, created_at TIMESTAMP DEFAULT NOW())`
56
+ await sql`
57
+ CREATE TYPE outbox_status AS ENUM ('created', 'active', 'completed', 'failed')
58
+ `
59
+ await sql`CREATE TABLE outbox_events (
60
+ id UUID PRIMARY KEY,
61
+ type TEXT NOT NULL,
62
+ payload JSONB NOT NULL,
63
+ occurred_at TIMESTAMP NOT NULL,
64
+ status outbox_status NOT NULL DEFAULT 'created',
65
+ retry_count INTEGER NOT NULL DEFAULT 0,
66
+ last_error TEXT,
67
+ next_retry_at TIMESTAMP,
68
+ created_on TIMESTAMP NOT NULL DEFAULT NOW(),
69
+ started_on TIMESTAMP,
70
+ completed_on TIMESTAMP,
71
+ keep_alive TIMESTAMP,
72
+ expire_in_seconds INTEGER NOT NULL DEFAULT 300
73
+ )`
74
+ })
75
+
76
+ beforeEach(async () => {
77
+ await sql`DELETE FROM users`
78
+ await sql`DELETE FROM outbox_events`
79
+ })
80
+
81
+ afterAll(async () => {
82
+ await sql.end()
83
+ })
84
+
85
+ it("should commit both business data and outbox event in a transaction", async () => {
86
+ const eventId = "3ed0f0a5-f4e1-4c7b-b5d1-1234567890ab"
87
+ const userId = "user_456"
88
+
89
+ await db.transaction(async (transaction) => {
90
+ await als.run(transaction, async () => {
91
+ // 1. Perform business operation
92
+ await transaction.insert(users).values({ id: userId, name: "Alice" })
93
+
94
+ // 2. Emit event using the transaction from ALS via our proxy
95
+ await eventBus.emit({
96
+ id: eventId,
97
+ type: "USER_CREATED",
98
+ payload: { userId, name: "Alice" },
99
+ occurredAt: new Date(),
100
+ })
101
+ })
102
+ })
103
+
104
+ // Verify both are persisted
105
+ const user = await db.select().from(users).where(eq(users.id, userId))
106
+ expect(user).toHaveLength(1)
107
+
108
+ const event = await db.select().from(outboxEvents).where(eq(outboxEvents.id, eventId))
109
+ expect(event).toHaveLength(1)
110
+ })
111
+
112
+ it("should rollback both business data and outbox event on failure", async () => {
113
+ const eventId = "4fd1f1b6-f5f2-4d8c-c6e2-2345678901bc"
114
+ const userId = "user_fail"
115
+
116
+ try {
117
+ await db.transaction(async (transaction) => {
118
+ await als.run(transaction, async () => {
119
+ await transaction.insert(users).values({ id: userId, name: "Bob" })
120
+
121
+ await eventBus.emit({
122
+ id: eventId,
123
+ type: "USER_CREATED",
124
+ payload: { userId, name: "Bob" },
125
+ occurredAt: new Date(),
126
+ })
127
+
128
+ throw new Error("Forced rollback")
129
+ })
130
+ })
131
+ } catch (_err) {
132
+ // Expected
133
+ }
134
+
135
+ // Verify neither are persisted
136
+ const user = await db.select().from(users).where(eq(users.id, userId))
137
+ expect(user).toHaveLength(0)
138
+
139
+ const event = await db.select().from(outboxEvents).where(eq(outboxEvents.id, eventId))
140
+ expect(event).toHaveLength(0)
141
+ })
142
+ })