diff --git a/packages/amplify-cli/adr/006-social-auth.md b/packages/amplify-cli/adr/006-social-auth.md deleted file mode 100644 index e61d8af51dc..00000000000 --- a/packages/amplify-cli/adr/006-social-auth.md +++ /dev/null @@ -1,346 +0,0 @@ -# ADR-005: Social Auth IDP & Domain Handling During Refactor - -## Status - -Accepted - -## Context - -When `amplify gen2-migration refactor` runs on an app with social identity providers -(Google, Facebook), the command must transfer the UserPool from the Gen1 stack to the -Gen2 stack. The UserPool carries associated resources — identity providers (IDPs) and a -UserPool domain — that differ fundamentally between Gen1 and Gen2. - -### How IDPs and Domains Differ Between Gen1 and Gen2 - -In Gen1, the Cognito domain and identity providers are **not native CloudFormation -resources**. They're created by Lambda-backed custom resources: - -``` -Gen1 auth stack: - HostedUICustomResourceInputs: Custom::LambdaCallout → creates UserPoolDomain - HostedUIProvidersCustomResourceInputs: Custom::LambdaCallout → creates Google/Facebook IDPs -``` - -CloudFormation doesn't know these are Cognito IDPs or a domain — it just knows it -invoked a Lambda. The physical IDPs and domain exist on the UserPool but are not -tracked as native CFN resources. - -In Gen2, the same resources are **native CloudFormation resources**: - -``` -Gen2 auth stack: - amplifyAuthUserPoolUserPoolDomain1F688B5B: AWS::Cognito::UserPoolDomain - amplifyAuthGoogleIdPA9736819: AWS::Cognito::UserPoolIdentityProvider - amplifyAuthFacebookIDP7CB5B5CC: AWS::Cognito::UserPoolIdentityProvider -``` - -Gen2 IDP resources reference `AmplifySecretFetcherResource` via `Fn::GetAtt` to fetch -OAuth secrets from SSM Parameter Store at deploy time. - -### Why Moving IDPs Through the Holding Stack Fails - -Two independent reasons prevent UserPoolDomain and UserPoolIdentityProvider from going -through the holding stack alongside the 4 core resources: - -1. **Orphaned Fn::GetAtt references**: IDP resources have `Fn::GetAtt` references to - `AmplifySecretFetcherResource` (stays in Gen2). Moving IDPs to the holding stack - breaks these references — `Template error: instance of Fn::GetAtt references -undefined resource`. - -2. **StackRefactor API property validation**: Replacing orphaned `Fn::GetAtt` with - placeholder strings passes structural validation but fails because the StackRefactor - API validates that template property values match live resource state — - `Resource does not match the destination resource's properties`. - -Note: the IdentityPool (a core resource that MUST go through holding) has the same -`Fn::GetAtt` → `AmplifySecretFetcherResource` issue via its `SupportedLoginProviders` -property. This is solved differently — the generate step removes `SupportedLoginProviders` -entirely via CDK escape hatch (see "SupportedLoginProviders Removal" below). - -### Approaches Evaluated - -**Case 1 — Do nothing.** The refactor succeeds but the next deploy fails with -`DuplicateProviderException` (IDPs) and `InvalidRequest` (domain). Social login works -immediately after refactor but all future deployments are blocked. - -**Case 2 — Override.** Add IDP types to `RESOURCE_TYPES` so they move to the holding -stack. On the next deploy, CDK synthesizes fresh IDP and domain resources. However, the -Gen1 pool already has IDPs and a domain (from LambdaCallout), so CFN CREATE fails. The -user must manually delete Gen1's IDPs and domain via Cognito API before deploying — -causing auth downtime during the gap. - -**Case 3 — Orphan + Import.** Orphan IDPs and domain from the source stack with -`DeletionPolicy: Retain` (physical resources survive), then re-import them into the -target stack using CloudFormation's `CreateChangeSet(ChangeSetType=IMPORT)`. No -deletion, no recreation, no downtime. - -Why import works: CFN import only uses `ResourceIdentifier` (UserPoolId + -ProviderName/Domain) to adopt physical resources. Template property values are metadata -for future updates — empty/placeholder values are accepted. This is distinct from the -StackRefactor API, which validates property match against live state. - -Why import is possible: Gen1's IDPs and domain are created by `Custom::LambdaCallout`. -CFN tracks them as custom resource invocations, not as native Cognito types. They're -physically present on the pool but **CFN-unmanaged as native types**, making them -eligible for import. - -## Evidence - -### Case 2 failure (override approach) - -Experimentally verified against a test UserPool: - -| Resource | CFN CREATE Result | -| ------------------------------------- | ---------------------------------- | -| `UserPoolIdentityProvider` (Google) | `HandlerErrorCode: AlreadyExists` | -| `UserPoolIdentityProvider` (Facebook) | `HandlerErrorCode: AlreadyExists` | -| `UserPoolDomain` (different prefix) | `HandlerErrorCode: InvalidRequest` | - -Both test stacks reached `ROLLBACK_COMPLETE`. Failed CREATE attempts did not damage -existing physical resources. - -### Case 3 success (orphan + import approach) - -Verified in two phases: - -**Raw CFN API import**: - -- `CreateChangeSet(ChangeSetType=IMPORT)` succeeded for all three resource types -- Stack reached `IMPORT_COMPLETE`, all resources `UPDATE_COMPLETE` -- Non-destructive: `CreationDate` and `LastModifiedDate` unchanged -- Import identifier for `UserPoolDomain` requires both `{UserPoolId, Domain}` -- Template only needs resource type, identifiers, and `DeletionPolicy` — provider - details and attribute mappings are not validated - -**CDK import + deploy**: - -- `cdk import --resource-mapping --force` succeeded (fully non-interactive) -- `cdk deploy` succeeded — only `CDKMetadata` created, imported resources untouched -- `cdk diff` post-deploy: zero differences -- Resource mapping keys must use CFN logical IDs, not CDK construct paths - -### Key finding: StackRefactor vs CFN Import - -| | StackRefactor API | CFN Import | -| ------------------- | ------------------------------------------ | --------------------------------------- | -| Property validation | Validates template values match live state | Does NOT validate — values are metadata | -| Identity mechanism | Logical ID matching across stacks | ResourceIdentifier tuple | -| Use case | Moving resources between stacks | Adopting existing physical resources | - -## Decision - -**Case 3 (Orphan + Import)** is the chosen approach. - -### Implementation - -#### Core Resource Types - -Only 4 core types move through the holding stack via the standard refactor flow: - -```typescript -export const RESOURCE_TYPES = [ - USER_POOL_TYPE, // AWS::Cognito::UserPool - USER_POOL_CLIENT_TYPE, // AWS::Cognito::UserPoolClient - IDENTITY_POOL_TYPE, // AWS::Cognito::IdentityPool - IDENTITY_POOL_ROLE_ATTACHMENT_TYPE, // AWS::Cognito::IdentityPoolRoleAttachment -]; -``` - -UserPoolDomain and UserPoolIdentityProvider are handled via the orphan + import path. - -#### Forward Flow - -``` -updateSource() → resolves Gen1 template (no-op changeset) -updateTarget() → resolves Gen2 template (no-op changeset) -beforeMove() → super.beforeMove() moves 4 core resources to holding - → Orphan IDPs + domain from Gen2 (Retain keeps physical resources) -move() → super.move() moves Gen1 core resources into Gen2 - → Import Gen1 IDPs + domain into Gen2 under the Gen2-original logical IDs -afterMove() → empty (not overridden) -``` - -#### Rollback Flow - -``` -updateSource() → resolves Gen2 template (no-op changeset) -updateTarget() → resolves Gen1 template (no-op changeset) -beforeMove() → empty (not overridden) -move() → super.move() moves core resources Gen2 → Gen1 - → Orphan IDPs + domain from Gen2 (imported during forward) -afterMove() → super.afterMove() restores P2 core resources from holding → Gen2 - → Import Gen2's original IDPs + domain back into Gen2 -``` - -#### Import Mechanics - -The import step uses `Cfn.importResources()` which wraps `CreateChangeSet(IMPORT)` + -`ExecuteChangeSet` + wait. Template additions use empty `ProviderDetails: {}` and -`AttributeMapping: {}` — CFN import does not validate these values. The next Gen2 -deploy regenerates real values from `AmplifySecretFetcherResource`. - -**Import identifiers:** - -| Resource Type | Identifier Keys | -| ---------------------------------------- | ---------------------------- | -| `AWS::Cognito::UserPoolDomain` | `{UserPoolId, Domain}` | -| `AWS::Cognito::UserPoolIdentityProvider` | `{UserPoolId, ProviderName}` | - -#### Orphan Mechanics - -The orphan step uses `Cfn.orphan()` which re-fetches the template at execute time, -verifies `DeletionPolicy: Retain` on every target resource (throws if missing), removes -the resources, and runs an UpdateStack. Because Retain is present, CloudFormation -disassociates the logical IDs from the physical resources without deleting them. - -#### Pool Discovery - -The import step needs the UserPool physical ID to build resource identifiers. Discovery -uses `StackFacade.fetchUserPoolId()` — `DescribeStackResources` filtered by -`ResourceType: AWS::Cognito::UserPool` → `PhysicalResourceId`. - -- Forward `move()`: reads UserPoolId from Gen1 `amplify-meta.json` output. -- Rollback `afterMove()`: the Gen2-original pool sits in the holding stack at plan - time (super.afterMove restores it only at execute time), so the pool ID is read from - the holding stack. - -#### SupportedLoginProviders Removal - -Gen2's `defineAuth` with `externalProviders` generates `SupportedLoginProviders` on the -IdentityPool, mapping social provider domains to client IDs via `Fn::GetAtt` → -`AmplifySecretFetcherResource`. This property enables direct federation (signing in to -the IdentityPool directly with a social provider token, bypassing the UserPool). - -Amplify's social login flow does not use direct federation — it routes through the -UserPool's Hosted UI → UserPoolIdentityProvider → UserPool tokens → IdentityPool via -`CognitoIdentityProviders`. Gen1 never sets `SupportedLoginProviders`. The `generate` -command removes it via a CDK escape hatch: - -```typescript -cfnIdentityPool.addPropertyDeletionOverride('SupportedLoginProviders'); -``` - -This eliminates the `Fn::GetAtt` → `AmplifySecretFetcherResource` reference on the -IdentityPool, so the IdentityPool can move through the holding stack without broken -references and `updateTarget()` produces an empty changeset. - -#### DeletionPolicy: Retain - -Retain is set at three points to ensure physical resources survive both the orphan -operations and eventual Gen1 decommission: - -1. **Lock step** — sets Retain on Gen1's `HostedUICustomResourceInputs` and - `HostedUIProvidersCustomResourceInputs` (Lambda-backed custom resources). Matched by - logical ID rather than type to avoid retaining unrelated `Custom::LambdaCallout` - resources. Prevents Lambda delete handlers from destroying IDPs/domain during Gen1 - stack teardown. - -2. **Generate step** — emits CDK escape hatches that set Retain on Gen2's - `UserPoolDomain` and `UserPoolIdentityProvider` resources. Ensures every deploy - preserves Retain so the forward orphan step's safety check passes. - -3. **Import step** — sets Retain and UpdateReplacePolicy inline on all imported resource - definitions. Ensures the rollback orphan step's safety check passes. - -The orphan operation validates Retain at execute time (not plan time) as defense-in- -depth — if a manual template edit removed Retain between plan and execute, the operation -aborts before any destructive mutation. - -#### NoEcho Parameter Handling - -`DescribeStacks` masks NoEcho parameter values as `"****"`. Passing that back to -UpdateStack/CreateChangeSet would re-resolve Refs to the literal `"****"`, crashing -Lambda-backed custom resources that `JSON.parse` the value (e.g., `hostedUIProviderCreds` -in Gen1 auth stacks). `resolveNoEchoParameters()` sends `UsePreviousValue: true` for -NoEcho params so CloudFormation uses the real stored value. Applied in all four resolve -paths (forward/rollback resolveSource/resolveTarget). - -#### Domain Handling - -The Gen1 domain prefix (e.g., `myapp-devenv`) differs from Gen2's auto-generated prefix -(e.g., `a1b2c3d4e5f6g7h8i9j0`). The import uses the Gen1 domain value. On the next Gen2 -deploy, `domain` is a replacement property — if the template specifies a different -prefix, CloudFormation would delete and recreate the domain (changing the hosted UI URL -and breaking OAuth redirect URIs). - -The `generate` command emits a domain override escape hatch: - -```typescript -cfnUserPoolDomain.domain = ''; -``` - -This preserves the Gen1 domain prefix after import, so subsequent deploys see zero drift. - -### Additional Fixes - -#### Output resolver (`cfn-output-resolver.ts`) - -- **Ref fallback to PhysicalResourceId**: Intra-stack Refs not exposed as stack Outputs - now resolve via `DescribeStackResources` -- **Skip Custom:: resources in GetAtt**: Custom resource GetAtt attributes come from - Lambda response data, not the physical resource ID — left unresolved to prevent - incorrect substitution -- **ARN double-nesting prevention**: When a runtime output value is already a full ARN, - returned directly instead of being wrapped - -#### Generate fixes (`auth.renderer.ts`) - -- Fixed `authorized_scopes` → `authorize_scopes` (Cognito API field name) -- Stopped filtering provider scopes against a hardcoded allowlist -- Preserves custom attribute mappings alongside standard ones -- Emits `SupportedLoginProviders` deletion override on IdentityPool -- Emits domain override with Gen1 prefix -- Fixed cfnIdentityPool to declare exactly once - -#### Dead code removal - -- Deleted `oauth-values-retriever.ts` — real OAuth secrets are not needed during refactor -- Removed `resolveOAuthParameters` hook from `ForwardCategoryRefactorer` - -## Consequences - -### What changes - -- `auth-cognito-forward.ts`: Overrides `beforeMove()` (orphan) and `move()` (import). - Exports shared pure functions: `buildImportSpec`, `extractSocialAuthLogicalIds`, - `extractImportLogicalIds`, `renderImportTable`. -- `auth-cognito-rollback.ts`: Overrides `move()` (orphan) and `afterMove()` (import). -- `cfn.ts`: New `orphan()` method (remove + update with Retain check) and - `importResources()` method (CreateChangeSet IMPORT + execute + wait). -- `resource-types.ts`: `AUTH_IMPORT_RESOURCES_TO_RETAIN` and - `AUTH_HOSTED_UI_RESOURCES_TO_RETAIN` constants. -- `lock.ts`: Retains Gen1 HostedUI custom resources by logical ID. -- `cfn-parameter-resolver.ts`: New `resolveNoEchoParameters()` function. -- `cfn-output-resolver.ts`: Ref fallback, Custom:: skip, ARN de-duplication. - -### What stays the same - -- The core refactor workflow phases (`resolveSource` → `updateSource` → `resolveTarget` - → `updateTarget` → `beforeMove` → `move` → `afterMove`) are preserved. -- Non-social-auth apps are completely unaffected — orphan/import steps check for - UserPoolDomain/UserPoolIdentityProvider resources and skip when absent. -- The holding stack pattern is preserved for core resources. - -### Risks - -- **Domain mismatch on next deploy**: If the generated code does not override the domain - to match Gen1's prefix, the next deploy triggers a domain replacement, changing the - hosted UI URL and requiring OAuth redirect URI updates in provider consoles. - -- **Secret mismatch**: If SSM secrets at `/amplify/shared/{appId}/` contain different - credentials than what Gen1's IDPs use, the next deploy updates the IDP credentials. - The migration guide instructs users to configure SSM secrets matching their Gen1 - credentials. - -### Comparison - -| | Case 1: Do Nothing | Case 2: Override | Case 3: Orphan + Import | -| ------------------------ | --------------------------------------- | -------------------------------- | ---------------------------- | -| Code complexity | None | Minimal | High | -| Refactor succeeds | Yes | Yes | Yes | -| Auth works post-refactor | Yes | Yes | Yes | -| Next deploy succeeds | **No** | **No** (without manual deletion) | **Yes** | -| Auth downtime | None during refactor, blocked on deploy | Minutes (IDP deletion gap) | **None** | -| Domain preserved | Orphaned | Changed (breaks redirect URIs) | **Yes** | -| Rollback complexity | Simple | Simple | Higher (mitigated by Retain) | diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.generator.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.generator.test.ts index e777b4b24a9..6b460b08b68 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.generator.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.generator.test.ts @@ -1264,4 +1264,464 @@ describe('FunctionGenerator', () => { const generator = createFunctionGenerator({ gen1App, backendGenerator, packageJsonGenerator, outputDir }); await expect(generator.plan()).rejects.toThrow("unsupported runtime 'python3.9'"); }); + + it('uses original model name casing from schema for table grants and env vars', async () => { + const gen1App = await createGen1App({ + providers: { awscloudformation: { StackName: 'amplify-test-main-123456', Region: 'us-east-1' } }, + function: { + myFunc: { + service: 'Lambda', + output: { Name: 'myFunc-main-abc', Arn: 'arn:aws:lambda:us-east-1:123:function:myFunc-main-abc' }, + }, + }, + api: { + myApi: { service: 'AppSync' }, + }, + }); + jest.spyOn(gen1App, 'resourceMetaOutput').mockReturnValue('myFunc-main-abc'); + jest.spyOn(gen1App, 'json').mockReturnValue({ + Resources: { + AmplifyResourcesPolicy: { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyDocument: { + Statement: [{ Effect: 'Allow', Action: ['dynamodb:GetItem'], Resource: [{ Ref: 'apiMyApiTable' }] }], + }, + }, + }, + }, + }); + jest.spyOn(gen1App, 'file').mockImplementation((filePath: string) => { + if (filePath.includes('build/schema.graphql')) { + return 'type ModelrandomItemConnection { items: [randomItem] }\ntype ModelMealPlanConnection { items: [MealPlan] }'; + } + return '{}'; + }); + jest.spyOn(gen1App, 'fileExists').mockReturnValue(false); + jest.spyOn(gen1App.aws, 'fetchFunctionConfig').mockResolvedValue({ + FunctionName: 'myFunc-main-abc', + Handler: 'index.handler', + Timeout: 30, + MemorySize: 128, + Runtime: 'nodejs18.x', + Environment: { + Variables: { + API_MYAPI_RANDOMITEMTABLE_NAME: 'randomItem-abc-main', + API_MYAPI_RANDOMITEMTABLE_ARN: 'arn:aws:dynamodb:us-east-1:123:table/randomItem-abc-main', + API_MYAPI_MEALPLANTABLE_NAME: 'MealPlan-abc-main', + API_MYAPI_MEALPLANTABLE_ARN: 'arn:aws:dynamodb:us-east-1:123:table/MealPlan-abc-main', + }, + }, + }); + jest.spyOn(gen1App.aws, 'fetchFunctionSchedule').mockResolvedValue(undefined); + + const generator = createFunctionGenerator({ gen1App, backendGenerator, packageJsonGenerator, outputDir }); + const ops = await generator.plan(); + await ops[0].execute(); + + const resourceTs = writtenFile('resource.ts'); + + // The table reference should use original model names, not naive capitalization + expect(resourceTs).toContain('randomItem'); + expect(resourceTs).toContain('MealPlan'); + // Should NOT contain incorrectly cased versions + expect(resourceTs).not.toContain('Randomitem'); + expect(resourceTs).not.toContain('Mealplan'); + }); + + it('falls back to raw schema regex when build/schema.graphql is not available', async () => { + const gen1App = await createGen1App({ + providers: { awscloudformation: { StackName: 'amplify-test-main-123456', Region: 'us-east-1' } }, + function: { + myFunc: { + service: 'Lambda', + output: { Name: 'myFunc-main-abc', Arn: 'arn:aws:lambda:us-east-1:123:function:myFunc-main-abc' }, + }, + }, + api: { + myApi: { service: 'AppSync' }, + }, + }); + jest.spyOn(gen1App, 'resourceMetaOutput').mockReturnValue('myFunc-main-abc'); + jest.spyOn(gen1App, 'json').mockReturnValue({ + Resources: { + AmplifyResourcesPolicy: { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyDocument: { + Statement: [{ Effect: 'Allow', Action: ['dynamodb:GetItem'], Resource: [{ Ref: 'apiMyApiTable' }] }], + }, + }, + }, + }, + }); + jest.spyOn(gen1App, 'file').mockImplementation((filePath: string) => { + if (filePath.includes('build/schema.graphql')) { + throw new Error('File not found'); + } + if (filePath.includes('schema.graphql')) { + return 'type randomItem @model { id: ID! }\ntype MealPlan @model { id: ID! }'; + } + return '{}'; + }); + jest.spyOn(gen1App, 'fileExists').mockImplementation((filePath: string) => { + return filePath.includes('schema.graphql') && !filePath.includes('build'); + }); + jest.spyOn(gen1App.aws, 'fetchFunctionConfig').mockResolvedValue({ + FunctionName: 'myFunc-main-abc', + Handler: 'index.handler', + Timeout: 30, + MemorySize: 128, + Runtime: 'nodejs18.x', + Environment: { + Variables: { + API_MYAPI_RANDOMITEMTABLE_NAME: 'randomItem-abc-main', + API_MYAPI_RANDOMITEMTABLE_ARN: 'arn:aws:dynamodb:us-east-1:123:table/randomItem-abc-main', + API_MYAPI_MEALPLANTABLE_NAME: 'MealPlan-abc-main', + API_MYAPI_MEALPLANTABLE_ARN: 'arn:aws:dynamodb:us-east-1:123:table/MealPlan-abc-main', + }, + }, + }); + jest.spyOn(gen1App.aws, 'fetchFunctionSchedule').mockResolvedValue(undefined); + + const generator = createFunctionGenerator({ gen1App, backendGenerator, packageJsonGenerator, outputDir }); + const ops = await generator.plan(); + await ops[0].execute(); + + const resourceTs = writtenFile('resource.ts'); + expect(resourceTs).toContain('randomItem'); + expect(resourceTs).toContain('MealPlan'); + expect(resourceTs).not.toContain('Randomitem'); + expect(resourceTs).not.toContain('Mealplan'); + }); + + it('handles models with multiple directives before @model in raw schema fallback', async () => { + const gen1App = await createGen1App({ + providers: { awscloudformation: { StackName: 'amplify-test-main-123456', Region: 'us-east-1' } }, + function: { + myFunc: { + service: 'Lambda', + output: { Name: 'myFunc-main-abc', Arn: 'arn:aws:lambda:us-east-1:123:function:myFunc-main-abc' }, + }, + }, + api: { + myApi: { service: 'AppSync' }, + }, + }); + jest.spyOn(gen1App, 'resourceMetaOutput').mockReturnValue('myFunc-main-abc'); + jest.spyOn(gen1App, 'json').mockReturnValue({ + Resources: { + AmplifyResourcesPolicy: { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyDocument: { + Statement: [{ Effect: 'Allow', Action: ['dynamodb:GetItem'], Resource: [{ Ref: 'apiMyApiTable' }] }], + }, + }, + }, + }, + }); + const rawSchema = [ + 'type todoItem @auth(rules: [{allow: owner}]) @model { id: ID! title: String! }', + 'type BlogPost @searchable @auth(rules: [{allow: public}]) @model { id: ID! content: String }', + ].join('\n'); + jest.spyOn(gen1App, 'file').mockImplementation((filePath: string) => { + if (filePath.includes('build/schema.graphql')) { + throw new Error('File not found'); + } + if (filePath.includes('schema.graphql')) { + return rawSchema; + } + return '{}'; + }); + jest.spyOn(gen1App, 'fileExists').mockImplementation((filePath: string) => { + return filePath.includes('schema.graphql') && !filePath.includes('build'); + }); + jest.spyOn(gen1App.aws, 'fetchFunctionConfig').mockResolvedValue({ + FunctionName: 'myFunc-main-abc', + Handler: 'index.handler', + Timeout: 30, + MemorySize: 128, + Runtime: 'nodejs18.x', + Environment: { + Variables: { + API_MYAPI_TODOITEMTABLE_NAME: 'todoItem-abc-main', + API_MYAPI_TODOITEMTABLE_ARN: 'arn:aws:dynamodb:us-east-1:123:table/todoItem-abc-main', + API_MYAPI_BLOGPOSTTABLE_NAME: 'BlogPost-abc-main', + API_MYAPI_BLOGPOSTTABLE_ARN: 'arn:aws:dynamodb:us-east-1:123:table/BlogPost-abc-main', + }, + }, + }); + jest.spyOn(gen1App.aws, 'fetchFunctionSchedule').mockResolvedValue(undefined); + + const generator = createFunctionGenerator({ gen1App, backendGenerator, packageJsonGenerator, outputDir }); + const ops = await generator.plan(); + await ops[0].execute(); + + const resourceTs = writtenFile('resource.ts'); + expect(resourceTs).toContain('todoItem'); + expect(resourceTs).toContain('BlogPost'); + expect(resourceTs).not.toContain('Todoitem'); + expect(resourceTs).not.toContain('Blogpost'); + }); + + it('recovers a @model(queries: null) model missing from the build schema by merging the raw schema', async () => { + // A model declared `@model(queries: null)` emits no ModelXConnection type, + // so it is absent from build/schema.graphql. It must still be recovered + // from the raw schema; otherwise it falls through to naive casing — the + // exact bug this fix targets. + const gen1App = await createGen1App({ + providers: { awscloudformation: { StackName: 'amplify-test-main-123456', Region: 'us-east-1' } }, + function: { + myFunc: { + service: 'Lambda', + output: { Name: 'myFunc-main-abc', Arn: 'arn:aws:lambda:us-east-1:123:function:myFunc-main-abc' }, + }, + }, + api: { + myApi: { service: 'AppSync' }, + }, + }); + jest.spyOn(gen1App, 'resourceMetaOutput').mockReturnValue('myFunc-main-abc'); + jest.spyOn(gen1App, 'json').mockReturnValue({ + Resources: { + AmplifyResourcesPolicy: { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyDocument: { + Statement: [{ Effect: 'Allow', Action: ['dynamodb:GetItem'], Resource: [{ Ref: 'apiMyApiTable' }] }], + }, + }, + }, + }, + }); + jest.spyOn(gen1App, 'file').mockImplementation((filePath: string) => { + // Build schema only contains the Connection type for the model WITH a + // list query (blogPost). secretNote (queries: null) is deliberately absent. + if (filePath.includes('build/schema.graphql')) { + return 'type ModelblogPostConnection { items: [blogPost] }\ntype blogPost @model { id: ID! }'; + } + if (filePath.includes('schema.graphql')) { + return 'type blogPost @model { id: ID! }\ntype secretNote @model(queries: null) { id: ID! }'; + } + return '{}'; + }); + jest.spyOn(gen1App, 'fileExists').mockImplementation((filePath: string) => { + return filePath.includes('schema.graphql') && !filePath.includes('build'); + }); + jest.spyOn(gen1App.aws, 'fetchFunctionConfig').mockResolvedValue({ + FunctionName: 'myFunc-main-abc', + Handler: 'index.handler', + Timeout: 30, + MemorySize: 128, + Runtime: 'nodejs18.x', + Environment: { + Variables: { + API_MYAPI_BLOGPOSTTABLE_NAME: 'blogPost-abc-main', + API_MYAPI_BLOGPOSTTABLE_ARN: 'arn:aws:dynamodb:us-east-1:123:table/blogPost-abc-main', + API_MYAPI_SECRETNOTETABLE_NAME: 'secretNote-abc-main', + API_MYAPI_SECRETNOTETABLE_ARN: 'arn:aws:dynamodb:us-east-1:123:table/secretNote-abc-main', + }, + }, + }); + jest.spyOn(gen1App.aws, 'fetchFunctionSchedule').mockResolvedValue(undefined); + + const generator = createFunctionGenerator({ gen1App, backendGenerator, packageJsonGenerator, outputDir }); + const ops = await generator.plan(); + await ops[0].execute(); + + const resourceTs = writtenFile('resource.ts'); + expect(resourceTs).toContain("backend.data.resources.tables['blogPost']"); + // secretNote is only in the raw schema — the merge must recover it. + expect(resourceTs).toContain("backend.data.resources.tables['secretNote']"); + expect(resourceTs).not.toContain('Secretnote'); + }); + + it('does not attribute a non-model type as a table when it precedes real models', async () => { + // A non-model `type Query` sits before the real models. It has no @model, + // so it must never become a table reference, and the models after it must + // still be cased correctly. (The scan is also bounded to each type as + // defense-in-depth so a hypothetical bodyless type cannot bleed into the + // next — unreachable for valid GraphQL, hence not separately testable.) + const gen1App = await createGen1App({ + providers: { awscloudformation: { StackName: 'amplify-test-main-123456', Region: 'us-east-1' } }, + function: { + myFunc: { + service: 'Lambda', + output: { Name: 'myFunc-main-abc', Arn: 'arn:aws:lambda:us-east-1:123:function:myFunc-main-abc' }, + }, + }, + api: { + myApi: { service: 'AppSync' }, + }, + }); + jest.spyOn(gen1App, 'resourceMetaOutput').mockReturnValue('myFunc-main-abc'); + jest.spyOn(gen1App, 'json').mockReturnValue({ + Resources: { + AmplifyResourcesPolicy: { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyDocument: { + Statement: [{ Effect: 'Allow', Action: ['dynamodb:GetItem'], Resource: [{ Ref: 'apiMyApiTable' }] }], + }, + }, + }, + }, + }); + jest.spyOn(gen1App, 'file').mockImplementation((filePath: string) => { + if (filePath.includes('build/schema.graphql')) { + throw new Error('File not found'); + } + if (filePath.includes('schema.graphql')) { + // `Query` is a non-model type declared before the real models. + return [ + 'type Query { customField: String }', + 'type blogPost @model { id: ID! }', + 'type secretNote @model(queries: null) { id: ID! }', + ].join('\n'); + } + return '{}'; + }); + jest.spyOn(gen1App, 'fileExists').mockImplementation((filePath: string) => { + return filePath.includes('schema.graphql') && !filePath.includes('build'); + }); + jest.spyOn(gen1App.aws, 'fetchFunctionConfig').mockResolvedValue({ + FunctionName: 'myFunc-main-abc', + Handler: 'index.handler', + Timeout: 30, + MemorySize: 128, + Runtime: 'nodejs18.x', + Environment: { + Variables: { + API_MYAPI_BLOGPOSTTABLE_NAME: 'blogPost-abc-main', + API_MYAPI_BLOGPOSTTABLE_ARN: 'arn:aws:dynamodb:us-east-1:123:table/blogPost-abc-main', + API_MYAPI_SECRETNOTETABLE_NAME: 'secretNote-abc-main', + API_MYAPI_SECRETNOTETABLE_ARN: 'arn:aws:dynamodb:us-east-1:123:table/secretNote-abc-main', + }, + }, + }); + jest.spyOn(gen1App.aws, 'fetchFunctionSchedule').mockResolvedValue(undefined); + + const generator = createFunctionGenerator({ gen1App, backendGenerator, packageJsonGenerator, outputDir }); + const ops = await generator.plan(); + await ops[0].execute(); + + const resourceTs = writtenFile('resource.ts'); + expect(resourceTs).toContain("backend.data.resources.tables['blogPost']"); + expect(resourceTs).toContain("backend.data.resources.tables['secretNote']"); + expect(resourceTs).not.toContain('Blogpost'); + expect(resourceTs).not.toContain('Secretnote'); + // `Query` is not a @model, so it must never appear as a table reference. + expect(resourceTs).not.toContain("tables['Query']"); + }); + + it('reads model names from schema directory when schema.graphql does not exist', async () => { + const gen1App = await createGen1App({ + providers: { awscloudformation: { StackName: 'amplify-test-main-123456', Region: 'us-east-1' } }, + function: { + myFunc: { + service: 'Lambda', + output: { Name: 'myFunc-main-abc', Arn: 'arn:aws:lambda:us-east-1:123:function:myFunc-main-abc' }, + }, + }, + api: { + myApi: { service: 'AppSync' }, + }, + }); + jest.spyOn(gen1App, 'resourceMetaOutput').mockReturnValue('myFunc-main-abc'); + jest.spyOn(gen1App, 'json').mockReturnValue({ + Resources: { + AmplifyResourcesPolicy: { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyDocument: { + Statement: [{ Effect: 'Allow', Action: ['dynamodb:GetItem'], Resource: [{ Ref: 'apiMyApiTable' }] }], + }, + }, + }, + }, + }); + + // Create a schema directory with multiple .graphql files inside ccbDir + const fsExtra = require('fs-extra'); + const schemaDirPath = require('path').join(gen1App.ccbDir, 'api', 'myApi', 'schema'); + fsExtra.mkdirpSync(schemaDirPath); + fsExtra.writeFileSync(require('path').join(schemaDirPath, 'todo.graphql'), 'type todoItem @model { id: ID! title: String! }'); + fsExtra.writeFileSync(require('path').join(schemaDirPath, 'blog.graphql'), 'type BlogPost @model { id: ID! content: String }'); + + jest.spyOn(gen1App, 'file').mockImplementation((filePath: string) => { + if (filePath.includes('build/schema.graphql')) { + throw new Error('File not found'); + } + return require('fs').readFileSync(require('path').join(gen1App.ccbDir, filePath), 'utf8'); + }); + jest.spyOn(gen1App, 'fileExists').mockImplementation((filePath: string) => { + if (filePath.includes('schema.graphql')) return false; + return false; + }); + jest.spyOn(gen1App.aws, 'fetchFunctionConfig').mockResolvedValue({ + FunctionName: 'myFunc-main-abc', + Handler: 'index.handler', + Timeout: 30, + MemorySize: 128, + Runtime: 'nodejs18.x', + Environment: { + Variables: { + API_MYAPI_TODOITEMTABLE_NAME: 'todoItem-abc-main', + API_MYAPI_TODOITEMTABLE_ARN: 'arn:aws:dynamodb:us-east-1:123:table/todoItem-abc-main', + API_MYAPI_BLOGPOSTTABLE_NAME: 'BlogPost-abc-main', + API_MYAPI_BLOGPOSTTABLE_ARN: 'arn:aws:dynamodb:us-east-1:123:table/BlogPost-abc-main', + }, + }, + }); + jest.spyOn(gen1App.aws, 'fetchFunctionSchedule').mockResolvedValue(undefined); + + const generator = createFunctionGenerator({ gen1App, backendGenerator, packageJsonGenerator, outputDir }); + const ops = await generator.plan(); + await ops[0].execute(); + + const resourceTs = writtenFile('resource.ts'); + expect(resourceTs).toContain('todoItem'); + expect(resourceTs).toContain('BlogPost'); + expect(resourceTs).not.toContain('Todoitem'); + expect(resourceTs).not.toContain('Blogpost'); + }); + + it('returns empty model names gracefully when no API category exists', async () => { + const gen1App = await createGen1App({ + providers: { awscloudformation: { StackName: 'amplify-test-main-123456', Region: 'us-east-1' } }, + function: { + myFunc: { + service: 'Lambda', + output: { Name: 'myFunc-main-abc', Arn: 'arn:aws:lambda:us-east-1:123:function:myFunc-main-abc' }, + }, + }, + }); + jest.spyOn(gen1App, 'resourceMetaOutput').mockReturnValue('myFunc-main-abc'); + jest.spyOn(gen1App, 'json').mockReturnValue({ Resources: {} }); + jest.spyOn(gen1App, 'fileExists').mockReturnValue(false); + jest.spyOn(gen1App.aws, 'fetchFunctionConfig').mockResolvedValue({ + FunctionName: 'myFunc-main-abc', + Handler: 'index.handler', + Timeout: 30, + MemorySize: 128, + Runtime: 'nodejs18.x', + Environment: { + Variables: { + API_MYAPI_SOMETABLE_NAME: 'SomeTable-abc-main', + }, + }, + }); + jest.spyOn(gen1App.aws, 'fetchFunctionSchedule').mockResolvedValue(undefined); + + const generator = createFunctionGenerator({ gen1App, backendGenerator, packageJsonGenerator, outputDir }); + const ops = await generator.plan(); + await ops[0].execute(); + + const resourceTs = writtenFile('resource.ts'); + // Without model names, falls back to naive capitalization: SOME -> 'Some'. + // Assert the exact generated table reference so this proves the fallback + // path rather than an incidental 'Some' substring appearing elsewhere. + expect(resourceTs).toContain("backend.data.resources.tables['Some']"); + }); }); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.renderer.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.renderer.test.ts new file mode 100644 index 00000000000..bec0809f98c --- /dev/null +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.renderer.test.ts @@ -0,0 +1,35 @@ +import { extractTableName } from '../../../../../../commands/gen2-migration/generate/amplify/function/function.renderer'; + +describe('extractTableName', () => { + it('recovers original casing via case-insensitive match against known model names', () => { + const models = ['randomItem', 'Meal']; + expect(extractTableName('API_MYAPI_RANDOMITEMTABLE_ARN', models)).toBe('randomItem'); + expect(extractTableName('API_MYAPI_MEALTABLE_NAME', models)).toBe('Meal'); + }); + + it('matches model names that contain underscores (greedy prefix must not swallow them)', () => { + // A positional `/API_.*_(.+?)TABLE_/` regex captures `MODEL` here because the + // greedy `.*` eats `MY_`. Matching the known name directly avoids that. + expect(extractTableName('API_MYAPI_MY_MODELTABLE_ARN', ['my_model'])).toBe('my_model'); + }); + + it('does not confuse a model whose uppercased name is a suffix of another', () => { + const models = ['Item', 'LineItem']; + expect(extractTableName('API_MYAPI_LINEITEMTABLE_ARN', models)).toBe('LineItem'); + expect(extractTableName('API_MYAPI_ITEMTABLE_ARN', models)).toBe('Item'); + }); + + it('falls back to naive capitalization when no model names are supplied', () => { + expect(extractTableName('API_MYAPI_SOMETABLE_NAME')).toBe('Some'); + }); + + it('anchors the fallback to the last segment before TABLE_ (ARN|NAME)', () => { + // Without known model names the greedy-prefix bug would capture `MODEL`; + // anchoring keeps the fallback deterministic on the final segment. + expect(extractTableName('API_MYAPI_MY_MODELTABLE_ARN')).toBe('Model'); + }); + + it('returns undefined for env vars that are not table references', () => { + expect(extractTableName('API_MYAPI_GRAPHQLAPIIDOUTPUT')).toBeUndefined(); + }); +}); diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.generator.ts index 6a38bafc61b..c7cb9427a57 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.generator.ts @@ -1,7 +1,10 @@ import path from 'node:path'; import fs from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { globSync } from 'glob'; import { AmplifyMigrationOperation } from '../../../_common/operation'; import { AmplifyError, JSONUtilities } from '@aws-amplify/amplify-cli-core'; +import { printer } from '@aws-amplify/amplify-prompts'; import { Planner } from '../../../_common/planner'; import { BackendGenerator } from '../backend.generator'; import { Gen1App, DiscoveredResource } from '../../../_common/gen1-app'; @@ -35,6 +38,7 @@ export class FunctionGenerator implements Planner { private readonly resource: DiscoveredResource; private readonly renderer: FunctionRenderer; private readonly logger: SpinningLogger; + private cachedModelNames: readonly string[] | undefined; public constructor(options: FunctionGeneratorOptions) { this.gen1App = options.gen1App; @@ -53,6 +57,16 @@ export class FunctionGenerator implements Planner { this.s3Generator = s3Generator; } + /** + * Returns model names extracted from the GraphQL schema. + * Caches the result so the schema is read at most once per generator. + */ + private readModelNames(): readonly string[] { + if (this.cachedModelNames) return this.cachedModelNames; + this.cachedModelNames = readSchemaModelNames(this.gen1App); + return this.cachedModelNames; + } + public async plan(): Promise { const resourceName = this.resource.resourceName; const deployedName = this.gen1App.resourceMetaOutput(this.resource, 'Name'); @@ -76,7 +90,7 @@ export class FunctionGenerator implements Planner { this.logger.debug(`Fetching Lambda function schedule '${deployedName}'`); const schedule = await this.gen1App.aws.fetchFunctionSchedule(deployedName); const entry = TS.extractFilePathFromHandler(config.Handler ?? 'index.js'); - const { literalEnvVars, dynamicEnvVars } = classifyEnvVars(config.Environment?.Variables ?? {}); + const { literalEnvVars, dynamicEnvVars } = classifyEnvVars(config.Environment?.Variables ?? {}, this.readModelNames()); const dynamoActions = this.extractDynamoActions(); const kinesisActions = this.extractKinesisActions(); @@ -104,6 +118,7 @@ export class FunctionGenerator implements Planner { dataTriggerModels, storageTriggerTables, unMappedAuthActions, + modelNames: this.readModelNames(), kinesisConfig: hasAnalytics ? { resourceName: this.gen1App.singleResourceName('analytics', 'Kinesis'), @@ -447,3 +462,122 @@ function resolveAuthAccess(cognitoActions: string[]): { permissions: AuthPermiss const unMapped = cognitoActions.filter((a) => !covered.has(a)); return { permissions: result as AuthPermissions, unMapped: unMapped }; } + +/** + * Reads model names from the Gen1 app's GraphQL schema. + * Returns an empty array when no AppSync API exists. + * + * Both sources below are merged (rather than short-circuiting on the first + * that yields anything) because neither is complete on its own: + * 1. `build/schema.graphql` (transformer output) exposes names via the + * standardised `ModelXConnection` types. Robust against directive ordering, + * but the transformer only emits a Connection type for models that have a + * list query, so a model declared `@model(queries: null)` never appears here. + * 2. The raw user schema (single file or directory) is parsed for + * `type ... @model` with a parser that handles interleaved directives. + * This covers every @model regardless of its query configuration. + * + * Merging guarantees a model missing from one source is still recovered from + * the other; names present in both are de-duplicated. + */ +function readSchemaModelNames(gen1App: Gen1App): readonly string[] { + const apiCategory = gen1App.categoryMeta('api'); + if (!apiCategory) return []; + const apiEntry = Object.entries(apiCategory).find(([, v]) => (v as Record).service === 'AppSync'); + if (!apiEntry) return []; + const [apiName] = apiEntry; + + const names = new Set(); + + // Source 1: build/schema.graphql (transformer standardised output). + try { + const buildSchema = gen1App.file(path.join('api', apiName, 'build', 'schema.graphql')); + const connectionRegex = /type\s+Model(\w+)Connection\b/g; + let match: RegExpExecArray | null; + while ((match = connectionRegex.exec(buildSchema)) !== null) { + names.add(match[1]); + } + } catch (err) { + // build schema not available — rely on the raw schema below + printer.debug(`readSchemaModelNames: build schema unavailable for api '${apiName}': ${String(err)}`); + } + + // Source 2: raw user schema — catches models with no Connection type + // (e.g. `@model(queries: null)`) that Source 1 cannot see. + try { + const schema = collectUserSchema(gen1App, apiName); + for (const name of extractModelNamesFromRawSchema(schema)) { + names.add(name); + } + } catch (err) { + // raw schema not available — keep whatever the build schema yielded + printer.debug(`readSchemaModelNames: raw schema unavailable for api '${apiName}': ${String(err)}`); + } + + return [...names]; +} + +/** + * Collects all user-authored GraphQL schema content from either the single + * `schema.graphql` file or the `schema/` directory (multi-file pattern). + */ +function collectUserSchema(gen1App: Gen1App, apiName: string): string { + // Gen1 apps use one layout XOR the other, never both: `amplify add api` + // scaffolds a single `schema.graphql`, and the multi-file `schema/` + // directory is the alternate layout. The Gen1 transformer's own schema + // loader reads one or the other (directory preferred), so preferring the + // single file when present and otherwise reading the directory mirrors + // that behaviour; there is no case where both must be unioned. + const schemaFilePath = path.join('api', apiName, 'schema.graphql'); + if (gen1App.fileExists(schemaFilePath)) { + return gen1App.file(schemaFilePath); + } + + const schemaDirPath = path.join('api', apiName, 'schema'); + const fullDirPath = path.join(gen1App.ccbDir, schemaDirPath); + if (!existsSync(fullDirPath)) return ''; + const files = globSync('**/*.graphql', { cwd: fullDirPath }).sort(); + return files.map((f) => gen1App.file(path.join(schemaDirPath, f))).join('\n'); +} + +/** + * Extracts @model type names from a raw GraphQL schema string. + * Handles directives with nested braces (e.g., @auth(rules: [{...}])) + * appearing between the type name and @model. + */ +function extractModelNamesFromRawSchema(schema: string): string[] { + const names: string[] = []; + const typeRegex = /\btype\s+(\w+)/g; + let match: RegExpExecArray | null; + while ((match = typeRegex.exec(schema)) !== null) { + const typeName = match[1]; + // Bound the scan to the current type definition: slice off everything + // from the next top-level `type` keyword onward. A well-formed type is + // terminated by its `{...}` body (handled by the brace break below), but + // bounding here keeps the scan airtight even for a bodyless type — it can + // never walk `afterName` into the following type looking for `@model`. + const rest = schema.slice(match.index + match[0].length); + const nextType = rest.search(/\btype\s/); + const afterName = nextType === -1 ? rest : rest.slice(0, nextType); + let depth = 0; + let foundModel = false; + for (let i = 0; i < afterName.length; i++) { + const ch = afterName[i]; + if (ch === '(') { + depth++; + } else if (ch === ')') { + depth--; + } else if (depth === 0 && ch === '{') { + break; + } + // Match `@model` as a whole directive; a bare startsWith would also + // accept `@models` / `@modelFoo`. + if (depth === 0 && /^@model(?![A-Za-z0-9])/.test(afterName.slice(i))) { + foundModel = true; + break; + } + } + if (foundModel) names.push(typeName); + } + return names; +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.renderer.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.renderer.ts index cdc726b3cd1..75ee4bcdd48 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.renderer.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.renderer.ts @@ -25,6 +25,7 @@ export interface FunctionRenderOptions { readonly kinesisConfig?: KinesisConfig; readonly unMappedAuthActions: readonly string[]; readonly storageTriggerTables: readonly string[]; + readonly modelNames?: readonly string[]; } export interface KinesisConfig { @@ -186,7 +187,7 @@ export class FunctionRenderer { const tableNames = new Set(); for (const hatch of opts.dynamicEnvVars) { if (hatch.name.startsWith('API_') && hatch.name.includes('TABLE_')) { - const tableName = extractTableName(hatch.name); + const tableName = extractTableName(hatch.name, opts.modelNames); if (tableName) tableNames.add(tableName); } } @@ -363,7 +364,10 @@ export class FunctionRenderer { * - retained: stay in the defineFunction() environment block * - escapeHatches: become addEnvironment() calls in applyEscapeHatches */ -export function classifyEnvVars(variables: Record): { +export function classifyEnvVars( + variables: Record, + modelNames: readonly string[] = [], +): { readonly literalEnvVars: Record; readonly dynamicEnvVars: readonly DynamicEnvVar[]; } { @@ -382,11 +386,11 @@ export function classifyEnvVars(variables: Record): { { suffix: '_GRAPHQLAPIIDOUTPUT', build: () => backendPath('data', 'apiId') }, { suffix: 'TABLE_ARN', - build: (envVar) => backendTableProp(extractTableName(envVar) ?? 'unknown', 'tableArn'), + build: (envVar) => backendTableProp(extractTableName(envVar, modelNames) ?? 'unknown', 'tableArn'), }, { suffix: 'TABLE_NAME', - build: (envVar) => backendTableProp(extractTableName(envVar) ?? 'unknown', 'tableName'), + build: (envVar) => backendTableProp(extractTableName(envVar, modelNames) ?? 'unknown', 'tableName'), }, ], }, @@ -513,9 +517,37 @@ function nonNull(expr: ts.Expression): ts.Expression { return factory.createNonNullExpression(expr); } -/** Extracts the table name from an API_*TABLE_* env var. */ -export function extractTableName(envVar: string): string | undefined { - const match = envVar.match(/API_.*_(.+?)TABLE_/); +/** + * Extracts the model name from an API_*TABLE_* env var by matching the + * uppercase segment against known model names from the GraphQL schema. + * + * When model names are provided, performs a case-insensitive lookup to + * recover the original casing (e.g., `RANDOMITEM` → `randomItem`). + * Falls back to capitalizing the first letter when no match is found. + * + * @example + * extractTableName('API_MYAPI_RANDOMITEMTABLE_ARN', ['randomItem', 'Meal']) // → 'randomItem' + * extractTableName('API_MYAPI_MEALTABLE_ARN', ['randomItem', 'Meal']) // → 'Meal' + */ +export function extractTableName(envVar: string, modelNames: readonly string[] = []): string | undefined { + // Preferred path: match the env var against known model names. The env var + // embeds the uppercased model name as `_TABLE_`. Testing the + // known name directly (instead of slicing a segment out) correctly handles + // model names that contain underscores, which a positional regex cannot + // disambiguate from the API-name prefix. + // + // Assumption: model names are unique after uppercasing. If two models only + // differ by case (e.g. `randomItem` vs `randomitem`) the first in schema + // order wins. GraphQL type names are case-sensitively unique, so such a + // collision is not expressible in a valid schema. + const upperEnv = envVar.toUpperCase(); + const matched = modelNames.find((name) => upperEnv.includes(`_${name.toUpperCase()}TABLE_`)); + if (matched) return matched; + + // Fallback (no schema model names available): anchor to the last segment + // before `TABLE_` so a greedy prefix cannot swallow underscore-delimited + // parts, then naively capitalize. + const match = envVar.match(/_([A-Za-z0-9]+)TABLE_(?:ARN|NAME)$/); if (!match) return undefined; const raw = match[1]; return raw.charAt(0).toUpperCase() + raw.slice(1).toLowerCase();