@photon-grove/evt 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +122 -2
  3. package/dist/aggregate.d.ts +83 -0
  4. package/dist/aggregate.d.ts.map +1 -0
  5. package/dist/aggregate.js +92 -0
  6. package/dist/aggregate.js.map +1 -0
  7. package/dist/base64.d.ts +8 -0
  8. package/dist/base64.d.ts.map +1 -0
  9. package/dist/base64.js +67 -0
  10. package/dist/base64.js.map +1 -0
  11. package/dist/codec.d.ts +56 -0
  12. package/dist/codec.d.ts.map +1 -0
  13. package/dist/codec.js +90 -0
  14. package/dist/codec.js.map +1 -0
  15. package/dist/commit-id.d.ts +3 -0
  16. package/dist/commit-id.d.ts.map +1 -0
  17. package/dist/commit-id.js +43 -0
  18. package/dist/commit-id.js.map +1 -0
  19. package/dist/dynamodb/head.d.ts +34 -0
  20. package/dist/dynamodb/head.d.ts.map +1 -0
  21. package/dist/dynamodb/head.js +62 -0
  22. package/dist/dynamodb/head.js.map +1 -0
  23. package/dist/dynamodb/index.d.ts +2 -0
  24. package/dist/dynamodb/index.d.ts.map +1 -0
  25. package/dist/dynamodb/index.js +4 -0
  26. package/dist/dynamodb/index.js.map +1 -0
  27. package/dist/dynamodb/item.d.ts +68 -0
  28. package/dist/dynamodb/item.d.ts.map +1 -0
  29. package/dist/dynamodb/item.js +263 -0
  30. package/dist/dynamodb/item.js.map +1 -0
  31. package/dist/envelope.d.ts +15 -0
  32. package/dist/envelope.d.ts.map +1 -0
  33. package/dist/envelope.js +59 -0
  34. package/dist/envelope.js.map +1 -0
  35. package/dist/errors.d.ts +111 -0
  36. package/dist/errors.d.ts.map +1 -0
  37. package/dist/errors.js +139 -0
  38. package/dist/errors.js.map +1 -0
  39. package/dist/event.d.ts +47 -0
  40. package/dist/event.d.ts.map +1 -0
  41. package/dist/event.js +64 -0
  42. package/dist/event.js.map +1 -0
  43. package/dist/fields.d.ts +31 -0
  44. package/dist/fields.d.ts.map +1 -0
  45. package/dist/fields.js +105 -0
  46. package/dist/fields.js.map +1 -0
  47. package/dist/index.d.ts +14 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +16 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/json.d.ts +47 -0
  52. package/dist/json.d.ts.map +1 -0
  53. package/dist/json.js +458 -0
  54. package/dist/json.js.map +1 -0
  55. package/dist/memory/index.d.ts +2 -0
  56. package/dist/memory/index.d.ts.map +1 -0
  57. package/dist/memory/index.js +3 -0
  58. package/dist/memory/index.js.map +1 -0
  59. package/dist/memory/repository.d.ts +34 -0
  60. package/dist/memory/repository.d.ts.map +1 -0
  61. package/dist/memory/repository.js +219 -0
  62. package/dist/memory/repository.js.map +1 -0
  63. package/dist/metadata.d.ts +47 -0
  64. package/dist/metadata.d.ts.map +1 -0
  65. package/dist/metadata.js +75 -0
  66. package/dist/metadata.js.map +1 -0
  67. package/dist/repository.d.ts +102 -0
  68. package/dist/repository.d.ts.map +1 -0
  69. package/dist/repository.js +131 -0
  70. package/dist/repository.js.map +1 -0
  71. package/dist/retry.d.ts +25 -0
  72. package/dist/retry.d.ts.map +1 -0
  73. package/dist/retry.js +50 -0
  74. package/dist/retry.js.map +1 -0
  75. package/dist/snapshots.d.ts +21 -0
  76. package/dist/snapshots.d.ts.map +1 -0
  77. package/dist/snapshots.js +35 -0
  78. package/dist/snapshots.js.map +1 -0
  79. package/dist/store.d.ts +103 -0
  80. package/dist/store.d.ts.map +1 -0
  81. package/dist/store.js +246 -0
  82. package/dist/store.js.map +1 -0
  83. package/dist/testing/equal.d.ts +22 -0
  84. package/dist/testing/equal.d.ts.map +1 -0
  85. package/dist/testing/equal.js +121 -0
  86. package/dist/testing/equal.js.map +1 -0
  87. package/dist/testing/index.d.ts +3 -0
  88. package/dist/testing/index.d.ts.map +1 -0
  89. package/dist/testing/index.js +5 -0
  90. package/dist/testing/index.js.map +1 -0
  91. package/dist/testing/scenario.d.ts +136 -0
  92. package/dist/testing/scenario.d.ts.map +1 -0
  93. package/dist/testing/scenario.js +307 -0
  94. package/dist/testing/scenario.js.map +1 -0
  95. package/dist/utf8.d.ts +17 -0
  96. package/dist/utf8.d.ts.map +1 -0
  97. package/dist/utf8.js +82 -0
  98. package/dist/utf8.js.map +1 -0
  99. package/package.json +56 -4
  100. package/src/aggregate.ts +196 -0
  101. package/src/base64.ts +80 -0
  102. package/src/codec.ts +139 -0
  103. package/src/commit-id.ts +50 -0
  104. package/src/dynamodb/head.ts +93 -0
  105. package/src/dynamodb/index.ts +21 -0
  106. package/src/dynamodb/item.ts +332 -0
  107. package/src/envelope.ts +67 -0
  108. package/src/errors.ts +214 -0
  109. package/src/event.ts +117 -0
  110. package/src/fields.ts +134 -0
  111. package/src/globals.d.ts +39 -0
  112. package/src/index.ts +75 -0
  113. package/src/json.ts +546 -0
  114. package/src/memory/index.ts +3 -0
  115. package/src/memory/repository.ts +313 -0
  116. package/src/metadata.ts +120 -0
  117. package/src/repository.ts +247 -0
  118. package/src/retry.ts +74 -0
  119. package/src/snapshots.ts +42 -0
  120. package/src/store.ts +377 -0
  121. package/src/testing/equal.ts +146 -0
  122. package/src/testing/index.ts +14 -0
  123. package/src/testing/scenario.ts +406 -0
  124. package/src/utf8.ts +92 -0
@@ -0,0 +1,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
+ }