effect-dynamodb 1.23.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 +1060 -50
  20. package/dist/EventStore.d.ts.map +1 -1
  21. package/dist/EventStore.js +1607 -299
  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
@@ -4,10 +4,18 @@
4
4
  * Provides:
5
5
  * - `Decider` type for command-event-state modeling
6
6
  * - `makeStream` factory for creating event streams bound to a Table
7
- * - Core operations: `append`, `read`, `readFrom`, `currentVersion`
8
- * - Snapshot primitives: `writeSnapshot`, `readSnapshot`
9
- * - `commandHandler` combinator for read-decide-append cycle (snapshot-aware,
10
- * with an optional `VersionConflict` retry policy)
7
+ * - Core operations: `append` (atomic), `read`, `readFrom`, `currentVersion`,
8
+ * `readLatest` (reads optionally strongly consistent)
9
+ * - Snapshot primitives: `writeSnapshot`, `readSnapshot`, and inline snapshots
10
+ * written in the append transaction (`AppendOptions.snapshot`)
11
+ * - `commandHandler` combinator for the load-decide-fold-append cycle
12
+ * (snapshot-aware single-request loads, consistent loads, an optional
13
+ * `VersionConflict` retry policy, caller-supplied `expectedVersion`,
14
+ * decision-derived `additionalItems`, inline snapshots)
15
+ * - Stream indexes (`makeStream({ indexes })`): sub-streams ordered by a key
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
11
19
  * - `fold` / `foldFrom` helpers for state reconstruction
12
20
  *
13
21
  * Built on the existing library primitives (DynamoSchema, KeyComposer, Query,
@@ -15,14 +23,15 @@
15
23
  */
16
24
  import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
17
25
  import { normalizeTtlSeconds } from "@effect-dynamodb/schema/Entity.js";
18
- 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";
19
27
  import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
20
- import { DateTime, Effect, Function, Option, Pipeable, Schedule, Schema, } from "effect";
28
+ import { DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
21
29
  import { DynamoClient } from "./DynamoClient.js";
22
- 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";
23
32
  import { fromAttributeMap, toAttributeMap } from "./Marshaller.js";
24
33
  import * as Query from "./Query.js";
25
- import { resolveTtlAttributeName } from "./Table.js";
34
+ import { DEFAULT_TTL_ATTRIBUTE_NAME, resolveTtlAttributeName, } from "./Table.js";
26
35
  // ---------------------------------------------------------------------------
27
36
  // Envelope schema — validates the persisted event's system fields on read
28
37
  // ---------------------------------------------------------------------------
@@ -38,10 +47,260 @@ const EventEnvelope = Schema.Struct({
38
47
  eventType: Schema.String,
39
48
  timestamp: Schema.String,
40
49
  });
50
+ /**
51
+ * @internal Why a {@link ReadIndexOptions} key condition cannot be sent, or
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).
57
+ */
58
+ const invalidIndexKeyCondition = (options) => {
59
+ if (options?.beginsWith !== undefined && options.between !== undefined) {
60
+ return "Pass at most one of beginsWith and between.";
61
+ }
62
+ const beginsWith = options?.beginsWith;
63
+ if (beginsWith !== undefined) {
64
+ return typeof beginsWith === "string"
65
+ ? undefined
66
+ : "beginsWith must be a string; omit it to read the whole sub-stream.";
67
+ }
68
+ const between = options?.between;
69
+ if (between === undefined)
70
+ return undefined;
71
+ if (!Array.isArray(between) ||
72
+ between.length !== 2 ||
73
+ !between.every((bound) => typeof bound === "string")) {
74
+ return "between must be a pair of strings.";
75
+ }
76
+ return undefined;
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
+ ];
104
+ /**
105
+ * @internal Attributes a stream writes on its own items — events, snapshot,
106
+ * idempotency sentinel — plus the default TTL attribute. An index attribute
107
+ * may not reuse any of them.
108
+ */
109
+ const STREAM_OWNED_ATTRIBUTES = [
110
+ "pk",
111
+ "sk",
112
+ "__edd_e__",
113
+ "streamId",
114
+ "version",
115
+ "eventType",
116
+ "data",
117
+ "metadata",
118
+ "timestamp",
119
+ "asOfVersion",
120
+ "state",
121
+ "commandId",
122
+ DEFAULT_TTL_ATTRIBUTE_NAME,
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
+ ];
133
+ /**
134
+ * @internal Validate `makeStream({ indexes })` at definition time.
135
+ *
136
+ * @throws `[EDD-9063]` for a malformed entry (unknown `type`, a `gsi` without
137
+ * `pk`, an `lsi` with `pk`, an empty name, a `key` that is not a function,
138
+ * an unknown `projection`, a member the entry type does not declare — a
139
+ * misspelled `projection` would otherwise silently mean `"all"`).
140
+ * @throws `[EDD-9064]` for an index attribute that collides with a
141
+ * stream-owned attribute.
142
+ * @throws `[EDD-9065]` for two indexes sharing a physical index name or an
143
+ * attribute.
144
+ */
145
+ const resolveStreamIndexes = (streamName, indexes) => {
146
+ if (indexes === undefined)
147
+ return [];
148
+ const fail = (code, message) => {
149
+ throw new Error(`[${code}] EventStream "${streamName}": ${message}`);
150
+ };
151
+ const isName = (value) => typeof value === "string" && value !== "";
152
+ const resolved = [];
153
+ for (const [name, raw] of Object.entries(indexes)) {
154
+ if (typeof raw !== "object" || raw === null) {
155
+ fail("EDD-9063", `index "${name}" must be an object; received ${String(raw)}.`);
156
+ }
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
+ }
163
+ const type = (entry.type ?? "lsi");
164
+ if (type !== "lsi" && type !== "gsi") {
165
+ fail("EDD-9063", `index "${name}": type must be "lsi" or "gsi"; received ${JSON.stringify(entry.type)}.`);
166
+ }
167
+ if (!isName(entry.index)) {
168
+ fail("EDD-9063", `index "${name}": index must name the physical index (a non-empty string).`);
169
+ }
170
+ if (!isName(entry.sk)) {
171
+ fail("EDD-9063", `index "${name}": sk must name the sort key attribute (a non-empty string).`);
172
+ }
173
+ if (type === "gsi" && !isName(entry.pk)) {
174
+ fail("EDD-9063", `index "${name}": a "gsi" index requires pk, the attribute carrying the stream's ` +
175
+ "partition key (a non-empty string).");
176
+ }
177
+ if (type === "lsi" && entry.pk !== undefined) {
178
+ fail("EDD-9063", `index "${name}": an "lsi" index uses the table partition key "pk"; remove pk, or ` +
179
+ 'declare type: "gsi".');
180
+ }
181
+ if (typeof entry.key !== "function") {
182
+ fail("EDD-9063", `index "${name}": key must be a function (event, version) => string | undefined.`);
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
+ }
189
+ const index = entry.index;
190
+ const sk = entry.sk;
191
+ const pk = type === "gsi" ? entry.pk : "pk";
192
+ const attributes = type === "gsi" ? [pk, sk] : [sk];
193
+ for (const attribute of attributes) {
194
+ if (STREAM_OWNED_ATTRIBUTES.includes(attribute)) {
195
+ fail("EDD-9064", `index "${name}": attribute "${attribute}" is written by the stream itself ` +
196
+ `(reserved: ${STREAM_OWNED_ATTRIBUTES.join(", ")}). Choose another attribute name.`);
197
+ }
198
+ }
199
+ if (type === "gsi" && pk === sk) {
200
+ fail("EDD-9065", `index "${name}": pk and sk must be different attributes ("${pk}").`);
201
+ }
202
+ for (const other of resolved) {
203
+ if (other.index === index) {
204
+ fail("EDD-9065", `indexes "${other.name}" and "${name}" both use the physical index "${index}".`);
205
+ }
206
+ const otherAttributes = other.type === "gsi" ? [other.pk, other.sk] : [other.sk];
207
+ const shared = attributes.find((attribute) => otherAttributes.includes(attribute));
208
+ if (shared !== undefined) {
209
+ fail("EDD-9065", `indexes "${other.name}" and "${name}" both use the attribute "${shared}".`);
210
+ }
211
+ }
212
+ resolved.push({
213
+ name,
214
+ type,
215
+ index,
216
+ pk,
217
+ sk,
218
+ projection,
219
+ key: entry.key,
220
+ });
221
+ }
222
+ return resolved;
223
+ };
41
224
  // ---------------------------------------------------------------------------
42
225
  // EventStreamTypeId
43
226
  // ---------------------------------------------------------------------------
44
227
  const EventStreamTypeId = Symbol.for("effect-dynamodb/EventStream");
228
+ /**
229
+ * @internal Carries the stream's stream-id formatter (composites joined in
230
+ * declaration order — the `streamId` reported on errors and stored on items),
231
+ * so {@link commandHandler} can report a pre-decide `VersionConflict` exactly
232
+ * as `append` would. Not part of the public interface: `makeStream` and `bind`
233
+ * attach it; a hand-built stream object falls back to joining the id's values.
234
+ */
235
+ const StreamIdFormatter = Symbol.for("effect-dynamodb/EventStream/StreamIdFormatter");
236
+ /** @internal */
237
+ const formatStreamIdOf = (stream, streamId) => {
238
+ const formatter = stream[StreamIdFormatter];
239
+ return formatter !== undefined ? formatter(streamId) : Object.values(streamId).join("#");
240
+ };
241
+ /**
242
+ * @internal Carries a probe for a command's idempotency sentinel (one strongly
243
+ * consistent `GetItem`), so {@link commandHandler} can tell a redelivered
244
+ * command from a lost race when its pre-decide If-Match check fails. Not part
245
+ * of the public interface: `makeStream` and `bind` attach it (a bound stream's
246
+ * probe has its services provided); a hand-built stream object has none.
247
+ */
248
+ const CommandSentinelProbe = Symbol.for("effect-dynamodb/EventStream/CommandSentinelProbe");
249
+ /** @internal */
250
+ const commandSentinelProbeOf = (stream) => stream[CommandSentinelProbe];
251
+ /**
252
+ * @internal Carries a head probe bounded below by a known version: the newest
253
+ * event version above `floorVersion`, or `floorVersion` itself when there is
254
+ * none. {@link commandHandler} uses it to confirm an unverified snapshot is
255
+ * still the head. Unlike `currentVersion`, it never reads the newest event when
256
+ * the snapshot is current — the key range above `floorVersion` is empty, so the
257
+ * `Query` reads no items and costs only DynamoDB's per-request minimum, however
258
+ * large the events are. Attached by `makeStream` and `bind` like
259
+ * {@link CommandSentinelProbe}; a hand-built stream falls back to
260
+ * `currentVersion`.
261
+ */
262
+ const HeadAboveProbe = Symbol.for("effect-dynamodb/EventStream/HeadAboveProbe");
263
+ /** @internal */
264
+ const headAboveProbeOf = (stream) => stream[HeadAboveProbe];
265
+ /**
266
+ * @internal `[EDD-9068]`: `verifySnapshot: false` is accepted only by a stream
267
+ * whose snapshot is written in every append (`mode: "inline"` without `every`),
268
+ * the only mode in which the snapshot is normally at the head. Returns the
269
+ * refusal message, or `undefined` when the option is acceptable — including on
270
+ * a stream without a snapshot config, where it is ignored.
271
+ */
272
+ const unverifiedSnapshotRefusal = (streamName, settings, operation) => settings === undefined || (settings.mode === "inline" && settings.every === undefined)
273
+ ? undefined
274
+ : `[EDD-9068] EventStream "${streamName}": ${operation} with verifySnapshot: false requires ` +
275
+ `snapshot mode "inline" without "every" — the only mode that writes the snapshot in every ` +
276
+ `append, so it is normally at the head. This stream's snapshot is mode "${settings.mode}"` +
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
+ };
45
304
  // ---------------------------------------------------------------------------
