effect-dynamodb 1.25.0 → 1.27.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 +454 -55
  18. package/dist/EventStore.d.ts.map +1 -1
  19. package/dist/EventStore.js +681 -129
  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
@@ -15,18 +15,32 @@
15
15
  * - Stream indexes (`makeStream({ indexes })`): sub-streams ordered by a key
16
16
  * derived from each event, on an LSI or GSI, each projecting all of the
17
17
  * event, all but its metadata, or only its keys — `readIndex`,
18
- * `query.index`, and `indexDefinitions` for the `CreateTable` fragments
18
+ * `query.index`, and `indexDefinitions` for the `CreateTable` fragments.
19
+ * A GSI may derive its partition too, so several streams share one
20
+ * partition of it, snapshots included (#149)
19
21
  * - `fold` / `foldFrom` helpers for state reconstruction
22
+ * - Packed storage (`makeStream({ storedAs: { data } })`, `snapshot.storedAs`):
23
+ * event data and snapshot state as one JSON string, optionally compressed —
24
+ * read by the stored form, so a stream may mix native, JSON and compressed
25
+ * items; `measureAppend` / `measureSnapshot` for caller-side size planning,
26
+ * and `decodeStreamImage` for DynamoDB Streams records
20
27
  *
21
28
  * Built on the existing library primitives (DynamoSchema, KeyComposer, Query,
22
29
  * DynamoClient, Marshaller).
23
30
  */
31
+ import { Compression } from "@effect-dynamodb/schema/Compression.js";
32
+ import { isPackedConfig } from "@effect-dynamodb/schema/DynamoModel.js";
24
33
  import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
25
34
  import { normalizeTtlSeconds } from "@effect-dynamodb/schema/Entity.js";
26
35
  import { AdditionalItemConditionFailed, AppendTooLarge, DuplicateCommand, isAwsConditionalCheckFailed, isAwsTransactionCancelled, TransactionCancelled, ValidationError, VersionConflict, } from "@effect-dynamodb/schema/Errors.js";
36
+ import { isDispatchEligible, packedShape, packValue, unpackStoredSync, } from "@effect-dynamodb/schema/internal/PackedCodec.js";
27
37
  import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
28
- import { DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
38
+ import { Context, DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
29
39
  import { DynamoClient } from "./DynamoClient.js";
40
+ import { eq } from "./internal/Expr.js";
41
+ import { itemBytes, transactItemBytes } from "./internal/ItemSize.js";
42
+ import { codecRequirements, currentRegistry, decodeImage, imageEntityType, runtimeCodecCheck, unregisteredCodecError, withRegistry, } from "./internal/Packed.js";
43
+ import { createPathBuilder } from "./internal/PathBuilder.js";
30
44
  import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, judgeCancellation, transactItemTarget, } from "./internal/TransactWriteOps.js";
31
45
  import { diagnosedValidationError, diagnoseFailure, mergeKeyAttributes, withCause, } from "./internal/WriteDiagnosis.js";
32
46
  import { fromAttributeMap, toAttributeMap } from "./Marshaller.js";
@@ -41,6 +55,13 @@ import { DEFAULT_TTL_ATTRIBUTE_NAME, resolveTtlAttributeName, } from "./Table.js
41
55
  *
42
56
  * @internal
43
57
  */
58
+ /** The codecs a stream's packed `data` / `state` name (`[EDD-9078]` when unregistered). */
59
+ const streamCodecRequirements = (streamName, storedAs) => codecRequirements(`stream "${streamName}"`, [
60
+ ["data", storedAs.data],
61
+ ["state", storedAs.state],
62
+ ]);
63
+ /** The attributes a stream image reads by stored form: event `data` only. */
64
+ const DATA_ATTRIBUTES = ["data"];
44
65
  const EventEnvelope = Schema.Struct({
45
66
  streamId: Schema.String,
46
67
  version: Schema.Number,
@@ -128,21 +149,31 @@ const STREAM_INDEX_CONFIG_KEYS = [
128
149
  "pk",
129
150
  "sk",
130
151
  "key",
152
+ "partition",
153
+ "snapshot",
131
154
  "projection",
132
155
  ];
156
+ /**
157
+ * @internal `pk = <streamPk>`: keeps one stream id's items in a partition of a
158
+ * stream index several streams (or stream ids) share (#149). The table's `pk`
159
+ * is projected into every index, whatever its projection.
160
+ */
161
+ const streamPkFilter = (streamPk) => eq(createPathBuilder().pk, streamPk);
133
162
  /**
134
163
  * @internal Validate `makeStream({ indexes })` at definition time.
135
164
  *
136
165
  * @throws `[EDD-9063]` for a malformed entry (unknown `type`, a `gsi` without
137
- * `pk`, an `lsi` with `pk`, an empty name, a `key` that is not a function,
138
- * an unknown `projection`, a member the entry type does not declare — a
139
- * misspelled `projection` would otherwise silently mean `"all"`).
166
+ * `pk`, an `lsi` with `pk`, `partition` or `snapshot`, an empty name, a
167
+ * `key` / `partition` / `snapshot` that is not a function, a `snapshot`
168
+ * without `partition` or on a stream without a snapshot config, an unknown
169
+ * `projection`, a member the entry type does not declare — a misspelled
170
+ * `projection` would otherwise silently mean `"all"`).
140
171
  * @throws `[EDD-9064]` for an index attribute that collides with a
141
172
  * stream-owned attribute.
142
173
  * @throws `[EDD-9065]` for two indexes sharing a physical index name or an
143
174
  * attribute.
144
175
  */
145
- const resolveStreamIndexes = (streamName, indexes) => {
176
+ const resolveStreamIndexes = (streamName, indexes, hasSnapshotConfig) => {
146
177
  if (indexes === undefined)
147
178
  return [];
148
179
  const fail = (code, message) => {
@@ -179,7 +210,30 @@ const resolveStreamIndexes = (streamName, indexes) => {
179
210
  'declare type: "gsi".');
180
211
  }
181
212
  if (typeof entry.key !== "function") {
182
- fail("EDD-9063", `index "${name}": key must be a function (event, version) => string | undefined.`);
213
+ fail("EDD-9063", `index "${name}": key must be a function (event, version, streamId) => string | undefined.`);
214
+ }
215
+ if (entry.partition !== undefined) {
216
+ if (type !== "gsi") {
217
+ fail("EDD-9063", `index "${name}": an "lsi" index is partitioned by the stream; only a "gsi" index ` +
218
+ 'derives its partition. Remove partition, or declare type: "gsi".');
219
+ }
220
+ if (typeof entry.partition !== "function") {
221
+ fail("EDD-9063", `index "${name}": partition must be a function (event, version, streamId) => string | undefined.`);
222
+ }
223
+ }
224
+ if (entry.snapshot !== undefined) {
225
+ if (typeof entry.snapshot !== "function") {
226
+ fail("EDD-9063", `index "${name}": snapshot must be a function (state, asOfVersion, streamId) => ` +
227
+ "{ partition, key } | undefined.");
228
+ }
229
+ if (entry.partition === undefined) {
230
+ fail("EDD-9063", `index "${name}": snapshot requires partition — only a "gsi" index with a derived ` +
231
+ "partition indexes the stream's snapshot.");
232
+ }
233
+ if (!hasSnapshotConfig) {
234
+ fail("EDD-9063", `index "${name}": snapshot requires the stream's snapshot config ` +
235
+ "(makeStream({ snapshot: { schema } })); the stream writes no snapshot to index.");
236
+ }
183
237
  }
184
238
  const projection = (entry.projection ?? "all");
185
239
  if (projection !== "all" && projection !== "withoutMetadata" && projection !== "keys") {
@@ -216,11 +270,67 @@ const resolveStreamIndexes = (streamName, indexes) => {
216
270
  pk,
217
271
  sk,
218
272
  projection,
273
+ partition: entry.partition === undefined ? "stream" : "derived",
274
+ snapshot: entry.snapshot !== undefined,
219
275
  key: entry.key,
276
+ derivePartition: entry.partition,
277
+ snapshotEntry: entry.snapshot,
220
278
  });
221
279
  }
222
280
  return resolved;
223
281
  };
282
+ /**
283
+ * @internal Validate `makeStream({ storedAs })` and `snapshot.storedAs` at
284
+ * definition time (#147).
285
+ *
286
+ * @throws `[EDD-9079]` for a `storedAs` that is not an object, a key other
287
+ * than `data`, or a value that is not a `DynamoModel.Packed(...)` config.
288
+ * @throws `[EDD-9073]` for a packed snapshot whose state schema's encoded side
289
+ * is not object- or array-shaped.
290
+ */
291
+ const resolveStreamStorage = (streamName, storedAs, snapshot) => {
292
+ const fail = (code, message) => {
293
+ throw new Error(`[${code}] EventStream "${streamName}": ${message}`);
294
+ };
295
+ const describe = (value) => value === null
296
+ ? "null"
297
+ : typeof value === "object"
298
+ ? "an object that is not a DynamoModel.Packed(...) config"
299
+ : `${typeof value} ${JSON.stringify(value)}`;
300
+ let data;
301
+ if (storedAs !== undefined) {
302
+ if (typeof storedAs !== "object" || storedAs === null || Array.isArray(storedAs)) {
303
+ fail("EDD-9079", `storedAs must be an object { data?: DynamoModel.Packed(...) }; received ${describe(storedAs)}.`);
304
+ }
305
+ for (const [key, value] of Object.entries(storedAs)) {
306
+ if (key !== "data") {
307
+ fail("EDD-9079", `storedAs declares "${key}", but only "data" can be stored packed` +
308
+ (key === "metadata" ? " — event metadata always stays a map" : "") +
309
+ (key === "state" ? " — pack snapshot state with snapshot.storedAs" : "") +
310
+ ".");
311
+ }
312
+ if (value !== undefined && !isPackedConfig(value)) {
313
+ fail("EDD-9079", `storedAs.data must be a DynamoModel.Packed(...) config; received ${describe(value)}.`);
314
+ }
315
+ data = value;
316
+ }
317
+ }
318
+ let state;
319
+ const snapshotStoredAs = snapshot?.storedAs;
320
+ if (snapshot !== undefined && snapshotStoredAs !== undefined) {
321
+ if (!isPackedConfig(snapshotStoredAs)) {
322
+ fail("EDD-9079", `snapshot.storedAs must be a DynamoModel.Packed(...) config; received ` +
323
+ `${describe(snapshotStoredAs)}.`);
324
+ }
325
+ const shape = packedShape(snapshot.schema);
326
+ if (shape._tag === "Refused") {
327
+ fail("EDD-9073", `snapshot.storedAs packs the snapshot state, but the state schema cannot be packed: ` +
328
+ `${shape.reason}. Packed storage holds object- or array-shaped JSON.`);
329
+ }
330
+ state = snapshotStoredAs;
331
+ }
332
+ return { data, state };
333
+ };
224
334
  // ---------------------------------------------------------------------------
225
335
  // EventStreamTypeId
226
336
  // ---------------------------------------------------------------------------
@@ -361,11 +471,20 @@ const eventTimestampsProblem = (timestamps, count, label, events) => {
361
471
  * `indexes` declares sub-streams ordered by a key derived from each event
362
472
  * (#140), on a local (default) or global secondary index. `append` writes each
363
473
  * event's derived key (and, for a GSI, the stream's partition key) on the
364
- * event item; snapshots and idempotency sentinels never carry them, so they
365
- * are never in an index. Read with {@link EventStream.readIndex} or
366
- * `query.index`, and create the indexes with {@link indexDefinitions}. Each
367
- * index's `projection` (default `"all"`) sets what it copies from each event
368
- * and how it is read — see {@link StreamIndexProjection}.
474
+ * event item. Idempotency sentinels never carry them; snapshots carry an
475
+ * entry only in a GSI that derives its partition and declares `snapshot`.
476
+ * Read with {@link EventStream.readIndex} or `query.index`, and create the
477
+ * indexes with {@link indexDefinitions}. Each index's `projection` (default
478
+ * `"all"`) sets what it copies from each event and how it is read — see
479
+ * {@link StreamIndexProjection}.
480
+ *
481
+ * A GSI's `partition` (#149) derives the partition from the stream id and the
482
+ * event instead, stored raw, so several streams declaring the same GSI write
483
+ * one partition of it — an order's line, payment and shipment streams under
484
+ * `ORDER#<orderId>`, read by one query — and its `snapshot` puts each stream's
485
+ * snapshot item in that partition too, in the same `Put` (so an inline
486
+ * snapshot enters the index in the append's own transaction). See
487
+ * {@link GlobalStreamIndexConfig}.
369
488
  *
370
489
  * Index attributes are written only at append time and events are never
371
490
  * rewritten. Declaring an index on a stream that already holds events, or
@@ -377,6 +496,36 @@ const eventTimestampsProblem = (timestamps, count, label, events) => {
377
496
  *
378
497
  * @example
379
498
  * ```typescript
499
+ * // Lines and payments of an order share `gsi1` under `ORDER#<orderId>`.
500
+ * const OrderLines = EventStore.makeStream({
501
+ * table: EventsTable,
502
+ * streamName: "OrderLine",
503
+ * events: [LineAdded, LineRemoved],
504
+ * streamId: { composite: ["orderId", "lineId"] },
505
+ * snapshot: { schema: LineState, mode: "inline" },
506
+ * indexes: {
507
+ * byOrder: {
508
+ * type: "gsi",
509
+ * index: "gsi1",
510
+ * pk: "gsi1pk",
511
+ * sk: "gsi1sk",
512
+ * partition: (_event, _version, streamId) => `ORDER#${streamId.orderId}`,
513
+ * key: (_event, version, streamId) => `LINE#${streamId.lineId}#EVT#${pad(version)}`,
514
+ * snapshot: (_state, _asOfVersion, streamId) => ({
515
+ * partition: `ORDER#${streamId.orderId}`,
516
+ * key: `LINE#${streamId.lineId}#STATE`,
517
+ * }),
518
+ * },
519
+ * },
520
+ * })
521
+ *
522
+ * const lineEvents = yield* OrderLines.readIndex("byOrder", { orderId, lineId }, {
523
+ * partition: `ORDER#${orderId}`,
524
+ * })
525
+ * ```
526
+ *
527
+ * @example
528
+ * ```typescript
380
529
  * const Entries = EventStore.makeStream({
381
530
  * table: EventsTable,
382
531
  * streamName: "Ledger",
@@ -401,10 +550,12 @@ const eventTimestampsProblem = (timestamps, count, label, events) => {
401
550
  * @throws `[EDD-9062]` when `snapshot.mode` is neither `"after-append"` nor
402
551
  * `"inline"`.
403
552
  * @throws `[EDD-9063]` for a malformed index: a `type` other than `"lsi"` /
404
- * `"gsi"`, a `gsi` without `pk`, an `lsi` with `pk`, an empty `index` / `sk`
405
- * / `pk`, a `key` that is not a function, a `projection` other than
406
- * `"all"` / `"withoutMetadata"` / `"keys"`, or a member an index entry does
407
- * not declare (such as a misspelled `projection`, also a type error).
553
+ * `"gsi"`, a `gsi` without `pk`, an `lsi` with `pk`, `partition` or
554
+ * `snapshot`, an empty `index` / `sk` / `pk`, a `key` / `partition` /
555
+ * `snapshot` that is not a function, a `snapshot` without `partition` or on
556
+ * a stream without a `snapshot` config, a `projection` other than `"all"` /
557
+ * `"withoutMetadata"` / `"keys"`, or a member an index entry does not
558
+ * declare (such as a misspelled `projection`, also a type error).
408
559
  * @throws `[EDD-9064]` when an index attribute is one the stream writes itself
409
560
  * (`pk`, `sk`, `__edd_e__`, `streamId`, `version`, `eventType`, `data`,
410
561
  * `metadata`, `timestamp`, `asOfVersion`, `state`, `commandId`, or the
@@ -413,6 +564,18 @@ const eventTimestampsProblem = (timestamps, count, label, events) => {
413
564
  * with `ValidationError`.
414
565
  * @throws `[EDD-9065]` when two indexes share a physical index name or an
415
566
  * attribute (or a GSI's `pk` and `sk` are the same attribute).
567
+ * @throws `[EDD-9079]` when `storedAs` declares a key other than `data`
568
+ * (metadata always stays a map), or a `storedAs.data` / `snapshot.storedAs`
569
+ * value is not a `DynamoModel.Packed(...)` config.
570
+ * @throws `[EDD-9073]` when `snapshot.storedAs` is set and the state schema's
571
+ * encoded side is not object- or array-shaped.
572
+ *
573
+ * `storedAs: { data: DynamoModel.Packed(...) }` stores event `data` packed, and
574
+ * `snapshot.storedAs` the snapshot `state` — see {@link StreamStorageConfig}.
575
+ * Register the codecs a compressed config names through the `Compression`
576
+ * service (`Compression.layer([...])` merged into the application layer): a
577
+ * bound stream refuses to bind without them, and an unbound one to run its
578
+ * first operation (`[EDD-9078]`).
416
579
  */
417
580
  export const makeStream = (config) => {
418
581
  const schema = config.table.schema;
@@ -463,8 +626,41 @@ export const makeStream = (config) => {
463
626
  const snapshotSettings = snapshot === undefined
464
627
  ? undefined
465
628
  : { mode: snapshot.mode ?? "after-append", every: snapshot.every };
629
+ // Packed storage (EDD-9079, EDD-9073) — validated at definition time.
630
+ const storage = resolveStreamStorage(config.streamName, config.storedAs, snapshot);
631
+ /** Event `data` packing, or `undefined` for the native map. */
632
+ const dataStoredAs = storage.data;
633
+ /** Snapshot `state` packing, or `undefined` for the native map. */
634
+ const stateStoredAs = storage.state;
635
+ /**
636
+ * Whether a snapshot's stored `state` is read by its stored form: when it is
637
+ * configured packed, or when its schema's encoded side is object- or
638
+ * array-shaped (a native state is then never `S` / `B`), so a stream that
639
+ * turned packing off still reads its packed snapshot.
640
+ */
641
+ const stateDispatch = snapshot !== undefined && (stateStoredAs !== undefined || isDispatchEligible(snapshot.schema));
642
+ /**
643
+ * The first operation run with a given compressor registry checks that it
644
+ * has every codec `storedAs` names (`[EDD-9078]`, a defect): the stream
645
+ * meets values compressed with them on every read and write. `bind` checks
646
+ * the registry it captures up front instead.
647
+ */
648
+ const codecCheck = runtimeCodecCheck(`EventStore (first use of stream "${config.streamName}")`, streamCodecRequirements(config.streamName, storage));
649
+ const checked = (op) => codecCheck === undefined ? op : (...args) => Effect.andThen(codecCheck, op(...args));
650
+ /** The `[EDD-9076]` guard for queries of a stream whose `data` is packed. */
651
+ const dataGuards = dataStoredAs === undefined
652
+ ? []
653
+ : [
654
+ {
655
+ attribute: "data",
656
+ owner: `EventStream "${config.streamName}"`,
657
+ storedAs: dataStoredAs,
658
+ alternative: (path) => `Use Query.filterBy((event) => /* test event.${path} */), which runs on the ` +
659
+ "decoded event inside the same paging loop.",
660
+ },
661
+ ];
466
662
  // Stream indexes (#140) — validated at definition time (EDD-9063..9065).
467
- const streamIndexes = resolveStreamIndexes(config.streamName, config.indexes);
663
+ const streamIndexes = resolveStreamIndexes(config.streamName, config.indexes, snapshot !== undefined);
468
664
  const indexSettings = {};
469
665
  for (const idx of streamIndexes) {
470
666
  indexSettings[idx.name] = {
@@ -473,8 +669,12 @@ export const makeStream = (config) => {
473
669
  pk: idx.pk,
474
670
  sk: idx.sk,
475
671
  projection: idx.projection,
672
+ partition: idx.partition,
673
+ snapshot: idx.snapshot,
476
674
  };
477
675
  }
676
+ /** The indexes that hold the stream's snapshot (#149). */
677
+ const snapshotIndexes = streamIndexes.filter((idx) => idx.snapshotEntry !== undefined);
478
678
  /** Every attribute the indexes write on an event item. */
479
679
  const indexAttributes = streamIndexes.flatMap((idx) => idx.type === "gsi" ? [idx.pk, idx.sk] : [idx.sk]);
480
680
  // Build union schema from event schemas for decoding
@@ -548,11 +748,41 @@ export const makeStream = (config) => {
548
748
  // Decode a raw DynamoDB item → StreamEvent<TEvent>
549
749
  // ---------------------------------------------------------------------------
550
750
  const decodeEnvelope = Schema.decodeUnknownEffect(EventEnvelope);
751
+ /**
752
+ * An event's stored `data` → its wire form (plain JSON), by the stored
753
+ * form. A native value costs one `typeof` check.
754
+ */
755
+ const unpackData = (stored, operation) => typeof stored === "string" || stored instanceof Uint8Array
756
+ ? withRegistry((registry) => unpackStoredSync(stored, registry, { entityType, operation }, "data"))
757
+ : Effect.succeed(stored);
758
+ /**
759
+ * A raw event row (`Query.select`) with its `data` — when projected — as
760
+ * plain parsed JSON, whatever its stored form (R3: a stream that switched
761
+ * encoding holds a mix). Never schema-decoded. Only this stream's event
762
+ * rows are read: a row of another type (`Query.ignoreOwnership`) is handed
763
+ * back untouched, its `__edd_e__` borrowed for the check and stripped again.
764
+ * In place, so the strip reaches the row returned.
765
+ */
766
+ const rawEventRow = Object.assign((raw) => {
767
+ const stored = raw.data;
768
+ if (raw.__edd_e__ !== entityType)
769
+ return Effect.succeed(raw);
770
+ if (typeof stored !== "string" && !(stored instanceof Uint8Array))
771
+ return Effect.succeed(raw);
772
+ return Effect.map(unpackData(stored, "EventStore.select"), (data) => {
773
+ raw.data = data;
774
+ return raw;
775
+ });
776
+ }, { reads: ["__edd_e__"], attributes: ["data"] });
551
777
  const decodeStreamEvent = (raw) => Effect.gen(function* () {
552
778
  const toValidationError = (operation) => (cause) => new ValidationError({ entityType, operation, cause });
553
779
  const envelope = yield* decodeEnvelope(raw).pipe(Effect.mapError(toValidationError("EventStore.decode")));
780
+ // `data` is read by its stored form (#147): a native map as it is, a
781
+ // JSON string (`S`) parsed, compressed binary (`B`) decompressed and
782
+ // parsed — whatever the stream's current `storedAs` says.
783
+ const wireData = yield* unpackData(raw.data, "EventStore.decode");
554
784
  const decoder = Schema.decodeUnknownEffect(eventUnion);
555
- const data = yield* decoder(raw.data).pipe(Effect.mapError(toValidationError("EventStore.decode")));
785
+ const data = yield* decoder(wireData).pipe(Effect.mapError(toValidationError("EventStore.decode")));
556
786
  // Metadata is decoded through its schema when the stream declares one —
557
787
  // the mirror of the encode performed by `append`. Streams without a
558
788
  // metadata schema surface the stored attribute map untouched.
@@ -575,34 +805,61 @@ export const makeStream = (config) => {
575
805
  // append
576
806
  // ---------------------------------------------------------------------------
577
807
  /**
578
- * An event's key in a stream index (#140): a string, or `undefined` to
579
- * leave the event out. Another type, or a `key` that throws, is a
580
- * `ValidationError`, raised before anything is written. DynamoDB's own
581
- * rules on a key — not empty, at most 1024 bytes for a sort key — are not
582
- * pre-checked: DynamoDB rejects the append and the rejection is diagnosed,
583
- * naming the index and event (see `sendAppendTransaction`).
808
+ * One value an index function derives for an item (#140, #149): an event's
809
+ * `key` or `partition`, a string or `undefined` to leave the item out.
810
+ * Another type, or a function that throws, is a `ValidationError`, raised
811
+ * before anything is written. DynamoDB's own rules on a key — not empty, at
812
+ * most 1024 bytes for a sort key and 2048 for a partition key — are not
813
+ * pre-checked: DynamoDB rejects the write and the rejection is diagnosed,
814
+ * naming the index and the item (see `sendAppendTransaction`).
584
815
  */
585
- const deriveIndexKey = (idx, event, version) => Effect.try({
586
- try: () => idx.key(event, version),
816
+ const deriveIndexValue = (idx, what, subject, derive) => Effect.try({
817
+ try: derive,
587
818
  catch: (cause) => new ValidationError({ entityType, operation: "EventStore.append.indexes", cause }),
588
819
  }).pipe(Effect.flatMap((derived) => {
589
820
  if (derived === undefined)
590
821
  return Effect.succeed(undefined);
591
- const refuse = (problem) => Effect.fail(new ValidationError({
592
- entityType,
593
- operation: "EventStore.append.indexes",
594
- cause: `The key of index "${idx.name}" for the event at version ${version} ${problem}. ` +
595
- "Nothing was written.",
596
- }));
597
822
  // A string, or the event is not indexed: what the stream's own key
598
823
  // contract means, which DynamoDB cannot see (a number would be sent
599
824
  // as `N`). An empty string is DynamoDB's to reject, and is diagnosed
600
825
  // from its rejection, naming the index and the event (#144).
601
826
  if (typeof derived !== "string") {
602
- return refuse(`must be a string or undefined; received ${String(derived)}`);
827
+ return Effect.fail(new ValidationError({
828
+ entityType,
829
+ operation: "EventStore.append.indexes",
830
+ cause: `The ${what} of index "${idx.name}" for ${subject} must be a string or ` +
831
+ `undefined; received ${String(derived)}. Nothing was written.`,
832
+ }));
603
833
  }
604
834
  return Effect.succeed(derived);
605
835
  }));
836
+ /**
837
+ * The snapshot's entry in a stream index (#149): its partition and key, or
838
+ * `undefined` to leave it out. Anything but an object of two strings, or an
839
+ * entry function that throws, is a `ValidationError` before anything is
840
+ * written; DynamoDB's rules on the values are its own, as for events.
841
+ */
842
+ const deriveSnapshotEntry = (idx, entry, state, streamId, asOfVersion, operation) => Effect.try({
843
+ try: () => entry(state, asOfVersion, streamId),
844
+ catch: (cause) => new ValidationError({ entityType: snapshotEntityType, operation, cause }),
845
+ }).pipe(Effect.flatMap((derived) => {
846
+ if (derived === undefined)
847
+ return Effect.succeed(undefined);
848
+ const { partition, key } = typeof derived === "object" && derived !== null
849
+ ? derived
850
+ : { partition: undefined, key: undefined };
851
+ if (typeof partition !== "string" || typeof key !== "string") {
852
+ return Effect.fail(new ValidationError({
853
+ entityType: snapshotEntityType,
854
+ operation,
855
+ cause: `The snapshot entry of index "${idx.name}" at version ${asOfVersion} must be ` +
856
+ "{ partition: string, key: string } or undefined; received " +
857
+ `${derived !== null && typeof derived === "object" ? JSON.stringify(derived) : String(derived)}. ` +
858
+ "Nothing was written.",
859
+ }));
860
+ }
861
+ return Effect.succeed({ partition, key });
862
+ }));
606
863
  /**
607
864
  * Encode snapshot state to wire form through the state schema, with the
608
865
  * same `decode → encode` fallback as events and metadata. A structurally
@@ -708,42 +965,127 @@ export const makeStream = (config) => {
708
965
  };
709
966
  /**
710
967
  * A diagnosed rejection of an append (#144) other than its item count. An
711
- * over-long (or empty) stream-index sort key OF AN EVENT — the value the
712
- * index's `key` function returned — is named by its index and event, as
713
- * the `indexes` option reports its other key problems. Only the event puts
714
- * carry stream-index keys: an additional item writing the same physical
715
- * attribute (single-table designs share `gsi1sk` and the like) is its own
716
- * entity's key, and is named as any other item.
968
+ * over-long (or empty) stream-index key OF AN EVENT OR THE INLINE SNAPSHOT
969
+ * — a value an index function returned — is named by its index and item,
970
+ * as the `indexes` option reports its other key problems (see
971
+ * {@link indexKeyError}). Only the stream's own items carry stream-index
972
+ * keys: an additional item writing the same physical attribute
973
+ * (single-table designs share `gsi1sk` and the like) is its own entity's
974
+ * key, and is named as any other item.
717
975
  */
718
976
  const appendLimitError = (diagnosis, original, tx) => {
719
- if ((diagnosis._tag === "KeyTooLong" && diagnosis.kind === "sort") ||
720
- diagnosis._tag === "EmptyKey") {
977
+ if (diagnosis._tag === "KeyTooLong" || diagnosis._tag === "EmptyKey") {
721
978
  const position = tx.targets().indexOf(diagnosis.target);
722
979
  const isEvent = position >= tx.guardCount && position < tx.guardCount + tx.eventCount;
723
- const idx = isEvent
724
- ? streamIndexes.find((candidate) => candidate.sk === diagnosis.attribute)
980
+ const isSnapshot = position >= 0 && position === tx.snapshotIndex;
981
+ const named = isEvent || isSnapshot
982
+ ? indexKeyError(diagnosis, original, isSnapshot, "the append", "EventStore.append.indexes")
725
983
  : undefined;
726
- if (idx !== undefined) {
727
- return new ValidationError({
728
- entityType,
729
- operation: "EventStore.append.indexes",
730
- cause: withCause(`The key of index "${idx.name}" for ${diagnosis.target.source} ` +
731
- (diagnosis._tag === "KeyTooLong"
732
- ? `is ${diagnosis.bytes} bytes, longer than DynamoDB's 1024-byte limit for a ` +
733
- "sort key."
734
- : "is an empty string, which DynamoDB does not accept as a key value; the " +
735
- "key function must return a non-empty string, or undefined to leave the " +
736
- "event out of the index.") +
737
- " DynamoDB rejected the append. Nothing was written.", original),
738
- });
739
- }
984
+ if (named !== undefined)
985
+ return named;
740
986
  }
741
987
  return diagnosedValidationError(diagnosis, original, {
742
988
  operation: "EventStore.append",
743
989
  entityType,
744
990
  });
745
991
  };
746
- const append = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
992
+ /**
993
+ * An over-long or empty key value in a stream-index attribute of an event
994
+ * or snapshot item, named by its index and item, or `undefined` when the
995
+ * attribute is not one the item's indexes derive. An event carries each
996
+ * index's `key` (and a derived `partition`); a snapshot, each snapshot
997
+ * index's entry. A stream-scoped GSI's partition is the stream's own key,
998
+ * which no function returned: it is named as any key.
999
+ */
1000
+ const indexKeyError = (diagnosis, original, isSnapshot, request, operation) => {
1001
+ const candidates = isSnapshot ? snapshotIndexes : streamIndexes;
1002
+ const kind = diagnosis._tag === "KeyTooLong"
1003
+ ? diagnosis.kind
1004
+ : candidates.some((idx) => idx.sk === diagnosis.attribute)
1005
+ ? "sort"
1006
+ : "partition";
1007
+ const idx = candidates.find((candidate) => kind === "sort"
1008
+ ? candidate.sk === diagnosis.attribute
1009
+ : candidate.partition === "derived" && candidate.pk === diagnosis.attribute);
1010
+ if (idx === undefined)
1011
+ return undefined;
1012
+ const value = isSnapshot
1013
+ ? `The snapshot entry's ${kind === "sort" ? "key" : "partition"}`
1014
+ : `The ${kind === "sort" ? "key" : "partition"}`;
1015
+ const leaveOut = isSnapshot
1016
+ ? "the snapshot function must return non-empty strings, or undefined to leave the " +
1017
+ "snapshot out of the index."
1018
+ : `the ${kind === "sort" ? "key" : "partition"} function must return a non-empty ` +
1019
+ "string, or undefined to leave the event out of the index.";
1020
+ return new ValidationError({
1021
+ entityType: isSnapshot ? snapshotEntityType : entityType,
1022
+ operation,
1023
+ cause: withCause(`${value} of index "${idx.name}" for ${diagnosis.target.source} ` +
1024
+ (diagnosis._tag === "KeyTooLong"
1025
+ ? `is ${diagnosis.bytes} bytes, longer than DynamoDB's ` +
1026
+ `${kind === "sort" ? "1024-byte limit for a sort" : "2048-byte limit for a partition"} key.`
1027
+ : `is an empty string, which DynamoDB does not accept as a key value; ${leaveOut}`) +
1028
+ ` DynamoDB rejected ${request}. Nothing was written.`, original),
1029
+ });
1030
+ };
1031
+ /**
1032
+ * The snapshot item for `state` at `asOfVersion`: the state encoded through
1033
+ * the state schema, then packed per `snapshot.storedAs` (#147) — so every
1034
+ * size measured afterwards is stored bytes — with its entry in each index
1035
+ * that holds snapshots (#149). Shared by `writeSnapshot`, the inline
1036
+ * snapshot of `append`, and `measureSnapshot` / `measureAppend`.
1037
+ *
1038
+ * `eventPartitions` — the partition the append's events have in each
1039
+ * index, by index name — is what the inline snapshot's entry must match.
1040
+ */
1041
+ const buildSnapshotItem = (streamId, state, asOfVersion, now, operation, eventPartitions) => Effect.gen(function* () {
1042
+ const encoded = yield* encodeSnapshotState(state, operation);
1043
+ const stored = stateStoredAs === undefined
1044
+ ? encoded
1045
+ : yield* packValue(stateStoredAs, encoded, yield* currentRegistry, {
1046
+ entityType: snapshotEntityType,
1047
+ attribute: "state",
1048
+ operation: `${operation}.packed`,
1049
+ });
1050
+ const item = {
1051
+ pk: composeStreamPk(streamId),
1052
+ sk: snapshotSk,
1053
+ __edd_e__: snapshotEntityType,
1054
+ streamId: composeStreamIdString(streamId),
1055
+ asOfVersion,
1056
+ state: stored,
1057
+ timestamp: now,
1058
+ };
1059
+ // The snapshot's index entries (#149), stored raw on the item itself,
1060
+ // so they are written by the same `Put` and count towards its size.
1061
+ for (const idx of snapshotIndexes) {
1062
+ const entry = yield* deriveSnapshotEntry(idx, idx.snapshotEntry, state, streamId, asOfVersion, operation);
1063
+ if (entry === undefined)
1064
+ continue;
1065
+ const events = eventPartitions?.get(idx.name);
1066
+ if (events !== undefined && events.partition !== entry.partition) {
1067
+ return yield* new ValidationError({
1068
+ entityType: snapshotEntityType,
1069
+ operation,
1070
+ cause: `The inline snapshot's partition in index "${idx.name}" is ` +
1071
+ `${JSON.stringify(entry.partition)}, but ${events.subject} has ` +
1072
+ `${JSON.stringify(events.partition)}: an append's events and its inline snapshot ` +
1073
+ "must be in the same partition of an index. Nothing was written.",
1074
+ });
1075
+ }
1076
+ item[idx.pk] = entry.partition;
1077
+ item[idx.sk] = entry.key;
1078
+ }
1079
+ return toAttributeMap(item);
1080
+ });
1081
+ /**
1082
+ * The stream's own items of an append — everything but the caller's
1083
+ * `additionalItems` — validated and built exactly as `append` sends them.
1084
+ * Shared by `append` and `measureAppend`, so a measurement is the size of
1085
+ * the request `append` would send. `undefined` for an append with nothing
1086
+ * to write (the historical no-op).
1087
+ */
1088
+ const buildAppend = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
747
1089
  const additionalOps = options?.additionalItems ?? [];
748
1090
  const idempotency = options?.idempotency;
749
1091
  const snapshotState = options?.snapshot;
@@ -794,7 +1136,7 @@ export const makeStream = (config) => {
794
1136
  additionalOps.length === 0 &&
795
1137
  idempotency === undefined &&
796
1138
  snapshotState === undefined) {
797
- return { version: expectedVersion, events: [] };
1139
+ return undefined;
798
1140
  }
799
1141
  // Resolve stream ID string for storage (join composites)
800
1142
  const streamIdStr = composeStreamIdString(streamId);
@@ -814,7 +1156,6 @@ export const makeStream = (config) => {
814
1156
  // atomicity — so one over the limit is rejected whole by DynamoDB and
815
1157
  // reported as `AppendTooLarge`, with the expanded item count.
816
1158
  const needsContiguityCheck = expectedVersion > 0 && events.length > 0;
817
- const client = yield* DynamoClient;
818
1159
  const tableConfig = yield* config.table.Tag;
819
1160
  const tableName = tableConfig.name;
820
1161
  // A custom TTL attribute is only known at runtime. An index key attribute
@@ -834,6 +1175,8 @@ export const makeStream = (config) => {
834
1175
  // Clock-backed timestamp (deterministic under TestClock; wall-clock in prod).
835
1176
  const nowDateTime = yield* DateTime.now;
836
1177
  const now = DateTime.formatIso(nowDateTime);
1178
+ // The compressor registry in scope, for packed `data` / `state` (#147).
1179
+ const registry = yield* currentRegistry;
837
1180
  // Validate and encode metadata to wire form if schema provided. Shared
838
1181
  // by every event; per-event `eventMetadata` is encoded in the loop below.
839
1182
  let encodedMetadata;
@@ -844,6 +1187,10 @@ export const makeStream = (config) => {
844
1187
  else if (options?.metadata !== undefined) {
845
1188
  encodedMetadata = options.metadata;
846
1189
  }
1190
+ // The partition the events have in each index that derives one (#149),
1191
+ // by index name, with the first event that has it: the events and the
1192
+ // inline snapshot of one append must agree.
1193
+ const eventPartitions = new Map();
847
1194
  // Build the event puts — one Put per event, each with attribute_not_exists(pk).
848
1195
  // Events are encoded to wire form through their schema (codec symmetry
849
1196
  // with the read path, which decodes through the same schema), so this is
@@ -859,6 +1206,16 @@ export const makeStream = (config) => {
859
1206
  // Inject _tag for plain Schema.Class events; Schema.TaggedClass
860
1207
  // events already carry _tag in their encoded form, which wins.
861
1208
  const eventData = { _tag: evtType, ...wire };
1209
+ // Packed `data` (#147): one JSON string, or compressed binary at
1210
+ // or over the threshold when that is smaller — packed here, before
1211
+ // marshalling, so every size measured afterwards is stored bytes.
1212
+ const storedData = dataStoredAs === undefined
1213
+ ? eventData
1214
+ : yield* packValue(dataStoredAs, eventData, registry, {
1215
+ entityType,
1216
+ attribute: "data",
1217
+ operation: "EventStore.append.packed",
1218
+ });
862
1219
  // This event's own metadata entry (#144), encoded as `metadata`
863
1220
  // is; an `undefined` entry writes no metadata attribute.
864
1221
  let itemMetadata = encodedMetadata;
@@ -884,7 +1241,7 @@ export const makeStream = (config) => {
884
1241
  streamId: streamIdStr,
885
1242
  version,
886
1243
  eventType: evtType,
887
- data: eventData,
1244
+ data: storedData,
888
1245
  timestamp: eventTimestamp === undefined ? now : DateTime.formatIso(eventTimestamp),
889
1246
  };
890
1247
  const item = { ...envelope };
@@ -892,15 +1249,39 @@ export const makeStream = (config) => {
892
1249
  item.metadata = itemMetadata;
893
1250
  }
894
1251
  // Stream indexes (#140): the derived key, stored raw, and for a
895
- // GSI the stream's partition key. The attributes are part of the
896
- // event item itself, so they count towards the size checks.
1252
+ // GSI the stream's partition key — or the derived partition, raw
1253
+ // too (#149). The attributes are part of the event item itself,
1254
+ // so they count towards the size checks. Both functions run for
1255
+ // every event, so each one's contract is checked on every event.
1256
+ const subject = `the event at version ${version}`;
897
1257
  for (const idx of streamIndexes) {
898
- const derived = yield* deriveIndexKey(idx, event, version);
899
- if (derived === undefined)
1258
+ const derived = yield* deriveIndexValue(idx, "key", subject, () => idx.key(event, version, streamId));
1259
+ let partition = pk;
1260
+ if (idx.derivePartition !== undefined) {
1261
+ const derivePartition = idx.derivePartition;
1262
+ partition = yield* deriveIndexValue(idx, "partition", subject, () => derivePartition(event, version, streamId));
1263
+ if (partition !== undefined) {
1264
+ const first = eventPartitions.get(idx.name);
1265
+ if (first === undefined) {
1266
+ eventPartitions.set(idx.name, { partition, subject });
1267
+ }
1268
+ else if (first.partition !== partition) {
1269
+ return yield* new ValidationError({
1270
+ entityType,
1271
+ operation: "EventStore.append.indexes",
1272
+ cause: `The partition of index "${idx.name}" for ${subject} is ` +
1273
+ `${JSON.stringify(partition)}, but ${first.subject} has ` +
1274
+ `${JSON.stringify(first.partition)}: every event of one append must be ` +
1275
+ "in the same partition of an index. Nothing was written.",
1276
+ });
1277
+ }
1278
+ }
1279
+ }
1280
+ if (derived === undefined || partition === undefined)
900
1281
  continue;
901
1282
  item[idx.sk] = derived;
902
1283
  if (idx.type === "gsi")
903
- item[idx.pk] = pk;
1284
+ item[idx.pk] = partition;
904
1285
  }
905
1286
  return {
906
1287
  Put: {
@@ -931,8 +1312,7 @@ export const makeStream = (config) => {
931
1312
  cause,
932
1313
  }),
933
1314
  });
934
- sentinel[resolveTtlAttributeName(tableConfig)] =
935
- DateTime.toEpochSeconds(nowDateTime) + ttlSeconds;
1315
+ sentinel[ttlAttribute] = DateTime.toEpochSeconds(nowDateTime) + ttlSeconds;
936
1316
  }
937
1317
  sentinelPut = {
938
1318
  Put: {
@@ -943,21 +1323,14 @@ export const makeStream = (config) => {
943
1323
  };
944
1324
  }
945
1325
  // The inline snapshot (#138): unconditional — see AppendOptions.snapshot.
1326
+ // Packed per `snapshot.storedAs` (#147), so the transaction-size and
1327
+ // item-size diagnosis count its stored bytes.
946
1328
  let snapshotPut;
947
1329
  if (snapshotState !== undefined) {
948
- const encoded = yield* encodeSnapshotState(snapshotState, "EventStore.append.snapshot");
949
1330
  snapshotPut = {
950
1331
  Put: {
951
1332
  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
- }),
1333
+ Item: yield* buildSnapshotItem(streamId, snapshotState, expectedVersion + events.length, now, "EventStore.append.snapshot", eventPartitions),
961
1334
  },
962
1335
  };
963
1336
  }
@@ -982,6 +1355,24 @@ export const makeStream = (config) => {
982
1355
  },
983
1356
  }
984
1357
  : undefined;
1358
+ const parts = {
1359
+ tableName,
1360
+ streamIdStr,
1361
+ contiguityCheck,
1362
+ eventItems,
1363
+ sentinelPut,
1364
+ snapshotPut,
1365
+ };
1366
+ return parts;
1367
+ });
1368
+ const append = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
1369
+ const additionalOps = options?.additionalItems ?? [];
1370
+ const idempotency = options?.idempotency;
1371
+ const parts = yield* buildAppend(streamId, events, expectedVersion, options);
1372
+ if (parts === undefined)
1373
+ return { version: expectedVersion, events: [] };
1374
+ const { tableName, streamIdStr, contiguityCheck, eventItems, sentinelPut, snapshotPut } = parts;
1375
+ const client = yield* DynamoClient;
985
1376
  /**
986
1377
  * Lay out the append's one transaction — see {@link AppendTransaction}:
987
1378
  * [0, C) version-contiguity ConditionCheck (C is 0 or 1)
@@ -1018,6 +1409,7 @@ export const makeStream = (config) => {
1018
1409
  if (sentinelPut !== undefined) {
1019
1410
  pushStreamItem(sentinelPut, entityType, "the idempotency sentinel");
1020
1411
  }
1412
+ const snapshotIndex = snapshotPut === undefined ? -1 : items.length;
1021
1413
  if (snapshotPut !== undefined) {
1022
1414
  pushStreamItem(snapshotPut, snapshotEntityType, "the inline snapshot");
1023
1415
  }
@@ -1030,6 +1422,7 @@ export const makeStream = (config) => {
1030
1422
  eventCount: eventItems.length,
1031
1423
  additional,
1032
1424
  sentinelIndex,
1425
+ snapshotIndex,
1033
1426
  expectedVersion,
1034
1427
  };
1035
1428
  };
@@ -1045,7 +1438,9 @@ export const makeStream = (config) => {
1045
1438
  // repeating an event, the contiguity check, the idempotency sentinel
1046
1439
  // or the inline snapshot, or another additional item), 100 items,
1047
1440
  // 4 MB, 400 KB per item, 1024-byte sort keys — are not pre-checked:
1048
- // `sendAppendTransaction` diagnoses DynamoDB's rejection (#144).
1441
+ // `sendAppendTransaction` diagnoses DynamoDB's rejection (#144). The
1442
+ // items carry packed `data` / `state` as stored (#147), so the
1443
+ // diagnosis attributes stored bytes.
1049
1444
  const outcome = yield* sendAppendTransaction(client, tx, streamIdStr, idempotency?.commandId);
1050
1445
  if (outcome === undefined) {
1051
1446
  return { version: expectedVersion + events.length, events };
@@ -1055,6 +1450,73 @@ export const makeStream = (config) => {
1055
1450
  return yield* Effect.fail(lost);
1056
1451
  });
1057
1452
  // ---------------------------------------------------------------------------
1453
+ // measureAppend / measureSnapshot — caller-side size planning (#147, R1)
1454
+ // ---------------------------------------------------------------------------
1455
+ const measureAppend = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
1456
+ if (options?.additionalItems !== undefined) {
1457
+ return yield* new ValidationError({
1458
+ entityType,
1459
+ operation: "EventStore.measureAppend",
1460
+ cause: "measureAppend does not measure additionalItems (building a guarded put reads the " +
1461
+ "table): measure the stream's own items without them, and add the size of your " +
1462
+ "own items.",
1463
+ });
1464
+ }
1465
+ const parts = yield* buildAppend(streamId, events, expectedVersion, options);
1466
+ if (parts === undefined) {
1467
+ return { events: [], sentinel: undefined, snapshot: undefined, transactionBytes: 0 };
1468
+ }
1469
+ const putBytes = (item) => itemBytes(item.Put?.Item ?? {});
1470
+ const owned = [
1471
+ ...(parts.contiguityCheck === undefined ? [] : [parts.contiguityCheck]),
1472
+ ...parts.eventItems,
1473
+ ...(parts.sentinelPut === undefined ? [] : [parts.sentinelPut]),
1474
+ ...(parts.snapshotPut === undefined ? [] : [parts.snapshotPut]),
1475
+ ];
1476
+ const measurement = {
1477
+ events: parts.eventItems.map(putBytes),
1478
+ sentinel: parts.sentinelPut === undefined ? undefined : putBytes(parts.sentinelPut),
1479
+ snapshot: parts.snapshotPut === undefined ? undefined : putBytes(parts.snapshotPut),
1480
+ transactionBytes: owned.reduce((sum, item) => sum + transactItemBytes(item), 0),
1481
+ };
1482
+ return measurement;
1483
+ });
1484
+ const measureSnapshot = (streamId, state, asOfVersion) => Effect.gen(function* () {
1485
+ if (snapshot === undefined)
1486
+ return yield* snapshotUnavailable("measureSnapshot");
1487
+ const now = DateTime.formatIso(yield* DateTime.now);
1488
+ const item = yield* buildSnapshotItem(streamId, state, asOfVersion, now, "EventStore.measureSnapshot");
1489
+ return itemBytes(item);
1490
+ });
1491
+ // ---------------------------------------------------------------------------
1492
+ // decodeStreamImage — DynamoDB Streams records (#147, R2)
1493
+ // ---------------------------------------------------------------------------
1494
+ const decodeStreamImage = (image, options) => Effect.gen(function* () {
1495
+ const ctx = { entityType, operation: "EventStore.decodeStreamImage" };
1496
+ // Not one of this stream's events — its snapshot, a sentinel, another
1497
+ // stream's or an entity's item: none, decided from the raw image before
1498
+ // anything is unmarshalled, so nothing in a foreign image can fail it.
1499
+ if (typeof image === "object" &&
1500
+ image !== null &&
1501
+ !Array.isArray(image) &&
1502
+ imageEntityType(image) !== entityType) {
1503
+ return Option.none();
1504
+ }
1505
+ // In DynamoDB JSON the S / B tag says what `data` is; unmarshalled, a
1506
+ // string that is base64 of a `B` value is told apart from JSON text.
1507
+ const raw = yield* decodeImage(image, DATA_ATTRIBUTES, ctx);
1508
+ const event = yield* decodeStreamEvent(raw);
1509
+ if (options?.raw !== true)
1510
+ return Option.some(event);
1511
+ // `raw.data` is the wire form the decode just read: the parsed JSON of a
1512
+ // packed value, the unmarshalled map of a native one.
1513
+ const withRaw = {
1514
+ ...event,
1515
+ rawData: raw.data,
1516
+ };
1517
+ return Option.some(withRaw);
1518
+ });
1519
+ // ---------------------------------------------------------------------------
1058
1520
  // read
1059
1521
  // ---------------------------------------------------------------------------
1060
1522
  /** Apply {@link ReadOptions} to an event query. */
@@ -1111,19 +1573,12 @@ export const makeStream = (config) => {
1111
1573
  const client = yield* DynamoClient;
1112
1574
  const { name: tableName } = yield* config.table.Tag;
1113
1575
  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
- };
1576
+ // Packed per `snapshot.storedAs` (#147): the diagnosis below measures
1577
+ // the stored bytes.
1578
+ const item = yield* buildSnapshotItem(streamId, state, asOfVersion, now, "EventStore.writeSnapshot");
1124
1579
  const put = {
1125
1580
  TableName: tableName,
1126
- Item: toAttributeMap(item),
1581
+ Item: item,
1127
1582
  // Monotonic: never regress the cache. Losing this race is a no-op,
1128
1583
  // not an error — the events it summarises are already durable.
1129
1584
  ConditionExpression: "attribute_not_exists(#pk) OR #asOfVersion < :asOfVersion",
@@ -1138,18 +1593,31 @@ export const makeStream = (config) => {
1138
1593
  item: { Put: put },
1139
1594
  target: () => transactItemTarget({ Put: put }, tableName, ["pk", "sk"], snapshotEntityType, "the snapshot"),
1140
1595
  }),
1141
- keys: () => ({ partition: ["pk"], sort: ["sk"] }),
1142
- rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, {
1143
- operation: "EventStore.writeSnapshot",
1144
- entityType: snapshotEntityType,
1596
+ keys: () => ({
1597
+ partition: ["pk", ...snapshotIndexes.map((idx) => idx.pk)],
1598
+ sort: ["sk", ...snapshotIndexes.map((idx) => idx.sk)],
1145
1599
  }),
1600
+ // A snapshot index entry's empty or over-long value (#149) is named
1601
+ // by its index, as an inline snapshot's is.
1602
+ rewrite: (diagnosis, original) => (diagnosis._tag === "KeyTooLong" || diagnosis._tag === "EmptyKey"
1603
+ ? indexKeyError(diagnosis, original, true, "the snapshot", "EventStore.writeSnapshot")
1604
+ : undefined) ??
1605
+ diagnosedValidationError(diagnosis, original, {
1606
+ operation: "EventStore.writeSnapshot",
1607
+ entityType: snapshotEntityType,
1608
+ }),
1146
1609
  }));
1147
1610
  });
1148
1611
  /**
1149
1612
  * Decode a stored snapshot item. A snapshot that fails to decode through the
1150
1613
  * state schema is a `ValidationError` — never silently discarded.
1151
1614
  */
1152
- const decodeSnapshotItem = (raw, operation) => Schema.decodeUnknownEffect(snapshot.schema)(raw.state).pipe(Effect.mapError((cause) => new ValidationError({ entityType: snapshotEntityType, operation, cause })), Effect.map((state) => ({
1615
+ const decodeSnapshotItem = (raw, operation) =>
1616
+ // `state` is read by its stored form (#147) when it may be packed — see
1617
+ // `stateDispatch` — then decoded through the state schema as before.
1618
+ (stateDispatch && (typeof raw.state === "string" || raw.state instanceof Uint8Array)
1619
+ ? withRegistry((registry) => unpackStoredSync(raw.state, registry, { entityType: snapshotEntityType, operation }, "state"))
1620
+ : 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
1621
  state,
1154
1622
  asOfVersion: raw.asOfVersion,
1155
1623
  timestamp: raw.timestamp,
@@ -1312,6 +1780,10 @@ export const makeStream = (config) => {
1312
1780
  skField: "sk",
1313
1781
  entityTypes: [entityType],
1314
1782
  decoder: (raw) => decodeStreamEvent(raw),
1783
+ // Raw rows (`Query.select`) carry `data` as parsed JSON, whatever its
1784
+ // stored form, and a filter inside a packed `data` is refused (#147).
1785
+ rawRow: rawEventRow,
1786
+ packed: dataGuards,
1315
1787
  resolveTableName: config.table.Tag.useSync((tc) => tc.name),
1316
1788
  keyFields: ["pk", "sk"],
1317
1789
  // Bound to the event SK range so non-event items in the stream partition
@@ -1323,15 +1795,44 @@ export const makeStream = (config) => {
1323
1795
  // Stream indexes — query.index / readIndex (#140)
1324
1796
  // ---------------------------------------------------------------------------
1325
1797
  /**
1326
- * A query of one stream index, scoped to the stream's partition: the LSI
1327
- * shares the table's `pk`; a GSI's `pk` attribute carries the same value.
1328
- * No sort-key condition — the index holds only this stream's indexed events
1329
- * under that key; `Query.where` narrows the derived-key range.
1798
+ * @internal Why `partition` cannot be passed for index `name`, or
1799
+ * `undefined` when it can: required (a string) for an index that derives
1800
+ * its partition (#149), refused for any other. Unreachable through the
1801
+ * types. An unknown name is left to {@link buildIndexQuery}.
1802
+ */
1803
+ const invalidIndexPartition = (name, partition) => {
1804
+ const idx = streamIndexes.find((candidate) => candidate.name === name);
1805
+ if (idx === undefined)
1806
+ return undefined;
1807
+ if (idx.partition === "derived") {
1808
+ return typeof partition === "string"
1809
+ ? undefined
1810
+ : `Index "${name}" derives its partition: pass the partition to read as ` +
1811
+ `options.partition (a string); received ${String(partition)}.`;
1812
+ }
1813
+ return partition === undefined
1814
+ ? undefined
1815
+ : `Index "${name}" is scoped to the stream; it takes no partition.`;
1816
+ };
1817
+ /**
1818
+ * A query of one stream index. A stream-scoped index is queried in the
1819
+ * stream's partition: the LSI shares the table's `pk`; a GSI's `pk`
1820
+ * attribute carries the same value. No sort-key condition — the index holds
1821
+ * only this stream's indexed events under that key; `Query.where` narrows
1822
+ * the derived-key range.
1823
+ *
1824
+ * An index that derives its partition (#149) is queried in `partition`,
1825
+ * which other streams (and this one's other stream ids, and snapshots) may
1826
+ * share: the `__edd_e__` filter keeps this stream's events, and a filter on
1827
+ * the table's `pk` — projected by every index — keeps `streamId`'s.
1330
1828
  *
1331
1829
  * @throws `[EDD-9066]` for a name the stream does not declare (unreachable
1332
1830
  * through the types).
1831
+ * @throws `[EDD-9080]` for a missing `partition` on an index that derives
1832
+ * it, or one passed to an index that does not (unreachable through the
1833
+ * types).
1333
1834
  */
1334
- const buildIndexQuery = (name, streamId) => {
1835
+ const buildIndexQuery = (name, streamId, partition) => {
1335
1836
  const idx = streamIndexes.find((candidate) => candidate.name === name);
1336
1837
  if (idx === undefined) {
1337
1838
  throw new Error(`[EDD-9066] EventStream "${config.streamName}": unknown index "${String(name)}". ` +
@@ -1339,14 +1840,23 @@ export const makeStream = (config) => {
1339
1840
  ? "The stream declares no indexes."
1340
1841
  : `Declared: ${streamIndexes.map((candidate) => `"${candidate.name}"`).join(", ")}.`));
1341
1842
  }
1342
- return Query.make({
1843
+ const partitionProblem = invalidIndexPartition(name, partition);
1844
+ if (partitionProblem !== undefined) {
1845
+ throw new Error(`[EDD-9080] EventStream "${config.streamName}": ${partitionProblem}`);
1846
+ }
1847
+ const streamPk = composeStreamPk(streamId);
1848
+ const query = Query.make({
1343
1849
  tableName: "",
1344
1850
  indexName: idx.index,
1345
1851
  pkField: idx.pk,
1346
- pkValue: composeStreamPk(streamId),
1852
+ pkValue: idx.partition === "derived" ? partition : streamPk,
1347
1853
  skField: idx.sk,
1348
1854
  entityTypes: [entityType],
1349
1855
  decoder: indexDecoder(idx),
1856
+ // As for `query.events` (#147) — the `"keys"` hydration path decodes
1857
+ // its fetched rows through the same hooks.
1858
+ rawRow: rawEventRow,
1859
+ packed: dataGuards,
1350
1860
  resolveTableName: config.table.Tag.useSync((tc) => tc.name),
1351
1861
  // DynamoDB's rejection of the key condition (an empty bound, a reversed
1352
1862
  // `between`) is named against the stream index; so, as for every
@@ -1367,6 +1877,7 @@ export const makeStream = (config) => {
1367
1877
  }
1368
1878
  : {}),
1369
1879
  });
1880
+ return idx.partition === "derived" ? Query.filterExpr(query, streamPkFilter(streamPk)) : query;
1370
1881
  };
1371
1882
  /**
1372
1883
  * @internal The decoder of events read through `idx`.
@@ -1413,7 +1924,11 @@ export const makeStream = (config) => {
1413
1924
  if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) {
1414
1925
  return yield* refuse(`limit must be a positive integer; received ${String(limit)}. Nothing was sent.`);
1415
1926
  }
1416
- let query = buildIndexQuery(name, streamId);
1927
+ const partitionProblem = invalidIndexPartition(name, options?.partition);
1928
+ if (partitionProblem !== undefined) {
1929
+ return yield* refuse(`${partitionProblem} Nothing was sent.`);
1930
+ }
1931
+ let query = buildIndexQuery(name, streamId, options?.partition);
1417
1932
  if (options?.beginsWith !== undefined) {
1418
1933
  query = Query.where(query, { beginsWith: options.beginsWith });
1419
1934
  }
@@ -1430,7 +1945,7 @@ export const makeStream = (config) => {
1430
1945
  });
1431
1946
  const queryNamespace = {
1432
1947
  events: (streamId) => buildEventsQuery(streamId),
1433
- index: (name, streamId) => buildIndexQuery(name, streamId),
1948
+ index: (name, streamId, options) => buildIndexQuery(name, streamId, options?.partition),
1434
1949
  };
1435
1950
  // ---------------------------------------------------------------------------
1436
1951
  // Return EventStream
@@ -1449,19 +1964,23 @@ export const makeStream = (config) => {
1449
1964
  streamName: config.streamName,
1450
1965
  eventSchema: eventUnion,
1451
1966
  snapshotConfig: snapshotSettings,
1967
+ storedAs: storage,
1452
1968
  indexes: indexSettings,
1453
- writeSnapshot,
1454
- readSnapshot,
1455
- append,
1456
- read,
1457
- readFrom,
1458
- currentVersion,
1459
- readLatest,
1460
- readIndex,
1969
+ writeSnapshot: checked(writeSnapshot),
1970
+ readSnapshot: checked(readSnapshot),
1971
+ append: checked(append),
1972
+ read: checked(read),
1973
+ readFrom: checked(readFrom),
1974
+ currentVersion: checked(currentVersion),
1975
+ readLatest: checked(readLatest),
1976
+ readIndex: checked(readIndex),
1977
+ measureAppend: checked(measureAppend),
1978
+ measureSnapshot: checked(measureSnapshot),
1979
+ decodeStreamImage: checked(decodeStreamImage),
1461
1980
  query: queryNamespace,
1462
1981
  [StreamIdFormatter]: composeStreamIdString,
1463
- [CommandSentinelProbe]: hasCommandSentinel,
1464
- [HeadAboveProbe]: headAbove,
1982
+ [CommandSentinelProbe]: checked(hasCommandSentinel),
1983
+ [HeadAboveProbe]: checked(headAbove),
1465
1984
  };
1466
1985
  };
1467
1986
  // ---------------------------------------------------------------------------
@@ -1483,6 +2002,11 @@ export const makeStream = (config) => {
1483
2002
  */
1484
2003
  export const bind = (stream) => Effect.gen(function* () {
1485
2004
  const ctx = yield* Effect.context();
2005
+ // The stream meets values compressed with every codec its `storedAs`
2006
+ // names, so the registry captured here must have them (#147).
2007
+ const codecError = unregisteredCodecError("EventStore.bind", streamCodecRequirements(stream.streamName, stream.storedAs), Context.getOrUndefined(ctx, Compression));
2008
+ if (codecError !== undefined)
2009
+ throw codecError;
1486
2010
  const provide = (effect) => Effect.provide(effect, ctx);
1487
2011
  const probe = commandSentinelProbeOf(stream);
1488
2012
  const headProbe = headAboveProbeOf(stream);
@@ -1495,6 +2019,7 @@ export const bind = (stream) => Effect.gen(function* () {
1495
2019
  streamName: stream.streamName,
1496
2020
  eventSchema: stream.eventSchema,
1497
2021
  snapshotConfig: stream.snapshotConfig,
2022
+ storedAs: stream.storedAs,
1498
2023
  indexes: stream.indexes,
1499
2024
  writeSnapshot: (streamId, state, asOfVersion) => provide(stream.writeSnapshot(streamId, state, asOfVersion)),
1500
2025
  readSnapshot: (streamId) => provide(stream.readSnapshot(streamId)),
@@ -1503,7 +2028,12 @@ export const bind = (stream) => Effect.gen(function* () {
1503
2028
  readFrom: (streamId, afterVersion, options) => provide(stream.readFrom(streamId, afterVersion, options)),
1504
2029
  currentVersion: (streamId, options) => provide(stream.currentVersion(streamId, options)),
1505
2030
  readLatest: (streamId, options) => provide(stream.readLatest(streamId, options)),
1506
- readIndex: (name, streamId, options) => provide(stream.readIndex(name, streamId, options)),
2031
+ readIndex: (name, streamId, ...options) => provide(stream.readIndex(name, streamId, ...options)),
2032
+ // The captured context carries a `Compression` registry provided where
2033
+ // the stream was bound (#147).
2034
+ measureAppend: (streamId, events, expectedVersion, options) => provide(stream.measureAppend(streamId, events, expectedVersion, options)),
2035
+ measureSnapshot: (streamId, state, asOfVersion) => provide(stream.measureSnapshot(streamId, state, asOfVersion)),
2036
+ decodeStreamImage: ((image, options) => provide(stream.decodeStreamImage(image, options))),
1507
2037
  query: stream.query,
1508
2038
  provide,
1509
2039
  [StreamIdFormatter]: (id) => formatStreamIdOf(stream, id),
@@ -1523,12 +2053,22 @@ export const bind = (stream) => Effect.gen(function* () {
1523
2053
  * Neither list names a key attribute: table and index keys are always
1524
2054
  * projected.
1525
2055
  */
1526
- const projectionDefinition = (projection) => projection === "all"
2056
+ const projectionDefinition = (projection, snapshot) => projection === "all"
1527
2057
  ? { ProjectionType: "ALL" }
1528
2058
  : {
1529
2059
  ProjectionType: "INCLUDE",
1530
- NonKeyAttributes: projection === "keys" ? ["__edd_e__"] : [...EVENT_ITEM_ATTRIBUTES],
2060
+ NonKeyAttributes: projection === "keys"
2061
+ ? ["__edd_e__"]
2062
+ : snapshot
2063
+ ? [...EVENT_ITEM_ATTRIBUTES, ...SNAPSHOT_ONLY_ATTRIBUTES].sort()
2064
+ : [...EVENT_ITEM_ATTRIBUTES],
1531
2065
  };
2066
+ /**
2067
+ * @internal The snapshot item's attributes an event item does not carry: what
2068
+ * a `"withoutMetadata"` index that holds snapshots (#149) projects besides
2069
+ * {@link EVENT_ITEM_ATTRIBUTES}.
2070
+ */
2071
+ const SNAPSHOT_ONLY_ATTRIBUTES = ["asOfVersion", "state"];
1532
2072
  /**
1533
2073
  * Derive the `CreateTable` fragments for the stream indexes (#140) of the given
1534
2074
  * streams (`EventStream` or `BoundEventStream`) — `AttributeDefinitions`,
@@ -1543,7 +2083,7 @@ const projectionDefinition = (projection) => projection === "all"
1543
2083
  * | `projection` | `Projection` |
1544
2084
  * |---|---|
1545
2085
  * | `"all"` (default) | `{ ProjectionType: "ALL" }` |
1546
- * | `"withoutMetadata"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__", "data", "eventType", "streamId", "timestamp", "version"] }` |
2086
+ * | `"withoutMetadata"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__", "data", "eventType", "streamId", "timestamp", "version"] }` — plus `"asOfVersion"` and `"state"` when a stream declaring the index puts its snapshot in it (#149) |
1547
2087
  * | `"keys"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__"] }` — not `KEYS_ONLY`: every read filters on `__edd_e__` |
1548
2088
  *
1549
2089
  * The deployed index must project what is declared — copy `Projection` from
@@ -1554,7 +2094,12 @@ const projectionDefinition = (projection) => projection === "all"
1554
2094
  * table over the limit).
1555
2095
  *
1556
2096
  * An index shared by several streams with the same definition is emitted
1557
- * once. Lists are sorted by index name, attributes by attribute name.
2097
+ * once — the way several streams write one GSI whose partition they derive
2098
+ * (`partition`, #149): an order's line, payment and shipment streams under
2099
+ * `ORDER#<orderId>`, say. Whether each declaration derives its partition or
2100
+ * indexes its snapshot is not part of the definition: the index's key schema
2101
+ * and projection kind are. Lists are sorted by index name, attributes by
2102
+ * attribute name.
1558
2103
  *
1559
2104
  * - An LSI can only be created with the table, and caps the item collection
1560
2105
  * of every partition key value in the table at 10 GB — entity partitions
@@ -1582,25 +2127,32 @@ const projectionDefinition = (projection) => projection === "all"
1582
2127
  *
1583
2128
  * @throws `[EDD-9067]` when two streams define the same physical index
1584
2129
  * differently (another type, other key attributes, or another projection).
2130
+ * Whether a declaration derives its partition or indexes its snapshot is not
2131
+ * part of the index's definition: streams that differ only there share it.
1585
2132
  */
1586
2133
  export const indexDefinitions = (...streams) => {
2134
+ /** Per physical index: its definition, its first declarer, and whether any declarer indexes snapshots. */
1587
2135
  const byIndex = new Map();
1588
2136
  for (const stream of streams) {
1589
2137
  for (const [name, raw] of Object.entries(stream.indexes)) {
1590
- const settings = { ...raw, projection: raw.projection ?? "all" };
2138
+ const settings = {
2139
+ type: raw.type,
2140
+ index: raw.index,
2141
+ pk: raw.pk,
2142
+ sk: raw.sk,
2143
+ projection: raw.projection ?? "all",
2144
+ };
2145
+ const snapshot = raw.snapshot === true;
1591
2146
  const owner = `index "${name}" of stream "${stream.streamName}"`;
1592
2147
  const existing = byIndex.get(settings.index);
1593
2148
  if (existing === undefined) {
1594
- byIndex.set(settings.index, {
1595
- type: settings.type,
1596
- index: settings.index,
1597
- pk: settings.pk,
1598
- sk: settings.sk,
1599
- projection: settings.projection,
1600
- owner,
1601
- });
2149
+ byIndex.set(settings.index, { ...settings, owner, snapshot });
1602
2150
  continue;
1603
2151
  }
2152
+ // Several streams writing one GSI they derive the partition of (#149)
2153
+ // is the point: one declaration indexing snapshots and another not
2154
+ // still share it, and the projection covers the snapshots.
2155
+ existing.snapshot ||= snapshot;
1604
2156
  if (existing.type !== settings.type ||
1605
2157
  existing.pk !== settings.pk ||
1606
2158
  existing.sk !== settings.sk ||
@@ -1623,7 +2175,7 @@ export const indexDefinitions = (...streams) => {
1623
2175
  { AttributeName: settings.pk, KeyType: "HASH" },
1624
2176
  { AttributeName: settings.sk, KeyType: "RANGE" },
1625
2177
  ],
1626
- Projection: projectionDefinition(settings.projection),
2178
+ Projection: projectionDefinition(settings.projection, settings.snapshot),
1627
2179
  };
1628
2180
  attributes.add(settings.sk);
1629
2181
  if (settings.type === "gsi") {