@requence/event-sourcing 1.4.1 → 1.5.1
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-24xjphpk.js → chunk-06anxtk6.js} +6 -6
- package/dist/{chunk-24xjphpk.js.map → chunk-06anxtk6.js.map} +2 -2
- package/dist/{chunk-263c07sn.js → chunk-1mr4cwpw.js} +8 -8
- package/dist/{chunk-263c07sn.js.map → chunk-1mr4cwpw.js.map} +2 -2
- package/dist/{chunk-3ejt4h7x.js → chunk-2wqpktzp.js} +78 -66
- package/dist/chunk-2wqpktzp.js.map +10 -0
- package/dist/{chunk-dgzw1nj9.js → chunk-467qtkth.js} +8 -3
- package/dist/chunk-467qtkth.js.map +10 -0
- package/dist/chunk-6dn6wxg7.js +11 -0
- package/dist/{chunk-jd4q8fcf.js.map → chunk-6dn6wxg7.js.map} +2 -2
- package/dist/{chunk-pc1q4se9.js → chunk-9qtjz725.js} +2 -2
- package/dist/{chunk-pc1q4se9.js.map → chunk-9qtjz725.js.map} +2 -2
- package/dist/{chunk-tfy01fah.js → chunk-c7sytwpp.js} +4 -4
- package/dist/{chunk-tfy01fah.js.map → chunk-c7sytwpp.js.map} +2 -2
- package/dist/{chunk-vvv14qxy.js → chunk-d6jqbrq2.js} +33 -31
- package/dist/{chunk-vvv14qxy.js.map → chunk-d6jqbrq2.js.map} +3 -3
- package/dist/{chunk-vep2byx4.js → chunk-f95c3da4.js} +77 -64
- package/dist/chunk-f95c3da4.js.map +10 -0
- package/dist/chunk-fpjnk143.js +13 -0
- package/dist/{chunk-6q8mr1sg.js.map → chunk-fpjnk143.js.map} +2 -2
- package/dist/{chunk-8qn1nqnh.js → chunk-fxb9fbvz.js} +9 -9
- package/dist/{chunk-8qn1nqnh.js.map → chunk-fxb9fbvz.js.map} +2 -2
- package/dist/chunk-hmvwn02w.js +22 -0
- package/dist/{chunk-6yrb30rx.js.map → chunk-hmvwn02w.js.map} +2 -2
- package/dist/{chunk-ty9bhdvx.js → chunk-j4mwjx07.js} +5 -3
- package/dist/chunk-j4mwjx07.js.map +10 -0
- package/dist/{chunk-391m4mfp.js → chunk-jw9e0fr0.js} +13 -13
- package/dist/{chunk-391m4mfp.js.map → chunk-jw9e0fr0.js.map} +2 -2
- package/dist/chunk-mbxxvskx.js +67 -0
- package/dist/chunk-mbxxvskx.js.map +10 -0
- package/dist/{chunk-yn2sjcg3.js → chunk-nrhw3kde.js} +57 -8
- package/dist/chunk-nrhw3kde.js.map +10 -0
- package/dist/{chunk-57b88rt5.js → chunk-pcggjrxr.js} +32 -32
- package/dist/{chunk-57b88rt5.js.map → chunk-pcggjrxr.js.map} +2 -2
- package/dist/chunk-tkwe4r0y.js +22 -0
- package/dist/{chunk-2fe8jjdc.js.map → chunk-tkwe4r0y.js.map} +2 -2
- package/dist/{chunk-z388cd25.js → chunk-zkz6ew8c.js} +5 -5
- package/dist/{chunk-z388cd25.js.map → chunk-zkz6ew8c.js.map} +2 -2
- package/dist/createAggregateRoot.d.ts +1 -1
- package/dist/createAggregateRoot.d.ts.map +1 -1
- package/dist/createAggregateRoot.js +15 -14
- package/dist/createAggregateRoot.js.map +1 -1
- package/dist/createCheckpointApi.d.ts +30 -0
- package/dist/createCheckpointApi.d.ts.map +1 -1
- package/dist/createCheckpointApi.js +5 -5
- package/dist/createCheckpointApi.js.map +1 -1
- package/dist/createEventListener.js +8 -8
- package/dist/createEventListener.js.map +1 -1
- package/dist/createEventStore.js +16 -16
- package/dist/createEventStore.js.map +1 -1
- package/dist/createProcessManager.d.ts.map +1 -1
- package/dist/createProcessManager.js +12 -12
- package/dist/createProcessManager.js.map +1 -1
- package/dist/createProjection.d.ts.map +1 -1
- package/dist/createProjection.js +12 -12
- package/dist/createProjection.js.map +1 -1
- package/dist/drizzle/index.js +132 -132
- package/dist/drizzle/index.js.map +2 -2
- package/dist/drizzle/postgres.js +14 -14
- package/dist/drizzle/postgres.js.map +1 -1
- package/dist/errors.js +24 -24
- package/dist/errors.js.map +1 -1
- package/dist/index.js +63 -63
- package/dist/index.js.map +1 -1
- package/dist/lock.d.ts +11 -3
- package/dist/lock.d.ts.map +1 -1
- package/dist/lock.js +4 -2
- package/dist/lock.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 +52 -45
- 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/redis/index.js +5 -4
- package/dist/redis/index.js.map +1 -1
- package/dist/redis/redisLock.d.ts +1 -1
- package/dist/redis/redisLock.d.ts.map +1 -1
- package/dist/redis/redisLock.js +5 -4
- package/dist/redis/redisLock.js.map +1 -1
- package/dist/refresh.js +9 -9
- package/dist/refresh.js.map +1 -1
- package/dist/replay.js +9 -9
- package/dist/replay.js.map +1 -1
- package/dist/snapshotGenerator.js +2 -2
- package/dist/superjson.js +8 -8
- package/dist/superjson.js.map +1 -1
- 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/dist/transaction.js +1 -1
- package/dist/utils.js +6 -6
- package/dist/utils.js.map +1 -1
- package/dist/wrappedEventBuilder.js +8 -8
- package/dist/wrappedEventBuilder.js.map +1 -1
- package/package.json +19 -10
- package/src/createAggregateRoot.ts +24 -5
- package/src/createCheckpointApi.ts +98 -0
- package/src/createProcessManager.ts +7 -6
- package/src/createProjection.ts +91 -65
- package/src/lock.ts +17 -3
- package/src/memory/index.spec.ts +4 -0
- package/src/memory/index.ts +55 -29
- package/src/redis/redisLock.ts +6 -2
- 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 +48 -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-2fe8jjdc.js +0 -22
- package/dist/chunk-3ejt4h7x.js.map +0 -10
- package/dist/chunk-6q8mr1sg.js +0 -13
- package/dist/chunk-6yrb30rx.js +0 -22
- package/dist/chunk-dgzw1nj9.js.map +0 -10
- package/dist/chunk-jd4q8fcf.js +0 -11
- package/dist/chunk-ty9bhdvx.js.map +0 -10
- package/dist/chunk-vep2byx4.js.map +0 -10
- package/dist/chunk-yn2sjcg3.js.map +0 -10
|
@@ -552,9 +552,12 @@ describe('hotAggregateRoot', () => {
|
|
|
552
552
|
},
|
|
553
553
|
}))
|
|
554
554
|
|
|
555
|
-
const { eventStore, pseudoEventStore } = setupEventStore(
|
|
556
|
-
|
|
557
|
-
|
|
555
|
+
const { eventStore, pseudoEventStore } = setupEventStore(
|
|
556
|
+
slowAggregateRoot,
|
|
557
|
+
{
|
|
558
|
+
lock: lock(40), // 40ms
|
|
559
|
+
},
|
|
560
|
+
)
|
|
558
561
|
|
|
559
562
|
const called: string[] = []
|
|
560
563
|
eventStore.createEventListener('test').withEventHandlers({
|
|
@@ -618,4 +621,46 @@ describe('hotAggregateRoot', () => {
|
|
|
618
621
|
|
|
619
622
|
expect(called).toBe(10)
|
|
620
623
|
})
|
|
624
|
+
|
|
625
|
+
it('keeps the lock alive while the stream replays', async () => {
|
|
626
|
+
const aggregateRoot = createAggregateRoot('slowReplay')
|
|
627
|
+
.withInitialState({ pings: 0 })
|
|
628
|
+
.withEvents(({ z }) => ({
|
|
629
|
+
Pinged: z.null(),
|
|
630
|
+
}))
|
|
631
|
+
.withEventHandlers((state) => ({
|
|
632
|
+
// Folding one event takes 10ms, so replaying ten of them outlives the
|
|
633
|
+
// 40ms lock by far. The replay is the library's own work, not a slow
|
|
634
|
+
// command, so it must not cost the caller its lock.
|
|
635
|
+
async onPinged() {
|
|
636
|
+
await setTimeout(10)
|
|
637
|
+
state.pings += 1
|
|
638
|
+
},
|
|
639
|
+
}))
|
|
640
|
+
.withCommands((_state, event) => ({
|
|
641
|
+
ping(times = 1) {
|
|
642
|
+
return Array.from({ length: times }, () => event('Pinged', null))
|
|
643
|
+
},
|
|
644
|
+
}))
|
|
645
|
+
|
|
646
|
+
const { pseudoEventStore } = setupEventStore(aggregateRoot, {
|
|
647
|
+
lock: lock(40),
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
await aggregateRoot.loadStream('a').ping(10).settled()
|
|
651
|
+
|
|
652
|
+
// Both replay ten events. Without the keep-alive the first one's lock
|
|
653
|
+
// lapses mid-replay, the second one takes it, and both append at v11.
|
|
654
|
+
await Promise.all([
|
|
655
|
+
aggregateRoot.loadStream('a').ping().settled(),
|
|
656
|
+
aggregateRoot.loadStream('a').ping().settled(),
|
|
657
|
+
])
|
|
658
|
+
|
|
659
|
+
const persisted = pseudoEventStore.filter(
|
|
660
|
+
(event) => event.streamType === 'slowReplay',
|
|
661
|
+
)
|
|
662
|
+
expect(persisted.map((event) => event.streamVersion)).toEqual(
|
|
663
|
+
Array.from({ length: 12 }, (_, index) => index + 1),
|
|
664
|
+
)
|
|
665
|
+
})
|
|
621
666
|
})
|
|
@@ -90,9 +90,7 @@ describe('Event Store', () => {
|
|
|
90
90
|
// write re-propagated stream A's stale ConcurrencyError instead of running.
|
|
91
91
|
await usersAggregate.newStream(userB).create('B').settled()
|
|
92
92
|
|
|
93
|
-
expect(
|
|
94
|
-
pseudoEventStore.some((e) => e.streamId === userB),
|
|
95
|
-
).toBeTrue()
|
|
93
|
+
expect(pseudoEventStore.some((e) => e.streamId === userB)).toBeTrue()
|
|
96
94
|
})
|
|
97
95
|
|
|
98
96
|
it('throws on interaction with posponed init', async () => {
|
|
@@ -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
|
+
})
|