Skip to main content

dynoxide/actions/
update_table.rs

1use crate::actions::create_table::StreamSpecification;
2use crate::actions::vector_lifecycle::VectorIndexLifecycle;
3use crate::actions::{TableDescription, build_table_description};
4use crate::actions::{gsi, helpers};
5use crate::errors::{DynoxideError, Result};
6use crate::storage_backend::StorageBackend;
7use crate::streams;
8use crate::types::{
9    AttributeDefinition, GlobalSecondaryIndex, Item, KeySchemaElement, Projection, VectorIndex,
10};
11use crate::validation;
12use serde::{Deserialize, Serialize};
13
14/// Internal raw deserialization struct.
15#[derive(Debug, Default, Deserialize)]
16struct UpdateTableRequestRaw {
17    #[serde(rename = "TableName", default)]
18    table_name: Option<String>,
19
20    #[serde(rename = "AttributeDefinitions", default)]
21    attribute_definitions: Option<Vec<AttributeDefinition>>,
22
23    #[serde(rename = "GlobalSecondaryIndexUpdates", default)]
24    global_secondary_index_updates: Option<Vec<GlobalSecondaryIndexUpdate>>,
25
26    // Raw JSON so the create action's Dimensions can be normalised (fractional
27    // values truncate, over-range values clamp) before the typed parse, the
28    // same as the CreateTable path.
29    #[serde(rename = "VectorIndexUpdates", default)]
30    vector_index_updates: Option<serde_json::Value>,
31
32    #[serde(rename = "StreamSpecification", default)]
33    stream_specification: Option<StreamSpecification>,
34
35    #[serde(rename = "DeletionProtectionEnabled", default)]
36    deletion_protection_enabled: Option<bool>,
37
38    #[serde(rename = "ProvisionedThroughput", default)]
39    provisioned_throughput: Option<serde_json::Value>,
40
41    #[serde(rename = "BillingMode", default)]
42    billing_mode: Option<String>,
43
44    #[serde(rename = "TableClass", default)]
45    table_class: Option<String>,
46
47    #[serde(rename = "OnDemandThroughput", default)]
48    on_demand_throughput: Option<crate::types::OnDemandThroughput>,
49}
50
51#[derive(Debug, Default)]
52pub struct UpdateTableRequest {
53    pub table_name: String,
54    pub attribute_definitions: Option<Vec<AttributeDefinition>>,
55    pub global_secondary_index_updates: Option<Vec<GlobalSecondaryIndexUpdate>>,
56    pub vector_index_updates: Option<Vec<VectorIndexUpdate>>,
57    pub stream_specification: Option<StreamSpecification>,
58    pub deletion_protection_enabled: Option<bool>,
59    pub provisioned_throughput: Option<serde_json::Value>,
60    pub billing_mode: Option<String>,
61    pub table_class: Option<String>,
62    pub on_demand_throughput: Option<crate::types::OnDemandThroughput>,
63}
64
65impl<'de> serde::Deserialize<'de> for UpdateTableRequest {
66    fn deserialize<D: serde::Deserializer<'de>>(
67        deserializer: D,
68    ) -> std::result::Result<Self, D::Error> {
69        let raw = UpdateTableRequestRaw::deserialize(deserializer)?;
70
71        // Phase 1: Check TableName missing
72        if raw.table_name.is_none() || raw.table_name.as_deref() == Some("") {
73            let msg = if raw.table_name.is_none() {
74                "The parameter 'TableName' is required but was not present in the request"
75            } else {
76                "TableName must be at least 3 characters long and at most 255 characters long"
77            };
78            return Err(serde::de::Error::custom(format!("VALIDATION:{}", msg)));
79        }
80
81        let table_name = raw.table_name.unwrap_or_default();
82
83        // Phase 2: Check TableName length
84        if table_name.len() < 3 || table_name.len() > 255 {
85            return Err(serde::de::Error::custom(
86                "VALIDATION:TableName must be at least 3 characters long and at most 255 characters long",
87            ));
88        }
89
90        // Phase 3: Multi-field constraint validation
91        let mut errors = Vec::new();
92
93        if !table_name
94            .chars()
95            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
96        {
97            errors.push(format!(
98                "Value '{}' at 'tableName' failed to satisfy constraint: \
99                 Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
100                table_name
101            ));
102        }
103
104        if let Some(msg) = crate::validation::format_validation_errors(&errors) {
105            return Err(serde::de::Error::custom(format!("VALIDATION:{}", msg)));
106        }
107
108        // Request-model constraint errors for the raw vector create actions,
109        // enveloped like CreateTable's collectors rather than surfacing raw
110        // serde errors. Real DynamoDB reports one envelope across index
111        // families, GSI entries before vector entries (captured eu-west-2 and
112        // us-east-1, 2026-08-12), so the GSI Update-action errors join in
113        // front here. The top-level ProvisionedThroughput family stays in the
114        // operation-layer envelope in validate_update_request, so a request
115        // invalid there and here gets this envelope alone; whether real
116        // DynamoDB folds that family in too is uncaptured.
117        if let Some(ref vix_val) = raw.vector_index_updates {
118            let mut vix_errors = Vec::new();
119            collect_vix_update_errors(vix_val, &mut vix_errors);
120            if !vix_errors.is_empty() {
121                let mut errors = Vec::new();
122                if let Some(ref gsi_updates) = raw.global_secondary_index_updates {
123                    collect_gsi_update_errors(gsi_updates, &mut errors);
124                }
125                errors.append(&mut vix_errors);
126                errors.truncate(10);
127                let msg = format!(
128                    "{} validation error{} detected: {}",
129                    errors.len(),
130                    if errors.len() == 1 { "" } else { "s" },
131                    errors.join("; ")
132                );
133                return Err(serde::de::Error::custom(format!("VALIDATION:{}", msg)));
134            }
135        }
136
137        let vector_index_updates = raw
138            .vector_index_updates
139            .as_ref()
140            .map(parse_vector_index_updates)
141            .transpose()
142            .map_err(serde::de::Error::custom)?;
143
144        Ok(UpdateTableRequest {
145            table_name,
146            attribute_definitions: raw.attribute_definitions,
147            global_secondary_index_updates: raw.global_secondary_index_updates,
148            vector_index_updates,
149            stream_specification: raw.stream_specification,
150            deletion_protection_enabled: raw.deletion_protection_enabled,
151            provisioned_throughput: raw.provisioned_throughput,
152            billing_mode: raw.billing_mode,
153            table_class: raw.table_class,
154            on_demand_throughput: raw.on_demand_throughput,
155        })
156    }
157}
158
159#[derive(Debug, Default, Deserialize)]
160pub struct GlobalSecondaryIndexUpdate {
161    #[serde(rename = "Update", default)]
162    pub update: Option<UpdateGsiAction>,
163
164    #[serde(rename = "Create", default)]
165    pub create: Option<CreateGsiAction>,
166
167    #[serde(rename = "Delete", default)]
168    pub delete: Option<DeleteGsiAction>,
169}
170
171#[derive(Debug, Default, Deserialize)]
172pub struct UpdateGsiAction {
173    #[serde(rename = "IndexName")]
174    pub index_name: String,
175
176    #[serde(rename = "ProvisionedThroughput", default)]
177    pub provisioned_throughput: Option<crate::types::ProvisionedThroughput>,
178}
179
180#[derive(Debug, Default, Deserialize)]
181pub struct CreateGsiAction {
182    #[serde(rename = "IndexName")]
183    pub index_name: String,
184
185    #[serde(rename = "KeySchema")]
186    pub key_schema: Vec<KeySchemaElement>,
187
188    #[serde(rename = "Projection")]
189    pub projection: Projection,
190}
191
192#[derive(Debug, Default, Deserialize)]
193pub struct DeleteGsiAction {
194    #[serde(rename = "IndexName")]
195    pub index_name: String,
196}
197
198/// One `VectorIndexUpdates` entry, modelled on [`GlobalSecondaryIndexUpdate`].
199/// Only Create and Delete actions exist: a vector index's configuration is
200/// immutable, so there is no Update action (captured from real DynamoDB,
201/// eu-west-2, 2026-08-11).
202#[derive(Debug, Default)]
203pub struct VectorIndexUpdate {
204    /// The create action carries a full vector index definition, the same
205    /// shape as a CreateTable `VectorIndexes` entry.
206    pub create: Option<VectorIndex>,
207    pub delete: Option<DeleteVectorIndexAction>,
208}
209
210#[derive(Debug, Default, Deserialize)]
211pub struct DeleteVectorIndexAction {
212    #[serde(rename = "IndexName")]
213    pub index_name: String,
214}
215
216/// Request-model constraint errors for `VectorIndexUpdates` create actions,
217/// mirroring CreateTable's `collect_vix_errors` with this operation's field
218/// paths: `vectorIndexUpdates.N.member.create.<field>` (captured from real
219/// DynamoDB, eu-west-2 and us-east-1, 2026-08-12).
220fn collect_vix_update_errors(val: &serde_json::Value, errors: &mut Vec<String>) {
221    let Some(arr) = val.as_array() else {
222        return;
223    };
224    for (i, elem) in arr.iter().enumerate().take(10) {
225        let Some(obj) = elem.as_object() else {
226            continue;
227        };
228        if let Some(create) = obj.get("Create").and_then(|v| v.as_object()) {
229            crate::actions::create_table::collect_vix_obj_errors(
230                create,
231                &format!("vectorIndexUpdates.{}.member.create", i + 1),
232                errors,
233            );
234        }
235    }
236}
237
238/// Run the `VectorIndexUpdates` request-model constraints and format the
239/// envelope, for a caller that does not arrive through the raw request's
240/// `Deserialize`. Feed it the canonical wire spelling; see the CreateTable
241/// sibling for why.
242#[cfg(feature = "mcp-server")]
243pub(crate) fn vector_index_updates_request_model_error(val: &serde_json::Value) -> Option<String> {
244    let mut errors = Vec::new();
245    collect_vix_update_errors(val, &mut errors);
246    errors.truncate(10);
247    if errors.is_empty() {
248        return None;
249    }
250    Some(format!(
251        "{} validation error{} detected: {}",
252        errors.len(),
253        if errors.len() == 1 { "" } else { "s" },
254        errors.join("; ")
255    ))
256}
257
258/// Parse the raw `VectorIndexUpdates` JSON into typed updates, normalising
259/// each create action's `Dimensions` the way the CreateTable path does so a
260/// fractional or over-range value cannot fail the typed `u32` parse.
261///
262/// Shared with the MCP surface, so an agent's update is parsed by the same code
263/// as a wire client's and the two cannot disagree about what a create action
264/// accepts.
265pub(crate) fn parse_vector_index_updates(
266    val: &serde_json::Value,
267) -> std::result::Result<Vec<VectorIndexUpdate>, String> {
268    let arr = val
269        .as_array()
270        .ok_or_else(|| "Unexpected field type".to_string())?;
271    let mut out = Vec::with_capacity(arr.len());
272    for elem in arr {
273        let obj = elem
274            .as_object()
275            .ok_or_else(|| "Unexpected value type in payload".to_string())?;
276        let mut update = VectorIndexUpdate::default();
277        // Wire member names are case sensitive, and every guard around this
278        // parser keys on the wire spelling: the request-model collector and the
279        // pre-deserialisation type checks both read `Create` and `Delete`. A
280        // second spelling accepted here would reach neither, so a lowercase
281        // action would apply with nothing validating it. A caller that speaks
282        // another casing canonicalises before calling in.
283        if let Some(create) = obj.get("Create").filter(|v| !v.is_null()) {
284            let mut create = create.clone();
285            if let Some(create_obj) = create.as_object_mut() {
286                crate::actions::create_table::normalise_vix_dimensions_obj(create_obj);
287            }
288            update.create = Some(serde_json::from_value(create).map_err(|e| e.to_string())?);
289        }
290        if let Some(delete) = obj.get("Delete").filter(|v| !v.is_null()) {
291            update.delete =
292                Some(serde_json::from_value(delete.clone()).map_err(|e| e.to_string())?);
293        }
294        out.push(update);
295    }
296    Ok(out)
297}
298
299#[derive(Debug, Default, Serialize)]
300pub struct UpdateTableResponse {
301    #[serde(rename = "TableDescription")]
302    pub table_description: TableDescription,
303}
304
305/// The online-index limit's message, shared by the per-call and per-table arms
306/// because DynamoDB answers both with the same bytes.
307const ONE_ONLINE_INDEX: &str = "Subscriber limit exceeded: Only 1 online index can be created or deleted \
308     simultaneously per table";
309
310pub async fn execute<S: StorageBackend>(
311    storage: &S,
312    mut request: UpdateTableRequest,
313    lifecycle: &VectorIndexLifecycle,
314) -> Result<UpdateTableResponse> {
315    // Table name validation is handled in the Deserialize impl
316
317    // An OnDemandThroughput object with no members carries no change, so
318    // treat it as absent: it neither satisfies the at-least-one-change
319    // guard nor produces an echo. Real DynamoDB returns InternalFailure for
320    // this input (captured eu-west-2, 2026-07-24); a deterministic
321    // validation error is a deliberate divergence from emulating a 500.
322    if request.on_demand_throughput.as_ref().is_some_and(|odt| {
323        odt.max_read_request_units.is_none() && odt.max_write_request_units.is_none()
324    }) {
325        request.on_demand_throughput = None;
326    }
327
328    // Phase 1: Validate request parameters BEFORE table existence check
329    // (DynamoDB validates these first and returns ValidationException,
330    // not ResourceNotFoundException)
331    validate_update_request(&request)?;
332
333    // Phase 2: Table existence check
334    let meta = helpers::require_table(storage, &request.table_name).await?;
335
336    let current_billing_mode = meta.billing_mode.as_deref().unwrap_or("PROVISIONED");
337
338    // Parse existing vector index definitions early: the billing-mode gate
339    // below and the update validation both need them.
340    let mut current_vixs: Vec<VectorIndex> = meta
341        .vector_index_definitions
342        .as_ref()
343        .map(|json| serde_json::from_str(json))
344        .transpose()
345        .map_err(|e| DynoxideError::InternalServerError(format!("Bad vector index JSON: {e}")))?
346        .unwrap_or_default();
347
348    // Phase 3: Post-table-existence validations
349
350    // PAY_PER_REQUEST table + ProvisionedThroughput update is not allowed
351    if current_billing_mode == "PAY_PER_REQUEST"
352        && request.billing_mode.is_none()
353        && request.provisioned_throughput.is_some()
354    {
355        return Err(DynoxideError::ValidationException(
356            "One or more parameter values were invalid: \
357             Neither ReadCapacityUnits nor WriteCapacityUnits can be \
358             specified when BillingMode is PAY_PER_REQUEST"
359                .to_string(),
360        ));
361    }
362
363    // BillingMode PROVISIONED without ProvisionedThroughput
364    if request.billing_mode.as_deref() == Some("PROVISIONED")
365        && request.provisioned_throughput.is_none()
366    {
367        return Err(DynoxideError::ValidationException(
368            "One or more parameter values were invalid: \
369             ProvisionedThroughput must be specified when BillingMode is PROVISIONED"
370                .to_string(),
371        ));
372    }
373
374    // A vector-indexed table cannot leave PAY_PER_REQUEST. The switch has
375    // its own string, distinct from the create-time gate's, and reads the
376    // stored definitions, so deleting the last index and flipping in the
377    // same call is still rejected. Captured from real DynamoDB (eu-west-2
378    // and us-east-1, 2026-08-12).
379    if request.billing_mode.as_deref() == Some("PROVISIONED") && !current_vixs.is_empty() {
380        return Err(DynoxideError::ValidationException(
381            "One or more parameter values were invalid: Tables with vector indexes must \
382             be in PAY_PER_REQUEST mode"
383                .to_string(),
384        ));
385    }
386
387    // OnDemandThroughput is only valid when the table ends up PAY_PER_REQUEST:
388    // either the request switches to it, or the table already is and the
389    // request does not switch away. The gate reads the committed billing mode
390    // and names the first present member, read checked first; the update
391    // wording carries "the" and no full stop, unlike CreateTable's. The gate
392    // fires before the bounds check when both are violated. All captured from
393    // real DynamoDB (eu-west-2, 2026-07-24).
394    if let Some(ref odt) = request.on_demand_throughput {
395        let target_is_provisioned = match request.billing_mode.as_deref() {
396            Some("PAY_PER_REQUEST") => false,
397            Some(_) => true,
398            None => current_billing_mode == "PROVISIONED",
399        };
400        let members = [
401            ("MaxReadRequestUnits", odt.max_read_request_units),
402            ("MaxWriteRequestUnits", odt.max_write_request_units),
403        ];
404        if target_is_provisioned
405            && let Some((member, _)) = members.iter().find(|(_, v)| v.is_some())
406        {
407            return Err(DynoxideError::ValidationException(format!(
408                "One or more parameter values were invalid: {member} for \
409                     OnDemandThroughput cannot be specified when the table BillingMode \
410                     is PROVISIONED"
411            )));
412        }
413        // Bounds: members must be at least 1, or exactly -1, which removes
414        // the ceiling. Message identical to CreateTable's.
415        for (member, value) in members {
416            if value.is_some_and(|v| v == 0 || v < -1) {
417                return Err(DynoxideError::ValidationException(format!(
418                    "One or more parameter values were invalid: Requested {member} for \
419                     OnDemandThroughput for table is outside of valid range"
420                )));
421            }
422        }
423    }
424
425    // Same read/write values check
426    if let Some(ref pt) = request.provisioned_throughput
427        && let Some(obj) = pt.as_object()
428    {
429        let new_rcu = obj
430            .get("ReadCapacityUnits")
431            .and_then(|v| v.as_i64())
432            .unwrap_or(0);
433        let new_wcu = obj
434            .get("WriteCapacityUnits")
435            .and_then(|v| v.as_i64())
436            .unwrap_or(0);
437
438        // Parse current provisioned throughput from metadata
439        let (cur_rcu, cur_wcu) = parse_current_throughput(&meta);
440
441        let billing_mode_unchanged = request.billing_mode.is_none()
442            || (request.billing_mode.as_deref() == Some("PROVISIONED")
443                && current_billing_mode == "PROVISIONED");
444
445        if new_rcu == cur_rcu && new_wcu == cur_wcu && billing_mode_unchanged {
446            return Err(DynoxideError::ValidationException(format!(
447                "The provisioned throughput for the table will not change. \
448                     The requested value equals the current value. \
449                     Current ReadCapacityUnits provisioned for the table: {}. \
450                     Requested ReadCapacityUnits: {}. \
451                     Current WriteCapacityUnits provisioned for the table: {}. \
452                     Requested WriteCapacityUnits: {}. \
453                     Refer to the Amazon DynamoDB Developer Guide for current limits \
454                     and how to request higher limits.",
455                cur_rcu, new_rcu, cur_wcu, new_wcu
456            )));
457        }
458    }
459
460    // Refuse a stream-specification change on a backend without streams before
461    // any mutation below (GSI creates, metadata writes) can land. Ordered after
462    // the AWS-pinned validations above so requests real DynamoDB would reject
463    // keep their pinned errors; only a request AWS would accept reaches this.
464    if request.stream_specification.is_some() && !storage.supports_streams() {
465        return Err(crate::storage_backend::BackendError::Unsupported {
466            capability: "streams",
467        }
468        .into());
469    }
470
471    // Parse existing GSI definitions
472    let mut current_gsis: Vec<GlobalSecondaryIndex> = meta
473        .gsi_definitions
474        .as_ref()
475        .map(|json| serde_json::from_str(json))
476        .transpose()
477        .map_err(|e| DynoxideError::InternalServerError(format!("Bad GSI JSON: {e}")))?
478        .unwrap_or_default();
479
480    // Parse existing LSI definitions: the cross-family duplicate checks below
481    // and the AttributeDefinitions reconciliation both need them.
482    let lsi_defs = crate::actions::lsi::parse_lsi_defs(&meta)?;
483
484    // GSI Update with high capacity on non-existent index
485    if let Some(ref updates) = request.global_secondary_index_updates {
486        for update in updates {
487            if let Some(ref upd) = update.update
488                && !current_gsis.iter().any(|g| g.index_name == upd.index_name)
489            {
490                // DynamoDB returns this specific message for GSI updates on
491                // non-existent indexes (even with out-of-bounds capacity)
492                return Err(DynoxideError::ValidationException(
493                    "This operation cannot be performed with given input values. \
494                         Please contact DynamoDB service team for more info: \
495                         Action Blocked: IndexUpdate"
496                        .to_string(),
497                ));
498            }
499        }
500    }
501
502    // Check GSI update count limit (DynamoDB allows at most 5 per request)
503    if let Some(ref updates) = request.global_secondary_index_updates
504        && updates.len() > 5
505    {
506        return Err(DynoxideError::LimitExceededException(
507            "Subscriber limit exceeded: Only 1 online index can be created or \
508                 deleted simultaneously per table"
509                .to_string(),
510        ));
511    }
512
513    // Merge provided attribute definitions into the existing set. DynamoDB
514    // treats UpdateTable's AttributeDefinitions as a delta: adding a GSI only
515    // requires the new index's key attributes, and the existing definitions
516    // (table keys, prior GSI keys) are preserved. Replacing them outright would
517    // drop attributes still referenced by the key schema and other indexes.
518    let mut attr_defs: Vec<AttributeDefinition> = serde_json::from_str(&meta.attribute_definitions)
519        .map_err(|e| DynoxideError::InternalServerError(format!("Bad attr defs JSON: {e}")))?;
520
521    if let Some(ref provided) = request.attribute_definitions {
522        for def in provided {
523            // An already-declared attribute keeps its existing type. Real
524            // DynamoDB ignores a redeclaration (even one carrying a different
525            // type) rather than overwriting or rejecting it, so only genuinely
526            // new attributes are appended. Verified against AWS in eu-west-2.
527            if !attr_defs
528                .iter()
529                .any(|d| d.attribute_name == def.attribute_name)
530            {
531                attr_defs.push(def.clone());
532            }
533        }
534    }
535
536    // Parse table key schema for backfill
537    let key_schema = helpers::parse_key_schema(&meta)?;
538
539    // Validate all GSI updates before making any changes
540    if let Some(ref updates) = request.global_secondary_index_updates {
541        for update in updates {
542            if let Some(ref create) = update.create {
543                if current_gsis
544                    .iter()
545                    .any(|g| g.index_name == create.index_name)
546                {
547                    return Err(DynoxideError::ValidationException(format!(
548                        "One or more parameter values were invalid: \
549                         Index already exists: {}",
550                        create.index_name
551                    )));
552                }
553                // A GSI create colliding with a vector index name uses the
554                // vector create path's wording, not the GSI same-family
555                // string above. Captured from real DynamoDB (eu-west-2 and
556                // us-east-1, 2026-08-12).
557                if current_vixs
558                    .iter()
559                    .any(|v| v.index_name == create.index_name)
560                {
561                    return Err(DynoxideError::ValidationException(
562                        "Attempting to create an index which already exists".to_string(),
563                    ));
564                }
565                // A GSI cannot key on a live vector attribute: the vector
566                // index already defines it as a list of the index's
567                // dimensions, so the proposed scalar key definition is a
568                // redefinition. The message interpolates the attribute name,
569                // the live index's dimensions, the request-declared scalar
570                // type, and the proposed key type. Captured from real
571                // DynamoDB (eu-west-2 and us-east-1, 2026-08-12). An element
572                // whose attribute the request does not declare falls through
573                // to validate_gsi's missing-declaration error below.
574                for elem in &create.key_schema {
575                    let Some(vix) = current_vixs
576                        .iter()
577                        .find(|v| v.vector_attribute.attribute_name == elem.attribute_name)
578                    else {
579                        continue;
580                    };
581                    let Some(def) = request
582                        .attribute_definitions
583                        .as_deref()
584                        .unwrap_or(&[])
585                        .iter()
586                        .find(|d| d.attribute_name == elem.attribute_name)
587                    else {
588                        continue;
589                    };
590                    let type_letter = match def.attribute_type {
591                        crate::types::ScalarAttributeType::S => "S",
592                        crate::types::ScalarAttributeType::N => "N",
593                        crate::types::ScalarAttributeType::B => "B",
594                    };
595                    let key_type = match elem.key_type {
596                        crate::types::KeyType::HASH => "HASH",
597                        crate::types::KeyType::RANGE => "RANGE",
598                    };
599                    return Err(DynoxideError::ValidationException(format!(
600                        "One or more parameter values were invalid: Attributes cannot be \
601                         redefined. Please check that your attribute has the same type as \
602                         previously defined. Existing schema: \
603                         VectorIndexSchema:[VectorAttribute: key{{{attr}:L:{dims}}}] \
604                         New schema: Schema:[SchemaElement: key{{{attr}:{type_letter}:{key_type}}}]",
605                        attr = elem.attribute_name,
606                        dims = vix.dimensions,
607                    )));
608                }
609                let gsi_def = GlobalSecondaryIndex {
610                    index_name: create.index_name.clone(),
611                    key_schema: create.key_schema.clone(),
612                    projection: create.projection.clone(),
613                    provisioned_throughput: None,
614                };
615                // A new index's key attributes must all appear in the request's
616                // own AttributeDefinitions, not the merged stored set: DynamoDB
617                // requires the request to (re)declare them even when the table
618                // already defines the attribute.
619                validation::validate_gsi(
620                    &gsi_def,
621                    request.attribute_definitions.as_deref().unwrap_or(&[]),
622                )?;
623            }
624            if let Some(ref delete) = update.delete
625                && !current_gsis
626                    .iter()
627                    .any(|g| g.index_name == delete.index_name)
628            {
629                return Err(DynoxideError::ResourceNotFoundException(format!(
630                    "Requested resource not found: Table: {} not found",
631                    delete.index_name
632                )));
633            }
634        }
635    }
636
637    // Validate all vector index updates before making any changes.
638    if let Some(ref updates) = request.vector_index_updates {
639        // Only one vector index action per call; the rejection is the GSI
640        // online-index machinery's own error. Captured from real DynamoDB
641        // (eu-west-2, 2026-08-11). Whether a call mixing GSI and vector
642        // actions trips a combined limit is uncaptured; each family keeps
643        // its own count here.
644        if updates.len() > 1 {
645            return Err(DynoxideError::LimitExceededException(
646                ONE_ONLINE_INDEX.to_string(),
647            ));
648        }
649        // The limit is per table, not per call. A second online index action is
650        // refused while one is still creating even when it arrives in its own
651        // UpdateTable, carrying the same message (captured eu-west-2,
652        // 2026-08-23: a create of a second index and a delete of a third both
653        // answered this while the first was still CREATING). The one action
654        // that gets through is a delete of the creating index itself, which is
655        // how a create is cancelled.
656        //
657        // Only indexes the table still defines are consulted, so a stale entry
658        // for one that has gone cannot wedge every later update.
659        let creating = lifecycle.phases_of(
660            storage,
661            &request.table_name,
662            current_vixs.iter().map(|v| v.index_name.as_str()),
663        );
664        let targeted = updates.first().and_then(|u| u.delete.as_ref());
665        let cancels_the_creating_index =
666            targeted.is_some_and(|d| creating.get(&d.index_name).is_creating());
667        if !updates.is_empty() && creating.any_creating() && !cancels_the_creating_index {
668            return Err(DynoxideError::LimitExceededException(
669                ONE_ONLINE_INDEX.to_string(),
670            ));
671        }
672
673        // Cancelling is only available once the index is past allocating.
674        // DynamoDB refuses the delete before that and says which phase to
675        // retry in, so the wait is discoverable from the answer rather than
676        // something a caller has to know (captured eu-west-2, 2026-08-23).
677        if let Some(delete) = targeted
678            && !creating.get(&delete.index_name).accepts_delete()
679        {
680            return Err(DynoxideError::ResourceInUseException(format!(
681                "Attempt to change a resource which is still in use: Index creation is \
682                     in resource allocation phase. Retry deletion during backfilling phase \
683                     or when the index is active. Table: {} Index: {}",
684                request.table_name, delete.index_name
685            )));
686        }
687
688        for update in updates {
689            if let Some(ref create) = update.create {
690                // Vector indexes only exist on PAY_PER_REQUEST tables; the
691                // UpdateTable path carries the same string as CreateTable's
692                // gate. Captured from real DynamoDB (eu-west-2 and us-east-1,
693                // 2026-08-12).
694                let target_billing_mode = request
695                    .billing_mode
696                    .as_deref()
697                    .unwrap_or(current_billing_mode);
698                if target_billing_mode != "PAY_PER_REQUEST" {
699                    return Err(DynoxideError::ValidationException(
700                        "One or more parameter values were invalid: Vector indexes are \
701                         only supported for PAY_PER_REQUEST tables"
702                            .to_string(),
703                    ));
704                }
705
706                // The vector create path has its own duplicate wording, with
707                // no index name in it: not the GSI duplicate string and not
708                // CreateTable's classic cross-index one. The check spans all
709                // index families: a name held by a live GSI or LSI collides
710                // too, with the same wording. Captured from real DynamoDB
711                // (eu-west-2 and us-east-1, 2026-08-12).
712                if current_vixs
713                    .iter()
714                    .any(|v| v.index_name == create.index_name)
715                    || current_gsis
716                        .iter()
717                        .any(|g| g.index_name == create.index_name)
718                    || lsi_defs.iter().any(|l| l.index_name == create.index_name)
719                {
720                    return Err(DynoxideError::ValidationException(
721                        "Attempting to create an index which already exists".to_string(),
722                    ));
723                }
724
725                // A new index's SearchSchema attributes must all appear in the
726                // request's own AttributeDefinitions, mirroring the GSI rule.
727                // Always entry 1 in the rendered path: a call carries at most
728                // one create action.
729                validation::validate_vector_index(
730                    create,
731                    request.attribute_definitions.as_deref().unwrap_or(&[]),
732                    "vectorIndexUpdates.1.member.create",
733                )?;
734
735                // Count limit (five per table), same string as CreateTable's.
736                // Captured from real DynamoDB (eu-west-2, 2026-08-11). A
737                // delete cannot share the call to free a slot: one entry per
738                // call, one action per entry, so the stored count is the
739                // effective one.
740                if current_vixs.len() >= 5 {
741                    return Err(DynoxideError::ValidationException(
742                        "One or more parameter values were invalid: VectorIndex count \
743                         exceeds the per-table limit of 5"
744                            .to_string(),
745                    ));
746                }
747
748                // Indexes on one vector attribute must agree on dimensions.
749                // The string is captured on the CreateTable path (eu-west-2,
750                // 2026-08-11); the invariant is structural, so the same check
751                // applies here.
752                if current_vixs.iter().any(|v| {
753                    v.vector_attribute.attribute_name == create.vector_attribute.attribute_name
754                        && v.dimensions != create.dimensions
755                }) {
756                    return Err(DynoxideError::ValidationException(format!(
757                        "One or more parameter values were invalid: Conflicting attribute \
758                         definition for '{}'. All VectorIndexes on the same vector attribute \
759                         must use the same dimensions.",
760                        create.vector_attribute.attribute_name
761                    )));
762                }
763
764                // The vector attribute must not be declared in
765                // AttributeDefinitions, checked against the merged set since
766                // the stored definitions count on UpdateTable. The string is
767                // captured on the CreateTable path only (eu-west-2 and
768                // us-east-1, 2026-08-12); the same rule is applied here.
769                let attr = create.vector_attribute.attribute_name.as_str();
770                if attr_defs.iter().any(|d| d.attribute_name == attr) {
771                    return Err(DynoxideError::ValidationException(format!(
772                        "One or more parameter values were invalid: Conflicting attribute \
773                         definition for '{attr}'. An attribute cannot be defined in \
774                         AttributeDefinitions when used as a VectorAttribute."
775                    )));
776                }
777            }
778            if let Some(ref delete) = update.delete
779                && !current_vixs
780                    .iter()
781                    .any(|v| v.index_name == delete.index_name)
782            {
783                // Bare index name, no quoting. Captured from real DynamoDB
784                // (eu-west-2 and us-east-1, 2026-08-12).
785                return Err(DynoxideError::ResourceNotFoundException(format!(
786                    "Requested resource not found: Index {} for table {}",
787                    delete.index_name, request.table_name
788                )));
789            }
790        }
791    }
792
793    // Determine if this is a throughput increase or decrease.
794    // Ensure timestamps strictly increase across successive updates
795    // (the dynalite test expects LastDecreaseDateTime > LastIncreaseDateTime).
796    let now = {
797        use std::sync::atomic::{AtomicU64, Ordering};
798        static LAST_TS: AtomicU64 = AtomicU64::new(0);
799        let wall = web_time::SystemTime::now()
800            .duration_since(web_time::UNIX_EPOCH)
801            .unwrap_or_default()
802            .as_secs_f64();
803        loop {
804            let prev_bits = LAST_TS.load(Ordering::SeqCst);
805            let prev_f = f64::from_bits(prev_bits);
806            let candidate = if wall > prev_f { wall } else { prev_f + 0.001 };
807            let candidate_bits = candidate.to_bits();
808            if LAST_TS
809                .compare_exchange(
810                    prev_bits,
811                    candidate_bits,
812                    Ordering::SeqCst,
813                    Ordering::SeqCst,
814                )
815                .is_ok()
816            {
817                break candidate;
818            }
819        }
820    };
821
822    let (cur_rcu, cur_wcu) = parse_current_throughput(&meta);
823    let is_pt_update = request.provisioned_throughput.is_some();
824    let (new_rcu, new_wcu) = if let Some(ref pt) = request.provisioned_throughput {
825        let obj = pt.as_object();
826        (
827            obj.and_then(|o| o.get("ReadCapacityUnits"))
828                .and_then(|v| v.as_i64())
829                .unwrap_or(0),
830            obj.and_then(|o| o.get("WriteCapacityUnits"))
831                .and_then(|v| v.as_i64())
832                .unwrap_or(0),
833        )
834    } else {
835        (cur_rcu, cur_wcu)
836    };
837
838    let is_increase = new_rcu > cur_rcu || new_wcu > cur_wcu;
839    let is_decrease = new_rcu < cur_rcu || new_wcu < cur_wcu;
840
841    // OnDemandThroughput merges member-wise over the stored ceilings, with -1
842    // removing a member (captured from real DynamoDB, eu-west-2 2026-07-24).
843    // The response echoes the merge with any -1 kept verbatim; the stored
844    // state has it stripped, and an empty result clears the column.
845    let odt_change = request.on_demand_throughput.as_ref().map(|req_odt| {
846        let stored: crate::types::OnDemandThroughput = meta
847            .on_demand_throughput
848            .as_deref()
849            .and_then(|json| serde_json::from_str(json).ok())
850            .unwrap_or_default();
851        let echo = crate::types::OnDemandThroughput {
852            max_read_request_units: req_odt
853                .max_read_request_units
854                .or(stored.max_read_request_units),
855            max_write_request_units: req_odt
856                .max_write_request_units
857                .or(stored.max_write_request_units),
858        };
859        let strip = |v: Option<i64>| v.filter(|&v| v != -1);
860        let effective = crate::types::OnDemandThroughput {
861            max_read_request_units: strip(echo.max_read_request_units),
862            max_write_request_units: strip(echo.max_write_request_units),
863        };
864        (echo, effective)
865    });
866
867    // All validation passed; perform mutations inside a single transaction.
868    helpers::with_write_transaction(storage, async {
869        if let Some(ref updates) = request.global_secondary_index_updates {
870            for update in updates {
871                if let Some(ref create) = update.create {
872                    let gsi_def = GlobalSecondaryIndex {
873                        index_name: create.index_name.clone(),
874                        key_schema: create.key_schema.clone(),
875                        projection: create.projection.clone(),
876                        provisioned_throughput: None,
877                    };
878
879                    storage
880                        .create_gsi_table(&request.table_name, &create.index_name)
881                        .await?;
882
883                    let gsi_p = gsi::gsi_to_def(&gsi_def)?;
884                    gsi::backfill_gsi(storage, &request.table_name, &key_schema, &gsi_p).await?;
885
886                    current_gsis.push(gsi_def);
887                }
888
889                if let Some(ref delete) = update.delete {
890                    storage
891                        .drop_gsi_table(&request.table_name, &delete.index_name)
892                        .await?;
893                    current_gsis.retain(|g| g.index_name != delete.index_name);
894                }
895            }
896        }
897
898        if let Some(ref updates) = request.vector_index_updates {
899            for update in updates {
900                if let Some(ref create) = update.create {
901                    storage
902                        .create_vector_table(&request.table_name, &create.index_name)
903                        .await?;
904
905                    // Backfill runs synchronously inside this transaction and
906                    // covers the items that exist as of this call; index
907                    // maintenance on later writes arrives separately. The
908                    // response still reports the index CREATING per the
909                    // captured lifecycle walk.
910                    backfill_vector_index(
911                        storage,
912                        &request.table_name,
913                        &key_schema,
914                        create,
915                        &attr_defs,
916                    )
917                    .await?;
918
919                    current_vixs.push(create.clone());
920                }
921
922                if let Some(ref delete) = update.delete {
923                    storage
924                        .drop_vector_table(&request.table_name, &delete.index_name)
925                        .await?;
926                    current_vixs.retain(|v| v.index_name != delete.index_name);
927                }
928            }
929
930            // An item is billed at a size that depends on which attributes a
931            // vector index reads, so the stored sizes TableSizeBytes sums
932            // follow the index set as it now stands.
933            super::vector_index::recompute_billed_sizes(
934                storage,
935                &request.table_name,
936                &current_vixs,
937            )
938            .await?;
939        }
940
941        // Reconcile AttributeDefinitions to exactly the attributes still
942        // referenced by the table key schema and surviving index key schemas.
943        // Surviving vector indexes' SearchSchema attributes count as used, so
944        // a GSI delete cannot prune an attribute a live vector schema needs.
945        // See reconcile_attribute_definitions for the AWS-verified rules.
946        reconcile_attribute_definitions(
947            &mut attr_defs,
948            &key_schema,
949            &current_gsis,
950            &lsi_defs,
951            &current_vixs,
952        );
953
954        // Update metadata
955        let attr_defs_json = serde_json::to_string(&attr_defs)
956            .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
957        let gsi_json = if current_gsis.is_empty() {
958            None
959        } else {
960            Some(
961                serde_json::to_string(&current_gsis)
962                    .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?,
963            )
964        };
965
966        storage
967            .update_table_metadata(&request.table_name, &attr_defs_json, gsi_json.as_deref())
968            .await?;
969
970        // Persist vector index definitions only when this request changed
971        // them, following the gsi_definitions convention (NULL when none
972        // remain).
973        if request
974            .vector_index_updates
975            .as_ref()
976            .is_some_and(|u| !u.is_empty())
977        {
978            let vix_json = if current_vixs.is_empty() {
979                None
980            } else {
981                Some(
982                    serde_json::to_string(&current_vixs)
983                        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?,
984                )
985            };
986            storage
987                .update_vector_index_definitions(&request.table_name, vix_json.as_deref())
988                .await?;
989        }
990
991        // Update provisioned throughput if requested
992        if is_pt_update {
993            let prev = parse_stored_throughput(&meta);
994            let mut stored = StoredProvisionedThroughput {
995                read_capacity_units: new_rcu,
996                write_capacity_units: new_wcu,
997                last_increase_date_time: prev.as_ref().and_then(|p| p.last_increase_date_time),
998                last_decrease_date_time: prev.as_ref().and_then(|p| p.last_decrease_date_time),
999                number_of_decreases_today: prev
1000                    .as_ref()
1001                    .and_then(|p| p.number_of_decreases_today)
1002                    .or(Some(0)),
1003            };
1004            if is_increase {
1005                stored.last_increase_date_time = Some(now);
1006            }
1007            if is_decrease {
1008                stored.last_decrease_date_time = Some(now);
1009                stored.number_of_decreases_today =
1010                    Some(stored.number_of_decreases_today.unwrap_or(0) + 1);
1011            }
1012            let pt_json = serde_json::to_string(&stored)
1013                .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
1014            storage
1015                .update_provisioned_throughput(&request.table_name, &pt_json)
1016                .await?;
1017        }
1018
1019        // Handle deletion protection changes
1020        if let Some(enabled) = request.deletion_protection_enabled {
1021            storage
1022                .update_deletion_protection(&request.table_name, enabled)
1023                .await?;
1024        }
1025
1026        // Handle table class changes
1027        if let Some(ref table_class) = request.table_class {
1028            storage
1029                .update_table_class(&request.table_name, table_class)
1030                .await?;
1031        }
1032
1033        // Handle on-demand throughput changes
1034        if let Some((_, ref effective)) = odt_change {
1035            if effective.max_read_request_units.is_none()
1036                && effective.max_write_request_units.is_none()
1037            {
1038                storage
1039                    .clear_on_demand_throughput(&request.table_name)
1040                    .await?;
1041            } else {
1042                let json = serde_json::to_string(effective)
1043                    .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
1044                storage
1045                    .update_on_demand_throughput(&request.table_name, &json)
1046                    .await?;
1047            }
1048        }
1049
1050        // Handle billing mode changes
1051        if let Some(ref billing_mode) = request.billing_mode {
1052            storage
1053                .update_billing_mode(&request.table_name, billing_mode)
1054                .await?;
1055            if billing_mode == "PAY_PER_REQUEST" {
1056                // Clear provisioned throughput to avoid stale data
1057                storage
1058                    .clear_provisioned_throughput(&request.table_name)
1059                    .await?;
1060            } else if billing_mode == "PROVISIONED" {
1061                // Switching away from on-demand clears the stored ceilings,
1062                // matching real DynamoDB (eu-west-2 capture, 2026-07-24).
1063                storage
1064                    .clear_on_demand_throughput(&request.table_name)
1065                    .await?;
1066            }
1067        }
1068
1069        // Handle stream specification changes
1070        if let Some(ref spec) = request.stream_specification {
1071            if spec.stream_enabled {
1072                let view_type = spec
1073                    .stream_view_type
1074                    .as_deref()
1075                    .unwrap_or("NEW_AND_OLD_IMAGES");
1076                let label = streams::generate_stream_label(storage.clock());
1077                storage
1078                    .enable_stream(&request.table_name, view_type, &label)
1079                    .await?;
1080            } else {
1081                storage.disable_stream(&request.table_name).await?;
1082            }
1083        }
1084
1085        Ok(())
1086    })
1087    .await?;
1088
1089    // Arm and disarm only once the transaction has committed, so a failed
1090    // update leaves no index claiming to be creating. A delete disarms whether
1091    // or not the index finished creating: cancelling a creating index has to
1092    // clear its entry, or DeleteTable's guard keeps refusing on an index that
1093    // no longer exists.
1094    if let Some(ref updates) = request.vector_index_updates {
1095        for update in updates {
1096            if let Some(ref create) = update.create {
1097                lifecycle.arm(storage, &request.table_name, &create.index_name);
1098            }
1099            if let Some(ref delete) = update.delete {
1100                lifecycle.disarm(&request.table_name, &delete.index_name);
1101            }
1102        }
1103    }
1104
1105    // Build response from updated metadata
1106    let updated_meta = helpers::require_table(storage, &request.table_name).await?;
1107    let vector_phases = lifecycle.phases_armed_on(storage, &request.table_name);
1108    let (item_count, table_size_bytes) =
1109        crate::actions::describe_table::item_count_and_size(storage, &request.table_name).await;
1110    let mut desc =
1111        build_table_description(&updated_meta, item_count, table_size_bytes, &vector_phases);
1112    crate::actions::describe_table::fill_index_figures(storage, &mut desc).await;
1113
1114    // The UpdateTable response echoes the merged ceilings with any -1 kept
1115    // verbatim; only DescribeTable afterwards shows the post-removal state.
1116    if let Some((echo, _)) = odt_change {
1117        desc.on_demand_throughput = Some(echo);
1118    }
1119
1120    // A vector index create reports the table UPDATING (captured eu-west-2,
1121    // 2026-08-11). The description above already says so, because a just-armed
1122    // index is allocating and that is what holds the table there; this states
1123    // it outright so the response does not depend on how the allocation window
1124    // is tuned. Everything else in the answer, the index's own CREATING
1125    // included, comes from the phases, which every later DescribeTable reads
1126    // too, so this response and the next description cannot disagree.
1127    if let Some(ref updates) = request.vector_index_updates
1128        && updates.iter().any(|u| u.create.is_some())
1129    {
1130        desc.table_status = "UPDATING".to_string();
1131    }
1132
1133    // DynamoDB returns UPDATING status during throughput changes
1134    if is_pt_update {
1135        desc.table_status = "UPDATING".to_string();
1136
1137        // The immediate response shows the OLD throughput values while the
1138        // table is in UPDATING status, but with updated timestamps.
1139        let stored = parse_stored_throughput(&updated_meta);
1140        if let Some(ref mut pt) = desc.provisioned_throughput {
1141            pt.read_capacity_units = cur_rcu as u64;
1142            pt.write_capacity_units = cur_wcu as u64;
1143            if let Some(ref s) = stored {
1144                pt.last_increase_date_time = s.last_increase_date_time;
1145                pt.last_decrease_date_time = s.last_decrease_date_time;
1146                pt.number_of_decreases_today = s.number_of_decreases_today.unwrap_or(0);
1147            }
1148        }
1149    }
1150
1151    Ok(UpdateTableResponse {
1152        table_description: desc,
1153    })
1154}
1155
1156/// Reconcile `attr_defs` to exactly the attributes referenced by the table key
1157/// schema plus every surviving index key schema. Real DynamoDB keeps the two in
1158/// lockstep: an attribute orphaned by a GSI delete is pruned, and an entry used
1159/// by no key schema is dropped rather than stored (neither is an error).
1160/// Verified against AWS in eu-west-2.
1161///
1162/// Surviving vector indexes' SearchSchema attributes join the used set: they
1163/// are declared in AttributeDefinitions like key attributes, so a GSI delete
1164/// must not prune an attribute a live vector schema still references. The
1165/// vector attribute itself never appears in AttributeDefinitions and needs no
1166/// entry here.
1167fn reconcile_attribute_definitions(
1168    attr_defs: &mut Vec<AttributeDefinition>,
1169    key_schema: &helpers::KeySchema,
1170    gsis: &[GlobalSecondaryIndex],
1171    lsi_defs: &[crate::actions::lsi::LsiDef],
1172    vixs: &[VectorIndex],
1173) {
1174    let mut used: std::collections::HashSet<&str> = std::collections::HashSet::new();
1175    used.insert(key_schema.partition_key.as_str());
1176    if let Some(ref sk) = key_schema.sort_key {
1177        used.insert(sk.as_str());
1178    }
1179    for g in gsis {
1180        for k in &g.key_schema {
1181            used.insert(k.attribute_name.as_str());
1182        }
1183    }
1184    for lsi in lsi_defs {
1185        used.insert(lsi.pk_attr.as_str());
1186        if let Some(ref sk) = lsi.sk_attr {
1187            used.insert(sk.as_str());
1188        }
1189    }
1190    for vix in vixs {
1191        if let Some(ref schema) = vix.search_schema {
1192            for elem in schema {
1193                used.insert(elem.attribute_name.as_str());
1194            }
1195        }
1196    }
1197    attr_defs.retain(|d| used.contains(d.attribute_name.as_str()));
1198}
1199
1200/// Request-model constraint errors for `GlobalSecondaryIndexUpdates` Update
1201/// actions, shared between the operation-layer validation and the raw parse
1202/// path's cross-family envelope.
1203fn collect_gsi_update_errors(updates: &[GlobalSecondaryIndexUpdate], errors: &mut Vec<String>) {
1204    for (i, update) in updates.iter().enumerate() {
1205        if let Some(ref upd) = update.update {
1206            // Validate Update.IndexName
1207            if upd.index_name.len() < 3 {
1208                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));
1209            }
1210            if !upd.index_name.is_empty()
1211                && !upd
1212                    .index_name
1213                    .chars()
1214                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
1215            {
1216                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));
1217            }
1218            // Validate Update.ProvisionedThroughput
1219            if let Some(ref pt) = upd.provisioned_throughput {
1220                let wcu = pt.write_capacity_units;
1221                let rcu = pt.read_capacity_units;
1222                if wcu.is_none() {
1223                    errors.push(format!("Value null at 'globalSecondaryIndexUpdates.{}.member.update.provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must not be null", i + 1));
1224                } else if let Some(w) = wcu
1225                    && w < 1
1226                {
1227                    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));
1228                }
1229                if rcu.is_none() {
1230                    errors.push(format!("Value null at 'globalSecondaryIndexUpdates.{}.member.update.provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must not be null", i + 1));
1231                } else if let Some(r) = rcu
1232                    && r < 1
1233                {
1234                    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));
1235                }
1236            } else {
1237                errors.push(format!("Value null at 'globalSecondaryIndexUpdates.{}.member.update.provisionedThroughput' failed to satisfy constraint: Member must not be null", i + 1));
1238            }
1239        }
1240    }
1241}
1242
1243/// Validate UpdateTable request parameters before checking table existence.
1244///
1245/// DynamoDB validates these parameters first and returns ValidationException
1246/// rather than ResourceNotFoundException when both are invalid.
1247fn validate_update_request(request: &UpdateTableRequest) -> Result<()> {
1248    // Multi-field constraint errors
1249    let mut errors = Vec::new();
1250
1251    // Validate ProvisionedThroughput fields
1252    if let Some(ref pt) = request.provisioned_throughput
1253        && let Some(obj) = pt.as_object()
1254    {
1255        let wcu = obj.get("WriteCapacityUnits");
1256        let rcu = obj.get("ReadCapacityUnits");
1257        if wcu.is_none() || wcu == Some(&serde_json::Value::Null) {
1258            errors.push("Value null at 'provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must not be null".to_string());
1259        } else if let Some(w) = wcu.and_then(|v| v.as_i64())
1260            && w < 1
1261        {
1262            errors.push(format!("Value '{}' at 'provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must have value greater than or equal to 1", w));
1263        }
1264        if rcu.is_none() || rcu == Some(&serde_json::Value::Null) {
1265            errors.push("Value null at 'provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must not be null".to_string());
1266        } else if let Some(r) = rcu.and_then(|v| v.as_i64())
1267            && r < 1
1268        {
1269            errors.push(format!("Value '{}' at 'provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must have value greater than or equal to 1", r));
1270        }
1271    }
1272
1273    // Validate GlobalSecondaryIndexUpdates fields
1274    if let Some(ref updates) = request.global_secondary_index_updates {
1275        collect_gsi_update_errors(updates, &mut errors);
1276    }
1277
1278    // Cap at 10 errors
1279    errors.truncate(10);
1280
1281    if !errors.is_empty() {
1282        let prefix = format!(
1283            "{} validation error{} detected: ",
1284            errors.len(),
1285            if errors.len() == 1 { "" } else { "s" }
1286        );
1287        return Err(DynoxideError::ValidationException(format!(
1288            "{}{}",
1289            prefix,
1290            errors.join("; ")
1291        )));
1292    }
1293
1294    // Single-error validations (after multi-field)
1295
1296    // BillingMode enum validation
1297    if let Some(ref bm) = request.billing_mode
1298        && bm != "PROVISIONED"
1299        && bm != "PAY_PER_REQUEST"
1300    {
1301        return Err(DynoxideError::ValidationException(format!(
1302            "1 validation error detected: Value '{}' at 'billingMode' \
1303                 failed to satisfy constraint: Member must satisfy enum value set: \
1304                 [PROVISIONED, PAY_PER_REQUEST]",
1305            bm
1306        )));
1307    }
1308
1309    // TableClass enum validation (mirrors CreateTable)
1310    if let Some(ref tc) = request.table_class
1311        && tc != "STANDARD"
1312        && tc != "STANDARD_INFREQUENT_ACCESS"
1313    {
1314        return Err(DynoxideError::ValidationException(format!(
1315            "1 validation error detected: Value '{tc}' at 'tableClass' failed to satisfy \
1316                 constraint: Member must satisfy enum value set: \
1317                 [STANDARD, STANDARD_INFREQUENT_ACCESS]"
1318        )));
1319    }
1320
1321    // BillingMode PAY_PER_REQUEST with ProvisionedThroughput is not allowed
1322    if request.billing_mode.as_deref() == Some("PAY_PER_REQUEST")
1323        && request.provisioned_throughput.is_some()
1324    {
1325        return Err(DynoxideError::ValidationException(
1326            "One or more parameter values were invalid: \
1327             Neither ReadCapacityUnits nor WriteCapacityUnits can be \
1328             specified when BillingMode is PAY_PER_REQUEST"
1329                .to_string(),
1330        ));
1331    }
1332
1333    // ProvisionedThroughput out-of-bounds
1334    if let Some(ref pt) = request.provisioned_throughput
1335        && let Some(obj) = pt.as_object()
1336    {
1337        let rcu = obj
1338            .get("ReadCapacityUnits")
1339            .and_then(|v| v.as_i64())
1340            .unwrap_or(0);
1341        let wcu = obj
1342            .get("WriteCapacityUnits")
1343            .and_then(|v| v.as_i64())
1344            .unwrap_or(0);
1345        const MAX_THROUGHPUT: i64 = 1_000_000_000_000;
1346        if rcu > MAX_THROUGHPUT {
1347            return Err(DynoxideError::ValidationException(format!(
1348                "Given value {} for ReadCapacityUnits is out of bounds",
1349                rcu
1350            )));
1351        }
1352        if wcu > MAX_THROUGHPUT {
1353            return Err(DynoxideError::ValidationException(format!(
1354                "Given value {} for WriteCapacityUnits is out of bounds",
1355                wcu
1356            )));
1357        }
1358    }
1359
1360    // "At least one of ...": a request must change something. A lone
1361    // TableClass, OnDemandThroughput, or DeletionProtectionEnabled counts, the
1362    // same as a throughput/billing/stream change. An empty
1363    // GlobalSecondaryIndexUpdates array is treated as "no GSI change" rather
1364    // than satisfying the requirement on its own.
1365    let no_config_change = request.provisioned_throughput.is_none()
1366        && request.billing_mode.is_none()
1367        && request.stream_specification.is_none()
1368        && request.deletion_protection_enabled.is_none()
1369        && request.table_class.is_none()
1370        && request.on_demand_throughput.is_none();
1371    let no_gsi_change = request
1372        .global_secondary_index_updates
1373        .as_ref()
1374        .is_none_or(|updates| updates.is_empty());
1375    let no_vector_change = request
1376        .vector_index_updates
1377        .as_ref()
1378        .is_none_or(|updates| updates.is_empty());
1379    if no_gsi_change && no_config_change && no_vector_change {
1380        return Err(DynoxideError::ValidationException(
1381            "At least one of ProvisionedThroughput, BillingMode, UpdateStreamEnabled, GlobalSecondaryIndexUpdates or SSESpecification or ReplicaUpdates is required".to_string(),
1382        ));
1383    }
1384
1385    // Validate GSI update structural constraints
1386    if let Some(ref updates) = request.global_secondary_index_updates {
1387        // Check empty index struct (no Update, Create, or Delete)
1388        for update in updates {
1389            if update.update.is_none() && update.create.is_none() && update.delete.is_none() {
1390                return Err(DynoxideError::ValidationException(
1391                    "One or more parameter values were invalid: One of GlobalSecondaryIndexUpdate.Update, GlobalSecondaryIndexUpdate.Create, GlobalSecondaryIndexUpdate.Delete must not be null".to_string(),
1392                ));
1393            }
1394        }
1395
1396        // Check repeated index names
1397        let mut seen_names = std::collections::HashSet::new();
1398        for update in updates {
1399            let name = if let Some(ref u) = update.update {
1400                Some(u.index_name.as_str())
1401            } else if let Some(ref c) = update.create {
1402                Some(c.index_name.as_str())
1403            } else {
1404                update.delete.as_ref().map(|d| d.index_name.as_str())
1405            };
1406            if let Some(name) = name
1407                && !seen_names.insert(name.to_string())
1408            {
1409                return Err(DynoxideError::ValidationException(format!(
1410                    "One or more parameter values were invalid: Only one global secondary index update per index is allowed simultaneously. Index: {}",
1411                    name
1412                )));
1413            }
1414        }
1415    }
1416
1417    // Validate vector index update structural constraints. An entry carrying
1418    // neither action mirrors the GSI structural message (with the two actions
1419    // the vector family has); this shape is not captured.
1420    if let Some(ref updates) = request.vector_index_updates {
1421        for update in updates {
1422            if update.create.is_none() && update.delete.is_none() {
1423                return Err(DynoxideError::ValidationException(
1424                    "One or more parameter values were invalid: One of \
1425                     VectorIndexUpdate.Create, VectorIndexUpdate.Delete must not be null"
1426                        .to_string(),
1427                ));
1428            }
1429            // A single entry cannot carry both actions: a per-object
1430            // structural rule with its own string, distinct from the
1431            // one-action-per-call LimitExceededException. Captured from real
1432            // DynamoDB (eu-west-2 and us-east-1, 2026-08-12).
1433            if update.create.is_some() && update.delete.is_some() {
1434                return Err(DynoxideError::ValidationException(
1435                    "One or more parameter values were invalid: Only one vector index \
1436                     action is allowed per VectorIndexUpdate object"
1437                        .to_string(),
1438                ));
1439            }
1440        }
1441    }
1442
1443    Ok(())
1444}
1445
1446/// Extended provisioned throughput stored in metadata, including timestamps.
1447#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1448struct StoredProvisionedThroughput {
1449    #[serde(rename = "ReadCapacityUnits")]
1450    read_capacity_units: i64,
1451    #[serde(rename = "WriteCapacityUnits")]
1452    write_capacity_units: i64,
1453    #[serde(
1454        rename = "LastIncreaseDateTime",
1455        skip_serializing_if = "Option::is_none"
1456    )]
1457    last_increase_date_time: Option<f64>,
1458    #[serde(
1459        rename = "LastDecreaseDateTime",
1460        skip_serializing_if = "Option::is_none"
1461    )]
1462    last_decrease_date_time: Option<f64>,
1463    #[serde(
1464        rename = "NumberOfDecreasesToday",
1465        skip_serializing_if = "Option::is_none"
1466    )]
1467    number_of_decreases_today: Option<u64>,
1468}
1469
1470/// Parse current provisioned throughput from table metadata.
1471fn parse_current_throughput(meta: &crate::storage::TableMetadata) -> (i64, i64) {
1472    parse_stored_throughput(meta)
1473        .map(|pt| (pt.read_capacity_units, pt.write_capacity_units))
1474        .unwrap_or((0, 0))
1475}
1476
1477/// Parse the full stored provisioned throughput including timestamps.
1478fn parse_stored_throughput(
1479    meta: &crate::storage::TableMetadata,
1480) -> Option<StoredProvisionedThroughput> {
1481    meta.provisioned_throughput
1482        .as_ref()
1483        .and_then(|pt_json| serde_json::from_str(pt_json).ok())
1484}
1485
1486/// Backfill existing items into a newly created vector index, processing in
1487/// batches like [`gsi::backfill_gsi`].
1488///
1489/// Items holding values a live write would reject are skipped silently: the
1490/// index goes active without them, while re-putting the same item once the
1491/// index exists is rejected. That asymmetry is real DynamoDB's behaviour,
1492/// captured in eu-west-2 and us-east-1 on 2026-08-12.
1493async fn backfill_vector_index<S: StorageBackend>(
1494    storage: &S,
1495    table_name: &str,
1496    key_schema: &helpers::KeySchema,
1497    vix: &VectorIndex,
1498    attr_defs: &[AttributeDefinition],
1499) -> Result<()> {
1500    const BATCH_SIZE: usize = 1000;
1501    let mut last_pk: Option<String> = None;
1502    let mut last_sk: Option<String> = None;
1503
1504    loop {
1505        let items = storage
1506            .scan_items(
1507                table_name,
1508                &crate::storage::ScanParams {
1509                    limit: Some(BATCH_SIZE),
1510                    exclusive_start_pk: last_pk.as_deref(),
1511                    exclusive_start_sk: last_sk.as_deref(),
1512                    ..Default::default()
1513                },
1514            )
1515            .await?;
1516
1517        if items.is_empty() {
1518            break;
1519        }
1520
1521        let mut rows: Vec<crate::storage_backend::VectorItemRow> = Vec::new();
1522        for (pk, sk, item_json) in &items {
1523            let item: Item = serde_json::from_str(item_json)
1524                .map_err(|e| DynoxideError::InternalServerError(format!("Bad item JSON: {e}")))?;
1525
1526            if let Some(row) = super::vector_index::vector_index_row(
1527                &item,
1528                vix,
1529                &key_schema.partition_key,
1530                key_schema.sort_key.as_deref(),
1531                attr_defs,
1532                pk,
1533                sk,
1534            )? {
1535                rows.push(row);
1536            }
1537        }
1538
1539        storage
1540            .insert_vector_items(table_name, &vix.index_name, &rows)
1541            .await?;
1542
1543        let last = &items[items.len() - 1];
1544        last_pk = Some(last.0.clone());
1545        last_sk = Some(last.1.clone());
1546
1547        if items.len() < BATCH_SIZE {
1548            break;
1549        }
1550    }
1551
1552    Ok(())
1553}