@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.
Files changed (124) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +122 -2
  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/head.d.ts +34 -0
  20. package/dist/dynamodb/head.d.ts.map +1 -0
  21. package/dist/dynamodb/head.js +62 -0
  22. package/dist/dynamodb/head.js.map +1 -0
  23. package/dist/dynamodb/index.d.ts +2 -0
  24. package/dist/dynamodb/index.d.ts.map +1 -0
  25. package/dist/dynamodb/index.js +4 -0
  26. package/dist/dynamodb/index.js.map +1 -0
  27. package/dist/dynamodb/item.d.ts +68 -0
  28. package/dist/dynamodb/item.d.ts.map +1 -0
  29. package/dist/dynamodb/item.js +263 -0
  30. package/dist/dynamodb/item.js.map +1 -0
  31. package/dist/envelope.d.ts +15 -0
  32. package/dist/envelope.d.ts.map +1 -0
  33. package/dist/envelope.js +59 -0
  34. package/dist/envelope.js.map +1 -0
  35. package/dist/errors.d.ts +111 -0
  36. package/dist/errors.d.ts.map +1 -0
  37. package/dist/errors.js +139 -0
  38. package/dist/errors.js.map +1 -0
  39. package/dist/event.d.ts +47 -0
  40. package/dist/event.d.ts.map +1 -0
  41. package/dist/event.js +64 -0
  42. package/dist/event.js.map +1 -0
  43. package/dist/fields.d.ts +31 -0
  44. package/dist/fields.d.ts.map +1 -0
  45. package/dist/fields.js +105 -0
  46. package/dist/fields.js.map +1 -0
  47. package/dist/index.d.ts +14 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +16 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/json.d.ts +47 -0
  52. package/dist/json.d.ts.map +1 -0
  53. package/dist/json.js +458 -0
  54. package/dist/json.js.map +1 -0
  55. package/dist/memory/index.d.ts +2 -0
  56. package/dist/memory/index.d.ts.map +1 -0
  57. package/dist/memory/index.js +3 -0
  58. package/dist/memory/index.js.map +1 -0
  59. package/dist/memory/repository.d.ts +34 -0
  60. package/dist/memory/repository.d.ts.map +1 -0
  61. package/dist/memory/repository.js +219 -0
  62. package/dist/memory/repository.js.map +1 -0
  63. package/dist/metadata.d.ts +47 -0
  64. package/dist/metadata.d.ts.map +1 -0
  65. package/dist/metadata.js +75 -0
  66. package/dist/metadata.js.map +1 -0
  67. package/dist/repository.d.ts +102 -0
  68. package/dist/repository.d.ts.map +1 -0
  69. package/dist/repository.js +131 -0
  70. package/dist/repository.js.map +1 -0
  71. package/dist/retry.d.ts +25 -0
  72. package/dist/retry.d.ts.map +1 -0
  73. package/dist/retry.js +50 -0
  74. package/dist/retry.js.map +1 -0
  75. package/dist/snapshots.d.ts +21 -0
  76. package/dist/snapshots.d.ts.map +1 -0
  77. package/dist/snapshots.js +35 -0
  78. package/dist/snapshots.js.map +1 -0
  79. package/dist/store.d.ts +103 -0
  80. package/dist/store.d.ts.map +1 -0
  81. package/dist/store.js +246 -0
  82. package/dist/store.js.map +1 -0
  83. package/dist/testing/equal.d.ts +22 -0
  84. package/dist/testing/equal.d.ts.map +1 -0
  85. package/dist/testing/equal.js +121 -0
  86. package/dist/testing/equal.js.map +1 -0
  87. package/dist/testing/index.d.ts +3 -0
  88. package/dist/testing/index.d.ts.map +1 -0
  89. package/dist/testing/index.js +5 -0
  90. package/dist/testing/index.js.map +1 -0
  91. package/dist/testing/scenario.d.ts +136 -0
  92. package/dist/testing/scenario.d.ts.map +1 -0
  93. package/dist/testing/scenario.js +307 -0
  94. package/dist/testing/scenario.js.map +1 -0
  95. package/dist/utf8.d.ts +17 -0
  96. package/dist/utf8.d.ts.map +1 -0
  97. package/dist/utf8.js +82 -0
  98. package/dist/utf8.js.map +1 -0
  99. package/package.json +56 -4
  100. package/src/aggregate.ts +196 -0
  101. package/src/base64.ts +80 -0
  102. package/src/codec.ts +139 -0
  103. package/src/commit-id.ts +50 -0
  104. package/src/dynamodb/head.ts +93 -0
  105. package/src/dynamodb/index.ts +21 -0
  106. package/src/dynamodb/item.ts +332 -0
  107. package/src/envelope.ts +67 -0
  108. package/src/errors.ts +214 -0
  109. package/src/event.ts +117 -0
  110. package/src/fields.ts +134 -0
  111. package/src/globals.d.ts +39 -0
  112. package/src/index.ts +75 -0
  113. package/src/json.ts +546 -0
  114. package/src/memory/index.ts +3 -0
  115. package/src/memory/repository.ts +313 -0
  116. package/src/metadata.ts +120 -0
  117. package/src/repository.ts +247 -0
  118. package/src/retry.ts +74 -0
  119. package/src/snapshots.ts +42 -0
  120. package/src/store.ts +377 -0
  121. package/src/testing/equal.ts +146 -0
  122. package/src/testing/index.ts +14 -0
  123. package/src/testing/scenario.ts +406 -0
  124. 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
+ }
@@ -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
+ }