Skip to main content

dynoxide/actions/
update_item.rs

1use crate::actions::helpers;
2use crate::errors::{DynoxideError, Result};
3use crate::storage_backend::StorageBackend;
4use crate::types::{self, AttributeValue};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Internal result from the transactional update work closure.
9struct UpdateWorkResult {
10    old_item: HashMap<String, AttributeValue>,
11    item: HashMap<String, AttributeValue>,
12    item_json: String,
13    size: usize,
14    /// Size of the item as it genuinely stood beforehand, or `None` when the
15    /// update was an upsert that created it. Distinct from `old_item`, which
16    /// carries injected key attributes in that case.
17    old_size: Option<usize>,
18}
19
20/// Internal deserialization struct for detecting missing fields.
21#[derive(Debug, Default, Deserialize)]
22struct UpdateItemRequestRaw {
23    #[serde(rename = "TableName", default)]
24    table_name: Option<String>,
25    #[serde(rename = "Key", default)]
26    key: Option<HashMap<String, AttributeValue>>,
27    #[serde(rename = "UpdateExpression", default)]
28    update_expression: Option<String>,
29    #[serde(rename = "ConditionExpression", default)]
30    condition_expression: Option<String>,
31    #[serde(rename = "ExpressionAttributeNames", default)]
32    expression_attribute_names: Option<HashMap<String, String>>,
33    #[serde(
34        rename = "ExpressionAttributeValues",
35        default,
36        deserialize_with = "crate::types::deserialize_named_null_refusals"
37    )]
38    expression_attribute_values: Option<HashMap<String, AttributeValue>>,
39    #[serde(rename = "ReturnValues", default)]
40    return_values: Option<String>,
41    #[serde(rename = "ReturnConsumedCapacity", default)]
42    return_consumed_capacity: Option<String>,
43    #[serde(rename = "ReturnValuesOnConditionCheckFailure", default)]
44    return_values_on_condition_check_failure: Option<String>,
45    #[serde(rename = "ReturnItemCollectionMetrics", default)]
46    return_item_collection_metrics: Option<String>,
47    #[serde(rename = "AttributeUpdates", default)]
48    attribute_updates: Option<HashMap<String, AttributeValueUpdate>>,
49    #[serde(rename = "Expected", default)]
50    expected: Option<serde_json::Value>,
51    #[serde(rename = "ConditionalOperator", default)]
52    conditional_operator: Option<String>,
53}
54
55#[derive(Debug, Default)]
56pub struct UpdateItemRequest {
57    pub table_name: String,
58    pub key: HashMap<String, AttributeValue>,
59    pub update_expression: Option<String>,
60    pub condition_expression: Option<String>,
61    pub expression_attribute_names: Option<HashMap<String, String>>,
62    pub expression_attribute_values: Option<HashMap<String, AttributeValue>>,
63    pub return_values: Option<String>,
64    pub return_consumed_capacity: Option<String>,
65    pub return_values_on_condition_check_failure: Option<String>,
66    pub return_item_collection_metrics: Option<String>,
67    pub attribute_updates: Option<HashMap<String, AttributeValueUpdate>>,
68    pub expected: Option<serde_json::Value>,
69    pub conditional_operator: Option<String>,
70}
71
72/// UpdateItem's first invalid enum member, or `None` if all are valid, and
73/// whether DynamoDB wraps it in the `1 validation error detected: ` envelope.
74/// UpdateItem reports one, where PutItem reports them all. In the newer
75/// validation layer (five of seven regions, 2026-10-09) the order is
76/// ReturnValues, ReturnValuesOnConditionCheckFailure,
77/// ReturnItemCollectionMetrics, ReturnConsumedCapacity, and the second and
78/// fourth come back without the envelope.
79fn first_invalid_member_enum(
80    return_values: Option<&str>,
81    return_values_on_condition_check_failure: Option<&str>,
82    return_item_collection_metrics: Option<&str>,
83    return_consumed_capacity: Option<&str>,
84) -> Option<(String, bool)> {
85    if let Some(rv) = return_values
86        && !crate::validation::RETURN_VALUES.contains(&rv)
87    {
88        return Some((crate::validation::RETURN_VALUES_REJECTION.to_string(), true));
89    }
90    if let Some(msg) = crate::validation::return_values_on_condition_check_failure_rejection(
91        return_values_on_condition_check_failure,
92        "returnValuesOnConditionCheckFailure",
93    ) {
94        return Some((msg, false));
95    }
96    if let Some(msg) =
97        crate::validation::return_item_collection_metrics_rejection(return_item_collection_metrics)
98    {
99        return Some((msg, true));
100    }
101    if let Some(msg) =
102        crate::validation::return_consumed_capacity_rejection(return_consumed_capacity)
103    {
104        return Some((msg, false));
105    }
106    None
107}
108
109impl<'de> serde::Deserialize<'de> for UpdateItemRequest {
110    fn deserialize<D: serde::Deserializer<'de>>(
111        deserializer: D,
112    ) -> std::result::Result<Self, D::Error> {
113        let raw = UpdateItemRequestRaw::deserialize(deserializer)?;
114        use crate::validation::format_validation_errors;
115
116        // A BOOL or NULL in a legacy condition, or its `Exists`, that doesn't
117        // read as a boolean makes the whole request malformed, ahead of
118        // everything else.
119        if let Some(message) = crate::types::malformed_condition_boolean(raw.expected.as_ref()) {
120            return Err(serde::de::Error::custom(message));
121        }
122
123        // Without its key, UpdateItem falls back to the older layer in all seven
124        // regions captured on 2026-10-09: every enum error in the older
125        // per-value wording, then the key, and no table name error beside
126        // them unless the name is missing altogether. The enums' order is the
127        // older layer's (ReturnConsumedCapacity, ReturnValues,
128        // ReturnValuesOnConditionCheckFailure, ReturnItemCollectionMetrics),
129        // with a missing name's error right after ReturnConsumedCapacity's
130        // (captured eu-west-2 and us-east-1, 2026-10-09). An empty name is
131        // still checked first.
132        if raw.key.is_none() && raw.table_name.as_deref() != Some("") {
133            let mut errors: Vec<String> = Vec::new();
134            if let Some(msg) = crate::validation::return_consumed_capacity_rejection(
135                raw.return_consumed_capacity.as_deref(),
136            ) {
137                errors.push(msg);
138            }
139            if raw.table_name.is_none() {
140                errors.extend(crate::validation::update_item_table_name_errors(None));
141            }
142            if let Some(rv) = raw.return_values.as_deref()
143                && !crate::validation::RETURN_VALUES.contains(&rv)
144            {
145                errors.push(crate::validation::return_values_value_rejection(rv));
146            }
147            if let Some(msg) = crate::validation::return_values_on_condition_check_failure_rejection(
148                raw.return_values_on_condition_check_failure.as_deref(),
149                "returnValuesOnConditionCheckFailure",
150            ) {
151                errors.push(msg);
152            }
153            if let Some(msg) = crate::validation::return_item_collection_metrics_rejection(
154                raw.return_item_collection_metrics.as_deref(),
155            ) {
156                errors.push(msg);
157            }
158            errors.push(
159                "Value null at 'key' failed to satisfy constraint: Member must not be null"
160                    .to_string(),
161            );
162            if let Some(msg) = format_validation_errors(&errors) {
163                return Err(serde::de::Error::custom(format!("VALIDATION:{}", msg)));
164            }
165        }
166
167        // A missing table name is joined with an invalid ReturnValues, the
168        // name's error first (captured eu-west-2 and us-east-1, 2026-10-09).
169        // The other enums beside a missing name are uncaptured.
170        if raw.table_name.is_none()
171            && let Some(rv) = raw.return_values.as_deref()
172            && !crate::validation::RETURN_VALUES.contains(&rv)
173        {
174            let mut errors = crate::validation::update_item_table_name_errors(None);
175            errors.push(crate::validation::return_values_value_rejection(rv));
176            if let Some(msg) = format_validation_errors(&errors) {
177                return Err(serde::de::Error::custom(format!("VALIDATION:{}", msg)));
178            }
179        }
180
181        // UpdateItem reports a table name error on its own, before the key and
182        // the enums (captured 2026-10-09, five of seven regions).
183        let table_name_errors =
184            crate::validation::update_item_table_name_errors(raw.table_name.as_deref());
185        if let Some(msg) = format_validation_errors(&table_name_errors) {
186            return Err(serde::de::Error::custom(format!("VALIDATION:{}", msg)));
187        }
188        let table_name = raw.table_name.unwrap_or_default();
189
190        let mut errors = Vec::new();
191
192        // Key constraint
193        if raw.key.is_none() {
194            errors.push(
195                "Value null at 'key' failed to satisfy constraint: \
196                 Member must not be null"
197                    .to_string(),
198            );
199        }
200
201        // UpdateItem stops at the first invalid enum and reports one error,
202        // where PutItem aggregates all of them. One reported without the
203        // envelope stands alone; beside a missing key it is left for later,
204        // since that pairing is uncaptured.
205        match first_invalid_member_enum(
206            raw.return_values.as_deref(),
207            raw.return_values_on_condition_check_failure.as_deref(),
208            raw.return_item_collection_metrics.as_deref(),
209            raw.return_consumed_capacity.as_deref(),
210        ) {
211            Some((msg, true)) => errors.push(msg),
212            Some((msg, false)) if errors.is_empty() => {
213                return Err(serde::de::Error::custom(format!("VALIDATION:{msg}")));
214            }
215            _ => {}
216        }
217
218        if let Some(msg) = format_validation_errors(&errors) {
219            return Err(serde::de::Error::custom(format!("VALIDATION:{}", msg)));
220        }
221
222        Ok(UpdateItemRequest {
223            table_name,
224            key: raw.key.unwrap_or_default(),
225            update_expression: raw.update_expression,
226            condition_expression: raw.condition_expression,
227            expression_attribute_names: raw.expression_attribute_names,
228            expression_attribute_values: raw.expression_attribute_values,
229            return_values: raw.return_values,
230            return_consumed_capacity: raw.return_consumed_capacity,
231            return_values_on_condition_check_failure: raw.return_values_on_condition_check_failure,
232            return_item_collection_metrics: raw.return_item_collection_metrics,
233            attribute_updates: raw.attribute_updates,
234            expected: raw.expected,
235            conditional_operator: raw.conditional_operator,
236        })
237    }
238}
239
240/// Legacy `AttributeUpdates` entry: one per attribute being modified.
241#[derive(Debug, Clone, Default, Deserialize)]
242pub struct AttributeValueUpdate {
243    #[serde(rename = "Action", default = "default_put_action")]
244    pub action: String,
245    #[serde(rename = "Value", default)]
246    pub value: Option<AttributeValue>,
247}
248
249/// DynamoDB's refusal of an `AttributeUpdates` action other than `ADD`,
250/// `DELETE` or `PUT`, written exactly. Tagged for the envelope, which adds
251/// `1 validation error detected: `.
252const ATTRIBUTE_UPDATE_ACTION_REJECTION: &str =
253    "Member must satisfy enum value set: [ADD, DELETE, PUT]' for Action";
254
255fn default_put_action() -> String {
256    "PUT".to_string()
257}
258
259#[derive(Debug, Default, Serialize)]
260pub struct UpdateItemResponse {
261    #[serde(rename = "Attributes", skip_serializing_if = "Option::is_none")]
262    // Number sets in numeric order here, as DynamoDB returns them;
263    // pinned by set_order's every_response_item_field test.
264    pub attributes: Option<HashMap<String, AttributeValue>>,
265    #[serde(rename = "ConsumedCapacity", skip_serializing_if = "Option::is_none")]
266    pub consumed_capacity: Option<types::ConsumedCapacity>,
267    #[serde(
268        rename = "ItemCollectionMetrics",
269        skip_serializing_if = "Option::is_none"
270    )]
271    pub item_collection_metrics: Option<crate::types::ItemCollectionMetrics>,
272}
273
274/// Apply the `Invalid UpdateExpression:` prefix to a sub-error message at the
275/// UpdateItem dispatch boundary. AWS DynamoDB tags the missing-EAV error
276/// (and similar UpdateExpression-scoped errors) with this prefix; the prefix
277/// must not leak into ConditionExpression contexts that share the same
278/// underlying validators in `crate::expressions::mod`. Idempotent so that
279/// errors which already carry the prefix (e.g. parser-level syntax errors)
280/// are not double-wrapped.
281fn wrap_invalid_update_expression(err: String) -> String {
282    if err.starts_with("Invalid UpdateExpression:") {
283        err
284    } else {
285        format!("Invalid UpdateExpression: {err}")
286    }
287}
288
289pub async fn execute<S: StorageBackend>(
290    storage: &S,
291    request: UpdateItemRequest,
292) -> Result<UpdateItemResponse> {
293    // Apply the request-validation envelope exactly once at the operation
294    // boundary, so every early return inside is covered and the tagged
295    // variant never escapes. A request read on the older layer never gets it.
296    let layer = crate::validation::single_write_layer(request.expected.as_ref());
297    execute_inner(storage, request, layer)
298        .await
299        .map_err(|e| crate::validation::resolve_for_layer(layer, e))
300}
301
302async fn execute_inner<S: StorageBackend>(
303    storage: &S,
304    mut request: UpdateItemRequest,
305    layer: crate::validation::ValueLayer,
306) -> Result<UpdateItemResponse> {
307    // Validate table name format before checking existence (DynamoDB validates input first)
308    if let Some(msg) = crate::validation::format_validation_errors(
309        &crate::validation::update_item_table_name_errors(Some(&request.table_name)),
310    ) {
311        return Err(DynoxideError::ValidationException(msg));
312    }
313
314    // An empty attribute name is refused as the request is read, ahead of every
315    // other value check, the expressions and the table lookup (captured in ten
316    // regions).
317    let updates_empty = request.attribute_updates.as_ref().is_some_and(|updates| {
318        updates.iter().any(|(name, update)| {
319            name.is_empty()
320                || update
321                    .value
322                    .as_ref()
323                    .is_some_and(crate::empty_names::value_has_empty_name)
324        })
325    });
326    crate::empty_names::check_single_item_write(
327        &request.key,
328        updates_empty,
329        request.expected.as_ref(),
330        request.expression_attribute_names.as_ref(),
331        request.expression_attribute_values.as_ref(),
332        None,
333    )?;
334
335    // Reject {NULL: false} up front. The HTTP path rejects it during request
336    // deserialisation; this keeps the in-process API in agreement (eu-west-2).
337    helpers::validate_no_null_false(&request.key)?;
338    if let Some(ref values) = request.expression_attribute_values {
339        helpers::validate_no_null_false(values)?;
340    }
341    if let Some(ref updates) = request.attribute_updates {
342        for update in updates.values() {
343            if let Some(ref value) = update.value {
344                helpers::validate_no_null_false_value(value)?;
345            }
346        }
347    }
348
349    // Validate expression/non-expression parameter conflicts BEFORE Expected conversion
350    {
351        let mut non_expr = Vec::new();
352        let mut expr_params = Vec::new();
353        if request.attribute_updates.is_some() {
354            non_expr.push("AttributeUpdates");
355        }
356        if request.expected.is_some() {
357            non_expr.push("Expected");
358        }
359        if request.conditional_operator.is_some() {
360            non_expr.push("ConditionalOperator");
361        }
362        if request.update_expression.is_some() {
363            expr_params.push("UpdateExpression");
364        }
365        if request.condition_expression.is_some() {
366            expr_params.push("ConditionExpression");
367        }
368        let no_raw_eav: Option<serde_json::Value> = None;
369        let ctx = helpers::ExpressionParamContext {
370            non_expression_params: non_expr,
371            expression_params: expr_params,
372            // DynamoDB names no expression after its "can only be specified
373            // when using expressions" message on the single-item writes
374            // (eu-west-2 and us-east-1, 2026-10-09).
375            all_expression_param_names: vec![],
376            expression_attribute_names: &request.expression_attribute_names,
377            expression_attribute_values: &request.expression_attribute_values,
378            expression_attribute_values_raw: &no_raw_eav,
379            layer,
380        };
381        // Enveloped families are tagged for the request-validation envelope.
382        helpers::validate_expression_params(&ctx)
383            .map_err(crate::validation::ClassifiedValidationError::into_tagged)?;
384    }
385
386    // ConditionalOperator only joins legacy Expected conditions, so it is
387    // refused without two of them, whatever its value (captured 2026-10-09).
388    if let Some(msg) = crate::validation::conditional_operator_misuse(
389        request.conditional_operator.as_deref(),
390        crate::validation::legacy_condition_count(request.expected.as_ref()),
391    ) {
392        return Err(DynoxideError::EnvelopedValidation(msg.to_string()));
393    }
394    // A key, AttributeUpdates value or Expected value nested too deep is
395    // refused, inside the envelope, before the key is matched to the schema or
396    // the table looked up.
397    crate::value_nesting::check_key_enveloped(&request.key)?;
398    crate::value_nesting::check_attribute_updates(
399        request
400            .attribute_updates
401            .iter()
402            .flat_map(|updates| updates.values())
403            .map(|update| update.value.as_ref()),
404    )?;
405    crate::value_nesting::check_expected(request.expected.as_ref())?;
406
407    // Validate key attribute values (unsupported datatypes, invalid numbers).
408    // A duplicated set member or a number it can't store comes back in the
409    // envelope (eu-west-2, us-east-1, eu-west-1 and us-west-2, 2026-10-09).
410    crate::validation::validate_key_values(&request.key, layer)
411        .map_err(crate::validation::ClassifiedValidationError::into_tagged)?;
412
413    // A legacy AttributeUpdates value with a repeated set member is refused in
414    // the envelope, in the current layer's wording (same capture).
415    if let Some(ref updates) = request.attribute_updates {
416        let mut names: Vec<&String> = updates.keys().collect();
417        names.sort();
418        for name in names {
419            if let Some(value) = updates[name].value.as_ref()
420                && let Some(message) = crate::validation::find_duplicate_set(value, layer)
421            {
422                return Err(DynoxideError::EnvelopedValidation(message));
423            }
424        }
425    }
426
427    // Validate legacy AttributeUpdates parameters
428    if request.update_expression.is_none()
429        && let Some(ref updates) = request.attribute_updates
430    {
431        // The action is matched exactly: `put` or `Delete` is refused before
432        // the table is looked up (eu-west-2 and us-east-1, 2026-10-10). The
433        // stray quote is DynamoDB's own. Every action is checked before any
434        // other update's own checks, so the answer doesn't depend on the
435        // order the map happens to iterate in.
436        if updates
437            .values()
438            .any(|update| !["ADD", "DELETE", "PUT"].contains(&update.action.as_str()))
439        {
440            return Err(DynoxideError::EnvelopedValidation(
441                ATTRIBUTE_UPDATE_ACTION_REJECTION.to_string(),
442            ));
443        }
444        for update in updates.values() {
445            let action = update.action.as_str();
446            if update.value.is_none() && action != "DELETE" {
447                return Err(DynoxideError::ValidationException(
448                    "One or more parameter values were invalid: \
449                         Only DELETE action is allowed when no attribute value is specified"
450                        .to_string(),
451                ));
452            }
453            if action == "DELETE"
454                && let Some(ref val) = update.value
455            {
456                let type_name = match val {
457                    AttributeValue::SS(_) | AttributeValue::NS(_) | AttributeValue::BS(_) => None,
458                    _ => Some(val.type_name()),
459                };
460                if let Some(tn) = type_name {
461                    return Err(DynoxideError::ValidationException(format!(
462                        "One or more parameter values were invalid: \
463                                 DELETE action with value is not supported for the type {tn}"
464                    )));
465                }
466            }
467            if action == "ADD"
468                && let Some(ref val) = update.value
469            {
470                let allowed = matches!(
471                    val,
472                    AttributeValue::N(_)
473                        | AttributeValue::SS(_)
474                        | AttributeValue::NS(_)
475                        | AttributeValue::BS(_)
476                        | AttributeValue::L(_)
477                );
478                if !allowed {
479                    let tn = val.type_name();
480                    return Err(DynoxideError::ValidationException(format!(
481                        "One or more parameter values were invalid: \
482                                 ADD action is not supported for the type {tn}"
483                    )));
484                }
485            }
486        }
487    }
488
489    // Read once and used twice: the validation immediately below and the
490    // conversion further down, either side of the expression checks between,
491    // none of which touch `expected` or `condition_expression`.
492    //
493    // The two uses do not share a condition. Validation additionally requires
494    // no UpdateExpression, conversion does not, so that guard stays on the
495    // validation rather than moving up here.
496    let expected: Option<HashMap<String, helpers::ExpectedCondition>> =
497        match request.expected.as_ref() {
498            Some(v) if request.condition_expression.is_none() => helpers::read_expected(v)?,
499            _ => None,
500        };
501
502    // Validate legacy Expected parameter. Every family comes back in the
503    // envelope (eu-west-2 and us-east-1, 2026-10-09).
504    if request.update_expression.is_none()
505        && let Some(ref expected) = expected
506    {
507        helpers::validate_expected_conditions(expected)
508            .map_err(crate::validation::tag_for_envelope)?;
509    }
510
511    // Validate empty ConditionExpression, inside the envelope (eu-west-2 and
512    // us-east-1, 2026-10-09). It is reported ahead of an empty
513    // UpdateExpression (eu-west-2 and us-east-1, 2026-10-09).
514    if let Some(ref ce) = request.condition_expression
515        && ce.is_empty()
516    {
517        return Err(DynoxideError::EnvelopedValidation(
518            "Invalid ConditionExpression: The expression can not be empty;".to_string(),
519        ));
520    }
521
522    // Validate empty UpdateExpression, inside the envelope too (same capture).
523    if let Some(ref ue) = request.update_expression
524        && ue.is_empty()
525    {
526        return Err(DynoxideError::EnvelopedValidation(
527            "Invalid UpdateExpression: The expression can not be empty;".to_string(),
528        ));
529    }
530
531    // Pre-validate UpdateExpression syntax BEFORE table lookup.
532    // DynamoDB validates expression syntax, reserved keywords, undefined attribute
533    // names/values, overlapping paths, etc. before checking table existence.
534    // Parse-time errors here are families DynamoDB wraps in the
535    // request-validation envelope, so they are tagged; the unused-attribute
536    // check propagates bare, matching PutItem.
537    if let Some(ref ue) = request.update_expression {
538        let parsed =
539            crate::expressions::update::parse(ue).map_err(DynoxideError::EnvelopedValidation)?;
540
541        // Track all attribute name/value references statically (without evaluating)
542        let tracker = crate::expressions::TrackedExpressionAttributes::new(
543            &request.expression_attribute_names,
544            &request.expression_attribute_values,
545        );
546        crate::expressions::update::track_references(&parsed, &tracker)
547            .map_err(|e| DynoxideError::EnvelopedValidation(wrap_invalid_update_expression(e)))?;
548
549        // Also walk the ConditionExpression to track its attribute usage. One
550        // that doesn't parse is reported here, ahead of any unused value
551        // (us-east-1 and eu-west-2, 2026-10-09).
552        if let Some(ref ce) = request.condition_expression {
553            let cond_parsed = crate::expressions::condition::parse(ce).map_err(|e| {
554                DynoxideError::EnvelopedValidation(format!("Invalid ConditionExpression: {e}"))
555            })?;
556            crate::expressions::condition::track_references(&cond_parsed, &tracker).map_err(
557                |e| DynoxideError::EnvelopedValidation(format!("Invalid ConditionExpression: {e}")),
558            )?;
559        }
560
561        // Check for unused expression attribute names/values. Enveloped, and
562        // ahead of the table lookup (us-east-1 and eu-west-2, 2026-10-09).
563        tracker
564            .check_unused()
565            .map_err(crate::validation::tag_for_envelope)?;
566    }
567
568    // Statically validate ConditionExpression (syntax + BETWEEN bounds, etc.)
569    // before table lookup. Parse-time, so tagged like the block above.
570    if let Some(ref ce) = request.condition_expression {
571        let parsed = crate::expressions::condition::parse(ce).map_err(|e| {
572            DynoxideError::EnvelopedValidation(format!("Invalid ConditionExpression: {e}"))
573        })?;
574        crate::expressions::condition::validate_static(
575            &parsed,
576            &request.expression_attribute_values,
577        )
578        .map_err(DynoxideError::EnvelopedValidation)?;
579        crate::expressions::condition::validate_operand_semantics(
580            &parsed,
581            &request.expression_attribute_names,
582            &request.expression_attribute_values,
583        )
584        .map_err(|e| {
585            DynoxideError::EnvelopedValidation(format!("Invalid ConditionExpression: {e}"))
586        })?;
587    }
588
589    // Convert legacy Expected parameter to ConditionExpression if no expression is set
590    if let Some(ref expected) = expected
591        && !expected.is_empty()
592    {
593        let converted = helpers::convert_expected_to_condition(
594            expected,
595            request.conditional_operator.as_deref(),
596        )?;
597        if !converted.expression.is_empty() {
598            request.condition_expression = Some(converted.expression);
599            let expr_values = request
600                .expression_attribute_values
601                .get_or_insert_with(HashMap::new);
602            expr_values.extend(converted.attribute_values);
603            let expr_names = request
604                .expression_attribute_names
605                .get_or_insert_with(HashMap::new);
606            expr_names.extend(converted.attribute_names);
607        }
608    }
609
610    let meta = helpers::require_table_for_item_op(storage, &request.table_name).await?;
611    let key_schema = helpers::parse_key_schema(&meta)?;
612
613    // Validate ReturnValues parameter. The constraint text is tagged without
614    // the envelope prefix; the operation boundary applies it, so the prefix
615    // has a single producer. It is the same text first_invalid_return_enum
616    // gives, so the in-process message matches the request deserialiser.
617    if let Some(ref rv) = request.return_values {
618        let rv_upper = rv.to_uppercase();
619        if !["NONE", "ALL_OLD", "ALL_NEW", "UPDATED_OLD", "UPDATED_NEW"]
620            .contains(&rv_upper.as_str())
621        {
622            return Err(DynoxideError::EnvelopedValidation(
623                crate::validation::RETURN_VALUES_REJECTION.to_string(),
624            ));
625        }
626    }
627
628    // Validate key
629    helpers::validate_key_only(&request.key, &key_schema)?;
630
631    // Extract key values
632    // TODO: validation must precede this call -- if reaching this line, caller has already validated keys.
633    let (pk, sk) = helpers::extract_key_strings(&request.key, &key_schema)?;
634
635    // Collect the set of attribute names affected by the legacy AttributeUpdates
636    // parameter, used later for UPDATED_OLD / UPDATED_NEW extraction.
637    let legacy_attr_names: Option<Vec<String>> = request
638        .attribute_updates
639        .as_ref()
640        .map(|updates| updates.keys().cloned().collect());
641
642    // Execution tracker: tracking disabled because unused-reference validation was
643    // already done statically by Tracker 1 (pre-validation block above). This tracker
644    // only needs name/value resolution, not usage tracking.
645    let tracker = crate::expressions::TrackedExpressionAttributes::without_tracking(
646        &request.expression_attribute_names,
647        &request.expression_attribute_values,
648    );
649
650    // Hold the request to the 400KB limit before the item is read. DynamoDB
651    // charges a standalone update for what its request carries, worked out from
652    // the statement alone, so the charge needs nothing from the stored item and
653    // is refused ahead of the condition, a SET on a key attribute and anything
654    // else that only shows once the update is applied. Request validation above
655    // still answers first. Captured against eu-west-2 on 9 October 2026; see
656    // `UpdateExpr::request_charge` for the costs. The legacy AttributeUpdates
657    // parameter carries no charge of its own.
658    if let Some(ref update_expr) = request.update_expression {
659        let parsed = crate::expressions::update::parse(update_expr)
660            .map_err(DynoxideError::ValidationException)?;
661        let charge = parsed
662            .request_charge(&tracker)
663            .map_err(DynoxideError::ValidationException)?;
664        if charge > types::MAX_ITEM_SIZE {
665            return Err(DynoxideError::ValidationException(
666                "Item size to update has exceeded the maximum allowed size".to_string(),
667            ));
668        }
669    }
670
671    // Wrap the condition check, base write and the GSI/LSI fan-out in a single
672    // transaction so a mid-fan-out failure rolls the whole update back, leaving
673    // no torn index. Unconditional because the atomicity guarantee applies to
674    // every single-item write. The block captures everything from get_item
675    // through the GSI/LSI fan-out and stream record.
676    let (
677        UpdateWorkResult {
678            old_item,
679            item,
680            item_json,
681            size,
682            old_size,
683        },
684        gsi_units,
685        lsi_units,
686        vector_bytes,
687    ) = helpers::with_write_transaction(storage, async {
688        // Fetch existing item (or create empty one for upsert)
689        let existing_json = storage.get_item(&request.table_name, &pk, &sk).await?;
690        let existing_item: HashMap<String, AttributeValue> = existing_json
691            .as_ref()
692            .and_then(|j| serde_json::from_str(j).ok())
693            .unwrap_or_default();
694
695        // Evaluate ConditionExpression against the original existing item BEFORE
696        // populating key attributes for upsert. Otherwise attribute_exists(PK)
697        // would always pass because the key was pre-populated.
698        if let Some(ref cond_expr) = request.condition_expression {
699            let parsed = crate::expressions::condition::parse(cond_expr)
700                .map_err(DynoxideError::ValidationException)?;
701            let result = crate::expressions::condition::evaluate(&parsed, &existing_item, &tracker)
702                .map_err(DynoxideError::ValidationException)?;
703            if !result {
704                let return_item = if request.return_values_on_condition_check_failure.as_deref()
705                    == Some("ALL_OLD")
706                    && existing_json.is_some()
707                {
708                    Some(existing_item.clone())
709                } else {
710                    None
711                };
712                return Err(DynoxideError::ConditionalCheckFailedException(
713                    "The conditional request failed".to_string(),
714                    return_item,
715                ));
716            }
717        }
718
719        // Build mutable item for the update expression.
720        // If item doesn't exist, populate key attributes for upsert.
721        let mut item = existing_item;
722        if existing_json.is_none() {
723            for (k, v) in &request.key {
724                item.insert(k.clone(), v.clone());
725            }
726        }
727
728        // Save old item for ReturnValues
729        let old_item = item.clone();
730
731        // Apply UpdateExpression
732        if let Some(ref update_expr) = request.update_expression {
733            let parsed = crate::expressions::update::parse(update_expr)
734                .map_err(DynoxideError::ValidationException)?;
735
736            // Validate: cannot modify key attributes with SET
737            // (key validation uses the free function, not tracked)
738            for action in &parsed.set_actions {
739                validate_not_key_attr(
740                    action.path.first(),
741                    &key_schema,
742                    &request.expression_attribute_names,
743                )?;
744            }
745
746            // Validate: cannot REMOVE key attributes
747            for path in &parsed.remove_actions {
748                validate_not_key_attr(
749                    path.first(),
750                    &key_schema,
751                    &request.expression_attribute_names,
752                )?;
753            }
754
755            // Validate: cannot ADD to key attributes
756            for action in &parsed.add_actions {
757                validate_not_key_attr(
758                    action.path.first(),
759                    &key_schema,
760                    &request.expression_attribute_names,
761                )?;
762            }
763
764            // Validate: cannot DELETE from key attributes
765            for action in &parsed.delete_actions {
766                validate_not_key_attr(
767                    action.path.first(),
768                    &key_schema,
769                    &request.expression_attribute_names,
770                )?;
771            }
772
773            crate::expressions::update::apply(&mut item, &parsed, &tracker)
774                .map_err(DynoxideError::ValidationException)?;
775        }
776
777        // Apply legacy AttributeUpdates (if no UpdateExpression was provided)
778        if request.update_expression.is_none()
779            && let Some(ref updates) = request.attribute_updates
780        {
781            apply_attribute_updates(&mut item, updates, &key_schema)?;
782        }
783
784        // Note: unused expression attribute validation already done in pre-validation
785        // block (Tracker 1). Not repeated here: runtime evaluation may skip branches
786        // (e.g., if_not_exists short-circuits) which would cause false positives.
787
788        // An UpdateExpression that leaves the item nested too deep (a value
789        // written far down an existing path) gets the short nesting message,
790        // bare. Captured us-east-1 and eu-west-2, 2026-10-09.
791        if request.update_expression.is_some() {
792            crate::validation::validate_updated_item_nesting(&item)?;
793        }
794
795        // Validate attribute values after update expression applied. This
796        // checks the post-update item, not the request, so its errors stay
797        // bare: plain `?` converts through the untagged From impl. Do not
798        // route it through into_tagged even though the messages match the
799        // tagged request-time families byte for byte.
800        crate::validation::validate_item_attribute_values(&item)?;
801        crate::validation::normalize_item_sets(&mut item);
802
803        // Reject an index key set to an invalid value, before the fan-out so the
804        // error surfaces. Checks only what this update changed (see the helper).
805        helpers::validate_updated_index_keys(&old_item, &item, &meta)?;
806
807        // The finished item is held to the limit as well as the request charge
808        // checked before the transaction. The charge binds while the statement
809        // carries more than it adds to the item; once the stored item holds
810        // enough besides, the item's own size does.
811        if types::item_size(&item) > types::MAX_ITEM_SIZE {
812            return Err(DynoxideError::ValidationException(
813                "Item size to update has exceeded the maximum allowed size".to_string(),
814            ));
815        }
816
817        // What the row records and the write is charged: the billed size, with
818        // a vector attribute at 4 bytes per dimension. The limit above sizes it
819        // by its numbers, as DynamoDB does.
820        let vector_defs = super::vector_index::parse_vector_defs(&meta)?;
821        let size = types::billed_item_size(&item, &vector_defs);
822
823        // Serialize and store
824        let item_json = serde_json::to_string(&item)
825            .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
826        let hash_prefix = request
827            .key
828            .get(&key_schema.partition_key)
829            .map(crate::storage::compute_hash_prefix)
830            .unwrap_or_default();
831        storage
832            .put_item_with_hash(
833                &request.table_name,
834                &pk,
835                &sk,
836                &item_json,
837                size,
838                &hash_prefix,
839            )
840            .await?;
841
842        // `old_item` is cloned after the key attributes are injected for the
843        // upsert case, so on a create-through-update it is a `{pk, sk}` image
844        // rather than an absent one. The index delta has to see genuine absence,
845        // so gate on whether a row was actually read back.
846        let prior_image = existing_json.is_some().then_some(&old_item);
847        let old_size = prior_image.map(|old| types::billed_item_size(old, &vector_defs));
848
849        let target = super::gsi::IndexWrite {
850            table_name: &request.table_name,
851            pk: &pk,
852            sk: &sk,
853            pk_attr: &key_schema.partition_key,
854            sk_attr: key_schema.sort_key.as_deref(),
855            old_item: prior_image,
856            capacity_mode: request.return_consumed_capacity.as_deref(),
857        };
858
859        // Maintain GSI tables (inside the transaction)
860        let gsi_units =
861            super::gsi::maintain_gsis_after_write(storage, &meta, &target, &item).await?;
862
863        // Maintain LSI tables (inside the transaction)
864        let lsi_units =
865            super::lsi::maintain_lsis_after_write(storage, &meta, &target, &item).await?;
866
867        // Maintain vector index shadow tables (inside the transaction). A
868        // REMOVE of the vector attribute derives no row, so this de-indexes
869        // the item; restoring the attribute re-indexes it.
870        let vector_bytes = super::vector_index::maintain_vector_indexes_after_write(
871            storage, &meta, &target, &item,
872        )
873        .await?;
874
875        // Record stream event (inside the transaction)
876        crate::streams::record_stream_event(storage, &meta, prior_image, Some(&item)).await?;
877
878        Ok((
879            UpdateWorkResult {
880                old_item,
881                item,
882                item_json,
883                size,
884                old_size,
885            },
886            gsi_units,
887            lsi_units,
888            vector_bytes,
889        ))
890    })
891    .await?;
892
893    // Handle ReturnValues
894    let return_values = request.return_values.as_deref().unwrap_or("NONE");
895    let attributes = match return_values.to_uppercase().as_str() {
896        "ALL_OLD" => Some(old_item),
897        "ALL_NEW" => Some(item),
898        "UPDATED_OLD" => {
899            if let Some(ref update_expr) = request.update_expression {
900                // Expression-based: extract only the attributes targeted by the expression.
901                let parsed = crate::expressions::update::parse(update_expr)
902                    .map_err(DynoxideError::ValidationException)?;
903                omit_if_empty(extract_updated_attrs(
904                    &old_item,
905                    &parsed,
906                    &request.expression_attribute_names,
907                ))
908            } else {
909                // Legacy AttributeUpdates: extract the named attributes from the old item.
910                legacy_attr_names
911                    .as_ref()
912                    .map(|names| extract_named_attrs(&old_item, names))
913                    .and_then(omit_if_empty)
914            }
915        }
916        "UPDATED_NEW" => {
917            if let Some(ref update_expr) = request.update_expression {
918                // Expression-based: extract only the attributes targeted by the expression.
919                let parsed = crate::expressions::update::parse(update_expr)
920                    .map_err(DynoxideError::ValidationException)?;
921                let new_item: HashMap<String, AttributeValue> = serde_json::from_str(&item_json)
922                    .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
923                omit_if_empty(extract_updated_attrs(
924                    &new_item,
925                    &parsed,
926                    &request.expression_attribute_names,
927                ))
928            } else {
929                // Legacy AttributeUpdates: extract the named attributes from the new item.
930                legacy_attr_names
931                    .as_ref()
932                    .map(|names| {
933                        let new_item: HashMap<String, AttributeValue> =
934                            serde_json::from_str(&item_json).unwrap_or_default();
935                        extract_named_attrs(&new_item, names)
936                    })
937                    .and_then(omit_if_empty)
938            }
939        }
940        _ => None, // "NONE" or default
941    };
942
943    // Build item collection metrics (only for tables with LSIs)
944    let pk_value = request.key.get(&key_schema.partition_key).cloned();
945    let item_collection_metrics = helpers::build_item_collection_metrics(
946        storage,
947        &meta,
948        &request.table_name,
949        &pk,
950        &key_schema.partition_key,
951        pk_value
952            .as_ref()
953            .unwrap_or(&AttributeValue::S(String::new())),
954        &request.return_item_collection_metrics,
955    )
956    .await?;
957
958    let consumed_capacity = types::consumed_capacity_with_vector_indexes(
959        &request.table_name,
960        types::table_write_capacity_units(old_size, Some(size)),
961        &gsi_units,
962        &lsi_units,
963        &vector_bytes,
964        &request.return_consumed_capacity,
965    );
966
967    Ok(UpdateItemResponse {
968        attributes,
969        consumed_capacity,
970        item_collection_metrics,
971    })
972}
973
974/// Apply legacy `AttributeUpdates` to the item, mutating it in place.
975///
976/// Each entry maps an attribute name to an action:
977/// - `PUT` (default): set the attribute to the given value
978/// - `ADD`: add a number or union a set
979/// - `DELETE`: remove the attribute, or remove elements from a set
980fn apply_attribute_updates(
981    item: &mut HashMap<String, AttributeValue>,
982    updates: &HashMap<String, AttributeValueUpdate>,
983    key_schema: &helpers::KeySchema,
984) -> Result<()> {
985    for (attr_name, update) in updates {
986        // Cannot modify key attributes
987        if attr_name == &key_schema.partition_key
988            || key_schema
989                .sort_key
990                .as_ref()
991                .is_some_and(|sk| sk == attr_name)
992        {
993            return Err(helpers::key_attribute_update_refusal(attr_name));
994        }
995
996        match update.action.as_str() {
997            "PUT" => {
998                if let Some(ref value) = update.value {
999                    item.insert(attr_name.clone(), value.clone());
1000                }
1001            }
1002            "ADD" => {
1003                if let Some(ref add_val) = update.value {
1004                    let path = vec![crate::expressions::PathElement::Attribute(
1005                        attr_name.clone(),
1006                    )];
1007                    crate::expressions::update::apply_add_public(item, &path, add_val)
1008                        .map_err(DynoxideError::ValidationException)?;
1009                }
1010            }
1011            "DELETE" => {
1012                if let Some(ref del_val) = update.value {
1013                    // DELETE with a value: remove elements from a set
1014                    let path = vec![crate::expressions::PathElement::Attribute(
1015                        attr_name.clone(),
1016                    )];
1017                    crate::expressions::update::apply_delete_public(item, &path, del_val)
1018                        .map_err(DynoxideError::ValidationException)?;
1019                } else {
1020                    // DELETE without a value: remove the attribute entirely
1021                    item.remove(attr_name);
1022                }
1023            }
1024            _ => {
1025                return Err(DynoxideError::EnvelopedValidation(
1026                    ATTRIBUTE_UPDATE_ACTION_REJECTION.to_string(),
1027                ));
1028            }
1029        }
1030    }
1031    Ok(())
1032}
1033
1034/// Extract only the attributes that were affected by the update expression,
1035/// at full path granularity.
1036///
1037/// For a nested target like `SET parent.child = :v` this returns only the
1038/// changed fragment (`{parent: {M: {child}}}`), not the whole `parent` map,
1039/// matching how AWS scopes `UPDATED_NEW` / `UPDATED_OLD`. A path that no longer
1040/// resolves in `item` (a removed attribute under `UPDATED_NEW`) contributes
1041/// nothing, so a REMOVE-only update yields an empty map.
1042fn extract_updated_attrs(
1043    item: &HashMap<String, AttributeValue>,
1044    expr: &crate::expressions::update::UpdateExpr,
1045    attr_names: &Option<HashMap<String, String>>,
1046) -> HashMap<String, AttributeValue> {
1047    use crate::expressions::{PathElement, resolve_path, resolve_path_elements};
1048
1049    let no_values: Option<HashMap<String, AttributeValue>> = None;
1050    let tracker =
1051        crate::expressions::TrackedExpressionAttributes::without_tracking(attr_names, &no_values);
1052
1053    // Collect every target path across all clauses, in clause order.
1054    let mut paths: Vec<&[PathElement]> = Vec::new();
1055    paths.extend(expr.set_actions.iter().map(|a| a.path.as_slice()));
1056    paths.extend(expr.remove_actions.iter().map(|p| p.as_slice()));
1057    paths.extend(expr.add_actions.iter().map(|a| a.path.as_slice()));
1058    paths.extend(expr.delete_actions.iter().map(|a| a.path.as_slice()));
1059
1060    let mut result = HashMap::new();
1061    for path in paths {
1062        let Ok(resolved) = resolve_path_elements(path, &tracker) else {
1063            continue;
1064        };
1065
1066        // `insert_at_path` rebuilds a list from index 0, so it can't represent a
1067        // target that dives through a list index (`list[2]`) as a pruned
1068        // fragment without mislocating the element and dropping its siblings.
1069        // For those, fall back to returning the whole top-level attribute, the
1070        // coarse-but-correct shape. Pure attribute paths get the granular
1071        // fragment AWS scopes `UPDATED_NEW` / `UPDATED_OLD` to.
1072        if resolved.iter().any(|e| matches!(e, PathElement::Index(_))) {
1073            if let Some(PathElement::Attribute(top)) = resolved.first()
1074                && let Some(val) = item.get(top)
1075            {
1076                result.insert(top.clone(), val.clone());
1077            }
1078            continue;
1079        }
1080
1081        if let Some(val) = resolve_path(item, &resolved) {
1082            crate::expressions::projection::insert_at_path(&mut result, &resolved, val);
1083        }
1084    }
1085
1086    result
1087}
1088
1089/// Collapse an empty projection to `None` so `Attributes` is omitted entirely.
1090///
1091/// AWS omits `Attributes` from a `UPDATED_NEW` / `UPDATED_OLD` response when
1092/// nothing was projected: for example a REMOVE-only update under `UPDATED_NEW`,
1093/// where no attribute was set to a new value. Returning `Some({})` instead would
1094/// serialise an empty `Attributes` map, which AWS never does.
1095fn omit_if_empty(map: HashMap<String, AttributeValue>) -> Option<HashMap<String, AttributeValue>> {
1096    if map.is_empty() { None } else { Some(map) }
1097}
1098
1099/// Extract named attributes from an item (used for legacy AttributeUpdates ReturnValues).
1100fn extract_named_attrs(
1101    item: &HashMap<String, AttributeValue>,
1102    attr_names: &[String],
1103) -> HashMap<String, AttributeValue> {
1104    let mut result = HashMap::new();
1105    for name in attr_names {
1106        if let Some(val) = item.get(name) {
1107            result.insert(name.clone(), val.clone());
1108        }
1109    }
1110    result
1111}
1112
1113/// Validate that a path element does not target a key attribute.
1114fn validate_not_key_attr(
1115    first_element: Option<&crate::expressions::PathElement>,
1116    key_schema: &helpers::KeySchema,
1117    expression_attribute_names: &Option<HashMap<String, String>>,
1118) -> crate::errors::Result<()> {
1119    if let Some(crate::expressions::PathElement::Attribute(name)) = first_element {
1120        let resolved_name = if name.starts_with('#') {
1121            crate::expressions::resolve_name(name, expression_attribute_names)
1122                .map_err(DynoxideError::ValidationException)?
1123        } else {
1124            name.clone()
1125        };
1126        if resolved_name == key_schema.partition_key
1127            || key_schema
1128                .sort_key
1129                .as_ref()
1130                .is_some_and(|sk| sk == &resolved_name)
1131        {
1132            return Err(helpers::key_attribute_update_refusal(&resolved_name));
1133        }
1134    }
1135    Ok(())
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140    use crate::actions::{create_table, put_item, update_item};
1141    use crate::storage::Storage;
1142    use crate::storage_backend::StorageBackend;
1143
1144    #[test]
1145    fn update_item_stops_at_first_invalid_enum() {
1146        // eu-west-2: UpdateItem reports one error for the first invalid enum
1147        // (ReturnValues first), unlike PutItem which aggregates them.
1148        let err = serde_json::from_value::<super::UpdateItemRequest>(serde_json::json!({
1149            "TableName": "_conformance_valid_table_name",
1150            "Key": {"pk": {"S": "test"}},
1151            "ReturnValues": "INVALID",
1152            "ReturnConsumedCapacity": "INVALID"
1153        }))
1154        .unwrap_err()
1155        .to_string();
1156        assert!(err.contains("1 validation error detected"), "got: {err}");
1157        assert!(err.contains("enum value set"), "got: {err}");
1158        // The generic form names neither the value nor the member (captured
1159        // 2026-10-08): the enum list is what identifies ReturnValues.
1160        assert!(
1161            err.contains("[ALL_OLD, UPDATED_OLD, ALL_NEW, UPDATE_NEW, NONE]"),
1162            "got: {err}"
1163        );
1164        assert!(!err.contains("returnConsumedCapacity"), "got: {err}");
1165    }
1166
1167    #[test]
1168    fn update_item_empty_table_name_reports_only_table_name() {
1169        let err = serde_json::from_value::<super::UpdateItemRequest>(serde_json::json!({
1170            "TableName": "",
1171            "Key": {}
1172        }))
1173        .unwrap_err()
1174        .to_string();
1175        assert!(err.contains("1 validation error detected"), "got: {err}");
1176        assert!(err.to_lowercase().contains("tablename"), "got: {err}");
1177    }
1178
1179    /// An update and its GSI fan-out succeed or fail as one unit: a mid-fan-out
1180    /// failure leaves the item at its pre-update value.
1181    #[test]
1182    fn update_item_rolls_back_base_write_when_gsi_fan_out_fails() {
1183        let storage = Storage::memory().unwrap();
1184
1185        let create = serde_json::from_value(serde_json::json!({
1186            "TableName": "Orders",
1187            "KeySchema": [{"AttributeName": "UserId", "KeyType": "HASH"}],
1188            "AttributeDefinitions": [
1189                {"AttributeName": "UserId", "AttributeType": "S"},
1190                {"AttributeName": "Status", "AttributeType": "S"},
1191                {"AttributeName": "Priority", "AttributeType": "S"}
1192            ],
1193            "GlobalSecondaryIndexes": [
1194                {"IndexName": "StatusIndex", "KeySchema": [{"AttributeName": "Status", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}},
1195                {"IndexName": "PriorityIndex", "KeySchema": [{"AttributeName": "Priority", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}}
1196            ]
1197        }))
1198        .unwrap();
1199        pollster::block_on(create_table::execute(&storage, create)).unwrap();
1200
1201        let put = serde_json::from_value(serde_json::json!({
1202            "TableName": "Orders",
1203            "Item": {"UserId": {"S": "u1"}, "Status": {"S": "SHIPPED"}, "Priority": {"S": "HIGH"}, "Note": {"S": "before"}}
1204        }))
1205        .unwrap();
1206        pollster::block_on(put_item::execute(&storage, put)).unwrap();
1207
1208        // Break the second GSI's fan-out by dropping its physical table.
1209        storage.drop_gsi_table("Orders", "PriorityIndex").unwrap();
1210
1211        let update = serde_json::from_value(serde_json::json!({
1212            "TableName": "Orders",
1213            "Key": {"UserId": {"S": "u1"}},
1214            "UpdateExpression": "SET Note = :n",
1215            "ExpressionAttributeValues": {":n": {"S": "after"}}
1216        }))
1217        .unwrap();
1218        let res = pollster::block_on(update_item::execute(&storage, update));
1219        assert!(
1220            res.is_err(),
1221            "a mid-fan-out failure must surface as an error"
1222        );
1223
1224        // The base write must roll back: the item is still present at its
1225        // pre-update value.
1226        let rows = pollster::block_on(<Storage as StorageBackend>::scan_items(
1227            &storage,
1228            "Orders",
1229            &Default::default(),
1230        ))
1231        .unwrap();
1232        assert_eq!(
1233            rows.len(),
1234            1,
1235            "the item must still be present after rollback"
1236        );
1237        let raw = &rows[0].2;
1238        assert!(
1239            raw.contains("\"before\"") && !raw.contains("\"after\""),
1240            "update must roll back when fan-out fails, leaving the original value: {raw}"
1241        );
1242    }
1243}