@outbox-event-bus/dynamodb-aws-sdk-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,469 @@
1
+ # DynamoDB AWS SDK Outbox Adapter
2
+
3
+ ![npm version](https://img.shields.io/npm/v/@outbox-event-bus/dynamodb-aws-sdk-outbox?style=flat-square&color=2563eb)
4
+ ![npm downloads](https://img.shields.io/npm/dm/@outbox-event-bus/dynamodb-aws-sdk-outbox?style=flat-square&color=2563eb)
5
+ ![license](https://img.shields.io/npm/l/@outbox-event-bus/dynamodb-aws-sdk-outbox?style=flat-square&color=2563eb)
6
+
7
+ > **Reliable, Serverless-First Event Storage for DynamoDB**
8
+
9
+ The DynamoDB adapter for `outbox-event-bus` provides a high-performance, resilient outbox implementation designed specifically for AWS environments. It leverages DynamoDB's native scalability, TTL features, and Global Secondary Indices (GSIs) to ensure zero event loss even under intense load.
10
+
11
+ ---
12
+
13
+ ## How it Works
14
+
15
+ The adapter persists events in a DynamoDB table and uses a Global Secondary Index (GSI) to efficiently track event progression.
16
+
17
+ ```mermaid
18
+ graph LR
19
+ A[emit] --> B{DynamoDB Table}
20
+ B -- Pending --> C[Poller]
21
+ C -- Claim --> D[Processing]
22
+ D -- Success --> E[Completed / Removed]
23
+ D -- Error --> F[Retry / Failed]
24
+ ```
25
+
26
+ ### Why DynamoDB?
27
+ - **Zero Connection Pools**: Unlike RDS, DynamoDB handles HTTP connections natively—perfect for AWS Lambda.
28
+ - **Auto-Scaling**: Seamlessly scales from 0 to millions of events without managing IOPS.
29
+ - **Fine-Grained IAM**: Security-first approach with native AWS authentication.
30
+
31
+ ---
32
+
33
+ ## Quick Start
34
+
35
+ ### 1. Installation
36
+
37
+ ```bash
38
+ npm install @outbox-event-bus/dynamodb-aws-sdk-outbox
39
+ ```
40
+
41
+ ### 2. Prepare Your Table
42
+
43
+ Create a DynamoDB table with an `id` partition key and a GSI for status tracking.
44
+
45
+ | Component | Attribute | Type | Detail |
46
+ | :--- | :--- | :--- | :--- |
47
+ | **Table PK** | `id` | String | Unique Event ID |
48
+ | **GSI PK** | `status` | String | `created`, `active`, etc. |
49
+ | **GSI SK** | `gsiSortKey` | Number | Unix timestamp for ordering |
50
+
51
+ ### 3. Initialize & Start
52
+
53
+ ```typescript
54
+ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
55
+ import { DynamoDBAwsSdkOutbox } from '@outbox-event-bus/dynamodb-aws-sdk-outbox';
56
+ import { OutboxEventBus } from 'outbox-event-bus';
57
+
58
+ const outbox = new DynamoDBAwsSdkOutbox({
59
+ client: new DynamoDBClient({ region: 'us-east-1' }),
60
+ tableName: 'my-events-table',
61
+ statusIndexName: 'status-gsiSortKey-index' // Name of your GSI
62
+ });
63
+
64
+ const bus = new OutboxEventBus(outbox, (error: OutboxError) => {
65
+ const event = error.context?.event;
66
+ console.error('Event failed:', event?.type, error);
67
+ });
68
+
69
+ bus.start();
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Feature Highlights
75
+
76
+ - **Atomic Transactions**: Emit events and update your business data in a single atomic operation.
77
+ - **Optimistic Locking**: Guarantees "at-least-once" delivery without double-processing, even with multiple workers.
78
+ - **Zombie Recovery**: Automatically detects and restarts events that got stuck due to Lambda timeouts or crashes.
79
+ - **Batch Processing**: Groups event updates into efficient `BatchWriteItem` requests.
80
+
81
+ ---
82
+
83
+ ## Concurrency & Locking
84
+
85
+ This adapter uses **Optimistic Locking** via Conditional Writes to ensure safe concurrent processing.
86
+
87
+ - **Atomic Claims**: The adapter uses `ConditionExpression` (e.g., `attribute_not_exists(locked_by)`) to claim events.
88
+ - **Multiple Workers**: You can safely run multiple instances (e.g., Lambda functions).
89
+ - **No Duplicates**: DynamoDB guarantees that only one worker can successfully claim a specific event.
90
+
91
+ ## How-to Guides
92
+
93
+ ### Transactional Writes (AsyncLocalStorage)
94
+
95
+ The recommended way to use the outbox is with `AsyncLocalStorage`. This allows you to collect multiple database operations and an event into a single atomic transaction.
96
+
97
+ ```typescript
98
+ import { TransactWriteCommand } from '@aws-sdk/lib-dynamodb';
99
+ import { AsyncLocalStorage } from 'node:async_hooks';
100
+
101
+ const als = new AsyncLocalStorage<any>();
102
+ const outbox = new DynamoDBAwsSdkOutbox({
103
+ client,
104
+ tableName: 'events',
105
+ getCollector: () => als.getStore() // Link the outbox to the current store
106
+ });
107
+
108
+ async function createUser(user: any) {
109
+ const transactionItems: any[] = [];
110
+
111
+ await als.run({ push: (item) => transactionItems.push(item) }, async () => {
112
+ // 1. Business Logic
113
+ transactionItems.push({ Put: { TableName: 'Users', Item: user } });
114
+
115
+ // 2. Emit Event (automatically attached to transactionItems via collector)
116
+ await bus.emit({ id: '...', type: 'user.created', payload: user });
117
+
118
+ // 3. Commit
119
+ await client.send(new TransactWriteCommand({ TransactItems: transactionItems }));
120
+ });
121
+ }
122
+ ```
123
+
124
+ ### Manual Transaction Collection
125
+
126
+ If you prefer explicit over implicit, you can pass a collection array directly.
127
+
128
+ ```typescript
129
+ const items: any[] = [];
130
+ items.push({ Put: { TableName: 'Data', Item: { id: 1 } } });
131
+
132
+ // The second argument is the collector
133
+ await bus.emit({ type: 'item.updated', payload: { id: 1 } }, items);
134
+
135
+ await client.send(new TransactWriteCommand({ TransactItems: items }));
136
+ ```
137
+
138
+ ---
139
+
140
+ ## Configuration Reference
141
+
142
+ ### `DynamoDBAwsSdkOutboxConfig`
143
+
144
+ ```typescript
145
+ interface DynamoDBAwsSdkOutboxConfig extends OutboxConfig {
146
+ // DynamoDB-specific options
147
+ client: DynamoDBClient; // AWS SDK v3 Client instance
148
+ tableName: string; // DynamoDB table name
149
+ statusIndexName?: string; // GSI name (default: 'status-gsiSortKey-index')
150
+ getCollector?: () => any[] | undefined; // Transaction collector getter
151
+ // Inherited from OutboxConfig
152
+ batchSize?: number; // Events per poll (default: 50)
153
+ pollIntervalMs?: number; // Polling interval (default: 1000ms)
154
+ processingTimeoutMs?: number; // Processing timeout (default: 30000ms)
155
+ maxRetries?: number; // Max retry attempts (default: 5)
156
+ baseBackoffMs?: number; // Base backoff delay (default: 1000ms)
157
+ maxErrorBackoffMs?: number; // Max polling error backoff (default: 30000ms)
158
+ }
159
+ ```
160
+
161
+ > [!NOTE]
162
+ > All adapters inherit base configuration options from `OutboxConfig`. See the [API Reference](https://github.com/dunika/outbox-event-bus/blob/main/docs/API_REFERENCE.md#base-outbox-configuration) for details on inherited options.
163
+
164
+ | Option | Type | Default | Description |
165
+ | :--- | :--- | :--- | :--- |
166
+ | `client` | `DynamoDBClient` | **Required** | AWS SDK v3 Client instance. |
167
+ | `tableName` | `string` | **Required** | The DynamoDB table name. |
168
+ | `statusIndexName` | `string` | `'status-gsiSortKey-index'` | The name of the GSI used for polling. |
169
+ | `getCollector` | `() => any[] \| undefined` | - | Transaction collector getter for atomic writes. |
170
+ | `batchSize` | `number` | `50` | Number of events to claim per poll. |
171
+ | `pollIntervalMs` | `number` | `1000` | Delay between poll cycles. |
172
+ | `processingTimeoutMs`| `number` | `30000` | After this time, a 'PROCESSING' event is considered stuck. |
173
+ | `maxRetries` | `number` | `5` | Maximum attempts for a failed event. |
174
+ | `baseBackoffMs` | `number` | `1000` | Base delay for exponential backoff. |
175
+ | `maxErrorBackoffMs` | `number` | `30000` | Maximum backoff delay after polling errors. |
176
+
177
+ ---
178
+
179
+ ## API Reference
180
+
181
+ ### `DynamoDBAwsSdkOutbox`
182
+
183
+ ```typescript
184
+ class DynamoDBAwsSdkOutbox implements IOutbox<any[]>
185
+ ```
186
+
187
+ #### Constructor
188
+
189
+ ```typescript
190
+ constructor(config: DynamoDBAwsSdkOutboxConfig)
191
+ ```
192
+
193
+ Creates a new DynamoDB outbox adapter using AWS SDK v3.
194
+
195
+ **Parameters:**
196
+ - `config`: Configuration object (see [Configuration Reference](#configuration-reference))
197
+
198
+ **Example:**
199
+ ```typescript
200
+ const outbox = new DynamoDBAwsSdkOutbox({
201
+ client: new DynamoDBClient({ region: 'us-east-1' }),
202
+ tableName: 'my-events-table',
203
+ statusIndexName: 'status-index',
204
+ batchSize: 100
205
+ });
206
+ ```
207
+
208
+ ---
209
+
210
+ #### Methods
211
+
212
+ ##### `publish(events: BusEvent[], transaction?: any[]): Promise<void>`
213
+
214
+ Publishes events to the outbox. If a `transaction` collector array is provided, events are added to it for atomic writes. Otherwise, events are written immediately.
215
+
216
+ **Parameters:**
217
+ - `events`: Array of events to publish
218
+ - `transaction`: Optional collector array for TransactWriteCommand
219
+
220
+ **Example:**
221
+ ```typescript
222
+ // Direct publish
223
+ await outbox.publish([
224
+ { id: '1', type: 'user.created', payload: user, occurredAt: new Date() }
225
+ ]);
226
+
227
+ // With transaction collector
228
+ const items: any[] = [];
229
+ await outbox.publish([event], items);
230
+ await client.send(new TransactWriteCommand({ TransactItems: items }));
231
+ ```
232
+
233
+ ---
234
+
235
+ ##### `getFailedEvents(): Promise<FailedBusEvent[]>`
236
+
237
+ Retrieves up to 100 failed events, ordered by occurrence time (newest first).
238
+
239
+ **Returns:** Array of `FailedBusEvent` objects with error details and retry count.
240
+
241
+ **Example:**
242
+ ```typescript
243
+ const failed = await outbox.getFailedEvents();
244
+ for (const event of failed) {
245
+ console.log(`Event ${event.id} failed ${event.retryCount} times`);
246
+ console.log(`Last error: ${event.error}`);
247
+ }
248
+ ```
249
+
250
+ ---
251
+
252
+ ##### `retryEvents(eventIds: string[]): Promise<void>`
253
+
254
+ Resets failed events to `created` status for retry. Clears retry count, error message, and next retry timestamp.
255
+
256
+ **Parameters:**
257
+ - `eventIds`: Array of event IDs to retry
258
+
259
+ **Example:**
260
+ ```typescript
261
+ const failed = await outbox.getFailedEvents();
262
+ await outbox.retryEvents(failed.map(e => e.id));
263
+ ```
264
+
265
+ ---
266
+
267
+ ##### `start(handler: (event: BusEvent) => Promise<void>, onError: ErrorHandler): void`
268
+
269
+ Starts the polling service to process events.
270
+
271
+ **Parameters:**
272
+ - `handler`: Async function to process each event
273
+ - `onError`: Error handler called when event processing fails
274
+
275
+ **Example:**
276
+ ```typescript
277
+ outbox.start(
278
+ async (event) => {
279
+ console.log('Processing:', event);
280
+ // Your event handling logic
281
+ },
282
+ (err, event) => {
283
+ console.error('Failed to process event:', err, event);
284
+ }
285
+ );
286
+ ```
287
+
288
+ ---
289
+
290
+ ##### `stop(): Promise<void>`
291
+
292
+ Stops the polling service gracefully and waits for in-flight events to complete.
293
+
294
+ **Example:**
295
+ ```typescript
296
+ await outbox.stop();
297
+ ```
298
+
299
+ ---
300
+
301
+ ## IAM Permissions
302
+
303
+ Your application's IAM role needs the following permissions:
304
+
305
+ ```json
306
+ {
307
+ "Version": "2012-10-17",
308
+ "Statement": [
309
+ {
310
+ "Effect": "Allow",
311
+ "Action": ["dynamodb:PutItem", "dynamodb:GetItem", "dynamodb:UpdateItem"],
312
+ "Resource": "arn:aws:dynamodb:*:*:table/YOUR_TABLE/index/YOUR_GSI"
313
+ },
314
+ {
315
+ "Effect": "Allow",
316
+ "Action": ["dynamodb:Query"],
317
+ "Resource": "arn:aws:dynamodb:*:*:table/YOUR_TABLE/index/YOUR_GSI"
318
+ }
319
+ ]
320
+ }
321
+ ```
322
+
323
+ ---
324
+
325
+ ## Troubleshooting
326
+
327
+ ### Events Not Moving
328
+
329
+ **Symptom**: Events are stuck in `created` status and never get processed.
330
+
331
+ **Causes**:
332
+ 1. `statusIndexName` doesn't match the actual GSI name in DynamoDB
333
+ 2. GSI not created or still being built
334
+ 3. Polling service not started
335
+
336
+ **Solution**:
337
+ ```typescript
338
+ // 1. Verify GSI name matches
339
+ const outbox = new DynamoDBAwsSdkOutbox({
340
+ client,
341
+ tableName: 'my-events',
342
+ statusIndexName: 'status-gsiSortKey-index' // Must match actual GSI name
343
+ });
344
+
345
+ // 2. Check GSI status in AWS Console or CLI
346
+ // aws dynamodb describe-table --table-name my-events
347
+
348
+ // 3. Ensure bus is started
349
+ bus.start();
350
+ ```
351
+
352
+ ---
353
+
354
+ ### ConditionalCheckFailedException
355
+
356
+ **Symptom**: Seeing `ConditionalCheckFailedException` errors in logs.
357
+
358
+ **Cause**: Multiple workers trying to claim the same event simultaneously (expected behavior).
359
+
360
+ **Solution**: This is **normal and expected**! The adapter uses optimistic locking to prevent duplicate processing. The error means another worker successfully claimed the event first. No action needed—the adapter handles this gracefully.
361
+
362
+ ---
363
+
364
+ ### Slow Polling / High Latency
365
+
366
+ **Symptom**: Events take a long time to process after being emitted.
367
+
368
+ **Causes**:
369
+ 1. Low Read Capacity Units (RCU) on the GSI
370
+ 2. Small `batchSize` setting
371
+ 3. Long `pollIntervalMs`
372
+
373
+ **Solution**:
374
+ ```typescript
375
+ // 1. Increase batch size for higher throughput
376
+ const outbox = new DynamoDBAwsSdkOutbox({
377
+ client,
378
+ tableName: 'events',
379
+ batchSize: 100, // Process more events per poll (default: 50)
380
+ pollIntervalMs: 500 // Poll more frequently (default: 1000ms)
381
+ });
382
+
383
+ // 2. Check and increase RCU on your GSI in AWS Console
384
+ // Recommended: Use On-Demand billing mode for auto-scaling
385
+ ```
386
+
387
+ ---
388
+
389
+ ### Duplicate Event Processing
390
+
391
+ **Symptom**: Event handlers are being called multiple times for the same event.
392
+
393
+ **Cause**: The outbox guarantees "at-least-once" delivery. Duplicates can occur due to:
394
+ - Worker crashes during processing
395
+ - Network timeouts
396
+ - Publisher retries
397
+
398
+ **Solution**: Make your event handlers **idempotent**:
399
+ ```typescript
400
+ bus.on('order.created', async (event) => {
401
+ // Use event ID for deduplication
402
+ const existing = await db.getOrder(event.payload.orderId);
403
+ if (existing) {
404
+ console.log('Order already processed, skipping');
405
+ return;
406
+ }
407
+
408
+ await db.createOrder(event.payload);
409
+ });
410
+ ```
411
+
412
+ ---
413
+
414
+ ### ProvisionedThroughputExceededException
415
+
416
+ **Symptom**: `ProvisionedThroughputExceededException` errors during high load.
417
+
418
+ **Cause**: Write or read capacity exceeded on table or GSI.
419
+
420
+ **Solution**:
421
+ ```typescript
422
+ // Option 1: Switch to On-Demand billing mode (recommended)
423
+ // aws dynamodb update-table --table-name events --billing-mode PAY_PER_REQUEST
424
+
425
+ // Option 2: Increase provisioned capacity
426
+ // aws dynamodb update-table --table-name events \
427
+ // --provisioned-throughput ReadCapacityUnits=100,WriteCapacityUnits=100
428
+
429
+ // Option 3: Reduce polling frequency temporarily
430
+ const outbox = new DynamoDBAwsSdkOutbox({
431
+ client,
432
+ tableName: 'events',
433
+ pollIntervalMs: 2000, // Reduce polling frequency
434
+ batchSize: 25 // Reduce batch size
435
+ });
436
+ ```
437
+
438
+ ---
439
+
440
+ ### Events Stuck in active
441
+
442
+ **Symptom**: Events remain in `active` status indefinitely.
443
+
444
+ **Cause**: Worker crashed or Lambda timed out during processing.
445
+
446
+ **Solution**: The adapter automatically recovers stuck events based on `processingTimeoutMs`. To force immediate recovery:
447
+
448
+ ```typescript
449
+ // Stuck events are automatically reclaimed after processingTimeoutMs
450
+ const outbox = new DynamoDBAwsSdkOutbox({
451
+ client,
452
+ tableName: 'events',
453
+ processingTimeoutMs: 30000 // Adjust based on handler complexity (default: 30s)
454
+ });
455
+
456
+ // Manual recovery: Query and reset stuck events
457
+ const response = await client.send(new QueryCommand({
458
+ TableName: 'events',
459
+ IndexName: 'status-gsiSortKey-index',
460
+ KeyConditionExpression: "#status = :active AND gsiSortKey <= :now",
461
+ ExpressionAttributeNames: {
462
+ "#status": "status"
463
+ },
464
+ ExpressionAttributeValues: {
465
+ ":active": { S: 'active' },
466
+ ":now": { N: String(Date.now()) }
467
+ }
468
+ }));
469
+ ```
package/dist/index.cjs ADDED
@@ -0,0 +1,224 @@
1
+ let _aws_sdk_client_dynamodb = require("@aws-sdk/client-dynamodb");
2
+ let _aws_sdk_lib_dynamodb = require("@aws-sdk/lib-dynamodb");
3
+ let outbox_event_bus = require("outbox-event-bus");
4
+ let node_async_hooks = require("node:async_hooks");
5
+
6
+ //#region src/dynamodb-aws-sdk-outbox.ts
7
+ const DYNAMODB_TRANSACTION_LIMIT = 100;
8
+ var DynamoDBAwsSdkOutbox = class {
9
+ config;
10
+ docClient;
11
+ poller;
12
+ constructor(config) {
13
+ this.config = {
14
+ batchSize: config.batchSize ?? 50,
15
+ pollIntervalMs: config.pollIntervalMs ?? 1e3,
16
+ maxRetries: config.maxRetries ?? 5,
17
+ baseBackoffMs: config.baseBackoffMs ?? 1e3,
18
+ processingTimeoutMs: config.processingTimeoutMs ?? 3e4,
19
+ maxErrorBackoffMs: config.maxErrorBackoffMs ?? 3e4,
20
+ tableName: config.tableName,
21
+ statusIndexName: config.statusIndexName ?? "status-gsiSortKey-index",
22
+ client: config.client,
23
+ getCollector: config.getCollector
24
+ };
25
+ this.docClient = _aws_sdk_lib_dynamodb.DynamoDBDocumentClient.from(config.client, { marshallOptions: { removeUndefinedValues: true } });
26
+ this.poller = new outbox_event_bus.PollingService({
27
+ pollIntervalMs: this.config.pollIntervalMs,
28
+ baseBackoffMs: this.config.baseBackoffMs,
29
+ maxErrorBackoffMs: this.config.maxErrorBackoffMs,
30
+ performMaintenance: () => this.recoverStuckEvents(),
31
+ processBatch: (handler) => this.processBatch(handler)
32
+ });
33
+ }
34
+ async publish(events, transaction) {
35
+ if (events.length === 0) return;
36
+ if (events.length > DYNAMODB_TRANSACTION_LIMIT) throw new outbox_event_bus.BatchSizeLimitError(DYNAMODB_TRANSACTION_LIMIT, events.length);
37
+ const collector = transaction ?? this.config.getCollector?.();
38
+ const items = events.map((event) => {
39
+ return { Put: {
40
+ TableName: this.config.tableName,
41
+ Item: {
42
+ id: event.id,
43
+ type: event.type,
44
+ payload: event.payload,
45
+ occurredAt: event.occurredAt.toISOString(),
46
+ status: outbox_event_bus.EventStatus.CREATED,
47
+ retryCount: 0,
48
+ gsiSortKey: event.occurredAt.getTime()
49
+ }
50
+ } };
51
+ });
52
+ if (collector) {
53
+ const itemsInCollector = collector.items?.length ?? 0;
54
+ if (itemsInCollector + items.length > DYNAMODB_TRANSACTION_LIMIT) throw new outbox_event_bus.BatchSizeLimitError(DYNAMODB_TRANSACTION_LIMIT, itemsInCollector + items.length);
55
+ for (const item of items) collector.push(item);
56
+ } else await this.docClient.send(new _aws_sdk_lib_dynamodb.TransactWriteCommand({ TransactItems: items }));
57
+ }
58
+ async getFailedEvents() {
59
+ const result = await this.docClient.send(new _aws_sdk_lib_dynamodb.QueryCommand({
60
+ TableName: this.config.tableName,
61
+ IndexName: this.config.statusIndexName,
62
+ KeyConditionExpression: "#status = :status",
63
+ ExpressionAttributeNames: { "#status": "status" },
64
+ ExpressionAttributeValues: { ":status": outbox_event_bus.EventStatus.FAILED },
65
+ Limit: 100,
66
+ ScanIndexForward: false
67
+ }));
68
+ if (!result.Items) return [];
69
+ return result.Items.map((item) => {
70
+ const event = {
71
+ id: item.id,
72
+ type: item.type,
73
+ payload: item.payload,
74
+ occurredAt: this.parseOccurredAt(item.occurredAt),
75
+ retryCount: item.retryCount || 0
76
+ };
77
+ if (item.lastError) event.error = item.lastError;
78
+ if (item.startedOn) event.lastAttemptAt = new Date(item.startedOn);
79
+ return event;
80
+ });
81
+ }
82
+ async retryEvents(eventIds) {
83
+ if (eventIds.length === 0) return;
84
+ await Promise.all(eventIds.map((id) => this.docClient.send(new _aws_sdk_lib_dynamodb.UpdateCommand({
85
+ TableName: this.config.tableName,
86
+ Key: { id },
87
+ UpdateExpression: "SET #status = :pending, retryCount = :zero, gsiSortKey = :now REMOVE lastError, nextRetryAt, startedOn",
88
+ ExpressionAttributeNames: { "#status": "status" },
89
+ ExpressionAttributeValues: {
90
+ ":pending": outbox_event_bus.EventStatus.CREATED,
91
+ ":zero": 0,
92
+ ":now": Date.now()
93
+ }
94
+ }))));
95
+ }
96
+ start(handler, onError) {
97
+ this.poller.start(handler, onError);
98
+ }
99
+ async stop() {
100
+ await this.poller.stop();
101
+ }
102
+ async processBatch(handler) {
103
+ const now = Date.now();
104
+ const result = await this.docClient.send(new _aws_sdk_lib_dynamodb.QueryCommand({
105
+ TableName: this.config.tableName,
106
+ IndexName: this.config.statusIndexName,
107
+ KeyConditionExpression: "#status = :status AND gsiSortKey <= :now",
108
+ ExpressionAttributeNames: { "#status": "status" },
109
+ ExpressionAttributeValues: {
110
+ ":status": outbox_event_bus.EventStatus.CREATED,
111
+ ":now": now
112
+ },
113
+ Limit: this.config.batchSize
114
+ }));
115
+ if (!result.Items || result.Items.length === 0) return;
116
+ for (const item of result.Items) {
117
+ const event = {
118
+ id: item.id,
119
+ type: item.type,
120
+ payload: item.payload,
121
+ occurredAt: this.parseOccurredAt(item.occurredAt)
122
+ };
123
+ try {
124
+ await this.markEventAsProcessing(item.id, now);
125
+ await handler(event);
126
+ await this.markEventAsCompleted(event.id);
127
+ } catch (error) {
128
+ if (error instanceof _aws_sdk_client_dynamodb.ConditionalCheckFailedException) continue;
129
+ const newRetryCount = (item.retryCount || 0) + 1;
130
+ (0, outbox_event_bus.reportEventError)(this.poller.onError, error, event, newRetryCount, this.config.maxRetries);
131
+ await this.markEventAsFailed(item.id, newRetryCount, error);
132
+ }
133
+ }
134
+ }
135
+ parseOccurredAt(occurredAt) {
136
+ if (occurredAt instanceof Date) return occurredAt;
137
+ if (typeof occurredAt === "string") return new Date(occurredAt);
138
+ return /* @__PURE__ */ new Date();
139
+ }
140
+ async markEventAsProcessing(id, now) {
141
+ await this.docClient.send(new _aws_sdk_lib_dynamodb.UpdateCommand({
142
+ TableName: this.config.tableName,
143
+ Key: { id },
144
+ UpdateExpression: "SET #status = :processing, gsiSortKey = :timeoutAt, startedOn = :now",
145
+ ExpressionAttributeNames: { "#status": "status" },
146
+ ExpressionAttributeValues: {
147
+ ":processing": outbox_event_bus.EventStatus.ACTIVE,
148
+ ":timeoutAt": now + this.config.processingTimeoutMs,
149
+ ":now": now
150
+ }
151
+ }));
152
+ }
153
+ async markEventAsCompleted(id) {
154
+ await this.docClient.send(new _aws_sdk_lib_dynamodb.UpdateCommand({
155
+ TableName: this.config.tableName,
156
+ Key: { id },
157
+ UpdateExpression: "SET #status = :completed, completedOn = :now REMOVE gsiSortKey",
158
+ ExpressionAttributeNames: { "#status": "status" },
159
+ ExpressionAttributeValues: {
160
+ ":completed": outbox_event_bus.EventStatus.COMPLETED,
161
+ ":now": Date.now()
162
+ }
163
+ }));
164
+ }
165
+ async markEventAsFailed(id, retryCount, error) {
166
+ const isFinalFailure = retryCount >= this.config.maxRetries;
167
+ const status = isFinalFailure ? outbox_event_bus.EventStatus.FAILED : outbox_event_bus.EventStatus.CREATED;
168
+ const errorMsg = (0, outbox_event_bus.formatErrorMessage)(error);
169
+ const now = Date.now();
170
+ const updateExpression = isFinalFailure ? "SET #status = :status, retryCount = :rc, lastError = :err, nextRetryAt = :now REMOVE gsiSortKey" : "SET #status = :status, retryCount = :rc, lastError = :err, gsiSortKey = :nextAttempt";
171
+ const expressionAttributeValues = {
172
+ ":status": status,
173
+ ":rc": retryCount,
174
+ ":err": errorMsg
175
+ };
176
+ if (isFinalFailure) expressionAttributeValues[":now"] = now;
177
+ else expressionAttributeValues[":nextAttempt"] = now + this.poller.calculateBackoff(retryCount);
178
+ await this.docClient.send(new _aws_sdk_lib_dynamodb.UpdateCommand({
179
+ TableName: this.config.tableName,
180
+ Key: { id },
181
+ UpdateExpression: updateExpression,
182
+ ExpressionAttributeNames: { "#status": "status" },
183
+ ExpressionAttributeValues: expressionAttributeValues
184
+ }));
185
+ }
186
+ async recoverStuckEvents() {
187
+ const now = Date.now();
188
+ const result = await this.docClient.send(new _aws_sdk_lib_dynamodb.QueryCommand({
189
+ TableName: this.config.tableName,
190
+ IndexName: this.config.statusIndexName,
191
+ KeyConditionExpression: "#status = :status AND gsiSortKey <= :now",
192
+ ExpressionAttributeNames: { "#status": "status" },
193
+ ExpressionAttributeValues: {
194
+ ":status": outbox_event_bus.EventStatus.ACTIVE,
195
+ ":now": now
196
+ }
197
+ }));
198
+ if (result.Items && result.Items.length > 0) await Promise.all(result.Items.map((item) => this.markEventAsFailed(item.id, (item.retryCount || 0) + 1, "Processing timeout")));
199
+ }
200
+ };
201
+
202
+ //#endregion
203
+ //#region src/transaction-storage.ts
204
+ const dynamodbAwsSdkTransactionStorage = new node_async_hooks.AsyncLocalStorage();
205
+ async function withDynamoDBAwsSdkTransaction(fn) {
206
+ const items = [];
207
+ const collector = {
208
+ push: (item) => items.push(item),
209
+ get items() {
210
+ return items;
211
+ }
212
+ };
213
+ return dynamodbAwsSdkTransactionStorage.run(collector, () => fn(collector));
214
+ }
215
+ function getDynamoDBAwsSdkCollector() {
216
+ return () => dynamodbAwsSdkTransactionStorage.getStore();
217
+ }
218
+
219
+ //#endregion
220
+ exports.DynamoDBAwsSdkOutbox = DynamoDBAwsSdkOutbox;
221
+ exports.dynamodbAwsSdkTransactionStorage = dynamodbAwsSdkTransactionStorage;
222
+ exports.getDynamoDBAwsSdkCollector = getDynamoDBAwsSdkCollector;
223
+ exports.withDynamoDBAwsSdkTransaction = withDynamoDBAwsSdkTransaction;
224
+ //# sourceMappingURL=index.cjs.map