effect-dynamodb 1.31.0 → 1.33.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/dist/EventStore.d.ts +397 -20
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +602 -59
- package/dist/EventStore.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/TransactWriteOps.d.ts +102 -4
- package/dist/internal/TransactWriteOps.d.ts.map +1 -1
- package/dist/internal/TransactWriteOps.js +104 -4
- package/dist/internal/TransactWriteOps.js.map +1 -1
- package/package.json +2 -2
package/dist/EventStore.js
CHANGED
|
@@ -11,7 +11,15 @@
|
|
|
11
11
|
* - `commandHandler` combinator for the load-decide-fold-append cycle
|
|
12
12
|
* (snapshot-aware single-request loads, consistent loads, an optional
|
|
13
13
|
* `VersionConflict` retry policy, caller-supplied `expectedVersion`,
|
|
14
|
-
* decision-derived `additionalItems`, inline snapshots
|
|
14
|
+
* decision-derived `additionalItems`, inline snapshots, a caller's own
|
|
15
|
+
* state record as the load via `loadState`)
|
|
16
|
+
* - Head proofs (`append({ headProof })`, #164): an additional item or the
|
|
17
|
+
* inline snapshot proves the stream head in place of the version-contiguity
|
|
18
|
+
* `ConditionCheck`
|
|
19
|
+
* - Sealing (#166): `append(..., { seal: true })` makes its last event the
|
|
20
|
+
* stream's final one (`StreamSealed` for any later append), and
|
|
21
|
+
* `stream.appendOp(...)` carries one stream's append — sealing or not — in
|
|
22
|
+
* another append's transaction
|
|
15
23
|
* - Stream indexes (`makeStream({ indexes })`): sub-streams ordered by a key
|
|
16
24
|
* derived from each event, on an LSI or GSI, each projecting all of the
|
|
17
25
|
* event, all but its metadata, or only its keys — `readIndex`,
|
|
@@ -37,7 +45,7 @@ import { Compression } from "@effect-dynamodb/schema/Compression.js";
|
|
|
37
45
|
import { isPackedConfig } from "@effect-dynamodb/schema/DynamoModel.js";
|
|
38
46
|
import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
|
|
39
47
|
import { normalizeTtlSeconds } from "@effect-dynamodb/schema/Entity.js";
|
|
40
|
-
import { AdditionalItemConditionFailed, AppendTooLarge, DuplicateCommand, isAwsConditionalCheckFailed, isAwsTransactionCancelled, TransactionCancelled, ValidationError, VersionConflict, } from "@effect-dynamodb/schema/Errors.js";
|
|
48
|
+
import { AdditionalItemConditionFailed, AppendTooLarge, DuplicateCommand, isAwsConditionalCheckFailed, isAwsTransactionCancelled, StreamSealed, TransactionCancelled, ValidationError, VersionConflict, } from "@effect-dynamodb/schema/Errors.js";
|
|
41
49
|
import { isDispatchEligible, packedShape, packValue, unpackStoredSync, } from "@effect-dynamodb/schema/internal/PackedCodec.js";
|
|
42
50
|
import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
|
|
43
51
|
import { Context, DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
|
|
@@ -48,7 +56,7 @@ import { itemBytes, transactItemBytes } from "./internal/ItemSize.js";
|
|
|
48
56
|
import { isMultiProjection, } from "./internal/MultiProjection.js";
|
|
49
57
|
import { codecRequirements, currentRegistry, decodeImage, imageEntityType, runtimeCodecCheck, unregisteredCodecError, withRegistry, } from "./internal/Packed.js";
|
|
50
58
|
import { createPathBuilder } from "./internal/PathBuilder.js";
|
|
51
|
-
import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, judgeCancellation, transactItemTarget, } from "./internal/TransactWriteOps.js";
|
|
59
|
+
import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, isStreamAppendOp, judgeCancellation, opConditionFailed, StreamAppendTypeId, transactItemTarget, } from "./internal/TransactWriteOps.js";
|
|
52
60
|
import { diagnosedValidationError, diagnoseFailure, mergeKeyAttributes, withCause, } from "./internal/WriteDiagnosis.js";
|
|
53
61
|
import { fromAttributeMap, toAttributeMap } from "./Marshaller.js";
|
|
54
62
|
import * as Query from "./Query.js";
|
|
@@ -129,6 +137,71 @@ const PROJECTED_ENVELOPE_ATTRIBUTES = [
|
|
|
129
137
|
"timestamp",
|
|
130
138
|
"version",
|
|
131
139
|
];
|
|
140
|
+
/**
|
|
141
|
+
* @internal The seal marker (#166): a native `BOOL` `true` on the sealing
|
|
142
|
+
* event's item and on the sealing append's inline snapshot. In the library's
|
|
143
|
+
* `__edd_*__` namespace, beside `__edd_e__`, so no model field can collide
|
|
144
|
+
* with it (event `data` is a nested map or a packed value). Not one of
|
|
145
|
+
* {@link EVENT_ITEM_ATTRIBUTES}: adding it would change the `INCLUDE` list of
|
|
146
|
+
* every deployed `"withoutMetadata"` index.
|
|
147
|
+
*/
|
|
148
|
+
const SEALED_ATTRIBUTE = "__edd_sealed__";
|
|
149
|
+
/**
|
|
150
|
+
* @internal The version a sealed item returned by a failed condition
|
|
151
|
+
* (`ReturnValuesOnConditionCheckFailure: "ALL_OLD"`) seals the stream at: an
|
|
152
|
+
* event's `version`, a snapshot's `asOfVersion`. `undefined` for a missing or
|
|
153
|
+
* unmarked item.
|
|
154
|
+
*/
|
|
155
|
+
const sealedVersionOf = (item) => {
|
|
156
|
+
if (item === undefined)
|
|
157
|
+
return undefined;
|
|
158
|
+
const marker = item[SEALED_ATTRIBUTE];
|
|
159
|
+
if (marker?.BOOL !== true)
|
|
160
|
+
return undefined;
|
|
161
|
+
const raw = fromAttributeMap(item);
|
|
162
|
+
const version = typeof raw.version === "number" ? raw.version : raw.asOfVersion;
|
|
163
|
+
return typeof version === "number" ? version : undefined;
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* @internal What a cancelled append means for one stream, from the reasons of
|
|
167
|
+
* its own items: `[0, guardCount)` the contiguity check,
|
|
168
|
+
* `[guardCount, guardCount + eventCount)` the event puts, and the snapshot at
|
|
169
|
+
* `snapshotIndex` (`-1`: none). `StreamSealed` when the contiguity check, the
|
|
170
|
+
* first event put or the snapshot failed and returned a sealed item (#166);
|
|
171
|
+
* `VersionConflict` for any other failure of the check or the event puts;
|
|
172
|
+
* `undefined` otherwise. Shared by an append's own verdict and a nested
|
|
173
|
+
* {@link StreamAppendOp}'s.
|
|
174
|
+
*/
|
|
175
|
+
const streamAppendVerdict = (reasons, layout) => {
|
|
176
|
+
const failedAt = (index) => index >= 0 && reasons[index]?.Code === "ConditionalCheckFailed";
|
|
177
|
+
const checked = [
|
|
178
|
+
...(layout.guardCount > 0 ? [0] : []),
|
|
179
|
+
...(layout.eventCount > 0 ? [layout.guardCount] : []),
|
|
180
|
+
layout.snapshotIndex,
|
|
181
|
+
];
|
|
182
|
+
for (const index of checked) {
|
|
183
|
+
if (!failedAt(index))
|
|
184
|
+
continue;
|
|
185
|
+
const sealedVersion = sealedVersionOf(reasons[index]?.Item);
|
|
186
|
+
if (sealedVersion !== undefined) {
|
|
187
|
+
return new StreamSealed({
|
|
188
|
+
streamName: layout.streamName,
|
|
189
|
+
streamId: layout.streamId,
|
|
190
|
+
sealedVersion,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
for (let i = 0; i < layout.guardCount + layout.eventCount; i++) {
|
|
195
|
+
if (failedAt(i)) {
|
|
196
|
+
return new VersionConflict({
|
|
197
|
+
streamName: layout.streamName,
|
|
198
|
+
streamId: layout.streamId,
|
|
199
|
+
expectedVersion: layout.expectedVersion,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return undefined;
|
|
204
|
+
};
|
|
132
205
|
/**
|
|
133
206
|
* @internal Attributes a stream writes on its own items — events, snapshot,
|
|
134
207
|
* idempotency sentinel — plus the default TTL attribute. An index attribute
|
|
@@ -147,6 +220,7 @@ const STREAM_OWNED_ATTRIBUTES = [
|
|
|
147
220
|
"asOfVersion",
|
|
148
221
|
"state",
|
|
149
222
|
"commandId",
|
|
223
|
+
SEALED_ATTRIBUTE,
|
|
150
224
|
DEFAULT_TTL_ATTRIBUTE_NAME,
|
|
151
225
|
];
|
|
152
226
|
/** @internal Every member a stream index entry may declare (`StreamIndexConfigKey`). */
|
|
@@ -410,6 +484,38 @@ const unverifiedSnapshotRefusal = (streamName, settings, operation) => settings
|
|
|
410
484
|
`snapshot mode "inline" without "every" — the only mode that writes the snapshot in every ` +
|
|
411
485
|
`append, so it is normally at the head. This stream's snapshot is mode "${settings.mode}"` +
|
|
412
486
|
`${settings.every === undefined ? "" : ` with every: ${settings.every}`}.`;
|
|
487
|
+
/**
|
|
488
|
+
* @internal `[EDD-9085]`: a snapshot head proof (`headProof: "snapshot"`) is
|
|
489
|
+
* accepted only by a stream whose snapshot is written in every append
|
|
490
|
+
* (`mode: "inline"` without `every`) — any other snapshot lags the head by
|
|
491
|
+
* design, and would fail the proof's condition. Returns the refusal message,
|
|
492
|
+
* or `undefined` when the stream accepts it.
|
|
493
|
+
*/
|
|
494
|
+
const snapshotProofRefusal = (streamName, settings, operation) => settings !== undefined && settings.mode === "inline" && settings.every === undefined
|
|
495
|
+
? undefined
|
|
496
|
+
: `[EDD-9085] EventStream "${streamName}": ${operation} with headProof: "snapshot" requires ` +
|
|
497
|
+
`snapshot mode "inline" without "every" — the only mode that writes the snapshot in every ` +
|
|
498
|
+
`append, so it can prove the head. ` +
|
|
499
|
+
(settings === undefined
|
|
500
|
+
? "This stream has no snapshot config."
|
|
501
|
+
: `This stream's snapshot is mode "${settings.mode}"` +
|
|
502
|
+
`${settings.every === undefined ? "" : ` with every: ${settings.every}`}.`);
|
|
503
|
+
/**
|
|
504
|
+
* @internal Why a `headProof` is malformed — not `"snapshot"` nor
|
|
505
|
+
* `{ additionalItem: n }` with `n` a non-negative integer — or `undefined`
|
|
506
|
+
* when it is well-formed.
|
|
507
|
+
*/
|
|
508
|
+
const headProofShapeProblem = (headProof) => {
|
|
509
|
+
if (headProof === "snapshot")
|
|
510
|
+
return undefined;
|
|
511
|
+
const n = typeof headProof === "object" && headProof !== null
|
|
512
|
+
? headProof.additionalItem
|
|
513
|
+
: undefined;
|
|
514
|
+
return typeof n === "number" && Number.isInteger(n) && n >= 0
|
|
515
|
+
? undefined
|
|
516
|
+
: `headProof must be "snapshot" or { additionalItem: n } with n a non-negative integer; ` +
|
|
517
|
+
`received ${JSON.stringify(headProof) ?? String(headProof)}. Nothing was written.`;
|
|
518
|
+
};
|
|
413
519
|
/**
|
|
414
520
|
* @internal Why an `eventTimestamps` value cannot be written for `count`
|
|
415
521
|
* events — not an array, the wrong length, or an entry that is neither a
|
|
@@ -583,8 +689,8 @@ const eventTimestampsProblem = (timestamps, count, label, events) => {
|
|
|
583
689
|
* declare (such as a misspelled `projection`, also a type error).
|
|
584
690
|
* @throws `[EDD-9064]` when an index attribute is one the stream writes itself
|
|
585
691
|
* (`pk`, `sk`, `__edd_e__`, `streamId`, `version`, `eventType`, `data`,
|
|
586
|
-
* `metadata`, `timestamp`, `asOfVersion`, `state`, `commandId`,
|
|
587
|
-
* default TTL attribute `_ttl`). A custom `TableConfig.ttlAttributeName`
|
|
692
|
+
* `metadata`, `timestamp`, `asOfVersion`, `state`, `commandId`, the seal
|
|
693
|
+
* marker `__edd_sealed__`, or the default TTL attribute `_ttl`). A custom `TableConfig.ttlAttributeName`
|
|
588
694
|
* is only known at runtime: an index attribute equal to it fails each append
|
|
589
695
|
* with `ValidationError`.
|
|
590
696
|
* @throws `[EDD-9065]` when two indexes share a physical index name or an
|
|
@@ -817,14 +923,18 @@ export const makeStream = (config) => {
|
|
|
817
923
|
? yield* Schema.decodeUnknownEffect(metadataSchema)(raw.metadata).pipe(Effect.mapError(toValidationError("EventStore.decode.metadata")))
|
|
818
924
|
: raw.metadata;
|
|
819
925
|
}
|
|
820
|
-
|
|
926
|
+
const event = {
|
|
821
927
|
streamId: envelope.streamId,
|
|
822
928
|
version: envelope.version,
|
|
823
929
|
eventType: envelope.eventType,
|
|
824
930
|
data,
|
|
825
931
|
metadata,
|
|
826
932
|
timestamp: envelope.timestamp,
|
|
933
|
+
// The seal marker (#166), present only on a sealing event, so an
|
|
934
|
+
// ordinary event's shape is unchanged.
|
|
935
|
+
...(raw[SEALED_ATTRIBUTE] === true ? { sealed: true } : {}),
|
|
827
936
|
};
|
|
937
|
+
return event;
|
|
828
938
|
});
|
|
829
939
|
// ---------------------------------------------------------------------------
|
|
830
940
|
// append
|
|
@@ -935,16 +1045,34 @@ export const makeStream = (config) => {
|
|
|
935
1045
|
commandId,
|
|
936
1046
|
}));
|
|
937
1047
|
}
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1048
|
+
// A sealed stream (#166): the contiguity check, the first event put
|
|
1049
|
+
// or the snapshot proof returned a sealing item — terminal, so it
|
|
1050
|
+
// ranks above a conflict. Otherwise the contiguity ConditionCheck
|
|
1051
|
+
// and the event puts both mean "the stream is not where you said it
|
|
1052
|
+
// was", so they share one verdict.
|
|
1053
|
+
const own = streamAppendVerdict(rawReasons, {
|
|
1054
|
+
guardCount: tx.guardCount,
|
|
1055
|
+
eventCount: tx.eventCount,
|
|
1056
|
+
snapshotIndex: tx.snapshotIndex,
|
|
1057
|
+
streamName: config.streamName,
|
|
1058
|
+
streamId: streamIdStr,
|
|
1059
|
+
expectedVersion: tx.expectedVersion,
|
|
1060
|
+
});
|
|
1061
|
+
if (own !== undefined)
|
|
1062
|
+
return Effect.fail(own);
|
|
1063
|
+
// The head proof (#164) stands in for the contiguity check, so its
|
|
1064
|
+
// own condition failing shares their verdict — ahead of the other
|
|
1065
|
+
// additional items.
|
|
1066
|
+
const proofFailed = tx.headProof === "snapshot"
|
|
1067
|
+
? failedAt(tx.snapshotIndex)
|
|
1068
|
+
: tx.headProof !== undefined &&
|
|
1069
|
+
opConditionFailed(tx.additional, rawReasons, tx.headProof.additionalItem, tx.guardCount + tx.eventCount);
|
|
1070
|
+
if (proofFailed) {
|
|
1071
|
+
return Effect.fail(new VersionConflict({
|
|
1072
|
+
streamName: config.streamName,
|
|
1073
|
+
streamId: streamIdStr,
|
|
1074
|
+
expectedVersion: tx.expectedVersion,
|
|
1075
|
+
}));
|
|
948
1076
|
}
|
|
949
1077
|
// The additional items, attributed back to the caller OPS that
|
|
950
1078
|
// produced them (several items can belong to one op). Only an op's
|
|
@@ -952,7 +1080,11 @@ export const makeStream = (config) => {
|
|
|
952
1080
|
// the caller set no condition on reports what the entity's own put
|
|
953
1081
|
// would — a taken unique value, a history conflict — or, having
|
|
954
1082
|
// lost a race to a concurrent write, is written again (#133).
|
|
1083
|
+
// A nested stream append's verdict (#166) comes first, naming its
|
|
1084
|
+
// own stream.
|
|
955
1085
|
const judged = judgeCancellation(tx.additional, rawReasons, tx.guardCount + tx.eventCount);
|
|
1086
|
+
if (judged?._tag === "stream")
|
|
1087
|
+
return Effect.fail(judged.error);
|
|
956
1088
|
if (judged?._tag === "fail")
|
|
957
1089
|
return Effect.fail(judged.error);
|
|
958
1090
|
if (judged?._tag === "conditions") {
|
|
@@ -1062,8 +1194,10 @@ export const makeStream = (config) => {
|
|
|
1062
1194
|
*
|
|
1063
1195
|
* `eventPartitions` — the partition the append's events have in each
|
|
1064
1196
|
* index, by index name — is what the inline snapshot's entry must match.
|
|
1197
|
+
* `sealed` writes the seal marker (#166): only a sealing append's inline
|
|
1198
|
+
* snapshot carries it.
|
|
1065
1199
|
*/
|
|
1066
|
-
const buildSnapshotItem = (streamId, state, asOfVersion, now, operation, eventPartitions) => Effect.gen(function* () {
|
|
1200
|
+
const buildSnapshotItem = (streamId, state, asOfVersion, now, operation, eventPartitions, sealed = false) => Effect.gen(function* () {
|
|
1067
1201
|
const encoded = yield* encodeSnapshotState(state, operation);
|
|
1068
1202
|
const stored = stateStoredAs === undefined
|
|
1069
1203
|
? encoded
|
|
@@ -1101,6 +1235,9 @@ export const makeStream = (config) => {
|
|
|
1101
1235
|
item[idx.pk] = entry.partition;
|
|
1102
1236
|
item[idx.sk] = entry.key;
|
|
1103
1237
|
}
|
|
1238
|
+
// Top-level and native, beside `state`, so packing never touches it.
|
|
1239
|
+
if (sealed)
|
|
1240
|
+
item[SEALED_ATTRIBUTE] = true;
|
|
1104
1241
|
return toAttributeMap(item);
|
|
1105
1242
|
});
|
|
1106
1243
|
/**
|
|
@@ -1153,6 +1290,69 @@ export const makeStream = (config) => {
|
|
|
1153
1290
|
if (snapshotState !== undefined && snapshot === undefined) {
|
|
1154
1291
|
return yield* snapshotUnavailable("append({ snapshot })");
|
|
1155
1292
|
}
|
|
1293
|
+
// The head proof (#164), checked ahead of the no-op fast path, as the
|
|
1294
|
+
// per-event options are. An index into `additionalItems` is checked by
|
|
1295
|
+
// `append`, which has them (`measureAppend` does not take them).
|
|
1296
|
+
const headProof = options?.headProof;
|
|
1297
|
+
if (headProof !== undefined) {
|
|
1298
|
+
const problem = headProofShapeProblem(headProof);
|
|
1299
|
+
if (problem !== undefined) {
|
|
1300
|
+
return yield* new ValidationError({
|
|
1301
|
+
entityType,
|
|
1302
|
+
operation: "EventStore.append.headProof",
|
|
1303
|
+
cause: problem,
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
if (headProof === "snapshot") {
|
|
1307
|
+
const refusal = snapshotProofRefusal(config.streamName, snapshotSettings, 'append({ headProof: "snapshot" })');
|
|
1308
|
+
if (refusal !== undefined)
|
|
1309
|
+
return yield* Effect.die(new Error(refusal));
|
|
1310
|
+
if (snapshotState === undefined) {
|
|
1311
|
+
return yield* new ValidationError({
|
|
1312
|
+
entityType: snapshotEntityType,
|
|
1313
|
+
operation: "EventStore.append.headProof",
|
|
1314
|
+
cause: 'headProof: "snapshot" requires the inline snapshot it names: pass `snapshot` ' +
|
|
1315
|
+
"with the state as of this append. Nothing was written.",
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
// The seal (#166), checked ahead of the no-op fast path: a seal with
|
|
1321
|
+
// nothing to mark must not pass as a no-op.
|
|
1322
|
+
const seal = options?.seal;
|
|
1323
|
+
if (seal !== undefined && typeof seal !== "boolean") {
|
|
1324
|
+
return yield* new ValidationError({
|
|
1325
|
+
entityType,
|
|
1326
|
+
operation: "EventStore.append.seal",
|
|
1327
|
+
cause: `seal must be a boolean; received ${JSON.stringify(seal) ?? String(seal)}. ` +
|
|
1328
|
+
"Nothing was written.",
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
const sealing = seal === true;
|
|
1332
|
+
if (sealing && events.length === 0) {
|
|
1333
|
+
return yield* new ValidationError({
|
|
1334
|
+
entityType,
|
|
1335
|
+
operation: "EventStore.append.seal",
|
|
1336
|
+
cause: "A seal needs at least one event to mark. Nothing was written.",
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
// On the only snapshot config that accepts the snapshot head proof, the
|
|
1340
|
+
// snapshot at the seal version must be the marked one: a seal without
|
|
1341
|
+
// it leaves the snapshot below the seal, and a later `writeSnapshot` at
|
|
1342
|
+
// the seal version would put an unmarked one a snapshot-proof writer
|
|
1343
|
+
// could append past.
|
|
1344
|
+
if (sealing &&
|
|
1345
|
+
snapshotState === undefined &&
|
|
1346
|
+
snapshotSettings?.mode === "inline" &&
|
|
1347
|
+
snapshotSettings.every === undefined) {
|
|
1348
|
+
return yield* new ValidationError({
|
|
1349
|
+
entityType,
|
|
1350
|
+
operation: "EventStore.append.seal",
|
|
1351
|
+
cause: `Stream "${config.streamName}" writes its snapshot in every append (mode "inline" ` +
|
|
1352
|
+
"without every), so a seal must carry the inline snapshot: pass `snapshot` with the " +
|
|
1353
|
+
"state as of the sealing event, which is then marked sealed too. Nothing was written.",
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1156
1356
|
// Nothing at all to write — preserve the historical no-op fast path.
|
|
1157
1357
|
// With additional items or a dedup sentinel the transaction still runs:
|
|
1158
1358
|
// a caller who asked for a side write means it, and silently dropping it
|
|
@@ -1180,7 +1380,8 @@ export const makeStream = (config) => {
|
|
|
1180
1380
|
// (#144): an append is never split — splitting would break its
|
|
1181
1381
|
// atomicity — so one over the limit is rejected whole by DynamoDB and
|
|
1182
1382
|
// reported as `AppendTooLarge`, with the expanded item count.
|
|
1183
|
-
|
|
1383
|
+
// A head proof (#164) proves the same thing, so it replaces the check.
|
|
1384
|
+
const needsContiguityCheck = expectedVersion > 0 && events.length > 0 && headProof === undefined;
|
|
1184
1385
|
const tableConfig = yield* config.table.Tag;
|
|
1185
1386
|
const tableName = tableConfig.name;
|
|
1186
1387
|
// A custom TTL attribute is only known at runtime. An index key attribute
|
|
@@ -1308,11 +1509,20 @@ export const makeStream = (config) => {
|
|
|
1308
1509
|
if (idx.type === "gsi")
|
|
1309
1510
|
item[idx.pk] = partition;
|
|
1310
1511
|
}
|
|
1512
|
+
// The seal marker (#166) on the sealing append's last event, after
|
|
1513
|
+
// the typed envelope, as `metadata` is.
|
|
1514
|
+
if (sealing && i === events.length - 1)
|
|
1515
|
+
item[SEALED_ATTRIBUTE] = true;
|
|
1311
1516
|
return {
|
|
1312
1517
|
Put: {
|
|
1313
1518
|
TableName: tableName,
|
|
1314
1519
|
Item: toAttributeMap(item),
|
|
1315
1520
|
ConditionExpression: "attribute_not_exists(pk)",
|
|
1521
|
+
// The first put of a writer one version behind a seal lands on
|
|
1522
|
+
// the sealing event: its item tells `StreamSealed` from a
|
|
1523
|
+
// conflict (#166). Only the first: every put's item would
|
|
1524
|
+
// bloat a failure response for writers already far behind.
|
|
1525
|
+
...(i === 0 ? { ReturnValuesOnConditionCheckFailure: "ALL_OLD" } : {}),
|
|
1316
1526
|
},
|
|
1317
1527
|
};
|
|
1318
1528
|
}));
|
|
@@ -1347,16 +1557,38 @@ export const makeStream = (config) => {
|
|
|
1347
1557
|
},
|
|
1348
1558
|
};
|
|
1349
1559
|
}
|
|
1350
|
-
// The inline snapshot (#138): unconditional — see AppendOptions.snapshot
|
|
1351
|
-
//
|
|
1352
|
-
//
|
|
1560
|
+
// The inline snapshot (#138): unconditional — see AppendOptions.snapshot
|
|
1561
|
+
// — unless it is the head proof (#164), when it is conditioned on being
|
|
1562
|
+
// at `expectedVersion`. Packed per `snapshot.storedAs` (#147), so the
|
|
1563
|
+
// transaction-size and item-size diagnosis count its stored bytes.
|
|
1353
1564
|
let snapshotPut;
|
|
1354
1565
|
if (snapshotState !== undefined) {
|
|
1566
|
+
const item = yield* buildSnapshotItem(streamId, snapshotState, expectedVersion + events.length, now, "EventStore.append.snapshot", eventPartitions, sealing);
|
|
1567
|
+
// As a head proof, a sealed snapshot fails the proof (#166), and
|
|
1568
|
+
// returns its item so the failure is reported as `StreamSealed`.
|
|
1355
1569
|
snapshotPut = {
|
|
1356
|
-
Put:
|
|
1357
|
-
TableName: tableName,
|
|
1358
|
-
|
|
1359
|
-
|
|
1570
|
+
Put: headProof !== "snapshot"
|
|
1571
|
+
? { TableName: tableName, Item: item }
|
|
1572
|
+
: expectedVersion === 0
|
|
1573
|
+
? {
|
|
1574
|
+
TableName: tableName,
|
|
1575
|
+
Item: item,
|
|
1576
|
+
ConditionExpression: "attribute_not_exists(pk)",
|
|
1577
|
+
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
|
|
1578
|
+
}
|
|
1579
|
+
: {
|
|
1580
|
+
TableName: tableName,
|
|
1581
|
+
Item: item,
|
|
1582
|
+
ConditionExpression: "#asOfVersion = :expectedVersion AND attribute_not_exists(#sealed)",
|
|
1583
|
+
ExpressionAttributeNames: {
|
|
1584
|
+
"#asOfVersion": "asOfVersion",
|
|
1585
|
+
"#sealed": SEALED_ATTRIBUTE,
|
|
1586
|
+
},
|
|
1587
|
+
ExpressionAttributeValues: toAttributeMap({
|
|
1588
|
+
":expectedVersion": expectedVersion,
|
|
1589
|
+
}),
|
|
1590
|
+
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
|
|
1591
|
+
},
|
|
1360
1592
|
};
|
|
1361
1593
|
}
|
|
1362
1594
|
/**
|
|
@@ -1370,13 +1602,20 @@ export const makeStream = (config) => {
|
|
|
1370
1602
|
* cancellation reason, mapping to VersionConflict just like a
|
|
1371
1603
|
* stale-version Put failure. A zero-event append (a pure side write)
|
|
1372
1604
|
* writes no version and so can open no gap.
|
|
1605
|
+
*
|
|
1606
|
+
* It also enforces the seal (#166): the event at `expectedVersion` must
|
|
1607
|
+
* not be a sealing event. It already reads that item, so the clause
|
|
1608
|
+
* costs nothing; `ALL_OLD` returns the item on failure (response bytes
|
|
1609
|
+
* only), so a sealed one is reported as `StreamSealed`.
|
|
1373
1610
|
*/
|
|
1374
1611
|
const contiguityCheck = needsContiguityCheck
|
|
1375
1612
|
? {
|
|
1376
1613
|
ConditionCheck: {
|
|
1377
1614
|
TableName: tableName,
|
|
1378
1615
|
Key: toAttributeMap({ pk, sk: composeEventSk(expectedVersion) }),
|
|
1379
|
-
ConditionExpression: "attribute_exists(pk)",
|
|
1616
|
+
ConditionExpression: "attribute_exists(#pk) AND attribute_not_exists(#sealed)",
|
|
1617
|
+
ExpressionAttributeNames: { "#pk": "pk", "#sealed": SEALED_ATTRIBUTE },
|
|
1618
|
+
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
|
|
1380
1619
|
},
|
|
1381
1620
|
}
|
|
1382
1621
|
: undefined;
|
|
@@ -1393,6 +1632,22 @@ export const makeStream = (config) => {
|
|
|
1393
1632
|
const append = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
|
|
1394
1633
|
const additionalOps = options?.additionalItems ?? [];
|
|
1395
1634
|
const idempotency = options?.idempotency;
|
|
1635
|
+
const headProof = options?.headProof;
|
|
1636
|
+
// The additional item a head proof names (#164) must exist — checked
|
|
1637
|
+
// before anything is read, and ahead of the no-op fast path. Its shape
|
|
1638
|
+
// is checked first, by `buildAppend`'s own rule.
|
|
1639
|
+
const proofOp = typeof headProof === "object" && headProofShapeProblem(headProof) === undefined
|
|
1640
|
+
? headProof.additionalItem
|
|
1641
|
+
: undefined;
|
|
1642
|
+
if (proofOp !== undefined && proofOp >= additionalOps.length) {
|
|
1643
|
+
return yield* new ValidationError({
|
|
1644
|
+
entityType,
|
|
1645
|
+
operation: "EventStore.append.headProof",
|
|
1646
|
+
cause: `headProof names additionalItems[${proofOp}], but the append has ` +
|
|
1647
|
+
`${additionalOps.length} additional item${additionalOps.length === 1 ? "" : "s"}. ` +
|
|
1648
|
+
"Nothing was written.",
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1396
1651
|
const parts = yield* buildAppend(streamId, events, expectedVersion, options);
|
|
1397
1652
|
if (parts === undefined)
|
|
1398
1653
|
return { version: expectedVersion, events: [] };
|
|
@@ -1449,6 +1704,7 @@ export const makeStream = (config) => {
|
|
|
1449
1704
|
sentinelIndex,
|
|
1450
1705
|
snapshotIndex,
|
|
1451
1706
|
expectedVersion,
|
|
1707
|
+
headProof,
|
|
1452
1708
|
};
|
|
1453
1709
|
};
|
|
1454
1710
|
// A guarded put among the additional items reads its item; a race with
|
|
@@ -1457,7 +1713,26 @@ export const makeStream = (config) => {
|
|
|
1457
1713
|
for (let attempt = 0; attempt < GUARDED_TRANSACTION_ATTEMPTS; attempt++) {
|
|
1458
1714
|
// Caller-owned items, compiled through the same builder
|
|
1459
1715
|
// `Transaction.transactWrite` uses, so the two APIs cannot drift.
|
|
1460
|
-
|
|
1716
|
+
// Another stream's append (#166) is accepted here, and only here.
|
|
1717
|
+
const additional = yield* buildTransactWriteItems(additionalOps, "EventStore.append.additionalItems", { streams: true });
|
|
1718
|
+
// An item without a condition of its own proves nothing about the
|
|
1719
|
+
// head: accepting it would drop the gap protection silently. Nor
|
|
1720
|
+
// does another stream's append: its conditions are its stream's.
|
|
1721
|
+
if (proofOp !== undefined && !additional.conditioned.has(proofOp)) {
|
|
1722
|
+
const named = additionalOps[proofOp];
|
|
1723
|
+
return yield* new ValidationError({
|
|
1724
|
+
entityType,
|
|
1725
|
+
operation: "EventStore.append.headProof",
|
|
1726
|
+
cause: isStreamAppendOp(named)
|
|
1727
|
+
? `headProof names additionalItems[${proofOp}], which is an append of stream ` +
|
|
1728
|
+
`"${named.streamName}" (appendOp): its conditions prove that stream's head, ` +
|
|
1729
|
+
"not this one's. Name an item conditioned on this stream being at " +
|
|
1730
|
+
"expectedVersion. Nothing was written."
|
|
1731
|
+
: `headProof names additionalItems[${proofOp}], which carries no condition of its ` +
|
|
1732
|
+
"own: the proof must be conditioned on the stream being at expectedVersion " +
|
|
1733
|
+
"(`.condition()`, `create()` or `Transaction.check`). Nothing was written.",
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1461
1736
|
const tx = layout(additional);
|
|
1462
1737
|
// DynamoDB's rules — one operation per item (an additional item
|
|
1463
1738
|
// repeating an event, the contiguity check, the idempotency sentinel
|
|
@@ -1475,6 +1750,73 @@ export const makeStream = (config) => {
|
|
|
1475
1750
|
return yield* Effect.fail(lost);
|
|
1476
1751
|
});
|
|
1477
1752
|
// ---------------------------------------------------------------------------
|
|
1753
|
+
// appendOp — this stream's append inside another append (#166)
|
|
1754
|
+
// ---------------------------------------------------------------------------
|
|
1755
|
+
/**
|
|
1756
|
+
* The nested append's items, built by `buildAppend` exactly as `append`
|
|
1757
|
+
* builds them — contiguity check, event puts, seal marker, inline snapshot
|
|
1758
|
+
* — with the verdict `append` itself applies to them. The options a nested
|
|
1759
|
+
* append may not carry are refused here, for untyped callers.
|
|
1760
|
+
*/
|
|
1761
|
+
const buildNestedAppend = (streamId, events, expectedVersion, options, source) => Effect.gen(function* () {
|
|
1762
|
+
const refuse = (cause) => new ValidationError({ entityType, operation: "EventStore.appendOp", cause });
|
|
1763
|
+
const untyped = options;
|
|
1764
|
+
for (const key of ["additionalItems", "idempotency", "headProof"]) {
|
|
1765
|
+
if (untyped?.[key] !== undefined) {
|
|
1766
|
+
return yield* refuse(`A stream append op may not carry ${key}: one sentinel and one head proof per ` +
|
|
1767
|
+
"transaction, carried by the appending stream. Put them on the outer append. " +
|
|
1768
|
+
"Nothing was written.");
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
if (events.length === 0) {
|
|
1772
|
+
return yield* refuse(`An append op of stream "${config.streamName}" needs at least one event: with none ` +
|
|
1773
|
+
"it writes nothing. Nothing was written.");
|
|
1774
|
+
}
|
|
1775
|
+
const parts = (yield* buildAppend(streamId, events, expectedVersion, options));
|
|
1776
|
+
const items = [];
|
|
1777
|
+
const sources = [];
|
|
1778
|
+
const label = `${source} (stream "${config.streamName}", ${parts.streamIdStr})`;
|
|
1779
|
+
if (parts.contiguityCheck !== undefined) {
|
|
1780
|
+
items.push(parts.contiguityCheck);
|
|
1781
|
+
sources.push([entityType, `${label}: the version-contiguity check`]);
|
|
1782
|
+
}
|
|
1783
|
+
const guardCount = items.length;
|
|
1784
|
+
for (const [i, item] of parts.eventItems.entries()) {
|
|
1785
|
+
items.push(item);
|
|
1786
|
+
sources.push([entityType, `${label}: the event at version ${expectedVersion + i + 1}`]);
|
|
1787
|
+
}
|
|
1788
|
+
const snapshotIndex = parts.snapshotPut === undefined ? -1 : items.length;
|
|
1789
|
+
if (parts.snapshotPut !== undefined) {
|
|
1790
|
+
items.push(parts.snapshotPut);
|
|
1791
|
+
sources.push([snapshotEntityType, `${label}: the inline snapshot`]);
|
|
1792
|
+
}
|
|
1793
|
+
const layout = {
|
|
1794
|
+
guardCount,
|
|
1795
|
+
eventCount: parts.eventItems.length,
|
|
1796
|
+
snapshotIndex,
|
|
1797
|
+
streamName: config.streamName,
|
|
1798
|
+
streamId: parts.streamIdStr,
|
|
1799
|
+
expectedVersion,
|
|
1800
|
+
};
|
|
1801
|
+
const built = {
|
|
1802
|
+
items,
|
|
1803
|
+
entityType,
|
|
1804
|
+
targets: () => items.map((item, i) => transactItemTarget(item, parts.tableName, ["pk", "sk"], sources[i][0], sources[i][1])),
|
|
1805
|
+
keys: () => ({
|
|
1806
|
+
partition: ["pk", ...streamIndexes.map((idx) => idx.pk)],
|
|
1807
|
+
sort: ["sk", ...streamIndexes.map((idx) => idx.sk)],
|
|
1808
|
+
}),
|
|
1809
|
+
verdict: (reasons) => streamAppendVerdict(reasons, layout),
|
|
1810
|
+
};
|
|
1811
|
+
return built;
|
|
1812
|
+
});
|
|
1813
|
+
const appendOp = (streamId, events, expectedVersion, options) => ({
|
|
1814
|
+
[StreamAppendTypeId]: StreamAppendTypeId,
|
|
1815
|
+
streamName: config.streamName,
|
|
1816
|
+
// The codec check runs as for every other operation of the stream.
|
|
1817
|
+
_build: checked((source) => buildNestedAppend(streamId, events, expectedVersion, options, source)),
|
|
1818
|
+
});
|
|
1819
|
+
// ---------------------------------------------------------------------------
|
|
1478
1820
|
// measureAppend / measureSnapshot — caller-side size planning (#147, R1)
|
|
1479
1821
|
// ---------------------------------------------------------------------------
|
|
1480
1822
|
const measureAppend = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
|
|
@@ -1664,6 +2006,20 @@ export const makeStream = (config) => {
|
|
|
1664
2006
|
});
|
|
1665
2007
|
return result.Item === undefined ? undefined : fromAttributeMap(result.Item);
|
|
1666
2008
|
});
|
|
2009
|
+
/** The raw event item at `version`, by its exact key, in one `GetItem` (#166). */
|
|
2010
|
+
const getEventItem = (streamId, version, consistentRead) => Effect.gen(function* () {
|
|
2011
|
+
const client = yield* DynamoClient;
|
|
2012
|
+
const { name: tableName } = yield* config.table.Tag;
|
|
2013
|
+
const result = yield* client.getItem({
|
|
2014
|
+
TableName: tableName,
|
|
2015
|
+
Key: toAttributeMap({
|
|
2016
|
+
pk: composeStreamPk(streamId),
|
|
2017
|
+
sk: composeEventSk(version),
|
|
2018
|
+
}),
|
|
2019
|
+
...(consistentRead ? { ConsistentRead: true } : {}),
|
|
2020
|
+
});
|
|
2021
|
+
return result.Item === undefined ? undefined : fromAttributeMap(result.Item);
|
|
2022
|
+
});
|
|
1667
2023
|
const readSnapshot = (streamId) => Effect.gen(function* () {
|
|
1668
2024
|
if (snapshot === undefined)
|
|
1669
2025
|
return yield* snapshotUnavailable("readSnapshot");
|
|
@@ -1680,11 +2036,16 @@ export const makeStream = (config) => {
|
|
|
1680
2036
|
// (`verifySnapshot` is ignored).
|
|
1681
2037
|
if (snapshot === undefined) {
|
|
1682
2038
|
const events = yield* read(streamId, options);
|
|
1683
|
-
|
|
2039
|
+
// Any sealing event read (#166), not only the newest: a write past
|
|
2040
|
+
// the seal leaves it behind the head (`seal.version < version`).
|
|
2041
|
+
const seal = events.find((event) => event.sealed === true);
|
|
2042
|
+
const state = {
|
|
1684
2043
|
snapshot: Option.none(),
|
|
1685
2044
|
events,
|
|
1686
2045
|
version: events[events.length - 1]?.version ?? 0,
|
|
2046
|
+
...(seal !== undefined ? { seal } : {}),
|
|
1687
2047
|
};
|
|
2048
|
+
return state;
|
|
1688
2049
|
}
|
|
1689
2050
|
// `verifySnapshot: false` — the snapshot item alone, by its exact key:
|
|
1690
2051
|
// a `GetItem` reads one item where the verified `Query` reads the
|
|
@@ -1698,11 +2059,29 @@ export const makeStream = (config) => {
|
|
|
1698
2059
|
const raw = yield* getSnapshotItem(streamId, options.consistentRead === true);
|
|
1699
2060
|
if (raw !== undefined) {
|
|
1700
2061
|
const current = yield* decodeSnapshotItem(raw, "EventStore.readLatest");
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
2062
|
+
// A snapshot written by a sealing append carries the marker (#166):
|
|
2063
|
+
// its version is the seal's, so one `GetItem` of that event returns
|
|
2064
|
+
// the sealing event — paid only on a sealed stream.
|
|
2065
|
+
if (raw[SEALED_ATTRIBUTE] !== true) {
|
|
2066
|
+
const state = {
|
|
2067
|
+
snapshot: Option.some(current),
|
|
2068
|
+
events: [],
|
|
2069
|
+
version: current.asOfVersion,
|
|
2070
|
+
};
|
|
2071
|
+
return state;
|
|
2072
|
+
}
|
|
2073
|
+
const sealRaw = yield* getEventItem(streamId, current.asOfVersion, options.consistentRead === true);
|
|
2074
|
+
// Missing only if the data was corrupted: fall through to the
|
|
2075
|
+
// verified read, which reports what the stream holds.
|
|
2076
|
+
if (sealRaw !== undefined) {
|
|
2077
|
+
const state = {
|
|
2078
|
+
snapshot: Option.some(current),
|
|
2079
|
+
events: [],
|
|
2080
|
+
version: current.asOfVersion,
|
|
2081
|
+
seal: yield* decodeStreamEvent(sealRaw),
|
|
2082
|
+
};
|
|
2083
|
+
return state;
|
|
2084
|
+
}
|
|
1706
2085
|
}
|
|
1707
2086
|
}
|
|
1708
2087
|
const client = yield* DynamoClient;
|
|
@@ -1774,11 +2153,28 @@ export const makeStream = (config) => {
|
|
|
1774
2153
|
const asOfVersion = Option.isSome(latest) ? latest.value.asOfVersion : 0;
|
|
1775
2154
|
const delta = eventRaws.filter((raw) => raw.version > asOfVersion).reverse();
|
|
1776
2155
|
const events = yield* Effect.forEach(delta, (raw) => decodeStreamEvent(raw));
|
|
1777
|
-
|
|
2156
|
+
// The sealing event (#166), from ANY event item the query fetched —
|
|
2157
|
+
// including those at or below the snapshot, which `events` leaves out
|
|
2158
|
+
// (a snapshot at the seal already folds it in) — with no extra request.
|
|
2159
|
+
// The oldest marked item is the seal; anything after it was written
|
|
2160
|
+
// past it.
|
|
2161
|
+
let sealRaw;
|
|
2162
|
+
for (const raw of eventRaws) {
|
|
2163
|
+
if (raw[SEALED_ATTRIBUTE] === true)
|
|
2164
|
+
sealRaw = raw;
|
|
2165
|
+
}
|
|
2166
|
+
const seal = sealRaw === undefined
|
|
2167
|
+
? undefined
|
|
2168
|
+
: sealRaw.version > asOfVersion
|
|
2169
|
+
? events.find((event) => event.version === sealRaw.version)
|
|
2170
|
+
: yield* decodeStreamEvent(sealRaw);
|
|
2171
|
+
const state = {
|
|
1778
2172
|
snapshot: latest,
|
|
1779
2173
|
events,
|
|
1780
2174
|
version: events[events.length - 1]?.version ?? asOfVersion,
|
|
2175
|
+
...(seal !== undefined ? { seal } : {}),
|
|
1781
2176
|
};
|
|
2177
|
+
return state;
|
|
1782
2178
|
});
|
|
1783
2179
|
// ---------------------------------------------------------------------------
|
|
1784
2180
|
// hasCommandSentinel (internal — see CommandSentinelProbe)
|
|
@@ -2000,6 +2396,7 @@ export const makeStream = (config) => {
|
|
|
2000
2396
|
writeSnapshot: checked(writeSnapshot),
|
|
2001
2397
|
readSnapshot: checked(readSnapshot),
|
|
2002
2398
|
append: checked(append),
|
|
2399
|
+
appendOp,
|
|
2003
2400
|
read: checked(read),
|
|
2004
2401
|
readFrom: checked(readFrom),
|
|
2005
2402
|
currentVersion: checked(currentVersion),
|
|
@@ -2058,6 +2455,17 @@ export const bind = (stream) => Effect.gen(function* () {
|
|
|
2058
2455
|
writeSnapshot: (streamId, state, asOfVersion) => provide(stream.writeSnapshot(streamId, state, asOfVersion)),
|
|
2059
2456
|
readSnapshot: (streamId) => provide(stream.readSnapshot(streamId)),
|
|
2060
2457
|
append: (streamId, events, expectedVersion, options) => provide(stream.append(streamId, events, expectedVersion, options)),
|
|
2458
|
+
// Built in the captured context (#166): only its TableConfig and
|
|
2459
|
+
// Compression are used — the outer append sends the items.
|
|
2460
|
+
appendOp: (streamId, events, expectedVersion, options) => {
|
|
2461
|
+
const op = stream.appendOp(streamId, events, expectedVersion, options);
|
|
2462
|
+
const bound = {
|
|
2463
|
+
[StreamAppendTypeId]: StreamAppendTypeId,
|
|
2464
|
+
streamName: op.streamName,
|
|
2465
|
+
_build: (source) => provide(op._build(source)),
|
|
2466
|
+
};
|
|
2467
|
+
return bound;
|
|
2468
|
+
},
|
|
2061
2469
|
read: (streamId, options) => provide(stream.read(streamId, options)),
|
|
2062
2470
|
readFrom: (streamId, afterVersion, options) => provide(stream.readFrom(streamId, afterVersion, options)),
|
|
2063
2471
|
currentVersion: (streamId, options) => provide(stream.currentVersion(streamId, options)),
|
|
@@ -2264,6 +2672,34 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
2264
2672
|
}
|
|
2265
2673
|
// Ignored on a stream without a snapshot config: there is no snapshot to trust.
|
|
2266
2674
|
const unverifiedLoads = options?.verifySnapshot === false && snapshotSettings !== undefined;
|
|
2675
|
+
// The head proof and a caller's load (#164), refused up front (EDD-9085)
|
|
2676
|
+
// when they cannot work together or on this stream.
|
|
2677
|
+
const loadState = options?.loadState;
|
|
2678
|
+
const headProof = options?.headProof;
|
|
2679
|
+
const refuse = (problem) => {
|
|
2680
|
+
throw new Error(`[EDD-9085] EventStream "${stream.streamName}": commandHandler ${problem}`);
|
|
2681
|
+
};
|
|
2682
|
+
if (loadState !== undefined && typeof loadState !== "function") {
|
|
2683
|
+
refuse("loadState must be a function of the stream id.");
|
|
2684
|
+
}
|
|
2685
|
+
if (headProof !== undefined) {
|
|
2686
|
+
const problem = headProofShapeProblem(headProof);
|
|
2687
|
+
if (problem !== undefined)
|
|
2688
|
+
refuse(problem.replace(" Nothing was written.", ""));
|
|
2689
|
+
if (headProof === "snapshot") {
|
|
2690
|
+
const refusal = snapshotProofRefusal(stream.streamName, snapshotSettings, "commandHandler");
|
|
2691
|
+
if (refusal !== undefined)
|
|
2692
|
+
throw new Error(refusal);
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
if (loadState !== undefined && options?.verifySnapshot === false) {
|
|
2696
|
+
refuse("with loadState and verifySnapshot: false — loadState replaces the stream load, so " +
|
|
2697
|
+
"there is no snapshot load to leave unverified.");
|
|
2698
|
+
}
|
|
2699
|
+
if (loadState !== undefined && headProof === "snapshot") {
|
|
2700
|
+
refuse('with loadState and headProof: "snapshot" — the handler never reads the snapshot, so ' +
|
|
2701
|
+
"it cannot know the snapshot is at the head.");
|
|
2702
|
+
}
|
|
2267
2703
|
const retryPolicy = options?.retry;
|
|
2268
2704
|
const schedule = retryPolicy === undefined
|
|
2269
2705
|
? undefined
|
|
@@ -2276,12 +2712,105 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
2276
2712
|
const unverifiedReadOptions = { ...readOptions, verifySnapshot: false };
|
|
2277
2713
|
return (streamId, command, callOptions) => {
|
|
2278
2714
|
const expectedVersion = callOptions?.expectedVersion;
|
|
2715
|
+
/** This handler's stream id, as `append` reports it on errors. */
|
|
2716
|
+
const ownStreamId = formatStreamIdOf(stream, streamId);
|
|
2717
|
+
/**
|
|
2718
|
+
* Whether a `VersionConflict` names this handler's stream — not another
|
|
2719
|
+
* stream whose `appendOp` rode in `additionalItems` (#166).
|
|
2720
|
+
*/
|
|
2721
|
+
const isOwnConflict = (error) => error.streamName === stream.streamName && error.streamId === ownStreamId;
|
|
2722
|
+
// A redelivery of a command that already committed: `append` ranks
|
|
2723
|
+
// `DuplicateCommand` above `VersionConflict` and `StreamSealed`, so the
|
|
2724
|
+
// pre-decide answers consult the command's sentinel first (one strongly
|
|
2725
|
+
// consistent `GetItem`, on those paths only).
|
|
2726
|
+
const failIfCommitted = Effect.gen(function* () {
|
|
2727
|
+
const probe = commandSentinelProbeOf(stream);
|
|
2728
|
+
const commandId = callOptions?.commandId;
|
|
2729
|
+
if (options?.idempotency !== undefined && commandId !== undefined && probe !== undefined) {
|
|
2730
|
+
if (yield* probe(streamId, commandId)) {
|
|
2731
|
+
return yield* new DuplicateCommand({
|
|
2732
|
+
streamName: stream.streamName,
|
|
2733
|
+
streamId: ownStreamId,
|
|
2734
|
+
commandId,
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
});
|
|
2739
|
+
// A sealed stream accepts no command (#166) — not even a no-op decision —
|
|
2740
|
+
// so a load that shows the seal fails before `decide`, ahead of the
|
|
2741
|
+
// If-Match check: no retry can succeed, and the caller's move is to the
|
|
2742
|
+
// successor stream.
|
|
2743
|
+
const refuseSealed = (loaded) => Effect.gen(function* () {
|
|
2744
|
+
const sealedVersion = loaded.sealedVersion;
|
|
2745
|
+
if (sealedVersion === undefined)
|
|
2746
|
+
return loaded;
|
|
2747
|
+
yield* failIfCommitted;
|
|
2748
|
+
if (sealedVersion < loaded.version) {
|
|
2749
|
+
// Written past the seal by a writer that does not enforce it: on a
|
|
2750
|
+
// closed stream nobody reads, so the application must recover them.
|
|
2751
|
+
yield* Effect.logWarning(`EventStore: stream "${stream.streamName}" (${ownStreamId}) is sealed at version ` +
|
|
2752
|
+
`${sealedVersion}, but has events past the seal: versions ${sealedVersion + 1}` +
|
|
2753
|
+
`${loaded.version > sealedVersion + 1 ? `…${loaded.version}` : ""}.`);
|
|
2754
|
+
}
|
|
2755
|
+
return yield* new StreamSealed({
|
|
2756
|
+
streamName: stream.streamName,
|
|
2757
|
+
streamId: ownStreamId,
|
|
2758
|
+
sealedVersion,
|
|
2759
|
+
});
|
|
2760
|
+
});
|
|
2279
2761
|
// 1. Establish the base state + version. A snapshot-configured stream
|
|
2280
2762
|
// loads its snapshot, the events after it and its head in one request
|
|
2281
2763
|
// (#138) — or, unverified, the snapshot item alone; any other stream
|
|
2282
2764
|
// replays from the beginning.
|
|
2283
|
-
const load = (verify) => Effect.
|
|
2765
|
+
const load = (verify) => Effect.flatMap(loadUnchecked(verify), (loaded) => refuseSealed(loaded));
|
|
2766
|
+
const loadUnchecked = (verify) => Effect.gen(function* () {
|
|
2284
2767
|
let state = decider.initialState;
|
|
2768
|
+
// The caller's own record (#164), trusted as the head: the append's
|
|
2769
|
+
// check (or the head proof) catches one that is not.
|
|
2770
|
+
if (loadState !== undefined) {
|
|
2771
|
+
const result = yield* loadState(streamId);
|
|
2772
|
+
const refused = (cause) => new ValidationError({
|
|
2773
|
+
entityType: stream.streamName,
|
|
2774
|
+
operation: "EventStore.commandHandler.loadState",
|
|
2775
|
+
cause,
|
|
2776
|
+
});
|
|
2777
|
+
if (!Option.isOption(result)) {
|
|
2778
|
+
return yield* refused("loadState must return an Option of { state, version }: None for a new stream. " +
|
|
2779
|
+
"Nothing was written.");
|
|
2780
|
+
}
|
|
2781
|
+
if (Option.isNone(result)) {
|
|
2782
|
+
const fresh = {
|
|
2783
|
+
state,
|
|
2784
|
+
version: 0,
|
|
2785
|
+
snapshotAsOfVersion: undefined,
|
|
2786
|
+
verified: true,
|
|
2787
|
+
eventsAfterSnapshot: 0,
|
|
2788
|
+
sealedVersion: undefined,
|
|
2789
|
+
};
|
|
2790
|
+
return fresh;
|
|
2791
|
+
}
|
|
2792
|
+
const record = result.value;
|
|
2793
|
+
if (!Number.isInteger(record.version) || record.version < 0) {
|
|
2794
|
+
return yield* refused(`loadState returned version ${String(record.version)}: it must be a non-negative ` +
|
|
2795
|
+
"integer, the stream version the state is as of. Nothing was written.");
|
|
2796
|
+
}
|
|
2797
|
+
const sealedVersion = record.sealedVersion;
|
|
2798
|
+
if (sealedVersion !== undefined &&
|
|
2799
|
+
(!Number.isInteger(sealedVersion) || sealedVersion < record.version)) {
|
|
2800
|
+
return yield* refused(`loadState returned sealedVersion ${String(sealedVersion)}: it must be a ` +
|
|
2801
|
+
`non-negative integer, not below the record's version ${record.version} — the ` +
|
|
2802
|
+
"version of the stream's sealing event. Nothing was written.");
|
|
2803
|
+
}
|
|
2804
|
+
const loaded = {
|
|
2805
|
+
state: record.state,
|
|
2806
|
+
version: record.version,
|
|
2807
|
+
snapshotAsOfVersion: undefined,
|
|
2808
|
+
verified: true,
|
|
2809
|
+
eventsAfterSnapshot: 0,
|
|
2810
|
+
sealedVersion,
|
|
2811
|
+
};
|
|
2812
|
+
return loaded;
|
|
2813
|
+
}
|
|
2285
2814
|
if (snapshotSettings !== undefined) {
|
|
2286
2815
|
const latest = (yield* stream.readLatest(streamId, verify ? readOptions : unverifiedReadOptions));
|
|
2287
2816
|
let snapshotAsOfVersion = 0;
|
|
@@ -2300,6 +2829,7 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
2300
2829
|
// already fallen back to the verified read.
|
|
2301
2830
|
verified: verify || Option.isNone(latest.snapshot),
|
|
2302
2831
|
eventsAfterSnapshot: latest.events.length,
|
|
2832
|
+
sealedVersion: latest.seal?.version,
|
|
2303
2833
|
};
|
|
2304
2834
|
return loaded;
|
|
2305
2835
|
}
|
|
@@ -2313,6 +2843,8 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
2313
2843
|
snapshotAsOfVersion: 0,
|
|
2314
2844
|
verified: true,
|
|
2315
2845
|
eventsAfterSnapshot: events.length,
|
|
2846
|
+
// Any sealing event (#166), not only the newest.
|
|
2847
|
+
sealedVersion: events.find((event) => event.sealed === true)?.version,
|
|
2316
2848
|
};
|
|
2317
2849
|
return loaded;
|
|
2318
2850
|
});
|
|
@@ -2400,17 +2932,36 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
2400
2932
|
}
|
|
2401
2933
|
// Inline snapshots (#138) ride in the append transaction: on every
|
|
2402
2934
|
// append, or once `every` events have accumulated since the snapshot.
|
|
2935
|
+
// A `loadState` load never saw the snapshot (#164), so its cadence is
|
|
2936
|
+
// an append that crosses a multiple of `every`.
|
|
2403
2937
|
const mode = snapshotSettings?.mode ?? "after-append";
|
|
2404
2938
|
const every = snapshotSettings?.every;
|
|
2405
2939
|
const newVersion = baseVersion + newEvents.length;
|
|
2940
|
+
const snapshotDue = (version) => every !== undefined &&
|
|
2941
|
+
(loaded.snapshotAsOfVersion === undefined
|
|
2942
|
+
? Math.floor(version / every) > Math.floor(baseVersion / every)
|
|
2943
|
+
: version - loaded.snapshotAsOfVersion >= every);
|
|
2406
2944
|
if (snapshotSettings !== undefined &&
|
|
2407
2945
|
mode === "inline" &&
|
|
2408
|
-
(every === undefined || newVersion
|
|
2946
|
+
(every === undefined || snapshotDue(newVersion))) {
|
|
2409
2947
|
appendOptions.snapshot = next;
|
|
2410
2948
|
}
|
|
2949
|
+
// The head proof (#164). The caller's record proves it on every
|
|
2950
|
+
// append; the inline snapshot only when this load found it at the
|
|
2951
|
+
// head — a lagging one would fail the condition, so the check stays.
|
|
2952
|
+
if (typeof headProof === "object") {
|
|
2953
|
+
appendOptions.headProof = headProof;
|
|
2954
|
+
}
|
|
2955
|
+
else if (headProof === "snapshot" &&
|
|
2956
|
+
appendOptions.snapshot !== undefined &&
|
|
2957
|
+
loaded.snapshotAsOfVersion === baseVersion) {
|
|
2958
|
+
appendOptions.headProof = "snapshot";
|
|
2959
|
+
}
|
|
2411
2960
|
const appended = stream.append(streamId, newEvents, baseVersion, appendOptions);
|
|
2961
|
+
// Only this stream's conflict can mean a lagging snapshot: one naming
|
|
2962
|
+
// another stream (a nested `appendOp`, #166) propagates.
|
|
2412
2963
|
const outcome = yield* returnAppendConflict
|
|
2413
|
-
? appended.pipe(Effect.catchIf((error) => error instanceof VersionConflict, (conflict) => Effect.succeed(conflict)))
|
|
2964
|
+
? appended.pipe(Effect.catchIf((error) => error instanceof VersionConflict && isOwnConflict(error), (conflict) => Effect.succeed(conflict)))
|
|
2414
2965
|
: appended;
|
|
2415
2966
|
if (outcome instanceof VersionConflict)
|
|
2416
2967
|
return outcome;
|
|
@@ -2419,9 +2970,7 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
2419
2970
|
// Best-effort: the events are already durable, so a snapshot-write
|
|
2420
2971
|
// failure must not report the command as failed. The next threshold
|
|
2421
2972
|
// crossing retries it.
|
|
2422
|
-
if (mode === "after-append" &&
|
|
2423
|
-
every !== undefined &&
|
|
2424
|
-
result.version - loaded.snapshotAsOfVersion >= every) {
|
|
2973
|
+
if (mode === "after-append" && snapshotDue(result.version)) {
|
|
2425
2974
|
yield* stream
|
|
2426
2975
|
.writeSnapshot(streamId, next, result.version)
|
|
2427
2976
|
.pipe(Effect.catchCause((cause) => Effect.logWarning(`EventStore: snapshot write failed for stream "${stream.streamName}" at version ${result.version}`, cause)));
|
|
@@ -2441,20 +2990,10 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
2441
2990
|
// consult the sentinel before reporting the conflict. The extra read is
|
|
2442
2991
|
// paid on this conflict path only.
|
|
2443
2992
|
const ifMatchMismatch = (expected, actualVersion) => Effect.gen(function* () {
|
|
2444
|
-
|
|
2445
|
-
const commandId = callOptions?.commandId;
|
|
2446
|
-
if (options?.idempotency !== undefined && commandId !== undefined && probe !== undefined) {
|
|
2447
|
-
if (yield* probe(streamId, commandId)) {
|
|
2448
|
-
return yield* new DuplicateCommand({
|
|
2449
|
-
streamName: stream.streamName,
|
|
2450
|
-
streamId: formatStreamIdOf(stream, streamId),
|
|
2451
|
-
commandId,
|
|
2452
|
-
});
|
|
2453
|
-
}
|
|
2454
|
-
}
|
|
2993
|
+
yield* failIfCommitted;
|
|
2455
2994
|
return yield* new VersionConflict({
|
|
2456
2995
|
streamName: stream.streamName,
|
|
2457
|
-
streamId:
|
|
2996
|
+
streamId: ownStreamId,
|
|
2458
2997
|
expectedVersion: expected,
|
|
2459
2998
|
actualVersion,
|
|
2460
2999
|
});
|
|
@@ -2600,9 +3139,11 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
2600
3139
|
return (yield* commit(verified, newEvents, false));
|
|
2601
3140
|
});
|
|
2602
3141
|
// A caller-supplied `expectedVersion` is a conditional write: its
|
|
2603
|
-
// `VersionConflict` (pre-decide or from the append) is the
|
|
2604
|
-
// cue to re-read and re-decide.
|
|
2605
|
-
|
|
3142
|
+
// `VersionConflict` of this stream (pre-decide or from the append) is the
|
|
3143
|
+
// answer, never a cue to re-read and re-decide. A conflict naming another
|
|
3144
|
+
// stream (a nested `appendOp`, #166) is retried either way: the If-Match
|
|
3145
|
+
// was not wrong, and the retried attempt checks it again on its reload.
|
|
3146
|
+
return schedule === undefined
|
|
2606
3147
|
? attempt
|
|
2607
3148
|
: Effect.retry(attempt, {
|
|
2608
3149
|
// `while` runs before the schedule, so the schedule only ever sees a
|
|
@@ -2610,10 +3151,12 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
2610
3151
|
// because `Retry.Options` demands a schedule whose input accepts the
|
|
2611
3152
|
// effect's *full* error union, and `Schedule` is contravariant on input.
|
|
2612
3153
|
schedule: schedule,
|
|
2613
|
-
// `VersionConflict` only. `DuplicateCommand`
|
|
2614
|
-
// commandId
|
|
2615
|
-
//
|
|
2616
|
-
|
|
3154
|
+
// `VersionConflict` only. `DuplicateCommand` and `StreamSealed` are
|
|
3155
|
+
// terminal — the same commandId, or a sealed stream, can never
|
|
3156
|
+
// succeed — and `AdditionalItemConditionFailed` will not resolve
|
|
3157
|
+
// itself by re-deciding either.
|
|
3158
|
+
while: (error) => error instanceof VersionConflict &&
|
|
3159
|
+
(expectedVersion === undefined || !isOwnConflict(error)),
|
|
2617
3160
|
});
|
|
2618
3161
|
};
|
|
2619
3162
|
};
|