@photon-grove/evt 0.1.0 → 0.1.2

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 (96) hide show
  1. package/README.md +85 -9
  2. package/dist/aggregate.d.ts +26 -0
  3. package/dist/aggregate.d.ts.map +1 -1
  4. package/dist/aggregate.js.map +1 -1
  5. package/dist/dynamodb/client.d.ts +83 -0
  6. package/dist/dynamodb/client.d.ts.map +1 -0
  7. package/dist/dynamodb/client.js +6 -0
  8. package/dist/dynamodb/client.js.map +1 -0
  9. package/dist/dynamodb/index.d.ts +6 -1
  10. package/dist/dynamodb/index.d.ts.map +1 -1
  11. package/dist/dynamodb/index.js +8 -3
  12. package/dist/dynamodb/index.js.map +1 -1
  13. package/dist/dynamodb/item.d.ts +2 -0
  14. package/dist/dynamodb/item.d.ts.map +1 -1
  15. package/dist/dynamodb/item.js +5 -4
  16. package/dist/dynamodb/item.js.map +1 -1
  17. package/dist/dynamodb/marker.d.ts +11 -0
  18. package/dist/dynamodb/marker.d.ts.map +1 -0
  19. package/dist/dynamodb/marker.js +56 -0
  20. package/dist/dynamodb/marker.js.map +1 -0
  21. package/dist/dynamodb/repository.d.ts +61 -0
  22. package/dist/dynamodb/repository.d.ts.map +1 -0
  23. package/dist/dynamodb/repository.js +748 -0
  24. package/dist/dynamodb/repository.js.map +1 -0
  25. package/dist/dynamodb/requests.d.ts +68 -0
  26. package/dist/dynamodb/requests.d.ts.map +1 -0
  27. package/dist/dynamodb/requests.js +194 -0
  28. package/dist/dynamodb/requests.js.map +1 -0
  29. package/dist/dynamodb/views.d.ts +18 -0
  30. package/dist/dynamodb/views.d.ts.map +1 -0
  31. package/dist/dynamodb/views.js +56 -0
  32. package/dist/dynamodb/views.js.map +1 -0
  33. package/dist/errors.d.ts +109 -2
  34. package/dist/errors.d.ts.map +1 -1
  35. package/dist/errors.js +118 -2
  36. package/dist/errors.js.map +1 -1
  37. package/dist/features.d.ts +55 -0
  38. package/dist/features.d.ts.map +1 -0
  39. package/dist/features.js +126 -0
  40. package/dist/features.js.map +1 -0
  41. package/dist/index.d.ts +4 -3
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +2 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/memory/index.d.ts +1 -1
  46. package/dist/memory/index.d.ts.map +1 -1
  47. package/dist/memory/index.js +1 -1
  48. package/dist/memory/index.js.map +1 -1
  49. package/dist/memory/repository.d.ts +38 -2
  50. package/dist/memory/repository.d.ts.map +1 -1
  51. package/dist/memory/repository.js +95 -6
  52. package/dist/memory/repository.js.map +1 -1
  53. package/dist/postgres/client.d.ts +32 -0
  54. package/dist/postgres/client.d.ts.map +1 -0
  55. package/dist/postgres/client.js +4 -0
  56. package/dist/postgres/client.js.map +1 -0
  57. package/dist/postgres/index.d.ts +4 -0
  58. package/dist/postgres/index.d.ts.map +1 -0
  59. package/dist/postgres/index.js +6 -0
  60. package/dist/postgres/index.js.map +1 -0
  61. package/dist/postgres/repository.d.ts +55 -0
  62. package/dist/postgres/repository.d.ts.map +1 -0
  63. package/dist/postgres/repository.js +527 -0
  64. package/dist/postgres/repository.js.map +1 -0
  65. package/dist/postgres/schema.d.ts +81 -0
  66. package/dist/postgres/schema.d.ts.map +1 -0
  67. package/dist/postgres/schema.js +233 -0
  68. package/dist/postgres/schema.js.map +1 -0
  69. package/dist/repository.d.ts +56 -7
  70. package/dist/repository.d.ts.map +1 -1
  71. package/dist/repository.js +24 -9
  72. package/dist/repository.js.map +1 -1
  73. package/dist/store.d.ts +7 -5
  74. package/dist/store.d.ts.map +1 -1
  75. package/dist/store.js +34 -9
  76. package/dist/store.js.map +1 -1
  77. package/package.json +16 -3
  78. package/src/aggregate.ts +28 -0
  79. package/src/dynamodb/client.ts +102 -0
  80. package/src/dynamodb/index.ts +27 -2
  81. package/src/dynamodb/item.ts +6 -4
  82. package/src/dynamodb/marker.ts +64 -0
  83. package/src/dynamodb/repository.ts +1053 -0
  84. package/src/dynamodb/requests.ts +276 -0
  85. package/src/dynamodb/views.ts +67 -0
  86. package/src/errors.ts +176 -2
  87. package/src/features.ts +175 -0
  88. package/src/index.ts +28 -1
  89. package/src/memory/index.ts +9 -1
  90. package/src/memory/repository.ts +156 -6
  91. package/src/postgres/client.ts +37 -0
  92. package/src/postgres/index.ts +23 -0
  93. package/src/postgres/repository.ts +773 -0
  94. package/src/postgres/schema.ts +286 -0
  95. package/src/repository.ts +86 -13
  96. package/src/store.ts +61 -11
