@photon-grove/evt 0.1.1 → 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 +51 -4
- package/dist/aggregate.d.ts +26 -0
- package/dist/aggregate.d.ts.map +1 -1
- package/dist/aggregate.js.map +1 -1
- 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 +2 -1
- package/dist/dynamodb/index.d.ts.map +1 -1
- package/dist/dynamodb/index.js +2 -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 +123 -27
- package/dist/dynamodb/repository.js.map +1 -1
- package/dist/dynamodb/requests.d.ts +37 -3
- package/dist/dynamodb/requests.d.ts.map +1 -1
- package/dist/dynamodb/requests.js +67 -9
- package/dist/dynamodb/requests.js.map +1 -1
- package/dist/dynamodb/views.d.ts +18 -0
- package/dist/dynamodb/views.d.ts.map +1 -0
- package/dist/dynamodb/views.js +56 -0
- package/dist/dynamodb/views.js.map +1 -0
- package/dist/errors.d.ts +51 -4
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +60 -4
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +4 -3
- 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/client.d.ts +32 -0
- package/dist/postgres/client.d.ts.map +1 -0
- package/dist/postgres/client.js +4 -0
- package/dist/postgres/client.js.map +1 -0
- package/dist/postgres/index.d.ts +4 -0
- package/dist/postgres/index.d.ts.map +1 -0
- package/dist/postgres/index.js +6 -0
- package/dist/postgres/index.js.map +1 -0
- package/dist/postgres/repository.d.ts +62 -0
- package/dist/postgres/repository.d.ts.map +1 -0
- package/dist/postgres/repository.js +598 -0
- package/dist/postgres/repository.js.map +1 -0
- package/dist/postgres/schema.d.ts +81 -0
- package/dist/postgres/schema.d.ts.map +1 -0
- package/dist/postgres/schema.js +249 -0
- package/dist/postgres/schema.js.map +1 -0
- package/dist/repository.d.ts +101 -6
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +108 -6
- 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 +15 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +38 -54
- 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 +5 -1
- package/src/aggregate.ts +28 -0
- package/src/dynamodb/head.ts +15 -0
- package/src/dynamodb/index.ts +2 -0
- package/src/dynamodb/repository.ts +177 -33
- package/src/dynamodb/requests.ts +114 -10
- package/src/dynamodb/views.ts +67 -0
- package/src/errors.ts +86 -4
- package/src/index.ts +19 -6
- package/src/memory/repository.ts +93 -32
- package/src/postgres/client.ts +37 -0
- package/src/postgres/index.ts +23 -0
- package/src/postgres/repository.ts +858 -0
- package/src/postgres/schema.ts +302 -0
- package/src/repository.ts +220 -8
- package/src/staging.ts +138 -0
- package/src/store.ts +66 -65
- package/src/transaction.ts +322 -0
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
ConflictError,
|
|
15
15
|
EvtError,
|
|
16
16
|
FeatureManifestChangedError,
|
|
17
|
+
FenceConflictError,
|
|
17
18
|
HistoryDependentProjectionError,
|
|
18
19
|
RebuildInProgressError,
|
|
19
20
|
SequenceGapError,
|
|
@@ -34,12 +35,17 @@ import {int64Argument} from '../fields.js'
|
|
|
34
35
|
import {
|
|
35
36
|
type CallOptions,
|
|
36
37
|
type Commit,
|
|
37
|
-
type
|
|
38
|
+
type NormalizedTransaction,
|
|
38
39
|
type StoreInitializer,
|
|
40
|
+
type TransactionCommit,
|
|
41
|
+
type TransactionalRepository,
|
|
39
42
|
checkBudget,
|
|
43
|
+
checkFenceRetention,
|
|
40
44
|
checkNotReserved,
|
|
41
|
-
commitSizes,
|
|
42
45
|
isReserved,
|
|
46
|
+
normalizeTransaction,
|
|
47
|
+
rankFailures,
|
|
48
|
+
transactionSizes,
|
|
43
49
|
validateCommit,
|
|
44
50
|
} from '../repository.js'
|
|
45
51
|
import type {DynamoClient, Page, RequestOptions, TransactItem} from './client.js'
|
|
@@ -54,11 +60,15 @@ import {
|
|
|
54
60
|
ttlFromItem,
|
|
55
61
|
} from './item.js'
|
|
56
62
|
import {FEATURES_PK, manifestFromItem, markerItem} from './marker.js'
|
|
63
|
+
import {DYNAMODB_BACKEND, transactItemSize} from './views.js'
|
|
57
64
|
import {
|
|
58
65
|
type CommitTtl,
|
|
59
|
-
|
|
66
|
+
type StreamLayout,
|
|
67
|
+
type TransactionLayout,
|
|
60
68
|
controlKey,
|
|
61
69
|
headKey,
|
|
70
|
+
projectorItems,
|
|
71
|
+
buildTransaction,
|
|
62
72
|
snapshotTransaction,
|
|
63
73
|
} from './requests.js'
|
|
64
74
|
|
|
@@ -119,7 +129,7 @@ const MAX_SNAPSHOT_GUARD_RETRIES = 4
|
|
|
119
129
|
* declares a feature, the next write fails, validates the manifest again, and either refuses an
|
|
120
130
|
* unsupported feature or fails once with a retryable {@link FeatureManifestChangedError}.
|
|
121
131
|
*/
|
|
122
|
-
export class DynamoRepository implements
|
|
132
|
+
export class DynamoRepository implements TransactionalRepository, StoreInitializer {
|
|
123
133
|
readonly #client: DynamoClient
|
|
124
134
|
readonly #table: string
|
|
125
135
|
readonly #lazy: boolean
|
|
@@ -185,24 +195,64 @@ export class DynamoRepository implements Repository, StoreInitializer {
|
|
|
185
195
|
options?.signal?.throwIfAborted()
|
|
186
196
|
validateCommit(commit)
|
|
187
197
|
checkNotReserved(commit.entityId)
|
|
188
|
-
if (commit.events.length
|
|
189
|
-
|
|
198
|
+
if (commit.events.length > 0) {
|
|
199
|
+
await this.#write(normalizeTransaction({streams: [commit]}), options)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Commits every stream and checks every fence in one `TransactWriteItems` call: the control
|
|
205
|
+
* checks, the projector operations, each written stream's actions, then a check on each fenced
|
|
206
|
+
* stream's head. A fence on a type this repository retains is refused before any request, and
|
|
207
|
+
* one on a type the store's manifest retains after the marker's validation.
|
|
208
|
+
*/
|
|
209
|
+
async commitTransaction(transaction: TransactionCommit, options?: CallOptions): Promise<void> {
|
|
210
|
+
options?.signal?.throwIfAborted()
|
|
211
|
+
const normalized = normalizeTransaction(transaction)
|
|
212
|
+
if (normalized.streams.length > 0 || normalized.fences.length > 0) {
|
|
213
|
+
await this.#write(normalized, options)
|
|
190
214
|
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async #write(transaction: NormalizedTransaction, options?: CallOptions): Promise<void> {
|
|
218
|
+
// One `ttl` per entity type for the whole transaction, so a stream's head's is never below one
|
|
219
|
+
// of its own items.
|
|
220
|
+
const ttls = new Map<string, number | undefined>()
|
|
221
|
+
const ttlFor = (entityType: string): number | undefined => {
|
|
222
|
+
if (!ttls.has(entityType)) {
|
|
223
|
+
ttls.set(entityType, this.#ttlFor(entityType))
|
|
224
|
+
}
|
|
191
225
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const ttl = this.#ttlFor(entityType)
|
|
226
|
+
return ttls.get(entityType)
|
|
227
|
+
}
|
|
195
228
|
|
|
196
229
|
// Every backend budgets a commit the same way, so an oversized one fails before any request.
|
|
197
|
-
|
|
230
|
+
// The projector operations are sized from the actions this repository sends, which refuses a
|
|
231
|
+
// group for another backend here too.
|
|
232
|
+
const sizes = projectorItems(transaction.projections).map(transactItemSize)
|
|
233
|
+
const projections = [{backend: DYNAMODB_BACKEND, operations: sizes, sizes}]
|
|
234
|
+
checkBudget(transactionSizes({...transaction, projections}, ttlFor))
|
|
235
|
+
|
|
236
|
+
// A fence on a type this repository retains needs no request to refuse.
|
|
237
|
+
checkFenceRetention(transaction, (entityType) => this.#retention.has(entityType))
|
|
198
238
|
|
|
199
239
|
const request = requestOptions(options)
|
|
200
240
|
const version = await this.#markerVersion(request)
|
|
201
|
-
|
|
241
|
+
if (transaction.fences.length > 0) {
|
|
242
|
+
// The store's manifest may retain a type this repository doesn't. The marker check holds it
|
|
243
|
+
// at the version read here, or fails the transaction.
|
|
244
|
+
const manifest = await this.#readManifest(request)
|
|
245
|
+
const retention = manifest?.retention ?? []
|
|
246
|
+
checkFenceRetention(transaction, (entityType) => retention.includes(entityType))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const {items, layout} = buildTransaction(this.#table, transaction, version, (commit) =>
|
|
250
|
+
this.#commitTtl(commit, ttlFor(commit.events[0]!.entityType)),
|
|
251
|
+
)
|
|
202
252
|
try {
|
|
203
253
|
await this.#client.transactWriteItems({TransactItems: items}, request)
|
|
204
254
|
} catch (error) {
|
|
205
|
-
throw await this.#classifyCommit(
|
|
255
|
+
throw await this.#classifyCommit(layout, error, request)
|
|
206
256
|
}
|
|
207
257
|
}
|
|
208
258
|
|
|
@@ -290,6 +340,12 @@ export class DynamoRepository implements Repository, StoreInitializer {
|
|
|
290
340
|
if (codes?.some((code) => THROTTLED.has(code))) {
|
|
291
341
|
throw new TransientError('the snapshot write was throttled', {cause: error})
|
|
292
342
|
}
|
|
343
|
+
if (codes?.includes('TransactionConflict')) {
|
|
344
|
+
throw new TransientError(
|
|
345
|
+
`transaction conflict on ${snapshot.entityId}: another writer was committing at the same time`,
|
|
346
|
+
{cause: error},
|
|
347
|
+
)
|
|
348
|
+
}
|
|
293
349
|
|
|
294
350
|
throw error
|
|
295
351
|
}
|
|
@@ -513,7 +569,14 @@ export class DynamoRepository implements Repository, StoreInitializer {
|
|
|
513
569
|
return new FeatureManifestChangedError({cause})
|
|
514
570
|
}
|
|
515
571
|
|
|
516
|
-
|
|
572
|
+
// Maps a cancelled transaction to the error it reports. The control checks outrank everything.
|
|
573
|
+
// Then every failed stream and fence is classified, and the ranking reports the first drift, else
|
|
574
|
+
// the first gap, else the first other failure, in transaction order.
|
|
575
|
+
async #classifyCommit(
|
|
576
|
+
layout: TransactionLayout,
|
|
577
|
+
error: unknown,
|
|
578
|
+
request: RequestOptions,
|
|
579
|
+
): Promise<unknown> {
|
|
517
580
|
const codes = cancellationCodes(error)
|
|
518
581
|
if (codes === null) {
|
|
519
582
|
return error
|
|
@@ -521,10 +584,14 @@ export class DynamoRepository implements Repository, StoreInitializer {
|
|
|
521
584
|
|
|
522
585
|
const failed = codes.indexOf(CONDITION_FAILED)
|
|
523
586
|
if (failed === -1) {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
587
|
+
// Another transaction was writing one of the items, perhaps one this commit never changes
|
|
588
|
+
// (a control check): nothing was written and nothing is known to be stale, so it is
|
|
589
|
+
// transient, like throttling, and never a conflict, as in Go and Rust.
|
|
590
|
+
const colliding = codes.indexOf('TransactionConflict')
|
|
591
|
+
if (colliding !== -1) {
|
|
592
|
+
return new TransientError(
|
|
593
|
+
`transaction conflict on ${entityAt(layout, colliding)}: another writer was committing at the same time`,
|
|
594
|
+
{cause: error},
|
|
528
595
|
)
|
|
529
596
|
}
|
|
530
597
|
if (codes.some((code) => THROTTLED.has(code))) {
|
|
@@ -542,15 +609,57 @@ export class DynamoRepository implements Repository, StoreInitializer {
|
|
|
542
609
|
return new RebuildInProgressError()
|
|
543
610
|
}
|
|
544
611
|
|
|
545
|
-
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
612
|
+
const failures: EvtError[] = []
|
|
613
|
+
for (const stream of layout.streams) {
|
|
614
|
+
const failure = await this.#streamFailure(stream, codes, request)
|
|
615
|
+
if (failure !== null) {
|
|
616
|
+
failures.push(failure)
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
for (const {index, fence} of layout.fences) {
|
|
620
|
+
if (codes[index] !== CONDITION_FAILED) {
|
|
621
|
+
continue
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const verdict = await this.#probe(fence.entityId, false, request)
|
|
625
|
+
failures.push(
|
|
626
|
+
verdict.drift ?? new FenceConflictError(fence.entityId, fence.head, fence.provenance),
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const ranked = rankFailures(failures)
|
|
631
|
+
if (ranked !== undefined) {
|
|
632
|
+
return ranked
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Only a projector operation's condition failed: its own error, as Go reports it.
|
|
636
|
+
if (failed < layout.projectorEnd) {
|
|
637
|
+
return error
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return new ConflictError(
|
|
641
|
+
entityAt(layout, failed),
|
|
642
|
+
'conditional check failed: the sequence or snapshot changed since it was read',
|
|
643
|
+
)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// What a written stream contributes to a failed transaction, or null when none of its actions
|
|
647
|
+
// failed. A failed head update is classified by the probe. A head update that passed while an
|
|
648
|
+
// event put failed is an event above the head, unless retention explains it. A failed snapshot
|
|
649
|
+
// put alone is an ordinary conflict.
|
|
650
|
+
async #streamFailure(
|
|
651
|
+
stream: StreamLayout,
|
|
652
|
+
codes: string[],
|
|
653
|
+
request: RequestOptions,
|
|
654
|
+
): Promise<EvtError | null> {
|
|
655
|
+
const {commit} = stream
|
|
549
656
|
const {entityId} = commit
|
|
550
657
|
const first = commit.events[0]!.sequence
|
|
551
658
|
const retained = this.#retention.has(commit.events[0]!.entityType)
|
|
659
|
+
const eventFailed = codes.slice(stream.head + 1, stream.eventsEnd).includes(CONDITION_FAILED)
|
|
660
|
+
const snapshotFailed = stream.snapshot !== null && codes[stream.snapshot] === CONDITION_FAILED
|
|
552
661
|
|
|
553
|
-
if (codes[head] === CONDITION_FAILED) {
|
|
662
|
+
if (codes[stream.head] === CONDITION_FAILED) {
|
|
554
663
|
const verdict = await this.#probe(entityId, retained, request)
|
|
555
664
|
if (verdict.drift) {
|
|
556
665
|
return verdict.drift
|
|
@@ -558,20 +667,26 @@ export class DynamoRepository implements Repository, StoreInitializer {
|
|
|
558
667
|
if (verdict.healthy && first > 1n && verdict.stored < first - 1n) {
|
|
559
668
|
return new SequenceGapError(entityId, verdict.stored, first)
|
|
560
669
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
670
|
+
|
|
671
|
+
return conflict(entityId)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (eventFailed && codes[stream.head] === 'None') {
|
|
675
|
+
// With retention, TTL can delete the head before an expired event, so a create passes its
|
|
676
|
+
// head condition and fails on the leftover item: a conflict until TTL deletes the rest. The
|
|
677
|
+
// probe already read the remaining items when it found the head item absent below the head
|
|
678
|
+
// its log derives; read them only if it didn't.
|
|
565
679
|
const verdict = await this.#probe(entityId, retained, request)
|
|
566
|
-
|
|
680
|
+
const expired =
|
|
681
|
+
retained && (verdict.expired ?? (await this.#remainingItemsExpired(entityId, request)))
|
|
682
|
+
if (!expired) {
|
|
567
683
|
return verdict.drift ?? drift(entityId, verdict.head, verdict.derived)
|
|
568
684
|
}
|
|
685
|
+
|
|
686
|
+
return conflict(entityId)
|
|
569
687
|
}
|
|
570
688
|
|
|
571
|
-
return
|
|
572
|
-
entityId,
|
|
573
|
-
'conditional check failed: the sequence or snapshot changed since it was read',
|
|
574
|
-
)
|
|
689
|
+
return eventFailed || snapshotFailed ? conflict(entityId) : null
|
|
575
690
|
}
|
|
576
691
|
|
|
577
692
|
// Classifies a stream whose head update failed, as the failure-path probe does: a stored head
|
|
@@ -584,8 +699,11 @@ export class DynamoRepository implements Repository, StoreInitializer {
|
|
|
584
699
|
const head = (await this.#readHead(entityId, request))?.head ?? null
|
|
585
700
|
const stored = head ?? 0n
|
|
586
701
|
if (stored < derived) {
|
|
587
|
-
if (retained && head === null
|
|
588
|
-
|
|
702
|
+
if (retained && head === null) {
|
|
703
|
+
const expired = await this.#remainingItemsExpired(entityId, request)
|
|
704
|
+
const drifted = expired ? null : drift(entityId, head, derived)
|
|
705
|
+
|
|
706
|
+
return {healthy: false, drift: drifted, stored, head, derived, expired}
|
|
589
707
|
}
|
|
590
708
|
|
|
591
709
|
return {healthy: false, drift: drift(entityId, head, derived), stored, head, derived}
|
|
@@ -958,6 +1076,32 @@ interface Verdict {
|
|
|
958
1076
|
stored: bigint
|
|
959
1077
|
head: bigint | null
|
|
960
1078
|
derived: bigint
|
|
1079
|
+
/** What `#remainingItemsExpired` found, when the probe read the remaining items. */
|
|
1080
|
+
expired?: boolean
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function conflict(entityId: string): ConflictError {
|
|
1084
|
+
return new ConflictError(
|
|
1085
|
+
entityId,
|
|
1086
|
+
'conditional check failed: the sequence or snapshot changed since it was read',
|
|
1087
|
+
)
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// The stream an action of a transaction belongs to, for a failure no stream check explains: the
|
|
1091
|
+
// written or fenced stream at that index, or else the transaction's first.
|
|
1092
|
+
function entityAt(layout: TransactionLayout, index: number): string {
|
|
1093
|
+
for (const stream of layout.streams) {
|
|
1094
|
+
if (index >= stream.head && (index < stream.eventsEnd || index === stream.snapshot)) {
|
|
1095
|
+
return stream.commit.entityId
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const fenced = layout.fences.find((each) => each.index === index)
|
|
1100
|
+
if (fenced !== undefined) {
|
|
1101
|
+
return fenced.fence.entityId
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
return layout.streams[0]?.commit.entityId ?? layout.fences[0]?.fence.entityId ?? ''
|
|
961
1105
|
}
|
|
962
1106
|
|
|
963
1107
|
function drift(entityId: string, head: bigint | null, derived: bigint): StreamHeadDriftError {
|
package/src/dynamodb/requests.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
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
|
|
4
|
-
// 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
|
-
import
|
|
9
|
+
import {EvtError} from '../errors.js'
|
|
10
|
+
import type {Commit, NormalizedTransaction, ProjectorGroup, ReadFence} from '../repository.js'
|
|
9
11
|
import type {TransactItem} from './client.js'
|
|
10
|
-
import {type HeadAction, headUpdate} from './head.js'
|
|
12
|
+
import {type HeadAction, headFence, headUpdate} from './head.js'
|
|
11
13
|
import {HEAD_SORT_KEY, type Item, SNAPSHOT_SORT_KEY, eventItem, snapshotItem} from './item.js'
|
|
12
14
|
import {FEATURES_PK, REBUILD_LOCK_PK} from './marker.js'
|
|
15
|
+
import {DYNAMODB_BACKEND} from './views.js'
|
|
13
16
|
|
|
14
17
|
/** The marker check of a writer that validated the manifest at version `:v`. */
|
|
15
18
|
export const MARKER_CHECK_CONDITION = 'attribute_not_exists(pk) OR version = :v'
|
|
@@ -99,8 +102,8 @@ export interface SnapshotTtl {
|
|
|
99
102
|
|
|
100
103
|
/**
|
|
101
104
|
* 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.
|
|
103
|
-
* for
|
|
105
|
+
* feature manifest at `markerVersion`. `ttl` is given only for a stream with retention. A projector
|
|
106
|
+
* group for any backend but DynamoDB is refused. Exported for the compatibility fixtures.
|
|
104
107
|
*/
|
|
105
108
|
export function commitTransaction(
|
|
106
109
|
table: string,
|
|
@@ -108,15 +111,83 @@ export function commitTransaction(
|
|
|
108
111
|
markerVersion: bigint,
|
|
109
112
|
ttl?: CommitTtl,
|
|
110
113
|
): TransactItem[] {
|
|
111
|
-
|
|
112
|
-
const last = commit.events.at(-1)
|
|
113
|
-
if (first === undefined || last === undefined) {
|
|
114
|
+
if (commit.events.length === 0) {
|
|
114
115
|
throw new RangeError('a commit transaction needs at least one event')
|
|
115
116
|
}
|
|
116
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} {
|
|
117
164
|
const items = [markerCheck(table, markerVersion), rebuildLockCheck(table)]
|
|
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)!
|
|
118
189
|
const head = headUpdate(commit.entityId, first.sequence, last.sequence, ttl?.head)
|
|
119
|
-
items
|
|
190
|
+
const items = [headUpdateItem(table, head)]
|
|
120
191
|
for (const event of commit.events) {
|
|
121
192
|
items.push(eventPut(table, event, ttl?.items))
|
|
122
193
|
}
|
|
@@ -136,6 +207,23 @@ export function commitTransaction(
|
|
|
136
207
|
return items
|
|
137
208
|
}
|
|
138
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
|
+
|
|
139
227
|
/**
|
|
140
228
|
* The actions of a snapshot written outside a commit: the marker check, the head guard, and the
|
|
141
229
|
* put, which never lowers the stored snapshot. It carries no rebuild-lock check. `ttl` is given
|
|
@@ -200,6 +288,22 @@ function snapshotGuard(
|
|
|
200
288
|
}
|
|
201
289
|
}
|
|
202
290
|
|
|
291
|
+
/** The DynamoDB actions of projector groups, in order. */
|
|
292
|
+
export function projectorItems(groups: readonly ProjectorGroup[]): TransactItem[] {
|
|
293
|
+
const items: TransactItem[] = []
|
|
294
|
+
for (const group of groups) {
|
|
295
|
+
if (group.backend !== DYNAMODB_BACKEND) {
|
|
296
|
+
throw new EvtError(
|
|
297
|
+
'invalidCommit',
|
|
298
|
+
`invalid commit: a DynamoDB repository can't write a ${group.backend} projector group`,
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
items.push(...(group.operations as TransactItem[]))
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return items
|
|
305
|
+
}
|
|
306
|
+
|
|
203
307
|
function headUpdateItem(table: string, action: HeadAction): TransactItem {
|
|
204
308
|
const update: NonNullable<TransactItem['Update']> = {
|
|
205
309
|
TableName: table,
|
|
@@ -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
|
+
}
|