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.
Files changed (94) hide show
  1. package/dist/Aggregate.d.ts +29 -17
  2. package/dist/Aggregate.d.ts.map +1 -1
  3. package/dist/Aggregate.js +795 -173
  4. package/dist/Aggregate.js.map +1 -1
  5. package/dist/Batch.d.ts.map +1 -1
  6. package/dist/Batch.js +196 -77
  7. package/dist/Batch.js.map +1 -1
  8. package/dist/Collection.d.ts.map +1 -1
  9. package/dist/Collection.js +11 -2
  10. package/dist/Collection.js.map +1 -1
  11. package/dist/Collections.d.ts +15 -6
  12. package/dist/Collections.d.ts.map +1 -1
  13. package/dist/Collections.js +2 -1
  14. package/dist/Collections.js.map +1 -1
  15. package/dist/DynamoClient.d.ts +119 -10
  16. package/dist/DynamoClient.d.ts.map +1 -1
  17. package/dist/DynamoClient.js +1148 -142
  18. package/dist/DynamoClient.js.map +1 -1
  19. package/dist/Entity.d.ts +126 -51
  20. package/dist/Entity.d.ts.map +1 -1
  21. package/dist/Entity.js +3477 -539
  22. package/dist/Entity.js.map +1 -1
  23. package/dist/EventStore.d.ts +4 -3
  24. package/dist/EventStore.d.ts.map +1 -1
  25. package/dist/EventStore.js +616 -53
  26. package/dist/EventStore.js.map +1 -1
  27. package/dist/Query.d.ts +311 -25
  28. package/dist/Query.d.ts.map +1 -1
  29. package/dist/Query.js +1159 -103
  30. package/dist/Query.js.map +1 -1
  31. package/dist/Table.d.ts +172 -3
  32. package/dist/Table.d.ts.map +1 -1
  33. package/dist/Table.js +85 -4
  34. package/dist/Table.js.map +1 -1
  35. package/dist/Transaction.d.ts +18 -2
  36. package/dist/Transaction.d.ts.map +1 -1
  37. package/dist/Transaction.js +62 -8
  38. package/dist/Transaction.js.map +1 -1
  39. package/dist/change-feed/DynamoStreamsClient.d.ts +1 -1
  40. package/dist/change-feed/DynamoStreamsClient.d.ts.map +1 -1
  41. package/dist/change-feed/DynamoStreamsClient.js +3 -1
  42. package/dist/change-feed/DynamoStreamsClient.js.map +1 -1
  43. package/dist/index.d.ts +6 -3
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +1 -1
  46. package/dist/index.js.map +1 -1
  47. package/dist/internal/BoundQuery.d.ts +226 -32
  48. package/dist/internal/BoundQuery.d.ts.map +1 -1
  49. package/dist/internal/BoundQuery.js +96 -18
  50. package/dist/internal/BoundQuery.js.map +1 -1
  51. package/dist/internal/Capacity.d.ts +8 -0
  52. package/dist/internal/Capacity.d.ts.map +1 -1
  53. package/dist/internal/Capacity.js +24 -4
  54. package/dist/internal/Capacity.js.map +1 -1
  55. package/dist/internal/CredentialRefusal.d.ts +32 -0
  56. package/dist/internal/CredentialRefusal.d.ts.map +1 -0
  57. package/dist/internal/CredentialRefusal.js +80 -0
  58. package/dist/internal/CredentialRefusal.js.map +1 -0
  59. package/dist/internal/Cursor.d.ts +90 -0
  60. package/dist/internal/Cursor.d.ts.map +1 -0
  61. package/dist/internal/Cursor.js +179 -0
  62. package/dist/internal/Cursor.js.map +1 -0
  63. package/dist/internal/ExpressionLimits.d.ts +18 -2
  64. package/dist/internal/ExpressionLimits.d.ts.map +1 -1
  65. package/dist/internal/ExpressionLimits.js +35 -2
  66. package/dist/internal/ExpressionLimits.js.map +1 -1
  67. package/dist/internal/ProjectionEngine.d.ts +16 -0
  68. package/dist/internal/ProjectionEngine.d.ts.map +1 -1
  69. package/dist/internal/ProjectionEngine.js +242 -46
  70. package/dist/internal/ProjectionEngine.js.map +1 -1
  71. package/dist/internal/RangePlanner.d.ts +394 -0
  72. package/dist/internal/RangePlanner.d.ts.map +1 -0
  73. package/dist/internal/RangePlanner.js +1296 -0
  74. package/dist/internal/RangePlanner.js.map +1 -0
  75. package/dist/internal/Rekey.d.ts +78 -0
  76. package/dist/internal/Rekey.d.ts.map +1 -0
  77. package/dist/internal/Rekey.js +323 -0
  78. package/dist/internal/Rekey.js.map +1 -0
  79. package/dist/internal/TransactPlan.d.ts +8 -1
  80. package/dist/internal/TransactPlan.d.ts.map +1 -1
  81. package/dist/internal/TransactPlan.js +9 -2
  82. package/dist/internal/TransactPlan.js.map +1 -1
  83. package/dist/internal/TransactWriteOps.d.ts +10 -7
  84. package/dist/internal/TransactWriteOps.d.ts.map +1 -1
  85. package/dist/internal/TransactWriteOps.js +82 -6
  86. package/dist/internal/TransactWriteOps.js.map +1 -1
  87. package/dist/internal/TransactableOps.d.ts +31 -0
  88. package/dist/internal/TransactableOps.d.ts.map +1 -1
  89. package/dist/internal/TransactableOps.js +50 -1
  90. package/dist/internal/TransactableOps.js.map +1 -1
  91. package/dist/internal/WriteDiagnosis.d.ts.map +1 -1
  92. package/dist/internal/WriteDiagnosis.js +10 -7
  93. package/dist/internal/WriteDiagnosis.js.map +1 -1
  94. 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) => btoa(JSON.stringify(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 express this — {@link pageSize} is the knob that sets `Limit`, and
164
- * {@link maxPages} stays the hard stop on the number of requests.
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, n) => new QueryImpl({
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
- pageSizeValue: n,
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 DynamoDB `ExclusiveStartKey`.
222
- * Pass an empty string to start from the beginning.
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
- exclusiveStartKey: decodeCursor(cursor),
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 its placeholders from
418
- // zero, so two compiled apart both wrote `#e0` and the second overwrote the
419
- // first's name (#133).
420
- if (state.exprFilters.length > 0) {
421
- const compiled = compileExpr(state.exprFilters.length === 1
422
- ? state.exprFilters[0]
423
- : { [ExprTag]: ExprTag, _tag: "and", exprs: state.exprFilters });
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 NOT derived from state here — it is per-request (it shrinks as a
514
- * `limit` budget is consumed) and is always supplied through `overrides`.
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: computeRequestLimit(state, state.limitValue),
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 DynamoDB `Limit` for the next request.
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
- * - `pageSize` always wins as the round-trip budget when it is set.
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
- * The `__edd_e__` ownership filter is not counted here: it only ever rejects
635
- * foreign items, which the accumulate loop handles by fetching another page.
636
- * - With a user filter, `limit` cannot be expressed as `Limit` at all (it bounds
637
- * rows examined, and the filter runs after), so the request asks for
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
- * are not: they can't be skipped server-side, and a run of them (an item's
643
- * versions, its tombstones) can far outnumber the items. The first request
644
- * asks for `limit`, and each later one twice the last — so a run of `n`
645
- * history rows costs about `log2(n / limit)` requests, not `n / limit` — and
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 request's 1-based number within the terminal.
941
+ * `page` is the read's 1-based number within the terminal.
650
942
  */
651
- const computeRequestLimit = (state, remaining, page = 1) => {
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.exprFilters.length > 0 || state.predicates.length > 0)
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): like a predicate,
667
- * it rejects rows after they are read, so the `limit` budget cannot be handed
668
- * to DynamoDB, and a count has to read the rows.
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
- return state.prepare === undefined
705
- ? Effect.succeed(state)
706
- : Effect.map(state.prepare(tableName), (prep) => {
707
- const swap = prep.replaceBeginsWith;
708
- return {
709
- ...state,
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 encodeCursor(key);
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
- if (state.keepRow !== undefined && state.skField !== undefined)
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
- return [...borrowed].filter((field) => !projected.has(field));
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 stream = yield* paginateInternal(self);
891
- const pages = yield* Stream.runCollect(stream);
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 — a `null` cursor
903
- * means genuinely exhausted. Combine with {@link startFrom} to iterate:
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
- * if (first.cursor) {
908
- * const second = yield* query.pipe(Query.limit(25), Query.startFrom(first.cursor), Query.execute)
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: null };
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 cmd = buildDynamoCommand(indexState, tableName, { ExclusiveStartKey: startKey, Limit: computeRequestLimit(state, remaining, pageCount) }, borrowedFields);
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); the
968
- * stream ends once {@link limit} items have been emitted in total, the key
969
- * range is exhausted, or {@link maxPages} requests have been made.
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 cmd = buildDynamoCommand(indexState, tableName, {
995
- ExclusiveStartKey: pageState.key,
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. {@link pageSize} sets the rows examined per request.
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 judged = [
1051
- ...new Set([
1052
- ...(state.liveRows?.reads ?? []),
1053
- ...(state.keepRow !== undefined && state.skField !== undefined ? [state.skField] : []),
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: computeRequestLimit(state, remaining, pages),
1964
+ Limit: limitSent,
1066
1965
  });
1067
1966
  const result = yield* send(client, state, cmd);
1068
1967
  const rows = (result.Items ?? []);
1069
- counted += rows.filter((row) => !isExcludedRow(state, row)).length;
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: computeRequestLimit(state, remaining),
1999
+ Limit: limitSent,
1088
2000
  });
1089
2001
  const result = yield* send(client, state, cmd);
1090
- total += result.Count ?? 0;
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
  // ---------------------------------------------------------------------------