@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
package/src/retry.ts ADDED
@@ -0,0 +1,74 @@
1
+ // Retry policy: how many times a command runs and how long to wait between attempts, with the
2
+ // defaults the compat fixtures pin (outcomes.json, retry).
3
+
4
+ import {EvtError} from './errors.js'
5
+
6
+ /**
7
+ * How many times to run a command and how long to wait between attempts. Delays grow
8
+ * exponentially, starting at `baseDelayMs` and capped at `maxDelayMs`, with jitter in the upper
9
+ * half of each (`[delay / 2, delay]`), so writers that conflicted together don't retry together.
10
+ */
11
+ export interface RetryPolicy {
12
+ /** Attempts in total, the first included. */
13
+ readonly maxAttempts: number
14
+ /** The delay before the first retry, before jitter. */
15
+ readonly baseDelayMs: number
16
+ /** The upper bound of any delay. */
17
+ readonly maxDelayMs: number
18
+ }
19
+
20
+ /** Five attempts, waiting from 25 ms up to at most 1 s, as Go and Rust do. */
21
+ export const DEFAULT_RETRY_POLICY: RetryPolicy = Object.freeze({
22
+ maxAttempts: 5,
23
+ baseDelayMs: 25,
24
+ maxDelayMs: 1000,
25
+ })
26
+
27
+ /** Refuses a policy with fewer than one attempt or a delay that isn't a whole, finite number. */
28
+ export function checkRetryPolicy(policy: RetryPolicy): void {
29
+ const valid =
30
+ Number.isSafeInteger(policy.maxAttempts) &&
31
+ policy.maxAttempts >= 1 &&
32
+ Number.isSafeInteger(policy.baseDelayMs) &&
33
+ policy.baseDelayMs >= 0 &&
34
+ Number.isSafeInteger(policy.maxDelayMs) &&
35
+ policy.maxDelayMs >= 0
36
+ if (!valid) {
37
+ throw new EvtError(
38
+ 'invalidArgument',
39
+ 'a retry policy needs at least one attempt and whole, non-negative delays: ' +
40
+ `${policy.maxAttempts}, ${policy.baseDelayMs}, ${policy.maxDelayMs}`,
41
+ )
42
+ }
43
+ }
44
+
45
+ /**
46
+ * The jittered delay in milliseconds before retry `retry` (0 for the first retry). `random`
47
+ * returns a number in [0, 1), as `Math.random` does.
48
+ */
49
+ export function retryDelay(policy: RetryPolicy, retry: number, random = Math.random): number {
50
+ const ceiling = Math.min(policy.baseDelayMs * 2 ** retry, policy.maxDelayMs)
51
+ const floor = Math.floor(ceiling / 2)
52
+
53
+ return floor + Math.floor(random() * (ceiling - floor + 1))
54
+ }
55
+
56
+ /** Waits `ms` milliseconds, or rejects with the signal's reason when it aborts first. */
57
+ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
58
+ return new Promise((resolve, reject) => {
59
+ if (signal?.aborted) {
60
+ reject(signal.reason)
61
+ return
62
+ }
63
+
64
+ const timer = setTimeout(() => {
65
+ signal?.removeEventListener('abort', aborted)
66
+ resolve()
67
+ }, ms)
68
+ const aborted = () => {
69
+ clearTimeout(timer)
70
+ reject(signal?.reason)
71
+ }
72
+ signal?.addEventListener('abort', aborted, {once: true})
73
+ })
74
+ }
@@ -0,0 +1,42 @@
1
+ // The snapshot policy and the per-item budget a snapshot must fit, the same on every backend.
2
+
3
+ import {snapshotItem, itemSize} from './dynamodb/item.js'
4
+ import type {SerializedSnapshot} from './event.js'
5
+ import {safeIntegerArgument} from './fields.js'
6
+
7
+ /**
8
+ * The most bytes one action of a commit may carry: 95% of DynamoDB's 400 KB item limit. A commit
9
+ * with a larger item fails on every backend.
10
+ */
11
+ export const MAX_COMMIT_ITEM_BYTES = 380_000
12
+
13
+ // A ttl attribute at its widest: the name and the 19 digits of the largest int64.
14
+ const WIDEST_TTL = {N: (2n ** 63n - 1n).toString()}
15
+
16
+ /**
17
+ * Whether a commit takes a snapshot, given the stream's current sequence, the events in the
18
+ * commit and the snapshot interval: 0 for no snapshot, otherwise the events it folds in, which is
19
+ * always the whole commit. A commit snapshots when it reaches or crosses the next multiple of the
20
+ * interval, and an interval of 0 (or less, as in Go) disables snapshots.
21
+ */
22
+ export function snapshotEventsDue(current: bigint, events: number, interval: number): number {
23
+ safeIntegerArgument(events, 'events')
24
+ if (safeIntegerArgument(interval, 'interval') <= 0) {
25
+ return 0
26
+ }
27
+
28
+ const divisor = BigInt(interval)
29
+ const nextSnapshotAt = interval - Number(((current % divisor) + divisor) % divisor)
30
+
31
+ return events < nextSnapshotAt ? 0 : events
32
+ }
33
+
34
+ /**
35
+ * The bytes the commit budget counts for a snapshot's item, with a `ttl` at its widest whether or
36
+ * not the entity type is retained. A store leaves out a snapshot over
37
+ * {@link MAX_COMMIT_ITEM_BYTES}, so an aggregate whose state outgrows the budget doesn't make
38
+ * every commit that reaches the interval fail.
39
+ */
40
+ export function snapshotCommitSize(snapshot: SerializedSnapshot): number {
41
+ return itemSize({...snapshotItem(snapshot), ttl: WIDEST_TTL})
42
+ }
package/src/store.ts ADDED
@@ -0,0 +1,377 @@
1
+ // The Store: loads aggregates, runs commands, and commits their events with the snapshots the
2
+ // snapshot policy calls for.
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 {commitIds} from './commit-id.js'
13
+ import {EvtError, SequenceGapError, SnapshotError, isConflict, isRetryable} from './errors.js'
14
+ import {type SerializedEvent, eventId} from './event.js'
15
+ import {type Metadata, emptyMetadata} from './metadata.js'
16
+ import type {CallOptions, NewSnapshot, Repository} from './repository.js'
17
+ import {
18
+ DEFAULT_RETRY_POLICY,
19
+ type RetryPolicy,
20
+ checkRetryPolicy,
21
+ retryDelay,
22
+ sleep,
23
+ } from './retry.js'
24
+ import {MAX_COMMIT_ITEM_BYTES, snapshotCommitSize, snapshotEventsDue} from './snapshots.js'
25
+
26
+ /** The default number of events between snapshots, as in Go and Rust. */
27
+ export const DEFAULT_SNAPSHOT_INTERVAL = 5
28
+
29
+ // How many times a load reads the stream when it finds a sequence gap.
30
+ const LOAD_ATTEMPTS = 3
31
+
32
+ const INT64_MAX = 2n ** 63n - 1n
33
+
34
+ /** How a {@link Store} fills in what commands leave out. */
35
+ export interface StoreOptions {
36
+ /** The region metadata gets when a command's metadata has none. */
37
+ region?: string | undefined
38
+ /** The clock, in Unix milliseconds, that stamps metadata timestamps. `Date.now` by default. */
39
+ clock?: (() => number) | undefined
40
+ /** The number of events between snapshots (default 5). 0 disables them. */
41
+ snapshotInterval?: number | undefined
42
+ /**
43
+ * Makes the commit ID of a commit whose metadata has neither a command ID nor a commit ID. ULID
44
+ * format by default.
45
+ */
46
+ commitId?: (() => string) | undefined
47
+ }
48
+
49
+ /** An aggregate loaded from its stream, with the position it was loaded at. */
50
+ export interface Loaded<S> {
51
+ readonly entityId: string
52
+ readonly state: S
53
+ /** The sequence of the last event folded into the state, 0 for a new entity. */
54
+ readonly sequence: bigint
55
+ /** The sequence of the snapshot the load started from, or the commit wrote, 0 with none. */
56
+ readonly snapshotSequence: bigint
57
+ /** The command IDs of the events folded in since the snapshot: the dedupe window. */
58
+ readonly seenCommandIds: ReadonlySet<string>
59
+ }
60
+
61
+ /** Options for {@link Store.execute}. */
62
+ export interface ExecuteOptions extends CallOptions {
63
+ /**
64
+ * The command's metadata. What it leaves out is empty, except the region and the timestamp,
65
+ * which the store fills in.
66
+ */
67
+ metadata?: Partial<Metadata> | undefined
68
+ }
69
+
70
+ /** Options for {@link Store.executeWithRetry}. */
71
+ export interface RetryOptions extends ExecuteOptions {
72
+ /** {@link DEFAULT_RETRY_POLICY} by default. */
73
+ policy?: RetryPolicy | undefined
74
+ /** Waits between attempts. A timer by default; inject one in tests. */
75
+ sleep?: ((ms: number, signal?: AbortSignal) => Promise<void>) | undefined
76
+ }
77
+
78
+ /**
79
+ * What a command did:
80
+ *
81
+ * - `committed`: it was accepted, and its events (possibly none) are stored.
82
+ * - `duplicate`: an event since the latest snapshot already carries its command ID, so it didn't
83
+ * run.
84
+ * - `rejected`: the handler refused it. Nothing was written.
85
+ */
86
+ export type ExecuteResult<S> =
87
+ | {kind: 'committed'; state: Loaded<S>; events: SerializedEvent[]}
88
+ | {kind: 'duplicate'; commandId: string}
89
+ | {kind: 'rejected'; rejection: Rejection; state: Loaded<S>}
90
+
91
+ export class Store {
92
+ readonly repository: Repository
93
+ readonly #region: string
94
+ readonly #clock: () => number
95
+ readonly #snapshotInterval: number
96
+ readonly #commitId: () => string
97
+
98
+ constructor(repository: Repository, options: StoreOptions = {}) {
99
+ const interval = options.snapshotInterval ?? DEFAULT_SNAPSHOT_INTERVAL
100
+ if (!Number.isSafeInteger(interval) || interval < 0) {
101
+ throw new EvtError(
102
+ 'invalidArgument',
103
+ `the snapshot interval must be a whole number of events: ${interval}`,
104
+ )
105
+ }
106
+
107
+ this.repository = repository
108
+ this.#region = options.region ?? ''
109
+ this.#clock = options.clock ?? Date.now
110
+ this.#snapshotInterval = interval
111
+ this.#commitId = options.commitId ?? commitIds(this.#clock)
112
+ }
113
+
114
+ /** The number of events between snapshots of `aggregate`: its own interval, or the store's. */
115
+ snapshotInterval(aggregate: {readonly snapshotInterval?: number | undefined}): number {
116
+ return aggregate.snapshotInterval ?? this.#snapshotInterval
117
+ }
118
+
119
+ /**
120
+ * Loads an aggregate from its snapshot and the events after it, or from every event when it has
121
+ * no snapshot. An entity with no events loads as the initial state at sequence 0.
122
+ *
123
+ * The events must continue the sequence. A gap means another writer snapshotted and compacted
124
+ * the stream between the two reads, so the load starts over, up to three times, and then fails
125
+ * with a {@link SequenceGapError}. When no events follow, the snapshot is read again to rule out
126
+ * a compaction that left no gap to see.
127
+ */
128
+ async load<S, C, D extends EventDefinitions>(
129
+ aggregate: Aggregate<S, C, D>,
130
+ entityId: string,
131
+ options: CallOptions = {},
132
+ ): Promise<Loaded<S>> {
133
+ // Each attempt waits for the one before it.
134
+ /* oxlint-disable no-await-in-loop */
135
+ for (let attempt = 1; ; attempt++) {
136
+ try {
137
+ return await this.#loadOnce(aggregate, entityId, options)
138
+ } catch (error) {
139
+ if (!isConflict(error) || attempt >= LOAD_ATTEMPTS) {
140
+ throw error
141
+ }
142
+ }
143
+ }
144
+ /* oxlint-enable no-await-in-loop */
145
+ }
146
+
147
+ /**
148
+ * Loads the aggregate, runs the command, and commits its events, with a snapshot when the
149
+ * commit reaches the snapshot interval. A snapshot whose item is over the per-item commit budget
150
+ * is left out, so the commit still succeeds and later loads replay from the one before.
151
+ *
152
+ * A command ID already carried by an event folded in since the latest snapshot makes the
153
+ * command a `duplicate`, and the handler doesn't run. A conflict with another writer throws;
154
+ * {@link executeWithRetry} runs the command again.
155
+ */
156
+ async execute<S, C, D extends EventDefinitions>(
157
+ aggregate: Aggregate<S, C, D>,
158
+ entityId: string,
159
+ command: C,
160
+ options: ExecuteOptions = {},
161
+ ): Promise<ExecuteResult<S>> {
162
+ const metadata = this.#metadata(options.metadata)
163
+ const loaded = await this.load(aggregate, entityId, options)
164
+
165
+ const commandId = metadata.commandId
166
+ if (commandId !== null && commandId !== '' && loaded.seenCommandIds.has(commandId)) {
167
+ return {kind: 'duplicate', commandId}
168
+ }
169
+
170
+ const decision = await aggregate.handle(loaded.state, command)
171
+ if (decision.kind === 'reject') {
172
+ return {kind: 'rejected', rejection: decision.rejection, state: loaded}
173
+ }
174
+ if (decision.events.length === 0) {
175
+ return {kind: 'committed', state: loaded, events: []}
176
+ }
177
+
178
+ const {state, events, snapshot} = this.#stage(aggregate, loaded, decision.events, metadata)
179
+ await this.repository.commit({entityId, events, snapshot}, options)
180
+
181
+ return {kind: 'committed', state, events}
182
+ }
183
+
184
+ /**
185
+ * Like {@link execute}, but loads and runs the command again after a retryable error (a
186
+ * conflict), waiting {@link retryDelay} between attempts. When the attempts run out, the last
187
+ * error is thrown. A rejection or a duplicate is a result, never retried.
188
+ */
189
+ async executeWithRetry<S, C, D extends EventDefinitions>(
190
+ aggregate: Aggregate<S, C, D>,
191
+ entityId: string,
192
+ command: C,
193
+ options: RetryOptions = {},
194
+ ): Promise<ExecuteResult<S>> {
195
+ const policy = options.policy ?? DEFAULT_RETRY_POLICY
196
+ checkRetryPolicy(policy)
197
+ const wait = options.sleep ?? sleep
198
+
199
+ // Each attempt waits for the one before it.
200
+ /* oxlint-disable no-await-in-loop */
201
+ for (let retry = 0; ; retry++) {
202
+ try {
203
+ return await this.execute(aggregate, entityId, command, options)
204
+ } catch (error) {
205
+ if (!isRetryable(error) || retry + 1 >= policy.maxAttempts) {
206
+ throw error
207
+ }
208
+ }
209
+
210
+ await wait(retryDelay(policy, retry), options.signal)
211
+ }
212
+ /* oxlint-enable no-await-in-loop */
213
+ }
214
+
215
+ async #loadOnce<S, C, D extends EventDefinitions>(
216
+ aggregate: Aggregate<S, C, D>,
217
+ entityId: string,
218
+ options: CallOptions,
219
+ ): Promise<Loaded<S>> {
220
+ const snapshot = await this.repository.snapshot(entityId, options)
221
+
222
+ let state = aggregate.initial()
223
+ let sequence = 0n
224
+ let snapshotSequence = 0n
225
+ if (snapshot) {
226
+ try {
227
+ state = aggregate.state.decode(snapshot.payload ?? new Uint8Array())
228
+ } catch (error) {
229
+ throw new SnapshotError(entityId, error)
230
+ }
231
+ sequence = snapshot.eventSequence
232
+ snapshotSequence = snapshot.sequence
233
+ }
234
+
235
+ const events = snapshot
236
+ ? await this.repository.eventsAfter(entityId, sequence, options)
237
+ : await this.repository.events(entityId, options)
238
+
239
+ // With no events after it, a snapshot and a compaction between the two reads would leave no
240
+ // gap to see, so the snapshot is read again: a newer one means the stream moved past it.
241
+ if (events.length === 0) {
242
+ const covered = (await this.repository.snapshot(entityId, options))?.eventSequence ?? 0n
243
+ if (covered > sequence) {
244
+ throw new SequenceGapError(entityId, sequence, covered + 1n)
245
+ }
246
+ }
247
+
248
+ const seenCommandIds = new Set<string>()
249
+ for (const stored of events) {
250
+ if (stored.sequence !== sequence + 1n) {
251
+ throw new SequenceGapError(entityId, sequence, stored.sequence)
252
+ }
253
+
254
+ state = aggregate.apply(state, replayEvent(aggregate.events, stored))
255
+ sequence = stored.sequence
256
+ const commandId = stored.metadata.commandId
257
+ if (commandId !== null && commandId !== '') {
258
+ seenCommandIds.add(commandId)
259
+ }
260
+ }
261
+
262
+ return {entityId, state, sequence, snapshotSequence, seenCommandIds}
263
+ }
264
+
265
+ // Applies the decided events, and builds the stored events and the snapshot the policy calls
266
+ // for. The state is only used once the commit succeeds.
267
+ #stage<S, C, D extends EventDefinitions>(
268
+ aggregate: Aggregate<S, C, D>,
269
+ loaded: Loaded<S>,
270
+ decided: readonly EventOf<D>[],
271
+ metadata: Metadata,
272
+ ): {state: Loaded<S>; events: SerializedEvent[]; snapshot: NewSnapshot | null} {
273
+ const entityId = loaded.entityId
274
+ let sequence = loaded.sequence
275
+ let state = loaded.state
276
+ const events: SerializedEvent[] = []
277
+
278
+ for (const event of decided) {
279
+ if (sequence >= INT64_MAX) {
280
+ throw new EvtError(
281
+ 'invalidCommit',
282
+ `invalid commit: entity ${entityId} is at the maximum event sequence`,
283
+ )
284
+ }
285
+ sequence++
286
+
287
+ const {version, payload} = encodeEventData(aggregate.events, event)
288
+ events.push({
289
+ id: eventId(entityId, sequence),
290
+ sequence,
291
+ type: event.type,
292
+ version,
293
+ entityId,
294
+ entityType: aggregate.type,
295
+ payload,
296
+ metadata: copyMetadata(metadata),
297
+ })
298
+ state = aggregate.apply(state, event)
299
+ }
300
+
301
+ const interval = this.snapshotInterval(aggregate)
302
+ let snapshot: NewSnapshot | null = null
303
+ if (snapshotEventsDue(loaded.sequence, decided.length, interval) > 0) {
304
+ snapshot = {
305
+ entityType: aggregate.type,
306
+ sequence: loaded.snapshotSequence + 1n,
307
+ payload: aggregate.state.encode(state),
308
+ }
309
+
310
+ // A snapshot over the per-item budget would fail every commit that carries it.
311
+ const size = snapshotCommitSize({...snapshot, entityId, eventSequence: sequence})
312
+ if (size > MAX_COMMIT_ITEM_BYTES) {
313
+ snapshot = null
314
+ }
315
+ }
316
+
317
+ const seenCommandIds = new Set(loaded.seenCommandIds)
318
+ if (metadata.commandId !== null && metadata.commandId !== '') {
319
+ seenCommandIds.add(metadata.commandId)
320
+ }
321
+
322
+ return {
323
+ state: {
324
+ entityId,
325
+ state,
326
+ sequence,
327
+ snapshotSequence: snapshot ? snapshot.sequence : loaded.snapshotSequence,
328
+ seenCommandIds,
329
+ },
330
+ events,
331
+ snapshot,
332
+ }
333
+ }
334
+
335
+ // The command's metadata with the store's region and the clock's timestamp where it has none,
336
+ // and the commit ID the engine stamps: the command ID, else the caller's, else a generated one.
337
+ // Field by field, so a field an untyped caller set to undefined is empty too, and every key is
338
+ // written.
339
+ #metadata(partial: Partial<Metadata> = {}): Metadata {
340
+ const empty = emptyMetadata()
341
+ const metadata: Metadata = {
342
+ commandId: partial.commandId ?? empty.commandId,
343
+ trace: partial.trace ? new Map(partial.trace) : empty.trace,
344
+ origin: partial.origin ? {...partial.origin} : empty.origin,
345
+ address: partial.address ?? empty.address,
346
+ region: partial.region || this.#region,
347
+ timestamp: partial.timestamp || rfc3339Seconds(this.#clock()),
348
+ principalId: partial.principalId ?? empty.principalId,
349
+ causationId: partial.causationId ?? empty.causationId,
350
+ extensions: partial.extensions ? new Map(partial.extensions) : empty.extensions,
351
+ commitId: partial.commitId ?? empty.commitId,
352
+ }
353
+
354
+ if (metadata.commandId !== null && metadata.commandId !== '') {
355
+ metadata.commitId = metadata.commandId
356
+ } else if (metadata.commitId === '') {
357
+ metadata.commitId = this.#commitId()
358
+ }
359
+
360
+ return metadata
361
+ }
362
+ }
363
+
364
+ // Each stored event gets its own metadata, so changing one changes no other.
365
+ function copyMetadata(metadata: Metadata): Metadata {
366
+ return {
367
+ ...metadata,
368
+ trace: metadata.trace && new Map(metadata.trace),
369
+ origin: metadata.origin && {...metadata.origin},
370
+ extensions: new Map(metadata.extensions),
371
+ }
372
+ }
373
+
374
+ // RFC 3339 in UTC, to the second, as Go's time.RFC3339 formats a UTC time.
375
+ function rfc3339Seconds(ms: number): string {
376
+ return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
377
+ }
@@ -0,0 +1,146 @@
1
+ // Structural equality and printing for test assertions, so /testing depends on no test framework.
2
+
3
+ /**
4
+ * A failed assertion. It carries `actual` and `expected` the way Node's `assert` does, so runners
5
+ * such as Vitest and Jest show a diff, and `node:test` prints both.
6
+ */
7
+ export class AssertionError extends Error {
8
+ override readonly name = 'AssertionError'
9
+ readonly actual: unknown
10
+ readonly expected: unknown
11
+
12
+ constructor(message: string, actual?: unknown, expected?: unknown) {
13
+ super(message)
14
+ this.actual = actual
15
+ this.expected = expected
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Whether two values have the same structure: objects of the same prototype by their own
21
+ * enumerable string and symbol keys (a key whose value is `undefined` counts as absent), arrays by
22
+ * their elements, holes included, `Map`s by their entries and `Set`s by their members, buffers and
23
+ * typed arrays by their bytes, dates by their time, regular expressions by their source and flags,
24
+ * and everything else with `Object.is`. `Map` keys and `Set` members match by identity, and
25
+ * `#private` fields aren't seen.
26
+ */
27
+ export function deepEqual(a: unknown, b: unknown): boolean {
28
+ if (Object.is(a, b)) {
29
+ return true
30
+ }
31
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
32
+ return false
33
+ }
34
+ if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) {
35
+ return false
36
+ }
37
+
38
+ if (Array.isArray(a)) {
39
+ const other = b as unknown[]
40
+ if (a.length !== other.length) {
41
+ return false
42
+ }
43
+
44
+ // An index loop, since every() skips holes.
45
+ for (let i = 0; i < a.length; i++) {
46
+ if (i in a !== i in other || !deepEqual(a[i], other[i])) {
47
+ return false
48
+ }
49
+ }
50
+
51
+ return true
52
+ }
53
+ if (a instanceof ArrayBuffer || ArrayBuffer.isView(a)) {
54
+ const left = bytes(a)
55
+ const right = bytes(b as ArrayBuffer | ArrayBufferView)
56
+
57
+ return left.length === right.length && left.every((byte, i) => byte === right[i])
58
+ }
59
+ if (a instanceof Map) {
60
+ const other = b as Map<unknown, unknown>
61
+
62
+ return (
63
+ a.size === other.size &&
64
+ [...a].every(([key, entry]) => other.has(key) && deepEqual(entry, other.get(key)))
65
+ )
66
+ }
67
+ if (a instanceof Set) {
68
+ const other = b as Set<unknown>
69
+
70
+ return a.size === other.size && [...a].every((entry) => other.has(entry))
71
+ }
72
+ if (a instanceof Date) {
73
+ return Object.is(a.getTime(), (b as Date).getTime())
74
+ }
75
+ if (a instanceof RegExp) {
76
+ return a.source === (b as RegExp).source && a.flags === (b as RegExp).flags
77
+ }
78
+
79
+ const left = definedKeys(a)
80
+ const right = definedKeys(b)
81
+
82
+ return (
83
+ left.length === right.length &&
84
+ left.every((key) => Object.hasOwn(b, key) && deepEqual(field(a, key), field(b, key)))
85
+ )
86
+ }
87
+
88
+ function bytes(value: ArrayBuffer | ArrayBufferView): Uint8Array {
89
+ return value instanceof ArrayBuffer
90
+ ? new Uint8Array(value)
91
+ : new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
92
+ }
93
+
94
+ // The own enumerable keys, symbols included, whose values aren't undefined.
95
+ function definedKeys(value: object): (string | symbol)[] {
96
+ const symbols = Object.getOwnPropertySymbols(value).filter((key) =>
97
+ Object.prototype.propertyIsEnumerable.call(value, key),
98
+ )
99
+
100
+ return [...Object.keys(value), ...symbols].filter((key) => field(value, key) !== undefined)
101
+ }
102
+
103
+ function field(value: object, key: string | symbol): unknown {
104
+ return (value as Record<string | symbol, unknown>)[key]
105
+ }
106
+
107
+ /** Prints a value for an assertion message, with `bigint`s, `Map`s and byte arrays legible. */
108
+ export function show(value: unknown): string {
109
+ if (typeof value === 'string') {
110
+ return JSON.stringify(value)
111
+ }
112
+ if (typeof value === 'bigint') {
113
+ return `${value}n`
114
+ }
115
+ if (Object.is(value, -0)) {
116
+ return '-0'
117
+ }
118
+ if (typeof value !== 'object' || value === null) {
119
+ return String(value)
120
+ }
121
+
122
+ if (Array.isArray(value)) {
123
+ return `[${value.map(show).join(', ')}]`
124
+ }
125
+ if (value instanceof Uint8Array) {
126
+ return `Uint8Array [${value.join(', ')}]`
127
+ }
128
+ if (value instanceof Map) {
129
+ const entries = [...value].map(([key, entry]) => `${show(key)} => ${show(entry)}`)
130
+
131
+ return `Map {${entries.join(', ')}}`
132
+ }
133
+ if (value instanceof Set) {
134
+ return `Set {${[...value].map(show).join(', ')}}`
135
+ }
136
+ if (value instanceof Date) {
137
+ return Number.isNaN(value.getTime()) ? 'Invalid Date' : `Date ${value.toISOString()}`
138
+ }
139
+ if (value instanceof Error) {
140
+ return `${value.name}: ${value.message}`
141
+ }
142
+
143
+ const entries = Object.entries(value).map(([key, entry]) => `${show(key)}: ${show(entry)}`)
144
+
145
+ return `{${entries.join(', ')}}`
146
+ }
@@ -0,0 +1,14 @@
1
+ // @photon-grove/evt/testing: given/when/then helpers over MemRepository. They throw an
2
+ // AssertionError on failure, so they work in any test runner.
3
+
4
+ export {AssertionError, deepEqual} from './equal.js'
5
+ export {
6
+ type Committed,
7
+ DEFAULT_ENTITY_ID,
8
+ Fixture,
9
+ Outcome,
10
+ Scenario,
11
+ type ScenarioSetup,
12
+ When,
13
+ scenario,
14
+ } from './scenario.js'