effect-dynamodb 1.10.0 → 1.12.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 (51) hide show
  1. package/dist/DynamoClient.d.ts +88 -5
  2. package/dist/DynamoClient.d.ts.map +1 -1
  3. package/dist/DynamoClient.js +158 -4
  4. package/dist/DynamoClient.js.map +1 -1
  5. package/dist/Entity.d.ts +35 -17
  6. package/dist/Entity.d.ts.map +1 -1
  7. package/dist/Entity.js +499 -9
  8. package/dist/Entity.js.map +1 -1
  9. package/dist/EventStore.d.ts +242 -35
  10. package/dist/EventStore.d.ts.map +1 -1
  11. package/dist/EventStore.js +516 -72
  12. package/dist/EventStore.js.map +1 -1
  13. package/dist/Table.d.ts +78 -0
  14. package/dist/Table.d.ts.map +1 -1
  15. package/dist/Table.js +153 -0
  16. package/dist/Table.js.map +1 -1
  17. package/dist/Transaction.d.ts +3 -18
  18. package/dist/Transaction.d.ts.map +1 -1
  19. package/dist/Transaction.js +13 -74
  20. package/dist/Transaction.js.map +1 -1
  21. package/dist/VectorSearchEmulation.d.ts +101 -0
  22. package/dist/VectorSearchEmulation.d.ts.map +1 -0
  23. package/dist/VectorSearchEmulation.js +290 -0
  24. package/dist/VectorSearchEmulation.js.map +1 -0
  25. package/dist/index.d.ts +9 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +4 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/internal/BoundCrud.d.ts +62 -48
  30. package/dist/internal/BoundCrud.d.ts.map +1 -1
  31. package/dist/internal/BoundCrud.js +7 -1
  32. package/dist/internal/BoundCrud.js.map +1 -1
  33. package/dist/internal/BoundVectorQuery.d.ts +130 -0
  34. package/dist/internal/BoundVectorQuery.d.ts.map +1 -0
  35. package/dist/internal/BoundVectorQuery.js +257 -0
  36. package/dist/internal/BoundVectorQuery.js.map +1 -0
  37. package/dist/internal/EntityCombinators.d.ts +16 -0
  38. package/dist/internal/EntityCombinators.d.ts.map +1 -1
  39. package/dist/internal/EntityCombinators.js +25 -1
  40. package/dist/internal/EntityCombinators.js.map +1 -1
  41. package/dist/internal/EntityOps.d.ts +19 -6
  42. package/dist/internal/EntityOps.d.ts.map +1 -1
  43. package/dist/internal/EntityOps.js +5 -2
  44. package/dist/internal/EntityOps.js.map +1 -1
  45. package/dist/internal/TransactWriteOps.d.ts +50 -0
  46. package/dist/internal/TransactWriteOps.d.ts.map +1 -0
  47. package/dist/internal/TransactWriteOps.js +102 -0
  48. package/dist/internal/TransactWriteOps.js.map +1 -0
  49. package/dist/internal/TransactableOps.d.ts +1 -1
  50. package/dist/internal/TransactableOps.d.ts.map +1 -1
  51. package/package.json +3 -3
@@ -5,19 +5,39 @@
5
5
  * - `Decider` type for command-event-state modeling
6
6
  * - `makeStream` factory for creating event streams bound to a Table
7
7
  * - Core operations: `append`, `read`, `readFrom`, `currentVersion`
8
- * - `commandHandler` combinator for read-decide-append cycle
8
+ * - Snapshot primitives: `writeSnapshot`, `readSnapshot`
9
+ * - `commandHandler` combinator for read-decide-append cycle (snapshot-aware,
10
+ * with an optional `VersionConflict` retry policy)
9
11
  * - `fold` / `foldFrom` helpers for state reconstruction
10
12
  *
11
13
  * Built on the existing library primitives (DynamoSchema, KeyComposer, Query,
12
14
  * DynamoClient, Marshaller).
13
15
  */
14
16
  import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
15
- import { isAwsTransactionCancelled, TransactionCancelled, ValidationError, VersionConflict, } from "@effect-dynamodb/schema/Errors.js";
17
+ import { normalizeTtlSeconds } from "@effect-dynamodb/schema/Entity.js";
18
+ import { AdditionalItemConditionFailed, AppendTooLarge, DuplicateCommand, isAwsConditionalCheckFailed, isAwsTransactionCancelled, TRANSACT_WRITE_ITEMS_LIMIT, TransactionCancelled, ValidationError, VersionConflict, } from "@effect-dynamodb/schema/Errors.js";
16
19
  import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
17
- import { DateTime, Effect, Function, Schema } from "effect";
20
+ import { DateTime, Effect, Function, Option, Pipeable, Schedule, Schema, } from "effect";
18
21
  import { DynamoClient } from "./DynamoClient.js";
19
- import { toAttributeMap } from "./Marshaller.js";
22
+ import { buildTransactWriteItems, } from "./internal/TransactWriteOps.js";
23
+ import { fromAttributeMap, toAttributeMap } from "./Marshaller.js";
20
24
  import * as Query from "./Query.js";
