From 7ea9a12c7da45edabc9901cfead7142c7eda8cde Mon Sep 17 00:00:00 2001 From: "Adrian Joshua Strutt (AI)" Date: Mon, 18 May 2026 21:13:23 +0000 Subject: [PATCH 1/2] fix(cli-internal): read DynamoDB event source properties from CFN template Extract StartingPosition and BatchSize from EventSourceMapping CFN resources instead of hardcoding LATEST. This allows the generated code to preserve the original trigger configuration (e.g., TRIM_HORIZON, custom batch sizes). --- Prompt: Fix dynamo trigger props to read from CFN template instead of hardcoding LATEST. --- .../amplify/function/function.generator.ts | 25 ++++--- .../amplify/function/function.renderer.ts | 66 +++++++++++++++---- 2 files changed, 68 insertions(+), 23 deletions(-) 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..d4b56cfdf0d 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 @@ -6,7 +6,14 @@ import { Planner } from '../../../_common/planner'; import { BackendGenerator } from '../backend.generator'; import { Gen1App, DiscoveredResource } from '../../../_common/gen1-app'; import { TS } from '../../ts'; -import { FunctionRenderer, FunctionRenderOptions, classifyEnvVars, DynamicEnvVar } from './function.renderer'; +import { + FunctionRenderer, + FunctionRenderOptions, + classifyEnvVars, + DynamicEnvVar, + DetectedDynamoTrigger, + extractDynamoTriggerProps, +} from './function.renderer'; import { RootPackageJsonGenerator } from '../../package.json.generator'; import { AuthPermissions } from '../auth/auth.renderer'; import { AuthGenerator } from '../auth/auth.generator'; @@ -247,10 +254,10 @@ export class FunctionGenerator implements Planner { return Array.from(new Set(args)); } - private detectDataTriggerModels(): string[] { + private detectDataTriggerModels(): DetectedDynamoTrigger[] { const templatePath = `function/${this.resource.resourceName}/${this.resource.resourceName}-cloudformation-template.json`; const template = this.gen1App.json(templatePath); - const models: string[] = []; + const triggers: DetectedDynamoTrigger[] = []; for (const resource of Object.values(template.Resources ?? {})) { const res = resource as Record; if (res.Type !== 'AWS::Lambda::EventSourceMapping') continue; @@ -260,9 +267,9 @@ export class FunctionGenerator implements Planner { const fnSub = fnImportValue?.['Fn::Sub']; if (!fnSub) continue; const match = fnSub.match(/:GetAtt:(\w+)Table:StreamArn/); - if (match) models.push(match[1]); + if (match) triggers.push({ name: match[1], props: extractDynamoTriggerProps(props) }); } - return models; + return triggers; } /** @@ -270,11 +277,11 @@ export class FunctionGenerator implements Planner { * CloudFormation template for EventSourceMapping resources that reference * storage table stream ARNs via `Ref: storageStreamArn`. */ - private detectDynamoTriggerTables(): string[] { + private detectDynamoTriggerTables(): DetectedDynamoTrigger[] { const templatePath = `function/${this.resource.resourceName}/${this.resource.resourceName}-cloudformation-template.json`; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped CloudFormation template const template = this.gen1App.json(templatePath); - const tables: string[] = []; + const triggers: DetectedDynamoTrigger[] = []; for (const resource of Object.values(template.Resources)) { const res = resource as Record; @@ -286,11 +293,11 @@ export class FunctionGenerator implements Planner { const match = eventSourceArn.Ref.match(/^storage(\w+)StreamArn$/); if (match) { - tables.push(match[1]); + triggers.push({ name: match[1], props: extractDynamoTriggerProps(props) }); } } - return tables; + return triggers; } private isKinesisTrigger(): boolean { 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..86508042038 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 @@ -9,6 +9,22 @@ const factory = ts.factory; * Options for rendering a complete function resource.ts file, * including defineFunction() and applyEscapeHatches(). */ +/** + * Event source mapping properties extracted from a Gen1 CFN template. + */ +export interface DynamoTriggerProps { + readonly startingPosition: string; + readonly batchSize?: number; +} + +/** + * A DynamoDB stream trigger with its CFN event source mapping properties. + */ +export interface DetectedDynamoTrigger { + readonly name: string; + readonly props: DynamoTriggerProps; +} + export interface FunctionRenderOptions { readonly resourceName: string; readonly entry: string; @@ -21,10 +37,10 @@ export interface FunctionRenderOptions { readonly dynamicEnvVars: readonly DynamicEnvVar[]; readonly dynamoActions: readonly string[]; readonly appSyncPermissions: { readonly hasMutation: boolean; readonly hasQuery: boolean }; - readonly dataTriggerModels: readonly string[]; + readonly dataTriggerModels: readonly DetectedDynamoTrigger[]; readonly kinesisConfig?: KinesisConfig; readonly unMappedAuthActions: readonly string[]; - readonly storageTriggerTables: readonly string[]; + readonly storageTriggerTables: readonly DetectedDynamoTrigger[]; } export interface KinesisConfig { @@ -271,7 +287,7 @@ export class FunctionRenderer { // Add storage table parameters for standalone DynamoDB tables referenced in the body. // Collect from both env-var escape hatches and storage triggers. const allStorageTableParams = new Set(storageTableNames); - for (const t of opts.storageTriggerTables) allStorageTableParams.add(t); + for (const t of opts.storageTriggerTables) allStorageTableParams.add(t.name); if (allStorageTableParams.size > 0) { if (!additionalImports['aws-cdk-lib/aws-dynamodb']) { additionalImports['aws-cdk-lib/aws-dynamodb'] = new Set(); @@ -710,14 +726,14 @@ function createUnMappedAuthGrant(funcName: string, actions: readonly string[]): } /** Creates a DynamoDB stream trigger for-of loop. */ -function createDynamoTrigger(functionName: string, models: readonly string[]): ts.ForOfStatement { +function createDynamoTrigger(functionName: string, triggers: readonly DetectedDynamoTrigger[]): ts.ForOfStatement { return factory.createForOfStatement( undefined, factory.createVariableDeclarationList( [factory.createVariableDeclaration('model', undefined, undefined, undefined)], ts.NodeFlags.Const, ), - factory.createArrayLiteralExpression(models.map((model) => factory.createStringLiteral(model))), + factory.createArrayLiteralExpression(triggers.map((t) => factory.createStringLiteral(t.name))), factory.createBlock( [ factory.createVariableStatement( @@ -753,7 +769,7 @@ function createDynamoTrigger(functionName: string, models: readonly string[]): t [ factory.createNewExpression(factory.createIdentifier('DynamoEventSource'), undefined, [ factory.createIdentifier('table'), - factory.createObjectLiteralExpression([TS.enumProp('startingPosition', 'StartingPosition', 'LATEST')]), + factory.createObjectLiteralExpression(createDynamoEventSourceProps(triggers[0]?.props)), ]), ], ), @@ -793,10 +809,9 @@ function createDynamoTrigger(functionName: string, models: readonly string[]): t } /** Creates storage DynamoDB stream triggers for standalone tables. */ -function createStorageDynamoTrigger(functionName: string, tableNames: readonly string[]): ts.Statement[] { +function createStorageDynamoTrigger(functionName: string, triggers: readonly DetectedDynamoTrigger[]): ts.Statement[] { const statements: ts.Statement[] = []; - for (const tableName of tableNames) { - // backend.funcName.resources.lambda.addEventSource(new DynamoEventSource(tableName, { startingPosition: StartingPosition.LATEST })) + for (const trigger of triggers) { statements.push( factory.createExpressionStatement( factory.createCallExpression( @@ -810,18 +825,17 @@ function createStorageDynamoTrigger(functionName: string, tableNames: readonly s undefined, [ factory.createNewExpression(factory.createIdentifier('DynamoEventSource'), undefined, [ - factory.createIdentifier(tableName), - factory.createObjectLiteralExpression([TS.enumProp('startingPosition', 'StartingPosition', 'LATEST')]), + factory.createIdentifier(trigger.name), + factory.createObjectLiteralExpression(createDynamoEventSourceProps(trigger.props)), ]), ], ), ), ); - // tableName.grantStreamRead(backend.funcName.resources.lambda.role!) statements.push( factory.createExpressionStatement( factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier(tableName), factory.createIdentifier('grantStreamRead')), + factory.createPropertyAccessExpression(factory.createIdentifier(trigger.name), factory.createIdentifier('grantStreamRead')), undefined, [ factory.createNonNullExpression( @@ -838,7 +852,7 @@ function createStorageDynamoTrigger(functionName: string, tableNames: readonly s statements.push( factory.createExpressionStatement( factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier(tableName), factory.createIdentifier('grantTableListStreams')), + factory.createPropertyAccessExpression(factory.createIdentifier(trigger.name), factory.createIdentifier('grantTableListStreams')), undefined, [ factory.createNonNullExpression( @@ -855,6 +869,30 @@ function createStorageDynamoTrigger(functionName: string, tableNames: readonly s return statements; } +/** + * Extracts DynamoDB event source mapping properties (StartingPosition + * and BatchSize) from a CFN EventSourceMapping resource's Properties. + * Falls back to LATEST if StartingPosition is missing. + */ +export function extractDynamoTriggerProps(props: Record | undefined): DynamoTriggerProps { + const startingPosition = typeof props?.StartingPosition === 'string' ? props.StartingPosition : 'LATEST'; + const batchSize = typeof props?.BatchSize === 'number' ? props.BatchSize : undefined; + return { startingPosition, batchSize }; +} + +/** + * Builds the object literal properties for a DynamoEventSource constructor: + * `{ startingPosition: StartingPosition.X, batchSize: N }`. + */ +function createDynamoEventSourceProps(props: DynamoTriggerProps | undefined): ts.PropertyAssignment[] { + const position = props?.startingPosition ?? 'LATEST'; + const assignments: ts.PropertyAssignment[] = [TS.enumProp('startingPosition', 'StartingPosition', position)]; + if (props?.batchSize !== undefined) { + assignments.push(factory.createPropertyAssignment('batchSize', factory.createNumericLiteral(props.batchSize))); + } + return assignments; +} + /** Creates a Kinesis stream trigger. */ function createKinesisTrigger(functionName: string): ts.Statement[] { const fromStreamArn = factory.createVariableStatement( From 90d40aed2eca38b8a347c709e9bc0eb959307745 Mon Sep 17 00:00:00 2001 From: Adrian Joshua Strutt Date: Mon, 18 May 2026 22:36:02 +0000 Subject: [PATCH 2/2] fix(cli-internal): fix triggers[0].props bug in createDynamoTrigger Generate separate addEventSource statements per DynamoDB trigger model instead of a single for-of loop that incorrectly used triggers[0].props for all models. This ensures each model gets its own StartingPosition and BatchSize from the CFN EventSourceMapping resource. Also adds 3 unit tests: - Single trigger with custom BatchSize and StartingPosition - Multiple triggers with different configs (proves the bug is fixed) - Fallback to LATEST when StartingPosition is missing from CFN --- .../function/activityTrigger/resource.ts | 1 + .../function/recorduseractivity/resource.ts | 47 +++- .../function/function.generator.test.ts | 265 +++++++++++++++++- .../amplify/function/function.renderer.ts | 146 +++++----- reports/report.xml | 53 ++++ 5 files changed, 419 insertions(+), 93 deletions(-) create mode 100644 reports/report.xml diff --git a/amplify-migration-apps/discussions/_snapshot.post.generate/amplify/function/activityTrigger/resource.ts b/amplify-migration-apps/discussions/_snapshot.post.generate/amplify/function/activityTrigger/resource.ts index 54da2dfcc7c..cb5efbd318e 100644 --- a/amplify-migration-apps/discussions/_snapshot.post.generate/amplify/function/activityTrigger/resource.ts +++ b/amplify-migration-apps/discussions/_snapshot.post.generate/amplify/function/activityTrigger/resource.ts @@ -51,6 +51,7 @@ export function applyEscapeHatches(backend: Backend, activity: Table) { backend.activityTrigger.resources.lambda.addEventSource( new DynamoEventSource(activity, { startingPosition: StartingPosition.LATEST, + batchSize: 100, }) ); activity.grantStreamRead(backend.activityTrigger.resources.lambda.role!); diff --git a/amplify-migration-apps/discussions/_snapshot.post.generate/amplify/function/recorduseractivity/resource.ts b/amplify-migration-apps/discussions/_snapshot.post.generate/amplify/function/recorduseractivity/resource.ts index a562bc46144..851daa4de1c 100644 --- a/amplify-migration-apps/discussions/_snapshot.post.generate/amplify/function/recorduseractivity/resource.ts +++ b/amplify-migration-apps/discussions/_snapshot.post.generate/amplify/function/recorduseractivity/resource.ts @@ -48,16 +48,39 @@ export function applyEscapeHatches(backend: Backend, activity: Table) { 'dynamodb:Delete*', 'dynamodb:PartiQLDelete' ); - for (const model of ['Topic', 'Post', 'Comment']) { - const table = backend.data.resources.tables[model]; - backend.recorduseractivity.resources.lambda.addEventSource( - new DynamoEventSource(table, { - startingPosition: StartingPosition.LATEST, - }) - ); - table.grantStreamRead(backend.recorduseractivity.resources.lambda.role!); - table.grantTableListStreams( - backend.recorduseractivity.resources.lambda.role! - ); - } + const tableTopic = backend.data.resources.tables['Topic']; + backend.recorduseractivity.resources.lambda.addEventSource( + new DynamoEventSource(tableTopic, { + startingPosition: StartingPosition.LATEST, + batchSize: 100, + }) + ); + tableTopic.grantStreamRead(backend.recorduseractivity.resources.lambda.role!); + tableTopic.grantTableListStreams( + backend.recorduseractivity.resources.lambda.role! + ); + const tablePost = backend.data.resources.tables['Post']; + backend.recorduseractivity.resources.lambda.addEventSource( + new DynamoEventSource(tablePost, { + startingPosition: StartingPosition.LATEST, + batchSize: 100, + }) + ); + tablePost.grantStreamRead(backend.recorduseractivity.resources.lambda.role!); + tablePost.grantTableListStreams( + backend.recorduseractivity.resources.lambda.role! + ); + const tableComment = backend.data.resources.tables['Comment']; + backend.recorduseractivity.resources.lambda.addEventSource( + new DynamoEventSource(tableComment, { + startingPosition: StartingPosition.LATEST, + batchSize: 100, + }) + ); + tableComment.grantStreamRead( + backend.recorduseractivity.resources.lambda.role! + ); + tableComment.grantTableListStreams( + backend.recorduseractivity.resources.lambda.role! + ); } 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..17cd8e51f61 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 @@ -1069,16 +1069,14 @@ describe('FunctionGenerator', () => { export function applyEscapeHatches(backend: Backend) { backend.myFunc.resources.cfnResources.cfnFunction.functionName = \`myFunc-\${branchName}\`; - for (const model of ['Todo']) { - const table = backend.data.resources.tables[model]; - backend.myFunc.resources.lambda.addEventSource( - new DynamoEventSource(table, { - startingPosition: StartingPosition.LATEST, - }) - ); - table.grantStreamRead(backend.myFunc.resources.lambda.role!); - table.grantTableListStreams(backend.myFunc.resources.lambda.role!); - } + const tableTodo = backend.data.resources.tables['Todo']; + backend.myFunc.resources.lambda.addEventSource( + new DynamoEventSource(tableTodo, { + startingPosition: StartingPosition.LATEST, + }) + ); + tableTodo.grantStreamRead(backend.myFunc.resources.lambda.role!); + tableTodo.grantTableListStreams(backend.myFunc.resources.lambda.role!); } " `); @@ -1264,4 +1262,251 @@ describe('FunctionGenerator', () => { const generator = createFunctionGenerator({ gen1App, backendGenerator, packageJsonGenerator, outputDir }); await expect(generator.plan()).rejects.toThrow("unsupported runtime 'python3.9'"); }); + + it('renders single DynamoDB trigger with custom BatchSize and StartingPosition from CFN', 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: { + EventSourceMapping: { + Type: 'AWS::Lambda::EventSourceMapping', + Properties: { + EventSourceArn: { + 'Fn::ImportValue': { + 'Fn::Sub': '${api}:GetAtt:TodoTable:StreamArn', + }, + }, + FunctionName: { Ref: 'LambdaFunction' }, + BatchSize: 50, + StartingPosition: 'TRIM_HORIZON', + }, + }, + }, + }); + jest.spyOn(gen1App, 'file').mockReturnValue('{}'); + jest.spyOn(gen1App, 'fileExists').mockReturnValue(false); + jest.spyOn(gen1App.aws, 'fetchFunctionConfig').mockResolvedValue({ + FunctionName: 'myFunc-main-abc', + Handler: 'index.handler', + Timeout: 3, + MemorySize: 128, + Runtime: 'nodejs18.x', + Environment: { Variables: {} }, + }); + jest.spyOn(gen1App.aws, 'fetchFunctionSchedule').mockResolvedValue(undefined); + + const generator = createFunctionGenerator({ gen1App, backendGenerator, packageJsonGenerator, outputDir }); + const ops = await generator.plan(); + await ops[0].execute(); + + expect(writtenFile('resource.ts')).toMatchInlineSnapshot(` + "import { defineFunction } from '@aws-amplify/backend'; + import { DynamoEventSource } from 'aws-cdk-lib/aws-lambda-event-sources'; + import { StartingPosition } from 'aws-cdk-lib/aws-lambda'; + import type { Backend } from '../../backend'; + + const branchName = process.env.AWS_BRANCH ?? 'sandbox'; + + export const myFunc = defineFunction({ + entry: './index.js', + name: \`myFunc-\${branchName}\`, + timeoutSeconds: 3, + memoryMB: 128, + runtime: 18, + }); + + export function applyEscapeHatches(backend: Backend) { + backend.myFunc.resources.cfnResources.cfnFunction.functionName = \`myFunc-\${branchName}\`; + const tableTodo = backend.data.resources.tables['Todo']; + backend.myFunc.resources.lambda.addEventSource( + new DynamoEventSource(tableTodo, { + startingPosition: StartingPosition.TRIM_HORIZON, + batchSize: 50, + }) + ); + tableTodo.grantStreamRead(backend.myFunc.resources.lambda.role!); + tableTodo.grantTableListStreams(backend.myFunc.resources.lambda.role!); + } + " + `); + }); + + it('renders multiple DynamoDB triggers with DIFFERENT BatchSize/StartingPosition per model', 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: { + TodoEventSourceMapping: { + Type: 'AWS::Lambda::EventSourceMapping', + Properties: { + EventSourceArn: { + 'Fn::ImportValue': { + 'Fn::Sub': '${api}:GetAtt:TodoTable:StreamArn', + }, + }, + FunctionName: { Ref: 'LambdaFunction' }, + BatchSize: 10, + StartingPosition: 'TRIM_HORIZON', + }, + }, + PostEventSourceMapping: { + Type: 'AWS::Lambda::EventSourceMapping', + Properties: { + EventSourceArn: { + 'Fn::ImportValue': { + 'Fn::Sub': '${api}:GetAtt:PostTable:StreamArn', + }, + }, + FunctionName: { Ref: 'LambdaFunction' }, + BatchSize: 100, + StartingPosition: 'LATEST', + }, + }, + }, + }); + jest.spyOn(gen1App, 'file').mockReturnValue('{}'); + jest.spyOn(gen1App, 'fileExists').mockReturnValue(false); + jest.spyOn(gen1App.aws, 'fetchFunctionConfig').mockResolvedValue({ + FunctionName: 'myFunc-main-abc', + Handler: 'index.handler', + Timeout: 3, + MemorySize: 128, + Runtime: 'nodejs18.x', + Environment: { Variables: {} }, + }); + jest.spyOn(gen1App.aws, 'fetchFunctionSchedule').mockResolvedValue(undefined); + + const generator = createFunctionGenerator({ gen1App, backendGenerator, packageJsonGenerator, outputDir }); + const ops = await generator.plan(); + await ops[0].execute(); + + expect(writtenFile('resource.ts')).toMatchInlineSnapshot(` + "import { defineFunction } from '@aws-amplify/backend'; + import { DynamoEventSource } from 'aws-cdk-lib/aws-lambda-event-sources'; + import { StartingPosition } from 'aws-cdk-lib/aws-lambda'; + import type { Backend } from '../../backend'; + + const branchName = process.env.AWS_BRANCH ?? 'sandbox'; + + export const myFunc = defineFunction({ + entry: './index.js', + name: \`myFunc-\${branchName}\`, + timeoutSeconds: 3, + memoryMB: 128, + runtime: 18, + }); + + export function applyEscapeHatches(backend: Backend) { + backend.myFunc.resources.cfnResources.cfnFunction.functionName = \`myFunc-\${branchName}\`; + const tableTodo = backend.data.resources.tables['Todo']; + backend.myFunc.resources.lambda.addEventSource( + new DynamoEventSource(tableTodo, { + startingPosition: StartingPosition.TRIM_HORIZON, + batchSize: 10, + }) + ); + tableTodo.grantStreamRead(backend.myFunc.resources.lambda.role!); + tableTodo.grantTableListStreams(backend.myFunc.resources.lambda.role!); + const tablePost = backend.data.resources.tables['Post']; + backend.myFunc.resources.lambda.addEventSource( + new DynamoEventSource(tablePost, { + startingPosition: StartingPosition.LATEST, + batchSize: 100, + }) + ); + tablePost.grantStreamRead(backend.myFunc.resources.lambda.role!); + tablePost.grantTableListStreams(backend.myFunc.resources.lambda.role!); + } + " + `); + }); + + it('falls back to StartingPosition LATEST when not specified in CFN template', 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: { + EventSourceMapping: { + Type: 'AWS::Lambda::EventSourceMapping', + Properties: { + EventSourceArn: { + 'Fn::ImportValue': { + 'Fn::Sub': '${api}:GetAtt:TodoTable:StreamArn', + }, + }, + FunctionName: { Ref: 'LambdaFunction' }, + }, + }, + }, + }); + jest.spyOn(gen1App, 'file').mockReturnValue('{}'); + jest.spyOn(gen1App, 'fileExists').mockReturnValue(false); + jest.spyOn(gen1App.aws, 'fetchFunctionConfig').mockResolvedValue({ + FunctionName: 'myFunc-main-abc', + Handler: 'index.handler', + Timeout: 3, + MemorySize: 128, + Runtime: 'nodejs18.x', + Environment: { Variables: {} }, + }); + jest.spyOn(gen1App.aws, 'fetchFunctionSchedule').mockResolvedValue(undefined); + + const generator = createFunctionGenerator({ gen1App, backendGenerator, packageJsonGenerator, outputDir }); + const ops = await generator.plan(); + await ops[0].execute(); + + expect(writtenFile('resource.ts')).toMatchInlineSnapshot(` + "import { defineFunction } from '@aws-amplify/backend'; + import { DynamoEventSource } from 'aws-cdk-lib/aws-lambda-event-sources'; + import { StartingPosition } from 'aws-cdk-lib/aws-lambda'; + import type { Backend } from '../../backend'; + + const branchName = process.env.AWS_BRANCH ?? 'sandbox'; + + export const myFunc = defineFunction({ + entry: './index.js', + name: \`myFunc-\${branchName}\`, + timeoutSeconds: 3, + memoryMB: 128, + runtime: 18, + }); + + export function applyEscapeHatches(backend: Backend) { + backend.myFunc.resources.cfnResources.cfnFunction.functionName = \`myFunc-\${branchName}\`; + const tableTodo = backend.data.resources.tables['Todo']; + backend.myFunc.resources.lambda.addEventSource( + new DynamoEventSource(tableTodo, { + startingPosition: StartingPosition.LATEST, + }) + ); + tableTodo.grantStreamRead(backend.myFunc.resources.lambda.role!); + tableTodo.grantTableListStreams(backend.myFunc.resources.lambda.role!); + } + " + `); + }); }); 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 86508042038..08c97fdcdbd 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 @@ -243,7 +243,7 @@ export class FunctionRenderer { if (opts.dataTriggerModels.length > 0) { additionalImports['aws-cdk-lib/aws-lambda-event-sources'] = new Set(['DynamoEventSource']); additionalImports['aws-cdk-lib/aws-lambda'] = new Set(['StartingPosition']); - statements.push(createDynamoTrigger(opts.resourceName, opts.dataTriggerModels)); + statements.push(...createDynamoTrigger(opts.resourceName, opts.dataTriggerModels)); } // Storage DynamoDB triggers (standalone tables, not AppSync-managed) @@ -725,87 +725,91 @@ function createUnMappedAuthGrant(funcName: string, actions: readonly string[]): ); } -/** Creates a DynamoDB stream trigger for-of loop. */ -function createDynamoTrigger(functionName: string, triggers: readonly DetectedDynamoTrigger[]): ts.ForOfStatement { - return factory.createForOfStatement( - undefined, - factory.createVariableDeclarationList( - [factory.createVariableDeclaration('model', undefined, undefined, undefined)], - ts.NodeFlags.Const, - ), - factory.createArrayLiteralExpression(triggers.map((t) => factory.createStringLiteral(t.name))), - factory.createBlock( - [ - factory.createVariableStatement( - [], - factory.createVariableDeclarationList( - [ - factory.createVariableDeclaration( - 'table', - undefined, - undefined, - factory.createElementAccessExpression( - factory.createPropertyAccessExpression( - factory.createIdentifier('backend.data.resources'), - factory.createIdentifier('tables'), - ), - factory.createIdentifier('model'), +/** Creates separate DynamoDB stream trigger statements per model. */ +function createDynamoTrigger(functionName: string, triggers: readonly DetectedDynamoTrigger[]): ts.Statement[] { + const statements: ts.Statement[] = []; + for (const trigger of triggers) { + const tableVar = `table${trigger.name}`; + // const tableX = backend.data.resources.tables["ModelName"] + statements.push( + factory.createVariableStatement( + [], + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + tableVar, + undefined, + undefined, + factory.createElementAccessExpression( + factory.createPropertyAccessExpression( + factory.createIdentifier('backend.data.resources'), + factory.createIdentifier('tables'), ), + factory.createStringLiteral(trigger.name), ), - ], - ts.NodeFlags.Const, - ), + ), + ], + ts.NodeFlags.Const, ), - factory.createExpressionStatement( - factory.createCallExpression( + ), + ); + // backend.funcName.resources.lambda.addEventSource(new DynamoEventSource(tableX, { ... })) + statements.push( + factory.createExpressionStatement( + factory.createCallExpression( + factory.createPropertyAccessExpression( factory.createPropertyAccessExpression( - factory.createPropertyAccessExpression( - factory.createIdentifier(`backend.${functionName}.resources`), - factory.createIdentifier('lambda'), - ), - factory.createIdentifier('addEventSource'), + factory.createIdentifier(`backend.${functionName}.resources`), + factory.createIdentifier('lambda'), ), - undefined, - [ - factory.createNewExpression(factory.createIdentifier('DynamoEventSource'), undefined, [ - factory.createIdentifier('table'), - factory.createObjectLiteralExpression(createDynamoEventSourceProps(triggers[0]?.props)), - ]), - ], + factory.createIdentifier('addEventSource'), ), + undefined, + [ + factory.createNewExpression(factory.createIdentifier('DynamoEventSource'), undefined, [ + factory.createIdentifier(tableVar), + factory.createObjectLiteralExpression(createDynamoEventSourceProps(trigger.props)), + ]), + ], ), - factory.createExpressionStatement( - factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier('table'), factory.createIdentifier('grantStreamRead')), - undefined, - [ - factory.createNonNullExpression( - factory.createPropertyAccessExpression( - factory.createIdentifier(`backend.${functionName}.resources.lambda`), - factory.createIdentifier('role'), - ), + ), + ); + // tableX.grantStreamRead(backend.funcName.resources.lambda.role!) + statements.push( + factory.createExpressionStatement( + factory.createCallExpression( + factory.createPropertyAccessExpression(factory.createIdentifier(tableVar), factory.createIdentifier('grantStreamRead')), + undefined, + [ + factory.createNonNullExpression( + factory.createPropertyAccessExpression( + factory.createIdentifier(`backend.${functionName}.resources.lambda`), + factory.createIdentifier('role'), ), - ], - ), + ), + ], ), - factory.createExpressionStatement( - factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier('table'), factory.createIdentifier('grantTableListStreams')), - undefined, - [ - factory.createNonNullExpression( - factory.createPropertyAccessExpression( - factory.createIdentifier(`backend.${functionName}.resources.lambda`), - factory.createIdentifier('role'), - ), + ), + ); + // tableX.grantTableListStreams(backend.funcName.resources.lambda.role!) + statements.push( + factory.createExpressionStatement( + factory.createCallExpression( + factory.createPropertyAccessExpression(factory.createIdentifier(tableVar), factory.createIdentifier('grantTableListStreams')), + undefined, + [ + factory.createNonNullExpression( + factory.createPropertyAccessExpression( + factory.createIdentifier(`backend.${functionName}.resources.lambda`), + factory.createIdentifier('role'), ), - ], - ), + ), + ], ), - ], - true, - ), - ); + ), + ); + } + return statements; } /** Creates storage DynamoDB stream triggers for standalone tables. */ diff --git a/reports/report.xml b/reports/report.xml new file mode 100644 index 00000000000..d952d093a98 --- /dev/null +++ b/reports/report.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file