46
305
  // makeStream factory
47
306
  // ---------------------------------------------------------------------------
@@ -85,6 +344,10 @@ const EventStreamTypeId = Symbol.for("effect-dynamodb/EventStream");
85
344
  * })
86
345
  * ```
87
346
  *
347
+ * `snapshot.mode: "inline"` writes the snapshot in the append transaction
348
+ * itself, so it is current after every command and state loads in one
349
+ * request — see {@link SnapshotMode}.
350
+ *
88
351
  * `casing` overrides the key casing for this stream, as an index's or a vector
89
352
  * index's `casing` does: the stream name, stream-id values and command ids in
90
353
  * its keys all take it. When omitted, the stream name is lower-cased and the
@@ -95,7 +358,61 @@ const EventStreamTypeId = Symbol.for("effect-dynamodb/EventStream");
95
358
  * (`<stream>.event` etc.) are always lower-cased. In the next major, omitting
96
359
  * `casing` will mean the schema's casing, as it does for indexes.
97
360
  *
361
+ * `indexes` declares sub-streams ordered by a key derived from each event
362
+ * (#140), on a local (default) or global secondary index. `append` writes each
363
+ * event's derived key (and, for a GSI, the stream's partition key) on the
364
+ * event item; snapshots and idempotency sentinels never carry them, so they
365
+ * are never in an index. Read with {@link EventStream.readIndex} or
366
+ * `query.index`, and create the indexes with {@link indexDefinitions}. Each
367
+ * index's `projection` (default `"all"`) sets what it copies from each event
368
+ * and how it is read — see {@link StreamIndexProjection}.
369
+ *
370
+ * Index attributes are written only at append time and events are never
371
+ * rewritten. Declaring an index on a stream that already holds events, or
372
+ * changing an index's `key`, therefore leaves the earlier events out of the
373
+ * index (or under their old keys), and `readIndex` returns only what was
374
+ * indexed — without an error. The library does not backfill; whether and how
375
+ * to re-index existing events (for example, an `UpdateItem` per event setting
376
+ * the derived attributes) is the application's decision.
377
+ *
378
+ * @example
379
+ * ```typescript
380
+ * const Entries = EventStore.makeStream({
381
+ * table: EventsTable,
382
+ * streamName: "Ledger",
383
+ * events: [EntryRecorded, EntryVoided],
384
+ * streamId: { composite: ["ledgerId"] },
385
+ * indexes: {
386
+ * byEntry: {
387
+ * index: "lsi1",
388
+ * sk: "lsi1sk",
389
+ * key: (event, version) =>
390
+ * event._tag === "EntryRecorded" ? `ENTRY#${event.section}#${pad(version)}` : undefined,
391
+ * },
392
+ * // Every event, by the day it was recorded (both event types carry `day`).
393
+ * byDay: { type: "gsi", index: "gsi1", pk: "gsi1pk", sk: "gsi1sk", key: (e) => e.day },
394
+ * },
395
+ * })
396
+ *
397
+ * const sectionThree = yield* Entries.readIndex("byEntry", { ledgerId }, { beginsWith: "ENTRY#3#" })
398
+ * ```
399
+ *
98
400
  * @throws `[EDD-9027]` when `snapshot.every` is not a positive integer.
401
+ * @throws `[EDD-9062]` when `snapshot.mode` is neither `"after-append"` nor
402
+ * `"inline"`.
403
+ * @throws `[EDD-9063]` for a malformed index: a `type` other than `"lsi"` /
404
+ * `"gsi"`, a `gsi` without `pk`, an `lsi` with `pk`, an empty `index` / `sk`
405
+ * / `pk`, a `key` that is not a function, a `projection` other than
406
+ * `"all"` / `"withoutMetadata"` / `"keys"`, or a member an index entry does
407
+ * not declare (such as a misspelled `projection`, also a type error).
408
+ * @throws `[EDD-9064]` when an index attribute is one the stream writes itself
409
+ * (`pk`, `sk`, `__edd_e__`, `streamId`, `version`, `eventType`, `data`,
410
+ * `metadata`, `timestamp`, `asOfVersion`, `state`, `commandId`, or the
411
+ * default TTL attribute `_ttl`). A custom `TableConfig.ttlAttributeName`
412
+ * is only known at runtime: an index attribute equal to it fails each append
413
+ * with `ValidationError`.
414
+ * @throws `[EDD-9065]` when two indexes share a physical index name or an
415
+ * attribute (or a GSI's `pk` and `sk` are the same attribute).
99
416
  */
100
417
  export const makeStream = (config) => {
101
418
  const schema = config.table.schema;
@@ -127,7 +444,7 @@ export const makeStream = (config) => {
127
444
  const commandKeyLabel = `${keyLabel}.command`;
128
445
  const compositeFields = config.streamId.composite;
129
446
  // -------------------------------------------------------------------------
130
- // Snapshot config validation (EDD-9027) — fail fast at definition time.
447
+ // Snapshot config validation (EDD-9027, EDD-9062) — fail fast at definition time.
131
448
  // -------------------------------------------------------------------------
132
449
  const snapshot = config.snapshot;
133
450
  if (snapshot !== undefined && snapshot.every !== undefined) {
@@ -136,7 +453,30 @@ export const makeStream = (config) => {
136
453
  `received ${String(snapshot.every)}.`);
137
454
  }
138
455
  }
139
- const snapshotSettings = snapshot === undefined ? undefined : { every: snapshot.every };
456
+ if (snapshot !== undefined &&
457
+ snapshot.mode !== undefined &&
458
+ snapshot.mode !== "after-append" &&
459
+ snapshot.mode !== "inline") {
460
+ throw new Error(`[EDD-9062] EventStream "${config.streamName}": snapshot.mode must be "after-append" or ` +
461
+ `"inline"; received ${JSON.stringify(snapshot.mode)}.`);
462
+ }
463
+ const snapshotSettings = snapshot === undefined
464
+ ? undefined
465
+ : { mode: snapshot.mode ?? "after-append", every: snapshot.every };
466
+ // Stream indexes (#140) — validated at definition time (EDD-9063..9065).
467
+ const streamIndexes = resolveStreamIndexes(config.streamName, config.indexes);
468
+ const indexSettings = {};
469
+ for (const idx of streamIndexes) {
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
+ };
477
+ }
478
+ /** Every attribute the indexes write on an event item. */
479
+ const indexAttributes = streamIndexes.flatMap((idx) => idx.type === "gsi" ? [idx.pk, idx.sk] : [idx.sk]);
140
480
  // Build union schema from event schemas for decoding
141
481
  const eventUnion = config.events.length === 1
142
482
  ? config.events[0]
@@ -151,6 +491,8 @@ export const makeStream = (config) => {
151
491
  return DynamoSchema.composeKey(schema, keyLabel, composites, keyOptions);
152
492
  };
153
493
  const composeEventSk = (version) => DynamoSchema.composeEventVersionKey(schema, eventKeyLabel, version, keyOptions);
494
+ /** Sort key of a command's idempotency sentinel. */
495
+ const composeCommandSk = (commandId) => DynamoSchema.composeKey(schema, commandKeyLabel, [commandId], keyOptions);
154
496
  /**
155
497
  * Every event SK begins with this; nothing else in the stream partition does.
156
498
  * Bounding event reads to it excludes the snapshot item at the key-condition
@@ -164,6 +506,13 @@ export const makeStream = (config) => {
164
506
  * The snapshot SK. Distinct entity-type label (`<stream>.snapshot` vs
165
507
  * `<stream>.event_1#…`), so it can never collide with an event SK, and it
166
508
  * sorts after every event in the partition.
509
+ *
510
+ * The three stream-owned SKs share `<prefix>#<label>.` and differ first at
511
+ * the literal suffix: `.command#<id>` < `.event_1#<version>` < `.snapshot`.
512
+ * The suffixes are lower-case, and a stream `casing` applies to all three
513
+ * alike (`"uppercase"` gives `.COMMAND` < `.EVENT_1` < `.SNAPSHOT`), so the
514
+ * order holds under every casing. `readLatest` relies on it: the range
515
+ * `[eventSkPrefix, snapshotSk]` holds exactly the events and the snapshot.
167
516
  */
168
517
  const snapshotSk = DynamoSchema.composeKey(schema, snapshotKeyLabel, [], keyOptions);
169
518
  const composeStreamIdString = (streamId) => compositeFields.map((f) => streamId[f]).join("#");
@@ -191,10 +540,10 @@ export const makeStream = (config) => {
191
540
  * `decode → encode` fallback for inputs already in encoded shape. Mirrors
192
541
  * the Entity write path (`encodeOrDecodeEncode` in `Entity.ts`).
193
542
  */
194
- const encodeToWire = (codec, input, operation) => Schema.encodeUnknownEffect(codec)(input).pipe(Effect.catch((primaryCause) => Schema.decodeUnknownEffect(codec)(input).pipe(Effect.flatMap((decoded) => Schema.encodeUnknownEffect(codec)(decoded)),
543
+ const encodeToWire = (codec, input, operation, errorEntityType = entityType) => Schema.encodeUnknownEffect(codec)(input).pipe(Effect.catch((primaryCause) => Schema.decodeUnknownEffect(codec)(input).pipe(Effect.flatMap((decoded) => Schema.encodeUnknownEffect(codec)(decoded)),
195
544
  // Surface the original encode error — its message is keyed on the
196
545
  // caller's input shape, which is what the user expects to see.
197
- Effect.catch(() => Effect.fail(new ValidationError({ entityType, operation, cause: primaryCause }))))));
546
+ Effect.catch(() => Effect.fail(new ValidationError({ entityType: errorEntityType, operation, cause: primaryCause }))))));
198
547
  // ---------------------------------------------------------------------------
199
548
  // Decode a raw DynamoDB item → StreamEvent<TEvent>
200
549
  // ---------------------------------------------------------------------------
@@ -225,47 +574,268 @@ export const makeStream = (config) => {
225
574
  // ---------------------------------------------------------------------------
226
575
  // append
227
576
  // ---------------------------------------------------------------------------
577
+ /**
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`).
584
+ */
585
+ const deriveIndexKey = (idx, event, version) => Effect.try({
586
+ try: () => idx.key(event, version),
587
+ catch: (cause) => new ValidationError({ entityType, operation: "EventStore.append.indexes", cause }),
588
+ }).pipe(Effect.flatMap((derived) => {
589
+ if (derived === undefined)
590
+ return Effect.succeed(undefined);
591
+ const refuse = (problem) => Effect.fail(new ValidationError({
592
+ entityType,
593
+ operation: "EventStore.append.indexes",
594
+ cause: `The key of index "${idx.name}" for the event at version ${version} ${problem}. ` +
595
+ "Nothing was written.",
596
+ }));
597
+ // 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)}`);
603
+ }
604
+ return Effect.succeed(derived);
605
+ }));
606
+ /**
607
+ * Encode snapshot state to wire form through the state schema, with the
608
+ * same `decode → encode` fallback as events and metadata. A structurally
609
+ * matching plain object — what an immutable `evolve` that spreads a
610
+ * `Schema.Class` state returns (`({ ...s, balance })`), which TypeScript
611
+ * accepts as the class type — is decoded into the schema's type first
612
+ * rather than refused with `Expected <Class>`.
613
+ */
614
+ const encodeSnapshotState = (state, operation) => encodeToWire(snapshot.schema, state, operation, snapshotEntityType);
615
+ /**
616
+ * Map a failed `TransactWriteItems` of an append to its verdict. Resolves to
617
+ * the race a guarded additional put lost (so the transaction is built and
618
+ * written again), and fails with every other verdict.
619
+ */
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({
624
+ streamName: config.streamName,
625
+ streamId: streamIdStr,
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,
644
+ }));
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({
651
+ streamName: config.streamName,
652
+ streamId: streamIdStr,
653
+ commandId,
654
+ }));
655
+ }
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",
691
+ reasons,
692
+ cause: error.cause,
693
+ }));
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,
707
+ }));
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
+ };
228
746
  const append = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
