Saltar a contenido

Migraciones de base de datos (Alembic)

Guía práctica para crear, validar y aplicar migraciones de esquema en el backend de Camarero IA. El backend usa PostgreSQL (driver asyncpg) tanto en local como en producción, SQLAlchemy 2.x async y Alembic para versionar el esquema.

Las migraciones viven en backend/alembic/versions/. La configuración del entorno está en backend/alembic/env.py.

Cuándo necesitas una migración

Cualquier cambio en el esquema de la BD: tabla nueva, columna nueva, índice, constraint o cambio de tipo. Si añades o tocas un modelo ORM y eso cambia la forma de una tabla, necesitas una migración.


Convenciones obligatorias

Estas reglas son normativas (ver backend/AGENTS.md). Síguelas siempre.

Regla Detalle
Nombre de fichero YYYYMMDD_descripcion_breve.py (p. ej. 20260614_table_lifecycle_fields.py).
Un solo head La cadena de revisiones debe ser lineal: una única cabeza. No crees ramas.
upgrade() y downgrade() Ambos completos e inversos: downgrade() deshace exactamente lo que hace upgrade().
NOT NULL en tabla existente Una columna nullable=False nueva sobre una tabla con datos requiere un server_default (o bien añadirla nullable=True primero y rellenar después).
Registrar el modelo Importa el modelo nuevo en app/main.py y en alembic/env.py para que autogenerate lo detecte.
Inmutabilidad Nunca edites una migración ya aplicada. Si necesitas corregir algo, crea una migración nueva encima.

Enums como String, no como tipo Enum

Por convención del repo, los enums se almacenan como sa.String(N) con el .value, nunca como el tipo Enum de SQLAlchemy. Una migración que añade un campo enum añade una columna String.


Registrar el modelo nuevo (clave para autogenerate)

Alembic solo detecta los cambios de los modelos cuyas tablas estén registradas en Base.metadata. En este proyecto eso ocurre por efecto secundario del import: cada módulo de modelo, al importarse, registra sus tablas en el Base compartido.

En alembic/env.py el target_metadata es Base.metadata y, justo encima, se importan todos los paquetes de modelos por módulo, por ejemplo:

from app.core.database import Base  # noqa: E402
# ...
from app.modules.table.infrastructure.models import table_model  # noqa: E402, F401
from app.modules.session.infrastructure.models import (  # noqa: E402, F401
    cart_model,
    idempotency_model,
    manager_call_model,
    session_model,
)
target_metadata = Base.metadata

Si tu tabla no aparece en el autogenerate

