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/Query.js
CHANGED
|
@@ -9,13 +9,15 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { ValidationError } from "@effect-dynamodb/schema/Errors.js";
|
|
11
11
|
import * as Projection from "@effect-dynamodb/schema/Projection.js";
|
|
12
|
-
import { Effect, Function, Option, Pipeable, Stream } from "effect";
|
|
12
|
+
import { Effect, Function, Option, Pipeable, Result, Stream } from "effect";
|
|
13
13
|
import { DynamoClient } from "./DynamoClient.js";
|
|
14
14
|
import * as Capacity from "./internal/Capacity.js";
|
|
15
|
+
import * as Cursor from "./internal/Cursor.js";
|
|
15
16
|
import { compileExpr, ExprTag, emptyPartProblem, isEmptyExpr } from "./internal/Expr.js";
|
|
16
17
|
import { batchGetRaw } from "./internal/Hydrate.js";
|
|
17
18
|
import { packedExprRefusal, packedProjectionRefusal, } from "./internal/Packed.js";
|
|
18
19
|
import { compilePath } from "./internal/PathBuilder.js";
|
|
20
|
+
import { compositePlanner, cursorRefusal, DEFAULT_MAX_SKIPS, displayKeyCondition, JUMP_INITIAL, JUMP_MAX_LIMIT, keyPassesPlanner, readFrom, toKeyCondition, } from "./internal/RangePlanner.js";
|
|
19
21
|
import { diagnoseFailure, explainDiagnosis, withCause } from "./internal/WriteDiagnosis.js";
|
|
20
22
|
import { fromAttributeMap, toAttributeValue } from "./Marshaller.js";
|
|
21
23
|
/**
|
|
@@ -85,10 +87,18 @@ export const make = (config) => new QueryImpl({
|
|
|
85
87
|
limitValue: undefined,
|
|
86
88
|
pageSizeValue: undefined,
|
|
87
89
|
maxPagesValue: undefined,
|
|
90
|
+
observers: [],
|
|
88
91
|
scanForward: true,
|
|
89
92
|
consistentRead: false,
|
|
90
93
|
ignoreOwnershipFlag: false,
|
|
91
94
|
exclusiveStartKey: undefined,
|
|
95
|
+
cursorText: undefined,
|
|
96
|
+
planner: undefined,
|
|
97
|
+
maxSkips: undefined,
|
|
98
|
+
key136: undefined,
|
|
99
|
+
zorderOptions: undefined,
|
|
100
|
+
plan: undefined,
|
|
101
|
+
rowCheck: undefined,
|
|
92
102
|
isScan: false,
|
|
93
103
|
projection: undefined,
|
|
94
104
|
projectionPaths: undefined,
|
|
@@ -122,10 +132,18 @@ export const makeScan = (config) => new QueryImpl({
|
|
|
122
132
|
limitValue: undefined,
|
|
123
133
|
pageSizeValue: undefined,
|
|
124
134
|
maxPagesValue: undefined,
|
|
135
|
+
observers: [],
|
|
125
136
|
scanForward: true,
|
|
126
137
|
consistentRead: false,
|
|
127
138
|
ignoreOwnershipFlag: false,
|
|
128
139
|
exclusiveStartKey: undefined,
|
|
140
|
+
cursorText: undefined,
|
|
141
|
+
planner: undefined,
|
|
142
|
+
maxSkips: undefined,
|
|
143
|
+
key136: undefined,
|
|
144
|
+
zorderOptions: undefined,
|
|
145
|
+
plan: undefined,
|
|
146
|
+
rowCheck: undefined,
|
|
129
147
|
isScan: true,
|
|
130
148
|
projection: undefined,
|
|
131
149
|
projectionPaths: undefined,
|
|
@@ -134,10 +152,9 @@ export const makeScan = (config) => new QueryImpl({
|
|
|
134
152
|
keyFields: normalizeKeyFields(config.keyFields),
|
|
135
153
|
});
|
|
136
154
|
// ---------------------------------------------------------------------------
|
|
137
|
-
// Cursor encoding (internal)
|
|
155
|
+
// Cursor encoding (internal) — see `internal/Cursor.ts`
|
|
138
156
|
// ---------------------------------------------------------------------------
|
|
139
|
-
const encodeCursor = (key) =>
|
|
140
|
-
const decodeCursor = (cursor) => JSON.parse(atob(cursor));
|
|
157
|
+
const encodeCursor = (key) => Cursor.encodeKeyCursor(key);
|
|
141
158
|
// ---------------------------------------------------------------------------
|
|
142
159
|
// Combinators (all dual for data-first and data-last)
|
|
143
160
|
// ---------------------------------------------------------------------------
|
|
@@ -152,16 +169,55 @@ export const where = Function.dual(2, (self, condition) => {
|
|
|
152
169
|
return new QueryImpl({
|
|
153
170
|
...state,
|
|
154
171
|
skConditions: [{ field: state.skField, condition }],
|
|
172
|
+
// A new condition is not the pins the 1.36 key was composed for (#171):
|
|
173
|
+
// the caller that composed it installs the 1.36 key again.
|
|
174
|
+
key136: undefined,
|
|
155
175
|
});
|
|
156
176
|
});
|
|
177
|
+
/** @internal Do two sort key conditions select the same keys, operand for operand? */
|
|
178
|
+
const sameCondition = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
179
|
+
/**
|
|
180
|
+
* @internal Also read the key 1.36 composed for this query's pins (#171,
|
|
181
|
+
* DESIGN.md §7 "Rows written by 1.36"): its partition key and its `eq` /
|
|
182
|
+
* `beginsWith` sort key condition (or none). A row 1.36 wrote whose key text
|
|
183
|
+
* changed in 1.37 sits there until it is written again or `rekey` moves it.
|
|
184
|
+
* A terminal reads both keys as two passes of one plan
|
|
185
|
+
* (`RangePlanner.keyPassesPlanner`): the 1.36 key first, or last under
|
|
186
|
+
* {@link reverse}. A key equal to the query's own adds nothing — unless its
|
|
187
|
+
* rows are split by form (`KeyPass.rows.split`) — so a query whose key text
|
|
188
|
+
* did not change sends exactly what it always sent. Installed after the
|
|
189
|
+
* pins' {@link where}, which clears it.
|
|
190
|
+
*/
|
|
191
|
+
export const alsoRead136 = (self, key) => {
|
|
192
|
+
const state = self._state;
|
|
193
|
+
if (key === undefined || state.isScan)
|
|
194
|
+
return self;
|
|
195
|
+
const own = state.skConditions[0]?.condition;
|
|
196
|
+
const same = key.pkValue === state.pkValue && sameCondition(key.condition, own);
|
|
197
|
+
if (same && key.rows?.split !== true)
|
|
198
|
+
return self;
|
|
199
|
+
return new QueryImpl({ ...state, key136: key });
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* @internal {@link alsoRead136} for a query whose 1.36 key differs in its
|
|
203
|
+
* partition key alone (a collection, a partition's tombstones): the 1.36
|
|
204
|
+
* partition under the query's own sort key condition.
|
|
205
|
+
*/
|
|
206
|
+
export const alsoReadPartition136 = (self, pkValue) => pkValue === undefined
|
|
207
|
+
? self
|
|
208
|
+
: alsoRead136(self, { pkValue, condition: self._state.skConditions[0]?.condition });
|
|
157
209
|
/**
|
|
158
210
|
* Return **at most `n` items** — a contract on results, not on round trips.
|
|
159
211
|
*
|
|
160
212
|
* The query accumulates across as many DynamoDB requests as it takes to reach
|
|
161
213
|
* `n` accepted items (or to exhaust the key range). Under a `FilterExpression`
|
|
162
214
|
* DynamoDB's own `Limit` bounds rows *examined* before the filter runs, so it
|
|
163
|
-
* cannot
|
|
164
|
-
*
|
|
215
|
+
* cannot carry `n` itself. Instead each request's `Limit` is sized from the
|
|
216
|
+
* pass rate the earlier requests observed (rows accepted / rows examined), so
|
|
217
|
+
* a selective filter reads about what it needs rather than a 1 MB page.
|
|
218
|
+
* {@link pageSize} fixes `Limit` instead (`"natural"` restores 1.36's sizing),
|
|
219
|
+
* and {@link maxPages} stays the hard stop on the number of requests — a query
|
|
220
|
+
* with one keeps 1.36's sizing, so one call returns what it did.
|
|
165
221
|
*
|
|
166
222
|
* ```ts
|
|
167
223
|
* query.pipe(Query.limit(3), Query.collect) // 3 items
|
|
@@ -177,10 +233,44 @@ export const limit = Function.dual(2, (self, n) => new QueryImpl({
|
|
|
177
233
|
* A contract on round trips, not on what comes back: a request under a
|
|
178
234
|
* `FilterExpression` may return fewer than `n` items, and pagination continues
|
|
179
235
|
* until the key range is exhausted (or {@link limit} / {@link maxPages} stops it).
|
|
236
|
+
* A number is used as given (DynamoDB rejects one it does not accept).
|
|
237
|
+
*
|
|
238
|
+
* `"natural"` opts out of the request sizing {@link limit} does under a filter,
|
|
239
|
+
* and sizes every request exactly as 1.36 did: a natural 1 MB page under a
|
|
240
|
+
* filter or predicate, `limit` doubling per request over history rows, and the
|
|
241
|
+
* outstanding `limit` otherwise. Any other string throws `[EDD-9095]`.
|
|
180
242
|
*/
|
|
181
|
-
export const pageSize = Function.dual(2, (self,
|
|
243
|
+
export const pageSize = Function.dual(2, (self, size) => {
|
|
244
|
+
if (typeof size === "string" && size !== "natural") {
|
|
245
|
+
throw new Error(`[EDD-9095] pageSize(${JSON.stringify(size)}) is not a page size: pass the number of ` +
|
|
246
|
+
`rows each request examines (DynamoDB's Limit), or "natural" to size requests as ` +
|
|
247
|
+
`1.36 did.`);
|
|
248
|
+
}
|
|
249
|
+
return new QueryImpl({
|
|
250
|
+
...self._state,
|
|
251
|
+
pageSizeValue: size,
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
/**
|
|
255
|
+
* Observe every request a terminal sends: `observer` runs after each one, in
|
|
256
|
+
* the terminal's fiber, with what it examined and kept. Several observers run
|
|
257
|
+
* in the order they were added. A failure of one is a defect. DynamoDB calls
|
|
258
|
+
* an observer makes are not counted in the query's consumed-capacity report.
|
|
259
|
+
*
|
|
260
|
+
* Σ `scannedCount` against Σ `accepted` is what a query read against what it
|
|
261
|
+
* returned:
|
|
262
|
+
*
|
|
263
|
+
* ```ts
|
|
264
|
+
* query.pipe(
|
|
265
|
+
* Query.onRequest((event) => Console.log(event.request, event.limit, event.scannedCount)),
|
|
266
|
+
* Query.limit(10),
|
|
267
|
+
* Query.collect,
|
|
268
|
+
* )
|
|
269
|
+
* ```
|
|
270
|
+
*/
|
|
271
|
+
export const onRequest = Function.dual(2, (self, observer) => new QueryImpl({
|
|
182
272
|
...self._state,
|
|
183
|
-
|
|
273
|
+
observers: [...self._state.observers, observer],
|
|
184
274
|
}));
|
|
185
275
|
/**
|
|
186
276
|
* Reverse the sort order (sets ScanIndexForward = false).
|
|
@@ -218,15 +308,63 @@ export const ignoreOwnership = (self) => new QueryImpl({
|
|
|
218
308
|
});
|
|
219
309
|
/**
|
|
220
310
|
* Set the starting cursor for pagination. The cursor is an opaque string
|
|
221
|
-
* returned by {@link execute} that encodes the
|
|
222
|
-
*
|
|
311
|
+
* returned by {@link execute} that encodes where the query resumes. Pass an
|
|
312
|
+
* empty string to start from the beginning.
|
|
313
|
+
*
|
|
314
|
+
* The cursor is read when a terminal runs, not here: a malformed one fails
|
|
315
|
+
* the terminal with a `ValidationError` (`[EDD-9094]`,
|
|
316
|
+
* `operation: "query.startFrom"`), as does a `.within()` cursor on a query
|
|
317
|
+
* without `.within()`. A `.within()` query also refuses one from another
|
|
318
|
+
* plan (other bounds, partition, direction or index declaration) or
|
|
319
|
+
* positioned outside its own. A query without `.within()` can't recognise
|
|
320
|
+
* another query's well-formed cursor: DynamoDB may reject it
|
|
321
|
+
* (`DynamoValidationError`) or the query resumes from it.
|
|
223
322
|
*/
|
|
224
323
|
export const startFrom = Function.dual(2, (self, cursor) => cursor === ""
|
|
225
324
|
? self
|
|
226
325
|
: new QueryImpl({
|
|
227
326
|
...self._state,
|
|
228
|
-
|
|
327
|
+
cursorText: cursor,
|
|
229
328
|
}));
|
|
329
|
+
/**
|
|
330
|
+
* @internal Install a bound query's #171 planning state: the planner its
|
|
331
|
+
* `.within()` built, with the skip-scan probe budget, or the options of its
|
|
332
|
+
* `.zorder()`, merged over earlier ones (validated there). The planner is a
|
|
333
|
+
* factory, resolved from the final state when a terminal runs, so the
|
|
334
|
+
* combinators that follow (`reverse`, `startFrom`, …) still apply. Not a
|
|
335
|
+
* combinator: Z-order and skip-scan are reached through `BoundQuery`.
|
|
336
|
+
*/
|
|
337
|
+
export const plan = (self, install) => new QueryImpl("zorder" in install
|
|
338
|
+
? { ...self._state, zorderOptions: { ...self._state.zorderOptions, ...install.zorder } }
|
|
339
|
+
: { ...self._state, planner: install.planner, maxSkips: install.maxSkips });
|
|
340
|
+
/**
|
|
341
|
+
* @internal A query no stored row satisfies (#171) — a `.history()` bound with
|
|
342
|
+
* no key text below it (`lt("")` on a string orderBy) — planned `empty`, as a
|
|
343
|
+
* `.within()` whose bounds admit no value is: nothing is sent, the terminals
|
|
344
|
+
* return no items, and `explain()` reports the `empty` plan.
|
|
345
|
+
*/
|
|
346
|
+
export const planEmpty = (self) => plan(self, {
|
|
347
|
+
planner: compositePlanner({
|
|
348
|
+
entityType: self._state.entityTypes.join(", ") || "unknown",
|
|
349
|
+
index: self._state.indexName ?? "primary",
|
|
350
|
+
pkField: self._state.pkField,
|
|
351
|
+
pkValue: self._state.pkValue,
|
|
352
|
+
skField: self._state.skField ?? "",
|
|
353
|
+
fingerprint: "empty",
|
|
354
|
+
space: { _tag: "none" },
|
|
355
|
+
inner: {
|
|
356
|
+
_tag: "single",
|
|
357
|
+
conditionFor: () => {
|
|
358
|
+
throw new Error("[internal] An empty plan has no range.");
|
|
359
|
+
},
|
|
360
|
+
rowCheck: undefined,
|
|
361
|
+
empty: true,
|
|
362
|
+
},
|
|
363
|
+
filterPlan: { prefix: "", rowCheck: undefined },
|
|
364
|
+
ineligible: undefined,
|
|
365
|
+
}),
|
|
366
|
+
maxSkips: undefined,
|
|
367
|
+
});
|
|
230
368
|
/** @internal The decoder of a projected (raw) row: the raw-row hook, or the identity. */
|
|
231
369
|
const rawRowDecoder = (hook) => hook ?? ((raw) => Effect.succeed(raw));
|
|
232
370
|
/** @internal Throw `[EDD-9076]` for a projection inside a packed attribute. */
|
|
@@ -413,14 +551,16 @@ const buildFilterClauses = (state) => {
|
|
|
413
551
|
values[`:et${i}`] = toAttributeValue(et);
|
|
414
552
|
});
|
|
415
553
|
}
|
|
416
|
-
// Expr-based filters (compiled from Entity.filter() callback/shorthand API)
|
|
417
|
-
// Compiled as ONE expression: each compile numbers
|
|
418
|
-
// zero, so two compiled apart both wrote `#e0` and the
|
|
419
|
-
// first's name (#133).
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
554
|
+
// Expr-based filters (compiled from Entity.filter() callback/shorthand API),
|
|
555
|
+
// and a plan's own (#171). Compiled as ONE expression: each compile numbers
|
|
556
|
+
// its placeholders from zero, so two compiled apart both wrote `#e0` and the
|
|
557
|
+
// second overwrote the first's name (#133).
|
|
558
|
+
const planFilter = state.plan?.planner.filter;
|
|
559
|
+
const exprFilters = planFilter === undefined ? state.exprFilters : [...state.exprFilters, planFilter];
|
|
560
|
+
if (exprFilters.length > 0) {
|
|
561
|
+
const compiled = compileExpr(exprFilters.length === 1
|
|
562
|
+
? exprFilters[0]
|
|
563
|
+
: { [ExprTag]: ExprTag, _tag: "and", exprs: exprFilters });
|
|
424
564
|
// Parenthesised beside the ownership clause: a top-level `OR` would
|
|
425
565
|
// otherwise bind looser than the `AND` and admit other entities' rows
|
|
426
566
|
// (`#eddE IN (:et0) AND a OR b`).
|
|
@@ -438,6 +578,10 @@ const buildProjection = (state, names, extraFields = []) => {
|
|
|
438
578
|
const counter = { value: 0 };
|
|
439
579
|
const projParts = [];
|
|
440
580
|
for (const segments of state.projectionPaths) {
|
|
581
|
+
// A path inside an attribute borrowed whole is read with it: DynamoDB
|
|
582
|
+
// rejects overlapping projection paths (see `selectedInside`).
|
|
583
|
+
if (segments.length > 1 && extraFields.includes(String(segments[0])))
|
|
584
|
+
continue;
|
|
441
585
|
projParts.push(compilePath(segments, names, "proj", counter));
|
|
442
586
|
}
|
|
443
587
|
for (const field of extraFields) {
|
|
@@ -510,8 +654,9 @@ const buildCommandInput = (state, extraProjectionFields = []) => {
|
|
|
510
654
|
/**
|
|
511
655
|
* @internal Build the full DynamoDB command parameters from state and table name.
|
|
512
656
|
*
|
|
513
|
-
* `Limit` is
|
|
514
|
-
*
|
|
657
|
+
* The default `Limit` is the first request's (what {@link asParams} reports).
|
|
658
|
+
* A terminal supplies each request's own through `overrides`: it shrinks as a
|
|
659
|
+
* `limit` budget is consumed and follows the observed pass rate.
|
|
515
660
|
*/
|
|
516
661
|
const buildDynamoCommand = (state, tableName, overrides, extraProjectionFields = []) => {
|
|
517
662
|
const input = buildCommandInput(state, extraProjectionFields);
|
|
@@ -524,7 +669,7 @@ const buildDynamoCommand = (state, tableName, overrides, extraProjectionFields =
|
|
|
524
669
|
ExpressionAttributeNames: input.ExpressionAttributeNames,
|
|
525
670
|
ExpressionAttributeValues: input.ExpressionAttributeValues,
|
|
526
671
|
ConsistentRead: input.ConsistentRead,
|
|
527
|
-
Limit:
|
|
672
|
+
Limit: requestLimit(state, state.limitValue, emptyEstimator, 1),
|
|
528
673
|
ScanIndexForward: state.isScan ? undefined : state.scanForward,
|
|
529
674
|
// An LSI that does not project the items: DynamoDB fetches them from the
|
|
530
675
|
// table. Not with a ProjectionExpression, which DynamoDB rejects beside
|
|
@@ -625,34 +770,181 @@ const hydrateRows = (client, tableName, state, rows) => {
|
|
|
625
770
|
// ---------------------------------------------------------------------------
|
|
626
771
|
/** The largest `Limit` a growing request asks for: DynamoDB stops at 1 MB anyway. */
|
|
627
772
|
const MAX_GROWN_LIMIT = 100_000;
|
|
773
|
+
/** The first filtered request asks for `remaining × INITIAL_FACTOR` rows, at least `MIN_FIRST`. */
|
|
774
|
+
const INITIAL_FACTOR = 2;
|
|
775
|
+
const MIN_FIRST = 16;
|
|
776
|
+
/** Headroom over the rows the observed pass rate says the outstanding items need. */
|
|
777
|
+
const SAFETY = 1.25;
|
|
778
|
+
/** How much a request grows over the last when nothing passed it. */
|
|
779
|
+
const GROWTH = 2;
|
|
780
|
+
const sizingMode = (state) => {
|
|
781
|
+
if (typeof state.pageSizeValue === "number")
|
|
782
|
+
return "fixed";
|
|
783
|
+
if (state.pageSizeValue === "natural")
|
|
784
|
+
return "natural";
|
|
785
|
+
if (state.limitValue === undefined)
|
|
786
|
+
return "none";
|
|
787
|
+
// Planned queries (`.within()`) are new, so they adapt under `maxPages` too.
|
|
788
|
+
if (state.maxPagesValue !== undefined && state.planner === undefined)
|
|
789
|
+
return "natural";
|
|
790
|
+
if (filtered(state) || dropsRows(state))
|
|
791
|
+
return "adaptive";
|
|
792
|
+
return "budget";
|
|
793
|
+
};
|
|
628
794
|
/**
|
|
629
|
-
* @internal
|
|
795
|
+
* @internal Whether rows are filtered — server-side (the caller's filter or a
|
|
796
|
+
* plan's own, #171), with a client predicate or by a plan's row check — so a
|
|
797
|
+
* request cannot assume every row it examines passes. (A row check also
|
|
798
|
+
* {@link dropsRows}: it is judged on the raw row.)
|
|
799
|
+
*/
|
|
800
|
+
const filtered = (state) => state.exprFilters.length > 0 ||
|
|
801
|
+
state.predicates.length > 0 ||
|
|
802
|
+
state.plan?.planner.filter !== undefined ||
|
|
803
|
+
state.rowCheck !== undefined;
|
|
804
|
+
const emptyEstimator = {
|
|
805
|
+
examined: 0,
|
|
806
|
+
accepted: 0,
|
|
807
|
+
lastLimit: 0,
|
|
808
|
+
lastExamined: 0,
|
|
809
|
+
lastAccepted: 0,
|
|
810
|
+
requests: 0,
|
|
811
|
+
};
|
|
812
|
+
/**
|
|
813
|
+
* @internal The adaptive `Limit` of the next request, `remaining` items still
|
|
814
|
+
* owed:
|
|
815
|
+
*
|
|
816
|
+
* - The first asks for twice `remaining` (at least 16) when the caller filters
|
|
817
|
+
* — nothing is known of the pass rate yet — and for `remaining` when only
|
|
818
|
+
* history rows are dropped, #133's first request.
|
|
819
|
+
* - While nothing has been accepted, each asks for twice the last, so a run of
|
|
820
|
+
* `n` rejected rows costs about `log2(n / limit)` requests (#133).
|
|
821
|
+
* - Then `remaining` over the pass rate, with 25% headroom. The rate is the
|
|
822
|
+
* lower of the cumulative one and the last request's own: after an early
|
|
823
|
+
* cluster of matches the cumulative rate stays high long after the matches
|
|
824
|
+
* thin out, and alone it would size the request after each isolated later
|
|
825
|
+
* match at a few dozen rows. After a request that accepted nothing, the
|
|
826
|
+
* `Limit` at least doubles (a long run of misses).
|
|
630
827
|
*
|
|
631
|
-
*
|
|
828
|
+
* So a request that neither fills the limit nor reaches DynamoDB's 1 MB cap
|
|
829
|
+
* is followed by one at least twice its size unless it accepted over a third
|
|
830
|
+
* of what was owed, and a query sends a logarithmic number of requests more
|
|
831
|
+
* than natural 1 MB pages. Clamped to 1..100,000 (DynamoDB stops at 1 MB
|
|
832
|
+
* anyway).
|
|
833
|
+
*/
|
|
834
|
+
const nextLimit = (est, remaining, filteredByUser) => {
|
|
835
|
+
let limit;
|
|
836
|
+
if (est.requests === 0) {
|
|
837
|
+
limit = filteredByUser ? Math.max(MIN_FIRST, Math.ceil(remaining * INITIAL_FACTOR)) : remaining;
|
|
838
|
+
}
|
|
839
|
+
else if (est.accepted === 0) {
|
|
840
|
+
limit = est.lastLimit * GROWTH;
|
|
841
|
+
}
|
|
842
|
+
else {
|
|
843
|
+
// Never above 1, whatever a response reports as examined.
|
|
844
|
+
const cumulative = est.accepted / Math.max(est.examined, est.accepted);
|
|
845
|
+
const rate = est.lastAccepted > 0
|
|
846
|
+
? Math.min(cumulative, est.lastAccepted / Math.max(est.lastExamined, est.lastAccepted))
|
|
847
|
+
: cumulative;
|
|
848
|
+
limit = Math.ceil((remaining / rate) * SAFETY);
|
|
849
|
+
if (est.lastAccepted === 0)
|
|
850
|
+
limit = Math.max(limit, est.lastLimit * GROWTH);
|
|
851
|
+
}
|
|
852
|
+
return Math.min(Math.max(limit, 1), MAX_GROWN_LIMIT);
|
|
853
|
+
};
|
|
854
|
+
/**
|
|
855
|
+
* @internal `est` after one more request: sent with `limitSent`, it examined
|
|
856
|
+
* `scanned` rows (`ScannedCount`, or the rows returned when a response lacks
|
|
857
|
+
* it), of which `accepted` became items.
|
|
858
|
+
*/
|
|
859
|
+
const observeEstimator = (est, observed) => ({
|
|
860
|
+
examined: est.examined + observed.scanned,
|
|
861
|
+
accepted: est.accepted + observed.accepted,
|
|
862
|
+
// A request without `Limit` (only outside adaptive sizing) asked for what it read.
|
|
863
|
+
lastLimit: observed.limitSent ?? observed.scanned,
|
|
864
|
+
lastExamined: observed.scanned,
|
|
865
|
+
lastAccepted: observed.accepted,
|
|
866
|
+
requests: est.requests + 1,
|
|
867
|
+
});
|
|
868
|
+
/**
|
|
869
|
+
* @internal DynamoDB `Limit` for read number `page` (1-based within the
|
|
870
|
+
* terminal; a skip-scan's probes are not reads), `remaining` items still owed
|
|
871
|
+
* (`undefined` without a {@link limit}) and `est` what the earlier reads
|
|
872
|
+
* observed. Adaptive sizing is {@link nextLimit}; every other mode of
|
|
873
|
+
* {@link sizingMode} is 1.36's {@link legacyRequestLimit}.
|
|
874
|
+
*/
|
|
875
|
+
const requestLimit = (state, remaining, est, page) => remaining !== undefined && sizingMode(state) === "adaptive"
|
|
876
|
+
? nextLimit(est, remaining, filtered(state))
|
|
877
|
+
: legacyRequestLimit(state, remaining, page);
|
|
878
|
+
/**
|
|
879
|
+
* @internal The `Limit` of a planned read: a `jump` read's follows its in-box
|
|
880
|
+
* run (design §8.5) unless `.zorder({ pageSize })` or a numeric `.pageSize(n)`
|
|
881
|
+
* fixes it, capped near what the outstanding `limit` needs; every other read
|
|
882
|
+
* is sized as {@link requestLimit} sizes any request.
|
|
883
|
+
*/
|
|
884
|
+
const readLimit = (state, step, remaining, est, read) => {
|
|
885
|
+
if (step.sizing._tag !== "jump")
|
|
886
|
+
return requestLimit(state, remaining, est, read);
|
|
887
|
+
const fixed = state.zorderOptions?.pageSize ??
|
|
888
|
+
(typeof state.pageSizeValue === "number" ? state.pageSizeValue : undefined);
|
|
889
|
+
if (fixed !== undefined)
|
|
890
|
+
return fixed;
|
|
891
|
+
const run = remaining === undefined
|
|
892
|
+
? step.sizing.run
|
|
893
|
+
: Math.min(step.sizing.run, Math.max(JUMP_INITIAL, Math.ceil(remaining * SAFETY)));
|
|
894
|
+
return Math.min(Math.max(run, 1), JUMP_MAX_LIMIT);
|
|
895
|
+
};
|
|
896
|
+
/**
|
|
897
|
+
* @internal How many planned ranges a terminal reads at once (design §9.3):
|
|
898
|
+
* `.zorder({ concurrency })`, else 4 with a `limit` (pages sized to it) and 1
|
|
899
|
+
* without (natural 1 MB pages, which several in flight against one partition
|
|
900
|
+
* key would throttle).
|
|
901
|
+
*/
|
|
902
|
+
const planConcurrency = (state) => state.zorderOptions?.concurrency ?? (state.limitValue !== undefined ? 4 : 1);
|
|
903
|
+
/** @internal AWS error names that mean "throttled" (design §9.5). */
|
|
904
|
+
const THROTTLING_NAMES = new Set([
|
|
905
|
+
"ProvisionedThroughputExceededException",
|
|
906
|
+
"ThrottlingException",
|
|
907
|
+
"RequestLimitExceeded",
|
|
908
|
+
]);
|
|
909
|
+
/**
|
|
910
|
+
* @internal Was a request refused for its rate? The client classifies the
|
|
911
|
+
* first two names as a `ThrottlingError`; `RequestLimitExceeded` (an
|
|
912
|
+
* account's on-demand request cap) reaches here as a `DynamoError`, so the
|
|
913
|
+
* AWS error's name is read too.
|
|
914
|
+
*/
|
|
915
|
+
const isThrottled = (error) => {
|
|
916
|
+
if (error._tag === "ThrottlingError")
|
|
917
|
+
return true;
|
|
918
|
+
if (error._tag !== "DynamoError")
|
|
919
|
+
return false;
|
|
920
|
+
const cause = error.cause;
|
|
921
|
+
return typeof cause?.name === "string" && THROTTLING_NAMES.has(cause.name);
|
|
922
|
+
};
|
|
923
|
+
/**
|
|
924
|
+
* @internal DynamoDB `Limit` for the next request as 1.36 sized it — what
|
|
925
|
+
* `pageSize("natural")`, a numeric `pageSize`, a `maxPages` cap, an unfiltered
|
|
926
|
+
* `limit` and no `limit` at all still send (see {@link sizingMode}).
|
|
927
|
+
*
|
|
928
|
+
* - A numeric `pageSize` always wins as the round-trip budget.
|
|
632
929
|
* - With a `limit` budget and NO user filter, every examined row is an accepted
|
|
633
930
|
* row, so the budget can be handed to DynamoDB directly (bounded by pageSize).
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
* `pageSize` — or, unset, a natural (1 MB) page.
|
|
639
|
-
* - A client-side predicate ({@link filterBy}) rejects rows even later, after
|
|
640
|
-
* decode, so it disqualifies the budget for exactly the same reason.
|
|
931
|
+
* - With a user filter, a client-side predicate ({@link filterBy}) or a plan's
|
|
932
|
+
* row check (#171), `limit` cannot be expressed as `Limit` (it bounds rows
|
|
933
|
+
* examined, and the filter runs after), so the request asks for `pageSize`
|
|
934
|
+
* — or, unset, a natural (1 MB) page.
|
|
641
935
|
* - Rows dropped as history ({@link QueryState.liveRows}, a prepared `keep`)
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
* the surplus past `limit` is discarded, the cursor rebuilt from the last
|
|
647
|
-
* item returned (#133). `pageSize`, when set, is used as is.
|
|
936
|
+
* can't be skipped server-side, and a run of them can far outnumber the
|
|
937
|
+
* items: the first request asks for `limit` and each later one twice the
|
|
938
|
+
* last, the surplus past `limit` discarded and the cursor rebuilt from the
|
|
939
|
+
* last item returned (#133). `pageSize`, when set, is used as is.
|
|
648
940
|
*
|
|
649
|
-
* `page` is the
|
|
941
|
+
* `page` is the read's 1-based number within the terminal.
|
|
650
942
|
*/
|
|
651
|
-
const
|
|
652
|
-
const pageSize = state.pageSizeValue;
|
|
943
|
+
const legacyRequestLimit = (state, remaining, page = 1) => {
|
|
944
|
+
const pageSize = typeof state.pageSizeValue === "number" ? state.pageSizeValue : undefined;
|
|
653
945
|
if (remaining === undefined)
|
|
654
946
|
return pageSize;
|
|
655
|
-
if (state
|
|
947
|
+
if (filtered(state))
|
|
656
948
|
return pageSize;
|
|
657
949
|
if (dropsRows(state)) {
|
|
658
950
|
if (pageSize !== undefined)
|
|
@@ -663,14 +955,24 @@ const computeRequestLimit = (state, remaining, page = 1) => {
|
|
|
663
955
|
return pageSize === undefined ? remaining : Math.min(pageSize, remaining);
|
|
664
956
|
};
|
|
665
957
|
/**
|
|
666
|
-
* @internal Whether the query drops rows client-side (#133)
|
|
667
|
-
*
|
|
668
|
-
*
|
|
958
|
+
* @internal Whether the query drops rows client-side (#133) — history rows, or
|
|
959
|
+
* rows a plan's row check rejects (#171): like a predicate, it rejects rows
|
|
960
|
+
* after they are read, so the `limit` budget cannot be handed to DynamoDB, and
|
|
961
|
+
* a count has to read the rows.
|
|
669
962
|
*/
|
|
670
|
-
const dropsRows = (state) => state.keepRow !== undefined || state.liveRows !== undefined;
|
|
671
|
-
/** @internal Is this raw row one the query leaves out (#133)? */
|
|
963
|
+
const dropsRows = (state) => state.keepRow !== undefined || state.liveRows !== undefined || state.rowCheck !== undefined;
|
|
964
|
+
/** @internal Is this raw row one the query leaves out (#133, #171)? */
|
|
672
965
|
const isExcludedRow = (state, row) => (state.keepRow !== undefined && !state.keepRow(row)) ||
|
|
673
|
-
(state.liveRows !== undefined && !state.liveRows.isLive(row))
|
|
966
|
+
(state.liveRows !== undefined && !state.liveRows.isLive(row)) ||
|
|
967
|
+
(state.rowCheck !== undefined && !state.rowCheck.accepts(row));
|
|
968
|
+
/** @internal The attributes a query reads to judge which rows it drops ({@link dropsRows}). */
|
|
969
|
+
const judgedAttributes = (state) => [
|
|
970
|
+
...new Set([
|
|
971
|
+
...(state.liveRows?.reads ?? []),
|
|
972
|
+
...(state.keepRow !== undefined && state.skField !== undefined ? [state.skField] : []),
|
|
973
|
+
...(state.rowCheck?.reads ?? []),
|
|
974
|
+
]),
|
|
975
|
+
];
|
|
674
976
|
/**
|
|
675
977
|
* @internal A filter with an empty part where none may be (`emptyPartProblem`):
|
|
676
978
|
* the `ValidationError` every path that builds a request fails with, before
|
|
@@ -691,9 +993,16 @@ const refuseEmptyFilterParts = (state) => {
|
|
|
691
993
|
};
|
|
692
994
|
/**
|
|
693
995
|
* @internal The state a terminal runs: {@link QueryState.prepare} applied
|
|
694
|
-
* against the resolved table
|
|
996
|
+
* against the resolved table, the planner resolved and the
|
|
997
|
+
* {@link startFrom} cursor placed — as `exclusiveStartKey`, or as the plan's
|
|
998
|
+
* first step (#171).
|
|
695
999
|
*/
|
|
696
1000
|
const prepared = (state, tableName) => {
|
|
1001
|
+
// `.within()` is offered only where no prepare step is installed (an
|
|
1002
|
+
// entity's `versions` installs one).
|
|
1003
|
+
if (state.planner !== undefined && state.prepare !== undefined) {
|
|
1004
|
+
return Effect.die(new Error("[internal] A planned query cannot also have a prepare step."));
|
|
1005
|
+
}
|
|
697
1006
|
// A consistent read of a GSI — which DynamoDB only reads eventually
|
|
698
1007
|
// consistently — is DynamoDB's to reject: diagnosed by `send` (#144).
|
|
699
1008
|
// A filter with an empty part where none may be is refused before
|
|
@@ -701,21 +1010,154 @@ const prepared = (state, tableName) => {
|
|
|
701
1010
|
const filterRefusal = refuseEmptyFilterParts(state);
|
|
702
1011
|
if (filterRefusal !== undefined)
|
|
703
1012
|
return Effect.fail(filterRefusal);
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
prepare: undefined,
|
|
711
|
-
keepRow: prep.keep,
|
|
712
|
-
skConditions: swap === undefined
|
|
713
|
-
? state.skConditions
|
|
714
|
-
: state.skConditions.map((c) => "beginsWith" in c.condition && c.condition.beginsWith === swap.from
|
|
715
|
-
? { ...c, condition: { beginsWith: swap.to } }
|
|
716
|
-
: c),
|
|
717
|
-
};
|
|
1013
|
+
// A query that also reads its 1.36 key (#171) plans both passes from the
|
|
1014
|
+
// prepared state: the prepare step may swap its own condition.
|
|
1015
|
+
if (state.key136 !== undefined && state.planner === undefined) {
|
|
1016
|
+
return Effect.flatMap(applyPrepare(state, tableName), (ready) => {
|
|
1017
|
+
const resumed = resume(ready);
|
|
1018
|
+
return resumed instanceof ValidationError ? Effect.fail(resumed) : Effect.succeed(resumed);
|
|
718
1019
|
});
|
|
1020
|
+
}
|
|
1021
|
+
const resumed = resume(state);
|
|
1022
|
+
if (resumed instanceof ValidationError)
|
|
1023
|
+
return Effect.fail(resumed);
|
|
1024
|
+
return applyPrepare(resumed, tableName);
|
|
1025
|
+
};
|
|
1026
|
+
/** @internal {@link QueryState.prepare}, run against the resolved table and applied. */
|
|
1027
|
+
const applyPrepare = (state, tableName) => state.prepare === undefined
|
|
1028
|
+
? Effect.succeed(state)
|
|
1029
|
+
: Effect.map(state.prepare(tableName), (prep) => {
|
|
1030
|
+
const swap = prep.replaceBeginsWith;
|
|
1031
|
+
return {
|
|
1032
|
+
...state,
|
|
1033
|
+
prepare: undefined,
|
|
1034
|
+
pkValue: prep.pkValue ?? state.pkValue,
|
|
1035
|
+
keepRow: prep.keep,
|
|
1036
|
+
skConditions: swap === undefined
|
|
1037
|
+
? state.skConditions
|
|
1038
|
+
: state.skConditions.map((c) => "beginsWith" in c.condition && c.condition.beginsWith === swap.from
|
|
1039
|
+
? { ...c, condition: { beginsWith: swap.to } }
|
|
1040
|
+
: c),
|
|
1041
|
+
};
|
|
1042
|
+
});
|
|
1043
|
+
/**
|
|
1044
|
+
* @internal The plan of a query that also reads its 1.36 key (#171): its own
|
|
1045
|
+
* key and the 1.36 key, as two passes. `undefined` without one.
|
|
1046
|
+
*/
|
|
1047
|
+
const keyPasses = (state) => state.key136 === undefined || state.isScan
|
|
1048
|
+
? undefined
|
|
1049
|
+
: keyPassesPlanner({
|
|
1050
|
+
entityType: state.entityTypes.join(", ") || "unknown",
|
|
1051
|
+
index: state.indexName ?? "primary",
|
|
1052
|
+
pkField: state.pkField,
|
|
1053
|
+
skField: state.skField,
|
|
1054
|
+
current: { pkValue: state.pkValue, condition: state.skConditions[0]?.condition },
|
|
1055
|
+
legacy: state.key136,
|
|
1056
|
+
});
|
|
1057
|
+
/** @internal The planner a `.within()` installed, resolved against the final state. */
|
|
1058
|
+
const resolvePlanner = (state, factory) => factory({
|
|
1059
|
+
reverse: !state.scanForward,
|
|
1060
|
+
maxSkips: state.maxSkips ?? DEFAULT_MAX_SKIPS,
|
|
1061
|
+
zorder: state.zorderOptions,
|
|
1062
|
+
});
|
|
1063
|
+
/**
|
|
1064
|
+
* @internal Decode the {@link startFrom} cursor and place it (design §10.2):
|
|
1065
|
+
* a query without a planner takes a legacy cursor or a v2 `{ k }`, unchecked
|
|
1066
|
+
* (it carries no fingerprint); with one, the planner places it in the
|
|
1067
|
+
* recomputed plan. A malformed cursor, a planned query's cursor here without
|
|
1068
|
+
* a planner, or one the planner refuses is `[EDD-9094]`.
|
|
1069
|
+
*/
|
|
1070
|
+
const resume = (state) => {
|
|
1071
|
+
const entityType = state.entityTypes.join(", ") || "unknown";
|
|
1072
|
+
const cursor = state.cursorText === undefined ? undefined : Cursor.decode(state.cursorText);
|
|
1073
|
+
if (state.cursorText !== undefined && cursor === undefined) {
|
|
1074
|
+
return cursorRefusal(entityType, "it is not a cursor a query returned");
|
|
1075
|
+
}
|
|
1076
|
+
const factory = state.planner ?? keyPasses(state);
|
|
1077
|
+
if (factory === undefined) {
|
|
1078
|
+
if (cursor === undefined)
|
|
1079
|
+
return state;
|
|
1080
|
+
if (cursor.p !== undefined) {
|
|
1081
|
+
// From the same query while it also read its 1.36 key (#171) — the
|
|
1082
|
+
// fallback since turned off: a position under the current key resumes.
|
|
1083
|
+
if (cursor.p === 1) {
|
|
1084
|
+
return cursorRefusal(entityType, "it reads rows under their 1.36 key, and this query no longer does");
|
|
1085
|
+
}
|
|
1086
|
+
return cursor.k === undefined
|
|
1087
|
+
? state
|
|
1088
|
+
: { ...state, exclusiveStartKey: Cursor.keyFromWire(cursor.k) };
|
|
1089
|
+
}
|
|
1090
|
+
if (cursor.k === undefined ||
|
|
1091
|
+
cursor.f !== undefined ||
|
|
1092
|
+
cursor.s !== undefined ||
|
|
1093
|
+
cursor.t !== undefined) {
|
|
1094
|
+
return cursorRefusal(entityType, "it comes from a query with .within(), and this one has none");
|
|
1095
|
+
}
|
|
1096
|
+
return { ...state, exclusiveStartKey: Cursor.keyFromWire(cursor.k) };
|
|
1097
|
+
}
|
|
1098
|
+
const planner = resolvePlanner(state, factory);
|
|
1099
|
+
const start = planner.start(cursor);
|
|
1100
|
+
return start instanceof ValidationError ? start : { ...state, plan: { planner, start } };
|
|
1101
|
+
};
|
|
1102
|
+
/** @internal The sort key condition list of a planned request over `range`. */
|
|
1103
|
+
const plannedConditions = (state, range) => {
|
|
1104
|
+
const condition = toKeyCondition(range);
|
|
1105
|
+
return condition === undefined || state.skField === undefined
|
|
1106
|
+
? []
|
|
1107
|
+
: [{ field: state.skField, condition }];
|
|
1108
|
+
};
|
|
1109
|
+
/** @internal The state one planned read is sent with: its range and its row check. */
|
|
1110
|
+
const readState = (state, step) => ({
|
|
1111
|
+
...state,
|
|
1112
|
+
pkValue: step.pkValue ?? state.pkValue,
|
|
1113
|
+
skConditions: plannedConditions(state, step.range),
|
|
1114
|
+
rowCheck: step.rowCheck,
|
|
1115
|
+
});
|
|
1116
|
+
/**
|
|
1117
|
+
* @internal The state a probe is sent with (design §7.2): its range, no
|
|
1118
|
+
* `FilterExpression` (so `Limit: 1` returns a row whenever one exists — the
|
|
1119
|
+
* partition holds only this entity's rows), no projection or hydration. It
|
|
1120
|
+
* inherits `consistentRead`, so a consistent tuple read never sees a tuple
|
|
1121
|
+
* the probe missed.
|
|
1122
|
+
*/
|
|
1123
|
+
const probeState = (state, step) => ({
|
|
1124
|
+
...state,
|
|
1125
|
+
skConditions: plannedConditions(state, {
|
|
1126
|
+
lower: step.lower,
|
|
1127
|
+
upper: step.upper,
|
|
1128
|
+
exact: true,
|
|
1129
|
+
}),
|
|
1130
|
+
exprFilters: [],
|
|
1131
|
+
predicates: [],
|
|
1132
|
+
ignoreOwnershipFlag: true,
|
|
1133
|
+
projection: undefined,
|
|
1134
|
+
projectionPaths: undefined,
|
|
1135
|
+
hydration: undefined,
|
|
1136
|
+
plan: undefined,
|
|
1137
|
+
rowCheck: undefined,
|
|
1138
|
+
});
|
|
1139
|
+
/**
|
|
1140
|
+
* @internal A probe's command: one row, its sort key only. A probe from the
|
|
1141
|
+
* start of the partition (a binary skip tuple with no prefix given, #171
|
|
1142
|
+
* §7.5) has no sort key condition to declare `#sk`, so it is declared here.
|
|
1143
|
+
*/
|
|
1144
|
+
const probeCommand = (state, step, tableName) => {
|
|
1145
|
+
const command = buildDynamoCommand(probeState(state, step), tableName, {
|
|
1146
|
+
Limit: 1,
|
|
1147
|
+
ProjectionExpression: "#sk",
|
|
1148
|
+
});
|
|
1149
|
+
return {
|
|
1150
|
+
...command,
|
|
1151
|
+
ExpressionAttributeNames: {
|
|
1152
|
+
...command.ExpressionAttributeNames,
|
|
1153
|
+
...(state.skField === undefined ? {} : { "#sk": state.skField }),
|
|
1154
|
+
},
|
|
1155
|
+
};
|
|
1156
|
+
};
|
|
1157
|
+
/** @internal The cursor of a planned query at `step`: v2, or `None` once done. */
|
|
1158
|
+
const plannedCursor = (planner, step) => {
|
|
1159
|
+
const cursor = planner.cursor(step);
|
|
1160
|
+
return cursor === null ? Option.none() : Option.some(Cursor.encodeV2(cursor));
|
|
719
1161
|
};
|
|
720
1162
|
/**
|
|
721
1163
|
* @internal Send one request of a terminal. DynamoDB's rejection of what the
|
|
@@ -749,6 +1191,41 @@ const send = (client, state, cmd) => {
|
|
|
749
1191
|
},
|
|
750
1192
|
}));
|
|
751
1193
|
};
|
|
1194
|
+
/**
|
|
1195
|
+
* @internal What a response says it examined and kept: `ScannedCount` and
|
|
1196
|
+
* `Count`, each the rows returned when the response lacks it (mocks, older
|
|
1197
|
+
* emulators).
|
|
1198
|
+
*/
|
|
1199
|
+
const responseCounts = (response) => {
|
|
1200
|
+
const returned = response.Items?.length ?? 0;
|
|
1201
|
+
return {
|
|
1202
|
+
scannedCount: response.ScannedCount ?? returned,
|
|
1203
|
+
count: response.Count ?? returned,
|
|
1204
|
+
};
|
|
1205
|
+
};
|
|
1206
|
+
/**
|
|
1207
|
+
* @internal Report one request to the query's observers ({@link onRequest}),
|
|
1208
|
+
* in order, once its rows have been examined. An observer's failure (or throw)
|
|
1209
|
+
* is a defect. They run outside the terminal's consumed-capacity operation, so
|
|
1210
|
+
* a DynamoDB call one makes is not charged to the query.
|
|
1211
|
+
*/
|
|
1212
|
+
const notify = (state, observed) => {
|
|
1213
|
+
if (state.observers.length === 0)
|
|
1214
|
+
return Effect.void;
|
|
1215
|
+
const [lower, upper] = displayKeyCondition(state.isScan ? undefined : state.skConditions[0]?.condition);
|
|
1216
|
+
const event = {
|
|
1217
|
+
kind: observed.kind,
|
|
1218
|
+
request: observed.request,
|
|
1219
|
+
lower,
|
|
1220
|
+
upper,
|
|
1221
|
+
limit: observed.limit,
|
|
1222
|
+
scannedCount: observed.scannedCount,
|
|
1223
|
+
count: observed.count,
|
|
1224
|
+
accepted: observed.accepted,
|
|
1225
|
+
discarded: observed.discarded ?? false,
|
|
1226
|
+
};
|
|
1227
|
+
return Capacity.detached(Effect.forEach(state.observers, (observer) => Effect.orDie(Effect.suspend(() => observer(event))), { discard: true }));
|
|
1228
|
+
};
|
|
752
1229
|
/** @internal Does the decoded item pass every client-side predicate? */
|
|
753
1230
|
const accepts = (state, item) => {
|
|
754
1231
|
for (const predicate of state.predicates) {
|
|
@@ -769,6 +1246,11 @@ const resumeKeyNames = (state, lastEvaluatedKey) => lastEvaluatedKey != null ? O
|
|
|
769
1246
|
* key (e.g. a projection dropped it) — the caller then falls back.
|
|
770
1247
|
*/
|
|
771
1248
|
const cursorFromItem = (item, names) => {
|
|
1249
|
+
const key = keyOfItem(item, names);
|
|
1250
|
+
return key === undefined ? undefined : encodeCursor(key);
|
|
1251
|
+
};
|
|
1252
|
+
/** @internal The resume key `names` pick from `item`, or `undefined` when it lacks one. */
|
|
1253
|
+
const keyOfItem = (item, names) => {
|
|
772
1254
|
if (!names || names.length === 0)
|
|
773
1255
|
return undefined;
|
|
774
1256
|
const key = {};
|
|
@@ -778,7 +1260,7 @@ const cursorFromItem = (item, names) => {
|
|
|
778
1260
|
return undefined;
|
|
779
1261
|
key[name] = value;
|
|
780
1262
|
}
|
|
781
|
-
return
|
|
1263
|
+
return key;
|
|
782
1264
|
};
|
|
783
1265
|
/**
|
|
784
1266
|
* @internal Key attributes to add to an active ProjectionExpression so an
|
|
@@ -795,15 +1277,31 @@ forCursor) => {
|
|
|
795
1277
|
if (projected.size === 0)
|
|
796
1278
|
return [];
|
|
797
1279
|
const borrowed = new Set(!forCursor || state.limitValue === undefined || !state.keyFields ? [] : state.keyFields);
|
|
798
|
-
// A query that drops rows reads what it judges them by (#133).
|
|
799
|
-
|
|
800
|
-
borrowed.add(state.skField);
|
|
801
|
-
for (const attr of state.liveRows?.reads ?? [])
|
|
1280
|
+
// A query that drops rows reads what it judges them by (#133, #171).
|
|
1281
|
+
for (const attr of judgedAttributes(state))
|
|
802
1282
|
borrowed.add(attr);
|
|
803
1283
|
// …and a raw-row hook reads what it dispatches on (a collection's `__edd_e__`).
|
|
804
1284
|
for (const attr of rawRowReads(state))
|
|
805
1285
|
borrowed.add(attr);
|
|
806
|
-
|
|
1286
|
+
const inside = selectedInside(state);
|
|
1287
|
+
return [...borrowed].filter((field) => !projected.has(field) || inside.has(field));
|
|
1288
|
+
};
|
|
1289
|
+
/**
|
|
1290
|
+
* @internal The attributes a projection selects only paths inside of
|
|
1291
|
+
* (`ref.name`), never whole. One a query also borrows — a row check reading a
|
|
1292
|
+
* ref-derived composite from its ref (#171) — is read whole, since DynamoDB
|
|
1293
|
+
* rejects overlapping projection paths, and handed back whole: stripping it
|
|
1294
|
+
* would drop what the caller selected.
|
|
1295
|
+
*/
|
|
1296
|
+
const selectedInside = (state) => {
|
|
1297
|
+
const paths = state.projectionPaths ?? [];
|
|
1298
|
+
const whole = new Set([
|
|
1299
|
+
...(state.projection ?? []),
|
|
1300
|
+
...paths.filter((segments) => segments.length === 1).map((segments) => String(segments[0])),
|
|
1301
|
+
]);
|
|
1302
|
+
return new Set(paths
|
|
1303
|
+
.filter((segments) => segments.length > 1 && !whole.has(String(segments[0])))
|
|
1304
|
+
.map((segments) => String(segments[0])));
|
|
807
1305
|
};
|
|
808
1306
|
/**
|
|
809
1307
|
* @internal Examine one response's rows in order, as `execute` and `paginate`
|
|
@@ -815,14 +1313,24 @@ forCursor) => {
|
|
|
815
1313
|
* hydration (its item gone) widens the window by one. With a predicate,
|
|
816
1314
|
* acceptance is only known after decode, so the whole response is examined
|
|
817
1315
|
* (#122). `stoppedAt` is the index of the row that filled the budget, or -1.
|
|
1316
|
+
* `accepted` counts the rows that became items: an excluded row, a hydration
|
|
1317
|
+
* miss and a predicate's rejection are not, and neither is a row past the
|
|
1318
|
+
* budget, left unexamined.
|
|
818
1319
|
*/
|
|
819
1320
|
const examinePage = (client, tableName, state, returned, budget, borrowedFields) => Effect.gen(function* () {
|
|
1321
|
+
// Nothing left to accept. Without this the loop below never advances
|
|
1322
|
+
// (`end` stays at `next`) — or, under a predicate, accepts one row past
|
|
1323
|
+
// the budget. No terminal asks; a guard all the same (#171).
|
|
1324
|
+
if (budget === 0)
|
|
1325
|
+
return { items: [], stoppedAt: -1, accepted: 0 };
|
|
820
1326
|
const hasPredicate = state.predicates.length > 0 || dropsRows(state);
|
|
821
1327
|
// Borrowed attributes the raw-row hook reads are stripped after it ran:
|
|
822
1328
|
// borrowed into the request's projection, or — on a `BatchGet`-hydrated
|
|
823
1329
|
// query — into the hydrating fetch's (see `hydrateRows`).
|
|
824
1330
|
const hookReads = new Set(rawRowReads(state));
|
|
825
1331
|
const strippedAfterHook = new Set(borrowedFields.filter((field) => hookReads.has(field)));
|
|
1332
|
+
// Borrowed whole, but selected inside: handed back whole.
|
|
1333
|
+
const kept = selectedInside(state);
|
|
826
1334
|
if (state.hydration?._tag === "BatchGet") {
|
|
827
1335
|
const projected = new Set([
|
|
828
1336
|
...(state.projection ?? []),
|
|
@@ -851,9 +1359,10 @@ const examinePage = (client, tableName, state, returned, budget, borrowedFields)
|
|
|
851
1359
|
if (source === undefined)
|
|
852
1360
|
continue;
|
|
853
1361
|
const raw = fromAttributeMap(source);
|
|
854
|
-
for (const field of borrowedFields)
|
|
855
|
-
if (!hookReads.has(field))
|
|
1362
|
+
for (const field of borrowedFields) {
|
|
1363
|
+
if (!hookReads.has(field) && !kept.has(field))
|
|
856
1364
|
delete raw[field];
|
|
1365
|
+
}
|
|
857
1366
|
const item = yield* state.decoder(raw);
|
|
858
1367
|
for (const field of strippedAfterHook)
|
|
859
1368
|
delete raw[field];
|
|
@@ -861,12 +1370,12 @@ const examinePage = (client, tableName, state, returned, budget, borrowedFields)
|
|
|
861
1370
|
continue;
|
|
862
1371
|
items.push(item);
|
|
863
1372
|
if (budget !== undefined && items.length >= budget) {
|
|
864
|
-
return { items, stoppedAt: positions[k] };
|
|
1373
|
+
return { items, stoppedAt: positions[k], accepted: items.length };
|
|
865
1374
|
}
|
|
866
1375
|
}
|
|
867
1376
|
next = end;
|
|
868
1377
|
}
|
|
869
|
-
return { items, stoppedAt: -1 };
|
|
1378
|
+
return { items, stoppedAt: -1, accepted: items.length };
|
|
870
1379
|
});
|
|
871
1380
|
/**
|
|
872
1381
|
* @internal The subject of a query for consumed-capacity reports: its
|
|
@@ -887,10 +1396,70 @@ export const operationOptions = (self) => ({
|
|
|
887
1396
|
* the key range (or {@link maxPages} requests).
|
|
888
1397
|
*/
|
|
889
1398
|
export const collect = (self) => Capacity.delegate("Query.collect", operationOptions(self), Effect.gen(function* () {
|
|
890
|
-
const
|
|
891
|
-
const
|
|
1399
|
+
const client = yield* Capacity.client;
|
|
1400
|
+
const tableName = self._state.resolveTableName
|
|
1401
|
+
? yield* self._state.resolveTableName
|
|
1402
|
+
: self._state.tableName;
|
|
1403
|
+
const state = yield* prepared(self._state, tableName).pipe(Effect.provideService(DynamoClient, client));
|
|
1404
|
+
const ranges = drainedRanges(state);
|
|
1405
|
+
if (ranges !== undefined)
|
|
1406
|
+
return yield* collectRanges(client, tableName, state, ranges);
|
|
1407
|
+
const pages = yield* Stream.runCollect(pageStream(client, tableName, state));
|
|
892
1408
|
return pages.flat();
|
|
893
1409
|
}));
|
|
1410
|
+
/**
|
|
1411
|
+
* @internal The ranges a `collect` drains independently (design §9.3): a
|
|
1412
|
+
* precise plan read to its end — no `limit`, no `maxPages`, no skip tuple —
|
|
1413
|
+
* so every range is read whole and none can be wasted. `undefined` otherwise.
|
|
1414
|
+
*/
|
|
1415
|
+
const drainedRanges = (state) => {
|
|
1416
|
+
const plan = state.plan;
|
|
1417
|
+
if (plan === undefined || state.limitValue !== undefined || state.maxPagesValue != null) {
|
|
1418
|
+
return undefined;
|
|
1419
|
+
}
|
|
1420
|
+
const start = plan.start;
|
|
1421
|
+
if (start._tag !== "Read" || start.at.mode !== "precise" || start.planIndex === undefined) {
|
|
1422
|
+
return undefined;
|
|
1423
|
+
}
|
|
1424
|
+
return [start, ...plan.planner.following(start, Number.POSITIVE_INFINITY)];
|
|
1425
|
+
};
|
|
1426
|
+
/**
|
|
1427
|
+
* @internal `collect` over independent ranges: each drained by its own
|
|
1428
|
+
* `LastEvaluatedKey` loop, up to {@link planConcurrency} at once, the items
|
|
1429
|
+
* concatenated in plan order. The first failure fails the call and interrupts
|
|
1430
|
+
* the other reads. Requests are numbered as they are sent.
|
|
1431
|
+
*/
|
|
1432
|
+
const collectRanges = (client, tableName, state, ranges) => {
|
|
1433
|
+
let requests = 0;
|
|
1434
|
+
const drain = (first) => Effect.gen(function* () {
|
|
1435
|
+
const items = [];
|
|
1436
|
+
let step = first;
|
|
1437
|
+
let est = emptyEstimator;
|
|
1438
|
+
while (true) {
|
|
1439
|
+
const request = ++requests;
|
|
1440
|
+
const response = yield* sendRead(client, tableName, state, step, undefined, est, est.requests + 1, false);
|
|
1441
|
+
const page = yield* examinePage(client, tableName, response.state, response.returned, undefined, response.borrowedFields);
|
|
1442
|
+
items.push(...page.items);
|
|
1443
|
+
est = observeEstimator(est, {
|
|
1444
|
+
scanned: response.counts.scannedCount,
|
|
1445
|
+
accepted: page.accepted,
|
|
1446
|
+
limitSent: response.limitSent,
|
|
1447
|
+
});
|
|
1448
|
+
yield* notify(response.state, {
|
|
1449
|
+
kind: "read",
|
|
1450
|
+
request,
|
|
1451
|
+
limit: response.limitSent,
|
|
1452
|
+
...response.counts,
|
|
1453
|
+
accepted: page.accepted,
|
|
1454
|
+
});
|
|
1455
|
+
const lek = response.lastEvaluatedKey;
|
|
1456
|
+
if (lek === undefined)
|
|
1457
|
+
return items;
|
|
1458
|
+
step = readFrom(step, lek);
|
|
1459
|
+
}
|
|
1460
|
+
});
|
|
1461
|
+
return Effect.map(Effect.forEach(ranges, (range) => drain(range), { concurrency: planConcurrency(state) }), (pages) => pages.flat());
|
|
1462
|
+
};
|
|
894
1463
|
/**
|
|
895
1464
|
* Execute one page and return a {@link Page} with an opaque cursor.
|
|
896
1465
|
*
|
|
@@ -899,14 +1468,20 @@ export const collect = (self) => Capacity.delegate("Query.collect", operationOpt
|
|
|
899
1468
|
* accumulates until `n` items are accepted, the key range is exhausted, or
|
|
900
1469
|
* {@link maxPages} requests have been made.
|
|
901
1470
|
*
|
|
902
|
-
* The cursor resumes after the last item actually returned —
|
|
903
|
-
*
|
|
1471
|
+
* The cursor resumes after the last item actually returned — `None` means
|
|
1472
|
+
* genuinely exhausted. A `Some` can still lead to an empty page:
|
|
1473
|
+
* a request that stops at its `Limit` cannot tell whether another match
|
|
1474
|
+
* follows, which is likelier when {@link limit} sizes requests under a
|
|
1475
|
+
* filter. Combine with {@link startFrom} to iterate:
|
|
904
1476
|
*
|
|
905
1477
|
* ```ts
|
|
906
1478
|
* const first = yield* query.pipe(Query.limit(25), Query.execute)
|
|
907
|
-
*
|
|
908
|
-
*
|
|
909
|
-
*
|
|
1479
|
+
* // Some(page) when there is a next page, None when `first` was the last
|
|
1480
|
+
* const second = yield* Effect.transposeOption(
|
|
1481
|
+
* Option.map(first.cursor, (cursor) =>
|
|
1482
|
+
* query.pipe(Query.limit(25), Query.startFrom(cursor), Query.execute),
|
|
1483
|
+
* ),
|
|
1484
|
+
* )
|
|
910
1485
|
* ```
|
|
911
1486
|
*/
|
|
912
1487
|
export const execute = (self) => Capacity.delegate("Query.execute", operationOptions(self), executeCore(self));
|
|
@@ -919,8 +1494,10 @@ const executeCore = (self) => Effect.gen(function* () {
|
|
|
919
1494
|
const indexState = indexQueryState(state);
|
|
920
1495
|
const limitValue = state.limitValue;
|
|
921
1496
|
if (limitValue !== undefined && limitValue <= 0) {
|
|
922
|
-
return { items: [], cursor:
|
|
1497
|
+
return { items: [], cursor: Option.none() };
|
|
923
1498
|
}
|
|
1499
|
+
if (state.plan !== undefined)
|
|
1500
|
+
return yield* executePlanned(client, tableName, state, state.plan);
|
|
924
1501
|
// Key attributes borrowed into an active projection so an over-reading
|
|
925
1502
|
// request can still rebuild a cursor. Stripped again before decoding.
|
|
926
1503
|
const borrowedFields = cursorProjectionFields(indexState, true);
|
|
@@ -928,10 +1505,12 @@ const executeCore = (self) => Effect.gen(function* () {
|
|
|
928
1505
|
let startKey = state.exclusiveStartKey;
|
|
929
1506
|
let pageCount = 0;
|
|
930
1507
|
let cursor = null;
|
|
1508
|
+
let est = emptyEstimator;
|
|
931
1509
|
while (true) {
|
|
932
1510
|
pageCount++;
|
|
933
1511
|
const remaining = limitValue === undefined ? undefined : limitValue - items.length;
|
|
934
|
-
const
|
|
1512
|
+
const limitSent = requestLimit(state, remaining, est, pageCount);
|
|
1513
|
+
const cmd = buildDynamoCommand(indexState, tableName, { ExclusiveStartKey: startKey, Limit: limitSent }, borrowedFields);
|
|
935
1514
|
const result = yield* send(client, state, cmd);
|
|
936
1515
|
const returned = (result.Items ?? []);
|
|
937
1516
|
const lastEvaluatedKey = result.LastEvaluatedKey;
|
|
@@ -939,6 +1518,19 @@ const executeCore = (self) => Effect.gen(function* () {
|
|
|
939
1518
|
const page = yield* examinePage(client, tableName, state, returned, remaining, borrowedFields);
|
|
940
1519
|
items.push(...page.items);
|
|
941
1520
|
const stoppedAt = page.stoppedAt;
|
|
1521
|
+
const counts = responseCounts(result);
|
|
1522
|
+
est = observeEstimator(est, {
|
|
1523
|
+
scanned: counts.scannedCount,
|
|
1524
|
+
accepted: page.accepted,
|
|
1525
|
+
limitSent,
|
|
1526
|
+
});
|
|
1527
|
+
yield* notify(state, {
|
|
1528
|
+
kind: "read",
|
|
1529
|
+
request: pageCount,
|
|
1530
|
+
limit: limitSent,
|
|
1531
|
+
...counts,
|
|
1532
|
+
accepted: page.accepted,
|
|
1533
|
+
});
|
|
942
1534
|
// Stopped inside the page: the surplus was discarded, so the cursor has
|
|
943
1535
|
// to be rebuilt from the last item handed back rather than from
|
|
944
1536
|
// LastEvaluatedKey. `stoppedAt` is the last row ACCEPTED, which is what
|
|
@@ -960,13 +1552,169 @@ const executeCore = (self) => Effect.gen(function* () {
|
|
|
960
1552
|
}
|
|
961
1553
|
startKey = lastEvaluatedKey;
|
|
962
1554
|
}
|
|
963
|
-
return { items, cursor };
|
|
1555
|
+
return { items, cursor: Option.fromNullOr(cursor) };
|
|
1556
|
+
});
|
|
1557
|
+
/**
|
|
1558
|
+
* @internal Send one planned read (design §9.2): the step's range and start
|
|
1559
|
+
* key over the query's state, its `Limit` sized as any request's — `read` is
|
|
1560
|
+
* its number among the terminal's reads.
|
|
1561
|
+
*/
|
|
1562
|
+
const sendRead = (client, tableName, state, step, remaining, est, read, forCursor) => Effect.gen(function* () {
|
|
1563
|
+
const sentState = readState(state, step);
|
|
1564
|
+
const indexState = indexQueryState(sentState);
|
|
1565
|
+
const borrowedFields = cursorProjectionFields(indexState, forCursor);
|
|
1566
|
+
const limitSent = readLimit(sentState, step, remaining, est, read);
|
|
1567
|
+
const cmd = buildDynamoCommand(indexState, tableName, { ExclusiveStartKey: step.startKey, Limit: limitSent }, borrowedFields);
|
|
1568
|
+
const result = yield* send(client, sentState, cmd);
|
|
1569
|
+
return {
|
|
1570
|
+
state: sentState,
|
|
1571
|
+
borrowedFields,
|
|
1572
|
+
limitSent,
|
|
1573
|
+
returned: (result.Items ?? []),
|
|
1574
|
+
lastEvaluatedKey: result.LastEvaluatedKey,
|
|
1575
|
+
counts: responseCounts(result),
|
|
1576
|
+
};
|
|
1577
|
+
});
|
|
1578
|
+
/**
|
|
1579
|
+
* @internal Send one probe (design §7.2) and report it: the sort key of the
|
|
1580
|
+
* first row in its range, or `undefined` when the range is empty.
|
|
1581
|
+
*/
|
|
1582
|
+
const sendProbe = (client, tableName, state, step, request) => Effect.gen(function* () {
|
|
1583
|
+
const sentState = probeState(state, step);
|
|
1584
|
+
const result = yield* send(client, sentState, probeCommand(state, step, tableName));
|
|
1585
|
+
yield* notify(sentState, {
|
|
1586
|
+
kind: "probe",
|
|
1587
|
+
request,
|
|
1588
|
+
limit: 1,
|
|
1589
|
+
...responseCounts(result),
|
|
1590
|
+
accepted: 0,
|
|
1591
|
+
});
|
|
1592
|
+
const value = state.skField === undefined ? undefined : result.Items?.[0]?.[state.skField];
|
|
1593
|
+
return value?.S ?? value?.B;
|
|
1594
|
+
});
|
|
1595
|
+
/**
|
|
1596
|
+
* @internal `execute` for a planned query (design §9.2): the planner names
|
|
1597
|
+
* each step, this loop sends it. The limit and `maxPages` are checked before
|
|
1598
|
+
* every request, so a probe is never sent when the call is about to return —
|
|
1599
|
+
* the cursor then names the pending step instead.
|
|
1600
|
+
*
|
|
1601
|
+
* Precise ranges are read in windows: a range and those following it, sent
|
|
1602
|
+
* concurrently and examined in plan order. A later member starts fresh (no
|
|
1603
|
+
* start key), so its rows are usable only while every earlier member finished
|
|
1604
|
+
* its range in one page; the window doubles after a window where all did (up
|
|
1605
|
+
* to {@link planConcurrency}) and drops back to one on any
|
|
1606
|
+
* `LastEvaluatedKey`. Without a `limit`, a page is one window.
|
|
1607
|
+
*/
|
|
1608
|
+
const executePlanned = (client, tableName, state, plan) => Effect.gen(function* () {
|
|
1609
|
+
const { planner } = plan;
|
|
1610
|
+
const limitValue = state.limitValue;
|
|
1611
|
+
const maxPages = state.maxPagesValue;
|
|
1612
|
+
const cap = planConcurrency(state);
|
|
1613
|
+
const items = [];
|
|
1614
|
+
const filled = () => limitValue !== undefined && items.length >= limitValue;
|
|
1615
|
+
let step = plan.start;
|
|
1616
|
+
let requests = 0;
|
|
1617
|
+
let est = emptyEstimator;
|
|
1618
|
+
let window = 1;
|
|
1619
|
+
while (true) {
|
|
1620
|
+
if (step._tag === "Done")
|
|
1621
|
+
return { items, cursor: Option.none() };
|
|
1622
|
+
if (filled() || (maxPages != null && requests >= maxPages)) {
|
|
1623
|
+
return { items, cursor: plannedCursor(planner, step) };
|
|
1624
|
+
}
|
|
1625
|
+
if (step._tag === "Probe") {
|
|
1626
|
+
requests++;
|
|
1627
|
+
step = planner.afterProbe(step, yield* sendProbe(client, tableName, state, step, requests));
|
|
1628
|
+
continue;
|
|
1629
|
+
}
|
|
1630
|
+
// The step, plus the ranges following it within the window and the
|
|
1631
|
+
// `maxPages` budget (composite and jump plans have none).
|
|
1632
|
+
const room = maxPages == null ? window : Math.min(window, maxPages - requests);
|
|
1633
|
+
const batch = [step, ...planner.following(step, room - 1)];
|
|
1634
|
+
const remainingAtSend = limitValue === undefined ? undefined : limitValue - items.length;
|
|
1635
|
+
const sent = yield* Effect.forEach(batch, (member, i) => Effect.result(sendRead(client, tableName, state, member, remainingAtSend, est, est.requests + i + 1, true)), { concurrency: batch.length });
|
|
1636
|
+
/** Report the members after `i` that were read and won't be used (§3.6). */
|
|
1637
|
+
const discardAfter = (i) => Effect.forEach(sent.slice(i + 1), (outcome, j) => Result.isFailure(outcome)
|
|
1638
|
+
? Effect.void
|
|
1639
|
+
: notify(outcome.success.state, {
|
|
1640
|
+
kind: "read",
|
|
1641
|
+
request: requests + i + j + 2,
|
|
1642
|
+
limit: outcome.success.limitSent,
|
|
1643
|
+
...outcome.success.counts,
|
|
1644
|
+
accepted: 0,
|
|
1645
|
+
discarded: true,
|
|
1646
|
+
}), { discard: true });
|
|
1647
|
+
let next = step;
|
|
1648
|
+
let continued = false;
|
|
1649
|
+
for (let i = 0; i < batch.length; i++) {
|
|
1650
|
+
const member = batch[i];
|
|
1651
|
+
const outcome = sent[i];
|
|
1652
|
+
if (Result.isFailure(outcome)) {
|
|
1653
|
+
yield* discardAfter(i);
|
|
1654
|
+
// A member after a usable prefix was throttled: hand back the prefix,
|
|
1655
|
+
// the cursor naming that member as not started — the next call
|
|
1656
|
+
// retries it and surfaces the error if it persists (§9.5). Anything
|
|
1657
|
+
// else, or the first member, fails the call.
|
|
1658
|
+
if (i > 0 && isThrottled(outcome.failure)) {
|
|
1659
|
+
return { items, cursor: plannedCursor(planner, member) };
|
|
1660
|
+
}
|
|
1661
|
+
return yield* Effect.fail(outcome.failure);
|
|
1662
|
+
}
|
|
1663
|
+
const response = outcome.success;
|
|
1664
|
+
// Never examined with nothing left: the loop returns as soon as the
|
|
1665
|
+
// limit is reached, discarding later members' results.
|
|
1666
|
+
const remaining = limitValue === undefined ? undefined : limitValue - items.length;
|
|
1667
|
+
const page = yield* examinePage(client, tableName, response.state, response.returned, remaining, response.borrowedFields);
|
|
1668
|
+
items.push(...page.items);
|
|
1669
|
+
est = observeEstimator(est, {
|
|
1670
|
+
scanned: response.counts.scannedCount,
|
|
1671
|
+
accepted: page.accepted,
|
|
1672
|
+
limitSent: response.limitSent,
|
|
1673
|
+
});
|
|
1674
|
+
yield* notify(response.state, {
|
|
1675
|
+
kind: "read",
|
|
1676
|
+
request: requests + i + 1,
|
|
1677
|
+
limit: response.limitSent,
|
|
1678
|
+
...response.counts,
|
|
1679
|
+
accepted: page.accepted,
|
|
1680
|
+
});
|
|
1681
|
+
const lek = response.lastEvaluatedKey;
|
|
1682
|
+
if (filled()) {
|
|
1683
|
+
yield* discardAfter(i);
|
|
1684
|
+
// Stopped inside the page: resume after the last row handed back
|
|
1685
|
+
// (#122), inside this member's range.
|
|
1686
|
+
if (page.stoppedAt >= 0 && page.stoppedAt < response.returned.length - 1) {
|
|
1687
|
+
const key = keyOfItem(response.returned[page.stoppedAt], resumeKeyNames(state, lek)) ?? lek;
|
|
1688
|
+
return {
|
|
1689
|
+
items,
|
|
1690
|
+
cursor: plannedCursor(planner, key === undefined ? planner.afterRead(member, undefined) : readFrom(member, key)),
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
// The member's own next step — pure: a pending probe is not sent.
|
|
1694
|
+
return { items, cursor: plannedCursor(planner, planner.afterRead(member, lek)) };
|
|
1695
|
+
}
|
|
1696
|
+
next = planner.afterRead(member, lek);
|
|
1697
|
+
// A later member started fresh, so it can't be resumed past this one.
|
|
1698
|
+
if (lek !== undefined) {
|
|
1699
|
+
yield* discardAfter(i);
|
|
1700
|
+
continued = true;
|
|
1701
|
+
break;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
requests += batch.length;
|
|
1705
|
+
window = continued ? 1 : Math.min(window * 2, cap);
|
|
1706
|
+
step = next;
|
|
1707
|
+
// Without a limit, a page is one window, as one read is without a plan.
|
|
1708
|
+
if (limitValue === undefined)
|
|
1709
|
+
return { items, cursor: plannedCursor(planner, step) };
|
|
1710
|
+
}
|
|
964
1711
|
});
|
|
965
1712
|
/**
|
|
966
1713
|
* Execute the query and return a Stream of page arrays.
|
|
967
|
-
* A page is one DynamoDB request (of {@link pageSize} rows, when set
|
|
968
|
-
*
|
|
969
|
-
*
|
|
1714
|
+
* A page is one DynamoDB request (of {@link pageSize} rows, when set, or as
|
|
1715
|
+
* {@link limit} sizes it under a filter); the stream ends once {@link limit}
|
|
1716
|
+
* items have been emitted in total, the key range is exhausted, or
|
|
1717
|
+
* {@link maxPages} requests have been made.
|
|
970
1718
|
*/
|
|
971
1719
|
export const paginate = (self) => Effect.flatMap(paginateInternal(self), (stream) => Capacity.operationStream("Query.paginate", operationOptions(self), stream, "delegate"));
|
|
972
1720
|
const paginateInternal = (self) => Effect.gen(function* () {
|
|
@@ -982,22 +1730,36 @@ const pageStream = (client, tableName, state) => {
|
|
|
982
1730
|
if (limitValue !== undefined && limitValue <= 0) {
|
|
983
1731
|
return Stream.empty;
|
|
984
1732
|
}
|
|
1733
|
+
if (state.plan !== undefined)
|
|
1734
|
+
return plannedPageStream(client, tableName, state, state.plan);
|
|
985
1735
|
return Stream.paginate({
|
|
986
1736
|
key: state.exclusiveStartKey,
|
|
987
1737
|
pageCount: 0,
|
|
988
1738
|
emitted: 0,
|
|
1739
|
+
estimator: emptyEstimator,
|
|
989
1740
|
}, (pageState) => Effect.gen(function* () {
|
|
990
1741
|
const pageCount = pageState.pageCount + 1;
|
|
991
1742
|
const remaining = limitValue === undefined ? undefined : limitValue - pageState.emitted;
|
|
992
1743
|
const indexState = indexQueryState(state);
|
|
993
1744
|
const borrowedFields = cursorProjectionFields(indexState, false);
|
|
994
|
-
const
|
|
995
|
-
|
|
996
|
-
Limit: computeRequestLimit(state, remaining, pageCount),
|
|
997
|
-
}, borrowedFields);
|
|
1745
|
+
const limitSent = requestLimit(state, remaining, pageState.estimator, pageCount);
|
|
1746
|
+
const cmd = buildDynamoCommand(indexState, tableName, { ExclusiveStartKey: pageState.key, Limit: limitSent }, borrowedFields);
|
|
998
1747
|
const result = yield* send(client, state, cmd);
|
|
999
1748
|
const returned = (result.Items ?? []);
|
|
1000
|
-
const { items: decoded } = yield* examinePage(client, tableName, state, returned, remaining, borrowedFields);
|
|
1749
|
+
const { items: decoded, accepted } = yield* examinePage(client, tableName, state, returned, remaining, borrowedFields);
|
|
1750
|
+
const counts = responseCounts(result);
|
|
1751
|
+
const estimator = observeEstimator(pageState.estimator, {
|
|
1752
|
+
scanned: counts.scannedCount,
|
|
1753
|
+
accepted,
|
|
1754
|
+
limitSent,
|
|
1755
|
+
});
|
|
1756
|
+
yield* notify(state, {
|
|
1757
|
+
kind: "read",
|
|
1758
|
+
request: pageCount,
|
|
1759
|
+
limit: limitSent,
|
|
1760
|
+
...counts,
|
|
1761
|
+
accepted,
|
|
1762
|
+
});
|
|
1001
1763
|
const emitted = pageState.emitted + decoded.length;
|
|
1002
1764
|
const hasMorePages = result.LastEvaluatedKey != null;
|
|
1003
1765
|
const maxPagesReached = state.maxPagesValue != null && pageCount >= state.maxPagesValue;
|
|
@@ -1007,11 +1769,144 @@ const pageStream = (client, tableName, state) => {
|
|
|
1007
1769
|
key: result.LastEvaluatedKey,
|
|
1008
1770
|
pageCount,
|
|
1009
1771
|
emitted,
|
|
1772
|
+
estimator,
|
|
1010
1773
|
})
|
|
1011
1774
|
: Option.none();
|
|
1012
1775
|
return [[decoded], nextState];
|
|
1013
1776
|
}));
|
|
1014
1777
|
};
|
|
1778
|
+
/**
|
|
1779
|
+
* @internal `pageStream` for a planned query (design §9.3). The page state is
|
|
1780
|
+
* plain data, so the stream stays re-runnable:
|
|
1781
|
+
*
|
|
1782
|
+
* - `open` — the ranges being read, in plan order, each with its next read
|
|
1783
|
+
* and at most one page read but not yet emitted. A later range's page waits
|
|
1784
|
+
* until every earlier range is drained, so a stream never discards a read.
|
|
1785
|
+
* - `step` — the plan's next step once `open` is drained: read only while
|
|
1786
|
+
* `open` is empty.
|
|
1787
|
+
* - `window` — how many ranges may be open: {@link planConcurrency}. Only a
|
|
1788
|
+
* precise plan has ranges `following` a read; composite, naive and jump
|
|
1789
|
+
* plans read one range at a time.
|
|
1790
|
+
*
|
|
1791
|
+
* Each pull opens ranges when none is open (sending the probes before the
|
|
1792
|
+
* next read), sends one read for every open range without a page, within
|
|
1793
|
+
* `maxPages`, then emits the head range's pages in order. A pull that reads
|
|
1794
|
+
* nothing emits nothing.
|
|
1795
|
+
*/
|
|
1796
|
+
const plannedPageStream = (client, tableName, state, plan) => {
|
|
1797
|
+
const { planner } = plan;
|
|
1798
|
+
const limitValue = state.limitValue;
|
|
1799
|
+
const maxPages = state.maxPagesValue;
|
|
1800
|
+
const initial = {
|
|
1801
|
+
step: plan.start,
|
|
1802
|
+
open: [],
|
|
1803
|
+
pageCount: 0,
|
|
1804
|
+
emitted: 0,
|
|
1805
|
+
estimator: emptyEstimator,
|
|
1806
|
+
window: planConcurrency(state),
|
|
1807
|
+
};
|
|
1808
|
+
return Stream.paginate(initial, (pageState) => Effect.gen(function* () {
|
|
1809
|
+
let { step, open, pageCount, emitted, estimator } = pageState;
|
|
1810
|
+
const { window } = pageState;
|
|
1811
|
+
const budget = () => maxPages == null || pageCount < maxPages;
|
|
1812
|
+
const nothing = [[], Option.none()];
|
|
1813
|
+
// Open ranges: the probes up to the next read, then the read and the
|
|
1814
|
+
// ranges following it, up to the window.
|
|
1815
|
+
if (open.length === 0) {
|
|
1816
|
+
while (step._tag === "Probe" && budget()) {
|
|
1817
|
+
pageCount++;
|
|
1818
|
+
step = planner.afterProbe(step, yield* sendProbe(client, tableName, state, step, pageCount));
|
|
1819
|
+
}
|
|
1820
|
+
if (step._tag !== "Read" || !budget())
|
|
1821
|
+
return nothing;
|
|
1822
|
+
open = [step, ...planner.following(step, window - 1)].map((member) => ({
|
|
1823
|
+
step: member,
|
|
1824
|
+
page: undefined,
|
|
1825
|
+
}));
|
|
1826
|
+
}
|
|
1827
|
+
// One read for every open range without a page, in plan order.
|
|
1828
|
+
const unread = open.flatMap((range, i) => (range.page === undefined ? [i] : []));
|
|
1829
|
+
const sending = maxPages == null ? unread : unread.slice(0, maxPages - pageCount);
|
|
1830
|
+
const remainingAtSend = limitValue === undefined ? undefined : limitValue - emitted;
|
|
1831
|
+
// Reads sent before this pull and not yet emitted (#133 numbers reads).
|
|
1832
|
+
const pending = open.length - unread.length;
|
|
1833
|
+
const responses = yield* Effect.forEach(sending, (i, n) => sendRead(client, tableName, state, open[i].step, remainingAtSend, estimator, estimator.requests + pending + n + 1, false), { concurrency: Math.max(sending.length, 1) });
|
|
1834
|
+
const sentAt = pageCount;
|
|
1835
|
+
open = open.map((range, i) => {
|
|
1836
|
+
const n = sending.indexOf(i);
|
|
1837
|
+
return n < 0
|
|
1838
|
+
? range
|
|
1839
|
+
: { ...range, page: { request: sentAt + n + 1, response: responses[n] } };
|
|
1840
|
+
});
|
|
1841
|
+
pageCount += sending.length;
|
|
1842
|
+
// Emit the head range's pages, in plan order, until one needs a read.
|
|
1843
|
+
const pages = [];
|
|
1844
|
+
const filled = () => limitValue !== undefined && emitted >= limitValue;
|
|
1845
|
+
while (open.length > 0 && open[0].page !== undefined && !filled()) {
|
|
1846
|
+
const [head, ...rest] = open;
|
|
1847
|
+
const { request, response } = head.page;
|
|
1848
|
+
const remaining = limitValue === undefined ? undefined : limitValue - emitted;
|
|
1849
|
+
const page = yield* examinePage(client, tableName, response.state, response.returned, remaining, response.borrowedFields);
|
|
1850
|
+
estimator = observeEstimator(estimator, {
|
|
1851
|
+
scanned: response.counts.scannedCount,
|
|
1852
|
+
accepted: page.accepted,
|
|
1853
|
+
limitSent: response.limitSent,
|
|
1854
|
+
});
|
|
1855
|
+
yield* notify(response.state, {
|
|
1856
|
+
kind: "read",
|
|
1857
|
+
request,
|
|
1858
|
+
limit: response.limitSent,
|
|
1859
|
+
...response.counts,
|
|
1860
|
+
accepted: page.accepted,
|
|
1861
|
+
});
|
|
1862
|
+
pages.push(page.items);
|
|
1863
|
+
emitted += page.items.length;
|
|
1864
|
+
const lek = response.lastEvaluatedKey;
|
|
1865
|
+
const next = planner.afterRead(head.step, lek);
|
|
1866
|
+
if (lek !== undefined && next._tag === "Read") {
|
|
1867
|
+
open = [{ step: next, page: undefined }, ...rest];
|
|
1868
|
+
}
|
|
1869
|
+
else {
|
|
1870
|
+
open = rest;
|
|
1871
|
+
if (rest.length === 0)
|
|
1872
|
+
step = next;
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
// Keep the window full: the ranges after the last open one. (When it
|
|
1876
|
+
// finishes, its own next step is the first range not yet opened.)
|
|
1877
|
+
const last = open[open.length - 1];
|
|
1878
|
+
if (last !== undefined && open.length < window && !filled()) {
|
|
1879
|
+
open = [
|
|
1880
|
+
...open,
|
|
1881
|
+
...planner.following(last.step, window - open.length).map((member) => ({
|
|
1882
|
+
step: member,
|
|
1883
|
+
page: undefined,
|
|
1884
|
+
})),
|
|
1885
|
+
];
|
|
1886
|
+
}
|
|
1887
|
+
const more = !filled() && budget() && (open.length > 0 || step._tag !== "Done");
|
|
1888
|
+
if (!more) {
|
|
1889
|
+
// The stream ends with pages read ahead and not emitted: report them (§3.6).
|
|
1890
|
+
const unused = open
|
|
1891
|
+
.flatMap((range) => (range.page === undefined ? [] : [range.page]))
|
|
1892
|
+
.sort((a, b) => a.request - b.request);
|
|
1893
|
+
yield* Effect.forEach(unused, ({ request, response }) => notify(response.state, {
|
|
1894
|
+
kind: "read",
|
|
1895
|
+
request,
|
|
1896
|
+
limit: response.limitSent,
|
|
1897
|
+
...response.counts,
|
|
1898
|
+
accepted: 0,
|
|
1899
|
+
discarded: true,
|
|
1900
|
+
}), { discard: true });
|
|
1901
|
+
}
|
|
1902
|
+
return [
|
|
1903
|
+
pages,
|
|
1904
|
+
more
|
|
1905
|
+
? Option.some({ step, open, pageCount, emitted, estimator, window })
|
|
1906
|
+
: Option.none(),
|
|
1907
|
+
];
|
|
1908
|
+
}));
|
|
1909
|
+
};
|
|
1015
1910
|
/**
|
|
1016
1911
|
* Execute a count query. Uses `Select: "COUNT"` on DynamoDB — no items are returned.
|
|
1017
1912
|
* Returns the total count across all requests (respects {@link maxPages}).
|
|
@@ -1019,7 +1914,9 @@ const pageStream = (client, tableName, state) => {
|
|
|
1019
1914
|
* {@link limit} caps the count, keeping `count()` equal to `collect().length`
|
|
1020
1915
|
* for the same query: `.limit(n).count()` returns `min(matching, n)` and stops
|
|
1021
1916
|
* counting once `n` is reached — which makes `.limit(1).count()` a cheap
|
|
1022
|
-
* existence check.
|
|
1917
|
+
* existence check. Under a filter, each request of a limited count is sized
|
|
1918
|
+
* from the pass rate the earlier ones showed, as {@link limit} describes.
|
|
1919
|
+
* {@link pageSize} sets the rows examined per request instead.
|
|
1023
1920
|
*/
|
|
1024
1921
|
export const count = (self) => Capacity.delegate("Query.count", operationOptions(self), countCore(self));
|
|
1025
1922
|
const countCore = (self) => Effect.gen(function* () {
|
|
@@ -1044,29 +1941,41 @@ const countCore = (self) => Effect.gen(function* () {
|
|
|
1044
1941
|
const items = yield* collect(self);
|
|
1045
1942
|
return items.length;
|
|
1046
1943
|
}
|
|
1944
|
+
if (state.plan !== undefined)
|
|
1945
|
+
return yield* countPlanned(client, tableName, state, state.plan);
|
|
1047
1946
|
// Rows that may be history are judged by a few attributes, so only those
|
|
1048
1947
|
// are read — never whole items (#133).
|
|
1049
1948
|
if (dropsRows(state)) {
|
|
1050
|
-
const
|
|
1051
|
-
...
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
];
|
|
1056
|
-
const judging = { ...state, projection: judged, projectionPaths: undefined };
|
|
1949
|
+
const judging = {
|
|
1950
|
+
...state,
|
|
1951
|
+
projection: judgedAttributes(state),
|
|
1952
|
+
projectionPaths: undefined,
|
|
1953
|
+
};
|
|
1057
1954
|
let counted = 0;
|
|
1058
1955
|
let pages = 0;
|
|
1059
1956
|
let start;
|
|
1957
|
+
let est = emptyEstimator;
|
|
1060
1958
|
do {
|
|
1061
1959
|
pages++;
|
|
1062
1960
|
const remaining = limitValue === undefined ? undefined : limitValue - counted;
|
|
1961
|
+
const limitSent = requestLimit(state, remaining, est, pages);
|
|
1063
1962
|
const cmd = buildDynamoCommand(judging, tableName, {
|
|
1064
1963
|
ExclusiveStartKey: start,
|
|
1065
|
-
Limit:
|
|
1964
|
+
Limit: limitSent,
|
|
1066
1965
|
});
|
|
1067
1966
|
const result = yield* send(client, state, cmd);
|
|
1068
1967
|
const rows = (result.Items ?? []);
|
|
1069
|
-
|
|
1968
|
+
const accepted = rows.filter((row) => !isExcludedRow(state, row)).length;
|
|
1969
|
+
counted += accepted;
|
|
1970
|
+
const counts = responseCounts(result);
|
|
1971
|
+
est = observeEstimator(est, { scanned: counts.scannedCount, accepted, limitSent });
|
|
1972
|
+
yield* notify(state, {
|
|
1973
|
+
kind: "count",
|
|
1974
|
+
request: pages,
|
|
1975
|
+
limit: limitSent,
|
|
1976
|
+
...counts,
|
|
1977
|
+
accepted,
|
|
1978
|
+
});
|
|
1070
1979
|
start = result.LastEvaluatedKey;
|
|
1071
1980
|
if (limitValue !== undefined && counted >= limitValue)
|
|
1072
1981
|
return limitValue;
|
|
@@ -1078,16 +1987,30 @@ const countCore = (self) => Effect.gen(function* () {
|
|
|
1078
1987
|
let total = 0;
|
|
1079
1988
|
let pageCount = 0;
|
|
1080
1989
|
let exclusiveStartKey;
|
|
1990
|
+
let est = emptyEstimator;
|
|
1081
1991
|
do {
|
|
1082
1992
|
pageCount++;
|
|
1083
1993
|
const remaining = limitValue === undefined ? undefined : limitValue - total;
|
|
1994
|
+
// Under a filter and a `limit`, sized from the pass rate `Count` shows.
|
|
1995
|
+
const limitSent = requestLimit(state, remaining, est, pageCount);
|
|
1084
1996
|
const cmd = buildDynamoCommand(state, tableName, {
|
|
1085
1997
|
ExclusiveStartKey: exclusiveStartKey,
|
|
1086
1998
|
Select: "COUNT",
|
|
1087
|
-
Limit:
|
|
1999
|
+
Limit: limitSent,
|
|
1088
2000
|
});
|
|
1089
2001
|
const result = yield* send(client, state, cmd);
|
|
1090
|
-
|
|
2002
|
+
const matched = result.Count ?? 0;
|
|
2003
|
+
total += matched;
|
|
2004
|
+
const scanned = result.ScannedCount ?? matched;
|
|
2005
|
+
est = observeEstimator(est, { scanned, accepted: matched, limitSent });
|
|
2006
|
+
yield* notify(state, {
|
|
2007
|
+
kind: "count",
|
|
2008
|
+
request: pageCount,
|
|
2009
|
+
limit: limitSent,
|
|
2010
|
+
scannedCount: scanned,
|
|
2011
|
+
count: matched,
|
|
2012
|
+
accepted: matched,
|
|
2013
|
+
});
|
|
1091
2014
|
exclusiveStartKey = result.LastEvaluatedKey;
|
|
1092
2015
|
if (limitValue !== undefined && total >= limitValue)
|
|
1093
2016
|
return limitValue;
|
|
@@ -1096,6 +2019,126 @@ const countCore = (self) => Effect.gen(function* () {
|
|
|
1096
2019
|
} while (exclusiveStartKey != null);
|
|
1097
2020
|
return total;
|
|
1098
2021
|
});
|
|
2022
|
+
/**
|
|
2023
|
+
* @internal `count` for a planned query (design §9.4): `Select: "COUNT"` per
|
|
2024
|
+
* read, probes through the same loop. A read whose rows a check judges (a row
|
|
2025
|
+
* check, history rows) reads only the attributes the check needs and counts
|
|
2026
|
+
* the rows it accepts. A jump read is sized from its in-box run, as `execute`
|
|
2027
|
+
* sizes it; without that, each `COUNT` would evaluate up to 1 MB into a gap.
|
|
2028
|
+
* A precise plan's ranges are counted independently, up to
|
|
2029
|
+
* {@link planConcurrency} at once, unless `maxPages` must cap them in order.
|
|
2030
|
+
*/
|
|
2031
|
+
const countPlanned = (client, tableName, state, plan) => Effect.gen(function* () {
|
|
2032
|
+
const { planner } = plan;
|
|
2033
|
+
const limitValue = state.limitValue;
|
|
2034
|
+
let total = 0;
|
|
2035
|
+
let requests = 0;
|
|
2036
|
+
const reached = () => limitValue !== undefined && total >= limitValue;
|
|
2037
|
+
/** Count one read; its `LastEvaluatedKey`. */
|
|
2038
|
+
const countRead = (step, est, request) => Effect.gen(function* () {
|
|
2039
|
+
const sentState = readState(state, step);
|
|
2040
|
+
const remaining = limitValue === undefined ? undefined : limitValue - total;
|
|
2041
|
+
const limitSent = readLimit(sentState, step, remaining, est, est.requests + 1);
|
|
2042
|
+
const judging = dropsRows(sentState);
|
|
2043
|
+
const cmd = judging
|
|
2044
|
+
? buildDynamoCommand({ ...sentState, projection: judgedAttributes(sentState), projectionPaths: undefined }, tableName, { ExclusiveStartKey: step.startKey, Limit: limitSent })
|
|
2045
|
+
: buildDynamoCommand(sentState, tableName, {
|
|
2046
|
+
ExclusiveStartKey: step.startKey,
|
|
2047
|
+
Select: "COUNT",
|
|
2048
|
+
Limit: limitSent,
|
|
2049
|
+
});
|
|
2050
|
+
const result = yield* send(client, sentState, cmd);
|
|
2051
|
+
const counts = responseCounts(result);
|
|
2052
|
+
const matched = judging
|
|
2053
|
+
? (result.Items ?? []).filter((row) => !isExcludedRow(sentState, row)).length
|
|
2054
|
+
: (result.Count ?? 0);
|
|
2055
|
+
const scanned = judging ? counts.scannedCount : (result.ScannedCount ?? matched);
|
|
2056
|
+
total += matched;
|
|
2057
|
+
yield* notify(sentState, {
|
|
2058
|
+
kind: "count",
|
|
2059
|
+
request,
|
|
2060
|
+
limit: limitSent,
|
|
2061
|
+
scannedCount: scanned,
|
|
2062
|
+
count: judging ? counts.count : matched,
|
|
2063
|
+
accepted: matched,
|
|
2064
|
+
});
|
|
2065
|
+
return {
|
|
2066
|
+
lastEvaluatedKey: result.LastEvaluatedKey,
|
|
2067
|
+
est: observeEstimator(est, { scanned, accepted: matched, limitSent }),
|
|
2068
|
+
};
|
|
2069
|
+
});
|
|
2070
|
+
const start = plan.start;
|
|
2071
|
+
if (state.maxPagesValue == null &&
|
|
2072
|
+
start._tag === "Read" &&
|
|
2073
|
+
start.at.mode === "precise" &&
|
|
2074
|
+
start.planIndex !== undefined) {
|
|
2075
|
+
const ranges = [start, ...planner.following(start, Number.POSITIVE_INFINITY)];
|
|
2076
|
+
yield* Effect.forEach(ranges, (first) => Effect.gen(function* () {
|
|
2077
|
+
let step = first;
|
|
2078
|
+
let est = emptyEstimator;
|
|
2079
|
+
while (!reached()) {
|
|
2080
|
+
const counted = yield* countRead(step, est, ++requests);
|
|
2081
|
+
if (counted.lastEvaluatedKey === undefined)
|
|
2082
|
+
return;
|
|
2083
|
+
est = counted.est;
|
|
2084
|
+
step = readFrom(step, counted.lastEvaluatedKey);
|
|
2085
|
+
}
|
|
2086
|
+
}), { concurrency: planConcurrency(state), discard: true });
|
|
2087
|
+
return reached() ? limitValue : total;
|
|
2088
|
+
}
|
|
2089
|
+
let step = start;
|
|
2090
|
+
let est = emptyEstimator;
|
|
2091
|
+
while (step._tag !== "Done") {
|
|
2092
|
+
if (state.maxPagesValue != null && requests >= state.maxPagesValue)
|
|
2093
|
+
break;
|
|
2094
|
+
requests++;
|
|
2095
|
+
if (step._tag === "Probe") {
|
|
2096
|
+
step = planner.afterProbe(step, yield* sendProbe(client, tableName, state, step, requests));
|
|
2097
|
+
continue;
|
|
2098
|
+
}
|
|
2099
|
+
const counted = yield* countRead(step, est, requests);
|
|
2100
|
+
est = counted.est;
|
|
2101
|
+
if (reached())
|
|
2102
|
+
return limitValue;
|
|
2103
|
+
step = planner.afterRead(step, counted.lastEvaluatedKey);
|
|
2104
|
+
}
|
|
2105
|
+
return total;
|
|
2106
|
+
});
|
|
2107
|
+
/**
|
|
2108
|
+
* Describe what a terminal would send — the plan, why it was chosen, the key
|
|
2109
|
+
* ranges known up front, the bounds checked per row and how requests are
|
|
2110
|
+
* sized — without sending anything (#171). Cursors are not read: the plan is
|
|
2111
|
+
* described from its start.
|
|
2112
|
+
*/
|
|
2113
|
+
export const explain = (self) => Effect.sync(() => {
|
|
2114
|
+
const state = self._state;
|
|
2115
|
+
const factory = state.planner ?? keyPasses(state);
|
|
2116
|
+
if (factory === undefined) {
|
|
2117
|
+
const [lower, upper] = displayKeyCondition(state.skConditions[0]?.condition);
|
|
2118
|
+
return {
|
|
2119
|
+
index: state.indexName ?? "primary",
|
|
2120
|
+
plan: "key",
|
|
2121
|
+
reason: state.isScan ? "scan: no key condition" : "one key condition",
|
|
2122
|
+
keyConditions: state.isScan ? [] : [{ lower, upper, exact: true }],
|
|
2123
|
+
filter: undefined,
|
|
2124
|
+
rowChecks: [],
|
|
2125
|
+
sizing: explainedSizing(state),
|
|
2126
|
+
};
|
|
2127
|
+
}
|
|
2128
|
+
const planner = resolvePlanner(state, factory);
|
|
2129
|
+
const start = planner.start(undefined);
|
|
2130
|
+
const planned = {
|
|
2131
|
+
...state,
|
|
2132
|
+
plan: start instanceof ValidationError ? undefined : { planner, start },
|
|
2133
|
+
rowCheck: planner.rowCheck,
|
|
2134
|
+
};
|
|
2135
|
+
return planner.explain(explainedSizing(planned));
|
|
2136
|
+
});
|
|
2137
|
+
/** @internal How `explain` names a query's sizing: no `limit` reads natural pages. */
|
|
2138
|
+
const explainedSizing = (state) => {
|
|
2139
|
+
const mode = sizingMode(state);
|
|
2140
|
+
return mode === "none" ? "natural" : mode;
|
|
2141
|
+
};
|
|
1099
2142
|
/**
|
|
1100
2143
|
* Return the built DynamoDB command input without executing.
|
|
1101
2144
|
* Useful for debugging, logging, or passing to DynamoClient directly.
|
|
@@ -1108,8 +2151,21 @@ export const asParams = (self) => Effect.gen(function* () {
|
|
|
1108
2151
|
if (refusal !== undefined)
|
|
1109
2152
|
return yield* refusal;
|
|
1110
2153
|
const tableName = state.resolveTableName ? yield* state.resolveTableName : state.tableName;
|
|
2154
|
+
// A planned query shows the first request it would send from the start:
|
|
2155
|
+
// its first probe, or its first read (#171).
|
|
2156
|
+
if (state.planner !== undefined) {
|
|
2157
|
+
const planner = resolvePlanner(state, state.planner);
|
|
2158
|
+
const start = planner.start(undefined);
|
|
2159
|
+
if (!(start instanceof ValidationError) && start._tag !== "Done") {
|
|
2160
|
+
const planned = { ...state, plan: { planner, start } };
|
|
2161
|
+
return start._tag === "Probe"
|
|
2162
|
+
? probeCommand(planned, start, tableName)
|
|
2163
|
+
: buildDynamoCommand(indexQueryState(readState(planned, start)), tableName);
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
1111
2166
|
// A `BatchGet`-hydrated query shows its index query; the `BatchGetItem`
|
|
1112
|
-
// that follows each page is not part of these parameters.
|
|
2167
|
+
// that follows each page is not part of these parameters. Nor is the
|
|
2168
|
+
// read of a 1.36 key (#171): these are the query's own key's.
|
|
1113
2169
|
return buildDynamoCommand(indexQueryState(state), tableName);
|
|
1114
2170
|
});
|
|
1115
2171
|
// ---------------------------------------------------------------------------
|