@requence/event-sourcing 0.0.1 → 1.0.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.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/dist/chunk-24xjphpk.js +51 -0
  3. package/dist/chunk-24xjphpk.js.map +10 -0
  4. package/dist/chunk-3t1vzbab.js +88 -0
  5. package/dist/chunk-3t1vzbab.js.map +10 -0
  6. package/dist/chunk-6q8mr1sg.js +13 -0
  7. package/dist/chunk-6q8mr1sg.js.map +10 -0
  8. package/dist/chunk-9psg50cb.js +22 -0
  9. package/dist/chunk-9psg50cb.js.map +10 -0
  10. package/dist/chunk-dgzw1nj9.js +50 -0
  11. package/dist/chunk-dgzw1nj9.js.map +10 -0
  12. package/dist/chunk-gq80tgvf.js +70 -0
  13. package/dist/chunk-gq80tgvf.js.map +10 -0
  14. package/dist/chunk-jd4q8fcf.js +11 -0
  15. package/dist/chunk-jd4q8fcf.js.map +10 -0
  16. package/dist/chunk-m07vf8q9.js +210 -0
  17. package/dist/chunk-m07vf8q9.js.map +10 -0
  18. package/dist/chunk-ncpk70p4.js +313 -0
  19. package/dist/chunk-ncpk70p4.js.map +10 -0
  20. package/dist/chunk-pmfzjavb.js +49 -0
  21. package/dist/chunk-pmfzjavb.js.map +10 -0
  22. package/dist/chunk-qdczt68t.js +84 -0
  23. package/dist/chunk-qdczt68t.js.map +10 -0
  24. package/dist/chunk-tfy01fah.js +34 -0
  25. package/dist/chunk-tfy01fah.js.map +10 -0
  26. package/dist/chunk-tkvj6x8k.js +389 -0
  27. package/dist/chunk-tkvj6x8k.js.map +10 -0
  28. package/dist/chunk-ty9bhdvx.js +53 -0
  29. package/dist/chunk-ty9bhdvx.js.map +10 -0
  30. package/dist/chunk-wmz6a1jk.js +233 -0
  31. package/dist/chunk-wmz6a1jk.js.map +10 -0
  32. package/dist/chunk-x90g388y.js +22 -0
  33. package/dist/chunk-x90g388y.js.map +10 -0
  34. package/dist/chunk-z388cd25.js +91 -0
  35. package/dist/chunk-z388cd25.js.map +10 -0
  36. package/dist/chunk-zf3x573k.js +65 -0
  37. package/dist/chunk-zf3x573k.js.map +10 -0
  38. package/dist/createAggregateRoot.d.ts +191 -0
  39. package/dist/createAggregateRoot.d.ts.map +1 -0
  40. package/dist/createAggregateRoot.js +18 -0
  41. package/dist/createAggregateRoot.js.map +9 -0
  42. package/dist/createCheckpointApi.d.ts +38 -0
  43. package/dist/createCheckpointApi.d.ts.map +1 -0
  44. package/dist/createCheckpointApi.js +9 -0
  45. package/dist/createCheckpointApi.js.map +9 -0
  46. package/dist/createEventListener.d.ts +46 -0
  47. package/dist/createEventListener.d.ts.map +1 -0
  48. package/dist/createEventListener.js +12 -0
  49. package/dist/createEventListener.js.map +9 -0
  50. package/dist/createEventStore.d.ts +57 -0
  51. package/dist/createEventStore.d.ts.map +1 -0
  52. package/dist/createEventStore.js +20 -0
  53. package/dist/createEventStore.js.map +9 -0
  54. package/dist/createProcessManager.d.ts +70 -0
  55. package/dist/createProcessManager.d.ts.map +1 -0
  56. package/dist/createProcessManager.js +16 -0
  57. package/dist/createProcessManager.js.map +9 -0
  58. package/dist/createProjection.d.ts +120 -0
  59. package/dist/createProjection.d.ts.map +1 -0
  60. package/dist/createProjection.js +16 -0
  61. package/dist/createProjection.js.map +9 -0
  62. package/dist/drizzle/index.d.ts +449 -0
  63. package/dist/drizzle/index.d.ts.map +1 -0
  64. package/dist/drizzle/index.js +325 -0
  65. package/dist/drizzle/index.js.map +10 -0
  66. package/dist/drizzle/postgres.d.ts +686 -0
  67. package/dist/drizzle/postgres.d.ts.map +1 -0
  68. package/dist/drizzle/postgres.js +18 -0
  69. package/dist/drizzle/postgres.js.map +9 -0
  70. package/dist/errors.d.ts +53 -0
  71. package/dist/errors.d.ts.map +1 -0
  72. package/dist/errors.js +26 -0
  73. package/dist/errors.js.map +9 -0
  74. package/dist/index.d.ts +15 -0
  75. package/dist/index.d.ts.map +1 -0
  76. package/dist/index.js +73 -0
  77. package/dist/index.js.map +9 -0
  78. package/dist/lock.d.ts +6 -0
  79. package/dist/lock.d.ts.map +1 -0
  80. package/dist/lock.js +8 -0
  81. package/dist/lock.js.map +9 -0
  82. package/dist/memory/index.d.ts +15 -0
  83. package/dist/memory/index.d.ts.map +1 -0
  84. package/dist/memory/index.js +178 -0
  85. package/dist/memory/index.js.map +10 -0
  86. package/dist/redis/index.d.ts +2 -0
  87. package/dist/redis/index.d.ts.map +1 -0
  88. package/dist/redis/index.js +8 -0
  89. package/dist/redis/index.js.map +9 -0
  90. package/dist/redis/redisLock.d.ts +4 -0
  91. package/dist/redis/redisLock.d.ts.map +1 -0
  92. package/dist/redis/redisLock.js +8 -0
  93. package/dist/redis/redisLock.js.map +9 -0
  94. package/dist/refresh.d.ts +4 -0
  95. package/dist/refresh.d.ts.map +1 -0
  96. package/dist/refresh.js +13 -0
  97. package/dist/refresh.js.map +9 -0
  98. package/dist/replay.d.ts +4 -0
  99. package/dist/replay.d.ts.map +1 -0
  100. package/dist/replay.js +13 -0
  101. package/dist/replay.js.map +9 -0
  102. package/dist/snapshotGenerator.d.ts +79 -0
  103. package/dist/snapshotGenerator.d.ts.map +1 -0
  104. package/dist/snapshotGenerator.js +9 -0
  105. package/dist/snapshotGenerator.js.map +9 -0
  106. package/dist/superjson.d.ts +5 -0
  107. package/dist/superjson.d.ts.map +1 -0
  108. package/dist/superjson.js +12 -0
  109. package/dist/superjson.js.map +9 -0
  110. package/dist/transaction.d.ts +13 -0
  111. package/dist/transaction.d.ts.map +1 -0
  112. package/dist/transaction.js +8 -0
  113. package/dist/transaction.js.map +9 -0
  114. package/dist/utilityTypes.d.js +2 -0
  115. package/dist/utilityTypes.d.js.map +9 -0
  116. package/dist/utils.d.ts +4 -0
  117. package/dist/utils.d.ts.map +1 -0
  118. package/dist/utils.js +10 -0
  119. package/dist/utils.js.map +9 -0
  120. package/dist/wrappedEventBuilder.d.ts +37 -0
  121. package/dist/wrappedEventBuilder.d.ts.map +1 -0
  122. package/dist/wrappedEventBuilder.js +12 -0
  123. package/dist/wrappedEventBuilder.js.map +9 -0
  124. package/package.json +85 -1
  125. package/src/createAggregateRoot.ts +902 -0
  126. package/src/createCheckpointApi.ts +106 -0
  127. package/src/createEventListener.ts +118 -0
  128. package/src/createEventStore.ts +446 -0
  129. package/src/createProcessManager.ts +363 -0
  130. package/src/createProjection.ts +577 -0
  131. package/src/drizzle/index.ts +449 -0
  132. package/src/drizzle/postgres.ts +120 -0
  133. package/src/errors.ts +85 -0
  134. package/src/index.ts +45 -0
  135. package/src/lock.ts +58 -0
  136. package/src/memory/index.ts +224 -0
  137. package/src/redis/index.ts +1 -0
  138. package/src/redis/redisLock.ts +61 -0
  139. package/src/refresh.ts +19 -0
  140. package/src/replay.ts +19 -0
  141. package/src/snapshotGenerator.ts +190 -0
  142. package/src/superjson.ts +11 -0
  143. package/src/tests/aggregateRoot.spec.ts +532 -0
  144. package/src/tests/eventListener.spec.ts +57 -0
  145. package/src/tests/eventStore.spec.ts +86 -0
  146. package/src/tests/processManager.spec.ts +185 -0
  147. package/src/tests/projection.spec.ts +511 -0
  148. package/src/tests/projectionProcessManager.spec.ts +203 -0
  149. package/src/tests/setup.ts +230 -0
  150. package/src/tests/transaction.spec.ts +185 -0
  151. package/src/transaction.ts +93 -0
  152. package/src/utilityTypes.d.ts +110 -0
  153. package/src/utils.ts +43 -0
  154. package/src/wrappedEventBuilder.ts +181 -0
