@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
package/src/memory/repository.ts
CHANGED
|
@@ -1,21 +1,34 @@
|
|
|
1
1
|
// The in-memory repository. It enforces the storage contract the way the live backends do: a head
|
|
2
|
-
// per stream that every commit raises, optimistic concurrency against it,
|
|
3
|
-
// classification and transaction budget. It's for
|
|
2
|
+
// per stream that every commit raises, optimistic concurrency against it, the feature marker and
|
|
3
|
+
// the rebuild-lock check, and the same failure classification and transaction budget. It's for
|
|
4
|
+
// tests and local development.
|
|
4
5
|
|
|
5
6
|
import {
|
|
6
7
|
ConflictError,
|
|
7
8
|
CompactionUncoveredError,
|
|
8
9
|
EvtError,
|
|
10
|
+
HistoryDependentProjectionError,
|
|
11
|
+
RebuildInProgressError,
|
|
9
12
|
SequenceGapError,
|
|
10
13
|
StreamHeadDriftError,
|
|
14
|
+
StreamHeadsNotMigratedError,
|
|
11
15
|
} from '../errors.js'
|
|
12
16
|
import type {SerializedEvent, SerializedSnapshot} from '../event.js'
|
|
17
|
+
import {
|
|
18
|
+
type FeatureManifest,
|
|
19
|
+
checkSupported,
|
|
20
|
+
checkWritable,
|
|
21
|
+
declaresStreamHeads,
|
|
22
|
+
entityTypeSet,
|
|
23
|
+
withStreamHeads,
|
|
24
|
+
} from '../features.js'
|
|
13
25
|
import {int64Argument} from '../fields.js'
|
|
14
26
|
import type {Metadata} from '../metadata.js'
|
|
15
27
|
import {
|
|
16
28
|
type CallOptions,
|
|
17
29
|
type Commit,
|
|
18
30
|
type Repository,
|
|
31
|
+
type StoreInitializer,
|
|
19
32
|
checkBudget,
|
|
20
33
|
checkNotReserved,
|
|
21
34
|
commitSizes,
|
|
@@ -30,8 +43,26 @@ interface Stream {
|
|
|
30
43
|
head: bigint
|
|
31
44
|
}
|
|
32
45
|
|
|
33
|
-
|
|
46
|
+
/** The state of the rebuild fence's two rows that a commit's lock check reads. */
|
|
47
|
+
export interface RebuildState {
|
|
48
|
+
/** The generation counter: odd while a rebuild holds the store or after a force-release. */
|
|
49
|
+
generation: bigint
|
|
50
|
+
/**
|
|
51
|
+
* The lock row: a rebuild's lease, the item a force-release leaves in its place, or none. A
|
|
52
|
+
* commit is refused while either exists, and while the generation is odd.
|
|
53
|
+
*/
|
|
54
|
+
lock: 'held' | 'forceReleased' | null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The store-wide control rows: the feature marker and the rebuild fence.
|
|
58
|
+
interface Controls {
|
|
59
|
+
manifest: FeatureManifest | null
|
|
60
|
+
rebuild: RebuildState
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Read a repository's private state, for the fixtures. The class's static block sets them.
|
|
34
64
|
let streamsOf: (repo: MemRepository) => Map<string, Stream>
|
|
65
|
+
let controlsOf: (repo: MemRepository) => Controls
|
|
35
66
|
|
|
36
67
|
/**
|
|
37
68
|
* An in-memory {@link Repository}. Every call copies what it stores and what it returns, so a
|
|
@@ -39,9 +70,26 @@ let streamsOf: (repo: MemRepository) => Map<string, Stream>
|
|
|
39
70
|
*
|
|
40
71
|
* Each call runs to completion before another starts, so concurrent commits to one stream behave
|
|
41
72
|
* like racing writers on a live backend: one wins and the rest fail with a conflict.
|
|
73
|
+
*
|
|
74
|
+
* A fresh store is initialized by its first commit or snapshot write, which declares stream-heads
|
|
75
|
+
* on its feature manifest, so {@link initialize} is needed only where code treats every backend
|
|
76
|
+
* alike.
|
|
42
77
|
*/
|
|
43
|
-
export class MemRepository implements Repository {
|
|
78
|
+
export class MemRepository implements Repository, StoreInitializer {
|
|
44
79
|
readonly #streams = new Map<string, Stream>()
|
|
80
|
+
readonly #controls: Controls = {manifest: null, rebuild: {generation: 0n, lock: null}}
|
|
81
|
+
|
|
82
|
+
async initialize(options?: CallOptions): Promise<boolean> {
|
|
83
|
+
options?.signal?.throwIfAborted()
|
|
84
|
+
checkSupported(this.#controls.manifest)
|
|
85
|
+
if (declaresStreamHeads(this.#controls.manifest)) {
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.#declareStreamHeads()
|
|
90
|
+
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
45
93
|
|
|
46
94
|
async commit(commit: Commit, options?: CallOptions): Promise<void> {
|
|
47
95
|
options?.signal?.throwIfAborted()
|
|
@@ -54,6 +102,14 @@ export class MemRepository implements Repository {
|
|
|
54
102
|
// Every backend budgets a commit the same way, so an oversized one fails before any write.
|
|
55
103
|
checkBudget(commitSizes(commit, () => undefined))
|
|
56
104
|
|
|
105
|
+
// The control rows come first, as the two checks that open every live commit: the marker,
|
|
106
|
+
// then the rebuild lock, which an unsettled generation fails, a force-released one included.
|
|
107
|
+
this.#checkManifest()
|
|
108
|
+
const {rebuild} = this.#controls
|
|
109
|
+
if (rebuild.lock !== null || rebuild.generation % 2n !== 0n) {
|
|
110
|
+
throw new RebuildInProgressError()
|
|
111
|
+
}
|
|
112
|
+
|
|
57
113
|
const held = this.#streams.get(commit.entityId) ?? emptyStream()
|
|
58
114
|
checkWrite(commit, held)
|
|
59
115
|
|
|
@@ -120,6 +176,7 @@ export class MemRepository implements Repository {
|
|
|
120
176
|
checkNotReserved(snapshot.entityId)
|
|
121
177
|
int64Argument(snapshot.sequence, 'snapshot.sequence')
|
|
122
178
|
int64Argument(snapshot.eventSequence, 'snapshot.eventSequence')
|
|
179
|
+
this.#checkManifest()
|
|
123
180
|
|
|
124
181
|
// The stream's head must cover the snapshot. One of an empty stream (event sequence 0) may
|
|
125
182
|
// have no head at all.
|
|
@@ -153,12 +210,22 @@ export class MemRepository implements Repository {
|
|
|
153
210
|
return 0
|
|
154
211
|
}
|
|
155
212
|
|
|
213
|
+
// Compaction validates the manifest, but never declares stream-heads itself.
|
|
214
|
+
const {manifest} = this.#controls
|
|
215
|
+
checkWritable(manifest)
|
|
216
|
+
|
|
156
217
|
const stream = this.#streams.get(entityId)
|
|
157
|
-
const
|
|
158
|
-
|
|
218
|
+
const snapshot = stream?.snapshot
|
|
219
|
+
const covered = snapshot?.eventSequence ?? null
|
|
220
|
+
if (stream === undefined || !snapshot || covered === null || covered < through) {
|
|
159
221
|
throw new CompactionUncoveredError(entityId, through, covered)
|
|
160
222
|
}
|
|
161
223
|
|
|
224
|
+
// A history-dependent type's rebuild replays the events compaction would delete.
|
|
225
|
+
if (manifest?.historyDependent.includes(snapshot.entityType)) {
|
|
226
|
+
throw new HistoryDependentProjectionError(snapshot.entityType, entityId)
|
|
227
|
+
}
|
|
228
|
+
|
|
162
229
|
let deleted = 0
|
|
163
230
|
for (const sequence of stream.events.keys()) {
|
|
164
231
|
if (sequence <= through) {
|
|
@@ -172,6 +239,27 @@ export class MemRepository implements Repository {
|
|
|
172
239
|
|
|
173
240
|
static {
|
|
174
241
|
streamsOf = (repo) => repo.#streams
|
|
242
|
+
controlsOf = (repo) => repo.#controls
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Refuses a write to a store whose manifest declares an unsupported pair, and declares
|
|
246
|
+
// stream-heads on a store without it, as its first writer, when the store holds no data.
|
|
247
|
+
#checkManifest(): void {
|
|
248
|
+
checkSupported(this.#controls.manifest)
|
|
249
|
+
if (!declaresStreamHeads(this.#controls.manifest)) {
|
|
250
|
+
this.#declareStreamHeads()
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#declareStreamHeads(): void {
|
|
255
|
+
const holdsData = Array.from(this.#streams.values()).some(
|
|
256
|
+
(stream) => stream.events.size > 0 || stream.snapshot !== null,
|
|
257
|
+
)
|
|
258
|
+
if (holdsData) {
|
|
259
|
+
throw new StreamHeadsNotMigratedError()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
this.#controls.manifest = withStreamHeads(this.#controls.manifest)
|
|
175
263
|
}
|
|
176
264
|
|
|
177
265
|
// The stream's events by sequence, ascending or descending. Sequences are unique.
|
|
@@ -282,6 +370,68 @@ export function headFixtures(repo: MemRepository): HeadFixtures {
|
|
|
282
370
|
}
|
|
283
371
|
}
|
|
284
372
|
|
|
373
|
+
/** A feature manifest's declarable parts, which a declaration changes in place. */
|
|
374
|
+
export interface ManifestDraft {
|
|
375
|
+
features: Map<string, number>
|
|
376
|
+
historyDependent: string[]
|
|
377
|
+
retention: string[]
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** What a conformance suite needs to declare features and hold the rebuild lock. */
|
|
381
|
+
export interface ControlFixtures {
|
|
382
|
+
/** The stored manifest, or null when the store has no marker row. */
|
|
383
|
+
manifest(): FeatureManifest | null
|
|
384
|
+
/** Changes the manifest in one declaration, bumping its version by one, and returns it. */
|
|
385
|
+
declare(change: (draft: ManifestDraft) => void): FeatureManifest
|
|
386
|
+
/** Sets the rebuild fence's rows, as a rebuild's lifecycle would. */
|
|
387
|
+
setRebuild(state: RebuildState): void
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Hooks that change a repository's control rows, for conformance suites only. A declaration may
|
|
392
|
+
* declare features this package doesn't model, which then refuse every write.
|
|
393
|
+
*/
|
|
394
|
+
export function controlFixtures(repo: MemRepository): ControlFixtures {
|
|
395
|
+
const controls = controlsOf(repo)
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
manifest() {
|
|
399
|
+
return controls.manifest && cloneManifest(controls.manifest)
|
|
400
|
+
},
|
|
401
|
+
declare(change) {
|
|
402
|
+
const draft: ManifestDraft = {
|
|
403
|
+
features: new Map(controls.manifest?.features),
|
|
404
|
+
historyDependent: [...(controls.manifest?.historyDependent ?? [])],
|
|
405
|
+
retention: [...(controls.manifest?.retention ?? [])],
|
|
406
|
+
}
|
|
407
|
+
change(draft)
|
|
408
|
+
|
|
409
|
+
// Stored as the marker row stores them: each set sorted and deduplicated.
|
|
410
|
+
controls.manifest = {
|
|
411
|
+
version: (controls.manifest?.version ?? 0n) + 1n,
|
|
412
|
+
features: new Map(draft.features),
|
|
413
|
+
historyDependent: entityTypeSet(draft.historyDependent),
|
|
414
|
+
retention: entityTypeSet(draft.retention),
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return cloneManifest(controls.manifest)
|
|
418
|
+
},
|
|
419
|
+
setRebuild(state) {
|
|
420
|
+
int64Argument(state.generation, 'generation')
|
|
421
|
+
controls.rebuild = {...state}
|
|
422
|
+
},
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function cloneManifest(manifest: FeatureManifest): FeatureManifest {
|
|
427
|
+
return {
|
|
428
|
+
version: manifest.version,
|
|
429
|
+
features: new Map(manifest.features),
|
|
430
|
+
historyDependent: [...manifest.historyDependent],
|
|
431
|
+
retention: [...manifest.retention],
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
285
435
|
function emptyStream(): Stream {
|
|
286
436
|
return {events: new Map(), snapshot: null, head: 0n}
|
|
287
437
|
}
|
package/src/repository.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import {headActionSize, headUpdate} from './dynamodb/head.js'
|
|
5
5
|
import {eventItem, itemSize, snapshotItem} from './dynamodb/item.js'
|
|
6
|
+
import {FEATURES_PK, REBUILD_LOCK_PK} from './dynamodb/marker.js'
|
|
6
7
|
import {CommitTooLargeError, EvtError, ReservedEntityIdError} from './errors.js'
|
|
7
8
|
import {type SerializedEvent, type SerializedSnapshot, eventId} from './event.js'
|
|
8
9
|
import {MAX_COMMIT_ITEM_BYTES} from './snapshots.js'
|
|
@@ -31,7 +32,11 @@ export interface Commit {
|
|
|
31
32
|
|
|
32
33
|
/** Options every repository call takes. */
|
|
33
34
|
export interface CallOptions {
|
|
34
|
-
/**
|
|
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
|
+
*/
|
|
35
40
|
signal?: AbortSignal | undefined
|
|
36
41
|
}
|
|
37
42
|
|
|
@@ -50,6 +55,16 @@ export interface CallOptions {
|
|
|
50
55
|
* stored) with a {@link ConflictError};
|
|
51
56
|
* - write a commit's events, head and snapshot atomically, or nothing;
|
|
52
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};
|
|
53
68
|
* - return events in ascending sequence order, and `latest` in descending order.
|
|
54
69
|
*/
|
|
55
70
|
export interface Repository {
|
|
@@ -88,6 +103,22 @@ export interface Repository {
|
|
|
88
103
|
compactBelow(entityId: string, through: bigint, options?: CallOptions): Promise<number>
|
|
89
104
|
}
|
|
90
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
|
+
|
|
91
122
|
/** The most actions one commit may carry, DynamoDB's transaction limit. */
|
|
92
123
|
export const MAX_COMMIT_ACTIONS = 100
|
|
93
124
|
|
|
@@ -100,10 +131,6 @@ export const RESERVED_PREFIXES: readonly string[] = Object.freeze([
|
|
|
100
131
|
'command-outcome#',
|
|
101
132
|
])
|
|
102
133
|
|
|
103
|
-
// The control rows every commit checks: the feature marker and the rebuild lock.
|
|
104
|
-
const FEATURES_ID = 'SYSTEM#FEATURES'
|
|
105
|
-
const REBUILD_LOCK_ID = 'SYSTEM#REBUILD'
|
|
106
|
-
|
|
107
134
|
/** Whether an entity ID is in a reserved namespace. */
|
|
108
135
|
export function isReserved(entityId: string): boolean {
|
|
109
136
|
return RESERVED_PREFIXES.some((prefix) => entityId.startsWith(prefix))
|
|
@@ -188,7 +215,8 @@ export function commitSizes(
|
|
|
188
215
|
commit: Commit,
|
|
189
216
|
ttl: (entityType: string) => number | undefined,
|
|
190
217
|
): number[] {
|
|
191
|
-
|
|
218
|
+
// The control rows every commit checks: the feature marker and the rebuild lock.
|
|
219
|
+
const sizes = [controlCheckSize(FEATURES_PK), controlCheckSize(REBUILD_LOCK_PK)]
|
|
192
220
|
const first = commit.events[0]
|
|
193
221
|
const last = commit.events.at(-1)
|
|
194
222
|
if (first === undefined || last === undefined) {
|
package/src/store.ts
CHANGED
|
@@ -182,9 +182,9 @@ export class Store {
|
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
/**
|
|
185
|
-
* Like {@link execute}, but loads and runs the command again after a retryable error (
|
|
186
|
-
*
|
|
187
|
-
* error is thrown. A rejection or a duplicate is a result, never retried.
|
|
185
|
+
* Like {@link execute}, but loads and runs the command again after a retryable error (see
|
|
186
|
+
* {@link isRetryable}), waiting {@link retryDelay} between attempts. When the attempts run out,
|
|
187
|
+
* the last error is thrown. A rejection or a duplicate is a result, never retried.
|
|
188
188
|
*/
|
|
189
189
|
async executeWithRetry<S, C, D extends EventDefinitions>(
|
|
190
190
|
aggregate: Aggregate<S, C, D>,
|