25
+ import { resolveTtlAttributeName } from "./Table.js";
26
+ // ---------------------------------------------------------------------------
27
+ // Envelope schema — validates the persisted event's system fields on read
28
+ // ---------------------------------------------------------------------------
29
+ /**
30
+ * Schema for the persisted event envelope (system fields written by `append`).
31
+ * Event `data` and `metadata` are decoded separately through their own schemas.
32
+ *
33
+ * @internal
34
+ */
35
+ const EventEnvelope = Schema.Struct({
36
+ streamId: Schema.String,
37
+ version: Schema.Number,
38
+ eventType: Schema.String,
39
+ timestamp: Schema.String,
40
+ });
21
41
  // ---------------------------------------------------------------------------
22
42
  // EventStreamTypeId
23
43
  // ---------------------------------------------------------------------------
@@ -28,13 +48,18 @@ const EventStreamTypeId = Symbol.for("effect-dynamodb/EventStream");
28
48
  /**
29
49
  * Create an EventStream bound to a Table.
30
50
  *
51
+ * Define event schemas with `Schema.TaggedClass` (not plain `Schema.Class`):
52
+ * stored events are decoded through a `Schema.Union` of the event schemas, and
53
+ * without a declared `_tag` field the union discriminates structurally — two
54
+ * event types with identical fields would mis-decode as each other.
55
+ *
31
56
  * @example
32
57
  * ```typescript
33
- * class MatchStarted extends Schema.Class<MatchStarted>("MatchStarted")({
58
+ * class MatchStarted extends Schema.TaggedClass<MatchStarted>()("MatchStarted", {
34
59
  * venue: Schema.String,
35
60
  * }) {}
36
61
  *
37
- * class InningsCompleted extends Schema.Class<InningsCompleted>("InningsCompleted")({
62
+ * class InningsCompleted extends Schema.TaggedClass<InningsCompleted>()("InningsCompleted", {
38
63
  * innings: Schema.Number,
39
64
  * runs: Schema.Number,
40
65
  * }) {}
@@ -46,11 +71,45 @@ const EventStreamTypeId = Symbol.for("effect-dynamodb/EventStream");
46
71
  * streamId: { composite: ["matchId"] },
47
72
  * })
48
73
  * ```
74
+ *
75
+ * Opt into snapshots by declaring a state schema:
76
+ *
77
+ * @example
78
+ * ```typescript
79
+ * const MatchEvents = EventStore.makeStream({
80
+ * table: EventsTable,
81
+ * streamName: "Match",
82
+ * events: [MatchStarted, InningsCompleted],
83
+ * streamId: { composite: ["matchId"] },
84
+ * snapshot: { schema: MatchStateSchema, every: 100 },
85
+ * })
86
+ * ```
87
+ *
88
+ * @throws `[EDD-9027]` when `snapshot.every` is not a positive integer.
49
89
  */
