effect-dynamodb 1.36.0 → 1.37.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 +29 -17
- package/dist/Aggregate.d.ts.map +1 -1
- package/dist/Aggregate.js +795 -173
- package/dist/Aggregate.js.map +1 -1
- package/dist/Batch.d.ts.map +1 -1
- package/dist/Batch.js +196 -77
- package/dist/Batch.js.map +1 -1
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +11 -2
- package/dist/Collection.js.map +1 -1
- package/dist/Collections.d.ts +15 -6
- package/dist/Collections.d.ts.map +1 -1
- package/dist/Collections.js +2 -1
- package/dist/Collections.js.map +1 -1
- package/dist/DynamoClient.d.ts +119 -10
- package/dist/DynamoClient.d.ts.map +1 -1
- package/dist/DynamoClient.js +1148 -142
- package/dist/DynamoClient.js.map +1 -1
- package/dist/Entity.d.ts +126 -51
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +3477 -539
- package/dist/Entity.js.map +1 -1
- package/dist/EventStore.d.ts +4 -3
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +616 -53
- package/dist/EventStore.js.map +1 -1
- package/dist/Query.d.ts +311 -25
- package/dist/Query.d.ts.map +1 -1
- package/dist/Query.js +1159 -103
- package/dist/Query.js.map +1 -1
- package/dist/Table.d.ts +172 -3
- package/dist/Table.d.ts.map +1 -1
- package/dist/Table.js +85 -4
- package/dist/Table.js.map +1 -1
- package/dist/Transaction.d.ts +18 -2
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +62 -8
- package/dist/Transaction.js.map +1 -1
- package/dist/change-feed/DynamoStreamsClient.d.ts +1 -1
- package/dist/change-feed/DynamoStreamsClient.d.ts.map +1 -1
- package/dist/change-feed/DynamoStreamsClient.js +3 -1
- package/dist/change-feed/DynamoStreamsClient.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/BoundQuery.d.ts +226 -32
- package/dist/internal/BoundQuery.d.ts.map +1 -1
- package/dist/internal/BoundQuery.js +96 -18
- package/dist/internal/BoundQuery.js.map +1 -1
- package/dist/internal/Capacity.d.ts +8 -0
- package/dist/internal/Capacity.d.ts.map +1 -1
- package/dist/internal/Capacity.js +24 -4
- package/dist/internal/Capacity.js.map +1 -1
- package/dist/internal/CredentialRefusal.d.ts +32 -0
- package/dist/internal/CredentialRefusal.d.ts.map +1 -0
- package/dist/internal/CredentialRefusal.js +80 -0
- package/dist/internal/CredentialRefusal.js.map +1 -0
- package/dist/internal/Cursor.d.ts +90 -0
- package/dist/internal/Cursor.d.ts.map +1 -0
- package/dist/internal/Cursor.js +179 -0
- package/dist/internal/Cursor.js.map +1 -0
- package/dist/internal/ExpressionLimits.d.ts +18 -2
- package/dist/internal/ExpressionLimits.d.ts.map +1 -1
- package/dist/internal/ExpressionLimits.js +35 -2
- package/dist/internal/ExpressionLimits.js.map +1 -1
- package/dist/internal/ProjectionEngine.d.ts +16 -0
- package/dist/internal/ProjectionEngine.d.ts.map +1 -1
- package/dist/internal/ProjectionEngine.js +242 -46
- package/dist/internal/ProjectionEngine.js.map +1 -1
- package/dist/internal/RangePlanner.d.ts +394 -0
- package/dist/internal/RangePlanner.d.ts.map +1 -0
- package/dist/internal/RangePlanner.js +1296 -0
- package/dist/internal/RangePlanner.js.map +1 -0
- package/dist/internal/Rekey.d.ts +78 -0
- package/dist/internal/Rekey.d.ts.map +1 -0
- package/dist/internal/Rekey.js +323 -0
- package/dist/internal/Rekey.js.map +1 -0
- package/dist/internal/TransactPlan.d.ts +8 -1
- package/dist/internal/TransactPlan.d.ts.map +1 -1
- package/dist/internal/TransactPlan.js +9 -2
- package/dist/internal/TransactPlan.js.map +1 -1
- package/dist/internal/TransactWriteOps.d.ts +10 -7
- package/dist/internal/TransactWriteOps.d.ts.map +1 -1
- package/dist/internal/TransactWriteOps.js +82 -6
- package/dist/internal/TransactWriteOps.js.map +1 -1
- package/dist/internal/TransactableOps.d.ts +31 -0
- package/dist/internal/TransactableOps.d.ts.map +1 -1
- package/dist/internal/TransactableOps.js +50 -1
- package/dist/internal/TransactableOps.js.map +1 -1
- package/dist/internal/WriteDiagnosis.d.ts.map +1 -1
- package/dist/internal/WriteDiagnosis.js +10 -7
- package/dist/internal/WriteDiagnosis.js.map +1 -1
- package/package.json +2 -2
package/dist/DynamoClient.js
CHANGED
|
@@ -12,18 +12,24 @@ import { Compression, make as makeCompressionRegistry, } from "@effect-dynamodb/
|
|
|
12
12
|
import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
|
|
13
13
|
import { Embedder } from "@effect-dynamodb/schema/Embedder.js";
|
|
14
14
|
import { DynamoError, DynamoValidationError, InternalServerError, isAwsInternalServerError, isAwsResourceNotFound, isAwsThrottling, isAwsValidationError, ResourceNotFoundError, ThrottlingError, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
|
|
15
|
-
import { compositeKeyFormKind, makeCompositeKeyForm, toCompositeKeyRecord, } from "@effect-dynamodb/schema/internal/CompositeCodec.js";
|
|
15
|
+
import { compositeKeyFormKind, keyFormValueKind, makeCompositeKeyForm, toCompositeKeyRecord, } from "@effect-dynamodb/schema/internal/CompositeCodec.js";
|
|
16
16
|
import { makeDefaultCrypto } from "@effect-dynamodb/schema/internal/DefaultCrypto.js";
|
|
17
|
+
import * as KeyText136 from "@effect-dynamodb/schema/internal/KeyText136.js";
|
|
18
|
+
import { getSchemaFields } from "@effect-dynamodb/schema/internal/SchemaAccessors.js";
|
|
19
|
+
import * as ZOrderCurve from "@effect-dynamodb/schema/internal/ZOrderCurve.js";
|
|
17
20
|
import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
|
|
18
|
-
import { Config, Context, Crypto, Duration, Effect, Layer, Option, } from "effect";
|
|
21
|
+
import { Config, Context, Crypto, DateTime, Duration, Effect, Layer, Option, SchemaAST, } from "effect";
|
|
19
22
|
import { bind as aggregateBind } from "./Aggregate.js";
|
|
20
23
|
import { bind as entityBind, fromDefinition as entityFromDefinition } from "./Entity.js";
|
|
21
24
|
import { BoundQueryImpl, collectionNaming, entityNaming, } from "./internal/BoundQuery.js";
|
|
22
25
|
import { makeBoundVectorQuery, } from "./internal/BoundVectorQuery.js";
|
|
23
26
|
import * as Capacity from "./internal/Capacity.js";
|
|
24
|
-
import {
|
|
27
|
+
import { redactCredentialRefusal } from "./internal/CredentialRefusal.js";
|
|
28
|
+
import { createConditionOps, ExprTag } from "./internal/Expr.js";
|
|
25
29
|
import { codecRequirements, collectionPackedQueryOptions, entityPackedQueryOptions, unregisteredCodecError, } from "./internal/Packed.js";
|
|
26
30
|
import { createPathBuilder } from "./internal/PathBuilder.js";
|
|
31
|
+
import { canonicalJson, compareUtf8, compositePlanner, keyJustBelow, zorderPlanner, } from "./internal/RangePlanner.js";
|
|
32
|
+
import * as Rekey from "./internal/Rekey.js";
|
|
27
33
|
import { diagnoseFailure, explainDiagnosis, withCause, } from "./internal/WriteDiagnosis.js";
|
|
28
34
|
import * as Query from "./Query.js";
|
|
29
35
|
import { mergeVectorIndexes, definition as tableDefinition, toVectorIndexSpec } from "./Table.js";
|
|
@@ -36,6 +42,942 @@ import { mergeVectorIndexes, definition as tableDefinition, toVectorIndexSpec }
|
|
|
36
42
|
* character DynamoDB key composition emits for the following segment.
|
|
37
43
|
*/
|
|
38
44
|
const SK_MAX_SENTINEL = "";
|
|
45
|
+
/**
|
|
46
|
+
* A composite value's key text as key composition writes it: serialized,
|
|
47
|
+
* cased and escaped (`KeyComposer.serializeSortKeyValue`), so it sorts as the
|
|
48
|
+
* cased value does.
|
|
49
|
+
*/
|
|
50
|
+
const valueText = (casing, keyForm) => KeyComposer.serializeSortKeyValue(keyForm, casing);
|
|
51
|
+
/** A sort key composite's segment as key composition writes it: `<name>_<value>`, cased. */
|
|
52
|
+
export const compositeSegment = (casing, field, keyForm) => `${DynamoSchema.applyCasing(field, casing)}_${valueText(casing, keyForm)}`;
|
|
53
|
+
/** `bound` with each operand mapped — into its key form, say. */
|
|
54
|
+
const mapBound = (bound, f) => {
|
|
55
|
+
if ("eq" in bound)
|
|
56
|
+
return { eq: f(bound.eq) };
|
|
57
|
+
if ("lt" in bound)
|
|
58
|
+
return { lt: f(bound.lt) };
|
|
59
|
+
if ("lte" in bound)
|
|
60
|
+
return { lte: f(bound.lte) };
|
|
61
|
+
if ("gt" in bound)
|
|
62
|
+
return { gt: f(bound.gt) };
|
|
63
|
+
if ("gte" in bound)
|
|
64
|
+
return { gte: f(bound.gte) };
|
|
65
|
+
if ("between" in bound)
|
|
66
|
+
return { between: [f(bound.between[0]), f(bound.between[1])] };
|
|
67
|
+
return { beginsWith: f(bound.beginsWith) };
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Does a composite's value satisfy `bound`, compared as key text (§11)? Key
|
|
71
|
+
* forms. Escaped key text sorts as the cased value, so this is the order the
|
|
72
|
+
* key condition reads in.
|
|
73
|
+
*/
|
|
74
|
+
const boundHolds = (casing, bound, keyForm) => {
|
|
75
|
+
const text = valueText(casing, keyForm);
|
|
76
|
+
const against = (operand) => compareUtf8(text, valueText(casing, operand));
|
|
77
|
+
if ("eq" in bound)
|
|
78
|
+
return against(bound.eq) === 0;
|
|
79
|
+
if ("lt" in bound)
|
|
80
|
+
return against(bound.lt) < 0;
|
|
81
|
+
if ("lte" in bound)
|
|
82
|
+
return against(bound.lte) <= 0;
|
|
83
|
+
if ("gt" in bound)
|
|
84
|
+
return against(bound.gt) > 0;
|
|
85
|
+
if ("gte" in bound)
|
|
86
|
+
return against(bound.gte) >= 0;
|
|
87
|
+
if ("between" in bound)
|
|
88
|
+
return against(bound.between[0]) >= 0 && against(bound.between[1]) <= 0;
|
|
89
|
+
// A number has no text prefix: `beginsWith` on a numeric composite is `eq`.
|
|
90
|
+
const operand = bound.beginsWith;
|
|
91
|
+
return typeof operand === "number" || typeof operand === "bigint"
|
|
92
|
+
? against(operand) === 0
|
|
93
|
+
: text.startsWith(valueText(casing, operand));
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* @internal Compose a bound on sort key composite `field` into a key condition, the
|
|
97
|
+
* same way `put` composes the stored key: `prefixText` — the key text of
|
|
98
|
+
* every composite before `field`, ending with the delimiter — then
|
|
99
|
+
* `<name>_<value>`. Operands are already in key form.
|
|
100
|
+
*
|
|
101
|
+
* A stored SK is `$schema#v1#entity#<name>_<cased value>#...`; comparing a
|
|
102
|
+
* raw operand against that is meaningless (issue #101 — `gte` matched the
|
|
103
|
+
* whole partition because `"1-009" < "ballkey_…"`, while `begins_with` /
|
|
104
|
+
* `between` matched nothing). So the operand is placed in the *position* of
|
|
105
|
+
* the composite it targets and composed like the key, with `serializeValue`,
|
|
106
|
+
* the `<name>_` prefix, the casing and the escaping.
|
|
107
|
+
*
|
|
108
|
+
* The condition is exact: a composite's key text is escaped
|
|
109
|
+
* (`KeyComposer.sortKeyText`), so it sorts as its value, and the delimiter
|
|
110
|
+
* that follows it sorts below every character it holds — a value's subtree
|
|
111
|
+
* (`v#…`) sorts after `v` itself and before every greater value (`v%20x`,
|
|
112
|
+
* `v0`), whatever characters the values hold (#171).
|
|
113
|
+
*
|
|
114
|
+
* `clampToPrefix` keeps a one-sided bound inside `prefixText`: DynamoDB allows
|
|
115
|
+
* one sort key condition, so once leading composites are pinned the open end
|
|
116
|
+
* is clamped and the condition becomes a `BETWEEN`. A strict `lt` on the last
|
|
117
|
+
* composite then ends at the greatest key text below the bound
|
|
118
|
+
* (`keyJustBelow`), BETWEEN being inclusive.
|
|
119
|
+
*
|
|
120
|
+
* Shared by `.where()` and `.within()` (#171), so the two can't drift.
|
|
121
|
+
*/
|
|
122
|
+
export const composeRangeUnder = (index, prefixText, field, bound, options) => {
|
|
123
|
+
// A missing operand composes the prefix alone, without its delimiter — as
|
|
124
|
+
// `composeSortKeyPrefix` stops at the first missing composite.
|
|
125
|
+
const compose = (value) => value === undefined || value === null
|
|
126
|
+
? prefixText.slice(0, -1)
|
|
127
|
+
: `${prefixText}${compositeSegment(index.casing, field, value)}`;
|
|
128
|
+
/**
|
|
129
|
+
* `begins_with` operand matching exactly the keys whose composite equals
|
|
130
|
+
* `value`: it stops on a segment boundary rather than leaking into sibling
|
|
131
|
+
* values (`status_done` vs `status_done_archived`, #115).
|
|
132
|
+
*/
|
|
133
|
+
const subtreeBeginsWith = (value) => `${compose(value)}${DynamoSchema.KEY_DELIMITER}`;
|
|
134
|
+
/**
|
|
135
|
+
* A bound sorting strictly above every key whose composite is `value`, and
|
|
136
|
+
* strictly below the first key of any greater value.
|
|
137
|
+
*
|
|
138
|
+
* The delimiter here is UNCONDITIONAL, including on the last composite:
|
|
139
|
+
* `compose(v)` alone would sort below `compose(v + "0")`, which is a
|
|
140
|
+
* strictly greater value that must stay inside a `gt`. `compose(v)#` sits
|
|
141
|
+
* above `compose(v)` and below every longer value because `#` is lower than
|
|
142
|
+
* every character a composite's escaped text holds.
|
|
143
|
+
*/
|
|
144
|
+
const aboveSubtree = (value) => `${compose(value)}${DynamoSchema.KEY_DELIMITER}${SK_MAX_SENTINEL}`;
|
|
145
|
+
/** Inclusive upper bound covering the whole subtree of `value`. */
|
|
146
|
+
const upper = (value) => (index.isLast ? compose(value) : aboveSubtree(value));
|
|
147
|
+
// `beginsWith` / `between` / `eq` carry the prefix in BOTH operands, so they
|
|
148
|
+
// never escape it.
|
|
149
|
+
if ("beginsWith" in bound) {
|
|
150
|
+
// A number has no text prefix: its key text is not its digits (`-3` is
|
|
151
|
+
// `-9999999999999996:`), and an integer's text prefixes its fractions'
|
|
152
|
+
// (3 of 3.14). `beginsWith` on a numeric composite is `eq` — what it
|
|
153
|
+
// amounted to up to 1.36 for non-negative integers, whose key text was
|
|
154
|
+
// fixed-width.
|
|
155
|
+
const operand = bound.beginsWith;
|
|
156
|
+
if (typeof operand === "number" || typeof operand === "bigint") {
|
|
157
|
+
return composeRangeUnder(index, prefixText, field, { eq: operand }, options);
|
|
158
|
+
}
|
|
159
|
+
return { beginsWith: compose(operand) };
|
|
160
|
+
}
|
|
161
|
+
if ("eq" in bound) {
|
|
162
|
+
return index.isLast ? { eq: compose(bound.eq) } : { beginsWith: subtreeBeginsWith(bound.eq) };
|
|
163
|
+
}
|
|
164
|
+
if ("between" in bound) {
|
|
165
|
+
return { between: [compose(bound.between[0]), upper(bound.between[1])] };
|
|
166
|
+
}
|
|
167
|
+
// One-sided operators are open on the other side, which is exactly right
|
|
168
|
+
// when nothing is clamped: the open end runs to the edge of the partition.
|
|
169
|
+
if (!options.clampToPrefix) {
|
|
170
|
+
if ("lt" in bound)
|
|
171
|
+
return { lt: compose(bound.lt) };
|
|
172
|
+
if ("lte" in bound)
|
|
173
|
+
return { lte: upper(bound.lte) };
|
|
174
|
+
// `>` is already exclusive of the composed value, so only a non-terminal
|
|
175
|
+
// target needs the subtree pushed past.
|
|
176
|
+
if ("gt" in bound)
|
|
177
|
+
return { gt: index.isLast ? compose(bound.gt) : aboveSubtree(bound.gt) };
|
|
178
|
+
return { gte: compose(bound.gte) };
|
|
179
|
+
}
|
|
180
|
+
const prefixMax = `${prefixText}${SK_MAX_SENTINEL}`;
|
|
181
|
+
if ("gte" in bound)
|
|
182
|
+
return { between: [compose(bound.gte), prefixMax] };
|
|
183
|
+
if ("gt" in bound)
|
|
184
|
+
return { between: [aboveSubtree(bound.gt), prefixMax] };
|
|
185
|
+
if ("lte" in bound)
|
|
186
|
+
return { between: [prefixText, upper(bound.lte)] };
|
|
187
|
+
// `lt`. On a non-terminal composite no stored key can equal the composed
|
|
188
|
+
// bound (trailing composites always follow), so BETWEEN's inclusive high end
|
|
189
|
+
// is harmless. On the LAST composite the bound IS a storable key: the high
|
|
190
|
+
// end is the greatest key text below it (`EDD-9046` refused this bound up to
|
|
191
|
+
// 1.36).
|
|
192
|
+
const below = compose(bound.lt);
|
|
193
|
+
return { between: [prefixText, index.isLast ? keyJustBelow(below) : below] };
|
|
194
|
+
};
|
|
195
|
+
/**
|
|
196
|
+
* @internal A `.within()` bound on composite `field` — its lower and / or upper end —
|
|
197
|
+
* composed under `prefixText` as {@link composeRangeUnder} composes each,
|
|
198
|
+
* clamped to it: one `BETWEEN` of the lower end's start and the upper end's
|
|
199
|
+
* end, or the one end given. `undefined` when no value satisfies both ends
|
|
200
|
+
* (`{ gt: 3, lte: 3 }`, `{ gte: "b", lt: "a" }`), judged on the ends' key
|
|
201
|
+
* text, which sorts as their values: nothing need be read, and the composed
|
|
202
|
+
* ends could be reversed, which DynamoDB refuses.
|
|
203
|
+
*/
|
|
204
|
+
export const composeIntervalUnder = (index, prefixText, field, lower, upper) => {
|
|
205
|
+
const under = (bound) => composeRangeUnder(index, prefixText, field, bound, { clampToPrefix: true });
|
|
206
|
+
if (lower === undefined || upper === undefined) {
|
|
207
|
+
const one = lower ?? upper;
|
|
208
|
+
if (one === undefined)
|
|
209
|
+
throw new Error("[internal] A .within() range bound has no end.");
|
|
210
|
+
return under(one);
|
|
211
|
+
}
|
|
212
|
+
const operand = (bound) => valueText(index.casing, Object.values(bound)[0]);
|
|
213
|
+
const order = compareUtf8(operand(lower), operand(upper));
|
|
214
|
+
if (order > 0 || (order === 0 && ("gt" in lower || "lt" in upper)))
|
|
215
|
+
return undefined;
|
|
216
|
+
return { between: [under(lower).between[0], under(upper).between[1]] };
|
|
217
|
+
};
|
|
218
|
+
/** A composed sort key condition as a planned key range: only the forms a clamped bound composes. */
|
|
219
|
+
const keyRangeOf = (condition, exact) => {
|
|
220
|
+
if ("eq" in condition)
|
|
221
|
+
return { lower: condition.eq, upper: condition.eq, exact };
|
|
222
|
+
if ("between" in condition) {
|
|
223
|
+
return { lower: condition.between[0], upper: condition.between[1], exact };
|
|
224
|
+
}
|
|
225
|
+
if ("beginsWith" in condition) {
|
|
226
|
+
return { lower: undefined, upper: undefined, beginsWith: condition.beginsWith, exact };
|
|
227
|
+
}
|
|
228
|
+
throw new Error("[internal] .within() composed an unclamped sort key condition.");
|
|
229
|
+
};
|
|
230
|
+
const BOUND_OPERATORS = new Set(["eq", "between", "gt", "gte", "lt", "lte"]);
|
|
231
|
+
/**
|
|
232
|
+
* How a row check reads a composite's key form from a raw row (§11): its
|
|
233
|
+
* stored attribute — for a ref-derived `${ref}Id`, the referenced identifier
|
|
234
|
+
* inside the stored ref, which is the value the key was composed from —
|
|
235
|
+
* through the accessor's encoder for a numeric field stored as a string, so a
|
|
236
|
+
* stored `"950000"` reads as `950000`. `undefined` when the row lacks it.
|
|
237
|
+
*/
|
|
238
|
+
const storedKeyForm = (args, field) => {
|
|
239
|
+
const resolve = args.entityLike._resolveDbName ?? ((name) => name);
|
|
240
|
+
const ref = args.entityLike._resolvedRefs?.find((r) => r.idFieldName === field);
|
|
241
|
+
const attribute = resolve(ref?.fieldName ?? field);
|
|
242
|
+
const schema = getSchemaFields(args.encoderSource)?.[field];
|
|
243
|
+
const wantsBigInt = schema !== undefined && SchemaAST.isBigInt(SchemaAST.toType(schema.ast));
|
|
244
|
+
const typeNumeric = compositeKeyFormKind(args.encoderSource, field) === "type-numeric";
|
|
245
|
+
return {
|
|
246
|
+
attribute,
|
|
247
|
+
read: (row) => {
|
|
248
|
+
const value = ref === undefined ? row[attribute] : row[attribute]?.M?.[ref.identifierField];
|
|
249
|
+
if (value === undefined)
|
|
250
|
+
return undefined;
|
|
251
|
+
const stored = value.S ?? value.N;
|
|
252
|
+
if (typeNumeric && stored !== undefined)
|
|
253
|
+
return args.compositeKeyForm(field, stored);
|
|
254
|
+
if (value.S !== undefined)
|
|
255
|
+
return value.S;
|
|
256
|
+
if (value.N !== undefined)
|
|
257
|
+
return wantsBigInt ? BigInt(value.N) : Number(value.N);
|
|
258
|
+
if (value.BOOL !== undefined)
|
|
259
|
+
return value.BOOL;
|
|
260
|
+
return undefined;
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
};
|
|
264
|
+
/**
|
|
265
|
+
* A composite's per-row check (§11): its key form read from the raw row
|
|
266
|
+
* ({@link storedKeyForm}), and `holds` asked of it; a row without it fails.
|
|
267
|
+
*/
|
|
268
|
+
const compositeCheck = (args, field, holds) => {
|
|
269
|
+
const stored = storedKeyForm(args, field);
|
|
270
|
+
return {
|
|
271
|
+
field,
|
|
272
|
+
attribute: stored.attribute,
|
|
273
|
+
accepts: (row) => {
|
|
274
|
+
const keyForm = stored.read(row);
|
|
275
|
+
return keyForm !== undefined && holds(keyForm);
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
/** Field checks as one row check, every one of them passing; `undefined` for none. */
|
|
280
|
+
const combineRowChecks = (checks) => checks.length === 0
|
|
281
|
+
? undefined
|
|
282
|
+
: {
|
|
283
|
+
fields: checks.map((check) => check.field),
|
|
284
|
+
reads: [...new Set(checks.map((check) => check.attribute))],
|
|
285
|
+
accepts: (row) => checks.every((check) => check.accepts(row)),
|
|
286
|
+
};
|
|
287
|
+
/**
|
|
288
|
+
* Plan `.within(bounds)` on a composite sort key accessor (design §7.1):
|
|
289
|
+
*
|
|
290
|
+
* - eq bounds right after the accessor's pins extend the prefix, like pins;
|
|
291
|
+
* - a range bound on the next composite (or nothing else bounded) is the `key`
|
|
292
|
+
* plan — one key condition, composed as `.where()` composes it;
|
|
293
|
+
* - otherwise the unbounded composites in between are the skip tuple:
|
|
294
|
+
* `skip-scan` when the tuple parse is sound (§7.3), else `filter`.
|
|
295
|
+
*
|
|
296
|
+
* Every bound the key condition can't express is checked per row (§11).
|
|
297
|
+
* Misuse from untyped code throws `[EDD-9093]`; a value that doesn't reach
|
|
298
|
+
* key form throws `[EDD-9050]` (the accessor's own encoder). `{}` plans
|
|
299
|
+
* nothing: the query is returned as it is.
|
|
300
|
+
*/
|
|
301
|
+
const planCompositeWithin = (args) => {
|
|
302
|
+
const { entityLike, accessor, indexDef, compositeKeyForm, pinnedKeyForm } = args;
|
|
303
|
+
const sk = indexDef.sk.composite;
|
|
304
|
+
const n = sk.length;
|
|
305
|
+
const p = sk.findIndex((attr) => pinnedKeyForm[attr] === undefined);
|
|
306
|
+
const pinnedCount = p === -1 ? n : p;
|
|
307
|
+
const refuse = (why) => {
|
|
308
|
+
throw new Error(`[EDD-9093] .within() on index "${accessor}" of entity "${entityLike.entityType}": ${why}`);
|
|
309
|
+
};
|
|
310
|
+
if (typeof args.bounds !== "object" || args.bounds === null || Array.isArray(args.bounds)) {
|
|
311
|
+
return refuse("pass an object of bounds keyed by sort key composite.");
|
|
312
|
+
}
|
|
313
|
+
const open = sk.slice(pinnedCount);
|
|
314
|
+
const fields = getSchemaFields(args.encoderSource);
|
|
315
|
+
/** The ref a `${ref}Id` composite is derived from: the item stores the ref, not the id. */
|
|
316
|
+
const refOf = (field) => entityLike._resolvedRefs?.find((ref) => ref.idFieldName === field);
|
|
317
|
+
/** The kind of value a composite's key form is, when its schema says (EDD-9093's type check). */
|
|
318
|
+
const kindOf = (field) => {
|
|
319
|
+
const schema = fields?.[field];
|
|
320
|
+
return schema === undefined ? undefined : keyFormValueKind(schema);
|
|
321
|
+
};
|
|
322
|
+
// -- validate and normalise (EDD-9093) --
|
|
323
|
+
const bounds = new Map();
|
|
324
|
+
for (const [field, raw] of Object.entries(args.bounds)) {
|
|
325
|
+
if (raw === undefined)
|
|
326
|
+
continue;
|
|
327
|
+
const i = sk.indexOf(field);
|
|
328
|
+
if (i === -1) {
|
|
329
|
+
return refuse(indexDef.pk.composite.includes(field)
|
|
330
|
+
? `"${field}" is a partition key composite: give it to the accessor.`
|
|
331
|
+
: `"${field}" is not a sort key composite of the index. Bound one of: ${open.join(", ")}.`);
|
|
332
|
+
}
|
|
333
|
+
if (i < pinnedCount) {
|
|
334
|
+
return refuse(`"${field}" is already pinned by the accessor, so it can't also be bounded. Drop it ` +
|
|
335
|
+
`from the accessor call to bound it here.`);
|
|
336
|
+
}
|
|
337
|
+
if (refOf(field) === undefined &&
|
|
338
|
+
compositeKeyFormKind(args.encoderSource, field) === "absent") {
|
|
339
|
+
return refuse(`"${field}" is not stored on the item, so its bound can't be checked per row. Give ` +
|
|
340
|
+
`it to the accessor, or bound it with .where().`);
|
|
341
|
+
}
|
|
342
|
+
const shape = raw;
|
|
343
|
+
const operators = typeof raw === "object" && raw !== null && !Array.isArray(raw)
|
|
344
|
+
? Object.keys(shape).filter((key) => shape[key] !== undefined)
|
|
345
|
+
: [];
|
|
346
|
+
const between = shape?.between;
|
|
347
|
+
const wellFormed = operators.length > 0 &&
|
|
348
|
+
operators.every((key) => BOUND_OPERATORS.has(key)) &&
|
|
349
|
+
(operators.includes("eq") || operators.includes("between") ? operators.length === 1 : true) &&
|
|
350
|
+
!(operators.includes("gt") && operators.includes("gte")) &&
|
|
351
|
+
!(operators.includes("lt") && operators.includes("lte")) &&
|
|
352
|
+
(between === undefined || (Array.isArray(between) && between.length === 2));
|
|
353
|
+
if (!wellFormed) {
|
|
354
|
+
return refuse(`the bound on "${field}" is not one of { eq }, { between: [low, high] }, or ` +
|
|
355
|
+
`{ gt | gte } and / or { lt | lte }.`);
|
|
356
|
+
}
|
|
357
|
+
const expected = kindOf(field);
|
|
358
|
+
const keyForm = (value) => {
|
|
359
|
+
const converted = compositeKeyForm(field, value);
|
|
360
|
+
if (expected !== undefined && typeof converted !== expected) {
|
|
361
|
+
return refuse(`the bound on "${field}" is ${JSON.stringify(String(value))} (${typeof value}), but ` +
|
|
362
|
+
`the composite's key form is a ${expected}.`);
|
|
363
|
+
}
|
|
364
|
+
return converted;
|
|
365
|
+
};
|
|
366
|
+
if (operators.includes("eq")) {
|
|
367
|
+
bounds.set(i, { _tag: "eq", value: keyForm(shape.eq) });
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
const [low, high] = between ?? [];
|
|
371
|
+
bounds.set(i, {
|
|
372
|
+
_tag: "range",
|
|
373
|
+
lower: between !== undefined
|
|
374
|
+
? { gte: keyForm(low) }
|
|
375
|
+
: "gt" in shape && shape.gt !== undefined
|
|
376
|
+
? { gt: keyForm(shape.gt) }
|
|
377
|
+
: "gte" in shape && shape.gte !== undefined
|
|
378
|
+
? { gte: keyForm(shape.gte) }
|
|
379
|
+
: undefined,
|
|
380
|
+
upper: between !== undefined
|
|
381
|
+
? { lte: keyForm(high) }
|
|
382
|
+
: "lt" in shape && shape.lt !== undefined
|
|
383
|
+
? { lt: keyForm(shape.lt) }
|
|
384
|
+
: "lte" in shape && shape.lte !== undefined
|
|
385
|
+
? { lte: keyForm(shape.lte) }
|
|
386
|
+
: undefined,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
if (bounds.size === 0)
|
|
390
|
+
return args.query;
|
|
391
|
+
// -- composition --
|
|
392
|
+
const casing = KeyComposer.effectiveCasing(entityLike._schema, indexDef);
|
|
393
|
+
const segment = (i, keyForm) => compositeSegment(casing, sk[i], keyForm);
|
|
394
|
+
const label = (i) => `${DynamoSchema.applyCasing(sk[i], casing)}_`;
|
|
395
|
+
/**
|
|
396
|
+
* Bound `i` composed under `prefixText`, clamped to it; `undefined` when no
|
|
397
|
+
* value satisfies it (`{ gt: 3, lte: 3 }`).
|
|
398
|
+
*/
|
|
399
|
+
const composeBound = (prefixText, i) => {
|
|
400
|
+
const bound = bounds.get(i);
|
|
401
|
+
const index = { casing, isLast: i === n - 1 };
|
|
402
|
+
return bound._tag === "eq"
|
|
403
|
+
? composeRangeUnder(index, prefixText, sk[i], { eq: bound.value }, { clampToPrefix: true })
|
|
404
|
+
: composeIntervalUnder(index, prefixText, sk[i], bound.lower, bound.upper);
|
|
405
|
+
};
|
|
406
|
+
/** Does bound `i` hold for a composite value (key form), compared as key text? */
|
|
407
|
+
const holds = (i, keyForm) => {
|
|
408
|
+
const bound = bounds.get(i);
|
|
409
|
+
if (bound._tag === "eq")
|
|
410
|
+
return boundHolds(casing, { eq: bound.value }, keyForm);
|
|
411
|
+
return ((bound.lower === undefined || boundHolds(casing, bound.lower, keyForm)) &&
|
|
412
|
+
(bound.upper === undefined || boundHolds(casing, bound.upper, keyForm)));
|
|
413
|
+
};
|
|
414
|
+
/**
|
|
415
|
+
* The key condition under `prefixText` from composite `from`: eq bounds
|
|
416
|
+
* extend the prefix, then one range bound (or the prefix alone). Returns
|
|
417
|
+
* the composites left to the row check.
|
|
418
|
+
*/
|
|
419
|
+
const keyCondition = (prefixText, from) => {
|
|
420
|
+
let q = prefixText;
|
|
421
|
+
let i = from;
|
|
422
|
+
while (i < n) {
|
|
423
|
+
const bound = bounds.get(i);
|
|
424
|
+
if (bound === undefined)
|
|
425
|
+
break;
|
|
426
|
+
if (bound._tag === "eq" && i < n - 1) {
|
|
427
|
+
q = `${q}${segment(i, bound.value)}${DynamoSchema.KEY_DELIMITER}`;
|
|
428
|
+
i++;
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
condition: composeBound(q, i),
|
|
433
|
+
checked: [...bounds.keys()].filter((j) => j > i),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
return { condition: { beginsWith: q }, checked: [...bounds.keys()].filter((j) => j >= i) };
|
|
437
|
+
};
|
|
438
|
+
/**
|
|
439
|
+
* Bounds checked on the raw row (§11): each composite's key text is
|
|
440
|
+
* composed from the stored attribute — `serializeValue`, casing and
|
|
441
|
+
* escaping, the composer's own path — and compared with the bound's key
|
|
442
|
+
* text ({@link boundHolds}). Key text sorts as the value, so a row passes
|
|
443
|
+
* exactly when the key condition would have admitted it, and the skip and
|
|
444
|
+
* filter plans agree on every row. A ref-derived composite (`${ref}Id`) is
|
|
445
|
+
* read from the stored ref: the referenced identifier inside its map, which
|
|
446
|
+
* is the value the key was composed from.
|
|
447
|
+
*/
|
|
448
|
+
const rowCheckFor = (indices) => combineRowChecks([...indices]
|
|
449
|
+
.sort((a, b) => a - b)
|
|
450
|
+
.map((i) => compositeCheck(args, sk[i], (keyForm) => holds(i, keyForm))));
|
|
451
|
+
// -- plan choice (§7.1) --
|
|
452
|
+
const prefix0 = KeyComposer.composeSortKeyBeginsWith(entityLike._schema, entityLike.entityType, 1, indexDef, pinnedKeyForm);
|
|
453
|
+
let k = pinnedCount;
|
|
454
|
+
let P = prefix0;
|
|
455
|
+
while (k < n - 1 && bounds.get(k)?._tag === "eq") {
|
|
456
|
+
P = `${P}${segment(k, bounds.get(k).value)}${DynamoSchema.KEY_DELIMITER}`;
|
|
457
|
+
k++;
|
|
458
|
+
}
|
|
459
|
+
let t = k;
|
|
460
|
+
while (t < n && !bounds.has(t))
|
|
461
|
+
t++;
|
|
462
|
+
const tupled = t < n && t > k;
|
|
463
|
+
const tupleFields = sk.slice(k, t);
|
|
464
|
+
const fingerprint = {
|
|
465
|
+
index: indexDef.index ?? "primary",
|
|
466
|
+
entityType: entityLike.entityType,
|
|
467
|
+
pk: args.pkValue,
|
|
468
|
+
prefix: prefix0,
|
|
469
|
+
bounds: [...bounds.entries()]
|
|
470
|
+
.sort(([a], [b]) => a - b)
|
|
471
|
+
.map(([i, bound]) => bound._tag === "eq"
|
|
472
|
+
? [sk[i], "eq", segment(i, bound.value)]
|
|
473
|
+
: [
|
|
474
|
+
sk[i],
|
|
475
|
+
bound.lower === undefined ? null : Object.keys(bound.lower)[0],
|
|
476
|
+
bound.lower === undefined ? null : segment(i, Object.values(bound.lower)[0]),
|
|
477
|
+
bound.upper === undefined ? null : Object.keys(bound.upper)[0],
|
|
478
|
+
bound.upper === undefined ? null : segment(i, Object.values(bound.upper)[0]),
|
|
479
|
+
]),
|
|
480
|
+
};
|
|
481
|
+
const inner = tupled ? keyCondition("", t) : keyCondition(prefix0, pinnedCount);
|
|
482
|
+
const innerRowCheck = rowCheckFor(inner.checked);
|
|
483
|
+
// No value satisfies some bound (`{ gt: 3, lte: 3 }`): nothing to read,
|
|
484
|
+
// whatever the prefix it is composed under.
|
|
485
|
+
const empty = [...bounds.keys()].some((i) => composeBound(prefix0, i) === undefined);
|
|
486
|
+
const space = tupled
|
|
487
|
+
? {
|
|
488
|
+
_tag: "string",
|
|
489
|
+
prefix: P,
|
|
490
|
+
labels: tupleFields.map((_, j) => label(k + j)),
|
|
491
|
+
next: label(t),
|
|
492
|
+
fields: tupleFields,
|
|
493
|
+
}
|
|
494
|
+
: { _tag: "none" };
|
|
495
|
+
// Any tuple composite parses soundly from the key text: escaped values
|
|
496
|
+
// hold no delimiter (§7.3). History rows don't parse as tuples.
|
|
497
|
+
const ineligible = tupled && indexDef.index === undefined && entityLike._liveRows?.() !== undefined
|
|
498
|
+
? "history rows on the primary index"
|
|
499
|
+
: undefined;
|
|
500
|
+
return Query.plan(args.query, {
|
|
501
|
+
planner: compositePlanner({
|
|
502
|
+
entityType: entityLike.entityType,
|
|
503
|
+
index: indexDef.index ?? "primary",
|
|
504
|
+
pkField: indexDef.pk.field,
|
|
505
|
+
pkValue: args.pkValue,
|
|
506
|
+
skField: indexDef.sk.field,
|
|
507
|
+
fingerprint,
|
|
508
|
+
space,
|
|
509
|
+
inner: {
|
|
510
|
+
_tag: "single",
|
|
511
|
+
conditionFor: (tuple) => {
|
|
512
|
+
const { condition } = tupled
|
|
513
|
+
? keyCondition(tuple ?? P, t)
|
|
514
|
+
: keyCondition(prefix0, pinnedCount);
|
|
515
|
+
// An empty plan reads nothing, so it never asks for its range.
|
|
516
|
+
if (condition === undefined)
|
|
517
|
+
throw new Error("[internal] An empty plan has no range.");
|
|
518
|
+
return keyRangeOf(condition, innerRowCheck === undefined);
|
|
519
|
+
},
|
|
520
|
+
rowCheck: innerRowCheck,
|
|
521
|
+
empty,
|
|
522
|
+
},
|
|
523
|
+
filterPlan: {
|
|
524
|
+
prefix: P,
|
|
525
|
+
rowCheck: rowCheckFor([...bounds.keys()].filter((i) => i >= k)),
|
|
526
|
+
},
|
|
527
|
+
ineligible,
|
|
528
|
+
}),
|
|
529
|
+
maxSkips: args.options?.maxSkips,
|
|
530
|
+
});
|
|
531
|
+
};
|
|
532
|
+
/** What a Z-order dimension bound may name: a composite's operators, and `in` (literals). */
|
|
533
|
+
const DIMENSION_OPERATORS = new Set([...BOUND_OPERATORS, "in"]);
|
|
534
|
+
/** The most operands DynamoDB's `IN` takes. */
|
|
535
|
+
const MAX_IN_OPERANDS = 100;
|
|
536
|
+
/** How many Z-order range plans an accessor keeps (§5.4). */
|
|
537
|
+
const ZORDER_PLAN_CACHE = 64;
|
|
538
|
+
/**
|
|
539
|
+
* An accessor's LRU of Z-order range plans. A plan is a pure function of the
|
|
540
|
+
* layout (the accessor's), the box and the cap, so a miss only recomputes —
|
|
541
|
+
* no cursor depends on what is cached.
|
|
542
|
+
*/
|
|
543
|
+
const makeZOrderPlans = (layout) => {
|
|
544
|
+
const cache = new Map();
|
|
545
|
+
return (box, maxRanges, refineFactor) => {
|
|
546
|
+
const key = canonicalJson({ box, maxRanges, refineFactor });
|
|
547
|
+
const hit = cache.get(key);
|
|
548
|
+
if (hit !== undefined) {
|
|
549
|
+
cache.delete(key);
|
|
550
|
+
cache.set(key, hit);
|
|
551
|
+
return hit;
|
|
552
|
+
}
|
|
553
|
+
const planned = ZOrderCurve.plan(layout, box, maxRanges, refineFactor);
|
|
554
|
+
cache.set(key, planned);
|
|
555
|
+
if (cache.size > ZORDER_PLAN_CACHE)
|
|
556
|
+
cache.delete(cache.keys().next().value);
|
|
557
|
+
return planned;
|
|
558
|
+
};
|
|
559
|
+
};
|
|
560
|
+
/** A plain object of bound operators (not a value: a `Date` and a `DateTime` are values). */
|
|
561
|
+
const isBoundShape = (u) => typeof u === "object" &&
|
|
562
|
+
u !== null &&
|
|
563
|
+
!Array.isArray(u) &&
|
|
564
|
+
!(u instanceof Date) &&
|
|
565
|
+
!DateTime.isDateTime(u);
|
|
566
|
+
/** The ends of a range bound (`eq` as both, inclusive). */
|
|
567
|
+
const boundEnds = (bound) => {
|
|
568
|
+
if ("eq" in bound && bound.eq !== undefined) {
|
|
569
|
+
return { lower: { value: bound.eq, strict: false }, upper: { value: bound.eq, strict: false } };
|
|
570
|
+
}
|
|
571
|
+
const between = bound.between;
|
|
572
|
+
if (between !== undefined) {
|
|
573
|
+
return {
|
|
574
|
+
lower: { value: between[0], strict: false },
|
|
575
|
+
upper: { value: between[1], strict: false },
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
return {
|
|
579
|
+
lower: bound.gt !== undefined
|
|
580
|
+
? { value: bound.gt, strict: true }
|
|
581
|
+
: bound.gte !== undefined
|
|
582
|
+
? { value: bound.gte, strict: false }
|
|
583
|
+
: undefined,
|
|
584
|
+
upper: bound.lt !== undefined
|
|
585
|
+
? { value: bound.lt, strict: true }
|
|
586
|
+
: bound.lte !== undefined
|
|
587
|
+
? { value: bound.lte, strict: false }
|
|
588
|
+
: undefined,
|
|
589
|
+
};
|
|
590
|
+
};
|
|
591
|
+
/** Numbers and bigints in numeric order (`bigint` against `bigint` exactly). */
|
|
592
|
+
const compareNumeric = (a, b) => {
|
|
593
|
+
if (typeof a === "bigint" && typeof b === "bigint")
|
|
594
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
595
|
+
const x = Number(a);
|
|
596
|
+
const y = Number(b);
|
|
597
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
598
|
+
};
|
|
599
|
+
/** Does `value` lie between the ends, as `compare` orders them? */
|
|
600
|
+
const withinEnds = (value, lower, upper, compare) => {
|
|
601
|
+
if (lower !== undefined) {
|
|
602
|
+
const order = compare(value, lower.value);
|
|
603
|
+
if (Number.isNaN(order) || order < 0 || (order === 0 && lower.strict))
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
if (upper !== undefined) {
|
|
607
|
+
const order = compare(value, upper.value);
|
|
608
|
+
if (Number.isNaN(order) || order > 0 || (order === 0 && upper.strict))
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
return true;
|
|
612
|
+
};
|
|
613
|
+
/**
|
|
614
|
+
* Plan `.within(bounds)` on a Z-order accessor (design §3.3, §5, §7.5, §11):
|
|
615
|
+
*
|
|
616
|
+
* - each dimension bound is put into key form through the accessor's encoder
|
|
617
|
+
* and quantized into the box (an inverted bound is the empty box: nothing
|
|
618
|
+
* is sent);
|
|
619
|
+
* - the given leading prefix composites are the key prefix `P`; from the first
|
|
620
|
+
* one left out, the prefix composites are a binary skip tuple, any given
|
|
621
|
+
* later one checked as each probed key is parsed;
|
|
622
|
+
* - the exact bounds are a `FilterExpression` on the stored attributes where
|
|
623
|
+
* DynamoDB's comparison agrees with the dimension's order, and a row check
|
|
624
|
+
* where it doesn't (numbers stored as strings, zoned dates, a `literals`
|
|
625
|
+
* selection too large for `IN`) and for hashed prefix values (collisions).
|
|
626
|
+
*
|
|
627
|
+
* Throws `[EDD-9093]` for misuse from untyped code, `[EDD-9050]` for a value
|
|
628
|
+
* that doesn't reach key form and `[EDD-9092]` for one no level holds — at
|
|
629
|
+
* `.within()`, so every terminal (and `explain`) has a valid box. `{}` plans
|
|
630
|
+
* nothing: the query is returned as it is.
|
|
631
|
+
*/
|
|
632
|
+
const planZOrderWithin = (args) => {
|
|
633
|
+
const { entityLike, accessor, indexDef, compositeKeyForm } = args;
|
|
634
|
+
const refuse = (why) => {
|
|
635
|
+
throw new Error(`[EDD-9093] .within() on index "${accessor}" of entity "${entityLike.entityType}": ${why}`);
|
|
636
|
+
};
|
|
637
|
+
if (!KeyComposer.isZOrderHalf(indexDef.sk) || indexDef.sk.zorderHalf.layout === undefined) {
|
|
638
|
+
throw new Error("[internal] A Z-order .within() reached a sort key that isn't bound.");
|
|
639
|
+
}
|
|
640
|
+
const { prefix } = indexDef.sk.zorderHalf;
|
|
641
|
+
const layout = indexDef.sk.zorderHalf.layout;
|
|
642
|
+
if (!isBoundShape(args.bounds)) {
|
|
643
|
+
return refuse("pass an object of bounds keyed by dimension or prefix composite.");
|
|
644
|
+
}
|
|
645
|
+
const dimensions = layout.dimensions;
|
|
646
|
+
const named = [...prefix.map((part) => part.field), ...dimensions.map((dim) => dim.field)];
|
|
647
|
+
const fields = getSchemaFields(args.encoderSource);
|
|
648
|
+
/** `value` in the field's key form: `[EDD-9050]` from the encoder, `[EDD-9093]` for the wrong type. */
|
|
649
|
+
const keyForm = (field, value) => {
|
|
650
|
+
const converted = compositeKeyForm(field, value);
|
|
651
|
+
const schema = fields?.[field];
|
|
652
|
+
const expected = schema === undefined ? undefined : keyFormValueKind(schema);
|
|
653
|
+
if (expected !== undefined && typeof converted !== expected) {
|
|
654
|
+
return refuse(`the bound on "${field}" is ${JSON.stringify(String(value))} (${typeof value}), but ` +
|
|
655
|
+
`the field's key form is a ${expected}.`);
|
|
656
|
+
}
|
|
657
|
+
return converted;
|
|
658
|
+
};
|
|
659
|
+
// -- validate and normalise (EDD-9093) --
|
|
660
|
+
const prefixValues = new Map();
|
|
661
|
+
const bounded = new Map();
|
|
662
|
+
for (const [field, raw] of Object.entries(args.bounds)) {
|
|
663
|
+
if (raw === undefined)
|
|
664
|
+
continue;
|
|
665
|
+
const shape = isBoundShape(raw) ? raw : undefined;
|
|
666
|
+
const operators = shape === undefined ? [] : Object.keys(shape).filter((key) => shape[key] !== undefined);
|
|
667
|
+
if (prefix.some((part) => part.field === field)) {
|
|
668
|
+
if (operators.some((key) => DIMENSION_OPERATORS.has(key))) {
|
|
669
|
+
return refuse(`"${field}" is a prefix composite: it takes one exact value, not a range bound.`);
|
|
670
|
+
}
|
|
671
|
+
prefixValues.set(field, keyForm(field, raw));
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
const dimension = dimensions.find((dim) => dim.field === field);
|
|
675
|
+
if (dimension === undefined) {
|
|
676
|
+
return refuse(indexDef.pk.composite.includes(field)
|
|
677
|
+
? `"${field}" is a partition key composite: give it to the accessor.`
|
|
678
|
+
: `"${field}" is not a dimension or prefix composite of the index. Bound one of: ` +
|
|
679
|
+
`${named.join(", ")}.`);
|
|
680
|
+
}
|
|
681
|
+
const between = shape?.between;
|
|
682
|
+
const members = shape?.in;
|
|
683
|
+
const wellFormed = shape !== undefined &&
|
|
684
|
+
operators.length > 0 &&
|
|
685
|
+
operators.every((key) => DIMENSION_OPERATORS.has(key)) &&
|
|
686
|
+
(["eq", "between", "in"].some((key) => operators.includes(key))
|
|
687
|
+
? operators.length === 1
|
|
688
|
+
: true) &&
|
|
689
|
+
!(operators.includes("gt") && operators.includes("gte")) &&
|
|
690
|
+
!(operators.includes("lt") && operators.includes("lte")) &&
|
|
691
|
+
(between === undefined || (Array.isArray(between) && between.length === 2)) &&
|
|
692
|
+
(members === undefined || Array.isArray(members));
|
|
693
|
+
if (!wellFormed) {
|
|
694
|
+
return refuse(`the bound on "${field}" is not one of { eq }, { between: [low, high] }, { in: [...] } ` +
|
|
695
|
+
`(a literals dimension), or { gt | gte } and / or { lt | lte }.`);
|
|
696
|
+
}
|
|
697
|
+
if (members !== undefined && dimension.spec.kind !== "literals") {
|
|
698
|
+
return refuse(`"in" bounds only a literals dimension, and "${field}" is ${dimension.spec.kind}.`);
|
|
699
|
+
}
|
|
700
|
+
const converted = {};
|
|
701
|
+
for (const key of operators) {
|
|
702
|
+
converted[key] =
|
|
703
|
+
key === "between" || key === "in"
|
|
704
|
+
? shape[key].map((value) => keyForm(field, value))
|
|
705
|
+
: keyForm(field, shape[key]);
|
|
706
|
+
}
|
|
707
|
+
bounded.set(field, { raw: shape, keyForm: converted });
|
|
708
|
+
}
|
|
709
|
+
if (prefixValues.size === 0 && bounded.size === 0)
|
|
710
|
+
return args.query;
|
|
711
|
+
/** A bound's epoch milliseconds: from the domain value when it has them, else its key form. */
|
|
712
|
+
const epochMs = (dimension, raw, key) => DateTime.isDateTime(raw)
|
|
713
|
+
? DateTime.toEpochMillis(raw)
|
|
714
|
+
: raw instanceof Date
|
|
715
|
+
? raw.getTime()
|
|
716
|
+
: ZOrderCurve.epochMsOf(dimension.storage, key);
|
|
717
|
+
/** Both ends of a bound: domain values and key forms side by side. */
|
|
718
|
+
const endsOf = (field) => {
|
|
719
|
+
const bound = bounded.get(field);
|
|
720
|
+
return { raw: boundEnds(bound.raw), key: boundEnds(bound.keyForm) };
|
|
721
|
+
};
|
|
722
|
+
/**
|
|
723
|
+
* An `Epoch` dimension's ends in its stored unit, exactly (§11): a stored
|
|
724
|
+
* second `s` stands for `s × 1000` ms, so each end is converted from the
|
|
725
|
+
* bound's epoch milliseconds — never from its key form, which floors.
|
|
726
|
+
*/
|
|
727
|
+
const storedEnds = (dimension, seconds) => {
|
|
728
|
+
const { raw, key } = endsOf(dimension.field);
|
|
729
|
+
const convert = (end) => {
|
|
730
|
+
const r = raw[end];
|
|
731
|
+
const k = key[end];
|
|
732
|
+
if (r === undefined || k === undefined)
|
|
733
|
+
return undefined;
|
|
734
|
+
const ms = epochMs(dimension, r.value, k.value);
|
|
735
|
+
if (!seconds)
|
|
736
|
+
return { value: ms, strict: r.strict };
|
|
737
|
+
const u = ms / 1000;
|
|
738
|
+
return end === "lower"
|
|
739
|
+
? { value: r.strict ? Math.floor(u) : Math.ceil(u), strict: r.strict }
|
|
740
|
+
: { value: r.strict ? Math.ceil(u) : Math.floor(u), strict: r.strict };
|
|
741
|
+
};
|
|
742
|
+
return { lower: convert("lower"), upper: convert("upper") };
|
|
743
|
+
};
|
|
744
|
+
/**
|
|
745
|
+
* The bound a dimension's levels are quantized from: its key form, except
|
|
746
|
+
* on epoch seconds, where the key form of a sub-second end is floored. There
|
|
747
|
+
* the whole stored seconds it holds decide the levels, and whether there are
|
|
748
|
+
* any: `{ gte: T, lt: T + 500 ms }` holds the second `T`, `{ gt: T, lt: T + 1 s }`
|
|
749
|
+
* none — inverted only once converted.
|
|
750
|
+
*/
|
|
751
|
+
const quantizable = (dimension) => {
|
|
752
|
+
const keyForm = bounded.get(dimension.field).keyForm;
|
|
753
|
+
const storage = dimension.storage;
|
|
754
|
+
if (storage._tag !== "Epoch" || storage.unit !== "epochSeconds")
|
|
755
|
+
return keyForm;
|
|
756
|
+
const { lower, upper } = storedEnds(dimension, true);
|
|
757
|
+
return {
|
|
758
|
+
...(lower === undefined ? {} : { gte: lower.strict ? lower.value + 1 : lower.value }),
|
|
759
|
+
...(upper === undefined ? {} : { lte: upper.strict ? upper.value - 1 : upper.value }),
|
|
760
|
+
};
|
|
761
|
+
};
|
|
762
|
+
// -- the box (§5.1) and each literals dimension's exact selection --
|
|
763
|
+
const literalSelection = (dimension, bound) => {
|
|
764
|
+
const index = (value) => Number(dimension.quantize(value));
|
|
765
|
+
if (bound.eq !== undefined)
|
|
766
|
+
return [index(bound.eq)];
|
|
767
|
+
if (bound.in !== undefined) {
|
|
768
|
+
return [...new Set(bound.in.map(index))].sort((a, b) => a - b);
|
|
769
|
+
}
|
|
770
|
+
const between = bound.between;
|
|
771
|
+
const lo = between !== undefined
|
|
772
|
+
? index(between[0])
|
|
773
|
+
: bound.gt !== undefined
|
|
774
|
+
? index(bound.gt) + 1
|
|
775
|
+
: bound.gte !== undefined
|
|
776
|
+
? index(bound.gte)
|
|
777
|
+
: 0;
|
|
778
|
+
const hi = between !== undefined
|
|
779
|
+
? index(between[1])
|
|
780
|
+
: bound.lt !== undefined
|
|
781
|
+
? index(bound.lt) - 1
|
|
782
|
+
: bound.lte !== undefined
|
|
783
|
+
? index(bound.lte)
|
|
784
|
+
: Number(dimension.levels) - 1;
|
|
785
|
+
return Array.from({ length: Math.max(0, hi - lo + 1) }, (_, i) => lo + i);
|
|
786
|
+
};
|
|
787
|
+
const { box, selections } = (() => {
|
|
788
|
+
try {
|
|
789
|
+
const quantized = ZOrderCurve.quantizeBounds(layout, Object.fromEntries(dimensions
|
|
790
|
+
.filter((dim) => bounded.has(dim.field))
|
|
791
|
+
.map((dim) => [dim.field, quantizable(dim)])));
|
|
792
|
+
const selected = new Map(dimensions
|
|
793
|
+
.filter((dim) => dim.spec.kind === "literals" && bounded.has(dim.field))
|
|
794
|
+
.map((dim) => [dim.field, literalSelection(dim, bounded.get(dim.field).keyForm)]));
|
|
795
|
+
const none = [...selected.values()].some((members) => members.length === 0);
|
|
796
|
+
return { box: none ? "empty" : quantized, selections: selected };
|
|
797
|
+
}
|
|
798
|
+
catch (error) {
|
|
799
|
+
// A value that reaches key form but no level (§3.3): thrown, like EDD-9050.
|
|
800
|
+
if (ZOrderCurve.isZOrderValueError(error))
|
|
801
|
+
throw new Error(error.message);
|
|
802
|
+
throw error;
|
|
803
|
+
}
|
|
804
|
+
})();
|
|
805
|
+
// -- exact bounds (§11): a FilterExpression where DynamoDB's order agrees, else row checks --
|
|
806
|
+
const path = (field) => ({ _tag: "path", segments: [field] });
|
|
807
|
+
const compare = (field, op, value) => ({
|
|
808
|
+
[ExprTag]: ExprTag,
|
|
809
|
+
_tag: op,
|
|
810
|
+
left: path(field),
|
|
811
|
+
right: { _tag: "value", value },
|
|
812
|
+
});
|
|
813
|
+
const membership = (field, values) => ({
|
|
814
|
+
[ExprTag]: ExprTag,
|
|
815
|
+
_tag: "in",
|
|
816
|
+
operand: path(field),
|
|
817
|
+
values: values.map((value) => ({ _tag: "value", value })),
|
|
818
|
+
});
|
|
819
|
+
const rangeFilter = (field, lower, upper) => lower !== undefined &&
|
|
820
|
+
upper !== undefined &&
|
|
821
|
+
!lower.strict &&
|
|
822
|
+
!upper.strict &&
|
|
823
|
+
lower.value === upper.value
|
|
824
|
+
? [compare(field, "eq", lower.value)]
|
|
825
|
+
: [
|
|
826
|
+
...(lower === undefined
|
|
827
|
+
? []
|
|
828
|
+
: [compare(field, lower.strict ? "gt" : "gte", lower.value)]),
|
|
829
|
+
...(upper === undefined
|
|
830
|
+
? []
|
|
831
|
+
: [compare(field, upper.strict ? "lt" : "lte", upper.value)]),
|
|
832
|
+
];
|
|
833
|
+
const filters = [];
|
|
834
|
+
const dimensionChecks = [];
|
|
835
|
+
for (const dimension of dimensions) {
|
|
836
|
+
const field = dimension.field;
|
|
837
|
+
if (!bounded.has(field))
|
|
838
|
+
continue;
|
|
839
|
+
const storage = dimension.storage;
|
|
840
|
+
if (storage._tag === "Literal") {
|
|
841
|
+
const values = dimension.spec.values;
|
|
842
|
+
const selected = selections.get(field) ?? [];
|
|
843
|
+
if (selected.length === values.length)
|
|
844
|
+
continue;
|
|
845
|
+
const chosen = new Set(selected.map((i) => values[i]));
|
|
846
|
+
const complement = values.filter((value) => !chosen.has(value));
|
|
847
|
+
if (chosen.size <= MAX_IN_OPERANDS) {
|
|
848
|
+
filters.push(membership(field, [...chosen]));
|
|
849
|
+
}
|
|
850
|
+
else if (complement.length <= MAX_IN_OPERANDS) {
|
|
851
|
+
// Exact: every item in the index carries the attribute (the half needs it).
|
|
852
|
+
filters.push({
|
|
853
|
+
[ExprTag]: ExprTag,
|
|
854
|
+
_tag: "not",
|
|
855
|
+
expr: membership(field, complement),
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
else {
|
|
859
|
+
const stored = storedKeyForm(args, field);
|
|
860
|
+
dimensionChecks.push({
|
|
861
|
+
field,
|
|
862
|
+
attribute: stored.attribute,
|
|
863
|
+
accepts: (row) => chosen.has(stored.read(row)),
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
const { raw, key } = endsOf(field);
|
|
869
|
+
switch (storage._tag) {
|
|
870
|
+
case "Number":
|
|
871
|
+
case "UtcIsoString":
|
|
872
|
+
case "RawString":
|
|
873
|
+
filters.push(...rangeFilter(field, key.lower, key.upper));
|
|
874
|
+
break;
|
|
875
|
+
case "Epoch": {
|
|
876
|
+
const stored = storedEnds(dimension, storage.unit === "epochSeconds");
|
|
877
|
+
filters.push(...rangeFilter(field, stored.lower, stored.upper));
|
|
878
|
+
break;
|
|
879
|
+
}
|
|
880
|
+
case "NumericString": {
|
|
881
|
+
// S order isn't numeric order: compared on the key form, per row.
|
|
882
|
+
const stored = storedKeyForm(args, field);
|
|
883
|
+
dimensionChecks.push({
|
|
884
|
+
field,
|
|
885
|
+
attribute: stored.attribute,
|
|
886
|
+
accepts: (row) => {
|
|
887
|
+
const value = stored.read(row);
|
|
888
|
+
return value !== undefined && withinEnds(value, key.lower, key.upper, compareNumeric);
|
|
889
|
+
},
|
|
890
|
+
});
|
|
891
|
+
break;
|
|
892
|
+
}
|
|
893
|
+
case "ZonedIsoString": {
|
|
894
|
+
// ISO strings with offsets don't sort chronologically: compared as epoch ms, per row.
|
|
895
|
+
const stored = storedKeyForm(args, field);
|
|
896
|
+
const toMs = (end, k) => end === undefined || k === undefined
|
|
897
|
+
? undefined
|
|
898
|
+
: { value: epochMs(dimension, end.value, k.value), strict: end.strict };
|
|
899
|
+
const lower = toMs(raw.lower, key.lower);
|
|
900
|
+
const upper = toMs(raw.upper, key.upper);
|
|
901
|
+
dimensionChecks.push({
|
|
902
|
+
field,
|
|
903
|
+
attribute: stored.attribute,
|
|
904
|
+
accepts: (row) => {
|
|
905
|
+
const ms = ZOrderCurve.epochMsOf(storage, stored.read(row));
|
|
906
|
+
return Number.isFinite(ms) && withinEnds(ms, lower, upper, compareNumeric);
|
|
907
|
+
},
|
|
908
|
+
});
|
|
909
|
+
break;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
// -- the prefix (§4.3, §7.5) --
|
|
914
|
+
const casing = KeyComposer.effectiveCasing(entityLike._schema, indexDef);
|
|
915
|
+
const textOf = (value) => valueText(casing, value);
|
|
916
|
+
const segmentOf = (part) => ZOrderCurve.prefixSegment(textOf(prefixValues.get(part.field)), part.hash);
|
|
917
|
+
/** A given prefix composite, checked on the cased value — never a hash, which can collide. */
|
|
918
|
+
const prefixCheck = (part) => {
|
|
919
|
+
const stored = storedKeyForm(args, part.field);
|
|
920
|
+
const wanted = textOf(prefixValues.get(part.field));
|
|
921
|
+
return {
|
|
922
|
+
field: part.field,
|
|
923
|
+
attribute: stored.attribute,
|
|
924
|
+
accepts: (row) => {
|
|
925
|
+
const value = stored.read(row);
|
|
926
|
+
return value !== undefined && textOf(value) === wanted;
|
|
927
|
+
},
|
|
928
|
+
};
|
|
929
|
+
};
|
|
930
|
+
const firstMissing = prefix.findIndex((part) => !prefixValues.has(part.field));
|
|
931
|
+
const leading = firstMissing === -1 ? prefix : prefix.slice(0, firstMissing);
|
|
932
|
+
const later = firstMissing === -1 ? [] : prefix.slice(firstMissing);
|
|
933
|
+
const hashed = prefix.filter((part) => part.hash !== undefined && prefixValues.has(part.field));
|
|
934
|
+
const parsedExactly = later.filter((part) => part.hash === undefined && prefixValues.has(part.field));
|
|
935
|
+
const renameExpr = entityNaming(entityLike._resolveDbName).renameExpr ?? ((expr) => expr);
|
|
936
|
+
const filter = filters.length === 0
|
|
937
|
+
? undefined
|
|
938
|
+
: renameExpr(filters.length === 1
|
|
939
|
+
? filters[0]
|
|
940
|
+
: { [ExprTag]: ExprTag, _tag: "and", exprs: filters });
|
|
941
|
+
return Query.plan(args.query, {
|
|
942
|
+
planner: zorderPlanner({
|
|
943
|
+
entityType: entityLike.entityType,
|
|
944
|
+
index: indexDef.index ?? "primary",
|
|
945
|
+
pkField: indexDef.pk.field,
|
|
946
|
+
pkValue: args.pkValue,
|
|
947
|
+
skField: indexDef.sk.field,
|
|
948
|
+
fingerprint: {
|
|
949
|
+
index: indexDef.index ?? "primary",
|
|
950
|
+
entityType: entityLike.entityType,
|
|
951
|
+
pk: args.pkValue,
|
|
952
|
+
prefix: prefix.map((part) => [
|
|
953
|
+
part.field,
|
|
954
|
+
part.hash ?? null,
|
|
955
|
+
prefixValues.has(part.field) ? textOf(prefixValues.get(part.field)) : null,
|
|
956
|
+
]),
|
|
957
|
+
box,
|
|
958
|
+
layout: dimensions.map((dim) => ({ spec: dim.spec, storage: dim.storage, bits: dim.bits })),
|
|
959
|
+
},
|
|
960
|
+
layout,
|
|
961
|
+
box,
|
|
962
|
+
bounded: bounded.size > 0,
|
|
963
|
+
prefix: ZOrderCurve.concatBytes(leading.map((part) => segmentOf(part))),
|
|
964
|
+
tuple: later.map((part) => ({
|
|
965
|
+
field: part.field,
|
|
966
|
+
hash: part.hash,
|
|
967
|
+
given: prefixValues.has(part.field) ? segmentOf(part) : undefined,
|
|
968
|
+
})),
|
|
969
|
+
filter,
|
|
970
|
+
rowCheck: combineRowChecks([...dimensionChecks, ...hashed.map(prefixCheck)]),
|
|
971
|
+
filterRowCheck: combineRowChecks([
|
|
972
|
+
...dimensionChecks,
|
|
973
|
+
...hashed.map(prefixCheck),
|
|
974
|
+
...parsedExactly.map(prefixCheck),
|
|
975
|
+
]),
|
|
976
|
+
ranges: (maxRanges, refineFactor) => box === "empty" ? { ranges: [], exact: true } : args.plans(box, maxRanges, refineFactor),
|
|
977
|
+
}),
|
|
978
|
+
maxSkips: args.options?.maxSkips,
|
|
979
|
+
});
|
|
980
|
+
};
|
|
39
981
|
/**
|
|
40
982
|
* The composite key form for an arbitrary entity-like value — the same rule
|
|
41
983
|
* `Entity` applies to its own keys (`internal/CompositeCodec.ts`).
|
|
@@ -330,8 +1272,29 @@ const makeFromConfig = (config) => Effect.gen(function* () {
|
|
|
330
1272
|
`the ENCODED value, but ${JSON.stringify(String(value))} encodes under neither ` +
|
|
331
1273
|
`encode nor decode->encode. Supply a value of the attribute's own type.`);
|
|
332
1274
|
});
|
|
1275
|
+
// A Z-order sort key (#171) is a binary address: its prefix composites
|
|
1276
|
+
// and dimensions are bounded with `.within()`, never pinned by the
|
|
1277
|
+
// accessor, which takes the partition key composites only.
|
|
1278
|
+
const zorder = KeyComposer.isZOrderHalf(indexDef.sk);
|
|
1279
|
+
const accessorDef = zorder
|
|
1280
|
+
? { ...indexDef, sk: { field: indexDef.sk.field, composite: [] } }
|
|
1281
|
+
: indexDef;
|
|
1282
|
+
// Its range plans, cached across the accessor's queries (§5.4).
|
|
1283
|
+
const zorderLayout = KeyComposer.isZOrderHalf(indexDef.sk)
|
|
1284
|
+
? indexDef.sk.zorderHalf.layout
|
|
1285
|
+
: undefined;
|
|
1286
|
+
const zorderPlans = zorderLayout === undefined ? undefined : makeZOrderPlans(zorderLayout);
|
|
333
1287
|
return (rawComposites) => {
|
|
334
|
-
|
|
1288
|
+
if (zorder) {
|
|
1289
|
+
const misplaced = indexDef.sk.composite.filter((attr) => rawComposites[attr] !== undefined);
|
|
1290
|
+
if (misplaced.length > 0) {
|
|
1291
|
+
throw new Error(`[EDD-9093] Index "${_indexName}" of entity "${entityLike.entityType}" has a ` +
|
|
1292
|
+
`Z-order sort key, so ${misplaced.map((attr) => `"${attr}"`).join(", ")} can't ` +
|
|
1293
|
+
`be passed to its accessor: pass the partition key composites ` +
|
|
1294
|
+
`(${indexDef.pk.composite.join(", ")}) and bound the rest with .within().`);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
validateQueryComposites(_indexName, accessorDef, rawComposites);
|
|
335
1298
|
const compositesKeyForm = toCompositeKeyRecord(compositeKeyForm, rawComposites);
|
|
336
1299
|
const pkValue = KeyComposer.composePk(entityLike._schema, entityLike.entityType, indexDef, compositesKeyForm);
|
|
337
1300
|
const query = Query.make({
|
|
@@ -359,35 +1322,49 @@ const makeFromConfig = (config) => Effect.gen(function* () {
|
|
|
359
1322
|
label: `${entityLike.entityType}.${_indexName}`,
|
|
360
1323
|
},
|
|
361
1324
|
});
|
|
362
|
-
// Apply SK
|
|
363
|
-
// `
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
1325
|
+
// Apply the SK condition from the provided composites
|
|
1326
|
+
// (`composeSortKeyCondition`): a partial prefix is a `begins_with`
|
|
1327
|
+
// terminated on a segment boundary, or it matches sibling values that
|
|
1328
|
+
// merely start with the supplied one (`status_done` also matching
|
|
1329
|
+
// `status_done_archived`, issue #115); a complete key is an exact `=`,
|
|
1330
|
+
// or `seq: 3` also matches 3.14 and `name: "a"` also matches "ab".
|
|
1331
|
+
const hasSkComposites = !zorder &&
|
|
1332
|
+
indexDef.sk.composite.some((attr) => compositesKeyForm[attr] !== undefined);
|
|
1333
|
+
const pinnedQuery = hasSkComposites
|
|
1334
|
+
? Query.where(query, KeyComposer.composeSortKeyCondition(entityLike._schema, entityLike.entityType, 1, indexDef, compositesKeyForm))
|
|
372
1335
|
: query;
|
|
1336
|
+
// Rows 1.36 wrote whose key text changed sit under the key 1.36
|
|
1337
|
+
// composed for these pins until they move (#171): the query reads it
|
|
1338
|
+
// too — the partition key, and the pins' `eq` / `begins_with`. A
|
|
1339
|
+
// Z-order index is new in 1.37, so it has no 1.36 rows.
|
|
1340
|
+
const pk136 = !zorder && KeyText136.enabled(entityLike._schema)
|
|
1341
|
+
? KeyText136.composePk(entityLike._schema, entityLike.entityType, indexDef, compositesKeyForm)
|
|
1342
|
+
: undefined;
|
|
1343
|
+
const finalQuery = pk136 === undefined
|
|
1344
|
+
? pinnedQuery
|
|
1345
|
+
: Query.alsoRead136(pinnedQuery, {
|
|
1346
|
+
pkValue: pk136,
|
|
1347
|
+
rows: entityLike._indexRows136?.(indexDef),
|
|
1348
|
+
condition: hasSkComposites
|
|
1349
|
+
? KeyText136.composeSortKeyCondition(entityLike._schema, entityLike.entityType, 1, indexDef, compositesKeyForm)
|
|
1350
|
+
: undefined,
|
|
1351
|
+
});
|
|
373
1352
|
const pathBuilder = createPathBuilder();
|
|
374
1353
|
const conditionOps = createConditionOps();
|
|
375
1354
|
// composeSkCondition — compose the user's `.where()` operand into a full
|
|
376
|
-
// sort key value the same way `put` composes the stored one
|
|
377
|
-
//
|
|
378
|
-
// A stored SK is `$schema#v1#entity#<name>_<cased value>#...`; comparing
|
|
379
|
-
// a raw operand against that is meaningless (issue #101 — `gte` matched
|
|
380
|
-
// the whole partition because `"1-009" < "ballkey_…"`, while
|
|
381
|
-
// `begins_with` / `between` matched nothing). So the operand is placed
|
|
382
|
-
// in the *position* of the SK composite it targets and run through the
|
|
383
|
-
// same composer, which applies `serializeValue`, the `<name>_` prefix
|
|
384
|
-
// and the schema casing.
|
|
1355
|
+
// sort key value the same way `put` composes the stored one
|
|
1356
|
+
// (`composeRangeUnder`).
|
|
385
1357
|
const skComposites = indexDef.sk.composite;
|
|
386
1358
|
const composeSkCondition = (condition, field) => {
|
|
1359
|
+
if (zorder) {
|
|
1360
|
+
throw new Error(`[EDD-9093] .where() on index "${_indexName}" of entity ` +
|
|
1361
|
+
`"${entityLike.entityType}": a Z-order sort key has no text order to compare ` +
|
|
1362
|
+
`against — bound its prefix composites and dimensions with .within().`);
|
|
1363
|
+
}
|
|
387
1364
|
if (skComposites.length === 0) {
|
|
388
|
-
throw new Error(`[EDD-9045] Index "${_indexName}" has no sort key
|
|
1365
|
+
throw new Error(`[EDD-9045] Index "${_indexName}" has no sort key composites, so there is ` +
|
|
389
1366
|
`nothing for .where() to constrain. Remove the .where() call, or add sort ` +
|
|
390
|
-
`key
|
|
1367
|
+
`key composites to the index definition.`);
|
|
391
1368
|
}
|
|
392
1369
|
// How many leading composites the accessor already pinned. Pinning is
|
|
393
1370
|
// a prefix by construction — `validateQueryComposites` rejects a hole
|
|
@@ -447,123 +1424,98 @@ const makeFromConfig = (config) => Effect.gen(function* () {
|
|
|
447
1424
|
.filter((attr) => compositesKeyForm[attr] === undefined);
|
|
448
1425
|
if (missing.length > 0) {
|
|
449
1426
|
throw new Error(`[EDD-9004] Sort key condition on "${targetAttr}" for index "${_indexName}" ` +
|
|
450
|
-
`requires prior
|
|
1427
|
+
`requires prior composites: ${missing.join(", ")}. Sort key composites must ` +
|
|
451
1428
|
`follow prefix ordering — supply them to the accessor before calling .where().`);
|
|
452
1429
|
}
|
|
453
|
-
// Compose
|
|
454
|
-
//
|
|
455
|
-
//
|
|
1430
|
+
// Compose under the pinned prefix — the key text of the composites
|
|
1431
|
+
// the accessor pinned, ending on a segment boundary (the target
|
|
1432
|
+
// follows them, so the shared delimiter rule always applies).
|
|
456
1433
|
const pinnedKeyForm = {};
|
|
457
1434
|
for (let i = 0; i < targetIndex; i++) {
|
|
458
1435
|
pinnedKeyForm[skComposites[i]] = compositesKeyForm[skComposites[i]];
|
|
459
1436
|
}
|
|
460
|
-
|
|
461
|
-
//
|
|
462
|
-
// of one
|
|
463
|
-
|
|
464
|
-
const compose = (value) => KeyComposer.composeSortKeyPrefix(entityLike._schema, entityLike.entityType, 1, indexDef, {
|
|
465
|
-
...pinnedKeyForm,
|
|
466
|
-
[targetAttr]: operand(value),
|
|
467
|
-
});
|
|
468
|
-
const isLastComposite = targetIndex === skComposites.length - 1;
|
|
469
|
-
/**
|
|
470
|
-
* `begins_with` operand matching exactly the keys whose target
|
|
471
|
-
* composite equals `value`. Delegates the delimiter rule (#115) to
|
|
472
|
-
* `composeSortKeyBeginsWith` — one rule, shared with the accessor's
|
|
473
|
-
* own prefix — so it stops on a segment boundary rather than leaking
|
|
474
|
-
* into sibling values (`status_done` vs `status_done_archived`).
|
|
475
|
-
*/
|
|
476
|
-
const subtreeBeginsWith = (value) => KeyComposer.composeSortKeyBeginsWith(entityLike._schema, entityLike.entityType, 1, indexDef, { ...pinnedKeyForm, [targetAttr]: operand(value) });
|
|
477
|
-
/**
|
|
478
|
-
* A bound sorting strictly above every key whose target composite is
|
|
479
|
-
* `value`, and strictly below the first key of any greater value.
|
|
480
|
-
*
|
|
481
|
-
* Unlike `subtreeBeginsWith` the delimiter here is UNCONDITIONAL,
|
|
482
|
-
* including on the last composite: `compose(v)` alone would sort
|
|
483
|
-
* below `compose(v + "0")`, which is a strictly greater value that
|
|
484
|
-
* must stay inside a `gt`. `compose(v)#` sits above `compose(v)` and
|
|
485
|
-
* below every longer value because `#` is lower than every character
|
|
486
|
-
* the composer emits for a value segment.
|
|
487
|
-
*/
|
|
488
|
-
const aboveSubtree = (value) => `${compose(value)}${DynamoSchema.KEY_DELIMITER}${SK_MAX_SENTINEL}`;
|
|
489
|
-
/** Inclusive upper bound covering the whole subtree of `value`. */
|
|
490
|
-
const upper = (value) => isLastComposite ? compose(value) : aboveSubtree(value);
|
|
491
|
-
// `beginsWith` / `between` / `eq` already carry the pinned compositesKeyForm
|
|
492
|
-
// in BOTH operands, so they never escape the pinned prefix.
|
|
493
|
-
if ("beginsWith" in condition)
|
|
494
|
-
return { beginsWith: compose(condition.beginsWith) };
|
|
495
|
-
if ("eq" in condition) {
|
|
496
|
-
return isLastComposite
|
|
497
|
-
? { eq: compose(condition.eq) }
|
|
498
|
-
: { beginsWith: subtreeBeginsWith(condition.eq) };
|
|
499
|
-
}
|
|
500
|
-
if ("between" in condition) {
|
|
501
|
-
return { between: [compose(condition.between[0]), upper(condition.between[1])] };
|
|
502
|
-
}
|
|
503
|
-
// One-sided operators are open on the other side. With no pinned
|
|
504
|
-
// compositesKeyForm that is exactly right — the open end runs to the edge of
|
|
505
|
-
// the partition. But once the accessor has pinned leading compositesKeyForm,
|
|
506
|
-
// DynamoDB's single sort key condition must ALSO stay inside that
|
|
507
|
-
// prefix, so the open end is clamped and the condition becomes a
|
|
508
|
-
// BETWEEN. (`Query.where` replaces the accessor's own `begins_with`,
|
|
509
|
-
// so it cannot do the clamping.)
|
|
1437
|
+
const prefixText = KeyComposer.composeSortKeyBeginsWith(entityLike._schema, entityLike.entityType, 1, indexDef, pinnedKeyForm);
|
|
1438
|
+
// Operands are encoded exactly as the accessor composites (and the
|
|
1439
|
+
// write path) are, so both sides of the comparison come out of one
|
|
1440
|
+
// pipeline.
|
|
510
1441
|
//
|
|
511
|
-
// The predicate is "the accessor pinned
|
|
512
|
-
// Those coincided for every shape the tests
|
|
513
|
-
// claims: an accessor can pin composite[0]
|
|
514
|
-
// index 0 skipped the clamp and dropped
|
|
515
|
-
// raises EDD-9053 above, so the two
|
|
516
|
-
// real predicate keeps them from
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
1442
|
+
// The clamp predicate is "the accessor pinned something", NOT
|
|
1443
|
+
// `targetIndex > 0`. Those coincided for every shape the tests
|
|
1444
|
+
// covered but are different claims: an accessor can pin composite[0]
|
|
1445
|
+
// itself, and then targeting index 0 skipped the clamp and dropped
|
|
1446
|
+
// the pin (#121). That call now raises EDD-9053 above, so the two
|
|
1447
|
+
// are equivalent again — stating the real predicate keeps them from
|
|
1448
|
+
// drifting apart a second time.
|
|
1449
|
+
return composeRangeUnder({
|
|
1450
|
+
casing: KeyComposer.effectiveCasing(entityLike._schema, indexDef),
|
|
1451
|
+
isLast: targetIndex === skComposites.length - 1,
|
|
1452
|
+
}, prefixText, targetAttr, mapBound(condition, (value) => compositeKeyForm(targetAttr, value)), { clampToPrefix: pinnedCount > 0 });
|
|
1453
|
+
};
|
|
1454
|
+
/**
|
|
1455
|
+
* The 1.36 key a `.where()` also reads (#171): an `eq` or
|
|
1456
|
+
* `beginsWith` extends the pins, so the rows it selects under 1.36's
|
|
1457
|
+
* key text are read too. A range bound reads only the current key.
|
|
1458
|
+
*/
|
|
1459
|
+
const where136 = (condition, field) => {
|
|
1460
|
+
if (pk136 === undefined || !("eq" in condition || "beginsWith" in condition)) {
|
|
1461
|
+
return undefined;
|
|
530
1462
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
if ("gt" in condition)
|
|
539
|
-
return { between: [aboveSubtree(condition.gt), pinnedMax] };
|
|
540
|
-
if ("lte" in condition)
|
|
541
|
-
return { between: [pinnedPrefix, upper(condition.lte)] };
|
|
542
|
-
if ("lt" in condition) {
|
|
543
|
-
// On a non-terminal composite no stored key can equal the composed
|
|
544
|
-
// bound (trailing compositesKeyForm always follow), so BETWEEN's inclusive
|
|
545
|
-
// high end is harmless. On the LAST composite the bound IS a
|
|
546
|
-
// storable key, and DynamoDB has neither a half-open BETWEEN nor a
|
|
547
|
-
// FilterExpression that may reference a key attribute — so there is
|
|
548
|
-
// no way to say `begins_with(prefix) AND sk < value` in one key
|
|
549
|
-
// condition. Refuse rather than silently return the boundary item.
|
|
550
|
-
if (!isLastComposite)
|
|
551
|
-
return { between: [pinnedPrefix, compose(condition.lt)] };
|
|
552
|
-
throw new Error(`[EDD-9046] A strict "lt" condition on sort key composite "${targetAttr}" for ` +
|
|
553
|
-
`index "${_indexName}" cannot be expressed once earlier compositesKeyForm ` +
|
|
554
|
-
`(${skComposites.slice(0, targetIndex).join(", ")}) are pinned by the accessor: ` +
|
|
555
|
-
`DynamoDB allows one sort key condition, BETWEEN is inclusive at both ends, and ` +
|
|
556
|
-
`a FilterExpression may not reference a key attribute. Use ` +
|
|
557
|
-
`.where((t, { between }) => between(t.${targetAttr}, low, high)) or "lte" instead.`);
|
|
1463
|
+
const pinnedCount = skComposites.findIndex((attr) => compositesKeyForm[attr] === undefined);
|
|
1464
|
+
const targetAttr = field !== undefined && skComposites.includes(field)
|
|
1465
|
+
? field
|
|
1466
|
+
: skComposites[pinnedCount === -1 ? skComposites.length - 1 : pinnedCount];
|
|
1467
|
+
const pinned = {};
|
|
1468
|
+
for (const attr of skComposites.slice(0, skComposites.indexOf(targetAttr))) {
|
|
1469
|
+
pinned[attr] = compositesKeyForm[attr];
|
|
558
1470
|
}
|
|
559
|
-
|
|
1471
|
+
const operand = (value) => compositeKeyForm(targetAttr, value);
|
|
1472
|
+
return {
|
|
1473
|
+
pkValue: pk136,
|
|
1474
|
+
rows: entityLike._indexRows136?.(indexDef),
|
|
1475
|
+
condition: KeyText136.composeBoundCondition(entityLike._schema, entityLike.entityType, 1, indexDef, pinned, targetAttr, "eq" in condition
|
|
1476
|
+
? { eq: operand(condition.eq) }
|
|
1477
|
+
: { beginsWith: operand(condition.beginsWith) }),
|
|
1478
|
+
};
|
|
560
1479
|
};
|
|
561
1480
|
const bqConfig = {
|
|
562
1481
|
pathBuilder,
|
|
563
1482
|
conditionOps,
|
|
564
1483
|
provide,
|
|
565
1484
|
composeSkCondition,
|
|
566
|
-
|
|
1485
|
+
where136,
|
|
1486
|
+
// A Z-order accessor bounds its dimensions and prefix composites
|
|
1487
|
+
// (#171), and takes `.zorder()`.
|
|
1488
|
+
...(zorder
|
|
1489
|
+
? {
|
|
1490
|
+
zorder: true,
|
|
1491
|
+
planWithin: (q, bounds, options) => planZOrderWithin({
|
|
1492
|
+
entityLike,
|
|
1493
|
+
accessor: _indexName,
|
|
1494
|
+
indexDef,
|
|
1495
|
+
encoderSource,
|
|
1496
|
+
compositeKeyForm,
|
|
1497
|
+
pkValue,
|
|
1498
|
+
plans: zorderPlans,
|
|
1499
|
+
query: q,
|
|
1500
|
+
bounds,
|
|
1501
|
+
options,
|
|
1502
|
+
}),
|
|
1503
|
+
}
|
|
1504
|
+
: {
|
|
1505
|
+
planWithin: (q, bounds, options) => planCompositeWithin({
|
|
1506
|
+
entityLike,
|
|
1507
|
+
accessor: _indexName,
|
|
1508
|
+
indexDef,
|
|
1509
|
+
encoderSource,
|
|
1510
|
+
compositeKeyForm,
|
|
1511
|
+
pinnedKeyForm: compositesKeyForm,
|
|
1512
|
+
pkValue,
|
|
1513
|
+
query: q,
|
|
1514
|
+
bounds,
|
|
1515
|
+
options,
|
|
1516
|
+
}),
|
|
1517
|
+
}),
|
|
1518
|
+
skFields: accessorDef.sk.composite,
|
|
567
1519
|
...entityNaming(entityLike._resolveDbName),
|
|
568
1520
|
operation: { name: "Entity.query", target: entityLike.entityType, index: _indexName },
|
|
569
1521
|
};
|
|
@@ -753,6 +1705,11 @@ const makeFromConfig = (config) => Effect.gen(function* () {
|
|
|
753
1705
|
const skPrefix = `${pre}#${casedNames.join("#")}`;
|
|
754
1706
|
query = Query.where(query, { beginsWith: skPrefix });
|
|
755
1707
|
}
|
|
1708
|
+
// A collection's sort key prefix holds names only, so its 1.36 key
|
|
1709
|
+
// differs in the partition key alone — a number composite (#171).
|
|
1710
|
+
query = Query.alsoReadPartition136(query, KeyText136.enabled(firstMember.entityLike._schema)
|
|
1711
|
+
? KeyText136.composePk(firstMember.entityLike._schema, firstMember.entityLike.entityType, indexDef, entityKeyForm(firstMember.entityLike, composites))
|
|
1712
|
+
: undefined);
|
|
756
1713
|
// Wrap in BoundQuery that groups results by entity key
|
|
757
1714
|
const pathBuilder = createPathBuilder();
|
|
758
1715
|
const conditionOps = createConditionOps();
|
|
@@ -805,6 +1762,39 @@ const makeFromConfig = (config) => Effect.gen(function* () {
|
|
|
805
1762
|
// 5. Build table operations
|
|
806
1763
|
const client = yield* DynamoClient;
|
|
807
1764
|
const tables = {};
|
|
1765
|
+
// `rekey` (#171) hands each row to the member owning it: the table's
|
|
1766
|
+
// entities and aggregates, those the client binds on the same table, and
|
|
1767
|
+
// the streams the caller passes (`DynamoClient.make` binds no streams).
|
|
1768
|
+
const rekeyFor = (tableName, tagKey, registered) => (options) => {
|
|
1769
|
+
const onTable = (member) => member._tableTag?.key === tagKey;
|
|
1770
|
+
const handlers = new Set();
|
|
1771
|
+
for (const member of [
|
|
1772
|
+
...registered,
|
|
1773
|
+
...Object.values(config.entities).filter(onTable),
|
|
1774
|
+
...Object.values(config.aggregates ?? {}).filter(onTable),
|
|
1775
|
+
...(options?.streams ?? []),
|
|
1776
|
+
]) {
|
|
1777
|
+
const handler = Rekey.handlerOf(member);
|
|
1778
|
+
if (handler !== undefined)
|
|
1779
|
+
handlers.add(handler);
|
|
1780
|
+
}
|
|
1781
|
+
// A projection's outbox on this table, of a read model on another (#171).
|
|
1782
|
+
const outboxOf = new Set();
|
|
1783
|
+
for (const entity of [...registered, ...Object.values(config.entities)]) {
|
|
1784
|
+
const outbox = entity._projectionOutbox;
|
|
1785
|
+
if (outbox === undefined || outbox.table.Tag.key !== tagKey || outboxOf.has(entity)) {
|
|
1786
|
+
continue;
|
|
1787
|
+
}
|
|
1788
|
+
outboxOf.add(entity);
|
|
1789
|
+
const readModel = entity;
|
|
1790
|
+
handlers.add({
|
|
1791
|
+
name: readModel.entityType,
|
|
1792
|
+
types: [`${readModel.entityType}#outbox`],
|
|
1793
|
+
row: (row, ctx) => readModel._rekeyProjectionRow(row, ctx, { pk: outbox.pk, sk: outbox.sk }),
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
return provide(Rekey.run({ tableName, members: [...handlers], options }));
|
|
1797
|
+
};
|
|
808
1798
|
if (config.tables) {
|
|
809
1799
|
for (const [tableKey, table] of Object.entries(config.tables)) {
|
|
810
1800
|
const tableConfig = yield* table.Tag;
|
|
@@ -842,7 +1832,13 @@ const makeFromConfig = (config) => Effect.gen(function* () {
|
|
|
842
1832
|
};
|
|
843
1833
|
}
|
|
844
1834
|
}
|
|
845
|
-
tables[tableKey] =
|
|
1835
|
+
tables[tableKey] = {
|
|
1836
|
+
...buildTableOperationsFromTable(tableConfig.name, mergedTable, client),
|
|
1837
|
+
rekey: rekeyFor(tableConfig.name, table.Tag.key, [
|
|
1838
|
+
...Object.values(mergedTable.entities),
|
|
1839
|
+
...Object.values(mergedTable.aggregates),
|
|
1840
|
+
]),
|
|
1841
|
+
};
|
|
846
1842
|
}
|
|
847
1843
|
}
|
|
848
1844
|
else {
|
|
@@ -879,7 +1875,10 @@ const makeFromConfig = (config) => Effect.gen(function* () {
|
|
|
879
1875
|
a,
|
|
880
1876
|
])),
|
|
881
1877
|
};
|
|
882
|
-
tables[tableConfig.name] =
|
|
1878
|
+
tables[tableConfig.name] = {
|
|
1879
|
+
...buildTableOperationsFromTable(tableConfig.name, syntheticTable, client),
|
|
1880
|
+
rekey: rekeyFor(tableConfig.name, tag.key, [...tableEntities, ...tableAggregates]),
|
|
1881
|
+
};
|
|
883
1882
|
}
|
|
884
1883
|
}
|
|
885
1884
|
return {
|
|
@@ -920,22 +1919,28 @@ const diagnoseVectorLimits = (operation, label, sent) => diagnoseFailure({
|
|
|
920
1919
|
/** Make createTable idempotent — ignore if table already exists. */
|
|
921
1920
|
const idempotentCreate = (effect) => Effect.catchIf(effect, isResourceInUse, () => Effect.void);
|
|
922
1921
|
const buildTableOperationsFromTable = (tableName, table, client) => {
|
|
923
|
-
|
|
1922
|
+
// Composed when a table is created: a table holding only event streams or a
|
|
1923
|
+
// projection's outbox has no definition to give, but its other operations
|
|
1924
|
+
// (`rekey` among them) still work.
|
|
1925
|
+
const definition = () => tableDefinition(table);
|
|
924
1926
|
const vectorIndexes = mergeVectorIndexes(table.entities);
|
|
925
1927
|
const sharers = (indexName) => vectorIndexes.get(indexName)?.sharers ?? [];
|
|
926
1928
|
return {
|
|
927
1929
|
...buildTableOperations(tableName, client),
|
|
928
|
-
create: (options) =>
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1930
|
+
create: (options) => Effect.suspend(() => {
|
|
1931
|
+
const def = definition();
|
|
1932
|
+
return idempotentCreate(client
|
|
1933
|
+
.createTable({
|
|
1934
|
+
TableName: tableName,
|
|
1935
|
+
BillingMode: options?.billingMode ?? "PAY_PER_REQUEST",
|
|
1936
|
+
...def,
|
|
1937
|
+
})
|
|
1938
|
+
.pipe(Effect.asVoid, diagnoseVectorLimits("CreateTable", `db.tables create of "${tableName}"`, {
|
|
1939
|
+
vectorIndexes: def.VectorIndexes ?? [],
|
|
1940
|
+
sharers,
|
|
1941
|
+
declared: vectorIndexes.size,
|
|
1942
|
+
})));
|
|
1943
|
+
}),
|
|
939
1944
|
addVectorIndex: (indexName) => {
|
|
940
1945
|
const entry = vectorIndexes.get(indexName);
|
|
941
1946
|
if (entry === undefined) {
|
|
@@ -1110,7 +2115,8 @@ const waitForVectorIndexReady = (tableName, indexName, client, options) => {
|
|
|
1110
2115
|
}));
|
|
1111
2116
|
};
|
|
1112
2117
|
/** @internal Classify an AWS SDK error into a specific tagged error type. */
|
|
1113
|
-
const classifyError = (operation) => (
|
|
2118
|
+
export const classifyError = (operation) => (thrown) => {
|
|
2119
|
+
const cause = redactCredentialRefusal(thrown);
|
|
1114
2120
|
if (isAwsThrottling(cause))
|
|
1115
2121
|
return new ThrottlingError({ operation, cause });
|
|
1116
2122
|
if (isAwsValidationError(cause))
|