@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.
- package/.turbo/turbo-build.log +39 -0
- package/README.md +228 -0
- package/dist/index.cjs +314 -0
- package/dist/index.d.cts +377 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +377 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +304 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +59 -0
- package/src/context.test.ts +60 -0
- package/src/context.ts +19 -0
- package/src/create-dispatch.test.ts +233 -0
- package/src/create-dispatch.ts +71 -0
- package/src/create-event-listener.test.ts +246 -0
- package/src/create-event-listener.ts +54 -0
- package/src/dispatch-event.test.ts +226 -0
- package/src/dispatch-event.ts +34 -0
- package/src/dispatch-events.test.ts +72 -0
- package/src/dispatch-events.ts +18 -0
- package/src/event-service.test.ts +357 -0
- package/src/event-service.ts +228 -0
- package/src/index.ts +9 -0
- package/src/init.test.ts +55 -0
- package/src/init.ts +14 -0
- package/src/stream-handler.test.ts +309 -0
- package/src/stream-handler.ts +108 -0
- package/src/types.ts +65 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +2 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
const { mockGetEventService, mockGetHead, mockAppendEvent, mockGetEventContext, mockUlid } =
|
|
2
|
+
vi.hoisted(() => ({
|
|
3
|
+
mockGetEventService: vi.fn(),
|
|
4
|
+
mockGetHead: vi.fn(),
|
|
5
|
+
mockAppendEvent: vi.fn(),
|
|
6
|
+
mockGetEventContext: vi.fn(),
|
|
7
|
+
mockUlid: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock('./init', () => ({
|
|
11
|
+
getEventService: mockGetEventService,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock('./context', () => ({
|
|
15
|
+
getEventContext: mockGetEventContext,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock('@auriclabs/api-core', () => ({
|
|
19
|
+
retry: vi.fn((fn: () => unknown) => fn()),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('@auriclabs/logger', () => ({
|
|
23
|
+
logger: { debug: vi.fn() },
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock('ulid', () => ({
|
|
27
|
+
ulid: mockUlid,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import { dispatchEvent } from './dispatch-event';
|
|
31
|
+
import { retry } from '@auriclabs/api-core';
|
|
32
|
+
|
|
33
|
+
describe('dispatchEvent', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
vi.clearAllMocks();
|
|
36
|
+
mockGetEventService.mockReturnValue({
|
|
37
|
+
getHead: mockGetHead,
|
|
38
|
+
appendEvent: mockAppendEvent,
|
|
39
|
+
});
|
|
40
|
+
mockGetEventContext.mockReturnValue({});
|
|
41
|
+
mockGetHead.mockResolvedValue(undefined);
|
|
42
|
+
mockAppendEvent.mockResolvedValue({ pk: 'AGG#test#1', sk: 'EVT#000000001', version: 1 });
|
|
43
|
+
mockUlid.mockReturnValue('01ARZ3NDEKTSV4RRFFQ69G5FAV');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('generates eventId with ulid prefix when not provided', async () => {
|
|
47
|
+
await dispatchEvent({
|
|
48
|
+
aggregateType: 'order',
|
|
49
|
+
aggregateId: 'o-1',
|
|
50
|
+
source: 'test',
|
|
51
|
+
eventType: 'OrderCreated',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(mockAppendEvent).toHaveBeenCalledWith(
|
|
55
|
+
expect.objectContaining({
|
|
56
|
+
eventId: 'evt-01ARZ3NDEKTSV4RRFFQ69G5FAV',
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('uses provided eventId when given', async () => {
|
|
62
|
+
await dispatchEvent({
|
|
63
|
+
aggregateType: 'order',
|
|
64
|
+
aggregateId: 'o-1',
|
|
65
|
+
source: 'test',
|
|
66
|
+
eventType: 'OrderCreated',
|
|
67
|
+
eventId: 'custom-event-id',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(mockAppendEvent).toHaveBeenCalledWith(
|
|
71
|
+
expect.objectContaining({
|
|
72
|
+
eventId: 'custom-event-id',
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('uses provided idempotencyKey when given', async () => {
|
|
78
|
+
await dispatchEvent({
|
|
79
|
+
aggregateType: 'order',
|
|
80
|
+
aggregateId: 'o-1',
|
|
81
|
+
source: 'test',
|
|
82
|
+
eventType: 'OrderCreated',
|
|
83
|
+
idempotencyKey: 'my-idem-key',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(mockAppendEvent).toHaveBeenCalledWith(
|
|
87
|
+
expect.objectContaining({
|
|
88
|
+
idempotencyKey: 'my-idem-key',
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('defaults idempotencyKey to eventId when not provided', async () => {
|
|
94
|
+
await dispatchEvent({
|
|
95
|
+
aggregateType: 'order',
|
|
96
|
+
aggregateId: 'o-1',
|
|
97
|
+
source: 'test',
|
|
98
|
+
eventType: 'OrderCreated',
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(mockAppendEvent).toHaveBeenCalledWith(
|
|
102
|
+
expect.objectContaining({
|
|
103
|
+
idempotencyKey: 'evt-01ARZ3NDEKTSV4RRFFQ69G5FAV',
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('reads head version before appending', async () => {
|
|
109
|
+
mockGetHead.mockResolvedValue({ currentVersion: 5 });
|
|
110
|
+
|
|
111
|
+
await dispatchEvent({
|
|
112
|
+
aggregateType: 'order',
|
|
113
|
+
aggregateId: 'o-1',
|
|
114
|
+
source: 'test',
|
|
115
|
+
eventType: 'OrderUpdated',
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(mockGetHead).toHaveBeenCalledWith('order', 'o-1');
|
|
119
|
+
expect(mockAppendEvent).toHaveBeenCalledWith(
|
|
120
|
+
expect.objectContaining({
|
|
121
|
+
expectedVersion: 5,
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('uses expectedVersion 0 when head does not exist', async () => {
|
|
127
|
+
mockGetHead.mockResolvedValue(undefined);
|
|
128
|
+
|
|
129
|
+
await dispatchEvent({
|
|
130
|
+
aggregateType: 'order',
|
|
131
|
+
aggregateId: 'o-1',
|
|
132
|
+
source: 'test',
|
|
133
|
+
eventType: 'OrderCreated',
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(mockAppendEvent).toHaveBeenCalledWith(
|
|
137
|
+
expect.objectContaining({
|
|
138
|
+
expectedVersion: 0,
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('merges event context into append args', async () => {
|
|
144
|
+
mockGetEventContext.mockReturnValue({
|
|
145
|
+
correlationId: 'ctx-corr-1',
|
|
146
|
+
actorId: 'ctx-actor-1',
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await dispatchEvent({
|
|
150
|
+
aggregateType: 'order',
|
|
151
|
+
aggregateId: 'o-1',
|
|
152
|
+
source: 'test',
|
|
153
|
+
eventType: 'OrderCreated',
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(mockAppendEvent).toHaveBeenCalledWith(
|
|
157
|
+
expect.objectContaining({
|
|
158
|
+
correlationId: 'ctx-corr-1',
|
|
159
|
+
actorId: 'ctx-actor-1',
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('event args override event context', async () => {
|
|
165
|
+
mockGetEventContext.mockReturnValue({
|
|
166
|
+
correlationId: 'ctx-corr',
|
|
167
|
+
actorId: 'ctx-actor',
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await dispatchEvent({
|
|
171
|
+
aggregateType: 'order',
|
|
172
|
+
aggregateId: 'o-1',
|
|
173
|
+
source: 'test',
|
|
174
|
+
eventType: 'OrderCreated',
|
|
175
|
+
correlationId: 'event-corr',
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(mockAppendEvent).toHaveBeenCalledWith(
|
|
179
|
+
expect.objectContaining({
|
|
180
|
+
correlationId: 'event-corr',
|
|
181
|
+
actorId: 'ctx-actor',
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('calls retry wrapper', async () => {
|
|
187
|
+
await dispatchEvent({
|
|
188
|
+
aggregateType: 'order',
|
|
189
|
+
aggregateId: 'o-1',
|
|
190
|
+
source: 'test',
|
|
191
|
+
eventType: 'OrderCreated',
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(retry).toHaveBeenCalledTimes(1);
|
|
195
|
+
expect(retry).toHaveBeenCalledWith(expect.any(Function));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('sets schemaVersion to 1', async () => {
|
|
199
|
+
await dispatchEvent({
|
|
200
|
+
aggregateType: 'order',
|
|
201
|
+
aggregateId: 'o-1',
|
|
202
|
+
source: 'test',
|
|
203
|
+
eventType: 'OrderCreated',
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(mockAppendEvent).toHaveBeenCalledWith(
|
|
207
|
+
expect.objectContaining({
|
|
208
|
+
schemaVersion: 1,
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('returns the appendEvent result', async () => {
|
|
214
|
+
const expected = { pk: 'AGG#order#o-1', sk: 'EVT#000000001', version: 1 };
|
|
215
|
+
mockAppendEvent.mockResolvedValue(expected);
|
|
216
|
+
|
|
217
|
+
const result = await dispatchEvent({
|
|
218
|
+
aggregateType: 'order',
|
|
219
|
+
aggregateId: 'o-1',
|
|
220
|
+
source: 'test',
|
|
221
|
+
eventType: 'OrderCreated',
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect(result).toEqual(expected);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { retry } from '@auriclabs/api-core';
|
|
2
|
+
import { logger } from '@auriclabs/logger';
|
|
3
|
+
import { ulid } from 'ulid';
|
|
4
|
+
|
|
5
|
+
import { AppendArgs, AppendEventResult } from './event-service';
|
|
6
|
+
import { getEventService } from './init';
|
|
7
|
+
import { getEventContext } from './context';
|
|
8
|
+
|
|
9
|
+
export type DispatchEventArgs = Omit<
|
|
10
|
+
AppendArgs,
|
|
11
|
+
'eventId' | 'expectedVersion' | 'schemaVersion' | 'occurredAt' | 'idempotencyKey'
|
|
12
|
+
> &
|
|
13
|
+
Partial<Pick<AppendArgs, 'idempotencyKey' | 'eventId'>>;
|
|
14
|
+
|
|
15
|
+
export const dispatchEvent = async (event: DispatchEventArgs): Promise<AppendEventResult> => {
|
|
16
|
+
const eventService = getEventService();
|
|
17
|
+
const eventId = event.eventId ?? `evt-${ulid()}`;
|
|
18
|
+
const occurredAt = new Date().toISOString();
|
|
19
|
+
const idempotencyKey = event.idempotencyKey ?? eventId;
|
|
20
|
+
|
|
21
|
+
return retry(async () => {
|
|
22
|
+
const head = await eventService.getHead(event.aggregateType, event.aggregateId);
|
|
23
|
+
logger.debug({ event }, 'Dispatching event');
|
|
24
|
+
return eventService.appendEvent({
|
|
25
|
+
...getEventContext(),
|
|
26
|
+
...event,
|
|
27
|
+
eventId,
|
|
28
|
+
expectedVersion: head?.currentVersion ?? 0,
|
|
29
|
+
schemaVersion: 1,
|
|
30
|
+
occurredAt,
|
|
31
|
+
idempotencyKey,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const { mockDispatchEvent } = vi.hoisted(() => ({
|
|
2
|
+
mockDispatchEvent: vi.fn(),
|
|
3
|
+
}));
|
|
4
|
+
|
|
5
|
+
vi.mock('./dispatch-event', () => ({
|
|
6
|
+
dispatchEvent: mockDispatchEvent,
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import { dispatchEvents } from './dispatch-events';
|
|
10
|
+
|
|
11
|
+
describe('dispatchEvents', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
mockDispatchEvent.mockReset();
|
|
14
|
+
mockDispatchEvent.mockResolvedValue({ pk: 'pk', sk: 'sk', version: 1 });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('dispatches events in parallel by default (Promise.all)', async () => {
|
|
18
|
+
const events = [
|
|
19
|
+
{ aggregateType: 'order', aggregateId: '1', source: 'test', eventType: 'A' },
|
|
20
|
+
{ aggregateType: 'order', aggregateId: '2', source: 'test', eventType: 'B' },
|
|
21
|
+
{ aggregateType: 'order', aggregateId: '3', source: 'test', eventType: 'C' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
await dispatchEvents(events);
|
|
25
|
+
|
|
26
|
+
expect(mockDispatchEvent).toHaveBeenCalledTimes(3);
|
|
27
|
+
expect(mockDispatchEvent).toHaveBeenCalledWith(events[0]);
|
|
28
|
+
expect(mockDispatchEvent).toHaveBeenCalledWith(events[1]);
|
|
29
|
+
expect(mockDispatchEvent).toHaveBeenCalledWith(events[2]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('dispatches events sequentially with inOrder: true', async () => {
|
|
33
|
+
const callOrder: number[] = [];
|
|
34
|
+
mockDispatchEvent.mockImplementation(async (event) => {
|
|
35
|
+
callOrder.push(event.aggregateId);
|
|
36
|
+
return { pk: 'pk', sk: 'sk', version: 1 };
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const events = [
|
|
40
|
+
{ aggregateType: 'order', aggregateId: '1', source: 'test', eventType: 'A' },
|
|
41
|
+
{ aggregateType: 'order', aggregateId: '2', source: 'test', eventType: 'B' },
|
|
42
|
+
{ aggregateType: 'order', aggregateId: '3', source: 'test', eventType: 'C' },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
await dispatchEvents(events, { inOrder: true });
|
|
46
|
+
|
|
47
|
+
expect(mockDispatchEvent).toHaveBeenCalledTimes(3);
|
|
48
|
+
expect(callOrder).toEqual(['1', '2', '3']);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('handles empty events array', async () => {
|
|
52
|
+
await dispatchEvents([]);
|
|
53
|
+
|
|
54
|
+
expect(mockDispatchEvent).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('handles empty events array with inOrder: true', async () => {
|
|
58
|
+
await dispatchEvents([], { inOrder: true });
|
|
59
|
+
|
|
60
|
+
expect(mockDispatchEvent).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('defaults inOrder to false when options not provided', async () => {
|
|
64
|
+
const events = [
|
|
65
|
+
{ aggregateType: 'order', aggregateId: '1', source: 'test', eventType: 'A' },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
await dispatchEvents(events);
|
|
69
|
+
|
|
70
|
+
expect(mockDispatchEvent).toHaveBeenCalledTimes(1);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { dispatchEvent, DispatchEventArgs } from './dispatch-event';
|
|
2
|
+
|
|
3
|
+
export interface DispatchEventsArgs {
|
|
4
|
+
inOrder?: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const dispatchEvents = async (
|
|
8
|
+
events: DispatchEventArgs[],
|
|
9
|
+
{ inOrder = false }: DispatchEventsArgs = {},
|
|
10
|
+
) => {
|
|
11
|
+
if (inOrder) {
|
|
12
|
+
for (const event of events) {
|
|
13
|
+
await dispatchEvent(event);
|
|
14
|
+
}
|
|
15
|
+
} else {
|
|
16
|
+
await Promise.all(events.map((event) => dispatchEvent(event)));
|
|
17
|
+
}
|
|
18
|
+
};
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
const { mockSend } = vi.hoisted(() => ({
|
|
2
|
+
mockSend: vi.fn(),
|
|
3
|
+
}));
|
|
4
|
+
|
|
5
|
+
vi.mock('@aws-sdk/client-dynamodb', () => ({
|
|
6
|
+
DynamoDBClient: vi.fn(),
|
|
7
|
+
ConditionalCheckFailedException: class ConditionalCheckFailedException extends Error {
|
|
8
|
+
constructor() {
|
|
9
|
+
super();
|
|
10
|
+
this.name = 'ConditionalCheckFailedException';
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('@aws-sdk/lib-dynamodb', () => ({
|
|
16
|
+
DynamoDBDocumentClient: { from: () => ({ send: mockSend }) },
|
|
17
|
+
TransactWriteCommand: vi.fn((input: unknown) => ({ input, _type: 'TransactWrite' })),
|
|
18
|
+
GetCommand: vi.fn((input: unknown) => ({ input, _type: 'Get' })),
|
|
19
|
+
QueryCommand: vi.fn((input: unknown) => ({ input, _type: 'Query' })),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('@auriclabs/pagination', () => ({
|
|
23
|
+
normalizePaginationResponse: vi.fn((input: unknown) => input),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import { createEventService } from './event-service';
|
|
27
|
+
import { TransactWriteCommand, GetCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
|
|
28
|
+
|
|
29
|
+
describe('event-service', () => {
|
|
30
|
+
const TABLE_NAME = 'test-events';
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
mockSend.mockReset();
|
|
34
|
+
vi.mocked(TransactWriteCommand).mockClear();
|
|
35
|
+
vi.mocked(GetCommand).mockClear();
|
|
36
|
+
vi.mocked(QueryCommand).mockClear();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('createEventService', () => {
|
|
40
|
+
it('returns an object with all expected methods', () => {
|
|
41
|
+
const service = createEventService(TABLE_NAME);
|
|
42
|
+
expect(service).toHaveProperty('appendEvent');
|
|
43
|
+
expect(service).toHaveProperty('getHead');
|
|
44
|
+
expect(service).toHaveProperty('getEvent');
|
|
45
|
+
expect(service).toHaveProperty('listEvents');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('appendEvent', () => {
|
|
50
|
+
it('sends TransactWriteCommand with correct table name and pk format', async () => {
|
|
51
|
+
const service = createEventService(TABLE_NAME);
|
|
52
|
+
mockSend.mockResolvedValue({});
|
|
53
|
+
|
|
54
|
+
await service.appendEvent({
|
|
55
|
+
aggregateType: 'order',
|
|
56
|
+
aggregateId: 'order-123',
|
|
57
|
+
source: 'order-service',
|
|
58
|
+
expectedVersion: 0,
|
|
59
|
+
idempotencyKey: 'idem-key-1',
|
|
60
|
+
eventId: 'evt-abc',
|
|
61
|
+
eventType: 'OrderCreated',
|
|
62
|
+
payload: { total: 100 },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(TransactWriteCommand).toHaveBeenCalledTimes(1);
|
|
66
|
+
const cmdInput = vi.mocked(TransactWriteCommand).mock.calls[0][0];
|
|
67
|
+
|
|
68
|
+
// Check Update item (HEAD)
|
|
69
|
+
const updateItem = cmdInput.TransactItems![0].Update!;
|
|
70
|
+
expect(updateItem.TableName).toBe(TABLE_NAME);
|
|
71
|
+
expect(updateItem.Key).toEqual({ pk: 'AGG#order#order-123', sk: 'HEAD' });
|
|
72
|
+
|
|
73
|
+
// Check Put item (event)
|
|
74
|
+
const putItem = cmdInput.TransactItems![1].Put!;
|
|
75
|
+
expect(putItem.TableName).toBe(TABLE_NAME);
|
|
76
|
+
expect(putItem.Item!.pk).toBe('AGG#order#order-123');
|
|
77
|
+
expect(putItem.Item!.sk).toBe('EVT#000000001');
|
|
78
|
+
expect(putItem.Item!.itemType).toBe('event');
|
|
79
|
+
expect(putItem.Item!.eventType).toBe('OrderCreated');
|
|
80
|
+
expect(putItem.Item!.payload).toEqual({ total: 100 });
|
|
81
|
+
expect(putItem.Item!.version).toBe(1);
|
|
82
|
+
expect(putItem.Item!.source).toBe('order-service');
|
|
83
|
+
expect(putItem.Item!.schemaVersion).toBe(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('pads version number to 9 digits', async () => {
|
|
87
|
+
const service = createEventService(TABLE_NAME);
|
|
88
|
+
mockSend.mockResolvedValue({});
|
|
89
|
+
|
|
90
|
+
await service.appendEvent({
|
|
91
|
+
aggregateType: 'order',
|
|
92
|
+
aggregateId: '1',
|
|
93
|
+
source: 'test',
|
|
94
|
+
expectedVersion: 41,
|
|
95
|
+
idempotencyKey: 'k',
|
|
96
|
+
eventId: 'e',
|
|
97
|
+
eventType: 'T',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const cmdInput = vi.mocked(TransactWriteCommand).mock.calls[0][0];
|
|
101
|
+
const putItem = cmdInput.TransactItems![1].Put!;
|
|
102
|
+
expect(putItem.Item!.sk).toBe('EVT#000000042');
|
|
103
|
+
expect(putItem.Item!.version).toBe(42);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('returns pk, sk, and version', async () => {
|
|
107
|
+
const service = createEventService(TABLE_NAME);
|
|
108
|
+
mockSend.mockResolvedValue({});
|
|
109
|
+
|
|
110
|
+
const result = await service.appendEvent({
|
|
111
|
+
aggregateType: 'wallet',
|
|
112
|
+
aggregateId: 'w-1',
|
|
113
|
+
source: 'billing',
|
|
114
|
+
expectedVersion: 5,
|
|
115
|
+
idempotencyKey: 'key',
|
|
116
|
+
eventId: 'evt-1',
|
|
117
|
+
eventType: 'CreditAdded',
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(result).toEqual({
|
|
121
|
+
pk: 'AGG#wallet#w-1',
|
|
122
|
+
sk: 'EVT#000000006',
|
|
123
|
+
version: 6,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('uses provided occurredAt instead of generating one', async () => {
|
|
128
|
+
const service = createEventService(TABLE_NAME);
|
|
129
|
+
mockSend.mockResolvedValue({});
|
|
130
|
+
|
|
131
|
+
await service.appendEvent({
|
|
132
|
+
aggregateType: 'order',
|
|
133
|
+
aggregateId: '1',
|
|
134
|
+
source: 'test',
|
|
135
|
+
expectedVersion: 0,
|
|
136
|
+
idempotencyKey: 'k',
|
|
137
|
+
eventId: 'e',
|
|
138
|
+
eventType: 'T',
|
|
139
|
+
occurredAt: '2025-01-01T00:00:00.000Z',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const cmdInput = vi.mocked(TransactWriteCommand).mock.calls[0][0];
|
|
143
|
+
const putItem = cmdInput.TransactItems![1].Put!;
|
|
144
|
+
expect(putItem.Item!.occurredAt).toBe('2025-01-01T00:00:00.000Z');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('includes optional metadata fields', async () => {
|
|
148
|
+
const service = createEventService(TABLE_NAME);
|
|
149
|
+
mockSend.mockResolvedValue({});
|
|
150
|
+
|
|
151
|
+
await service.appendEvent({
|
|
152
|
+
aggregateType: 'order',
|
|
153
|
+
aggregateId: '1',
|
|
154
|
+
source: 'test',
|
|
155
|
+
expectedVersion: 0,
|
|
156
|
+
idempotencyKey: 'k',
|
|
157
|
+
eventId: 'e',
|
|
158
|
+
eventType: 'T',
|
|
159
|
+
correlationId: 'corr-1',
|
|
160
|
+
causationId: 'cause-1',
|
|
161
|
+
actorId: 'user-1',
|
|
162
|
+
schemaVersion: 2,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const cmdInput = vi.mocked(TransactWriteCommand).mock.calls[0][0];
|
|
166
|
+
const putItem = cmdInput.TransactItems![1].Put!;
|
|
167
|
+
expect(putItem.Item!.correlationId).toBe('corr-1');
|
|
168
|
+
expect(putItem.Item!.causationId).toBe('cause-1');
|
|
169
|
+
expect(putItem.Item!.actorId).toBe('user-1');
|
|
170
|
+
expect(putItem.Item!.schemaVersion).toBe(2);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('throws OCC error on ConditionalCheckFailedException', async () => {
|
|
174
|
+
const service = createEventService(TABLE_NAME);
|
|
175
|
+
const { ConditionalCheckFailedException } = await import('@aws-sdk/client-dynamodb');
|
|
176
|
+
mockSend.mockRejectedValue(new ConditionalCheckFailedException());
|
|
177
|
+
|
|
178
|
+
await expect(
|
|
179
|
+
service.appendEvent({
|
|
180
|
+
aggregateType: 'order',
|
|
181
|
+
aggregateId: 'o-1',
|
|
182
|
+
source: 'test',
|
|
183
|
+
expectedVersion: 3,
|
|
184
|
+
idempotencyKey: 'k',
|
|
185
|
+
eventId: 'e',
|
|
186
|
+
eventType: 'T',
|
|
187
|
+
}),
|
|
188
|
+
).rejects.toThrow('OCC failed for aggregate order/o-1: expectedVersion=3');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('re-throws non-OCC errors', async () => {
|
|
192
|
+
const service = createEventService(TABLE_NAME);
|
|
193
|
+
mockSend.mockRejectedValue(new Error('Network error'));
|
|
194
|
+
|
|
195
|
+
await expect(
|
|
196
|
+
service.appendEvent({
|
|
197
|
+
aggregateType: 'order',
|
|
198
|
+
aggregateId: 'o-1',
|
|
199
|
+
source: 'test',
|
|
200
|
+
expectedVersion: 0,
|
|
201
|
+
idempotencyKey: 'k',
|
|
202
|
+
eventId: 'e',
|
|
203
|
+
eventType: 'T',
|
|
204
|
+
}),
|
|
205
|
+
).rejects.toThrow('Network error');
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('getHead', () => {
|
|
210
|
+
it('sends GetCommand with correct pk and sk=HEAD', async () => {
|
|
211
|
+
const service = createEventService(TABLE_NAME);
|
|
212
|
+
const head = { pk: 'AGG#order#o-1', sk: 'HEAD', currentVersion: 5 };
|
|
213
|
+
mockSend.mockResolvedValue({ Item: head });
|
|
214
|
+
|
|
215
|
+
const result = await service.getHead('order', 'o-1');
|
|
216
|
+
|
|
217
|
+
expect(GetCommand).toHaveBeenCalledWith({
|
|
218
|
+
TableName: TABLE_NAME,
|
|
219
|
+
Key: { pk: 'AGG#order#o-1', sk: 'HEAD' },
|
|
220
|
+
});
|
|
221
|
+
expect(result).toEqual(head);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('returns undefined when no head exists', async () => {
|
|
225
|
+
const service = createEventService(TABLE_NAME);
|
|
226
|
+
mockSend.mockResolvedValue({ Item: undefined });
|
|
227
|
+
|
|
228
|
+
const result = await service.getHead('order', 'nonexistent');
|
|
229
|
+
expect(result).toBeUndefined();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('getEvent', () => {
|
|
234
|
+
it('sends GetCommand with correct pk and padded version sk', async () => {
|
|
235
|
+
const service = createEventService(TABLE_NAME);
|
|
236
|
+
const event = { pk: 'AGG#order#o-1', sk: 'EVT#000000003', eventType: 'OrderCreated' };
|
|
237
|
+
mockSend.mockResolvedValue({ Item: event });
|
|
238
|
+
|
|
239
|
+
const result = await service.getEvent('order', 'o-1', 3);
|
|
240
|
+
|
|
241
|
+
expect(GetCommand).toHaveBeenCalledWith({
|
|
242
|
+
TableName: TABLE_NAME,
|
|
243
|
+
Key: { pk: 'AGG#order#o-1', sk: 'EVT#000000003' },
|
|
244
|
+
});
|
|
245
|
+
expect(result).toEqual(event);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('returns undefined when event does not exist', async () => {
|
|
249
|
+
const service = createEventService(TABLE_NAME);
|
|
250
|
+
mockSend.mockResolvedValue({ Item: undefined });
|
|
251
|
+
|
|
252
|
+
const result = await service.getEvent('order', 'o-1', 999);
|
|
253
|
+
expect(result).toBeUndefined();
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('listEvents', () => {
|
|
258
|
+
it('sends QueryCommand with default range when no bounds specified', async () => {
|
|
259
|
+
const service = createEventService(TABLE_NAME);
|
|
260
|
+
mockSend.mockResolvedValue({ Items: [], LastEvaluatedKey: undefined });
|
|
261
|
+
|
|
262
|
+
await service.listEvents({ aggregateType: 'order', aggregateId: 'o-1' });
|
|
263
|
+
|
|
264
|
+
expect(QueryCommand).toHaveBeenCalledWith({
|
|
265
|
+
TableName: TABLE_NAME,
|
|
266
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :from AND :to',
|
|
267
|
+
ExpressionAttributeValues: {
|
|
268
|
+
':pk': 'AGG#order#o-1',
|
|
269
|
+
':from': 'EVT#000000000',
|
|
270
|
+
':to': 'EVT#999999999',
|
|
271
|
+
},
|
|
272
|
+
ScanIndexForward: true,
|
|
273
|
+
Limit: undefined,
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('respects fromVersionExclusive', async () => {
|
|
278
|
+
const service = createEventService(TABLE_NAME);
|
|
279
|
+
mockSend.mockResolvedValue({ Items: [], LastEvaluatedKey: undefined });
|
|
280
|
+
|
|
281
|
+
await service.listEvents({
|
|
282
|
+
aggregateType: 'order',
|
|
283
|
+
aggregateId: 'o-1',
|
|
284
|
+
fromVersionExclusive: 5,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const cmdInput = vi.mocked(QueryCommand).mock.calls[0][0];
|
|
288
|
+
// fromVersionExclusive=5 means start from version 6
|
|
289
|
+
expect(cmdInput.ExpressionAttributeValues![':from']).toBe('EVT#000000006');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('respects toVersionInclusive', async () => {
|
|
293
|
+
const service = createEventService(TABLE_NAME);
|
|
294
|
+
mockSend.mockResolvedValue({ Items: [], LastEvaluatedKey: undefined });
|
|
295
|
+
|
|
296
|
+
await service.listEvents({
|
|
297
|
+
aggregateType: 'order',
|
|
298
|
+
aggregateId: 'o-1',
|
|
299
|
+
toVersionInclusive: 10,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const cmdInput = vi.mocked(QueryCommand).mock.calls[0][0];
|
|
303
|
+
expect(cmdInput.ExpressionAttributeValues![':to']).toBe('EVT#000000010');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('respects limit parameter', async () => {
|
|
307
|
+
const service = createEventService(TABLE_NAME);
|
|
308
|
+
mockSend.mockResolvedValue({ Items: [], LastEvaluatedKey: undefined });
|
|
309
|
+
|
|
310
|
+
await service.listEvents({
|
|
311
|
+
aggregateType: 'order',
|
|
312
|
+
aggregateId: 'o-1',
|
|
313
|
+
limit: 25,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const cmdInput = vi.mocked(QueryCommand).mock.calls[0][0];
|
|
317
|
+
expect(cmdInput.Limit).toBe(25);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('returns items from query result', async () => {
|
|
321
|
+
const service = createEventService(TABLE_NAME);
|
|
322
|
+
const events = [
|
|
323
|
+
{ pk: 'AGG#order#o-1', sk: 'EVT#000000001', eventType: 'OrderCreated' },
|
|
324
|
+
{ pk: 'AGG#order#o-1', sk: 'EVT#000000002', eventType: 'OrderUpdated' },
|
|
325
|
+
];
|
|
326
|
+
mockSend.mockResolvedValue({ Items: events, LastEvaluatedKey: undefined });
|
|
327
|
+
|
|
328
|
+
const result = await service.listEvents({ aggregateType: 'order', aggregateId: 'o-1' });
|
|
329
|
+
|
|
330
|
+
expect(result).toEqual({ data: events, cursor: undefined });
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('extracts cursor from LastEvaluatedKey sk', async () => {
|
|
334
|
+
const service = createEventService(TABLE_NAME);
|
|
335
|
+
mockSend.mockResolvedValue({
|
|
336
|
+
Items: [{ pk: 'AGG#order#o-1', sk: 'EVT#000000001' }],
|
|
337
|
+
LastEvaluatedKey: { pk: 'AGG#order#o-1', sk: 'EVT#000000001' },
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const result = await service.listEvents({ aggregateType: 'order', aggregateId: 'o-1' });
|
|
341
|
+
|
|
342
|
+
expect(result).toEqual({
|
|
343
|
+
data: [{ pk: 'AGG#order#o-1', sk: 'EVT#000000001' }],
|
|
344
|
+
cursor: 'EVT#000000001',
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('returns empty data array when Items is undefined', async () => {
|
|
349
|
+
const service = createEventService(TABLE_NAME);
|
|
350
|
+
mockSend.mockResolvedValue({ Items: undefined, LastEvaluatedKey: undefined });
|
|
351
|
+
|
|
352
|
+
const result = await service.listEvents({ aggregateType: 'order', aggregateId: 'o-1' });
|
|
353
|
+
|
|
354
|
+
expect(result).toEqual({ data: [], cursor: undefined });
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
});
|