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