El error de TypeScript que solo CI podía ver: amplify_outputs.json, imports de solo tipo y un gate local en verde

Log de compilación de AWS Amplify con un error TS2307 por amplify_outputs.json, junto a un diagrama de la cadena de imports desde una Lambda worker hasta el archivo JSON generado, pasando por un módulo compartido de lib

Todas las verificaciones en mi máquina estaban en verde. npm run lint, npx jest con 1622 tests, next build — y, sobre todo, el typecheck del backend que mis propios estándares de ingeniería exigen antes de cualquier pull request:

npx tsc --noEmit -p amplify/tsconfig.json

Hice push. La compilación de staging en Amplify falló en la fase de backend, antes de compilar una sola línea del frontend:

[SyntaxError] TypeScript validation check failed.
lib/admin/courses/client.ts:7:21 - error TS2307: Cannot find module '../../../amplify_outputs.json'

El mismo comando, el mismo archivo de proyecto, la misma versión de TypeScript: verde en local, rojo en CI. Este artículo es la anatomía de esa diferencia, porque la lección va mucho más allá de Amplify: un gate previo al despliegue tiene que reproducir el estado del sistema de archivos de CI, no solo su alcance de compilación.

Resumen

  1. amplify_outputs.json es un archivo generado, está en .gitignore y nunca se commitea. ampx pipeline-deploy valida con TypeScript el grafo del backend antes de escribirlo.
  2. En local el archivo existe (lo escribió ampx sandbox), así que el typecheck idéntico lo resuelve sin problema. Esa es toda la diferencia.
  3. Un import de solo tipo (import type { X } from './client') igualmente resuelve ./client. Eso arrastra ese módulo — y todo lo que él importa — al programa.
  4. Solución: mover los tipos a un módulo que no importe ningún archivo generado y apuntar allí todos los módulos alcanzables desde el backend.
  5. Ciérralo con un gate: ejecuta el typecheck del backend con el archivo generado escondido temporalmente.

El contexto: por qué el backend leía código de la app

El proyecto es una app de Next.js (App Router) sobre Amplify Gen 2. Dos funcionalidades ejecutan su trabajo lento como jobs en segundo plano en Lambda: un worker de importación de cursos y, más recientemente, un worker de evaluación que llama a una API externa de puntuación y guarda los resultados.

Esos handlers viven en amplify/functions/** y necesitan la misma lógica de dominio que usa la web: leer cursos, recorrer lecciones, actualizar filas de jobs. En lugar de duplicarla, importan los módulos compartidos de lib/ por ruta relativa, exactamente como indican las reglas de la casa:

Mantén el código compartido alcanzable desde el backend con imports relativos, nunca alias @/, para que ambos alcances de compilación y el bundler de Lambda lo resuelvan igual.

Esa regla se escribió tras un fallo de despliegue anterior en otro proyecto: mismo código de error, causa distinta. Es un buen consejo, y se siguió. Simplemente no bastaba.

Cómo un import de solo tipo arrastró un JSON al despliegue

Los módulos compartidos reciben el cliente de datos de Amplify como parámetro, y lo tipaban así:

// lib/admin/courses/reads.ts
// Types only: importing the client module's *values* would pull Next's server
// runtime in at module scope and make this module untestable without it.
import type { CourseAuthMode, CourseClient } from './client'

El comentario demuestra que el autor ya sabía que los valores eran peligrosos aquí. Lo que se le escapa es que import type no sale gratis. TypeScript todavía tiene que encontrar ./client para saber qué significa CourseClient, así que client.ts entra en el programa — y client.ts empieza así:

// lib/admin/courses/client.ts
import { createServerRunner } from '@aws-amplify/adapter-nextjs'
import { generateServerClientUsingCookies } from '@aws-amplify/adapter-nextjs/api'
import { cookies } from 'next/headers'

import type { Schema } from '../../../amplify/data/resource'
import outputs from '../../../amplify_outputs.json'

const { runWithAmplifyServerContext } = createServerRunner({ config: outputs })

La cadena que llegó a CI es, por tanto:

amplify/functions/course-import-worker/handler.ts
  → lib/admin/courses/import-jobs.ts
      → import type { CourseClient } from './client'
          → lib/admin/courses/client.ts
              → import outputs from '../../../amplify_outputs.json'   ← does not exist yet

Seis módulos compartidos tenían ese import — reads, import-jobs, translations, enumeration, audio-jobs, tts-jobs — y todos son alcanzables desde un worker.

Por qué pasaron todas las verificaciones locales

amplify/tsconfig.json es el alcance correcto: es exactamente lo que valida el despliegue. El gate no se equivocaba en qué archivos compilar. Se equivocaba en qué existe.

LocalCI de Amplify
Alcance de compilaciónamplify/tsconfig.jsonamplify/tsconfig.json
Archivos incluidosworker → lib/**client.tsidénticos
amplify_outputs.jsonpresente (lo escribió ampx sandbox)aún no generado
ResultadoverdeTS2307

ampx pipeline-deploy ejecuta primero su validación de TypeScript del grafo del backend, y solo después despliega el backend y escribe amplify_outputs.json. El archivo cuya ausencia rompió la verificación es una salida del mismo paso que se está verificando. En local ese orden es invisible, porque un sandbox escribió el archivo hace semanas y ahí sigue.

Esto es toda una categoría de bug: cualquier import de un artefacto generado en tiempo de compilación es una mina para el despliegue, y ninguna configuración del compilador la revelará mientras el artefacto siga en tu disco.

Reproducir CI en un comando

El truco es hacer que tu máquina se parezca a un checkout limpio de CI: esconde el archivo generado y ejecuta el gate exacto:

mv amplify_outputs.json /tmp/outputs.bak
npx tsc --noEmit -p amplify/tsconfig.json
mv /tmp/outputs.bak amplify_outputs.json

Eso reprodujo el fallo de inmediato y confirmó que el error era el único: nada más en el grafo del backend dependía de archivos generados.

Para ver por qué un archivo está en el programa, añade --explainFiles. Imprime la cadena de imports que arrastró cada archivo:

mv amplify_outputs.json /tmp/outputs.bak
npx tsc --noEmit -p amplify/tsconfig.json --explainFiles
mv /tmp/outputs.bak amplify_outputs.json

La salida se lee como un stack trace de la resolución de módulos:

lib/admin/courses/client.ts
  Imported via './client' from file 'lib/admin/courses/reads.ts'
lib/admin/courses/reads.ts
  Imported via '../../../lib/admin/courses/reads' from file
  'amplify/functions/course-import-worker/handler.ts'

Tres líneas de salida sustituyeron a una hora de conjeturas. Si te llevas una sola herramienta de este artículo, que sea --explainFiles.

La solución: tipos que no tocan nada generado

Los tipos en sí no necesitan nada de lo que importa client.ts. CourseClient puede derivarse directamente de la función fábrica del adaptador, así que vive tranquilo en un módulo sin ningún import de archivos generados:

// lib/admin/courses/client-types.ts
import type { generateServerClientUsingCookies } from '@aws-amplify/adapter-nextjs/api'

import type { Schema } from '../../../amplify/data/resource'

/** Signed-in admin/editor reads and every write; `iam` covers guest reads. */
export type CourseAuthMode = 'userPool' | 'iam'

