effect-dynamodb 1.23.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,15 +4,23 @@
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,
14
21
  * DynamoClient, Marshaller).
15
22
  */
23
+ import type { AttributeDefinition, GlobalSecondaryIndex, LocalSecondaryIndex } from "@aws-sdk/client-dynamodb";
16
24
  import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
17
25
  import { AdditionalItemConditionFailed, AppendTooLarge, type ConcurrentModification, DuplicateCommand, type OptimisticLockError, TransactionCancelled, type UniqueConstraintViolation, ValidationError, VersionConflict } from "@effect-dynamodb/schema/Errors.js";
18
26
  import { type Duration, Effect, Option, Pipeable, Schedule, Schema } from "effect";
@@ -24,8 +32,13 @@ import { type Table, type TableConfig } from "./Table.js";
24
32
  * A Decider encodes the command-event-state triad for an aggregate.
25
33
  *
26
34
  * - `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
35
+ * - `evolve` — left fold: apply one event to a state. It may return new state or
36
+ * mutate the given state in place and return it; the library requires
37
+ * neither. State the library persists, returns or projects is always this
38
+ * fold, never anything produced inside `decide`.
39
+ * - `initialState` — starting state for a new aggregate. An `evolve` that
40
+ * mutates in place must not mutate a shared `initialState` object (declare it
41
+ * as a getter that returns a fresh value).
29
42
  */
30
43
  export interface Decider<State, Command, Event, E = never> {
31
44
  readonly decide: (command: Command, state: State) => Effect.Effect<ReadonlyArray<Event>, E>;
@@ -80,16 +93,52 @@ export interface Snapshot<State> {
80
93
  * - `schema` — the state codec. Snapshot state round-trips through it
81
94
  * (`Schema.encodeUnknownEffect` on write, `Schema.decodeUnknownEffect` on
82
95
  * 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.
96
+ * - `mode` — how {@link commandHandler} writes snapshots (see
97
+ * {@link SnapshotMode}). Default `"after-append"`.
98
+ * - `every` — optional snapshot cadence for {@link commandHandler}: write a
99
+ * fresh snapshot once at least this many events have accumulated since the
100
+ * last one. Must be a positive integer. With `mode: "after-append"` and no
101
+ * `every`, the handler never snapshots; with `mode: "inline"` and no `every`,
102
+ * it snapshots on every append.
103
+ *
104
+ * `every` also sizes the first page of {@link EventStream.readLatest}, which
105
+ * {@link commandHandler} loads state with: a snapshot that lags the head by up
106
+ * to `every` events still loads in one request. The first page is read whatever
107
+ * the actual lag, so every load reads up to `every + 1` items (`2` without
108
+ * `every`), including events the snapshot already covers, which are discarded.
109
+ * For an `"after-append"` stream that trades read capacity for requests: with
110
+ * `every: 100` and a snapshot one event behind, a load reads the snapshot and
111
+ * 100 events in one request, where a `GetItem` plus `readFrom` would read the
112
+ * snapshot and one event in two. Size `every` with that in mind, or use
113
+ * `mode: "inline"`, which keeps the snapshot current so the page stays small.
86
114
  */
87
115
  export interface SnapshotConfig<TSchema extends Schema.Top = Schema.Top> {
116
+ /**
117
+ * The state schema. Snapshot state is encoded through it, falling back to
118
+ * `decode → encode` (as events are) for a value not already in its type
119
+ * shape — so a `Schema.Class` state folded by an immutable `evolve` that
120
+ * spreads (`({ ...s, balance })`, a plain object) still encodes.
121
+ */
88
122
  readonly schema: TSchema;
123
+ readonly mode?: SnapshotMode | undefined;
89
124
  readonly every?: number | undefined;
90
125
  }
126
+ /**
127
+ * How {@link commandHandler} writes snapshots.
128
+ *
129
+ * - `"after-append"` (default) — a best-effort `writeSnapshot` after the append
130
+ * succeeds, at the `every` cadence. A failed snapshot write never fails the
131
+ * command; the snapshot simply lags until the next threshold crossing.
132
+ * - `"inline"` — the snapshot `Put` rides in the append's own transaction (see
133
+ * {@link AppendOptions.snapshot}), on every append, or at the `every` cadence
134
+ * when one is set. The snapshot is then current after every command that
135
+ * writes one, so {@link EventStream.readLatest} loads state in one request.
136
+ * The snapshot counts towards the transaction's item and size limits.
137
+ */
138
+ export type SnapshotMode = "after-append" | "inline";
91
139
  /** The runtime snapshot settings exposed on a stream. */
92
140
  export interface SnapshotSettings {
141
+ readonly mode: SnapshotMode;
93
142
  readonly every: number | undefined;
94
143
  }
95
144
  /**
@@ -100,6 +149,12 @@ export interface SnapshotSettings {
100
149
  * `commandId` therefore cancels the whole transaction and surfaces as
101
150
  * `DuplicateCommand` — the events are never written twice.
102
151
  *
152
+ * The sentinel is consulted only by an append. Through `commandHandler`, a
153
+ * redelivery whose `decide` returns no events against the loaded state never
154
+ * appends, so it succeeds as a no-op instead of failing with
155
+ * `DuplicateCommand`. Whether a no-op redelivery needs to be told apart is the
156
+ * application's call (see {@link CommandOptions.commandId}).
157
+ *
103
158
  * The sentinel is co-located in the stream's own partition, so `commandId`
104
159
  * uniqueness is scoped to the stream (which is what "have I already applied this
105
160
  * command to this aggregate?" asks). It is invisible to `read` / `readFrom` /
@@ -122,8 +177,13 @@ export interface AppendIdempotency {
122
177
  */
123
178
  readonly ttl?: Duration.Duration | string;
124
179
  }
125
- /** Options accepted by {@link EventStream.append}. */
126
- export interface AppendOptions<TMetadata> {
180
+ /**
181
+ * Options accepted by {@link EventStream.append}.
182
+ *
183
+ * `TState` is the stream's snapshot state type (`never` for a stream without a
184
+ * `snapshot` config, so `snapshot` cannot be supplied there).
185
+ */
186
+ export interface AppendOptions<TMetadata, TState = never> {
127
187
  /** Per-append metadata, validated against the stream's metadata schema when configured. */
128
188
  readonly metadata?: TMetadata;
129
189
  /**
@@ -147,9 +207,199 @@ export interface AppendOptions<TMetadata> {
147
207
  readonly additionalItems?: ReadonlyArray<TransactWriteOp>;
148
208
  /** Opt in to exactly-once command processing — see {@link AppendIdempotency}. */
149
209
  readonly idempotency?: AppendIdempotency;
210
+ /**
211
+ * An inline snapshot: the stream's state as of this append's last event.
212
+ *
213
+ * The snapshot `Put` (`asOfVersion` = `expectedVersion + events.length`,
214
+ * encoded through the state schema) joins the **same transaction** as the
215
+ * events, after the idempotency sentinel, so it commits if and only if the
216
+ * events do. The `Put` is unconditional: the event puts already prove this
217
+ * writer owns `asOfVersion`, so it cannot regress the snapshot. It counts
218
+ * towards the item and size limits, and a cancellation reason at its
219
+ * position (throttling, for example) reports `TransactionCancelled`.
220
+ *
221
+ * `undefined` means no snapshot. Requires at least one event — with none,
222
+ * nothing proves the writer owns the version, and the append fails with
223
+ * `ValidationError`. On a stream without a `snapshot` config (where the type
224
+ * is `never`) it dies with `[EDD-9026]`, as `writeSnapshot` does.
225
+ */
226
+ readonly snapshot?: TState;
150
227
  }
151
228
  /** Error channel of {@link EventStream.append}. */
152
229
  export type AppendError = VersionConflict | DuplicateCommand | AdditionalItemConditionFailed | AppendTooLarge | DynamoClientError | ValidationError | TransactionCancelled | UniqueConstraintViolation | OptimisticLockError | ConcurrentModification;
230
+ /**
231
+ * Options accepted by {@link EventStream.read}, {@link EventStream.readFrom} and
232
+ * {@link EventStream.currentVersion}.
233
+ *
234
+ * `consistentRead: true` sets `ConsistentRead` on every `Query` page, so the
235
+ * read observes every append acknowledged before it started (read-your-writes).
236
+ * Default `false`: eventually consistent, at half the read cost.
237
+ *
238
+ * {@link commandHandler} loads state with strongly consistent reads by default —
239
+ * see {@link CommandHandlerOptions.consistentRead}.
240
+ */
241
+ export interface ReadOptions {
242
+ readonly consistentRead?: boolean | undefined;
243
+ }
244
+ /**
245
+ * Options accepted by {@link EventStream.readLatest}: {@link ReadOptions} plus
246
+ * `verifySnapshot`.
247
+ *
248
+ * - `verifySnapshot` — default `true`: one `Query` reads the snapshot and the
249
+ * events after it, so the result is verified against the stream head.
250
+ * `false` reads **only the snapshot item**, with one `GetItem` (consistent
251
+ * per `consistentRead`), and returns
252
+ * `{ snapshot, events: [], version: snapshot.asOfVersion }` **without
253
+ * checking that no event follows it** — `version` is then the snapshot's
254
+ * version, not a verified head. When there is no snapshot item, it falls
255
+ * back to the verified read and returns that result.
256
+ *
257
+ * Why: a `Query`'s read capacity counts every item it reads, and the
258
+ * verified read's first page is the snapshot **plus** the newest event,
259
+ * even when the snapshot is current; a `GetItem` reads the snapshot alone.
260
+ * With large events that roughly halves the read capacity of a load.
261
+ *
262
+ * Only a stream whose snapshot config is `mode: "inline"` **without**
263
+ * `every` (the only mode that writes the snapshot in every append, so it is
264
+ * normally at the head) accepts `false`; any other snapshot config dies with
265
+ * `[EDD-9068]`. On a stream without a `snapshot` config the option is
266
+ * ignored. {@link commandHandler} exposes the same trade-off, with
267
+ * automatic fallbacks, as {@link CommandHandlerOptions.verifySnapshot}.
268
+ */
269
+ export interface ReadLatestOptions extends ReadOptions {
270
+ readonly verifySnapshot?: boolean | undefined;
271
+ }
272
+ /**
273
+ * A stream's latest state as {@link EventStream.readLatest} loads it.
274
+ *
275
+ * - `snapshot` — the stream's snapshot, if one exists (always `None` on a
276
+ * stream without a `snapshot` config).
277
+ * - `events` — the events after the snapshot (every event when there is
278
+ * none), ascending by version. Fold them onto the snapshot's state.
279
+ * - `version` — the stream head: the newest event's version, the snapshot's
280
+ * `asOfVersion` when no event follows it, `0` for an empty stream. With
281
+ * `verifySnapshot: false` (see {@link ReadLatestOptions}) and a snapshot
282
+ * present, it is the snapshot's `asOfVersion` and `events` is empty —
283
+ * **unverified**: events appended without a snapshot (a raw `append`, data
284
+ * written before `mode: "inline"`) may follow it.
285
+ */
286
+ export interface LatestState<TState, TEvent, M = Record<string, unknown> | undefined> {
287
+ readonly snapshot: Option.Option<Snapshot<TState>>;
288
+ readonly events: ReadonlyArray<StreamEvent<TEvent, M>>;
289
+ readonly version: number;
290
+ }
291
+ /**
292
+ * Derives an event's key in a stream index from the domain event (before it is
293
+ * encoded) and the version it is appended at.
294
+ *
295
+ * - A string is the event's sort key in the index. It is stored **raw**: no
296
+ * casing and no prefixing is applied, so compose it to sort the way you
297
+ * query (zero-pad numbers, for example).
298
+ * - `undefined` leaves the event out of the index (a sparse index).
299
+ * - An empty string (or any other value) fails the append with
300
+ * `ValidationError` before anything is written — DynamoDB rejects empty key
301
+ * values. So do a key over DynamoDB's 1024-byte sort-key limit and a `key`
302
+ * that throws.
303
+ *
304
+ * The key is derived only when an event is appended. Events are never
305
+ * rewritten, so changing `key` later leaves events already stored under their
306
+ * old keys (or out of the index), and declaring an index on a stream that
307
+ * already holds events indexes only the events appended from then on. The
308
+ * library does not backfill: re-deriving and writing the index attributes on
309
+ * existing events is the application's decision (see `indexes` on
310
+ * {@link makeStream}).
311
+ */
312
+ export type StreamIndexKey<TEvent> = (event: TEvent, version: number) => string | undefined;
313
+ /**
314
+ * A stream index on a **local** secondary index (the default `type`). An LSI
315
+ * shares the table's partition key (`pk`), so its items are exactly the
316
+ * stream's indexed events, readable with strongly consistent reads.
317
+ *
318
+ * - `index` — the physical LSI name.
319
+ * - `sk` — the attribute carrying the derived key (the LSI's sort key).
320
+ * - `key` — see {@link StreamIndexKey}.
321
+ *
322
+ * An LSI must be created with the table. A table with any LSI caps the item
323
+ * collection of **every** partition key value in it at 10 GB — not only the
324
+ * indexed stream's partitions, but every stream's (indexed or not) and every
325
+ * entity partition sharing the table; a write that would exceed it fails. Its
326
+ * projection must be `ALL`, because events are decoded from the index item —
327
+ * see {@link indexDefinitions}.
328
+ */
329
+ export interface LocalStreamIndexConfig<TEvent> {
330
+ readonly type?: "lsi" | undefined;
331
+ readonly index: string;
332
+ readonly sk: string;
333
+ /** An LSI uses the table partition key; supplying `pk` is refused (`[EDD-9063]`). */
334
+ readonly pk?: undefined;
335
+ readonly key: StreamIndexKey<TEvent>;
336
+ }
337
+ /**
338
+ * A stream index on a **global** secondary index. `append` writes the stream's
339
+ * partition key value into the `pk` attribute of each indexed event, so the
340
+ * index is scoped to the same stream: the eventually consistent equivalent of
341
+ * an LSI, without the LSI's 10 GB item-collection cap. A strongly consistent
342
+ * read of it is refused with `ValidationError`.
343
+ *
344
+ * Unlike an LSI, the GSI itself can be added to an existing table
345
+ * (`UpdateTable`), but that does not index the stream's existing events: the
346
+ * index attributes are written only by `append`, so only events appended after
347
+ * the index is declared are in it — see {@link StreamIndexKey}.
348
+ *
349
+ * - `index` — the physical GSI name.
350
+ * - `pk` — the attribute carrying the stream's partition key (the GSI's HASH key).
351
+ * - `sk` — the attribute carrying the derived key (the GSI's RANGE key).
352
+ * - `key` — see {@link StreamIndexKey}.
353
+ */
354
+ export interface GlobalStreamIndexConfig<TEvent> {
355
+ readonly type: "gsi";
356
+ readonly index: string;
357
+ readonly pk: string;
358
+ readonly sk: string;
359
+ readonly key: StreamIndexKey<TEvent>;
360
+ }
361
+ /**
362
+ * One entry of `makeStream({ indexes })`: a sub-stream of the stream's events,
363
+ * ordered by a key derived from each event. See {@link LocalStreamIndexConfig}
364
+ * and {@link GlobalStreamIndexConfig}.
365
+ */
366
+ export type StreamIndexConfig<TEvent> = LocalStreamIndexConfig<TEvent> | GlobalStreamIndexConfig<TEvent>;
367
+ /**
368
+ * The runtime settings of one stream index, as exposed on
369
+ * {@link EventStream.indexes}. `pk` is the index's partition key attribute:
370
+ * the table's `pk` for an LSI, the configured attribute for a GSI.
371
+ */
372
+ export interface StreamIndexSettings {
373
+ readonly type: "lsi" | "gsi";
374
+ readonly index: string;
375
+ readonly pk: string;
376
+ readonly sk: string;
377
+ }
378
+ /**
379
+ * Options accepted by {@link EventStream.readIndex}.
380
+ *
381
+ * - `beginsWith` / `between` — a sort-key condition on the derived key (at most
382
+ * one of them). Omitted, the whole sub-stream is read. Each bound must be a
383
+ * non-empty string, and `between`'s lower bound must not sort (by UTF-8
384
+ * bytes, as DynamoDB sorts) after its upper bound; otherwise the read fails
385
+ * with `ValidationError` before anything is sent.
386
+ * - `reverse` — descending key order.
387
+ * - `limit` — return at most this many events (a positive integer).
388
+ * - `consistentRead` — strongly consistent read. Refused with
389
+ * `ValidationError` on a GSI-backed index, which DynamoDB reads only
390
+ * eventually consistently.
391
+ */
392
+ export type ReadIndexOptions = {
393
+ readonly reverse?: boolean | undefined;
394
+ readonly limit?: number | undefined;
395
+ readonly consistentRead?: boolean | undefined;
396
+ } & ({
397
+ readonly beginsWith?: string | undefined;
398
+ readonly between?: undefined;
399
+ } | {
400
+ readonly between?: readonly [string, string] | undefined;
401
+ readonly beginsWith?: undefined;
402
+ });
153
403
  type StreamIdInput<T extends ReadonlyArray<string>> = {
154
404
  readonly [K in T[number]]: string;
155
405
  };
@@ -160,14 +410,28 @@ export type EventStreamTypeId = typeof EventStreamTypeId;
160
410
  *
161
411
  * Created via {@link makeStream}. Operations are called directly on the stream:
162
412
  * `MatchEvents.append(...)`, `MatchEvents.read(...)`, `MatchEvents.query.events(...)`.
413
+ *
414
+ * `TIndexName` is the union of the stream's index names (`makeStream({ indexes })`),
415
+ * `never` for a stream without indexes, so {@link EventStream.readIndex} and
416
+ * `query.index` only accept a declared name.
163
417
  */
164
- export interface EventStream<TEvent, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TState = never> extends Pipeable.Pipeable {
418
+ export interface EventStream<TEvent, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TState = never, TIndexName extends string = never> extends Pipeable.Pipeable {
165
419
  readonly [EventStreamTypeId]: EventStreamTypeId;
166
420
  readonly streamName: string;
167
421
  readonly eventSchema: Schema.Top;
422
+ /**
423
+ * The stream's indexes (`makeStream({ indexes })`) by name — their physical
424
+ * index and key attributes. Empty for a stream without indexes.
425
+ * {@link indexDefinitions} derives `CreateTable` fragments from it.
426
+ */
427
+ readonly indexes: {
428
+ readonly [K in TIndexName]: StreamIndexSettings;
429
+ };
168
430
  /**
169
431
  * Present iff the stream was created with a `snapshot` config. Its presence
170
- * is what switches {@link commandHandler} onto the snapshot-aware read path.
432
+ * is what switches {@link commandHandler} onto the snapshot-aware read path
433
+ * ({@link EventStream.readLatest}); its `mode` decides how the handler writes
434
+ * snapshots.
171
435
  */
172
436
  readonly snapshotConfig: SnapshotSettings | undefined;
173
437
  /**
@@ -196,12 +460,77 @@ export interface EventStream<TEvent, TStreamIdFields extends ReadonlyArray<strin
196
460
  * hide state-schema evolution bugs.
197
461
  */
198
462
  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>;
463
+ /**
464
+ * Append `events` after `expectedVersion`, atomically, in one transaction —
465
+ * see {@link AppendOptions}.
466
+ * A method, like `writeSnapshot`, so a snapshot-less stream (whose
467
+ * `options.snapshot` is `never`) stays assignable to other streams.
468
+ */
469
+ append(streamId: StreamIdInput<TStreamIdFields>, events: ReadonlyArray<TEvent>, expectedVersion: number, options?: AppendOptions<TMetadata, TState> | undefined): Effect.Effect<AppendResult<TEvent>, AppendError, DynamoClient | TableConfig>;
470
+ /**
471
+ * Read every event of the stream, ascending by version. Pass
472
+ * `{ consistentRead: true }` for read-your-writes — see {@link ReadOptions}.
473
+ */
474
+ read(streamId: StreamIdInput<TStreamIdFields>, options?: ReadOptions | undefined): Effect.Effect<ReadonlyArray<StreamEvent<TEvent, StreamMetadata<TMetadata>>>, DynamoClientError | ValidationError, DynamoClient | TableConfig>;
475
+ /**
476
+ * Read the events after `afterVersion` (exclusive), ascending. Pass
477
+ * `{ consistentRead: true }` for read-your-writes — see {@link ReadOptions}.
478
+ */
479
+ readFrom(streamId: StreamIdInput<TStreamIdFields>, afterVersion: number, options?: ReadOptions | undefined): Effect.Effect<ReadonlyArray<StreamEvent<TEvent, StreamMetadata<TMetadata>>>, DynamoClientError | ValidationError, DynamoClient | TableConfig>;
480
+ /**
481
+ * The version of the stream's newest event (`0` for an empty stream), in one
482
+ * request. Pass `{ consistentRead: true }` for read-your-writes — see
483
+ * {@link ReadOptions}.
484
+ */
485
+ currentVersion(streamId: StreamIdInput<TStreamIdFields>, options?: ReadOptions | undefined): Effect.Effect<number, DynamoClientError | ValidationError, DynamoClient | TableConfig>;
486
+ /**
487
+ * The stream's snapshot (if any), the events after it, and its head — in
488
+ * **one request** in steady state. This is how {@link commandHandler} loads
489
+ * state on a snapshot-configured stream.
490
+ *
491
+ * Issues a single reverse `Query` over the sort-key range from the first
492
+ * event to the snapshot (the snapshot sorts after every event, and the
493
+ * idempotency sentinels before them, so the range holds exactly the events
494
+ * and the snapshot), with a first page of `(every ?? 1) + 1` items. Further
495
+ * pages are read only while the snapshot's `asOfVersion` has not been reached
496
+ * (a snapshot lagging by more than `every` events) — each sized to the events
497
+ * still missing, so a lagging snapshot costs one more request — or to the
498
+ * start of the stream when there is no snapshot. Pass
499
+ * `{ consistentRead: true }` for read-your-writes; it applies to the snapshot
500
+ * and the events alike.
501
+ *
502
+ * A snapshot that fails to decode through the state schema fails with
503
+ * `ValidationError`, as {@link EventStream.readSnapshot} does. On a stream
504
+ * without a `snapshot` config it is {@link EventStream.read} plus the head.
505
+ *
506
+ * `{ verifySnapshot: false }` reads only the snapshot item, with one
507
+ * `GetItem`, and returns it with no events and its `asOfVersion` as an
508
+ * **unverified** `version`; with no snapshot item it falls back to the
509
+ * verified read. Accepted only on a `mode: "inline"` stream without `every`
510
+ * (otherwise a defect, `[EDD-9068]`), ignored on a stream without a
511
+ * `snapshot` config — see {@link ReadLatestOptions}.
512
+ */
513
+ readLatest(streamId: StreamIdInput<TStreamIdFields>, options?: ReadLatestOptions | undefined): Effect.Effect<LatestState<TState, TEvent, StreamMetadata<TMetadata>>, DynamoClientError | ValidationError, DynamoClient | TableConfig>;
514
+ /**
515
+ * Read the stream's events in the order of one of its indexes (#140) — the
516
+ * events whose index `key` returned a string, ordered by that key. A
517
+ * convenience over `query.index`; pass `beginsWith` or `between` to narrow
518
+ * the key range, `reverse`, `limit` and `consistentRead` (refused on a GSI)
519
+ * — see {@link ReadIndexOptions}.
520
+ */
521
+ readIndex(name: TIndexName, streamId: StreamIdInput<TStreamIdFields>, options?: ReadIndexOptions | undefined): Effect.Effect<ReadonlyArray<StreamEvent<TEvent, StreamMetadata<TMetadata>>>, DynamoClientError | ValidationError, DynamoClient | TableConfig>;
203
522
  readonly query: {
204
523
  events(streamId: StreamIdInput<TStreamIdFields>): Query.Query<StreamEvent<TEvent, StreamMetadata<TMetadata>>>;
524
+ /**
525
+ * A query of one of the stream's indexes (#140), scoped to `streamId`:
526
+ * decoded events in index-key order. It composes with the `Query`
527
+ * combinators — `Query.where` (a condition on the derived key),
528
+ * `Query.reverse`, `Query.limit`, `Query.consistentRead` (refused with
529
+ * `ValidationError` on a GSI-backed index) — and terminals
530
+ * (`Query.collect`, `Query.execute`, `Query.paginate`). The
531
+ * `__edd_e__ = <stream>.event` filter still applies.
532
+ */
533
+ index(name: TIndexName, streamId: StreamIdInput<TStreamIdFields>): Query.Query<StreamEvent<TEvent, StreamMetadata<TMetadata>>>;
205
534
  };
206
535
  }
207
536
  /**
@@ -244,6 +573,10 @@ export interface EventStream<TEvent, TStreamIdFields extends ReadonlyArray<strin
244
573
  * })
245
574
  * ```
