@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/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@outbox-event-bus/postgres-drizzle-outbox",
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/dunika/outbox-event-bus.git",
9
+ "directory": "adapters/postgres-drizzle"
10
+ },
11
+ "homepage": "https://github.com/dunika/outbox-event-bus/tree/main/adapters/postgres-drizzle#readme",
12
+ "type": "module",
13
+ "main": "./dist/index.cjs",
14
+ "module": "./dist/index.mjs",
15
+ "types": "./dist/index.d.mts",
16
+ "exports": {
17
+ ".": {
18
+ "import": {
19
+ "types": "./dist/index.d.mts",
20
+ "default": "./dist/index.mjs"
21
+ },
22
+ "require": {
23
+ "types": "./dist/index.d.cts",
24
+ "default": "./dist/index.cjs"
25
+ }
26
+ }
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "src",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsdown",
36
+ "test": "vitest run",
37
+ "test:e2e": "docker-compose up -d --wait && vitest run -c vitest.e2e.config.ts; status=$?; docker-compose down; exit $status"
38
+ },
39
+ "dependencies": {
40
+ "outbox-event-bus": "workspace:*"
41
+ },
42
+ "peerDependencies": {
43
+ "drizzle-orm": "^0.45.1",
44
+ "postgres": "^3.4.7"
45
+ },
46
+ "devDependencies": {
47
+ "@outbox-event-bus/config": "workspace:*",
48
+ "@types/node": "catalog:",
49
+ "drizzle-kit": "^0.31.8",
50
+ "drizzle-orm": "^0.45.1",
51
+ "postgres": "^3.4.7",
52
+ "tsdown": "catalog:",
53
+ "typescript": "catalog:",
54
+ "vitest": "catalog:"
55
+ }
56
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./postgres-drizzle-outbox"
2
+ export * from "./transaction-storage"
@@ -0,0 +1,461 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { eq } from "drizzle-orm"
3
+ import { drizzle } from "drizzle-orm/postgres-js"
4
+ import postgres from "postgres"
5
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
6
+ import { PostgresDrizzleOutbox } from "./index"
7
+ import { outboxEvents } from "./schema"
8
+
9
+ const DB_URL = "postgres://test_user:test_password@localhost:5433/outbox_test"
10
+
11
+ describe("PostgresDrizzleOutbox E2E", () => {
12
+ let client: postgres.Sql
13
+ let db: ReturnType<typeof drizzle>
14
+ let outbox: PostgresDrizzleOutbox
15
+
16
+ beforeAll(async () => {
17
+ const maxRetries = 10
18
+ const delay = 1000
19
+
20
+ for (let i = 0; i < maxRetries; i++) {
21
+ try {
22
+ client = postgres(DB_URL)
23
+ db = drizzle(client)
24
+ await client`SELECT 1`
25
+ break
26
+ } catch (e: unknown) {
27
+ if (i === maxRetries - 1) {
28
+ throw e
29
+ }
30
+ if (client) {
31
+ await client.end()
32
+ }
33
+ await new Promise((res) => setTimeout(res, delay))
34
+ }
35
+ }
36
+
37
+ // 2. Run migrations (push schema)
38
+ // For this example, we'll just push the schema using drizzle-kit or raw sql if needed.
39
+ // However, since we are in code, we can use `migrate` if we had migration files.
40
+ // Instead, let's create tables manually for this test or assume drizzle-kit push was run.
41
+ // Actually, `drizzle-kit push` is for dev.
42
+ // Let's execute raw SQL to create tables to keep it self-contained without needing migration files generated.
43
+
44
+ // Use unsafe for multiple statements or split them. Postgres.js template tag effectively prepares statements.
45
+
46
+ await client`DROP TABLE IF EXISTS outbox_events_archive`
47
+ await client`DROP TABLE IF EXISTS outbox_events`
48
+ await client`DROP TYPE IF EXISTS outbox_status`
49
+
50
+ await client`
51
+ CREATE TYPE outbox_status AS ENUM ('created', 'active', 'completed', 'failed')
52
+ `
53
+
54
+ await client`
55
+ CREATE TABLE outbox_events (
56
+ id uuid PRIMARY KEY,
57
+ type text NOT NULL,
58
+ payload jsonb NOT NULL,
59
+ occurred_at timestamp NOT NULL,
60
+ status outbox_status NOT NULL DEFAULT 'created',
61
+ retry_count integer NOT NULL DEFAULT 0,
62
+ last_error text,
63
+ next_retry_at timestamp,
64
+ created_on timestamp NOT NULL DEFAULT now(),
65
+ started_on timestamp,
66
+ completed_on timestamp,
67
+ keep_alive timestamp,
68
+ expire_in_seconds integer NOT NULL DEFAULT 300
69
+ )
70
+ `
71
+
72
+ await client`
73
+ CREATE TABLE outbox_events_archive (
74
+ id uuid PRIMARY KEY,
75
+ type text NOT NULL,
76
+ payload jsonb NOT NULL,
77
+ occurred_at timestamp NOT NULL,
78
+ status outbox_status NOT NULL,
79
+ retry_count integer NOT NULL,
80
+ last_error text,
81
+ created_on timestamp NOT NULL,
82
+ started_on timestamp,
83
+ completed_on timestamp NOT NULL
84
+ )
85
+ `
86
+ })
87
+
88
+ beforeEach(async () => {
89
+ await client`TRUNCATE TABLE outbox_events CASCADE`
90
+ await client`TRUNCATE TABLE outbox_events_archive CASCADE`
91
+ })
92
+
93
+ afterAll(async () => {
94
+ await client.end()
95
+ })
96
+
97
+ it("should process events end-to-end", async () => {
98
+ outbox = new PostgresDrizzleOutbox({
99
+ db,
100
+ pollIntervalMs: 100,
101
+ })
102
+
103
+ const eventId = crypto.randomUUID()
104
+ const event = {
105
+ id: eventId,
106
+ type: "user.created",
107
+ payload: { userId: "123", email: "test@example.com" },
108
+ occurredAt: new Date(),
109
+ }
110
+
111
+ await outbox.publish([event])
112
+
113
+ const result = await db.select().from(outboxEvents).where(eq(outboxEvents.id, eventId))
114
+ expect(result).toHaveLength(1)
115
+ expect(result[0]?.status).toBe("created")
116
+
117
+ const processedEvents: any[] = []
118
+ const handler = async (event: any) => {
119
+ processedEvents.push(event)
120
+ }
121
+
122
+ await outbox.start(handler, (err: unknown) => console.error("Outbox Error:", err))
123
+
124
+ await new Promise((resolve) => setTimeout(resolve, 2000))
125
+
126
+ expect(processedEvents).toHaveLength(1)
127
+ expect(processedEvents[0].id).toBe(eventId)
128
+
129
+ const resultAfter = await db.select().from(outboxEvents).where(eq(outboxEvents.id, eventId))
130
+ expect(resultAfter).toHaveLength(0)
131
+
132
+ await outbox.stop()
133
+ })
134
+
135
+ it("should retry failed events", async () => {
136
+ outbox = new PostgresDrizzleOutbox({
137
+ db,
138
+ pollIntervalMs: 100,
139
+ baseBackoffMs: 100,
140
+ })
141
+
142
+ const eventId = crypto.randomUUID()
143
+ const event = {
144
+ id: eventId,
145
+ type: "order.placed",
146
+ payload: { orderId: "abc" },
147
+ occurredAt: new Date(),
148
+ }
149
+
150
+ await outbox.publish([event])
151
+
152
+ let _attempts = 0
153
+ const onError = vi.fn()
154
+
155
+ const handler = async (_event: any) => {
156
+ _attempts++
157
+ throw new Error("Processing failed")
158
+ }
159
+
160
+ await outbox.start(handler, onError)
161
+
162
+ // Wait for multiple attempts
163
+ await new Promise((resolve) => setTimeout(resolve, 2000))
164
+
165
+ await outbox.stop()
166
+
167
+ const result = await db.select().from(outboxEvents).where(eq(outboxEvents.id, eventId))
168
+ expect(result).toHaveLength(1)
169
+ expect(result[0]?.status).toBe("failed")
170
+ expect(result[0]?.retryCount).toBeGreaterThan(1)
171
+ expect(onError).toHaveBeenCalled()
172
+ })
173
+
174
+ it("should support manual management of failed events", async () => {
175
+ outbox = new PostgresDrizzleOutbox({
176
+ db,
177
+ pollIntervalMs: 100,
178
+ })
179
+
180
+ const eventId = crypto.randomUUID()
181
+ const event = {
182
+ id: eventId,
183
+ type: "manual.retry",
184
+ payload: {},
185
+ occurredAt: new Date(),
186
+ }
187
+
188
+ await db.insert(outboxEvents).values({
189
+ id: eventId,
190
+ type: event.type,
191
+ payload: event.payload,
192
+ occurredAt: event.occurredAt,
193
+ status: "failed",
194
+ retryCount: 5,
195
+ lastError: "Manual failure",
196
+ })
197
+
198
+ const inserted = await db.select().from(outboxEvents).where(eq(outboxEvents.id, eventId))
199
+
200
+ const failed = await outbox.getFailedEvents()
201
+ const targetEvent = failed.find((e) => e.id === eventId)
202
+
203
+ expect(targetEvent).toBeDefined()
204
+ expect(targetEvent!.id).toBe(eventId)
205
+ expect(targetEvent!.error).toBe("Manual failure")
206
+ expect(targetEvent!.retryCount).toBe(5)
207
+ await outbox.retryEvents([eventId])
208
+
209
+ const retried = await db.select().from(outboxEvents).where(eq(outboxEvents.id, eventId))
210
+ expect(retried[0]?.status).toBe("created")
211
+ expect(retried[0]?.retryCount).toBe(0)
212
+ expect(retried[0]?.lastError).toBeNull()
213
+
214
+ const processed: any[] = []
215
+ outbox.start(
216
+ async (e) => {
217
+ processed.push(e)
218
+ },
219
+ (err) => console.error(err)
220
+ )
221
+
222
+ await new Promise((r) => setTimeout(r, 1000))
223
+ await outbox.stop()
224
+
225
+ expect(processed).toHaveLength(1)
226
+ expect(processed[0].id).toBe(eventId)
227
+ })
228
+
229
+ it("should recover from stuck events", async () => {
230
+ outbox = new PostgresDrizzleOutbox({
231
+ db,
232
+ pollIntervalMs: 100,
233
+ })
234
+
235
+ const eventId = crypto.randomUUID()
236
+ const now = new Date()
237
+
238
+ await db.insert(outboxEvents).values({
239
+ id: eventId,
240
+ type: "stuck.event",
241
+ payload: { stuck: true },
242
+ occurredAt: new Date(now.getTime() - 400000),
243
+ status: "active",
244
+ retryCount: 0,
245
+ keepAlive: new Date(now.getTime() - 350000),
246
+ expireInSeconds: 300,
247
+ createdOn: new Date(now.getTime() - 400000),
248
+ })
249
+
250
+ const processedEvents: any[] = []
251
+ outbox.start(
252
+ async (event) => {
253
+ processedEvents.push(event)
254
+ },
255
+ (err: unknown) => console.error("Outbox Error:", err)
256
+ )
257
+
258
+ await new Promise((resolve) => setTimeout(resolve, 2000))
259
+
260
+ expect(processedEvents.some((e) => e.id === eventId)).toBe(true)
261
+
262
+ await outbox.stop()
263
+ })
264
+
265
+ it("should handle concurrent processing safely", async () => {
266
+ const eventCount = 50
267
+ const events = Array.from({ length: eventCount }).map((_, i) => ({
268
+ id: crypto.randomUUID(),
269
+ type: "concurrent.test",
270
+ payload: { index: i },
271
+ occurredAt: new Date(),
272
+ }))
273
+
274
+ outbox = new PostgresDrizzleOutbox({
275
+ db,
276
+ pollIntervalMs: 100,
277
+ })
278
+ await outbox.publish(events)
279
+ await outbox.stop()
280
+
281
+ const workerCount = 5
282
+ const processedEvents: any[] = []
283
+ const workers: PostgresDrizzleOutbox[] = []
284
+
285
+ const handler = async (event: any) => {
286
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * 50))
287
+ processedEvents.push(event)
288
+ }
289
+
290
+ for (let i = 0; i < workerCount; i++) {
291
+ const worker = new PostgresDrizzleOutbox({
292
+ db,
293
+ pollIntervalMs: 100 + Math.random() * 50,
294
+ batchSize: 5,
295
+ })
296
+ workers.push(worker)
297
+ worker.start(handler, (err) => console.error(`Worker ${i} Error:`, err))
298
+ }
299
+
300
+ const maxWaitTime = 10000
301
+ const startTime = Date.now()
302
+
303
+ while (processedEvents.length < eventCount && Date.now() - startTime < maxWaitTime) {
304
+ await new Promise((resolve) => setTimeout(resolve, 200))
305
+ }
306
+
307
+ await Promise.all(workers.map((w) => w.stop()))
308
+
309
+ expect(processedEvents).toHaveLength(eventCount)
310
+ const ids = processedEvents.map((event) => event.id)
311
+ const uniqueIds = new Set(ids)
312
+ expect(uniqueIds.size).toBe(eventCount)
313
+ })
314
+
315
+ it("should handle partial batch failures (one succeeds, one fails)", async () => {
316
+ outbox = new PostgresDrizzleOutbox({
317
+ db,
318
+ pollIntervalMs: 100,
319
+ baseBackoffMs: 100,
320
+ })
321
+
322
+ const successId = randomUUID()
323
+ const failId = randomUUID()
324
+
325
+ const events = [
326
+ {
327
+ id: successId,
328
+ type: "partial.success",
329
+ payload: { fail: false },
330
+ occurredAt: new Date(),
331
+ },
332
+ {
333
+ id: failId,
334
+ type: "partial.fail",
335
+ payload: { fail: true },
336
+ occurredAt: new Date(),
337
+ },
338
+ ]
339
+
340
+ await outbox.publish(events)
341
+
342
+ const processedEvents: any[] = []
343
+ const onError = vi.fn()
344
+
345
+ const handler = async (event: any) => {
346
+ if (event.payload.fail) {
347
+ throw new Error("Intentional partial failure")
348
+ }
349
+ processedEvents.push(event)
350
+ }
351
+
352
+ await outbox.start(handler, onError)
353
+
354
+ await new Promise((resolve) => setTimeout(resolve, 3000))
355
+
356
+ await outbox.stop()
357
+
358
+ const successResult = await db.select().from(outboxEvents).where(eq(outboxEvents.id, successId))
359
+ expect(successResult).toHaveLength(0)
360
+
361
+ const failResult = await db.select().from(outboxEvents).where(eq(outboxEvents.id, failId))
362
+ expect(failResult).toHaveLength(1)
363
+ expect(failResult[0]?.status).toBe("failed")
364
+ expect(failResult[0]?.retryCount).toBeGreaterThan(0)
365
+
366
+ // 3. Handler should have successfully processed one
367
+ expect(processedEvents).toHaveLength(1)
368
+ expect(processedEvents[0].id).toBe(successId)
369
+
370
+ expect(onError).toHaveBeenCalled()
371
+ })
372
+
373
+ it("should not publish events when transaction is rolled back", async () => {
374
+ outbox = new PostgresDrizzleOutbox({
375
+ db,
376
+ pollIntervalMs: 100,
377
+ })
378
+
379
+ const eventId = crypto.randomUUID()
380
+ const event = {
381
+ id: eventId,
382
+ type: "transaction.test",
383
+ payload: { test: "rollback" },
384
+ occurredAt: new Date(),
385
+ }
386
+
387
+ try {
388
+ await db.transaction(async (tx) => {
389
+ await outbox.publish([event], tx)
390
+
391
+ const inTx = await tx.select().from(outboxEvents).where(eq(outboxEvents.id, eventId))
392
+ expect(inTx).toHaveLength(1)
393
+ expect(inTx[0]?.status).toBe("created")
394
+
395
+ throw new Error("Intentional rollback")
396
+ })
397
+ } catch (error: any) {
398
+ expect(error.message).toBe("Intentional rollback")
399
+ }
400
+
401
+ const afterRollback = await db.select().from(outboxEvents).where(eq(outboxEvents.id, eventId))
402
+ expect(afterRollback).toHaveLength(0)
403
+
404
+ const processedEvents: any[] = []
405
+ outbox.start(
406
+ async (e) => {
407
+ processedEvents.push(e)
408
+ },
409
+ (err) => console.error(err)
410
+ )
411
+
412
+ await new Promise((r) => setTimeout(r, 1000))
413
+ await outbox.stop()
414
+
415
+ expect(processedEvents).toHaveLength(0)
416
+ })
417
+
418
+ it("should publish events when transaction is committed", async () => {
419
+ outbox = new PostgresDrizzleOutbox({
420
+ db,
421
+ pollIntervalMs: 100,
422
+ })
423
+
424
+ const eventId = crypto.randomUUID()
425
+ const event = {
426
+ id: eventId,
427
+ type: "transaction.test",
428
+ payload: { test: "commit" },
429
+ occurredAt: new Date(),
430
+ }
431
+
432
+ await db.transaction(async (tx) => {
433
+ await outbox.publish([event], tx)
434
+
435
+ const inTx = await tx.select().from(outboxEvents).where(eq(outboxEvents.id, eventId))
436
+ expect(inTx).toHaveLength(1)
437
+ expect(inTx[0]?.status).toBe("created")
438
+ })
439
+
440
+ const afterCommit = await db.select().from(outboxEvents).where(eq(outboxEvents.id, eventId))
441
+ expect(afterCommit).toHaveLength(1)
442
+ expect(afterCommit[0]?.status).toBe("created")
443
+
444
+ const processedEvents: any[] = []
445
+ outbox.start(
446
+ async (e) => {
447
+ processedEvents.push(e)
448
+ },
449
+ (err) => console.error(err)
450
+ )
451
+
452
+ await new Promise((r) => setTimeout(r, 1000))
453
+ await outbox.stop()
454
+
455
+ expect(processedEvents).toHaveLength(1)
456
+ expect(processedEvents[0].id).toBe(eventId)
457
+
458
+ const final = await db.select().from(outboxEvents).where(eq(outboxEvents.id, eventId))
459
+ expect(final).toHaveLength(0)
460
+ })
461
+ })
@@ -0,0 +1,156 @@
1
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
3
+ import { PostgresDrizzleOutbox } from "./index"
4
+ import { outboxEvents } from "./schema"
5
+
6
+ // Mock the database client
7
+ const mockDb = {
8
+ insert: vi.fn(),
9
+ select: vi.fn(),
10
+ update: vi.fn(),
11
+ delete: vi.fn(),
12
+ transaction: vi.fn(),
13
+ } as unknown as PostgresJsDatabase<any>
14
+
15
+ describe("PostgresDrizzleOutbox", () => {
16
+ let outbox: PostgresDrizzleOutbox
17
+ let queryBuilder: any
18
+
19
+ beforeEach(() => {
20
+ vi.clearAllMocks()
21
+
22
+ queryBuilder = {
23
+ values: vi.fn().mockReturnThis(),
24
+ from: vi.fn().mockReturnThis(),
25
+ where: vi.fn().mockReturnThis(),
26
+ limit: vi.fn().mockReturnThis(),
27
+ for: vi.fn().mockReturnThis(),
28
+ set: vi.fn().mockReturnThis(),
29
+ }
30
+
31
+ // Configure db methods to return the builder
32
+ ;(mockDb.insert as any).mockReturnValue(queryBuilder)
33
+ ;(mockDb.select as any).mockReturnValue(queryBuilder)
34
+ ;(mockDb.update as any).mockReturnValue(queryBuilder)
35
+ ;(mockDb.delete as any).mockReturnValue(queryBuilder)
36
+
37
+ // Transaction mock
38
+ ;(mockDb.transaction as any).mockImplementation(async (cb: any) => cb(mockDb))
39
+
40
+ outbox = new PostgresDrizzleOutbox({ db: mockDb, pollIntervalMs: 50 })
41
+ })
42
+
43
+ afterEach(async () => {
44
+ await outbox.stop()
45
+ })
46
+
47
+ it("should publish events", async () => {
48
+ const events = [
49
+ {
50
+ id: "1",
51
+ type: "test",
52
+ payload: {},
53
+ occurredAt: new Date(),
54
+ },
55
+ ]
56
+
57
+ await outbox.publish(events)
58
+
59
+ expect(mockDb.insert).toHaveBeenCalledWith(outboxEvents)
60
+ expect(queryBuilder.values).toHaveBeenCalledWith(
61
+ expect.arrayContaining([expect.objectContaining({ id: "1", status: "created" })])
62
+ )
63
+ })
64
+
65
+ it("should poll and process events", async () => {
66
+ const testEvents = [
67
+ {
68
+ id: "1",
69
+ type: "test",
70
+ payload: {},
71
+ occurredAt: new Date(),
72
+ status: "created",
73
+ retryCount: 0,
74
+ createdOn: new Date(),
75
+ },
76
+ ]
77
+
78
+ queryBuilder.for.mockResolvedValueOnce(testEvents)
79
+ queryBuilder.for.mockResolvedValue([])
80
+
81
+ const handler = vi.fn().mockResolvedValue(undefined)
82
+ const onError = vi.fn()
83
+
84
+ outbox.start(handler, onError)
85
+
86
+ await new Promise((resolve) => setTimeout(resolve, 150))
87
+
88
+ expect(handler).toHaveBeenCalled()
89
+ expect(mockDb.update).toHaveBeenCalled()
90
+ expect(queryBuilder.set).toHaveBeenCalledWith(expect.objectContaining({ status: "active" }))
91
+
92
+ expect(mockDb.insert).toHaveBeenCalledWith(expect.anything())
93
+ expect(mockDb.delete).toHaveBeenCalledWith(outboxEvents)
94
+ })
95
+
96
+ it("should retry failed events", async () => {
97
+ const testEvents = [
98
+ {
99
+ id: "1",
100
+ type: "test",
101
+ payload: {},
102
+ occurredAt: new Date(),
103
+ status: "created",
104
+ retryCount: 0,
105
+ createdOn: new Date(),
106
+ },
107
+ ]
108
+
109
+ queryBuilder.for.mockResolvedValueOnce(testEvents)
110
+ queryBuilder.for.mockResolvedValue([])
111
+
112
+ const handler = vi.fn().mockRejectedValue(new Error("processing failed"))
113
+
114
+ outbox.start(handler, vi.fn())
115
+ await new Promise((resolve) => setTimeout(resolve, 200))
116
+
117
+ expect(handler).toHaveBeenCalled()
118
+
119
+ // Should verify it updated to failed state
120
+ expect(queryBuilder.set).toHaveBeenCalledWith(
121
+ expect.objectContaining({
122
+ status: "failed",
123
+ lastError: "processing failed",
124
+ retryCount: 1,
125
+ })
126
+ )
127
+ })
128
+
129
+ it("should recover stuck active events", async () => {
130
+ const activeStuckEvent = {
131
+ id: "1",
132
+ type: "test",
133
+ payload: {},
134
+ occurredAt: new Date(),
135
+ status: "active",
136
+ retryCount: 0,
137
+ createdOn: new Date(),
138
+ keepAlive: new Date(Date.now() - 1000 * 60 * 10), // 10 mins ago (default expire is 5 mins)
139
+ expireInSeconds: 300,
140
+ }
141
+
142
+ // Mock select return
143
+ queryBuilder.for.mockResolvedValueOnce([activeStuckEvent])
144
+ queryBuilder.for.mockResolvedValue([])
145
+
146
+ const handler = vi.fn().mockResolvedValue(undefined)
147
+
148
+ outbox.start(handler, vi.fn())
149
+ await new Promise((resolve) => setTimeout(resolve, 60))
150
+
151
+ expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: "1" }))
152
+
153
+ // Should be picked up and processed (status updated to active again with new timestamp)
154
+ expect(queryBuilder.set).toHaveBeenCalledWith(expect.objectContaining({ status: "active" }))
155
+ })
156
+ })