effect-dynamodb 1.24.0 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Aggregate.d.ts +5 -2
- package/dist/Aggregate.d.ts.map +1 -1
- package/dist/Aggregate.js +136 -53
- package/dist/Aggregate.js.map +1 -1
- package/dist/Batch.d.ts +14 -5
- package/dist/Batch.d.ts.map +1 -1
- package/dist/Batch.js +184 -119
- package/dist/Batch.js.map +1 -1
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +2 -4
- package/dist/Collection.js.map +1 -1
- package/dist/DynamoClient.d.ts.map +1 -1
- package/dist/DynamoClient.js +42 -14
- package/dist/DynamoClient.js.map +1 -1
- package/dist/Entity.d.ts +19 -13
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +287 -226
- package/dist/Entity.js.map +1 -1
- package/dist/EventStore.d.ts +416 -73
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +525 -181
- package/dist/EventStore.js.map +1 -1
- package/dist/Query.d.ts +47 -14
- package/dist/Query.d.ts.map +1 -1
- package/dist/Query.js +190 -68
- package/dist/Query.js.map +1 -1
- package/dist/Table.d.ts +56 -6
- package/dist/Table.d.ts.map +1 -1
- package/dist/Table.js +134 -54
- package/dist/Table.js.map +1 -1
- package/dist/Transaction.d.ts +8 -0
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +67 -22
- package/dist/Transaction.js.map +1 -1
- package/dist/VectorSearchEmulation.d.ts +3 -2
- package/dist/VectorSearchEmulation.d.ts.map +1 -1
- package/dist/VectorSearchEmulation.js +14 -2
- package/dist/VectorSearchEmulation.js.map +1 -1
- package/dist/internal/BoundQuery.d.ts +3 -2
- package/dist/internal/BoundQuery.d.ts.map +1 -1
- package/dist/internal/BoundQuery.js.map +1 -1
- package/dist/internal/BoundVectorQuery.d.ts +5 -1
- package/dist/internal/BoundVectorQuery.d.ts.map +1 -1
- package/dist/internal/BoundVectorQuery.js +17 -5
- package/dist/internal/BoundVectorQuery.js.map +1 -1
- package/dist/internal/EntityCombinators.d.ts +8 -5
- package/dist/internal/EntityCombinators.d.ts.map +1 -1
- package/dist/internal/EntityCombinators.js +8 -5
- package/dist/internal/EntityCombinators.js.map +1 -1
- package/dist/internal/EntityOps.d.ts +1 -1
- package/dist/internal/Expr.d.ts +4 -0
- package/dist/internal/Expr.d.ts.map +1 -1
- package/dist/internal/Expr.js +6 -4
- package/dist/internal/Expr.js.map +1 -1
- package/dist/internal/ExpressionLimits.d.ts +45 -0
- package/dist/internal/ExpressionLimits.d.ts.map +1 -0
- package/dist/internal/ExpressionLimits.js +89 -0
- package/dist/internal/ExpressionLimits.js.map +1 -0
- package/dist/internal/Hydrate.d.ts +62 -0
- package/dist/internal/Hydrate.d.ts.map +1 -0
- package/dist/internal/Hydrate.js +191 -0
- package/dist/internal/Hydrate.js.map +1 -0
- package/dist/internal/ItemSize.d.ts +60 -24
- package/dist/internal/ItemSize.d.ts.map +1 -1
- package/dist/internal/ItemSize.js +122 -39
- package/dist/internal/ItemSize.js.map +1 -1
- package/dist/internal/TransactWriteOps.d.ts +22 -46
- package/dist/internal/TransactWriteOps.d.ts.map +1 -1
- package/dist/internal/TransactWriteOps.js +26 -95
- package/dist/internal/TransactWriteOps.js.map +1 -1
- package/dist/internal/WriteDiagnosis.d.ts +353 -0
- package/dist/internal/WriteDiagnosis.d.ts.map +1 -0
- package/dist/internal/WriteDiagnosis.js +1028 -0
- package/dist/internal/WriteDiagnosis.js.map +1 -0
- package/dist/internal/sparse_check.js +43 -79
- package/dist/internal/sparse_check.js.map +1 -1
- package/package.json +2 -2
package/dist/EventStore.js
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
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
|
|
19
20
|
*
|
|
20
21
|
* Built on the existing library primitives (DynamoSchema, KeyComposer, Query,
|
|
@@ -22,11 +23,12 @@
|
|
|
22
23
|
*/
|
|
23
24
|
import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
|
|
24
25
|
import { normalizeTtlSeconds } from "@effect-dynamodb/schema/Entity.js";
|
|
25
|
-
import { AdditionalItemConditionFailed, AppendTooLarge, DuplicateCommand, isAwsConditionalCheckFailed, isAwsTransactionCancelled,
|
|
26
|
+
import { AdditionalItemConditionFailed, AppendTooLarge, DuplicateCommand, isAwsConditionalCheckFailed, isAwsTransactionCancelled, TransactionCancelled, ValidationError, VersionConflict, } from "@effect-dynamodb/schema/Errors.js";
|
|
26
27
|
import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
|
|
27
28
|
import { DateTime, Effect, Function, Option, Pipeable, Result, Schedule, Schema, } from "effect";
|
|
28
29
|
import { DynamoClient } from "./DynamoClient.js";
|
|
29
|
-
import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, judgeCancellation,
|
|
30
|
+
import { buildTransactWriteItems, GUARDED_TRANSACTION_ATTEMPTS, judgeCancellation, transactItemTarget, } from "./internal/TransactWriteOps.js";
|
|
31
|
+
import { diagnosedValidationError, diagnoseFailure, mergeKeyAttributes, withCause, } from "./internal/WriteDiagnosis.js";
|
|
30
32
|
import { fromAttributeMap, toAttributeMap } from "./Marshaller.js";
|
|
31
33
|
import * as Query from "./Query.js";
|
|
32
34
|
import { DEFAULT_TTL_ATTRIBUTE_NAME, resolveTtlAttributeName, } from "./Table.js";
|
|
@@ -45,30 +47,13 @@ const EventEnvelope = Schema.Struct({
|
|
|
45
47
|
eventType: Schema.String,
|
|
46
48
|
timestamp: Schema.String,
|
|
47
49
|
});
|
|
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
50
|
/**
|
|
69
51
|
* @internal Why a {@link ReadIndexOptions} key condition cannot be sent, or
|
|
70
|
-
* `undefined` when it can
|
|
71
|
-
* `
|
|
52
|
+
* `undefined` when it can: the options' own meaning — at most one of
|
|
53
|
+
* `beginsWith` and `between`, string bounds (a number would be sent as `N`
|
|
54
|
+
* against a string key). DynamoDB's rules on the bounds — not empty, a
|
|
55
|
+
* `between` in order — are DynamoDB's to enforce: its rejection is diagnosed,
|
|
56
|
+
* naming the index and the bound (#144).
|
|
72
57
|
*/
|
|
73
58
|
const invalidIndexKeyCondition = (options) => {
|
|
74
59
|
if (options?.beginsWith !== undefined && options.between !== undefined) {
|
|
@@ -76,26 +61,46 @@ const invalidIndexKeyCondition = (options) => {
|
|
|
76
61
|
}
|
|
77
62
|
const beginsWith = options?.beginsWith;
|
|
78
63
|
if (beginsWith !== undefined) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
return undefined;
|
|
64
|
+
return typeof beginsWith === "string"
|
|
65
|
+
? undefined
|
|
66
|
+
: "beginsWith must be a string; omit it to read the whole sub-stream.";
|
|
84
67
|
}
|
|
85
68
|
const between = options?.between;
|
|
86
69
|
if (between === undefined)
|
|
87
70
|
return undefined;
|
|
88
71
|
if (!Array.isArray(between) ||
|
|
89
72
|
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)}.`;
|
|
73
|
+
!between.every((bound) => typeof bound === "string")) {
|
|
74
|
+
return "between must be a pair of strings.";
|
|
96
75
|
}
|
|
97
76
|
return undefined;
|
|
98
77
|
};
|
|
78
|
+
/**
|
|
79
|
+
* @internal The attributes `append` writes on every event item besides the
|
|
80
|
+
* table key (`pk`, `sk`), the optional `metadata` and the index attributes:
|
|
81
|
+
* the envelope, `data` and the `__edd_e__` discriminator. Sorted. A
|
|
82
|
+
* `"withoutMetadata"` stream index projects exactly these.
|
|
83
|
+
*/
|
|
84
|
+
const EVENT_ITEM_ATTRIBUTES = [
|
|
85
|
+
"__edd_e__",
|
|
86
|
+
"data",
|
|
87
|
+
"eventType",
|
|
88
|
+
"streamId",
|
|
89
|
+
"timestamp",
|
|
90
|
+
"version",
|
|
91
|
+
];
|
|
92
|
+
/**
|
|
93
|
+
* @internal The event attributes an `"all"` or `"withoutMetadata"` stream index
|
|
94
|
+
* guarantees on its rows and a decode needs: a row lacking one was read
|
|
95
|
+
* through an index deployed narrower than declared.
|
|
96
|
+
*/
|
|
97
|
+
const PROJECTED_ENVELOPE_ATTRIBUTES = [
|
|
98
|
+
"data",
|
|
99
|
+
"eventType",
|
|
100
|
+
"streamId",
|
|
101
|
+
"timestamp",
|
|
102
|
+
"version",
|
|
103
|
+
];
|
|
99
104
|
/**
|
|
100
105
|
* @internal Attributes a stream writes on its own items — events, snapshot,
|
|
101
106
|
* idempotency sentinel — plus the default TTL attribute. An index attribute
|
|
@@ -116,11 +121,22 @@ const STREAM_OWNED_ATTRIBUTES = [
|
|
|
116
121
|
"commandId",
|
|
117
122
|
DEFAULT_TTL_ATTRIBUTE_NAME,
|
|
118
123
|
];
|
|
124
|
+
/** @internal Every member a stream index entry may declare (`StreamIndexConfigKey`). */
|
|
125
|
+
const STREAM_INDEX_CONFIG_KEYS = [
|
|
126
|
+
"type",
|
|
127
|
+
"index",
|
|
128
|
+
"pk",
|
|
129
|
+
"sk",
|
|
130
|
+
"key",
|
|
131
|
+
"projection",
|
|
132
|
+
];
|
|
119
133
|
/**
|
|
120
134
|
* @internal Validate `makeStream({ indexes })` at definition time.
|
|
121
135
|
*
|
|
122
136
|
* @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
|
|
137
|
+
* `pk`, an `lsi` with `pk`, an empty name, a `key` that is not a function,
|
|
138
|
+
* an unknown `projection`, a member the entry type does not declare — a
|
|
139
|
+
* misspelled `projection` would otherwise silently mean `"all"`).
|
|
124
140
|
* @throws `[EDD-9064]` for an index attribute that collides with a
|
|
125
141
|
* stream-owned attribute.
|
|
126
142
|
* @throws `[EDD-9065]` for two indexes sharing a physical index name or an
|
|
@@ -139,6 +155,11 @@ const resolveStreamIndexes = (streamName, indexes) => {
|
|
|
139
155
|
fail("EDD-9063", `index "${name}" must be an object; received ${String(raw)}.`);
|
|
140
156
|
}
|
|
141
157
|
const entry = raw;
|
|
158
|
+
const unknownKey = Object.keys(entry).find((key) => !STREAM_INDEX_CONFIG_KEYS.includes(key));
|
|
159
|
+
if (unknownKey !== undefined) {
|
|
160
|
+
fail("EDD-9063", `index "${name}": unknown member "${unknownKey}"; an index declares ` +
|
|
161
|
+
`${STREAM_INDEX_CONFIG_KEYS.join(", ")}.`);
|
|
162
|
+
}
|
|
142
163
|
const type = (entry.type ?? "lsi");
|
|
143
164
|
if (type !== "lsi" && type !== "gsi") {
|
|
144
165
|
fail("EDD-9063", `index "${name}": type must be "lsi" or "gsi"; received ${JSON.stringify(entry.type)}.`);
|
|
@@ -160,6 +181,11 @@ const resolveStreamIndexes = (streamName, indexes) => {
|
|
|
160
181
|
if (typeof entry.key !== "function") {
|
|
161
182
|
fail("EDD-9063", `index "${name}": key must be a function (event, version) => string | undefined.`);
|
|
162
183
|
}
|
|
184
|
+
const projection = (entry.projection ?? "all");
|
|
185
|
+
if (projection !== "all" && projection !== "withoutMetadata" && projection !== "keys") {
|
|
186
|
+
fail("EDD-9063", `index "${name}": projection must be "all", "withoutMetadata" or "keys"; received ` +
|
|
187
|
+
`${JSON.stringify(entry.projection)}.`);
|
|
188
|
+
}
|
|
163
189
|
const index = entry.index;
|
|
164
190
|
const sk = entry.sk;
|
|
165
191
|
const pk = type === "gsi" ? entry.pk : "pk";
|
|
@@ -189,6 +215,7 @@ const resolveStreamIndexes = (streamName, indexes) => {
|
|
|
189
215
|
index,
|
|
190
216
|
pk,
|
|
191
217
|
sk,
|
|
218
|
+
projection,
|
|
192
219
|
key: entry.key,
|
|
193
220
|
});
|
|
194
221
|
}
|
|
@@ -248,6 +275,32 @@ const unverifiedSnapshotRefusal = (streamName, settings, operation) => settings
|
|
|
248
275
|
`snapshot mode "inline" without "every" — the only mode that writes the snapshot in every ` +
|
|
249
276
|
`append, so it is normally at the head. This stream's snapshot is mode "${settings.mode}"` +
|
|
250
277
|
`${settings.every === undefined ? "" : ` with every: ${settings.every}`}.`;
|
|
278
|
+
/**
|
|
279
|
+
* @internal Why an `eventTimestamps` value cannot be written for `count`
|
|
280
|
+
* events — not an array, the wrong length, or an entry that is neither a
|
|
281
|
+
* valid `DateTime` (finite epoch) nor `undefined` — or `undefined` when it can. `label` opens the
|
|
282
|
+
* message (`"eventTimestamps has"` / `"eventTimestamps returned"`) and
|
|
283
|
+
* `events` names the events (`"2 events"` / `"the decision's 2 events"`).
|
|
284
|
+
*/
|
|
285
|
+
const eventTimestampsProblem = (timestamps, count, label, events) => {
|
|
286
|
+
if (!Array.isArray(timestamps) || timestamps.length !== count) {
|
|
287
|
+
return (`${label} ${Array.isArray(timestamps) ? timestamps.length : "no"} entries for ${events}: ` +
|
|
288
|
+
"it needs exactly one entry (a DateTime, or undefined) per event, in order.");
|
|
289
|
+
}
|
|
290
|
+
const malformed = timestamps.findIndex((t) => t !== undefined && (!DateTime.isDateTime(t) || !Number.isFinite(DateTime.toEpochMillis(t))));
|
|
291
|
+
if (malformed === -1)
|
|
292
|
+
return undefined;
|
|
293
|
+
const entry = timestamps[malformed];
|
|
294
|
+
// A DateTime with a NaN epoch (e.g. `setPartsUtc` from a bad parse) passes
|
|
295
|
+
// `isDateTime` but makes `formatIso` throw, so it is refused here instead.
|
|
296
|
+
if (DateTime.isDateTime(entry)) {
|
|
297
|
+
return (`eventTimestamps[${malformed}] is an invalid DateTime (its epoch milliseconds are not a ` +
|
|
298
|
+
"finite number): each entry must be a valid DateTime, or undefined to keep the append's own time.");
|
|
299
|
+
}
|
|
300
|
+
return (`eventTimestamps[${malformed}] is not a DateTime (received ` +
|
|
301
|
+
`${entry instanceof Date ? "a Date — convert it with DateTime.fromDateUnsafe" : entry === null ? "null" : typeof entry}): ` +
|
|
302
|
+
"each entry must be a DateTime, or undefined to keep the append's own time.");
|
|
303
|
+
};
|
|
251
304
|
// ---------------------------------------------------------------------------
|
|
252
305
|
// makeStream factory
|
|
253
306
|
// ---------------------------------------------------------------------------
|
|
@@ -310,7 +363,9 @@ const unverifiedSnapshotRefusal = (streamName, settings, operation) => settings
|
|
|
310
363
|
* event's derived key (and, for a GSI, the stream's partition key) on the
|
|
311
364
|
* event item; snapshots and idempotency sentinels never carry them, so they
|
|
312
365
|
* are never in an index. Read with {@link EventStream.readIndex} or
|
|
313
|
-
* `query.index`, and create the indexes with {@link indexDefinitions}.
|
|
366
|
+
* `query.index`, and create the indexes with {@link indexDefinitions}. Each
|
|
367
|
+
* index's `projection` (default `"all"`) sets what it copies from each event
|
|
368
|
+
* and how it is read — see {@link StreamIndexProjection}.
|
|
314
369
|
*
|
|
315
370
|
* Index attributes are written only at append time and events are never
|
|
316
371
|
* rewritten. Declaring an index on a stream that already holds events, or
|
|
@@ -347,7 +402,9 @@ const unverifiedSnapshotRefusal = (streamName, settings, operation) => settings
|
|
|
347
402
|
* `"inline"`.
|
|
348
403
|
* @throws `[EDD-9063]` for a malformed index: a `type` other than `"lsi"` /
|
|
349
404
|
* `"gsi"`, a `gsi` without `pk`, an `lsi` with `pk`, an empty `index` / `sk`
|
|
350
|
-
* / `pk`,
|
|
405
|
+
* / `pk`, a `key` that is not a function, a `projection` other than
|
|
406
|
+
* `"all"` / `"withoutMetadata"` / `"keys"`, or a member an index entry does
|
|
407
|
+
* not declare (such as a misspelled `projection`, also a type error).
|
|
351
408
|
* @throws `[EDD-9064]` when an index attribute is one the stream writes itself
|
|
352
409
|
* (`pk`, `sk`, `__edd_e__`, `streamId`, `version`, `eventType`, `data`,
|
|
353
410
|
* `metadata`, `timestamp`, `asOfVersion`, `state`, `commandId`, or the
|
|
@@ -410,7 +467,13 @@ export const makeStream = (config) => {
|
|
|
410
467
|
const streamIndexes = resolveStreamIndexes(config.streamName, config.indexes);
|
|
411
468
|
const indexSettings = {};
|
|
412
469
|
for (const idx of streamIndexes) {
|
|
413
|
-
indexSettings[idx.name] = {
|
|
470
|
+
indexSettings[idx.name] = {
|
|
471
|
+
type: idx.type,
|
|
472
|
+
index: idx.index,
|
|
473
|
+
pk: idx.pk,
|
|
474
|
+
sk: idx.sk,
|
|
475
|
+
projection: idx.projection,
|
|
476
|
+
};
|
|
414
477
|
}
|
|
415
478
|
/** Every attribute the indexes write on an event item. */
|
|
416
479
|
const indexAttributes = streamIndexes.flatMap((idx) => idx.type === "gsi" ? [idx.pk, idx.sk] : [idx.sk]);
|
|
@@ -512,10 +575,12 @@ export const makeStream = (config) => {
|
|
|
512
575
|
// append
|
|
513
576
|
// ---------------------------------------------------------------------------
|
|
514
577
|
/**
|
|
515
|
-
* An event's key in a stream index (#140): a
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
578
|
+
* An event's key in a stream index (#140): a string, or `undefined` to
|
|
579
|
+
* leave the event out. Another type, or a `key` that throws, is a
|
|
580
|
+
* `ValidationError`, raised before anything is written. DynamoDB's own
|
|
581
|
+
* rules on a key — not empty, at most 1024 bytes for a sort key — are not
|
|
582
|
+
* pre-checked: DynamoDB rejects the append and the rejection is diagnosed,
|
|
583
|
+
* naming the index and event (see `sendAppendTransaction`).
|
|
519
584
|
*/
|
|
520
585
|
const deriveIndexKey = (idx, event, version) => Effect.try({
|
|
521
586
|
try: () => idx.key(event, version),
|
|
@@ -529,12 +594,12 @@ export const makeStream = (config) => {
|
|
|
529
594
|
cause: `The key of index "${idx.name}" for the event at version ${version} ${problem}. ` +
|
|
530
595
|
"Nothing was written.",
|
|
531
596
|
}));
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
if (
|
|
537
|
-
return refuse(`
|
|
597
|
+
// A string, or the event is not indexed: what the stream's own key
|
|
598
|
+
// contract means, which DynamoDB cannot see (a number would be sent
|
|
599
|
+
// as `N`). An empty string is DynamoDB's to reject, and is diagnosed
|
|
600
|
+
// from its rejection, naming the index and the event (#144).
|
|
601
|
+
if (typeof derived !== "string") {
|
|
602
|
+
return refuse(`must be a string or undefined; received ${String(derived)}`);
|
|
538
603
|
}
|
|
539
604
|
return Effect.succeed(derived);
|
|
540
605
|
}));
|
|
@@ -552,69 +617,172 @@ export const makeStream = (config) => {
|
|
|
552
617
|
* the race a guarded additional put lost (so the transaction is built and
|
|
553
618
|
* written again), and fails with every other verdict.
|
|
554
619
|
*/
|
|
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({
|
|
620
|
+
const sendAppendTransaction = (client, tx, streamIdStr, commandId) => {
|
|
621
|
+
const sent = () => ({ _tag: "Transaction", items: tx.items, targets: tx.targets });
|
|
622
|
+
const diagnosed = (diagnosis, original) => diagnosis._tag === "TooManyItems"
|
|
623
|
+
? new AppendTooLarge({
|
|
570
624
|
streamName: config.streamName,
|
|
571
625
|
streamId: streamIdStr,
|
|
572
|
-
|
|
626
|
+
count: diagnosis.count,
|
|
627
|
+
limit: diagnosis.limit,
|
|
628
|
+
cause: original,
|
|
629
|
+
})
|
|
630
|
+
: appendLimitError(diagnosis, original, tx);
|
|
631
|
+
return client.transactWriteItems({ TransactItems: tx.items }).pipe(Effect.as(undefined),
|
|
632
|
+
// One operation per item (#133, #144): diagnosed BEFORE the conditional
|
|
633
|
+
// mapping below — DynamoDB can report a repeated item as a conditional
|
|
634
|
+
// failure, which would read as a version conflict or a lost race (and
|
|
635
|
+
// be retried). Terminal.
|
|
636
|
+
diagnoseFailure({ sent, only: ["RepeatedItem"], rewrite: diagnosed }), Effect.catch((error) => {
|
|
637
|
+
if (!isAwsTransactionCancelled(error.cause)) {
|
|
638
|
+
return Effect.fail(error);
|
|
639
|
+
}
|
|
640
|
+
const rawReasons = error.cause.CancellationReasons ?? [];
|
|
641
|
+
const reasons = rawReasons.map((r) => ({
|
|
642
|
+
code: r?.Code,
|
|
643
|
+
message: r?.Message,
|
|
573
644
|
}));
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
if (failedAt(
|
|
579
|
-
return Effect.fail(new
|
|
645
|
+
const failedAt = (index) => index >= 0 && reasons[index]?.code === "ConditionalCheckFailed";
|
|
646
|
+
// Precedence is ordered by how terminal the caller's response should
|
|
647
|
+
// be: a duplicate can never succeed on retry, a version conflict
|
|
648
|
+
// invites a re-read, and only then are the additional items judged.
|
|
649
|
+
if (commandId !== undefined && failedAt(tx.sentinelIndex)) {
|
|
650
|
+
return Effect.fail(new DuplicateCommand({
|
|
580
651
|
streamName: config.streamName,
|
|
581
652
|
streamId: streamIdStr,
|
|
582
|
-
|
|
653
|
+
commandId,
|
|
583
654
|
}));
|
|
584
655
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
656
|
+
// The contiguity ConditionCheck and the event puts both mean "the
|
|
657
|
+
// stream is not where you said it was", so they share one verdict.
|
|
658
|
+
for (let i = 0; i < tx.guardCount + tx.eventCount; i++) {
|
|
659
|
+
if (failedAt(i)) {
|
|
660
|
+
return Effect.fail(new VersionConflict({
|
|
661
|
+
streamName: config.streamName,
|
|
662
|
+
streamId: streamIdStr,
|
|
663
|
+
expectedVersion: tx.expectedVersion,
|
|
664
|
+
}));
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
// The additional items, attributed back to the caller OPS that
|
|
668
|
+
// produced them (several items can belong to one op). Only an op's
|
|
669
|
+
// own condition is `AdditionalItemConditionFailed`: a guarded put
|
|
670
|
+
// the caller set no condition on reports what the entity's own put
|
|
671
|
+
// would — a taken unique value, a history conflict — or, having
|
|
672
|
+
// lost a race to a concurrent write, is written again (#133).
|
|
673
|
+
const judged = judgeCancellation(tx.additional, rawReasons, tx.guardCount + tx.eventCount);
|
|
674
|
+
if (judged?._tag === "fail")
|
|
675
|
+
return Effect.fail(judged.error);
|
|
676
|
+
if (judged?._tag === "conditions") {
|
|
677
|
+
return Effect.fail(new AdditionalItemConditionFailed({
|
|
678
|
+
streamName: config.streamName,
|
|
679
|
+
streamId: streamIdStr,
|
|
680
|
+
indices: judged.opIndices,
|
|
681
|
+
reasons,
|
|
682
|
+
}));
|
|
683
|
+
}
|
|
684
|
+
if (judged?._tag === "retry")
|
|
685
|
+
return Effect.succeed(judged.error);
|
|
686
|
+
// No conditional failure we can positionally justify (throttling,
|
|
687
|
+
// TransactionConflict, a reason at the inline snapshot's position, or
|
|
688
|
+
// a truncated/absent reason list) — never guess a VersionConflict.
|
|
689
|
+
return Effect.fail(new TransactionCancelled({
|
|
690
|
+
operation: "TransactWriteItems",
|
|
600
691
|
reasons,
|
|
692
|
+
cause: error.cause,
|
|
601
693
|
}));
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
694
|
+
}),
|
|
695
|
+
// DynamoDB's limits are not pre-checked (#144): a rejection the
|
|
696
|
+
// conditional mapping above could not explain is diagnosed against the
|
|
697
|
+
// transaction sent, naming the event, sentinel, snapshot or additional
|
|
698
|
+
// item at fault. Over 100 items is `AppendTooLarge`, with the EXPANDED
|
|
699
|
+
// count.
|
|
700
|
+
diagnoseFailure({
|
|
701
|
+
sent,
|
|
702
|
+
keys: () => mergeKeyAttributes({
|
|
703
|
+
partition: ["pk", ...streamIndexes.map((idx) => idx.pk)],
|
|
704
|
+
sort: ["sk", ...streamIndexes.map((idx) => idx.sk)],
|
|
705
|
+
}, tx.additional.keys()),
|
|
706
|
+
rewrite: diagnosed,
|
|
612
707
|
}));
|
|
613
|
-
}
|
|
708
|
+
};
|
|
709
|
+
/**
|
|
710
|
+
* A diagnosed rejection of an append (#144) other than its item count. An
|
|
711
|
+
* over-long (or empty) stream-index sort key OF AN EVENT — the value the
|
|
712
|
+
* index's `key` function returned — is named by its index and event, as
|
|
713
|
+
* the `indexes` option reports its other key problems. Only the event puts
|
|
714
|
+
* carry stream-index keys: an additional item writing the same physical
|
|
715
|
+
* attribute (single-table designs share `gsi1sk` and the like) is its own
|
|
716
|
+
* entity's key, and is named as any other item.
|
|
717
|
+
*/
|
|
718
|
+
const appendLimitError = (diagnosis, original, tx) => {
|
|
719
|
+
if ((diagnosis._tag === "KeyTooLong" && diagnosis.kind === "sort") ||
|
|
720
|
+
diagnosis._tag === "EmptyKey") {
|
|
721
|
+
const position = tx.targets().indexOf(diagnosis.target);
|
|
722
|
+
const isEvent = position >= tx.guardCount && position < tx.guardCount + tx.eventCount;
|
|
723
|
+
const idx = isEvent
|
|
724
|
+
? streamIndexes.find((candidate) => candidate.sk === diagnosis.attribute)
|
|
725
|
+
: undefined;
|
|
726
|
+
if (idx !== undefined) {
|
|
727
|
+
return new ValidationError({
|
|
728
|
+
entityType,
|
|
729
|
+
operation: "EventStore.append.indexes",
|
|
730
|
+
cause: withCause(`The key of index "${idx.name}" for ${diagnosis.target.source} ` +
|
|
731
|
+
(diagnosis._tag === "KeyTooLong"
|
|
732
|
+
? `is ${diagnosis.bytes} bytes, longer than DynamoDB's 1024-byte limit for a ` +
|
|
733
|
+
"sort key."
|
|
734
|
+
: "is an empty string, which DynamoDB does not accept as a key value; the " +
|
|
735
|
+
"key function must return a non-empty string, or undefined to leave the " +
|
|
736
|
+
"event out of the index.") +
|
|
737
|
+
" DynamoDB rejected the append. Nothing was written.", original),
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return diagnosedValidationError(diagnosis, original, {
|
|
742
|
+
operation: "EventStore.append",
|
|
743
|
+
entityType,
|
|
744
|
+
});
|
|
745
|
+
};
|
|
614
746
|
const append = (streamId, events, expectedVersion, options) => Effect.gen(function* () {
|
|
615
747
|
const additionalOps = options?.additionalItems ?? [];
|
|
616
748
|
const idempotency = options?.idempotency;
|
|
617
749
|
const snapshotState = options?.snapshot;
|
|
750
|
+
const eventMetadata = options?.eventMetadata;
|
|
751
|
+
// Per-event metadata (#144) is checked first — ahead of the no-op fast
|
|
752
|
+
// path below, which would otherwise accept `append(id, [], v,
|
|
753
|
+
// { eventMetadata: [m] })` without looking at it.
|
|
754
|
+
if (eventMetadata !== undefined) {
|
|
755
|
+
if (options?.metadata !== undefined) {
|
|
756
|
+
return yield* new ValidationError({
|
|
757
|
+
entityType,
|
|
758
|
+
operation: "EventStore.append.eventMetadata",
|
|
759
|
+
cause: "metadata and eventMetadata are mutually exclusive: pass one value shared by " +
|
|
760
|
+
"every event, or one entry per event. Nothing was written.",
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
if (!Array.isArray(eventMetadata) || eventMetadata.length !== events.length) {
|
|
764
|
+
return yield* new ValidationError({
|
|
765
|
+
entityType,
|
|
766
|
+
operation: "EventStore.append.eventMetadata",
|
|
767
|
+
cause: `eventMetadata has ${Array.isArray(eventMetadata) ? eventMetadata.length : "no"} ` +
|
|
768
|
+
`entries for ${events.length} events: it needs exactly one entry (or undefined) ` +
|
|
769
|
+
"per event, in order. Nothing was written.",
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
// Per-event timestamps (#144), checked alongside the metadata and for
|
|
774
|
+
// the same reason: ahead of the no-op fast path.
|
|
775
|
+
const eventTimestamps = options?.eventTimestamps;
|
|
776
|
+
if (eventTimestamps !== undefined) {
|
|
777
|
+
const problem = eventTimestampsProblem(eventTimestamps, events.length, "eventTimestamps has", `${events.length} events`);
|
|
778
|
+
if (problem !== undefined) {
|
|
779
|
+
return yield* new ValidationError({
|
|
780
|
+
entityType,
|
|
781
|
+
operation: "EventStore.append.eventTimestamps",
|
|
782
|
+
cause: `${problem} Nothing was written.`,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
}
|
|
618
786
|
if (snapshotState !== undefined && snapshot === undefined) {
|
|
619
787
|
return yield* snapshotUnavailable("append({ snapshot })");
|
|
620
788
|
}
|
|
@@ -641,32 +809,11 @@ export const makeStream = (config) => {
|
|
|
641
809
|
`owns version ${expectedVersion}. Nothing was written.`,
|
|
642
810
|
});
|
|
643
811
|
}
|
|
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.
|
|
812
|
+
// DynamoDB's 100-item limit on one transaction is not pre-checked
|
|
813
|
+
// (#144): an append is never split — splitting would break its
|
|
814
|
+
// atomicity — so one over the limit is rejected whole by DynamoDB and
|
|
815
|
+
// reported as `AppendTooLarge`, with the expanded item count.
|
|
657
816
|
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
817
|
const client = yield* DynamoClient;
|
|
671
818
|
const tableConfig = yield* config.table.Tag;
|
|
672
819
|
const tableName = tableConfig.name;
|
|
@@ -687,7 +834,8 @@ export const makeStream = (config) => {
|
|
|
687
834
|
// Clock-backed timestamp (deterministic under TestClock; wall-clock in prod).
|
|
688
835
|
const nowDateTime = yield* DateTime.now;
|
|
689
836
|
const now = DateTime.formatIso(nowDateTime);
|
|
690
|
-
// Validate and encode metadata to wire form if schema provided
|
|
837
|
+
// Validate and encode metadata to wire form if schema provided. Shared
|
|
838
|
+
// by every event; per-event `eventMetadata` is encoded in the loop below.
|
|
691
839
|
let encodedMetadata;
|
|
692
840
|
if (options?.metadata !== undefined && metadataSchema) {
|
|
693
841
|
const encoded = yield* encodeToWire(metadataSchema, options.metadata, "EventStore.append.metadata");
|
|
@@ -711,7 +859,25 @@ export const makeStream = (config) => {
|
|
|
711
859
|
// Inject _tag for plain Schema.Class events; Schema.TaggedClass
|
|
712
860
|
// events already carry _tag in their encoded form, which wins.
|
|
713
861
|
const eventData = { _tag: evtType, ...wire };
|
|
714
|
-
|
|
862
|
+
// This event's own metadata entry (#144), encoded as `metadata`
|
|
863
|
+
// is; an `undefined` entry writes no metadata attribute.
|
|
864
|
+
let itemMetadata = encodedMetadata;
|
|
865
|
+
if (eventMetadata !== undefined) {
|
|
866
|
+
const entry = eventMetadata[i];
|
|
867
|
+
itemMetadata =
|
|
868
|
+
entry === undefined
|
|
869
|
+
? undefined
|
|
870
|
+
: metadataSchema
|
|
871
|
+
? (yield* encodeToWire(metadataSchema, entry, `EventStore.append.eventMetadata[${i}]`))
|
|
872
|
+
: entry;
|
|
873
|
+
}
|
|
874
|
+
// This event's own timestamp (#144), or the append's `now`.
|
|
875
|
+
// `formatIso` renders the UTC instant, so a zoned DateTime is
|
|
876
|
+
// stored in the same format as `now`.
|
|
877
|
+
const eventTimestamp = eventTimestamps?.[i];
|
|
878
|
+
// Exactly the table key and EVENT_ITEM_ATTRIBUTES — what a
|
|
879
|
+
// "withoutMetadata" stream index projects — checked by the type.
|
|
880
|
+
const envelope = {
|
|
715
881
|
pk,
|
|
716
882
|
sk: composeEventSk(version),
|
|
717
883
|
__edd_e__: entityType,
|
|
@@ -719,10 +885,11 @@ export const makeStream = (config) => {
|
|
|
719
885
|
version,
|
|
720
886
|
eventType: evtType,
|
|
721
887
|
data: eventData,
|
|
722
|
-
timestamp: now,
|
|
888
|
+
timestamp: eventTimestamp === undefined ? now : DateTime.formatIso(eventTimestamp),
|
|
723
889
|
};
|
|
724
|
-
|
|
725
|
-
|
|
890
|
+
const item = { ...envelope };
|
|
891
|
+
if (itemMetadata !== undefined) {
|
|
892
|
+
item.metadata = itemMetadata;
|
|
726
893
|
}
|
|
727
894
|
// Stream indexes (#140): the derived key, stored raw, and for a
|
|
728
895
|
// GSI the stream's partition key. The attributes are part of the
|
|
@@ -832,10 +999,11 @@ export const makeStream = (config) => {
|
|
|
832
999
|
*/
|
|
833
1000
|
const layout = (additional) => {
|
|
834
1001
|
const items = [];
|
|
835
|
-
|
|
1002
|
+
// What names each item, so the targets are built only on failure.
|
|
1003
|
+
const naming = [];
|
|
836
1004
|
const pushStreamItem = (item, type, source) => {
|
|
837
1005
|
items.push(item);
|
|
838
|
-
|
|
1006
|
+
naming.push(() => [transactItemTarget(item, tableName, ["pk", "sk"], type, source)]);
|
|
839
1007
|
};
|
|
840
1008
|
if (contiguityCheck !== undefined) {
|
|
841
1009
|
pushStreamItem(contiguityCheck, entityType, "the version-contiguity check");
|
|
@@ -845,7 +1013,7 @@ export const makeStream = (config) => {
|
|
|
845
1013
|
pushStreamItem(item, entityType, `the event at version ${expectedVersion + i + 1}`);
|
|
846
1014
|
}
|
|
847
1015
|
items.push(...additional.items);
|
|
848
|
-
|
|
1016
|
+
naming.push(additional.targets);
|
|
849
1017
|
const sentinelIndex = sentinelPut === undefined ? -1 : items.length;
|
|
850
1018
|
if (sentinelPut !== undefined) {
|
|
851
1019
|
pushStreamItem(sentinelPut, entityType, "the idempotency sentinel");
|
|
@@ -853,9 +1021,11 @@ export const makeStream = (config) => {
|
|
|
853
1021
|
if (snapshotPut !== undefined) {
|
|
854
1022
|
pushStreamItem(snapshotPut, snapshotEntityType, "the inline snapshot");
|
|
855
1023
|
}
|
|
1024
|
+
let targets;
|
|
856
1025
|
return {
|
|
857
1026
|
items,
|
|
858
|
-
|
|
1027
|
+
// Built once, on failure: a diagnosis's target is found by identity.
|
|
1028
|
+
targets: () => (targets ??= naming.flatMap((name) => name())),
|
|
859
1029
|
guardCount,
|
|
860
1030
|
eventCount: eventItems.length,
|
|
861
1031
|
additional,
|
|
@@ -871,25 +1041,11 @@ export const makeStream = (config) => {
|
|
|
871
1041
|
// `Transaction.transactWrite` uses, so the two APIs cannot drift.
|
|
872
1042
|
const additional = yield* buildTransactWriteItems(additionalOps, "EventStore.append.additionalItems");
|
|
873
1043
|
const tx = layout(additional);
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
|
|
879
|
-
return yield* new AppendTooLarge({
|
|
880
|
-
streamName: config.streamName,
|
|
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");
|
|
1044
|
+
// DynamoDB's rules — one operation per item (an additional item
|
|
1045
|
+
// repeating an event, the contiguity check, the idempotency sentinel
|
|
1046
|
+
// or the inline snapshot, or another additional item), 100 items,
|
|
1047
|
+
// 4 MB, 400 KB per item, 1024-byte sort keys — are not pre-checked:
|
|
1048
|
+
// `sendAppendTransaction` diagnoses DynamoDB's rejection (#144).
|
|
893
1049
|
const outcome = yield* sendAppendTransaction(client, tx, streamIdStr, idempotency?.commandId);
|
|
894
1050
|
if (outcome === undefined) {
|
|
895
1051
|
return { version: expectedVersion + events.length, events };
|
|
@@ -965,8 +1121,7 @@ export const makeStream = (config) => {
|
|
|
965
1121
|
state: encoded,
|
|
966
1122
|
timestamp: now,
|
|
967
1123
|
};
|
|
968
|
-
|
|
969
|
-
.putItem({
|
|
1124
|
+
const put = {
|
|
970
1125
|
TableName: tableName,
|
|
971
1126
|
Item: toAttributeMap(item),
|
|
972
1127
|
// Monotonic: never regress the cache. Losing this race is a no-op,
|
|
@@ -974,8 +1129,21 @@ export const makeStream = (config) => {
|
|
|
974
1129
|
ConditionExpression: "attribute_not_exists(#pk) OR #asOfVersion < :asOfVersion",
|
|
975
1130
|
ExpressionAttributeNames: { "#pk": "pk", "#asOfVersion": "asOfVersion" },
|
|
976
1131
|
ExpressionAttributeValues: toAttributeMap({ ":asOfVersion": asOfVersion }),
|
|
977
|
-
}
|
|
978
|
-
|
|
1132
|
+
};
|
|
1133
|
+
yield* client.putItem(put).pipe(Effect.catchIf((error) => isAwsConditionalCheckFailed(error.cause), () => Effect.void),
|
|
1134
|
+
// An oversized snapshot is DynamoDB's to reject (#144); named here.
|
|
1135
|
+
diagnoseFailure({
|
|
1136
|
+
sent: () => ({
|
|
1137
|
+
_tag: "Item",
|
|
1138
|
+
item: { Put: put },
|
|
1139
|
+
target: () => transactItemTarget({ Put: put }, tableName, ["pk", "sk"], snapshotEntityType, "the snapshot"),
|
|
1140
|
+
}),
|
|
1141
|
+
keys: () => ({ partition: ["pk"], sort: ["sk"] }),
|
|
1142
|
+
rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, {
|
|
1143
|
+
operation: "EventStore.writeSnapshot",
|
|
1144
|
+
entityType: snapshotEntityType,
|
|
1145
|
+
}),
|
|
1146
|
+
}));
|
|
979
1147
|
});
|
|
980
1148
|
/**
|
|
981
1149
|
* Decode a stored snapshot item. A snapshot that fails to decode through the
|
|
@@ -1178,14 +1346,63 @@ export const makeStream = (config) => {
|
|
|
1178
1346
|
pkValue: composeStreamPk(streamId),
|
|
1179
1347
|
skField: idx.sk,
|
|
1180
1348
|
entityTypes: [entityType],
|
|
1181
|
-
decoder: (
|
|
1349
|
+
decoder: indexDecoder(idx),
|
|
1182
1350
|
resolveTableName: config.table.Tag.useSync((tc) => tc.name),
|
|
1183
|
-
//
|
|
1184
|
-
//
|
|
1185
|
-
|
|
1351
|
+
// DynamoDB's rejection of the key condition (an empty bound, a reversed
|
|
1352
|
+
// `between`) is named against the stream index; so, as for every
|
|
1353
|
+
// query, is a consistent read of a GSI (#144).
|
|
1354
|
+
keyConditionDiagnosis: {
|
|
1355
|
+
operation: "EventStore.readIndex",
|
|
1356
|
+
label: `EventStream "${config.streamName}" index "${idx.name}"`,
|
|
1357
|
+
},
|
|
1186
1358
|
keyFields: [idx.pk, idx.sk, "pk", "sk"],
|
|
1359
|
+
// A "keys" index holds no events: an LSI has DynamoDB fetch them
|
|
1360
|
+
// (`Select: ALL_ATTRIBUTES`), a GSI fetches each page's with
|
|
1361
|
+
// `BatchGetItem` on the table key.
|
|
1362
|
+
...(idx.projection === "keys"
|
|
1363
|
+
? {
|
|
1364
|
+
hydration: idx.type === "lsi"
|
|
1365
|
+
? { _tag: "Fetch" }
|
|
1366
|
+
: { _tag: "BatchGet", tableKey: ["pk", "sk"] },
|
|
1367
|
+
}
|
|
1368
|
+
: {}),
|
|
1187
1369
|
});
|
|
1188
1370
|
};
|
|
1371
|
+
/**
|
|
1372
|
+
* @internal The decoder of events read through `idx`.
|
|
1373
|
+
*
|
|
1374
|
+
* - `"withoutMetadata"` ignores any `metadata` on the row (an index deployed
|
|
1375
|
+
* wider than declared still carries it), so the value matches the type.
|
|
1376
|
+
* - `"all"` / `"withoutMetadata"`: a decode failure on a row that lacks an
|
|
1377
|
+
* attribute the declared projection guarantees is diagnosed as the
|
|
1378
|
+
* deployed index projecting less than declared — a `ValidationError`
|
|
1379
|
+
* (`operation: "EventStore.index.decode"`) naming the index, the original
|
|
1380
|
+
* error kept as its cause. Anything else passes through unchanged.
|
|
1381
|
+
* - `"keys"` decodes items fetched from the table, so a missing attribute
|
|
1382
|
+
* there is not a projection problem: failures pass through.
|
|
1383
|
+
*/
|
|
1384
|
+
const indexDecoder = (idx) => (raw) => {
|
|
1385
|
+
if (idx.projection === "keys")
|
|
1386
|
+
return decodeStreamEvent(raw);
|
|
1387
|
+
let row = raw;
|
|
1388
|
+
if (idx.projection === "withoutMetadata" && raw.metadata !== undefined) {
|
|
1389
|
+
row = { ...raw };
|
|
1390
|
+
delete row.metadata;
|
|
1391
|
+
}
|
|
1392
|
+
return decodeStreamEvent(row).pipe(Effect.mapError((error) => {
|
|
1393
|
+
const missing = PROJECTED_ENVELOPE_ATTRIBUTES.filter((name) => row[name] === undefined);
|
|
1394
|
+
if (missing.length === 0)
|
|
1395
|
+
return error;
|
|
1396
|
+
return new ValidationError({
|
|
1397
|
+
entityType,
|
|
1398
|
+
operation: "EventStore.index.decode",
|
|
1399
|
+
cause: withCause(`Stream index "${idx.name}" (physical "${idx.index}") is declared ` +
|
|
1400
|
+
`projection: "${idx.projection}", but its rows lack ${missing.join(", ")}: the ` +
|
|
1401
|
+
"deployed index likely projects less than declared. Deploy the Projection " +
|
|
1402
|
+
"EventStore.indexDefinitions emits for it.", error),
|
|
1403
|
+
});
|
|
1404
|
+
}));
|
|
1405
|
+
};
|
|
1189
1406
|
const readIndex = (name, streamId, options) => Effect.gen(function* () {
|
|
1190
1407
|
const refuse = (cause) => new ValidationError({ entityType, operation: "EventStore.readIndex", cause });
|
|
1191
1408
|
const invalidCondition = invalidIndexKeyCondition(options);
|
|
@@ -1298,14 +1515,43 @@ export const bind = (stream) => Effect.gen(function* () {
|
|
|
1298
1515
|
: (id, floorVersion, options) => provide(headProbe(id, floorVersion, options)),
|
|
1299
1516
|
};
|
|
1300
1517
|
});
|
|
1518
|
+
// ---------------------------------------------------------------------------
|
|
1519
|
+
// EventStore.indexDefinitions — CreateTable fragments for stream indexes (#140)
|
|
1520
|
+
// ---------------------------------------------------------------------------
|
|
1521
|
+
/**
|
|
1522
|
+
* @internal The `Projection` a stream index of `projection` is created with.
|
|
1523
|
+
* Neither list names a key attribute: table and index keys are always
|
|
1524
|
+
* projected.
|
|
1525
|
+
*/
|
|
1526
|
+
const projectionDefinition = (projection) => projection === "all"
|
|
1527
|
+
? { ProjectionType: "ALL" }
|
|
1528
|
+
: {
|
|
1529
|
+
ProjectionType: "INCLUDE",
|
|
1530
|
+
NonKeyAttributes: projection === "keys" ? ["__edd_e__"] : [...EVENT_ITEM_ATTRIBUTES],
|
|
1531
|
+
};
|
|
1301
1532
|
/**
|
|
1302
1533
|
* Derive the `CreateTable` fragments for the stream indexes (#140) of the given
|
|
1303
1534
|
* streams (`EventStream` or `BoundEventStream`) — `AttributeDefinitions`,
|
|
1304
|
-
* `LocalSecondaryIndexes` and `GlobalSecondaryIndexes
|
|
1305
|
-
*
|
|
1306
|
-
*
|
|
1307
|
-
*
|
|
1308
|
-
*
|
|
1535
|
+
* `LocalSecondaryIndexes` and `GlobalSecondaryIndexes` — to merge into the
|
|
1536
|
+
* caller's `CreateTable` input. Event tables are not derived by
|
|
1537
|
+
* `Table.definition`, and the table's own `pk` / `sk` are not included: add
|
|
1538
|
+
* them (and, on a provisioned table, each GSI's `ProvisionedThroughput`)
|
|
1539
|
+
* yourself.
|
|
1540
|
+
*
|
|
1541
|
+
* Each index's `Projection` follows its {@link StreamIndexProjection}:
|
|
1542
|
+
*
|
|
1543
|
+
* | `projection` | `Projection` |
|
|
1544
|
+
* |---|---|
|
|
1545
|
+
* | `"all"` (default) | `{ ProjectionType: "ALL" }` |
|
|
1546
|
+
* | `"withoutMetadata"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__", "data", "eventType", "streamId", "timestamp", "version"] }` |
|
|
1547
|
+
* | `"keys"` | `{ ProjectionType: "INCLUDE", NonKeyAttributes: ["__edd_e__"] }` — not `KEYS_ONLY`: every read filters on `__edd_e__` |
|
|
1548
|
+
*
|
|
1549
|
+
* The deployed index must project what is declared — copy `Projection` from
|
|
1550
|
+
* this output into CDK or Terraform definitions. Neither an LSI's nor a GSI's
|
|
1551
|
+
* projection can be changed in place, and the library migrates nothing. A
|
|
1552
|
+
* table projects at most 100 `NonKeyAttributes` across all its indexes;
|
|
1553
|
+
* `"withoutMetadata"` spends 6 of them and `"keys"` 1 (DynamoDB refuses a
|
|
1554
|
+
* table over the limit).
|
|
1309
1555
|
*
|
|
1310
1556
|
* An index shared by several streams with the same definition is emitted
|
|
1311
1557
|
* once. Lists are sorted by index name, attributes by attribute name.
|
|
@@ -1313,7 +1559,6 @@ export const bind = (stream) => Effect.gen(function* () {
|
|
|
1313
1559
|
* - An LSI can only be created with the table, and caps the item collection
|
|
1314
1560
|
* of every partition key value in the table at 10 GB — entity partitions
|
|
1315
1561
|
* and streams without indexes included.
|
|
1316
|
-
* - The projection must stay `ALL`: events are decoded from the index item.
|
|
1317
1562
|
*
|
|
1318
1563
|
* @example
|
|
1319
1564
|
* ```typescript
|
|
@@ -1336,12 +1581,13 @@ export const bind = (stream) => Effect.gen(function* () {
|
|
|
1336
1581
|
* ```
|
|
1337
1582
|
*
|
|
1338
1583
|
* @throws `[EDD-9067]` when two streams define the same physical index
|
|
1339
|
-
* differently (another type,
|
|
1584
|
+
* differently (another type, other key attributes, or another projection).
|
|
1340
1585
|
*/
|
|
1341
1586
|
export const indexDefinitions = (...streams) => {
|
|
1342
1587
|
const byIndex = new Map();
|
|
1343
1588
|
for (const stream of streams) {
|
|
1344
|
-
for (const [name,
|
|
1589
|
+
for (const [name, raw] of Object.entries(stream.indexes)) {
|
|
1590
|
+
const settings = { ...raw, projection: raw.projection ?? "all" };
|
|
1345
1591
|
const owner = `index "${name}" of stream "${stream.streamName}"`;
|
|
1346
1592
|
const existing = byIndex.get(settings.index);
|
|
1347
1593
|
if (existing === undefined) {
|
|
@@ -1350,14 +1596,16 @@ export const indexDefinitions = (...streams) => {
|
|
|
1350
1596
|
index: settings.index,
|
|
1351
1597
|
pk: settings.pk,
|
|
1352
1598
|
sk: settings.sk,
|
|
1599
|
+
projection: settings.projection,
|
|
1353
1600
|
owner,
|
|
1354
1601
|
});
|
|
1355
1602
|
continue;
|
|
1356
1603
|
}
|
|
1357
1604
|
if (existing.type !== settings.type ||
|
|
1358
1605
|
existing.pk !== settings.pk ||
|
|
1359
|
-
existing.sk !== settings.sk
|
|
1360
|
-
|
|
1606
|
+
existing.sk !== settings.sk ||
|
|
1607
|
+
existing.projection !== settings.projection) {
|
|
1608
|
+
const describe = (s) => `${s.type} (pk "${s.pk}", sk "${s.sk}", projection "${s.projection}")`;
|
|
1361
1609
|
throw new Error(`[EDD-9067] EventStore.indexDefinitions: the physical index "${settings.index}" is ` +
|
|
1362
1610
|
`defined as ${describe(existing)} by ${existing.owner} and as ${describe(settings)} ` +
|
|
1363
1611
|
`by ${owner}.`);
|
|
@@ -1375,7 +1623,7 @@ export const indexDefinitions = (...streams) => {
|
|
|
1375
1623
|
{ AttributeName: settings.pk, KeyType: "HASH" },
|
|
1376
1624
|
{ AttributeName: settings.sk, KeyType: "RANGE" },
|
|
1377
1625
|
],
|
|
1378
|
-
Projection:
|
|
1626
|
+
Projection: projectionDefinition(settings.projection),
|
|
1379
1627
|
};
|
|
1380
1628
|
attributes.add(settings.sk);
|
|
1381
1629
|
if (settings.type === "gsi") {
|
|
@@ -1396,13 +1644,27 @@ export const indexDefinitions = (...streams) => {
|
|
|
1396
1644
|
};
|
|
1397
1645
|
/** @internal Both `EventStream` and `BoundEventStream` carry this brand. */
|
|
1398
1646
|
const hasEventStreamBrand = (u) => typeof u === "object" && u !== null && EventStreamTypeId in u;
|
|
1399
|
-
/** @internal Resolve a {@link
|
|
1647
|
+
/** @internal Resolve a {@link CommandOptionsBase.additionalItems} against a decision. */
|
|
1400
1648
|
const deriveAdditionalItems = (input, decision) => {
|
|
1401
1649
|
if (typeof input !== "function")
|
|
1402
1650
|
return Effect.succeed(input);
|
|
1403
1651
|
const derived = input(decision);
|
|
1404
1652
|
return Effect.isEffect(derived) ? derived : Effect.succeed(derived);
|
|
1405
1653
|
};
|
|
1654
|
+
/** @internal Resolve an `eventMetadata` ({@link CommandMetadataOptions}) against a decision. */
|
|
1655
|
+
const deriveEventMetadata = (input, decision) => {
|
|
1656
|
+
if (input === undefined)
|
|
1657
|
+
return Effect.succeed(undefined);
|
|
1658
|
+
const derived = input(decision);
|
|
1659
|
+
return Effect.isEffect(derived) ? derived : Effect.succeed(derived);
|
|
1660
|
+
};
|
|
1661
|
+
/** @internal Resolve a {@link CommandOptionsBase.eventTimestamps} against a decision. */
|
|
1662
|
+
const deriveEventTimestamps = (input, decision) => {
|
|
1663
|
+
if (input === undefined)
|
|
1664
|
+
return Effect.succeed(undefined);
|
|
1665
|
+
const derived = input(decision);
|
|
1666
|
+
return Effect.isEffect(derived) ? derived : Effect.succeed(derived);
|
|
1667
|
+
};
|
|
1406
1668
|
/** @internal */
|
|
1407
1669
|
const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
1408
1670
|
// `verifySnapshot: false` is refused up front unless the stream writes its
|
|
@@ -1493,15 +1755,44 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
1493
1755
|
for (const event of newEvents) {
|
|
1494
1756
|
next = decider.evolve(next, event);
|
|
1495
1757
|
}
|
|
1496
|
-
// 6. Derive the caller's
|
|
1497
|
-
//
|
|
1498
|
-
//
|
|
1499
|
-
|
|
1758
|
+
// 6. Derive the caller's per-event metadata, then per-event
|
|
1759
|
+
// timestamps, then items, from the decision — in that order
|
|
1760
|
+
// (#144). Re-run on every attempt (and on a re-decision), because
|
|
1761
|
+
// the whole cycle is the retried unit.
|
|
1762
|
+
const decision = {
|
|
1500
1763
|
events: newEvents,
|
|
1501
1764
|
state: next,
|
|
1502
1765
|
previous,
|
|
1503
1766
|
version: baseVersion,
|
|
1504
|
-
}
|
|
1767
|
+
};
|
|
1768
|
+
const eventMetadata = yield* deriveEventMetadata(callOptions?.eventMetadata, decision);
|
|
1769
|
+
// A supplied function must return the array — `undefined` (e.g. a
|
|
1770
|
+
// block body with no `return`) is refused, not silently dropped.
|
|
1771
|
+
if (callOptions?.eventMetadata !== undefined &&
|
|
1772
|
+
(!Array.isArray(eventMetadata) || eventMetadata.length !== newEvents.length)) {
|
|
1773
|
+
return yield* new ValidationError({
|
|
1774
|
+
entityType: stream.streamName,
|
|
1775
|
+
operation: "EventStore.commandHandler.eventMetadata",
|
|
1776
|
+
cause: `eventMetadata returned ${Array.isArray(eventMetadata) ? eventMetadata.length : "no"} ` +
|
|
1777
|
+
`entries for the decision's ${newEvents.length} events: it must return exactly ` +
|
|
1778
|
+
"one entry (or undefined) per event, in order. Nothing was written.",
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
// A function (checked before the load), returning the array or an
|
|
1782
|
+
// `Effect` of it; the result of either form is checked here, so the
|
|
1783
|
+
// error names the handler's option rather than `append`'s.
|
|
1784
|
+
const eventTimestamps = yield* deriveEventTimestamps(callOptions?.eventTimestamps, decision);
|
|
1785
|
+
if (callOptions?.eventTimestamps !== undefined) {
|
|
1786
|
+
const problem = eventTimestampsProblem(eventTimestamps, newEvents.length, "eventTimestamps returned", `the decision's ${newEvents.length} events`);
|
|
1787
|
+
if (problem !== undefined) {
|
|
1788
|
+
return yield* new ValidationError({
|
|
1789
|
+
entityType: stream.streamName,
|
|
1790
|
+
operation: "EventStore.commandHandler.eventTimestamps",
|
|
1791
|
+
cause: `${problem} Nothing was written.`,
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
const additionalItems = yield* deriveAdditionalItems(callOptions?.additionalItems, decision);
|
|
1505
1796
|
// 7. Append with optimistic concurrency, plus the caller's items, the
|
|
1506
1797
|
// dedup sentinel and an inline snapshot, all in one transaction.
|
|
1507
1798
|
// `baseVersion` equals any caller-supplied `expectedVersion` here
|
|
@@ -1509,6 +1800,10 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
1509
1800
|
const appendOptions = {};
|
|
1510
1801
|
if (callOptions?.metadata !== undefined)
|
|
1511
1802
|
appendOptions.metadata = callOptions.metadata;
|
|
1803
|
+
if (eventMetadata !== undefined)
|
|
1804
|
+
appendOptions.eventMetadata = eventMetadata;
|
|
1805
|
+
if (eventTimestamps !== undefined)
|
|
1806
|
+
appendOptions.eventTimestamps = eventTimestamps;
|
|
1512
1807
|
if (additionalItems !== undefined)
|
|
1513
1808
|
appendOptions.additionalItems = additionalItems;
|
|
1514
1809
|
if (options?.idempotency !== undefined && callOptions?.commandId !== undefined) {
|
|
@@ -1598,6 +1893,37 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
1598
1893
|
cause: "commandId is required when commandHandler is configured with `idempotency`.",
|
|
1599
1894
|
});
|
|
1600
1895
|
}
|
|
1896
|
+
// The same backstop for the exclusive metadata pair (#144), and for an
|
|
1897
|
+
// `eventMetadata` or `eventTimestamps` that is not the function it must
|
|
1898
|
+
// be — refused before anything is read, rather than by `append` after
|
|
1899
|
+
// `decide`.
|
|
1900
|
+
if (callOptions?.eventMetadata !== undefined) {
|
|
1901
|
+
if (callOptions.metadata !== undefined) {
|
|
1902
|
+
return yield* new ValidationError({
|
|
1903
|
+
entityType: stream.streamName,
|
|
1904
|
+
operation: "EventStore.commandHandler.eventMetadata",
|
|
1905
|
+
cause: "metadata and eventMetadata are mutually exclusive: pass one value shared by " +
|
|
1906
|
+
"every event, or a function deriving one entry per event. Nothing was written.",
|
|
1907
|
+
});
|
|
1908
|
+
}
|
|
1909
|
+
if (typeof callOptions.eventMetadata !== "function") {
|
|
1910
|
+
return yield* new ValidationError({
|
|
1911
|
+
entityType: stream.streamName,
|
|
1912
|
+
operation: "EventStore.commandHandler.eventMetadata",
|
|
1913
|
+
cause: "eventMetadata must be a function of the decision: the number of events is " +
|
|
1914
|
+
"known only once `decide` has run. Nothing was written.",
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
if (callOptions?.eventTimestamps !== undefined &&
|
|
1919
|
+
typeof callOptions.eventTimestamps !== "function") {
|
|
1920
|
+
return yield* new ValidationError({
|
|
1921
|
+
entityType: stream.streamName,
|
|
1922
|
+
operation: "EventStore.commandHandler.eventTimestamps",
|
|
1923
|
+
cause: "eventTimestamps must be a function of the decision: the number of events is " +
|
|
1924
|
+
"known only once `decide` has run. Nothing was written.",
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1601
1927
|
// A malformed If-Match (`NaN` from a failed parse, a negative or
|
|
1602
1928
|
// fractional number) is a caller bug, not a conflict — refuse it before
|
|
1603
1929
|
// reading anything.
|
|
@@ -1741,6 +2067,24 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
1741
2067
|
* yield* handle({ matchId: "m-1" }, command, {
|
|
1742
2068
|
* additionalItems: ({ state }) => [Scoreboard.put({ matchId: "m-1", runs: state.runs })],
|
|
1743
2069
|
* })
|
|
2070
|
+
*
|
|
2071
|
+
* // Per-event metadata derived from the decision: entry i is written onto event i
|
|
2072
|
+
* // (`crypto` is the `Crypto.Crypto` service; `randomUUIDv7` works the same way)
|
|
2073
|
+
* yield* handle({ matchId: "m-1" }, command, {
|
|
2074
|
+
* eventMetadata: ({ events }) =>
|
|
2075
|
+
* Effect.forEach(events, () => Effect.map(crypto.randomULID, (eventId) => ({ eventId }))),
|
|
2076
|
+
* })
|
|
2077
|
+
*
|
|
2078
|
+
* // Per-event timestamps derived from the decision (undefined → the Clock time)
|
|
2079
|
+
* yield* handle({ matchId: "m-1" }, command, {
|
|
2080
|
+
* eventTimestamps: ({ events }) => events.map((event) => event.occurredAt),
|
|
2081
|
+
* })
|
|
2082
|
+
*
|
|
2083
|
+
* // ...or from a service, as an Effect (its errors / requirements join the handler's)
|
|
2084
|
+
* yield* handle({ matchId: "m-1" }, command, {
|
|
2085
|
+
* eventTimestamps: ({ events }) =>
|
|
2086
|
+
* Effect.forEach(events, (event) => SourceLog.originalTime(event.sourceId)),
|
|
2087
|
+
* })
|
|
1744
2088
|
* ```
|
|
1745
2089
|
*
|
|
1746
2090
|
* Each invocation runs
|
|
@@ -1756,7 +2100,7 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
1756
2100
|
* item alone (one `GetItem`); an answer that is not a successful append is
|
|
1757
2101
|
* confirmed against the head first, and a snapshot that proves stale falls
|
|
1758
2102
|
* back to the verified load — in which case `decide` may run twice.
|
|
1759
|
-
* - **expectedVersion.** See {@link
|
|
2103
|
+
* - **expectedVersion.** See {@link CommandOptionsBase.expectedVersion}.
|
|
1760
2104
|
* - **Fold before append.** The new events are folded into state before the
|
|
1761
2105
|
* append, so the state returned, snapshotted and handed to a function-form
|
|
1762
2106
|
* `additionalItems` (see {@link Decision}) is always the `evolve` fold.
|
|
@@ -1766,8 +2110,8 @@ const makeCommandHandlerImpl = (decider, stream, options) => {
|
|
|
1766
2110
|
* fresh snapshot is written (best-effort) after a successful append once the
|
|
1767
2111
|
* cadence threshold is crossed. See {@link SnapshotMode}.
|
|
1768
2112
|
* - **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
|
|
2113
|
+
* too large for one transaction is rejected whole by DynamoDB and fails with
|
|
2114
|
+
* `AppendTooLarge` — nothing is written. A large command is split by the application into stepped
|
|
1771
2115
|
* commands — each an ordinary call, chained by `expectedVersion` — so a
|
|
1772
2116
|
* failure partway leaves the stream at a real intermediate state.
|
|
1773
2117
|
*
|