@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,256 @@
1
+ // The `TransactWriteItems` actions of a commit and of a snapshot write, built from the storage
2
+ // format alone (storage format, "Writes"). Every commit opens with the two control checks, the
3
+ // feature marker's and the rebuild lock's, then writes the stream: its head update, its event
4
+ // puts, and its snapshot put when it has one.
5
+
6
+ import type {SerializedEvent, SerializedSnapshot} from '../event.js'
7
+ import {int64Argument, safeIntegerArgument} from '../fields.js'
8
+ import type {Commit} from '../repository.js'
9
+ import type {TransactItem} from './client.js'
10
+ import {type HeadAction, headUpdate} from './head.js'
11
+ import {HEAD_SORT_KEY, type Item, SNAPSHOT_SORT_KEY, eventItem, snapshotItem} from './item.js'
12
+ import {FEATURES_PK, REBUILD_LOCK_PK} from './marker.js'
13
+
14
+ /** The marker check of a writer that validated the manifest at version `:v`. */
15
+ export const MARKER_CHECK_CONDITION = 'attribute_not_exists(pk) OR version = :v'
16
+
17
+ /** The rebuild-lock check: no lock row, so the rebuild generation is settled. */
18
+ export const REBUILD_LOCK_CONDITION = 'attribute_not_exists(pk)'
19
+
20
+ /** The condition of an event put: the sequence is new. */
21
+ export const EVENT_PUT_CONDITION = 'attribute_not_exists(sk)'
22
+
23
+ /** The condition of a commit's first snapshot of a stream. */
24
+ export const FIRST_SNAPSHOT_CONDITION = 'attribute_not_exists(seq)'
25
+
26
+ /** The condition of a commit's later snapshot: the stored one is the one before it. */
27
+ export const NEXT_SNAPSHOT_CONDITION = 'seq = :seq'
28
+
29
+ /** A snapshot write's head guard: the head covers the snapshot. */
30
+ export const SNAPSHOT_GUARD_CONDITION = 'head >= :new'
31
+
32
+ /** The guard of a snapshot of an empty stream (event sequence 0), which needs no head. */
33
+ export const SNAPSHOT_GUARD_EMPTY_CONDITION = 'attribute_not_exists(head) OR head >= :new'
34
+
35
+ /** The update expression of a retained stream's snapshot guard: it refreshes the head's `ttl`. */
36
+ export const SNAPSHOT_GUARD_RETAINED_UPDATE = 'SET #ttl = :t'
37
+
38
+ /** {@link SNAPSHOT_GUARD_CONDITION} with retention: the head's `ttl` is raised, never lowered. */
39
+ export const SNAPSHOT_GUARD_RETAINED_CONDITION =
40
+ 'head >= :new AND (attribute_not_exists(#ttl) OR #ttl <= :t)'
41
+
42
+ /** A snapshot write's put: it never lowers the stored snapshot's event sequence. */
43
+ export const SNAPSHOT_WRITE_CONDITION = 'attribute_not_exists(eventSeq) OR eventSeq <= :new'
44
+
45
+ /** The key of a control row, or of a stream's snapshot. */
46
+ export function controlKey(pk: string): Item {
47
+ return {pk: {S: pk}, sk: {N: SNAPSHOT_SORT_KEY.toString()}}
48
+ }
49
+
50
+ /** The key of a stream's head item. */
51
+ export function headKey(entityId: string): Item {
52
+ return {pk: {S: entityId}, sk: {N: HEAD_SORT_KEY.toString()}}
53
+ }
54
+
55
+ /** The marker check of a writer that validated the manifest at `version`. */
56
+ export function markerCheck(table: string, version: bigint): TransactItem {
57
+ return {
58
+ ConditionCheck: {
59
+ TableName: table,
60
+ Key: controlKey(FEATURES_PK),
61
+ ConditionExpression: MARKER_CHECK_CONDITION,
62
+ ExpressionAttributeValues: {':v': number(version)},
63
+ },
64
+ }
65
+ }
66
+
67
+ /** The rebuild-lock check every commit carries second. */
68
+ export function rebuildLockCheck(table: string): TransactItem {
69
+ return {
70
+ ConditionCheck: {
71
+ TableName: table,
72
+ Key: controlKey(REBUILD_LOCK_PK),
73
+ ConditionExpression: REBUILD_LOCK_CONDITION,
74
+ },
75
+ }
76
+ }
77
+
78
+ /** The `ttl`s of a commit to a stream with retention, in Unix seconds. */
79
+ export interface CommitTtl {
80
+ /** The commit's own `ttl`, which its event and snapshot items carry. */
81
+ items: number
82
+ /**
83
+ * The head item's: the commit's own `ttl` on a create, and on an append the larger of it and
84
+ * the largest `ttl` the writer loaded for the stream, because the head's is never lowered.
85
+ */
86
+ head: number
87
+ }
88
+
89
+ /** The `ttl` of a snapshot written outside a commit, to a stream with retention. */
90
+ export interface SnapshotTtl {
91
+ /** The snapshot's `ttl`, in Unix seconds, stamped when it's written. */
92
+ ttl: number
93
+ /**
94
+ * Whether the head guard refreshes the head's `ttl` to at least the snapshot's. True unless the
95
+ * stored head's `ttl` is known to cover it already.
96
+ */
97
+ refresh: boolean
98
+ }
99
+
100
+ /**
101
+ * The actions of a commit with at least one event, in order, by a writer that validated the
102
+ * feature manifest at `markerVersion`. `ttl` is given only for a stream with retention. Exported
103
+ * for the compatibility fixtures: its signature changes when commits span streams.
104
+ */
105
+ export function commitTransaction(
106
+ table: string,
107
+ commit: Commit,
108
+ markerVersion: bigint,
109
+ ttl?: CommitTtl,
110
+ ): TransactItem[] {
111
+ const first = commit.events[0]
112
+ const last = commit.events.at(-1)
113
+ if (first === undefined || last === undefined) {
114
+ throw new RangeError('a commit transaction needs at least one event')
115
+ }
116
+
117
+ const items = [markerCheck(table, markerVersion), rebuildLockCheck(table)]
118
+ const head = headUpdate(commit.entityId, first.sequence, last.sequence, ttl?.head)
119
+ items.push(headUpdateItem(table, head))
120
+ for (const event of commit.events) {
121
+ items.push(eventPut(table, event, ttl?.items))
122
+ }
123
+
124
+ const snapshot = commit.snapshot
125
+ if (snapshot) {
126
+ const stored: SerializedSnapshot = {
127
+ entityType: snapshot.entityType,
128
+ entityId: commit.entityId,
129
+ sequence: snapshot.sequence,
130
+ eventSequence: last.sequence,
131
+ payload: snapshot.payload,
132
+ }
133
+ items.push(commitSnapshotPut(table, stored, ttl?.items))
134
+ }
135
+
136
+ return items
137
+ }
138
+
139
+ /**
140
+ * The actions of a snapshot written outside a commit: the marker check, the head guard, and the
141
+ * put, which never lowers the stored snapshot. It carries no rebuild-lock check. `ttl` is given
142
+ * only for a stream with retention. Exported for the compatibility fixtures.
143
+ */
144
+ export function snapshotTransaction(
145
+ table: string,
146
+ snapshot: SerializedSnapshot,
147
+ markerVersion: bigint,
148
+ ttl?: SnapshotTtl,
149
+ ): TransactItem[] {
150
+ const eventSequence = int64Argument(snapshot.eventSequence, 'snapshot.eventSequence')
151
+
152
+ return [
153
+ markerCheck(table, markerVersion),
154
+ snapshotGuard(table, snapshot.entityId, eventSequence, ttl),
155
+ {
156
+ Put: {
157
+ TableName: table,
158
+ Item: snapshotItem(snapshot, ttl?.ttl),
159
+ ConditionExpression: SNAPSHOT_WRITE_CONDITION,
160
+ ExpressionAttributeValues: {':new': number(eventSequence)},
161
+ },
162
+ },
163
+ ]
164
+ }
165
+
166
+ // The head item's action in a snapshot write: a check that the head covers the snapshot, or, for a
167
+ // stream with retention, an update that also keeps the head's `ttl` at least the snapshot's. A
168
+ // snapshot of an empty stream may have no head item, so it's always a check: an update would
169
+ // create a head item without a head.
170
+ function snapshotGuard(
171
+ table: string,
172
+ entityId: string,
173
+ eventSequence: bigint,
174
+ ttl: SnapshotTtl | undefined,
175
+ ): TransactItem {
176
+ const values: Record<string, {N: string}> = {':new': number(eventSequence)}
177
+ if (eventSequence === 0n || ttl === undefined || !ttl.refresh) {
178
+ return {
179
+ ConditionCheck: {
180
+ TableName: table,
181
+ Key: headKey(entityId),
182
+ ConditionExpression:
183
+ eventSequence === 0n ? SNAPSHOT_GUARD_EMPTY_CONDITION : SNAPSHOT_GUARD_CONDITION,
184
+ ExpressionAttributeValues: values,
185
+ },
186
+ }
187
+ }
188
+
189
+ values[':t'] = number(BigInt(safeIntegerArgument(ttl.ttl, 'ttl')))
190
+
191
+ return {
192
+ Update: {
193
+ TableName: table,
194
+ Key: headKey(entityId),
195
+ UpdateExpression: SNAPSHOT_GUARD_RETAINED_UPDATE,
196
+ ConditionExpression: SNAPSHOT_GUARD_RETAINED_CONDITION,
197
+ ExpressionAttributeNames: {'#ttl': 'ttl'},
198
+ ExpressionAttributeValues: values,
199
+ },
200
+ }
201
+ }
202
+
203
+ function headUpdateItem(table: string, action: HeadAction): TransactItem {
204
+ const update: NonNullable<TransactItem['Update']> = {
205
+ TableName: table,
206
+ Key: headKey(action.entityId),
207
+ UpdateExpression: action.updateExpression ?? '',
208
+ ConditionExpression: action.conditionExpression,
209
+ ExpressionAttributeValues: Object.fromEntries(
210
+ action.values.map(([placeholder, value]) => [placeholder, number(value)]),
211
+ ),
212
+ }
213
+ if (action.names.length > 0) {
214
+ update.ExpressionAttributeNames = Object.fromEntries(action.names)
215
+ }
216
+
217
+ return {Update: update}
218
+ }
219
+
220
+ function eventPut(table: string, event: SerializedEvent, ttl: number | undefined): TransactItem {
221
+ return {
222
+ Put: {TableName: table, Item: eventItem(event, ttl), ConditionExpression: EVENT_PUT_CONDITION},
223
+ }
224
+ }
225
+
226
+ // A commit's snapshot follows the stored one: sequence 1 needs none, and a later one needs the
227
+ // one before it.
228
+ function commitSnapshotPut(
229
+ table: string,
230
+ snapshot: SerializedSnapshot,
231
+ ttl: number | undefined,
232
+ ): TransactItem {
233
+ const sequence = int64Argument(snapshot.sequence, 'snapshot.sequence')
234
+ if (sequence <= 1n) {
235
+ return {
236
+ Put: {
237
+ TableName: table,
238
+ Item: snapshotItem(snapshot, ttl),
239
+ ConditionExpression: FIRST_SNAPSHOT_CONDITION,
240
+ },
241
+ }
242
+ }
243
+
244
+ return {
245
+ Put: {
246
+ TableName: table,
247
+ Item: snapshotItem(snapshot, ttl),
248
+ ConditionExpression: NEXT_SNAPSHOT_CONDITION,
249
+ ExpressionAttributeValues: {':seq': number(sequence - 1n)},
250
+ },
251
+ }
252
+ }
253
+
254
+ function number(value: bigint): {N: string} {
255
+ return {N: value.toString()}
256
+ }
@@ -0,0 +1,67 @@
1
+ // The stream envelope: the EventBridge-style message around each committed event, which consumers
2
+ // in every language unwrap. Go decodes it with projectors.StreamRecordFromEnvelope.
3
+
4
+ import {type SerializedEvent, eventFromJson, encodeEvent} from './event.js'
5
+ import {DOCUMENT, invalid, objectOrNull} from './fields.js'
6
+ import {RawJson, parseJson, stringifyJson} from './json.js'
7
+
8
+ // Go's zero time.Time, which the publisher left in the time field.
9
+ const ZERO_TIME = '0001-01-01T00:00:00Z'
10
+
11
+ /** Encodes the envelope for an event, byte for byte as the Go publisher wrote it. */
12
+ export function encodeEnvelope(event: SerializedEvent, source: string): string {
13
+ return stringifyJson({
14
+ version: '',
15
+ id: event.id,
16
+ 'detail-type': event.type,
17
+ source,
18
+ account: '',
19
+ time: ZERO_TIME,
20
+ region: '',
21
+ resources: null,
22
+ detail: new RawJson(encodeEvent(event)),
23
+ })
24
+ }
25
+
26
+ /**
27
+ * Decodes the event from an envelope body: an SNS message, or an SQS body delivered raw. An event
28
+ * with no entity ID or event ID is refused.
29
+ */
30
+ export function decodeEnvelope(body: string): SerializedEvent {
31
+ const envelope = objectOrNull(parseJson(body, DOCUMENT), 'envelope') ?? new Map()
32
+ const event = eventFromJson(objectOrNull(envelope.get('detail'), 'envelope.detail'))
33
+ if (event.entityId === '') {
34
+ throw invalid('event envelope has no entity id')
35
+ }
36
+ if (event.id === '') {
37
+ throw invalid('event envelope has no event id')
38
+ }
39
+
40
+ return event
41
+ }
42
+
43
+ /**
44
+ * The message attributes the publisher set, all strings: `entityType` and `eventType`, then
45
+ * `commandId` and `traceparent` when they aren't empty, and `correlationId`, the command ID or
46
+ * else the traceparent.
47
+ */
48
+ export function messageAttributes(event: SerializedEvent): Record<string, string> {
49
+ const attributes: Record<string, string> = {
50
+ entityType: event.entityType,
51
+ eventType: event.type,
52
+ }
53
+
54
+ const commandId = event.metadata.commandId
55
+ if (commandId !== null && commandId !== '') {
56
+ attributes['commandId'] = commandId
57
+ attributes['correlationId'] = commandId
58
+ }
59
+
60
+ const traceparent = event.metadata.trace?.get('traceparent')
61
+ if (traceparent !== undefined && traceparent !== '') {
62
+ attributes['traceparent'] = traceparent
63
+ attributes['correlationId'] ??= traceparent
64
+ }
65
+
66
+ return attributes
67
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,365 @@
1
+ // Every failure evt throws is an EvtError with a code, as the Rust crate returns its Error enum.
2
+ // Domain outcomes (a rejected command, a duplicate) are values, never errors. A failure with
3
+ // details has its own subclass, so `instanceof` narrows to its fields.
4
+
5
+ /**
6
+ * What went wrong:
7
+ *
8
+ * - `invalidJson`: text that isn't JSON.
9
+ * - `invalidRecord`: a stored record (an event, a snapshot, an item) with the wrong shape.
10
+ * - `invalidArgument`: a value evt can't write, such as a float that isn't finite or an object
11
+ * whose key order the engine has already changed, or a read with a bad argument, such as
12
+ * `latest(0)`.
13
+ * - `invalidCommit`: a commit built wrong, such as one with another entity's event. Nothing was
14
+ * written.
15
+ * - `conflict`: optimistic concurrency failed. Another writer committed since the stream was
16
+ * read. Reload and run the command again.
17
+ * - `sequenceGap`: a commit or a load skips a sequence. A conflict.
18
+ * - `streamHeadDrift`: a stream's head disagrees with its events or snapshot. Never retried.
19
+ * - `commitTooLarge`: the commit is over the transaction budget. Nothing was written.
20
+ * - `compactionUncovered`: no snapshot covers the events compaction was asked to delete.
21
+ * - `reservedEntityId`: a write to an entity ID in a namespace only the engine writes.
22
+ * - `unsupportedFeature`: the store declares a feature this package doesn't model. Nothing was
23
+ * written.
24
+ * - `streamHeadsNotMigrated`: the store holds data but doesn't declare stream-heads. Nothing was
25
+ * written.
26
+ * - `storeNotInitialized`: the store doesn't declare stream-heads, and the writer doesn't
27
+ * initialize a store itself. Nothing was written.
28
+ * - `featureManifestChanged`: another writer changed the store's feature manifest since this one
29
+ * validated it. Retryable: the writer validates it again.
30
+ * - `transient`: the backend throttled the write. Retryable. Nothing was written.
31
+ * - `rebuildInProgress`: a projection rebuild holds the store, so commits wait. Retryable.
32
+ * - `historyDependentProjection`: compaction was refused for a history-dependent entity type.
33
+ * - `replay`: a stored event failed to upcast or decode while an aggregate loaded.
34
+ * - `snapshot`: a stored snapshot failed to decode while an aggregate loaded.
35
+ */
36
+ export type EvtErrorCode =
37
+ | 'invalidJson'
38
+ | 'invalidRecord'
39
+ | 'invalidArgument'
40
+ | 'invalidCommit'
41
+ | 'conflict'
42
+ | 'sequenceGap'
43
+ | 'streamHeadDrift'
44
+ | 'commitTooLarge'
45
+ | 'compactionUncovered'
46
+ | 'reservedEntityId'
47
+ | 'unsupportedFeature'
48
+ | 'streamHeadsNotMigrated'
49
+ | 'storeNotInitialized'
50
+ | 'featureManifestChanged'
51
+ | 'transient'
52
+ | 'rebuildInProgress'
53
+ | 'historyDependentProjection'
54
+ | 'replay'
55
+ | 'snapshot'
56
+
57
+ export class EvtError extends Error {
58
+ override readonly name: string = 'EvtError'
59
+ readonly code: EvtErrorCode
60
+
61
+ constructor(code: EvtErrorCode, message: string, options?: {cause?: unknown}) {
62
+ super(message, options)
63
+ this.code = code
64
+ }
65
+ }
66
+
67
+ /** Optimistic concurrency failed on a written stream: its sequence or snapshot moved. */
68
+ export class ConflictError extends EvtError {
69
+ override readonly name = 'ConflictError'
70
+ override readonly code = 'conflict'
71
+ readonly entityId: string
72
+
73
+ constructor(entityId: string, detail: string) {
74
+ super('conflict', `conflict on entity ${entityId}: ${detail}`)
75
+ this.entityId = entityId
76
+ }
77
+ }
78
+
79
+ /** A commit's first event skips past the stream's head, or a load found a hole. A conflict. */
80
+ export class SequenceGapError extends EvtError {
81
+ override readonly name = 'SequenceGapError'
82
+ override readonly code = 'sequenceGap'
83
+ readonly entityId: string
84
+ /** The last sequence before the gap. */
85
+ readonly after: bigint
86
+ /** The sequence found next, which isn't `after + 1`. */
87
+ readonly found: bigint
88
+
89
+ constructor(entityId: string, after: bigint, found: bigint) {
90
+ super(
91
+ 'sequenceGap',
92
+ `conflict on entity ${entityId}: the stream skips from sequence ${after} to ${found}: it ` +
93
+ 'was compacted during the load, or events expired',
94
+ )
95
+ this.entityId = entityId
96
+ this.after = after
97
+ this.found = found
98
+ }
99
+ }
100
+
101
+ /**
102
+ * A stream's head disagrees with its events or snapshot (ADR 0007): a writer appended without
103
+ * raising the head, events were deleted, a snapshot is ahead of the head, or a stream has data and
104
+ * no head. It isn't retryable: running the command again fails the same way. Repair is offline.
105
+ */
106
+ export class StreamHeadDriftError extends EvtError {
107
+ override readonly name = 'StreamHeadDriftError'
108
+ override readonly code = 'streamHeadDrift'
109
+ readonly entityId: string
110
+ /** The stored head, or null when the stream has no head. */
111
+ readonly stored: bigint | null
112
+ /** The head its events and snapshot imply: the higher of the two. */
113
+ readonly derived: bigint
114
+
115
+ constructor(entityId: string, stored: bigint | null, derived: bigint) {
116
+ super(
117
+ 'streamHeadDrift',
118
+ `stream head drift on entity ${entityId}: stored head ${stored ?? 'absent'}, ` +
119
+ `derived head ${derived}`,
120
+ )
121
+ this.entityId = entityId
122
+ this.stored = stored
123
+ this.derived = derived
124
+ }
125
+ }
126
+
127
+ /** The commit is over the transaction budget: too many actions, bytes, or bytes in one item. */
128
+ export class CommitTooLargeError extends EvtError {
129
+ override readonly name = 'CommitTooLargeError'
130
+ override readonly code = 'commitTooLarge'
131
+ readonly actions: number
132
+ readonly bytes: number
133
+ readonly largestItem: number
134
+
135
+ constructor(actions: number, bytes: number, largestItem: number) {
136
+ super(
137
+ 'commitTooLarge',
138
+ `commit exceeds transaction budget: ${actions} actions, ${bytes} bytes, ` +
139
+ `largest item ${largestItem}`,
140
+ )
141
+ this.actions = actions
142
+ this.bytes = bytes
143
+ this.largestItem = largestItem
144
+ }
145
+ }
146
+
147
+ /** Compaction was refused because no snapshot covers the events it would delete. */
148
+ export class CompactionUncoveredError extends EvtError {
149
+ override readonly name = 'CompactionUncoveredError'
150
+ override readonly code = 'compactionUncovered'
151
+ readonly entityId: string
152
+ readonly through: bigint
153
+ /** The event sequence the stored snapshot covers, or null with no snapshot. */
154
+ readonly covered: bigint | null
155
+
156
+ constructor(entityId: string, through: bigint, covered: bigint | null) {
157
+ super(
158
+ 'compactionUncovered',
159
+ 'compaction refused: no durable snapshot covers the requested range ' +
160
+ `(entity ${entityId}, through ${through}, snapshot covers ${covered ?? 'nothing'})`,
161
+ )
162
+ this.entityId = entityId
163
+ this.through = through
164
+ this.covered = covered
165
+ }
166
+ }
167
+
168
+ /** A write to an entity ID in a reserved namespace (`SYSTEM#`, `command-outcome#`). */
169
+ export class ReservedEntityIdError extends EvtError {
170
+ override readonly name = 'ReservedEntityIdError'
171
+ override readonly code = 'reservedEntityId'
172
+ readonly entityId: string
173
+
174
+ constructor(entityId: string) {
175
+ super(
176
+ 'reservedEntityId',
177
+ `entity ID ${entityId} is in a reserved namespace (SYSTEM#, command-outcome#)`,
178
+ )
179
+ this.entityId = entityId
180
+ }
181
+ }
182
+
183
+ /** A feature and one of its stored-format versions. */
184
+ export interface FeaturePair {
185
+ readonly name: string
186
+ readonly version: number
187
+ }
188
+
189
+ /**
190
+ * The store's feature manifest declares pairs this package doesn't model, so it refuses to write:
191
+ * a commit, a snapshot write, compaction or initialization. Nothing was written.
192
+ */
193
+ export class UnsupportedFeatureError extends EvtError {
194
+ override readonly name = 'UnsupportedFeatureError'
195
+ override readonly code = 'unsupportedFeature'
196
+ /** Every unsupported pair, sorted by name. */
197
+ readonly unsupported: readonly FeaturePair[]
198
+
199
+ constructor(unsupported: readonly FeaturePair[]) {
200
+ const pairs = unsupported.map((pair) => `${pair.name}@${pair.version}`).join(', ')
201
+ super('unsupportedFeature', `store declares unsupported features (${pairs}); refusing to write`)
202
+ this.unsupported = unsupported
203
+ }
204
+ }
205
+
206
+ /**
207
+ * The store holds data but its feature manifest doesn't declare stream-heads: its streams may have
208
+ * no head items, so a head-aware write could fail every stream. Migrate the store with every older
209
+ * writer stopped. Nothing was written.
210
+ */
211
+ export class StreamHeadsNotMigratedError extends EvtError {
212
+ override readonly name = 'StreamHeadsNotMigratedError'
213
+ override readonly code = 'streamHeadsNotMigrated'
214
+
215
+ constructor() {
216
+ super(
217
+ 'streamHeadsNotMigrated',
218
+ 'store does not declare the stream-heads feature and holds data without per-stream head ' +
219
+ 'items; migrate it before writing',
220
+ )
221
+ }
222
+ }
223
+
224
+ /**
225
+ * The store's feature manifest doesn't declare stream-heads, and this writer doesn't initialize a
226
+ * store itself (the DynamoDB default), so it refuses rather than scan the table to learn whether
227
+ * it's fresh. Initialize a fresh store once, at deploy time, or migrate one written before stream
228
+ * heads. Nothing was written.
229
+ */
230
+ export class StoreNotInitializedError extends EvtError {
231
+ override readonly name = 'StoreNotInitializedError'
232
+ override readonly code = 'storeNotInitialized'
233
+
234
+ constructor() {
235
+ super(
236
+ 'storeNotInitialized',
237
+ 'store is not initialized: the feature manifest does not declare stream-heads; initialize ' +
238
+ 'a fresh store, or migrate one written before stream heads, before writing',
239
+ )
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Another writer changed the store's feature manifest after this one validated it, so the commit's
245
+ * marker check failed. The writer has validated the new manifest, which it supports. Retryable.
246
+ * Nothing was written.
247
+ */
248
+ export class FeatureManifestChangedError extends EvtError {
249
+ override readonly name = 'FeatureManifestChangedError'
250
+ override readonly code = 'featureManifestChanged'
251
+
252
+ constructor(options?: {cause?: unknown}) {
253
+ super('featureManifestChanged', 'feature manifest changed under this writer', options)
254
+ }
255
+ }
256
+
257
+ /**
258
+ * The backend refused the write for now, such as a DynamoDB transaction cancelled for throttling.
259
+ * Retryable, after a backoff. Nothing was written. The backend's error is its cause.
260
+ */
261
+ export class TransientError extends EvtError {
262
+ override readonly name = 'TransientError'
263
+ override readonly code = 'transient'
264
+
265
+ constructor(detail: string, options?: {cause?: unknown}) {
266
+ super('transient', `transient failure: ${detail}`, options)
267
+ }
268
+ }
269
+
270
+ /**
271
+ * A projection rebuild holds the store, or one was force-released and no rebuild has finished
272
+ * since, so every commit is refused. Retryable: the command can run again once the rebuild ends.
273
+ */
274
+ export class RebuildInProgressError extends EvtError {
275
+ override readonly name = 'RebuildInProgressError'
276
+ override readonly code = 'rebuildInProgress'
277
+
278
+ constructor() {
279
+ super('rebuildInProgress', 'a projection rebuild is in progress')
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Compaction was refused because the store declares the entity's type history-dependent: its
285
+ * projectors' rebuild replays the events compaction would delete. Nothing was deleted.
286
+ */
287
+ export class HistoryDependentProjectionError extends EvtError {
288
+ override readonly name = 'HistoryDependentProjectionError'
289
+ override readonly code = 'historyDependentProjection'
290
+ readonly entityType: string
291
+ readonly entityId: string
292
+
293
+ constructor(entityType: string, entityId: string) {
294
+ super(
295
+ 'historyDependentProjection',
296
+ `entity type ${entityType} is declared history-dependent; compaction would delete the ` +
297
+ `commits its rebuild replays (entity ${entityId})`,
298
+ )
299
+ this.entityType = entityType
300
+ this.entityId = entityId
301
+ }
302
+ }
303
+
304
+ /** A load failed closed on a stored event: an upcaster threw, or the payload didn't decode. */
305
+ export class ReplayError extends EvtError {
306
+ override readonly name = 'ReplayError'
307
+ override readonly code = 'replay'
308
+ readonly eventType: string
309
+ /** The stored version, or the version an earlier upcaster produced. */
310
+ readonly version: number
311
+ readonly phase: 'upcast' | 'decode'
312
+
313
+ constructor(
314
+ eventType: string,
315
+ version: number,
316
+ phase: 'upcast' | 'decode',
317
+ detail: string,
318
+ cause?: unknown,
319
+ ) {
320
+ const doing = phase === 'upcast' ? 'upcasting' : 'decoding'
321
+ super('replay', `replay failed while ${doing} ${eventType} v${version}: ${detail}`, {cause})
322
+ this.eventType = eventType
323
+ this.version = version
324
+ this.phase = phase
325
+ }
326
+ }
327
+
328
+ /** A load found a stored snapshot whose payload the aggregate's state codec doesn't decode. */
329
+ export class SnapshotError extends EvtError {
330
+ override readonly name = 'SnapshotError'
331
+ override readonly code = 'snapshot'
332
+ readonly entityId: string
333
+
334
+ constructor(entityId: string, cause: unknown) {
335
+ super('snapshot', `decoding snapshot for entity ${entityId}: ${messageOf(cause)}`, {cause})
336
+ this.entityId = entityId
337
+ }
338
+ }
339
+
340
+ /** Whether an error is an optimistic-concurrency conflict: a `conflict` or a `sequenceGap`. */
341
+ export function isConflict(error: unknown): boolean {
342
+ return error instanceof EvtError && (error.code === 'conflict' || error.code === 'sequenceGap')
343
+ }
344
+
345
+ /**
346
+ * Whether reloading and running the command again may succeed: after a conflict, while a rebuild
347
+ * holds the store, after the feature manifest changed under the writer, or after throttling.
348
+ */
349
+ export function isRetryable(error: unknown): boolean {
350
+ if (isConflict(error)) {
351
+ return true
352
+ }
353
+
354
+ return (
355
+ error instanceof EvtError &&
356
+ (error.code === 'rebuildInProgress' ||
357
+ error.code === 'featureManifestChanged' ||
358
+ error.code === 'transient')
359
+ )
360
+ }
361
+
362
+ /** An error's message, or the value itself as text. */
363
+ export function messageOf(error: unknown): string {
364
+ return error instanceof Error ? error.message : String(error)
365
+ }