@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,233 @@
1
+ const { mockDispatchEvent, mockGetEventContext, mockUlid } = vi.hoisted(() => ({
2
+ mockDispatchEvent: vi.fn(),
3
+ mockGetEventContext: vi.fn(),
4
+ mockUlid: vi.fn(),
5
+ }));
6
+
7
+ vi.mock('./dispatch-event', () => ({
8
+ dispatchEvent: mockDispatchEvent,
9
+ }));
10
+
11
+ vi.mock('./context', () => ({
12
+ getEventContext: mockGetEventContext,
13
+ }));
14
+
15
+ vi.mock('ulid', () => ({
16
+ ulid: mockUlid,
17
+ }));
18
+
19
+ import { createDispatch } from './create-dispatch';
20
+
21
+ describe('createDispatch', () => {
22
+ beforeEach(() => {
23
+ vi.clearAllMocks();
24
+ mockGetEventContext.mockReturnValue({});
25
+ mockUlid.mockReturnValue('01ARZ3NDEKTSV4RRFFQ69G5FAV');
26
+ mockDispatchEvent.mockResolvedValue({ pk: 'pk', sk: 'sk', version: 1 });
27
+ });
28
+
29
+ it('creates dispatch functions from a record', () => {
30
+ const dispatch = createDispatch(
31
+ {
32
+ orderCreated: () => ({
33
+ aggregateType: 'order',
34
+ aggregateId: 'o-1',
35
+ source: 'test',
36
+ eventType: 'OrderCreated',
37
+ }),
38
+ },
39
+ {},
40
+ );
41
+
42
+ expect(dispatch).toHaveProperty('orderCreated');
43
+ expect(typeof dispatch.orderCreated).toBe('function');
44
+ });
45
+
46
+ it('passes args through to event builder', async () => {
47
+ const builder = vi.fn((name: string, amount: number) => ({
48
+ aggregateType: 'order',
49
+ aggregateId: 'o-1',
50
+ source: 'test',
51
+ eventType: 'OrderCreated',
52
+ payload: { name, amount },
53
+ }));
54
+
55
+ const dispatch = createDispatch({ create: builder }, {});
56
+
57
+ await dispatch.create('test-order', 100);
58
+
59
+ expect(builder).toHaveBeenCalledWith('test-order', 100);
60
+ });
61
+
62
+ it('calls dispatchEvent with assembled args', async () => {
63
+ const dispatch = createDispatch(
64
+ {
65
+ orderCreated: () => ({
66
+ aggregateType: 'order',
67
+ aggregateId: 'o-1',
68
+ source: 'test',
69
+ eventType: 'OrderCreated',
70
+ }),
71
+ },
72
+ {},
73
+ );
74
+
75
+ await dispatch.orderCreated();
76
+
77
+ expect(mockDispatchEvent).toHaveBeenCalledWith(
78
+ expect.objectContaining({
79
+ aggregateType: 'order',
80
+ aggregateId: 'o-1',
81
+ source: 'test',
82
+ eventType: 'OrderCreated',
83
+ eventId: 'evt-01ARZ3NDEKTSV4RRFFQ69G5FAV',
84
+ }),
85
+ );
86
+ });
87
+
88
+ it('merges event context into dispatch args', async () => {
89
+ mockGetEventContext.mockReturnValue({
90
+ correlationId: 'ctx-corr',
91
+ actorId: 'ctx-actor',
92
+ });
93
+
94
+ const dispatch = createDispatch(
95
+ {
96
+ orderCreated: () => ({
97
+ aggregateType: 'order',
98
+ aggregateId: 'o-1',
99
+ source: 'test',
100
+ eventType: 'OrderCreated',
101
+ }),
102
+ },
103
+ {},
104
+ );
105
+
106
+ await dispatch.orderCreated();
107
+
108
+ expect(mockDispatchEvent).toHaveBeenCalledWith(
109
+ expect.objectContaining({
110
+ correlationId: 'ctx-corr',
111
+ actorId: 'ctx-actor',
112
+ }),
113
+ );
114
+ });
115
+
116
+ it('merges options into dispatch args', async () => {
117
+ const dispatch = createDispatch(
118
+ {
119
+ orderCreated: () => ({
120
+ eventType: 'OrderCreated',
121
+ aggregateId: 'o-1',
122
+ }),
123
+ },
124
+ {
125
+ aggregateType: 'order',
126
+ source: 'order-service',
127
+ },
128
+ );
129
+
130
+ await dispatch.orderCreated();
131
+
132
+ expect(mockDispatchEvent).toHaveBeenCalledWith(
133
+ expect.objectContaining({
134
+ aggregateType: 'order',
135
+ source: 'order-service',
136
+ eventType: 'OrderCreated',
137
+ aggregateId: 'o-1',
138
+ }),
139
+ );
140
+ });
141
+
142
+ it('supports factory function as options', async () => {
143
+ const dispatch = createDispatch(
144
+ {
145
+ orderCreated: () => ({
146
+ eventType: 'OrderCreated',
147
+ aggregateId: 'o-1',
148
+ }),
149
+ },
150
+ (context) => ({
151
+ aggregateType: 'order',
152
+ source: 'order-service',
153
+ correlationId: context.eventId,
154
+ }),
155
+ );
156
+
157
+ await dispatch.orderCreated();
158
+
159
+ expect(mockDispatchEvent).toHaveBeenCalledWith(
160
+ expect.objectContaining({
161
+ aggregateType: 'order',
162
+ source: 'order-service',
163
+ correlationId: 'evt-01ARZ3NDEKTSV4RRFFQ69G5FAV',
164
+ }),
165
+ );
166
+ });
167
+
168
+ it('supports ValueOrFactory fields in the record return', async () => {
169
+ const dispatch = createDispatch(
170
+ {
171
+ orderCreated: () => ({
172
+ aggregateType: 'order',
173
+ aggregateId: 'o-1',
174
+ source: 'test',
175
+ eventType: 'OrderCreated',
176
+ correlationId: (ctx: { eventId: string }) => ctx.eventId,
177
+ }),
178
+ },
179
+ {},
180
+ );
181
+
182
+ await dispatch.orderCreated();
183
+
184
+ expect(mockDispatchEvent).toHaveBeenCalledWith(
185
+ expect.objectContaining({
186
+ correlationId: 'evt-01ARZ3NDEKTSV4RRFFQ69G5FAV',
187
+ }),
188
+ );
189
+ });
190
+
191
+ it('supports function-style return from builder (DispatchEventArgsFactory)', async () => {
192
+ const dispatch = createDispatch(
193
+ {
194
+ orderCreated: () => (context: { eventId: string }) => ({
195
+ aggregateType: 'order',
196
+ aggregateId: 'o-1',
197
+ source: 'test',
198
+ eventType: 'OrderCreated',
199
+ correlationId: context.eventId,
200
+ }),
201
+ },
202
+ {},
203
+ );
204
+
205
+ await dispatch.orderCreated();
206
+
207
+ expect(mockDispatchEvent).toHaveBeenCalledWith(
208
+ expect.objectContaining({
209
+ correlationId: 'evt-01ARZ3NDEKTSV4RRFFQ69G5FAV',
210
+ }),
211
+ );
212
+ });
213
+
214
+ it('returns the dispatchEvent result', async () => {
215
+ const expected = { pk: 'AGG#order#o-1', sk: 'EVT#000000001', version: 1 };
216
+ mockDispatchEvent.mockResolvedValue(expected);
217
+
218
+ const dispatch = createDispatch(
219
+ {
220
+ orderCreated: () => ({
221
+ aggregateType: 'order',
222
+ aggregateId: 'o-1',
223
+ source: 'test',
224
+ eventType: 'OrderCreated',
225
+ }),
226
+ },
227
+ {},
228
+ );
229
+
230
+ const result = await dispatch.orderCreated();
231
+ expect(result).toEqual(expected);
232
+ });
233
+ });
@@ -0,0 +1,71 @@
1
+ import { ulid } from 'ulid';
2
+
3
+ import { EventContext, getEventContext } from './context';
4
+ import { dispatchEvent, DispatchEventArgs } from './dispatch-event';
5
+ import { AppendEventResult } from './event-service';
6
+
7
+ export type MakePartial<T, O> = Omit<T, keyof O> & Partial<O>;
8
+
9
+ export type DispatchRecord<
10
+ Options extends Partial<DispatchEventArgs> = Partial<DispatchEventArgs>,
11
+ > = Record<
12
+ string,
13
+ (
14
+ ...args: any[]
15
+ ) =>
16
+ | ValueOrFactoryRecord<MakePartial<DispatchEventArgs, Options>>
17
+ | DispatchEventArgsFactory<Options>
18
+ >;
19
+
20
+ export type ValueOrFactory<T> = T | ((context: EventContext) => T);
21
+ export type ValueOrFactoryRecord<T> = {
22
+ [K in keyof T]: ValueOrFactory<T[K]>;
23
+ };
24
+
25
+ export type DispatchEventArgsFactory<Options extends Partial<DispatchEventArgs>> = (
26
+ context: EventContext,
27
+ ) => MakePartial<DispatchEventArgs, Options>;
28
+
29
+ export type DispatchRecordResponse<
30
+ R extends DispatchRecord<O>,
31
+ O extends Partial<DispatchEventArgs>,
32
+ > = {
33
+ [K in keyof R]: (...args: Parameters<R[K]>) => Promise<AppendEventResult>;
34
+ };
35
+
36
+ export function createDispatch<DR extends DispatchRecord<O>, O extends Partial<DispatchEventArgs>>(
37
+ record: DR,
38
+ optionsOrFactory?: ValueOrFactoryRecord<O> | ((context: EventContext) => O),
39
+ ): DispatchRecordResponse<DR, O> {
40
+ return Object.fromEntries(
41
+ Object.entries(record).map(([key, value]) => [
42
+ key,
43
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
44
+ (...args: any[]) => {
45
+ const eventId = `evt-${ulid()}`;
46
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
47
+ const result = value(...args);
48
+ const context: EventContext = { eventId, ...getEventContext() };
49
+ const executeValueFn = (value: any) =>
50
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
51
+ typeof value === 'function' ? value(context) : value;
52
+ const parseResponse = (result: any) =>
53
+ Object.fromEntries(
54
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
55
+ Object.entries(result).map(([key, value]) => [key, executeValueFn(value)]),
56
+ );
57
+ Object.assign(
58
+ context,
59
+ typeof result === 'function' ? result(context) : parseResponse(result),
60
+ );
61
+ Object.assign(
62
+ context,
63
+ typeof optionsOrFactory === 'function'
64
+ ? optionsOrFactory(context)
65
+ : parseResponse(optionsOrFactory),
66
+ );
67
+ return dispatchEvent(context as DispatchEventArgs);
68
+ },
69
+ ]),
70
+ ) as DispatchRecordResponse<DR, O>;
71
+ }
@@ -0,0 +1,246 @@
1
+ const { mockSetEventContext } = vi.hoisted(() => ({
2
+ mockSetEventContext: vi.fn(),
3
+ }));
4
+
5
+ vi.mock('@auriclabs/logger', () => ({
6
+ logger: { debug: vi.fn(), error: vi.fn() },
7
+ }));
8
+
9
+ vi.mock('./context', () => ({
10
+ setEventContext: mockSetEventContext,
11
+ }));
12
+
13
+ import { createEventListener } from './create-event-listener';
14
+ import { logger } from '@auriclabs/logger';
15
+ import type { SQSEvent } from 'aws-lambda';
16
+
17
+ const makeRecord = (body: object, messageId = 'msg-1') => ({
18
+ messageId,
19
+ body: JSON.stringify(body),
20
+ receiptHandle: 'handle',
21
+ attributes: {} as any,
22
+ messageAttributes: {},
23
+ md5OfBody: '',
24
+ eventSource: 'aws:sqs',
25
+ eventSourceARN: 'arn:aws:sqs:us-east-1:123:queue',
26
+ awsRegion: 'us-east-1',
27
+ });
28
+
29
+ const makeEvent = (overrides = {}) => ({
30
+ eventType: 'OrderCreated',
31
+ eventId: 'evt-1',
32
+ aggregateType: 'order',
33
+ aggregateId: 'o-1',
34
+ correlationId: 'corr-1',
35
+ actorId: 'actor-1',
36
+ payload: {},
37
+ ...overrides,
38
+ });
39
+
40
+ describe('createEventListener', () => {
41
+ beforeEach(() => {
42
+ vi.clearAllMocks();
43
+ });
44
+
45
+ it('returns a function (SQS batch handler)', () => {
46
+ const handler = createEventListener({});
47
+ expect(typeof handler).toBe('function');
48
+ });
49
+
50
+ it('parses event from record body and calls matching handler', async () => {
51
+ const orderCreatedHandler = vi.fn();
52
+ const handler = createEventListener({ OrderCreated: orderCreatedHandler });
53
+
54
+ const sqsEvent: SQSEvent = {
55
+ Records: [makeRecord(makeEvent())],
56
+ };
57
+
58
+ const result = await handler(sqsEvent);
59
+
60
+ expect(orderCreatedHandler).toHaveBeenCalledTimes(1);
61
+ expect(orderCreatedHandler).toHaveBeenCalledWith(
62
+ expect.objectContaining({ eventType: 'OrderCreated' }),
63
+ );
64
+ expect(result.batchItemFailures).toEqual([]);
65
+ });
66
+
67
+ it('does not fail when no handler matches the event type', async () => {
68
+ const handler = createEventListener({ SomeOtherEvent: vi.fn() });
69
+
70
+ const sqsEvent: SQSEvent = {
71
+ Records: [makeRecord(makeEvent({ eventType: 'UnknownEvent' }))],
72
+ };
73
+
74
+ const result = await handler(sqsEvent);
75
+
76
+ expect(result.batchItemFailures).toEqual([]);
77
+ });
78
+
79
+ it('resolves string aliases to actual handlers', async () => {
80
+ const actualHandler = vi.fn();
81
+ const handler = createEventListener({
82
+ OrderCreated: 'OrderPlaced',
83
+ OrderPlaced: actualHandler,
84
+ });
85
+
86
+ const sqsEvent: SQSEvent = {
87
+ Records: [makeRecord(makeEvent({ eventType: 'OrderCreated' }))],
88
+ };
89
+
90
+ await handler(sqsEvent);
91
+
92
+ expect(actualHandler).toHaveBeenCalledTimes(1);
93
+ });
94
+
95
+ it('resolves chained string aliases', async () => {
96
+ const actualHandler = vi.fn();
97
+ const handler = createEventListener({
98
+ OrderCreated: 'AliasOne',
99
+ AliasOne: 'AliasTwo',
100
+ AliasTwo: actualHandler,
101
+ });
102
+
103
+ const sqsEvent: SQSEvent = {
104
+ Records: [makeRecord(makeEvent({ eventType: 'OrderCreated' }))],
105
+ };
106
+
107
+ await handler(sqsEvent);
108
+
109
+ expect(actualHandler).toHaveBeenCalledTimes(1);
110
+ });
111
+
112
+ it('sets event context from incoming event', async () => {
113
+ const handler = createEventListener({ OrderCreated: vi.fn() });
114
+
115
+ const sqsEvent: SQSEvent = {
116
+ Records: [
117
+ makeRecord(
118
+ makeEvent({
119
+ eventId: 'evt-123',
120
+ correlationId: 'corr-456',
121
+ actorId: 'user-789',
122
+ }),
123
+ ),
124
+ ],
125
+ };
126
+
127
+ await handler(sqsEvent);
128
+
129
+ expect(mockSetEventContext).toHaveBeenCalledWith({
130
+ causationId: 'evt-123',
131
+ correlationId: 'corr-456',
132
+ actorId: 'user-789',
133
+ });
134
+ });
135
+
136
+ it('returns batch failures on handler error', async () => {
137
+ const handler = createEventListener({
138
+ OrderCreated: () => {
139
+ throw new Error('Handler failed');
140
+ },
141
+ });
142
+
143
+ const sqsEvent: SQSEvent = {
144
+ Records: [makeRecord(makeEvent(), 'msg-fail')],
145
+ };
146
+
147
+ const result = await handler(sqsEvent);
148
+
149
+ expect(result.batchItemFailures).toEqual([{ itemIdentifier: 'msg-fail' }]);
150
+ });
151
+
152
+ it('marks subsequent records as failed after first failure (FIFO behavior)', async () => {
153
+ const handler = createEventListener({
154
+ OrderCreated: () => {
155
+ throw new Error('fail');
156
+ },
157
+ OrderUpdated: vi.fn(),
158
+ });
159
+
160
+ const sqsEvent: SQSEvent = {
161
+ Records: [
162
+ makeRecord(makeEvent({ eventType: 'OrderCreated' }), 'msg-1'),
163
+ makeRecord(makeEvent({ eventType: 'OrderUpdated' }), 'msg-2'),
164
+ makeRecord(makeEvent({ eventType: 'OrderUpdated' }), 'msg-3'),
165
+ ],
166
+ };
167
+
168
+ const result = await handler(sqsEvent);
169
+
170
+ expect(result.batchItemFailures).toEqual([
171
+ { itemIdentifier: 'msg-1' },
172
+ { itemIdentifier: 'msg-2' },
173
+ { itemIdentifier: 'msg-3' },
174
+ ]);
175
+ });
176
+
177
+ it('logs events in debug mode', async () => {
178
+ const handler = createEventListener({ OrderCreated: vi.fn() }, { debug: true });
179
+
180
+ const sqsEvent: SQSEvent = {
181
+ Records: [makeRecord(makeEvent())],
182
+ };
183
+
184
+ await handler(sqsEvent);
185
+
186
+ expect(logger.debug).toHaveBeenCalledWith(
187
+ { event: expect.objectContaining({ eventType: 'OrderCreated' }) },
188
+ 'Processing event',
189
+ );
190
+ });
191
+
192
+ it('does not log events when debug is false', async () => {
193
+ const handler = createEventListener({ OrderCreated: vi.fn() }, { debug: false });
194
+
195
+ const sqsEvent: SQSEvent = {
196
+ Records: [makeRecord(makeEvent())],
197
+ };
198
+
199
+ await handler(sqsEvent);
200
+
201
+ expect(logger.debug).not.toHaveBeenCalled();
202
+ });
203
+
204
+ it('logs error details when handler fails', async () => {
205
+ const error = new Error('boom');
206
+ const handler = createEventListener({
207
+ OrderCreated: () => {
208
+ throw error;
209
+ },
210
+ });
211
+
212
+ const event = makeEvent();
213
+ const sqsEvent: SQSEvent = {
214
+ Records: [makeRecord(event, 'msg-err')],
215
+ };
216
+
217
+ await handler(sqsEvent);
218
+
219
+ expect(logger.error).toHaveBeenCalledWith(
220
+ expect.objectContaining({ error, event: expect.any(Object) }),
221
+ 'Error processing event',
222
+ );
223
+ });
224
+
225
+ it('handles multiple successful records', async () => {
226
+ const handlerA = vi.fn();
227
+ const handlerB = vi.fn();
228
+ const handler = createEventListener({
229
+ OrderCreated: handlerA,
230
+ OrderUpdated: handlerB,
231
+ });
232
+
233
+ const sqsEvent: SQSEvent = {
234
+ Records: [
235
+ makeRecord(makeEvent({ eventType: 'OrderCreated' }), 'msg-1'),
236
+ makeRecord(makeEvent({ eventType: 'OrderUpdated' }), 'msg-2'),
237
+ ],
238
+ };
239
+
240
+ const result = await handler(sqsEvent);
241
+
242
+ expect(handlerA).toHaveBeenCalledTimes(1);
243
+ expect(handlerB).toHaveBeenCalledTimes(1);
244
+ expect(result.batchItemFailures).toEqual([]);
245
+ });
246
+ });
@@ -0,0 +1,54 @@
1
+ import { logger } from '@auriclabs/logger';
2
+ import { SQSBatchResponse, SQSEvent } from 'aws-lambda';
3
+
4
+ import { setEventContext } from './context';
5
+ import { EventHandlers, EventRecord } from './types';
6
+
7
+ export interface CreateEventListenerOptions {
8
+ debug?: boolean;
9
+ }
10
+
11
+ export const createEventListener =
12
+ (eventHandlers: EventHandlers, { debug = false }: CreateEventListenerOptions = {}) =>
13
+ async (sqsEvent: SQSEvent) => {
14
+ const response: SQSBatchResponse = {
15
+ batchItemFailures: [],
16
+ };
17
+ let hasFailed = false;
18
+ for (const record of sqsEvent.Records) {
19
+ // skip the job if it has failed
20
+ if (hasFailed) {
21
+ response.batchItemFailures.push({
22
+ itemIdentifier: record.messageId,
23
+ });
24
+ continue;
25
+ }
26
+
27
+ let event: EventRecord | undefined;
28
+ try {
29
+ event = JSON.parse(record.body) as EventRecord;
30
+ if (debug) {
31
+ logger.debug({ event }, 'Processing event');
32
+ }
33
+ let handler = eventHandlers[event.eventType];
34
+ while (typeof handler === 'string') {
35
+ handler = eventHandlers[handler];
36
+ }
37
+ if (typeof handler === 'function') {
38
+ setEventContext({
39
+ causationId: event.eventId,
40
+ correlationId: event.correlationId,
41
+ actorId: event.actorId,
42
+ });
43
+ await handler(event);
44
+ }
45
+ } catch (error) {
46
+ hasFailed = true;
47
+ logger.error({ error, event, body: record.body }, 'Error processing event');
48
+ response.batchItemFailures.push({
49
+ itemIdentifier: record.messageId,
50
+ });
51
+ }
52
+ }
53
+ return response;
54
+ };