effect-dynamodb 1.32.0 → 1.34.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/Entity.d.ts +49 -2
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +291 -55
- package/dist/Entity.js.map +1 -1
- package/dist/EventStore.d.ts +255 -12
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +396 -54
- package/dist/EventStore.js.map +1 -1
- package/dist/Transaction.d.ts +18 -4
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +23 -3
- package/dist/Transaction.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/AttributeValueEquals.d.ts +10 -0
- package/dist/internal/AttributeValueEquals.d.ts.map +1 -0
- package/dist/internal/AttributeValueEquals.js +47 -0
- package/dist/internal/AttributeValueEquals.js.map +1 -0
- package/dist/internal/BoundCrud.d.ts +8 -0
- package/dist/internal/BoundCrud.d.ts.map +1 -1
- package/dist/internal/BoundCrud.js.map +1 -1
- package/dist/internal/TransactPlan.d.ts +77 -0
- package/dist/internal/TransactPlan.d.ts.map +1 -0
- package/dist/internal/TransactPlan.js +135 -0
- package/dist/internal/TransactPlan.js.map +1 -0
- package/dist/internal/TransactWriteOps.d.ts +140 -15
- package/dist/internal/TransactWriteOps.d.ts.map +1 -1
- package/dist/internal/TransactWriteOps.js +154 -7
- package/dist/internal/TransactWriteOps.js.map +1 -1
- package/dist/internal/TransactableOps.d.ts +26 -6
- package/dist/internal/TransactableOps.d.ts.map +1 -1
- package/dist/internal/TransactableOps.js +33 -7
- package/dist/internal/TransactableOps.js.map +1 -1
- package/package.json +2 -2
package/dist/EventStore.js
CHANGED
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
* - Head proofs (`append({ headProof })`, #164): an additional item or the
|
|
17
17
|
* inline snapshot proves the stream head in place of the version-contiguity
|
|
18
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
|
|
19
23
|
* - Stream indexes (`makeStream({ indexes })`): sub-streams ordered by a key
|
|
20
24
|
* derived from each event, on an LSI or GSI, each projecting all of the
|
|
21
25
|
* event, all but its metadata, or only its keys — `readIndex`,
|
|
@@ -41,7 +45,7 @@ import { Compression } from "@effect-dynamodb/schema/Compression.js";
|
|
|
41
45
|
import { isPackedConfig } from "@effect-dynamodb/schema/DynamoModel.js";
|
|
42
46
|
import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
|
|
43
47
|
import { normalizeTtlSeconds } from "@effect-dynamodb/schema/Entity.js";
|
|
44
|
-
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";
|
|
45
49
|
import { isDispatchEligible, packedShape, packValue, unpackStoredSync, } from "@effect-dynamodb/schema/internal/PackedCodec.js";
|
|
46
50
|
import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
|
|
47
51
|
import { Context, DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
|
|
@@ -52,7 +56,7 @@ import { itemBytes, transactItemBytes } from "./internal/ItemSize.js";
|
|
|
52
56
|
import { isMultiProjection, } from "./internal/MultiProjection.js";
|
|
53
57
|
import { codecRequirements, currentRegistry, decodeImage, imageEntityType, runtimeCodecCheck, unregisteredCodecError, withRegistry, } from "./internal/Packed.js";
|
|
54
58
|
import { createPathBuilder } from "./internal/PathBuilder.js";
|
|
55
|
-
import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, judgeCancellation, opConditionFailed, transactItemTarget, } from "./internal/TransactWriteOps.js";
|
|
59
|
+
import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, isStreamAppendOp, judgeCancellation, opConditionFailed, StreamAppendTypeId, transactItemTarget, } from "./internal/TransactWriteOps.js";
|
|
56
60
|
import { diagnosedValidationError, diagnoseFailure, mergeKeyAttributes, withCause, } from "./internal/WriteDiagnosis.js";
|
|
57
61
|
import { fromAttributeMap, toAttributeMap } from "./Marshaller.js";
|
|
58
62
|
import * as Query from "./Query.js";
|
|
@@ -133,6 +137,71 @@ const PROJECTED_ENVELOPE_ATTRIBUTES = [
|
|
|
133
137
|
"timestamp",
|
|
134
138
|
"version",
|
|
135
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
|
+
};
|
|
136
205
|
/**
|
|
137
206
|
* @internal Attributes a stream writes on its own items — events, snapshot,
|
|
138
207
|
* idempotency sentinel — plus the default TTL attribute. An index attribute
|
|
@@ -151,6 +220,7 @@ const STREAM_OWNED_ATTRIBUTES = [
|
|
|
151
220
|
"asOfVersion",
|
|
152
221
|
"state",
|
|
153
222
|
"commandId",
|
|
223
|
+
SEALED_ATTRIBUTE,
|
|
154
224
|
DEFAULT_TTL_ATTRIBUTE_NAME,
|
|
155
225
|
];
|
|
156
226
|
/** @internal Every member a stream index entry may declare (`StreamIndexConfigKey`). */
|
|
@@ -619,8 +689,8 @@ const eventTimestampsProblem = (timestamps, count, label, events) => {
|
|
|
619
689
|
* declare (such as a misspelled `projection`, also a type error).
|
|
620
690
|
* @throws `[EDD-9064]` when an index attribute is one the stream writes itself
|
|
621
691
|
* (`pk`, `sk`, `__edd_e__`, `streamId`, `version`, `eventType`, `data`,
|
|
622
|
-
* `metadata`, `timestamp`, `asOfVersion`, `state`, `commandId`,
|
|
623
|
-
* 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`
|
|
624
694
|
* is only known at runtime: an index attribute equal to it fails each append
|
|
625
695
|
* with `ValidationError`.
|
|
626
696
|
* @throws `[EDD-9065]` when two indexes share a physical index name or an
|
|
@@ -853,14 +923,18 @@ export const makeStream = (config) => {
|
|
|
853
923
|
? yield* Schema.decodeUnknownEffect(metadataSchema)(raw.metadata).pipe(Effect.mapError(toValidationError("EventStore.decode.metadata")))
|
|
854
924
|
: raw.metadata;
|
|
855
925
|
}
|
|
856
|
-
|
|
926
|
+
const event = {
|
|
857
927
|
streamId: envelope.streamId,
|
|
858
928
|
version: envelope.version,
|
|
859
929
|
eventType: envelope.eventType,
|
|
860
930
|
data,
|
|
861
931
|
metadata,
|
|
862
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 } : {}),
|
|
863
936
|
};
|
|
937
|
+
return event;
|
|
864
938
|
});
|
|
865
939
|
// ---------------------------------------------------------------------------
|
|
866
940
|
// append
|
|
@@ -971,17 +1045,21 @@ export const makeStream = (config) => {
|
|
|
971
1045
|
commandId,
|
|
972
1046
|
}));
|
|
973
1047
|
}
|
|
974
|
-
//
|
|
975
|
-
//
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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);
|
|
985
1063
|
// The head proof (#164) stands in for the contiguity check, so its
|
|
986
1064
|
// own condition failing shares their verdict — ahead of the other
|
|
987
1065
|
// additional items.
|
|
@@ -1002,7 +1080,11 @@ export const makeStream = (config) => {
|
|
|
1002
1080
|
// the caller set no condition on reports what the entity's own put
|
|
1003
1081
|
// would — a taken unique value, a history conflict — or, having
|
|
1004
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.
|
|
1005
1085
|
const judged = judgeCancellation(tx.additional, rawReasons, tx.guardCount + tx.eventCount);
|
|
1086
|
+
if (judged?._tag === "stream")
|
|
1087
|
+
return Effect.fail(judged.error);
|
|
1006
1088
|
if (judged?._tag === "fail")
|
|
1007
1089
|
return Effect.fail(judged.error);
|
|
1008
1090
|
if (judged?._tag === "conditions") {
|
|
@@ -1112,8 +1194,10 @@ export const makeStream = (config) => {
|
|
|
1112
1194
|
*
|
|
1113
1195
|
* `eventPartitions` — the partition the append's events have in each
|
|
1114
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.
|
|
1115
1199
|
*/
|
|
1116
|
-
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* () {
|
|
1117
1201
|
const encoded = yield* encodeSnapshotState(state, operation);
|
|
1118
1202
|
const stored = stateStoredAs === undefined
|
|
1119
1203
|
? encoded
|
|
@@ -1151,6 +1235,9 @@ export const makeStream = (config) => {
|
|
|
1151
1235
|
item[idx.pk] = entry.partition;
|
|
1152
1236
|
item[idx.sk] = entry.key;
|
|
1153
1237
|
}
|
|
1238
|
+
// Top-level and native, beside `state`, so packing never touches it.
|
|
1239
|
+
if (sealed)
|
|
1240
|
+
item[SEALED_ATTRIBUTE] = true;
|
|
1154
1241
|
return toAttributeMap(item);
|
|
1155
1242
|
});
|
|
1156
1243
|
/**
|
|
@@ -1230,6 +1317,42 @@ export const makeStream = (config) => {
|
|
|
1230
1317
|
}
|
|
1231
1318
|
}
|
|
1232
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
|
+
}
|
|
1233
1356
|
// Nothing at all to write — preserve the historical no-op fast path.
|
|
1234
1357
|
// With additional items or a dedup sentinel the transaction still runs:
|
|
1235
1358
|
// a caller who asked for a side write means it, and silently dropping it
|
|
@@ -1386,11 +1509,20 @@ export const makeStream = (config) => {
|
|
|
1386
1509
|
if (idx.type === "gsi")
|
|
1387
1510
|
item[idx.pk] = partition;
|
|
1388
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;
|
|
1389
1516
|
return {
|
|
1390
1517
|
Put: {
|
|
1391
1518
|
TableName: tableName,
|
|
1392
1519
|
Item: toAttributeMap(item),
|
|
1393
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" } : {}),
|
|
1394
1526
|
},
|
|
1395
1527
|
};
|
|
1396
1528
|
}));
|
|
@@ -1431,7 +1563,9 @@ export const makeStream = (config) => {
|
|
|
1431
1563
|
// transaction-size and item-size diagnosis count its stored bytes.
|
|
1432
1564
|
let snapshotPut;
|
|
1433
1565
|
if (snapshotState !== undefined) {
|
|
1434
|
-
const item = yield* buildSnapshotItem(streamId, snapshotState, expectedVersion + events.length, now, "EventStore.append.snapshot", eventPartitions);
|
|
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`.
|
|
1435
1569
|
snapshotPut = {
|
|
1436
1570
|
Put: headProof !== "snapshot"
|
|
1437
1571
|
? { TableName: tableName, Item: item }
|
|
@@ -1440,15 +1574,20 @@ export const makeStream = (config) => {
|
|
|
1440
1574
|
TableName: tableName,
|
|
1441
1575
|
Item: item,
|
|
1442
1576
|
ConditionExpression: "attribute_not_exists(pk)",
|
|
1577
|
+
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
|
|
1443
1578
|
}
|
|
1444
1579
|
: {
|
|
1445
1580
|
TableName: tableName,
|
|
1446
1581
|
Item: item,
|
|
1447
|
-
ConditionExpression: "#asOfVersion = :expectedVersion",
|
|
1448
|
-
ExpressionAttributeNames: {
|
|
1582
|
+
ConditionExpression: "#asOfVersion = :expectedVersion AND attribute_not_exists(#sealed)",
|
|
1583
|
+
ExpressionAttributeNames: {
|
|
1584
|
+
"#asOfVersion": "asOfVersion",
|
|
1585
|
+
"#sealed": SEALED_ATTRIBUTE,
|
|
1586
|
+
},
|
|
1449
1587
|
ExpressionAttributeValues: toAttributeMap({
|
|
1450
1588
|
":expectedVersion": expectedVersion,
|
|
1451
1589
|
}),
|
|
1590
|
+
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
|
|
1452
1591
|
},
|
|
1453
1592
|
};
|
|
1454
1593
|
}
|
|
@@ -1463,13 +1602,20 @@ export const makeStream = (config) => {
|
|
|
1463
1602
|
* cancellation reason, mapping to VersionConflict just like a
|
|
1464
1603
|
* stale-version Put failure. A zero-event append (a pure side write)
|
|
1465
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`.
|
|
1466
1610
|
*/
|
|
1467
1611
|
const contiguityCheck = needsContiguityCheck
|
|
1468
1612
|
? {
|
|
1469
1613
|
ConditionCheck: {
|
|
1470
1614
|
TableName: tableName,
|
|
1471
1615
|
Key: toAttributeMap({ pk, sk: composeEventSk(expectedVersion) }),
|
|
1472
|
-
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",
|
|
1473
1619
|
},
|
|
1474
1620
|
}
|
|
1475
1621
|
: undefined;
|
|
@@ -1567,16 +1713,24 @@ export const makeStream = (config) => {
|
|
|
1567
1713
|
for (let attempt = 0; attempt < GUARDED_TRANSACTION_ATTEMPTS; attempt++) {
|
|
1568
1714
|
// Caller-owned items, compiled through the same builder
|
|
1569
1715
|
// `Transaction.transactWrite` uses, so the two APIs cannot drift.
|
|
1570
|
-
|
|
1716
|
+
// Another stream's append (#166) is accepted here, and only here.
|
|
1717
|
+
const additional = yield* buildTransactWriteItems(additionalOps, "EventStore.append.additionalItems", { streams: true });
|
|
1571
1718
|
// An item without a condition of its own proves nothing about the
|
|
1572
|
-
// head: accepting it would drop the gap protection silently.
|
|
1719
|
+
// head: accepting it would drop the gap protection silently. Nor
|
|
1720
|
+
// does another stream's append: its conditions are its stream's.
|
|
1573
1721
|
if (proofOp !== undefined && !additional.conditioned.has(proofOp)) {
|
|
1722
|
+
const named = additionalOps[proofOp];
|
|
1574
1723
|
return yield* new ValidationError({
|
|
1575
1724
|
entityType,
|
|
1576
1725
|
operation: "EventStore.append.headProof",
|
|
1577
|
-
cause:
|
|
1578
|
-
|
|
1579
|
-
|
|
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.",
|
|
1580
1734
|
});
|
|
1581
1735
|
}
|
|
1582
1736
|
const tx = layout(additional);
|
|
@@ -1596,6 +1750,73 @@ export const makeStream = (config) => {
|
|
|
1596
1750
|
return yield* Effect.fail(lost);
|
|
1597
1751
|
});
|
|
1598
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
|
+
// ---------------------------------------------------------------------------
|
|
1599
1820
|
// measureAppend / measureSnapshot — caller-side size planning (#147, R1)
|
|
1600
1821
|
// ---------------------------------------------------------------------------
|
|
1601
1822
|
const measureAppend = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
|
|
@@ -1785,6 +2006,20 @@ export const makeStream = (config) => {
|
|
|
1785
2006
|
});
|
|
1786
2007
|
return result.Item === undefined ? undefined : fromAttributeMap(result.Item);
|
|
1787
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
|
+
});
|
|
1788
2023
|
const readSnapshot = (streamId) => Effect.gen(function* () {
|
|
1789
2024
|
if (snapshot === undefined)
|
|
1790
2025
|
return yield* snapshotUnavailable("readSnapshot");
|
|
@@ -1801,11 +2036,16 @@ export const makeStream = (config) => {
|
|
|
1801
2036
|
// (`verifySnapshot` is ignored).
|
|
1802
2037
|
if (snapshot === undefined) {
|
|
1803
2038
|
const events = yield* read(streamId, options);
|
|
1804
|
-
|
|
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 = {
|
|
1805
2043
|
snapshot: Option.none(),
|
|
1806
2044
|
events,
|
|
1807
2045
|
version: events[events.length - 1]?.version ?? 0,
|
|
2046
|
+
...(seal !== undefined ? { seal } : {}),
|
|
1808
2047
|
};
|
|
2048
|
+
return state;
|
|
1809
2049
|
}
|
|
1810
2050
|
// `verifySnapshot: false` — the snapshot item alone, by its exact key:
|
|
1811
2051
|
// a `GetItem` reads one item where the verified `Query` reads the
|
|
@@ -1819,11 +2059,29 @@ export const makeStream = (config) => {
|
|
|
1819
2059
|
const raw = yield* getSnapshotItem(streamId, options.consistentRead === true);
|
|
1820
2060
|
if (raw !== undefined) {
|
|
1821
2061
|
const current = yield* decodeSnapshotItem(raw, "EventStore.readLatest");
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
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
|
+
}
|
|
1827
2085
|
}
|
|
1828
2086
|
}
|
|
1829
2087
|
const client = yield* DynamoClient;
|
|
@@ -1895,11 +2153,28 @@ export const makeStream = (config) => {
|
|
|
1895
2153
|
const asOfVersion = Option.isSome(latest) ? latest.value.asOfVersion : 0;
|
|
1896
2154
|
const delta = eventRaws.filter((raw) => raw.version > asOfVersion).reverse();
|
|
1897
2155
|
const events = yield* Effect.forEach(delta, (raw) => decodeStreamEvent(raw));
|
|
1898
|
-
|
|
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 = {
|
|
1899
2172
|
snapshot: latest,
|
|
1900
2173
|
events,
|
|
1901
2174
|
version: events[events.length - 1]?.version ?? asOfVersion,
|
|
2175
|
+
...(seal !== undefined ? { seal } : {}),
|
|
1902
2176
|
};
|
|
2177
|
+
return state;
|
|
1903
2178
|
});
|
|
1904
2179
|
// ---------------------------------------------------------------------------
|
|
1905
2180
|
// hasCommandSentinel (internal — see CommandSentinelProbe)
|
|
@@ -2121,6 +2396,7 @@ export const makeStream = (config) => {
|
|
|
2121
2396
|
writeSnapshot: checked(writeSnapshot),
|
|
2122
2397
|
readSnapshot: checked(readSnapshot),
|
|
2123
2398
|
append: checked(append),
|
|
2399
|
+
appendOp,
|
|
2124
2400
|
read: checked(read),
|
|
2125
2401
|
readFrom: checked(readFrom),
|
|
2126
2402
|
currentVersion: checked(currentVersion),
|
|
@@ -2179,6 +2455,17 @@ export const bind = (stream) => Effect.gen(function* () {
|
|
|
2179
2455
|
writeSnapshot: (streamId, state, asOfVersion) => provide(stream.writeSnapshot(streamId, state, asOfVersion)),
|
|
2180
2456
|
readSnapshot: (streamId) => provide(stream.readSnapshot(streamId)),
|
|
2181
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
|
+
},
|
|
2182
2469
|
read: (streamId, options) => provide(stream.read(streamId, options)),
|
|
2183
2470
|
readFrom: (streamId, afterVersion, options) => provide(stream.readFrom(streamId, afterVersion, options)),
|
|
2184
2471
|
currentVersion: (streamId, options) => provide(stream.currentVersion(streamId, options)),
|
|
@@ -2425,11 +2712,58 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
2425
2712
|
const unverifiedReadOptions = { ...readOptions, verifySnapshot: false };
|
|
2426
2713
|
return (streamId, command, callOptions) => {
|
|
2427
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
|
+
});
|
|
2428
2761
|
// 1. Establish the base state + version. A snapshot-configured stream
|
|
2429
2762
|
// loads its snapshot, the events after it and its head in one request
|
|
2430
2763
|
// (#138) — or, unverified, the snapshot item alone; any other stream
|
|
2431
2764
|
// replays from the beginning.
|
|
2432
|
-
const load = (verify) => Effect.
|
|
2765
|
+
const load = (verify) => Effect.flatMap(loadUnchecked(verify), (loaded) => refuseSealed(loaded));
|
|
2766
|
+
const loadUnchecked = (verify) => Effect.gen(function* () {
|
|
2433
2767
|
let state = decider.initialState;
|
|
2434
2768
|
// The caller's own record (#164), trusted as the head: the append's
|
|
2435
2769
|
// check (or the head proof) catches one that is not.
|
|
@@ -2451,6 +2785,7 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
2451
2785
|
snapshotAsOfVersion: undefined,
|
|
2452
2786
|
verified: true,
|
|
2453
2787
|
eventsAfterSnapshot: 0,
|
|
2788
|
+
sealedVersion: undefined,
|
|
2454
2789
|
};
|
|
2455
2790
|
return fresh;
|
|
2456
2791
|
}
|
|
@@ -2459,12 +2794,20 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
2459
2794
|
return yield* refused(`loadState returned version ${String(record.version)}: it must be a non-negative ` +
|
|
2460
2795
|
"integer, the stream version the state is as of. Nothing was written.");
|
|
2461
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
|
+
}
|
|
2462
2804
|
const loaded = {
|
|
2463
2805
|
state: record.state,
|
|
2464
2806
|
version: record.version,
|
|
2465
2807
|
snapshotAsOfVersion: undefined,
|
|
2466
2808
|
verified: true,
|
|
2467
2809
|
eventsAfterSnapshot: 0,
|
|
2810
|
+
sealedVersion,
|
|
2468
2811
|
};
|
|
2469
2812
|
return loaded;
|
|
2470
2813
|
}
|
|
@@ -2486,6 +2829,7 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
2486
2829
|
// already fallen back to the verified read.
|
|
2487
2830
|
verified: verify || Option.isNone(latest.snapshot),
|
|
2488
2831
|
eventsAfterSnapshot: latest.events.length,
|
|
2832
|
+
sealedVersion: latest.seal?.version,
|
|
2489
2833
|
};
|
|
2490
2834
|
return loaded;
|
|
2491
2835
|
}
|
|
@@ -2499,6 +2843,8 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
2499
2843
|
snapshotAsOfVersion: 0,
|
|
2500
2844
|
verified: true,
|
|
2501
2845
|
eventsAfterSnapshot: events.length,
|
|
2846
|
+
// Any sealing event (#166), not only the newest.
|
|
2847
|
+
sealedVersion: events.find((event) => event.sealed === true)?.version,
|
|
2502
2848
|
};
|
|
2503
2849
|
return loaded;
|
|
2504
2850
|
});
|
|
@@ -2612,8 +2958,10 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
2612
2958
|
appendOptions.headProof = "snapshot";
|
|
2613
2959
|
}
|
|
2614
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.
|
|
2615
2963
|
const outcome = yield* returnAppendConflict
|
|
2616
|
-
? 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)))
|
|
2617
2965
|
: appended;
|
|
2618
2966
|
if (outcome instanceof VersionConflict)
|
|
2619
2967
|
return outcome;
|
|
@@ -2642,20 +2990,10 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
2642
2990
|
// consult the sentinel before reporting the conflict. The extra read is
|
|
2643
2991
|
// paid on this conflict path only.
|
|
2644
2992
|
const ifMatchMismatch = (expected, actualVersion) => Effect.gen(function* () {
|
|
2645
|
-
|
|
2646
|
-
const commandId = callOptions?.commandId;
|
|
2647
|
-
if (options?.idempotency !== undefined && commandId !== undefined && probe !== undefined) {
|
|
2648
|
-
if (yield* probe(streamId, commandId)) {
|
|
2649
|
-
return yield* new DuplicateCommand({
|
|
2650
|
-
streamName: stream.streamName,
|
|
2651
|
-
streamId: formatStreamIdOf(stream, streamId),
|
|
2652
|
-
commandId,
|
|
2653
|
-
});
|
|
2654
|
-
}
|
|
2655
|
-
}
|
|
2993
|
+
yield* failIfCommitted;
|
|
2656
2994
|
return yield* new VersionConflict({
|
|
2657
2995
|
streamName: stream.streamName,
|
|
2658
|
-
streamId:
|
|
2996
|
+
streamId: ownStreamId,
|
|
2659
2997
|
expectedVersion: expected,
|
|
2660
2998
|
actualVersion,
|
|
2661
2999
|
});
|
|
@@ -2801,9 +3139,11 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
2801
3139
|
return (yield* commit(verified, newEvents, false));
|
|
2802
3140
|
});
|
|
2803
3141
|
// A caller-supplied `expectedVersion` is a conditional write: its
|
|
2804
|
-
// `VersionConflict` (pre-decide or from the append) is the
|
|
2805
|
-
// cue to re-read and re-decide.
|
|
2806
|
-
|
|
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
|
|
2807
3147
|
? attempt
|
|
2808
3148
|
: Effect.retry(attempt, {
|
|
2809
3149
|
// `while` runs before the schedule, so the schedule only ever sees a
|
|
@@ -2811,10 +3151,12 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
2811
3151
|
// because `Retry.Options` demands a schedule whose input accepts the
|
|
2812
3152
|
// effect's *full* error union, and `Schedule` is contravariant on input.
|
|
2813
3153
|
schedule: schedule,
|
|
2814
|
-
// `VersionConflict` only. `DuplicateCommand`
|
|
2815
|
-
// commandId
|
|
2816
|
-
//
|
|
2817
|
-
|
|
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)),
|
|
2818
3160
|
});
|
|
2819
3161
|
};
|
|
2820
3162
|
};
|