Learning AI-DLC building a game, part 9: an AppSync deploy error

Part 9 of the AI-DLC game series: an AWS AppSync deploy that rolls back because three JavaScript resolvers are rejected, and the one-line rule that caused it

This is part 9 of a series on learning AI-DLC by building a game, World Rift Conflict, in Godot with an AWS backend. Part 8 planned the work. The first unit, a walking skeleton, is now built, and this post is about what happened when it met AWS for the first time.

Parts so far:

TL;DR

  1. Every local suite was green, and the first deploy still rolled back: AppSync refused all three JavaScript resolvers with "The code contains one or more errors".
  2. The tests run the resolvers in Node with a fake util. AppSync runs them in APPSYNC_JS, a restricted JavaScript that Node does not imitate.
  3. My first fix, made from lint evidence, found one real problem and missed the cause. The second deploy failed the same way.
  4. With appsync:EvaluateCode granted, AppSync named the rule in one call: code may not call a function declared with export function.
  5. Grant EvaluateCode and check resolvers with it before the first deploy. Local lint is not the runtime.

Where the work stood

The walking skeleton is the smallest slice of the game that works end to end: a short match against computer-run countries, and the signed-in player's result saved to the backend. Code Generation had built it, and every local check passed:

SuiteResult
Godot (gdUnit4)129 test cases, 0 failures
TypeScript unit tests146 pass
Infrastructure tests31 pass
Backend tests21 pass

Local tests are not enough for a walking skeleton. AI-DLC's skeleton checkpoint asks for the real end-to-end run, which here means three AWS steps, each waiting for my go: a spending-alert stack, its email confirmation, and the test backend redeployed with the new result store.

Permissions came first

The project's rules say that, before asking for a go on a deploy, the agent checks read-only what my AWS role can do. It found that my sign-in, an SSO role set up for Amplify, could deploy Amplify sandboxes but could not read SNS topics, AWS Budgets or CloudWatch alarms. The alert check that gates every test deploy could never pass with that role.

The agent offered three ways: add read rights to the role, change the check to use the CDK bootstrap's read-only role (a design change), or sign in with a much broader role. I added a small inline policy with four read actions, two of them limited to the game's topic and budget by name.

The alert stack then deployed. The check still failed on one line, "email subscription: not confirmed", because I had not clicked the confirmation email. That is the kind of mistake the check exists for.

The deploy fails

The test backend redeploy rolled back cleanly. In the nested data stack, the three new resolvers had all failed:

FnQuerylastResult1  "The code contains one or more errors.
                     (Service: AppSync, Status Code: 400 ...)"

That message names no file and no line. The synth output showed Amplify uploads each .js resolver as written, so AppSync was refusing our source exactly as it was.

Why did the tests miss it? They import each resolver into Node and install a fake util object, so the request and response functions run as written. But AppSync does not run Node. It runs APPSYNC_JS, a subset of JavaScript with its own rules: no regex literals, no try/catch, no classes, and more. Node accepts all of it.

A wrong fix, made honestly

The clean way to find the error is AppSync's EvaluateCode API, which runs resolver code in the real runtime and creates nothing. My role was not allowed to call it, and not allowed to read CloudTrail either.

So the agent offered two ways: add EvaluateCode to the role first, or repair on the evidence from AWS's lint rules (@aws-appsync/eslint-plugin) and redeploy. I chose the faster one.

The lint found one real error: the ID check in save-result.js used a regex. It found nothing in the other two files, which AppSync had also refused. The agent's theory for what all three shared was that each passed util.dynamodb.toMapValues into a helper function. It tested the lint on that pattern and found the rule did not flag it, so the theory was still a guess. It said so, fixed both, and added a regression test.

The second deploy failed with the same message for all three resolvers.

At that point the agent stopped. It confirmed the deploy had shipped the new code, named one more candidate, and asked for EvaluateCode rather than try a second blind fix. A failed guess had cost a three-minute rollback. A second one would have cost the same, and taught nothing.

The cause in one call

With appsync:EvaluateCode added to the role, each file gave an exact answer:

Error: Invalid function: buildLastResultQuery
[code: INVALID_FUNCTION_INVOCATION]
[start: fileName: code.js, line: 52, col: 10]

The other two named buildPlayedTodayGet and validateSaveInput. All three are helpers that request calls, and all three were declared with export function so the tests could import them. A helper that was not exported had passed.

A three-line probe settled the rule:

// Refused: "Invalid function: helper"
export function helper(x) { return { v: x } }
export function request(ctx) { return helper(1) }

// Accepted: returns {"v":1}
function helper(x) { return { v: x } }
export { helper }
export function request(ctx) { return helper(1) }

In APPSYNC_JS, a function declared with export function is an entry point, and code may not call it. The same function declared plainly and exported in an export { ... } list is fine, and the tests import it the same way.

The fix moved every helper to that form, leaving only request and response as export function. Before any redeploy, the agent ran each path through EvaluateCode: a valid save, a bad ID, a save that already existed, the newest result, no result, and today's play time. All came back as expected.

What it taught

  • Check resolvers in the real runtime before the first deploy. EvaluateCode returned the exact line in one call. Granting it should come with writing the first AppSync JavaScript resolver.
  • Local lint is not the runtime. @aws-appsync/eslint-plugin caught the regex and missed the export rule. It is still worth running, as one layer.
  • Ask for evidence instead of making a second guess. The first fix was reasonable and half right. The second guess would have been a coin flip.
  • Make the lesson a test. A new backend test runs AWS's lint on every resolver and checks the two rules the lint misses: only request and response use export function, and no util function is passed as an argument. It fails on the old code.

How AI-DLC helped: the stage paused for my go on every AWS action, the read-only permission check came before any deploy, and the lessons went into the project's learnings, where the next stage that writes a resolver will read them.

What is next

The resolvers are verified in AppSync. Next is the redeploy of the test backend and the walking skeleton's end-to-end run, which decides the skeleton checkpoint.