effect-dynamodb 1.24.0 → 1.25.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 (77) hide show
  1. package/dist/Aggregate.d.ts +5 -2
  2. package/dist/Aggregate.d.ts.map +1 -1
  3. package/dist/Aggregate.js +136 -53
  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.map +1 -1
  10. package/dist/Collection.js +2 -4
  11. package/dist/Collection.js.map +1 -1
  12. package/dist/DynamoClient.d.ts.map +1 -1
  13. package/dist/DynamoClient.js +42 -14
  14. package/dist/DynamoClient.js.map +1 -1
  15. package/dist/Entity.d.ts +19 -13
  16. package/dist/Entity.d.ts.map +1 -1
  17. package/dist/Entity.js +287 -226
  18. package/dist/Entity.js.map +1 -1
  19. package/dist/EventStore.d.ts +416 -73
  20. package/dist/EventStore.d.ts.map +1 -1
  21. package/dist/EventStore.js +525 -181
  22. package/dist/EventStore.js.map +1 -1
  23. package/dist/Query.d.ts +47 -14
  24. package/dist/Query.d.ts.map +1 -1
  25. package/dist/Query.js +190 -68
  26. package/dist/Query.js.map +1 -1
  27. package/dist/Table.d.ts +56 -6
  28. package/dist/Table.d.ts.map +1 -1
  29. package/dist/Table.js +134 -54
  30. package/dist/Table.js.map +1 -1
  31. package/dist/Transaction.d.ts +8 -0
  32. package/dist/Transaction.d.ts.map +1 -1
  33. package/dist/Transaction.js +67 -22
  34. package/dist/Transaction.js.map +1 -1
  35. package/dist/VectorSearchEmulation.d.ts +3 -2
  36. package/dist/VectorSearchEmulation.d.ts.map +1 -1
  37. package/dist/VectorSearchEmulation.js +14 -2
  38. package/dist/VectorSearchEmulation.js.map +1 -1
  39. package/dist/internal/BoundQuery.d.ts +3 -2
  40. package/dist/internal/BoundQuery.d.ts.map +1 -1
  41. package/dist/internal/BoundQuery.js.map +1 -1
  42. package/dist/internal/BoundVectorQuery.d.ts +5 -1
  43. package/dist/internal/BoundVectorQuery.d.ts.map +1 -1
  44. package/dist/internal/BoundVectorQuery.js +17 -5
  45. package/dist/internal/BoundVectorQuery.js.map +1 -1
  46. package/dist/internal/EntityCombinators.d.ts +8 -5
  47. package/dist/internal/EntityCombinators.d.ts.map +1 -1
  48. package/dist/internal/EntityCombinators.js +8 -5
  49. package/dist/internal/EntityCombinators.js.map +1 -1
  50. package/dist/internal/EntityOps.d.ts +1 -1
  51. package/dist/internal/Expr.d.ts +4 -0
  52. package/dist/internal/Expr.d.ts.map +1 -1
  53. package/dist/internal/Expr.js +6 -4
  54. package/dist/internal/Expr.js.map +1 -1
  55. package/dist/internal/ExpressionLimits.d.ts +45 -0
  56. package/dist/internal/ExpressionLimits.d.ts.map +1 -0
  57. package/dist/internal/ExpressionLimits.js +89 -0
  58. package/dist/internal/ExpressionLimits.js.map +1 -0
  59. package/dist/internal/Hydrate.d.ts +62 -0
  60. package/dist/internal/Hydrate.d.ts.map +1 -0
  61. package/dist/internal/Hydrate.js +191 -0
  62. package/dist/internal/Hydrate.js.map +1 -0
  63. package/dist/internal/ItemSize.d.ts +60 -24
  64. package/dist/internal/ItemSize.d.ts.map +1 -1
  65. package/dist/internal/ItemSize.js +122 -39
  66. package/dist/internal/ItemSize.js.map +1 -1
  67. package/dist/internal/TransactWriteOps.d.ts +22 -46
  68. package/dist/internal/TransactWriteOps.d.ts.map +1 -1
  69. package/dist/internal/TransactWriteOps.js +26 -95
  70. package/dist/internal/TransactWriteOps.js.map +1 -1
  71. package/dist/internal/WriteDiagnosis.d.ts +353 -0
  72. package/dist/internal/WriteDiagnosis.d.ts.map +1 -0
  73. package/dist/internal/WriteDiagnosis.js +1028 -0
  74. package/dist/internal/WriteDiagnosis.js.map +1 -0
  75. package/dist/internal/sparse_check.js +43 -79
  76. package/dist/internal/sparse_check.js.map +1 -1
  77. package/package.json +2 -2
@@ -13,8 +13,9 @@
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
19
20
  *
20
21
  * Built on the existing library primitives (DynamoSchema, KeyComposer, Query,
@@ -22,11 +23,12 @@
22
23
  */
23
24
  import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
24
25
  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";
26
+ import { AdditionalItemConditionFailed, AppendTooLarge, DuplicateCommand, isAwsConditionalCheckFailed, isAwsTransactionCancelled, TransactionCancelled, ValidationError, VersionConflict, } from "@effect-dynamodb/schema/Errors.js";
26
27
  import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
27
28
  import { DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
28
29
  import { DynamoClient } from "./DynamoClient.js";
29
- import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, judgeCancellation, refuseOversizedTransaction, refuseRepeatedItems, transactItemTarget, } from "./internal/TransactWriteOps.js";
30
+ import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, judgeCancellation, transactItemTarget, } from "./internal/TransactWriteOps.js";
31
+ import { diagnosedValidationError, diagnoseFailure, mergeKeyAttributes, withCause, } from "./internal/WriteDiagnosis.js";
30
32
  import { fromAttributeMap, toAttributeMap } from "./Marshaller.js";
31
33
  import * as Query from "./Query.js";
32
34
  import { DEFAULT_TTL_ATTRIBUTE_NAME, resolveTtlAttributeName, } from "./Table.js";
@@ -45,30 +47,13 @@ const EventEnvelope = Schema.Struct({
45
47
  eventType: Schema.String,
46
48
  timestamp: Schema.String,
47
49
  });
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
50
  /**
69
51
  * @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.
52
+ * `undefined` when it can: the options' own meaning — at most one of
53
+ * `beginsWith` and `between`, string bounds (a number would be sent as `N`
54
+ * against a string key). DynamoDB's rules on the bounds — not empty, a
55
+ * `between` in order — are DynamoDB's to enforce: its rejection is diagnosed,
56
+ * naming the index and the bound (#144).
72
57
  */
