effect-dynamodb 1.22.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.
Files changed (68) hide show
  1. package/dist/Aggregate.d.ts +9 -4
  2. package/dist/Aggregate.d.ts.map +1 -1
  3. package/dist/Aggregate.js +644 -219
  4. package/dist/Aggregate.js.map +1 -1
  5. package/dist/Batch.d.ts +7 -0
  6. package/dist/Batch.d.ts.map +1 -1
  7. package/dist/Batch.js +132 -7
  8. package/dist/Batch.js.map +1 -1
  9. package/dist/Collection.d.ts.map +1 -1
  10. package/dist/Collection.js +13 -31
  11. package/dist/Collection.js.map +1 -1
  12. package/dist/DynamoClient.d.ts +40 -3
  13. package/dist/DynamoClient.d.ts.map +1 -1
  14. package/dist/DynamoClient.js +70 -19
  15. package/dist/DynamoClient.js.map +1 -1
  16. package/dist/Entity.d.ts +138 -55
  17. package/dist/Entity.d.ts.map +1 -1
  18. package/dist/Entity.js +3061 -626
  19. package/dist/Entity.js.map +1 -1
  20. package/dist/EventStore.d.ts +729 -53
  21. package/dist/EventStore.d.ts.map +1 -1
  22. package/dist/EventStore.js +1216 -231
  23. package/dist/EventStore.js.map +1 -1
  24. package/dist/Query.d.ts +75 -3
  25. package/dist/Query.d.ts.map +1 -1
  26. package/dist/Query.js +228 -30
  27. package/dist/Query.js.map +1 -1
  28. package/dist/Transaction.d.ts +13 -2
  29. package/dist/Transaction.d.ts.map +1 -1
  30. package/dist/Transaction.js +48 -38
  31. package/dist/Transaction.js.map +1 -1
  32. package/dist/index.d.ts +3 -3
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/internal/BoundCrud.d.ts +31 -20
  37. package/dist/internal/BoundCrud.d.ts.map +1 -1
  38. package/dist/internal/BoundCrud.js +1 -1
  39. package/dist/internal/BoundCrud.js.map +1 -1
  40. package/dist/internal/BoundQuery.d.ts +47 -2
  41. package/dist/internal/BoundQuery.d.ts.map +1 -1
  42. package/dist/internal/BoundQuery.js +182 -18
  43. package/dist/internal/BoundQuery.js.map +1 -1
  44. package/dist/internal/EntityCombinators.d.ts +32 -10
  45. package/dist/internal/EntityCombinators.d.ts.map +1 -1
  46. package/dist/internal/EntityCombinators.js +16 -7
  47. package/dist/internal/EntityCombinators.js.map +1 -1
  48. package/dist/internal/EntityOps.d.ts +44 -10
  49. package/dist/internal/EntityOps.d.ts.map +1 -1
  50. package/dist/internal/EntityOps.js +48 -5
  51. package/dist/internal/EntityOps.js.map +1 -1
  52. package/dist/internal/Expr.d.ts +23 -0
  53. package/dist/internal/Expr.d.ts.map +1 -1
  54. package/dist/internal/Expr.js +84 -9
  55. package/dist/internal/Expr.js.map +1 -1
  56. package/dist/internal/ItemSize.d.ts +38 -0
  57. package/dist/internal/ItemSize.d.ts.map +1 -0
  58. package/dist/internal/ItemSize.js +62 -0
  59. package/dist/internal/ItemSize.js.map +1 -0
  60. package/dist/internal/TransactWriteOps.d.ts +112 -19
  61. package/dist/internal/TransactWriteOps.d.ts.map +1 -1
  62. package/dist/internal/TransactWriteOps.js +210 -33
  63. package/dist/internal/TransactWriteOps.js.map +1 -1
  64. package/dist/internal/TransactableOps.d.ts +33 -14
  65. package/dist/internal/TransactableOps.d.ts.map +1 -1
  66. package/dist/internal/TransactableOps.js +46 -19
  67. package/dist/internal/TransactableOps.js.map +1 -1
  68. package/package.json +2 -2
@@ -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
- import { buildTransactWriteItems, } from "./internal/TransactWriteOps.js";
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,71 +743,12 @@ export const makeStream = (config) => {
310
743
  },
311
744
  };
