effect-dynamodb 1.23.0 → 1.24.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.
@@ -4,10 +4,17 @@
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 — `readIndex`, `query.index`,
17
+ * and `indexDefinitions` for the `CreateTable` fragments
11
18
  * - `fold` / `foldFrom` helpers for state reconstruction
12
19
  *
13
20
  * Built on the existing library primitives (DynamoSchema, KeyComposer, Query,
@@ -17,12 +24,12 @@ import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
17
24
  import { normalizeTtlSeconds } from "@effect-dynamodb/schema/Entity.js";
18
25
  import { AdditionalItemConditionFailed, AppendTooLarge, DuplicateCommand, isAwsConditionalCheckFailed, isAwsTransactionCancelled, TRANSACT_WRITE_ITEMS_LIMIT, TransactionCancelled, ValidationError, VersionConflict, } from "@effect-dynamodb/schema/Errors.js";
19
26
  import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
20
- import { DateTime, Effect, Function, Option, Pipeable, Schedule, Schema, } from "effect";
27
+ import { DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
21
28
  import { DynamoClient } from "./DynamoClient.js";
22
29
  import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, judgeCancellation, refuseOversizedTransaction, refuseRepeatedItems, transactItemTarget, } from "./internal/TransactWriteOps.js";
23
30
  import { fromAttributeMap, toAttributeMap } from "./Marshaller.js";
24
31
  import * as Query from "./Query.js";
25
- import { resolveTtlAttributeName } from "./Table.js";
32
+ import { DEFAULT_TTL_ATTRIBUTE_NAME, resolveTtlAttributeName, } from "./Table.js";
26
33
  // ---------------------------------------------------------------------------
27
34
  // Envelope schema — validates the persisted event's system fields on read
28
35
  // ---------------------------------------------------------------------------
@@ -38,10 +45,209 @@ const EventEnvelope = Schema.Struct({
38
45
  eventType: Schema.String,
39
46
  timestamp: Schema.String,
40
47
  });
48
+ /** @internal DynamoDB's limit on a sort key value, which an index key is. */
49
+ const MAX_INDEX_SORT_KEY_BYTES = 1024;
50
+ /** @internal */
51
+ const utf8 = new TextEncoder();
52
+ /**
53
+ * @internal Compare two strings by their UTF-8 bytes — the order DynamoDB
54
+ * sorts (and evaluates `BETWEEN` on) string keys. JavaScript's `<` compares
55
+ * UTF-16 code units, which disagrees for characters outside the BMP.
56
+ */
57
+ const compareUtf8 = (a, b) => {
58
+ const left = utf8.encode(a);
59
+ const right = utf8.encode(b);
60
+ const length = Math.min(left.length, right.length);
61
+ for (let i = 0; i < length; i++) {
62
+ const diff = left[i] - right[i];
63
+ if (diff !== 0)
64
+ return diff;
65
+ }
66
+ return left.length - right.length;
67
+ };
68
+ /**
69
+ * @internal Why a {@link ReadIndexOptions} key condition cannot be sent, or
70
+ * `undefined` when it can. DynamoDB rejects an empty key value and a
71
+ * `BETWEEN` whose lower bound sorts after its upper bound.
72
+ */
73
+ const invalidIndexKeyCondition = (options) => {
74
+ if (options?.beginsWith !== undefined && options.between !== undefined) {
75
+ return "Pass at most one of beginsWith and between.";
76
+ }
77
+ const beginsWith = options?.beginsWith;
78
+ if (beginsWith !== undefined) {
79
+ if (typeof beginsWith !== "string" || beginsWith.length === 0) {
80
+ return ("beginsWith must be a non-empty string — DynamoDB rejects an empty key value; " +
81
+ "omit it to read the whole sub-stream.");
82
+ }
83
+ return undefined;
84
+ }
85
+ const between = options?.between;
86
+ if (between === undefined)
87
+ return undefined;
88
+ if (!Array.isArray(between) ||
89
+ between.length !== 2 ||
90
+ !between.every((bound) => typeof bound === "string" && bound.length > 0)) {
91
+ return "between must be a pair of non-empty strings — DynamoDB rejects an empty key value.";
92
+ }
93
+ const [low, high] = between;
94
+ if (compareUtf8(low, high) > 0) {
95
+ return `between's lower bound ${JSON.stringify(low)} sorts after its upper bound ${JSON.stringify(high)}.`;
96
+ }
97
+ return undefined;
98
+ };
99
+ /**
100
+ * @internal Attributes a stream writes on its own items — events, snapshot,
101
+ * idempotency sentinel — plus the default TTL attribute. An index attribute
102
+ * may not reuse any of them.
103
+ */
104
+ const STREAM_OWNED_ATTRIBUTES = [
105
+ "pk",
106
+ "sk",
107
+ "__edd_e__",
108
+ "streamId",
109
+ "version",
110
+ "eventType",
111
+ "data",
112
+ "metadata",
113
+ "timestamp",
114
+ "asOfVersion",
115
+ "state",
116
+ "commandId",
117
+ DEFAULT_TTL_ATTRIBUTE_NAME,
118
+ ];
119
+ /**
120
+ * @internal Validate `makeStream({ indexes })` at definition time.
121
+ *
122
+ * @throws `[EDD-9063]` for a malformed entry (unknown `type`, a `gsi` without
123
+ * `pk`, an `lsi` with `pk`, an empty name, a `key` that is not a function).
124
+ * @throws `[EDD-9064]` for an index attribute that collides with a
125
+ * stream-owned attribute.
126
+ * @throws `[EDD-9065]` for two indexes sharing a physical index name or an
127
+ * attribute.
128
+ */
129
+ const resolveStreamIndexes = (streamName, indexes) => {
130
+ if (indexes === undefined)
131
+ return [];
132
+ const fail = (code, message) => {
133
+ throw new Error(`[${code}] EventStream "${streamName}": ${message}`);
134
+ };
135
+ const isName = (value) => typeof value === "string" && value !== "";
136
+ const resolved = [];
137
+ for (const [name, raw] of Object.entries(indexes)) {
138
+ if (typeof raw !== "object" || raw === null) {
139
+ fail("EDD-9063", `index "${name}" must be an object; received ${String(raw)}.`);
140
+ }
141
+ const entry = raw;
142
+ const type = (entry.type ?? "lsi");
143
+ if (type !== "lsi" && type !== "gsi") {
144
+ fail("EDD-9063", `index "${name}": type must be "lsi" or "gsi"; received ${JSON.stringify(entry.type)}.`);
145
+ }
146
+ if (!isName(entry.index)) {
147
+ fail("EDD-9063", `index "${name}": index must name the physical index (a non-empty string).`);
148
+ }
149
+ if (!isName(entry.sk)) {
150
+ fail("EDD-9063", `index "${name}": sk must name the sort key attribute (a non-empty string).`);
151
+ }
152
+ if (type === "gsi" && !isName(entry.pk)) {
153
+ fail("EDD-9063", `index "${name}": a "gsi" index requires pk, the attribute carrying the stream's ` +
154
+ "partition key (a non-empty string).");
155
+ }
156
+ if (type === "lsi" && entry.pk !== undefined) {
157
+ fail("EDD-9063", `index "${name}": an "lsi" index uses the table partition key "pk"; remove pk, or ` +
158
+ 'declare type: "gsi".');
159
+ }
160
+ if (typeof entry.key !== "function") {
161
+ fail("EDD-9063", `index "${name}": key must be a function (event, version) => string | undefined.`);
162
+ }
163
+ const index = entry.index;
164
+ const sk = entry.sk;
165
+ const pk = type === "gsi" ? entry.pk : "pk";
166
+ const attributes = type === "gsi" ? [pk, sk] : [sk];
167
+ for (const attribute of attributes) {
168
+ if (STREAM_OWNED_ATTRIBUTES.includes(attribute)) {
169
+ fail("EDD-9064", `index "${name}": attribute "${attribute}" is written by the stream itself ` +
170
+ `(reserved: ${STREAM_OWNED_ATTRIBUTES.join(", ")}). Choose another attribute name.`);
171
+ }
172
+ }
173
+ if (type === "gsi" && pk === sk) {
174
+ fail("EDD-9065", `index "${name}": pk and sk must be different attributes ("${pk}").`);
175
+ }
176
+ for (const other of resolved) {
177
+ if (other.index === index) {
178
+ fail("EDD-9065", `indexes "${other.name}" and "${name}" both use the physical index "${index}".`);
179
+ }
180
+ const otherAttributes = other.type === "gsi" ? [other.pk, other.sk] : [other.sk];
181
+ const shared = attributes.find((attribute) => otherAttributes.includes(attribute));
182
+ if (shared !== undefined) {
183
+ fail("EDD-9065", `indexes "${other.name}" and "${name}" both use the attribute "${shared}".`);
184
+ }
185
+ }
186
+ resolved.push({
187
+ name,
188
+ type,
189
+ index,
190
+ pk,
191
+ sk,
192
+ key: entry.key,
193
+ });
194
+ }
195
+ return resolved;
196
+ };
41
197
  // ---------------------------------------------------------------------------
42
198
  // EventStreamTypeId
43
199
  // ---------------------------------------------------------------------------
44
200
  const EventStreamTypeId = Symbol.for("effect-dynamodb/EventStream");
201
+ /**
202
+ * @internal Carries the stream's stream-id formatter (composites joined in
203
+ * declaration order — the `streamId` reported on errors and stored on items),
204
+ * so {@link commandHandler} can report a pre-decide `VersionConflict` exactly
205
+ * as `append` would. Not part of the public interface: `makeStream` and `bind`
206
+ * attach it; a hand-built stream object falls back to joining the id's values.
207
+ */
208
+ const StreamIdFormatter = Symbol.for("effect-dynamodb/EventStream/StreamIdFormatter");
209
+ /** @internal */
210
+ const formatStreamIdOf = (stream, streamId) => {
211
+ const formatter = stream[StreamIdFormatter];
212
+ return formatter !== undefined ? formatter(streamId) : Object.values(streamId).join("#");
213
+ };
214
+ /**
215
+ * @internal Carries a probe for a command's idempotency sentinel (one strongly
216
+ * consistent `GetItem`), so {@link commandHandler} can tell a redelivered
217
+ * command from a lost race when its pre-decide If-Match check fails. Not part
218
+ * of the public interface: `makeStream` and `bind` attach it (a bound stream's
219
+ * probe has its services provided); a hand-built stream object has none.
220
+ */
221
+ const CommandSentinelProbe = Symbol.for("effect-dynamodb/EventStream/CommandSentinelProbe");
222
+ /** @internal */
223
+ const commandSentinelProbeOf = (stream) => stream[CommandSentinelProbe];
224
+ /**
225
+ * @internal Carries a head probe bounded below by a known version: the newest
226
+ * event version above `floorVersion`, or `floorVersion` itself when there is
227
+ * none. {@link commandHandler} uses it to confirm an unverified snapshot is
228
+ * still the head. Unlike `currentVersion`, it never reads the newest event when
229
+ * the snapshot is current — the key range above `floorVersion` is empty, so the
230
+ * `Query` reads no items and costs only DynamoDB's per-request minimum, however
231
+ * large the events are. Attached by `makeStream` and `bind` like
232
+ * {@link CommandSentinelProbe}; a hand-built stream falls back to
233
+ * `currentVersion`.
234
+ */
235
+ const HeadAboveProbe = Symbol.for("effect-dynamodb/EventStream/HeadAboveProbe");
236
+ /** @internal */
237
+ const headAboveProbeOf = (stream) => stream[HeadAboveProbe];
238
+ /**
239
+ * @internal `[EDD-9068]`: `verifySnapshot: false` is accepted only by a stream
240
+ * whose snapshot is written in every append (`mode: "inline"` without `every`),
241
+ * the only mode in which the snapshot is normally at the head. Returns the
242
+ * refusal message, or `undefined` when the option is acceptable — including on
243
+ * a stream without a snapshot config, where it is ignored.
244
+ */
245
+ const unverifiedSnapshotRefusal = (streamName, settings, operation) => settings === undefined || (settings.mode === "inline" && settings.every === undefined)
246
+ ? undefined
247
+ : `[EDD-9068] EventStream "${streamName}": ${operation} with verifySnapshot: false requires ` +
248
+ `snapshot mode "inline" without "every" — the only mode that writes the snapshot in every ` +
249
+ `append, so it is normally at the head. This stream's snapshot is mode "${settings.mode}"` +
250
+ `${settings.every === undefined ? "" : ` with every: ${settings.every}`}.`;
45
251
  // ---------------------------------------------------------------------------
46
252
  // makeStream factory
47
253
  // ---------------------------------------------------------------------------
@@ -85,6 +291,10 @@ const EventStreamTypeId = Symbol.for("effect-dynamodb/EventStream");
85
291
  * })
