@photon-grove/evt 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +164 -1
  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/client.d.ts +83 -0
  20. package/dist/dynamodb/client.d.ts.map +1 -0
  21. package/dist/dynamodb/client.js +6 -0
  22. package/dist/dynamodb/client.js.map +1 -0
  23. package/dist/dynamodb/head.d.ts +34 -0
  24. package/dist/dynamodb/head.d.ts.map +1 -0
  25. package/dist/dynamodb/head.js +62 -0
  26. package/dist/dynamodb/head.js.map +1 -0
  27. package/dist/dynamodb/index.d.ts +6 -0
  28. package/dist/dynamodb/index.d.ts.map +1 -0
  29. package/dist/dynamodb/index.js +8 -0
  30. package/dist/dynamodb/index.js.map +1 -0
  31. package/dist/dynamodb/item.d.ts +70 -0
  32. package/dist/dynamodb/item.d.ts.map +1 -0
  33. package/dist/dynamodb/item.js +264 -0
  34. package/dist/dynamodb/item.js.map +1 -0
  35. package/dist/dynamodb/marker.d.ts +11 -0
  36. package/dist/dynamodb/marker.d.ts.map +1 -0
  37. package/dist/dynamodb/marker.js +56 -0
  38. package/dist/dynamodb/marker.js.map +1 -0
  39. package/dist/dynamodb/repository.d.ts +61 -0
  40. package/dist/dynamodb/repository.d.ts.map +1 -0
  41. package/dist/dynamodb/repository.js +731 -0
  42. package/dist/dynamodb/repository.js.map +1 -0
  43. package/dist/dynamodb/requests.d.ts +65 -0
  44. package/dist/dynamodb/requests.d.ts.map +1 -0
  45. package/dist/dynamodb/requests.js +179 -0
  46. package/dist/dynamodb/requests.js.map +1 -0
  47. package/dist/envelope.d.ts +15 -0
  48. package/dist/envelope.d.ts.map +1 -0
  49. package/dist/envelope.js +59 -0
  50. package/dist/envelope.js.map +1 -0
  51. package/dist/errors.d.ts +205 -0
  52. package/dist/errors.d.ts.map +1 -0
  53. package/dist/errors.js +240 -0
  54. package/dist/errors.js.map +1 -0
  55. package/dist/event.d.ts +47 -0
  56. package/dist/event.d.ts.map +1 -0
  57. package/dist/event.js +64 -0
  58. package/dist/event.js.map +1 -0
  59. package/dist/features.d.ts +55 -0
  60. package/dist/features.d.ts.map +1 -0
  61. package/dist/features.js +126 -0
  62. package/dist/features.js.map +1 -0
  63. package/dist/fields.d.ts +31 -0
  64. package/dist/fields.d.ts.map +1 -0
  65. package/dist/fields.js +105 -0
  66. package/dist/fields.js.map +1 -0
  67. package/dist/index.d.ts +15 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +17 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/json.d.ts +47 -0
  72. package/dist/json.d.ts.map +1 -0
  73. package/dist/json.js +458 -0
  74. package/dist/json.js.map +1 -0
  75. package/dist/memory/index.d.ts +2 -0
  76. package/dist/memory/index.d.ts.map +1 -0
  77. package/dist/memory/index.js +3 -0
  78. package/dist/memory/index.js.map +1 -0
  79. package/dist/memory/repository.d.ts +70 -0
  80. package/dist/memory/repository.d.ts.map +1 -0
  81. package/dist/memory/repository.js +308 -0
  82. package/dist/memory/repository.js.map +1 -0
  83. package/dist/metadata.d.ts +47 -0
  84. package/dist/metadata.d.ts.map +1 -0
  85. package/dist/metadata.js +75 -0
  86. package/dist/metadata.js.map +1 -0
  87. package/dist/repository.d.ts +131 -0
  88. package/dist/repository.d.ts.map +1 -0
  89. package/dist/repository.js +130 -0
  90. package/dist/repository.js.map +1 -0
  91. package/dist/retry.d.ts +25 -0
  92. package/dist/retry.d.ts.map +1 -0
  93. package/dist/retry.js +50 -0
  94. package/dist/retry.js.map +1 -0
  95. package/dist/snapshots.d.ts +21 -0
  96. package/dist/snapshots.d.ts.map +1 -0
  97. package/dist/snapshots.js +35 -0
  98. package/dist/snapshots.js.map +1 -0
  99. package/dist/store.d.ts +103 -0
  100. package/dist/store.d.ts.map +1 -0
  101. package/dist/store.js +246 -0
  102. package/dist/store.js.map +1 -0
  103. package/dist/testing/equal.d.ts +22 -0
  104. package/dist/testing/equal.d.ts.map +1 -0
  105. package/dist/testing/equal.js +121 -0
  106. package/dist/testing/equal.js.map +1 -0
  107. package/dist/testing/index.d.ts +3 -0
  108. package/dist/testing/index.d.ts.map +1 -0
  109. package/dist/testing/index.js +5 -0
  110. package/dist/testing/index.js.map +1 -0
  111. package/dist/testing/scenario.d.ts +136 -0
  112. package/dist/testing/scenario.d.ts.map +1 -0
  113. package/dist/testing/scenario.js +307 -0
  114. package/dist/testing/scenario.js.map +1 -0
  115. package/dist/utf8.d.ts +17 -0
  116. package/dist/utf8.d.ts.map +1 -0
  117. package/dist/utf8.js +82 -0
  118. package/dist/utf8.js.map +1 -0
  119. package/package.json +58 -2
  120. package/src/aggregate.ts +196 -0
  121. package/src/base64.ts +80 -0
  122. package/src/codec.ts +139 -0
  123. package/src/commit-id.ts +50 -0
  124. package/src/dynamodb/client.ts +102 -0
  125. package/src/dynamodb/head.ts +93 -0
  126. package/src/dynamodb/index.ts +45 -0
  127. package/src/dynamodb/item.ts +334 -0
  128. package/src/dynamodb/marker.ts +64 -0
  129. package/src/dynamodb/repository.ts +1030 -0
  130. package/src/dynamodb/requests.ts +256 -0
  131. package/src/envelope.ts +67 -0
  132. package/src/errors.ts +365 -0
  133. package/src/event.ts +117 -0
  134. package/src/features.ts +175 -0
  135. package/src/fields.ts +134 -0
  136. package/src/globals.d.ts +39 -0
  137. package/src/index.ts +98 -0
  138. package/src/json.ts +546 -0
  139. package/src/memory/index.ts +11 -0
  140. package/src/memory/repository.ts +463 -0
  141. package/src/metadata.ts +120 -0
  142. package/src/repository.ts +275 -0
  143. package/src/retry.ts +74 -0
  144. package/src/snapshots.ts +42 -0
  145. package/src/store.ts +377 -0
  146. package/src/testing/equal.ts +146 -0
  147. package/src/testing/index.ts +14 -0
  148. package/src/testing/scenario.ts +406 -0
  149. package/src/utf8.ts +92 -0
