effect-dynamodb 1.23.0 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Aggregate.d.ts +5 -2
- package/dist/Aggregate.d.ts.map +1 -1
- package/dist/Aggregate.js +136 -53
- package/dist/Aggregate.js.map +1 -1
- package/dist/Batch.d.ts +14 -5
- package/dist/Batch.d.ts.map +1 -1
- package/dist/Batch.js +184 -119
- package/dist/Batch.js.map +1 -1
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +2 -4
- package/dist/Collection.js.map +1 -1
- package/dist/DynamoClient.d.ts.map +1 -1
- package/dist/DynamoClient.js +42 -14
- package/dist/DynamoClient.js.map +1 -1
- package/dist/Entity.d.ts +19 -13
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +287 -226
- package/dist/Entity.js.map +1 -1
- package/dist/EventStore.d.ts +1060 -50
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +1607 -299
- package/dist/EventStore.js.map +1 -1
- package/dist/Query.d.ts +47 -14
- package/dist/Query.d.ts.map +1 -1
- package/dist/Query.js +190 -68
- package/dist/Query.js.map +1 -1
- package/dist/Table.d.ts +56 -6
- package/dist/Table.d.ts.map +1 -1
- package/dist/Table.js +134 -54
- package/dist/Table.js.map +1 -1
- package/dist/Transaction.d.ts +8 -0
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +67 -22
- package/dist/Transaction.js.map +1 -1
- package/dist/VectorSearchEmulation.d.ts +3 -2
- package/dist/VectorSearchEmulation.d.ts.map +1 -1
- package/dist/VectorSearchEmulation.js +14 -2
- package/dist/VectorSearchEmulation.js.map +1 -1
- package/dist/internal/BoundQuery.d.ts +3 -2
- package/dist/internal/BoundQuery.d.ts.map +1 -1
- package/dist/internal/BoundQuery.js.map +1 -1
- package/dist/internal/BoundVectorQuery.d.ts +5 -1
- package/dist/internal/BoundVectorQuery.d.ts.map +1 -1
- package/dist/internal/BoundVectorQuery.js +17 -5
- package/dist/internal/BoundVectorQuery.js.map +1 -1
- package/dist/internal/EntityCombinators.d.ts +8 -5
- package/dist/internal/EntityCombinators.d.ts.map +1 -1
- package/dist/internal/EntityCombinators.js +8 -5
- package/dist/internal/EntityCombinators.js.map +1 -1
- package/dist/internal/EntityOps.d.ts +1 -1
- package/dist/internal/Expr.d.ts +4 -0
- package/dist/internal/Expr.d.ts.map +1 -1
- package/dist/internal/Expr.js +6 -4
- package/dist/internal/Expr.js.map +1 -1
- package/dist/internal/ExpressionLimits.d.ts +45 -0
- package/dist/internal/ExpressionLimits.d.ts.map +1 -0
- package/dist/internal/ExpressionLimits.js +89 -0
- package/dist/internal/ExpressionLimits.js.map +1 -0
- package/dist/internal/Hydrate.d.ts +62 -0
- package/dist/internal/Hydrate.d.ts.map +1 -0
- package/dist/internal/Hydrate.js +191 -0
- package/dist/internal/Hydrate.js.map +1 -0
- package/dist/internal/ItemSize.d.ts +60 -24
- package/dist/internal/ItemSize.d.ts.map +1 -1
- package/dist/internal/ItemSize.js +122 -39
- package/dist/internal/ItemSize.js.map +1 -1
- package/dist/internal/TransactWriteOps.d.ts +22 -46
- package/dist/internal/TransactWriteOps.d.ts.map +1 -1
- package/dist/internal/TransactWriteOps.js +26 -95
- package/dist/internal/TransactWriteOps.js.map +1 -1
- package/dist/internal/WriteDiagnosis.d.ts +353 -0
- package/dist/internal/WriteDiagnosis.d.ts.map +1 -0
- package/dist/internal/WriteDiagnosis.js +1028 -0
- package/dist/internal/WriteDiagnosis.js.map +1 -0
- package/dist/internal/sparse_check.js +43 -79
- package/dist/internal/sparse_check.js.map +1 -1
- package/package.json +2 -2
package/dist/EventStore.js
CHANGED
|
@@ -4,10 +4,18 @@
|
|
|
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
|
|
8
|
-
*
|
|
9
|
-
* - `
|
|
10
|
-
*
|
|
7
|
+
* - Core operations: `append` (atomic), `read`, `readFrom`, `currentVersion`,
|
|
8
|
+
* `readLatest` (reads optionally strongly consistent)
|
|
9
|
+
* - Snapshot primitives: `writeSnapshot`, `readSnapshot`, and inline snapshots
|
|
10
|
+
* written in the append transaction (`AppendOptions.snapshot`)
|
|
11
|
+
* - `commandHandler` combinator for the load-decide-fold-append cycle
|
|
12
|
+
* (snapshot-aware single-request loads, consistent loads, an optional
|
|
13
|
+
* `VersionConflict` retry policy, caller-supplied `expectedVersion`,
|
|
14
|
+
* decision-derived `additionalItems`, inline snapshots)
|
|
15
|
+
* - Stream indexes (`makeStream({ indexes })`): sub-streams ordered by a key
|
|
16
|
+
* derived from each event, on an LSI or GSI, each projecting all of the
|
|
17
|
+
* event, all but its metadata, or only its keys — `readIndex`,
|
|
18
|
+
* `query.index`, and `indexDefinitions` for the `CreateTable` fragments
|
|
11
19
|
* - `fold` / `foldFrom` helpers for state reconstruction
|
|
12
20
|
*
|
|
13
21
|
* Built on the existing library primitives (DynamoSchema, KeyComposer, Query,
|
|
@@ -15,14 +23,15 @@
|
|
|
15
23
|
*/
|
|
16
24
|
import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
|
|
17
25
|
import { normalizeTtlSeconds } from "@effect-dynamodb/schema/Entity.js";
|
|
18
|
-
import { AdditionalItemConditionFailed, AppendTooLarge, DuplicateCommand, isAwsConditionalCheckFailed, isAwsTransactionCancelled,
|
|
26
|
+
import { AdditionalItemConditionFailed, AppendTooLarge, DuplicateCommand, isAwsConditionalCheckFailed, isAwsTransactionCancelled, TransactionCancelled, ValidationError, VersionConflict, } from "@effect-dynamodb/schema/Errors.js";
|
|
19
27
|
import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
|
|
20
|
-
import { DateTime, Effect, Function, Option, Pipeable, Schedule, Schema, } from "effect";
|
|
28
|
+
import { DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
|
|
21
29
|
import { DynamoClient } from "./DynamoClient.js";
|
|
22
|
-
import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, judgeCancellation,
|
|
30
|
+
import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, judgeCancellation, transactItemTarget, } from "./internal/TransactWriteOps.js";
|
|
31
|
+
import { diagnosedValidationError, diagnoseFailure, mergeKeyAttributes, withCause, } from "./internal/WriteDiagnosis.js";
|
|
23
32
|
import { fromAttributeMap, toAttributeMap } from "./Marshaller.js";
|
|
24
33
|
import * as Query from "./Query.js";
|
|
25
|
-
import { resolveTtlAttributeName } from "./Table.js";
|
|
34
|
+
import { DEFAULT_TTL_ATTRIBUTE_NAME, resolveTtlAttributeName, } from "./Table.js";
|
|
26
35
|
// ---------------------------------------------------------------------------
|
|
27
36
|
// Envelope schema — validates the persisted event's system fields on read
|
|
28
37
|
// ---------------------------------------------------------------------------
|
|
@@ -38,10 +47,260 @@ const EventEnvelope = Schema.Struct({
|
|
|
38
47
|
eventType: Schema.String,
|
|
39
48
|
timestamp: Schema.String,
|
|
40
49
|
});
|
|
50
|
+
/**
|
|
51
|
+
* @internal Why a {@link ReadIndexOptions} key condition cannot be sent, or
|
|
52
|
+
* `undefined` when it can: the options' own meaning — at most one of
|
|
53
|
+
* `beginsWith` and `between`, string bounds (a number would be sent as `N`
|
|
54
|
+
* against a string key). DynamoDB's rules on the bounds — not empty, a
|
|
55
|
+
* `between` in order — are DynamoDB's to enforce: its rejection is diagnosed,
|
|
56
|
+
* naming the index and the bound (#144).
|
|
57
|
+
*/
|
|
58
|
+
const invalidIndexKeyCondition = (options) => {
|
|
59
|
+
if (options?.beginsWith !== undefined && options.between !== undefined) {
|
|
60
|
+
return "Pass at most one of beginsWith and between.";
|
|
61
|
+
}
|
|
62
|
+
const beginsWith = options?.beginsWith;
|
|
63
|
+
if (beginsWith !== undefined) {
|
|
64
|
+
return typeof beginsWith === "string"
|
|
65
|
+
? undefined
|
|
66
|
+
: "beginsWith must be a string; omit it to read the whole sub-stream.";
|
|
67
|
+
}
|
|
68
|
+
const between = options?.between;
|
|
69
|
+
if (between === undefined)
|
|
70
|
+
return undefined;
|
|
71
|
+
if (!Array.isArray(between) ||
|
|
72
|
+
between.length !== 2 ||
|
|
73
|
+
!between.every((bound) => typeof bound === "string")) {
|
|
74
|
+
return "between must be a pair of strings.";
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* @internal The attributes `append` writes on every event item besides the
|
|
80
|
+
* table key (`pk`, `sk`), the optional `metadata` and the index attributes:
|
|
81
|
+
* the envelope, `data` and the `__edd_e__` discriminator. Sorted. A
|
|
82
|
+
* `"withoutMetadata"` stream index projects exactly these.
|
|
83
|
+
*/
|
|
84
|
+
const EVENT_ITEM_ATTRIBUTES = [
|
|
85
|
+
"__edd_e__",
|
|
86
|
+
"data",
|
|
87
|
+
"eventType",
|
|
88
|
+
"streamId",
|
|
89
|
+
"timestamp",
|
|
90
|
+
"version",
|
|
91
|
+
];
|
|
92
|
+
/**
|
|
93
|
+
* @internal The event attributes an `"all"` or `"withoutMetadata"` stream index
|
|
94
|
+
* guarantees on its rows and a decode needs: a row lacking one was read
|
|
95
|
+
* through an index deployed narrower than declared.
|
|
96
|
+
*/
|
|
97
|
+
const PROJECTED_ENVELOPE_ATTRIBUTES = [
|
|
98
|
+
"data",
|
|
99
|
+
"eventType",
|
|
100
|
+
"streamId",
|
|
101
|
+
"timestamp",
|
|
102
|
+
"version",
|
|
103
|
+
];
|
|
104
|
+
/**
|
|
105
|
+
* @internal Attributes a stream writes on its own items — events, snapshot,
|
|
106
|
+
* idempotency sentinel — plus the default TTL attribute. An index attribute
|
|
107
|
+
* may not reuse any of them.
|
|
108
|
+
*/
|
|
109
|
+
const STREAM_OWNED_ATTRIBUTES = [
|
|
110
|
+
"pk",
|
|
111
|
+
"sk",
|
|
112
|
+
"__edd_e__",
|
|
113
|
+
"streamId",
|
|
114
|
+
"version",
|
|
115
|
+
"eventType",
|
|
116
|
+
"data",
|
|
117
|
+
"metadata",
|
|
118
|
+
"timestamp",
|
|
119
|
+
"asOfVersion",
|
|
120
|
+
"state",
|
|
121
|
+
"commandId",
|
|
122
|
+
DEFAULT_TTL_ATTRIBUTE_NAME,
|
|
123
|
+
];
|
|
124
|
+
/** @internal Every member a stream index entry may declare (`StreamIndexConfigKey`). */
|
|
125
|
+
const STREAM_INDEX_CONFIG_KEYS = [
|
|
126
|
+
"type",
|
|
127
|
+
"index",
|
|
128
|
+
"pk",
|
|
129
|
+
"sk",
|
|
130
|
+
"key",
|
|
131
|
+
"projection",
|
|
132
|
+
];
|
|
133
|
+
/**
|
|
134
|
+
* @internal Validate `makeStream({ indexes })` at definition time.
|
|
135
|
+
*
|
|
136
|
+
* @throws `[EDD-9063]` for a malformed entry (unknown `type`, a `gsi` without
|
|
137
|
+
* `pk`, an `lsi` with `pk`, an empty name, a `key` that is not a function,
|
|
138
|
+
* an unknown `projection`, a member the entry type does not declare — a
|
|
139
|
+
* misspelled `projection` would otherwise silently mean `"all"`).
|
|
140
|
+
* @throws `[EDD-9064]` for an index attribute that collides with a
|
|
141
|
+
* stream-owned attribute.
|
|
142
|
+
* @throws `[EDD-9065]` for two indexes sharing a physical index name or an
|
|
143
|
+
* attribute.
|
|
144
|
+
*/
|
|
145
|
+
const resolveStreamIndexes = (streamName, indexes) => {
|
|
146
|
+
if (indexes === undefined)
|
|
147
|
+
return [];
|
|
148
|
+
const fail = (code, message) => {
|
|
149
|
+
throw new Error(`[${code}] EventStream "${streamName}": ${message}`);
|
|
150
|
+
};
|
|
151
|
+
const isName = (value) => typeof value === "string" && value !== "";
|
|
152
|
+
const resolved = [];
|
|
153
|
+
for (const [name, raw] of Object.entries(indexes)) {
|
|
154
|
+
if (typeof raw !== "object" || raw === null) {
|
|
155
|
+
fail("EDD-9063", `index "${name}" must be an object; received ${String(raw)}.`);
|
|
156
|
+
}
|
|
157
|
+
const entry = raw;
|
|
158
|
+
const unknownKey = Object.keys(entry).find((key) => !STREAM_INDEX_CONFIG_KEYS.includes(key));
|
|
159
|
+
if (unknownKey !== undefined) {
|
|
160
|
+
fail("EDD-9063", `index "${name}": unknown member "${unknownKey}"; an index declares ` +
|
|
161
|
+
`${STREAM_INDEX_CONFIG_KEYS.join(", ")}.`);
|
|
162
|
+
}
|
|
163
|
+
const type = (entry.type ?? "lsi");
|
|
164
|
+
if (type !== "lsi" && type !== "gsi") {
|
|
165
|
+
fail("EDD-9063", `index "${name}": type must be "lsi" or "gsi"; received ${JSON.stringify(entry.type)}.`);
|
|
166
|
+
}
|
|
167
|
+
if (!isName(entry.index)) {
|
|
168
|
+
fail("EDD-9063", `index "${name}": index must name the physical index (a non-empty string).`);
|
|
169
|
+
}
|
|
170
|
+
if (!isName(entry.sk)) {
|
|
171
|
+
fail("EDD-9063", `index "${name}": sk must name the sort key attribute (a non-empty string).`);
|
|
172
|
+
}
|
|
173
|
+
if (type === "gsi" && !isName(entry.pk)) {
|
|
174
|
+
fail("EDD-9063", `index "${name}": a "gsi" index requires pk, the attribute carrying the stream's ` +
|
|
175
|
+
"partition key (a non-empty string).");
|
|
176
|
+
}
|
|
177
|
+
if (type === "lsi" && entry.pk !== undefined) {
|
|
178
|
+
fail("EDD-9063", `index "${name}": an "lsi" index uses the table partition key "pk"; remove pk, or ` +
|
|
179
|
+
'declare type: "gsi".');
|
|
180
|
+
}
|
|
181
|
+
if (typeof entry.key !== "function") {
|
|
182
|
+
fail("EDD-9063", `index "${name}": key must be a function (event, version) => string | undefined.`);
|
|
183
|
+
}
|
|
184
|
+
const projection = (entry.projection ?? "all");
|
|
185
|
+
if (projection !== "all" && projection !== "withoutMetadata" && projection !== "keys") {
|
|
186
|
+
fail("EDD-9063", `index "${name}": projection must be "all", "withoutMetadata" or "keys"; received ` +
|
|
187
|
+
`${JSON.stringify(entry.projection)}.`);
|
|
188
|
+
}
|
|
189
|
+
const index = entry.index;
|
|
190
|
+
const sk = entry.sk;
|
|
191
|
+
const pk = type === "gsi" ? entry.pk : "pk";
|
|
192
|
+
const attributes = type === "gsi" ? [pk, sk] : [sk];
|
|
193
|
+
for (const attribute of attributes) {
|
|
194
|
+
if (STREAM_OWNED_ATTRIBUTES.includes(attribute)) {
|
|
195
|
+
fail("EDD-9064", `index "${name}": attribute "${attribute}" is written by the stream itself ` +
|
|
196
|
+
`(reserved: ${STREAM_OWNED_ATTRIBUTES.join(", ")}). Choose another attribute name.`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (type === "gsi" && pk === sk) {
|
|
200
|
+
fail("EDD-9065", `index "${name}": pk and sk must be different attributes ("${pk}").`);
|
|
201
|
+
}
|
|
202
|
+
for (const other of resolved) {
|
|
203
|
+
if (other.index === index) {
|
|
204
|
+
fail("EDD-9065", `indexes "${other.name}" and "${name}" both use the physical index "${index}".`);
|
|
205
|
+
}
|
|
206
|
+
const otherAttributes = other.type === "gsi" ? [other.pk, other.sk] : [other.sk];
|
|
207
|
+
const shared = attributes.find((attribute) => otherAttributes.includes(attribute));
|
|
208
|
+
if (shared !== undefined) {
|
|
209
|
+
fail("EDD-9065", `indexes "${other.name}" and "${name}" both use the attribute "${shared}".`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
resolved.push({
|
|
213
|
+
name,
|
|
214
|
+
type,
|
|
215
|
+
index,
|
|
216
|
+
pk,
|
|
217
|
+
sk,
|
|
218
|
+
projection,
|
|
219
|
+
key: entry.key,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return resolved;
|
|
223
|
+
};
|
|
41
224
|
// ---------------------------------------------------------------------------
|
|
42
225
|
// EventStreamTypeId
|
|
43
226
|
// ---------------------------------------------------------------------------
|
|
44
227
|
const EventStreamTypeId = Symbol.for("effect-dynamodb/EventStream");
|
|
228
|
+
/**
|
|
229
|
+
* @internal Carries the stream's stream-id formatter (composites joined in
|
|
230
|
+
* declaration order — the `streamId` reported on errors and stored on items),
|
|
231
|
+
* so {@link commandHandler} can report a pre-decide `VersionConflict` exactly
|
|
232
|
+
* as `append` would. Not part of the public interface: `makeStream` and `bind`
|
|
233
|
+
* attach it; a hand-built stream object falls back to joining the id's values.
|
|
234
|
+
*/
|
|
235
|
+
const StreamIdFormatter = Symbol.for("effect-dynamodb/EventStream/StreamIdFormatter");
|
|
236
|
+
/** @internal */
|
|
237
|
+
const formatStreamIdOf = (stream, streamId) => {
|
|
238
|
+
const formatter = stream[StreamIdFormatter];
|
|
239
|
+
return formatter !== undefined ? formatter(streamId) : Object.values(streamId).join("#");
|
|
240
|
+
};
|
|
241
|
+
/**
|
|
242
|
+
* @internal Carries a probe for a command's idempotency sentinel (one strongly
|
|
243
|
+
* consistent `GetItem`), so {@link commandHandler} can tell a redelivered
|
|
244
|
+
* command from a lost race when its pre-decide If-Match check fails. Not part
|
|
245
|
+
* of the public interface: `makeStream` and `bind` attach it (a bound stream's
|
|
246
|
+
* probe has its services provided); a hand-built stream object has none.
|
|
247
|
+
*/
|
|
248
|
+
const CommandSentinelProbe = Symbol.for("effect-dynamodb/EventStream/CommandSentinelProbe");
|
|
249
|
+
/** @internal */
|
|
250
|
+
const commandSentinelProbeOf = (stream) => stream[CommandSentinelProbe];
|
|
251
|
+
/**
|
|
252
|
+
* @internal Carries a head probe bounded below by a known version: the newest
|
|
253
|
+
* event version above `floorVersion`, or `floorVersion` itself when there is
|
|
254
|
+
* none. {@link commandHandler} uses it to confirm an unverified snapshot is
|
|
255
|
+
* still the head. Unlike `currentVersion`, it never reads the newest event when
|
|
256
|
+
* the snapshot is current — the key range above `floorVersion` is empty, so the
|
|
257
|
+
* `Query` reads no items and costs only DynamoDB's per-request minimum, however
|
|
258
|
+
* large the events are. Attached by `makeStream` and `bind` like
|
|
259
|
+
* {@link CommandSentinelProbe}; a hand-built stream falls back to
|
|
260
|
+
* `currentVersion`.
|
|
261
|
+
*/
|
|
262
|
+
const HeadAboveProbe = Symbol.for("effect-dynamodb/EventStream/HeadAboveProbe");
|
|
263
|
+
/** @internal */
|
|
264
|
+
const headAboveProbeOf = (stream) => stream[HeadAboveProbe];
|
|
265
|
+
/**
|
|
266
|
+
* @internal `[EDD-9068]`: `verifySnapshot: false` is accepted only by a stream
|
|
267
|
+
* whose snapshot is written in every append (`mode: "inline"` without `every`),
|
|
268
|
+
* the only mode in which the snapshot is normally at the head. Returns the
|
|
269
|
+
* refusal message, or `undefined` when the option is acceptable — including on
|
|
270
|
+
* a stream without a snapshot config, where it is ignored.
|
|
271
|
+
*/
|
|
272
|
+
const unverifiedSnapshotRefusal = (streamName, settings, operation) => settings === undefined || (settings.mode === "inline" && settings.every === undefined)
|
|
273
|
+
? undefined
|
|
274
|
+
: `[EDD-9068] EventStream "${streamName}": ${operation} with verifySnapshot: false requires ` +
|
|
275
|
+
`snapshot mode "inline" without "every" — the only mode that writes the snapshot in every ` +
|
|
276
|
+
`append, so it is normally at the head. This stream's snapshot is mode "${settings.mode}"` +
|
|
277
|
+
`${settings.every === undefined ? "" : ` with every: ${settings.every}`}.`;
|
|
278
|
+
/**
|
|
279
|
+
* @internal Why an `eventTimestamps` value cannot be written for `count`
|
|
280
|
+
* events — not an array, the wrong length, or an entry that is neither a
|
|
281
|
+
* valid `DateTime` (finite epoch) nor `undefined` — or `undefined` when it can. `label` opens the
|
|
282
|
+
* message (`"eventTimestamps has"` / `"eventTimestamps returned"`) and
|
|
283
|
+
* `events` names the events (`"2 events"` / `"the decision's 2 events"`).
|
|
284
|
+
*/
|
|
285
|
+
const eventTimestampsProblem = (timestamps, count, label, events) => {
|
|
286
|
+
if (!Array.isArray(timestamps) || timestamps.length !== count) {
|
|
287
|
+
return (`${label} ${Array.isArray(timestamps) ? timestamps.length : "no"} entries for ${events}: ` +
|
|
288
|
+
"it needs exactly one entry (a DateTime, or undefined) per event, in order.");
|
|
289
|
+
}
|
|
290
|
+
const malformed = timestamps.findIndex((t) => t !== undefined && (!DateTime.isDateTime(t) || !Number.isFinite(DateTime.toEpochMillis(t))));
|
|
291
|
+
if (malformed === -1)
|
|
292
|
+
return undefined;
|
|
293
|
+
const entry = timestamps[malformed];
|
|
294
|
+
// A DateTime with a NaN epoch (e.g. `setPartsUtc` from a bad parse) passes
|
|
295
|
+
// `isDateTime` but makes `formatIso` throw, so it is refused here instead.
|
|
296
|
+
if (DateTime.isDateTime(entry)) {
|
|
297
|
+
return (`eventTimestamps[${malformed}] is an invalid DateTime (its epoch milliseconds are not a ` +
|
|
298
|
+
"finite number): each entry must be a valid DateTime, or undefined to keep the append's own time.");
|
|
299
|
+
}
|
|
300
|
+
return (`eventTimestamps[${malformed}] is not a DateTime (received ` +
|
|
301
|
+
`${entry instanceof Date ? "a Date — convert it with DateTime.fromDateUnsafe" : entry === null ? "null" : typeof entry}): ` +
|
|
302
|
+
"each entry must be a DateTime, or undefined to keep the append's own time.");
|
|
303
|
+
};
|
|
45
304
|
// ---------------------------------------------------------------------------
|
|
46
305
|
// makeStream factory
|
|
47
306
|
// ---------------------------------------------------------------------------
|
|
@@ -85,6 +344,10 @@ const EventStreamTypeId = Symbol.for("effect-dynamodb/EventStream");
|
|
|
85
344
|
* })
|
|
86
345
|
* ```
|
|
87
346
|
*
|
|
347
|
+
* `snapshot.mode: "inline"` writes the snapshot in the append transaction
|
|
348
|
+
* itself, so it is current after every command and state loads in one
|
|
349
|
+
* request — see {@link SnapshotMode}.
|
|
350
|
+
*
|
|
88
351
|
* `casing` overrides the key casing for this stream, as an index's or a vector
|
|
89
352
|
* index's `casing` does: the stream name, stream-id values and command ids in
|
|
90
353
|
* its keys all take it. When omitted, the stream name is lower-cased and the
|
|
@@ -95,7 +358,61 @@ const EventStreamTypeId = Symbol.for("effect-dynamodb/EventStream");
|
|
|
95
358
|
* (`<stream>.event` etc.) are always lower-cased. In the next major, omitting
|
|
96
359
|
* `casing` will mean the schema's casing, as it does for indexes.
|
|
97
360
|
*
|
|
361
|
+
* `indexes` declares sub-streams ordered by a key derived from each event
|
|
362
|
+
* (#140), on a local (default) or global secondary index. `append` writes each
|
|
363
|
+
* event's derived key (and, for a GSI, the stream's partition key) on the
|
|
364
|
+
* event item; snapshots and idempotency sentinels never carry them, so they
|
|
365
|
+
* are never in an index. Read with {@link EventStream.readIndex} or
|
|
366
|
+
* `query.index`, and create the indexes with {@link indexDefinitions}. Each
|
|
367
|
+
* index's `projection` (default `"all"`) sets what it copies from each event
|
|
368
|
+
* and how it is read — see {@link StreamIndexProjection}.
|
|
369
|
+
*
|
|
370
|
+
* Index attributes are written only at append time and events are never
|
|
371
|
+
* rewritten. Declaring an index on a stream that already holds events, or
|
|
372
|
+
* changing an index's `key`, therefore leaves the earlier events out of the
|
|
373
|
+
* index (or under their old keys), and `readIndex` returns only what was
|
|
374
|
+
* indexed — without an error. The library does not backfill; whether and how
|
|
375
|
+
* to re-index existing events (for example, an `UpdateItem` per event setting
|
|
376
|
+
* the derived attributes) is the application's decision.
|
|
377
|
+
*
|
|
378
|
+
* @example
|
|
379
|
+
* ```typescript
|
|
380
|
+
* const Entries = EventStore.makeStream({
|
|
381
|
+
* table: EventsTable,
|
|
382
|
+
* streamName: "Ledger",
|
|
383
|
+
* events: [EntryRecorded, EntryVoided],
|
|
384
|
+
* streamId: { composite: ["ledgerId"] },
|
|
385
|
+
* indexes: {
|
|
386
|
+
* byEntry: {
|
|
387
|
+
* index: "lsi1",
|
|
388
|
+
* sk: "lsi1sk",
|
|
389
|
+
* key: (event, version) =>
|
|
390
|
+
* event._tag === "EntryRecorded" ? `ENTRY#${event.section}#${pad(version)}` : undefined,
|
|
391
|
+
* },
|
|
392
|
+
* // Every event, by the day it was recorded (both event types carry `day`).
|
|
393
|
+
* byDay: { type: "gsi", index: "gsi1", pk: "gsi1pk", sk: "gsi1sk", key: (e) => e.day },
|
|
394
|
+
* },
|
|
395
|
+
* })
|
|
396
|
+
*
|
|
397
|
+
* const sectionThree = yield* Entries.readIndex("byEntry", { ledgerId }, { beginsWith: "ENTRY#3#" })
|
|
398
|
+
* ```
|
|
399
|
+
*
|
|
98
400
|
* @throws `[EDD-9027]` when `snapshot.every` is not a positive integer.
|
|
401
|
+
* @throws `[EDD-9062]` when `snapshot.mode` is neither `"after-append"` nor
|
|
402
|
+
* `"inline"`.
|
|
403
|
+
* @throws `[EDD-9063]` for a malformed index: a `type` other than `"lsi"` /
|
|
404
|
+
* `"gsi"`, a `gsi` without `pk`, an `lsi` with `pk`, an empty `index` / `sk`
|
|
405
|
+
* / `pk`, a `key` that is not a function, a `projection` other than
|
|
406
|
+
* `"all"` / `"withoutMetadata"` / `"keys"`, or a member an index entry does
|
|
407
|
+
* not declare (such as a misspelled `projection`, also a type error).
|
|
408
|
+
* @throws `[EDD-9064]` when an index attribute is one the stream writes itself
|
|
409
|
+
* (`pk`, `sk`, `__edd_e__`, `streamId`, `version`, `eventType`, `data`,
|
|
410
|
+
* `metadata`, `timestamp`, `asOfVersion`, `state`, `commandId`, or the
|
|
411
|
+
* default TTL attribute `_ttl`). A custom `TableConfig.ttlAttributeName`
|
|
412
|
+
* is only known at runtime: an index attribute equal to it fails each append
|
|
413
|
+
* with `ValidationError`.
|
|
414
|
+
* @throws `[EDD-9065]` when two indexes share a physical index name or an
|
|
415
|
+
* attribute (or a GSI's `pk` and `sk` are the same attribute).
|
|
99
416
|
*/
|
|
100
417
|
export const makeStream = (config) => {
|
|
101
418
|
const schema = config.table.schema;
|
|
@@ -127,7 +444,7 @@ export const makeStream = (config) => {
|
|
|
127
444
|
const commandKeyLabel = `${keyLabel}.command`;
|
|
128
445
|
const compositeFields = config.streamId.composite;
|
|
129
446
|
// -------------------------------------------------------------------------
|
|
130
|
-
// Snapshot config validation (EDD-9027) — fail fast at definition time.
|
|
447
|
+
// Snapshot config validation (EDD-9027, EDD-9062) — fail fast at definition time.
|
|
131
448
|
// -------------------------------------------------------------------------
|
|
132
449
|
const snapshot = config.snapshot;
|
|
133
450
|
if (snapshot !== undefined && snapshot.every !== undefined) {
|
|
@@ -136,7 +453,30 @@ export const makeStream = (config) => {
|
|
|
136
453
|
`received ${String(snapshot.every)}.`);
|
|
137
454
|
}
|
|
138
455
|
}
|
|
139
|
-
|
|
456
|
+
if (snapshot !== undefined &&
|
|
457
|
+
snapshot.mode !== undefined &&
|
|
458
|
+
snapshot.mode !== "after-append" &&
|
|
459
|
+
snapshot.mode !== "inline") {
|
|
460
|
+
throw new Error(`[EDD-9062] EventStream "${config.streamName}": snapshot.mode must be "after-append" or ` +
|
|
461
|
+
`"inline"; received ${JSON.stringify(snapshot.mode)}.`);
|
|
462
|
+
}
|
|
463
|
+
const snapshotSettings = snapshot === undefined
|
|
464
|
+
? undefined
|
|
465
|
+
: { mode: snapshot.mode ?? "after-append", every: snapshot.every };
|
|
466
|
+
// Stream indexes (#140) — validated at definition time (EDD-9063..9065).
|
|
467
|
+
const streamIndexes = resolveStreamIndexes(config.streamName, config.indexes);
|
|
468
|
+
const indexSettings = {};
|
|
469
|
+
for (const idx of streamIndexes) {
|
|
470
|
+
indexSettings[idx.name] = {
|
|
471
|
+
type: idx.type,
|
|
472
|
+
index: idx.index,
|
|
473
|
+
pk: idx.pk,
|
|
474
|
+
sk: idx.sk,
|
|
475
|
+
projection: idx.projection,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
/** Every attribute the indexes write on an event item. */
|
|
479
|
+
const indexAttributes = streamIndexes.flatMap((idx) => idx.type === "gsi" ? [idx.pk, idx.sk] : [idx.sk]);
|
|
140
480
|
// Build union schema from event schemas for decoding
|
|
141
481
|
const eventUnion = config.events.length === 1
|
|
142
482
|
? config.events[0]
|
|
@@ -151,6 +491,8 @@ export const makeStream = (config) => {
|
|
|
151
491
|
return DynamoSchema.composeKey(schema, keyLabel, composites, keyOptions);
|
|
152
492
|
};
|
|
153
493
|
const composeEventSk = (version) => DynamoSchema.composeEventVersionKey(schema, eventKeyLabel, version, keyOptions);
|
|
494
|
+
/** Sort key of a command's idempotency sentinel. */
|
|
495
|
+
const composeCommandSk = (commandId) => DynamoSchema.composeKey(schema, commandKeyLabel, [commandId], keyOptions);
|
|
154
496
|
/**
|
|
155
497
|
* Every event SK begins with this; nothing else in the stream partition does.
|
|
156
498
|
* Bounding event reads to it excludes the snapshot item at the key-condition
|
|
@@ -164,6 +506,13 @@ export const makeStream = (config) => {
|
|
|
164
506
|
* The snapshot SK. Distinct entity-type label (`<stream>.snapshot` vs
|
|
165
507
|
* `<stream>.event_1#…`), so it can never collide with an event SK, and it
|
|
166
508
|
* sorts after every event in the partition.
|
|
509
|
+
*
|
|
510
|
+
* The three stream-owned SKs share `<prefix>#<label>.` and differ first at
|
|
511
|
+
* the literal suffix: `.command#<id>` < `.event_1#<version>` < `.snapshot`.
|
|
512
|
+
* The suffixes are lower-case, and a stream `casing` applies to all three
|
|
513
|
+
* alike (`"uppercase"` gives `.COMMAND` < `.EVENT_1` < `.SNAPSHOT`), so the
|
|
514
|
+
* order holds under every casing. `readLatest` relies on it: the range
|
|
515
|
+
* `[eventSkPrefix, snapshotSk]` holds exactly the events and the snapshot.
|
|
167
516
|
*/
|
|
168
517
|
const snapshotSk = DynamoSchema.composeKey(schema, snapshotKeyLabel, [], keyOptions);
|
|
169
518
|
const composeStreamIdString = (streamId) => compositeFields.map((f) => streamId[f]).join("#");
|
|
@@ -191,10 +540,10 @@ export const makeStream = (config) => {
|
|
|
191
540
|
* `decode → encode` fallback for inputs already in encoded shape. Mirrors
|
|
192
541
|
* the Entity write path (`encodeOrDecodeEncode` in `Entity.ts`).
|
|
193
542
|
*/
|
|
194
|
-
const encodeToWire = (codec, input, operation) => Schema.encodeUnknownEffect(codec)(input).pipe(Effect.catch((primaryCause) => Schema.decodeUnknownEffect(codec)(input).pipe(Effect.flatMap((decoded) => Schema.encodeUnknownEffect(codec)(decoded)),
|
|
543
|
+
const encodeToWire = (codec, input, operation, errorEntityType = entityType) => Schema.encodeUnknownEffect(codec)(input).pipe(Effect.catch((primaryCause) => Schema.decodeUnknownEffect(codec)(input).pipe(Effect.flatMap((decoded) => Schema.encodeUnknownEffect(codec)(decoded)),
|
|
195
544
|
// Surface the original encode error — its message is keyed on the
|
|
196
545
|
// caller's input shape, which is what the user expects to see.
|
|
197
|
-
Effect.catch(() => Effect.fail(new ValidationError({ entityType, operation, cause: primaryCause }))))));
|
|
546
|
+
Effect.catch(() => Effect.fail(new ValidationError({ entityType: errorEntityType, operation, cause: primaryCause }))))));
|
|
198
547
|
// ---------------------------------------------------------------------------
|
|
199
548
|
// Decode a raw DynamoDB item → StreamEvent<TEvent>
|
|
200
549
|
// ---------------------------------------------------------------------------
|
|
@@ -225,47 +574,268 @@ export const makeStream = (config) => {
|
|
|
225
574
|
// ---------------------------------------------------------------------------
|
|
226
575
|
// append
|
|
227
576
|
// ---------------------------------------------------------------------------
|
|
577
|
+
/**
|
|
578
|
+
* An event's key in a stream index (#140): a string, or `undefined` to
|
|
579
|
+
* leave the event out. Another type, or a `key` that throws, is a
|
|
580
|
+
* `ValidationError`, raised before anything is written. DynamoDB's own
|
|
581
|
+
* rules on a key — not empty, at most 1024 bytes for a sort key — are not
|
|
582
|
+
* pre-checked: DynamoDB rejects the append and the rejection is diagnosed,
|
|
583
|
+
* naming the index and event (see `sendAppendTransaction`).
|
|
584
|
+
*/
|
|
585
|
+
const deriveIndexKey = (idx, event, version) => Effect.try({
|
|
586
|
+
try: () => idx.key(event, version),
|
|
587
|
+
catch: (cause) => new ValidationError({ entityType, operation: "EventStore.append.indexes", cause }),
|
|
588
|
+
}).pipe(Effect.flatMap((derived) => {
|
|
589
|
+
if (derived === undefined)
|
|
590
|
+
return Effect.succeed(undefined);
|
|
591
|
+
const refuse = (problem) => Effect.fail(new ValidationError({
|
|
592
|
+
entityType,
|
|
593
|
+
operation: "EventStore.append.indexes",
|
|
594
|
+
cause: `The key of index "${idx.name}" for the event at version ${version} ${problem}. ` +
|
|
595
|
+
"Nothing was written.",
|
|
596
|
+
}));
|
|
597
|
+
// A string, or the event is not indexed: what the stream's own key
|
|
598
|
+
// contract means, which DynamoDB cannot see (a number would be sent
|
|
599
|
+
// as `N`). An empty string is DynamoDB's to reject, and is diagnosed
|
|
600
|
+
// from its rejection, naming the index and the event (#144).
|
|
601
|
+
if (typeof derived !== "string") {
|
|
602
|
+
return refuse(`must be a string or undefined; received ${String(derived)}`);
|
|
603
|
+
}
|
|
604
|
+
return Effect.succeed(derived);
|
|
605
|
+
}));
|
|
606
|
+
/**
|
|
607
|
+
* Encode snapshot state to wire form through the state schema, with the
|
|
608
|
+
* same `decode → encode` fallback as events and metadata. A structurally
|
|
609
|
+
* matching plain object — what an immutable `evolve` that spreads a
|
|
610
|
+
* `Schema.Class` state returns (`({ ...s, balance })`), which TypeScript
|
|
611
|
+
* accepts as the class type — is decoded into the schema's type first
|
|
612
|
+
* rather than refused with `Expected <Class>`.
|
|
613
|
+
*/
|
|
614
|
+
const encodeSnapshotState = (state, operation) => encodeToWire(snapshot.schema, state, operation, snapshotEntityType);
|
|
615
|
+
/**
|
|
616
|
+
* Map a failed `TransactWriteItems` of an append to its verdict. Resolves to
|
|
617
|
+
* the race a guarded additional put lost (so the transaction is built and
|
|
618
|
+
* written again), and fails with every other verdict.
|
|
619
|
+
*/
|
|
620
|
+
const sendAppendTransaction = (client, tx, streamIdStr, commandId) => {
|
|
621
|
+
const sent = () => ({ _tag: "Transaction", items: tx.items, targets: tx.targets });
|
|
622
|
+
const diagnosed = (diagnosis, original) => diagnosis._tag === "TooManyItems"
|
|
623
|
+
? new AppendTooLarge({
|
|
624
|
+
streamName: config.streamName,
|
|
625
|
+
streamId: streamIdStr,
|
|
626
|
+
count: diagnosis.count,
|
|
627
|
+
limit: diagnosis.limit,
|
|
628
|
+
cause: original,
|
|
629
|
+
})
|
|
630
|
+
: appendLimitError(diagnosis, original, tx);
|
|
631
|
+
return client.transactWriteItems({ TransactItems: tx.items }).pipe(Effect.as(undefined),
|
|
632
|
+
// One operation per item (#133, #144): diagnosed BEFORE the conditional
|
|
633
|
+
// mapping below — DynamoDB can report a repeated item as a conditional
|
|
634
|
+
// failure, which would read as a version conflict or a lost race (and
|
|
635
|
+
// be retried). Terminal.
|
|
636
|
+
diagnoseFailure({ sent, only: ["RepeatedItem"], rewrite: diagnosed }), Effect.catch((error) => {
|
|
637
|
+
if (!isAwsTransactionCancelled(error.cause)) {
|
|
638
|
+
return Effect.fail(error);
|
|
639
|
+
}
|
|
640
|
+
const rawReasons = error.cause.CancellationReasons ?? [];
|
|
641
|
+
const reasons = rawReasons.map((r) => ({
|
|
642
|
+
code: r?.Code,
|
|
643
|
+
message: r?.Message,
|
|
644
|
+
}));
|
|
645
|
+
const failedAt = (index) => index >= 0 && reasons[index]?.code === "ConditionalCheckFailed";
|
|
646
|
+
// Precedence is ordered by how terminal the caller's response should
|
|
647
|
+
// be: a duplicate can never succeed on retry, a version conflict
|
|
648
|
+
// invites a re-read, and only then are the additional items judged.
|
|
649
|
+
if (commandId !== undefined && failedAt(tx.sentinelIndex)) {
|
|
650
|
+
return Effect.fail(new DuplicateCommand({
|
|
651
|
+
streamName: config.streamName,
|
|
652
|
+
streamId: streamIdStr,
|
|
653
|
+
commandId,
|
|
654
|
+
}));
|
|
655
|
+
}
|
|
656
|
+
// The contiguity ConditionCheck and the event puts both mean "the
|
|
657
|
+
// stream is not where you said it was", so they share one verdict.
|
|
658
|
+
for (let i = 0; i < tx.guardCount + tx.eventCount; i++) {
|
|
659
|
+
if (failedAt(i)) {
|
|
660
|
+
return Effect.fail(new VersionConflict({
|
|
661
|
+
streamName: config.streamName,
|
|
662
|
+
streamId: streamIdStr,
|
|
663
|
+
expectedVersion: tx.expectedVersion,
|
|
664
|
+
}));
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
// The additional items, attributed back to the caller OPS that
|
|
668
|
+
// produced them (several items can belong to one op). Only an op's
|
|
669
|
+
// own condition is `AdditionalItemConditionFailed`: a guarded put
|
|
670
|
+
// the caller set no condition on reports what the entity's own put
|
|
671
|
+
// would — a taken unique value, a history conflict — or, having
|
|
672
|
+
// lost a race to a concurrent write, is written again (#133).
|
|
673
|
+
const judged = judgeCancellation(tx.additional, rawReasons, tx.guardCount + tx.eventCount);
|
|
674
|
+
if (judged?._tag === "fail")
|
|
675
|
+
return Effect.fail(judged.error);
|
|
676
|
+
if (judged?._tag === "conditions") {
|
|
677
|
+
return Effect.fail(new AdditionalItemConditionFailed({
|
|
678
|
+
streamName: config.streamName,
|
|
679
|
+
streamId: streamIdStr,
|
|
680
|
+
indices: judged.opIndices,
|
|
681
|
+
reasons,
|
|
682
|
+
}));
|
|
683
|
+
}
|
|
684
|
+
if (judged?._tag === "retry")
|
|
685
|
+
return Effect.succeed(judged.error);
|
|
686
|
+
// No conditional failure we can positionally justify (throttling,
|
|
687
|
+
// TransactionConflict, a reason at the inline snapshot's position, or
|
|
688
|
+
// a truncated/absent reason list) — never guess a VersionConflict.
|
|
689
|
+
return Effect.fail(new TransactionCancelled({
|
|
690
|
+
operation: "TransactWriteItems",
|
|
691
|
+
reasons,
|
|
692
|
+
cause: error.cause,
|
|
693
|
+
}));
|
|
694
|
+
}),
|
|
695
|
+
// DynamoDB's limits are not pre-checked (#144): a rejection the
|
|
696
|
+
// conditional mapping above could not explain is diagnosed against the
|
|
697
|
+
// transaction sent, naming the event, sentinel, snapshot or additional
|
|
698
|
+
// item at fault. Over 100 items is `AppendTooLarge`, with the EXPANDED
|
|
699
|
+
// count.
|
|
700
|
+
diagnoseFailure({
|
|
701
|
+
sent,
|
|
702
|
+
keys: () => mergeKeyAttributes({
|
|
703
|
+
partition: ["pk", ...streamIndexes.map((idx) => idx.pk)],
|
|
704
|
+
sort: ["sk", ...streamIndexes.map((idx) => idx.sk)],
|
|
705
|
+
}, tx.additional.keys()),
|
|
706
|
+
rewrite: diagnosed,
|
|
707
|
+
}));
|
|
708
|
+
};
|
|
709
|
+
/**
|
|
710
|
+
* A diagnosed rejection of an append (#144) other than its item count. An
|
|
711
|
+
* over-long (or empty) stream-index sort key OF AN EVENT — the value the
|
|
712
|
+
* index's `key` function returned — is named by its index and event, as
|
|
713
|
+
* the `indexes` option reports its other key problems. Only the event puts
|
|
714
|
+
* carry stream-index keys: an additional item writing the same physical
|
|
715
|
+
* attribute (single-table designs share `gsi1sk` and the like) is its own
|
|
716
|
+
* entity's key, and is named as any other item.
|
|
717
|
+
*/
|
|
718
|
+
const appendLimitError = (diagnosis, original, tx) => {
|
|
719
|
+
if ((diagnosis._tag === "KeyTooLong" && diagnosis.kind === "sort") ||
|
|
720
|
+
diagnosis._tag === "EmptyKey") {
|
|
721
|
+
const position = tx.targets().indexOf(diagnosis.target);
|
|
722
|
+
const isEvent = position >= tx.guardCount && position < tx.guardCount + tx.eventCount;
|
|
723
|
+
const idx = isEvent
|
|
724
|
+
? streamIndexes.find((candidate) => candidate.sk === diagnosis.attribute)
|
|
725
|
+
: undefined;
|
|
726
|
+
if (idx !== undefined) {
|
|
727
|
+
return new ValidationError({
|
|
728
|
+
entityType,
|
|
729
|
+
operation: "EventStore.append.indexes",
|
|
730
|
+
cause: withCause(`The key of index "${idx.name}" for ${diagnosis.target.source} ` +
|
|
731
|
+
(diagnosis._tag === "KeyTooLong"
|
|
732
|
+
? `is ${diagnosis.bytes} bytes, longer than DynamoDB's 1024-byte limit for a ` +
|
|
733
|
+
"sort key."
|
|
734
|
+
: "is an empty string, which DynamoDB does not accept as a key value; the " +
|
|
735
|
+
"key function must return a non-empty string, or undefined to leave the " +
|
|
736
|
+
"event out of the index.") +
|
|
737
|
+
" DynamoDB rejected the append. Nothing was written.", original),
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return diagnosedValidationError(diagnosis, original, {
|
|
742
|
+
operation: "EventStore.append",
|
|
743
|
+
entityType,
|
|
744
|
+
});
|
|
745
|
+
};
|
|
228
746
|
const append = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
|
|
229
747
|
const additionalOps = options?.additionalItems ?? [];
|
|
230
748
|
const idempotency = options?.idempotency;
|
|
749
|
+
const snapshotState = options?.snapshot;
|
|
750
|
+
const eventMetadata = options?.eventMetadata;
|
|
751
|
+
// Per-event metadata (#144) is checked first — ahead of the no-op fast
|
|
752
|
+
// path below, which would otherwise accept `append(id, [], v,
|
|
753
|
+
// { eventMetadata: [m] })` without looking at it.
|
|
754
|
+
if (eventMetadata !== undefined) {
|
|
755
|
+
if (options?.metadata !== undefined) {
|
|
756
|
+
return yield* new ValidationError({
|
|
757
|
+
entityType,
|
|
758
|
+
operation: "EventStore.append.eventMetadata",
|
|
759
|
+
cause: "metadata and eventMetadata are mutually exclusive: pass one value shared by " +
|
|
760
|
+
"every event, or one entry per event. Nothing was written.",
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
if (!Array.isArray(eventMetadata) || eventMetadata.length !== events.length) {
|
|
764
|
+
return yield* new ValidationError({
|
|
765
|
+
entityType,
|
|
766
|
+
operation: "EventStore.append.eventMetadata",
|
|
767
|
+
cause: `eventMetadata has ${Array.isArray(eventMetadata) ? eventMetadata.length : "no"} ` +
|
|
768
|
+
`entries for ${events.length} events: it needs exactly one entry (or undefined) ` +
|
|
769
|
+
"per event, in order. Nothing was written.",
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
// Per-event timestamps (#144), checked alongside the metadata and for
|
|
774
|
+
// the same reason: ahead of the no-op fast path.
|
|
775
|
+
const eventTimestamps = options?.eventTimestamps;
|
|
776
|
+
if (eventTimestamps !== undefined) {
|
|
777
|
+
const problem = eventTimestampsProblem(eventTimestamps, events.length, "eventTimestamps has", `${events.length} events`);
|
|
778
|
+
if (problem !== undefined) {
|
|
779
|
+
return yield* new ValidationError({
|
|
780
|
+
entityType,
|
|
781
|
+
operation: "EventStore.append.eventTimestamps",
|
|
782
|
+
cause: `${problem} Nothing was written.`,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
if (snapshotState !== undefined && snapshot === undefined) {
|
|
787
|
+
return yield* snapshotUnavailable("append({ snapshot })");
|
|
788
|
+
}
|
|
231
789
|
// Nothing at all to write — preserve the historical no-op fast path.
|
|
232
790
|
// With additional items or a dedup sentinel the transaction still runs:
|
|
233
791
|
// a caller who asked for a side write means it, and silently dropping it
|
|
234
792
|
// would lose data.
|
|
235
|
-
if (events.length === 0 &&
|
|
793
|
+
if (events.length === 0 &&
|
|
794
|
+
additionalOps.length === 0 &&
|
|
795
|
+
idempotency === undefined &&
|
|
796
|
+
snapshotState === undefined) {
|
|
236
797
|
return { version: expectedVersion, events: [] };
|
|
237
798
|
}
|
|
238
799
|
// Resolve stream ID string for storage (join composites)
|
|
239
800
|
const streamIdStr = composeStreamIdString(streamId);
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
// failing now keeps an oversized append free. The authoritative check runs
|
|
250
|
-
// once the ops are compiled, below.
|
|
251
|
-
const needsContiguityCheck = expectedVersion > 0 && events.length > 0;
|
|
252
|
-
const fixedItems = events.length + (idempotency !== undefined ? 1 : 0) + (needsContiguityCheck ? 1 : 0);
|
|
253
|
-
if (fixedItems + additionalOps.length > TRANSACT_WRITE_ITEMS_LIMIT) {
|
|
254
|
-
return yield* new AppendTooLarge({
|
|
255
|
-
streamName: config.streamName,
|
|
256
|
-
streamId: streamIdStr,
|
|
257
|
-
count: fixedItems + additionalOps.length,
|
|
258
|
-
limit: TRANSACT_WRITE_ITEMS_LIMIT,
|
|
801
|
+
// An inline snapshot is unconditional because the event puts prove this
|
|
802
|
+
// writer owns its version. Without events nothing proves it, and an
|
|
803
|
+
// unconditional put could regress the snapshot.
|
|
804
|
+
if (snapshotState !== undefined && events.length === 0) {
|
|
805
|
+
return yield* new ValidationError({
|
|
806
|
+
entityType: snapshotEntityType,
|
|
807
|
+
operation: "EventStore.append.snapshot",
|
|
808
|
+
cause: "An inline snapshot requires at least one event: nothing else proves the writer " +
|
|
809
|
+
`owns version ${expectedVersion}. Nothing was written.`,
|
|
259
810
|
});
|
|
260
811
|
}
|
|
812
|
+
// DynamoDB's 100-item limit on one transaction is not pre-checked
|
|
813
|
+
// (#144): an append is never split — splitting would break its
|
|
814
|
+
// atomicity — so one over the limit is rejected whole by DynamoDB and
|
|
815
|
+
// reported as `AppendTooLarge`, with the expanded item count.
|
|
816
|
+
const needsContiguityCheck = expectedVersion > 0 && events.length > 0;
|
|
261
817
|
const client = yield* DynamoClient;
|
|
262
818
|
const tableConfig = yield* config.table.Tag;
|
|
263
819
|
const tableName = tableConfig.name;
|
|
820
|
+
// A custom TTL attribute is only known at runtime. An index key attribute
|
|
821
|
+
// sharing its name would receive the sentinel's numeric TTL, which a
|
|
822
|
+
// string-typed index key rejects — refuse it before anything is written.
|
|
823
|
+
const ttlAttribute = resolveTtlAttributeName(tableConfig);
|
|
824
|
+
if (indexAttributes.includes(ttlAttribute)) {
|
|
825
|
+
return yield* new ValidationError({
|
|
826
|
+
entityType,
|
|
827
|
+
operation: "EventStore.append.indexes",
|
|
828
|
+
cause: `An index of stream "${config.streamName}" uses the attribute "${ttlAttribute}", ` +
|
|
829
|
+
"which is the table's TTL attribute. Choose another index attribute name. " +
|
|
830
|
+
"Nothing was written.",
|
|
831
|
+
});
|
|
832
|
+
}
|
|
264
833
|
const pk = composeStreamPk(streamId);
|
|
265
834
|
// Clock-backed timestamp (deterministic under TestClock; wall-clock in prod).
|
|
266
835
|
const nowDateTime = yield* DateTime.now;
|
|
267
836
|
const now = DateTime.formatIso(nowDateTime);
|
|
268
|
-
// Validate and encode metadata to wire form if schema provided
|
|
837
|
+
// Validate and encode metadata to wire form if schema provided. Shared
|
|
838
|
+
// by every event; per-event `eventMetadata` is encoded in the loop below.
|
|
269
839
|
let encodedMetadata;
|
|
270
840
|
if (options?.metadata !== undefined && metadataSchema) {
|
|
271
841
|
const encoded = yield* encodeToWire(metadataSchema, options.metadata, "EventStore.append.metadata");
|
|
@@ -289,7 +859,25 @@ export const makeStream = (config) => {
|
|
|
289
859
|
// Inject _tag for plain Schema.Class events; Schema.TaggedClass
|
|
290
860
|
// events already carry _tag in their encoded form, which wins.
|
|
291
861
|
const eventData = { _tag: evtType, ...wire };
|
|
292
|
-
|
|
862
|
+
// This event's own metadata entry (#144), encoded as `metadata`
|
|
863
|
+
// is; an `undefined` entry writes no metadata attribute.
|
|
864
|
+
let itemMetadata = encodedMetadata;
|
|
865
|
+
if (eventMetadata !== undefined) {
|
|
866
|
+
const entry = eventMetadata[i];
|
|
867
|
+
itemMetadata =
|
|
868
|
+
entry === undefined
|
|
869
|
+
? undefined
|
|
870
|
+
: metadataSchema
|
|
871
|
+
? (yield* encodeToWire(metadataSchema, entry, `EventStore.append.eventMetadata[${i}]`))
|
|
872
|
+
: entry;
|
|
873
|
+
}
|
|
874
|
+
// This event's own timestamp (#144), or the append's `now`.
|
|
875
|
+
// `formatIso` renders the UTC instant, so a zoned DateTime is
|
|
876
|
+
// stored in the same format as `now`.
|
|
877
|
+
const eventTimestamp = eventTimestamps?.[i];
|
|
878
|
+
// Exactly the table key and EVENT_ITEM_ATTRIBUTES — what a
|
|
879
|
+
// "withoutMetadata" stream index projects — checked by the type.
|
|
880
|
+
const envelope = {
|
|
293
881
|
pk,
|
|
294
882
|
sk: composeEventSk(version),
|
|
295
883
|
__edd_e__: entityType,
|
|
@@ -297,10 +885,22 @@ export const makeStream = (config) => {
|
|
|
297
885
|
version,
|
|
298
886
|
eventType: evtType,
|
|
299
887
|
data: eventData,
|
|
300
|
-
timestamp: now,
|
|
888
|
+
timestamp: eventTimestamp === undefined ? now : DateTime.formatIso(eventTimestamp),
|
|
301
889
|
};
|
|
302
|
-
|
|
303
|
-
|
|
890
|
+
const item = { ...envelope };
|
|
891
|
+
if (itemMetadata !== undefined) {
|
|
892
|
+
item.metadata = itemMetadata;
|
|
893
|
+
}
|
|
894
|
+
// Stream indexes (#140): the derived key, stored raw, and for a
|
|
895
|
+
// GSI the stream's partition key. The attributes are part of the
|
|
896
|
+
// event item itself, so they count towards the size checks.
|
|
897
|
+
for (const idx of streamIndexes) {
|
|
898
|
+
const derived = yield* deriveIndexKey(idx, event, version);
|
|
899
|
+
if (derived === undefined)
|
|
900
|
+
continue;
|
|
901
|
+
item[idx.sk] = derived;
|
|
902
|
+
if (idx.type === "gsi")
|
|
903
|
+
item[idx.pk] = pk;
|
|
304
904
|
}
|
|
305
905
|
return {
|
|
306
906
|
Put: {
|
|
@@ -310,6 +910,129 @@ export const makeStream = (config) => {
|
|
|
310
910
|
},
|
|
311
911
|
};
|
|
312
912
|
}));
|
|
913
|
+
// The idempotency sentinel: a Put guarded by `attribute_not_exists(pk)`.
|
|
914
|
+
let sentinelPut;
|
|
915
|
+
if (idempotency !== undefined) {
|
|
916
|
+
const sentinel = {
|
|
917
|
+
pk,
|
|
918
|
+
sk: composeCommandSk(idempotency.commandId),
|
|
919
|
+
__edd_e__: commandEntityType,
|
|
920
|
+
streamId: streamIdStr,
|
|
921
|
+
commandId: idempotency.commandId,
|
|
922
|
+
version: expectedVersion + events.length,
|
|
923
|
+
timestamp: now,
|
|
924
|
+
};
|
|
925
|
+
if (idempotency.ttl !== undefined) {
|
|
926
|
+
const ttlSeconds = yield* Effect.try({
|
|
927
|
+
try: () => normalizeTtlSeconds(idempotency.ttl),
|
|
928
|
+
catch: (cause) => new ValidationError({
|
|
929
|
+
entityType: commandEntityType,
|
|
930
|
+
operation: "EventStore.append.idempotency.ttl",
|
|
931
|
+
cause,
|
|
932
|
+
}),
|
|
933
|
+
});
|
|
934
|
+
sentinel[resolveTtlAttributeName(tableConfig)] =
|
|
935
|
+
DateTime.toEpochSeconds(nowDateTime) + ttlSeconds;
|
|
936
|
+
}
|
|
937
|
+
sentinelPut = {
|
|
938
|
+
Put: {
|
|
939
|
+
TableName: tableName,
|
|
940
|
+
Item: toAttributeMap(sentinel),
|
|
941
|
+
ConditionExpression: "attribute_not_exists(pk)",
|
|
942
|
+
},
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
// The inline snapshot (#138): unconditional — see AppendOptions.snapshot.
|
|
946
|
+
let snapshotPut;
|
|
947
|
+
if (snapshotState !== undefined) {
|
|
948
|
+
const encoded = yield* encodeSnapshotState(snapshotState, "EventStore.append.snapshot");
|
|
949
|
+
snapshotPut = {
|
|
950
|
+
Put: {
|
|
951
|
+
TableName: tableName,
|
|
952
|
+
Item: toAttributeMap({
|
|
953
|
+
pk,
|
|
954
|
+
sk: snapshotSk,
|
|
955
|
+
__edd_e__: snapshotEntityType,
|
|
956
|
+
streamId: streamIdStr,
|
|
957
|
+
asOfVersion: expectedVersion + events.length,
|
|
958
|
+
state: encoded,
|
|
959
|
+
timestamp: now,
|
|
960
|
+
}),
|
|
961
|
+
},
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Version-contiguity guard: `attribute_not_exists(pk)` on the event puts
|
|
966
|
+
* only rejects STALE expected versions (the target slot already exists).
|
|
967
|
+
* An AHEAD expectedVersion (e.g. 10 when the stream is at 3) would
|
|
968
|
+
* silently write from version 11, leaving a permanent gap. When events
|
|
969
|
+
* are written after version > 0, require the event at exactly
|
|
970
|
+
* `expectedVersion` to exist so the appended range is contiguous with the
|
|
971
|
+
* stream head. Its failure surfaces as a ConditionalCheckFailed
|
|
972
|
+
* cancellation reason, mapping to VersionConflict just like a
|
|
973
|
+
* stale-version Put failure. A zero-event append (a pure side write)
|
|
974
|
+
* writes no version and so can open no gap.
|
|
975
|
+
*/
|
|
976
|
+
const contiguityCheck = needsContiguityCheck
|
|
977
|
+
? {
|
|
978
|
+
ConditionCheck: {
|
|
979
|
+
TableName: tableName,
|
|
980
|
+
Key: toAttributeMap({ pk, sk: composeEventSk(expectedVersion) }),
|
|
981
|
+
ConditionExpression: "attribute_exists(pk)",
|
|
982
|
+
},
|
|
983
|
+
}
|
|
984
|
+
: undefined;
|
|
985
|
+
/**
|
|
986
|
+
* Lay out the append's one transaction — see {@link AppendTransaction}:
|
|
987
|
+
* [0, C) version-contiguity ConditionCheck (C is 0 or 1)
|
|
988
|
+
* [C, C + E) event puts
|
|
989
|
+
* [C + E, C + E + A) additional ITEMS (caller op order preserved)
|
|
990
|
+
* then idempotency sentinel, then inline snapshot
|
|
991
|
+
* (last, so adding them never shifts the
|
|
992
|
+
* additional-item indices the caller sees)
|
|
993
|
+
*
|
|
994
|
+
* `A` is the count of EMITTED items, which is >= the number of caller
|
|
995
|
+
* ops: a guarded put (#133) expands into its item plus its sentinel
|
|
996
|
+
* reservations and releases plus a snapshot. The caller-facing `indices`
|
|
997
|
+
* on `AdditionalItemConditionFailed` are still indices into the caller's
|
|
998
|
+
* `additionalItems` array — `judgeCancellation` maps them.
|
|
999
|
+
*/
|
|
1000
|
+
const layout = (additional) => {
|
|
1001
|
+
const items = [];
|
|
1002
|
+
// What names each item, so the targets are built only on failure.
|
|
1003
|
+
const naming = [];
|
|
1004
|
+
const pushStreamItem = (item, type, source) => {
|
|
1005
|
+
items.push(item);
|
|
1006
|
+
naming.push(() => [transactItemTarget(item, tableName, ["pk", "sk"], type, source)]);
|
|
1007
|
+
};
|
|
1008
|
+
if (contiguityCheck !== undefined) {
|
|
1009
|
+
pushStreamItem(contiguityCheck, entityType, "the version-contiguity check");
|
|
1010
|
+
}
|
|
1011
|
+
const guardCount = items.length;
|
|
1012
|
+
for (const [i, item] of eventItems.entries()) {
|
|
1013
|
+
pushStreamItem(item, entityType, `the event at version ${expectedVersion + i + 1}`);
|
|
1014
|
+
}
|
|
1015
|
+
items.push(...additional.items);
|
|
1016
|
+
naming.push(additional.targets);
|
|
1017
|
+
const sentinelIndex = sentinelPut === undefined ? -1 : items.length;
|
|
1018
|
+
if (sentinelPut !== undefined) {
|
|
1019
|
+
pushStreamItem(sentinelPut, entityType, "the idempotency sentinel");
|
|
1020
|
+
}
|
|
1021
|
+
if (snapshotPut !== undefined) {
|
|
1022
|
+
pushStreamItem(snapshotPut, snapshotEntityType, "the inline snapshot");
|
|
1023
|
+
}
|
|
1024
|
+
let targets;
|
|
1025
|
+
return {
|
|
1026
|
+
items,
|
|
1027
|
+
// Built once, on failure: a diagnosis's target is found by identity.
|
|
1028
|
+
targets: () => (targets ??= naming.flatMap((name) => name())),
|
|
1029
|
+
guardCount,
|
|
1030
|
+
eventCount: eventItems.length,
|
|
1031
|
+
additional,
|
|
1032
|
+
sentinelIndex,
|
|
1033
|
+
expectedVersion,
|
|
1034
|
+
};
|
|
1035
|
+
};
|
|
313
1036
|
// A guarded put among the additional items reads its item; a race with
|
|
314
1037
|
// that read cancels the append, which is then built and written again.
|
|
315
1038
|
let lost;
|
|
@@ -317,174 +1040,15 @@ export const makeStream = (config) => {
|
|
|
317
1040
|
// Caller-owned items, compiled through the same builder
|
|
318
1041
|
// `Transaction.transactWrite` uses, so the two APIs cannot drift.
|
|
319
1042
|
const additional = yield* buildTransactWriteItems(additionalOps, "EventStore.append.additionalItems");
|
|
320
|
-
const
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
// the
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
streamName: config.streamName,
|
|
328
|
-
streamId: streamIdStr,
|
|
329
|
-
count: fixedItems + additionalItems.length,
|
|
330
|
-
limit: TRANSACT_WRITE_ITEMS_LIMIT,
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
// Version-contiguity guard: `attribute_not_exists(pk)` on the event puts
|
|
334
|
-
// only rejects STALE expected versions (the target slot already exists).
|
|
335
|
-
// An AHEAD expectedVersion (e.g. 10 when the stream is at 3) would
|
|
336
|
-
// silently write from version 11, leaving a permanent gap. When
|
|
337
|
-
// expectedVersion > 0, require the event at exactly `expectedVersion` to
|
|
338
|
-
// exist so the appended range is contiguous with the stream head. Its
|
|
339
|
-
// failure surfaces as a ConditionalCheckFailed cancellation reason,
|
|
340
|
-
// mapping to VersionConflict below just like a stale-version Put failure.
|
|
341
|
-
//
|
|
342
|
-
// Only when events are actually being written: the guard exists to stop an
|
|
343
|
-
// AHEAD expectedVersion opening a permanent gap, and a zero-event append
|
|
344
|
-
// (pure `additionalItems` / sentinel side-write) writes no version and so
|
|
345
|
-
// can open no gap.
|
|
346
|
-
const contiguityCheck = needsContiguityCheck
|
|
347
|
-
? [
|
|
348
|
-
{
|
|
349
|
-
ConditionCheck: {
|
|
350
|
-
TableName: tableName,
|
|
351
|
-
Key: toAttributeMap({ pk, sk: composeEventSk(expectedVersion) }),
|
|
352
|
-
ConditionExpression: "attribute_exists(pk)",
|
|
353
|
-
},
|
|
354
|
-
},
|
|
355
|
-
]
|
|
356
|
-
: [];
|
|
357
|
-
// Item layout is load-bearing — cancellation reasons are positional:
|
|
358
|
-
// [0, C) version-contiguity ConditionCheck (C is 0 or 1)
|
|
359
|
-
// [C, C + E) event puts
|
|
360
|
-
// [C + E, C + E + A) additional ITEMS (caller op order preserved)
|
|
361
|
-
// C + E + A idempotency sentinel (last, so adding it never
|
|
362
|
-
// shifts the additional-item indices the caller sees)
|
|
363
|
-
//
|
|
364
|
-
// `A` is the count of EMITTED items, which is >= the number of caller ops:
|
|
365
|
-
// a guarded put (#133) expands into its item plus its sentinel
|
|
366
|
-
// reservations and releases plus a snapshot. The 1:1 "item index == caller
|
|
367
|
-
// index" assumption is gone, so the reason mapping below goes through
|
|
368
|
-
// `judgeCancellation` — the caller-facing `indices` on
|
|
369
|
-
// `AdditionalItemConditionFailed` are still indices into the caller's
|
|
370
|
-
// `additionalItems` array, unchanged.
|
|
371
|
-
const transactItems = [
|
|
372
|
-
...contiguityCheck,
|
|
373
|
-
...eventItems,
|
|
374
|
-
...additionalItems,
|
|
375
|
-
];
|
|
376
|
-
const checkCount = contiguityCheck.length;
|
|
377
|
-
const eventCount = eventItems.length;
|
|
378
|
-
const sentinelIndex = idempotency !== undefined ? transactItems.length : -1;
|
|
379
|
-
if (idempotency !== undefined) {
|
|
380
|
-
const sentinel = {
|
|
381
|
-
pk,
|
|
382
|
-
sk: DynamoSchema.composeKey(schema, commandKeyLabel, [idempotency.commandId], keyOptions),
|
|
383
|
-
__edd_e__: commandEntityType,
|
|
384
|
-
streamId: streamIdStr,
|
|
385
|
-
commandId: idempotency.commandId,
|
|
386
|
-
version: expectedVersion + events.length,
|
|
387
|
-
timestamp: now,
|
|
388
|
-
};
|
|
389
|
-
if (idempotency.ttl !== undefined) {
|
|
390
|
-
const ttlSeconds = yield* Effect.try({
|
|
391
|
-
try: () => normalizeTtlSeconds(idempotency.ttl),
|
|
392
|
-
catch: (cause) => new ValidationError({
|
|
393
|
-
entityType: commandEntityType,
|
|
394
|
-
operation: "EventStore.append.idempotency.ttl",
|
|
395
|
-
cause,
|
|
396
|
-
}),
|
|
397
|
-
});
|
|
398
|
-
sentinel[resolveTtlAttributeName(tableConfig)] =
|
|
399
|
-
DateTime.toEpochSeconds(nowDateTime) + ttlSeconds;
|
|
400
|
-
}
|
|
401
|
-
transactItems.push({
|
|
402
|
-
Put: {
|
|
403
|
-
TableName: tableName,
|
|
404
|
-
Item: toAttributeMap(sentinel),
|
|
405
|
-
ConditionExpression: "attribute_not_exists(pk)",
|
|
406
|
-
},
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
// Checked before anything is sent: one op per item — an additional item
|
|
410
|
-
// repeating an event, the contiguity check or the idempotency sentinel
|
|
411
|
-
// is refused, as additional items repeating each other are — and the
|
|
412
|
-
// whole transaction within DynamoDB's 4 MB (#133).
|
|
413
|
-
const additionalStart = checkCount + eventCount;
|
|
414
|
-
const streamItemSource = (i) => i < checkCount
|
|
415
|
-
? "the version-contiguity check"
|
|
416
|
-
: i < additionalStart
|
|
417
|
-
? `the event at version ${expectedVersion + i - checkCount + 1}`
|
|
418
|
-
: "the idempotency sentinel";
|
|
419
|
-
const targets = transactItems.map((item, i) => i >= additionalStart && i < additionalStart + additionalItems.length
|
|
420
|
-
? additional.targets[i - additionalStart]
|
|
421
|
-
: transactItemTarget(item, tableName, ["pk", "sk"], entityType, streamItemSource(i)));
|
|
422
|
-
yield* refuseRepeatedItems(targets, "EventStore.append");
|
|
423
|
-
yield* refuseOversizedTransaction(transactItems, targets, "EventStore.append");
|
|
424
|
-
const outcome = yield* client.transactWriteItems({ TransactItems: transactItems }).pipe(Effect.as(undefined), Effect.catch((error) => {
|
|
425
|
-
if (!isAwsTransactionCancelled(error.cause)) {
|
|
426
|
-
return Effect.fail(error);
|
|
427
|
-
}
|
|
428
|
-
const rawReasons = error.cause.CancellationReasons ?? [];
|
|
429
|
-
const reasons = rawReasons.map((r) => ({
|
|
430
|
-
code: r?.Code,
|
|
431
|
-
message: r?.Message,
|
|
432
|
-
}));
|
|
433
|
-
const failedAt = (index) => index >= 0 && reasons[index]?.code === "ConditionalCheckFailed";
|
|
434
|
-
// Precedence is ordered by how terminal the caller's response should
|
|
435
|
-
// be: a duplicate can never succeed on retry, a version conflict
|
|
436
|
-
// invites a re-read, and only then are the additional items judged.
|
|
437
|
-
if (idempotency !== undefined && failedAt(sentinelIndex)) {
|
|
438
|
-
return Effect.fail(new DuplicateCommand({
|
|
439
|
-
streamName: config.streamName,
|
|
440
|
-
streamId: streamIdStr,
|
|
441
|
-
commandId: idempotency.commandId,
|
|
442
|
-
}));
|
|
443
|
-
}
|
|
444
|
-
// The contiguity ConditionCheck and the event puts both mean "the
|
|
445
|
-
// stream is not where you said it was", so they share one verdict.
|
|
446
|
-
for (let i = 0; i < checkCount + eventCount; i++) {
|
|
447
|
-
if (failedAt(i)) {
|
|
448
|
-
return Effect.fail(new VersionConflict({
|
|
449
|
-
streamName: config.streamName,
|
|
450
|
-
streamId: streamIdStr,
|
|
451
|
-
expectedVersion,
|
|
452
|
-
}));
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
// The additional items, attributed back to the caller OPS that
|
|
456
|
-
// produced them (several items can belong to one op). Only an op's
|
|
457
|
-
// own condition is `AdditionalItemConditionFailed`: a guarded put
|
|
458
|
-
// the caller set no condition on reports what the entity's own put
|
|
459
|
-
// would — a taken unique value, a history conflict — or, having
|
|
460
|
-
// lost a race to a concurrent write, is written again (#133).
|
|
461
|
-
const judged = judgeCancellation(additional, rawReasons, checkCount + eventCount);
|
|
462
|
-
if (judged?._tag === "fail")
|
|
463
|
-
return Effect.fail(judged.error);
|
|
464
|
-
if (judged?._tag === "conditions") {
|
|
465
|
-
return Effect.fail(new AdditionalItemConditionFailed({
|
|
466
|
-
streamName: config.streamName,
|
|
467
|
-
streamId: streamIdStr,
|
|
468
|
-
indices: judged.opIndices,
|
|
469
|
-
reasons,
|
|
470
|
-
}));
|
|
471
|
-
}
|
|
472
|
-
if (judged?._tag === "retry")
|
|
473
|
-
return Effect.succeed(judged.error);
|
|
474
|
-
// No conditional failure we can positionally justify (throttling,
|
|
475
|
-
// TransactionConflict, or a truncated/absent reason list) — never
|
|
476
|
-
// guess a VersionConflict.
|
|
477
|
-
return Effect.fail(new TransactionCancelled({
|
|
478
|
-
operation: "TransactWriteItems",
|
|
479
|
-
reasons,
|
|
480
|
-
cause: error.cause,
|
|
481
|
-
}));
|
|
482
|
-
}));
|
|
1043
|
+
const tx = layout(additional);
|
|
1044
|
+
// DynamoDB's rules — one operation per item (an additional item
|
|
1045
|
+
// repeating an event, the contiguity check, the idempotency sentinel
|
|
1046
|
+
// or the inline snapshot, or another additional item), 100 items,
|
|
1047
|
+
// 4 MB, 400 KB per item, 1024-byte sort keys — are not pre-checked:
|
|
1048
|
+
// `sendAppendTransaction` diagnoses DynamoDB's rejection (#144).
|
|
1049
|
+
const outcome = yield* sendAppendTransaction(client, tx, streamIdStr, idempotency?.commandId);
|
|
483
1050
|
if (outcome === undefined) {
|
|
484
|
-
return {
|
|
485
|
-
version: expectedVersion + events.length,
|
|
486
|
-
events,
|
|
487
|
-
};
|
|
1051
|
+
return { version: expectedVersion + events.length, events };
|
|
488
1052
|
}
|
|
489
1053
|
lost = outcome;
|
|
490
1054
|
}
|
|
@@ -493,37 +1057,49 @@ export const makeStream = (config) => {
|
|
|
493
1057
|
// ---------------------------------------------------------------------------
|
|
494
1058
|
// read
|
|
495
1059
|
// ---------------------------------------------------------------------------
|
|
496
|
-
|
|
497
|
-
|
|
1060
|
+
/** Apply {@link ReadOptions} to an event query. */
|
|
1061
|
+
const withReadOptions = (query, options) => options?.consistentRead === true ? Query.consistentRead(query) : query;
|
|
1062
|
+
const read = (streamId, options) => Effect.gen(function* () {
|
|
1063
|
+
const query = withReadOptions(buildEventsQuery(streamId), options);
|
|
498
1064
|
return yield* Query.collect(query);
|
|
499
1065
|
});
|
|
500
1066
|
// ---------------------------------------------------------------------------
|
|
501
1067
|
// readFrom
|
|
502
1068
|
// ---------------------------------------------------------------------------
|
|
503
|
-
const readFrom = (streamId, afterVersion) => Effect.gen(function* () {
|
|
1069
|
+
const readFrom = (streamId, afterVersion, options) => Effect.gen(function* () {
|
|
504
1070
|
// Versions are integers, so the inclusive lower bound `afterVersion + 1`
|
|
505
1071
|
// is exactly the old exclusive `#sk > eventSk(afterVersion)`. The upper
|
|
506
1072
|
// bound keeps the snapshot item (which sorts after every event) out of the
|
|
507
1073
|
// scanned range.
|
|
508
|
-
const query = buildEventsQuery(streamId).pipe(Query.where({ between: [composeEventSk(afterVersion + 1), maxEventSk] }));
|
|
1074
|
+
const query = withReadOptions(buildEventsQuery(streamId).pipe(Query.where({ between: [composeEventSk(afterVersion + 1), maxEventSk] })), options);
|
|
509
1075
|
return yield* Query.collect(query);
|
|
510
1076
|
});
|
|
511
1077
|
// ---------------------------------------------------------------------------
|
|
512
1078
|
// currentVersion
|
|
513
1079
|
// ---------------------------------------------------------------------------
|
|
514
|
-
const currentVersion = (streamId) => Effect.gen(function* () {
|
|
1080
|
+
const currentVersion = (streamId, options) => Effect.gen(function* () {
|
|
515
1081
|
// Single page, not `collect`: with `Limit: 1` DynamoDB returns a
|
|
516
1082
|
// `LastEvaluatedKey` on every truncated page, so `collect` would walk the
|
|
517
1083
|
// whole partition one request per item. The `begins_with` bound on
|
|
518
1084
|
// `buildEventsQuery` guarantees the single evaluated item is the newest
|
|
519
1085
|
// *event* (never the snapshot, which sorts last).
|
|
520
|
-
const query = buildEventsQuery(streamId).pipe(Query.reverse, Query.limit(1));
|
|
1086
|
+
const query = withReadOptions(buildEventsQuery(streamId).pipe(Query.reverse, Query.limit(1)), options);
|
|
521
1087
|
const page = yield* Query.execute(query);
|
|
522
1088
|
const newest = page.items[0];
|
|
523
1089
|
if (newest === undefined)
|
|
524
1090
|
return 0;
|
|
525
1091
|
return newest.version;
|
|
526
1092
|
});
|
|
1093
|
+
// headAbove (internal — see HeadAboveProbe)
|
|
1094
|
+
const headAbove = (streamId, floorVersion, options) => Effect.gen(function* () {
|
|
1095
|
+
// Reverse, `Limit: 1`, bounded below by `floorVersion + 1`: an empty range
|
|
1096
|
+
// (the common case — nothing appended past the snapshot) reads no items;
|
|
1097
|
+
// otherwise the single evaluated item is the head.
|
|
1098
|
+
const query = withReadOptions(buildEventsQuery(streamId).pipe(Query.where({ between: [composeEventSk(floorVersion + 1), maxEventSk] }), Query.reverse, Query.limit(1)), options);
|
|
1099
|
+
const page = yield* Query.execute(query);
|
|
1100
|
+
const newest = page.items[0];
|
|
1101
|
+
return newest === undefined ? floorVersion : newest.version;
|
|
1102
|
+
});
|
|
527
1103
|
// ---------------------------------------------------------------------------
|
|
528
1104
|
// Snapshot primitives
|
|
529
1105
|
// ---------------------------------------------------------------------------
|
|
@@ -535,11 +1111,7 @@ export const makeStream = (config) => {
|
|
|
535
1111
|
const client = yield* DynamoClient;
|
|
536
1112
|
const { name: tableName } = yield* config.table.Tag;
|
|
537
1113
|
const now = DateTime.formatIso(yield* DateTime.now);
|
|
538
|
-
const encoded = yield*
|
|
539
|
-
entityType: snapshotEntityType,
|
|
540
|
-
operation: "EventStore.writeSnapshot",
|
|
541
|
-
cause,
|
|
542
|
-
})));
|
|
1114
|
+
const encoded = yield* encodeSnapshotState(state, "EventStore.writeSnapshot");
|
|
543
1115
|
const item = {
|
|
544
1116
|
pk: composeStreamPk(streamId),
|
|
545
1117
|
sk: snapshotSk,
|
|
@@ -549,8 +1121,7 @@ export const makeStream = (config) => {
|
|
|
549
1121
|
state: encoded,
|
|
550
1122
|
timestamp: now,
|
|
551
1123
|
};
|
|
552
|
-
|
|
553
|
-
.putItem({
|
|
1124
|
+
const put = {
|
|
554
1125
|
TableName: tableName,
|
|
555
1126
|
Item: toAttributeMap(item),
|
|
556
1127
|
// Monotonic: never regress the cache. Losing this race is a no-op,
|
|
@@ -558,12 +1129,33 @@ export const makeStream = (config) => {
|
|
|
558
1129
|
ConditionExpression: "attribute_not_exists(#pk) OR #asOfVersion < :asOfVersion",
|
|
559
1130
|
ExpressionAttributeNames: { "#pk": "pk", "#asOfVersion": "asOfVersion" },
|
|
560
1131
|
ExpressionAttributeValues: toAttributeMap({ ":asOfVersion": asOfVersion }),
|
|
561
|
-
}
|
|
562
|
-
|
|
1132
|
+
};
|
|
1133
|
+
yield* client.putItem(put).pipe(Effect.catchIf((error) => isAwsConditionalCheckFailed(error.cause), () => Effect.void),
|
|
1134
|
+
// An oversized snapshot is DynamoDB's to reject (#144); named here.
|
|
1135
|
+
diagnoseFailure({
|
|
1136
|
+
sent: () => ({
|
|
1137
|
+
_tag: "Item",
|
|
1138
|
+
item: { Put: put },
|
|
1139
|
+
target: () => transactItemTarget({ Put: put }, tableName, ["pk", "sk"], snapshotEntityType, "the snapshot"),
|
|
1140
|
+
}),
|
|
1141
|
+
keys: () => ({ partition: ["pk"], sort: ["sk"] }),
|
|
1142
|
+
rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, {
|
|
1143
|
+
operation: "EventStore.writeSnapshot",
|
|
1144
|
+
entityType: snapshotEntityType,
|
|
1145
|
+
}),
|
|
1146
|
+
}));
|
|
563
1147
|
});
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
1148
|
+
/**
|
|
1149
|
+
* Decode a stored snapshot item. A snapshot that fails to decode through the
|
|
1150
|
+
* state schema is a `ValidationError` — never silently discarded.
|
|
1151
|
+
*/
|
|
1152
|
+
const decodeSnapshotItem = (raw, operation) => Schema.decodeUnknownEffect(snapshot.schema)(raw.state).pipe(Effect.mapError((cause) => new ValidationError({ entityType: snapshotEntityType, operation, cause })), Effect.map((state) => ({
|
|
1153
|
+
state,
|
|
1154
|
+
asOfVersion: raw.asOfVersion,
|
|
1155
|
+
timestamp: raw.timestamp,
|
|
1156
|
+
})));
|
|
1157
|
+
/** The raw snapshot item, by its exact key, in one `GetItem`. */
|
|
1158
|
+
const getSnapshotItem = (streamId, consistentRead) => Effect.gen(function* () {
|
|
567
1159
|
const client = yield* DynamoClient;
|
|
568
1160
|
const { name: tableName } = yield* config.table.Tag;
|
|
569
1161
|
const result = yield* client.getItem({
|
|
@@ -572,21 +1164,140 @@ export const makeStream = (config) => {
|
|
|
572
1164
|
pk: composeStreamPk(streamId),
|
|
573
1165
|
sk: snapshotSk,
|
|
574
1166
|
}),
|
|
575
|
-
ConsistentRead: true,
|
|
1167
|
+
...(consistentRead ? { ConsistentRead: true } : {}),
|
|
576
1168
|
});
|
|
577
|
-
|
|
1169
|
+
return result.Item === undefined ? undefined : fromAttributeMap(result.Item);
|
|
1170
|
+
});
|
|
1171
|
+
const readSnapshot = (streamId) => Effect.gen(function* () {
|
|
1172
|
+
if (snapshot === undefined)
|
|
1173
|
+
return yield* snapshotUnavailable("readSnapshot");
|
|
1174
|
+
const raw = yield* getSnapshotItem(streamId, true);
|
|
1175
|
+
if (raw === undefined)
|
|
578
1176
|
return Option.none();
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
1177
|
+
return Option.some(yield* decodeSnapshotItem(raw, "EventStore.readSnapshot"));
|
|
1178
|
+
});
|
|
1179
|
+
// ---------------------------------------------------------------------------
|
|
1180
|
+
// readLatest — snapshot + delta + head in one request (#138)
|
|
1181
|
+
// ---------------------------------------------------------------------------
|
|
1182
|
+
const readLatest = (streamId, options) => Effect.gen(function* () {
|
|
1183
|
+
// Without a snapshot config there is nothing to read but the events
|
|
1184
|
+
// (`verifySnapshot` is ignored).
|
|
1185
|
+
if (snapshot === undefined) {
|
|
1186
|
+
const events = yield* read(streamId, options);
|
|
1187
|
+
return {
|
|
1188
|
+
snapshot: Option.none(),
|
|
1189
|
+
events,
|
|
1190
|
+
version: events[events.length - 1]?.version ?? 0,
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
// `verifySnapshot: false` — the snapshot item alone, by its exact key:
|
|
1194
|
+
// a `GetItem` reads one item where the verified `Query` reads the
|
|
1195
|
+
// snapshot and at least the newest event. Its version is unverified
|
|
1196
|
+
// (see `ReadLatestOptions`). Without a snapshot item there is nothing to
|
|
1197
|
+
// trust, so fall through to the verified read.
|
|
1198
|
+
if (options?.verifySnapshot === false) {
|
|
1199
|
+
const refusal = unverifiedSnapshotRefusal(config.streamName, snapshotSettings, "readLatest");
|
|
1200
|
+
if (refusal !== undefined)
|
|
1201
|
+
return yield* Effect.die(new Error(refusal));
|
|
1202
|
+
const raw = yield* getSnapshotItem(streamId, options.consistentRead === true);
|
|
1203
|
+
if (raw !== undefined) {
|
|
1204
|
+
const current = yield* decodeSnapshotItem(raw, "EventStore.readLatest");
|
|
1205
|
+
return {
|
|
1206
|
+
snapshot: Option.some(current),
|
|
1207
|
+
events: [],
|
|
1208
|
+
version: current.asOfVersion,
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
const client = yield* DynamoClient;
|
|
1213
|
+
const { name: tableName } = yield* config.table.Tag;
|
|
1214
|
+
// One reverse Query over `[first event SK, snapshot SK]`: the snapshot
|
|
1215
|
+
// sorts after every event and the command sentinels before them (see
|
|
1216
|
+
// `snapshotSk`), so the range holds exactly the events and the snapshot,
|
|
1217
|
+
// and the snapshot is the first item evaluated. The `__edd_e__` filter is
|
|
1218
|
+
// belt and braces — `Limit` counts items before it applies.
|
|
1219
|
+
const base = {
|
|
1220
|
+
TableName: tableName,
|
|
1221
|
+
KeyConditionExpression: "#pk = :pk AND #sk BETWEEN :first AND :snapshot",
|
|
1222
|
+
FilterExpression: "#e IN (:eventType, :snapshotType)",
|
|
1223
|
+
ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk", "#e": "__edd_e__" },
|
|
1224
|
+
ExpressionAttributeValues: toAttributeMap({
|
|
1225
|
+
":pk": composeStreamPk(streamId),
|
|
1226
|
+
":first": eventSkPrefix,
|
|
1227
|
+
":snapshot": snapshotSk,
|
|
1228
|
+
":eventType": entityType,
|
|
1229
|
+
":snapshotType": snapshotEntityType,
|
|
1230
|
+
}),
|
|
1231
|
+
ScanIndexForward: false,
|
|
1232
|
+
...(options?.consistentRead === true ? { ConsistentRead: true } : {}),
|
|
1233
|
+
};
|
|
1234
|
+
let snapshotRaw;
|
|
1235
|
+
/** Event items, newest first. */
|
|
1236
|
+
const eventRaws = [];
|
|
1237
|
+
// The first page is sized for the snapshot plus a lag of up to `every`
|
|
1238
|
+
// events, so a current (or modestly lagging) snapshot loads in one
|
|
1239
|
+
// request.
|
|
1240
|
+
let limit = (snapshot.every ?? 1) + 1;
|
|
1241
|
+
let startKey;
|
|
1242
|
+
for (;;) {
|
|
1243
|
+
const page = yield* client.query({
|
|
1244
|
+
...base,
|
|
1245
|
+
...(limit !== undefined ? { Limit: limit } : {}),
|
|
1246
|
+
...(startKey !== undefined ? { ExclusiveStartKey: startKey } : {}),
|
|
1247
|
+
});
|
|
1248
|
+
for (const item of page.Items ?? []) {
|
|
1249
|
+
const raw = fromAttributeMap(item);
|
|
1250
|
+
if (raw.__edd_e__ === snapshotEntityType)
|
|
1251
|
+
snapshotRaw = raw;
|
|
1252
|
+
else
|
|
1253
|
+
eventRaws.push(raw);
|
|
1254
|
+
}
|
|
1255
|
+
startKey = page.LastEvaluatedKey;
|
|
1256
|
+
if (startKey === undefined)
|
|
1257
|
+
break;
|
|
1258
|
+
// Without a snapshot (it would have been the first item evaluated),
|
|
1259
|
+
// read on, unlimited, to the start of the stream.
|
|
1260
|
+
if (snapshotRaw === undefined) {
|
|
1261
|
+
limit = undefined;
|
|
1262
|
+
continue;
|
|
1263
|
+
}
|
|
1264
|
+
// Done once the event right after the snapshot has been seen: versions
|
|
1265
|
+
// are contiguous, so every event the snapshot does not cover is then in
|
|
1266
|
+
// hand, and everything older is already folded into it. Otherwise read
|
|
1267
|
+
// exactly the events still missing, `asOfVersion + 1 … oldest - 1`
|
|
1268
|
+
// (DynamoDB's 1 MB page cap aside).
|
|
1269
|
+
const asOfVersion = snapshotRaw.asOfVersion;
|
|
1270
|
+
const oldest = eventRaws[eventRaws.length - 1]?.version;
|
|
1271
|
+
if (oldest !== undefined && oldest <= asOfVersion + 1)
|
|
1272
|
+
break;
|
|
1273
|
+
limit = oldest === undefined ? undefined : oldest - asOfVersion - 1;
|
|
1274
|
+
}
|
|
1275
|
+
const latest = snapshotRaw === undefined
|
|
1276
|
+
? Option.none()
|
|
1277
|
+
: Option.some(yield* decodeSnapshotItem(snapshotRaw, "EventStore.readLatest"));
|
|
1278
|
+
const asOfVersion = Option.isSome(latest) ? latest.value.asOfVersion : 0;
|
|
1279
|
+
const delta = eventRaws.filter((raw) => raw.version > asOfVersion).reverse();
|
|
1280
|
+
const events = yield* Effect.forEach(delta, (raw) => decodeStreamEvent(raw));
|
|
1281
|
+
return {
|
|
1282
|
+
snapshot: latest,
|
|
1283
|
+
events,
|
|
1284
|
+
version: events[events.length - 1]?.version ?? asOfVersion,
|
|
1285
|
+
};
|
|
1286
|
+
});
|
|
1287
|
+
// ---------------------------------------------------------------------------
|
|
1288
|
+
// hasCommandSentinel (internal — see CommandSentinelProbe)
|
|
1289
|
+
// ---------------------------------------------------------------------------
|
|
1290
|
+
const hasCommandSentinel = (streamId, commandId) => Effect.gen(function* () {
|
|
1291
|
+
const client = yield* DynamoClient;
|
|
1292
|
+
const { name: tableName } = yield* config.table.Tag;
|
|
1293
|
+
const result = yield* client.getItem({
|
|
1294
|
+
TableName: tableName,
|
|
1295
|
+
Key: toAttributeMap({ pk: composeStreamPk(streamId), sk: composeCommandSk(commandId) }),
|
|
1296
|
+
ConsistentRead: true,
|
|
1297
|
+
ProjectionExpression: "#pk",
|
|
1298
|
+
ExpressionAttributeNames: { "#pk": "pk" },
|
|
589
1299
|
});
|
|
1300
|
+
return result.Item !== undefined;
|
|
590
1301
|
});
|
|
591
1302
|
// ---------------------------------------------------------------------------
|
|
592
1303
|
// query.events helper
|
|
@@ -608,8 +1319,118 @@ export const makeStream = (config) => {
|
|
|
608
1319
|
// `Query.where` replaces this — the `__edd_e__` filter still applies.
|
|
609
1320
|
}).pipe(Query.where({ beginsWith: eventSkPrefix }));
|
|
610
1321
|
};
|
|
1322
|
+
// ---------------------------------------------------------------------------
|
|
1323
|
+
// Stream indexes — query.index / readIndex (#140)
|
|
1324
|
+
// ---------------------------------------------------------------------------
|
|
1325
|
+
/**
|
|
1326
|
+
* A query of one stream index, scoped to the stream's partition: the LSI
|
|
1327
|
+
* shares the table's `pk`; a GSI's `pk` attribute carries the same value.
|
|
1328
|
+
* No sort-key condition — the index holds only this stream's indexed events
|
|
1329
|
+
* under that key; `Query.where` narrows the derived-key range.
|
|
1330
|
+
*
|
|
1331
|
+
* @throws `[EDD-9066]` for a name the stream does not declare (unreachable
|
|
1332
|
+
* through the types).
|
|
1333
|
+
*/
|
|
1334
|
+
const buildIndexQuery = (name, streamId) => {
|
|
1335
|
+
const idx = streamIndexes.find((candidate) => candidate.name === name);
|
|
1336
|
+
if (idx === undefined) {
|
|
1337
|
+
throw new Error(`[EDD-9066] EventStream "${config.streamName}": unknown index "${String(name)}". ` +
|
|
1338
|
+
(streamIndexes.length === 0
|
|
1339
|
+
? "The stream declares no indexes."
|
|
1340
|
+
: `Declared: ${streamIndexes.map((candidate) => `"${candidate.name}"`).join(", ")}.`));
|
|
1341
|
+
}
|
|
1342
|
+
return Query.make({
|
|
1343
|
+
tableName: "",
|
|
1344
|
+
indexName: idx.index,
|
|
1345
|
+
pkField: idx.pk,
|
|
1346
|
+
pkValue: composeStreamPk(streamId),
|
|
1347
|
+
skField: idx.sk,
|
|
1348
|
+
entityTypes: [entityType],
|
|
1349
|
+
decoder: indexDecoder(idx),
|
|
1350
|
+
resolveTableName: config.table.Tag.useSync((tc) => tc.name),
|
|
1351
|
+
// DynamoDB's rejection of the key condition (an empty bound, a reversed
|
|
1352
|
+
// `between`) is named against the stream index; so, as for every
|
|
1353
|
+
// query, is a consistent read of a GSI (#144).
|
|
1354
|
+
keyConditionDiagnosis: {
|
|
1355
|
+
operation: "EventStore.readIndex",
|
|
1356
|
+
label: `EventStream "${config.streamName}" index "${idx.name}"`,
|
|
1357
|
+
},
|
|
1358
|
+
keyFields: [idx.pk, idx.sk, "pk", "sk"],
|
|
1359
|
+
// A "keys" index holds no events: an LSI has DynamoDB fetch them
|
|
1360
|
+
// (`Select: ALL_ATTRIBUTES`), a GSI fetches each page's with
|
|
1361
|
+
// `BatchGetItem` on the table key.
|
|
1362
|
+
...(idx.projection === "keys"
|
|
1363
|
+
? {
|
|
1364
|
+
hydration: idx.type === "lsi"
|
|
1365
|
+
? { _tag: "Fetch" }
|
|
1366
|
+
: { _tag: "BatchGet", tableKey: ["pk", "sk"] },
|
|
1367
|
+
}
|
|
1368
|
+
: {}),
|
|
1369
|
+
});
|
|
1370
|
+
};
|
|
1371
|
+
/**
|
|
1372
|
+
* @internal The decoder of events read through `idx`.
|
|
1373
|
+
*
|
|
1374
|
+
* - `"withoutMetadata"` ignores any `metadata` on the row (an index deployed
|
|
1375
|
+
* wider than declared still carries it), so the value matches the type.
|
|
1376
|
+
* - `"all"` / `"withoutMetadata"`: a decode failure on a row that lacks an
|
|
1377
|
+
* attribute the declared projection guarantees is diagnosed as the
|
|
1378
|
+
* deployed index projecting less than declared — a `ValidationError`
|
|
1379
|
+
* (`operation: "EventStore.index.decode"`) naming the index, the original
|
|
1380
|
+
* error kept as its cause. Anything else passes through unchanged.
|
|
1381
|
+
* - `"keys"` decodes items fetched from the table, so a missing attribute
|
|
1382
|
+
* there is not a projection problem: failures pass through.
|
|
1383
|
+
*/
|
|
1384
|
+
const indexDecoder = (idx) => (raw) => {
|
|
1385
|
+
if (idx.projection === "keys")
|
|
1386
|
+
return decodeStreamEvent(raw);
|
|
1387
|
+
let row = raw;
|
|
1388
|
+
if (idx.projection === "withoutMetadata" && raw.metadata !== undefined) {
|
|
1389
|
+
row = { ...raw };
|
|
1390
|
+
delete row.metadata;
|
|
1391
|
+
}
|
|
1392
|
+
return decodeStreamEvent(row).pipe(Effect.mapError((error) => {
|
|
1393
|
+
const missing = PROJECTED_ENVELOPE_ATTRIBUTES.filter((name) => row[name] === undefined);
|
|
1394
|
+
if (missing.length === 0)
|
|
1395
|
+
return error;
|
|
1396
|
+
return new ValidationError({
|
|
1397
|
+
entityType,
|
|
1398
|
+
operation: "EventStore.index.decode",
|
|
1399
|
+
cause: withCause(`Stream index "${idx.name}" (physical "${idx.index}") is declared ` +
|
|
1400
|
+
`projection: "${idx.projection}", but its rows lack ${missing.join(", ")}: the ` +
|
|
1401
|
+
"deployed index likely projects less than declared. Deploy the Projection " +
|
|
1402
|
+
"EventStore.indexDefinitions emits for it.", error),
|
|
1403
|
+
});
|
|
1404
|
+
}));
|
|
1405
|
+
};
|
|
1406
|
+
const readIndex = (name, streamId, options) => Effect.gen(function* () {
|
|
1407
|
+
const refuse = (cause) => new ValidationError({ entityType, operation: "EventStore.readIndex", cause });
|
|
1408
|
+
const invalidCondition = invalidIndexKeyCondition(options);
|
|
1409
|
+
if (invalidCondition !== undefined) {
|
|
1410
|
+
return yield* refuse(`${invalidCondition} Nothing was sent.`);
|
|
1411
|
+
}
|
|
1412
|
+
const limit = options?.limit;
|
|
1413
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) {
|
|
1414
|
+
return yield* refuse(`limit must be a positive integer; received ${String(limit)}. Nothing was sent.`);
|
|
1415
|
+
}
|
|
1416
|
+
let query = buildIndexQuery(name, streamId);
|
|
1417
|
+
if (options?.beginsWith !== undefined) {
|
|
1418
|
+
query = Query.where(query, { beginsWith: options.beginsWith });
|
|
1419
|
+
}
|
|
1420
|
+
else if (options?.between !== undefined) {
|
|
1421
|
+
query = Query.where(query, { between: options.between });
|
|
1422
|
+
}
|
|
1423
|
+
if (options?.reverse === true)
|
|
1424
|
+
query = Query.reverse(query);
|
|
1425
|
+
if (limit !== undefined)
|
|
1426
|
+
query = Query.limit(query, limit);
|
|
1427
|
+
if (options?.consistentRead === true)
|
|
1428
|
+
query = Query.consistentRead(query);
|
|
1429
|
+
return yield* Query.collect(query);
|
|
1430
|
+
});
|
|
611
1431
|
const queryNamespace = {
|
|
612
1432
|
events: (streamId) => buildEventsQuery(streamId),
|
|
1433
|
+
index: (name, streamId) => buildIndexQuery(name, streamId),
|
|
613
1434
|
};
|
|
614
1435
|
// ---------------------------------------------------------------------------
|
|
615
1436
|
// Return EventStream
|
|
@@ -628,13 +1449,19 @@ export const makeStream = (config) => {
|
|
|
628
1449
|
streamName: config.streamName,
|
|
629
1450
|
eventSchema: eventUnion,
|
|
630
1451
|
snapshotConfig: snapshotSettings,
|
|
1452
|
+
indexes: indexSettings,
|
|
631
1453
|
writeSnapshot,
|
|
632
1454
|
readSnapshot,
|
|
633
1455
|
append,
|
|
634
1456
|
read,
|
|
635
1457
|
readFrom,
|
|
636
1458
|
currentVersion,
|
|
1459
|
+
readLatest,
|
|
1460
|
+
readIndex,
|
|
637
1461
|
query: queryNamespace,
|
|
1462
|
+
[StreamIdFormatter]: composeStreamIdString,
|
|
1463
|
+
[CommandSentinelProbe]: hasCommandSentinel,
|
|
1464
|
+
[HeadAboveProbe]: headAbove,
|
|
638
1465
|
};
|
|
639
1466
|
};
|
|
640
1467
|
// ---------------------------------------------------------------------------
|
|
@@ -657,6 +1484,8 @@ export const makeStream = (config) => {
|
|
|
657
1484
|
export const bind = (stream) => Effect.gen(function* () {
|
|
658
1485
|
const ctx = yield* Effect.context();
|
|
659
1486
|
const provide = (effect) => Effect.provide(effect, ctx);
|
|
1487
|
+
const probe = commandSentinelProbeOf(stream);
|
|
1488
|
+
const headProbe = headAboveProbeOf(stream);
|
|
660
1489
|
return {
|
|
661
1490
|
[EventStreamTypeId]: EventStreamTypeId,
|
|
662
1491
|
pipe() {
|
|
@@ -666,106 +1495,528 @@ export const bind = (stream) => Effect.gen(function* () {
|
|
|
666
1495
|
streamName: stream.streamName,
|
|
667
1496
|
eventSchema: stream.eventSchema,
|
|
668
1497
|
snapshotConfig: stream.snapshotConfig,
|
|
1498
|
+
indexes: stream.indexes,
|
|
669
1499
|
writeSnapshot: (streamId, state, asOfVersion) => provide(stream.writeSnapshot(streamId, state, asOfVersion)),
|
|
670
1500
|
readSnapshot: (streamId) => provide(stream.readSnapshot(streamId)),
|
|
671
1501
|
append: (streamId, events, expectedVersion, options) => provide(stream.append(streamId, events, expectedVersion, options)),
|
|
672
|
-
read: (streamId) => provide(stream.read(streamId)),
|
|
673
|
-
readFrom: (streamId, afterVersion) => provide(stream.readFrom(streamId, afterVersion)),
|
|
674
|
-
currentVersion: (streamId) => provide(stream.currentVersion(streamId)),
|
|
1502
|
+
read: (streamId, options) => provide(stream.read(streamId, options)),
|
|
1503
|
+
readFrom: (streamId, afterVersion, options) => provide(stream.readFrom(streamId, afterVersion, options)),
|
|
1504
|
+
currentVersion: (streamId, options) => provide(stream.currentVersion(streamId, options)),
|
|
1505
|
+
readLatest: (streamId, options) => provide(stream.readLatest(streamId, options)),
|
|
1506
|
+
readIndex: (name, streamId, options) => provide(stream.readIndex(name, streamId, options)),
|
|
675
1507
|
query: stream.query,
|
|
676
1508
|
provide,
|
|
1509
|
+
[StreamIdFormatter]: (id) => formatStreamIdOf(stream, id),
|
|
1510
|
+
[CommandSentinelProbe]: probe === undefined
|
|
1511
|
+
? undefined
|
|
1512
|
+
: (id, commandId) => provide(probe(id, commandId)),
|
|
1513
|
+
[HeadAboveProbe]: headProbe === undefined
|
|
1514
|
+
? undefined
|
|
1515
|
+
: (id, floorVersion, options) => provide(headProbe(id, floorVersion, options)),
|
|
677
1516
|
};
|
|
678
1517
|
});
|
|
1518
|
+
// ---------------------------------------------------------------------------
|
|
1519
|
+
// EventStore.indexDefinitions — CreateTable fragments for stream indexes (#140)
|
|
1520
|
+
// ---------------------------------------------------------------------------
|
|
1521
|
+
/**
|
|
1522
|
+
* @internal The `Projection` a stream index of `projection` is created with.
|
|
1523
|
+
* Neither list names a key attribute: table and index keys are always
|
|
1524
|
+
* projected.
|
|
1525
|
+
*/
|
|
1526
|
+
const projectionDefinition = (projection) => projection === "all"
|
|
1527
|
+
? { ProjectionType: "ALL" }
|
|
1528
|
+
: {
|
|
1529
|
+
ProjectionType: "INCLUDE",
|
|
1530
|
+
NonKeyAttributes: projection === "keys" ? ["__edd_e__"] : [...EVENT_ITEM_ATTRIBUTES],
|
|
1531
|
+
};
|
|
1532
|
+
/**
|
|
1533
|
+
* Derive the `CreateTable` fragments for the stream indexes (#140) of the given
|
|
1534
|
+
* streams (`EventStream` or `BoundEventStream`) — `AttributeDefinitions`,
|
|
1535
|
+
* `LocalSecondaryIndexes` and `GlobalSecondaryIndexes` — to merge into the
|
|
1536
|
+
* caller's `CreateTable` input. Event tables are not derived by
|
|
1537
|
+
* `Table.definition`, and the table's own `pk` / `sk` are not included: add
|
|
1538
|
+
* them (and, on a provisioned table, each GSI's `ProvisionedThroughput`)
|
|
1539
|
+
* yourself.
|
|
1540
|
+
*
|
|
1541
|
+
* Each index's `Projection` follows its {@link StreamIndexProjection}:
|
|
1542
|
+
*
|
|
1543
|
+
* | `projection` | `Projection` |
|
|
1544
|
+
* |---|---|
|
|
1545
|
+
* | `"all"` (default) | `{ ProjectionType: "ALL" }` |
|
|
1546
|
+
* | `"withoutMetadata"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__", "data", "eventType", "streamId", "timestamp", "version"] }` |
|
|
1547
|
+
* | `"keys"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__"] }` — not `KEYS_ONLY`: every read filters on `__edd_e__` |
|
|
1548
|
+
*
|
|
1549
|
+
* The deployed index must project what is declared — copy `Projection` from
|
|
1550
|
+
* this output into CDK or Terraform definitions. Neither an LSI's nor a GSI's
|
|
1551
|
+
* projection can be changed in place, and the library migrates nothing. A
|
|
1552
|
+
* table projects at most 100 `NonKeyAttributes` across all its indexes;
|
|
1553
|
+
* `"withoutMetadata"` spends 6 of them and `"keys"` 1 (DynamoDB refuses a
|
|
1554
|
+
* table over the limit).
|
|
1555
|
+
*
|
|
1556
|
+
* An index shared by several streams with the same definition is emitted
|
|
1557
|
+
* once. Lists are sorted by index name, attributes by attribute name.
|
|
1558
|
+
*
|
|
1559
|
+
* - An LSI can only be created with the table, and caps the item collection
|
|
1560
|
+
* of every partition key value in the table at 10 GB — entity partitions
|
|
1561
|
+
* and streams without indexes included.
|
|
1562
|
+
*
|
|
1563
|
+
* @example
|
|
1564
|
+
* ```typescript
|
|
1565
|
+
* const fragments = EventStore.indexDefinitions(Entries, Orders)
|
|
1566
|
+
* yield* client.createTable({
|
|
1567
|
+
* TableName: "events",
|
|
1568
|
+
* BillingMode: "PAY_PER_REQUEST",
|
|
1569
|
+
* KeySchema: [
|
|
1570
|
+
* { AttributeName: "pk", KeyType: "HASH" },
|
|
1571
|
+
* { AttributeName: "sk", KeyType: "RANGE" },
|
|
1572
|
+
* ],
|
|
1573
|
+
* AttributeDefinitions: [
|
|
1574
|
+
* { AttributeName: "pk", AttributeType: "S" },
|
|
1575
|
+
* { AttributeName: "sk", AttributeType: "S" },
|
|
1576
|
+
* ...fragments.AttributeDefinitions,
|
|
1577
|
+
* ],
|
|
1578
|
+
* LocalSecondaryIndexes: fragments.LocalSecondaryIndexes,
|
|
1579
|
+
* GlobalSecondaryIndexes: fragments.GlobalSecondaryIndexes,
|
|
1580
|
+
* })
|
|
1581
|
+
* ```
|
|
1582
|
+
*
|
|
1583
|
+
* @throws `[EDD-9067]` when two streams define the same physical index
|
|
1584
|
+
* differently (another type, other key attributes, or another projection).
|
|
1585
|
+
*/
|
|
1586
|
+
export const indexDefinitions = (...streams) => {
|
|
1587
|
+
const byIndex = new Map();
|
|
1588
|
+
for (const stream of streams) {
|
|
1589
|
+
for (const [name, raw] of Object.entries(stream.indexes)) {
|
|
1590
|
+
const settings = { ...raw, projection: raw.projection ?? "all" };
|
|
1591
|
+
const owner = `index "${name}" of stream "${stream.streamName}"`;
|
|
1592
|
+
const existing = byIndex.get(settings.index);
|
|
1593
|
+
if (existing === undefined) {
|
|
1594
|
+
byIndex.set(settings.index, {
|
|
1595
|
+
type: settings.type,
|
|
1596
|
+
index: settings.index,
|
|
1597
|
+
pk: settings.pk,
|
|
1598
|
+
sk: settings.sk,
|
|
1599
|
+
projection: settings.projection,
|
|
1600
|
+
owner,
|
|
1601
|
+
});
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1604
|
+
if (existing.type !== settings.type ||
|
|
1605
|
+
existing.pk !== settings.pk ||
|
|
1606
|
+
existing.sk !== settings.sk ||
|
|
1607
|
+
existing.projection !== settings.projection) {
|
|
1608
|
+
const describe = (s) => `${s.type} (pk "${s.pk}", sk "${s.sk}", projection "${s.projection}")`;
|
|
1609
|
+
throw new Error(`[EDD-9067] EventStore.indexDefinitions: the physical index "${settings.index}" is ` +
|
|
1610
|
+
`defined as ${describe(existing)} by ${existing.owner} and as ${describe(settings)} ` +
|
|
1611
|
+
`by ${owner}.`);
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
const attributes = new Set();
|
|
1616
|
+
const lsis = [];
|
|
1617
|
+
const gsis = [];
|
|
1618
|
+
const sorted = [...byIndex.values()].sort((a, b) => a.index.localeCompare(b.index));
|
|
1619
|
+
for (const settings of sorted) {
|
|
1620
|
+
const definition = {
|
|
1621
|
+
IndexName: settings.index,
|
|
1622
|
+
KeySchema: [
|
|
1623
|
+
{ AttributeName: settings.pk, KeyType: "HASH" },
|
|
1624
|
+
{ AttributeName: settings.sk, KeyType: "RANGE" },
|
|
1625
|
+
],
|
|
1626
|
+
Projection: projectionDefinition(settings.projection),
|
|
1627
|
+
};
|
|
1628
|
+
attributes.add(settings.sk);
|
|
1629
|
+
if (settings.type === "gsi") {
|
|
1630
|
+
attributes.add(settings.pk);
|
|
1631
|
+
gsis.push(definition);
|
|
1632
|
+
}
|
|
1633
|
+
else {
|
|
1634
|
+
lsis.push(definition);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
return {
|
|
1638
|
+
AttributeDefinitions: [...attributes]
|
|
1639
|
+
.sort()
|
|
1640
|
+
.map((name) => ({ AttributeName: name, AttributeType: "S" })),
|
|
1641
|
+
...(lsis.length > 0 ? { LocalSecondaryIndexes: lsis } : {}),
|
|
1642
|
+
...(gsis.length > 0 ? { GlobalSecondaryIndexes: gsis } : {}),
|
|
1643
|
+
};
|
|
1644
|
+
};
|
|
679
1645
|
/** @internal Both `EventStream` and `BoundEventStream` carry this brand. */
|
|
680
1646
|
const hasEventStreamBrand = (u) => typeof u === "object" && u !== null && EventStreamTypeId in u;
|
|
1647
|
+
/** @internal Resolve a {@link CommandOptionsBase.additionalItems} against a decision. */
|
|
1648
|
+
const deriveAdditionalItems = (input, decision) => {
|
|
1649
|
+
if (typeof input !== "function")
|
|
1650
|
+
return Effect.succeed(input);
|
|
1651
|
+
const derived = input(decision);
|
|
1652
|
+
return Effect.isEffect(derived) ? derived : Effect.succeed(derived);
|
|
1653
|
+
};
|
|
1654
|
+
/** @internal Resolve an `eventMetadata` ({@link CommandMetadataOptions}) against a decision. */
|
|
1655
|
+
const deriveEventMetadata = (input, decision) => {
|
|
1656
|
+
if (input === undefined)
|
|
1657
|
+
return Effect.succeed(undefined);
|
|
1658
|
+
const derived = input(decision);
|
|
1659
|
+
return Effect.isEffect(derived) ? derived : Effect.succeed(derived);
|
|
1660
|
+
};
|
|
1661
|
+
/** @internal Resolve a {@link CommandOptionsBase.eventTimestamps} against a decision. */
|
|
1662
|
+
const deriveEventTimestamps = (input, decision) => {
|
|
1663
|
+
if (input === undefined)
|
|
1664
|
+
return Effect.succeed(undefined);
|
|
1665
|
+
const derived = input(decision);
|
|
1666
|
+
return Effect.isEffect(derived) ? derived : Effect.succeed(derived);
|
|
1667
|
+
};
|
|
681
1668
|
/** @internal */
|
|
682
1669
|
const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
1670
|
+
// `verifySnapshot: false` is refused up front unless the stream writes its
|
|
1671
|
+
// snapshot in every append (EDD-9068) — at handler construction, so a
|
|
1672
|
+
// misconfiguration fails at startup rather than on the first command.
|
|
1673
|
+
const snapshotSettings = stream.snapshotConfig;
|
|
1674
|
+
if (options?.verifySnapshot === false) {
|
|
1675
|
+
const refusal = unverifiedSnapshotRefusal(stream.streamName, snapshotSettings, "commandHandler");
|
|
1676
|
+
if (refusal !== undefined)
|
|
1677
|
+
throw new Error(refusal);
|
|
1678
|
+
}
|
|
1679
|
+
// Ignored on a stream without a snapshot config: there is no snapshot to trust.
|
|
1680
|
+
const unverifiedLoads = options?.verifySnapshot === false && snapshotSettings !== undefined;
|
|
683
1681
|
const retryPolicy = options?.retry;
|
|
684
1682
|
const schedule = retryPolicy === undefined
|
|
685
1683
|
? undefined
|
|
686
1684
|
: typeof retryPolicy === "number"
|
|
687
1685
|
? Schedule.recurs(retryPolicy)
|
|
688
1686
|
: retryPolicy;
|
|
1687
|
+
// Consistent by default: an eventually consistent load can hand `decide`
|
|
1688
|
+
// state that misses acknowledged events (#139).
|
|
1689
|
+
const readOptions = { consistentRead: options?.consistentRead ?? true };
|
|
1690
|
+
const unverifiedReadOptions = { ...readOptions, verifySnapshot: false };
|
|
689
1691
|
return (streamId, command, callOptions) => {
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
return yield* new ValidationError({
|
|
697
|
-
entityType: stream.streamName,
|
|
698
|
-
operation: "EventStore.commandHandler",
|
|
699
|
-
cause: "commandId is required when commandHandler is configured with `idempotency`.",
|
|
700
|
-
});
|
|
701
|
-
}
|
|
702
|
-
const snapshotSettings = stream.snapshotConfig;
|
|
703
|
-
// 1. Establish the base state + version — from a snapshot plus its delta
|
|
704
|
-
// when the stream has snapshots enabled and one exists, otherwise from
|
|
705
|
-
// a full replay.
|
|
1692
|
+
const expectedVersion = callOptions?.expectedVersion;
|
|
1693
|
+
// 1. Establish the base state + version. A snapshot-configured stream
|
|
1694
|
+
// loads its snapshot, the events after it and its head in one request
|
|
1695
|
+
// (#138) — or, unverified, the snapshot item alone; any other stream
|
|
1696
|
+
// replays from the beginning.
|
|
1697
|
+
const load = (verify) => Effect.gen(function* () {
|
|
706
1698
|
let state = decider.initialState;
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
snapshotAsOfVersion = snapshot.value.asOfVersion;
|
|
714
|
-
baseVersion = snapshot.value.asOfVersion;
|
|
715
|
-
state = snapshot.value.state;
|
|
716
|
-
const delta = yield* stream.readFrom(streamId, snapshot.value.asOfVersion);
|
|
717
|
-
for (const event of delta) {
|
|
718
|
-
state = decider.evolve(state, event.data);
|
|
1699
|
+
if (snapshotSettings !== undefined) {
|
|
1700
|
+
const latest = (yield* stream.readLatest(streamId, verify ? readOptions : unverifiedReadOptions));
|
|
1701
|
+
let snapshotAsOfVersion = 0;
|
|
1702
|
+
if (Option.isSome(latest.snapshot)) {
|
|
1703
|
+
snapshotAsOfVersion = latest.snapshot.value.asOfVersion;
|
|
1704
|
+
state = latest.snapshot.value.state;
|
|
719
1705
|
}
|
|
720
|
-
const
|
|
721
|
-
if (newest !== undefined)
|
|
722
|
-
baseVersion = newest.version;
|
|
723
|
-
}
|
|
724
|
-
else {
|
|
725
|
-
const events = yield* stream.read(streamId);
|
|
726
|
-
for (const event of events) {
|
|
1706
|
+
for (const event of latest.events) {
|
|
727
1707
|
state = decider.evolve(state, event.data);
|
|
728
1708
|
}
|
|
729
|
-
const
|
|
730
|
-
|
|
1709
|
+
const loaded = {
|
|
1710
|
+
state,
|
|
1711
|
+
version: latest.version,
|
|
1712
|
+
snapshotAsOfVersion,
|
|
1713
|
+
// An unverified read returns a snapshot alone; without one it has
|
|
1714
|
+
// already fallen back to the verified read.
|
|
1715
|
+
verified: verify || Option.isNone(latest.snapshot),
|
|
1716
|
+
eventsAfterSnapshot: latest.events.length,
|
|
1717
|
+
};
|
|
1718
|
+
return loaded;
|
|
1719
|
+
}
|
|
1720
|
+
const events = yield* stream.read(streamId, readOptions);
|
|
1721
|
+
for (const event of events) {
|
|
1722
|
+
state = decider.evolve(state, event.data);
|
|
731
1723
|
}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
1724
|
+
const loaded = {
|
|
1725
|
+
state,
|
|
1726
|
+
version: events[events.length - 1]?.version ?? 0,
|
|
1727
|
+
snapshotAsOfVersion: 0,
|
|
1728
|
+
verified: true,
|
|
1729
|
+
eventsAfterSnapshot: events.length,
|
|
1730
|
+
};
|
|
1731
|
+
return loaded;
|
|
1732
|
+
});
|
|
1733
|
+
// 4–8. Commit one decision: fold, derive items, append, snapshot. With
|
|
1734
|
+
// `returnAppendConflict`, an append that fails with `VersionConflict`
|
|
1735
|
+
// returns the conflict instead of failing, so the caller can tell a
|
|
1736
|
+
// stale unverified load from a lost race.
|
|
1737
|
+
const commit = (loaded, newEvents, returnAppendConflict) => Effect.gen(function* () {
|
|
1738
|
+
const baseVersion = loaded.version;
|
|
1739
|
+
const state = loaded.state;
|
|
1740
|
+
// 4. No-op command — return current state
|
|
735
1741
|
if (newEvents.length === 0) {
|
|
736
|
-
|
|
1742
|
+
const noop = {
|
|
1743
|
+
state,
|
|
1744
|
+
version: baseVersion,
|
|
1745
|
+
events: [],
|
|
1746
|
+
};
|
|
1747
|
+
return noop;
|
|
1748
|
+
}
|
|
1749
|
+
// 5. Fold the new events BEFORE appending (#137): the post-decision
|
|
1750
|
+
// state is what a function-form `additionalItems` projects, what a
|
|
1751
|
+
// snapshot records, and what the handler returns — always the
|
|
1752
|
+
// `evolve` fold, never anything produced inside `decide`.
|
|
1753
|
+
const previous = state;
|
|
1754
|
+
let next = state;
|
|
1755
|
+
for (const event of newEvents) {
|
|
1756
|
+
next = decider.evolve(next, event);
|
|
1757
|
+
}
|
|
1758
|
+
// 6. Derive the caller's per-event metadata, then per-event
|
|
1759
|
+
// timestamps, then items, from the decision — in that order
|
|
1760
|
+
// (#144). Re-run on every attempt (and on a re-decision), because
|
|
1761
|
+
// the whole cycle is the retried unit.
|
|
1762
|
+
const decision = {
|
|
1763
|
+
events: newEvents,
|
|
1764
|
+
state: next,
|
|
1765
|
+
previous,
|
|
1766
|
+
version: baseVersion,
|
|
1767
|
+
};
|
|
1768
|
+
const eventMetadata = yield* deriveEventMetadata(callOptions?.eventMetadata, decision);
|
|
1769
|
+
// A supplied function must return the array — `undefined` (e.g. a
|
|
1770
|
+
// block body with no `return`) is refused, not silently dropped.
|
|
1771
|
+
if (callOptions?.eventMetadata !== undefined &&
|
|
1772
|
+
(!Array.isArray(eventMetadata) || eventMetadata.length !== newEvents.length)) {
|
|
1773
|
+
return yield* new ValidationError({
|
|
1774
|
+
entityType: stream.streamName,
|
|
1775
|
+
operation: "EventStore.commandHandler.eventMetadata",
|
|
1776
|
+
cause: `eventMetadata returned ${Array.isArray(eventMetadata) ? eventMetadata.length : "no"} ` +
|
|
1777
|
+
`entries for the decision's ${newEvents.length} events: it must return exactly ` +
|
|
1778
|
+
"one entry (or undefined) per event, in order. Nothing was written.",
|
|
1779
|
+
});
|
|
737
1780
|
}
|
|
738
|
-
//
|
|
739
|
-
//
|
|
1781
|
+
// A function (checked before the load), returning the array or an
|
|
1782
|
+
// `Effect` of it; the result of either form is checked here, so the
|
|
1783
|
+
// error names the handler's option rather than `append`'s.
|
|
1784
|
+
const eventTimestamps = yield* deriveEventTimestamps(callOptions?.eventTimestamps, decision);
|
|
1785
|
+
if (callOptions?.eventTimestamps !== undefined) {
|
|
1786
|
+
const problem = eventTimestampsProblem(eventTimestamps, newEvents.length, "eventTimestamps returned", `the decision's ${newEvents.length} events`);
|
|
1787
|
+
if (problem !== undefined) {
|
|
1788
|
+
return yield* new ValidationError({
|
|
1789
|
+
entityType: stream.streamName,
|
|
1790
|
+
operation: "EventStore.commandHandler.eventTimestamps",
|
|
1791
|
+
cause: `${problem} Nothing was written.`,
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
const additionalItems = yield* deriveAdditionalItems(callOptions?.additionalItems, decision);
|
|
1796
|
+
// 7. Append with optimistic concurrency, plus the caller's items, the
|
|
1797
|
+
// dedup sentinel and an inline snapshot, all in one transaction.
|
|
1798
|
+
// `baseVersion` equals any caller-supplied `expectedVersion` here
|
|
1799
|
+
// (checked in step 2).
|
|
740
1800
|
const appendOptions = {};
|
|
741
1801
|
if (callOptions?.metadata !== undefined)
|
|
742
1802
|
appendOptions.metadata = callOptions.metadata;
|
|
743
|
-
if (
|
|
744
|
-
appendOptions.
|
|
745
|
-
|
|
1803
|
+
if (eventMetadata !== undefined)
|
|
1804
|
+
appendOptions.eventMetadata = eventMetadata;
|
|
1805
|
+
if (eventTimestamps !== undefined)
|
|
1806
|
+
appendOptions.eventTimestamps = eventTimestamps;
|
|
1807
|
+
if (additionalItems !== undefined)
|
|
1808
|
+
appendOptions.additionalItems = additionalItems;
|
|
746
1809
|
if (options?.idempotency !== undefined && callOptions?.commandId !== undefined) {
|
|
747
1810
|
appendOptions.idempotency =
|
|
748
1811
|
options.idempotency.ttl !== undefined
|
|
749
1812
|
? { commandId: callOptions.commandId, ttl: options.idempotency.ttl }
|
|
750
1813
|
: { commandId: callOptions.commandId };
|
|
751
1814
|
}
|
|
752
|
-
|
|
753
|
-
//
|
|
754
|
-
|
|
755
|
-
state = decider.evolve(state, event);
|
|
756
|
-
}
|
|
757
|
-
// 6. Auto-snapshot once the cadence threshold is crossed. Best-effort:
|
|
758
|
-
// the events are already durable, so a snapshot-write failure must not
|
|
759
|
-
// report the command as failed. The next threshold crossing retries it.
|
|
1815
|
+
// Inline snapshots (#138) ride in the append transaction: on every
|
|
1816
|
+
// append, or once `every` events have accumulated since the snapshot.
|
|
1817
|
+
const mode = snapshotSettings?.mode ?? "after-append";
|
|
760
1818
|
const every = snapshotSettings?.every;
|
|
761
|
-
|
|
1819
|
+
const newVersion = baseVersion + newEvents.length;
|
|
1820
|
+
if (snapshotSettings !== undefined &&
|
|
1821
|
+
mode === "inline" &&
|
|
1822
|
+
(every === undefined || newVersion - loaded.snapshotAsOfVersion >= every)) {
|
|
1823
|
+
appendOptions.snapshot = next;
|
|
1824
|
+
}
|
|
1825
|
+
const appended = stream.append(streamId, newEvents, baseVersion, appendOptions);
|
|
1826
|
+
const outcome = yield* returnAppendConflict
|
|
1827
|
+
? appended.pipe(Effect.catchIf((error) => error instanceof VersionConflict, (conflict) => Effect.succeed(conflict)))
|
|
1828
|
+
: appended;
|
|
1829
|
+
if (outcome instanceof VersionConflict)
|
|
1830
|
+
return outcome;
|
|
1831
|
+
const result = outcome;
|
|
1832
|
+
// 8. After-append snapshots, once the cadence threshold is crossed.
|
|
1833
|
+
// Best-effort: the events are already durable, so a snapshot-write
|
|
1834
|
+
// failure must not report the command as failed. The next threshold
|
|
1835
|
+
// crossing retries it.
|
|
1836
|
+
if (mode === "after-append" &&
|
|
1837
|
+
every !== undefined &&
|
|
1838
|
+
result.version - loaded.snapshotAsOfVersion >= every) {
|
|
762
1839
|
yield* stream
|
|
763
|
-
.writeSnapshot(streamId,
|
|
1840
|
+
.writeSnapshot(streamId, next, result.version)
|
|
764
1841
|
.pipe(Effect.catchCause((cause) => Effect.logWarning(`EventStore: snapshot write failed for stream "${stream.streamName}" at version ${result.version}`, cause)));
|
|
765
1842
|
}
|
|
766
|
-
|
|
1843
|
+
const done = {
|
|
1844
|
+
state: next,
|
|
1845
|
+
version: result.version,
|
|
1846
|
+
events: newEvents,
|
|
1847
|
+
};
|
|
1848
|
+
return done;
|
|
1849
|
+
});
|
|
1850
|
+
// The If-Match answer for a stream at `actualVersion` (#136). A
|
|
1851
|
+
// redelivery of a command that already committed (its response was lost)
|
|
1852
|
+
// arrives with its original If-Match, which the stream has since moved
|
|
1853
|
+
// past. It is a duplicate, not a lost race — `append` ranks
|
|
1854
|
+
// `DuplicateCommand` above `VersionConflict` for the same reason — so
|
|
1855
|
+
// consult the sentinel before reporting the conflict. The extra read is
|
|
1856
|
+
// paid on this conflict path only.
|
|
1857
|
+
const ifMatchMismatch = (expected, actualVersion) => Effect.gen(function* () {
|
|
1858
|
+
const probe = commandSentinelProbeOf(stream);
|
|
1859
|
+
const commandId = callOptions?.commandId;
|
|
1860
|
+
if (options?.idempotency !== undefined && commandId !== undefined && probe !== undefined) {
|
|
1861
|
+
if (yield* probe(streamId, commandId)) {
|
|
1862
|
+
return yield* new DuplicateCommand({
|
|
1863
|
+
streamName: stream.streamName,
|
|
1864
|
+
streamId: formatStreamIdOf(stream, streamId),
|
|
1865
|
+
commandId,
|
|
1866
|
+
});
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
return yield* new VersionConflict({
|
|
1870
|
+
streamName: stream.streamName,
|
|
1871
|
+
streamId: formatStreamIdOf(stream, streamId),
|
|
1872
|
+
expectedVersion: expected,
|
|
1873
|
+
actualVersion,
|
|
1874
|
+
});
|
|
767
1875
|
});
|
|
768
|
-
|
|
1876
|
+
// The head, known to be at least `floorVersion` (see HeadAboveProbe): one
|
|
1877
|
+
// `Query` that reads no items while the snapshot is current.
|
|
1878
|
+
const headAbove = (floorVersion) => {
|
|
1879
|
+
const probe = headAboveProbeOf(stream);
|
|
1880
|
+
return probe !== undefined
|
|
1881
|
+
? probe(streamId, floorVersion, readOptions)
|
|
1882
|
+
: stream.currentVersion(streamId, readOptions);
|
|
1883
|
+
};
|
|
1884
|
+
const attempt = Effect.gen(function* () {
|
|
1885
|
+
// Backstop for JS callers and `any`-shaped call sites: silently degrading
|
|
1886
|
+
// to at-least-once would look like success right up until the day a
|
|
1887
|
+
// duplicate mattered. Not retryable — `while` below only retries
|
|
1888
|
+
// `VersionConflict`, so this surfaces on the first attempt.
|
|
1889
|
+
if (options?.idempotency !== undefined && callOptions?.commandId === undefined) {
|
|
1890
|
+
return yield* new ValidationError({
|
|
1891
|
+
entityType: stream.streamName,
|
|
1892
|
+
operation: "EventStore.commandHandler",
|
|
1893
|
+
cause: "commandId is required when commandHandler is configured with `idempotency`.",
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
// The same backstop for the exclusive metadata pair (#144), and for an
|
|
1897
|
+
// `eventMetadata` or `eventTimestamps` that is not the function it must
|
|
1898
|
+
// be — refused before anything is read, rather than by `append` after
|
|
1899
|
+
// `decide`.
|
|
1900
|
+
if (callOptions?.eventMetadata !== undefined) {
|
|
1901
|
+
if (callOptions.metadata !== undefined) {
|
|
1902
|
+
return yield* new ValidationError({
|
|
1903
|
+
entityType: stream.streamName,
|
|
1904
|
+
operation: "EventStore.commandHandler.eventMetadata",
|
|
1905
|
+
cause: "metadata and eventMetadata are mutually exclusive: pass one value shared by " +
|
|
1906
|
+
"every event, or a function deriving one entry per event. Nothing was written.",
|
|
1907
|
+
});
|
|
1908
|
+
}
|
|
1909
|
+
if (typeof callOptions.eventMetadata !== "function") {
|
|
1910
|
+
return yield* new ValidationError({
|
|
1911
|
+
entityType: stream.streamName,
|
|
1912
|
+
operation: "EventStore.commandHandler.eventMetadata",
|
|
1913
|
+
cause: "eventMetadata must be a function of the decision: the number of events is " +
|
|
1914
|
+
"known only once `decide` has run. Nothing was written.",
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
if (callOptions?.eventTimestamps !== undefined &&
|
|
1919
|
+
typeof callOptions.eventTimestamps !== "function") {
|
|
1920
|
+
return yield* new ValidationError({
|
|
1921
|
+
entityType: stream.streamName,
|
|
1922
|
+
operation: "EventStore.commandHandler.eventTimestamps",
|
|
1923
|
+
cause: "eventTimestamps must be a function of the decision: the number of events is " +
|
|
1924
|
+
"known only once `decide` has run. Nothing was written.",
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
// A malformed If-Match (`NaN` from a failed parse, a negative or
|
|
1928
|
+
// fractional number) is a caller bug, not a conflict — refuse it before
|
|
1929
|
+
// reading anything.
|
|
1930
|
+
if (expectedVersion !== undefined &&
|
|
1931
|
+
(!Number.isInteger(expectedVersion) || expectedVersion < 0)) {
|
|
1932
|
+
return yield* new ValidationError({
|
|
1933
|
+
entityType: stream.streamName,
|
|
1934
|
+
operation: "EventStore.commandHandler",
|
|
1935
|
+
cause: `expectedVersion must be a non-negative integer; received ${String(expectedVersion)}.`,
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
// 1. Load (see `load`).
|
|
1939
|
+
let loaded = yield* load(!unverifiedLoads);
|
|
1940
|
+
// An unverified snapshot at another version than the caller's If-Match
|
|
1941
|
+
// may merely lag the head: re-read verified, so only a genuine mismatch
|
|
1942
|
+
// is reported, with the verified head as its `actualVersion`.
|
|
1943
|
+
if (expectedVersion !== undefined && !loaded.verified && loaded.version !== expectedVersion) {
|
|
1944
|
+
loaded = yield* load(true);
|
|
1945
|
+
}
|
|
1946
|
+
// 2. If-Match (#136): the caller saw a different version, so `decide`
|
|
1947
|
+
// must not run against state the caller never saw.
|
|
1948
|
+
if (expectedVersion !== undefined && expectedVersion !== loaded.version) {
|
|
1949
|
+
return yield* ifMatchMismatch(expectedVersion, loaded.version);
|
|
1950
|
+
}
|
|
1951
|
+
// 3. Decide on a verified load, and commit. An If-Match call's
|
|
1952
|
+
// append-time conflict is its answer (never re-read or retried), and
|
|
1953
|
+
// a verified load's conflict is a lost race.
|
|
1954
|
+
if (loaded.verified) {
|
|
1955
|
+
const newEvents = yield* decider.decide(command, loaded.state);
|
|
1956
|
+
return (yield* commit(loaded, newEvents, false));
|
|
1957
|
+
}
|
|
1958
|
+
// 3'. Decide on an unverified snapshot. It is normally at the head, but
|
|
1959
|
+
// events appended without one (a raw `append`, data written before
|
|
1960
|
+
// inline mode) make it lag — and then every answer made on it is
|
|
1961
|
+
// stale, not only one that appends. So no answer is returned until
|
|
1962
|
+
// the head is confirmed: an append confirms it by succeeding; a
|
|
1963
|
+
// decision that appends nothing (a `decide` failure or a no-op) and
|
|
1964
|
+
// an append conflict are confirmed with one more read.
|
|
1965
|
+
const decided = yield* Effect.result(decider.decide(command, loaded.state));
|
|
1966
|
+
const first = Result.isSuccess(decided) && decided.success.length > 0
|
|
1967
|
+
? yield* commit(loaded, decided.success, true)
|
|
1968
|
+
: undefined;
|
|
1969
|
+
if (first !== undefined && !(first instanceof VersionConflict))
|
|
1970
|
+
return first;
|
|
1971
|
+
// The answer the first decision gives when the snapshot proves current.
|
|
1972
|
+
const firstAnswer = first !== undefined
|
|
1973
|
+
? Effect.fail(first)
|
|
1974
|
+
: Result.isFailure(decided)
|
|
1975
|
+
? Effect.fail(decided.failure)
|
|
1976
|
+
: Effect.succeed({ state: loaded.state, version: loaded.version, events: [] });
|
|
1977
|
+
// With an If-Match equal to the snapshot's `asOfVersion`, the snapshot
|
|
1978
|
+
// state is exactly the state the caller saw, so `decide` ran on the
|
|
1979
|
+
// right state — the only question is whether the stream is still at
|
|
1980
|
+
// that version. A newer head answers exactly as a verified load would
|
|
1981
|
+
// have (the pre-decide check above): `DuplicateCommand` for a committed
|
|
1982
|
+
// redelivery, else `VersionConflict` with the head as `actualVersion`.
|
|
1983
|
+
// The first decision is discarded. Never re-decided: an If-Match call's
|
|
1984
|
+
// conflict is its answer.
|
|
1985
|
+
if (expectedVersion !== undefined) {
|
|
1986
|
+
const head = yield* headAbove(expectedVersion);
|
|
1987
|
+
if (head !== expectedVersion)
|
|
1988
|
+
return yield* ifMatchMismatch(expectedVersion, head);
|
|
1989
|
+
return yield* firstAnswer;
|
|
1990
|
+
}
|
|
1991
|
+
// No If-Match, nothing appended: a head at the snapshot's version
|
|
1992
|
+
// confirms the answer (one `Query` above the snapshot's version, which
|
|
1993
|
+
// reads no items while the snapshot is current).
|
|
1994
|
+
if (first === undefined) {
|
|
1995
|
+
const head = yield* headAbove(loaded.version);
|
|
1996
|
+
if (head === loaded.version)
|
|
1997
|
+
return yield* firstAnswer;
|
|
1998
|
+
}
|
|
1999
|
+
// No If-Match, the append conflicted — the snapshot lagged the head, or
|
|
2000
|
+
// another writer won the race; one verified load tells them apart. A
|
|
2001
|
+
// snapshot at the head is a genuine race, which the retry policy
|
|
2002
|
+
// handles as it always has (a retried attempt loads unverified again).
|
|
2003
|
+
// A lagging snapshot is decided on again, once: only a successful inline
|
|
2004
|
+
// append rewrites the snapshot, so surfacing the conflict would fail
|
|
2005
|
+
// every command on the stream until something else repaired it.
|
|
2006
|
+
const verified = yield* load(true);
|
|
2007
|
+
if (first !== undefined && verified.eventsAfterSnapshot === 0)
|
|
2008
|
+
return yield* first;
|
|
2009
|
+
// The first decision was made on stale state: decide again on the
|
|
2010
|
+
// verified state — `decide` (and a function-form `additionalItems`)
|
|
2011
|
+
// runs a second time in this attempt. Its own conflict follows the
|
|
2012
|
+
// retry policy.
|
|
2013
|
+
const newEvents = yield* decider.decide(command, verified.state);
|
|
2014
|
+
return (yield* commit(verified, newEvents, false));
|
|
2015
|
+
});
|
|
2016
|
+
// A caller-supplied `expectedVersion` is a conditional write: its
|
|
2017
|
+
// `VersionConflict` (pre-decide or from the append) is the answer, never a
|
|
2018
|
+
// cue to re-read and re-decide.
|
|
2019
|
+
return schedule === undefined || expectedVersion !== undefined
|
|
769
2020
|
? attempt
|
|
770
2021
|
: Effect.retry(attempt, {
|
|
771
2022
|
// `while` runs before the schedule, so the schedule only ever sees a
|
|
@@ -805,12 +2056,69 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
805
2056
|
* idempotency: { ttl: Duration.days(1) },
|
|
806
2057
|
* })
|
|
807
2058
|
* yield* handle({ matchId: "m-1" }, command, { commandId: "cmd-7f3a" })
|
|
2059
|
+
*
|
|
2060
|
+
* // Load state from the inline snapshot alone (mode "inline" without `every`)
|
|
2061
|
+
* const handle = EventStore.commandHandler(decider, stream, { verifySnapshot: false })
|
|
2062
|
+
*
|
|
2063
|
+
* // If-Match: fail with VersionConflict (never retried) unless the stream is at v7
|
|
2064
|
+
* yield* handle({ matchId: "m-1" }, command, { expectedVersion: 7 })
|
|
2065
|
+
*
|
|
2066
|
+
* // Inline projection, committed in the same transaction as the events
|
|
2067
|
+
* yield* handle({ matchId: "m-1" }, command, {
|
|
2068
|
+
* additionalItems: ({ state }) => [Scoreboard.put({ matchId: "m-1", runs: state.runs })],
|
|
2069
|
+
* })
|
|
2070
|
+
*
|
|
2071
|
+
* // Per-event metadata derived from the decision: entry i is written onto event i
|
|
2072
|
+
* // (`crypto` is the `Crypto.Crypto` service; `randomUUIDv7` works the same way)
|
|
2073
|
+
* yield* handle({ matchId: "m-1" }, command, {
|
|
2074
|
+
* eventMetadata: ({ events }) =>
|
|
2075
|
+
* Effect.forEach(events, () => Effect.map(crypto.randomULID, (eventId) => ({ eventId }))),
|
|
2076
|
+
* })
|
|
2077
|
+
*
|
|
2078
|
+
* // Per-event timestamps derived from the decision (undefined → the Clock time)
|
|
2079
|
+
* yield* handle({ matchId: "m-1" }, command, {
|
|
2080
|
+
* eventTimestamps: ({ events }) => events.map((event) => event.occurredAt),
|
|
2081
|
+
* })
|
|
2082
|
+
*
|
|
2083
|
+
* // ...or from a service, as an Effect (its errors / requirements join the handler's)
|
|
2084
|
+
* yield* handle({ matchId: "m-1" }, command, {
|
|
2085
|
+
* eventTimestamps: ({ events }) =>
|
|
2086
|
+
* Effect.forEach(events, (event) => SourceLog.originalTime(event.sourceId)),
|
|
2087
|
+
* })
|
|
808
2088
|
* ```
|
|
809
2089
|
*
|
|
810
|
-
*
|
|
811
|
-
*
|
|
812
|
-
*
|
|
813
|
-
*
|
|
2090
|
+
* Each invocation runs
|
|
2091
|
+
* `load → [expectedVersion check] → decide → fold new events → derive items → append → [snapshot]`.
|
|
2092
|
+
*
|
|
2093
|
+
* - **Load.** Strongly consistent by default — see
|
|
2094
|
+
* {@link CommandHandlerOptions.consistentRead}. When the stream declares a
|
|
2095
|
+
* `snapshot` config, state is loaded with {@link EventStream.readLatest} —
|
|
2096
|
+
* the snapshot, the events after it and the head in one request in steady
|
|
2097
|
+
* state — and only those events are folded, instead of replaying the stream
|
|
2098
|
+
* from the beginning. On a `mode: "inline"` stream without `every`,
|
|
2099
|
+
* {@link CommandHandlerOptions.verifySnapshot}`: false` loads the snapshot
|
|
2100
|
+
* item alone (one `GetItem`); an answer that is not a successful append is
|
|
2101
|
+
* confirmed against the head first, and a snapshot that proves stale falls
|
|
2102
|
+
* back to the verified load — in which case `decide` may run twice.
|
|
2103
|
+
* - **expectedVersion.** See {@link CommandOptionsBase.expectedVersion}.
|
|
2104
|
+
* - **Fold before append.** The new events are folded into state before the
|
|
2105
|
+
* append, so the state returned, snapshotted and handed to a function-form
|
|
2106
|
+
* `additionalItems` (see {@link Decision}) is always the `evolve` fold.
|
|
2107
|
+
* - **Snapshot.** With `snapshot.mode: "inline"`, the snapshot is written in
|
|
2108
|
+
* the append transaction itself — on every append, or at the `every`
|
|
2109
|
+
* cadence. With the default `"after-append"` and `snapshot.every` set, a
|
|
2110
|
+
* fresh snapshot is written (best-effort) after a successful append once the
|
|
2111
|
+
* cadence threshold is crossed. See {@link SnapshotMode}.
|
|
2112
|
+
* - **One atomic append.** One command, one decision, one append: a decision
|
|
2113
|
+
* too large for one transaction is rejected whole by DynamoDB and fails with
|
|
2114
|
+
* `AppendTooLarge` — nothing is written. A large command is split by the application into stepped
|
|
2115
|
+
* commands — each an ordinary call, chained by `expectedVersion` — so a
|
|
2116
|
+
* failure partway leaves the stream at a real intermediate state.
|
|
2117
|
+
*
|
|
2118
|
+
* The handler is generic per call over the error and requirements of a
|
|
2119
|
+
* function-form `additionalItems` that returns an `Effect`: they join the
|
|
2120
|
+
* handler's error channel and requirements (on a `BoundEventStream` handler
|
|
2121
|
+
* they are its only requirements).
|
|
814
2122
|
*
|
|
815
2123
|
* Without `idempotency`, command processing is **at-least-once**: a retry after
|
|
816
2124
|
* an acked-but-lost response re-runs `decide` and appends again.
|