86
292
  * ```
87
293
  *
294
+ * `snapshot.mode: "inline"` writes the snapshot in the append transaction
295
+ * itself, so it is current after every command and state loads in one
296
+ * request — see {@link SnapshotMode}.
297
+ *
88
298
  * `casing` overrides the key casing for this stream, as an index's or a vector
89
299
  * index's `casing` does: the stream name, stream-id values and command ids in
90
300
  * its keys all take it. When omitted, the stream name is lower-cased and the
@@ -95,7 +305,57 @@ const EventStreamTypeId = Symbol.for("effect-dynamodb/EventStream");
95
305
  * (`<stream>.event` etc.) are always lower-cased. In the next major, omitting
96
306
  * `casing` will mean the schema's casing, as it does for indexes.
97
307
  *
308
+ * `indexes` declares sub-streams ordered by a key derived from each event
309
+ * (#140), on a local (default) or global secondary index. `append` writes each
310
+ * event's derived key (and, for a GSI, the stream's partition key) on the
311
+ * event item; snapshots and idempotency sentinels never carry them, so they
312
+ * are never in an index. Read with {@link EventStream.readIndex} or
313
+ * `query.index`, and create the indexes with {@link indexDefinitions}.
314
+ *
315
+ * Index attributes are written only at append time and events are never
316
+ * rewritten. Declaring an index on a stream that already holds events, or
317
+ * changing an index's `key`, therefore leaves the earlier events out of the
318
+ * index (or under their old keys), and `readIndex` returns only what was
319
+ * indexed — without an error. The library does not backfill; whether and how
320
+ * to re-index existing events (for example, an `UpdateItem` per event setting
321
+ * the derived attributes) is the application's decision.
322
+ *
323
+ * @example
324
+ * ```typescript
325
+ * const Entries = EventStore.makeStream({
326
+ * table: EventsTable,
327
+ * streamName: "Ledger",
328
+ * events: [EntryRecorded, EntryVoided],
329
+ * streamId: { composite: ["ledgerId"] },
330
+ * indexes: {
331
+ * byEntry: {
332
+ * index: "lsi1",
333
+ * sk: "lsi1sk",
334
+ * key: (event, version) =>
335
+ * event._tag === "EntryRecorded" ? `ENTRY#${event.section}#${pad(version)}` : undefined,
336
+ * },
337
+ * // Every event, by the day it was recorded (both event types carry `day`).
338
+ * byDay: { type: "gsi", index: "gsi1", pk: "gsi1pk", sk: "gsi1sk", key: (e) => e.day },
339
+ * },
340
+ * })
341
+ *
342
+ * const sectionThree = yield* Entries.readIndex("byEntry", { ledgerId }, { beginsWith: "ENTRY#3#" })
343
+ * ```
344
+ *
98
345
  * @throws `[EDD-9027]` when `snapshot.every` is not a positive integer.
346
+ * @throws `[EDD-9062]` when `snapshot.mode` is neither `"after-append"` nor
347
+ * `"inline"`.
348
+ * @throws `[EDD-9063]` for a malformed index: a `type` other than `"lsi"` /
349
+ * `"gsi"`, a `gsi` without `pk`, an `lsi` with `pk`, an empty `index` / `sk`
350
+ * / `pk`, or a `key` that is not a function.
351
+ * @throws `[EDD-9064]` when an index attribute is one the stream writes itself
352
+ * (`pk`, `sk`, `__edd_e__`, `streamId`, `version`, `eventType`, `data`,
353
+ * `metadata`, `timestamp`, `asOfVersion`, `state`, `commandId`, or the
354
+ * default TTL attribute `_ttl`). A custom `TableConfig.ttlAttributeName`
355
+ * is only known at runtime: an index attribute equal to it fails each append
356
+ * with `ValidationError`.
357
+ * @throws `[EDD-9065]` when two indexes share a physical index name or an
358
+ * attribute (or a GSI's `pk` and `sk` are the same attribute).
99
359
  */
100
360
  export const makeStream = (config) => {
101
361
  const schema = config.table.schema;
@@ -127,7 +387,7 @@ export const makeStream = (config) => {
127
387
  const commandKeyLabel = `${keyLabel}.command`;
128
388
  const compositeFields = config.streamId.composite;
129
389
  // -------------------------------------------------------------------------
130
- // Snapshot config validation (EDD-9027) — fail fast at definition time.
390
+ // Snapshot config validation (EDD-9027, EDD-9062) — fail fast at definition time.
131
391
  // -------------------------------------------------------------------------
132
392
  const snapshot = config.snapshot;
133
393
  if (snapshot !== undefined && snapshot.every !== undefined) {
@@ -136,7 +396,24 @@ export const makeStream = (config) => {
136
396
  `received ${String(snapshot.every)}.`);
137
397
  }
138
398
  }
139
- const snapshotSettings = snapshot === undefined ? undefined : { every: snapshot.every };
399
+ if (snapshot !== undefined &&
400
+ snapshot.mode !== undefined &&
401
+ snapshot.mode !== "after-append" &&
402
+ snapshot.mode !== "inline") {
403
+ throw new Error(`[EDD-9062] EventStream "${config.streamName}": snapshot.mode must be "after-append" or ` +
404
+ `"inline"; received ${JSON.stringify(snapshot.mode)}.`);
405
+ }
406
+ const snapshotSettings = snapshot === undefined
407
+ ? undefined
408
+ : { mode: snapshot.mode ?? "after-append", every: snapshot.every };
409
+ // Stream indexes (#140) — validated at definition time (EDD-9063..9065).
410
+ const streamIndexes = resolveStreamIndexes(config.streamName, config.indexes);
411
+ const indexSettings = {};
412
+ for (const idx of streamIndexes) {
413
+ indexSettings[idx.name] = { type: idx.type, index: idx.index, pk: idx.pk, sk: idx.sk };
414
+ }
415
+ /** Every attribute the indexes write on an event item. */
416
+ const indexAttributes = streamIndexes.flatMap((idx) => idx.type === "gsi" ? [idx.pk, idx.sk] : [idx.sk]);
140
417
  // Build union schema from event schemas for decoding
141
418
  const eventUnion = config.events.length === 1
142
419
  ? config.events[0]
@@ -151,6 +428,8 @@ export const makeStream = (config) => {
151
428
  return DynamoSchema.composeKey(schema, keyLabel, composites, keyOptions);
152
429
  };
153
430
  const composeEventSk = (version) => DynamoSchema.composeEventVersionKey(schema, eventKeyLabel, version, keyOptions);
431
+ /** Sort key of a command's idempotency sentinel. */
432
+ const composeCommandSk = (commandId) => DynamoSchema.composeKey(schema, commandKeyLabel, [commandId], keyOptions);
154
433
  /**
155
434
  * Every event SK begins with this; nothing else in the stream partition does.
156
435
  * Bounding event reads to it excludes the snapshot item at the key-condition
@@ -164,6 +443,13 @@ export const makeStream = (config) => {
164
443
  * The snapshot SK. Distinct entity-type label (`<stream>.snapshot` vs
165
444
  * `<stream>.event_1#…`), so it can never collide with an event SK, and it
166
445
  * sorts after every event in the partition.
446
+ *
447
+ * The three stream-owned SKs share `<prefix>#<label>.` and differ first at
448
+ * the literal suffix: `.command#<id>` < `.event_1#<version>` < `.snapshot`.
449
+ * The suffixes are lower-case, and a stream `casing` applies to all three
450
+ * alike (`"uppercase"` gives `.COMMAND` < `.EVENT_1` < `.SNAPSHOT`), so the
451
+ * order holds under every casing. `readLatest` relies on it: the range
452
+ * `[eventSkPrefix, snapshotSk]` holds exactly the events and the snapshot.
167
453
  */
168
454
  const snapshotSk = DynamoSchema.composeKey(schema, snapshotKeyLabel, [], keyOptions);
169
455
  const composeStreamIdString = (streamId) => compositeFields.map((f) => streamId[f]).join("#");
