@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.
- package/LICENSE +176 -0
- package/README.md +164 -1
- 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/client.d.ts +83 -0
- package/dist/dynamodb/client.d.ts.map +1 -0
- package/dist/dynamodb/client.js +6 -0
- package/dist/dynamodb/client.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 +6 -0
- package/dist/dynamodb/index.d.ts.map +1 -0
- package/dist/dynamodb/index.js +8 -0
- package/dist/dynamodb/index.js.map +1 -0
- package/dist/dynamodb/item.d.ts +70 -0
- package/dist/dynamodb/item.d.ts.map +1 -0
- package/dist/dynamodb/item.js +264 -0
- package/dist/dynamodb/item.js.map +1 -0
- package/dist/dynamodb/marker.d.ts +11 -0
- package/dist/dynamodb/marker.d.ts.map +1 -0
- package/dist/dynamodb/marker.js +56 -0
- package/dist/dynamodb/marker.js.map +1 -0
- package/dist/dynamodb/repository.d.ts +61 -0
- package/dist/dynamodb/repository.d.ts.map +1 -0
- package/dist/dynamodb/repository.js +731 -0
- package/dist/dynamodb/repository.js.map +1 -0
- package/dist/dynamodb/requests.d.ts +65 -0
- package/dist/dynamodb/requests.d.ts.map +1 -0
- package/dist/dynamodb/requests.js +179 -0
- package/dist/dynamodb/requests.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 +205 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +240 -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/features.d.ts +55 -0
- package/dist/features.d.ts.map +1 -0
- package/dist/features.js +126 -0
- package/dist/features.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 +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -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 +70 -0
- package/dist/memory/repository.d.ts.map +1 -0
- package/dist/memory/repository.js +308 -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 +131 -0
- package/dist/repository.d.ts.map +1 -0
- package/dist/repository.js +130 -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 +58 -2
- 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/client.ts +102 -0
- package/src/dynamodb/head.ts +93 -0
- package/src/dynamodb/index.ts +45 -0
- package/src/dynamodb/item.ts +334 -0
- package/src/dynamodb/marker.ts +64 -0
- package/src/dynamodb/repository.ts +1030 -0
- package/src/dynamodb/requests.ts +256 -0
- package/src/envelope.ts +67 -0
- package/src/errors.ts +365 -0
- package/src/event.ts +117 -0
- package/src/features.ts +175 -0
- package/src/fields.ts +134 -0
- package/src/globals.d.ts +39 -0
- package/src/index.ts +98 -0
- package/src/json.ts +546 -0
- package/src/memory/index.ts +11 -0
- package/src/memory/repository.ts +463 -0
- package/src/metadata.ts +120 -0
- package/src/repository.ts +275 -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/src/event.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Serialized events and snapshots: the stored form every repository reads and writes, and their
|
|
2
|
+
// JSON encodings, which match Go's evt.SerializedEvent and evt.SerializedSnapshot.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DOCUMENT,
|
|
6
|
+
bytesField,
|
|
7
|
+
int64Argument,
|
|
8
|
+
int64Field,
|
|
9
|
+
objectOrNull,
|
|
10
|
+
safeIntegerArgument,
|
|
11
|
+
safeIntegerField,
|
|
12
|
+
stringField,
|
|
13
|
+
type JsonObject,
|
|
14
|
+
} from './fields.js'
|
|
15
|
+
import {parseJson, stringifyJson} from './json.js'
|
|
16
|
+
import {type Metadata, metadataFromJson, metadataJson} from './metadata.js'
|
|
17
|
+
|
|
18
|
+
/** An event as it is stored. Its JSON is the `detail` of a stream envelope. */
|
|
19
|
+
export interface SerializedEvent {
|
|
20
|
+
/** `{entityId}:{sequence}`. */
|
|
21
|
+
id: string
|
|
22
|
+
/** The 1-based position of the event in its stream. */
|
|
23
|
+
sequence: bigint
|
|
24
|
+
/** The event kind, such as `account:opened`. */
|
|
25
|
+
type: string
|
|
26
|
+
/** The payload's schema version. */
|
|
27
|
+
version: number
|
|
28
|
+
/** The stream the event belongs to. */
|
|
29
|
+
entityId: string
|
|
30
|
+
/** The aggregate type of the entity. */
|
|
31
|
+
entityType: string
|
|
32
|
+
/**
|
|
33
|
+
* The payload bytes, stored verbatim. Null only when decoded from a JSON `null`, which is
|
|
34
|
+
* written back as `null`, as Go does.
|
|
35
|
+
*/
|
|
36
|
+
payload: Uint8Array | null
|
|
37
|
+
metadata: Metadata
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A snapshot of aggregate state. A stream keeps at most one, its latest. */
|
|
41
|
+
export interface SerializedSnapshot {
|
|
42
|
+
entityType: string
|
|
43
|
+
entityId: string
|
|
44
|
+
/** 1 for an entity's first snapshot, then one more for each. */
|
|
45
|
+
sequence: bigint
|
|
46
|
+
/** The last event sequence folded into the snapshot. */
|
|
47
|
+
eventSequence: bigint
|
|
48
|
+
/** The state's bytes, stored verbatim. Null only when decoded from a JSON `null`. */
|
|
49
|
+
payload: Uint8Array | null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The event ID for a sequence of a stream: `{entityId}:{sequence}`. */
|
|
53
|
+
export function eventId(entityId: string, sequence: bigint): string {
|
|
54
|
+
return `${entityId}:${sequence}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Encodes an event as the JSON Go writes for it. */
|
|
58
|
+
export function encodeEvent(event: SerializedEvent): string {
|
|
59
|
+
return stringifyJson(eventJson(event))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Decodes an event's JSON leniently, as Go does. */
|
|
63
|
+
export function decodeEvent(json: string): SerializedEvent {
|
|
64
|
+
return eventFromJson(objectOrNull(parseJson(json, DOCUMENT), 'event'))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Encodes a snapshot as the JSON Go writes for it. Its entity ID key is `entityID`. */
|
|
68
|
+
export function encodeSnapshot(snapshot: SerializedSnapshot): string {
|
|
69
|
+
return stringifyJson({
|
|
70
|
+
entityType: snapshot.entityType,
|
|
71
|
+
entityID: snapshot.entityId,
|
|
72
|
+
sequence: int64Argument(snapshot.sequence, 'snapshot.sequence'),
|
|
73
|
+
eventSequence: int64Argument(snapshot.eventSequence, 'snapshot.eventSequence'),
|
|
74
|
+
payload: snapshot.payload,
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Decodes a snapshot's JSON leniently, as Go does. */
|
|
79
|
+
export function decodeSnapshot(json: string): SerializedSnapshot {
|
|
80
|
+
const object = objectOrNull(parseJson(json, DOCUMENT), 'snapshot') ?? new Map()
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
entityType: stringField(object, 'entityType', 'snapshot'),
|
|
84
|
+
entityId: stringField(object, 'entityID', 'snapshot'),
|
|
85
|
+
sequence: int64Field(object, 'sequence', 'snapshot'),
|
|
86
|
+
eventSequence: int64Field(object, 'eventSequence', 'snapshot'),
|
|
87
|
+
payload: bytesField(object, 'payload', 'snapshot'),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function eventJson(event: SerializedEvent): object {
|
|
92
|
+
return {
|
|
93
|
+
id: event.id,
|
|
94
|
+
sequence: int64Argument(event.sequence, 'event.sequence'),
|
|
95
|
+
type: event.type,
|
|
96
|
+
version: safeIntegerArgument(event.version, 'event.version'),
|
|
97
|
+
entityId: event.entityId,
|
|
98
|
+
entityType: event.entityType,
|
|
99
|
+
payload: event.payload,
|
|
100
|
+
metadata: metadataJson(event.metadata),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function eventFromJson(value: JsonObject | null): SerializedEvent {
|
|
105
|
+
const object = value ?? new Map()
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
id: stringField(object, 'id', 'event'),
|
|
109
|
+
sequence: int64Field(object, 'sequence', 'event'),
|
|
110
|
+
type: stringField(object, 'type', 'event'),
|
|
111
|
+
version: safeIntegerField(object, 'version', 'event'),
|
|
112
|
+
entityId: stringField(object, 'entityId', 'event'),
|
|
113
|
+
entityType: stringField(object, 'entityType', 'event'),
|
|
114
|
+
payload: bytesField(object, 'payload', 'event'),
|
|
115
|
+
metadata: metadataFromJson(object.get('metadata')),
|
|
116
|
+
}
|
|
117
|
+
}
|
package/src/features.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// The feature manifest a store keeps on its SYSTEM#FEATURES marker row (storage format, "Feature
|
|
2
|
+
// marker"): the extensions it declares, each at a stored-format version, and the entity types
|
|
3
|
+
// declared history-dependent or retained. A writer refuses a store that declares a pair it doesn't
|
|
4
|
+
// model, and every writer requires stream-heads.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
EvtError,
|
|
8
|
+
type FeaturePair,
|
|
9
|
+
StreamHeadsNotMigratedError,
|
|
10
|
+
UnsupportedFeatureError,
|
|
11
|
+
} from './errors.js'
|
|
12
|
+
import {DOCUMENT, int64, invalid} from './fields.js'
|
|
13
|
+
import {type JsonValue, parseJson, stringifyJson} from './json.js'
|
|
14
|
+
import {compareUtf8} from './utf8.js'
|
|
15
|
+
|
|
16
|
+
/** The feature every writer models and requires: per-stream head items (ADR 0007). */
|
|
17
|
+
export const STREAM_HEADS_FEATURE = 'stream-heads'
|
|
18
|
+
|
|
19
|
+
/** The stored-format version of {@link STREAM_HEADS_FEATURE} this package reads and writes. */
|
|
20
|
+
export const STREAM_HEADS_VERSION = 1
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The `(feature, stored-format version)` pairs this package models. The other features the
|
|
24
|
+
* storage format defines (`journal`, `journal-compaction`, `postgres-views`) are Rust-first, so a
|
|
25
|
+
* store that declares any of them is refused.
|
|
26
|
+
*/
|
|
27
|
+
export const SUPPORTED_FEATURES: readonly FeaturePair[] = Object.freeze([
|
|
28
|
+
Object.freeze({name: STREAM_HEADS_FEATURE, version: STREAM_HEADS_VERSION}),
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
// What validation reads. Private, so no caller can add a pair to it.
|
|
32
|
+
const supported = new Map(SUPPORTED_FEATURES.map((pair) => [pair.name, pair.version]))
|
|
33
|
+
|
|
34
|
+
/** The parsed marker row. A store with no marker row has no manifest. */
|
|
35
|
+
export interface FeatureManifest {
|
|
36
|
+
/** Bumped by exactly one on every declaration. */
|
|
37
|
+
readonly version: bigint
|
|
38
|
+
/** Each declared feature's stored-format version. */
|
|
39
|
+
readonly features: ReadonlyMap<string, number>
|
|
40
|
+
/** The entity types whose commit-wide projectors are history-dependent: never compacted. */
|
|
41
|
+
readonly historyDependent: readonly string[]
|
|
42
|
+
/** The entity types configured with retention: never fenced. */
|
|
43
|
+
readonly retention: readonly string[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Refuses a manifest that declares a pair this package doesn't model, listing every such pair,
|
|
48
|
+
* with an {@link UnsupportedFeatureError}.
|
|
49
|
+
*/
|
|
50
|
+
export function checkSupported(manifest: FeatureManifest | null): void {
|
|
51
|
+
const unsupported: FeaturePair[] = []
|
|
52
|
+
for (const [name, version] of sortedFeatures(manifest?.features ?? new Map())) {
|
|
53
|
+
if (supported.get(name) !== version) {
|
|
54
|
+
unsupported.push({name, version})
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (unsupported.length > 0) {
|
|
59
|
+
throw new UnsupportedFeatureError(unsupported)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Whether the manifest declares stream-heads at the version this package models. */
|
|
64
|
+
export function declaresStreamHeads(manifest: FeatureManifest | null): boolean {
|
|
65
|
+
return manifest?.features.get(STREAM_HEADS_FEATURE) === STREAM_HEADS_VERSION
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Refuses a write to a store whose manifest declares an unsupported pair, or doesn't declare
|
|
70
|
+
* stream-heads, without declaring anything.
|
|
71
|
+
*/
|
|
72
|
+
export function checkWritable(manifest: FeatureManifest | null): void {
|
|
73
|
+
checkSupported(manifest)
|
|
74
|
+
if (!declaresStreamHeads(manifest)) {
|
|
75
|
+
throw new StreamHeadsNotMigratedError()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The manifest with stream-heads declared and its version bumped: a first writer's declaration. */
|
|
80
|
+
export function withStreamHeads(manifest: FeatureManifest | null): FeatureManifest {
|
|
81
|
+
const features = new Map(manifest?.features)
|
|
82
|
+
features.set(STREAM_HEADS_FEATURE, STREAM_HEADS_VERSION)
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
version: (manifest?.version ?? 0n) + 1n,
|
|
86
|
+
features,
|
|
87
|
+
historyDependent: manifest?.historyDependent ?? [],
|
|
88
|
+
retention: manifest?.retention ?? [],
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A manifest's stored JSON texts, as the marker row holds them. */
|
|
93
|
+
export interface ManifestTexts {
|
|
94
|
+
/** A JSON object, feature name to stored-format version, keys sorted. `{}` when empty. */
|
|
95
|
+
features: string
|
|
96
|
+
/** A JSON array of entity types, sorted by UTF-8 bytes and deduplicated. `[]` when empty. */
|
|
97
|
+
historyDependent: string
|
|
98
|
+
/** Like `historyDependent`. */
|
|
99
|
+
retention: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Encodes a manifest's declarable parts as the marker row stores them. */
|
|
103
|
+
export function encodeManifest(manifest: FeatureManifest): ManifestTexts {
|
|
104
|
+
return {
|
|
105
|
+
features: stringifyJson(new Map(manifest.features)),
|
|
106
|
+
historyDependent: stringifyJson(entityTypeSet(manifest.historyDependent)),
|
|
107
|
+
retention: stringifyJson(entityTypeSet(manifest.retention)),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Decodes a marker row's stored texts. An empty text is an empty map or set, as an absent
|
|
113
|
+
* attribute is.
|
|
114
|
+
*/
|
|
115
|
+
export function decodeManifest(version: bigint, texts: ManifestTexts): FeatureManifest {
|
|
116
|
+
return {
|
|
117
|
+
version,
|
|
118
|
+
features: texts.features === '' ? new Map() : parseFeatures(texts.features),
|
|
119
|
+
historyDependent: parseEntityTypes(texts.historyDependent, 'historyDependent'),
|
|
120
|
+
retention: parseEntityTypes(texts.retention, 'retention'),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** An entity-type set in its stored order: sorted by UTF-8 bytes and deduplicated. */
|
|
125
|
+
export function entityTypeSet(types: readonly string[]): string[] {
|
|
126
|
+
// A fresh array, sorted in place: ES2022 has no toSorted.
|
|
127
|
+
const set = Array.from(new Set(types))
|
|
128
|
+
set.sort(compareUtf8)
|
|
129
|
+
|
|
130
|
+
return set
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseFeatures(text: string): Map<string, number> {
|
|
134
|
+
const parsed = parseStored(text, 'features')
|
|
135
|
+
if (!(parsed instanceof Map)) {
|
|
136
|
+
throw invalid('the feature manifest is not a JSON object')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Go and Rust read a version as an int64. One past the safe integers is no version this
|
|
140
|
+
// package models, so rounding it changes only the refusal's text.
|
|
141
|
+
const features = new Map<string, number>()
|
|
142
|
+
for (const [name, version] of parsed) {
|
|
143
|
+
features.set(name, Number(int64(version, `the feature manifest's ${name} version`)))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return features
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseEntityTypes(text: string, what: string): string[] {
|
|
150
|
+
if (text.trim() === '') {
|
|
151
|
+
return []
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const parsed = parseStored(text, what)
|
|
155
|
+
if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== 'string')) {
|
|
156
|
+
throw invalid(`the ${what} set is not a JSON array of strings`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return entityTypeSet(parsed as string[])
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseStored(text: string, what: string): JsonValue {
|
|
163
|
+
try {
|
|
164
|
+
return parseJson(text, DOCUMENT)
|
|
165
|
+
} catch (error) {
|
|
166
|
+
throw new EvtError('invalidRecord', `the stored ${what} is not JSON`, {cause: error})
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function sortedFeatures(features: ReadonlyMap<string, number>): [string, number][] {
|
|
171
|
+
const sorted = Array.from(features)
|
|
172
|
+
sorted.sort(([a], [b]) => compareUtf8(a, b))
|
|
173
|
+
|
|
174
|
+
return sorted
|
|
175
|
+
}
|
package/src/fields.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Lenient decoding of engine documents by Go's rules: a missing field or null is the zero value,
|
|
2
|
+
// unknown fields are ignored, and a value of the wrong JSON type is an error. Field names match
|
|
3
|
+
// exactly, as in Rust (Go also matches them case-insensitively).
|
|
4
|
+
|
|
5
|
+
import {base64Decode} from './base64.js'
|
|
6
|
+
import {EvtError} from './errors.js'
|
|
7
|
+
import {JsonNumber, type JsonValue, type ParseOptions} from './json.js'
|
|
8
|
+
|
|
9
|
+
export type JsonObject = Map<string, JsonValue>
|
|
10
|
+
|
|
11
|
+
const INT64_MIN = -(2n ** 63n)
|
|
12
|
+
|
|
13
|
+
const INT64_MAX = 2n ** 63n - 1n
|
|
14
|
+
|
|
15
|
+
/** How evt parses its own documents: a repeated key is refused. */
|
|
16
|
+
export const DOCUMENT: ParseOptions = {duplicateKeys: 'refuse'}
|
|
17
|
+
|
|
18
|
+
export function invalid(message: string): EvtError {
|
|
19
|
+
return new EvtError('invalidRecord', message)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** An object, or null for JSON null. */
|
|
23
|
+
export function objectOrNull(value: JsonValue | undefined, what: string): JsonObject | null {
|
|
24
|
+
if (value === undefined || value === null) {
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
if (!(value instanceof Map)) {
|
|
28
|
+
throw invalid(`${what} is not an object`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return value
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A string field, `""` when missing or null. */
|
|
35
|
+
export function stringField(object: JsonObject, name: string, what: string): string {
|
|
36
|
+
return nullableString(object, name, what) ?? ''
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A string field that may be null (a Go `*string`). */
|
|
40
|
+
export function nullableString(object: JsonObject, name: string, what: string): string | null {
|
|
41
|
+
const value = object.get(name)
|
|
42
|
+
if (value === undefined || value === null) {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
if (typeof value !== 'string') {
|
|
46
|
+
throw invalid(`${what}.${name} is not a string`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** An int64 field, `0n` when missing or null. `1.0`, `1e3` and `"1"` are refused, as in Go. */
|
|
53
|
+
export function int64Field(object: JsonObject, name: string, what: string): bigint {
|
|
54
|
+
const value = object.get(name)
|
|
55
|
+
if (value === undefined || value === null) {
|
|
56
|
+
return 0n
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return int64(value, `${what}.${name}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A small integer field, `0` when missing or null, refused if it isn't a safe integer. */
|
|
63
|
+
export function safeIntegerField(object: JsonObject, name: string, what: string): number {
|
|
64
|
+
const value = int64Field(object, name, what)
|
|
65
|
+
if (value < BigInt(Number.MIN_SAFE_INTEGER) || value > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
66
|
+
throw invalid(`${what}.${name} is ${value}, past the integers this field holds`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return Number(value)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Reads a JSON number as an int64. */
|
|
73
|
+
export function int64(value: JsonValue, what: string): bigint {
|
|
74
|
+
if (!(value instanceof JsonNumber) || !value.isInteger) {
|
|
75
|
+
throw invalid(`${what} is not an integer`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return checkInt64(BigInt(value.text), what)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Refuses an integer outside int64. */
|
|
82
|
+
export function checkInt64(value: bigint, what: string): bigint {
|
|
83
|
+
if (value < INT64_MIN || value > INT64_MAX) {
|
|
84
|
+
throw invalid(`${what} is ${value}, outside int64`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return value
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Refuses to write an integer that no reader would read back as an int64. */
|
|
91
|
+
export function int64Argument(value: bigint, what: string): bigint {
|
|
92
|
+
if (value < INT64_MIN || value > INT64_MAX) {
|
|
93
|
+
throw new EvtError('invalidArgument', `${what} is ${value}, outside int64`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return value
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Refuses to write a small integer that isn't a safe integer, such as 1.5 or NaN. */
|
|
100
|
+
export function safeIntegerArgument(value: number, what: string): number {
|
|
101
|
+
if (!Number.isSafeInteger(value)) {
|
|
102
|
+
throw new EvtError('invalidArgument', `${what} is ${value}, not a safe integer`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return value
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A base64 field (a Go `[]byte`), null when missing or null. */
|
|
109
|
+
export function bytesField(object: JsonObject, name: string, what: string): Uint8Array | null {
|
|
110
|
+
const text = nullableString(object, name, what)
|
|
111
|
+
|
|
112
|
+
return text === null ? null : base64Decode(text)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A Go `map[string]string`: null for a missing or null map, and `""` for a null value.
|
|
117
|
+
*/
|
|
118
|
+
export function stringMap(value: JsonValue | undefined, what: string): Map<string, string> | null {
|
|
119
|
+
const object = objectOrNull(value, what)
|
|
120
|
+
if (object === null) {
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const map = new Map<string, string>()
|
|
125
|
+
for (const [key, entry] of object) {
|
|
126
|
+
if (entry !== null && typeof entry !== 'string') {
|
|
127
|
+
throw invalid(`${what}.${key} is not a string`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
map.set(key, entry ?? '')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return map
|
|
134
|
+
}
|
package/src/globals.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The only runtime globals the published code may use: web-standard APIs that Node, Bun, Deno,
|
|
2
|
+
// edge runtimes and browsers all provide (ADR 0009, section 3). tsconfig.json loads no DOM or Node
|
|
3
|
+
// types, so anything else, such as Buffer, process or document, is a type error. Declare a global
|
|
4
|
+
// here only when the code needs it and every target runtime has it, typed no looser than the
|
|
5
|
+
// runtimes' own declarations: tsconfig.test.json checks src/ against Node's types instead.
|
|
6
|
+
|
|
7
|
+
declare class TextEncoder {
|
|
8
|
+
readonly encoding: string
|
|
9
|
+
encode(input?: string): Uint8Array<ArrayBuffer>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
declare class TextDecoder {
|
|
13
|
+
constructor(label?: string, options?: {fatal?: boolean; ignoreBOM?: boolean})
|
|
14
|
+
readonly encoding: string
|
|
15
|
+
readonly fatal: boolean
|
|
16
|
+
decode(input?: ArrayBuffer | ArrayBufferView, options?: {stream?: boolean}): string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface AbortSignal {
|
|
20
|
+
readonly aborted: boolean
|
|
21
|
+
readonly reason: unknown
|
|
22
|
+
throwIfAborted(): void
|
|
23
|
+
addEventListener(type: 'abort', listener: () => void, options?: {once?: boolean}): void
|
|
24
|
+
removeEventListener(type: 'abort', listener: () => void): void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Browsers define crypto.subtle only in secure contexts (HTTPS or localhost).
|
|
28
|
+
declare var crypto: {
|
|
29
|
+
getRandomValues<T extends Uint8Array>(array: T): T
|
|
30
|
+
readonly subtle: {
|
|
31
|
+
digest(algorithm: 'SHA-256', data: Uint8Array<ArrayBuffer>): Promise<ArrayBuffer>
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
declare function setTimeout(callback: () => void, ms?: number): unknown
|
|
36
|
+
|
|
37
|
+
declare function clearTimeout(id: unknown): void
|
|
38
|
+
|
|
39
|
+
declare function queueMicrotask(callback: () => void): void
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// @photon-grove/evt: event sourcing for TypeScript, wire-compatible with the Go and Rust evt
|
|
2
|
+
// implementations (docs/adr/0009-typescript-implementation.md). The storage contract is
|
|
3
|
+
// docs/reference/storage-format.md, and compat/fixtures holds its executable form.
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
type Aggregate,
|
|
7
|
+
type AggregateDefinition,
|
|
8
|
+
type Decision,
|
|
9
|
+
type EventDefinition,
|
|
10
|
+
type EventDefinitions,
|
|
11
|
+
type EventOf,
|
|
12
|
+
type Rejection,
|
|
13
|
+
type Upcaster,
|
|
14
|
+
defineAggregate,
|
|
15
|
+
defineEvents,
|
|
16
|
+
jsonUpcaster,
|
|
17
|
+
} from './aggregate.js'
|
|
18
|
+
export {base64Decode, base64Encode} from './base64.js'
|
|
19
|
+
export {
|
|
20
|
+
type Codec,
|
|
21
|
+
type JsonOptions,
|
|
22
|
+
type StandardResult,
|
|
23
|
+
type StandardSchemaV1,
|
|
24
|
+
json,
|
|
25
|
+
} from './codec.js'
|
|
26
|
+
export {encodeEnvelope, decodeEnvelope, messageAttributes} from './envelope.js'
|
|
27
|
+
export {
|
|
28
|
+
CommitTooLargeError,
|
|
29
|
+
CompactionUncoveredError,
|
|
30
|
+
ConflictError,
|
|
31
|
+
EvtError,
|
|
32
|
+
type EvtErrorCode,
|
|
33
|
+
FeatureManifestChangedError,
|
|
34
|
+
HistoryDependentProjectionError,
|
|
35
|
+
RebuildInProgressError,
|
|
36
|
+
ReplayError,
|
|
37
|
+
ReservedEntityIdError,
|
|
38
|
+
SequenceGapError,
|
|
39
|
+
SnapshotError,
|
|
40
|
+
StoreNotInitializedError,
|
|
41
|
+
StreamHeadDriftError,
|
|
42
|
+
StreamHeadsNotMigratedError,
|
|
43
|
+
TransientError,
|
|
44
|
+
UnsupportedFeatureError,
|
|
45
|
+
type FeaturePair,
|
|
46
|
+
isConflict,
|
|
47
|
+
isRetryable,
|
|
48
|
+
} from './errors.js'
|
|
49
|
+
export {
|
|
50
|
+
type SerializedEvent,
|
|
51
|
+
type SerializedSnapshot,
|
|
52
|
+
decodeEvent,
|
|
53
|
+
decodeSnapshot,
|
|
54
|
+
encodeEvent,
|
|
55
|
+
encodeSnapshot,
|
|
56
|
+
eventId,
|
|
57
|
+
} from './event.js'
|
|
58
|
+
export {
|
|
59
|
+
type FeatureManifest,
|
|
60
|
+
type ManifestTexts,
|
|
61
|
+
STREAM_HEADS_FEATURE,
|
|
62
|
+
STREAM_HEADS_VERSION,
|
|
63
|
+
SUPPORTED_FEATURES,
|
|
64
|
+
decodeManifest,
|
|
65
|
+
encodeManifest,
|
|
66
|
+
} from './features.js'
|
|
67
|
+
export {JsonNumber, type JsonValue, parseJson, stringifyJson} from './json.js'
|
|
68
|
+
export {
|
|
69
|
+
type Metadata,
|
|
70
|
+
type Origin,
|
|
71
|
+
decodeMetadata,
|
|
72
|
+
effectiveCausationId,
|
|
73
|
+
emptyMetadata,
|
|
74
|
+
encodeMetadata,
|
|
75
|
+
} from './metadata.js'
|
|
76
|
+
export type {
|
|
77
|
+
CallOptions,
|
|
78
|
+
Commit,
|
|
79
|
+
NewSnapshot,
|
|
80
|
+
Repository,
|
|
81
|
+
StoreInitializer,
|
|
82
|
+
} from './repository.js'
|
|
83
|
+
export {
|
|
84
|
+
DEFAULT_RETRY_POLICY,
|
|
85
|
+
type RetryPolicy,
|
|
86
|
+
retryDelay,
|
|
87
|
+
} from './retry.js'
|
|
88
|
+
export {MAX_COMMIT_ITEM_BYTES, snapshotCommitSize, snapshotEventsDue} from './snapshots.js'
|
|
89
|
+
export {compareUtf8, utf8Length} from './utf8.js'
|
|
90
|
+
export {
|
|
91
|
+
DEFAULT_SNAPSHOT_INTERVAL,
|
|
92
|
+
type ExecuteOptions,
|
|
93
|
+
type ExecuteResult,
|
|
94
|
+
type Loaded,
|
|
95
|
+
type RetryOptions,
|
|
96
|
+
Store,
|
|
97
|
+
type StoreOptions,
|
|
98
|
+
} from './store.js'
|