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.
Files changed (36) hide show
  1. package/dist/Entity.d.ts +49 -2
  2. package/dist/Entity.d.ts.map +1 -1
  3. package/dist/Entity.js +291 -55
  4. package/dist/Entity.js.map +1 -1
  5. package/dist/EventStore.d.ts +255 -12
  6. package/dist/EventStore.d.ts.map +1 -1
  7. package/dist/EventStore.js +396 -54
  8. package/dist/EventStore.js.map +1 -1
  9. package/dist/Transaction.d.ts +18 -4
  10. package/dist/Transaction.d.ts.map +1 -1
  11. package/dist/Transaction.js +23 -3
  12. package/dist/Transaction.js.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/AttributeValueEquals.d.ts +10 -0
  18. package/dist/internal/AttributeValueEquals.d.ts.map +1 -0
  19. package/dist/internal/AttributeValueEquals.js +47 -0
  20. package/dist/internal/AttributeValueEquals.js.map +1 -0
  21. package/dist/internal/BoundCrud.d.ts +8 -0
  22. package/dist/internal/BoundCrud.d.ts.map +1 -1
  23. package/dist/internal/BoundCrud.js.map +1 -1
  24. package/dist/internal/TransactPlan.d.ts +77 -0
  25. package/dist/internal/TransactPlan.d.ts.map +1 -0
  26. package/dist/internal/TransactPlan.js +135 -0
  27. package/dist/internal/TransactPlan.js.map +1 -0
  28. package/dist/internal/TransactWriteOps.d.ts +140 -15
  29. package/dist/internal/TransactWriteOps.d.ts.map +1 -1
  30. package/dist/internal/TransactWriteOps.js +154 -7
  31. package/dist/internal/TransactWriteOps.js.map +1 -1
  32. package/dist/internal/TransactableOps.d.ts +26 -6
  33. package/dist/internal/TransactableOps.d.ts.map +1 -1
  34. package/dist/internal/TransactableOps.js +33 -7
  35. package/dist/internal/TransactableOps.js.map +1 -1
  36. package/package.json +2 -2
@@ -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`, or the
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
- return {
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
- // The contiguity ConditionCheck and the event puts both mean "the
975
- // stream is not where you said it was", so they share one verdict.
976
- for (let i = 0; i < tx.guardCount + tx.eventCount; i++) {
977
- if (failedAt(i)) {
978
- return Effect.fail(new VersionConflict({
979
- streamName: config.streamName,
980
- streamId: streamIdStr,
981
- expectedVersion: tx.expectedVersion,
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: { "#asOfVersion": "asOfVersion" },
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
- 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 });
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: `headProof names additionalItems[${proofOp}], which carries no condition of its ` +
1578
- "own: the proof must be conditioned on the stream being at expectedVersion " +
1579
- "(`.condition()`, `create()` or `Transaction.check`). Nothing was written.",
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
- 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 = {
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
- return {
1823
- snapshot: Option.some(current),
1824
- events: [],
1825
- version: current.asOfVersion,
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
- 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 = {
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.gen(function* () {
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
- const probe = commandSentinelProbeOf(stream);
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: formatStreamIdOf(stream, 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 answer, never a
2805
- // cue to re-read and re-decide.
2806
- 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
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` is terminal — the same
2815
- // commandId can never succeed — and `AdditionalItemConditionFailed`
2816
- // will not resolve itself by re-deciding either.
2817
- 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)),
2818
3160
  });
2819
3161
  };
2820
3162
  };