effect-dynamodb 1.26.0 → 1.28.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.
@@ -15,13 +15,20 @@
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
20
22
  * - Packed storage (`makeStream({ storedAs: { data } })`, `snapshot.storedAs`):
21
23
  * event data and snapshot state as one JSON string, optionally compressed —
22
24
  * read by the stored form, so a stream may mix native, JSON and compressed
23
25
  * items; `measureAppend` / `measureSnapshot` for caller-side size planning,
24
26
  * and `decodeStreamImage` for DynamoDB Streams records
27
+ * - Change-feed projections (#151): `projection` folds a stream into a
28
+ * read-model entity in version order, filling gaps from the store instead of
29
+ * waiting for delivery (`applyBatch`, `apply`, `catchUp`, `rebuild`,
30
+ * `processBatch`, `bindProjection`), plus `decoder`, `eventFilterPattern`
31
+ * and `batchResponse` for the change feed itself
25
32
  *
26
33
  * Built on the existing library primitives (DynamoSchema, KeyComposer, Query,
27
34
  * DynamoClient, Marshaller).
@@ -35,8 +42,11 @@ import { isDispatchEligible, packedShape, packValue, unpackStoredSync, } from "@
35
42
  import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
36
43
  import { Context, DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
37
44
  import { DynamoClient } from "./DynamoClient.js";
45
+ import { ProjectionDecode, } from "./internal/EventProjection.js";
46
+ import { eq } from "./internal/Expr.js";
38
47
  import { itemBytes, transactItemBytes } from "./internal/ItemSize.js";
39
48
  import { codecRequirements, currentRegistry, decodeImage, imageEntityType, runtimeCodecCheck, unregisteredCodecError, withRegistry, } from "./internal/Packed.js";
49
+ import { createPathBuilder } from "./internal/PathBuilder.js";
40
50
  import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, judgeCancellation, transactItemTarget, } from "./internal/TransactWriteOps.js";
41
51
  import { diagnosedValidationError, diagnoseFailure, mergeKeyAttributes, withCause, } from "./internal/WriteDiagnosis.js";
42
52
  import { fromAttributeMap, toAttributeMap } from "./Marshaller.js";
@@ -145,21 +155,31 @@ const STREAM_INDEX_CONFIG_KEYS = [
145
155
  "pk",
146
156
  "sk",
147
157
  "key",
158
+ "partition",
159
+ "snapshot",
148
160
  "projection",
149
161
  ];
162
+ /**
163
+ * @internal `pk = <streamPk>`: keeps one stream id's items in a partition of a
164
+ * stream index several streams (or stream ids) share (#149). The table's `pk`
165
+ * is projected into every index, whatever its projection.
166
+ */
167
+ const streamPkFilter = (streamPk) => eq(createPathBuilder().pk, streamPk);
150
168
  /**
151
169
  * @internal Validate `makeStream({ indexes })` at definition time.
152
170
  *
153
171
  * @throws `[EDD-9063]` for a malformed entry (unknown `type`, a `gsi` without
154
- * `pk`, an `lsi` with `pk`, an empty name, a `key` that is not a function,
155
- * an unknown `projection`, a member the entry type does not declare — a
156
- * misspelled `projection` would otherwise silently mean `"all"`).
172
+ * `pk`, an `lsi` with `pk`, `partition` or `snapshot`, an empty name, a
173
+ * `key` / `partition` / `snapshot` that is not a function, a `snapshot`
174
+ * without `partition` or on a stream without a snapshot config, an unknown
175
+ * `projection`, a member the entry type does not declare — a misspelled
176
+ * `projection` would otherwise silently mean `"all"`).
157
177
  * @throws `[EDD-9064]` for an index attribute that collides with a
158
178
  * stream-owned attribute.
159
179
  * @throws `[EDD-9065]` for two indexes sharing a physical index name or an
160
180
  * attribute.
161
181
  */
162
- const resolveStreamIndexes = (streamName, indexes) => {
182
+ const resolveStreamIndexes = (streamName, indexes, hasSnapshotConfig) => {
163
183
  if (indexes === undefined)
164
184
  return [];
165
185
  const fail = (code, message) => {
@@ -196,7 +216,30 @@ const resolveStreamIndexes = (streamName, indexes) => {
196
216
  'declare type: "gsi".');
197
217
  }
198
218
  if (typeof entry.key !== "function") {
199
- fail("EDD-9063", `index "${name}": key must be a function (event, version) => string | undefined.`);
219
+ fail("EDD-9063", `index "${name}": key must be a function (event, version, streamId) => string | undefined.`);
220
+ }
221
+ if (entry.partition !== undefined) {
222
+ if (type !== "gsi") {
223
+ fail("EDD-9063", `index "${name}": an "lsi" index is partitioned by the stream; only a "gsi" index ` +
224
+ 'derives its partition. Remove partition, or declare type: "gsi".');
225
+ }
226
+ if (typeof entry.partition !== "function") {
227
+ fail("EDD-9063", `index "${name}": partition must be a function (event, version, streamId) => string | undefined.`);
228
+ }
229
+ }
230
+ if (entry.snapshot !== undefined) {
231
+ if (typeof entry.snapshot !== "function") {
232
+ fail("EDD-9063", `index "${name}": snapshot must be a function (state, asOfVersion, streamId) => ` +
233
+ "{ partition, key } | undefined.");
234
+ }
235
+ if (entry.partition === undefined) {
236
+ fail("EDD-9063", `index "${name}": snapshot requires partition — only a "gsi" index with a derived ` +
237
+ "partition indexes the stream's snapshot.");
238
+ }
239
+ if (!hasSnapshotConfig) {
240
+ fail("EDD-9063", `index "${name}": snapshot requires the stream's snapshot config ` +
241
+ "(makeStream({ snapshot: { schema } })); the stream writes no snapshot to index.");
242
+ }
200
243
  }
201
244
  const projection = (entry.projection ?? "all");
202
245
  if (projection !== "all" && projection !== "withoutMetadata" && projection !== "keys") {
@@ -233,7 +276,11 @@ const resolveStreamIndexes = (streamName, indexes) => {
233
276
  pk,
234
277
  sk,
235
278
  projection,
279
+ partition: entry.partition === undefined ? "stream" : "derived",
280
+ snapshot: entry.snapshot !== undefined,
236
281
  key: entry.key,
282
+ derivePartition: entry.partition,
283
+ snapshotEntry: entry.snapshot,
237
284
  });
238
285
  }
239
286
  return resolved;
@@ -302,6 +349,24 @@ const EventStreamTypeId = Symbol.for("effect-dynamodb/EventStream");
302
349
  * attach it; a hand-built stream object falls back to joining the id's values.
303
350
  */
304
351
  const StreamIdFormatter = Symbol.for("effect-dynamodb/EventStream/StreamIdFormatter");
352
+ /** @internal {@link EventStream.parseStreamId}, shared by `makeStream` and `bind`. */
353
+ const parseStreamIdWith = (streamName, fields, streamId) => {
354
+ const parts = fields.length === 1 ? [streamId] : streamId.split("#");
355
+ if (parts.length !== fields.length) {
356
+ return Effect.fail(new ValidationError({
357
+ entityType: `${streamName.toLowerCase()}.event`,
358
+ operation: "EventStore.parseStreamId",
359
+ cause: `Stream id ${JSON.stringify(streamId)} has ${parts.length} "#"-separated parts, but ` +
360
+ `the stream id has ${fields.length} fields (${fields.join(", ")}): a value containing ` +
361
+ `"#" makes the id ambiguous.`,
362
+ }));
363
+ }
364
+ const id = {};
365
+ fields.forEach((field, i) => {
366
+ id[field] = parts[i];
367
+ });
368
+ return Effect.succeed(id);
369
+ };
305
370
  /** @internal */
306
371
  const formatStreamIdOf = (stream, streamId) => {
307
372
  const formatter = stream[StreamIdFormatter];
@@ -430,11 +495,20 @@ const eventTimestampsProblem = (timestamps, count, label, events) => {
430
495
  * `indexes` declares sub-streams ordered by a key derived from each event
431
496
  * (#140), on a local (default) or global secondary index. `append` writes each
432
497
  * event's derived key (and, for a GSI, the stream's partition key) on the
433
- * event item; snapshots and idempotency sentinels never carry them, so they
434
- * are never in an index. Read with {@link EventStream.readIndex} or
435
- * `query.index`, and create the indexes with {@link indexDefinitions}. Each
436
- * index's `projection` (default `"all"`) sets what it copies from each event
437
- * and how it is read — see {@link StreamIndexProjection}.
498
+ * event item. Idempotency sentinels never carry them; snapshots carry an
499
+ * entry only in a GSI that derives its partition and declares `snapshot`.
500
+ * Read with {@link EventStream.readIndex} or `query.index`, and create the
501
+ * indexes with {@link indexDefinitions}. Each index's `projection` (default
502
+ * `"all"`) sets what it copies from each event and how it is read — see
503
+ * {@link StreamIndexProjection}.
504
+ *
505
+ * A GSI's `partition` (#149) derives the partition from the stream id and the
506
+ * event instead, stored raw, so several streams declaring the same GSI write
507
+ * one partition of it — an order's line, payment and shipment streams under
508
+ * `ORDER#<orderId>`, read by one query — and its `snapshot` puts each stream's
509
+ * snapshot item in that partition too, in the same `Put` (so an inline
510
+ * snapshot enters the index in the append's own transaction). See
511
+ * {@link GlobalStreamIndexConfig}.
438
512
  *
439
513
  * Index attributes are written only at append time and events are never
440
514
  * rewritten. Declaring an index on a stream that already holds events, or
@@ -446,6 +520,36 @@ const eventTimestampsProblem = (timestamps, count, label, events) => {
446
520
  *
447
521
  * @example
448
522
  * ```typescript
523
+ * // Lines and payments of an order share `gsi1` under `ORDER#<orderId>`.
524
+ * const OrderLines = EventStore.makeStream({
525
+ * table: EventsTable,
526
+ * streamName: "OrderLine",
527
+ * events: [LineAdded, LineRemoved],
528
+ * streamId: { composite: ["orderId", "lineId"] },
529
+ * snapshot: { schema: LineState, mode: "inline" },
530
+ * indexes: {
531
+ * byOrder: {
532
+ * type: "gsi",
533
+ * index: "gsi1",
534
+ * pk: "gsi1pk",
535
+ * sk: "gsi1sk",
536
+ * partition: (_event, _version, streamId) => `ORDER#${streamId.orderId}`,
537
+ * key: (_event, version, streamId) => `LINE#${streamId.lineId}#EVT#${pad(version)}`,
538
+ * snapshot: (_state, _asOfVersion, streamId) => ({
539
+ * partition: `ORDER#${streamId.orderId}`,
540
+ * key: `LINE#${streamId.lineId}#STATE`,
541
+ * }),
542
+ * },
543
+ * },
544
+ * })
545
+ *
546
+ * const lineEvents = yield* OrderLines.readIndex("byOrder", { orderId, lineId }, {
547
+ * partition: `ORDER#${orderId}`,
548
+ * })
549
+ * ```
550
+ *
551
+ * @example
552
+ * ```typescript
449
553
  * const Entries = EventStore.makeStream({
450
554
  * table: EventsTable,
451
555
  * streamName: "Ledger",
@@ -470,10 +574,12 @@ const eventTimestampsProblem = (timestamps, count, label, events) => {
470
574
  * @throws `[EDD-9062]` when `snapshot.mode` is neither `"after-append"` nor
471
575
  * `"inline"`.
472
576
  * @throws `[EDD-9063]` for a malformed index: a `type` other than `"lsi"` /
473
- * `"gsi"`, a `gsi` without `pk`, an `lsi` with `pk`, an empty `index` / `sk`
474
- * / `pk`, a `key` that is not a function, a `projection` other than
475
- * `"all"` / `"withoutMetadata"` / `"keys"`, or a member an index entry does
476
- * not declare (such as a misspelled `projection`, also a type error).
577
+ * `"gsi"`, a `gsi` without `pk`, an `lsi` with `pk`, `partition` or
578
+ * `snapshot`, an empty `index` / `sk` / `pk`, a `key` / `partition` /
579
+ * `snapshot` that is not a function, a `snapshot` without `partition` or on
580
+ * a stream without a `snapshot` config, a `projection` other than `"all"` /
581
+ * `"withoutMetadata"` / `"keys"`, or a member an index entry does not
582
+ * declare (such as a misspelled `projection`, also a type error).
477
583
  * @throws `[EDD-9064]` when an index attribute is one the stream writes itself
478
584
  * (`pk`, `sk`, `__edd_e__`, `streamId`, `version`, `eventType`, `data`,
479
585
  * `metadata`, `timestamp`, `asOfVersion`, `state`, `commandId`, or the
@@ -578,7 +684,7 @@ export const makeStream = (config) => {
578
684
  },
579
685
  ];
580
686
  // Stream indexes (#140) — validated at definition time (EDD-9063..9065).
581
- const streamIndexes = resolveStreamIndexes(config.streamName, config.indexes);
687
+ const streamIndexes = resolveStreamIndexes(config.streamName, config.indexes, snapshot !== undefined);
582
688
  const indexSettings = {};
583
689
  for (const idx of streamIndexes) {
584
690
  indexSettings[idx.name] = {
@@ -587,8 +693,12 @@ export const makeStream = (config) => {
587
693
  pk: idx.pk,
588
694
  sk: idx.sk,
589
695
  projection: idx.projection,
696
+ partition: idx.partition,
697
+ snapshot: idx.snapshot,
590
698
  };
591
699
  }
700
+ /** The indexes that hold the stream's snapshot (#149). */
701
+ const snapshotIndexes = streamIndexes.filter((idx) => idx.snapshotEntry !== undefined);
592
702
  /** Every attribute the indexes write on an event item. */
593
703
  const indexAttributes = streamIndexes.flatMap((idx) => idx.type === "gsi" ? [idx.pk, idx.sk] : [idx.sk]);
594
704
  // Build union schema from event schemas for decoding
@@ -719,34 +829,61 @@ export const makeStream = (config) => {
719
829
  // append
720
830
  // ---------------------------------------------------------------------------
721
831
  /**
722
- * An event's key in a stream index (#140): a string, or `undefined` to
723
- * leave the event out. Another type, or a `key` that throws, is a
724
- * `ValidationError`, raised before anything is written. DynamoDB's own
725
- * rules on a key — not empty, at most 1024 bytes for a sort key — are not
726
- * pre-checked: DynamoDB rejects the append and the rejection is diagnosed,
727
- * naming the index and event (see `sendAppendTransaction`).
832
+ * One value an index function derives for an item (#140, #149): an event's
833
+ * `key` or `partition`, a string or `undefined` to leave the item out.
834
+ * Another type, or a function that throws, is a `ValidationError`, raised
835
+ * before anything is written. DynamoDB's own rules on a key — not empty, at
836
+ * most 1024 bytes for a sort key and 2048 for a partition key — are not
837
+ * pre-checked: DynamoDB rejects the write and the rejection is diagnosed,
838
+ * naming the index and the item (see `sendAppendTransaction`).
728
839
  */
729
- const deriveIndexKey = (idx, event, version) => Effect.try({
730
- try: () => idx.key(event, version),
840
+ const deriveIndexValue = (idx, what, subject, derive) => Effect.try({
841
+ try: derive,
731
842
  catch: (cause) => new ValidationError({ entityType, operation: "EventStore.append.indexes", cause }),
732
843
  }).pipe(Effect.flatMap((derived) => {
733
844
  if (derived === undefined)
734
845
  return Effect.succeed(undefined);
735
- const refuse = (problem) => Effect.fail(new ValidationError({
736
- entityType,
737
- operation: "EventStore.append.indexes",
738
- cause: `The key of index "${idx.name}" for the event at version ${version} ${problem}. ` +
739
- "Nothing was written.",
740
- }));
741
846
  // A string, or the event is not indexed: what the stream's own key
742
847
  // contract means, which DynamoDB cannot see (a number would be sent
743
848
  // as `N`). An empty string is DynamoDB's to reject, and is diagnosed
744
849
  // from its rejection, naming the index and the event (#144).
745
850
  if (typeof derived !== "string") {
746
- return refuse(`must be a string or undefined; received ${String(derived)}`);
851
+ return Effect.fail(new ValidationError({
852
+ entityType,
853
+ operation: "EventStore.append.indexes",
854
+ cause: `The ${what} of index "${idx.name}" for ${subject} must be a string or ` +
855
+ `undefined; received ${String(derived)}. Nothing was written.`,
856
+ }));
747
857
  }
748
858
  return Effect.succeed(derived);
749
859
  }));
860
+ /**
861
+ * The snapshot's entry in a stream index (#149): its partition and key, or
862
+ * `undefined` to leave it out. Anything but an object of two strings, or an
863
+ * entry function that throws, is a `ValidationError` before anything is
864
+ * written; DynamoDB's rules on the values are its own, as for events.
865
+ */
866
+ const deriveSnapshotEntry = (idx, entry, state, streamId, asOfVersion, operation) => Effect.try({
867
+ try: () => entry(state, asOfVersion, streamId),
868
+ catch: (cause) => new ValidationError({ entityType: snapshotEntityType, operation, cause }),
869
+ }).pipe(Effect.flatMap((derived) => {
870
+ if (derived === undefined)
871
+ return Effect.succeed(undefined);
872
+ const { partition, key } = typeof derived === "object" && derived !== null
873
+ ? derived
874
+ : { partition: undefined, key: undefined };
875
+ if (typeof partition !== "string" || typeof key !== "string") {
876
+ return Effect.fail(new ValidationError({
877
+ entityType: snapshotEntityType,
878
+ operation,
879
+ cause: `The snapshot entry of index "${idx.name}" at version ${asOfVersion} must be ` +
880
+ "{ partition: string, key: string } or undefined; received " +
881
+ `${derived !== null && typeof derived === "object" ? JSON.stringify(derived) : String(derived)}. ` +
882
+ "Nothing was written.",
883
+ }));
884
+ }
885
+ return Effect.succeed({ partition, key });
886
+ }));
750
887
  /**
751
888
  * Encode snapshot state to wire form through the state schema, with the
752
889
  * same `decode → encode` fallback as events and metadata. A structurally
@@ -852,48 +989,80 @@ export const makeStream = (config) => {
852
989
  };
853
990
  /**
854
991
  * A diagnosed rejection of an append (#144) other than its item count. An
855
- * over-long (or empty) stream-index sort key OF AN EVENT — the value the
856
- * index's `key` function returned — is named by its index and event, as
857
- * the `indexes` option reports its other key problems. Only the event puts
858
- * carry stream-index keys: an additional item writing the same physical
859
- * attribute (single-table designs share `gsi1sk` and the like) is its own
860
- * entity's key, and is named as any other item.
992
+ * over-long (or empty) stream-index key OF AN EVENT OR THE INLINE SNAPSHOT
993
+ * — a value an index function returned — is named by its index and item,
994
+ * as the `indexes` option reports its other key problems (see
995
+ * {@link indexKeyError}). Only the stream's own items carry stream-index
996
+ * keys: an additional item writing the same physical attribute
997
+ * (single-table designs share `gsi1sk` and the like) is its own entity's
998
+ * key, and is named as any other item.
861
999
  */
862
1000
  const appendLimitError = (diagnosis, original, tx) => {
863
- if ((diagnosis._tag === "KeyTooLong" && diagnosis.kind === "sort") ||
864
- diagnosis._tag === "EmptyKey") {
1001
+ if (diagnosis._tag === "KeyTooLong" || diagnosis._tag === "EmptyKey") {
865
1002
  const position = tx.targets().indexOf(diagnosis.target);
866
1003
  const isEvent = position >= tx.guardCount && position < tx.guardCount + tx.eventCount;
867
- const idx = isEvent
868
- ? streamIndexes.find((candidate) => candidate.sk === diagnosis.attribute)
1004
+ const isSnapshot = position >= 0 && position === tx.snapshotIndex;
1005
+ const named = isEvent || isSnapshot
1006
+ ? indexKeyError(diagnosis, original, isSnapshot, "the append", "EventStore.append.indexes")
869
1007
  : undefined;
870
- if (idx !== undefined) {
871
- return new ValidationError({
872
- entityType,
873
- operation: "EventStore.append.indexes",
874
- cause: withCause(`The key of index "${idx.name}" for ${diagnosis.target.source} ` +
875
- (diagnosis._tag === "KeyTooLong"
876
- ? `is ${diagnosis.bytes} bytes, longer than DynamoDB's 1024-byte limit for a ` +
877
- "sort key."
878
- : "is an empty string, which DynamoDB does not accept as a key value; the " +
879
- "key function must return a non-empty string, or undefined to leave the " +
880
- "event out of the index.") +
881
- " DynamoDB rejected the append. Nothing was written.", original),
882
- });
883
- }
1008
+ if (named !== undefined)
1009
+ return named;
884
1010
  }
885
1011
  return diagnosedValidationError(diagnosis, original, {
886
1012
  operation: "EventStore.append",
887
1013
  entityType,
888
1014
  });
889
1015
  };
1016
+ /**
1017
+ * An over-long or empty key value in a stream-index attribute of an event
1018
+ * or snapshot item, named by its index and item, or `undefined` when the
1019
+ * attribute is not one the item's indexes derive. An event carries each
1020
+ * index's `key` (and a derived `partition`); a snapshot, each snapshot
1021
+ * index's entry. A stream-scoped GSI's partition is the stream's own key,
1022
+ * which no function returned: it is named as any key.
1023
+ */
1024
+ const indexKeyError = (diagnosis, original, isSnapshot, request, operation) => {
1025
+ const candidates = isSnapshot ? snapshotIndexes : streamIndexes;
1026
+ const kind = diagnosis._tag === "KeyTooLong"
1027
+ ? diagnosis.kind
1028
+ : candidates.some((idx) => idx.sk === diagnosis.attribute)
1029
+ ? "sort"
1030
+ : "partition";
1031
+ const idx = candidates.find((candidate) => kind === "sort"
1032
+ ? candidate.sk === diagnosis.attribute
1033
+ : candidate.partition === "derived" && candidate.pk === diagnosis.attribute);
1034
+ if (idx === undefined)
1035
+ return undefined;
1036
+ const value = isSnapshot
1037
+ ? `The snapshot entry's ${kind === "sort" ? "key" : "partition"}`
1038
+ : `The ${kind === "sort" ? "key" : "partition"}`;
1039
+ const leaveOut = isSnapshot
1040
+ ? "the snapshot function must return non-empty strings, or undefined to leave the " +
1041
+ "snapshot out of the index."
1042
+ : `the ${kind === "sort" ? "key" : "partition"} function must return a non-empty ` +
1043
+ "string, or undefined to leave the event out of the index.";
1044
+ return new ValidationError({
1045
+ entityType: isSnapshot ? snapshotEntityType : entityType,
1046
+ operation,
1047
+ cause: withCause(`${value} of index "${idx.name}" for ${diagnosis.target.source} ` +
1048
+ (diagnosis._tag === "KeyTooLong"
1049
+ ? `is ${diagnosis.bytes} bytes, longer than DynamoDB's ` +
1050
+ `${kind === "sort" ? "1024-byte limit for a sort" : "2048-byte limit for a partition"} key.`
1051
+ : `is an empty string, which DynamoDB does not accept as a key value; ${leaveOut}`) +
1052
+ ` DynamoDB rejected ${request}. Nothing was written.`, original),
1053
+ });
1054
+ };
890
1055
  /**
891
1056
  * The snapshot item for `state` at `asOfVersion`: the state encoded through
892
1057
  * 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`.
1058
+ * size measured afterwards is stored bytes — with its entry in each index
1059
+ * that holds snapshots (#149). Shared by `writeSnapshot`, the inline
1060
+ * snapshot of `append`, and `measureSnapshot` / `measureAppend`.
1061
+ *
1062
+ * `eventPartitions` — the partition the append's events have in each
1063
+ * index, by index name — is what the inline snapshot's entry must match.
895
1064
  */
896
- const buildSnapshotItem = (pk, streamIdStr, state, asOfVersion, now, operation) => Effect.gen(function* () {
1065
+ const buildSnapshotItem = (streamId, state, asOfVersion, now, operation, eventPartitions) => Effect.gen(function* () {
897
1066
  const encoded = yield* encodeSnapshotState(state, operation);
898
1067
  const stored = stateStoredAs === undefined
899
1068
  ? encoded
@@ -902,15 +1071,36 @@ export const makeStream = (config) => {
902
1071
  attribute: "state",
903
1072
  operation: `${operation}.packed`,
904
1073
  });
905
- return toAttributeMap({
906
- pk,
1074
+ const item = {
1075
+ pk: composeStreamPk(streamId),
907
1076
  sk: snapshotSk,
908
1077
  __edd_e__: snapshotEntityType,
909
- streamId: streamIdStr,
1078
+ streamId: composeStreamIdString(streamId),
910
1079
  asOfVersion,
911
1080
  state: stored,
912
1081
  timestamp: now,
913
- });
1082
+ };
1083
+ // The snapshot's index entries (#149), stored raw on the item itself,
1084
+ // so they are written by the same `Put` and count towards its size.
1085
+ for (const idx of snapshotIndexes) {
1086
+ const entry = yield* deriveSnapshotEntry(idx, idx.snapshotEntry, state, streamId, asOfVersion, operation);
1087
+ if (entry === undefined)
1088
+ continue;
1089
+ const events = eventPartitions?.get(idx.name);
1090
+ if (events !== undefined && events.partition !== entry.partition) {
1091
+ return yield* new ValidationError({
1092
+ entityType: snapshotEntityType,
1093
+ operation,
1094
+ cause: `The inline snapshot's partition in index "${idx.name}" is ` +
1095
+ `${JSON.stringify(entry.partition)}, but ${events.subject} has ` +
1096
+ `${JSON.stringify(events.partition)}: an append's events and its inline snapshot ` +
1097
+ "must be in the same partition of an index. Nothing was written.",
1098
+ });
1099
+ }
1100
+ item[idx.pk] = entry.partition;
1101
+ item[idx.sk] = entry.key;
1102
+ }
1103
+ return toAttributeMap(item);
914
1104
  });
915
1105
  /**
916
1106
  * The stream's own items of an append — everything but the caller's
@@ -1021,6 +1211,10 @@ export const makeStream = (config) => {
1021
1211
  else if (options?.metadata !== undefined) {
1022
1212
  encodedMetadata = options.metadata;
1023
1213
  }
1214
+ // The partition the events have in each index that derives one (#149),
1215
+ // by index name, with the first event that has it: the events and the
1216
+ // inline snapshot of one append must agree.
1217
+ const eventPartitions = new Map();
1024
1218
  // Build the event puts — one Put per event, each with attribute_not_exists(pk).
1025
1219
  // Events are encoded to wire form through their schema (codec symmetry
1026
1220
  // with the read path, which decodes through the same schema), so this is
@@ -1079,15 +1273,39 @@ export const makeStream = (config) => {
1079
1273
  item.metadata = itemMetadata;
1080
1274
  }
1081
1275
  // Stream indexes (#140): the derived key, stored raw, and for a
1082
- // GSI the stream's partition key. The attributes are part of the
1083
- // event item itself, so they count towards the size checks.
1276
+ // GSI the stream's partition key — or the derived partition, raw
1277
+ // too (#149). The attributes are part of the event item itself,
1278
+ // so they count towards the size checks. Both functions run for
1279
+ // every event, so each one's contract is checked on every event.
1280
+ const subject = `the event at version ${version}`;
1084
1281
  for (const idx of streamIndexes) {
1085
- const derived = yield* deriveIndexKey(idx, event, version);
1086
- if (derived === undefined)
1282
+ const derived = yield* deriveIndexValue(idx, "key", subject, () => idx.key(event, version, streamId));
1283
+ let partition = pk;
1284
+ if (idx.derivePartition !== undefined) {
1285
+ const derivePartition = idx.derivePartition;
1286
+ partition = yield* deriveIndexValue(idx, "partition", subject, () => derivePartition(event, version, streamId));
1287
+ if (partition !== undefined) {
1288
+ const first = eventPartitions.get(idx.name);
1289
+ if (first === undefined) {
1290
+ eventPartitions.set(idx.name, { partition, subject });
1291
+ }
1292
+ else if (first.partition !== partition) {
1293
+ return yield* new ValidationError({
1294
+ entityType,
1295
+ operation: "EventStore.append.indexes",
1296
+ cause: `The partition of index "${idx.name}" for ${subject} is ` +
1297
+ `${JSON.stringify(partition)}, but ${first.subject} has ` +
1298
+ `${JSON.stringify(first.partition)}: every event of one append must be ` +
1299
+ "in the same partition of an index. Nothing was written.",
1300
+ });
1301
+ }
1302
+ }
1303
+ }
1304
+ if (derived === undefined || partition === undefined)
1087
1305
  continue;
1088
1306
  item[idx.sk] = derived;
1089
1307
  if (idx.type === "gsi")
1090
- item[idx.pk] = pk;
1308
+ item[idx.pk] = partition;
1091
1309
  }
1092
1310
  return {
1093
1311
  Put: {
@@ -1136,7 +1354,7 @@ export const makeStream = (config) => {
1136
1354
  snapshotPut = {
1137
1355
  Put: {
1138
1356
  TableName: tableName,
1139
- Item: yield* buildSnapshotItem(pk, streamIdStr, snapshotState, expectedVersion + events.length, now, "EventStore.append.snapshot"),
1357
+ Item: yield* buildSnapshotItem(streamId, snapshotState, expectedVersion + events.length, now, "EventStore.append.snapshot", eventPartitions),
1140
1358
  },
1141
1359
  };
1142
1360
  }
@@ -1215,6 +1433,7 @@ export const makeStream = (config) => {
1215
1433
  if (sentinelPut !== undefined) {
1216
1434
  pushStreamItem(sentinelPut, entityType, "the idempotency sentinel");
1217
1435
  }
1436
+ const snapshotIndex = snapshotPut === undefined ? -1 : items.length;
1218
1437
  if (snapshotPut !== undefined) {
1219
1438
  pushStreamItem(snapshotPut, snapshotEntityType, "the inline snapshot");
1220
1439
  }
@@ -1227,6 +1446,7 @@ export const makeStream = (config) => {
1227
1446
  eventCount: eventItems.length,
1228
1447
  additional,
1229
1448
  sentinelIndex,
1449
+ snapshotIndex,
1230
1450
  expectedVersion,
1231
1451
  };
1232
1452
  };
@@ -1289,7 +1509,7 @@ export const makeStream = (config) => {
1289
1509
  if (snapshot === undefined)
1290
1510
  return yield* snapshotUnavailable("measureSnapshot");
1291
1511
  const now = DateTime.formatIso(yield* DateTime.now);
1292
- const item = yield* buildSnapshotItem(composeStreamPk(streamId), composeStreamIdString(streamId), state, asOfVersion, now, "EventStore.measureSnapshot");
1512
+ const item = yield* buildSnapshotItem(streamId, state, asOfVersion, now, "EventStore.measureSnapshot");
1293
1513
  return itemBytes(item);
1294
1514
  });
1295
1515
  // ---------------------------------------------------------------------------
@@ -1336,8 +1556,11 @@ export const makeStream = (config) => {
1336
1556
  // Versions are integers, so the inclusive lower bound `afterVersion + 1`
1337
1557
  // is exactly the old exclusive `#sk > eventSk(afterVersion)`. The upper
1338
1558
  // bound keeps the snapshot item (which sorts after every event) out of the
1339
- // scanned range.
1340
- const query = withReadOptions(buildEventsQuery(streamId).pipe(Query.where({ between: [composeEventSk(afterVersion + 1), maxEventSk] })), options);
1559
+ // scanned range; `toVersion` narrows it to the versions wanted.
1560
+ const upper = options?.toVersion === undefined ? maxEventSk : composeEventSk(options.toVersion);
1561
+ if (options?.toVersion !== undefined && options.toVersion <= afterVersion)
1562
+ return [];
1563
+ const query = withReadOptions(buildEventsQuery(streamId).pipe(Query.where({ between: [composeEventSk(afterVersion + 1), upper] })), options);
1341
1564
  return yield* Query.collect(query);
1342
1565
  });
1343
1566
  // ---------------------------------------------------------------------------
@@ -1379,7 +1602,7 @@ export const makeStream = (config) => {
1379
1602
  const now = DateTime.formatIso(yield* DateTime.now);
1380
1603
  // Packed per `snapshot.storedAs` (#147): the diagnosis below measures
1381
1604
  // the stored bytes.
1382
- const item = yield* buildSnapshotItem(composeStreamPk(streamId), composeStreamIdString(streamId), state, asOfVersion, now, "EventStore.writeSnapshot");
1605
+ const item = yield* buildSnapshotItem(streamId, state, asOfVersion, now, "EventStore.writeSnapshot");
1383
1606
  const put = {
1384
1607
  TableName: tableName,
1385
1608
  Item: item,
@@ -1397,11 +1620,19 @@ export const makeStream = (config) => {
1397
1620
  item: { Put: put },
1398
1621
  target: () => transactItemTarget({ Put: put }, tableName, ["pk", "sk"], snapshotEntityType, "the snapshot"),
1399
1622
  }),
1400
- keys: () => ({ partition: ["pk"], sort: ["sk"] }),
1401
- rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, {
1402
- operation: "EventStore.writeSnapshot",
1403
- entityType: snapshotEntityType,
1623
+ keys: () => ({
1624
+ partition: ["pk", ...snapshotIndexes.map((idx) => idx.pk)],
1625
+ sort: ["sk", ...snapshotIndexes.map((idx) => idx.sk)],
1404
1626
  }),
1627
+ // A snapshot index entry's empty or over-long value (#149) is named
1628
+ // by its index, as an inline snapshot's is.
1629
+ rewrite: (diagnosis, original) => (diagnosis._tag === "KeyTooLong" || diagnosis._tag === "EmptyKey"
1630
+ ? indexKeyError(diagnosis, original, true, "the snapshot", "EventStore.writeSnapshot")
1631
+ : undefined) ??
1632
+ diagnosedValidationError(diagnosis, original, {
1633
+ operation: "EventStore.writeSnapshot",
1634
+ entityType: snapshotEntityType,
1635
+ }),
1405
1636
  }));
1406
1637
  });
1407
1638
  /**
@@ -1591,15 +1822,44 @@ export const makeStream = (config) => {
1591
1822
  // Stream indexes — query.index / readIndex (#140)
1592
1823
  // ---------------------------------------------------------------------------
1593
1824
  /**
1594
- * A query of one stream index, scoped to the stream's partition: the LSI
1595
- * shares the table's `pk`; a GSI's `pk` attribute carries the same value.
1596
- * No sort-key condition — the index holds only this stream's indexed events
1597
- * under that key; `Query.where` narrows the derived-key range.
1825
+ * @internal Why `partition` cannot be passed for index `name`, or
1826
+ * `undefined` when it can: required (a string) for an index that derives
1827
+ * its partition (#149), refused for any other. Unreachable through the
1828
+ * types. An unknown name is left to {@link buildIndexQuery}.
1829
+ */
1830
+ const invalidIndexPartition = (name, partition) => {
1831
+ const idx = streamIndexes.find((candidate) => candidate.name === name);
1832
+ if (idx === undefined)
1833
+ return undefined;
1834
+ if (idx.partition === "derived") {
1835
+ return typeof partition === "string"
1836
+ ? undefined
1837
+ : `Index "${name}" derives its partition: pass the partition to read as ` +
1838
+ `options.partition (a string); received ${String(partition)}.`;
1839
+ }
1840
+ return partition === undefined
1841
+ ? undefined
1842
+ : `Index "${name}" is scoped to the stream; it takes no partition.`;
1843
+ };
1844
+ /**
1845
+ * A query of one stream index. A stream-scoped index is queried in the
1846
+ * stream's partition: the LSI shares the table's `pk`; a GSI's `pk`
1847
+ * attribute carries the same value. No sort-key condition — the index holds
1848
+ * only this stream's indexed events under that key; `Query.where` narrows
1849
+ * the derived-key range.
1850
+ *
1851
+ * An index that derives its partition (#149) is queried in `partition`,
1852
+ * which other streams (and this one's other stream ids, and snapshots) may
1853
+ * share: the `__edd_e__` filter keeps this stream's events, and a filter on
1854
+ * the table's `pk` — projected by every index — keeps `streamId`'s.
1598
1855
  *
1599
1856
  * @throws `[EDD-9066]` for a name the stream does not declare (unreachable
1600
1857
  * through the types).
1858
+ * @throws `[EDD-9080]` for a missing `partition` on an index that derives
1859
+ * it, or one passed to an index that does not (unreachable through the
1860
+ * types).
1601
1861
  */
1602
- const buildIndexQuery = (name, streamId) => {
1862
+ const buildIndexQuery = (name, streamId, partition) => {
1603
1863
  const idx = streamIndexes.find((candidate) => candidate.name === name);
1604
1864
  if (idx === undefined) {
1605
1865
  throw new Error(`[EDD-9066] EventStream "${config.streamName}": unknown index "${String(name)}". ` +
@@ -1607,11 +1867,16 @@ export const makeStream = (config) => {
1607
1867
  ? "The stream declares no indexes."
1608
1868
  : `Declared: ${streamIndexes.map((candidate) => `"${candidate.name}"`).join(", ")}.`));
1609
1869
  }
1610
- return Query.make({
1870
+ const partitionProblem = invalidIndexPartition(name, partition);
1871
+ if (partitionProblem !== undefined) {
1872
+ throw new Error(`[EDD-9080] EventStream "${config.streamName}": ${partitionProblem}`);
1873
+ }
1874
+ const streamPk = composeStreamPk(streamId);
1875
+ const query = Query.make({
1611
1876
  tableName: "",
1612
1877
  indexName: idx.index,
1613
1878
  pkField: idx.pk,
1614
- pkValue: composeStreamPk(streamId),
1879
+ pkValue: idx.partition === "derived" ? partition : streamPk,
1615
1880
  skField: idx.sk,
1616
1881
  entityTypes: [entityType],
1617
1882
  decoder: indexDecoder(idx),
@@ -1639,6 +1904,7 @@ export const makeStream = (config) => {
1639
1904
  }
1640
1905
  : {}),
1641
1906
  });
1907
+ return idx.partition === "derived" ? Query.filterExpr(query, streamPkFilter(streamPk)) : query;
1642
1908
  };
1643
1909
  /**
1644
1910
  * @internal The decoder of events read through `idx`.
@@ -1685,7 +1951,11 @@ export const makeStream = (config) => {
1685
1951
  if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) {
1686
1952
  return yield* refuse(`limit must be a positive integer; received ${String(limit)}. Nothing was sent.`);
1687
1953
  }
1688
- let query = buildIndexQuery(name, streamId);
1954
+ const partitionProblem = invalidIndexPartition(name, options?.partition);
1955
+ if (partitionProblem !== undefined) {
1956
+ return yield* refuse(`${partitionProblem} Nothing was sent.`);
1957
+ }
1958
+ let query = buildIndexQuery(name, streamId, options?.partition);
1689
1959
  if (options?.beginsWith !== undefined) {
1690
1960
  query = Query.where(query, { beginsWith: options.beginsWith });
1691
1961
  }
@@ -1702,7 +1972,7 @@ export const makeStream = (config) => {
1702
1972
  });
1703
1973
  const queryNamespace = {
1704
1974
  events: (streamId) => buildEventsQuery(streamId),
1705
- index: (name, streamId) => buildIndexQuery(name, streamId),
1975
+ index: (name, streamId, options) => buildIndexQuery(name, streamId, options?.partition),
1706
1976
  };
1707
1977
  // ---------------------------------------------------------------------------
1708
1978
  // Return EventStream
@@ -1720,6 +1990,9 @@ export const makeStream = (config) => {
1720
1990
  },
1721
1991
  streamName: config.streamName,
1722
1992
  eventSchema: eventUnion,
1993
+ streamIdFields: compositeFields,
1994
+ eventMarker: entityType,
1995
+ parseStreamId: (id) => parseStreamIdWith(config.streamName, compositeFields, id),
1723
1996
  snapshotConfig: snapshotSettings,
1724
1997
  storedAs: storage,
1725
1998
  indexes: indexSettings,
@@ -1775,6 +2048,9 @@ export const bind = (stream) => Effect.gen(function* () {
1775
2048
  },
1776
2049
  streamName: stream.streamName,
1777
2050
  eventSchema: stream.eventSchema,
2051
+ streamIdFields: stream.streamIdFields,
2052
+ eventMarker: stream.eventMarker,
2053
+ parseStreamId: (id) => stream.parseStreamId(id),
1778
2054
  snapshotConfig: stream.snapshotConfig,
1779
2055
  storedAs: stream.storedAs,
1780
2056
  indexes: stream.indexes,
@@ -1785,7 +2061,7 @@ export const bind = (stream) => Effect.gen(function* () {
1785
2061
  readFrom: (streamId, afterVersion, options) => provide(stream.readFrom(streamId, afterVersion, options)),
1786
2062
  currentVersion: (streamId, options) => provide(stream.currentVersion(streamId, options)),
1787
2063
  readLatest: (streamId, options) => provide(stream.readLatest(streamId, options)),
1788
- readIndex: (name, streamId, options) => provide(stream.readIndex(name, streamId, options)),
2064
+ readIndex: (name, streamId, ...options) => provide(stream.readIndex(name, streamId, ...options)),
1789
2065
  // The captured context carries a `Compression` registry provided where
1790
2066
  // the stream was bound (#147).
1791
2067
  measureAppend: (streamId, events, expectedVersion, options) => provide(stream.measureAppend(streamId, events, expectedVersion, options)),
@@ -1810,12 +2086,22 @@ export const bind = (stream) => Effect.gen(function* () {
1810
2086
  * Neither list names a key attribute: table and index keys are always
1811
2087
  * projected.
1812
2088
  */
1813
- const projectionDefinition = (projection) => projection === "all"
2089
+ const projectionDefinition = (projection, snapshot) => projection === "all"
1814
2090
  ? { ProjectionType: "ALL" }
1815
2091
  : {
1816
2092
  ProjectionType: "INCLUDE",
1817
- NonKeyAttributes: projection === "keys" ? ["__edd_e__"] : [...EVENT_ITEM_ATTRIBUTES],
2093
+ NonKeyAttributes: projection === "keys"
2094
+ ? ["__edd_e__"]
2095
+ : snapshot
2096
+ ? [...EVENT_ITEM_ATTRIBUTES, ...SNAPSHOT_ONLY_ATTRIBUTES].sort()
2097
+ : [...EVENT_ITEM_ATTRIBUTES],
1818
2098
  };
2099
+ /**
2100
+ * @internal The snapshot item's attributes an event item does not carry: what
2101
+ * a `"withoutMetadata"` index that holds snapshots (#149) projects besides
2102
+ * {@link EVENT_ITEM_ATTRIBUTES}.
2103
+ */
2104
+ const SNAPSHOT_ONLY_ATTRIBUTES = ["asOfVersion", "state"];
1819
2105
  /**
1820
2106
  * Derive the `CreateTable` fragments for the stream indexes (#140) of the given
1821
2107
  * streams (`EventStream` or `BoundEventStream`) — `AttributeDefinitions`,
@@ -1830,7 +2116,7 @@ const projectionDefinition = (projection) => projection === "all"
1830
2116
  * | `projection` | `Projection` |
1831
2117
  * |---|---|
1832
2118
  * | `"all"` (default) | `{ ProjectionType: "ALL" }` |
1833
- * | `"withoutMetadata"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__", "data", "eventType", "streamId", "timestamp", "version"] }` |
2119
+ * | `"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) |
1834
2120
  * | `"keys"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__"] }` — not `KEYS_ONLY`: every read filters on `__edd_e__` |
1835
2121
  *
1836
2122
  * The deployed index must project what is declared — copy `Projection` from
@@ -1841,7 +2127,12 @@ const projectionDefinition = (projection) => projection === "all"
1841
2127
  * table over the limit).
1842
2128
  *
1843
2129
  * An index shared by several streams with the same definition is emitted
1844
- * once. Lists are sorted by index name, attributes by attribute name.
2130
+ * once — the way several streams write one GSI whose partition they derive
2131
+ * (`partition`, #149): an order's line, payment and shipment streams under
2132
+ * `ORDER#<orderId>`, say. Whether each declaration derives its partition or
2133
+ * indexes its snapshot is not part of the definition: the index's key schema
2134
+ * and projection kind are. Lists are sorted by index name, attributes by
2135
+ * attribute name.
1845
2136
  *
1846
2137
  * - An LSI can only be created with the table, and caps the item collection
1847
2138
  * of every partition key value in the table at 10 GB — entity partitions
@@ -1869,25 +2160,32 @@ const projectionDefinition = (projection) => projection === "all"
1869
2160
  *
1870
2161
  * @throws `[EDD-9067]` when two streams define the same physical index
1871
2162
  * differently (another type, other key attributes, or another projection).
2163
+ * Whether a declaration derives its partition or indexes its snapshot is not
2164
+ * part of the index's definition: streams that differ only there share it.
1872
2165
  */
1873
2166
  export const indexDefinitions = (...streams) => {
2167
+ /** Per physical index: its definition, its first declarer, and whether any declarer indexes snapshots. */
1874
2168
  const byIndex = new Map();
1875
2169
  for (const stream of streams) {
1876
2170
  for (const [name, raw] of Object.entries(stream.indexes)) {
1877
- const settings = { ...raw, projection: raw.projection ?? "all" };
2171
+ const settings = {
2172
+ type: raw.type,
2173
+ index: raw.index,
2174
+ pk: raw.pk,
2175
+ sk: raw.sk,
2176
+ projection: raw.projection ?? "all",
2177
+ };
2178
+ const snapshot = raw.snapshot === true;
1878
2179
  const owner = `index "${name}" of stream "${stream.streamName}"`;
1879
2180
  const existing = byIndex.get(settings.index);
1880
2181
  if (existing === undefined) {
1881
- byIndex.set(settings.index, {
1882
- type: settings.type,
1883
- index: settings.index,
1884
- pk: settings.pk,
1885
- sk: settings.sk,
1886
- projection: settings.projection,
1887
- owner,
1888
- });
2182
+ byIndex.set(settings.index, { ...settings, owner, snapshot });
1889
2183
  continue;
1890
2184
  }
2185
+ // Several streams writing one GSI they derive the partition of (#149)
2186
+ // is the point: one declaration indexing snapshots and another not
2187
+ // still share it, and the projection covers the snapshots.
2188
+ existing.snapshot ||= snapshot;
1891
2189
  if (existing.type !== settings.type ||
1892
2190
  existing.pk !== settings.pk ||
1893
2191
  existing.sk !== settings.sk ||
@@ -1910,7 +2208,7 @@ export const indexDefinitions = (...streams) => {
1910
2208
  { AttributeName: settings.pk, KeyType: "HASH" },
1911
2209
  { AttributeName: settings.sk, KeyType: "RANGE" },
1912
2210
  ],
1913
- Projection: projectionDefinition(settings.projection),
2211
+ Projection: projectionDefinition(settings.projection, settings.snapshot),
1914
2212
  };
1915
2213
  attributes.add(settings.sk);
1916
2214
  if (settings.type === "gsi") {
@@ -2450,4 +2748,46 @@ export const foldFrom = Function.dual(3, (decider, startState, events) => {
2450
2748
  }
2451
2749
  return state;
2452
2750
  });
2751
+ // ---------------------------------------------------------------------------
2752
+ // Change-feed projections (#151)
2753
+ // ---------------------------------------------------------------------------
2754
+ export { batchResponse, decoder, eventFilterPattern, processBatch, projection, } from "./internal/EventProjection.js";
2755
+ /**
2756
+ * Bind a projection to the services its operations need — `DynamoClient`,
2757
+ * the `TableConfig`s of the stream's table and the read model's, `evolve`'s
2758
+ * requirements, and a `Compression` registry for packed event data — so every
2759
+ * operation has `R = never`. Like {@link bind}, it checks the registry holds
2760
+ * the codecs the stream's `storedAs` names (`[EDD-9078]`).
2761
+ *
2762
+ * @example
2763
+ * ```ts
2764
+ * const summaries = yield* EventStore.bindProjection(OrderSummaryProjection)
2765
+ * export const handler = (event: { Records: Array<EventStore.StreamRecord> }) =>
2766
+ * summaries.processBatch(event.Records, { source: "lambda" }).pipe(
2767
+ * Effect.map((result) => result.response),
2768
+ * Effect.runPromise,
2769
+ * )
2770
+ * ```
2771
+ */
2772
+ export const bindProjection = (projection) => Effect.gen(function* () {
2773
+ const ctx = yield* Effect.context();
2774
+ // The codec check `bind` makes, against the registry captured here.
2775
+ yield* Effect.provide(bind(projection.stream), ctx);
2776
+ const provide = (effect) => Effect.provide(effect, ctx);
2777
+ return {
2778
+ ...projection,
2779
+ pipe() {
2780
+ // eslint-disable-next-line prefer-rest-params
2781
+ return Pipeable.pipeArguments(this, arguments);
2782
+ },
2783
+ applyBatch: (events, options) => provide(projection.applyBatch(events, options)),
2784
+ apply: (event) => provide(projection.apply(event)),
2785
+ catchUp: (streamId) => provide(projection.catchUp(streamId)),
2786
+ // A multi-projection batch decodes through this, under the captured context.
2787
+ [ProjectionDecode]: (image) => provide(projection[ProjectionDecode](image)),
2788
+ rebuild: (streamId) => provide(projection.rebuild(streamId)),
2789
+ // Both overloads share one implementation.
2790
+ processBatch: ((records, options) => provide(projection.processBatch(records, options))),
2791
+ };
2792
+ });
2453
2793
  //# sourceMappingURL=EventStore.js.map