@photon-grove/evt 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +176 -0
- package/README.md +122 -2
- package/dist/aggregate.d.ts +83 -0
- package/dist/aggregate.d.ts.map +1 -0
- package/dist/aggregate.js +92 -0
- package/dist/aggregate.js.map +1 -0
- package/dist/base64.d.ts +8 -0
- package/dist/base64.d.ts.map +1 -0
- package/dist/base64.js +67 -0
- package/dist/base64.js.map +1 -0
- package/dist/codec.d.ts +56 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +90 -0
- package/dist/codec.js.map +1 -0
- package/dist/commit-id.d.ts +3 -0
- package/dist/commit-id.d.ts.map +1 -0
- package/dist/commit-id.js +43 -0
- package/dist/commit-id.js.map +1 -0
- package/dist/dynamodb/head.d.ts +34 -0
- package/dist/dynamodb/head.d.ts.map +1 -0
- package/dist/dynamodb/head.js +62 -0
- package/dist/dynamodb/head.js.map +1 -0
- package/dist/dynamodb/index.d.ts +2 -0
- package/dist/dynamodb/index.d.ts.map +1 -0
- package/dist/dynamodb/index.js +4 -0
- package/dist/dynamodb/index.js.map +1 -0
- package/dist/dynamodb/item.d.ts +68 -0
- package/dist/dynamodb/item.d.ts.map +1 -0
- package/dist/dynamodb/item.js +263 -0
- package/dist/dynamodb/item.js.map +1 -0
- package/dist/envelope.d.ts +15 -0
- package/dist/envelope.d.ts.map +1 -0
- package/dist/envelope.js +59 -0
- package/dist/envelope.js.map +1 -0
- package/dist/errors.d.ts +111 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +139 -0
- package/dist/errors.js.map +1 -0
- package/dist/event.d.ts +47 -0
- package/dist/event.d.ts.map +1 -0
- package/dist/event.js +64 -0
- package/dist/event.js.map +1 -0
- package/dist/fields.d.ts +31 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +105 -0
- package/dist/fields.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/json.d.ts +47 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +458 -0
- package/dist/json.js.map +1 -0
- package/dist/memory/index.d.ts +2 -0
- package/dist/memory/index.d.ts.map +1 -0
- package/dist/memory/index.js +3 -0
- package/dist/memory/index.js.map +1 -0
- package/dist/memory/repository.d.ts +34 -0
- package/dist/memory/repository.d.ts.map +1 -0
- package/dist/memory/repository.js +219 -0
- package/dist/memory/repository.js.map +1 -0
- package/dist/metadata.d.ts +47 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +75 -0
- package/dist/metadata.js.map +1 -0
- package/dist/repository.d.ts +102 -0
- package/dist/repository.d.ts.map +1 -0
- package/dist/repository.js +131 -0
- package/dist/repository.js.map +1 -0
- package/dist/retry.d.ts +25 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +50 -0
- package/dist/retry.js.map +1 -0
- package/dist/snapshots.d.ts +21 -0
- package/dist/snapshots.d.ts.map +1 -0
- package/dist/snapshots.js +35 -0
- package/dist/snapshots.js.map +1 -0
- package/dist/store.d.ts +103 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +246 -0
- package/dist/store.js.map +1 -0
- package/dist/testing/equal.d.ts +22 -0
- package/dist/testing/equal.d.ts.map +1 -0
- package/dist/testing/equal.js +121 -0
- package/dist/testing/equal.js.map +1 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/scenario.d.ts +136 -0
- package/dist/testing/scenario.d.ts.map +1 -0
- package/dist/testing/scenario.js +307 -0
- package/dist/testing/scenario.js.map +1 -0
- package/dist/utf8.d.ts +17 -0
- package/dist/utf8.d.ts.map +1 -0
- package/dist/utf8.js +82 -0
- package/dist/utf8.js.map +1 -0
- package/package.json +56 -4
- package/src/aggregate.ts +196 -0
- package/src/base64.ts +80 -0
- package/src/codec.ts +139 -0
- package/src/commit-id.ts +50 -0
- package/src/dynamodb/head.ts +93 -0
- package/src/dynamodb/index.ts +21 -0
- package/src/dynamodb/item.ts +332 -0
- package/src/envelope.ts +67 -0
- package/src/errors.ts +214 -0
- package/src/event.ts +117 -0
- package/src/fields.ts +134 -0
- package/src/globals.d.ts +39 -0
- package/src/index.ts +75 -0
- package/src/json.ts +546 -0
- package/src/memory/index.ts +3 -0
- package/src/memory/repository.ts +313 -0
- package/src/metadata.ts +120 -0
- package/src/repository.ts +247 -0
- package/src/retry.ts +74 -0
- package/src/snapshots.ts +42 -0
- package/src/store.ts +377 -0
- package/src/testing/equal.ts +146 -0
- package/src/testing/index.ts +14 -0
- package/src/testing/scenario.ts +406 -0
- package/src/utf8.ts +92 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// The in-memory repository. It enforces the storage contract the way the live backends do: a head
|
|
2
|
+
// per stream that every commit raises, optimistic concurrency against it, and the same failure
|
|
3
|
+
// classification and transaction budget. It's for tests and local development.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ConflictError,
|
|
7
|
+
CompactionUncoveredError,
|
|
8
|
+
EvtError,
|
|
9
|
+
SequenceGapError,
|
|
10
|
+
StreamHeadDriftError,
|
|
11
|
+
} from '../errors.js'
|
|
12
|
+
import type {SerializedEvent, SerializedSnapshot} from '../event.js'
|
|
13
|
+
import {int64Argument} from '../fields.js'
|
|
14
|
+
import type {Metadata} from '../metadata.js'
|
|
15
|
+
import {
|
|
16
|
+
type CallOptions,
|
|
17
|
+
type Commit,
|
|
18
|
+
type Repository,
|
|
19
|
+
checkBudget,
|
|
20
|
+
checkNotReserved,
|
|
21
|
+
commitSizes,
|
|
22
|
+
validateCommit,
|
|
23
|
+
} from '../repository.js'
|
|
24
|
+
|
|
25
|
+
interface Stream {
|
|
26
|
+
/** The stream's events by sequence. Compaction deletes from it. */
|
|
27
|
+
events: Map<bigint, SerializedEvent>
|
|
28
|
+
snapshot: SerializedSnapshot | null
|
|
29
|
+
/** The highest event sequence ever committed to the stream, 0 while it has none. */
|
|
30
|
+
head: bigint
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Reads a repository's streams, for headFixtures. The class's static block sets it.
|
|
34
|
+
let streamsOf: (repo: MemRepository) => Map<string, Stream>
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* An in-memory {@link Repository}. Every call copies what it stores and what it returns, so a
|
|
38
|
+
* caller that changes an event after committing it, or one it read, changes nothing stored.
|
|
39
|
+
*
|
|
40
|
+
* Each call runs to completion before another starts, so concurrent commits to one stream behave
|
|
41
|
+
* like racing writers on a live backend: one wins and the rest fail with a conflict.
|
|
42
|
+
*/
|
|
43
|
+
export class MemRepository implements Repository {
|
|
44
|
+
readonly #streams = new Map<string, Stream>()
|
|
45
|
+
|
|
46
|
+
async commit(commit: Commit, options?: CallOptions): Promise<void> {
|
|
47
|
+
options?.signal?.throwIfAborted()
|
|
48
|
+
validateCommit(commit)
|
|
49
|
+
checkNotReserved(commit.entityId)
|
|
50
|
+
if (commit.events.length === 0) {
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Every backend budgets a commit the same way, so an oversized one fails before any write.
|
|
55
|
+
checkBudget(commitSizes(commit, () => undefined))
|
|
56
|
+
|
|
57
|
+
const held = this.#streams.get(commit.entityId) ?? emptyStream()
|
|
58
|
+
checkWrite(commit, held)
|
|
59
|
+
|
|
60
|
+
// Copy everything before the first write, so a copy that throws leaves the stream as it was.
|
|
61
|
+
const events = commit.events.map(cloneEvent)
|
|
62
|
+
const last = events.at(-1)!.sequence
|
|
63
|
+
const snapshot = commit.snapshot && {
|
|
64
|
+
entityType: commit.snapshot.entityType,
|
|
65
|
+
entityId: commit.entityId,
|
|
66
|
+
sequence: commit.snapshot.sequence,
|
|
67
|
+
eventSequence: last,
|
|
68
|
+
payload: copy(commit.snapshot.payload),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const event of events) {
|
|
72
|
+
held.events.set(event.sequence, event)
|
|
73
|
+
}
|
|
74
|
+
held.head = held.head > last ? held.head : last
|
|
75
|
+
if (snapshot) {
|
|
76
|
+
held.snapshot = snapshot
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this.#streams.set(commit.entityId, held)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async events(entityId: string, options?: CallOptions): Promise<SerializedEvent[]> {
|
|
83
|
+
return this.eventsAfter(entityId, 0n, options)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async eventsAfter(
|
|
87
|
+
entityId: string,
|
|
88
|
+
after: bigint,
|
|
89
|
+
options?: CallOptions,
|
|
90
|
+
): Promise<SerializedEvent[]> {
|
|
91
|
+
options?.signal?.throwIfAborted()
|
|
92
|
+
int64Argument(after, 'after')
|
|
93
|
+
|
|
94
|
+
return this.#sorted(entityId)
|
|
95
|
+
.filter((event) => event.sequence > after)
|
|
96
|
+
.map(cloneEvent)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async latest(entityId: string, n: number, options?: CallOptions): Promise<SerializedEvent[]> {
|
|
100
|
+
options?.signal?.throwIfAborted()
|
|
101
|
+
if (!Number.isSafeInteger(n) || n < 1) {
|
|
102
|
+
throw new EvtError(
|
|
103
|
+
'invalidArgument',
|
|
104
|
+
`latest(n) requires a whole number of at least one event: got n = ${n}`,
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return this.#sorted(entityId, true).slice(0, n).map(cloneEvent)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async snapshot(entityId: string, options?: CallOptions): Promise<SerializedSnapshot | null> {
|
|
112
|
+
options?.signal?.throwIfAborted()
|
|
113
|
+
const snapshot = this.#streams.get(entityId)?.snapshot
|
|
114
|
+
|
|
115
|
+
return snapshot ? cloneSnapshot(snapshot) : null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async putSnapshot(snapshot: SerializedSnapshot, options?: CallOptions): Promise<boolean> {
|
|
119
|
+
options?.signal?.throwIfAborted()
|
|
120
|
+
checkNotReserved(snapshot.entityId)
|
|
121
|
+
int64Argument(snapshot.sequence, 'snapshot.sequence')
|
|
122
|
+
int64Argument(snapshot.eventSequence, 'snapshot.eventSequence')
|
|
123
|
+
|
|
124
|
+
// The stream's head must cover the snapshot. One of an empty stream (event sequence 0) may
|
|
125
|
+
// have no head at all.
|
|
126
|
+
const held = this.#streams.get(snapshot.entityId)
|
|
127
|
+
const head = held?.head ?? 0n
|
|
128
|
+
if (snapshot.eventSequence > 0n && head < snapshot.eventSequence) {
|
|
129
|
+
const derived = held ? derivedHead(held) : 0n
|
|
130
|
+
throw drift(
|
|
131
|
+
snapshot.entityId,
|
|
132
|
+
head,
|
|
133
|
+
derived > snapshot.eventSequence ? derived : snapshot.eventSequence,
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const stored = held?.snapshot
|
|
138
|
+
if (stored && stored.eventSequence > snapshot.eventSequence) {
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const stream = held ?? emptyStream()
|
|
143
|
+
stream.snapshot = cloneSnapshot(snapshot)
|
|
144
|
+
this.#streams.set(snapshot.entityId, stream)
|
|
145
|
+
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async compactBelow(entityId: string, through: bigint, options?: CallOptions): Promise<number> {
|
|
150
|
+
options?.signal?.throwIfAborted()
|
|
151
|
+
checkNotReserved(entityId)
|
|
152
|
+
if (int64Argument(through, 'through') < 1n) {
|
|
153
|
+
return 0
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const stream = this.#streams.get(entityId)
|
|
157
|
+
const covered = stream?.snapshot?.eventSequence ?? null
|
|
158
|
+
if (stream === undefined || covered === null || covered < through) {
|
|
159
|
+
throw new CompactionUncoveredError(entityId, through, covered)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let deleted = 0
|
|
163
|
+
for (const sequence of stream.events.keys()) {
|
|
164
|
+
if (sequence <= through) {
|
|
165
|
+
stream.events.delete(sequence)
|
|
166
|
+
deleted++
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return deleted
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
static {
|
|
174
|
+
streamsOf = (repo) => repo.#streams
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The stream's events by sequence, ascending or descending. Sequences are unique.
|
|
178
|
+
#sorted(entityId: string, descending = false): SerializedEvent[] {
|
|
179
|
+
const events = Array.from(this.#streams.get(entityId)?.events.values() ?? [])
|
|
180
|
+
const order = descending ? -1 : 1
|
|
181
|
+
events.sort((a, b) => (a.sequence < b.sequence ? -order : order))
|
|
182
|
+
|
|
183
|
+
return events
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Refuses a commit the stored stream doesn't accept, as the live backends' conditions do. The
|
|
188
|
+
// head update needs no head for a create and the head to be the sequence before the first event
|
|
189
|
+
// for an append. Then no event may exist already, and a snapshot must follow the stored one.
|
|
190
|
+
function checkWrite(commit: Commit, held: Stream): void {
|
|
191
|
+
const first = commit.events[0]!.sequence
|
|
192
|
+
const headPasses = first <= 1n ? held.head === 0n : held.head === first - 1n
|
|
193
|
+
if (!headPasses) {
|
|
194
|
+
throw probe(commit.entityId, held, first)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The head update passed and an event put would fail: an event sits above the head.
|
|
198
|
+
if (commit.events.some((event) => held.events.has(event.sequence))) {
|
|
199
|
+
throw drift(commit.entityId, held.head, derivedHead(held))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const snapshot = commit.snapshot
|
|
203
|
+
const stored = held.snapshot?.sequence ?? 0n
|
|
204
|
+
if (snapshot && snapshot.sequence !== stored + 1n) {
|
|
205
|
+
throw new ConflictError(
|
|
206
|
+
commit.entityId,
|
|
207
|
+
`snapshot sequence moved: stored ${stored}, commit writes ${snapshot.sequence}`,
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Classifies a failed head update the way the live backends' failure-path probe does: a stored
|
|
213
|
+
// head that disagrees with the head derived from the log is drift. Memory has no concurrent
|
|
214
|
+
// writes, so a second derivation would find the same head. A healthy stream fails with a gap when
|
|
215
|
+
// the commit skips past its head, and with a conflict otherwise.
|
|
216
|
+
function probe(entityId: string, held: Stream, first: bigint): EvtError {
|
|
217
|
+
const derived = derivedHead(held)
|
|
218
|
+
if (held.head !== derived) {
|
|
219
|
+
return drift(entityId, held.head, derived)
|
|
220
|
+
}
|
|
221
|
+
if (held.head < first - 1n) {
|
|
222
|
+
return new SequenceGapError(entityId, held.head, first)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return new ConflictError(
|
|
226
|
+
entityId,
|
|
227
|
+
'conditional check failed: the sequence or snapshot changed since it was read',
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// The head a stream's log implies: the higher of its last event and its snapshot's event sequence.
|
|
232
|
+
function derivedHead(stream: Stream): bigint {
|
|
233
|
+
let head = stream.snapshot?.eventSequence ?? 0n
|
|
234
|
+
for (const sequence of stream.events.keys()) {
|
|
235
|
+
if (sequence > head) {
|
|
236
|
+
head = sequence
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return head
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// A stored head of 0 is an absent head.
|
|
244
|
+
function drift(entityId: string, stored: bigint, derived: bigint): StreamHeadDriftError {
|
|
245
|
+
return new StreamHeadDriftError(entityId, stored > 0n ? stored : null, derived)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** What a conformance suite needs to make a stream drift, which no repository call can do. */
|
|
249
|
+
export interface HeadFixtures {
|
|
250
|
+
/** Appends an event without raising its stream's head, as a pre-heads writer would. */
|
|
251
|
+
appendBypassingHead(event: SerializedEvent): void
|
|
252
|
+
/** Deletes a stream's head, leaving its events and snapshot. */
|
|
253
|
+
deleteHead(entityId: string): void
|
|
254
|
+
/** A stream's stored head, or null when it has none. */
|
|
255
|
+
storedHead(entityId: string): bigint | null
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Hooks that corrupt a repository's heads, for conformance suites only: a stream they touch
|
|
260
|
+
* drifts, and every later write to it fails.
|
|
261
|
+
*/
|
|
262
|
+
export function headFixtures(repo: MemRepository): HeadFixtures {
|
|
263
|
+
const streams = streamsOf(repo)
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
appendBypassingHead(event) {
|
|
267
|
+
const stream = streams.get(event.entityId) ?? emptyStream()
|
|
268
|
+
stream.events.set(event.sequence, cloneEvent(event))
|
|
269
|
+
streams.set(event.entityId, stream)
|
|
270
|
+
},
|
|
271
|
+
deleteHead(entityId) {
|
|
272
|
+
const stream = streams.get(entityId)
|
|
273
|
+
if (stream) {
|
|
274
|
+
stream.head = 0n
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
storedHead(entityId) {
|
|
278
|
+
const head = streams.get(entityId)?.head ?? 0n
|
|
279
|
+
|
|
280
|
+
return head > 0n ? head : null
|
|
281
|
+
},
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function emptyStream(): Stream {
|
|
286
|
+
return {events: new Map(), snapshot: null, head: 0n}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function cloneEvent(event: SerializedEvent): SerializedEvent {
|
|
290
|
+
return {
|
|
291
|
+
...event,
|
|
292
|
+
payload: event.payload && copy(event.payload),
|
|
293
|
+
metadata: cloneMetadata(event.metadata),
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function cloneMetadata(metadata: Metadata): Metadata {
|
|
298
|
+
return {
|
|
299
|
+
...metadata,
|
|
300
|
+
trace: metadata.trace && new Map(metadata.trace),
|
|
301
|
+
origin: metadata.origin && {...metadata.origin},
|
|
302
|
+
extensions: new Map(metadata.extensions),
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function cloneSnapshot(snapshot: SerializedSnapshot): SerializedSnapshot {
|
|
307
|
+
return {...snapshot, payload: snapshot.payload && copy(snapshot.payload)}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// A copy of the bytes. Not slice(): a Node Buffer's slice is a view of the same memory.
|
|
311
|
+
function copy(bytes: Uint8Array): Uint8Array {
|
|
312
|
+
return new Uint8Array(bytes)
|
|
313
|
+
}
|
package/src/metadata.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Event metadata: the envelope fields stored with every event, mirroring Go's evt.Metadata.
|
|
2
|
+
|
|
3
|
+
import {DOCUMENT, nullableString, objectOrNull, stringField, stringMap} from './fields.js'
|
|
4
|
+
import {type JsonValue, parseJson, stringifyJson} from './json.js'
|
|
5
|
+
|
|
6
|
+
/** The system and entry point that initiated a command. */
|
|
7
|
+
export interface Origin {
|
|
8
|
+
source: string
|
|
9
|
+
endpoint: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Metadata stored with each event. Every key is always written, in this order, with absent values
|
|
14
|
+
* as `null` (or `""` for `region`, `timestamp` and `commitId`, and `{}` for `extensions`).
|
|
15
|
+
*/
|
|
16
|
+
export interface Metadata {
|
|
17
|
+
/** The command that produced the event. Used to reject duplicate commands. */
|
|
18
|
+
commandId: string | null
|
|
19
|
+
/** The W3C trace-context carrier. Its keys are written sorted. */
|
|
20
|
+
trace: Map<string, string> | null
|
|
21
|
+
origin: Origin | null
|
|
22
|
+
/** An optional caller address. Decoding keeps a stored `""`. */
|
|
23
|
+
address: string | null
|
|
24
|
+
/** The region the command ran in. */
|
|
25
|
+
region: string
|
|
26
|
+
/** RFC 3339 UTC with second precision, such as `2026-01-02T03:04:05Z`. */
|
|
27
|
+
timestamp: string
|
|
28
|
+
/** Who issued the command. */
|
|
29
|
+
principalId: string | null
|
|
30
|
+
/**
|
|
31
|
+
* The command that caused this one. The encoder writes the `commandId` when it is null, and
|
|
32
|
+
* decoding leaves a missing value null.
|
|
33
|
+
*/
|
|
34
|
+
causationId: string | null
|
|
35
|
+
/** String-keyed application metadata. Its keys are written sorted. */
|
|
36
|
+
extensions: Map<string, string>
|
|
37
|
+
/** The commit that wrote the event, set by the engine. */
|
|
38
|
+
commitId: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Metadata with every field at its zero value: what `{}` or `null` decodes to. */
|
|
42
|
+
export function emptyMetadata(): Metadata {
|
|
43
|
+
return {
|
|
44
|
+
commandId: null,
|
|
45
|
+
trace: null,
|
|
46
|
+
origin: null,
|
|
47
|
+
address: null,
|
|
48
|
+
region: '',
|
|
49
|
+
timestamp: '',
|
|
50
|
+
principalId: null,
|
|
51
|
+
causationId: null,
|
|
52
|
+
extensions: new Map(),
|
|
53
|
+
commitId: '',
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The causation ID the encoder writes: the one set, otherwise the command ID. */
|
|
58
|
+
export function effectiveCausationId(metadata: Metadata): string | null {
|
|
59
|
+
return metadata.causationId ?? metadata.commandId
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Encodes metadata as Go's canonical JSON. */
|
|
63
|
+
export function encodeMetadata(metadata: Metadata): string {
|
|
64
|
+
return stringifyJson(metadataJson(metadata))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Decodes metadata JSON leniently, as Go does. */
|
|
68
|
+
export function decodeMetadata(json: string): Metadata {
|
|
69
|
+
return metadataFromJson(parseJson(json, DOCUMENT))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The metadata's JSON shape, for documents that embed it. */
|
|
73
|
+
export function metadataJson(metadata: Metadata): object {
|
|
74
|
+
return {
|
|
75
|
+
commandId: metadata.commandId,
|
|
76
|
+
trace: metadata.trace,
|
|
77
|
+
origin: metadata.origin && {source: metadata.origin.source, endpoint: metadata.origin.endpoint},
|
|
78
|
+
address: metadata.address,
|
|
79
|
+
region: metadata.region,
|
|
80
|
+
timestamp: metadata.timestamp,
|
|
81
|
+
principalId: metadata.principalId,
|
|
82
|
+
causationId: effectiveCausationId(metadata),
|
|
83
|
+
extensions: metadata.extensions,
|
|
84
|
+
commitId: metadata.commitId,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Decodes metadata from a parsed value. Null is the zero value. */
|
|
89
|
+
export function metadataFromJson(value: JsonValue | undefined): Metadata {
|
|
90
|
+
const object = objectOrNull(value, 'metadata')
|
|
91
|
+
const metadata = emptyMetadata()
|
|
92
|
+
if (object === null) {
|
|
93
|
+
return metadata
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
metadata.commandId = nullableString(object, 'commandId', 'metadata')
|
|
97
|
+
metadata.trace = stringMap(object.get('trace'), 'metadata.trace')
|
|
98
|
+
metadata.origin = origin(object.get('origin'))
|
|
99
|
+
metadata.address = nullableString(object, 'address', 'metadata')
|
|
100
|
+
metadata.region = stringField(object, 'region', 'metadata')
|
|
101
|
+
metadata.timestamp = stringField(object, 'timestamp', 'metadata')
|
|
102
|
+
metadata.principalId = nullableString(object, 'principalId', 'metadata')
|
|
103
|
+
metadata.causationId = nullableString(object, 'causationId', 'metadata')
|
|
104
|
+
metadata.extensions = stringMap(object.get('extensions'), 'metadata.extensions') ?? new Map()
|
|
105
|
+
metadata.commitId = stringField(object, 'commitId', 'metadata')
|
|
106
|
+
|
|
107
|
+
return metadata
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function origin(value: JsonValue | undefined): Origin | null {
|
|
111
|
+
const object = objectOrNull(value, 'metadata.origin')
|
|
112
|
+
if (object === null) {
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
source: stringField(object, 'source', 'metadata.origin'),
|
|
118
|
+
endpoint: stringField(object, 'endpoint', 'metadata.origin'),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// The storage contract every backend implements, and the commit rules every backend applies the
|
|
2
|
+
// same way: validation, the reserved namespaces, and the transaction budget.
|
|
3
|
+
|
|
4
|
+
import {headActionSize, headUpdate} from './dynamodb/head.js'
|
|
5
|
+
import {eventItem, itemSize, snapshotItem} from './dynamodb/item.js'
|
|
6
|
+
import {CommitTooLargeError, EvtError, ReservedEntityIdError} from './errors.js'
|
|
7
|
+
import {type SerializedEvent, type SerializedSnapshot, eventId} from './event.js'
|
|
8
|
+
import {MAX_COMMIT_ITEM_BYTES} from './snapshots.js'
|
|
9
|
+
|
|
10
|
+
/** A snapshot to write with a commit. Its event sequence is the commit's last event. */
|
|
11
|
+
export interface NewSnapshot {
|
|
12
|
+
entityType: string
|
|
13
|
+
/** The stored snapshot's sequence plus one, or 1 for the stream's first snapshot. */
|
|
14
|
+
sequence: bigint
|
|
15
|
+
/** The aggregate state after the commit's events. */
|
|
16
|
+
payload: Uint8Array
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Everything a repository writes atomically for one command. A commit with no events is a no-op,
|
|
21
|
+
* and its snapshot is ignored.
|
|
22
|
+
*/
|
|
23
|
+
export interface Commit {
|
|
24
|
+
/** The stream the events are appended to. */
|
|
25
|
+
entityId: string
|
|
26
|
+
/** The new events, in ascending sequence order. */
|
|
27
|
+
events: SerializedEvent[]
|
|
28
|
+
/** The snapshot to write, when the commit reaches a snapshot boundary. */
|
|
29
|
+
snapshot?: NewSnapshot | null | undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Options every repository call takes. */
|
|
33
|
+
export interface CallOptions {
|
|
34
|
+
/** Aborts the call. An abort before the call starts rejects with the signal's reason. */
|
|
35
|
+
signal?: AbortSignal | undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* An append-only event log with one optional snapshot and one head per stream (ADR 0007). Every
|
|
40
|
+
* backend must:
|
|
41
|
+
*
|
|
42
|
+
* - refuse a commit built wrong ({@link validateCommit}) with `invalidCommit`;
|
|
43
|
+
* - keep a head per stream, the highest event sequence ever committed to it, which a commit
|
|
44
|
+
* raises to its last event and compaction never touches;
|
|
45
|
+
* - refuse a commit whose first event doesn't follow the stored head with a
|
|
46
|
+
* {@link ConflictError}, or with a {@link SequenceGapError} when it skips past the head;
|
|
47
|
+
* - report a stream whose head disagrees with its events or snapshot as a
|
|
48
|
+
* {@link StreamHeadDriftError}, ranked above a gap and any other conflict;
|
|
49
|
+
* - refuse a commit's snapshot whose sequence isn't the stored snapshot's plus one (1 with none
|
|
50
|
+
* stored) with a {@link ConflictError};
|
|
51
|
+
* - write a commit's events, head and snapshot atomically, or nothing;
|
|
52
|
+
* - refuse an entity ID in a reserved namespace on every write path, before any request;
|
|
53
|
+
* - return events in ascending sequence order, and `latest` in descending order.
|
|
54
|
+
*/
|
|
55
|
+
export interface Repository {
|
|
56
|
+
/** Appends a commit's events, and its snapshot if it has one, atomically. */
|
|
57
|
+
commit(commit: Commit, options?: CallOptions): Promise<void>
|
|
58
|
+
|
|
59
|
+
/** Every stored event of the stream, ascending. */
|
|
60
|
+
events(entityId: string, options?: CallOptions): Promise<SerializedEvent[]>
|
|
61
|
+
|
|
62
|
+
/** The stream's events after sequence `after`, ascending. */
|
|
63
|
+
eventsAfter(entityId: string, after: bigint, options?: CallOptions): Promise<SerializedEvent[]>
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The stream's last `n` events, newest first, never the snapshot. `n` must be a safe integer of
|
|
67
|
+
* at least 1, or the call fails with `invalidArgument` before any request.
|
|
68
|
+
*/
|
|
69
|
+
latest(entityId: string, n: number, options?: CallOptions): Promise<SerializedEvent[]>
|
|
70
|
+
|
|
71
|
+
/** The stream's snapshot, or null when it has none. */
|
|
72
|
+
snapshot(entityId: string, options?: CallOptions): Promise<SerializedSnapshot | null>
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Writes a snapshot outside a commit, such as a catch-up snapshot. It never lowers the stored
|
|
76
|
+
* snapshot's event sequence: it replaces a snapshot at or below it and resolves `true`, or
|
|
77
|
+
* leaves a newer one in place and resolves `false`. The stream's head must cover the snapshot,
|
|
78
|
+
* or the write fails with a {@link StreamHeadDriftError}. A snapshot at event sequence 0 needs no
|
|
79
|
+
* head.
|
|
80
|
+
*/
|
|
81
|
+
putSnapshot(snapshot: SerializedSnapshot, options?: CallOptions): Promise<boolean>
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Deletes the stream's events with sequences 1 to `through`, but only when the stored snapshot
|
|
85
|
+
* covers them (its event sequence is at least `through`), and resolves to how many it deleted.
|
|
86
|
+
* It never deletes the snapshot or touches the head. A `through` below 1 deletes nothing.
|
|
87
|
+
*/
|
|
88
|
+
compactBelow(entityId: string, through: bigint, options?: CallOptions): Promise<number>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The most actions one commit may carry, DynamoDB's transaction limit. */
|
|
92
|
+
export const MAX_COMMIT_ACTIONS = 100
|
|
93
|
+
|
|
94
|
+
/** The most bytes one commit may carry, summed over its actions: 95% of DynamoDB's 4 MB. */
|
|
95
|
+
export const MAX_COMMIT_BYTES = 3_800_000
|
|
96
|
+
|
|
97
|
+
/** The prefixes of the entity IDs only the engine writes. */
|
|
98
|
+
export const RESERVED_PREFIXES: readonly string[] = Object.freeze([
|
|
99
|
+
'SYSTEM#',
|
|
100
|
+
'command-outcome#',
|
|
101
|
+
])
|
|
102
|
+
|
|
103
|
+
// The control rows every commit checks: the feature marker and the rebuild lock.
|
|
104
|
+
const FEATURES_ID = 'SYSTEM#FEATURES'
|
|
105
|
+
const REBUILD_LOCK_ID = 'SYSTEM#REBUILD'
|
|
106
|
+
|
|
107
|
+
/** Whether an entity ID is in a reserved namespace. */
|
|
108
|
+
export function isReserved(entityId: string): boolean {
|
|
109
|
+
return RESERVED_PREFIXES.some((prefix) => entityId.startsWith(prefix))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Refuses an entity ID in a reserved namespace, on a write path, before any request. */
|
|
113
|
+
export function checkNotReserved(entityId: string): void {
|
|
114
|
+
if (isReserved(entityId)) {
|
|
115
|
+
throw new ReservedEntityIdError(entityId)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Checks that a commit's events can be stored as written: each belongs to the commit's entity and
|
|
121
|
+
* has the ID `{entityId}:{sequence}`, they share one entity type, the first sequence is at least
|
|
122
|
+
* 1, and the sequences are contiguous. A snapshot must have the events' entity type and a
|
|
123
|
+
* sequence of at least 1. A commit with no events is valid, and its snapshot isn't checked.
|
|
124
|
+
*/
|
|
125
|
+
export function validateCommit(commit: Commit): void {
|
|
126
|
+
const [first] = commit.events
|
|
127
|
+
if (first === undefined) {
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const event of commit.events) {
|
|
132
|
+
if (event.entityType !== first.entityType) {
|
|
133
|
+
throw invalidCommit(
|
|
134
|
+
`event ${event.id} has entity type ${event.entityType}, but the commit's first event ` +
|
|
135
|
+
`has ${first.entityType}`,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const snapshot = commit.snapshot
|
|
141
|
+
if (snapshot && snapshot.entityType !== first.entityType) {
|
|
142
|
+
throw invalidCommit(
|
|
143
|
+
`the snapshot has entity type ${snapshot.entityType}, but the events have ` +
|
|
144
|
+
first.entityType,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
if (snapshot && snapshot.sequence < 1n) {
|
|
148
|
+
throw invalidCommit(
|
|
149
|
+
`snapshot sequence ${snapshot.sequence} is below 1; snapshot sequences start at 1`,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const event of commit.events) {
|
|
154
|
+
if (event.entityId !== commit.entityId) {
|
|
155
|
+
throw invalidCommit(
|
|
156
|
+
`event ${event.id} belongs to entity ${event.entityId}, not ${commit.entityId}`,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const id = eventId(event.entityId, event.sequence)
|
|
161
|
+
if (event.id !== id) {
|
|
162
|
+
throw invalidCommit(`event ${event.id} should have the ID ${id}`)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (first.sequence < 1n) {
|
|
167
|
+
throw invalidCommit(`event sequence ${first.sequence} is below 1; sequences start at 1`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (let i = 1; i < commit.events.length; i++) {
|
|
171
|
+
const previous = commit.events[i - 1]!.sequence
|
|
172
|
+
const next = commit.events[i]!.sequence
|
|
173
|
+
if (next !== previous + 1n) {
|
|
174
|
+
throw invalidCommit(
|
|
175
|
+
`event sequence ${next} follows ${previous}; sequences must be contiguous`,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The size of each action a commit's transaction carries, in order: the feature-marker and
|
|
183
|
+
* rebuild-lock checks, then the stream's head update, its event puts, and its snapshot put when
|
|
184
|
+
* it writes one. `ttl` gives the expiry an entity type's items carry, if it's retained. Every
|
|
185
|
+
* backend budgets a commit by these sizes, so an oversized commit fails the same way on each.
|
|
186
|
+
*/
|
|
187
|
+
export function commitSizes(
|
|
188
|
+
commit: Commit,
|
|
189
|
+
ttl: (entityType: string) => number | undefined,
|
|
190
|
+
): number[] {
|
|
191
|
+
const sizes = [controlCheckSize(FEATURES_ID), controlCheckSize(REBUILD_LOCK_ID)]
|
|
192
|
+
const first = commit.events[0]
|
|
193
|
+
const last = commit.events.at(-1)
|
|
194
|
+
if (first === undefined || last === undefined) {
|
|
195
|
+
return sizes
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const head = headUpdate(commit.entityId, first.sequence, last.sequence, ttl(first.entityType))
|
|
199
|
+
sizes.push(headActionSize(head))
|
|
200
|
+
for (const event of commit.events) {
|
|
201
|
+
sizes.push(itemSize(eventItem(event, ttl(event.entityType))))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const snapshot = commit.snapshot
|
|
205
|
+
if (snapshot) {
|
|
206
|
+
const stored: SerializedSnapshot = {
|
|
207
|
+
entityType: snapshot.entityType,
|
|
208
|
+
entityId: commit.entityId,
|
|
209
|
+
sequence: snapshot.sequence,
|
|
210
|
+
eventSequence: last.sequence,
|
|
211
|
+
payload: snapshot.payload,
|
|
212
|
+
}
|
|
213
|
+
sizes.push(itemSize(snapshotItem(stored, ttl(snapshot.entityType))))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return sizes
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Refuses a commit whose actions are over the budget: more than {@link MAX_COMMIT_ACTIONS}, more
|
|
221
|
+
* than {@link MAX_COMMIT_BYTES} in all, or one over {@link MAX_COMMIT_ITEM_BYTES}.
|
|
222
|
+
*/
|
|
223
|
+
export function checkBudget(sizes: readonly number[]): void {
|
|
224
|
+
let bytes = 0
|
|
225
|
+
let largestItem = 0
|
|
226
|
+
for (const size of sizes) {
|
|
227
|
+
bytes += size
|
|
228
|
+
largestItem = Math.max(largestItem, size)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const tooLarge =
|
|
232
|
+
sizes.length > MAX_COMMIT_ACTIONS ||
|
|
233
|
+
bytes > MAX_COMMIT_BYTES ||
|
|
234
|
+
largestItem > MAX_COMMIT_ITEM_BYTES
|
|
235
|
+
if (tooLarge) {
|
|
236
|
+
throw new CommitTooLargeError(sizes.length, bytes, largestItem)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// A control check is sized by its key: `pk` and `sk = 0`.
|
|
241
|
+
function controlCheckSize(pk: string): number {
|
|
242
|
+
return itemSize({pk: {S: pk}, sk: {N: '0'}})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function invalidCommit(message: string): EvtError {
|
|
246
|
+
return new EvtError('invalidCommit', `invalid commit: ${message}`)
|
|
247
|
+
}
|