73
58
  const invalidIndexKeyCondition = (options) => {
74
59
  if (options?.beginsWith !== undefined && options.between !== undefined) {
@@ -76,26 +61,46 @@ const invalidIndexKeyCondition = (options) => {
76
61
  }
77
62
  const beginsWith = options?.beginsWith;
78
63
  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;
64
+ return typeof beginsWith === "string"
65
+ ? undefined
66
+ : "beginsWith must be a string; omit it to read the whole sub-stream.";
84
67
  }
85
68
  const between = options?.between;
86
69
  if (between === undefined)
87
70
  return undefined;
88
71
  if (!Array.isArray(between) ||
89
72
  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)}.`;
73
+ !between.every((bound) => typeof bound === "string")) {
74
+ return "between must be a pair of strings.";
96
75
  }
97
76
  return undefined;
98
77
  };
78
+ /**
79
+ * @internal The attributes `append` writes on every event item besides the
80
+ * table key (`pk`, `sk`), the optional `metadata` and the index attributes:
81
+ * the envelope, `data` and the `__edd_e__` discriminator. Sorted. A
82
+ * `"withoutMetadata"` stream index projects exactly these.
83
+ */
84
+ const EVENT_ITEM_ATTRIBUTES = [
85
+ "__edd_e__",
86
+ "data",
87
+ "eventType",
88
+ "streamId",
89
+ "timestamp",
90
+ "version",
91
+ ];
92
+ /**
93
+ * @internal The event attributes an `"all"` or `"withoutMetadata"` stream index
94
+ * guarantees on its rows and a decode needs: a row lacking one was read
95
+ * through an index deployed narrower than declared.
96
+ */
97
+ const PROJECTED_ENVELOPE_ATTRIBUTES = [
98
+ "data",
99
+ "eventType",
100
+ "streamId",
101
+ "timestamp",
102
+ "version",
103
+ ];
99
104
  /**
100
105
  * @internal Attributes a stream writes on its own items — events, snapshot,
101
106
  * idempotency sentinel — plus the default TTL attribute. An index attribute
@@ -116,11 +121,22 @@ const STREAM_OWNED_ATTRIBUTES = [
116
121
  "commandId",
117
122
  DEFAULT_TTL_ATTRIBUTE_NAME,
118
123
  ];
124
+ /** @internal Every member a stream index entry may declare (`StreamIndexConfigKey`). */
125
+ const STREAM_INDEX_CONFIG_KEYS = [
126
+ "type",
127
+ "index",
128
+ "pk",
129
+ "sk",
130
+ "key",
131
+ "projection",
132
+ ];
119
133
  /**
120
134
  * @internal Validate `makeStream({ indexes })` at definition time.
121
135
  *
122
136
  * @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).
137
+ * `pk`, an `lsi` with `pk`, an empty name, a `key` that is not a function,
138
+ * an unknown `projection`, a member the entry type does not declare — a
139
+ * misspelled `projection` would otherwise silently mean `"all"`).
124
140
  * @throws `[EDD-9064]` for an index attribute that collides with a
125
141
  * stream-owned attribute.
126
142
  * @throws `[EDD-9065]` for two indexes sharing a physical index name or an
@@ -139,6 +155,11 @@ const resolveStreamIndexes = (streamName, indexes) => {
139
155
  fail("EDD-9063", `index "${name}" must be an object; received ${String(raw)}.`);
140
156
  }
141
157
  const entry = raw;
158
+ const unknownKey = Object.keys(entry).find((key) => !STREAM_INDEX_CONFIG_KEYS.includes(key));
159
+ if (unknownKey !== undefined) {
160
+ fail("EDD-9063", `index "${name}": unknown member "${unknownKey}"; an index declares ` +
161
+ `${STREAM_INDEX_CONFIG_KEYS.join(", ")}.`);
162
+ }
142
163
  const type = (entry.type ?? "lsi");
143
164
  if (type !== "lsi" && type !== "gsi") {
144
165
  fail("EDD-9063", `index "${name}": type must be "lsi" or "gsi"; received ${JSON.stringify(entry.type)}.`);
@@ -160,6 +181,11 @@ const resolveStreamIndexes = (streamName, indexes) => {
160
181
  if (typeof entry.key !== "function") {
161
182
  fail("EDD-9063", `index "${name}": key must be a function (event, version) => string | undefined.`);
162
183
  }
184
+ const projection = (entry.projection ?? "all");
185
+ if (projection !== "all" && projection !== "withoutMetadata" && projection !== "keys") {
186
+ fail("EDD-9063", `index "${name}": projection must be "all", "withoutMetadata" or "keys"; received ` +
187
+ `${JSON.stringify(entry.projection)}.`);
188
+ }
163
189
  const index = entry.index;
164
190
  const sk = entry.sk;
165
191
  const pk = type === "gsi" ? entry.pk : "pk";
@@ -189,6 +215,7 @@ const resolveStreamIndexes = (streamName, indexes) => {
189
215
  index,
190
216
  pk,
191
217
  sk,
218
+ projection,
192
219
  key: entry.key,
193
220
  });
194
221
  }
@@ -248,6 +275,32 @@ const unverifiedSnapshotRefusal = (streamName, settings, operation) => settings
248
275
  `snapshot mode "inline" without "every" — the only mode that writes the snapshot in every ` +
249
276
  `append, so it is normally at the head. This stream's snapshot is mode "${settings.mode}"` +
250
277
  `${settings.every === undefined ? "" : ` with every: ${settings.every}`}.`;
278
+ /**
279
+ * @internal Why an `eventTimestamps` value cannot be written for `count`
280
+ * events — not an array, the wrong length, or an entry that is neither a
281
+ * valid `DateTime` (finite epoch) nor `undefined` — or `undefined` when it can. `label` opens the
282
+ * message (`"eventTimestamps has"` / `"eventTimestamps returned"`) and
283
+ * `events` names the events (`"2 events"` / `"the decision's 2 events"`).
284
+ */
285
+ const eventTimestampsProblem = (timestamps, count, label, events) => {
286
+ if (!Array.isArray(timestamps) || timestamps.length !== count) {
287
+ return (`${label} ${Array.isArray(timestamps) ? timestamps.length : "no"} entries for ${events}: ` +
288
+ "it needs exactly one entry (a DateTime, or undefined) per event, in order.");
289
+ }
290
+ const malformed = timestamps.findIndex((t) => t !== undefined && (!DateTime.isDateTime(t) || !Number.isFinite(DateTime.toEpochMillis(t))));
291
+ if (malformed === -1)
292
+ return undefined;
293
+ const entry = timestamps[malformed];
294
+ // A DateTime with a NaN epoch (e.g. `setPartsUtc` from a bad parse) passes
295
+ // `isDateTime` but makes `formatIso` throw, so it is refused here instead.
296
+ if (DateTime.isDateTime(entry)) {
297
+ return (`eventTimestamps[${malformed}] is an invalid DateTime (its epoch milliseconds are not a ` +
298
+ "finite number): each entry must be a valid DateTime, or undefined to keep the append's own time.");
299
+ }
300
+ return (`eventTimestamps[${malformed}] is not a DateTime (received ` +
301
+ `${entry instanceof Date ? "a Date — convert it with DateTime.fromDateUnsafe" : entry === null ? "null" : typeof entry}): ` +
302
+ "each entry must be a DateTime, or undefined to keep the append's own time.");
303
+ };
251
304
  // ---------------------------------------------------------------------------
