Skip to content

Commit 4795a78

Browse files
committed
fix(dynamodb): guard the pre-translation GSI scan, correct the UpdateTable comments, coerce explicit throughput numbers
Addresses the remaining findings from the 3-axis review carried out by a separate session. The existing-index scan in the billing flip runs before the translation, so a non-array GlobalSecondaryIndexes hit .map on a plain object and died with a bare TypeError instead of the named error the translator raises a few lines later. It is now Array.isArray-guarded like every other new site. Two comments claimed UpdateTable accepts only one of BillingMode, ReplicaUpdates and GlobalSecondaryIndexUpdates per call. That contradicted the flip path, which deliberately sends BillingMode and GlobalSecondaryIndexUpdates together because AWS requires per-GSI capacity in the same call. Both now state the real constraint and its documented exception. An explicitly supplied, already-SDK-shaped throughput block was cast straight through, the one path skipping toFiniteNumber, so a stringly-typed CFn value would reach the SDK unnormalized while every derived value was coerced. The three such sites now go through a shared helper that also drops junk keys.
1 parent 97e1e44 commit 4795a78

2 files changed

Lines changed: 87 additions & 12 deletions

File tree

src/provisioning/providers/dynamodb-globaltable-provider.ts

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
27762812
function 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
}

tests/unit/provisioning/dynamodb-globaltable-provider-gsi-throughput.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,35 @@ describe('DynamoDBGlobalTable GSI throughput translation (issue #1387)', () => {
408408
).rejects.toThrow(/GlobalSecondaryIndexes must be an array/);
409409
});
410410

411+
it('coerces a stringly-typed explicit ProvisionedThroughput instead of forwarding it raw', () => {
412+
// CFn is stringly typed, and an explicitly-supplied already-SDK-shaped
413+
// block is the one path that would otherwise skip toFiniteNumber, so a
414+
// "5" would reach the SDK unnormalized while every derived value is a
415+
// number. Junk keys are dropped too.
416+
const [gsi] = toSdkGlobalSecondaryIndexes(
417+
{
418+
GlobalSecondaryIndexes: [
419+
{
420+
IndexName: 'explicit',
421+
KeySchema: [{ AttributeName: 'g', KeyType: 'HASH' }],
422+
Projection: { ProjectionType: 'ALL' },
423+
ProvisionedThroughput: {
424+
ReadCapacityUnits: '5',
425+
WriteCapacityUnits: '11',
426+
NotAnSdkMember: 'junk',
427+
},
428+
},
429+
],
430+
},
431+
'us-east-1',
432+
'PROVISIONED'
433+
);
434+
expect(gsi!.ProvisionedThroughput).toEqual({
435+
ReadCapacityUnits: 5,
436+
WriteCapacityUnits: 11,
437+
});
438+
});
439+
411440
it('throws on a non-array GlobalSecondaryIndexes instead of deploying a table with none', () => {
412441
// Absent is legitimately empty; present-but-not-an-array (an unresolved
413442
// intrinsic) previously collapsed to [] and created the table with ZERO

0 commit comments

Comments
 (0)