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.
- package/dist/Aggregate.d.ts +29 -17
- package/dist/Aggregate.d.ts.map +1 -1
- package/dist/Aggregate.js +795 -173
- package/dist/Aggregate.js.map +1 -1
- package/dist/Batch.d.ts.map +1 -1
- package/dist/Batch.js +196 -77
- package/dist/Batch.js.map +1 -1
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +11 -2
- package/dist/Collection.js.map +1 -1
- package/dist/Collections.d.ts +15 -6
- package/dist/Collections.d.ts.map +1 -1
- package/dist/Collections.js +2 -1
- package/dist/Collections.js.map +1 -1
- package/dist/DynamoClient.d.ts +117 -10
- package/dist/DynamoClient.d.ts.map +1 -1
- package/dist/DynamoClient.js +1145 -141
- package/dist/DynamoClient.js.map +1 -1
- package/dist/Entity.d.ts +126 -51
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +3477 -539
- package/dist/Entity.js.map +1 -1
- package/dist/EventStore.d.ts +4 -3
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +616 -53
- package/dist/EventStore.js.map +1 -1
- package/dist/Query.d.ts +311 -25
- package/dist/Query.d.ts.map +1 -1
- package/dist/Query.js +1159 -103
- package/dist/Query.js.map +1 -1
- package/dist/Table.d.ts +172 -3
- package/dist/Table.d.ts.map +1 -1
- package/dist/Table.js +85 -4
- package/dist/Table.js.map +1 -1
- package/dist/Transaction.d.ts +18 -2
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +62 -8
- package/dist/Transaction.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/BoundQuery.d.ts +226 -32
- package/dist/internal/BoundQuery.d.ts.map +1 -1
- package/dist/internal/BoundQuery.js +96 -18
- package/dist/internal/BoundQuery.js.map +1 -1
- package/dist/internal/Capacity.d.ts +8 -0
- package/dist/internal/Capacity.d.ts.map +1 -1
- package/dist/internal/Capacity.js +24 -4
- package/dist/internal/Capacity.js.map +1 -1
- package/dist/internal/Cursor.d.ts +90 -0
- package/dist/internal/Cursor.d.ts.map +1 -0
- package/dist/internal/Cursor.js +179 -0
- package/dist/internal/Cursor.js.map +1 -0
- package/dist/internal/ExpressionLimits.d.ts +18 -2
- package/dist/internal/ExpressionLimits.d.ts.map +1 -1
- package/dist/internal/ExpressionLimits.js +35 -2
- package/dist/internal/ExpressionLimits.js.map +1 -1
- package/dist/internal/ProjectionEngine.d.ts +16 -0
- package/dist/internal/ProjectionEngine.d.ts.map +1 -1
- package/dist/internal/ProjectionEngine.js +242 -46
- package/dist/internal/ProjectionEngine.js.map +1 -1
- package/dist/internal/RangePlanner.d.ts +394 -0
- package/dist/internal/RangePlanner.d.ts.map +1 -0
- package/dist/internal/RangePlanner.js +1296 -0
- package/dist/internal/RangePlanner.js.map +1 -0
- package/dist/internal/Rekey.d.ts +78 -0
- package/dist/internal/Rekey.d.ts.map +1 -0
- package/dist/internal/Rekey.js +323 -0
- package/dist/internal/Rekey.js.map +1 -0
- package/dist/internal/TransactPlan.d.ts +8 -1
- package/dist/internal/TransactPlan.d.ts.map +1 -1
- package/dist/internal/TransactPlan.js +9 -2
- package/dist/internal/TransactPlan.js.map +1 -1
- package/dist/internal/TransactWriteOps.d.ts +10 -7
- package/dist/internal/TransactWriteOps.d.ts.map +1 -1
- package/dist/internal/TransactWriteOps.js +82 -6
- package/dist/internal/TransactWriteOps.js.map +1 -1
- package/dist/internal/TransactableOps.d.ts +31 -0
- package/dist/internal/TransactableOps.d.ts.map +1 -1
- package/dist/internal/TransactableOps.js +50 -1
- package/dist/internal/TransactableOps.js.map +1 -1
- package/dist/internal/WriteDiagnosis.d.ts.map +1 -1
- package/dist/internal/WriteDiagnosis.js +10 -7
- package/dist/internal/WriteDiagnosis.js.map +1 -1
- package/package.json +2 -2
package/dist/EventStore.js
CHANGED
|
@@ -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
|
|
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) =>
|
|
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,
|
|
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
|
|
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
|
|
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:
|
|
1772
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
//
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
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:
|
|
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:
|
|
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":
|
|
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:
|
|
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
|
|
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
|
// ---------------------------------------------------------------------------
|