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    request: UpdateTableRequest,
160) -> Result<UpdateTableResponse> {
161    // Table name validation is handled in the Deserialize impl
162
163    // Phase 1: Validate request parameters BEFORE table existence check
164    // (DynamoDB validates these first and returns ValidationException,
165    // not ResourceNotFoundException)
166    validate_update_request(&request)?;
167
168    // Phase 2: Table existence check
169    let meta = helpers::require_table(storage, &request.table_name).await?;
170
171    let current_billing_mode = meta.billing_mode.as_deref().unwrap_or("PROVISIONED");
172
173    // Phase 3: Post-table-existence validations
174
175    // PAY_PER_REQUEST table + ProvisionedThroughput update is not allowed
176    if current_billing_mode == "PAY_PER_REQUEST"
177        && request.billing_mode.is_none()
178        && request.provisioned_throughput.is_some()
179    {
180        return Err(DynoxideError::ValidationException(
181            "One or more parameter values were invalid: \
182             Neither ReadCapacityUnits nor WriteCapacityUnits can be \
183             specified when BillingMode is PAY_PER_REQUEST"
184                .to_string(),
185        ));
186    }
187
188    // BillingMode PROVISIONED without ProvisionedThroughput
189    if request.billing_mode.as_deref() == Some("PROVISIONED")
190        && request.provisioned_throughput.is_none()
191    {
192        return Err(DynoxideError::ValidationException(
193            "One or more parameter values were invalid: \
194             ProvisionedThroughput must be specified when BillingMode is PROVISIONED"
195                .to_string(),
196        ));
197    }
198
199    // Same read/write values check
200    if let Some(ref pt) = request.provisioned_throughput {
201        if let Some(obj) = pt.as_object() {
202            let new_rcu = obj
203                .get("ReadCapacityUnits")
204                .and_then(|v| v.as_i64())
205                .unwrap_or(0);
206            let new_wcu = obj
207                .get("WriteCapacityUnits")
208                .and_then(|v| v.as_i64())
209                .unwrap_or(0);
210
211            // Parse current provisioned throughput from metadata
212            let (cur_rcu, cur_wcu) = parse_current_throughput(&meta);
213
214            let billing_mode_unchanged = request.billing_mode.is_none()
215                || (request.billing_mode.as_deref() == Some("PROVISIONED")
216                    && current_billing_mode == "PROVISIONED");
217
218            if new_rcu == cur_rcu && new_wcu == cur_wcu && billing_mode_unchanged {
219                return Err(DynoxideError::ValidationException(format!(
220                    "The provisioned throughput for the table will not change. \
221                     The requested value equals the current value. \
222                     Current ReadCapacityUnits provisioned for the table: {}. \
223                     Requested ReadCapacityUnits: {}. \
224                     Current WriteCapacityUnits provisioned for the table: {}. \
225                     Requested WriteCapacityUnits: {}. \
226                     Refer to the Amazon DynamoDB Developer Guide for current limits \
227                     and how to request higher limits.",
228                    cur_rcu, new_rcu, cur_wcu, new_wcu
229                )));
230            }
231        }
232    }
233
234    // Parse existing GSI definitions
235    let mut current_gsis: Vec<GlobalSecondaryIndex> = meta
236        .gsi_definitions
237        .as_ref()
238        .map(|json| serde_json::from_str(json))
239        .transpose()
240        .map_err(|e| DynoxideError::InternalServerError(format!("Bad GSI JSON: {e}")))?
241        .unwrap_or_default();
242
243    // GSI Update with high capacity on non-existent index
244    if let Some(ref updates) = request.global_secondary_index_updates {
245        for update in updates {
246            if let Some(ref upd) = update.update {
247                if !current_gsis.iter().any(|g| g.index_name == upd.index_name) {
248                    // DynamoDB returns this specific message for GSI updates on
249                    // non-existent indexes (even with out-of-bounds capacity)
250                    return Err(DynoxideError::ValidationException(
251                        "This operation cannot be performed with given input values. \
252                         Please contact DynamoDB service team for more info: \
253                         Action Blocked: IndexUpdate"
254                            .to_string(),
255                    ));
256                }
257            }
258        }
259    }
260
261    // Check GSI update count limit (DynamoDB allows at most 5 per request)
262    if let Some(ref updates) = request.global_secondary_index_updates {
263        if updates.len() > 5 {
264            return Err(DynoxideError::LimitExceededException(
265                "Subscriber limit exceeded: Only 1 online index can be created or \
266                 deleted simultaneously per table"
267                    .to_string(),
268            ));
269        }
270    }
271
272    // Merge provided attribute definitions into the existing set. DynamoDB
273    // treats UpdateTable's AttributeDefinitions as a delta: adding a GSI only
274    // requires the new index's key attributes, and the existing definitions
275    // (table keys, prior GSI keys) are preserved. Replacing them outright would
276    // drop attributes still referenced by the key schema and other indexes.
277    let mut attr_defs: Vec<AttributeDefinition> = serde_json::from_str(&meta.attribute_definitions)
278        .map_err(|e| DynoxideError::InternalServerError(format!("Bad attr defs JSON: {e}")))?;
279
280    if let Some(ref provided) = request.attribute_definitions {
281        for def in provided {
282            // An already-declared attribute keeps its existing type. Real
283            // DynamoDB ignores a redeclaration (even one carrying a different
284            // type) rather than overwriting or rejecting it, so only genuinely
285            // new attributes are appended. Verified against AWS in eu-west-2.
286            if !attr_defs
287                .iter()
288                .any(|d| d.attribute_name == def.attribute_name)
289            {
290                attr_defs.push(def.clone());
291            }
292        }
293    }
294
295    // Parse table key schema for backfill
296    let key_schema = helpers::parse_key_schema(&meta)?;
297
298    // Validate all GSI updates before making any changes
299    if let Some(ref updates) = request.global_secondary_index_updates {
300        for update in updates {
301            if let Some(ref create) = update.create {
302                if current_gsis
303                    .iter()
304                    .any(|g| g.index_name == create.index_name)
305                {
306                    return Err(DynoxideError::ValidationException(format!(
307                        "One or more parameter values were invalid: \
308                         Index already exists: {}",
309                        create.index_name
310                    )));
311                }
312                let gsi_def = GlobalSecondaryIndex {
313                    index_name: create.index_name.clone(),
314                    key_schema: create.key_schema.clone(),
315                    projection: create.projection.clone(),
316                    provisioned_throughput: None,
317                };
318                validation::validate_gsi(&gsi_def, &attr_defs)?;
319            }
320            if let Some(ref delete) = update.delete {
321                if !current_gsis
322                    .iter()
323                    .any(|g| g.index_name == delete.index_name)
324                {
325                    return Err(DynoxideError::ResourceNotFoundException(format!(
326                        "Requested resource not found: Table: {} not found",
327                        delete.index_name
328                    )));
329                }
330            }
331        }
332    }
333
334    // Determine if this is a throughput increase or decrease.
335    // Ensure timestamps strictly increase across successive updates
336    // (the dynalite test expects LastDecreaseDateTime > LastIncreaseDateTime).
337    let now = {
338        use std::sync::atomic::{AtomicU64, Ordering};
339        static LAST_TS: AtomicU64 = AtomicU64::new(0);
340        let wall = web_time::SystemTime::now()
341            .duration_since(web_time::UNIX_EPOCH)
342            .unwrap_or_default()
343            .as_secs_f64();
344        loop {
345            let prev_bits = LAST_TS.load(Ordering::SeqCst);
346            let prev_f = f64::from_bits(prev_bits);
347            let candidate = if wall > prev_f { wall } else { prev_f + 0.001 };
348            let candidate_bits = candidate.to_bits();
349            if LAST_TS
350                .compare_exchange(
351                    prev_bits,
352                    candidate_bits,
353                    Ordering::SeqCst,
354                    Ordering::SeqCst,
355                )
356                .is_ok()
357            {
358                break candidate;
359            }
360        }
361    };
362
363    let (cur_rcu, cur_wcu) = parse_current_throughput(&meta);
364    let is_pt_update = request.provisioned_throughput.is_some();
365    let (new_rcu, new_wcu) = if let Some(ref pt) = request.provisioned_throughput {
366        let obj = pt.as_object();
367        (
368            obj.and_then(|o| o.get("ReadCapacityUnits"))
369                .and_then(|v| v.as_i64())
370                .unwrap_or(0),
371            obj.and_then(|o| o.get("WriteCapacityUnits"))
372                .and_then(|v| v.as_i64())
373                .unwrap_or(0),
374        )
375    } else {
376        (cur_rcu, cur_wcu)
377    };
378
379    let is_increase = new_rcu > cur_rcu || new_wcu > cur_wcu;
380    let is_decrease = new_rcu < cur_rcu || new_wcu < cur_wcu;
381
382    // All validation passed; perform mutations inside a single transaction.
383    helpers::with_write_transaction(storage, async {
384        if let Some(ref updates) = request.global_secondary_index_updates {
385            for update in updates {
386                if let Some(ref create) = update.create {
387                    let gsi_def = GlobalSecondaryIndex {
388                        index_name: create.index_name.clone(),
389                        key_schema: create.key_schema.clone(),
390                        projection: create.projection.clone(),
391                        provisioned_throughput: None,
392                    };
393
394                    storage
395                        .create_gsi_table(&request.table_name, &create.index_name)
396                        .await?;
397
398                    let gsi_p = gsi::gsi_to_def(&gsi_def)?;
399                    backfill_gsi(storage, &request.table_name, &key_schema, &gsi_p).await?;
400
401                    current_gsis.push(gsi_def);
402                }
403
404                if let Some(ref delete) = update.delete {
405                    storage
406                        .drop_gsi_table(&request.table_name, &delete.index_name)
407                        .await?;
408                    current_gsis.retain(|g| g.index_name != delete.index_name);
409                }
410            }
411        }
412
413        // Reconcile AttributeDefinitions to exactly the attributes still
414        // referenced by the table key schema and surviving index key schemas.
415        // See reconcile_attribute_definitions for the AWS-verified rules.
416        let lsi_defs = crate::actions::lsi::parse_lsi_defs(&meta)?;
417        reconcile_attribute_definitions(&mut attr_defs, &key_schema, &current_gsis, &lsi_defs);
418
419        // Update metadata
420        let attr_defs_json = serde_json::to_string(&attr_defs)
421            .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
422        let gsi_json = if current_gsis.is_empty() {
423            None
424        } else {
425            Some(
426                serde_json::to_string(&current_gsis)
427                    .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?,
428            )
429        };
430
431        storage
432            .update_table_metadata(&request.table_name, &attr_defs_json, gsi_json.as_deref())
433            .await?;
434
435        // Update provisioned throughput if requested
436        if is_pt_update {
437            let prev = parse_stored_throughput(&meta);
438            let mut stored = StoredProvisionedThroughput {
439                read_capacity_units: new_rcu,
440                write_capacity_units: new_wcu,
441                last_increase_date_time: prev.as_ref().and_then(|p| p.last_increase_date_time),
442                last_decrease_date_time: prev.as_ref().and_then(|p| p.last_decrease_date_time),
443                number_of_decreases_today: prev
444                    .as_ref()
445                    .and_then(|p| p.number_of_decreases_today)
446                    .or(Some(0)),
447            };
448            if is_increase {
449                stored.last_increase_date_time = Some(now);
450            }
451            if is_decrease {
452                stored.last_decrease_date_time = Some(now);
453                stored.number_of_decreases_today =
454                    Some(stored.number_of_decreases_today.unwrap_or(0) + 1);
455            }
456            let pt_json = serde_json::to_string(&stored)
457                .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
458            storage
459                .update_provisioned_throughput(&request.table_name, &pt_json)
460                .await?;
461        }
462
463        // Handle deletion protection changes
464        if let Some(enabled) = request.deletion_protection_enabled {
465            storage
466                .update_deletion_protection(&request.table_name, enabled)
467                .await?;
468        }
469
470        // Handle table class changes
471        if let Some(ref table_class) = request.table_class {
472            storage
473                .update_table_class(&request.table_name, table_class)
474                .await?;
475        }
476
477        // Handle on-demand throughput changes
478        if let Some(ref on_demand) = request.on_demand_throughput {
479            let json = serde_json::to_string(on_demand)
480                .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
481            storage
482                .update_on_demand_throughput(&request.table_name, &json)
483                .await?;
484        }
485
486        // Handle billing mode changes
487        if let Some(ref billing_mode) = request.billing_mode {
488            storage
489                .update_billing_mode(&request.table_name, billing_mode)
490                .await?;
491            if billing_mode == "PAY_PER_REQUEST" {
492                // Clear provisioned throughput to avoid stale data
493                storage
494                    .clear_provisioned_throughput(&request.table_name)
495                    .await?;
496            }
497        }
498
499        // Handle stream specification changes
500        if let Some(ref spec) = request.stream_specification {
501            if spec.stream_enabled {
502                let view_type = spec
503                    .stream_view_type
504                    .as_deref()
505                    .unwrap_or("NEW_AND_OLD_IMAGES");
506                let label = streams::generate_stream_label(storage.clock());
507                storage
508                    .enable_stream(&request.table_name, view_type, &label)
509                    .await?;
510            } else {
511                storage.disable_stream(&request.table_name).await?;
512            }
513        }
514
515        Ok(())
516    })
517    .await?;
518
519    // Build response from updated metadata
520    let updated_meta = helpers::require_table(storage, &request.table_name).await?;
521    let mut desc = build_table_description(&updated_meta, Some(0), Some(0));
522
523    // DynamoDB returns UPDATING status during throughput changes
524    if is_pt_update {
525        desc.table_status = "UPDATING".to_string();
526
527        // The immediate response shows the OLD throughput values while the
528        // table is in UPDATING status, but with updated timestamps.
529        let stored = parse_stored_throughput(&updated_meta);
530        if let Some(ref mut pt) = desc.provisioned_throughput {
531            pt.read_capacity_units = cur_rcu as u64;
532            pt.write_capacity_units = cur_wcu as u64;
533            if let Some(ref s) = stored {
534                pt.last_increase_date_time = s.last_increase_date_time;
535                pt.last_decrease_date_time = s.last_decrease_date_time;
536                pt.number_of_decreases_today = s.number_of_decreases_today.unwrap_or(0);
537            }
538        }
539    }
540
541    Ok(UpdateTableResponse {
542        table_description: desc,
543    })
544}
545
546/// Reconcile `attr_defs` to exactly the attributes referenced by the table key
547/// schema plus every surviving index key schema. Real DynamoDB keeps the two in
548/// lockstep: an attribute orphaned by a GSI delete is pruned, and an entry used
549/// by no key schema is dropped rather than stored (neither is an error).
550/// Verified against AWS in eu-west-2.
551fn reconcile_attribute_definitions(
552    attr_defs: &mut Vec<AttributeDefinition>,
553    key_schema: &helpers::KeySchema,
554    gsis: &[GlobalSecondaryIndex],
555    lsi_defs: &[crate::actions::lsi::LsiDef],
556) {
557    let mut used: std::collections::HashSet<&str> = std::collections::HashSet::new();
558    used.insert(key_schema.partition_key.as_str());
559    if let Some(ref sk) = key_schema.sort_key {
560        used.insert(sk.as_str());
561    }
562    for g in gsis {
563        for k in &g.key_schema {
564            used.insert(k.attribute_name.as_str());
565        }
566    }
567    for lsi in lsi_defs {
568        used.insert(lsi.pk_attr.as_str());
569        if let Some(ref sk) = lsi.sk_attr {
570            used.insert(sk.as_str());
571        }
572    }
573    attr_defs.retain(|d| used.contains(d.attribute_name.as_str()));
574}
575
576/// Validate UpdateTable request parameters before checking table existence.
577///
578/// DynamoDB validates these parameters first and returns ValidationException
579/// rather than ResourceNotFoundException when both are invalid.
580fn validate_update_request(request: &UpdateTableRequest) -> Result<()> {
581    // Multi-field constraint errors
582    let mut errors = Vec::new();
583
584    // Validate ProvisionedThroughput fields
585    if let Some(ref pt) = request.provisioned_throughput {
586        if let Some(obj) = pt.as_object() {
587            let wcu = obj.get("WriteCapacityUnits");
588            let rcu = obj.get("ReadCapacityUnits");
589            if wcu.is_none() || wcu == Some(&serde_json::Value::Null) {
590                errors.push("Value null at 'provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must not be null".to_string());
591            } else if let Some(w) = wcu.and_then(|v| v.as_i64()) {
592                if w < 1 {
593                    errors.push(format!("Value '{}' at 'provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must have value greater than or equal to 1", w));
594                }
595            }
596            if rcu.is_none() || rcu == Some(&serde_json::Value::Null) {
597                errors.push("Value null at 'provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must not be null".to_string());
598            } else if let Some(r) = rcu.and_then(|v| v.as_i64()) {
599                if r < 1 {
600                    errors.push(format!("Value '{}' at 'provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must have value greater than or equal to 1", r));
601                }
602            }
603        }
604    }
605
606    // Validate GlobalSecondaryIndexUpdates fields
607    if let Some(ref updates) = request.global_secondary_index_updates {
608        for (i, update) in updates.iter().enumerate() {
609            if let Some(ref upd) = update.update {
610                // Validate Update.IndexName
611                if upd.index_name.len() < 3 {
612                    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));
613                }
614                if !upd.index_name.is_empty()
615                    && !upd
616                        .index_name
617                        .chars()
618                        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
619                {
620                    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));
621                }
622                // Validate Update.ProvisionedThroughput
623                if let Some(ref pt) = upd.provisioned_throughput {
624                    let wcu = pt.write_capacity_units;
625                    let rcu = pt.read_capacity_units;
626                    if wcu.is_none() {
627                        errors.push(format!("Value null at 'globalSecondaryIndexUpdates.{}.member.update.provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must not be null", i + 1));
628                    } else if let Some(w) = wcu {
629                        if w < 1 {
630                            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));
631                        }
632                    }
633                    if rcu.is_none() {
634                        errors.push(format!("Value null at 'globalSecondaryIndexUpdates.{}.member.update.provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must not be null", i + 1));
635                    } else if let Some(r) = rcu {
636                        if r < 1 {
637                            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));
638                        }
639                    }
640                } else {
641                    errors.push(format!("Value null at 'globalSecondaryIndexUpdates.{}.member.update.provisionedThroughput' failed to satisfy constraint: Member must not be null", i + 1));
642                }
643            }
644        }
645    }
646
647    // Cap at 10 errors
648    errors.truncate(10);
649
650    if !errors.is_empty() {
651        let prefix = format!(
652            "{} validation error{} detected: ",
653            errors.len(),
654            if errors.len() == 1 { "" } else { "s" }
655        );
656        return Err(DynoxideError::ValidationException(format!(
657            "{}{}",
658            prefix,
659            errors.join("; ")
660        )));
661    }
662
663    // Single-error validations (after multi-field)
664
665    // BillingMode enum validation
666    if let Some(ref bm) = request.billing_mode {
667        if bm != "PROVISIONED" && bm != "PAY_PER_REQUEST" {
668            return Err(DynoxideError::ValidationException(format!(
669                "1 validation error detected: Value '{}' at 'billingMode' \
670                 failed to satisfy constraint: Member must satisfy enum value set: \
671                 [PROVISIONED, PAY_PER_REQUEST]",
672                bm
673            )));
674        }
675    }
676
677    // TableClass enum validation (mirrors CreateTable)
678    if let Some(ref tc) = request.table_class {
679        if tc != "STANDARD" && tc != "STANDARD_INFREQUENT_ACCESS" {
680            return Err(DynoxideError::ValidationException(format!(
681                "1 validation error detected: Value '{tc}' at 'tableClass' failed to satisfy \
682                 constraint: Member must satisfy enum value set: \
683                 [STANDARD, STANDARD_INFREQUENT_ACCESS]"
684            )));
685        }
686    }
687
688    // BillingMode PAY_PER_REQUEST with ProvisionedThroughput is not allowed
689    if request.billing_mode.as_deref() == Some("PAY_PER_REQUEST")
690        && request.provisioned_throughput.is_some()
691    {
692        return Err(DynoxideError::ValidationException(
693            "One or more parameter values were invalid: \
694             Neither ReadCapacityUnits nor WriteCapacityUnits can be \
695             specified when BillingMode is PAY_PER_REQUEST"
696                .to_string(),
697        ));
698    }
699
700    // ProvisionedThroughput out-of-bounds
701    if let Some(ref pt) = request.provisioned_throughput {
702        if let Some(obj) = pt.as_object() {
703            let rcu = obj
704                .get("ReadCapacityUnits")
705                .and_then(|v| v.as_i64())
706                .unwrap_or(0);
707            let wcu = obj
708                .get("WriteCapacityUnits")
709                .and_then(|v| v.as_i64())
710                .unwrap_or(0);
711            const MAX_THROUGHPUT: i64 = 1_000_000_000_000;
712            if rcu > MAX_THROUGHPUT {
713                return Err(DynoxideError::ValidationException(format!(
714                    "Given value {} for ReadCapacityUnits is out of bounds",
715                    rcu
716                )));
717            }
718            if wcu > MAX_THROUGHPUT {
719                return Err(DynoxideError::ValidationException(format!(
720                    "Given value {} for WriteCapacityUnits is out of bounds",
721                    wcu
722                )));
723            }
724        }
725    }
726
727    // "At least one of ..." — a request must change something. A lone
728    // TableClass, OnDemandThroughput, or DeletionProtectionEnabled counts, the
729    // same as a throughput/billing/stream change. An empty
730    // GlobalSecondaryIndexUpdates array is treated as "no GSI change" rather
731    // than satisfying the requirement on its own.
732    let no_config_change = request.provisioned_throughput.is_none()
733        && request.billing_mode.is_none()
734        && request.stream_specification.is_none()
735        && request.deletion_protection_enabled.is_none()
736        && request.table_class.is_none()
737        && request.on_demand_throughput.is_none();
738    let no_gsi_change = request
739        .global_secondary_index_updates
740        .as_ref()
741        .is_none_or(|updates| updates.is_empty());
742    if no_gsi_change && no_config_change {
743        return Err(DynoxideError::ValidationException(
744            "At least one of ProvisionedThroughput, BillingMode, UpdateStreamEnabled, GlobalSecondaryIndexUpdates or SSESpecification or ReplicaUpdates is required".to_string(),
745        ));
746    }
747
748    // Validate GSI update structural constraints
749    if let Some(ref updates) = request.global_secondary_index_updates {
750        // Check empty index struct (no Update, Create, or Delete)
751        for update in updates {
752            if update.update.is_none() && update.create.is_none() && update.delete.is_none() {
753                return Err(DynoxideError::ValidationException(
754                    "One or more parameter values were invalid: One of GlobalSecondaryIndexUpdate.Update, GlobalSecondaryIndexUpdate.Create, GlobalSecondaryIndexUpdate.Delete must not be null".to_string(),
755                ));
756            }
757        }
758
759        // Check repeated index names
760        let mut seen_names = std::collections::HashSet::new();
761        for update in updates {
762            let name = if let Some(ref u) = update.update {
763                Some(u.index_name.as_str())
764            } else if let Some(ref c) = update.create {
765                Some(c.index_name.as_str())
766            } else {
767                update.delete.as_ref().map(|d| d.index_name.as_str())
768            };
769            if let Some(name) = name {
770                if !seen_names.insert(name.to_string()) {
771                    return Err(DynoxideError::ValidationException(format!(
772                        "One or more parameter values were invalid: Only one global secondary index update per index is allowed simultaneously. Index: {}",
773                        name
774                    )));
775                }
776            }
777        }
778    }
779
780    Ok(())
781}
782
783/// Extended provisioned throughput stored in metadata, including timestamps.
784#[derive(Debug, Clone, Default, Serialize, Deserialize)]
785struct StoredProvisionedThroughput {
786    #[serde(rename = "ReadCapacityUnits")]
787    read_capacity_units: i64,
788    #[serde(rename = "WriteCapacityUnits")]
789    write_capacity_units: i64,
790    #[serde(
791        rename = "LastIncreaseDateTime",
792        skip_serializing_if = "Option::is_none"
793    )]
794    last_increase_date_time: Option<f64>,
795    #[serde(
796        rename = "LastDecreaseDateTime",
797        skip_serializing_if = "Option::is_none"
798    )]
799    last_decrease_date_time: Option<f64>,
800    #[serde(
801        rename = "NumberOfDecreasesToday",
802        skip_serializing_if = "Option::is_none"
803    )]
804    number_of_decreases_today: Option<u64>,
805}
806
807/// Parse current provisioned throughput from table metadata.
808fn parse_current_throughput(meta: &crate::storage::TableMetadata) -> (i64, i64) {
809    parse_stored_throughput(meta)
810        .map(|pt| (pt.read_capacity_units, pt.write_capacity_units))
811        .unwrap_or((0, 0))
812}
813
814/// Parse the full stored provisioned throughput including timestamps.
815fn parse_stored_throughput(
816    meta: &crate::storage::TableMetadata,
817) -> Option<StoredProvisionedThroughput> {
818    meta.provisioned_throughput
819        .as_ref()
820        .and_then(|pt_json| serde_json::from_str(pt_json).ok())
821}
822
823/// Backfill existing items into a newly created GSI, processing in batches.
824async fn backfill_gsi<S: StorageBackend>(
825    storage: &S,
826    table_name: &str,
827    key_schema: &helpers::KeySchema,
828    gsi_def: &gsi::GsiDef,
829) -> Result<()> {
830    const BATCH_SIZE: usize = 1000;
831    let mut last_pk: Option<String> = None;
832    let mut last_sk: Option<String> = None;
833
834    loop {
835        let items = storage
836            .scan_items(
837                table_name,
838                &crate::storage::ScanParams {
839                    limit: Some(BATCH_SIZE),
840                    exclusive_start_pk: last_pk.as_deref(),
841                    exclusive_start_sk: last_sk.as_deref(),
842                    ..Default::default()
843                },
844            )
845            .await?;
846
847        if items.is_empty() {
848            break;
849        }
850
851        let mut rows: Vec<crate::storage_backend::GsiItemRow> = Vec::new();
852        for (pk, sk, item_json) in &items {
853            let item: crate::types::Item = serde_json::from_str(item_json)
854                .map_err(|e| DynoxideError::InternalServerError(format!("Bad item JSON: {e}")))?;
855
856            // Backfill only the items that belong in this index (sparse).
857            if let Some((gsi_pk, gsi_sk)) = gsi_def.index_key_strings(&item) {
858                let projected = gsi::build_index_item(
859                    &item,
860                    gsi_def,
861                    &key_schema.partition_key,
862                    key_schema.sort_key.as_deref(),
863                );
864                let projected_json = serde_json::to_string(&projected)
865                    .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
866
867                rows.push(crate::storage_backend::GsiItemRow {
868                    gsi_pk,
869                    gsi_sk,
870                    table_pk: pk.clone(),
871                    table_sk: sk.clone(),
872                    item_json: projected_json,
873                });
874            }
875        }
876
877        storage
878            .insert_gsi_items(table_name, &gsi_def.index_name, &rows)
879            .await?;
880
881        let last = &items[items.len() - 1];
882        last_pk = Some(last.0.clone());
883        last_sk = Some(last.1.clone());
884
885        if items.len() < BATCH_SIZE {
886            break;
887        }
888    }
889
890    Ok(())
891}