@requence/event-sourcing 1.4.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/dist/{chunk-yn2sjcg3.js → chunk-3251p253.js} +50 -1
- package/dist/chunk-3251p253.js.map +10 -0
- package/dist/{chunk-vep2byx4.js → chunk-7akf01zj.js} +11 -11
- package/dist/{chunk-vep2byx4.js.map → chunk-7akf01zj.js.map} +3 -3
- package/dist/{chunk-57b88rt5.js → chunk-8edc30gz.js} +11 -11
- package/dist/{chunk-57b88rt5.js.map → chunk-8edc30gz.js.map} +1 -1
- package/dist/chunk-ak901ckt.js +67 -0
- package/dist/chunk-ak901ckt.js.map +10 -0
- package/dist/{chunk-vvv14qxy.js → chunk-kqh3q3fj.js} +10 -8
- package/dist/{chunk-vvv14qxy.js.map → chunk-kqh3q3fj.js.map} +3 -3
- package/dist/{chunk-3ejt4h7x.js → chunk-zznvzvnv.js} +58 -46
- package/dist/chunk-zznvzvnv.js.map +10 -0
- package/dist/createAggregateRoot.d.ts.map +1 -1
- package/dist/createAggregateRoot.js +7 -7
- package/dist/createCheckpointApi.d.ts +30 -0
- package/dist/createCheckpointApi.d.ts.map +1 -1
- package/dist/createCheckpointApi.js +1 -1
- package/dist/createEventStore.js +8 -8
- package/dist/createProcessManager.d.ts.map +1 -1
- package/dist/createProcessManager.js +3 -3
- package/dist/createProjection.d.ts.map +1 -1
- package/dist/createProjection.js +2 -2
- package/dist/drizzle/index.js +14 -14
- package/dist/drizzle/index.js.map +1 -1
- package/dist/index.js +18 -18
- package/dist/index.js.map +1 -1
- package/dist/memory/index.d.ts +14 -3
- package/dist/memory/index.d.ts.map +1 -1
- package/dist/memory/index.js +37 -30
- package/dist/memory/index.js.map +3 -3
- package/dist/memory/index.spec.d.ts +2 -0
- package/dist/memory/index.spec.d.ts.map +1 -0
- package/dist/surreal/index.d.ts +35 -0
- package/dist/surreal/index.d.ts.map +1 -0
- package/dist/surreal/index.js +415 -0
- package/dist/surreal/index.js.map +10 -0
- package/dist/surreal/index.spec.d.ts +2 -0
- package/dist/surreal/index.spec.d.ts.map +1 -0
- package/dist/surreal/schema.d.ts +12 -0
- package/dist/surreal/schema.d.ts.map +1 -0
- package/dist/surreal/schema.js +12 -0
- package/dist/surreal/schema.js.map +9 -0
- package/dist/tests/projectionMultiInstance.spec.d.ts +2 -0
- package/dist/tests/projectionMultiInstance.spec.d.ts.map +1 -0
- package/dist/tests/storageDriverContract.d.ts +19 -0
- package/dist/tests/storageDriverContract.d.ts.map +1 -0
- package/package.json +19 -10
- package/src/createAggregateRoot.ts +2 -3
- package/src/createCheckpointApi.ts +98 -0
- package/src/createProcessManager.ts +7 -6
- package/src/createProjection.ts +91 -65
- package/src/memory/index.spec.ts +4 -0
- package/src/memory/index.ts +55 -29
- package/src/surreal/index.spec.ts +152 -0
- package/src/surreal/index.ts +604 -0
- package/src/surreal/schema.ts +68 -0
- package/src/tests/aggregateRoot.spec.ts +6 -3
- package/src/tests/eventStore.spec.ts +1 -3
- package/src/tests/processManagerMultiInstance.spec.ts +18 -7
- package/src/tests/projectionMultiInstance.spec.ts +283 -0
- package/src/tests/storageDriverContract.ts +612 -0
- package/dist/chunk-3ejt4h7x.js.map +0 -10
- package/dist/chunk-yn2sjcg3.js.map +0 -10
|
@@ -18,6 +18,14 @@ const checkpointSchema = z.object({
|
|
|
18
18
|
|
|
19
19
|
export type Checkpoint = z.output<typeof checkpointSchema>
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The outcome of {@link CheckpointApi.claim}: either this instance owns the
|
|
23
|
+
* event and must run its work, or another instance already has it.
|
|
24
|
+
*/
|
|
25
|
+
export type ClaimResult =
|
|
26
|
+
| { claimed: false }
|
|
27
|
+
| { claimed: true; release: () => Promise<void> }
|
|
28
|
+
|
|
21
29
|
export type CheckpointMethods = {
|
|
22
30
|
get: (
|
|
23
31
|
type: 'projection' | 'processManager',
|
|
@@ -57,12 +65,16 @@ export function createCheckpointApi(
|
|
|
57
65
|
// every load/upsert so optimistic-concurrency writes can detect when another
|
|
58
66
|
// instance has advanced the same checkpoint.
|
|
59
67
|
let version: number | null = null
|
|
68
|
+
// The position that goes with `version`. Only {@link CheckpointApi.claim}
|
|
69
|
+
// needs it, to put the cursor back if the work it claimed then fails.
|
|
70
|
+
let position: number | null = null
|
|
60
71
|
|
|
61
72
|
const api = {
|
|
62
73
|
relatedStreamEvents: params.relatedStreamEvents,
|
|
63
74
|
async clear() {
|
|
64
75
|
await params.delete(params.type, params.name)
|
|
65
76
|
version = null
|
|
77
|
+
position = null
|
|
66
78
|
},
|
|
67
79
|
/**
|
|
68
80
|
* Load the persisted checkpoint and sync the local version. Returns the
|
|
@@ -72,8 +84,93 @@ export function createCheckpointApi(
|
|
|
72
84
|
async load() {
|
|
73
85
|
const checkpoint = await params.get(params.type, params.name)
|
|
74
86
|
version = checkpoint?.version ?? null
|
|
87
|
+
position = checkpoint?.lastEventPosition ?? null
|
|
75
88
|
return checkpoint ?? null
|
|
76
89
|
},
|
|
90
|
+
/**
|
|
91
|
+
* Takes ownership of `event` *before* its work runs, so that exactly one
|
|
92
|
+
* instance does that work.
|
|
93
|
+
*
|
|
94
|
+
* Every instance runs its own copy of a projection against shared storage,
|
|
95
|
+
* and each decides what it has already applied from a cursor only it can
|
|
96
|
+
* see. Two instances catching up over the same backlog — or one catching up
|
|
97
|
+
* while the other applies an event it just appended — therefore both reach
|
|
98
|
+
* the same handler. Writing the cursor first, through the same
|
|
99
|
+
* compare-and-swap the ordinary write uses, is what breaks the tie: the
|
|
100
|
+
* loser is told so and skips the event instead of repeating its side
|
|
101
|
+
* effects.
|
|
102
|
+
*
|
|
103
|
+
* Returns `{ claimed: false }` when another writer already covers this
|
|
104
|
+
* position. On success the caller gets a `release` to call if the work then
|
|
105
|
+
* fails, which puts the cursor back so the event is picked up again rather
|
|
106
|
+
* than silently skipped — without it, claiming would turn every failed
|
|
107
|
+
* handler into a lost event.
|
|
108
|
+
*/
|
|
109
|
+
async claim(
|
|
110
|
+
event: BaseOutputEvent,
|
|
111
|
+
metadata: Record<string, any> = {},
|
|
112
|
+
): Promise<ClaimResult> {
|
|
113
|
+
while (true) {
|
|
114
|
+
const expectedVersion = version
|
|
115
|
+
const previousPosition = position
|
|
116
|
+
const nextVersion = (expectedVersion ?? -1) + 1
|
|
117
|
+
|
|
118
|
+
const committed = await params.upsert(
|
|
119
|
+
{
|
|
120
|
+
lastEventPosition: event.position,
|
|
121
|
+
type: params.type,
|
|
122
|
+
name: params.name,
|
|
123
|
+
metadata,
|
|
124
|
+
version: nextVersion,
|
|
125
|
+
},
|
|
126
|
+
expectedVersion,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if (committed) {
|
|
130
|
+
version = nextVersion
|
|
131
|
+
position = event.position
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
claimed: true,
|
|
135
|
+
async release() {
|
|
136
|
+
// Only undo a claim that is still the newest write. Once anything
|
|
137
|
+
// else has moved the cursor, rewinding it would drop that work.
|
|
138
|
+
if (version !== nextVersion) {
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (previousPosition === null) {
|
|
143
|
+
await api.clear()
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const restored = await params.upsert(
|
|
148
|
+
{
|
|
149
|
+
lastEventPosition: previousPosition,
|
|
150
|
+
type: params.type,
|
|
151
|
+
name: params.name,
|
|
152
|
+
metadata,
|
|
153
|
+
version: nextVersion + 1,
|
|
154
|
+
},
|
|
155
|
+
nextVersion,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if (restored) {
|
|
159
|
+
version = nextVersion + 1
|
|
160
|
+
position = previousPosition
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Another writer advanced this checkpoint. Refresh our view of it.
|
|
167
|
+
const current = await api.load()
|
|
168
|
+
|
|
169
|
+
if (current && current.lastEventPosition >= event.position) {
|
|
170
|
+
return { claimed: false }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
},
|
|
77
174
|
/**
|
|
78
175
|
* Persist the checkpoint position (and optional metadata) for the given
|
|
79
176
|
* event.
|
|
@@ -108,6 +205,7 @@ export function createCheckpointApi(
|
|
|
108
205
|
|
|
109
206
|
if (committed) {
|
|
110
207
|
version = nextVersion
|
|
208
|
+
position = event.position
|
|
111
209
|
return
|
|
112
210
|
}
|
|
113
211
|
|
|
@@ -293,7 +293,9 @@ export function buildProcessManagerCreator(params: {
|
|
|
293
293
|
await hydrated
|
|
294
294
|
},
|
|
295
295
|
async beginSession() {
|
|
296
|
-
if (!hasState)
|
|
296
|
+
if (!hasState) {
|
|
297
|
+
return
|
|
298
|
+
}
|
|
297
299
|
// Load through the checkpoint API so the local version is synced with
|
|
298
300
|
// the store; the subsequent fold relies on it for compare-and-swap.
|
|
299
301
|
const checkpoint = await checkpointApi.load()
|
|
@@ -310,7 +312,9 @@ export function buildProcessManagerCreator(params: {
|
|
|
310
312
|
sessionActive = true
|
|
311
313
|
},
|
|
312
314
|
endSession() {
|
|
313
|
-
if (!hasState)
|
|
315
|
+
if (!hasState) {
|
|
316
|
+
return
|
|
317
|
+
}
|
|
314
318
|
state = null
|
|
315
319
|
sessionActive = false
|
|
316
320
|
},
|
|
@@ -379,10 +383,7 @@ export function buildProcessManagerCreator(params: {
|
|
|
379
383
|
async state() {
|
|
380
384
|
await hydrated
|
|
381
385
|
if (!sessionActive && hasState) {
|
|
382
|
-
const checkpoint = await params.checkpoint.get(
|
|
383
|
-
'processManager',
|
|
384
|
-
name,
|
|
385
|
-
)
|
|
386
|
+
const checkpoint = await params.checkpoint.get('processManager', name)
|
|
386
387
|
return checkpoint?.metadata?.state ?? clone(initialState)
|
|
387
388
|
}
|
|
388
389
|
return state
|
package/src/createProjection.ts
CHANGED
|
@@ -266,86 +266,112 @@ export function buildProjectionCreator(params: {
|
|
|
266
266
|
|
|
267
267
|
const runner = (async () => {
|
|
268
268
|
await unlocked
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
269
|
+
|
|
270
|
+
// Take ownership of the event before anything observable happens, so a
|
|
271
|
+
// peer applying the same event does not apply it twice. `claimed` is
|
|
272
|
+
// null while replaying: a replay deliberately re-applies everything and
|
|
273
|
+
// `beginReplay` leaves the stored cursor where it was, so a claim would
|
|
274
|
+
// read that not-yet-rewound position and skip the whole run.
|
|
275
|
+
const claimed = isReplaying() ? null : await checkpointApi.claim(event)
|
|
276
|
+
|
|
277
|
+
if (claimed && !claimed.claimed) {
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const release = claimed?.claimed ? claimed.release : null
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
await withProjectionInfo({ name, event }, async () => {
|
|
285
|
+
try {
|
|
286
|
+
await handler?.(event, {
|
|
287
|
+
replaying,
|
|
288
|
+
async replayUntil(position, filterEvents) {
|
|
289
|
+
if (position >= event.position) {
|
|
290
|
+
throw new ProjectionReplayLoopError(
|
|
291
|
+
`Event handler on${event.type} of projection ${name} tried to replay to the future`,
|
|
292
|
+
{ name, eventHandler: `on${event.type}` },
|
|
293
|
+
event,
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
locks.delete(event.streamId)
|
|
298
|
+
return api.replayUntil(event.streamId, position, filterEvents)
|
|
299
|
+
},
|
|
300
|
+
})
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (error instanceof ReplaySkipError) {
|
|
303
|
+
if (isReplaying()) {
|
|
304
|
+
return
|
|
280
305
|
}
|
|
281
306
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
})
|
|
286
|
-
} catch (error) {
|
|
287
|
-
if (error instanceof ReplaySkipError) {
|
|
288
|
-
if (isReplaying()) {
|
|
289
|
-
return
|
|
307
|
+
throw new Error(
|
|
308
|
+
'ReplaySkipError should never be thrown outside replay',
|
|
309
|
+
)
|
|
290
310
|
}
|
|
291
311
|
|
|
292
|
-
|
|
293
|
-
|
|
312
|
+
if (
|
|
313
|
+
!(error instanceof Error) ||
|
|
314
|
+
error instanceof ProjectionReplayLoopError
|
|
315
|
+
) {
|
|
316
|
+
throw error
|
|
317
|
+
}
|
|
318
|
+
throw new ProjectionEventHandlerExecutionError(
|
|
319
|
+
name,
|
|
320
|
+
`on${event.type}`,
|
|
321
|
+
event,
|
|
322
|
+
error,
|
|
294
323
|
)
|
|
295
324
|
}
|
|
325
|
+
try {
|
|
326
|
+
await globalEventHandler?.(event)
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (!(error instanceof Error)) {
|
|
329
|
+
throw error
|
|
330
|
+
}
|
|
296
331
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
throw new ProjectionEventHandlerExecutionError(
|
|
304
|
-
name,
|
|
305
|
-
`on${event.type}`,
|
|
306
|
-
event,
|
|
307
|
-
error,
|
|
308
|
-
)
|
|
309
|
-
}
|
|
310
|
-
try {
|
|
311
|
-
await globalEventHandler?.(event)
|
|
312
|
-
} catch (error) {
|
|
313
|
-
if (!(error instanceof Error)) {
|
|
314
|
-
throw error
|
|
332
|
+
throw new ProjectionEventHandlerExecutionError(
|
|
333
|
+
name,
|
|
334
|
+
'GLOBAL',
|
|
335
|
+
event,
|
|
336
|
+
error,
|
|
337
|
+
)
|
|
315
338
|
}
|
|
339
|
+
})
|
|
316
340
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
341
|
+
if (params.snapshot && snapshotting) {
|
|
342
|
+
const appliedEvents = await params.snapshot.incrementAppliedCount({
|
|
343
|
+
projection: name,
|
|
344
|
+
id: event.streamId,
|
|
345
|
+
})
|
|
346
|
+
const snapshot = await snapshotting.internalCapture(
|
|
320
347
|
event,
|
|
321
|
-
|
|
348
|
+
appliedEvents,
|
|
322
349
|
)
|
|
323
|
-
}
|
|
324
|
-
})
|
|
325
|
-
|
|
326
|
-
if (params.snapshot && snapshotting) {
|
|
327
|
-
const appliedEvents = await params.snapshot.incrementAppliedCount({
|
|
328
|
-
projection: name,
|
|
329
|
-
id: event.streamId,
|
|
330
|
-
})
|
|
331
|
-
const snapshot = await snapshotting.internalCapture(
|
|
332
|
-
event,
|
|
333
|
-
appliedEvents,
|
|
334
|
-
)
|
|
335
350
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
351
|
+
if (snapshot) {
|
|
352
|
+
await params.snapshot.put({
|
|
353
|
+
projectionType: name,
|
|
354
|
+
projectionId: event.streamId,
|
|
355
|
+
lastEventPosition: event.position,
|
|
356
|
+
state: snapshot.state,
|
|
357
|
+
schemaVersion: snapshot.version,
|
|
358
|
+
createdAt: new Date(),
|
|
359
|
+
})
|
|
360
|
+
}
|
|
345
361
|
}
|
|
362
|
+
} catch (error) {
|
|
363
|
+
// The cursor was moved before this work ran, so it has to go back or
|
|
364
|
+
// the event would never be retried — a claim must not turn a failing
|
|
365
|
+
// handler into a silently skipped one.
|
|
366
|
+
await release?.()
|
|
367
|
+
throw error
|
|
346
368
|
}
|
|
347
369
|
|
|
348
|
-
|
|
370
|
+
// A claim already wrote the cursor. A replay did not take one, so it
|
|
371
|
+
// still advances the cursor the ordinary way.
|
|
372
|
+
if (!release) {
|
|
373
|
+
await checkpointApi.upsert(event)
|
|
374
|
+
}
|
|
349
375
|
})()
|
|
350
376
|
|
|
351
377
|
if (exclusive) {
|
package/src/memory/index.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
AggregateRootSnapshotMethods,
|
|
3
|
+
AnyAggregateRoot,
|
|
4
|
+
} from '../createAggregateRoot.ts'
|
|
5
|
+
import type { Checkpoint, CheckpointMethods } from '../createCheckpointApi.ts'
|
|
6
|
+
import type { ProjectionSnapshotMethods } from '../createProjection.ts'
|
|
3
7
|
import {
|
|
4
8
|
type AggregateRootSnapshot,
|
|
5
9
|
ConcurrencyError,
|
|
@@ -13,16 +17,20 @@ import type {
|
|
|
13
17
|
EventStore,
|
|
14
18
|
EventStoreParamsWithAggregateRootSnapshots,
|
|
15
19
|
EventsFromRoot,
|
|
20
|
+
LoadEvents,
|
|
16
21
|
OnProgress,
|
|
17
22
|
} from '../createEventStore.ts'
|
|
18
23
|
import type { LockCreator } from '../lock.ts'
|
|
19
|
-
import type {
|
|
24
|
+
import type {
|
|
25
|
+
BaseInputEvent,
|
|
26
|
+
BaseOutputEvent,
|
|
27
|
+
MaybePromise,
|
|
28
|
+
StreamId,
|
|
29
|
+
} from '../utilityTypes.js'
|
|
20
30
|
|
|
21
31
|
export { createAggregateRoot } from '../index.ts'
|
|
22
32
|
|
|
23
|
-
export type OnEventsAppended = (
|
|
24
|
-
events: BaseOutputEvent[],
|
|
25
|
-
) => MaybePromise<void>
|
|
33
|
+
export type OnEventsAppended = (events: BaseOutputEvent[]) => MaybePromise<void>
|
|
26
34
|
|
|
27
35
|
interface MemoryEventStoreParams<Root extends AnyAggregateRoot> {
|
|
28
36
|
aggregateRoots: Root[]
|
|
@@ -34,33 +42,26 @@ interface MemoryEventStoreParams<Root extends AnyAggregateRoot> {
|
|
|
34
42
|
onEventsAppended?: OnEventsAppended
|
|
35
43
|
}
|
|
36
44
|
|
|
37
|
-
export function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
export function createMemoryStorage(params?: {
|
|
46
|
+
onEventsAppended?: OnEventsAppended
|
|
47
|
+
}): {
|
|
48
|
+
loadEvents: LoadEvents
|
|
49
|
+
appendEvents: (
|
|
50
|
+
streamId: StreamId,
|
|
51
|
+
events: BaseInputEvent[],
|
|
52
|
+
expectedVersion: number,
|
|
53
|
+
) => BaseOutputEvent[]
|
|
54
|
+
checkpoint: CheckpointMethods
|
|
55
|
+
projectionSnapshot: ProjectionSnapshotMethods
|
|
56
|
+
aggregateRootSnapshot: AggregateRootSnapshotMethods
|
|
57
|
+
} {
|
|
50
58
|
const events: Event[] = []
|
|
51
59
|
const checkpoints = new Set<Checkpoint>()
|
|
52
60
|
const aggregateRootSnapshots = new Map<string, AggregateRootSnapshot>()
|
|
53
61
|
const projectionSnapshots = new Set<ProjectionSnapshot>()
|
|
54
62
|
const projectionAppliedCount = new Map<string, number>()
|
|
55
63
|
|
|
56
|
-
return
|
|
57
|
-
aggregateRoots,
|
|
58
|
-
postProcessEvent,
|
|
59
|
-
onProjectionReplay,
|
|
60
|
-
onProcessManagerRefresh,
|
|
61
|
-
autoInit,
|
|
62
|
-
lock,
|
|
63
|
-
|
|
64
|
+
return {
|
|
64
65
|
async *loadEvents(select, range) {
|
|
65
66
|
let streamTypes: string[] | null = null
|
|
66
67
|
let streamIds: string[] | null = null
|
|
@@ -131,7 +132,7 @@ export function createEventStore<const Root extends AnyAggregateRoot>({
|
|
|
131
132
|
}))
|
|
132
133
|
|
|
133
134
|
events.push(...extendedEvents)
|
|
134
|
-
onEventsAppended?.(extendedEvents)
|
|
135
|
+
params?.onEventsAppended?.(extendedEvents)
|
|
135
136
|
return extendedEvents
|
|
136
137
|
},
|
|
137
138
|
|
|
@@ -155,7 +156,10 @@ export function createEventStore<const Root extends AnyAggregateRoot>({
|
|
|
155
156
|
if (persisted) {
|
|
156
157
|
// Compare-and-swap: reject the write when another writer advanced the
|
|
157
158
|
// checkpoint, or when the caller expected to create a fresh one.
|
|
158
|
-
if (
|
|
159
|
+
if (
|
|
160
|
+
expectedVersion === null ||
|
|
161
|
+
persisted.version !== expectedVersion
|
|
162
|
+
) {
|
|
159
163
|
return false
|
|
160
164
|
}
|
|
161
165
|
persisted.lastEventPosition = checkpoint.lastEventPosition
|
|
@@ -232,5 +236,27 @@ export function createEventStore<const Root extends AnyAggregateRoot>({
|
|
|
232
236
|
}
|
|
233
237
|
},
|
|
234
238
|
},
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function createEventStore<const Root extends AnyAggregateRoot>({
|
|
243
|
+
aggregateRoots,
|
|
244
|
+
postProcessEvent,
|
|
245
|
+
onProjectionReplay,
|
|
246
|
+
onProcessManagerRefresh,
|
|
247
|
+
onEventsAppended,
|
|
248
|
+
autoInit,
|
|
249
|
+
lock,
|
|
250
|
+
}: MemoryEventStoreParams<Root>): EventStore<EventsFromRoot<Root>, Root, true> {
|
|
251
|
+
const storage = createMemoryStorage({ onEventsAppended })
|
|
252
|
+
|
|
253
|
+
return createBaseEventStore({
|
|
254
|
+
aggregateRoots,
|
|
255
|
+
postProcessEvent,
|
|
256
|
+
onProjectionReplay,
|
|
257
|
+
onProcessManagerRefresh,
|
|
258
|
+
autoInit,
|
|
259
|
+
lock,
|
|
260
|
+
...storage,
|
|
235
261
|
}) as any
|
|
236
262
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { afterAll, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { createNodeEngines } from '@surrealdb/node'
|
|
3
|
+
import { Surreal } from 'surrealdb'
|
|
4
|
+
|
|
5
|
+
import { ConcurrencyError } from '../errors.ts'
|
|
6
|
+
import { runStorageDriverContract } from '../tests/storageDriverContract.ts'
|
|
7
|
+
import { createSurrealStorage, initSchema } from './index.ts'
|
|
8
|
+
|
|
9
|
+
// One shared embedded engine for the whole file; isolation comes from a
|
|
10
|
+
// fresh logical database per test (tests run sequentially).
|
|
11
|
+
let connection: Surreal | undefined
|
|
12
|
+
|
|
13
|
+
async function makeDatabase() {
|
|
14
|
+
if (!connection) {
|
|
15
|
+
connection = new Surreal({ engines: { ...createNodeEngines() } })
|
|
16
|
+
await connection.connect('mem://')
|
|
17
|
+
}
|
|
18
|
+
await connection.use({ namespace: 'test', database: crypto.randomUUID() })
|
|
19
|
+
return connection
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
afterAll(async () => {
|
|
23
|
+
await connection?.close()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
runStorageDriverContract('surreal', async () => {
|
|
27
|
+
const database = await makeDatabase()
|
|
28
|
+
return createSurrealStorage({ database })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('surreal driver specifics', () => {
|
|
32
|
+
it('initializes the schema idempotently', async () => {
|
|
33
|
+
const database = await makeDatabase()
|
|
34
|
+
await initSchema(database)
|
|
35
|
+
await initSchema(database)
|
|
36
|
+
|
|
37
|
+
const storage = createSurrealStorage({ database })
|
|
38
|
+
const persisted = await storage.appendEvents(
|
|
39
|
+
{ stream: 'order', id: 'o1' },
|
|
40
|
+
[{ type: 'created', payload: {}, metadata: {}, schemaVersion: 1 }],
|
|
41
|
+
0,
|
|
42
|
+
)
|
|
43
|
+
expect(persisted[0].position).toBe(1)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('skips schema creation when initSchema is disabled', async () => {
|
|
47
|
+
const database = await makeDatabase()
|
|
48
|
+
const storage = createSurrealStorage({ database, initSchema: false })
|
|
49
|
+
|
|
50
|
+
let error: unknown
|
|
51
|
+
try {
|
|
52
|
+
await storage.appendEvents(
|
|
53
|
+
{ stream: 'order', id: 'o1' },
|
|
54
|
+
[{ type: 'created', payload: {}, metadata: {}, schemaVersion: 1 }],
|
|
55
|
+
0,
|
|
56
|
+
)
|
|
57
|
+
} catch (err) {
|
|
58
|
+
error = err
|
|
59
|
+
}
|
|
60
|
+
// Without the SCHEMAFULL tables the counter arithmetic cannot resolve.
|
|
61
|
+
expect(error).toBeTruthy()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('assigns gapless, monotonically increasing global positions', async () => {
|
|
65
|
+
const database = await makeDatabase()
|
|
66
|
+
const storage = createSurrealStorage({ database })
|
|
67
|
+
|
|
68
|
+
await storage.appendEvents(
|
|
69
|
+
{ stream: 'order', id: 'o1' },
|
|
70
|
+
[
|
|
71
|
+
{ type: 'created', payload: {}, metadata: {}, schemaVersion: 1 },
|
|
72
|
+
{ type: 'updated', payload: {}, metadata: {}, schemaVersion: 1 },
|
|
73
|
+
],
|
|
74
|
+
0,
|
|
75
|
+
)
|
|
76
|
+
const second = await storage.appendEvents(
|
|
77
|
+
{ stream: 'order', id: 'o2' },
|
|
78
|
+
[{ type: 'created', payload: {}, metadata: {}, schemaVersion: 1 }],
|
|
79
|
+
0,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
expect(second[0].position).toBe(3)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('serializes concurrent appends to different streams without losing positions', async () => {
|
|
86
|
+
const database = await makeDatabase()
|
|
87
|
+
const storage = createSurrealStorage({ database })
|
|
88
|
+
|
|
89
|
+
const results = await Promise.all(
|
|
90
|
+
Array.from({ length: 8 }, (_, index) =>
|
|
91
|
+
storage.appendEvents(
|
|
92
|
+
{ stream: 'order', id: `o${index}` },
|
|
93
|
+
[{ type: 'created', payload: {}, metadata: {}, schemaVersion: 1 }],
|
|
94
|
+
0,
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
const positions = results
|
|
100
|
+
.map(([event]) => event.position)
|
|
101
|
+
.toSorted((a, b) => a - b)
|
|
102
|
+
expect(positions).toEqual([1, 2, 3, 4, 5, 6, 7, 8])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('reports the current version in ConcurrencyError', async () => {
|
|
106
|
+
const database = await makeDatabase()
|
|
107
|
+
const storage = createSurrealStorage({ database })
|
|
108
|
+
|
|
109
|
+
await storage.appendEvents(
|
|
110
|
+
{ stream: 'order', id: 'o1' },
|
|
111
|
+
[
|
|
112
|
+
{ type: 'created', payload: {}, metadata: {}, schemaVersion: 1 },
|
|
113
|
+
{ type: 'updated', payload: {}, metadata: {}, schemaVersion: 1 },
|
|
114
|
+
],
|
|
115
|
+
0,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
let error: unknown
|
|
119
|
+
try {
|
|
120
|
+
await storage.appendEvents(
|
|
121
|
+
{ stream: 'order', id: 'o1' },
|
|
122
|
+
[{ type: 'updated', payload: {}, metadata: {}, schemaVersion: 1 }],
|
|
123
|
+
1,
|
|
124
|
+
)
|
|
125
|
+
} catch (err) {
|
|
126
|
+
error = err
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
expect(error).toBeInstanceOf(ConcurrencyError)
|
|
130
|
+
expect((error as Error).message).toContain('expected v1, actual v2')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('notifies onEventsAppended with the persisted events', async () => {
|
|
134
|
+
const database = await makeDatabase()
|
|
135
|
+
let appended: unknown[] = []
|
|
136
|
+
const storage = createSurrealStorage({
|
|
137
|
+
database,
|
|
138
|
+
onEventsAppended: (events) => {
|
|
139
|
+
appended = events
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
await storage.appendEvents(
|
|
144
|
+
{ stream: 'order', id: 'o1' },
|
|
145
|
+
[{ type: 'created', payload: { n: 1 }, metadata: {}, schemaVersion: 1 }],
|
|
146
|
+
0,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
expect(appended).toHaveLength(1)
|
|
150
|
+
expect((appended[0] as any).position).toBe(1)
|
|
151
|
+
})
|
|
152
|
+
})
|