Skip to main content

dynoxide/actions/
update_table.rs

1use crate::actions::create_table::StreamSpecification;
2use crate::actions::{TableDescription, build_table_description};
3use crate::actions::{gsi, helpers};
4use crate::errors::{DynoxideError, Result};
5use crate::storage_backend::StorageBackend;
6use crate::streams;
7use crate::types::{AttributeDefinition, GlobalSecondaryIndex, KeySchemaElement, Projection};
8use crate::validation;
9use serde::{Deserialize, Serialize};
10
11/// Internal raw deserialization struct.
12#[derive(Debug, Default, Deserialize)]
13struct UpdateTableRequestRaw {
14    #[serde(rename = "TableName", default)]
15    table_name: Option<String>,
16
17    #[serde(rename = "AttributeDefinitions", default)]
18    attribute_definitions: Option<Vec<AttributeDefinition>>,
19
20    #[serde(rename = "GlobalSecondaryIndexUpdates", default)]
21    global_secondary_index_updates: Option<Vec<GlobalSecondaryIndexUpdate>>,
22
23    #[serde(rename = "StreamSpecification", default)]
24    stream_specification: Option<StreamSpecification>,
25
26    #[serde(rename = "DeletionProtectionEnabled", default)]
27    deletion_protection_enabled: Option<bool>,
28
29    #[serde(rename = "ProvisionedThroughput", default)]
30    provisioned_throughput: Option<serde_json::Value>,
31
32    #[serde(rename = "BillingMode", default)]
33    billing_mode: Option<String>,
34
35    #[serde(rename = "TableClass", default)]
36    table_class: Option<String>,
37
38    #[serde(rename = "OnDemandThroughput", default)]
39    on_demand_throughput: Option<crate::types::OnDemandThroughput>,
40}
41
42#[derive(Debug, Default)]
43pub struct UpdateTableRequest {
44    pub table_name: String,
45    pub attribute_definitions: Option<Vec<AttributeDefinition>>,
46    pub global_secondary_index_updates: Option<Vec<GlobalSecondaryIndexUpdate>>,
47    pub stream_specification: Option<StreamSpecification>,
48    pub deletion_protection_enabled: Option<bool>,
49    pub provisioned_throughput: Option<serde_json::Value>,
50    pub billing_mode: Option<String>,
51    pub table_class: Option<String>,
52    pub on_demand_throughput: Option<crate::types::OnDemandThroughput>,
53}
54
55impl<'de> serde::Deserialize<'de> for UpdateTableRequest {
56    fn deserialize<D: serde::Deserializer<'de>>(
57        deserializer: D,
58    ) -> std::result::Result<Self, D::Error> {
59        let raw = UpdateTableRequestRaw::deserialize(deserializer)?;
60
61        // Phase 1: Check TableName missing
62        if raw.table_name.is_none() || raw.table_name.as_deref() == Some("") {
63            let msg = if raw.table_name.is_none() {
64                "The parameter 'TableName' is required but was not present in the request"
65            } else {
66                "TableName must be at least 3 characters long and at most 255 characters long"
67            };
68            return Err(serde::de::Error::custom(format!("VALIDATION:{}", msg)));
69        }
70
71        let table_name = raw.table_name.unwrap_or_default();
72
73        // Phase 2: Check TableName length
74        if table_name.len() < 3 || table_name.len() > 255 {
75            return Err(serde::de::Error::custom(
76                "VALIDATION:TableName must be at least 3 characters long and at most 255 characters long",
77            ));
78        }
79
80        // Phase 3: Multi-field constraint validation
81        let mut errors = Vec::new();
82
83        if !table_name
84            .chars()
85            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
86        {
87            errors.push(format!(
88                "Value '{}' at 'tableName' failed to satisfy constraint: \
89                 Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
90                table_name
91            ));
92        }
93
94        if let Some(msg) = crate::validation::format_validation_errors(&errors) {
95            return Err(serde::de::Error::custom(format!("VALIDATION:{}", msg)));
96        }
97
98        Ok(UpdateTableRequest {
99            table_name,
100            attribute_definitions: raw.attribute_definitions,
101            global_secondary_index_updates: raw.global_secondary_index_updates,
102            stream_specification: raw.stream_specification,
103            deletion_protection_enabled: raw.deletion_protection_enabled,
104            provisioned_throughput: raw.provisioned_throughput,
105            billing_mode: raw.billing_mode,
106            table_class: raw.table_class,
107            on_demand_throughput: raw.on_demand_throughput,
108        })
109    }
110}
111
112#[derive(Debug, Default, Deserialize)]
113pub struct GlobalSecondaryIndexUpdate {
114    #[serde(rename = "Update", default)]
115    pub update: Option<UpdateGsiAction>,
116
117    #[serde(rename = "Create", default)]
118    pub create: Option<CreateGsiAction>,
119
120    #[serde(rename = "Delete", default)]
121    pub delete: Option<DeleteGsiAction>,
122}
123
124#[derive(Debug, Default, Deserialize)]
125pub struct UpdateGsiAction {
126    #[serde(rename = "IndexName")]
127    pub index_name: String,
128
129    #[serde(rename = "ProvisionedThroughput", default)]
130    pub provisioned_throughput: Option<crate::types::ProvisionedThroughput>,
131}
132
133#[derive(Debug, Default, Deserialize)]
134pub struct CreateGsiAction {
135    #[serde(rename = "IndexName")]
136    pub index_name: String,
137
138    #[serde(rename = "KeySchema")]
139    pub key_schema: Vec<KeySchemaElement>,
140
141    #[serde(rename = "Projection")]
142    pub projection: Projection,
143}
144
145#[derive(Debug, Default, Deserialize)]
146pub struct DeleteGsiAction {
147    #[serde(rename = "IndexName")]
148    pub index_name: String,
149}
150
151#[derive(Debug, Default, Serialize)]
152pub struct UpdateTableResponse {
153    #[serde(rename = "TableDescription")]
154    pub table_description: TableDescription,
155}
156
157pub async fn execute<S: StorageBackend>(
158    storage: &S,
159    mut request: UpdateTableRequest,
160) -> Result<UpdateTableResponse> {
161    // Table name validation is handled in the Deserialize impl
162
163    // An OnDemandThroughput object with no members carries no change, so
164    // treat it as absent: it neither satisfies the at-least-one-change
165    // guard nor produces an echo. Real DynamoDB returns InternalFailure for
166    // this input (captured eu-west-2, 2026-07-24); a deterministic
167    // validation error is a deliberate divergence from emulating a 500.
168    if request.on_demand_throughput.as_ref().is_some_and(|odt| {
169        odt.max_read_request_units.is_none() && odt.max_write_request_units.is_none()
170    }) {
171        request.on_demand_throughput = None;
172    }
173
174    // Phase 1: Validate request parameters BEFORE table existence check
175    // (DynamoDB validates these first and returns ValidationException,
176    // not ResourceNotFoundException)
177    validate_update_request(&request)?;
178
179    // Phase 2: Table existence check
180    let meta = helpers::require_table(storage, &request.table_name).await?;
181
182    let current_billing_mode = meta.billing_mode.as_deref().unwrap_or("PROVISIONED");
183
184    // Phase 3: Post-table-existence validations
185
186    // PAY_PER_REQUEST table + ProvisionedThroughput update is not allowed
187    if current_billing_mode == "PAY_PER_REQUEST"
188        && request.billing_mode.is_none()
189        && request.provisioned_throughput.is_some()
190    {
191        return Err(DynoxideError::ValidationException(
192            "One or more parameter values were invalid: \
193             Neither ReadCapacityUnits nor WriteCapacityUnits can be \
194             specified when BillingMode is PAY_PER_REQUEST"
195                .to_string(),
196        ));
197    }
198
199    // BillingMode PROVISIONED without ProvisionedThroughput
200    if request.billing_mode.as_deref() == Some("PROVISIONED")
201        && request.provisioned_throughput.is_none()
202    {
203        return Err(DynoxideError::ValidationException(
204            "One or more parameter values were invalid: \
205             ProvisionedThroughput must be specified when BillingMode is PROVISIONED"
206                .to_string(),
207        ));
208    }
209
210    // OnDemandThroughput is only valid when the table ends up PAY_PER_REQUEST:
211    // either the request switches to it, or the table already is and the
212    // request does not switch away. The gate reads the committed billing mode
213    // and names the first present member, read checked first; the update
214    // wording carries "the" and no full stop, unlike CreateTable's. The gate
215    // fires before the bounds check when both are violated. All captured from
216    // real DynamoDB (eu-west-2, 2026-07-24).
217    if let Some(ref odt) = request.on_demand_throughput {
218        let target_is_provisioned = match request.billing_mode.as_deref() {
219            Some("PAY_PER_REQUEST") => false,
220            Some(_) => true,
221            None => current_billing_mode == "PROVISIONED",
222        };
223        let members = [
224            ("MaxReadRequestUnits", odt.max_read_request_units),
225            ("MaxWriteRequestUnits", odt.max_write_request_units),
226        ];
227        if target_is_provisioned {
228            if let Some((member, _)) = members.iter().find(|(_, v)| v.is_some()) {
229                return Err(DynoxideError::ValidationException(format!(
230                    "One or more parameter values were invalid: {member} for \
231                     OnDemandThroughput cannot be specified when the table BillingMode \
232                     is PROVISIONED"
233                )));
234            }
235        }
236        // Bounds: members must be at least 1, or exactly -1, which removes
237        // the ceiling. Message identical to CreateTable's.
238        for (member, value) in members {
239            if value.is_some_and(|v| v == 0 || v < -1) {
240                return Err(DynoxideError::ValidationException(format!(
241                    "One or more parameter values were invalid: Requested {member} for \
242                     OnDemandThroughput for table is outside of valid range"
243                )));
244            }
245        }
246    }
247
248    // Same read/write values check
249    if let Some(ref pt) = request.provisioned_throughput {
250        if let Some(obj) = pt.as_object() {
251            let new_rcu = obj
252                .get("ReadCapacityUnits")
253                .and_then(|v| v.as_i64())
254                .unwrap_or(0);
255            let new_wcu = obj
256                .get("WriteCapacityUnits")
257                .and_then(|v| v.as_i64())
258                .unwrap_or(0);
259
260            // Parse current provisioned throughput from metadata
261            let (cur_rcu, cur_wcu) = parse_current_throughput(&meta);
262
263            let billing_mode_unchanged = request.billing_mode.is_none()
264                || (request.billing_mode.as_deref() == Some("PROVISIONED")
265                    && current_billing_mode == "PROVISIONED");
266
267            if new_rcu == cur_rcu && new_wcu == cur_wcu && billing_mode_unchanged {
268                return Err(DynoxideError::ValidationException(format!(
269                    "The provisioned throughput for the table will not change. \
270                     The requested value equals the current value. \
271                     Current ReadCapacityUnits provisioned for the table: {}. \
272                     Requested ReadCapacityUnits: {}. \
273                     Current WriteCapacityUnits provisioned for the table: {}. \
274                     Requested WriteCapacityUnits: {}. \
275                     Refer to the Amazon DynamoDB Developer Guide for current limits \
276                     and how to request higher limits.",
277                    cur_rcu, new_rcu, cur_wcu, new_wcu
278                )));
279            }
280        }
281    }
282
283    // Refuse a stream-specification change on a backend without streams before
284    // any mutation below (GSI creates, metadata writes) can land. Ordered after
285    // the AWS-pinned validations above so requests real DynamoDB would reject
286    // keep their pinned errors; only a request AWS would accept reaches this.
287    if request.stream_specification.is_some() && !storage.supports_streams() {
288        return Err(crate::storage_backend::BackendError::Unsupported {
289            capability: "streams",
290        }
291        .into());
292    }
293
294    // Parse existing GSI definitions
295    let mut current_gsis: Vec<GlobalSecondaryIndex> = meta
296        .gsi_definitions
297        .as_ref()
298        .map(|json| serde_json::from_str(json))
299        .transpose()
300        .map_err(|e| DynoxideError::InternalServerError(format!("Bad GSI JSON: {e}")))?
301        .unwrap_or_default();
302
303    // GSI Update with high capacity on non-existent index
304    if let Some(ref updates) = request.global_secondary_index_updates {
305        for update in updates {
306            if let Some(ref upd) = update.update {
307                if !current_gsis.iter().any(|g| g.index_name == upd.index_name) {
308                    // DynamoDB returns this specific message for GSI updates on
309                    // non-existent indexes (even with out-of-bounds capacity)
310                    return Err(DynoxideError::ValidationException(
311                        "This operation cannot be performed with given input values. \
312                         Please contact DynamoDB service team for more info: \
313                         Action Blocked: IndexUpdate"
314                            .to_string(),
315                    ));
316                }
317            }
318        }
319    }
320
321    // Check GSI update count limit (DynamoDB allows at most 5 per request)
322    if let Some(ref updates) = request.global_secondary_index_updates {
323        if updates.len() > 5 {
324            return Err(DynoxideError::LimitExceededException(
325                "Subscriber limit exceeded: Only 1 online index can be created or \
326                 deleted simultaneously per table"
327                    .to_string(),
328            ));
329        }
330    }
331
332    // Merge provided attribute definitions into the existing set. DynamoDB
333    // treats UpdateTable's AttributeDefinitions as a delta: adding a GSI only
334    // requires the new index's key attributes, and the existing definitions
335    // (table keys, prior GSI keys) are preserved. Replacing them outright would
336    // drop attributes still referenced by the key schema and other indexes.
337    let mut attr_defs: Vec<AttributeDefinition> = serde_json::from_str(&meta.attribute_definitions)
338        .map_err(|e| DynoxideError::InternalServerError(format!("Bad attr defs JSON: {e}")))?;
339
340    if let Some(ref provided) = request.attribute_definitions {
341        for def in provided {
342            // An already-declared attribute keeps its existing type. Real
343            // DynamoDB ignores a redeclaration (even one carrying a different
344            // type) rather than overwriting or rejecting it, so only genuinely
345            // new attributes are appended. Verified against AWS in eu-west-2.
346            if !attr_defs
347                .iter()
348                .any(|d| d.attribute_name == def.attribute_name)
349            {
350                attr_defs.push(def.clone());
351            }
352        }
353    }
354
355    // Parse table key schema for backfill
356    let key_schema = helpers::parse_key_schema(&meta)?;
357
358    // Validate all GSI updates before making any changes
359    if let Some(ref updates) = request.global_secondary_index_updates {
360        for update in updates {
361            if let Some(ref create) = update.create {
362                if current_gsis
363                    .iter()
364                    .any(|g| g.index_name == create.index_name)
365                {
366                    return Err(DynoxideError::ValidationException(format!(
367                        "One or more parameter values were invalid: \
368                         Index already exists: {}",
369                        create.index_name
370                    )));
371                }
372                let gsi_def = GlobalSecondaryIndex {
373                    index_name: create.index_name.clone(),
374                    key_schema: create.key_schema.clone(),
375                    projection: create.projection.clone(),
376                    provisioned_throughput: None,
377                };
378                // A new index's key attributes must all appear in the request's
379                // own AttributeDefinitions, not the merged stored set: DynamoDB
380                // requires the request to (re)declare them even when the table
381                // already defines the attribute.
382                validation::validate_gsi(
383                    &gsi_def,
384                    request.attribute_definitions.as_deref().unwrap_or(&[]),
385                )?;
386            }
387            if let Some(ref delete) = update.delete {
388                if !current_gsis
389                    .iter()
390                    .any(|g| g.index_name == delete.index_name)
391                {
392                    return Err(DynoxideError::ResourceNotFoundException(format!(
393                        "Requested resource not found: Table: {} not found",
394                        delete.index_name
395                    )));
396                }
397            }
398        }
399    }
400
401    // Determine if this is a throughput increase or decrease.
402    // Ensure timestamps strictly increase across successive updates
403    // (the dynalite test expects LastDecreaseDateTime > LastIncreaseDateTime).
404    let now = {
405        use std::sync::atomic::{AtomicU64, Ordering};
406        static LAST_TS: AtomicU64 = AtomicU64::new(0);
407        let wall = web_time::SystemTime::now()
408            .duration_since(web_time::UNIX_EPOCH)
409            .unwrap_or_default()
410            .as_secs_f64();
411        loop {
412            let prev_bits = LAST_TS.load(Ordering::SeqCst);
413            let prev_f = f64::from_bits(prev_bits);
414            let candidate = if wall > prev_f { wall } else { prev_f + 0.001 };
415            let candidate_bits = candidate.to_bits();
416            if LAST_TS
417                .compare_exchange(
418                    prev_bits,
419                    candidate_bits,
420                    Ordering::SeqCst,
421                    Ordering::SeqCst,
422                )
423                .is_ok()
424            {
425                break candidate;
426            }
427        }
428    };
429
430    let (cur_rcu, cur_wcu) = parse_current_throughput(&meta);
431    let is_pt_update = request.provisioned_throughput.is_some();
432    let (new_rcu, new_wcu) = if let Some(ref pt) = request.provisioned_throughput {
433        let obj = pt.as_object();
434        (
435            obj.and_then(|o| o.get("ReadCapacityUnits"))
436                .and_then(|v| v.as_i64())
437                .unwrap_or(0),
438            obj.and_then(|o| o.get("WriteCapacityUnits"))
439                .and_then(|v| v.as_i64())
440                .unwrap_or(0),
441        )
442    } else {
443        (cur_rcu, cur_wcu)
444    };
445
446    let is_increase = new_rcu > cur_rcu || new_wcu > cur_wcu;
447    let is_decrease = new_rcu < cur_rcu || new_wcu < cur_wcu;
448
449    // OnDemandThroughput merges member-wise over the stored ceilings, with -1
450    // removing a member (captured from real DynamoDB, eu-west-2 2026-07-24).
451    // The response echoes the merge with any -1 kept verbatim; the stored
452    // state has it stripped, and an empty result clears the column.
453    let odt_change = request.on_demand_throughput.as_ref().map(|req_odt| {
454        let stored: crate::types::OnDemandThroughput = meta
455            .on_demand_throughput
456            .as_deref()
457            .and_then(|json| serde_json::from_str(json).ok())
458            .unwrap_or_default();
459        let echo = crate::types::OnDemandThroughput {
460            max_read_request_units: req_odt
461                .max_read_request_units
462                .or(stored.max_read_request_units),
463            max_write_request_units: req_odt
464                .max_write_request_units
465                .or(stored.max_write_request_units),
466        };
467        let strip = |v: Option<i64>| v.filter(|&v| v != -1);
468        let effective = crate::types::OnDemandThroughput {
469            max_read_request_units: strip(echo.max_read_request_units),
470            max_write_request_units: strip(echo.max_write_request_units),
471        };
472        (echo, effective)
473    });
474
475    // All validation passed; perform mutations inside a single transaction.
476    helpers::with_write_transaction(storage, async {
477        if let Some(ref updates) = request.global_secondary_index_updates {
478            for update in updates {
479                if let Some(ref create) = update.create {
480                    let gsi_def = GlobalSecondaryIndex {
481                        index_name: create.index_name.clone(),
482                        key_schema: create.key_schema.clone(),
483                        projection: create.projection.clone(),
484                        provisioned_throughput: None,
485                    };
486
487                    storage
488                        .create_gsi_table(&request.table_name, &create.index_name)
489                        .await?;
490
491                    let gsi_p = gsi::gsi_to_def(&gsi_def)?;
492                    backfill_gsi(storage, &request.table_name, &key_schema, &gsi_p).await?;
493
494                    current_gsis.push(gsi_def);
495                }
496
497                if let Some(ref delete) = update.delete {
498                    storage
499                        .drop_gsi_table(&request.table_name, &delete.index_name)
500                        .await?;
501                    current_gsis.retain(|g| g.index_name != delete.index_name);
502                }
503            }
504        }
505
506        // Reconcile AttributeDefinitions to exactly the attributes still
507        // referenced by the table key schema and surviving index key schemas.
508        // See reconcile_attribute_definitions for the AWS-verified rules.
509        let lsi_defs = crate::actions::lsi::parse_lsi_defs(&meta)?;
510        reconcile_attribute_definitions(&mut attr_defs, &key_schema, &current_gsis, &lsi_defs);
511
512        // Update metadata
513        let attr_defs_json = serde_json::to_string(&attr_defs)
514            .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
515        let gsi_json = if current_gsis.is_empty() {
516            None
517        } else {
518            Some(
519                serde_json::to_string(&current_gsis)
520                    .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?,
521            )
522        };
523
524        storage
525            .update_table_metadata(&request.table_name, &attr_defs_json, gsi_json.as_deref())
526            .await?;
527
528        // Update provisioned throughput if requested
529        if is_pt_update {
530            let prev = parse_stored_throughput(&meta);
531            let mut stored = StoredProvisionedThroughput {
532                read_capacity_units: new_rcu,
533                write_capacity_units: new_wcu,
534                last_increase_date_time: prev.as_ref().and_then(|p| p.last_increase_date_time),
535                last_decrease_date_time: prev.as_ref().and_then(|p| p.last_decrease_date_time),
536                number_of_decreases_today: prev
537                    .as_ref()
538                    .and_then(|p| p.number_of_decreases_today)
539                    .or(Some(0)),
540            };
541            if is_increase {
542                stored.last_increase_date_time = Some(now);
543            }
544            if is_decrease {
545                stored.last_decrease_date_time = Some(now);
546                stored.number_of_decreases_today =
547                    Some(stored.number_of_decreases_today.unwrap_or(0) + 1);
548            }
549            let pt_json = serde_json::to_string(&stored)
550                .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
551            storage
552                .update_provisioned_throughput(&request.table_name, &pt_json)
553                .await?;
554        }
555
556        // Handle deletion protection changes
557        if let Some(enabled) = request.deletion_protection_enabled {
558            storage
559                .update_deletion_protection(&request.table_name, enabled)
560                .await?;
561        }
562
563        // Handle table class changes
564        if let Some(ref table_class) = request.table_class {
565            storage
566                .update_table_class(&request.table_name, table_class)
567                .await?;
568        }
569
570        // Handle on-demand throughput changes
571        if let Some((_, ref effective)) = odt_change {
572            if effective.max_read_request_units.is_none()
573                && effective.max_write_request_units.is_none()
574            {
575                storage
576                    .clear_on_demand_throughput(&request.table_name)
577                    .await?;
578            } else {
579                let json = serde_json::to_string(effective)
580                    .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
581                storage
582                    .update_on_demand_throughput(&request.table_name, &json)
583                    .await?;
584            }
585        }
586
587        // Handle billing mode changes
588        if let Some(ref billing_mode) = request.billing_mode {
589            storage
590                .update_billing_mode(&request.table_name, billing_mode)
591                .await?;
592            if billing_mode == "PAY_PER_REQUEST" {
593                // Clear provisioned throughput to avoid stale data
594                storage
595                    .clear_provisioned_throughput(&request.table_name)
596                    .await?;
597            } else if billing_mode == "PROVISIONED" {
598                // Switching away from on-demand clears the stored ceilings,
599                // matching real DynamoDB (eu-west-2 capture, 2026-07-24).
600                storage
601                    .clear_on_demand_throughput(&request.table_name)
602                    .await?;
603            }
604        }
605
606        // Handle stream specification changes
607        if let Some(ref spec) = request.stream_specification {
608            if spec.stream_enabled {
609                let view_type = spec
610                    .stream_view_type
611                    .as_deref()
612                    .unwrap_or("NEW_AND_OLD_IMAGES");
613                let label = streams::generate_stream_label(storage.clock());
614                storage
615                    .enable_stream(&request.table_name, view_type, &label)
616                    .await?;
617            } else {
618                storage.disable_stream(&request.table_name).await?;
619            }
620        }
621
622        Ok(())
623    })
624    .await?;
625
626    // Build response from updated metadata
627    let updated_meta = helpers::require_table(storage, &request.table_name).await?;
628    let mut desc = build_table_description(&updated_meta, Some(0), Some(0));
629
630    // The UpdateTable response echoes the merged ceilings with any -1 kept
631    // verbatim; only DescribeTable afterwards shows the post-removal state.
632    if let Some((echo, _)) = odt_change {
633        desc.on_demand_throughput = Some(echo);
634    }
635
636    // DynamoDB returns UPDATING status during throughput changes
637    if is_pt_update {
638        desc.table_status = "UPDATING".to_string();
639
640        // The immediate response shows the OLD throughput values while the
641        // table is in UPDATING status, but with updated timestamps.
642        let stored = parse_stored_throughput(&updated_meta);
643        if let Some(ref mut pt) = desc.provisioned_throughput {
644            pt.read_capacity_units = cur_rcu as u64;
645            pt.write_capacity_units = cur_wcu as u64;
646            if let Some(ref s) = stored {
647                pt.last_increase_date_time = s.last_increase_date_time;
648                pt.last_decrease_date_time = s.last_decrease_date_time;
649                pt.number_of_decreases_today = s.number_of_decreases_today.unwrap_or(0);
650            }
651        }
652    }
653
654    Ok(UpdateTableResponse {
655        table_description: desc,
656    })
657}
658
659/// Reconcile `attr_defs` to exactly the attributes referenced by the table key
660/// schema plus every surviving index key schema. Real DynamoDB keeps the two in
661/// lockstep: an attribute orphaned by a GSI delete is pruned, and an entry used
662/// by no key schema is dropped rather than stored (neither is an error).
663/// Verified against AWS in eu-west-2.
664fn reconcile_attribute_definitions(
665    attr_defs: &mut Vec<AttributeDefinition>,
666    key_schema: &helpers::KeySchema,
667    gsis: &[GlobalSecondaryIndex],
668    lsi_defs: &[crate::actions::lsi::LsiDef],
669) {
670    let mut used: std::collections::HashSet<&str> = std::collections::HashSet::new();
671    used.insert(key_schema.partition_key.as_str());
672    if let Some(ref sk) = key_schema.sort_key {
673        used.insert(sk.as_str());
674    }
675    for g in gsis {
676        for k in &g.key_schema {
677            used.insert(k.attribute_name.as_str());
678        }
679    }
680    for lsi in lsi_defs {
681        used.insert(lsi.pk_attr.as_str());
682        if let Some(ref sk) = lsi.sk_attr {
683            used.insert(sk.as_str());
684        }
685    }
686    attr_defs.retain(|d| used.contains(d.attribute_name.as_str()));
687}
688
689/// Validate UpdateTable request parameters before checking table existence.
690///
691/// DynamoDB validates these parameters first and returns ValidationException
692/// rather than ResourceNotFoundException when both are invalid.
693fn validate_update_request(request: &UpdateTableRequest) -> Result<()> {
694    // Multi-field constraint errors
695    let mut errors = Vec::new();
696
697    // Validate ProvisionedThroughput fields
698    if let Some(ref pt) = request.provisioned_throughput {
699        if let Some(obj) = pt.as_object() {
700            let wcu = obj.get("WriteCapacityUnits");
701            let rcu = obj.get("ReadCapacityUnits");
702            if wcu.is_none() || wcu == Some(&serde_json::Value::Null) {
703                errors.push("Value null at 'provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must not be null".to_string());
704            } else if let Some(w) = wcu.and_then(|v| v.as_i64()) {
705                if w < 1 {
706                    errors.push(format!("Value '{}' at 'provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must have value greater than or equal to 1", w));
707                }
708            }
709            if rcu.is_none() || rcu == Some(&serde_json::Value::Null) {
710                errors.push("Value null at 'provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must not be null".to_string());
711            } else if let Some(r) = rcu.and_then(|v| v.as_i64()) {
712                if r < 1 {
713                    errors.push(format!("Value '{}' at 'provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must have value greater than or equal to 1", r));
714                }
715            }
716        }
717    }
718
719    // Validate GlobalSecondaryIndexUpdates fields
720    if let Some(ref updates) = request.global_secondary_index_updates {
721        for (i, update) in updates.iter().enumerate() {
722            if let Some(ref upd) = update.update {
723                // Validate Update.IndexName
724                if upd.index_name.len() < 3 {
725                    errors.push(format!("Value '{}' at 'globalSecondaryIndexUpdates.{}.member.update.indexName' failed to satisfy constraint: Member must have length greater than or equal to 3", upd.index_name, i + 1));
726                }
727                if !upd.index_name.is_empty()
728                    && !upd
729                        .index_name
730                        .chars()
731                        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
732                {
733                    errors.push(format!("Value '{}' at 'globalSecondaryIndexUpdates.{}.member.update.indexName' failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+", upd.index_name, i + 1));
734                }
735                // Validate Update.ProvisionedThroughput
736                if let Some(ref pt) = upd.provisioned_throughput {
737                    let wcu = pt.write_capacity_units;
738                    let rcu = pt.read_capacity_units;
739                    if wcu.is_none() {
740                        errors.push(format!("Value null at 'globalSecondaryIndexUpdates.{}.member.update.provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must not be null", i + 1));
741                    } else if let Some(w) = wcu {
742                        if w < 1 {
743                            errors.push(format!("Value '{}' at 'globalSecondaryIndexUpdates.{}.member.update.provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must have value greater than or equal to 1", w, i + 1));
744                        }
745                    }
746                    if rcu.is_none() {
747                        errors.push(format!("Value null at 'globalSecondaryIndexUpdates.{}.member.update.provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must not be null", i + 1));
748                    } else if let Some(r) = rcu {
749                        if r < 1 {
750                            errors.push(format!("Value '{}' at 'globalSecondaryIndexUpdates.{}.member.update.provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must have value greater than or equal to 1", r, i + 1));
751                        }
752                    }
753                } else {
754                    errors.push(format!("Value null at 'globalSecondaryIndexUpdates.{}.member.update.provisionedThroughput' failed to satisfy constraint: Member must not be null", i + 1));
755                }
756            }
757        }
758    }
759
760    // Cap at 10 errors
761    errors.truncate(10);
762
763    if !errors.is_empty() {
764        let prefix = format!(
765            "{} validation error{} detected: ",
766            errors.len(),
767            if errors.len() == 1 { "" } else { "s" }
768        );
769        return Err(DynoxideError::ValidationException(format!(
770            "{}{}",
771            prefix,
772            errors.join("; ")
773        )));
774    }
775
776    // Single-error validations (after multi-field)
777
778    // BillingMode enum validation
779    if let Some(ref bm) = request.billing_mode {
780        if bm != "PROVISIONED" && bm != "PAY_PER_REQUEST" {
781            return Err(DynoxideError::ValidationException(format!(
782                "1 validation error detected: Value '{}' at 'billingMode' \
783                 failed to satisfy constraint: Member must satisfy enum value set: \
784                 [PROVISIONED, PAY_PER_REQUEST]",
785                bm
786            )));
787        }
788    }
789
790    // TableClass enum validation (mirrors CreateTable)
791    if let Some(ref tc) = request.table_class {
792        if tc != "STANDARD" && tc != "STANDARD_INFREQUENT_ACCESS" {
793            return Err(DynoxideError::ValidationException(format!(
794                "1 validation error detected: Value '{tc}' at 'tableClass' failed to satisfy \
795                 constraint: Member must satisfy enum value set: \
796                 [STANDARD, STANDARD_INFREQUENT_ACCESS]"
797            )));
798        }
799    }
800
801    // BillingMode PAY_PER_REQUEST with ProvisionedThroughput is not allowed
802    if request.billing_mode.as_deref() == Some("PAY_PER_REQUEST")
803        && request.provisioned_throughput.is_some()
804    {
805        return Err(DynoxideError::ValidationException(
806            "One or more parameter values were invalid: \
807             Neither ReadCapacityUnits nor WriteCapacityUnits can be \
808             specified when BillingMode is PAY_PER_REQUEST"
809                .to_string(),
810        ));
811    }
812
813    // ProvisionedThroughput out-of-bounds
814    if let Some(ref pt) = request.provisioned_throughput {
815        if let Some(obj) = pt.as_object() {
816            let rcu = obj
817                .get("ReadCapacityUnits")
818                .and_then(|v| v.as_i64())
819                .unwrap_or(0);
820            let wcu = obj
821                .get("WriteCapacityUnits")
822                .and_then(|v| v.as_i64())
823                .unwrap_or(0);
824            const MAX_THROUGHPUT: i64 = 1_000_000_000_000;
825            if rcu > MAX_THROUGHPUT {
826                return Err(DynoxideError::ValidationException(format!(
827                    "Given value {} for ReadCapacityUnits is out of bounds",
828                    rcu
829                )));
830            }
831            if wcu > MAX_THROUGHPUT {
832                return Err(DynoxideError::ValidationException(format!(
833                    "Given value {} for WriteCapacityUnits is out of bounds",
834                    wcu
835                )));
836            }
837        }
838    }
839
840    // "At least one of ..." — a request must change something. A lone
841    // TableClass, OnDemandThroughput, or DeletionProtectionEnabled counts, the
842    // same as a throughput/billing/stream change. An empty
843    // GlobalSecondaryIndexUpdates array is treated as "no GSI change" rather
844    // than satisfying the requirement on its own.
845    let no_config_change = request.provisioned_throughput.is_none()
846        && request.billing_mode.is_none()
847        && request.stream_specification.is_none()
848        && request.deletion_protection_enabled.is_none()
849        && request.table_class.is_none()
850        && request.on_demand_throughput.is_none();
851    let no_gsi_change = request
852        .global_secondary_index_updates
853        .as_ref()
854        .is_none_or(|updates| updates.is_empty());
855    if no_gsi_change && no_config_change {
856        return Err(DynoxideError::ValidationException(
857            "At least one of ProvisionedThroughput, BillingMode, UpdateStreamEnabled, GlobalSecondaryIndexUpdates or SSESpecification or ReplicaUpdates is required".to_string(),
858        ));
859    }
860
861    // Validate GSI update structural constraints
862    if let Some(ref updates) = request.global_secondary_index_updates {
863        // Check empty index struct (no Update, Create, or Delete)
864        for update in updates {
865            if update.update.is_none() && update.create.is_none() && update.delete.is_none() {
866                return Err(DynoxideError::ValidationException(
867                    "One or more parameter values were invalid: One of GlobalSecondaryIndexUpdate.Update, GlobalSecondaryIndexUpdate.Create, GlobalSecondaryIndexUpdate.Delete must not be null".to_string(),
868                ));
869            }
870        }
871
872        // Check repeated index names
873        let mut seen_names = std::collections::HashSet::new();
874        for update in updates {
875            let name = if let Some(ref u) = update.update {
876                Some(u.index_name.as_str())
877            } else if let Some(ref c) = update.create {
878                Some(c.index_name.as_str())
879            } else {
880                update.delete.as_ref().map(|d| d.index_name.as_str())
881            };
882            if let Some(name) = name {
883                if !seen_names.insert(name.to_string()) {
884                    return Err(DynoxideError::ValidationException(format!(
885                        "One or more parameter values were invalid: Only one global secondary index update per index is allowed simultaneously. Index: {}",
886                        name
887                    )));
888                }
889            }
890        }
891    }
892
893    Ok(())
894}
895
896/// Extended provisioned throughput stored in metadata, including timestamps.
897#[derive(Debug, Clone, Default, Serialize, Deserialize)]
898struct StoredProvisionedThroughput {
899    #[serde(rename = "ReadCapacityUnits")]
900    read_capacity_units: i64,
901    #[serde(rename = "WriteCapacityUnits")]
902    write_capacity_units: i64,
903    #[serde(
904        rename = "LastIncreaseDateTime",
905        skip_serializing_if = "Option::is_none"
906    )]
907    last_increase_date_time: Option<f64>,
908    #[serde(
909        rename = "LastDecreaseDateTime",
910        skip_serializing_if = "Option::is_none"
911    )]
912    last_decrease_date_time: Option<f64>,
913    #[serde(
914        rename = "NumberOfDecreasesToday",
915        skip_serializing_if = "Option::is_none"
916    )]
917    number_of_decreases_today: Option<u64>,
918}
919
920/// Parse current provisioned throughput from table metadata.
921fn parse_current_throughput(meta: &crate::storage::TableMetadata) -> (i64, i64) {
922    parse_stored_throughput(meta)
923        .map(|pt| (pt.read_capacity_units, pt.write_capacity_units))
924        .unwrap_or((0, 0))
925}
926
927/// Parse the full stored provisioned throughput including timestamps.
928fn parse_stored_throughput(
929    meta: &crate::storage::TableMetadata,
930) -> Option<StoredProvisionedThroughput> {
931    meta.provisioned_throughput
932        .as_ref()
933        .and_then(|pt_json| serde_json::from_str(pt_json).ok())
934}
935
936/// Backfill existing items into a newly created GSI, processing in batches.
937async fn backfill_gsi<S: StorageBackend>(
938    storage: &S,
939    table_name: &str,
940    key_schema: &helpers::KeySchema,
941    gsi_def: &gsi::GsiDef,
942) -> Result<()> {
943    const BATCH_SIZE: usize = 1000;
944    let mut last_pk: Option<String> = None;
945    let mut last_sk: Option<String> = None;
946
947    loop {
948        let items = storage
949            .scan_items(
950                table_name,
951                &crate::storage::ScanParams {
952                    limit: Some(BATCH_SIZE),
953                    exclusive_start_pk: last_pk.as_deref(),
954                    exclusive_start_sk: last_sk.as_deref(),
955                    ..Default::default()
956                },
957            )
958            .await?;
959
960        if items.is_empty() {
961            break;
962        }
963
964        let mut rows: Vec<crate::storage_backend::GsiItemRow> = Vec::new();
965        for (pk, sk, item_json) in &items {
966            let item: crate::types::Item = serde_json::from_str(item_json)
967                .map_err(|e| DynoxideError::InternalServerError(format!("Bad item JSON: {e}")))?;
968
969            // Backfill only the items that belong in this index (sparse).
970            if let Some((gsi_pk, gsi_sk)) = gsi_def.index_key_strings(&item) {
971                let projected = gsi::build_index_item(
972                    &item,
973                    gsi_def,
974                    &key_schema.partition_key,
975                    key_schema.sort_key.as_deref(),
976                );
977                let projected_json = serde_json::to_string(&projected)
978                    .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
979
980                rows.push(crate::storage_backend::GsiItemRow {
981                    gsi_pk,
982                    gsi_sk,
983                    table_pk: pk.clone(),
984                    table_sk: sk.clone(),
985                    item_json: projected_json,
986                });
987            }
988        }
989
990        storage
991            .insert_gsi_items(table_name, &gsi_def.index_name, &rows)
992            .await?;
993
994        let last = &items[items.len() - 1];
995        last_pk = Some(last.0.clone());
996        last_sk = Some(last.1.clone());
997
998        if items.len() < BATCH_SIZE {
999            break;
1000        }
1001    }
1002
1003    Ok(())
1004}