246
575
  *
576
+ * `snapshot.mode: "inline"` writes the snapshot in the append transaction
577
+ * itself, so it is current after every command and state loads in one
578
+ * request — see {@link SnapshotMode}.
579
+ *
247
580
  * `casing` overrides the key casing for this stream, as an index's or a vector
248
581
  * index's `casing` does: the stream name, stream-id values and command ids in
249
582
  * its keys all take it. When omitted, the stream name is lower-cased and the
@@ -254,11 +587,61 @@ export interface EventStream<TEvent, TStreamIdFields extends ReadonlyArray<strin
254
587
  * (`<stream>.event` etc.) are always lower-cased. In the next major, omitting
255
588
  * `casing` will mean the schema's casing, as it does for indexes.
256
589
  *
590
+ * `indexes` declares sub-streams ordered by a key derived from each event
591
+ * (#140), on a local (default) or global secondary index. `append` writes each
592
+ * event's derived key (and, for a GSI, the stream's partition key) on the
593
+ * event item; snapshots and idempotency sentinels never carry them, so they
594
+ * are never in an index. Read with {@link EventStream.readIndex} or
595
+ * `query.index`, and create the indexes with {@link indexDefinitions}.
596
+ *
597
+ * Index attributes are written only at append time and events are never
598
+ * rewritten. Declaring an index on a stream that already holds events, or
599
+ * changing an index's `key`, therefore leaves the earlier events out of the
600
+ * index (or under their old keys), and `readIndex` returns only what was
601
+ * indexed — without an error. The library does not backfill; whether and how
602
+ * to re-index existing events (for example, an `UpdateItem` per event setting
603
+ * the derived attributes) is the application's decision.
604
+ *
605
+ * @example
606
+ * ```typescript
607
+ * const Entries = EventStore.makeStream({
608
+ * table: EventsTable,
609
+ * streamName: "Ledger",
610
+ * events: [EntryRecorded, EntryVoided],
611
+ * streamId: { composite: ["ledgerId"] },
612
+ * indexes: {
613
+ * byEntry: {
614
+ * index: "lsi1",
615
+ * sk: "lsi1sk",
616
+ * key: (event, version) =>
617
+ * event._tag === "EntryRecorded" ? `ENTRY#${event.section}#${pad(version)}` : undefined,
618
+ * },
619
+ * // Every event, by the day it was recorded (both event types carry `day`).
620
+ * byDay: { type: "gsi", index: "gsi1", pk: "gsi1pk", sk: "gsi1sk", key: (e) => e.day },
621
+ * },
622
+ * })
623
+ *
624
+ * const sectionThree = yield* Entries.readIndex("byEntry", { ledgerId }, { beginsWith: "ENTRY#3#" })
625
+ * ```
626
+ *
257
627
  * @throws `[EDD-9027]` when `snapshot.every` is not a positive integer.
628
+ * @throws `[EDD-9062]` when `snapshot.mode` is neither `"after-append"` nor
629
+ * `"inline"`.
630
+ * @throws `[EDD-9063]` for a malformed index: a `type` other than `"lsi"` /
631
+ * `"gsi"`, a `gsi` without `pk`, an `lsi` with `pk`, an empty `index` / `sk`
632
+ * / `pk`, or a `key` that is not a function.
633
+ * @throws `[EDD-9064]` when an index attribute is one the stream writes itself
634
+ * (`pk`, `sk`, `__edd_e__`, `streamId`, `version`, `eventType`, `data`,
635
+ * `metadata`, `timestamp`, `asOfVersion`, `state`, `commandId`, or the
636
+ * default TTL attribute `_ttl`). A custom `TableConfig.ttlAttributeName`
637
+ * is only known at runtime: an index attribute equal to it fails each append
638
+ * with `ValidationError`.
639
+ * @throws `[EDD-9065]` when two indexes share a physical index name or an
640
+ * attribute (or a GSI's `pk` and `sk` are the same attribute).
258
641
  */
259
642
  export declare const makeStream: <const TEvents extends ReadonlyArray<Schema.Top>, TTable extends Table, const TStreamName extends string, const TStreamId extends {
260
643
  readonly composite: ReadonlyArray<string>;
261
- }, TMetadata extends Schema.Top | undefined = undefined, TSnapshot extends SnapshotConfig | undefined = undefined>(config: {
644
+ }, TMetadata extends Schema.Top | undefined = undefined, TSnapshot extends SnapshotConfig | undefined = undefined, const TIndexName extends string = never>(config: {
262
645
  readonly table: TTable;
263
646
  readonly streamName: TStreamName;
264
647
  readonly events: TEvents;
@@ -266,7 +649,8 @@ export declare const makeStream: <const TEvents extends ReadonlyArray<Schema.Top
266
649
  readonly metadata?: TMetadata;
267
650
  readonly snapshot?: TSnapshot;
268
651
  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>;
652
+ readonly indexes?: { readonly [K in TIndexName]: StreamIndexConfig<Schema.Schema.Type<TEvents[number]>>; };
653
+ }) => 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, TIndexName>;
270
654
  /**
271
655
  * An EventStream whose operations have `DynamoClient` and `TableConfig` already
272
656
  * resolved, so all methods return `Effect<A, E, never>`.
@@ -287,22 +671,40 @@ export declare const makeStream: <const TEvents extends ReadonlyArray<Schema.Top
287
671
  * }) {}
288
672
  * ```