312
745
  }));
313
- // Caller-owned items, compiled through the same builder
314
- // `Transaction.transactWrite` uses, so the two APIs cannot drift.
315
- const { items: additionalItems, provenance: additionalProvenance } = yield* buildTransactWriteItems(additionalOps, "EventStore.append.additionalItems");
316
- // Authoritative cap check: one additional op can compile to several items
317
- // (#113), so the pre-flight lower bound above is not sufficient. Reporting
318
- // the EXPANDED count is the point — "you passed 40 items" when the caller
319
- // passed 30 ops is baffling without it.
320
- if (fixedItems + additionalItems.length > TRANSACT_WRITE_ITEMS_LIMIT) {
321
- return yield* new AppendTooLarge({
322
- streamName: config.streamName,
323
- streamId: streamIdStr,
324
- count: fixedItems + additionalItems.length,
325
- limit: TRANSACT_WRITE_ITEMS_LIMIT,
326
- });
327
- }
328
- // Version-contiguity guard: `attribute_not_exists(pk)` on the event puts
329
- // only rejects STALE expected versions (the target slot already exists).
330
- // An AHEAD expectedVersion (e.g. 10 when the stream is at 3) would
331
- // silently write from version 11, leaving a permanent gap. When
332
- // expectedVersion > 0, require the event at exactly `expectedVersion` to
333
- // exist so the appended range is contiguous with the stream head. Its
334
- // failure surfaces as a ConditionalCheckFailed cancellation reason,
335
- // mapping to VersionConflict below just like a stale-version Put failure.
336
- //
337
- // Only when events are actually being written: the guard exists to stop an
338
- // AHEAD expectedVersion opening a permanent gap, and a zero-event append
339
- // (pure `additionalItems` / sentinel side-write) writes no version and so
340
- // can open no gap.
341
- const contiguityCheck = needsContiguityCheck
342
- ? [
343
- {
344
- ConditionCheck: {
345
- TableName: tableName,
346
- Key: toAttributeMap({ pk, sk: composeEventSk(expectedVersion) }),
347
- ConditionExpression: "attribute_exists(pk)",
348
- },
349
- },
350
- ]
351
- : [];
352
- // Item layout is load-bearing — cancellation reasons are positional:
353
- // [0, C) version-contiguity ConditionCheck (C is 0 or 1)
354
- // [C, C + E) event puts
355
- // [C + E, C + E + A) additional ITEMS (caller op order preserved)
356
- // C + E + A idempotency sentinel (last, so adding it never
357
- // shifts the additional-item indices the caller sees)
358
- //
359
- // `A` is the count of EMITTED items, which is >= the number of caller ops:
360
- // a `unique` / `retain` put expands into its item plus sentinels plus a
361
- // snapshot (#113). The 1:1 "item index == caller index" assumption is gone,
362
- // so the reason mapping below goes through `additionalProvenance` — the
363
- // caller-facing `indices` on `AdditionalItemConditionFailed` are still
364
- // indices into the caller's `additionalItems` array, unchanged.
365
- const transactItems = [
366
- ...contiguityCheck,
367
- ...eventItems,
368
- ...additionalItems,
369
- ];
370
- const checkCount = contiguityCheck.length;
371
- const eventCount = eventItems.length;
372
- const additionalCount = additionalItems.length;
373
- const sentinelIndex = idempotency !== undefined ? transactItems.length : -1;
746
+ // The idempotency sentinel: a Put guarded by `attribute_not_exists(pk)`.
747
+ let sentinelPut;
374
748
  if (idempotency !== undefined) {
375
749
  const sentinel = {
376
750
  pk,
377
- sk: DynamoSchema.composeKey(schema, commandKeyLabel, [idempotency.commandId], keyOptions),
751
+ sk: composeCommandSk(idempotency.commandId),
378
752
  __edd_e__: commandEntityType,
379
753
  streamId: streamIdStr,
380
754
  commandId: idempotency.commandId,
@@ -393,116 +767,183 @@ export const makeStream = (config) => {
393
767
  sentinel[resolveTtlAttributeName(tableConfig)] =
394
768
  DateTime.toEpochSeconds(nowDateTime) + ttlSeconds;
395
769
  }
396
- transactItems.push({
770
+ sentinelPut = {
397
771
  Put: {
398
772
  TableName: tableName,
399
773
  Item: toAttributeMap(sentinel),
400
774
  ConditionExpression: "attribute_not_exists(pk)",
401
775
  },
402
- });
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
+ };
403
796
  }
404
- yield* client.transactWriteItems({ TransactItems: transactItems }).pipe(Effect.mapError((error) => {
405
- if (!isAwsTransactionCancelled(error.cause)) {
406
- return error;
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
+ },
407
816
  }
408
- const reasons = (error.cause.CancellationReasons ?? []).map((r) => ({
409
- code: r?.Code,
410
- message: r?.Message,
411
- }));
412
- const failedAt = (index) => index >= 0 && reasons[index]?.code === "ConditionalCheckFailed";
413
- // Precedence is ordered by how terminal the caller's response should
414
- // be: a duplicate can never succeed on retry, a version conflict
415
- // invites a re-read, and only then is the caller's own condition the
416
- // most specific explanation left.
417
- if (idempotency !== undefined && failedAt(sentinelIndex)) {
418
- return new DuplicateCommand({
419
- streamName: config.streamName,
420
- streamId: streamIdStr,
421
- commandId: idempotency.commandId,
422
- });
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");
423
842
  }
424
- // The contiguity ConditionCheck and the event puts both mean "the
425
- // stream is not where you said it was", so they share one verdict.
426
- for (let i = 0; i < checkCount + eventCount; i++) {
427
- if (failedAt(i)) {
428
- return new VersionConflict({
429
- streamName: config.streamName,
430
- streamId: streamIdStr,
431
- expectedVersion,
432
- });
433
- }
843
+ const guardCount = items.length;
844
+ for (const [i, item] of eventItems.entries()) {
845
+ pushStreamItem(item, entityType, `the event at version ${expectedVersion + i + 1}`);
434
846
  }
435
- // Attribute each failed additional ITEM back to the caller OP that
436
- // produced it. Several items can belong to one op (its main item, its
437
- // sentinels, its snapshot), so indices are deduped — a caller who
438
- // passed one op must never see it reported twice.
439
- const failedOps = new Set();
440
- for (let i = 0; i < additionalCount; i++) {
441
- if (!failedAt(checkCount + eventCount + i))
442
- continue;
443
- const from = additionalProvenance[i];
444
- // A reason with no provenance entry cannot be justified positionally;
445
- // fall through to TransactionCancelled rather than guess.
446
- if (from !== undefined)
447
- failedOps.add(from.opIndex);
448
- }
449
- const failedAdditional = Array.from(failedOps).sort((a, b) => a - b);
450
- if (failedAdditional.length > 0) {
451
- return new AdditionalItemConditionFailed({
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
+ };
866
+ // A guarded put among the additional items reads its item; a race with
867
+ // that read cancels the append, which is then built and written again.
868
+ let lost;
869
+ for (let attempt = 0; attempt < GUARDED_TRANSACTION_ATTEMPTS; attempt++) {
870
+ // Caller-owned items, compiled through the same builder
871
+ // `Transaction.transactWrite` uses, so the two APIs cannot drift.
872
+ const additional = yield* buildTransactWriteItems(additionalOps, "EventStore.append.additionalItems");
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) {
879
+ return yield* new AppendTooLarge({
452
880
  streamName: config.streamName,
453
881
  streamId: streamIdStr,
454
- indices: failedAdditional,
455
- reasons,
882
+ count: tx.items.length,
883
+ limit: TRANSACT_WRITE_ITEMS_LIMIT,
456
884
  });
457
885
  }
458
- // No conditional failure we can positionally justify (throttling,
459
- // TransactionConflict, or a truncated/absent reason list) — never
460
- // guess a VersionConflict.
461
- return new TransactionCancelled({
462
- operation: "TransactWriteItems",
463
- reasons,
464
- cause: error.cause,
465
- });
466
- }));
467
- return {
468
- version: expectedVersion + events.length,
469
- events,
470
- };
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);
894
+ if (outcome === undefined) {
895
+ return { version: expectedVersion + events.length, events };
896
+ }
897
+ lost = outcome;
898
+ }
899
+ return yield* Effect.fail(lost);
471
900
  });
