effect-dynamodb 1.24.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/Aggregate.d.ts +12 -2
  2. package/dist/Aggregate.d.ts.map +1 -1
  3. package/dist/Aggregate.js +284 -55
  4. package/dist/Aggregate.js.map +1 -1
  5. package/dist/Batch.d.ts +14 -5
  6. package/dist/Batch.d.ts.map +1 -1
  7. package/dist/Batch.js +184 -119
  8. package/dist/Batch.js.map +1 -1
  9. package/dist/Collection.d.ts +9 -1
  10. package/dist/Collection.d.ts.map +1 -1
  11. package/dist/Collection.js +5 -4
  12. package/dist/Collection.js.map +1 -1
  13. package/dist/DynamoClient.d.ts +80 -28
  14. package/dist/DynamoClient.d.ts.map +1 -1
  15. package/dist/DynamoClient.js +125 -25
  16. package/dist/DynamoClient.js.map +1 -1
  17. package/dist/Entity.d.ts +99 -40
  18. package/dist/Entity.d.ts.map +1 -1
  19. package/dist/Entity.js +693 -302
  20. package/dist/Entity.js.map +1 -1
  21. package/dist/EventStore.d.ts +598 -75
  22. package/dist/EventStore.d.ts.map +1 -1
  23. package/dist/EventStore.js +852 -221
  24. package/dist/EventStore.js.map +1 -1
  25. package/dist/Query.d.ts +100 -14
  26. package/dist/Query.d.ts.map +1 -1
  27. package/dist/Query.js +275 -71
  28. package/dist/Query.js.map +1 -1
  29. package/dist/StorageEncoding.d.ts +160 -0
  30. package/dist/StorageEncoding.d.ts.map +1 -0
  31. package/dist/StorageEncoding.js +258 -0
  32. package/dist/StorageEncoding.js.map +1 -0
  33. package/dist/Table.d.ts +56 -6
  34. package/dist/Table.d.ts.map +1 -1
  35. package/dist/Table.js +142 -60
  36. package/dist/Table.js.map +1 -1
  37. package/dist/Transaction.d.ts +8 -0
  38. package/dist/Transaction.d.ts.map +1 -1
  39. package/dist/Transaction.js +72 -22
  40. package/dist/Transaction.js.map +1 -1
  41. package/dist/VectorSearchEmulation.d.ts +3 -2
  42. package/dist/VectorSearchEmulation.d.ts.map +1 -1
  43. package/dist/VectorSearchEmulation.js +14 -2
  44. package/dist/VectorSearchEmulation.js.map +1 -1
  45. package/dist/compression/zlib.d.ts +75 -0
  46. package/dist/compression/zlib.d.ts.map +1 -0
  47. package/dist/compression/zlib.js +109 -0
  48. package/dist/compression/zlib.js.map +1 -0
  49. package/dist/index.d.ts +4 -1
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +3 -1
  52. package/dist/index.js.map +1 -1
  53. package/dist/internal/BoundCrud.d.ts +39 -35
  54. package/dist/internal/BoundCrud.d.ts.map +1 -1
  55. package/dist/internal/BoundCrud.js.map +1 -1
  56. package/dist/internal/BoundQuery.d.ts +6 -5
  57. package/dist/internal/BoundQuery.d.ts.map +1 -1
  58. package/dist/internal/BoundQuery.js.map +1 -1
  59. package/dist/internal/BoundVectorQuery.d.ts +17 -1
  60. package/dist/internal/BoundVectorQuery.d.ts.map +1 -1
  61. package/dist/internal/BoundVectorQuery.js +31 -6
  62. package/dist/internal/BoundVectorQuery.js.map +1 -1
  63. package/dist/internal/EntityCombinators.d.ts +8 -5
  64. package/dist/internal/EntityCombinators.d.ts.map +1 -1
  65. package/dist/internal/EntityCombinators.js +24 -6
  66. package/dist/internal/EntityCombinators.js.map +1 -1
  67. package/dist/internal/EntityOps.d.ts +1 -1
  68. package/dist/internal/Expr.d.ts +9 -3
  69. package/dist/internal/Expr.d.ts.map +1 -1
  70. package/dist/internal/Expr.js +7 -5
  71. package/dist/internal/Expr.js.map +1 -1
  72. package/dist/internal/ExpressionLimits.d.ts +45 -0
  73. package/dist/internal/ExpressionLimits.d.ts.map +1 -0
  74. package/dist/internal/ExpressionLimits.js +89 -0
  75. package/dist/internal/ExpressionLimits.js.map +1 -0
  76. package/dist/internal/Hydrate.d.ts +62 -0
  77. package/dist/internal/Hydrate.d.ts.map +1 -0
  78. package/dist/internal/Hydrate.js +191 -0
  79. package/dist/internal/Hydrate.js.map +1 -0
  80. package/dist/internal/ItemSize.d.ts +60 -24
  81. package/dist/internal/ItemSize.d.ts.map +1 -1
  82. package/dist/internal/ItemSize.js +122 -39
  83. package/dist/internal/ItemSize.js.map +1 -1
  84. package/dist/internal/Packed.d.ts +289 -0
  85. package/dist/internal/Packed.d.ts.map +1 -0
  86. package/dist/internal/Packed.js +654 -0
  87. package/dist/internal/Packed.js.map +1 -0
  88. package/dist/internal/PathBuilder.d.ts +45 -2
  89. package/dist/internal/PathBuilder.d.ts.map +1 -1
  90. package/dist/internal/PathBuilder.js +4 -0
  91. package/dist/internal/PathBuilder.js.map +1 -1
  92. package/dist/internal/TransactWriteOps.d.ts +22 -46
  93. package/dist/internal/TransactWriteOps.d.ts.map +1 -1
  94. package/dist/internal/TransactWriteOps.js +26 -95
  95. package/dist/internal/TransactWriteOps.js.map +1 -1
  96. package/dist/internal/TransactableOps.d.ts +1 -1
  97. package/dist/internal/TransactableOps.d.ts.map +1 -1
  98. package/dist/internal/TransactableOps.js +2 -0
  99. package/dist/internal/TransactableOps.js.map +1 -1
  100. package/dist/internal/WriteDiagnosis.d.ts +353 -0
  101. package/dist/internal/WriteDiagnosis.d.ts.map +1 -0
  102. package/dist/internal/WriteDiagnosis.js +1028 -0
  103. package/dist/internal/WriteDiagnosis.js.map +1 -0
  104. package/dist/internal/sparse_check.js +43 -79
  105. package/dist/internal/sparse_check.js.map +1 -1
  106. package/package.json +6 -2
@@ -13,20 +13,32 @@
13
13
  * `VersionConflict` retry policy, caller-supplied `expectedVersion`,
14
14
  * decision-derived `additionalItems`, inline snapshots)
15
15
  * - Stream indexes (`makeStream({ indexes })`): sub-streams ordered by a key
16
- * derived from each event, on an LSI or GSI — `readIndex`, `query.index`,
17
- * and `indexDefinitions` for the `CreateTable` fragments
16
+ * derived from each event, on an LSI or GSI, each projecting all of the
17
+ * event, all but its metadata, or only its keys — `readIndex`,
18
+ * `query.index`, and `indexDefinitions` for the `CreateTable` fragments
18
19
  * - `fold` / `foldFrom` helpers for state reconstruction
20
+ * - Packed storage (`makeStream({ storedAs: { data } })`, `snapshot.storedAs`):
21
+ * event data and snapshot state as one JSON string, optionally compressed —
22
+ * read by the stored form, so a stream may mix native, JSON and compressed
23
+ * items; `measureAppend` / `measureSnapshot` for caller-side size planning,
24
+ * and `decodeStreamImage` for DynamoDB Streams records
19
25
  *
20
26
  * Built on the existing library primitives (DynamoSchema, KeyComposer, Query,
21
27
  * DynamoClient, Marshaller).
22
28
  */
29
+ import { Compression } from "@effect-dynamodb/schema/Compression.js";
30
+ import { isPackedConfig } from "@effect-dynamodb/schema/DynamoModel.js";
23
31
  import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
24
32
  import { normalizeTtlSeconds } from "@effect-dynamodb/schema/Entity.js";
25
- import { AdditionalItemConditionFailed, AppendTooLarge, DuplicateCommand, isAwsConditionalCheckFailed, isAwsTransactionCancelled, TRANSACT_WRITE_ITEMS_LIMIT, TransactionCancelled, ValidationError, VersionConflict, } from "@effect-dynamodb/schema/Errors.js";
33
+ import { AdditionalItemConditionFailed, AppendTooLarge, DuplicateCommand, isAwsConditionalCheckFailed, isAwsTransactionCancelled, TransactionCancelled, ValidationError, VersionConflict, } from "@effect-dynamodb/schema/Errors.js";
34
+ import { isDispatchEligible, packedShape, packValue, unpackStoredSync, } from "@effect-dynamodb/schema/internal/PackedCodec.js";
26
35
  import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
27
- import { DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
36
+ import { Context, DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
28
37
  import { DynamoClient } from "./DynamoClient.js";
29
- import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, judgeCancellation, refuseOversizedTransaction, refuseRepeatedItems, transactItemTarget, } from "./internal/TransactWriteOps.js";
38
+ import { itemBytes, transactItemBytes } from "./internal/ItemSize.js";
39
+ import { codecRequirements, currentRegistry, decodeImage, imageEntityType, runtimeCodecCheck, unregisteredCodecError, withRegistry, } from "./internal/Packed.js";
40
+ import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, judgeCancellation, transactItemTarget, } from "./internal/TransactWriteOps.js";
41
+ import { diagnosedValidationError, diagnoseFailure, mergeKeyAttributes, withCause, } from "./internal/WriteDiagnosis.js";
30
42
  import { fromAttributeMap, toAttributeMap } from "./Marshaller.js";
31
43
  import * as Query from "./Query.js";
32
44
  import { DEFAULT_TTL_ATTRIBUTE_NAME, resolveTtlAttributeName, } from "./Table.js";
@@ -39,36 +51,26 @@ import { DEFAULT_TTL_ATTRIBUTE_NAME, resolveTtlAttributeName, } from "./Table.js
39
51
  *
40
52
  * @internal
41
53
  */
54
+ /** The codecs a stream's packed `data` / `state` name (`[EDD-9078]` when unregistered). */
55
+ const streamCodecRequirements = (streamName, storedAs) => codecRequirements(`stream "${streamName}"`, [
56
+ ["data", storedAs.data],
57
+ ["state", storedAs.state],
58
+ ]);
59
+ /** The attributes a stream image reads by stored form: event `data` only. */
60
+ const DATA_ATTRIBUTES = ["data"];
42
61
  const EventEnvelope = Schema.Struct({
43
62
  streamId: Schema.String,
44
63
  version: Schema.Number,
45
64
  eventType: Schema.String,
46
65
  timestamp: Schema.String,
47
66
  });
