effect-dynamodb 1.36.1 → 1.37.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 (86) hide show
  1. package/dist/Aggregate.d.ts +29 -17
  2. package/dist/Aggregate.d.ts.map +1 -1
  3. package/dist/Aggregate.js +795 -173
  4. package/dist/Aggregate.js.map +1 -1
  5. package/dist/Batch.d.ts.map +1 -1
  6. package/dist/Batch.js +196 -77
  7. package/dist/Batch.js.map +1 -1
  8. package/dist/Collection.d.ts.map +1 -1
  9. package/dist/Collection.js +11 -2
  10. package/dist/Collection.js.map +1 -1
  11. package/dist/Collections.d.ts +15 -6
  12. package/dist/Collections.d.ts.map +1 -1
  13. package/dist/Collections.js +2 -1
  14. package/dist/Collections.js.map +1 -1
  15. package/dist/DynamoClient.d.ts +117 -10
  16. package/dist/DynamoClient.d.ts.map +1 -1
  17. package/dist/DynamoClient.js +1145 -141
  18. package/dist/DynamoClient.js.map +1 -1
  19. package/dist/Entity.d.ts +126 -51
  20. package/dist/Entity.d.ts.map +1 -1
  21. package/dist/Entity.js +3477 -539
  22. package/dist/Entity.js.map +1 -1
  23. package/dist/EventStore.d.ts +4 -3
  24. package/dist/EventStore.d.ts.map +1 -1
  25. package/dist/EventStore.js +616 -53
  26. package/dist/EventStore.js.map +1 -1
  27. package/dist/Query.d.ts +311 -25
  28. package/dist/Query.d.ts.map +1 -1
  29. package/dist/Query.js +1159 -103
  30. package/dist/Query.js.map +1 -1
  31. package/dist/Table.d.ts +172 -3
  32. package/dist/Table.d.ts.map +1 -1
  33. package/dist/Table.js +85 -4
  34. package/dist/Table.js.map +1 -1
  35. package/dist/Transaction.d.ts +18 -2
  36. package/dist/Transaction.d.ts.map +1 -1
  37. package/dist/Transaction.js +62 -8
  38. package/dist/Transaction.js.map +1 -1
  39. package/dist/index.d.ts +6 -3
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/internal/BoundQuery.d.ts +226 -32
  44. package/dist/internal/BoundQuery.d.ts.map +1 -1
  45. package/dist/internal/BoundQuery.js +96 -18
  46. package/dist/internal/BoundQuery.js.map +1 -1
  47. package/dist/internal/Capacity.d.ts +8 -0
  48. package/dist/internal/Capacity.d.ts.map +1 -1
  49. package/dist/internal/Capacity.js +24 -4
  50. package/dist/internal/Capacity.js.map +1 -1
  51. package/dist/internal/Cursor.d.ts +90 -0
  52. package/dist/internal/Cursor.d.ts.map +1 -0
  53. package/dist/internal/Cursor.js +179 -0
  54. package/dist/internal/Cursor.js.map +1 -0
  55. package/dist/internal/ExpressionLimits.d.ts +18 -2
  56. package/dist/internal/ExpressionLimits.d.ts.map +1 -1
  57. package/dist/internal/ExpressionLimits.js +35 -2
  58. package/dist/internal/ExpressionLimits.js.map +1 -1
  59. package/dist/internal/ProjectionEngine.d.ts +16 -0
  60. package/dist/internal/ProjectionEngine.d.ts.map +1 -1
  61. package/dist/internal/ProjectionEngine.js +242 -46
  62. package/dist/internal/ProjectionEngine.js.map +1 -1
  63. package/dist/internal/RangePlanner.d.ts +394 -0
  64. package/dist/internal/RangePlanner.d.ts.map +1 -0
  65. package/dist/internal/RangePlanner.js +1296 -0
  66. package/dist/internal/RangePlanner.js.map +1 -0
  67. package/dist/internal/Rekey.d.ts +78 -0
  68. package/dist/internal/Rekey.d.ts.map +1 -0
  69. package/dist/internal/Rekey.js +323 -0
  70. package/dist/internal/Rekey.js.map +1 -0
  71. package/dist/internal/TransactPlan.d.ts +8 -1
  72. package/dist/internal/TransactPlan.d.ts.map +1 -1
  73. package/dist/internal/TransactPlan.js +9 -2
  74. package/dist/internal/TransactPlan.js.map +1 -1
  75. package/dist/internal/TransactWriteOps.d.ts +10 -7
  76. package/dist/internal/TransactWriteOps.d.ts.map +1 -1
  77. package/dist/internal/TransactWriteOps.js +82 -6
  78. package/dist/internal/TransactWriteOps.js.map +1 -1
  79. package/dist/internal/TransactableOps.d.ts +31 -0
  80. package/dist/internal/TransactableOps.d.ts.map +1 -1
  81. package/dist/internal/TransactableOps.js +50 -1
  82. package/dist/internal/TransactableOps.js.map +1 -1
  83. package/dist/internal/WriteDiagnosis.d.ts.map +1 -1
  84. package/dist/internal/WriteDiagnosis.js +10 -7
  85. package/dist/internal/WriteDiagnosis.js.map +1 -1
  86. package/package.json +2 -2
@@ -49,16 +49,20 @@ import { isPackedConfig } from "@effect-dynamodb/schema/DynamoModel.js";
49
49
  import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
50
50
  import { normalizeTtlSeconds } from "@effect-dynamodb/schema/Entity.js";
51
51
  import { AdditionalItemConditionFailed, AppendTooLarge, DuplicateCommand, isAwsConditionalCheckFailed, isAwsTransactionCancelled, isAwsValidationError, StreamSealed, TransactionCancelled, ValidationError, VersionConflict, } from "@effect-dynamodb/schema/Errors.js";
52
+ import * as KeyText136 from "@effect-dynamodb/schema/internal/KeyText136.js";
52
53
  import { isDispatchEligible, packedShape, packValue, unpackStoredSync, } from "@effect-dynamodb/schema/internal/PackedCodec.js";
53
54
  import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
54
55
  import { Context, DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
56
+ import { attributeValueEquals } from "./internal/AttributeValueEquals.js";
55
57
  import * as Capacity from "./internal/Capacity.js";
56
58
  import { ProjectionRoutes } from "./internal/EventProjection.js";
57
- import { eq } from "./internal/Expr.js";
59
+ import { eq, or } from "./internal/Expr.js";
60
+ import { asReadGuard } from "./internal/ExpressionLimits.js";
58
61
  import { itemBytes, transactItemBytes } from "./internal/ItemSize.js";
59
62
  import { isMultiProjection, } from "./internal/MultiProjection.js";
60
63
  import { codecRequirements, currentRegistry, decodeImage, imageEntityType, runtimeCodecCheck, unregisteredCodecError, withRegistry, } from "./internal/Packed.js";
61
64
  import { createPathBuilder } from "./internal/PathBuilder.js";
65
+ import * as Rekey from "./internal/Rekey.js";
62
66
  import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, isStreamAppendOp, judgeCancellation, opConditionFailed, StreamAppendTypeId, transactItemTarget, } from "./internal/TransactWriteOps.js";
