effect-dynamodb 1.11.0 → 1.12.1
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.
- package/dist/EventStore.d.ts +242 -35
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +516 -72
- package/dist/EventStore.js.map +1 -1
- package/dist/Transaction.d.ts +3 -18
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +13 -74
- package/dist/Transaction.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/TransactWriteOps.d.ts +50 -0
- package/dist/internal/TransactWriteOps.d.ts.map +1 -0
- package/dist/internal/TransactWriteOps.js +102 -0
- package/dist/internal/TransactWriteOps.js.map +1 -0
- package/package.json +5 -5
package/dist/EventStore.js
CHANGED
|
@@ -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
|
-
* -
|
|
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 {
|
|
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 {
|
|
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.
|
|
58
|
+
* class MatchStarted extends Schema.TaggedClass<MatchStarted>()("MatchStarted", {
|
|
34
59
|
* venue: Schema.String,
|
|
35
60
|
* }) {}
|
|
36
61
|
*
|
|
37
|
-
* class InningsCompleted extends Schema.
|
|
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((
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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:
|
|
80
|
-
version:
|
|
81
|
-
eventType:
|
|
190
|
+
streamId: envelope.streamId,
|
|
191
|
+
version: envelope.version,
|
|
192
|
+
eventType: envelope.eventType,
|
|
82
193
|
data,
|
|
83
|
-
metadata
|
|
84
|
-
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
|
-
|
|
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
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
107
|
-
|
|
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
|
|
117
|
-
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
193
|
-
|
|
442
|
+
const page = yield* Query.execute(query);
|
|
443
|
+
const newest = page.items[0];
|
|
444
|
+
if (newest === undefined)
|
|
194
445
|
return 0;
|
|
195
|
-
return
|
|
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 =
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|