48
- /** @internal DynamoDB's limit on a sort key value, which an index key is. */
49
- const MAX_INDEX_SORT_KEY_BYTES = 1024;
50
- /** @internal */
51
- const utf8 = new TextEncoder();
52
- /**
53
- * @internal Compare two strings by their UTF-8 bytes — the order DynamoDB
54
- * sorts (and evaluates `BETWEEN` on) string keys. JavaScript's `<` compares
55
- * UTF-16 code units, which disagrees for characters outside the BMP.
56
- */
57
- const compareUtf8 = (a, b) => {
58
- const left = utf8.encode(a);
59
- const right = utf8.encode(b);
60
- const length = Math.min(left.length, right.length);
61
- for (let i = 0; i < length; i++) {
62
- const diff = left[i] - right[i];
63
- if (diff !== 0)
64
- return diff;
65
- }
66
- return left.length - right.length;
67
- };
68
67
  /**
69
68
  * @internal Why a {@link ReadIndexOptions} key condition cannot be sent, or
70
- * `undefined` when it can. DynamoDB rejects an empty key value and a
71
- * `BETWEEN` whose lower bound sorts after its upper bound.
69
+ * `undefined` when it can: the options' own meaning — at most one of
70
+ * `beginsWith` and `between`, string bounds (a number would be sent as `N`
71
+ * against a string key). DynamoDB's rules on the bounds — not empty, a
72
+ * `between` in order — are DynamoDB's to enforce: its rejection is diagnosed,
73
+ * naming the index and the bound (#144).
72
74
  */
73
75
  const invalidIndexKeyCondition = (options) => {
74
76
  if (options?.beginsWith !== undefined && options.between !== undefined) {
@@ -76,26 +78,46 @@ const invalidIndexKeyCondition = (options) => {
76
78
  }
77
79
  const beginsWith = options?.beginsWith;
78
80
  if (beginsWith !== undefined) {
79
- if (typeof beginsWith !== "string" || beginsWith.length === 0) {
80
- return ("beginsWith must be a non-empty string — DynamoDB rejects an empty key value; " +
81
- "omit it to read the whole sub-stream.");
82
- }
83
- return undefined;
81
+ return typeof beginsWith === "string"
82
+ ? undefined
83
+ : "beginsWith must be a string; omit it to read the whole sub-stream.";
84
84
  }
85
85
  const between = options?.between;
86
86
  if (between === undefined)
87
87
  return undefined;
88
88
  if (!Array.isArray(between) ||
89
89
  between.length !== 2 ||
90
- !between.every((bound) => typeof bound === "string" && bound.length > 0)) {
91
- return "between must be a pair of non-empty strings — DynamoDB rejects an empty key value.";
92
- }
93
- const [low, high] = between;
94
- if (compareUtf8(low, high) > 0) {
95
- return `between's lower bound ${JSON.stringify(low)} sorts after its upper bound ${JSON.stringify(high)}.`;
90
+ !between.every((bound) => typeof bound === "string")) {
91
+ return "between must be a pair of strings.";
96
92
  }
97
93
  return undefined;
98
94
  };
95
+ /**
96
+ * @internal The attributes `append` writes on every event item besides the
97
+ * table key (`pk`, `sk`), the optional `metadata` and the index attributes:
98
+ * the envelope, `data` and the `__edd_e__` discriminator. Sorted. A
99
+ * `"withoutMetadata"` stream index projects exactly these.
100
+ */
101
+ const EVENT_ITEM_ATTRIBUTES = [
102
+ "__edd_e__",
103
+ "data",
104
+ "eventType",
105
+ "streamId",
106
+ "timestamp",
107
+ "version",
108
+ ];
109
+ /**
110
+ * @internal The event attributes an `"all"` or `"withoutMetadata"` stream index
111
+ * guarantees on its rows and a decode needs: a row lacking one was read
112
+ * through an index deployed narrower than declared.
113
+ */
114
+ const PROJECTED_ENVELOPE_ATTRIBUTES = [
115
+ "data",
116
+ "eventType",
117
+ "streamId",
118
+ "timestamp",
119
+ "version",
120
+ ];
99
121
  /**
100
122
  * @internal Attributes a stream writes on its own items — events, snapshot,
101
123
  * idempotency sentinel — plus the default TTL attribute. An index attribute
@@ -116,11 +138,22 @@ const STREAM_OWNED_ATTRIBUTES = [
116
138
  "commandId",
117
139
  DEFAULT_TTL_ATTRIBUTE_NAME,
118
140
  ];
141
+ /** @internal Every member a stream index entry may declare (`StreamIndexConfigKey`). */
142
+ const STREAM_INDEX_CONFIG_KEYS = [
143
+ "type",
144
+ "index",
145
+ "pk",
146
+ "sk",
147
+ "key",
148
+ "projection",
149
+ ];
119
150
  /**
120
151
  * @internal Validate `makeStream({ indexes })` at definition time.
121
152
  *
122
153
  * @throws `[EDD-9063]` for a malformed entry (unknown `type`, a `gsi` without
123
- * `pk`, an `lsi` with `pk`, an empty name, a `key` that is not a function).
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"`).
124
157
  * @throws `[EDD-9064]` for an index attribute that collides with a
125
158
  * stream-owned attribute.
126
159
  * @throws `[EDD-9065]` for two indexes sharing a physical index name or an
@@ -139,6 +172,11 @@ const resolveStreamIndexes = (streamName, indexes) => {
139
172
  fail("EDD-9063", `index "${name}" must be an object; received ${String(raw)}.`);
140
173
  }
141
174
  const entry = raw;
175
+ const unknownKey = Object.keys(entry).find((key) => !STREAM_INDEX_CONFIG_KEYS.includes(key));
176
+ if (unknownKey !== undefined) {
177
+ fail("EDD-9063", `index "${name}": unknown member "${unknownKey}"; an index declares ` +
178
+ `${STREAM_INDEX_CONFIG_KEYS.join(", ")}.`);
179
+ }
142
180
  const type = (entry.type ?? "lsi");
143
181
  if (type !== "lsi" && type !== "gsi") {
144
182
  fail("EDD-9063", `index "${name}": type must be "lsi" or "gsi"; received ${JSON.stringify(entry.type)}.`);
@@ -160,6 +198,11 @@ const resolveStreamIndexes = (streamName, indexes) => {
160
198
  if (typeof entry.key !== "function") {
161
199
  fail("EDD-9063", `index "${name}": key must be a function (event, version) => string | undefined.`);
162
200
  }
201
+ const projection = (entry.projection ?? "all");
202
+ if (projection !== "all" && projection !== "withoutMetadata" && projection !== "keys") {
203
+ fail("EDD-9063", `index "${name}": projection must be "all", "withoutMetadata" or "keys"; received ` +
204
+ `${JSON.stringify(entry.projection)}.`);
205
+ }
163
206
  const index = entry.index;
164
207
  const sk = entry.sk;
165
208
  const pk = type === "gsi" ? entry.pk : "pk";
@@ -189,11 +232,64 @@ const resolveStreamIndexes = (streamName, indexes) => {
189
232
  index,
190
233
  pk,
191
234
  sk,
235
+ projection,
192
236
  key: entry.key,
193
237
  });
194
238
  }
195
239
  return resolved;
196
240
  };
241
+ /**
242
+ * @internal Validate `makeStream({ storedAs })` and `snapshot.storedAs` at
243
+ * definition time (#147).
244
+ *
245
+ * @throws `[EDD-9079]` for a `storedAs` that is not an object, a key other
246
+ * than `data`, or a value that is not a `DynamoModel.Packed(...)` config.
247
+ * @throws `[EDD-9073]` for a packed snapshot whose state schema's encoded side
248
+ * is not object- or array-shaped.
249
+ */
250
+ const resolveStreamStorage = (streamName, storedAs, snapshot) => {
251
+ const fail = (code, message) => {
252
+ throw new Error(`[${code}] EventStream "${streamName}": ${message}`);
253
+ };
254
+ const describe = (value) => value === null
255
+ ? "null"
256
+ : typeof value === "object"
257
+ ? "an object that is not a DynamoModel.Packed(...) config"
258
+ : `${typeof value} ${JSON.stringify(value)}`;
259
+ let data;
260
+ if (storedAs !== undefined) {
261
+ if (typeof storedAs !== "object" || storedAs === null || Array.isArray(storedAs)) {
262
+ fail("EDD-9079", `storedAs must be an object { data?: DynamoModel.Packed(...) }; received ${describe(storedAs)}.`);
263
+ }
264
+ for (const [key, value] of Object.entries(storedAs)) {
265
+ if (key !== "data") {
266
+ fail("EDD-9079", `storedAs declares "${key}", but only "data" can be stored packed` +
267
+ (key === "metadata" ? " — event metadata always stays a map" : "") +
268
+ (key === "state" ? " — pack snapshot state with snapshot.storedAs" : "") +
269
+ ".");
270
+ }
271
+ if (value !== undefined && !isPackedConfig(value)) {
272
+ fail("EDD-9079", `storedAs.data must be a DynamoModel.Packed(...) config; received ${describe(value)}.`);
273
+ }
274
+ data = value;
275
+ }
276
+ }
277
+ let state;
278
+ const snapshotStoredAs = snapshot?.storedAs;
279
+ if (snapshot !== undefined && snapshotStoredAs !== undefined) {
280
+ if (!isPackedConfig(snapshotStoredAs)) {
281
+ fail("EDD-9079", `snapshot.storedAs must be a DynamoModel.Packed(...) config; received ` +
282
+ `${describe(snapshotStoredAs)}.`);
283
+ }
284
+ const shape = packedShape(snapshot.schema);
285
+ if (shape._tag === "Refused") {
286
+ fail("EDD-9073", `snapshot.storedAs packs the snapshot state, but the state schema cannot be packed: ` +
287
+ `${shape.reason}. Packed storage holds object- or array-shaped JSON.`);
288
+ }
289
+ state = snapshotStoredAs;
290
+ }
291
+ return { data, state };
292
+ };
197
293
  // ---------------------------------------------------------------------------
198
294
  // EventStreamTypeId
199
295
  // ---------------------------------------------------------------------------
@@ -248,6 +344,32 @@ const unverifiedSnapshotRefusal = (streamName, settings, operation) => settings
248
344
  `snapshot mode "inline" without "every" — the only mode that writes the snapshot in every ` +
249
345
  `append, so it is normally at the head. This stream's snapshot is mode "${settings.mode}"` +
250
346
  `${settings.every === undefined ? "" : ` with every: ${settings.every}`}.`;
347
+ /**
348
+ * @internal Why an `eventTimestamps` value cannot be written for `count`
349
+ * events — not an array, the wrong length, or an entry that is neither a
350
+ * valid `DateTime` (finite epoch) nor `undefined` — or `undefined` when it can. `label` opens the
351
+ * message (`"eventTimestamps has"` / `"eventTimestamps returned"`) and
352
+ * `events` names the events (`"2 events"` / `"the decision's 2 events"`).
353
+ */
354
+ const eventTimestampsProblem = (timestamps, count, label, events) => {
355
+ if (!Array.isArray(timestamps) || timestamps.length !== count) {
356
+ return (`${label} ${Array.isArray(timestamps) ? timestamps.length : "no"} entries for ${events}: ` +
357
+ "it needs exactly one entry (a DateTime, or undefined) per event, in order.");
358
+ }
359
+ const malformed = timestamps.findIndex((t) => t !== undefined && (!DateTime.isDateTime(t) || !Number.isFinite(DateTime.toEpochMillis(t))));
360
+ if (malformed === -1)
361
+ return undefined;
362
+ const entry = timestamps[malformed];
363
+ // A DateTime with a NaN epoch (e.g. `setPartsUtc` from a bad parse) passes
364
+ // `isDateTime` but makes `formatIso` throw, so it is refused here instead.
365
+ if (DateTime.isDateTime(entry)) {
366
+ return (`eventTimestamps[${malformed}] is an invalid DateTime (its epoch milliseconds are not a ` +
367
+ "finite number): each entry must be a valid DateTime, or undefined to keep the append's own time.");
368
+ }
369
+ return (`eventTimestamps[${malformed}] is not a DateTime (received ` +
370
+ `${entry instanceof Date ? "a Date — convert it with DateTime.fromDateUnsafe" : entry === null ? "null" : typeof entry}): ` +
371
+ "each entry must be a DateTime, or undefined to keep the append's own time.");
372
+ };
251
373
  // ---------------------------------------------------------------------------
252
374
  // makeStream factory
253
375
  // ---------------------------------------------------------------------------
@@ -310,7 +432,9 @@ const unverifiedSnapshotRefusal = (streamName, settings, operation) => settings
310
432
  * event's derived key (and, for a GSI, the stream's partition key) on the
