effect-dynamodb 1.23.0 → 1.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/EventStore.d.ts +714 -47
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +1231 -267
- package/dist/EventStore.js.map +1 -1
- package/package.json +2 -2
package/dist/EventStore.js
CHANGED
|
@@ -4,10 +4,17 @@
|
|
|
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 — `readIndex`, `query.index`,
|
|
17
|
+
* and `indexDefinitions` for the `CreateTable` fragments
|
|
11
18
|
* - `fold` / `foldFrom` helpers for state reconstruction
|
|
12
19
|
*
|
|
13
20
|
* Built on the existing library primitives (DynamoSchema, KeyComposer, Query,
|
|
@@ -17,12 +24,12 @@ import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
|
|
|
17
24
|
import { normalizeTtlSeconds } from "@effect-dynamodb/schema/Entity.js";
|
|
18
25
|
import { AdditionalItemConditionFailed, AppendTooLarge, DuplicateCommand, isAwsConditionalCheckFailed, isAwsTransactionCancelled, TRANSACT_WRITE_ITEMS_LIMIT, TransactionCancelled, ValidationError, VersionConflict, } from "@effect-dynamodb/schema/Errors.js";
|
|
19
26
|
import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
|
|
20
|
-
import { DateTime, Effect, Function, Option, Pipeable, Schedule, Schema, } from "effect";
|
|
27
|
+
import { DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
|
|
21
28
|
import { DynamoClient } from "./DynamoClient.js";
|
|
22
29
|
import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, judgeCancellation, refuseOversizedTransaction, refuseRepeatedItems, transactItemTarget, } from "./internal/TransactWriteOps.js";
|
|
23
30
|
import { fromAttributeMap, toAttributeMap } from "./Marshaller.js";
|
|
24
31
|
import * as Query from "./Query.js";
|
|
25
|
-
import { resolveTtlAttributeName } from "./Table.js";
|
|
32
|
+
import { DEFAULT_TTL_ATTRIBUTE_NAME, resolveTtlAttributeName, } from "./Table.js";
|
|
26
33
|
// ---------------------------------------------------------------------------
|
|
27
34
|
// Envelope schema — validates the persisted event's system fields on read
|
|
28
35
|
// ---------------------------------------------------------------------------
|
|
@@ -38,10 +45,209 @@ const EventEnvelope = Schema.Struct({
|
|
|
38
45
|
eventType: Schema.String,
|
|
39
46
|
timestamp: Schema.String,
|
|
40
47
|
});
|
|
48
|
+
/** @internal DynamoDB's limit on a sort key value, which an index key is. */
|
|
49
|
+
const MAX_INDEX_SORT_KEY_BYTES = 1024;
|
|
50
|
+
/** @internal */
|
|
51
|
+
const utf8 = new TextEncoder();
|
|
52
|
+
/**
|
|
53
|
+
* @internal Compare two strings by their UTF-8 bytes — the order DynamoDB
|
|
54
|
+
* sorts (and evaluates `BETWEEN` on) string keys. JavaScript's `<` compares
|
|
55
|
+
* UTF-16 code units, which disagrees for characters outside the BMP.
|
|
56
|
+
*/
|
|
57
|
+
const compareUtf8 = (a, b) => {
|
|
58
|
+
const left = utf8.encode(a);
|
|
59
|
+
const right = utf8.encode(b);
|
|
60
|
+
const length = Math.min(left.length, right.length);
|
|
61
|
+
for (let i = 0; i < length; i++) {
|
|
62
|
+
const diff = left[i] - right[i];
|
|
63
|
+
if (diff !== 0)
|
|
64
|
+
return diff;
|
|
65
|
+
}
|
|
66
|
+
return left.length - right.length;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* @internal Why a {@link ReadIndexOptions} key condition cannot be sent, or
|
|
70
|
+
* `undefined` when it can. DynamoDB rejects an empty key value and a
|
|
71
|
+
* `BETWEEN` whose lower bound sorts after its upper bound.
|
|
72
|
+
*/
|
|
73
|
+
const invalidIndexKeyCondition = (options) => {
|
|
74
|
+
if (options?.beginsWith !== undefined && options.between !== undefined) {
|
|
75
|
+
return "Pass at most one of beginsWith and between.";
|
|
76
|
+
}
|
|
77
|
+
const beginsWith = options?.beginsWith;
|
|
78
|
+
if (beginsWith !== undefined) {
|
|
79
|
+
if (typeof beginsWith !== "string" || beginsWith.length === 0) {
|
|
80
|
+
return ("beginsWith must be a non-empty string — DynamoDB rejects an empty key value; " +
|
|
81
|
+
"omit it to read the whole sub-stream.");
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
const between = options?.between;
|
|
86
|
+
if (between === undefined)
|
|
87
|
+
return undefined;
|
|
88
|
+
if (!Array.isArray(between) ||
|
|
89
|
+
between.length !== 2 ||
|
|
90
|
+
!between.every((bound) => typeof bound === "string" && bound.length > 0)) {
|
|
91
|
+
return "between must be a pair of non-empty strings — DynamoDB rejects an empty key value.";
|
|
92
|
+
}
|
|
93
|
+
const [low, high] = between;
|
|
94
|
+
if (compareUtf8(low, high) > 0) {
|
|
95
|
+
return `between's lower bound ${JSON.stringify(low)} sorts after its upper bound ${JSON.stringify(high)}.`;
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* @internal Attributes a stream writes on its own items — events, snapshot,
|
|
101
|
+
* idempotency sentinel — plus the default TTL attribute. An index attribute
|
|
102
|
+
* may not reuse any of them.
|
|
103
|
+
*/
|
|
104
|
+
const STREAM_OWNED_ATTRIBUTES = [
|
|
105
|
+
"pk",
|
|
106
|
+
"sk",
|
|
107
|
+
"__edd_e__",
|
|
108
|
+
"streamId",
|
|
109
|
+
"version",
|
|
110
|
+
"eventType",
|
|
111
|
+
"data",
|
|
112
|
+
"metadata",
|
|
113
|
+
"timestamp",
|
|
114
|
+
"asOfVersion",
|
|
115
|
+
"state",
|
|
116
|
+
"commandId",
|
|
117
|
+
DEFAULT_TTL_ATTRIBUTE_NAME,
|
|
118
|
+
];
|
|
119
|
+
/**
|
|
120
|
+
* @internal Validate `makeStream({ indexes })` at definition time.
|
|
121
|
+
*
|
|
122
|
+
* @throws `[EDD-9063]` for a malformed entry (unknown `type`, a `gsi` without
|
|
123
|
+
* `pk`, an `lsi` with `pk`, an empty name, a `key` that is not a function).
|
|
124
|
+
* @throws `[EDD-9064]` for an index attribute that collides with a
|
|
125
|
+
* stream-owned attribute.
|
|
126
|
+
* @throws `[EDD-9065]` for two indexes sharing a physical index name or an
|
|
127
|
+
* attribute.
|
|
128
|
+
*/
|
|
129
|
+
const resolveStreamIndexes = (streamName, indexes) => {
|
|
130
|
+
if (indexes === undefined)
|
|
131
|
+
return [];
|
|
132
|
+
const fail = (code, message) => {
|
|
133
|
+
throw new Error(`[${code}] EventStream "${streamName}": ${message}`);
|
|
134
|
+
};
|
|
135
|
+
const isName = (value) => typeof value === "string" && value !== "";
|
|
136
|
+
const resolved = [];
|
|
137
|
+
for (const [name, raw] of Object.entries(indexes)) {
|
|
138
|
+
if (typeof raw !== "object" || raw === null) {
|
|
139
|
+
fail("EDD-9063", `index "${name}" must be an object; received ${String(raw)}.`);
|
|
140
|
+
}
|
|
141
|
+
const entry = raw;
|
|
142
|
+
const type = (entry.type ?? "lsi");
|
|
143
|
+
if (type !== "lsi" && type !== "gsi") {
|
|
144
|
+
fail("EDD-9063", `index "${name}": type must be "lsi" or "gsi"; received ${JSON.stringify(entry.type)}.`);
|
|
145
|
+
}
|
|
146
|
+
if (!isName(entry.index)) {
|
|
147
|
+
fail("EDD-9063", `index "${name}": index must name the physical index (a non-empty string).`);
|
|
148
|
+
}
|
|
149
|
+
if (!isName(entry.sk)) {
|
|
150
|
+
fail("EDD-9063", `index "${name}": sk must name the sort key attribute (a non-empty string).`);
|
|
151
|
+
}
|
|
152
|
+
if (type === "gsi" && !isName(entry.pk)) {
|
|
153
|
+
fail("EDD-9063", `index "${name}": a "gsi" index requires pk, the attribute carrying the stream's ` +
|
|
154
|
+
"partition key (a non-empty string).");
|
|
155
|
+
}
|
|
156
|
+
if (type === "lsi" && entry.pk !== undefined) {
|
|
157
|
+
fail("EDD-9063", `index "${name}": an "lsi" index uses the table partition key "pk"; remove pk, or ` +
|
|
158
|
+
'declare type: "gsi".');
|
|
159
|
+
}
|
|
160
|
+
if (typeof entry.key !== "function") {
|
|
161
|
+
fail("EDD-9063", `index "${name}": key must be a function (event, version) => string | undefined.`);
|
|
162
|
+
}
|
|
163
|
+
const index = entry.index;
|
|
164
|
+
const sk = entry.sk;
|
|
165
|
+
const pk = type === "gsi" ? entry.pk : "pk";
|
|
166
|
+
const attributes = type === "gsi" ? [pk, sk] : [sk];
|
|
167
|
+
for (const attribute of attributes) {
|
|
168
|
+
if (STREAM_OWNED_ATTRIBUTES.includes(attribute)) {
|
|
169
|
+
fail("EDD-9064", `index "${name}": attribute "${attribute}" is written by the stream itself ` +
|
|
170
|
+
`(reserved: ${STREAM_OWNED_ATTRIBUTES.join(", ")}). Choose another attribute name.`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (type === "gsi" && pk === sk) {
|
|
174
|
+
fail("EDD-9065", `index "${name}": pk and sk must be different attributes ("${pk}").`);
|
|
175
|
+
}
|
|
176
|
+
for (const other of resolved) {
|
|
177
|
+
if (other.index === index) {
|
|
178
|
+
fail("EDD-9065", `indexes "${other.name}" and "${name}" both use the physical index "${index}".`);
|
|
179
|
+
}
|
|
180
|
+
const otherAttributes = other.type === "gsi" ? [other.pk, other.sk] : [other.sk];
|
|
181
|
+
const shared = attributes.find((attribute) => otherAttributes.includes(attribute));
|
|
182
|
+
if (shared !== undefined) {
|
|
183
|
+
fail("EDD-9065", `indexes "${other.name}" and "${name}" both use the attribute "${shared}".`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
resolved.push({
|
|
187
|
+
name,
|
|
188
|
+
type,
|
|
189
|
+
index,
|
|
190
|
+
pk,
|
|
191
|
+
sk,
|
|
192
|
+
key: entry.key,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return resolved;
|
|
196
|
+
};
|
|
41
197
|
// ---------------------------------------------------------------------------
|
|
42
198
|
// EventStreamTypeId
|
|
43
199
|
// ---------------------------------------------------------------------------
|
|
44
200
|
const EventStreamTypeId = Symbol.for("effect-dynamodb/EventStream");
|
|
201
|
+
/**
|
|
202
|
+
* @internal Carries the stream's stream-id formatter (composites joined in
|
|
203
|
+
* declaration order — the `streamId` reported on errors and stored on items),
|
|
204
|
+
* so {@link commandHandler} can report a pre-decide `VersionConflict` exactly
|
|
205
|
+
* as `append` would. Not part of the public interface: `makeStream` and `bind`
|
|
206
|
+
* attach it; a hand-built stream object falls back to joining the id's values.
|
|
207
|
+
*/
|
|
208
|
+
const StreamIdFormatter = Symbol.for("effect-dynamodb/EventStream/StreamIdFormatter");
|
|
209
|
+
/** @internal */
|
|
210
|
+
const formatStreamIdOf = (stream, streamId) => {
|
|
211
|
+
const formatter = stream[StreamIdFormatter];
|
|
212
|
+
return formatter !== undefined ? formatter(streamId) : Object.values(streamId).join("#");
|
|
213
|
+
};
|
|
214
|
+
/**
|
|
215
|
+
* @internal Carries a probe for a command's idempotency sentinel (one strongly
|
|
216
|
+
* consistent `GetItem`), so {@link commandHandler} can tell a redelivered
|
|
217
|
+
* command from a lost race when its pre-decide If-Match check fails. Not part
|
|
218
|
+
* of the public interface: `makeStream` and `bind` attach it (a bound stream's
|
|
219
|
+
* probe has its services provided); a hand-built stream object has none.
|
|
220
|
+
*/
|
|
221
|
+
const CommandSentinelProbe = Symbol.for("effect-dynamodb/EventStream/CommandSentinelProbe");
|
|
222
|
+
/** @internal */
|
|
223
|
+
const commandSentinelProbeOf = (stream) => stream[CommandSentinelProbe];
|
|
224
|
+
/**
|
|
225
|
+
* @internal Carries a head probe bounded below by a known version: the newest
|
|
226
|
+
* event version above `floorVersion`, or `floorVersion` itself when there is
|
|
227
|
+
* none. {@link commandHandler} uses it to confirm an unverified snapshot is
|
|
228
|
+
* still the head. Unlike `currentVersion`, it never reads the newest event when
|
|
229
|
+
* the snapshot is current — the key range above `floorVersion` is empty, so the
|
|
230
|
+
* `Query` reads no items and costs only DynamoDB's per-request minimum, however
|
|
231
|
+
* large the events are. Attached by `makeStream` and `bind` like
|
|
232
|
+
* {@link CommandSentinelProbe}; a hand-built stream falls back to
|
|
233
|
+
* `currentVersion`.
|
|
234
|
+
*/
|
|
235
|
+
const HeadAboveProbe = Symbol.for("effect-dynamodb/EventStream/HeadAboveProbe");
|
|
236
|
+
/** @internal */
|
|
237
|
+
const headAboveProbeOf = (stream) => stream[HeadAboveProbe];
|
|
238
|
+
/**
|
|
239
|
+
* @internal `[EDD-9068]`: `verifySnapshot: false` is accepted only by a stream
|
|
240
|
+
* whose snapshot is written in every append (`mode: "inline"` without `every`),
|
|
241
|
+
* the only mode in which the snapshot is normally at the head. Returns the
|
|
242
|
+
* refusal message, or `undefined` when the option is acceptable — including on
|
|
243
|
+
* a stream without a snapshot config, where it is ignored.
|
|
244
|
+
*/
|
|
245
|
+
const unverifiedSnapshotRefusal = (streamName, settings, operation) => settings === undefined || (settings.mode === "inline" && settings.every === undefined)
|
|
246
|
+
? undefined
|
|
247
|
+
: `[EDD-9068] EventStream "${streamName}": ${operation} with verifySnapshot: false requires ` +
|
|
248
|
+
`snapshot mode "inline" without "every" — the only mode that writes the snapshot in every ` +
|
|
249
|
+
`append, so it is normally at the head. This stream's snapshot is mode "${settings.mode}"` +
|
|
250
|
+
`${settings.every === undefined ? "" : ` with every: ${settings.every}`}.`;
|
|
45
251
|
// ---------------------------------------------------------------------------
|
|
46
252
|
// makeStream factory
|
|
47
253
|
// ---------------------------------------------------------------------------
|
|
@@ -85,6 +291,10 @@ const EventStreamTypeId = Symbol.for("effect-dynamodb/EventStream");
|
|
|
85
291
|
* })
|
|
86
292
|
* ```
|
|
87
293
|
*
|
|
294
|
+
* `snapshot.mode: "inline"` writes the snapshot in the append transaction
|
|
295
|
+
* itself, so it is current after every command and state loads in one
|
|
296
|
+
* request — see {@link SnapshotMode}.
|
|
297
|
+
*
|
|
88
298
|
* `casing` overrides the key casing for this stream, as an index's or a vector
|
|
89
299
|
* index's `casing` does: the stream name, stream-id values and command ids in
|
|
90
300
|
* its keys all take it. When omitted, the stream name is lower-cased and the
|
|
@@ -95,7 +305,57 @@ const EventStreamTypeId = Symbol.for("effect-dynamodb/EventStream");
|
|
|
95
305
|
* (`<stream>.event` etc.) are always lower-cased. In the next major, omitting
|
|
96
306
|
* `casing` will mean the schema's casing, as it does for indexes.
|
|
97
307
|
*
|
|
308
|
+
* `indexes` declares sub-streams ordered by a key derived from each event
|
|
309
|
+
* (#140), on a local (default) or global secondary index. `append` writes each
|
|
310
|
+
* event's derived key (and, for a GSI, the stream's partition key) on the
|
|
311
|
+
* event item; snapshots and idempotency sentinels never carry them, so they
|
|
312
|
+
* are never in an index. Read with {@link EventStream.readIndex} or
|
|
313
|
+
* `query.index`, and create the indexes with {@link indexDefinitions}.
|
|
314
|
+
*
|
|
315
|
+
* Index attributes are written only at append time and events are never
|
|
316
|
+
* rewritten. Declaring an index on a stream that already holds events, or
|
|
317
|
+
* changing an index's `key`, therefore leaves the earlier events out of the
|
|
318
|
+
* index (or under their old keys), and `readIndex` returns only what was
|
|
319
|
+
* indexed — without an error. The library does not backfill; whether and how
|
|
320
|
+
* to re-index existing events (for example, an `UpdateItem` per event setting
|
|
321
|
+
* the derived attributes) is the application's decision.
|
|
322
|
+
*
|
|
323
|
+
* @example
|
|
324
|
+
* ```typescript
|
|
325
|
+
* const Entries = EventStore.makeStream({
|
|
326
|
+
* table: EventsTable,
|
|
327
|
+
* streamName: "Ledger",
|
|
328
|
+
* events: [EntryRecorded, EntryVoided],
|
|
329
|
+
* streamId: { composite: ["ledgerId"] },
|
|
330
|
+
* indexes: {
|
|
331
|
+
* byEntry: {
|
|
332
|
+
* index: "lsi1",
|
|
333
|
+
* sk: "lsi1sk",
|
|
334
|
+
* key: (event, version) =>
|
|
335
|
+
* event._tag === "EntryRecorded" ? `ENTRY#${event.section}#${pad(version)}` : undefined,
|
|
336
|
+
* },
|
|
337
|
+
* // Every event, by the day it was recorded (both event types carry `day`).
|
|
338
|
+
* byDay: { type: "gsi", index: "gsi1", pk: "gsi1pk", sk: "gsi1sk", key: (e) => e.day },
|
|
339
|
+
* },
|
|
340
|
+
* })
|
|
341
|
+
*
|
|
342
|
+
* const sectionThree = yield* Entries.readIndex("byEntry", { ledgerId }, { beginsWith: "ENTRY#3#" })
|
|
343
|
+
* ```
|
|
344
|
+
*
|
|
98
345
|
* @throws `[EDD-9027]` when `snapshot.every` is not a positive integer.
|
|
346
|
+
* @throws `[EDD-9062]` when `snapshot.mode` is neither `"after-append"` nor
|
|
347
|
+
* `"inline"`.
|
|
348
|
+
* @throws `[EDD-9063]` for a malformed index: a `type` other than `"lsi"` /
|
|
349
|
+
* `"gsi"`, a `gsi` without `pk`, an `lsi` with `pk`, an empty `index` / `sk`
|
|
350
|
+
* / `pk`, or a `key` that is not a function.
|
|
351
|
+
* @throws `[EDD-9064]` when an index attribute is one the stream writes itself
|
|
352
|
+
* (`pk`, `sk`, `__edd_e__`, `streamId`, `version`, `eventType`, `data`,
|
|
353
|
+
* `metadata`, `timestamp`, `asOfVersion`, `state`, `commandId`, or the
|
|
354
|
+
* default TTL attribute `_ttl`). A custom `TableConfig.ttlAttributeName`
|
|
355
|
+
* is only known at runtime: an index attribute equal to it fails each append
|
|
356
|
+
* with `ValidationError`.
|
|
357
|
+
* @throws `[EDD-9065]` when two indexes share a physical index name or an
|
|
358
|
+
* attribute (or a GSI's `pk` and `sk` are the same attribute).
|
|
99
359
|
*/
|
|
100
360
|
export const makeStream = (config) => {
|
|
101
361
|
const schema = config.table.schema;
|
|
@@ -127,7 +387,7 @@ export const makeStream = (config) => {
|
|
|
127
387
|
const commandKeyLabel = `${keyLabel}.command`;
|
|
128
388
|
const compositeFields = config.streamId.composite;
|
|
129
389
|
// -------------------------------------------------------------------------
|
|
130
|
-
// Snapshot config validation (EDD-9027) — fail fast at definition time.
|
|
390
|
+
// Snapshot config validation (EDD-9027, EDD-9062) — fail fast at definition time.
|
|
131
391
|
// -------------------------------------------------------------------------
|
|
132
392
|
const snapshot = config.snapshot;
|
|
133
393
|
if (snapshot !== undefined && snapshot.every !== undefined) {
|
|
@@ -136,7 +396,24 @@ export const makeStream = (config) => {
|
|
|
136
396
|
`received ${String(snapshot.every)}.`);
|
|
137
397
|
}
|
|
138
398
|
}
|
|
139
|
-
|
|
399
|
+
if (snapshot !== undefined &&
|
|
400
|
+
snapshot.mode !== undefined &&
|
|
401
|
+
snapshot.mode !== "after-append" &&
|
|
402
|
+
snapshot.mode !== "inline") {
|
|
403
|
+
throw new Error(`[EDD-9062] EventStream "${config.streamName}": snapshot.mode must be "after-append" or ` +
|
|
404
|
+
`"inline"; received ${JSON.stringify(snapshot.mode)}.`);
|
|
405
|
+
}
|
|
406
|
+
const snapshotSettings = snapshot === undefined
|
|
407
|
+
? undefined
|
|
408
|
+
: { mode: snapshot.mode ?? "after-append", every: snapshot.every };
|
|
409
|
+
// Stream indexes (#140) — validated at definition time (EDD-9063..9065).
|
|
410
|
+
const streamIndexes = resolveStreamIndexes(config.streamName, config.indexes);
|
|
411
|
+
const indexSettings = {};
|
|
412
|
+
for (const idx of streamIndexes) {
|
|
413
|
+
indexSettings[idx.name] = { type: idx.type, index: idx.index, pk: idx.pk, sk: idx.sk };
|
|
414
|
+
}
|
|
415
|
+
/** Every attribute the indexes write on an event item. */
|
|
416
|
+
const indexAttributes = streamIndexes.flatMap((idx) => idx.type === "gsi" ? [idx.pk, idx.sk] : [idx.sk]);
|
|
140
417
|
// Build union schema from event schemas for decoding
|
|
141
418
|
const eventUnion = config.events.length === 1
|
|
142
419
|
? config.events[0]
|
|
@@ -151,6 +428,8 @@ export const makeStream = (config) => {
|
|
|
151
428
|
return DynamoSchema.composeKey(schema, keyLabel, composites, keyOptions);
|
|
152
429
|
};
|
|
153
430
|
const composeEventSk = (version) => DynamoSchema.composeEventVersionKey(schema, eventKeyLabel, version, keyOptions);
|
|
431
|
+
/** Sort key of a command's idempotency sentinel. */
|
|
432
|
+
const composeCommandSk = (commandId) => DynamoSchema.composeKey(schema, commandKeyLabel, [commandId], keyOptions);
|
|
154
433
|
/**
|
|
155
434
|
* Every event SK begins with this; nothing else in the stream partition does.
|
|
156
435
|
* Bounding event reads to it excludes the snapshot item at the key-condition
|
|
@@ -164,6 +443,13 @@ export const makeStream = (config) => {
|
|
|
164
443
|
* The snapshot SK. Distinct entity-type label (`<stream>.snapshot` vs
|
|
165
444
|
* `<stream>.event_1#…`), so it can never collide with an event SK, and it
|
|
166
445
|
* sorts after every event in the partition.
|
|
446
|
+
*
|
|
447
|
+
* The three stream-owned SKs share `<prefix>#<label>.` and differ first at
|
|
448
|
+
* the literal suffix: `.command#<id>` < `.event_1#<version>` < `.snapshot`.
|
|
449
|
+
* The suffixes are lower-case, and a stream `casing` applies to all three
|
|
450
|
+
* alike (`"uppercase"` gives `.COMMAND` < `.EVENT_1` < `.SNAPSHOT`), so the
|
|
451
|
+
* order holds under every casing. `readLatest` relies on it: the range
|
|
452
|
+
* `[eventSkPrefix, snapshotSk]` holds exactly the events and the snapshot.
|
|
167
453
|
*/
|
|
168
454
|
const snapshotSk = DynamoSchema.composeKey(schema, snapshotKeyLabel, [], keyOptions);
|
|
169
455
|
const composeStreamIdString = (streamId) => compositeFields.map((f) => streamId[f]).join("#");
|
|
@@ -191,10 +477,10 @@ export const makeStream = (config) => {
|
|
|
191
477
|
* `decode → encode` fallback for inputs already in encoded shape. Mirrors
|
|
192
478
|
* the Entity write path (`encodeOrDecodeEncode` in `Entity.ts`).
|
|
193
479
|
*/
|
|
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)),
|
|
480
|
+
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
481
|
// Surface the original encode error — its message is keyed on the
|
|
196
482
|
// caller's input shape, which is what the user expects to see.
|
|
197
|
-
Effect.catch(() => Effect.fail(new ValidationError({ entityType, operation, cause: primaryCause }))))));
|
|
483
|
+
Effect.catch(() => Effect.fail(new ValidationError({ entityType: errorEntityType, operation, cause: primaryCause }))))));
|
|
198
484
|
// ---------------------------------------------------------------------------
|
|
199
485
|
// Decode a raw DynamoDB item → StreamEvent<TEvent>
|
|
200
486
|
// ---------------------------------------------------------------------------
|
|
@@ -225,23 +511,143 @@ export const makeStream = (config) => {
|
|
|
225
511
|
// ---------------------------------------------------------------------------
|
|
226
512
|
// append
|
|
227
513
|
// ---------------------------------------------------------------------------
|
|
514
|
+
/**
|
|
515
|
+
* An event's key in a stream index (#140): a non-empty string, or
|
|
516
|
+
* `undefined` to leave the event out. Anything else — an empty string
|
|
517
|
+
* (DynamoDB rejects empty key values), another type, a `key` that throws —
|
|
518
|
+
* is a `ValidationError`, raised before anything is written.
|
|
519
|
+
*/
|
|
520
|
+
const deriveIndexKey = (idx, event, version) => Effect.try({
|
|
521
|
+
try: () => idx.key(event, version),
|
|
522
|
+
catch: (cause) => new ValidationError({ entityType, operation: "EventStore.append.indexes", cause }),
|
|
523
|
+
}).pipe(Effect.flatMap((derived) => {
|
|
524
|
+
if (derived === undefined)
|
|
525
|
+
return Effect.succeed(undefined);
|
|
526
|
+
const refuse = (problem) => Effect.fail(new ValidationError({
|
|
527
|
+
entityType,
|
|
528
|
+
operation: "EventStore.append.indexes",
|
|
529
|
+
cause: `The key of index "${idx.name}" for the event at version ${version} ${problem}. ` +
|
|
530
|
+
"Nothing was written.",
|
|
531
|
+
}));
|
|
532
|
+
if (typeof derived !== "string" || derived === "") {
|
|
533
|
+
return refuse("must be a non-empty string or undefined; received " +
|
|
534
|
+
(typeof derived === "string" ? "an empty string" : String(derived)));
|
|
535
|
+
}
|
|
536
|
+
if (utf8.encode(derived).length > MAX_INDEX_SORT_KEY_BYTES) {
|
|
537
|
+
return refuse(`is longer than DynamoDB's ${MAX_INDEX_SORT_KEY_BYTES}-byte limit for a sort key`);
|
|
538
|
+
}
|
|
539
|
+
return Effect.succeed(derived);
|
|
540
|
+
}));
|
|
541
|
+
/**
|
|
542
|
+
* Encode snapshot state to wire form through the state schema, with the
|
|
543
|
+
* same `decode → encode` fallback as events and metadata. A structurally
|
|
544
|
+
* matching plain object — what an immutable `evolve` that spreads a
|
|
545
|
+
* `Schema.Class` state returns (`({ ...s, balance })`), which TypeScript
|
|
546
|
+
* accepts as the class type — is decoded into the schema's type first
|
|
547
|
+
* rather than refused with `Expected <Class>`.
|
|
548
|
+
*/
|
|
549
|
+
const encodeSnapshotState = (state, operation) => encodeToWire(snapshot.schema, state, operation, snapshotEntityType);
|
|
550
|
+
/**
|
|
551
|
+
* Map a failed `TransactWriteItems` of an append to its verdict. Resolves to
|
|
552
|
+
* the race a guarded additional put lost (so the transaction is built and
|
|
553
|
+
* written again), and fails with every other verdict.
|
|
554
|
+
*/
|
|
555
|
+
const sendAppendTransaction = (client, tx, streamIdStr, commandId) => client.transactWriteItems({ TransactItems: tx.items }).pipe(Effect.as(undefined), Effect.catch((error) => {
|
|
556
|
+
if (!isAwsTransactionCancelled(error.cause)) {
|
|
557
|
+
return Effect.fail(error);
|
|
558
|
+
}
|
|
559
|
+
const rawReasons = error.cause.CancellationReasons ?? [];
|
|
560
|
+
const reasons = rawReasons.map((r) => ({
|
|
561
|
+
code: r?.Code,
|
|
562
|
+
message: r?.Message,
|
|
563
|
+
}));
|
|
564
|
+
const failedAt = (index) => index >= 0 && reasons[index]?.code === "ConditionalCheckFailed";
|
|
565
|
+
// Precedence is ordered by how terminal the caller's response should
|
|
566
|
+
// be: a duplicate can never succeed on retry, a version conflict
|
|
567
|
+
// invites a re-read, and only then are the additional items judged.
|
|
568
|
+
if (commandId !== undefined && failedAt(tx.sentinelIndex)) {
|
|
569
|
+
return Effect.fail(new DuplicateCommand({
|
|
570
|
+
streamName: config.streamName,
|
|
571
|
+
streamId: streamIdStr,
|
|
572
|
+
commandId,
|
|
573
|
+
}));
|
|
574
|
+
}
|
|
575
|
+
// The contiguity ConditionCheck and the event puts both mean "the
|
|
576
|
+
// stream is not where you said it was", so they share one verdict.
|
|
577
|
+
for (let i = 0; i < tx.guardCount + tx.eventCount; i++) {
|
|
578
|
+
if (failedAt(i)) {
|
|
579
|
+
return Effect.fail(new VersionConflict({
|
|
580
|
+
streamName: config.streamName,
|
|
581
|
+
streamId: streamIdStr,
|
|
582
|
+
expectedVersion: tx.expectedVersion,
|
|
583
|
+
}));
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// The additional items, attributed back to the caller OPS that
|
|
587
|
+
// produced them (several items can belong to one op). Only an op's
|
|
588
|
+
// own condition is `AdditionalItemConditionFailed`: a guarded put
|
|
589
|
+
// the caller set no condition on reports what the entity's own put
|
|
590
|
+
// would — a taken unique value, a history conflict — or, having
|
|
591
|
+
// lost a race to a concurrent write, is written again (#133).
|
|
592
|
+
const judged = judgeCancellation(tx.additional, rawReasons, tx.guardCount + tx.eventCount);
|
|
593
|
+
if (judged?._tag === "fail")
|
|
594
|
+
return Effect.fail(judged.error);
|
|
595
|
+
if (judged?._tag === "conditions") {
|
|
596
|
+
return Effect.fail(new AdditionalItemConditionFailed({
|
|
597
|
+
streamName: config.streamName,
|
|
598
|
+
streamId: streamIdStr,
|
|
599
|
+
indices: judged.opIndices,
|
|
600
|
+
reasons,
|
|
601
|
+
}));
|
|
602
|
+
}
|
|
603
|
+
if (judged?._tag === "retry")
|
|
604
|
+
return Effect.succeed(judged.error);
|
|
605
|
+
// No conditional failure we can positionally justify (throttling,
|
|
606
|
+
// TransactionConflict, a reason at the inline snapshot's position, or
|
|
607
|
+
// a truncated/absent reason list) — never guess a VersionConflict.
|
|
608
|
+
return Effect.fail(new TransactionCancelled({
|
|
609
|
+
operation: "TransactWriteItems",
|
|
610
|
+
reasons,
|
|
611
|
+
cause: error.cause,
|
|
612
|
+
}));
|
|
613
|
+
}));
|
|
228
614
|
const append = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
|
|
229
615
|
const additionalOps = options?.additionalItems ?? [];
|
|
230
616
|
const idempotency = options?.idempotency;
|
|
617
|
+
const snapshotState = options?.snapshot;
|
|
618
|
+
if (snapshotState !== undefined && snapshot === undefined) {
|
|
619
|
+
return yield* snapshotUnavailable("append({ snapshot })");
|
|
620
|
+
}
|
|
231
621
|
// Nothing at all to write — preserve the historical no-op fast path.
|
|
232
622
|
// With additional items or a dedup sentinel the transaction still runs:
|
|
233
623
|
// a caller who asked for a side write means it, and silently dropping it
|
|
234
624
|
// would lose data.
|
|
235
|
-
if (events.length === 0 &&
|
|
625
|
+
if (events.length === 0 &&
|
|
626
|
+
additionalOps.length === 0 &&
|
|
627
|
+
idempotency === undefined &&
|
|
628
|
+
snapshotState === undefined) {
|
|
236
629
|
return { version: expectedVersion, events: [] };
|
|
237
630
|
}
|
|
238
631
|
// Resolve stream ID string for storage (join composites)
|
|
239
632
|
const streamIdStr = composeStreamIdString(streamId);
|
|
633
|
+
// An inline snapshot is unconditional because the event puts prove this
|
|
634
|
+
// writer owns its version. Without events nothing proves it, and an
|
|
635
|
+
// unconditional put could regress the snapshot.
|
|
636
|
+
if (snapshotState !== undefined && events.length === 0) {
|
|
637
|
+
return yield* new ValidationError({
|
|
638
|
+
entityType: snapshotEntityType,
|
|
639
|
+
operation: "EventStore.append.snapshot",
|
|
640
|
+
cause: "An inline snapshot requires at least one event: nothing else proves the writer " +
|
|
641
|
+
`owns version ${expectedVersion}. Nothing was written.`,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
240
644
|
// Guard: every item the transaction will carry — one Put per event, the
|
|
241
645
|
// items each caller-supplied additional op compiles to, the idempotency
|
|
242
|
-
// sentinel, and the version-contiguity
|
|
243
|
-
// expectedVersion > 0 — must fit DynamoDB's
|
|
244
|
-
// Never
|
|
646
|
+
// sentinel, the inline snapshot, and the version-contiguity
|
|
647
|
+
// ConditionCheck when expectedVersion > 0 — must fit DynamoDB's
|
|
648
|
+
// TransactWriteItems limit. Never split: splitting would break append
|
|
649
|
+
// atomicity (a large command is stepped by the application instead — one
|
|
650
|
+
// command, one decision, one atomic append).
|
|
245
651
|
//
|
|
246
652
|
// This first check is a LOWER BOUND, counting one item per additional op.
|
|
247
653
|
// Expansion (uniqueness sentinels, version snapshots — #113) only ever
|
|
@@ -249,7 +655,10 @@ export const makeStream = (config) => {
|
|
|
249
655
|
// failing now keeps an oversized append free. The authoritative check runs
|
|
250
656
|
// once the ops are compiled, below.
|
|
251
657
|
const needsContiguityCheck = expectedVersion > 0 && events.length > 0;
|
|
252
|
-
const fixedItems = events.length +
|
|
658
|
+
const fixedItems = events.length +
|
|
659
|
+
(idempotency !== undefined ? 1 : 0) +
|
|
660
|
+
(snapshotState !== undefined ? 1 : 0) +
|
|
661
|
+
(needsContiguityCheck ? 1 : 0);
|
|
253
662
|
if (fixedItems + additionalOps.length > TRANSACT_WRITE_ITEMS_LIMIT) {
|
|
254
663
|
return yield* new AppendTooLarge({
|
|
255
664
|
streamName: config.streamName,
|
|
@@ -261,6 +670,19 @@ export const makeStream = (config) => {
|
|
|
261
670
|
const client = yield* DynamoClient;
|
|
262
671
|
const tableConfig = yield* config.table.Tag;
|
|
263
672
|
const tableName = tableConfig.name;
|
|
673
|
+
// A custom TTL attribute is only known at runtime. An index key attribute
|
|
674
|
+
// sharing its name would receive the sentinel's numeric TTL, which a
|
|
675
|
+
// string-typed index key rejects — refuse it before anything is written.
|
|
676
|
+
const ttlAttribute = resolveTtlAttributeName(tableConfig);
|
|
677
|
+
if (indexAttributes.includes(ttlAttribute)) {
|
|
678
|
+
return yield* new ValidationError({
|
|
679
|
+
entityType,
|
|
680
|
+
operation: "EventStore.append.indexes",
|
|
681
|
+
cause: `An index of stream "${config.streamName}" uses the attribute "${ttlAttribute}", ` +
|
|
682
|
+
"which is the table's TTL attribute. Choose another index attribute name. " +
|
|
683
|
+
"Nothing was written.",
|
|
684
|
+
});
|
|
685
|
+
}
|
|
264
686
|
const pk = composeStreamPk(streamId);
|
|
265
687
|
// Clock-backed timestamp (deterministic under TestClock; wall-clock in prod).
|
|
266
688
|
const nowDateTime = yield* DateTime.now;
|
|
@@ -302,6 +724,17 @@ export const makeStream = (config) => {
|
|
|
302
724
|
if (encodedMetadata !== undefined) {
|
|
303
725
|
item.metadata = encodedMetadata;
|
|
304
726
|
}
|
|
727
|
+
// Stream indexes (#140): the derived key, stored raw, and for a
|
|
728
|
+
// GSI the stream's partition key. The attributes are part of the
|
|
729
|
+
// event item itself, so they count towards the size checks.
|
|
730
|
+
for (const idx of streamIndexes) {
|
|
731
|
+
const derived = yield* deriveIndexKey(idx, event, version);
|
|
732
|
+
if (derived === undefined)
|
|
733
|
+
continue;
|
|
734
|
+
item[idx.sk] = derived;
|
|
735
|
+
if (idx.type === "gsi")
|
|
736
|
+
item[idx.pk] = pk;
|
|
737
|
+
}
|
|
305
738
|
return {
|
|
306
739
|
Put: {
|
|
307
740
|
TableName: tableName,
|
|
@@ -310,6 +743,126 @@ export const makeStream = (config) => {
|
|
|
310
743
|
},
|
|
311
744
|
};
|
|
312
745
|
}));
|
|
746
|
+
// The idempotency sentinel: a Put guarded by `attribute_not_exists(pk)`.
|
|
747
|
+
let sentinelPut;
|
|
748
|
+
if (idempotency !== undefined) {
|
|
749
|
+
const sentinel = {
|
|
750
|
+
pk,
|
|
751
|
+
sk: composeCommandSk(idempotency.commandId),
|
|
752
|
+
__edd_e__: commandEntityType,
|
|
753
|
+
streamId: streamIdStr,
|
|
754
|
+
commandId: idempotency.commandId,
|
|
755
|
+
version: expectedVersion + events.length,
|
|
756
|
+
timestamp: now,
|
|
757
|
+
};
|
|
758
|
+
if (idempotency.ttl !== undefined) {
|
|
759
|
+
const ttlSeconds = yield* Effect.try({
|
|
760
|
+
try: () => normalizeTtlSeconds(idempotency.ttl),
|
|
761
|
+
catch: (cause) => new ValidationError({
|
|
762
|
+
entityType: commandEntityType,
|
|
763
|
+
operation: "EventStore.append.idempotency.ttl",
|
|
764
|
+
cause,
|
|
765
|
+
}),
|
|
766
|
+
});
|
|
767
|
+
sentinel[resolveTtlAttributeName(tableConfig)] =
|
|
768
|
+
DateTime.toEpochSeconds(nowDateTime) + ttlSeconds;
|
|
769
|
+
}
|
|
770
|
+
sentinelPut = {
|
|
771
|
+
Put: {
|
|
772
|
+
TableName: tableName,
|
|
773
|
+
Item: toAttributeMap(sentinel),
|
|
774
|
+
ConditionExpression: "attribute_not_exists(pk)",
|
|
775
|
+
},
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
// The inline snapshot (#138): unconditional — see AppendOptions.snapshot.
|
|
779
|
+
let snapshotPut;
|
|
780
|
+
if (snapshotState !== undefined) {
|
|
781
|
+
const encoded = yield* encodeSnapshotState(snapshotState, "EventStore.append.snapshot");
|
|
782
|
+
snapshotPut = {
|
|
783
|
+
Put: {
|
|
784
|
+
TableName: tableName,
|
|
785
|
+
Item: toAttributeMap({
|
|
786
|
+
pk,
|
|
787
|
+
sk: snapshotSk,
|
|
788
|
+
__edd_e__: snapshotEntityType,
|
|
789
|
+
streamId: streamIdStr,
|
|
790
|
+
asOfVersion: expectedVersion + events.length,
|
|
791
|
+
state: encoded,
|
|
792
|
+
timestamp: now,
|
|
793
|
+
}),
|
|
794
|
+
},
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Version-contiguity guard: `attribute_not_exists(pk)` on the event puts
|
|
799
|
+
* only rejects STALE expected versions (the target slot already exists).
|
|
800
|
+
* An AHEAD expectedVersion (e.g. 10 when the stream is at 3) would
|
|
801
|
+
* silently write from version 11, leaving a permanent gap. When events
|
|
802
|
+
* are written after version > 0, require the event at exactly
|
|
803
|
+
* `expectedVersion` to exist so the appended range is contiguous with the
|
|
804
|
+
* stream head. Its failure surfaces as a ConditionalCheckFailed
|
|
805
|
+
* cancellation reason, mapping to VersionConflict just like a
|
|
806
|
+
* stale-version Put failure. A zero-event append (a pure side write)
|
|
807
|
+
* writes no version and so can open no gap.
|
|
808
|
+
*/
|
|
809
|
+
const contiguityCheck = needsContiguityCheck
|
|
810
|
+
? {
|
|
811
|
+
ConditionCheck: {
|
|
812
|
+
TableName: tableName,
|
|
813
|
+
Key: toAttributeMap({ pk, sk: composeEventSk(expectedVersion) }),
|
|
814
|
+
ConditionExpression: "attribute_exists(pk)",
|
|
815
|
+
},
|
|
816
|
+
}
|
|
817
|
+
: undefined;
|
|
818
|
+
/**
|
|
819
|
+
* Lay out the append's one transaction — see {@link AppendTransaction}:
|
|
820
|
+
* [0, C) version-contiguity ConditionCheck (C is 0 or 1)
|
|
821
|
+
* [C, C + E) event puts
|
|
822
|
+
* [C + E, C + E + A) additional ITEMS (caller op order preserved)
|
|
823
|
+
* then idempotency sentinel, then inline snapshot
|
|
824
|
+
* (last, so adding them never shifts the
|
|
825
|
+
* additional-item indices the caller sees)
|
|
826
|
+
*
|
|
827
|
+
* `A` is the count of EMITTED items, which is >= the number of caller
|
|
828
|
+
* ops: a guarded put (#133) expands into its item plus its sentinel
|
|
829
|
+
* reservations and releases plus a snapshot. The caller-facing `indices`
|
|
830
|
+
* on `AdditionalItemConditionFailed` are still indices into the caller's
|
|
831
|
+
* `additionalItems` array — `judgeCancellation` maps them.
|
|
832
|
+
*/
|
|
833
|
+
const layout = (additional) => {
|
|
834
|
+
const items = [];
|
|
835
|
+
const targets = [];
|
|
836
|
+
const pushStreamItem = (item, type, source) => {
|
|
837
|
+
items.push(item);
|
|
838
|
+
targets.push(transactItemTarget(item, tableName, ["pk", "sk"], type, source));
|
|
839
|
+
};
|
|
840
|
+
if (contiguityCheck !== undefined) {
|
|
841
|
+
pushStreamItem(contiguityCheck, entityType, "the version-contiguity check");
|
|
842
|
+
}
|
|
843
|
+
const guardCount = items.length;
|
|
844
|
+
for (const [i, item] of eventItems.entries()) {
|
|
845
|
+
pushStreamItem(item, entityType, `the event at version ${expectedVersion + i + 1}`);
|
|
846
|
+
}
|
|
847
|
+
items.push(...additional.items);
|
|
848
|
+
targets.push(...additional.targets);
|
|
849
|
+
const sentinelIndex = sentinelPut === undefined ? -1 : items.length;
|
|
850
|
+
if (sentinelPut !== undefined) {
|
|
851
|
+
pushStreamItem(sentinelPut, entityType, "the idempotency sentinel");
|
|
852
|
+
}
|
|
853
|
+
if (snapshotPut !== undefined) {
|
|
854
|
+
pushStreamItem(snapshotPut, snapshotEntityType, "the inline snapshot");
|
|
855
|
+
}
|
|
856
|
+
return {
|
|
857
|
+
items,
|
|
858
|
+
targets,
|
|
859
|
+
guardCount,
|
|
860
|
+
eventCount: eventItems.length,
|
|
861
|
+
additional,
|
|
862
|
+
sentinelIndex,
|
|
863
|
+
expectedVersion,
|
|
864
|
+
};
|
|
865
|
+
};
|
|
313
866
|
// A guarded put among the additional items reads its item; a race with
|
|
314
867
|
// that read cancels the append, which is then built and written again.
|
|
315
868
|
let lost;
|
|
@@ -317,174 +870,29 @@ export const makeStream = (config) => {
|
|
|
317
870
|
// Caller-owned items, compiled through the same builder
|
|
318
871
|
// `Transaction.transactWrite` uses, so the two APIs cannot drift.
|
|
319
872
|
const additional = yield* buildTransactWriteItems(additionalOps, "EventStore.append.additionalItems");
|
|
320
|
-
const
|
|
321
|
-
// Authoritative cap check: one additional op can compile to several
|
|
322
|
-
// (#113), so the pre-flight lower bound above is not sufficient.
|
|
323
|
-
// the EXPANDED count is the point — "you passed 40 items"
|
|
324
|
-
// passed 30 ops is baffling without it.
|
|
325
|
-
if (
|
|
873
|
+
const tx = layout(additional);
|
|
874
|
+
// Authoritative cap check: one additional op can compile to several
|
|
875
|
+
// items (#113), so the pre-flight lower bound above is not sufficient.
|
|
876
|
+
// Reporting the EXPANDED count is the point — "you passed 40 items"
|
|
877
|
+
// when the caller passed 30 ops is baffling without it.
|
|
878
|
+
if (tx.items.length > TRANSACT_WRITE_ITEMS_LIMIT) {
|
|
326
879
|
return yield* new AppendTooLarge({
|
|
327
880
|
streamName: config.streamName,
|
|
328
881
|
streamId: streamIdStr,
|
|
329
|
-
count:
|
|
882
|
+
count: tx.items.length,
|
|
330
883
|
limit: TRANSACT_WRITE_ITEMS_LIMIT,
|
|
331
884
|
});
|
|
332
885
|
}
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
}));
|
|
886
|
+
// Checked before anything is sent: one op per item — an additional
|
|
887
|
+
// item repeating an event, the contiguity check, the idempotency
|
|
888
|
+
// sentinel or the inline snapshot is refused, as additional items
|
|
889
|
+
// repeating each other are — and the transaction within DynamoDB's
|
|
890
|
+
// 4 MB (#133).
|
|
891
|
+
yield* refuseRepeatedItems(tx.targets, "EventStore.append");
|
|
892
|
+
yield* refuseOversizedTransaction(tx.items, tx.targets, "EventStore.append");
|
|
893
|
+
const outcome = yield* sendAppendTransaction(client, tx, streamIdStr, idempotency?.commandId);
|
|
483
894
|
if (outcome === undefined) {
|
|
484
|
-
return {
|
|
485
|
-
version: expectedVersion + events.length,
|
|
486
|
-
events,
|
|
487
|
-
};
|
|
895
|
+
return { version: expectedVersion + events.length, events };
|
|
488
896
|
}
|
|
489
897
|
lost = outcome;
|
|
490
898
|
}
|
|
@@ -493,37 +901,49 @@ export const makeStream = (config) => {
|
|
|
493
901
|
// ---------------------------------------------------------------------------
|
|
494
902
|
// read
|
|
495
903
|
// ---------------------------------------------------------------------------
|
|
496
|
-
|
|
497
|
-
|
|
904
|
+
/** Apply {@link ReadOptions} to an event query. */
|
|
905
|
+
const withReadOptions = (query, options) => options?.consistentRead === true ? Query.consistentRead(query) : query;
|
|
906
|
+
const read = (streamId, options) => Effect.gen(function* () {
|
|
907
|
+
const query = withReadOptions(buildEventsQuery(streamId), options);
|
|
498
908
|
return yield* Query.collect(query);
|
|
499
909
|
});
|
|
500
910
|
// ---------------------------------------------------------------------------
|
|
501
911
|
// readFrom
|
|
502
912
|
// ---------------------------------------------------------------------------
|
|
503
|
-
const readFrom = (streamId, afterVersion) => Effect.gen(function* () {
|
|
913
|
+
const readFrom = (streamId, afterVersion, options) => Effect.gen(function* () {
|
|
504
914
|
// Versions are integers, so the inclusive lower bound `afterVersion + 1`
|
|
505
915
|
// is exactly the old exclusive `#sk > eventSk(afterVersion)`. The upper
|
|
506
916
|
// bound keeps the snapshot item (which sorts after every event) out of the
|
|
507
917
|
// scanned range.
|
|
508
|
-
const query = buildEventsQuery(streamId).pipe(Query.where({ between: [composeEventSk(afterVersion + 1), maxEventSk] }));
|
|
918
|
+
const query = withReadOptions(buildEventsQuery(streamId).pipe(Query.where({ between: [composeEventSk(afterVersion + 1), maxEventSk] })), options);
|
|
509
919
|
return yield* Query.collect(query);
|
|
510
920
|
});
|
|
511
921
|
// ---------------------------------------------------------------------------
|
|
512
922
|
// currentVersion
|
|
513
923
|
// ---------------------------------------------------------------------------
|
|
514
|
-
const currentVersion = (streamId) => Effect.gen(function* () {
|
|
924
|
+
const currentVersion = (streamId, options) => Effect.gen(function* () {
|
|
515
925
|
// Single page, not `collect`: with `Limit: 1` DynamoDB returns a
|
|
516
926
|
// `LastEvaluatedKey` on every truncated page, so `collect` would walk the
|
|
517
927
|
// whole partition one request per item. The `begins_with` bound on
|
|
518
928
|
// `buildEventsQuery` guarantees the single evaluated item is the newest
|
|
519
929
|
// *event* (never the snapshot, which sorts last).
|
|
520
|
-
const query = buildEventsQuery(streamId).pipe(Query.reverse, Query.limit(1));
|
|
930
|
+
const query = withReadOptions(buildEventsQuery(streamId).pipe(Query.reverse, Query.limit(1)), options);
|
|
521
931
|
const page = yield* Query.execute(query);
|
|
522
932
|
const newest = page.items[0];
|
|
523
933
|
if (newest === undefined)
|
|
524
934
|
return 0;
|
|
525
935
|
return newest.version;
|
|
526
936
|
});
|
|
937
|
+
// headAbove (internal — see HeadAboveProbe)
|
|
938
|
+
const headAbove = (streamId, floorVersion, options) => Effect.gen(function* () {
|
|
939
|
+
// Reverse, `Limit: 1`, bounded below by `floorVersion + 1`: an empty range
|
|
940
|
+
// (the common case — nothing appended past the snapshot) reads no items;
|
|
941
|
+
// otherwise the single evaluated item is the head.
|
|
942
|
+
const query = withReadOptions(buildEventsQuery(streamId).pipe(Query.where({ between: [composeEventSk(floorVersion + 1), maxEventSk] }), Query.reverse, Query.limit(1)), options);
|
|
943
|
+
const page = yield* Query.execute(query);
|
|
944
|
+
const newest = page.items[0];
|
|
945
|
+
return newest === undefined ? floorVersion : newest.version;
|
|
946
|
+
});
|
|
527
947
|
// ---------------------------------------------------------------------------
|
|
528
948
|
// Snapshot primitives
|
|
529
949
|
// ---------------------------------------------------------------------------
|
|
@@ -535,11 +955,7 @@ export const makeStream = (config) => {
|
|
|
535
955
|
const client = yield* DynamoClient;
|
|
536
956
|
const { name: tableName } = yield* config.table.Tag;
|
|
537
957
|
const now = DateTime.formatIso(yield* DateTime.now);
|
|
538
|
-
const encoded = yield*
|
|
539
|
-
entityType: snapshotEntityType,
|
|
540
|
-
operation: "EventStore.writeSnapshot",
|
|
541
|
-
cause,
|
|
542
|
-
})));
|
|
958
|
+
const encoded = yield* encodeSnapshotState(state, "EventStore.writeSnapshot");
|
|
543
959
|
const item = {
|
|
544
960
|
pk: composeStreamPk(streamId),
|
|
545
961
|
sk: snapshotSk,
|
|
@@ -561,9 +977,17 @@ export const makeStream = (config) => {
|
|
|
561
977
|
})
|
|
562
978
|
.pipe(Effect.catchIf((error) => isAwsConditionalCheckFailed(error.cause), () => Effect.void));
|
|
563
979
|
});
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
980
|
+
/**
|
|
981
|
+
* Decode a stored snapshot item. A snapshot that fails to decode through the
|
|
982
|
+
* state schema is a `ValidationError` — never silently discarded.
|
|
983
|
+
*/
|
|
984
|
+
const decodeSnapshotItem = (raw, operation) => Schema.decodeUnknownEffect(snapshot.schema)(raw.state).pipe(Effect.mapError((cause) => new ValidationError({ entityType: snapshotEntityType, operation, cause })), Effect.map((state) => ({
|
|
985
|
+
state,
|
|
986
|
+
asOfVersion: raw.asOfVersion,
|
|
987
|
+
timestamp: raw.timestamp,
|
|
988
|
+
})));
|
|
989
|
+
/** The raw snapshot item, by its exact key, in one `GetItem`. */
|
|
990
|
+
const getSnapshotItem = (streamId, consistentRead) => Effect.gen(function* () {
|
|
567
991
|
const client = yield* DynamoClient;
|
|
568
992
|
const { name: tableName } = yield* config.table.Tag;
|
|
569
993
|
const result = yield* client.getItem({
|
|
@@ -572,21 +996,140 @@ export const makeStream = (config) => {
|
|
|
572
996
|
pk: composeStreamPk(streamId),
|
|
573
997
|
sk: snapshotSk,
|
|
574
998
|
}),
|
|
575
|
-
ConsistentRead: true,
|
|
999
|
+
...(consistentRead ? { ConsistentRead: true } : {}),
|
|
576
1000
|
});
|
|
577
|
-
|
|
1001
|
+
return result.Item === undefined ? undefined : fromAttributeMap(result.Item);
|
|
1002
|
+
});
|
|
1003
|
+
const readSnapshot = (streamId) => Effect.gen(function* () {
|
|
1004
|
+
if (snapshot === undefined)
|
|
1005
|
+
return yield* snapshotUnavailable("readSnapshot");
|
|
1006
|
+
const raw = yield* getSnapshotItem(streamId, true);
|
|
1007
|
+
if (raw === undefined)
|
|
578
1008
|
return Option.none();
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
1009
|
+
return Option.some(yield* decodeSnapshotItem(raw, "EventStore.readSnapshot"));
|
|
1010
|
+
});
|
|
1011
|
+
// ---------------------------------------------------------------------------
|
|
1012
|
+
// readLatest — snapshot + delta + head in one request (#138)
|
|
1013
|
+
// ---------------------------------------------------------------------------
|
|
1014
|
+
const readLatest = (streamId, options) => Effect.gen(function* () {
|
|
1015
|
+
// Without a snapshot config there is nothing to read but the events
|
|
1016
|
+
// (`verifySnapshot` is ignored).
|
|
1017
|
+
if (snapshot === undefined) {
|
|
1018
|
+
const events = yield* read(streamId, options);
|
|
1019
|
+
return {
|
|
1020
|
+
snapshot: Option.none(),
|
|
1021
|
+
events,
|
|
1022
|
+
version: events[events.length - 1]?.version ?? 0,
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
// `verifySnapshot: false` — the snapshot item alone, by its exact key:
|
|
1026
|
+
// a `GetItem` reads one item where the verified `Query` reads the
|
|
1027
|
+
// snapshot and at least the newest event. Its version is unverified
|
|
1028
|
+
// (see `ReadLatestOptions`). Without a snapshot item there is nothing to
|
|
1029
|
+
// trust, so fall through to the verified read.
|
|
1030
|
+
if (options?.verifySnapshot === false) {
|
|
1031
|
+
const refusal = unverifiedSnapshotRefusal(config.streamName, snapshotSettings, "readLatest");
|
|
1032
|
+
if (refusal !== undefined)
|
|
1033
|
+
return yield* Effect.die(new Error(refusal));
|
|
1034
|
+
const raw = yield* getSnapshotItem(streamId, options.consistentRead === true);
|
|
1035
|
+
if (raw !== undefined) {
|
|
1036
|
+
const current = yield* decodeSnapshotItem(raw, "EventStore.readLatest");
|
|
1037
|
+
return {
|
|
1038
|
+
snapshot: Option.some(current),
|
|
1039
|
+
events: [],
|
|
1040
|
+
version: current.asOfVersion,
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
const client = yield* DynamoClient;
|
|
1045
|
+
const { name: tableName } = yield* config.table.Tag;
|
|
1046
|
+
// One reverse Query over `[first event SK, snapshot SK]`: the snapshot
|
|
1047
|
+
// sorts after every event and the command sentinels before them (see
|
|
1048
|
+
// `snapshotSk`), so the range holds exactly the events and the snapshot,
|
|
1049
|
+
// and the snapshot is the first item evaluated. The `__edd_e__` filter is
|
|
1050
|
+
// belt and braces — `Limit` counts items before it applies.
|
|
1051
|
+
const base = {
|
|
1052
|
+
TableName: tableName,
|
|
1053
|
+
KeyConditionExpression: "#pk = :pk AND #sk BETWEEN :first AND :snapshot",
|
|
1054
|
+
FilterExpression: "#e IN (:eventType, :snapshotType)",
|
|
1055
|
+
ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk", "#e": "__edd_e__" },
|
|
1056
|
+
ExpressionAttributeValues: toAttributeMap({
|
|
1057
|
+
":pk": composeStreamPk(streamId),
|
|
1058
|
+
":first": eventSkPrefix,
|
|
1059
|
+
":snapshot": snapshotSk,
|
|
1060
|
+
":eventType": entityType,
|
|
1061
|
+
":snapshotType": snapshotEntityType,
|
|
1062
|
+
}),
|
|
1063
|
+
ScanIndexForward: false,
|
|
1064
|
+
...(options?.consistentRead === true ? { ConsistentRead: true } : {}),
|
|
1065
|
+
};
|
|
1066
|
+
let snapshotRaw;
|
|
1067
|
+
/** Event items, newest first. */
|
|
1068
|
+
const eventRaws = [];
|
|
1069
|
+
// The first page is sized for the snapshot plus a lag of up to `every`
|
|
1070
|
+
// events, so a current (or modestly lagging) snapshot loads in one
|
|
1071
|
+
// request.
|
|
1072
|
+
let limit = (snapshot.every ?? 1) + 1;
|
|
1073
|
+
let startKey;
|
|
1074
|
+
for (;;) {
|
|
1075
|
+
const page = yield* client.query({
|
|
1076
|
+
...base,
|
|
1077
|
+
...(limit !== undefined ? { Limit: limit } : {}),
|
|
1078
|
+
...(startKey !== undefined ? { ExclusiveStartKey: startKey } : {}),
|
|
1079
|
+
});
|
|
1080
|
+
for (const item of page.Items ?? []) {
|
|
1081
|
+
const raw = fromAttributeMap(item);
|
|
1082
|
+
if (raw.__edd_e__ === snapshotEntityType)
|
|
1083
|
+
snapshotRaw = raw;
|
|
1084
|
+
else
|
|
1085
|
+
eventRaws.push(raw);
|
|
1086
|
+
}
|
|
1087
|
+
startKey = page.LastEvaluatedKey;
|
|
1088
|
+
if (startKey === undefined)
|
|
1089
|
+
break;
|
|
1090
|
+
// Without a snapshot (it would have been the first item evaluated),
|
|
1091
|
+
// read on, unlimited, to the start of the stream.
|
|
1092
|
+
if (snapshotRaw === undefined) {
|
|
1093
|
+
limit = undefined;
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
// Done once the event right after the snapshot has been seen: versions
|
|
1097
|
+
// are contiguous, so every event the snapshot does not cover is then in
|
|
1098
|
+
// hand, and everything older is already folded into it. Otherwise read
|
|
1099
|
+
// exactly the events still missing, `asOfVersion + 1 … oldest - 1`
|
|
1100
|
+
// (DynamoDB's 1 MB page cap aside).
|
|
1101
|
+
const asOfVersion = snapshotRaw.asOfVersion;
|
|
1102
|
+
const oldest = eventRaws[eventRaws.length - 1]?.version;
|
|
1103
|
+
if (oldest !== undefined && oldest <= asOfVersion + 1)
|
|
1104
|
+
break;
|
|
1105
|
+
limit = oldest === undefined ? undefined : oldest - asOfVersion - 1;
|
|
1106
|
+
}
|
|
1107
|
+
const latest = snapshotRaw === undefined
|
|
1108
|
+
? Option.none()
|
|
1109
|
+
: Option.some(yield* decodeSnapshotItem(snapshotRaw, "EventStore.readLatest"));
|
|
1110
|
+
const asOfVersion = Option.isSome(latest) ? latest.value.asOfVersion : 0;
|
|
1111
|
+
const delta = eventRaws.filter((raw) => raw.version > asOfVersion).reverse();
|
|
1112
|
+
const events = yield* Effect.forEach(delta, (raw) => decodeStreamEvent(raw));
|
|
1113
|
+
return {
|
|
1114
|
+
snapshot: latest,
|
|
1115
|
+
events,
|
|
1116
|
+
version: events[events.length - 1]?.version ?? asOfVersion,
|
|
1117
|
+
};
|
|
1118
|
+
});
|
|
1119
|
+
// ---------------------------------------------------------------------------
|
|
1120
|
+
// hasCommandSentinel (internal — see CommandSentinelProbe)
|
|
1121
|
+
// ---------------------------------------------------------------------------
|
|
1122
|
+
const hasCommandSentinel = (streamId, commandId) => Effect.gen(function* () {
|
|
1123
|
+
const client = yield* DynamoClient;
|
|
1124
|
+
const { name: tableName } = yield* config.table.Tag;
|
|
1125
|
+
const result = yield* client.getItem({
|
|
1126
|
+
TableName: tableName,
|
|
1127
|
+
Key: toAttributeMap({ pk: composeStreamPk(streamId), sk: composeCommandSk(commandId) }),
|
|
1128
|
+
ConsistentRead: true,
|
|
1129
|
+
ProjectionExpression: "#pk",
|
|
1130
|
+
ExpressionAttributeNames: { "#pk": "pk" },
|
|
589
1131
|
});
|
|
1132
|
+
return result.Item !== undefined;
|
|
590
1133
|
});
|
|
591
1134
|
// ---------------------------------------------------------------------------
|
|
592
1135
|
// query.events helper
|
|
@@ -608,8 +1151,69 @@ export const makeStream = (config) => {
|
|
|
608
1151
|
// `Query.where` replaces this — the `__edd_e__` filter still applies.
|
|
609
1152
|
}).pipe(Query.where({ beginsWith: eventSkPrefix }));
|
|
610
1153
|
};
|
|
1154
|
+
// ---------------------------------------------------------------------------
|
|
1155
|
+
// Stream indexes — query.index / readIndex (#140)
|
|
1156
|
+
// ---------------------------------------------------------------------------
|
|
1157
|
+
/**
|
|
1158
|
+
* A query of one stream index, scoped to the stream's partition: the LSI
|
|
1159
|
+
* shares the table's `pk`; a GSI's `pk` attribute carries the same value.
|
|
1160
|
+
* No sort-key condition — the index holds only this stream's indexed events
|
|
1161
|
+
* under that key; `Query.where` narrows the derived-key range.
|
|
1162
|
+
*
|
|
1163
|
+
* @throws `[EDD-9066]` for a name the stream does not declare (unreachable
|
|
1164
|
+
* through the types).
|
|
1165
|
+
*/
|
|
1166
|
+
const buildIndexQuery = (name, streamId) => {
|
|
1167
|
+
const idx = streamIndexes.find((candidate) => candidate.name === name);
|
|
1168
|
+
if (idx === undefined) {
|
|
1169
|
+
throw new Error(`[EDD-9066] EventStream "${config.streamName}": unknown index "${String(name)}". ` +
|
|
1170
|
+
(streamIndexes.length === 0
|
|
1171
|
+
? "The stream declares no indexes."
|
|
1172
|
+
: `Declared: ${streamIndexes.map((candidate) => `"${candidate.name}"`).join(", ")}.`));
|
|
1173
|
+
}
|
|
1174
|
+
return Query.make({
|
|
1175
|
+
tableName: "",
|
|
1176
|
+
indexName: idx.index,
|
|
1177
|
+
pkField: idx.pk,
|
|
1178
|
+
pkValue: composeStreamPk(streamId),
|
|
1179
|
+
skField: idx.sk,
|
|
1180
|
+
entityTypes: [entityType],
|
|
1181
|
+
decoder: (raw) => decodeStreamEvent(raw),
|
|
1182
|
+
resolveTableName: config.table.Tag.useSync((tc) => tc.name),
|
|
1183
|
+
// A GSI is read only eventually consistently: `Query.consistentRead`
|
|
1184
|
+
// on it is refused before anything is sent.
|
|
1185
|
+
globalIndex: idx.type === "gsi",
|
|
1186
|
+
keyFields: [idx.pk, idx.sk, "pk", "sk"],
|
|
1187
|
+
});
|
|
1188
|
+
};
|
|
1189
|
+
const readIndex = (name, streamId, options) => Effect.gen(function* () {
|
|
1190
|
+
const refuse = (cause) => new ValidationError({ entityType, operation: "EventStore.readIndex", cause });
|
|
1191
|
+
const invalidCondition = invalidIndexKeyCondition(options);
|
|
1192
|
+
if (invalidCondition !== undefined) {
|
|
1193
|
+
return yield* refuse(`${invalidCondition} Nothing was sent.`);
|
|
1194
|
+
}
|
|
1195
|
+
const limit = options?.limit;
|
|
1196
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) {
|
|
1197
|
+
return yield* refuse(`limit must be a positive integer; received ${String(limit)}. Nothing was sent.`);
|
|
1198
|
+
}
|
|
1199
|
+
let query = buildIndexQuery(name, streamId);
|
|
1200
|
+
if (options?.beginsWith !== undefined) {
|
|
1201
|
+
query = Query.where(query, { beginsWith: options.beginsWith });
|
|
1202
|
+
}
|
|
1203
|
+
else if (options?.between !== undefined) {
|
|
1204
|
+
query = Query.where(query, { between: options.between });
|
|
1205
|
+
}
|
|
1206
|
+
if (options?.reverse === true)
|
|
1207
|
+
query = Query.reverse(query);
|
|
1208
|
+
if (limit !== undefined)
|
|
1209
|
+
query = Query.limit(query, limit);
|
|
1210
|
+
if (options?.consistentRead === true)
|
|
1211
|
+
query = Query.consistentRead(query);
|
|
1212
|
+
return yield* Query.collect(query);
|
|
1213
|
+
});
|
|
611
1214
|
const queryNamespace = {
|
|
612
1215
|
events: (streamId) => buildEventsQuery(streamId),
|
|
1216
|
+
index: (name, streamId) => buildIndexQuery(name, streamId),
|
|
613
1217
|
};
|
|
614
1218
|
// ---------------------------------------------------------------------------
|
|
615
1219
|
// Return EventStream
|
|
@@ -628,13 +1232,19 @@ export const makeStream = (config) => {
|
|
|
628
1232
|
streamName: config.streamName,
|
|
629
1233
|
eventSchema: eventUnion,
|
|
630
1234
|
snapshotConfig: snapshotSettings,
|
|
1235
|
+
indexes: indexSettings,
|
|
631
1236
|
writeSnapshot,
|
|
632
1237
|
readSnapshot,
|
|
633
1238
|
append,
|
|
634
1239
|
read,
|
|
635
1240
|
readFrom,
|
|
636
1241
|
currentVersion,
|
|
1242
|
+
readLatest,
|
|
1243
|
+
readIndex,
|
|
637
1244
|
query: queryNamespace,
|
|
1245
|
+
[StreamIdFormatter]: composeStreamIdString,
|
|
1246
|
+
[CommandSentinelProbe]: hasCommandSentinel,
|
|
1247
|
+
[HeadAboveProbe]: headAbove,
|
|
638
1248
|
};
|
|
639
1249
|
};
|
|
640
1250
|
// ---------------------------------------------------------------------------
|
|
@@ -657,6 +1267,8 @@ export const makeStream = (config) => {
|
|
|
657
1267
|
export const bind = (stream) => Effect.gen(function* () {
|
|
658
1268
|
const ctx = yield* Effect.context();
|
|
659
1269
|
const provide = (effect) => Effect.provide(effect, ctx);
|
|
1270
|
+
const probe = commandSentinelProbeOf(stream);
|
|
1271
|
+
const headProbe = headAboveProbeOf(stream);
|
|
660
1272
|
return {
|
|
661
1273
|
[EventStreamTypeId]: EventStreamTypeId,
|
|
662
1274
|
pipe() {
|
|
@@ -666,106 +1278,419 @@ export const bind = (stream) => Effect.gen(function* () {
|
|
|
666
1278
|
streamName: stream.streamName,
|
|
667
1279
|
eventSchema: stream.eventSchema,
|
|
668
1280
|
snapshotConfig: stream.snapshotConfig,
|
|
1281
|
+
indexes: stream.indexes,
|
|
669
1282
|
writeSnapshot: (streamId, state, asOfVersion) => provide(stream.writeSnapshot(streamId, state, asOfVersion)),
|
|
670
1283
|
readSnapshot: (streamId) => provide(stream.readSnapshot(streamId)),
|
|
671
1284
|
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)),
|
|
1285
|
+
read: (streamId, options) => provide(stream.read(streamId, options)),
|
|
1286
|
+
readFrom: (streamId, afterVersion, options) => provide(stream.readFrom(streamId, afterVersion, options)),
|
|
1287
|
+
currentVersion: (streamId, options) => provide(stream.currentVersion(streamId, options)),
|
|
1288
|
+
readLatest: (streamId, options) => provide(stream.readLatest(streamId, options)),
|
|
1289
|
+
readIndex: (name, streamId, options) => provide(stream.readIndex(name, streamId, options)),
|
|
675
1290
|
query: stream.query,
|
|
676
1291
|
provide,
|
|
1292
|
+
[StreamIdFormatter]: (id) => formatStreamIdOf(stream, id),
|
|
1293
|
+
[CommandSentinelProbe]: probe === undefined
|
|
1294
|
+
? undefined
|
|
1295
|
+
: (id, commandId) => provide(probe(id, commandId)),
|
|
1296
|
+
[HeadAboveProbe]: headProbe === undefined
|
|
1297
|
+
? undefined
|
|
1298
|
+
: (id, floorVersion, options) => provide(headProbe(id, floorVersion, options)),
|
|
677
1299
|
};
|
|
678
1300
|
});
|
|
1301
|
+
/**
|
|
1302
|
+
* Derive the `CreateTable` fragments for the stream indexes (#140) of the given
|
|
1303
|
+
* streams (`EventStream` or `BoundEventStream`) — `AttributeDefinitions`,
|
|
1304
|
+
* `LocalSecondaryIndexes` and `GlobalSecondaryIndexes`, each index projecting
|
|
1305
|
+
* `ALL` — to merge into the caller's `CreateTable` input. Event tables are not
|
|
1306
|
+
* derived by `Table.definition`, and the table's own `pk` / `sk` are not
|
|
1307
|
+
* included: add them (and, on a provisioned table, each GSI's
|
|
1308
|
+
* `ProvisionedThroughput`) yourself.
|
|
1309
|
+
*
|
|
1310
|
+
* An index shared by several streams with the same definition is emitted
|
|
1311
|
+
* once. Lists are sorted by index name, attributes by attribute name.
|
|
1312
|
+
*
|
|
1313
|
+
* - An LSI can only be created with the table, and caps the item collection
|
|
1314
|
+
* of every partition key value in the table at 10 GB — entity partitions
|
|
1315
|
+
* and streams without indexes included.
|
|
1316
|
+
* - The projection must stay `ALL`: events are decoded from the index item.
|
|
1317
|
+
*
|
|
1318
|
+
* @example
|
|
1319
|
+
* ```typescript
|
|
1320
|
+
* const fragments = EventStore.indexDefinitions(Entries, Orders)
|
|
1321
|
+
* yield* client.createTable({
|
|
1322
|
+
* TableName: "events",
|
|
1323
|
+
* BillingMode: "PAY_PER_REQUEST",
|
|
1324
|
+
* KeySchema: [
|
|
1325
|
+
* { AttributeName: "pk", KeyType: "HASH" },
|
|
1326
|
+
* { AttributeName: "sk", KeyType: "RANGE" },
|
|
1327
|
+
* ],
|
|
1328
|
+
* AttributeDefinitions: [
|
|
1329
|
+
* { AttributeName: "pk", AttributeType: "S" },
|
|
1330
|
+
* { AttributeName: "sk", AttributeType: "S" },
|
|
1331
|
+
* ...fragments.AttributeDefinitions,
|
|
1332
|
+
* ],
|
|
1333
|
+
* LocalSecondaryIndexes: fragments.LocalSecondaryIndexes,
|
|
1334
|
+
* GlobalSecondaryIndexes: fragments.GlobalSecondaryIndexes,
|
|
1335
|
+
* })
|
|
1336
|
+
* ```
|
|
1337
|
+
*
|
|
1338
|
+
* @throws `[EDD-9067]` when two streams define the same physical index
|
|
1339
|
+
* differently (another type, or other key attributes).
|
|
1340
|
+
*/
|
|
1341
|
+
export const indexDefinitions = (...streams) => {
|
|
1342
|
+
const byIndex = new Map();
|
|
1343
|
+
for (const stream of streams) {
|
|
1344
|
+
for (const [name, settings] of Object.entries(stream.indexes)) {
|
|
1345
|
+
const owner = `index "${name}" of stream "${stream.streamName}"`;
|
|
1346
|
+
const existing = byIndex.get(settings.index);
|
|
1347
|
+
if (existing === undefined) {
|
|
1348
|
+
byIndex.set(settings.index, {
|
|
1349
|
+
type: settings.type,
|
|
1350
|
+
index: settings.index,
|
|
1351
|
+
pk: settings.pk,
|
|
1352
|
+
sk: settings.sk,
|
|
1353
|
+
owner,
|
|
1354
|
+
});
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1357
|
+
if (existing.type !== settings.type ||
|
|
1358
|
+
existing.pk !== settings.pk ||
|
|
1359
|
+
existing.sk !== settings.sk) {
|
|
1360
|
+
const describe = (s) => `${s.type} (pk "${s.pk}", sk "${s.sk}")`;
|
|
1361
|
+
throw new Error(`[EDD-9067] EventStore.indexDefinitions: the physical index "${settings.index}" is ` +
|
|
1362
|
+
`defined as ${describe(existing)} by ${existing.owner} and as ${describe(settings)} ` +
|
|
1363
|
+
`by ${owner}.`);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
const attributes = new Set();
|
|
1368
|
+
const lsis = [];
|
|
1369
|
+
const gsis = [];
|
|
1370
|
+
const sorted = [...byIndex.values()].sort((a, b) => a.index.localeCompare(b.index));
|
|
1371
|
+
for (const settings of sorted) {
|
|
1372
|
+
const definition = {
|
|
1373
|
+
IndexName: settings.index,
|
|
1374
|
+
KeySchema: [
|
|
1375
|
+
{ AttributeName: settings.pk, KeyType: "HASH" },
|
|
1376
|
+
{ AttributeName: settings.sk, KeyType: "RANGE" },
|
|
1377
|
+
],
|
|
1378
|
+
Projection: { ProjectionType: "ALL" },
|
|
1379
|
+
};
|
|
1380
|
+
attributes.add(settings.sk);
|
|
1381
|
+
if (settings.type === "gsi") {
|
|
1382
|
+
attributes.add(settings.pk);
|
|
1383
|
+
gsis.push(definition);
|
|
1384
|
+
}
|
|
1385
|
+
else {
|
|
1386
|
+
lsis.push(definition);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
return {
|
|
1390
|
+
AttributeDefinitions: [...attributes]
|
|
1391
|
+
.sort()
|
|
1392
|
+
.map((name) => ({ AttributeName: name, AttributeType: "S" })),
|
|
1393
|
+
...(lsis.length > 0 ? { LocalSecondaryIndexes: lsis } : {}),
|
|
1394
|
+
...(gsis.length > 0 ? { GlobalSecondaryIndexes: gsis } : {}),
|
|
1395
|
+
};
|
|
1396
|
+
};
|
|
679
1397
|
/** @internal Both `EventStream` and `BoundEventStream` carry this brand. */
|
|
680
1398
|
const hasEventStreamBrand = (u) => typeof u === "object" && u !== null && EventStreamTypeId in u;
|
|
1399
|
+
/** @internal Resolve a {@link CommandOptions.additionalItems} against a decision. */
|
|
1400
|
+
const deriveAdditionalItems = (input, decision) => {
|
|
1401
|
+
if (typeof input !== "function")
|
|
1402
|
+
return Effect.succeed(input);
|
|
1403
|
+
const derived = input(decision);
|
|
1404
|
+
return Effect.isEffect(derived) ? derived : Effect.succeed(derived);
|
|
1405
|
+
};
|
|
681
1406
|
/** @internal */
|
|
682
1407
|
const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
1408
|
+
// `verifySnapshot: false` is refused up front unless the stream writes its
|
|
1409
|
+
// snapshot in every append (EDD-9068) — at handler construction, so a
|
|
1410
|
+
// misconfiguration fails at startup rather than on the first command.
|
|
1411
|
+
const snapshotSettings = stream.snapshotConfig;
|
|
1412
|
+
if (options?.verifySnapshot === false) {
|
|
1413
|
+
const refusal = unverifiedSnapshotRefusal(stream.streamName, snapshotSettings, "commandHandler");
|
|
1414
|
+
if (refusal !== undefined)
|
|
1415
|
+
throw new Error(refusal);
|
|
1416
|
+
}
|
|
1417
|
+
// Ignored on a stream without a snapshot config: there is no snapshot to trust.
|
|
1418
|
+
const unverifiedLoads = options?.verifySnapshot === false && snapshotSettings !== undefined;
|
|
683
1419
|
const retryPolicy = options?.retry;
|
|
684
1420
|
const schedule = retryPolicy === undefined
|
|
685
1421
|
? undefined
|
|
686
1422
|
: typeof retryPolicy === "number"
|
|
687
1423
|
? Schedule.recurs(retryPolicy)
|
|
688
1424
|
: retryPolicy;
|
|
1425
|
+
// Consistent by default: an eventually consistent load can hand `decide`
|
|
1426
|
+
// state that misses acknowledged events (#139).
|
|
1427
|
+
const readOptions = { consistentRead: options?.consistentRead ?? true };
|
|
1428
|
+
const unverifiedReadOptions = { ...readOptions, verifySnapshot: false };
|
|
689
1429
|
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.
|
|
1430
|
+
const expectedVersion = callOptions?.expectedVersion;
|
|
1431
|
+
// 1. Establish the base state + version. A snapshot-configured stream
|
|
1432
|
+
// loads its snapshot, the events after it and its head in one request
|
|
1433
|
+
// (#138) — or, unverified, the snapshot item alone; any other stream
|
|
1434
|
+
// replays from the beginning.
|
|
1435
|
+
const load = (verify) => Effect.gen(function* () {
|
|
706
1436
|
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);
|
|
1437
|
+
if (snapshotSettings !== undefined) {
|
|
1438
|
+
const latest = (yield* stream.readLatest(streamId, verify ? readOptions : unverifiedReadOptions));
|
|
1439
|
+
let snapshotAsOfVersion = 0;
|
|
1440
|
+
if (Option.isSome(latest.snapshot)) {
|
|
1441
|
+
snapshotAsOfVersion = latest.snapshot.value.asOfVersion;
|
|
1442
|
+
state = latest.snapshot.value.state;
|
|
719
1443
|
}
|
|
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) {
|
|
1444
|
+
for (const event of latest.events) {
|
|
727
1445
|
state = decider.evolve(state, event.data);
|
|
728
1446
|
}
|
|
729
|
-
const
|
|
730
|
-
|
|
1447
|
+
const loaded = {
|
|
1448
|
+
state,
|
|
1449
|
+
version: latest.version,
|
|
1450
|
+
snapshotAsOfVersion,
|
|
1451
|
+
// An unverified read returns a snapshot alone; without one it has
|
|
1452
|
+
// already fallen back to the verified read.
|
|
1453
|
+
verified: verify || Option.isNone(latest.snapshot),
|
|
1454
|
+
eventsAfterSnapshot: latest.events.length,
|
|
1455
|
+
};
|
|
1456
|
+
return loaded;
|
|
731
1457
|
}
|
|
732
|
-
|
|
733
|
-
const
|
|
734
|
-
|
|
1458
|
+
const events = yield* stream.read(streamId, readOptions);
|
|
1459
|
+
for (const event of events) {
|
|
1460
|
+
state = decider.evolve(state, event.data);
|
|
1461
|
+
}
|
|
1462
|
+
const loaded = {
|
|
1463
|
+
state,
|
|
1464
|
+
version: events[events.length - 1]?.version ?? 0,
|
|
1465
|
+
snapshotAsOfVersion: 0,
|
|
1466
|
+
verified: true,
|
|
1467
|
+
eventsAfterSnapshot: events.length,
|
|
1468
|
+
};
|
|
1469
|
+
return loaded;
|
|
1470
|
+
});
|
|
1471
|
+
// 4–8. Commit one decision: fold, derive items, append, snapshot. With
|
|
1472
|
+
// `returnAppendConflict`, an append that fails with `VersionConflict`
|
|
1473
|
+
// returns the conflict instead of failing, so the caller can tell a
|
|
1474
|
+
// stale unverified load from a lost race.
|
|
1475
|
+
const commit = (loaded, newEvents, returnAppendConflict) => Effect.gen(function* () {
|
|
1476
|
+
const baseVersion = loaded.version;
|
|
1477
|
+
const state = loaded.state;
|
|
1478
|
+
// 4. No-op command — return current state
|
|
735
1479
|
if (newEvents.length === 0) {
|
|
736
|
-
|
|
1480
|
+
const noop = {
|
|
1481
|
+
state,
|
|
1482
|
+
version: baseVersion,
|
|
1483
|
+
events: [],
|
|
1484
|
+
};
|
|
1485
|
+
return noop;
|
|
737
1486
|
}
|
|
738
|
-
//
|
|
739
|
-
//
|
|
1487
|
+
// 5. Fold the new events BEFORE appending (#137): the post-decision
|
|
1488
|
+
// state is what a function-form `additionalItems` projects, what a
|
|
1489
|
+
// snapshot records, and what the handler returns — always the
|
|
1490
|
+
// `evolve` fold, never anything produced inside `decide`.
|
|
1491
|
+
const previous = state;
|
|
1492
|
+
let next = state;
|
|
1493
|
+
for (const event of newEvents) {
|
|
1494
|
+
next = decider.evolve(next, event);
|
|
1495
|
+
}
|
|
1496
|
+
// 6. Derive the caller's items from the decision. Re-run on every
|
|
1497
|
+
// attempt (and on a re-decision), because the whole cycle is the
|
|
1498
|
+
// retried unit.
|
|
1499
|
+
const additionalItems = yield* deriveAdditionalItems(callOptions?.additionalItems, {
|
|
1500
|
+
events: newEvents,
|
|
1501
|
+
state: next,
|
|
1502
|
+
previous,
|
|
1503
|
+
version: baseVersion,
|
|
1504
|
+
});
|
|
1505
|
+
// 7. Append with optimistic concurrency, plus the caller's items, the
|
|
1506
|
+
// dedup sentinel and an inline snapshot, all in one transaction.
|
|
1507
|
+
// `baseVersion` equals any caller-supplied `expectedVersion` here
|
|
1508
|
+
// (checked in step 2).
|
|
740
1509
|
const appendOptions = {};
|
|
741
1510
|
if (callOptions?.metadata !== undefined)
|
|
742
1511
|
appendOptions.metadata = callOptions.metadata;
|
|
743
|
-
if (
|
|
744
|
-
appendOptions.additionalItems =
|
|
745
|
-
}
|
|
1512
|
+
if (additionalItems !== undefined)
|
|
1513
|
+
appendOptions.additionalItems = additionalItems;
|
|
746
1514
|
if (options?.idempotency !== undefined && callOptions?.commandId !== undefined) {
|
|
747
1515
|
appendOptions.idempotency =
|
|
748
1516
|
options.idempotency.ttl !== undefined
|
|
749
1517
|
? { commandId: callOptions.commandId, ttl: options.idempotency.ttl }
|
|
750
1518
|
: { commandId: callOptions.commandId };
|
|
751
1519
|
}
|
|
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.
|
|
1520
|
+
// Inline snapshots (#138) ride in the append transaction: on every
|
|
1521
|
+
// append, or once `every` events have accumulated since the snapshot.
|
|
1522
|
+
const mode = snapshotSettings?.mode ?? "after-append";
|
|
760
1523
|
const every = snapshotSettings?.every;
|
|
761
|
-
|
|
1524
|
+
const newVersion = baseVersion + newEvents.length;
|
|
1525
|
+
if (snapshotSettings !== undefined &&
|
|
1526
|
+
mode === "inline" &&
|
|
1527
|
+
(every === undefined || newVersion - loaded.snapshotAsOfVersion >= every)) {
|
|
1528
|
+
appendOptions.snapshot = next;
|
|
1529
|
+
}
|
|
1530
|
+
const appended = stream.append(streamId, newEvents, baseVersion, appendOptions);
|
|
1531
|
+
const outcome = yield* returnAppendConflict
|
|
1532
|
+
? appended.pipe(Effect.catchIf((error) => error instanceof VersionConflict, (conflict) => Effect.succeed(conflict)))
|
|
1533
|
+
: appended;
|
|
1534
|
+
if (outcome instanceof VersionConflict)
|
|
1535
|
+
return outcome;
|
|
1536
|
+
const result = outcome;
|
|
1537
|
+
// 8. After-append snapshots, once the cadence threshold is crossed.
|
|
1538
|
+
// Best-effort: the events are already durable, so a snapshot-write
|
|
1539
|
+
// failure must not report the command as failed. The next threshold
|
|
1540
|
+
// crossing retries it.
|
|
1541
|
+
if (mode === "after-append" &&
|
|
1542
|
+
every !== undefined &&
|
|
1543
|
+
result.version - loaded.snapshotAsOfVersion >= every) {
|
|
762
1544
|
yield* stream
|
|
763
|
-
.writeSnapshot(streamId,
|
|
1545
|
+
.writeSnapshot(streamId, next, result.version)
|
|
764
1546
|
.pipe(Effect.catchCause((cause) => Effect.logWarning(`EventStore: snapshot write failed for stream "${stream.streamName}" at version ${result.version}`, cause)));
|
|
765
1547
|
}
|
|
766
|
-
|
|
1548
|
+
const done = {
|
|
1549
|
+
state: next,
|
|
1550
|
+
version: result.version,
|
|
1551
|
+
events: newEvents,
|
|
1552
|
+
};
|
|
1553
|
+
return done;
|
|
1554
|
+
});
|
|
1555
|
+
// The If-Match answer for a stream at `actualVersion` (#136). A
|
|
1556
|
+
// redelivery of a command that already committed (its response was lost)
|
|
1557
|
+
// arrives with its original If-Match, which the stream has since moved
|
|
1558
|
+
// past. It is a duplicate, not a lost race — `append` ranks
|
|
1559
|
+
// `DuplicateCommand` above `VersionConflict` for the same reason — so
|
|
1560
|
+
// consult the sentinel before reporting the conflict. The extra read is
|
|
1561
|
+
// paid on this conflict path only.
|
|
1562
|
+
const ifMatchMismatch = (expected, actualVersion) => Effect.gen(function* () {
|
|
1563
|
+
const probe = commandSentinelProbeOf(stream);
|
|
1564
|
+
const commandId = callOptions?.commandId;
|
|
1565
|
+
if (options?.idempotency !== undefined && commandId !== undefined && probe !== undefined) {
|
|
1566
|
+
if (yield* probe(streamId, commandId)) {
|
|
1567
|
+
return yield* new DuplicateCommand({
|
|
1568
|
+
streamName: stream.streamName,
|
|
1569
|
+
streamId: formatStreamIdOf(stream, streamId),
|
|
1570
|
+
commandId,
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
return yield* new VersionConflict({
|
|
1575
|
+
streamName: stream.streamName,
|
|
1576
|
+
streamId: formatStreamIdOf(stream, streamId),
|
|
1577
|
+
expectedVersion: expected,
|
|
1578
|
+
actualVersion,
|
|
1579
|
+
});
|
|
767
1580
|
});
|
|
768
|
-
|
|
1581
|
+
// The head, known to be at least `floorVersion` (see HeadAboveProbe): one
|
|
1582
|
+
// `Query` that reads no items while the snapshot is current.
|
|
1583
|
+
const headAbove = (floorVersion) => {
|
|
1584
|
+
const probe = headAboveProbeOf(stream);
|
|
1585
|
+
return probe !== undefined
|
|
1586
|
+
? probe(streamId, floorVersion, readOptions)
|
|
1587
|
+
: stream.currentVersion(streamId, readOptions);
|
|
1588
|
+
};
|
|
1589
|
+
const attempt = Effect.gen(function* () {
|
|
1590
|
+
// Backstop for JS callers and `any`-shaped call sites: silently degrading
|
|
1591
|
+
// to at-least-once would look like success right up until the day a
|
|
1592
|
+
// duplicate mattered. Not retryable — `while` below only retries
|
|
1593
|
+
// `VersionConflict`, so this surfaces on the first attempt.
|
|
1594
|
+
if (options?.idempotency !== undefined && callOptions?.commandId === undefined) {
|
|
1595
|
+
return yield* new ValidationError({
|
|
1596
|
+
entityType: stream.streamName,
|
|
1597
|
+
operation: "EventStore.commandHandler",
|
|
1598
|
+
cause: "commandId is required when commandHandler is configured with `idempotency`.",
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
// A malformed If-Match (`NaN` from a failed parse, a negative or
|
|
1602
|
+
// fractional number) is a caller bug, not a conflict — refuse it before
|
|
1603
|
+
// reading anything.
|
|
1604
|
+
if (expectedVersion !== undefined &&
|
|
1605
|
+
(!Number.isInteger(expectedVersion) || expectedVersion < 0)) {
|
|
1606
|
+
return yield* new ValidationError({
|
|
1607
|
+
entityType: stream.streamName,
|
|
1608
|
+
operation: "EventStore.commandHandler",
|
|
1609
|
+
cause: `expectedVersion must be a non-negative integer; received ${String(expectedVersion)}.`,
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
// 1. Load (see `load`).
|
|
1613
|
+
let loaded = yield* load(!unverifiedLoads);
|
|
1614
|
+
// An unverified snapshot at another version than the caller's If-Match
|
|
1615
|
+
// may merely lag the head: re-read verified, so only a genuine mismatch
|
|
1616
|
+
// is reported, with the verified head as its `actualVersion`.
|
|
1617
|
+
if (expectedVersion !== undefined && !loaded.verified && loaded.version !== expectedVersion) {
|
|
1618
|
+
loaded = yield* load(true);
|
|
1619
|
+
}
|
|
1620
|
+
// 2. If-Match (#136): the caller saw a different version, so `decide`
|
|
1621
|
+
// must not run against state the caller never saw.
|
|
1622
|
+
if (expectedVersion !== undefined && expectedVersion !== loaded.version) {
|
|
1623
|
+
return yield* ifMatchMismatch(expectedVersion, loaded.version);
|
|
1624
|
+
}
|
|
1625
|
+
// 3. Decide on a verified load, and commit. An If-Match call's
|
|
1626
|
+
// append-time conflict is its answer (never re-read or retried), and
|
|
1627
|
+
// a verified load's conflict is a lost race.
|
|
1628
|
+
if (loaded.verified) {
|
|
1629
|
+
const newEvents = yield* decider.decide(command, loaded.state);
|
|
1630
|
+
return (yield* commit(loaded, newEvents, false));
|
|
1631
|
+
}
|
|
1632
|
+
// 3'. Decide on an unverified snapshot. It is normally at the head, but
|
|
1633
|
+
// events appended without one (a raw `append`, data written before
|
|
1634
|
+
// inline mode) make it lag — and then every answer made on it is
|
|
1635
|
+
// stale, not only one that appends. So no answer is returned until
|
|
1636
|
+
// the head is confirmed: an append confirms it by succeeding; a
|
|
1637
|
+
// decision that appends nothing (a `decide` failure or a no-op) and
|
|
1638
|
+
// an append conflict are confirmed with one more read.
|
|
1639
|
+
const decided = yield* Effect.result(decider.decide(command, loaded.state));
|
|
1640
|
+
const first = Result.isSuccess(decided) && decided.success.length > 0
|
|
1641
|
+
? yield* commit(loaded, decided.success, true)
|
|
1642
|
+
: undefined;
|
|
1643
|
+
if (first !== undefined && !(first instanceof VersionConflict))
|
|
1644
|
+
return first;
|
|
1645
|
+
// The answer the first decision gives when the snapshot proves current.
|
|
1646
|
+
const firstAnswer = first !== undefined
|
|
1647
|
+
? Effect.fail(first)
|
|
1648
|
+
: Result.isFailure(decided)
|
|
1649
|
+
? Effect.fail(decided.failure)
|
|
1650
|
+
: Effect.succeed({ state: loaded.state, version: loaded.version, events: [] });
|
|
1651
|
+
// With an If-Match equal to the snapshot's `asOfVersion`, the snapshot
|
|
1652
|
+
// state is exactly the state the caller saw, so `decide` ran on the
|
|
1653
|
+
// right state — the only question is whether the stream is still at
|
|
1654
|
+
// that version. A newer head answers exactly as a verified load would
|
|
1655
|
+
// have (the pre-decide check above): `DuplicateCommand` for a committed
|
|
1656
|
+
// redelivery, else `VersionConflict` with the head as `actualVersion`.
|
|
1657
|
+
// The first decision is discarded. Never re-decided: an If-Match call's
|
|
1658
|
+
// conflict is its answer.
|
|
1659
|
+
if (expectedVersion !== undefined) {
|
|
1660
|
+
const head = yield* headAbove(expectedVersion);
|
|
1661
|
+
if (head !== expectedVersion)
|
|
1662
|
+
return yield* ifMatchMismatch(expectedVersion, head);
|
|
1663
|
+
return yield* firstAnswer;
|
|
1664
|
+
}
|
|
1665
|
+
// No If-Match, nothing appended: a head at the snapshot's version
|
|
1666
|
+
// confirms the answer (one `Query` above the snapshot's version, which
|
|
1667
|
+
// reads no items while the snapshot is current).
|
|
1668
|
+
if (first === undefined) {
|
|
1669
|
+
const head = yield* headAbove(loaded.version);
|
|
1670
|
+
if (head === loaded.version)
|
|
1671
|
+
return yield* firstAnswer;
|
|
1672
|
+
}
|
|
1673
|
+
// No If-Match, the append conflicted — the snapshot lagged the head, or
|
|
1674
|
+
// another writer won the race; one verified load tells them apart. A
|
|
1675
|
+
// snapshot at the head is a genuine race, which the retry policy
|
|
1676
|
+
// handles as it always has (a retried attempt loads unverified again).
|
|
1677
|
+
// A lagging snapshot is decided on again, once: only a successful inline
|
|
1678
|
+
// append rewrites the snapshot, so surfacing the conflict would fail
|
|
1679
|
+
// every command on the stream until something else repaired it.
|
|
1680
|
+
const verified = yield* load(true);
|
|
1681
|
+
if (first !== undefined && verified.eventsAfterSnapshot === 0)
|
|
1682
|
+
return yield* first;
|
|
1683
|
+
// The first decision was made on stale state: decide again on the
|
|
1684
|
+
// verified state — `decide` (and a function-form `additionalItems`)
|
|
1685
|
+
// runs a second time in this attempt. Its own conflict follows the
|
|
1686
|
+
// retry policy.
|
|
1687
|
+
const newEvents = yield* decider.decide(command, verified.state);
|
|
1688
|
+
return (yield* commit(verified, newEvents, false));
|
|
1689
|
+
});
|
|
1690
|
+
// A caller-supplied `expectedVersion` is a conditional write: its
|
|
1691
|
+
// `VersionConflict` (pre-decide or from the append) is the answer, never a
|
|
1692
|
+
// cue to re-read and re-decide.
|
|
1693
|
+
return schedule === undefined || expectedVersion !== undefined
|
|
769
1694
|
? attempt
|
|
770
1695
|
: Effect.retry(attempt, {
|
|
771
1696
|
// `while` runs before the schedule, so the schedule only ever sees a
|
|
@@ -805,12 +1730,51 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
805
1730
|
* idempotency: { ttl: Duration.days(1) },
|
|
806
1731
|
* })
|
|
807
1732
|
* yield* handle({ matchId: "m-1" }, command, { commandId: "cmd-7f3a" })
|
|
1733
|
+
*
|
|
1734
|
+
* // Load state from the inline snapshot alone (mode "inline" without `every`)
|
|
1735
|
+
* const handle = EventStore.commandHandler(decider, stream, { verifySnapshot: false })
|
|
1736
|
+
*
|
|
1737
|
+
* // If-Match: fail with VersionConflict (never retried) unless the stream is at v7
|
|
1738
|
+
* yield* handle({ matchId: "m-1" }, command, { expectedVersion: 7 })
|
|
1739
|
+
*
|
|
1740
|
+
* // Inline projection, committed in the same transaction as the events
|
|
1741
|
+
* yield* handle({ matchId: "m-1" }, command, {
|
|
1742
|
+
* additionalItems: ({ state }) => [Scoreboard.put({ matchId: "m-1", runs: state.runs })],
|
|
1743
|
+
* })
|
|
808
1744
|
* ```
|
|
809
1745
|
*
|
|
810
|
-
*
|
|
811
|
-
*
|
|
812
|
-
*
|
|
813
|
-
*
|
|
1746
|
+
* Each invocation runs
|
|
1747
|
+
* `load → [expectedVersion check] → decide → fold new events → derive items → append → [snapshot]`.
|
|
1748
|
+
*
|
|
1749
|
+
* - **Load.** Strongly consistent by default — see
|
|
1750
|
+
* {@link CommandHandlerOptions.consistentRead}. When the stream declares a
|
|
1751
|
+
* `snapshot` config, state is loaded with {@link EventStream.readLatest} —
|
|
1752
|
+
* the snapshot, the events after it and the head in one request in steady
|
|
1753
|
+
* state — and only those events are folded, instead of replaying the stream
|
|
1754
|
+
* from the beginning. On a `mode: "inline"` stream without `every`,
|
|
1755
|
+
* {@link CommandHandlerOptions.verifySnapshot}`: false` loads the snapshot
|
|
1756
|
+
* item alone (one `GetItem`); an answer that is not a successful append is
|
|
1757
|
+
* confirmed against the head first, and a snapshot that proves stale falls
|
|
1758
|
+
* back to the verified load — in which case `decide` may run twice.
|
|
1759
|
+
* - **expectedVersion.** See {@link CommandOptions.expectedVersion}.
|
|
1760
|
+
* - **Fold before append.** The new events are folded into state before the
|
|
1761
|
+
* append, so the state returned, snapshotted and handed to a function-form
|
|
1762
|
+
* `additionalItems` (see {@link Decision}) is always the `evolve` fold.
|
|
1763
|
+
* - **Snapshot.** With `snapshot.mode: "inline"`, the snapshot is written in
|
|
1764
|
+
* the append transaction itself — on every append, or at the `every`
|
|
1765
|
+
* cadence. With the default `"after-append"` and `snapshot.every` set, a
|
|
1766
|
+
* fresh snapshot is written (best-effort) after a successful append once the
|
|
1767
|
+
* cadence threshold is crossed. See {@link SnapshotMode}.
|
|
1768
|
+
* - **One atomic append.** One command, one decision, one append: a decision
|
|
1769
|
+
* too large for one transaction fails with `AppendTooLarge` before anything
|
|
1770
|
+
* is written. A large command is split by the application into stepped
|
|
1771
|
+
* commands — each an ordinary call, chained by `expectedVersion` — so a
|
|
1772
|
+
* failure partway leaves the stream at a real intermediate state.
|
|
1773
|
+
*
|
|
1774
|
+
* The handler is generic per call over the error and requirements of a
|
|
1775
|
+
* function-form `additionalItems` that returns an `Effect`: they join the
|
|
1776
|
+
* handler's error channel and requirements (on a `BoundEventStream` handler
|
|
1777
|
+
* they are its only requirements).
|
|
814
1778
|
*
|
|
815
1779
|
* Without `idempotency`, command processing is **at-least-once**: a retry after
|
|
816
1780
|
* an acked-but-lost response re-runs `decide` and appends again.
|