effect-dynamodb 1.25.0 → 1.26.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 +7 -0
- package/dist/Aggregate.d.ts.map +1 -1
- package/dist/Aggregate.js +150 -4
- package/dist/Aggregate.js.map +1 -1
- package/dist/Collection.d.ts +9 -1
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +3 -0
- package/dist/Collection.js.map +1 -1
- package/dist/DynamoClient.d.ts +80 -28
- package/dist/DynamoClient.d.ts.map +1 -1
- package/dist/DynamoClient.js +83 -11
- package/dist/DynamoClient.js.map +1 -1
- package/dist/Entity.d.ts +80 -27
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +406 -76
- package/dist/Entity.js.map +1 -1
- package/dist/EventStore.d.ts +182 -2
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +328 -41
- package/dist/EventStore.js.map +1 -1
- package/dist/Query.d.ts +53 -0
- package/dist/Query.d.ts.map +1 -1
- package/dist/Query.js +88 -6
- package/dist/Query.js.map +1 -1
- package/dist/StorageEncoding.d.ts +160 -0
- package/dist/StorageEncoding.d.ts.map +1 -0
- package/dist/StorageEncoding.js +258 -0
- package/dist/StorageEncoding.js.map +1 -0
- package/dist/Table.d.ts.map +1 -1
- package/dist/Table.js +8 -6
- package/dist/Table.js.map +1 -1
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +5 -0
- package/dist/Transaction.js.map +1 -1
- package/dist/compression/zlib.d.ts +75 -0
- package/dist/compression/zlib.d.ts.map +1 -0
- package/dist/compression/zlib.js +109 -0
- package/dist/compression/zlib.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/BoundCrud.d.ts +39 -35
- package/dist/internal/BoundCrud.d.ts.map +1 -1
- package/dist/internal/BoundCrud.js.map +1 -1
- package/dist/internal/BoundQuery.d.ts +3 -3
- package/dist/internal/BoundQuery.d.ts.map +1 -1
- package/dist/internal/BoundQuery.js.map +1 -1
- package/dist/internal/BoundVectorQuery.d.ts +12 -0
- package/dist/internal/BoundVectorQuery.d.ts.map +1 -1
- package/dist/internal/BoundVectorQuery.js +14 -1
- package/dist/internal/BoundVectorQuery.js.map +1 -1
- package/dist/internal/EntityCombinators.d.ts.map +1 -1
- package/dist/internal/EntityCombinators.js +16 -1
- package/dist/internal/EntityCombinators.js.map +1 -1
- package/dist/internal/Expr.d.ts +5 -3
- package/dist/internal/Expr.d.ts.map +1 -1
- package/dist/internal/Expr.js +1 -1
- package/dist/internal/Expr.js.map +1 -1
- package/dist/internal/Packed.d.ts +289 -0
- package/dist/internal/Packed.d.ts.map +1 -0
- package/dist/internal/Packed.js +654 -0
- package/dist/internal/Packed.js.map +1 -0
- package/dist/internal/PathBuilder.d.ts +45 -2
- package/dist/internal/PathBuilder.d.ts.map +1 -1
- package/dist/internal/PathBuilder.js +4 -0
- package/dist/internal/PathBuilder.js.map +1 -1
- package/dist/internal/TransactableOps.d.ts +1 -1
- package/dist/internal/TransactableOps.d.ts.map +1 -1
- package/dist/internal/TransactableOps.js +2 -0
- package/dist/internal/TransactableOps.js.map +1 -1
- package/package.json +6 -2
package/dist/EventStore.js
CHANGED
|
@@ -17,16 +17,26 @@
|
|
|
17
17
|
* event, all but its metadata, or only its keys — `readIndex`,
|
|
18
18
|
* `query.index`, and `indexDefinitions` for the `CreateTable` fragments
|
|
19
19
|
* - `fold` / `foldFrom` helpers for state reconstruction
|
|
20
|
+
* - Packed storage (`makeStream({ storedAs: { data } })`, `snapshot.storedAs`):
|
|
21
|
+
* event data and snapshot state as one JSON string, optionally compressed —
|
|
22
|
+
* read by the stored form, so a stream may mix native, JSON and compressed
|
|
23
|
+
* items; `measureAppend` / `measureSnapshot` for caller-side size planning,
|
|
24
|
+
* and `decodeStreamImage` for DynamoDB Streams records
|
|
20
25
|
*
|
|
21
26
|
* Built on the existing library primitives (DynamoSchema, KeyComposer, Query,
|
|
22
27
|
* DynamoClient, Marshaller).
|
|
23
28
|
*/
|
|
29
|
+
import { Compression } from "@effect-dynamodb/schema/Compression.js";
|
|
30
|
+
import { isPackedConfig } from "@effect-dynamodb/schema/DynamoModel.js";
|
|
24
31
|
import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
|
|
25
32
|
import { normalizeTtlSeconds } from "@effect-dynamodb/schema/Entity.js";
|
|
26
33
|
import { AdditionalItemConditionFailed, AppendTooLarge, DuplicateCommand, isAwsConditionalCheckFailed, isAwsTransactionCancelled, TransactionCancelled, ValidationError, VersionConflict, } from "@effect-dynamodb/schema/Errors.js";
|
|
34
|
+
import { isDispatchEligible, packedShape, packValue, unpackStoredSync, } from "@effect-dynamodb/schema/internal/PackedCodec.js";
|
|
27
35
|
import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
|
|
28
|
-
import { DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
|
|
36
|
+
import { Context, DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
|
|
29
37
|
import { DynamoClient } from "./DynamoClient.js";
|
|
38
|
+
import { itemBytes, transactItemBytes } from "./internal/ItemSize.js";
|
|
39
|
+
import { codecRequirements, currentRegistry, decodeImage, imageEntityType, runtimeCodecCheck, unregisteredCodecError, withRegistry, } from "./internal/Packed.js";
|
|
30
40
|
import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, judgeCancellation, transactItemTarget, } from "./internal/TransactWriteOps.js";
|
|
31
41
|
import { diagnosedValidationError, diagnoseFailure, mergeKeyAttributes, withCause, } from "./internal/WriteDiagnosis.js";
|
|
32
42
|
import { fromAttributeMap, toAttributeMap } from "./Marshaller.js";
|
|
@@ -41,6 +51,13 @@ import { DEFAULT_TTL_ATTRIBUTE_NAME, resolveTtlAttributeName, } from "./Table.js
|
|
|
41
51
|
*
|
|
42
52
|
* @internal
|
|
43
53
|
*/
|
|
54
|
+
/** The codecs a stream's packed `data` / `state` name (`[EDD-9078]` when unregistered). */
|
|
55
|
+
const streamCodecRequirements = (streamName, storedAs) => codecRequirements(`stream "${streamName}"`, [
|
|
56
|
+
["data", storedAs.data],
|
|
57
|
+
["state", storedAs.state],
|
|
58
|
+
]);
|
|
59
|
+
/** The attributes a stream image reads by stored form: event `data` only. */
|
|
60
|
+
const DATA_ATTRIBUTES = ["data"];
|
|
44
61
|
const EventEnvelope = Schema.Struct({
|
|
45
62
|
streamId: Schema.String,
|
|
46
63
|
version: Schema.Number,
|
|
@@ -221,6 +238,58 @@ const resolveStreamIndexes = (streamName, indexes) => {
|
|
|
221
238
|
}
|
|
222
239
|
return resolved;
|
|
223
240
|
};
|
|
241
|
+
/**
|
|
242
|
+
* @internal Validate `makeStream({ storedAs })` and `snapshot.storedAs` at
|
|
243
|
+
* definition time (#147).
|
|
244
|
+
*
|
|
245
|
+
* @throws `[EDD-9079]` for a `storedAs` that is not an object, a key other
|
|
246
|
+
* than `data`, or a value that is not a `DynamoModel.Packed(...)` config.
|
|
247
|
+
* @throws `[EDD-9073]` for a packed snapshot whose state schema's encoded side
|
|
248
|
+
* is not object- or array-shaped.
|
|
249
|
+
*/
|
|
250
|
+
const resolveStreamStorage = (streamName, storedAs, snapshot) => {
|
|
251
|
+
const fail = (code, message) => {
|
|
252
|
+
throw new Error(`[${code}] EventStream "${streamName}": ${message}`);
|
|
253
|
+
};
|
|
254
|
+
const describe = (value) => value === null
|
|
255
|
+
? "null"
|
|
256
|
+
: typeof value === "object"
|
|
257
|
+
? "an object that is not a DynamoModel.Packed(...) config"
|
|
258
|
+
: `${typeof value} ${JSON.stringify(value)}`;
|
|
259
|
+
let data;
|
|
260
|
+
if (storedAs !== undefined) {
|
|
261
|
+
if (typeof storedAs !== "object" || storedAs === null || Array.isArray(storedAs)) {
|
|
262
|
+
fail("EDD-9079", `storedAs must be an object { data?: DynamoModel.Packed(...) }; received ${describe(storedAs)}.`);
|
|
263
|
+
}
|
|
264
|
+
for (const [key, value] of Object.entries(storedAs)) {
|
|
265
|
+
if (key !== "data") {
|
|
266
|
+
fail("EDD-9079", `storedAs declares "${key}", but only "data" can be stored packed` +
|
|
267
|
+
(key === "metadata" ? " — event metadata always stays a map" : "") +
|
|
268
|
+
(key === "state" ? " — pack snapshot state with snapshot.storedAs" : "") +
|
|
269
|
+
".");
|
|
270
|
+
}
|
|
271
|
+
if (value !== undefined && !isPackedConfig(value)) {
|
|
272
|
+
fail("EDD-9079", `storedAs.data must be a DynamoModel.Packed(...) config; received ${describe(value)}.`);
|
|
273
|
+
}
|
|
274
|
+
data = value;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
let state;
|
|
278
|
+
const snapshotStoredAs = snapshot?.storedAs;
|
|
279
|
+
if (snapshot !== undefined && snapshotStoredAs !== undefined) {
|
|
280
|
+
if (!isPackedConfig(snapshotStoredAs)) {
|
|
281
|
+
fail("EDD-9079", `snapshot.storedAs must be a DynamoModel.Packed(...) config; received ` +
|
|
282
|
+
`${describe(snapshotStoredAs)}.`);
|
|
283
|
+
}
|
|
284
|
+
const shape = packedShape(snapshot.schema);
|
|
285
|
+
if (shape._tag === "Refused") {
|
|
286
|
+
fail("EDD-9073", `snapshot.storedAs packs the snapshot state, but the state schema cannot be packed: ` +
|
|
287
|
+
`${shape.reason}. Packed storage holds object- or array-shaped JSON.`);
|
|
288
|
+
}
|
|
289
|
+
state = snapshotStoredAs;
|
|
290
|
+
}
|
|
291
|
+
return { data, state };
|
|
292
|
+
};
|
|
224
293
|
// ---------------------------------------------------------------------------
|
|
225
294
|
// EventStreamTypeId
|
|
226
295
|
// ---------------------------------------------------------------------------
|
|
@@ -413,6 +482,18 @@ const eventTimestampsProblem = (timestamps, count, label, events) => {
|
|
|
413
482
|
* with `ValidationError`.
|
|
414
483
|
* @throws `[EDD-9065]` when two indexes share a physical index name or an
|
|
415
484
|
* attribute (or a GSI's `pk` and `sk` are the same attribute).
|
|
485
|
+
* @throws `[EDD-9079]` when `storedAs` declares a key other than `data`
|
|
486
|
+
* (metadata always stays a map), or a `storedAs.data` / `snapshot.storedAs`
|
|
487
|
+
* value is not a `DynamoModel.Packed(...)` config.
|
|
488
|
+
* @throws `[EDD-9073]` when `snapshot.storedAs` is set and the state schema's
|
|
489
|
+
* encoded side is not object- or array-shaped.
|
|
490
|
+
*
|
|
491
|
+
* `storedAs: { data: DynamoModel.Packed(...) }` stores event `data` packed, and
|
|
492
|
+
* `snapshot.storedAs` the snapshot `state` — see {@link StreamStorageConfig}.
|
|
493
|
+
* Register the codecs a compressed config names through the `Compression`
|
|
494
|
+
* service (`Compression.layer([...])` merged into the application layer): a
|
|
495
|
+
* bound stream refuses to bind without them, and an unbound one to run its
|
|
496
|
+
* first operation (`[EDD-9078]`).
|
|
416
497
|
*/
|
|
417
498
|
export const makeStream = (config) => {
|
|
418
499
|
const schema = config.table.schema;
|
|
@@ -463,6 +544,39 @@ export const makeStream = (config) => {
|
|
|
463
544
|
const snapshotSettings = snapshot === undefined
|
|
464
545
|
? undefined
|
|
465
546
|
: { mode: snapshot.mode ?? "after-append", every: snapshot.every };
|
|
547
|
+
// Packed storage (EDD-9079, EDD-9073) — validated at definition time.
|
|
548
|
+
const storage = resolveStreamStorage(config.streamName, config.storedAs, snapshot);
|
|
549
|
+
/** Event `data` packing, or `undefined` for the native map. */
|
|
550
|
+
const dataStoredAs = storage.data;
|
|
551
|
+
/** Snapshot `state` packing, or `undefined` for the native map. */
|
|
552
|
+
const stateStoredAs = storage.state;
|
|
553
|
+
/**
|
|
554
|
+
* Whether a snapshot's stored `state` is read by its stored form: when it is
|
|
555
|
+
* configured packed, or when its schema's encoded side is object- or
|
|
556
|
+
* array-shaped (a native state is then never `S` / `B`), so a stream that
|
|
557
|
+
* turned packing off still reads its packed snapshot.
|
|
558
|
+
*/
|
|
559
|
+
const stateDispatch = snapshot !== undefined && (stateStoredAs !== undefined || isDispatchEligible(snapshot.schema));
|
|
560
|
+
/**
|
|
561
|
+
* The first operation run with a given compressor registry checks that it
|
|
562
|
+
* has every codec `storedAs` names (`[EDD-9078]`, a defect): the stream
|
|
563
|
+
* meets values compressed with them on every read and write. `bind` checks
|
|
564
|
+
* the registry it captures up front instead.
|
|
565
|
+
*/
|
|
566
|
+
const codecCheck = runtimeCodecCheck(`EventStore (first use of stream "${config.streamName}")`, streamCodecRequirements(config.streamName, storage));
|
|
567
|
+
const checked = (op) => codecCheck === undefined ? op : (...args) => Effect.andThen(codecCheck, op(...args));
|
|
568
|
+
/** The `[EDD-9076]` guard for queries of a stream whose `data` is packed. */
|
|
569
|
+
const dataGuards = dataStoredAs === undefined
|
|
570
|
+
? []
|
|
571
|
+
: [
|
|
572
|
+
{
|
|
573
|
+
attribute: "data",
|
|
574
|
+
owner: `EventStream "${config.streamName}"`,
|
|
575
|
+
storedAs: dataStoredAs,
|
|
576
|
+
alternative: (path) => `Use Query.filterBy((event) => /* test event.${path} */), which runs on the ` +
|
|
577
|
+
"decoded event inside the same paging loop.",
|
|
578
|
+
},
|
|
579
|
+
];
|
|
466
580
|
// Stream indexes (#140) — validated at definition time (EDD-9063..9065).
|
|
467
581
|
const streamIndexes = resolveStreamIndexes(config.streamName, config.indexes);
|
|
468
582
|
const indexSettings = {};
|
|
@@ -548,11 +662,41 @@ export const makeStream = (config) => {
|
|
|
548
662
|
// Decode a raw DynamoDB item → StreamEvent<TEvent>
|
|
549
663
|
// ---------------------------------------------------------------------------
|
|
550
664
|
const decodeEnvelope = Schema.decodeUnknownEffect(EventEnvelope);
|
|
665
|
+
/**
|
|
666
|
+
* An event's stored `data` → its wire form (plain JSON), by the stored
|
|
667
|
+
* form. A native value costs one `typeof` check.
|
|
668
|
+
*/
|
|
669
|
+
const unpackData = (stored, operation) => typeof stored === "string" || stored instanceof Uint8Array
|
|
670
|
+
? withRegistry((registry) => unpackStoredSync(stored, registry, { entityType, operation }, "data"))
|
|
671
|
+
: Effect.succeed(stored);
|
|
672
|
+
/**
|
|
673
|
+
* A raw event row (`Query.select`) with its `data` — when projected — as
|
|
674
|
+
* plain parsed JSON, whatever its stored form (R3: a stream that switched
|
|
675
|
+
* encoding holds a mix). Never schema-decoded. Only this stream's event
|
|
676
|
+
* rows are read: a row of another type (`Query.ignoreOwnership`) is handed
|
|
677
|
+
* back untouched, its `__edd_e__` borrowed for the check and stripped again.
|
|
678
|
+
* In place, so the strip reaches the row returned.
|
|
679
|
+
*/
|
|
680
|
+
const rawEventRow = Object.assign((raw) => {
|
|
681
|
+
const stored = raw.data;
|
|
682
|
+
if (raw.__edd_e__ !== entityType)
|
|
683
|
+
return Effect.succeed(raw);
|
|
684
|
+
if (typeof stored !== "string" && !(stored instanceof Uint8Array))
|
|
685
|
+
return Effect.succeed(raw);
|
|
686
|
+
return Effect.map(unpackData(stored, "EventStore.select"), (data) => {
|
|
687
|
+
raw.data = data;
|
|
688
|
+
return raw;
|
|
689
|
+
});
|
|
690
|
+
}, { reads: ["__edd_e__"], attributes: ["data"] });
|
|
551
691
|
const decodeStreamEvent = (raw) => Effect.gen(function* () {
|
|
552
692
|
const toValidationError = (operation) => (cause) => new ValidationError({ entityType, operation, cause });
|
|
553
693
|
const envelope = yield* decodeEnvelope(raw).pipe(Effect.mapError(toValidationError("EventStore.decode")));
|
|
694
|
+
// `data` is read by its stored form (#147): a native map as it is, a
|
|
695
|
+
// JSON string (`S`) parsed, compressed binary (`B`) decompressed and
|
|
696
|
+
// parsed — whatever the stream's current `storedAs` says.
|
|
697
|
+
const wireData = yield* unpackData(raw.data, "EventStore.decode");
|
|
554
698
|
const decoder = Schema.decodeUnknownEffect(eventUnion);
|
|
555
|
-
const data = yield* decoder(
|
|
699
|
+
const data = yield* decoder(wireData).pipe(Effect.mapError(toValidationError("EventStore.decode")));
|
|
556
700
|
// Metadata is decoded through its schema when the stream declares one —
|
|
557
701
|
// the mirror of the encode performed by `append`. Streams without a
|
|
558
702
|
// metadata schema surface the stored attribute map untouched.
|
|
@@ -743,7 +887,39 @@ export const makeStream = (config) => {
|
|
|
743
887
|
entityType,
|
|
744
888
|
});
|
|
745
889
|
};
|
|
746
|
-
|
|
890
|
+
/**
|
|
891
|
+
* The snapshot item for `state` at `asOfVersion`: the state encoded through
|
|
892
|
+
* the state schema, then packed per `snapshot.storedAs` (#147) — so every
|
|
893
|
+
* size measured afterwards is the stored size. Shared by `writeSnapshot`,
|
|
894
|
+
* the inline snapshot of `append`, and `measureSnapshot` / `measureAppend`.
|
|
895
|
+
*/
|
|
896
|
+
const buildSnapshotItem = (pk, streamIdStr, state, asOfVersion, now, operation) => Effect.gen(function* () {
|
|
897
|
+
const encoded = yield* encodeSnapshotState(state, operation);
|
|
898
|
+
const stored = stateStoredAs === undefined
|
|
899
|
+
? encoded
|
|
900
|
+
: yield* packValue(stateStoredAs, encoded, yield* currentRegistry, {
|
|
901
|
+
entityType: snapshotEntityType,
|
|
902
|
+
attribute: "state",
|
|
903
|
+
operation: `${operation}.packed`,
|
|
904
|
+
});
|
|
905
|
+
return toAttributeMap({
|
|
906
|
+
pk,
|
|
907
|
+
sk: snapshotSk,
|
|
908
|
+
__edd_e__: snapshotEntityType,
|
|
909
|
+
streamId: streamIdStr,
|
|
910
|
+
asOfVersion,
|
|
911
|
+
state: stored,
|
|
912
|
+
timestamp: now,
|
|
913
|
+
});
|
|
914
|
+
});
|
|
915
|
+
/**
|
|
916
|
+
* The stream's own items of an append — everything but the caller's
|
|
917
|
+
* `additionalItems` — validated and built exactly as `append` sends them.
|
|
918
|
+
* Shared by `append` and `measureAppend`, so a measurement is the size of
|
|
919
|
+
* the request `append` would send. `undefined` for an append with nothing
|
|
920
|
+
* to write (the historical no-op).
|
|
921
|
+
*/
|
|
922
|
+
const buildAppend = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
|
|
747
923
|
const additionalOps = options?.additionalItems ?? [];
|
|
748
924
|
const idempotency = options?.idempotency;
|
|
749
925
|
const snapshotState = options?.snapshot;
|
|
@@ -794,7 +970,7 @@ export const makeStream = (config) => {
|
|
|
794
970
|
additionalOps.length === 0 &&
|
|
795
971
|
idempotency === undefined &&
|
|
796
972
|
snapshotState === undefined) {
|
|
797
|
-
return
|
|
973
|
+
return undefined;
|
|
798
974
|
}
|
|
799
975
|
// Resolve stream ID string for storage (join composites)
|
|
800
976
|
const streamIdStr = composeStreamIdString(streamId);
|
|
@@ -814,7 +990,6 @@ export const makeStream = (config) => {
|
|
|
814
990
|
// atomicity — so one over the limit is rejected whole by DynamoDB and
|
|
815
991
|
// reported as `AppendTooLarge`, with the expanded item count.
|
|
816
992
|
const needsContiguityCheck = expectedVersion > 0 && events.length > 0;
|
|
817
|
-
const client = yield* DynamoClient;
|
|
818
993
|
const tableConfig = yield* config.table.Tag;
|
|
819
994
|
const tableName = tableConfig.name;
|
|
820
995
|
// A custom TTL attribute is only known at runtime. An index key attribute
|
|
@@ -834,6 +1009,8 @@ export const makeStream = (config) => {
|
|
|
834
1009
|
// Clock-backed timestamp (deterministic under TestClock; wall-clock in prod).
|
|
835
1010
|
const nowDateTime = yield* DateTime.now;
|
|
836
1011
|
const now = DateTime.formatIso(nowDateTime);
|
|
1012
|
+
// The compressor registry in scope, for packed `data` / `state` (#147).
|
|
1013
|
+
const registry = yield* currentRegistry;
|
|
837
1014
|
// Validate and encode metadata to wire form if schema provided. Shared
|
|
838
1015
|
// by every event; per-event `eventMetadata` is encoded in the loop below.
|
|
839
1016
|
let encodedMetadata;
|
|
@@ -859,6 +1036,16 @@ export const makeStream = (config) => {
|
|
|
859
1036
|
// Inject _tag for plain Schema.Class events; Schema.TaggedClass
|
|
860
1037
|
// events already carry _tag in their encoded form, which wins.
|
|
861
1038
|
const eventData = { _tag: evtType, ...wire };
|
|
1039
|
+
// Packed `data` (#147): one JSON string, or compressed binary at
|
|
1040
|
+
// or over the threshold when that is smaller — packed here, before
|
|
1041
|
+
// marshalling, so every size measured afterwards is stored bytes.
|
|
1042
|
+
const storedData = dataStoredAs === undefined
|
|
1043
|
+
? eventData
|
|
1044
|
+
: yield* packValue(dataStoredAs, eventData, registry, {
|
|
1045
|
+
entityType,
|
|
1046
|
+
attribute: "data",
|
|
1047
|
+
operation: "EventStore.append.packed",
|
|
1048
|
+
});
|
|
862
1049
|
// This event's own metadata entry (#144), encoded as `metadata`
|
|
863
1050
|
// is; an `undefined` entry writes no metadata attribute.
|
|
864
1051
|
let itemMetadata = encodedMetadata;
|
|
@@ -884,7 +1071,7 @@ export const makeStream = (config) => {
|
|
|
884
1071
|
streamId: streamIdStr,
|
|
885
1072
|
version,
|
|
886
1073
|
eventType: evtType,
|
|
887
|
-
data:
|
|
1074
|
+
data: storedData,
|
|
888
1075
|
timestamp: eventTimestamp === undefined ? now : DateTime.formatIso(eventTimestamp),
|
|
889
1076
|
};
|
|
890
1077
|
const item = { ...envelope };
|
|
@@ -931,8 +1118,7 @@ export const makeStream = (config) => {
|
|
|
931
1118
|
cause,
|
|
932
1119
|
}),
|
|
933
1120
|
});
|
|
934
|
-
sentinel[
|
|
935
|
-
DateTime.toEpochSeconds(nowDateTime) + ttlSeconds;
|
|
1121
|
+
sentinel[ttlAttribute] = DateTime.toEpochSeconds(nowDateTime) + ttlSeconds;
|
|
936
1122
|
}
|
|
937
1123
|
sentinelPut = {
|
|
938
1124
|
Put: {
|
|
@@ -943,21 +1129,14 @@ export const makeStream = (config) => {
|
|
|
943
1129
|
};
|
|
944
1130
|
}
|
|
945
1131
|
// The inline snapshot (#138): unconditional — see AppendOptions.snapshot.
|
|
1132
|
+
// Packed per `snapshot.storedAs` (#147), so the transaction-size and
|
|
1133
|
+
// item-size diagnosis count its stored bytes.
|
|
946
1134
|
let snapshotPut;
|
|
947
1135
|
if (snapshotState !== undefined) {
|
|
948
|
-
const encoded = yield* encodeSnapshotState(snapshotState, "EventStore.append.snapshot");
|
|
949
1136
|
snapshotPut = {
|
|
950
1137
|
Put: {
|
|
951
1138
|
TableName: tableName,
|
|
952
|
-
Item:
|
|
953
|
-
pk,
|
|
954
|
-
sk: snapshotSk,
|
|
955
|
-
__edd_e__: snapshotEntityType,
|
|
956
|
-
streamId: streamIdStr,
|
|
957
|
-
asOfVersion: expectedVersion + events.length,
|
|
958
|
-
state: encoded,
|
|
959
|
-
timestamp: now,
|
|
960
|
-
}),
|
|
1139
|
+
Item: yield* buildSnapshotItem(pk, streamIdStr, snapshotState, expectedVersion + events.length, now, "EventStore.append.snapshot"),
|
|
961
1140
|
},
|
|
962
1141
|
};
|
|
963
1142
|
}
|
|
@@ -982,6 +1161,24 @@ export const makeStream = (config) => {
|
|
|
982
1161
|
},
|
|
983
1162
|
}
|
|
984
1163
|
: undefined;
|
|
1164
|
+
const parts = {
|
|
1165
|
+
tableName,
|
|
1166
|
+
streamIdStr,
|
|
1167
|
+
contiguityCheck,
|
|
1168
|
+
eventItems,
|
|
1169
|
+
sentinelPut,
|
|
1170
|
+
snapshotPut,
|
|
1171
|
+
};
|
|
1172
|
+
return parts;
|
|
1173
|
+
});
|
|
1174
|
+
const append = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
|
|
1175
|
+
const additionalOps = options?.additionalItems ?? [];
|
|
1176
|
+
const idempotency = options?.idempotency;
|
|
1177
|
+
const parts = yield* buildAppend(streamId, events, expectedVersion, options);
|
|
1178
|
+
if (parts === undefined)
|
|
1179
|
+
return { version: expectedVersion, events: [] };
|
|
1180
|
+
const { tableName, streamIdStr, contiguityCheck, eventItems, sentinelPut, snapshotPut } = parts;
|
|
1181
|
+
const client = yield* DynamoClient;
|
|
985
1182
|
/**
|
|
986
1183
|
* Lay out the append's one transaction — see {@link AppendTransaction}:
|
|
987
1184
|
* [0, C) version-contiguity ConditionCheck (C is 0 or 1)
|
|
@@ -1045,7 +1242,9 @@ export const makeStream = (config) => {
|
|
|
1045
1242
|
// repeating an event, the contiguity check, the idempotency sentinel
|
|
1046
1243
|
// or the inline snapshot, or another additional item), 100 items,
|
|
1047
1244
|
// 4 MB, 400 KB per item, 1024-byte sort keys — are not pre-checked:
|
|
1048
|
-
// `sendAppendTransaction` diagnoses DynamoDB's rejection (#144).
|
|
1245
|
+
// `sendAppendTransaction` diagnoses DynamoDB's rejection (#144). The
|
|
1246
|
+
// items carry packed `data` / `state` as stored (#147), so the
|
|
1247
|
+
// diagnosis attributes stored bytes.
|
|
1049
1248
|
const outcome = yield* sendAppendTransaction(client, tx, streamIdStr, idempotency?.commandId);
|
|
1050
1249
|
if (outcome === undefined) {
|
|
1051
1250
|
return { version: expectedVersion + events.length, events };
|
|
@@ -1055,6 +1254,73 @@ export const makeStream = (config) => {
|
|
|
1055
1254
|
return yield* Effect.fail(lost);
|
|
1056
1255
|
});
|
|
1057
1256
|
// ---------------------------------------------------------------------------
|
|
1257
|
+
// measureAppend / measureSnapshot — caller-side size planning (#147, R1)
|
|
1258
|
+
// ---------------------------------------------------------------------------
|
|
1259
|
+
const measureAppend = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
|
|
1260
|
+
if (options?.additionalItems !== undefined) {
|
|
1261
|
+
return yield* new ValidationError({
|
|
1262
|
+
entityType,
|
|
1263
|
+
operation: "EventStore.measureAppend",
|
|
1264
|
+
cause: "measureAppend does not measure additionalItems (building a guarded put reads the " +
|
|
1265
|
+
"table): measure the stream's own items without them, and add the size of your " +
|
|
1266
|
+
"own items.",
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
const parts = yield* buildAppend(streamId, events, expectedVersion, options);
|
|
1270
|
+
if (parts === undefined) {
|
|
1271
|
+
return { events: [], sentinel: undefined, snapshot: undefined, transactionBytes: 0 };
|
|
1272
|
+
}
|
|
1273
|
+
const putBytes = (item) => itemBytes(item.Put?.Item ?? {});
|
|
1274
|
+
const owned = [
|
|
1275
|
+
...(parts.contiguityCheck === undefined ? [] : [parts.contiguityCheck]),
|
|
1276
|
+
...parts.eventItems,
|
|
1277
|
+
...(parts.sentinelPut === undefined ? [] : [parts.sentinelPut]),
|
|
1278
|
+
...(parts.snapshotPut === undefined ? [] : [parts.snapshotPut]),
|
|
1279
|
+
];
|
|
1280
|
+
const measurement = {
|
|
1281
|
+
events: parts.eventItems.map(putBytes),
|
|
1282
|
+
sentinel: parts.sentinelPut === undefined ? undefined : putBytes(parts.sentinelPut),
|
|
1283
|
+
snapshot: parts.snapshotPut === undefined ? undefined : putBytes(parts.snapshotPut),
|
|
1284
|
+
transactionBytes: owned.reduce((sum, item) => sum + transactItemBytes(item), 0),
|
|
1285
|
+
};
|
|
1286
|
+
return measurement;
|
|
1287
|
+
});
|
|
1288
|
+
const measureSnapshot = (streamId, state, asOfVersion) => Effect.gen(function* () {
|
|
1289
|
+
if (snapshot === undefined)
|
|
1290
|
+
return yield* snapshotUnavailable("measureSnapshot");
|
|
1291
|
+
const now = DateTime.formatIso(yield* DateTime.now);
|
|
1292
|
+
const item = yield* buildSnapshotItem(composeStreamPk(streamId), composeStreamIdString(streamId), state, asOfVersion, now, "EventStore.measureSnapshot");
|
|
1293
|
+
return itemBytes(item);
|
|
1294
|
+
});
|
|
1295
|
+
// ---------------------------------------------------------------------------
|
|
1296
|
+
// decodeStreamImage — DynamoDB Streams records (#147, R2)
|
|
1297
|
+
// ---------------------------------------------------------------------------
|
|
1298
|
+
const decodeStreamImage = (image, options) => Effect.gen(function* () {
|
|
1299
|
+
const ctx = { entityType, operation: "EventStore.decodeStreamImage" };
|
|
1300
|
+
// Not one of this stream's events — its snapshot, a sentinel, another
|
|
1301
|
+
// stream's or an entity's item: none, decided from the raw image before
|
|
1302
|
+
// anything is unmarshalled, so nothing in a foreign image can fail it.
|
|
1303
|
+
if (typeof image === "object" &&
|
|
1304
|
+
image !== null &&
|
|
1305
|
+
!Array.isArray(image) &&
|
|
1306
|
+
imageEntityType(image) !== entityType) {
|
|
1307
|
+
return Option.none();
|
|
1308
|
+
}
|
|
1309
|
+
// In DynamoDB JSON the S / B tag says what `data` is; unmarshalled, a
|
|
1310
|
+
// string that is base64 of a `B` value is told apart from JSON text.
|
|
1311
|
+
const raw = yield* decodeImage(image, DATA_ATTRIBUTES, ctx);
|
|
1312
|
+
const event = yield* decodeStreamEvent(raw);
|
|
1313
|
+
if (options?.raw !== true)
|
|
1314
|
+
return Option.some(event);
|
|
1315
|
+
// `raw.data` is the wire form the decode just read: the parsed JSON of a
|
|
1316
|
+
// packed value, the unmarshalled map of a native one.
|
|
1317
|
+
const withRaw = {
|
|
1318
|
+
...event,
|
|
1319
|
+
rawData: raw.data,
|
|
1320
|
+
};
|
|
1321
|
+
return Option.some(withRaw);
|
|
1322
|
+
});
|
|
1323
|
+
// ---------------------------------------------------------------------------
|
|
1058
1324
|
// read
|
|
1059
1325
|
// ---------------------------------------------------------------------------
|
|
1060
1326
|
/** Apply {@link ReadOptions} to an event query. */
|
|
@@ -1111,19 +1377,12 @@ export const makeStream = (config) => {
|
|
|
1111
1377
|
const client = yield* DynamoClient;
|
|
1112
1378
|
const { name: tableName } = yield* config.table.Tag;
|
|
1113
1379
|
const now = DateTime.formatIso(yield* DateTime.now);
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
sk: snapshotSk,
|
|
1118
|
-
__edd_e__: snapshotEntityType,
|
|
1119
|
-
streamId: composeStreamIdString(streamId),
|
|
1120
|
-
asOfVersion,
|
|
1121
|
-
state: encoded,
|
|
1122
|
-
timestamp: now,
|
|
1123
|
-
};
|
|
1380
|
+
// Packed per `snapshot.storedAs` (#147): the diagnosis below measures
|
|
1381
|
+
// the stored bytes.
|
|
1382
|
+
const item = yield* buildSnapshotItem(composeStreamPk(streamId), composeStreamIdString(streamId), state, asOfVersion, now, "EventStore.writeSnapshot");
|
|
1124
1383
|
const put = {
|
|
1125
1384
|
TableName: tableName,
|
|
1126
|
-
Item:
|
|
1385
|
+
Item: item,
|
|
1127
1386
|
// Monotonic: never regress the cache. Losing this race is a no-op,
|
|
1128
1387
|
// not an error — the events it summarises are already durable.
|
|
1129
1388
|
ConditionExpression: "attribute_not_exists(#pk) OR #asOfVersion < :asOfVersion",
|
|
@@ -1149,7 +1408,12 @@ export const makeStream = (config) => {
|
|
|
1149
1408
|
* Decode a stored snapshot item. A snapshot that fails to decode through the
|
|
1150
1409
|
* state schema is a `ValidationError` — never silently discarded.
|
|
1151
1410
|
*/
|
|
1152
|
-
const decodeSnapshotItem = (raw, operation) =>
|
|
1411
|
+
const decodeSnapshotItem = (raw, operation) =>
|
|
1412
|
+
// `state` is read by its stored form (#147) when it may be packed — see
|
|
1413
|
+
// `stateDispatch` — then decoded through the state schema as before.
|
|
1414
|
+
(stateDispatch && (typeof raw.state === "string" || raw.state instanceof Uint8Array)
|
|
1415
|
+
? withRegistry((registry) => unpackStoredSync(raw.state, registry, { entityType: snapshotEntityType, operation }, "state"))
|
|
1416
|
+
: Effect.succeed(raw.state)).pipe(Effect.flatMap((state) => Schema.decodeUnknownEffect(snapshot.schema)(state).pipe(Effect.mapError((cause) => new ValidationError({ entityType: snapshotEntityType, operation, cause })))), Effect.map((state) => ({
|
|
1153
1417
|
state,
|
|
1154
1418
|
asOfVersion: raw.asOfVersion,
|
|
1155
1419
|
timestamp: raw.timestamp,
|
|
@@ -1312,6 +1576,10 @@ export const makeStream = (config) => {
|
|
|
1312
1576
|
skField: "sk",
|
|
1313
1577
|
entityTypes: [entityType],
|
|
1314
1578
|
decoder: (raw) => decodeStreamEvent(raw),
|
|
1579
|
+
// Raw rows (`Query.select`) carry `data` as parsed JSON, whatever its
|
|
1580
|
+
// stored form, and a filter inside a packed `data` is refused (#147).
|
|
1581
|
+
rawRow: rawEventRow,
|
|
1582
|
+
packed: dataGuards,
|
|
1315
1583
|
resolveTableName: config.table.Tag.useSync((tc) => tc.name),
|
|
1316
1584
|
keyFields: ["pk", "sk"],
|
|
1317
1585
|
// Bound to the event SK range so non-event items in the stream partition
|
|
@@ -1347,6 +1615,10 @@ export const makeStream = (config) => {
|
|
|
1347
1615
|
skField: idx.sk,
|
|
1348
1616
|
entityTypes: [entityType],
|
|
1349
1617
|
decoder: indexDecoder(idx),
|
|
1618
|
+
// As for `query.events` (#147) — the `"keys"` hydration path decodes
|
|
1619
|
+
// its fetched rows through the same hooks.
|
|
1620
|
+
rawRow: rawEventRow,
|
|
1621
|
+
packed: dataGuards,
|
|
1350
1622
|
resolveTableName: config.table.Tag.useSync((tc) => tc.name),
|
|
1351
1623
|
// DynamoDB's rejection of the key condition (an empty bound, a reversed
|
|
1352
1624
|
// `between`) is named against the stream index; so, as for every
|
|
@@ -1449,19 +1721,23 @@ export const makeStream = (config) => {
|
|
|
1449
1721
|
streamName: config.streamName,
|
|
1450
1722
|
eventSchema: eventUnion,
|
|
1451
1723
|
snapshotConfig: snapshotSettings,
|
|
1724
|
+
storedAs: storage,
|
|
1452
1725
|
indexes: indexSettings,
|
|
1453
|
-
writeSnapshot,
|
|
1454
|
-
readSnapshot,
|
|
1455
|
-
append,
|
|
1456
|
-
read,
|
|
1457
|
-
readFrom,
|
|
1458
|
-
currentVersion,
|
|
1459
|
-
readLatest,
|
|
1460
|
-
readIndex,
|
|
1726
|
+
writeSnapshot: checked(writeSnapshot),
|
|
1727
|
+
readSnapshot: checked(readSnapshot),
|
|
1728
|
+
append: checked(append),
|
|
1729
|
+
read: checked(read),
|
|
1730
|
+
readFrom: checked(readFrom),
|
|
1731
|
+
currentVersion: checked(currentVersion),
|
|
1732
|
+
readLatest: checked(readLatest),
|
|
1733
|
+
readIndex: checked(readIndex),
|
|
1734
|
+
measureAppend: checked(measureAppend),
|
|
1735
|
+
measureSnapshot: checked(measureSnapshot),
|
|
1736
|
+
decodeStreamImage: checked(decodeStreamImage),
|
|
1461
1737
|
query: queryNamespace,
|
|
1462
1738
|
[StreamIdFormatter]: composeStreamIdString,
|
|
1463
|
-
[CommandSentinelProbe]: hasCommandSentinel,
|
|
1464
|
-
[HeadAboveProbe]: headAbove,
|
|
1739
|
+
[CommandSentinelProbe]: checked(hasCommandSentinel),
|
|
1740
|
+
[HeadAboveProbe]: checked(headAbove),
|
|
1465
1741
|
};
|
|
1466
1742
|
};
|
|
1467
1743
|
// ---------------------------------------------------------------------------
|
|
@@ -1483,6 +1759,11 @@ export const makeStream = (config) => {
|
|
|
1483
1759
|
*/
|
|
1484
1760
|
export const bind = (stream) => Effect.gen(function* () {
|
|
1485
1761
|
const ctx = yield* Effect.context();
|
|
1762
|
+
// The stream meets values compressed with every codec its `storedAs`
|
|
1763
|
+
// names, so the registry captured here must have them (#147).
|
|
1764
|
+
const codecError = unregisteredCodecError("EventStore.bind", streamCodecRequirements(stream.streamName, stream.storedAs), Context.getOrUndefined(ctx, Compression));
|
|
1765
|
+
if (codecError !== undefined)
|
|
1766
|
+
throw codecError;
|
|
1486
1767
|
const provide = (effect) => Effect.provide(effect, ctx);
|
|
1487
1768
|
const probe = commandSentinelProbeOf(stream);
|
|
1488
1769
|
const headProbe = headAboveProbeOf(stream);
|
|
@@ -1495,6 +1776,7 @@ export const bind = (stream) => Effect.gen(function* () {
|
|
|
1495
1776
|
streamName: stream.streamName,
|
|
1496
1777
|
eventSchema: stream.eventSchema,
|
|
1497
1778
|
snapshotConfig: stream.snapshotConfig,
|
|
1779
|
+
storedAs: stream.storedAs,
|
|
1498
1780
|
indexes: stream.indexes,
|
|
1499
1781
|
writeSnapshot: (streamId, state, asOfVersion) => provide(stream.writeSnapshot(streamId, state, asOfVersion)),
|
|
1500
1782
|
readSnapshot: (streamId) => provide(stream.readSnapshot(streamId)),
|
|
@@ -1504,6 +1786,11 @@ export const bind = (stream) => Effect.gen(function* () {
|
|
|
1504
1786
|
currentVersion: (streamId, options) => provide(stream.currentVersion(streamId, options)),
|
|
1505
1787
|
readLatest: (streamId, options) => provide(stream.readLatest(streamId, options)),
|
|
1506
1788
|
readIndex: (name, streamId, options) => provide(stream.readIndex(name, streamId, options)),
|
|
1789
|
+
// The captured context carries a `Compression` registry provided where
|
|
1790
|
+
// the stream was bound (#147).
|
|
1791
|
+
measureAppend: (streamId, events, expectedVersion, options) => provide(stream.measureAppend(streamId, events, expectedVersion, options)),
|
|
1792
|
+
measureSnapshot: (streamId, state, asOfVersion) => provide(stream.measureSnapshot(streamId, state, asOfVersion)),
|
|
1793
|
+
decodeStreamImage: ((image, options) => provide(stream.decodeStreamImage(image, options))),
|
|
1507
1794
|
query: stream.query,
|
|
1508
1795
|
provide,
|
|
1509
1796
|
[StreamIdFormatter]: (id) => formatStreamIdOf(stream, id),
|