@photon-grove/evt 0.1.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.
Files changed (65) hide show
  1. package/README.md +50 -9
  2. package/dist/dynamodb/client.d.ts +83 -0
  3. package/dist/dynamodb/client.d.ts.map +1 -0
  4. package/dist/dynamodb/client.js +6 -0
  5. package/dist/dynamodb/client.js.map +1 -0
  6. package/dist/dynamodb/index.d.ts +5 -1
  7. package/dist/dynamodb/index.d.ts.map +1 -1
  8. package/dist/dynamodb/index.js +7 -3
  9. package/dist/dynamodb/index.js.map +1 -1
  10. package/dist/dynamodb/item.d.ts +2 -0
  11. package/dist/dynamodb/item.d.ts.map +1 -1
  12. package/dist/dynamodb/item.js +5 -4
  13. package/dist/dynamodb/item.js.map +1 -1
  14. package/dist/dynamodb/marker.d.ts +11 -0
  15. package/dist/dynamodb/marker.d.ts.map +1 -0
  16. package/dist/dynamodb/marker.js +56 -0
  17. package/dist/dynamodb/marker.js.map +1 -0
  18. package/dist/dynamodb/repository.d.ts +61 -0
  19. package/dist/dynamodb/repository.d.ts.map +1 -0
  20. package/dist/dynamodb/repository.js +731 -0
  21. package/dist/dynamodb/repository.js.map +1 -0
  22. package/dist/dynamodb/requests.d.ts +65 -0
  23. package/dist/dynamodb/requests.d.ts.map +1 -0
  24. package/dist/dynamodb/requests.js +179 -0
  25. package/dist/dynamodb/requests.js.map +1 -0
  26. package/dist/errors.d.ts +96 -2
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/errors.js +103 -2
  29. package/dist/errors.js.map +1 -1
  30. package/dist/features.d.ts +55 -0
  31. package/dist/features.d.ts.map +1 -0
  32. package/dist/features.js +126 -0
  33. package/dist/features.js.map +1 -0
  34. package/dist/index.d.ts +3 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/memory/index.d.ts +1 -1
  39. package/dist/memory/index.d.ts.map +1 -1
  40. package/dist/memory/index.js +1 -1
  41. package/dist/memory/index.js.map +1 -1
  42. package/dist/memory/repository.d.ts +38 -2
  43. package/dist/memory/repository.d.ts.map +1 -1
  44. package/dist/memory/repository.js +95 -6
  45. package/dist/memory/repository.js.map +1 -1
  46. package/dist/repository.d.ts +30 -1
  47. package/dist/repository.d.ts.map +1 -1
  48. package/dist/repository.js +3 -4
  49. package/dist/repository.js.map +1 -1
  50. package/dist/store.d.ts +3 -3
  51. package/dist/store.js +3 -3
  52. package/package.json +12 -3
  53. package/src/dynamodb/client.ts +102 -0
  54. package/src/dynamodb/index.ts +26 -2
  55. package/src/dynamodb/item.ts +6 -4
  56. package/src/dynamodb/marker.ts +64 -0
  57. package/src/dynamodb/repository.ts +1030 -0
  58. package/src/dynamodb/requests.ts +256 -0
  59. package/src/errors.ts +153 -2
  60. package/src/features.ts +175 -0
  61. package/src/index.ts +24 -1
  62. package/src/memory/index.ts +9 -1
  63. package/src/memory/repository.ts +156 -6
  64. package/src/repository.ts +34 -6
  65. package/src/store.ts +3 -3
