effect-dynamodb 1.23.0 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/Aggregate.d.ts +5 -2
  2. package/dist/Aggregate.d.ts.map +1 -1
  3. package/dist/Aggregate.js +136 -53
  4. package/dist/Aggregate.js.map +1 -1
  5. package/dist/Batch.d.ts +14 -5
  6. package/dist/Batch.d.ts.map +1 -1
  7. package/dist/Batch.js +184 -119
  8. package/dist/Batch.js.map +1 -1
  9. package/dist/Collection.d.ts.map +1 -1
  10. package/dist/Collection.js +2 -4
  11. package/dist/Collection.js.map +1 -1
  12. package/dist/DynamoClient.d.ts.map +1 -1
  13. package/dist/DynamoClient.js +42 -14
  14. package/dist/DynamoClient.js.map +1 -1
  15. package/dist/Entity.d.ts +19 -13
  16. package/dist/Entity.d.ts.map +1 -1
  17. package/dist/Entity.js +287 -226
  18. package/dist/Entity.js.map +1 -1
  19. package/dist/EventStore.d.ts +1060 -50
  20. package/dist/EventStore.d.ts.map +1 -1
  21. package/dist/EventStore.js +1607 -299
  22. package/dist/EventStore.js.map +1 -1
  23. package/dist/Query.d.ts +47 -14
  24. package/dist/Query.d.ts.map +1 -1
  25. package/dist/Query.js +190 -68
  26. package/dist/Query.js.map +1 -1
  27. package/dist/Table.d.ts +56 -6
  28. package/dist/Table.d.ts.map +1 -1
  29. package/dist/Table.js +134 -54
  30. package/dist/Table.js.map +1 -1
  31. package/dist/Transaction.d.ts +8 -0
  32. package/dist/Transaction.d.ts.map +1 -1
  33. package/dist/Transaction.js +67 -22
  34. package/dist/Transaction.js.map +1 -1
  35. package/dist/VectorSearchEmulation.d.ts +3 -2
  36. package/dist/VectorSearchEmulation.d.ts.map +1 -1
  37. package/dist/VectorSearchEmulation.js +14 -2
  38. package/dist/VectorSearchEmulation.js.map +1 -1
  39. package/dist/internal/BoundQuery.d.ts +3 -2
  40. package/dist/internal/BoundQuery.d.ts.map +1 -1
  41. package/dist/internal/BoundQuery.js.map +1 -1
  42. package/dist/internal/BoundVectorQuery.d.ts +5 -1
  43. package/dist/internal/BoundVectorQuery.d.ts.map +1 -1
  44. package/dist/internal/BoundVectorQuery.js +17 -5
  45. package/dist/internal/BoundVectorQuery.js.map +1 -1
  46. package/dist/internal/EntityCombinators.d.ts +8 -5
  47. package/dist/internal/EntityCombinators.d.ts.map +1 -1
  48. package/dist/internal/EntityCombinators.js +8 -5
  49. package/dist/internal/EntityCombinators.js.map +1 -1
  50. package/dist/internal/EntityOps.d.ts +1 -1
  51. package/dist/internal/Expr.d.ts +4 -0
  52. package/dist/internal/Expr.d.ts.map +1 -1
  53. package/dist/internal/Expr.js +6 -4
  54. package/dist/internal/Expr.js.map +1 -1
  55. package/dist/internal/ExpressionLimits.d.ts +45 -0
  56. package/dist/internal/ExpressionLimits.d.ts.map +1 -0
  57. package/dist/internal/ExpressionLimits.js +89 -0
  58. package/dist/internal/ExpressionLimits.js.map +1 -0
  59. package/dist/internal/Hydrate.d.ts +62 -0
  60. package/dist/internal/Hydrate.d.ts.map +1 -0
  61. package/dist/internal/Hydrate.js +191 -0
  62. package/dist/internal/Hydrate.js.map +1 -0
  63. package/dist/internal/ItemSize.d.ts +60 -24
  64. package/dist/internal/ItemSize.d.ts.map +1 -1
  65. package/dist/internal/ItemSize.js +122 -39
  66. package/dist/internal/ItemSize.js.map +1 -1
  67. package/dist/internal/TransactWriteOps.d.ts +22 -46
  68. package/dist/internal/TransactWriteOps.d.ts.map +1 -1
  69. package/dist/internal/TransactWriteOps.js +26 -95
  70. package/dist/internal/TransactWriteOps.js.map +1 -1
  71. package/dist/internal/WriteDiagnosis.d.ts +353 -0
  72. package/dist/internal/WriteDiagnosis.d.ts.map +1 -0
  73. package/dist/internal/WriteDiagnosis.js +1028 -0
  74. package/dist/internal/WriteDiagnosis.js.map +1 -0
  75. package/dist/internal/sparse_check.js +43 -79
  76. package/dist/internal/sparse_check.js.map +1 -1
  77. package/package.json +2 -2
@@ -4,18 +4,27 @@
4
4
  * Provides:
5
5
  * - `Decider` type for command-event-state modeling
6
6
  * - `makeStream` factory for creating event streams bound to a Table
7
- * - Core operations: `append`, `read`, `readFrom`, `currentVersion`
8
- * - Snapshot primitives: `writeSnapshot`, `readSnapshot`
9
- * - `commandHandler` combinator for read-decide-append cycle (snapshot-aware,
10
- * with an optional `VersionConflict` retry policy)
7
+ * - Core operations: `append` (atomic), `read`, `readFrom`, `currentVersion`,
8
+ * `readLatest` (reads optionally strongly consistent)
9
+ * - Snapshot primitives: `writeSnapshot`, `readSnapshot`, and inline snapshots
10
+ * written in the append transaction (`AppendOptions.snapshot`)
11
+ * - `commandHandler` combinator for the load-decide-fold-append cycle
12
+ * (snapshot-aware single-request loads, consistent loads, an optional
13
+ * `VersionConflict` retry policy, caller-supplied `expectedVersion`,
14
+ * decision-derived `additionalItems`, inline snapshots)
15
+ * - Stream indexes (`makeStream({ indexes })`): sub-streams ordered by a key
16
+ * derived from each event, on an LSI or GSI, each projecting all of the
17
+ * event, all but its metadata, or only its keys — `readIndex`,
18
+ * `query.index`, and `indexDefinitions` for the `CreateTable` fragments
11
19
  * - `fold` / `foldFrom` helpers for state reconstruction
12
20
  *
13
21
  * Built on the existing library primitives (DynamoSchema, KeyComposer, Query,
14
22
  * DynamoClient, Marshaller).
15
23
  */
24
+ import type { AttributeDefinition, GlobalSecondaryIndex, LocalSecondaryIndex } from "@aws-sdk/client-dynamodb";
16
25
  import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
17
26
  import { AdditionalItemConditionFailed, AppendTooLarge, type ConcurrentModification, DuplicateCommand, type OptimisticLockError, TransactionCancelled, type UniqueConstraintViolation, ValidationError, VersionConflict } from "@effect-dynamodb/schema/Errors.js";
18
- import { type Duration, Effect, Option, Pipeable, Schedule, Schema } from "effect";
27
+ import { DateTime, type Duration, Effect, Option, Pipeable, Schedule, Schema } from "effect";
19
28
  import { DynamoClient, type DynamoClientError } from "./DynamoClient.js";
20
29
  import { type TransactWriteOp } from "./internal/TransactWriteOps.js";
21
30
  import * as Query from "./Query.js";
@@ -24,8 +33,13 @@ import { type Table, type TableConfig } from "./Table.js";
24
33
  * A Decider encodes the command-event-state triad for an aggregate.
25
34
  *
26
35
  * - `decide` — given a command and current state, produce events (or fail with E)
27
- * - `evolve` — pure left fold: apply one event to a state
28
- * - `initialState` — starting state for a new aggregate
36
+ * - `evolve` — left fold: apply one event to a state. It may return new state or
37
+ * mutate the given state in place and return it; the library requires
38
+ * neither. State the library persists, returns or projects is always this
39
+ * fold, never anything produced inside `decide`.
40
+ * - `initialState` — starting state for a new aggregate. An `evolve` that
41
+ * mutates in place must not mutate a shared `initialState` object (declare it
42
+ * as a getter that returns a fresh value).
29
43
  */
30
44
  export interface Decider<State, Command, Event, E = never> {
31
45
  readonly decide: (command: Command, state: State) => Effect.Effect<ReadonlyArray<Event>, E>;
@@ -45,6 +59,11 @@ export interface StreamEvent<A, M = Record<string, unknown> | undefined> {
45
59
  readonly eventType: string;
46
60
  readonly data: A;
47
61
  readonly metadata: M;
62
+ /**
63
+ * ISO 8601 (UTC): when the event was appended, by the append's `Clock` — or
64
+ * the `eventTimestamps` entry it was appended with
65
+ * ({@link AppendOptionsBase.eventTimestamps}).
66
+ */
48
67
  readonly timestamp: string;
49
68
  }
50
69
  /**
@@ -80,16 +99,52 @@ export interface Snapshot<State> {
80
99
  * - `schema` — the state codec. Snapshot state round-trips through it
81
100
  * (`Schema.encodeUnknownEffect` on write, `Schema.decodeUnknownEffect` on
82
101
  * read), so transforming schemas work.
83
- * - `every` — optional auto-snapshot cadence for {@link commandHandler}: after a
84
- * successful append, write a fresh snapshot once at least this many events
85
- * have accumulated since the last one. Must be a positive integer.
102
+ * - `mode` — how {@link commandHandler} writes snapshots (see
103
+ * {@link SnapshotMode}). Default `"after-append"`.
104
+ * - `every` — optional snapshot cadence for {@link commandHandler}: write a
105
+ * fresh snapshot once at least this many events have accumulated since the
106
+ * last one. Must be a positive integer. With `mode: "after-append"` and no
107
+ * `every`, the handler never snapshots; with `mode: "inline"` and no `every`,
108
+ * it snapshots on every append.
109
+ *
110
+ * `every` also sizes the first page of {@link EventStream.readLatest}, which
111
+ * {@link commandHandler} loads state with: a snapshot that lags the head by up
112
+ * to `every` events still loads in one request. The first page is read whatever
113
+ * the actual lag, so every load reads up to `every + 1` items (`2` without
114
+ * `every`), including events the snapshot already covers, which are discarded.
115
+ * For an `"after-append"` stream that trades read capacity for requests: with
116
+ * `every: 100` and a snapshot one event behind, a load reads the snapshot and
117
+ * 100 events in one request, where a `GetItem` plus `readFrom` would read the
118
+ * snapshot and one event in two. Size `every` with that in mind, or use
119
+ * `mode: "inline"`, which keeps the snapshot current so the page stays small.
86
120
  */
87
121
  export interface SnapshotConfig<TSchema extends Schema.Top = Schema.Top> {
122
+ /**
123
+ * The state schema. Snapshot state is encoded through it, falling back to
124
+ * `decode → encode` (as events are) for a value not already in its type
125
+ * shape — so a `Schema.Class` state folded by an immutable `evolve` that
126
+ * spreads (`({ ...s, balance })`, a plain object) still encodes.
127
+ */
88
128
  readonly schema: TSchema;
129
+ readonly mode?: SnapshotMode | undefined;
89
130
  readonly every?: number | undefined;
90
131
  }
132
+ /**
133
+ * How {@link commandHandler} writes snapshots.
134
+ *
135
+ * - `"after-append"` (default) — a best-effort `writeSnapshot` after the append
136
+ * succeeds, at the `every` cadence. A failed snapshot write never fails the
137
+ * command; the snapshot simply lags until the next threshold crossing.
138
+ * - `"inline"` — the snapshot `Put` rides in the append's own transaction (see
139
+ * {@link AppendOptionsBase.snapshot}), on every append, or at the `every` cadence
140
+ * when one is set. The snapshot is then current after every command that
141
+ * writes one, so {@link EventStream.readLatest} loads state in one request.
142
+ * The snapshot counts towards the transaction's item and size limits.
143
+ */
144
+ export type SnapshotMode = "after-append" | "inline";
91
145
  /** The runtime snapshot settings exposed on a stream. */
92
146
  export interface SnapshotSettings {
147
+ readonly mode: SnapshotMode;
93
148
  readonly every: number | undefined;
94
149
  }
95
150
  /**
@@ -100,6 +155,12 @@ export interface SnapshotSettings {
100
155
  * `commandId` therefore cancels the whole transaction and surfaces as
101
156
  * `DuplicateCommand` — the events are never written twice.
102
157
  *
158
+ * The sentinel is consulted only by an append. Through `commandHandler`, a
159
+ * redelivery whose `decide` returns no events against the loaded state never
160
+ * appends, so it succeeds as a no-op instead of failing with
161
+ * `DuplicateCommand`. Whether a no-op redelivery needs to be told apart is the
162
+ * application's call (see {@link CommandOptionsBase.commandId}).
163
+ *
103
164
  * The sentinel is co-located in the stream's own partition, so `commandId`
104
165
  * uniqueness is scoped to the stream (which is what "have I already applied this
105
166
  * command to this aggregate?" asks). It is invisible to `read` / `readFrom` /
@@ -122,10 +183,47 @@ export interface AppendIdempotency {
122
183
  */
123
184
  readonly ttl?: Duration.Duration | string;
124
185
  }
