@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,332 @@
1
+ // The DynamoDB item layout of the event log. It's pure: it shapes items the way the AWS SDK's
2
+ // low-level client takes them, and imports nothing from the SDK.
3
+ //
4
+ // | Attribute | Type | Event item | Snapshot item (sk 0) | Head item (sk -1) |
5
+ // | ------------ | ---- | -------------- | -------------------- | ----------------- |
6
+ // | `pk` | S | entity ID | entity ID | entity ID |
7
+ // | `sk` | N | event sequence | `0` | `-1` |
8
+ // | `type` | S | event type | | |
9
+ // | `version` | N | event version | | |
10
+ // | `entityType` | S | entity type | entity type | |
11
+ // | `payload` | S | payload JSON | state JSON | |
12
+ // | `metadata` | S | metadata JSON | | |
13
+ // | `seq` | N | | snapshot sequence | |
14
+ // | `eventSeq` | N | | last event folded in | |
15
+ // | `head` | N | | | highest sequence |
16
+ // | `ttl` | N | optional | optional | optional |
17
+
18
+ import {type SerializedEvent, type SerializedSnapshot, eventId} from '../event.js'
19
+ import {EvtError} from '../errors.js'
20
+ import {
21
+ DOCUMENT,
22
+ checkInt64,
23
+ int64Argument,
24
+ invalid,
25
+ safeIntegerArgument,
26
+ } from '../fields.js'
27
+ import {parseJson} from '../json.js'
28
+ import {encodeMetadata, metadataFromJson} from '../metadata.js'
29
+ import {utf8Decode, utf8Encode, utf8Length} from '../utf8.js'
30
+
31
+ /** A DynamoDB attribute value, shaped like the SDK's `AttributeValue`. */
32
+ export type AttributeValue =
33
+ | {S: string}
34
+ | {N: string}
35
+ | {B: Uint8Array}
36
+ | {BOOL: boolean}
37
+ | {NULL: boolean}
38
+ | {M: Item}
39
+ | {L: AttributeValue[]}
40
+ | {SS: string[]}
41
+ | {NS: string[]}
42
+ | {BS: Uint8Array[]}
43
+
44
+ /** A DynamoDB item. */
45
+ export type Item = Record<string, AttributeValue>
46
+
47
+ /** The sort key of a stream's inline snapshot. */
48
+ export const SNAPSHOT_SORT_KEY = 0n
49
+
50
+ /** The sort key of a stream's head item. */
51
+ export const HEAD_SORT_KEY = -1n
52
+
53
+ /** The sort key of a single-item view row. */
54
+ export const DEFAULT_VIEW_SORT_KEY = 'VIEW'
55
+
56
+ /** Encodes an event as an event-log item. `ttl` (Unix seconds) is written only when positive. */
57
+ export function eventItem(event: SerializedEvent, ttl?: number): Item {
58
+ return withTtl(
59
+ {
60
+ pk: {S: event.entityId},
61
+ sk: {N: int64Argument(event.sequence, 'event.sequence').toString()},
62
+ type: {S: event.type},
63
+ version: {N: safeIntegerArgument(event.version, 'event.version').toString()},
64
+ entityType: {S: event.entityType},
65
+ payload: {S: payloadText(event.payload, 'event payload')},
66
+ metadata: {S: encodeMetadata(event.metadata)},
67
+ },
68
+ ttl,
69
+ )
70
+ }
71
+
72
+ /** Encodes a stream's inline snapshot item. `ttl` is written only when positive. */
73
+ export function snapshotItem(snapshot: SerializedSnapshot, ttl?: number): Item {
74
+ return withTtl(
75
+ {
76
+ pk: {S: snapshot.entityId},
77
+ sk: {N: SNAPSHOT_SORT_KEY.toString()},
78
+ seq: {N: int64Argument(snapshot.sequence, 'snapshot.sequence').toString()},
79
+ eventSeq: {N: int64Argument(snapshot.eventSequence, 'snapshot.eventSequence').toString()},
80
+ entityType: {S: snapshot.entityType},
81
+ payload: {S: payloadText(snapshot.payload, 'snapshot payload')},
82
+ },
83
+ ttl,
84
+ )
85
+ }
86
+
87
+ /** Encodes a stream's head item. `ttl` is written only when positive. */
88
+ export function headItem(entityId: string, head: bigint, ttl?: number): Item {
89
+ const item: Item = {
90
+ pk: {S: entityId},
91
+ sk: {N: HEAD_SORT_KEY.toString()},
92
+ head: {N: int64Argument(head, 'head').toString()},
93
+ }
94
+
95
+ return withTtl(item, ttl)
96
+ }
97
+
98
+ /** Encodes a single-item view row: `pk = {entityType}#{entityId}`, `sk = "VIEW"`. */
99
+ export function viewItem(entityType: string, entityId: string, payload: Uint8Array): Item {
100
+ return {
101
+ pk: {S: `${entityType}#${entityId}`},
102
+ sk: {S: DEFAULT_VIEW_SORT_KEY},
103
+ entityID: {S: entityId},
104
+ entityType: {S: entityType},
105
+ payload: {S: payloadText(payload, 'view payload')},
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Decodes an event-log item. Missing attributes other than the key are empty, and `metadata` must
111
+ * be present and not blank. A JSON `null` metadata is the zero value, as in Go.
112
+ */
113
+ export function eventFromItem(item: Item): SerializedEvent {
114
+ const entityId = requiredString(item, 'pk')
115
+ const sequence = requiredNumber(item, 'sk')
116
+ const id = eventId(entityId, sequence)
117
+ const metadata = optionalString(item, 'metadata') ?? ''
118
+ if (metadata.trim() === '') {
119
+ throw invalid(`event ${id} has empty metadata`)
120
+ }
121
+
122
+ let decoded
123
+ try {
124
+ decoded = metadataFromJson(parseJson(metadata, DOCUMENT))
125
+ } catch (error) {
126
+ throw new EvtError('invalidRecord', `event ${id} metadata: ${message(error)}`, {cause: error})
127
+ }
128
+
129
+ return {
130
+ id,
131
+ sequence,
132
+ type: optionalString(item, 'type') ?? '',
133
+ version: safeInteger(optionalNumber(item, 'version') ?? 0n, 'version'),
134
+ entityId,
135
+ entityType: optionalString(item, 'entityType') ?? '',
136
+ payload: utf8Encode(optionalString(item, 'payload') ?? ''),
137
+ metadata: decoded,
138
+ }
139
+ }
140
+
141
+ /** Decodes a stream's inline snapshot item. */
142
+ export function snapshotFromItem(item: Item): SerializedSnapshot {
143
+ return {
144
+ entityType: optionalString(item, 'entityType') ?? '',
145
+ entityId: requiredString(item, 'pk'),
146
+ sequence: optionalNumber(item, 'seq') ?? 0n,
147
+ eventSequence: optionalNumber(item, 'eventSeq') ?? 0n,
148
+ payload: utf8Encode(optionalString(item, 'payload') ?? ''),
149
+ }
150
+ }
151
+
152
+ /** Decodes a head item into its head and its `ttl`, if it has one. */
153
+ export function headFromItem(item: Item): {head: bigint; ttl: number | undefined} {
154
+ const ttl = optionalNumber(item, 'ttl')
155
+
156
+ return {
157
+ head: requiredNumber(item, 'head'),
158
+ ttl: ttl === undefined ? undefined : safeInteger(ttl, 'ttl'),
159
+ }
160
+ }
161
+
162
+ /** Whether an item is a stream's inline snapshot (`sk = 0`). */
163
+ export function isSnapshotItem(item: Item): boolean {
164
+ return sortKey(item) === SNAPSHOT_SORT_KEY
165
+ }
166
+
167
+ /** An item's numeric sort key, if it has one. */
168
+ export function sortKey(item: Item): bigint | undefined {
169
+ const sk = item['sk']
170
+ if (sk === undefined || !('N' in sk)) {
171
+ return undefined
172
+ }
173
+
174
+ return parseNumber(sk.N)
175
+ }
176
+
177
+ /**
178
+ * The `ttl` of an item written at `nowMs` (Unix milliseconds) under a retention of `retentionMs`:
179
+ * their sum in whole Unix seconds, rounded down after adding, as Go's `Time.Add(...).Unix()` is.
180
+ * Undefined, so no `ttl` is written, for a zero retention. A retention with a fraction of a
181
+ * millisecond is refused, because Go would count it.
182
+ */
183
+ export function expiry(nowMs: number, retentionMs: number): number | undefined {
184
+ if (!Number.isSafeInteger(nowMs) || !Number.isSafeInteger(retentionMs) || retentionMs < 0) {
185
+ throw new EvtError(
186
+ 'invalidArgument',
187
+ 'the clock and the retention must be whole, non-negative milliseconds: ' +
188
+ `${nowMs}, ${retentionMs}`,
189
+ )
190
+ }
191
+ if (retentionMs === 0) {
192
+ return undefined
193
+ }
194
+
195
+ return Math.floor((nowMs + retentionMs) / 1000)
196
+ }
197
+
198
+ /**
199
+ * The size DynamoDB counts for an item: each attribute's name plus its value, in UTF-8 bytes. The
200
+ * commit budget measures every action with it.
201
+ */
202
+ export function itemSize(item: Item): number {
203
+ let size = 0
204
+ for (const [name, value] of Object.entries(item)) {
205
+ size += utf8Length(name) + valueSize(value)
206
+ }
207
+
208
+ return size
209
+ }
210
+
211
+ function valueSize(value: AttributeValue): number {
212
+ if ('S' in value) {
213
+ return utf8Length(value.S)
214
+ }
215
+ if ('N' in value) {
216
+ return utf8Length(value.N)
217
+ }
218
+ if ('B' in value) {
219
+ return value.B.length
220
+ }
221
+ if ('BOOL' in value || 'NULL' in value) {
222
+ return 1
223
+ }
224
+ if ('M' in value) {
225
+ return 3 + itemSize(value.M) + Object.keys(value.M).length
226
+ }
227
+ if ('L' in value) {
228
+ return value.L.reduce((size, entry) => size + 1 + valueSize(entry), 3)
229
+ }
230
+ if ('SS' in value) {
231
+ return value.SS.reduce((size, entry) => size + utf8Length(entry), 0)
232
+ }
233
+ if ('NS' in value) {
234
+ return value.NS.reduce((size, entry) => size + utf8Length(entry), 0)
235
+ }
236
+
237
+ return value.BS.reduce((size, entry) => size + entry.length, 0)
238
+ }
239
+
240
+ function withTtl(item: Item, ttl: number | undefined): Item {
241
+ if (ttl !== undefined && safeIntegerArgument(ttl, 'ttl') > 0) {
242
+ item['ttl'] = {N: ttl.toString()}
243
+ }
244
+
245
+ return item
246
+ }
247
+
248
+ // A payload is stored as a DynamoDB string, so it must be UTF-8. A null payload is stored empty.
249
+ function payloadText(payload: Uint8Array | null, what: string): string {
250
+ if (payload === null) {
251
+ return ''
252
+ }
253
+
254
+ try {
255
+ return utf8Decode(payload, what)
256
+ } catch (error) {
257
+ const reason = `${what} is not valid UTF-8 and cannot be stored as a string`
258
+ throw new EvtError('invalidArgument', reason, {cause: error})
259
+ }
260
+ }
261
+
262
+ function requiredString(item: Item, name: string): string {
263
+ const value = optionalString(item, name)
264
+ if (value === undefined) {
265
+ throw invalid(`item has no ${name} attribute`)
266
+ }
267
+
268
+ return value
269
+ }
270
+
271
+ function requiredNumber(item: Item, name: string): bigint {
272
+ const value = optionalNumber(item, name)
273
+ if (value === undefined) {
274
+ throw invalid(`item has no ${name} attribute`)
275
+ }
276
+
277
+ return value
278
+ }
279
+
280
+ function optionalString(item: Item, name: string): string | undefined {
281
+ const value = item[name]
282
+ if (value === undefined || 'NULL' in value) {
283
+ return undefined
284
+ }
285
+ if (!('S' in value)) {
286
+ throw invalid(`attribute ${name} is not a string`)
287
+ }
288
+
289
+ return value.S
290
+ }
291
+
292
+ function optionalNumber(item: Item, name: string): bigint | undefined {
293
+ const value = item[name]
294
+ if (value === undefined || 'NULL' in value) {
295
+ return undefined
296
+ }
297
+ if (!('N' in value)) {
298
+ throw invalid(`attribute ${name} is not a number`)
299
+ }
300
+
301
+ const number = parseNumber(value.N)
302
+ if (number === undefined) {
303
+ throw invalid(`attribute ${name} is not an integer: ${value.N}`)
304
+ }
305
+
306
+ return number
307
+ }
308
+
309
+ // An int64 in decimal, as strconv.ParseInt reads it: an optional sign, then digits.
310
+ function parseNumber(text: string): bigint | undefined {
311
+ if (!/^[+-]?\d+$/.test(text)) {
312
+ return undefined
313
+ }
314
+
315
+ try {
316
+ return checkInt64(BigInt(text), 'number')
317
+ } catch {
318
+ return undefined
319
+ }
320
+ }
321
+
322
+ function safeInteger(value: bigint, name: string): number {
323
+ if (value < BigInt(Number.MIN_SAFE_INTEGER) || value > BigInt(Number.MAX_SAFE_INTEGER)) {
324
+ throw invalid(`attribute ${name} is ${value}, past the integers it holds`)
325
+ }
326
+
327
+ return Number(value)
328
+ }
329
+
330
+ function message(error: unknown): string {
331
+ return error instanceof Error ? error.message : String(error)
332
+ }
@@ -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,214 @@
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
+ * - `replay`: a stored event failed to upcast or decode while an aggregate loaded.
23
+ * - `snapshot`: a stored snapshot failed to decode while an aggregate loaded.
24
+ */
25
+ export type EvtErrorCode =
26
+ | 'invalidJson'
27
+ | 'invalidRecord'
28
+ | 'invalidArgument'
29
+ | 'invalidCommit'
30
+ | 'conflict'
31
+ | 'sequenceGap'
32
+ | 'streamHeadDrift'
33
+ | 'commitTooLarge'
34
+ | 'compactionUncovered'
35
+ | 'reservedEntityId'
36
+ | 'replay'
37
+ | 'snapshot'
38
+
39
+ export class EvtError extends Error {
40
+ override readonly name: string = 'EvtError'
41
+ readonly code: EvtErrorCode
42
+
43
+ constructor(code: EvtErrorCode, message: string, options?: {cause?: unknown}) {
44
+ super(message, options)
45
+ this.code = code
46
+ }
47
+ }
48
+
49
+ /** Optimistic concurrency failed on a written stream: its sequence or snapshot moved. */
50
+ export class ConflictError extends EvtError {
51
+ override readonly name = 'ConflictError'
52
+ override readonly code = 'conflict'
53
+ readonly entityId: string
54
+
55
+ constructor(entityId: string, detail: string) {
56
+ super('conflict', `conflict on entity ${entityId}: ${detail}`)
57
+ this.entityId = entityId
58
+ }
59
+ }
60
+
61
+ /** A commit's first event skips past the stream's head, or a load found a hole. A conflict. */
62
+ export class SequenceGapError extends EvtError {
63
+ override readonly name = 'SequenceGapError'
64
+ override readonly code = 'sequenceGap'
65
+ readonly entityId: string
66
+ /** The last sequence before the gap. */
67
+ readonly after: bigint
68
+ /** The sequence found next, which isn't `after + 1`. */
69
+ readonly found: bigint
70
+
71
+ constructor(entityId: string, after: bigint, found: bigint) {
72
+ super(
73
+ 'sequenceGap',
74
+ `conflict on entity ${entityId}: the stream skips from sequence ${after} to ${found}: it ` +
75
+ 'was compacted during the load, or events expired',
76
+ )
77
+ this.entityId = entityId
78
+ this.after = after
79
+ this.found = found
80
+ }
81
+ }
82
+
83
+ /**
84
+ * A stream's head disagrees with its events or snapshot (ADR 0007): a writer appended without
85
+ * raising the head, events were deleted, a snapshot is ahead of the head, or a stream has data and
86
+ * no head. It isn't retryable: running the command again fails the same way. Repair is offline.
87
+ */
88
+ export class StreamHeadDriftError extends EvtError {
89
+ override readonly name = 'StreamHeadDriftError'
90
+ override readonly code = 'streamHeadDrift'
91
+ readonly entityId: string
92
+ /** The stored head, or null when the stream has no head. */
93
+ readonly stored: bigint | null
94
+ /** The head its events and snapshot imply: the higher of the two. */
95
+ readonly derived: bigint
96
+
97
+ constructor(entityId: string, stored: bigint | null, derived: bigint) {
98
+ super(
99
+ 'streamHeadDrift',
100
+ `stream head drift on entity ${entityId}: stored head ${stored ?? 'absent'}, ` +
101
+ `derived head ${derived}`,
102
+ )
103
+ this.entityId = entityId
104
+ this.stored = stored
105
+ this.derived = derived
106
+ }
107
+ }
108
+
109
+ /** The commit is over the transaction budget: too many actions, bytes, or bytes in one item. */
110
+ export class CommitTooLargeError extends EvtError {
111
+ override readonly name = 'CommitTooLargeError'
112
+ override readonly code = 'commitTooLarge'
113
+ readonly actions: number
114
+ readonly bytes: number
115
+ readonly largestItem: number
116
+
117
+ constructor(actions: number, bytes: number, largestItem: number) {
118
+ super(
119
+ 'commitTooLarge',
120
+ `commit exceeds transaction budget: ${actions} actions, ${bytes} bytes, ` +
121
+ `largest item ${largestItem}`,
122
+ )
123
+ this.actions = actions
124
+ this.bytes = bytes
125
+ this.largestItem = largestItem
126
+ }
127
+ }
128
+
129
+ /** Compaction was refused because no snapshot covers the events it would delete. */
130
+ export class CompactionUncoveredError extends EvtError {
131
+ override readonly name = 'CompactionUncoveredError'
132
+ override readonly code = 'compactionUncovered'
133
+ readonly entityId: string
134
+ readonly through: bigint
135
+ /** The event sequence the stored snapshot covers, or null with no snapshot. */
136
+ readonly covered: bigint | null
137
+
138
+ constructor(entityId: string, through: bigint, covered: bigint | null) {
139
+ super(
140
+ 'compactionUncovered',
141
+ 'compaction refused: no durable snapshot covers the requested range ' +
142
+ `(entity ${entityId}, through ${through}, snapshot covers ${covered ?? 'nothing'})`,
143
+ )
144
+ this.entityId = entityId
145
+ this.through = through
146
+ this.covered = covered
147
+ }
148
+ }
149
+
150
+ /** A write to an entity ID in a reserved namespace (`SYSTEM#`, `command-outcome#`). */
151
+ export class ReservedEntityIdError extends EvtError {
152
+ override readonly name = 'ReservedEntityIdError'
153
+ override readonly code = 'reservedEntityId'
154
+ readonly entityId: string
155
+
156
+ constructor(entityId: string) {
157
+ super(
158
+ 'reservedEntityId',
159
+ `entity ID ${entityId} is in a reserved namespace (SYSTEM#, command-outcome#)`,
160
+ )
161
+ this.entityId = entityId
162
+ }
163
+ }
164
+
165
+ /** A load failed closed on a stored event: an upcaster threw, or the payload didn't decode. */
166
+ export class ReplayError extends EvtError {
167
+ override readonly name = 'ReplayError'
168
+ override readonly code = 'replay'
169
+ readonly eventType: string
170
+ /** The stored version, or the version an earlier upcaster produced. */
171
+ readonly version: number
172
+ readonly phase: 'upcast' | 'decode'
173
+
174
+ constructor(
175
+ eventType: string,
176
+ version: number,
177
+ phase: 'upcast' | 'decode',
178
+ detail: string,
179
+ cause?: unknown,
180
+ ) {
181
+ const doing = phase === 'upcast' ? 'upcasting' : 'decoding'
182
+ super('replay', `replay failed while ${doing} ${eventType} v${version}: ${detail}`, {cause})
183
+ this.eventType = eventType
184
+ this.version = version
185
+ this.phase = phase
186
+ }
187
+ }
188
+
189
+ /** A load found a stored snapshot whose payload the aggregate's state codec doesn't decode. */
190
+ export class SnapshotError extends EvtError {
191
+ override readonly name = 'SnapshotError'
192
+ override readonly code = 'snapshot'
193
+ readonly entityId: string
194
+
195
+ constructor(entityId: string, cause: unknown) {
196
+ super('snapshot', `decoding snapshot for entity ${entityId}: ${messageOf(cause)}`, {cause})
197
+ this.entityId = entityId
198
+ }
199
+ }
200
+
201
+ /** Whether an error is an optimistic-concurrency conflict: a `conflict` or a `sequenceGap`. */
202
+ export function isConflict(error: unknown): boolean {
203
+ return error instanceof EvtError && (error.code === 'conflict' || error.code === 'sequenceGap')
204
+ }
205
+
206
+ /** Whether reloading and running the command again may succeed. So far, only after a conflict. */
207
+ export function isRetryable(error: unknown): boolean {
208
+ return isConflict(error)
209
+ }
210
+
211
+ /** An error's message, or the value itself as text. */
212
+ export function messageOf(error: unknown): string {
213
+ return error instanceof Error ? error.message : String(error)
214
+ }