@photon-grove/evt 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/dist/dynamodb/head.d.ts +6 -0
- package/dist/dynamodb/head.d.ts.map +1 -1
- package/dist/dynamodb/head.js +11 -0
- package/dist/dynamodb/head.js.map +1 -1
- package/dist/dynamodb/index.d.ts +1 -1
- package/dist/dynamodb/index.d.ts.map +1 -1
- package/dist/dynamodb/index.js +1 -1
- package/dist/dynamodb/index.js.map +1 -1
- package/dist/dynamodb/repository.d.ts +9 -2
- package/dist/dynamodb/repository.d.ts.map +1 -1
- package/dist/dynamodb/repository.js +114 -35
- package/dist/dynamodb/repository.js.map +1 -1
- package/dist/dynamodb/requests.d.ts +36 -5
- package/dist/dynamodb/requests.d.ts.map +1 -1
- package/dist/dynamodb/requests.js +56 -13
- package/dist/dynamodb/requests.js.map +1 -1
- package/dist/errors.d.ts +38 -4
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +45 -4
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/memory/repository.d.ts +3 -2
- package/dist/memory/repository.d.ts.map +1 -1
- package/dist/memory/repository.js +78 -29
- package/dist/memory/repository.js.map +1 -1
- package/dist/postgres/repository.d.ts +9 -2
- package/dist/postgres/repository.d.ts.map +1 -1
- package/dist/postgres/repository.js +107 -36
- package/dist/postgres/repository.js.map +1 -1
- package/dist/postgres/schema.d.ts.map +1 -1
- package/dist/postgres/schema.js +16 -0
- package/dist/postgres/schema.js.map +1 -1
- package/dist/repository.d.ts +75 -0
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +89 -3
- package/dist/repository.js.map +1 -1
- package/dist/staging.d.ts +28 -0
- package/dist/staging.d.ts.map +1 -0
- package/dist/staging.js +89 -0
- package/dist/staging.js.map +1 -0
- package/dist/store.d.ts +11 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +30 -71
- package/dist/store.js.map +1 -1
- package/dist/transaction.d.ts +113 -0
- package/dist/transaction.d.ts.map +1 -0
- package/dist/transaction.js +207 -0
- package/dist/transaction.js.map +1 -0
- package/package.json +1 -1
- package/src/dynamodb/head.ts +15 -0
- package/src/dynamodb/index.ts +1 -0
- package/src/dynamodb/repository.ts +162 -41
- package/src/dynamodb/requests.ts +98 -14
- package/src/errors.ts +63 -4
- package/src/index.ts +16 -7
- package/src/memory/repository.ts +93 -32
- package/src/postgres/repository.ts +133 -48
- package/src/postgres/schema.ts +16 -0
- package/src/repository.ts +169 -2
- package/src/staging.ts +138 -0
- package/src/store.ts +47 -96
- package/src/transaction.ts +322 -0
package/src/dynamodb/requests.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// The `TransactWriteItems` actions of a commit and of a snapshot write, built from the storage
|
|
2
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
|
|
4
|
-
// its head update, its event puts, and its snapshot put when it has one.
|
|
3
|
+
// feature marker's and the rebuild lock's, then its projector operations, then writes each stream:
|
|
4
|
+
// its head update, its event puts, and its snapshot put when it has one. A transaction then checks
|
|
5
|
+
// the head of every stream it fenced.
|
|
5
6
|
|
|
6
7
|
import type {SerializedEvent, SerializedSnapshot} from '../event.js'
|
|
7
8
|
import {int64Argument, safeIntegerArgument} from '../fields.js'
|
|
8
9
|
import {EvtError} from '../errors.js'
|
|
9
|
-
import type {Commit} from '../repository.js'
|
|
10
|
+
import type {Commit, NormalizedTransaction, ProjectorGroup, ReadFence} from '../repository.js'
|
|
10
11
|
import type {TransactItem} from './client.js'
|
|
11
|
-
import {type HeadAction, headUpdate} from './head.js'
|
|
12
|
+
import {type HeadAction, headFence, headUpdate} from './head.js'
|
|
12
13
|
import {HEAD_SORT_KEY, type Item, SNAPSHOT_SORT_KEY, eventItem, snapshotItem} from './item.js'
|
|
13
14
|
import {FEATURES_PK, REBUILD_LOCK_PK} from './marker.js'
|
|
14
15
|
import {DYNAMODB_BACKEND} from './views.js'
|
|
@@ -102,8 +103,7 @@ export interface SnapshotTtl {
|
|
|
102
103
|
/**
|
|
103
104
|
* The actions of a commit with at least one event, in order, by a writer that validated the
|
|
104
105
|
* 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
|
|
106
|
-
* signature changes when commits span streams.
|
|
106
|
+
* group for any backend but DynamoDB is refused. Exported for the compatibility fixtures.
|
|
107
107
|
*/
|
|
108
108
|
export function commitTransaction(
|
|
109
109
|
table: string,
|
|
@@ -111,16 +111,83 @@ export function commitTransaction(
|
|
|
111
111
|
markerVersion: bigint,
|
|
112
112
|
ttl?: CommitTtl,
|
|
113
113
|
): TransactItem[] {
|
|
114
|
-
|
|
115
|
-
const last = commit.events.at(-1)
|
|
116
|
-
if (first === undefined || last === undefined) {
|
|
114
|
+
if (commit.events.length === 0) {
|
|
117
115
|
throw new RangeError('a commit transaction needs at least one event')
|
|
118
116
|
}
|
|
119
117
|
|
|
118
|
+
const transaction = {streams: [commit], fences: [], projections: commit.projections ?? []}
|
|
119
|
+
|
|
120
|
+
return transactionItems(table, transaction, markerVersion, () => ttl)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Where each stream's and fence's actions sit in a transaction, to classify a cancellation. */
|
|
124
|
+
export interface TransactionLayout {
|
|
125
|
+
/** The index after the control checks and the projector operations. */
|
|
126
|
+
readonly projectorEnd: number
|
|
127
|
+
readonly streams: readonly StreamLayout[]
|
|
128
|
+
readonly fences: readonly {readonly index: number; readonly fence: ReadFence}[]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A written stream's actions in a transaction. */
|
|
132
|
+
export interface StreamLayout {
|
|
133
|
+
readonly commit: Commit
|
|
134
|
+
/** The head update's index. Its event puts follow it. */
|
|
135
|
+
readonly head: number
|
|
136
|
+
/** The index after its last event put. */
|
|
137
|
+
readonly eventsEnd: number
|
|
138
|
+
/** The snapshot put's index, when it writes one. */
|
|
139
|
+
readonly snapshot: number | null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The actions of a normalized transaction that writes or fences at least one stream, in order:
|
|
144
|
+
* the control checks, the projector operations in declaration order, every written stream's head
|
|
145
|
+
* update, event puts and snapshot put, then a check on every fenced stream's head. `ttl` gives a
|
|
146
|
+
* written stream's `ttl`s when it has retention. Exported for the compatibility fixtures.
|
|
147
|
+
*/
|
|
148
|
+
export function transactionItems(
|
|
149
|
+
table: string,
|
|
150
|
+
transaction: NormalizedTransaction,
|
|
151
|
+
markerVersion: bigint,
|
|
152
|
+
ttl: (commit: Commit) => CommitTtl | undefined,
|
|
153
|
+
): TransactItem[] {
|
|
154
|
+
return buildTransaction(table, transaction, markerVersion, ttl).items
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** {@link transactionItems}, with where each stream's and fence's actions sit. */
|
|
158
|
+
export function buildTransaction(
|
|
159
|
+
table: string,
|
|
160
|
+
transaction: NormalizedTransaction,
|
|
161
|
+
markerVersion: bigint,
|
|
162
|
+
ttl: (commit: Commit) => CommitTtl | undefined,
|
|
163
|
+
): {items: TransactItem[]; layout: TransactionLayout} {
|
|
120
164
|
const items = [markerCheck(table, markerVersion), rebuildLockCheck(table)]
|
|
121
|
-
items.push(...projectorItems(
|
|
165
|
+
items.push(...projectorItems(transaction.projections))
|
|
166
|
+
const projectorEnd = items.length
|
|
167
|
+
|
|
168
|
+
const streams: StreamLayout[] = []
|
|
169
|
+
for (const commit of transaction.streams) {
|
|
170
|
+
const head = items.length
|
|
171
|
+
items.push(...streamItems(table, commit, ttl(commit)))
|
|
172
|
+
const eventsEnd = head + 1 + commit.events.length
|
|
173
|
+
streams.push({commit, head, eventsEnd, snapshot: items.length > eventsEnd ? eventsEnd : null})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const fences = transaction.fences.map((fence) => {
|
|
177
|
+
items.push(fenceCheck(table, fence))
|
|
178
|
+
|
|
179
|
+
return {index: items.length - 1, fence}
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
return {items, layout: {projectorEnd, streams, fences}}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// A written stream's actions: its head update, its event puts and its snapshot put.
|
|
186
|
+
function streamItems(table: string, commit: Commit, ttl: CommitTtl | undefined): TransactItem[] {
|
|
187
|
+
const first = commit.events[0]!
|
|
188
|
+
const last = commit.events.at(-1)!
|
|
122
189
|
const head = headUpdate(commit.entityId, first.sequence, last.sequence, ttl?.head)
|
|
123
|
-
items
|
|
190
|
+
const items = [headUpdateItem(table, head)]
|
|
124
191
|
for (const event of commit.events) {
|
|
125
192
|
items.push(eventPut(table, event, ttl?.items))
|
|
126
193
|
}
|
|
@@ -140,6 +207,23 @@ export function commitTransaction(
|
|
|
140
207
|
return items
|
|
141
208
|
}
|
|
142
209
|
|
|
210
|
+
// The check on a fenced stream's head item: it still has the head the transaction read.
|
|
211
|
+
function fenceCheck(table: string, fence: ReadFence): TransactItem {
|
|
212
|
+
const action = headFence(fence.entityId, fence.head)
|
|
213
|
+
const check: NonNullable<TransactItem['ConditionCheck']> = {
|
|
214
|
+
TableName: table,
|
|
215
|
+
Key: headKey(fence.entityId),
|
|
216
|
+
ConditionExpression: action.conditionExpression,
|
|
217
|
+
}
|
|
218
|
+
if (action.values.length > 0) {
|
|
219
|
+
check.ExpressionAttributeValues = Object.fromEntries(
|
|
220
|
+
action.values.map(([placeholder, value]) => [placeholder, number(value)]),
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {ConditionCheck: check}
|
|
225
|
+
}
|
|
226
|
+
|
|
143
227
|
/**
|
|
144
228
|
* The actions of a snapshot written outside a commit: the marker check, the head guard, and the
|
|
145
229
|
* put, which never lowers the stored snapshot. It carries no rebuild-lock check. `ttl` is given
|
|
@@ -204,10 +288,10 @@ function snapshotGuard(
|
|
|
204
288
|
}
|
|
205
289
|
}
|
|
206
290
|
|
|
207
|
-
/** The DynamoDB actions of
|
|
208
|
-
export function projectorItems(
|
|
291
|
+
/** The DynamoDB actions of projector groups, in order. */
|
|
292
|
+
export function projectorItems(groups: readonly ProjectorGroup[]): TransactItem[] {
|
|
209
293
|
const items: TransactItem[] = []
|
|
210
|
-
for (const group of
|
|
294
|
+
for (const group of groups) {
|
|
211
295
|
if (group.backend !== DYNAMODB_BACKEND) {
|
|
212
296
|
throw new EvtError(
|
|
213
297
|
'invalidCommit',
|
package/src/errors.ts
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
* - `conflict`: optimistic concurrency failed. Another writer committed since the stream was
|
|
16
16
|
* read. Reload and run the command again.
|
|
17
17
|
* - `sequenceGap`: a commit or a load skips a sequence. A conflict.
|
|
18
|
+
* - `fenceConflict`: another writer moved a stream the transaction read but didn't write. A
|
|
19
|
+
* conflict, retryable when the engine observed the fenced head.
|
|
20
|
+
* - `retentionConflict`: a transaction fenced a stream whose entity type has retention, which a
|
|
21
|
+
* fence can't protect. Nothing was written.
|
|
18
22
|
* - `streamHeadDrift`: a stream's head disagrees with its events or snapshot. Never retried.
|
|
19
23
|
* - `commitTooLarge`: the commit is over the transaction budget. Nothing was written.
|
|
20
24
|
* - `compactionUncovered`: no snapshot covers the events compaction was asked to delete.
|
|
@@ -42,6 +46,8 @@ export type EvtErrorCode =
|
|
|
42
46
|
| 'invalidCommit'
|
|
43
47
|
| 'conflict'
|
|
44
48
|
| 'sequenceGap'
|
|
49
|
+
| 'fenceConflict'
|
|
50
|
+
| 'retentionConflict'
|
|
45
51
|
| 'streamHeadDrift'
|
|
46
52
|
| 'commitTooLarge'
|
|
47
53
|
| 'compactionUncovered'
|
|
@@ -283,6 +289,48 @@ export class RebuildInProgressError extends EvtError {
|
|
|
283
289
|
}
|
|
284
290
|
}
|
|
285
291
|
|
|
292
|
+
/** Where a read fence's head came from (ADR 0004): the engine's own read, or the caller. */
|
|
293
|
+
export type FenceProvenance = 'engine' | 'caller'
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* A read fence failed: another writer moved a stream the transaction read but didn't write past
|
|
297
|
+
* the head it observed. A fence the engine filled is retryable, since loading again observes the
|
|
298
|
+
* new head. A caller-stamped one isn't: the caller's input is stale. Nothing was written.
|
|
299
|
+
*/
|
|
300
|
+
export class FenceConflictError extends EvtError {
|
|
301
|
+
override readonly name = 'FenceConflictError'
|
|
302
|
+
override readonly code = 'fenceConflict'
|
|
303
|
+
readonly entityId: string
|
|
304
|
+
/** The head the transaction fenced the stream at. */
|
|
305
|
+
readonly head: bigint
|
|
306
|
+
readonly provenance: FenceProvenance
|
|
307
|
+
|
|
308
|
+
constructor(entityId: string, head: bigint, provenance: FenceProvenance) {
|
|
309
|
+
super('fenceConflict', `read fence failed for ${entityId} at head ${head} (${provenance})`)
|
|
310
|
+
this.entityId = entityId
|
|
311
|
+
this.head = head
|
|
312
|
+
this.provenance = provenance
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* A transaction fenced a stream whose entity type has retention: TTL deletes items in no order, so
|
|
318
|
+
* a fence on a retained stream protects nothing. Nothing was written.
|
|
319
|
+
*/
|
|
320
|
+
export class RetentionConflictError extends EvtError {
|
|
321
|
+
override readonly name = 'RetentionConflictError'
|
|
322
|
+
override readonly code = 'retentionConflict'
|
|
323
|
+
readonly entityType: string
|
|
324
|
+
|
|
325
|
+
constructor(entityType: string) {
|
|
326
|
+
super(
|
|
327
|
+
'retentionConflict',
|
|
328
|
+
`read fences refused for retention-enabled entity type ${entityType}`,
|
|
329
|
+
)
|
|
330
|
+
this.entityType = entityType
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
286
334
|
/**
|
|
287
335
|
* Compaction was refused because the store declares the entity's type history-dependent: its
|
|
288
336
|
* projectors' rebuild replays the events compaction would delete. Nothing was deleted.
|
|
@@ -360,16 +408,27 @@ export class SnapshotError extends EvtError {
|
|
|
360
408
|
}
|
|
361
409
|
}
|
|
362
410
|
|
|
363
|
-
/**
|
|
411
|
+
/**
|
|
412
|
+
* Whether an error is an optimistic-concurrency conflict: a `conflict`, a `sequenceGap` or a
|
|
413
|
+
* `fenceConflict`.
|
|
414
|
+
*/
|
|
364
415
|
export function isConflict(error: unknown): boolean {
|
|
365
|
-
return
|
|
416
|
+
return (
|
|
417
|
+
error instanceof EvtError &&
|
|
418
|
+
(error.code === 'conflict' || error.code === 'sequenceGap' || error.code === 'fenceConflict')
|
|
419
|
+
)
|
|
366
420
|
}
|
|
367
421
|
|
|
368
422
|
/**
|
|
369
|
-
* Whether reloading and running the command again may succeed: after a conflict
|
|
370
|
-
* holds the store, after the feature manifest changed
|
|
423
|
+
* Whether reloading and running the command again may succeed: after a conflict (a fence the
|
|
424
|
+
* caller stamped excepted), while a rebuild holds the store, after the feature manifest changed
|
|
425
|
+
* under the writer, or after throttling.
|
|
371
426
|
*/
|
|
372
427
|
export function isRetryable(error: unknown): boolean {
|
|
428
|
+
// A caller-stamped fence stays stale however often the command runs again.
|
|
429
|
+
if (error instanceof FenceConflictError) {
|
|
430
|
+
return error.provenance === 'engine'
|
|
431
|
+
}
|
|
373
432
|
if (isConflict(error)) {
|
|
374
433
|
return true
|
|
375
434
|
}
|
package/src/index.ts
CHANGED
|
@@ -33,11 +33,14 @@ export {
|
|
|
33
33
|
EvtError,
|
|
34
34
|
type EvtErrorCode,
|
|
35
35
|
FeatureManifestChangedError,
|
|
36
|
+
FenceConflictError,
|
|
37
|
+
type FenceProvenance,
|
|
36
38
|
HistoryDependentProjectionError,
|
|
37
39
|
ProjectorOnlyCommitError,
|
|
38
40
|
RebuildInProgressError,
|
|
39
41
|
ReplayError,
|
|
40
42
|
ReservedEntityIdError,
|
|
43
|
+
RetentionConflictError,
|
|
41
44
|
SequenceGapError,
|
|
42
45
|
SnapshotError,
|
|
43
46
|
StoreNotInitializedError,
|
|
@@ -76,13 +79,18 @@ export {
|
|
|
76
79
|
emptyMetadata,
|
|
77
80
|
encodeMetadata,
|
|
78
81
|
} from './metadata.js'
|
|
79
|
-
export
|
|
80
|
-
CallOptions,
|
|
81
|
-
Commit,
|
|
82
|
-
NewSnapshot,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
export {
|
|
83
|
+
type CallOptions,
|
|
84
|
+
type Commit,
|
|
85
|
+
type NewSnapshot,
|
|
86
|
+
type NormalizedTransaction,
|
|
87
|
+
type ProjectorGroup,
|
|
88
|
+
type ReadFence,
|
|
89
|
+
type Repository,
|
|
90
|
+
type StoreInitializer,
|
|
91
|
+
type TransactionCommit,
|
|
92
|
+
type TransactionalRepository,
|
|
93
|
+
normalizeTransaction,
|
|
86
94
|
} from './repository.js'
|
|
87
95
|
export {
|
|
88
96
|
DEFAULT_RETRY_POLICY,
|
|
@@ -91,6 +99,7 @@ export {
|
|
|
91
99
|
} from './retry.js'
|
|
92
100
|
export {MAX_COMMIT_ITEM_BYTES, snapshotCommitSize, snapshotEventsDue} from './snapshots.js'
|
|
93
101
|
export {compareUtf8, utf8Length} from './utf8.js'
|
|
102
|
+
export {type CallerFence, StreamRead, Transaction, type TransactionResult} from './transaction.js'
|
|
94
103
|
export {
|
|
95
104
|
DEFAULT_SNAPSHOT_INTERVAL,
|
|
96
105
|
type ExecuteOptions,
|
package/src/memory/repository.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
ConflictError,
|
|
8
8
|
CompactionUncoveredError,
|
|
9
9
|
EvtError,
|
|
10
|
+
FenceConflictError,
|
|
10
11
|
HistoryDependentProjectionError,
|
|
11
12
|
RebuildInProgressError,
|
|
12
13
|
SequenceGapError,
|
|
@@ -27,11 +28,17 @@ import type {Metadata} from '../metadata.js'
|
|
|
27
28
|
import {
|
|
28
29
|
type CallOptions,
|
|
29
30
|
type Commit,
|
|
30
|
-
type
|
|
31
|
+
type NormalizedTransaction,
|
|
32
|
+
type ReadFence,
|
|
31
33
|
type StoreInitializer,
|
|
34
|
+
type TransactionCommit,
|
|
35
|
+
type TransactionalRepository,
|
|
32
36
|
checkBudget,
|
|
37
|
+
checkFenceRetention,
|
|
33
38
|
checkNotReserved,
|
|
34
|
-
|
|
39
|
+
normalizeTransaction,
|
|
40
|
+
rankFailures,
|
|
41
|
+
transactionSizes,
|
|
35
42
|
validateCommit,
|
|
36
43
|
} from '../repository.js'
|
|
37
44
|
|
|
@@ -75,7 +82,7 @@ let controlsOf: (repo: MemRepository) => Controls
|
|
|
75
82
|
* on its feature manifest, so {@link initialize} is needed only where code treats every backend
|
|
76
83
|
* alike.
|
|
77
84
|
*/
|
|
78
|
-
export class MemRepository implements
|
|
85
|
+
export class MemRepository implements TransactionalRepository, StoreInitializer {
|
|
79
86
|
readonly #streams = new Map<string, Stream>()
|
|
80
87
|
readonly #controls: Controls = {manifest: null, rebuild: {generation: 0n, lock: null}}
|
|
81
88
|
|
|
@@ -95,44 +102,80 @@ export class MemRepository implements Repository, StoreInitializer {
|
|
|
95
102
|
options?.signal?.throwIfAborted()
|
|
96
103
|
validateCommit(commit)
|
|
97
104
|
checkNotReserved(commit.entityId)
|
|
98
|
-
if (commit.events.length
|
|
99
|
-
|
|
105
|
+
if (commit.events.length > 0) {
|
|
106
|
+
this.#write(normalizeTransaction({streams: [commit]}))
|
|
100
107
|
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async commitTransaction(transaction: TransactionCommit, options?: CallOptions): Promise<void> {
|
|
111
|
+
options?.signal?.throwIfAborted()
|
|
112
|
+
const normalized = normalizeTransaction(transaction)
|
|
113
|
+
if (normalized.streams.length > 0 || normalized.fences.length > 0) {
|
|
114
|
+
this.#write(normalized)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
101
117
|
|
|
118
|
+
// Writes a transaction, or refuses it with nothing written, in the order the live backends
|
|
119
|
+
// check: the budget, the marker and the fences' retention, the rebuild lock, then every stream
|
|
120
|
+
// and fence, ranked.
|
|
121
|
+
#write(transaction: NormalizedTransaction): void {
|
|
102
122
|
// Every backend budgets a commit the same way, so an oversized one fails before any write.
|
|
103
|
-
checkBudget(
|
|
123
|
+
checkBudget(transactionSizes(transaction, () => undefined))
|
|
104
124
|
|
|
105
125
|
// The control rows come first, as the two checks that open every live commit: the marker,
|
|
106
126
|
// then the rebuild lock, which an unsettled generation fails, a force-released one included.
|
|
107
127
|
this.#checkManifest()
|
|
128
|
+
const retention = this.#controls.manifest?.retention ?? []
|
|
129
|
+
checkFenceRetention(transaction, (entityType) => retention.includes(entityType))
|
|
108
130
|
const {rebuild} = this.#controls
|
|
109
131
|
if (rebuild.lock !== null || rebuild.generation % 2n !== 0n) {
|
|
110
132
|
throw new RebuildInProgressError()
|
|
111
133
|
}
|
|
112
134
|
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const snapshot = commit.snapshot && {
|
|
120
|
-
entityType: commit.snapshot.entityType,
|
|
121
|
-
entityId: commit.entityId,
|
|
122
|
-
sequence: commit.snapshot.sequence,
|
|
123
|
-
eventSequence: last,
|
|
124
|
-
payload: copy(commit.snapshot.payload),
|
|
135
|
+
const failures: EvtError[] = []
|
|
136
|
+
for (const stream of transaction.streams) {
|
|
137
|
+
const failure = checkWrite(stream, this.#streams.get(stream.entityId) ?? emptyStream())
|
|
138
|
+
if (failure !== null) {
|
|
139
|
+
failures.push(failure)
|
|
140
|
+
}
|
|
125
141
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
142
|
+
for (const fence of transaction.fences) {
|
|
143
|
+
const failure = checkFence(fence, this.#streams.get(fence.entityId) ?? emptyStream())
|
|
144
|
+
if (failure !== null) {
|
|
145
|
+
failures.push(failure)
|
|
146
|
+
}
|
|
129
147
|
}
|
|
130
|
-
|
|
131
|
-
if (
|
|
132
|
-
|
|
148
|
+
const failure = rankFailures(failures)
|
|
149
|
+
if (failure !== undefined) {
|
|
150
|
+
throw failure
|
|
133
151
|
}
|
|
134
152
|
|
|
135
|
-
|
|
153
|
+
// Copy everything before the first write, so a copy that throws leaves every stream as it was.
|
|
154
|
+
const writes = transaction.streams.map((commit) => {
|
|
155
|
+
const events = commit.events.map(cloneEvent)
|
|
156
|
+
const last = events.at(-1)!.sequence
|
|
157
|
+
const snapshot = commit.snapshot && {
|
|
158
|
+
entityType: commit.snapshot.entityType,
|
|
159
|
+
entityId: commit.entityId,
|
|
160
|
+
sequence: commit.snapshot.sequence,
|
|
161
|
+
eventSequence: last,
|
|
162
|
+
payload: copy(commit.snapshot.payload),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {entityId: commit.entityId, events, last, snapshot}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
for (const {entityId, events, last, snapshot} of writes) {
|
|
169
|
+
const held = this.#streams.get(entityId) ?? emptyStream()
|
|
170
|
+
for (const event of events) {
|
|
171
|
+
held.events.set(event.sequence, event)
|
|
172
|
+
}
|
|
173
|
+
held.head = held.head > last ? held.head : last
|
|
174
|
+
if (snapshot) {
|
|
175
|
+
held.snapshot = snapshot
|
|
176
|
+
}
|
|
177
|
+
this.#streams.set(entityId, held)
|
|
178
|
+
}
|
|
136
179
|
}
|
|
137
180
|
|
|
138
181
|
async events(entityId: string, options?: CallOptions): Promise<SerializedEvent[]> {
|
|
@@ -272,29 +315,47 @@ export class MemRepository implements Repository, StoreInitializer {
|
|
|
272
315
|
}
|
|
273
316
|
}
|
|
274
317
|
|
|
275
|
-
//
|
|
276
|
-
// head update needs no head for a create and the head to be the sequence before
|
|
277
|
-
// for an append. Then no event may exist already, and a snapshot must follow the
|
|
278
|
-
|
|
318
|
+
// The failure of a commit the stored stream doesn't accept, as the live backends' conditions find
|
|
319
|
+
// it, or null. The head update needs no head for a create and the head to be the sequence before
|
|
320
|
+
// the first event for an append. Then no event may exist already, and a snapshot must follow the
|
|
321
|
+
// stored one.
|
|
322
|
+
function checkWrite(commit: Commit, held: Stream): EvtError | null {
|
|
279
323
|
const first = commit.events[0]!.sequence
|
|
280
324
|
const headPasses = first <= 1n ? held.head === 0n : held.head === first - 1n
|
|
281
325
|
if (!headPasses) {
|
|
282
|
-
|
|
326
|
+
return probe(commit.entityId, held, first)
|
|
283
327
|
}
|
|
284
328
|
|
|
285
329
|
// The head update passed and an event put would fail: an event sits above the head.
|
|
286
330
|
if (commit.events.some((event) => held.events.has(event.sequence))) {
|
|
287
|
-
|
|
331
|
+
return drift(commit.entityId, held.head, derivedHead(held))
|
|
288
332
|
}
|
|
289
333
|
|
|
290
334
|
const snapshot = commit.snapshot
|
|
291
335
|
const stored = held.snapshot?.sequence ?? 0n
|
|
292
336
|
if (snapshot && snapshot.sequence !== stored + 1n) {
|
|
293
|
-
|
|
337
|
+
return new ConflictError(
|
|
294
338
|
commit.entityId,
|
|
295
339
|
`snapshot sequence moved: stored ${stored}, commit writes ${snapshot.sequence}`,
|
|
296
340
|
)
|
|
297
341
|
}
|
|
342
|
+
|
|
343
|
+
return null
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// The failure of a fence whose stream's head moved, or null: drift when the head disagrees with
|
|
347
|
+
// the log, and otherwise a fence conflict.
|
|
348
|
+
function checkFence(fence: ReadFence, held: Stream): EvtError | null {
|
|
349
|
+
if (held.head === fence.head) {
|
|
350
|
+
return null
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const derived = derivedHead(held)
|
|
354
|
+
if (held.head !== derived) {
|
|
355
|
+
return drift(fence.entityId, held.head, derived)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return new FenceConflictError(fence.entityId, fence.head, fence.provenance)
|
|
298
359
|
}
|
|
299
360
|
|
|
300
361
|
// Classifies a failed head update the way the live backends' failure-path probe does: a stored
|