@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.
Files changed (149) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +164 -1
  3. package/dist/aggregate.d.ts +83 -0
  4. package/dist/aggregate.d.ts.map +1 -0
  5. package/dist/aggregate.js +92 -0
  6. package/dist/aggregate.js.map +1 -0
  7. package/dist/base64.d.ts +8 -0
  8. package/dist/base64.d.ts.map +1 -0
  9. package/dist/base64.js +67 -0
  10. package/dist/base64.js.map +1 -0
  11. package/dist/codec.d.ts +56 -0
  12. package/dist/codec.d.ts.map +1 -0
  13. package/dist/codec.js +90 -0
  14. package/dist/codec.js.map +1 -0
  15. package/dist/commit-id.d.ts +3 -0
  16. package/dist/commit-id.d.ts.map +1 -0
  17. package/dist/commit-id.js +43 -0
  18. package/dist/commit-id.js.map +1 -0
  19. package/dist/dynamodb/client.d.ts +83 -0
  20. package/dist/dynamodb/client.d.ts.map +1 -0
  21. package/dist/dynamodb/client.js +6 -0
  22. package/dist/dynamodb/client.js.map +1 -0
  23. package/dist/dynamodb/head.d.ts +34 -0
  24. package/dist/dynamodb/head.d.ts.map +1 -0
  25. package/dist/dynamodb/head.js +62 -0
  26. package/dist/dynamodb/head.js.map +1 -0
  27. package/dist/dynamodb/index.d.ts +6 -0
  28. package/dist/dynamodb/index.d.ts.map +1 -0
  29. package/dist/dynamodb/index.js +8 -0
  30. package/dist/dynamodb/index.js.map +1 -0
  31. package/dist/dynamodb/item.d.ts +70 -0
  32. package/dist/dynamodb/item.d.ts.map +1 -0
  33. package/dist/dynamodb/item.js +264 -0
  34. package/dist/dynamodb/item.js.map +1 -0
  35. package/dist/dynamodb/marker.d.ts +11 -0
  36. package/dist/dynamodb/marker.d.ts.map +1 -0
  37. package/dist/dynamodb/marker.js +56 -0
  38. package/dist/dynamodb/marker.js.map +1 -0
  39. package/dist/dynamodb/repository.d.ts +61 -0
  40. package/dist/dynamodb/repository.d.ts.map +1 -0
  41. package/dist/dynamodb/repository.js +731 -0
  42. package/dist/dynamodb/repository.js.map +1 -0
  43. package/dist/dynamodb/requests.d.ts +65 -0
  44. package/dist/dynamodb/requests.d.ts.map +1 -0
  45. package/dist/dynamodb/requests.js +179 -0
  46. package/dist/dynamodb/requests.js.map +1 -0
  47. package/dist/envelope.d.ts +15 -0
  48. package/dist/envelope.d.ts.map +1 -0
  49. package/dist/envelope.js +59 -0
  50. package/dist/envelope.js.map +1 -0
  51. package/dist/errors.d.ts +205 -0
  52. package/dist/errors.d.ts.map +1 -0
  53. package/dist/errors.js +240 -0
  54. package/dist/errors.js.map +1 -0
  55. package/dist/event.d.ts +47 -0
  56. package/dist/event.d.ts.map +1 -0
  57. package/dist/event.js +64 -0
  58. package/dist/event.js.map +1 -0
  59. package/dist/features.d.ts +55 -0
  60. package/dist/features.d.ts.map +1 -0
  61. package/dist/features.js +126 -0
  62. package/dist/features.js.map +1 -0
  63. package/dist/fields.d.ts +31 -0
  64. package/dist/fields.d.ts.map +1 -0
  65. package/dist/fields.js +105 -0
  66. package/dist/fields.js.map +1 -0
  67. package/dist/index.d.ts +15 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +17 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/json.d.ts +47 -0
  72. package/dist/json.d.ts.map +1 -0
  73. package/dist/json.js +458 -0
  74. package/dist/json.js.map +1 -0
  75. package/dist/memory/index.d.ts +2 -0
  76. package/dist/memory/index.d.ts.map +1 -0
  77. package/dist/memory/index.js +3 -0
  78. package/dist/memory/index.js.map +1 -0
  79. package/dist/memory/repository.d.ts +70 -0
  80. package/dist/memory/repository.d.ts.map +1 -0
  81. package/dist/memory/repository.js +308 -0
  82. package/dist/memory/repository.js.map +1 -0
  83. package/dist/metadata.d.ts +47 -0
  84. package/dist/metadata.d.ts.map +1 -0
  85. package/dist/metadata.js +75 -0
  86. package/dist/metadata.js.map +1 -0
  87. package/dist/repository.d.ts +131 -0
  88. package/dist/repository.d.ts.map +1 -0
  89. package/dist/repository.js +130 -0
  90. package/dist/repository.js.map +1 -0
  91. package/dist/retry.d.ts +25 -0
  92. package/dist/retry.d.ts.map +1 -0
  93. package/dist/retry.js +50 -0
  94. package/dist/retry.js.map +1 -0
  95. package/dist/snapshots.d.ts +21 -0
  96. package/dist/snapshots.d.ts.map +1 -0
  97. package/dist/snapshots.js +35 -0
  98. package/dist/snapshots.js.map +1 -0
  99. package/dist/store.d.ts +103 -0
  100. package/dist/store.d.ts.map +1 -0
  101. package/dist/store.js +246 -0
  102. package/dist/store.js.map +1 -0
  103. package/dist/testing/equal.d.ts +22 -0
  104. package/dist/testing/equal.d.ts.map +1 -0
  105. package/dist/testing/equal.js +121 -0
  106. package/dist/testing/equal.js.map +1 -0
  107. package/dist/testing/index.d.ts +3 -0
  108. package/dist/testing/index.d.ts.map +1 -0
  109. package/dist/testing/index.js +5 -0
  110. package/dist/testing/index.js.map +1 -0
  111. package/dist/testing/scenario.d.ts +136 -0
  112. package/dist/testing/scenario.d.ts.map +1 -0
  113. package/dist/testing/scenario.js +307 -0
  114. package/dist/testing/scenario.js.map +1 -0
  115. package/dist/utf8.d.ts +17 -0
  116. package/dist/utf8.d.ts.map +1 -0
  117. package/dist/utf8.js +82 -0
  118. package/dist/utf8.js.map +1 -0
  119. package/package.json +58 -2
  120. package/src/aggregate.ts +196 -0
  121. package/src/base64.ts +80 -0
  122. package/src/codec.ts +139 -0
  123. package/src/commit-id.ts +50 -0
  124. package/src/dynamodb/client.ts +102 -0
  125. package/src/dynamodb/head.ts +93 -0
  126. package/src/dynamodb/index.ts +45 -0
  127. package/src/dynamodb/item.ts +334 -0
  128. package/src/dynamodb/marker.ts +64 -0
  129. package/src/dynamodb/repository.ts +1030 -0
  130. package/src/dynamodb/requests.ts +256 -0
  131. package/src/envelope.ts +67 -0
  132. package/src/errors.ts +365 -0
  133. package/src/event.ts +117 -0
  134. package/src/features.ts +175 -0
  135. package/src/fields.ts +134 -0
  136. package/src/globals.d.ts +39 -0
  137. package/src/index.ts +98 -0
  138. package/src/json.ts +546 -0
  139. package/src/memory/index.ts +11 -0
  140. package/src/memory/repository.ts +463 -0
  141. package/src/metadata.ts +120 -0
  142. package/src/repository.ts +275 -0
  143. package/src/retry.ts +74 -0
  144. package/src/snapshots.ts +42 -0
  145. package/src/store.ts +377 -0
  146. package/src/testing/equal.ts +146 -0
  147. package/src/testing/index.ts +14 -0
  148. package/src/testing/scenario.ts +406 -0
  149. 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
+ }
@@ -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
+ }