effect-dynamodb 1.15.0 → 1.17.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 (60) hide show
  1. package/dist/Aggregate.d.ts +49 -5
  2. package/dist/Aggregate.d.ts.map +1 -1
  3. package/dist/Aggregate.js +479 -88
  4. package/dist/Aggregate.js.map +1 -1
  5. package/dist/Batch.d.ts +10 -2
  6. package/dist/Batch.d.ts.map +1 -1
  7. package/dist/Batch.js +45 -6
  8. package/dist/Batch.js.map +1 -1
  9. package/dist/Collection.d.ts +0 -7
  10. package/dist/Collection.d.ts.map +1 -1
  11. package/dist/Collection.js +84 -2
  12. package/dist/Collection.js.map +1 -1
  13. package/dist/DynamoClient.d.ts +3 -1
  14. package/dist/DynamoClient.d.ts.map +1 -1
  15. package/dist/DynamoClient.js +253 -26
  16. package/dist/DynamoClient.js.map +1 -1
  17. package/dist/Entity.d.ts +87 -6
  18. package/dist/Entity.d.ts.map +1 -1
  19. package/dist/Entity.js +413 -144
  20. package/dist/Entity.js.map +1 -1
  21. package/dist/EventStore.d.ts.map +1 -1
  22. package/dist/EventStore.js +49 -17
  23. package/dist/EventStore.js.map +1 -1
  24. package/dist/Query.d.ts +56 -5
  25. package/dist/Query.d.ts.map +1 -1
  26. package/dist/Query.js +218 -34
  27. package/dist/Query.js.map +1 -1
  28. package/dist/Transaction.d.ts +2 -2
  29. package/dist/Transaction.d.ts.map +1 -1
  30. package/dist/Transaction.js +29 -9
  31. package/dist/Transaction.js.map +1 -1
  32. package/dist/internal/BoundCrud.d.ts +76 -9
  33. package/dist/internal/BoundCrud.d.ts.map +1 -1
  34. package/dist/internal/BoundCrud.js +22 -1
  35. package/dist/internal/BoundCrud.js.map +1 -1
  36. package/dist/internal/BoundQuery.d.ts +75 -13
  37. package/dist/internal/BoundQuery.d.ts.map +1 -1
  38. package/dist/internal/BoundQuery.js +85 -11
  39. package/dist/internal/BoundQuery.js.map +1 -1
  40. package/dist/internal/BoundVectorQuery.d.ts +9 -0
  41. package/dist/internal/BoundVectorQuery.d.ts.map +1 -1
  42. package/dist/internal/BoundVectorQuery.js +3 -2
  43. package/dist/internal/BoundVectorQuery.js.map +1 -1
  44. package/dist/internal/EntityCombinators.d.ts +31 -5
  45. package/dist/internal/EntityCombinators.d.ts.map +1 -1
  46. package/dist/internal/EntityCombinators.js +7 -7
  47. package/dist/internal/EntityCombinators.js.map +1 -1
  48. package/dist/internal/EntityOps.d.ts +14 -1
  49. package/dist/internal/EntityOps.d.ts.map +1 -1
  50. package/dist/internal/EntityOps.js +4 -1
  51. package/dist/internal/EntityOps.js.map +1 -1
  52. package/dist/internal/TransactWriteOps.d.ts +44 -5
  53. package/dist/internal/TransactWriteOps.d.ts.map +1 -1
  54. package/dist/internal/TransactWriteOps.js +98 -20
  55. package/dist/internal/TransactWriteOps.js.map +1 -1
  56. package/dist/internal/TransactableOps.d.ts +69 -1
  57. package/dist/internal/TransactableOps.d.ts.map +1 -1
  58. package/dist/internal/TransactableOps.js +138 -4
  59. package/dist/internal/TransactableOps.js.map +1 -1
  60. package/package.json +2 -2
package/dist/Aggregate.js CHANGED
@@ -11,13 +11,17 @@
11
11
  import { TypeId as SchemaAggregateTypeId } from "@effect-dynamodb/schema/Aggregate.js";
12
12
  import * as DynamoModel from "@effect-dynamodb/schema/DynamoModel.js";
13
13
  import { composeCollectionKey, composeKey } from "@effect-dynamodb/schema/DynamoSchema.js";
