@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.
- package/README.md +50 -9
- package/dist/dynamodb/client.d.ts +83 -0
- package/dist/dynamodb/client.d.ts.map +1 -0
- package/dist/dynamodb/client.js +6 -0
- package/dist/dynamodb/client.js.map +1 -0
- package/dist/dynamodb/index.d.ts +5 -1
- package/dist/dynamodb/index.d.ts.map +1 -1
- package/dist/dynamodb/index.js +7 -3
- package/dist/dynamodb/index.js.map +1 -1
- package/dist/dynamodb/item.d.ts +2 -0
- package/dist/dynamodb/item.d.ts.map +1 -1
- package/dist/dynamodb/item.js +5 -4
- package/dist/dynamodb/item.js.map +1 -1
- package/dist/dynamodb/marker.d.ts +11 -0
- package/dist/dynamodb/marker.d.ts.map +1 -0
- package/dist/dynamodb/marker.js +56 -0
- package/dist/dynamodb/marker.js.map +1 -0
- package/dist/dynamodb/repository.d.ts +61 -0
- package/dist/dynamodb/repository.d.ts.map +1 -0
- package/dist/dynamodb/repository.js +731 -0
- package/dist/dynamodb/repository.js.map +1 -0
- package/dist/dynamodb/requests.d.ts +65 -0
- package/dist/dynamodb/requests.d.ts.map +1 -0
- package/dist/dynamodb/requests.js +179 -0
- package/dist/dynamodb/requests.js.map +1 -0
- package/dist/errors.d.ts +96 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +103 -2
- package/dist/errors.js.map +1 -1
- package/dist/features.d.ts +55 -0
- package/dist/features.d.ts.map +1 -0
- package/dist/features.js +126 -0
- package/dist/features.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/memory/index.d.ts +1 -1
- package/dist/memory/index.d.ts.map +1 -1
- package/dist/memory/index.js +1 -1
- package/dist/memory/index.js.map +1 -1
- package/dist/memory/repository.d.ts +38 -2
- package/dist/memory/repository.d.ts.map +1 -1
- package/dist/memory/repository.js +95 -6
- package/dist/memory/repository.js.map +1 -1
- package/dist/repository.d.ts +30 -1
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +3 -4
- package/dist/repository.js.map +1 -1
- package/dist/store.d.ts +3 -3
- package/dist/store.js +3 -3
- package/package.json +12 -3
- package/src/dynamodb/client.ts +102 -0
- package/src/dynamodb/index.ts +26 -2
- package/src/dynamodb/item.ts +6 -4
- package/src/dynamodb/marker.ts +64 -0
- package/src/dynamodb/repository.ts +1030 -0
- package/src/dynamodb/requests.ts +256 -0
- package/src/errors.ts +153 -2
- package/src/features.ts +175 -0
- package/src/index.ts +24 -1
- package/src/memory/index.ts +9 -1
- package/src/memory/repository.ts +156 -6
- package/src/repository.ts +34 -6
- package/src/store.ts +3 -3
|
@@ -0,0 +1,256 @@
|
|
|
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 writes the stream: its head update, its event
|
|
4
|
+
// 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 type {Commit} from '../repository.js'
|
|
9
|
+
import type {TransactItem} from './client.js'
|
|
10
|
+
import {type HeadAction, headUpdate} from './head.js'
|
|
11
|
+
import {HEAD_SORT_KEY, type Item, SNAPSHOT_SORT_KEY, eventItem, snapshotItem} from './item.js'
|
|
12
|
+
import {FEATURES_PK, REBUILD_LOCK_PK} from './marker.js'
|
|
13
|
+
|
|
14
|
+
/** The marker check of a writer that validated the manifest at version `:v`. */
|
|
15
|
+
export const MARKER_CHECK_CONDITION = 'attribute_not_exists(pk) OR version = :v'
|
|
16
|
+
|
|
17
|
+
/** The rebuild-lock check: no lock row, so the rebuild generation is settled. */
|
|
18
|
+
export const REBUILD_LOCK_CONDITION = 'attribute_not_exists(pk)'
|
|
19
|
+
|
|
20
|
+
/** The condition of an event put: the sequence is new. */
|
|
21
|
+
export const EVENT_PUT_CONDITION = 'attribute_not_exists(sk)'
|
|
22
|
+
|
|
23
|
+
/** The condition of a commit's first snapshot of a stream. */
|
|
24
|
+
export const FIRST_SNAPSHOT_CONDITION = 'attribute_not_exists(seq)'
|
|
25
|
+
|
|
26
|
+
/** The condition of a commit's later snapshot: the stored one is the one before it. */
|
|
27
|
+
export const NEXT_SNAPSHOT_CONDITION = 'seq = :seq'
|
|
28
|
+
|
|
29
|
+
/** A snapshot write's head guard: the head covers the snapshot. */
|
|
30
|
+
export const SNAPSHOT_GUARD_CONDITION = 'head >= :new'
|
|
31
|
+
|
|
32
|
+
/** The guard of a snapshot of an empty stream (event sequence 0), which needs no head. */
|
|
33
|
+
export const SNAPSHOT_GUARD_EMPTY_CONDITION = 'attribute_not_exists(head) OR head >= :new'
|
|
34
|
+
|
|
35
|
+
/** The update expression of a retained stream's snapshot guard: it refreshes the head's `ttl`. */
|
|
36
|
+
export const SNAPSHOT_GUARD_RETAINED_UPDATE = 'SET #ttl = :t'
|
|
37
|
+
|
|
38
|
+
/** {@link SNAPSHOT_GUARD_CONDITION} with retention: the head's `ttl` is raised, never lowered. */
|
|
39
|
+
export const SNAPSHOT_GUARD_RETAINED_CONDITION =
|
|
40
|
+
'head >= :new AND (attribute_not_exists(#ttl) OR #ttl <= :t)'
|
|
41
|
+
|
|
42
|
+
/** A snapshot write's put: it never lowers the stored snapshot's event sequence. */
|
|
43
|
+
export const SNAPSHOT_WRITE_CONDITION = 'attribute_not_exists(eventSeq) OR eventSeq <= :new'
|
|
44
|
+
|
|
45
|
+
/** The key of a control row, or of a stream's snapshot. */
|
|
46
|
+
export function controlKey(pk: string): Item {
|
|
47
|
+
return {pk: {S: pk}, sk: {N: SNAPSHOT_SORT_KEY.toString()}}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The key of a stream's head item. */
|
|
51
|
+
export function headKey(entityId: string): Item {
|
|
52
|
+
return {pk: {S: entityId}, sk: {N: HEAD_SORT_KEY.toString()}}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The marker check of a writer that validated the manifest at `version`. */
|
|
56
|
+
export function markerCheck(table: string, version: bigint): TransactItem {
|
|
57
|
+
return {
|
|
58
|
+
ConditionCheck: {
|
|
59
|
+
TableName: table,
|
|
60
|
+
Key: controlKey(FEATURES_PK),
|
|
61
|
+
ConditionExpression: MARKER_CHECK_CONDITION,
|
|
62
|
+
ExpressionAttributeValues: {':v': number(version)},
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The rebuild-lock check every commit carries second. */
|
|
68
|
+
export function rebuildLockCheck(table: string): TransactItem {
|
|
69
|
+
return {
|
|
70
|
+
ConditionCheck: {
|
|
71
|
+
TableName: table,
|
|
72
|
+
Key: controlKey(REBUILD_LOCK_PK),
|
|
73
|
+
ConditionExpression: REBUILD_LOCK_CONDITION,
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The `ttl`s of a commit to a stream with retention, in Unix seconds. */
|
|
79
|
+
export interface CommitTtl {
|
|
80
|
+
/** The commit's own `ttl`, which its event and snapshot items carry. */
|
|
81
|
+
items: number
|
|
82
|
+
/**
|
|
83
|
+
* The head item's: the commit's own `ttl` on a create, and on an append the larger of it and
|
|
84
|
+
* the largest `ttl` the writer loaded for the stream, because the head's is never lowered.
|
|
85
|
+
*/
|
|
86
|
+
head: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The `ttl` of a snapshot written outside a commit, to a stream with retention. */
|
|
90
|
+
export interface SnapshotTtl {
|
|
91
|
+
/** The snapshot's `ttl`, in Unix seconds, stamped when it's written. */
|
|
92
|
+
ttl: number
|
|
93
|
+
/**
|
|
94
|
+
* Whether the head guard refreshes the head's `ttl` to at least the snapshot's. True unless the
|
|
95
|
+
* stored head's `ttl` is known to cover it already.
|
|
96
|
+
*/
|
|
97
|
+
refresh: boolean
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The actions of a commit with at least one event, in order, by a writer that validated the
|
|
102
|
+
* feature manifest at `markerVersion`. `ttl` is given only for a stream with retention. Exported
|
|
103
|
+
* for the compatibility fixtures: its signature changes when commits span streams.
|
|
104
|
+
*/
|
|
105
|
+
export function commitTransaction(
|
|
106
|
+
table: string,
|
|
107
|
+
commit: Commit,
|
|
108
|
+
markerVersion: bigint,
|
|
109
|
+
ttl?: CommitTtl,
|
|
110
|
+
): TransactItem[] {
|
|
111
|
+
const first = commit.events[0]
|
|
112
|
+
const last = commit.events.at(-1)
|
|
113
|
+
if (first === undefined || last === undefined) {
|
|
114
|
+
throw new RangeError('a commit transaction needs at least one event')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const items = [markerCheck(table, markerVersion), rebuildLockCheck(table)]
|
|
118
|
+
const head = headUpdate(commit.entityId, first.sequence, last.sequence, ttl?.head)
|
|
119
|
+
items.push(headUpdateItem(table, head))
|
|
120
|
+
for (const event of commit.events) {
|
|
121
|
+
items.push(eventPut(table, event, ttl?.items))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const snapshot = commit.snapshot
|
|
125
|
+
if (snapshot) {
|
|
126
|
+
const stored: SerializedSnapshot = {
|
|
127
|
+
entityType: snapshot.entityType,
|
|
128
|
+
entityId: commit.entityId,
|
|
129
|
+
sequence: snapshot.sequence,
|
|
130
|
+
eventSequence: last.sequence,
|
|
131
|
+
payload: snapshot.payload,
|
|
132
|
+
}
|
|
133
|
+
items.push(commitSnapshotPut(table, stored, ttl?.items))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return items
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The actions of a snapshot written outside a commit: the marker check, the head guard, and the
|
|
141
|
+
* put, which never lowers the stored snapshot. It carries no rebuild-lock check. `ttl` is given
|
|
142
|
+
* only for a stream with retention. Exported for the compatibility fixtures.
|
|
143
|
+
*/
|
|
144
|
+
export function snapshotTransaction(
|
|
145
|
+
table: string,
|
|
146
|
+
snapshot: SerializedSnapshot,
|
|
147
|
+
markerVersion: bigint,
|
|
148
|
+
ttl?: SnapshotTtl,
|
|
149
|
+
): TransactItem[] {
|
|
150
|
+
const eventSequence = int64Argument(snapshot.eventSequence, 'snapshot.eventSequence')
|
|
151
|
+
|
|
152
|
+
return [
|
|
153
|
+
markerCheck(table, markerVersion),
|
|
154
|
+
snapshotGuard(table, snapshot.entityId, eventSequence, ttl),
|
|
155
|
+
{
|
|
156
|
+
Put: {
|
|
157
|
+
TableName: table,
|
|
158
|
+
Item: snapshotItem(snapshot, ttl?.ttl),
|
|
159
|
+
ConditionExpression: SNAPSHOT_WRITE_CONDITION,
|
|
160
|
+
ExpressionAttributeValues: {':new': number(eventSequence)},
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The head item's action in a snapshot write: a check that the head covers the snapshot, or, for a
|
|
167
|
+
// stream with retention, an update that also keeps the head's `ttl` at least the snapshot's. A
|
|
168
|
+
// snapshot of an empty stream may have no head item, so it's always a check: an update would
|
|
169
|
+
// create a head item without a head.
|
|
170
|
+
function snapshotGuard(
|
|
171
|
+
table: string,
|
|
172
|
+
entityId: string,
|
|
173
|
+
eventSequence: bigint,
|
|
174
|
+
ttl: SnapshotTtl | undefined,
|
|
175
|
+
): TransactItem {
|
|
176
|
+
const values: Record<string, {N: string}> = {':new': number(eventSequence)}
|
|
177
|
+
if (eventSequence === 0n || ttl === undefined || !ttl.refresh) {
|
|
178
|
+
return {
|
|
179
|
+
ConditionCheck: {
|
|
180
|
+
TableName: table,
|
|
181
|
+
Key: headKey(entityId),
|
|
182
|
+
ConditionExpression:
|
|
183
|
+
eventSequence === 0n ? SNAPSHOT_GUARD_EMPTY_CONDITION : SNAPSHOT_GUARD_CONDITION,
|
|
184
|
+
ExpressionAttributeValues: values,
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
values[':t'] = number(BigInt(safeIntegerArgument(ttl.ttl, 'ttl')))
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
Update: {
|
|
193
|
+
TableName: table,
|
|
194
|
+
Key: headKey(entityId),
|
|
195
|
+
UpdateExpression: SNAPSHOT_GUARD_RETAINED_UPDATE,
|
|
196
|
+
ConditionExpression: SNAPSHOT_GUARD_RETAINED_CONDITION,
|
|
197
|
+
ExpressionAttributeNames: {'#ttl': 'ttl'},
|
|
198
|
+
ExpressionAttributeValues: values,
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function headUpdateItem(table: string, action: HeadAction): TransactItem {
|
|
204
|
+
const update: NonNullable<TransactItem['Update']> = {
|
|
205
|
+
TableName: table,
|
|
206
|
+
Key: headKey(action.entityId),
|
|
207
|
+
UpdateExpression: action.updateExpression ?? '',
|
|
208
|
+
ConditionExpression: action.conditionExpression,
|
|
209
|
+
ExpressionAttributeValues: Object.fromEntries(
|
|
210
|
+
action.values.map(([placeholder, value]) => [placeholder, number(value)]),
|
|
211
|
+
),
|
|
212
|
+
}
|
|
213
|
+
if (action.names.length > 0) {
|
|
214
|
+
update.ExpressionAttributeNames = Object.fromEntries(action.names)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {Update: update}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function eventPut(table: string, event: SerializedEvent, ttl: number | undefined): TransactItem {
|
|
221
|
+
return {
|
|
222
|
+
Put: {TableName: table, Item: eventItem(event, ttl), ConditionExpression: EVENT_PUT_CONDITION},
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// A commit's snapshot follows the stored one: sequence 1 needs none, and a later one needs the
|
|
227
|
+
// one before it.
|
|
228
|
+
function commitSnapshotPut(
|
|
229
|
+
table: string,
|
|
230
|
+
snapshot: SerializedSnapshot,
|
|
231
|
+
ttl: number | undefined,
|
|
232
|
+
): TransactItem {
|
|
233
|
+
const sequence = int64Argument(snapshot.sequence, 'snapshot.sequence')
|
|
234
|
+
if (sequence <= 1n) {
|
|
235
|
+
return {
|
|
236
|
+
Put: {
|
|
237
|
+
TableName: table,
|
|
238
|
+
Item: snapshotItem(snapshot, ttl),
|
|
239
|
+
ConditionExpression: FIRST_SNAPSHOT_CONDITION,
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
Put: {
|
|
246
|
+
TableName: table,
|
|
247
|
+
Item: snapshotItem(snapshot, ttl),
|
|
248
|
+
ConditionExpression: NEXT_SNAPSHOT_CONDITION,
|
|
249
|
+
ExpressionAttributeValues: {':seq': number(sequence - 1n)},
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function number(value: bigint): {N: string} {
|
|
255
|
+
return {N: value.toString()}
|
|
256
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -19,6 +19,17 @@
|
|
|
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.
|
|
22
33
|
* - `replay`: a stored event failed to upcast or decode while an aggregate loaded.
|
|
23
34
|
* - `snapshot`: a stored snapshot failed to decode while an aggregate loaded.
|
|
24
35
|
*/
|
|
@@ -33,6 +44,13 @@ export type EvtErrorCode =
|
|
|
33
44
|
| 'commitTooLarge'
|
|
34
45
|
| 'compactionUncovered'
|
|
35
46
|
| 'reservedEntityId'
|
|
47
|
+
| 'unsupportedFeature'
|
|
48
|
+
| 'streamHeadsNotMigrated'
|
|
49
|
+
| 'storeNotInitialized'
|
|
50
|
+
| 'featureManifestChanged'
|
|
51
|
+
| 'transient'
|
|
52
|
+
| 'rebuildInProgress'
|
|
53
|
+
| 'historyDependentProjection'
|
|
36
54
|
| 'replay'
|
|
37
55
|
| 'snapshot'
|
|
38
56
|
|
|
@@ -162,6 +180,127 @@ export class ReservedEntityIdError extends EvtError {
|
|
|
162
180
|
}
|
|
163
181
|
}
|
|
164
182
|
|
|
183
|
+
/** A feature and one of its stored-format versions. */
|
|
184
|
+
export interface FeaturePair {
|
|
185
|
+
readonly name: string
|
|
186
|
+
readonly version: number
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The store's feature manifest declares pairs this package doesn't model, so it refuses to write:
|
|
191
|
+
* a commit, a snapshot write, compaction or initialization. Nothing was written.
|
|
192
|
+
*/
|
|
193
|
+
export class UnsupportedFeatureError extends EvtError {
|
|
194
|
+
override readonly name = 'UnsupportedFeatureError'
|
|
195
|
+
override readonly code = 'unsupportedFeature'
|
|
196
|
+
/** Every unsupported pair, sorted by name. */
|
|
197
|
+
readonly unsupported: readonly FeaturePair[]
|
|
198
|
+
|
|
199
|
+
constructor(unsupported: readonly FeaturePair[]) {
|
|
200
|
+
const pairs = unsupported.map((pair) => `${pair.name}@${pair.version}`).join(', ')
|
|
201
|
+
super('unsupportedFeature', `store declares unsupported features (${pairs}); refusing to write`)
|
|
202
|
+
this.unsupported = unsupported
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The store holds data but its feature manifest doesn't declare stream-heads: its streams may have
|
|
208
|
+
* no head items, so a head-aware write could fail every stream. Migrate the store with every older
|
|
209
|
+
* writer stopped. Nothing was written.
|
|
210
|
+
*/
|
|
211
|
+
export class StreamHeadsNotMigratedError extends EvtError {
|
|
212
|
+
override readonly name = 'StreamHeadsNotMigratedError'
|
|
213
|
+
override readonly code = 'streamHeadsNotMigrated'
|
|
214
|
+
|
|
215
|
+
constructor() {
|
|
216
|
+
super(
|
|
217
|
+
'streamHeadsNotMigrated',
|
|
218
|
+
'store does not declare the stream-heads feature and holds data without per-stream head ' +
|
|
219
|
+
'items; migrate it before writing',
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The store's feature manifest doesn't declare stream-heads, and this writer doesn't initialize a
|
|
226
|
+
* store itself (the DynamoDB default), so it refuses rather than scan the table to learn whether
|
|
227
|
+
* it's fresh. Initialize a fresh store once, at deploy time, or migrate one written before stream
|
|
228
|
+
* heads. Nothing was written.
|
|
229
|
+
*/
|
|
230
|
+
export class StoreNotInitializedError extends EvtError {
|
|
231
|
+
override readonly name = 'StoreNotInitializedError'
|
|
232
|
+
override readonly code = 'storeNotInitialized'
|
|
233
|
+
|
|
234
|
+
constructor() {
|
|
235
|
+
super(
|
|
236
|
+
'storeNotInitialized',
|
|
237
|
+
'store is not initialized: the feature manifest does not declare stream-heads; initialize ' +
|
|
238
|
+
'a fresh store, or migrate one written before stream heads, before writing',
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Another writer changed the store's feature manifest after this one validated it, so the commit's
|
|
245
|
+
* marker check failed. The writer has validated the new manifest, which it supports. Retryable.
|
|
246
|
+
* Nothing was written.
|
|
247
|
+
*/
|
|
248
|
+
export class FeatureManifestChangedError extends EvtError {
|
|
249
|
+
override readonly name = 'FeatureManifestChangedError'
|
|
250
|
+
override readonly code = 'featureManifestChanged'
|
|
251
|
+
|
|
252
|
+
constructor(options?: {cause?: unknown}) {
|
|
253
|
+
super('featureManifestChanged', 'feature manifest changed under this writer', options)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The backend refused the write for now, such as a DynamoDB transaction cancelled for throttling.
|
|
259
|
+
* Retryable, after a backoff. Nothing was written. The backend's error is its cause.
|
|
260
|
+
*/
|
|
261
|
+
export class TransientError extends EvtError {
|
|
262
|
+
override readonly name = 'TransientError'
|
|
263
|
+
override readonly code = 'transient'
|
|
264
|
+
|
|
265
|
+
constructor(detail: string, options?: {cause?: unknown}) {
|
|
266
|
+
super('transient', `transient failure: ${detail}`, options)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* A projection rebuild holds the store, or one was force-released and no rebuild has finished
|
|
272
|
+
* since, so every commit is refused. Retryable: the command can run again once the rebuild ends.
|
|
273
|
+
*/
|
|
274
|
+
export class RebuildInProgressError extends EvtError {
|
|
275
|
+
override readonly name = 'RebuildInProgressError'
|
|
276
|
+
override readonly code = 'rebuildInProgress'
|
|
277
|
+
|
|
278
|
+
constructor() {
|
|
279
|
+
super('rebuildInProgress', 'a projection rebuild is in progress')
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Compaction was refused because the store declares the entity's type history-dependent: its
|
|
285
|
+
* projectors' rebuild replays the events compaction would delete. Nothing was deleted.
|
|
286
|
+
*/
|
|
287
|
+
export class HistoryDependentProjectionError extends EvtError {
|
|
288
|
+
override readonly name = 'HistoryDependentProjectionError'
|
|
289
|
+
override readonly code = 'historyDependentProjection'
|
|
290
|
+
readonly entityType: string
|
|
291
|
+
readonly entityId: string
|
|
292
|
+
|
|
293
|
+
constructor(entityType: string, entityId: string) {
|
|
294
|
+
super(
|
|
295
|
+
'historyDependentProjection',
|
|
296
|
+
`entity type ${entityType} is declared history-dependent; compaction would delete the ` +
|
|
297
|
+
`commits its rebuild replays (entity ${entityId})`,
|
|
298
|
+
)
|
|
299
|
+
this.entityType = entityType
|
|
300
|
+
this.entityId = entityId
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
165
304
|
/** A load failed closed on a stored event: an upcaster threw, or the payload didn't decode. */
|
|
166
305
|
export class ReplayError extends EvtError {
|
|
167
306
|
override readonly name = 'ReplayError'
|
|
@@ -203,9 +342,21 @@ export function isConflict(error: unknown): boolean {
|
|
|
203
342
|
return error instanceof EvtError && (error.code === 'conflict' || error.code === 'sequenceGap')
|
|
204
343
|
}
|
|
205
344
|
|
|
206
|
-
/**
|
|
345
|
+
/**
|
|
346
|
+
* Whether reloading and running the command again may succeed: after a conflict, while a rebuild
|
|
347
|
+
* holds the store, after the feature manifest changed under the writer, or after throttling.
|
|
348
|
+
*/
|
|
207
349
|
export function isRetryable(error: unknown): boolean {
|
|
208
|
-
|
|
350
|
+
if (isConflict(error)) {
|
|
351
|
+
return true
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
error instanceof EvtError &&
|
|
356
|
+
(error.code === 'rebuildInProgress' ||
|
|
357
|
+
error.code === 'featureManifestChanged' ||
|
|
358
|
+
error.code === 'transient')
|
|
359
|
+
)
|
|
209
360
|
}
|
|
210
361
|
|
|
211
362
|
/** An error's message, or the value itself as text. */
|
package/src/features.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// The feature manifest a store keeps on its SYSTEM#FEATURES marker row (storage format, "Feature
|
|
2
|
+
// marker"): the extensions it declares, each at a stored-format version, and the entity types
|
|
3
|
+
// declared history-dependent or retained. A writer refuses a store that declares a pair it doesn't
|
|
4
|
+
// model, and every writer requires stream-heads.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
EvtError,
|
|
8
|
+
type FeaturePair,
|
|
9
|
+
StreamHeadsNotMigratedError,
|
|
10
|
+
UnsupportedFeatureError,
|
|
11
|
+
} from './errors.js'
|
|
12
|
+
import {DOCUMENT, int64, invalid} from './fields.js'
|
|
13
|
+
import {type JsonValue, parseJson, stringifyJson} from './json.js'
|
|
14
|
+
import {compareUtf8} from './utf8.js'
|
|
15
|
+
|
|
16
|
+
/** The feature every writer models and requires: per-stream head items (ADR 0007). */
|
|
17
|
+
export const STREAM_HEADS_FEATURE = 'stream-heads'
|
|
18
|
+
|
|
19
|
+
/** The stored-format version of {@link STREAM_HEADS_FEATURE} this package reads and writes. */
|
|
20
|
+
export const STREAM_HEADS_VERSION = 1
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The `(feature, stored-format version)` pairs this package models. The other features the
|
|
24
|
+
* storage format defines (`journal`, `journal-compaction`, `postgres-views`) are Rust-first, so a
|
|
25
|
+
* store that declares any of them is refused.
|
|
26
|
+
*/
|
|
27
|
+
export const SUPPORTED_FEATURES: readonly FeaturePair[] = Object.freeze([
|
|
28
|
+
Object.freeze({name: STREAM_HEADS_FEATURE, version: STREAM_HEADS_VERSION}),
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
// What validation reads. Private, so no caller can add a pair to it.
|
|
32
|
+
const supported = new Map(SUPPORTED_FEATURES.map((pair) => [pair.name, pair.version]))
|
|
33
|
+
|
|
34
|
+
/** The parsed marker row. A store with no marker row has no manifest. */
|
|
35
|
+
export interface FeatureManifest {
|
|
36
|
+
/** Bumped by exactly one on every declaration. */
|
|
37
|
+
readonly version: bigint
|
|
38
|
+
/** Each declared feature's stored-format version. */
|
|
39
|
+
readonly features: ReadonlyMap<string, number>
|
|
40
|
+
/** The entity types whose commit-wide projectors are history-dependent: never compacted. */
|
|
41
|
+
readonly historyDependent: readonly string[]
|
|
42
|
+
/** The entity types configured with retention: never fenced. */
|
|
43
|
+
readonly retention: readonly string[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Refuses a manifest that declares a pair this package doesn't model, listing every such pair,
|
|
48
|
+
* with an {@link UnsupportedFeatureError}.
|
|
49
|
+
*/
|
|
50
|
+
export function checkSupported(manifest: FeatureManifest | null): void {
|
|
51
|
+
const unsupported: FeaturePair[] = []
|
|
52
|
+
for (const [name, version] of sortedFeatures(manifest?.features ?? new Map())) {
|
|
53
|
+
if (supported.get(name) !== version) {
|
|
54
|
+
unsupported.push({name, version})
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (unsupported.length > 0) {
|
|
59
|
+
throw new UnsupportedFeatureError(unsupported)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Whether the manifest declares stream-heads at the version this package models. */
|
|
64
|
+
export function declaresStreamHeads(manifest: FeatureManifest | null): boolean {
|
|
65
|
+
return manifest?.features.get(STREAM_HEADS_FEATURE) === STREAM_HEADS_VERSION
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Refuses a write to a store whose manifest declares an unsupported pair, or doesn't declare
|
|
70
|
+
* stream-heads, without declaring anything.
|
|
71
|
+
*/
|
|
72
|
+
export function checkWritable(manifest: FeatureManifest | null): void {
|
|
73
|
+
checkSupported(manifest)
|
|
74
|
+
if (!declaresStreamHeads(manifest)) {
|
|
75
|
+
throw new StreamHeadsNotMigratedError()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The manifest with stream-heads declared and its version bumped: a first writer's declaration. */
|
|
80
|
+
export function withStreamHeads(manifest: FeatureManifest | null): FeatureManifest {
|
|
81
|
+
const features = new Map(manifest?.features)
|
|
82
|
+
features.set(STREAM_HEADS_FEATURE, STREAM_HEADS_VERSION)
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
version: (manifest?.version ?? 0n) + 1n,
|
|
86
|
+
features,
|
|
87
|
+
historyDependent: manifest?.historyDependent ?? [],
|
|
88
|
+
retention: manifest?.retention ?? [],
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A manifest's stored JSON texts, as the marker row holds them. */
|
|
93
|
+
export interface ManifestTexts {
|
|
94
|
+
/** A JSON object, feature name to stored-format version, keys sorted. `{}` when empty. */
|
|
95
|
+
features: string
|
|
96
|
+
/** A JSON array of entity types, sorted by UTF-8 bytes and deduplicated. `[]` when empty. */
|
|
97
|
+
historyDependent: string
|
|
98
|
+
/** Like `historyDependent`. */
|
|
99
|
+
retention: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Encodes a manifest's declarable parts as the marker row stores them. */
|
|
103
|
+
export function encodeManifest(manifest: FeatureManifest): ManifestTexts {
|
|
104
|
+
return {
|
|
105
|
+
features: stringifyJson(new Map(manifest.features)),
|
|
106
|
+
historyDependent: stringifyJson(entityTypeSet(manifest.historyDependent)),
|
|
107
|
+
retention: stringifyJson(entityTypeSet(manifest.retention)),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Decodes a marker row's stored texts. An empty text is an empty map or set, as an absent
|
|
113
|
+
* attribute is.
|
|
114
|
+
*/
|
|
115
|
+
export function decodeManifest(version: bigint, texts: ManifestTexts): FeatureManifest {
|
|
116
|
+
return {
|
|
117
|
+
version,
|
|
118
|
+
features: texts.features === '' ? new Map() : parseFeatures(texts.features),
|
|
119
|
+
historyDependent: parseEntityTypes(texts.historyDependent, 'historyDependent'),
|
|
120
|
+
retention: parseEntityTypes(texts.retention, 'retention'),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** An entity-type set in its stored order: sorted by UTF-8 bytes and deduplicated. */
|
|
125
|
+
export function entityTypeSet(types: readonly string[]): string[] {
|
|
126
|
+
// A fresh array, sorted in place: ES2022 has no toSorted.
|
|
127
|
+
const set = Array.from(new Set(types))
|
|
128
|
+
set.sort(compareUtf8)
|
|
129
|
+
|
|
130
|
+
return set
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseFeatures(text: string): Map<string, number> {
|
|
134
|
+
const parsed = parseStored(text, 'features')
|
|
135
|
+
if (!(parsed instanceof Map)) {
|
|
136
|
+
throw invalid('the feature manifest is not a JSON object')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Go and Rust read a version as an int64. One past the safe integers is no version this
|
|
140
|
+
// package models, so rounding it changes only the refusal's text.
|
|
141
|
+
const features = new Map<string, number>()
|
|
142
|
+
for (const [name, version] of parsed) {
|
|
143
|
+
features.set(name, Number(int64(version, `the feature manifest's ${name} version`)))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return features
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseEntityTypes(text: string, what: string): string[] {
|
|
150
|
+
if (text.trim() === '') {
|
|
151
|
+
return []
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const parsed = parseStored(text, what)
|
|
155
|
+
if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== 'string')) {
|
|
156
|
+
throw invalid(`the ${what} set is not a JSON array of strings`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return entityTypeSet(parsed as string[])
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseStored(text: string, what: string): JsonValue {
|
|
163
|
+
try {
|
|
164
|
+
return parseJson(text, DOCUMENT)
|
|
165
|
+
} catch (error) {
|
|
166
|
+
throw new EvtError('invalidRecord', `the stored ${what} is not JSON`, {cause: error})
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function sortedFeatures(features: ReadonlyMap<string, number>): [string, number][] {
|
|
171
|
+
const sorted = Array.from(features)
|
|
172
|
+
sorted.sort(([a], [b]) => compareUtf8(a, b))
|
|
173
|
+
|
|
174
|
+
return sorted
|
|
175
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -30,11 +30,19 @@ export {
|
|
|
30
30
|
ConflictError,
|
|
31
31
|
EvtError,
|
|
32
32
|
type EvtErrorCode,
|
|
33
|
+
FeatureManifestChangedError,
|
|
34
|
+
HistoryDependentProjectionError,
|
|
35
|
+
RebuildInProgressError,
|
|
33
36
|
ReplayError,
|
|
34
37
|
ReservedEntityIdError,
|
|
35
38
|
SequenceGapError,
|
|
36
39
|
SnapshotError,
|
|
40
|
+
StoreNotInitializedError,
|
|
37
41
|
StreamHeadDriftError,
|
|
42
|
+
StreamHeadsNotMigratedError,
|
|
43
|
+
TransientError,
|
|
44
|
+
UnsupportedFeatureError,
|
|
45
|
+
type FeaturePair,
|
|
38
46
|
isConflict,
|
|
39
47
|
isRetryable,
|
|
40
48
|
} from './errors.js'
|
|
@@ -47,6 +55,15 @@ export {
|
|
|
47
55
|
encodeSnapshot,
|
|
48
56
|
eventId,
|
|
49
57
|
} from './event.js'
|
|
58
|
+
export {
|
|
59
|
+
type FeatureManifest,
|
|
60
|
+
type ManifestTexts,
|
|
61
|
+
STREAM_HEADS_FEATURE,
|
|
62
|
+
STREAM_HEADS_VERSION,
|
|
63
|
+
SUPPORTED_FEATURES,
|
|
64
|
+
decodeManifest,
|
|
65
|
+
encodeManifest,
|
|
66
|
+
} from './features.js'
|
|
50
67
|
export {JsonNumber, type JsonValue, parseJson, stringifyJson} from './json.js'
|
|
51
68
|
export {
|
|
52
69
|
type Metadata,
|
|
@@ -56,7 +73,13 @@ export {
|
|
|
56
73
|
emptyMetadata,
|
|
57
74
|
encodeMetadata,
|
|
58
75
|
} from './metadata.js'
|
|
59
|
-
export type {
|
|
76
|
+
export type {
|
|
77
|
+
CallOptions,
|
|
78
|
+
Commit,
|
|
79
|
+
NewSnapshot,
|
|
80
|
+
Repository,
|
|
81
|
+
StoreInitializer,
|
|
82
|
+
} from './repository.js'
|
|
60
83
|
export {
|
|
61
84
|
DEFAULT_RETRY_POLICY,
|
|
62
85
|
type RetryPolicy,
|
package/src/memory/index.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
// @photon-grove/evt/memory: the in-memory repository, for tests and local development.
|
|
2
2
|
|
|
3
|
-
export {
|
|
3
|
+
export {
|
|
4
|
+
type ControlFixtures,
|
|
5
|
+
type HeadFixtures,
|
|
6
|
+
type ManifestDraft,
|
|
7
|
+
MemRepository,
|
|
8
|
+
type RebuildState,
|
|
9
|
+
controlFixtures,
|
|
10
|
+
headFixtures,
|
|
11
|
+
} from './repository.js'
|