472
901
  // ---------------------------------------------------------------------------
473
902
  // read
474
903
  // ---------------------------------------------------------------------------
475
- const read = (streamId) => Effect.gen(function* () {
476
- 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);
477
908
  return yield* Query.collect(query);
478
909
  });
479
910
  // ---------------------------------------------------------------------------
480
911
  // readFrom
481
912
  // ---------------------------------------------------------------------------
482
- const readFrom = (streamId, afterVersion) => Effect.gen(function* () {
913
+ const readFrom = (streamId, afterVersion, options) => Effect.gen(function* () {
483
914
  // Versions are integers, so the inclusive lower bound `afterVersion + 1`
484
915
  // is exactly the old exclusive `#sk > eventSk(afterVersion)`. The upper
485
916
  // bound keeps the snapshot item (which sorts after every event) out of the
486
917
  // scanned range.
487
- 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);
488
919
  return yield* Query.collect(query);
489
920
  });
490
921
  // ---------------------------------------------------------------------------
491
922
  // currentVersion
492
923
  // ---------------------------------------------------------------------------
493
- const currentVersion = (streamId) => Effect.gen(function* () {
924
+ const currentVersion = (streamId, options) => Effect.gen(function* () {
494
925
  // Single page, not `collect`: with `Limit: 1` DynamoDB returns a
495
926
  // `LastEvaluatedKey` on every truncated page, so `collect` would walk the
496
927
  // whole partition one request per item. The `begins_with` bound on
497
928
  // `buildEventsQuery` guarantees the single evaluated item is the newest
498
929
  // *event* (never the snapshot, which sorts last).
499
- const query = buildEventsQuery(streamId).pipe(Query.reverse, Query.limit(1));
930
+ const query = withReadOptions(buildEventsQuery(streamId).pipe(Query.reverse, Query.limit(1)), options);
500
931
  const page = yield* Query.execute(query);
501
932
  const newest = page.items[0];
502
933
  if (newest === undefined)
503
934
  return 0;
504
935
  return newest.version;
505
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
+ });
506
947
  // ---------------------------------------------------------------------------
507
948
  // Snapshot primitives
508
949
  // ---------------------------------------------------------------------------
@@ -514,11 +955,7 @@ export const makeStream = (config) => {
514
955
  const client = yield* DynamoClient;
515
956
  const { name: tableName } = yield* config.table.Tag;
516
957
  const now = DateTime.formatIso(yield* DateTime.now);
517
- const encoded = yield* Schema.encodeUnknownEffect(snapshot.schema)(state).pipe(Effect.mapError((cause) => new ValidationError({
518
- entityType: snapshotEntityType,
519
- operation: "EventStore.writeSnapshot",
520
- cause,
521
- })));
958
+ const encoded = yield* encodeSnapshotState(state, "EventStore.writeSnapshot");
522
959
  const item = {
523
960
  pk: composeStreamPk(streamId),
524
961
  sk: snapshotSk,
@@ -540,9 +977,17 @@ export const makeStream = (config) => {
540
977
  })
541
978
  .pipe(Effect.catchIf((error) => isAwsConditionalCheckFailed(error.cause), () => Effect.void));
542
979
  });
543
- const readSnapshot = (streamId) => Effect.gen(function* () {
544
- if (snapshot === undefined)
545
- 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* () {
546
991
  const client = yield* DynamoClient;
547
992
  const { name: tableName } = yield* config.table.Tag;
548
993
  const result = yield* client.getItem({
@@ -551,21 +996,140 @@ export const makeStream = (config) => {
551
996
  pk: composeStreamPk(streamId),
552
997
  sk: snapshotSk,
553
998
  }),
554
- ConsistentRead: true,
999
+ ...(consistentRead ? { ConsistentRead: true } : {}),
555
1000
  });
556
- 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)
557
1008
  return Option.none();
558
- const raw = fromAttributeMap(result.Item);
559
- const state = yield* Schema.decodeUnknownEffect(snapshot.schema)(raw.state).pipe(Effect.mapError((cause) => new ValidationError({
560
- entityType: snapshotEntityType,
561
- operation: "EventStore.readSnapshot",
562
- cause,
563
- })));
564
- return Option.some({
565
- state,
566
- asOfVersion: raw.asOfVersion,
567
- 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" },
568
1131
  });
1132
+ return result.Item !== undefined;
569
1133
  });
570
1134
  // ---------------------------------------------------------------------------
571
1135
  // query.events helper
@@ -587,8 +1151,69 @@ export const makeStream = (config) => {
587
1151
  // `Query.where` replaces this — the `__edd_e__` filter still applies.
588
1152
  }).pipe(Query.where({ beginsWith: eventSkPrefix }));
