effect-dynamodb 1.26.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.
@@ -15,7 +15,9 @@
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 —
@@ -35,8 +37,10 @@ import { isDispatchEligible, packedShape, packValue, unpackStoredSync, } from "@
35
37
  import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
36
38
  import { Context, DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
37
39
  import { DynamoClient } from "./DynamoClient.js";
40
+ import { eq } from "./internal/Expr.js";
38
41
  import { itemBytes, transactItemBytes } from "./internal/ItemSize.js";
39
42
  import { codecRequirements, currentRegistry, decodeImage, imageEntityType, runtimeCodecCheck, unregisteredCodecError, withRegistry, } from "./internal/Packed.js";
43
+ import { createPathBuilder } from "./internal/PathBuilder.js";
40
44
  import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, judgeCancellation, transactItemTarget, } from "./internal/TransactWriteOps.js";
41
45
  import { diagnosedValidationError, diagnoseFailure, mergeKeyAttributes, withCause, } from "./internal/WriteDiagnosis.js";
42
46
  import { fromAttributeMap, toAttributeMap } from "./Marshaller.js";
@@ -145,21 +149,31 @@ const STREAM_INDEX_CONFIG_KEYS = [
145
149
  "pk",
146
150
  "sk",
147
151
  "key",
152
+ "partition",
153
+ "snapshot",
148
154
  "projection",
149
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);
150
162
  /**
151
163
  * @internal Validate `makeStream({ indexes })` at definition time.
152
164
  *
153
165
  * @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"`).
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"`).
157
171
  * @throws `[EDD-9064]` for an index attribute that collides with a
158
172
  * stream-owned attribute.
159
173
  * @throws `[EDD-9065]` for two indexes sharing a physical index name or an
160
174
  * attribute.
161
175
  */
162
- const resolveStreamIndexes = (streamName, indexes) => {
176
+ const resolveStreamIndexes = (streamName, indexes, hasSnapshotConfig) => {
163
177
  if (indexes === undefined)
164
178
  return [];
165
179
  const fail = (code, message) => {
@@ -196,7 +210,30 @@ const resolveStreamIndexes = (streamName, indexes) => {
196
210
  'declare type: "gsi".');
197
211
  }
198
212
  if (typeof entry.key !== "function") {
199
- 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
+ }
200
237
  }
201
238
  const projection = (entry.projection ?? "all");
202
239
  if (projection !== "all" && projection !== "withoutMetadata" && projection !== "keys") {
@@ -233,7 +270,11 @@ const resolveStreamIndexes = (streamName, indexes) => {
233
270
  pk,
234
271
  sk,
235
272
  projection,
273
+ partition: entry.partition === undefined ? "stream" : "derived",
274
+ snapshot: entry.snapshot !== undefined,
236
275
  key: entry.key,
276
+ derivePartition: entry.partition,
277
+ snapshotEntry: entry.snapshot,
237
278
  });
238
279
  }
239
280
  return resolved;
@@ -430,11 +471,20 @@ const eventTimestampsProblem = (timestamps, count, label, events) => {
430
471
  * `indexes` declares sub-streams ordered by a key derived from each event
431
472
  * (#140), on a local (default) or global secondary index. `append` writes each
432
473
  * 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}.
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}.
438
488
  *
439
489
  * Index attributes are written only at append time and events are never
440
490
  * rewritten. Declaring an index on a stream that already holds events, or
