@photon-grove/evt 0.0.0 → 0.1.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.
- package/LICENSE +176 -0
- package/README.md +164 -1
- 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/client.d.ts +83 -0
- package/dist/dynamodb/client.d.ts.map +1 -0
- package/dist/dynamodb/client.js +6 -0
- package/dist/dynamodb/client.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 +6 -0
- package/dist/dynamodb/index.d.ts.map +1 -0
- package/dist/dynamodb/index.js +8 -0
- package/dist/dynamodb/index.js.map +1 -0
- package/dist/dynamodb/item.d.ts +70 -0
- package/dist/dynamodb/item.d.ts.map +1 -0
- package/dist/dynamodb/item.js +264 -0
- package/dist/dynamodb/item.js.map +1 -0
- package/dist/dynamodb/marker.d.ts +11 -0
- package/dist/dynamodb/marker.d.ts.map +1 -0
- package/dist/dynamodb/marker.js +56 -0
- package/dist/dynamodb/marker.js.map +1 -0
- package/dist/dynamodb/repository.d.ts +61 -0
- package/dist/dynamodb/repository.d.ts.map +1 -0
- package/dist/dynamodb/repository.js +731 -0
- package/dist/dynamodb/repository.js.map +1 -0
- package/dist/dynamodb/requests.d.ts +65 -0
- package/dist/dynamodb/requests.d.ts.map +1 -0
- package/dist/dynamodb/requests.js +179 -0
- package/dist/dynamodb/requests.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 +205 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +240 -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/features.d.ts +55 -0
- package/dist/features.d.ts.map +1 -0
- package/dist/features.js +126 -0
- package/dist/features.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 +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -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 +70 -0
- package/dist/memory/repository.d.ts.map +1 -0
- package/dist/memory/repository.js +308 -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 +131 -0
- package/dist/repository.d.ts.map +1 -0
- package/dist/repository.js +130 -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 +58 -2
- 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/client.ts +102 -0
- package/src/dynamodb/head.ts +93 -0
- package/src/dynamodb/index.ts +45 -0
- package/src/dynamodb/item.ts +334 -0
- package/src/dynamodb/marker.ts +64 -0
- package/src/dynamodb/repository.ts +1030 -0
- package/src/dynamodb/requests.ts +256 -0
- package/src/envelope.ts +67 -0
- package/src/errors.ts +365 -0
- package/src/event.ts +117 -0
- package/src/features.ts +175 -0
- package/src/fields.ts +134 -0
- package/src/globals.d.ts +39 -0
- package/src/index.ts +98 -0
- package/src/json.ts +546 -0
- package/src/memory/index.ts +11 -0
- package/src/memory/repository.ts +463 -0
- package/src/metadata.ts +120 -0
- package/src/repository.ts +275 -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,463 @@
|
|
|
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, the feature marker and
|
|
3
|
+
// the rebuild-lock check, and the same failure classification and transaction budget. It's for
|
|
4
|
+
// tests and local development.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
ConflictError,
|
|
8
|
+
CompactionUncoveredError,
|
|
9
|
+
EvtError,
|
|
10
|
+
HistoryDependentProjectionError,
|
|
11
|
+
RebuildInProgressError,
|
|
12
|
+
SequenceGapError,
|
|
13
|
+
StreamHeadDriftError,
|
|
14
|
+
StreamHeadsNotMigratedError,
|
|
15
|
+
} from '../errors.js'
|
|
16
|
+
import type {SerializedEvent, SerializedSnapshot} from '../event.js'
|
|
17
|
+
import {
|
|
18
|
+
type FeatureManifest,
|
|
19
|
+
checkSupported,
|
|
20
|
+
checkWritable,
|
|
21
|
+
declaresStreamHeads,
|
|
22
|
+
entityTypeSet,
|
|
23
|
+
withStreamHeads,
|
|
24
|
+
} from '../features.js'
|
|
25
|
+
import {int64Argument} from '../fields.js'
|
|
26
|
+
import type {Metadata} from '../metadata.js'
|
|
27
|
+
import {
|
|
28
|
+
type CallOptions,
|
|
29
|
+
type Commit,
|
|
30
|
+
type Repository,
|
|
31
|
+
type StoreInitializer,
|
|
32
|
+
checkBudget,
|
|
33
|
+
checkNotReserved,
|
|
34
|
+
commitSizes,
|
|
35
|
+
validateCommit,
|
|
36
|
+
} from '../repository.js'
|
|
37
|
+
|
|
38
|
+
interface Stream {
|
|
39
|
+
/** The stream's events by sequence. Compaction deletes from it. */
|
|
40
|
+
events: Map<bigint, SerializedEvent>
|
|
41
|
+
snapshot: SerializedSnapshot | null
|
|
42
|
+
/** The highest event sequence ever committed to the stream, 0 while it has none. */
|
|
43
|
+
head: bigint
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The state of the rebuild fence's two rows that a commit's lock check reads. */
|
|
47
|
+
export interface RebuildState {
|
|
48
|
+
/** The generation counter: odd while a rebuild holds the store or after a force-release. */
|
|
49
|
+
generation: bigint
|
|
50
|
+
/**
|
|
51
|
+
* The lock row: a rebuild's lease, the item a force-release leaves in its place, or none. A
|
|
52
|
+
* commit is refused while either exists, and while the generation is odd.
|
|
53
|
+
*/
|
|
54
|
+
lock: 'held' | 'forceReleased' | null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The store-wide control rows: the feature marker and the rebuild fence.
|
|
58
|
+
interface Controls {
|
|
59
|
+
manifest: FeatureManifest | null
|
|
60
|
+
rebuild: RebuildState
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Read a repository's private state, for the fixtures. The class's static block sets them.
|
|
64
|
+
let streamsOf: (repo: MemRepository) => Map<string, Stream>
|
|
65
|
+
let controlsOf: (repo: MemRepository) => Controls
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* An in-memory {@link Repository}. Every call copies what it stores and what it returns, so a
|
|
69
|
+
* caller that changes an event after committing it, or one it read, changes nothing stored.
|
|
70
|
+
*
|
|
71
|
+
* Each call runs to completion before another starts, so concurrent commits to one stream behave
|
|
72
|
+
* like racing writers on a live backend: one wins and the rest fail with a conflict.
|
|
73
|
+
*
|
|
74
|
+
* A fresh store is initialized by its first commit or snapshot write, which declares stream-heads
|
|
75
|
+
* on its feature manifest, so {@link initialize} is needed only where code treats every backend
|
|
76
|
+
* alike.
|
|
77
|
+
*/
|
|
78
|
+
export class MemRepository implements Repository, StoreInitializer {
|
|
79
|
+
readonly #streams = new Map<string, Stream>()
|
|
80
|
+
readonly #controls: Controls = {manifest: null, rebuild: {generation: 0n, lock: null}}
|
|
81
|
+
|
|
82
|
+
async initialize(options?: CallOptions): Promise<boolean> {
|
|
83
|
+
options?.signal?.throwIfAborted()
|
|
84
|
+
checkSupported(this.#controls.manifest)
|
|
85
|
+
if (declaresStreamHeads(this.#controls.manifest)) {
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.#declareStreamHeads()
|
|
90
|
+
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async commit(commit: Commit, options?: CallOptions): Promise<void> {
|
|
95
|
+
options?.signal?.throwIfAborted()
|
|
96
|
+
validateCommit(commit)
|
|
97
|
+
checkNotReserved(commit.entityId)
|
|
98
|
+
if (commit.events.length === 0) {
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Every backend budgets a commit the same way, so an oversized one fails before any write.
|
|
103
|
+
checkBudget(commitSizes(commit, () => undefined))
|
|
104
|
+
|
|
105
|
+
// The control rows come first, as the two checks that open every live commit: the marker,
|
|
106
|
+
// then the rebuild lock, which an unsettled generation fails, a force-released one included.
|
|
107
|
+
this.#checkManifest()
|
|
108
|
+
const {rebuild} = this.#controls
|
|
109
|
+
if (rebuild.lock !== null || rebuild.generation % 2n !== 0n) {
|
|
110
|
+
throw new RebuildInProgressError()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const held = this.#streams.get(commit.entityId) ?? emptyStream()
|
|
114
|
+
checkWrite(commit, held)
|
|
115
|
+
|
|
116
|
+
// Copy everything before the first write, so a copy that throws leaves the stream as it was.
|
|
117
|
+
const events = commit.events.map(cloneEvent)
|
|
118
|
+
const last = events.at(-1)!.sequence
|
|
119
|
+
const snapshot = commit.snapshot && {
|
|
120
|
+
entityType: commit.snapshot.entityType,
|
|
121
|
+
entityId: commit.entityId,
|
|
122
|
+
sequence: commit.snapshot.sequence,
|
|
123
|
+
eventSequence: last,
|
|
124
|
+
payload: copy(commit.snapshot.payload),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const event of events) {
|
|
128
|
+
held.events.set(event.sequence, event)
|
|
129
|
+
}
|
|
130
|
+
held.head = held.head > last ? held.head : last
|
|
131
|
+
if (snapshot) {
|
|
132
|
+
held.snapshot = snapshot
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this.#streams.set(commit.entityId, held)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async events(entityId: string, options?: CallOptions): Promise<SerializedEvent[]> {
|
|
139
|
+
return this.eventsAfter(entityId, 0n, options)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async eventsAfter(
|
|
143
|
+
entityId: string,
|
|
144
|
+
after: bigint,
|
|
145
|
+
options?: CallOptions,
|
|
146
|
+
): Promise<SerializedEvent[]> {
|
|
147
|
+
options?.signal?.throwIfAborted()
|
|
148
|
+
int64Argument(after, 'after')
|
|
149
|
+
|
|
150
|
+
return this.#sorted(entityId)
|
|
151
|
+
.filter((event) => event.sequence > after)
|
|
152
|
+
.map(cloneEvent)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async latest(entityId: string, n: number, options?: CallOptions): Promise<SerializedEvent[]> {
|
|
156
|
+
options?.signal?.throwIfAborted()
|
|
157
|
+
if (!Number.isSafeInteger(n) || n < 1) {
|
|
158
|
+
throw new EvtError(
|
|
159
|
+
'invalidArgument',
|
|
160
|
+
`latest(n) requires a whole number of at least one event: got n = ${n}`,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return this.#sorted(entityId, true).slice(0, n).map(cloneEvent)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async snapshot(entityId: string, options?: CallOptions): Promise<SerializedSnapshot | null> {
|
|
168
|
+
options?.signal?.throwIfAborted()
|
|
169
|
+
const snapshot = this.#streams.get(entityId)?.snapshot
|
|
170
|
+
|
|
171
|
+
return snapshot ? cloneSnapshot(snapshot) : null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async putSnapshot(snapshot: SerializedSnapshot, options?: CallOptions): Promise<boolean> {
|
|
175
|
+
options?.signal?.throwIfAborted()
|
|
176
|
+
checkNotReserved(snapshot.entityId)
|
|
177
|
+
int64Argument(snapshot.sequence, 'snapshot.sequence')
|
|
178
|
+
int64Argument(snapshot.eventSequence, 'snapshot.eventSequence')
|
|
179
|
+
this.#checkManifest()
|
|
180
|
+
|
|
181
|
+
// The stream's head must cover the snapshot. One of an empty stream (event sequence 0) may
|
|
182
|
+
// have no head at all.
|
|
183
|
+
const held = this.#streams.get(snapshot.entityId)
|
|
184
|
+
const head = held?.head ?? 0n
|
|
185
|
+
if (snapshot.eventSequence > 0n && head < snapshot.eventSequence) {
|
|
186
|
+
const derived = held ? derivedHead(held) : 0n
|
|
187
|
+
throw drift(
|
|
188
|
+
snapshot.entityId,
|
|
189
|
+
head,
|
|
190
|
+
derived > snapshot.eventSequence ? derived : snapshot.eventSequence,
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const stored = held?.snapshot
|
|
195
|
+
if (stored && stored.eventSequence > snapshot.eventSequence) {
|
|
196
|
+
return false
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const stream = held ?? emptyStream()
|
|
200
|
+
stream.snapshot = cloneSnapshot(snapshot)
|
|
201
|
+
this.#streams.set(snapshot.entityId, stream)
|
|
202
|
+
|
|
203
|
+
return true
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async compactBelow(entityId: string, through: bigint, options?: CallOptions): Promise<number> {
|
|
207
|
+
options?.signal?.throwIfAborted()
|
|
208
|
+
checkNotReserved(entityId)
|
|
209
|
+
if (int64Argument(through, 'through') < 1n) {
|
|
210
|
+
return 0
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Compaction validates the manifest, but never declares stream-heads itself.
|
|
214
|
+
const {manifest} = this.#controls
|
|
215
|
+
checkWritable(manifest)
|
|
216
|
+
|
|
217
|
+
const stream = this.#streams.get(entityId)
|
|
218
|
+
const snapshot = stream?.snapshot
|
|
219
|
+
const covered = snapshot?.eventSequence ?? null
|
|
220
|
+
if (stream === undefined || !snapshot || covered === null || covered < through) {
|
|
221
|
+
throw new CompactionUncoveredError(entityId, through, covered)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// A history-dependent type's rebuild replays the events compaction would delete.
|
|
225
|
+
if (manifest?.historyDependent.includes(snapshot.entityType)) {
|
|
226
|
+
throw new HistoryDependentProjectionError(snapshot.entityType, entityId)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let deleted = 0
|
|
230
|
+
for (const sequence of stream.events.keys()) {
|
|
231
|
+
if (sequence <= through) {
|
|
232
|
+
stream.events.delete(sequence)
|
|
233
|
+
deleted++
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return deleted
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
static {
|
|
241
|
+
streamsOf = (repo) => repo.#streams
|
|
242
|
+
controlsOf = (repo) => repo.#controls
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Refuses a write to a store whose manifest declares an unsupported pair, and declares
|
|
246
|
+
// stream-heads on a store without it, as its first writer, when the store holds no data.
|
|
247
|
+
#checkManifest(): void {
|
|
248
|
+
checkSupported(this.#controls.manifest)
|
|
249
|
+
if (!declaresStreamHeads(this.#controls.manifest)) {
|
|
250
|
+
this.#declareStreamHeads()
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#declareStreamHeads(): void {
|
|
255
|
+
const holdsData = Array.from(this.#streams.values()).some(
|
|
256
|
+
(stream) => stream.events.size > 0 || stream.snapshot !== null,
|
|
257
|
+
)
|
|
258
|
+
if (holdsData) {
|
|
259
|
+
throw new StreamHeadsNotMigratedError()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
this.#controls.manifest = withStreamHeads(this.#controls.manifest)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// The stream's events by sequence, ascending or descending. Sequences are unique.
|
|
266
|
+
#sorted(entityId: string, descending = false): SerializedEvent[] {
|
|
267
|
+
const events = Array.from(this.#streams.get(entityId)?.events.values() ?? [])
|
|
268
|
+
const order = descending ? -1 : 1
|
|
269
|
+
events.sort((a, b) => (a.sequence < b.sequence ? -order : order))
|
|
270
|
+
|
|
271
|
+
return events
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Refuses a commit the stored stream doesn't accept, as the live backends' conditions do. The
|
|
276
|
+
// head update needs no head for a create and the head to be the sequence before the first event
|
|
277
|
+
// for an append. Then no event may exist already, and a snapshot must follow the stored one.
|
|
278
|
+
function checkWrite(commit: Commit, held: Stream): void {
|
|
279
|
+
const first = commit.events[0]!.sequence
|
|
280
|
+
const headPasses = first <= 1n ? held.head === 0n : held.head === first - 1n
|
|
281
|
+
if (!headPasses) {
|
|
282
|
+
throw probe(commit.entityId, held, first)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// The head update passed and an event put would fail: an event sits above the head.
|
|
286
|
+
if (commit.events.some((event) => held.events.has(event.sequence))) {
|
|
287
|
+
throw drift(commit.entityId, held.head, derivedHead(held))
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const snapshot = commit.snapshot
|
|
291
|
+
const stored = held.snapshot?.sequence ?? 0n
|
|
292
|
+
if (snapshot && snapshot.sequence !== stored + 1n) {
|
|
293
|
+
throw new ConflictError(
|
|
294
|
+
commit.entityId,
|
|
295
|
+
`snapshot sequence moved: stored ${stored}, commit writes ${snapshot.sequence}`,
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Classifies a failed head update the way the live backends' failure-path probe does: a stored
|
|
301
|
+
// head that disagrees with the head derived from the log is drift. Memory has no concurrent
|
|
302
|
+
// writes, so a second derivation would find the same head. A healthy stream fails with a gap when
|
|
303
|
+
// the commit skips past its head, and with a conflict otherwise.
|
|
304
|
+
function probe(entityId: string, held: Stream, first: bigint): EvtError {
|
|
305
|
+
const derived = derivedHead(held)
|
|
306
|
+
if (held.head !== derived) {
|
|
307
|
+
return drift(entityId, held.head, derived)
|
|
308
|
+
}
|
|
309
|
+
if (held.head < first - 1n) {
|
|
310
|
+
return new SequenceGapError(entityId, held.head, first)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return new ConflictError(
|
|
314
|
+
entityId,
|
|
315
|
+
'conditional check failed: the sequence or snapshot changed since it was read',
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// The head a stream's log implies: the higher of its last event and its snapshot's event sequence.
|
|
320
|
+
function derivedHead(stream: Stream): bigint {
|
|
321
|
+
let head = stream.snapshot?.eventSequence ?? 0n
|
|
322
|
+
for (const sequence of stream.events.keys()) {
|
|
323
|
+
if (sequence > head) {
|
|
324
|
+
head = sequence
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return head
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// A stored head of 0 is an absent head.
|
|
332
|
+
function drift(entityId: string, stored: bigint, derived: bigint): StreamHeadDriftError {
|
|
333
|
+
return new StreamHeadDriftError(entityId, stored > 0n ? stored : null, derived)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** What a conformance suite needs to make a stream drift, which no repository call can do. */
|
|
337
|
+
export interface HeadFixtures {
|
|
338
|
+
/** Appends an event without raising its stream's head, as a pre-heads writer would. */
|
|
339
|
+
appendBypassingHead(event: SerializedEvent): void
|
|
340
|
+
/** Deletes a stream's head, leaving its events and snapshot. */
|
|
341
|
+
deleteHead(entityId: string): void
|
|
342
|
+
/** A stream's stored head, or null when it has none. */
|
|
343
|
+
storedHead(entityId: string): bigint | null
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Hooks that corrupt a repository's heads, for conformance suites only: a stream they touch
|
|
348
|
+
* drifts, and every later write to it fails.
|
|
349
|
+
*/
|
|
350
|
+
export function headFixtures(repo: MemRepository): HeadFixtures {
|
|
351
|
+
const streams = streamsOf(repo)
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
appendBypassingHead(event) {
|
|
355
|
+
const stream = streams.get(event.entityId) ?? emptyStream()
|
|
356
|
+
stream.events.set(event.sequence, cloneEvent(event))
|
|
357
|
+
streams.set(event.entityId, stream)
|
|
358
|
+
},
|
|
359
|
+
deleteHead(entityId) {
|
|
360
|
+
const stream = streams.get(entityId)
|
|
361
|
+
if (stream) {
|
|
362
|
+
stream.head = 0n
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
storedHead(entityId) {
|
|
366
|
+
const head = streams.get(entityId)?.head ?? 0n
|
|
367
|
+
|
|
368
|
+
return head > 0n ? head : null
|
|
369
|
+
},
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** A feature manifest's declarable parts, which a declaration changes in place. */
|
|
374
|
+
export interface ManifestDraft {
|
|
375
|
+
features: Map<string, number>
|
|
376
|
+
historyDependent: string[]
|
|
377
|
+
retention: string[]
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** What a conformance suite needs to declare features and hold the rebuild lock. */
|
|
381
|
+
export interface ControlFixtures {
|
|
382
|
+
/** The stored manifest, or null when the store has no marker row. */
|
|
383
|
+
manifest(): FeatureManifest | null
|
|
384
|
+
/** Changes the manifest in one declaration, bumping its version by one, and returns it. */
|
|
385
|
+
declare(change: (draft: ManifestDraft) => void): FeatureManifest
|
|
386
|
+
/** Sets the rebuild fence's rows, as a rebuild's lifecycle would. */
|
|
387
|
+
setRebuild(state: RebuildState): void
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Hooks that change a repository's control rows, for conformance suites only. A declaration may
|
|
392
|
+
* declare features this package doesn't model, which then refuse every write.
|
|
393
|
+
*/
|
|
394
|
+
export function controlFixtures(repo: MemRepository): ControlFixtures {
|
|
395
|
+
const controls = controlsOf(repo)
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
manifest() {
|
|
399
|
+
return controls.manifest && cloneManifest(controls.manifest)
|
|
400
|
+
},
|
|
401
|
+
declare(change) {
|
|
402
|
+
const draft: ManifestDraft = {
|
|
403
|
+
features: new Map(controls.manifest?.features),
|
|
404
|
+
historyDependent: [...(controls.manifest?.historyDependent ?? [])],
|
|
405
|
+
retention: [...(controls.manifest?.retention ?? [])],
|
|
406
|
+
}
|
|
407
|
+
change(draft)
|
|
408
|
+
|
|
409
|
+
// Stored as the marker row stores them: each set sorted and deduplicated.
|
|
410
|
+
controls.manifest = {
|
|
411
|
+
version: (controls.manifest?.version ?? 0n) + 1n,
|
|
412
|
+
features: new Map(draft.features),
|
|
413
|
+
historyDependent: entityTypeSet(draft.historyDependent),
|
|
414
|
+
retention: entityTypeSet(draft.retention),
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return cloneManifest(controls.manifest)
|
|
418
|
+
},
|
|
419
|
+
setRebuild(state) {
|
|
420
|
+
int64Argument(state.generation, 'generation')
|
|
421
|
+
controls.rebuild = {...state}
|
|
422
|
+
},
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function cloneManifest(manifest: FeatureManifest): FeatureManifest {
|
|
427
|
+
return {
|
|
428
|
+
version: manifest.version,
|
|
429
|
+
features: new Map(manifest.features),
|
|
430
|
+
historyDependent: [...manifest.historyDependent],
|
|
431
|
+
retention: [...manifest.retention],
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function emptyStream(): Stream {
|
|
436
|
+
return {events: new Map(), snapshot: null, head: 0n}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function cloneEvent(event: SerializedEvent): SerializedEvent {
|
|
440
|
+
return {
|
|
441
|
+
...event,
|
|
442
|
+
payload: event.payload && copy(event.payload),
|
|
443
|
+
metadata: cloneMetadata(event.metadata),
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function cloneMetadata(metadata: Metadata): Metadata {
|
|
448
|
+
return {
|
|
449
|
+
...metadata,
|
|
450
|
+
trace: metadata.trace && new Map(metadata.trace),
|
|
451
|
+
origin: metadata.origin && {...metadata.origin},
|
|
452
|
+
extensions: new Map(metadata.extensions),
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function cloneSnapshot(snapshot: SerializedSnapshot): SerializedSnapshot {
|
|
457
|
+
return {...snapshot, payload: snapshot.payload && copy(snapshot.payload)}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// A copy of the bytes. Not slice(): a Node Buffer's slice is a view of the same memory.
|
|
461
|
+
function copy(bytes: Uint8Array): Uint8Array {
|
|
462
|
+
return new Uint8Array(bytes)
|
|
463
|
+
}
|
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
|
+
}
|