@@ -0,0 +1,275 @@
1
+ // The storage contract every backend implements, and the commit rules every backend applies the
2
+ // same way: validation, the reserved namespaces, and the transaction budget.
3
+
4
+ import {headActionSize, headUpdate} from './dynamodb/head.js'
5
+ import {eventItem, itemSize, snapshotItem} from './dynamodb/item.js'
6
+ import {FEATURES_PK, REBUILD_LOCK_PK} from './dynamodb/marker.js'
7
+ import {CommitTooLargeError, EvtError, ReservedEntityIdError} from './errors.js'
8
+ import {type SerializedEvent, type SerializedSnapshot, eventId} from './event.js'
9
+ import {MAX_COMMIT_ITEM_BYTES} from './snapshots.js'
10
+
11
+ /** A snapshot to write with a commit. Its event sequence is the commit's last event. */
12
+ export interface NewSnapshot {
13
+ entityType: string
14
+ /** The stored snapshot's sequence plus one, or 1 for the stream's first snapshot. */
15
+ sequence: bigint
16
+ /** The aggregate state after the commit's events. */
17
+ payload: Uint8Array
18
+ }
19
+
20
+ /**
21
+ * Everything a repository writes atomically for one command. A commit with no events is a no-op,
22
+ * and its snapshot is ignored.
23
+ */
24
+ export interface Commit {
25
+ /** The stream the events are appended to. */
26
+ entityId: string
27
+ /** The new events, in ascending sequence order. */
28
+ events: SerializedEvent[]
29
+ /** The snapshot to write, when the commit reaches a snapshot boundary. */
30
+ snapshot?: NewSnapshot | null | undefined
31
+ }
32
+
33
+ /** Options every repository call takes. */
34
+ export interface CallOptions {
35
+ /**
36
+ * Aborts the call. An abort before the call starts rejects with the signal's reason and writes
37
+ * nothing. An abort while a live backend's write is in flight rejects too, but the write may
38
+ * have been applied: load the stream again before deciding it wasn't.
39
+ */
40
+ signal?: AbortSignal | undefined
41
+ }
42
+
43
+ /**
44
+ * An append-only event log with one optional snapshot and one head per stream (ADR 0007). Every
45
+ * backend must:
46
+ *
47
+ * - refuse a commit built wrong ({@link validateCommit}) with `invalidCommit`;
48
+ * - keep a head per stream, the highest event sequence ever committed to it, which a commit
49
+ * raises to its last event and compaction never touches;
50
+ * - refuse a commit whose first event doesn't follow the stored head with a
51
+ * {@link ConflictError}, or with a {@link SequenceGapError} when it skips past the head;
52
+ * - report a stream whose head disagrees with its events or snapshot as a
53
+ * {@link StreamHeadDriftError}, ranked above a gap and any other conflict;
54
+ * - refuse a commit's snapshot whose sequence isn't the stored snapshot's plus one (1 with none
55
+ * stored) with a {@link ConflictError};
56
+ * - write a commit's events, head and snapshot atomically, or nothing;
57
+ * - refuse an entity ID in a reserved namespace on every write path, before any request;
58
+ * - refuse every write (a commit, a snapshot write, compaction) to a store whose feature manifest
59
+ * declares a pair this package doesn't model, with an {@link UnsupportedFeatureError}, and to
60
+ * one that doesn't declare stream-heads: with a {@link StreamHeadsNotMigratedError} when it
61
+ * holds data, and otherwise either by declaring stream-heads on the first commit or snapshot
62
+ * write or with a {@link StoreNotInitializedError}, as the backend initializes;
63
+ * - refuse a commit, but not a snapshot write or compaction, while a projection rebuild holds the
64
+ * store, with a {@link RebuildInProgressError}, after the manifest's refusals and before every
65
+ * stream's;
66
+ * - refuse compaction of an entity whose type the manifest declares history-dependent, with a
67
+ * {@link HistoryDependentProjectionError};
68
+ * - return events in ascending sequence order, and `latest` in descending order.
69
+ */
70
+ export interface Repository {
71
+ /** Appends a commit's events, and its snapshot if it has one, atomically. */
72
+ commit(commit: Commit, options?: CallOptions): Promise<void>
73
+
74
+ /** Every stored event of the stream, ascending. */
75
+ events(entityId: string, options?: CallOptions): Promise<SerializedEvent[]>
76
+
77
+ /** The stream's events after sequence `after`, ascending. */
78
+ eventsAfter(entityId: string, after: bigint, options?: CallOptions): Promise<SerializedEvent[]>
79
+
80
+ /**
81
+ * The stream's last `n` events, newest first, never the snapshot. `n` must be a safe integer of
82
+ * at least 1, or the call fails with `invalidArgument` before any request.
83
+ */
84
+ latest(entityId: string, n: number, options?: CallOptions): Promise<SerializedEvent[]>
85
+
86
+ /** The stream's snapshot, or null when it has none. */
87
+ snapshot(entityId: string, options?: CallOptions): Promise<SerializedSnapshot | null>
88
+
89
+ /**
90
+ * Writes a snapshot outside a commit, such as a catch-up snapshot. It never lowers the stored
91
+ * snapshot's event sequence: it replaces a snapshot at or below it and resolves `true`, or
92
+ * leaves a newer one in place and resolves `false`. The stream's head must cover the snapshot,
93
+ * or the write fails with a {@link StreamHeadDriftError}. A snapshot at event sequence 0 needs no
94
+ * head.
95
+ */
96
+ putSnapshot(snapshot: SerializedSnapshot, options?: CallOptions): Promise<boolean>
97
+
98
+ /**
99
+ * Deletes the stream's events with sequences 1 to `through`, but only when the stored snapshot
100
+ * covers them (its event sequence is at least `through`), and resolves to how many it deleted.
101
+ * It never deletes the snapshot or touches the head. A `through` below 1 deletes nothing.
102
+ */
103
+ compactBelow(entityId: string, through: bigint, options?: CallOptions): Promise<number>
104
+ }
105
+
106
+ /**
107
+ * A repository whose store can be initialized explicitly: its feature manifest declares
108
+ * stream-heads before any writer writes. A strict backend, such as DynamoDB by default, refuses
109
+ * every write until then. One whose writers initialize a fresh store on their first write, such
110
+ * as the in-memory one, needs it only where code treats every backend alike.
111
+ */
112
+ export interface StoreInitializer {
113
+ /**
114
+ * Declares stream-heads on a store that holds no data, and resolves `true` when this call
115
+ * declared it. A store that already declares it is left alone and resolves `false`. One that
116
+ * holds data without it is refused with a {@link StreamHeadsNotMigratedError}, and one that
117
+ * declares an unsupported feature with an {@link UnsupportedFeatureError}.
118
+ */
119
+ initialize(options?: CallOptions): Promise<boolean>
120
+ }
121
+
122
+ /** The most actions one commit may carry, DynamoDB's transaction limit. */
123
+ export const MAX_COMMIT_ACTIONS = 100
124
+
125
+ /** The most bytes one commit may carry, summed over its actions: 95% of DynamoDB's 4 MB. */
126
+ export const MAX_COMMIT_BYTES = 3_800_000
127
+
128
+ /** The prefixes of the entity IDs only the engine writes. */
129
+ export const RESERVED_PREFIXES: readonly string[] = Object.freeze([
130
+ 'SYSTEM#',
131
+ 'command-outcome#',
132
+ ])
133
+
134
+ /** Whether an entity ID is in a reserved namespace. */
135
+ export function isReserved(entityId: string): boolean {
136
+ return RESERVED_PREFIXES.some((prefix) => entityId.startsWith(prefix))
137
+ }
138
+
139
+ /** Refuses an entity ID in a reserved namespace, on a write path, before any request. */
140
+ export function checkNotReserved(entityId: string): void {
141
+ if (isReserved(entityId)) {
142
+ throw new ReservedEntityIdError(entityId)
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Checks that a commit's events can be stored as written: each belongs to the commit's entity and
148
+ * has the ID `{entityId}:{sequence}`, they share one entity type, the first sequence is at least
149
+ * 1, and the sequences are contiguous. A snapshot must have the events' entity type and a
150
+ * sequence of at least 1. A commit with no events is valid, and its snapshot isn't checked.
151
+ */
152
+ export function validateCommit(commit: Commit): void {
153
+ const [first] = commit.events
154
+ if (first === undefined) {
155
+ return
156
+ }
157
+
158
+ for (const event of commit.events) {
159
+ if (event.entityType !== first.entityType) {
160
+ throw invalidCommit(
161
+ `event ${event.id} has entity type ${event.entityType}, but the commit's first event ` +
162
+ `has ${first.entityType}`,
163
+ )
164
+ }
165
+ }
166
+
167
+ const snapshot = commit.snapshot
168
+ if (snapshot && snapshot.entityType !== first.entityType) {
169
+ throw invalidCommit(
170
+ `the snapshot has entity type ${snapshot.entityType}, but the events have ` +
171
+ first.entityType,
172
+ )
173
+ }
174
+ if (snapshot && snapshot.sequence < 1n) {
175
+ throw invalidCommit(
176
+ `snapshot sequence ${snapshot.sequence} is below 1; snapshot sequences start at 1`,
177
+ )
178
+ }
179
+
180
+ for (const event of commit.events) {
181
+ if (event.entityId !== commit.entityId) {
182
+ throw invalidCommit(
183
+ `event ${event.id} belongs to entity ${event.entityId}, not ${commit.entityId}`,
184
+ )
185
+ }
186
+
187
+ const id = eventId(event.entityId, event.sequence)
188
+ if (event.id !== id) {
189
+ throw invalidCommit(`event ${event.id} should have the ID ${id}`)
190
+ }
191
+ }
192
+
193
+ if (first.sequence < 1n) {
194
+ throw invalidCommit(`event sequence ${first.sequence} is below 1; sequences start at 1`)
195
+ }
196
+
197
+ for (let i = 1; i < commit.events.length; i++) {
198
+ const previous = commit.events[i - 1]!.sequence
199
+ const next = commit.events[i]!.sequence
200
+ if (next !== previous + 1n) {
201
+ throw invalidCommit(
202
+ `event sequence ${next} follows ${previous}; sequences must be contiguous`,
203
+ )
204
+ }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * The size of each action a commit's transaction carries, in order: the feature-marker and
210
+ * rebuild-lock checks, then the stream's head update, its event puts, and its snapshot put when
211
+ * it writes one. `ttl` gives the expiry an entity type's items carry, if it's retained. Every
212
+ * backend budgets a commit by these sizes, so an oversized commit fails the same way on each.
213
+ */
214
+ export function commitSizes(
215
+ commit: Commit,
216
+ ttl: (entityType: string) => number | undefined,
217
+ ): number[] {
218
+ // The control rows every commit checks: the feature marker and the rebuild lock.
219
+ const sizes = [controlCheckSize(FEATURES_PK), controlCheckSize(REBUILD_LOCK_PK)]
220
+ const first = commit.events[0]
221
+ const last = commit.events.at(-1)
222
+ if (first === undefined || last === undefined) {
223
+ return sizes
224
+ }
225
+
226
+ const head = headUpdate(commit.entityId, first.sequence, last.sequence, ttl(first.entityType))
227
+ sizes.push(headActionSize(head))
228
+ for (const event of commit.events) {
229
+ sizes.push(itemSize(eventItem(event, ttl(event.entityType))))
230
+ }
231
+
232
+ const snapshot = commit.snapshot
233
+ if (snapshot) {
234
+ const stored: SerializedSnapshot = {
235
+ entityType: snapshot.entityType,
236
+ entityId: commit.entityId,
237
+ sequence: snapshot.sequence,
238
+ eventSequence: last.sequence,
239
+ payload: snapshot.payload,
240
+ }
241
+ sizes.push(itemSize(snapshotItem(stored, ttl(snapshot.entityType))))
242
+ }
243
+
244
+ return sizes
245
+ }
246
+
247
+ /**
248
+ * Refuses a commit whose actions are over the budget: more than {@link MAX_COMMIT_ACTIONS}, more
249
+ * than {@link MAX_COMMIT_BYTES} in all, or one over {@link MAX_COMMIT_ITEM_BYTES}.
250
+ */
251
+ export function checkBudget(sizes: readonly number[]): void {
252
+ let bytes = 0
253
+ let largestItem = 0
254
+ for (const size of sizes) {
255
+ bytes += size
256
+ largestItem = Math.max(largestItem, size)
257
+ }
258
+
259
+ const tooLarge =
260
+ sizes.length > MAX_COMMIT_ACTIONS ||
261
+ bytes > MAX_COMMIT_BYTES ||
262
+ largestItem > MAX_COMMIT_ITEM_BYTES
263
+ if (tooLarge) {
264
+ throw new CommitTooLargeError(sizes.length, bytes, largestItem)
265
+ }
266
+ }
267
+
268
+ // A control check is sized by its key: `pk` and `sk = 0`.
269
+ function controlCheckSize(pk: string): number {
270
+ return itemSize({pk: {S: pk}, sk: {N: '0'}})
271
+ }
272
+
273
+ function invalidCommit(message: string): EvtError {
274
+ return new EvtError('invalidCommit', `invalid commit: ${message}`)
275
+ }
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
+ }