@requence/event-sourcing 1.4.1 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +6 -5
  2. package/dist/{chunk-24xjphpk.js → chunk-06anxtk6.js} +6 -6
  3. package/dist/{chunk-24xjphpk.js.map → chunk-06anxtk6.js.map} +2 -2
  4. package/dist/{chunk-263c07sn.js → chunk-1mr4cwpw.js} +8 -8
  5. package/dist/{chunk-263c07sn.js.map → chunk-1mr4cwpw.js.map} +2 -2
  6. package/dist/{chunk-3ejt4h7x.js → chunk-2wqpktzp.js} +78 -66
  7. package/dist/chunk-2wqpktzp.js.map +10 -0
  8. package/dist/{chunk-dgzw1nj9.js → chunk-467qtkth.js} +8 -3
  9. package/dist/chunk-467qtkth.js.map +10 -0
  10. package/dist/chunk-6dn6wxg7.js +11 -0
  11. package/dist/{chunk-jd4q8fcf.js.map → chunk-6dn6wxg7.js.map} +2 -2
  12. package/dist/{chunk-pc1q4se9.js → chunk-9qtjz725.js} +2 -2
  13. package/dist/{chunk-pc1q4se9.js.map → chunk-9qtjz725.js.map} +2 -2
  14. package/dist/{chunk-tfy01fah.js → chunk-c7sytwpp.js} +4 -4
  15. package/dist/{chunk-tfy01fah.js.map → chunk-c7sytwpp.js.map} +2 -2
  16. package/dist/{chunk-vvv14qxy.js → chunk-d6jqbrq2.js} +33 -31
  17. package/dist/{chunk-vvv14qxy.js.map → chunk-d6jqbrq2.js.map} +3 -3
  18. package/dist/{chunk-vep2byx4.js → chunk-f95c3da4.js} +77 -64
  19. package/dist/chunk-f95c3da4.js.map +10 -0
  20. package/dist/chunk-fpjnk143.js +13 -0
  21. package/dist/{chunk-6q8mr1sg.js.map → chunk-fpjnk143.js.map} +2 -2
  22. package/dist/{chunk-8qn1nqnh.js → chunk-fxb9fbvz.js} +9 -9
  23. package/dist/{chunk-8qn1nqnh.js.map → chunk-fxb9fbvz.js.map} +2 -2
  24. package/dist/chunk-hmvwn02w.js +22 -0
  25. package/dist/{chunk-6yrb30rx.js.map → chunk-hmvwn02w.js.map} +2 -2
  26. package/dist/{chunk-ty9bhdvx.js → chunk-j4mwjx07.js} +5 -3
  27. package/dist/chunk-j4mwjx07.js.map +10 -0
  28. package/dist/{chunk-391m4mfp.js → chunk-jw9e0fr0.js} +13 -13
  29. package/dist/{chunk-391m4mfp.js.map → chunk-jw9e0fr0.js.map} +2 -2
  30. package/dist/chunk-mbxxvskx.js +67 -0
  31. package/dist/chunk-mbxxvskx.js.map +10 -0
  32. package/dist/{chunk-yn2sjcg3.js → chunk-nrhw3kde.js} +57 -8
  33. package/dist/chunk-nrhw3kde.js.map +10 -0
  34. package/dist/{chunk-57b88rt5.js → chunk-pcggjrxr.js} +32 -32
  35. package/dist/{chunk-57b88rt5.js.map → chunk-pcggjrxr.js.map} +2 -2
  36. package/dist/chunk-tkwe4r0y.js +22 -0
  37. package/dist/{chunk-2fe8jjdc.js.map → chunk-tkwe4r0y.js.map} +2 -2
  38. package/dist/{chunk-z388cd25.js → chunk-zkz6ew8c.js} +5 -5
  39. package/dist/{chunk-z388cd25.js.map → chunk-zkz6ew8c.js.map} +2 -2
  40. package/dist/createAggregateRoot.d.ts +1 -1
  41. package/dist/createAggregateRoot.d.ts.map +1 -1
  42. package/dist/createAggregateRoot.js +15 -14
  43. package/dist/createAggregateRoot.js.map +1 -1
  44. package/dist/createCheckpointApi.d.ts +30 -0
  45. package/dist/createCheckpointApi.d.ts.map +1 -1
  46. package/dist/createCheckpointApi.js +5 -5
  47. package/dist/createCheckpointApi.js.map +1 -1
  48. package/dist/createEventListener.js +8 -8
  49. package/dist/createEventListener.js.map +1 -1
  50. package/dist/createEventStore.js +16 -16
  51. package/dist/createEventStore.js.map +1 -1
  52. package/dist/createProcessManager.d.ts.map +1 -1
  53. package/dist/createProcessManager.js +12 -12
  54. package/dist/createProcessManager.js.map +1 -1
  55. package/dist/createProjection.d.ts.map +1 -1
  56. package/dist/createProjection.js +12 -12
  57. package/dist/createProjection.js.map +1 -1
  58. package/dist/drizzle/index.js +132 -132
  59. package/dist/drizzle/index.js.map +2 -2
  60. package/dist/drizzle/postgres.js +14 -14
  61. package/dist/drizzle/postgres.js.map +1 -1
  62. package/dist/errors.js +24 -24
  63. package/dist/errors.js.map +1 -1
  64. package/dist/index.js +63 -63
  65. package/dist/index.js.map +1 -1
  66. package/dist/lock.d.ts +11 -3
  67. package/dist/lock.d.ts.map +1 -1
  68. package/dist/lock.js +4 -2
  69. package/dist/lock.js.map +1 -1
  70. package/dist/memory/index.d.ts +14 -3
  71. package/dist/memory/index.d.ts.map +1 -1
  72. package/dist/memory/index.js +52 -45
  73. package/dist/memory/index.js.map +3 -3
  74. package/dist/memory/index.spec.d.ts +2 -0
  75. package/dist/memory/index.spec.d.ts.map +1 -0
  76. package/dist/redis/index.js +5 -4
  77. package/dist/redis/index.js.map +1 -1
  78. package/dist/redis/redisLock.d.ts +1 -1
  79. package/dist/redis/redisLock.d.ts.map +1 -1
  80. package/dist/redis/redisLock.js +5 -4
  81. package/dist/redis/redisLock.js.map +1 -1
  82. package/dist/refresh.js +9 -9
  83. package/dist/refresh.js.map +1 -1
  84. package/dist/replay.js +9 -9
  85. package/dist/replay.js.map +1 -1
  86. package/dist/snapshotGenerator.js +2 -2
  87. package/dist/superjson.js +8 -8
  88. package/dist/superjson.js.map +1 -1
  89. package/dist/surreal/index.d.ts +35 -0
  90. package/dist/surreal/index.d.ts.map +1 -0
  91. package/dist/surreal/index.js +415 -0
  92. package/dist/surreal/index.js.map +10 -0
  93. package/dist/surreal/index.spec.d.ts +2 -0
  94. package/dist/surreal/index.spec.d.ts.map +1 -0
  95. package/dist/surreal/schema.d.ts +12 -0
  96. package/dist/surreal/schema.d.ts.map +1 -0
  97. package/dist/surreal/schema.js +12 -0
  98. package/dist/surreal/schema.js.map +9 -0
  99. package/dist/tests/projectionMultiInstance.spec.d.ts +2 -0
  100. package/dist/tests/projectionMultiInstance.spec.d.ts.map +1 -0
  101. package/dist/tests/storageDriverContract.d.ts +19 -0
  102. package/dist/tests/storageDriverContract.d.ts.map +1 -0
  103. package/dist/transaction.js +1 -1
  104. package/dist/utils.js +6 -6
  105. package/dist/utils.js.map +1 -1
  106. package/dist/wrappedEventBuilder.js +8 -8
  107. package/dist/wrappedEventBuilder.js.map +1 -1
  108. package/package.json +19 -10
  109. package/src/createAggregateRoot.ts +24 -5
  110. package/src/createCheckpointApi.ts +98 -0
  111. package/src/createProcessManager.ts +7 -6
  112. package/src/createProjection.ts +91 -65
  113. package/src/lock.ts +17 -3
  114. package/src/memory/index.spec.ts +4 -0
  115. package/src/memory/index.ts +55 -29
  116. package/src/redis/redisLock.ts +6 -2
  117. package/src/surreal/index.spec.ts +152 -0
  118. package/src/surreal/index.ts +604 -0
  119. package/src/surreal/schema.ts +68 -0
  120. package/src/tests/aggregateRoot.spec.ts +48 -3
  121. package/src/tests/eventStore.spec.ts +1 -3
  122. package/src/tests/processManagerMultiInstance.spec.ts +18 -7
  123. package/src/tests/projectionMultiInstance.spec.ts +283 -0
  124. package/src/tests/storageDriverContract.ts +612 -0
  125. package/dist/chunk-2fe8jjdc.js +0 -22
  126. package/dist/chunk-3ejt4h7x.js.map +0 -10
  127. package/dist/chunk-6q8mr1sg.js +0 -13
  128. package/dist/chunk-6yrb30rx.js +0 -22
  129. package/dist/chunk-dgzw1nj9.js.map +0 -10
  130. package/dist/chunk-jd4q8fcf.js +0 -11
  131. package/dist/chunk-ty9bhdvx.js.map +0 -10
  132. package/dist/chunk-vep2byx4.js.map +0 -10
  133. package/dist/chunk-yn2sjcg3.js.map +0 -10