63
67
  import { diagnosedValidationError, diagnoseFailure, mergeKeyAttributes, withCause, } from "./internal/WriteDiagnosis.js";
64
68
  import { fromAttributeMap, toAttributeMap } from "./Marshaller.js";
@@ -161,6 +165,15 @@ const SEALED_ATTRIBUTE = "__edd_sealed__";
161
165
  * same version (a conflict), however alike their content.
162
166
  */
163
167
  const WRITE_TOKEN_ATTRIBUTE = "__edd_wt__";
168
+ /**
169
+ * @internal Set by `rekey` (#171) on the first event of a stream it moved
170
+ * from its 1.36 partition key, once every row is copied to the current one:
171
+ * the stream lives there from then on. `locateStream` reads it; an append
172
+ * located at the 1.36 key checks it is absent (`AppendParts.locationCheck`),
173
+ * so a writer that located the stream before the move fails with
174
+ * `VersionConflict` instead of writing where nothing reads.
175
+ */
176
+ const MOVED_ATTRIBUTE = "__edd_moved__";
164
177
  /** @internal The write token's length in hex digits: 128 random bits. */
165
178
  const WRITE_TOKEN_LENGTH = 32;
166
179
  /** @internal A fresh write token, from the platform's Web Crypto (CSPRNG). */
@@ -243,7 +256,7 @@ const putItemReason = (cause) => {
243
256
  };
244
257
  /**
245
258
  * @internal What a cancelled append means for one stream, from the reasons of
246
- * its own items: `[0, guardCount)` the contiguity check,
259
+ * its own items: `[0, guardCount)` the contiguity and location checks,
247
260
  * `[guardCount, guardCount + eventCount)` the event puts, and the snapshot at
248
261
  * `snapshotIndex` (`-1`: none). `StreamSealed` when the contiguity check, the
249
262
  * first event put or the snapshot failed and returned a sealed item (#166);
@@ -301,6 +314,7 @@ const STREAM_OWNED_ATTRIBUTES = [
301
314
  "commandId",
302
315
  SEALED_ATTRIBUTE,
303
316
  WRITE_TOKEN_ATTRIBUTE,
317
+ MOVED_ATTRIBUTE,
304
318
  DEFAULT_TTL_ATTRIBUTE_NAME,
305
319
  ];
306
320
  /** @internal Every member a stream index entry may declare (`StreamIndexConfigKey`). */
