Skip to content

Commit 88590d5

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/cfn-addresourcedependency-deprecation
2 parents 34c745d + 2c2f531 commit 88590d5

6 files changed

Lines changed: 240 additions & 37 deletions

File tree

packages/amplify-graphql-api-construct-tests/src/__tests__/backends/admin-role/apiInvoker.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,24 @@ import { defaultProvider } from '@aws-sdk/credential-provider-node';
33
import { SignatureV4 } from '@aws-sdk/signature-v4';
44
import { HttpRequest } from '@aws-sdk/protocol-http';
55
import { default as fetch, Request } from 'node-fetch';
6-
import type { GraphqlProxiedLambdaResponse } from '../../../lambda-request';
6+
7+
/**
8+
* Shape of the response this lambda returns to callers.
9+
*
10+
* Intentionally declared inline rather than imported from the test helpers: this file is bundled as a standalone lambda entry point and is
11+
* copied into a scratch CDK project, so any relative import reaching outside its own directory cannot be resolved.
12+
*/
13+
export type GraphqlProxiedLambdaResponse<ResponseDataType> = {
14+
statusCode: number;
15+
body: {
16+
errors: Array<{
17+
status?: number;
18+
message: string;
19+
stack: string[];
20+
}>;
21+
data: ResponseDataType;
22+
};
23+
};
724

825
if (!process.env.GRAPHQL_URL) throw new Error('GRAPHQL_URL not found in environment variables');
926
const graphqlEndpoint = new URL(process.env.GRAPHQL_URL);

