@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.
- package/LICENSE +176 -0
- package/README.md +122 -2
- package/dist/aggregate.d.ts +83 -0
- package/dist/aggregate.d.ts.map +1 -0
- package/dist/aggregate.js +92 -0
- package/dist/aggregate.js.map +1 -0
- package/dist/base64.d.ts +8 -0
- package/dist/base64.d.ts.map +1 -0
- package/dist/base64.js +67 -0
- package/dist/base64.js.map +1 -0
- package/dist/codec.d.ts +56 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +90 -0
- package/dist/codec.js.map +1 -0
- package/dist/commit-id.d.ts +3 -0
- package/dist/commit-id.d.ts.map +1 -0
- package/dist/commit-id.js +43 -0
- package/dist/commit-id.js.map +1 -0
- package/dist/dynamodb/head.d.ts +34 -0
- package/dist/dynamodb/head.d.ts.map +1 -0
- package/dist/dynamodb/head.js +62 -0
- package/dist/dynamodb/head.js.map +1 -0
- package/dist/dynamodb/index.d.ts +2 -0
- package/dist/dynamodb/index.d.ts.map +1 -0
- package/dist/dynamodb/index.js +4 -0
- package/dist/dynamodb/index.js.map +1 -0
- package/dist/dynamodb/item.d.ts +68 -0
- package/dist/dynamodb/item.d.ts.map +1 -0
- package/dist/dynamodb/item.js +263 -0
- package/dist/dynamodb/item.js.map +1 -0
- package/dist/envelope.d.ts +15 -0
- package/dist/envelope.d.ts.map +1 -0
- package/dist/envelope.js +59 -0
- package/dist/envelope.js.map +1 -0
- package/dist/errors.d.ts +111 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +139 -0
- package/dist/errors.js.map +1 -0
- package/dist/event.d.ts +47 -0
- package/dist/event.d.ts.map +1 -0
- package/dist/event.js +64 -0
- package/dist/event.js.map +1 -0
- package/dist/fields.d.ts +31 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +105 -0
- package/dist/fields.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/json.d.ts +47 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +458 -0
- package/dist/json.js.map +1 -0
- package/dist/memory/index.d.ts +2 -0
- package/dist/memory/index.d.ts.map +1 -0
- package/dist/memory/index.js +3 -0
- package/dist/memory/index.js.map +1 -0
- package/dist/memory/repository.d.ts +34 -0
- package/dist/memory/repository.d.ts.map +1 -0
- package/dist/memory/repository.js +219 -0
- package/dist/memory/repository.js.map +1 -0
- package/dist/metadata.d.ts +47 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +75 -0
- package/dist/metadata.js.map +1 -0
- package/dist/repository.d.ts +102 -0
- package/dist/repository.d.ts.map +1 -0
- package/dist/repository.js +131 -0
- package/dist/repository.js.map +1 -0
- package/dist/retry.d.ts +25 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +50 -0
- package/dist/retry.js.map +1 -0
- package/dist/snapshots.d.ts +21 -0
- package/dist/snapshots.d.ts.map +1 -0
- package/dist/snapshots.js +35 -0
- package/dist/snapshots.js.map +1 -0
- package/dist/store.d.ts +103 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +246 -0
- package/dist/store.js.map +1 -0
- package/dist/testing/equal.d.ts +22 -0
- package/dist/testing/equal.d.ts.map +1 -0
- package/dist/testing/equal.js +121 -0
- package/dist/testing/equal.js.map +1 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/scenario.d.ts +136 -0
- package/dist/testing/scenario.d.ts.map +1 -0
- package/dist/testing/scenario.js +307 -0
- package/dist/testing/scenario.js.map +1 -0
- package/dist/utf8.d.ts +17 -0
- package/dist/utf8.d.ts.map +1 -0
- package/dist/utf8.js +82 -0
- package/dist/utf8.js.map +1 -0
- package/package.json +56 -4
- package/src/aggregate.ts +196 -0
- package/src/base64.ts +80 -0
- package/src/codec.ts +139 -0
- package/src/commit-id.ts +50 -0
- package/src/dynamodb/head.ts +93 -0
- package/src/dynamodb/index.ts +21 -0
- package/src/dynamodb/item.ts +332 -0
- package/src/envelope.ts +67 -0
- package/src/errors.ts +214 -0
- package/src/event.ts +117 -0
- package/src/fields.ts +134 -0
- package/src/globals.d.ts +39 -0
- package/src/index.ts +75 -0
- package/src/json.ts +546 -0
- package/src/memory/index.ts +3 -0
- package/src/memory/repository.ts +313 -0
- package/src/metadata.ts +120 -0
- package/src/repository.ts +247 -0
- package/src/retry.ts +74 -0
- package/src/snapshots.ts +42 -0
- package/src/store.ts +377 -0
- package/src/testing/equal.ts +146 -0
- package/src/testing/index.ts +14 -0
- package/src/testing/scenario.ts +406 -0
- package/src/utf8.ts +92 -0
|
@@ -0,0 +1,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
|
+
}
|
package/src/envelope.ts
ADDED
|
@@ -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
|
+
}
|