@@ -319,7 +333,12 @@ const STREAM_INDEX_CONFIG_KEYS = [
319
333
  * stream index several streams (or stream ids) share (#149). The table's `pk`
320
334
  * is projected into every index, whatever its projection.
321
335
  */
322
- const streamPkFilter = (streamPk) => eq(createPathBuilder().pk, streamPk);
336
+ const streamPkFilter = (streamPk, streamPk136) => {
337
+ const pk = createPathBuilder().pk;
338
+ // A stream still under its 1.36 key (#171) has its items there: either
339
+ // key, since a stream is never split across the two.
340
+ return streamPk136 === undefined ? eq(pk, streamPk) : or(eq(pk, streamPk), eq(pk, streamPk136));
341
+ };
323
342
  /**
324
343
  * @internal Validate `makeStream({ indexes })` at definition time.
325
344
  *
@@ -907,6 +926,64 @@ export const makeStream = (config) => {
907
926
  const composites = KeyComposer.extractComposites(compositeFields, streamId);
908
927
  return DynamoSchema.composeKey(schema, keyLabel, composites, keyOptions);
909
928
  };
929
+ /**
930
+ * The partition key 1.36 composed for `streamId`, when the 1.36 key
931
+ * fallback is on and its text differs from {@link composeStreamPk}'s — a
932
+ * number stream id composite whose text changed (#171). `force` answers
933
+ * with the fallback off too: `rekey` moves streams whatever it is set to.
934
+ */
935
+ const composeStreamPk136 = (streamId, force = false) => {
936
+ if (!force && !KeyText136.enabled(schema))
937
+ return undefined;
938
+ const composites = KeyText136.extractComposites(compositeFields, streamId);
939
+ const pk136 = DynamoSchema.composeKey(schema, keyLabel, composites, keyOptions);
940
+ return pk136 === composeStreamPk(streamId) ? undefined : pk136;
941
+ };
942
+ /**
943
+ * The partition key a stream lives under (#171, DESIGN.md §7 "Rows written
944
+ * by 1.36"): its current key, unless that key's text changed since 1.36 and
945
+ * the stream's first event is under its 1.36 key. A stream 1.36 started is
946
+ * read and appended there — its events, snapshot, seal and command
947
+ * sentinels — until `rekey` moves it whole (and marks its first event
948
+ * there moved); a stream is never split across the two keys, and a new one
949
+ * starts under its current key. A stream whose key text did not change
950
+ * costs no request; one whose did, one consistent `GetItem` of the first
951
+ * event's key.
952
+ */
953
+ const locateStream = (streamId) => composeStreamPk136(streamId) === undefined
954
+ ? Effect.succeed(composeStreamPk(streamId))
955
+ : Effect.gen(function* () {
956
+ const { name: tableName } = yield* config.table.Tag;
957
+ return yield* locateStreamIn(tableName, streamId);
958
+ });
959
+ /** {@link locateStream} in the table named `tableName`. */
960
+ const locateStreamIn = (tableName, streamId) => {
961
+ const current = composeStreamPk(streamId);
962
+ const pk136 = composeStreamPk136(streamId);
963
+ if (pk136 === undefined)
964
+ return Effect.succeed(current);
965
+ return Effect.gen(function* () {
966
+ const client = yield* Capacity.client;
967
+ const first = yield* client.getItem({
968
+ TableName: tableName,
969
+ Key: toAttributeMap({ pk: pk136, sk: composeEventSk(1) }),
970
+ ConsistentRead: true,
971
+ ProjectionExpression: "#pk, #moved",
972
+ ExpressionAttributeNames: { "#pk": "pk", "#moved": MOVED_ATTRIBUTE },
973
+ });
974
+ return first.Item === undefined || first.Item[MOVED_ATTRIBUTE] !== undefined ? current : pk136;
975
+ });
976
+ };
977
+ /**
978
+ * The `prepare` step of a query of the stream's partition: read it where
979
+ * the stream lives ({@link locateStream}). Only a stream whose key text
980
+ * changed has one.
981
+ */
982
+ const locatedPrepare = (streamId) => composeStreamPk136(streamId) === undefined
983
+ ? {}
984
+ : {
985
+ prepare: (tableName) => Effect.map(locateStreamIn(tableName, streamId), (pkValue) => ({ pkValue })),
986
+ };
910
987
  const composeEventSk = (version) => DynamoSchema.composeEventVersionKey(schema, eventKeyLabel, version, keyOptions);
911
988
  /** Sort key of a command's idempotency sentinel. */
912
989
  const composeCommandSk = (commandId) => DynamoSchema.composeKey(schema, commandKeyLabel, [commandId], keyOptions);
@@ -1352,7 +1429,9 @@ export const makeStream = (config) => {
1352
1429
  * `sealed` writes the seal marker (#166): only a sealing append's inline
1353
1430
  * snapshot carries it.
1354
1431
  */
1355
- const buildSnapshotItem = (streamId, state, asOfVersion, now, operation, eventPartitions, sealed = false) => Effect.gen(function* () {
1432
+ const buildSnapshotItem = (streamId,
1433
+ /** The partition the stream lives under ({@link locateStream}). */
1434
+ pk, state, asOfVersion, now, operation, eventPartitions, sealed = false) => Effect.gen(function* () {
1356
1435
  const encoded = yield* encodeSnapshotState(state, operation);
1357
1436
  const stored = stateStoredAs === undefined
1358
1437
  ? encoded
@@ -1362,7 +1441,7 @@ export const makeStream = (config) => {
1362
1441
  operation: `${operation}.packed`,
1363
1442
  });
1364
1443
  const item = {
1365
- pk: composeStreamPk(streamId),
1444
+ pk,
1366
1445
  sk: snapshotSk,
1367
1446
  __edd_e__: snapshotEntityType,
1368
1447
  streamId: composeStreamIdString(streamId),
@@ -1402,7 +1481,9 @@ export const makeStream = (config) => {
1402
1481
  * the request `append` would send. `undefined` for an append with nothing
1403
1482
  * to write (the historical no-op).
1404
1483
  */
1405
- const buildAppend = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
1484
+ const buildAppend = (streamId, events, expectedVersion, options,
1485
+ /** Where the stream lives ({@link locateStream}); its current key when not given. */
1486
+ streamPk) => Effect.gen(function* () {
1406
1487
  const additionalOps = options?.additionalItems ?? [];
1407
1488
  const idempotency = options?.idempotency;
1408
1489
  const snapshotState = options?.snapshot;
@@ -1552,7 +1633,7 @@ export const makeStream = (config) => {
1552
1633
  "Nothing was written.",
1553
1634
  });
1554
1635
  }
1555
- const pk = composeStreamPk(streamId);
1636
+ const pk = streamPk ?? composeStreamPk(streamId);
1556
1637
  // Clock-backed timestamp (deterministic under TestClock; wall-clock in prod).
1557
1638
  const nowDateTime = yield* DateTime.now;
1558
1639
  const now = DateTime.formatIso(nowDateTime);
@@ -1718,7 +1799,7 @@ export const makeStream = (config) => {
1718
1799
  // transaction-size and item-size diagnosis count its stored bytes.
1719
1800
  let snapshotPut;
1720
1801
  if (snapshotState !== undefined) {
1721
- const item = yield* buildSnapshotItem(streamId, snapshotState, expectedVersion + events.length, now, "EventStore.append.snapshot", eventPartitions, sealing);
1802
+ const item = yield* buildSnapshotItem(streamId, pk, snapshotState, expectedVersion + events.length, now, "EventStore.append.snapshot", eventPartitions, sealing);
1722
1803
  // As a head proof, a sealed snapshot fails the proof (#166), and
1723
1804
  // returns its item so the failure is reported as `StreamSealed`.
1724
1805
  snapshotPut = {
@@ -1763,21 +1844,51 @@ export const makeStream = (config) => {
1763
1844
  * costs nothing; `ALL_OLD` returns the item on failure (response bytes
1764
1845
  * only), so a sealed one is reported as `StreamSealed`.
1765
1846
  */
1847
+ // A stream still under its 1.36 key (#171) must not have been moved
1848
+ // since it was located: `rekey` marks its first event once the stream
1849
+ // is copied (`MOVED_ATTRIBUTE`). Checked on the first event — by the
1850
+ // contiguity check when it reads that event — for an append that
1851
+ // writes anything into the stream's partition: events onto the
1852
+ // stream, or a command sentinel alone (a command that decided no
1853
+ // events, recorded for exactly-once), which would otherwise land where
1854
+ // nothing reads it. An append writing events from version 0 needs no
1855
+ // check: its first Put fails on its own, the first event being there.
1856
+ const located136 = streamPk !== undefined &&
1857
+ streamPk !== composeStreamPk(streamId) &&
1858
+ (events.length > 0 ? expectedVersion > 0 : sentinelPut !== undefined);
1859
+ const foldLocation = located136 && needsContiguityCheck && expectedVersion === 1;
1766
1860
  const contiguityCheck = needsContiguityCheck
1767
1861
  ? {
1768
1862
  ConditionCheck: {
1769
1863
  TableName: tableName,
1770
1864
  Key: toAttributeMap({ pk, sk: composeEventSk(expectedVersion) }),
1771
- ConditionExpression: "attribute_exists(#pk) AND attribute_not_exists(#sealed)",
1772
- ExpressionAttributeNames: { "#pk": "pk", "#sealed": SEALED_ATTRIBUTE },
1865
+ ConditionExpression: foldLocation
1866
+ ? "attribute_exists(#pk) AND attribute_not_exists(#sealed) AND attribute_not_exists(#moved)"
1867
+ : "attribute_exists(#pk) AND attribute_not_exists(#sealed)",
1868
+ ExpressionAttributeNames: {
1869
+ "#pk": "pk",
1870
+ "#sealed": SEALED_ATTRIBUTE,
1871
+ ...(foldLocation && { "#moved": MOVED_ATTRIBUTE }),
1872
+ },
1773
1873
  ReturnValuesOnConditionCheckFailure: "ALL_OLD",
1774
1874
  },
1775
1875
  }
1776
1876
  : undefined;
1877
+ const locationCheck = located136 && !foldLocation
1878
+ ? {
1879
+ ConditionCheck: {
1880
+ TableName: tableName,
1881
+ Key: toAttributeMap({ pk, sk: composeEventSk(1) }),
1882
+ ConditionExpression: "attribute_exists(#pk) AND attribute_not_exists(#moved)",
1883
+ ExpressionAttributeNames: { "#pk": "pk", "#moved": MOVED_ATTRIBUTE },
1884
+ },
1885
+ }
1886
+ : undefined;
1777
1887
  const parts = {
1778
1888
  tableName,
1779
1889
  streamIdStr,
1780
1890
  contiguityCheck,
1891
+ locationCheck,
1781
1892
  eventItems,
1782
1893
  sentinelPut,
1783
1894
  snapshotPut,
@@ -1803,14 +1914,17 @@ export const makeStream = (config) => {
1803
1914
  "Nothing was written.",
1804
1915
  });
1805
1916
  }
1806
- const parts = yield* buildAppend(streamId, events, expectedVersion, options);
1917
+ // A stream 1.36 started is appended where it lives (#171).
1918
+ const pk = yield* locateStream(streamId);
1919
+ const parts = yield* buildAppend(streamId, events, expectedVersion, options, pk);
1807
1920
  if (parts === undefined)
1808
1921
  return { version: expectedVersion, events: [] };
1809
- const { tableName, streamIdStr, contiguityCheck, eventItems, sentinelPut, snapshotPut } = parts;
1922
+ const { tableName, streamIdStr, contiguityCheck, locationCheck, eventItems, sentinelPut, snapshotPut, } = parts;
1810
1923
  const client = yield* Capacity.client;
1811
1924
  /**
1812
1925
  * Lay out the append's one transaction — see {@link AppendTransaction}:
1813
- * [0, C) version-contiguity ConditionCheck (C is 0 or 1)
1926
+ * [0, C) version-contiguity ConditionCheck, then the
1927
+ * 1.36 location check (#171) — C is 0 to 2
1814
1928
  * [C, C + E) event puts
1815
1929
  * [C + E, C + E + A) additional ITEMS (caller op order preserved)
1816
1930
  * then idempotency sentinel, then inline snapshot
@@ -1834,6 +1948,9 @@ export const makeStream = (config) => {
1834
1948
  if (contiguityCheck !== undefined) {
1835
1949
  pushStreamItem(contiguityCheck, entityType, "the version-contiguity check");
1836
1950
  }
1951
+ if (locationCheck !== undefined) {
1952
+ pushStreamItem(locationCheck, entityType, "the check that rekey has not moved the stream");
1953
+ }
1837
1954
  const guardCount = items.length;
1838
1955
  for (const [i, item] of eventItems.entries()) {
1839
1956
  pushStreamItem(item, entityType, `the event at version ${expectedVersion + i + 1}`);
@@ -1927,7 +2044,7 @@ export const makeStream = (config) => {
1927
2044
  return yield* refuse(`An append op of stream "${config.streamName}" needs at least one event: with none ` +
1928
2045
  "it writes nothing. Nothing was written.");
1929
2046
  }
1930
- const parts = (yield* buildAppend(streamId, events, expectedVersion, options));
2047
+ const parts = (yield* buildAppend(streamId, events, expectedVersion, options, yield* locateStream(streamId)));
1931
2048
  const items = [];
1932
2049
  const sources = [];
1933
2050
  const label = `${source} (stream "${config.streamName}", ${parts.streamIdStr})`;
@@ -1935,6 +2052,10 @@ export const makeStream = (config) => {
1935
2052
  items.push(parts.contiguityCheck);
1936
2053
  sources.push([entityType, `${label}: the version-contiguity check`]);
1937
2054
  }
2055
+ if (parts.locationCheck !== undefined) {
2056
+ items.push(parts.locationCheck);
2057
+ sources.push([entityType, `${label}: the check that rekey has not moved the stream`]);
2058
+ }
1938
2059
  const guardCount = items.length;
1939
2060
  for (const [i, item] of parts.eventItems.entries()) {
1940
2061
  items.push(item);
@@ -2014,7 +2135,7 @@ export const makeStream = (config) => {
2014
2135
  if (snapshot === undefined)
2015
2136
  return yield* snapshotUnavailable("measureSnapshot");
2016
2137
  const now = DateTime.formatIso(yield* DateTime.now);
2017
- const item = yield* buildSnapshotItem(streamId, state, asOfVersion, now, "EventStore.measureSnapshot");
2138
+ const item = yield* buildSnapshotItem(streamId, composeStreamPk(streamId), state, asOfVersion, now, "EventStore.measureSnapshot");
2018
2139
  return itemBytes(item);
2019
2140
  });
2020
2141
  // ---------------------------------------------------------------------------
@@ -2105,40 +2226,82 @@ export const makeStream = (config) => {
2105
2226
  const client = yield* Capacity.client;
2106
2227
  const { name: tableName } = yield* config.table.Tag;
2107
2228
  const now = DateTime.formatIso(yield* DateTime.now);
2108
- // Packed per `snapshot.storedAs` (#147): the diagnosis below measures
2109
- // the stored bytes.
2110
- const item = yield* buildSnapshotItem(streamId, state, asOfVersion, now, "EventStore.writeSnapshot");
2111
- const put = {
2112
- TableName: tableName,
2113
- Item: item,
2114
- // Monotonic: never regress the cache. Losing this race is a no-op,
2115
- // not an error — the events it summarises are already durable.
2116
- ConditionExpression: "attribute_not_exists(#pk) OR #asOfVersion < :asOfVersion",
2117
- ExpressionAttributeNames: { "#pk": "pk", "#asOfVersion": "asOfVersion" },
2118
- ExpressionAttributeValues: toAttributeMap({ ":asOfVersion": asOfVersion }),
2119
- };
2120
- yield* client.putItem(put).pipe(Effect.catchIf((error) => isAwsConditionalCheckFailed(error.cause), () => Effect.void),
2121
- // An oversized snapshot is DynamoDB's to reject (#144); named here.
2122
- diagnoseFailure({
2123
- sent: () => ({
2124
- _tag: "Item",
2125
- item: { Put: put },
2126
- target: () => transactItemTarget({ Put: put }, tableName, ["pk", "sk"], snapshotEntityType, "the snapshot"),
2127
- }),
2128
- keys: () => ({
2129
- partition: ["pk", ...snapshotIndexes.map((idx) => idx.pk)],
2130
- sort: ["sk", ...snapshotIndexes.map((idx) => idx.sk)],
2131
- }),
2132
- // A snapshot index entry's empty or over-long value (#149) is named
2133
- // by its index, as an inline snapshot's is.
2134
- rewrite: (diagnosis, original) => (diagnosis._tag === "KeyTooLong" || diagnosis._tag === "EmptyKey"
2135
- ? indexKeyError(diagnosis, original, true, "the snapshot", "EventStore.writeSnapshot")
2136
- : undefined) ??
2137
- diagnosedValidationError(diagnosis, original, {
2138
- operation: "EventStore.writeSnapshot",
2139
- entityType: snapshotEntityType,
2229
+ const keys = () => ({
2230
+ partition: ["pk", ...snapshotIndexes.map((idx) => idx.pk)],
2231
+ sort: ["sk", ...snapshotIndexes.map((idx) => idx.sk)],
2232
+ });
2233
+ // A snapshot index entry's empty or over-long value (#149) is named
2234
+ // by its index, as an inline snapshot's is.
2235
+ const rewrite = (diagnosis, original) => (diagnosis._tag === "KeyTooLong" || diagnosis._tag === "EmptyKey"
2236
+ ? indexKeyError(diagnosis, original, true, "the snapshot", "EventStore.writeSnapshot")
2237
+ : undefined) ??
2238
+ diagnosedValidationError(diagnosis, original, {
2239
+ operation: "EventStore.writeSnapshot",
2240
+ entityType: snapshotEntityType,
2241
+ });
2242
+ // A stream located under its 1.36 key (#171) that `rekey` moves after
2243
+ // the locate is written at its current key on a second pass.
2244
+ for (let pass = 0; pass < 2; pass++) {
2245
+ const pk = yield* locateStream(streamId);
2246
+ // Packed per `snapshot.storedAs` (#147): the diagnosis below measures
2247
+ // the stored bytes.
2248
+ const item = yield* buildSnapshotItem(streamId, pk, state, asOfVersion, now, "EventStore.writeSnapshot");
2249
+ const put = {
2250
+ TableName: tableName,
2251
+ Item: item,
2252
+ // Monotonic: never regress the cache. Losing this race is a no-op,
2253
+ // not an error — the events it summarises are already durable.
2254
+ ConditionExpression: "attribute_not_exists(#pk) OR #asOfVersion < :asOfVersion",
2255
+ ExpressionAttributeNames: { "#pk": "pk", "#asOfVersion": "asOfVersion" },
2256
+ ExpressionAttributeValues: toAttributeMap({ ":asOfVersion": asOfVersion }),
2257
+ };
2258
+ const target = (written) => () => transactItemTarget(written, tableName, ["pk", "sk"], snapshotEntityType, "the snapshot");
2259
+ if (pk === composeStreamPk(streamId)) {
2260
+ return yield* client.putItem(put).pipe(Effect.catchIf((error) => isAwsConditionalCheckFailed(error.cause), () => Effect.void),
2261
+ // An oversized snapshot is DynamoDB's to reject (#144); named here.
2262
+ diagnoseFailure({
2263
+ sent: () => ({ _tag: "Item", item: { Put: put }, target: target({ Put: put }) }),
2264
+ keys,
2265
+ rewrite,
2266
+ }));
2267
+ }
2268
+ // Under its 1.36 key, the snapshot is written there only while the
2269
+ // stream is — checked on its first event, as an append checks it.
2270
+ const items = [
2271
+ { Put: put },
2272
+ {
2273
+ ConditionCheck: {
2274
+ TableName: tableName,
2275
+ Key: toAttributeMap({ pk, sk: composeEventSk(1) }),
2276
+ ConditionExpression: "attribute_exists(#pk) AND attribute_not_exists(#moved)",
2277
+ ExpressionAttributeNames: { "#pk": "pk", "#moved": MOVED_ATTRIBUTE },
2278
+ },
2279
+ },
2280
+ ];
2281
+ const moved = yield* client.transactWriteItems({ TransactItems: [...items] }).pipe(Effect.as(false), Effect.catch((error) => {
2282
+ if (!isAwsTransactionCancelled(error.cause))
2283
+ return Effect.fail(error);
2284
+ const reasons = error.cause.CancellationReasons ?? [];
2285
+ if (reasons[1]?.Code === "ConditionalCheckFailed")
2286
+ return Effect.succeed(true);
2287
+ return reasons[0]?.Code === "ConditionalCheckFailed"
2288
+ ? Effect.succeed(false)
2289
+ : Effect.fail(error);
2290
+ }), diagnoseFailure({
2291
+ sent: () => ({
2292
+ _tag: "Transaction",
2293
+ items,
2294
+ targets: () => [
2295
+ target(items[0])(),
2296
+ transactItemTarget(items[1], tableName, ["pk", "sk"], entityType, "the check that rekey has not moved the stream"),
2297
+ ],
2140
2298
  }),
2141
- }));
2299
+ keys,
2300
+ rewrite,
2301
+ }));
2302
+ if (!moved)
2303
+ return;
2304
+ }
2142
2305
  });
2143
2306
  /**
2144
2307
  * Decode a stored snapshot item. A snapshot that fails to decode through the
@@ -2161,7 +2324,7 @@ export const makeStream = (config) => {
2161
2324
  const result = yield* client.getItem({
2162
2325
  TableName: tableName,
2163
2326
  Key: toAttributeMap({
2164
- pk: composeStreamPk(streamId),
2327
+ pk: yield* locateStream(streamId),
2165
2328
  sk: snapshotSk,
2166
2329
  }),
2167
2330
  ...(consistentRead ? { ConsistentRead: true } : {}),
@@ -2175,7 +2338,7 @@ export const makeStream = (config) => {
2175
2338
  const result = yield* client.getItem({
2176
2339
  TableName: tableName,
2177
2340
  Key: toAttributeMap({
2178
- pk: composeStreamPk(streamId),
2341
+ pk: yield* locateStream(streamId),
2179
2342
  sk: composeEventSk(version),
2180
2343
  }),
2181
2344
  ...(consistentRead ? { ConsistentRead: true } : {}),
@@ -2259,7 +2422,7 @@ export const makeStream = (config) => {
2259
2422
  FilterExpression: "#e IN (:eventType, :snapshotType)",
2260
2423
  ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk", "#e": "__edd_e__" },
2261
2424
  ExpressionAttributeValues: toAttributeMap({
2262
- ":pk": composeStreamPk(streamId),
2425
+ ":pk": yield* locateStream(streamId),
2263
2426
  ":first": eventSkPrefix,
2264
2427
  ":snapshot": snapshotSk,
2265
2428
  ":eventType": entityType,
@@ -2346,7 +2509,7 @@ export const makeStream = (config) => {
2346
2509
  const { name: tableName } = yield* config.table.Tag;
2347
2510
  const result = yield* client.getItem({
2348
2511
  TableName: tableName,
2349
- Key: toAttributeMap({ pk: composeStreamPk(streamId), sk: composeCommandSk(commandId) }),
2512
+ Key: toAttributeMap({ pk: yield* locateStream(streamId), sk: composeCommandSk(commandId) }),
2350
2513
  ConsistentRead: true,
2351
2514
  ProjectionExpression: "#pk",
2352
2515
  ExpressionAttributeNames: { "#pk": "pk" },
@@ -2375,6 +2538,8 @@ export const makeStream = (config) => {
2375
2538
  // Consumed capacity reports these queries under the stream name, as
2376
2539
  // every `EventStream.*` operation does (#174).
2377
2540
  operationTarget: config.streamName,
2541
+ // A stream 1.36 started is read where it lives (#171).
2542
+ ...locatedPrepare(streamId),
2378
2543
  // Bound to the event SK range so non-event items in the stream partition
2379
2544
  // (the snapshot) are excluded at the key-condition level. A caller-supplied
2380
2545
  // `Query.where` replaces this — the `__edd_e__` filter still applies.
@@ -2434,6 +2599,7 @@ export const makeStream = (config) => {
2434
2599
  throw new Error(`[EDD-9080] EventStream "${config.streamName}": ${partitionProblem}`);
2435
2600
  }
2436
2601
  const streamPk = composeStreamPk(streamId);
2602
+ const derived = idx.partition === "derived";
2437
2603
  const query = Query.make({
2438
2604
  tableName: "",
2439
2605
  indexName: idx.index,
@@ -2456,6 +2622,9 @@ export const makeStream = (config) => {
2456
2622
  },
2457
2623
  keyFields: [idx.pk, idx.sk, "pk", "sk"],
2458
2624
  operationTarget: config.streamName,
2625
+ // A stream-scoped index of a stream 1.36 started is read in the 1.36
2626
+ // partition, as its events are (#171).
2627
+ ...(derived ? {} : locatedPrepare(streamId)),
2459
2628
  // A "keys" index holds no events: an LSI has DynamoDB fetch them
2460
2629
  // (`Select: ALL_ATTRIBUTES`), a GSI fetches each page's with
2461
2630
  // `BatchGetItem` on the table key.
@@ -2467,7 +2636,9 @@ export const makeStream = (config) => {
2467
2636
  }
2468
2637
  : {}),
2469
2638
  });
2470
- return idx.partition === "derived" ? Query.filterExpr(query, streamPkFilter(streamPk)) : query;
2639
+ return derived
2640
+ ? Query.filterExpr(query, streamPkFilter(streamPk, composeStreamPk136(streamId)))
2641
+ : query;
2471
2642
  };
2472
2643
  /**
2473
2644
  * @internal The decoder of events read through `idx`.
@@ -2545,6 +2716,395 @@ export const makeStream = (config) => {
2545
2716
  // type parameter is opaque, causing Effect.gen to infer `unknown` for R. The cast
2546
2717
  // is safe because all operations correctly require DynamoClient | TableConfig at
2547
2718
  // runtime — the user must provide these layers.
2719
+ // ---------------------------------------------------------------------------
2720
+ // rekey (#171): the stream's part in `db.tables.<Table>.rekey()`
2721
+ //
2722
+ // A stream 1.36 started under a partition key whose text changed (a number
2723
+ // stream id composite) moves whole, while writers keep appending: every row
2724
+ // is COPIED to the current key — where nothing is, or a snapshot over an
2725
+ // older one; one transaction then FLIPS it — marks its first event moved,
2726
+ // conditioned on the head and the snapshot being as copied — so
2727
+ // `locateStream` reads the current key from then on, and an append or
2728
+ // snapshot write that located the 1.36 key before fails its location
2729
+ // check; the 1.36 rows are DELETED last, each while it is as read, the
2730
+ // events from the head down and the first event last. A flip or delete
2731
+ // that loses to a write reads again.
2732
+ // ---------------------------------------------------------------------------
2733
+ /** Attributes holding the stream's partition key: a GSI index's partition. */
2734
+ const partitionAttributes = streamIndexes.flatMap((idx) => (idx.type === "gsi" ? [idx.pk] : []));
2735
+ /** At most this many ways to split a stored stream id into its composites are tried. */
2736
+ const STREAM_ID_SPLITS = 256;
2737
+ /**
2738
+ * The stream ids a stored `streamId` can be. A multi-composite id is its
2739
+ * parts joined by `#`, and a string part may hold `#` itself, so every way
2740
+ * to split the text into as many parts is tried; each part is its text, or
2741
+ * the number (or bigint) it spells — the text alone does not say which.
2742
+ */
2743
+ const streamIdCandidates = (text) => {
2744
+ const count = compositeFields.length;
2745
+ const pieces = text.split("#");
2746
+ if (pieces.length < count)
2747
+ return [];
2748
+ const splits = [];
2749
+ const split = (start, parts) => {
2750
+ if (splits.length >= STREAM_ID_SPLITS)
2751
+ return;
2752
+ if (parts.length === count - 1) {
2753
+ splits.push([...parts, pieces.slice(start).join("#")]);
2754
+ return;
2755
+ }
2756
+ for (let end = start + 1; end <= pieces.length - (count - 1 - parts.length); end++) {
2757
+ split(end, [...parts, pieces.slice(start, end).join("#")]);
2758
+ }
2759
+ };
2760
+ split(0, []);
2761
+ return splits.flatMap((parts) => {
2762
+ let candidates = [{}];
2763
+ compositeFields.forEach((field, i) => {
2764
+ const part = parts[i];
2765
+ const values = [part];
2766
+ const n = Number(part);
2767
+ if (part.trim() !== "" && Number.isFinite(n) && String(n) === part)
2768
+ values.push(n);
2769
+ if (/^-?\d+$/.test(part) && String(BigInt(part)) === part)
2770
+ values.push(BigInt(part));
2771
+ candidates = candidates.flatMap((candidate) => values.map((value) => ({ ...candidate, [field]: value })));
2772
+ });
2773
+ return candidates;
2774
+ });
2775
+ };
2776
+ /**
2777
+ * Where a stream row belongs now and where 1.36 put it, from its
2778
+ * `streamId`: the readings of the id whose keys hold the row's partition
2779
+ * key. `"ambiguous"` when readings disagree on where it belongs.
2780
+ */
2781
+ const streamKeys = (row) => {
2782
+ const pk = row.pk?.S;
2783
+ const text = row.streamId?.S;
2784
+ if (pk === undefined || text === undefined)
2785
+ return undefined;
2786
+ let found;
2787
+ for (const candidate of streamIdCandidates(text)) {
2788
+ try {
2789
+ const current = composeStreamPk(candidate);
2790
+ const old = composeStreamPk136(candidate, true) ?? current;
2791
+ if (pk !== current && pk !== old)
2792
+ continue;
2793
+ if (found === undefined)
2794
+ found = { current, old };
2795
+ else if (found.current !== current || found.old !== old)
2796
+ return "ambiguous";
2797
+ }
2798
+ catch {
2799
+ // Not this reading of the id.
2800
+ }
2801
+ }
2802
+ return found;
2803
+ };
2804
+ const sameRow = (a, b) => {
2805
+ if (a === undefined || b === undefined)
2806
+ return a === b;
2807
+ const names = new Set([...Object.keys(a), ...Object.keys(b)]);
2808
+ for (const name of names)
2809
+ if (!attributeValueEquals(a[name], b[name]))
2810
+ return false;
2811
+ return true;
2812
+ };
2813
+ /** How many attempts a stream move makes in all: a flip that loses to a write copies again. */
2814
+ const STREAM_MOVE_ATTEMPTS = 8;
2815
+ /**
2816
+ * Move the stream under `from` to `to` — or, when its first event is no
2817
+ * longer under `from` (moved by an earlier run), the rows left there.
2818
+ * Command sentinels whose TTL has passed are gone, whether or not DynamoDB
2819
+ * has deleted them yet.
2820
+ */
2821
+ const moveStream = (ctx, from, to, streamIdText, ttlAttribute) => Effect.gen(function* () {
2822
+ const client = yield* Capacity.client;
2823
+ const firstSk = composeEventSk(1);
2824
+ const nowSeconds = DateTime.toEpochSeconds(yield* DateTime.now);
2825
+ const live = (row) => {
2826
+ const ttl = row[ttlAttribute]?.N;
2827
+ return ttl === undefined || Number(ttl) > nowSeconds;
2828
+ };
2829
+ const readAll = (pkValue) => Effect.gen(function* () {
2830
+ const rows = [];
2831
+ let start;
2832
+ do {
2833
+ const page = yield* client.query({
2834
+ TableName: ctx.tableName,
2835
+ KeyConditionExpression: "#pk = :pk",
2836
+ ExpressionAttributeNames: { "#pk": "pk" },
2837
+ ExpressionAttributeValues: toAttributeMap({ ":pk": pkValue }),
2838
+ ConsistentRead: true,
2839
+ ...(start !== undefined && { ExclusiveStartKey: start }),
2840
+ });
2841
+ rows.push(...(page.Items ?? []).filter(live));
2842
+ start = page.LastEvaluatedKey;
2843
+ } while (start !== undefined);
2844
+ return rows;
2845
+ });
2846
+ const copyOf = (row) => {
2847
+ const copy = { ...row, pk: { S: to } };
2848
+ delete copy[MOVED_ATTRIBUTE];
2849
+ for (const attr of partitionAttributes) {
2850
+ if (copy[attr]?.S === from)
2851
+ copy[attr] = { S: to };
2852
+ }
2853
+ return copy;
2854
+ };
2855
+ const skOf = (row) => row.sk?.S ?? "";
2856
+ const versionOf = (row) => row.__edd_e__?.S === entityType ? Number(row.version?.N ?? 0) : 0;
2857
+ const asOfOf = (row) => Number(row.asOfVersion?.N ?? 0);
2858
+ const conflict = (reason, message, sk = firstSk) => ({
2859
+ conflicts: [
2860
+ {
2861
+ member: config.streamName,
2862
+ kind: "stream",
2863
+ reason,
2864
+ key: { pk: from, sk },
2865
+ target: { pk: to, sk },
2866
+ message: `Stream ${JSON.stringify(streamIdText)}: ${message}`,
2867
+ },
2868
+ ],
2869
+ });
2870
+ const cancelled = (error) => isAwsConditionalCheckFailed(error.cause) ? Effect.succeed(false) : Effect.fail(error);
2871
+ /**
2872
+ * Copy `row` to `to`: where nothing is (or only a row whose TTL has
2873
+ * passed) — or, a snapshot, over an older one (`older`), so a copy never
2874
+ * regresses it. `false` when a write got there first.
2875
+ */
2876
+ const copy = (row, older) => client
2877
+ .putItem({
2878
+ TableName: ctx.tableName,
2879
+ Item: copyOf(row),
2880
+ ...(older === undefined
2881
+ ? {
2882
+ ConditionExpression: "attribute_not_exists(#pk) OR #ttl <= :now",
2883
+ ExpressionAttributeNames: { "#pk": "pk", "#ttl": ttlAttribute },
2884
+ ExpressionAttributeValues: { ":now": { N: String(nowSeconds) } },
2885
+ }
2886
+ : {
2887
+ ConditionExpression: "#asOf < :asOf",
2888
+ ExpressionAttributeNames: { "#asOf": "asOfVersion" },
2889
+ ExpressionAttributeValues: { ":asOf": row.asOfVersion ?? { N: "0" } },
2890
+ }),
2891
+ })
2892
+ .pipe(Effect.as(true), Effect.catch(cancelled));
2893
+ /** Delete `row` at `from` while it is as read. `false` when a write changed it. */
2894
+ const remove = (row) => client
2895
+ .deleteItem({
2896
+ TableName: ctx.tableName,
2897
+ Key: toAttributeMap({ pk: from, sk: skOf(row) }),
2898
+ ...asReadGuard(row, "pk", []),
2899
+ })
2900
+ .pipe(Effect.as(true), Effect.catch(cancelled));
2901
+ /**
2902
+ * Bring every row still under `from` over, once the stream lives at
2903
+ * `to`: a row `to` lacks is copied (a command sentinel written beside
2904
+ * the flip, a row an append that located the stream before the flip
2905
+ * wrote), a snapshot newer than `to`'s copied over it, and then each is
2906
+ * deleted while it is as read — the events from the head down, the
2907
+ * other rows, and the first event last: until it goes, the stream is
2908
+ * located here, and an append that located it here fails its check. A
2909
+ * row `to` holds differently (an event or sentinel written under both
2910
+ * keys) is a conflict, and the rows are left as they are. `undefined`
2911
+ * when a write landed in between: read again.
2912
+ */
2913
+ const drain = Effect.gen(function* () {
2914
+ const left = yield* readAll(from);
2915
+ const there = new Map((yield* readAll(to)).map((row) => [skOf(row), row]));
2916
+ const differing = [];
2917
+ for (const row of left) {
2918
+ const held = there.get(skOf(row));
2919
+ if (held === undefined || sameRow(copyOf(row), held))
2920
+ continue;
2921
+ if (skOf(row) === snapshotSk)
2922
+ continue;
2923
+ differing.push(row);
2924
+ }
2925
+ if (differing.length > 0) {
2926
+ return Rekey.mergeTallies(...differing.map((row) => conflict("bothKeys", "a row of it is under both partition keys, and the two differ; the rows " +
2927
+ "under its 1.36 key are left as they are.", skOf(row))));
2928
+ }
2929
+ for (const row of left) {
2930
+ const held = there.get(skOf(row));
2931
+ const copied = held === undefined
2932
+ ? yield* copy(row)
2933
+ : skOf(row) === snapshotSk && asOfOf(row) > asOfOf(held)
2934
+ ? yield* copy(row, held)
2935
+ : true;
2936
+ if (!copied)
2937
+ return undefined;
2938
+ }
2939
+ const ordered = [
2940
+ ...left.filter((row) => versionOf(row) > 1).sort((a, b) => versionOf(b) - versionOf(a)),
2941
+ ...left.filter((row) => versionOf(row) === 0),
2942
+ ...left.filter((row) => versionOf(row) === 1),
2943
+ ];
2944
+ for (const row of ordered) {
2945
+ if (!(yield* remove(row)))
2946
+ return undefined;
2947
+ }
2948
+ return { moved: { stream: left.length } };
2949
+ });
2950
+ for (let attempt = 0; attempt < STREAM_MOVE_ATTEMPTS; attempt++) {
2951
+ const rows = yield* readAll(from);
2952
+ // Moved since the scan read it.
2953
+ if (rows.length === 0)
2954
+ return {};
2955
+ const first = rows.find((row) => skOf(row) === firstSk);
2956
+ // Flipped by an earlier attempt or run — or never started here: the
2957
+ // stream lives at `to`, and the rows left move to it.
2958
+ const flipped = first === undefined || first[MOVED_ATTRIBUTE] !== undefined;
2959
+ if (!flipped) {
2960
+ const there = new Map((yield* readAll(to)).map((row) => [skOf(row), row]));
2961
+ const here = new Map(rows.map((row) => [skOf(row), row]));
2962
+ // Rows under the current key are copies of an earlier attempt —
2963
+ // anything else is another stream's: a row this one does not have,
2964
+ // or one it has differently (a snapshot only grows newer).
2965
+ for (const [sk, row] of there) {
2966
+ const own = here.get(sk);
2967
+ if (own === undefined) {
2968
+ return conflict("taken", "rows of another stream are under its current partition key.");
2969
+ }
2970
+ if (sk !== snapshotSk && !sameRow(copyOf(own), row)) {
2971
+ return conflict("bothKeys", "rows of another stream are under its current partition key.", sk);
2972
+ }
2973
+ }
2974
+ if (ctx.dryRun)
2975
+ return { moved: { stream: rows.length } };
2976
+ // COPY
2977
+ let copiedAll = true;
2978
+ for (const row of rows) {
2979
+ const held = there.get(skOf(row));
2980
+ const copied = held === undefined
2981
+ ? yield* copy(row)
2982
+ : skOf(row) === snapshotSk && asOfOf(row) > asOfOf(held)
2983
+ ? yield* copy(row, held)
2984
+ : true;
2985
+ if (!copied) {
2986
+ copiedAll = false;
2987
+ break;
2988
+ }
2989
+ }
2990
+ if (!copiedAll)
2991
+ continue;
2992
+ // FLIP — conditioned on the stream being as copied: no event past
2993
+ // the head, the head (and its seal) and the snapshot unchanged.
2994
+ const head = rows.reduce((top, row) => Math.max(top, versionOf(row)), 1);
2995
+ const headRow = rows.find((row) => versionOf(row) === head);
2996
+ const snapshot = rows.find((row) => skOf(row) === snapshotSk);
2997
+ const sealClause = headRow?.[SEALED_ATTRIBUTE] === undefined
2998
+ ? "attribute_not_exists(#sealed)"
2999
+ : "attribute_exists(#sealed)";
3000
+ const items = [
3001
+ {
3002
+ Update: {
3003
+ TableName: ctx.tableName,
3004
+ Key: toAttributeMap({ pk: from, sk: firstSk }),
3005
+ UpdateExpression: "SET #moved = :to",
3006
+ ConditionExpression: head === 1
3007
+ ? `attribute_exists(#pk) AND attribute_not_exists(#moved) AND ${sealClause}`
3008
+ : "attribute_exists(#pk) AND attribute_not_exists(#moved)",
3009
+ ExpressionAttributeNames: {
3010
+ "#pk": "pk",
3011
+ "#moved": MOVED_ATTRIBUTE,
3012
+ ...(head === 1 && { "#sealed": SEALED_ATTRIBUTE }),
3013
+ },
3014
+ ExpressionAttributeValues: toAttributeMap({ ":to": to }),
3015
+ },
3016
+ },
3017
+ {
3018
+ ConditionCheck: {
3019
+ TableName: ctx.tableName,
3020
+ Key: toAttributeMap({ pk: from, sk: composeEventSk(head + 1) }),
3021
+ ConditionExpression: "attribute_not_exists(#pk)",
3022
+ ExpressionAttributeNames: { "#pk": "pk" },
3023
+ },
3024
+ },
3025
+ ...(head > 1
3026
+ ? [
3027
+ {
3028
+ ConditionCheck: {
3029
+ TableName: ctx.tableName,
3030
+ Key: toAttributeMap({ pk: from, sk: composeEventSk(head) }),
3031
+ ConditionExpression: `attribute_exists(#pk) AND ${sealClause}`,
3032
+ ExpressionAttributeNames: { "#pk": "pk", "#sealed": SEALED_ATTRIBUTE },
3033
+ },
3034
+ },
3035
+ ]
3036
+ : []),
3037
+ {
3038
+ ConditionCheck: {
3039
+ TableName: ctx.tableName,
3040
+ Key: toAttributeMap({ pk: from, sk: snapshotSk }),
3041
+ ...(snapshot === undefined
3042
+ ? {
3043
+ ConditionExpression: "attribute_not_exists(#pk)",
3044
+ ExpressionAttributeNames: { "#pk": "pk" },
3045
+ }
3046
+ : {
3047
+ ConditionExpression: "#asOf = :asOf AND #ts = :ts",
3048
+ ExpressionAttributeNames: { "#asOf": "asOfVersion", "#ts": "timestamp" },
3049
+ ExpressionAttributeValues: {
3050
+ ":asOf": snapshot.asOfVersion ?? { NULL: true },
3051
+ ":ts": snapshot.timestamp ?? { NULL: true },
3052
+ },
3053
+ }),
3054
+ },
3055
+ },
3056
+ ];
3057
+ const won = yield* client.transactWriteItems({ TransactItems: items }).pipe(Effect.as(true), Effect.catch((err) => isAwsTransactionCancelled(err.cause) ? Effect.succeed(false) : Effect.fail(err)));
3058
+ // A write landed in between: copy again.
3059
+ if (!won)
3060
+ continue;
3061
+ }
3062
+ else if (ctx.dryRun) {
3063
+ return { moved: { stream: rows.length } };
3064
+ }
3065
+ // DELETE
3066
+ const drained = yield* drain;
3067
+ if (drained !== undefined)
3068
+ return drained;
3069
+ }
3070
+ return conflict("contended", "writes changed it on every attempt to move it.");
3071
+ });
3072
+ const rekeyHandler = {
3073
+ name: config.streamName,
3074
+ types: [entityType, snapshotEntityType, commandEntityType],
3075
+ row: (row, ctx) => Effect.gen(function* () {
3076
+ const tableConfig = yield* config.table.Tag;
3077
+ if (tableConfig.name !== ctx.tableName)
3078
+ return undefined;
3079
+ const keys = streamKeys(row);
3080
+ if (keys === undefined)
3081
+ return { skipped: { unrecognizedKey: 1 } };
3082
+ if (keys === "ambiguous") {
3083
+ return {
3084
+ conflicts: [
3085
+ {
3086
+ member: config.streamName,
3087
+ kind: "stream",
3088
+ reason: "failed",
3089
+ key: { pk: row.pk?.S, sk: row.sk?.S },
3090
+ target: undefined,
3091
+ message: `Stream ${JSON.stringify(row.streamId?.S ?? "")}: its stored stream id reads ` +
3092
+ "as more than one id of the stream's composites, and they disagree on where " +
3093
+ "it belongs.",
3094
+ },
3095
+ ],
3096
+ };
3097
+ }
3098
+ if (row.pk?.S === keys.current)
3099
+ return { unchanged: 1 };
3100
+ // A stream under its 1.36 key moves whole, once per run, from
3101
+ // whichever of its rows the scan reaches first — and rows left there
3102
+ // after it moved move after it.
3103
+ if (!ctx.claim(`${config.streamName}\u0000${keys.old}`))
3104
+ return {};
3105
+ return yield* moveStream(ctx, keys.old, keys.current, row.streamId?.S ?? "", resolveTtlAttributeName(tableConfig));
3106
+ }),
3107
+ };
2548
3108
  return {
2549
3109
  [EventStreamTypeId]: EventStreamTypeId,
2550
3110
  pipe() {
@@ -2575,6 +3135,7 @@ export const makeStream = (config) => {
2575
3135
  [StreamIdFormatter]: composeStreamIdString,
2576
3136
  [CommandSentinelProbe]: checked(hasCommandSentinel),
2577
3137
  [HeadAboveProbe]: checked(headAbove),
3138
+ [Rekey.RekeyHandler]: rekeyHandler,
2578
3139
  };
2579
3140
  };
2580
3141
  // ---------------------------------------------------------------------------
@@ -2653,6 +3214,8 @@ export const bind = (stream) => Effect.gen(function* () {
2653
3214
  [HeadAboveProbe]: headProbe === undefined
2654
3215
  ? undefined
2655
3216
  : (id, floorVersion, options) => provide(headProbe(id, floorVersion, options)),
3217
+ // `rekey` runs it with the table's services, as it runs every member's.
3218
+ [Rekey.RekeyHandler]: Rekey.handlerOf(stream),
2656
3219
  };
2657
3220
  });
2658
3221
  // ---------------------------------------------------------------------------