@@ -0,0 +1,1030 @@
1
+ // The DynamoDB repository: one event-log table keyed by `pk` (the entity ID) and `sk` (the event
2
+ // sequence, 0 for the inline snapshot and -1 for the head item), as the storage format lays it
3
+ // out. Every commit is one TransactWriteItems call that opens with the feature-marker and
4
+ // rebuild-lock checks, and a failed one is classified by its cancellation reasons, then by reading
5
+ // the stream, the way the Go and Rust repositories classify it. Entity types with retention carry a
6
+ // `ttl` on every item, and the head item's is never lowered (storage format, "Retention").
7
+ //
8
+ // Pages, retries and the probe's reads each depend on the call before them, so they await in loops
9
+ // on purpose.
10
+ /* oxlint-disable no-await-in-loop */
11
+
12
+ import {
13
+ CompactionUncoveredError,
14
+ ConflictError,
15
+ EvtError,
16
+ FeatureManifestChangedError,
17
+ HistoryDependentProjectionError,
18
+ RebuildInProgressError,
19
+ SequenceGapError,
20
+ StoreNotInitializedError,
21
+ StreamHeadDriftError,
22
+ StreamHeadsNotMigratedError,
23
+ TransientError,
24
+ } from '../errors.js'
25
+ import type {SerializedEvent, SerializedSnapshot} from '../event.js'
26
+ import {
27
+ type FeatureManifest,
28
+ STREAM_HEADS_FEATURE,
29
+ STREAM_HEADS_VERSION,
30
+ checkSupported,
31
+ declaresStreamHeads,
32
+ } from '../features.js'
33
+ import {int64Argument} from '../fields.js'
34
+ import {
35
+ type CallOptions,
36
+ type Commit,
37
+ type Repository,
38
+ type StoreInitializer,
39
+ checkBudget,
40
+ checkNotReserved,
41
+ commitSizes,
42
+ isReserved,
43
+ validateCommit,
44
+ } from '../repository.js'
45
+ import type {DynamoClient, Page, RequestOptions, TransactItem} from './client.js'
46
+ import {
47
+ HEAD_SORT_KEY,
48
+ type Item,
49
+ eventFromItem,
50
+ expiry,
51
+ headFromItem,
52
+ snapshotFromItem,
53
+ sortKey,
54
+ ttlFromItem,
55
+ } from './item.js'
56
+ import {FEATURES_PK, manifestFromItem, markerItem} from './marker.js'
57
+ import {
58
+ type CommitTtl,
59
+ commitTransaction,
60
+ controlKey,
61
+ headKey,
62
+ snapshotTransaction,
63
+ } from './requests.js'
64
+
65
+ /** How a {@link DynamoRepository} reads and initializes its store. */
66
+ export interface DynamoRepositoryOptions {
67
+ /**
68
+ * Whether a write to a store that doesn't declare stream-heads initializes it, scanning the
69
+ * table to make sure it holds no data, instead of refusing with a
70
+ * {@link StoreNotInitializedError}. Off by default, so writers never need `dynamodb:Scan`:
71
+ * initialize a fresh store once, at deploy time, with {@link DynamoRepository.initialize}.
72
+ */
73
+ lazyInitialization?: boolean
74
+ /**
75
+ * Whether event and snapshot reads are strongly consistent. On by default. Head reads, the
76
+ * failure-path probe, compaction and the feature marker always read consistently.
77
+ */
78
+ consistentRead?: boolean
79
+ /**
80
+ * How long the items of each entity type live, in whole milliseconds, keyed by entity type.
81
+ * Every event, snapshot and head item of a type with a positive retention carries a DynamoDB
82
+ * `ttl`, the write time plus the retention in Unix seconds, for the table's TTL to delete. A
83
+ * type that isn't listed, or has 0, is kept forever. TTL deletes items in no order, so retain
84
+ * only short-lived streams that nothing replays. Declare the retained types in the store's
85
+ * feature manifest too, from Go or Rust, so that their writers refuse read fences on them.
86
+ */
87
+ retention?: Readonly<Record<string, number>> | ReadonlyMap<string, number>
88
+ /**
89
+ * The clock that stamps `ttl`s and decides which have passed, in Unix milliseconds as
90
+ * `Date.now` gives them, never seconds. `Date.now` by default.
91
+ */
92
+ clock?: () => number
93
+ }
94
+
95
+ // A declaration is a read-modify-write; racing declarations retry this many times.
96
+ const MAX_DECLARE_ATTEMPTS = 8
97
+
98
+ // Each page of a scan that only has to find one item.
99
+ const SCAN_PROBE_PAGE_SIZE = 100
100
+
101
+ // The most events one latest(n) page reads.
102
+ const LATEST_PAGE_SIZE = 100
103
+
104
+ // The most deletes in one compaction transaction, beside its marker check.
105
+ const COMPACTION_BATCH_SIZE = 99
106
+
107
+ // How many streams' loaded `ttl`s a repository remembers before it forgets them all.
108
+ const MAX_TTL_FLOORS = 4096
109
+
110
+ // How often a snapshot write sends its head guard again after another writer moved the head.
111
+ const MAX_SNAPSHOT_GUARD_RETRIES = 4
112
+
113
+ /**
114
+ * A {@link Repository} on a DynamoDB event-log table, through the AWS SDK's low-level `DynamoDB`
115
+ * client or anything shaped like it ({@link DynamoClient}).
116
+ *
117
+ * The repository validates the store's feature manifest at its first write and caches the version
118
+ * it validated. Every commit and snapshot write checks that version, so when another writer
119
+ * declares a feature, the next write fails, validates the manifest again, and either refuses an
120
+ * unsupported feature or fails once with a retryable {@link FeatureManifestChangedError}.
121
+ */
122
+ export class DynamoRepository implements Repository, StoreInitializer {
123
+ readonly #client: DynamoClient
124
+ readonly #table: string
125
+ readonly #lazy: boolean
126
+ readonly #consistentRead: boolean
127
+ readonly #retention: ReadonlyMap<string, number>
128
+ readonly #clock: () => number
129
+
130
+ // The largest `ttl` this repository loaded for each stream, which a commit's head `ttl` covers.
131
+ readonly #ttlFloors = new Map<string, number>()
132
+
133
+ // The manifest version this writer validated, or null until it validates.
134
+ #validated: bigint | null = null
135
+ // Serializes validation, so concurrent writes read and declare the manifest once.
136
+ #lock: Promise<unknown> = Promise.resolve()
137
+
138
+ constructor(client: DynamoClient, eventsTable: string, options: DynamoRepositoryOptions = {}) {
139
+ this.#client = client
140
+ this.#table = eventsTable
141
+ this.#lazy = options.lazyInitialization ?? false
142
+ this.#consistentRead = options.consistentRead ?? true
143
+ this.#retention = retentionMap(options.retention)
144
+ this.#clock = options.clock ?? Date.now
145
+ }
146
+
147
+ /** The events table this repository writes. */
148
+ get eventsTable(): string {
149
+ return this.#table
150
+ }
151
+
152
+ /**
153
+ * Declares stream-heads on a store that holds no data, and resolves `true` when this call
154
+ * declared it. It scans the table with strongly consistent reads, so run it once, at deploy
155
+ * time, with a role that may Scan. Concurrent initializations let exactly one declare.
156
+ */
157
+ async initialize(options?: CallOptions): Promise<boolean> {
158
+ options?.signal?.throwIfAborted()
159
+ const request = requestOptions(options)
160
+
161
+ return this.#exclusive(async () => {
162
+ try {
163
+ const manifest = await this.#readManifest(request)
164
+ checkSupported(manifest)
165
+ if (declaresStreamHeads(manifest)) {
166
+ return false
167
+ }
168
+ if (!(await this.#storeIsEmpty(request))) {
169
+ throw new StreamHeadsNotMigratedError()
170
+ }
171
+
172
+ // Another initializer may have declared a feature this package doesn't model.
173
+ const {manifest: declared, wrote} = await this.#declareOnEmptyStore(request)
174
+ checkSupported(declared)
175
+
176
+ return wrote
177
+ } finally {
178
+ // A declaration, or its retraction, moved the version: the next write reads it again.
179
+ this.#validated = null
180
+ }
181
+ })
182
+ }
183
+
184
+ async commit(commit: Commit, options?: CallOptions): Promise<void> {
185
+ options?.signal?.throwIfAborted()
186
+ validateCommit(commit)
187
+ checkNotReserved(commit.entityId)
188
+ if (commit.events.length === 0) {
189
+ return
190
+ }
191
+
192
+ // One `ttl` for the whole commit, so its head's is never below one of its own items.
193
+ const entityType = commit.events[0]!.entityType
194
+ const ttl = this.#ttlFor(entityType)
195
+
196
+ // Every backend budgets a commit the same way, so an oversized one fails before any request.
197
+ checkBudget(commitSizes(commit, (type) => (type === entityType ? ttl : undefined)))
198
+
199
+ const request = requestOptions(options)
200
+ const version = await this.#markerVersion(request)
201
+ const items = commitTransaction(this.#table, commit, version, this.#commitTtl(commit, ttl))
202
+ try {
203
+ await this.#client.transactWriteItems({TransactItems: items}, request)
204
+ } catch (error) {
205
+ throw await this.#classifyCommit(commit, error, request)
206
+ }
207
+ }
208
+
209
+ async events(entityId: string, options?: CallOptions): Promise<SerializedEvent[]> {
210
+ return this.eventsAfter(entityId, 0n, options)
211
+ }
212
+
213
+ async eventsAfter(
214
+ entityId: string,
215
+ after: bigint,
216
+ options?: CallOptions,
217
+ ): Promise<SerializedEvent[]> {
218
+ options?.signal?.throwIfAborted()
219
+ int64Argument(after, 'after')
220
+
221
+ // A negative `after` would reach the head item (sk -1) and the snapshot (sk 0).
222
+ const from = after > 0n ? after : 0n
223
+ const events: SerializedEvent[] = []
224
+ const pages = this.#query(entityId, from, true, undefined, requestOptions(options))
225
+ for await (const page of pages) {
226
+ events.push(...this.#decodeEvents(page))
227
+ }
228
+
229
+ return events
230
+ }
231
+
232
+ async latest(entityId: string, n: number, options?: CallOptions): Promise<SerializedEvent[]> {
233
+ options?.signal?.throwIfAborted()
234
+ if (!Number.isSafeInteger(n) || n < 1) {
235
+ throw new EvtError(
236
+ 'invalidArgument',
237
+ `latest(n) requires a whole number of at least one event: got n = ${n}`,
238
+ )
239
+ }
240
+
241
+ const limit = Math.min(n, LATEST_PAGE_SIZE)
242
+ const events: SerializedEvent[] = []
243
+ for await (const page of this.#query(entityId, 0n, false, limit, requestOptions(options))) {
244
+ events.push(...this.#decodeEvents(page))
245
+ if (events.length >= n) {
246
+ break
247
+ }
248
+ }
249
+
250
+ return events.slice(0, n)
251
+ }
252
+
253
+ async snapshot(entityId: string, options?: CallOptions): Promise<SerializedSnapshot | null> {
254
+ options?.signal?.throwIfAborted()
255
+
256
+ return this.#readSnapshot(entityId, this.#consistentRead, requestOptions(options))
257
+ }
258
+
259
+ async putSnapshot(snapshot: SerializedSnapshot, options?: CallOptions): Promise<boolean> {
260
+ options?.signal?.throwIfAborted()
261
+ checkNotReserved(snapshot.entityId)
262
+ int64Argument(snapshot.sequence, 'snapshot.sequence')
263
+ const eventSequence = int64Argument(snapshot.eventSequence, 'snapshot.eventSequence')
264
+
265
+ // Stamped now, as the snapshot's own items are, so it expires with its stream.
266
+ const ttl = this.#ttlFor(snapshot.entityType)
267
+ const request = requestOptions(options)
268
+ const version = await this.#markerVersion(request)
269
+ for (let retries = 0, refresh = true; ; retries++) {
270
+ const guarded = ttl === undefined ? undefined : {ttl, refresh}
271
+ try {
272
+ await this.#client.transactWriteItems(
273
+ {TransactItems: snapshotTransaction(this.#table, snapshot, version, guarded)},
274
+ request,
275
+ )
276
+ this.#observeTtl(snapshot.entityId, ttl)
277
+
278
+ return true
279
+ } catch (error) {
280
+ const codes = cancellationCodes(error)
281
+ const failed = codes?.indexOf(CONDITION_FAILED) ?? -1
282
+ if (failed === 0) {
283
+ throw await this.#markerCheckFailed(error, request)
284
+ }
285
+ if (failed === 2) {
286
+ // A newer snapshot is stored, and stays.
287
+ return false
288
+ }
289
+ if (failed !== 1) {
290
+ if (codes?.some((code) => THROTTLED.has(code))) {
291
+ throw new TransientError('the snapshot write was throttled', {cause: error})
292
+ }
293
+
294
+ throw error
295
+ }
296
+
297
+ // The head guard failed. A refreshing guard can fail on its ttl clause alone, when another
298
+ // writer moved the head: the head covers the snapshot, so send it again, refreshing only
299
+ // when the stored ttl is still below the snapshot's.
300
+ const head = await this.#readHead(snapshot.entityId, request)
301
+ const refreshed = guarded?.refresh === true && eventSequence > 0n
302
+ if (refreshed && head !== null && head.head >= eventSequence) {
303
+ if (retries >= MAX_SNAPSHOT_GUARD_RETRIES) {
304
+ throw new TransientError(
305
+ `the head item of ${snapshot.entityId} kept moving under its snapshot write`,
306
+ {cause: error},
307
+ )
308
+ }
309
+
310
+ refresh = (head.ttl ?? 0) < ttl!
311
+ continue
312
+ }
313
+
314
+ // The head doesn't cover the snapshot: a stream whose log is ahead of its head.
315
+ const derived = await this.#deriveHead(snapshot.entityId, request)
316
+ throw drift(
317
+ snapshot.entityId,
318
+ head?.head ?? null,
319
+ derived > eventSequence ? derived : eventSequence,
320
+ )
321
+ }
322
+ }
323
+ }
324
+
325
+ async compactBelow(entityId: string, through: bigint, options?: CallOptions): Promise<number> {
326
+ options?.signal?.throwIfAborted()
327
+ checkNotReserved(entityId)
328
+ if (int64Argument(through, 'through') < 1n) {
329
+ return 0
330
+ }
331
+
332
+ const request = requestOptions(options)
333
+ const snapshot = await this.#readSnapshot(entityId, true, request)
334
+ let check = this.#compactionCheck(await this.#readManifest(request), entityId, snapshot)
335
+ if (snapshot === null || snapshot.eventSequence < through) {
336
+ throw new CompactionUncoveredError(entityId, through, snapshot?.eventSequence ?? null)
337
+ }
338
+
339
+ const keys: Item[] = []
340
+ for await (const page of this.#rangeKeys(entityId, through, request)) {
341
+ keys.push(...page)
342
+ }
343
+
344
+ // Each batch of deletes checks the manifest the writer validated. A declaration between
345
+ // batches stops the later ones, but can't roll back those already deleted.
346
+ let deleted = 0
347
+ for (let start = 0; start < keys.length; start += COMPACTION_BATCH_SIZE) {
348
+ const batch = keys.slice(start, start + COMPACTION_BATCH_SIZE)
349
+ for (let attempt = 0; ; attempt++) {
350
+ const deletes: TransactItem[] = batch.map((key) => ({
351
+ Delete: {TableName: this.#table, Key: key},
352
+ }))
353
+ try {
354
+ await this.#client.transactWriteItems({TransactItems: [check, ...deletes]}, request)
355
+ deleted += batch.length
356
+ break
357
+ } catch (error) {
358
+ if (cancellationCodes(error)?.indexOf(CONDITION_FAILED) !== 0) {
359
+ throw error
360
+ }
361
+
362
+ // Validate the new manifest first, so a declaration that refuses compaction says so.
363
+ check = this.#compactionCheck(await this.#readManifest(request), entityId, snapshot)
364
+ if (attempt >= MAX_DECLARE_ATTEMPTS) {
365
+ throw new FeatureManifestChangedError({cause: error})
366
+ }
367
+ }
368
+ }
369
+ }
370
+
371
+ return deleted
372
+ }
373
+
374
+ // Validates the manifest for compaction, which never declares stream-heads, and builds the check
375
+ // each batch carries on the version it read.
376
+ #compactionCheck(
377
+ manifest: FeatureManifest | null,
378
+ entityId: string,
379
+ snapshot: SerializedSnapshot | null,
380
+ ): TransactItem {
381
+ checkSupported(manifest)
382
+ if (manifest === null || !declaresStreamHeads(manifest)) {
383
+ throw this.#lazy ? new StreamHeadsNotMigratedError() : new StoreNotInitializedError()
384
+ }
385
+ if (snapshot && manifest.historyDependent.includes(snapshot.entityType)) {
386
+ throw new HistoryDependentProjectionError(snapshot.entityType, entityId)
387
+ }
388
+
389
+ return {
390
+ ConditionCheck: {
391
+ TableName: this.#table,
392
+ Key: controlKey(FEATURES_PK),
393
+ ConditionExpression: 'version = :observed',
394
+ ExpressionAttributeValues: {':observed': {N: manifest.version.toString()}},
395
+ },
396
+ }
397
+ }
398
+
399
+ // The `ttl` an item of the entity type written now carries, or undefined without retention.
400
+ #ttlFor(entityType: string): number | undefined {
401
+ const retention = this.#retention.get(entityType)
402
+ const ttl = retention === undefined ? undefined : expiry(this.#clock(), retention)
403
+
404
+ // An expiry at or before the epoch is no ttl at all, as Go writes none for 0.
405
+ return ttl !== undefined && ttl > 0 ? ttl : undefined
406
+ }
407
+
408
+ // The commit's `ttl`s: its own on its items, and on its head the larger of its own and what this
409
+ // writer loaded for the stream, except on a create, which has nothing to cover.
410
+ #commitTtl(commit: Commit, ttl: number | undefined): CommitTtl | undefined {
411
+ if (ttl === undefined) {
412
+ return undefined
413
+ }
414
+
415
+ const create = commit.events[0]!.sequence <= 1n
416
+ const floor = create ? 0 : (this.#ttlFloors.get(commit.entityId) ?? 0)
417
+
418
+ return {items: ttl, head: Math.max(ttl, floor)}
419
+ }
420
+
421
+ // Remembers a loaded item's `ttl` for its stream. The memory is bounded: a full one is cleared,
422
+ // which costs at most a conflict and a retry, whose load observes the `ttl` again.
423
+ #observeTtl(entityId: string, ttl: number | undefined): void {
424
+ if (ttl === undefined || ttl <= 0) {
425
+ return
426
+ }
427
+
428
+ const current = this.#ttlFloors.get(entityId)
429
+ if (current === undefined && this.#ttlFloors.size >= MAX_TTL_FLOORS) {
430
+ this.#ttlFloors.clear()
431
+ }
432
+ if (current === undefined || ttl > current) {
433
+ this.#ttlFloors.set(entityId, ttl)
434
+ }
435
+ }
436
+
437
+ #decodeEvents(items: Item[]): SerializedEvent[] {
438
+ const events: SerializedEvent[] = []
439
+ for (const item of items) {
440
+ const event = eventFromItem(item)
441
+ if (event.sequence >= 1n) {
442
+ this.#observeTtl(event.entityId, ttlFromItem(item))
443
+ events.push(event)
444
+ }
445
+ }
446
+
447
+ return events
448
+ }
449
+
450
+ // The manifest version the next write checks, validating it first when this writer hasn't.
451
+ #markerVersion(request: RequestOptions): Promise<bigint> {
452
+ return this.#exclusive(async () => {
453
+ const version = await this.#validate(true, request)
454
+ if (version === null) {
455
+ throw new RangeError('a declaring validation always caches a version')
456
+ }
457
+
458
+ return version
459
+ })
460
+ }
461
+
462
+ // Reads and validates the manifest, unless this writer cached a validation. A store that doesn't
463
+ // declare stream-heads is refused as uninitialized, unless the writer initializes lazily: then
464
+ // one that holds data is refused as unmigrated, and an empty one is declared when `declare` is
465
+ // set. Resolves to the cached version, or null when nothing was cached. The caller holds #lock.
466
+ async #validate(declare: boolean, request: RequestOptions): Promise<bigint | null> {
467
+ if (this.#validated !== null) {
468
+ return this.#validated
469
+ }
470
+
471
+ let manifest = await this.#readManifest(request)
472
+ checkSupported(manifest)
473
+ if (!declaresStreamHeads(manifest)) {
474
+ if (!this.#lazy) {
475
+ throw new StoreNotInitializedError()
476
+ }
477
+ if (!(await this.#storeIsEmpty(request))) {
478
+ throw new StreamHeadsNotMigratedError()
479
+ }
480
+ if (!declare) {
481
+ return null
482
+ }
483
+
484
+ // Another writer may have declared a feature this package doesn't model.
485
+ const declared = await this.#declareOnEmptyStore(request)
486
+ checkSupported(declared.manifest)
487
+ manifest = declared.manifest
488
+ }
489
+
490
+ this.#validated = manifest!.version
491
+
492
+ return this.#validated
493
+ }
494
+
495
+ // A failed marker check: the manifest changed under this writer. It validates the new one at
496
+ // once, so an unsupported feature refuses the write, and anything else is retryable.
497
+ async #markerCheckFailed(cause: unknown, request: RequestOptions): Promise<EvtError> {
498
+ try {
499
+ // Dropped under the lock, so a validation already running can't cache the old version again.
500
+ await this.#exclusive(() => {
501
+ this.#validated = null
502
+
503
+ return this.#validate(false, request)
504
+ })
505
+ } catch (error) {
506
+ if (error instanceof EvtError) {
507
+ return error
508
+ }
509
+
510
+ throw error
511
+ }
512
+
513
+ return new FeatureManifestChangedError({cause})
514
+ }
515
+
516
+ async #classifyCommit(commit: Commit, error: unknown, request: RequestOptions): Promise<unknown> {
517
+ const codes = cancellationCodes(error)
518
+ if (codes === null) {
519
+ return error
520
+ }
521
+
522
+ const failed = codes.indexOf(CONDITION_FAILED)
523
+ if (failed === -1) {
524
+ if (codes.includes('TransactionConflict')) {
525
+ return new ConflictError(
526
+ commit.entityId,
527
+ 'transaction conflict: another writer was committing at the same time',
528
+ )
529
+ }
530
+ if (codes.some((code) => THROTTLED.has(code))) {
531
+ return new TransientError('the commit was throttled', {cause: error})
532
+ }
533
+
534
+ return error
535
+ }
536
+
537
+ // The control checks outrank every stream failure: the marker, then the rebuild lock.
538
+ if (failed === 0) {
539
+ return this.#markerCheckFailed(error, request)
540
+ }
541
+ if (codes[1] === CONDITION_FAILED) {
542
+ return new RebuildInProgressError()
543
+ }
544
+
545
+ // The stream's head update, then its event puts and its snapshot put.
546
+ const head = 2
547
+ const eventsEnd = head + 1 + commit.events.length
548
+ const eventFailed = codes.slice(head + 1, eventsEnd).includes(CONDITION_FAILED)
549
+ const {entityId} = commit
550
+ const first = commit.events[0]!.sequence
551
+ const retained = this.#retention.has(commit.events[0]!.entityType)
552
+
553
+ if (codes[head] === CONDITION_FAILED) {
554
+ const verdict = await this.#probe(entityId, retained, request)
555
+ if (verdict.drift) {
556
+ return verdict.drift
557
+ }
558
+ if (verdict.healthy && first > 1n && verdict.stored < first - 1n) {
559
+ return new SequenceGapError(entityId, verdict.stored, first)
560
+ }
561
+ } else if (codes[head] === 'None' && eventFailed) {
562
+ // The head update passed and an event put failed: an event sits above the head. With
563
+ // retention, TTL can delete the head before an expired event, so a create passes its head
564
+ // condition and fails on the leftover item: a conflict until TTL deletes the rest.
565
+ const verdict = await this.#probe(entityId, retained, request)
566
+ if (!retained || !(await this.#remainingItemsExpired(entityId, request))) {
567
+ return verdict.drift ?? drift(entityId, verdict.head, verdict.derived)
568
+ }
569
+ }
570
+
571
+ return new ConflictError(
572
+ entityId,
573
+ 'conditional check failed: the sequence or snapshot changed since it was read',
574
+ )
575
+ }
576
+
577
+ // Classifies a stream whose head update failed, as the failure-path probe does: a stored head
578
+ // below the head its log derives is drift, an equal one is healthy, and one above it is either a
579
+ // writer that committed in between (derive again) or drift. With retention, TTL deletes items in
580
+ // no order, so a head above its log, or a missing head over items that have all expired, is a
581
+ // conflict that heals once TTL deletes the rest.
582
+ async #probe(entityId: string, retained: boolean, request: RequestOptions): Promise<Verdict> {
583
+ const derived = await this.#deriveHead(entityId, request)
584
+ const head = (await this.#readHead(entityId, request))?.head ?? null
585
+ const stored = head ?? 0n
586
+ if (stored < derived) {
587
+ if (retained && head === null && (await this.#remainingItemsExpired(entityId, request))) {
588
+ return {healthy: false, drift: null, stored, head, derived}
589
+ }
590
+
591
+ return {healthy: false, drift: drift(entityId, head, derived), stored, head, derived}
592
+ }
593
+ if (stored === derived) {
594
+ return {healthy: true, drift: null, stored, head, derived}
595
+ }
596
+ if (retained) {
597
+ // The head outlived part of the log it counted: TTL deleted the tail first.
598
+ return {healthy: false, drift: null, stored, head, derived}
599
+ }
600
+
601
+ const second = await this.#deriveHead(entityId, request)
602
+ const drifted = second < stored ? drift(entityId, head, second) : null
603
+
604
+ return {healthy: false, drift: drifted, stored, head, derived: second}
605
+ }
606
+
607
+ // A stream's head as its log derives it, with strongly consistent reads: the higher of its
608
+ // snapshot's event sequence and its highest event sequence.
609
+ async #deriveHead(entityId: string, request: RequestOptions): Promise<bigint> {
610
+ const snapshot = await this.#client.getItem(
611
+ {
612
+ TableName: this.#table,
613
+ Key: controlKey(entityId),
614
+ ConsistentRead: true,
615
+ ProjectionExpression: 'eventSeq',
616
+ },
617
+ request,
618
+ )
619
+ const eventSeq = (snapshot.Item as Item | undefined)?.['eventSeq']
620
+ let derived = eventSeq && 'N' in eventSeq ? BigInt(eventSeq.N) : 0n
621
+
622
+ const latest = await this.#client.query(
623
+ {
624
+ TableName: this.#table,
625
+ KeyConditionExpression: 'pk = :pk AND sk > :sk',
626
+ ExpressionAttributeValues: {':pk': {S: entityId}, ':sk': {N: '0'}},
627
+ ConsistentRead: true,
628
+ ProjectionExpression: 'sk',
629
+ ScanIndexForward: false,
630
+ Limit: 1,
631
+ },
632
+ request,
633
+ )
634
+ const [item] = (latest.Items ?? []) as Item[]
635
+ const sequence = item && sortKey(item)
636
+ if (sequence !== undefined && sequence > derived) {
637
+ derived = sequence
638
+ }
639
+
640
+ return derived
641
+ }
642
+
643
+ // Whether every item still stored for the stream, its snapshot and its events, has a `ttl` that
644
+ // has passed, so TTL will delete it. An item without a `ttl` never expires.
645
+ async #remainingItemsExpired(entityId: string, request: RequestOptions): Promise<boolean> {
646
+ const now = Math.floor(this.#clock() / 1000)
647
+ const expired = (item: Item): boolean => {
648
+ const ttl = ttlFromItem(item)
649
+
650
+ return ttl !== undefined && ttl <= now
651
+ }
652
+
653
+ const snapshot = await this.#client.getItem(
654
+ {
655
+ TableName: this.#table,
656
+ Key: controlKey(entityId),
657
+ ConsistentRead: true,
658
+ // The key too, so a snapshot without a ttl doesn't read back empty, as an absent one does.
659
+ ProjectionExpression: 'sk, #ttl',
660
+ ExpressionAttributeNames: {'#ttl': 'ttl'},
661
+ },
662
+ request,
663
+ )
664
+ const item = snapshot.Item as Item | undefined
665
+ if (item && Object.keys(item).length > 0 && !expired(item)) {
666
+ return false
667
+ }
668
+
669
+ let start: Item | undefined
670
+ do {
671
+ const page = await this.#client.query(
672
+ {
673
+ TableName: this.#table,
674
+ KeyConditionExpression: 'pk = :pk AND sk > :sk',
675
+ ExpressionAttributeNames: {'#ttl': 'ttl'},
676
+ ExpressionAttributeValues: {':pk': {S: entityId}, ':sk': {N: '0'}},
677
+ ConsistentRead: true,
678
+ ProjectionExpression: 'sk, #ttl',
679
+ ...(start && {ExclusiveStartKey: start}),
680
+ },
681
+ request,
682
+ )
683
+ if (((page.Items ?? []) as Item[]).some((event) => !expired(event))) {
684
+ return false
685
+ }
686
+ start = page.LastEvaluatedKey as Item | undefined
687
+ } while (start !== undefined)
688
+
689
+ return true
690
+ }
691
+
692
+ // The stored head and its `ttl`, or null when the stream has no head item.
693
+ async #readHead(
694
+ entityId: string,
695
+ request: RequestOptions,
696
+ ): Promise<{head: bigint; ttl: number | undefined} | null> {
697
+ const output = await this.#client.getItem(
698
+ {TableName: this.#table, Key: headKey(entityId), ConsistentRead: true},
699
+ request,
700
+ )
701
+ const item = output.Item as Item | undefined
702
+ if (!item || Object.keys(item).length === 0) {
703
+ return null
704
+ }
705
+
706
+ // A head item without its attribute reads as head 0, as Go reads it, so it classifies as drift.
707
+ const head =
708
+ item['head'] === undefined ? {head: 0n, ttl: ttlFromItem(item)} : headFromItem(item)
709
+ this.#observeTtl(entityId, head.ttl)
710
+
711
+ return head
712
+ }
713
+
714
+ async #readSnapshot(
715
+ entityId: string,
716
+ consistent: boolean,
717
+ request: RequestOptions,
718
+ ): Promise<SerializedSnapshot | null> {
719
+ const output = await this.#client.getItem(
720
+ {TableName: this.#table, Key: controlKey(entityId), ConsistentRead: consistent},
721
+ request,
722
+ )
723
+ const item = output.Item as Item | undefined
724
+ if (!item || Object.keys(item).length === 0) {
725
+ return null
726
+ }
727
+
728
+ this.#observeTtl(entityId, ttlFromItem(item))
729
+
730
+ return snapshotFromItem(item)
731
+ }
732
+
733
+ // The marker row, with a strongly consistent read, or null when the store has none.
734
+ async #readManifest(request: RequestOptions): Promise<FeatureManifest | null> {
735
+ const output = await this.#client.getItem(
736
+ {TableName: this.#table, Key: controlKey(FEATURES_PK), ConsistentRead: true},
737
+ request,
738
+ )
739
+ const item = output.Item as Item | undefined
740
+
741
+ return item && Object.keys(item).length > 0 ? manifestFromItem(item) : null
742
+ }
743
+
744
+ // Applies a change to the manifest in one conditional write that bumps its version by one, and
745
+ // retries against a racing declaration. A change that reports false needs no write: the
746
+ // manifest already says what it would.
747
+ async #declare(
748
+ change: (draft: Draft) => boolean,
749
+ request: RequestOptions,
750
+ ): Promise<{manifest: FeatureManifest; wrote: boolean}> {
751
+ for (let attempt = 0; ; attempt++) {
752
+ const stored = await this.#readManifest(request)
753
+ const draft: Draft = {
754
+ features: new Map(stored?.features),
755
+ historyDependent: [...(stored?.historyDependent ?? [])],
756
+ retention: [...(stored?.retention ?? [])],
757
+ }
758
+ if (!change(draft)) {
759
+ return {manifest: stored ?? {...draft, version: 0n}, wrote: false}
760
+ }
761
+
762
+ const manifest: FeatureManifest = {...draft, version: (stored?.version ?? 0n) + 1n}
763
+ const condition =
764
+ stored === null
765
+ ? {ConditionExpression: 'attribute_not_exists(pk)'}
766
+ : {
767
+ ConditionExpression: 'version = :observed',
768
+ ExpressionAttributeValues: {':observed': {N: stored.version.toString()}},
769
+ }
770
+ try {
771
+ await this.#client.putItem(
772
+ {TableName: this.#table, Item: markerItem(manifest), ...condition},
773
+ request,
774
+ )
775
+
776
+ return {manifest, wrote: true}
777
+ } catch (error) {
778
+ if (!isConditionalCheckFailed(error) || attempt >= MAX_DECLARE_ATTEMPTS) {
779
+ throw error
780
+ }
781
+ }
782
+ }
783
+ }
784
+
785
+ // Declares stream-heads on a store that held no data when it was scanned, then closes the window
786
+ // since: the declaration commits first, so every marker-reading writer sees it, and only then is
787
+ // the table scanned for a stream without a head item, which retracts it. When another
788
+ // initializer declared first, its manifest is the one reported, as it stands now.
789
+ async #declareOnEmptyStore(
790
+ request: RequestOptions,
791
+ ): Promise<{manifest: FeatureManifest; wrote: boolean}> {
792
+ const declared = await this.#declare((draft) => {
793
+ if (draft.features.get(STREAM_HEADS_FEATURE) === STREAM_HEADS_VERSION) {
794
+ return false
795
+ }
796
+
797
+ draft.features.set(STREAM_HEADS_FEATURE, STREAM_HEADS_VERSION)
798
+
799
+ return true
800
+ }, request)
801
+
802
+ if (!declared.wrote) {
803
+ // The winner ran its own verification, which may have retracted its declaration.
804
+ const current = await this.#readManifest(request)
805
+ if (!declaresStreamHeads(current)) {
806
+ throw new StreamHeadsNotMigratedError()
807
+ }
808
+
809
+ return {manifest: current!, wrote: false}
810
+ }
811
+
812
+ if (await this.#hasHeadlessStream(request)) {
813
+ await this.#declare((draft) => draft.features.delete(STREAM_HEADS_FEATURE), request)
814
+ throw new StreamHeadsNotMigratedError()
815
+ }
816
+
817
+ return declared
818
+ }
819
+
820
+ // Whether the table holds no item outside the reserved namespaces, by a strongly consistent scan
821
+ // that stops at the first one it finds.
822
+ async #storeIsEmpty(request: RequestOptions): Promise<boolean> {
823
+ for await (const items of this.#scan('pk', request)) {
824
+ if (items.some((item) => !reservedItem(item))) {
825
+ return false
826
+ }
827
+ }
828
+
829
+ return true
830
+ }
831
+
832
+ // Whether any stream has items but no head item. A scan isn't atomic across items, so a stream
833
+ // that looks headless is confirmed by reading its head item.
834
+ async #hasHeadlessStream(request: RequestOptions): Promise<boolean> {
835
+ const streams = new Set<string>()
836
+ const headed = new Set<string>()
837
+ for await (const items of this.#scan('pk, sk', request)) {
838
+ for (const item of items) {
839
+ const pk = item['pk']
840
+ if (pk === undefined || !('S' in pk) || isReserved(pk.S)) {
841
+ continue
842
+ }
843
+
844
+ streams.add(pk.S)
845
+ if (sortKey(item) === HEAD_SORT_KEY) {
846
+ headed.add(pk.S)
847
+ }
848
+ }
849
+ }
850
+
851
+ for (const entityId of streams) {
852
+ if (!headed.has(entityId) && (await this.#readHead(entityId, request)) === null) {
853
+ return true
854
+ }
855
+ }
856
+
857
+ return false
858
+ }
859
+
860
+ async *#scan(projection: string, request: RequestOptions): AsyncGenerator<Item[]> {
861
+ let start: Item | undefined
862
+ do {
863
+ const page: Page = await this.#client.scan(
864
+ {
865
+ TableName: this.#table,
866
+ ConsistentRead: true,
867
+ ProjectionExpression: projection,
868
+ Limit: SCAN_PROBE_PAGE_SIZE,
869
+ ...(start && {ExclusiveStartKey: start}),
870
+ },
871
+ request,
872
+ )
873
+ yield (page.Items ?? []) as Item[]
874
+ start = page.LastEvaluatedKey as Item | undefined
875
+ } while (start !== undefined)
876
+ }
877
+
878
+ // The pages of a stream's events after `after`, in either order.
879
+ async *#query(
880
+ entityId: string,
881
+ after: bigint,
882
+ ascending: boolean,
883
+ limit: number | undefined,
884
+ request: RequestOptions,
885
+ ): AsyncGenerator<Item[]> {
886
+ let start: Item | undefined
887
+ do {
888
+ const page = await this.#client.query(
889
+ {
890
+ TableName: this.#table,
891
+ KeyConditionExpression: 'pk = :pk AND sk > :sk',
892
+ ExpressionAttributeValues: {':pk': {S: entityId}, ':sk': {N: after.toString()}},
893
+ ConsistentRead: this.#consistentRead,
894
+ ScanIndexForward: ascending,
895
+ ...(limit !== undefined && {Limit: limit}),
896
+ ...(start && {ExclusiveStartKey: start}),
897
+ },
898
+ request,
899
+ )
900
+ yield (page.Items ?? []) as Item[]
901
+ start = page.LastEvaluatedKey as Item | undefined
902
+ } while (start !== undefined)
903
+ }
904
+
905
+ // The keys of the stream's events 1 to `through`, read consistently.
906
+ async *#rangeKeys(
907
+ entityId: string,
908
+ through: bigint,
909
+ request: RequestOptions,
910
+ ): AsyncGenerator<Item[]> {
911
+ let start: Item | undefined
912
+ do {
913
+ const page = await this.#client.query(
914
+ {
915
+ TableName: this.#table,
916
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :lo AND :hi',
917
+ ExpressionAttributeValues: {
918
+ ':pk': {S: entityId},
919
+ ':lo': {N: '1'},
920
+ ':hi': {N: through.toString()},
921
+ },
922
+ ConsistentRead: true,
923
+ ProjectionExpression: 'pk, sk',
924
+ ...(start && {ExclusiveStartKey: start}),
925
+ },
926
+ request,
927
+ )
928
+ const items = (page.Items ?? []) as Item[]
929
+ yield items.flatMap(({pk, sk}) => (pk && sk ? [{pk, sk}] : []))
930
+ start = page.LastEvaluatedKey as Item | undefined
931
+ } while (start !== undefined)
932
+ }
933
+
934
+ // Runs `task` after every task queued before it, so validation reads and declares once.
935
+ #exclusive<T>(task: () => Promise<T>): Promise<T> {
936
+ const result = this.#lock.then(task, task)
937
+ this.#lock = result.catch(() => undefined)
938
+
939
+ return result
940
+ }
941
+ }
942
+
943
+ const CONDITION_FAILED = 'ConditionalCheckFailed'
944
+
945
+ // The cancellation reasons of a transaction DynamoDB throttled, which Go and Rust retry too.
946
+ const THROTTLED = new Set(['ThrottlingError', 'ProvisionedThroughputExceeded'])
947
+
948
+ interface Draft {
949
+ features: Map<string, number>
950
+ historyDependent: string[]
951
+ retention: string[]
952
+ }
953
+
954
+ interface Verdict {
955
+ healthy: boolean
956
+ drift: StreamHeadDriftError | null
957
+ /** The stored head, 0 when the stream has none. */
958
+ stored: bigint
959
+ head: bigint | null
960
+ derived: bigint
961
+ }
962
+
963
+ function drift(entityId: string, head: bigint | null, derived: bigint): StreamHeadDriftError {
964
+ return new StreamHeadDriftError(entityId, head, derived)
965
+ }
966
+
967
+ // The retention option as a map of the entity types with a positive retention, each checked to be
968
+ // whole milliseconds.
969
+ function retentionMap(
970
+ retention: Readonly<Record<string, number>> | ReadonlyMap<string, number> | undefined,
971
+ ): ReadonlyMap<string, number> {
972
+ const entries = isMap(retention) ? [...retention.entries()] : Object.entries(retention ?? {})
973
+ const retained = new Map<string, number>()
974
+ for (const [entityType, milliseconds] of entries) {
975
+ if (!Number.isSafeInteger(milliseconds) || milliseconds < 0) {
976
+ throw new EvtError(
977
+ 'invalidArgument',
978
+ `the retention of ${entityType} must be whole, non-negative milliseconds: ${milliseconds}`,
979
+ )
980
+ }
981
+ if (milliseconds > 0) {
982
+ retained.set(entityType, milliseconds)
983
+ }
984
+ }
985
+
986
+ return retained
987
+ }
988
+
989
+ // A map by its methods, so one from another realm counts too.
990
+ function isMap<K, V>(value: object | undefined): value is ReadonlyMap<K, V> {
991
+ const map = value as Partial<ReadonlyMap<K, V>> | undefined
992
+
993
+ return typeof map?.get === 'function' && typeof map.entries === 'function'
994
+ }
995
+
996
+ function reservedItem(item: Item): boolean {
997
+ const pk = item['pk']
998
+
999
+ return pk !== undefined && 'S' in pk && isReserved(pk.S)
1000
+ }
1001
+
1002
+ function requestOptions(options: CallOptions | undefined): RequestOptions {
1003
+ return options?.signal ? {abortSignal: options.signal} : {}
1004
+ }
1005
+
1006
+ // The reason codes of a cancelled transaction, one per action, or null for any other error.
1007
+ function cancellationCodes(error: unknown): string[] | null {
1008
+ if (!isNamed(error, 'TransactionCanceledException')) {
1009
+ return null
1010
+ }
1011
+
1012
+ const reasons = (error as {CancellationReasons?: unknown}).CancellationReasons
1013
+ if (!Array.isArray(reasons)) {
1014
+ return null
1015
+ }
1016
+
1017
+ return reasons.map((reason) => {
1018
+ const code = (reason as {Code?: unknown} | null)?.Code
1019
+
1020
+ return typeof code === 'string' ? code : ''
1021
+ })
1022
+ }
1023
+
1024
+ function isConditionalCheckFailed(error: unknown): boolean {
1025
+ return isNamed(error, 'ConditionalCheckFailedException')
1026
+ }
1027
+
1028
+ function isNamed(error: unknown, name: string): boolean {
1029
+ return typeof error === 'object' && error !== null && (error as {name?: unknown}).name === name
1030
+ }