@photon-grove/evt 0.1.1 → 0.2.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 +51 -4
- package/dist/aggregate.d.ts +26 -0
- package/dist/aggregate.d.ts.map +1 -1
- package/dist/aggregate.js.map +1 -1
- package/dist/dynamodb/head.d.ts +6 -0
- package/dist/dynamodb/head.d.ts.map +1 -1
- package/dist/dynamodb/head.js +11 -0
- package/dist/dynamodb/head.js.map +1 -1
- package/dist/dynamodb/index.d.ts +2 -1
- package/dist/dynamodb/index.d.ts.map +1 -1
- package/dist/dynamodb/index.js +2 -1
- package/dist/dynamodb/index.js.map +1 -1
- package/dist/dynamodb/repository.d.ts +9 -2
- package/dist/dynamodb/repository.d.ts.map +1 -1
- package/dist/dynamodb/repository.js +123 -27
- package/dist/dynamodb/repository.js.map +1 -1
- package/dist/dynamodb/requests.d.ts +37 -3
- package/dist/dynamodb/requests.d.ts.map +1 -1
- package/dist/dynamodb/requests.js +67 -9
- package/dist/dynamodb/requests.js.map +1 -1
- package/dist/dynamodb/views.d.ts +18 -0
- package/dist/dynamodb/views.d.ts.map +1 -0
- package/dist/dynamodb/views.js +56 -0
- package/dist/dynamodb/views.js.map +1 -0
- package/dist/errors.d.ts +51 -4
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +60 -4
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/memory/repository.d.ts +3 -2
- package/dist/memory/repository.d.ts.map +1 -1
- package/dist/memory/repository.js +78 -29
- package/dist/memory/repository.js.map +1 -1
- package/dist/postgres/client.d.ts +32 -0
- package/dist/postgres/client.d.ts.map +1 -0
- package/dist/postgres/client.js +4 -0
- package/dist/postgres/client.js.map +1 -0
- package/dist/postgres/index.d.ts +4 -0
- package/dist/postgres/index.d.ts.map +1 -0
- package/dist/postgres/index.js +6 -0
- package/dist/postgres/index.js.map +1 -0
- package/dist/postgres/repository.d.ts +62 -0
- package/dist/postgres/repository.d.ts.map +1 -0
- package/dist/postgres/repository.js +598 -0
- package/dist/postgres/repository.js.map +1 -0
- package/dist/postgres/schema.d.ts +81 -0
- package/dist/postgres/schema.d.ts.map +1 -0
- package/dist/postgres/schema.js +249 -0
- package/dist/postgres/schema.js.map +1 -0
- package/dist/repository.d.ts +101 -6
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +108 -6
- package/dist/repository.js.map +1 -1
- package/dist/staging.d.ts +28 -0
- package/dist/staging.d.ts.map +1 -0
- package/dist/staging.js +89 -0
- package/dist/staging.js.map +1 -0
- package/dist/store.d.ts +15 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +38 -54
- package/dist/store.js.map +1 -1
- package/dist/transaction.d.ts +113 -0
- package/dist/transaction.d.ts.map +1 -0
- package/dist/transaction.js +207 -0
- package/dist/transaction.js.map +1 -0
- package/package.json +5 -1
- package/src/aggregate.ts +28 -0
- package/src/dynamodb/head.ts +15 -0
- package/src/dynamodb/index.ts +2 -0
- package/src/dynamodb/repository.ts +177 -33
- package/src/dynamodb/requests.ts +114 -10
- package/src/dynamodb/views.ts +67 -0
- package/src/errors.ts +86 -4
- package/src/index.ts +19 -6
- package/src/memory/repository.ts +93 -32
- package/src/postgres/client.ts +37 -0
- package/src/postgres/index.ts +23 -0
- package/src/postgres/repository.ts +858 -0
- package/src/postgres/schema.ts +302 -0
- package/src/repository.ts +220 -8
- package/src/staging.ts +138 -0
- package/src/store.ts +66 -65
- package/src/transaction.ts +322 -0
package/src/staging.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// What a commit of one stream carries beyond its decided events, the same for a single command
|
|
2
|
+
// and for a transaction: the stored events, the snapshot the policy calls for, and the projectors'
|
|
3
|
+
// operations.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type Aggregate,
|
|
7
|
+
type EventDefinitions,
|
|
8
|
+
type EventOf,
|
|
9
|
+
type Projection,
|
|
10
|
+
encodeEventData,
|
|
11
|
+
} from './aggregate.js'
|
|
12
|
+
import {EvtError} from './errors.js'
|
|
13
|
+
import {type SerializedEvent, eventId} from './event.js'
|
|
14
|
+
import type {Metadata} from './metadata.js'
|
|
15
|
+
import type {NewSnapshot, ProjectorGroup} from './repository.js'
|
|
16
|
+
import {MAX_COMMIT_ITEM_BYTES, snapshotCommitSize, snapshotEventsDue} from './snapshots.js'
|
|
17
|
+
|
|
18
|
+
const INT64_MAX = 2n ** 63n - 1n
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Serializes decided events continuing the stream after `after`, each with its own copy of
|
|
22
|
+
* `metadata`, and then folds them into `state`. Every event is encoded before any is applied, so
|
|
23
|
+
* when one fails to encode, `state` is untouched, even for an aggregate whose `apply` changes the
|
|
24
|
+
* state it's given.
|
|
25
|
+
*/
|
|
26
|
+
export function serializeEvents<S, C, D extends EventDefinitions>(
|
|
27
|
+
aggregate: Aggregate<S, C, D>,
|
|
28
|
+
entityId: string,
|
|
29
|
+
after: bigint,
|
|
30
|
+
state: S,
|
|
31
|
+
decided: readonly EventOf<D>[],
|
|
32
|
+
metadata: Metadata,
|
|
33
|
+
): {events: SerializedEvent[]; state: S} {
|
|
34
|
+
let sequence = after
|
|
35
|
+
const events: SerializedEvent[] = []
|
|
36
|
+
|
|
37
|
+
for (const event of decided) {
|
|
38
|
+
if (sequence >= INT64_MAX) {
|
|
39
|
+
throw new EvtError(
|
|
40
|
+
'invalidCommit',
|
|
41
|
+
`invalid commit: entity ${entityId} is at the maximum event sequence`,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
sequence++
|
|
45
|
+
|
|
46
|
+
const {version, payload} = encodeEventData(aggregate.events, event)
|
|
47
|
+
events.push({
|
|
48
|
+
id: eventId(entityId, sequence),
|
|
49
|
+
sequence,
|
|
50
|
+
type: event.type,
|
|
51
|
+
version,
|
|
52
|
+
entityId,
|
|
53
|
+
entityType: aggregate.type,
|
|
54
|
+
payload,
|
|
55
|
+
metadata: copyMetadata(metadata),
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const event of decided) {
|
|
60
|
+
state = aggregate.apply(state, event)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {events, state}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The snapshot a commit of `appended` events after `loadedSequence` carries, or null when the
|
|
68
|
+
* interval calls for none. A snapshot whose item is over the per-item commit budget is left out
|
|
69
|
+
* too, since it would fail every commit that carries it.
|
|
70
|
+
*/
|
|
71
|
+
export function commitSnapshot<S, C, D extends EventDefinitions>(
|
|
72
|
+
aggregate: Aggregate<S, C, D>,
|
|
73
|
+
entityId: string,
|
|
74
|
+
interval: number,
|
|
75
|
+
loaded: {readonly sequence: bigint; readonly snapshotSequence: bigint},
|
|
76
|
+
appended: number,
|
|
77
|
+
state: S,
|
|
78
|
+
): NewSnapshot | null {
|
|
79
|
+
if (snapshotEventsDue(loaded.sequence, appended, interval) === 0) {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const snapshot: NewSnapshot = {
|
|
84
|
+
entityType: aggregate.type,
|
|
85
|
+
sequence: loaded.snapshotSequence + 1n,
|
|
86
|
+
payload: aggregate.state.encode(state),
|
|
87
|
+
}
|
|
88
|
+
const eventSequence = loaded.sequence + BigInt(appended)
|
|
89
|
+
const size = snapshotCommitSize({...snapshot, entityId, eventSequence})
|
|
90
|
+
|
|
91
|
+
return size > MAX_COMMIT_ITEM_BYTES ? null : snapshot
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The operations the aggregate's projectors add to a commit, in declaration order. */
|
|
95
|
+
export function projectCommit<S, C, D extends EventDefinitions>(
|
|
96
|
+
aggregate: Aggregate<S, C, D>,
|
|
97
|
+
entityId: string,
|
|
98
|
+
state: S,
|
|
99
|
+
events: readonly SerializedEvent[],
|
|
100
|
+
snapshot: NewSnapshot | null,
|
|
101
|
+
): ProjectorGroup[] {
|
|
102
|
+
const projectors = aggregate.projectors ?? []
|
|
103
|
+
if (projectors.length === 0) {
|
|
104
|
+
return []
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Each projector gets its own frozen copy of the events, their metadata and every byte array,
|
|
108
|
+
// so none can change what another one sees, or what the commit writes.
|
|
109
|
+
const encodedState = snapshot?.payload ?? aggregate.state.encode(state)
|
|
110
|
+
const detached = (): Projection<S> =>
|
|
111
|
+
Object.freeze({
|
|
112
|
+
entityType: aggregate.type,
|
|
113
|
+
entityId,
|
|
114
|
+
state,
|
|
115
|
+
encodedState: encodedState.slice(),
|
|
116
|
+
events: Object.freeze(
|
|
117
|
+
events.map((event) =>
|
|
118
|
+
Object.freeze({
|
|
119
|
+
...event,
|
|
120
|
+
payload: event.payload && event.payload.slice(),
|
|
121
|
+
metadata: Object.freeze(copyMetadata(event.metadata)),
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
),
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
return projectors.flatMap((projector) => projector.project(detached()) ?? [])
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A copy of the metadata, so changing one event's changes no other. */
|
|
131
|
+
export function copyMetadata(metadata: Metadata): Metadata {
|
|
132
|
+
return {
|
|
133
|
+
...metadata,
|
|
134
|
+
trace: metadata.trace && new Map(metadata.trace),
|
|
135
|
+
origin: metadata.origin && {...metadata.origin},
|
|
136
|
+
extensions: new Map(metadata.extensions),
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -6,14 +6,19 @@ import {
|
|
|
6
6
|
type EventDefinitions,
|
|
7
7
|
type EventOf,
|
|
8
8
|
type Rejection,
|
|
9
|
-
encodeEventData,
|
|
10
9
|
replayEvent,
|
|
11
10
|
} from './aggregate.js'
|
|
12
11
|
import {commitIds} from './commit-id.js'
|
|
13
12
|
import {EvtError, SequenceGapError, SnapshotError, isConflict, isRetryable} from './errors.js'
|
|
14
|
-
import
|
|
13
|
+
import type {SerializedEvent} from './event.js'
|
|
15
14
|
import {type Metadata, emptyMetadata} from './metadata.js'
|
|
16
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
CallOptions,
|
|
17
|
+
NewSnapshot,
|
|
18
|
+
ProjectorGroup,
|
|
19
|
+
Repository,
|
|
20
|
+
TransactionalRepository,
|
|
21
|
+
} from './repository.js'
|
|
17
22
|
import {
|
|
18
23
|
DEFAULT_RETRY_POLICY,
|
|
19
24
|
type RetryPolicy,
|
|
@@ -21,7 +26,8 @@ import {
|
|
|
21
26
|
retryDelay,
|
|
22
27
|
sleep,
|
|
23
28
|
} from './retry.js'
|
|
24
|
-
import {
|
|
29
|
+
import {commitSnapshot, projectCommit, serializeEvents} from './staging.js'
|
|
30
|
+
import {Transaction} from './transaction.js'
|
|
25
31
|
|
|
26
32
|
/** The default number of events between snapshots, as in Go and Rust. */
|
|
27
33
|
export const DEFAULT_SNAPSHOT_INTERVAL = 5
|
|
@@ -29,8 +35,6 @@ export const DEFAULT_SNAPSHOT_INTERVAL = 5
|
|
|
29
35
|
// How many times a load reads the stream when it finds a sequence gap.
|
|
30
36
|
const LOAD_ATTEMPTS = 3
|
|
31
37
|
|
|
32
|
-
const INT64_MAX = 2n ** 63n - 1n
|
|
33
|
-
|
|
34
38
|
/** How a {@link Store} fills in what commands leave out. */
|
|
35
39
|
export interface StoreOptions {
|
|
36
40
|
/** The region metadata gets when a command's metadata has none. */
|
|
@@ -146,8 +150,10 @@ export class Store {
|
|
|
146
150
|
|
|
147
151
|
/**
|
|
148
152
|
* Loads the aggregate, runs the command, and commits its events, with a snapshot when the
|
|
149
|
-
* commit reaches the snapshot interval
|
|
150
|
-
* is left out, so the commit still succeeds and
|
|
153
|
+
* commit reaches the snapshot interval, and the aggregate's projector operations. A snapshot
|
|
154
|
+
* whose item is over the per-item commit budget is left out, so the commit still succeeds and
|
|
155
|
+
* later loads replay from the one before, but a projector's own items are budgeted as they are:
|
|
156
|
+
* a view row of a state that large fails the commit.
|
|
151
157
|
*
|
|
152
158
|
* A command ID already carried by an event folded in since the latest snapshot makes the
|
|
153
159
|
* command a `duplicate`, and the handler doesn't run. A conflict with another writer throws;
|
|
@@ -175,8 +181,13 @@ export class Store {
|
|
|
175
181
|
return {kind: 'committed', state: loaded, events: []}
|
|
176
182
|
}
|
|
177
183
|
|
|
178
|
-
const {state, events, snapshot} = this.#stage(
|
|
179
|
-
|
|
184
|
+
const {state, events, snapshot, projections} = this.#stage(
|
|
185
|
+
aggregate,
|
|
186
|
+
loaded,
|
|
187
|
+
decision.events,
|
|
188
|
+
metadata,
|
|
189
|
+
)
|
|
190
|
+
await this.repository.commit({entityId, events, snapshot, projections}, options)
|
|
180
191
|
|
|
181
192
|
return {kind: 'committed', state, events}
|
|
182
193
|
}
|
|
@@ -212,6 +223,28 @@ export class Store {
|
|
|
212
223
|
/* oxlint-enable no-await-in-loop */
|
|
213
224
|
}
|
|
214
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Begins a transaction that reads streams of any aggregate types, appends to some of them, and
|
|
228
|
+
* commits them all at once, with a read fence on every stream it reads but doesn't write. The
|
|
229
|
+
* store's region, clock, commit IDs and snapshot interval apply to it, and each stream's
|
|
230
|
+
* aggregate projectors run in its commit. The repository must be a
|
|
231
|
+
* {@link TransactionalRepository}, as every one this package ships is, or this throws
|
|
232
|
+
* `invalidArgument`. {@link execute} stays the shortcut for one stream.
|
|
233
|
+
*/
|
|
234
|
+
transaction(): Transaction {
|
|
235
|
+
const repository = this.repository
|
|
236
|
+
if (!isTransactional(repository)) {
|
|
237
|
+
throw new EvtError('invalidArgument', 'the repository has no commitTransaction')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return new Transaction({
|
|
241
|
+
repository,
|
|
242
|
+
load: (aggregate, entityId, options) => this.load(aggregate, entityId, options),
|
|
243
|
+
snapshotInterval: (aggregate) => this.snapshotInterval(aggregate),
|
|
244
|
+
metadata: (partial) => this.#metadata(partial),
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
215
248
|
async #loadOnce<S, C, D extends EventDefinitions>(
|
|
216
249
|
aggregate: Aggregate<S, C, D>,
|
|
217
250
|
entityId: string,
|
|
@@ -262,57 +295,30 @@ export class Store {
|
|
|
262
295
|
return {entityId, state, sequence, snapshotSequence, seenCommandIds}
|
|
263
296
|
}
|
|
264
297
|
|
|
265
|
-
// Applies the decided events, and builds the stored events
|
|
266
|
-
//
|
|
298
|
+
// Applies the decided events, and builds the stored events, the snapshot the policy calls for,
|
|
299
|
+
// and the projectors' operations. The state is only used once the commit succeeds.
|
|
267
300
|
#stage<S, C, D extends EventDefinitions>(
|
|
268
301
|
aggregate: Aggregate<S, C, D>,
|
|
269
302
|
loaded: Loaded<S>,
|
|
270
303
|
decided: readonly EventOf<D>[],
|
|
271
304
|
metadata: Metadata,
|
|
272
|
-
): {
|
|
305
|
+
): {
|
|
306
|
+
state: Loaded<S>
|
|
307
|
+
events: SerializedEvent[]
|
|
308
|
+
snapshot: NewSnapshot | null
|
|
309
|
+
projections: ProjectorGroup[]
|
|
310
|
+
} {
|
|
273
311
|
const entityId = loaded.entityId
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
`invalid commit: entity ${entityId} is at the maximum event sequence`,
|
|
283
|
-
)
|
|
284
|
-
}
|
|
285
|
-
sequence++
|
|
286
|
-
|
|
287
|
-
const {version, payload} = encodeEventData(aggregate.events, event)
|
|
288
|
-
events.push({
|
|
289
|
-
id: eventId(entityId, sequence),
|
|
290
|
-
sequence,
|
|
291
|
-
type: event.type,
|
|
292
|
-
version,
|
|
293
|
-
entityId,
|
|
294
|
-
entityType: aggregate.type,
|
|
295
|
-
payload,
|
|
296
|
-
metadata: copyMetadata(metadata),
|
|
297
|
-
})
|
|
298
|
-
state = aggregate.apply(state, event)
|
|
299
|
-
}
|
|
300
|
-
|
|
312
|
+
const {events, state} = serializeEvents(
|
|
313
|
+
aggregate,
|
|
314
|
+
entityId,
|
|
315
|
+
loaded.sequence,
|
|
316
|
+
loaded.state,
|
|
317
|
+
decided,
|
|
318
|
+
metadata,
|
|
319
|
+
)
|
|
301
320
|
const interval = this.snapshotInterval(aggregate)
|
|
302
|
-
|
|
303
|
-
if (snapshotEventsDue(loaded.sequence, decided.length, interval) > 0) {
|
|
304
|
-
snapshot = {
|
|
305
|
-
entityType: aggregate.type,
|
|
306
|
-
sequence: loaded.snapshotSequence + 1n,
|
|
307
|
-
payload: aggregate.state.encode(state),
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// A snapshot over the per-item budget would fail every commit that carries it.
|
|
311
|
-
const size = snapshotCommitSize({...snapshot, entityId, eventSequence: sequence})
|
|
312
|
-
if (size > MAX_COMMIT_ITEM_BYTES) {
|
|
313
|
-
snapshot = null
|
|
314
|
-
}
|
|
315
|
-
}
|
|
321
|
+
const snapshot = commitSnapshot(aggregate, entityId, interval, loaded, events.length, state)
|
|
316
322
|
|
|
317
323
|
const seenCommandIds = new Set(loaded.seenCommandIds)
|
|
318
324
|
if (metadata.commandId !== null && metadata.commandId !== '') {
|
|
@@ -323,12 +329,13 @@ export class Store {
|
|
|
323
329
|
state: {
|
|
324
330
|
entityId,
|
|
325
331
|
state,
|
|
326
|
-
sequence,
|
|
332
|
+
sequence: loaded.sequence + BigInt(events.length),
|
|
327
333
|
snapshotSequence: snapshot ? snapshot.sequence : loaded.snapshotSequence,
|
|
328
334
|
seenCommandIds,
|
|
329
335
|
},
|
|
330
336
|
events,
|
|
331
337
|
snapshot,
|
|
338
|
+
projections: projectCommit(aggregate, entityId, state, events, snapshot),
|
|
332
339
|
}
|
|
333
340
|
}
|
|
334
341
|
|
|
@@ -361,17 +368,11 @@ export class Store {
|
|
|
361
368
|
}
|
|
362
369
|
}
|
|
363
370
|
|
|
364
|
-
// Each stored event gets its own metadata, so changing one changes no other.
|
|
365
|
-
function copyMetadata(metadata: Metadata): Metadata {
|
|
366
|
-
return {
|
|
367
|
-
...metadata,
|
|
368
|
-
trace: metadata.trace && new Map(metadata.trace),
|
|
369
|
-
origin: metadata.origin && {...metadata.origin},
|
|
370
|
-
extensions: new Map(metadata.extensions),
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
371
|
// RFC 3339 in UTC, to the second, as Go's time.RFC3339 formats a UTC time.
|
|
375
372
|
function rfc3339Seconds(ms: number): string {
|
|
376
373
|
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
|
|
377
374
|
}
|
|
375
|
+
|
|
376
|
+
function isTransactional(repository: Repository): repository is TransactionalRepository {
|
|
377
|
+
return typeof (repository as Partial<TransactionalRepository>).commitTransaction === 'function'
|
|
378
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// The Store's multi-stream transaction (ADR 0004 C1, C2): a read set of any aggregate types, the
|
|
2
|
+
// events decided for some of them, and one atomic commit that fences every stream it read but
|
|
3
|
+
// didn't write.
|
|
4
|
+
|
|
5
|
+
import type {Aggregate, EventDefinitions, EventOf} from './aggregate.js'
|
|
6
|
+
import {EvtError} from './errors.js'
|
|
7
|
+
import type {SerializedEvent} from './event.js'
|
|
8
|
+
import {type Metadata, emptyMetadata} from './metadata.js'
|
|
9
|
+
import {
|
|
10
|
+
type CallOptions,
|
|
11
|
+
type Commit,
|
|
12
|
+
type ReadFence,
|
|
13
|
+
type TransactionalRepository,
|
|
14
|
+
checkNotReserved,
|
|
15
|
+
} from './repository.js'
|
|
16
|
+
import {commitSnapshot, copyMetadata, projectCommit, serializeEvents} from './staging.js'
|
|
17
|
+
import type {ExecuteOptions, Loaded} from './store.js'
|
|
18
|
+
import {compareUtf8} from './utf8.js'
|
|
19
|
+
|
|
20
|
+
const MAX_INT64 = 2n ** 63n - 1n
|
|
21
|
+
|
|
22
|
+
// The key of the property that types a handle by its aggregate's events. It exists only in types,
|
|
23
|
+
// and a symbol keeps it out of the way while the published declarations still check it.
|
|
24
|
+
declare const eventTypes: unique symbol
|
|
25
|
+
|
|
26
|
+
/** What a transaction needs from its store. */
|
|
27
|
+
export interface TransactionContext {
|
|
28
|
+
readonly repository: TransactionalRepository
|
|
29
|
+
load<S, C, D extends EventDefinitions>(
|
|
30
|
+
aggregate: Aggregate<S, C, D>,
|
|
31
|
+
entityId: string,
|
|
32
|
+
options?: CallOptions,
|
|
33
|
+
): Promise<Loaded<S>>
|
|
34
|
+
snapshotInterval(aggregate: {readonly snapshotInterval?: number | undefined}): number
|
|
35
|
+
/** The command's metadata with what the store fills in, and the commit ID stamped. */
|
|
36
|
+
metadata(partial?: Partial<Metadata>): Metadata
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A fence the caller stamps on a stream the transaction doesn't read. */
|
|
40
|
+
export interface CallerFence {
|
|
41
|
+
readonly entityId: string
|
|
42
|
+
/** The stream's entity type, which a fence on a retained type is refused for. */
|
|
43
|
+
readonly entityType: string
|
|
44
|
+
/** The head the caller observed: 0 for a stream with no events. */
|
|
45
|
+
readonly head: bigint
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What a transaction's commit did:
|
|
50
|
+
*
|
|
51
|
+
* - `committed`: every stream's events (possibly none) are stored, and every fence held. `events`
|
|
52
|
+
* are the stored events in the order the commit wrote them: by entity ID bytes, then sequence.
|
|
53
|
+
* - `duplicate`: an event a read stream folded in since its latest snapshot already carries the
|
|
54
|
+
* command ID, so nothing was written.
|
|
55
|
+
*/
|
|
56
|
+
export type TransactionResult =
|
|
57
|
+
{kind: 'committed'; events: SerializedEvent[]} | {kind: 'duplicate'; commandId: string}
|
|
58
|
+
|
|
59
|
+
// One stream of the read set: what the read loaded, and what the transaction appended since.
|
|
60
|
+
interface Stream {
|
|
61
|
+
// oxlint-disable-next-line typescript/no-explicit-any -- each stream has its own aggregate type.
|
|
62
|
+
readonly aggregate: Aggregate<any, any, any>
|
|
63
|
+
// The position of its read call, which orders the commit's projector operations.
|
|
64
|
+
readonly order: number
|
|
65
|
+
readonly loaded: Loaded<unknown>
|
|
66
|
+
state: unknown
|
|
67
|
+
events: SerializedEvent[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A stream read into a {@link Transaction}, typed by its aggregate. Pass it to
|
|
72
|
+
* {@link Transaction.append} to stage events for it; a read never appended to is fenced at the
|
|
73
|
+
* head it was read at.
|
|
74
|
+
*/
|
|
75
|
+
export class StreamRead<S, D extends EventDefinitions> {
|
|
76
|
+
readonly #stream: Stream
|
|
77
|
+
// Ties the handle to its aggregate's event types, which only append reads.
|
|
78
|
+
declare readonly [eventTypes]?: D
|
|
79
|
+
|
|
80
|
+
/** @internal Built by {@link Transaction.read}. */
|
|
81
|
+
constructor(stream: Stream) {
|
|
82
|
+
this.#stream = stream
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get entityId(): string {
|
|
86
|
+
return this.#stream.loaded.entityId
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The state, with the events appended through this handle. */
|
|
90
|
+
get state(): S {
|
|
91
|
+
return this.#stream.state as S
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The sequence of the last event, with the events appended through this handle. */
|
|
95
|
+
get sequence(): bigint {
|
|
96
|
+
return this.#stream.loaded.sequence + BigInt(this.#stream.events.length)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The stream as it was read. */
|
|
100
|
+
get loaded(): Loaded<S> {
|
|
101
|
+
return this.#stream.loaded as Loaded<S>
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One atomic commit across several streams. {@link read} loads streams of any aggregate types,
|
|
107
|
+
* {@link append} stages the events decided for them, {@link fence} adds a fence the caller stamped
|
|
108
|
+
* on a stream it didn't read, and {@link commit} writes everything in one repository transaction,
|
|
109
|
+
* or nothing. A stream read but never appended to adds a fence the engine filled at the head it
|
|
110
|
+
* was read at, so the commit fails with a retryable `FenceConflictError` once another writer
|
|
111
|
+
* moves it. Begin one with {@link Store.transaction}.
|
|
112
|
+
*
|
|
113
|
+
* Every staging call checks its input before it changes anything, so one that throws leaves the
|
|
114
|
+
* transaction as it was. A transaction commits once: after {@link commit}, whatever it returned
|
|
115
|
+
* or threw, every call throws `invalidCommit`. Begin a new transaction to try again.
|
|
116
|
+
*/
|
|
117
|
+
export class Transaction {
|
|
118
|
+
readonly #context: TransactionContext
|
|
119
|
+
readonly #streams = new Map<StreamRead<unknown, EventDefinitions>, Stream>()
|
|
120
|
+
// Every stream read or being read, by ID, with the position of its read call.
|
|
121
|
+
readonly #ids = new Map<string, number>()
|
|
122
|
+
readonly #fences = new Map<string, CallerFence>()
|
|
123
|
+
#reads = 0
|
|
124
|
+
#pending = 0
|
|
125
|
+
#done = false
|
|
126
|
+
|
|
127
|
+
/** @internal Begun by {@link Store.transaction}. */
|
|
128
|
+
constructor(context: TransactionContext) {
|
|
129
|
+
this.#context = context
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Loads a stream into the read set, as {@link Store.load} does, and returns its typed handle.
|
|
134
|
+
* Reading a stream the transaction already read, or is reading, throws `invalidCommit`.
|
|
135
|
+
*/
|
|
136
|
+
async read<S, C, D extends EventDefinitions>(
|
|
137
|
+
aggregate: Aggregate<S, C, D>,
|
|
138
|
+
entityId: string,
|
|
139
|
+
options: CallOptions = {},
|
|
140
|
+
): Promise<StreamRead<S, D>> {
|
|
141
|
+
this.#checkOpen()
|
|
142
|
+
if (this.#ids.has(entityId)) {
|
|
143
|
+
throw invalidCommit(`duplicate read of ${entityId}`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The ID is held while the load runs, so a concurrent read of the same stream is refused too,
|
|
147
|
+
// and the read keeps the place it was called in, whenever its load finishes.
|
|
148
|
+
const order = this.#reads++
|
|
149
|
+
this.#ids.set(entityId, order)
|
|
150
|
+
this.#pending++
|
|
151
|
+
let loaded: Loaded<S>
|
|
152
|
+
try {
|
|
153
|
+
loaded = await this.#context.load(aggregate, entityId, options)
|
|
154
|
+
} catch (error) {
|
|
155
|
+
this.#ids.delete(entityId)
|
|
156
|
+
throw error
|
|
157
|
+
} finally {
|
|
158
|
+
this.#pending--
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const stream: Stream = {
|
|
162
|
+
aggregate,
|
|
163
|
+
order,
|
|
164
|
+
loaded,
|
|
165
|
+
state: loaded.state,
|
|
166
|
+
events: [],
|
|
167
|
+
}
|
|
168
|
+
const read = new StreamRead<S, D>(stream)
|
|
169
|
+
this.#streams.set(read as StreamRead<unknown, EventDefinitions>, stream)
|
|
170
|
+
|
|
171
|
+
return read
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Stages events for a stream this transaction read. They're applied to the handle's state, and
|
|
176
|
+
* appending again continues the sequence. The commit stamps their metadata, and runs the
|
|
177
|
+
* aggregate's projectors over every event appended to the stream, with its final state. A
|
|
178
|
+
* handle from another transaction throws `invalidCommit`.
|
|
179
|
+
*/
|
|
180
|
+
append<S, D extends EventDefinitions>(
|
|
181
|
+
read: StreamRead<S, D>,
|
|
182
|
+
events: readonly EventOf<D>[],
|
|
183
|
+
): this {
|
|
184
|
+
this.#checkOpen()
|
|
185
|
+
const stream = this.#streams.get(read as StreamRead<unknown, EventDefinitions>)
|
|
186
|
+
if (stream === undefined) {
|
|
187
|
+
throw invalidCommit(`stream ${read.entityId} was not read by this transaction`)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Serialized now, so an event the aggregate can't encode fails here, with nothing staged.
|
|
191
|
+
const after = stream.loaded.sequence + BigInt(stream.events.length)
|
|
192
|
+
const staged = serializeEvents(
|
|
193
|
+
stream.aggregate,
|
|
194
|
+
stream.loaded.entityId,
|
|
195
|
+
after,
|
|
196
|
+
stream.state,
|
|
197
|
+
events,
|
|
198
|
+
emptyMetadata(),
|
|
199
|
+
)
|
|
200
|
+
stream.events.push(...staged.events)
|
|
201
|
+
stream.state = staged.state
|
|
202
|
+
|
|
203
|
+
return this
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Adds a fence the caller stamped, for a stream the transaction doesn't read: the commit fails
|
|
208
|
+
* with a `FenceConflictError` once another writer moves the stream past `head`, which isn't
|
|
209
|
+
* retryable, since only the caller knows what it read. A fence on a stream the transaction reads,
|
|
210
|
+
* before or after the read, is dropped at commit: the read's own fence, or its write, holds the
|
|
211
|
+
* stream instead. A fence in a reserved namespace, at a head outside [0, 2^63 - 1), or on a
|
|
212
|
+
* stream already fenced throws, with nothing added.
|
|
213
|
+
*/
|
|
214
|
+
fence(fence: CallerFence): this {
|
|
215
|
+
this.#checkOpen()
|
|
216
|
+
checkNotReserved(fence.entityId)
|
|
217
|
+
if (fence.head < 0n || fence.head >= MAX_INT64 || this.#fences.has(fence.entityId)) {
|
|
218
|
+
throw invalidCommit(`invalid or duplicate fence ${fence.entityId}`)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.#fences.set(fence.entityId, {...fence})
|
|
222
|
+
|
|
223
|
+
return this
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Stamps one commit ID and the command's metadata on every appended event, and commits every
|
|
228
|
+
* stream with events, a fence on every stream read without any, and the caller's fences, in one
|
|
229
|
+
* atomic repository transaction. On failure nothing is written. A command ID that an event a
|
|
230
|
+
* read stream folded in since its latest snapshot already carries is a `duplicate`, and nothing
|
|
231
|
+
* is written. Projector operations follow the streams in the order they were read. A commit
|
|
232
|
+
* while a read is still loading throws `invalidCommit`, and leaves the transaction open.
|
|
233
|
+
*/
|
|
234
|
+
async commit(options: ExecuteOptions = {}): Promise<TransactionResult> {
|
|
235
|
+
this.#checkOpen()
|
|
236
|
+
if (this.#pending > 0) {
|
|
237
|
+
throw invalidCommit('a read is still loading; await it before the commit')
|
|
238
|
+
}
|
|
239
|
+
this.#done = true
|
|
240
|
+
|
|
241
|
+
const metadata = this.#context.metadata(options.metadata)
|
|
242
|
+
const commandId = metadata.commandId
|
|
243
|
+
const streams = [...this.#streams.values()]
|
|
244
|
+
streams.sort((a, b) => a.order - b.order)
|
|
245
|
+
if (
|
|
246
|
+
commandId !== null &&
|
|
247
|
+
commandId !== '' &&
|
|
248
|
+
streams.some((stream) => stream.loaded.seenCommandIds.has(commandId))
|
|
249
|
+
) {
|
|
250
|
+
return {kind: 'duplicate', commandId}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const commits: Commit[] = []
|
|
254
|
+
const fences: ReadFence[] = []
|
|
255
|
+
for (const stream of streams) {
|
|
256
|
+
const {aggregate, loaded} = stream
|
|
257
|
+
if (stream.events.length === 0) {
|
|
258
|
+
fences.push({
|
|
259
|
+
entityId: loaded.entityId,
|
|
260
|
+
entityType: aggregate.type,
|
|
261
|
+
head: loaded.sequence,
|
|
262
|
+
provenance: 'engine',
|
|
263
|
+
})
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (const event of stream.events) {
|
|
268
|
+
event.metadata = copyMetadata(metadata)
|
|
269
|
+
}
|
|
270
|
+
const interval = this.#context.snapshotInterval(aggregate)
|
|
271
|
+
const appended = stream.events.length
|
|
272
|
+
const snapshot = commitSnapshot(
|
|
273
|
+
aggregate,
|
|
274
|
+
loaded.entityId,
|
|
275
|
+
interval,
|
|
276
|
+
loaded,
|
|
277
|
+
appended,
|
|
278
|
+
stream.state,
|
|
279
|
+
)
|
|
280
|
+
commits.push({
|
|
281
|
+
entityId: loaded.entityId,
|
|
282
|
+
events: stream.events,
|
|
283
|
+
snapshot,
|
|
284
|
+
projections: projectCommit(
|
|
285
|
+
aggregate,
|
|
286
|
+
loaded.entityId,
|
|
287
|
+
stream.state,
|
|
288
|
+
stream.events,
|
|
289
|
+
snapshot,
|
|
290
|
+
),
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// A caller's fence on a stream the transaction read is superseded by the read's own.
|
|
295
|
+
for (const fence of this.#fences.values()) {
|
|
296
|
+
if (!this.#ids.has(fence.entityId)) {
|
|
297
|
+
fences.push({...fence, provenance: 'caller'})
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
await this.#context.repository.commitTransaction({streams: commits, fences}, options)
|
|
302
|
+
|
|
303
|
+
// Sorted apart from what the repository was given, which stays in read order.
|
|
304
|
+
const written = [...commits]
|
|
305
|
+
written.sort((a, b) => compareUtf8(a.entityId, b.entityId))
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
kind: 'committed',
|
|
309
|
+
events: written.flatMap((commit) => commit.events),
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
#checkOpen(): void {
|
|
314
|
+
if (this.#done) {
|
|
315
|
+
throw invalidCommit('the transaction has committed; begin a new one')
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function invalidCommit(message: string): EvtError {
|
|
321
|
+
return new EvtError('invalidCommit', `invalid commit: ${message}`)
|
|
322
|
+
}
|