effect-dynamodb 1.24.0 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Aggregate.d.ts +12 -2
- package/dist/Aggregate.d.ts.map +1 -1
- package/dist/Aggregate.js +284 -55
- package/dist/Aggregate.js.map +1 -1
- package/dist/Batch.d.ts +14 -5
- package/dist/Batch.d.ts.map +1 -1
- package/dist/Batch.js +184 -119
- package/dist/Batch.js.map +1 -1
- package/dist/Collection.d.ts +9 -1
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +5 -4
- package/dist/Collection.js.map +1 -1
- package/dist/DynamoClient.d.ts +80 -28
- package/dist/DynamoClient.d.ts.map +1 -1
- package/dist/DynamoClient.js +125 -25
- package/dist/DynamoClient.js.map +1 -1
- package/dist/Entity.d.ts +99 -40
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +693 -302
- package/dist/Entity.js.map +1 -1
- package/dist/EventStore.d.ts +598 -75
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +852 -221
- package/dist/EventStore.js.map +1 -1
- package/dist/Query.d.ts +100 -14
- package/dist/Query.d.ts.map +1 -1
- package/dist/Query.js +275 -71
- package/dist/Query.js.map +1 -1
- package/dist/StorageEncoding.d.ts +160 -0
- package/dist/StorageEncoding.d.ts.map +1 -0
- package/dist/StorageEncoding.js +258 -0
- package/dist/StorageEncoding.js.map +1 -0
- package/dist/Table.d.ts +56 -6
- package/dist/Table.d.ts.map +1 -1
- package/dist/Table.js +142 -60
- package/dist/Table.js.map +1 -1
- package/dist/Transaction.d.ts +8 -0
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +72 -22
- package/dist/Transaction.js.map +1 -1
- package/dist/VectorSearchEmulation.d.ts +3 -2
- package/dist/VectorSearchEmulation.d.ts.map +1 -1
- package/dist/VectorSearchEmulation.js +14 -2
- package/dist/VectorSearchEmulation.js.map +1 -1
- package/dist/compression/zlib.d.ts +75 -0
- package/dist/compression/zlib.d.ts.map +1 -0
- package/dist/compression/zlib.js +109 -0
- package/dist/compression/zlib.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/BoundCrud.d.ts +39 -35
- package/dist/internal/BoundCrud.d.ts.map +1 -1
- package/dist/internal/BoundCrud.js.map +1 -1
- package/dist/internal/BoundQuery.d.ts +6 -5
- package/dist/internal/BoundQuery.d.ts.map +1 -1
- package/dist/internal/BoundQuery.js.map +1 -1
- package/dist/internal/BoundVectorQuery.d.ts +17 -1
- package/dist/internal/BoundVectorQuery.d.ts.map +1 -1
- package/dist/internal/BoundVectorQuery.js +31 -6
- package/dist/internal/BoundVectorQuery.js.map +1 -1
- package/dist/internal/EntityCombinators.d.ts +8 -5
- package/dist/internal/EntityCombinators.d.ts.map +1 -1
- package/dist/internal/EntityCombinators.js +24 -6
- package/dist/internal/EntityCombinators.js.map +1 -1
- package/dist/internal/EntityOps.d.ts +1 -1
- package/dist/internal/Expr.d.ts +9 -3
- package/dist/internal/Expr.d.ts.map +1 -1
- package/dist/internal/Expr.js +7 -5
- package/dist/internal/Expr.js.map +1 -1
- package/dist/internal/ExpressionLimits.d.ts +45 -0
- package/dist/internal/ExpressionLimits.d.ts.map +1 -0
- package/dist/internal/ExpressionLimits.js +89 -0
- package/dist/internal/ExpressionLimits.js.map +1 -0
- package/dist/internal/Hydrate.d.ts +62 -0
- package/dist/internal/Hydrate.d.ts.map +1 -0
- package/dist/internal/Hydrate.js +191 -0
- package/dist/internal/Hydrate.js.map +1 -0
- package/dist/internal/ItemSize.d.ts +60 -24
- package/dist/internal/ItemSize.d.ts.map +1 -1
- package/dist/internal/ItemSize.js +122 -39
- package/dist/internal/ItemSize.js.map +1 -1
- package/dist/internal/Packed.d.ts +289 -0
- package/dist/internal/Packed.d.ts.map +1 -0
- package/dist/internal/Packed.js +654 -0
- package/dist/internal/Packed.js.map +1 -0
- package/dist/internal/PathBuilder.d.ts +45 -2
- package/dist/internal/PathBuilder.d.ts.map +1 -1
- package/dist/internal/PathBuilder.js +4 -0
- package/dist/internal/PathBuilder.js.map +1 -1
- package/dist/internal/TransactWriteOps.d.ts +22 -46
- package/dist/internal/TransactWriteOps.d.ts.map +1 -1
- package/dist/internal/TransactWriteOps.js +26 -95
- package/dist/internal/TransactWriteOps.js.map +1 -1
- package/dist/internal/TransactableOps.d.ts +1 -1
- package/dist/internal/TransactableOps.d.ts.map +1 -1
- package/dist/internal/TransactableOps.js +2 -0
- package/dist/internal/TransactableOps.js.map +1 -1
- package/dist/internal/WriteDiagnosis.d.ts +353 -0
- package/dist/internal/WriteDiagnosis.d.ts.map +1 -0
- package/dist/internal/WriteDiagnosis.js +1028 -0
- package/dist/internal/WriteDiagnosis.js.map +1 -0
- package/dist/internal/sparse_check.js +43 -79
- package/dist/internal/sparse_check.js.map +1 -1
- package/package.json +6 -2
package/dist/EventStore.js
CHANGED
|
@@ -13,20 +13,32 @@
|
|
|
13
13
|
* `VersionConflict` retry policy, caller-supplied `expectedVersion`,
|
|
14
14
|
* decision-derived `additionalItems`, inline snapshots)
|
|
15
15
|
* - Stream indexes (`makeStream({ indexes })`): sub-streams ordered by a key
|
|
16
|
-
* derived from each event, on an LSI or GSI
|
|
17
|
-
*
|
|
16
|
+
* derived from each event, on an LSI or GSI, each projecting all of the
|
|
17
|
+
* event, all but its metadata, or only its keys — `readIndex`,
|
|
18
|
+
* `query.index`, and `indexDefinitions` for the `CreateTable` fragments
|
|
18
19
|
* - `fold` / `foldFrom` helpers for state reconstruction
|
|
20
|
+
* - Packed storage (`makeStream({ storedAs: { data } })`, `snapshot.storedAs`):
|
|
21
|
+
* event data and snapshot state as one JSON string, optionally compressed —
|
|
22
|
+
* read by the stored form, so a stream may mix native, JSON and compressed
|
|
23
|
+
* items; `measureAppend` / `measureSnapshot` for caller-side size planning,
|
|
24
|
+
* and `decodeStreamImage` for DynamoDB Streams records
|
|
19
25
|
*
|
|
20
26
|
* Built on the existing library primitives (DynamoSchema, KeyComposer, Query,
|
|
21
27
|
* DynamoClient, Marshaller).
|
|
22
28
|
*/
|
|
29
|
+
import { Compression } from "@effect-dynamodb/schema/Compression.js";
|
|
30
|
+
import { isPackedConfig } from "@effect-dynamodb/schema/DynamoModel.js";
|
|
23
31
|
import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
|
|
24
32
|
import { normalizeTtlSeconds } from "@effect-dynamodb/schema/Entity.js";
|
|
25
|
-
import { AdditionalItemConditionFailed, AppendTooLarge, DuplicateCommand, isAwsConditionalCheckFailed, isAwsTransactionCancelled,
|
|
33
|
+
import { AdditionalItemConditionFailed, AppendTooLarge, DuplicateCommand, isAwsConditionalCheckFailed, isAwsTransactionCancelled, TransactionCancelled, ValidationError, VersionConflict, } from "@effect-dynamodb/schema/Errors.js";
|
|
34
|
+
import { isDispatchEligible, packedShape, packValue, unpackStoredSync, } from "@effect-dynamodb/schema/internal/PackedCodec.js";
|
|
26
35
|
import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
|
|
27
|
-
import { DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
|
|
36
|
+
import { Context, DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
|
|
28
37
|
import { DynamoClient } from "./DynamoClient.js";
|
|
29
|
-
import {
|
|
38
|
+
import { itemBytes, transactItemBytes } from "./internal/ItemSize.js";
|
|
39
|
+
import { codecRequirements, currentRegistry, decodeImage, imageEntityType, runtimeCodecCheck, unregisteredCodecError, withRegistry, } from "./internal/Packed.js";
|
|
40
|
+
import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, judgeCancellation, transactItemTarget, } from "./internal/TransactWriteOps.js";
|
|
41
|
+
import { diagnosedValidationError, diagnoseFailure, mergeKeyAttributes, withCause, } from "./internal/WriteDiagnosis.js";
|
|
30
42
|
import { fromAttributeMap, toAttributeMap } from "./Marshaller.js";
|
|
31
43
|
import * as Query from "./Query.js";
|
|
32
44
|
import { DEFAULT_TTL_ATTRIBUTE_NAME, resolveTtlAttributeName, } from "./Table.js";
|
|
@@ -39,36 +51,26 @@ import { DEFAULT_TTL_ATTRIBUTE_NAME, resolveTtlAttributeName, } from "./Table.js
|
|
|
39
51
|
*
|
|
40
52
|
* @internal
|
|
41
53
|
*/
|
|
54
|
+
/** The codecs a stream's packed `data` / `state` name (`[EDD-9078]` when unregistered). */
|
|
55
|
+
const streamCodecRequirements = (streamName, storedAs) => codecRequirements(`stream "${streamName}"`, [
|
|
56
|
+
["data", storedAs.data],
|
|
57
|
+
["state", storedAs.state],
|
|
58
|
+
]);
|
|
59
|
+
/** The attributes a stream image reads by stored form: event `data` only. */
|
|
60
|
+
const DATA_ATTRIBUTES = ["data"];
|
|
42
61
|
const EventEnvelope = Schema.Struct({
|
|
43
62
|
streamId: Schema.String,
|
|
44
63
|
version: Schema.Number,
|
|
45
64
|
eventType: Schema.String,
|
|
46
65
|
timestamp: Schema.String,
|
|
47
66
|
});
|
|
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
67
|
/**
|
|
69
68
|
* @internal Why a {@link ReadIndexOptions} key condition cannot be sent, or
|
|
70
|
-
* `undefined` when it can
|
|
71
|
-
* `
|
|
69
|
+
* `undefined` when it can: the options' own meaning — at most one of
|
|
70
|
+
* `beginsWith` and `between`, string bounds (a number would be sent as `N`
|
|
71
|
+
* against a string key). DynamoDB's rules on the bounds — not empty, a
|
|
72
|
+
* `between` in order — are DynamoDB's to enforce: its rejection is diagnosed,
|
|
73
|
+
* naming the index and the bound (#144).
|
|
72
74
|
*/
|
|
73
75
|
const invalidIndexKeyCondition = (options) => {
|
|
74
76
|
if (options?.beginsWith !== undefined && options.between !== undefined) {
|
|
@@ -76,26 +78,46 @@ const invalidIndexKeyCondition = (options) => {
|
|
|
76
78
|
}
|
|
77
79
|
const beginsWith = options?.beginsWith;
|
|
78
80
|
if (beginsWith !== undefined) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
return undefined;
|
|
81
|
+
return typeof beginsWith === "string"
|
|
82
|
+
? undefined
|
|
83
|
+
: "beginsWith must be a string; omit it to read the whole sub-stream.";
|
|
84
84
|
}
|
|
85
85
|
const between = options?.between;
|
|
86
86
|
if (between === undefined)
|
|
87
87
|
return undefined;
|
|
88
88
|
if (!Array.isArray(between) ||
|
|
89
89
|
between.length !== 2 ||
|
|
90
|
-
!between.every((bound) => typeof bound === "string"
|
|
91
|
-
return "between must be a pair of
|
|
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)}.`;
|
|
90
|
+
!between.every((bound) => typeof bound === "string")) {
|
|
91
|
+
return "between must be a pair of strings.";
|
|
96
92
|
}
|
|
97
93
|
return undefined;
|
|
98
94
|
};
|
|
95
|
+
/**
|
|
96
|
+
* @internal The attributes `append` writes on every event item besides the
|
|
97
|
+
* table key (`pk`, `sk`), the optional `metadata` and the index attributes:
|
|
98
|
+
* the envelope, `data` and the `__edd_e__` discriminator. Sorted. A
|
|
99
|
+
* `"withoutMetadata"` stream index projects exactly these.
|
|
100
|
+
*/
|
|
101
|
+
const EVENT_ITEM_ATTRIBUTES = [
|
|
102
|
+
"__edd_e__",
|
|
103
|
+
"data",
|
|
104
|
+
"eventType",
|
|
105
|
+
"streamId",
|
|
106
|
+
"timestamp",
|
|
107
|
+
"version",
|
|
108
|
+
];
|
|
109
|
+
/**
|
|
110
|
+
* @internal The event attributes an `"all"` or `"withoutMetadata"` stream index
|
|
111
|
+
* guarantees on its rows and a decode needs: a row lacking one was read
|
|
112
|
+
* through an index deployed narrower than declared.
|
|
113
|
+
*/
|
|
114
|
+
const PROJECTED_ENVELOPE_ATTRIBUTES = [
|
|
115
|
+
"data",
|
|
116
|
+
"eventType",
|
|
117
|
+
"streamId",
|
|
118
|
+
"timestamp",
|
|
119
|
+
"version",
|
|
120
|
+
];
|
|
99
121
|
/**
|
|
100
122
|
* @internal Attributes a stream writes on its own items — events, snapshot,
|
|
101
123
|
* idempotency sentinel — plus the default TTL attribute. An index attribute
|
|
@@ -116,11 +138,22 @@ const STREAM_OWNED_ATTRIBUTES = [
|
|
|
116
138
|
"commandId",
|
|
117
139
|
DEFAULT_TTL_ATTRIBUTE_NAME,
|
|
118
140
|
];
|
|
141
|
+
/** @internal Every member a stream index entry may declare (`StreamIndexConfigKey`). */
|
|
142
|
+
const STREAM_INDEX_CONFIG_KEYS = [
|
|
143
|
+
"type",
|
|
144
|
+
"index",
|
|
145
|
+
"pk",
|
|
146
|
+
"sk",
|
|
147
|
+
"key",
|
|
148
|
+
"projection",
|
|
149
|
+
];
|
|
119
150
|
/**
|
|
120
151
|
* @internal Validate `makeStream({ indexes })` at definition time.
|
|
121
152
|
*
|
|
122
153
|
* @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
|
|
154
|
+
* `pk`, an `lsi` with `pk`, an empty name, a `key` that is not a function,
|
|
155
|
+
* an unknown `projection`, a member the entry type does not declare — a
|
|
156
|
+
* misspelled `projection` would otherwise silently mean `"all"`).
|
|
124
157
|
* @throws `[EDD-9064]` for an index attribute that collides with a
|
|
125
158
|
* stream-owned attribute.
|
|
126
159
|
* @throws `[EDD-9065]` for two indexes sharing a physical index name or an
|
|
@@ -139,6 +172,11 @@ const resolveStreamIndexes = (streamName, indexes) => {
|
|
|
139
172
|
fail("EDD-9063", `index "${name}" must be an object; received ${String(raw)}.`);
|
|
140
173
|
}
|
|
141
174
|
const entry = raw;
|
|
175
|
+
const unknownKey = Object.keys(entry).find((key) => !STREAM_INDEX_CONFIG_KEYS.includes(key));
|
|
176
|
+
if (unknownKey !== undefined) {
|
|
177
|
+
fail("EDD-9063", `index "${name}": unknown member "${unknownKey}"; an index declares ` +
|
|
178
|
+
`${STREAM_INDEX_CONFIG_KEYS.join(", ")}.`);
|
|
179
|
+
}
|
|
142
180
|
const type = (entry.type ?? "lsi");
|
|
143
181
|
if (type !== "lsi" && type !== "gsi") {
|
|
144
182
|
fail("EDD-9063", `index "${name}": type must be "lsi" or "gsi"; received ${JSON.stringify(entry.type)}.`);
|
|
@@ -160,6 +198,11 @@ const resolveStreamIndexes = (streamName, indexes) => {
|
|
|
160
198
|
if (typeof entry.key !== "function") {
|
|
161
199
|
fail("EDD-9063", `index "${name}": key must be a function (event, version) => string | undefined.`);
|
|
162
200
|
}
|
|
201
|
+
const projection = (entry.projection ?? "all");
|
|
202
|
+
if (projection !== "all" && projection !== "withoutMetadata" && projection !== "keys") {
|
|
203
|
+
fail("EDD-9063", `index "${name}": projection must be "all", "withoutMetadata" or "keys"; received ` +
|
|
204
|
+
`${JSON.stringify(entry.projection)}.`);
|
|
205
|
+
}
|
|
163
206
|
const index = entry.index;
|
|
164
207
|
const sk = entry.sk;
|
|
165
208
|
const pk = type === "gsi" ? entry.pk : "pk";
|
|
@@ -189,11 +232,64 @@ const resolveStreamIndexes = (streamName, indexes) => {
|
|
|
189
232
|
index,
|
|
190
233
|
pk,
|
|
191
234
|
sk,
|
|
235
|
+
projection,
|
|
192
236
|
key: entry.key,
|
|
193
237
|
});
|
|
194
238
|
}
|
|
195
239
|
return resolved;
|
|
196
240
|
};
|
|
241
|
+
/**
|
|
242
|
+
* @internal Validate `makeStream({ storedAs })` and `snapshot.storedAs` at
|
|
243
|
+
* definition time (#147).
|
|
244
|
+
*
|
|
245
|
+
* @throws `[EDD-9079]` for a `storedAs` that is not an object, a key other
|
|
246
|
+
* than `data`, or a value that is not a `DynamoModel.Packed(...)` config.
|
|
247
|
+
* @throws `[EDD-9073]` for a packed snapshot whose state schema's encoded side
|
|
248
|
+
* is not object- or array-shaped.
|
|
249
|
+
*/
|
|
250
|
+
const resolveStreamStorage = (streamName, storedAs, snapshot) => {
|
|
251
|
+
const fail = (code, message) => {
|
|
252
|
+
throw new Error(`[${code}] EventStream "${streamName}": ${message}`);
|
|
253
|
+
};
|
|
254
|
+
const describe = (value) => value === null
|
|
255
|
+
? "null"
|
|
256
|
+
: typeof value === "object"
|
|
257
|
+
? "an object that is not a DynamoModel.Packed(...) config"
|
|
258
|
+
: `${typeof value} ${JSON.stringify(value)}`;
|
|
259
|
+
let data;
|
|
260
|
+
if (storedAs !== undefined) {
|
|
261
|
+
if (typeof storedAs !== "object" || storedAs === null || Array.isArray(storedAs)) {
|
|
262
|
+
fail("EDD-9079", `storedAs must be an object { data?: DynamoModel.Packed(...) }; received ${describe(storedAs)}.`);
|
|
263
|
+
}
|
|
264
|
+
for (const [key, value] of Object.entries(storedAs)) {
|
|
265
|
+
if (key !== "data") {
|
|
266
|
+
fail("EDD-9079", `storedAs declares "${key}", but only "data" can be stored packed` +
|
|
267
|
+
(key === "metadata" ? " — event metadata always stays a map" : "") +
|
|
268
|
+
(key === "state" ? " — pack snapshot state with snapshot.storedAs" : "") +
|
|
269
|
+
".");
|
|
270
|
+
}
|
|
271
|
+
if (value !== undefined && !isPackedConfig(value)) {
|
|
272
|
+
fail("EDD-9079", `storedAs.data must be a DynamoModel.Packed(...) config; received ${describe(value)}.`);
|
|
273
|
+
}
|
|
274
|
+
data = value;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
let state;
|
|
278
|
+
const snapshotStoredAs = snapshot?.storedAs;
|
|
279
|
+
if (snapshot !== undefined && snapshotStoredAs !== undefined) {
|
|
280
|
+
if (!isPackedConfig(snapshotStoredAs)) {
|
|
281
|
+
fail("EDD-9079", `snapshot.storedAs must be a DynamoModel.Packed(...) config; received ` +
|
|
282
|
+
`${describe(snapshotStoredAs)}.`);
|
|
283
|
+
}
|
|
284
|
+
const shape = packedShape(snapshot.schema);
|
|
285
|
+
if (shape._tag === "Refused") {
|
|
286
|
+
fail("EDD-9073", `snapshot.storedAs packs the snapshot state, but the state schema cannot be packed: ` +
|
|
287
|
+
`${shape.reason}. Packed storage holds object- or array-shaped JSON.`);
|
|
288
|
+
}
|
|
289
|
+
state = snapshotStoredAs;
|
|
290
|
+
}
|
|
291
|
+
return { data, state };
|
|
292
|
+
};
|
|
197
293
|
// ---------------------------------------------------------------------------
|
|
198
294
|
// EventStreamTypeId
|
|
199
295
|
// ---------------------------------------------------------------------------
|
|
@@ -248,6 +344,32 @@ const unverifiedSnapshotRefusal = (streamName, settings, operation) => settings
|
|
|
248
344
|
`snapshot mode "inline" without "every" — the only mode that writes the snapshot in every ` +
|
|
249
345
|
`append, so it is normally at the head. This stream's snapshot is mode "${settings.mode}"` +
|
|
250
346
|
`${settings.every === undefined ? "" : ` with every: ${settings.every}`}.`;
|
|
347
|
+
/**
|
|
348
|
+
* @internal Why an `eventTimestamps` value cannot be written for `count`
|
|
349
|
+
* events — not an array, the wrong length, or an entry that is neither a
|
|
350
|
+
* valid `DateTime` (finite epoch) nor `undefined` — or `undefined` when it can. `label` opens the
|
|
351
|
+
* message (`"eventTimestamps has"` / `"eventTimestamps returned"`) and
|
|
352
|
+
* `events` names the events (`"2 events"` / `"the decision's 2 events"`).
|
|
353
|
+
*/
|
|
354
|
+
const eventTimestampsProblem = (timestamps, count, label, events) => {
|
|
355
|
+
if (!Array.isArray(timestamps) || timestamps.length !== count) {
|
|
356
|
+
return (`${label} ${Array.isArray(timestamps) ? timestamps.length : "no"} entries for ${events}: ` +
|
|
357
|
+
"it needs exactly one entry (a DateTime, or undefined) per event, in order.");
|
|
358
|
+
}
|
|
359
|
+
const malformed = timestamps.findIndex((t) => t !== undefined && (!DateTime.isDateTime(t) || !Number.isFinite(DateTime.toEpochMillis(t))));
|
|
360
|
+
if (malformed === -1)
|
|
361
|
+
return undefined;
|
|
362
|
+
const entry = timestamps[malformed];
|
|
363
|
+
// A DateTime with a NaN epoch (e.g. `setPartsUtc` from a bad parse) passes
|
|
364
|
+
// `isDateTime` but makes `formatIso` throw, so it is refused here instead.
|
|
365
|
+
if (DateTime.isDateTime(entry)) {
|
|
366
|
+
return (`eventTimestamps[${malformed}] is an invalid DateTime (its epoch milliseconds are not a ` +
|
|
367
|
+
"finite number): each entry must be a valid DateTime, or undefined to keep the append's own time.");
|
|
368
|
+
}
|
|
369
|
+
return (`eventTimestamps[${malformed}] is not a DateTime (received ` +
|
|
370
|
+
`${entry instanceof Date ? "a Date — convert it with DateTime.fromDateUnsafe" : entry === null ? "null" : typeof entry}): ` +
|
|
371
|
+
"each entry must be a DateTime, or undefined to keep the append's own time.");
|
|
372
|
+
};
|
|
251
373
|
// ---------------------------------------------------------------------------
|
|
252
374
|
// makeStream factory
|
|
253
375
|
// ---------------------------------------------------------------------------
|
|
@@ -310,7 +432,9 @@ const unverifiedSnapshotRefusal = (streamName, settings, operation) => settings
|
|
|
310
432
|
* event's derived key (and, for a GSI, the stream's partition key) on the
|
|
311
433
|
* event item; snapshots and idempotency sentinels never carry them, so they
|
|
312
434
|
* are never in an index. Read with {@link EventStream.readIndex} or
|
|
313
|
-
* `query.index`, and create the indexes with {@link indexDefinitions}.
|
|
435
|
+
* `query.index`, and create the indexes with {@link indexDefinitions}. Each
|
|
436
|
+
* index's `projection` (default `"all"`) sets what it copies from each event
|
|
437
|
+
* and how it is read — see {@link StreamIndexProjection}.
|
|
314
438
|
*
|
|
315
439
|
* Index attributes are written only at append time and events are never
|
|
316
440
|
* rewritten. Declaring an index on a stream that already holds events, or
|
|
@@ -347,7 +471,9 @@ const unverifiedSnapshotRefusal = (streamName, settings, operation) => settings
|
|
|
347
471
|
* `"inline"`.
|
|
348
472
|
* @throws `[EDD-9063]` for a malformed index: a `type` other than `"lsi"` /
|
|
349
473
|
* `"gsi"`, a `gsi` without `pk`, an `lsi` with `pk`, an empty `index` / `sk`
|
|
350
|
-
* / `pk`,
|
|
474
|
+
* / `pk`, a `key` that is not a function, a `projection` other than
|
|
475
|
+
* `"all"` / `"withoutMetadata"` / `"keys"`, or a member an index entry does
|
|
476
|
+
* not declare (such as a misspelled `projection`, also a type error).
|
|
351
477
|
* @throws `[EDD-9064]` when an index attribute is one the stream writes itself
|
|
352
478
|
* (`pk`, `sk`, `__edd_e__`, `streamId`, `version`, `eventType`, `data`,
|
|
353
479
|
* `metadata`, `timestamp`, `asOfVersion`, `state`, `commandId`, or the
|
|
@@ -356,6 +482,18 @@ const unverifiedSnapshotRefusal = (streamName, settings, operation) => settings
|
|
|
356
482
|
* with `ValidationError`.
|
|
357
483
|
* @throws `[EDD-9065]` when two indexes share a physical index name or an
|
|
358
484
|
* attribute (or a GSI's `pk` and `sk` are the same attribute).
|
|
485
|
+
* @throws `[EDD-9079]` when `storedAs` declares a key other than `data`
|
|
486
|
+
* (metadata always stays a map), or a `storedAs.data` / `snapshot.storedAs`
|
|
487
|
+
* value is not a `DynamoModel.Packed(...)` config.
|
|
488
|
+
* @throws `[EDD-9073]` when `snapshot.storedAs` is set and the state schema's
|
|
489
|
+
* encoded side is not object- or array-shaped.
|
|
490
|
+
*
|
|
491
|
+
* `storedAs: { data: DynamoModel.Packed(...) }` stores event `data` packed, and
|
|
492
|
+
* `snapshot.storedAs` the snapshot `state` — see {@link StreamStorageConfig}.
|
|
493
|
+
* Register the codecs a compressed config names through the `Compression`
|
|
494
|
+
* service (`Compression.layer([...])` merged into the application layer): a
|
|
495
|
+
* bound stream refuses to bind without them, and an unbound one to run its
|
|
496
|
+
* first operation (`[EDD-9078]`).
|
|
359
497
|
*/
|
|
360
498
|
export const makeStream = (config) => {
|
|
361
499
|
const schema = config.table.schema;
|
|
@@ -406,11 +544,50 @@ export const makeStream = (config) => {
|
|
|
406
544
|
const snapshotSettings = snapshot === undefined
|
|
407
545
|
? undefined
|
|
408
546
|
: { mode: snapshot.mode ?? "after-append", every: snapshot.every };
|
|
547
|
+
// Packed storage (EDD-9079, EDD-9073) — validated at definition time.
|
|
548
|
+
const storage = resolveStreamStorage(config.streamName, config.storedAs, snapshot);
|
|
549
|
+
/** Event `data` packing, or `undefined` for the native map. */
|
|
550
|
+
const dataStoredAs = storage.data;
|
|
551
|
+
/** Snapshot `state` packing, or `undefined` for the native map. */
|
|
552
|
+
const stateStoredAs = storage.state;
|
|
553
|
+
/**
|
|
554
|
+
* Whether a snapshot's stored `state` is read by its stored form: when it is
|
|
555
|
+
* configured packed, or when its schema's encoded side is object- or
|
|
556
|
+
* array-shaped (a native state is then never `S` / `B`), so a stream that
|
|
557
|
+
* turned packing off still reads its packed snapshot.
|
|
558
|
+
*/
|
|
559
|
+
const stateDispatch = snapshot !== undefined && (stateStoredAs !== undefined || isDispatchEligible(snapshot.schema));
|
|
560
|
+
/**
|
|
561
|
+
* The first operation run with a given compressor registry checks that it
|
|
562
|
+
* has every codec `storedAs` names (`[EDD-9078]`, a defect): the stream
|
|
563
|
+
* meets values compressed with them on every read and write. `bind` checks
|
|
564
|
+
* the registry it captures up front instead.
|
|
565
|
+
*/
|
|
566
|
+
const codecCheck = runtimeCodecCheck(`EventStore (first use of stream "${config.streamName}")`, streamCodecRequirements(config.streamName, storage));
|
|
567
|
+
const checked = (op) => codecCheck === undefined ? op : (...args) => Effect.andThen(codecCheck, op(...args));
|
|
568
|
+
/** The `[EDD-9076]` guard for queries of a stream whose `data` is packed. */
|
|
569
|
+
const dataGuards = dataStoredAs === undefined
|
|
570
|
+
? []
|
|
571
|
+
: [
|
|
572
|
+
{
|
|
573
|
+
attribute: "data",
|
|
574
|
+
owner: `EventStream "${config.streamName}"`,
|
|
575
|
+
storedAs: dataStoredAs,
|
|
576
|
+
alternative: (path) => `Use Query.filterBy((event) => /* test event.${path} */), which runs on the ` +
|
|
577
|
+
"decoded event inside the same paging loop.",
|
|
578
|
+
},
|
|
579
|
+
];
|
|
409
580
|
// Stream indexes (#140) — validated at definition time (EDD-9063..9065).
|
|
410
581
|
const streamIndexes = resolveStreamIndexes(config.streamName, config.indexes);
|
|
411
582
|
const indexSettings = {};
|
|
412
583
|
for (const idx of streamIndexes) {
|
|
413
|
-
indexSettings[idx.name] = {
|
|
584
|
+
indexSettings[idx.name] = {
|
|
585
|
+
type: idx.type,
|
|
586
|
+
index: idx.index,
|
|
587
|
+
pk: idx.pk,
|
|
588
|
+
sk: idx.sk,
|
|
589
|
+
projection: idx.projection,
|
|
590
|
+
};
|
|
414
591
|
}
|
|
415
592
|
/** Every attribute the indexes write on an event item. */
|
|
416
593
|
const indexAttributes = streamIndexes.flatMap((idx) => idx.type === "gsi" ? [idx.pk, idx.sk] : [idx.sk]);
|
|
@@ -485,11 +662,41 @@ export const makeStream = (config) => {
|
|
|
485
662
|
// Decode a raw DynamoDB item → StreamEvent<TEvent>
|
|
486
663
|
// ---------------------------------------------------------------------------
|
|
487
664
|
const decodeEnvelope = Schema.decodeUnknownEffect(EventEnvelope);
|
|
665
|
+
/**
|
|
666
|
+
* An event's stored `data` → its wire form (plain JSON), by the stored
|
|
667
|
+
* form. A native value costs one `typeof` check.
|
|
668
|
+
*/
|
|
669
|
+
const unpackData = (stored, operation) => typeof stored === "string" || stored instanceof Uint8Array
|
|
670
|
+
? withRegistry((registry) => unpackStoredSync(stored, registry, { entityType, operation }, "data"))
|
|
671
|
+
: Effect.succeed(stored);
|
|
672
|
+
/**
|
|
673
|
+
* A raw event row (`Query.select`) with its `data` — when projected — as
|
|
674
|
+
* plain parsed JSON, whatever its stored form (R3: a stream that switched
|
|
675
|
+
* encoding holds a mix). Never schema-decoded. Only this stream's event
|
|
676
|
+
* rows are read: a row of another type (`Query.ignoreOwnership`) is handed
|
|
677
|
+
* back untouched, its `__edd_e__` borrowed for the check and stripped again.
|
|
678
|
+
* In place, so the strip reaches the row returned.
|
|
679
|
+
*/
|
|
680
|
+
const rawEventRow = Object.assign((raw) => {
|
|
681
|
+
const stored = raw.data;
|
|
682
|
+
if (raw.__edd_e__ !== entityType)
|
|
683
|
+
return Effect.succeed(raw);
|
|
684
|
+
if (typeof stored !== "string" && !(stored instanceof Uint8Array))
|
|
685
|
+
return Effect.succeed(raw);
|
|
686
|
+
return Effect.map(unpackData(stored, "EventStore.select"), (data) => {
|
|
687
|
+
raw.data = data;
|
|
688
|
+
return raw;
|
|
689
|
+
});
|
|
690
|
+
}, { reads: ["__edd_e__"], attributes: ["data"] });
|
|
488
691
|
const decodeStreamEvent = (raw) => Effect.gen(function* () {
|
|
489
692
|
const toValidationError = (operation) => (cause) => new ValidationError({ entityType, operation, cause });
|
|
490
693
|
const envelope = yield* decodeEnvelope(raw).pipe(Effect.mapError(toValidationError("EventStore.decode")));
|
|
694
|
+
// `data` is read by its stored form (#147): a native map as it is, a
|
|
695
|
+
// JSON string (`S`) parsed, compressed binary (`B`) decompressed and
|
|
696
|
+
// parsed — whatever the stream's current `storedAs` says.
|
|
697
|
+
const wireData = yield* unpackData(raw.data, "EventStore.decode");
|
|
491
698
|
const decoder = Schema.decodeUnknownEffect(eventUnion);
|
|
492
|
-
const data = yield* decoder(
|
|
699
|
+
const data = yield* decoder(wireData).pipe(Effect.mapError(toValidationError("EventStore.decode")));
|
|
493
700
|
// Metadata is decoded through its schema when the stream declares one —
|
|
494
701
|
// the mirror of the encode performed by `append`. Streams without a
|
|
495
702
|
// metadata schema surface the stored attribute map untouched.
|
|
@@ -512,10 +719,12 @@ export const makeStream = (config) => {
|
|
|
512
719
|
// append
|
|
513
720
|
// ---------------------------------------------------------------------------
|
|
514
721
|
/**
|
|
515
|
-
* An event's key in a stream index (#140): a
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
722
|
+
* An event's key in a stream index (#140): a string, or `undefined` to
|
|
723
|
+
* leave the event out. Another type, or a `key` that throws, is a
|
|
724
|
+
* `ValidationError`, raised before anything is written. DynamoDB's own
|
|
725
|
+
* rules on a key — not empty, at most 1024 bytes for a sort key — are not
|
|
726
|
+
* pre-checked: DynamoDB rejects the append and the rejection is diagnosed,
|
|
727
|
+
* naming the index and event (see `sendAppendTransaction`).
|
|
519
728
|
*/
|
|
520
729
|
const deriveIndexKey = (idx, event, version) => Effect.try({
|
|
521
730
|
try: () => idx.key(event, version),
|
|
@@ -529,12 +738,12 @@ export const makeStream = (config) => {
|
|
|
529
738
|
cause: `The key of index "${idx.name}" for the event at version ${version} ${problem}. ` +
|
|
530
739
|
"Nothing was written.",
|
|
531
740
|
}));
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
if (
|
|
537
|
-
return refuse(`
|
|
741
|
+
// A string, or the event is not indexed: what the stream's own key
|
|
742
|
+
// contract means, which DynamoDB cannot see (a number would be sent
|
|
743
|
+
// as `N`). An empty string is DynamoDB's to reject, and is diagnosed
|
|
744
|
+
// from its rejection, naming the index and the event (#144).
|
|
745
|
+
if (typeof derived !== "string") {
|
|
746
|
+
return refuse(`must be a string or undefined; received ${String(derived)}`);
|
|
538
747
|
}
|
|
539
748
|
return Effect.succeed(derived);
|
|
540
749
|
}));
|
|
@@ -552,69 +761,204 @@ export const makeStream = (config) => {
|
|
|
552
761
|
* the race a guarded additional put lost (so the transaction is built and
|
|
553
762
|
* written again), and fails with every other verdict.
|
|
554
763
|
*/
|
|
555
|
-
const sendAppendTransaction = (client, tx, streamIdStr, commandId) =>
|
|
556
|
-
|
|
557
|
-
|
|
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({
|
|
764
|
+
const sendAppendTransaction = (client, tx, streamIdStr, commandId) => {
|
|
765
|
+
const sent = () => ({ _tag: "Transaction", items: tx.items, targets: tx.targets });
|
|
766
|
+
const diagnosed = (diagnosis, original) => diagnosis._tag === "TooManyItems"
|
|
767
|
+
? new AppendTooLarge({
|
|
570
768
|
streamName: config.streamName,
|
|
571
769
|
streamId: streamIdStr,
|
|
572
|
-
|
|
770
|
+
count: diagnosis.count,
|
|
771
|
+
limit: diagnosis.limit,
|
|
772
|
+
cause: original,
|
|
773
|
+
})
|
|
774
|
+
: appendLimitError(diagnosis, original, tx);
|
|
775
|
+
return client.transactWriteItems({ TransactItems: tx.items }).pipe(Effect.as(undefined),
|
|
776
|
+
// One operation per item (#133, #144): diagnosed BEFORE the conditional
|
|
777
|
+
// mapping below — DynamoDB can report a repeated item as a conditional
|
|
778
|
+
// failure, which would read as a version conflict or a lost race (and
|
|
779
|
+
// be retried). Terminal.
|
|
780
|
+
diagnoseFailure({ sent, only: ["RepeatedItem"], rewrite: diagnosed }), Effect.catch((error) => {
|
|
781
|
+
if (!isAwsTransactionCancelled(error.cause)) {
|
|
782
|
+
return Effect.fail(error);
|
|
783
|
+
}
|
|
784
|
+
const rawReasons = error.cause.CancellationReasons ?? [];
|
|
785
|
+
const reasons = rawReasons.map((r) => ({
|
|
786
|
+
code: r?.Code,
|
|
787
|
+
message: r?.Message,
|
|
573
788
|
}));
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
if (failedAt(
|
|
579
|
-
return Effect.fail(new
|
|
789
|
+
const failedAt = (index) => index >= 0 && reasons[index]?.code === "ConditionalCheckFailed";
|
|
790
|
+
// Precedence is ordered by how terminal the caller's response should
|
|
791
|
+
// be: a duplicate can never succeed on retry, a version conflict
|
|
792
|
+
// invites a re-read, and only then are the additional items judged.
|
|
793
|
+
if (commandId !== undefined && failedAt(tx.sentinelIndex)) {
|
|
794
|
+
return Effect.fail(new DuplicateCommand({
|
|
580
795
|
streamName: config.streamName,
|
|
581
796
|
streamId: streamIdStr,
|
|
582
|
-
|
|
797
|
+
commandId,
|
|
583
798
|
}));
|
|
584
799
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
800
|
+
// The contiguity ConditionCheck and the event puts both mean "the
|
|
801
|
+
// stream is not where you said it was", so they share one verdict.
|
|
802
|
+
for (let i = 0; i < tx.guardCount + tx.eventCount; i++) {
|
|
803
|
+
if (failedAt(i)) {
|
|
804
|
+
return Effect.fail(new VersionConflict({
|
|
805
|
+
streamName: config.streamName,
|
|
806
|
+
streamId: streamIdStr,
|
|
807
|
+
expectedVersion: tx.expectedVersion,
|
|
808
|
+
}));
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
// The additional items, attributed back to the caller OPS that
|
|
812
|
+
// produced them (several items can belong to one op). Only an op's
|
|
813
|
+
// own condition is `AdditionalItemConditionFailed`: a guarded put
|
|
814
|
+
// the caller set no condition on reports what the entity's own put
|
|
815
|
+
// would — a taken unique value, a history conflict — or, having
|
|
816
|
+
// lost a race to a concurrent write, is written again (#133).
|
|
817
|
+
const judged = judgeCancellation(tx.additional, rawReasons, tx.guardCount + tx.eventCount);
|
|
818
|
+
if (judged?._tag === "fail")
|
|
819
|
+
return Effect.fail(judged.error);
|
|
820
|
+
if (judged?._tag === "conditions") {
|
|
821
|
+
return Effect.fail(new AdditionalItemConditionFailed({
|
|
822
|
+
streamName: config.streamName,
|
|
823
|
+
streamId: streamIdStr,
|
|
824
|
+
indices: judged.opIndices,
|
|
825
|
+
reasons,
|
|
826
|
+
}));
|
|
827
|
+
}
|
|
828
|
+
if (judged?._tag === "retry")
|
|
829
|
+
return Effect.succeed(judged.error);
|
|
830
|
+
// No conditional failure we can positionally justify (throttling,
|
|
831
|
+
// TransactionConflict, a reason at the inline snapshot's position, or
|
|
832
|
+
// a truncated/absent reason list) — never guess a VersionConflict.
|
|
833
|
+
return Effect.fail(new TransactionCancelled({
|
|
834
|
+
operation: "TransactWriteItems",
|
|
600
835
|
reasons,
|
|
836
|
+
cause: error.cause,
|
|
601
837
|
}));
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
838
|
+
}),
|
|
839
|
+
// DynamoDB's limits are not pre-checked (#144): a rejection the
|
|
840
|
+
// conditional mapping above could not explain is diagnosed against the
|
|
841
|
+
// transaction sent, naming the event, sentinel, snapshot or additional
|
|
842
|
+
// item at fault. Over 100 items is `AppendTooLarge`, with the EXPANDED
|
|
843
|
+
// count.
|
|
844
|
+
diagnoseFailure({
|
|
845
|
+
sent,
|
|
846
|
+
keys: () => mergeKeyAttributes({
|
|
847
|
+
partition: ["pk", ...streamIndexes.map((idx) => idx.pk)],
|
|
848
|
+
sort: ["sk", ...streamIndexes.map((idx) => idx.sk)],
|
|
849
|
+
}, tx.additional.keys()),
|
|
850
|
+
rewrite: diagnosed,
|
|
612
851
|
}));
|
|
613
|
-
}
|
|
614
|
-
|
|
852
|
+
};
|
|
853
|
+
/**
|
|
854
|
+
* A diagnosed rejection of an append (#144) other than its item count. An
|
|
855
|
+
* over-long (or empty) stream-index sort key OF AN EVENT — the value the
|
|
856
|
+
* index's `key` function returned — is named by its index and event, as
|
|
857
|
+
* the `indexes` option reports its other key problems. Only the event puts
|
|
858
|
+
* carry stream-index keys: an additional item writing the same physical
|
|
859
|
+
* attribute (single-table designs share `gsi1sk` and the like) is its own
|
|
860
|
+
* entity's key, and is named as any other item.
|
|
861
|
+
*/
|
|
862
|
+
const appendLimitError = (diagnosis, original, tx) => {
|
|
863
|
+
if ((diagnosis._tag === "KeyTooLong" && diagnosis.kind === "sort") ||
|
|
864
|
+
diagnosis._tag === "EmptyKey") {
|
|
865
|
+
const position = tx.targets().indexOf(diagnosis.target);
|
|
866
|
+
const isEvent = position >= tx.guardCount && position < tx.guardCount + tx.eventCount;
|
|
867
|
+
const idx = isEvent
|
|
868
|
+
? streamIndexes.find((candidate) => candidate.sk === diagnosis.attribute)
|
|
869
|
+
: undefined;
|
|
870
|
+
if (idx !== undefined) {
|
|
871
|
+
return new ValidationError({
|
|
872
|
+
entityType,
|
|
873
|
+
operation: "EventStore.append.indexes",
|
|
874
|
+
cause: withCause(`The key of index "${idx.name}" for ${diagnosis.target.source} ` +
|
|
875
|
+
(diagnosis._tag === "KeyTooLong"
|
|
876
|
+
? `is ${diagnosis.bytes} bytes, longer than DynamoDB's 1024-byte limit for a ` +
|
|
877
|
+
"sort key."
|
|
878
|
+
: "is an empty string, which DynamoDB does not accept as a key value; the " +
|
|
879
|
+
"key function must return a non-empty string, or undefined to leave the " +
|
|
880
|
+
"event out of the index.") +
|
|
881
|
+
" DynamoDB rejected the append. Nothing was written.", original),
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
return diagnosedValidationError(diagnosis, original, {
|
|
886
|
+
operation: "EventStore.append",
|
|
887
|
+
entityType,
|
|
888
|
+
});
|
|
889
|
+
};
|
|
890
|
+
/**
|
|
891
|
+
* The snapshot item for `state` at `asOfVersion`: the state encoded through
|
|
892
|
+
* the state schema, then packed per `snapshot.storedAs` (#147) — so every
|
|
893
|
+
* size measured afterwards is the stored size. Shared by `writeSnapshot`,
|
|
894
|
+
* the inline snapshot of `append`, and `measureSnapshot` / `measureAppend`.
|
|
895
|
+
*/
|
|
896
|
+
const buildSnapshotItem = (pk, streamIdStr, state, asOfVersion, now, operation) => Effect.gen(function* () {
|
|
897
|
+
const encoded = yield* encodeSnapshotState(state, operation);
|
|
898
|
+
const stored = stateStoredAs === undefined
|
|
899
|
+
? encoded
|
|
900
|
+
: yield* packValue(stateStoredAs, encoded, yield* currentRegistry, {
|
|
901
|
+
entityType: snapshotEntityType,
|
|
902
|
+
attribute: "state",
|
|
903
|
+
operation: `${operation}.packed`,
|
|
904
|
+
});
|
|
905
|
+
return toAttributeMap({
|
|
906
|
+
pk,
|
|
907
|
+
sk: snapshotSk,
|
|
908
|
+
__edd_e__: snapshotEntityType,
|
|
909
|
+
streamId: streamIdStr,
|
|
910
|
+
asOfVersion,
|
|
911
|
+
state: stored,
|
|
912
|
+
timestamp: now,
|
|
913
|
+
});
|
|
914
|
+
});
|
|
915
|
+
/**
|
|
916
|
+
* The stream's own items of an append — everything but the caller's
|
|
917
|
+
* `additionalItems` — validated and built exactly as `append` sends them.
|
|
918
|
+
* Shared by `append` and `measureAppend`, so a measurement is the size of
|
|
919
|
+
* the request `append` would send. `undefined` for an append with nothing
|
|
920
|
+
* to write (the historical no-op).
|
|
921
|
+
*/
|
|
922
|
+
const buildAppend = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
|
|
615
923
|
const additionalOps = options?.additionalItems ?? [];
|
|
616
924
|
const idempotency = options?.idempotency;
|
|
617
925
|
const snapshotState = options?.snapshot;
|
|
926
|
+
const eventMetadata = options?.eventMetadata;
|
|
927
|
+
// Per-event metadata (#144) is checked first — ahead of the no-op fast
|
|
928
|
+
// path below, which would otherwise accept `append(id, [], v,
|
|
929
|
+
// { eventMetadata: [m] })` without looking at it.
|
|
930
|
+
if (eventMetadata !== undefined) {
|
|
931
|
+
if (options?.metadata !== undefined) {
|
|
932
|
+
return yield* new ValidationError({
|
|
933
|
+
entityType,
|
|
934
|
+
operation: "EventStore.append.eventMetadata",
|
|
935
|
+
cause: "metadata and eventMetadata are mutually exclusive: pass one value shared by " +
|
|
936
|
+
"every event, or one entry per event. Nothing was written.",
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
if (!Array.isArray(eventMetadata) || eventMetadata.length !== events.length) {
|
|
940
|
+
return yield* new ValidationError({
|
|
941
|
+
entityType,
|
|
942
|
+
operation: "EventStore.append.eventMetadata",
|
|
943
|
+
cause: `eventMetadata has ${Array.isArray(eventMetadata) ? eventMetadata.length : "no"} ` +
|
|
944
|
+
`entries for ${events.length} events: it needs exactly one entry (or undefined) ` +
|
|
945
|
+
"per event, in order. Nothing was written.",
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
// Per-event timestamps (#144), checked alongside the metadata and for
|
|
950
|
+
// the same reason: ahead of the no-op fast path.
|
|
951
|
+
const eventTimestamps = options?.eventTimestamps;
|
|
952
|
+
if (eventTimestamps !== undefined) {
|
|
953
|
+
const problem = eventTimestampsProblem(eventTimestamps, events.length, "eventTimestamps has", `${events.length} events`);
|
|
954
|
+
if (problem !== undefined) {
|
|
955
|
+
return yield* new ValidationError({
|
|
956
|
+
entityType,
|
|
957
|
+
operation: "EventStore.append.eventTimestamps",
|
|
958
|
+
cause: `${problem} Nothing was written.`,
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
}
|
|
618
962
|
if (snapshotState !== undefined && snapshot === undefined) {
|
|
619
963
|
return yield* snapshotUnavailable("append({ snapshot })");
|
|
620
964
|
}
|
|
@@ -626,7 +970,7 @@ export const makeStream = (config) => {
|
|
|
626
970
|
additionalOps.length === 0 &&
|
|
627
971
|
idempotency === undefined &&
|
|
628
972
|
snapshotState === undefined) {
|
|
629
|
-
return
|
|
973
|
+
return undefined;
|
|
630
974
|
}
|
|
631
975
|
// Resolve stream ID string for storage (join composites)
|
|
632
976
|
const streamIdStr = composeStreamIdString(streamId);
|
|
@@ -641,33 +985,11 @@ export const makeStream = (config) => {
|
|
|
641
985
|
`owns version ${expectedVersion}. Nothing was written.`,
|
|
642
986
|
});
|
|
643
987
|
}
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
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).
|
|
651
|
-
//
|
|
652
|
-
// This first check is a LOWER BOUND, counting one item per additional op.
|
|
653
|
-
// Expansion (uniqueness sentinels, version snapshots — #113) only ever
|
|
654
|
-
// adds items, so an append that already fails here can never fit, and
|
|
655
|
-
// failing now keeps an oversized append free. The authoritative check runs
|
|
656
|
-
// once the ops are compiled, below.
|
|
988
|
+
// DynamoDB's 100-item limit on one transaction is not pre-checked
|
|
989
|
+
// (#144): an append is never split — splitting would break its
|
|
990
|
+
// atomicity — so one over the limit is rejected whole by DynamoDB and
|
|
991
|
+
// reported as `AppendTooLarge`, with the expanded item count.
|
|
657
992
|
const needsContiguityCheck = expectedVersion > 0 && events.length > 0;
|
|
658
|
-
const fixedItems = events.length +
|
|
659
|
-
(idempotency !== undefined ? 1 : 0) +
|
|
660
|
-
(snapshotState !== undefined ? 1 : 0) +
|
|
661
|
-
(needsContiguityCheck ? 1 : 0);
|
|
662
|
-
if (fixedItems + additionalOps.length > TRANSACT_WRITE_ITEMS_LIMIT) {
|
|
663
|
-
return yield* new AppendTooLarge({
|
|
664
|
-
streamName: config.streamName,
|
|
665
|
-
streamId: streamIdStr,
|
|
666
|
-
count: fixedItems + additionalOps.length,
|
|
667
|
-
limit: TRANSACT_WRITE_ITEMS_LIMIT,
|
|
668
|
-
});
|
|
669
|
-
}
|
|
670
|
-
const client = yield* DynamoClient;
|
|
671
993
|
const tableConfig = yield* config.table.Tag;
|
|
672
994
|
const tableName = tableConfig.name;
|
|
673
995
|
// A custom TTL attribute is only known at runtime. An index key attribute
|
|
@@ -687,7 +1009,10 @@ export const makeStream = (config) => {
|
|
|
687
1009
|
// Clock-backed timestamp (deterministic under TestClock; wall-clock in prod).
|
|
688
1010
|
const nowDateTime = yield* DateTime.now;
|
|
689
1011
|
const now = DateTime.formatIso(nowDateTime);
|
|
690
|
-
//
|
|
1012
|
+
// The compressor registry in scope, for packed `data` / `state` (#147).
|
|
1013
|
+
const registry = yield* currentRegistry;
|
|
1014
|
+
// Validate and encode metadata to wire form if schema provided. Shared
|
|
1015
|
+
// by every event; per-event `eventMetadata` is encoded in the loop below.
|
|
691
1016
|
let encodedMetadata;
|
|
692
1017
|
if (options?.metadata !== undefined && metadataSchema) {
|
|
693
1018
|
const encoded = yield* encodeToWire(metadataSchema, options.metadata, "EventStore.append.metadata");
|
|
@@ -711,18 +1036,47 @@ export const makeStream = (config) => {
|
|
|
711
1036
|
// Inject _tag for plain Schema.Class events; Schema.TaggedClass
|
|
712
1037
|
// events already carry _tag in their encoded form, which wins.
|
|
713
1038
|
const eventData = { _tag: evtType, ...wire };
|
|
714
|
-
|
|
1039
|
+
// Packed `data` (#147): one JSON string, or compressed binary at
|
|
1040
|
+
// or over the threshold when that is smaller — packed here, before
|
|
1041
|
+
// marshalling, so every size measured afterwards is stored bytes.
|
|
1042
|
+
const storedData = dataStoredAs === undefined
|
|
1043
|
+
? eventData
|
|
1044
|
+
: yield* packValue(dataStoredAs, eventData, registry, {
|
|
1045
|
+
entityType,
|
|
1046
|
+
attribute: "data",
|
|
1047
|
+
operation: "EventStore.append.packed",
|
|
1048
|
+
});
|
|
1049
|
+
// This event's own metadata entry (#144), encoded as `metadata`
|
|
1050
|
+
// is; an `undefined` entry writes no metadata attribute.
|
|
1051
|
+
let itemMetadata = encodedMetadata;
|
|
1052
|
+
if (eventMetadata !== undefined) {
|
|
1053
|
+
const entry = eventMetadata[i];
|
|
1054
|
+
itemMetadata =
|
|
1055
|
+
entry === undefined
|
|
1056
|
+
? undefined
|
|
1057
|
+
: metadataSchema
|
|
1058
|
+
? (yield* encodeToWire(metadataSchema, entry, `EventStore.append.eventMetadata[${i}]`))
|
|
1059
|
+
: entry;
|
|
1060
|
+
}
|
|
1061
|
+
// This event's own timestamp (#144), or the append's `now`.
|
|
1062
|
+
// `formatIso` renders the UTC instant, so a zoned DateTime is
|
|
1063
|
+
// stored in the same format as `now`.
|
|
1064
|
+
const eventTimestamp = eventTimestamps?.[i];
|
|
1065
|
+
// Exactly the table key and EVENT_ITEM_ATTRIBUTES — what a
|
|
1066
|
+
// "withoutMetadata" stream index projects — checked by the type.
|
|
1067
|
+
const envelope = {
|
|
715
1068
|
pk,
|
|
716
1069
|
sk: composeEventSk(version),
|
|
717
1070
|
__edd_e__: entityType,
|
|
718
1071
|
streamId: streamIdStr,
|
|
719
1072
|
version,
|
|
720
1073
|
eventType: evtType,
|
|
721
|
-
data:
|
|
722
|
-
timestamp: now,
|
|
1074
|
+
data: storedData,
|
|
1075
|
+
timestamp: eventTimestamp === undefined ? now : DateTime.formatIso(eventTimestamp),
|
|
723
1076
|
};
|
|
724
|
-
|
|
725
|
-
|
|
1077
|
+
const item = { ...envelope };
|
|
1078
|
+
if (itemMetadata !== undefined) {
|
|
1079
|
+
item.metadata = itemMetadata;
|
|
726
1080
|
}
|
|
727
1081
|
// Stream indexes (#140): the derived key, stored raw, and for a
|
|
728
1082
|
// GSI the stream's partition key. The attributes are part of the
|
|
@@ -764,8 +1118,7 @@ export const makeStream = (config) => {
|
|
|
764
1118
|
cause,
|
|
765
1119
|
}),
|
|
766
1120
|
});
|
|
767
|
-
sentinel[
|
|
768
|
-
DateTime.toEpochSeconds(nowDateTime) + ttlSeconds;
|
|
1121
|
+
sentinel[ttlAttribute] = DateTime.toEpochSeconds(nowDateTime) + ttlSeconds;
|
|
769
1122
|
}
|
|
770
1123
|
sentinelPut = {
|
|
771
1124
|
Put: {
|
|
@@ -776,21 +1129,14 @@ export const makeStream = (config) => {
|
|
|
776
1129
|
};
|
|
777
1130
|
}
|
|
778
1131
|
// The inline snapshot (#138): unconditional — see AppendOptions.snapshot.
|
|
1132
|
+
// Packed per `snapshot.storedAs` (#147), so the transaction-size and
|
|
1133
|
+
// item-size diagnosis count its stored bytes.
|
|
779
1134
|
let snapshotPut;
|
|
780
1135
|
if (snapshotState !== undefined) {
|
|
781
|
-
const encoded = yield* encodeSnapshotState(snapshotState, "EventStore.append.snapshot");
|
|
782
1136
|
snapshotPut = {
|
|
783
1137
|
Put: {
|
|
784
1138
|
TableName: tableName,
|
|
785
|
-
Item:
|
|
786
|
-
pk,
|
|
787
|
-
sk: snapshotSk,
|
|
788
|
-
__edd_e__: snapshotEntityType,
|
|
789
|
-
streamId: streamIdStr,
|
|
790
|
-
asOfVersion: expectedVersion + events.length,
|
|
791
|
-
state: encoded,
|
|
792
|
-
timestamp: now,
|
|
793
|
-
}),
|
|
1139
|
+
Item: yield* buildSnapshotItem(pk, streamIdStr, snapshotState, expectedVersion + events.length, now, "EventStore.append.snapshot"),
|
|
794
1140
|
},
|
|
795
1141
|
};
|
|
796
1142
|
}
|
|
@@ -815,6 +1161,24 @@ export const makeStream = (config) => {
|
|
|
815
1161
|
},
|
|
816
1162
|
}
|
|
817
1163
|
: undefined;
|
|
1164
|
+
const parts = {
|
|
1165
|
+
tableName,
|
|
1166
|
+
streamIdStr,
|
|
1167
|
+
contiguityCheck,
|
|
1168
|
+
eventItems,
|
|
1169
|
+
sentinelPut,
|
|
1170
|
+
snapshotPut,
|
|
1171
|
+
};
|
|
1172
|
+
return parts;
|
|
1173
|
+
});
|
|
1174
|
+
const append = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
|
|
1175
|
+
const additionalOps = options?.additionalItems ?? [];
|
|
1176
|
+
const idempotency = options?.idempotency;
|
|
1177
|
+
const parts = yield* buildAppend(streamId, events, expectedVersion, options);
|
|
1178
|
+
if (parts === undefined)
|
|
1179
|
+
return { version: expectedVersion, events: [] };
|
|
1180
|
+
const { tableName, streamIdStr, contiguityCheck, eventItems, sentinelPut, snapshotPut } = parts;
|
|
1181
|
+
const client = yield* DynamoClient;
|
|
818
1182
|
/**
|
|
819
1183
|
* Lay out the append's one transaction — see {@link AppendTransaction}:
|
|
820
1184
|
* [0, C) version-contiguity ConditionCheck (C is 0 or 1)
|
|
@@ -832,10 +1196,11 @@ export const makeStream = (config) => {
|
|
|
832
1196
|
*/
|
|
833
1197
|
const layout = (additional) => {
|
|
834
1198
|
const items = [];
|
|
835
|
-
|
|
1199
|
+
// What names each item, so the targets are built only on failure.
|
|
1200
|
+
const naming = [];
|
|
836
1201
|
const pushStreamItem = (item, type, source) => {
|
|
837
1202
|
items.push(item);
|
|
838
|
-
|
|
1203
|
+
naming.push(() => [transactItemTarget(item, tableName, ["pk", "sk"], type, source)]);
|
|
839
1204
|
};
|
|
840
1205
|
if (contiguityCheck !== undefined) {
|
|
841
1206
|
pushStreamItem(contiguityCheck, entityType, "the version-contiguity check");
|
|
@@ -845,7 +1210,7 @@ export const makeStream = (config) => {
|
|
|
845
1210
|
pushStreamItem(item, entityType, `the event at version ${expectedVersion + i + 1}`);
|
|
846
1211
|
}
|
|
847
1212
|
items.push(...additional.items);
|
|
848
|
-
|
|
1213
|
+
naming.push(additional.targets);
|
|
849
1214
|
const sentinelIndex = sentinelPut === undefined ? -1 : items.length;
|
|
850
1215
|
if (sentinelPut !== undefined) {
|
|
851
1216
|
pushStreamItem(sentinelPut, entityType, "the idempotency sentinel");
|
|
@@ -853,9 +1218,11 @@ export const makeStream = (config) => {
|
|
|
853
1218
|
if (snapshotPut !== undefined) {
|
|
854
1219
|
pushStreamItem(snapshotPut, snapshotEntityType, "the inline snapshot");
|
|
855
1220
|
}
|
|
1221
|
+
let targets;
|
|
856
1222
|
return {
|
|
857
1223
|
items,
|
|
858
|
-
|
|
1224
|
+
// Built once, on failure: a diagnosis's target is found by identity.
|
|
1225
|
+
targets: () => (targets ??= naming.flatMap((name) => name())),
|
|
859
1226
|
guardCount,
|
|
860
1227
|
eventCount: eventItems.length,
|
|
861
1228
|
additional,
|
|
@@ -871,25 +1238,13 @@ export const makeStream = (config) => {
|
|
|
871
1238
|
// `Transaction.transactWrite` uses, so the two APIs cannot drift.
|
|
872
1239
|
const additional = yield* buildTransactWriteItems(additionalOps, "EventStore.append.additionalItems");
|
|
873
1240
|
const tx = layout(additional);
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
streamId: streamIdStr,
|
|
882
|
-
count: tx.items.length,
|
|
883
|
-
limit: TRANSACT_WRITE_ITEMS_LIMIT,
|
|
884
|
-
});
|
|
885
|
-
}
|
|
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");
|
|
1241
|
+
// DynamoDB's rules — one operation per item (an additional item
|
|
1242
|
+
// repeating an event, the contiguity check, the idempotency sentinel
|
|
1243
|
+
// or the inline snapshot, or another additional item), 100 items,
|
|
1244
|
+
// 4 MB, 400 KB per item, 1024-byte sort keys — are not pre-checked:
|
|
1245
|
+
// `sendAppendTransaction` diagnoses DynamoDB's rejection (#144). The
|
|
1246
|
+
// items carry packed `data` / `state` as stored (#147), so the
|
|
1247
|
+
// diagnosis attributes stored bytes.
|
|
893
1248
|
const outcome = yield* sendAppendTransaction(client, tx, streamIdStr, idempotency?.commandId);
|
|
894
1249
|
if (outcome === undefined) {
|
|
895
1250
|
return { version: expectedVersion + events.length, events };
|
|
@@ -899,6 +1254,73 @@ export const makeStream = (config) => {
|
|
|
899
1254
|
return yield* Effect.fail(lost);
|
|
900
1255
|
});
|
|
901
1256
|
// ---------------------------------------------------------------------------
|
|
1257
|
+
// measureAppend / measureSnapshot — caller-side size planning (#147, R1)
|
|
1258
|
+
// ---------------------------------------------------------------------------
|
|
1259
|
+
const measureAppend = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
|
|
1260
|
+
if (options?.additionalItems !== undefined) {
|
|
1261
|
+
return yield* new ValidationError({
|
|
1262
|
+
entityType,
|
|
1263
|
+
operation: "EventStore.measureAppend",
|
|
1264
|
+
cause: "measureAppend does not measure additionalItems (building a guarded put reads the " +
|
|
1265
|
+
"table): measure the stream's own items without them, and add the size of your " +
|
|
1266
|
+
"own items.",
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
const parts = yield* buildAppend(streamId, events, expectedVersion, options);
|
|
1270
|
+
if (parts === undefined) {
|
|
1271
|
+
return { events: [], sentinel: undefined, snapshot: undefined, transactionBytes: 0 };
|
|
1272
|
+
}
|
|
1273
|
+
const putBytes = (item) => itemBytes(item.Put?.Item ?? {});
|
|
1274
|
+
const owned = [
|
|
1275
|
+
...(parts.contiguityCheck === undefined ? [] : [parts.contiguityCheck]),
|
|
1276
|
+
...parts.eventItems,
|
|
1277
|
+
...(parts.sentinelPut === undefined ? [] : [parts.sentinelPut]),
|
|
1278
|
+
...(parts.snapshotPut === undefined ? [] : [parts.snapshotPut]),
|
|
1279
|
+
];
|
|
1280
|
+
const measurement = {
|
|
1281
|
+
events: parts.eventItems.map(putBytes),
|
|
1282
|
+
sentinel: parts.sentinelPut === undefined ? undefined : putBytes(parts.sentinelPut),
|
|
1283
|
+
snapshot: parts.snapshotPut === undefined ? undefined : putBytes(parts.snapshotPut),
|
|
1284
|
+
transactionBytes: owned.reduce((sum, item) => sum + transactItemBytes(item), 0),
|
|
1285
|
+
};
|
|
1286
|
+
return measurement;
|
|
1287
|
+
});
|
|
1288
|
+
const measureSnapshot = (streamId, state, asOfVersion) => Effect.gen(function* () {
|
|
1289
|
+
if (snapshot === undefined)
|
|
1290
|
+
return yield* snapshotUnavailable("measureSnapshot");
|
|
1291
|
+
const now = DateTime.formatIso(yield* DateTime.now);
|
|
1292
|
+
const item = yield* buildSnapshotItem(composeStreamPk(streamId), composeStreamIdString(streamId), state, asOfVersion, now, "EventStore.measureSnapshot");
|
|
1293
|
+
return itemBytes(item);
|
|
1294
|
+
});
|
|
1295
|
+
// ---------------------------------------------------------------------------
|
|
1296
|
+
// decodeStreamImage — DynamoDB Streams records (#147, R2)
|
|
1297
|
+
// ---------------------------------------------------------------------------
|
|
1298
|
+
const decodeStreamImage = (image, options) => Effect.gen(function* () {
|
|
1299
|
+
const ctx = { entityType, operation: "EventStore.decodeStreamImage" };
|
|
1300
|
+
// Not one of this stream's events — its snapshot, a sentinel, another
|
|
1301
|
+
// stream's or an entity's item: none, decided from the raw image before
|
|
1302
|
+
// anything is unmarshalled, so nothing in a foreign image can fail it.
|
|
1303
|
+
if (typeof image === "object" &&
|
|
1304
|
+
image !== null &&
|
|
1305
|
+
!Array.isArray(image) &&
|
|
1306
|
+
imageEntityType(image) !== entityType) {
|
|
1307
|
+
return Option.none();
|
|
1308
|
+
}
|
|
1309
|
+
// In DynamoDB JSON the S / B tag says what `data` is; unmarshalled, a
|
|
1310
|
+
// string that is base64 of a `B` value is told apart from JSON text.
|
|
1311
|
+
const raw = yield* decodeImage(image, DATA_ATTRIBUTES, ctx);
|
|
1312
|
+
const event = yield* decodeStreamEvent(raw);
|
|
1313
|
+
if (options?.raw !== true)
|
|
1314
|
+
return Option.some(event);
|
|
1315
|
+
// `raw.data` is the wire form the decode just read: the parsed JSON of a
|
|
1316
|
+
// packed value, the unmarshalled map of a native one.
|
|
1317
|
+
const withRaw = {
|
|
1318
|
+
...event,
|
|
1319
|
+
rawData: raw.data,
|
|
1320
|
+
};
|
|
1321
|
+
return Option.some(withRaw);
|
|
1322
|
+
});
|
|
1323
|
+
// ---------------------------------------------------------------------------
|
|
902
1324
|
// read
|
|
903
1325
|
// ---------------------------------------------------------------------------
|
|
904
1326
|
/** Apply {@link ReadOptions} to an event query. */
|
|
@@ -955,33 +1377,43 @@ export const makeStream = (config) => {
|
|
|
955
1377
|
const client = yield* DynamoClient;
|
|
956
1378
|
const { name: tableName } = yield* config.table.Tag;
|
|
957
1379
|
const now = DateTime.formatIso(yield* DateTime.now);
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
__edd_e__: snapshotEntityType,
|
|
963
|
-
streamId: composeStreamIdString(streamId),
|
|
964
|
-
asOfVersion,
|
|
965
|
-
state: encoded,
|
|
966
|
-
timestamp: now,
|
|
967
|
-
};
|
|
968
|
-
yield* client
|
|
969
|
-
.putItem({
|
|
1380
|
+
// Packed per `snapshot.storedAs` (#147): the diagnosis below measures
|
|
1381
|
+
// the stored bytes.
|
|
1382
|
+
const item = yield* buildSnapshotItem(composeStreamPk(streamId), composeStreamIdString(streamId), state, asOfVersion, now, "EventStore.writeSnapshot");
|
|
1383
|
+
const put = {
|
|
970
1384
|
TableName: tableName,
|
|
971
|
-
Item:
|
|
1385
|
+
Item: item,
|
|
972
1386
|
// Monotonic: never regress the cache. Losing this race is a no-op,
|
|
973
1387
|
// not an error — the events it summarises are already durable.
|
|
974
1388
|
ConditionExpression: "attribute_not_exists(#pk) OR #asOfVersion < :asOfVersion",
|
|
975
1389
|
ExpressionAttributeNames: { "#pk": "pk", "#asOfVersion": "asOfVersion" },
|
|
976
1390
|
ExpressionAttributeValues: toAttributeMap({ ":asOfVersion": asOfVersion }),
|
|
977
|
-
}
|
|
978
|
-
|
|
1391
|
+
};
|
|
1392
|
+
yield* client.putItem(put).pipe(Effect.catchIf((error) => isAwsConditionalCheckFailed(error.cause), () => Effect.void),
|
|
1393
|
+
// An oversized snapshot is DynamoDB's to reject (#144); named here.
|
|
1394
|
+
diagnoseFailure({
|
|
1395
|
+
sent: () => ({
|
|
1396
|
+
_tag: "Item",
|
|
1397
|
+
item: { Put: put },
|
|
1398
|
+
target: () => transactItemTarget({ Put: put }, tableName, ["pk", "sk"], snapshotEntityType, "the snapshot"),
|
|
1399
|
+
}),
|
|
1400
|
+
keys: () => ({ partition: ["pk"], sort: ["sk"] }),
|
|
1401
|
+
rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, {
|
|
1402
|
+
operation: "EventStore.writeSnapshot",
|
|
1403
|
+
entityType: snapshotEntityType,
|
|
1404
|
+
}),
|
|
1405
|
+
}));
|
|
979
1406
|
});
|
|
980
1407
|
/**
|
|
981
1408
|
* Decode a stored snapshot item. A snapshot that fails to decode through the
|
|
982
1409
|
* state schema is a `ValidationError` — never silently discarded.
|
|
983
1410
|
*/
|
|
984
|
-
const decodeSnapshotItem = (raw, operation) =>
|
|
1411
|
+
const decodeSnapshotItem = (raw, operation) =>
|
|
1412
|
+
// `state` is read by its stored form (#147) when it may be packed — see
|
|
1413
|
+
// `stateDispatch` — then decoded through the state schema as before.
|
|
1414
|
+
(stateDispatch && (typeof raw.state === "string" || raw.state instanceof Uint8Array)
|
|
1415
|
+
? withRegistry((registry) => unpackStoredSync(raw.state, registry, { entityType: snapshotEntityType, operation }, "state"))
|
|
1416
|
+
: Effect.succeed(raw.state)).pipe(Effect.flatMap((state) => Schema.decodeUnknownEffect(snapshot.schema)(state).pipe(Effect.mapError((cause) => new ValidationError({ entityType: snapshotEntityType, operation, cause })))), Effect.map((state) => ({
|
|
985
1417
|
state,
|
|
986
1418
|
asOfVersion: raw.asOfVersion,
|
|
987
1419
|
timestamp: raw.timestamp,
|
|
@@ -1144,6 +1576,10 @@ export const makeStream = (config) => {
|
|
|
1144
1576
|
skField: "sk",
|
|
1145
1577
|
entityTypes: [entityType],
|
|
1146
1578
|
decoder: (raw) => decodeStreamEvent(raw),
|
|
1579
|
+
// Raw rows (`Query.select`) carry `data` as parsed JSON, whatever its
|
|
1580
|
+
// stored form, and a filter inside a packed `data` is refused (#147).
|
|
1581
|
+
rawRow: rawEventRow,
|
|
1582
|
+
packed: dataGuards,
|
|
1147
1583
|
resolveTableName: config.table.Tag.useSync((tc) => tc.name),
|
|
1148
1584
|
keyFields: ["pk", "sk"],
|
|
1149
1585
|
// Bound to the event SK range so non-event items in the stream partition
|
|
@@ -1178,14 +1614,67 @@ export const makeStream = (config) => {
|
|
|
1178
1614
|
pkValue: composeStreamPk(streamId),
|
|
1179
1615
|
skField: idx.sk,
|
|
1180
1616
|
entityTypes: [entityType],
|
|
1181
|
-
decoder: (
|
|
1617
|
+
decoder: indexDecoder(idx),
|
|
1618
|
+
// As for `query.events` (#147) — the `"keys"` hydration path decodes
|
|
1619
|
+
// its fetched rows through the same hooks.
|
|
1620
|
+
rawRow: rawEventRow,
|
|
1621
|
+
packed: dataGuards,
|
|
1182
1622
|
resolveTableName: config.table.Tag.useSync((tc) => tc.name),
|
|
1183
|
-
//
|
|
1184
|
-
//
|
|
1185
|
-
|
|
1623
|
+
// DynamoDB's rejection of the key condition (an empty bound, a reversed
|
|
1624
|
+
// `between`) is named against the stream index; so, as for every
|
|
1625
|
+
// query, is a consistent read of a GSI (#144).
|
|
1626
|
+
keyConditionDiagnosis: {
|
|
1627
|
+
operation: "EventStore.readIndex",
|
|
1628
|
+
label: `EventStream "${config.streamName}" index "${idx.name}"`,
|
|
1629
|
+
},
|
|
1186
1630
|
keyFields: [idx.pk, idx.sk, "pk", "sk"],
|
|
1631
|
+
// A "keys" index holds no events: an LSI has DynamoDB fetch them
|
|
1632
|
+
// (`Select: ALL_ATTRIBUTES`), a GSI fetches each page's with
|
|
1633
|
+
// `BatchGetItem` on the table key.
|
|
1634
|
+
...(idx.projection === "keys"
|
|
1635
|
+
? {
|
|
1636
|
+
hydration: idx.type === "lsi"
|
|
1637
|
+
? { _tag: "Fetch" }
|
|
1638
|
+
: { _tag: "BatchGet", tableKey: ["pk", "sk"] },
|
|
1639
|
+
}
|
|
1640
|
+
: {}),
|
|
1187
1641
|
});
|
|
1188
1642
|
};
|
|
1643
|
+
/**
|
|
1644
|
+
* @internal The decoder of events read through `idx`.
|
|
1645
|
+
*
|
|
1646
|
+
* - `"withoutMetadata"` ignores any `metadata` on the row (an index deployed
|
|
1647
|
+
* wider than declared still carries it), so the value matches the type.
|
|
1648
|
+
* - `"all"` / `"withoutMetadata"`: a decode failure on a row that lacks an
|
|
1649
|
+
* attribute the declared projection guarantees is diagnosed as the
|
|
1650
|
+
* deployed index projecting less than declared — a `ValidationError`
|
|
1651
|
+
* (`operation: "EventStore.index.decode"`) naming the index, the original
|
|
1652
|
+
* error kept as its cause. Anything else passes through unchanged.
|
|
1653
|
+
* - `"keys"` decodes items fetched from the table, so a missing attribute
|
|
1654
|
+
* there is not a projection problem: failures pass through.
|
|
1655
|
+
*/
|
|
1656
|
+
const indexDecoder = (idx) => (raw) => {
|
|
1657
|
+
if (idx.projection === "keys")
|
|
1658
|
+
return decodeStreamEvent(raw);
|
|
1659
|
+
let row = raw;
|
|
1660
|
+
if (idx.projection === "withoutMetadata" && raw.metadata !== undefined) {
|
|
1661
|
+
row = { ...raw };
|
|
1662
|
+
delete row.metadata;
|
|
1663
|
+
}
|
|
1664
|
+
return decodeStreamEvent(row).pipe(Effect.mapError((error) => {
|
|
1665
|
+
const missing = PROJECTED_ENVELOPE_ATTRIBUTES.filter((name) => row[name] === undefined);
|
|
1666
|
+
if (missing.length === 0)
|
|
1667
|
+
return error;
|
|
1668
|
+
return new ValidationError({
|
|
1669
|
+
entityType,
|
|
1670
|
+
operation: "EventStore.index.decode",
|
|
1671
|
+
cause: withCause(`Stream index "${idx.name}" (physical "${idx.index}") is declared ` +
|
|
1672
|
+
`projection: "${idx.projection}", but its rows lack ${missing.join(", ")}: the ` +
|
|
1673
|
+
"deployed index likely projects less than declared. Deploy the Projection " +
|
|
1674
|
+
"EventStore.indexDefinitions emits for it.", error),
|
|
1675
|
+
});
|
|
1676
|
+
}));
|
|
1677
|
+
};
|
|
1189
1678
|
const readIndex = (name, streamId, options) => Effect.gen(function* () {
|
|
1190
1679
|
const refuse = (cause) => new ValidationError({ entityType, operation: "EventStore.readIndex", cause });
|
|
1191
1680
|
const invalidCondition = invalidIndexKeyCondition(options);
|
|
@@ -1232,19 +1721,23 @@ export const makeStream = (config) => {
|
|
|
1232
1721
|
streamName: config.streamName,
|
|
1233
1722
|
eventSchema: eventUnion,
|
|
1234
1723
|
snapshotConfig: snapshotSettings,
|
|
1724
|
+
storedAs: storage,
|
|
1235
1725
|
indexes: indexSettings,
|
|
1236
|
-
writeSnapshot,
|
|
1237
|
-
readSnapshot,
|
|
1238
|
-
append,
|
|
1239
|
-
read,
|
|
1240
|
-
readFrom,
|
|
1241
|
-
currentVersion,
|
|
1242
|
-
readLatest,
|
|
1243
|
-
readIndex,
|
|
1726
|
+
writeSnapshot: checked(writeSnapshot),
|
|
1727
|
+
readSnapshot: checked(readSnapshot),
|
|
1728
|
+
append: checked(append),
|
|
1729
|
+
read: checked(read),
|
|
1730
|
+
readFrom: checked(readFrom),
|
|
1731
|
+
currentVersion: checked(currentVersion),
|
|
1732
|
+
readLatest: checked(readLatest),
|
|
1733
|
+
readIndex: checked(readIndex),
|
|
1734
|
+
measureAppend: checked(measureAppend),
|
|
1735
|
+
measureSnapshot: checked(measureSnapshot),
|
|
1736
|
+
decodeStreamImage: checked(decodeStreamImage),
|
|
1244
1737
|
query: queryNamespace,
|
|
1245
1738
|
[StreamIdFormatter]: composeStreamIdString,
|
|
1246
|
-
[CommandSentinelProbe]: hasCommandSentinel,
|
|
1247
|
-
[HeadAboveProbe]: headAbove,
|
|
1739
|
+
[CommandSentinelProbe]: checked(hasCommandSentinel),
|
|
1740
|
+
[HeadAboveProbe]: checked(headAbove),
|
|
1248
1741
|
};
|
|
1249
1742
|
};
|
|
1250
1743
|
// ---------------------------------------------------------------------------
|
|
@@ -1266,6 +1759,11 @@ export const makeStream = (config) => {
|
|
|
1266
1759
|
*/
|
|
1267
1760
|
export const bind = (stream) => Effect.gen(function* () {
|
|
1268
1761
|
const ctx = yield* Effect.context();
|
|
1762
|
+
// The stream meets values compressed with every codec its `storedAs`
|
|
1763
|
+
// names, so the registry captured here must have them (#147).
|
|
1764
|
+
const codecError = unregisteredCodecError("EventStore.bind", streamCodecRequirements(stream.streamName, stream.storedAs), Context.getOrUndefined(ctx, Compression));
|
|
1765
|
+
if (codecError !== undefined)
|
|
1766
|
+
throw codecError;
|
|
1269
1767
|
const provide = (effect) => Effect.provide(effect, ctx);
|
|
1270
1768
|
const probe = commandSentinelProbeOf(stream);
|
|
1271
1769
|
const headProbe = headAboveProbeOf(stream);
|
|
@@ -1278,6 +1776,7 @@ export const bind = (stream) => Effect.gen(function* () {
|
|
|
1278
1776
|
streamName: stream.streamName,
|
|
1279
1777
|
eventSchema: stream.eventSchema,
|
|
1280
1778
|
snapshotConfig: stream.snapshotConfig,
|
|
1779
|
+
storedAs: stream.storedAs,
|
|
1281
1780
|
indexes: stream.indexes,
|
|
1282
1781
|
writeSnapshot: (streamId, state, asOfVersion) => provide(stream.writeSnapshot(streamId, state, asOfVersion)),
|
|
1283
1782
|
readSnapshot: (streamId) => provide(stream.readSnapshot(streamId)),
|
|
@@ -1287,6 +1786,11 @@ export const bind = (stream) => Effect.gen(function* () {
|
|
|
1287
1786
|
currentVersion: (streamId, options) => provide(stream.currentVersion(streamId, options)),
|
|
1288
1787
|
readLatest: (streamId, options) => provide(stream.readLatest(streamId, options)),
|
|
1289
1788
|
readIndex: (name, streamId, options) => provide(stream.readIndex(name, streamId, options)),
|
|
1789
|
+
// The captured context carries a `Compression` registry provided where
|
|
1790
|
+
// the stream was bound (#147).
|
|
1791
|
+
measureAppend: (streamId, events, expectedVersion, options) => provide(stream.measureAppend(streamId, events, expectedVersion, options)),
|
|
1792
|
+
measureSnapshot: (streamId, state, asOfVersion) => provide(stream.measureSnapshot(streamId, state, asOfVersion)),
|
|
1793
|
+
decodeStreamImage: ((image, options) => provide(stream.decodeStreamImage(image, options))),
|
|
1290
1794
|
query: stream.query,
|
|
1291
1795
|
provide,
|
|
1292
1796
|
[StreamIdFormatter]: (id) => formatStreamIdOf(stream, id),
|
|
@@ -1298,14 +1802,43 @@ export const bind = (stream) => Effect.gen(function* () {
|
|
|
1298
1802
|
: (id, floorVersion, options) => provide(headProbe(id, floorVersion, options)),
|
|
1299
1803
|
};
|
|
1300
1804
|
});
|
|
1805
|
+
// ---------------------------------------------------------------------------
|
|
1806
|
+
// EventStore.indexDefinitions — CreateTable fragments for stream indexes (#140)
|
|
1807
|
+
// ---------------------------------------------------------------------------
|
|
1808
|
+
/**
|
|
1809
|
+
* @internal The `Projection` a stream index of `projection` is created with.
|
|
1810
|
+
* Neither list names a key attribute: table and index keys are always
|
|
1811
|
+
* projected.
|
|
1812
|
+
*/
|
|
1813
|
+
const projectionDefinition = (projection) => projection === "all"
|
|
1814
|
+
? { ProjectionType: "ALL" }
|
|
1815
|
+
: {
|
|
1816
|
+
ProjectionType: "INCLUDE",
|
|
1817
|
+
NonKeyAttributes: projection === "keys" ? ["__edd_e__"] : [...EVENT_ITEM_ATTRIBUTES],
|
|
1818
|
+
};
|
|
1301
1819
|
/**
|
|
1302
1820
|
* Derive the `CreateTable` fragments for the stream indexes (#140) of the given
|
|
1303
1821
|
* streams (`EventStream` or `BoundEventStream`) — `AttributeDefinitions`,
|
|
1304
|
-
* `LocalSecondaryIndexes` and `GlobalSecondaryIndexes
|
|
1305
|
-
*
|
|
1306
|
-
*
|
|
1307
|
-
*
|
|
1308
|
-
*
|
|
1822
|
+
* `LocalSecondaryIndexes` and `GlobalSecondaryIndexes` — to merge into the
|
|
1823
|
+
* caller's `CreateTable` input. Event tables are not derived by
|
|
1824
|
+
* `Table.definition`, and the table's own `pk` / `sk` are not included: add
|
|
1825
|
+
* them (and, on a provisioned table, each GSI's `ProvisionedThroughput`)
|
|
1826
|
+
* yourself.
|
|
1827
|
+
*
|
|
1828
|
+
* Each index's `Projection` follows its {@link StreamIndexProjection}:
|
|
1829
|
+
*
|
|
1830
|
+
* | `projection` | `Projection` |
|
|
1831
|
+
* |---|---|
|
|
1832
|
+
* | `"all"` (default) | `{ ProjectionType: "ALL" }` |
|
|
1833
|
+
* | `"withoutMetadata"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__", "data", "eventType", "streamId", "timestamp", "version"] }` |
|
|
1834
|
+
* | `"keys"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__"] }` — not `KEYS_ONLY`: every read filters on `__edd_e__` |
|
|
1835
|
+
*
|
|
1836
|
+
* The deployed index must project what is declared — copy `Projection` from
|
|
1837
|
+
* this output into CDK or Terraform definitions. Neither an LSI's nor a GSI's
|
|
1838
|
+
* projection can be changed in place, and the library migrates nothing. A
|
|
1839
|
+
* table projects at most 100 `NonKeyAttributes` across all its indexes;
|
|
1840
|
+
* `"withoutMetadata"` spends 6 of them and `"keys"` 1 (DynamoDB refuses a
|
|
1841
|
+
* table over the limit).
|
|
1309
1842
|
*
|
|
1310
1843
|
* An index shared by several streams with the same definition is emitted
|
|
1311
1844
|
* once. Lists are sorted by index name, attributes by attribute name.
|
|
@@ -1313,7 +1846,6 @@ export const bind = (stream) => Effect.gen(function* () {
|
|
|
1313
1846
|
* - An LSI can only be created with the table, and caps the item collection
|
|
1314
1847
|
* of every partition key value in the table at 10 GB — entity partitions
|
|
1315
1848
|
* and streams without indexes included.
|
|
1316
|
-
* - The projection must stay `ALL`: events are decoded from the index item.
|
|
1317
1849
|
*
|
|
1318
1850
|
* @example
|
|
1319
1851
|
* ```typescript
|
|
@@ -1336,12 +1868,13 @@ export const bind = (stream) => Effect.gen(function* () {
|
|
|
1336
1868
|
* ```
|
|
1337
1869
|
*
|
|
1338
1870
|
* @throws `[EDD-9067]` when two streams define the same physical index
|
|
1339
|
-
* differently (another type,
|
|
1871
|
+
* differently (another type, other key attributes, or another projection).
|
|
1340
1872
|
*/
|
|
1341
1873
|
export const indexDefinitions = (...streams) => {
|
|
1342
1874
|
const byIndex = new Map();
|
|
1343
1875
|
for (const stream of streams) {
|
|
1344
|
-
for (const [name,
|
|
1876
|
+
for (const [name, raw] of Object.entries(stream.indexes)) {
|
|
1877
|
+
const settings = { ...raw, projection: raw.projection ?? "all" };
|
|
1345
1878
|
const owner = `index "${name}" of stream "${stream.streamName}"`;
|
|
1346
1879
|
const existing = byIndex.get(settings.index);
|
|
1347
1880
|
if (existing === undefined) {
|
|
@@ -1350,14 +1883,16 @@ export const indexDefinitions = (...streams) => {
|
|
|
1350
1883
|
index: settings.index,
|
|
1351
1884
|
pk: settings.pk,
|
|
1352
1885
|
sk: settings.sk,
|
|
1886
|
+
projection: settings.projection,
|
|
1353
1887
|
owner,
|
|
1354
1888
|
});
|
|
1355
1889
|
continue;
|
|
1356
1890
|
}
|
|
1357
1891
|
if (existing.type !== settings.type ||
|
|
1358
1892
|
existing.pk !== settings.pk ||
|
|
1359
|
-
existing.sk !== settings.sk
|
|
1360
|
-
|
|
1893
|
+
existing.sk !== settings.sk ||
|
|
1894
|
+
existing.projection !== settings.projection) {
|
|
1895
|
+
const describe = (s) => `${s.type} (pk "${s.pk}", sk "${s.sk}", projection "${s.projection}")`;
|
|
1361
1896
|
throw new Error(`[EDD-9067] EventStore.indexDefinitions: the physical index "${settings.index}" is ` +
|
|
1362
1897
|
`defined as ${describe(existing)} by ${existing.owner} and as ${describe(settings)} ` +
|
|
1363
1898
|
`by ${owner}.`);
|
|
@@ -1375,7 +1910,7 @@ export const indexDefinitions = (...streams) => {
|
|
|
1375
1910
|
{ AttributeName: settings.pk, KeyType: "HASH" },
|
|
1376
1911
|
{ AttributeName: settings.sk, KeyType: "RANGE" },
|
|
1377
1912
|
],
|
|
1378
|
-
Projection:
|
|
1913
|
+
Projection: projectionDefinition(settings.projection),
|
|
1379
1914
|
};
|
|
1380
1915
|
attributes.add(settings.sk);
|
|
1381
1916
|
if (settings.type === "gsi") {
|
|
@@ -1396,13 +1931,27 @@ export const indexDefinitions = (...streams) => {
|
|
|
1396
1931
|
};
|
|
1397
1932
|
/** @internal Both `EventStream` and `BoundEventStream` carry this brand. */
|
|
1398
1933
|
const hasEventStreamBrand = (u) => typeof u === "object" && u !== null && EventStreamTypeId in u;
|
|
1399
|
-
/** @internal Resolve a {@link
|
|
1934
|
+
/** @internal Resolve a {@link CommandOptionsBase.additionalItems} against a decision. */
|
|
1400
1935
|
const deriveAdditionalItems = (input, decision) => {
|
|
1401
1936
|
if (typeof input !== "function")
|
|
1402
1937
|
return Effect.succeed(input);
|
|
1403
1938
|
const derived = input(decision);
|
|
1404
1939
|
return Effect.isEffect(derived) ? derived : Effect.succeed(derived);
|
|
1405
1940
|
};
|
|
1941
|
+
/** @internal Resolve an `eventMetadata` ({@link CommandMetadataOptions}) against a decision. */
|
|
1942
|
+
const deriveEventMetadata = (input, decision) => {
|
|
1943
|
+
if (input === undefined)
|
|
1944
|
+
return Effect.succeed(undefined);
|
|
1945
|
+
const derived = input(decision);
|
|
1946
|
+
return Effect.isEffect(derived) ? derived : Effect.succeed(derived);
|
|
1947
|
+
};
|
|
1948
|
+
/** @internal Resolve a {@link CommandOptionsBase.eventTimestamps} against a decision. */
|
|
1949
|
+
const deriveEventTimestamps = (input, decision) => {
|
|
1950
|
+
if (input === undefined)
|
|
1951
|
+
return Effect.succeed(undefined);
|
|
1952
|
+
const derived = input(decision);
|
|
1953
|
+
return Effect.isEffect(derived) ? derived : Effect.succeed(derived);
|
|
1954
|
+
};
|
|
1406
1955
|
/** @internal */
|
|
1407
1956
|
const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
1408
1957
|
// `verifySnapshot: false` is refused up front unless the stream writes its
|
|
@@ -1493,15 +2042,44 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
1493
2042
|
for (const event of newEvents) {
|
|
1494
2043
|
next = decider.evolve(next, event);
|
|
1495
2044
|
}
|
|
1496
|
-
// 6. Derive the caller's
|
|
1497
|
-
//
|
|
1498
|
-
//
|
|
1499
|
-
|
|
2045
|
+
// 6. Derive the caller's per-event metadata, then per-event
|
|
2046
|
+
// timestamps, then items, from the decision — in that order
|
|
2047
|
+
// (#144). Re-run on every attempt (and on a re-decision), because
|
|
2048
|
+
// the whole cycle is the retried unit.
|
|
2049
|
+
const decision = {
|
|
1500
2050
|
events: newEvents,
|
|
1501
2051
|
state: next,
|
|
1502
2052
|
previous,
|
|
1503
2053
|
version: baseVersion,
|
|
1504
|
-
}
|
|
2054
|
+
};
|
|
2055
|
+
const eventMetadata = yield* deriveEventMetadata(callOptions?.eventMetadata, decision);
|
|
2056
|
+
// A supplied function must return the array — `undefined` (e.g. a
|
|
2057
|
+
// block body with no `return`) is refused, not silently dropped.
|
|
2058
|
+
if (callOptions?.eventMetadata !== undefined &&
|
|
2059
|
+
(!Array.isArray(eventMetadata) || eventMetadata.length !== newEvents.length)) {
|
|
2060
|
+
return yield* new ValidationError({
|
|
2061
|
+
entityType: stream.streamName,
|
|
2062
|
+
operation: "EventStore.commandHandler.eventMetadata",
|
|
2063
|
+
cause: `eventMetadata returned ${Array.isArray(eventMetadata) ? eventMetadata.length : "no"} ` +
|
|
2064
|
+
`entries for the decision's ${newEvents.length} events: it must return exactly ` +
|
|
2065
|
+
"one entry (or undefined) per event, in order. Nothing was written.",
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
// A function (checked before the load), returning the array or an
|
|
2069
|
+
// `Effect` of it; the result of either form is checked here, so the
|
|
2070
|
+
// error names the handler's option rather than `append`'s.
|
|
2071
|
+
const eventTimestamps = yield* deriveEventTimestamps(callOptions?.eventTimestamps, decision);
|
|
2072
|
+
if (callOptions?.eventTimestamps !== undefined) {
|
|
2073
|
+
const problem = eventTimestampsProblem(eventTimestamps, newEvents.length, "eventTimestamps returned", `the decision's ${newEvents.length} events`);
|
|
2074
|
+
if (problem !== undefined) {
|
|
2075
|
+
return yield* new ValidationError({
|
|
2076
|
+
entityType: stream.streamName,
|
|
2077
|
+
operation: "EventStore.commandHandler.eventTimestamps",
|
|
2078
|
+
cause: `${problem} Nothing was written.`,
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
const additionalItems = yield* deriveAdditionalItems(callOptions?.additionalItems, decision);
|
|
1505
2083
|
// 7. Append with optimistic concurrency, plus the caller's items, the
|
|
1506
2084
|
// dedup sentinel and an inline snapshot, all in one transaction.
|
|
1507
2085
|
// `baseVersion` equals any caller-supplied `expectedVersion` here
|
|
@@ -1509,6 +2087,10 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
1509
2087
|
const appendOptions = {};
|
|
1510
2088
|
if (callOptions?.metadata !== undefined)
|
|
1511
2089
|
appendOptions.metadata = callOptions.metadata;
|
|
2090
|
+
if (eventMetadata !== undefined)
|
|
2091
|
+
appendOptions.eventMetadata = eventMetadata;
|
|
2092
|
+
if (eventTimestamps !== undefined)
|
|
2093
|
+
appendOptions.eventTimestamps = eventTimestamps;
|
|
1512
2094
|
if (additionalItems !== undefined)
|
|
1513
2095
|
appendOptions.additionalItems = additionalItems;
|
|
1514
2096
|
if (options?.idempotency !== undefined && callOptions?.commandId !== undefined) {
|
|
@@ -1598,6 +2180,37 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
1598
2180
|
cause: "commandId is required when commandHandler is configured with `idempotency`.",
|
|
1599
2181
|
});
|
|
1600
2182
|
}
|
|
2183
|
+
// The same backstop for the exclusive metadata pair (#144), and for an
|
|
2184
|
+
// `eventMetadata` or `eventTimestamps` that is not the function it must
|
|
2185
|
+
// be — refused before anything is read, rather than by `append` after
|
|
2186
|
+
// `decide`.
|
|
2187
|
+
if (callOptions?.eventMetadata !== undefined) {
|
|
2188
|
+
if (callOptions.metadata !== undefined) {
|
|
2189
|
+
return yield* new ValidationError({
|
|
2190
|
+
entityType: stream.streamName,
|
|
2191
|
+
operation: "EventStore.commandHandler.eventMetadata",
|
|
2192
|
+
cause: "metadata and eventMetadata are mutually exclusive: pass one value shared by " +
|
|
2193
|
+
"every event, or a function deriving one entry per event. Nothing was written.",
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2196
|
+
if (typeof callOptions.eventMetadata !== "function") {
|
|
2197
|
+
return yield* new ValidationError({
|
|
2198
|
+
entityType: stream.streamName,
|
|
2199
|
+
operation: "EventStore.commandHandler.eventMetadata",
|
|
2200
|
+
cause: "eventMetadata must be a function of the decision: the number of events is " +
|
|
2201
|
+
"known only once `decide` has run. Nothing was written.",
|
|
2202
|
+
});
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
if (callOptions?.eventTimestamps !== undefined &&
|
|
2206
|
+
typeof callOptions.eventTimestamps !== "function") {
|
|
2207
|
+
return yield* new ValidationError({
|
|
2208
|
+
entityType: stream.streamName,
|
|
2209
|
+
operation: "EventStore.commandHandler.eventTimestamps",
|
|
2210
|
+
cause: "eventTimestamps must be a function of the decision: the number of events is " +
|
|
2211
|
+
"known only once `decide` has run. Nothing was written.",
|
|
2212
|
+
});
|
|
2213
|
+
}
|
|
1601
2214
|
// A malformed If-Match (`NaN` from a failed parse, a negative or
|
|
1602
2215
|
// fractional number) is a caller bug, not a conflict — refuse it before
|
|
1603
2216
|
// reading anything.
|
|
@@ -1741,6 +2354,24 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
1741
2354
|
* yield* handle({ matchId: "m-1" }, command, {
|
|
1742
2355
|
* additionalItems: ({ state }) => [Scoreboard.put({ matchId: "m-1", runs: state.runs })],
|
|
1743
2356
|
* })
|
|
2357
|
+
*
|
|
2358
|
+
* // Per-event metadata derived from the decision: entry i is written onto event i
|
|
2359
|
+
* // (`crypto` is the `Crypto.Crypto` service; `randomUUIDv7` works the same way)
|
|
2360
|
+
* yield* handle({ matchId: "m-1" }, command, {
|
|
2361
|
+
* eventMetadata: ({ events }) =>
|
|
2362
|
+
* Effect.forEach(events, () => Effect.map(crypto.randomULID, (eventId) => ({ eventId }))),
|
|
2363
|
+
* })
|
|
2364
|
+
*
|
|
2365
|
+
* // Per-event timestamps derived from the decision (undefined → the Clock time)
|
|
2366
|
+
* yield* handle({ matchId: "m-1" }, command, {
|
|
2367
|
+
* eventTimestamps: ({ events }) => events.map((event) => event.occurredAt),
|
|
2368
|
+
* })
|
|
2369
|
+
*
|
|
2370
|
+
* // ...or from a service, as an Effect (its errors / requirements join the handler's)
|
|
2371
|
+
* yield* handle({ matchId: "m-1" }, command, {
|
|
2372
|
+
* eventTimestamps: ({ events }) =>
|
|
2373
|
+
* Effect.forEach(events, (event) => SourceLog.originalTime(event.sourceId)),
|
|
2374
|
+
* })
|
|
1744
2375
|
* ```
|
|
1745
2376
|
*
|
|
1746
2377
|
* Each invocation runs
|
|
@@ -1756,7 +2387,7 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
1756
2387
|
* item alone (one `GetItem`); an answer that is not a successful append is
|
|
1757
2388
|
* confirmed against the head first, and a snapshot that proves stale falls
|
|
1758
2389
|
* back to the verified load — in which case `decide` may run twice.
|
|
1759
|
-
* - **expectedVersion.** See {@link
|
|
2390
|
+
* - **expectedVersion.** See {@link CommandOptionsBase.expectedVersion}.
|
|
1760
2391
|
* - **Fold before append.** The new events are folded into state before the
|
|
1761
2392
|
* append, so the state returned, snapshotted and handed to a function-form
|
|
1762
2393
|
* `additionalItems` (see {@link Decision}) is always the `evolve` fold.
|
|
@@ -1766,8 +2397,8 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
1766
2397
|
* fresh snapshot is written (best-effort) after a successful append once the
|
|
1767
2398
|
* cadence threshold is crossed. See {@link SnapshotMode}.
|
|
1768
2399
|
* - **One atomic append.** One command, one decision, one append: a decision
|
|
1769
|
-
* too large for one transaction
|
|
1770
|
-
* is written. A large command is split by the application into stepped
|
|
2400
|
+
* too large for one transaction is rejected whole by DynamoDB and fails with
|
|
2401
|
+
* `AppendTooLarge` — nothing is written. A large command is split by the application into stepped
|
|
1771
2402
|
* commands — each an ordinary call, chained by `expectedVersion` — so a
|
|
1772
2403
|
* failure partway leaves the stream at a real intermediate state.
|
|
1773
2404
|
*
|