@@ -0,0 +1,276 @@
1
+ // The `TransactWriteItems` actions of a commit and of a snapshot write, built from the storage
2
+ // format alone (storage format, "Writes"). Every commit opens with the two control checks, the
3
+ // feature marker's and the rebuild lock's, then its projector operations, then writes the stream:
4
+ // its head update, its event puts, and its snapshot put when it has one.
5
+
6
+ import type {SerializedEvent, SerializedSnapshot} from '../event.js'
7
+ import {int64Argument, safeIntegerArgument} from '../fields.js'
8
+ import {EvtError} from '../errors.js'
9
+ import type {Commit} from '../repository.js'
10
+ import type {TransactItem} from './client.js'
11
+ import {type HeadAction, headUpdate} from './head.js'
12
+ import {HEAD_SORT_KEY, type Item, SNAPSHOT_SORT_KEY, eventItem, snapshotItem} from './item.js'
13
+ import {FEATURES_PK, REBUILD_LOCK_PK} from './marker.js'
14
+ import {DYNAMODB_BACKEND} from './views.js'
15
+
16
+ /** The marker check of a writer that validated the manifest at version `:v`. */
17
+ export const MARKER_CHECK_CONDITION = 'attribute_not_exists(pk) OR version = :v'
18
+
19
+ /** The rebuild-lock check: no lock row, so the rebuild generation is settled. */
20
+ export const REBUILD_LOCK_CONDITION = 'attribute_not_exists(pk)'
21
+
22
+ /** The condition of an event put: the sequence is new. */
23
+ export const EVENT_PUT_CONDITION = 'attribute_not_exists(sk)'
24
+
25
+ /** The condition of a commit's first snapshot of a stream. */
26
+ export const FIRST_SNAPSHOT_CONDITION = 'attribute_not_exists(seq)'
27
+
28
+ /** The condition of a commit's later snapshot: the stored one is the one before it. */
29
+ export const NEXT_SNAPSHOT_CONDITION = 'seq = :seq'
30
+
31
+ /** A snapshot write's head guard: the head covers the snapshot. */
32
+ export const SNAPSHOT_GUARD_CONDITION = 'head >= :new'
33
+
34
+ /** The guard of a snapshot of an empty stream (event sequence 0), which needs no head. */
35
+ export const SNAPSHOT_GUARD_EMPTY_CONDITION = 'attribute_not_exists(head) OR head >= :new'
36
+
37
+ /** The update expression of a retained stream's snapshot guard: it refreshes the head's `ttl`. */
38
+ export const SNAPSHOT_GUARD_RETAINED_UPDATE = 'SET #ttl = :t'
39
+
40
+ /** {@link SNAPSHOT_GUARD_CONDITION} with retention: the head's `ttl` is raised, never lowered. */
41
+ export const SNAPSHOT_GUARD_RETAINED_CONDITION =
42
+ 'head >= :new AND (attribute_not_exists(#ttl) OR #ttl <= :t)'
43
+
44
+ /** A snapshot write's put: it never lowers the stored snapshot's event sequence. */
45
+ export const SNAPSHOT_WRITE_CONDITION = 'attribute_not_exists(eventSeq) OR eventSeq <= :new'
46
+
47
+ /** The key of a control row, or of a stream's snapshot. */
48
+ export function controlKey(pk: string): Item {
49
+ return {pk: {S: pk}, sk: {N: SNAPSHOT_SORT_KEY.toString()}}
50
+ }
51
+
52
+ /** The key of a stream's head item. */
53
+ export function headKey(entityId: string): Item {
54
+ return {pk: {S: entityId}, sk: {N: HEAD_SORT_KEY.toString()}}
55
+ }
56
+
57
+ /** The marker check of a writer that validated the manifest at `version`. */
58
+ export function markerCheck(table: string, version: bigint): TransactItem {
59
+ return {
60
+ ConditionCheck: {
61
+ TableName: table,
62
+ Key: controlKey(FEATURES_PK),
63
+ ConditionExpression: MARKER_CHECK_CONDITION,
64
+ ExpressionAttributeValues: {':v': number(version)},
65
+ },
66
+ }
67
+ }
68
+
69
+ /** The rebuild-lock check every commit carries second. */
70
+ export function rebuildLockCheck(table: string): TransactItem {
71
+ return {
72
+ ConditionCheck: {
73
+ TableName: table,
74
+ Key: controlKey(REBUILD_LOCK_PK),
75
+ ConditionExpression: REBUILD_LOCK_CONDITION,
76
+ },
77
+ }
78
+ }
79
+
80
+ /** The `ttl`s of a commit to a stream with retention, in Unix seconds. */
81
+ export interface CommitTtl {
82
+ /** The commit's own `ttl`, which its event and snapshot items carry. */
83
+ items: number
84
+ /**
85
+ * The head item's: the commit's own `ttl` on a create, and on an append the larger of it and
86
+ * the largest `ttl` the writer loaded for the stream, because the head's is never lowered.
87
+ */
88
+ head: number
89
+ }
90
+
91
+ /** The `ttl` of a snapshot written outside a commit, to a stream with retention. */
92
+ export interface SnapshotTtl {
93
+ /** The snapshot's `ttl`, in Unix seconds, stamped when it's written. */
94
+ ttl: number
95
+ /**
96
+ * Whether the head guard refreshes the head's `ttl` to at least the snapshot's. True unless the
97
+ * stored head's `ttl` is known to cover it already.
98
+ */
99
+ refresh: boolean
100
+ }
101
+
102
+ /**
103
+ * The actions of a commit with at least one event, in order, by a writer that validated the
104
+ * feature manifest at `markerVersion`. `ttl` is given only for a stream with retention. A projector
105
+ * group for any backend but DynamoDB is refused. Exported for the compatibility fixtures: its
106
+ * signature changes when commits span streams.
107
+ */
108
+ export function commitTransaction(
109
+ table: string,
110
+ commit: Commit,
111
+ markerVersion: bigint,
112
+ ttl?: CommitTtl,
113
+ ): TransactItem[] {
114
+ const first = commit.events[0]
115
+ const last = commit.events.at(-1)
116
+ if (first === undefined || last === undefined) {
117
+ throw new RangeError('a commit transaction needs at least one event')
118
+ }
119
+
120
+ const items = [markerCheck(table, markerVersion), rebuildLockCheck(table)]
121
+ items.push(...projectorItems(commit))
122
+ const head = headUpdate(commit.entityId, first.sequence, last.sequence, ttl?.head)
123
+ items.push(headUpdateItem(table, head))
124
+ for (const event of commit.events) {
125
+ items.push(eventPut(table, event, ttl?.items))
126
+ }
127
+
128
+ const snapshot = commit.snapshot
129
+ if (snapshot) {
130
+ const stored: SerializedSnapshot = {
131
+ entityType: snapshot.entityType,
132
+ entityId: commit.entityId,
133
+ sequence: snapshot.sequence,
134
+ eventSequence: last.sequence,
135
+ payload: snapshot.payload,
136
+ }
137
+ items.push(commitSnapshotPut(table, stored, ttl?.items))
138
+ }
139
+
140
+ return items
141
+ }
142
+
143
+ /**
144
+ * The actions of a snapshot written outside a commit: the marker check, the head guard, and the
145
+ * put, which never lowers the stored snapshot. It carries no rebuild-lock check. `ttl` is given
146
+ * only for a stream with retention. Exported for the compatibility fixtures.
147
+ */
148
+ export function snapshotTransaction(
149
+ table: string,
150
+ snapshot: SerializedSnapshot,
151
+ markerVersion: bigint,
152
+ ttl?: SnapshotTtl,
153
+ ): TransactItem[] {
154
+ const eventSequence = int64Argument(snapshot.eventSequence, 'snapshot.eventSequence')
155
+
156
+ return [
157
+ markerCheck(table, markerVersion),
158
+ snapshotGuard(table, snapshot.entityId, eventSequence, ttl),
159
+ {
160
+ Put: {
161
+ TableName: table,
162
+ Item: snapshotItem(snapshot, ttl?.ttl),
163
+ ConditionExpression: SNAPSHOT_WRITE_CONDITION,
164
+ ExpressionAttributeValues: {':new': number(eventSequence)},
165
+ },
166
+ },
167
+ ]
168
+ }
169
+
170
+ // The head item's action in a snapshot write: a check that the head covers the snapshot, or, for a
171
+ // stream with retention, an update that also keeps the head's `ttl` at least the snapshot's. A
172
+ // snapshot of an empty stream may have no head item, so it's always a check: an update would
173
+ // create a head item without a head.
174
+ function snapshotGuard(
175
+ table: string,
176
+ entityId: string,
177
+ eventSequence: bigint,
178
+ ttl: SnapshotTtl | undefined,
179
+ ): TransactItem {
180
+ const values: Record<string, {N: string}> = {':new': number(eventSequence)}
181
+ if (eventSequence === 0n || ttl === undefined || !ttl.refresh) {
182
+ return {
183
+ ConditionCheck: {
184
+ TableName: table,
185
+ Key: headKey(entityId),
186
+ ConditionExpression:
187
+ eventSequence === 0n ? SNAPSHOT_GUARD_EMPTY_CONDITION : SNAPSHOT_GUARD_CONDITION,
188
+ ExpressionAttributeValues: values,
189
+ },
190
+ }
191
+ }
192
+
193
+ values[':t'] = number(BigInt(safeIntegerArgument(ttl.ttl, 'ttl')))
194
+
195
+ return {
196
+ Update: {
197
+ TableName: table,
198
+ Key: headKey(entityId),
199
+ UpdateExpression: SNAPSHOT_GUARD_RETAINED_UPDATE,
200
+ ConditionExpression: SNAPSHOT_GUARD_RETAINED_CONDITION,
201
+ ExpressionAttributeNames: {'#ttl': 'ttl'},
202
+ ExpressionAttributeValues: values,
203
+ },
204
+ }
205
+ }
206
+
207
+ /** The DynamoDB actions of a commit's projector groups, in order. */
208
+ export function projectorItems(commit: Commit): TransactItem[] {
209
+ const items: TransactItem[] = []
210
+ for (const group of commit.projections ?? []) {
211
+ if (group.backend !== DYNAMODB_BACKEND) {
212
+ throw new EvtError(
213
+ 'invalidCommit',
214
+ `invalid commit: a DynamoDB repository can't write a ${group.backend} projector group`,
215
+ )
216
+ }
217
+ items.push(...(group.operations as TransactItem[]))
218
+ }
219
+
220
+ return items
221
+ }
222
+
223
+ function headUpdateItem(table: string, action: HeadAction): TransactItem {
224
+ const update: NonNullable<TransactItem['Update']> = {
225
+ TableName: table,
226
+ Key: headKey(action.entityId),
227
+ UpdateExpression: action.updateExpression ?? '',
228
+ ConditionExpression: action.conditionExpression,
229
+ ExpressionAttributeValues: Object.fromEntries(
230
+ action.values.map(([placeholder, value]) => [placeholder, number(value)]),
231
+ ),
232
+ }
233
+ if (action.names.length > 0) {
234
+ update.ExpressionAttributeNames = Object.fromEntries(action.names)
235
+ }
236
+
237
+ return {Update: update}
238
+ }
239
+
240
+ function eventPut(table: string, event: SerializedEvent, ttl: number | undefined): TransactItem {
241
+ return {
242
+ Put: {TableName: table, Item: eventItem(event, ttl), ConditionExpression: EVENT_PUT_CONDITION},
243
+ }
244
+ }
245
+
246
+ // A commit's snapshot follows the stored one: sequence 1 needs none, and a later one needs the
247
+ // one before it.
248
+ function commitSnapshotPut(
249
+ table: string,
250
+ snapshot: SerializedSnapshot,
251
+ ttl: number | undefined,
252
+ ): TransactItem {
253
+ const sequence = int64Argument(snapshot.sequence, 'snapshot.sequence')
254
+ if (sequence <= 1n) {
255
+ return {
256
+ Put: {
257
+ TableName: table,
258
+ Item: snapshotItem(snapshot, ttl),
259
+ ConditionExpression: FIRST_SNAPSHOT_CONDITION,
260
+ },
261
+ }
262
+ }
263
+
264
+ return {
265
+ Put: {
266
+ TableName: table,
267
+ Item: snapshotItem(snapshot, ttl),
268
+ ConditionExpression: NEXT_SNAPSHOT_CONDITION,
269
+ ExpressionAttributeValues: {':seq': number(sequence - 1n)},
270
+ },
271
+ }
272
+ }
273
+
274
+ function number(value: bigint): {N: string} {
275
+ return {N: value.toString()}
276
+ }
@@ -0,0 +1,67 @@
1
+ // The per-entity view projector: one row per entity in a views table, its state as the aggregate's
2
+ // codec encodes it, put in the same transaction as the commit's events (storage format, "Views").
3
+ // Commit-wide projectors, which own their keys, supersede it in Go and Rust, but a TypeScript
4
+ // writer of an entity type that a Go service projects this way must write the row too.
5
+
6
+ import type {Projection, Projector} from '../aggregate.js'
7
+ import {utf8Length} from '../utf8.js'
8
+ import type {TransactItem} from './client.js'
9
+ import {itemSize, viewItem} from './item.js'
10
+
11
+ /** The backend name of the projector groups the DynamoDB repository writes. */
12
+ export const DYNAMODB_BACKEND = 'dynamodb'
13
+
14
+ /**
15
+ * A projector that puts the entity's view row in `viewsTable` with every commit:
16
+ * `pk = {entityType}#{entityId}`, `sk = "VIEW"`, and the encoded state as its payload, as Go's
17
+ * `dynamo.NewViewProjector` writes it. The put is unconditional, so the row is the state after the
18
+ * entity's latest commit.
19
+ */
20
+ export function viewProjector<S>(viewsTable: string): Projector<S> {
21
+ if (viewsTable === '') {
22
+ throw new RangeError('a view projector needs a views table')
23
+ }
24
+
25
+ return {
26
+ project({entityType, entityId, encodedState}: Projection<S>) {
27
+ if (entityId === '') {
28
+ return null
29
+ }
30
+
31
+ const put: TransactItem = {
32
+ Put: {TableName: viewsTable, Item: viewItem(entityType, entityId, encodedState)},
33
+ }
34
+
35
+ return {backend: DYNAMODB_BACKEND, operations: [put], sizes: [transactItemSize(put)]}
36
+ },
37
+ }
38
+ }
39
+
40
+ /**
41
+ * An action's size in the commit budget: a put counts its item, and an update, delete or condition
42
+ * check counts its key plus its expression strings, name placeholders (the placeholder and the
43
+ * name) and attribute values.
44
+ */
45
+ export function transactItemSize(action: TransactItem): number {
46
+ if (action.Put) {
47
+ return itemSize(action.Put.Item)
48
+ }
49
+
50
+ const body = action.Update ?? action.Delete ?? action.ConditionCheck
51
+ if (!body) {
52
+ return 0
53
+ }
54
+
55
+ let size = itemSize(body.Key) + utf8Length(body.ConditionExpression ?? '')
56
+ if (action.Update) {
57
+ size += utf8Length(action.Update.UpdateExpression)
58
+ }
59
+ for (const [placeholder, name] of Object.entries(body.ExpressionAttributeNames ?? {})) {
60
+ size += utf8Length(placeholder) + utf8Length(name)
61
+ }
62
+ for (const [placeholder, value] of Object.entries(body.ExpressionAttributeValues ?? {})) {
63
+ size += utf8Length(placeholder) + itemSize({'': value})
64
+ }
65
+
66
+ return size
67
+ }
package/src/errors.ts CHANGED
@@ -19,6 +19,19 @@
19
19
  * - `commitTooLarge`: the commit is over the transaction budget. Nothing was written.