@@ -0,0 +1,203 @@
1
+ import { setTimeout } from 'node:timers/promises'
2
+
3
+ import { describe, expect, it } from 'bun:test'
4
+ import { z } from 'zod/v4'
5
+
6
+ import { createUserAggregate, setupEventStore } from './setup.ts'
7
+ import { createAggregateRoot } from '../createAggregateRoot.ts'
8
+
9
+ describe('Projection & ProcessManager', () => {
10
+ it('is settled when all chained events are handled', async () => {
11
+ const usersAggregate = createUserAggregate()
12
+ const { eventStore } = setupEventStore(usersAggregate)
13
+
14
+ const users = new Map<string, { name: string; age?: number }>()
15
+ eventStore.createProjection('user').withEventHandlers({
16
+ onUserCreated({ streamId, payload }) {
17
+ if (users.size > 0) {
18
+ throw new Error('wtf')
19
+ }
20
+ users.set(streamId, { name: payload.name })
21
+ },
22
+ async onUserUpdated({ streamId, payload }) {
23
+ await setTimeout(50)
24
+ users.get(streamId)!.age = payload.age
25
+ },
26
+ })
27
+
28
+ eventStore.createProcessManager('user').withEventHandlers({
29
+ async onUserCreated({ streamId }) {
30
+ await setTimeout(50)
31
+ usersAggregate.loadStream(streamId).update(40)
32
+ },
33
+ })
34
+
35
+ await usersAggregate.newStream().create('Torsten').update(30).settled()
36
+
37
+ expect(users.size).toBe(1)
38
+ expect(Array.from(users.values()).at(0)).toEqual({
39
+ name: 'Torsten',
40
+ age: 40,
41
+ })
42
+ })
43
+
44
+ it('is settled when all deeply chained events are handled', async () => {
45
+ const groupAggregate = createAggregateRoot('group')
46
+ .withInitialState({
47
+ id: null as string | null,
48
+ })
49
+ .withEvents({
50
+ GroupCreated: z.object({
51
+ name: z.string(),
52
+ }),
53
+ GroupDeleted: z.null(),
54
+ })
55
+ .withEventHandlers((state) => ({
56
+ onGroupCreated({ streamId }) {
57
+ state.id = streamId
58
+ },
59
+ }))
60
+ .withCommands((_state, event) => ({
61
+ create(name: string) {
62
+ return event('GroupCreated', { name })
63
+ },
64
+ delete() {
65
+ return event('GroupDeleted', null)
66
+ },
67
+ }))
68
+
69
+ const projectAggregate = createAggregateRoot('project')
70
+ .withEvents({
71
+ ProjectCreated: z.object({
72
+ name: z.string(),
73
+ groupId: z.uuid(),
74
+ }),
75
+ ProjectDeleted: z.null(),
76
+ })
77
+ .withCommands((event) => ({
78
+ create(name: string, groupId: string) {
79
+ return event('ProjectCreated', { name, groupId })
80
+ },
81
+ delete() {
82
+ return event('ProjectDeleted', null)
83
+ },
84
+ }))
85
+
86
+ const { eventStore } = setupEventStore([groupAggregate, projectAggregate])
87
+
88
+ const groups = new Map<string, string>()
89
+ eventStore.createProjection('group').withEventHandlers({
90
+ onGroupCreated({ streamId: groupId, payload }) {
91
+ groups.set(groupId, payload.name)
92
+ },
93
+ onGroupDeleted({ streamId: groupId }) {
94
+ groups.delete(groupId)
95
+ },
96
+ })
97
+
98
+ const projects = new Map<string, string>()
99
+ eventStore.createProjection('project').withEventHandlers({
100
+ onProjectCreated({ streamId: projectId, payload }) {
101
+ projects.set(projectId, payload.name)
102
+ },
103
+ onProjectDeleted({ streamId: projectId }) {
104
+ projects.delete(projectId)
105
+ },
106
+ })
107
+
108
+ eventStore
109
+ .createProcessManager('cascade')
110
+ .withState({
111
+ projectsByGroup: new Map<string, Set<string>>(),
112
+ })
113
+ .withEventHandlers((state) => ({
114
+ async onProjectCreated({ streamId: projectId, payload: { groupId } }) {
115
+ await setTimeout(20)
116
+ if (!state.projectsByGroup.has(groupId)) {
117
+ state.projectsByGroup.set(groupId, new Set())
118
+ }
119
+ state.projectsByGroup.get(groupId)!.add(projectId)
120
+ },
121
+ async onGroupDeleted({ streamId: groupId }) {
122
+ await setTimeout(20)
123
+ const projectIds = state.projectsByGroup.get(groupId)
124
+
125
+ projectIds?.forEach((projectId) => {
126
+ projectAggregate.loadStream(projectId).delete()
127
+ })
128
+ state.projectsByGroup.delete(groupId)
129
+ },
130
+ }))
131
+
132
+ const groupState = await groupAggregate.newStream().create('groupA').state()
133
+ await Promise.all([
134
+ projectAggregate
135
+ .newStream()
136
+ .create('projectA1', groupState.id!)
137
+ .settled(),
138
+ projectAggregate
139
+ .newStream()
140
+ .create('projectA2', groupState.id!)
141
+ .settled(),
142
+ ])
143
+
144
+ expect(groups.size).toBe(1)
145
+ expect(projects.size).toBe(2)
146
+
147
+ await groupAggregate.loadStream(groupState.id!).delete().settled()
148
+
149
+ expect(groups.size).toBe(0)
150
+ expect(projects.size).toBe(0)
151
+ })
152
+
153
+ it('can change commands with process manager', async () => {
154
+ const orderOfOperations: string[] = []
155
+ const userAggregate = createAggregateRoot('users')
156
+ .withEvents({
157
+ UserCreated: z.object({
158
+ name: z.string(),
159
+ }),
160
+ UserAgeSet: z.object({
161
+ age: z.int(),
162
+ }),
163
+ UserRoleSet: z.object({
164
+ role: z.string(),
165
+ }),
166
+ })
167
+ .withCommands((event) => ({
168
+ async *create(name: string) {
169
+ await setTimeout(10)
170
+ yield event('UserCreated', { name })
171
+ orderOfOperations.push('created')
172
+ },
173
+ async *setAge(age: number) {
174
+ await setTimeout(20)
175
+ yield event('UserAgeSet', { age })
176
+ orderOfOperations.push('age set')
177
+ },
178
+ async *setRole(role: string) {
179
+ await setTimeout(30)
180
+ yield event('UserRoleSet', { role })
181
+ orderOfOperations.push('role set')
182
+ },
183
+ }))
184
+ const { eventStore } = setupEventStore(userAggregate)
185
+
186
+ eventStore.createProcessManager('user').withEventHandlers({
187
+ async onUserCreated({ streamId }) {
188
+ await setTimeout(30)
189
+ orderOfOperations.push('injected')
190
+ userAggregate.loadStream(streamId).setRole('Admin')
191
+ },
192
+ })
193
+
194
+ await userAggregate.newStream().create('Torsten').setAge(40).settled()
195
+
196
+ expect(orderOfOperations).toEqual([
197
+ 'created',
198
+ 'age set',
199
+ 'injected',
200
+ 'role set',
201
+ ])
202
+ })
203
+ })
@@ -0,0 +1,230 @@
1
+ import { setTimeout } from 'node:timers/promises'
2
+
3
+ import { z } from 'zod/v4'
4
+
5
+ import type { AnyAggregateRoot } from '../createAggregateRoot.ts'
6
+ import type { Checkpoint } from '../createCheckpointApi.ts'
7
+ import {
8
+ type AggregateRootSnapshot,
9
+ ConcurrencyError,
10
+ type Event,
11
+ type ProjectionSnapshot,
12
+ createAggregateRoot,
13
+ createEventStore,
14
+ isStreamEvents,
15
+ isStreamId,
16
+ } from '../index.ts'
17
+ import type { LockCreator } from '../lock.ts'
18
+
19
+ export function createUserAggregate() {
20
+ return createAggregateRoot('users')
21
+ .withEvents({
22
+ UserCreated: z.object({
23
+ name: z.string(),
24
+ }),
25
+ UserUpdated: z.object({
26
+ age: z.int(),
27
+ }),
28
+ Test: z.function({
29
+ input: [z.number()],
30
+ output: z.string(),
31
+ }),
32
+ })
33
+ .withCommands((event) => ({
34
+ create(name: string) {
35
+ return event('UserCreated', { name })
36
+ },
37
+ update(age: number) {
38
+ return event('UserUpdated', { age })
39
+ },
40
+ }))
41
+ }
42
+
43
+ export function setupEventStore<const Root extends AnyAggregateRoot>(
44
+ aggregateRoot: Root | Root[],
45
+ options?: { preloadEvents?: Event[]; autoInit?: boolean; lock?: LockCreator },
46
+ ) {
47
+ const aggregateRoots = Array.isArray(aggregateRoot)
48
+ ? aggregateRoot
49
+ : [aggregateRoot]
50
+ const pseudoEventStore: Event[] = options?.preloadEvents
51
+ ? [...options.preloadEvents]
52
+ : []
53
+ const pseudoCheckpointStore = new Set<Checkpoint>()
54
+ const pseudoAggregateRootSnapshotStore = new Map<
55
+ string,
56
+ AggregateRootSnapshot
57
+ >()
58
+ const pseudoProjectionSnapshotStore = new Set<ProjectionSnapshot>()
59
+ const pseudoProjectionAppliedCount = new Map<string, number>()
60
+ const eventStore = createEventStore({
61
+ aggregateRoots,
62
+ lock: options?.lock,
63
+ autoInit: options?.autoInit,
64
+ appendEvents({ id, stream }, events, expectedVersion) {
65
+ const lastEvent = pseudoEventStore.findLast(
66
+ (e) => e.streamId === id && e.streamType === stream,
67
+ )
68
+
69
+ const currentVersion = lastEvent?.streamVersion ?? 0
70
+
71
+ if (currentVersion !== expectedVersion) {
72
+ throw new ConcurrencyError(
73
+ { id, stream },
74
+ expectedVersion,
75
+ currentVersion,
76
+ )
77
+ }
78
+
79
+ const extendedEvents: Event[] = events.map((event, index) => ({
80
+ ...event,
81
+ createdAt: new Date(),
82
+ position: pseudoEventStore.length + index,
83
+ streamId: id,
84
+ streamType: stream,
85
+ streamVersion: expectedVersion + index + 1,
86
+ }))
87
+
88
+ pseudoEventStore.push(...extendedEvents)
89
+ return extendedEvents
90
+ },
91
+ async *loadEvents(select, range) {
92
+ let streamTypes: string[] | null = null
93
+ let streamIds: string[] | null = null
94
+ let eventTypes: string[] | null = null
95
+ if (select) {
96
+ const selects = Array.isArray(select) ? select : [select]
97
+ streamTypes = selects.map((select) => select.stream)
98
+ streamIds = selects
99
+ .map((select) => (isStreamId(select) ? select.id : null))
100
+ .filter(Boolean) as string[]
101
+ eventTypes = selects.flatMap((select) =>
102
+ isStreamEvents(select) ? select.events : [],
103
+ )
104
+ }
105
+
106
+ const filteredEvents = pseudoEventStore.filter((event) => {
107
+ if (streamTypes?.length && !streamTypes.includes(event.streamType)) {
108
+ return false
109
+ }
110
+
111
+ if (streamIds?.length && !streamIds.includes(event.streamId)) {
112
+ return false
113
+ }
114
+
115
+ if (eventTypes?.length && !eventTypes.includes(event.type)) {
116
+ return false
117
+ }
118
+
119
+ if (range?.from && event.position < range.from) {
120
+ return false
121
+ }
122
+
123
+ if (range?.to && event.position > range.to) {
124
+ return false
125
+ }
126
+
127
+ return true
128
+ })
129
+
130
+ await setTimeout(10)
131
+
132
+ for (const event of filteredEvents) {
133
+ yield event
134
+ }
135
+ },
136
+
137
+ checkpoint: {
138
+ get(type, name) {
139
+ for (const checkpoint of pseudoCheckpointStore) {
140
+ if (checkpoint.type === type && checkpoint.name === name) {
141
+ return checkpoint
142
+ }
143
+ }
144
+ },
145
+ upsert(checkpoint) {
146
+ let persistedCheckpoint: Checkpoint | undefined
147
+ for (const c of pseudoCheckpointStore) {
148
+ if (c.type === checkpoint.type && c.name === checkpoint.name) {
149
+ persistedCheckpoint = c
150
+ break
151
+ }
152
+ }
153
+
154
+ if (persistedCheckpoint) {
155
+ persistedCheckpoint.lastEventPosition = checkpoint.lastEventPosition
156
+ } else {
157
+ pseudoCheckpointStore.add(checkpoint)
158
+ }
159
+ },
160
+ delete(type, name) {
161
+ for (const checkpoint of pseudoCheckpointStore) {
162
+ if (checkpoint.type === type && checkpoint.name === name) {
163
+ pseudoCheckpointStore.delete(checkpoint)
164
+ }
165
+ }
166
+ },
167
+ },
168
+ aggregateRootSnapshot: {
169
+ put(snapshot) {
170
+ pseudoAggregateRootSnapshotStore.set(snapshot.streamId, snapshot)
171
+ },
172
+ get(select) {
173
+ return pseudoAggregateRootSnapshotStore.get(select.id)
174
+ },
175
+ delete(target, fromPosition) {
176
+ for (const [key, value] of pseudoAggregateRootSnapshotStore) {
177
+ if (key === target.id && value.lastEventPosition > fromPosition) {
178
+ pseudoAggregateRootSnapshotStore.delete(key)
179
+ }
180
+ }
181
+ },
182
+ },
183
+ projectionSnapshot: {
184
+ put(snapshot) {
185
+ pseudoProjectionSnapshotStore.add(snapshot)
186
+ },
187
+ get(select) {
188
+ return Array.from(pseudoProjectionSnapshotStore)
189
+ .toReversed()
190
+ .find(
191
+ (snapshot) =>
192
+ snapshot.projectionId === select.id &&
193
+ snapshot.projectionType === select.projection,
194
+ )
195
+ },
196
+ delete(target, fromPosition) {
197
+ for (const snapshot of pseudoProjectionSnapshotStore) {
198
+ if (target.id && snapshot.projectionId !== target.id) {
199
+ continue
200
+ }
201
+
202
+ if (snapshot.projectionType !== target.projection) {
203
+ continue
204
+ }
205
+
206
+ if (fromPosition && snapshot.lastEventPosition < fromPosition) {
207
+ continue
208
+ }
209
+
210
+ pseudoProjectionSnapshotStore.delete(snapshot)
211
+ }
212
+ },
213
+ incrementAppliedCount(select) {
214
+ const key = `${select.projection}-${select.id}`
215
+ const nextCount = (pseudoProjectionAppliedCount.get(key) ?? 0) + 1
216
+ pseudoProjectionAppliedCount.set(key, nextCount)
217
+ return nextCount
218
+ },
219
+ },
220
+ })
221
+
222
+ return {
223
+ eventStore,
224
+ pseudoCheckpointStore,
225
+ pseudoEventStore,
226
+ pseudoAggregateRootSnapshotStore,
227
+ pseudoProjectionSnapshotStore,
228
+ pseudoProjectionAppliedCount,
229
+ }
230
+ }
@@ -0,0 +1,185 @@
1
+ import { setTimeout } from 'node:timers/promises'
2
+
3
+ import { describe, expect, it } from 'bun:test'
4
+ import { z } from 'zod/v4'
5
+
6
+ import { createAggregateRoot } from '../createAggregateRoot.ts'
7
+ import { createUserAggregate, setupEventStore } from './setup.ts'
8
+ import { CommandError } from '../errors.ts'
9
+
10
+ describe('transaction', () => {
11
+ const createAggregateRoots = () => {
12
+ const aggA = createAggregateRoot('A')
13
+ .withEvents({
14
+ ACreated: z.object({
15
+ name: z.string(),
16
+ }),
17
+ })
18
+ .withCommands((event) => ({
19
+ async create(name: string) {
20
+ await setTimeout(200)
21
+ return event('ACreated', { name })
22
+ },
23
+ }))
24
+
25
+ const aggB = createAggregateRoot('B')
26
+ .withEvents({
27
+ BCreated: z.object({
28
+ name: z.string(),
29
+ }),
30
+ })
31
+ .withCommands((event) => ({
32
+ create(name: string) {
33
+ return event('BCreated', { name })
34
+ },
35
+ async update() {
36
+ await setTimeout(100)
37
+ throw new Error('Cannot update')
38
+ },
39
+ }))
40
+
41
+ return { aggA, aggB }
42
+ }
43
+
44
+ it('can commit events', async () => {
45
+ const { aggA, aggB } = createAggregateRoots()
46
+ const { eventStore, pseudoEventStore } = setupEventStore([aggA, aggB])
47
+
48
+ let aCreated = 0
49
+ let bCreated = 0
50
+
51
+ eventStore.createProcessManager('test').withEventHandlers({
52
+ onACreated() {
53
+ aCreated += 1
54
+ },
55
+ onBCreated() {
56
+ bCreated += 1
57
+ },
58
+ })
59
+
60
+ await eventStore.transaction(() => {
61
+ aggA.newStream().create('valueA')
62
+ aggB.newStream().create('valueB')
63
+ })
64
+
65
+ expect(pseudoEventStore.length).toBe(2)
66
+ expect(aCreated).toBe(1)
67
+ expect(bCreated).toBe(1)
68
+ })
69
+
70
+ it('does not commit on error inside transaction block', async () => {
71
+ const { aggA, aggB } = createAggregateRoots()
72
+ const { eventStore, pseudoEventStore } = setupEventStore([aggA, aggB])
73
+
74
+ expect(
75
+ eventStore.transaction(() => {
76
+ aggA.newStream().create('valueA')
77
+ aggB.newStream().create('valueB')
78
+ throw new Error('stop')
79
+ }),
80
+ ).rejects.toThrow('stop')
81
+
82
+ expect(pseudoEventStore.length).toBe(0)
83
+ })
84
+
85
+ it('does not commit on error in command', async () => {
86
+ const { aggA, aggB } = createAggregateRoots()
87
+ const { eventStore, pseudoEventStore } = setupEventStore([aggA, aggB])
88
+
89
+ expect(
90
+ eventStore.transaction(() => {
91
+ aggA.newStream().create('valueA')
92
+ aggB.newStream().create('valueB').update()
93
+ }),
94
+ ).rejects.toThrow(CommandError)
95
+
96
+ expect(pseudoEventStore.length).toBe(0)
97
+ })
98
+
99
+ it('awaits multiple commands', async () => {
100
+ const userAggregate = createUserAggregate()
101
+ const { eventStore, pseudoEventStore } = setupEventStore(userAggregate)
102
+
103
+ await eventStore.transaction(async () => {
104
+ userAggregate.newStream().create('Torsten').update(40)
105
+ })
106
+
107
+ expect(pseudoEventStore.length).toBe(2)
108
+ })
109
+
110
+ it('keeps order of created events', async () => {
111
+ const aggA = createAggregateRoot('a')
112
+ .withEvents({
113
+ Created: z.literal('a'),
114
+ })
115
+ .withCommands((event) => ({
116
+ create: () => event('Created', 'a'),
117
+ }))
118
+
119
+ const aggB = createAggregateRoot('a')
120
+ .withEvents({
121
+ Created: z.literal('b'),
122
+ Updated: z.literal('b'),
123
+ })
124
+ .withCommands((event) => ({
125
+ create: () => event('Created', 'b'),
126
+ update: () => event('Updated', 'b'),
127
+ }))
128
+
129
+ const { eventStore, pseudoEventStore } = setupEventStore([aggA, aggB])
130
+
131
+ await eventStore.transaction(async () => {
132
+ aggA.newStream('a1').create()
133
+ aggB.newStream('b1').create().update()
134
+ aggA.newStream('a2').create()
135
+ })
136
+
137
+ expect(pseudoEventStore.at(0)).toMatchObject({
138
+ type: 'Created',
139
+ payload: 'a',
140
+ streamId: 'a1',
141
+ })
142
+ expect(pseudoEventStore.at(1)).toMatchObject({
143
+ type: 'Created',
144
+ payload: 'b',
145
+ streamId: 'b1',
146
+ })
147
+ expect(pseudoEventStore.at(2)).toMatchObject({
148
+ type: 'Updated',
149
+ payload: 'b',
150
+ streamId: 'b1',
151
+ })
152
+ expect(pseudoEventStore.at(3)).toMatchObject({
153
+ type: 'Created',
154
+ payload: 'a',
155
+ streamId: 'a2',
156
+ })
157
+ })
158
+
159
+ it('can access state inside transaction', async () => {
160
+ expect.assertions(1)
161
+
162
+ const aggregateRoot = createAggregateRoot('test')
163
+ .withInitialState({
164
+ created: false,
165
+ })
166
+ .withEvents({
167
+ Created: z.null(),
168
+ })
169
+ .withEventHandlers((state) => ({
170
+ onCreated() {
171
+ state.created = true
172
+ },
173
+ }))
174
+ .withCommands((_, event) => ({
175
+ create() {
176
+ return event('Created', null)
177
+ },
178
+ }))
179
+ const { eventStore } = setupEventStore(aggregateRoot)
180
+ await eventStore.transaction(async () => {
181
+ const state = await aggregateRoot.newStream().create().state()
182
+ expect(state.created).toBeTrue()
183
+ })
184
+ })
185
+ })
@@ -0,0 +1,93 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+
3
+ async function awaitAll(getPromises: () => Promise<any>[]) {
4
+ let promises = getPromises()
5
+
6
+ while (true) {
7
+ await Promise.all(promises)
8
+ const nextPromises = getPromises()
9
+ if (nextPromises.length > promises.length) {
10
+ promises = nextPromises
11
+ } else {
12
+ break
13
+ }
14
+ }
15
+ }
16
+
17
+ export type TransactionDelay = {
18
+ resolve: () => void
19
+ reject: (error: Error) => void
20
+ commit: () => Promise<void>
21
+ }
22
+
23
+ export default function createTransaction() {
24
+ const transactionStore = new AsyncLocalStorage<{
25
+ delayPromises: Promise<void>[]
26
+ commitPromises: Promise<void>[]
27
+ afterHandlers: Array<() => Promise<any>>
28
+ }>()
29
+
30
+ return {
31
+ get active() {
32
+ return Boolean(transactionStore.getStore())
33
+ },
34
+
35
+ delay(): TransactionDelay {
36
+ const store = transactionStore.getStore()
37
+ if (!store) {
38
+ return {
39
+ resolve: () => {},
40
+ reject: () => {},
41
+ commit: () => Promise.resolve(),
42
+ }
43
+ }
44
+
45
+ const idx = store.commitPromises.length
46
+ const { resolve, reject, promise } = Promise.withResolvers<void>()
47
+ store.delayPromises.push(promise)
48
+
49
+ const { promise: commitPromise, resolve: resolveCommitPromise } =
50
+ Promise.withResolvers<void>()
51
+ store.commitPromises.push(commitPromise)
52
+
53
+ return {
54
+ resolve,
55
+ reject,
56
+ async commit() {
57
+ if (idx > 0) {
58
+ await store.commitPromises.at(idx - 1)
59
+ }
60
+
61
+ resolveCommitPromise()
62
+ await awaitAll(() => store.delayPromises)
63
+ },
64
+ }
65
+ },
66
+
67
+ after(onAfter: () => Promise<any>) {
68
+ transactionStore.getStore()?.afterHandlers.push(onAfter)
69
+ },
70
+
71
+ async run<T extends () => any>(handler: T) {
72
+ const { resolve, reject, promise } = Promise.withResolvers<void>()
73
+ const delayPromises = [promise]
74
+ const afterHandlers: Array<() => Promise<any>> = []
75
+ let result
76
+ try {
77
+ result = await transactionStore.run(
78
+ { delayPromises, afterHandlers, commitPromises: [] },
79
+ handler,
80
+ )
81
+ resolve()
82
+ } catch (error) {
83
+ reject(error)
84
+ }
85
+ await awaitAll(() => delayPromises)
86
+ await Promise.all(afterHandlers.map((handler) => handler()))
87
+
88
+ return result
89
+ },
90
+ }
91
+ }
92
+
93
+ export type Transaction = ReturnType<typeof createTransaction>