289
673
  */
290
- export interface BoundEventStream<TEvent, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TState = never> extends Pipeable.Pipeable {
674
+ export interface BoundEventStream<TEvent, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TState = never, TIndexName extends string = never> extends Pipeable.Pipeable {
291
675
  readonly [EventStreamTypeId]: EventStreamTypeId;
292
676
  readonly streamName: string;
293
677
  readonly eventSchema: Schema.Top;
294
678
  /** See {@link EventStream.snapshotConfig}. */
295
679
  readonly snapshotConfig: SnapshotSettings | undefined;
680
+ /** See {@link EventStream.indexes}. */
681
+ readonly indexes: {
682
+ readonly [K in TIndexName]: StreamIndexSettings;
683
+ };
296
684
  /** See {@link EventStream.writeSnapshot} — a method for the same variance reason. */
297
685
  writeSnapshot(streamId: StreamIdInput<TStreamIdFields>, state: TState, asOfVersion: number): Effect.Effect<void, DynamoClientError | ValidationError, never>;
298
686
  /** See {@link EventStream.readSnapshot}. */
299
687
  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>;
688
+ /** See {@link EventStream.append}. */
689
+ append(streamId: StreamIdInput<TStreamIdFields>, events: ReadonlyArray<TEvent>, expectedVersion: number, options?: AppendOptions<TMetadata, TState> | undefined): Effect.Effect<AppendResult<TEvent>, AppendError, never>;
690
+ /** See {@link EventStream.read}. */
691
+ read(streamId: StreamIdInput<TStreamIdFields>, options?: ReadOptions | undefined): Effect.Effect<ReadonlyArray<StreamEvent<TEvent, StreamMetadata<TMetadata>>>, DynamoClientError | ValidationError, never>;
692
+ /** See {@link EventStream.readFrom}. */
693
+ readFrom(streamId: StreamIdInput<TStreamIdFields>, afterVersion: number, options?: ReadOptions | undefined): Effect.Effect<ReadonlyArray<StreamEvent<TEvent, StreamMetadata<TMetadata>>>, DynamoClientError | ValidationError, never>;
694
+ /** See {@link EventStream.currentVersion}. */
695
+ currentVersion(streamId: StreamIdInput<TStreamIdFields>, options?: ReadOptions | undefined): Effect.Effect<number, DynamoClientError | ValidationError, never>;
696
+ /** See {@link EventStream.readLatest}. */
697
+ readLatest(streamId: StreamIdInput<TStreamIdFields>, options?: ReadLatestOptions | undefined): Effect.Effect<LatestState<TState, TEvent, StreamMetadata<TMetadata>>, DynamoClientError | ValidationError, never>;
698
+ /** See {@link EventStream.readIndex}. */
699
+ readIndex(name: TIndexName, streamId: StreamIdInput<TStreamIdFields>, options?: ReadIndexOptions | undefined): Effect.Effect<ReadonlyArray<StreamEvent<TEvent, StreamMetadata<TMetadata>>>, DynamoClientError | ValidationError, never>;
700
+ /**
701
+ * The stream's query builders — unbound: run them with {@link provide}, or
702
+ * where `DynamoClient` and `TableConfig` are provided.
703
+ */
304
704
  readonly query: {
305
705
  events(streamId: StreamIdInput<TStreamIdFields>): Query.Query<StreamEvent<TEvent, StreamMetadata<TMetadata>>>;
706
+ /** See `EventStream.query.index`. */
707
+ index(name: TIndexName, streamId: StreamIdInput<TStreamIdFields>): Query.Query<StreamEvent<TEvent, StreamMetadata<TMetadata>>>;
306
708
  };
307
709
  /** Escape hatch: provide DynamoClient | TableConfig to an arbitrary effect. */
308
710
  readonly provide: <A, E>(effect: Effect.Effect<A, E, DynamoClient | TableConfig>) => Effect.Effect<A, E, never>;
@@ -321,7 +723,64 @@ export interface BoundEventStream<TEvent, TStreamIdFields extends ReadonlyArray<
321
723
  * yield* stream.append({ matchId: "m-1" }, [event], 0) // R = never
322
724
  * ```
323
725
  */
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>;
726
+ export declare const bind: <TEvent, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TState, TIndexName extends string = never>(stream: EventStream<TEvent, TStreamIdFields, TMetadata, TState, TIndexName>) => Effect.Effect<BoundEventStream<TEvent, TStreamIdFields, TMetadata, TState, TIndexName>, never, DynamoClient | TableConfig>;
727
+ /**
728
+ * `CreateTable` fragments for the stream indexes of one or more streams — see
729
+ * {@link indexDefinitions}. The lists are absent when empty, because
730
+ * DynamoDB rejects an empty index list.
731
+ */
732
+ export interface StreamIndexDefinitions {
733
+ /** The index key attributes (all strings), excluding the table's `pk` / `sk`. */
734
+ readonly AttributeDefinitions: Array<AttributeDefinition>;
735
+ readonly LocalSecondaryIndexes?: Array<LocalSecondaryIndex> | undefined;
736
+ readonly GlobalSecondaryIndexes?: Array<GlobalSecondaryIndex> | undefined;
737
+ }
738
+ /**
739
+ * Derive the `CreateTable` fragments for the stream indexes (#140) of the given
740
+ * streams (`EventStream` or `BoundEventStream`) — `AttributeDefinitions`,
741
+ * `LocalSecondaryIndexes` and `GlobalSecondaryIndexes`, each index projecting
742
+ * `ALL` — to merge into the caller's `CreateTable` input. Event tables are not
743
+ * derived by `Table.definition`, and the table's own `pk` / `sk` are not
744
+ * included: add them (and, on a provisioned table, each GSI's
745
+ * `ProvisionedThroughput`) yourself.
746
+ *
747
+ * An index shared by several streams with the same definition is emitted
748
+ * once. Lists are sorted by index name, attributes by attribute name.
749
+ *
750
+ * - An LSI can only be created with the table, and caps the item collection
751
+ * of every partition key value in the table at 10 GB — entity partitions
752
+ * and streams without indexes included.
753
+ * - The projection must stay `ALL`: events are decoded from the index item.
754
+ *
755
+ * @example
756
+ * ```typescript
757
+ * const fragments = EventStore.indexDefinitions(Entries, Orders)
758
+ * yield* client.createTable({
759
+ * TableName: "events",
760
+ * BillingMode: "PAY_PER_REQUEST",
761
+ * KeySchema: [
762
+ * { AttributeName: "pk", KeyType: "HASH" },
763
+ * { AttributeName: "sk", KeyType: "RANGE" },
764
+ * ],
765
+ * AttributeDefinitions: [
766
+ * { AttributeName: "pk", AttributeType: "S" },
767
+ * { AttributeName: "sk", AttributeType: "S" },
768
+ * ...fragments.AttributeDefinitions,
769
+ * ],
770
+ * LocalSecondaryIndexes: fragments.LocalSecondaryIndexes,
771
+ * GlobalSecondaryIndexes: fragments.GlobalSecondaryIndexes,
772
+ * })
773
+ * ```
774
+ *
775
+ * @throws `[EDD-9067]` when two streams define the same physical index
776
+ * differently (another type, or other key attributes).
777
+ */
778
+ export declare const indexDefinitions: (...streams: ReadonlyArray<{
779
+ readonly streamName: string;
780
+ readonly indexes: {
781
+ readonly [name: string]: StreamIndexSettings;
782
+ };
783
+ }>) => StreamIndexDefinitions;
325
784
  /**
326
785
  * Handler-level configuration for {@link commandHandler}.
327
786
  *
@@ -336,32 +795,188 @@ export interface CommandHandlerOptions {
336
795
  * The retried unit is the entire read–decide–append cycle, so every attempt
337
796
  * decides against freshly read state — a blind re-append of stale events is
338
797
  * impossible by construction. Snapshot reads participate: a retried attempt
339
- * re-reads the snapshot and its delta.
798
+ * re-reads the snapshot and its delta (the snapshot alone with
799
+ * {@link verifySnapshot}`: false`), and a function-form `additionalItems` is
800
+ * re-evaluated against the new decision.
340
801
  *
341
802
  * A number `n` is shorthand for `Schedule.recurs(n)` (n retries *after* the
342
803
  * initial attempt). Omit for the default: no retry.
343
804
  *
344
- * `DuplicateCommand` is deliberately NOT retried — it is terminal.
805
+ * `DuplicateCommand` is deliberately NOT retried — it is terminal. Nor is
806
+ * any call that supplies {@link CommandOptions.expectedVersion}: the caller asked
807
+ * for a conditional write, so its `VersionConflict` is always surfaced.
345
808
  */
346
809
  readonly retry?: number | Schedule.Schedule<unknown, VersionConflict> | undefined;
347
810
  /** Opt in to exactly-once command processing — see {@link AppendIdempotency}. */
348
811
  readonly idempotency?: {
349
812
  readonly ttl?: Duration.Duration | string;
350
813
  };
814
+ /**
815
+ * Load state with strongly consistent reads. Default `true`.
816
+ *
817
+ * An eventually consistent load can miss the newest events, so `decide` runs
818
+ * against stale state and the append then fails with `VersionConflict` (or
819
+ * burns a retry). A consistent load costs twice the read capacity of an
820
+ * eventually consistent one. Set `false` to trade that for the occasional
821
+ * conflict. On a snapshot-configured stream the snapshot and its delta are
822
+ * one query ({@link EventStream.readLatest}), so the setting covers both.
823
+ */
824
+ readonly consistentRead?: boolean | undefined;
825
+ /**
826
+ * Verify the snapshot against the stream head when loading state. Default
827
+ * `true`.
828
+ *
829
+ * With `true`, a snapshot-configured stream loads with one `Query` that
830
+ * reads the snapshot **and** the newest event (more on a lagging snapshot),
831
+ * so the state `decide` sees is verified current. `false` loads with one
832
+ * `GetItem` of the snapshot item only ({@link EventStream.readLatest} with
833
+ * `verifySnapshot: false`). A `Query`'s read capacity counts every item it
834
+ * reads, a `GetItem` only the snapshot, so with large events `false`
835
+ * roughly halves the read capacity per command.
836
+ *
837
+ * Accepted only when the stream's snapshot config is `mode: "inline"`
838
+ * **without** `every` — the only mode that writes the snapshot in every
839
+ * append's transaction, so it is normally at the head. Any other snapshot
840
+ * config throws `[EDD-9068]` when the handler is created (data-first or
841
+ * data-last). On a stream without a `snapshot` config the option is
842
+ * ignored: state is replayed and verified as always.
843
+ *
844
+ * The snapshot can still lag the head — events appended without one (a raw
845
+ * `append`, data written before `mode: "inline"`). So no answer made on an
846
+ * unverified snapshot is returned until the head confirms it: a successful
847
+ * append confirms it by itself; anything else — a `decide` failure, a no-op
848
+ * decision, an append `VersionConflict` — is checked with one more read,
849
+ * and a stale load falls back to a verified one:
850
+ *
851
+ * - **No snapshot item** (a stream not yet snapshotted): the verified load
852
+ * runs at once, and `decide` runs once.
853
+ * - **With {@link CommandOptions.expectedVersion}**: a snapshot whose
854
+ * `asOfVersion` differs from it is re-read verified before the If-Match
855
+ * check, so only a genuine mismatch is reported (its `actualVersion` is
856
+ * the verified head). A snapshot at the expected version holds exactly the
857
+ * state the caller saw, so `decide` runs on it; if that decision does not
858
+ * append, one head `Query` above the If-Match checks the head (see
859
+ * below). A head past the
860
+ * If-Match discards the decision and answers as a verified load would:
861
+ * `DuplicateCommand` for a committed redelivery, else `VersionConflict`
862
+ * with the head as `actualVersion` — never re-decided or retried.
863
+ * - **Without it**, a `decide` failure or a no-op is checked against the
864
+ * head the same way and returned when the head is at the snapshot. An
865
+ * append that fails with `VersionConflict` triggers one verified load:
866
+ * a snapshot at the head means another writer won a genuine race, and
867
+ * the `VersionConflict` goes to the {@link retry} policy as it always
868
+ * does. Otherwise — the head past the snapshot, or past the version
869
+ * decided on — the first decision was made on stale state and is
870
+ * discarded: the handler loads verified (if it has not) and decides again
871
+ * on that state, then appends — **`decide` (and a function-form
872
+ * `additionalItems`, for a decision that appends) runs twice for that
873
+ * call**. That is the optimistic-concurrency price of skipping the
874
+ * verification read, the same one a `VersionConflict` retry pays: a
875
+ * decision is only as current as the state it was made on, and a stale
876
+ * one cannot be patched, only made again. Without the re-decision, a
877
+ * lagging snapshot would fail or mis-answer every command on the stream,
878
+ * since only a successful inline append rewrites it. It happens at most
879
+ * once per attempt, and the second decision's outcome is final for the
880
+ * attempt (its conflict follows the policy).
881
+ *
882
+ * The head check is a reverse `Limit: 1` `Query` bounded below by the
883
+ * snapshot's version (or the If-Match), never a read of the newest event:
884
+ * while the snapshot is current the range is empty, so it reads no items
885
+ * and costs only DynamoDB's per-request minimum, however large the events
886
+ * are. A decision that appends nothing therefore costs one extra round trip,
887
+ * not extra item reads.
888
+ */
889
+ readonly verifySnapshot?: boolean | undefined;
351
890
  }
352
- /** Per-call options accepted by a handler produced by {@link commandHandler}. */
353
- export interface CommandOptions<TMetadata> {
891
+ /**
892
+ * The outcome of one `decide` call, handed to a function-form
893
+ * {@link CommandOptions.additionalItems}.
894
+ *
895
+ * - `events` — what `decide` returned (never empty: the function is not called
896
+ * for a no-op decision).
897
+ * - `state` — the `evolve` fold of `previous` with `events`: the state the
898
+ * handler returns and snapshots.
899
+ * - `previous` — the state `decide` was given.
900
+ * - `version` — the stream version the events are appended after; the new
901
+ * events take `version + 1 … version + events.length`.
902
+ *
903
+ * `evolve` may mutate state in place. When it does, `previous` and `state` are
904
+ * the same reference and `previous` already reflects `events`; a projection
905
+ * that needs a pristine `previous` requires an `evolve` that returns new state.
906
+ */
907
+ export interface Decision<State, Event> {
908
+ readonly events: ReadonlyArray<Event>;
909
+ readonly state: State;
910
+ readonly previous: State;
911
+ readonly version: number;
912
+ }
913
+ /**
914
+ * The forms {@link CommandOptions.additionalItems} accepts: a static array of
915
+ * transact ops, or a function of the {@link Decision} returning the array
916
+ * directly (pure projection) or as an `Effect` (a projection that needs a read).
917
+ * The effect's `E2` joins the handler's error channel and its `R2` the handler's
918
+ * requirements.
919
+ */
920
+ export type AdditionalItemsInput<State, Event, E2 = never, R2 = never> = ReadonlyArray<TransactWriteOp> | ((decision: Decision<State, Event>) => ReadonlyArray<TransactWriteOp> | Effect.Effect<ReadonlyArray<TransactWriteOp>, E2, R2>);
921
+ /**
922
+ * Per-call options accepted by a handler produced by {@link commandHandler}.
923
+ *
924
+ * `State` / `Event` type the {@link Decision} a function-form `additionalItems`
925
+ * receives; `E2` / `R2` are the error and requirements of the `Effect` it may
926
+ * return. The handler infers all four, so they only need naming when this type
927
+ * is written out by hand.
928
+ */
929
+ export interface CommandOptions<TMetadata, State = unknown, Event = unknown, E2 = never, R2 = never> {
354
930
  readonly metadata?: TMetadata;
355
931
  /**
356
932
  * Identifier for this command delivery. Required when the handler was created
357
- * with `idempotency`; a replayed id fails with `DuplicateCommand`.
933
+ * with `idempotency`; a replayed id fails with `DuplicateCommand` — also when
934
+ * the replay carries an {@link expectedVersion} the stream has since moved
935
+ * past (see there).
936
+ *
937
+ * Otherwise the sentinel is consulted when the events are appended, after
938
+ * `decide`, so a replay whose `decide` fails against the advanced state
939
+ * reports that failure, and one whose `decide` returns no events succeeds as
940
+ * a no-op. Only a redelivery that appends fails with `DuplicateCommand`.
358
941
  */
359
942
  readonly commandId?: string;
360
- /** Caller-owned transact items committed atomically with the produced events. */
361
- readonly additionalItems?: ReadonlyArray<TransactWriteOp>;
943
+ /**
944
+ * The stream version the caller last saw — an HTTP `If-Match`.
945
+ *
946
+ * After state is loaded and **before `decide` runs**, a stream at any other
947
+ * version fails the call with `VersionConflict`, carrying the loaded version
948
+ * as `actualVersion`; `decide` never sees state the caller did not. Otherwise
949
+ * the append is conditioned on this version, so a writer that slips in
950
+ * between the load and the append also fails it with `VersionConflict`
951
+ * (without `actualVersion`). Neither is retried, whatever the handler's
952
+ * `retry` policy says.
953
+ *
954
+ * With `idempotency`, a mismatch is first checked against the command's
955
+ * sentinel (one strongly consistent `GetItem`, on the mismatch path only): a
956
+ * redelivery of a command that already committed — its response lost, the
957
+ * same `commandId` and If-Match sent again — fails with `DuplicateCommand`,
958
+ * not `VersionConflict`, matching the precedence `append` applies.
959
+ *
960
+ * A no-op decision (`decide` returns `[]`) at the matching version succeeds
961
+ * with the current state and version. Must be a non-negative integer — any
962
+ * other value fails with `ValidationError` before anything is read.
963
+ */
964
+ readonly expectedVersion?: number | undefined;
965
+ /**
966
+ * Caller-owned transact items committed atomically with the produced events
967
+ * (see {@link AppendOptions.additionalItems}).
968
+ *
969
+ * Either a static array, or a function of the {@link Decision} — an inline
970
+ * projection — returning the array or an `Effect` of it. The function runs
971
+ * after `decide` and after the new events are folded, is not called when
972
+ * `decide` returns no events, and is re-run on every retry attempt. The items
973
+ * it returns count towards `AppendTooLarge`, and the `indices` of an
974
+ * `AdditionalItemConditionFailed` refer to the array it returned.
975
+ */
976
+ readonly additionalItems?: AdditionalItemsInput<State, Event, E2, R2>;
362
977
  }
363
978
  /** Per-call options when the handler was created with `idempotency` — `commandId` is required. */
364
- export interface IdempotentCommandOptions<TMetadata> extends CommandOptions<TMetadata> {
979
+ export interface IdempotentCommandOptions<TMetadata, State = unknown, Event = unknown, E2 = never, R2 = never> extends CommandOptions<TMetadata, State, Event, E2, R2> {
365
980
  readonly commandId: string;
366
981
  }
367
982
  /**
@@ -369,12 +984,28 @@ export interface IdempotentCommandOptions<TMetadata> extends CommandOptions<TMet
369
984
  * required (and `commandId` within it non-optional), so a missing `commandId`
370
985
  * is a compile error rather than a silent downgrade to at-least-once.
371
986
  */
372
- type CommandOptionsArgs<TMetadata, TConfig extends CommandHandlerOptions | undefined> = TConfig extends {
987
+ type CommandOptionsArgs<TMetadata, TConfig extends CommandHandlerOptions | undefined, State, TEvent, E2, R2> = TConfig extends {
373
988
  readonly idempotency: object;
374
- } ? [options: IdempotentCommandOptions<TMetadata>] : [options?: CommandOptions<TMetadata> | undefined];
989
+ } ? [options: IdempotentCommandOptions<TMetadata, State, TEvent, E2, R2>] : [options?: CommandOptions<TMetadata, State, TEvent, E2, R2> | undefined];
375
990
  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>;
991
+ /**
992
+ * A handler over an {@link EventStream}. Generic per call over the error (`E2`)
993
+ * and requirements (`R2`) of a function-form `additionalItems` that returns an
994
+ * `Effect`; both are `never` otherwise.
995
+ */
996
+ type CommandHandler<State, Command, TEvent, E, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TConfig extends CommandHandlerOptions | undefined = undefined> = <E2 = never, R2 = never>(streamId: StreamIdInput<TStreamIdFields>, command: Command, ...options: CommandOptionsArgs<TMetadata, TConfig, State, TEvent, E2, R2>) => Effect.Effect<CommandHandlerResult<State, TEvent>, CommandHandlerErrors<E> | E2, DynamoClient | TableConfig | R2>;
997
+ /**
998
+ * A handler over a {@link BoundEventStream}: the stream's services are already
999
+ * provided, so a function-form `additionalItems`' `R2` is its only requirement.
1000
+ */
1001
+ type BoundCommandHandler<State, Command, TEvent, E, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TConfig extends CommandHandlerOptions | undefined = undefined> = <E2 = never, R2 = never>(streamId: StreamIdInput<TStreamIdFields>, command: Command, ...options: CommandOptionsArgs<TMetadata, TConfig, State, TEvent, E2, R2>) => Effect.Effect<CommandHandlerResult<State, TEvent>, CommandHandlerErrors<E> | E2, R2>;
1002
+ /**
1003
+ * The handler the data-last {@link commandHandler} returns for stream `S`: a
1004
+ * {@link BoundCommandHandler} for a `BoundEventStream` (checked first — a bound
1005
+ * stream is also structurally an `EventStream`), a {@link CommandHandler}
1006
+ * otherwise.
1007
+ */
1008
+ 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
1009
  /**
379
1010
  * Create a command handler that reads, decides, and appends atomically.
380
1011
  *
@@ -400,12 +1031,51 @@ type BoundCommandHandler<State, Command, TEvent, E, TStreamIdFields extends Read
400
1031
  * idempotency: { ttl: Duration.days(1) },
401
1032
  * })
402
1033
  * yield* handle({ matchId: "m-1" }, command, { commandId: "cmd-7f3a" })
1034
+ *
1035
+ * // Load state from the inline snapshot alone (mode "inline" without `every`)
1036
+ * const handle = EventStore.commandHandler(decider, stream, { verifySnapshot: false })
1037
+ *
1038
+ * // If-Match: fail with VersionConflict (never retried) unless the stream is at v7
1039
+ * yield* handle({ matchId: "m-1" }, command, { expectedVersion: 7 })
1040
+ *
1041
+ * // Inline projection, committed in the same transaction as the events
1042
+ * yield* handle({ matchId: "m-1" }, command, {
1043
+ * additionalItems: ({ state }) => [Scoreboard.put({ matchId: "m-1", runs: state.runs })],
1044
+ * })
403
1045
  * ```
404
1046
  *
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.
1047
+ * Each invocation runs
1048
+ * `load → [expectedVersion check] → decide → fold new events → derive items → append → [snapshot]`.
1049
+ *
1050
+ * - **Load.** Strongly consistent by default — see
1051
+ * {@link CommandHandlerOptions.consistentRead}. When the stream declares a
1052
+ * `snapshot` config, state is loaded with {@link EventStream.readLatest} —
1053
+ * the snapshot, the events after it and the head in one request in steady
1054
+ * state — and only those events are folded, instead of replaying the stream
1055
+ * from the beginning. On a `mode: "inline"` stream without `every`,
1056
+ * {@link CommandHandlerOptions.verifySnapshot}`: false` loads the snapshot
1057
+ * item alone (one `GetItem`); an answer that is not a successful append is
1058
+ * confirmed against the head first, and a snapshot that proves stale falls
1059
+ * back to the verified load — in which case `decide` may run twice.
1060
+ * - **expectedVersion.** See {@link CommandOptions.expectedVersion}.
1061
+ * - **Fold before append.** The new events are folded into state before the
1062
+ * append, so the state returned, snapshotted and handed to a function-form
1063
+ * `additionalItems` (see {@link Decision}) is always the `evolve` fold.
1064
+ * - **Snapshot.** With `snapshot.mode: "inline"`, the snapshot is written in
1065
+ * the append transaction itself — on every append, or at the `every`
1066
+ * cadence. With the default `"after-append"` and `snapshot.every` set, a
1067
+ * fresh snapshot is written (best-effort) after a successful append once the
1068
+ * cadence threshold is crossed. See {@link SnapshotMode}.
1069
+ * - **One atomic append.** One command, one decision, one append: a decision
1070
+ * too large for one transaction fails with `AppendTooLarge` before anything
1071
+ * is written. A large command is split by the application into stepped
1072
+ * commands — each an ordinary call, chained by `expectedVersion` — so a
1073
+ * failure partway leaves the stream at a real intermediate state.
1074
+ *
1075
+ * The handler is generic per call over the error and requirements of a
1076
+ * function-form `additionalItems` that returns an `Effect`: they join the
1077
+ * handler's error channel and requirements (on a `BoundEventStream` handler
1078
+ * they are its only requirements).
409
1079
  *
410
1080
  * Without `idempotency`, command processing is **at-least-once**: a retry after
411
1081
  * an acked-but-lost response re-runs `decide` and appends again.
@@ -417,12 +1087,9 @@ type BoundCommandHandler<State, Command, TEvent, E, TStreamIdFields extends Read
417
1087
  * `EventStreamTypeId` brand of the second argument.
418
1088
  */
419
1089
  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>;
1090
+ <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> | EventStream<TEvent, ReadonlyArray<string>, any, State, any>>(stream: S) => CommandHandlerFor<S, State, Command, TEvent, E, TConfig>;
1091
+ <State, Command, TEvent, E, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TState extends State, TIndexName extends string, const TConfig extends CommandHandlerOptions | undefined = undefined>(decider: Decider<State, Command, TEvent, E>, stream: BoundEventStream<TEvent, TStreamIdFields, TMetadata, TState, TIndexName>, options?: TConfig): BoundCommandHandler<State, Command, TEvent, E, TStreamIdFields, TMetadata, TConfig>;
1092
+ <State, Command, TEvent, E, TStreamIdFields extends ReadonlyArray<string>, TMetadata, TState extends State, TIndexName extends string, const TConfig extends CommandHandlerOptions | undefined = undefined>(decider: Decider<State, Command, TEvent, E>, stream: EventStream<TEvent, TStreamIdFields, TMetadata, TState, TIndexName>, options?: TConfig): CommandHandler<State, Command, TEvent, E, TStreamIdFields, TMetadata, TConfig>;
426
1093
  };
427
1094
  /**
428
1095
  * Reconstruct state from events by folding through a decider's `evolve` function.