20
20
  * - `compactionUncovered`: no snapshot covers the events compaction was asked to delete.
21
21
  * - `reservedEntityId`: a write to an entity ID in a namespace only the engine writes.
22
+ * - `unsupportedFeature`: the store declares a feature this package doesn't model. Nothing was
23
+ * written.
24
+ * - `streamHeadsNotMigrated`: the store holds data but doesn't declare stream-heads. Nothing was
25
+ * written.
26
+ * - `storeNotInitialized`: the store doesn't declare stream-heads, and the writer doesn't
27
+ * initialize a store itself. Nothing was written.
28
+ * - `featureManifestChanged`: another writer changed the store's feature manifest since this one
29
+ * validated it. Retryable: the writer validates it again.
30
+ * - `transient`: the backend throttled the write. Retryable. Nothing was written.
31
+ * - `rebuildInProgress`: a projection rebuild holds the store, so commits wait. Retryable.
32
+ * - `historyDependentProjection`: compaction was refused for a history-dependent entity type.
33
+ * - `projectorOnlyCommit`: a commit carries projector operations but no events. Nothing was
34
+ * written.
22
35
  * - `replay`: a stored event failed to upcast or decode while an aggregate loaded.
23
36
  * - `snapshot`: a stored snapshot failed to decode while an aggregate loaded.
