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.
Files changed (72) hide show
  1. package/dist/Aggregate.d.ts +7 -0
  2. package/dist/Aggregate.d.ts.map +1 -1
  3. package/dist/Aggregate.js +150 -4
  4. package/dist/Aggregate.js.map +1 -1
  5. package/dist/Collection.d.ts +9 -1
  6. package/dist/Collection.d.ts.map +1 -1
  7. package/dist/Collection.js +3 -0
  8. package/dist/Collection.js.map +1 -1
  9. package/dist/DynamoClient.d.ts +80 -28
  10. package/dist/DynamoClient.d.ts.map +1 -1
  11. package/dist/DynamoClient.js +83 -11
  12. package/dist/DynamoClient.js.map +1 -1
  13. package/dist/Entity.d.ts +80 -27
  14. package/dist/Entity.d.ts.map +1 -1
  15. package/dist/Entity.js +406 -76
  16. package/dist/Entity.js.map +1 -1
  17. package/dist/EventStore.d.ts +182 -2
  18. package/dist/EventStore.d.ts.map +1 -1
  19. package/dist/EventStore.js +328 -41
  20. package/dist/EventStore.js.map +1 -1
  21. package/dist/Query.d.ts +53 -0
  22. package/dist/Query.d.ts.map +1 -1
  23. package/dist/Query.js +88 -6
  24. package/dist/Query.js.map +1 -1
  25. package/dist/StorageEncoding.d.ts +160 -0
  26. package/dist/StorageEncoding.d.ts.map +1 -0
  27. package/dist/StorageEncoding.js +258 -0
  28. package/dist/StorageEncoding.js.map +1 -0
  29. package/dist/Table.d.ts.map +1 -1
  30. package/dist/Table.js +8 -6
  31. package/dist/Table.js.map +1 -1
  32. package/dist/Transaction.d.ts.map +1 -1
  33. package/dist/Transaction.js +5 -0
  34. package/dist/Transaction.js.map +1 -1
  35. package/dist/compression/zlib.d.ts +75 -0
  36. package/dist/compression/zlib.d.ts.map +1 -0
  37. package/dist/compression/zlib.js +109 -0
  38. package/dist/compression/zlib.js.map +1 -0
  39. package/dist/index.d.ts +4 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +3 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/internal/BoundCrud.d.ts +39 -35
  44. package/dist/internal/BoundCrud.d.ts.map +1 -1
  45. package/dist/internal/BoundCrud.js.map +1 -1
  46. package/dist/internal/BoundQuery.d.ts +3 -3
  47. package/dist/internal/BoundQuery.d.ts.map +1 -1
  48. package/dist/internal/BoundQuery.js.map +1 -1
  49. package/dist/internal/BoundVectorQuery.d.ts +12 -0
  50. package/dist/internal/BoundVectorQuery.d.ts.map +1 -1
  51. package/dist/internal/BoundVectorQuery.js +14 -1
  52. package/dist/internal/BoundVectorQuery.js.map +1 -1
  53. package/dist/internal/EntityCombinators.d.ts.map +1 -1
  54. package/dist/internal/EntityCombinators.js +16 -1
  55. package/dist/internal/EntityCombinators.js.map +1 -1
  56. package/dist/internal/Expr.d.ts +5 -3
  57. package/dist/internal/Expr.d.ts.map +1 -1
  58. package/dist/internal/Expr.js +1 -1
  59. package/dist/internal/Expr.js.map +1 -1
  60. package/dist/internal/Packed.d.ts +289 -0
  61. package/dist/internal/Packed.d.ts.map +1 -0
  62. package/dist/internal/Packed.js +654 -0
  63. package/dist/internal/Packed.js.map +1 -0
  64. package/dist/internal/PathBuilder.d.ts +45 -2
  65. package/dist/internal/PathBuilder.d.ts.map +1 -1
  66. package/dist/internal/PathBuilder.js +4 -0
  67. package/dist/internal/PathBuilder.js.map +1 -1
  68. package/dist/internal/TransactableOps.d.ts +1 -1
  69. package/dist/internal/TransactableOps.d.ts.map +1 -1
  70. package/dist/internal/TransactableOps.js +2 -0
  71. package/dist/internal/TransactableOps.js.map +1 -1
  72. package/package.json +6 -2
@@ -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(raw.data).pipe(Effect.mapError(toValidationError("EventStore.decode")));
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
- const append = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
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 { version: expectedVersion, events: [] };
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: eventData,
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[resolveTtlAttributeName(tableConfig)] =
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: toAttributeMap({
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
- const encoded = yield* encodeSnapshotState(state, "EventStore.writeSnapshot");
1115
- const item = {
1116
- pk: composeStreamPk(streamId),
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: toAttributeMap(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) => Schema.decodeUnknownEffect(snapshot.schema)(raw.state).pipe(Effect.mapError((cause) => new ValidationError({ entityType: snapshotEntityType, operation, cause })), Effect.map((state) => ({
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),