Skip to content

Commit c783212

Browse files
authored
Merge branch 'main' into stream-is-closed
2 parents 1641695 + 2c2f531 commit c783212

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

572+
/**
573+
* Resolves the provisioned throughput that should be applied to a single global secondary index.
574+
*
575+
* Precedence is the index's own end-state throughput, falling back to the table-level end-state
576+
* throughput when the index does not declare one.
577+
*
578+
* @param endState The input table state from user
579+
* @param indexEndState The end state of the specific index, if it is present in the end state
580+
* @returns the read/write capacity pair to apply, or undefined when the index must not carry a
581+
* ProvisionedThroughput (table is billed PAY_PER_REQUEST, or neither source supplies a complete
582+
* read/write capacity pair). DynamoDB rejects a partially populated ProvisionedThroughput, so a
583+
* complete pair is the only valid non-undefined result.
584+
*/
585+
const resolveGsiProvisionedThroughput = (
586+
endState: CustomDDB.Input,
587+
indexEndState?: CustomDDB.GlobalSecondaryIndexProperty,
588+
): { readCapacityUnits: number; writeCapacityUnits: number } | undefined => {
589+
if (endState.billingMode === 'PAY_PER_REQUEST') {
590+
return undefined;
591+
}
592+
const candidate = indexEndState?.provisionedThroughput ?? endState.provisionedThroughput;
593+
if (candidate?.readCapacityUnits === undefined || candidate?.writeCapacityUnits === undefined) {
594+
return undefined;
595+
}
596+
return {
597+
readCapacityUnits: candidate.readCapacityUnits,
598+
writeCapacityUnits: candidate.writeCapacityUnits,
599+
};
600+
};
601+
572602
/**
573603
* You can only perform one of the following operations at once:
574604
- Modify the provisioned throughput settings of the table.
@@ -605,17 +635,25 @@ export const getNextAtomicUpdate = (currentState: TableDescription, endState: Cu
605635
// should be updated with the provisionedThroughput at the same time. Otherwise it will fail the parameter validation.
606636
// The table's throughput will be applied by default.
607637
if (isTableBillingModeModified && endState.billingMode === 'PROVISIONED') {
608-
const indexToBeUpdated = currentStateGSIs.map((gsiToUpdate) => {
609-
return {
638+
const endStateGSIsByName = new Map((endState.globalSecondaryIndexes ?? []).map((gsi) => [gsi.indexName, gsi]));
639+
const indexToBeUpdated = currentStateGSIs
640+
.map((gsiToUpdate) => ({
641+
indexName: gsiToUpdate.IndexName,
642+
throughput: resolveGsiProvisionedThroughput(endState, endStateGSIsByName.get(gsiToUpdate.IndexName!)),
643+
}))
644+
.filter(
645+
(gsi): gsi is { indexName: string | undefined; throughput: { readCapacityUnits: number; writeCapacityUnits: number } } =>
646+
gsi.throughput !== undefined,
647+
)
648+
.map((gsi) => ({
610649
Update: {
611-
IndexName: gsiToUpdate.IndexName,
650+
IndexName: gsi.indexName,
612651
ProvisionedThroughput: {
613-
ReadCapacityUnits: endState.provisionedThroughput?.readCapacityUnits,
614-
WriteCapacityUnits: endState.provisionedThroughput?.writeCapacityUnits,
652+
ReadCapacityUnits: gsi.throughput.readCapacityUnits,
653+
WriteCapacityUnits: gsi.throughput.writeCapacityUnits,
615654
},
616655
},
617-
};
618-
});
656+
}));
619657
updateInput = {
620658
...updateInput,
621659
GlobalSecondaryIndexUpdates: indexToBeUpdated.length > 0 ? indexToBeUpdated : undefined,
@@ -678,14 +716,8 @@ const getNextGSIUpdate = (currentState: TableDescription, endState: CustomDDB.In
678716

679717
const gsiToAdd = endStateGSIs.find(gsiRequiresCreationPredicate);
680718
if (gsiToAdd) {
681-
let gsiProvisionThroughput: any = gsiToAdd.provisionedThroughput;
682719
// When table is billing at `PROVISIONED` and no throughput defined for gsi, the table's throughput will be used by default
683-
if (endState.billingMode === 'PROVISIONED' && gsiToAdd.provisionedThroughput === undefined) {
684-
gsiProvisionThroughput = {
685-
readCapacityUnits: endState.provisionedThroughput?.readCapacityUnits,
686-
writeCapacityUnits: endState.provisionedThroughput?.writeCapacityUnits,
687-
};
688-
}
720+
const gsiProvisionThroughput: any = resolveGsiProvisionedThroughput(endState, gsiToAdd);
689721
const attributeNamesToInclude = gsiToAdd.keySchema.map((schema) => schema.attributeName);
690722
const gsiToAddAction = {
691723
IndexName: gsiToAdd.indexName,
@@ -708,35 +740,31 @@ const getNextGSIUpdate = (currentState: TableDescription, endState: CustomDDB.In
708740

709741
// The major update is the index provisioned throughput
710742
const gsiRequiresUpdatePredicate = (endStateGSI: CustomDDB.GlobalSecondaryIndexProperty): boolean => {
711-
if (
712-
endState.provisionedThroughput &&
713-
endState.provisionedThroughput.readCapacityUnits &&
714-
endState.provisionedThroughput.writeCapacityUnits &&
715-
currentStateGSINames.includes(endStateGSI.indexName)
716-
) {
717-
const currentStateGSI = currentStateGSIs.find((gsi) => gsi.IndexName === endStateGSI.indexName);
718-
if (currentStateGSI) {
719-
if (
720-
currentStateGSI.ProvisionedThroughput?.ReadCapacityUnits !== endStateGSI.provisionedThroughput?.readCapacityUnits ||
721-
currentStateGSI.ProvisionedThroughput?.WriteCapacityUnits !== endStateGSI.provisionedThroughput?.writeCapacityUnits
722-
) {
723-
return true;
724-
}
725-
}
743+
const resolvedThroughput = resolveGsiProvisionedThroughput(endState, endStateGSI);
744+
if (!resolvedThroughput || !currentStateGSINames.includes(endStateGSI.indexName)) {
745+
return false;
726746
}
727-
return false;
747+
const currentStateGSI = currentStateGSIs.find((gsi) => gsi.IndexName === endStateGSI.indexName);
748+
if (!currentStateGSI) {
749+
return false;
750+
}
751+
return (
752+
currentStateGSI.ProvisionedThroughput?.ReadCapacityUnits !== resolvedThroughput.readCapacityUnits ||
753+
currentStateGSI.ProvisionedThroughput?.WriteCapacityUnits !== resolvedThroughput.writeCapacityUnits
754+
);
728755
};
729756
const gsiToUpdate = endStateGSIs.find(gsiRequiresUpdatePredicate);
730757
if (gsiToUpdate) {
758+
const resolvedThroughput = resolveGsiProvisionedThroughput(endState, gsiToUpdate)!;
731759
return {
732760
TableName: currentState.TableName!,
733761
GlobalSecondaryIndexUpdates: [
734762
{
735763
Update: {
736764
IndexName: gsiToUpdate.indexName,
737765
ProvisionedThroughput: {
738-
ReadCapacityUnits: gsiToUpdate.provisionedThroughput?.readCapacityUnits!,
739-
WriteCapacityUnits: gsiToUpdate.provisionedThroughput?.writeCapacityUnits!,
766+
ReadCapacityUnits: resolvedThroughput.readCapacityUnits,
767+
WriteCapacityUnits: resolvedThroughput.writeCapacityUnits,
740768
},
741769
},
742770
},

0 commit comments

Comments
 (0)