50
90
  export const makeStream = (config) => {
51
91
  const schema = config.table.schema;
52
92
  const entityType = `${config.streamName.toLowerCase()}.event`;
93
+ const snapshotEntityType = `${config.streamName.toLowerCase()}.snapshot`;
94
+ /**
95
+ * Entity type of the command-dedup sentinel. Distinct from `entityType` so the
96
+ * sentinel is filtered out of every event query (`read`, `readFrom`,
97
+ * `currentVersion` all constrain `__edd_e__`), and it sorts before the event
98
+ * keys (`.command` < `.event`) so it also falls outside `readFrom` ranges.
99
+ */
100
+ const commandEntityType = `${config.streamName.toLowerCase()}.command`;
53
101
  const compositeFields = config.streamId.composite;
102
+ // -------------------------------------------------------------------------
103
+ // Snapshot config validation (EDD-9027) — fail fast at definition time.
104
+ // -------------------------------------------------------------------------
105
+ const snapshot = config.snapshot;
106
+ if (snapshot !== undefined && snapshot.every !== undefined) {
107
+ if (!Number.isInteger(snapshot.every) || snapshot.every <= 0) {
108
+ throw new Error(`[EDD-9027] EventStream "${config.streamName}": snapshot.every must be a positive integer; ` +
109
+ `received ${String(snapshot.every)}.`);
110
+ }
111
+ }
112
+ const snapshotSettings = snapshot === undefined ? undefined : { every: snapshot.every };
54
113
  // Build union schema from event schemas for decoding
55
114
  const eventUnion = config.events.length === 1
56
115
  ? config.events[0]
@@ -65,63 +124,142 @@ export const makeStream = (config) => {
65
124
  return DynamoSchema.composeKey(schema, config.streamName.toLowerCase(), composites);
66
125
  };
67
126
  const composeEventSk = (version) => DynamoSchema.composeEventVersionKey(schema, entityType, version);
127
+ /**
128
+ * Every event SK begins with this; nothing else in the stream partition does.
129
+ * Bounding event reads to it excludes the snapshot item at the key-condition
130
+ * level, which matters because DynamoDB applies `Limit` *before*
131
+ * `FilterExpression` — a filtered-out snapshot would still burn a `Limit` slot.
132
+ */
133
+ const eventSkPrefix = DynamoSchema.composeEventVersionKeyPrefix(schema, entityType);
134
+ /** Inclusive upper bound of the event SK range (10-digit padding maximum). */
135
+ const maxEventSk = composeEventSk(DynamoSchema.MAX_EVENT_VERSION);
136
+ /**
137
+ * The snapshot SK. Distinct entity-type label (`<stream>.snapshot` vs
138
+ * `<stream>.event_1#…`), so it can never collide with an event SK, and it
139
+ * sorts after every event in the partition.
140
+ */
141
+ const snapshotSk = DynamoSchema.composeKey(schema, snapshotEntityType, []);
142
+ const composeStreamIdString = (streamId) => compositeFields.map((f) => streamId[f]).join("#");
143
+ // ---------------------------------------------------------------------------
144
+ // Codec helpers — write encodes, read decodes (symmetry with Entity/Aggregate)
145
+ // ---------------------------------------------------------------------------
146
+ /**
147
+ * Resolve the schema to encode an event with. Prefers the exact member
148
+ * schema (via `instanceof` for `Schema.Class`/`Schema.TaggedClass` events)
149
+ * over the union so structurally-overlapping members can't shadow each
150
+ * other; falls back to the union for non-class event schemas.
151
+ */
152
+ const memberSchemaFor = (event) => {
153
+ if (config.events.length === 1)
154
+ return config.events[0];
155
+ for (const member of config.events) {
156
+ const ctor = member;
157
+ if (typeof ctor === "function" && event instanceof ctor)
158
+ return member;
159
+ }
160
+ return eventUnion;
161
+ };
162
+ /**
163
+ * Validate input and produce wire-form output: `Schema.encode` first, with a
164
+ * `decode → encode` fallback for inputs already in encoded shape. Mirrors
165
+ * the Entity write path (`encodeOrDecodeEncode` in `Entity.ts`).
166
+ */
167
+ const encodeToWire = (codec, input, operation) => Schema.encodeUnknownEffect(codec)(input).pipe(Effect.catch((primaryCause) => Schema.decodeUnknownEffect(codec)(input).pipe(Effect.flatMap((decoded) => Schema.encodeUnknownEffect(codec)(decoded)),
168
+ // Surface the original encode error — its message is keyed on the
169
+ // caller's input shape, which is what the user expects to see.
170
+ Effect.catch(() => Effect.fail(new ValidationError({ entityType, operation, cause: primaryCause }))))));
68
171
  // ---------------------------------------------------------------------------
69
172
  // Decode a raw DynamoDB item → StreamEvent<TEvent>
70
173
  // ---------------------------------------------------------------------------
174
+ const decodeEnvelope = Schema.decodeUnknownEffect(EventEnvelope);
71
175
  const decodeStreamEvent = (raw) => Effect.gen(function* () {
176
+ const toValidationError = (operation) => (cause) => new ValidationError({ entityType, operation, cause });
177
+ const envelope = yield* decodeEnvelope(raw).pipe(Effect.mapError(toValidationError("EventStore.decode")));
72
178
  const decoder = Schema.decodeUnknownEffect(eventUnion);
73
- const data = yield* decoder(raw.data).pipe(Effect.mapError((cause) => new ValidationError({
74
- entityType,
75
- operation: "EventStore.decode",
76
- cause,
77
- })));
179
+ const data = yield* decoder(raw.data).pipe(Effect.mapError(toValidationError("EventStore.decode")));
180
+ // Metadata is decoded through its schema when the stream declares one —
181
+ // the mirror of the encode performed by `append`. Streams without a
182
+ // metadata schema surface the stored attribute map untouched.
183
+ let metadata;
184
+ if (raw.metadata !== undefined) {
185
+ metadata = metadataSchema
186
+ ? yield* Schema.decodeUnknownEffect(metadataSchema)(raw.metadata).pipe(Effect.mapError(toValidationError("EventStore.decode.metadata")))
187
+ : raw.metadata;
188
+ }
78
189
  return {
79
- streamId: raw.streamId,
80
- version: raw.version,
81
- eventType: raw.eventType,
190
+ streamId: envelope.streamId,
191
+ version: envelope.version,
192
+ eventType: envelope.eventType,
82
193
  data,
83
- metadata: raw.metadata,
84
- timestamp: raw.timestamp,
194
+ metadata,
195
+ timestamp: envelope.timestamp,
85
196
  };
86
197
  });
87
198
  // ---------------------------------------------------------------------------
88
199
  // append
89
200
  // ---------------------------------------------------------------------------
90
201
  const append = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
91
- if (events.length === 0) {
202
+ const additionalOps = options?.additionalItems ?? [];
203
+ const idempotency = options?.idempotency;
204
+ // Nothing at all to write — preserve the historical no-op fast path.
205
+ // With additional items or a dedup sentinel the transaction still runs:
206
+ // a caller who asked for a side write means it, and silently dropping it
207
+ // would lose data.
208
+ if (events.length === 0 && additionalOps.length === 0 && idempotency === undefined) {
92
209
  return { version: expectedVersion, events: [] };
93
210
  }
211
+ // Resolve stream ID string for storage (join composites)
212
+ const streamIdStr = composeStreamIdString(streamId);
213
+ // Guard: every item the transaction will carry — one Put per event, one
214
+ // per caller-supplied additional item, the idempotency sentinel, and the
215
+ // version-contiguity ConditionCheck when expectedVersion > 0 — must fit
216
+ // DynamoDB's TransactWriteItems limit. Counted before any work is done so
217
+ // an oversized append costs nothing. Never chunk: chunking would break
218
+ // append atomicity.
219
+ const needsContiguityCheck = expectedVersion > 0 && events.length > 0;
220
+ const requiredItems = events.length +
221
+ additionalOps.length +
222
+ (idempotency !== undefined ? 1 : 0) +
223
+ (needsContiguityCheck ? 1 : 0);
224
+ if (requiredItems > TRANSACT_WRITE_ITEMS_LIMIT) {
225
+ return yield* new AppendTooLarge({
226
+ streamName: config.streamName,
227
+ streamId: streamIdStr,
228
+ count: requiredItems,
229
+ limit: TRANSACT_WRITE_ITEMS_LIMIT,
230
+ });
231
+ }
94
232
  const client = yield* DynamoClient;
95
- const { name: tableName } = yield* config.table.Tag;
233
+ const tableConfig = yield* config.table.Tag;
234
+ const tableName = tableConfig.name;
96
235
  const pk = composeStreamPk(streamId);
97
236
  // Clock-backed timestamp (deterministic under TestClock; wall-clock in prod).
98
- const now = DateTime.formatIso(yield* DateTime.now);
99
- // Resolve stream ID string for storage (join composites)
100
- const streamIdStr = compositeFields
101
- .map((f) => streamId[f])
102
- .join("#");
103
- // Validate and encode metadata if schema provided
237
+ const nowDateTime = yield* DateTime.now;
238
+ const now = DateTime.formatIso(nowDateTime);
239
+ // Validate and encode metadata to wire form if schema provided
104
240
  let encodedMetadata;
105
241
  if (options?.metadata !== undefined && metadataSchema) {
106
- const validated = yield* Schema.decodeUnknownEffect(metadataSchema)(options.metadata).pipe(Effect.mapError((cause) => new ValidationError({
107
- entityType,
108
- operation: "EventStore.append.metadata",
109
- cause,
110
- })));
111
- encodedMetadata = validated;
242
+ const encoded = yield* encodeToWire(metadataSchema, options.metadata, "EventStore.append.metadata");
243
+ encodedMetadata = encoded;
112
244
  }
113
245
  else if (options?.metadata !== undefined) {
114
246
  encodedMetadata = options.metadata;
115
247
  }
116
- // Build transact items — one Put per event, each with attribute_not_exists(pk)
117
- const transactItems = events.map((event, i) => {
248
+ // Build the event puts — one Put per event, each with attribute_not_exists(pk).
249
+ // Events are encoded to wire form through their schema (codec symmetry
250
+ // with the read path, which decodes through the same schema), so this is
251
+ // an effectful build rather than a plain `map`.
252
+ const eventItems = yield* Effect.forEach(events, (event, i) => Effect.gen(function* () {
118
253
  const version = expectedVersion + i + 1;
119
254
  // In Effect v4, Schema.Class instances don't have _tag as an own property.
120
255
  // The identifier is on the constructor (class) itself.
121
256
  const evtType = event._tag ??
122
257
  event.constructor.identifier ??
123
258
  event.constructor.name;
124
- const eventData = { _tag: evtType, ...event };
259
+ const wire = yield* encodeToWire(memberSchemaFor(event), event, "EventStore.append");
260
+ // Inject _tag for plain Schema.Class events; Schema.TaggedClass
261
+ // events already carry _tag in their encoded form, which wins.
262
+ const eventData = { _tag: evtType, ...wire };
125
263
  const item = {
126
264
  pk,
127
265
  sk: composeEventSk(version),
@@ -142,28 +280,131 @@ export const makeStream = (config) => {
142
280
  ConditionExpression: "attribute_not_exists(pk)",
143
281
  },
144
282
  };
145
- });
283
+ }));
284
+ // Caller-owned items, compiled through the same builder
285
+ // `Transaction.transactWrite` uses, so the two APIs cannot drift.
286
+ const additionalItems = yield* buildTransactWriteItems(additionalOps, "EventStore.append.additionalItems");
287
+ // Version-contiguity guard: `attribute_not_exists(pk)` on the event puts
288
+ // only rejects STALE expected versions (the target slot already exists).
289
+ // An AHEAD expectedVersion (e.g. 10 when the stream is at 3) would
290
+ // silently write from version 11, leaving a permanent gap. When
291
+ // expectedVersion > 0, require the event at exactly `expectedVersion` to
292
+ // exist so the appended range is contiguous with the stream head. Its
293
+ // failure surfaces as a ConditionalCheckFailed cancellation reason,
294
+ // mapping to VersionConflict below just like a stale-version Put failure.
295
+ //
296
+ // Only when events are actually being written: the guard exists to stop an
297
+ // AHEAD expectedVersion opening a permanent gap, and a zero-event append
298
+ // (pure `additionalItems` / sentinel side-write) writes no version and so
299
+ // can open no gap.
300
+ const contiguityCheck = needsContiguityCheck
301
+ ? [
302
+ {
303
+ ConditionCheck: {
304
+ TableName: tableName,
305
+ Key: toAttributeMap({ pk, sk: composeEventSk(expectedVersion) }),
306
+ ConditionExpression: "attribute_exists(pk)",
307
+ },
308
+ },
309
+ ]
310
+ : [];
311
+ // Item layout is load-bearing — cancellation reasons are positional:
312
+ // [0, C) version-contiguity ConditionCheck (C is 0 or 1)
313
+ // [C, C + E) event puts
314
+ // [C + E, C + E + A) additional items (caller order preserved)
315
+ // C + E + A idempotency sentinel (last, so adding it never
316
+ // shifts the additional-item indices the caller sees)
317
+ const transactItems = [
318
+ ...contiguityCheck,
319
+ ...eventItems,
320
+ ...additionalItems,
321
+ ];
322
+ const checkCount = contiguityCheck.length;
323
+ const eventCount = eventItems.length;
324
+ const additionalCount = additionalItems.length;
325
+ const sentinelIndex = idempotency !== undefined ? transactItems.length : -1;
326
+ if (idempotency !== undefined) {
327
+ const sentinel = {
328
+ pk,
329
+ sk: DynamoSchema.composeKey(schema, commandEntityType, [idempotency.commandId]),
330
+ __edd_e__: commandEntityType,
331
+ streamId: streamIdStr,
332
+ commandId: idempotency.commandId,
333
+ version: expectedVersion + events.length,
334
+ timestamp: now,
335
+ };
336
+ if (idempotency.ttl !== undefined) {
337
+ const ttlSeconds = yield* Effect.try({
338
+ try: () => normalizeTtlSeconds(idempotency.ttl),
339
+ catch: (cause) => new ValidationError({
340
+ entityType: commandEntityType,
341
+ operation: "EventStore.append.idempotency.ttl",
342
+ cause,
343
+ }),
344
+ });
345
+ sentinel[resolveTtlAttributeName(tableConfig)] =
346
+ DateTime.toEpochSeconds(nowDateTime) + ttlSeconds;
347
+ }
348
+ transactItems.push({
349
+ Put: {
350
+ TableName: tableName,
351
+ Item: toAttributeMap(sentinel),
352
+ ConditionExpression: "attribute_not_exists(pk)",
353
+ },
354
+ });
355
+ }
146
356
  yield* client.transactWriteItems({ TransactItems: transactItems }).pipe(Effect.mapError((error) => {
147
- if (isAwsTransactionCancelled(error.cause)) {
148
- const reasons = (error.cause.CancellationReasons ?? []).map((r) => ({
149
- code: r?.Code,
150
- message: r?.Message,
151
- }));
152
- const hasConflict = reasons.some((r) => r.code === "ConditionalCheckFailed");
153
- if (hasConflict) {
357
+ if (!isAwsTransactionCancelled(error.cause)) {
358
+ return error;
359
+ }
360
+ const reasons = (error.cause.CancellationReasons ?? []).map((r) => ({
361
+ code: r?.Code,
362
+ message: r?.Message,
363
+ }));
364
+ const failedAt = (index) => index >= 0 && reasons[index]?.code === "ConditionalCheckFailed";
365
+ // Precedence is ordered by how terminal the caller's response should
366
+ // be: a duplicate can never succeed on retry, a version conflict
367
+ // invites a re-read, and only then is the caller's own condition the
368
+ // most specific explanation left.
369
+ if (idempotency !== undefined && failedAt(sentinelIndex)) {
370
+ return new DuplicateCommand({
371
+ streamName: config.streamName,
372
+ streamId: streamIdStr,
373
+ commandId: idempotency.commandId,
374
+ });
375
+ }
376
+ // The contiguity ConditionCheck and the event puts both mean "the
377
+ // stream is not where you said it was", so they share one verdict.
378
+ for (let i = 0; i < checkCount + eventCount; i++) {
379
+ if (failedAt(i)) {
154
380
  return new VersionConflict({
155
381
  streamName: config.streamName,
156
382
  streamId: streamIdStr,
157
383
  expectedVersion,
158
384
  });
159
385
  }
160
- return new TransactionCancelled({
161
- operation: "TransactWriteItems",
386
+ }
387
+ const failedAdditional = [];
388
+ for (let i = 0; i < additionalCount; i++) {
389
+ if (failedAt(checkCount + eventCount + i))
390
+ failedAdditional.push(i);
391
+ }
392
+ if (failedAdditional.length > 0) {
393
+ return new AdditionalItemConditionFailed({
394
+ streamName: config.streamName,
395
+ streamId: streamIdStr,
396
+ indices: failedAdditional,
162
397
  reasons,
163
- cause: error.cause,
164
398
  });
165
399
  }
166
- return error;
400
+ // No conditional failure we can positionally justify (throttling,
401
+ // TransactionConflict, or a truncated/absent reason list) — never
402
+ // guess a VersionConflict.
403
+ return new TransactionCancelled({
404
+ operation: "TransactWriteItems",
405
+ reasons,
406
+ cause: error.cause,
407
+ });
167
408
  }));
168
409
  return {
169
410
  version: expectedVersion + events.length,
@@ -181,18 +422,92 @@ export const makeStream = (config) => {
181
422
  // readFrom
182
423
  // ---------------------------------------------------------------------------
183
424
  const readFrom = (streamId, afterVersion) => Effect.gen(function* () {
184
- const query = buildEventsQuery(streamId).pipe(Query.where({ gt: composeEventSk(afterVersion) }));
425
+ // Versions are integers, so the inclusive lower bound `afterVersion + 1`
426
+ // is exactly the old exclusive `#sk > eventSk(afterVersion)`. The upper
427
+ // bound keeps the snapshot item (which sorts after every event) out of the
428
+ // scanned range.
429
+ const query = buildEventsQuery(streamId).pipe(Query.where({ between: [composeEventSk(afterVersion + 1), maxEventSk] }));
185
430
  return yield* Query.collect(query);
186
431
  });
187
432
  // ---------------------------------------------------------------------------
188
433
  // currentVersion
189
434
  // ---------------------------------------------------------------------------
190
435
  const currentVersion = (streamId) => Effect.gen(function* () {
436
+ // Single page, not `collect`: with `Limit: 1` DynamoDB returns a
437
+ // `LastEvaluatedKey` on every truncated page, so `collect` would walk the
438
+ // whole partition one request per item. The `begins_with` bound on
439
+ // `buildEventsQuery` guarantees the single evaluated item is the newest
440
+ // *event* (never the snapshot, which sorts last).
191
441
  const query = buildEventsQuery(streamId).pipe(Query.reverse, Query.limit(1));
192
- const results = yield* Query.collect(query);
193
- if (results.length === 0)
442
+ const page = yield* Query.execute(query);
443
+ const newest = page.items[0];
444
+ if (newest === undefined)
194
445
  return 0;
195
- return results[0].version;
446
+ return newest.version;
447
+ });
448
+ // ---------------------------------------------------------------------------
449
+ // Snapshot primitives
450
+ // ---------------------------------------------------------------------------
451
+ const snapshotUnavailable = (operation) => Effect.die(new Error(`[EDD-9026] EventStream "${config.streamName}": ${operation} requires a snapshot config. ` +
452
+ `Declare one with makeStream({ ..., snapshot: { schema } }).`));
453
+ const writeSnapshot = (streamId, state, asOfVersion) => Effect.gen(function* () {
454
+ if (snapshot === undefined)
455
+ return yield* snapshotUnavailable("writeSnapshot");
456
+ const client = yield* DynamoClient;
457
+ const { name: tableName } = yield* config.table.Tag;
458
+ const now = DateTime.formatIso(yield* DateTime.now);
459
+ const encoded = yield* Schema.encodeUnknownEffect(snapshot.schema)(state).pipe(Effect.mapError((cause) => new ValidationError({
460
+ entityType: snapshotEntityType,
461
+ operation: "EventStore.writeSnapshot",
462
+ cause,
463
+ })));
464
+ const item = {
465
+ pk: composeStreamPk(streamId),
466
+ sk: snapshotSk,
467
+ __edd_e__: snapshotEntityType,
468
+ streamId: composeStreamIdString(streamId),
469
+ asOfVersion,
470
+ state: encoded,
471
+ timestamp: now,
472
+ };
473
+ yield* client
474
+ .putItem({
475
+ TableName: tableName,
476
+ Item: toAttributeMap(item),
477
+ // Monotonic: never regress the cache. Losing this race is a no-op,
478
+ // not an error — the events it summarises are already durable.
479
+ ConditionExpression: "attribute_not_exists(#pk) OR #asOfVersion < :asOfVersion",
480
+ ExpressionAttributeNames: { "#pk": "pk", "#asOfVersion": "asOfVersion" },
481
+ ExpressionAttributeValues: toAttributeMap({ ":asOfVersion": asOfVersion }),
482
+ })
483
+ .pipe(Effect.catchIf((error) => isAwsConditionalCheckFailed(error.cause), () => Effect.void));
484
+ });
485
+ const readSnapshot = (streamId) => Effect.gen(function* () {
486
+ if (snapshot === undefined)
487
+ return yield* snapshotUnavailable("readSnapshot");
488
+ const client = yield* DynamoClient;
489
+ const { name: tableName } = yield* config.table.Tag;
490
+ const result = yield* client.getItem({
491
+ TableName: tableName,
492
+ Key: toAttributeMap({
493
+ pk: composeStreamPk(streamId),
494
+ sk: snapshotSk,
495
+ }),
496
+ ConsistentRead: true,
497
+ });
498
+ if (result.Item === undefined)
499
+ return Option.none();
500
+ const raw = fromAttributeMap(result.Item);
501
+ const state = yield* Schema.decodeUnknownEffect(snapshot.schema)(raw.state).pipe(Effect.mapError((cause) => new ValidationError({
502
+ entityType: snapshotEntityType,
503
+ operation: "EventStore.readSnapshot",
504
+ cause,
505
+ })));
506
+ return Option.some({
507
+ state,
508
+ asOfVersion: raw.asOfVersion,
509
+ timestamp: raw.timestamp,
510
+ });
196
511
  });
197
512
  // ---------------------------------------------------------------------------
198
513
  // query.events helper
@@ -208,7 +523,10 @@ export const makeStream = (config) => {
208
523
  entityTypes: [entityType],
209
524
  decoder: (raw) => decodeStreamEvent(raw),
210
525
  resolveTableName: config.table.Tag.useSync((tc) => tc.name),
211
- });
526
+ // Bound to the event SK range so non-event items in the stream partition
527
+ // (the snapshot) are excluded at the key-condition level. A caller-supplied
528
+ // `Query.where` replaces this — the `__edd_e__` filter still applies.
529
+ }).pipe(Query.where({ beginsWith: eventSkPrefix }));
212
530
  };
213
531
  const queryNamespace = {
214
532
  events: (streamId) => buildEventsQuery(streamId),
@@ -223,8 +541,15 @@ export const makeStream = (config) => {
223
541
  // runtime — the user must provide these layers.
224
542
  return {
225
543
  [EventStreamTypeId]: EventStreamTypeId,
544
+ pipe() {
545
+ // eslint-disable-next-line prefer-rest-params
546
+ return Pipeable.pipeArguments(this, arguments);
547
+ },
226
548
  streamName: config.streamName,
227
549
  eventSchema: eventUnion,
550
+ snapshotConfig: snapshotSettings,
551
+ writeSnapshot,
552
+ readSnapshot,
228
553
  append,
229
554
  read,
230
555
  readFrom,
@@ -254,8 +579,15 @@ export const bind = (stream) => Effect.gen(function* () {
254
579
  const provide = (effect) => Effect.provide(effect, ctx);
255
580
  return {
256
581
  [EventStreamTypeId]: EventStreamTypeId,
582
+ pipe() {
583
+ // eslint-disable-next-line prefer-rest-params
584
+ return Pipeable.pipeArguments(this, arguments);
585
+ },
257
586
  streamName: stream.streamName,
258
587
  eventSchema: stream.eventSchema,
588
+ snapshotConfig: stream.snapshotConfig,
589
+ writeSnapshot: (streamId, state, asOfVersion) => provide(stream.writeSnapshot(streamId, state, asOfVersion)),
590
+ readSnapshot: (streamId) => provide(stream.readSnapshot(streamId)),
259
591
  append: (streamId, events, expectedVersion, options) => provide(stream.append(streamId, events, expectedVersion, options)),
260
592
  read: (streamId) => provide(stream.read(streamId)),
261
593
  readFrom: (streamId, afterVersion) => provide(stream.readFrom(streamId, afterVersion)),
@@ -264,6 +596,110 @@ export const bind = (stream) => Effect.gen(function* () {
264
596
  provide,
265
597
  };
266
598
  });
599
+ /** @internal Both `EventStream` and `BoundEventStream` carry this brand. */
600
+ const hasEventStreamBrand = (u) => typeof u === "object" && u !== null && EventStreamTypeId in u;
601
+ /** @internal */
602
+ const makeCommandHandlerImpl = (decider, stream, options) => {
603
+ const retryPolicy = options?.retry;
604
+ const schedule = retryPolicy === undefined
605
+ ? undefined
606
+ : typeof retryPolicy === "number"
607
+ ? Schedule.recurs(retryPolicy)
608
+ : retryPolicy;
609
+ return (streamId, command, callOptions) => {
610
+ const attempt = Effect.gen(function* () {
611
+ // Backstop for JS callers and `any`-shaped call sites: silently degrading
612
+ // to at-least-once would look like success right up until the day a
613
+ // duplicate mattered. Not retryable — `while` below only retries
614
+ // `VersionConflict`, so this surfaces on the first attempt.
615
+ if (options?.idempotency !== undefined && callOptions?.commandId === undefined) {
616
+ return yield* new ValidationError({
617
+ entityType: stream.streamName,
618
+ operation: "EventStore.commandHandler",
619
+ cause: "commandId is required when commandHandler is configured with `idempotency`.",
620
+ });
621
+ }
622
+ const snapshotSettings = stream.snapshotConfig;
623
+ // 1. Establish the base state + version — from a snapshot plus its delta
624
+ // when the stream has snapshots enabled and one exists, otherwise from
625
+ // a full replay.
626
+ let state = decider.initialState;
627
+ let baseVersion = 0;
628
+ let snapshotAsOfVersion = 0;
629
+ const snapshot = snapshotSettings === undefined
630
+ ? Option.none()
631
+ : (yield* stream.readSnapshot(streamId));
632
+ if (Option.isSome(snapshot)) {
633
+ snapshotAsOfVersion = snapshot.value.asOfVersion;
634
+ baseVersion = snapshot.value.asOfVersion;
635
+ state = snapshot.value.state;
636
+ const delta = yield* stream.readFrom(streamId, snapshot.value.asOfVersion);
637
+ for (const event of delta) {
638
+ state = decider.evolve(state, event.data);
639
+ }
640
+ const newest = delta[delta.length - 1];
641
+ if (newest !== undefined)
642
+ baseVersion = newest.version;
643
+ }
644
+ else {
645
+ const events = yield* stream.read(streamId);
646
+ for (const event of events) {
647
+ state = decider.evolve(state, event.data);
648
+ }
649
+ const newest = events[events.length - 1];
650
+ baseVersion = newest === undefined ? 0 : newest.version;
651
+ }
652
+ // 2. Decide
653
+ const newEvents = yield* decider.decide(command, state);
654
+ // 3. No-op command — return current state
655
+ if (newEvents.length === 0) {
656
+ return { state, version: baseVersion, events: [] };
657
+ }
658
+ // 4. Append with optimistic concurrency, plus any caller-owned items and
659
+ // the dedup sentinel, all in one transaction.
660
+ const appendOptions = {};
661
+ if (callOptions?.metadata !== undefined)
662
+ appendOptions.metadata = callOptions.metadata;
663
+ if (callOptions?.additionalItems !== undefined) {
664
+ appendOptions.additionalItems = callOptions.additionalItems;
665
+ }
666
+ if (options?.idempotency !== undefined && callOptions?.commandId !== undefined) {
667
+ appendOptions.idempotency =
668
+ options.idempotency.ttl !== undefined
669
+ ? { commandId: callOptions.commandId, ttl: options.idempotency.ttl }
670
+ : { commandId: callOptions.commandId };
671
+ }
672
+ const result = yield* stream.append(streamId, newEvents, baseVersion, appendOptions);
673
+ // 5. Evolve state through the new events
674
+ for (const event of newEvents) {
675
+ state = decider.evolve(state, event);
676
+ }
677
+ // 6. Auto-snapshot once the cadence threshold is crossed. Best-effort:
678
+ // the events are already durable, so a snapshot-write failure must not
679
+ // report the command as failed. The next threshold crossing retries it.
680
+ const every = snapshotSettings?.every;
681
+ if (every !== undefined && result.version - snapshotAsOfVersion >= every) {
682
+ yield* stream
683
+ .writeSnapshot(streamId, state, result.version)
684
+ .pipe(Effect.catchCause((cause) => Effect.logWarning(`EventStore: snapshot write failed for stream "${stream.streamName}" at version ${result.version}`, cause)));
685
+ }
686
+ return { state, version: result.version, events: newEvents };
687
+ });
688
+ return schedule === undefined
689
+ ? attempt
690
+ : Effect.retry(attempt, {
691
+ // `while` runs before the schedule, so the schedule only ever sees a
692
+ // `VersionConflict` input — the widening cast is sound. It is needed
693
+ // because `Retry.Options` demands a schedule whose input accepts the
694
+ // effect's *full* error union, and `Schedule` is contravariant on input.
695
+ schedule: schedule,
696
+ // `VersionConflict` only. `DuplicateCommand` is terminal — the same
697
+ // commandId can never succeed — and `AdditionalItemConditionFailed`
698
+ // will not resolve itself by re-deciding either.
699
+ while: (error) => error instanceof VersionConflict,
700
+ });
701
+ };
702
+ };
267
703
  /**
268
704
  * Create a command handler that reads, decides, and appends atomically.
269
705
  *
@@ -279,31 +715,39 @@ export const bind = (stream) => Effect.gen(function* () {
279
715
  *
280
716
  * // Data-last (pipe)
281
717
  * const handle = stream.pipe(EventStore.commandHandler(decider))
718
+ *
719
+ * // Retry the full read-decide-append cycle on VersionConflict
720
+ * const handle = EventStore.commandHandler(decider, stream, { retry: 3 })
721
+ * const handle2 = stream.pipe(EventStore.commandHandler(decider, { retry: 3 }))
722
+ *
723
+ * // Exactly-once command processing — `commandId` becomes required per call
724
+ * const handle = EventStore.commandHandler(decider, stream, {
725
+ * idempotency: { ttl: Duration.days(1) },
726
+ * })
727
+ * yield* handle({ matchId: "m-1" }, command, { commandId: "cmd-7f3a" })
282
728
  * ```
729
+ *
730
+ * When the stream declares a `snapshot` config, each invocation reads the
731
+ * snapshot and folds only the events after it, instead of replaying the stream
732
+ * from the beginning. With `snapshot.every` set, a fresh snapshot is written
733
+ * (best-effort) after a successful append once the cadence threshold is crossed.
734
+ *
735
+ * Without `idempotency`, command processing is **at-least-once**: a retry after
736
+ * an acked-but-lost response re-runs `decide` and appends again.
737
+ *
738
+ * Note: this is a hand-rolled dual rather than `Function.dual` — the data
739
+ * argument (`stream`) is the *second* parameter, which `Function.dual` cannot
740
+ * express, and its numeric-arity form would silently drop the trailing options
741
+ * (`retry`, `idempotency`) that both forms depend on. Dispatch is on the
742
+ * `EventStreamTypeId` brand of the second argument.
283
743
  */
284
- export const commandHandler = Function.dual(2, (decider, stream) => (streamId, command, options) => Effect.gen(function* () {
285
- // 1. Read all events
286
- const events = yield* stream.read(streamId);
287
- // 2. Fold to current state
288
- const curVersion = events.length > 0 ? events[events.length - 1].version : 0;
289
- let state = decider.initialState;
290
- for (const event of events) {
291
- state = decider.evolve(state, event.data);
744
+ export const commandHandler = ((decider, streamOrOptions, maybeOptions) => {
745
+ if (hasEventStreamBrand(streamOrOptions)) {
746
+ return makeCommandHandlerImpl(decider, streamOrOptions, maybeOptions);
292
747
  }
293
- // 3. Decide
294
- const newEvents = yield* decider.decide(command, state);
295
- // 4. No-op command — return current state
296
- if (newEvents.length === 0) {
297
- return { state, version: curVersion, events: [] };
298
- }
299
- // 5. Append with optimistic concurrency
300
- const result = yield* stream.append(streamId, newEvents, curVersion, options);
301
- // 6. Evolve state through new events
302
- for (const event of newEvents) {
303
- state = decider.evolve(state, event);
304
- }
305
- return { state, version: result.version, events: newEvents };
306
- }));
748
+ const options = streamOrOptions;
749
+ return (stream) => makeCommandHandlerImpl(decider, stream, options);
750
+ });
307
751
  // ---------------------------------------------------------------------------
308
752
  // fold helpers
309
753
  // ---------------------------------------------------------------------------