effect-dynamodb 1.36.1 → 1.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Aggregate.d.ts +29 -17
- package/dist/Aggregate.d.ts.map +1 -1
- package/dist/Aggregate.js +795 -173
- package/dist/Aggregate.js.map +1 -1
- package/dist/Batch.d.ts.map +1 -1
- package/dist/Batch.js +196 -77
- package/dist/Batch.js.map +1 -1
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +11 -2
- package/dist/Collection.js.map +1 -1
- package/dist/Collections.d.ts +15 -6
- package/dist/Collections.d.ts.map +1 -1
- package/dist/Collections.js +2 -1
- package/dist/Collections.js.map +1 -1
- package/dist/DynamoClient.d.ts +117 -10
- package/dist/DynamoClient.d.ts.map +1 -1
- package/dist/DynamoClient.js +1145 -141
- package/dist/DynamoClient.js.map +1 -1
- package/dist/Entity.d.ts +126 -51
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +3477 -539
- package/dist/Entity.js.map +1 -1
- package/dist/EventStore.d.ts +4 -3
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +616 -53
- package/dist/EventStore.js.map +1 -1
- package/dist/Query.d.ts +311 -25
- package/dist/Query.d.ts.map +1 -1
- package/dist/Query.js +1159 -103
- package/dist/Query.js.map +1 -1
- package/dist/Table.d.ts +172 -3
- package/dist/Table.d.ts.map +1 -1
- package/dist/Table.js +85 -4
- package/dist/Table.js.map +1 -1
- package/dist/Transaction.d.ts +18 -2
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +62 -8
- package/dist/Transaction.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/BoundQuery.d.ts +226 -32
- package/dist/internal/BoundQuery.d.ts.map +1 -1
- package/dist/internal/BoundQuery.js +96 -18
- package/dist/internal/BoundQuery.js.map +1 -1
- package/dist/internal/Capacity.d.ts +8 -0
- package/dist/internal/Capacity.d.ts.map +1 -1
- package/dist/internal/Capacity.js +24 -4
- package/dist/internal/Capacity.js.map +1 -1
- package/dist/internal/Cursor.d.ts +90 -0
- package/dist/internal/Cursor.d.ts.map +1 -0
- package/dist/internal/Cursor.js +179 -0
- package/dist/internal/Cursor.js.map +1 -0
- package/dist/internal/ExpressionLimits.d.ts +18 -2
- package/dist/internal/ExpressionLimits.d.ts.map +1 -1
- package/dist/internal/ExpressionLimits.js +35 -2
- package/dist/internal/ExpressionLimits.js.map +1 -1
- package/dist/internal/ProjectionEngine.d.ts +16 -0
- package/dist/internal/ProjectionEngine.d.ts.map +1 -1
- package/dist/internal/ProjectionEngine.js +242 -46
- package/dist/internal/ProjectionEngine.js.map +1 -1
- package/dist/internal/RangePlanner.d.ts +394 -0
- package/dist/internal/RangePlanner.d.ts.map +1 -0
- package/dist/internal/RangePlanner.js +1296 -0
- package/dist/internal/RangePlanner.js.map +1 -0
- package/dist/internal/Rekey.d.ts +78 -0
- package/dist/internal/Rekey.d.ts.map +1 -0
- package/dist/internal/Rekey.js +323 -0
- package/dist/internal/Rekey.js.map +1 -0
- package/dist/internal/TransactPlan.d.ts +8 -1
- package/dist/internal/TransactPlan.d.ts.map +1 -1
- package/dist/internal/TransactPlan.js +9 -2
- package/dist/internal/TransactPlan.js.map +1 -1
- package/dist/internal/TransactWriteOps.d.ts +10 -7
- package/dist/internal/TransactWriteOps.d.ts.map +1 -1
- package/dist/internal/TransactWriteOps.js +82 -6
- package/dist/internal/TransactWriteOps.js.map +1 -1
- package/dist/internal/TransactableOps.d.ts +31 -0
- package/dist/internal/TransactableOps.d.ts.map +1 -1
- package/dist/internal/TransactableOps.js +50 -1
- package/dist/internal/TransactableOps.js.map +1 -1
- package/dist/internal/WriteDiagnosis.d.ts.map +1 -1
- package/dist/internal/WriteDiagnosis.js +10 -7
- package/dist/internal/WriteDiagnosis.js.map +1 -1
- package/package.json +2 -2
package/dist/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
|
|
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
|
|
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
|
|
657
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
// `
|
|
904
|
-
// `String(5)` would look for `5` where `0000000000000005`
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
...
|
|
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:
|
|
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
|
-
|
|
1439
|
+
let startKey = options?.cursor
|
|
962
1440
|
? JSON.parse(atob(options.cursor))
|
|
963
1441
|
: undefined;
|
|
964
|
-
const
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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)) :
|
|
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
|
|
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:
|
|
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,
|
|
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
|
|
1887
|
-
|
|
1888
|
-
...
|
|
1889
|
-
|
|
1890
|
-
|
|
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
|
|
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: [...
|
|
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
|
|
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: [
|
|
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
|
|
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] =
|
|
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,
|
|
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
|
-
|
|
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
|
|
3031
|
+
const skText = composeCollectionKey(schema, listConfig.name, skValues);
|
|
2416
3032
|
exprNames["#sk"] = listConfig.sk.field;
|
|
2417
|
-
|
|
2418
|
-
|
|
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);
|