@@ -0,0 +1,604 @@
1
+ import { RecordId, type Surreal } from 'surrealdb'
2
+
3
+ import type {
4
+ AggregateRootSnapshotMethods,
5
+ AnyAggregateRoot,
6
+ } from '../createAggregateRoot.ts'
7
+ import type { CheckpointMethods } from '../createCheckpointApi.ts'
8
+ import type { ProjectionSnapshotMethods } from '../createProjection.ts'
9
+ import {
10
+ ConcurrencyError,
11
+ createEventStore as createBaseEventStore,
12
+ isStreamEvents,
13
+ isStreamId,
14
+ } from '../index.ts'
15
+ import type {
16
+ EventStore,
17
+ EventStoreParamsWithAggregateRootSnapshots,
18
+ EventsFromRoot,
19
+ LoadEvents,
20
+ OnProgress,
21
+ } from '../createEventStore.ts'
22
+ import type { LockCreator } from '../lock.ts'
23
+ import { superjson } from '../superjson.ts'
24
+ import { TABLES, initSchema as runInitSchema } from './schema.ts'
25
+ import type {
26
+ BaseInputEvent,
27
+ BaseOutputEvent,
28
+ MaybePromise,
29
+ StreamId,
30
+ } from '../utilityTypes.js'
31
+
32
+ export { createAggregateRoot } from '../index.ts'
33
+ export { extendTypes } from '../superjson.ts'
34
+ export { SCHEMA_STATEMENTS, TABLES, initSchema } from './schema.ts'
35
+
36
+ export type OnEventsAppended = (events: BaseOutputEvent[]) => MaybePromise<void>
37
+
38
+ interface SurrealEventStoreParams<Root extends AnyAggregateRoot> {
39
+ database: Surreal | (() => Surreal)
40
+ aggregateRoots: Root[]
41
+ autoInit?: boolean
42
+ initSchema?: boolean
43
+ lock?: LockCreator
44
+ postProcessEvent?: EventStoreParamsWithAggregateRootSnapshots<Root>['postProcessEvent']
45
+ onProjectionReplay?: OnProgress
46
+ onProcessManagerRefresh?: OnProgress
47
+ onEventsAppended?: OnEventsAppended
48
+ }
49
+
50
+ const CONFLICT_RETRIES = 10
51
+ const CONCURRENCY_MARKER = 'es_concurrency:'
52
+ const CHECKPOINT_CONFLICT_MARKER = 'es_checkpoint_conflict'
53
+
54
+ type QueryResponse =
55
+ | { success: true; result: unknown }
56
+ | { success: false; error: Error & { kind?: string } }
57
+
58
+ type ServerErrorLike = Error & {
59
+ kind?: string
60
+ isTransactionConflict?: boolean
61
+ }
62
+
63
+ function isThrownError(error: unknown): boolean {
64
+ return (
65
+ typeof error === 'object' &&
66
+ error !== null &&
67
+ (error as ServerErrorLike).kind === 'Thrown'
68
+ )
69
+ }
70
+
71
+ function isAlreadyExistsError(error: unknown): boolean {
72
+ return (
73
+ typeof error === 'object' &&
74
+ error !== null &&
75
+ (error as ServerErrorLike).kind === 'AlreadyExists'
76
+ )
77
+ }
78
+
79
+ function isUniqueIndexViolation(error: unknown): boolean {
80
+ return (
81
+ error instanceof Error && /index .* already contains/i.test(error.message)
82
+ )
83
+ }
84
+
85
+ // Snapshot-isolation conflicts are safe to retry. SurrealDB >= 3.1 reports
86
+ // them with a structured `TransactionConflict` detail; older servers only
87
+ // expose the human-readable message.
88
+ function isTransactionConflict(error: unknown): error is ServerErrorLike {
89
+ if (typeof error !== 'object' || error === null) {
90
+ return false
91
+ }
92
+ const serverError = error as ServerErrorLike
93
+ if (serverError.isTransactionConflict) {
94
+ return true
95
+ }
96
+ return /read or write conflict|transaction can be retried/i.test(
97
+ serverError.message ?? '',
98
+ )
99
+ }
100
+
101
+ // Statements after a failed one only report generic "not executed" errors —
102
+ // surface the statement error that actually caused the transaction to fail.
103
+ function primaryError(errors: ServerErrorLike[]): ServerErrorLike {
104
+ return (
105
+ errors.find((error) => !/was not executed/i.test(error.message)) ??
106
+ errors[0]
107
+ )
108
+ }
109
+
110
+ function toDate(value: unknown): Date {
111
+ if (value instanceof Date) {
112
+ return value
113
+ }
114
+ const dateTime = value as { toDate?: () => Date }
115
+ if (typeof dateTime?.toDate === 'function') {
116
+ return dateTime.toDate()
117
+ }
118
+ return new Date(String(value))
119
+ }
120
+
121
+ function toOutputEvent(row: any): BaseOutputEvent {
122
+ const { id: _id, ...event } = row
123
+ return {
124
+ ...event,
125
+ position: Number(event.position),
126
+ streamVersion: Number(event.streamVersion),
127
+ createdAt: toDate(event.createdAt),
128
+ payload: superjson.parse(event.payload),
129
+ metadata: superjson.deserialize(event.metadata),
130
+ }
131
+ }
132
+
133
+ // Jittered exponential backoff so concurrent writers racing on the position
134
+ // counter do not retry in lockstep.
135
+ function conflictBackoff(attempt: number) {
136
+ const ms = Math.min(200, 5 * 2 ** attempt) * (0.5 + Math.random())
137
+ return new Promise((resolve) => setTimeout(resolve, ms))
138
+ }
139
+
140
+ // The head version is computed with array::max instead of
141
+ // ORDER BY ... DESC LIMIT 1: inside a transaction the index scan serves that
142
+ // query in ascending order with the limit pushed down, returning the lowest
143
+ // version instead of the highest.
144
+ const APPEND_QUERY = `
145
+ BEGIN;
146
+ LET $head = array::max(SELECT VALUE streamVersion FROM ${TABLES.events} WHERE streamId = $streamId AND streamType = $streamType) ?? 0;
147
+ IF $head != $expectedVersion { THROW "${CONCURRENCY_MARKER}" + <string>$head; };
148
+ LET $next = (UPSERT ONLY ${TABLES.counters}:${TABLES.events} SET value += $count RETURN VALUE value);
149
+ LET $base = $next - $count;
150
+ INSERT INTO ${TABLES.events} $events.map(|$event, $index| $event + { position: $base + $index + 1 });
151
+ COMMIT;
152
+ `
153
+
154
+ const CHECKPOINT_UPSERT_QUERY = `
155
+ BEGIN;
156
+ LET $current = (SELECT VALUE version FROM ONLY $rid);
157
+ IF $current = NONE {
158
+ CREATE $rid CONTENT $content;
159
+ } ELSE IF $insertOnly = false AND $current = $expectedVersion {
160
+ UPDATE $rid MERGE $changes;
161
+ } ELSE {
162
+ THROW "${CHECKPOINT_CONFLICT_MARKER}";
163
+ };
164
+ COMMIT;
165
+ `
166
+
167
+ export function createSurrealStorage(params: {
168
+ database: Surreal | (() => Surreal)
169
+ initSchema?: boolean
170
+ onEventsAppended?: OnEventsAppended
171
+ }): {
172
+ loadEvents: LoadEvents
173
+ appendEvents: (
174
+ streamId: StreamId,
175
+ events: BaseInputEvent[],
176
+ expectedVersion: number,
177
+ ) => Promise<BaseOutputEvent[]>
178
+ checkpoint: CheckpointMethods
179
+ projectionSnapshot: ProjectionSnapshotMethods
180
+ aggregateRootSnapshot: AggregateRootSnapshotMethods
181
+ } {
182
+ const db = () =>
183
+ typeof params.database === 'function' ? params.database() : params.database
184
+
185
+ let schemaReady: Promise<void> | undefined
186
+ const ready = () => {
187
+ if (params.initSchema === false) {
188
+ return Promise.resolve()
189
+ }
190
+ schemaReady ??= runInitSchema(db())
191
+ return schemaReady
192
+ }
193
+
194
+ // Statement failures come back as per-statement error responses, but
195
+ // transaction conflicts reject the query promise itself — normalize both
196
+ // into the errors array so the retry loops handle them uniformly.
197
+ async function runWithResponses(
198
+ query: string,
199
+ bindings: Record<string, unknown>,
200
+ ): Promise<{ responses: QueryResponse[]; errors: ServerErrorLike[] }> {
201
+ try {
202
+ const responses = (await db()
203
+ .query(query, bindings)
204
+ .responses()) as QueryResponse[]
205
+ const errors = responses
206
+ .filter((response) => !response.success)
207
+ .map((response) => (response as { error: ServerErrorLike }).error)
208
+ return { responses, errors }
209
+ } catch (error) {
210
+ if (isTransactionConflict(error)) {
211
+ return { responses: [], errors: [error] }
212
+ }
213
+ throw error
214
+ }
215
+ }
216
+
217
+ return {
218
+ async *loadEvents(select, range) {
219
+ await ready()
220
+ const conditions = ['position > $lastPosition']
221
+ const bindings: Record<string, unknown> = {}
222
+
223
+ if (select) {
224
+ const selects = Array.isArray(select) ? select : [select]
225
+ const streamTypes = selects.map((select) => select.stream)
226
+ const streamIds = selects
227
+ .map((select) => (isStreamId(select) ? select.id : null))
228
+ .filter(Boolean) as string[]
229
+ const eventTypes = selects.flatMap((select) =>
230
+ isStreamEvents(select) ? select.events : [],
231
+ )
232
+ conditions.push('streamType IN $streamTypes')
233
+ bindings.streamTypes = streamTypes
234
+
235
+ if (streamIds.length > 0) {
236
+ conditions.push('streamId IN $streamIds')
237
+ bindings.streamIds = streamIds
238
+ }
239
+
240
+ if (eventTypes.length > 0) {
241
+ conditions.push('type IN $eventTypes')
242
+ bindings.eventTypes = eventTypes
243
+ }
244
+ }
245
+ let lastPosition = 0
246
+
247
+ if (range?.from) {
248
+ lastPosition = range.from - 1
249
+ }
250
+
251
+ // range.to is applied client-side: combining both bounds on the
252
+ // uniquely indexed position field trips a SurrealDB 3.0 planner bug
253
+ // where the exclusive lower bound is ignored.
254
+ const chunkSize = 50
255
+ const query = `SELECT * FROM ${TABLES.events} WHERE ${conditions.join(' AND ')} ORDER BY position ASC LIMIT ${chunkSize};`
256
+
257
+ while (true) {
258
+ const [resolvedEvents] = (await db()
259
+ .query(query, { ...bindings, lastPosition })
260
+ .collect()) as [any[]]
261
+
262
+ if (resolvedEvents.length === 0) {
263
+ break
264
+ }
265
+
266
+ for (const resolvedEvent of resolvedEvents) {
267
+ const position = Number(resolvedEvent.position)
268
+ if (range?.to && position > range.to) {
269
+ return
270
+ }
271
+ yield toOutputEvent(resolvedEvent)
272
+ lastPosition = position
273
+ }
274
+ }
275
+ },
276
+
277
+ async appendEvents(streamId, newEvents, expectedVersion) {
278
+ await ready()
279
+ const mappedEvents = newEvents.map((event, index) => ({
280
+ streamId: streamId.id,
281
+ streamType: streamId.stream,
282
+ streamVersion: expectedVersion + 1 + index,
283
+ type: event.type,
284
+ payload: superjson.stringify(event.payload),
285
+ metadata: superjson.serialize(event.metadata),
286
+ causationId: event.causationId ?? undefined,
287
+ actorId: event.actorId ?? undefined,
288
+ correlationId: event.correlationId ?? undefined,
289
+ schemaVersion: event.schemaVersion,
290
+ }))
291
+
292
+ for (let attempt = 0; ; attempt++) {
293
+ const { responses, errors } = await runWithResponses(APPEND_QUERY, {
294
+ streamId: streamId.id,
295
+ streamType: streamId.stream,
296
+ expectedVersion,
297
+ count: mappedEvents.length,
298
+ events: mappedEvents,
299
+ })
300
+
301
+ if (errors.length === 0) {
302
+ const persistedEvents = responses
303
+ .map((response) => (response.success ? response.result : undefined))
304
+ .find(Array.isArray) as any[]
305
+
306
+ const resolvedEvents = persistedEvents
307
+ .map(toOutputEvent)
308
+ .toSorted((eventA, eventB) => eventA.position - eventB.position)
309
+
310
+ params.onEventsAppended?.(resolvedEvents)
311
+
312
+ return resolvedEvents
313
+ }
314
+
315
+ const concurrency = errors.find(
316
+ (error) =>
317
+ isThrownError(error) && error.message.includes(CONCURRENCY_MARKER),
318
+ )
319
+ if (concurrency) {
320
+ const currentVersion = Number(
321
+ new RegExp(`${CONCURRENCY_MARKER}(\\d+)`).exec(
322
+ concurrency.message,
323
+ )![1],
324
+ )
325
+ throw new ConcurrencyError(streamId, expectedVersion, currentVersion)
326
+ }
327
+
328
+ if (errors.some(isUniqueIndexViolation)) {
329
+ const [version] = (await db()
330
+ .query(
331
+ `RETURN array::max(SELECT VALUE streamVersion FROM ${TABLES.events} WHERE streamId = $streamId AND streamType = $streamType) ?? 0;`,
332
+ { streamId: streamId.id, streamType: streamId.stream },
333
+ )
334
+ .collect()) as [number]
335
+
336
+ const currentVersion = Number(version ?? 0)
337
+ throw new ConcurrencyError(streamId, expectedVersion, currentVersion)
338
+ }
339
+
340
+ if (errors.some(isTransactionConflict) && attempt < CONFLICT_RETRIES) {
341
+ await conflictBackoff(attempt)
342
+ continue
343
+ }
344
+
345
+ throw primaryError(errors)
346
+ }
347
+ },
348
+
349
+ checkpoint: {
350
+ async upsert(checkpoint, expectedVersion) {
351
+ await ready()
352
+ const rid = new RecordId(TABLES.checkpoints, [
353
+ checkpoint.type,
354
+ checkpoint.name,
355
+ ])
356
+ const metadata = superjson.stringify(checkpoint.metadata)
357
+ const changes = {
358
+ lastEventPosition: checkpoint.lastEventPosition,
359
+ metadata,
360
+ version: checkpoint.version,
361
+ }
362
+
363
+ // Compare-and-swap: create when no checkpoint exists, otherwise only
364
+ // apply when the stored version still matches `expectedVersion`. A
365
+ // `null` expectation means "insert only" — an existing record is a
366
+ // conflict.
367
+ for (let attempt = 0; ; attempt++) {
368
+ const { errors } = await runWithResponses(CHECKPOINT_UPSERT_QUERY, {
369
+ rid,
370
+ content: {
371
+ type: checkpoint.type,
372
+ name: checkpoint.name,
373
+ ...changes,
374
+ },
375
+ changes,
376
+ insertOnly: expectedVersion === null,
377
+ expectedVersion: expectedVersion ?? 0,
378
+ })
379
+
380
+ if (errors.length === 0) {
381
+ return true
382
+ }
383
+
384
+ if (
385
+ errors.some(
386
+ (error) =>
387
+ isAlreadyExistsError(error) ||
388
+ (isThrownError(error) &&
389
+ error.message.includes(CHECKPOINT_CONFLICT_MARKER)),
390
+ )
391
+ ) {
392
+ return false
393
+ }
394
+
395
+ if (
396
+ errors.some(isTransactionConflict) &&
397
+ attempt < CONFLICT_RETRIES
398
+ ) {
399
+ await conflictBackoff(attempt)
400
+ continue
401
+ }
402
+
403
+ throw primaryError(errors)
404
+ }
405
+ },
406
+ async get(type, name) {
407
+ await ready()
408
+ const rid = new RecordId(TABLES.checkpoints, [type, name])
409
+ const [checkpoint] = (await db()
410
+ .query(`SELECT * OMIT id FROM ONLY $rid;`, { rid })
411
+ .collect()) as [any]
412
+
413
+ if (!checkpoint) {
414
+ return null
415
+ }
416
+
417
+ return {
418
+ type: checkpoint.type,
419
+ name: checkpoint.name,
420
+ lastEventPosition: Number(checkpoint.lastEventPosition),
421
+ version: Number(checkpoint.version),
422
+ metadata: superjson.parse(checkpoint.metadata),
423
+ }
424
+ },
425
+ async delete(type, name) {
426
+ await ready()
427
+ const rid = new RecordId(TABLES.checkpoints, [type, name])
428
+ await db().query(`DELETE $rid;`, { rid }).collect()
429
+ },
430
+ },
431
+
432
+ projectionSnapshot: {
433
+ async put(snapshot) {
434
+ await ready()
435
+ await db()
436
+ .query(`CREATE ${TABLES.projectionSnapshots} CONTENT $content;`, {
437
+ content: {
438
+ projectionId: snapshot.projectionId,
439
+ projectionType: snapshot.projectionType,
440
+ lastEventPosition: snapshot.lastEventPosition,
441
+ schemaVersion: snapshot.schemaVersion,
442
+ state: superjson.stringify(snapshot.state),
443
+ metadata: superjson.stringify(snapshot.metadata),
444
+ },
445
+ })
446
+ .collect()
447
+ },
448
+ async get(select) {
449
+ await ready()
450
+ const [[snapshot]] = (await db()
451
+ .query(
452
+ `SELECT * OMIT id FROM ${TABLES.projectionSnapshots} WHERE projectionId = $id AND projectionType = $projection ORDER BY lastEventPosition DESC LIMIT 1;`,
453
+ { id: select.id, projection: select.projection },
454
+ )
455
+ .collect()) as [any[]]
456
+
457
+ if (!snapshot) {
458
+ return null
459
+ }
460
+
461
+ return {
462
+ ...snapshot,
463
+ lastEventPosition: Number(snapshot.lastEventPosition),
464
+ schemaVersion: Number(snapshot.schemaVersion),
465
+ createdAt: toDate(snapshot.createdAt),
466
+ state: superjson.parse(snapshot.state),
467
+ metadata: superjson.parse(snapshot.metadata),
468
+ }
469
+ },
470
+ async delete(target, fromPosition) {
471
+ await ready()
472
+ const conditions = ['projectionType = $projection']
473
+ const bindings: Record<string, unknown> = {
474
+ projection: target.projection,
475
+ }
476
+
477
+ if (target.id) {
478
+ conditions.push('projectionId = $id')
479
+ bindings.id = target.id
480
+ }
481
+
482
+ if (fromPosition) {
483
+ conditions.push('lastEventPosition > $fromPosition')
484
+ bindings.fromPosition = fromPosition
485
+ }
486
+
487
+ await db()
488
+ .query(
489
+ `DELETE ${TABLES.projectionSnapshots} WHERE ${conditions.join(' AND ')};`,
490
+ bindings,
491
+ )
492
+ .collect()
493
+ },
494
+ async incrementAppliedCount(select) {
495
+ await ready()
496
+ for (let attempt = 0; ; attempt++) {
497
+ try {
498
+ const [appliedEvents] = (await db()
499
+ .query(
500
+ `UPSERT ONLY ${TABLES.projectionAppliedEvents}:[$projection, $id] SET appliedEvents += 1 RETURN VALUE appliedEvents;`,
501
+ { projection: select.projection, id: select.id },
502
+ )
503
+ .collect()) as [number]
504
+
505
+ return Number(appliedEvents)
506
+ } catch (error) {
507
+ if (isTransactionConflict(error) && attempt < CONFLICT_RETRIES) {
508
+ await conflictBackoff(attempt)
509
+ continue
510
+ }
511
+ throw error
512
+ }
513
+ }
514
+ },
515
+ },
516
+
517
+ aggregateRootSnapshot: {
518
+ async put(snapshot) {
519
+ await ready()
520
+ await db()
521
+ .query(`CREATE ${TABLES.aggregateRootSnapshots} CONTENT $content;`, {
522
+ content: {
523
+ streamId: snapshot.streamId,
524
+ streamType: snapshot.streamType,
525
+ streamVersion: snapshot.streamVersion,
526
+ lastEventPosition: snapshot.lastEventPosition,
527
+ schemaVersion: snapshot.schemaVersion,
528
+ state: superjson.stringify(snapshot.state),
529
+ metadata: superjson.stringify(snapshot.metadata),
530
+ },
531
+ })
532
+ .collect()
533
+ },
534
+ async get(select) {
535
+ await ready()
536
+ const [[snapshot]] = (await db()
537
+ .query(
538
+ `SELECT * OMIT id FROM ${TABLES.aggregateRootSnapshots} WHERE streamId = $id AND streamType = $stream ORDER BY streamVersion DESC LIMIT 1;`,
539
+ { id: select.id, stream: select.stream },
540
+ )
541
+ .collect()) as [any[]]
542
+
543
+ if (!snapshot) {
544
+ return null
545
+ }
546
+
547
+ return {
548
+ ...snapshot,
549
+ streamVersion: Number(snapshot.streamVersion),
550
+ lastEventPosition: Number(snapshot.lastEventPosition),
551
+ schemaVersion: Number(snapshot.schemaVersion),
552
+ createdAt: toDate(snapshot.createdAt),
553
+ state: superjson.parse(snapshot.state),
554
+ metadata: superjson.parse(snapshot.metadata),
555
+ }
556
+ },
557
+ async delete(target, fromStreamVersion) {
558
+ await ready()
559
+ await db()
560
+ .query(
561
+ `DELETE ${TABLES.aggregateRootSnapshots} WHERE streamId = $id AND streamType = $stream AND streamVersion > $fromStreamVersion;`,
562
+ {
563
+ id: target.id,
564
+ stream: target.stream,
565
+ fromStreamVersion,
566
+ },
567
+ )
568
+ .collect()
569
+ },
570
+ },
571
+ }
572
+ }
573
+
574
+ export function createEventStore<const Root extends AnyAggregateRoot>({
575
+ database,
576
+ aggregateRoots,
577
+ postProcessEvent,
578
+ onProjectionReplay,
579
+ onProcessManagerRefresh,
580
+ onEventsAppended,
581
+ autoInit,
582
+ initSchema,
583
+ lock,
584
+ }: SurrealEventStoreParams<Root>): EventStore<
585
+ EventsFromRoot<Root>,
586
+ Root,
587
+ true
588
+ > {
589
+ const storage = createSurrealStorage({
590
+ database,
591
+ initSchema,
592
+ onEventsAppended,
593
+ })
594
+
595
+ return createBaseEventStore({
596
+ aggregateRoots,
597
+ postProcessEvent,
598
+ onProjectionReplay,
599
+ onProcessManagerRefresh,
600
+ autoInit,
601
+ lock,
602
+ ...storage,
603
+ }) as any
604
+ }
@@ -0,0 +1,68 @@
1
+ import type { Surreal } from 'surrealdb'
2
+
3
+ export const TABLES = {
4
+ events: 'events',
5
+ counters: 'event_sourcing_counters',
6
+ checkpoints: 'checkpoints',
7
+ aggregateRootSnapshots: 'aggregate_root_snapshots',
8
+ projectionSnapshots: 'projection_snapshots',
9
+ projectionAppliedEvents: 'projection_applied_events',
10
+ } as const
11
+
12
+ // Idempotent DDL — safe to run on every boot. All tables are SCHEMAFULL, so
13
+ // SurrealDB rejects writes carrying fields that are not defined here.
14
+ export const SCHEMA_STATEMENTS = `
15
+ DEFINE TABLE IF NOT EXISTS ${TABLES.events} SCHEMAFULL;
16
+ DEFINE FIELD IF NOT EXISTS position ON ${TABLES.events} TYPE int;
17
+ DEFINE FIELD IF NOT EXISTS streamId ON ${TABLES.events} TYPE string;
18
+ DEFINE FIELD IF NOT EXISTS streamType ON ${TABLES.events} TYPE string;
19
+ DEFINE FIELD IF NOT EXISTS streamVersion ON ${TABLES.events} TYPE int;
20
+ DEFINE FIELD IF NOT EXISTS type ON ${TABLES.events} TYPE string;
21
+ DEFINE FIELD IF NOT EXISTS createdAt ON ${TABLES.events} TYPE datetime DEFAULT time::now();
22
+ DEFINE FIELD IF NOT EXISTS payload ON ${TABLES.events} TYPE string;
23
+ DEFINE FIELD IF NOT EXISTS metadata ON ${TABLES.events} TYPE object FLEXIBLE;
24
+ DEFINE FIELD IF NOT EXISTS causationId ON ${TABLES.events} TYPE option<string>;
25
+ DEFINE FIELD IF NOT EXISTS correlationId ON ${TABLES.events} TYPE option<string>;
26
+ DEFINE FIELD IF NOT EXISTS actorId ON ${TABLES.events} TYPE option<string>;
27
+ DEFINE FIELD IF NOT EXISTS schemaVersion ON ${TABLES.events} TYPE int;
28
+ DEFINE INDEX IF NOT EXISTS events_stream_unique ON ${TABLES.events} FIELDS streamId, streamType, streamVersion UNIQUE;
29
+ DEFINE INDEX IF NOT EXISTS events_position ON ${TABLES.events} FIELDS position UNIQUE;
30
+
31
+ DEFINE TABLE IF NOT EXISTS ${TABLES.counters} SCHEMAFULL;
32
+ DEFINE FIELD IF NOT EXISTS value ON ${TABLES.counters} TYPE int;
33
+
34
+ DEFINE TABLE IF NOT EXISTS ${TABLES.checkpoints} SCHEMAFULL;
35
+ DEFINE FIELD IF NOT EXISTS type ON ${TABLES.checkpoints} TYPE string;
36
+ DEFINE FIELD IF NOT EXISTS name ON ${TABLES.checkpoints} TYPE string;
37
+ DEFINE FIELD IF NOT EXISTS lastEventPosition ON ${TABLES.checkpoints} TYPE int;
38
+ DEFINE FIELD IF NOT EXISTS metadata ON ${TABLES.checkpoints} TYPE string;
39
+ DEFINE FIELD IF NOT EXISTS version ON ${TABLES.checkpoints} TYPE int DEFAULT 0;
40
+
41
+ DEFINE TABLE IF NOT EXISTS ${TABLES.aggregateRootSnapshots} SCHEMAFULL;
42
+ DEFINE FIELD IF NOT EXISTS streamId ON ${TABLES.aggregateRootSnapshots} TYPE string;
43
+ DEFINE FIELD IF NOT EXISTS streamType ON ${TABLES.aggregateRootSnapshots} TYPE string;
44
+ DEFINE FIELD IF NOT EXISTS streamVersion ON ${TABLES.aggregateRootSnapshots} TYPE int;
45
+ DEFINE FIELD IF NOT EXISTS state ON ${TABLES.aggregateRootSnapshots} TYPE string;
46
+ DEFINE FIELD IF NOT EXISTS metadata ON ${TABLES.aggregateRootSnapshots} TYPE string;
47
+ DEFINE FIELD IF NOT EXISTS createdAt ON ${TABLES.aggregateRootSnapshots} TYPE datetime DEFAULT time::now();
48
+ DEFINE FIELD IF NOT EXISTS lastEventPosition ON ${TABLES.aggregateRootSnapshots} TYPE int;
49
+ DEFINE FIELD IF NOT EXISTS schemaVersion ON ${TABLES.aggregateRootSnapshots} TYPE int;
50
+ DEFINE INDEX IF NOT EXISTS aggregate_root_snapshots_unique ON ${TABLES.aggregateRootSnapshots} FIELDS streamType, streamId, streamVersion UNIQUE;
51
+
52
+ DEFINE TABLE IF NOT EXISTS ${TABLES.projectionSnapshots} SCHEMAFULL;
53
+ DEFINE FIELD IF NOT EXISTS projectionId ON ${TABLES.projectionSnapshots} TYPE string;
54
+ DEFINE FIELD IF NOT EXISTS projectionType ON ${TABLES.projectionSnapshots} TYPE string;
55
+ DEFINE FIELD IF NOT EXISTS state ON ${TABLES.projectionSnapshots} TYPE string;
56
+ DEFINE FIELD IF NOT EXISTS metadata ON ${TABLES.projectionSnapshots} TYPE string;
57
+ DEFINE FIELD IF NOT EXISTS createdAt ON ${TABLES.projectionSnapshots} TYPE datetime DEFAULT time::now();
58
+ DEFINE FIELD IF NOT EXISTS lastEventPosition ON ${TABLES.projectionSnapshots} TYPE int;
59
+ DEFINE FIELD IF NOT EXISTS schemaVersion ON ${TABLES.projectionSnapshots} TYPE int;
60
+ DEFINE INDEX IF NOT EXISTS projection_snapshots_unique ON ${TABLES.projectionSnapshots} FIELDS projectionId, projectionType, lastEventPosition UNIQUE;
61
+
62
+ DEFINE TABLE IF NOT EXISTS ${TABLES.projectionAppliedEvents} SCHEMAFULL;
63
+ DEFINE FIELD IF NOT EXISTS appliedEvents ON ${TABLES.projectionAppliedEvents} TYPE int;
64
+ `
65
+
66
+ export async function initSchema(database: Surreal): Promise<void> {
67
+ await database.query(SCHEMA_STATEMENTS)
68
+ }