@@ -80,10 +80,15 @@ import type {
8080 * - `update()` covers every mutable surface — Tags, DeletionProtection,
8181 * TableClass, SSE, StreamSpec, OnDemand throughput, BillingMode flip,
8282 * Replica add / remove / modify, GSI add / remove / modify, TTL toggle.
83- * - The serialization is load-bearing: AWS's `UpdateTable` accepts only
84- * ONE of `{BillingMode, ReplicaUpdates, GlobalSecondaryIndexUpdates}`
85- * per call, so each category is its own SDK round-trip with a wait-for
86- * -ACTIVE in between. Immutable property changes (TableName, KeySchema,
83+ * - The serialization is load-bearing: AWS's `UpdateTable` does not accept
84+ * `ReplicaUpdates` alongside `BillingMode` / `GlobalSecondaryIndexUpdates`,
85+ * so each category is its own SDK round-trip with a wait-for-ACTIVE in
86+ * between. The ONE documented exception is the `PAY_PER_REQUEST ->
87+ * PROVISIONED` flip, where AWS REQUIRES per-GSI `ProvisionedThroughput` in
88+ * the SAME call as `BillingMode` ("you must specify read and write capacity
89+ * unit values for the table and for each global secondary index") — so that
90+ * path deliberately sends both together (Issue #1387). Immutable property
91+ * changes (TableName, KeySchema,
8792 * AttributeDefinitions removal, LocalSecondaryIndexes) throw
8893 * `ProvisioningError` naming the offending field — the deploy engine's
8994 * diff classification should catch these as REPLACEMENT before ever
@@ -646,10 +651,13 @@ export class DynamoDBGlobalTableProvider implements ResourceProvider {
646651 /**
647652 * Update a DynamoDB Global Table in place.
648653 *
649- * AWS-side state-machine constraint: `UpdateTable` accepts only ONE of
650- * `{BillingMode, ReplicaUpdates, GlobalSecondaryIndexUpdates}` per call ,
654+ * AWS-side state-machine constraint: `UpdateTable` does not accept
655+ * `ReplicaUpdates` alongside `BillingMode` / `GlobalSecondaryIndexUpdates` ,
651656 * so each category must serialize into its own SDK round-trip with a
652- * `waitForTableActiveAfterUpdate` between every step. Order:
657+ * `waitForTableActiveAfterUpdate` between every step. The ONE exception is
658+ * step 4's `PAY_PER_REQUEST -> PROVISIONED` flip, which AWS requires to
659+ * carry per-GSI `ProvisionedThroughput` in the same call (Issue #1387).
660+ * Order:
653661 * 1. Wait for current ACTIVE (defensive).
654662 * 2. Tags diff (TagResource / UntagResource — no wait needed).
655663 * 3. Non-conflicting flat fields (DeletionProtectionEnabled / TableClass
@@ -917,8 +925,13 @@ export class DynamoDBGlobalTableProvider implements ResourceProvider {
917925 // Only indexes that ALREADY exist on AWS can take an `Update`
918926 // action; a GSI introduced by this same deploy is created (with
919927 // its throughput) by step 6's `added` loop.
928+ // `Array.isArray` rather than a bare cast: this scan runs BEFORE the
929+ // translation below, so a non-array value (an unresolved intrinsic)
930+ // would hit `.map` on a plain object and die with a bare TypeError
931+ // instead of the named error the translator raises a few lines down.
932+ const previousCfnIndexes = previousProperties [ 'GlobalSecondaryIndexes' ] ;
920933 const existingIndexNames = new Set (
921- ( ( previousProperties [ 'GlobalSecondaryIndexes' ] ?? [ ] ) as unknown [ ] )
934+ ( Array . isArray ( previousCfnIndexes ) ? ( previousCfnIndexes as unknown [ ] ) : [ ] )
922935 . map ( ( entry ) => ( entry as Record < string , unknown > | null ) ?. [ 'IndexName' ] )
923936 . filter ( ( name ) : name is string => typeof name === 'string' )
924937 ) ;
@@ -2773,6 +2786,29 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
27732786}
27742787
27752788/** Coerce a CFn numeric (CFn is stringly-typed) to a finite number. */
2789+ /**
2790+ * Normalize an explicitly-supplied, already-SDK-shaped throughput block.
2791+ *
2792+ * The CFn schema forbids these members, but cdkd state written before Issue
2793+ * #1387 and hand-authored templates can carry them. Forwarding the raw record
2794+ * would be the ONE path that skips {@link toFiniteNumber}, so a stringly-typed
2795+ * CFn `"5"` would reach the SDK unnormalized while every DERIVED value is
2796+ * coerced — an inconsistency that only shows up on the rarest input. Picking
2797+ * the known members explicitly also drops junk keys the SDK serializer would
2798+ * discard silently anyway.
2799+ */
2800+ function coerceThroughputNumbers < K extends string > (
2801+ block : Record < string , unknown > ,
2802+ members : readonly K [ ]
2803+ ) : Record < K , number | undefined > {
2804+ const out = { } as Record < K , number | undefined > ;
2805+ for ( const member of members ) {
2806+ const n = toFiniteNumber ( block [ member ] ) ;
2807+ if ( n !== undefined ) out [ member ] = n ;
2808+ }
2809+ return out ;
2810+ }
2811+
27762812function toFiniteNumber ( value : unknown ) : number | undefined {
27772813 if ( value === undefined || value === null || value === '' ) return undefined ;
27782814 const n = Number ( value ) ;
@@ -2930,7 +2966,10 @@ export function toSdkGlobalSecondaryIndexes(
29302966 const explicitProvisioned = asRecord ( gsi [ 'ProvisionedThroughput' ] ) ;
29312967 const explicitOnDemand = asRecord ( gsi [ 'OnDemandThroughput' ] ) ;
29322968 if ( explicitProvisioned ) {
2933- sdk . ProvisionedThroughput = explicitProvisioned as unknown as ProvisionedThroughput ;
2969+ sdk . ProvisionedThroughput = coerceThroughputNumbers ( explicitProvisioned , [
2970+ 'ReadCapacityUnits' ,
2971+ 'WriteCapacityUnits' ,
2972+ ] ) as ProvisionedThroughput ;
29342973 } else if ( billingMode === 'PROVISIONED' ) {
29352974 sdk . ProvisionedThroughput = {
29362975 ReadCapacityUnits :
@@ -2944,7 +2983,10 @@ export function toSdkGlobalSecondaryIndexes(
29442983 }
29452984
29462985 if ( explicitOnDemand ) {
2947- sdk . OnDemandThroughput = explicitOnDemand as unknown as OnDemandThroughput ;
2986+ sdk . OnDemandThroughput = coerceThroughputNumbers ( explicitOnDemand , [
2987+ 'MaxReadRequestUnits' ,
2988+ 'MaxWriteRequestUnits' ,
2989+ ] ) as OnDemandThroughput ;
29482990 } else if ( billingMode !== 'PROVISIONED' ) {
29492991 const maxWrite = toFiniteNumber (
29502992 asRecord ( gsi [ 'WriteOnDemandThroughputSettings' ] ) ?. [ 'MaxWriteRequestUnits' ]
@@ -3006,12 +3048,16 @@ export function toSdkReplicaGlobalSecondaryIndexes(
30063048 asRecord ( cfn [ 'ReadOnDemandThroughputSettings' ] ) ?. [ 'MaxReadRequestUnits' ]
30073049 ) ;
30083050 if ( explicitProvisioned ) {
3009- sdk . ProvisionedThroughputOverride = explicitProvisioned ;
3051+ sdk . ProvisionedThroughputOverride = coerceThroughputNumbers ( explicitProvisioned , [
3052+ 'ReadCapacityUnits' ,
3053+ ] ) ;
30103054 } else if ( readCapacity !== undefined ) {
30113055 sdk . ProvisionedThroughputOverride = { ReadCapacityUnits : readCapacity } ;
30123056 }
30133057 if ( explicitOnDemand ) {
3014- sdk . OnDemandThroughputOverride = explicitOnDemand ;
3058+ sdk . OnDemandThroughputOverride = coerceThroughputNumbers ( explicitOnDemand , [
3059+ 'MaxReadRequestUnits' ,
3060+ ] ) ;
30153061 } else if ( maxReadRequestUnits !== undefined ) {
30163062 sdk . OnDemandThroughputOverride = { MaxReadRequestUnits : maxReadRequestUnits } ;
30173063 }
0 commit comments