@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,406 @@
|
|
|
1
|
+
// Given/when/then helpers over MemRepository, the counterpart of Go's `test` package and Rust's
|
|
2
|
+
// `photon_evt::testing`.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type Aggregate,
|
|
6
|
+
type EventDefinitions,
|
|
7
|
+
type EventOf,
|
|
8
|
+
type Rejection,
|
|
9
|
+
encodeEventData,
|
|
10
|
+
replayEvent,
|
|
11
|
+
} from '../aggregate.js'
|
|
12
|
+
import {type SerializedEvent, eventId} from '../event.js'
|
|
13
|
+
import {MemRepository} from '../memory/repository.js'
|
|
14
|
+
import {type Metadata, emptyMetadata} from '../metadata.js'
|
|
15
|
+
import {type ExecuteResult, type Loaded, Store, type StoreOptions} from '../store.js'
|
|
16
|
+
import {AssertionError, deepEqual, show} from './equal.js'
|
|
17
|
+
|
|
18
|
+
/** The entity ID a {@link Scenario} runs against unless {@link Scenario.entity} sets one. */
|
|
19
|
+
export const DEFAULT_ENTITY_ID = 'entity-1'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A {@link MemRepository} with helpers to seed and read streams of any aggregate. Hand its
|
|
23
|
+
* `repository` to the code under test, or run several {@link Scenario}s on it with
|
|
24
|
+
* {@link Scenario.on} so they build on each other.
|
|
25
|
+
*/
|
|
26
|
+
export class Fixture {
|
|
27
|
+
readonly repository: MemRepository
|
|
28
|
+
|
|
29
|
+
constructor(repository: MemRepository = new MemRepository()) {
|
|
30
|
+
this.repository = repository
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A store over the fixture's repository. */
|
|
34
|
+
store(options: StoreOptions = {}): Store {
|
|
35
|
+
return new Store(this.repository, options)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Appends events to a stream as if earlier commands had committed them. On a stream that
|
|
40
|
+
* already has events, it continues the sequence from the loaded aggregate, which is where the
|
|
41
|
+
* snapshot leaves off after a compaction. It writes no snapshot, and its metadata is empty but
|
|
42
|
+
* for a commit ID.
|
|
43
|
+
*/
|
|
44
|
+
async given<S, C, D extends EventDefinitions>(
|
|
45
|
+
aggregate: Aggregate<S, C, D>,
|
|
46
|
+
entityId: string,
|
|
47
|
+
events: readonly EventOf<D>[],
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
if (events.length === 0) {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let sequence = (await this.load(aggregate, entityId)).sequence
|
|
54
|
+
const commitId = `given-${entityId}-${sequence + 1n}`
|
|
55
|
+
const stored: SerializedEvent[] = events.map((event) => {
|
|
56
|
+
sequence++
|
|
57
|
+
const {version, payload} = encodeEventData(aggregate.events, event)
|
|
58
|
+
const metadata: Metadata = {...emptyMetadata(), commitId}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
id: eventId(entityId, sequence),
|
|
62
|
+
sequence,
|
|
63
|
+
type: event.type,
|
|
64
|
+
version,
|
|
65
|
+
entityId,
|
|
66
|
+
entityType: aggregate.type,
|
|
67
|
+
payload,
|
|
68
|
+
metadata,
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
await this.repository.commit({entityId, events: stored})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The stream's stored events, upcast and decoded, oldest first. */
|
|
76
|
+
async events<S, C, D extends EventDefinitions>(
|
|
77
|
+
aggregate: Aggregate<S, C, D>,
|
|
78
|
+
entityId: string,
|
|
79
|
+
): Promise<EventOf<D>[]> {
|
|
80
|
+
const stored = await this.repository.events(entityId)
|
|
81
|
+
|
|
82
|
+
return stored.map((event) => replayEvent(aggregate.events, event))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The aggregate loaded from its stream, from the snapshot like {@link Store.load}. */
|
|
86
|
+
load<S, C, D extends EventDefinitions>(
|
|
87
|
+
aggregate: Aggregate<S, C, D>,
|
|
88
|
+
entityId: string,
|
|
89
|
+
): Promise<Loaded<S>> {
|
|
90
|
+
return this.store().load(aggregate, entityId)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Asserts that the stream holds exactly these events, decoded. */
|
|
94
|
+
async assertEvents<S, C, D extends EventDefinitions>(
|
|
95
|
+
aggregate: Aggregate<S, C, D>,
|
|
96
|
+
entityId: string,
|
|
97
|
+
expected: readonly EventOf<D>[],
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
const actual = await this.events(aggregate, entityId)
|
|
100
|
+
|
|
101
|
+
assertEqual(actual, expected, `events of stream ${entityId}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Starts a given/when/then test of one command against one stream:
|
|
107
|
+
*
|
|
108
|
+
* ```ts
|
|
109
|
+
* await scenario(account)
|
|
110
|
+
* .given({type: 'account:opened', data: {owner: 'ada'}})
|
|
111
|
+
* .when({type: 'deposit', amountCents: 5})
|
|
112
|
+
* .thenEvents({type: 'account:deposited', data: {amountCents: 5}})
|
|
113
|
+
* ```
|
|
114
|
+
*
|
|
115
|
+
* Each `then` method runs the command through a real {@link Store} over a {@link MemRepository}
|
|
116
|
+
* and throws an {@link AssertionError} when the outcome differs, which fails the surrounding test
|
|
117
|
+
* in any runner. {@link When.run} gives the {@link Outcome} to inspect further.
|
|
118
|
+
*/
|
|
119
|
+
export function scenario<S, C, D extends EventDefinitions>(
|
|
120
|
+
aggregate: Aggregate<S, C, D>,
|
|
121
|
+
): Scenario<S, C, D> {
|
|
122
|
+
return new Scenario(aggregate, {
|
|
123
|
+
fixture: null,
|
|
124
|
+
entityId: DEFAULT_ENTITY_ID,
|
|
125
|
+
given: [],
|
|
126
|
+
metadata: {},
|
|
127
|
+
options: {},
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** What a {@link Scenario} runs with. Build one with {@link scenario}. */
|
|
132
|
+
export interface ScenarioSetup<D extends EventDefinitions> {
|
|
133
|
+
readonly fixture: Fixture | null
|
|
134
|
+
readonly entityId: string
|
|
135
|
+
readonly given: readonly EventOf<D>[]
|
|
136
|
+
readonly metadata: Partial<Metadata>
|
|
137
|
+
readonly options: StoreOptions
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A command's setting: the aggregate, the stream and its prior events, and how the store runs.
|
|
142
|
+
* Each method returns a new scenario, so one can be the base of several tests. Without
|
|
143
|
+
* {@link Scenario.on}, each run gets a fixture of its own.
|
|
144
|
+
*/
|
|
145
|
+
export class Scenario<S, C, D extends EventDefinitions> {
|
|
146
|
+
readonly aggregate: Aggregate<S, C, D>
|
|
147
|
+
readonly #setup: ScenarioSetup<D>
|
|
148
|
+
|
|
149
|
+
constructor(aggregate: Aggregate<S, C, D>, setup: ScenarioSetup<D>) {
|
|
150
|
+
this.aggregate = aggregate
|
|
151
|
+
this.#setup = setup
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Adds events the stream holds before the command. */
|
|
155
|
+
given(...events: EventOf<D>[]): Scenario<S, C, D> {
|
|
156
|
+
return this.#with({given: [...this.#setup.given, ...events]})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Sets the stream's entity ID ({@link DEFAULT_ENTITY_ID} by default). */
|
|
160
|
+
entity(entityId: string): Scenario<S, C, D> {
|
|
161
|
+
return this.#with({entityId})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Sets the metadata the command runs with, such as a command ID to test duplicates. */
|
|
165
|
+
metadata(metadata: Partial<Metadata>): Scenario<S, C, D> {
|
|
166
|
+
return this.#with({metadata})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Sets the store's options: its clock, region, snapshot interval or commit IDs. */
|
|
170
|
+
options(options: StoreOptions): Scenario<S, C, D> {
|
|
171
|
+
return this.#with({options})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Runs on an existing fixture, so the streams it holds are visible and several scenarios build
|
|
176
|
+
* on each other. Each run seeds the given events and runs the command again, so on a shared
|
|
177
|
+
* fixture, running a scenario twice writes it twice. Each run gets a new fixture otherwise.
|
|
178
|
+
*/
|
|
179
|
+
on(fixture: Fixture): Scenario<S, C, D> {
|
|
180
|
+
return this.#with({fixture})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Names the command to run. */
|
|
184
|
+
when(command: C): When<S, C, D> {
|
|
185
|
+
return new When(this.aggregate, this.#setup, command)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#with(change: Partial<ScenarioSetup<D>>): Scenario<S, C, D> {
|
|
189
|
+
return new Scenario(this.aggregate, {...this.#setup, ...change})
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A {@link Scenario} with its command, ready to run. */
|
|
194
|
+
export class When<S, C, D extends EventDefinitions> {
|
|
195
|
+
readonly #aggregate: Aggregate<S, C, D>
|
|
196
|
+
readonly #setup: ScenarioSetup<D>
|
|
197
|
+
readonly #command: C
|
|
198
|
+
|
|
199
|
+
constructor(aggregate: Aggregate<S, C, D>, setup: ScenarioSetup<D>, command: C) {
|
|
200
|
+
this.#aggregate = aggregate
|
|
201
|
+
this.#setup = setup
|
|
202
|
+
this.#command = command
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Seeds the given events, runs the command, and resolves to its {@link Outcome}. Each call does
|
|
207
|
+
* both again. Invalid store options or a failure to seed throw; a failure of the command is the
|
|
208
|
+
* outcome's.
|
|
209
|
+
*/
|
|
210
|
+
async run(): Promise<Outcome<S, C, D>> {
|
|
211
|
+
const {entityId, given, metadata, options} = this.#setup
|
|
212
|
+
const fixture = this.#setup.fixture ?? new Fixture()
|
|
213
|
+
const store = fixture.store(options)
|
|
214
|
+
await fixture.given(this.#aggregate, entityId, given)
|
|
215
|
+
|
|
216
|
+
let settled: PromiseSettledResult<ExecuteResult<S>>
|
|
217
|
+
try {
|
|
218
|
+
const value = await store.execute(this.#aggregate, entityId, this.#command, {metadata})
|
|
219
|
+
settled = {status: 'fulfilled', value}
|
|
220
|
+
} catch (error) {
|
|
221
|
+
settled = {status: 'rejected', reason: error}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return new Outcome(this.#aggregate, fixture, entityId, settled)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Asserts that the command commits exactly these events. */
|
|
228
|
+
async thenEvents(...expected: EventOf<D>[]): Promise<void> {
|
|
229
|
+
const outcome = await this.run()
|
|
230
|
+
outcome.assertEvents(...expected)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Asserts that the command commits and emits nothing. */
|
|
234
|
+
async thenNoEvents(): Promise<void> {
|
|
235
|
+
const outcome = await this.run()
|
|
236
|
+
outcome.assertEvents()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Asserts that the handler rejects the command, with this code or a rejection `matches`
|
|
241
|
+
* accepts.
|
|
242
|
+
*/
|
|
243
|
+
async thenRejected(matches?: string | ((rejection: Rejection) => boolean)): Promise<void> {
|
|
244
|
+
const outcome = await this.run()
|
|
245
|
+
outcome.assertRejected(matches)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Asserts that the command is a duplicate of one already in the dedupe window. */
|
|
249
|
+
async thenDuplicate(): Promise<void> {
|
|
250
|
+
const outcome = await this.run()
|
|
251
|
+
outcome.assertDuplicate()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Asserts that the store throws, with an error `matches` accepts. */
|
|
255
|
+
async thenError(matches?: (error: unknown) => boolean): Promise<void> {
|
|
256
|
+
const outcome = await this.run()
|
|
257
|
+
outcome.assertError(matches)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Asserts that the command commits, then hands `check` the state after it. */
|
|
261
|
+
async thenState(check: (state: S) => void | Promise<void>): Promise<void> {
|
|
262
|
+
const outcome = await this.run()
|
|
263
|
+
await check(outcome.state.state)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The result of a command that committed. */
|
|
268
|
+
export type Committed<S> = Extract<ExecuteResult<S>, {kind: 'committed'}>
|
|
269
|
+
|
|
270
|
+
/** What running a {@link Scenario} did. */
|
|
271
|
+
export class Outcome<S, C, D extends EventDefinitions> {
|
|
272
|
+
readonly aggregate: Aggregate<S, C, D>
|
|
273
|
+
/** The fixture the scenario ran on, to inspect other streams. */
|
|
274
|
+
readonly fixture: Fixture
|
|
275
|
+
readonly entityId: string
|
|
276
|
+
/** What {@link Store.execute} resolved to, or the error it threw. */
|
|
277
|
+
readonly settled: PromiseSettledResult<ExecuteResult<S>>
|
|
278
|
+
|
|
279
|
+
constructor(
|
|
280
|
+
aggregate: Aggregate<S, C, D>,
|
|
281
|
+
fixture: Fixture,
|
|
282
|
+
entityId: string,
|
|
283
|
+
settled: PromiseSettledResult<ExecuteResult<S>>,
|
|
284
|
+
) {
|
|
285
|
+
this.aggregate = aggregate
|
|
286
|
+
this.fixture = fixture
|
|
287
|
+
this.entityId = entityId
|
|
288
|
+
this.settled = settled
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The events as stored, if the command committed. */
|
|
292
|
+
get storedEvents(): SerializedEvent[] | null {
|
|
293
|
+
const settled = this.settled
|
|
294
|
+
|
|
295
|
+
return settled.status === 'fulfilled' && settled.value.kind === 'committed'
|
|
296
|
+
? settled.value.events
|
|
297
|
+
: null
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The aggregate after the command. Throws unless it committed. */
|
|
301
|
+
get state(): Loaded<S> {
|
|
302
|
+
return this.assertCommitted().state
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Asserts that the command committed, and returns what it did. */
|
|
306
|
+
assertCommitted(): Committed<S> {
|
|
307
|
+
const settled = this.settled
|
|
308
|
+
if (settled.status === 'fulfilled' && settled.value.kind === 'committed') {
|
|
309
|
+
return settled.value
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
throw new AssertionError(
|
|
313
|
+
`expected the ${this.aggregate.type} command to commit, got ${this.#describe()}`,
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Asserts that the command committed exactly these events, decoded. */
|
|
318
|
+
assertEvents(...expected: EventOf<D>[]): void {
|
|
319
|
+
const committed = this.assertCommitted()
|
|
320
|
+
const actual = committed.events.map((event) => replayEvent(this.aggregate.events, event))
|
|
321
|
+
|
|
322
|
+
assertEqual(actual, expected, `events emitted for ${this.aggregate.type} ${this.entityId}`)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Asserts that the handler rejected the command, with this code or as `matches` accepts. */
|
|
326
|
+
assertRejected(matches?: string | ((rejection: Rejection) => boolean)): Rejection {
|
|
327
|
+
const settled = this.settled
|
|
328
|
+
if (settled.status !== 'fulfilled' || settled.value.kind !== 'rejected') {
|
|
329
|
+
throw new AssertionError(`expected a rejection, got ${this.#describe()}`)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const rejection = settled.value.rejection
|
|
333
|
+
if (typeof matches === 'string') {
|
|
334
|
+
if (rejection.code !== matches) {
|
|
335
|
+
throw new AssertionError(
|
|
336
|
+
`expected the rejection ${show(matches)}, got ${show(rejection.code)}`,
|
|
337
|
+
rejection.code,
|
|
338
|
+
matches,
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
} else if (matches && !matches(rejection)) {
|
|
342
|
+
throw new AssertionError(`unexpected rejection: ${show(rejection)}`, rejection)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return rejection
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Asserts that the command was a duplicate, and returns its command ID. */
|
|
349
|
+
assertDuplicate(): string {
|
|
350
|
+
const settled = this.settled
|
|
351
|
+
if (settled.status !== 'fulfilled' || settled.value.kind !== 'duplicate') {
|
|
352
|
+
throw new AssertionError(`expected a duplicate command, got ${this.#describe()}`)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return settled.value.commandId
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Asserts that the store threw, with an error `matches` accepts, and returns the error. */
|
|
359
|
+
assertError(matches?: (error: unknown) => boolean): unknown {
|
|
360
|
+
const settled = this.settled
|
|
361
|
+
if (settled.status !== 'rejected') {
|
|
362
|
+
throw new AssertionError(`expected a store error, got ${this.#describe()}`)
|
|
363
|
+
}
|
|
364
|
+
if (matches && !matches(settled.reason)) {
|
|
365
|
+
throw new AssertionError(`unexpected store error: ${show(settled.reason)}`, settled.reason)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return settled.reason
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
#describe(): string {
|
|
372
|
+
const settled = this.settled
|
|
373
|
+
if (settled.status === 'rejected') {
|
|
374
|
+
return `a store error: ${show(settled.reason)}`
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const result = settled.value
|
|
378
|
+
switch (result.kind) {
|
|
379
|
+
case 'committed':
|
|
380
|
+
return `committed events: ${showEvents(this.aggregate.events, result.events)}`
|
|
381
|
+
case 'duplicate':
|
|
382
|
+
return `a duplicate of command ${show(result.commandId)}`
|
|
383
|
+
case 'rejected':
|
|
384
|
+
return `a rejection: ${show(result.rejection)}`
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// The events decoded, or as stored when they don't decode, so a failure message never throws.
|
|
390
|
+
function showEvents<D extends EventDefinitions>(events: D, stored: SerializedEvent[]): string {
|
|
391
|
+
try {
|
|
392
|
+
return show(stored.map((event) => replayEvent(events, event)))
|
|
393
|
+
} catch {
|
|
394
|
+
return `${show(stored.map((event) => `${event.type} v${event.version}`))} (undecodable)`
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function assertEqual(actual: unknown, expected: unknown, what: string): void {
|
|
399
|
+
if (!deepEqual(actual, expected)) {
|
|
400
|
+
throw new AssertionError(
|
|
401
|
+
`${what}: expected ${show(expected)}, got ${show(actual)}`,
|
|
402
|
+
actual,
|
|
403
|
+
expected,
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
}
|
package/src/utf8.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// UTF-8 sizes and conversions. The storage format counts and compares UTF-8 bytes, and a JS string
|
|
2
|
+
// is UTF-16, so nothing here uses `.length` or `<` on strings.
|
|
3
|
+
|
|
4
|
+
import {EvtError} from './errors.js'
|
|
5
|
+
|
|
6
|
+
const encoder = new TextEncoder()
|
|
7
|
+
|
|
8
|
+
// fatal refuses bytes that aren't UTF-8, and ignoreBOM keeps a leading U+FEFF, which Go keeps.
|
|
9
|
+
const decoder = new TextDecoder('utf-8', {fatal: true, ignoreBOM: true})
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The UTF-8 length of a string in bytes, counted without allocating. A lone surrogate counts as
|
|
13
|
+
* 3, the U+FFFD that `TextEncoder` writes for it.
|
|
14
|
+
*/
|
|
15
|
+
export function utf8Length(text: string): number {
|
|
16
|
+
let bytes = 0
|
|
17
|
+
for (let i = 0; i < text.length; i++) {
|
|
18
|
+
const unit = text.charCodeAt(i)
|
|
19
|
+
if (unit < 0x80) {
|
|
20
|
+
bytes += 1
|
|
21
|
+
} else if (unit < 0x800) {
|
|
22
|
+
bytes += 2
|
|
23
|
+
} else if (isHighSurrogate(unit) && isLowSurrogate(text.charCodeAt(i + 1))) {
|
|
24
|
+
bytes += 4
|
|
25
|
+
i++
|
|
26
|
+
} else {
|
|
27
|
+
bytes += 3
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return bytes
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Encodes a string as UTF-8. A lone surrogate becomes U+FFFD. */
|
|
35
|
+
export function utf8Encode(text: string): Uint8Array<ArrayBuffer> {
|
|
36
|
+
return encoder.encode(text)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Decodes UTF-8 bytes, refusing bytes that aren't UTF-8. */
|
|
40
|
+
export function utf8Decode(bytes: Uint8Array, what: string): string {
|
|
41
|
+
try {
|
|
42
|
+
return decoder.decode(bytes)
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new EvtError('invalidRecord', `${what} is not valid UTF-8`, {cause: error})
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compares two strings by their UTF-8 bytes, which is how Go orders map keys. Code point order is
|
|
50
|
+
* UTF-8 byte order, so this compares code points, where `<` would compare UTF-16 code units.
|
|
51
|
+
*/
|
|
52
|
+
export function compareUtf8(a: string, b: string): number {
|
|
53
|
+
let i = 0
|
|
54
|
+
let j = 0
|
|
55
|
+
while (i < a.length && j < b.length) {
|
|
56
|
+
const x = codePointAt(a, i)
|
|
57
|
+
const y = codePointAt(b, j)
|
|
58
|
+
if (x !== y) {
|
|
59
|
+
return x < y ? -1 : 1
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
i += x > 0xffff ? 2 : 1
|
|
63
|
+
j += y > 0xffff ? 2 : 1
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return a.length - i - (b.length - j)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The code point at `i`, with a lone surrogate read as U+FFFD (what Go writes for it). */
|
|
70
|
+
function codePointAt(text: string, i: number): number {
|
|
71
|
+
const unit = text.charCodeAt(i)
|
|
72
|
+
if (isHighSurrogate(unit)) {
|
|
73
|
+
const next = text.charCodeAt(i + 1)
|
|
74
|
+
if (isLowSurrogate(next)) {
|
|
75
|
+
return (unit - 0xd800) * 0x400 + (next - 0xdc00) + 0x10000
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return isSurrogate(unit) ? 0xfffd : unit
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isHighSurrogate(unit: number): boolean {
|
|
83
|
+
return unit >= 0xd800 && unit <= 0xdbff
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isLowSurrogate(unit: number): boolean {
|
|
87
|
+
return unit >= 0xdc00 && unit <= 0xdfff
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isSurrogate(unit: number): boolean {
|
|
91
|
+
return unit >= 0xd800 && unit <= 0xdfff
|
|
92
|
+
}
|