effect-dynamodb 1.24.0 → 1.26.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 +12 -2
- package/dist/Aggregate.d.ts.map +1 -1
- package/dist/Aggregate.js +284 -55
- package/dist/Aggregate.js.map +1 -1
- package/dist/Batch.d.ts +14 -5
- package/dist/Batch.d.ts.map +1 -1
- package/dist/Batch.js +184 -119
- package/dist/Batch.js.map +1 -1
- package/dist/Collection.d.ts +9 -1
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +5 -4
- package/dist/Collection.js.map +1 -1
- package/dist/DynamoClient.d.ts +80 -28
- package/dist/DynamoClient.d.ts.map +1 -1
- package/dist/DynamoClient.js +125 -25
- package/dist/DynamoClient.js.map +1 -1
- package/dist/Entity.d.ts +99 -40
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +693 -302
- package/dist/Entity.js.map +1 -1
- package/dist/EventStore.d.ts +598 -75
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +852 -221
- package/dist/EventStore.js.map +1 -1
- package/dist/Query.d.ts +100 -14
- package/dist/Query.d.ts.map +1 -1
- package/dist/Query.js +275 -71
- package/dist/Query.js.map +1 -1
- package/dist/StorageEncoding.d.ts +160 -0
- package/dist/StorageEncoding.d.ts.map +1 -0
- package/dist/StorageEncoding.js +258 -0
- package/dist/StorageEncoding.js.map +1 -0
- package/dist/Table.d.ts +56 -6
- package/dist/Table.d.ts.map +1 -1
- package/dist/Table.js +142 -60
- package/dist/Table.js.map +1 -1
- package/dist/Transaction.d.ts +8 -0
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +72 -22
- package/dist/Transaction.js.map +1 -1
- package/dist/VectorSearchEmulation.d.ts +3 -2
- package/dist/VectorSearchEmulation.d.ts.map +1 -1
- package/dist/VectorSearchEmulation.js +14 -2
- package/dist/VectorSearchEmulation.js.map +1 -1
- package/dist/compression/zlib.d.ts +75 -0
- package/dist/compression/zlib.d.ts.map +1 -0
- package/dist/compression/zlib.js +109 -0
- package/dist/compression/zlib.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/BoundCrud.d.ts +39 -35
- package/dist/internal/BoundCrud.d.ts.map +1 -1
- package/dist/internal/BoundCrud.js.map +1 -1
- package/dist/internal/BoundQuery.d.ts +6 -5
- package/dist/internal/BoundQuery.d.ts.map +1 -1
- package/dist/internal/BoundQuery.js.map +1 -1
- package/dist/internal/BoundVectorQuery.d.ts +17 -1
- package/dist/internal/BoundVectorQuery.d.ts.map +1 -1
- package/dist/internal/BoundVectorQuery.js +31 -6
- package/dist/internal/BoundVectorQuery.js.map +1 -1
- package/dist/internal/EntityCombinators.d.ts +8 -5
- package/dist/internal/EntityCombinators.d.ts.map +1 -1
- package/dist/internal/EntityCombinators.js +24 -6
- package/dist/internal/EntityCombinators.js.map +1 -1
- package/dist/internal/EntityOps.d.ts +1 -1
- package/dist/internal/Expr.d.ts +9 -3
- package/dist/internal/Expr.d.ts.map +1 -1
- package/dist/internal/Expr.js +7 -5
- package/dist/internal/Expr.js.map +1 -1
- package/dist/internal/ExpressionLimits.d.ts +45 -0
- package/dist/internal/ExpressionLimits.d.ts.map +1 -0
- package/dist/internal/ExpressionLimits.js +89 -0
- package/dist/internal/ExpressionLimits.js.map +1 -0
- package/dist/internal/Hydrate.d.ts +62 -0
- package/dist/internal/Hydrate.d.ts.map +1 -0
- package/dist/internal/Hydrate.js +191 -0
- package/dist/internal/Hydrate.js.map +1 -0
- package/dist/internal/ItemSize.d.ts +60 -24
- package/dist/internal/ItemSize.d.ts.map +1 -1
- package/dist/internal/ItemSize.js +122 -39
- package/dist/internal/ItemSize.js.map +1 -1
- package/dist/internal/Packed.d.ts +289 -0
- package/dist/internal/Packed.d.ts.map +1 -0
- package/dist/internal/Packed.js +654 -0
- package/dist/internal/Packed.js.map +1 -0
- package/dist/internal/PathBuilder.d.ts +45 -2
- package/dist/internal/PathBuilder.d.ts.map +1 -1
- package/dist/internal/PathBuilder.js +4 -0
- package/dist/internal/PathBuilder.js.map +1 -1
- package/dist/internal/TransactWriteOps.d.ts +22 -46
- package/dist/internal/TransactWriteOps.d.ts.map +1 -1
- package/dist/internal/TransactWriteOps.js +26 -95
- package/dist/internal/TransactWriteOps.js.map +1 -1
- package/dist/internal/TransactableOps.d.ts +1 -1
- package/dist/internal/TransactableOps.d.ts.map +1 -1
- package/dist/internal/TransactableOps.js +2 -0
- package/dist/internal/TransactableOps.js.map +1 -1
- package/dist/internal/WriteDiagnosis.d.ts +353 -0
- package/dist/internal/WriteDiagnosis.d.ts.map +1 -0
- package/dist/internal/WriteDiagnosis.js +1028 -0
- package/dist/internal/WriteDiagnosis.js.map +1 -0
- package/dist/internal/sparse_check.js +43 -79
- package/dist/internal/sparse_check.js.map +1 -1
- package/package.json +6 -2
package/dist/Entity.js
CHANGED
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
import { getIdentifierField, getSparseFields, isConfiguredModel, isRefField, } from "@effect-dynamodb/schema/DynamoModel.js";
|
|
10
10
|
import * as DynamoSchema from "@effect-dynamodb/schema/DynamoSchema.js";
|
|
11
11
|
import { Embedder } from "@effect-dynamodb/schema/Embedder.js";
|
|
12
|
-
import { CascadePartialFailure, ConcurrentModification, ConditionalCheckFailed, DeleteAppliedButUnreadable, EmbeddingError, ItemNotDeleted, ItemNotFound, isAwsConditionalCheckFailed, isAwsTransactionCancelled, OptimisticLockError, RefNotFound, StaleAppend, TransactionOverflow, UniqueConstraintViolation, UpdateAppliedButUnreadable, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
|
|
12
|
+
import { CascadePartialFailure, ConcurrentModification, ConditionalCheckFailed, DeleteAppliedButUnreadable, DynamoError, EmbeddingError, ItemNotDeleted, ItemNotFound, isAwsConditionalCheckFailed, isAwsTransactionCancelled, OptimisticLockError, RefNotFound, StaleAppend, TransactionOverflow, UniqueConstraintViolation, UpdateAppliedButUnreadable, ValidationError, } from "@effect-dynamodb/schema/Errors.js";
|
|
13
13
|
import { makeCompositeKeyForm, toCompositeKeyRecord, } from "@effect-dynamodb/schema/internal/CompositeCodec.js";
|
|
14
14
|
import { makeDefaultCrypto } from "@effect-dynamodb/schema/internal/DefaultCrypto.js";
|
|
15
|
+
import { packedIndexRefusal } from "@effect-dynamodb/schema/internal/EntityPacked.js";
|
|
15
16
|
import * as KeyComposer from "@effect-dynamodb/schema/KeyComposer.js";
|
|
16
17
|
import { normalizeGsiConfig } from "@effect-dynamodb/schema/KeyComposer.js";
|
|
17
18
|
import { deriveSourceText } from "@effect-dynamodb/schema/VectorIndex.js";
|
|
@@ -20,8 +21,12 @@ import { DynamoClient } from "./DynamoClient.js";
|
|
|
20
21
|
import { isBoundOp, makeBoundAppend, makeBoundDelete, makeBoundGet, makeBoundPut, makeBoundUpdate, } from "./internal/BoundCrud.js";
|
|
21
22
|
import { BoundQueryImpl, entityNaming, } from "./internal/BoundQuery.js";
|
|
22
23
|
import { compileExpr, createConditionOps, emptyPartProblem, isExpr, nonEmptyCondition, parseShorthand, parseSimpleShorthand, toExpr, } from "./internal/Expr.js";
|
|
24
|
+
import { countOperators, expressionFits } from "./internal/ExpressionLimits.js";
|
|
25
|
+
import { BASE_DELAY_MS, MAX_RETRIES } from "./internal/Hydrate.js";
|
|
26
|
+
import { decodeImage, imageEntityType, ownRowsRawRow, packAttributeValueSync, packedExprRefusal, packedProjectionRefusal, packItemSync, unpackRow, withRegistry, } from "./internal/Packed.js";
|
|
23
27
|
import { compilePath, createPathBuilder } from "./internal/PathBuilder.js";
|
|
24
28
|
import { freshIncarnationToken, generateTimestampPrimitive, INCARNATION_TOKEN, UNSENTINELED_DEFAULTS, } from "./internal/TransactableOps.js";
|
|
29
|
+
import { batchEntries, diagnosedValidationError, diagnoseFailure, keyAttributesOf, targetsFor, transactItemTarget, } from "./internal/WriteDiagnosis.js";
|
|
25
30
|
import { decodeSparseFields, encodeSparseFields, fromAttributeMap, toAttributeMap, toAttributeValue, } from "./Marshaller.js";
|
|
26
31
|
import * as Query from "./Query.js";
|
|
27
32
|
import { filterExpr, selectPaths } from "./Query.js";
|
|
@@ -131,58 +136,8 @@ const keyFormFor = (target, record) => {
|
|
|
131
136
|
*/
|
|
132
137
|
class MissingForCreate extends Data.TaggedError("MissingForCreate") {
|
|
133
138
|
}
|
|
134
|
-
/**
|
|
135
|
-
|
|
136
|
-
const OPERATOR_LIMIT = 300;
|
|
137
|
-
/**
|
|
138
|
-
* The tokens of a compiled expression. Names and values are placeholders
|
|
139
|
-
* (`#…`, `:…`), so no operator can hide inside one.
|
|
140
|
-
*/
|
|
141
|
-
const EXPRESSION_TOKEN = /#\w+|:\w+|<>|<=|>=|[=<>()+\-,.[\]]|[A-Za-z_]\w*|\d+/g;
|
|
142
|
-
/**
|
|
143
|
-
* The operators DynamoDB counts against an expression's 300 (#133), measured
|
|
144
|
-
* against DynamoDB: in a condition, each comparison (`=`, `<>`, `<`, `<=`,
|
|
145
|
-
* `>`, `>=`), `AND` / `OR` / `NOT`, `BETWEEN` (whose own `AND` is part of it,
|
|
146
|
-
* not another operator), `IN`, and each function; in an update, each `+` /
|
|
147
|
-
* `-` and each function (`if_not_exists`, `list_append`) — a `SET` clause's
|
|
148
|
-
* `=` is not an operator.
|
|
149
|
-
*/
|
|
150
|
-
/** @internal */
|
|
151
|
-
export const countOperators = (expression, kind) => {
|
|
152
|
-
const tokens = expression.match(EXPRESSION_TOKEN) ?? [];
|
|
153
|
-
let count = 0;
|
|
154
|
-
let betweens = 0;
|
|
155
|
-
for (const [i, token] of tokens.entries()) {
|
|
156
|
-
const word = token.toUpperCase();
|
|
157
|
-
const isFunction = /^[A-Za-z_]\w*$/.test(token) && tokens[i + 1] === "(";
|
|
158
|
-
if (kind === "update") {
|
|
159
|
-
if (token === "+" || token === "-" || isFunction)
|
|
160
|
-
count++;
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
if (isFunction)
|
|
164
|
-
count++;
|
|
165
|
-
else if (["=", "<>", "<", "<=", ">", ">="].includes(token))
|
|
166
|
-
count++;
|
|
167
|
-
else if (word === "BETWEEN") {
|
|
168
|
-
count++;
|
|
169
|
-
betweens++;
|
|
170
|
-
}
|
|
171
|
-
else if (word === "AND" && betweens > 0)
|
|
172
|
-
betweens--;
|
|
173
|
-
else if (word === "AND" || word === "OR" || word === "NOT" || word === "IN")
|
|
174
|
-
count++;
|
|
175
|
-
}
|
|
176
|
-
return count;
|
|
177
|
-
};
|
|
178
|
-
/** What DynamoDB counts against an expression's limits. */
|
|
179
|
-
const expressionCost = (expression, kind = "condition") => expression === undefined
|
|
180
|
-
? { length: 0, operators: 0 }
|
|
181
|
-
: { length: expression.length, operators: countOperators(expression, kind) };
|
|
182
|
-
const expressionFits = (expression, kind = "condition") => {
|
|
183
|
-
const cost = expressionCost(expression, kind);
|
|
184
|
-
return cost.length <= EXPRESSION_LIMIT && cost.operators <= OPERATOR_LIMIT;
|
|
185
|
-
};
|
|
139
|
+
/** @internal Re-exported for tests: what DynamoDB counts against an expression's 300 operators. */
|
|
140
|
+
export { countOperators };
|
|
186
141
|
const bytesKey = (bytes) => Array.from(bytes).join(",");
|
|
187
142
|
/** Structural equality of two marshalled values (sets compare as sets). */
|
|
188
143
|
const attributeValueEquals = (a, b) => {
|
|
@@ -270,9 +225,6 @@ Effect.catch(() => Effect.fail(new ValidationError({
|
|
|
270
225
|
operation: `${operation}.decode`,
|
|
271
226
|
cause: primaryCause,
|
|
272
227
|
}))))));
|
|
273
|
-
// ---------------------------------------------------------------------------
|
|
274
|
-
// Transaction limit pre-check (100-item DynamoDB limit)
|
|
275
|
-
// ---------------------------------------------------------------------------
|
|
276
228
|
/**
|
|
277
229
|
* Compile a condition that may be either an Expr ADT node or a ConditionInput object.
|
|
278
230
|
* Routes both paths through the Expr ADT compiler for a single compilation backend.
|
|
@@ -284,15 +236,6 @@ const compileCondition = (cond, resolveDbNameFn) => {
|
|
|
284
236
|
const expr = isExpr(cond) ? cond : parseShorthand(cond);
|
|
285
237
|
return compileExpr(expr, resolveDbNameFn);
|
|
286
238
|
};
|
|
287
|
-
const TRANSACTION_LIMIT = 100;
|
|
288
|
-
const checkTransactionLimit = (entityType, operation, items) => items.length > TRANSACTION_LIMIT
|
|
289
|
-
? Effect.fail(new TransactionOverflow({
|
|
290
|
-
entityType,
|
|
291
|
-
operation,
|
|
292
|
-
itemCount: items.length,
|
|
293
|
-
limit: TRANSACTION_LIMIT,
|
|
294
|
-
}))
|
|
295
|
-
: Effect.void;
|
|
296
239
|
/** Attempts a guarded put makes before it reports a lost race (#133). */
|
|
297
240
|
const GUARDED_PUT_ATTEMPTS = 3;
|
|
298
241
|
/**
|
|
@@ -673,17 +616,46 @@ const makeImpl = (config, precomputedData) => {
|
|
|
673
616
|
if (encoded.issue !== undefined) {
|
|
674
617
|
return `.set() of immutable "${field}": ${String(encoded.issue)}`;
|
|
675
618
|
}
|
|
676
|
-
out.push({
|
|
619
|
+
out.push({
|
|
620
|
+
field,
|
|
621
|
+
attr: resolveDbName(field),
|
|
622
|
+
value: toAttributeValue(encoded.encoded),
|
|
623
|
+
wire: encoded.encoded,
|
|
624
|
+
packed: field in packedFields,
|
|
625
|
+
});
|
|
677
626
|
}
|
|
678
627
|
return out;
|
|
679
628
|
};
|
|
629
|
+
const immutableChangedMessage = (field) => `.set() of "${field}": "${field}" is immutable and the update's value ` +
|
|
630
|
+
"differs from the stored one.";
|
|
631
|
+
/** A restated immutable value that differs from the stored item's (packed fields excepted). */
|
|
680
632
|
const immutableMismatch = (asserted, stored) => {
|
|
681
|
-
const changed = asserted.find(({ attr, value }) => !attributeValueEquals(stored[attr], value));
|
|
682
|
-
return changed === undefined
|
|
683
|
-
? undefined
|
|
684
|
-
: `.set() of "${changed.field}": "${changed.field}" is immutable and the update's value ` +
|
|
685
|
-
"differs from the stored one.";
|
|
633
|
+
const changed = asserted.find(({ attr, value, packed }) => !packed && !attributeValueEquals(stored[attr], value));
|
|
634
|
+
return changed === undefined ? undefined : immutableChangedMessage(changed.field);
|
|
686
635
|
};
|
|
636
|
+
/**
|
|
637
|
+
* A restated immutable PACKED value that differs from the stored item's,
|
|
638
|
+
* compared by the stored value's unpacked JSON (#147) — whatever codec or
|
|
639
|
+
* threshold it was written under.
|
|
640
|
+
*/
|
|
641
|
+
const packedImmutableMismatch = (asserted, stored) => Effect.gen(function* () {
|
|
642
|
+
for (const { field, attr, wire, packed } of asserted) {
|
|
643
|
+
if (!packed)
|
|
644
|
+
continue;
|
|
645
|
+
const storedValue = stored[attr];
|
|
646
|
+
if (storedValue === undefined) {
|
|
647
|
+
if (wire === undefined || wire === null)
|
|
648
|
+
continue;
|
|
649
|
+
return immutableChangedMessage(field);
|
|
650
|
+
}
|
|
651
|
+
const raw = fromAttributeMap({ [attr]: storedValue });
|
|
652
|
+
yield* unpackFields(raw, "update.decode");
|
|
653
|
+
if (!attributeValueEquals(toAttributeValue(raw[attr]), toAttributeValue(wire))) {
|
|
654
|
+
return immutableChangedMessage(field);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return undefined;
|
|
658
|
+
});
|
|
687
659
|
/**
|
|
688
660
|
* Whether an update can safely create the item it names (#133): its payload
|
|
689
661
|
* supplies every required model field and every primary-key composite, so
|
|
@@ -739,6 +711,136 @@ const makeImpl = (config, precomputedData) => {
|
|
|
739
711
|
return;
|
|
740
712
|
decodeSparseFields(raw, sparseFields);
|
|
741
713
|
};
|
|
714
|
+
// ---------------------------------------------------------------------------
|
|
715
|
+
// Packed fields (`storedAs: DynamoModel.Packed`) — see
|
|
716
|
+
// `docs/designs/storage-encoding.md` §6. Packing runs after renames and
|
|
717
|
+
// sparse flattening, before `toAttributeMap`; unpacking after
|
|
718
|
+
// `fromAttributeMap`, before sparse rebuild and renames — the same points as
|
|
719
|
+
// the sparse layout, keyed by stored attribute names.
|
|
720
|
+
// ---------------------------------------------------------------------------
|
|
721
|
+
const { packedFields, packedAttributes, dispatchAttributes, hasPackedFields } = data;
|
|
722
|
+
/**
|
|
723
|
+
* Pack, in place, the packed attributes of a stored-name item. `only`
|
|
724
|
+
* restricts it to those stored attributes (the read-merge update path packs
|
|
725
|
+
* only the fields it names: the others still hold their stored value).
|
|
726
|
+
*/
|
|
727
|
+
const packFields = (item, operation, only) => {
|
|
728
|
+
if (!hasPackedFields)
|
|
729
|
+
return Effect.void;
|
|
730
|
+
const targets = only === undefined
|
|
731
|
+
? packedAttributes
|
|
732
|
+
: Object.fromEntries(Object.entries(packedAttributes).filter(([attr]) => only.has(attr)));
|
|
733
|
+
if (Object.keys(targets).length === 0)
|
|
734
|
+
return Effect.void;
|
|
735
|
+
return withRegistry((registry) => packItemSync(item, targets, registry, { entityType, operation: `${operation}.packed` }));
|
|
736
|
+
};
|
|
737
|
+
/** Pack one attribute's wire value straight to its `AttributeValue` (an `UpdateItem` SET). */
|
|
738
|
+
const packAttribute = (attribute, wire, operation) => withRegistry((registry) => packAttributeValueSync(packedAttributes[attribute], wire, registry, { entityType, operation: `${operation}.packed` }, attribute));
|
|
739
|
+
/**
|
|
740
|
+
* Read, in place, the dispatch-eligible attributes of a stored-name row by
|
|
741
|
+
* their stored form. A row with no string or binary value on any of them
|
|
742
|
+
* (every native row) costs one `typeof` per attribute.
|
|
743
|
+
*/
|
|
744
|
+
const unpackFields = (raw, operation = "decode") => dispatchAttributes.length === 0
|
|
745
|
+
? Effect.void
|
|
746
|
+
: unpackRow(raw, dispatchAttributes, { entityType, operation });
|
|
747
|
+
/**
|
|
748
|
+
* A domain-named copy of `record` with its packed (or once-packed) attributes
|
|
749
|
+
* read by their stored form. The update paths derive vector source text from
|
|
750
|
+
* it: an untouched packed field still holds its stored value there (a JSON
|
|
751
|
+
* string or compressed bytes), not the value the source `compose` expects.
|
|
752
|
+
* `record` itself is left as is, so the write still carries the stored bytes.
|
|
753
|
+
*/
|
|
754
|
+
const unpackedDomainCopy = (record, operation) => {
|
|
755
|
+
if (dispatchAttributes.length === 0)
|
|
756
|
+
return Effect.succeed(record);
|
|
757
|
+
const stored = { ...record };
|
|
758
|
+
renameToDynamo(stored);
|
|
759
|
+
return Effect.map(unpackFields(stored, operation), () => {
|
|
760
|
+
renameFromDynamo(stored);
|
|
761
|
+
return stored;
|
|
762
|
+
});
|
|
763
|
+
};
|
|
764
|
+
/** The packed attributes an expression or projection may not look inside (EDD-9076). */
|
|
765
|
+
const packedGuards = Object.entries(packedFields).flatMap(([field, storedAs]) => {
|
|
766
|
+
const guard = (attribute) => ({
|
|
767
|
+
attribute,
|
|
768
|
+
owner: `Entity "${entityType}"`,
|
|
769
|
+
storedAs,
|
|
770
|
+
alternative: () => `To filter on it, use .filterBy((item) => ...), which runs on the decoded item; to ` +
|
|
771
|
+
`condition a write on it, read the item and compare in code, then write the whole ` +
|
|
772
|
+
`value with .set({ ${field} }).`,
|
|
773
|
+
});
|
|
774
|
+
const stored = resolveDbName(field);
|
|
775
|
+
return stored === field ? [guard(field)] : [guard(field), guard(stored)];
|
|
776
|
+
});
|
|
777
|
+
/** Throw `[EDD-9076]` when an expression looks inside a packed field. */
|
|
778
|
+
const refusePackedExpr = (expr, usage) => {
|
|
779
|
+
const refused = packedExprRefusal(expr, packedGuards, usage);
|
|
780
|
+
if (refused !== undefined)
|
|
781
|
+
throw new Error(refused);
|
|
782
|
+
};
|
|
783
|
+
/**
|
|
784
|
+
* The `Query.make` options of this entity's queries: the packed guards, and
|
|
785
|
+
* the raw-row hook that reads packed attributes of a projected row of THIS
|
|
786
|
+
* entity type by their stored form (parsed JSON, not schema-decoded) — rows
|
|
787
|
+
* of other types (`ignoreOwnership()`) are left untouched.
|
|
788
|
+
*/
|
|
789
|
+
const packedQueryOptions = {
|
|
790
|
+
packed: packedGuards,
|
|
791
|
+
rawRow: ownRowsRawRow(entityType, dispatchAttributes, (raw) => unpackFields(raw)),
|
|
792
|
+
};
|
|
793
|
+
/**
|
|
794
|
+
* `[EDD-9077]`: an update applies anything but a whole-value `.set()` /
|
|
795
|
+
* `.remove()` to a packed field — DynamoDB cannot compute inside one opaque
|
|
796
|
+
* value. `undefined` when the update is fine.
|
|
797
|
+
*/
|
|
798
|
+
const refusedPackedUpdate = (uState) => {
|
|
799
|
+
if (!hasPackedFields)
|
|
800
|
+
return undefined;
|
|
801
|
+
const refuse = (op, field) => `[EDD-9077] Entity "${entityType}": ${op} on "${field}", but "${field}" is stored packed ` +
|
|
802
|
+
`(format "${packedFields[field].format}"): DynamoDB holds it as one opaque string or ` +
|
|
803
|
+
`binary value and cannot apply ${op} inside it. Write the whole value with ` +
|
|
804
|
+
`.set({ ${field} }) (or .remove(["${field}"])).`;
|
|
805
|
+
const records = [
|
|
806
|
+
[".add()", uState.add],
|
|
807
|
+
[".subtract()", uState.subtract],
|
|
808
|
+
[".append()", uState.append],
|
|
809
|
+
[".deleteFromSet()", uState.deleteFromSet],
|
|
810
|
+
];
|
|
811
|
+
for (const [op, values] of records) {
|
|
812
|
+
for (const field of Object.keys(values ?? {})) {
|
|
813
|
+
if (field in packedFields)
|
|
814
|
+
return refuse(op, field);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
const pathOps = [
|
|
818
|
+
["pathSet", uState.pathSets ?? []],
|
|
819
|
+
["pathRemove", (uState.pathRemoves ?? []).map((segments) => ({ segments }))],
|
|
820
|
+
["pathAdd", uState.pathAdds ?? []],
|
|
821
|
+
["pathSubtract", uState.pathSubtracts ?? []],
|
|
822
|
+
["pathAppend", uState.pathAppends ?? []],
|
|
823
|
+
["pathPrepend", uState.pathPrepends ?? []],
|
|
824
|
+
["pathIfNotExists", uState.pathIfNotExists ?? []],
|
|
825
|
+
["pathDelete", uState.pathDeletes ?? []],
|
|
826
|
+
];
|
|
827
|
+
for (const [op, ops] of pathOps) {
|
|
828
|
+
for (const { segments } of ops) {
|
|
829
|
+
const head = segments[0];
|
|
830
|
+
if (typeof head === "string" && head in packedFields)
|
|
831
|
+
return refuse(op, head);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
for (const { field } of uState.sparseRemoveEntries ?? []) {
|
|
835
|
+
if (field in packedFields)
|
|
836
|
+
return refuse(".removeEntries()", field);
|
|
837
|
+
}
|
|
838
|
+
for (const field of uState.sparseClearFields ?? []) {
|
|
839
|
+
if (field in packedFields)
|
|
840
|
+
return refuse(".clearMap()", field);
|
|
841
|
+
}
|
|
842
|
+
return undefined;
|
|
843
|
+
};
|
|
742
844
|
/**
|
|
743
845
|
* A DOMAIN-keyed VIEW of a row that came off the wire.
|
|
744
846
|
*
|
|
@@ -833,6 +935,59 @@ const makeImpl = (config, precomputedData) => {
|
|
|
833
935
|
* through `keyForm(...)`.
|
|
834
936
|
*/
|
|
835
937
|
const keyForm = (record) => toCompositeKeyRecord(compositeKeyForm, record);
|
|
938
|
+
// -------------------------------------------------------------------------
|
|
939
|
+
// Write-limit diagnosis (#144)
|
|
940
|
+
//
|
|
941
|
+
// DynamoDB's limits — 400 KB per item, 100 items and 4 MB per transaction,
|
|
942
|
+
// 1024-byte sort keys — are not pre-checked. Every write is sent; when
|
|
943
|
+
// DynamoDB rejects it, `internal/WriteDiagnosis.ts` recognises the
|
|
944
|
+
// rejection, confirms it against the request and names the item. Applied
|
|
945
|
+
// after each site's conditional-failure mapping; nothing runs on success.
|
|
946
|
+
// -------------------------------------------------------------------------
|
|
947
|
+
const entityKeyFields = () => [
|
|
948
|
+
config.indexes.primary.pk.field,
|
|
949
|
+
config.indexes.primary.sk.field,
|
|
950
|
+
];
|
|
951
|
+
const entityKeys = () => keyAttributesOf(config.indexes);
|
|
952
|
+
/**
|
|
953
|
+
* Diagnose a rejected single-item write (`PutItem` / `UpdateItem` /
|
|
954
|
+
* `DeleteItem`). `callerCondition`: the caller's own `.condition()` within
|
|
955
|
+
* the write's condition, to say how much of an over-long one is theirs.
|
|
956
|
+
*/
|
|
957
|
+
const diagnoseItemWrite = (operation, item, callerCondition) => diagnoseFailure({
|
|
958
|
+
sent: () => ({
|
|
959
|
+
_tag: "Item",
|
|
960
|
+
item,
|
|
961
|
+
target: () => targetsFor([item], entityKeyFields(), entityType, () => `the ${entityType} ${operation}`)[0],
|
|
962
|
+
callerCondition,
|
|
963
|
+
}),
|
|
964
|
+
keys: entityKeys,
|
|
965
|
+
rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, { operation, entityType }),
|
|
966
|
+
});
|
|
967
|
+
/**
|
|
968
|
+
* Diagnose a rejected `TransactWriteItems` of this entity's write: over 100
|
|
969
|
+
* items is `TransactionOverflow`, every other limit `ValidationError`.
|
|
970
|
+
*/
|
|
971
|
+
const diagnoseTransactionWrite = (operation, items,
|
|
972
|
+
/** The caller's own `.condition()` within the first item's (its main write's) condition. */
|
|
973
|
+
callerCondition) => diagnoseFailure({
|
|
974
|
+
sent: () => ({
|
|
975
|
+
_tag: "Transaction",
|
|
976
|
+
items,
|
|
977
|
+
targets: () => targetsFor(items, entityKeyFields(), entityType, (index) => index === 0 ? "the item" : `item ${index} of the ${operation} transaction`),
|
|
978
|
+
callerCondition: (index) => (index === 0 ? callerCondition?.() : undefined),
|
|
979
|
+
}),
|
|
980
|
+
keys: entityKeys,
|
|
981
|
+
rewrite: (diagnosis, original) => diagnosis._tag === "TooManyItems"
|
|
982
|
+
? new TransactionOverflow({
|
|
983
|
+
entityType,
|
|
984
|
+
operation,
|
|
985
|
+
itemCount: diagnosis.count,
|
|
986
|
+
limit: diagnosis.limit,
|
|
987
|
+
cause: original,
|
|
988
|
+
})
|
|
989
|
+
: diagnosedValidationError(diagnosis, original, { operation, entityType }),
|
|
990
|
+
});
|
|
836
991
|
const composePrimaryKey = (record) => {
|
|
837
992
|
const primary = config.indexes.primary;
|
|
838
993
|
const recordKeyForm = keyForm(record);
|
|
@@ -979,43 +1134,68 @@ const makeImpl = (config, precomputedData) => {
|
|
|
979
1134
|
const corruption = versionCorruption(raw, "decode");
|
|
980
1135
|
if (corruption !== undefined)
|
|
981
1136
|
return Effect.fail(corruption);
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1137
|
+
return Effect.flatMap(unpackFields(raw), () => {
|
|
1138
|
+
deserializeSparseFields(raw);
|
|
1139
|
+
renameFromDynamo(raw);
|
|
1140
|
+
return Schema.decodeUnknownEffect(schemas.recordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
|
|
1141
|
+
entityType,
|
|
1142
|
+
operation: "decode",
|
|
1143
|
+
cause,
|
|
1144
|
+
})));
|
|
1145
|
+
});
|
|
989
1146
|
};
|
|
990
1147
|
/** Decode a raw item using the schema selected by mode */
|
|
991
1148
|
const decodeAs = (raw, marshalled, mode) => {
|
|
992
1149
|
const corruption = versionCorruption(raw, "decode");
|
|
993
1150
|
if (corruption !== undefined)
|
|
994
1151
|
return Effect.fail(corruption);
|
|
1152
|
+
// Native: the item as stored — packed values stay their `S` / `B`.
|
|
995
1153
|
if (mode === "native")
|
|
996
1154
|
return Effect.succeed(marshalled);
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1155
|
+
return Effect.flatMap(unpackFields(raw), () => {
|
|
1156
|
+
// Collect flattened sparse-map attributes back into the domain Record.
|
|
1157
|
+
// Must happen before rename so the rebuilt Record sits at the domain field
|
|
1158
|
+
// name (sparse field renaming is not supported).
|
|
1159
|
+
deserializeSparseFields(raw);
|
|
1160
|
+
// Rename DynamoDB attributes back to domain field names. Substituted
|
|
1161
|
+
// schemas handle wire→domain conversion (date primitives, Redacted, etc.).
|
|
1162
|
+
renameFromDynamo(raw);
|
|
1163
|
+
const targetSchema = mode === "model"
|
|
1164
|
+
? schemas.modelSchema
|
|
1165
|
+
: mode === "record"
|
|
1166
|
+
? schemas.recordSchema
|
|
1167
|
+
: schemas.itemSchema;
|
|
1168
|
+
return Schema.decodeUnknownEffect(targetSchema)(raw).pipe(Effect.map((decoded) => mode === "model" && isSchemaClass && !hasHiddenFields
|
|
1169
|
+
? new rawModel(decoded)
|
|
1170
|
+
: mode !== "item"
|
|
1171
|
+
? attachPrototype(decoded)
|
|
1172
|
+
: decoded), Effect.mapError((cause) => new ValidationError({
|
|
1173
|
+
entityType,
|
|
1174
|
+
operation: "decode",
|
|
1175
|
+
cause,
|
|
1176
|
+
})));
|
|
1177
|
+
});
|
|
1178
|
+
};
|
|
1179
|
+
/**
|
|
1180
|
+
* Decode a DynamoDB Streams image into an item record (`Entity.Item`): the
|
|
1181
|
+
* image normalised (DynamoDB JSON or unmarshalled; `B` as bytes or base64),
|
|
1182
|
+
* its dispatch-eligible attributes read by stored form, then sparse rebuild,
|
|
1183
|
+
* renames and the item-schema decode — exactly what a table read applies.
|
|
1184
|
+
*/
|
|
1185
|
+
const decodeImageItem = (image) => Effect.gen(function* () {
|
|
1186
|
+
// An image of another entity type is decoded as 1.25.0 decoded it: its
|
|
1187
|
+
// attributes are never read as packed values (a plain string of that
|
|
1188
|
+
// type is not this entity's JSON). The type is read off the raw image.
|
|
1189
|
+
const owner = imageEntityType(image);
|
|
1190
|
+
const raw = yield* decodeImage(image, owner === undefined || owner === entityType ? dispatchAttributes : [], { entityType, operation: "decodeMarshalledItem" }, { probe: config.indexes.primary.pk.field });
|
|
1191
|
+
const corruption = versionCorruption(raw, "decodeMarshalledItem");
|
|
1192
|
+
if (corruption !== undefined)
|
|
1193
|
+
return yield* corruption;
|
|
1000
1194
|
deserializeSparseFields(raw);
|
|
1001
|
-
// Rename DynamoDB attributes back to domain field names. Substituted
|
|
1002
|
-
// schemas handle wire→domain conversion (date primitives, Redacted, etc.).
|
|
1003
1195
|
renameFromDynamo(raw);
|
|
1004
|
-
const
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
? schemas.recordSchema
|
|
1008
|
-
: schemas.itemSchema;
|
|
1009
|
-
return Schema.decodeUnknownEffect(targetSchema)(raw).pipe(Effect.map((decoded) => mode === "model" && isSchemaClass && !hasHiddenFields
|
|
1010
|
-
? new rawModel(decoded)
|
|
1011
|
-
: mode !== "item"
|
|
1012
|
-
? attachPrototype(decoded)
|
|
1013
|
-
: decoded), Effect.mapError((cause) => new ValidationError({
|
|
1014
|
-
entityType,
|
|
1015
|
-
operation: "decode",
|
|
1016
|
-
cause,
|
|
1017
|
-
})));
|
|
1018
|
-
};
|
|
1196
|
+
const decoded = yield* Schema.decodeUnknownEffect(schemas.itemSchema)(raw).pipe(Effect.mapError((cause) => new ValidationError({ entityType, operation: "decodeMarshalledItem", cause })));
|
|
1197
|
+
return attachPrototype(decoded);
|
|
1198
|
+
});
|
|
1019
1199
|
const nowIso = (now) => DateTime.formatIso(now);
|
|
1020
1200
|
/**
|
|
1021
1201
|
* Generate a timestamp value as a wire primitive ready for marshalling.
|
|
@@ -1320,6 +1500,9 @@ const makeImpl = (config, precomputedData) => {
|
|
|
1320
1500
|
const current = yield* client.getItem({ TableName: tableName, Key: marshalledKey });
|
|
1321
1501
|
if (current.Item) {
|
|
1322
1502
|
const currentDomain = fromAttributeMap(current.Item);
|
|
1503
|
+
// Packed fields are read by their stored form: the source text is
|
|
1504
|
+
// derived from the value, not its JSON string or compressed bytes.
|
|
1505
|
+
yield* unpackFields(currentDomain, "update.vector");
|
|
1323
1506
|
renameFromDynamo(currentDomain);
|
|
1324
1507
|
merged = { ...currentDomain, ...merged };
|
|
1325
1508
|
}
|
|
@@ -1485,25 +1668,6 @@ const makeImpl = (config, precomputedData) => {
|
|
|
1485
1668
|
}
|
|
1486
1669
|
return new ConditionalCheckFailed({ entityType, key });
|
|
1487
1670
|
};
|
|
1488
|
-
/**
|
|
1489
|
-
* A condition the library sends — its own guard plus the user's
|
|
1490
|
-
* `.condition()` — that exceeds DynamoDB's limits on one expression (4 KB,
|
|
1491
|
-
* 300 operators and functions): refused BEFORE writing (#133).
|
|
1492
|
-
*/
|
|
1493
|
-
const oversizedCondition = (operation, condition, user) => {
|
|
1494
|
-
if (condition === undefined || expressionFits(condition))
|
|
1495
|
-
return undefined;
|
|
1496
|
-
const own = expressionCost(user);
|
|
1497
|
-
return new ValidationError({
|
|
1498
|
-
entityType,
|
|
1499
|
-
operation,
|
|
1500
|
-
cause: `The ${operation}'s condition is ${expressionCost(condition).length} characters / ` +
|
|
1501
|
-
`${expressionCost(condition).operators} operators — beyond DynamoDB's limits on one ` +
|
|
1502
|
-
`expression (${EXPRESSION_LIMIT} characters, ${OPERATOR_LIMIT} operators). The ` +
|
|
1503
|
-
`.condition() alone is ${own.length} characters / ${own.operators} operators, and ` +
|
|
1504
|
-
"the library's own guard needs room beside it: simplify the condition.",
|
|
1505
|
-
});
|
|
1506
|
-
};
|
|
1507
1671
|
/** The domain field a stored attribute holds (`storedAs` renames reversed). */
|
|
1508
1672
|
const domainNameOf = (attr) => Object.keys(data.modelFields).find((field) => resolveDbName(field) === attr) ?? attr;
|
|
1509
1673
|
/**
|
|
@@ -1514,7 +1678,9 @@ const makeImpl = (config, precomputedData) => {
|
|
|
1514
1678
|
* absent). `"item"` means the whole item — a tombstone copies all of it —
|
|
1515
1679
|
* so every stored attribute is guarded, plus every attribute the entity
|
|
1516
1680
|
* knows of that was absent; an item too wide for that falls back to the
|
|
1517
|
-
* strongest guard within DynamoDB's expression limit (never a refusal).
|
|
1681
|
+
* strongest guard within DynamoDB's expression limit (never a refusal). A
|
|
1682
|
+
* guard that cannot fit beside `others` at all is sent as it is: DynamoDB
|
|
1683
|
+
* rejects the expression, and the rejection is diagnosed (#144).
|
|
1518
1684
|
*/
|
|
1519
1685
|
const deleteGuard = (read, dependsOn,
|
|
1520
1686
|
/** The rest of the condition, ANDed with the guard (the user's, …). */
|
|
@@ -1540,15 +1706,10 @@ const makeImpl = (config, precomputedData) => {
|
|
|
1540
1706
|
const incarnation = incarnationGuard(read, names, values);
|
|
1541
1707
|
if (incarnation !== undefined)
|
|
1542
1708
|
parts.push(incarnation);
|
|
1543
|
-
|
|
1544
|
-
if (!fits(expression))
|
|
1545
|
-
return tooLargeBeside(expression);
|
|
1546
|
-
return { expression, names, values, inputs: [] };
|
|
1709
|
+
return { expression: parts.join(" AND "), names, values, inputs: [] };
|
|
1547
1710
|
}
|
|
1548
1711
|
names["#dpk"] = pkField;
|
|
1549
1712
|
parts.push("attribute_exists(#dpk)");
|
|
1550
|
-
if (!fits(parts.join(" AND ")))
|
|
1551
|
-
return tooLargeBeside(parts.join(" AND "));
|
|
1552
1713
|
const attrs = dependsOn === "item"
|
|
1553
1714
|
? [
|
|
1554
1715
|
...new Set([
|
|
@@ -1604,9 +1765,6 @@ const makeImpl = (config, precomputedData) => {
|
|
|
1604
1765
|
inputs.push(attr);
|
|
1605
1766
|
}
|
|
1606
1767
|
return { expression, names, values, inputs };
|
|
1607
|
-
function tooLargeBeside(guard) {
|
|
1608
|
-
return (oversizedCondition("write", others === undefined || others === "" ? guard : `${guard} AND (${others})`, others) ?? new ValidationError({ entityType, operation: "write", cause: "condition too large" }));
|
|
1609
|
-
}
|
|
1610
1768
|
};
|
|
1611
1769
|
/** Which predicate rejected a guarded delete — from the stored item (ALL_OLD). */
|
|
1612
1770
|
const deleteRejection = (key, read, inputs, stored, userCondition) => {
|
|
@@ -1711,16 +1869,18 @@ const makeImpl = (config, precomputedData) => {
|
|
|
1711
1869
|
if (mode === "native")
|
|
1712
1870
|
return Effect.succeed(projected);
|
|
1713
1871
|
const raw = fromAttributeMap(projected);
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1872
|
+
return Effect.flatMap(unpackFields(raw), () => {
|
|
1873
|
+
deserializeSparseFields(raw);
|
|
1874
|
+
renameFromDynamo(raw);
|
|
1875
|
+
const target = mode === "model"
|
|
1876
|
+
? schemas.modelSchema
|
|
1877
|
+
: mode === "record"
|
|
1878
|
+
? schemas.recordSchema
|
|
1879
|
+
: schemas.itemSchema;
|
|
1880
|
+
const fields = target.fields;
|
|
1881
|
+
const present = Object.fromEntries(Object.entries(fields).filter(([name]) => name in raw));
|
|
1882
|
+
return Schema.decodeUnknownEffect(Schema.Struct(present))(raw).pipe(Effect.mapError((cause) => new ValidationError({ entityType, operation: "decode", cause })));
|
|
1883
|
+
});
|
|
1724
1884
|
};
|
|
1725
1885
|
/**
|
|
1726
1886
|
* The value an update returns for its `returnValues` mode, from the item
|
|
@@ -2192,15 +2352,12 @@ const makeImpl = (config, precomputedData) => {
|
|
|
2192
2352
|
inputs: [],
|
|
2193
2353
|
}
|
|
2194
2354
|
: deleteGuard(current, uniqueAttrs, userCondition?.expression);
|
|
2195
|
-
if (guard instanceof ValidationError)
|
|
2196
|
-
return yield* guard;
|
|
2197
2355
|
const mainValues = { ...guard.values, ...userCondition?.values };
|
|
2356
|
+
// Sent as built: a condition over DynamoDB's expression limits is
|
|
2357
|
+
// DynamoDB's to reject, and is diagnosed naming the caller's part (#144).
|
|
2198
2358
|
const mainCondition = userCondition
|
|
2199
2359
|
? `${guard.expression} AND (${userCondition.expression})`
|
|
2200
2360
|
: guard.expression;
|
|
2201
|
-
const tooLarge = oversizedCondition(args.operation, mainCondition, userCondition?.expression);
|
|
2202
|
-
if (tooLarge !== undefined)
|
|
2203
|
-
return yield* tooLarge;
|
|
2204
2361
|
const items = [
|
|
2205
2362
|
{
|
|
2206
2363
|
Put: {
|
|
@@ -2355,7 +2512,6 @@ const makeImpl = (config, precomputedData) => {
|
|
|
2355
2512
|
let lost;
|
|
2356
2513
|
for (let attempt = 0; attempt < GUARDED_PUT_ATTEMPTS; attempt++) {
|
|
2357
2514
|
const plan = yield* planPut(args);
|
|
2358
|
-
yield* checkTransactionLimit(entityType, args.operation, plan.items);
|
|
2359
2515
|
const write = plan.items.length === 1
|
|
2360
2516
|
? client.putItem(plan.items[0].Put)
|
|
2361
2517
|
: client.transactWriteItems({ TransactItems: [...plan.items] });
|
|
@@ -2367,7 +2523,11 @@ const makeImpl = (config, precomputedData) => {
|
|
|
2367
2523
|
: undefined;
|
|
2368
2524
|
const read = reasons !== undefined ? plan.verdict(reasons) : undefined;
|
|
2369
2525
|
return read !== undefined ? Effect.succeed(read) : Effect.fail(err);
|
|
2370
|
-
})
|
|
2526
|
+
}),
|
|
2527
|
+
// Terminal: a diagnosed limit is never retried as a lost race.
|
|
2528
|
+
plan.items.length === 1
|
|
2529
|
+
? diagnoseItemWrite(args.operation, plan.items[0], () => args.userCondition?.expression)
|
|
2530
|
+
: diagnoseTransactionWrite(args.operation, plan.items, () => args.userCondition?.expression));
|
|
2371
2531
|
if (verdict === undefined)
|
|
2372
2532
|
return plan;
|
|
2373
2533
|
switch (verdict._tag) {
|
|
@@ -2518,6 +2678,11 @@ const makeImpl = (config, precomputedData) => {
|
|
|
2518
2678
|
}
|
|
2519
2679
|
// Resolve target table name
|
|
2520
2680
|
const { name: targetTableName } = yield* target._tableTag;
|
|
2681
|
+
// The target's primary-key attributes. A target need not be keyed `pk` /
|
|
2682
|
+
// `sk`, and one cascade can span targets keyed differently, so each
|
|
2683
|
+
// update reads its key — and is diagnosed — by its own target's fields.
|
|
2684
|
+
const targetPrimary = target.indexes.primary;
|
|
2685
|
+
const keyFields = [targetPrimary.pk.field, targetPrimary.sk.field];
|
|
2521
2686
|
// Compose GSI PK for the source entity's ID
|
|
2522
2687
|
const targetSchema = target._schema;
|
|
2523
2688
|
const gsiPkValue = KeyComposer.composePk(targetSchema, target.entityType, cascadeIdx.indexDef, keyFormFor(target, { [matchingRef.idFieldName]: sourceIdValue }));
|
|
@@ -2558,8 +2723,10 @@ const makeImpl = (config, precomputedData) => {
|
|
|
2558
2723
|
});
|
|
2559
2724
|
for (const item of queryResult.Items ?? []) {
|
|
2560
2725
|
allUpdateOps.push({
|
|
2726
|
+
entityType: target.entityType,
|
|
2561
2727
|
tableName: targetTableName,
|
|
2562
|
-
key: {
|
|
2728
|
+
key: { [keyFields[0]]: item[keyFields[0]], [keyFields[1]]: item[keyFields[1]] },
|
|
2729
|
+
keyFields,
|
|
2563
2730
|
refFieldName: matchingRef.fieldName,
|
|
2564
2731
|
refData: toAttributeValue(sourceDomainData),
|
|
2565
2732
|
});
|
|
@@ -2570,45 +2737,76 @@ const makeImpl = (config, precomputedData) => {
|
|
|
2570
2737
|
if (allUpdateOps.length === 0)
|
|
2571
2738
|
return;
|
|
2572
2739
|
if (mode === "transactional") {
|
|
2573
|
-
// Transactional mode —
|
|
2574
|
-
|
|
2575
|
-
|
|
2740
|
+
// Transactional mode — one atomic transaction. DynamoDB's limits (100
|
|
2741
|
+
// items, 4 MB, item size, sort keys) are not pre-checked (#144): a
|
|
2742
|
+
// rejection is diagnosed and reported as a cascade that wrote nothing.
|
|
2743
|
+
const cascadeItems = allUpdateOps.map((op) => ({
|
|
2744
|
+
Update: {
|
|
2745
|
+
TableName: op.tableName,
|
|
2746
|
+
Key: op.key,
|
|
2747
|
+
UpdateExpression: "SET #ref = :refData",
|
|
2748
|
+
ExpressionAttributeNames: { "#ref": op.refFieldName },
|
|
2749
|
+
ExpressionAttributeValues: { ":refData": op.refData },
|
|
2750
|
+
},
|
|
2751
|
+
}));
|
|
2752
|
+
yield* client.transactWriteItems({ TransactItems: [...cascadeItems] }).pipe(diagnoseFailure({
|
|
2753
|
+
sent: () => ({
|
|
2754
|
+
_tag: "Transaction",
|
|
2755
|
+
items: cascadeItems,
|
|
2756
|
+
targets: () => cascadeItems.map((item, index) => {
|
|
2757
|
+
const op = allUpdateOps[index];
|
|
2758
|
+
return transactItemTarget(item, op.tableName, op.keyFields, op.entityType, `the cascade update of ${op.entityType} (item ${index})`);
|
|
2759
|
+
}),
|
|
2760
|
+
}),
|
|
2761
|
+
keys: () => ({
|
|
2762
|
+
partition: [...new Set(allUpdateOps.map((op) => op.keyFields[0]))],
|
|
2763
|
+
sort: [...new Set(allUpdateOps.map((op) => op.keyFields[1]))],
|
|
2764
|
+
}),
|
|
2765
|
+
rewrite: (diagnosis, original) => new CascadePartialFailure({
|
|
2576
2766
|
sourceEntity: sourceEntityType,
|
|
2577
2767
|
sourceId: sourceIdValue,
|
|
2578
2768
|
succeeded: 0,
|
|
2579
2769
|
failed: allUpdateOps.length,
|
|
2580
|
-
errors: [
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
UpdateExpression: "SET #ref = :refData",
|
|
2589
|
-
ExpressionAttributeNames: { "#ref": op.refFieldName },
|
|
2590
|
-
ExpressionAttributeValues: { ":refData": op.refData },
|
|
2591
|
-
},
|
|
2592
|
-
})),
|
|
2593
|
-
});
|
|
2770
|
+
errors: [
|
|
2771
|
+
diagnosedValidationError(diagnosis, original, {
|
|
2772
|
+
operation: "cascade",
|
|
2773
|
+
entityType: sourceEntityType,
|
|
2774
|
+
}),
|
|
2775
|
+
],
|
|
2776
|
+
}),
|
|
2777
|
+
}));
|
|
2594
2778
|
}
|
|
2595
2779
|
else {
|
|
2596
2780
|
// Eventual mode — concurrent updates with partial failure tracking
|
|
2597
2781
|
let succeeded = 0;
|
|
2598
2782
|
const errors = [];
|
|
2599
|
-
const updateEffects = allUpdateOps.map((op) =>
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
.
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2783
|
+
const updateEffects = allUpdateOps.map((op) => {
|
|
2784
|
+
const update = {
|
|
2785
|
+
TableName: op.tableName,
|
|
2786
|
+
Key: op.key,
|
|
2787
|
+
UpdateExpression: "SET #ref = :refData",
|
|
2788
|
+
ExpressionAttributeNames: { "#ref": op.refFieldName },
|
|
2789
|
+
ExpressionAttributeValues: { ":refData": op.refData },
|
|
2790
|
+
};
|
|
2791
|
+
return client.updateItem(update).pipe(
|
|
2792
|
+
// An item the cascade grows past 400 KB is named in `errors`.
|
|
2793
|
+
diagnoseFailure({
|
|
2794
|
+
sent: () => ({
|
|
2795
|
+
_tag: "Item",
|
|
2796
|
+
item: { Update: update },
|
|
2797
|
+
target: () => transactItemTarget({ Update: update }, op.tableName, op.keyFields, op.entityType, `the cascade update of ${op.entityType}`),
|
|
2798
|
+
}),
|
|
2799
|
+
keys: () => ({ partition: [op.keyFields[0]], sort: [op.keyFields[1]] }),
|
|
2800
|
+
rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, {
|
|
2801
|
+
operation: "cascade",
|
|
2802
|
+
entityType: op.entityType,
|
|
2803
|
+
}),
|
|
2804
|
+
}), Effect.tap(() => Effect.sync(() => {
|
|
2805
|
+
succeeded++;
|
|
2806
|
+
})), Effect.catch((err) => Effect.sync(() => {
|
|
2807
|
+
errors.push(err);
|
|
2808
|
+
})));
|
|
2809
|
+
});
|
|
2612
2810
|
yield* Effect.all(updateEffects, { concurrency: 10 });
|
|
2613
2811
|
if (errors.length > 0) {
|
|
2614
2812
|
return yield* new CascadePartialFailure({
|
|
@@ -2730,6 +2928,11 @@ const makeImpl = (config, precomputedData) => {
|
|
|
2730
2928
|
cause: e instanceof Error ? e.message : String(e),
|
|
2731
2929
|
});
|
|
2732
2930
|
}
|
|
2931
|
+
// Pack `storedAs: DynamoModel.Packed` fields to their stored form
|
|
2932
|
+
// (JSON string, or compressed binary over the threshold) — after
|
|
2933
|
+
// renames and sparse flattening, before marshalling, so every size
|
|
2934
|
+
// measured from here on is the stored size.
|
|
2935
|
+
yield* packFields(item, operation);
|
|
2733
2936
|
// The caller's condition, ANDed onto `create`'s not-exists guard —
|
|
2734
2937
|
// a guard no `.condition()` can replace (#133). `upsert`'s create
|
|
2735
2938
|
// attempt brings its own (`runGuardedPut` requires the item missing).
|
|
@@ -2778,7 +2981,7 @@ const makeImpl = (config, precomputedData) => {
|
|
|
2778
2981
|
});
|
|
2779
2982
|
}
|
|
2780
2983
|
return err;
|
|
2781
|
-
}));
|
|
2984
|
+
}), diagnoseItemWrite("put", { Put: putInput }));
|
|
2782
2985
|
}
|
|
2783
2986
|
// Decode and return using selected mode
|
|
2784
2987
|
return yield* decodeAs(item, marshalledItem, mode);
|
|
@@ -2816,6 +3019,14 @@ const makeImpl = (config, precomputedData) => {
|
|
|
2816
3019
|
let projectionExpression;
|
|
2817
3020
|
let projectionNames;
|
|
2818
3021
|
if (opts.projection && opts.projection.length > 0) {
|
|
3022
|
+
const packedProjection = packedProjectionRefusal(opts.projection.map((attribute) => [attribute]), packedGuards);
|
|
3023
|
+
if (packedProjection !== undefined) {
|
|
3024
|
+
return yield* new ValidationError({
|
|
3025
|
+
entityType,
|
|
3026
|
+
operation: "get.projection",
|
|
3027
|
+
cause: packedProjection,
|
|
3028
|
+
});
|
|
3029
|
+
}
|
|
2819
3030
|
const proj = Projection.projection(opts.projection);
|
|
2820
3031
|
projectionExpression = proj.expression;
|
|
2821
3032
|
projectionNames = proj.names;
|
|
@@ -2832,9 +3043,12 @@ const makeImpl = (config, precomputedData) => {
|
|
|
2832
3043
|
if (!result.Item) {
|
|
2833
3044
|
return yield* new ItemNotFound({ entityType, key: encodedKey });
|
|
2834
3045
|
}
|
|
2835
|
-
// Raw mode: return unmarshalled record without schema decode
|
|
3046
|
+
// Raw mode: return unmarshalled record without schema decode — a
|
|
3047
|
+
// packed attribute as its parsed JSON (#147).
|
|
2836
3048
|
if (mode === "raw") {
|
|
2837
|
-
|
|
3049
|
+
const row = fromAttributeMap(result.Item);
|
|
3050
|
+
yield* unpackFields(row);
|
|
3051
|
+
return row;
|
|
2838
3052
|
}
|
|
2839
3053
|
// Unmarshall and decode using selected mode
|
|
2840
3054
|
const raw = fromAttributeMap(result.Item);
|
|
@@ -2849,6 +3063,15 @@ const makeImpl = (config, precomputedData) => {
|
|
|
2849
3063
|
* Used instead of reading it again: the write is guarded on it either way.
|
|
2850
3064
|
*/
|
|
2851
3065
|
preRead) => Effect.gen(function* () {
|
|
3066
|
+
// A packed field takes only a whole-value .set() / .remove() (#147).
|
|
3067
|
+
const packedRefusal = refusedPackedUpdate(requested);
|
|
3068
|
+
if (packedRefusal !== undefined) {
|
|
3069
|
+
return yield* new ValidationError({
|
|
3070
|
+
entityType,
|
|
3071
|
+
operation: "update",
|
|
3072
|
+
cause: packedRefusal,
|
|
3073
|
+
});
|
|
3074
|
+
}
|
|
2852
3075
|
// Path operations on a field that feeds a key or a unique sentinel
|
|
2853
3076
|
// become the record operations they are equivalent to, or are
|
|
2854
3077
|
// refused (#133) — before anything reads the state.
|
|
@@ -3241,7 +3464,10 @@ const makeImpl = (config, precomputedData) => {
|
|
|
3241
3464
|
const embedFor = new Set(vectorIndexesNeedingUpdate(collectTouchedFields(hydratedUpdates, uState), uState.withVectors).map(([logicalName]) => logicalName));
|
|
3242
3465
|
// `newItem` is the fully merged post-update item (removals already
|
|
3243
3466
|
// applied above), so a put-style recompute is exact.
|
|
3244
|
-
|
|
3467
|
+
// Untouched packed fields still hold their stored form on `newItem`
|
|
3468
|
+
// (kept byte-identical for the write diff), so the source text is
|
|
3469
|
+
// derived from an unpacked copy.
|
|
3470
|
+
const vectorWrite = yield* computeVectorAttributes(yield* unpackedDomainCopy(newItem, "update.vector"), uState.withVectors, embedFor);
|
|
3245
3471
|
Object.assign(newItem, vectorWrite.sets);
|
|
3246
3472
|
for (const field of vectorWrite.removes)
|
|
3247
3473
|
delete newItem[field];
|
|
@@ -3259,9 +3485,17 @@ const makeImpl = (config, precomputedData) => {
|
|
|
3259
3485
|
cause: e instanceof Error ? e.message : String(e),
|
|
3260
3486
|
});
|
|
3261
3487
|
}
|
|
3488
|
+
// Pack only the packed fields this update names: the others still
|
|
3489
|
+
// hold the stored value read above (a string or bytes), carried
|
|
3490
|
+
// byte-identical so the "write only what differs" diff below leaves
|
|
3491
|
+
// them alone (#147).
|
|
3492
|
+
yield* packFields(newItem, "update", new Set(Object.entries(hydratedUpdates)
|
|
3493
|
+
.filter(([field, value]) => field in packedFields && value != null)
|
|
3494
|
+
.map(([field]) => resolveDbName(field))));
|
|
3262
3495
|
const currentItem = currentResult.Item;
|
|
3263
3496
|
yield* checkVersion(currentItem, "update");
|
|
3264
|
-
const immutableChange = immutableMismatch(immutables, currentItem)
|
|
3497
|
+
const immutableChange = immutableMismatch(immutables, currentItem) ??
|
|
3498
|
+
(yield* packedImmutableMismatch(immutables, currentItem));
|
|
3265
3499
|
if (immutableChange !== undefined) {
|
|
3266
3500
|
return yield* new ValidationError({
|
|
3267
3501
|
entityType,
|
|
@@ -3414,12 +3648,9 @@ const makeImpl = (config, precomputedData) => {
|
|
|
3414
3648
|
// versioned (exact), otherwise under the strongest guard that fits —
|
|
3415
3649
|
// the same fallback a wide soft delete uses.
|
|
3416
3650
|
const wide = !expressionFits(updateExpression, "update");
|
|
3417
|
-
const
|
|
3651
|
+
const wideGuard = wide && !systemFields.version
|
|
3418
3652
|
? deleteGuard(currentItem, "item", condParts.slice(1).join(" AND "))
|
|
3419
3653
|
: undefined;
|
|
3420
|
-
if (wideGuardOrRefusal instanceof ValidationError)
|
|
3421
|
-
return yield* wideGuardOrRefusal;
|
|
3422
|
-
const wideGuard = wideGuardOrRefusal;
|
|
3423
3654
|
const putCondition = wideGuard === undefined
|
|
3424
3655
|
? condParts.join(" AND ")
|
|
3425
3656
|
: [wideGuard.expression, ...condParts.slice(1)].join(" AND ");
|
|
@@ -3435,11 +3666,8 @@ const makeImpl = (config, precomputedData) => {
|
|
|
3435
3666
|
...(Object.keys(putValues).length > 0 && { ExpressionAttributeValues: putValues }),
|
|
3436
3667
|
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
|
|
3437
3668
|
};
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
: undefined);
|
|
3441
|
-
if (tooLarge !== undefined)
|
|
3442
|
-
return yield* tooLarge;
|
|
3669
|
+
// A condition over DynamoDB's expression limits is sent as built:
|
|
3670
|
+
// DynamoDB rejects it, and the rejection is diagnosed (#144).
|
|
3443
3671
|
// The stored attributes the rejection compares: the guarded inputs,
|
|
3444
3672
|
// and on the wide fallback whatever else its guard covered.
|
|
3445
3673
|
const guardedAttrs = [
|
|
@@ -3490,7 +3718,7 @@ const makeImpl = (config, precomputedData) => {
|
|
|
3490
3718
|
// write's own SET / REMOVE is the exact new image as well.
|
|
3491
3719
|
const result = yield* withCurrent((wide
|
|
3492
3720
|
? client.putItem({ ...mainPut, ReturnValues: "ALL_OLD" })
|
|
3493
|
-
: client.updateItem({ ...mainUpdate, ReturnValues: "ALL_OLD" })).pipe(Effect.mapError((err) => isAwsConditionalCheckFailed(err.cause) ? recordRejected(err.cause.Item) : err)));
|
|
3721
|
+
: client.updateItem({ ...mainUpdate, ReturnValues: "ALL_OLD" })).pipe(Effect.mapError((err) => isAwsConditionalCheckFailed(err.cause) ? recordRejected(err.cause.Item) : err), diagnoseItemWrite("update", wide ? { Put: mainPut } : { Update: mainUpdate }, () => uc?.expression)));
|
|
3494
3722
|
oldImage = result.Attributes ?? currentItem;
|
|
3495
3723
|
const applied = {
|
|
3496
3724
|
...oldImage,
|
|
@@ -3538,7 +3766,6 @@ const makeImpl = (config, precomputedData) => {
|
|
|
3538
3766
|
});
|
|
3539
3767
|
}
|
|
3540
3768
|
}
|
|
3541
|
-
yield* checkTransactionLimit(entityType, "update", transactItems);
|
|
3542
3769
|
yield* withCurrent(client.transactWriteItems({ TransactItems: transactItems }).pipe(Effect.mapError((err) => {
|
|
3543
3770
|
if (isAwsTransactionCancelled(err.cause)) {
|
|
3544
3771
|
const reasons = err.cause.CancellationReasons;
|
|
@@ -3571,7 +3798,7 @@ const makeImpl = (config, precomputedData) => {
|
|
|
3571
3798
|
return recordRejected(err.cause.Item);
|
|
3572
3799
|
}
|
|
3573
3800
|
return err;
|
|
3574
|
-
})));
|
|
3801
|
+
}), diagnoseTransactionWrite("update", transactItems, () => uc?.expression)));
|
|
3575
3802
|
}
|
|
3576
3803
|
// Execute cascade if configured (retain path) — from the new item.
|
|
3577
3804
|
if (uState.cascade) {
|
|
@@ -3609,7 +3836,8 @@ const makeImpl = (config, precomputedData) => {
|
|
|
3609
3836
|
return yield* missingItem();
|
|
3610
3837
|
}
|
|
3611
3838
|
yield* checkVersion(current.Item, "update");
|
|
3612
|
-
const immutableChange = immutableMismatch(immutables, current.Item)
|
|
3839
|
+
const immutableChange = immutableMismatch(immutables, current.Item) ??
|
|
3840
|
+
(yield* packedImmutableMismatch(immutables, current.Item));
|
|
3613
3841
|
if (immutableChange !== undefined) {
|
|
3614
3842
|
return yield* new ValidationError({
|
|
3615
3843
|
entityType,
|
|
@@ -3662,6 +3890,28 @@ const makeImpl = (config, precomputedData) => {
|
|
|
3662
3890
|
[uState.remove, uState.sparseRemoveEntries, uState.sparseClearFields].every((ops) => ops === undefined || ops.length === 0) &&
|
|
3663
3891
|
[uState.add, uState.subtract, uState.append, uState.deleteFromSet].every((ops) => ops === undefined || Object.keys(ops).length === 0) &&
|
|
3664
3892
|
!hasPathOps(uState);
|
|
3893
|
+
// A restated packed immutable cannot be asserted by a condition on its
|
|
3894
|
+
// stored bytes (they depend on the codec and threshold it was written
|
|
3895
|
+
// under): an unread write reads it and compares the unpacked JSON
|
|
3896
|
+
// (#147). The write still requires the item (`attribute_exists`), and
|
|
3897
|
+
// no library write changes an immutable value in between.
|
|
3898
|
+
if (plainWrite && immutables.some((asserted) => asserted.packed)) {
|
|
3899
|
+
const stored = yield* client.getItem({
|
|
3900
|
+
TableName: tableName,
|
|
3901
|
+
Key: marshalledKey,
|
|
3902
|
+
ConsistentRead: true,
|
|
3903
|
+
});
|
|
3904
|
+
if (stored.Item !== undefined) {
|
|
3905
|
+
const packedChange = yield* packedImmutableMismatch(immutables, stored.Item);
|
|
3906
|
+
if (packedChange !== undefined) {
|
|
3907
|
+
return yield* new ValidationError({
|
|
3908
|
+
entityType,
|
|
3909
|
+
operation: "update",
|
|
3910
|
+
cause: packedChange,
|
|
3911
|
+
});
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
}
|
|
3665
3915
|
// Build UpdateExpression
|
|
3666
3916
|
const setClauses = [];
|
|
3667
3917
|
const names = {};
|
|
@@ -3723,8 +3973,13 @@ const makeImpl = (config, precomputedData) => {
|
|
|
3723
3973
|
}
|
|
3724
3974
|
const nameKey = `#u${counter}`;
|
|
3725
3975
|
const valKey = `:u${counter}`;
|
|
3726
|
-
|
|
3727
|
-
|
|
3976
|
+
const storedName = resolveDbName(attr);
|
|
3977
|
+
names[nameKey] = storedName;
|
|
3978
|
+
// A packed field is written whole, as its stored form (#147).
|
|
3979
|
+
values[valKey] =
|
|
3980
|
+
attr in packedFields
|
|
3981
|
+
? (yield* packAttribute(storedName, val, "update"))
|
|
3982
|
+
: toAttributeValue(val);
|
|
3728
3983
|
setClauses.push(`${nameKey} = ${valKey}`);
|
|
3729
3984
|
counter++;
|
|
3730
3985
|
}
|
|
@@ -4123,7 +4378,9 @@ const makeImpl = (config, precomputedData) => {
|
|
|
4123
4378
|
}
|
|
4124
4379
|
if (plainWrite) {
|
|
4125
4380
|
// Restated immutable values must match the item's.
|
|
4126
|
-
for (const [i, { attr, value }] of immutables.entries()) {
|
|
4381
|
+
for (const [i, { attr, value, packed }] of immutables.entries()) {
|
|
4382
|
+
if (packed)
|
|
4383
|
+
continue; // read and compared above (#147)
|
|
4127
4384
|
names[`#imm${i}`] = attr;
|
|
4128
4385
|
values[`:imm${i}`] = value;
|
|
4129
4386
|
condParts.push(`#imm${i} = :imm${i}`);
|
|
@@ -4137,14 +4394,9 @@ const makeImpl = (config, precomputedData) => {
|
|
|
4137
4394
|
if (incarnation !== undefined)
|
|
4138
4395
|
condParts.push(incarnation);
|
|
4139
4396
|
}
|
|
4397
|
+
// Sent as built: a condition over DynamoDB's expression limits is
|
|
4398
|
+
// DynamoDB's to reject, and is diagnosed naming the caller's part (#144).
|
|
4140
4399
|
const conditionExpression = condParts.length > 0 ? condParts.join(" AND ") : undefined;
|
|
4141
|
-
{
|
|
4142
|
-
const tooLarge = oversizedCondition("update", conditionExpression, sentCond !== undefined
|
|
4143
|
-
? compileCondition(sentCond, resolveDbName)?.expression
|
|
4144
|
-
: undefined);
|
|
4145
|
-
if (tooLarge !== undefined)
|
|
4146
|
-
return yield* tooLarge;
|
|
4147
|
-
}
|
|
4148
4400
|
// The top-level attributes this update writes — what `updatedOld` /
|
|
4149
4401
|
// `updatedNew` return.
|
|
4150
4402
|
const written = expressionTargets(updateExpression, names);
|
|
@@ -4172,11 +4424,6 @@ const makeImpl = (config, precomputedData) => {
|
|
|
4172
4424
|
if (incarnation !== undefined)
|
|
4173
4425
|
guardParts.push(incarnation);
|
|
4174
4426
|
}
|
|
4175
|
-
const tooLarge = oversizedCondition("update", guardParts.join(" AND "), sentCond !== undefined
|
|
4176
|
-
? compileCondition(sentCond, resolveDbName)?.expression
|
|
4177
|
-
: undefined);
|
|
4178
|
-
if (tooLarge !== undefined)
|
|
4179
|
-
return yield* tooLarge;
|
|
4180
4427
|
const transactItems = [
|
|
4181
4428
|
{
|
|
4182
4429
|
Update: {
|
|
@@ -4193,7 +4440,6 @@ const makeImpl = (config, precomputedData) => {
|
|
|
4193
4440
|
},
|
|
4194
4441
|
{ Put: snapshotPut(tableName, snapshot.item) },
|
|
4195
4442
|
];
|
|
4196
|
-
yield* checkTransactionLimit(entityType, "update", transactItems);
|
|
4197
4443
|
yield* client.transactWriteItems({ TransactItems: transactItems }).pipe(Effect.mapError((err) => {
|
|
4198
4444
|
// The update is guarded by the snapshot version AND any user
|
|
4199
4445
|
// condition; the stored item (ALL_OLD) says which rejected it.
|
|
@@ -4211,7 +4457,7 @@ const makeImpl = (config, precomputedData) => {
|
|
|
4211
4457
|
if (isAwsConditionalCheckFailed(err.cause))
|
|
4212
4458
|
return rejection(err.cause.Item);
|
|
4213
4459
|
return err;
|
|
4214
|
-
}));
|
|
4460
|
+
}), diagnoseTransactionWrite("update", transactItems, () => uc?.expression));
|
|
4215
4461
|
// A transacted Update returns no attributes. The old item is exact:
|
|
4216
4462
|
// the version condition proves `snapshot.raw` is what it replaced.
|
|
4217
4463
|
// The new item is read back only when needed, and proven ours.
|
|
@@ -4234,8 +4480,7 @@ const makeImpl = (config, precomputedData) => {
|
|
|
4234
4480
|
// DynamoDB rejects an empty `ExpressionAttributeValues` map. When
|
|
4235
4481
|
// the UpdateExpression is REMOVE-only (e.g. clearMap with no other
|
|
4236
4482
|
// combinators), `values` may be empty — omit the property entirely.
|
|
4237
|
-
const
|
|
4238
|
-
.updateItem({
|
|
4483
|
+
const updateInput = {
|
|
4239
4484
|
TableName: tableName,
|
|
4240
4485
|
Key: marshalledKey,
|
|
4241
4486
|
UpdateExpression: updateExpression,
|
|
@@ -4246,8 +4491,8 @@ const makeImpl = (config, precomputedData) => {
|
|
|
4246
4491
|
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
|
|
4247
4492
|
}),
|
|
4248
4493
|
ReturnValues: returned,
|
|
4249
|
-
}
|
|
4250
|
-
|
|
4494
|
+
};
|
|
4495
|
+
const result = yield* client.updateItem(updateInput).pipe(Effect.mapError((err) => {
|
|
4251
4496
|
// The stored item (ALL_OLD) says what rejected the update: no
|
|
4252
4497
|
// item (it must exist), a restated immutable value, the
|
|
4253
4498
|
// version CAS, or the user's condition.
|
|
@@ -4276,7 +4521,7 @@ const makeImpl = (config, precomputedData) => {
|
|
|
4276
4521
|
: undefined, userCond !== undefined);
|
|
4277
4522
|
}
|
|
4278
4523
|
return err;
|
|
4279
|
-
}));
|
|
4524
|
+
}), diagnoseItemWrite("update", { Update: updateInput }, () => uc?.expression));
|
|
4280
4525
|
// A cascade never meets an old-image mode here: that combination is
|
|
4281
4526
|
// routed to the read-then-write branch, or read first (`casRead`).
|
|
4282
4527
|
const newImage = wantsOld ? undefined : result.Attributes;
|
|
@@ -4475,8 +4720,6 @@ const makeImpl = (config, precomputedData) => {
|
|
|
4475
4720
|
// into them (#133).
|
|
4476
4721
|
yield* checkVersion(result.Item, "delete");
|
|
4477
4722
|
const softGuard = deleteGuard(result.Item, "item", userCondition?.expression);
|
|
4478
|
-
if (softGuard instanceof ValidationError)
|
|
4479
|
-
return yield* softGuard;
|
|
4480
4723
|
const raw = fromAttributeMap(result.Item);
|
|
4481
4724
|
// Clock-backed time source; `now` is the ISO timestamp used for the
|
|
4482
4725
|
// deleted SK + deletedAt, `dtNow` drives the optional TTL + snapshot.
|
|
@@ -4545,8 +4788,7 @@ const makeImpl = (config, precomputedData) => {
|
|
|
4545
4788
|
const releases = hasUniqueConstraints && !preserveUnique()
|
|
4546
4789
|
? yield* ownedReleases(tableName, raw, transactItems)
|
|
4547
4790
|
: [];
|
|
4548
|
-
yield*
|
|
4549
|
-
yield* guardedDelete(client.transactWriteItems({ TransactItems: transactItems }), result.Item, softGuard.inputs, snapshotAt, releases);
|
|
4791
|
+
yield* guardedDelete(client.transactWriteItems({ TransactItems: transactItems }), result.Item, softGuard.inputs, snapshotAt, releases).pipe(diagnoseTransactionWrite("delete", transactItems, () => userCondition?.expression));
|
|
4550
4792
|
return result.Item;
|
|
4551
4793
|
}
|
|
4552
4794
|
else {
|
|
@@ -4569,8 +4811,7 @@ const makeImpl = (config, precomputedData) => {
|
|
|
4569
4811
|
if (!userCondition)
|
|
4570
4812
|
return undefined;
|
|
4571
4813
|
const values = userCondition.values;
|
|
4572
|
-
|
|
4573
|
-
.deleteItem({
|
|
4814
|
+
const missingDelete = {
|
|
4574
4815
|
TableName: tableName,
|
|
4575
4816
|
Key: marshalledKey,
|
|
4576
4817
|
ConditionExpression: `attribute_not_exists(#dpk) AND (${userCondition.expression})`,
|
|
@@ -4580,10 +4821,12 @@ const makeImpl = (config, precomputedData) => {
|
|
|
4580
4821
|
},
|
|
4581
4822
|
...(Object.keys(values).length > 0 && { ExpressionAttributeValues: values }),
|
|
4582
4823
|
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
|
|
4583
|
-
}
|
|
4584
|
-
|
|
4824
|
+
};
|
|
4825
|
+
yield* client.deleteItem(missingDelete).pipe(Effect.mapError((err) => isAwsConditionalCheckFailed(err.cause)
|
|
4585
4826
|
? conditionRejection(encodedKey, err.cause.Item, err.cause.Item === undefined ? undefined : { version: 0 }, true)
|
|
4586
|
-
: err)
|
|
4827
|
+
: err), diagnoseItemWrite("delete", {
|
|
4828
|
+
Delete: missingDelete,
|
|
4829
|
+
}));
|
|
4587
4830
|
return undefined;
|
|
4588
4831
|
}
|
|
4589
4832
|
// The sentinel deletes are keyed by the unique values read, and the
|
|
@@ -4596,8 +4839,6 @@ const makeImpl = (config, precomputedData) => {
|
|
|
4596
4839
|
...new Set(Object.values(config.unique).flatMap((def) => resolveUniqueFields(def).map(resolveDbName))),
|
|
4597
4840
|
]
|
|
4598
4841
|
: [], userCondition?.expression);
|
|
4599
|
-
if (hardGuard instanceof ValidationError)
|
|
4600
|
-
return yield* hardGuard;
|
|
4601
4842
|
const raw = fromAttributeMap(result.Item);
|
|
4602
4843
|
const transactItems = [];
|
|
4603
4844
|
// Delete entity item — index 0, and the only item conditioned on the
|
|
@@ -4627,8 +4868,7 @@ const makeImpl = (config, precomputedData) => {
|
|
|
4627
4868
|
const releases = hasUniqueConstraints
|
|
4628
4869
|
? yield* ownedReleases(tableName, raw, transactItems)
|
|
4629
4870
|
: [];
|
|
4630
|
-
yield*
|
|
4631
|
-
yield* guardedDelete(client.transactWriteItems({ TransactItems: transactItems }), result.Item, hardGuard.inputs, snapshotAt, releases);
|
|
4871
|
+
yield* guardedDelete(client.transactWriteItems({ TransactItems: transactItems }), result.Item, hardGuard.inputs, snapshotAt, releases).pipe(diagnoseTransactionWrite("delete", transactItems, () => userCondition?.expression));
|
|
4632
4872
|
return result.Item;
|
|
4633
4873
|
}
|
|
4634
4874
|
});
|
|
@@ -4668,9 +4908,9 @@ const makeImpl = (config, precomputedData) => {
|
|
|
4668
4908
|
if (opts.returnValues !== undefined) {
|
|
4669
4909
|
deleteInput.ReturnValues = opts.returnValues === "allOld" ? "ALL_OLD" : "NONE";
|
|
4670
4910
|
}
|
|
4671
|
-
const output = yield* client
|
|
4672
|
-
|
|
4673
|
-
|
|
4911
|
+
const output = yield* client.deleteItem(deleteInput).pipe(Effect.mapError(mapDeleteConditionFailure), diagnoseItemWrite("delete", {
|
|
4912
|
+
Delete: deleteInput,
|
|
4913
|
+
}));
|
|
4674
4914
|
return yield* deletedResult(output.Attributes);
|
|
4675
4915
|
}
|
|
4676
4916
|
}), self, key);
|
|
@@ -4887,8 +5127,13 @@ const makeImpl = (config, precomputedData) => {
|
|
|
4887
5127
|
}
|
|
4888
5128
|
const nameKey = `#u${counter}`;
|
|
4889
5129
|
const valKey = `:u${counter}`;
|
|
4890
|
-
|
|
4891
|
-
|
|
5130
|
+
const storedName = resolveDbName(attr);
|
|
5131
|
+
names[nameKey] = storedName;
|
|
5132
|
+
// A packed field is written whole, as its stored form (#147).
|
|
5133
|
+
values[valKey] =
|
|
5134
|
+
attr in packedFields
|
|
5135
|
+
? (yield* packAttribute(storedName, val, "upsert"))
|
|
5136
|
+
: toAttributeValue(val);
|
|
4892
5137
|
// Immutable fields use if_not_exists — only set on first create
|
|
4893
5138
|
if (immutableFields.has(attr)) {
|
|
4894
5139
|
setClauses.push(`${nameKey} = if_not_exists(${nameKey}, ${valKey})`);
|
|
@@ -5005,8 +5250,7 @@ const makeImpl = (config, precomputedData) => {
|
|
|
5005
5250
|
Object.assign(names, uc.names);
|
|
5006
5251
|
Object.assign(values, uc.values);
|
|
5007
5252
|
}
|
|
5008
|
-
const
|
|
5009
|
-
.updateItem({
|
|
5253
|
+
const upsertInput = {
|
|
5010
5254
|
TableName: tableName,
|
|
5011
5255
|
Key: marshalledKey,
|
|
5012
5256
|
UpdateExpression: updateExpression,
|
|
@@ -5018,8 +5262,8 @@ const makeImpl = (config, precomputedData) => {
|
|
|
5018
5262
|
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
|
|
5019
5263
|
}),
|
|
5020
5264
|
ReturnValues: "ALL_NEW",
|
|
5021
|
-
}
|
|
5022
|
-
|
|
5265
|
+
};
|
|
5266
|
+
const result = yield* client.updateItem(upsertInput).pipe(Effect.mapError((err) => {
|
|
5023
5267
|
if (isAwsConditionalCheckFailed(err.cause)) {
|
|
5024
5268
|
const corrupt = versionCorruption(err.cause.Item, "upsert");
|
|
5025
5269
|
if (corrupt !== undefined)
|
|
@@ -5028,6 +5272,8 @@ const makeImpl = (config, precomputedData) => {
|
|
|
5028
5272
|
return new ConditionalCheckFailed({ entityType, key: item });
|
|
5029
5273
|
}
|
|
5030
5274
|
return err;
|
|
5275
|
+
}), diagnoseItemWrite("upsert", {
|
|
5276
|
+
Update: upsertInput,
|
|
5031
5277
|
}));
|
|
5032
5278
|
if (!result.Attributes) {
|
|
5033
5279
|
return yield* new ItemNotFound({ entityType, key: item });
|
|
@@ -5118,6 +5364,16 @@ const makeImpl = (config, precomputedData) => {
|
|
|
5118
5364
|
// `append` takes its condition as an argument — no `condition`
|
|
5119
5365
|
// combinator — so an empty one becomes none here (#133).
|
|
5120
5366
|
const userCondition = nonEmptyCondition(givenCondition);
|
|
5367
|
+
if (userCondition !== undefined) {
|
|
5368
|
+
const packedRefusal = packedExprRefusal(toExpr(userCondition), packedGuards, "condition");
|
|
5369
|
+
if (packedRefusal !== undefined) {
|
|
5370
|
+
return yield* new ValidationError({
|
|
5371
|
+
entityType,
|
|
5372
|
+
operation: "append.condition",
|
|
5373
|
+
cause: packedRefusal,
|
|
5374
|
+
});
|
|
5375
|
+
}
|
|
5376
|
+
}
|
|
5121
5377
|
const emptyPart = userCondition === undefined ? undefined : emptyPartProblem(toExpr(userCondition));
|
|
5122
5378
|
if (emptyPart !== undefined) {
|
|
5123
5379
|
return yield* new ValidationError({
|
|
@@ -5215,6 +5471,15 @@ const makeImpl = (config, precomputedData) => {
|
|
|
5215
5471
|
});
|
|
5216
5472
|
// `encoded` is already in wire-form via Schema.encode — used directly.
|
|
5217
5473
|
const serialisedInput = { ...encoded };
|
|
5474
|
+
// Packed `appendInput` fields (#147), packed ONCE: the same stored value
|
|
5475
|
+
// is SET on the current item and written on the event item.
|
|
5476
|
+
const packedStored = {};
|
|
5477
|
+
for (const field of Object.keys(packedFields)) {
|
|
5478
|
+
if (serialisedInput[field] !== undefined) {
|
|
5479
|
+
packedStored[resolveDbName(field)] = serialisedInput[field];
|
|
5480
|
+
}
|
|
5481
|
+
}
|
|
5482
|
+
yield* packFields(packedStored, "append");
|
|
5218
5483
|
// ---------- Build UpdateItem (scoped SET + CAS) ----------
|
|
5219
5484
|
const setClauses = [];
|
|
5220
5485
|
const names = {};
|
|
@@ -5231,8 +5496,9 @@ const makeImpl = (config, precomputedData) => {
|
|
|
5231
5496
|
continue;
|
|
5232
5497
|
const nameKey = `#a${counter}`;
|
|
5233
5498
|
const valKey = `:a${counter}`;
|
|
5234
|
-
|
|
5235
|
-
|
|
5499
|
+
const storedName = resolveDbName(attr);
|
|
5500
|
+
names[nameKey] = storedName;
|
|
5501
|
+
values[valKey] = toAttributeValue(attr in packedFields ? packedStored[storedName] : val);
|
|
5236
5502
|
setClauses.push(`${nameKey} = ${valKey}`);
|
|
5237
5503
|
counter++;
|
|
5238
5504
|
}
|
|
@@ -5331,6 +5597,8 @@ const makeImpl = (config, precomputedData) => {
|
|
|
5331
5597
|
const eventItem = { ...encoded };
|
|
5332
5598
|
// Rename domain → DB names for the Put (matches put path)
|
|
5333
5599
|
renameToDynamo(eventItem);
|
|
5600
|
+
// The packed fields' stored values — the same ones SET above (#147).
|
|
5601
|
+
Object.assign(eventItem, packedStored);
|
|
5334
5602
|
// pk + sk (event)
|
|
5335
5603
|
eventItem[primary.pk.field] = pkValue;
|
|
5336
5604
|
eventItem[primary.sk.field] = eventSk;
|
|
@@ -5368,30 +5636,42 @@ const makeImpl = (config, precomputedData) => {
|
|
|
5368
5636
|
}
|
|
5369
5637
|
const marshalledEventItem = toAttributeMap(eventItem);
|
|
5370
5638
|
// ---------- Execute TransactWriteItems ----------
|
|
5371
|
-
const
|
|
5372
|
-
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
5380
|
-
ExpressionAttributeNames: names,
|
|
5381
|
-
ExpressionAttributeValues: values,
|
|
5382
|
-
},
|
|
5639
|
+
const appendItems = [
|
|
5640
|
+
{
|
|
5641
|
+
Update: {
|
|
5642
|
+
TableName: tableName,
|
|
5643
|
+
Key: marshalledKey,
|
|
5644
|
+
UpdateExpression: updateExpression,
|
|
5645
|
+
ConditionExpression: finalCondition,
|
|
5646
|
+
ExpressionAttributeNames: names,
|
|
5647
|
+
ExpressionAttributeValues: values,
|
|
5383
5648
|
},
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
|
|
5649
|
+
},
|
|
5650
|
+
{
|
|
5651
|
+
Put: {
|
|
5652
|
+
TableName: tableName,
|
|
5653
|
+
Item: marshalledEventItem,
|
|
5389
5654
|
},
|
|
5390
|
-
|
|
5391
|
-
|
|
5392
|
-
|
|
5655
|
+
},
|
|
5656
|
+
];
|
|
5657
|
+
const transactResult = yield* client.transactWriteItems({ TransactItems: appendItems }).pipe(
|
|
5658
|
+
// A limit DynamoDB enforced (#144) — the current item grown past
|
|
5659
|
+
// 400 KB, an oversized event item, an over-long sort key — is
|
|
5660
|
+
// diagnosed first: such a cancellation carries no conditional
|
|
5661
|
+
// failure, so it is never a stale append.
|
|
5662
|
+
diagnoseFailure({
|
|
5663
|
+
sent: () => ({
|
|
5664
|
+
_tag: "Transaction",
|
|
5665
|
+
items: appendItems,
|
|
5666
|
+
targets: () => targetsFor(appendItems, entityKeyFields(), entityType, (index) => index === 0 ? "the current item" : "the event item"),
|
|
5667
|
+
}),
|
|
5668
|
+
keys: entityKeys,
|
|
5669
|
+
rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, { operation: "append", entityType }),
|
|
5670
|
+
}), Effect.matchEffect({
|
|
5393
5671
|
onSuccess: () => Effect.succeed({ stale: false }),
|
|
5394
5672
|
onFailure: (err) => {
|
|
5673
|
+
if (err._tag === "ValidationError")
|
|
5674
|
+
return Effect.fail(err);
|
|
5395
5675
|
// TransactionCancelled or ConditionalCheckFailed at the transaction
|
|
5396
5676
|
// level both surface here. Disambiguation between CAS and
|
|
5397
5677
|
// user-condition happens after the follow-up GetItem (when run).
|
|
@@ -5488,13 +5768,15 @@ const makeImpl = (config, precomputedData) => {
|
|
|
5488
5768
|
// NOT carry `<prefix>#*` attributes. `deserializeSparseFields` populates
|
|
5489
5769
|
// empty `{}` Records under the domain field names so the historyRecord
|
|
5490
5770
|
// schema (which still includes the sparse Record fields) decodes.
|
|
5491
|
-
|
|
5492
|
-
|
|
5493
|
-
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
|
|
5771
|
+
return Effect.flatMap(unpackFields(raw, "history.decode"), () => {
|
|
5772
|
+
deserializeSparseFields(raw);
|
|
5773
|
+
renameFromDynamo(raw);
|
|
5774
|
+
return Schema.decodeUnknownEffect(schemas.historyRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
|
|
5775
|
+
entityType,
|
|
5776
|
+
operation: "history.decode",
|
|
5777
|
+
cause,
|
|
5778
|
+
})));
|
|
5779
|
+
});
|
|
5498
5780
|
};
|
|
5499
5781
|
return Query.make({
|
|
5500
5782
|
tableName: "",
|
|
@@ -5504,6 +5786,7 @@ const makeImpl = (config, precomputedData) => {
|
|
|
5504
5786
|
skField: primary.sk.field,
|
|
5505
5787
|
entityTypes: [entityType],
|
|
5506
5788
|
decoder: (raw) => decodeHistory(raw),
|
|
5789
|
+
...packedQueryOptions,
|
|
5507
5790
|
resolveTableName: tableTag.useSync((tc) => tc.name),
|
|
5508
5791
|
keyFields: [primary.pk.field, primary.sk.field],
|
|
5509
5792
|
}).pipe(Query.where({ beginsWith: prefix }));
|
|
@@ -5525,12 +5808,13 @@ const makeImpl = (config, precomputedData) => {
|
|
|
5525
5808
|
const query = Query.make({
|
|
5526
5809
|
tableName: "",
|
|
5527
5810
|
indexName: indexDef.index,
|
|
5528
|
-
globalIndex: indexDef.index !== undefined,
|
|
5529
5811
|
pkField: indexDef.pk.field,
|
|
5530
5812
|
pkValue,
|
|
5531
5813
|
skField: indexDef.sk.field,
|
|
5532
5814
|
entityTypes: [entityType],
|
|
5533
5815
|
decoder: (raw) => decodeRecord(raw),
|
|
5816
|
+
...packedQueryOptions,
|
|
5817
|
+
...packedQueryOptions,
|
|
5534
5818
|
resolveTableName: tableTag.useSync((tc) => tc.name),
|
|
5535
5819
|
keyFields: [
|
|
5536
5820
|
indexDef.pk.field,
|
|
@@ -5538,6 +5822,8 @@ const makeImpl = (config, precomputedData) => {
|
|
|
5538
5822
|
config.indexes.primary.pk.field,
|
|
5539
5823
|
config.indexes.primary.sk.field,
|
|
5540
5824
|
],
|
|
5825
|
+
// DynamoDB's rejection of the key condition (#144) names the accessor.
|
|
5826
|
+
keyConditionDiagnosis: { operation: "query", label: `${entityType}.query.${indexName}` },
|
|
5541
5827
|
});
|
|
5542
5828
|
if (hasSkComposites) {
|
|
5543
5829
|
// `composeSortKeyBeginsWith`, not `composeSortKeyPrefix` — the operand
|
|
@@ -5558,6 +5844,7 @@ const makeImpl = (config, precomputedData) => {
|
|
|
5558
5844
|
indexName: undefined,
|
|
5559
5845
|
entityTypes: [entityType],
|
|
5560
5846
|
decoder: (raw) => decodeRecord(raw),
|
|
5847
|
+
...packedQueryOptions,
|
|
5561
5848
|
resolveTableName: tableTag.useSync((tc) => tc.name),
|
|
5562
5849
|
keyFields: [config.indexes.primary.pk.field, config.indexes.primary.sk.field],
|
|
5563
5850
|
liveRows: liveRows(),
|
|
@@ -5661,6 +5948,7 @@ const makeImpl = (config, precomputedData) => {
|
|
|
5661
5948
|
skField: primary.sk.field,
|
|
5662
5949
|
entityTypes: [entityType],
|
|
5663
5950
|
decoder: (raw) => decodeRecord(raw),
|
|
5951
|
+
...packedQueryOptions,
|
|
5664
5952
|
resolveTableName: tableTag.useSync((tc) => tc.name),
|
|
5665
5953
|
keyFields: [primary.pk.field, primary.sk.field],
|
|
5666
5954
|
prepare: withLegacy,
|
|
@@ -5687,6 +5975,9 @@ const makeImpl = (config, precomputedData) => {
|
|
|
5687
5975
|
// (itemSchema would fail because it expects GSI key fields that aren't present)
|
|
5688
5976
|
if (mode === "native")
|
|
5689
5977
|
return tombstone;
|
|
5978
|
+
// Packed fields: read by their stored form (copied as-is onto the
|
|
5979
|
+
// tombstone, #147).
|
|
5980
|
+
yield* unpackFields(raw, "deleted.get.decode");
|
|
5690
5981
|
// Sparse Map fields: rebuild domain Records from flattened attrs so
|
|
5691
5982
|
// soft-deleted items decode correctly. Sparse data is preserved
|
|
5692
5983
|
// verbatim across soft-delete (GSI keys are stripped, sparse data is
|
|
@@ -5715,16 +6006,18 @@ const makeImpl = (config, precomputedData) => {
|
|
|
5715
6006
|
const corruption = versionCorruption(raw, "deleted.list.decode");
|
|
5716
6007
|
if (corruption !== undefined)
|
|
5717
6008
|
return Effect.fail(corruption);
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
6009
|
+
return Effect.flatMap(unpackFields(raw, "deleted.list.decode"), () => {
|
|
6010
|
+
// Sparse fields are domain data and are preserved across soft-delete.
|
|
6011
|
+
// Rebuild Records from flattened attrs before schema decode.
|
|
6012
|
+
deserializeSparseFields(raw);
|
|
6013
|
+
// Then attribute → domain names, same order as `decodeRecord` (#127).
|
|
6014
|
+
renameFromDynamo(raw);
|
|
6015
|
+
return Schema.decodeUnknownEffect(schemas.deletedRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
|
|
6016
|
+
entityType,
|
|
6017
|
+
operation: "deleted.list.decode",
|
|
6018
|
+
cause,
|
|
6019
|
+
})));
|
|
6020
|
+
});
|
|
5728
6021
|
};
|
|
5729
6022
|
return Query.make({
|
|
5730
6023
|
tableName: "",
|
|
@@ -5734,6 +6027,7 @@ const makeImpl = (config, precomputedData) => {
|
|
|
5734
6027
|
skField: primary.sk.field,
|
|
5735
6028
|
entityTypes: [],
|
|
5736
6029
|
decoder: (raw) => decodeDeleted(raw),
|
|
6030
|
+
...packedQueryOptions,
|
|
5737
6031
|
resolveTableName: tableTag.useSync((tc) => tc.name),
|
|
5738
6032
|
keyFields: [primary.pk.field, primary.sk.field],
|
|
5739
6033
|
}).pipe(Query.where({ beginsWith: deletedPrefix }));
|
|
@@ -5874,7 +6168,6 @@ const makeImpl = (config, precomputedData) => {
|
|
|
5874
6168
|
});
|
|
5875
6169
|
}
|
|
5876
6170
|
}
|
|
5877
|
-
yield* checkTransactionLimit(entityType, "restore", transactItems);
|
|
5878
6171
|
yield* client
|
|
5879
6172
|
.transactWriteItems({
|
|
5880
6173
|
TransactItems: transactItems,
|
|
@@ -5921,7 +6214,7 @@ const makeImpl = (config, precomputedData) => {
|
|
|
5921
6214
|
}
|
|
5922
6215
|
}
|
|
5923
6216
|
return err;
|
|
5924
|
-
}));
|
|
6217
|
+
}), diagnoseTransactionWrite("restore", transactItems));
|
|
5925
6218
|
return yield* decodeAs(restoredItem, marshalledRestoredItem, mode);
|
|
5926
6219
|
}), self, key);
|
|
5927
6220
|
// ---------------------------------------------------------------------------
|
|
@@ -6067,24 +6360,77 @@ const makeImpl = (config, precomputedData) => {
|
|
|
6067
6360
|
// Released one by one, each only while still this item's: a batch
|
|
6068
6361
|
// delete cannot carry the ownership condition.
|
|
6069
6362
|
for (const sentinel of yield* ownedSentinels(tableName, [...held.values()], owner)) {
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
|
|
6073
|
-
|
|
6363
|
+
const release = sentinelRelease(tableName, sentinel, owner);
|
|
6364
|
+
yield* client.deleteItem(release.Delete).pipe(Effect.catchIf((err) => isAwsConditionalCheckFailed(err.cause), () => Effect.void),
|
|
6365
|
+
// A delete of a stored key cannot meet a size limit; wrapped
|
|
6366
|
+
// for uniformity (#144), so it passes through in practice.
|
|
6367
|
+
diagnoseItemWrite("purge", release));
|
|
6368
|
+
}
|
|
6369
|
+
}
|
|
6370
|
+
// Batch delete in chunks of 25. `UnprocessedItems` are retried with
|
|
6371
|
+
// `Batch.write`'s backoff; what the purge had deleted is always
|
|
6372
|
+
// stated exactly, whether a request is rejected or retries run out.
|
|
6373
|
+
let deleted = 0;
|
|
6374
|
+
const deletedSoFar = () => deleted === 0
|
|
6375
|
+
? "the purge had deleted none of the partition's items"
|
|
6376
|
+
: `the purge had already deleted ${deleted} of the partition's ${allItems.length} items`;
|
|
6074
6377
|
for (let i = 0; i < allItems.length; i += 25) {
|
|
6075
6378
|
const chunk = allItems.slice(i, i + 25);
|
|
6076
|
-
|
|
6077
|
-
|
|
6078
|
-
|
|
6079
|
-
|
|
6080
|
-
|
|
6081
|
-
|
|
6082
|
-
[primary.sk.field]: item[primary.sk.field],
|
|
6083
|
-
},
|
|
6379
|
+
let requestItems = {
|
|
6380
|
+
[tableName]: chunk.map((item) => ({
|
|
6381
|
+
DeleteRequest: {
|
|
6382
|
+
Key: {
|
|
6383
|
+
[primary.pk.field]: item[primary.pk.field],
|
|
6384
|
+
[primary.sk.field]: item[primary.sk.field],
|
|
6084
6385
|
},
|
|
6085
|
-
}
|
|
6086
|
-
},
|
|
6087
|
-
}
|
|
6386
|
+
},
|
|
6387
|
+
})),
|
|
6388
|
+
};
|
|
6389
|
+
let retries = 0;
|
|
6390
|
+
for (;;) {
|
|
6391
|
+
const sentItems = requestItems;
|
|
6392
|
+
const sentCount = Object.values(sentItems).reduce((sum, requests) => sum + requests.length, 0);
|
|
6393
|
+
const response = yield* client.batchWriteItem({ RequestItems: sentItems }).pipe(
|
|
6394
|
+
// Deletes of stored keys: wrapped for uniformity (#144).
|
|
6395
|
+
diagnoseFailure({
|
|
6396
|
+
sent: () => {
|
|
6397
|
+
const items = batchEntries(sentItems);
|
|
6398
|
+
return {
|
|
6399
|
+
_tag: "Batch",
|
|
6400
|
+
items,
|
|
6401
|
+
targets: () => targetsFor(items, entityKeyFields(), entityType, (index) => `purged item ${deleted + index}`),
|
|
6402
|
+
};
|
|
6403
|
+
},
|
|
6404
|
+
keys: entityKeys,
|
|
6405
|
+
rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, {
|
|
6406
|
+
operation: "purge",
|
|
6407
|
+
entityType,
|
|
6408
|
+
committed: `${deletedSoFar()} before it`,
|
|
6409
|
+
}),
|
|
6410
|
+
}));
|
|
6411
|
+
const unprocessed = {};
|
|
6412
|
+
let remaining = 0;
|
|
6413
|
+
for (const [table, requests] of Object.entries(response.UnprocessedItems ?? {})) {
|
|
6414
|
+
if (requests.length === 0)
|
|
6415
|
+
continue;
|
|
6416
|
+
unprocessed[table] = requests;
|
|
6417
|
+
remaining += requests.length;
|
|
6418
|
+
}
|
|
6419
|
+
deleted += sentCount - remaining;
|
|
6420
|
+
if (remaining === 0)
|
|
6421
|
+
break;
|
|
6422
|
+
retries++;
|
|
6423
|
+
if (retries > MAX_RETRIES) {
|
|
6424
|
+
return yield* new DynamoError({
|
|
6425
|
+
operation: "BatchWriteItem",
|
|
6426
|
+
cause: new Error(`purge: ${remaining} of the partition's ${allItems.length} items were still ` +
|
|
6427
|
+
`unprocessed after ${MAX_RETRIES} retries; ${deletedSoFar()}. The rest ` +
|
|
6428
|
+
"were not deleted: purge again to finish."),
|
|
6429
|
+
});
|
|
6430
|
+
}
|
|
6431
|
+
yield* Effect.sleep(`${BASE_DELAY_MS * 2 ** (retries - 1)} millis`);
|
|
6432
|
+
requestItems = unprocessed;
|
|
6433
|
+
}
|
|
6088
6434
|
}
|
|
6089
6435
|
}), self, key);
|
|
6090
6436
|
// ---------------------------------------------------------------------------
|
|
@@ -6102,6 +6448,7 @@ const makeImpl = (config, precomputedData) => {
|
|
|
6102
6448
|
const expr = typeof cbOrShorthand === "function"
|
|
6103
6449
|
? cbOrShorthand(entityPathBuilder, entityConditionOps)
|
|
6104
6450
|
: parseSimpleShorthand(cbOrShorthand);
|
|
6451
|
+
refusePackedExpr(expr, "condition");
|
|
6105
6452
|
return (self) => conditionCombinator(self, expr);
|
|
6106
6453
|
};
|
|
6107
6454
|
/**
|
|
@@ -6112,17 +6459,25 @@ const makeImpl = (config, precomputedData) => {
|
|
|
6112
6459
|
const expr = typeof cbOrShorthand === "function"
|
|
6113
6460
|
? cbOrShorthand(entityPathBuilder, entityConditionOps)
|
|
6114
6461
|
: parseSimpleShorthand(cbOrShorthand);
|
|
6462
|
+
refusePackedExpr(expr, "filter");
|
|
6115
6463
|
return (q) => filterExpr(q, expr);
|
|
6116
6464
|
};
|
|
6117
6465
|
/**
|
|
6118
6466
|
* Build a select (projection) combinator from callback or string array.
|
|
6119
6467
|
*/
|
|
6120
6468
|
const entitySelect = (cbOrAttrs) => {
|
|
6469
|
+
const refuse = (paths) => {
|
|
6470
|
+
const refused = packedProjectionRefusal(paths, packedGuards);
|
|
6471
|
+
if (refused !== undefined)
|
|
6472
|
+
throw new Error(refused);
|
|
6473
|
+
};
|
|
6121
6474
|
if (typeof cbOrAttrs === "function") {
|
|
6122
6475
|
const paths = cbOrAttrs(entityPathBuilder);
|
|
6123
6476
|
const segments = paths.map((p) => p.segments);
|
|
6477
|
+
refuse(segments);
|
|
6124
6478
|
return (q) => selectPaths(q, segments);
|
|
6125
6479
|
}
|
|
6480
|
+
refuse(cbOrAttrs.map((attribute) => [attribute]));
|
|
6126
6481
|
return (q) => Query.select(q, cbOrAttrs);
|
|
6127
6482
|
};
|
|
6128
6483
|
// ---------------------------------------------------------------------------
|
|
@@ -6150,8 +6505,14 @@ const makeImpl = (config, precomputedData) => {
|
|
|
6150
6505
|
generatedId: config.generatedId,
|
|
6151
6506
|
vectorIndexes: config.vectorIndexes,
|
|
6152
6507
|
_vectorIndexes: vectorIndexes,
|
|
6508
|
+
_packedFields: Object.keys(packedFields),
|
|
6509
|
+
_packFields: packFields,
|
|
6510
|
+
_unpackFields: unpackFields,
|
|
6511
|
+
_dispatchAttributes: dispatchAttributes,
|
|
6512
|
+
_packedGuards: packedGuards,
|
|
6513
|
+
_decodeImage: decodeImageItem,
|
|
6153
6514
|
_resolvedRefs: resolvedRefs,
|
|
6154
|
-
/** @internal Full decode pipeline: rename + schema decode. Used by Batch/Aggregate. */
|
|
6515
|
+
/** @internal Full decode pipeline: unpack + sparse + rename + schema decode. Used by Batch/Aggregate. */
|
|
6155
6516
|
_decodeRecord: decodeRecord,
|
|
6156
6517
|
/** @internal Domain field name → stored attribute name (`storedAs` renames). */
|
|
6157
6518
|
_resolveDbName: resolveDbName,
|
|
@@ -6173,6 +6534,9 @@ const makeImpl = (config, precomputedData) => {
|
|
|
6173
6534
|
tableTag = injectedTableTag;
|
|
6174
6535
|
},
|
|
6175
6536
|
_injectIndex: (name, def) => {
|
|
6537
|
+
const refused = packedIndexRefusal(entityType, packedFields, name, def);
|
|
6538
|
+
if (refused !== undefined)
|
|
6539
|
+
throw new Error(refused);
|
|
6176
6540
|
allIndexes = { ...allIndexes, [name]: def };
|
|
6177
6541
|
},
|
|
6178
6542
|
get _schema() {
|
|
@@ -6360,8 +6724,7 @@ export const bind = (entity) => Effect.gen(function* () {
|
|
|
6360
6724
|
// UpdateItem would resurrect it as a key-plus-vector fragment that
|
|
6361
6725
|
// decodes to nothing but still answers searches. Guard on the item
|
|
6362
6726
|
// still existing and treat the rejection as "skipped".
|
|
6363
|
-
|
|
6364
|
-
.updateItem({
|
|
6727
|
+
const update = {
|
|
6365
6728
|
TableName: tableName,
|
|
6366
6729
|
Key: {
|
|
6367
6730
|
[primary.pk.field]: marshalled[primary.pk.field],
|
|
@@ -6371,8 +6734,21 @@ export const bind = (entity) => Effect.gen(function* () {
|
|
|
6371
6734
|
ConditionExpression: "attribute_exists(#pk)",
|
|
6372
6735
|
ExpressionAttributeNames: names,
|
|
6373
6736
|
ExpressionAttributeValues: values,
|
|
6374
|
-
}
|
|
6375
|
-
|
|
6737
|
+
};
|
|
6738
|
+
return yield* client.updateItem(update).pipe(Effect.as(true), Effect.catchIf((err) => isAwsConditionalCheckFailed(err.cause), () => Effect.succeed(false)),
|
|
6739
|
+
// An embedding that grows the item past 400 KB, named (#144).
|
|
6740
|
+
diagnoseFailure({
|
|
6741
|
+
sent: () => ({
|
|
6742
|
+
_tag: "Item",
|
|
6743
|
+
item: { Update: update },
|
|
6744
|
+
target: () => targetsFor([{ Update: update }], [primary.pk.field, primary.sk.field], entity.entityType, () => "the re-embedded item")[0],
|
|
6745
|
+
}),
|
|
6746
|
+
keys: () => keyAttributesOf(entity.indexes),
|
|
6747
|
+
rewrite: (diagnosis, original) => diagnosedValidationError(diagnosis, original, {
|
|
6748
|
+
operation: "reembed",
|
|
6749
|
+
entityType: entity.entityType,
|
|
6750
|
+
}),
|
|
6751
|
+
}));
|
|
6376
6752
|
});
|
|
6377
6753
|
let updated = 0;
|
|
6378
6754
|
let exclusiveStartKey;
|
|
@@ -6575,12 +6951,27 @@ export const itemSchema = (entity) => entity.schemas.itemSchema;
|
|
|
6575
6951
|
/**
|
|
6576
6952
|
* Decode a marshalled DynamoDB item (AttributeValue format) into a typed Entity.Record.
|
|
6577
6953
|
*
|
|
6578
|
-
*
|
|
6579
|
-
*
|
|
6954
|
+
* Built for DynamoDB Streams records (`NewImage` / `OldImage`) and raw SDK
|
|
6955
|
+
* responses. Applies exactly what a table read applies:
|
|
6956
|
+
*
|
|
6957
|
+
* - **Packed fields** (`storedAs: DynamoModel.Packed`) are read by their
|
|
6958
|
+
* stored form — a legacy native map, a JSON string (`S`), or compressed
|
|
6959
|
+
* binary (`B`). Lambda and EventBridge Pipes deliver `B` / `BS` members as
|
|
6960
|
+
* base64 text; they are decoded at any depth.
|
|
6961
|
+
* - Sparse-map fields are rebuilt and `field:` renames reversed.
|
|
6962
|
+
*
|
|
6963
|
+
* Accepts DynamoDB JSON (`{ pk: { S: "…" }, … }`, `B` as bytes or base64) or
|
|
6964
|
+
* an already-unmarshalled item. Fails with `ValidationError` if the item does
|
|
6965
|
+
* not match the schema, or when a compressed value's codec is not registered —
|
|
6966
|
+
* its `cause` is then a `CompressorNotRegistered` (register the codec with a
|
|
6967
|
+
* `Compression.layer([...])` provided where the decode runs).
|
|
6580
6968
|
*
|
|
6581
|
-
*
|
|
6969
|
+
* Note: {@link itemSchema} is a pure schema over unmarshalled items and does
|
|
6970
|
+
* not unpack — use this for entities with packed fields.
|
|
6582
6971
|
*/
|
|
6583
6972
|
export const decodeMarshalledItem = (entity, marshalledItem) => {
|
|
6973
|
+
if (entity._decodeImage !== undefined)
|
|
6974
|
+
return entity._decodeImage(marshalledItem);
|
|
6584
6975
|
const corruption = entity._versionCorruption?.(marshalledItem, "decodeMarshalledItem");
|
|
6585
6976
|
if (corruption !== undefined)
|
|
6586
6977
|
return Effect.fail(corruption);
|