14
- import { AggregateAssemblyError, AggregateTransactionOverflow, RefNotFound, TransactionCancelled, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
14
+ import { AggregateAssemblyError, AggregateDecompositionError, AggregateTransactionOverflow, RefNotFound, TransactionCancelled, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
15
+ import { makeCompositeKeyForm, toCompositeKeyRecord, } from "@effect-dynamodb/schema/internal/CompositeCodec.js";
15
16
  import { buildDateTransform, matchDateRepresentation, resolveSystemFields, substituteSchemaDeep, validateNoTransformOverride, } from "@effect-dynamodb/schema/internal/EntitySchemas.js";
17
+ import { hasEncodingTransformation } from "@effect-dynamodb/schema/internal/SchemaAccessors.js";
16
18
  import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
17
- import { DateTime, Effect, Schema, SchemaAST } from "effect";
19
+ import { DateTime, Effect, Option, Schema, SchemaAST } from "effect";
18
20
  import * as Batch from "./Batch.js";
19
21
  import { DynamoClient } from "./DynamoClient.js";
20
22
  import { fromDefinition as entityFromDefinition } from "./Entity.js";
23
+ import { compileExpr, createConditionOps, parseSimpleShorthand, } from "./internal/Expr.js";
24
+ import { createPathBuilder } from "./internal/PathBuilder.js";
21
25
  import { generateTimestampPrimitive } from "./internal/TransactableOps.js";
22
26
  import { fromAttributeMap, toAttributeMap, toAttributeValue } from "./Marshaller.js";
23
27
  import { resolvePrimaryKey } from "./Table.js";
@@ -96,38 +100,98 @@ const inferDateEncoding = (ast) => {
96
100
  return undefined;
97
101
  };
98
102
  /**
99
- * Build per-field date ENCODERS for a schema's own date fields. Encoders convert
100
- * a domain date value to its wire primitive on the WRITE path — decompose works
101
- * from the schema-decoded domain object, so EVERY date field (Pattern A self-date
102
- * AND Pattern B transform) must serialize for storage. The READ path is handled
103
- * entirely by the aggregate's tolerant `decodeSchema` (see `makeAggregate`), so
104
- * no separate decoders are needed.
103
+ * Build per-field ATTRIBUTE encoders for a schema's own fields — the write-side
104
+ * equivalent of what `Entity.put` gets for free by encoding its whole input
105
+ * through `inputSchema`.
106
+ *
107
+ * Decomposition works from the schema-DECODED domain object, so every attribute
108
+ * it produces is a Type-side value. Marshalling those directly stores a shape
109
+ * the read path cannot decode: a `Schema.BigIntFromString` field lands as
110
+ * `{N:"5"}` and assembly's decode (which expects the encoded string) rejects it,
111
+ * so the aggregate cannot round-trip at all. Dates were noticed first (#72) and
112
+ * got a date-only pass; the same argument applies to every transformed field.
113
+ *
114
+ * Exactly ONE encoder per field, so nothing is ever encoded twice:
115
+ *
116
+ * 1. **A date encoding** — an explicit `DynamoModel.storedAs` annotation, or the
117
+ * inferred default for a standard Effect date schema. This wins over the
118
+ * field's own encode because `storedAs` is precisely an override of it
119
+ * (a `Schema.DateTimeUtc` field marked `storedAs(DateEpochMs)` must store the
120
+ * epoch, not the ISO string its own schema would produce). This branch is the
121
+ * former `buildAttrEncoders`, unchanged.
122
+ * 2. **Any other encoding transformation** — the field's own `encode`. A ref
123
+ * field's schema is the referenced entity's, so a hydrated ref is encoded
124
+ * with the entity it came from, not with the aggregate's schema.
125
+ * 3. **No transformation** — no encoder at all, so the stored bytes are
126
+ * identical to before this existed.
127
+ *
128
+ * The READ path is handled entirely by the aggregate's tolerant `decodeSchema`
129
+ * (see `makeAggregate`), so no separate decoders are needed.
105
130
  */
106
- const buildDateEncoders = (fields) => {
131
+ const buildAttrEncoders = (fields) => {
107
132
  const encoders = {};
108
- if (fields) {
109
- for (const field of Object.keys(fields)) {
110
- const fieldSchema = fields[field];
111
- // Precedence: explicit `DynamoModel.storedAs` annotation > inferred default
112
- // for standard Effect date schemas (handles `Schema.optional()` wrappers).
113
- const encoding = DynamoModel.getEncoding(fieldSchema) ?? inferDateEncoding(fieldSchema.ast);
114
- if (!encoding)
115
- continue;
133
+ if (!fields)
134
+ return encoders;
135
+ for (const field of Object.keys(fields)) {
136
+ const fieldSchema = fields[field];
137
+ // 1. Date encoding — `storedAs` override, else the inferred date default.
138
+ const encoding = DynamoModel.getEncoding(fieldSchema) ?? inferDateEncoding(fieldSchema.ast);
139
+ if (encoding) {
116
140
  const encode = Schema.encodeUnknownSync(buildDateTransform(encoding));
117
141
  encoders[field] = (value) => encode(value);
142
+ continue;
118
143
  }
144
+ // 3. Identity by construction — `SchemaAST` documents `encoding === undefined`
145
+ // as "type and encoded forms are identical". Adding an encoder here could
146
+ // only change bytes that are already correct.
147
+ if (!hasEncodingTransformation(fieldSchema))
148
+ continue;
149
+ // 2. The field's own encode, with `decode -> encode` as the fallback so a
150
+ // caller who already supplied wire form round-trips to itself (the same
151
+ // strategy `Entity.put` uses). Neither working means the value is neither
152
+ // Type nor Encoded for this field; store it unchanged rather than
153
+ // introduce a new write-time failure mode mid-release — that shape is
154
+ // already unreadable, and the tolerant read path reports it.
155
+ const codec = fieldSchema;
156
+ const encode = Schema.encodeUnknownOption(codec);
157
+ const decode = Schema.decodeUnknownOption(codec);
158
+ encoders[field] = (value) => {
159
+ const direct = encode(value);
160
+ if (Option.isSome(direct))
161
+ return direct.value;
162
+ const decoded = decode(value);
163
+ if (Option.isSome(decoded)) {
164
+ const reencoded = encode(decoded.value);
165
+ if (Option.isSome(reencoded))
166
+ return reencoded.value;
167
+ }
168
+ return value;
169
+ };
119
170
  }
120
171
  return encoders;
121
172
  };
122
- /** A schema's declared fields, if it exposes them (Schema.Struct / Schema.Class). */
123
- const fieldsOf = (schema) => schema?.fields;
173
+ /**
174
+ * A schema's declared fields, if it exposes them (Schema.Struct / Schema.Class).
175
+ *
176
+ * Unwraps `DynamoModel.configure(...)` first: that returns a `{ model, attributes }`
177
+ * WRAPPER, not a schema, so reading `.fields` off it yields nothing. An edge
178
+ * entity declared with a configured model therefore got NO encoders at all —
179
+ * its dates marshalled as `{M:{...}}` and its transformed fields as their Type
180
+ * values, on every row of that edge. Configured models are the norm (any
181
+ * `identifier: true` / `field:` rename produces one), so this blind spot covered
182
+ * most real edges.
183
+ */
184
+ const fieldsOf = (schema) => {
185
+ const source = DynamoModel.isConfiguredModel(schema) ? schema.model : schema;
186
+ return source?.fields;
187
+ };
124
188
  /**
125
189
  * Apply a node's date encoders in place — converts the node entity's own domain
126
190
  * date fields to their wire primitives before the decomposed item is marshalled
127
191
  * (issue #72: without this, an edge's `DateTime` field marshals to `{M:{}}` and
128
192
  * the subsequent `get`/assemble fails decoding it as a string).
129
193
  */
130
- const applyNodeDateEncoders = (attrs, encoders) => {
194
+ const applyNodeAttrEncoders = (attrs, encoders) => {
131
195
  if (!encoders)
132
196
  return;
133
197
  for (const [field, encode] of Object.entries(encoders)) {
@@ -140,7 +204,12 @@ const applyNodeDateEncoders = (attrs, encoders) => {
140
204
  // ---------------------------------------------------------------------------
141
205
  const makeAggregate = (schema, config) => {
142
206
  // Build resolved graph by walking edges recursively
143
- const rootNode = resolveNode(null, config.root.entityType, "root", config.edges);
207
+ const rootNode = resolveNode({
208
+ fieldName: null,
209
+ entityType: config.root.entityType,
210
+ cardinality: "root",
211
+ edges: config.edges,
212
+ });
144
213
  const aggregateName = config.root.entityType;
145
214
  const contextFields = config.context ?? [];
146
215
  // Detect date encodings for all root schema fields (once, at make() time)
@@ -169,7 +238,7 @@ const makeAggregate = (schema, config) => {
169
238
  // Date ENCODERS for the write path: decompose works from the schema-decoded
170
239
  // domain object, so every root date field (Pattern A self-date AND Pattern B
171
240
  // transform) is serialized to its wire primitive for storage.
172
- const dateEncoders = buildDateEncoders(schemaFields);
241
+ const attrEncoders = buildAttrEncoders(schemaFields);
173
242
  // Decode schema for read/assemble + input validation. Mirrors the raw `schema`
174
243
  // but recursively substitutes EVERY date / Redacted leaf — root and nested,
175
244
  // Pattern A and Pattern B — with a TOLERANT transform. This is the single
@@ -178,7 +247,7 @@ const makeAggregate = (schema, config) => {
178
247
  // - update re-decodes the mutated state, which carries domain `DateTime`
179
248
  // values → accepted as-is (a strict `*FromString` decoder would reject
180
249
  // them). It is decode-only, so making transforms tolerant is safe — the
181
- // stored wire format is still produced by `dateEncoders` / node encoders.
250
+ // stored wire format is still produced by `attrEncoders` / node encoders.
182
251
  // Nested edge / ref classes round-trip with their class instance identity
183
252
  // preserved (Option A). Returns the raw `schema` unchanged when it carries no
184
253
  // date / Redacted leaf at all (zero overhead).
@@ -190,6 +259,7 @@ const makeAggregate = (schema, config) => {
190
259
  // `substituteSchemaDeep` introspects directly — re-pointing it at the element
191
260
  // model would drop the `Array` and yield "Expected object, got []" on assemble.
192
261
  const edgeRefModels = new Map();
262
+ const unwrapModel = (model) => DynamoModel.isConfiguredModel(model) ? model.model : model;
193
263
  for (const [edgeName, edge] of Object.entries(config.edges)) {
194
264
  if (!("_tag" in edge))
195
265
  continue;
@@ -199,7 +269,20 @@ const makeAggregate = (schema, config) => {
199
269
  const model = entity?.model;
200
270
  if (!model)
201
271
  continue;
202
- edgeRefModels.set(edgeName, DynamoModel.isConfiguredModel(model) ? model.model : model);
272
+ edgeRefModels.set(edgeName, unwrapModel(model));
273
+ // A ref nested INSIDE an edge's model needs the same treatment. `maker` on
274
+ // an edge entity is annotated with `DynamoModel.ref`, and `Schema.annotate`
275
+ // drops a `Schema.Class`'s `.fields`, so the substitution cannot introspect
276
+ // it and the target's own transformed fields keep their strict schemas —
277
+ // which is what made `update` reject a `bigint` at
278
+ // `["supplier"]["maker"]["founded"]` (#116). Registering the target by field
279
+ // name re-points it exactly as a top-level edge is re-pointed.
280
+ for (const nested of entity?._data?.resolvedRefs ?? []) {
281
+ const nestedModel = nested.refEntity?.model;
282
+ if (nestedModel && !edgeRefModels.has(nested.fieldName)) {
283
+ edgeRefModels.set(nested.fieldName, unwrapModel(nestedModel));
284
+ }
285
+ }
203
286
  }
204
287
  const decodeSchema = substituteSchemaDeep(schema, {
205
288
  tolerantTransforms: true,
@@ -277,11 +360,23 @@ const makeAggregate = (schema, config) => {
277
360
  // Build optics at construction time (once per aggregate definition)
278
361
  const classToPlain = Schema.toIso(schema);
279
362
  const opticRoot = Schema.toIsoFocus(schema);
363
+ // Composite key form — the SAME rule and the SAME function the entity path
364
+ // uses (`internal/CompositeCodec.ts`). Aggregates compose from assembled
365
+ // domain objects, so without this a `DateEpochMs` composite would key on its
366
+ // ISO form here and its epoch form on the entity path, and a numeric
367
+ // composite with a string encoding would not be zero-padded on either.
368
+ const compositeKeyForm = makeCompositeKeyForm(schema, (attr, value) => {
369
+ throw new Error(`[EDD-9050] Composite "${attr}" on aggregate "${aggregateName}" could not be put into ` +
370
+ `its key form. The attribute's schema carries an encoding transformation, but ` +
371
+ `${JSON.stringify(String(value))} resolves under neither encode nor decode->encode. ` +
372
+ `Supply a value of the attribute's own type.`);
373
+ });
374
+ const keyRecord = (record) => toCompositeKeyRecord(compositeKeyForm, record);
280
375
  /** Shared: compose PK and query all items */
281
376
  const fetchPartition = (key) => Effect.gen(function* () {
282
377
  const client = yield* DynamoClient;
283
378
  const tableConfig = yield* config.table.Tag;
284
- const composites = KeyComposer.extractComposites(config.pk.composite, key);
379
+ const composites = KeyComposer.extractComposites(config.pk.composite, keyRecord(key));
285
380
  const pkValue = composeCollectionKey(config.schema, config.collection.name, composites);
286
381
  const allItems = yield* queryAllItems(client, tableConfig.name, config.collection.index, config.pk.field, pkValue, consistentRead);
287
382
  return { client, tableConfig, pkValue, composites, allItems };
@@ -335,16 +430,19 @@ const makeAggregate = (schema, config) => {
335
430
  })));
336
431
  const assembled = { ...decoded };
337
432
  // 3. Compose PK
338
- const composites = KeyComposer.extractComposites(config.pk.composite, assembled);
433
+ const composites = KeyComposer.extractComposites(config.pk.composite, keyRecord(assembled));
339
434
  const pkValue = composeCollectionKey(config.schema, config.collection.name, composites);
340
435
  // 4. Decompose into items grouped by sub-aggregate transaction boundaries
341
- const groups = yield* decomposeAggregate(assembled, rootNode, contextFields, dateEncoders, aggregateName);
342
- // 5. Compose collection SK composites for root item
343
- const collectionSkComposites = KeyComposer.extractComposites(config.collection.sk?.composite ?? [], assembled);
436
+ const groups = yield* decomposeAggregate(assembled, rootNode, contextFields, attrEncoders, aggregateName, config.schema);
437
+ // 5. Compose collection SK composites for root item. Through the key
438
+ // form — `update` already did (see `newDynamo` / `oldDynamo` below), so
439
+ // leaving `create` raw made the two write paths mirror the collection SK
440
+ // differently for a transformed composite.
441
+ const collectionSkComposites = KeyComposer.extractComposites(config.collection.sk?.composite ?? [], keyRecord(assembled));
344
442
  // 6. Build DynamoDB items with composed keys (create only ever PUTs).
345
443
  // Every row is new, so `created` has nothing to carry forward.
346
444
  const now = yield* DateTime.now;
347
- const dynamoGroups = buildDynamoItems(groups, config, pkValue, collectionSkComposites, stampFor(now, EMPTY_CREATED));
445
+ const dynamoGroups = buildDynamoItems(groups, config, pkValue, collectionSkComposites, keyRecord, stampFor(now, EMPTY_CREATED));
348
446
  // 7. Write via sub-aggregate transactions
349
447
  yield* writeTransactionGroups(client, tableConfig.name, dynamoGroups.map((g) => ({ group: g.group, puts: g.items, deletes: [] })), aggregateName);
350
448
  return decoded;
@@ -381,8 +479,8 @@ const makeAggregate = (schema, config) => {
381
479
  const assembledOld = { ...current };
382
480
  const assembledNew = { ...decoded };
383
481
  // 4. Decompose both old and new
384
- const oldGroups = yield* decomposeAggregate(assembledOld, rootNode, contextFields, dateEncoders, aggregateName);
385
- const newGroups = yield* decomposeAggregate(assembledNew, rootNode, contextFields, dateEncoders, aggregateName);
482
+ const oldGroups = yield* decomposeAggregate(assembledOld, rootNode, contextFields, attrEncoders, aggregateName, config.schema);
483
+ const newGroups = yield* decomposeAggregate(assembledNew, rootNode, contextFields, attrEncoders, aggregateName, config.schema);
386
484
  // 5. Diff at the ITEM (primary-key) level, not just the group level.
387
485
  // A `many`-edge element — and a cleared `one` edge — lives in its PARENT's
388
486
  // transaction group, so removing it SHRINKS a group rather than dropping a
@@ -410,8 +508,8 @@ const makeAggregate = (schema, config) => {
410
508
  // PUTs and would otherwise clobber it. The old set is only mined for
411
509
  // (pk, sk) delete keys, so it is never stamped.
412
510
  const now = yield* DateTime.now;
413
- const newDynamo = buildDynamoItems(newGroups, config, pkValue, KeyComposer.extractComposites(config.collection.sk?.composite ?? [], assembledNew), stampFor(now, readExistingCreated(allItems)));
414
- const oldDynamo = buildDynamoItems(oldGroups, config, pkValue, KeyComposer.extractComposites(config.collection.sk?.composite ?? [], assembledOld));
511
+ const newDynamo = buildDynamoItems(newGroups, config, pkValue, KeyComposer.extractComposites(config.collection.sk?.composite ?? [], keyRecord(assembledNew)), keyRecord, stampFor(now, readExistingCreated(allItems)));
512
+ const oldDynamo = buildDynamoItems(oldGroups, config, pkValue, KeyComposer.extractComposites(config.collection.sk?.composite ?? [], keyRecord(assembledOld)), keyRecord);
415
513
  const skOf = (item) => item.sk?.S ?? "";
416
514
  const newItemsByGroup = new Map(newDynamo.map((g) => [g.group, g.items]));
417
515
  const oldItemsByGroup = new Map(oldDynamo.map((g) => [g.group, g.items]));
@@ -474,38 +572,98 @@ const makeAggregate = (schema, config) => {
474
572
  }
475
573
  const listConfig = config.list;
476
574
  const limit = options?.limit;
477
- const startKey = options?.cursor
478
- ? JSON.parse(atob(options.cursor))
479
- : undefined;
480
- // Compose PK from filter values matching PK composites
481
- const listPkComposites = KeyComposer.extractComposites(listConfig.pk.composite, filter ?? {});
482
- // Build SK prefix from contiguous filter values matching SK composites
575
+ const pageSize = options?.pageSize;
576
+ // Root-item FilterExpression. Compiled once — the same two forms
577
+ // `BoundQuery.filter()` takes, through the same `Expr` compiler, so
578
+ // there is one filter vocabulary rather than a second dialect here.
579
+ const rootFilter = compileListFilter(options?.filter);
580
+ // Attribute names that make up a resume key on the list index: the index
581
+ // key plus the table key. A `LastEvaluatedKey` supplies them when there
582
+ // is one; this is the fallback for the case a filtered request over-reads
583
+ // on the LAST page, where the surplus still has to be re-read next time.
584
+ const listKeyFields = Array.from(new Set([
585
+ tablePrimary?.pk ?? config.pk.field,
586
+ tablePrimary?.sk ?? "sk",
587
+ listConfig.pk.field,
588
+ listConfig.sk.field,
589
+ ]));
590
+ // Compose PK from filter values matching PK composites. The filter is a
591
+ // DOMAIN record, so it goes through the same key form the write side
592
+ // used — otherwise `list` composes a key `create` never wrote and
593
+ // silently returns nothing (#111).
594
+ const listPkComposites = KeyComposer.extractComposites(listConfig.pk.composite, keyRecord(filter ?? {}));
595
+ // Build SK prefix from contiguous filter values matching SK composites.
596
+ // `serializeValue`, NOT `String(v)` — the write side pads numerics, so
597
+ // `String(5)` would look for `5` where `0000000000000005` is stored.
483
598
  const skValues = [];
599
+ const listSkFilter = keyRecord(filter ?? {});
484
600
  for (const attr of listConfig.sk.composite) {
485
- if (filter?.[attr] !== undefined)
486
- skValues.push(String(filter[attr]));
601
+ if (listSkFilter[attr] !== undefined)
602
+ skValues.push(KeyComposer.serializeValue(listSkFilter[attr]));
487
603
  else
488
604
  break; // Stop at first gap (prefix matching)
489
605
  }
490
606
  let rootItems;
491
607
  let nextKey;
492
608
  if (listConfig.cardinality) {
493
- // Fan out: query each shard in parallel, merge results
494
- // Pagination not supported for sharded lists — returns all results
609
+ // A sharded list is a fan-out over N independent partitions, and a
610
+ // DynamoDB cursor addresses a position in ONE of them. Resuming would
611
+ // need a per-shard cursor set plus a defined global merge order, which
612
+ // this fan-out does not have (results are concatenated shard by shard).
613
+ // Rejecting is the honest answer — the previous behaviour accepted the
614
+ // cursor and restarted from the beginning, silently.
615
+ if (options?.cursor !== undefined) {
616
+ return yield* new ValidationError({
617
+ entityType: aggregateName,
618
+ operation: "list",
619
+ cause: `[EDD-9051] Aggregate "${aggregateName}" has a sharded list ` +
620
+ `(list.cardinality = ${listConfig.cardinality}), and a sharded fan-out has no ` +
621
+ "resumable cursor: a DynamoDB cursor names a position in one partition, while " +
622
+ "this query spans all of them with no defined order across shards. `list` on a " +
623
+ "sharded aggregate always returns `cursor: null`; use `limit` to bound the " +
624
+ "result, or drop `list.cardinality` if the partition needs paging more than it " +
625
+ "needs spreading.",
626
+ });
627
+ }
628
+ // Fan out across shards and merge. `limit` bounds each shard too — no
629
+ // single shard can contribute more than the whole page.
495
630
  const shardQueries = Array.from({ length: listConfig.cardinality }, (_, shard) => {
496
631
  const shardPkValue = composeCollectionKey(config.schema, listConfig.name, [
497
632
  ...listPkComposites,
498
633
  String(shard),
499
634
  ]);
500
- return queryListPartition(client, tableConfig, listConfig, config.schema, shardPkValue, skValues);
635
+ return queryListPartition(client, tableConfig, listConfig, config.schema, {
636
+ pkValue: shardPkValue,
637
+ skValues,
638
+ limit,
639
+ pageSize,
640
+ filter: rootFilter,
641
+ reverse: options?.reverse === true,
642
+ keyFields: listKeyFields,
643
+ });
501
644
  });
502
645
  const shardResults = yield* Effect.all(shardQueries);
503
- rootItems = shardResults.flatMap((r) => r.items);
646
+ const merged = shardResults.flatMap((r) => r.items);
647
+ // Truncate the merged fan-out to `limit`. Previously the option was
648
+ // accepted and discarded, so a large aggregate set returned everything.
649
+ rootItems = limit === undefined ? merged : merged.slice(0, limit);
504
650
  nextKey = undefined;
505
651
  }
506
652
  else {
653
+ const startKey = options?.cursor
654
+ ? JSON.parse(atob(options.cursor))
655
+ : undefined;
507
656
  const listPkValue = composeCollectionKey(config.schema, listConfig.name, listPkComposites);
508
- const result = yield* queryListPartition(client, tableConfig, listConfig, config.schema, listPkValue, skValues, limit, startKey);
657
+ const result = yield* queryListPartition(client, tableConfig, listConfig, config.schema, {
658
+ pkValue: listPkValue,
659
+ skValues,
660
+ limit,
661
+ pageSize,
662
+ startKey,
663
+ filter: rootFilter,
664
+ reverse: options?.reverse === true,
665
+ keyFields: listKeyFields,
666
+ });
509
667
  rootItems = result.items;
510
668
  nextKey = result.lastKey;
511
669
  }
@@ -593,10 +751,8 @@ const deepEqualGroups = (a, b) => {
593
751
  return deepEqual(item.attributes, other.attributes);
594
752
  });
595
753
  };
596
- // ---------------------------------------------------------------------------
597
- // Internal: Resolve the graph
598
- // ---------------------------------------------------------------------------
599
- const resolveNode = (fieldName, entityType, cardinality, edges, discriminator, assemble, decompose, ownDiscriminator, dateEncoders) => {
754
+ const resolveNode = (args) => {
755
+ const { assemble, cardinality, attrEncoders, decompose, discriminator, edges, entityType, fieldName, ownDiscriminator, refIdentifierField, skComposite, } = args;
600
756
  const children = [];
601
757
  for (const [field, edge] of Object.entries(edges)) {
602
758
  if ("_tag" in edge) {
@@ -605,19 +761,47 @@ const resolveNode = (fieldName, entityType, cardinality, edges, discriminator, a
605
761
  const mergedDisc = edge.discriminator
606
762
  ? { ...(discriminator ?? {}), ...edge.discriminator }
607
763
  : discriminator;
608
- children.push(resolveNode(field, edge.entityType, "one", {}, mergedDisc, undefined, undefined, edge.discriminator,
609
- // Encode this edge entity's own date fields on write (issue #72).
610
- buildDateEncoders(fieldsOf(edge.entity?.model))));
764
+ children.push(resolveNode({
765
+ fieldName: field,
766
+ entityType: edge.entityType,
767
+ cardinality: "one",
768
+ edges: {},
769
+ discriminator: mergedDisc,
770
+ ownDiscriminator: edge.discriminator,
771
+ // Encode this edge entity's own date fields on write (issue #72).
772
+ attrEncoders: buildAttrEncoders(fieldsOf(edge.entity?.model)),
773
+ }));
611
774
  }
612
775
  else if (edge._tag === "ManyEdge") {
613
- children.push(resolveNode(field, edge.entityType, "many", {}, discriminator, edge.assemble, edge.decompose, undefined, buildDateEncoders(fieldsOf(edge.entity?.model))));
776
+ children.push(resolveNode({
777
+ fieldName: field,
778
+ entityType: edge.entityType,
779
+ cardinality: "many",
780
+ edges: {},
781
+ discriminator,
782
+ assemble: edge.assemble,
783
+ decompose: edge.decompose,
784
+ attrEncoders: buildAttrEncoders(fieldsOf(edge.entity?.model)),
785
+ // Declared sort-key composites, authoritative when present (#103).
786
+ skComposite: edge.sk?.composite,
787
+ refIdentifierField: edge.entity
788
+ ? DynamoModel.getIdentifierField(edge.entity.model)?.name
789
+ : undefined,
790
+ }));
614
791
  }
615
792
  else if (edge._tag === "BoundSubAggregate") {
616
793
  const bound = edge;
617
- const subChildren = resolveNode(field, bound.aggregate.root.entityType, "one", bound.aggregate.edges, bound.discriminator, undefined, undefined, bound.discriminator,
618
- // The sub-aggregate root item carries the sub-schema's own (non-edge)
619
- // date fields; its child edges get their own encoders via recursion.
620
- buildDateEncoders(fieldsOf(bound.aggregate.schema)));
794
+ const subChildren = resolveNode({
795
+ fieldName: field,
796
+ entityType: bound.aggregate.root.entityType,
797
+ cardinality: "one",
798
+ edges: bound.aggregate.edges,
799
+ discriminator: bound.discriminator,
800
+ ownDiscriminator: bound.discriminator,
801
+ // The sub-aggregate root item carries the sub-schema's own (non-edge)
802
+ // date fields; its child edges get their own encoders via recursion.
803
+ attrEncoders: buildAttrEncoders(fieldsOf(bound.aggregate.schema)),
804
+ });
621
805
  children.push(subChildren);
622
806
  }
623
807
  }
@@ -632,7 +816,9 @@ const resolveNode = (fieldName, entityType, cardinality, edges, discriminator, a
632
816
  children,
633
817
  ...(assemble !== undefined && { assemble }),
634
818
  ...(decompose !== undefined && { decompose }),
635
- dateEncoders,
819
+ ...(skComposite !== undefined && { skComposite }),
820
+ ...(refIdentifierField !== undefined && { refIdentifierField }),
821
+ attrEncoders,
636
822
  };
637
823
  };
638
824
  // ---------------------------------------------------------------------------
@@ -1051,7 +1237,7 @@ const replaceRefIds = (input, refRequests, lookup) => {
1051
1237
  * Decompose an assembled domain object into DynamoDB items grouped by
1052
1238
  * sub-aggregate transaction boundaries.
1053
1239
  */
1054
- const decomposeAggregate = (assembled, rootNode, contextFields, dateEncoders, aggregateName) => Effect.gen(function* () {
1240
+ const decomposeAggregate = (assembled, rootNode, contextFields, attrEncoders, aggregateName, schema) => Effect.gen(function* () {
1055
1241
  const items = [];
1056
1242
  // Extract context values from the root, serializing date fields for DynamoDB storage.
1057
1243
  // Domain Date/DateTime objects must be encoded before toAttributeMap() marshalling,
@@ -1060,7 +1246,7 @@ const decomposeAggregate = (assembled, rootNode, contextFields, dateEncoders, ag
1060
1246
  const contextValues = {};
1061
1247
  for (const field of contextFields) {
1062
1248
  const value = assembled[field];
1063
- const encode = dateEncoders[field];
1249
+ const encode = attrEncoders[field];
1064
1250
  contextValues[field] = encode && value != null ? encode(value) : value;
1065
1251
  }
1066
1252
  // Root item: fields not claimed by edges
@@ -1070,7 +1256,7 @@ const decomposeAggregate = (assembled, rootNode, contextFields, dateEncoders, ag
1070
1256
  if (edgeFieldNames.has(key))
1071
1257
  continue;
1072
1258
  // Serialize date fields in root attributes (same as context values)
1073
- const encode = dateEncoders[key];
1259
+ const encode = attrEncoders[key];
1074
1260
  rootAttrs[key] = encode && value != null ? encode(value) : value;
1075
1261
  }
1076
1262
  items.push({
@@ -1078,12 +1264,37 @@ const decomposeAggregate = (assembled, rootNode, contextFields, dateEncoders, ag
1078
1264
  attributes: rootAttrs,
1079
1265
  transactionGroup: "root",
1080
1266
  skComposites: [],
1267
+ member: rootNode.entityType,
1081
1268
  });
1082
1269
  // Decompose each edge
1083
1270
  for (const child of rootNode.children) {
1084
1271
  const fieldValue = assembled[child.fieldName];
1085
1272
  yield* decomposeNode(items, child, fieldValue, contextValues, "root", [], aggregateName);
1086
1273
  }
1274
+ // Two items composing the same sort key are two writes to one row. DynamoDB
1275
+ // rejects the whole TransactWriteItems with a ValidationException ("Transaction
1276
+ // request cannot include multiple operations on one item") that names no edge,
1277
+ // and carries no CancellationReasons — so `writeTransactionGroups` cannot
1278
+ // decode it and the collision reaches the caller as an opaque client error.
1279
+ // Catch it here, where the edge that produced it is still known (#103).
1280
+ const seen = new Map();
1281
+ for (const item of items) {
1282
+ const sk = composeKey(schema, item.entityType, [...item.skComposites]);
1283
+ const prior = seen.get(sk);
1284
+ if (prior !== undefined) {
1285
+ const hint = prior.member === item.member
1286
+ ? ` Give the "${item.member}" edge a declared sort key naming attributes that differ` +
1287
+ ` between its elements: Aggregate.many("${item.member}", { …, sk: { composite: [...] } }).`
1288
+ : "";
1289
+ return yield* new AggregateDecompositionError({
1290
+ aggregate: aggregateName,
1291
+ member: item.member,
1292
+ reason: `"${prior.member}" and "${item.member}" both compose the sort key "${sk}", ` +
1293
+ `so one would overwrite the other.${hint}`,
1294
+ });
1295
+ }
1296
+ seen.set(sk, item);
1297
+ }
1087
1298
  // Group by transaction
1088
1299
  const groups = new Map();
1089
1300
  for (const item of items) {
@@ -1129,7 +1340,7 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
1129
1340
  subRootAttrs[key] = val;
1130
1341
  }
1131
1342
  // Serialize this node's own date fields to their wire primitive (#72).
1132
- applyNodeDateEncoders(subRootAttrs, node.dateEncoders);
1343
+ applyNodeAttrEncoders(subRootAttrs, node.attrEncoders);
1133
1344
  // Inject context and discriminator
1134
1345
  mergeContextValues(subRootAttrs, contextValues);
1135
1346
  if (node.discriminator) {
@@ -1143,6 +1354,7 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
1143
1354
  attributes: subRootAttrs,
1144
1355
  transactionGroup: txGroup,
1145
1356
  skComposites: discValues,
1357
+ member: node.fieldName ?? node.entityType,
1146
1358
  });
1147
1359
  // Decompose children within this sub-aggregate
1148
1360
  for (const child of node.children) {
@@ -1154,7 +1366,7 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
1154
1366
  // Simple one-to-one edge (no children)
1155
1367
  const attrs = { ...value };
1156
1368
  // Serialize this edge entity's own date fields to their wire primitive (#72).
1157
- applyNodeDateEncoders(attrs, node.dateEncoders);
1369
+ applyNodeAttrEncoders(attrs, node.attrEncoders);
1158
1370
  mergeContextValues(attrs, contextValues);
1159
1371
  if (node.discriminator) {
1160
1372
  for (const [k, v] of Object.entries(node.discriminator)) {
@@ -1173,6 +1385,7 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
1173
1385
  attributes: attrs,
1174
1386
  transactionGroup: parentGroup,
1175
1387
  skComposites: [...parentDiscriminatorValues, ...ownDiscValues],
1388
+ member: node.fieldName ?? node.entityType,
1176
1389
  });
1177
1390
  }
1178
1391
  else {
@@ -1185,7 +1398,7 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
1185
1398
  for (const elem of arrayItems) {
1186
1399
  const attrs = { ...elem };
1187
1400
  // Serialize this edge entity's own date fields to their wire primitive (#72).
1188
- applyNodeDateEncoders(attrs, node.dateEncoders);
1401
+ applyNodeAttrEncoders(attrs, node.attrEncoders);
1189
1402
  mergeContextValues(attrs, contextValues);
1190
1403
  if (node.discriminator) {
1191
1404
  for (const [k, v] of Object.entries(node.discriminator)) {
@@ -1193,22 +1406,109 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
1193
1406
  attrs[k] = v;
1194
1407
  }
1195
1408
  }
1196
- // Extract ref identifiers for SK composites
1197
- const itemComposites = extractRefIdentifiers(attrs);
1409
+ const itemComposites = yield* manyEdgeSkComposites(node, attrs, aggregateName);
1198
1410
  items.push({
1199
1411
  entityType: node.entityType,
1200
1412
  attributes: attrs,
1201
1413
  transactionGroup: parentGroup,
1202
1414
  skComposites: [...parentDiscriminatorValues, ...itemComposites],
1415
+ member: node.fieldName ?? node.entityType,
1203
1416
  });
1204
1417
  }
1205
1418
  }
1206
1419
  });
1420
+ /**
1421
+ * Read one declared sk composite off a decomposed element.
1422
+ *
1423
+ * Dotted paths are supported because ref hydration REPLACES the id field with the
1424
+ * hydrated object — an input element `{ contactId, role }` decomposes to
1425
+ * `{ contact: { contactId, … }, role }`, so the referenced entity's identifier is
1426
+ * only reachable as `"contact.contactId"`.
1427
+ */
1428
+ const readCompositePath = (attrs, path) => {
1429
+ if (!path.includes("."))
1430
+ return attrs[path];
1431
+ let current = attrs;
1432
+ for (const segment of path.split(".")) {
1433
+ if (current == null || typeof current !== "object")
1434
+ return undefined;
1435
+ current = current[segment];
1436
+ }
1437
+ return current;
1438
+ };
1439
+ /**
1440
+ * Whether a resolved composite value can be a sort-key segment.
1441
+ *
1442
+ * `KeyComposer.serializeValue` falls through to `String(value)`, so an object
1443
+ * reaches the key as `[object Object]` or, for a Schema.Class instance, its
1444
+ * whole JSON body. Either is silently wrong and unqueryable — and naming the
1445
+ * ref object (`"umpire"`) instead of a scalar path (`"umpire.id"`) is the easy
1446
+ * mistake to make, precisely because the dotted path is the unfamiliar part.
1447
+ */
1448
+ const isScalarComposite = (value) => {
1449
+ switch (typeof value) {
1450
+ case "string":
1451
+ case "number":
1452
+ case "bigint":
1453
+ case "boolean":
1454
+ return true;
1455
+ case "object":
1456
+ return value instanceof Date || DateTime.isDateTime(value);
1457
+ default:
1458
+ return false;
1459
+ }
1460
+ };
1461
+ /**
1462
+ * Sort-key composites for one element of a `many` edge.
1463
+ *
1464
+ * A declared `sk.composite` is AUTHORITATIVE — it replaces the ref-identifier
1465
+ * heuristic rather than extending it, so both the uniqueness and the ordering of
1466
+ * an edge's elements are the user's to state (#103). Without one, uniqueness is
1467
+ * bounded by the referenced entity's identifier and the same entity cannot appear
1468
+ * twice in one aggregate.
1469
+ *
1470
+ * Resolution never throws: a declared attribute that is missing or null is a
1471
+ * modelling error, reported as an {@link AggregateDecompositionError} naming the
1472
+ * edge and the attributes, not a defect escaping the decompose walk.
1473
+ */
1474
+ const manyEdgeSkComposites = (node, attrs, aggregateName) => Effect.gen(function* () {
1475
+ const declared = node.skComposite;
1476
+ if (declared === undefined)
1477
+ return extractRefIdentifiers(attrs, node.refIdentifierField);
1478
+ const values = declared.map((path) => readCompositePath(attrs, path));
1479
+ const missing = declared.filter((_, i) => values[i] == null);
1480
+ if (missing.length > 0) {
1481
+ return yield* new AggregateDecompositionError({
1482
+ aggregate: aggregateName,
1483
+ member: node.fieldName ?? node.entityType,
1484
+ reason: `sk.composite names ${missing.map((m) => `"${m}"`).join(", ")}, ` +
1485
+ `${missing.length === 1 ? "which is" : "which are"} missing from the element. ` +
1486
+ `A referenced entity's identifier is reachable by dotted path after hydration ` +
1487
+ `(e.g. "contact.contactId", not "contactId").`,
1488
+ });
1489
+ }
1490
+ const nonScalar = declared.filter((_, i) => !isScalarComposite(values[i]));
1491
+ if (nonScalar.length > 0) {
1492
+ return yield* new AggregateDecompositionError({
1493
+ aggregate: aggregateName,
1494
+ member: node.fieldName ?? node.entityType,
1495
+ reason: `sk.composite names ${nonScalar.map((m) => `"${m}"`).join(", ")}, ` +
1496
+ `${nonScalar.length === 1 ? "which resolves" : "which resolve"} to a non-scalar value. ` +
1497
+ `A sort key segment must be a string, number, bigint, boolean or date — name the ` +
1498
+ `attribute itself by dotted path (e.g. "umpire.id", not "umpire").`,
1499
+ });
1500
+ }
1501
+ return values.map((value) => KeyComposer.serializeValue(value));
1502
+ });
1207
1503
  /**
1208
1504
  * Extract ref identifier values from an item's attributes for use as SK composites.
1209
1505
  * Walks fields looking for embedded objects that have an identifier-annotated field.
1506
+ *
1507
+ * A heuristic, and order-dependent: it takes each nested object's `id` (else its
1508
+ * first `*Id` key) in property order. Declaring `sk.composite` on the edge opts
1509
+ * out of it entirely — see {@link manyEdgeSkComposites}.
1210
1510
  */
1211
- const extractRefIdentifiers = (attrs) => {
1511
+ const extractRefIdentifiers = (attrs, refIdentifierField) => {
1212
1512
  const ids = [];
1213
1513
  // Case 1: Element wraps entity — look for nested objects with id-like fields
1214
1514
  for (const [, value] of Object.entries(attrs)) {
@@ -1227,9 +1527,17 @@ const extractRefIdentifiers = (attrs) => {
1227
1527
  }
1228
1528
  }
1229
1529
  }
1230
- // Case 2: Element IS entity — top-level id field (e.g., Umpire with { id: "ump-1", ... })
1231
- if (ids.length === 0 && typeof attrs.id === "string") {
1232
- ids.push(attrs.id);
1530
+ // Case 2: Element IS entity — the entity's own fields are flat, so there is no
1531
+ // nested object for case 1 to find. Prefer the edge entity's declared
1532
+ // `DynamoModel.identifier` field; fall back to a literal `id` for edges with no
1533
+ // entity. Without this an `Array(Player)` edge composes NO composites and every
1534
+ // element collapses onto one row (#103).
1535
+ if (ids.length === 0) {
1536
+ const declared = refIdentifierField !== undefined ? attrs[refIdentifierField] : undefined;
1537
+ if (typeof declared === "string")
1538
+ ids.push(declared);
1539
+ else if (typeof attrs.id === "string")
1540
+ ids.push(attrs.id);
1233
1541
  }
1234
1542
  return ids;
1235
1543
  };
@@ -1253,7 +1561,14 @@ const EMPTY_CREATED = new Map();
1253
1561
  * on every comparison and silently collapse the diff into a full-partition
1254
1562
  * rewrite on every update (#98).
1255
1563
  */
1256
- const buildDynamoItems = (groups, config, pkValue, rootCollectionSkComposites, stamp) => groups.map((group) => ({
1564
+ const buildDynamoItems = (groups, config, pkValue, rootCollectionSkComposites,
1565
+ /**
1566
+ * The aggregate's composite key-form normaliser. Required, not optional: the
1567
+ * list-GSI keys composed below have to be spelled exactly as `Aggregate.list`
1568
+ * spells them when it reads, and the only way to guarantee that is for both
1569
+ * to go through the same function (#111).
1570
+ */
1571
+ keyRecord, stamp) => groups.map((group) => ({
1257
1572
  group: group.name,
1258
1573
  items: group.items.map((item) => {
1259
1574
  const attrs = { ...item.attributes };
@@ -1276,7 +1591,7 @@ const buildDynamoItems = (groups, config, pkValue, rootCollectionSkComposites, s
1276
1591
  }
1277
1592
  // List collection GSI keys (root items only)
1278
1593
  if (isRootItem && config.list) {
1279
- const listPkComposites = KeyComposer.extractComposites(config.list.pk.composite, item.attributes);
1594
+ const listPkComposites = KeyComposer.extractComposites(config.list.pk.composite, keyRecord(item.attributes));
1280
1595
  if (config.list.cardinality) {
1281
1596
  const shard = hashToShard(pkValue, config.list.cardinality);
1282
1597
  attrs[config.list.pk.field] = composeCollectionKey(config.schema, config.list.name, [
@@ -1287,7 +1602,7 @@ const buildDynamoItems = (groups, config, pkValue, rootCollectionSkComposites, s
1287
1602
  else {
1288
1603
  attrs[config.list.pk.field] = composeCollectionKey(config.schema, config.list.name, listPkComposites);
1289
1604
  }
1290
- const listSkComposites = KeyComposer.extractComposites(config.list.sk.composite, item.attributes);
1605
+ const listSkComposites = KeyComposer.extractComposites(config.list.sk.composite, keyRecord(item.attributes));
1291
1606
  attrs[config.list.sk.field] = composeCollectionKey(config.schema, config.list.name, listSkComposites);
1292
1607
  }
1293
1608
  // System timestamps — last, so a generated value wins over the same-named
@@ -1377,7 +1692,60 @@ const deleteAllItems = (client, tableName, allItems, pkField) => Effect.gen(func
1377
1692
  });
1378
1693
  }
1379
1694
  });
1380
- const queryListPartition = (client, tableConfig, listConfig, schema, pkValue, skValues, limit, startKey) => Effect.gen(function* () {
1695
+ // ---------------------------------------------------------------------------
1696
+ // Internal: list filter compilation
1697
+ // ---------------------------------------------------------------------------
1698
+ /**
1699
+ * @internal Compile a {@link ListFilter} to a `FilterExpression` fragment.
1700
+ *
1701
+ * Both forms route through the same `Expr` compiler `BoundQuery.filter()` uses,
1702
+ * so `{ status: "shipped" }` and `(t, { eq }) => eq(t.status, "shipped")` mean
1703
+ * the same thing here as they do on an entity query. A shorthand with no
1704
+ * entries compiles to the empty string — that is a no-op, not `FilterExpression: ""`,
1705
+ * which DynamoDB rejects.
1706
+ */
1707
+ const compileListFilter = (filter) => {
1708
+ if (filter === undefined)
1709
+ return undefined;
1710
+ const expr = typeof filter === "function"
1711
+ ? filter(createPathBuilder(), createConditionOps())
1712
+ : parseSimpleShorthand(filter);
1713
+ const compiled = compileExpr(expr);
1714
+ return compiled.expression === "" ? undefined : compiled;
1715
+ };
1716
+ /**
1717
+ * @internal DynamoDB `Limit` for the next request — the same rule `Query.ts`
1718
+ * applies, and for the same reason: `Limit` bounds rows EXAMINED, and a
1719
+ * `FilterExpression` runs after, so under a filter `limit` cannot be expressed
1720
+ * as `Limit` at all. `pageSize` (or, unset, a natural 1 MB page) is the budget
1721
+ * then, and the loop keeps asking until the page fills.
1722
+ */
1723
+ const listRequestLimit = (options, remaining) => {
1724
+ if (remaining === undefined)
1725
+ return options.pageSize;
1726
+ if (options.filter !== undefined)
1727
+ return options.pageSize;
1728
+ return options.pageSize === undefined ? remaining : Math.min(options.pageSize, remaining);
1729
+ };
1730
+ /**
1731
+ * @internal Rebuild a resume key from an item the query returned. Every item
1732
+ * carries its table key and index key as ordinary attributes, so any item can
1733
+ * address itself. Returns `undefined` if a name is missing.
1734
+ */
1735
+ const listKeyFromItem = (item, names) => {
1736
+ if (names.length === 0)
1737
+ return undefined;
1738
+ const key = {};
1739
+ for (const name of names) {
1740
+ const value = item[name];
1741
+ if (value === undefined)
1742
+ return undefined;
1743
+ key[name] = value;
1744
+ }
1745
+ return key;
1746
+ };
1747
+ const queryListPartition = (client, tableConfig, listConfig, schema, options) => Effect.gen(function* () {
1748
+ const { filter, limit, pkValue, skValues } = options;
1381
1749
  const exprNames = { "#pk": listConfig.pk.field };
1382
1750
  const exprValues = { ":pk": toAttributeValue(pkValue) };
1383
1751
  let keyCondition = "#pk = :pk";
@@ -1387,28 +1755,51 @@ const queryListPartition = (client, tableConfig, listConfig, schema, pkValue, sk
1387
1755
  exprValues[":skPrefix"] = toAttributeValue(skPrefix);
1388
1756
  keyCondition += " AND begins_with(#sk, :skPrefix)";
1389
1757
  }
1758
+ if (filter !== undefined) {
1759
+ Object.assign(exprNames, filter.names);
1760
+ Object.assign(exprValues, filter.values);
1761
+ }
1762
+ if (limit !== undefined && limit <= 0)
1763
+ return { items: [], lastKey: undefined };
1390
1764
  const items = [];
1391
- let lastKey = startKey;
1392
- do {
1765
+ let startKey = options.startKey;
1766
+ let lastKey;
1767
+ for (;;) {
1768
+ const remaining = limit === undefined ? undefined : limit - items.length;
1769
+ const requestLimit = listRequestLimit(options, remaining);
1393
1770
  const result = yield* client.query({
1394
1771
  TableName: tableConfig.name,
1395
1772
  IndexName: listConfig.index,
1396
1773
  KeyConditionExpression: keyCondition,
1397
1774
  ExpressionAttributeNames: exprNames,
1398
1775
  ExpressionAttributeValues: exprValues,
1399
- ExclusiveStartKey: lastKey,
1400
- ...(limit !== undefined ? { Limit: limit - items.length } : {}),
1776
+ ExclusiveStartKey: startKey,
1777
+ ...(filter !== undefined ? { FilterExpression: filter.expression } : {}),
1778
+ ...(requestLimit !== undefined ? { Limit: requestLimit } : {}),
1779
+ ...(options.reverse === true ? { ScanIndexForward: false } : {}),
1401
1780
  });
1402
- if (result.Items) {
1403
- for (const item of result.Items) {
1404
- items.push(fromAttributeMap(item));
1405
- }
1781
+ const returned = (result.Items ?? []);
1782
+ const lastEvaluatedKey = result.LastEvaluatedKey;
1783
+ const take = remaining === undefined ? returned.length : Math.min(remaining, returned.length);
1784
+ for (let i = 0; i < take; i++)
1785
+ items.push(fromAttributeMap(returned[i]));
1786
+ // Over-read — a filtered request returns whatever survives the filter, not
1787
+ // `Limit` rows. The surplus is dropped, so `LastEvaluatedKey` (the last row
1788
+ // EXAMINED) would skip past items the caller never saw. Resume from the
1789
+ // last item actually handed back instead.
1790
+ if (take < returned.length) {
1791
+ lastKey =
1792
+ listKeyFromItem(returned[take - 1], lastEvaluatedKey != null ? Object.keys(lastEvaluatedKey) : options.keyFields) ?? lastEvaluatedKey;
1793
+ break;
1406
1794
  }
1407
- lastKey = result.LastEvaluatedKey;
1408
- // Stop when we've collected enough items
1409
- if (limit !== undefined && items.length >= limit)
1795
+ const exhausted = lastEvaluatedKey == null;
1796
+ const limitReached = limit !== undefined && items.length >= limit;
1797
+ if (exhausted || limitReached) {
1798
+ lastKey = lastEvaluatedKey;
1410
1799
  break;
1411
- } while (lastKey !== undefined);
1800
+ }
1801
+ startKey = lastEvaluatedKey;
1802
+ }
1412
1803
  return { items, lastKey };
1413
1804
  });
1414
1805
  //# sourceMappingURL=Aggregate.js.map