Casi siempre es porque el modelo no está importado. Añade el import del modelo nuevo en alembic/env.py (con # noqa: E402, F401) y en app/main.py, siguiendo el mismo orden que ya usa la app. Sin ese import, alembic revision --autogenerate generará una migración vacía.


Crear una migración

  1. Define o modifica el modelo ORM en su módulo (app/modules/{m}/infrastructure/models/...).
  2. Asegúrate de que el modelo está importado en alembic/env.py y en app/main.py (ver sección anterior).
  3. Genera la revisión con autogenerate:
alembic revision --autogenerate -m "descripcion_breve"
  1. Revisa el fichero generado a mano. Autogenerate es un punto de partida, no la verdad final: comprueba el down_revision, el server_default de columnas NOT NULL, los índices y que downgrade() sea el inverso exacto.
  2. Renombra el fichero si hace falta para que cumpla el patrón YYYYMMDD_descripcion.py.

down_revision y la cadena lineal

El down_revision de tu migración debe apuntar a la revisión que era head antes de la tuya. Si dos personas crean migraciones en paralelo aparecen dos heads: hay que rebasar una de ellas para que apunte a la otra y restaurar la linealidad.


Reglas para columnas

Columna NOT NULL nueva en una tabla existente

Una tabla en producción ya tiene filas. Si añades una columna nullable=False sin valor por defecto, la migración falla porque las filas existentes no pueden satisfacer la restricción. Dos opciones válidas:

  • Opción A (recomendada): server_default. La columna se crea nullable=False con un valor por defecto a nivel de servidor, que rellena las filas existentes.
  • Opción B: dos pasos. Añadirla nullable=True, rellenar los datos en la propia migración, y después aplicar el ALTER a NOT NULL.

Las columnas que son nullable=True no tienen este problema y se pueden añadir directamente.


Ejemplo real del repo

La migración backend/alembic/versions/20260614_table_lifecycle_fields.py añade campos de ciclo de vida de mesa a table_sessions para el dashboard de control de sala del manager. Ilustra todas las convenciones a la vez.

Cabecera con los identificadores de revisión (nótese la cadena lineal vía down_revision):

revision: str = "20260614_table_lifecycle_fields"
down_revision: Union[str, Sequence[str], None] = "20260613_selected_options_lines"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None

El upgrade() añade una columna NOT NULL con server_default (para que las filas existentes hagan backfill limpio) y el resto como nullable. El enum se almacena como String:

def upgrade() -> None:
    op.add_column(
        "table_sessions",
        sa.Column(
            "lifecycle_state",
            sa.String(length=30),
            server_default="READING",
            nullable=False,
        ),
    )
    op.create_index(
        op.f("ix_table_sessions_lifecycle_state"),
        "table_sessions",
        ["lifecycle_state"],
        unique=False,
    )
    op.add_column(
        "table_sessions", sa.Column("seated_at", sa.DateTime(timezone=True), nullable=True)
    )
    # ... más columnas nullable timezone-aware ...
    op.add_column(
        "table_sessions", sa.Column("table_profile", sa.String(length=30), nullable=True)
    )

El downgrade() es el inverso exacto: borra las columnas en orden inverso y elimina el índice antes de la columna que indexa:

def downgrade() -> None:
    op.drop_column("table_sessions", "table_profile")
    # ... drops en orden inverso ...
    op.drop_index(
        op.f("ix_table_sessions_lifecycle_state"), table_name="table_sessions"
    )
    op.drop_column("table_sessions", "lifecycle_state")

Qué aprender de este ejemplo

  • lifecycle_state es NOT NULL → lleva server_default="READING".
  • Los timestamps son DateTime(timezone=True) (timezone-aware, convención del proyecto).
  • El enum se guarda como String(30), no como tipo Enum.
  • downgrade() borra en orden inverso y suelta el índice antes que su columna.

Verificar la migración

Antes de dar la migración por buena, aplícala contra una base de datos desechable y comprueba que sube y baja sin errores.

# Aplicar todas las migraciones pendientes hasta la cabeza
alembic upgrade head

# (recomendado) Verificar que el downgrade también funciona
alembic downgrade -1
alembic upgrade head

Comprobaciones útiles del estado de las revisiones:

# Revisión(es) actualmente aplicada(s) en la BD
alembic current

# La cabeza de la cadena de revisiones — debe ser UNA sola
alembic heads

# Historial completo de revisiones
alembic history

Más de un head = cadena rota

Si alembic heads devuelve más de una revisión, hay un merge pendiente: dos migraciones comparten el mismo down_revision. Reapunta el down_revision de una de ellas a la otra para volver a una cadena lineal antes de mergear.

Bootstrap de BD vacía

En este repo, sobre una base de datos vacía, env.py no reproduce toda la cadena de revisiones: crea el esquema canónico actual con Base.metadata.create_all() y luego hace stamp a head. Las bases de datos ya existentes sí siguen el camino incremental normal de alembic upgrade. Por eso, para verificar el camino incremental de tu migración, parte de una BD que ya tenga el esquema previo aplicado, no de una vacía.


Errores comunes

  • La migración sale vacía. El modelo no está importado en alembic/env.py / app/main.py. Añade el import y vuelve a generar.
  • upgrade head falla con NOT NULL. Añadiste una columna nullable=False sin server_default sobre una tabla con datos. Usa server_default o el patrón de dos pasos.
  • Dos heads. Dos migraciones en paralelo. Reapunta down_revision y restaura la linealidad.
  • Editaste una migración ya aplicada. No lo hagas: las BD que ya la aplicaron no la re-ejecutan. Crea una migración nueva encima.

Páginas relacionadas