252
305
  // makeStream factory
253
306
  // ---------------------------------------------------------------------------
@@ -310,7 +363,9 @@ const unverifiedSnapshotRefusal = (streamName, settings, operation) => settings
310
363
  * event's derived key (and, for a GSI, the stream's partition key) on the
311
364
  * event item; snapshots and idempotency sentinels never carry them, so they
312
365
  * are never in an index. Read with {@link EventStream.readIndex} or
313
- * `query.index`, and create the indexes with {@link indexDefinitions}.
366
+ * `query.index`, and create the indexes with {@link indexDefinitions}. Each
367
+ * index's `projection` (default `"all"`) sets what it copies from each event
368
+ * and how it is read — see {@link StreamIndexProjection}.
314
369
  *
315
370
  * Index attributes are written only at append time and events are never
316
371
  * rewritten. Declaring an index on a stream that already holds events, or
@@ -347,7 +402,9 @@ const unverifiedSnapshotRefusal = (streamName, settings, operation) => settings
347
402
  * `"inline"`.
348
403
  * @throws `[EDD-9063]` for a malformed index: a `type` other than `"lsi"` /
349
404
  * `"gsi"`, a `gsi` without `pk`, an `lsi` with `pk`, an empty `index` / `sk`
350
- * / `pk`, or a `key` that is not a function.
405
+ * / `pk`, a `key` that is not a function, a `projection` other than
406
+ * `"all"` / `"withoutMetadata"` / `"keys"`, or a member an index entry does
407
+ * not declare (such as a misspelled `projection`, also a type error).
351
408
  * @throws `[EDD-9064]` when an index attribute is one the stream writes itself
352
409
  * (`pk`, `sk`, `__edd_e__`, `streamId`, `version`, `eventType`, `data`,
353
410
  * `metadata`, `timestamp`, `asOfVersion`, `state`, `commandId`, or the
@@ -410,7 +467,13 @@ export const makeStream = (config) => {
410
467
  const streamIndexes = resolveStreamIndexes(config.streamName, config.indexes);
411
468
  const indexSettings = {};
412
469
  for (const idx of streamIndexes) {
413
- indexSettings[idx.name] = { type: idx.type, index: idx.index, pk: idx.pk, sk: idx.sk };
470
+ indexSettings[idx.name] = {
471
+ type: idx.type,
472
+ index: idx.index,
473
+ pk: idx.pk,
474
+ sk: idx.sk,
475
+ projection: idx.projection,
476
+ };
414
477
  }
415
478
  /** Every attribute the indexes write on an event item. */
416
479
  const indexAttributes = streamIndexes.flatMap((idx) => idx.type === "gsi" ? [idx.pk, idx.sk] : [idx.sk]);
@@ -512,10 +575,12 @@ export const makeStream = (config) => {
512
575
  // append
513
576
  // ---------------------------------------------------------------------------
514
577
  /**
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.
578
+ * An event's key in a stream index (#140): a string, or `undefined` to
579
+ * leave the event out. Another type, or a `key` that throws, is a
580
+ * `ValidationError`, raised before anything is written. DynamoDB's own
581
+ * rules on a key — not empty, at most 1024 bytes for a sort key — are not
582
+ * pre-checked: DynamoDB rejects the append and the rejection is diagnosed,
583
+ * naming the index and event (see `sendAppendTransaction`).
519
584
  */
520
585
  const deriveIndexKey = (idx, event, version) => Effect.try({
521
586
  try: () => idx.key(event, version),
@@ -529,12 +594,12 @@ export const makeStream = (config) => {
529
594
  cause: `The key of index "${idx.name}" for the event at version ${version} ${problem}. ` +
530
595
  "Nothing was written.",
531
596
  }));
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`);
597
+ // A string, or the event is not indexed: what the stream's own key
598
+ // contract means, which DynamoDB cannot see (a number would be sent
599
+ // as `N`). An empty string is DynamoDB's to reject, and is diagnosed
600
+ // from its rejection, naming the index and the event (#144).
601
+ if (typeof derived !== "string") {
602
+ return refuse(`must be a string or undefined; received ${String(derived)}`);
538
603
  }
539
604
  return Effect.succeed(derived);
540
605
  }));
@@ -552,69 +617,172 @@ export const makeStream = (config) => {
552
617
  * the race a guarded additional put lost (so the transaction is built and
553
618
  * written again), and fails with every other verdict.
554
619
  */
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({
620
+ const sendAppendTransaction = (client, tx, streamIdStr, commandId) => {
621
+ const sent = () => ({ _tag: "Transaction", items: tx.items, targets: tx.targets });
622
+ const diagnosed = (diagnosis, original) => diagnosis._tag === "TooManyItems"
623
+ ? new AppendTooLarge({
570
624
  streamName: config.streamName,
571
625
  streamId: streamIdStr,
572
- commandId,
626
+ count: diagnosis.count,
627
+ limit: diagnosis.limit,
628
+ cause: original,
629
+ })
630
+ : appendLimitError(diagnosis, original, tx);
631
+ return client.transactWriteItems({ TransactItems: tx.items }).pipe(Effect.as(undefined),
632
+ // One operation per item (#133, #144): diagnosed BEFORE the conditional
633
+ // mapping below — DynamoDB can report a repeated item as a conditional
634
+ // failure, which would read as a version conflict or a lost race (and
635
+ // be retried). Terminal.
636
+ diagnoseFailure({ sent, only: ["RepeatedItem"], rewrite: diagnosed }), Effect.catch((error) => {
637
+ if (!isAwsTransactionCancelled(error.cause)) {
638
+ return Effect.fail(error);
639
+ }
640
+ const rawReasons = error.cause.CancellationReasons ?? [];
641
+ const reasons = rawReasons.map((r) => ({
642
+ code: r?.Code,
643
+ message: r?.Message,
573
644
  }));
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({
645
+ const failedAt = (index) => index >= 0 && reasons[index]?.code === "ConditionalCheckFailed";
646
+ // Precedence is ordered by how terminal the caller's response should
647
+ // be: a duplicate can never succeed on retry, a version conflict
648
+ // invites a re-read, and only then are the additional items judged.
649
+ if (commandId !== undefined && failedAt(tx.sentinelIndex)) {
650
+ return Effect.fail(new DuplicateCommand({
580
651
  streamName: config.streamName,
581
652
  streamId: streamIdStr,
582
- expectedVersion: tx.expectedVersion,
653
+ commandId,
583
654
  }));
584
655
  }
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,
656
+ // The contiguity ConditionCheck and the event puts both mean "the
657
+ // stream is not where you said it was", so they share one verdict.
658
+ for (let i = 0; i < tx.guardCount + tx.eventCount; i++) {
659
+ if (failedAt(i)) {
660
+ return Effect.fail(new VersionConflict({
661
+ streamName: config.streamName,
662
+ streamId: streamIdStr,
663
+ expectedVersion: tx.expectedVersion,
664
+ }));
665
+ }
666
+ }
667
+ // The additional items, attributed back to the caller OPS that
668
+ // produced them (several items can belong to one op). Only an op's
669
+ // own condition is `AdditionalItemConditionFailed`: a guarded put
670
+ // the caller set no condition on reports what the entity's own put
671
+ // would — a taken unique value, a history conflict — or, having
672
+ // lost a race to a concurrent write, is written again (#133).
673
+ const judged = judgeCancellation(tx.additional, rawReasons, tx.guardCount + tx.eventCount);
674
+ if (judged?._tag === "fail")
675
+ return Effect.fail(judged.error);
676
+ if (judged?._tag === "conditions") {
677
+ return Effect.fail(new AdditionalItemConditionFailed({
678
+ streamName: config.streamName,
679
+ streamId: streamIdStr,
680
+ indices: judged.opIndices,
681
+ reasons,
682
+ }));
683
+ }
684
+ if (judged?._tag === "retry")
685
+ return Effect.succeed(judged.error);
686
+ // No conditional failure we can positionally justify (throttling,
687
+ // TransactionConflict, a reason at the inline snapshot's position, or
688
+ // a truncated/absent reason list) — never guess a VersionConflict.
689
+ return Effect.fail(new TransactionCancelled({
690
+ operation: "TransactWriteItems",
600
691
  reasons,
692
+ cause: error.cause,
601
693
  }));
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,
694
+ }),
695
+ // DynamoDB's limits are not pre-checked (#144): a rejection the
696
+ // conditional mapping above could not explain is diagnosed against the
697
+ // transaction sent, naming the event, sentinel, snapshot or additional
698
+ // item at fault. Over 100 items is `AppendTooLarge`, with the EXPANDED
699
+ // count.
700
+ diagnoseFailure({
701
+ sent,
702
+ keys: () => mergeKeyAttributes({
703
+ partition: ["pk", ...streamIndexes.map((idx) => idx.pk)],
704
+ sort: ["sk", ...streamIndexes.map((idx) => idx.sk)],
705
+ }, tx.additional.keys()),
706
+ rewrite: diagnosed,
612
707
  }));
613
- }));
708
+ };
709
+ /**
710
+ * A diagnosed rejection of an append (#144) other than its item count. An
711
+ * over-long (or empty) stream-index sort key OF AN EVENT — the value the
712
+ * index's `key` function returned — is named by its index and event, as
713
+ * the `indexes` option reports its other key problems. Only the event puts
714
+ * carry stream-index keys: an additional item writing the same physical
715
+ * attribute (single-table designs share `gsi1sk` and the like) is its own
716
+ * entity's key, and is named as any other item.
717
+ */
718
+ const appendLimitError = (diagnosis, original, tx) => {
719
+ if ((diagnosis._tag === "KeyTooLong" && diagnosis.kind === "sort") ||
720
+ diagnosis._tag === "EmptyKey") {
721
+ const position = tx.targets().indexOf(diagnosis.target);
722
+ const isEvent = position >= tx.guardCount && position < tx.guardCount + tx.eventCount;
723
+ const idx = isEvent
724
+ ? streamIndexes.find((candidate) => candidate.sk === diagnosis.attribute)
725
+ : undefined;
726
+ if (idx !== undefined) {
727
+ return new ValidationError({
728
+ entityType,
729
+ operation: "EventStore.append.indexes",
730
+ cause: withCause(`The key of index "${idx.name}" for ${diagnosis.target.source} ` +
731
+ (diagnosis._tag === "KeyTooLong"
732
+ ? `is ${diagnosis.bytes} bytes, longer than DynamoDB's 1024-byte limit for a ` +
733
+ "sort key."
734
+ : "is an empty string, which DynamoDB does not accept as a key value; the " +
735
+ "key function must return a non-empty string, or undefined to leave the " +
736
+ "event out of the index.") +
737
+ " DynamoDB rejected the append. Nothing was written.", original),
738
+ });
739
+ }
740
+ }
741
+ return diagnosedValidationError(diagnosis, original, {
742
+ operation: "EventStore.append",
743
+ entityType,
744
+ });
745
+ };
614
746
  const append = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