packages/amplify-graphql-api-construct-tests/src/__tests__/backends/custom-query-mutation-extension/authorizer.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
exports.handler = async (event) => {
1+
type CustomAuthorizerEvent = {
2+
authorizationToken?: string;
3+
};
4+
5+
exports.handler = async (event: CustomAuthorizerEvent) => {
26
const { authorizationToken } = event;
37
const response = {
48
isAuthorized: authorizationToken === 'custom-authorized',

packages/amplify-graphql-api-construct-tests/src/commands.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,38 @@ const appendToCDKContext = (projectPath: string, additionalContext: Record<strin
6565
writeFileSync(cdkJsonPath, JSON.stringify(cdkJson, null, 2));
6666
};
6767

68+
/**
69+
* Pinned version of the `aws-cdk` CLI used to scaffold e2e test projects.
70+
*
71+
* The CLI must be pinned independently of `aws-cdk-lib`: the two have used separate version lines since CLI v2.1000.0, so there is no
72+
* `aws-cdk` release matching a modern `aws-cdk-lib` version. Leaving the CLI floating means `cdk init` silently picks up upstream template
73+
* changes, which has broken e2e groups before (the template switched the synth command from `ts-node` to `tsc && tsx`, turning synth into a
74+
* whole-project typecheck).
75+
*/
76+
const CDK_CLI_VERSION = '2.1134.0';
77+
78+
/**
79+
* Removes the whole-project `tsc` typecheck from the generated `cdk.json` synth command.
80+
*
81+
* Backend templates are copied wholesale into the scratch project's `bin/` directory, and some of those files are lambda entry points that
82+
* are only ever referenced by esbuild as a path string -- they are never imported by `app.ts`. A whole-project typecheck compiles them
83+
* anyway, in a directory they were never written to resolve from, failing synth before it starts. Transpiling only the import graph (the
84+
* historical behavior) keeps synth scoped to code the app actually loads.
85+
*/
86+
const removeWholeProjectTypecheckFromSynth = (projectPath: string): void => {
87+
const cdkJsonPath = path.join(projectPath, 'cdk.json');
88+
const cdkJson = JSON.parse(readFileSync(cdkJsonPath, 'utf-8'));
89+
if (typeof cdkJson.app !== 'string') {
90+
return;
91+
}
92+
const appWithoutTypecheck = cdkJson.app.replace(/^\s*npx\s+tsc\s*&&\s*/, '');
93+
if (appWithoutTypecheck === cdkJson.app) {
94+
return;
95+
}
96+
cdkJson.app = appWithoutTypecheck;
97+
writeFileSync(cdkJsonPath, JSON.stringify(cdkJson, null, 2));
98+
};
99+
68100
export type InitCDKProjectProps = {
69101
construct?: CdkConstruct;
70102
cdkContext?: Record<string, string>;
@@ -82,7 +114,7 @@ export type InitCDKProjectProps = {
82114
export const initCDKProject = async (cwd: string, templatePath: string, props?: InitCDKProjectProps): Promise<string> => {
83115
const { cdkVersion = '2.260.0', additionalDependencies = [] } = props ?? {};
84116

85-
await spawn(getNpxPath(), ['cdk', 'init', 'app', '--language', 'typescript'], {
117+
await spawn(getNpxPath(), [`aws-cdk@${CDK_CLI_VERSION}`, 'init', 'app', '--language', 'typescript'], {
86118
cwd,
87119
stripColors: true,
88120
// npx cdk does not work on verdaccio
@@ -93,6 +125,8 @@ export const initCDKProject = async (cwd: string, templatePath: string, props?:
93125
.sendYes()
94126
.runAsync();
95127

128+
removeWholeProjectTypecheckFromSynth(cwd);
129+
96130
if (props?.cdkContext) {
97131
appendToCDKContext(cwd, props.cdkContext);
98132
}

packages/amplify-graphql-model-transformer/src/__tests__/__snapshots__/amplify-table-manager-lambda.test.ts.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -398,8 +398,8 @@ Object {
398398
"Update": Object {
399399
"IndexName": "gsi1",
400400
"ProvisionedThroughput": Object {
401-
"ReadCapacityUnits": 5,
402-
"WriteCapacityUnits": 5,
401+
"ReadCapacityUnits": 4,
402+
"WriteCapacityUnits": 4,
403403
},
404404
},
405405
},

packages/amplify-graphql-model-transformer/src/__tests__/amplify-table-manager-lambda.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,6 +1114,126 @@ describe('Custom Resource Lambda Tests', () => {
11141114
nextUpdate = getNextAtomicUpdate(currentState, endState);
11151115
expect(nextUpdate).toMatchSnapshot();
11161116
});
1117+
describe('per-index provisioned throughput', () => {
1118+
const keySchemaFor = (attributeName: string) => [{ attributeName, keyType: 'HASH' }];
1119+
const currentGsi = (indexName: string, attributeName: string, throughput?: { read: number; write: number }) => ({
1120+
IndexName: indexName,
1121+
KeySchema: [{ AttributeName: attributeName, KeyType: 'HASH' as const }],
1122+
Projection: { ProjectionType: 'ALL' as const },
1123+
...(throughput ? { ProvisionedThroughput: { ReadCapacityUnits: throughput.read, WriteCapacityUnits: throughput.write } } : {}),
1124+
});
1125+
const endStateGsi = (indexName: string, attributeName: string, throughput?: { read: number; write: number }) => ({
1126+
indexName,
1127+
keySchema: keySchemaFor(attributeName),
1128+
projection: { projectionType: 'ALL' },
1129+
...(throughput ? { provisionedThroughput: { readCapacityUnits: throughput.read, writeCapacityUnits: throughput.write } } : {}),
1130+
});
1131+
const twoIndexAttributeDefinitions = [
1132+
{ attributeName: 'pk', attributeType: 'S' },
1133+
{ attributeName: 'sk', attributeType: 'S' },
1134+
{ attributeName: 'name', attributeType: 'S' },
1135+
{ attributeName: 'title', attributeType: 'S' },
1136+
];
1137+
1138+
it('populates non-null capacity for every GSI when billingMode flips to PROVISIONED and only one GSI declares its own throughput', () => {
1139+
currentState = {
1140+
...currentStateBase,
1141+
BillingModeSummary: { BillingMode: 'PAY_PER_REQUEST' },
1142+
GlobalSecondaryIndexes: [currentGsi('gsi1', 'name'), currentGsi('gsi2', 'title')],
1143+
};
1144+
endState = {
1145+
...baseTableDef,
1146+
billingMode: 'PROVISIONED',
1147+
provisionedThroughput: { readCapacityUnits: 10, writeCapacityUnits: 10 },
1148+
attributeDefinitions: twoIndexAttributeDefinitions,
1149+
globalSecondaryIndexes: [endStateGsi('gsi1', 'name', { read: 3, write: 4 }), endStateGsi('gsi2', 'title')],
1150+
};
1151+
1152+
nextUpdate = getNextAtomicUpdate(currentState, endState);
1153+
1154+
const gsiUpdates = nextUpdate!.GlobalSecondaryIndexUpdates!;
1155+
expect(gsiUpdates).toHaveLength(2);
1156+
// gsi1 keeps its own declared throughput, gsi2 inherits the table-level default
1157+
expect(gsiUpdates[0].Update).toEqual({
1158+
IndexName: 'gsi1',
1159+
ProvisionedThroughput: { ReadCapacityUnits: 3, WriteCapacityUnits: 4 },
1160+
});
1161+
expect(gsiUpdates[1].Update).toEqual({
1162+
IndexName: 'gsi2',
1163+
ProvisionedThroughput: { ReadCapacityUnits: 10, WriteCapacityUnits: 10 },
1164+
});
1165+
gsiUpdates.forEach((gsiUpdate) => {
1166+
expect(gsiUpdate.Update!.ProvisionedThroughput!.ReadCapacityUnits).toEqual(expect.any(Number));
1167+
expect(gsiUpdate.Update!.ProvisionedThroughput!.WriteCapacityUnits).toEqual(expect.any(Number));
1168+
});
1169+
});
1170+
1171+
// Regression: the GSI Update action used to source capacity from the end-state index only, emitting
1172+
// undefined read/write capacity when the index inherited the table-level throughput. DynamoDB then
1173+
// rejected UpdateTable with "Value null at 'globalSecondaryIndexUpdates.1.member.update.provisionedThroughput.*'".
1174+
it('falls back to table-level throughput when an existing GSI does not declare its own throughput', () => {
1175+
currentState = {
1176+
...currentStateBase,
1177+
BillingModeSummary: { BillingMode: 'PROVISIONED' },
1178+
ProvisionedThroughput: { ReadCapacityUnits: 10, WriteCapacityUnits: 10 },
1179+
GlobalSecondaryIndexes: [
1180+
currentGsi('gsi1', 'name', { read: 10, write: 10 }),
1181+
currentGsi('gsi2', 'title', { read: 5, write: 5 }),
1182+
],
1183+
};
1184+
endState = {
1185+
...baseTableDef,
1186+
billingMode: 'PROVISIONED',
1187+
provisionedThroughput: { readCapacityUnits: 10, writeCapacityUnits: 10 },
1188+
attributeDefinitions: twoIndexAttributeDefinitions,
1189+
globalSecondaryIndexes: [endStateGsi('gsi1', 'name'), endStateGsi('gsi2', 'title')],
1190+
};
1191+
1192+
nextUpdate = getNextAtomicUpdate(currentState, endState);
1193+
1194+
// gsi1 already matches the table-level default so only gsi2 needs an update, with real numbers
1195+
expect(nextUpdate!.GlobalSecondaryIndexUpdates).toEqual([
1196+
{
1197+
Update: {
1198+
IndexName: 'gsi2',
1199+
ProvisionedThroughput: { ReadCapacityUnits: 10, WriteCapacityUnits: 10 },
1200+
},
1201+
},
1202+
]);
1203+
});
1204+
1205+
it('does not emit a GSI throughput update when no throughput can be resolved', () => {
1206+
currentState = {
1207+
...currentStateBase,
1208+
BillingModeSummary: { BillingMode: 'PROVISIONED' },
1209+
GlobalSecondaryIndexes: [currentGsi('gsi1', 'name', { read: 5, write: 5 })],
1210+
};
1211+
endState = {
1212+
...baseTableDef,
1213+
billingMode: 'PROVISIONED',
1214+
attributeDefinitions: twoIndexAttributeDefinitions,
1215+
globalSecondaryIndexes: [endStateGsi('gsi1', 'name')],
1216+
};
1217+
1218+
expect(getNextAtomicUpdate(currentState, endState)).toBeUndefined();
1219+
});
1220+
1221+
it('omits ProvisionedThroughput on GSI updates when the table is billed PAY_PER_REQUEST', () => {
1222+
currentState = {
1223+
...currentStateBase,
1224+
BillingModeSummary: { BillingMode: 'PAY_PER_REQUEST' },
1225+
GlobalSecondaryIndexes: [currentGsi('gsi1', 'name', { read: 5, write: 5 })],
1226+
};
1227+
endState = {
1228+
...baseTableDef,
1229+
billingMode: 'PAY_PER_REQUEST',
1230+
attributeDefinitions: twoIndexAttributeDefinitions,
1231+
globalSecondaryIndexes: [endStateGsi('gsi1', 'name', { read: 9, write: 9 })],
1232+
};
1233+
1234+
expect(getNextAtomicUpdate(currentState, endState)).toBeUndefined();
1235+
});
1236+
});
11171237
});
11181238
});
11191239
describe('isTtlModified', () => {

packages/amplify-graphql-model-transformer/src/resources/amplify-dynamodb-table/amplify-table-manager-lambda/amplify-table-manager-handler.ts

Lines changed: 60 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,36 @@ const defaultPhysicalResourceId = (req: AWSLambda.CloudFormationCustomResourceEv
565565
}
566566
};
567567

568+
/**
569+
* Resolves the provisioned throughput that should be applied to a single global secondary index.
570+
*
571+
* Precedence is the index's own end-state throughput, falling back to the table-level end-state
572+
* throughput when the index does not declare one.
573+
*
574+
* @param endState The input table state from user
575+
* @param indexEndState The end state of the specific index, if it is present in the end state
576+
* @returns the read/write capacity pair to apply, or undefined when the index must not carry a
577+
* ProvisionedThroughput (table is billed PAY_PER_REQUEST, or neither source supplies a complete
578+
* read/write capacity pair). DynamoDB rejects a partially populated ProvisionedThroughput, so a
579+
* complete pair is the only valid non-undefined result.
580+
*/
581+
const resolveGsiProvisionedThroughput = (
582+
endState: CustomDDB.Input,
583+
indexEndState?: CustomDDB.GlobalSecondaryIndexProperty,
584+
): { readCapacityUnits: number; writeCapacityUnits: number } | undefined => {
585+
if (endState.billingMode === 'PAY_PER_REQUEST') {
586+
return undefined;
587+
}
588+
const candidate = indexEndState?.provisionedThroughput ?? endState.provisionedThroughput;
589+
if (candidate?.readCapacityUnits === undefined || candidate?.writeCapacityUnits === undefined) {
590+
return undefined;
591+
}
592+
return {
593+
readCapacityUnits: candidate.readCapacityUnits,
594+
writeCapacityUnits: candidate.writeCapacityUnits,
595+
};
596+
};
597+
568598
/**
569599
* You can only perform one of the following operations at once:
570600
- Modify the provisioned throughput settings of the table.
@@ -601,17 +631,25 @@ export const getNextAtomicUpdate = (currentState: TableDescription, endState: Cu
601631
// should be updated with the provisionedThroughput at the same time. Otherwise it will fail the parameter validation.
602632
// The table's throughput will be applied by default.
603633
if (isTableBillingModeModified && endState.billingMode === 'PROVISIONED') {
604-
const indexToBeUpdated = currentStateGSIs.map((gsiToUpdate) => {
605-
return {
634+
const endStateGSIsByName = new Map((endState.globalSecondaryIndexes ?? []).map((gsi) => [gsi.indexName, gsi]));
635+
const indexToBeUpdated = currentStateGSIs
636+
.map((gsiToUpdate) => ({
637+
indexName: gsiToUpdate.IndexName,
638+
throughput: resolveGsiProvisionedThroughput(endState, endStateGSIsByName.get(gsiToUpdate.IndexName!)),
639+
}))
640+
.filter(
641+
(gsi): gsi is { indexName: string | undefined; throughput: { readCapacityUnits: number; writeCapacityUnits: number } } =>
642+
gsi.throughput !== undefined,
643+
)
644+
.map((gsi) => ({
606645
Update: {
607-
IndexName: gsiToUpdate.IndexName,
646+
IndexName: gsi.indexName,
608647
ProvisionedThroughput: {
609-
ReadCapacityUnits: endState.provisionedThroughput?.readCapacityUnits,
610-
WriteCapacityUnits: endState.provisionedThroughput?.writeCapacityUnits,
648+
ReadCapacityUnits: gsi.throughput.readCapacityUnits,
649+
WriteCapacityUnits: gsi.throughput.writeCapacityUnits,
611650
},
612651
},
613-
};
614-
});
652+
}));
615653
updateInput = {
616654
...updateInput,
617655
GlobalSecondaryIndexUpdates: indexToBeUpdated.length > 0 ? indexToBeUpdated : undefined,
@@ -674,14 +712,8 @@ const getNextGSIUpdate = (currentState: TableDescription, endState: CustomDDB.In
674712

675713
const gsiToAdd = endStateGSIs.find(gsiRequiresCreationPredicate);
676714
if (gsiToAdd) {
677-
let gsiProvisionThroughput: any = gsiToAdd.provisionedThroughput;
678715
// When table is billing at `PROVISIONED` and no throughput defined for gsi, the table's throughput will be used by default
679-
if (endState.billingMode === 'PROVISIONED' && gsiToAdd.provisionedThroughput === undefined) {
680-
gsiProvisionThroughput = {
681-
readCapacityUnits: endState.provisionedThroughput?.readCapacityUnits,
682-
writeCapacityUnits: endState.provisionedThroughput?.writeCapacityUnits,
683-
};
684-
}
716+
const gsiProvisionThroughput: any = resolveGsiProvisionedThroughput(endState, gsiToAdd);
685717
const attributeNamesToInclude = gsiToAdd.keySchema.map((schema) => schema.attributeName);
686718
const gsiToAddAction = {
687719
IndexName: gsiToAdd.indexName,
@@ -704,35 +736,31 @@ const getNextGSIUpdate = (currentState: TableDescription, endState: CustomDDB.In
704736

705737
// The major update is the index provisioned throughput
706738
const gsiRequiresUpdatePredicate = (endStateGSI: CustomDDB.GlobalSecondaryIndexProperty): boolean => {
707-
if (
708-
endState.provisionedThroughput &&
709-
endState.provisionedThroughput.readCapacityUnits &&
710-
endState.provisionedThroughput.writeCapacityUnits &&
711-
currentStateGSINames.includes(endStateGSI.indexName)
712-
) {
713-
const currentStateGSI = currentStateGSIs.find((gsi) => gsi.IndexName === endStateGSI.indexName);
714-
if (currentStateGSI) {
715-
if (
716-
currentStateGSI.ProvisionedThroughput?.ReadCapacityUnits !== endStateGSI.provisionedThroughput?.readCapacityUnits ||
717-
currentStateGSI.ProvisionedThroughput?.WriteCapacityUnits !== endStateGSI.provisionedThroughput?.writeCapacityUnits
718-
) {
719-
return true;
720-
}
721-
}
739+
const resolvedThroughput = resolveGsiProvisionedThroughput(endState, endStateGSI);
740+
if (!resolvedThroughput || !currentStateGSINames.includes(endStateGSI.indexName)) {
741+
return false;
722742
}
723-
return false;
743+
const currentStateGSI = currentStateGSIs.find((gsi) => gsi.IndexName === endStateGSI.indexName);
744+
if (!currentStateGSI) {
745+
return false;
746+
}
747+
return (
748+
currentStateGSI.ProvisionedThroughput?.ReadCapacityUnits !== resolvedThroughput.readCapacityUnits ||
749+
currentStateGSI.ProvisionedThroughput?.WriteCapacityUnits !== resolvedThroughput.writeCapacityUnits
750+
);
724751
};
725752
const gsiToUpdate = endStateGSIs.find(gsiRequiresUpdatePredicate);
726753
if (gsiToUpdate) {
754+
const resolvedThroughput = resolveGsiProvisionedThroughput(endState, gsiToUpdate)!;
727755
return {
728756
TableName: currentState.TableName!,
729757
GlobalSecondaryIndexUpdates: [
730758
{
731759
Update: {
732760
IndexName: gsiToUpdate.indexName,
733761
ProvisionedThroughput: {
734-
ReadCapacityUnits: gsiToUpdate.provisionedThroughput?.readCapacityUnits!,
735-
WriteCapacityUnits: gsiToUpdate.provisionedThroughput?.writeCapacityUnits!,
762+
ReadCapacityUnits: resolvedThroughput.readCapacityUnits,
763+
WriteCapacityUnits: resolvedThroughput.writeCapacityUnits,
736764
},
737765
},
738766
},

0 commit comments

Comments
 (0)