589
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
+ });
590
1214
  const queryNamespace = {
591
1215
  events: (streamId) => buildEventsQuery(streamId),
1216
+ index: (name, streamId) => buildIndexQuery(name, streamId),
592
1217
  };
593
1218
  // ---------------------------------------------------------------------------
594
1219
  // Return EventStream
@@ -607,13 +1232,19 @@ export const makeStream = (config) => {
607
1232
  streamName: config.streamName,
608
1233
  eventSchema: eventUnion,
609
1234
  snapshotConfig: snapshotSettings,
1235
+ indexes: indexSettings,
610
1236
  writeSnapshot,
611
1237
  readSnapshot,
612
1238
  append,
613
1239
  read,
614
1240
  readFrom,
615
1241
  currentVersion,
1242
+ readLatest,
1243
+ readIndex,
616
1244
  query: queryNamespace,
1245
+ [StreamIdFormatter]: composeStreamIdString,
1246
+ [CommandSentinelProbe]: hasCommandSentinel,
1247
+ [HeadAboveProbe]: headAbove,
617
1248
  };
618
1249
  };
619
1250
  // ---------------------------------------------------------------------------
@@ -636,6 +1267,8 @@ export const makeStream = (config) => {
636
1267
  export const bind = (stream) => Effect.gen(function* () {
637
1268
  const ctx = yield* Effect.context();
638
1269
  const provide = (effect) => Effect.provide(effect, ctx);
1270
+ const probe = commandSentinelProbeOf(stream);
1271
+ const headProbe = headAboveProbeOf(stream);
639
1272
  return {
640
1273
  [EventStreamTypeId]: EventStreamTypeId,
641
1274
  pipe() {
@@ -645,106 +1278,419 @@ export const bind = (stream) => Effect.gen(function* () {
645
1278
  streamName: stream.streamName,
646
1279
  eventSchema: stream.eventSchema,
647
1280
  snapshotConfig: stream.snapshotConfig,
1281
+ indexes: stream.indexes,
648
1282
  writeSnapshot: (streamId, state, asOfVersion) => provide(stream.writeSnapshot(streamId, state, asOfVersion)),
649
1283
  readSnapshot: (streamId) => provide(stream.readSnapshot(streamId)),
650
1284
  append: (streamId, events, expectedVersion, options) => provide(stream.append(streamId, events, expectedVersion, options)),
651
- read: (streamId) => provide(stream.read(streamId)),
652
- readFrom: (streamId, afterVersion) => provide(stream.readFrom(streamId, afterVersion)),
653
- 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)),
654
1290
  query: stream.query,
655
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)),
656
1299
  };
657
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
+ };
658
1397
  /** @internal Both `EventStream` and `BoundEventStream` carry this brand. */
659
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
+ };
660
1406
  /** @internal */