615
747
  const additionalOps = options?.additionalItems ?? [];
616
748
  const idempotency = options?.idempotency;
617
749
  const snapshotState = options?.snapshot;
750
+ const eventMetadata = options?.eventMetadata;
751
+ // Per-event metadata (#144) is checked first — ahead of the no-op fast
752
+ // path below, which would otherwise accept `append(id, [], v,
753
+ // { eventMetadata: [m] })` without looking at it.
754
+ if (eventMetadata !== undefined) {
755
+ if (options?.metadata !== undefined) {
756
+ return yield* new ValidationError({
757
+ entityType,
758
+ operation: "EventStore.append.eventMetadata",
759
+ cause: "metadata and eventMetadata are mutually exclusive: pass one value shared by " +
760
+ "every event, or one entry per event. Nothing was written.",
761
+ });
762
+ }
763
+ if (!Array.isArray(eventMetadata) || eventMetadata.length !== events.length) {
764
+ return yield* new ValidationError({
765
+ entityType,
766
+ operation: "EventStore.append.eventMetadata",
767
+ cause: `eventMetadata has ${Array.isArray(eventMetadata) ? eventMetadata.length : "no"} ` +
768
+ `entries for ${events.length} events: it needs exactly one entry (or undefined) ` +
769
+ "per event, in order. Nothing was written.",
770
+ });
771
+ }
772
+ }
773
+ // Per-event timestamps (#144), checked alongside the metadata and for
774
+ // the same reason: ahead of the no-op fast path.
775
+ const eventTimestamps = options?.eventTimestamps;
776
+ if (eventTimestamps !== undefined) {
777
+ const problem = eventTimestampsProblem(eventTimestamps, events.length, "eventTimestamps has", `${events.length} events`);
778
+ if (problem !== undefined) {
779
+ return yield* new ValidationError({
780
+ entityType,
781
+ operation: "EventStore.append.eventTimestamps",
782
+ cause: `${problem} Nothing was written.`,
783
+ });
784
+ }
785
+ }
618
786
  if (snapshotState !== undefined && snapshot === undefined) {
619
787
  return yield* snapshotUnavailable("append({ snapshot })");
620
788
  }
@@ -641,32 +809,11 @@ export const makeStream = (config) => {
641
809
  `owns version ${expectedVersion}. Nothing was written.`,
642
810
  });
