@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.
Files changed (133) hide show
  1. package/README.md +6 -5
  2. package/dist/{chunk-24xjphpk.js → chunk-06anxtk6.js} +6 -6
  3. package/dist/{chunk-24xjphpk.js.map → chunk-06anxtk6.js.map} +2 -2
  4. package/dist/{chunk-263c07sn.js → chunk-1mr4cwpw.js} +8 -8
  5. package/dist/{chunk-263c07sn.js.map → chunk-1mr4cwpw.js.map} +2 -2
  6. package/dist/{chunk-3ejt4h7x.js → chunk-2wqpktzp.js} +78 -66
  7. package/dist/chunk-2wqpktzp.js.map +10 -0
  8. package/dist/{chunk-dgzw1nj9.js → chunk-467qtkth.js} +8 -3
  9. package/dist/chunk-467qtkth.js.map +10 -0
  10. package/dist/chunk-6dn6wxg7.js +11 -0
  11. package/dist/{chunk-jd4q8fcf.js.map → chunk-6dn6wxg7.js.map} +2 -2
  12. package/dist/{chunk-pc1q4se9.js → chunk-9qtjz725.js} +2 -2
  13. package/dist/{chunk-pc1q4se9.js.map → chunk-9qtjz725.js.map} +2 -2
  14. package/dist/{chunk-tfy01fah.js → chunk-c7sytwpp.js} +4 -4
  15. package/dist/{chunk-tfy01fah.js.map → chunk-c7sytwpp.js.map} +2 -2
  16. package/dist/{chunk-vvv14qxy.js → chunk-d6jqbrq2.js} +33 -31
  17. package/dist/{chunk-vvv14qxy.js.map → chunk-d6jqbrq2.js.map} +3 -3
  18. package/dist/{chunk-vep2byx4.js → chunk-f95c3da4.js} +77 -64
  19. package/dist/chunk-f95c3da4.js.map +10 -0
  20. package/dist/chunk-fpjnk143.js +13 -0
  21. package/dist/{chunk-6q8mr1sg.js.map → chunk-fpjnk143.js.map} +2 -2
  22. package/dist/{chunk-8qn1nqnh.js → chunk-fxb9fbvz.js} +9 -9
  23. package/dist/{chunk-8qn1nqnh.js.map → chunk-fxb9fbvz.js.map} +2 -2
  24. package/dist/chunk-hmvwn02w.js +22 -0
  25. package/dist/{chunk-6yrb30rx.js.map → chunk-hmvwn02w.js.map} +2 -2
  26. package/dist/{chunk-ty9bhdvx.js → chunk-j4mwjx07.js} +5 -3
  27. package/dist/chunk-j4mwjx07.js.map +10 -0
  28. package/dist/{chunk-391m4mfp.js → chunk-jw9e0fr0.js} +13 -13
  29. package/dist/{chunk-391m4mfp.js.map → chunk-jw9e0fr0.js.map} +2 -2
  30. package/dist/chunk-mbxxvskx.js +67 -0
  31. package/dist/chunk-mbxxvskx.js.map +10 -0
  32. package/dist/{chunk-yn2sjcg3.js → chunk-nrhw3kde.js} +57 -8
  33. package/dist/chunk-nrhw3kde.js.map +10 -0
  34. package/dist/{chunk-57b88rt5.js → chunk-pcggjrxr.js} +32 -32
  35. package/dist/{chunk-57b88rt5.js.map → chunk-pcggjrxr.js.map} +2 -2
  36. package/dist/chunk-tkwe4r0y.js +22 -0
  37. package/dist/{chunk-2fe8jjdc.js.map → chunk-tkwe4r0y.js.map} +2 -2
  38. package/dist/{chunk-z388cd25.js → chunk-zkz6ew8c.js} +5 -5
  39. package/dist/{chunk-z388cd25.js.map → chunk-zkz6ew8c.js.map} +2 -2
  40. package/dist/createAggregateRoot.d.ts +1 -1
  41. package/dist/createAggregateRoot.d.ts.map +1 -1
  42. package/dist/createAggregateRoot.js +15 -14
  43. package/dist/createAggregateRoot.js.map +1 -1
  44. package/dist/createCheckpointApi.d.ts +30 -0
  45. package/dist/createCheckpointApi.d.ts.map +1 -1
  46. package/dist/createCheckpointApi.js +5 -5
  47. package/dist/createCheckpointApi.js.map +1 -1
  48. package/dist/createEventListener.js +8 -8
  49. package/dist/createEventListener.js.map +1 -1
  50. package/dist/createEventStore.js +16 -16
  51. package/dist/createEventStore.js.map +1 -1
  52. package/dist/createProcessManager.d.ts.map +1 -1
  53. package/dist/createProcessManager.js +12 -12
  54. package/dist/createProcessManager.js.map +1 -1
  55. package/dist/createProjection.d.ts.map +1 -1
  56. package/dist/createProjection.js +12 -12
  57. package/dist/createProjection.js.map +1 -1
  58. package/dist/drizzle/index.js +132 -132
  59. package/dist/drizzle/index.js.map +2 -2
  60. package/dist/drizzle/postgres.js +14 -14
  61. package/dist/drizzle/postgres.js.map +1 -1
  62. package/dist/errors.js +24 -24
  63. package/dist/errors.js.map +1 -1
  64. package/dist/index.js +63 -63
  65. package/dist/index.js.map +1 -1
  66. package/dist/lock.d.ts +11 -3
  67. package/dist/lock.d.ts.map +1 -1
  68. package/dist/lock.js +4 -2
  69. package/dist/lock.js.map +1 -1
  70. package/dist/memory/index.d.ts +14 -3
  71. package/dist/memory/index.d.ts.map +1 -1
  72. package/dist/memory/index.js +52 -45
  73. package/dist/memory/index.js.map +3 -3
  74. package/dist/memory/index.spec.d.ts +2 -0
  75. package/dist/memory/index.spec.d.ts.map +1 -0
  76. package/dist/redis/index.js +5 -4
  77. package/dist/redis/index.js.map +1 -1
  78. package/dist/redis/redisLock.d.ts +1 -1
  79. package/dist/redis/redisLock.d.ts.map +1 -1
  80. package/dist/redis/redisLock.js +5 -4
  81. package/dist/redis/redisLock.js.map +1 -1
  82. package/dist/refresh.js +9 -9
  83. package/dist/refresh.js.map +1 -1
  84. package/dist/replay.js +9 -9
  85. package/dist/replay.js.map +1 -1
  86. package/dist/snapshotGenerator.js +2 -2
  87. package/dist/superjson.js +8 -8
  88. package/dist/superjson.js.map +1 -1
  89. package/dist/surreal/index.d.ts +35 -0
  90. package/dist/surreal/index.d.ts.map +1 -0
  91. package/dist/surreal/index.js +415 -0
  92. package/dist/surreal/index.js.map +10 -0
  93. package/dist/surreal/index.spec.d.ts +2 -0
  94. package/dist/surreal/index.spec.d.ts.map +1 -0
  95. package/dist/surreal/schema.d.ts +12 -0
  96. package/dist/surreal/schema.d.ts.map +1 -0
  97. package/dist/surreal/schema.js +12 -0
  98. package/dist/surreal/schema.js.map +9 -0
  99. package/dist/tests/projectionMultiInstance.spec.d.ts +2 -0
  100. package/dist/tests/projectionMultiInstance.spec.d.ts.map +1 -0
  101. package/dist/tests/storageDriverContract.d.ts +19 -0
  102. package/dist/tests/storageDriverContract.d.ts.map +1 -0
  103. package/dist/transaction.js +1 -1
  104. package/dist/utils.js +6 -6
  105. package/dist/utils.js.map +1 -1
  106. package/dist/wrappedEventBuilder.js +8 -8
  107. package/dist/wrappedEventBuilder.js.map +1 -1
  108. package/package.json +19 -10
  109. package/src/createAggregateRoot.ts +24 -5
  110. package/src/createCheckpointApi.ts +98 -0
  111. package/src/createProcessManager.ts +7 -6
  112. package/src/createProjection.ts +91 -65
  113. package/src/lock.ts +17 -3
  114. package/src/memory/index.spec.ts +4 -0
  115. package/src/memory/index.ts +55 -29
  116. package/src/redis/redisLock.ts +6 -2
  117. package/src/surreal/index.spec.ts +152 -0
  118. package/src/surreal/index.ts +604 -0
  119. package/src/surreal/schema.ts +68 -0
  120. package/src/tests/aggregateRoot.spec.ts +48 -3
  121. package/src/tests/eventStore.spec.ts +1 -3
  122. package/src/tests/processManagerMultiInstance.spec.ts +18 -7
  123. package/src/tests/projectionMultiInstance.spec.ts +283 -0
  124. package/src/tests/storageDriverContract.ts +612 -0
  125. package/dist/chunk-2fe8jjdc.js +0 -22
  126. package/dist/chunk-3ejt4h7x.js.map +0 -10
  127. package/dist/chunk-6q8mr1sg.js +0 -13
  128. package/dist/chunk-6yrb30rx.js +0 -22
  129. package/dist/chunk-dgzw1nj9.js.map +0 -10
  130. package/dist/chunk-jd4q8fcf.js +0 -11
  131. package/dist/chunk-ty9bhdvx.js.map +0 -10
  132. package/dist/chunk-vep2byx4.js.map +0 -10
  133. 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(slowAggregateRoot, {
556
- lock: lock(40), // 40ms
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
- if (streamIds?.length && !streamIds.includes(event.streamId)) continue
79
- if (eventTypes?.length && !eventTypes.includes(event.type)) continue
80
- if (range?.from !== undefined && event.position < range.from) continue
81
- if (range?.to !== undefined && event.position > range.to) continue
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) return cp
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
+ })