@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.
Files changed (64) hide show
  1. package/README.md +6 -5
  2. package/dist/{chunk-yn2sjcg3.js → chunk-3251p253.js} +50 -1
  3. package/dist/chunk-3251p253.js.map +10 -0
  4. package/dist/{chunk-vep2byx4.js → chunk-7akf01zj.js} +11 -11
  5. package/dist/{chunk-vep2byx4.js.map → chunk-7akf01zj.js.map} +3 -3
  6. package/dist/{chunk-57b88rt5.js → chunk-8edc30gz.js} +11 -11
  7. package/dist/{chunk-57b88rt5.js.map → chunk-8edc30gz.js.map} +1 -1
  8. package/dist/chunk-ak901ckt.js +67 -0
  9. package/dist/chunk-ak901ckt.js.map +10 -0
  10. package/dist/{chunk-vvv14qxy.js → chunk-kqh3q3fj.js} +10 -8
  11. package/dist/{chunk-vvv14qxy.js.map → chunk-kqh3q3fj.js.map} +3 -3
  12. package/dist/{chunk-3ejt4h7x.js → chunk-zznvzvnv.js} +58 -46
  13. package/dist/chunk-zznvzvnv.js.map +10 -0
  14. package/dist/createAggregateRoot.d.ts.map +1 -1
  15. package/dist/createAggregateRoot.js +7 -7
  16. package/dist/createCheckpointApi.d.ts +30 -0
  17. package/dist/createCheckpointApi.d.ts.map +1 -1
  18. package/dist/createCheckpointApi.js +1 -1
  19. package/dist/createEventStore.js +8 -8
  20. package/dist/createProcessManager.d.ts.map +1 -1
  21. package/dist/createProcessManager.js +3 -3
  22. package/dist/createProjection.d.ts.map +1 -1
  23. package/dist/createProjection.js +2 -2
  24. package/dist/drizzle/index.js +14 -14
  25. package/dist/drizzle/index.js.map +1 -1
  26. package/dist/index.js +18 -18
  27. package/dist/index.js.map +1 -1
  28. package/dist/memory/index.d.ts +14 -3
  29. package/dist/memory/index.d.ts.map +1 -1
  30. package/dist/memory/index.js +37 -30
  31. package/dist/memory/index.js.map +3 -3
  32. package/dist/memory/index.spec.d.ts +2 -0
  33. package/dist/memory/index.spec.d.ts.map +1 -0
  34. package/dist/surreal/index.d.ts +35 -0
  35. package/dist/surreal/index.d.ts.map +1 -0
  36. package/dist/surreal/index.js +415 -0
  37. package/dist/surreal/index.js.map +10 -0
  38. package/dist/surreal/index.spec.d.ts +2 -0
  39. package/dist/surreal/index.spec.d.ts.map +1 -0
  40. package/dist/surreal/schema.d.ts +12 -0
  41. package/dist/surreal/schema.d.ts.map +1 -0
  42. package/dist/surreal/schema.js +12 -0
  43. package/dist/surreal/schema.js.map +9 -0
  44. package/dist/tests/projectionMultiInstance.spec.d.ts +2 -0
  45. package/dist/tests/projectionMultiInstance.spec.d.ts.map +1 -0
  46. package/dist/tests/storageDriverContract.d.ts +19 -0
  47. package/dist/tests/storageDriverContract.d.ts.map +1 -0
  48. package/package.json +19 -10
  49. package/src/createAggregateRoot.ts +2 -3
  50. package/src/createCheckpointApi.ts +98 -0
  51. package/src/createProcessManager.ts +7 -6
  52. package/src/createProjection.ts +91 -65
  53. package/src/memory/index.spec.ts +4 -0
  54. package/src/memory/index.ts +55 -29
  55. package/src/surreal/index.spec.ts +152 -0
  56. package/src/surreal/index.ts +604 -0
  57. package/src/surreal/schema.ts +68 -0
  58. package/src/tests/aggregateRoot.spec.ts +6 -3
  59. package/src/tests/eventStore.spec.ts +1 -3
  60. package/src/tests/processManagerMultiInstance.spec.ts +18 -7
  61. package/src/tests/projectionMultiInstance.spec.ts +283 -0
  62. package/src/tests/storageDriverContract.ts +612 -0
  63. package/dist/chunk-3ejt4h7x.js.map +0 -10
  64. 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) return
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) return
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
@@ -266,86 +266,112 @@ export function buildProjectionCreator(params: {
266
266
 
267
267
  const runner = (async () => {
268
268
  await unlocked
269
- await withProjectionInfo({ name, event }, async () => {
270
- try {
271
- await handler?.(event, {
272
- replaying,
273
- async replayUntil(position, filterEvents) {
274
- if (position >= event.position) {
275
- throw new ProjectionReplayLoopError(
276
- `Event handler on${event.type} of projection ${name} tried to replay to the future`,
277
- { name, eventHandler: `on${event.type}` },
278
- event,
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
- locks.delete(event.streamId)
283
- return api.replayUntil(event.streamId, position, filterEvents)
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
- throw new Error(
293
- 'ReplaySkipError should never be thrown outside replay',
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
- if (
298
- !(error instanceof Error) ||
299
- error instanceof ProjectionReplayLoopError
300
- ) {
301
- throw error
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
- throw new ProjectionEventHandlerExecutionError(
318
- name,
319
- 'GLOBAL',
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
- error,
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
- if (snapshot) {
337
- await params.snapshot.put({
338
- projectionType: name,
339
- projectionId: event.streamId,
340
- lastEventPosition: event.position,
341
- state: snapshot.state,
342
- schemaVersion: snapshot.version,
343
- createdAt: new Date(),
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
- await checkpointApi.upsert(event)
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) {
@@ -0,0 +1,4 @@
1
+ import { runStorageDriverContract } from '../tests/storageDriverContract.ts'
2
+ import { createMemoryStorage } from './index.ts'
3
+
4
+ runStorageDriverContract('memory', () => createMemoryStorage())
@@ -1,5 +1,9 @@
1
- import type { AnyAggregateRoot } from '../createAggregateRoot.ts'
2
- import type { Checkpoint } from '../createCheckpointApi.ts'
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 { BaseOutputEvent, MaybePromise } from '../utilityTypes.js'
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 createEventStore<const Root extends AnyAggregateRoot>({
38
- aggregateRoots,
39
- postProcessEvent,
40
- onProjectionReplay,
41
- onProcessManagerRefresh,
42
- onEventsAppended,
43
- autoInit,
44
- lock,
45
- }: MemoryEventStoreParams<Root>): EventStore<
46
- EventsFromRoot<Root>,
47
- Root,
48
- true
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 createBaseEventStore({
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 (expectedVersion === null || persisted.version !== expectedVersion) {
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
+ })