@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
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@photon-grove/evt",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Event sourcing for TypeScript, wire-compatible with the Go and Rust evt implementations.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"event-sourcing",
|
|
7
|
+
"cqrs",
|
|
8
|
+
"dynamodb",
|
|
9
|
+
"postgresql"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/photon-grove/evt/tree/main/packages/evt#readme",
|
|
12
|
+
"bugs": "https://github.com/photon-grove/evt/issues",
|
|
13
|
+
"license": "Apache-2.0",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/photon-grove/evt.git",
|
|
17
|
+
"directory": "packages/evt"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=22.12"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"src"
|
|
27
|
+
],
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./dynamodb": {
|
|
34
|
+
"types": "./dist/dynamodb/index.d.ts",
|
|
35
|
+
"default": "./dist/dynamodb/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./memory": {
|
|
38
|
+
"types": "./dist/memory/index.d.ts",
|
|
39
|
+
"default": "./dist/memory/index.js"
|
|
40
|
+
},
|
|
41
|
+
"./testing": {
|
|
42
|
+
"types": "./dist/testing/index.d.ts",
|
|
43
|
+
"default": "./dist/testing/index.js"
|
|
44
|
+
},
|
|
45
|
+
"./package.json": "./package.json"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsc",
|
|
49
|
+
"check": "tsc --noEmit && tsc -p tsconfig.test.json && oxlint --deny-warnings && node scripts/check-imports.ts",
|
|
50
|
+
"test": "vitest run"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^25.9.9",
|
|
54
|
+
"oxlint": "1.88.0",
|
|
55
|
+
"typescript": "^7.0.2",
|
|
56
|
+
"vitest": "5.0.5"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/aggregate.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Aggregates: an entity's state folded from its events, the commands it decides on, and the typed
|
|
2
|
+
// events it records, each with its stored version, payload codec and upcasters.
|
|
3
|
+
|
|
4
|
+
import type {Codec} from './codec.js'
|
|
5
|
+
import {EvtError, ReplayError, messageOf} from './errors.js'
|
|
6
|
+
import type {SerializedEvent} from './event.js'
|
|
7
|
+
import {type JsonValue, parseJson, stringifyJson} from './json.js'
|
|
8
|
+
import {utf8Decode, utf8Encode} from './utf8.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Migrates a stored payload of its event type before it is decoded, taking an event at the
|
|
12
|
+
* upcaster's from version to its to version. An event type's upcasters run in declaration order,
|
|
13
|
+
* each once, and each sees the output of the ones before it, so a 1 → 2 upcaster followed by a
|
|
14
|
+
* 2 → 3 one takes a version 1 event to version 3. Stored events are never rewritten.
|
|
15
|
+
*/
|
|
16
|
+
export interface Upcaster {
|
|
17
|
+
readonly from: number
|
|
18
|
+
readonly to: number
|
|
19
|
+
upcast(payload: Uint8Array): Uint8Array
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* An upcaster that rewrites a JSON payload. `migrate` gets it losslessly, as {@link parseJson}
|
|
24
|
+
* reads it (objects as `Map`s in their stored order, numbers as {@link JsonNumber}s), and returns
|
|
25
|
+
* the new payload, which is written with {@link stringifyJson}. What the migration doesn't touch
|
|
26
|
+
* is written back exactly, whatever its integers or keys.
|
|
27
|
+
*/
|
|
28
|
+
export function jsonUpcaster(
|
|
29
|
+
from: number,
|
|
30
|
+
to: number,
|
|
31
|
+
migrate: (payload: JsonValue) => unknown,
|
|
32
|
+
): Upcaster {
|
|
33
|
+
return {
|
|
34
|
+
from,
|
|
35
|
+
to,
|
|
36
|
+
upcast(payload) {
|
|
37
|
+
const migrated = migrate(parseJson(utf8Decode(payload, 'payload')))
|
|
38
|
+
|
|
39
|
+
return utf8Encode(stringifyJson(migrated))
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** How one event type is stored: its current version, its payload codec, and its upcasters. */
|
|
45
|
+
export interface EventDefinition<T> {
|
|
46
|
+
/** The version new events are written with, and the one stored events are upcast to. */
|
|
47
|
+
readonly version: number
|
|
48
|
+
readonly codec: Codec<T>
|
|
49
|
+
readonly upcasters?: readonly Upcaster[] | undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// oxlint-disable-next-line typescript/no-explicit-any -- each entry has its own data type.
|
|
53
|
+
export type EventDefinitions = Readonly<Record<string, EventDefinition<any>>>
|
|
54
|
+
|
|
55
|
+
/** The events a set of definitions describes: `{type, data}` for each event type. */
|
|
56
|
+
export type EventOf<D extends EventDefinitions> = {
|
|
57
|
+
[K in keyof D & string]: {
|
|
58
|
+
type: K
|
|
59
|
+
data: D[K] extends EventDefinition<infer T> ? T : never
|
|
60
|
+
}
|
|
61
|
+
}[keyof D & string]
|
|
62
|
+
|
|
63
|
+
/** Declares an aggregate's event types, checking each version and upcaster. */
|
|
64
|
+
export function defineEvents<const D extends EventDefinitions>(definitions: D): D {
|
|
65
|
+
for (const [type, definition] of Object.entries(definitions)) {
|
|
66
|
+
positive(definition.version, `${type} version`)
|
|
67
|
+
for (const upcaster of definition.upcasters ?? []) {
|
|
68
|
+
positive(upcaster.from, `${type} upcaster from`)
|
|
69
|
+
positive(upcaster.to, `${type} upcaster to`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return definitions
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A domain refusal of a command: a code to branch on, a message, and optional JSON details. */
|
|
77
|
+
export interface Rejection {
|
|
78
|
+
code: string
|
|
79
|
+
message: string
|
|
80
|
+
details?: unknown
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** What a command handler decides: accept with events (possibly none), or reject. */
|
|
84
|
+
export type Decision<E> =
|
|
85
|
+
| {kind: 'accept'; events: readonly E[]}
|
|
86
|
+
| {kind: 'reject'; rejection: Rejection}
|
|
87
|
+
|
|
88
|
+
/** An aggregate type, as {@link defineAggregate} takes it. */
|
|
89
|
+
export interface AggregateDefinition<S, C, D extends EventDefinitions> {
|
|
90
|
+
/** The stored entity type, such as `account`. */
|
|
91
|
+
readonly type: string
|
|
92
|
+
readonly events: D
|
|
93
|
+
/** The snapshot codec: how the state is stored. */
|
|
94
|
+
readonly state: Codec<S>
|
|
95
|
+
/** The state of an entity with no events. */
|
|
96
|
+
initial(): S
|
|
97
|
+
/** Decides what a command does. It must not change `state`. */
|
|
98
|
+
handle(state: S, command: C): Decision<EventOf<D>> | Promise<Decision<EventOf<D>>>
|
|
99
|
+
/** Folds one event into the state, returning the next state. It must not fail. */
|
|
100
|
+
apply(state: S, event: EventOf<D>): S
|
|
101
|
+
/**
|
|
102
|
+
* This type's own number of events between snapshots, overriding the store's. 0 disables its
|
|
103
|
+
* snapshots.
|
|
104
|
+
*/
|
|
105
|
+
readonly snapshotInterval?: number | undefined
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A checked aggregate type. */
|
|
109
|
+
export type Aggregate<S, C, D extends EventDefinitions> = Readonly<AggregateDefinition<S, C, D>>
|
|
110
|
+
|
|
111
|
+
/** Declares an aggregate type. */
|
|
112
|
+
export function defineAggregate<S, C, D extends EventDefinitions>(
|
|
113
|
+
definition: AggregateDefinition<S, C, D>,
|
|
114
|
+
): Aggregate<S, C, D> {
|
|
115
|
+
if (definition.type === '') {
|
|
116
|
+
throw new EvtError('invalidArgument', 'an aggregate type must have a name')
|
|
117
|
+
}
|
|
118
|
+
if (definition.snapshotInterval !== undefined) {
|
|
119
|
+
const interval = definition.snapshotInterval
|
|
120
|
+
if (!Number.isSafeInteger(interval) || interval < 0) {
|
|
121
|
+
throw new EvtError(
|
|
122
|
+
'invalidArgument',
|
|
123
|
+
`${definition.type} snapshot interval must be a whole number of events: ${interval}`,
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return definition
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Encodes one event's data with its type's codec, as the payload and version to store. */
|
|
132
|
+
export function encodeEventData<D extends EventDefinitions>(
|
|
133
|
+
events: D,
|
|
134
|
+
event: EventOf<D>,
|
|
135
|
+
): {version: number; payload: Uint8Array} {
|
|
136
|
+
const definition = definitionOf(events, event.type)
|
|
137
|
+
if (definition === undefined) {
|
|
138
|
+
throw new EvtError('invalidArgument', `${event.type} is not one of the aggregate's event types`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {version: definition.version, payload: definition.codec.encode(event.data)}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Upcasts and decodes a stored event, failing closed with a {@link ReplayError}. */
|
|
145
|
+
export function replayEvent<D extends EventDefinitions>(
|
|
146
|
+
events: D,
|
|
147
|
+
stored: SerializedEvent,
|
|
148
|
+
): EventOf<D> {
|
|
149
|
+
const definition = definitionOf(events, stored.type)
|
|
150
|
+
if (definition === undefined) {
|
|
151
|
+
throw new ReplayError(stored.type, stored.version, 'decode', 'unknown event type')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let payload = stored.payload ?? new Uint8Array()
|
|
155
|
+
let version = stored.version
|
|
156
|
+
for (const upcaster of definition.upcasters ?? []) {
|
|
157
|
+
if (upcaster.from !== version) {
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
payload = upcaster.upcast(payload)
|
|
163
|
+
} catch (error) {
|
|
164
|
+
throw new ReplayError(stored.type, version, 'upcast', messageOf(error), error)
|
|
165
|
+
}
|
|
166
|
+
version = upcaster.to
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (version !== definition.version) {
|
|
170
|
+
throw new ReplayError(
|
|
171
|
+
stored.type,
|
|
172
|
+
version,
|
|
173
|
+
'decode',
|
|
174
|
+
`no upcaster takes it to version ${definition.version}`,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
return {type: stored.type, data: definition.codec.decode(payload)} as EventOf<D>
|
|
180
|
+
} catch (error) {
|
|
181
|
+
throw new ReplayError(stored.type, version, 'decode', messageOf(error), error)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function definitionOf<D extends EventDefinitions>(
|
|
186
|
+
events: D,
|
|
187
|
+
type: string,
|
|
188
|
+
): EventDefinition<unknown> | undefined {
|
|
189
|
+
return Object.hasOwn(events, type) ? events[type] : undefined
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function positive(value: number, what: string): void {
|
|
193
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
194
|
+
throw new EvtError('invalidArgument', `${what} must be a whole number of at least 1: ${value}`)
|
|
195
|
+
}
|
|
196
|
+
}
|
package/src/base64.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Standard, padded base64 (Go's base64.StdEncoding), which is how a Go []byte appears in JSON.
|
|
2
|
+
// It's our own code because Buffer is Node-only and Uint8Array.fromBase64 isn't on every runtime.
|
|
3
|
+
|
|
4
|
+
import {EvtError} from './errors.js'
|
|
5
|
+
|
|
6
|
+
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
7
|
+
|
|
8
|
+
const SYMBOLS = new Map(Array.from(ALPHABET, (symbol, value) => [symbol.charCodeAt(0), value]))
|
|
9
|
+
|
|
10
|
+
const PAD = 0x3d // '='
|
|
11
|
+
|
|
12
|
+
/** Encodes bytes as standard, padded base64. */
|
|
13
|
+
export function base64Encode(bytes: Uint8Array): string {
|
|
14
|
+
let out = ''
|
|
15
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
16
|
+
const b0 = bytes[i] ?? 0
|
|
17
|
+
const b1 = bytes[i + 1] ?? 0
|
|
18
|
+
const b2 = bytes[i + 2] ?? 0
|
|
19
|
+
const triple = (b0 << 16) | (b1 << 8) | b2
|
|
20
|
+
|
|
21
|
+
out += ALPHABET.charAt((triple >> 18) & 63)
|
|
22
|
+
out += ALPHABET.charAt((triple >> 12) & 63)
|
|
23
|
+
out += i + 1 < bytes.length ? ALPHABET.charAt((triple >> 6) & 63) : '='
|
|
24
|
+
out += i + 2 < bytes.length ? ALPHABET.charAt(triple & 63) : '='
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return out
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Decodes standard, padded base64 the way Go does: `\r` and `\n` are skipped, and anything else
|
|
32
|
+
* outside the alphabet, missing padding, or padding before the end is refused.
|
|
33
|
+
*/
|
|
34
|
+
export function base64Decode(text: string): Uint8Array<ArrayBuffer> {
|
|
35
|
+
const symbols: number[] = []
|
|
36
|
+
for (let i = 0; i < text.length; i++) {
|
|
37
|
+
const unit = text.charCodeAt(i)
|
|
38
|
+
if (unit !== 0x0d && unit !== 0x0a) {
|
|
39
|
+
symbols.push(unit)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (symbols.length % 4 !== 0) {
|
|
43
|
+
throw invalid(`length ${symbols.length}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const quads = symbols.length / 4
|
|
47
|
+
const last = symbols.slice(-2)
|
|
48
|
+
const padding = last[1] === PAD ? (last[0] === PAD ? 2 : 1) : 0
|
|
49
|
+
const out = new Uint8Array(quads * 3 - padding)
|
|
50
|
+
|
|
51
|
+
for (let quad = 0; quad < quads; quad++) {
|
|
52
|
+
let triple = 0
|
|
53
|
+
for (let offset = 0; offset < 4; offset++) {
|
|
54
|
+
const index = quad * 4 + offset
|
|
55
|
+
const symbol = symbols[index] ?? PAD
|
|
56
|
+
const isPadding = quad === quads - 1 && offset >= 4 - padding
|
|
57
|
+
const value = isPadding ? 0 : SYMBOLS.get(symbol)
|
|
58
|
+
if (value === undefined) {
|
|
59
|
+
throw invalid(`at input byte ${index}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
triple = (triple << 6) | value
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const at = quad * 3
|
|
66
|
+
out[at] = triple >> 16
|
|
67
|
+
if (at + 1 < out.length) {
|
|
68
|
+
out[at + 1] = (triple >> 8) & 0xff
|
|
69
|
+
}
|
|
70
|
+
if (at + 2 < out.length) {
|
|
71
|
+
out[at + 2] = triple & 0xff
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function invalid(where: string): EvtError {
|
|
79
|
+
return new EvtError('invalidRecord', `illegal base64 data ${where}`)
|
|
80
|
+
}
|
package/src/codec.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Payload codecs: how an application turns event data and aggregate state into stored bytes and
|
|
2
|
+
// back. evt stores the bytes verbatim, so a codec owns their format.
|
|
3
|
+
|
|
4
|
+
import {EvtError, messageOf} from './errors.js'
|
|
5
|
+
import {JsonNumber, type JsonValue, parseJson, stringifyJson} from './json.js'
|
|
6
|
+
import {utf8Decode, utf8Encode} from './utf8.js'
|
|
7
|
+
|
|
8
|
+
/** Encodes values of `T` to stored bytes and decodes them back. */
|
|
9
|
+
export interface Codec<T> {
|
|
10
|
+
encode(value: T): Uint8Array
|
|
11
|
+
decode(bytes: Uint8Array): T
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The part of a [Standard Schema](https://standardschema.dev) validator evt calls, so Zod,
|
|
16
|
+
* Valibot, ArkType and the rest work without evt depending on any of them.
|
|
17
|
+
*/
|
|
18
|
+
export interface StandardSchemaV1<Input = unknown, Output = Input> {
|
|
19
|
+
readonly '~standard': {
|
|
20
|
+
readonly version: 1
|
|
21
|
+
readonly vendor: string
|
|
22
|
+
readonly validate: (value: unknown) => StandardResult<Output> | Promise<StandardResult<Output>>
|
|
23
|
+
readonly types?: {readonly input: Input; readonly output: Output} | undefined
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A Standard Schema validation result: the value, or the issues that refused it. */
|
|
28
|
+
export type StandardResult<Output> =
|
|
29
|
+
| {readonly value: Output; readonly issues?: undefined}
|
|
30
|
+
| {readonly issues: readonly {readonly message: string}[]}
|
|
31
|
+
|
|
32
|
+
/** Options for {@link json}. */
|
|
33
|
+
export interface JsonOptions<T> {
|
|
34
|
+
/** Validates each value before it's encoded and after it's decoded, synchronously. */
|
|
35
|
+
schema?: StandardSchemaV1<unknown, T> | undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A JSON codec written with evt's encoder (the one Go's `encoding/json` output is reproduced
|
|
40
|
+
* with). Plain objects encode in property order.
|
|
41
|
+
*
|
|
42
|
+
* Decoding gives plain objects, arrays and numbers, so `T` must be plain JSON: a `Map`, a `bigint`
|
|
43
|
+
* or a `Date` doesn't decode back to itself. A domain that must match a Go or Rust one byte for
|
|
44
|
+
* byte, with a map whose keys are written sorted, needs a codec of its own that decodes the map as
|
|
45
|
+
* a `Map`. An integer past `Number.MAX_SAFE_INTEGER` is refused rather than rounded.
|
|
46
|
+
*
|
|
47
|
+
* Encoding refuses a value decoding would refuse, and with a `schema`, each value is validated
|
|
48
|
+
* before it's encoded and after it's decoded, so an event or a snapshot that couldn't be read back
|
|
49
|
+
* fails its commit instead of every later load. The schema must not transform values: the value
|
|
50
|
+
* is encoded as given.
|
|
51
|
+
*/
|
|
52
|
+
export function json<T>(options: JsonOptions<T> = {}): Codec<T> {
|
|
53
|
+
const schema = options.schema
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
encode(value) {
|
|
57
|
+
if (schema) {
|
|
58
|
+
validate(schema, value, 'invalidArgument')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const text = stringifyJson(value)
|
|
62
|
+
readable(text)
|
|
63
|
+
|
|
64
|
+
return utf8Encode(text)
|
|
65
|
+
},
|
|
66
|
+
decode(bytes) {
|
|
67
|
+
const value = plainJson(parseJson(utf8Decode(bytes, 'payload')))
|
|
68
|
+
|
|
69
|
+
return schema ? validate(schema, value, 'invalidRecord') : (value as T)
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A JSON value as plain JavaScript: objects as plain objects, numbers as `number`. An integer past
|
|
76
|
+
* the safe range is refused, because `number` would round it.
|
|
77
|
+
*/
|
|
78
|
+
export function plainJson(value: JsonValue): unknown {
|
|
79
|
+
if (value instanceof JsonNumber) {
|
|
80
|
+
const number = Number(value.text)
|
|
81
|
+
if (value.isInteger && !Number.isSafeInteger(number)) {
|
|
82
|
+
throw new EvtError(
|
|
83
|
+
'invalidRecord',
|
|
84
|
+
`the integer ${value.text} is past the integers a number holds; decode it with a codec ` +
|
|
85
|
+
'of its own',
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return number
|
|
90
|
+
}
|
|
91
|
+
if (Array.isArray(value)) {
|
|
92
|
+
return value.map(plainJson)
|
|
93
|
+
}
|
|
94
|
+
if (value instanceof Map) {
|
|
95
|
+
const object: Record<string, unknown> = {}
|
|
96
|
+
for (const [key, entry] of value) {
|
|
97
|
+
// defineProperty, so a "__proto__" key is a plain property and never a prototype.
|
|
98
|
+
Object.defineProperty(object, key, {
|
|
99
|
+
value: plainJson(entry),
|
|
100
|
+
enumerable: true,
|
|
101
|
+
writable: true,
|
|
102
|
+
configurable: true,
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return object
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return value
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Refuses JSON that decode would refuse, such as an integer past the safe range, so the value
|
|
113
|
+
// fails its commit instead of every later load.
|
|
114
|
+
function readable(text: string): void {
|
|
115
|
+
try {
|
|
116
|
+
plainJson(parseJson(text))
|
|
117
|
+
} catch (error) {
|
|
118
|
+
throw new EvtError('invalidArgument', messageOf(error), {cause: error})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function validate<T>(
|
|
123
|
+
schema: StandardSchemaV1<unknown, T>,
|
|
124
|
+
value: unknown,
|
|
125
|
+
code: 'invalidArgument' | 'invalidRecord',
|
|
126
|
+
): T {
|
|
127
|
+
const result = schema['~standard'].validate(value)
|
|
128
|
+
if ('then' in result && typeof result.then === 'function') {
|
|
129
|
+
throw new EvtError('invalidArgument', 'a codec schema must validate synchronously')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const settled = result as StandardResult<T>
|
|
133
|
+
if (settled.issues) {
|
|
134
|
+
const issues = settled.issues.map((issue) => issue.message).join('; ')
|
|
135
|
+
throw new EvtError(code, `the payload does not match its schema: ${issues}`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return settled.value
|
|
139
|
+
}
|
package/src/commit-id.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Commit IDs for commits whose command carries none: ULID-format identifiers, 26 Crockford base32
|
|
2
|
+
// characters for a 48-bit millisecond timestamp and 80 random bits. IDs one generator makes in one
|
|
3
|
+
// millisecond increase, so they sort by creation time.
|
|
4
|
+
|
|
5
|
+
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
|
|
6
|
+
const ENTROPY_MAX = (1n << 80n) - 1n
|
|
7
|
+
const TIMESTAMP_MASK = (1n << 48n) - 1n
|
|
8
|
+
|
|
9
|
+
/** Makes a ULID-format commit ID generator that reads `clock` (Unix milliseconds). */
|
|
10
|
+
export function commitIds(clock: () => number): () => string {
|
|
11
|
+
let lastMs = -1n
|
|
12
|
+
let entropy = 0n
|
|
13
|
+
|
|
14
|
+
return () => {
|
|
15
|
+
// A clock that steps backwards keeps the last timestamp, so IDs never decrease.
|
|
16
|
+
let ms = BigInt(Math.max(0, Math.floor(clock()))) & TIMESTAMP_MASK
|
|
17
|
+
if (ms < lastMs) {
|
|
18
|
+
ms = lastMs
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (ms === lastMs && entropy < ENTROPY_MAX) {
|
|
22
|
+
entropy++
|
|
23
|
+
} else {
|
|
24
|
+
// A new millisecond, or 2^80 IDs in this one.
|
|
25
|
+
if (ms === lastMs) {
|
|
26
|
+
ms = (ms + 1n) & TIMESTAMP_MASK
|
|
27
|
+
}
|
|
28
|
+
lastMs = ms
|
|
29
|
+
entropy = randomEntropy()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return encode((ms << 80n) | entropy)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function randomEntropy(): bigint {
|
|
37
|
+
const bytes = crypto.getRandomValues(new Uint8Array(10))
|
|
38
|
+
|
|
39
|
+
return bytes.reduce((value, byte) => (value << 8n) | BigInt(byte), 0n)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function encode(value: bigint): string {
|
|
43
|
+
let text = ''
|
|
44
|
+
for (let i = 0; i < 26; i++) {
|
|
45
|
+
text = ALPHABET[Number(value & 31n)]! + text
|
|
46
|
+
value >>= 5n
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return text
|
|
50
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// The DynamoDB actions on a stream's head item (ADR 0007), independent of any SDK: the one source
|
|
2
|
+
// of the head's expression strings, which the writer builds its actions from and the commit
|
|
3
|
+
// budget sizes. The fence and snapshot-guard forms join them with the DynamoDB backend.
|
|
4
|
+
|
|
5
|
+
import {safeIntegerArgument} from '../fields.js'
|
|
6
|
+
import {utf8Length} from '../utf8.js'
|
|
7
|
+
import {HEAD_SORT_KEY, itemSize} from './item.js'
|
|
8
|
+
|
|
9
|
+
/** The update expression of a head update on a stream without retention. */
|
|
10
|
+
export const HEAD_UPDATE = 'SET head = :last'
|
|
11
|
+
|
|
12
|
+
/** The update expression of a head update on a stream with retention, which raises `ttl` too. */
|
|
13
|
+
export const HEAD_UPDATE_RETAINED = 'SET head = :last, #ttl = :t'
|
|
14
|
+
|
|
15
|
+
/** The condition of the head update of a stream the commit creates (first sequence 1). */
|
|
16
|
+
export const HEAD_CREATE_CONDITION = 'attribute_not_exists(head)'
|
|
17
|
+
|
|
18
|
+
/** The condition of the head update of an append: the head is the sequence before the first. */
|
|
19
|
+
export const HEAD_APPEND_CONDITION = 'head = :prev'
|
|
20
|
+
|
|
21
|
+
/** {@link HEAD_APPEND_CONDITION} with retention: `ttl` is raised, never lowered. */
|
|
22
|
+
export const HEAD_APPEND_RETAINED_CONDITION =
|
|
23
|
+
'head = :prev AND (attribute_not_exists(#ttl) OR #ttl <= :t)'
|
|
24
|
+
|
|
25
|
+
/** An `Update` or `ConditionCheck` on a stream's head item. */
|
|
26
|
+
export interface HeadAction {
|
|
27
|
+
entityId: string
|
|
28
|
+
/** The update expression, or null for a `ConditionCheck`. */
|
|
29
|
+
updateExpression: string | null
|
|
30
|
+
conditionExpression: string
|
|
31
|
+
/** `ExpressionAttributeNames`: placeholder and attribute name. */
|
|
32
|
+
names: [string, string][]
|
|
33
|
+
/** `ExpressionAttributeValues`: placeholder and number. */
|
|
34
|
+
values: [string, bigint][]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The head update of a stream whose commit writes events `first` to `last`. A first sequence of 1
|
|
39
|
+
* creates the head. `ttl` is the `:t` of the never-lower rule, given only for a stream with
|
|
40
|
+
* retention.
|
|
41
|
+
*/
|
|
42
|
+
export function headUpdate(
|
|
43
|
+
entityId: string,
|
|
44
|
+
first: bigint,
|
|
45
|
+
last: bigint,
|
|
46
|
+
ttl?: number,
|
|
47
|
+
): HeadAction {
|
|
48
|
+
const create = first <= 1n
|
|
49
|
+
const values: [string, bigint][] = [[':last', last]]
|
|
50
|
+
const names: [string, string][] = []
|
|
51
|
+
|
|
52
|
+
if (!create) {
|
|
53
|
+
values.push([':prev', first - 1n])
|
|
54
|
+
}
|
|
55
|
+
if (ttl !== undefined) {
|
|
56
|
+
names.push(['#ttl', 'ttl'])
|
|
57
|
+
values.push([':t', BigInt(safeIntegerArgument(ttl, 'ttl'))])
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let conditionExpression = HEAD_CREATE_CONDITION
|
|
61
|
+
if (!create) {
|
|
62
|
+
conditionExpression =
|
|
63
|
+
ttl === undefined ? HEAD_APPEND_CONDITION : HEAD_APPEND_RETAINED_CONDITION
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
entityId,
|
|
68
|
+
updateExpression: ttl === undefined ? HEAD_UPDATE : HEAD_UPDATE_RETAINED,
|
|
69
|
+
conditionExpression,
|
|
70
|
+
names,
|
|
71
|
+
values,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* An action's size in the commit budget, by the rule for any update, delete or condition check:
|
|
77
|
+
* its key attributes plus its expression strings, name placeholders (the placeholder and the
|
|
78
|
+
* name), and attribute values (the placeholder and the number's text).
|
|
79
|
+
*/
|
|
80
|
+
export function headActionSize(action: HeadAction): number {
|
|
81
|
+
const key = itemSize({pk: {S: action.entityId}, sk: {N: HEAD_SORT_KEY.toString()}})
|
|
82
|
+
let size =
|
|
83
|
+
key + utf8Length(action.updateExpression ?? '') + utf8Length(action.conditionExpression)
|
|
84
|
+
|
|
85
|
+
for (const [placeholder, name] of action.names) {
|
|
86
|
+
size += utf8Length(placeholder) + utf8Length(name)
|
|
87
|
+
}
|
|
88
|
+
for (const [placeholder, number] of action.values) {
|
|
89
|
+
size += utf8Length(placeholder) + number.toString().length
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return size
|
|
93
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// @photon-grove/evt/dynamodb: the DynamoDB backend. So far it holds the item codec, which imports
|
|
2
|
+
// nothing from the AWS SDK.
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
type AttributeValue,
|
|
6
|
+
type Item,
|
|
7
|
+
DEFAULT_VIEW_SORT_KEY,
|
|
8
|
+
HEAD_SORT_KEY,
|
|
9
|
+
SNAPSHOT_SORT_KEY,
|
|
10
|
+
eventFromItem,
|
|
11
|
+
eventItem,
|
|
12
|
+
expiry,
|
|
13
|
+
headFromItem,
|
|
14
|
+
headItem,
|
|
15
|
+
isSnapshotItem,
|
|
16
|
+
itemSize,
|
|
17
|
+
snapshotFromItem,
|
|
18
|
+
snapshotItem,
|
|
19
|
+
sortKey,
|
|
20
|
+
viewItem,
|
|
21
|
+
} from './item.js'
|