311
433
  * event item; snapshots and idempotency sentinels never carry them, so they
312
434
  * are never in an index. Read with {@link EventStream.readIndex} or
313
- * `query.index`, and create the indexes with {@link indexDefinitions}.
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}.
314
438
  *
315
439
  * Index attributes are written only at append time and events are never
316
440
  * rewritten. Declaring an index on a stream that already holds events, or
@@ -347,7 +471,9 @@ const unverifiedSnapshotRefusal = (streamName, settings, operation) => settings
347
471
  * `"inline"`.
348
472
  * @throws `[EDD-9063]` for a malformed index: a `type` other than `"lsi"` /
349
473
  * `"gsi"`, a `gsi` without `pk`, an `lsi` with `pk`, an empty `index` / `sk`
350
- * / `pk`, or a `key` that is not a function.
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).
351
477
  * @throws `[EDD-9064]` when an index attribute is one the stream writes itself
352
478
  * (`pk`, `sk`, `__edd_e__`, `streamId`, `version`, `eventType`, `data`,
353
479
  * `metadata`, `timestamp`, `asOfVersion`, `state`, `commandId`, or the
@@ -356,6 +482,18 @@ const unverifiedSnapshotRefusal = (streamName, settings, operation) => settings
356
482
  * with `ValidationError`.
357
483
  * @throws `[EDD-9065]` when two indexes share a physical index name or an
358
484
  * attribute (or a GSI's `pk` and `sk` are the same attribute).
485
+ * @throws `[EDD-9079]` when `storedAs` declares a key other than `data`
486
+ * (metadata always stays a map), or a `storedAs.data` / `snapshot.storedAs`
487
+ * value is not a `DynamoModel.Packed(...)` config.
488
+ * @throws `[EDD-9073]` when `snapshot.storedAs` is set and the state schema's
489
+ * encoded side is not object- or array-shaped.
490
+ *
491
+ * `storedAs: { data: DynamoModel.Packed(...) }` stores event `data` packed, and
492
+ * `snapshot.storedAs` the snapshot `state` — see {@link StreamStorageConfig}.
493
+ * Register the codecs a compressed config names through the `Compression`
494
+ * service (`Compression.layer([...])` merged into the application layer): a
495
+ * bound stream refuses to bind without them, and an unbound one to run its
496
+ * first operation (`[EDD-9078]`).
359
497
  */
360
498
  export const makeStream = (config) => {
361
499
  const schema = config.table.schema;
@@ -406,11 +544,50 @@ export const makeStream = (config) => {
406
544
  const snapshotSettings = snapshot === undefined
407
545
  ? undefined
408
546
  : { mode: snapshot.mode ?? "after-append", every: snapshot.every };
547
+ // Packed storage (EDD-9079, EDD-9073) — validated at definition time.
548
+ const storage = resolveStreamStorage(config.streamName, config.storedAs, snapshot);
549
+ /** Event `data` packing, or `undefined` for the native map. */
550
+ const dataStoredAs = storage.data;
551
+ /** Snapshot `state` packing, or `undefined` for the native map. */
552
+ const stateStoredAs = storage.state;
553
+ /**
554
+ * Whether a snapshot's stored `state` is read by its stored form: when it is
555
+ * configured packed, or when its schema's encoded side is object- or
556
+ * array-shaped (a native state is then never `S` / `B`), so a stream that
557
+ * turned packing off still reads its packed snapshot.
558
+ */
559
+ const stateDispatch = snapshot !== undefined && (stateStoredAs !== undefined || isDispatchEligible(snapshot.schema));
560
+ /**
561
+ * The first operation run with a given compressor registry checks that it
562
+ * has every codec `storedAs` names (`[EDD-9078]`, a defect): the stream
563
+ * meets values compressed with them on every read and write. `bind` checks
564
+ * the registry it captures up front instead.
565
+ */
566
+ const codecCheck = runtimeCodecCheck(`EventStore (first use of stream "${config.streamName}")`, streamCodecRequirements(config.streamName, storage));
567
+ const checked = (op) => codecCheck === undefined ? op : (...args) => Effect.andThen(codecCheck, op(...args));
568
+ /** The `[EDD-9076]` guard for queries of a stream whose `data` is packed. */
569
+ const dataGuards = dataStoredAs === undefined
570
+ ? []
571
+ : [
572
+ {
573
+ attribute: "data",
574
+ owner: `EventStream "${config.streamName}"`,
575
+ storedAs: dataStoredAs,
576
+ alternative: (path) => `Use Query.filterBy((event) => /* test event.${path} */), which runs on the ` +
577
+ "decoded event inside the same paging loop.",
578
+ },
579
+ ];
409
580
  // Stream indexes (#140) — validated at definition time (EDD-9063..9065).
410
581
  const streamIndexes = resolveStreamIndexes(config.streamName, config.indexes);
411
582
  const indexSettings = {};
412
583
  for (const idx of streamIndexes) {
413
- indexSettings[idx.name] = { type: idx.type, index: idx.index, pk: idx.pk, sk: idx.sk };
584
+ indexSettings[idx.name] = {
585
+ type: idx.type,
586
+ index: idx.index,
587
+ pk: idx.pk,
588
+ sk: idx.sk,
589
+ projection: idx.projection,
590
+ };
414
591
  }
415
592
  /** Every attribute the indexes write on an event item. */
416
593
  const indexAttributes = streamIndexes.flatMap((idx) => idx.type === "gsi" ? [idx.pk, idx.sk] : [idx.sk]);
@@ -485,11 +662,41 @@ export const makeStream = (config) => {
485
662
  // Decode a raw DynamoDB item → StreamEvent<TEvent>
486
663
  // ---------------------------------------------------------------------------
487
664
  const decodeEnvelope = Schema.decodeUnknownEffect(EventEnvelope);
665
+ /**
666
+ * An event's stored `data` → its wire form (plain JSON), by the stored
667
+ * form. A native value costs one `typeof` check.
668
+ */
669
+ const unpackData = (stored, operation) => typeof stored === "string" || stored instanceof Uint8Array
670
+ ? withRegistry((registry) => unpackStoredSync(stored, registry, { entityType, operation }, "data"))
671
+ : Effect.succeed(stored);
672
+ /**
673
+ * A raw event row (`Query.select`) with its `data` — when projected — as
674
+ * plain parsed JSON, whatever its stored form (R3: a stream that switched
675
+ * encoding holds a mix). Never schema-decoded. Only this stream's event
676
+ * rows are read: a row of another type (`Query.ignoreOwnership`) is handed
677
+ * back untouched, its `__edd_e__` borrowed for the check and stripped again.
678
+ * In place, so the strip reaches the row returned.
679
+ */
680
+ const rawEventRow = Object.assign((raw) => {
681
+ const stored = raw.data;
682
+ if (raw.__edd_e__ !== entityType)
683
+ return Effect.succeed(raw);
684
+ if (typeof stored !== "string" && !(stored instanceof Uint8Array))
685
+ return Effect.succeed(raw);
686
+ return Effect.map(unpackData(stored, "EventStore.select"), (data) => {
687
+ raw.data = data;
688
+ return raw;
689
+ });
690
+ }, { reads: ["__edd_e__"], attributes: ["data"] });
488
691
  const decodeStreamEvent = (raw) => Effect.gen(function* () {
489
692
  const toValidationError = (operation) => (cause) => new ValidationError({ entityType, operation, cause });
490
693
  const envelope = yield* decodeEnvelope(raw).pipe(Effect.mapError(toValidationError("EventStore.decode")));
694
+ // `data` is read by its stored form (#147): a native map as it is, a
695
+ // JSON string (`S`) parsed, compressed binary (`B`) decompressed and
696
+ // parsed — whatever the stream's current `storedAs` says.
697
+ const wireData = yield* unpackData(raw.data, "EventStore.decode");
491
698
  const decoder = Schema.decodeUnknownEffect(eventUnion);
492
- const data = yield* decoder(raw.data).pipe(Effect.mapError(toValidationError("EventStore.decode")));
699
+ const data = yield* decoder(wireData).pipe(Effect.mapError(toValidationError("EventStore.decode")));
493
700
  // Metadata is decoded through its schema when the stream declares one —
494
701
  // the mirror of the encode performed by `append`. Streams without a
495
702
  // metadata schema surface the stored attribute map untouched.
@@ -512,10 +719,12 @@ export const makeStream = (config) => {
512
719
  // append
513
720
  // ---------------------------------------------------------------------------
514
721
  /**
515
- * An event's key in a stream index (#140): a non-empty string, or
516
- * `undefined` to leave the event out. Anything else — an empty string
517
- * (DynamoDB rejects empty key values), another type, a `key` that throws —
518
- * is a `ValidationError`, raised before anything is written.
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`).
519
728
  */
520
729
  const deriveIndexKey = (idx, event, version) => Effect.try({
521
730
  try: () => idx.key(event, version),
@@ -529,12 +738,12 @@ export const makeStream = (config) => {
529
738
  cause: `The key of index "${idx.name}" for the event at version ${version} ${problem}. ` +
530
739
  "Nothing was written.",
531
740
  }));
532
- if (typeof derived !== "string" || derived === "") {
533
- return refuse("must be a non-empty string or undefined; received " +
534
- (typeof derived === "string" ? "an empty string" : String(derived)));
535
- }
536
- if (utf8.encode(derived).length > MAX_INDEX_SORT_KEY_BYTES) {
537
- return refuse(`is longer than DynamoDB's ${MAX_INDEX_SORT_KEY_BYTES}-byte limit for a sort key`);
741
+ // A string, or the event is not indexed: what the stream's own key
742
+ // contract means, which DynamoDB cannot see (a number would be sent
743
+ // as `N`). An empty string is DynamoDB's to reject, and is diagnosed
744
+ // from its rejection, naming the index and the event (#144).
745
+ if (typeof derived !== "string") {
746
+ return refuse(`must be a string or undefined; received ${String(derived)}`);
538
747
  }
539
748
  return Effect.succeed(derived);
540
749
  }));
@@ -552,69 +761,204 @@ export const makeStream = (config) => {
552
761
  * the race a guarded additional put lost (so the transaction is built and
553
762
  * written again), and fails with every other verdict.
554
763
  */
555
- const sendAppendTransaction = (client, tx, streamIdStr, commandId) => client.transactWriteItems({ TransactItems: tx.items }).pipe(Effect.as(undefined), Effect.catch((error) => {
556
- if (!isAwsTransactionCancelled(error.cause)) {
557
- return Effect.fail(error);
558
- }
559
- const rawReasons = error.cause.CancellationReasons ?? [];
560
- const reasons = rawReasons.map((r) => ({
561
- code: r?.Code,
562
- message: r?.Message,
563
- }));
564
- const failedAt = (index) => index >= 0 && reasons[index]?.code === "ConditionalCheckFailed";
565
- // Precedence is ordered by how terminal the caller's response should
566
- // be: a duplicate can never succeed on retry, a version conflict
567
- // invites a re-read, and only then are the additional items judged.
568
- if (commandId !== undefined && failedAt(tx.sentinelIndex)) {
569
- return Effect.fail(new DuplicateCommand({
764
+ const sendAppendTransaction = (client, tx, streamIdStr, commandId) => {
765
+ const sent = () => ({ _tag: "Transaction", items: tx.items, targets: tx.targets });
766
+ const diagnosed = (diagnosis, original) => diagnosis._tag === "TooManyItems"
767
+ ? new AppendTooLarge({
570
768
  streamName: config.streamName,
571
769
  streamId: streamIdStr,
572
- commandId,
770
+ count: diagnosis.count,
771
+ limit: diagnosis.limit,
772
+ cause: original,
773
+ })
774
+ : appendLimitError(diagnosis, original, tx);
775
+ return client.transactWriteItems({ TransactItems: tx.items }).pipe(Effect.as(undefined),
776
+ // One operation per item (#133, #144): diagnosed BEFORE the conditional
777
+ // mapping below — DynamoDB can report a repeated item as a conditional
778
+ // failure, which would read as a version conflict or a lost race (and
779
+ // be retried). Terminal.
780
+ diagnoseFailure({ sent, only: ["RepeatedItem"], rewrite: diagnosed }), Effect.catch((error) => {
781
+ if (!isAwsTransactionCancelled(error.cause)) {
782
+ return Effect.fail(error);
783
+ }
784
+ const rawReasons = error.cause.CancellationReasons ?? [];
785
+ const reasons = rawReasons.map((r) => ({
786
+ code: r?.Code,
787
+ message: r?.Message,
573
788
  }));
574
- }
575
- // The contiguity ConditionCheck and the event puts both mean "the
576
- // stream is not where you said it was", so they share one verdict.
577
- for (let i = 0; i < tx.guardCount + tx.eventCount; i++) {
578
- if (failedAt(i)) {
579
- return Effect.fail(new VersionConflict({
789
+ const failedAt = (index) => index >= 0 && reasons[index]?.code === "ConditionalCheckFailed";
790
+ // Precedence is ordered by how terminal the caller's response should
791
+ // be: a duplicate can never succeed on retry, a version conflict
792
+ // invites a re-read, and only then are the additional items judged.
793
+ if (commandId !== undefined && failedAt(tx.sentinelIndex)) {
794
+ return Effect.fail(new DuplicateCommand({
580
795
  streamName: config.streamName,
581
796
  streamId: streamIdStr,
582
- expectedVersion: tx.expectedVersion,
797
+ commandId,
583
798
  }));
584
799
  }
585
- }
586
- // The additional items, attributed back to the caller OPS that
587
- // produced them (several items can belong to one op). Only an op's
588
- // own condition is `AdditionalItemConditionFailed`: a guarded put
589
- // the caller set no condition on reports what the entity's own put
590
- // would — a taken unique value, a history conflict — or, having
591
- // lost a race to a concurrent write, is written again (#133).
592
- const judged = judgeCancellation(tx.additional, rawReasons, tx.guardCount + tx.eventCount);
593
- if (judged?._tag === "fail")
594
- return Effect.fail(judged.error);
595
- if (judged?._tag === "conditions") {
596
- return Effect.fail(new AdditionalItemConditionFailed({
597
- streamName: config.streamName,
598
- streamId: streamIdStr,
599
- indices: judged.opIndices,
800
+ // The contiguity ConditionCheck and the event puts both mean "the
801
+ // stream is not where you said it was", so they share one verdict.
802
+ for (let i = 0; i < tx.guardCount + tx.eventCount; i++) {
803
+ if (failedAt(i)) {
804
+ return Effect.fail(new VersionConflict({
805
+ streamName: config.streamName,
806
+ streamId: streamIdStr,
807
+ expectedVersion: tx.expectedVersion,
808
+ }));
809
+ }
810
+ }
811
+ // The additional items, attributed back to the caller OPS that
812
+ // produced them (several items can belong to one op). Only an op's
813
+ // own condition is `AdditionalItemConditionFailed`: a guarded put
814
+ // the caller set no condition on reports what the entity's own put
815
+ // would — a taken unique value, a history conflict — or, having
816
+ // lost a race to a concurrent write, is written again (#133).
817
+ const judged = judgeCancellation(tx.additional, rawReasons, tx.guardCount + tx.eventCount);
818
+ if (judged?._tag === "fail")
819
+ return Effect.fail(judged.error);
820
+ if (judged?._tag === "conditions") {
821
+ return Effect.fail(new AdditionalItemConditionFailed({
822
+ streamName: config.streamName,
823
+ streamId: streamIdStr,
824
+ indices: judged.opIndices,
825
+ reasons,
826
+ }));
827
+ }
828
+ if (judged?._tag === "retry")
829
+ return Effect.succeed(judged.error);
830
+ // No conditional failure we can positionally justify (throttling,
831
+ // TransactionConflict, a reason at the inline snapshot's position, or
832
+ // a truncated/absent reason list) — never guess a VersionConflict.
833
+ return Effect.fail(new TransactionCancelled({
834
+ operation: "TransactWriteItems",
600
835
  reasons,
836
+ cause: error.cause,
601
837
  }));
602
- }
603
- if (judged?._tag === "retry")
604
- return Effect.succeed(judged.error);
605
- // No conditional failure we can positionally justify (throttling,
606
- // TransactionConflict, a reason at the inline snapshot's position, or
607
- // a truncated/absent reason list) — never guess a VersionConflict.
608
- return Effect.fail(new TransactionCancelled({
609
- operation: "TransactWriteItems",
610
- reasons,
611
- cause: error.cause,
838
+ }),
839
+ // DynamoDB's limits are not pre-checked (#144): a rejection the
840
+ // conditional mapping above could not explain is diagnosed against the
841
+ // transaction sent, naming the event, sentinel, snapshot or additional
842
+ // item at fault. Over 100 items is `AppendTooLarge`, with the EXPANDED
843
+ // count.
844
+ diagnoseFailure({
845
+ sent,
846
+ keys: () => mergeKeyAttributes({
847
+ partition: ["pk", ...streamIndexes.map((idx) => idx.pk)],
848
+ sort: ["sk", ...streamIndexes.map((idx) => idx.sk)],
849
+ }, tx.additional.keys()),
850
+ rewrite: diagnosed,
612
851
  }));
613
- }));
614
- const append = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
852
+ };
853
+ /**
854
+ * 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.
861
+ */
862
+ const appendLimitError = (diagnosis, original, tx) => {
863
+ if ((diagnosis._tag === "KeyTooLong" && diagnosis.kind === "sort") ||
864
+ diagnosis._tag === "EmptyKey") {
865
+ const position = tx.targets().indexOf(diagnosis.target);
866
+ const isEvent = position >= tx.guardCount && position < tx.guardCount + tx.eventCount;
867
+ const idx = isEvent
868
+ ? streamIndexes.find((candidate) => candidate.sk === diagnosis.attribute)
869
+ : 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
+ }
884
+ }
885
+ return diagnosedValidationError(diagnosis, original, {
886
+ operation: "EventStore.append",
887
+ entityType,
888
+ });
889
+ };
890
+ /**
891
+ * The snapshot item for `state` at `asOfVersion`: the state encoded through
892
+ * the state schema, then packed per `snapshot.storedAs` (#147) — so every
893
+ * size measured afterwards is the stored size. Shared by `writeSnapshot`,
894
+ * the inline snapshot of `append`, and `measureSnapshot` / `measureAppend`.
895
+ */
896
+ const buildSnapshotItem = (pk, streamIdStr, state, asOfVersion, now, operation) => Effect.gen(function* () {
897
+ const encoded = yield* encodeSnapshotState(state, operation);
898
+ const stored = stateStoredAs === undefined
899
+ ? encoded
900
+ : yield* packValue(stateStoredAs, encoded, yield* currentRegistry, {
901
+ entityType: snapshotEntityType,
902
+ attribute: "state",
903
+ operation: `${operation}.packed`,
904
+ });
905
+ return toAttributeMap({
906
+ pk,
907
+ sk: snapshotSk,
908
+ __edd_e__: snapshotEntityType,
909
+ streamId: streamIdStr,
910
+ asOfVersion,
911
+ state: stored,
912
+ timestamp: now,
913
+ });
914
+ });
915
+ /**
916
+ * The stream's own items of an append — everything but the caller's
917
+ * `additionalItems` — validated and built exactly as `append` sends them.
918
+ * Shared by `append` and `measureAppend`, so a measurement is the size of
919
+ * the request `append` would send. `undefined` for an append with nothing
920
+ * to write (the historical no-op).
921
+ */
922
+ const buildAppend = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
615
923
  const additionalOps = options?.additionalItems ?? [];
616
924
  const idempotency = options?.idempotency;
617
925
  const snapshotState = options?.snapshot;
926
+ const eventMetadata = options?.eventMetadata;
927
+ // Per-event metadata (#144) is checked first — ahead of the no-op fast
928
+ // path below, which would otherwise accept `append(id, [], v,
929
+ // { eventMetadata: [m] })` without looking at it.
930
+ if (eventMetadata !== undefined) {
931
+ if (options?.metadata !== undefined) {
932
+ return yield* new ValidationError({
933
+ entityType,
934
+ operation: "EventStore.append.eventMetadata",
935
+ cause: "metadata and eventMetadata are mutually exclusive: pass one value shared by " +
936
+ "every event, or one entry per event. Nothing was written.",
937
+ });
938
+ }
939
+ if (!Array.isArray(eventMetadata) || eventMetadata.length !== events.length) {
940
+ return yield* new ValidationError({
941
+ entityType,
942
+ operation: "EventStore.append.eventMetadata",
943
+ cause: `eventMetadata has ${Array.isArray(eventMetadata) ? eventMetadata.length : "no"} ` +
944
+ `entries for ${events.length} events: it needs exactly one entry (or undefined) ` +
945
+ "per event, in order. Nothing was written.",
946
+ });
947
+ }
948
+ }
949
+ // Per-event timestamps (#144), checked alongside the metadata and for
950
+ // the same reason: ahead of the no-op fast path.
951
+ const eventTimestamps = options?.eventTimestamps;
952
+ if (eventTimestamps !== undefined) {
953
+ const problem = eventTimestampsProblem(eventTimestamps, events.length, "eventTimestamps has", `${events.length} events`);
954
+ if (problem !== undefined) {
955
+ return yield* new ValidationError({
956
+ entityType,
957
+ operation: "EventStore.append.eventTimestamps",
958
+ cause: `${problem} Nothing was written.`,
959
+ });
960
+ }
961
+ }
618
962
  if (snapshotState !== undefined && snapshot === undefined) {
619
963
  return yield* snapshotUnavailable("append({ snapshot })");
620
964
  }
@@ -626,7 +970,7 @@ export const makeStream = (config) => {
626
970
  additionalOps.length === 0 &&
627
971
  idempotency === undefined &&
628
972
  snapshotState === undefined) {
629
- return { version: expectedVersion, events: [] };
973
+ return undefined;
630
974
  }
631
975
  // Resolve stream ID string for storage (join composites)
632
976
  const streamIdStr = composeStreamIdString(streamId);
@@ -641,33 +985,11 @@ export const makeStream = (config) => {
641
985
  `owns version ${expectedVersion}. Nothing was written.`,
642
986
  });
643
987
  }
644
- // Guard: every item the transaction will carry — one Put per event, the
645
- // items each caller-supplied additional op compiles to, the idempotency
646
- // sentinel, the inline snapshot, and the version-contiguity
647
- // ConditionCheck when expectedVersion > 0 — must fit DynamoDB's
648
- // TransactWriteItems limit. Never split: splitting would break append
649
- // atomicity (a large command is stepped by the application instead — one
650
- // command, one decision, one atomic append).
651
- //
652
- // This first check is a LOWER BOUND, counting one item per additional op.
653
- // Expansion (uniqueness sentinels, version snapshots — #113) only ever
654
- // adds items, so an append that already fails here can never fit, and
655
- // failing now keeps an oversized append free. The authoritative check runs
656
- // once the ops are compiled, below.
988
+ // DynamoDB's 100-item limit on one transaction is not pre-checked
989
+ // (#144): an append is never split — splitting would break its
990
+ // atomicity — so one over the limit is rejected whole by DynamoDB and
991
+ // reported as `AppendTooLarge`, with the expanded item count.
657
992
  const needsContiguityCheck = expectedVersion > 0 && events.length > 0;
658
- const fixedItems = events.length +
659
- (idempotency !== undefined ? 1 : 0) +
660
- (snapshotState !== undefined ? 1 : 0) +
661
- (needsContiguityCheck ? 1 : 0);
662
- if (fixedItems + additionalOps.length > TRANSACT_WRITE_ITEMS_LIMIT) {
663
- return yield* new AppendTooLarge({
664
- streamName: config.streamName,
665
- streamId: streamIdStr,
666
- count: fixedItems + additionalOps.length,
667
- limit: TRANSACT_WRITE_ITEMS_LIMIT,
668
- });
669
- }
670
- const client = yield* DynamoClient;
671
993
  const tableConfig = yield* config.table.Tag;
672
994
  const tableName = tableConfig.name;
673
995
  // A custom TTL attribute is only known at runtime. An index key attribute
@@ -687,7 +1009,10 @@ export const makeStream = (config) => {
687
1009
  // Clock-backed timestamp (deterministic under TestClock; wall-clock in prod).
688
1010
  const nowDateTime = yield* DateTime.now;
689
1011
  const now = DateTime.formatIso(nowDateTime);
690
- // Validate and encode metadata to wire form if schema provided
1012
+ // The compressor registry in scope, for packed `data` / `state` (#147).
1013
+ const registry = yield* currentRegistry;
1014
+ // Validate and encode metadata to wire form if schema provided. Shared
1015
+ // by every event; per-event `eventMetadata` is encoded in the loop below.
691
1016
  let encodedMetadata;
692
1017
  if (options?.metadata !== undefined && metadataSchema) {
693
1018
  const encoded = yield* encodeToWire(metadataSchema, options.metadata, "EventStore.append.metadata");
@@ -711,18 +1036,47 @@ export const makeStream = (config) => {
711
1036
  // Inject _tag for plain Schema.Class events; Schema.TaggedClass
712
1037
  // events already carry _tag in their encoded form, which wins.
713
1038
  const eventData = { _tag: evtType, ...wire };
714
- const item = {
1039
+ // Packed `data` (#147): one JSON string, or compressed binary at
1040
+ // or over the threshold when that is smaller — packed here, before
1041
+ // marshalling, so every size measured afterwards is stored bytes.
1042
+ const storedData = dataStoredAs === undefined
1043
+ ? eventData
1044
+ : yield* packValue(dataStoredAs, eventData, registry, {
1045
+ entityType,
1046
+ attribute: "data",
1047
+ operation: "EventStore.append.packed",
1048
+ });
1049
+ // This event's own metadata entry (#144), encoded as `metadata`
1050
+ // is; an `undefined` entry writes no metadata attribute.
1051
+ let itemMetadata = encodedMetadata;
1052
+ if (eventMetadata !== undefined) {
1053
+ const entry = eventMetadata[i];
1054
+ itemMetadata =
1055
+ entry === undefined
1056
+ ? undefined
1057
+ : metadataSchema
1058
+ ? (yield* encodeToWire(metadataSchema, entry, `EventStore.append.eventMetadata[${i}]`))
1059
+ : entry;
1060
+ }
1061
+ // This event's own timestamp (#144), or the append's `now`.
1062
+ // `formatIso` renders the UTC instant, so a zoned DateTime is
1063
+ // stored in the same format as `now`.
1064
+ const eventTimestamp = eventTimestamps?.[i];
1065
+ // Exactly the table key and EVENT_ITEM_ATTRIBUTES — what a
1066
+ // "withoutMetadata" stream index projects — checked by the type.
1067
+ const envelope = {
715
1068
  pk,
716
1069
  sk: composeEventSk(version),
717
1070
  __edd_e__: entityType,
718
1071
  streamId: streamIdStr,
719
1072
  version,
720
1073
  eventType: evtType,
721
- data: eventData,
722
- timestamp: now,
1074
+ data: storedData,
1075
+ timestamp: eventTimestamp === undefined ? now : DateTime.formatIso(eventTimestamp),
723
1076
  };
724
- if (encodedMetadata !== undefined) {
725
- item.metadata = encodedMetadata;
1077
+ const item = { ...envelope };
1078
+ if (itemMetadata !== undefined) {
1079
+ item.metadata = itemMetadata;
726
1080
  }
727
1081
  // Stream indexes (#140): the derived key, stored raw, and for a
728
1082
  // GSI the stream's partition key. The attributes are part of the
@@ -764,8 +1118,7 @@ export const makeStream = (config) => {
764
1118
  cause,
765
1119
  }),
766
1120
  });
767
- sentinel[resolveTtlAttributeName(tableConfig)] =
768
- DateTime.toEpochSeconds(nowDateTime) + ttlSeconds;
1121
+ sentinel[ttlAttribute] = DateTime.toEpochSeconds(nowDateTime) + ttlSeconds;
769
1122
  }
770
1123
  sentinelPut = {
771
1124
  Put: {
@@ -776,21 +1129,14 @@ export const makeStream = (config) => {
776
1129
  };
777
1130
  }
778
1131
  // The inline snapshot (#138): unconditional — see AppendOptions.snapshot.
1132
+ // Packed per `snapshot.storedAs` (#147), so the transaction-size and
1133
+ // item-size diagnosis count its stored bytes.
779
1134
  let snapshotPut;
780
1135
  if (snapshotState !== undefined) {
781
- const encoded = yield* encodeSnapshotState(snapshotState, "EventStore.append.snapshot");
782
1136
  snapshotPut = {
783
1137
  Put: {
784
1138
  TableName: tableName,
785
- Item: toAttributeMap({
786
- pk,
787
- sk: snapshotSk,
788
- __edd_e__: snapshotEntityType,
789
- streamId: streamIdStr,
790
- asOfVersion: expectedVersion + events.length,
791
- state: encoded,
792
- timestamp: now,
793
- }),
1139
+ Item: yield* buildSnapshotItem(pk, streamIdStr, snapshotState, expectedVersion + events.length, now, "EventStore.append.snapshot"),
794
1140
  },
795
1141
  };
796
1142
  }
@@ -815,6 +1161,24 @@ export const makeStream = (config) => {
815
1161
  },
816
1162
  }
817
1163
  : undefined;
1164
+ const parts = {
1165
+ tableName,
1166
+ streamIdStr,
1167
+ contiguityCheck,
1168
+ eventItems,
1169
+ sentinelPut,
1170
+ snapshotPut,
1171
+ };
1172
+ return parts;
1173
+ });
1174
+ const append = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
1175
+ const additionalOps = options?.additionalItems ?? [];
1176
+ const idempotency = options?.idempotency;
1177
+ const parts = yield* buildAppend(streamId, events, expectedVersion, options);
1178
+ if (parts === undefined)
1179
+ return { version: expectedVersion, events: [] };
1180
+ const { tableName, streamIdStr, contiguityCheck, eventItems, sentinelPut, snapshotPut } = parts;
1181
+ const client = yield* DynamoClient;
818
1182
  /**
819
1183
  * Lay out the append's one transaction — see {@link AppendTransaction}:
820
1184
  * [0, C) version-contiguity ConditionCheck (C is 0 or 1)
@@ -832,10 +1196,11 @@ export const makeStream = (config) => {
832
1196
  */
833
1197
  const layout = (additional) => {
834
1198
  const items = [];
835
- const targets = [];
1199
+ // What names each item, so the targets are built only on failure.
1200
+ const naming = [];
836
1201
  const pushStreamItem = (item, type, source) => {
837
1202
  items.push(item);
838
- targets.push(transactItemTarget(item, tableName, ["pk", "sk"], type, source));
1203
+ naming.push(() => [transactItemTarget(item, tableName, ["pk", "sk"], type, source)]);
839
1204
  };
840
1205
  if (contiguityCheck !== undefined) {
841
1206
  pushStreamItem(contiguityCheck, entityType, "the version-contiguity check");
@@ -845,7 +1210,7 @@ export const makeStream = (config) => {
845
1210
  pushStreamItem(item, entityType, `the event at version ${expectedVersion + i + 1}`);
846
1211
  }
847
1212
  items.push(...additional.items);
848
- targets.push(...additional.targets);
1213
+ naming.push(additional.targets);
849
1214
  const sentinelIndex = sentinelPut === undefined ? -1 : items.length;
850
1215
  if (sentinelPut !== undefined) {
851
1216
  pushStreamItem(sentinelPut, entityType, "the idempotency sentinel");
@@ -853,9 +1218,11 @@ export const makeStream = (config) => {
853
1218
  if (snapshotPut !== undefined) {
854
1219
  pushStreamItem(snapshotPut, snapshotEntityType, "the inline snapshot");
855
1220
  }
1221
+ let targets;
856
1222
  return {
857
1223
  items,
858
- targets,
1224
+ // Built once, on failure: a diagnosis's target is found by identity.
1225
+ targets: () => (targets ??= naming.flatMap((name) => name())),
859
1226
  guardCount,
860
1227
  eventCount: eventItems.length,
861
1228
  additional,
@@ -871,25 +1238,13 @@ export const makeStream = (config) => {
871
1238
  // `Transaction.transactWrite` uses, so the two APIs cannot drift.
872
1239
  const additional = yield* buildTransactWriteItems(additionalOps, "EventStore.append.additionalItems");
873
1240
  const tx = layout(additional);
874
- // Authoritative cap check: one additional op can compile to several
875
- // items (#113), so the pre-flight lower bound above is not sufficient.
876
- // Reporting the EXPANDED count is the point — "you passed 40 items"
877
- // when the caller passed 30 ops is baffling without it.
878
- if (tx.items.length > TRANSACT_WRITE_ITEMS_LIMIT) {
879
- return yield* new AppendTooLarge({
880
- streamName: config.streamName,
881
- streamId: streamIdStr,
882
- count: tx.items.length,
883
- limit: TRANSACT_WRITE_ITEMS_LIMIT,
884
- });
885
- }
886
- // Checked before anything is sent: one op per item — an additional
887
- // item repeating an event, the contiguity check, the idempotency
888
- // sentinel or the inline snapshot is refused, as additional items
889
- // repeating each other are — and the transaction within DynamoDB's
890
- // 4 MB (#133).
891
- yield* refuseRepeatedItems(tx.targets, "EventStore.append");
892
- yield* refuseOversizedTransaction(tx.items, tx.targets, "EventStore.append");
1241
+ // DynamoDB's rules — one operation per item (an additional item
1242
+ // repeating an event, the contiguity check, the idempotency sentinel
1243
+ // or the inline snapshot, or another additional item), 100 items,
1244
+ // 4 MB, 400 KB per item, 1024-byte sort keys — are not pre-checked:
1245
+ // `sendAppendTransaction` diagnoses DynamoDB's rejection (#144). The
1246
+ // items carry packed `data` / `state` as stored (#147), so the
1247
+ // diagnosis attributes stored bytes.
893
1248
  const outcome = yield* sendAppendTransaction(client, tx, streamIdStr, idempotency?.commandId);
894
1249
  if (outcome === undefined) {
895
1250
  return { version: expectedVersion + events.length, events };
@@ -899,6 +1254,73 @@ export const makeStream = (config) => {
899
1254
  return yield* Effect.fail(lost);
900
1255
  });
901
1256
  // ---------------------------------------------------------------------------
1257
+ // measureAppend / measureSnapshot — caller-side size planning (#147, R1)
1258
+ // ---------------------------------------------------------------------------
1259
+ const measureAppend = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
1260
+ if (options?.additionalItems !== undefined) {
1261
+ return yield* new ValidationError({
1262
+ entityType,
1263
+ operation: "EventStore.measureAppend",
1264
+ cause: "measureAppend does not measure additionalItems (building a guarded put reads the " +
1265
+ "table): measure the stream's own items without them, and add the size of your " +
1266
+ "own items.",
1267
+ });
1268
+ }
1269
+ const parts = yield* buildAppend(streamId, events, expectedVersion, options);
1270
+ if (parts === undefined) {
1271
+ return { events: [], sentinel: undefined, snapshot: undefined, transactionBytes: 0 };
1272
+ }
1273
+ const putBytes = (item) => itemBytes(item.Put?.Item ?? {});
1274
+ const owned = [
1275
+ ...(parts.contiguityCheck === undefined ? [] : [parts.contiguityCheck]),
1276
+ ...parts.eventItems,
1277
+ ...(parts.sentinelPut === undefined ? [] : [parts.sentinelPut]),
1278
+ ...(parts.snapshotPut === undefined ? [] : [parts.snapshotPut]),
1279
+ ];
1280
+ const measurement = {
1281
+ events: parts.eventItems.map(putBytes),
1282
+ sentinel: parts.sentinelPut === undefined ? undefined : putBytes(parts.sentinelPut),
1283
+ snapshot: parts.snapshotPut === undefined ? undefined : putBytes(parts.snapshotPut),
1284
+ transactionBytes: owned.reduce((sum, item) => sum + transactItemBytes(item), 0),
1285
+ };
1286
+ return measurement;
1287
+ });
1288
+ const measureSnapshot = (streamId, state, asOfVersion) => Effect.gen(function* () {
1289
+ if (snapshot === undefined)
1290
+ return yield* snapshotUnavailable("measureSnapshot");
1291
+ const now = DateTime.formatIso(yield* DateTime.now);
1292
+ const item = yield* buildSnapshotItem(composeStreamPk(streamId), composeStreamIdString(streamId), state, asOfVersion, now, "EventStore.measureSnapshot");
1293
+ return itemBytes(item);
1294
+ });
1295
+ // ---------------------------------------------------------------------------
1296
+ // decodeStreamImage — DynamoDB Streams records (#147, R2)
1297
+ // ---------------------------------------------------------------------------
1298
+ const decodeStreamImage = (image, options) => Effect.gen(function* () {
1299
+ const ctx = { entityType, operation: "EventStore.decodeStreamImage" };
1300
+ // Not one of this stream's events — its snapshot, a sentinel, another
1301
+ // stream's or an entity's item: none, decided from the raw image before
1302
+ // anything is unmarshalled, so nothing in a foreign image can fail it.
1303
+ if (typeof image === "object" &&
1304
+ image !== null &&
1305
+ !Array.isArray(image) &&
1306
+ imageEntityType(image) !== entityType) {
1307
+ return Option.none();
1308
+ }
1309
+ // In DynamoDB JSON the S / B tag says what `data` is; unmarshalled, a
1310
+ // string that is base64 of a `B` value is told apart from JSON text.
1311
+ const raw = yield* decodeImage(image, DATA_ATTRIBUTES, ctx);
1312
+ const event = yield* decodeStreamEvent(raw);
1313
+ if (options?.raw !== true)
1314
+ return Option.some(event);
1315
+ // `raw.data` is the wire form the decode just read: the parsed JSON of a
1316
+ // packed value, the unmarshalled map of a native one.
1317
+ const withRaw = {
1318
+ ...event,
1319
+ rawData: raw.data,
1320
+ };
1321
+ return Option.some(withRaw);
1322
+ });
1323
+ // ---------------------------------------------------------------------------
902
1324
  // read
903
1325
  // ---------------------------------------------------------------------------
904
1326
  /** Apply {@link ReadOptions} to an event query. */
@@ -955,33 +1377,43 @@ export const makeStream = (config) => {
955
1377
  const client = yield* DynamoClient;
956
1378
  const { name: tableName } = yield* config.table.Tag;
957
1379
  const now = DateTime.formatIso(yield* DateTime.now);
958
- const encoded = yield* encodeSnapshotState(state, "EventStore.writeSnapshot");
959
- const item = {
960
- pk: composeStreamPk(streamId),
961
- sk: snapshotSk,
962
- __edd_e__: snapshotEntityType,
963
- streamId: composeStreamIdString(streamId),
964
- asOfVersion,
965
- state: encoded,
966
- timestamp: now,
967
- };
968
- yield* client
969
- .putItem({
1380
+ // Packed per `snapshot.storedAs` (#147): the diagnosis below measures
1381
+ // the stored bytes.
1382
+ const item = yield* buildSnapshotItem(composeStreamPk(streamId), composeStreamIdString(streamId), state, asOfVersion, now, "EventStore.writeSnapshot");
1383
+ const put = {
970
1384
  TableName: tableName,
971
- Item: toAttributeMap(item),
1385
+ Item: item,
972
1386
  // Monotonic: never regress the cache. Losing this race is a no-op,
973
1387
  // not an error — the events it summarises are already durable.
974
1388
  ConditionExpression: "attribute_not_exists(#pk) OR #asOfVersion < :asOfVersion",
975
1389
  ExpressionAttributeNames: { "#pk": "pk", "#asOfVersion": "asOfVersion" },
976
1390
  ExpressionAttributeValues: toAttributeMap({ ":asOfVersion": asOfVersion }),
977
- })
978
- .pipe(Effect.catchIf((error) => isAwsConditionalCheckFailed(error.cause), () => Effect.void));
1391
+ };
1392
+ yield* client.putItem(put).pipe(Effect.catchIf((error) => isAwsConditionalCheckFailed(error.cause), () => Effect.void),
1393
+ // An oversized snapshot is DynamoDB's to reject (#144); named here.
1394
+ diagnoseFailure({
1395
+ sent: () => ({
1396
+ _tag: "Item",
1397
+ item: { Put: put },
1398
+ target: () => transactItemTarget({ Put: put }, tableName, ["pk", "sk"], snapshotEntityType, "the snapshot"),
1399
+ }),
1400
+ keys: () => ({ partition: ["pk"], sort: ["sk"] }),
1401
+ rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, {
1402
+ operation: "EventStore.writeSnapshot",
1403
+ entityType: snapshotEntityType,
1404
+ }),
1405
+ }));
979
1406
  });
980
1407
  /**
981
1408
  * Decode a stored snapshot item. A snapshot that fails to decode through the
982
1409
  * state schema is a `ValidationError` — never silently discarded.
983
1410
  */
984
- const decodeSnapshotItem = (raw, operation) => Schema.decodeUnknownEffect(snapshot.schema)(raw.state).pipe(Effect.mapError((cause) => new ValidationError({ entityType: snapshotEntityType, operation, cause })), Effect.map((state) => ({
1411
+ const decodeSnapshotItem = (raw, operation) =>
1412
+ // `state` is read by its stored form (#147) when it may be packed — see
1413
+ // `stateDispatch` — then decoded through the state schema as before.
1414
+ (stateDispatch && (typeof raw.state === "string" || raw.state instanceof Uint8Array)
1415
+ ? withRegistry((registry) => unpackStoredSync(raw.state, registry, { entityType: snapshotEntityType, operation }, "state"))
1416
+ : Effect.succeed(raw.state)).pipe(Effect.flatMap((state) => Schema.decodeUnknownEffect(snapshot.schema)(state).pipe(Effect.mapError((cause) => new ValidationError({ entityType: snapshotEntityType, operation, cause })))), Effect.map((state) => ({
985
1417
  state,
986
1418
  asOfVersion: raw.asOfVersion,
987
1419
  timestamp: raw.timestamp,
@@ -1144,6 +1576,10 @@ export const makeStream = (config) => {
1144
1576
  skField: "sk",
1145
1577
  entityTypes: [entityType],
1146
1578
  decoder: (raw) => decodeStreamEvent(raw),
1579
+ // Raw rows (`Query.select`) carry `data` as parsed JSON, whatever its
1580
+ // stored form, and a filter inside a packed `data` is refused (#147).
1581
+ rawRow: rawEventRow,
1582
+ packed: dataGuards,
1147
1583
  resolveTableName: config.table.Tag.useSync((tc) => tc.name),
1148
1584
  keyFields: ["pk", "sk"],
1149
1585
  // Bound to the event SK range so non-event items in the stream partition
@@ -1178,14 +1614,67 @@ export const makeStream = (config) => {
1178
1614
  pkValue: composeStreamPk(streamId),
1179
1615
  skField: idx.sk,
1180
1616
  entityTypes: [entityType],
1181
- decoder: (raw) => decodeStreamEvent(raw),
1617
+ decoder: indexDecoder(idx),
1618
+ // As for `query.events` (#147) — the `"keys"` hydration path decodes
1619
+ // its fetched rows through the same hooks.
1620
+ rawRow: rawEventRow,
1621
+ packed: dataGuards,
1182
1622
  resolveTableName: config.table.Tag.useSync((tc) => tc.name),
1183
- // A GSI is read only eventually consistently: `Query.consistentRead`
1184
- // on it is refused before anything is sent.
1185
- globalIndex: idx.type === "gsi",
1623
+ // DynamoDB's rejection of the key condition (an empty bound, a reversed
1624
+ // `between`) is named against the stream index; so, as for every
1625
+ // query, is a consistent read of a GSI (#144).
1626
+ keyConditionDiagnosis: {
1627
+ operation: "EventStore.readIndex",
1628
+ label: `EventStream "${config.streamName}" index "${idx.name}"`,
1629
+ },
1186
1630
  keyFields: [idx.pk, idx.sk, "pk", "sk"],
1631
+ // A "keys" index holds no events: an LSI has DynamoDB fetch them
1632
+ // (`Select: ALL_ATTRIBUTES`), a GSI fetches each page's with
1633
+ // `BatchGetItem` on the table key.
1634
+ ...(idx.projection === "keys"
1635
+ ? {
1636
+ hydration: idx.type === "lsi"
1637
+ ? { _tag: "Fetch" }
1638
+ : { _tag: "BatchGet", tableKey: ["pk", "sk"] },
1639
+ }
1640
+ : {}),
1187
1641
  });
1188
1642
  };
1643
+ /**
1644
+ * @internal The decoder of events read through `idx`.
1645
+ *
1646
+ * - `"withoutMetadata"` ignores any `metadata` on the row (an index deployed
1647
+ * wider than declared still carries it), so the value matches the type.
1648
+ * - `"all"` / `"withoutMetadata"`: a decode failure on a row that lacks an
1649
+ * attribute the declared projection guarantees is diagnosed as the
1650
+ * deployed index projecting less than declared — a `ValidationError`
1651
+ * (`operation: "EventStore.index.decode"`) naming the index, the original
1652
+ * error kept as its cause. Anything else passes through unchanged.
1653
+ * - `"keys"` decodes items fetched from the table, so a missing attribute
1654
+ * there is not a projection problem: failures pass through.
1655
+ */
1656
+ const indexDecoder = (idx) => (raw) => {
1657
+ if (idx.projection === "keys")
1658
+ return decodeStreamEvent(raw);
1659
+ let row = raw;
1660
+ if (idx.projection === "withoutMetadata" && raw.metadata !== undefined) {
1661
+ row = { ...raw };
1662
+ delete row.metadata;
1663
+ }
1664
+ return decodeStreamEvent(row).pipe(Effect.mapError((error) => {
1665
+ const missing = PROJECTED_ENVELOPE_ATTRIBUTES.filter((name) => row[name] === undefined);
1666
+ if (missing.length === 0)
1667
+ return error;
1668
+ return new ValidationError({
1669
+ entityType,
1670
+ operation: "EventStore.index.decode",
1671
+ cause: withCause(`Stream index "${idx.name}" (physical "${idx.index}") is declared ` +
1672
+ `projection: "${idx.projection}", but its rows lack ${missing.join(", ")}: the ` +
1673
+ "deployed index likely projects less than declared. Deploy the Projection " +
1674
+ "EventStore.indexDefinitions emits for it.", error),
1675
+ });
1676
+ }));
1677
+ };
1189
1678
  const readIndex = (name, streamId, options) => Effect.gen(function* () {
1190
1679
  const refuse = (cause) => new ValidationError({ entityType, operation: "EventStore.readIndex", cause });
1191
1680
  const invalidCondition = invalidIndexKeyCondition(options);
@@ -1232,19 +1721,23 @@ export const makeStream = (config) => {
1232
1721
  streamName: config.streamName,
1233
1722
  eventSchema: eventUnion,
1234
1723
  snapshotConfig: snapshotSettings,
1724
+ storedAs: storage,
1235
1725
  indexes: indexSettings,
1236
- writeSnapshot,
1237
- readSnapshot,
1238
- append,
1239
- read,
1240
- readFrom,
1241
- currentVersion,
1242
- readLatest,
1243
- readIndex,
1726
+ writeSnapshot: checked(writeSnapshot),
1727
+ readSnapshot: checked(readSnapshot),
1728
+ append: checked(append),
1729
+ read: checked(read),
1730
+ readFrom: checked(readFrom),
1731
+ currentVersion: checked(currentVersion),
1732
+ readLatest: checked(readLatest),
1733
+ readIndex: checked(readIndex),
1734
+ measureAppend: checked(measureAppend),
1735
+ measureSnapshot: checked(measureSnapshot),
1736
+ decodeStreamImage: checked(decodeStreamImage),
1244
1737
  query: queryNamespace,
1245
1738
  [StreamIdFormatter]: composeStreamIdString,
1246
- [CommandSentinelProbe]: hasCommandSentinel,
1247
- [HeadAboveProbe]: headAbove,
1739
+ [CommandSentinelProbe]: checked(hasCommandSentinel),
1740
+ [HeadAboveProbe]: checked(headAbove),
1248
1741
  };
1249
1742
  };
1250
1743
  // ---------------------------------------------------------------------------
@@ -1266,6 +1759,11 @@ export const makeStream = (config) => {
1266
1759
  */
1267
1760
  export const bind = (stream) => Effect.gen(function* () {
1268
1761
  const ctx = yield* Effect.context();
1762
+ // The stream meets values compressed with every codec its `storedAs`
1763
+ // names, so the registry captured here must have them (#147).
1764
+ const codecError = unregisteredCodecError("EventStore.bind", streamCodecRequirements(stream.streamName, stream.storedAs), Context.getOrUndefined(ctx, Compression));
1765
+ if (codecError !== undefined)
1766
+ throw codecError;
1269
1767
  const provide = (effect) => Effect.provide(effect, ctx);
1270
1768
  const probe = commandSentinelProbeOf(stream);
1271
1769
  const headProbe = headAboveProbeOf(stream);
@@ -1278,6 +1776,7 @@ export const bind = (stream) => Effect.gen(function* () {
1278
1776
  streamName: stream.streamName,
1279
1777
  eventSchema: stream.eventSchema,
1280
1778
  snapshotConfig: stream.snapshotConfig,
1779
+ storedAs: stream.storedAs,
1281
1780
  indexes: stream.indexes,
1282
1781
  writeSnapshot: (streamId, state, asOfVersion) => provide(stream.writeSnapshot(streamId, state, asOfVersion)),
1283
1782
  readSnapshot: (streamId) => provide(stream.readSnapshot(streamId)),
@@ -1287,6 +1786,11 @@ export const bind = (stream) => Effect.gen(function* () {
1287
1786
  currentVersion: (streamId, options) => provide(stream.currentVersion(streamId, options)),
1288
1787
  readLatest: (streamId, options) => provide(stream.readLatest(streamId, options)),
1289
1788
  readIndex: (name, streamId, options) => provide(stream.readIndex(name, streamId, options)),
1789
+ // The captured context carries a `Compression` registry provided where
1790
+ // the stream was bound (#147).
1791
+ measureAppend: (streamId, events, expectedVersion, options) => provide(stream.measureAppend(streamId, events, expectedVersion, options)),
1792
+ measureSnapshot: (streamId, state, asOfVersion) => provide(stream.measureSnapshot(streamId, state, asOfVersion)),
1793
+ decodeStreamImage: ((image, options) => provide(stream.decodeStreamImage(image, options))),
1290
1794
  query: stream.query,
1291
1795
  provide,
1292
1796
  [StreamIdFormatter]: (id) => formatStreamIdOf(stream, id),
@@ -1298,14 +1802,43 @@ export const bind = (stream) => Effect.gen(function* () {
1298
1802
  : (id, floorVersion, options) => provide(headProbe(id, floorVersion, options)),
1299
1803
  };
1300
1804
  });
1805
+ // ---------------------------------------------------------------------------
1806
+ // EventStore.indexDefinitions — CreateTable fragments for stream indexes (#140)
1807
+ // ---------------------------------------------------------------------------
1808
+ /**
1809
+ * @internal The `Projection` a stream index of `projection` is created with.
1810
+ * Neither list names a key attribute: table and index keys are always
1811
+ * projected.
1812
+ */
1813
+ const projectionDefinition = (projection) => projection === "all"
1814
+ ? { ProjectionType: "ALL" }
1815
+ : {
1816
+ ProjectionType: "INCLUDE",
1817
+ NonKeyAttributes: projection === "keys" ? ["__edd_e__"] : [...EVENT_ITEM_ATTRIBUTES],
1818
+ };
1301
1819
  /**
1302
1820
  * Derive the `CreateTable` fragments for the stream indexes (#140) of the given
1303
1821
  * streams (`EventStream` or `BoundEventStream`) — `AttributeDefinitions`,
1304
- * `LocalSecondaryIndexes` and `GlobalSecondaryIndexes`, each index projecting
1305
- * `ALL` — to merge into the caller's `CreateTable` input. Event tables are not
1306
- * derived by `Table.definition`, and the table's own `pk` / `sk` are not
1307
- * included: add them (and, on a provisioned table, each GSI's
1308
- * `ProvisionedThroughput`) yourself.
1822
+ * `LocalSecondaryIndexes` and `GlobalSecondaryIndexes` — to merge into the
1823
+ * caller's `CreateTable` input. Event tables are not derived by
1824
+ * `Table.definition`, and the table's own `pk` / `sk` are not included: add
1825
+ * them (and, on a provisioned table, each GSI's `ProvisionedThroughput`)
1826
+ * yourself.
1827
+ *
1828
+ * Each index's `Projection` follows its {@link StreamIndexProjection}:
1829
+ *
1830
+ * | `projection` | `Projection` |
1831
+ * |---|---|
1832
+ * | `"all"` (default) | `{ ProjectionType: "ALL" }` |
1833
+ * | `"withoutMetadata"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__", "data", "eventType", "streamId", "timestamp", "version"] }` |
1834
+ * | `"keys"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__"] }` — not `KEYS_ONLY`: every read filters on `__edd_e__` |
1835
+ *
1836
+ * The deployed index must project what is declared — copy `Projection` from
1837
+ * this output into CDK or Terraform definitions. Neither an LSI's nor a GSI's
1838
+ * projection can be changed in place, and the library migrates nothing. A
1839
+ * table projects at most 100 `NonKeyAttributes` across all its indexes;
1840
+ * `"withoutMetadata"` spends 6 of them and `"keys"` 1 (DynamoDB refuses a
1841
+ * table over the limit).
1309
1842
  *
1310
1843
  * An index shared by several streams with the same definition is emitted
1311
1844
  * once. Lists are sorted by index name, attributes by attribute name.
@@ -1313,7 +1846,6 @@ export const bind = (stream) => Effect.gen(function* () {
1313
1846
  * - An LSI can only be created with the table, and caps the item collection
1314
1847
  * of every partition key value in the table at 10 GB — entity partitions
1315
1848
  * and streams without indexes included.
1316
- * - The projection must stay `ALL`: events are decoded from the index item.
1317
1849
  *
1318
1850
  * @example
1319
1851
  * ```typescript
@@ -1336,12 +1868,13 @@ export const bind = (stream) => Effect.gen(function* () {
1336
1868
  * ```
1337
1869
  *
1338
1870
  * @throws `[EDD-9067]` when two streams define the same physical index
1339
- * differently (another type, or other key attributes).
1871
+ * differently (another type, other key attributes, or another projection).
1340
1872
  */
1341
1873
  export const indexDefinitions = (...streams) => {
1342
1874
  const byIndex = new Map();
1343
1875
  for (const stream of streams) {
1344
- for (const [name, settings] of Object.entries(stream.indexes)) {
1876
+ for (const [name, raw] of Object.entries(stream.indexes)) {
1877
+ const settings = { ...raw, projection: raw.projection ?? "all" };
1345
1878
  const owner = `index "${name}" of stream "${stream.streamName}"`;
1346
1879
  const existing = byIndex.get(settings.index);
1347
1880
  if (existing === undefined) {
@@ -1350,14 +1883,16 @@ export const indexDefinitions = (...streams) => {
1350
1883
  index: settings.index,
1351
1884
  pk: settings.pk,
1352
1885
  sk: settings.sk,
1886
+ projection: settings.projection,
1353
1887
  owner,
1354
1888
  });
1355
1889
  continue;
1356
1890
  }
1357
1891
  if (existing.type !== settings.type ||
1358
1892
  existing.pk !== settings.pk ||
1359
- existing.sk !== settings.sk) {
1360
- const describe = (s) => `${s.type} (pk "${s.pk}", sk "${s.sk}")`;
1893
+ existing.sk !== settings.sk ||
1894
+ existing.projection !== settings.projection) {
1895
+ const describe = (s) => `${s.type} (pk "${s.pk}", sk "${s.sk}", projection "${s.projection}")`;
1361
1896
  throw new Error(`[EDD-9067] EventStore.indexDefinitions: the physical index "${settings.index}" is ` +
1362
1897
  `defined as ${describe(existing)} by ${existing.owner} and as ${describe(settings)} ` +
1363
1898
  `by ${owner}.`);
@@ -1375,7 +1910,7 @@ export const indexDefinitions = (...streams) => {
1375
1910
  { AttributeName: settings.pk, KeyType: "HASH" },
1376
1911
  { AttributeName: settings.sk, KeyType: "RANGE" },
1377
1912
  ],
1378
- Projection: { ProjectionType: "ALL" },
1913
+ Projection: projectionDefinition(settings.projection),
1379
1914
  };
1380
1915
  attributes.add(settings.sk);
1381
1916
  if (settings.type === "gsi") {
@@ -1396,13 +1931,27 @@ export const indexDefinitions = (...streams) => {
1396
1931
  };
1397
1932
  /** @internal Both `EventStream` and `BoundEventStream` carry this brand. */
1398
1933
  const hasEventStreamBrand = (u) => typeof u === "object" && u !== null && EventStreamTypeId in u;
1399
- /** @internal Resolve a {@link CommandOptions.additionalItems} against a decision. */
1934
+ /** @internal Resolve a {@link CommandOptionsBase.additionalItems} against a decision. */
1400
1935
  const deriveAdditionalItems = (input, decision) => {
1401
1936
  if (typeof input !== "function")
1402
1937
  return Effect.succeed(input);
1403
1938
  const derived = input(decision);
1404
1939
  return Effect.isEffect(derived) ? derived : Effect.succeed(derived);
1405
1940
  };
1941
+ /** @internal Resolve an `eventMetadata` ({@link CommandMetadataOptions}) against a decision. */
1942
+ const deriveEventMetadata = (input, decision) => {
1943
+ if (input === undefined)
1944
+ return Effect.succeed(undefined);
1945
+ const derived = input(decision);
1946
+ return Effect.isEffect(derived) ? derived : Effect.succeed(derived);
1947
+ };
1948
+ /** @internal Resolve a {@link CommandOptionsBase.eventTimestamps} against a decision. */
1949
+ const deriveEventTimestamps = (input, decision) => {
1950
+ if (input === undefined)
1951
+ return Effect.succeed(undefined);
1952
+ const derived = input(decision);
1953
+ return Effect.isEffect(derived) ? derived : Effect.succeed(derived);
1954
+ };
1406
1955
  /** @internal */
1407
1956
  const makeCommandHandlerImpl = (decider, stream, options) => {
1408
1957
  // `verifySnapshot: false` is refused up front unless the stream writes its
@@ -1493,15 +2042,44 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
1493
2042
  for (const event of newEvents) {
1494
2043
  next = decider.evolve(next, event);
1495
2044
  }
1496
- // 6. Derive the caller's items from the decision. Re-run on every
1497
- // attempt (and on a re-decision), because the whole cycle is the
1498
- // retried unit.
1499
- const additionalItems = yield* deriveAdditionalItems(callOptions?.additionalItems, {
2045
+ // 6. Derive the caller's per-event metadata, then per-event
2046
+ // timestamps, then items, from the decision — in that order
2047
+ // (#144). Re-run on every attempt (and on a re-decision), because
2048
+ // the whole cycle is the retried unit.
2049
+ const decision = {
1500
2050
  events: newEvents,
1501
2051
  state: next,
1502
2052
  previous,
1503
2053
  version: baseVersion,
1504
- });
2054
+ };
2055
+ const eventMetadata = yield* deriveEventMetadata(callOptions?.eventMetadata, decision);
2056
+ // A supplied function must return the array — `undefined` (e.g. a
2057
+ // block body with no `return`) is refused, not silently dropped.
2058
+ if (callOptions?.eventMetadata !== undefined &&
2059
+ (!Array.isArray(eventMetadata) || eventMetadata.length !== newEvents.length)) {
2060
+ return yield* new ValidationError({
2061
+ entityType: stream.streamName,
2062
+ operation: "EventStore.commandHandler.eventMetadata",
2063
+ cause: `eventMetadata returned ${Array.isArray(eventMetadata) ? eventMetadata.length : "no"} ` +
2064
+ `entries for the decision's ${newEvents.length} events: it must return exactly ` +
2065
+ "one entry (or undefined) per event, in order. Nothing was written.",
2066
+ });
2067
+ }
2068
+ // A function (checked before the load), returning the array or an
2069
+ // `Effect` of it; the result of either form is checked here, so the
2070
+ // error names the handler's option rather than `append`'s.
2071
+ const eventTimestamps = yield* deriveEventTimestamps(callOptions?.eventTimestamps, decision);
2072
+ if (callOptions?.eventTimestamps !== undefined) {
2073
+ const problem = eventTimestampsProblem(eventTimestamps, newEvents.length, "eventTimestamps returned", `the decision's ${newEvents.length} events`);
2074
+ if (problem !== undefined) {
2075
+ return yield* new ValidationError({
2076
+ entityType: stream.streamName,
2077
+ operation: "EventStore.commandHandler.eventTimestamps",
2078
+ cause: `${problem} Nothing was written.`,
2079
+ });
2080
+ }
2081
+ }
2082
+ const additionalItems = yield* deriveAdditionalItems(callOptions?.additionalItems, decision);
1505
2083
  // 7. Append with optimistic concurrency, plus the caller's items, the
1506
2084
  // dedup sentinel and an inline snapshot, all in one transaction.
1507
2085
  // `baseVersion` equals any caller-supplied `expectedVersion` here
@@ -1509,6 +2087,10 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
1509
2087
  const appendOptions = {};
1510
2088
  if (callOptions?.metadata !== undefined)
1511
2089
  appendOptions.metadata = callOptions.metadata;
2090
+ if (eventMetadata !== undefined)
2091
+ appendOptions.eventMetadata = eventMetadata;
2092
+ if (eventTimestamps !== undefined)
2093
+ appendOptions.eventTimestamps = eventTimestamps;
1512
2094
  if (additionalItems !== undefined)
1513
2095
  appendOptions.additionalItems = additionalItems;
1514
2096
  if (options?.idempotency !== undefined && callOptions?.commandId !== undefined) {
@@ -1598,6 +2180,37 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
1598
2180
  cause: "commandId is required when commandHandler is configured with `idempotency`.",
1599
2181
  });
1600
2182
  }
2183
+ // The same backstop for the exclusive metadata pair (#144), and for an
2184
+ // `eventMetadata` or `eventTimestamps` that is not the function it must
2185
+ // be — refused before anything is read, rather than by `append` after
2186
+ // `decide`.
2187
+ if (callOptions?.eventMetadata !== undefined) {
2188
+ if (callOptions.metadata !== undefined) {
2189
+ return yield* new ValidationError({
2190
+ entityType: stream.streamName,
2191
+ operation: "EventStore.commandHandler.eventMetadata",
2192
+ cause: "metadata and eventMetadata are mutually exclusive: pass one value shared by " +
2193
+ "every event, or a function deriving one entry per event. Nothing was written.",
2194
+ });
2195
+ }
2196
+ if (typeof callOptions.eventMetadata !== "function") {
2197
+ return yield* new ValidationError({
2198
+ entityType: stream.streamName,
2199
+ operation: "EventStore.commandHandler.eventMetadata",
2200
+ cause: "eventMetadata must be a function of the decision: the number of events is " +
2201
+ "known only once `decide` has run. Nothing was written.",
2202
+ });
2203
+ }
2204
+ }
2205
+ if (callOptions?.eventTimestamps !== undefined &&
2206
+ typeof callOptions.eventTimestamps !== "function") {
2207
+ return yield* new ValidationError({
2208
+ entityType: stream.streamName,
2209
+ operation: "EventStore.commandHandler.eventTimestamps",
2210
+ cause: "eventTimestamps must be a function of the decision: the number of events is " +
2211
+ "known only once `decide` has run. Nothing was written.",
2212
+ });
2213
+ }
1601
2214
  // A malformed If-Match (`NaN` from a failed parse, a negative or
1602
2215
  // fractional number) is a caller bug, not a conflict — refuse it before
1603
2216
  // reading anything.
@@ -1741,6 +2354,24 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
1741
2354
  * yield* handle({ matchId: "m-1" }, command, {
1742
2355
  * additionalItems: ({ state }) => [Scoreboard.put({ matchId: "m-1", runs: state.runs })],
1743
2356
  * })
2357
+ *
2358
+ * // Per-event metadata derived from the decision: entry i is written onto event i
2359
+ * // (`crypto` is the `Crypto.Crypto` service; `randomUUIDv7` works the same way)
2360
+ * yield* handle({ matchId: "m-1" }, command, {
2361
+ * eventMetadata: ({ events }) =>
2362
+ * Effect.forEach(events, () => Effect.map(crypto.randomULID, (eventId) => ({ eventId }))),
2363
+ * })
2364
+ *
2365
+ * // Per-event timestamps derived from the decision (undefined → the Clock time)
2366
+ * yield* handle({ matchId: "m-1" }, command, {
2367
+ * eventTimestamps: ({ events }) => events.map((event) => event.occurredAt),
2368
+ * })
2369
+ *
2370
+ * // ...or from a service, as an Effect (its errors / requirements join the handler's)
2371
+ * yield* handle({ matchId: "m-1" }, command, {
2372
+ * eventTimestamps: ({ events }) =>
2373
+ * Effect.forEach(events, (event) => SourceLog.originalTime(event.sourceId)),
2374
+ * })
1744
2375
  * ```
1745
2376
  *
1746
2377
  * Each invocation runs
@@ -1756,7 +2387,7 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
1756
2387
  * item alone (one `GetItem`); an answer that is not a successful append is
1757
2388
  * confirmed against the head first, and a snapshot that proves stale falls
1758
2389
  * back to the verified load — in which case `decide` may run twice.
1759
- * - **expectedVersion.** See {@link CommandOptions.expectedVersion}.
2390
+ * - **expectedVersion.** See {@link CommandOptionsBase.expectedVersion}.
1760
2391
  * - **Fold before append.** The new events are folded into state before the
1761
2392
  * append, so the state returned, snapshotted and handed to a function-form
1762
2393
  * `additionalItems` (see {@link Decision}) is always the `evolve` fold.
@@ -1766,8 +2397,8 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
1766
2397
  * fresh snapshot is written (best-effort) after a successful append once the
1767
2398
  * cadence threshold is crossed. See {@link SnapshotMode}.
1768
2399
  * - **One atomic append.** One command, one decision, one append: a decision
1769
- * too large for one transaction fails with `AppendTooLarge` before anything
1770
- * is written. A large command is split by the application into stepped
2400
+ * too large for one transaction is rejected whole by DynamoDB and fails with
2401
+ * `AppendTooLarge` — nothing is written. A large command is split by the application into stepped
1771
2402
  * commands — each an ordinary call, chained by `expectedVersion` — so a
1772
2403
  * failure partway leaves the stream at a real intermediate state.
1773
2404
  *