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.
@@ -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`, or the
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
- return {
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
- // The contiguity ConditionCheck and the event puts both mean "the
939
- // stream is not where you said it was", so they share one verdict.
940
- for (let i = 0; i < tx.guardCount + tx.eventCount; i++) {
941
- if (failedAt(i)) {
942
- return Effect.fail(new VersionConflict({
943
- streamName: config.streamName,
944
- streamId: streamIdStr,
945
- expectedVersion: tx.expectedVersion,
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
- const needsContiguityCheck = expectedVersion > 0 && events.length > 0;
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
- // Packed per `snapshot.storedAs` (#147), so the transaction-size and
1352
- // item-size diagnosis count its stored bytes.
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
- Item: yield* buildSnapshotItem(streamId, snapshotState, expectedVersion + events.length, now, "EventStore.append.snapshot", eventPartitions),
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
- const additional = yield* buildTransactWriteItems(additionalOps, "EventStore.append.additionalItems");
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
- return {
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
- return {
1702
- snapshot: Option.some(current),
1703
- events: [],
1704
- version: current.asOfVersion,
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
- return {
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.gen(function* () {
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 - loaded.snapshotAsOfVersion >= every)) {
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
- const probe = commandSentinelProbeOf(stream);
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: formatStreamIdOf(stream, 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 answer, never a
2604
- // cue to re-read and re-decide.
2605
- return schedule === undefined || expectedVersion !== undefined
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` is terminal — the same
2614
- // commandId can never succeed — and `AdditionalItemConditionFailed`
2615
- // will not resolve itself by re-deciding either.
2616
- while: (error) => error instanceof VersionConflict,
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
  };