export type CourseClient = ReturnType<
  typeof generateServerClientUsingCookies<Schema>
>

Importar amplify/data/resource no es problema: es código fuente, está en el repositorio y ya forma parte del programa del backend. Después, los seis módulos alcanzables desde los workers cambian una línea cada uno:

// before — reaches client.ts, and through it the generated JSON
import type { CourseAuthMode, CourseClient } from './client'

// after — reaches nothing that CI has yet to generate
import type { CourseAuthMode, CourseClient } from './client-types'

Y client.ts reexporta los tipos para que sus consumidores exclusivos del servidor sigan funcionando sin cambios:

// lib/admin/courses/client.ts
import type { CourseAuthMode, CourseClient } from './client-types'
export type { CourseAuthMode, CourseClient } from './client-types'

El tropiezo que merece quedar documentado

Mi primer push llevaba solo la línea export type { … } from './client-types'. Una reexportación no trae el nombre al ámbito del propio módulo: lo reenvía a quien importe, y nada más. client.ts sigue usando CourseClient en sus propias firmas, así que necesita también su propio import type. Las dos líneas, no una.

El typecheck raíz lo detectó minutos después. Que es la mitad pequeña y alentadora de esta historia: los gates en capas sí funcionan, en cuanto cada capa verifica de verdad lo que dice verificar.

La regla que generaliza

Amplify es solo el escenario. La forma del bug aparece en todas partes donde el código generado convive con el código fuente: amplify_outputs.json, .env.generated, clientes de Prisma, salida de codegen de GraphQL, stubs de protobuf, next-env.d.ts.

Tres reglas que aplico ahora:

  • Un gate previo al despliegue debe reproducir el estado del sistema de archivos de CI, no solo su alcance de compilación. Si CI parte de un checkout limpio, tu gate también debería: esconde los artefactos generados y vuelve a ejecutarlo.
  • Los imports de solo tipo igualmente resuelven módulos. import type borra la emisión, no la resolución. Todo lo que puedas alcanzar a través de una anotación de tipo forma parte de tu compilación.
  • Mantén el código compartido alcanzable desde el backend libre de artefactos de compilación. Si un módulo importa configuración generada, runtime del framework (next/headers) o archivos específicos del entorno, no es código compartido: es código de servidor, y solo el servidor puede importarlo.

La regla de la casa para este proyecto dice ahora: cualquier cambio que toque amplify/** o código compartido que el backend importe pasa por el typecheck del backend con los archivos generados escondidos, antes del pull request. Cuesta unos cuatro segundos.

Referencias

  • No se puede resolver amplify_outputs.json

    Para corregir el error de compilación "no se puede resolver amplify_outputs.json", añade npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID a la configuración de compilación y adjunta la política AdministratorAccess-Amplify

  • Desplegar una app fullstack de Next.js 16 en AWS Amplify Gen 2 (SSR): seis fallos y cómo evitarlos

    Llevar una app de Next.js 16 con un backend de Amplify Gen 2 a Amplify Hosting SSR costó seis compilaciones fallidas — cada una revelando una capa más profunda que la anterior. Esta es la cadena completa: desincronización del lockfile, el límite de 220 MB de cómputo, el callejón sin salida de la especificación de despliegue, un binario xz ausente, un usuario de compilación sin permisos de root y una incompatibilidad entre layer y runtime — con la solución de cada uno.

  • Corrección de errores de dependencia circular de CloudFormation en AWS Amplify Gen 2.

    Cuando tu función Lambda necesita acceso a Cognito Y se usa como controlador de datos, usa resourceGroupName: 'auth' en tu definición de función para evitar dependencias circulares.