effect-dynamodb 1.23.0 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Aggregate.d.ts +5 -2
- package/dist/Aggregate.d.ts.map +1 -1
- package/dist/Aggregate.js +136 -53
- package/dist/Aggregate.js.map +1 -1
- package/dist/Batch.d.ts +14 -5
- package/dist/Batch.d.ts.map +1 -1
- package/dist/Batch.js +184 -119
- package/dist/Batch.js.map +1 -1
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +2 -4
- package/dist/Collection.js.map +1 -1
- package/dist/DynamoClient.d.ts.map +1 -1
- package/dist/DynamoClient.js +42 -14
- package/dist/DynamoClient.js.map +1 -1
- package/dist/Entity.d.ts +19 -13
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +287 -226
- package/dist/Entity.js.map +1 -1
- package/dist/EventStore.d.ts +1060 -50
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +1607 -299
- package/dist/EventStore.js.map +1 -1
- package/dist/Query.d.ts +47 -14
- package/dist/Query.d.ts.map +1 -1
- package/dist/Query.js +190 -68
- package/dist/Query.js.map +1 -1
- package/dist/Table.d.ts +56 -6
- package/dist/Table.d.ts.map +1 -1
- package/dist/Table.js +134 -54
- package/dist/Table.js.map +1 -1
- package/dist/Transaction.d.ts +8 -0
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +67 -22
- package/dist/Transaction.js.map +1 -1
- package/dist/VectorSearchEmulation.d.ts +3 -2
- package/dist/VectorSearchEmulation.d.ts.map +1 -1
- package/dist/VectorSearchEmulation.js +14 -2
- package/dist/VectorSearchEmulation.js.map +1 -1
- package/dist/internal/BoundQuery.d.ts +3 -2
- package/dist/internal/BoundQuery.d.ts.map +1 -1
- package/dist/internal/BoundQuery.js.map +1 -1
- package/dist/internal/BoundVectorQuery.d.ts +5 -1
- package/dist/internal/BoundVectorQuery.d.ts.map +1 -1
- package/dist/internal/BoundVectorQuery.js +17 -5
- package/dist/internal/BoundVectorQuery.js.map +1 -1
- package/dist/internal/EntityCombinators.d.ts +8 -5
- package/dist/internal/EntityCombinators.d.ts.map +1 -1
- package/dist/internal/EntityCombinators.js +8 -5
- package/dist/internal/EntityCombinators.js.map +1 -1
- package/dist/internal/EntityOps.d.ts +1 -1
- package/dist/internal/Expr.d.ts +4 -0
- package/dist/internal/Expr.d.ts.map +1 -1
- package/dist/internal/Expr.js +6 -4
- package/dist/internal/Expr.js.map +1 -1
- package/dist/internal/ExpressionLimits.d.ts +45 -0
- package/dist/internal/ExpressionLimits.d.ts.map +1 -0
- package/dist/internal/ExpressionLimits.js +89 -0
- package/dist/internal/ExpressionLimits.js.map +1 -0
- package/dist/internal/Hydrate.d.ts +62 -0
- package/dist/internal/Hydrate.d.ts.map +1 -0
- package/dist/internal/Hydrate.js +191 -0
- package/dist/internal/Hydrate.js.map +1 -0
- package/dist/internal/ItemSize.d.ts +60 -24
- package/dist/internal/ItemSize.d.ts.map +1 -1
- package/dist/internal/ItemSize.js +122 -39
- package/dist/internal/ItemSize.js.map +1 -1
- package/dist/internal/TransactWriteOps.d.ts +22 -46
- package/dist/internal/TransactWriteOps.d.ts.map +1 -1
- package/dist/internal/TransactWriteOps.js +26 -95
- package/dist/internal/TransactWriteOps.js.map +1 -1
- package/dist/internal/WriteDiagnosis.d.ts +353 -0
- package/dist/internal/WriteDiagnosis.d.ts.map +1 -0
- package/dist/internal/WriteDiagnosis.js +1028 -0
- package/dist/internal/WriteDiagnosis.js.map +1 -0
- package/dist/internal/sparse_check.js +43 -79
- package/dist/internal/sparse_check.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,1028 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @internal Failure-time diagnosis of DynamoDB's rejections (#144).
|
|
3
|
+
*
|
|
4
|
+
* The library does not pre-check limits or request rules DynamoDB enforces
|
|
5
|
+
* itself — the 400 KB item, the 4 MB and 100-item transaction, the 2048-byte
|
|
6
|
+
* partition key and 1024-byte sort key, one operation per item, the limits on
|
|
7
|
+
* one expression, an `IN ()` with no operands, an empty key value, a reversed
|
|
8
|
+
* `BETWEEN`, a consistent read of a GSI, a table's vector index limits, a
|
|
9
|
+
* vector search's `TopK`. It sends the request; when DynamoDB rejects it, this
|
|
10
|
+
* module:
|
|
11
|
+
*
|
|
12
|
+
* 1. **recognises** the rejection by a stable fragment of its message (a
|
|
13
|
+
* request-wide `ValidationException` or `LimitExceededException`, or a
|
|
14
|
+
* `TransactionCanceledException` whose positional reason carries
|
|
15
|
+
* `Code: "ValidationError"`);
|
|
16
|
+
* 2. **confirms** it against the request the site sent — an item count, a
|
|
17
|
+
* key's UTF-8 bytes, an expression's length or operators, two entries
|
|
18
|
+
* naming one item, an empty key value, the order of two bounds (each
|
|
19
|
+
* confirmed only when the request breaks the rule), or, for item and
|
|
20
|
+
* transaction sizes, by checking the request holds something the message
|
|
21
|
+
* can apply to and measuring it exactly (`ItemSize.ts`);
|
|
22
|
+
* 3. **attributes** it to the culprit, using the targets the site built.
|
|
23
|
+
*
|
|
24
|
+
* A site turns the {@link Diagnosis} into its own tagged error with
|
|
25
|
+
* DynamoDB's error kept as the cause ({@link withCause}); anything
|
|
26
|
+
* unrecognised or unconfirmed passes through unchanged. Nothing is measured,
|
|
27
|
+
* and no target is built, unless DynamoDB has rejected the request.
|
|
28
|
+
*
|
|
29
|
+
* Every site goes through {@link diagnoseFailure}: writes ({@link SentWrite}),
|
|
30
|
+
* reads ({@link SentRead}), table management ({@link SentTable}) and vector
|
|
31
|
+
* searches ({@link SentSearch}) share the one recognise / confirm / rewrite
|
|
32
|
+
* path.
|
|
33
|
+
*/
|
|
34
|
+
import { isAwsTransactionCancelled, isAwsValidationError, TRANSACT_WRITE_ITEMS_LIMIT, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
|
|
35
|
+
import { MAX_DIMENSIONS, MAX_INLINE_FILTERS, MAX_TOP_K, MAX_VECTOR_INDEXES_PER_TABLE, MIN_DIMENSIONS, MIN_TOP_K, } from "@effect-dynamodb/schema/VectorIndex.js";
|
|
36
|
+
import { Effect } from "effect";
|
|
37
|
+
import { countOperators, EXPRESSION_MAX_LENGTH, EXPRESSION_MAX_OPERATORS, expressionCost, IN_MAX_OPERANDS, largestInOperands, } from "./ExpressionLimits.js";
|
|
38
|
+
import { attributeBytes, ITEM_MAX_BYTES, itemBytes, TRANSACT_WRITE_MAX_BYTES, transactItemBytes, } from "./ItemSize.js";
|
|
39
|
+
/** DynamoDB's limit on a partition (hash) key value, in UTF-8 bytes. */
|
|
40
|
+
export const PARTITION_KEY_MAX_BYTES = 2048;
|
|
41
|
+
/** DynamoDB's limit on a sort (range) key value, in UTF-8 bytes. */
|
|
42
|
+
export const SORT_KEY_MAX_BYTES = 1024;
|
|
43
|
+
const NO_KEYS = { partition: [], sort: [] };
|
|
44
|
+
/**
|
|
45
|
+
* The target of a transact entry in `tableName`, keyed by `keyFields` (the
|
|
46
|
+
* table's primary key attributes): read from a Put's item, or any other
|
|
47
|
+
* entry's `Key` (a `TransactGetItems` entry's included).
|
|
48
|
+
*/
|
|
49
|
+
export const transactItemTarget = (item, tableName, keyFields, entityType, source) => {
|
|
50
|
+
const attributes = "Get" in item && item.Get !== undefined
|
|
51
|
+
? item.Get.Key
|
|
52
|
+
: (() => {
|
|
53
|
+
const write = item;
|
|
54
|
+
return (write.Put?.Item ?? write.Delete?.Key ?? write.Update?.Key ?? write.ConditionCheck?.Key);
|
|
55
|
+
})();
|
|
56
|
+
const parts = keyFields.map((field) => {
|
|
57
|
+
const value = attributes?.[field];
|
|
58
|
+
return { field, value, text: value?.S ?? value?.N ?? JSON.stringify(value ?? null) };
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
identity: [
|
|
62
|
+
tableName,
|
|
63
|
+
...parts.map((p) => `${p.field}=${JSON.stringify(p.value ?? null)}`),
|
|
64
|
+
].join("\u0000"),
|
|
65
|
+
entityType,
|
|
66
|
+
source,
|
|
67
|
+
key: parts.map((p) => `${p.field}=${p.text}`).join(", "),
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Recognise
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
const ITEM_SIZE = /Item size has exceeded the maximum allowed size/i;
|
|
74
|
+
const ITEM_SIZE_UPDATE = /Item size to update has exceeded/i;
|
|
75
|
+
const ITEM_COUNT = /length less than or equal to 100\b/i;
|
|
76
|
+
const PAYLOAD = /payload size cannot exceed 4\s?MB/i;
|
|
77
|
+
const PAYLOAD_SIZE = /Payload Size:\s*(\d+)/i;
|
|
78
|
+
// A key value over its limit. DynamoDB Local words a table key's (partition
|
|
79
|
+
// and sort key in one message: "Hash primary key values must be under 2048
|
|
80
|
+
// bytes, and range primary key values must be under 1024 bytes") and an index
|
|
81
|
+
// key's ("One or more parameter values were invalid: Size limit exceeded")
|
|
82
|
+
// alike for both kinds, so either is confirmed against both. AWS words each
|
|
83
|
+
// kind its own way: "Aggregated size of all range keys has exceeded the size
|
|
84
|
+
// limit of 1024 bytes" (documented) and "Size of hashkey has exceeded the
|
|
85
|
+
// maximum size limit of 2048 bytes" (as AWS reports it; unverified here — Local
|
|
86
|
+
// cannot produce it).
|
|
87
|
+
const KEY_SIZE_EITHER = /Size limit exceeded|Hash primary key values must be under 2048 bytes|range primary key values must be under 1024 bytes/i;
|
|
88
|
+
const KEY_SIZE_SORT = /Aggregated size of all range keys/i;
|
|
89
|
+
const KEY_SIZE_PARTITION = /Size of hash\s?key has exceeded/i;
|
|
90
|
+
const KEY_SIZE = new RegExp([KEY_SIZE_EITHER, KEY_SIZE_SORT, KEY_SIZE_PARTITION].map((r) => r.source).join("|"), "i");
|
|
91
|
+
// A transaction (or `TransactGetItems`) naming one item twice; a batch
|
|
92
|
+
// request doing so.
|
|
93
|
+
const REPEATED = /cannot include multiple operations on one item/i;
|
|
94
|
+
const REPEATED_BATCH = /Provided list of item keys contains duplicates/i;
|
|
95
|
+
const EXPRESSION_KIND = /Invalid (\w+)Expression:/i;
|
|
96
|
+
const EXPRESSION_LENGTH = /Expression size has exceeded the maximum allowed size/i;
|
|
97
|
+
const EXPRESSION_OPERATORS = /too many operators/i;
|
|
98
|
+
const EXPRESSION_IN = /IN operator is provided with too many operands/i;
|
|
99
|
+
const EMPTY_KEY = /cannot contain an empty string value/i;
|
|
100
|
+
const EMPTY_KEY_INDEX = /IndexName:\s*([^,\s]+),\s*IndexKey:\s*([^,\s]+)/i;
|
|
101
|
+
const EMPTY_KEY_TABLE = /(?<!Index)Key:\s*([^,\s]+)/;
|
|
102
|
+
const BETWEEN_REVERSED = /BETWEEN operator requires upper bound to be greater than or equal to/i;
|
|
103
|
+
// DynamoDB Local 3.1.0 says "Consistent read cannot be true when querying a GSI";
|
|
104
|
+
// AWS and later Local releases say "Consistent reads are not supported on global
|
|
105
|
+
// secondary indexes".
|
|
106
|
+
const CONSISTENT_GSI = /Consistent read cannot be true when (querying|scanning) a GSI|Consistent reads are not supported on global secondary indexes/i;
|
|
107
|
+
// `IN ()` — an `isIn()` with no values — is a syntax error to DynamoDB
|
|
108
|
+
// (Local: 'Invalid ConditionExpression: Syntax error; token: ")", near: "()"',
|
|
109
|
+
// the `near` running on to the next token — `"())"`, `"() AND"` — when one
|
|
110
|
+
// follows; `FilterExpression` likewise), request-wide even in a transaction.
|
|
111
|
+
// The message names no attribute: confirmed by finding the `IN ()` in the
|
|
112
|
+
// request.
|
|
113
|
+
const EMPTY_IN = /Syntax error; token: "\)", near: "\(\)/i;
|
|
114
|
+
const EMPTY_IN_OPERAND = /((?:size\(\s*)?[#\w.[\]]+\)?)\s+IN\s*\(\s*\)/i;
|
|
115
|
+
// A vector index's dimensions or a vector search's TopK outside DynamoDB's
|
|
116
|
+
// range. DynamoDB Local has neither vector indexes nor `SearchVectors`, so
|
|
117
|
+
// these follow AWS's documented ranges (Dimensions 1..4096, TopK 1..100) and
|
|
118
|
+
// its standard member-constraint wording ("Value '5000' at
|
|
119
|
+
// 'vectorIndexes.1.member.dimensions' failed to satisfy constraint: Member
|
|
120
|
+
// must have value less than or equal to 4096"); unverified on AWS. Matched on
|
|
121
|
+
// the member's name only, and confirmed against the request.
|
|
122
|
+
const VECTOR_DIMENSIONS = /dimension/i;
|
|
123
|
+
const TOP_K = /top\s?k/i;
|
|
124
|
+
// AWS documents the vector-index quotas (18 inline filters per index, 5
|
|
125
|
+
// vector indexes per table, adjustable) but not the wording of their
|
|
126
|
+
// rejection, and DynamoDB Local has no vector indexes: matched loosely, and
|
|
127
|
+
// confirmed against the request.
|
|
128
|
+
const VECTOR = /vector/i;
|
|
129
|
+
const VECTOR_FILTERS = /inline.?filter/i;
|
|
130
|
+
const VECTOR_COUNT = /limit|exceed|maximum|at most|too many/i;
|
|
131
|
+
const STATED_LIMIT = /(?:limit|maximum|at most|exceeds?)\D{0,40}?(\d+)/i;
|
|
132
|
+
// Limits on vector indexes that are not their count per table: how many may
|
|
133
|
+
// be created at once ("Only 1 … index can be created or deleted
|
|
134
|
+
// simultaneously per table"), or a name's length.
|
|
135
|
+
const VECTOR_NOT_COUNT = /simultaneous|at a time|concurrent|in progress|length/i;
|
|
136
|
+
const messageOf = (cause) => {
|
|
137
|
+
if (cause instanceof Error)
|
|
138
|
+
return cause.message;
|
|
139
|
+
if (cause !== null && typeof cause === "object" && "message" in cause) {
|
|
140
|
+
return String(cause.message);
|
|
141
|
+
}
|
|
142
|
+
return "";
|
|
143
|
+
};
|
|
144
|
+
const isAwsLimitExceeded = (cause) => cause != null &&
|
|
145
|
+
typeof cause === "object" &&
|
|
146
|
+
"name" in cause &&
|
|
147
|
+
cause.name === "LimitExceededException";
|
|
148
|
+
/**
|
|
149
|
+
* The failure a site's error carries, if it is one this module can read: a
|
|
150
|
+
* DynamoDB `ValidationException` or `LimitExceededException` (however the
|
|
151
|
+
* client classified it), a `TransactionCanceledException`, or a site's
|
|
152
|
+
* `TransactionCancelled`.
|
|
153
|
+
*/
|
|
154
|
+
const readFailure = (error) => {
|
|
155
|
+
if (error === null || typeof error !== "object" || !("_tag" in error))
|
|
156
|
+
return undefined;
|
|
157
|
+
const tagged = error;
|
|
158
|
+
if (tagged._tag === "TransactionCancelled") {
|
|
159
|
+
return {
|
|
160
|
+
_tag: "Cancelled",
|
|
161
|
+
reasons: error.reasons ??
|
|
162
|
+
[],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (tagged._tag !== "DynamoError" && tagged._tag !== "DynamoValidationError")
|
|
166
|
+
return undefined;
|
|
167
|
+
const cause = tagged.cause;
|
|
168
|
+
if (isAwsValidationError(cause) || isAwsLimitExceeded(cause)) {
|
|
169
|
+
return { _tag: "Request", message: messageOf(cause) };
|
|
170
|
+
}
|
|
171
|
+
if (isAwsTransactionCancelled(cause)) {
|
|
172
|
+
return {
|
|
173
|
+
_tag: "Cancelled",
|
|
174
|
+
reasons: (cause.CancellationReasons ?? []).map((r) => r === undefined ? undefined : { code: r.Code, message: r.Message }),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
};
|
|
179
|
+
/** Whether `error` is a rejection this module could diagnose at all. */
|
|
180
|
+
export const isDiagnosable = (error) => readFailure(error) !== undefined;
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Measure
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
/**
|
|
185
|
+
* The attributes an entry writes, as far as its request shows them: a Put's
|
|
186
|
+
* item, any entry's key, and an Update's `SET #name = :value` pairs.
|
|
187
|
+
*/
|
|
188
|
+
const writtenAttributes = (item) => {
|
|
189
|
+
if (item.Put !== undefined)
|
|
190
|
+
return item.Put.Item ?? {};
|
|
191
|
+
const keyed = item.Delete ?? item.ConditionCheck;
|
|
192
|
+
if (keyed !== undefined)
|
|
193
|
+
return keyed.Key ?? {};
|
|
194
|
+
const update = item.Update;
|
|
195
|
+
if (update === undefined)
|
|
196
|
+
return {};
|
|
197
|
+
const written = { ...update.Key };
|
|
198
|
+
const names = update.ExpressionAttributeNames ?? {};
|
|
199
|
+
const values = update.ExpressionAttributeValues ?? {};
|
|
200
|
+
const setClause = /\bSET\b([\s\S]*?)(?=\b(?:REMOVE|ADD|DELETE)\b|$)/i.exec(update.UpdateExpression ?? "")?.[1];
|
|
201
|
+
if (setClause !== undefined) {
|
|
202
|
+
for (const match of setClause.matchAll(/(#[\w]+)\s*=\s*(:[\w]+)\s*(?=,|$)/g)) {
|
|
203
|
+
const name = names[match[1]];
|
|
204
|
+
const value = values[match[2]];
|
|
205
|
+
if (name !== undefined && value !== undefined)
|
|
206
|
+
written[name] = value;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return written;
|
|
210
|
+
};
|
|
211
|
+
/** A key attribute's size, by DynamoDB's rules: a string's UTF-8 bytes, a binary's bytes. */
|
|
212
|
+
const keyBytes = (value) => attributeBytes(value);
|
|
213
|
+
/** An empty string or binary: what DynamoDB refuses as a key value. */
|
|
214
|
+
const isEmptyKeyValue = (value) => value !== undefined &&
|
|
215
|
+
((value.S !== undefined && value.S === "") || (value.B !== undefined && value.B.length === 0));
|
|
216
|
+
/**
|
|
217
|
+
* The first key attribute of `item` over its limit — a partition key over
|
|
218
|
+
* {@link PARTITION_KEY_MAX_BYTES}, a sort key over {@link SORT_KEY_MAX_BYTES} —
|
|
219
|
+
* among the `kinds` a rejection can mean.
|
|
220
|
+
*/
|
|
221
|
+
const oversizedKey = (item, keys, kinds) => {
|
|
222
|
+
const written = writtenAttributes(item);
|
|
223
|
+
for (const kind of kinds) {
|
|
224
|
+
const limit = kind === "partition" ? PARTITION_KEY_MAX_BYTES : SORT_KEY_MAX_BYTES;
|
|
225
|
+
for (const attribute of kind === "partition" ? keys.partition : keys.sort) {
|
|
226
|
+
const value = written[attribute];
|
|
227
|
+
if (value === undefined)
|
|
228
|
+
continue;
|
|
229
|
+
const bytes = keyBytes(value);
|
|
230
|
+
if (bytes > limit)
|
|
231
|
+
return { kind, attribute, bytes };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return undefined;
|
|
235
|
+
};
|
|
236
|
+
/** The key kinds a key-size message can mean. */
|
|
237
|
+
const keySizeKinds = (message) => KEY_SIZE_EITHER.test(message)
|
|
238
|
+
? ["partition", "sort"]
|
|
239
|
+
: KEY_SIZE_PARTITION.test(message)
|
|
240
|
+
? ["partition"]
|
|
241
|
+
: ["sort"];
|
|
242
|
+
/** The attribute (path) an `IN ()` in `expression` tests, its placeholders resolved. */
|
|
243
|
+
const emptyInOperand = (expression, names) => {
|
|
244
|
+
const operand = EMPTY_IN_OPERAND.exec(expression ?? "")?.[1];
|
|
245
|
+
return operand === undefined
|
|
246
|
+
? undefined
|
|
247
|
+
: operand.replace(/#\w+/g, (token) => names?.[token] ?? token);
|
|
248
|
+
};
|
|
249
|
+
/** A Put's item size — the 400 KB measure, which its expressions do not count toward. */
|
|
250
|
+
const putBytes = (item) => item.Put === undefined ? undefined : itemBytes(item.Put.Item ?? {});
|
|
251
|
+
/** An entry's expression of `kind`, if it carries one. */
|
|
252
|
+
const expressionOf = (item, kind) => {
|
|
253
|
+
if (kind === "UpdateExpression")
|
|
254
|
+
return item.Update?.UpdateExpression;
|
|
255
|
+
return (item.Put?.ConditionExpression ??
|
|
256
|
+
item.Update?.ConditionExpression ??
|
|
257
|
+
item.Delete?.ConditionExpression ??
|
|
258
|
+
item.ConditionCheck?.ConditionExpression);
|
|
259
|
+
};
|
|
260
|
+
/**
|
|
261
|
+
* UTF-8 byte order — the order DynamoDB sorts (and evaluates `BETWEEN` on)
|
|
262
|
+
* string keys. JavaScript's `<` compares UTF-16 code units, which disagrees
|
|
263
|
+
* for characters outside the BMP.
|
|
264
|
+
*/
|
|
265
|
+
const utf8 = new TextEncoder();
|
|
266
|
+
export const compareUtf8 = (a, b) => compareBytes(utf8.encode(a), utf8.encode(b));
|
|
267
|
+
const compareBytes = (left, right) => {
|
|
268
|
+
const length = Math.min(left.length, right.length);
|
|
269
|
+
for (let i = 0; i < length; i++) {
|
|
270
|
+
const diff = left[i] - right[i];
|
|
271
|
+
if (diff !== 0)
|
|
272
|
+
return diff;
|
|
273
|
+
}
|
|
274
|
+
return left.length - right.length;
|
|
275
|
+
};
|
|
276
|
+
/** `a` sorts after `b` as DynamoDB orders key values of their (shared) type. */
|
|
277
|
+
const sortsAfter = (a, b) => {
|
|
278
|
+
if (a.S !== undefined && b.S !== undefined)
|
|
279
|
+
return compareUtf8(a.S, b.S) > 0;
|
|
280
|
+
if (a.N !== undefined && b.N !== undefined)
|
|
281
|
+
return Number(a.N) > Number(b.N);
|
|
282
|
+
if (a.B !== undefined && b.B !== undefined)
|
|
283
|
+
return compareBytes(a.B, b.B) > 0;
|
|
284
|
+
return false;
|
|
285
|
+
};
|
|
286
|
+
/** A key value, readable. */
|
|
287
|
+
const keyValueText = (value) => value.S ??
|
|
288
|
+
value.N ??
|
|
289
|
+
(value.B !== undefined ? `<${value.B.length} bytes>` : JSON.stringify(value));
|
|
290
|
+
/**
|
|
291
|
+
* The comparisons of a key condition — `#a = :v`, `#a BETWEEN :l AND :h`,
|
|
292
|
+
* `begins_with(#a, :v)` — with their attributes resolved through `names`.
|
|
293
|
+
*/
|
|
294
|
+
const keyComparisons = (input) => {
|
|
295
|
+
const expression = input.KeyConditionExpression ?? "";
|
|
296
|
+
const names = input.ExpressionAttributeNames ?? {};
|
|
297
|
+
const values = input.ExpressionAttributeValues ?? {};
|
|
298
|
+
const attribute = (token) => names[token] ?? token;
|
|
299
|
+
const value = (token) => values[token];
|
|
300
|
+
const found = [];
|
|
301
|
+
const defined = (vs) => vs.filter((v) => v !== undefined);
|
|
302
|
+
for (const m of expression.matchAll(/([#\w.]+)\s+BETWEEN\s+(:\w+)\s+AND\s+(:\w+)/gi)) {
|
|
303
|
+
found.push({
|
|
304
|
+
attribute: attribute(m[1]),
|
|
305
|
+
operator: "between",
|
|
306
|
+
values: defined([value(m[2]), value(m[3])]),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
for (const m of expression.matchAll(/begins_with\s*\(\s*([#\w.]+)\s*,\s*(:\w+)\s*\)/gi)) {
|
|
310
|
+
found.push({ attribute: attribute(m[1]), operator: "other", values: defined([value(m[2])]) });
|
|
311
|
+
}
|
|
312
|
+
for (const m of expression.matchAll(/([#\w.]+)\s*(?:=|<>|<=|>=|<|>)\s*(:\w+)/g)) {
|
|
313
|
+
found.push({ attribute: attribute(m[1]), operator: "other", values: defined([value(m[2])]) });
|
|
314
|
+
}
|
|
315
|
+
return found;
|
|
316
|
+
};
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// Confirm and attribute: writes
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
const entriesOf = (sent) => sent._tag === "Item"
|
|
321
|
+
? [sent.item]
|
|
322
|
+
: sent._tag === "TransactGet" || sent._tag === "BatchGet"
|
|
323
|
+
? []
|
|
324
|
+
: sent.items;
|
|
325
|
+
const targetsOf = (sent) => {
|
|
326
|
+
if (sent._tag === "Item")
|
|
327
|
+
return [sent.target()];
|
|
328
|
+
if (sent._tag === "TransactGet")
|
|
329
|
+
return sent.targets?.() ?? [];
|
|
330
|
+
return sent.targets();
|
|
331
|
+
};
|
|
332
|
+
const targetAt = (sent, index) => {
|
|
333
|
+
if (sent._tag === "Item")
|
|
334
|
+
return index === 0 ? sent.target() : undefined;
|
|
335
|
+
if (sent._tag === "TransactGet")
|
|
336
|
+
return sent.targets?.()[index];
|
|
337
|
+
return sent.targets()[index];
|
|
338
|
+
};
|
|
339
|
+
/** The first two entries of `sent` that target one item, if any do. */
|
|
340
|
+
const repeatedItem = (sent) => {
|
|
341
|
+
if (sent._tag === "Item")
|
|
342
|
+
return undefined;
|
|
343
|
+
const seen = new Map();
|
|
344
|
+
for (const target of targetsOf(sent)) {
|
|
345
|
+
const first = seen.get(target.identity);
|
|
346
|
+
if (first !== undefined) {
|
|
347
|
+
return {
|
|
348
|
+
_tag: "RepeatedItem",
|
|
349
|
+
first,
|
|
350
|
+
second: target,
|
|
351
|
+
batch: sent._tag === "Batch" || sent._tag === "BatchGet",
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
seen.set(target.identity, target);
|
|
355
|
+
}
|
|
356
|
+
return undefined;
|
|
357
|
+
};
|
|
358
|
+
/** The `Put` over the item limit, else the largest `Put` (likely), else `undefined`. */
|
|
359
|
+
const oversizedPut = (sent) => {
|
|
360
|
+
const entries = entriesOf(sent);
|
|
361
|
+
let largest;
|
|
362
|
+
for (const [index, item] of entries.entries()) {
|
|
363
|
+
const bytes = putBytes(item);
|
|
364
|
+
if (bytes === undefined)
|
|
365
|
+
continue;
|
|
366
|
+
if (bytes > ITEM_MAX_BYTES) {
|
|
367
|
+
const target = targetAt(sent, index);
|
|
368
|
+
return target === undefined
|
|
369
|
+
? undefined
|
|
370
|
+
: { _tag: "ItemTooLarge", target, bytes, likely: false };
|
|
371
|
+
}
|
|
372
|
+
if (largest === undefined || bytes > largest.bytes)
|
|
373
|
+
largest = { index, bytes };
|
|
374
|
+
}
|
|
375
|
+
if (largest === undefined)
|
|
376
|
+
return undefined;
|
|
377
|
+
const target = targetAt(sent, largest.index);
|
|
378
|
+
if (target === undefined)
|
|
379
|
+
return undefined;
|
|
380
|
+
return {
|
|
381
|
+
_tag: "ItemTooLarge",
|
|
382
|
+
target,
|
|
383
|
+
bytes: largest.bytes,
|
|
384
|
+
likely: sent._tag !== "Item",
|
|
385
|
+
};
|
|
386
|
+
};
|
|
387
|
+
/** The one `Update` an item-size-to-update message can mean, if the request holds one. */
|
|
388
|
+
const growingUpdate = (sent) => {
|
|
389
|
+
const entries = entriesOf(sent);
|
|
390
|
+
const updates = [...entries.entries()].filter(([, item]) => item.Update !== undefined);
|
|
391
|
+
if (updates.length !== 1)
|
|
392
|
+
return undefined;
|
|
393
|
+
const target = targetAt(sent, updates[0][0]);
|
|
394
|
+
return target === undefined
|
|
395
|
+
? undefined
|
|
396
|
+
: { _tag: "ItemTooLarge", target, bytes: undefined, likely: sent._tag !== "Item" };
|
|
397
|
+
};
|
|
398
|
+
const keyAt = (sent, index, keys, kinds) => {
|
|
399
|
+
const item = entriesOf(sent)[index];
|
|
400
|
+
if (item === undefined)
|
|
401
|
+
return undefined;
|
|
402
|
+
const over = oversizedKey(item, keys, kinds);
|
|
403
|
+
if (over === undefined)
|
|
404
|
+
return undefined;
|
|
405
|
+
const target = targetAt(sent, index);
|
|
406
|
+
return target === undefined ? undefined : { _tag: "KeyTooLong", target, ...over };
|
|
407
|
+
};
|
|
408
|
+
const anyKey = (sent, keys, kinds) => {
|
|
409
|
+
const entries = entriesOf(sent);
|
|
410
|
+
for (let index = 0; index < entries.length; index++) {
|
|
411
|
+
const found = keyAt(sent, index, keys, kinds);
|
|
412
|
+
if (found !== undefined)
|
|
413
|
+
return found;
|
|
414
|
+
}
|
|
415
|
+
return undefined;
|
|
416
|
+
};
|
|
417
|
+
/**
|
|
418
|
+
* An `IN ()` rejection, confirmed: the first entry whose condition holds an
|
|
419
|
+
* `IN` with no operands.
|
|
420
|
+
*/
|
|
421
|
+
const emptyInCondition = (sent) => {
|
|
422
|
+
for (const [index, item] of entriesOf(sent).entries()) {
|
|
423
|
+
const entry = item.Put ?? item.Update ?? item.Delete ?? item.ConditionCheck;
|
|
424
|
+
const attribute = emptyInOperand(entry?.ConditionExpression, entry?.ExpressionAttributeNames);
|
|
425
|
+
if (attribute === undefined)
|
|
426
|
+
continue;
|
|
427
|
+
const target = targetAt(sent, index);
|
|
428
|
+
return target === undefined
|
|
429
|
+
? undefined
|
|
430
|
+
: { _tag: "EmptyIn", expression: "ConditionExpression", attribute, target };
|
|
431
|
+
}
|
|
432
|
+
return undefined;
|
|
433
|
+
};
|
|
434
|
+
/**
|
|
435
|
+
* An expression-limit rejection, confirmed: the first entry whose expression
|
|
436
|
+
* of the kind DynamoDB named (either, if it named none) breaks that limit.
|
|
437
|
+
*/
|
|
438
|
+
const oversizedExpression = (message, sent) => {
|
|
439
|
+
const limit = EXPRESSION_LENGTH.test(message)
|
|
440
|
+
? "length"
|
|
441
|
+
: EXPRESSION_OPERATORS.test(message)
|
|
442
|
+
? "operators"
|
|
443
|
+
: EXPRESSION_IN.test(message)
|
|
444
|
+
? "inOperands"
|
|
445
|
+
: undefined;
|
|
446
|
+
if (limit === undefined)
|
|
447
|
+
return undefined;
|
|
448
|
+
const named = EXPRESSION_KIND.exec(message)?.[1]?.toLowerCase();
|
|
449
|
+
const kinds = named === "condition"
|
|
450
|
+
? ["ConditionExpression"]
|
|
451
|
+
: named === "update"
|
|
452
|
+
? ["UpdateExpression"]
|
|
453
|
+
: named === undefined
|
|
454
|
+
? ["ConditionExpression", "UpdateExpression"]
|
|
455
|
+
: [];
|
|
456
|
+
const entries = entriesOf(sent);
|
|
457
|
+
for (const kind of kinds) {
|
|
458
|
+
for (const [index, item] of entries.entries()) {
|
|
459
|
+
const expression = expressionOf(item, kind);
|
|
460
|
+
if (expression === undefined)
|
|
461
|
+
continue;
|
|
462
|
+
const counting = kind === "UpdateExpression" ? "update" : "condition";
|
|
463
|
+
const measured = limit === "length"
|
|
464
|
+
? expression.length
|
|
465
|
+
: limit === "operators"
|
|
466
|
+
? countOperators(expression, counting)
|
|
467
|
+
: largestInOperands(expression);
|
|
468
|
+
const max = limit === "length"
|
|
469
|
+
? EXPRESSION_MAX_LENGTH
|
|
470
|
+
: limit === "operators"
|
|
471
|
+
? EXPRESSION_MAX_OPERATORS
|
|
472
|
+
: IN_MAX_OPERANDS;
|
|
473
|
+
if (measured <= max)
|
|
474
|
+
continue;
|
|
475
|
+
const target = targetAt(sent, index);
|
|
476
|
+
if (target === undefined)
|
|
477
|
+
return undefined;
|
|
478
|
+
const callerText = kind !== "ConditionExpression"
|
|
479
|
+
? undefined
|
|
480
|
+
: sent._tag === "Item"
|
|
481
|
+
? sent.callerCondition?.()
|
|
482
|
+
: sent._tag === "Transaction"
|
|
483
|
+
? sent.callerCondition?.(index)
|
|
484
|
+
: undefined;
|
|
485
|
+
const cost = callerText === undefined ? undefined : expressionCost(callerText);
|
|
486
|
+
return {
|
|
487
|
+
_tag: "ExpressionTooLarge",
|
|
488
|
+
target,
|
|
489
|
+
expression: kind,
|
|
490
|
+
limit,
|
|
491
|
+
measured,
|
|
492
|
+
caller: cost === undefined || callerText === undefined
|
|
493
|
+
? undefined
|
|
494
|
+
: { ...cost, inOperands: largestInOperands(callerText) },
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return undefined;
|
|
499
|
+
};
|
|
500
|
+
/**
|
|
501
|
+
* An empty-key rejection, confirmed: an entry writes an empty value to the
|
|
502
|
+
* key attribute DynamoDB named.
|
|
503
|
+
*/
|
|
504
|
+
const emptyKey = (message, sent) => {
|
|
505
|
+
const onIndex = EMPTY_KEY_INDEX.exec(message);
|
|
506
|
+
const attribute = onIndex?.[2] ?? EMPTY_KEY_TABLE.exec(message)?.[1];
|
|
507
|
+
if (attribute === undefined)
|
|
508
|
+
return undefined;
|
|
509
|
+
const entries = entriesOf(sent);
|
|
510
|
+
for (const [index, item] of entries.entries()) {
|
|
511
|
+
if (!isEmptyKeyValue(writtenAttributes(item)[attribute]))
|
|
512
|
+
continue;
|
|
513
|
+
const target = targetAt(sent, index);
|
|
514
|
+
return target === undefined
|
|
515
|
+
? undefined
|
|
516
|
+
: { _tag: "EmptyKey", target, attribute, index: onIndex?.[1] };
|
|
517
|
+
}
|
|
518
|
+
return undefined;
|
|
519
|
+
};
|
|
520
|
+
const diagnoseRequest = (message, sent, keys) => {
|
|
521
|
+
if (REPEATED.test(message) || REPEATED_BATCH.test(message))
|
|
522
|
+
return repeatedItem(sent);
|
|
523
|
+
if (ITEM_COUNT.test(message)) {
|
|
524
|
+
const count = sent._tag === "TransactGet"
|
|
525
|
+
? sent.count
|
|
526
|
+
: sent._tag === "Transaction"
|
|
527
|
+
? sent.items.length
|
|
528
|
+
: undefined;
|
|
529
|
+
return count !== undefined && count > TRANSACT_WRITE_ITEMS_LIMIT
|
|
530
|
+
? { _tag: "TooManyItems", count, limit: TRANSACT_WRITE_ITEMS_LIMIT }
|
|
531
|
+
: undefined;
|
|
532
|
+
}
|
|
533
|
+
if (PAYLOAD.test(message)) {
|
|
534
|
+
if (sent._tag !== "Transaction" || sent.items.length === 0)
|
|
535
|
+
return undefined;
|
|
536
|
+
const sized = sent.items.map((item, index) => ({ index, bytes: transactItemBytes(item) }));
|
|
537
|
+
const measured = sized.reduce((sum, { bytes }) => sum + bytes, 0);
|
|
538
|
+
const targets = sent.targets();
|
|
539
|
+
const largest = [...sized]
|
|
540
|
+
.sort((a, b) => b.bytes - a.bytes)
|
|
541
|
+
.slice(0, 3)
|
|
542
|
+
.flatMap(({ index, bytes }) => {
|
|
543
|
+
const target = targets[index];
|
|
544
|
+
return target === undefined ? [] : [{ target, bytes }];
|
|
545
|
+
});
|
|
546
|
+
const reported = PAYLOAD_SIZE.exec(message)?.[1];
|
|
547
|
+
return {
|
|
548
|
+
_tag: "TransactionTooLarge",
|
|
549
|
+
reported: reported === undefined ? undefined : Number(reported),
|
|
550
|
+
measured,
|
|
551
|
+
largest,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
if (KEY_SIZE.test(message))
|
|
555
|
+
return anyKey(sent, keys, keySizeKinds(message));
|
|
556
|
+
if (EMPTY_KEY.test(message))
|
|
557
|
+
return emptyKey(message, sent);
|
|
558
|
+
if (EMPTY_IN.test(message)) {
|
|
559
|
+
return EXPRESSION_KIND.exec(message)?.[1]?.toLowerCase() === "condition"
|
|
560
|
+
? emptyInCondition(sent)
|
|
561
|
+
: undefined;
|
|
562
|
+
}
|
|
563
|
+
if (EXPRESSION_LENGTH.test(message) ||
|
|
564
|
+
EXPRESSION_OPERATORS.test(message) ||
|
|
565
|
+
EXPRESSION_IN.test(message)) {
|
|
566
|
+
return oversizedExpression(message, sent);
|
|
567
|
+
}
|
|
568
|
+
if (ITEM_SIZE_UPDATE.test(message)) {
|
|
569
|
+
if (sent._tag === "Item") {
|
|
570
|
+
return sent.item.Update === undefined && sent.item.Put === undefined
|
|
571
|
+
? undefined
|
|
572
|
+
: { _tag: "ItemTooLarge", target: sent.target(), bytes: putBytes(sent.item), likely: false };
|
|
573
|
+
}
|
|
574
|
+
return growingUpdate(sent);
|
|
575
|
+
}
|
|
576
|
+
if (ITEM_SIZE.test(message)) {
|
|
577
|
+
if (sent._tag === "Item" && sent.item.Update !== undefined) {
|
|
578
|
+
return { _tag: "ItemTooLarge", target: sent.target(), bytes: undefined, likely: false };
|
|
579
|
+
}
|
|
580
|
+
return oversizedPut(sent);
|
|
581
|
+
}
|
|
582
|
+
return undefined;
|
|
583
|
+
};
|
|
584
|
+
const diagnoseCancellation = (reasons, sent, keys) => {
|
|
585
|
+
// A request that repeats an item can never commit, whatever DynamoDB
|
|
586
|
+
// reported for it: DynamoDB Local applies the operations in order, and a
|
|
587
|
+
// repeat whose condition then fails comes back as a conditional failure
|
|
588
|
+
// indistinguishable from a lost race.
|
|
589
|
+
const repeated = repeatedItem(sent);
|
|
590
|
+
if (repeated !== undefined)
|
|
591
|
+
return repeated;
|
|
592
|
+
// Any other conditional failure is the site's to explain, never this module's.
|
|
593
|
+
if (reasons.some((reason) => reason?.code === "ConditionalCheckFailed"))
|
|
594
|
+
return undefined;
|
|
595
|
+
const entries = entriesOf(sent);
|
|
596
|
+
for (const [index, reason] of reasons.entries()) {
|
|
597
|
+
if (reason?.code !== "ValidationError")
|
|
598
|
+
continue;
|
|
599
|
+
const message = reason.message ?? "";
|
|
600
|
+
const item = entries[index];
|
|
601
|
+
if (item === undefined)
|
|
602
|
+
continue;
|
|
603
|
+
if (KEY_SIZE.test(message)) {
|
|
604
|
+
const found = keyAt(sent, index, keys, keySizeKinds(message));
|
|
605
|
+
if (found !== undefined)
|
|
606
|
+
return found;
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if ((ITEM_SIZE_UPDATE.test(message) || ITEM_SIZE.test(message)) &&
|
|
610
|
+
(item.Update !== undefined || item.Put !== undefined)) {
|
|
611
|
+
const target = targetAt(sent, index);
|
|
612
|
+
if (target !== undefined) {
|
|
613
|
+
return { _tag: "ItemTooLarge", target, bytes: putBytes(item), likely: false };
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return undefined;
|
|
618
|
+
};
|
|
619
|
+
// ---------------------------------------------------------------------------
|
|
620
|
+
// Confirm and attribute: reads
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
const diagnoseRead = (message, sent) => {
|
|
623
|
+
const index = sent.input.IndexName;
|
|
624
|
+
if (CONSISTENT_GSI.test(message)) {
|
|
625
|
+
return sent.input.ConsistentRead === true && index !== undefined
|
|
626
|
+
? { _tag: "ConsistentReadOnGsi", index, scan: sent.scan }
|
|
627
|
+
: undefined;
|
|
628
|
+
}
|
|
629
|
+
if (EMPTY_IN.test(message)) {
|
|
630
|
+
if (EXPRESSION_KIND.exec(message)?.[1]?.toLowerCase() !== "filter")
|
|
631
|
+
return undefined;
|
|
632
|
+
const attribute = emptyInOperand(sent.input.FilterExpression, sent.input.ExpressionAttributeNames);
|
|
633
|
+
return attribute === undefined
|
|
634
|
+
? undefined
|
|
635
|
+
: { _tag: "EmptyIn", expression: "FilterExpression", attribute, target: undefined };
|
|
636
|
+
}
|
|
637
|
+
if (sent.scan)
|
|
638
|
+
return undefined;
|
|
639
|
+
if (BETWEEN_REVERSED.test(message)) {
|
|
640
|
+
for (const comparison of keyComparisons(sent.input)) {
|
|
641
|
+
const [low, high] = comparison.values;
|
|
642
|
+
if (comparison.operator !== "between" || low === undefined || high === undefined)
|
|
643
|
+
continue;
|
|
644
|
+
if (!sortsAfter(low, high))
|
|
645
|
+
continue;
|
|
646
|
+
return {
|
|
647
|
+
_tag: "BetweenReversed",
|
|
648
|
+
attribute: comparison.attribute,
|
|
649
|
+
index,
|
|
650
|
+
low: keyValueText(low),
|
|
651
|
+
high: keyValueText(high),
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
return undefined;
|
|
655
|
+
}
|
|
656
|
+
if (EMPTY_KEY.test(message)) {
|
|
657
|
+
const attribute = EMPTY_KEY_INDEX.exec(message)?.[2] ?? EMPTY_KEY_TABLE.exec(message)?.[1] ?? undefined;
|
|
658
|
+
if (attribute === undefined)
|
|
659
|
+
return undefined;
|
|
660
|
+
const empty = keyComparisons(sent.input).some((comparison) => comparison.attribute === attribute && comparison.values.some((v) => isEmptyKeyValue(v)));
|
|
661
|
+
return empty ? { _tag: "EmptyKeyBound", attribute, index } : undefined;
|
|
662
|
+
}
|
|
663
|
+
return undefined;
|
|
664
|
+
};
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
// Confirm and attribute: table management
|
|
667
|
+
// ---------------------------------------------------------------------------
|
|
668
|
+
const inlineFilters = (index) => (index.SearchSchema ?? [])
|
|
669
|
+
.filter((element) => element.SearchSchemaElementType === "INLINE_FILTER")
|
|
670
|
+
.map((element) => element.AttributeName ?? "");
|
|
671
|
+
const outOfDimensionRange = (index) => index.Dimensions !== undefined &&
|
|
672
|
+
(index.Dimensions < MIN_DIMENSIONS || index.Dimensions > MAX_DIMENSIONS);
|
|
673
|
+
const diagnoseTable = (message, sent) => {
|
|
674
|
+
if (sent.vectorIndexes.length === 0)
|
|
675
|
+
return undefined;
|
|
676
|
+
if (VECTOR_DIMENSIONS.test(message)) {
|
|
677
|
+
// The index DynamoDB named, if it named one of the request's; else the
|
|
678
|
+
// first outside the range.
|
|
679
|
+
const over = sent.vectorIndexes.filter(outOfDimensionRange);
|
|
680
|
+
const culprit = over.find((index) => index.IndexName !== undefined && message.includes(index.IndexName)) ??
|
|
681
|
+
over[0];
|
|
682
|
+
if (culprit === undefined)
|
|
683
|
+
return undefined;
|
|
684
|
+
const name = culprit.IndexName ?? "";
|
|
685
|
+
return {
|
|
686
|
+
_tag: "VectorIndexDimensions",
|
|
687
|
+
operation: sent.operation,
|
|
688
|
+
index: name,
|
|
689
|
+
dimensions: culprit.Dimensions,
|
|
690
|
+
sharers: sent.sharers(name),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
if (!VECTOR.test(message))
|
|
694
|
+
return undefined;
|
|
695
|
+
if (VECTOR_FILTERS.test(message)) {
|
|
696
|
+
// The index DynamoDB named, if it named one of the request's; else the
|
|
697
|
+
// first over the limit.
|
|
698
|
+
const over = sent.vectorIndexes.filter((index) => inlineFilters(index).length > MAX_INLINE_FILTERS);
|
|
699
|
+
const culprit = over.find((index) => index.IndexName !== undefined && message.includes(index.IndexName)) ??
|
|
700
|
+
over[0];
|
|
701
|
+
if (culprit === undefined)
|
|
702
|
+
return undefined;
|
|
703
|
+
const name = culprit.IndexName ?? "";
|
|
704
|
+
return {
|
|
705
|
+
_tag: "VectorIndexFilters",
|
|
706
|
+
operation: sent.operation,
|
|
707
|
+
index: name,
|
|
708
|
+
filters: inlineFilters(culprit),
|
|
709
|
+
sharers: sent.sharers(name),
|
|
710
|
+
limit: MAX_INLINE_FILTERS,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
if (!VECTOR_COUNT.test(message) || VECTOR_NOT_COUNT.test(message))
|
|
714
|
+
return undefined;
|
|
715
|
+
const statedText = STATED_LIMIT.exec(message)?.[1];
|
|
716
|
+
const stated = statedText === undefined ? undefined : Number(statedText);
|
|
717
|
+
const limit = stated ?? MAX_VECTOR_INDEXES_PER_TABLE;
|
|
718
|
+
const indexes = sent.vectorIndexes.map((index) => ({
|
|
719
|
+
index: index.IndexName ?? "",
|
|
720
|
+
sharers: sent.sharers(index.IndexName ?? ""),
|
|
721
|
+
}));
|
|
722
|
+
// Confirmed by counting against the limit DynamoDB stated, or its default
|
|
723
|
+
// quota. `CreateTable` carries every vector index; `UpdateTable` adds to
|
|
724
|
+
// the ones the table already has, read back after the rejection.
|
|
725
|
+
const existing = sent.operation === "UpdateTable" ? sent.existing : 0;
|
|
726
|
+
if (existing === undefined || existing + indexes.length <= limit)
|
|
727
|
+
return undefined;
|
|
728
|
+
return {
|
|
729
|
+
_tag: "VectorIndexCount",
|
|
730
|
+
operation: sent.operation,
|
|
731
|
+
indexes,
|
|
732
|
+
declared: sent.declared,
|
|
733
|
+
existing: sent.operation === "UpdateTable" ? existing : undefined,
|
|
734
|
+
stated,
|
|
735
|
+
};
|
|
736
|
+
};
|
|
737
|
+
/**
|
|
738
|
+
* A search rejected for its `TopK`, confirmed: the `TopK` sent is not an
|
|
739
|
+
* integer in DynamoDB's range.
|
|
740
|
+
*/
|
|
741
|
+
const diagnoseSearch = (message, sent) => TOP_K.test(message) &&
|
|
742
|
+
(!Number.isInteger(sent.topK) || sent.topK < MIN_TOP_K || sent.topK > MAX_TOP_K)
|
|
743
|
+
? { _tag: "TopKOutOfRange", index: sent.index, topK: sent.topK }
|
|
744
|
+
: undefined;
|
|
745
|
+
/**
|
|
746
|
+
* Read DynamoDB's rejection of `sent`. `keys` names the key attributes a write
|
|
747
|
+
* writes (the table's and its indexes', by kind), for the key-size shape.
|
|
748
|
+
* `undefined`: unrecognised or unconfirmed — the site fails with `error`
|
|
749
|
+
* unchanged.
|
|
750
|
+
*/
|
|
751
|
+
export const diagnoseRejection = (error, sent, keys = NO_KEYS) => {
|
|
752
|
+
const failure = readFailure(error);
|
|
753
|
+
if (failure === undefined)
|
|
754
|
+
return undefined;
|
|
755
|
+
switch (sent._tag) {
|
|
756
|
+
case "Read":
|
|
757
|
+
return failure._tag === "Request" ? diagnoseRead(failure.message, sent) : undefined;
|
|
758
|
+
case "Table":
|
|
759
|
+
return failure._tag === "Request" ? diagnoseTable(failure.message, sent) : undefined;
|
|
760
|
+
case "Search":
|
|
761
|
+
return failure._tag === "Request" ? diagnoseSearch(failure.message, sent) : undefined;
|
|
762
|
+
default:
|
|
763
|
+
return failure._tag === "Request"
|
|
764
|
+
? diagnoseRequest(failure.message, sent, keys)
|
|
765
|
+
: diagnoseCancellation(failure.reasons, sent, keys);
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
// ---------------------------------------------------------------------------
|
|
769
|
+
// Explain
|
|
770
|
+
// ---------------------------------------------------------------------------
|
|
771
|
+
const named = (target) => `${target.source}, at ${target.key}`;
|
|
772
|
+
const list = (items) => items.join(", ");
|
|
773
|
+
/**
|
|
774
|
+
* The explanation every site puts in its error, so the wording is identical
|
|
775
|
+
* library-wide. See {@link ExplainOptions} for a multi-request operation.
|
|
776
|
+
*/
|
|
777
|
+
export const explainDiagnosis = (diagnosis, operation, options) => {
|
|
778
|
+
const outcome = options?.committed !== undefined
|
|
779
|
+
? `This request wrote nothing; ${options.committed}.`
|
|
780
|
+
: "Nothing was written.";
|
|
781
|
+
switch (diagnosis._tag) {
|
|
782
|
+
case "ItemTooLarge":
|
|
783
|
+
return (`${operation}: DynamoDB rejected the write because an item exceeds its limit of ` +
|
|
784
|
+
`${ITEM_MAX_BYTES} bytes (400 KB) for one item — ` +
|
|
785
|
+
(diagnosis.likely ? "most likely the item of " : "the item of ") +
|
|
786
|
+
named(diagnosis.target) +
|
|
787
|
+
(diagnosis.bytes === undefined
|
|
788
|
+
? " (the item it would produce is not in the request, so it cannot be measured)"
|
|
789
|
+
: `, ${diagnosis.bytes} bytes by DynamoDB's item-size rules`) +
|
|
790
|
+
"." +
|
|
791
|
+
(diagnosis.likely
|
|
792
|
+
? " No item measures over the limit by the library's size model (measured against " +
|
|
793
|
+
"DynamoDB Local; AWS documents its sizes as approximate), so the largest is named."
|
|
794
|
+
: "") +
|
|
795
|
+
` ${outcome}`);
|
|
796
|
+
case "TooManyItems":
|
|
797
|
+
return (`${operation}: the transaction holds ${diagnosis.count} items, over DynamoDB's limit ` +
|
|
798
|
+
`of ${diagnosis.limit} items for one transaction. ${outcome}`);
|
|
799
|
+
case "TransactionTooLarge": {
|
|
800
|
+
const disagrees = diagnosis.measured <= TRANSACT_WRITE_MAX_BYTES;
|
|
801
|
+
return (`${operation}: DynamoDB rejected the transaction for exceeding its limit of ` +
|
|
802
|
+
`${TRANSACT_WRITE_MAX_BYTES} bytes (4 MB) for one transaction` +
|
|
803
|
+
(diagnosis.reported === undefined ? "" : ` (it reported ${diagnosis.reported} bytes)`) +
|
|
804
|
+
`; the library measures the request at ${diagnosis.measured} bytes ` +
|
|
805
|
+
"(its items, or an update's or delete's key, plus every expression with its names and values)." +
|
|
806
|
+
(diagnosis.reported === undefined
|
|
807
|
+
? ""
|
|
808
|
+
: " DynamoDB's figure is its running total in request order, up to the action that " +
|
|
809
|
+
"crossed the limit.") +
|
|
810
|
+
(disagrees
|
|
811
|
+
? " That is within the limit: the library's size model disagrees with DynamoDB's for " +
|
|
812
|
+
"this request, so it was not split to fit."
|
|
813
|
+
: "") +
|
|
814
|
+
" The largest: " +
|
|
815
|
+
diagnosis.largest
|
|
816
|
+
.map(({ target, bytes }) => `${named(target)} (${bytes} bytes)`)
|
|
817
|
+
.join("; ") +
|
|
818
|
+
". A put of a retain entity counts twice: its item and its version snapshot. " +
|
|
819
|
+
`Split the operations into smaller transactions. ${outcome}`);
|
|
820
|
+
}
|
|
821
|
+
case "KeyTooLong": {
|
|
822
|
+
const [kind, limit] = diagnosis.kind === "partition"
|
|
823
|
+
? ["partition key", PARTITION_KEY_MAX_BYTES]
|
|
824
|
+
: ["sort key", SORT_KEY_MAX_BYTES];
|
|
825
|
+
return (`${operation}: the ${kind} "${diagnosis.attribute}" of ${named(diagnosis.target)} is ` +
|
|
826
|
+
`${diagnosis.bytes} bytes, longer than DynamoDB's ${limit}-byte limit for ` +
|
|
827
|
+
`a ${kind}. ${outcome}`);
|
|
828
|
+
}
|
|
829
|
+
case "RepeatedItem":
|
|
830
|
+
return diagnosis.batch
|
|
831
|
+
? `${operation}: the batch request touches one item more than once — ` +
|
|
832
|
+
`${diagnosis.first.source} and ${diagnosis.second.source} both target the item at ` +
|
|
833
|
+
`${diagnosis.second.key}. DynamoDB allows one request per item in a batch request, ` +
|
|
834
|
+
`and rejected it. Write the item once. ${outcome}`
|
|
835
|
+
: `${operation}: the transaction touches one item more than once — ` +
|
|
836
|
+
`${diagnosis.first.source} and ${diagnosis.second.source} both target the item at ` +
|
|
837
|
+
`${diagnosis.second.key}. DynamoDB allows one operation per item in a transaction, ` +
|
|
838
|
+
"counting the uniqueness sentinels and version snapshots a put writes, and rejected " +
|
|
839
|
+
`it. Split the operations into separate writes. ${outcome}`;
|
|
840
|
+
case "ExpressionTooLarge": {
|
|
841
|
+
const subject = `the ${diagnosis.expression} of ${named(diagnosis.target)}`;
|
|
842
|
+
const over = diagnosis.limit === "length"
|
|
843
|
+
? `it is ${diagnosis.measured} characters, over DynamoDB's limit of ` +
|
|
844
|
+
`${EXPRESSION_MAX_LENGTH} characters for one expression`
|
|
845
|
+
: diagnosis.limit === "operators"
|
|
846
|
+
? `it holds ${diagnosis.measured} operators and functions, over DynamoDB's limit of ` +
|
|
847
|
+
`${EXPRESSION_MAX_OPERATORS} for one expression`
|
|
848
|
+
: `an IN in it holds ${diagnosis.measured} operands, over DynamoDB's limit of ` +
|
|
849
|
+
`${IN_MAX_OPERANDS}`;
|
|
850
|
+
const caller = diagnosis.caller;
|
|
851
|
+
// The library added a guard beside the caller's condition: say so.
|
|
852
|
+
const shared = caller !== undefined &&
|
|
853
|
+
(diagnosis.limit === "length"
|
|
854
|
+
? caller.length
|
|
855
|
+
: diagnosis.limit === "operators"
|
|
856
|
+
? caller.operators
|
|
857
|
+
: caller.inOperands) < diagnosis.measured;
|
|
858
|
+
const advice = caller !== undefined && shared
|
|
859
|
+
? ` The .condition() alone is ${caller.length} characters / ${caller.operators} ` +
|
|
860
|
+
"operators, and the library's own guard needs room beside it: simplify the condition."
|
|
861
|
+
: diagnosis.expression === "UpdateExpression"
|
|
862
|
+
? " Split the update into smaller ones."
|
|
863
|
+
: " Simplify the condition.";
|
|
864
|
+
return `${operation}: DynamoDB rejected ${subject}: ${over}.${advice} ${outcome}`;
|
|
865
|
+
}
|
|
866
|
+
case "EmptyKey":
|
|
867
|
+
return (`${operation}: DynamoDB rejected ${named(diagnosis.target)}: it writes an empty value ` +
|
|
868
|
+
`to the key attribute "${diagnosis.attribute}"` +
|
|
869
|
+
(diagnosis.index === undefined ? "" : ` of index "${diagnosis.index}"`) +
|
|
870
|
+
`, and DynamoDB does not accept an empty string as a key value. ${outcome}`);
|
|
871
|
+
case "EmptyIn":
|
|
872
|
+
return diagnosis.target === undefined
|
|
873
|
+
? `${operation}: DynamoDB rejected the filter: isIn() on "${diagnosis.attribute}" has no ` +
|
|
874
|
+
"values, and DynamoDB rejects the `IN ()` it compiles to as a syntax error. Give " +
|
|
875
|
+
"isIn() at least one value, or leave the filter out."
|
|
876
|
+
: `${operation}: DynamoDB rejected ${named(diagnosis.target)}: isIn() on ` +
|
|
877
|
+
`"${diagnosis.attribute}" in its condition has no values, and DynamoDB rejects the ` +
|
|
878
|
+
"`IN ()` it compiles to as a syntax error. Give isIn() at least one value, or " +
|
|
879
|
+
`leave the condition out. ${outcome}`;
|
|
880
|
+
case "EmptyKeyBound":
|
|
881
|
+
return (`${operation}: DynamoDB rejected the query: its key condition compares the key ` +
|
|
882
|
+
`attribute "${diagnosis.attribute}"` +
|
|
883
|
+
(diagnosis.index === undefined ? "" : ` of index "${diagnosis.index}"`) +
|
|
884
|
+
" with an empty string, which DynamoDB does not accept as a key value.");
|
|
885
|
+
case "BetweenReversed":
|
|
886
|
+
return (`${operation}: DynamoDB rejected the query: between's lower bound ` +
|
|
887
|
+
`${JSON.stringify(diagnosis.low)} sorts after its upper bound ` +
|
|
888
|
+
`${JSON.stringify(diagnosis.high)} on "${diagnosis.attribute}"` +
|
|
889
|
+
(diagnosis.index === undefined ? "" : ` of index "${diagnosis.index}"`) +
|
|
890
|
+
" (DynamoDB orders strings by their UTF-8 bytes).");
|
|
891
|
+
case "ConsistentReadOnGsi":
|
|
892
|
+
return (`consistentRead: index "${diagnosis.index}" is a global secondary index, which ` +
|
|
893
|
+
"DynamoDB reads only eventually consistently, so DynamoDB rejected the consistent " +
|
|
894
|
+
`${diagnosis.scan ? "scan" : "query"}.`);
|
|
895
|
+
case "VectorIndexFilters":
|
|
896
|
+
return (`${operation}: DynamoDB rejected vector index "${diagnosis.index}": it declares ` +
|
|
897
|
+
`${diagnosis.filters.length} INLINE_FILTER attributes (${list(diagnosis.filters)}), over ` +
|
|
898
|
+
`DynamoDB's limit of ${diagnosis.limit} per vector index. They are the union of the ` +
|
|
899
|
+
`filters of the entities sharing it (${list(diagnosis.sharers)}). ` +
|
|
900
|
+
(diagnosis.operation === "CreateTable"
|
|
901
|
+
? "The table was not created."
|
|
902
|
+
: "Nothing was changed."));
|
|
903
|
+
case "VectorIndexDimensions":
|
|
904
|
+
return (`${operation}: DynamoDB rejected vector index "${diagnosis.index}": its dimensions ` +
|
|
905
|
+
`(${diagnosis.dimensions}) are outside the range DynamoDB accepts, ` +
|
|
906
|
+
`${MIN_DIMENSIONS}..${MAX_DIMENSIONS}. The entities sharing it ` +
|
|
907
|
+
`(${list(diagnosis.sharers)}) declare it. ` +
|
|
908
|
+
(diagnosis.operation === "CreateTable"
|
|
909
|
+
? "The table was not created."
|
|
910
|
+
: "Nothing was changed."));
|
|
911
|
+
case "TopKOutOfRange":
|
|
912
|
+
return (`${operation}: DynamoDB rejected the search of vector index "${diagnosis.index}": ` +
|
|
913
|
+
`topK(${diagnosis.topK}) is outside the range DynamoDB accepts, an integer in ` +
|
|
914
|
+
`${MIN_TOP_K}..${MAX_TOP_K}. ` +
|
|
915
|
+
`A vector search returns at most ${MAX_TOP_K} results, with no further pages.`);
|
|
916
|
+
case "VectorIndexCount": {
|
|
917
|
+
const indexes = diagnosis.indexes
|
|
918
|
+
.map(({ index, sharers }) => `"${index}" (${list(sharers)})`)
|
|
919
|
+
.join(", ");
|
|
920
|
+
const quota = `its limit on vector indexes per table (${diagnosis.stated === undefined
|
|
921
|
+
? `${MAX_VECTOR_INDEXES_PER_TABLE} by default`
|
|
922
|
+
: diagnosis.stated}; the quota is adjustable through AWS Service Quotas)`;
|
|
923
|
+
return diagnosis.operation === "CreateTable"
|
|
924
|
+
? `${operation}: DynamoDB rejected the table's ${diagnosis.indexes.length} vector ` +
|
|
925
|
+
`indexes, ${indexes}, for exceeding ${quota}. The table was not created.`
|
|
926
|
+
: `${operation}: DynamoDB rejected adding vector index ${indexes} for exceeding ` +
|
|
927
|
+
`${quota}: the table already has ${diagnosis.existing ?? 0} vector ` +
|
|
928
|
+
`${diagnosis.existing === 1 ? "index" : "indexes"}, and its entities declare ` +
|
|
929
|
+
`${diagnosis.declared} in all. Nothing was changed.`;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
/**
|
|
934
|
+
* An `Error` carrying `message`, with DynamoDB's error one step down as its
|
|
935
|
+
* own `cause` — what a diagnosed error stores in its `cause`, so the readable
|
|
936
|
+
* text stays where callers look and the original is kept.
|
|
937
|
+
*/
|
|
938
|
+
export const withCause = (message, original) => new Error(message, { cause: original });
|
|
939
|
+
/** The entity (or stream) a diagnosis names, for an error's `entityType`. */
|
|
940
|
+
export const diagnosedEntityType = (diagnosis, fallback) => {
|
|
941
|
+
switch (diagnosis._tag) {
|
|
942
|
+
case "ItemTooLarge":
|
|
943
|
+
case "KeyTooLong":
|
|
944
|
+
case "ExpressionTooLarge":
|
|
945
|
+
case "EmptyKey":
|
|
946
|
+
return diagnosis.target.entityType;
|
|
947
|
+
case "EmptyIn":
|
|
948
|
+
return diagnosis.target?.entityType ?? fallback;
|
|
949
|
+
case "RepeatedItem":
|
|
950
|
+
return diagnosis.first.entityType;
|
|
951
|
+
case "TransactionTooLarge":
|
|
952
|
+
return diagnosis.largest[0]?.target.entityType ?? fallback;
|
|
953
|
+
default:
|
|
954
|
+
return fallback;
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
/** The `ValidationError` a diagnosis becomes at most sites. */
|
|
958
|
+
export const diagnosedValidationError = (diagnosis, original, options) => new ValidationError({
|
|
959
|
+
entityType: diagnosedEntityType(diagnosis, options.entityType),
|
|
960
|
+
operation: options.operation,
|
|
961
|
+
cause: withCause(explainDiagnosis(diagnosis, options.operation, options), original),
|
|
962
|
+
});
|
|
963
|
+
// ---------------------------------------------------------------------------
|
|
964
|
+
// Wiring
|
|
965
|
+
// ---------------------------------------------------------------------------
|
|
966
|
+
/**
|
|
967
|
+
* Wrap a site's request so that, on failure only, a diagnosable error is read
|
|
968
|
+
* against what was sent and — when confirmed — rewritten by `rewrite`.
|
|
969
|
+
* Everything else, and every success, passes through untouched.
|
|
970
|
+
*
|
|
971
|
+
* Placed after the site's own conditional-failure mapping, so it only ever
|
|
972
|
+
* sees what that mapping could not explain — except for a repeated item: a
|
|
973
|
+
* site whose mapping reads conditional failures (and may retry them) wraps its
|
|
974
|
+
* request a second time, BEFORE that mapping, with `only: ["RepeatedItem"]`,
|
|
975
|
+
* because DynamoDB can report a repeat as a conditional failure that reads as
|
|
976
|
+
* a lost race. A diagnosis is terminal: no retry loop sees it.
|
|
977
|
+
*
|
|
978
|
+
* `only` limits the rewrite to those diagnoses; any other passes through.
|
|
979
|
+
*/
|
|
980
|
+
export const diagnoseFailure = (options) => (self) => Effect.catch(self, (error) => {
|
|
981
|
+
if (!isDiagnosable(error))
|
|
982
|
+
return Effect.fail(error);
|
|
983
|
+
const diagnosis = diagnoseRejection(error, options.sent(), options.keys?.() ?? NO_KEYS);
|
|
984
|
+
if (diagnosis === undefined)
|
|
985
|
+
return Effect.fail(error);
|
|
986
|
+
if (options.only !== undefined && !options.only.includes(diagnosis._tag)) {
|
|
987
|
+
return Effect.fail(error);
|
|
988
|
+
}
|
|
989
|
+
return Effect.fail(options.rewrite(diagnosis, error));
|
|
990
|
+
});
|
|
991
|
+
// ---------------------------------------------------------------------------
|
|
992
|
+
// Targets
|
|
993
|
+
// ---------------------------------------------------------------------------
|
|
994
|
+
/**
|
|
995
|
+
* Targets for entries that all share one primary key schema (`keyFields`),
|
|
996
|
+
* named by `source(index, item)`. Each entry's own `TableName` is used.
|
|
997
|
+
*/
|
|
998
|
+
export const targetsFor = (items, keyFields, entityType, source) => items.map((item, index) => transactItemTarget(item, item.Put?.TableName ??
|
|
999
|
+
item.Update?.TableName ??
|
|
1000
|
+
item.Delete?.TableName ??
|
|
1001
|
+
item.ConditionCheck?.TableName ??
|
|
1002
|
+
"", keyFields, typeof entityType === "string" ? entityType : entityType(index), source(index, item)));
|
|
1003
|
+
/** A `BatchWriteItem` request map, flattened to transact-entry form in request order. */
|
|
1004
|
+
export const batchEntries = (requestItems) => Object.entries(requestItems).flatMap(([tableName, requests]) => requests.flatMap((request) => {
|
|
1005
|
+
if (request.PutRequest !== undefined) {
|
|
1006
|
+
return [{ Put: { TableName: tableName, Item: request.PutRequest.Item ?? {} } }];
|
|
1007
|
+
}
|
|
1008
|
+
if (request.DeleteRequest !== undefined) {
|
|
1009
|
+
return [{ Delete: { TableName: tableName, Key: request.DeleteRequest.Key ?? {} } }];
|
|
1010
|
+
}
|
|
1011
|
+
return [];
|
|
1012
|
+
}));
|
|
1013
|
+
/**
|
|
1014
|
+
* The key attributes an entity writes, by kind: its primary key's and each
|
|
1015
|
+
* index's partition and sort keys. `indexes` is an entity's `indexes` record.
|
|
1016
|
+
*/
|
|
1017
|
+
export const keyAttributesOf = (indexes) => {
|
|
1018
|
+
const fields = (half) => [
|
|
1019
|
+
...new Set(Object.values(indexes).flatMap((index) => index?.[half]?.field === undefined ? [] : [index[half].field])),
|
|
1020
|
+
];
|
|
1021
|
+
return { partition: fields("pk"), sort: fields("sk") };
|
|
1022
|
+
};
|
|
1023
|
+
/** Key attributes from several sources, each attribute once per kind. */
|
|
1024
|
+
export const mergeKeyAttributes = (...all) => ({
|
|
1025
|
+
partition: [...new Set(all.flatMap((keys) => keys.partition))],
|
|
1026
|
+
sort: [...new Set(all.flatMap((keys) => keys.sort))],
|
|
1027
|
+
});
|
|
1028
|
+
//# sourceMappingURL=WriteDiagnosis.js.map
|