125
- /** Options accepted by {@link EventStream.append}. */
126
- export interface AppendOptions<TMetadata> {
127
- /** Per-append metadata, validated against the stream's metadata schema when configured. */
186
+ /**
187
+ * The metadata of an {@link EventStream.append}: one value shared by every
188
+ * event (`metadata`), or one entry per event (`eventMetadata`) — never both.
189
+ *
190
+ * Passing both is a compile error, and a `ValidationError` at runtime for an
191
+ * untyped caller. `undefined` counts as absent for either, so
192
+ * `{ metadata: undefined, eventMetadata: [...] }` is not "both".
193
+ */
194
+ export type AppendMetadataOptions<TMetadata> = {
195
+ /**
196
+ * Metadata written onto **every** event of the append — a copy per item,
197
+ * encoded once through the stream's metadata schema when one is
198
+ * configured. For metadata that belongs to each event (an event id, the
199
+ * original occurrence time of an imported event) use `eventMetadata`:
200
+ * packing a list into `metadata` stores every entry on every item, so
201
+ * stored metadata grows with the square of the append size.
202
+ */
128
203
  readonly metadata?: TMetadata;
204
+ readonly eventMetadata?: undefined;
205
+ } | {
206
+ readonly metadata?: undefined;
207
+ /**
208
+ * Per-event metadata: entry `i` is written onto event `i` (and read back
209
+ * as that event's `StreamEvent.metadata`). Each defined entry is encoded
210
+ * through the stream's metadata schema, as `metadata` is; an `undefined`
211
+ * entry writes its event without a `metadata` attribute.
212
+ *
213
+ * Must have exactly one entry per event — a length mismatch fails with
214
+ * `ValidationError` before anything is read or written, also for an
215
+ * append of no events (where only `[]` is accepted).
216
+ */
217
+ readonly eventMetadata?: ReadonlyArray<TMetadata | undefined>;
218
+ };
219
+ /**
220
+ * The options of {@link EventStream.append} other than its metadata (see
221
+ * {@link AppendMetadataOptions}).
222
+ *
223
+ * `TState` is the stream's snapshot state type (`never` for a stream without a
224
+ * `snapshot` config, so `snapshot` cannot be supplied there).
225
+ */
226
+ export interface AppendOptionsBase<TState = never> {
129
227
  /**
130
228
  * Caller-owned transact items committed atomically with the events — the same
131
229
  * op union `Transaction.transactWrite` accepts (`EntityPut`, `EntityDelete`,
@@ -147,9 +245,305 @@ export interface AppendOptions<TMetadata> {
147
245
  readonly additionalItems?: ReadonlyArray<TransactWriteOp>;
148
246
  /** Opt in to exactly-once command processing — see {@link AppendIdempotency}. */
149
247
  readonly idempotency?: AppendIdempotency;
248
+ /**
249
+ * An inline snapshot: the stream's state as of this append's last event.
250
+ *
251
+ * The snapshot `Put` (`asOfVersion` = `expectedVersion + events.length`,
252
+ * encoded through the state schema) joins the **same transaction** as the
253
+ * events, after the idempotency sentinel, so it commits if and only if the
254
+ * events do. The `Put` is unconditional: the event puts already prove this
255
+ * writer owns `asOfVersion`, so it cannot regress the snapshot. It counts
256
+ * towards the item and size limits, and a cancellation reason at its
257
+ * position (throttling, for example) reports `TransactionCancelled`.
258
+ *
259
+ * `undefined` means no snapshot. Requires at least one event — with none,
260
+ * nothing proves the writer owns the version, and the append fails with
261
+ * `ValidationError`. On a stream without a `snapshot` config (where the type
262
+ * is `never`) it dies with `[EDD-9026]`, as `writeSnapshot` does.
263
+ */
264
+ readonly snapshot?: TState;
265
+ /**
266
+ * Per-event timestamps: entry `i` is written as event `i`'s `timestamp` (and
267
+ * read back as that event's `StreamEvent.timestamp`), for history imported
268
+ * with its original occurrence times. An `undefined` entry keeps the
269
+ * append's `Clock` time. Independent of `metadata` / `eventMetadata`.
270
+ *
271
+ * Written with `DateTime.formatIso` — the format the append's own time is
272
+ * written in — so a zoned `DateTime` is stored as its UTC instant.
273
+ *
274
+ * - Must have exactly one entry per event, each a valid `DateTime` (finite
275
+ * epoch) or `undefined` — anything else fails with `ValidationError` before anything is read or
276
+ * written, also for an append of no events (where only `[]` is accepted).
277
+ * - Only the event items change: the idempotency sentinel, its TTL and the
278
+ * inline snapshot record when the append was written.
279
+ * - No ordering is enforced. `version` orders a stream; timestamps may
280
+ * repeat, run backwards or lie in the future, as imported history can.
281
+ */
282
+ readonly eventTimestamps?: ReadonlyArray<DateTime.DateTime | undefined>;
150
283
  }
284
+ /**
285
+ * Options accepted by {@link EventStream.append}: {@link AppendOptionsBase}
286
+ * plus the exclusive `metadata` / `eventMetadata` pair
287
+ * ({@link AppendMetadataOptions}).
288
+ *
289
+ * A type alias rather than an interface, since it is an intersection with a
290
+ * union: an interface cannot extend it.
291
+ */
292
+ export type AppendOptions<TMetadata, TState = never> = AppendOptionsBase<TState> & AppendMetadataOptions<TMetadata>;
151
293
  /** Error channel of {@link EventStream.append}. */
152
294
  export type AppendError = VersionConflict | DuplicateCommand | AdditionalItemConditionFailed | AppendTooLarge | DynamoClientError | ValidationError | TransactionCancelled | UniqueConstraintViolation | OptimisticLockError | ConcurrentModification;
295
+ /**
296
+ * Options accepted by {@link EventStream.read}, {@link EventStream.readFrom} and
297
+ * {@link EventStream.currentVersion}.
298
+ *
299
+ * `consistentRead: true` sets `ConsistentRead` on every `Query` page, so the
300
+ * read observes every append acknowledged before it started (read-your-writes).
301
+ * Default `false`: eventually consistent, at half the read cost.
302
+ *
303
+ * {@link commandHandler} loads state with strongly consistent reads by default —
304
+ * see {@link CommandHandlerOptions.consistentRead}.
305
+ */
306
+ export interface ReadOptions {
307
+ readonly consistentRead?: boolean | undefined;
308
+ }
309
+ /**
310
+ * Options accepted by {@link EventStream.readLatest}: {@link ReadOptions} plus
311
+ * `verifySnapshot`.
312
+ *
313
+ * - `verifySnapshot` — default `true`: one `Query` reads the snapshot and the
314
+ * events after it, so the result is verified against the stream head.
315
+ * `false` reads **only the snapshot item**, with one `GetItem` (consistent
316
+ * per `consistentRead`), and returns
317
+ * `{ snapshot, events: [], version: snapshot.asOfVersion }` **without
318
+ * checking that no event follows it** — `version` is then the snapshot's
319
+ * version, not a verified head. When there is no snapshot item, it falls
320
+ * back to the verified read and returns that result.
321
+ *
322
+ * Why: a `Query`'s read capacity counts every item it reads, and the
323
+ * verified read's first page is the snapshot **plus** the newest event,
324
+ * even when the snapshot is current; a `GetItem` reads the snapshot alone.
325
+ * With large events that roughly halves the read capacity of a load.
326
+ *
327
+ * Only a stream whose snapshot config is `mode: "inline"` **without**
328
+ * `every` (the only mode that writes the snapshot in every append, so it is
329
+ * normally at the head) accepts `false`; any other snapshot config dies with
330
+ * `[EDD-9068]`. On a stream without a `snapshot` config the option is
331
+ * ignored. {@link commandHandler} exposes the same trade-off, with
332
+ * automatic fallbacks, as {@link CommandHandlerOptions.verifySnapshot}.
333
+ */
334
+ export interface ReadLatestOptions extends ReadOptions {
335
+ readonly verifySnapshot?: boolean | undefined;
336
+ }
337
+ /**
338
+ * A stream's latest state as {@link EventStream.readLatest} loads it.
339
+ *
340
+ * - `snapshot` — the stream's snapshot, if one exists (always `None` on a
341
+ * stream without a `snapshot` config).
342
+ * - `events` — the events after the snapshot (every event when there is
343
+ * none), ascending by version. Fold them onto the snapshot's state.
344
+ * - `version` — the stream head: the newest event's version, the snapshot's
345
+ * `asOfVersion` when no event follows it, `0` for an empty stream. With
346
+ * `verifySnapshot: false` (see {@link ReadLatestOptions}) and a snapshot
347
+ * present, it is the snapshot's `asOfVersion` and `events` is empty —
348
+ * **unverified**: events appended without a snapshot (a raw `append`, data
349
+ * written before `mode: "inline"`) may follow it.
350
+ */
351
+ export interface LatestState<TState, TEvent, M = Record<string, unknown> | undefined> {
352
+ readonly snapshot: Option.Option<Snapshot<TState>>;
353
+ readonly events: ReadonlyArray<StreamEvent<TEvent, M>>;
354
+ readonly version: number;
355
+ }
356
+ /**
357
+ * Derives an event's key in a stream index from the domain event (before it is
358
+ * encoded) and the version it is appended at.
359
+ *
360
+ * - A string is the event's sort key in the index. It is stored **raw**: no
361
+ * casing and no prefixing is applied, so compose it to sort the way you
362
+ * query (zero-pad numbers, for example).
363
+ * - `undefined` leaves the event out of the index (a sparse index).
364
+ * - An empty string (or any other value) fails the append with
365
+ * `ValidationError` before anything is written — DynamoDB rejects empty key
366
+ * values — as does a `key` that throws. A key over DynamoDB's 1024-byte
367
+ * sort-key limit is sent and rejected by DynamoDB; the append then fails
368
+ * with `ValidationError` naming the index and the event (#144), and nothing
369
+ * is written.
370
+ *
371
+ * The key is derived only when an event is appended. Events are never
372
+ * rewritten, so changing `key` later leaves events already stored under their
373
+ * old keys (or out of the index), and declaring an index on a stream that
374
+ * already holds events indexes only the events appended from then on. The
375
+ * library does not backfill: re-deriving and writing the index attributes on
376
+ * existing events is the application's decision (see `indexes` on
377
+ * {@link makeStream}).
378
+ */
379
+ export type StreamIndexKey<TEvent> = (event: TEvent, version: number) => string | undefined;
380
+ /**
381
+ * A stream index on a **local** secondary index (the default `type`). An LSI
382
+ * shares the table's partition key (`pk`), so its items are exactly the
383
+ * stream's indexed events, readable with strongly consistent reads.
384
+ *
385
+ * - `index` — the physical LSI name.
386
+ * - `sk` — the attribute carrying the derived key (the LSI's sort key).
387
+ * - `key` — see {@link StreamIndexKey}.
388
+ *
389
+ * An LSI must be created with the table. A table with any LSI caps the item
390
+ * collection of **every** partition key value in it at 10 GB — not only the
391
+ * indexed stream's partitions, but every stream's (indexed or not) and every
392
+ * entity partition sharing the table; a write that would exceed it fails. What
393
+ * the index copies from each event is its `projection` — see
394
+ * {@link StreamIndexProjection}.
395
+ */
396
+ export interface LocalStreamIndexConfig<TEvent> {
397
+ readonly type?: "lsi" | undefined;
398
+ readonly index: string;
399
+ readonly sk: string;
400
+ /** An LSI uses the table partition key; supplying `pk` is refused (`[EDD-9063]`). */
401
+ readonly pk?: undefined;
402
+ readonly key: StreamIndexKey<TEvent>;
403
+ /**
404
+ * What the index copies from each event — see {@link StreamIndexProjection}.
405
+ * Default `"all"`. Must match the deployed index.
406
+ */
407
+ readonly projection?: StreamIndexProjection | undefined;
408
+ }
409
+ /**
410
+ * A stream index on a **global** secondary index. `append` writes the stream's
411
+ * partition key value into the `pk` attribute of each indexed event, so the
412
+ * index is scoped to the same stream: the eventually consistent equivalent of
413
+ * an LSI, without the LSI's 10 GB item-collection cap. DynamoDB rejects a
414
+ * strongly consistent read of it, diagnosed as a `ValidationError` naming the
415
+ * index.
416
+ *
417
+ * Unlike an LSI, the GSI itself can be added to an existing table
418
+ * (`UpdateTable`), but that does not index the stream's existing events: the
419
+ * index attributes are written only by `append`, so only events appended after
420
+ * the index is declared are in it — see {@link StreamIndexKey}.
421
+ *
422
+ * - `index` — the physical GSI name.
423
+ * - `pk` — the attribute carrying the stream's partition key (the GSI's HASH key).
424
+ * - `sk` — the attribute carrying the derived key (the GSI's RANGE key).
425
+ * - `key` — see {@link StreamIndexKey}.
426
+ * - `projection` — see {@link StreamIndexProjection}.
427
+ */
428
+ export interface GlobalStreamIndexConfig<TEvent> {
429
+ readonly type: "gsi";
430
+ readonly index: string;
431
+ readonly pk: string;
432
+ readonly sk: string;
433
+ readonly key: StreamIndexKey<TEvent>;
434
+ /**
435
+ * What the index copies from each event — see {@link StreamIndexProjection}.
436
+ * Default `"all"`. Must match the deployed index.
437
+ */
438
+ readonly projection?: StreamIndexProjection | undefined;
439
+ }
440
+ /**
441
+ * One entry of `makeStream({ indexes })`: a sub-stream of the stream's events,
442
+ * ordered by a key derived from each event. See {@link LocalStreamIndexConfig}
443
+ * and {@link GlobalStreamIndexConfig}.
444
+ */
445
+ export type StreamIndexConfig<TEvent> = LocalStreamIndexConfig<TEvent> | GlobalStreamIndexConfig<TEvent>;
446
+ /**
447
+ * What a stream index copies from each event (`projection` on an entry of
448
+ * `makeStream({ indexes })`). {@link indexDefinitions} emits the matching
449
+ * `Projection`, and the reads choose their path from it.
450
+ *
451
+ * | Value | Emitted `Projection` | Read |
452
+ * |---|---|---|
453
+ * | `"all"` (default) | `ALL` | one query |
454
+ * | `"withoutMetadata"` | `INCLUDE` of every event attribute except `metadata` | one query; `metadata` is `undefined` (and typed so) |
455
+ * | `"keys"` | `INCLUDE ["__edd_e__"]` — **not** `KEYS_ONLY` | LSI: one query with `Select: ALL_ATTRIBUTES` (DynamoDB fetches each event from the table). GSI: the query, then `BatchGetItem` for the events of each page, in index order |
456
+ *
457
+ * `__edd_e__` is always projected because every read filters on it, and
458
+ * DynamoDB refuses a filter on an attribute an index does not project.
459
+ *
460
+ * `metadata` is often an event's largest attribute; `"withoutMetadata"` keeps
461
+ * it out of the index, and `"keys"` keeps the index entries minimal at the cost
462
+ * of a table read per event read (rounded up to 4 KB each).
463
+ *
464
+ * **The declared projection must match the deployed index.** The library does
465
+ * not migrate: an LSI's projection is fixed when the table is created, and a
466
+ * GSI's cannot be updated (it must be deleted and created again). Tables
467
+ * deployed with CDK or Terraform copy `Projection` from
468
+ * {@link indexDefinitions}' output. An index deployed narrower than declared
469
+ * fails reads: DynamoDB refuses the `__edd_e__` filter ("does not project one
470
+ * or more filter attributes"), or rows lack event attributes, which the read
471
+ * reports as a `ValidationError` (`operation: "EventStore.index.decode"`)
472
+ * naming the index.
473
+ */
474
+ export type StreamIndexProjection = "all" | "withoutMetadata" | "keys";
475
+ /**
476
+ * The runtime settings of one stream index, as exposed on
477
+ * {@link EventStream.indexes}. `pk` is the index's partition key attribute:
478
+ * the table's `pk` for an LSI, the configured attribute for a GSI.
479
+ * `projection` is resolved: `"all"` when the index declares none.
480
+ */
481
+ export interface StreamIndexSettings {
482
+ readonly type: "lsi" | "gsi";
483
+ readonly index: string;
484
+ readonly pk: string;
485
+ readonly sk: string;
486
+ readonly projection: StreamIndexProjection;
487
+ }
488
+ /**
489
+ * The `metadata` type of an event read through a stream index of projection
490
+ * `P`: `undefined` for a `"withoutMetadata"` index, which does not hold it,
491
+ * the stream's {@link StreamMetadata} otherwise.
492
+ */
493
+ export type IndexEventMetadata<TMetadata, P extends StreamIndexProjection> = [P] extends [
494
+ "withoutMetadata"
495
+ ] ? undefined : StreamMetadata<TMetadata>;
496
+ /** @internal The `projection` an index config declares (`"all"` when none). */
497
+ type ProjectionOf<C> = C extends {
498
+ readonly projection: infer P extends StreamIndexProjection;
499
+ } ? P : "all";
500
+ /**
501
+ * The projection of each index of a `makeStream({ indexes })` config, by name.
502
+ * `"all"` for an index that declares none.
503
+ */
504
+ export type IndexProjectionsOf<TIndexes> = {
505
+ readonly [K in keyof TIndexes & string]: ProjectionOf<TIndexes[K]>;
506
+ };
507
+ /** @internal The members a stream index entry may declare. */
508
+ type StreamIndexConfigKey = keyof LocalStreamIndexConfig<unknown> | keyof GlobalStreamIndexConfig<unknown>;
509
+ /**
510
+ * The `indexes` parameter of {@link makeStream}: each entry is a
511
+ * {@link StreamIndexConfig}, and a member it does not declare (a misspelled
512
+ * `projection`, say) is typed `never`, so it is a compile error. Inferring
513
+ * `TIndexes` from the entries turns off TypeScript's own excess-property check,
514
+ * which would otherwise catch it.
515
+ */
516
+ export type StreamIndexesConfig<TIndexes, TEvent> = {
517
+ readonly [K in keyof TIndexes]: StreamIndexConfig<TEvent> & {
518
+ readonly [P in Exclude<keyof TIndexes[K], StreamIndexConfigKey>]: never;
519
+ };
520
+ };
521
+ /**
522
+ * Options accepted by {@link EventStream.readIndex}.
523
+ *
524
+ * - `beginsWith` / `between` — a sort-key condition on the derived key (at most
525
+ * one of them, string bounds). Omitted, the whole sub-stream is read.
526
+ * DynamoDB rejects an empty bound, and a `between` whose lower bound sorts
527
+ * (by UTF-8 bytes) after its upper bound; the read then fails with a
528
+ * `ValidationError` naming the index and the bound, DynamoDB's error as its
529
+ * cause.
530
+ * - `reverse` — descending key order.
531
+ * - `limit` — return at most this many events (a positive integer).
532
+ * - `consistentRead` — strongly consistent read. DynamoDB rejects it on a
533
+ * GSI-backed index, which it reads only eventually consistently: the read
534
+ * fails with a `ValidationError` naming the index.
535
+ */
536
+ export type ReadIndexOptions = {
537
+ readonly reverse?: boolean | undefined;
538
+ readonly limit?: number | undefined;
539
+ readonly consistentRead?: boolean | undefined;
540
+ } & ({
541
+ readonly beginsWith?: string | undefined;
542
+ readonly between?: undefined;
543
+ } | {
544
+ readonly between?: readonly [string, string] | undefined;
545
+ readonly beginsWith?: undefined;
546
+ });
153
547
  type StreamIdInput<T extends ReadonlyArray<string>> = {
154
548
  readonly [K in T[number]]: string;
155
549
  };
@@ -160,14 +554,34 @@ export type EventStreamTypeId = typeof EventStreamTypeId;
160
554
  *
161
555
  * Created via {@link makeStream}. Operations are called directly on the stream:
162
556
  * `MatchEvents.append(...)`, `MatchEvents.read(...)`, `MatchEvents.query.events(...)`.
557
+ *
558
+ * `TIndexName` is the union of the stream's index names (`makeStream({ indexes })`),
559
+ * `never` for a stream without indexes, so {@link EventStream.readIndex} and
560
+ * `query.index` only accept a declared name. `TIndexProjections` is each
561
+ * index's {@link StreamIndexProjection} by name, so an event read through a
562
+ * `"withoutMetadata"` index has `metadata: undefined`.
163
563
  */
164
- export interface EventStream<TEvent, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TState = never> extends Pipeable.Pipeable {
564
+ export interface EventStream<TEvent, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TState = never, TIndexName extends string = never, TIndexProjections extends {
565
+ readonly [K in TIndexName]: StreamIndexProjection;
566
+ } = {
567
+ readonly [K in TIndexName]: "all";
568
+ }> extends Pipeable.Pipeable {
165
569
  readonly [EventStreamTypeId]: EventStreamTypeId;
166
570
  readonly streamName: string;
167
571
  readonly eventSchema: Schema.Top;
572
+ /**
573
+ * The stream's indexes (`makeStream({ indexes })`) by name — their physical
574
+ * index and key attributes. Empty for a stream without indexes.
575
+ * {@link indexDefinitions} derives `CreateTable` fragments from it.
576
+ */
577
+ readonly indexes: {
578
+ readonly [K in TIndexName]: StreamIndexSettings;
579
+ };
168
580
  /**
169
581
  * Present iff the stream was created with a `snapshot` config. Its presence
170
- * is what switches {@link commandHandler} onto the snapshot-aware read path.
582
+ * is what switches {@link commandHandler} onto the snapshot-aware read path
583
+ * ({@link EventStream.readLatest}); its `mode` decides how the handler writes
584
+ * snapshots.
171
585
  */
172
586
  readonly snapshotConfig: SnapshotSettings | undefined;
173
587
  /**
@@ -196,12 +610,90 @@ export interface EventStream<TEvent, TStreamIdFields extends ReadonlyArray<strin
196
610
  * hide state-schema evolution bugs.
197
611
  */
198
612
  readSnapshot(streamId: StreamIdInput<TStreamIdFields>): Effect.Effect<Option.Option<Snapshot<TState>>, DynamoClientError | ValidationError, DynamoClient | TableConfig>;
199
- append(streamId: StreamIdInput<TStreamIdFields>, events: ReadonlyArray<TEvent>, expectedVersion: number, options?: AppendOptions<TMetadata> | undefined): Effect.Effect<AppendResult<TEvent>, AppendError, DynamoClient | TableConfig>;
200
- read(streamId: StreamIdInput<TStreamIdFields>): Effect.Effect<ReadonlyArray<StreamEvent<TEvent, StreamMetadata<TMetadata>>>, DynamoClientError | ValidationError, DynamoClient | TableConfig>;
201
- readFrom(streamId: StreamIdInput<TStreamIdFields>, afterVersion: number): Effect.Effect<ReadonlyArray<StreamEvent<TEvent, StreamMetadata<TMetadata>>>, DynamoClientError | ValidationError, DynamoClient | TableConfig>;
202
- currentVersion(streamId: StreamIdInput<TStreamIdFields>): Effect.Effect<number, DynamoClientError | ValidationError, DynamoClient | TableConfig>;
613
+ /**
614
+ * Append `events` after `expectedVersion`, atomically, in one transaction —
615
+ * see {@link AppendOptions}.
616
+ * A method, like `writeSnapshot`, so a snapshot-less stream (whose
617
+ * `options.snapshot` is `never`) stays assignable to other streams.
618
+ */
619
+ append(streamId: StreamIdInput<TStreamIdFields>, events: ReadonlyArray<TEvent>, expectedVersion: number, options?: AppendOptions<TMetadata, TState> | undefined): Effect.Effect<AppendResult<TEvent>, AppendError, DynamoClient | TableConfig>;
620
+ /**
621
+ * Read every event of the stream, ascending by version. Pass
622
+ * `{ consistentRead: true }` for read-your-writes — see {@link ReadOptions}.
623
+ */
624
+ read(streamId: StreamIdInput<TStreamIdFields>, options?: ReadOptions | undefined): Effect.Effect<ReadonlyArray<StreamEvent<TEvent, StreamMetadata<TMetadata>>>, DynamoClientError | ValidationError, DynamoClient | TableConfig>;
625
+ /**
626
+ * Read the events after `afterVersion` (exclusive), ascending. Pass
627
+ * `{ consistentRead: true }` for read-your-writes — see {@link ReadOptions}.
628
+ */
629
+ readFrom(streamId: StreamIdInput<TStreamIdFields>, afterVersion: number, options?: ReadOptions | undefined): Effect.Effect<ReadonlyArray<StreamEvent<TEvent, StreamMetadata<TMetadata>>>, DynamoClientError | ValidationError, DynamoClient | TableConfig>;
630
+ /**
631
+ * The version of the stream's newest event (`0` for an empty stream), in one
632
+ * request. Pass `{ consistentRead: true }` for read-your-writes — see
633
+ * {@link ReadOptions}.
634
+ */
635
+ currentVersion(streamId: StreamIdInput<TStreamIdFields>, options?: ReadOptions | undefined): Effect.Effect<number, DynamoClientError | ValidationError, DynamoClient | TableConfig>;
636
+ /**
637
+ * The stream's snapshot (if any), the events after it, and its head — in
638
+ * **one request** in steady state. This is how {@link commandHandler} loads
639
+ * state on a snapshot-configured stream.
640
+ *
641
+ * Issues a single reverse `Query` over the sort-key range from the first
642
+ * event to the snapshot (the snapshot sorts after every event, and the
643
+ * idempotency sentinels before them, so the range holds exactly the events
644
+ * and the snapshot), with a first page of `(every ?? 1) + 1` items. Further
645
+ * pages are read only while the snapshot's `asOfVersion` has not been reached
646
+ * (a snapshot lagging by more than `every` events) — each sized to the events
647
+ * still missing, so a lagging snapshot costs one more request — or to the
648
+ * start of the stream when there is no snapshot. Pass
649
+ * `{ consistentRead: true }` for read-your-writes; it applies to the snapshot
650
+ * and the events alike.
651
+ *
652
+ * A snapshot that fails to decode through the state schema fails with
653
+ * `ValidationError`, as {@link EventStream.readSnapshot} does. On a stream
654
+ * without a `snapshot` config it is {@link EventStream.read} plus the head.
655
+ *
656
+ * `{ verifySnapshot: false }` reads only the snapshot item, with one
657
+ * `GetItem`, and returns it with no events and its `asOfVersion` as an
658
+ * **unverified** `version`; with no snapshot item it falls back to the
659
+ * verified read. Accepted only on a `mode: "inline"` stream without `every`
660
+ * (otherwise a defect, `[EDD-9068]`), ignored on a stream without a
661
+ * `snapshot` config — see {@link ReadLatestOptions}.
662
+ */
663
+ readLatest(streamId: StreamIdInput<TStreamIdFields>, options?: ReadLatestOptions | undefined): Effect.Effect<LatestState<TState, TEvent, StreamMetadata<TMetadata>>, DynamoClientError | ValidationError, DynamoClient | TableConfig>;
664
+ /**
665
+ * Read the stream's events in the order of one of its indexes (#140) — the
666
+ * events whose index `key` returned a string, ordered by that key. A
667
+ * convenience over `query.index`; pass `beginsWith` or `between` to narrow
668
+ * the key range, `reverse`, `limit` and `consistentRead` (which DynamoDB
669
+ * rejects on a GSI; the error names the index) — see {@link ReadIndexOptions}.
670
+ *
671
+ * How the events are read follows the index's {@link StreamIndexProjection}:
672
+ * one query for `"all"` and `"withoutMetadata"` (whose events have
673
+ * `metadata: undefined`, as the type says); for `"keys"`, one query with
674
+ * `Select: ALL_ATTRIBUTES` on an LSI, or the query followed by `BatchGetItem`
675
+ * (unprocessed keys retried, index order kept) on a GSI.
676
+ */
677
+ readIndex<N extends TIndexName>(name: N, streamId: StreamIdInput<TStreamIdFields>, options?: ReadIndexOptions | undefined): Effect.Effect<ReadonlyArray<StreamEvent<TEvent, IndexEventMetadata<TMetadata, TIndexProjections[N]>>>, DynamoClientError | ValidationError, DynamoClient | TableConfig>;
203
678
  readonly query: {
204
679
  events(streamId: StreamIdInput<TStreamIdFields>): Query.Query<StreamEvent<TEvent, StreamMetadata<TMetadata>>>;
680
+ /**
681
+ * A query of one of the stream's indexes (#140), scoped to `streamId`:
682
+ * decoded events in index-key order. It composes with the `Query`
683
+ * combinators — `Query.where` (a condition on the derived key),
684
+ * `Query.reverse`, `Query.limit`, `Query.consistentRead` (DynamoDB rejects
685
+ * it on a GSI-backed index: a `ValidationError` naming the index) — and terminals
686
+ * (`Query.collect`, `Query.execute`, `Query.paginate`). The
687
+ * `__edd_e__ = <stream>.event` filter still applies.
688
+ *
689
+ * It reads as {@link EventStream.readIndex} does for the index's
690
+ * projection: a `"keys"` GSI fetches each page's events with
691
+ * `BatchGetItem` (`Query.select` moves onto that fetch); `Query.count`
692
+ * counts index entries and fetches nothing. On a `"keys"` or
693
+ * `"withoutMetadata"` index, a `Query.filter` on an attribute the index
694
+ * does not project is refused by DynamoDB — use `Query.filterBy`.
695
+ */
696
+ index<N extends TIndexName>(name: N, streamId: StreamIdInput<TStreamIdFields>): Query.Query<StreamEvent<TEvent, IndexEventMetadata<TMetadata, TIndexProjections[N]>>>;
205
697
  };
206
698
  }
207
699
  /**
@@ -244,6 +736,10 @@ export interface EventStream<TEvent, TStreamIdFields extends ReadonlyArray<strin
244
736
  * })
245
737
  * ```
246
738
  *
739
+ * `snapshot.mode: "inline"` writes the snapshot in the append transaction
740
+ * itself, so it is current after every command and state loads in one
741
+ * request — see {@link SnapshotMode}.
742
+ *
247
743
  * `casing` overrides the key casing for this stream, as an index's or a vector
248
744
  * index's `casing` does: the stream name, stream-id values and command ids in
249
745
  * its keys all take it. When omitted, the stream name is lower-cased and the
@@ -254,11 +750,65 @@ export interface EventStream<TEvent, TStreamIdFields extends ReadonlyArray<strin
254
750
  * (`<stream>.event` etc.) are always lower-cased. In the next major, omitting
255
751
  * `casing` will mean the schema's casing, as it does for indexes.
256
752
  *
753
+ * `indexes` declares sub-streams ordered by a key derived from each event
754
+ * (#140), on a local (default) or global secondary index. `append` writes each
755
+ * event's derived key (and, for a GSI, the stream's partition key) on the
756
+ * event item; snapshots and idempotency sentinels never carry them, so they
757
+ * are never in an index. Read with {@link EventStream.readIndex} or
758
+ * `query.index`, and create the indexes with {@link indexDefinitions}. Each
759
+ * index's `projection` (default `"all"`) sets what it copies from each event
760
+ * and how it is read — see {@link StreamIndexProjection}.
761
+ *
762
+ * Index attributes are written only at append time and events are never
763
+ * rewritten. Declaring an index on a stream that already holds events, or
764
+ * changing an index's `key`, therefore leaves the earlier events out of the
765
+ * index (or under their old keys), and `readIndex` returns only what was
766
+ * indexed — without an error. The library does not backfill; whether and how
767
+ * to re-index existing events (for example, an `UpdateItem` per event setting
768
+ * the derived attributes) is the application's decision.
769
+ *
770
+ * @example
771
+ * ```typescript
772
+ * const Entries = EventStore.makeStream({
773
+ * table: EventsTable,
774
+ * streamName: "Ledger",
775
+ * events: [EntryRecorded, EntryVoided],
776
+ * streamId: { composite: ["ledgerId"] },
777
+ * indexes: {
778
+ * byEntry: {
779
+ * index: "lsi1",
780
+ * sk: "lsi1sk",
781
+ * key: (event, version) =>
782
+ * event._tag === "EntryRecorded" ? `ENTRY#${event.section}#${pad(version)}` : undefined,
783
+ * },
784
+ * // Every event, by the day it was recorded (both event types carry `day`).
785
+ * byDay: { type: "gsi", index: "gsi1", pk: "gsi1pk", sk: "gsi1sk", key: (e) => e.day },
786
+ * },
787
+ * })
788
+ *
789
+ * const sectionThree = yield* Entries.readIndex("byEntry", { ledgerId }, { beginsWith: "ENTRY#3#" })
790
+ * ```
791
+ *
257
792
  * @throws `[EDD-9027]` when `snapshot.every` is not a positive integer.
793
+ * @throws `[EDD-9062]` when `snapshot.mode` is neither `"after-append"` nor
794
+ * `"inline"`.
795
+ * @throws `[EDD-9063]` for a malformed index: a `type` other than `"lsi"` /
796
+ * `"gsi"`, a `gsi` without `pk`, an `lsi` with `pk`, an empty `index` / `sk`
797
+ * / `pk`, a `key` that is not a function, a `projection` other than
798
+ * `"all"` / `"withoutMetadata"` / `"keys"`, or a member an index entry does
799
+ * not declare (such as a misspelled `projection`, also a type error).
800
+ * @throws `[EDD-9064]` when an index attribute is one the stream writes itself
801
+ * (`pk`, `sk`, `__edd_e__`, `streamId`, `version`, `eventType`, `data`,
802
+ * `metadata`, `timestamp`, `asOfVersion`, `state`, `commandId`, or the
803
+ * default TTL attribute `_ttl`). A custom `TableConfig.ttlAttributeName`
804
+ * is only known at runtime: an index attribute equal to it fails each append
805
+ * with `ValidationError`.
806
+ * @throws `[EDD-9065]` when two indexes share a physical index name or an
807
+ * attribute (or a GSI's `pk` and `sk` are the same attribute).
258
808
  */
259
809
  export declare const makeStream: <const TEvents extends ReadonlyArray<Schema.Top>, TTable extends Table, const TStreamName extends string, const TStreamId extends {
260
810
  readonly composite: ReadonlyArray<string>;
261
- }, TMetadata extends Schema.Top | undefined = undefined, TSnapshot extends SnapshotConfig | undefined = undefined>(config: {
811
+ }, TMetadata extends Schema.Top | undefined = undefined, TSnapshot extends SnapshotConfig | undefined = undefined, const TIndexes extends { readonly [K in keyof TIndexes]: StreamIndexConfig<Schema.Schema.Type<TEvents[number]>>; } = {}>(config: {
262
812
  readonly table: TTable;
263
813
  readonly streamName: TStreamName;
264
814
  readonly events: TEvents;
@@ -266,7 +816,8 @@ export declare const makeStream: <const TEvents extends ReadonlyArray<Schema.Top
266
816
  readonly metadata?: TMetadata;
267
817
  readonly snapshot?: TSnapshot;
268
818
  readonly casing?: DynamoSchema.Casing | undefined;
269
- }) => EventStream<Schema.Schema.Type<TEvents[number]>, TStreamId["composite"], TMetadata extends Schema.Top ? Schema.Schema.Type<TMetadata> : undefined, TSnapshot extends SnapshotConfig<infer TStateSchema> ? Schema.Schema.Type<TStateSchema> : never>;
819
+ readonly indexes?: TIndexes & StreamIndexesConfig<TIndexes, Schema.Schema.Type<TEvents[number]>>;
820
+ }) => EventStream<Schema.Schema.Type<TEvents[number]>, TStreamId["composite"], TMetadata extends Schema.Top ? Schema.Schema.Type<TMetadata> : undefined, TSnapshot extends SnapshotConfig<infer TStateSchema> ? Schema.Schema.Type<TStateSchema> : never, keyof TIndexes & string, IndexProjectionsOf<TIndexes>>;
270
821
  /**
271
822
  * An EventStream whose operations have `DynamoClient` and `TableConfig` already
272
823
  * resolved, so all methods return `Effect<A, E, never>`.
@@ -287,22 +838,44 @@ export declare const makeStream: <const TEvents extends ReadonlyArray<Schema.Top
287
838
  * }) {}
288
839
  * ```
289
840
  */
290
- export interface BoundEventStream<TEvent, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TState = never> extends Pipeable.Pipeable {
841
+ export interface BoundEventStream<TEvent, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TState = never, TIndexName extends string = never, TIndexProjections extends {
842
+ readonly [K in TIndexName]: StreamIndexProjection;
843
+ } = {
844
+ readonly [K in TIndexName]: "all";
845
+ }> extends Pipeable.Pipeable {
291
846
  readonly [EventStreamTypeId]: EventStreamTypeId;
292
847
  readonly streamName: string;
293
848
  readonly eventSchema: Schema.Top;
294
849
  /** See {@link EventStream.snapshotConfig}. */
295
850
  readonly snapshotConfig: SnapshotSettings | undefined;
851
+ /** See {@link EventStream.indexes}. */
852
+ readonly indexes: {
853
+ readonly [K in TIndexName]: StreamIndexSettings;
854
+ };
296
855
  /** See {@link EventStream.writeSnapshot} — a method for the same variance reason. */
297
856
  writeSnapshot(streamId: StreamIdInput<TStreamIdFields>, state: TState, asOfVersion: number): Effect.Effect<void, DynamoClientError | ValidationError, never>;
298
857
  /** See {@link EventStream.readSnapshot}. */
299
858
  readSnapshot(streamId: StreamIdInput<TStreamIdFields>): Effect.Effect<Option.Option<Snapshot<TState>>, DynamoClientError | ValidationError, never>;
300
- append(streamId: StreamIdInput<TStreamIdFields>, events: ReadonlyArray<TEvent>, expectedVersion: number, options?: AppendOptions<TMetadata> | undefined): Effect.Effect<AppendResult<TEvent>, AppendError, never>;
301
- read(streamId: StreamIdInput<TStreamIdFields>): Effect.Effect<ReadonlyArray<StreamEvent<TEvent, StreamMetadata<TMetadata>>>, DynamoClientError | ValidationError, never>;
302
- readFrom(streamId: StreamIdInput<TStreamIdFields>, afterVersion: number): Effect.Effect<ReadonlyArray<StreamEvent<TEvent, StreamMetadata<TMetadata>>>, DynamoClientError | ValidationError, never>;
303
- currentVersion(streamId: StreamIdInput<TStreamIdFields>): Effect.Effect<number, DynamoClientError | ValidationError, never>;
859
+ /** See {@link EventStream.append}. */
860
+ append(streamId: StreamIdInput<TStreamIdFields>, events: ReadonlyArray<TEvent>, expectedVersion: number, options?: AppendOptions<TMetadata, TState> | undefined): Effect.Effect<AppendResult<TEvent>, AppendError, never>;
861
+ /** See {@link EventStream.read}. */
862
+ read(streamId: StreamIdInput<TStreamIdFields>, options?: ReadOptions | undefined): Effect.Effect<ReadonlyArray<StreamEvent<TEvent, StreamMetadata<TMetadata>>>, DynamoClientError | ValidationError, never>;
863
+ /** See {@link EventStream.readFrom}. */
864
+ readFrom(streamId: StreamIdInput<TStreamIdFields>, afterVersion: number, options?: ReadOptions | undefined): Effect.Effect<ReadonlyArray<StreamEvent<TEvent, StreamMetadata<TMetadata>>>, DynamoClientError | ValidationError, never>;
865
+ /** See {@link EventStream.currentVersion}. */
866
+ currentVersion(streamId: StreamIdInput<TStreamIdFields>, options?: ReadOptions | undefined): Effect.Effect<number, DynamoClientError | ValidationError, never>;
867
+ /** See {@link EventStream.readLatest}. */
868
+ readLatest(streamId: StreamIdInput<TStreamIdFields>, options?: ReadLatestOptions | undefined): Effect.Effect<LatestState<TState, TEvent, StreamMetadata<TMetadata>>, DynamoClientError | ValidationError, never>;
869
+ /** See {@link EventStream.readIndex}. */
870
+ readIndex<N extends TIndexName>(name: N, streamId: StreamIdInput<TStreamIdFields>, options?: ReadIndexOptions | undefined): Effect.Effect<ReadonlyArray<StreamEvent<TEvent, IndexEventMetadata<TMetadata, TIndexProjections[N]>>>, DynamoClientError | ValidationError, never>;
871
+ /**
872
+ * The stream's query builders — unbound: run them with {@link provide}, or
873
+ * where `DynamoClient` and `TableConfig` are provided.
874
+ */
304
875
  readonly query: {
305
876
  events(streamId: StreamIdInput<TStreamIdFields>): Query.Query<StreamEvent<TEvent, StreamMetadata<TMetadata>>>;
877
+ /** See `EventStream.query.index`. */
878
+ index<N extends TIndexName>(name: N, streamId: StreamIdInput<TStreamIdFields>): Query.Query<StreamEvent<TEvent, IndexEventMetadata<TMetadata, TIndexProjections[N]>>>;
306
879
  };
307
880
  /** Escape hatch: provide DynamoClient | TableConfig to an arbitrary effect. */
308
881
  readonly provide: <A, E>(effect: Effect.Effect<A, E, DynamoClient | TableConfig>) => Effect.Effect<A, E, never>;
@@ -321,7 +894,81 @@ export interface BoundEventStream<TEvent, TStreamIdFields extends ReadonlyArray<
321
894
  * yield* stream.append({ matchId: "m-1" }, [event], 0) // R = never
322
895
  * ```
323
896
  */
324
- export declare const bind: <TEvent, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TState>(stream: EventStream<TEvent, TStreamIdFields, TMetadata, TState>) => Effect.Effect<BoundEventStream<TEvent, TStreamIdFields, TMetadata, TState>, never, DynamoClient | TableConfig>;
897
+ export declare const bind: <TEvent, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TState, TIndexName extends string = never, TIndexProjections extends { readonly [K in TIndexName]: StreamIndexProjection; } = { readonly [K in TIndexName]: "all"; }>(stream: EventStream<TEvent, TStreamIdFields, TMetadata, TState, TIndexName, TIndexProjections>) => Effect.Effect<BoundEventStream<TEvent, TStreamIdFields, TMetadata, TState, TIndexName, TIndexProjections>, never, DynamoClient | TableConfig>;
898
+ /**
899
+ * `CreateTable` fragments for the stream indexes of one or more streams — see
900
+ * {@link indexDefinitions}. The lists are absent when empty, because
901
+ * DynamoDB rejects an empty index list.
902
+ */
903
+ export interface StreamIndexDefinitions {
904
+ /** The index key attributes (all strings), excluding the table's `pk` / `sk`. */
905
+ readonly AttributeDefinitions: Array<AttributeDefinition>;
906
+ readonly LocalSecondaryIndexes?: Array<LocalSecondaryIndex> | undefined;
907
+ readonly GlobalSecondaryIndexes?: Array<GlobalSecondaryIndex> | undefined;
908
+ }
909
+ /**
910
+ * Derive the `CreateTable` fragments for the stream indexes (#140) of the given
911
+ * streams (`EventStream` or `BoundEventStream`) — `AttributeDefinitions`,
912
+ * `LocalSecondaryIndexes` and `GlobalSecondaryIndexes` — to merge into the
913
+ * caller's `CreateTable` input. Event tables are not derived by
914
+ * `Table.definition`, and the table's own `pk` / `sk` are not included: add
915
+ * them (and, on a provisioned table, each GSI's `ProvisionedThroughput`)
916
+ * yourself.
917
+ *
918
+ * Each index's `Projection` follows its {@link StreamIndexProjection}:
919
+ *
920
+ * | `projection` | `Projection` |
921
+ * |---|---|
922
+ * | `"all"` (default) | `{ ProjectionType: "ALL" }` |
923
+ * | `"withoutMetadata"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__", "data", "eventType", "streamId", "timestamp", "version"] }` |
924
+ * | `"keys"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__"] }` — not `KEYS_ONLY`: every read filters on `__edd_e__` |
925
+ *
926
+ * The deployed index must project what is declared — copy `Projection` from
927
+ * this output into CDK or Terraform definitions. Neither an LSI's nor a GSI's
928
+ * projection can be changed in place, and the library migrates nothing. A
929
+ * table projects at most 100 `NonKeyAttributes` across all its indexes;
930
+ * `"withoutMetadata"` spends 6 of them and `"keys"` 1 (DynamoDB refuses a
931
+ * table over the limit).
932
+ *
933
+ * An index shared by several streams with the same definition is emitted
934
+ * once. Lists are sorted by index name, attributes by attribute name.
935
+ *
936
+ * - An LSI can only be created with the table, and caps the item collection
937
+ * of every partition key value in the table at 10 GB — entity partitions
938
+ * and streams without indexes included.
939
+ *
940
+ * @example
941
+ * ```typescript
942
+ * const fragments = EventStore.indexDefinitions(Entries, Orders)
943
+ * yield* client.createTable({
944
+ * TableName: "events",
945
+ * BillingMode: "PAY_PER_REQUEST",
946
+ * KeySchema: [
947
+ * { AttributeName: "pk", KeyType: "HASH" },
948
+ * { AttributeName: "sk", KeyType: "RANGE" },
949
+ * ],
950
+ * AttributeDefinitions: [
951
+ * { AttributeName: "pk", AttributeType: "S" },
952
+ * { AttributeName: "sk", AttributeType: "S" },
953
+ * ...fragments.AttributeDefinitions,
954
+ * ],
955
+ * LocalSecondaryIndexes: fragments.LocalSecondaryIndexes,
956
+ * GlobalSecondaryIndexes: fragments.GlobalSecondaryIndexes,
957
+ * })
958
+ * ```
959
+ *
960
+ * @throws `[EDD-9067]` when two streams define the same physical index
961
+ * differently (another type, other key attributes, or another projection).
962
+ */
963
+ export declare const indexDefinitions: (...streams: ReadonlyArray<{
964
+ readonly streamName: string;
965
+ readonly indexes: {
966
+ readonly [name: string]: Omit<StreamIndexSettings, "projection"> & {
967
+ /** Read as `"all"` when absent (a hand-built settings object). */
968
+ readonly projection?: StreamIndexProjection | undefined;
969
+ };
970
+ };
971
+ }>) => StreamIndexDefinitions;
325
972
  /**
326
973
  * Handler-level configuration for {@link commandHandler}.
327
974
  *
@@ -336,45 +983,350 @@ export interface CommandHandlerOptions {
336
983
  * The retried unit is the entire read–decide–append cycle, so every attempt
337
984
  * decides against freshly read state — a blind re-append of stale events is
338
985
  * impossible by construction. Snapshot reads participate: a retried attempt
339
- * re-reads the snapshot and its delta.
986
+ * re-reads the snapshot and its delta (the snapshot alone with
987
+ * {@link verifySnapshot}`: false`), and a function-form `additionalItems` is
988
+ * re-evaluated against the new decision.
340
989
  *
341
990
  * A number `n` is shorthand for `Schedule.recurs(n)` (n retries *after* the
342
991
  * initial attempt). Omit for the default: no retry.
343
992
  *
344
- * `DuplicateCommand` is deliberately NOT retried — it is terminal.
993
+ * `DuplicateCommand` is deliberately NOT retried — it is terminal. Nor is
994
+ * any call that supplies {@link CommandOptionsBase.expectedVersion}: the caller asked
995
+ * for a conditional write, so its `VersionConflict` is always surfaced.
345
996
  */
346
997
  readonly retry?: number | Schedule.Schedule<unknown, VersionConflict> | undefined;
347
998
  /** Opt in to exactly-once command processing — see {@link AppendIdempotency}. */
348
999
  readonly idempotency?: {
349
1000
  readonly ttl?: Duration.Duration | string;
350
1001
  };
1002
+ /**
1003
+ * Load state with strongly consistent reads. Default `true`.
1004
+ *
1005
+ * An eventually consistent load can miss the newest events, so `decide` runs
1006
+ * against stale state and the append then fails with `VersionConflict` (or
1007
+ * burns a retry). A consistent load costs twice the read capacity of an
1008
+ * eventually consistent one. Set `false` to trade that for the occasional
1009
+ * conflict. On a snapshot-configured stream the snapshot and its delta are
1010
+ * one query ({@link EventStream.readLatest}), so the setting covers both.
1011
+ */
1012
+ readonly consistentRead?: boolean | undefined;
1013
+ /**
1014
+ * Verify the snapshot against the stream head when loading state. Default
1015
+ * `true`.
1016
+ *
1017
+ * With `true`, a snapshot-configured stream loads with one `Query` that
1018
+ * reads the snapshot **and** the newest event (more on a lagging snapshot),
1019
+ * so the state `decide` sees is verified current. `false` loads with one
1020
+ * `GetItem` of the snapshot item only ({@link EventStream.readLatest} with
1021
+ * `verifySnapshot: false`). A `Query`'s read capacity counts every item it
1022
+ * reads, a `GetItem` only the snapshot, so with large events `false`
1023
+ * roughly halves the read capacity per command.
1024
+ *
1025
+ * Accepted only when the stream's snapshot config is `mode: "inline"`
1026
+ * **without** `every` — the only mode that writes the snapshot in every
1027
+ * append's transaction, so it is normally at the head. Any other snapshot
1028
+ * config throws `[EDD-9068]` when the handler is created (data-first or
1029
+ * data-last). On a stream without a `snapshot` config the option is
1030
+ * ignored: state is replayed and verified as always.
1031
+ *
1032
+ * The snapshot can still lag the head — events appended without one (a raw
1033
+ * `append`, data written before `mode: "inline"`). So no answer made on an
1034
+ * unverified snapshot is returned until the head confirms it: a successful
1035
+ * append confirms it by itself; anything else — a `decide` failure, a no-op
1036
+ * decision, an append `VersionConflict` — is checked with one more read,
1037
+ * and a stale load falls back to a verified one:
1038
+ *
1039
+ * - **No snapshot item** (a stream not yet snapshotted): the verified load
1040
+ * runs at once, and `decide` runs once.
1041
+ * - **With {@link CommandOptionsBase.expectedVersion}**: a snapshot whose
1042
+ * `asOfVersion` differs from it is re-read verified before the If-Match
1043
+ * check, so only a genuine mismatch is reported (its `actualVersion` is
1044
+ * the verified head). A snapshot at the expected version holds exactly the
1045
+ * state the caller saw, so `decide` runs on it; if that decision does not
1046
+ * append, one head `Query` above the If-Match checks the head (see
1047
+ * below). A head past the
1048
+ * If-Match discards the decision and answers as a verified load would:
1049
+ * `DuplicateCommand` for a committed redelivery, else `VersionConflict`
1050
+ * with the head as `actualVersion` — never re-decided or retried.
1051
+ * - **Without it**, a `decide` failure or a no-op is checked against the
1052
+ * head the same way and returned when the head is at the snapshot. An
1053
+ * append that fails with `VersionConflict` triggers one verified load:
1054
+ * a snapshot at the head means another writer won a genuine race, and
1055
+ * the `VersionConflict` goes to the {@link retry} policy as it always
1056
+ * does. Otherwise — the head past the snapshot, or past the version
1057
+ * decided on — the first decision was made on stale state and is
1058
+ * discarded: the handler loads verified (if it has not) and decides again
1059
+ * on that state, then appends — **`decide` (and a function-form
1060
+ * `additionalItems`, for a decision that appends) runs twice for that
1061
+ * call**. That is the optimistic-concurrency price of skipping the
1062
+ * verification read, the same one a `VersionConflict` retry pays: a
1063
+ * decision is only as current as the state it was made on, and a stale
1064
+ * one cannot be patched, only made again. Without the re-decision, a
1065
+ * lagging snapshot would fail or mis-answer every command on the stream,
1066
+ * since only a successful inline append rewrites it. It happens at most
1067
+ * once per attempt, and the second decision's outcome is final for the
1068
+ * attempt (its conflict follows the policy).
1069
+ *
1070
+ * The head check is a reverse `Limit: 1` `Query` bounded below by the
1071
+ * snapshot's version (or the If-Match), never a read of the newest event:
1072
+ * while the snapshot is current the range is empty, so it reads no items
1073
+ * and costs only DynamoDB's per-request minimum, however large the events
1074
+ * are. A decision that appends nothing therefore costs one extra round trip,
1075
+ * not extra item reads.
1076
+ */
1077
+ readonly verifySnapshot?: boolean | undefined;
351
1078
  }
352
- /** Per-call options accepted by a handler produced by {@link commandHandler}. */
353
- export interface CommandOptions<TMetadata> {
1079
+ /**
1080
+ * The outcome of one `decide` call, handed to a function-form
1081
+ * {@link CommandOptionsBase.additionalItems}, to `eventMetadata`
1082
+ * ({@link CommandMetadataOptions}) and to
1083
+ * {@link CommandOptionsBase.eventTimestamps}.
1084
+ *
1085
+ * - `events` — what `decide` returned (never empty: the function is not called
1086
+ * for a no-op decision).
1087
+ * - `state` — the `evolve` fold of `previous` with `events`: the state the
1088
+ * handler returns and snapshots.
1089
+ * - `previous` — the state `decide` was given.
1090
+ * - `version` — the stream version the events are appended after; the new
1091
+ * events take `version + 1 … version + events.length`.
1092
+ *
1093
+ * `evolve` may mutate state in place. When it does, `previous` and `state` are
1094
+ * the same reference and `previous` already reflects `events`; a projection
1095
+ * that needs a pristine `previous` requires an `evolve` that returns new state.
1096
+ */
1097
+ export interface Decision<State, Event> {
1098
+ readonly events: ReadonlyArray<Event>;
1099
+ readonly state: State;
1100
+ readonly previous: State;
1101
+ readonly version: number;
1102
+ }
1103
+ /**
1104
+ * The forms {@link CommandOptionsBase.additionalItems} accepts: a static array of
1105
+ * transact ops, or a function of the {@link Decision} returning the array
1106
+ * directly (pure projection) or as an `Effect` (a projection that needs a read).
1107
+ * The effect's `E2` joins the handler's error channel and its `R2` the handler's
1108
+ * requirements.
1109
+ */
1110
+ export type AdditionalItemsInput<State, Event, E2 = never, R2 = never> = ReadonlyArray<TransactWriteOp> | ((decision: Decision<State, Event>) => ReadonlyArray<TransactWriteOp> | Effect.Effect<ReadonlyArray<TransactWriteOp>, E2, R2>);
1111
+ /**
1112
+ * The form `eventMetadata` ({@link CommandMetadataOptions}) accepts: a
1113
+ * function of the {@link Decision} returning one metadata entry per event
1114
+ * (`undefined` for an event without metadata), directly or as an `Effect`. The
1115
+ * effect's `E3` joins the handler's error channel and its `R3` the handler's
1116
+ * requirements.
1117
+ *
1118
+ * Function-only: the number of events is known only once `decide` has run.
1119
+ * The `Effect` form lets ids be minted through the `Crypto` service
1120
+ * (`crypto.randomULID`, or `randomUUIDv7`), which keeps them deterministic
1121
+ * under `TestClock` and a test `Crypto` layer with fixed bytes.
1122
+ */
1123
+ export type EventMetadataInput<State, Event, TMetadata, E3 = never, R3 = never> = (decision: Decision<State, Event>) => ReadonlyArray<TMetadata | undefined> | Effect.Effect<ReadonlyArray<TMetadata | undefined>, E3, R3>;
1124
+ /**
1125
+ * The form {@link CommandOptionsBase.eventTimestamps} accepts: a function of
1126
+ * the {@link Decision} returning one timestamp per event (`undefined` keeps
1127
+ * the append's `Clock` time), directly or as an `Effect` — exactly as
1128
+ * {@link EventMetadataInput}. The effect's `E4` joins the handler's error
1129
+ * channel and its `R4` the handler's requirements.
1130
+ *
1131
+ * Function-only: the number of events is known only once `decide` has run.
1132
+ * The `Effect` form lets times come from a service (a source log, a
1133
+ * per-tenant clock) or from `Clock`, which keeps them deterministic under
1134
+ * `TestClock`.
1135
+ *
1136
+ * `E4` / `R4` are separate from `eventMetadata`'s `E3` / `R3`: with one shared
1137
+ * pair, an `eventMetadata` effect and an `eventTimestamps` effect with
1138
+ * different error types would not compile — TypeScript fixes the parameter
1139
+ * from one candidate and rejects the other instead of inferring a union.
1140
+ */
1141
+ export type EventTimestampsInput<State, Event, E4 = never, R4 = never> = (decision: Decision<State, Event>) => ReadonlyArray<DateTime.DateTime | undefined> | Effect.Effect<ReadonlyArray<DateTime.DateTime | undefined>, E4, R4>;
1142
+ /**
1143
+ * The per-call metadata of a {@link commandHandler} handler: one value shared
1144
+ * by every event (`metadata`), or a function deriving one entry per event from
1145
+ * the decision (`eventMetadata`) — never both (see
1146
+ * {@link AppendMetadataOptions}).
1147
+ */
1148
+ export type CommandMetadataOptions<TMetadata, State, Event, E3 = never, R3 = never> = {
1149
+ /**
1150
+ * Metadata written onto every event the decision appends (see
1151
+ * {@link AppendMetadataOptions}). Fixed before `decide` runs; to give
1152
+ * each event its own entry, use `eventMetadata`.
1153
+ */
354
1154
  readonly metadata?: TMetadata;
1155
+ readonly eventMetadata?: undefined;
1156
+ } | {
1157
+ readonly metadata?: undefined;
1158
+ /**
1159
+ * Per-event metadata derived from the {@link Decision}: the function
1160
+ * returns one entry per event, in order, and entry `i` is written onto
1161
+ * event `i` (see {@link AppendMetadataOptions}).
1162
+ *
1163
+ * - Runs after `decide` and after the new events are folded, receiving
1164
+ * the same `Decision` a function-form `additionalItems` does — and
1165
+ * **before** `additionalItems`, so an effectful pair runs in a
1166
+ * predictable order.
1167
+ * - Not called when `decide` returns no events.
1168
+ * - Re-run on every attempt: each `VersionConflict` retry, and the
1169
+ * re-decision a stale unverified snapshot triggers
1170
+ * ({@link CommandHandlerOptions.verifySnapshot}`: false`). Ids minted
1171
+ * per attempt are safe — a failed attempt writes nothing.
1172
+ * - A result whose length differs from the decision's events fails with
1173
+ * `ValidationError` (`operation: "EventStore.commandHandler.eventMetadata"`),
1174
+ * never retried. A failure of the returned `Effect` surfaces as is,
1175
+ * and is not retried either.
1176
+ *
1177
+ * Without `idempotency`, a redelivery after a lost response decides and
1178
+ * appends again, minting new ids. With it, the redelivery fails with
1179
+ * `DuplicateCommand`, so the first commit's ids are the only ones written:
1180
+ *
1181
+ * ```ts
1182
+ * // `crypto` is the `Crypto.Crypto` service; a ULID takes its time from the
1183
+ * // `Clock`, so `TestClock` plus a fixed-bytes `Crypto` make it deterministic
1184
+ * const handle = EventStore.commandHandler(decider, stream, {
1185
+ * idempotency: { ttl: Duration.days(1) },
1186
+ * })
1187
+ * yield* handle(streamId, command, {
1188
+ * commandId: deliveryId,
1189
+ * eventMetadata: ({ events }) =>
1190
+ * Effect.forEach(events, () =>
1191
+ * Effect.map(crypto.randomULID, (eventId) => ({ eventId })),
1192
+ * ),
1193
+ * })
1194
+ * ```
1195
+ */
1196
+ readonly eventMetadata?: EventMetadataInput<State, Event, TMetadata, E3, R3>;
1197
+ };
1198
+ /**
1199
+ * The options of a {@link commandHandler} handler other than its metadata
1200
+ * (see {@link CommandMetadataOptions}).
1201
+ */
1202
+ export interface CommandOptionsBase<State = unknown, Event = unknown, E2 = never, R2 = never, E4 = never, R4 = never> {
355
1203
  /**
356
1204
  * Identifier for this command delivery. Required when the handler was created
357
- * with `idempotency`; a replayed id fails with `DuplicateCommand`.
1205
+ * with `idempotency`; a replayed id fails with `DuplicateCommand` — also when
1206
+ * the replay carries an {@link expectedVersion} the stream has since moved
1207
+ * past (see there).
1208
+ *
1209
+ * Otherwise the sentinel is consulted when the events are appended, after
1210
+ * `decide`, so a replay whose `decide` fails against the advanced state
1211
+ * reports that failure, and one whose `decide` returns no events succeeds as
1212
+ * a no-op. Only a redelivery that appends fails with `DuplicateCommand`.
358
1213
  */
359
1214
  readonly commandId?: string;
360
- /** Caller-owned transact items committed atomically with the produced events. */
361
- readonly additionalItems?: ReadonlyArray<TransactWriteOp>;
1215
+ /**
1216
+ * The stream version the caller last saw — an HTTP `If-Match`.
1217
+ *
1218
+ * After state is loaded and **before `decide` runs**, a stream at any other
1219
+ * version fails the call with `VersionConflict`, carrying the loaded version
1220
+ * as `actualVersion`; `decide` never sees state the caller did not. Otherwise
1221
+ * the append is conditioned on this version, so a writer that slips in
1222
+ * between the load and the append also fails it with `VersionConflict`
1223
+ * (without `actualVersion`). Neither is retried, whatever the handler's
1224
+ * `retry` policy says.
1225
+ *
1226
+ * With `idempotency`, a mismatch is first checked against the command's
1227
+ * sentinel (one strongly consistent `GetItem`, on the mismatch path only): a
1228
+ * redelivery of a command that already committed — its response lost, the
1229
+ * same `commandId` and If-Match sent again — fails with `DuplicateCommand`,
1230
+ * not `VersionConflict`, matching the precedence `append` applies.
1231
+ *
1232
+ * A no-op decision (`decide` returns `[]`) at the matching version succeeds
1233
+ * with the current state and version. Must be a non-negative integer — any
1234
+ * other value fails with `ValidationError` before anything is read.
1235
+ */
1236
+ readonly expectedVersion?: number | undefined;
1237
+ /**
1238
+ * Caller-owned transact items committed atomically with the produced events
1239
+ * (see {@link AppendOptionsBase.additionalItems}).
1240
+ *
1241
+ * Either a static array, or a function of the {@link Decision} — an inline
1242
+ * projection — returning the array or an `Effect` of it. The function runs
1243
+ * after `decide` and after the new events are folded, is not called when
1244
+ * `decide` returns no events, and is re-run on every retry attempt. The items
1245
+ * it returns count towards `AppendTooLarge`, and the `indices` of an
1246
+ * `AdditionalItemConditionFailed` refer to the array it returned.
1247
+ */
1248
+ readonly additionalItems?: AdditionalItemsInput<State, Event, E2, R2>;
1249
+ /**
1250
+ * Per-event timestamps derived from the {@link Decision}: the function
1251
+ * returns one entry per event, in order, directly or as an `Effect` (see
1252
+ * {@link EventTimestampsInput}), and entry `i` is written as event `i`'s
1253
+ * `timestamp` — `undefined` keeps the append's `Clock` time (see
1254
+ * {@link AppendOptionsBase.eventTimestamps}). Independent of `metadata` /
1255
+ * `eventMetadata`. The idempotency sentinel, its TTL and an inline snapshot
1256
+ * keep the append's `Clock` time; timestamps carry no ordering check.
1257
+ *
1258
+ * - Runs after `eventMetadata` and before `additionalItems`, each to
1259
+ * completion before the next, all receiving the same `Decision`; not
1260
+ * called when `decide` returns no events; re-run on every attempt — each
1261
+ * `VersionConflict` retry and the re-decision a stale unverified snapshot
1262
+ * triggers — as `eventMetadata` is.
1263
+ * - A failure of the returned `Effect` surfaces as is, is not retried, and
1264
+ * `additionalItems` is not run.
1265
+ * - A result — plain or produced by the `Effect` — of the wrong length, or
1266
+ * with an entry that is neither a valid `DateTime` (finite epoch) nor
1267
+ * `undefined`, fails with `ValidationError`
1268
+ * (`operation: "EventStore.commandHandler.eventTimestamps"`), never
1269
+ * retried. A value that is not a function is refused before anything is
1270
+ * read.
1271
+ *
1272
+ * ```ts
1273
+ * // plain: the times are in the events
1274
+ * eventTimestamps: ({ events }) => events.map((event) => event.occurredAt)
1275
+ *
1276
+ * // Effect: the times come from a service
1277
+ * eventTimestamps: ({ events }) =>
1278
+ * Effect.forEach(events, (event) => SourceLog.originalTime(event.sourceId))
1279
+ * ```
1280
+ */
1281
+ readonly eventTimestamps?: EventTimestampsInput<State, Event, E4, R4>;
362
1282
  }
1283
+ /**
1284
+ * Per-call options accepted by a handler produced by {@link commandHandler}:
1285
+ * {@link CommandOptionsBase} plus the exclusive `metadata` / `eventMetadata`
1286
+ * pair ({@link CommandMetadataOptions}).
1287
+ *
1288
+ * `State` / `Event` type the {@link Decision} a function-form `additionalItems`,
1289
+ * `eventMetadata` or `eventTimestamps` receives; `E2` / `R2` are the error and
1290
+ * requirements of the `Effect` `additionalItems` may return, `E3` / `R3` those
1291
+ * of the `Effect` `eventMetadata` may return, `E4` / `R4` those of the
1292
+ * `Effect` `eventTimestamps` may return — separate, so effects with different
1293
+ * error types all join the handler's error channel. The handler infers all of
1294
+ * them, so they only need naming when this type is written out by hand.
1295
+ */
1296
+ export type CommandOptions<TMetadata, State = unknown, Event = unknown, E2 = never, R2 = never, E3 = never, R3 = never, E4 = never, R4 = never> = CommandOptionsBase<State, Event, E2, R2, E4, R4> & CommandMetadataOptions<TMetadata, State, Event, E3, R3>;
363
1297
  /** Per-call options when the handler was created with `idempotency` — `commandId` is required. */
364
- export interface IdempotentCommandOptions<TMetadata> extends CommandOptions<TMetadata> {
1298
+ export type IdempotentCommandOptions<TMetadata, State = unknown, Event = unknown, E2 = never, R2 = never, E3 = never, R3 = never, E4 = never, R4 = never> = CommandOptions<TMetadata, State, Event, E2, R2, E3, R3, E4, R4> & {
365
1299
  readonly commandId: string;
366
- }
1300
+ };
367
1301
  /**
368
1302
  * Options arity: configuring `idempotency` makes the handler's options parameter
369
1303
  * required (and `commandId` within it non-optional), so a missing `commandId`
370
1304
  * is a compile error rather than a silent downgrade to at-least-once.
371
1305
  */
372
- type CommandOptionsArgs<TMetadata, TConfig extends CommandHandlerOptions | undefined> = TConfig extends {
1306
+ type CommandOptionsArgs<TMetadata, TConfig extends CommandHandlerOptions | undefined, State, TEvent, E2, R2, E3, R3, E4, R4> = TConfig extends {
373
1307
  readonly idempotency: object;
374
- } ? [options: IdempotentCommandOptions<TMetadata>] : [options?: CommandOptions<TMetadata> | undefined];
1308
+ } ? [options: IdempotentCommandOptions<TMetadata, State, TEvent, E2, R2, E3, R3, E4, R4>] : [options?: CommandOptions<TMetadata, State, TEvent, E2, R2, E3, R3, E4, R4> | undefined];
375
1309
  type CommandHandlerErrors<E> = E | VersionConflict | DuplicateCommand | AdditionalItemConditionFailed | AppendTooLarge | DynamoClientError | ValidationError | TransactionCancelled | UniqueConstraintViolation | OptimisticLockError | ConcurrentModification;
376
- type CommandHandler<State, Command, TEvent, E, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TConfig extends CommandHandlerOptions | undefined = undefined> = (streamId: StreamIdInput<TStreamIdFields>, command: Command, ...options: CommandOptionsArgs<TMetadata, TConfig>) => Effect.Effect<CommandHandlerResult<State, TEvent>, CommandHandlerErrors<E>, DynamoClient | TableConfig>;
377
- type BoundCommandHandler<State, Command, TEvent, E, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TConfig extends CommandHandlerOptions | undefined = undefined> = (streamId: StreamIdInput<TStreamIdFields>, command: Command, ...options: CommandOptionsArgs<TMetadata, TConfig>) => Effect.Effect<CommandHandlerResult<State, TEvent>, CommandHandlerErrors<E>, never>;
1310
+ /**
1311
+ * A handler over an {@link EventStream}. Generic per call over the error (`E2`)
1312
+ * and requirements (`R2`) of a function-form `additionalItems` that returns an
1313
+ * `Effect`, those (`E3` / `R3`) of an `eventMetadata` that does, and those
1314
+ * (`E4` / `R4`) of an `eventTimestamps` that does; all are `never` otherwise.
1315
+ */
1316
+ type CommandHandler<State, Command, TEvent, E, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TConfig extends CommandHandlerOptions | undefined = undefined> = <E2 = never, R2 = never, E3 = never, R3 = never, E4 = never, R4 = never>(streamId: StreamIdInput<TStreamIdFields>, command: Command, ...options: CommandOptionsArgs<TMetadata, TConfig, State, TEvent, E2, R2, E3, R3, E4, R4>) => Effect.Effect<CommandHandlerResult<State, TEvent>, CommandHandlerErrors<E> | E2 | E3 | E4, DynamoClient | TableConfig | R2 | R3 | R4>;
1317
+ /**
1318
+ * A handler over a {@link BoundEventStream}: the stream's services are already
1319
+ * provided, so the `R2` / `R3` / `R4` of a function-form `additionalItems` /
1320
+ * `eventMetadata` / `eventTimestamps` are its only requirements.
1321
+ */
1322
+ type BoundCommandHandler<State, Command, TEvent, E, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TConfig extends CommandHandlerOptions | undefined = undefined> = <E2 = never, R2 = never, E3 = never, R3 = never, E4 = never, R4 = never>(streamId: StreamIdInput<TStreamIdFields>, command: Command, ...options: CommandOptionsArgs<TMetadata, TConfig, State, TEvent, E2, R2, E3, R3, E4, R4>) => Effect.Effect<CommandHandlerResult<State, TEvent>, CommandHandlerErrors<E> | E2 | E3 | E4, R2 | R3 | R4>;
1323
+ /**
1324
+ * The handler the data-last {@link commandHandler} returns for stream `S`: a
1325
+ * {@link BoundCommandHandler} for a `BoundEventStream` (checked first — a bound
1326
+ * stream is also structurally an `EventStream`), a {@link CommandHandler}
1327
+ * otherwise.
1328
+ */
1329
+ type CommandHandlerFor<S, State, Command, TEvent, E, TConfig extends CommandHandlerOptions | undefined> = S extends BoundEventStream<any, infer TStreamIdFields, infer TMetadata, any, any> ? BoundCommandHandler<State, Command, TEvent, E, TStreamIdFields, TMetadata, TConfig> : S extends EventStream<any, infer TStreamIdFields, infer TMetadata, any, any> ? CommandHandler<State, Command, TEvent, E, TStreamIdFields, TMetadata, TConfig> : never;
378
1330
  /**
379
1331
  * Create a command handler that reads, decides, and appends atomically.
380
1332
  *
@@ -400,12 +1352,69 @@ type BoundCommandHandler<State, Command, TEvent, E, TStreamIdFields extends Read
400
1352
  * idempotency: { ttl: Duration.days(1) },
401
1353
  * })
402
1354
  * yield* handle({ matchId: "m-1" }, command, { commandId: "cmd-7f3a" })
1355
+ *
1356
+ * // Load state from the inline snapshot alone (mode "inline" without `every`)
1357
+ * const handle = EventStore.commandHandler(decider, stream, { verifySnapshot: false })
1358
+ *
1359
+ * // If-Match: fail with VersionConflict (never retried) unless the stream is at v7
1360
+ * yield* handle({ matchId: "m-1" }, command, { expectedVersion: 7 })
1361
+ *
1362
+ * // Inline projection, committed in the same transaction as the events
1363
+ * yield* handle({ matchId: "m-1" }, command, {
1364
+ * additionalItems: ({ state }) => [Scoreboard.put({ matchId: "m-1", runs: state.runs })],
1365
+ * })
1366
+ *
1367
+ * // Per-event metadata derived from the decision: entry i is written onto event i
1368
+ * // (`crypto` is the `Crypto.Crypto` service; `randomUUIDv7` works the same way)
1369
+ * yield* handle({ matchId: "m-1" }, command, {
1370
+ * eventMetadata: ({ events }) =>
1371
+ * Effect.forEach(events, () => Effect.map(crypto.randomULID, (eventId) => ({ eventId }))),
1372
+ * })
1373
+ *
1374
+ * // Per-event timestamps derived from the decision (undefined → the Clock time)
1375
+ * yield* handle({ matchId: "m-1" }, command, {
1376
+ * eventTimestamps: ({ events }) => events.map((event) => event.occurredAt),
1377
+ * })
1378
+ *
1379
+ * // ...or from a service, as an Effect (its errors / requirements join the handler's)
1380
+ * yield* handle({ matchId: "m-1" }, command, {
1381
+ * eventTimestamps: ({ events }) =>
1382
+ * Effect.forEach(events, (event) => SourceLog.originalTime(event.sourceId)),
1383
+ * })
403
1384
  * ```
404
1385
  *
405
- * When the stream declares a `snapshot` config, each invocation reads the
406
- * snapshot and folds only the events after it, instead of replaying the stream
407
- * from the beginning. With `snapshot.every` set, a fresh snapshot is written
408
- * (best-effort) after a successful append once the cadence threshold is crossed.
1386
+ * Each invocation runs
1387
+ * `load → [expectedVersion check] → decide → fold new events → derive items → append → [snapshot]`.
1388
+ *
1389
+ * - **Load.** Strongly consistent by default — see
1390
+ * {@link CommandHandlerOptions.consistentRead}. When the stream declares a
1391
+ * `snapshot` config, state is loaded with {@link EventStream.readLatest} —
1392
+ * the snapshot, the events after it and the head in one request in steady
1393
+ * state — and only those events are folded, instead of replaying the stream
1394
+ * from the beginning. On a `mode: "inline"` stream without `every`,
1395
+ * {@link CommandHandlerOptions.verifySnapshot}`: false` loads the snapshot
1396
+ * item alone (one `GetItem`); an answer that is not a successful append is
1397
+ * confirmed against the head first, and a snapshot that proves stale falls
1398
+ * back to the verified load — in which case `decide` may run twice.
1399
+ * - **expectedVersion.** See {@link CommandOptionsBase.expectedVersion}.
1400
+ * - **Fold before append.** The new events are folded into state before the
1401
+ * append, so the state returned, snapshotted and handed to a function-form
1402
+ * `additionalItems` (see {@link Decision}) is always the `evolve` fold.
1403
+ * - **Snapshot.** With `snapshot.mode: "inline"`, the snapshot is written in
1404
+ * the append transaction itself — on every append, or at the `every`
1405
+ * cadence. With the default `"after-append"` and `snapshot.every` set, a
1406
+ * fresh snapshot is written (best-effort) after a successful append once the
1407
+ * cadence threshold is crossed. See {@link SnapshotMode}.
1408
+ * - **One atomic append.** One command, one decision, one append: a decision
1409
+ * too large for one transaction is rejected whole by DynamoDB and fails with
1410
+ * `AppendTooLarge` — nothing is written. A large command is split by the application into stepped
1411
+ * commands — each an ordinary call, chained by `expectedVersion` — so a
1412
+ * failure partway leaves the stream at a real intermediate state.
1413
+ *
1414
+ * The handler is generic per call over the error and requirements of a
1415
+ * function-form `additionalItems` that returns an `Effect`: they join the
1416
+ * handler's error channel and requirements (on a `BoundEventStream` handler
1417
+ * they are its only requirements).
409
1418
  *
410
1419
  * Without `idempotency`, command processing is **at-least-once**: a retry after
411
1420
  * an acked-but-lost response re-runs `decide` and appends again.
@@ -417,12 +1426,13 @@ type BoundCommandHandler<State, Command, TEvent, E, TStreamIdFields extends Read
417
1426
  * `EventStreamTypeId` brand of the second argument.
418
1427
  */
419
1428
  export declare const commandHandler: {
420
- <State, Command, TEvent, E, const TConfig extends CommandHandlerOptions | undefined = undefined>(decider: Decider<State, Command, TEvent, E>, options?: TConfig): {
421
- <TStreamIdFields extends ReadonlyArray<string>, TMetadata, TState extends State>(stream: BoundEventStream<TEvent, TStreamIdFields, TMetadata, TState>): BoundCommandHandler<State, Command, TEvent, E, TStreamIdFields, TMetadata, TConfig>;
422
- <TStreamIdFields extends ReadonlyArray<string>, TMetadata, TState extends State>(stream: EventStream<TEvent, TStreamIdFields, TMetadata, TState>): CommandHandler<State, Command, TEvent, E, TStreamIdFields, TMetadata, TConfig>;
423
- };
424
- <State, Command, TEvent, E, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TState extends State, const TConfig extends CommandHandlerOptions | undefined = undefined>(decider: Decider<State, Command, TEvent, E>, stream: BoundEventStream<TEvent, TStreamIdFields, TMetadata, TState>, options?: TConfig): BoundCommandHandler<State, Command, TEvent, E, TStreamIdFields, TMetadata, TConfig>;
425
- <State, Command, TEvent, E, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TState extends State, const TConfig extends CommandHandlerOptions | undefined = undefined>(decider: Decider<State, Command, TEvent, E>, stream: EventStream<TEvent, TStreamIdFields, TMetadata, TState>, options?: TConfig): CommandHandler<State, Command, TEvent, E, TStreamIdFields, TMetadata, TConfig>;
1429
+ <State, Command, TEvent, E, const TConfig extends CommandHandlerOptions | undefined = undefined>(decider: Decider<State, Command, TEvent, E>, options?: TConfig): <S extends BoundEventStream<TEvent, any, any, State, any, any> | EventStream<TEvent, ReadonlyArray<string>, any, State, any, any>>(stream: S) => CommandHandlerFor<S, State, Command, TEvent, E, TConfig>;
1430
+ <State, Command, TEvent, E, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TState extends State, TIndexName extends string, TIndexProjections extends {
1431
+ readonly [K in TIndexName]: StreamIndexProjection;
1432
+ }, const TConfig extends CommandHandlerOptions | undefined = undefined>(decider: Decider<State, Command, TEvent, E>, stream: BoundEventStream<TEvent, TStreamIdFields, TMetadata, TState, TIndexName, TIndexProjections>, options?: TConfig): BoundCommandHandler<State, Command, TEvent, E, TStreamIdFields, TMetadata, TConfig>;
1433
+ <State, Command, TEvent, E, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TState extends State, TIndexName extends string, TIndexProjections extends {
1434
+ readonly [K in TIndexName]: StreamIndexProjection;
1435
+ }, const TConfig extends CommandHandlerOptions | undefined = undefined>(decider: Decider<State, Command, TEvent, E>, stream: EventStream<TEvent, TStreamIdFields, TMetadata, TState, TIndexName, TIndexProjections>, options?: TConfig): CommandHandler<State, Command, TEvent, E, TStreamIdFields, TMetadata, TConfig>;
426
1436
  };
427
1437
  /**
428
1438
  * Reconstruct state from events by folding through a decider's `evolve` function.