@@ -191,10 +477,10 @@ export const makeStream = (config) => {
191
477
  * `decode → encode` fallback for inputs already in encoded shape. Mirrors
192
478
  * the Entity write path (`encodeOrDecodeEncode` in `Entity.ts`).
193
479
  */
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)),
480
+ 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
481
  // Surface the original encode error — its message is keyed on the
196
482
  // caller's input shape, which is what the user expects to see.
197
- Effect.catch(() => Effect.fail(new ValidationError({ entityType, operation, cause: primaryCause }))))));
483
+ Effect.catch(() => Effect.fail(new ValidationError({ entityType: errorEntityType, operation, cause: primaryCause }))))));
198
484
  // ---------------------------------------------------------------------------
199
485
  // Decode a raw DynamoDB item → StreamEvent<TEvent>
200
486
  // ---------------------------------------------------------------------------
@@ -225,23 +511,143 @@ export const makeStream = (config) => {
225
511
  // ---------------------------------------------------------------------------
226
512
  // append
227
513
  // ---------------------------------------------------------------------------
514
+ /**
515
+ * An event's key in a stream index (#140): a non-empty string, or
516
+ * `undefined` to leave the event out. Anything else — an empty string
517
+ * (DynamoDB rejects empty key values), another type, a `key` that throws —
518
+ * is a `ValidationError`, raised before anything is written.
519
+ */
520
+ const deriveIndexKey = (idx, event, version) => Effect.try({
521
+ try: () => idx.key(event, version),
522
+ catch: (cause) => new ValidationError({ entityType, operation: "EventStore.append.indexes", cause }),
523
+ }).pipe(Effect.flatMap((derived) => {
524
+ if (derived === undefined)
525
+ return Effect.succeed(undefined);
526
+ const refuse = (problem) => Effect.fail(new ValidationError({
527
+ entityType,
528
+ operation: "EventStore.append.indexes",
529
+ cause: `The key of index "${idx.name}" for the event at version ${version} ${problem}. ` +
530
+ "Nothing was written.",
531
+ }));
532
+ if (typeof derived !== "string" || derived === "") {
533
+ return refuse("must be a non-empty string or undefined; received " +
534
+ (typeof derived === "string" ? "an empty string" : String(derived)));
535
+ }
536
+ if (utf8.encode(derived).length > MAX_INDEX_SORT_KEY_BYTES) {
537
+ return refuse(`is longer than DynamoDB's ${MAX_INDEX_SORT_KEY_BYTES}-byte limit for a sort key`);
538
+ }
539
+ return Effect.succeed(derived);
540
+ }));
541
+ /**
542
+ * Encode snapshot state to wire form through the state schema, with the
543
+ * same `decode → encode` fallback as events and metadata. A structurally
544
+ * matching plain object — what an immutable `evolve` that spreads a
545
+ * `Schema.Class` state returns (`({ ...s, balance })`), which TypeScript
546
+ * accepts as the class type — is decoded into the schema's type first
547
+ * rather than refused with `Expected <Class>`.
548
+ */
549
+ const encodeSnapshotState = (state, operation) => encodeToWire(snapshot.schema, state, operation, snapshotEntityType);
550
+ /**
551
+ * Map a failed `TransactWriteItems` of an append to its verdict. Resolves to
552
+ * the race a guarded additional put lost (so the transaction is built and
553
+ * written again), and fails with every other verdict.
554
+ */
555
+ const sendAppendTransaction = (client, tx, streamIdStr, commandId) => client.transactWriteItems({ TransactItems: tx.items }).pipe(Effect.as(undefined), Effect.catch((error) => {
556
+ if (!isAwsTransactionCancelled(error.cause)) {
557
+ return Effect.fail(error);
558
+ }
559
+ const rawReasons = error.cause.CancellationReasons ?? [];
560
+ const reasons = rawReasons.map((r) => ({
561
+ code: r?.Code,
562
+ message: r?.Message,
563
+ }));
564
+ const failedAt = (index) => index >= 0 && reasons[index]?.code === "ConditionalCheckFailed";
565
+ // Precedence is ordered by how terminal the caller's response should
566
+ // be: a duplicate can never succeed on retry, a version conflict
567
+ // invites a re-read, and only then are the additional items judged.
568
+ if (commandId !== undefined && failedAt(tx.sentinelIndex)) {
569
+ return Effect.fail(new DuplicateCommand({
570
+ streamName: config.streamName,
571
+ streamId: streamIdStr,
572
+ commandId,
573
+ }));
574
+ }
575
+ // The contiguity ConditionCheck and the event puts both mean "the
576
+ // stream is not where you said it was", so they share one verdict.
577
+ for (let i = 0; i < tx.guardCount + tx.eventCount; i++) {
578
+ if (failedAt(i)) {
579
+ return Effect.fail(new VersionConflict({
580
+ streamName: config.streamName,
581
+ streamId: streamIdStr,
582
+ expectedVersion: tx.expectedVersion,
583
+ }));
584
+ }
585
+ }
586
+ // The additional items, attributed back to the caller OPS that
587
+ // produced them (several items can belong to one op). Only an op's
588
+ // own condition is `AdditionalItemConditionFailed`: a guarded put
589
+ // the caller set no condition on reports what the entity's own put
590
+ // would — a taken unique value, a history conflict — or, having
591
+ // lost a race to a concurrent write, is written again (#133).
592
+ const judged = judgeCancellation(tx.additional, rawReasons, tx.guardCount + tx.eventCount);
593
+ if (judged?._tag === "fail")
594
+ return Effect.fail(judged.error);
595
+ if (judged?._tag === "conditions") {
596
+ return Effect.fail(new AdditionalItemConditionFailed({
597
+ streamName: config.streamName,
598
+ streamId: streamIdStr,
599
+ indices: judged.opIndices,
600
+ reasons,
601
+ }));
602
+ }
603
+ if (judged?._tag === "retry")
604
+ return Effect.succeed(judged.error);
605
+ // No conditional failure we can positionally justify (throttling,
606
+ // TransactionConflict, a reason at the inline snapshot's position, or
607
+ // a truncated/absent reason list) — never guess a VersionConflict.
608
+ return Effect.fail(new TransactionCancelled({
609
+ operation: "TransactWriteItems",
610
+ reasons,
611
+ cause: error.cause,
612
+ }));
613
+ }));
228
614
  const append = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
229
615
  const additionalOps = options?.additionalItems ?? [];
230
616
  const idempotency = options?.idempotency;
617
+ const snapshotState = options?.snapshot;
618
+ if (snapshotState !== undefined && snapshot === undefined) {
619
+ return yield* snapshotUnavailable("append({ snapshot })");
620
+ }
231
621
  // Nothing at all to write — preserve the historical no-op fast path.
232
622
  // With additional items or a dedup sentinel the transaction still runs:
233
623
  // a caller who asked for a side write means it, and silently dropping it
234
624
  // would lose data.
235
- if (events.length === 0 && additionalOps.length === 0 && idempotency === undefined) {
625
+ if (events.length === 0 &&
626
+ additionalOps.length === 0 &&
627
+ idempotency === undefined &&
628
+ snapshotState === undefined) {
236
629
  return { version: expectedVersion, events: [] };
237
630
  }
238
631
  // Resolve stream ID string for storage (join composites)
239
632
  const streamIdStr = composeStreamIdString(streamId);
633
+ // An inline snapshot is unconditional because the event puts prove this
634
+ // writer owns its version. Without events nothing proves it, and an
635
+ // unconditional put could regress the snapshot.
636
+ if (snapshotState !== undefined && events.length === 0) {
637
+ return yield* new ValidationError({
638
+ entityType: snapshotEntityType,
639
+ operation: "EventStore.append.snapshot",
640
+ cause: "An inline snapshot requires at least one event: nothing else proves the writer " +
641
+ `owns version ${expectedVersion}. Nothing was written.`,
642
+ });
643
+ }
240
644
  // Guard: every item the transaction will carry — one Put per event, the
241
645
  // 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.
646
+ // sentinel, the inline snapshot, and the version-contiguity
647
+ // ConditionCheck when expectedVersion > 0 — must fit DynamoDB's
648
+ // TransactWriteItems limit. Never split: splitting would break append
649
+ // atomicity (a large command is stepped by the application instead — one
650
+ // command, one decision, one atomic append).
245
651
  //
246
652
  // This first check is a LOWER BOUND, counting one item per additional op.
247
653
  // Expansion (uniqueness sentinels, version snapshots — #113) only ever
@@ -249,7 +655,10 @@ export const makeStream = (config) => {
249
655
  // failing now keeps an oversized append free. The authoritative check runs
250
656
  // once the ops are compiled, below.
251
657
  const needsContiguityCheck = expectedVersion > 0 && events.length > 0;
252
- const fixedItems = events.length + (idempotency !== undefined ? 1 : 0) + (needsContiguityCheck ? 1 : 0);
658
+ const fixedItems = events.length +
659
+ (idempotency !== undefined ? 1 : 0) +
660
+ (snapshotState !== undefined ? 1 : 0) +
661
+ (needsContiguityCheck ? 1 : 0);
253
662
  if (fixedItems + additionalOps.length > TRANSACT_WRITE_ITEMS_LIMIT) {
254
663
  return yield* new AppendTooLarge({
255
664
  streamName: config.streamName,
@@ -261,6 +670,19 @@ export const makeStream = (config) => {
261
670
  const client = yield* DynamoClient;
262
671
  const tableConfig = yield* config.table.Tag;
263
672
  const tableName = tableConfig.name;
673
+ // A custom TTL attribute is only known at runtime. An index key attribute
674
+ // sharing its name would receive the sentinel's numeric TTL, which a
675
+ // string-typed index key rejects — refuse it before anything is written.
676
+ const ttlAttribute = resolveTtlAttributeName(tableConfig);
677
+ if (indexAttributes.includes(ttlAttribute)) {
678
+ return yield* new ValidationError({
679
+ entityType,
680
+ operation: "EventStore.append.indexes",
681
+ cause: `An index of stream "${config.streamName}" uses the attribute "${ttlAttribute}", ` +
682
+ "which is the table's TTL attribute. Choose another index attribute name. " +
683
+ "Nothing was written.",
684
+ });
685
+ }
264
686
  const pk = composeStreamPk(streamId);
265
687
  // Clock-backed timestamp (deterministic under TestClock; wall-clock in prod).
266
688
  const nowDateTime = yield* DateTime.now;
@@ -302,6 +724,17 @@ export const makeStream = (config) => {
302
724
  if (encodedMetadata !== undefined) {
303
725
  item.metadata = encodedMetadata;
304
726
  }
727
+ // Stream indexes (#140): the derived key, stored raw, and for a
728
+ // GSI the stream's partition key. The attributes are part of the
729
+ // event item itself, so they count towards the size checks.
730
+ for (const idx of streamIndexes) {
731
+ const derived = yield* deriveIndexKey(idx, event, version);
732
+ if (derived === undefined)
733
+ continue;
734
+ item[idx.sk] = derived;
735
+ if (idx.type === "gsi")
736
+ item[idx.pk] = pk;
737
+ }
305
738
  return {
306
739
  Put: {
307
740
  TableName: tableName,
@@ -310,6 +743,126 @@ export const makeStream = (config) => {
310
743
  },
311
744
  };
312
745
  }));
746
+ // The idempotency sentinel: a Put guarded by `attribute_not_exists(pk)`.
747
+ let sentinelPut;
748
+ if (idempotency !== undefined) {
749
+ const sentinel = {
750
+ pk,
751
+ sk: composeCommandSk(idempotency.commandId),
752
+ __edd_e__: commandEntityType,
753
+ streamId: streamIdStr,
754
+ commandId: idempotency.commandId,
755
+ version: expectedVersion + events.length,
756
+ timestamp: now,
757
+ };
758
+ if (idempotency.ttl !== undefined) {
759
+ const ttlSeconds = yield* Effect.try({
760
+ try: () => normalizeTtlSeconds(idempotency.ttl),
761
+ catch: (cause) => new ValidationError({
762
+ entityType: commandEntityType,
763
+ operation: "EventStore.append.idempotency.ttl",
764
+ cause,
765
+ }),
766
+ });
767
+ sentinel[resolveTtlAttributeName(tableConfig)] =
768
+ DateTime.toEpochSeconds(nowDateTime) + ttlSeconds;
769
+ }
770
+ sentinelPut = {
771
+ Put: {
772
+ TableName: tableName,
773
+ Item: toAttributeMap(sentinel),
774
+ ConditionExpression: "attribute_not_exists(pk)",
775
+ },
776
+ };
777
+ }
778
+ // The inline snapshot (#138): unconditional — see AppendOptions.snapshot.
779
+ let snapshotPut;
780
+ if (snapshotState !== undefined) {
781
+ const encoded = yield* encodeSnapshotState(snapshotState, "EventStore.append.snapshot");
782
+ snapshotPut = {
783
+ Put: {
784
+ TableName: tableName,
785
+ Item: toAttributeMap({
786
+ pk,
787
+ sk: snapshotSk,
788
+ __edd_e__: snapshotEntityType,
789
+ streamId: streamIdStr,
790
+ asOfVersion: expectedVersion + events.length,
791
+ state: encoded,
792
+ timestamp: now,
793
+ }),
794
+ },
795
+ };
796
+ }
797
+ /**
798
+ * Version-contiguity guard: `attribute_not_exists(pk)` on the event puts
799
+ * only rejects STALE expected versions (the target slot already exists).
800
+ * An AHEAD expectedVersion (e.g. 10 when the stream is at 3) would
801
+ * silently write from version 11, leaving a permanent gap. When events
802
+ * are written after version > 0, require the event at exactly
803
+ * `expectedVersion` to exist so the appended range is contiguous with the
804
+ * stream head. Its failure surfaces as a ConditionalCheckFailed
805
+ * cancellation reason, mapping to VersionConflict just like a
806
+ * stale-version Put failure. A zero-event append (a pure side write)
807
+ * writes no version and so can open no gap.
808
+ */
809
+ const contiguityCheck = needsContiguityCheck
810
+ ? {
811
+ ConditionCheck: {
812
+ TableName: tableName,
813
+ Key: toAttributeMap({ pk, sk: composeEventSk(expectedVersion) }),
814
+ ConditionExpression: "attribute_exists(pk)",
815
+ },
816
+ }
817
+ : undefined;
818
+ /**
819
+ * Lay out the append's one transaction — see {@link AppendTransaction}:
820
+ * [0, C) version-contiguity ConditionCheck (C is 0 or 1)
821
+ * [C, C + E) event puts
822
+ * [C + E, C + E + A) additional ITEMS (caller op order preserved)
823
+ * then idempotency sentinel, then inline snapshot
824
+ * (last, so adding them never shifts the
825
+ * additional-item indices the caller sees)
826
+ *
827
+ * `A` is the count of EMITTED items, which is >= the number of caller
828
+ * ops: a guarded put (#133) expands into its item plus its sentinel
829
+ * reservations and releases plus a snapshot. The caller-facing `indices`
830
+ * on `AdditionalItemConditionFailed` are still indices into the caller's
831
+ * `additionalItems` array — `judgeCancellation` maps them.
832
+ */
833
+ const layout = (additional) => {
834
+ const items = [];
835
+ const targets = [];
836
+ const pushStreamItem = (item, type, source) => {
837
+ items.push(item);
838
+ targets.push(transactItemTarget(item, tableName, ["pk", "sk"], type, source));
839
+ };
840
+ if (contiguityCheck !== undefined) {
841
+ pushStreamItem(contiguityCheck, entityType, "the version-contiguity check");
842
+ }
843
+ const guardCount = items.length;
844
+ for (const [i, item] of eventItems.entries()) {
845
+ pushStreamItem(item, entityType, `the event at version ${expectedVersion + i + 1}`);
846
+ }
847
+ items.push(...additional.items);
848
+ targets.push(...additional.targets);
849
+ const sentinelIndex = sentinelPut === undefined ? -1 : items.length;
850
+ if (sentinelPut !== undefined) {
851
+ pushStreamItem(sentinelPut, entityType, "the idempotency sentinel");
852
+ }
853
+ if (snapshotPut !== undefined) {
854
+ pushStreamItem(snapshotPut, snapshotEntityType, "the inline snapshot");
855
+ }
856
+ return {
857
+ items,
858
+ targets,
859
+ guardCount,
860
+ eventCount: eventItems.length,
861
+ additional,
862
+ sentinelIndex,
863
+ expectedVersion,
864
+ };
865
+ };
313
866
  // A guarded put among the additional items reads its item; a race with
314
867
  // that read cancels the append, which is then built and written again.
315
868
  let lost;
@@ -317,174 +870,29 @@ export const makeStream = (config) => {
317
870
  // Caller-owned items, compiled through the same builder
318
871
  // `Transaction.transactWrite` uses, so the two APIs cannot drift.
319
872
  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) {
873
+ const tx = layout(additional);
874
+ // Authoritative cap check: one additional op can compile to several
875
+ // items (#113), so the pre-flight lower bound above is not sufficient.
876
+ // Reporting the EXPANDED count is the point — "you passed 40 items"
877
+ // when the caller passed 30 ops is baffling without it.
878
+ if (tx.items.length > TRANSACT_WRITE_ITEMS_LIMIT) {
326
879
  return yield* new AppendTooLarge({
327
880
  streamName: config.streamName,
328
881
  streamId: streamIdStr,
329
- count: fixedItems + additionalItems.length,
882
+ count: tx.items.length,
330
883
  limit: TRANSACT_WRITE_ITEMS_LIMIT,
331
884
  });
332
885
  }
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
- }));
886
+ // Checked before anything is sent: one op per item — an additional
887
+ // item repeating an event, the contiguity check, the idempotency
888
+ // sentinel or the inline snapshot is refused, as additional items
889
+ // repeating each other are — and the transaction within DynamoDB's
890
+ // 4 MB (#133).
891
+ yield* refuseRepeatedItems(tx.targets, "EventStore.append");
892
+ yield* refuseOversizedTransaction(tx.items, tx.targets, "EventStore.append");
893
+ const outcome = yield* sendAppendTransaction(client, tx, streamIdStr, idempotency?.commandId);
483
894
  if (outcome === undefined) {
484
- return {
485
- version: expectedVersion + events.length,
486
- events,
487
- };
895
+ return { version: expectedVersion + events.length, events };
488
896
  }
489
897
  lost = outcome;
490
898
  }
@@ -493,37 +901,49 @@ export const makeStream = (config) => {
493
901
  // ---------------------------------------------------------------------------
494
902
  // read
495
903
  // ---------------------------------------------------------------------------
496
- const read = (streamId) => Effect.gen(function* () {
497
- const query = buildEventsQuery(streamId);
904
+ /** Apply {@link ReadOptions} to an event query. */
905
+ const withReadOptions = (query, options) => options?.consistentRead === true ? Query.consistentRead(query) : query;
906
+ const read = (streamId, options) => Effect.gen(function* () {
907
+ const query = withReadOptions(buildEventsQuery(streamId), options);
498
908
  return yield* Query.collect(query);
499
909
  });
500
910
  // ---------------------------------------------------------------------------
501
911
  // readFrom
502
912
  // ---------------------------------------------------------------------------
503
- const readFrom = (streamId, afterVersion) => Effect.gen(function* () {
913
+ const readFrom = (streamId, afterVersion, options) => Effect.gen(function* () {
504
914
  // Versions are integers, so the inclusive lower bound `afterVersion + 1`
505
915
  // is exactly the old exclusive `#sk > eventSk(afterVersion)`. The upper
506
916
  // bound keeps the snapshot item (which sorts after every event) out of the
507
917
  // scanned range.
508
- const query = buildEventsQuery(streamId).pipe(Query.where({ between: [composeEventSk(afterVersion + 1), maxEventSk] }));
918
+ const query = withReadOptions(buildEventsQuery(streamId).pipe(Query.where({ between: [composeEventSk(afterVersion + 1), maxEventSk] })), options);
509
919
  return yield* Query.collect(query);
510
920
  });
511
921
  // ---------------------------------------------------------------------------
512
922
  // currentVersion
513
923
  // ---------------------------------------------------------------------------
514
- const currentVersion = (streamId) => Effect.gen(function* () {
924
+ const currentVersion = (streamId, options) => Effect.gen(function* () {
515
925
  // Single page, not `collect`: with `Limit: 1` DynamoDB returns a
516
926
  // `LastEvaluatedKey` on every truncated page, so `collect` would walk the
517
927
  // whole partition one request per item. The `begins_with` bound on
518
928
  // `buildEventsQuery` guarantees the single evaluated item is the newest
519
929
  // *event* (never the snapshot, which sorts last).
520
- const query = buildEventsQuery(streamId).pipe(Query.reverse, Query.limit(1));
930
+ const query = withReadOptions(buildEventsQuery(streamId).pipe(Query.reverse, Query.limit(1)), options);
521
931
  const page = yield* Query.execute(query);
522
932
  const newest = page.items[0];
523
933
  if (newest === undefined)
524
934
  return 0;
525
935
  return newest.version;
526
936
  });
937
+ // headAbove (internal — see HeadAboveProbe)
938
+ const headAbove = (streamId, floorVersion, options) => Effect.gen(function* () {
939
+ // Reverse, `Limit: 1`, bounded below by `floorVersion + 1`: an empty range
940
+ // (the common case — nothing appended past the snapshot) reads no items;
941
+ // otherwise the single evaluated item is the head.
942
+ const query = withReadOptions(buildEventsQuery(streamId).pipe(Query.where({ between: [composeEventSk(floorVersion + 1), maxEventSk] }), Query.reverse, Query.limit(1)), options);
943
+ const page = yield* Query.execute(query);
944
+ const newest = page.items[0];
945
+ return newest === undefined ? floorVersion : newest.version;
946
+ });
527
947
  // ---------------------------------------------------------------------------
528
948
  // Snapshot primitives
529
949
  // ---------------------------------------------------------------------------
@@ -535,11 +955,7 @@ export const makeStream = (config) => {
535
955
  const client = yield* DynamoClient;
536
956
  const { name: tableName } = yield* config.table.Tag;
537
957
  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
- })));
958
+ const encoded = yield* encodeSnapshotState(state, "EventStore.writeSnapshot");
543
959
  const item = {
544
960
  pk: composeStreamPk(streamId),
545
961
  sk: snapshotSk,
@@ -561,9 +977,17 @@ export const makeStream = (config) => {
561
977
  })
562
978
  .pipe(Effect.catchIf((error) => isAwsConditionalCheckFailed(error.cause), () => Effect.void));
563
979
  });
564
- const readSnapshot = (streamId) => Effect.gen(function* () {
565
- if (snapshot === undefined)
566
- return yield* snapshotUnavailable("readSnapshot");
980
+ /**
981
+ * Decode a stored snapshot item. A snapshot that fails to decode through the
982
+ * state schema is a `ValidationError` — never silently discarded.
983
+ */
984
+ const decodeSnapshotItem = (raw, operation) => Schema.decodeUnknownEffect(snapshot.schema)(raw.state).pipe(Effect.mapError((cause) => new ValidationError({ entityType: snapshotEntityType, operation, cause })), Effect.map((state) => ({
985
+ state,
986
+ asOfVersion: raw.asOfVersion,
987
+ timestamp: raw.timestamp,
988
+ })));
989
+ /** The raw snapshot item, by its exact key, in one `GetItem`. */
990
+ const getSnapshotItem = (streamId, consistentRead) => Effect.gen(function* () {
567
991
  const client = yield* DynamoClient;
568
992
  const { name: tableName } = yield* config.table.Tag;
569
993
  const result = yield* client.getItem({
@@ -572,21 +996,140 @@ export const makeStream = (config) => {
572
996
  pk: composeStreamPk(streamId),
573
997
  sk: snapshotSk,
574
998
  }),
575
- ConsistentRead: true,
999
+ ...(consistentRead ? { ConsistentRead: true } : {}),
576
1000
  });
577
- if (result.Item === undefined)
1001
+ return result.Item === undefined ? undefined : fromAttributeMap(result.Item);
1002
+ });
1003
+ const readSnapshot = (streamId) => Effect.gen(function* () {
1004
+ if (snapshot === undefined)
1005
+ return yield* snapshotUnavailable("readSnapshot");
1006
+ const raw = yield* getSnapshotItem(streamId, true);
1007
+ if (raw === undefined)
578
1008
  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,
1009
+ return Option.some(yield* decodeSnapshotItem(raw, "EventStore.readSnapshot"));
1010
+ });
1011
+ // ---------------------------------------------------------------------------
1012
+ // readLatest — snapshot + delta + head in one request (#138)
1013
+ // ---------------------------------------------------------------------------
1014
+ const readLatest = (streamId, options) => Effect.gen(function* () {
1015
+ // Without a snapshot config there is nothing to read but the events
1016
+ // (`verifySnapshot` is ignored).
1017
+ if (snapshot === undefined) {
1018
+ const events = yield* read(streamId, options);
1019
+ return {
1020
+ snapshot: Option.none(),
1021
+ events,
1022
+ version: events[events.length - 1]?.version ?? 0,
1023
+ };
1024
+ }
1025
+ // `verifySnapshot: false` — the snapshot item alone, by its exact key:
1026
+ // a `GetItem` reads one item where the verified `Query` reads the
1027
+ // snapshot and at least the newest event. Its version is unverified
1028
+ // (see `ReadLatestOptions`). Without a snapshot item there is nothing to
1029
+ // trust, so fall through to the verified read.
1030
+ if (options?.verifySnapshot === false) {
1031
+ const refusal = unverifiedSnapshotRefusal(config.streamName, snapshotSettings, "readLatest");
1032
+ if (refusal !== undefined)
1033
+ return yield* Effect.die(new Error(refusal));
1034
+ const raw = yield* getSnapshotItem(streamId, options.consistentRead === true);
1035
+ if (raw !== undefined) {
1036
+ const current = yield* decodeSnapshotItem(raw, "EventStore.readLatest");
1037
+ return {
1038
+ snapshot: Option.some(current),
1039
+ events: [],
1040
+ version: current.asOfVersion,
1041
+ };
1042
+ }
1043
+ }
1044
+ const client = yield* DynamoClient;
1045
+ const { name: tableName } = yield* config.table.Tag;
1046
+ // One reverse Query over `[first event SK, snapshot SK]`: the snapshot
1047
+ // sorts after every event and the command sentinels before them (see
1048
+ // `snapshotSk`), so the range holds exactly the events and the snapshot,
1049
+ // and the snapshot is the first item evaluated. The `__edd_e__` filter is
1050
+ // belt and braces — `Limit` counts items before it applies.
1051
+ const base = {
1052
+ TableName: tableName,
1053
+ KeyConditionExpression: "#pk = :pk AND #sk BETWEEN :first AND :snapshot",
1054
+ FilterExpression: "#e IN (:eventType, :snapshotType)",
1055
+ ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk", "#e": "__edd_e__" },
1056
+ ExpressionAttributeValues: toAttributeMap({
1057
+ ":pk": composeStreamPk(streamId),
1058
+ ":first": eventSkPrefix,
1059
+ ":snapshot": snapshotSk,
1060
+ ":eventType": entityType,
1061
+ ":snapshotType": snapshotEntityType,
1062
+ }),
1063
+ ScanIndexForward: false,
1064
+ ...(options?.consistentRead === true ? { ConsistentRead: true } : {}),
1065
+ };
1066
+ let snapshotRaw;
1067
+ /** Event items, newest first. */
1068
+ const eventRaws = [];
1069
+ // The first page is sized for the snapshot plus a lag of up to `every`
1070
+ // events, so a current (or modestly lagging) snapshot loads in one
1071
+ // request.
1072
+ let limit = (snapshot.every ?? 1) + 1;
1073
+ let startKey;
1074
+ for (;;) {
1075
+ const page = yield* client.query({
1076
+ ...base,
1077
+ ...(limit !== undefined ? { Limit: limit } : {}),
1078
+ ...(startKey !== undefined ? { ExclusiveStartKey: startKey } : {}),
1079
+ });
1080
+ for (const item of page.Items ?? []) {
1081
+ const raw = fromAttributeMap(item);
1082
+ if (raw.__edd_e__ === snapshotEntityType)
1083
+ snapshotRaw = raw;
1084
+ else
1085
+ eventRaws.push(raw);
1086
+ }
1087
+ startKey = page.LastEvaluatedKey;
1088
+ if (startKey === undefined)
1089
+ break;
1090
+ // Without a snapshot (it would have been the first item evaluated),
1091
+ // read on, unlimited, to the start of the stream.
1092
+ if (snapshotRaw === undefined) {
1093
+ limit = undefined;
1094
+ continue;
1095
+ }
1096
+ // Done once the event right after the snapshot has been seen: versions
1097
+ // are contiguous, so every event the snapshot does not cover is then in
1098
+ // hand, and everything older is already folded into it. Otherwise read
1099
+ // exactly the events still missing, `asOfVersion + 1 … oldest - 1`
1100
+ // (DynamoDB's 1 MB page cap aside).
1101
+ const asOfVersion = snapshotRaw.asOfVersion;
1102
+ const oldest = eventRaws[eventRaws.length - 1]?.version;
1103
+ if (oldest !== undefined && oldest <= asOfVersion + 1)
1104
+ break;
1105
+ limit = oldest === undefined ? undefined : oldest - asOfVersion - 1;
1106
+ }
1107
+ const latest = snapshotRaw === undefined
1108
+ ? Option.none()
1109
+ : Option.some(yield* decodeSnapshotItem(snapshotRaw, "EventStore.readLatest"));
1110
+ const asOfVersion = Option.isSome(latest) ? latest.value.asOfVersion : 0;
1111
+ const delta = eventRaws.filter((raw) => raw.version > asOfVersion).reverse();
1112
+ const events = yield* Effect.forEach(delta, (raw) => decodeStreamEvent(raw));
1113
+ return {
1114
+ snapshot: latest,
1115
+ events,
1116
+ version: events[events.length - 1]?.version ?? asOfVersion,
1117
+ };
1118
+ });
1119
+ // ---------------------------------------------------------------------------
1120
+ // hasCommandSentinel (internal — see CommandSentinelProbe)
1121
+ // ---------------------------------------------------------------------------
1122
+ const hasCommandSentinel = (streamId, commandId) => Effect.gen(function* () {
1123
+ const client = yield* DynamoClient;
1124
+ const { name: tableName } = yield* config.table.Tag;
1125
+ const result = yield* client.getItem({
1126
+ TableName: tableName,
1127
+ Key: toAttributeMap({ pk: composeStreamPk(streamId), sk: composeCommandSk(commandId) }),
1128
+ ConsistentRead: true,
1129
+ ProjectionExpression: "#pk",
1130
+ ExpressionAttributeNames: { "#pk": "pk" },
589
1131
  });
1132
+ return result.Item !== undefined;
590
1133
  });
591
1134
  // ---------------------------------------------------------------------------
592
1135
  // query.events helper
@@ -608,8 +1151,69 @@ export const makeStream = (config) => {
608
1151
  // `Query.where` replaces this — the `__edd_e__` filter still applies.
609
1152
  }).pipe(Query.where({ beginsWith: eventSkPrefix }));
610
1153
  };
1154
+ // ---------------------------------------------------------------------------
1155
+ // Stream indexes — query.index / readIndex (#140)
1156
+ // ---------------------------------------------------------------------------
1157
+ /**
1158
+ * A query of one stream index, scoped to the stream's partition: the LSI
1159
+ * shares the table's `pk`; a GSI's `pk` attribute carries the same value.
1160
+ * No sort-key condition — the index holds only this stream's indexed events
1161
+ * under that key; `Query.where` narrows the derived-key range.
1162
+ *
1163
+ * @throws `[EDD-9066]` for a name the stream does not declare (unreachable
1164
+ * through the types).
1165
+ */
1166
+ const buildIndexQuery = (name, streamId) => {
1167
+ const idx = streamIndexes.find((candidate) => candidate.name === name);
1168
+ if (idx === undefined) {
1169
+ throw new Error(`[EDD-9066] EventStream "${config.streamName}": unknown index "${String(name)}". ` +
1170
+ (streamIndexes.length === 0
1171
+ ? "The stream declares no indexes."
1172
+ : `Declared: ${streamIndexes.map((candidate) => `"${candidate.name}"`).join(", ")}.`));
1173
+ }
1174
+ return Query.make({
1175
+ tableName: "",
1176
+ indexName: idx.index,
1177
+ pkField: idx.pk,
1178
+ pkValue: composeStreamPk(streamId),
1179
+ skField: idx.sk,
1180
+ entityTypes: [entityType],
1181
+ decoder: (raw) => decodeStreamEvent(raw),
1182
+ resolveTableName: config.table.Tag.useSync((tc) => tc.name),
1183
+ // A GSI is read only eventually consistently: `Query.consistentRead`
1184
+ // on it is refused before anything is sent.
1185
+ globalIndex: idx.type === "gsi",
1186
+ keyFields: [idx.pk, idx.sk, "pk", "sk"],
1187
+ });
1188
+ };
1189
+ const readIndex = (name, streamId, options) => Effect.gen(function* () {
1190
+ const refuse = (cause) => new ValidationError({ entityType, operation: "EventStore.readIndex", cause });
1191
+ const invalidCondition = invalidIndexKeyCondition(options);
1192
+ if (invalidCondition !== undefined) {
1193
+ return yield* refuse(`${invalidCondition} Nothing was sent.`);
1194
+ }
1195
+ const limit = options?.limit;
1196
+ if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) {
1197
+ return yield* refuse(`limit must be a positive integer; received ${String(limit)}. Nothing was sent.`);
1198
+ }
1199
+ let query = buildIndexQuery(name, streamId);
1200
+ if (options?.beginsWith !== undefined) {
1201
+ query = Query.where(query, { beginsWith: options.beginsWith });
1202
+ }
1203
+ else if (options?.between !== undefined) {
1204
+ query = Query.where(query, { between: options.between });
1205
+ }
1206
+ if (options?.reverse === true)
1207
+ query = Query.reverse(query);
1208
+ if (limit !== undefined)
1209
+ query = Query.limit(query, limit);
1210
+ if (options?.consistentRead === true)
1211
+ query = Query.consistentRead(query);
1212
+ return yield* Query.collect(query);
1213
+ });
611
1214
  const queryNamespace = {
612
1215
  events: (streamId) => buildEventsQuery(streamId),
1216
+ index: (name, streamId) => buildIndexQuery(name, streamId),
613
1217
  };
614
1218
  // ---------------------------------------------------------------------------
615
1219
  // Return EventStream
@@ -628,13 +1232,19 @@ export const makeStream = (config) => {
628
1232
  streamName: config.streamName,
629
1233
  eventSchema: eventUnion,
630
1234
  snapshotConfig: snapshotSettings,
1235
+ indexes: indexSettings,
631
1236
  writeSnapshot,
632
1237
  readSnapshot,
633
1238
  append,
634
1239
  read,
635
1240
  readFrom,
636
1241
  currentVersion,
1242
+ readLatest,
1243
+ readIndex,
637
1244
  query: queryNamespace,
1245
+ [StreamIdFormatter]: composeStreamIdString,
1246
+ [CommandSentinelProbe]: hasCommandSentinel,
1247
+ [HeadAboveProbe]: headAbove,
638
1248
  };
639
1249
  };
640
1250
  // ---------------------------------------------------------------------------
@@ -657,6 +1267,8 @@ export const makeStream = (config) => {
657
1267
  export const bind = (stream) => Effect.gen(function* () {
658
1268
  const ctx = yield* Effect.context();
659
1269
  const provide = (effect) => Effect.provide(effect, ctx);
1270
+ const probe = commandSentinelProbeOf(stream);
1271
+ const headProbe = headAboveProbeOf(stream);
660
1272
  return {
661
1273
  [EventStreamTypeId]: EventStreamTypeId,
662
1274
  pipe() {
@@ -666,106 +1278,419 @@ export const bind = (stream) => Effect.gen(function* () {
666
1278
  streamName: stream.streamName,
667
1279
  eventSchema: stream.eventSchema,
668
1280
  snapshotConfig: stream.snapshotConfig,
1281
+ indexes: stream.indexes,
669
1282
  writeSnapshot: (streamId, state, asOfVersion) => provide(stream.writeSnapshot(streamId, state, asOfVersion)),
670
1283
  readSnapshot: (streamId) => provide(stream.readSnapshot(streamId)),
671
1284
  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)),
1285
+ read: (streamId, options) => provide(stream.read(streamId, options)),
1286
+ readFrom: (streamId, afterVersion, options) => provide(stream.readFrom(streamId, afterVersion, options)),
1287
+ currentVersion: (streamId, options) => provide(stream.currentVersion(streamId, options)),
1288
+ readLatest: (streamId, options) => provide(stream.readLatest(streamId, options)),
1289
+ readIndex: (name, streamId, options) => provide(stream.readIndex(name, streamId, options)),
675
1290
  query: stream.query,
676
1291
  provide,
1292
+ [StreamIdFormatter]: (id) => formatStreamIdOf(stream, id),
1293
+ [CommandSentinelProbe]: probe === undefined
1294
+ ? undefined
1295
+ : (id, commandId) => provide(probe(id, commandId)),
1296
+ [HeadAboveProbe]: headProbe === undefined
1297
+ ? undefined
1298
+ : (id, floorVersion, options) => provide(headProbe(id, floorVersion, options)),
677
1299
  };
678
1300
  });
1301
+ /**
1302
+ * Derive the `CreateTable` fragments for the stream indexes (#140) of the given
1303
+ * streams (`EventStream` or `BoundEventStream`) — `AttributeDefinitions`,
1304
+ * `LocalSecondaryIndexes` and `GlobalSecondaryIndexes`, each index projecting
1305
+ * `ALL` — to merge into the caller's `CreateTable` input. Event tables are not
1306
+ * derived by `Table.definition`, and the table's own `pk` / `sk` are not
1307
+ * included: add them (and, on a provisioned table, each GSI's
1308
+ * `ProvisionedThroughput`) yourself.
1309
+ *
1310
+ * An index shared by several streams with the same definition is emitted
1311
+ * once. Lists are sorted by index name, attributes by attribute name.
1312
+ *
1313
+ * - An LSI can only be created with the table, and caps the item collection
1314
+ * of every partition key value in the table at 10 GB — entity partitions
1315
+ * and streams without indexes included.
1316
+ * - The projection must stay `ALL`: events are decoded from the index item.
1317
+ *
1318
+ * @example
1319
+ * ```typescript
1320
+ * const fragments = EventStore.indexDefinitions(Entries, Orders)
1321
+ * yield* client.createTable({
1322
+ * TableName: "events",
1323
+ * BillingMode: "PAY_PER_REQUEST",
1324
+ * KeySchema: [
1325
+ * { AttributeName: "pk", KeyType: "HASH" },
1326
+ * { AttributeName: "sk", KeyType: "RANGE" },
1327
+ * ],
1328
+ * AttributeDefinitions: [
1329
+ * { AttributeName: "pk", AttributeType: "S" },
1330
+ * { AttributeName: "sk", AttributeType: "S" },
1331
+ * ...fragments.AttributeDefinitions,
1332
+ * ],
1333
+ * LocalSecondaryIndexes: fragments.LocalSecondaryIndexes,
1334
+ * GlobalSecondaryIndexes: fragments.GlobalSecondaryIndexes,
1335
+ * })
1336
+ * ```
1337
+ *
1338
+ * @throws `[EDD-9067]` when two streams define the same physical index
1339
+ * differently (another type, or other key attributes).
1340
+ */
1341
+ export const indexDefinitions = (...streams) => {
1342
+ const byIndex = new Map();
1343
+ for (const stream of streams) {
1344
+ for (const [name, settings] of Object.entries(stream.indexes)) {
1345
+ const owner = `index "${name}" of stream "${stream.streamName}"`;
1346
+ const existing = byIndex.get(settings.index);
1347
+ if (existing === undefined) {
1348
+ byIndex.set(settings.index, {
1349
+ type: settings.type,
1350
+ index: settings.index,
1351
+ pk: settings.pk,
1352
+ sk: settings.sk,
1353
+ owner,
1354
+ });
1355
+ continue;
1356
+ }
1357
+ if (existing.type !== settings.type ||
1358
+ existing.pk !== settings.pk ||
1359
+ existing.sk !== settings.sk) {
1360
+ const describe = (s) => `${s.type} (pk "${s.pk}", sk "${s.sk}")`;
1361
+ throw new Error(`[EDD-9067] EventStore.indexDefinitions: the physical index "${settings.index}" is ` +
1362
+ `defined as ${describe(existing)} by ${existing.owner} and as ${describe(settings)} ` +
1363
+ `by ${owner}.`);
1364
+ }
1365
+ }
1366
+ }
1367
+ const attributes = new Set();
1368
+ const lsis = [];
1369
+ const gsis = [];
1370
+ const sorted = [...byIndex.values()].sort((a, b) => a.index.localeCompare(b.index));
1371
+ for (const settings of sorted) {
1372
+ const definition = {
1373
+ IndexName: settings.index,
1374
+ KeySchema: [
1375
+ { AttributeName: settings.pk, KeyType: "HASH" },
1376
+ { AttributeName: settings.sk, KeyType: "RANGE" },
1377
+ ],
1378
+ Projection: { ProjectionType: "ALL" },
1379
+ };
1380
+ attributes.add(settings.sk);
1381
+ if (settings.type === "gsi") {
1382
+ attributes.add(settings.pk);
1383
+ gsis.push(definition);
1384
+ }
1385
+ else {
1386
+ lsis.push(definition);
1387
+ }
1388
+ }
1389
+ return {
1390
+ AttributeDefinitions: [...attributes]
1391
+ .sort()
1392
+ .map((name) => ({ AttributeName: name, AttributeType: "S" })),
1393
+ ...(lsis.length > 0 ? { LocalSecondaryIndexes: lsis } : {}),
1394
+ ...(gsis.length > 0 ? { GlobalSecondaryIndexes: gsis } : {}),
1395
+ };
1396
+ };
679
1397
  /** @internal Both `EventStream` and `BoundEventStream` carry this brand. */
680
1398
  const hasEventStreamBrand = (u) => typeof u === "object" && u !== null && EventStreamTypeId in u;
1399
+ /** @internal Resolve a {@link CommandOptions.additionalItems} against a decision. */
1400
+ const deriveAdditionalItems = (input, decision) => {
1401
+ if (typeof input !== "function")
1402
+ return Effect.succeed(input);
1403
+ const derived = input(decision);
1404
+ return Effect.isEffect(derived) ? derived : Effect.succeed(derived);
1405
+ };
681
1406
  /** @internal */
682
1407
  const makeCommandHandlerImpl = (decider, stream, options) => {
1408
+ // `verifySnapshot: false` is refused up front unless the stream writes its
1409
+ // snapshot in every append (EDD-9068) — at handler construction, so a
1410
+ // misconfiguration fails at startup rather than on the first command.
1411
+ const snapshotSettings = stream.snapshotConfig;
1412
+ if (options?.verifySnapshot === false) {
1413
+ const refusal = unverifiedSnapshotRefusal(stream.streamName, snapshotSettings, "commandHandler");
1414
+ if (refusal !== undefined)
1415
+ throw new Error(refusal);
1416
+ }
1417
+ // Ignored on a stream without a snapshot config: there is no snapshot to trust.
1418
+ const unverifiedLoads = options?.verifySnapshot === false && snapshotSettings !== undefined;
683
1419
  const retryPolicy = options?.retry;
684
1420
  const schedule = retryPolicy === undefined
685
1421
  ? undefined
686
1422
  : typeof retryPolicy === "number"
687
1423
  ? Schedule.recurs(retryPolicy)
688
1424
  : retryPolicy;
1425
+ // Consistent by default: an eventually consistent load can hand `decide`
1426
+ // state that misses acknowledged events (#139).
1427
+ const readOptions = { consistentRead: options?.consistentRead ?? true };
1428
+ const unverifiedReadOptions = { ...readOptions, verifySnapshot: false };
689
1429
  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.
1430
+ const expectedVersion = callOptions?.expectedVersion;
1431
+ // 1. Establish the base state + version. A snapshot-configured stream
1432
+ // loads its snapshot, the events after it and its head in one request
1433
+ // (#138) — or, unverified, the snapshot item alone; any other stream
1434
+ // replays from the beginning.
1435
+ const load = (verify) => Effect.gen(function* () {
706
1436
  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);
1437
+ if (snapshotSettings !== undefined) {
1438
+ const latest = (yield* stream.readLatest(streamId, verify ? readOptions : unverifiedReadOptions));
1439
+ let snapshotAsOfVersion = 0;
1440
+ if (Option.isSome(latest.snapshot)) {
1441
+ snapshotAsOfVersion = latest.snapshot.value.asOfVersion;
1442
+ state = latest.snapshot.value.state;
719
1443
  }
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) {
1444
+ for (const event of latest.events) {
727
1445
  state = decider.evolve(state, event.data);
728
1446
  }
729
- const newest = events[events.length - 1];
730
- baseVersion = newest === undefined ? 0 : newest.version;
1447
+ const loaded = {
1448
+ state,
1449
+ version: latest.version,
1450
+ snapshotAsOfVersion,
1451
+ // An unverified read returns a snapshot alone; without one it has
1452
+ // already fallen back to the verified read.
1453
+ verified: verify || Option.isNone(latest.snapshot),
1454
+ eventsAfterSnapshot: latest.events.length,
1455
+ };
1456
+ return loaded;
731
1457
  }
732
- // 2. Decide
733
- const newEvents = yield* decider.decide(command, state);
734
- // 3. No-op command — return current state
1458
+ const events = yield* stream.read(streamId, readOptions);
1459
+ for (const event of events) {
1460
+ state = decider.evolve(state, event.data);
1461
+ }
1462
+ const loaded = {
1463
+ state,
1464
+ version: events[events.length - 1]?.version ?? 0,
1465
+ snapshotAsOfVersion: 0,
1466
+ verified: true,
1467
+ eventsAfterSnapshot: events.length,
1468
+ };
1469
+ return loaded;
1470
+ });
1471
+ // 4–8. Commit one decision: fold, derive items, append, snapshot. With
1472
+ // `returnAppendConflict`, an append that fails with `VersionConflict`
1473
+ // returns the conflict instead of failing, so the caller can tell a
1474
+ // stale unverified load from a lost race.
1475
+ const commit = (loaded, newEvents, returnAppendConflict) => Effect.gen(function* () {
1476
+ const baseVersion = loaded.version;
1477
+ const state = loaded.state;
1478
+ // 4. No-op command — return current state
735
1479
  if (newEvents.length === 0) {
736
- return { state, version: baseVersion, events: [] };
1480
+ const noop = {
1481
+ state,
1482
+ version: baseVersion,
1483
+ events: [],
1484
+ };
1485
+ return noop;
737
1486
  }
738
- // 4. Append with optimistic concurrency, plus any caller-owned items and
739
- // the dedup sentinel, all in one transaction.
1487
+ // 5. Fold the new events BEFORE appending (#137): the post-decision
1488
+ // state is what a function-form `additionalItems` projects, what a
1489
+ // snapshot records, and what the handler returns — always the
1490
+ // `evolve` fold, never anything produced inside `decide`.
1491
+ const previous = state;
1492
+ let next = state;
1493
+ for (const event of newEvents) {
1494
+ next = decider.evolve(next, event);
1495
+ }
1496
+ // 6. Derive the caller's items from the decision. Re-run on every
1497
+ // attempt (and on a re-decision), because the whole cycle is the
1498
+ // retried unit.
1499
+ const additionalItems = yield* deriveAdditionalItems(callOptions?.additionalItems, {
1500
+ events: newEvents,
1501
+ state: next,
1502
+ previous,
1503
+ version: baseVersion,
1504
+ });
1505
+ // 7. Append with optimistic concurrency, plus the caller's items, the
1506
+ // dedup sentinel and an inline snapshot, all in one transaction.
1507
+ // `baseVersion` equals any caller-supplied `expectedVersion` here
1508
+ // (checked in step 2).
740
1509
  const appendOptions = {};
741
1510
  if (callOptions?.metadata !== undefined)
742
1511
  appendOptions.metadata = callOptions.metadata;
743
- if (callOptions?.additionalItems !== undefined) {
744
- appendOptions.additionalItems = callOptions.additionalItems;
745
- }
1512
+ if (additionalItems !== undefined)
1513
+ appendOptions.additionalItems = additionalItems;
746
1514
  if (options?.idempotency !== undefined && callOptions?.commandId !== undefined) {
747
1515
  appendOptions.idempotency =
748
1516
  options.idempotency.ttl !== undefined
749
1517
  ? { commandId: callOptions.commandId, ttl: options.idempotency.ttl }
750
1518
  : { commandId: callOptions.commandId };
751
1519
  }
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.
1520
+ // Inline snapshots (#138) ride in the append transaction: on every
1521
+ // append, or once `every` events have accumulated since the snapshot.
1522
+ const mode = snapshotSettings?.mode ?? "after-append";
760
1523
  const every = snapshotSettings?.every;
761
- if (every !== undefined && result.version - snapshotAsOfVersion >= every) {
1524
+ const newVersion = baseVersion + newEvents.length;
1525
+ if (snapshotSettings !== undefined &&
1526
+ mode === "inline" &&
1527
+ (every === undefined || newVersion - loaded.snapshotAsOfVersion >= every)) {
1528
+ appendOptions.snapshot = next;
1529
+ }
1530
+ const appended = stream.append(streamId, newEvents, baseVersion, appendOptions);
1531
+ const outcome = yield* returnAppendConflict
1532
+ ? appended.pipe(Effect.catchIf((error) => error instanceof VersionConflict, (conflict) => Effect.succeed(conflict)))
1533
+ : appended;
1534
+ if (outcome instanceof VersionConflict)
1535
+ return outcome;
1536
+ const result = outcome;
1537
+ // 8. After-append snapshots, once the cadence threshold is crossed.
1538
+ // Best-effort: the events are already durable, so a snapshot-write
1539
+ // failure must not report the command as failed. The next threshold
1540
+ // crossing retries it.
1541
+ if (mode === "after-append" &&
1542
+ every !== undefined &&
1543
+ result.version - loaded.snapshotAsOfVersion >= every) {
762
1544
  yield* stream
763
- .writeSnapshot(streamId, state, result.version)
1545
+ .writeSnapshot(streamId, next, result.version)
764
1546
  .pipe(Effect.catchCause((cause) => Effect.logWarning(`EventStore: snapshot write failed for stream "${stream.streamName}" at version ${result.version}`, cause)));
765
1547
  }
766
- return { state, version: result.version, events: newEvents };
1548
+ const done = {
1549
+ state: next,
1550
+ version: result.version,
1551
+ events: newEvents,
1552
+ };
1553
+ return done;
1554
+ });
1555
+ // The If-Match answer for a stream at `actualVersion` (#136). A
1556
+ // redelivery of a command that already committed (its response was lost)
1557
+ // arrives with its original If-Match, which the stream has since moved
1558
+ // past. It is a duplicate, not a lost race — `append` ranks
1559
+ // `DuplicateCommand` above `VersionConflict` for the same reason — so
1560
+ // consult the sentinel before reporting the conflict. The extra read is
1561
+ // paid on this conflict path only.
1562
+ const ifMatchMismatch = (expected, actualVersion) => Effect.gen(function* () {
1563
+ const probe = commandSentinelProbeOf(stream);
1564
+ const commandId = callOptions?.commandId;
1565
+ if (options?.idempotency !== undefined && commandId !== undefined && probe !== undefined) {
1566
+ if (yield* probe(streamId, commandId)) {
1567
+ return yield* new DuplicateCommand({
1568
+ streamName: stream.streamName,
1569
+ streamId: formatStreamIdOf(stream, streamId),
1570
+ commandId,
1571
+ });
1572
+ }
1573
+ }
1574
+ return yield* new VersionConflict({
1575
+ streamName: stream.streamName,
1576
+ streamId: formatStreamIdOf(stream, streamId),
1577
+ expectedVersion: expected,
1578
+ actualVersion,
1579
+ });
767
1580
  });
768
- return schedule === undefined
1581
+ // The head, known to be at least `floorVersion` (see HeadAboveProbe): one
1582
+ // `Query` that reads no items while the snapshot is current.
1583
+ const headAbove = (floorVersion) => {
1584
+ const probe = headAboveProbeOf(stream);
1585
+ return probe !== undefined
1586
+ ? probe(streamId, floorVersion, readOptions)
1587
+ : stream.currentVersion(streamId, readOptions);
1588
+ };
1589
+ const attempt = Effect.gen(function* () {
1590
+ // Backstop for JS callers and `any`-shaped call sites: silently degrading
1591
+ // to at-least-once would look like success right up until the day a
1592
+ // duplicate mattered. Not retryable — `while` below only retries
1593
+ // `VersionConflict`, so this surfaces on the first attempt.
1594
+ if (options?.idempotency !== undefined && callOptions?.commandId === undefined) {
1595
+ return yield* new ValidationError({
1596
+ entityType: stream.streamName,
1597
+ operation: "EventStore.commandHandler",
1598
+ cause: "commandId is required when commandHandler is configured with `idempotency`.",
1599
+ });
1600
+ }
1601
+ // A malformed If-Match (`NaN` from a failed parse, a negative or
1602
+ // fractional number) is a caller bug, not a conflict — refuse it before
1603
+ // reading anything.
1604
+ if (expectedVersion !== undefined &&
1605
+ (!Number.isInteger(expectedVersion) || expectedVersion < 0)) {
1606
+ return yield* new ValidationError({
1607
+ entityType: stream.streamName,
1608
+ operation: "EventStore.commandHandler",
1609
+ cause: `expectedVersion must be a non-negative integer; received ${String(expectedVersion)}.`,
1610
+ });
1611
+ }
1612
+ // 1. Load (see `load`).
1613
+ let loaded = yield* load(!unverifiedLoads);
1614
+ // An unverified snapshot at another version than the caller's If-Match
1615
+ // may merely lag the head: re-read verified, so only a genuine mismatch
1616
+ // is reported, with the verified head as its `actualVersion`.
1617
+ if (expectedVersion !== undefined && !loaded.verified && loaded.version !== expectedVersion) {
1618
+ loaded = yield* load(true);
1619
+ }
1620
+ // 2. If-Match (#136): the caller saw a different version, so `decide`
1621
+ // must not run against state the caller never saw.
1622
+ if (expectedVersion !== undefined && expectedVersion !== loaded.version) {
1623
+ return yield* ifMatchMismatch(expectedVersion, loaded.version);
1624
+ }
1625
+ // 3. Decide on a verified load, and commit. An If-Match call's
1626
+ // append-time conflict is its answer (never re-read or retried), and
1627
+ // a verified load's conflict is a lost race.
1628
+ if (loaded.verified) {
1629
+ const newEvents = yield* decider.decide(command, loaded.state);
1630
+ return (yield* commit(loaded, newEvents, false));
1631
+ }
1632
+ // 3'. Decide on an unverified snapshot. It is normally at the head, but
1633
+ // events appended without one (a raw `append`, data written before
1634
+ // inline mode) make it lag — and then every answer made on it is
1635
+ // stale, not only one that appends. So no answer is returned until
1636
+ // the head is confirmed: an append confirms it by succeeding; a
1637
+ // decision that appends nothing (a `decide` failure or a no-op) and
1638
+ // an append conflict are confirmed with one more read.
1639
+ const decided = yield* Effect.result(decider.decide(command, loaded.state));
1640
+ const first = Result.isSuccess(decided) && decided.success.length > 0
1641
+ ? yield* commit(loaded, decided.success, true)
1642
+ : undefined;
1643
+ if (first !== undefined && !(first instanceof VersionConflict))
1644
+ return first;
1645
+ // The answer the first decision gives when the snapshot proves current.
1646
+ const firstAnswer = first !== undefined
1647
+ ? Effect.fail(first)
1648
+ : Result.isFailure(decided)
1649
+ ? Effect.fail(decided.failure)
1650
+ : Effect.succeed({ state: loaded.state, version: loaded.version, events: [] });
1651
+ // With an If-Match equal to the snapshot's `asOfVersion`, the snapshot
1652
+ // state is exactly the state the caller saw, so `decide` ran on the
1653
+ // right state — the only question is whether the stream is still at
1654
+ // that version. A newer head answers exactly as a verified load would
1655
+ // have (the pre-decide check above): `DuplicateCommand` for a committed
1656
+ // redelivery, else `VersionConflict` with the head as `actualVersion`.
1657
+ // The first decision is discarded. Never re-decided: an If-Match call's
1658
+ // conflict is its answer.
1659
+ if (expectedVersion !== undefined) {
1660
+ const head = yield* headAbove(expectedVersion);
1661
+ if (head !== expectedVersion)
1662
+ return yield* ifMatchMismatch(expectedVersion, head);
1663
+ return yield* firstAnswer;
1664
+ }
1665
+ // No If-Match, nothing appended: a head at the snapshot's version
1666
+ // confirms the answer (one `Query` above the snapshot's version, which
1667
+ // reads no items while the snapshot is current).
1668
+ if (first === undefined) {
1669
+ const head = yield* headAbove(loaded.version);
1670
+ if (head === loaded.version)
1671
+ return yield* firstAnswer;
1672
+ }
1673
+ // No If-Match, the append conflicted — the snapshot lagged the head, or
1674
+ // another writer won the race; one verified load tells them apart. A
1675
+ // snapshot at the head is a genuine race, which the retry policy
1676
+ // handles as it always has (a retried attempt loads unverified again).
1677
+ // A lagging snapshot is decided on again, once: only a successful inline
1678
+ // append rewrites the snapshot, so surfacing the conflict would fail
1679
+ // every command on the stream until something else repaired it.
1680
+ const verified = yield* load(true);
1681
+ if (first !== undefined && verified.eventsAfterSnapshot === 0)
1682
+ return yield* first;
1683
+ // The first decision was made on stale state: decide again on the
1684
+ // verified state — `decide` (and a function-form `additionalItems`)
1685
+ // runs a second time in this attempt. Its own conflict follows the
1686
+ // retry policy.
1687
+ const newEvents = yield* decider.decide(command, verified.state);
1688
+ return (yield* commit(verified, newEvents, false));
1689
+ });
1690
+ // A caller-supplied `expectedVersion` is a conditional write: its
1691
+ // `VersionConflict` (pre-decide or from the append) is the answer, never a
1692
+ // cue to re-read and re-decide.
1693
+ return schedule === undefined || expectedVersion !== undefined
769
1694
  ? attempt
770
1695
  : Effect.retry(attempt, {
771
1696
  // `while` runs before the schedule, so the schedule only ever sees a
@@ -805,12 +1730,51 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
805
1730
  * idempotency: { ttl: Duration.days(1) },
806
1731
  * })
807
1732
  * yield* handle({ matchId: "m-1" }, command, { commandId: "cmd-7f3a" })
1733
+ *
1734
+ * // Load state from the inline snapshot alone (mode "inline" without `every`)
1735
+ * const handle = EventStore.commandHandler(decider, stream, { verifySnapshot: false })
1736
+ *
1737
+ * // If-Match: fail with VersionConflict (never retried) unless the stream is at v7
1738
+ * yield* handle({ matchId: "m-1" }, command, { expectedVersion: 7 })
1739
+ *
1740
+ * // Inline projection, committed in the same transaction as the events
1741
+ * yield* handle({ matchId: "m-1" }, command, {
1742
+ * additionalItems: ({ state }) => [Scoreboard.put({ matchId: "m-1", runs: state.runs })],
1743
+ * })
808
1744
  * ```
809
1745
  *
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.
1746
+ * Each invocation runs
1747
+ * `load → [expectedVersion check] → decide → fold new events → derive items → append → [snapshot]`.
1748
+ *
1749
+ * - **Load.** Strongly consistent by default — see
1750
+ * {@link CommandHandlerOptions.consistentRead}. When the stream declares a
1751
+ * `snapshot` config, state is loaded with {@link EventStream.readLatest} —
1752
+ * the snapshot, the events after it and the head in one request in steady
1753
+ * state — and only those events are folded, instead of replaying the stream
1754
+ * from the beginning. On a `mode: "inline"` stream without `every`,
1755
+ * {@link CommandHandlerOptions.verifySnapshot}`: false` loads the snapshot
1756
+ * item alone (one `GetItem`); an answer that is not a successful append is
1757
+ * confirmed against the head first, and a snapshot that proves stale falls
1758
+ * back to the verified load — in which case `decide` may run twice.
1759
+ * - **expectedVersion.** See {@link CommandOptions.expectedVersion}.
1760
+ * - **Fold before append.** The new events are folded into state before the
1761
+ * append, so the state returned, snapshotted and handed to a function-form
1762
+ * `additionalItems` (see {@link Decision}) is always the `evolve` fold.
1763
+ * - **Snapshot.** With `snapshot.mode: "inline"`, the snapshot is written in
1764
+ * the append transaction itself — on every append, or at the `every`
1765
+ * cadence. With the default `"after-append"` and `snapshot.every` set, a
1766
+ * fresh snapshot is written (best-effort) after a successful append once the
1767
+ * cadence threshold is crossed. See {@link SnapshotMode}.
1768
+ * - **One atomic append.** One command, one decision, one append: a decision
1769
+ * too large for one transaction fails with `AppendTooLarge` before anything
1770
+ * is written. A large command is split by the application into stepped
1771
+ * commands — each an ordinary call, chained by `expectedVersion` — so a
1772
+ * failure partway leaves the stream at a real intermediate state.
1773
+ *
1774
+ * The handler is generic per call over the error and requirements of a
1775
+ * function-form `additionalItems` that returns an `Effect`: they join the
1776
+ * handler's error channel and requirements (on a `BoundEventStream` handler
1777
+ * they are its only requirements).
814
1778
  *
815
1779
  * Without `idempotency`, command processing is **at-least-once**: a retry after
816
1780
  * an acked-but-lost response re-runs `decide` and appends again.