@requence/event-sourcing 1.4.0 → 1.5.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 +6 -5
- package/dist/{chunk-yn2sjcg3.js → chunk-3251p253.js} +50 -1
- package/dist/chunk-3251p253.js.map +10 -0
- package/dist/{chunk-vep2byx4.js → chunk-7akf01zj.js} +11 -11
- package/dist/{chunk-vep2byx4.js.map → chunk-7akf01zj.js.map} +3 -3
- package/dist/{chunk-57b88rt5.js → chunk-8edc30gz.js} +11 -11
- package/dist/{chunk-57b88rt5.js.map → chunk-8edc30gz.js.map} +1 -1
- package/dist/chunk-ak901ckt.js +67 -0
- package/dist/chunk-ak901ckt.js.map +10 -0
- package/dist/{chunk-vvv14qxy.js → chunk-kqh3q3fj.js} +10 -8
- package/dist/{chunk-vvv14qxy.js.map → chunk-kqh3q3fj.js.map} +3 -3
- package/dist/{chunk-3ejt4h7x.js → chunk-zznvzvnv.js} +58 -46
- package/dist/chunk-zznvzvnv.js.map +10 -0
- package/dist/createAggregateRoot.d.ts.map +1 -1
- package/dist/createAggregateRoot.js +7 -7
- package/dist/createCheckpointApi.d.ts +30 -0
- package/dist/createCheckpointApi.d.ts.map +1 -1
- package/dist/createCheckpointApi.js +1 -1
- package/dist/createEventStore.js +8 -8
- package/dist/createProcessManager.d.ts.map +1 -1
- package/dist/createProcessManager.js +3 -3
- package/dist/createProjection.d.ts.map +1 -1
- package/dist/createProjection.js +2 -2
- package/dist/drizzle/index.js +15 -15
- package/dist/drizzle/index.js.map +3 -3
- package/dist/index.js +18 -18
- package/dist/index.js.map +1 -1
- package/dist/memory/index.d.ts +14 -3
- package/dist/memory/index.d.ts.map +1 -1
- package/dist/memory/index.js +37 -30
- package/dist/memory/index.js.map +3 -3
- package/dist/memory/index.spec.d.ts +2 -0
- package/dist/memory/index.spec.d.ts.map +1 -0
- package/dist/surreal/index.d.ts +35 -0
- package/dist/surreal/index.d.ts.map +1 -0
- package/dist/surreal/index.js +415 -0
- package/dist/surreal/index.js.map +10 -0
- package/dist/surreal/index.spec.d.ts +2 -0
- package/dist/surreal/index.spec.d.ts.map +1 -0
- package/dist/surreal/schema.d.ts +12 -0
- package/dist/surreal/schema.d.ts.map +1 -0
- package/dist/surreal/schema.js +12 -0
- package/dist/surreal/schema.js.map +9 -0
- package/dist/tests/projectionMultiInstance.spec.d.ts +2 -0
- package/dist/tests/projectionMultiInstance.spec.d.ts.map +1 -0
- package/dist/tests/storageDriverContract.d.ts +19 -0
- package/dist/tests/storageDriverContract.d.ts.map +1 -0
- package/package.json +19 -10
- package/src/createAggregateRoot.ts +2 -3
- package/src/createCheckpointApi.ts +98 -0
- package/src/createProcessManager.ts +7 -6
- package/src/createProjection.ts +91 -65
- package/src/drizzle/index.ts +1 -1
- package/src/memory/index.spec.ts +4 -0
- package/src/memory/index.ts +55 -29
- package/src/surreal/index.spec.ts +152 -0
- package/src/surreal/index.ts +604 -0
- package/src/surreal/schema.ts +68 -0
- package/src/tests/aggregateRoot.spec.ts +6 -3
- package/src/tests/eventStore.spec.ts +1 -3
- package/src/tests/processManagerMultiInstance.spec.ts +18 -7
- package/src/tests/projectionMultiInstance.spec.ts +283 -0
- package/src/tests/storageDriverContract.ts +612 -0
- package/dist/chunk-3ejt4h7x.js.map +0 -10
- package/dist/chunk-yn2sjcg3.js.map +0 -10
|
@@ -5,8 +5,8 @@ import { z } from 'zod/v4'
|
|
|
5
5
|
import type { Checkpoint } from '../createCheckpointApi.ts'
|
|
6
6
|
import type { BaseOutputEvent, MaybePromise } from '../utilityTypes.js'
|
|
7
7
|
import {
|
|
8
|
-
type Event,
|
|
9
8
|
ConcurrencyError,
|
|
9
|
+
type Event,
|
|
10
10
|
createAggregateRoot,
|
|
11
11
|
createEventStore,
|
|
12
12
|
isStreamEvents,
|
|
@@ -73,19 +73,30 @@ function createSharedDatabase() {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
for (const event of events) {
|
|
76
|
-
if (streamTypes?.length && !streamTypes.includes(event.streamType))
|
|
76
|
+
if (streamTypes?.length && !streamTypes.includes(event.streamType)) {
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
if (streamIds?.length && !streamIds.includes(event.streamId)) {
|
|
77
80
|
continue
|
|
78
|
-
|
|
79
|
-
if (eventTypes?.length && !eventTypes.includes(event.type))
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
}
|
|
82
|
+
if (eventTypes?.length && !eventTypes.includes(event.type)) {
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
if (range?.from !== undefined && event.position < range.from) {
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (range?.to !== undefined && event.position > range.to) {
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
82
91
|
yield event
|
|
83
92
|
}
|
|
84
93
|
},
|
|
85
94
|
checkpoint: {
|
|
86
95
|
get(type: string, name: string) {
|
|
87
96
|
for (const cp of checkpoints) {
|
|
88
|
-
if (cp.type === type && cp.name === name)
|
|
97
|
+
if (cp.type === type && cp.name === name) {
|
|
98
|
+
return cp
|
|
99
|
+
}
|
|
89
100
|
}
|
|
90
101
|
},
|
|
91
102
|
upsert(checkpoint: Checkpoint, expectedVersion: number | null) {
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod/v4'
|
|
4
|
+
|
|
5
|
+
import type { Checkpoint } from '../createCheckpointApi.ts'
|
|
6
|
+
import type { BaseOutputEvent } from '../utilityTypes.js'
|
|
7
|
+
import {
|
|
8
|
+
ConcurrencyError,
|
|
9
|
+
type Event,
|
|
10
|
+
createAggregateRoot,
|
|
11
|
+
createEventStore,
|
|
12
|
+
isStreamEvents,
|
|
13
|
+
isStreamId,
|
|
14
|
+
} from '../index.ts'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Two event store instances against one backing store — the shape of a
|
|
18
|
+
* horizontally-scaled deployment, where every instance runs its own copy of
|
|
19
|
+
* every projection but they all write to the same projection tables.
|
|
20
|
+
*
|
|
21
|
+
* A projection decides what it has already applied from its own in-memory
|
|
22
|
+
* `lastEventPosition`, which cannot see a peer. So when two instances catch up
|
|
23
|
+
* over the same backlog, or one catches up while the other applies events it
|
|
24
|
+
* just appended, the same event reaches both handlers. These tests pin down that
|
|
25
|
+
* each event's handler runs exactly once across the cluster.
|
|
26
|
+
*/
|
|
27
|
+
function createSharedDatabase() {
|
|
28
|
+
const events: Event[] = []
|
|
29
|
+
const checkpoints = new Set<Checkpoint>()
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
events,
|
|
33
|
+
checkpoints,
|
|
34
|
+
|
|
35
|
+
appendEvents(
|
|
36
|
+
{ id, stream }: { id: string; stream: string },
|
|
37
|
+
newEvents: any[],
|
|
38
|
+
expectedVersion: number,
|
|
39
|
+
) {
|
|
40
|
+
const lastEvent = events.findLast(
|
|
41
|
+
(e) => e.streamId === id && e.streamType === stream,
|
|
42
|
+
)
|
|
43
|
+
const currentVersion = lastEvent?.streamVersion ?? 0
|
|
44
|
+
|
|
45
|
+
if (currentVersion !== expectedVersion) {
|
|
46
|
+
throw new ConcurrencyError(
|
|
47
|
+
{ id, stream },
|
|
48
|
+
expectedVersion,
|
|
49
|
+
currentVersion,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const appended: Event[] = newEvents.map((event, index) => ({
|
|
54
|
+
...event,
|
|
55
|
+
createdAt: new Date(),
|
|
56
|
+
position: events.length + index,
|
|
57
|
+
streamId: id,
|
|
58
|
+
streamType: stream,
|
|
59
|
+
streamVersion: expectedVersion + index + 1,
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
events.push(...appended)
|
|
63
|
+
return appended
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
async *loadEvents(
|
|
67
|
+
select: any,
|
|
68
|
+
range?: { from?: number; to?: number },
|
|
69
|
+
): AsyncGenerator<BaseOutputEvent> {
|
|
70
|
+
let streamTypes: string[] | null = null
|
|
71
|
+
let streamIds: string[] | null = null
|
|
72
|
+
let eventTypes: string[] | null = null
|
|
73
|
+
|
|
74
|
+
if (select) {
|
|
75
|
+
const selects = Array.isArray(select) ? select : [select]
|
|
76
|
+
streamTypes = selects.map((s: any) => s.stream)
|
|
77
|
+
streamIds = selects
|
|
78
|
+
.map((s: any) => (isStreamId(s) ? s.id : null))
|
|
79
|
+
.filter(Boolean) as string[]
|
|
80
|
+
eventTypes = selects.flatMap((s: any) =>
|
|
81
|
+
isStreamEvents(s) ? s.events : [],
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const event of events) {
|
|
86
|
+
if (streamTypes?.length && !streamTypes.includes(event.streamType)) {
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (streamIds?.length && !streamIds.includes(event.streamId)) {
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if (eventTypes?.length && !eventTypes.includes(event.type)) {
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if (range?.from !== undefined && event.position < range.from) {
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
if (range?.to !== undefined && event.position > range.to) {
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
yield event
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
checkpoint: {
|
|
106
|
+
get(type: string, name: string) {
|
|
107
|
+
for (const cp of checkpoints) {
|
|
108
|
+
if (cp.type === type && cp.name === name) {
|
|
109
|
+
return cp
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
upsert(checkpoint: Checkpoint, expectedVersion: number | null) {
|
|
114
|
+
let persisted: Checkpoint | undefined
|
|
115
|
+
for (const c of checkpoints) {
|
|
116
|
+
if (c.type === checkpoint.type && c.name === checkpoint.name) {
|
|
117
|
+
persisted = c
|
|
118
|
+
break
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (persisted) {
|
|
123
|
+
// The optimistic-concurrency guard a real driver implements with
|
|
124
|
+
// `INSERT … ON CONFLICT DO UPDATE … WHERE version = expected`.
|
|
125
|
+
if (
|
|
126
|
+
expectedVersion === null ||
|
|
127
|
+
persisted.version !== expectedVersion
|
|
128
|
+
) {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
persisted.lastEventPosition = checkpoint.lastEventPosition
|
|
132
|
+
persisted.metadata = checkpoint.metadata
|
|
133
|
+
persisted.version = checkpoint.version
|
|
134
|
+
} else {
|
|
135
|
+
if (expectedVersion !== null) {
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
checkpoints.add({ ...checkpoint })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return true
|
|
142
|
+
},
|
|
143
|
+
delete(type: string, name: string) {
|
|
144
|
+
for (const cp of checkpoints) {
|
|
145
|
+
if (cp.type === type && cp.name === name) {
|
|
146
|
+
checkpoints.delete(cp)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function counterAggregate() {
|
|
155
|
+
return createAggregateRoot('counters')
|
|
156
|
+
.withEvents({ CounterIncremented: z.object({ amount: z.number() }) })
|
|
157
|
+
.withCommands((event) => ({
|
|
158
|
+
increment(amount: number) {
|
|
159
|
+
return event('CounterIncremented', { amount })
|
|
160
|
+
},
|
|
161
|
+
}))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function createInstance(sharedDb: ReturnType<typeof createSharedDatabase>) {
|
|
165
|
+
const aggregate = counterAggregate()
|
|
166
|
+
|
|
167
|
+
const eventStore = createEventStore({
|
|
168
|
+
aggregateRoots: [aggregate],
|
|
169
|
+
autoInit: false,
|
|
170
|
+
appendEvents: sharedDb.appendEvents.bind(sharedDb),
|
|
171
|
+
loadEvents: sharedDb.loadEvents.bind(sharedDb),
|
|
172
|
+
checkpoint: sharedDb.checkpoint,
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
return { aggregate, eventStore }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Puts a backlog of events in the store with no projection to consume them. */
|
|
179
|
+
async function seedBacklog(
|
|
180
|
+
sharedDb: ReturnType<typeof createSharedDatabase>,
|
|
181
|
+
count: number,
|
|
182
|
+
) {
|
|
183
|
+
const seeder = createInstance(sharedDb)
|
|
184
|
+
await seeder.eventStore.init()
|
|
185
|
+
await seeder.eventStore.isReady()
|
|
186
|
+
|
|
187
|
+
const stream = seeder.aggregate.newStream()
|
|
188
|
+
for (let i = 0; i < count; i++) {
|
|
189
|
+
stream.increment(1)
|
|
190
|
+
}
|
|
191
|
+
await stream.settled()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
describe('Projection multi-instance', () => {
|
|
195
|
+
it('applies each backlogged event once when two instances catch up at the same time', async () => {
|
|
196
|
+
const sharedDb = createSharedDatabase()
|
|
197
|
+
await seedBacklog(sharedDb, 5)
|
|
198
|
+
|
|
199
|
+
// Stands in for the shared projection table both instances write to.
|
|
200
|
+
const applied: number[] = []
|
|
201
|
+
|
|
202
|
+
const instances = [createInstance(sharedDb), createInstance(sharedDb)]
|
|
203
|
+
for (const instance of instances) {
|
|
204
|
+
instance.eventStore.createProjection('counter').withEventHandlers({
|
|
205
|
+
async onCounterIncremented({ position }) {
|
|
206
|
+
applied.push(position)
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await Promise.all(instances.map((i) => i.eventStore.init()))
|
|
212
|
+
await Promise.all(instances.map((i) => i.eventStore.isReady()))
|
|
213
|
+
|
|
214
|
+
expect(applied.toSorted((a, b) => a - b)).toEqual([0, 1, 2, 3, 4])
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('does not re-apply an event to a handler that cannot take it twice', async () => {
|
|
218
|
+
const sharedDb = createSharedDatabase()
|
|
219
|
+
await seedBacklog(sharedDb, 5)
|
|
220
|
+
|
|
221
|
+
// A primary key, in miniature: the second insert of a row is an error, which
|
|
222
|
+
// is what kills a real instance mid-hydration.
|
|
223
|
+
const rows = new Set<number>()
|
|
224
|
+
const insert = (position: number) => {
|
|
225
|
+
if (rows.has(position)) {
|
|
226
|
+
throw new Error(`duplicate key: position ${position} already applied`)
|
|
227
|
+
}
|
|
228
|
+
rows.add(position)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const instances = [createInstance(sharedDb), createInstance(sharedDb)]
|
|
232
|
+
for (const instance of instances) {
|
|
233
|
+
instance.eventStore.createProjection('counter').withEventHandlers({
|
|
234
|
+
async onCounterIncremented({ position }) {
|
|
235
|
+
insert(position)
|
|
236
|
+
},
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
await Promise.all(instances.map((i) => i.eventStore.init()))
|
|
241
|
+
await Promise.all(instances.map((i) => i.eventStore.isReady()))
|
|
242
|
+
|
|
243
|
+
expect([...rows].toSorted((a, b) => a - b)).toEqual([0, 1, 2, 3, 4])
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('leaves an event to be retried when its handler fails', async () => {
|
|
247
|
+
// Taking ownership of an event before running its handler must not turn a
|
|
248
|
+
// failed handler into a silently skipped event: the checkpoint has to end up
|
|
249
|
+
// back where it was so a later catch-up picks the event up again.
|
|
250
|
+
const sharedDb = createSharedDatabase()
|
|
251
|
+
await seedBacklog(sharedDb, 3)
|
|
252
|
+
|
|
253
|
+
let failing = true
|
|
254
|
+
const applied: number[] = []
|
|
255
|
+
|
|
256
|
+
const first = createInstance(sharedDb)
|
|
257
|
+
first.eventStore.createProjection('counter').withEventHandlers({
|
|
258
|
+
async onCounterIncremented({ position }) {
|
|
259
|
+
if (failing && position === 1) {
|
|
260
|
+
throw new Error('handler blew up')
|
|
261
|
+
}
|
|
262
|
+
applied.push(position)
|
|
263
|
+
},
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
await expect(first.eventStore.init()).rejects.toThrow('handler blew up')
|
|
267
|
+
|
|
268
|
+
failing = false
|
|
269
|
+
const second = createInstance(sharedDb)
|
|
270
|
+
second.eventStore.createProjection('counter').withEventHandlers({
|
|
271
|
+
async onCounterIncremented({ position }) {
|
|
272
|
+
applied.push(position)
|
|
273
|
+
},
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
await second.eventStore.init()
|
|
277
|
+
await second.eventStore.isReady()
|
|
278
|
+
|
|
279
|
+
// Event 1 has to have been applied by the second instance, and events it
|
|
280
|
+
// never reached must not be lost either.
|
|
281
|
+
expect(applied.toSorted((a, b) => a - b)).toEqual([0, 1, 2])
|
|
282
|
+
})
|
|
283
|
+
})
|