@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.
- package/LICENSE +21 -0
- package/dist/chunk-24xjphpk.js +51 -0
- package/dist/chunk-24xjphpk.js.map +10 -0
- package/dist/chunk-3t1vzbab.js +88 -0
- package/dist/chunk-3t1vzbab.js.map +10 -0
- package/dist/chunk-6q8mr1sg.js +13 -0
- package/dist/chunk-6q8mr1sg.js.map +10 -0
- package/dist/chunk-9psg50cb.js +22 -0
- package/dist/chunk-9psg50cb.js.map +10 -0
- package/dist/chunk-dgzw1nj9.js +50 -0
- package/dist/chunk-dgzw1nj9.js.map +10 -0
- package/dist/chunk-gq80tgvf.js +70 -0
- package/dist/chunk-gq80tgvf.js.map +10 -0
- package/dist/chunk-jd4q8fcf.js +11 -0
- package/dist/chunk-jd4q8fcf.js.map +10 -0
- package/dist/chunk-m07vf8q9.js +210 -0
- package/dist/chunk-m07vf8q9.js.map +10 -0
- package/dist/chunk-ncpk70p4.js +313 -0
- package/dist/chunk-ncpk70p4.js.map +10 -0
- package/dist/chunk-pmfzjavb.js +49 -0
- package/dist/chunk-pmfzjavb.js.map +10 -0
- package/dist/chunk-qdczt68t.js +84 -0
- package/dist/chunk-qdczt68t.js.map +10 -0
- package/dist/chunk-tfy01fah.js +34 -0
- package/dist/chunk-tfy01fah.js.map +10 -0
- package/dist/chunk-tkvj6x8k.js +389 -0
- package/dist/chunk-tkvj6x8k.js.map +10 -0
- package/dist/chunk-ty9bhdvx.js +53 -0
- package/dist/chunk-ty9bhdvx.js.map +10 -0
- package/dist/chunk-wmz6a1jk.js +233 -0
- package/dist/chunk-wmz6a1jk.js.map +10 -0
- package/dist/chunk-x90g388y.js +22 -0
- package/dist/chunk-x90g388y.js.map +10 -0
- package/dist/chunk-z388cd25.js +91 -0
- package/dist/chunk-z388cd25.js.map +10 -0
- package/dist/chunk-zf3x573k.js +65 -0
- package/dist/chunk-zf3x573k.js.map +10 -0
- package/dist/createAggregateRoot.d.ts +191 -0
- package/dist/createAggregateRoot.d.ts.map +1 -0
- package/dist/createAggregateRoot.js +18 -0
- package/dist/createAggregateRoot.js.map +9 -0
- package/dist/createCheckpointApi.d.ts +38 -0
- package/dist/createCheckpointApi.d.ts.map +1 -0
- package/dist/createCheckpointApi.js +9 -0
- package/dist/createCheckpointApi.js.map +9 -0
- package/dist/createEventListener.d.ts +46 -0
- package/dist/createEventListener.d.ts.map +1 -0
- package/dist/createEventListener.js +12 -0
- package/dist/createEventListener.js.map +9 -0
- package/dist/createEventStore.d.ts +57 -0
- package/dist/createEventStore.d.ts.map +1 -0
- package/dist/createEventStore.js +20 -0
- package/dist/createEventStore.js.map +9 -0
- package/dist/createProcessManager.d.ts +70 -0
- package/dist/createProcessManager.d.ts.map +1 -0
- package/dist/createProcessManager.js +16 -0
- package/dist/createProcessManager.js.map +9 -0
- package/dist/createProjection.d.ts +120 -0
- package/dist/createProjection.d.ts.map +1 -0
- package/dist/createProjection.js +16 -0
- package/dist/createProjection.js.map +9 -0
- package/dist/drizzle/index.d.ts +449 -0
- package/dist/drizzle/index.d.ts.map +1 -0
- package/dist/drizzle/index.js +325 -0
- package/dist/drizzle/index.js.map +10 -0
- package/dist/drizzle/postgres.d.ts +686 -0
- package/dist/drizzle/postgres.d.ts.map +1 -0
- package/dist/drizzle/postgres.js +18 -0
- package/dist/drizzle/postgres.js.map +9 -0
- package/dist/errors.d.ts +53 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +9 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +73 -0
- package/dist/index.js.map +9 -0
- package/dist/lock.d.ts +6 -0
- package/dist/lock.d.ts.map +1 -0
- package/dist/lock.js +8 -0
- package/dist/lock.js.map +9 -0
- package/dist/memory/index.d.ts +15 -0
- package/dist/memory/index.d.ts.map +1 -0
- package/dist/memory/index.js +178 -0
- package/dist/memory/index.js.map +10 -0
- package/dist/redis/index.d.ts +2 -0
- package/dist/redis/index.d.ts.map +1 -0
- package/dist/redis/index.js +8 -0
- package/dist/redis/index.js.map +9 -0
- package/dist/redis/redisLock.d.ts +4 -0
- package/dist/redis/redisLock.d.ts.map +1 -0
- package/dist/redis/redisLock.js +8 -0
- package/dist/redis/redisLock.js.map +9 -0
- package/dist/refresh.d.ts +4 -0
- package/dist/refresh.d.ts.map +1 -0
- package/dist/refresh.js +13 -0
- package/dist/refresh.js.map +9 -0
- package/dist/replay.d.ts +4 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +13 -0
- package/dist/replay.js.map +9 -0
- package/dist/snapshotGenerator.d.ts +79 -0
- package/dist/snapshotGenerator.d.ts.map +1 -0
- package/dist/snapshotGenerator.js +9 -0
- package/dist/snapshotGenerator.js.map +9 -0
- package/dist/superjson.d.ts +5 -0
- package/dist/superjson.d.ts.map +1 -0
- package/dist/superjson.js +12 -0
- package/dist/superjson.js.map +9 -0
- package/dist/transaction.d.ts +13 -0
- package/dist/transaction.d.ts.map +1 -0
- package/dist/transaction.js +8 -0
- package/dist/transaction.js.map +9 -0
- package/dist/utilityTypes.d.js +2 -0
- package/dist/utilityTypes.d.js.map +9 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +10 -0
- package/dist/utils.js.map +9 -0
- package/dist/wrappedEventBuilder.d.ts +37 -0
- package/dist/wrappedEventBuilder.d.ts.map +1 -0
- package/dist/wrappedEventBuilder.js +12 -0
- package/dist/wrappedEventBuilder.js.map +9 -0
- package/package.json +85 -1
- package/src/createAggregateRoot.ts +902 -0
- package/src/createCheckpointApi.ts +106 -0
- package/src/createEventListener.ts +118 -0
- package/src/createEventStore.ts +446 -0
- package/src/createProcessManager.ts +363 -0
- package/src/createProjection.ts +577 -0
- package/src/drizzle/index.ts +449 -0
- package/src/drizzle/postgres.ts +120 -0
- package/src/errors.ts +85 -0
- package/src/index.ts +45 -0
- package/src/lock.ts +58 -0
- package/src/memory/index.ts +224 -0
- package/src/redis/index.ts +1 -0
- package/src/redis/redisLock.ts +61 -0
- package/src/refresh.ts +19 -0
- package/src/replay.ts +19 -0
- package/src/snapshotGenerator.ts +190 -0
- package/src/superjson.ts +11 -0
- package/src/tests/aggregateRoot.spec.ts +532 -0
- package/src/tests/eventListener.spec.ts +57 -0
- package/src/tests/eventStore.spec.ts +86 -0
- package/src/tests/processManager.spec.ts +185 -0
- package/src/tests/projection.spec.ts +511 -0
- package/src/tests/projectionProcessManager.spec.ts +203 -0
- package/src/tests/setup.ts +230 -0
- package/src/tests/transaction.spec.ts +185 -0
- package/src/transaction.ts +93 -0
- package/src/utilityTypes.d.ts +110 -0
- package/src/utils.ts +43 -0
- 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>
|