661
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;
662
1419
  const retryPolicy = options?.retry;
663
1420
  const schedule = retryPolicy === undefined
664
1421
  ? undefined
665
1422
  : typeof retryPolicy === "number"
666
1423
  ? Schedule.recurs(retryPolicy)
667
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 };
668
1429
  return (streamId, command, callOptions) => {
669
- const attempt = Effect.gen(function* () {
670
- // Backstop for JS callers and `any`-shaped call sites: silently degrading
671
- // to at-least-once would look like success right up until the day a
672
- // duplicate mattered. Not retryable — `while` below only retries
673
- // `VersionConflict`, so this surfaces on the first attempt.
674
- if (options?.idempotency !== undefined && callOptions?.commandId === undefined) {
675
- return yield* new ValidationError({
676
- entityType: stream.streamName,
677
- operation: "EventStore.commandHandler",
678
- cause: "commandId is required when commandHandler is configured with `idempotency`.",
679
- });
680
- }
681
- const snapshotSettings = stream.snapshotConfig;
682
- // 1. Establish the base state + version — from a snapshot plus its delta
683
- // when the stream has snapshots enabled and one exists, otherwise from
684
- // 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* () {
685
1436
  let state = decider.initialState;
686
- let baseVersion = 0;
687
- let snapshotAsOfVersion = 0;
688
- const snapshot = snapshotSettings === undefined
689
- ? Option.none()
690
- : (yield* stream.readSnapshot(streamId));
691
- if (Option.isSome(snapshot)) {
692
- snapshotAsOfVersion = snapshot.value.asOfVersion;
693
- baseVersion = snapshot.value.asOfVersion;
694
- state = snapshot.value.state;
695
- const delta = yield* stream.readFrom(streamId, snapshot.value.asOfVersion);
696
- for (const event of delta) {
697
- 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;
698
1443
  }
699
- const newest = delta[delta.length - 1];
700
- if (newest !== undefined)
701
- baseVersion = newest.version;
702
- }
703
- else {
704
- const events = yield* stream.read(streamId);
705
- for (const event of events) {
1444
+ for (const event of latest.events) {
706
1445
  state = decider.evolve(state, event.data);
707
1446
  }
708
- const newest = events[events.length - 1];
709
- 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;
710
1457
  }
711
- // 2. Decide
712
- const newEvents = yield* decider.decide(command, state);
713
- // 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
714
1479
  if (newEvents.length === 0) {
715
- return { state, version: baseVersion, events: [] };
1480
+ const noop = {
1481
+ state,
1482
+ version: baseVersion,
1483
+ events: [],
1484
+ };
1485
+ return noop;
716
1486
  }
717
- // 4. Append with optimistic concurrency, plus any caller-owned items and
718
- // 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).
719
1509
  const appendOptions = {};
720
1510
  if (callOptions?.metadata !== undefined)
721
1511
  appendOptions.metadata = callOptions.metadata;
722
- if (callOptions?.additionalItems !== undefined) {
723
- appendOptions.additionalItems = callOptions.additionalItems;
724
- }
1512
+ if (additionalItems !== undefined)
1513
+ appendOptions.additionalItems = additionalItems;
725
1514
  if (options?.idempotency !== undefined && callOptions?.commandId !== undefined) {
726
1515
  appendOptions.idempotency =
727
1516
  options.idempotency.ttl !== undefined
728
1517
  ? { commandId: callOptions.commandId, ttl: options.idempotency.ttl }
729
1518
  : { commandId: callOptions.commandId };
730
1519
  }
731
- const result = yield* stream.append(streamId, newEvents, baseVersion, appendOptions);
732
- // 5. Evolve state through the new events
733
- for (const event of newEvents) {
734
- state = decider.evolve(state, event);
735
- }
736
- // 6. Auto-snapshot once the cadence threshold is crossed. Best-effort:
737
- // the events are already durable, so a snapshot-write failure must not
738
- // 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";
739
1523
  const every = snapshotSettings?.every;
740
- 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) {
741
1544
  yield* stream
742
- .writeSnapshot(streamId, state, result.version)
1545
+ .writeSnapshot(streamId, next, result.version)
743
1546
  .pipe(Effect.catchCause((cause) => Effect.logWarning(`EventStore: snapshot write failed for stream "${stream.streamName}" at version ${result.version}`, cause)));
744
1547
  }
745
- return { state, version: result.version, events: newEvents };
1548
+ const done = {
1549
+ state: next,
1550
+ version: result.version,
1551
+ events: newEvents,
1552
+ };
1553
+ return done;
746
1554
  });
747
- return schedule === undefined
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
+ });
1580
+ });
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
748
1694
  ? attempt
749
1695
  : Effect.retry(attempt, {
750
1696
  // `while` runs before the schedule, so the schedule only ever sees a
@@ -784,12 +1730,51 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
784
1730
  * idempotency: { ttl: Duration.days(1) },
785
1731
  * })
786
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
+ * })
787
1744
  * ```
788
1745
  *
789
- * When the stream declares a `snapshot` config, each invocation reads the
790
- * snapshot and folds only the events after it, instead of replaying the stream
791
- * from the beginning. With `snapshot.every` set, a fresh snapshot is written
792
- * (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).
793
1778
  *
794
1779
  * Without `idempotency`, command processing is **at-least-once**: a retry after
795
1780
  * an acked-but-lost response re-runs `decide` and appends again.