24
37
  */
@@ -33,6 +46,14 @@ export type EvtErrorCode =
33
46
  | 'commitTooLarge'
34
47
  | 'compactionUncovered'
35
48
  | 'reservedEntityId'
49
+ | 'unsupportedFeature'
50
+ | 'streamHeadsNotMigrated'
51
+ | 'storeNotInitialized'
52
+ | 'featureManifestChanged'
53
+ | 'transient'
54
+ | 'rebuildInProgress'
55
+ | 'historyDependentProjection'
56
+ | 'projectorOnlyCommit'
36
57
  | 'replay'
37
58
  | 'snapshot'
38
59
 
@@ -162,6 +183,147 @@ export class ReservedEntityIdError extends EvtError {
162
183
  }
163
184
  }
164
185
 
186
+ /** A feature and one of its stored-format versions. */
187
+ export interface FeaturePair {
188
+ readonly name: string
189
+ readonly version: number
190
+ }
191
+
192
+ /**
193
+ * The store's feature manifest declares pairs this package doesn't model, so it refuses to write:
194
+ * a commit, a snapshot write, compaction or initialization. Nothing was written.
195
+ */
196
+ export class UnsupportedFeatureError extends EvtError {
197
+ override readonly name = 'UnsupportedFeatureError'
198
+ override readonly code = 'unsupportedFeature'
199
+ /** Every unsupported pair, sorted by name. */
200
+ readonly unsupported: readonly FeaturePair[]
201
+
202
+ constructor(unsupported: readonly FeaturePair[]) {
203
+ const pairs = unsupported.map((pair) => `${pair.name}@${pair.version}`).join(', ')
204
+ super('unsupportedFeature', `store declares unsupported features (${pairs}); refusing to write`)
205
+ this.unsupported = unsupported
206
+ }
207
+ }
208
+
209
+ /**
210
+ * The store holds data but its feature manifest doesn't declare stream-heads: its streams may have
211
+ * no head items, so a head-aware write could fail every stream. Migrate the store with every older
212
+ * writer stopped. Nothing was written.
213
+ */
214
+ export class StreamHeadsNotMigratedError extends EvtError {
215
+ override readonly name = 'StreamHeadsNotMigratedError'
216
+ override readonly code = 'streamHeadsNotMigrated'
217
+
218
+ constructor() {
219
+ super(
220
+ 'streamHeadsNotMigrated',
221
+ 'store does not declare the stream-heads feature and holds data without per-stream head ' +
222
+ 'items; migrate it before writing',
223
+ )
224
+ }
225
+ }
226
+
227
+ /**
228
+ * The store's feature manifest doesn't declare stream-heads, and this writer doesn't initialize a
229
+ * store itself (the DynamoDB default), so it refuses rather than scan the table to learn whether
230
+ * it's fresh. Initialize a fresh store once, at deploy time, or migrate one written before stream
231
+ * heads. Nothing was written.
232
+ */
233
+ export class StoreNotInitializedError extends EvtError {
234
+ override readonly name = 'StoreNotInitializedError'
235
+ override readonly code = 'storeNotInitialized'
236
+
237
+ constructor() {
238
+ super(
239
+ 'storeNotInitialized',
240
+ 'store is not initialized: the feature manifest does not declare stream-heads; initialize ' +
241
+ 'a fresh store, or migrate one written before stream heads, before writing',
242
+ )
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Another writer changed the store's feature manifest after this one validated it, so the commit's
248
+ * marker check failed. The writer has validated the new manifest, which it supports. Retryable.
249
+ * Nothing was written.
250
+ */
251
+ export class FeatureManifestChangedError extends EvtError {
252
+ override readonly name = 'FeatureManifestChangedError'
253
+ override readonly code = 'featureManifestChanged'
254
+
255
+ constructor(options?: {cause?: unknown}) {
256
+ super('featureManifestChanged', 'feature manifest changed under this writer', options)
257
+ }
258
+ }
259
+
260
+ /**
261
+ * The backend refused the write for now, such as a DynamoDB transaction cancelled for throttling.
262
+ * Retryable, after a backoff. Nothing was written. The backend's error is its cause.
263
+ */
264
+ export class TransientError extends EvtError {
265
+ override readonly name = 'TransientError'
266
+ override readonly code = 'transient'
267
+
268
+ constructor(detail: string, options?: {cause?: unknown}) {
269
+ super('transient', `transient failure: ${detail}`, options)
270
+ }
271
+ }
272
+
273
+ /**
274
+ * A projection rebuild holds the store, or one was force-released and no rebuild has finished
275
+ * since, so every commit is refused. Retryable: the command can run again once the rebuild ends.
276
+ */
277
+ export class RebuildInProgressError extends EvtError {
278
+ override readonly name = 'RebuildInProgressError'
279
+ override readonly code = 'rebuildInProgress'
280
+
281
+ constructor() {
282
+ super('rebuildInProgress', 'a projection rebuild is in progress')
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Compaction was refused because the store declares the entity's type history-dependent: its
288
+ * projectors' rebuild replays the events compaction would delete. Nothing was deleted.
289
+ */
290
+ export class HistoryDependentProjectionError extends EvtError {
291
+ override readonly name = 'HistoryDependentProjectionError'
292
+ override readonly code = 'historyDependentProjection'
293
+ readonly entityType: string
294
+ readonly entityId: string
295
+
296
+ constructor(entityType: string, entityId: string) {
297
+ super(
298
+ 'historyDependentProjection',
299
+ `entity type ${entityType} is declared history-dependent; compaction would delete the ` +
300
+ `commits its rebuild replays (entity ${entityId})`,
301
+ )
302
+ this.entityType = entityType
303
+ this.entityId = entityId
304
+ }
305
+ }
306
+
307
+ /**
308
+ * A commit carried projector operations but no events. Views are projections of committed events,
309
+ * so operations with no event behind them are refused on every backend. Nothing was written.
310
+ */
311
+ export class ProjectorOnlyCommitError extends EvtError {
312
+ override readonly name = 'ProjectorOnlyCommitError'
313
+ override readonly code = 'projectorOnlyCommit'
314
+ /** How many projector operations the commit carried. */
315
+ readonly operations: number
316
+
317
+ constructor(operations: number) {
318
+ super(
319
+ 'projectorOnlyCommit',
320
+ `a commit carries ${operations} projector operations but no events; views need a ` +
321
+ 'backing event in the same commit',
322
+ )
323
+ this.operations = operations
324
+ }
325
+ }
326
+
165
327
  /** A load failed closed on a stored event: an upcaster threw, or the payload didn't decode. */
166
328
  export class ReplayError extends EvtError {
167
329
  override readonly name = 'ReplayError'
@@ -203,9 +365,21 @@ export function isConflict(error: unknown): boolean {
203
365
  return error instanceof EvtError && (error.code === 'conflict' || error.code === 'sequenceGap')
204
366
  }
205
367
 
206
- /** Whether reloading and running the command again may succeed. So far, only after a conflict. */
368
+ /**
369
+ * Whether reloading and running the command again may succeed: after a conflict, while a rebuild
370
+ * holds the store, after the feature manifest changed under the writer, or after throttling.
371
+ */
207
372
  export function isRetryable(error: unknown): boolean {
208
- return isConflict(error)
373
+ if (isConflict(error)) {
374
+ return true
375
+ }
376
+
377
+ return (
378
+ error instanceof EvtError &&
379
+ (error.code === 'rebuildInProgress' ||
380
+ error.code === 'featureManifestChanged' ||
381
+ error.code === 'transient')
382
+ )
209
383
  }
210
384
 
211
385
  /** An error's message, or the value itself as text. */