@@ -446,6 +496,36 @@ const eventTimestampsProblem = (timestamps, count, label, events) => {
446
496
  *
447
497
  * @example
448
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
449
529
  * const Entries = EventStore.makeStream({
450
530
  * table: EventsTable,
451
531
  * streamName: "Ledger",
@@ -470,10 +550,12 @@ const eventTimestampsProblem = (timestamps, count, label, events) => {
470
550
  * @throws `[EDD-9062]` when `snapshot.mode` is neither `"after-append"` nor
471
551
  * `"inline"`.
472
552
  * @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).
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).
477
559
  * @throws `[EDD-9064]` when an index attribute is one the stream writes itself
478
560
  * (`pk`, `sk`, `__edd_e__`, `streamId`, `version`, `eventType`, `data`,
479
561
  * `metadata`, `timestamp`, `asOfVersion`, `state`, `commandId`, or the
@@ -578,7 +660,7 @@ export const makeStream = (config) => {
578
660
  },
579
661
  ];
580
662
  // Stream indexes (#140) — validated at definition time (EDD-9063..9065).
581
- const streamIndexes = resolveStreamIndexes(config.streamName, config.indexes);
663
+ const streamIndexes = resolveStreamIndexes(config.streamName, config.indexes, snapshot !== undefined);
582
664
  const indexSettings = {};
583
665
  for (const idx of streamIndexes) {
584
666
  indexSettings[idx.name] = {
@@ -587,8 +669,12 @@ export const makeStream = (config) => {
587
669
  pk: idx.pk,
588
670
  sk: idx.sk,
589
671
  projection: idx.projection,
672
+ partition: idx.partition,
673
+ snapshot: idx.snapshot,
590
674
  };
591
675
  }
676
+ /** The indexes that hold the stream's snapshot (#149). */
677
+ const snapshotIndexes = streamIndexes.filter((idx) => idx.snapshotEntry !== undefined);
592
678
  /** Every attribute the indexes write on an event item. */
593
679
  const indexAttributes = streamIndexes.flatMap((idx) => idx.type === "gsi" ? [idx.pk, idx.sk] : [idx.sk]);
594
680
  // Build union schema from event schemas for decoding
@@ -719,34 +805,61 @@ export const makeStream = (config) => {
719
805
  // append
720
806
  // ---------------------------------------------------------------------------
721
807
  /**
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`).
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`).
728
815
  */
729
- const deriveIndexKey = (idx, event, version) => Effect.try({
730
- try: () => idx.key(event, version),
816
+ const deriveIndexValue = (idx, what, subject, derive) => Effect.try({
817
+ try: derive,
731
818
  catch: (cause) => new ValidationError({ entityType, operation: "EventStore.append.indexes", cause }),
732
819
  }).pipe(Effect.flatMap((derived) => {
733
820
  if (derived === undefined)
734
821
  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
822
  // A string, or the event is not indexed: what the stream's own key
742
823
  // contract means, which DynamoDB cannot see (a number would be sent
743
824
  // as `N`). An empty string is DynamoDB's to reject, and is diagnosed
744
825
  // from its rejection, naming the index and the event (#144).
745
826
  if (typeof derived !== "string") {
746
- 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
+ }));
747
833
  }
748
834
  return Effect.succeed(derived);
749
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
+ }));
750
863
  /**
751
864
  * Encode snapshot state to wire form through the state schema, with the
752
865
  * same `decode → encode` fallback as events and metadata. A structurally
@@ -852,48 +965,80 @@ export const makeStream = (config) => {
852
965
  };
853
966
  /**
854
967
  * 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.
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.
861
975
  */
862
976
  const appendLimitError = (diagnosis, original, tx) => {
863
- if ((diagnosis._tag === "KeyTooLong" && diagnosis.kind === "sort") ||
864
- diagnosis._tag === "EmptyKey") {
977
+ if (diagnosis._tag === "KeyTooLong" || diagnosis._tag === "EmptyKey") {
865
978
  const position = tx.targets().indexOf(diagnosis.target);
866
979
  const isEvent = position >= tx.guardCount && position < tx.guardCount + tx.eventCount;
867
- const idx = isEvent
868
- ? 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")
869
983
  : 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
- }
984
+ if (named !== undefined)
985
+ return named;
884
986
  }
885
987
  return diagnosedValidationError(diagnosis, original, {
886
988
  operation: "EventStore.append",
887
989
  entityType,
888
990
  });
889
991
  };
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
+ };
890
1031
  /**
891
1032
  * The snapshot item for `state` at `asOfVersion`: the state encoded through
892
1033
  * 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`.
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.
895
1040
  */
896
- const buildSnapshotItem = (pk, streamIdStr, state, asOfVersion, now, operation) => Effect.gen(function* () {
1041
+ const buildSnapshotItem = (streamId, state, asOfVersion, now, operation, eventPartitions) => Effect.gen(function* () {
897
1042
  const encoded = yield* encodeSnapshotState(state, operation);
898
1043
  const stored = stateStoredAs === undefined
899
1044
  ? encoded
@@ -902,15 +1047,36 @@ export const makeStream = (config) => {
902
1047
  attribute: "state",
903
1048
  operation: `${operation}.packed`,
904
1049
  });
905
- return toAttributeMap({
906
- pk,
1050
+ const item = {
1051
+ pk: composeStreamPk(streamId),
907
1052
  sk: snapshotSk,
908
1053
  __edd_e__: snapshotEntityType,
909
- streamId: streamIdStr,
1054
+ streamId: composeStreamIdString(streamId),
910
1055
  asOfVersion,
911
1056
  state: stored,
912
1057
  timestamp: now,
913
- });
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);
914
1080
  });
915
1081
  /**
916
1082
  * The stream's own items of an append — everything but the caller's
@@ -1021,6 +1187,10 @@ export const makeStream = (config) => {
1021
1187
  else if (options?.metadata !== undefined) {
1022
1188
  encodedMetadata = options.metadata;
1023
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();
1024
1194
  // Build the event puts — one Put per event, each with attribute_not_exists(pk).
1025
1195
  // Events are encoded to wire form through their schema (codec symmetry
1026
1196
  // with the read path, which decodes through the same schema), so this is
@@ -1079,15 +1249,39 @@ export const makeStream = (config) => {
1079
1249
  item.metadata = itemMetadata;
1080
1250
  }
1081
1251
  // 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.
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}`;
1084
1257
  for (const idx of streamIndexes) {
1085
- const derived = yield* deriveIndexKey(idx, event, version);
1086
- 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)
1087
1281
  continue;
1088
1282
  item[idx.sk] = derived;
1089
1283
  if (idx.type === "gsi")
1090
- item[idx.pk] = pk;
1284
+ item[idx.pk] = partition;
1091
1285
  }
1092
1286
  return {
1093
1287
  Put: {
@@ -1136,7 +1330,7 @@ export const makeStream = (config) => {
1136
1330
  snapshotPut = {
1137
1331
  Put: {
1138
1332
  TableName: tableName,
1139
- Item: yield* buildSnapshotItem(pk, streamIdStr, snapshotState, expectedVersion + events.length, now, "EventStore.append.snapshot"),
1333
+ Item: yield* buildSnapshotItem(streamId, snapshotState, expectedVersion + events.length, now, "EventStore.append.snapshot", eventPartitions),
1140
1334
  },
1141
1335
  };
1142
1336
  }
@@ -1215,6 +1409,7 @@ export const makeStream = (config) => {
1215
1409
  if (sentinelPut !== undefined) {
1216
1410
  pushStreamItem(sentinelPut, entityType, "the idempotency sentinel");
1217
1411
  }
1412
+ const snapshotIndex = snapshotPut === undefined ? -1 : items.length;
1218
1413
  if (snapshotPut !== undefined) {
1219
1414
  pushStreamItem(snapshotPut, snapshotEntityType, "the inline snapshot");
1220
1415
  }
@@ -1227,6 +1422,7 @@ export const makeStream = (config) => {
1227
1422
  eventCount: eventItems.length,
1228
1423
  additional,
1229
1424
  sentinelIndex,
1425
+ snapshotIndex,
1230
1426
  expectedVersion,
1231
1427
  };
1232
1428
  };
@@ -1289,7 +1485,7 @@ export const makeStream = (config) => {
1289
1485
  if (snapshot === undefined)
1290
1486
  return yield* snapshotUnavailable("measureSnapshot");
1291
1487
  const now = DateTime.formatIso(yield* DateTime.now);
1292
- const item = yield* buildSnapshotItem(composeStreamPk(streamId), composeStreamIdString(streamId), state, asOfVersion, now, "EventStore.measureSnapshot");
1488
+ const item = yield* buildSnapshotItem(streamId, state, asOfVersion, now, "EventStore.measureSnapshot");
1293
1489
  return itemBytes(item);
1294
1490
  });
1295
1491
  // ---------------------------------------------------------------------------
@@ -1379,7 +1575,7 @@ export const makeStream = (config) => {
1379
1575
  const now = DateTime.formatIso(yield* DateTime.now);
1380
1576
  // Packed per `snapshot.storedAs` (#147): the diagnosis below measures
1381
1577
  // the stored bytes.
1382
- const item = yield* buildSnapshotItem(composeStreamPk(streamId), composeStreamIdString(streamId), state, asOfVersion, now, "EventStore.writeSnapshot");
1578
+ const item = yield* buildSnapshotItem(streamId, state, asOfVersion, now, "EventStore.writeSnapshot");
1383
1579
  const put = {
1384
1580
  TableName: tableName,
1385
1581
  Item: item,
@@ -1397,11 +1593,19 @@ export const makeStream = (config) => {
1397
1593
  item: { Put: put },
1398
1594
  target: () => transactItemTarget({ Put: put }, tableName, ["pk", "sk"], snapshotEntityType, "the snapshot"),
1399
1595
  }),
1400
- keys: () => ({ partition: ["pk"], sort: ["sk"] }),
1401
- rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, {
1402
- operation: "EventStore.writeSnapshot",
1403
- entityType: snapshotEntityType,
1596
+ keys: () => ({
1597
+ partition: ["pk", ...snapshotIndexes.map((idx) => idx.pk)],
1598
+ sort: ["sk", ...snapshotIndexes.map((idx) => idx.sk)],
1404
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
+ }),
1405
1609
  }));
1406
1610
  });
1407
1611
  /**
@@ -1591,15 +1795,44 @@ export const makeStream = (config) => {
1591
1795
  // Stream indexes — query.index / readIndex (#140)
1592
1796
  // ---------------------------------------------------------------------------
1593
1797
  /**
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.
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.
1598
1828
  *
1599
1829
  * @throws `[EDD-9066]` for a name the stream does not declare (unreachable
1600
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).
1601
1834
  */
1602
- const buildIndexQuery = (name, streamId) => {
1835
+ const buildIndexQuery = (name, streamId, partition) => {
1603
1836
  const idx = streamIndexes.find((candidate) => candidate.name === name);
1604
1837
  if (idx === undefined) {
1605
1838
  throw new Error(`[EDD-9066] EventStream "${config.streamName}": unknown index "${String(name)}". ` +
@@ -1607,11 +1840,16 @@ export const makeStream = (config) => {
1607
1840
  ? "The stream declares no indexes."
1608
1841
  : `Declared: ${streamIndexes.map((candidate) => `"${candidate.name}"`).join(", ")}.`));
1609
1842
  }
1610
- 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({
1611
1849
  tableName: "",
1612
1850
  indexName: idx.index,
1613
1851
  pkField: idx.pk,
1614
- pkValue: composeStreamPk(streamId),
1852
+ pkValue: idx.partition === "derived" ? partition : streamPk,
1615
1853
  skField: idx.sk,
1616
1854
  entityTypes: [entityType],
1617
1855
  decoder: indexDecoder(idx),
@@ -1639,6 +1877,7 @@ export const makeStream = (config) => {
1639
1877
  }
1640
1878
  : {}),
1641
1879
  });
1880
+ return idx.partition === "derived" ? Query.filterExpr(query, streamPkFilter(streamPk)) : query;
1642
1881
  };
1643
1882
  /**
1644
1883
  * @internal The decoder of events read through `idx`.
@@ -1685,7 +1924,11 @@ export const makeStream = (config) => {
1685
1924
  if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) {
1686
1925
  return yield* refuse(`limit must be a positive integer; received ${String(limit)}. Nothing was sent.`);
1687
1926
  }
1688
- 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);
1689
1932
  if (options?.beginsWith !== undefined) {
1690
1933
  query = Query.where(query, { beginsWith: options.beginsWith });
1691
1934
  }
@@ -1702,7 +1945,7 @@ export const makeStream = (config) => {
1702
1945
  });
1703
1946
  const queryNamespace = {
1704
1947
  events: (streamId) => buildEventsQuery(streamId),
1705
- index: (name, streamId) => buildIndexQuery(name, streamId),
1948
+ index: (name, streamId, options) => buildIndexQuery(name, streamId, options?.partition),
1706
1949
  };
1707
1950
  // ---------------------------------------------------------------------------
1708
1951
  // Return EventStream
@@ -1785,7 +2028,7 @@ export const bind = (stream) => Effect.gen(function* () {
1785
2028
  readFrom: (streamId, afterVersion, options) => provide(stream.readFrom(streamId, afterVersion, options)),
1786
2029
  currentVersion: (streamId, options) => provide(stream.currentVersion(streamId, options)),
1787
2030
  readLatest: (streamId, options) => provide(stream.readLatest(streamId, options)),
1788
- readIndex: (name, streamId, options) => provide(stream.readIndex(name, streamId, options)),
2031
+ readIndex: (name, streamId, ...options) => provide(stream.readIndex(name, streamId, ...options)),
1789
2032
  // The captured context carries a `Compression` registry provided where
1790
2033
  // the stream was bound (#147).
1791
2034
  measureAppend: (streamId, events, expectedVersion, options) => provide(stream.measureAppend(streamId, events, expectedVersion, options)),
@@ -1810,12 +2053,22 @@ export const bind = (stream) => Effect.gen(function* () {
1810
2053
  * Neither list names a key attribute: table and index keys are always
1811
2054
  * projected.
1812
2055
  */
1813
- const projectionDefinition = (projection) => projection === "all"
2056
+ const projectionDefinition = (projection, snapshot) => projection === "all"
1814
2057
  ? { ProjectionType: "ALL" }
1815
2058
  : {
1816
2059
  ProjectionType: "INCLUDE",
1817
- 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],
1818
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"];
1819
2072
  /**
1820
2073
  * Derive the `CreateTable` fragments for the stream indexes (#140) of the given
1821
2074
  * streams (`EventStream` or `BoundEventStream`) — `AttributeDefinitions`,
@@ -1830,7 +2083,7 @@ const projectionDefinition = (projection) => projection === "all"
1830
2083
  * | `projection` | `Projection` |
1831
2084
  * |---|---|
1832
2085
  * | `"all"` (default) | `{ ProjectionType: "ALL" }` |
1833
- * | `"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) |
1834
2087
  * | `"keys"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__"] }` — not `KEYS_ONLY`: every read filters on `__edd_e__` |
1835
2088
  *
1836
2089
  * The deployed index must project what is declared — copy `Projection` from
@@ -1841,7 +2094,12 @@ const projectionDefinition = (projection) => projection === "all"
1841
2094
  * table over the limit).
1842
2095
  *
1843
2096
  * An index shared by several streams with the same definition is emitted
1844
- * 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.
1845
2103
  *
1846
2104
  * - An LSI can only be created with the table, and caps the item collection
1847
2105
  * of every partition key value in the table at 10 GB — entity partitions
@@ -1869,25 +2127,32 @@ const projectionDefinition = (projection) => projection === "all"
1869
2127
  *
1870
2128
  * @throws `[EDD-9067]` when two streams define the same physical index
1871
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.
1872
2132
  */
1873
2133
  export const indexDefinitions = (...streams) => {
2134
+ /** Per physical index: its definition, its first declarer, and whether any declarer indexes snapshots. */
1874
2135
  const byIndex = new Map();
1875
2136
  for (const stream of streams) {
1876
2137
  for (const [name, raw] of Object.entries(stream.indexes)) {
1877
- 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;
1878
2146
  const owner = `index "${name}" of stream "${stream.streamName}"`;
1879
2147
  const existing = byIndex.get(settings.index);
1880
2148
  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
- });
2149
+ byIndex.set(settings.index, { ...settings, owner, snapshot });
1889
2150
  continue;
1890
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;
1891
2156
  if (existing.type !== settings.type ||
1892
2157
  existing.pk !== settings.pk ||
1893
2158
  existing.sk !== settings.sk ||
@@ -1910,7 +2175,7 @@ export const indexDefinitions = (...streams) => {
1910
2175
  { AttributeName: settings.pk, KeyType: "HASH" },
1911
2176
  { AttributeName: settings.sk, KeyType: "RANGE" },
1912
2177
  ],
1913
- Projection: projectionDefinition(settings.projection),
2178
+ Projection: projectionDefinition(settings.projection, settings.snapshot),
1914
2179
  };
1915
2180
  attributes.add(settings.sk);
1916
2181
  if (settings.type === "gsi") {