@auriclabs/events 0.1.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,108 @@
1
+ import { logger } from '@auriclabs/logger';
2
+ import { AttributeValue } from '@aws-sdk/client-dynamodb';
3
+ import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge';
4
+ import { SendMessageBatchCommand, SQSClient } from '@aws-sdk/client-sqs';
5
+ import { unmarshall } from '@aws-sdk/util-dynamodb';
6
+ import { DynamoDBStreamEvent } from 'aws-lambda';
7
+ import { kebabCase } from 'lodash-es';
8
+
9
+ import { AggregateHead, EventRecord } from './types';
10
+
11
+ const BATCH_SIZE = 10;
12
+
13
+ export interface CreateStreamHandlerConfig {
14
+ busName: string;
15
+ queueUrls: string[];
16
+ }
17
+
18
+ /**
19
+ * Creates a Lambda handler for DynamoDB stream events.
20
+ * Processes INSERT events from the event store table and forwards them to SQS queues and EventBridge.
21
+ */
22
+ export function createStreamHandler(config: CreateStreamHandlerConfig) {
23
+ const sqsClient = new SQSClient();
24
+ const eventBridge = new EventBridgeClient({});
25
+
26
+ function chunkArray<T>(array: T[], chunkSize: number): T[][] {
27
+ const chunks: T[][] = [];
28
+ for (let i = 0; i < array.length; i += chunkSize) {
29
+ chunks.push(array.slice(i, i + chunkSize));
30
+ }
31
+ return chunks;
32
+ }
33
+
34
+ async function sendToQueuesBatch(eventRecords: EventRecord[]) {
35
+ await Promise.all(config.queueUrls.map((queue) => sendToQueueBatch(eventRecords, queue)));
36
+ }
37
+
38
+ async function sendToQueueBatch(eventRecords: EventRecord[], queue: string) {
39
+ const batches = chunkArray(eventRecords, BATCH_SIZE);
40
+
41
+ for (const batch of batches) {
42
+ try {
43
+ const entries = batch.map((eventRecord, index) => ({
44
+ Id: `${eventRecord.eventId}-${index}`,
45
+ MessageBody: JSON.stringify(eventRecord),
46
+ MessageGroupId: eventRecord.aggregateId,
47
+ MessageDeduplicationId: eventRecord.eventId,
48
+ }));
49
+
50
+ await sqsClient.send(
51
+ new SendMessageBatchCommand({
52
+ QueueUrl: queue,
53
+ Entries: entries,
54
+ }),
55
+ );
56
+ } catch (error) {
57
+ logger.error({ error, batch, queue }, 'Error sending batch to queue');
58
+ throw error;
59
+ }
60
+ }
61
+ }
62
+
63
+ async function sendToBusBatch(eventRecords: EventRecord[]) {
64
+ const batches = chunkArray(eventRecords, BATCH_SIZE);
65
+
66
+ for (const batch of batches) {
67
+ try {
68
+ const entries = batch.map((eventRecord) => {
69
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
70
+ const source = eventRecord.source ?? kebabCase(eventRecord.aggregateType.split('.')[0]);
71
+ return {
72
+ Source: source,
73
+ DetailType: eventRecord.eventType,
74
+ Detail: JSON.stringify(eventRecord),
75
+ EventBusName: config.busName,
76
+ };
77
+ });
78
+
79
+ await eventBridge.send(
80
+ new PutEventsCommand({
81
+ Entries: entries,
82
+ }),
83
+ );
84
+ } catch (error) {
85
+ logger.error({ error, batch }, 'Error sending batch to bus');
86
+ throw error;
87
+ }
88
+ }
89
+ }
90
+
91
+ return async (event: DynamoDBStreamEvent): Promise<void> => {
92
+ const eventRecords = event.Records.filter((record) => record.eventName === 'INSERT')
93
+ .map((record) => {
94
+ try {
95
+ const data = record.dynamodb?.NewImage;
96
+ return unmarshall(data as Record<string, AttributeValue>) as EventRecord | AggregateHead;
97
+ } catch (error) {
98
+ logger.error({ error, record }, 'Error unmarshalling event record');
99
+ return undefined;
100
+ }
101
+ })
102
+ .filter((eventRecord): eventRecord is EventRecord => eventRecord?.itemType === 'event');
103
+
104
+ if (eventRecords.length > 0) {
105
+ await Promise.all([sendToBusBatch(eventRecords), sendToQueuesBatch(eventRecords)]);
106
+ }
107
+ };
108
+ }
package/src/types.ts ADDED
@@ -0,0 +1,65 @@
1
+ // helpers (optional, but nice)
2
+ export type Brand<T, B extends string> = T & { readonly __brand: B };
3
+ export type Source = Brand<string, 'Source'>;
4
+ export type AggregateId = Brand<string, 'AggregateId'>;
5
+ export type AggregateType = Brand<string, 'AggregateType'>;
6
+ export type EventId = Brand<string, 'EventId'>; // ULID/UUID
7
+
8
+ // If you use prefixed keys like AGG#wallet#wal_123 and EVT#000000042:
9
+ export type AggregatePK = `AGG#${string}#${string}`;
10
+ export type EventSK = `EVT#${string}` | 'HEAD';
11
+ export type ItemType = 'event' | 'head';
12
+
13
+ // ---------- Event item ----------
14
+ export interface EventRecord<P = unknown> {
15
+ /** DynamoDB keys */
16
+ pk: AggregatePK; // or PK if you use uppercase
17
+ sk: EventSK; // "EVT#000000042"
18
+ itemType: Extract<ItemType, 'event'>;
19
+
20
+ /** Aggregate routing */
21
+ source: Source;
22
+ aggregateId: AggregateId;
23
+ aggregateType: AggregateType;
24
+ version: number; // post-apply version
25
+
26
+ /** Event identity & semantics */
27
+ eventId: EventId; // string (ULID/UUID), not number
28
+ eventType: string;
29
+ schemaVersion?: number; // optional but recommended
30
+ occurredAt: string; // ISO timestamp ("2025-10-01T08:12:34.567Z")
31
+
32
+ /** Tracing (optional) */
33
+ correlationId?: string;
34
+ causationId?: string;
35
+ actorId?: string;
36
+
37
+ /** Domain payload */
38
+ payload: Readonly<P>;
39
+ }
40
+
41
+ // ---------- HEAD (aggregate metadata) ----------
42
+ export interface AggregateHead {
43
+ /** DynamoDB keys */
44
+ pk: AggregatePK;
45
+ sk: 'HEAD';
46
+ itemType: Extract<ItemType, 'head'>;
47
+
48
+ /** Aggregate identity */
49
+ aggregateId: AggregateId;
50
+ aggregateType: AggregateType;
51
+
52
+ /** Version tracking */
53
+ currentVersion: number;
54
+
55
+ /** Idempotency/debug */
56
+ lastEventId?: EventId;
57
+ lastIdemKey?: string; // make optional; only set when you use it
58
+ updatedAt: string; // ISO timestamp
59
+ }
60
+
61
+ export type EventHandlers = Record<
62
+ string,
63
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
64
+ ((event: EventRecord<any>) => Promise<void> | void) | string
65
+ >;
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "extends": "@auriclabs/ts-config/node-package",
3
+ "compilerOptions": {
4
+ "rootDir": ".",
5
+ "outDir": "dist"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": [
9
+ "dist",
10
+ "node_modules",
11
+ "**/*.test.ts",
12
+ "**/*.spec.ts",
13
+ "**/__tests__/**",
14
+ "**/__mocks__/**",
15
+ "vitest.config.ts"
16
+ ]
17
+ }
@@ -0,0 +1,2 @@
1
+ import nodeConfig from '@auriclabs/vitest-config/node';
2
+ export default { ...nodeConfig };