643
811
  }
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.
812
+ // DynamoDB's 100-item limit on one transaction is not pre-checked
813
+ // (#144): an append is never split — splitting would break its
814
+ // atomicity — so one over the limit is rejected whole by DynamoDB and
815
+ // reported as `AppendTooLarge`, with the expanded item count.
657
816
  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
817
  const client = yield* DynamoClient;
671
818
  const tableConfig = yield* config.table.Tag;
672
819
  const tableName = tableConfig.name;
@@ -687,7 +834,8 @@ export const makeStream = (config) => {
687
834
  // Clock-backed timestamp (deterministic under TestClock; wall-clock in prod).
688
835
  const nowDateTime = yield* DateTime.now;
689
836
  const now = DateTime.formatIso(nowDateTime);
690
- // Validate and encode metadata to wire form if schema provided
837
+ // Validate and encode metadata to wire form if schema provided. Shared
838
+ // by every event; per-event `eventMetadata` is encoded in the loop below.
691
839
  let encodedMetadata;
692
840
  if (options?.metadata !== undefined && metadataSchema) {
693
841
  const encoded = yield* encodeToWire(metadataSchema, options.metadata, "EventStore.append.metadata");
@@ -711,7 +859,25 @@ export const makeStream = (config) => {
711
859
  // Inject _tag for plain Schema.Class events; Schema.TaggedClass
712
860
  // events already carry _tag in their encoded form, which wins.
713
861
  const eventData = { _tag: evtType, ...wire };
714
- const item = {
862
+ // This event's own metadata entry (#144), encoded as `metadata`
863
+ // is; an `undefined` entry writes no metadata attribute.
864
+ let itemMetadata = encodedMetadata;
865
+ if (eventMetadata !== undefined) {
866
+ const entry = eventMetadata[i];
867
+ itemMetadata =
868
+ entry === undefined
869
+ ? undefined
870
+ : metadataSchema
871
+ ? (yield* encodeToWire(metadataSchema, entry, `EventStore.append.eventMetadata[${i}]`))
872
+ : entry;
873
+ }
874
+ // This event's own timestamp (#144), or the append's `now`.
875
+ // `formatIso` renders the UTC instant, so a zoned DateTime is
876
+ // stored in the same format as `now`.
877
+ const eventTimestamp = eventTimestamps?.[i];
878
+ // Exactly the table key and EVENT_ITEM_ATTRIBUTES — what a
879
+ // "withoutMetadata" stream index projects — checked by the type.
880
+ const envelope = {
715
881
  pk,
716
882
  sk: composeEventSk(version),
717
883
  __edd_e__: entityType,
@@ -719,10 +885,11 @@ export const makeStream = (config) => {
719
885
  version,
720
886
  eventType: evtType,
721
887
  data: eventData,
722
- timestamp: now,
888
+ timestamp: eventTimestamp === undefined ? now : DateTime.formatIso(eventTimestamp),
723
889
  };
724
- if (encodedMetadata !== undefined) {
725
- item.metadata = encodedMetadata;
890
+ const item = { ...envelope };
891
+ if (itemMetadata !== undefined) {
892
+ item.metadata = itemMetadata;
726
893
  }
727
894
  // Stream indexes (#140): the derived key, stored raw, and for a
728
895
  // GSI the stream's partition key. The attributes are part of the
@@ -832,10 +999,11 @@ export const makeStream = (config) => {
832
999
  */
833
1000
  const layout = (additional) => {
834
1001
  const items = [];
835
- const targets = [];
1002
+ // What names each item, so the targets are built only on failure.
1003
+ const naming = [];
836
1004
  const pushStreamItem = (item, type, source) => {
837
1005
  items.push(item);
838
- targets.push(transactItemTarget(item, tableName, ["pk", "sk"], type, source));
1006
+ naming.push(() => [transactItemTarget(item, tableName, ["pk", "sk"], type, source)]);
839
1007
  };
840
1008
  if (contiguityCheck !== undefined) {
841
1009
  pushStreamItem(contiguityCheck, entityType, "the version-contiguity check");
@@ -845,7 +1013,7 @@ export const makeStream = (config) => {
845
1013
  pushStreamItem(item, entityType, `the event at version ${expectedVersion + i + 1}`);
846
1014
  }
847
1015
  items.push(...additional.items);
848
- targets.push(...additional.targets);
1016
+ naming.push(additional.targets);
849
1017
  const sentinelIndex = sentinelPut === undefined ? -1 : items.length;
850
1018
  if (sentinelPut !== undefined) {
851
1019
  pushStreamItem(sentinelPut, entityType, "the idempotency sentinel");
@@ -853,9 +1021,11 @@ export const makeStream = (config) => {
853
1021
  if (snapshotPut !== undefined) {
854
1022
  pushStreamItem(snapshotPut, snapshotEntityType, "the inline snapshot");
855
1023
  }
1024
+ let targets;
856
1025
  return {
857
1026
  items,
858
- targets,
1027
+ // Built once, on failure: a diagnosis's target is found by identity.
1028
+ targets: () => (targets ??= naming.flatMap((name) => name())),
859
1029
  guardCount,
860
1030
  eventCount: eventItems.length,
861
1031
  additional,
@@ -871,25 +1041,11 @@ export const makeStream = (config) => {
871
1041
  // `Transaction.transactWrite` uses, so the two APIs cannot drift.
872
1042
  const additional = yield* buildTransactWriteItems(additionalOps, "EventStore.append.additionalItems");
873
1043
  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");
1044
+ // DynamoDB's rules — one operation per item (an additional item
1045
+ // repeating an event, the contiguity check, the idempotency sentinel
1046
+ // or the inline snapshot, or another additional item), 100 items,
1047
+ // 4 MB, 400 KB per item, 1024-byte sort keys — are not pre-checked:
1048
+ // `sendAppendTransaction` diagnoses DynamoDB's rejection (#144).
893
1049
  const outcome = yield* sendAppendTransaction(client, tx, streamIdStr, idempotency?.commandId);
894
1050
  if (outcome === undefined) {
895
1051
  return { version: expectedVersion + events.length, events };
@@ -965,8 +1121,7 @@ export const makeStream = (config) => {
965
1121
  state: encoded,
966
1122
  timestamp: now,
967
1123
  };
968
- yield* client
969
- .putItem({
1124
+ const put = {
970
1125
  TableName: tableName,
971
1126
  Item: toAttributeMap(item),
972
1127
  // Monotonic: never regress the cache. Losing this race is a no-op,
@@ -974,8 +1129,21 @@ export const makeStream = (config) => {
974
1129
  ConditionExpression: "attribute_not_exists(#pk) OR #asOfVersion < :asOfVersion",
975
1130
  ExpressionAttributeNames: { "#pk": "pk", "#asOfVersion": "asOfVersion" },
976
1131
  ExpressionAttributeValues: toAttributeMap({ ":asOfVersion": asOfVersion }),
977
- })
978
- .pipe(Effect.catchIf((error) => isAwsConditionalCheckFailed(error.cause), () => Effect.void));
1132
+ };
1133
+ yield* client.putItem(put).pipe(Effect.catchIf((error) => isAwsConditionalCheckFailed(error.cause), () => Effect.void),
1134
+ // An oversized snapshot is DynamoDB's to reject (#144); named here.
1135
+ diagnoseFailure({
1136
+ sent: () => ({
1137
+ _tag: "Item",
1138
+ item: { Put: put },
1139
+ target: () => transactItemTarget({ Put: put }, tableName, ["pk", "sk"], snapshotEntityType, "the snapshot"),
1140
+ }),
1141
+ keys: () => ({ partition: ["pk"], sort: ["sk"] }),
1142
+ rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, {
1143
+ operation: "EventStore.writeSnapshot",
1144
+ entityType: snapshotEntityType,
1145
+ }),
1146
+ }));
979
1147
  });
980
1148
  /**
981
1149
  * Decode a stored snapshot item. A snapshot that fails to decode through the
@@ -1178,14 +1346,63 @@ export const makeStream = (config) => {
1178
1346
  pkValue: composeStreamPk(streamId),
1179
1347
  skField: idx.sk,
1180
1348
  entityTypes: [entityType],
1181
- decoder: (raw) => decodeStreamEvent(raw),
1349
+ decoder: indexDecoder(idx),
1182
1350
  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",
1351
+ // DynamoDB's rejection of the key condition (an empty bound, a reversed
1352
+ // `between`) is named against the stream index; so, as for every
1353
+ // query, is a consistent read of a GSI (#144).
1354
+ keyConditionDiagnosis: {
1355
+ operation: "EventStore.readIndex",
1356
+ label: `EventStream "${config.streamName}" index "${idx.name}"`,
1357
+ },
1186
1358
  keyFields: [idx.pk, idx.sk, "pk", "sk"],
1359
+ // A "keys" index holds no events: an LSI has DynamoDB fetch them
1360
+ // (`Select: ALL_ATTRIBUTES`), a GSI fetches each page's with
1361
+ // `BatchGetItem` on the table key.
1362
+ ...(idx.projection === "keys"
1363
+ ? {
1364
+ hydration: idx.type === "lsi"
1365
+ ? { _tag: "Fetch" }
1366
+ : { _tag: "BatchGet", tableKey: ["pk", "sk"] },
1367
+ }
1368
+ : {}),
1187
1369
  });
1188
1370
  };
1371
+ /**
1372
+ * @internal The decoder of events read through `idx`.
1373
+ *
1374
+ * - `"withoutMetadata"` ignores any `metadata` on the row (an index deployed
1375
+ * wider than declared still carries it), so the value matches the type.
1376
+ * - `"all"` / `"withoutMetadata"`: a decode failure on a row that lacks an
1377
+ * attribute the declared projection guarantees is diagnosed as the
1378
+ * deployed index projecting less than declared — a `ValidationError`
1379
+ * (`operation: "EventStore.index.decode"`) naming the index, the original
1380
+ * error kept as its cause. Anything else passes through unchanged.
1381
+ * - `"keys"` decodes items fetched from the table, so a missing attribute
1382
+ * there is not a projection problem: failures pass through.
1383
+ */
1384
+ const indexDecoder = (idx) => (raw) => {
1385
+ if (idx.projection === "keys")
1386
+ return decodeStreamEvent(raw);
1387
+ let row = raw;
1388
+ if (idx.projection === "withoutMetadata" && raw.metadata !== undefined) {
1389
+ row = { ...raw };
1390
+ delete row.metadata;
1391
+ }
1392
+ return decodeStreamEvent(row).pipe(Effect.mapError((error) => {
1393
+ const missing = PROJECTED_ENVELOPE_ATTRIBUTES.filter((name) => row[name] === undefined);
1394
+ if (missing.length === 0)
1395
+ return error;
1396
+ return new ValidationError({
1397
+ entityType,
1398
+ operation: "EventStore.index.decode",
1399
+ cause: withCause(`Stream index "${idx.name}" (physical "${idx.index}") is declared ` +
1400
+ `projection: "${idx.projection}", but its rows lack ${missing.join(", ")}: the ` +
1401
+ "deployed index likely projects less than declared. Deploy the Projection " +
1402
+ "EventStore.indexDefinitions emits for it.", error),
1403
+ });
1404
+ }));
1405
+ };
1189
1406
  const readIndex = (name, streamId, options) => Effect.gen(function* () {
1190
1407
  const refuse = (cause) => new ValidationError({ entityType, operation: "EventStore.readIndex", cause });
1191
1408
  const invalidCondition = invalidIndexKeyCondition(options);
@@ -1298,14 +1515,43 @@ export const bind = (stream) => Effect.gen(function* () {
1298
1515
  : (id, floorVersion, options) => provide(headProbe(id, floorVersion, options)),
1299
1516
  };
1300
1517
  });
1518
+ // ---------------------------------------------------------------------------
1519
+ // EventStore.indexDefinitions — CreateTable fragments for stream indexes (#140)
1520
+ // ---------------------------------------------------------------------------
1521
+ /**
1522
+ * @internal The `Projection` a stream index of `projection` is created with.
1523
+ * Neither list names a key attribute: table and index keys are always
1524
+ * projected.
1525
+ */
1526
+ const projectionDefinition = (projection) => projection === "all"
1527
+ ? { ProjectionType: "ALL" }
1528
+ : {
1529
+ ProjectionType: "INCLUDE",
1530
+ NonKeyAttributes: projection === "keys" ? ["__edd_e__"] : [...EVENT_ITEM_ATTRIBUTES],
1531
+ };
1301
1532
  /**
1302
1533
  * Derive the `CreateTable` fragments for the stream indexes (#140) of the given
1303
1534
  * 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.
1535
+ * `LocalSecondaryIndexes` and `GlobalSecondaryIndexes` — to merge into the
1536
+ * caller's `CreateTable` input. Event tables are not derived by
1537
+ * `Table.definition`, and the table's own `pk` / `sk` are not included: add
1538
+ * them (and, on a provisioned table, each GSI's `ProvisionedThroughput`)
1539
+ * yourself.
1540
+ *
1541
+ * Each index's `Projection` follows its {@link StreamIndexProjection}:
1542
+ *
1543
+ * | `projection` | `Projection` |
1544
+ * |---|---|
1545
+ * | `"all"` (default) | `{ ProjectionType: "ALL" }` |
1546
+ * | `"withoutMetadata"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__", "data", "eventType", "streamId", "timestamp", "version"] }` |
1547
+ * | `"keys"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__"] }` — not `KEYS_ONLY`: every read filters on `__edd_e__` |
1548
+ *
1549
+ * The deployed index must project what is declared — copy `Projection` from
1550
+ * this output into CDK or Terraform definitions. Neither an LSI's nor a GSI's
1551
+ * projection can be changed in place, and the library migrates nothing. A
1552
+ * table projects at most 100 `NonKeyAttributes` across all its indexes;
1553
+ * `"withoutMetadata"` spends 6 of them and `"keys"` 1 (DynamoDB refuses a
1554
+ * table over the limit).
1309
1555
  *
1310
1556
  * An index shared by several streams with the same definition is emitted
1311
1557
  * once. Lists are sorted by index name, attributes by attribute name.
@@ -1313,7 +1559,6 @@ export const bind = (stream) => Effect.gen(function* () {
1313
1559
  * - An LSI can only be created with the table, and caps the item collection
1314
1560
  * of every partition key value in the table at 10 GB — entity partitions
1315
1561
  * and streams without indexes included.
1316
- * - The projection must stay `ALL`: events are decoded from the index item.
1317
1562
  *
1318
1563
  * @example
1319
1564
  * ```typescript
@@ -1336,12 +1581,13 @@ export const bind = (stream) => Effect.gen(function* () {
1336
1581
  * ```
1337
1582
  *
1338
1583
  * @throws `[EDD-9067]` when two streams define the same physical index
1339
- * differently (another type, or other key attributes).
1584
+ * differently (another type, other key attributes, or another projection).
1340
1585
  */
1341
1586
  export const indexDefinitions = (...streams) => {
1342
1587
  const byIndex = new Map();
1343
1588
  for (const stream of streams) {
1344
- for (const [name, settings] of Object.entries(stream.indexes)) {
1589
+ for (const [name, raw] of Object.entries(stream.indexes)) {
1590
+ const settings = { ...raw, projection: raw.projection ?? "all" };
1345
1591
  const owner = `index "${name}" of stream "${stream.streamName}"`;
1346
1592
  const existing = byIndex.get(settings.index);
1347
1593
  if (existing === undefined) {
@@ -1350,14 +1596,16 @@ export const indexDefinitions = (...streams) => {
1350
1596
  index: settings.index,
1351
1597
  pk: settings.pk,
1352
1598
  sk: settings.sk,
1599
+ projection: settings.projection,
1353
1600
  owner,
1354
1601
  });
1355
1602
  continue;
1356
1603
  }
1357
1604
  if (existing.type !== settings.type ||
1358
1605
  existing.pk !== settings.pk ||
1359
- existing.sk !== settings.sk) {
1360
- const describe = (s) => `${s.type} (pk "${s.pk}", sk "${s.sk}")`;
1606
+ existing.sk !== settings.sk ||
1607
+ existing.projection !== settings.projection) {
1608
+ const describe = (s) => `${s.type} (pk "${s.pk}", sk "${s.sk}", projection "${s.projection}")`;
1361
1609
  throw new Error(`[EDD-9067] EventStore.indexDefinitions: the physical index "${settings.index}" is ` +
1362
1610
  `defined as ${describe(existing)} by ${existing.owner} and as ${describe(settings)} ` +
1363
1611
  `by ${owner}.`);
@@ -1375,7 +1623,7 @@ export const indexDefinitions = (...streams) => {
1375
1623
  { AttributeName: settings.pk, KeyType: "HASH" },
1376
1624
  { AttributeName: settings.sk, KeyType: "RANGE" },
1377
1625
  ],
1378
- Projection: { ProjectionType: "ALL" },
1626
+ Projection: projectionDefinition(settings.projection),
1379
1627
  };
1380
1628
  attributes.add(settings.sk);
1381
1629
  if (settings.type === "gsi") {
@@ -1396,13 +1644,27 @@ export const indexDefinitions = (...streams) => {
1396
1644
  };
1397
1645
  /** @internal Both `EventStream` and `BoundEventStream` carry this brand. */
1398
1646
  const hasEventStreamBrand = (u) => typeof u === "object" && u !== null && EventStreamTypeId in u;
1399
- /** @internal Resolve a {@link CommandOptions.additionalItems} against a decision. */
1647
+ /** @internal Resolve a {@link CommandOptionsBase.additionalItems} against a decision. */
1400
1648
  const deriveAdditionalItems = (input, decision) => {
1401
1649
  if (typeof input !== "function")
1402
1650
  return Effect.succeed(input);
1403
1651
  const derived = input(decision);
1404
1652
  return Effect.isEffect(derived) ? derived : Effect.succeed(derived);
1405
1653
  };
1654
+ /** @internal Resolve an `eventMetadata` ({@link CommandMetadataOptions}) against a decision. */
1655
+ const deriveEventMetadata = (input, decision) => {
1656
+ if (input === undefined)
1657
+ return Effect.succeed(undefined);
1658
+ const derived = input(decision);
1659
+ return Effect.isEffect(derived) ? derived : Effect.succeed(derived);
1660
+ };
1661
+ /** @internal Resolve a {@link CommandOptionsBase.eventTimestamps} against a decision. */
1662
+ const deriveEventTimestamps = (input, decision) => {
1663
+ if (input === undefined)
1664
+ return Effect.succeed(undefined);
1665
+ const derived = input(decision);
1666
+ return Effect.isEffect(derived) ? derived : Effect.succeed(derived);
1667
+ };
1406
1668
  /** @internal */
1407
1669
  const makeCommandHandlerImpl = (decider, stream, options) => {
1408
1670
  // `verifySnapshot: false` is refused up front unless the stream writes its
@@ -1493,15 +1755,44 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
1493
1755
  for (const event of newEvents) {
1494
1756
  next = decider.evolve(next, event);
1495
1757
  }
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, {
1758
+ // 6. Derive the caller's per-event metadata, then per-event
1759
+ // timestamps, then items, from the decision — in that order
1760
+ // (#144). Re-run on every attempt (and on a re-decision), because
1761
+ // the whole cycle is the retried unit.
1762
+ const decision = {
1500
1763
  events: newEvents,
1501
1764
  state: next,
1502
1765
  previous,
1503
1766
  version: baseVersion,
1504
- });
1767
+ };
1768
+ const eventMetadata = yield* deriveEventMetadata(callOptions?.eventMetadata, decision);
1769
+ // A supplied function must return the array — `undefined` (e.g. a
1770
+ // block body with no `return`) is refused, not silently dropped.
1771
+ if (callOptions?.eventMetadata !== undefined &&
1772
+ (!Array.isArray(eventMetadata) || eventMetadata.length !== newEvents.length)) {
1773
+ return yield* new ValidationError({
1774
+ entityType: stream.streamName,
1775
+ operation: "EventStore.commandHandler.eventMetadata",
1776
+ cause: `eventMetadata returned ${Array.isArray(eventMetadata) ? eventMetadata.length : "no"} ` +
1777
+ `entries for the decision's ${newEvents.length} events: it must return exactly ` +
1778
+ "one entry (or undefined) per event, in order. Nothing was written.",
1779
+ });
1780
+ }
1781
+ // A function (checked before the load), returning the array or an
1782
+ // `Effect` of it; the result of either form is checked here, so the
1783
+ // error names the handler's option rather than `append`'s.
1784
+ const eventTimestamps = yield* deriveEventTimestamps(callOptions?.eventTimestamps, decision);
1785
+ if (callOptions?.eventTimestamps !== undefined) {
1786
+ const problem = eventTimestampsProblem(eventTimestamps, newEvents.length, "eventTimestamps returned", `the decision's ${newEvents.length} events`);
1787
+ if (problem !== undefined) {
1788
+ return yield* new ValidationError({
1789
+ entityType: stream.streamName,
1790
+ operation: "EventStore.commandHandler.eventTimestamps",
1791
+ cause: `${problem} Nothing was written.`,
1792
+ });
1793
+ }
1794
+ }
1795
+ const additionalItems = yield* deriveAdditionalItems(callOptions?.additionalItems, decision);
1505
1796
  // 7. Append with optimistic concurrency, plus the caller's items, the
1506
1797
  // dedup sentinel and an inline snapshot, all in one transaction.
1507
1798
  // `baseVersion` equals any caller-supplied `expectedVersion` here
@@ -1509,6 +1800,10 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
1509
1800
  const appendOptions = {};
1510
1801
  if (callOptions?.metadata !== undefined)
1511
1802
  appendOptions.metadata = callOptions.metadata;
1803
+ if (eventMetadata !== undefined)
1804
+ appendOptions.eventMetadata = eventMetadata;
1805
+ if (eventTimestamps !== undefined)
1806
+ appendOptions.eventTimestamps = eventTimestamps;
1512
1807
  if (additionalItems !== undefined)
1513
1808
  appendOptions.additionalItems = additionalItems;
1514
1809
  if (options?.idempotency !== undefined && callOptions?.commandId !== undefined) {
@@ -1598,6 +1893,37 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
1598
1893
  cause: "commandId is required when commandHandler is configured with `idempotency`.",
1599
1894
  });
1600
1895
  }
1896
+ // The same backstop for the exclusive metadata pair (#144), and for an
1897
+ // `eventMetadata` or `eventTimestamps` that is not the function it must
1898
+ // be — refused before anything is read, rather than by `append` after
1899
+ // `decide`.
1900
+ if (callOptions?.eventMetadata !== undefined) {
1901
+ if (callOptions.metadata !== undefined) {
1902
+ return yield* new ValidationError({
1903
+ entityType: stream.streamName,
1904
+ operation: "EventStore.commandHandler.eventMetadata",
1905
+ cause: "metadata and eventMetadata are mutually exclusive: pass one value shared by " +
1906
+ "every event, or a function deriving one entry per event. Nothing was written.",
1907
+ });
1908
+ }
1909
+ if (typeof callOptions.eventMetadata !== "function") {
1910
+ return yield* new ValidationError({
1911
+ entityType: stream.streamName,
1912
+ operation: "EventStore.commandHandler.eventMetadata",
1913
+ cause: "eventMetadata must be a function of the decision: the number of events is " +
1914
+ "known only once `decide` has run. Nothing was written.",
1915
+ });
1916
+ }
1917
+ }
1918
+ if (callOptions?.eventTimestamps !== undefined &&
1919
+ typeof callOptions.eventTimestamps !== "function") {
1920
+ return yield* new ValidationError({
1921
+ entityType: stream.streamName,
1922
+ operation: "EventStore.commandHandler.eventTimestamps",
1923
+ cause: "eventTimestamps must be a function of the decision: the number of events is " +
1924
+ "known only once `decide` has run. Nothing was written.",
1925
+ });
1926
+ }
1601
1927
  // A malformed If-Match (`NaN` from a failed parse, a negative or
1602
1928
  // fractional number) is a caller bug, not a conflict — refuse it before
1603
1929
  // reading anything.
@@ -1741,6 +2067,24 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
1741
2067
  * yield* handle({ matchId: "m-1" }, command, {
1742
2068
  * additionalItems: ({ state }) => [Scoreboard.put({ matchId: "m-1", runs: state.runs })],
1743
2069
  * })
2070
+ *
2071
+ * // Per-event metadata derived from the decision: entry i is written onto event i
2072
+ * // (`crypto` is the `Crypto.Crypto` service; `randomUUIDv7` works the same way)
2073
+ * yield* handle({ matchId: "m-1" }, command, {
2074
+ * eventMetadata: ({ events }) =>
2075
+ * Effect.forEach(events, () => Effect.map(crypto.randomULID, (eventId) => ({ eventId }))),
2076
+ * })
2077
+ *
2078
+ * // Per-event timestamps derived from the decision (undefined → the Clock time)
2079
+ * yield* handle({ matchId: "m-1" }, command, {
2080
+ * eventTimestamps: ({ events }) => events.map((event) => event.occurredAt),
2081
+ * })
2082
+ *
2083
+ * // ...or from a service, as an Effect (its errors / requirements join the handler's)
2084
+ * yield* handle({ matchId: "m-1" }, command, {
2085
+ * eventTimestamps: ({ events }) =>
2086
+ * Effect.forEach(events, (event) => SourceLog.originalTime(event.sourceId)),
2087
+ * })
1744
2088
  * ```
1745
2089
  *
1746
2090
  * Each invocation runs
@@ -1756,7 +2100,7 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
1756
2100
  * item alone (one `GetItem`); an answer that is not a successful append is
1757
2101
  * confirmed against the head first, and a snapshot that proves stale falls
1758
2102
  * back to the verified load — in which case `decide` may run twice.
1759
- * - **expectedVersion.** See {@link CommandOptions.expectedVersion}.
2103
+ * - **expectedVersion.** See {@link CommandOptionsBase.expectedVersion}.
1760
2104
  * - **Fold before append.** The new events are folded into state before the
1761
2105
  * append, so the state returned, snapshotted and handed to a function-form
1762
2106
  * `additionalItems` (see {@link Decision}) is always the `evolve` fold.
@@ -1766,8 +2110,8 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
1766
2110
  * fresh snapshot is written (best-effort) after a successful append once the
1767
2111
  * cadence threshold is crossed. See {@link SnapshotMode}.
1768
2112
  * - **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
2113
+ * too large for one transaction is rejected whole by DynamoDB and fails with
2114
+ * `AppendTooLarge` — nothing is written. A large command is split by the application into stepped
1771
2115
  * commands — each an ordinary call, chained by `expectedVersion` — so a
1772
2116
  * failure partway leaves the stream at a real intermediate state.
1773
2117
  *