effect-dynamodb 1.36.0 → 1.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/Aggregate.d.ts +29 -17
  2. package/dist/Aggregate.d.ts.map +1 -1
  3. package/dist/Aggregate.js +795 -173
  4. package/dist/Aggregate.js.map +1 -1
  5. package/dist/Batch.d.ts.map +1 -1
  6. package/dist/Batch.js +196 -77
  7. package/dist/Batch.js.map +1 -1
  8. package/dist/Collection.d.ts.map +1 -1
  9. package/dist/Collection.js +11 -2
  10. package/dist/Collection.js.map +1 -1
  11. package/dist/Collections.d.ts +15 -6
  12. package/dist/Collections.d.ts.map +1 -1
  13. package/dist/Collections.js +2 -1
  14. package/dist/Collections.js.map +1 -1
  15. package/dist/DynamoClient.d.ts +119 -10
  16. package/dist/DynamoClient.d.ts.map +1 -1
  17. package/dist/DynamoClient.js +1148 -142
  18. package/dist/DynamoClient.js.map +1 -1
  19. package/dist/Entity.d.ts +126 -51
  20. package/dist/Entity.d.ts.map +1 -1
  21. package/dist/Entity.js +3477 -539
  22. package/dist/Entity.js.map +1 -1
  23. package/dist/EventStore.d.ts +4 -3
  24. package/dist/EventStore.d.ts.map +1 -1
  25. package/dist/EventStore.js +616 -53
  26. package/dist/EventStore.js.map +1 -1
  27. package/dist/Query.d.ts +311 -25
  28. package/dist/Query.d.ts.map +1 -1
  29. package/dist/Query.js +1159 -103
  30. package/dist/Query.js.map +1 -1
  31. package/dist/Table.d.ts +172 -3
  32. package/dist/Table.d.ts.map +1 -1
  33. package/dist/Table.js +85 -4
  34. package/dist/Table.js.map +1 -1
  35. package/dist/Transaction.d.ts +18 -2
  36. package/dist/Transaction.d.ts.map +1 -1
  37. package/dist/Transaction.js +62 -8
  38. package/dist/Transaction.js.map +1 -1
  39. package/dist/change-feed/DynamoStreamsClient.d.ts +1 -1
  40. package/dist/change-feed/DynamoStreamsClient.d.ts.map +1 -1
  41. package/dist/change-feed/DynamoStreamsClient.js +3 -1
  42. package/dist/change-feed/DynamoStreamsClient.js.map +1 -1
  43. package/dist/index.d.ts +6 -3
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +1 -1
  46. package/dist/index.js.map +1 -1
  47. package/dist/internal/BoundQuery.d.ts +226 -32
  48. package/dist/internal/BoundQuery.d.ts.map +1 -1
  49. package/dist/internal/BoundQuery.js +96 -18
  50. package/dist/internal/BoundQuery.js.map +1 -1
  51. package/dist/internal/Capacity.d.ts +8 -0
  52. package/dist/internal/Capacity.d.ts.map +1 -1
  53. package/dist/internal/Capacity.js +24 -4
  54. package/dist/internal/Capacity.js.map +1 -1
  55. package/dist/internal/CredentialRefusal.d.ts +32 -0
  56. package/dist/internal/CredentialRefusal.d.ts.map +1 -0
  57. package/dist/internal/CredentialRefusal.js +80 -0
  58. package/dist/internal/CredentialRefusal.js.map +1 -0
  59. package/dist/internal/Cursor.d.ts +90 -0
  60. package/dist/internal/Cursor.d.ts.map +1 -0
  61. package/dist/internal/Cursor.js +179 -0
  62. package/dist/internal/Cursor.js.map +1 -0
  63. package/dist/internal/ExpressionLimits.d.ts +18 -2
  64. package/dist/internal/ExpressionLimits.d.ts.map +1 -1
  65. package/dist/internal/ExpressionLimits.js +35 -2
  66. package/dist/internal/ExpressionLimits.js.map +1 -1
  67. package/dist/internal/ProjectionEngine.d.ts +16 -0
  68. package/dist/internal/ProjectionEngine.d.ts.map +1 -1
  69. package/dist/internal/ProjectionEngine.js +242 -46
  70. package/dist/internal/ProjectionEngine.js.map +1 -1
  71. package/dist/internal/RangePlanner.d.ts +394 -0
  72. package/dist/internal/RangePlanner.d.ts.map +1 -0
  73. package/dist/internal/RangePlanner.js +1296 -0
  74. package/dist/internal/RangePlanner.js.map +1 -0
  75. package/dist/internal/Rekey.d.ts +78 -0
  76. package/dist/internal/Rekey.d.ts.map +1 -0
  77. package/dist/internal/Rekey.js +323 -0
  78. package/dist/internal/Rekey.js.map +1 -0
  79. package/dist/internal/TransactPlan.d.ts +8 -1
  80. package/dist/internal/TransactPlan.d.ts.map +1 -1
  81. package/dist/internal/TransactPlan.js +9 -2
  82. package/dist/internal/TransactPlan.js.map +1 -1
  83. package/dist/internal/TransactWriteOps.d.ts +10 -7
  84. package/dist/internal/TransactWriteOps.d.ts.map +1 -1
  85. package/dist/internal/TransactWriteOps.js +82 -6
  86. package/dist/internal/TransactWriteOps.js.map +1 -1
  87. package/dist/internal/TransactableOps.d.ts +31 -0
  88. package/dist/internal/TransactableOps.d.ts.map +1 -1
  89. package/dist/internal/TransactableOps.js +50 -1
  90. package/dist/internal/TransactableOps.js.map +1 -1
  91. package/dist/internal/WriteDiagnosis.d.ts.map +1 -1
  92. package/dist/internal/WriteDiagnosis.js +10 -7
  93. package/dist/internal/WriteDiagnosis.js.map +1 -1
  94. package/package.json +2 -2
package/dist/Aggregate.js CHANGED
@@ -10,10 +10,11 @@
10
10
  */
11
11
  import { TypeId as SchemaAggregateTypeId } from "@effect-dynamodb/schema/Aggregate.js";
12
12
  import * as DynamoModel from "@effect-dynamodb/schema/DynamoModel.js";