229
747
  const additionalOps = options?.additionalItems ?? [];
230
748
  const idempotency = options?.idempotency;
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
+ }
786
+ if (snapshotState !== undefined && snapshot === undefined) {
787
+ return yield* snapshotUnavailable("append({ snapshot })");
788
+ }
231
789
  // Nothing at all to write — preserve the historical no-op fast path.
232
790
  // With additional items or a dedup sentinel the transaction still runs:
233
791
  // a caller who asked for a side write means it, and silently dropping it
234
792
  // would lose data.
235
- if (events.length === 0 && additionalOps.length === 0 && idempotency === undefined) {
793
+ if (events.length === 0 &&
794
+ additionalOps.length === 0 &&
795
+ idempotency === undefined &&
796
+ snapshotState === undefined) {
236
797
  return { version: expectedVersion, events: [] };
237
798
  }
238
799
  // Resolve stream ID string for storage (join composites)
239
800
  const streamIdStr = composeStreamIdString(streamId);
240
- // Guard: every item the transaction will carry — one Put per event, the
241
- // items each caller-supplied additional op compiles to, the idempotency
242
- // sentinel, and the version-contiguity ConditionCheck when
243
- // expectedVersion > 0 — must fit DynamoDB's TransactWriteItems limit.
244
- // Never chunk: chunking would break append atomicity.
245
- //
246
- // This first check is a LOWER BOUND, counting one item per additional op.
247
- // Expansion (uniqueness sentinels, version snapshots — #113) only ever
248
- // adds items, so an append that already fails here can never fit, and
249
- // failing now keeps an oversized append free. The authoritative check runs
250
- // once the ops are compiled, below.
251
- const needsContiguityCheck = expectedVersion > 0 && events.length > 0;
252
- const fixedItems = events.length + (idempotency !== undefined ? 1 : 0) + (needsContiguityCheck ? 1 : 0);
253
- if (fixedItems + additionalOps.length > TRANSACT_WRITE_ITEMS_LIMIT) {
254
- return yield* new AppendTooLarge({
255
- streamName: config.streamName,
256
- streamId: streamIdStr,
257
- count: fixedItems + additionalOps.length,
258
- limit: TRANSACT_WRITE_ITEMS_LIMIT,
801
+ // An inline snapshot is unconditional because the event puts prove this
802
+ // writer owns its version. Without events nothing proves it, and an
803
+ // unconditional put could regress the snapshot.
804
+ if (snapshotState !== undefined && events.length === 0) {
805
+ return yield* new ValidationError({
806
+ entityType: snapshotEntityType,
807
+ operation: "EventStore.append.snapshot",
808
+ cause: "An inline snapshot requires at least one event: nothing else proves the writer " +
809
+ `owns version ${expectedVersion}. Nothing was written.`,
259
810
  });
260
811
  }
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.
816
+ const needsContiguityCheck = expectedVersion > 0 && events.length > 0;
261
817
  const client = yield* DynamoClient;
262
818
  const tableConfig = yield* config.table.Tag;
263
819
  const tableName = tableConfig.name;
820
+ // A custom TTL attribute is only known at runtime. An index key attribute
821
+ // sharing its name would receive the sentinel's numeric TTL, which a
822
+ // string-typed index key rejects — refuse it before anything is written.
823
+ const ttlAttribute = resolveTtlAttributeName(tableConfig);
824
+ if (indexAttributes.includes(ttlAttribute)) {
825
+ return yield* new ValidationError({
826
+ entityType,
827
+ operation: "EventStore.append.indexes",
828
+ cause: `An index of stream "${config.streamName}" uses the attribute "${ttlAttribute}", ` +
829
+ "which is the table's TTL attribute. Choose another index attribute name. " +
830
+ "Nothing was written.",
831
+ });
832
+ }
264
833
  const pk = composeStreamPk(streamId);
265
834
  // Clock-backed timestamp (deterministic under TestClock; wall-clock in prod).
266
835
  const nowDateTime = yield* DateTime.now;
267
836
  const now = DateTime.formatIso(nowDateTime);
268
- // 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.
269
839
  let encodedMetadata;
270
840
  if (options?.metadata !== undefined && metadataSchema) {
271
841
  const encoded = yield* encodeToWire(metadataSchema, options.metadata, "EventStore.append.metadata");
@@ -289,7 +859,25 @@ export const makeStream = (config) => {
289
859
  // Inject _tag for plain Schema.Class events; Schema.TaggedClass
290
860
  // events already carry _tag in their encoded form, which wins.
291
861
  const eventData = { _tag: evtType, ...wire };
292
- 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 = {
293
881
  pk,
294
882
  sk: composeEventSk(version),
295
883
  __edd_e__: entityType,
@@ -297,10 +885,22 @@ export const makeStream = (config) => {
297
885
  version,
298
886
  eventType: evtType,
299
887
  data: eventData,
300
- timestamp: now,
888
+ timestamp: eventTimestamp === undefined ? now : DateTime.formatIso(eventTimestamp),
301
889
  };
302
- if (encodedMetadata !== undefined) {
303
- item.metadata = encodedMetadata;
890
+ const item = { ...envelope };
891
+ if (itemMetadata !== undefined) {
892
+ item.metadata = itemMetadata;
893
+ }
894
+ // Stream indexes (#140): the derived key, stored raw, and for a
895
+ // GSI the stream's partition key. The attributes are part of the
896
+ // event item itself, so they count towards the size checks.
897
+ for (const idx of streamIndexes) {
898
+ const derived = yield* deriveIndexKey(idx, event, version);
899
+ if (derived === undefined)
900
+ continue;
901
+ item[idx.sk] = derived;
902
+ if (idx.type === "gsi")
903
+ item[idx.pk] = pk;
304
904
  }
305
905
  return {
306
906
  Put: {
@@ -310,6 +910,129 @@ export const makeStream = (config) => {
310
910
  },
311
911
  };
312
912
  }));
913
+ // The idempotency sentinel: a Put guarded by `attribute_not_exists(pk)`.
914
+ let sentinelPut;
915
+ if (idempotency !== undefined) {
916
+ const sentinel = {
917
+ pk,
918
+ sk: composeCommandSk(idempotency.commandId),
919
+ __edd_e__: commandEntityType,
920
+ streamId: streamIdStr,
921
+ commandId: idempotency.commandId,
922
+ version: expectedVersion + events.length,
923
+ timestamp: now,
924
+ };
925
+ if (idempotency.ttl !== undefined) {
926
+ const ttlSeconds = yield* Effect.try({
927
+ try: () => normalizeTtlSeconds(idempotency.ttl),
928
+ catch: (cause) => new ValidationError({
929
+ entityType: commandEntityType,
930
+ operation: "EventStore.append.idempotency.ttl",
931
+ cause,
932
+ }),
933
+ });
934
+ sentinel[resolveTtlAttributeName(tableConfig)] =
935
+ DateTime.toEpochSeconds(nowDateTime) + ttlSeconds;
936
+ }
937
+ sentinelPut = {
938
+ Put: {
939
+ TableName: tableName,
940
+ Item: toAttributeMap(sentinel),
941
+ ConditionExpression: "attribute_not_exists(pk)",
942
+ },
943
+ };
944
+ }
945
+ // The inline snapshot (#138): unconditional — see AppendOptions.snapshot.
946
+ let snapshotPut;
947
+ if (snapshotState !== undefined) {
948
+ const encoded = yield* encodeSnapshotState(snapshotState, "EventStore.append.snapshot");
949
+ snapshotPut = {
950
+ Put: {
951
+ TableName: tableName,
952
+ Item: toAttributeMap({
953
+ pk,
954
+ sk: snapshotSk,
955
+ __edd_e__: snapshotEntityType,
956
+ streamId: streamIdStr,
957
+ asOfVersion: expectedVersion + events.length,
958
+ state: encoded,
959
+ timestamp: now,
960
+ }),
961
+ },
962
+ };
963
+ }
964
+ /**
965
+ * Version-contiguity guard: `attribute_not_exists(pk)` on the event puts
966
+ * only rejects STALE expected versions (the target slot already exists).
967
+ * An AHEAD expectedVersion (e.g. 10 when the stream is at 3) would
968
+ * silently write from version 11, leaving a permanent gap. When events
969
+ * are written after version > 0, require the event at exactly
970
+ * `expectedVersion` to exist so the appended range is contiguous with the
971
+ * stream head. Its failure surfaces as a ConditionalCheckFailed
972
+ * cancellation reason, mapping to VersionConflict just like a
973
+ * stale-version Put failure. A zero-event append (a pure side write)
974
+ * writes no version and so can open no gap.
975
+ */
976
+ const contiguityCheck = needsContiguityCheck
977
+ ? {
978
+ ConditionCheck: {
979
+ TableName: tableName,
980
+ Key: toAttributeMap({ pk, sk: composeEventSk(expectedVersion) }),
981
+ ConditionExpression: "attribute_exists(pk)",
982
+ },
983
+ }
984
+ : undefined;
985
+ /**
986
+ * Lay out the append's one transaction — see {@link AppendTransaction}:
987
+ * [0, C) version-contiguity ConditionCheck (C is 0 or 1)
988
+ * [C, C + E) event puts
989
+ * [C + E, C + E + A) additional ITEMS (caller op order preserved)
990
+ * then idempotency sentinel, then inline snapshot
991
+ * (last, so adding them never shifts the
992
+ * additional-item indices the caller sees)
993
+ *
994
+ * `A` is the count of EMITTED items, which is >= the number of caller
995
+ * ops: a guarded put (#133) expands into its item plus its sentinel
996
+ * reservations and releases plus a snapshot. The caller-facing `indices`
997
+ * on `AdditionalItemConditionFailed` are still indices into the caller's
998
+ * `additionalItems` array — `judgeCancellation` maps them.
999
+ */
1000
+ const layout = (additional) => {
1001
+ const items = [];
1002
+ // What names each item, so the targets are built only on failure.
1003
+ const naming = [];
1004
+ const pushStreamItem = (item, type, source) => {
1005
+ items.push(item);
1006
+ naming.push(() => [transactItemTarget(item, tableName, ["pk", "sk"], type, source)]);
1007
+ };
1008
+ if (contiguityCheck !== undefined) {
1009
+ pushStreamItem(contiguityCheck, entityType, "the version-contiguity check");
1010
+ }
1011
+ const guardCount = items.length;
1012
+ for (const [i, item] of eventItems.entries()) {
1013
+ pushStreamItem(item, entityType, `the event at version ${expectedVersion + i + 1}`);
1014
+ }
1015
+ items.push(...additional.items);
1016
+ naming.push(additional.targets);
1017
+ const sentinelIndex = sentinelPut === undefined ? -1 : items.length;
1018
+ if (sentinelPut !== undefined) {
1019
+ pushStreamItem(sentinelPut, entityType, "the idempotency sentinel");
1020
+ }
1021
+ if (snapshotPut !== undefined) {
1022
+ pushStreamItem(snapshotPut, snapshotEntityType, "the inline snapshot");
1023
+ }
1024
+ let targets;
1025
+ return {
1026
+ items,
1027
+ // Built once, on failure: a diagnosis's target is found by identity.
1028
+ targets: () => (targets ??= naming.flatMap((name) => name())),
1029
+ guardCount,
1030
+ eventCount: eventItems.length,
1031
+ additional,
1032
+ sentinelIndex,
1033
+ expectedVersion,
1034
+ };
1035
+ };
313
1036
  // A guarded put among the additional items reads its item; a race with
314
1037
  // that read cancels the append, which is then built and written again.
315
1038
  let lost;
@@ -317,174 +1040,15 @@ export const makeStream = (config) => {
317
1040
  // Caller-owned items, compiled through the same builder
318
1041
  // `Transaction.transactWrite` uses, so the two APIs cannot drift.
319
1042
  const additional = yield* buildTransactWriteItems(additionalOps, "EventStore.append.additionalItems");
320
- const additionalItems = additional.items;
321
- // Authoritative cap check: one additional op can compile to several items
322
- // (#113), so the pre-flight lower bound above is not sufficient. Reporting
323
- // the EXPANDED count is the point — "you passed 40 items" when the caller
324
- // passed 30 ops is baffling without it.
325
- if (fixedItems + additionalItems.length > TRANSACT_WRITE_ITEMS_LIMIT) {
326
- return yield* new AppendTooLarge({
327
- streamName: config.streamName,
328
- streamId: streamIdStr,
329
- count: fixedItems + additionalItems.length,
330
- limit: TRANSACT_WRITE_ITEMS_LIMIT,
331
- });
332
- }
333
- // Version-contiguity guard: `attribute_not_exists(pk)` on the event puts
334
- // only rejects STALE expected versions (the target slot already exists).
335
- // An AHEAD expectedVersion (e.g. 10 when the stream is at 3) would
336
- // silently write from version 11, leaving a permanent gap. When
337
- // expectedVersion > 0, require the event at exactly `expectedVersion` to
338
- // exist so the appended range is contiguous with the stream head. Its
339
- // failure surfaces as a ConditionalCheckFailed cancellation reason,
340
- // mapping to VersionConflict below just like a stale-version Put failure.
341
- //
342
- // Only when events are actually being written: the guard exists to stop an
343
- // AHEAD expectedVersion opening a permanent gap, and a zero-event append
344
- // (pure `additionalItems` / sentinel side-write) writes no version and so
345
- // can open no gap.
346
- const contiguityCheck = needsContiguityCheck
347
- ? [
348
- {
349
- ConditionCheck: {
350
- TableName: tableName,
351
- Key: toAttributeMap({ pk, sk: composeEventSk(expectedVersion) }),
352
- ConditionExpression: "attribute_exists(pk)",
353
- },
354
- },
355
- ]
356
- : [];
357
- // Item layout is load-bearing — cancellation reasons are positional:
358
- // [0, C) version-contiguity ConditionCheck (C is 0 or 1)
359
- // [C, C + E) event puts
360
- // [C + E, C + E + A) additional ITEMS (caller op order preserved)
361
- // C + E + A idempotency sentinel (last, so adding it never
362
- // shifts the additional-item indices the caller sees)
363
- //
364
- // `A` is the count of EMITTED items, which is >= the number of caller ops:
365
- // a guarded put (#133) expands into its item plus its sentinel
366
- // reservations and releases plus a snapshot. The 1:1 "item index == caller
367
- // index" assumption is gone, so the reason mapping below goes through
368
- // `judgeCancellation` — the caller-facing `indices` on
369
- // `AdditionalItemConditionFailed` are still indices into the caller's
370
- // `additionalItems` array, unchanged.
371
- const transactItems = [
372
- ...contiguityCheck,
373
- ...eventItems,
374
- ...additionalItems,
375
- ];
376
- const checkCount = contiguityCheck.length;
377
- const eventCount = eventItems.length;
378
- const sentinelIndex = idempotency !== undefined ? transactItems.length : -1;
379
- if (idempotency !== undefined) {
380
- const sentinel = {
381
- pk,
382
- sk: DynamoSchema.composeKey(schema, commandKeyLabel, [idempotency.commandId], keyOptions),
383
- __edd_e__: commandEntityType,
384
- streamId: streamIdStr,
385
- commandId: idempotency.commandId,
386
- version: expectedVersion + events.length,
387
- timestamp: now,
388
- };
389
- if (idempotency.ttl !== undefined) {
390
- const ttlSeconds = yield* Effect.try({
391
- try: () => normalizeTtlSeconds(idempotency.ttl),
392
- catch: (cause) => new ValidationError({
393
- entityType: commandEntityType,
394
- operation: "EventStore.append.idempotency.ttl",
395
- cause,
396
- }),
397
- });
398
- sentinel[resolveTtlAttributeName(tableConfig)] =
399
- DateTime.toEpochSeconds(nowDateTime) + ttlSeconds;
400
- }
401
- transactItems.push({
402
- Put: {
403
- TableName: tableName,
404
- Item: toAttributeMap(sentinel),
405
- ConditionExpression: "attribute_not_exists(pk)",
406
- },
407
- });
408
- }
409
- // Checked before anything is sent: one op per item — an additional item
410
- // repeating an event, the contiguity check or the idempotency sentinel
411
- // is refused, as additional items repeating each other are — and the
412
- // whole transaction within DynamoDB's 4 MB (#133).
413
- const additionalStart = checkCount + eventCount;
414
- const streamItemSource = (i) => i < checkCount
415
- ? "the version-contiguity check"
416
- : i < additionalStart
417
- ? `the event at version ${expectedVersion + i - checkCount + 1}`
418
- : "the idempotency sentinel";
419
- const targets = transactItems.map((item, i) => i >= additionalStart && i < additionalStart + additionalItems.length
420
- ? additional.targets[i - additionalStart]
421
- : transactItemTarget(item, tableName, ["pk", "sk"], entityType, streamItemSource(i)));
422
- yield* refuseRepeatedItems(targets, "EventStore.append");
423
- yield* refuseOversizedTransaction(transactItems, targets, "EventStore.append");
424
- const outcome = yield* client.transactWriteItems({ TransactItems: transactItems }).pipe(Effect.as(undefined), Effect.catch((error) => {
425
- if (!isAwsTransactionCancelled(error.cause)) {
426
- return Effect.fail(error);
427
- }
428
- const rawReasons = error.cause.CancellationReasons ?? [];
429
- const reasons = rawReasons.map((r) => ({
430
- code: r?.Code,
431
- message: r?.Message,
432
- }));
433
- const failedAt = (index) => index >= 0 && reasons[index]?.code === "ConditionalCheckFailed";
434
- // Precedence is ordered by how terminal the caller's response should
435
- // be: a duplicate can never succeed on retry, a version conflict
436
- // invites a re-read, and only then are the additional items judged.
437
- if (idempotency !== undefined && failedAt(sentinelIndex)) {
438
- return Effect.fail(new DuplicateCommand({
439
- streamName: config.streamName,
440
- streamId: streamIdStr,
441
- commandId: idempotency.commandId,
442
- }));
443
- }
444
- // The contiguity ConditionCheck and the event puts both mean "the
445
- // stream is not where you said it was", so they share one verdict.
446
- for (let i = 0; i < checkCount + eventCount; i++) {
447
- if (failedAt(i)) {
448
- return Effect.fail(new VersionConflict({
449
- streamName: config.streamName,
450
- streamId: streamIdStr,
451
- expectedVersion,
452
- }));
453
- }
454
- }
455
- // The additional items, attributed back to the caller OPS that
456
- // produced them (several items can belong to one op). Only an op's
457
- // own condition is `AdditionalItemConditionFailed`: a guarded put
458
- // the caller set no condition on reports what the entity's own put
459
- // would — a taken unique value, a history conflict — or, having
460
- // lost a race to a concurrent write, is written again (#133).
461
- const judged = judgeCancellation(additional, rawReasons, checkCount + eventCount);
462
- if (judged?._tag === "fail")
463
- return Effect.fail(judged.error);
464
- if (judged?._tag === "conditions") {
465
- return Effect.fail(new AdditionalItemConditionFailed({
466
- streamName: config.streamName,
467
- streamId: streamIdStr,
468
- indices: judged.opIndices,
469
- reasons,
470
- }));
471
- }
472
- if (judged?._tag === "retry")
473
- return Effect.succeed(judged.error);
474
- // No conditional failure we can positionally justify (throttling,
475
- // TransactionConflict, or a truncated/absent reason list) — never
476
- // guess a VersionConflict.
477
- return Effect.fail(new TransactionCancelled({
478
- operation: "TransactWriteItems",
479
- reasons,
480
- cause: error.cause,
481
- }));
482
- }));
1043
+ const tx = layout(additional);
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).
1049
+ const outcome = yield* sendAppendTransaction(client, tx, streamIdStr, idempotency?.commandId);
483
1050
  if (outcome === undefined) {
484
- return {
485
- version: expectedVersion + events.length,
486
- events,
487
- };
1051
+ return { version: expectedVersion + events.length, events };
488
1052
  }
489
1053
  lost = outcome;
490
1054
  }
@@ -493,37 +1057,49 @@ export const makeStream = (config) => {
493
1057
  // ---------------------------------------------------------------------------
494
1058
  // read
495
1059
  // ---------------------------------------------------------------------------
496
- const read = (streamId) => Effect.gen(function* () {
497
- const query = buildEventsQuery(streamId);
1060
+ /** Apply {@link ReadOptions} to an event query. */
1061
+ const withReadOptions = (query, options) => options?.consistentRead === true ? Query.consistentRead(query) : query;
1062
+ const read = (streamId, options) => Effect.gen(function* () {
1063
+ const query = withReadOptions(buildEventsQuery(streamId), options);
498
1064
  return yield* Query.collect(query);
499
1065
  });
500
1066
  // ---------------------------------------------------------------------------
501
1067
  // readFrom
502
1068
  // ---------------------------------------------------------------------------
503
- const readFrom = (streamId, afterVersion) => Effect.gen(function* () {
1069
+ const readFrom = (streamId, afterVersion, options) => Effect.gen(function* () {
504
1070
  // Versions are integers, so the inclusive lower bound `afterVersion + 1`
505
1071
  // is exactly the old exclusive `#sk > eventSk(afterVersion)`. The upper
506
1072
  // bound keeps the snapshot item (which sorts after every event) out of the
507
1073
  // scanned range.
508
- const query = buildEventsQuery(streamId).pipe(Query.where({ between: [composeEventSk(afterVersion + 1), maxEventSk] }));
1074
+ const query = withReadOptions(buildEventsQuery(streamId).pipe(Query.where({ between: [composeEventSk(afterVersion + 1), maxEventSk] })), options);
509
1075
  return yield* Query.collect(query);
510
1076
  });
511
1077
  // ---------------------------------------------------------------------------
512
1078
  // currentVersion
513
1079
  // ---------------------------------------------------------------------------
514
- const currentVersion = (streamId) => Effect.gen(function* () {
1080
+ const currentVersion = (streamId, options) => Effect.gen(function* () {
515
1081
  // Single page, not `collect`: with `Limit: 1` DynamoDB returns a
516
1082
  // `LastEvaluatedKey` on every truncated page, so `collect` would walk the
517
1083
  // whole partition one request per item. The `begins_with` bound on
518
1084
  // `buildEventsQuery` guarantees the single evaluated item is the newest
519
1085
  // *event* (never the snapshot, which sorts last).
520
- const query = buildEventsQuery(streamId).pipe(Query.reverse, Query.limit(1));
1086
+ const query = withReadOptions(buildEventsQuery(streamId).pipe(Query.reverse, Query.limit(1)), options);
521
1087
  const page = yield* Query.execute(query);
522
1088
  const newest = page.items[0];
523
1089
  if (newest === undefined)
524
1090
  return 0;
525
1091
  return newest.version;
526
1092
  });
1093
+ // headAbove (internal — see HeadAboveProbe)
1094
+ const headAbove = (streamId, floorVersion, options) => Effect.gen(function* () {
1095
+ // Reverse, `Limit: 1`, bounded below by `floorVersion + 1`: an empty range
1096
+ // (the common case — nothing appended past the snapshot) reads no items;
1097
+ // otherwise the single evaluated item is the head.
1098
+ const query = withReadOptions(buildEventsQuery(streamId).pipe(Query.where({ between: [composeEventSk(floorVersion + 1), maxEventSk] }), Query.reverse, Query.limit(1)), options);
1099
+ const page = yield* Query.execute(query);
1100
+ const newest = page.items[0];
1101
+ return newest === undefined ? floorVersion : newest.version;
1102
+ });
527
1103
  // ---------------------------------------------------------------------------
528
1104
  // Snapshot primitives
529
1105
  // ---------------------------------------------------------------------------
@@ -535,11 +1111,7 @@ export const makeStream = (config) => {
535
1111
  const client = yield* DynamoClient;
536
1112
  const { name: tableName } = yield* config.table.Tag;
537
1113
  const now = DateTime.formatIso(yield* DateTime.now);
538
- const encoded = yield* Schema.encodeUnknownEffect(snapshot.schema)(state).pipe(Effect.mapError((cause) => new ValidationError({
539
- entityType: snapshotEntityType,
540
- operation: "EventStore.writeSnapshot",
541
- cause,
542
- })));
1114
+ const encoded = yield* encodeSnapshotState(state, "EventStore.writeSnapshot");
543
1115
  const item = {
544
1116
  pk: composeStreamPk(streamId),
545
1117
  sk: snapshotSk,
@@ -549,8 +1121,7 @@ export const makeStream = (config) => {
549
1121
  state: encoded,
550
1122
  timestamp: now,
551
1123
  };
552
- yield* client
553
- .putItem({
1124
+ const put = {
554
1125
  TableName: tableName,
555
1126
  Item: toAttributeMap(item),
556
1127
  // Monotonic: never regress the cache. Losing this race is a no-op,
@@ -558,12 +1129,33 @@ export const makeStream = (config) => {
558
1129
  ConditionExpression: "attribute_not_exists(#pk) OR #asOfVersion < :asOfVersion",
559
1130
  ExpressionAttributeNames: { "#pk": "pk", "#asOfVersion": "asOfVersion" },
560
1131
  ExpressionAttributeValues: toAttributeMap({ ":asOfVersion": asOfVersion }),
561
- })
562
- .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
+ }));
563
1147
  });
564
- const readSnapshot = (streamId) => Effect.gen(function* () {
565
- if (snapshot === undefined)
566
- return yield* snapshotUnavailable("readSnapshot");
1148
+ /**
1149
+ * Decode a stored snapshot item. A snapshot that fails to decode through the
1150
+ * state schema is a `ValidationError` — never silently discarded.
1151
+ */
1152
+ const decodeSnapshotItem = (raw, operation) => Schema.decodeUnknownEffect(snapshot.schema)(raw.state).pipe(Effect.mapError((cause) => new ValidationError({ entityType: snapshotEntityType, operation, cause })), Effect.map((state) => ({
1153
+ state,
1154
+ asOfVersion: raw.asOfVersion,
1155
+ timestamp: raw.timestamp,
1156
+ })));
1157
+ /** The raw snapshot item, by its exact key, in one `GetItem`. */
1158
+ const getSnapshotItem = (streamId, consistentRead) => Effect.gen(function* () {
567
1159
  const client = yield* DynamoClient;
568
1160
  const { name: tableName } = yield* config.table.Tag;
569
1161
  const result = yield* client.getItem({
@@ -572,21 +1164,140 @@ export const makeStream = (config) => {
572
1164
  pk: composeStreamPk(streamId),
573
1165
  sk: snapshotSk,
574
1166
  }),
575
- ConsistentRead: true,
1167
+ ...(consistentRead ? { ConsistentRead: true } : {}),
576
1168
  });
577
- if (result.Item === undefined)
1169
+ return result.Item === undefined ? undefined : fromAttributeMap(result.Item);
1170
+ });
1171
+ const readSnapshot = (streamId) => Effect.gen(function* () {
1172
+ if (snapshot === undefined)
1173
+ return yield* snapshotUnavailable("readSnapshot");
1174
+ const raw = yield* getSnapshotItem(streamId, true);
1175
+ if (raw === undefined)
578
1176
  return Option.none();
579
- const raw = fromAttributeMap(result.Item);
580
- const state = yield* Schema.decodeUnknownEffect(snapshot.schema)(raw.state).pipe(Effect.mapError((cause) => new ValidationError({
581
- entityType: snapshotEntityType,
582
- operation: "EventStore.readSnapshot",
583
- cause,
584
- })));
585
- return Option.some({
586
- state,
587
- asOfVersion: raw.asOfVersion,
588
- timestamp: raw.timestamp,
1177
+ return Option.some(yield* decodeSnapshotItem(raw, "EventStore.readSnapshot"));
1178
+ });
1179
+ // ---------------------------------------------------------------------------
1180
+ // readLatest — snapshot + delta + head in one request (#138)
1181
+ // ---------------------------------------------------------------------------
1182
+ const readLatest = (streamId, options) => Effect.gen(function* () {
1183
+ // Without a snapshot config there is nothing to read but the events
1184
+ // (`verifySnapshot` is ignored).
1185
+ if (snapshot === undefined) {
1186
+ const events = yield* read(streamId, options);
1187
+ return {
1188
+ snapshot: Option.none(),
1189
+ events,
1190
+ version: events[events.length - 1]?.version ?? 0,
1191
+ };
1192
+ }
1193
+ // `verifySnapshot: false` — the snapshot item alone, by its exact key:
1194
+ // a `GetItem` reads one item where the verified `Query` reads the
1195
+ // snapshot and at least the newest event. Its version is unverified
1196
+ // (see `ReadLatestOptions`). Without a snapshot item there is nothing to
1197
+ // trust, so fall through to the verified read.
1198
+ if (options?.verifySnapshot === false) {
1199
+ const refusal = unverifiedSnapshotRefusal(config.streamName, snapshotSettings, "readLatest");
1200
+ if (refusal !== undefined)
1201
+ return yield* Effect.die(new Error(refusal));
1202
+ const raw = yield* getSnapshotItem(streamId, options.consistentRead === true);
1203
+ if (raw !== undefined) {
1204
+ const current = yield* decodeSnapshotItem(raw, "EventStore.readLatest");
1205
+ return {
1206
+ snapshot: Option.some(current),
1207
+ events: [],
1208
+ version: current.asOfVersion,
1209
+ };
1210
+ }
1211
+ }
1212
+ const client = yield* DynamoClient;
1213
+ const { name: tableName } = yield* config.table.Tag;
1214
+ // One reverse Query over `[first event SK, snapshot SK]`: the snapshot
1215
+ // sorts after every event and the command sentinels before them (see
1216
+ // `snapshotSk`), so the range holds exactly the events and the snapshot,
1217
+ // and the snapshot is the first item evaluated. The `__edd_e__` filter is
1218
+ // belt and braces — `Limit` counts items before it applies.
1219
+ const base = {
1220
+ TableName: tableName,
1221
+ KeyConditionExpression: "#pk = :pk AND #sk BETWEEN :first AND :snapshot",
1222
+ FilterExpression: "#e IN (:eventType, :snapshotType)",
1223
+ ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk", "#e": "__edd_e__" },
1224
+ ExpressionAttributeValues: toAttributeMap({
1225
+ ":pk": composeStreamPk(streamId),
1226
+ ":first": eventSkPrefix,
1227
+ ":snapshot": snapshotSk,
1228
+ ":eventType": entityType,
1229
+ ":snapshotType": snapshotEntityType,
1230
+ }),
1231
+ ScanIndexForward: false,
1232
+ ...(options?.consistentRead === true ? { ConsistentRead: true } : {}),
1233
+ };
1234
+ let snapshotRaw;
1235
+ /** Event items, newest first. */
1236
+ const eventRaws = [];
1237
+ // The first page is sized for the snapshot plus a lag of up to `every`
1238
+ // events, so a current (or modestly lagging) snapshot loads in one
1239
+ // request.
1240
+ let limit = (snapshot.every ?? 1) + 1;
1241
+ let startKey;
1242
+ for (;;) {
1243
+ const page = yield* client.query({
1244
+ ...base,
1245
+ ...(limit !== undefined ? { Limit: limit } : {}),
1246
+ ...(startKey !== undefined ? { ExclusiveStartKey: startKey } : {}),
1247
+ });
1248
+ for (const item of page.Items ?? []) {
1249
+ const raw = fromAttributeMap(item);
1250
+ if (raw.__edd_e__ === snapshotEntityType)
1251
+ snapshotRaw = raw;
1252
+ else
1253
+ eventRaws.push(raw);
1254
+ }
1255
+ startKey = page.LastEvaluatedKey;
1256
+ if (startKey === undefined)
1257
+ break;
1258
+ // Without a snapshot (it would have been the first item evaluated),
1259
+ // read on, unlimited, to the start of the stream.
1260
+ if (snapshotRaw === undefined) {
1261
+ limit = undefined;
1262
+ continue;
1263
+ }
1264
+ // Done once the event right after the snapshot has been seen: versions
1265
+ // are contiguous, so every event the snapshot does not cover is then in
1266
+ // hand, and everything older is already folded into it. Otherwise read
1267
+ // exactly the events still missing, `asOfVersion + 1 … oldest - 1`
1268
+ // (DynamoDB's 1 MB page cap aside).
1269
+ const asOfVersion = snapshotRaw.asOfVersion;
1270
+ const oldest = eventRaws[eventRaws.length - 1]?.version;
1271
+ if (oldest !== undefined && oldest <= asOfVersion + 1)
1272
+ break;
1273
+ limit = oldest === undefined ? undefined : oldest - asOfVersion - 1;
1274
+ }
1275
+ const latest = snapshotRaw === undefined
1276
+ ? Option.none()
1277
+ : Option.some(yield* decodeSnapshotItem(snapshotRaw, "EventStore.readLatest"));
1278
+ const asOfVersion = Option.isSome(latest) ? latest.value.asOfVersion : 0;
1279
+ const delta = eventRaws.filter((raw) => raw.version > asOfVersion).reverse();
1280
+ const events = yield* Effect.forEach(delta, (raw) => decodeStreamEvent(raw));
1281
+ return {
1282
+ snapshot: latest,
1283
+ events,
1284
+ version: events[events.length - 1]?.version ?? asOfVersion,
1285
+ };
1286
+ });
1287
+ // ---------------------------------------------------------------------------
1288
+ // hasCommandSentinel (internal — see CommandSentinelProbe)
1289
+ // ---------------------------------------------------------------------------
1290
+ const hasCommandSentinel = (streamId, commandId) => Effect.gen(function* () {
1291
+ const client = yield* DynamoClient;
1292
+ const { name: tableName } = yield* config.table.Tag;
1293
+ const result = yield* client.getItem({
1294
+ TableName: tableName,
1295
+ Key: toAttributeMap({ pk: composeStreamPk(streamId), sk: composeCommandSk(commandId) }),
1296
+ ConsistentRead: true,
1297
+ ProjectionExpression: "#pk",
1298
+ ExpressionAttributeNames: { "#pk": "pk" },
589
1299
  });
1300
+ return result.Item !== undefined;
590
1301
  });
591
1302
  // ---------------------------------------------------------------------------
592
1303
  // query.events helper
@@ -608,8 +1319,118 @@ export const makeStream = (config) => {
608
1319
  // `Query.where` replaces this — the `__edd_e__` filter still applies.
609
1320
  }).pipe(Query.where({ beginsWith: eventSkPrefix }));
610
1321
  };
1322
+ // ---------------------------------------------------------------------------
1323
+ // Stream indexes — query.index / readIndex (#140)
1324
+ // ---------------------------------------------------------------------------
1325
+ /**
1326
+ * A query of one stream index, scoped to the stream's partition: the LSI
1327
+ * shares the table's `pk`; a GSI's `pk` attribute carries the same value.
1328
+ * No sort-key condition — the index holds only this stream's indexed events
1329
+ * under that key; `Query.where` narrows the derived-key range.
1330
+ *
1331
+ * @throws `[EDD-9066]` for a name the stream does not declare (unreachable
1332
+ * through the types).
1333
+ */
1334
+ const buildIndexQuery = (name, streamId) => {
1335
+ const idx = streamIndexes.find((candidate) => candidate.name === name);
1336
+ if (idx === undefined) {
1337
+ throw new Error(`[EDD-9066] EventStream "${config.streamName}": unknown index "${String(name)}". ` +
1338
+ (streamIndexes.length === 0
1339
+ ? "The stream declares no indexes."
1340
+ : `Declared: ${streamIndexes.map((candidate) => `"${candidate.name}"`).join(", ")}.`));
1341
+ }
1342
+ return Query.make({
1343
+ tableName: "",
1344
+ indexName: idx.index,
1345
+ pkField: idx.pk,
1346
+ pkValue: composeStreamPk(streamId),
1347
+ skField: idx.sk,
1348
+ entityTypes: [entityType],
1349
+ decoder: indexDecoder(idx),
1350
+ resolveTableName: config.table.Tag.useSync((tc) => tc.name),
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
+ },
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
+ : {}),
1369
+ });
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
+ };
1406
+ const readIndex = (name, streamId, options) => Effect.gen(function* () {
1407
+ const refuse = (cause) => new ValidationError({ entityType, operation: "EventStore.readIndex", cause });
1408
+ const invalidCondition = invalidIndexKeyCondition(options);
1409
+ if (invalidCondition !== undefined) {
1410
+ return yield* refuse(`${invalidCondition} Nothing was sent.`);
1411
+ }
1412
+ const limit = options?.limit;
1413
+ if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) {
1414
+ return yield* refuse(`limit must be a positive integer; received ${String(limit)}. Nothing was sent.`);
1415
+ }
1416
+ let query = buildIndexQuery(name, streamId);
1417
+ if (options?.beginsWith !== undefined) {
1418
+ query = Query.where(query, { beginsWith: options.beginsWith });
1419
+ }
1420
+ else if (options?.between !== undefined) {
1421
+ query = Query.where(query, { between: options.between });
1422
+ }
1423
+ if (options?.reverse === true)
1424
+ query = Query.reverse(query);
1425
+ if (limit !== undefined)
1426
+ query = Query.limit(query, limit);
1427
+ if (options?.consistentRead === true)
1428
+ query = Query.consistentRead(query);
1429
+ return yield* Query.collect(query);
1430
+ });
611
1431
  const queryNamespace = {
612
1432
  events: (streamId) => buildEventsQuery(streamId),
1433
+ index: (name, streamId) => buildIndexQuery(name, streamId),
613
1434
  };
614
1435
  // ---------------------------------------------------------------------------
615
1436
  // Return EventStream
@@ -628,13 +1449,19 @@ export const makeStream = (config) => {
628
1449
  streamName: config.streamName,
629
1450
  eventSchema: eventUnion,
630
1451
  snapshotConfig: snapshotSettings,
1452
+ indexes: indexSettings,
631
1453
  writeSnapshot,
632
1454
  readSnapshot,
633
1455
  append,
634
1456
  read,
635
1457
  readFrom,
636
1458
  currentVersion,
1459
+ readLatest,
1460
+ readIndex,
637
1461
  query: queryNamespace,
1462
+ [StreamIdFormatter]: composeStreamIdString,
1463
+ [CommandSentinelProbe]: hasCommandSentinel,
1464
+ [HeadAboveProbe]: headAbove,
638
1465
  };
639
1466
  };
640
1467
  // ---------------------------------------------------------------------------
@@ -657,6 +1484,8 @@ export const makeStream = (config) => {
657
1484
  export const bind = (stream) => Effect.gen(function* () {
658
1485
  const ctx = yield* Effect.context();
659
1486
  const provide = (effect) => Effect.provide(effect, ctx);
1487
+ const probe = commandSentinelProbeOf(stream);
1488
+ const headProbe = headAboveProbeOf(stream);
660
1489
  return {
661
1490
  [EventStreamTypeId]: EventStreamTypeId,
662
1491
  pipe() {
@@ -666,106 +1495,528 @@ export const bind = (stream) => Effect.gen(function* () {
666
1495
  streamName: stream.streamName,
667
1496
  eventSchema: stream.eventSchema,
668
1497
  snapshotConfig: stream.snapshotConfig,
1498
+ indexes: stream.indexes,
669
1499
  writeSnapshot: (streamId, state, asOfVersion) => provide(stream.writeSnapshot(streamId, state, asOfVersion)),
670
1500
  readSnapshot: (streamId) => provide(stream.readSnapshot(streamId)),
671
1501
  append: (streamId, events, expectedVersion, options) => provide(stream.append(streamId, events, expectedVersion, options)),
672
- read: (streamId) => provide(stream.read(streamId)),
673
- readFrom: (streamId, afterVersion) => provide(stream.readFrom(streamId, afterVersion)),
674
- currentVersion: (streamId) => provide(stream.currentVersion(streamId)),
1502
+ read: (streamId, options) => provide(stream.read(streamId, options)),
1503
+ readFrom: (streamId, afterVersion, options) => provide(stream.readFrom(streamId, afterVersion, options)),
1504
+ currentVersion: (streamId, options) => provide(stream.currentVersion(streamId, options)),
1505
+ readLatest: (streamId, options) => provide(stream.readLatest(streamId, options)),
1506
+ readIndex: (name, streamId, options) => provide(stream.readIndex(name, streamId, options)),
675
1507
  query: stream.query,
676
1508
  provide,
1509
+ [StreamIdFormatter]: (id) => formatStreamIdOf(stream, id),
1510
+ [CommandSentinelProbe]: probe === undefined
1511
+ ? undefined
1512
+ : (id, commandId) => provide(probe(id, commandId)),
1513
+ [HeadAboveProbe]: headProbe === undefined
1514
+ ? undefined
1515
+ : (id, floorVersion, options) => provide(headProbe(id, floorVersion, options)),
677
1516
  };
678
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
+ };
1532
+ /**
1533
+ * Derive the `CreateTable` fragments for the stream indexes (#140) of the given
1534
+ * streams (`EventStream` or `BoundEventStream`) — `AttributeDefinitions`,
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).
1555
+ *
1556
+ * An index shared by several streams with the same definition is emitted
1557
+ * once. Lists are sorted by index name, attributes by attribute name.
1558
+ *
1559
+ * - An LSI can only be created with the table, and caps the item collection
1560
+ * of every partition key value in the table at 10 GB — entity partitions
1561
+ * and streams without indexes included.
1562
+ *
1563
+ * @example
1564
+ * ```typescript
1565
+ * const fragments = EventStore.indexDefinitions(Entries, Orders)
1566
+ * yield* client.createTable({
1567
+ * TableName: "events",
1568
+ * BillingMode: "PAY_PER_REQUEST",
1569
+ * KeySchema: [
1570
+ * { AttributeName: "pk", KeyType: "HASH" },
1571
+ * { AttributeName: "sk", KeyType: "RANGE" },
1572
+ * ],
1573
+ * AttributeDefinitions: [
1574
+ * { AttributeName: "pk", AttributeType: "S" },
1575
+ * { AttributeName: "sk", AttributeType: "S" },
1576
+ * ...fragments.AttributeDefinitions,
1577
+ * ],
1578
+ * LocalSecondaryIndexes: fragments.LocalSecondaryIndexes,
1579
+ * GlobalSecondaryIndexes: fragments.GlobalSecondaryIndexes,
1580
+ * })
1581
+ * ```
1582
+ *
1583
+ * @throws `[EDD-9067]` when two streams define the same physical index
1584
+ * differently (another type, other key attributes, or another projection).
1585
+ */
1586
+ export const indexDefinitions = (...streams) => {
1587
+ const byIndex = new Map();
1588
+ for (const stream of streams) {
1589
+ for (const [name, raw] of Object.entries(stream.indexes)) {
1590
+ const settings = { ...raw, projection: raw.projection ?? "all" };
1591
+ const owner = `index "${name}" of stream "${stream.streamName}"`;
1592
+ const existing = byIndex.get(settings.index);
1593
+ if (existing === undefined) {
1594
+ byIndex.set(settings.index, {
1595
+ type: settings.type,
1596
+ index: settings.index,
1597
+ pk: settings.pk,
1598
+ sk: settings.sk,
1599
+ projection: settings.projection,
1600
+ owner,
1601
+ });
1602
+ continue;
1603
+ }
1604
+ if (existing.type !== settings.type ||
1605
+ existing.pk !== settings.pk ||
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}")`;
1609
+ throw new Error(`[EDD-9067] EventStore.indexDefinitions: the physical index "${settings.index}" is ` +
1610
+ `defined as ${describe(existing)} by ${existing.owner} and as ${describe(settings)} ` +
1611
+ `by ${owner}.`);
1612
+ }
1613
+ }
1614
+ }
1615
+ const attributes = new Set();
1616
+ const lsis = [];
1617
+ const gsis = [];
1618
+ const sorted = [...byIndex.values()].sort((a, b) => a.index.localeCompare(b.index));
1619
+ for (const settings of sorted) {
1620
+ const definition = {
1621
+ IndexName: settings.index,
1622
+ KeySchema: [
1623
+ { AttributeName: settings.pk, KeyType: "HASH" },
1624
+ { AttributeName: settings.sk, KeyType: "RANGE" },
1625
+ ],
1626
+ Projection: projectionDefinition(settings.projection),
1627
+ };
1628
+ attributes.add(settings.sk);
1629
+ if (settings.type === "gsi") {
1630
+ attributes.add(settings.pk);
1631
+ gsis.push(definition);
1632
+ }
1633
+ else {
1634
+ lsis.push(definition);
1635
+ }
1636
+ }
1637
+ return {
1638
+ AttributeDefinitions: [...attributes]
1639
+ .sort()
1640
+ .map((name) => ({ AttributeName: name, AttributeType: "S" })),
1641
+ ...(lsis.length > 0 ? { LocalSecondaryIndexes: lsis } : {}),
1642
+ ...(gsis.length > 0 ? { GlobalSecondaryIndexes: gsis } : {}),
1643
+ };
1644
+ };
679
1645
  /** @internal Both `EventStream` and `BoundEventStream` carry this brand. */
680
1646
  const hasEventStreamBrand = (u) => typeof u === "object" && u !== null && EventStreamTypeId in u;
1647
+ /** @internal Resolve a {@link CommandOptionsBase.additionalItems} against a decision. */
1648
+ const deriveAdditionalItems = (input, decision) => {
1649
+ if (typeof input !== "function")
1650
+ return Effect.succeed(input);
1651
+ const derived = input(decision);
1652
+ return Effect.isEffect(derived) ? derived : Effect.succeed(derived);
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
+ };
681
1668
  /** @internal */
682
1669
  const makeCommandHandlerImpl = (decider, stream, options) => {
1670
+ // `verifySnapshot: false` is refused up front unless the stream writes its
1671
+ // snapshot in every append (EDD-9068) — at handler construction, so a
1672
+ // misconfiguration fails at startup rather than on the first command.
1673
+ const snapshotSettings = stream.snapshotConfig;
1674
+ if (options?.verifySnapshot === false) {
1675
+ const refusal = unverifiedSnapshotRefusal(stream.streamName, snapshotSettings, "commandHandler");
1676
+ if (refusal !== undefined)
1677
+ throw new Error(refusal);
1678
+ }
1679
+ // Ignored on a stream without a snapshot config: there is no snapshot to trust.
1680
+ const unverifiedLoads = options?.verifySnapshot === false && snapshotSettings !== undefined;
683
1681
  const retryPolicy = options?.retry;
684
1682
  const schedule = retryPolicy === undefined
685
1683
  ? undefined
686
1684
  : typeof retryPolicy === "number"
687
1685
  ? Schedule.recurs(retryPolicy)
688
1686
  : retryPolicy;
1687
+ // Consistent by default: an eventually consistent load can hand `decide`
1688
+ // state that misses acknowledged events (#139).
1689
+ const readOptions = { consistentRead: options?.consistentRead ?? true };
1690
+ const unverifiedReadOptions = { ...readOptions, verifySnapshot: false };
689
1691
  return (streamId, command, callOptions) => {
690
- const attempt = Effect.gen(function* () {
691
- // Backstop for JS callers and `any`-shaped call sites: silently degrading
692
- // to at-least-once would look like success right up until the day a
693
- // duplicate mattered. Not retryable — `while` below only retries
694
- // `VersionConflict`, so this surfaces on the first attempt.
695
- if (options?.idempotency !== undefined && callOptions?.commandId === undefined) {
696
- return yield* new ValidationError({
697
- entityType: stream.streamName,
698
- operation: "EventStore.commandHandler",
699
- cause: "commandId is required when commandHandler is configured with `idempotency`.",
700
- });
701
- }
702
- const snapshotSettings = stream.snapshotConfig;
703
- // 1. Establish the base state + version — from a snapshot plus its delta
704
- // when the stream has snapshots enabled and one exists, otherwise from
705
- // a full replay.
1692
+ const expectedVersion = callOptions?.expectedVersion;
1693
+ // 1. Establish the base state + version. A snapshot-configured stream
1694
+ // loads its snapshot, the events after it and its head in one request
1695
+ // (#138) — or, unverified, the snapshot item alone; any other stream
1696
+ // replays from the beginning.
1697
+ const load = (verify) => Effect.gen(function* () {
706
1698
  let state = decider.initialState;
707
- let baseVersion = 0;
708
- let snapshotAsOfVersion = 0;
709
- const snapshot = snapshotSettings === undefined
710
- ? Option.none()
711
- : (yield* stream.readSnapshot(streamId));
712
- if (Option.isSome(snapshot)) {
713
- snapshotAsOfVersion = snapshot.value.asOfVersion;
714
- baseVersion = snapshot.value.asOfVersion;
715
- state = snapshot.value.state;
716
- const delta = yield* stream.readFrom(streamId, snapshot.value.asOfVersion);
717
- for (const event of delta) {
718
- state = decider.evolve(state, event.data);
1699
+ if (snapshotSettings !== undefined) {
1700
+ const latest = (yield* stream.readLatest(streamId, verify ? readOptions : unverifiedReadOptions));
1701
+ let snapshotAsOfVersion = 0;
1702
+ if (Option.isSome(latest.snapshot)) {
1703
+ snapshotAsOfVersion = latest.snapshot.value.asOfVersion;
1704
+ state = latest.snapshot.value.state;
719
1705
  }
720
- const newest = delta[delta.length - 1];
721
- if (newest !== undefined)
722
- baseVersion = newest.version;
723
- }
724
- else {
725
- const events = yield* stream.read(streamId);
726
- for (const event of events) {
1706
+ for (const event of latest.events) {
727
1707
  state = decider.evolve(state, event.data);
728
1708
  }
729
- const newest = events[events.length - 1];
730
- baseVersion = newest === undefined ? 0 : newest.version;
1709
+ const loaded = {
1710
+ state,
1711
+ version: latest.version,
1712
+ snapshotAsOfVersion,
1713
+ // An unverified read returns a snapshot alone; without one it has
1714
+ // already fallen back to the verified read.
1715
+ verified: verify || Option.isNone(latest.snapshot),
1716
+ eventsAfterSnapshot: latest.events.length,
1717
+ };
1718
+ return loaded;
1719
+ }
1720
+ const events = yield* stream.read(streamId, readOptions);
1721
+ for (const event of events) {
1722
+ state = decider.evolve(state, event.data);
731
1723
  }
732
- // 2. Decide
733
- const newEvents = yield* decider.decide(command, state);
734
- // 3. No-op command — return current state
1724
+ const loaded = {
1725
+ state,
1726
+ version: events[events.length - 1]?.version ?? 0,
1727
+ snapshotAsOfVersion: 0,
1728
+ verified: true,
1729
+ eventsAfterSnapshot: events.length,
1730
+ };
1731
+ return loaded;
1732
+ });
1733
+ // 4–8. Commit one decision: fold, derive items, append, snapshot. With
1734
+ // `returnAppendConflict`, an append that fails with `VersionConflict`
1735
+ // returns the conflict instead of failing, so the caller can tell a
1736
+ // stale unverified load from a lost race.
1737
+ const commit = (loaded, newEvents, returnAppendConflict) => Effect.gen(function* () {
1738
+ const baseVersion = loaded.version;
1739
+ const state = loaded.state;
1740
+ // 4. No-op command — return current state
735
1741
  if (newEvents.length === 0) {
736
- return { state, version: baseVersion, events: [] };
1742
+ const noop = {
1743
+ state,
1744
+ version: baseVersion,
1745
+ events: [],
1746
+ };
1747
+ return noop;
1748
+ }
1749
+ // 5. Fold the new events BEFORE appending (#137): the post-decision
1750
+ // state is what a function-form `additionalItems` projects, what a
1751
+ // snapshot records, and what the handler returns — always the
1752
+ // `evolve` fold, never anything produced inside `decide`.
1753
+ const previous = state;
1754
+ let next = state;
1755
+ for (const event of newEvents) {
1756
+ next = decider.evolve(next, event);
1757
+ }
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 = {
1763
+ events: newEvents,
1764
+ state: next,
1765
+ previous,
1766
+ version: baseVersion,
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
+ });
737
1780
  }
738
- // 4. Append with optimistic concurrency, plus any caller-owned items and
739
- // the dedup sentinel, all in one transaction.
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);
1796
+ // 7. Append with optimistic concurrency, plus the caller's items, the
1797
+ // dedup sentinel and an inline snapshot, all in one transaction.
1798
+ // `baseVersion` equals any caller-supplied `expectedVersion` here
1799
+ // (checked in step 2).
740
1800
  const appendOptions = {};
741
1801
  if (callOptions?.metadata !== undefined)
742
1802
  appendOptions.metadata = callOptions.metadata;
743
- if (callOptions?.additionalItems !== undefined) {
744
- appendOptions.additionalItems = callOptions.additionalItems;
745
- }
1803
+ if (eventMetadata !== undefined)
1804
+ appendOptions.eventMetadata = eventMetadata;
1805
+ if (eventTimestamps !== undefined)
1806
+ appendOptions.eventTimestamps = eventTimestamps;
1807
+ if (additionalItems !== undefined)
1808
+ appendOptions.additionalItems = additionalItems;
746
1809
  if (options?.idempotency !== undefined && callOptions?.commandId !== undefined) {
747
1810
  appendOptions.idempotency =
748
1811
  options.idempotency.ttl !== undefined
749
1812
  ? { commandId: callOptions.commandId, ttl: options.idempotency.ttl }
750
1813
  : { commandId: callOptions.commandId };
751
1814
  }
752
- const result = yield* stream.append(streamId, newEvents, baseVersion, appendOptions);
753
- // 5. Evolve state through the new events
754
- for (const event of newEvents) {
755
- state = decider.evolve(state, event);
756
- }
757
- // 6. Auto-snapshot once the cadence threshold is crossed. Best-effort:
758
- // the events are already durable, so a snapshot-write failure must not
759
- // report the command as failed. The next threshold crossing retries it.
1815
+ // Inline snapshots (#138) ride in the append transaction: on every
1816
+ // append, or once `every` events have accumulated since the snapshot.
1817
+ const mode = snapshotSettings?.mode ?? "after-append";
760
1818
  const every = snapshotSettings?.every;
761
- if (every !== undefined && result.version - snapshotAsOfVersion >= every) {
1819
+ const newVersion = baseVersion + newEvents.length;
1820
+ if (snapshotSettings !== undefined &&
1821
+ mode === "inline" &&
1822
+ (every === undefined || newVersion - loaded.snapshotAsOfVersion >= every)) {
1823
+ appendOptions.snapshot = next;
1824
+ }
1825
+ const appended = stream.append(streamId, newEvents, baseVersion, appendOptions);
1826
+ const outcome = yield* returnAppendConflict
1827
+ ? appended.pipe(Effect.catchIf((error) => error instanceof VersionConflict, (conflict) => Effect.succeed(conflict)))
1828
+ : appended;
1829
+ if (outcome instanceof VersionConflict)
1830
+ return outcome;
1831
+ const result = outcome;
1832
+ // 8. After-append snapshots, once the cadence threshold is crossed.
1833
+ // Best-effort: the events are already durable, so a snapshot-write
1834
+ // failure must not report the command as failed. The next threshold
1835
+ // crossing retries it.
1836
+ if (mode === "after-append" &&
1837
+ every !== undefined &&
1838
+ result.version - loaded.snapshotAsOfVersion >= every) {
762
1839
  yield* stream
763
- .writeSnapshot(streamId, state, result.version)
1840
+ .writeSnapshot(streamId, next, result.version)
764
1841
  .pipe(Effect.catchCause((cause) => Effect.logWarning(`EventStore: snapshot write failed for stream "${stream.streamName}" at version ${result.version}`, cause)));
765
1842
  }
766
- return { state, version: result.version, events: newEvents };
1843
+ const done = {
1844
+ state: next,
1845
+ version: result.version,
1846
+ events: newEvents,
1847
+ };
1848
+ return done;
1849
+ });
1850
+ // The If-Match answer for a stream at `actualVersion` (#136). A
1851
+ // redelivery of a command that already committed (its response was lost)
1852
+ // arrives with its original If-Match, which the stream has since moved
1853
+ // past. It is a duplicate, not a lost race — `append` ranks
1854
+ // `DuplicateCommand` above `VersionConflict` for the same reason — so
1855
+ // consult the sentinel before reporting the conflict. The extra read is
1856
+ // paid on this conflict path only.
1857
+ const ifMatchMismatch = (expected, actualVersion) => Effect.gen(function* () {
1858
+ const probe = commandSentinelProbeOf(stream);
1859
+ const commandId = callOptions?.commandId;
1860
+ if (options?.idempotency !== undefined && commandId !== undefined && probe !== undefined) {
1861
+ if (yield* probe(streamId, commandId)) {
1862
+ return yield* new DuplicateCommand({
1863
+ streamName: stream.streamName,
1864
+ streamId: formatStreamIdOf(stream, streamId),
1865
+ commandId,
1866
+ });
1867
+ }
1868
+ }
1869
+ return yield* new VersionConflict({
1870
+ streamName: stream.streamName,
1871
+ streamId: formatStreamIdOf(stream, streamId),
1872
+ expectedVersion: expected,
1873
+ actualVersion,
1874
+ });
767
1875
  });
768
- return schedule === undefined
1876
+ // The head, known to be at least `floorVersion` (see HeadAboveProbe): one
1877
+ // `Query` that reads no items while the snapshot is current.
1878
+ const headAbove = (floorVersion) => {
1879
+ const probe = headAboveProbeOf(stream);
1880
+ return probe !== undefined
1881
+ ? probe(streamId, floorVersion, readOptions)
1882
+ : stream.currentVersion(streamId, readOptions);
1883
+ };
1884
+ const attempt = Effect.gen(function* () {
1885
+ // Backstop for JS callers and `any`-shaped call sites: silently degrading
1886
+ // to at-least-once would look like success right up until the day a
1887
+ // duplicate mattered. Not retryable — `while` below only retries
1888
+ // `VersionConflict`, so this surfaces on the first attempt.
1889
+ if (options?.idempotency !== undefined && callOptions?.commandId === undefined) {
1890
+ return yield* new ValidationError({
1891
+ entityType: stream.streamName,
1892
+ operation: "EventStore.commandHandler",
1893
+ cause: "commandId is required when commandHandler is configured with `idempotency`.",
1894
+ });
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
+ }
1927
+ // A malformed If-Match (`NaN` from a failed parse, a negative or
1928
+ // fractional number) is a caller bug, not a conflict — refuse it before
1929
+ // reading anything.
1930
+ if (expectedVersion !== undefined &&
1931
+ (!Number.isInteger(expectedVersion) || expectedVersion < 0)) {
1932
+ return yield* new ValidationError({
1933
+ entityType: stream.streamName,
1934
+ operation: "EventStore.commandHandler",
1935
+ cause: `expectedVersion must be a non-negative integer; received ${String(expectedVersion)}.`,
1936
+ });
1937
+ }
1938
+ // 1. Load (see `load`).
1939
+ let loaded = yield* load(!unverifiedLoads);
1940
+ // An unverified snapshot at another version than the caller's If-Match
1941
+ // may merely lag the head: re-read verified, so only a genuine mismatch
1942
+ // is reported, with the verified head as its `actualVersion`.
1943
+ if (expectedVersion !== undefined && !loaded.verified && loaded.version !== expectedVersion) {
1944
+ loaded = yield* load(true);
1945
+ }
1946
+ // 2. If-Match (#136): the caller saw a different version, so `decide`
1947
+ // must not run against state the caller never saw.
1948
+ if (expectedVersion !== undefined && expectedVersion !== loaded.version) {
1949
+ return yield* ifMatchMismatch(expectedVersion, loaded.version);
1950
+ }
1951
+ // 3. Decide on a verified load, and commit. An If-Match call's
1952
+ // append-time conflict is its answer (never re-read or retried), and
1953
+ // a verified load's conflict is a lost race.
1954
+ if (loaded.verified) {
1955
+ const newEvents = yield* decider.decide(command, loaded.state);
1956
+ return (yield* commit(loaded, newEvents, false));
1957
+ }
1958
+ // 3'. Decide on an unverified snapshot. It is normally at the head, but
1959
+ // events appended without one (a raw `append`, data written before
1960
+ // inline mode) make it lag — and then every answer made on it is
1961
+ // stale, not only one that appends. So no answer is returned until
1962
+ // the head is confirmed: an append confirms it by succeeding; a
1963
+ // decision that appends nothing (a `decide` failure or a no-op) and
1964
+ // an append conflict are confirmed with one more read.
1965
+ const decided = yield* Effect.result(decider.decide(command, loaded.state));
1966
+ const first = Result.isSuccess(decided) && decided.success.length > 0
1967
+ ? yield* commit(loaded, decided.success, true)
1968
+ : undefined;
1969
+ if (first !== undefined && !(first instanceof VersionConflict))
1970
+ return first;
1971
+ // The answer the first decision gives when the snapshot proves current.
1972
+ const firstAnswer = first !== undefined
1973
+ ? Effect.fail(first)
1974
+ : Result.isFailure(decided)
1975
+ ? Effect.fail(decided.failure)
1976
+ : Effect.succeed({ state: loaded.state, version: loaded.version, events: [] });
1977
+ // With an If-Match equal to the snapshot's `asOfVersion`, the snapshot
1978
+ // state is exactly the state the caller saw, so `decide` ran on the
1979
+ // right state — the only question is whether the stream is still at
1980
+ // that version. A newer head answers exactly as a verified load would
1981
+ // have (the pre-decide check above): `DuplicateCommand` for a committed
1982
+ // redelivery, else `VersionConflict` with the head as `actualVersion`.
1983
+ // The first decision is discarded. Never re-decided: an If-Match call's
1984
+ // conflict is its answer.
1985
+ if (expectedVersion !== undefined) {
1986
+ const head = yield* headAbove(expectedVersion);
1987
+ if (head !== expectedVersion)
1988
+ return yield* ifMatchMismatch(expectedVersion, head);
1989
+ return yield* firstAnswer;
1990
+ }
1991
+ // No If-Match, nothing appended: a head at the snapshot's version
1992
+ // confirms the answer (one `Query` above the snapshot's version, which
1993
+ // reads no items while the snapshot is current).
1994
+ if (first === undefined) {
1995
+ const head = yield* headAbove(loaded.version);
1996
+ if (head === loaded.version)
1997
+ return yield* firstAnswer;
1998
+ }
1999
+ // No If-Match, the append conflicted — the snapshot lagged the head, or
2000
+ // another writer won the race; one verified load tells them apart. A
2001
+ // snapshot at the head is a genuine race, which the retry policy
2002
+ // handles as it always has (a retried attempt loads unverified again).
2003
+ // A lagging snapshot is decided on again, once: only a successful inline
2004
+ // append rewrites the snapshot, so surfacing the conflict would fail
2005
+ // every command on the stream until something else repaired it.
2006
+ const verified = yield* load(true);
2007
+ if (first !== undefined && verified.eventsAfterSnapshot === 0)
2008
+ return yield* first;
2009
+ // The first decision was made on stale state: decide again on the
2010
+ // verified state — `decide` (and a function-form `additionalItems`)
2011
+ // runs a second time in this attempt. Its own conflict follows the
2012
+ // retry policy.
2013
+ const newEvents = yield* decider.decide(command, verified.state);
2014
+ return (yield* commit(verified, newEvents, false));
2015
+ });
2016
+ // A caller-supplied `expectedVersion` is a conditional write: its
2017
+ // `VersionConflict` (pre-decide or from the append) is the answer, never a
2018
+ // cue to re-read and re-decide.
2019
+ return schedule === undefined || expectedVersion !== undefined
769
2020
  ? attempt
770
2021
  : Effect.retry(attempt, {
771
2022
  // `while` runs before the schedule, so the schedule only ever sees a
@@ -805,12 +2056,69 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
805
2056
  * idempotency: { ttl: Duration.days(1) },
806
2057
  * })
807
2058
  * yield* handle({ matchId: "m-1" }, command, { commandId: "cmd-7f3a" })
2059
+ *
2060
+ * // Load state from the inline snapshot alone (mode "inline" without `every`)
2061
+ * const handle = EventStore.commandHandler(decider, stream, { verifySnapshot: false })
2062
+ *
2063
+ * // If-Match: fail with VersionConflict (never retried) unless the stream is at v7
2064
+ * yield* handle({ matchId: "m-1" }, command, { expectedVersion: 7 })
2065
+ *
2066
+ * // Inline projection, committed in the same transaction as the events
2067
+ * yield* handle({ matchId: "m-1" }, command, {
2068
+ * additionalItems: ({ state }) => [Scoreboard.put({ matchId: "m-1", runs: state.runs })],
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
+ * })
808
2088
  * ```
809
2089
  *
810
- * When the stream declares a `snapshot` config, each invocation reads the
811
- * snapshot and folds only the events after it, instead of replaying the stream
812
- * from the beginning. With `snapshot.every` set, a fresh snapshot is written
813
- * (best-effort) after a successful append once the cadence threshold is crossed.
2090
+ * Each invocation runs
2091
+ * `load → [expectedVersion check] → decide → fold new events → derive items → append → [snapshot]`.
2092
+ *
2093
+ * - **Load.** Strongly consistent by default — see
2094
+ * {@link CommandHandlerOptions.consistentRead}. When the stream declares a
2095
+ * `snapshot` config, state is loaded with {@link EventStream.readLatest} —
2096
+ * the snapshot, the events after it and the head in one request in steady
2097
+ * state — and only those events are folded, instead of replaying the stream
2098
+ * from the beginning. On a `mode: "inline"` stream without `every`,
2099
+ * {@link CommandHandlerOptions.verifySnapshot}`: false` loads the snapshot
2100
+ * item alone (one `GetItem`); an answer that is not a successful append is
2101
+ * confirmed against the head first, and a snapshot that proves stale falls
2102
+ * back to the verified load — in which case `decide` may run twice.
2103
+ * - **expectedVersion.** See {@link CommandOptionsBase.expectedVersion}.
2104
+ * - **Fold before append.** The new events are folded into state before the
2105
+ * append, so the state returned, snapshotted and handed to a function-form
2106
+ * `additionalItems` (see {@link Decision}) is always the `evolve` fold.
2107
+ * - **Snapshot.** With `snapshot.mode: "inline"`, the snapshot is written in
2108
+ * the append transaction itself — on every append, or at the `every`
2109
+ * cadence. With the default `"after-append"` and `snapshot.every` set, a
2110
+ * fresh snapshot is written (best-effort) after a successful append once the
2111
+ * cadence threshold is crossed. See {@link SnapshotMode}.
2112
+ * - **One atomic append.** One command, one decision, one append: a decision
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
2115
+ * commands — each an ordinary call, chained by `expectedVersion` — so a
2116
+ * failure partway leaves the stream at a real intermediate state.
2117
+ *
2118
+ * The handler is generic per call over the error and requirements of a
2119
+ * function-form `additionalItems` that returns an `Effect`: they join the
2120
+ * handler's error channel and requirements (on a `BoundEventStream` handler
2121
+ * they are its only requirements).
814
2122
  *
815
2123
  * Without `idempotency`, command processing is **at-least-once**: a retry after
816
2124
  * an acked-but-lost response re-runs `decide` and appends again.