13
- import { composeCollectionKey, composeKey } from "@effect-dynamodb/schema/DynamoSchema.js";
14
- import { AggregateAssemblyError, AggregateDecompositionError, AggregateTransactionOverflow, ConditionalCheckFailed, DynamoError, DynamoValidationError, RefNotFound, TransactionCancelled, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
13
+ import { composeCollectionKey, composeKey, KEY_DELIMITER, } from "@effect-dynamodb/schema/DynamoSchema.js";
14
+ import { AggregateAssemblyError, AggregateDecompositionError, AggregateTransactionOverflow, ConditionalCheckFailed, DynamoError, DynamoValidationError, isAwsTransactionCancelled, RefNotFound, TransactionCancelled, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
15
15
  import { makeCompositeKeyForm, toCompositeKeyRecord, } from "@effect-dynamodb/schema/internal/CompositeCodec.js";
16
16
  import { buildDateTransform, containsWireTransform, matchDateRepresentation, optionalField, resolveSystemFields, substituteSchemaDeep, validateNoTransformOverride, } from "@effect-dynamodb/schema/internal/EntitySchemas.js";
17
+ import * as KeyText136 from "@effect-dynamodb/schema/internal/KeyText136.js";
17
18
  import { isDispatchEligible } from "@effect-dynamodb/schema/internal/PackedCodec.js";
18
19
  import { extractArrayElement, getSchemaFields, hasEncodingTransformation, } from "@effect-dynamodb/schema/internal/SchemaAccessors.js";
19
20
  import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
@@ -22,8 +23,10 @@ import * as Batch from "./Batch.js";
22
23
  import { fromDefinition as entityFromDefinition } from "./Entity.js";
23
24
  import * as Capacity from "./internal/Capacity.js";
24
25
  import { compileExpr, createConditionOps, emptyPartProblem, isEmptyExpr, parseSimpleShorthand, } from "./internal/Expr.js";
26
+ import { asReadGuard } from "./internal/ExpressionLimits.js";
25
27
  import { hasStoredForm, packItemSync, unpackItemSync, withRegistry } from "./internal/Packed.js";
26
28
  import { createPathBuilder } from "./internal/PathBuilder.js";
29
+ import * as Rekey from "./internal/Rekey.js";
27
30
  import { generateTimestampPrimitive } from "./internal/TransactableOps.js";
28
31
  import { diagnosedValidationError, diagnoseFailure, explainDiagnosis, mergeKeyAttributes, transactItemTarget, withCause, } from "./internal/WriteDiagnosis.js";
29
32
  import { fromAttributeMap, toAttributeMap, toAttributeValue } from "./Marshaller.js";
@@ -590,7 +593,9 @@ const makeAggregate = (schema, config) => {
590
593
  return map;
591
594
  };
592
595
  /** Bind the per-write stamp: config + the Clock-backed instant + carry-forward. */
593
- const stampFor = (now, existingCreated) => stampFields === undefined ? undefined : { fields: stampFields, now, existingCreated };
596
+ const stampFor = (now, existingCreated, existingUpdated) => stampFields === undefined
597
+ ? undefined
598
+ : { fields: stampFields, now, existingCreated, existingUpdated };
594
599
  // Build optics at construction time (once per aggregate definition)
595
600
  const classToPlain = Schema.toIso(schema);
596
601
  const opticRoot = Schema.toIsoFocus(schema);
@@ -644,26 +649,538 @@ const makeAggregate = (schema, config) => {
644
649
  "Drop `consistentRead` from the aggregate, or key it on the table's primary " +
645
650
  "partition key so assembly reads the base table.", original);
646
651
  /** Compose the PK and query all items; a rejected read fails as `rejected` names it. */
647
- const readPartition = (key, rejected) => Effect.flatMap(readPartitionRaw(key, rejected), (read) =>
652
+ const readPartition = (key, rejected, force = false, stored) => Effect.flatMap(readPartitionRaw(key, rejected, force, stored), (read) =>
648
653
  // Packed edge attributes, read by their stored form (#147).
649
654
  Effect.as(unpackAggregateItems(read.allItems, dispatchByType, aggregateName), read));
655
+ /**
656
+ * The collection partition key 1.36 composed for `record` (#171, DESIGN.md
657
+ * §7 "Rows written by 1.36"), when its text is not `pkValue`'s: a numeric
658
+ * partition composite. `undefined` when the text is the same, or the
659
+ * schema's `legacyKeys` is off — unless `force`d: `rekey` moves rows
660
+ * whatever it is set to.
661
+ */
662
+ const collectionPk136 = (record, pkValue, force = false) => {
663
+ if (!force && !KeyText136.enabled(config.schema))
664
+ return undefined;
665
+ const pk136 = composeCollectionKey(config.schema, config.collection.name, KeyText136.extractComposites(config.pk.composite, keyRecord(record)));
666
+ return pk136 === pkValue ? undefined : pk136;
667
+ };
650
668
  /** {@link readPartition} without unpacking — `delete` needs only the keys. */
651
- const readPartitionRaw = (key, rejected) => Effect.gen(function* () {
669
+ const readPartitionRaw = (key, rejected, force = false,
670
+ /** Each item as stored, too ({@link queryAllItems}). */
671
+ stored) => Effect.gen(function* () {
652
672
  const client = yield* Capacity.client;
653
673
  const tableConfig = yield* config.table.Tag;
654
674
  const composites = KeyComposer.extractComposites(config.pk.composite, keyRecord(key));
655
675
  const pkValue = composeCollectionKey(config.schema, config.collection.name, composites);
656
- const allItems = yield* queryAllItems(client, tableConfig.name, config.collection.index, config.pk.field, pkValue, consistentRead, rejected);
657
- return { client, tableConfig, pkValue, composites, allItems };
676
+ const read = (partition) => queryAllItems(client, tableConfig.name, config.collection.index, config.pk.field, partition, consistentRead, rejected, stored);
677
+ // An aggregate 1.36 wrote under its 1.36 partition text is read there
678
+ // too, until `update` or `rekey` moves its rows (#171). Its rows come
679
+ // first: they are the older ones.
680
+ const pk136 = collectionPk136(key, pkValue, force);
681
+ const allItems = pk136 === undefined
682
+ ? yield* read(pkValue)
683
+ : [...(yield* read(pk136)), ...(yield* read(pkValue))];
684
+ return { client, tableConfig, pkValue, pk136, composites, allItems };
658
685
  });
659
686
  /** Shared: compose PK and query all items, a rejected read a `ValidationError`. */
660
- const fetchPartition = (key, operation) => readPartition(key, (diagnosis, original) => new ValidationError({
687
+ const fetchPartition = (key, operation, force = false, stored) => readPartition(key, (diagnosis, original) => new ValidationError({
661
688
  entityType: aggregateName,
662
689
  operation: `${operation}.consistentRead`,
663
690
  cause: assemblyReadCause(operation, diagnosis, original),
664
- }));
691
+ }), force, stored);
665
692
  const derivedSchemas = deriveAggregateSchemas(schema, config.edges, config.pk.composite);
666
- return withOperations(aggregateName, {
693
+ /** A stored row's position: its partition and sort key. */
694
+ const positionOf = (pk, sk) => `${pk}\u0000${sk}`;
695
+ /** Attributes holding index keys the aggregate composes: the collection SK mirror, the list keys. */
696
+ const indexFields = [
697
+ ...(config.collection.index !== undefined && config.collection.sk !== undefined
698
+ ? [config.collection.sk.field]
699
+ : []),
700
+ ...(config.list === undefined ? [] : [config.list.pk.field, config.list.sk.field]),
701
+ ];
702
+ /** Index key attributes of `composed` whose text the row stored as `stored` doesn't hold. */
703
+ const staleIndexFields = (composed, stored) => indexFields.filter((field) => {
704
+ const text = composed[field]?.S;
705
+ return text !== undefined && text !== stored[field];
706
+ });
707
+ /**
708
+ * Where the aggregate's rows are stored (#171, DESIGN.md §7 "Rows written by
709
+ * 1.36"): for each decomposed item, its current key and its 1.36 key — the
710
+ * 1.36 partition text, and the sort key 1.36 composed from its
711
+ * discriminator and `many`-edge composites — the rows stored at each, and
712
+ * where the item is ({@link placeRows}).
713
+ */
714
+ const locateRows = (groups, composed, allItems, pkValue, pk136) => {
715
+ const storedAt = new Map();
716
+ for (const item of allItems) {
717
+ storedAt.set(positionOf(String(item[config.pk.field]), String(item.sk)), item);
718
+ }
719
+ const rows = groups.flatMap((group, g) => group.items.map((item, i) => {
720
+ const built = composed[g].items[i];
721
+ return {
722
+ group: group.name,
723
+ built,
724
+ current: { pk: pkValue, sk: built.sk?.S ?? "" },
725
+ old: {
726
+ pk: pk136 ?? pkValue,
727
+ sk: composeKey(config.schema, item.entityType, [...item.skComposites136]),
728
+ },
729
+ };
730
+ }));
731
+ const placements = placeRows(rows.map((row) => ({
732
+ current: positionOf(row.current.pk, row.current.sk),
733
+ old: positionOf(row.old.pk, row.old.sk),
734
+ })), (position) => storedAt.has(position));
735
+ return rows.map((row, r) => ({
736
+ ...row,
737
+ placement: placements[r],
738
+ atCurrent: storedAt.get(positionOf(row.current.pk, row.current.sk)),
739
+ atOld: storedAt.get(positionOf(row.old.pk, row.old.sk)),
740
+ }));
741
+ };
742
+ const updateCore = (key, mutationFn) => Effect.gen(function* () {
743
+ const { client, tableConfig, pkValue, pk136, allItems } = yield* fetchPartition(key, "update");
744
+ if (allItems.length === 0) {
745
+ return yield* new AggregateAssemblyError({
746
+ aggregate: aggregateName,
747
+ reason: "No items found for aggregate key",
748
+ key,
749
+ });
750
+ }
751
+ // 1. Assemble current state
752
+ const current = yield* assembleAggregate(decodeSchema, rootNode, allItems, contextFields, key, aggregateName, systemAttrs);
753
+ // 2. Apply mutation — provide optics context for composable updates
754
+ const state = toPlainState(current);
755
+ const updated = mutationFn({
756
+ state,
757
+ cursor: makeCursor(state, opticRoot),
758
+ optic: opticRoot,
759
+ current: current,
760
+ });
761
+ // 3. Validate the mutated state via `decodeSchema` — its tolerant date
762
+ // transforms accept the domain `DateTime` values the mutation yields
763
+ // for every date field (root + nested edges, Pattern A and Pattern B),
764
+ // so update no longer trips the "Expected string, got DateTime" decode
765
+ // that a strict `*FromString` schema would raise (#72 update path).
766
+ const decoded = yield* Schema.decodeUnknownEffect(writeSchema)(updated).pipe(Effect.mapError((cause) => new ValidationError({
767
+ entityType: aggregateName,
768
+ operation: "aggregate.update",
769
+ cause,
770
+ })));
771
+ const assembledOld = { ...current };
772
+ const assembledNew = { ...decoded };
773
+ // 4. Decompose both old and new
774
+ const oldGroups = yield* decomposeAggregate(assembledOld, rootNode, contextFields, rootEncoders, aggregateName, config.schema);
775
+ const newGroups = yield* decomposeAggregate(assembledNew, rootNode, contextFields, rootEncoders, aggregateName, config.schema);
776
+ // 5. Diff at the ITEM (primary-key) level, not just the group level.
777
+ // A `many`-edge element — and a cleared `one` edge — lives in its PARENT's
778
+ // transaction group, so removing it SHRINKS a group rather than dropping a
779
+ // whole group. A group-level diff would rewrite the group's surviving PUTs
780
+ // but never DELETE the removed row, leaving an orphan that re-appears on
781
+ // read (#74). We therefore compute, per changed group, both the items to
782
+ // PUT (new) and the items to DELETE (in old, absent from the entire new
783
+ // state), and apply them together in one transaction.
784
+ const oldGroupMap = new Map(oldGroups.map((g) => [g.name, g]));
785
+ const newGroupMap = new Map(newGroups.map((g) => [g.name, g]));
786
+ // Check if context fields changed — if so, ALL groups must be rewritten
787
+ let contextChanged = false;
788
+ for (const field of contextFields) {
789
+ if (!deepEqual(assembledOld[field], assembledNew[field])) {
790
+ contextChanged = true;
791
+ break;
792
+ }
793
+ }
794
+ // 6. Build the full new + old DynamoDB item sets (with composed keys). The
795
+ // collection SK composites only affect the LSI/GSI mirror attributes,
796
+ // never the (pk, sk) identity used to PUT or DELETE, so the old set's
797
+ // composites are irrelevant — we read only its (pk, sk) for deletes.
798
+ // Rewritten rows are stamped `updated`; their stored `created` is read
799
+ // back off the fetched partition and re-emitted verbatim, since these are
800
+ // PUTs and would otherwise clobber it. The old set is only mined for
801
+ // (pk, sk) delete keys, so it is never stamped.
802
+ const now = yield* DateTime.now;
803
+ const oldDynamo = buildDynamoItems(oldGroups, config, pkValue, sortKeyComposites(config.schema, KeyComposer.extractComposites(config.collection.sk?.composite ?? [], keyRecord(assembledOld))), keyRecord);
804
+ const skOf = (item) => item.sk?.S ?? "";
805
+ // Rows still under their 1.36 key (#171, DESIGN.md §7 "Rows written by
806
+ // 1.36"): the 1.36 partition text, or a sort key whose discriminator or
807
+ // `many`-edge composite text changed ({@link locateRows}). Each moves in
808
+ // its own group's transaction — the group is rewritten at the current
809
+ // keys and the row's stored key deleted beside the Put — keeping its
810
+ // `created`. A row at its current key whose list or collection index key
811
+ // holds 1.36 text (a list sort key composite whose text changed) has its
812
+ // group rewritten too, which recomposes the index keys. A row under both
813
+ // keys is taken as current; `rekey` reports it.
814
+ const existingCreated = new Map(readExistingCreated(allItems));
815
+ const moved = new Map();
816
+ const movedSks = new Set();
817
+ const relisted = new Set();
818
+ if (KeyText136.enabled(config.schema)) {
819
+ const createdField = stampFields?.createdAt ?? null;
820
+ for (const row of locateRows(oldGroups, oldDynamo, allItems, pkValue, pk136)) {
821
+ if (row.placement === "old") {
822
+ const keys = moved.get(row.group) ?? [];
823
+ keys.push(toAttributeMap({ [config.pk.field]: row.old.pk, sk: row.old.sk }));
824
+ moved.set(row.group, keys);
825
+ movedSks.add(row.current.sk);
826
+ const created = createdField === null ? undefined : row.atOld?.[createdField];
827
+ if (created !== undefined)
828
+ existingCreated.set(row.current.sk, created);
829
+ }
830
+ else if ((row.placement === "current" || row.placement === "both") &&
831
+ row.atCurrent !== undefined &&
832
+ staleIndexFields(row.built, row.atCurrent).length > 0) {
833
+ relisted.add(row.group);
834
+ }
835
+ }
836
+ }
837
+ // Packed AFTER the group diff above compared the decomposed values, so
838
+ // an unchanged packed value is never rewritten (#147).
839
+ const newDynamo = buildDynamoItems(yield* packAggregateGroups(newGroups, packedByType, aggregateName, "update"), config, pkValue, sortKeyComposites(config.schema, KeyComposer.extractComposites(config.collection.sk?.composite ?? [], keyRecord(assembledNew))), keyRecord, stampFor(now, existingCreated));
840
+ const newItemsByGroup = new Map(newDynamo.map((g) => [g.group, g.items]));
841
+ const oldItemsByGroup = new Map(oldDynamo.map((g) => [g.group, g.items]));
842
+ // Every sk that survives anywhere in the new decomposition — an old item is
843
+ // an orphan to DELETE only if its key appears in no new group at all.
844
+ const survivingSks = new Set();
845
+ for (const g of newDynamo) {
846
+ for (const item of g.items)
847
+ survivingSks.add(skOf(item));
848
+ }
849
+ // 7. Per group (union of old + new names), collect PUTs + orphan DELETEs.
850
+ const groupWrites = [];
851
+ const groupNames = new Set([...newGroupMap.keys(), ...oldGroupMap.keys()]);
852
+ for (const name of groupNames) {
853
+ const oldGroup = oldGroupMap.get(name);
854
+ const newGroup = newGroupMap.get(name);
855
+ const changed = contextChanged ||
856
+ oldGroup === undefined ||
857
+ newGroup === undefined ||
858
+ !deepEqualGroups(oldGroup, newGroup) ||
859
+ moved.has(name) ||
860
+ relisted.has(name) ||
861
+ (newItemsByGroup.get(name) ?? []).some((item) => movedSks.has(skOf(item)));
862
+ if (!changed)
863
+ continue;
864
+ const puts = newItemsByGroup.get(name) ?? [];
865
+ // A row under its 1.36 key is deleted there — removed from the
866
+ // aggregate or moved — never at a current key it does not have. A
867
+ // 1.36 key that is another row's current key (#171: 1.36 did not
868
+ // escape) is that row's Put, which overwrites it: the same item can't
869
+ // be written twice in one transaction.
870
+ const deletes = [
871
+ ...(oldItemsByGroup.get(name) ?? [])
872
+ .filter((item) => !survivingSks.has(skOf(item)) && !movedSks.has(skOf(item)))
873
+ .map((item) => ({
874
+ [config.pk.field]: item[config.pk.field],
875
+ sk: item.sk,
876
+ })),
877
+ ...(moved.get(name) ?? []).filter((key) => !(key[config.pk.field]?.S === pkValue &&
878
+ survivingSks.has(key.sk?.S ?? ""))),
879
+ ];
880
+ if (puts.length === 0 && deletes.length === 0)
881
+ continue;
882
+ groupWrites.push({ group: name, puts, deletes });
883
+ }
884
+ if (groupWrites.length > 0) {
885
+ yield* writeTransactionGroups(client, tableConfig.name, groupWrites, aggregateName, groupKeys);
886
+ }
887
+ return decoded;
888
+ });
889
+ /** How many times `rekey` reads an aggregate again after a write changed it in between. */
890
+ const REKEY_ATTEMPTS = 3;
891
+ /**
892
+ * `rekey`'s move of the aggregate at `key` (#171, DESIGN.md §7 "`rekey`"):
893
+ * read as `update` reads it, whatever `legacyKeys` is set to, and placed
894
+ * ({@link locateRows}). Each row under its 1.36 key moves — a Put of the
895
+ * row `update` would write at its current key, keeping its `created` and
896
+ * `updated`, beside a Delete of the 1.36 row — and a row at its current key
897
+ * whose list or collection index key holds 1.36 text has those keys
898
+ * rewritten. Nothing else is written, and every write is conditional: the
899
+ * Put only where nothing is, the Delete and the rewrite only while the row
900
+ * is as read (`asReadGuard`). Rows whose keys chain move in order, the
901
+ * one whose current key is free first, so no write lands on a row still to
902
+ * move. A write that loses to a concurrent one reads the aggregate again; a
903
+ * row stored under both keys leaves the aggregate as it is, reported. A dry
904
+ * run writes nothing.
905
+ */
906
+ const rekeyCore = (key, dryRun) => Effect.gen(function* () {
907
+ let movedRows = 0;
908
+ let rewrittenRows = 0;
909
+ for (let attempt = 0; attempt < REKEY_ATTEMPTS; attempt++) {
910
+ const stored = [];
911
+ const { client, tableConfig, pkValue, pk136, allItems } = yield* fetchPartition(key, "rekey", true, stored);
912
+ const done = (rows) => {
913
+ const unchanged = Math.max(0, rows - movedRows - rewrittenRows);
914
+ return {
915
+ ...(unchanged > 0 && { unchanged }),
916
+ ...(movedRows > 0 && { moved: { aggregate: movedRows } }),
917
+ ...(rewrittenRows > 0 && { indexKeysRewritten: rewrittenRows }),
918
+ };
919
+ };
920
+ // Gone since the scan read it: deleted by a write.
921
+ if (allItems.length === 0)
922
+ return done(0);
923
+ // A write moving the aggregate between the reads of its two partitions shows its
924
+ // rows under both, and they can't be assembled or placed: with rows in both, read
925
+ // again, and while a row read under the 1.36 partition has gone from it, retry.
926
+ const at136 = allItems.filter((item) => item[config.pk.field] === pk136);
927
+ if (pk136 !== undefined && at136.length > 0 && at136.length < allItems.length) {
928
+ const again = yield* fetchPartition(key, "rekey", true);
929
+ const held = new Set(again.allItems
930
+ .filter((item) => item[config.pk.field] === pk136)
931
+ .map((item) => String(item.sk)));
932
+ if (at136.some((item) => !held.has(String(item.sk))))
933
+ continue;
934
+ }
935
+ const current = yield* assembleAggregate(decodeSchema, rootNode, allItems, contextFields, key, aggregateName, systemAttrs);
936
+ const assembled = { ...current };
937
+ const groups = yield* decomposeAggregate(assembled, rootNode, contextFields, rootEncoders, aggregateName, config.schema);
938
+ const rootComposites = sortKeyComposites(config.schema, KeyComposer.extractComposites(config.collection.sk?.composite ?? [], keyRecord(assembled)));
939
+ const rows = locateRows(groups, buildDynamoItems(groups, config, pkValue, rootComposites, keyRecord), allItems, pkValue, pk136);
940
+ const doubled = rows.filter((row) => row.placement === "both" || row.placement === "ambiguous");
941
+ if (doubled.length > 0) {
942
+ // In one partition, a move during the read shows a row at both keys too: it's
943
+ // only under both while it's still under its 1.36 key.
944
+ const held = yield* Effect.forEach(doubled, (row) => client
945
+ .getItem({
946
+ TableName: tableConfig.name,
947
+ Key: toAttributeMap({ [config.pk.field]: row.old.pk, sk: row.old.sk }),
948
+ ConsistentRead: true,
949
+ })
950
+ .pipe(Effect.map(({ Item }) => Item !== undefined)));
951
+ if (held.some((still) => !still))
952
+ continue;
953
+ return {
954
+ conflicts: doubled.map((row) => ({
955
+ member: aggregateName,
956
+ kind: "aggregate",
957
+ reason: "bothKeys",
958
+ key: { [config.pk.field]: row.old.pk, sk: row.old.sk },
959
+ target: { [config.pk.field]: row.current.pk, sk: row.current.sk },
960
+ message: row.placement === "both"
961
+ ? "The row is also stored under its current key: the aggregate is left as it is."
962
+ : "Its rows' keys alias across 1.36 and 1.37 text, and which row is stored " +
963
+ "under which key can't be told from the keys held: the aggregate is left " +
964
+ "as it is.",
965
+ })),
966
+ };
967
+ }
968
+ // Each row's write keeps the `created` and `updated` of the row it moves.
969
+ const createdField = stampFields?.createdAt ?? null;
970
+ const updatedField = stampFields?.updatedAt ?? null;
971
+ const keptCreated = new Map();
972
+ const keptUpdated = new Map();
973
+ for (const row of rows) {
974
+ const from = row.placement === "old" ? row.atOld : row.atCurrent;
975
+ if (from === undefined)
976
+ continue;
977
+ if (createdField !== null && from[createdField] !== undefined) {
978
+ keptCreated.set(row.current.sk, from[createdField]);
979
+ }
980
+ if (updatedField !== null && from[updatedField] !== undefined) {
981
+ keptUpdated.set(row.current.sk, from[updatedField]);
982
+ }
983
+ }
984
+ const built = buildDynamoItems(yield* packAggregateGroups(groups, packedByType, aggregateName, "update"), config, pkValue, rootComposites, keyRecord, stampFor(yield* DateTime.now, keptCreated, keptUpdated)).flatMap((group) => group.items);
985
+ const storedAt = new Map(stored.map((item) => [
986
+ positionOf(item[config.pk.field]?.S ?? "", item.sk?.S ?? ""),
987
+ item,
988
+ ]));
989
+ const guardOf = (at) => asReadGuard(storedAt.get(positionOf(at.pk, at.sk)) ?? {}, config.pk.field, [
990
+ ...(updatedField === null ? [] : [updatedField]),
991
+ ]);
992
+ const keyOf = (at) => toAttributeMap({ [config.pk.field]: at.pk, sk: at.sk });
993
+ const moves = [];
994
+ const rewrites = [];
995
+ for (const [r, row] of rows.entries()) {
996
+ if (row.placement === "old") {
997
+ moves.push({
998
+ from: row.old,
999
+ to: row.current,
1000
+ items: [
1001
+ {
1002
+ Put: {
1003
+ TableName: tableConfig.name,
1004
+ Item: built[r],
1005
+ ConditionExpression: "attribute_not_exists(#pk)",
1006
+ ExpressionAttributeNames: { "#pk": config.pk.field },
1007
+ },
1008
+ },
1009
+ {
1010
+ Delete: { TableName: tableConfig.name, Key: keyOf(row.old), ...guardOf(row.old) },
1011
+ },
1012
+ ],
1013
+ });
1014
+ continue;
1015
+ }
1016
+ if (row.placement !== "current" || row.atCurrent === undefined)
1017
+ continue;
1018
+ const stale = staleIndexFields(built[r], row.atCurrent);
1019
+ if (stale.length === 0)
1020
+ continue;
1021
+ const guard = guardOf(row.current);
1022
+ const names = { ...guard.ExpressionAttributeNames };
1023
+ const values = { ...guard.ExpressionAttributeValues };
1024
+ const sets = stale.map((field, i) => {
1025
+ names[`#x${i}`] = field;
1026
+ values[`:x${i}`] = built[r][field];
1027
+ return `#x${i} = :x${i}`;
1028
+ });
1029
+ rewrites.push([
1030
+ {
1031
+ Update: {
1032
+ TableName: tableConfig.name,
1033
+ Key: keyOf(row.current),
1034
+ UpdateExpression: `SET ${sets.join(", ")}`,
1035
+ ConditionExpression: guard.ConditionExpression,
1036
+ ExpressionAttributeNames: names,
1037
+ ExpressionAttributeValues: values,
1038
+ },
1039
+ },
1040
+ ]);
1041
+ }
1042
+ if (moves.length === 0 && rewrites.length === 0)
1043
+ return done(rows.length);
1044
+ if (dryRun) {
1045
+ movedRows += moves.length;
1046
+ rewrittenRows += rewrites.length;
1047
+ return done(rows.length);
1048
+ }
1049
+ // In waves: a row moves once no row still to move is stored at its
1050
+ // current key. Each move's Put and Delete share a transaction; up to
1051
+ // TRANSACT_LIMIT items go in one.
1052
+ const units = rewrites.map((items) => ({ kind: "rewrite", items }));
1053
+ let pending = moves;
1054
+ let lost = false;
1055
+ while (!lost && (pending.length > 0 || units.length > 0)) {
1056
+ const sources = new Set(pending.map((move) => positionOf(move.from.pk, move.from.sk)));
1057
+ const wave = pending.filter((move) => !sources.has(positionOf(move.to.pk, move.to.sk)));
1058
+ // Moves whose keys cycle: `placeRows` leaves such rows ambiguous, so
1059
+ // none reach here — reported below if one ever does.
1060
+ if (wave.length === 0 && units.length === 0)
1061
+ break;
1062
+ pending = pending.filter((move) => !wave.includes(move));
1063
+ units.push(...wave.map((move) => ({ kind: "move", items: move.items })));
1064
+ while (!lost && units.length > 0) {
1065
+ const chunk = [];
1066
+ let size = 0;
1067
+ while (units.length > 0 && size + units[0].items.length <= TRANSACT_LIMIT) {
1068
+ size += units[0].items.length;
1069
+ chunk.push(units.shift());
1070
+ }
1071
+ const written = yield* client
1072
+ .transactWriteItems({ TransactItems: chunk.flatMap((unit) => unit.items) })
1073
+ .pipe(Effect.as(true), Effect.catch((error) => isAwsTransactionCancelled(error.cause)
1074
+ ? Effect.succeed(false)
1075
+ : Effect.fail(error)));
1076
+ if (!written) {
1077
+ lost = true;
1078
+ break;
1079
+ }
1080
+ for (const unit of chunk) {
1081
+ if (unit.kind === "move")
1082
+ movedRows++;
1083
+ else
1084
+ rewrittenRows++;
1085
+ }
1086
+ }
1087
+ }
1088
+ // A write landed in between: read the aggregate again.
1089
+ if (lost)
1090
+ continue;
1091
+ if (pending.length === 0)
1092
+ return done(rows.length);
1093
+ return {
1094
+ ...done(rows.length - pending.length),
1095
+ conflicts: pending.map((move) => ({
1096
+ member: aggregateName,
1097
+ kind: "aggregate",
1098
+ reason: "failed",
1099
+ key: { [config.pk.field]: move.from.pk, sk: move.from.sk },
1100
+ target: { [config.pk.field]: move.to.pk, sk: move.to.sk },
1101
+ message: "Its keys cycle with another row's: neither can move first.",
1102
+ })),
1103
+ };
1104
+ }
1105
+ return {
1106
+ ...(movedRows > 0 && { moved: { aggregate: movedRows } }),
1107
+ ...(rewrittenRows > 0 && { indexKeysRewritten: rewrittenRows }),
1108
+ conflicts: [
1109
+ {
1110
+ member: aggregateName,
1111
+ kind: "aggregate",
1112
+ reason: "contended",
1113
+ key,
1114
+ target: undefined,
1115
+ message: "Concurrent writes changed the aggregate on every attempt to move it.",
1116
+ },
1117
+ ],
1118
+ };
1119
+ });
1120
+ // rekey (#171): the aggregate's part in `db.tables.<Table>.rekey()`. Its
1121
+ // rows move together ({@link rekeyCore}): the scan's root row — under the
1122
+ // current or the 1.36 partition text — runs the move once per run; a row
1123
+ // below the root moves with it.
1124
+ const rekeyTypes = (() => {
1125
+ const types = new Set();
1126
+ const walk = (node) => {
1127
+ types.add(node.entityType);
1128
+ for (const child of node.children)
1129
+ walk(child);
1130
+ };
1131
+ walk(rootNode);
1132
+ return [...types];
1133
+ })();
1134
+ const rekeyPrefix = composeCollectionKey(config.schema, config.collection.name, []);
1135
+ const rekeyRootSk = composeKey(config.schema, config.root.entityType, []);
1136
+ const rekeyHandler = {
1137
+ name: aggregateName,
1138
+ types: rekeyTypes,
1139
+ row: (row, ctx) => Effect.gen(function* () {
1140
+ const pk = row[config.pk.field]?.S;
1141
+ if (pk === undefined || (pk !== rekeyPrefix && !pk.startsWith(`${rekeyPrefix}#`))) {
1142
+ return undefined;
1143
+ }
1144
+ if (row.sk?.S !== rekeyRootSk || row.__edd_e__?.S !== config.root.entityType)
1145
+ return {};
1146
+ const key = fromAttributeMap(row);
1147
+ let current;
1148
+ try {
1149
+ current = composeCollectionKey(config.schema, config.collection.name, KeyComposer.extractComposites(config.pk.composite, keyRecord(key)));
1150
+ }
1151
+ catch {
1152
+ return { skipped: { unrecognizedKey: 1 } };
1153
+ }
1154
+ if (!ctx.claim(`${aggregateName}\u0000${current}`))
1155
+ return {};
1156
+ return yield* rekeyCore(key, ctx.dryRun).pipe(Effect.catch((error) => {
1157
+ if (Rekey.isDynamoClientError(error))
1158
+ return Effect.fail(error);
1159
+ const assembly = error._tag === "AggregateAssemblyError" ? error.reason : undefined;
1160
+ // Gone since the scan read it: deleted by a write.
1161
+ if (assembly === "Missing root item")
1162
+ return Effect.succeed({});
1163
+ // A root under both partition keys (its sort key is fixed, so one
1164
+ // partition holds one): neither copy is judged.
1165
+ const both = assembly === "Multiple root items found";
1166
+ return Effect.succeed({
1167
+ conflicts: [
1168
+ {
1169
+ member: aggregateName,
1170
+ kind: "aggregate",
1171
+ reason: both ? "bothKeys" : "failed",
1172
+ key: { [config.pk.field]: pk, sk: rekeyRootSk },
1173
+ target: { [config.pk.field]: current, sk: rekeyRootSk },
1174
+ message: both
1175
+ ? "The aggregate's root is also stored under its current key."
1176
+ : `${error._tag}: ${String(assembly ?? error.message ?? "")}`,
1177
+ },
1178
+ ],
1179
+ });
1180
+ }));
1181
+ }),
1182
+ };
1183
+ return Rekey.carry(rekeyHandler, withOperations(aggregateName, {
667
1184
  [TypeId]: TypeId,
668
1185
  _tag: "Aggregate",
669
1186
  _tableTag: config.table.Tag,
@@ -720,7 +1237,7 @@ const makeAggregate = (schema, config) => {
720
1237
  // form — `update` already did (see `newDynamo` / `oldDynamo` below), so
721
1238
  // leaving `create` raw made the two write paths mirror the collection SK
722
1239
  // differently for a transformed composite.
723
- const collectionSkComposites = KeyComposer.extractComposites(config.collection.sk?.composite ?? [], keyRecord(assembled));
1240
+ const collectionSkComposites = sortKeyComposites(config.schema, KeyComposer.extractComposites(config.collection.sk?.composite ?? [], keyRecord(assembled)));
724
1241
  // 6. Build DynamoDB items with composed keys (create only ever PUTs).
725
1242
  // Every row is new, so `created` has nothing to carry forward.
726
1243
  const now = yield* DateTime.now;
@@ -739,116 +1256,18 @@ const makeAggregate = (schema, config) => {
739
1256
  puts: [...g.items].sort((a, b) => Number(isRoot(b)) - Number(isRoot(a))),
740
1257
  deletes: [],
741
1258
  }));
1259
+ // An aggregate 1.36 created under its 1.36 partition text exists too:
1260
+ // its root is checked absent in the same transaction (#171).
1261
+ const pk136 = collectionPk136(assembled, pkValue);
742
1262
  yield* writeTransactionGroups(client, tableConfig.name, ordered, aggregateName, groupKeys, {
743
1263
  pkField: config.pk.field,
744
1264
  entityType: config.root.entityType,
745
1265
  key: { [config.pk.field]: pkValue, sk: rootSk },
1266
+ ...(pk136 === undefined ? {} : { key136: { [config.pk.field]: pk136, sk: rootSk } }),
746
1267
  });
747
1268
  return decoded;
748
1269
  }),
749
- update: (key, mutationFn) => Effect.gen(function* () {
750
- const { client, tableConfig, pkValue, allItems } = yield* fetchPartition(key, "update");
751
- if (allItems.length === 0) {
752
- return yield* new AggregateAssemblyError({
753
- aggregate: aggregateName,
754
- reason: "No items found for aggregate key",
755
- key,
756
- });
757
- }
758
- // 1. Assemble current state
759
- const current = yield* assembleAggregate(decodeSchema, rootNode, allItems, contextFields, key, aggregateName, systemAttrs);
760
- // 2. Apply mutation — provide optics context for composable updates
761
- const state = toPlainState(current);
762
- const updated = mutationFn({
763
- state,
764
- cursor: makeCursor(state, opticRoot),
765
- optic: opticRoot,
766
- current: current,
767
- });
768
- // 3. Validate the mutated state via `decodeSchema` — its tolerant date
769
- // transforms accept the domain `DateTime` values the mutation yields
770
- // for every date field (root + nested edges, Pattern A and Pattern B),
771
- // so update no longer trips the "Expected string, got DateTime" decode
772
- // that a strict `*FromString` schema would raise (#72 update path).
773
- const decoded = yield* Schema.decodeUnknownEffect(writeSchema)(updated).pipe(Effect.mapError((cause) => new ValidationError({
774
- entityType: aggregateName,
775
- operation: "aggregate.update",
776
- cause,
777
- })));
778
- const assembledOld = { ...current };
779
- const assembledNew = { ...decoded };
780
- // 4. Decompose both old and new
781
- const oldGroups = yield* decomposeAggregate(assembledOld, rootNode, contextFields, rootEncoders, aggregateName, config.schema);
782
- const newGroups = yield* decomposeAggregate(assembledNew, rootNode, contextFields, rootEncoders, aggregateName, config.schema);
783
- // 5. Diff at the ITEM (primary-key) level, not just the group level.
784
- // A `many`-edge element — and a cleared `one` edge — lives in its PARENT's
785
- // transaction group, so removing it SHRINKS a group rather than dropping a
786
- // whole group. A group-level diff would rewrite the group's surviving PUTs
787
- // but never DELETE the removed row, leaving an orphan that re-appears on
788
- // read (#74). We therefore compute, per changed group, both the items to
789
- // PUT (new) and the items to DELETE (in old, absent from the entire new
790
- // state), and apply them together in one transaction.
791
- const oldGroupMap = new Map(oldGroups.map((g) => [g.name, g]));
792
- const newGroupMap = new Map(newGroups.map((g) => [g.name, g]));
793
- // Check if context fields changed — if so, ALL groups must be rewritten
794
- let contextChanged = false;
795
- for (const field of contextFields) {
796
- if (!deepEqual(assembledOld[field], assembledNew[field])) {
797
- contextChanged = true;
798
- break;
799
- }
800
- }
801
- // 6. Build the full new + old DynamoDB item sets (with composed keys). The
802
- // collection SK composites only affect the LSI/GSI mirror attributes,
803
- // never the (pk, sk) identity used to PUT or DELETE, so the old set's
804
- // composites are irrelevant — we read only its (pk, sk) for deletes.
805
- // Rewritten rows are stamped `updated`; their stored `created` is read
806
- // back off the fetched partition and re-emitted verbatim, since these are
807
- // PUTs and would otherwise clobber it. The old set is only mined for
808
- // (pk, sk) delete keys, so it is never stamped.
809
- const now = yield* DateTime.now;
810
- // Packed AFTER the group diff above compared the decomposed values, so
811
- // an unchanged packed value is never rewritten (#147).
812
- const newDynamo = buildDynamoItems(yield* packAggregateGroups(newGroups, packedByType, aggregateName, "update"), config, pkValue, KeyComposer.extractComposites(config.collection.sk?.composite ?? [], keyRecord(assembledNew)), keyRecord, stampFor(now, readExistingCreated(allItems)));
813
- const oldDynamo = buildDynamoItems(oldGroups, config, pkValue, KeyComposer.extractComposites(config.collection.sk?.composite ?? [], keyRecord(assembledOld)), keyRecord);
814
- const skOf = (item) => item.sk?.S ?? "";
815
- const newItemsByGroup = new Map(newDynamo.map((g) => [g.group, g.items]));
816
- const oldItemsByGroup = new Map(oldDynamo.map((g) => [g.group, g.items]));
817
- // Every sk that survives anywhere in the new decomposition — an old item is
818
- // an orphan to DELETE only if its key appears in no new group at all.
819
- const survivingSks = new Set();
820
- for (const g of newDynamo) {
821
- for (const item of g.items)
822
- survivingSks.add(skOf(item));
823
- }
824
- // 7. Per group (union of old + new names), collect PUTs + orphan DELETEs.
825
- const groupWrites = [];
826
- const groupNames = new Set([...newGroupMap.keys(), ...oldGroupMap.keys()]);
827
- for (const name of groupNames) {
828
- const oldGroup = oldGroupMap.get(name);
829
- const newGroup = newGroupMap.get(name);
830
- const changed = contextChanged ||
831
- oldGroup === undefined ||
832
- newGroup === undefined ||
833
- !deepEqualGroups(oldGroup, newGroup);
834
- if (!changed)
835
- continue;
836
- const puts = newItemsByGroup.get(name) ?? [];
837
- const deletes = (oldItemsByGroup.get(name) ?? [])
838
- .filter((item) => !survivingSks.has(skOf(item)))
839
- .map((item) => ({
840
- [config.pk.field]: item[config.pk.field],
841
- sk: item.sk,
842
- }));
843
- if (puts.length === 0 && deletes.length === 0)
844
- continue;
845
- groupWrites.push({ group: name, puts, deletes });
846
- }
847
- if (groupWrites.length > 0) {
848
- yield* writeTransactionGroups(client, tableConfig.name, groupWrites, aggregateName, groupKeys);
849
- }
850
- return decoded;
851
- }),
1270
+ update: (key, mutationFn) => updateCore(key, mutationFn),
852
1271
  delete: (key) => Effect.gen(function* () {
853
1272
  const { client, tableConfig, allItems } = yield* readPartitionRaw(key,
854
1273
  // `delete` declares no `ValidationError`: a rejected read keeps
@@ -900,16 +1319,71 @@ const makeAggregate = (schema, config) => {
900
1319
  // silently returns nothing (#111).
901
1320
  const listPkComposites = KeyComposer.extractComposites(listConfig.pk.composite, keyRecord(filter ?? {}));
902
1321
  // Build SK prefix from contiguous filter values matching SK composites.
903
- // `serializeValue`, NOT `String(v)` — the write side pads numerics, so
904
- // `String(5)` would look for `5` where `0000000000000005` is stored.
1322
+ // `serializeSortKeyValue`, NOT `String(v)` — the write side encodes
1323
+ // numerics, so `String(5)` would look for `5` where `0000000000000005`
1324
+ // is stored, and escapes the sort key's text.
905
1325
  const skValues = [];
906
1326
  const listSkFilter = keyRecord(filter ?? {});
907
1327
  for (const attr of listConfig.sk.composite) {
908
1328
  if (listSkFilter[attr] !== undefined)
909
- skValues.push(KeyComposer.serializeValue(listSkFilter[attr]));
1329
+ skValues.push(KeyComposer.serializeSortKeyValue(listSkFilter[attr], config.schema.casing));
910
1330
  else
911
1331
  break; // Stop at first gap (prefix matching)
912
1332
  }
1333
+ const skComplete = skValues.length === listConfig.sk.composite.length;
1334
+ // The list keys 1.36 composed for the filter (#171, DESIGN.md §7 "Rows
1335
+ // written by 1.36"): when their text differs, the root items 1.36
1336
+ // wrote are listed too, until `update` or `rekey` moves them — under
1337
+ // the same equality / prefix rule, ahead of the current key's (behind
1338
+ // it under `reverse`).
1339
+ const legacy = (() => {
1340
+ if (!KeyText136.enabled(config.schema))
1341
+ return undefined;
1342
+ const pkComposites = KeyText136.extractComposites(listConfig.pk.composite, keyRecord(filter ?? {}));
1343
+ const skValues136 = [];
1344
+ for (const attr of listConfig.sk.composite) {
1345
+ if (listSkFilter[attr] !== undefined)
1346
+ skValues136.push(KeyText136.serializeValue(listSkFilter[attr]));
1347
+ else
1348
+ break;
1349
+ }
1350
+ // Compared as the keys spell them: cased.
1351
+ const text = (values) => composeCollectionKey(config.schema, listConfig.name, values);
1352
+ const samePk = text(pkComposites) === text(listPkComposites);
1353
+ const sameSk = text(skValues136) === text(skValues);
1354
+ return samePk && sameSk ? undefined : { pkComposites, skValues: skValues136, samePk };
1355
+ })();
1356
+ const current = { pkComposites: listPkComposites, skValues, samePk: true };
1357
+ // Under a shared partition, a row the current key condition matches is
1358
+ // read by the current pass only.
1359
+ const skMatcher = (pass) => {
1360
+ const text = composeCollectionKey(config.schema, listConfig.name, pass.skValues);
1361
+ return (sk) => pass.skValues.length === 0 ||
1362
+ (typeof sk === "string" &&
1363
+ (skComplete ? sk === text : sk.startsWith(`${text}${KEY_DELIMITER}`)));
1364
+ };
1365
+ const inCurrent = skMatcher(current);
1366
+ // A root item the current composer keys under the 1.36 condition too
1367
+ // (a value holding `#`, or `%` and two hex digits, spelled as another
1368
+ // value's escaped text) is not a 1.36 row: its list sort key is the
1369
+ // one its values compose now.
1370
+ const unmoved = (item) => {
1371
+ try {
1372
+ const sk = composeCollectionKey(config.schema, listConfig.name, sortKeyComposites(config.schema, KeyComposer.extractComposites(listConfig.sk.composite, keyRecord(item))));
1373
+ return item[listConfig.sk.field] !== sk;
1374
+ }
1375
+ catch {
1376
+ return true;
1377
+ }
1378
+ };
1379
+ const acceptFor = (pass) => {
1380
+ const filterBy = options?.filterBy;
1381
+ if (pass === current || pass.samePk === false)
1382
+ return filterBy;
1383
+ return (item) => !inCurrent(item[listConfig.sk.field]) &&
1384
+ unmoved(item) &&
1385
+ (filterBy === undefined || filterBy(item));
1386
+ };
913
1387
  let rootItems;
914
1388
  let nextKey;
915
1389
  if (listConfig.cardinality) {
@@ -927,29 +1401,33 @@ const makeAggregate = (schema, config) => {
927
1401
  `(list.cardinality = ${listConfig.cardinality}), and a sharded fan-out has no ` +
928
1402
  "resumable cursor: a DynamoDB cursor names a position in one partition, while " +
929
1403
  "this query spans all of them with no defined order across shards. `list` on a " +
930
- "sharded aggregate always returns `cursor: null`; use `limit` to bound the " +
1404
+ "sharded aggregate always returns `cursor: None`; use `limit` to bound the " +
931
1405
  "result, or drop `list.cardinality` if the partition needs paging more than it " +
932
1406
  "needs spreading.",
933
1407
  });
934
1408
  }
935
1409
  // Fan out across shards and merge. `limit` bounds each shard too — no
936
- // single shard can contribute more than the whole page.
937
- const shardQueries = Array.from({ length: listConfig.cardinality }, (_, shard) => {
1410
+ // single shard can contribute more than the whole page. The 1.36
1411
+ // keys' shards are read too, first (#171).
1412
+ const passes = legacy === undefined ? [current] : [legacy, current];
1413
+ const cardinality = listConfig.cardinality;
1414
+ const shardQueries = passes.flatMap((pass) => Array.from({ length: cardinality }, (_, shard) => {
938
1415
  const shardPkValue = composeCollectionKey(config.schema, listConfig.name, [
939
- ...listPkComposites,
1416
+ ...pass.pkComposites,
940
1417
  String(shard),
941
1418
  ]);
942
1419
  return queryListPartition(client, tableConfig, listConfig, config.schema, {
943
1420
  pkValue: shardPkValue,
944
- skValues,
1421
+ skValues: pass.skValues,
1422
+ skComplete,
945
1423
  limit,
946
1424
  pageSize,
947
1425
  filter: rootFilter,
948
- accept: options?.filterBy,
1426
+ accept: acceptFor(pass),
949
1427
  reverse: options?.reverse === true,
950
1428
  keyFields: listKeyFields,
951
1429
  }, aggregateName);
952
- });
1430
+ }));
953
1431
  const shardResults = yield* Effect.all(shardQueries);
954
1432
  const merged = shardResults.flatMap((r) => r.items);
955
1433
  // Truncate the merged fan-out to `limit`. Previously the option was
@@ -958,23 +1436,55 @@ const makeAggregate = (schema, config) => {
958
1436
  nextKey = undefined;
959
1437
  }
960
1438
  else {
961
- const startKey = options?.cursor
1439
+ let startKey = options?.cursor
962
1440
  ? JSON.parse(atob(options.cursor))
963
1441
  : undefined;
964
- const listPkValue = composeCollectionKey(config.schema, listConfig.name, listPkComposites);
965
- const result = yield* queryListPartition(client, tableConfig, listConfig, config.schema, {
966
- pkValue: listPkValue,
967
- skValues,
968
- limit,
969
- pageSize,
970
- startKey,
971
- filter: rootFilter,
972
- accept: options?.filterBy,
973
- reverse: options?.reverse === true,
974
- keyFields: listKeyFields,
975
- }, aggregateName);
976
- rootItems = result.items;
977
- nextKey = result.lastKey;
1442
+ const passes = legacy === undefined
1443
+ ? [current]
1444
+ : options?.reverse === true
1445
+ ? [current, legacy]
1446
+ : [legacy, current];
1447
+ const pkOf = (pass) => composeCollectionKey(config.schema, listConfig.name, pass.pkComposites);
1448
+ // A cursor resumes the pass its key is in (a cursor names a row).
1449
+ let at = 0;
1450
+ if (startKey !== undefined && legacy !== undefined) {
1451
+ const inLegacy = legacy.samePk === false
1452
+ ? startKey[listConfig.pk.field]?.S === pkOf(legacy)
1453
+ : !inCurrent(startKey[listConfig.sk.field]?.S);
1454
+ at = passes.indexOf(inLegacy ? legacy : current);
1455
+ }
1456
+ rootItems = [];
1457
+ nextKey = undefined;
1458
+ for (; at < passes.length; at++) {
1459
+ const pass = passes[at];
1460
+ const result = yield* queryListPartition(client, tableConfig, listConfig, config.schema, {
1461
+ pkValue: pkOf(pass),
1462
+ skValues: pass.skValues,
1463
+ skComplete,
1464
+ limit: limit === undefined ? undefined : limit - rootItems.length,
1465
+ pageSize,
1466
+ startKey,
1467
+ filter: rootFilter,
1468
+ accept: acceptFor(pass),
1469
+ reverse: options?.reverse === true,
1470
+ keyFields: listKeyFields,
1471
+ }, aggregateName);
1472
+ rootItems.push(...result.items);
1473
+ startKey = undefined;
1474
+ if (result.lastKey !== undefined) {
1475
+ nextKey = result.lastKey;
1476
+ break;
1477
+ }
1478
+ if (limit !== undefined && rootItems.length >= limit) {
1479
+ // The page ends where this pass does: resume after its last
1480
+ // row, which reads the rest of the pass (nothing) and goes on.
1481
+ const last = rootItems[rootItems.length - 1];
1482
+ if (at < passes.length - 1 && last !== undefined) {
1483
+ nextKey = listKeyFromItem(toAttributeMap(last), listKeyFields);
1484
+ }
1485
+ break;
1486
+ }
1487
+ }
978
1488
  }
979
1489
  // Extract PK composite values from each root item and assemble
980
1490
  const aggregates = [];
@@ -993,10 +1503,10 @@ const makeAggregate = (schema, config) => {
993
1503
  if (assembled !== undefined)
994
1504
  aggregates.push(assembled);
995
1505
  }
996
- const cursor = nextKey ? btoa(JSON.stringify(nextKey)) : null;
1506
+ const cursor = nextKey ? Option.some(btoa(JSON.stringify(nextKey))) : Option.none();
997
1507
  return { data: aggregates, cursor };
998
1508
  }),
999
- });
1509
+ }));
1000
1510
  };
1001
1511
  /**
1002
1512
  * Each aggregate operation as a named operation for consumed-capacity
@@ -1317,7 +1827,9 @@ const unpackAggregateItems = (items, dispatchByType, aggregateName) => dispatchB
1317
1827
  // ---------------------------------------------------------------------------
1318
1828
  const queryAllItems = (client, tableName, indexName, pkField, pkValue, consistentRead,
1319
1829
  /** A consistent read of a GSI, which DynamoDB rejects, as the caller names it (#144). */
1320
- consistentReadRejected) => Effect.gen(function* () {
1830
+ consistentReadRejected,
1831
+ /** Where to keep each item as stored, too: `rekey` guards its writes on them (#171). */
1832
+ stored) => Effect.gen(function* () {
1321
1833
  const allItems = [];
1322
1834
  let exclusiveStartKey;
1323
1835
  // Paginate through all results. Assembly needs every item in the partition and
@@ -1341,6 +1853,7 @@ consistentReadRejected) => Effect.gen(function* () {
1341
1853
  if (result.Items) {
1342
1854
  for (const item of result.Items) {
1343
1855
  allItems.push(fromAttributeMap(item));
1856
+ stored?.push(item);
1344
1857
  }
1345
1858
  }
1346
1859
  exclusiveStartKey = result.LastEvaluatedKey;
@@ -1816,12 +2329,13 @@ const decomposeAggregate = (assembled, rootNode, contextFields, encoders, aggreg
1816
2329
  keyAttributes: rootKeyAttrs,
1817
2330
  transactionGroup: "root",
1818
2331
  skComposites: [],
2332
+ skComposites136: [],
1819
2333
  member: rootNode.entityType,
1820
2334
  });
1821
2335
  // Decompose each edge
1822
2336
  for (const child of rootNode.children) {
1823
2337
  const fieldValue = assembled[child.fieldName];
1824
- yield* decomposeNode(items, child, fieldValue, contextValues, "root", [], aggregateName);
2338
+ yield* decomposeNode(items, child, fieldValue, contextValues, "root", { current: [], legacy: [] }, aggregateName, schema.casing);
1825
2339
  }
1826
2340
  // Two items composing the same sort key are two writes to one row. DynamoDB
1827
2341
  // rejects the whole TransactWriteItems with a ValidationException ("Transaction
@@ -1836,7 +2350,7 @@ const decomposeAggregate = (assembled, rootNode, contextFields, encoders, aggreg
1836
2350
  if (prior !== undefined) {
1837
2351
  const hint = prior.member === item.member
1838
2352
  ? ` Give the "${item.member}" edge a declared sort key naming attributes that differ` +
1839
- ` between its elements: Aggregate.many("${item.member}", { …, sk: { composite: [...] } }).`
2353
+ ` between its elements: ${item.member}: Aggregate.many({ …, sk: { composite: [...] } }).`
1840
2354
  : "";
1841
2355
  return yield* new AggregateDecompositionError({
1842
2356
  aggregate: aggregateName,
@@ -1866,10 +2380,24 @@ const mergeContextValues = (attrs, contextValues) => {
1866
2380
  attrs[key] = value;
1867
2381
  }
1868
2382
  };
2383
+ /** A node's own static discriminator values as {@link DiscriminatorValues}. */
2384
+ const ownDiscriminatorValues = (node, casing) => {
2385
+ const entries = node.ownDiscriminator === undefined
2386
+ ? []
2387
+ : Object.entries(node.ownDiscriminator).filter(([, v]) => typeof v !== "function");
2388
+ return {
2389
+ current: entries.flatMap(([k, v]) => [k, KeyComposer.serializeSortKeyValue(v, casing)]),
2390
+ legacy: entries.flatMap(([k, v]) => [k, KeyText136.serializeValue(v)]),
2391
+ };
2392
+ };
1869
2393
  /**
1870
- * Recursively decompose a node's value into DynamoDB items.
2394
+ * Recursively decompose a node's value into DynamoDB items. Sort key values
2395
+ * are escaped with the schema's `casing` (`KeyComposer.sortKeyText`); the
2396
+ * discriminator names between them are field names, kept as they are.
1871
2397
  */
1872
- const decomposeNode = (items, node, value, contextValues, parentGroup, parentDiscriminatorValues, aggregateName) => Effect.gen(function* () {
2398
+ const decomposeNode = (items, node, value, contextValues, parentGroup,
2399
+ /** The parent's discriminator values, in today's text and in 1.36's (#171). */
2400
+ parentDiscriminators, aggregateName, casing) => Effect.gen(function* () {
1873
2401
  // Optional edges may have null/undefined values — skip decomposition
1874
2402
  if (value == null)
1875
2403
  return;
@@ -1883,14 +2411,11 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
1883
2411
  // Discriminator values for SK composition: the parent sub-aggregate's
1884
2412
  // (name#value pairs, empty at the top level) then this node's own, so a
1885
2413
  // nested sub-aggregate's rows are keyed apart per binding of its parent.
1886
- const discValues = [
1887
- ...parentDiscriminatorValues,
1888
- ...(node.ownDiscriminator
1889
- ? Object.entries(node.ownDiscriminator)
1890
- .filter(([, v]) => typeof v !== "function")
1891
- .flatMap(([k, v]) => [k, KeyComposer.serializeValue(v)])
1892
- : []),
1893
- ];
2414
+ const own = ownDiscriminatorValues(node, casing);
2415
+ const discValues = {
2416
+ current: [...parentDiscriminators.current, ...own.current],
2417
+ legacy: [...parentDiscriminators.legacy, ...own.legacy],
2418
+ };
1894
2419
  // Sub-aggregate root item: fields not claimed by child edges
1895
2420
  const childEdgeNames = new Set(node.children.map((c) => c.fieldName).filter(Boolean));
1896
2421
  const subRootAttrs = {};
@@ -1913,13 +2438,14 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
1913
2438
  entityType: node.entityType,
1914
2439
  attributes: subRootAttrs,
1915
2440
  transactionGroup: txGroup,
1916
- skComposites: discValues,
2441
+ skComposites: discValues.current,
2442
+ skComposites136: discValues.legacy,
1917
2443
  member: node.fieldName ?? node.entityType,
1918
2444
  });
1919
2445
  // Decompose children within this sub-aggregate
1920
2446
  for (const child of node.children) {
1921
2447
  const childValue = subValue[child.fieldName];
1922
- yield* decomposeNode(items, child, childValue, contextValues, txGroup, discValues, aggregateName);
2448
+ yield* decomposeNode(items, child, childValue, contextValues, txGroup, discValues, aggregateName, casing);
1923
2449
  }
1924
2450
  }
1925
2451
  else if (node.cardinality === "one") {
@@ -1935,16 +2461,13 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
1935
2461
  }
1936
2462
  }
1937
2463
  // Own discriminator values as name#value pairs for SK composition
1938
- const ownDiscValues = node.ownDiscriminator
1939
- ? Object.entries(node.ownDiscriminator)
1940
- .filter(([, v]) => typeof v !== "function")
1941
- .flatMap(([k, v]) => [k, KeyComposer.serializeValue(v)])
1942
- : [];
2464
+ const own = ownDiscriminatorValues(node, casing);
1943
2465
  items.push({
1944
2466
  entityType: node.entityType,
1945
2467
  attributes: attrs,
1946
2468
  transactionGroup: parentGroup,
1947
- skComposites: [...parentDiscriminatorValues, ...ownDiscValues],
2469
+ skComposites: [...parentDiscriminators.current, ...own.current],
2470
+ skComposites136: [...parentDiscriminators.legacy, ...own.legacy],
1948
2471
  member: node.fieldName ?? node.entityType,
1949
2472
  });
1950
2473
  }
@@ -1978,12 +2501,19 @@ const decomposeNode = (items, node, value, contextValues, parentGroup, parentDis
1978
2501
  const keyAttrs = { ...elem };
1979
2502
  applyNodeAttrEncoders(keyAttrs, node.keyAttrEncoders);
1980
2503
  withContext(keyAttrs, contextValues.key);
1981
- const itemComposites = yield* manyEdgeSkComposites(node, keyAttrs, aggregateName);
2504
+ const values = yield* manyEdgeSkComposites(node, keyAttrs, aggregateName);
1982
2505
  items.push({
1983
2506
  entityType: node.entityType,
1984
2507
  attributes: attrs,
1985
2508
  transactionGroup: parentGroup,
1986
- skComposites: [...parentDiscriminatorValues, ...itemComposites],
2509
+ skComposites: [
2510
+ ...parentDiscriminators.current,
2511
+ ...values.map((value) => KeyComposer.serializeSortKeyValue(value, casing)),
2512
+ ],
2513
+ skComposites136: [
2514
+ ...parentDiscriminators.legacy,
2515
+ ...values.map((value) => KeyText136.serializeValue(value)),
2516
+ ],
1987
2517
  member: node.fieldName ?? node.entityType,
1988
2518
  });
1989
2519
  }
@@ -2008,6 +2538,13 @@ const readCompositePath = (attrs, path) => {
2008
2538
  }
2009
2539
  return current;
2010
2540
  };
2541
+ /**
2542
+ * A collection or list sort key's composites as the key holds them: each
2543
+ * serialized value (`KeyComposer.extractComposites` over the key-form record)
2544
+ * cased and escaped (`KeyComposer.sortKeyText`), so the key's text sorts as
2545
+ * its values and a value never holds the delimiter.
2546
+ */
2547
+ const sortKeyComposites = (schema, composites) => composites.map((text) => KeyComposer.sortKeyText(text, schema.casing));
2011
2548
  /**
2012
2549
  * Whether a resolved composite value can be a sort-key segment.
2013
2550
  *
@@ -2070,7 +2607,7 @@ const manyEdgeSkComposites = (node, attrs, aggregateName) => Effect.gen(function
2070
2607
  `attribute itself by dotted path (e.g. "umpire.id", not "umpire").`,
2071
2608
  });
2072
2609
  }
2073
- return values.map((value) => KeyComposer.serializeValue(value));
2610
+ return values;
2074
2611
  });
2075
2612
  /**
2076
2613
  * Extract ref identifier values from an item's attributes for use as SK composites.
@@ -2174,7 +2711,7 @@ keyRecord, stamp) => groups.map((group) => ({
2174
2711
  else {
2175
2712
  attrs[config.list.pk.field] = composeCollectionKey(config.schema, config.list.name, listPkComposites);
2176
2713
  }
2177
- const listSkComposites = KeyComposer.extractComposites(config.list.sk.composite, keyRecord(item.keyAttributes ?? item.attributes));
2714
+ const listSkComposites = sortKeyComposites(config.schema, KeyComposer.extractComposites(config.list.sk.composite, keyRecord(item.keyAttributes ?? item.attributes)));
2178
2715
  attrs[config.list.sk.field] = composeCollectionKey(config.schema, config.list.name, listSkComposites);
2179
2716
  }
2180
2717
  // System timestamps — last, so a generated value wins over the same-named
@@ -2187,7 +2724,9 @@ keyRecord, stamp) => groups.map((group) => ({
2187
2724
  attrs[fields.createdAt] = prior ?? generateTimestampPrimitive(now, fields.createdEncoding);
2188
2725
  }
2189
2726
  if (fields.updatedAt !== null) {
2190
- attrs[fields.updatedAt] = generateTimestampPrimitive(now, fields.updatedEncoding);
2727
+ attrs[fields.updatedAt] =
2728
+ stamp.existingUpdated?.get(sk) ??
2729
+ generateTimestampPrimitive(now, fields.updatedEncoding);
2191
2730
  }
2192
2731
  }
2193
2732
  return toAttributeMap(attrs);
@@ -2226,15 +2765,31 @@ function writeTransactionGroups(client, tableName, groups, aggregateName, keys,
2226
2765
  },
2227
2766
  })),
2228
2767
  ...group.deletes.map((key) => ({ Delete: { TableName: tableName, Key: key } })),
2768
+ ...(rootGuard?.key136 !== undefined && rootIndex >= 0
2769
+ ? [
2770
+ {
2771
+ ConditionCheck: {
2772
+ TableName: tableName,
2773
+ Key: toAttributeMap(rootGuard.key136),
2774
+ ConditionExpression: "attribute_not_exists(#pk)",
2775
+ ExpressionAttributeNames: { "#pk": rootGuard.pkField },
2776
+ },
2777
+ },
2778
+ ]
2779
+ : []),
2229
2780
  ];
2781
+ // The 1.36 root's check, last in the transaction.
2782
+ const root136Index = rootGuard?.key136 !== undefined && rootIndex >= 0 ? itemCount : -1;
2230
2783
  // Named once, for both diagnoses below.
2231
2784
  const sent = () => ({
2232
2785
  _tag: "Transaction",
2233
2786
  items: transactItems,
2234
2787
  targets: () => transactItems.map((item) => {
2235
2788
  const entityType = item.Put?.Item?.__edd_e__?.S ?? aggregateName;
2236
- return transactItemTarget(item, tableName, [keys.pkField, "sk"], entityType, `the ${item.Put !== undefined ? entityType : "removed"} item of ` +
2237
- `sub-aggregate "${group.group}"`);
2789
+ return transactItemTarget(item, tableName, [keys.pkField, "sk"], entityType, item.ConditionCheck !== undefined
2790
+ ? `the 1.36 key of the root item of sub-aggregate "${group.group}"`
2791
+ : `the ${item.Put !== undefined ? entityType : "removed"} item of ` +
2792
+ `sub-aggregate "${group.group}"`);
2238
2793
  }),
2239
2794
  });
2240
2795
  const already = [...committed];
@@ -2271,9 +2826,11 @@ function writeTransactionGroups(client, tableName, groups, aggregateName, keys,
2271
2826
  "name" in error.cause &&
2272
2827
  error.cause.name === "TransactionCanceledException") {
2273
2828
  const cancelled = error.cause;
2829
+ const reasons = cancelled.CancellationReasons;
2274
2830
  if (rootGuard !== undefined &&
2275
2831
  rootIndex >= 0 &&
2276
- cancelled.CancellationReasons?.[rootIndex]?.Code === "ConditionalCheckFailed") {
2832
+ (reasons?.[rootIndex]?.Code === "ConditionalCheckFailed" ||
2833
+ (root136Index >= 0 && reasons?.[root136Index]?.Code === "ConditionalCheckFailed"))) {
2277
2834
  return new ConditionalCheckFailed({
2278
2835
  entityType: rootGuard.entityType,
2279
2836
  key: rootGuard.key,
@@ -2298,6 +2855,60 @@ function writeTransactionGroups(client, tableName, groups, aggregateName, keys,
2298
2855
  });
2299
2856
  }
2300
2857
  // ---------------------------------------------------------------------------
2858
+ // Internal: Rows under their 1.36 keys (#171)
2859
+ // ---------------------------------------------------------------------------
2860
+ /** DynamoDB's limit on the items of one `TransactWriteItems`. */
2861
+ const TRANSACT_LIMIT = 100;
2862
+ /**
2863
+ * Place an aggregate's rows from the keys that hold a row (#171). Each row has
2864
+ * a `current` and an `old` (1.36) position. 1.36 did not escape sort key text,
2865
+ * so in one partition a row's 1.36 key can be another row's current key —
2866
+ * `a%20b`'s 1.36 sort key is `a b`'s current one — and the rows' keys chain:
2867
+ * `a b` → `a%20b` → `a%2520b`. A chain of n rows has n + 1 keys. With exactly
2868
+ * one of them free, the rows before it are under their 1.36 keys and those
2869
+ * after it under their current keys. With every key held, a lone row is
2870
+ * stored under `both` keys, and a longer chain is `ambiguous`; with more than
2871
+ * one free, a row with neither key held is `missing` and the rest are
2872
+ * `ambiguous`. Escaping only lengthens a value's text, so keys never cycle.
2873
+ */
2874
+ const placeRows = (slots, held) => {
2875
+ const placements = slots.map((slot) => slot.current !== slot.old ? "ambiguous" : held(slot.current) ? "current" : "missing");
2876
+ const byOld = new Map();
2877
+ const currents = new Set();
2878
+ for (const [i, slot] of slots.entries()) {
2879
+ if (slot.current === slot.old)
2880
+ continue;
2881
+ byOld.set(slot.old, i);
2882
+ currents.add(slot.current);
2883
+ }
2884
+ for (const [head, slot] of slots.entries()) {
2885
+ // A chain starts at a row whose 1.36 key is no other row's current key.
2886
+ if (slot.current === slot.old || currents.has(slot.old))
2887
+ continue;
2888
+ const chain = [];
2889
+ for (let i = head; i !== undefined && !chain.includes(i); i = byOld.get(slots[i].current)) {
2890
+ chain.push(i);
2891
+ }
2892
+ const keys = [slot.old, ...chain.map((i) => slots[i].current)];
2893
+ const free = keys.flatMap((key, k) => (held(key) ? [] : [k]));
2894
+ chain.forEach((i, k) => {
2895
+ placements[i] =
2896
+ free.length === 1
2897
+ ? k < free[0]
2898
+ ? "old"
2899
+ : "current"
2900
+ : free.length === 0
2901
+ ? chain.length === 1
2902
+ ? "both"
2903
+ : "ambiguous"
2904
+ : !held(slots[i].old) && !held(slots[i].current)
2905
+ ? "missing"
2906
+ : "ambiguous";
2907
+ });
2908
+ }
2909
+ return placements;
2910
+ };
2911
+ // ---------------------------------------------------------------------------
2301
2912
  // Internal: Delete all items in an aggregate partition
2302
2913
  // ---------------------------------------------------------------------------
2303
2914
  const deleteAllItems = (client, tableName, allItems, pkField) => Effect.gen(function* () {
@@ -2411,11 +3022,22 @@ const queryListPartition = (client, tableConfig, listConfig, schema, options, ag
2411
3022
  const exprNames = { "#pk": listConfig.pk.field };
2412
3023
  const exprValues = { ":pk": toAttributeValue(pkValue) };
2413
3024
  let keyCondition = "#pk = :pk";
3025
+ // The rule `KeyComposer.composeSortKeyCondition` applies to entity
3026
+ // accessors: a complete key is matched with `=`, a partial one with
3027
+ // `begins_with` on the prefix and the delimiter. A bare `begins_with` on
3028
+ // the composed values matches longer siblings — `alice` also matching
3029
+ // `alicex`, and `rate: 3` matching 3.5, whose key text extends 3's.
2414
3030
  if (skValues.length > 0) {
2415
- const skPrefix = composeCollectionKey(schema, listConfig.name, skValues);
3031
+ const skText = composeCollectionKey(schema, listConfig.name, skValues);
2416
3032
  exprNames["#sk"] = listConfig.sk.field;
2417
- exprValues[":skPrefix"] = toAttributeValue(skPrefix);
2418
- keyCondition += " AND begins_with(#sk, :skPrefix)";
3033
+ if (options.skComplete) {
3034
+ exprValues[":sk"] = toAttributeValue(skText);
3035
+ keyCondition += " AND #sk = :sk";
3036
+ }
3037
+ else {
3038
+ exprValues[":skPrefix"] = toAttributeValue(`${skText}${KEY_DELIMITER}`);
3039
+ keyCondition += " AND begins_with(#sk, :skPrefix)";
3040
+ }
2419
3041
  }
2420
3042
  if (filter !== undefined) {
2421
3043
  Object.assign(exprNames, filter.names);