Python para m-b-core/Prácticas/5 · Postgres y alembic
Práctica 5 de 8

Postgres propio con SQLModel y alembic

Hasta ahora solo leías la base heredada. Acá el mini-repo tiene datos suyos: levantas un Postgres 16 en Docker Compose, declaras dos tablas con SQLModel, generas y revisas dos migraciones con alembic, y encierras una unidad de negocio en una sola transacción. De paso arreglas un bug real de m-b-core que imprime la contraseña de producción en los logs de cada deploy.

concepto · el esquema es código: los modelos lo declaran, alembic lo aplica y la transacción lo protege2 sesionespostgres:16 · SQLModel · alembic async · asyncpg · pytest -m integration
Construyesdocker-compose.yml, shared/db.py, shared/models.py, alembic/ con dos migraciones, locales/config_service.py y sus tests
Igual que en m-b-coreshared/db.py con engine perezoso, alembic/env.py async, migraciones numeradas 0001_…, marca integration
Distintoacá la máscara de la URL es la de SQLAlchemy; m-b-core la hace con un split('@') que no oculta nada
Terminas con5 tests unit sobre SQLite en memoria, 2 integration contra el Postgres del compose, esquema en 0002 y un downgrade probado

Objetivos

  • Levantar un Postgres 16 en Docker Compose con healthcheck, volumen con nombre y proyecto fijo, y saber por qué esas tres cosas importan.
  • Escribir shared/db.py (engine async sobre asyncpg, perezoso, desde settings.DB_URL) y declarar LocalConfig y Aviso con SQLModel.
  • Configurar alembic/env.py en modo async y enmascarar la URL con make_url(...).render_as_string(hide_password=True), en vez del split('@') que filtra la contraseña en m-b-core.
  • Generar la migración inicial con --autogenerate, revisarla a mano y agregarle lo que alembic no puede saber.
  • Agregar una columna con una segunda migración sin perder datos, y comprobar en vivo qué sí y qué no recupera un downgrade.
  • Escribir un servicio cuya unidad de negocio es una sola transacción (async with session.begin()), y separar tests unit (SQLite en memoria) de integration (Postgres real).

Antes de empezar

Vienes de la Práctica 4: el mini-repo ya tiene pyproject.toml, pytest.ini con asyncio_mode = auto, requirements/ pineados, el Makefile con PYTHONPATH := . y shared/legacy_db/ leyendo la base heredada con legacy_db_read(sql, params). Esa parte no se toca. Lo que agregas hoy es la otra mitad, la que en m-b-core convive con ella: un esquema propio en Postgres, con ORM y migraciones.

Esa convivencia es deliberada. El CLAUDE.md de m-b-core, guideline 10, dice: Don't add pandas, polars, or ORMs for compute. Queries are raw SQL via SQLAlchemy text(). No es "los ORM están prohibidos": para leer la legacy se usa SQL crudo, porque esas tablas no las controlamos. El esquema nuevo sí, y ahí hay modelos y migraciones. Confundir las dos mitades es el error más común al llegar al repo.

Necesitas Docker corriendo. Agrega a requirements/base.txt las cuatro dependencias de hoy:

requirements/base.txt (agregar)
sqlmodel==0.0.24
sqlalchemy[asyncio]==2.0.37
alembic==1.14.1
asyncpg==0.30.0
cd mesa-core-practicas
source .venv/bin/activate
pip install -r requirements/local.txt
docker info > /dev/null && echo "docker ok"

Construcción paso a paso

Paso 1 Un Postgres 16 en tu laptop

Nada de instalar Postgres en el sistema: una base por proyecto, declarada en el repo, que se levanta y se tira con un comando. Es lo que hace el docker-compose.yml de m-b-core (su CLAUDE.md arranca con docker compose up -d postgres redis).

docker-compose.yml
name: mesa-core-practicas

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: practicas
      POSTGRES_PASSWORD: localdev   # solo local; en prod la contraseña sale de Secret Manager
      POSTGRES_DB: practicas
    ports:
      - "5445:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U practicas -d practicas"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  pgdata:

La URL de conexión no vive en el código: plantilla versionada, archivo real fuera de git (env/*.env al .gitignore, con !env/*.env.template).

env/local.env.template
# Copia a env/local.env (ignorado por git) y rellena.
ENV=dev
DB_URL=postgresql+asyncpg://practicas:CAMBIAME@localhost:5445/practicas

Y los atajos en el Makefile, que ya exporta PYTHONPATH := .:

Makefile (agregar)
ALEMBIC := $(VENV)/bin/alembic

db-up:
	docker compose up -d postgres

db-down:
	docker compose down

db-rev:                       # ej: make db-rev m="agrega columna x"
	$(ALEMBIC) revision --autogenerate -m "$(m)"

db-up-head:
	$(ALEMBIC) upgrade head

db-down-1:
	$(ALEMBIC) downgrade -1
cp env/local.env.template env/local.env
# edita env/local.env y pon la contraseña del compose (localdev)
make db-up
docker compose ps
Deberías ver
docker compose up -d postgres
 Network mesa-core-practicas_default  Created
 Volume mesa-core-practicas_pgdata  Created
 Container mesa-core-practicas-postgres-1  Started

NAME                             IMAGE         COMMAND                  SERVICE    CREATED         STATUS                   PORTS
mesa-core-practicas-postgres-1   postgres:16   "docker-entrypoint.s…"   postgres   9 seconds ago   Up 8 seconds (healthy)   0.0.0.0:5445->5432/tcp
Las cuatro decisiones del archivo. name: fija el nombre del proyecto; sin esa línea Compose lo saca del nombre de la carpeta, y con ella todas las copias del repo comparten stack (trampa 1, peor de lo que suena). El healthcheck con pg_isready es lo que hace que (healthy) signifique algo: Postgres tarda un par de segundos en aceptar conexiones tras arrancar, y sin healthcheck tu primer alembic upgrade falla por carrera. El volumen con nombre hace que los datos sobrevivan a docker compose down y solo mueran con down -v. Y el puerto 5445:5432: el 5432 del host casi siempre está tomado; elige uno tuyo y anótalo en la URL.

Paso 2 shared/db.py: un engine, muchas sesiones

Primero, settings.py gana una variable. Sigue siendo el único módulo que lee el entorno:

shared/settings.py (agregar a Settings)
    # postgresql+asyncpg://user:pass@host:5445/db — nunca literal en el código
    DB_URL: str = os.getenv("DB_URL", "")
shared/db.py
"""Conexión y sesiones async a Postgres (nuestro esquema propio).

Calca shared/db.py de m-b-core: engine perezoso (se crea en el primer uso y
solo si hay DB_URL), sessionmaker async y un context manager `db_session()`.
"""

from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager

from sqlalchemy.ext.asyncio import (
    AsyncEngine,
    AsyncSession,
    async_sessionmaker,
    create_async_engine,
)

from .settings import settings

_engine: AsyncEngine | None = None
_SessionLocal: async_sessionmaker[AsyncSession] | None = None


def get_engine() -> AsyncEngine:
    """Engine único del proceso. Falla claro si falta DB_URL."""
    global _engine, _SessionLocal
    if _engine is None:
        if not settings.DB_URL:
            raise ValueError("DB_URL no está configurada (env/local.env).")
        _engine = create_async_engine(
            settings.DB_URL,
            pool_pre_ping=True,
            echo=settings.ENV == "staging",  # ojo: echo=True vuelca SQL con datos
        )
        _SessionLocal = async_sessionmaker(_engine, expire_on_commit=False, class_=AsyncSession)
    return _engine


@asynccontextmanager
async def db_session() -> AsyncGenerator[AsyncSession, None]:
    """Una sesión por unidad de trabajo. Quien la usa decide la transacción."""
    get_engine()
    assert _SessionLocal is not None
    async with _SessionLocal() as session:
        yield session

Comprueba que conecta. El Makefile exporta PYTHONPATH, pero no carga env/local.env: eso lo haces tú en la terminal.

set -a && . ./env/local.env && set +a
export PYTHONPATH=.
python -c "
import asyncio
from sqlalchemy import text
from shared.db import db_session

async def main():
    async with db_session() as s:
        print((await s.execute(text('select version()'))).scalar_one())

asyncio.run(main())"
Deberías ver
PostgreSQL 16.15 (Debian 16.15-1.pgdg13+2) on aarch64-unknown-linux-gnu, compiled by gcc …
Perezoso, no ansioso. El engine se crea en la primera llamada a get_engine(), no al importar el módulo. Parece un detalle y no lo es: el _ensure_engine() de m-b-core tiene el mismo diseño con un comentario explícito — so the app can start without a database (e.g. local API-only docker compose). Un módulo que abre conexiones al importarse rompe los tests y rompe el arranque del contenedor cuando la base todavía no está. pool_pre_ping=True descarta conexiones muertas antes de usarlas (imprescindible detrás de un pooler que corta ocioso). Y mira echo=settings.ENV == "staging": es el default de m-b-core, y es el patrón que llenó de SQL el stdout de staging — echo=True vuelca cada sentencia con sus valores, así que en un entorno con datos reales es a la vez una fuga y una factura de logging.

Paso 3 Dos tablas nuestras, declaradas con SQLModel

SQLModel es Pydantic y SQLAlchemy a la vez: la misma clase valida datos y define la tabla. LocalConfig guarda lo que la legacy no tiene (si el local acepta reservas online, cuántos días de anticipación); Aviso es el registro de avisos enviados, que en la práctica 7 será la tabla de idempotencia del worker.

shared/models.py
"""Modelos SQLModel del esquema propio: la fuente de verdad de alembic."""

from datetime import UTC, datetime

from sqlalchemy import Column, DateTime
from sqlmodel import Field, SQLModel


def _utcnow() -> datetime:
    # aware, siempre; naive está prohibido (práctica 6)
    return datetime.now(UTC)


class LocalConfig(SQLModel, table=True):
    """Configuración propia por restaurante (lo que el legacy no tiene)."""

    __tablename__ = "local_config"

    # Local.Id del legacy: la clave viene de fuera, no la genera Postgres
    local_id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": False})
    permite_reserva_online: bool = Field(default=True)
    max_dias_anticipacion: int = Field(default=30, ge=0)
    updated_at: datetime = Field(
        default_factory=_utcnow, sa_column=Column(DateTime(timezone=True), nullable=False)
    )


class Aviso(SQLModel, table=True):
    """Registro de avisos enviados (idempotencia del worker, práctica 7)."""

    __tablename__ = "aviso"

    id: int | None = Field(default=None, primary_key=True)
    local_id: int = Field(index=True)
    tipo: str = Field(max_length=40)
    enviado_en: datetime = Field(
        default_factory=_utcnow, sa_column=Column(DateTime(timezone=True), nullable=False)
    )
python -c "
from sqlmodel import SQLModel
from shared import models  # el import es lo que registra las tablas
print(sorted(SQLModel.metadata.tables))"
Deberías ver
['aviso', 'local_config']
Tres cosas y un import. (1) local_id lleva autoincrement=False: es el Local.Id que ya existe en la legacy, no un serial que invente Postgres — por defecto SQLAlchemy crea una secuencia y tu 11 se vuelve un 1. (2) DateTime(timezone=True) con datetime.now(UTC): timestamptz. La práctica 6 se dedica entera a por qué no es opcional. (3) Field(ge=0) valida en Python, no en la base; la restricción de verdad la agregas a mano en la migración del paso 5. Y el import: SQLModel.metadata solo conoce las tablas de los módulos que alguien importó. Ese detalle es la trampa 3, y cuando muerde borra tu esquema entero.

Paso 4 alembic en modo async, y la máscara que m-b-core tiene mal

alembic trae plantillas; la que necesitas es la async, porque tu engine es asyncpg:

alembic init -t async alembic
Deberías ver
Creating directory '…/mesa-core-practicas/alembic' ...  done
Creating directory '…/mesa-core-practicas/alembic/versions' ...  done
Generating …/alembic/script.py.mako ...  done
Generating …/alembic/env.py ...  done
Generating …/alembic.ini ...  done
Please edit configuration/connection/logging settings in '…/alembic.ini' before proceeding.

Tres cambios en alembic.ini. El más importante es dejar sqlalchemy.url vacío: la URL tiene la contraseña y este archivo va a git.

alembic.ini (los tres cambios)
script_location = alembic

# el nombre del archivo es el id de revisión, y el id lo elegimos nosotros
file_template = %%(rev)s

# para que `from shared…` funcione dentro de env.py
prepend_sys_path = .

# vacío a propósito: la URL la pone env.py desde el entorno
sqlalchemy.url =

Y ahora env.py, que es el archivo que de verdad importa:

alembic/env.py
"""Entorno de alembic (async). Calca alembic/env.py de m-b-core, con dos diferencias
deliberadas: la URL viene de shared.settings (no de os.getenv suelto) y la máscara
de la URL es la de SQLAlchemy (`render_as_string(hide_password=True)`), no un
`split('@')` a mano — ver la trampa 1 de la práctica.
"""

import asyncio
from logging.config import fileConfig

from sqlalchemy import pool
from sqlalchemy.engine import Connection, make_url
from sqlalchemy.ext.asyncio import async_engine_from_config
from sqlmodel import SQLModel

from alembic import context
from shared import models  # noqa: F401 — registra las tablas en SQLModel.metadata
from shared.settings import settings

config = context.config
fileConfig(config.config_file_name)

# La fuente de verdad del esquema son los modelos SQLModel.
target_metadata = SQLModel.metadata

if not settings.DB_URL:
    raise RuntimeError("DB_URL requerido para alembic (env/local.env)")

# Máscara correcta: SQLAlchemy sabe cuál es la contraseña.
print("[alembic] DB_URL:", make_url(settings.DB_URL).render_as_string(hide_password=True))
config.set_main_option("sqlalchemy.url", settings.DB_URL)


def do_run_migrations(connection: Connection) -> None:
    context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
    with context.begin_transaction():
        context.run_migrations()


async def run_async_migrations() -> None:
    connectable = async_engine_from_config(
        config.get_section(config.config_ini_section, {}),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )
    async with connectable.connect() as connection:
        await connection.run_sync(do_run_migrations)
    await connectable.dispose()


asyncio.run(run_async_migrations())   # (la plantilla trae también el modo offline)

Agrega también el import de SQLModel a la plantilla de migraciones, o cada archivo generado fallará al importarse:

alembic/script.py.mako (agregar bajo los imports)
import sqlmodel  # tipos AutoString de SQLModel
alembic current
Deberías ver (base vacía: no hay revisión aplicada)
INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO  [alembic.runtime.migration] Will assume transactional DDL.
[alembic] DB_URL: postgresql+asyncpg://practicas:***@localhost:5445/practicas

La línea que acabas de escribir arregla un bug de producción. El alembic/env.py de m-b-core, línea 47, hace la máscara a mano:

m-b-core · alembic/env.py:47 (el original)
    print(
        f"[alembic] DB_URL (masked): {DB_URL.split('@')[0]}@***:***@***"
        if "@" in DB_URL
        else "[alembic] DB_URL: <not set>"
    )

Córrelo con una URL con la forma de la de producción y compara las dos salidas:

python -c "
from sqlalchemy.engine import make_url
DB_URL = 'postgresql+asyncpg://mesa_app:S3cr3t0-DeProd@10.20.0.7:6432/mesa'
print('m-b-core :', f\"[alembic] DB_URL (masked): {DB_URL.split('@')[0]}@***:***@***\")
print('correcto :', '[alembic] DB_URL:', make_url(DB_URL).render_as_string(hide_password=True))"
Deberías ver (y ahí está el problema)
m-b-core : [alembic] DB_URL (masked): postgresql+asyncpg://mesa_app:S3cr3t0-DeProd@***:***@***
correcto : [alembic] DB_URL: postgresql+asyncpg://mesa_app:***@10.20.0.7:6432/mesa
Lee las dos líneas otra vez. split('@')[0] corta por la arroba y se queda con la primera mitad, que es justo esquema://usuario:contraseña. Los asteriscos tapan el host y el puerto: lo único que no era secreto. La palabra (masked) es lo que hace que nadie lo mire dos veces. Y esa línea corre en cada alembic upgrade head del deploy, así que la contraseña queda escrita en los logs de Cloud Build de cada release; el mismo patrón está repetido en la línea 99, para la URL del engine. make_url() parsea la URL de verdad y render_as_string(hide_password=True) reemplaza solo el componente contraseña. La lección general: cuando enmascares un secreto no cortes strings, usa el parser del formato — y verifica la máscara con un caso real antes de confiar en ella.

Paso 5 La primera migración: generar y después leer

Con la base vacía y los modelos importados, alembic compara lo que hay con lo que debería haber y escribe la diferencia:

alembic revision --autogenerate -m "local_config y aviso" --rev-id 0001_local_config_aviso
Deberías ver
INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO  [alembic.runtime.migration] Will assume transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'aviso'
INFO  [alembic.autogenerate.compare] Detected added index ''ix_aviso_local_id'' on '('local_id',)'
INFO  [alembic.autogenerate.compare] Detected added table 'local_config'
[alembic] DB_URL: postgresql+asyncpg://practicas:***@localhost:5445/practicas
Generating …/alembic/versions/0001_local_config_aviso.py ...  done

El --rev-id no es cosmético: con file_template = %%(rev)s el id es el nombre del archivo, así que las migraciones se ordenan solas y se citan por número en los PRs. Es la convención de m-b-core (0001_create_legacy_schema.py, 0002_legacy_replica_state_and_grants.py…). Sin él te queda a2f7902804d6.py y nadie sabe qué hay dentro.

Ahora abre el archivo. Esto es lo que alembic escribió, tal cual, y el comentario # please adjust! va en serio:

alembic/versions/0001_local_config_aviso.py (crudo, recién generado)
def upgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    op.create_table('aviso', …)
    op.create_index(op.f('ix_aviso_local_id'), 'aviso', ['local_id'], unique=False)
    op.create_table('local_config',
    sa.Column('local_id', sa.Integer(), autoincrement=False, nullable=False),
    sa.Column('permite_reserva_online', sa.Boolean(), nullable=False),
    sa.Column('max_dias_anticipacion', sa.Integer(), nullable=False),
    sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
    sa.PrimaryKeyConstraint('local_id')
    )
    # ### end Alembic commands ###

Está casi bien: recogió el autoincrement=False y el timezone=True porque los declaraste en el modelo. Falta lo que no puede saber: Field(ge=0) es una validación de Pydantic y vive en Python, así que la base aceptaría -5 sin chistar. Agrégalo a mano, con el comentario que explica por qué:

alembic/versions/0001_local_config_aviso.py (revisado a mano)
    op.create_table('local_config',
    # Revisado a mano: local_id es el Id del legacy, no un serial de Postgres
    sa.Column('local_id', sa.Integer(), autoincrement=False, nullable=False),
    …
    sa.PrimaryKeyConstraint('local_id'),
    # Revisado a mano: `Field(ge=0)` valida en Python, no en la BD. La restricción
    # que importa vive donde viven los datos.
    sa.CheckConstraint('max_dias_anticipacion >= 0', name='ck_local_config_max_dias'),
    )
make db-up-head
docker compose exec -T postgres psql -U practicas -d practicas -c '\d local_config'
Deberías ver
INFO  [alembic.runtime.migration] Running upgrade  -> 0001_local_config_aviso, local_config y aviso

                            Table "public.local_config"
         Column         |           Type           | Nullable
------------------------+--------------------------+----------
 local_id               | integer                  | not null
 permite_reserva_online | boolean                  | not null
 max_dias_anticipacion  | integer                  | not null
 updated_at             | timestamp with time zone | not null
Indexes:
    "local_config_pkey" PRIMARY KEY, btree (local_id)
Check constraints:
    "ck_local_config_max_dias" CHECK (max_dias_anticipacion >= 0)
--autogenerate es un borrador, no un resultado. Compara estructura: tablas, columnas, tipos, índices. No ve reglas de negocio, ni CHECK que quieras poner tú, ni datos que haya que migrar, ni el orden en que conviene hacer las cosas para no bloquear una tabla grande. Frente a Laravel la diferencia es esta: make:migration te da un archivo vacío para que escribas el DDL; --autogenerate te da el DDL escrito para que lo leas. Ninguno de los dos te libera de leerlo. Y en m-b-core la migración es lo único que corre antes del tráfico en cada deploy: un op.drop_column que se coló en un autogenerate no es un bug, es un incidente.

Paso 6 Segunda migración: agregar una columna sin perder nada (y qué sí se pierde al bajar)

Antes de tocar nada, mete una fila para tener algo que perder. Esto es la parte del ejercicio que no se puede saltar:

docker compose exec -T postgres psql -U practicas -d practicas \
  -c "INSERT INTO local_config (local_id, permite_reserva_online, max_dias_anticipacion, updated_at)
      VALUES (11, true, 30, now());"
Deberías ver
INSERT 0 1

Ahora agrega el campo al modelo. Es una preparación de la práctica 6: un local puede tener una zona horaria propia que no coincida con la de su Ubigeo.

shared/models.py (dentro de LocalConfig)
    # Nullable: los locales existentes no la tienen y no queremos inventar un valor.
    zona_horaria_override: str | None = Field(default=None, max_length=64)
alembic revision --autogenerate -m "local_config.zona_horaria_override" --rev-id 0002_zona_horaria_override
Deberías ver
INFO  [alembic.ddl.postgresql] Detected sequence named 'aviso_id_seq' as owned by integer column 'aviso(id)', assuming SERIAL and omitting
INFO  [alembic.autogenerate.compare] Detected added column 'local_config.zona_horaria_override'
Generating …/alembic/versions/0002_zona_horaria_override.py ...  done
alembic/versions/0002_zona_horaria_override.py
revision: str = '0002_zona_horaria_override'
down_revision: Union[str, None] = '0001_local_config_aviso'


def upgrade() -> None:
    op.add_column('local_config', sa.Column('zona_horaria_override',
        sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True))


def downgrade() -> None:
    op.drop_column('local_config', 'zona_horaria_override')
make db-up-head
docker compose exec -T postgres psql -U practicas -d practicas \
  -c "SELECT local_id, max_dias_anticipacion, zona_horaria_override FROM local_config;"
Deberías ver
INFO  [alembic.runtime.migration] Running upgrade 0001_local_config_aviso -> 0002_zona_horaria_override, local_config.zona_horaria_override

 local_id | max_dias_anticipacion | zona_horaria_override
----------+-----------------------+-----------------------
       11 |                    30 |
(1 row)

La fila sigue ahí y la columna nueva llegó en NULL. Eso es lo que hace segura esta migración: nullable=True y sin server_default, así que Postgres solo toca el catálogo y no reescribe la tabla. Ahora la otra mitad del experimento. Llena la columna y baja una revisión:

docker compose exec -T postgres psql -U practicas -d practicas \
  -c "UPDATE local_config SET zona_horaria_override = 'America/Lima' WHERE local_id = 11;" \
  -c "SELECT local_id, zona_horaria_override FROM local_config;"
make db-down-1
make db-up-head
docker compose exec -T postgres psql -U practicas -d practicas \
  -c "SELECT local_id, zona_horaria_override FROM local_config;"
Deberías ver (la lección)
UPDATE 1
 local_id | zona_horaria_override
----------+-----------------------
       11 | America/Lima
(1 row)

INFO  [alembic.runtime.migration] Running downgrade 0002_zona_horaria_override -> 0001_local_config_aviso, …
INFO  [alembic.runtime.migration] Running upgrade 0001_local_config_aviso -> 0002_zona_horaria_override, …

 local_id | zona_horaria_override
----------+-----------------------
       11 |
(1 row)
El downgrade restaura el esquema, nunca los datos. Bajaste y volviste a subir: la columna está de vuelta con el mismo tipo y la misma longitud, y el America/Lima ya no existe. op.drop_column es un DROP COLUMN: los valores se fueron con la columna y ningún upgrade los va a adivinar. Por eso el downgrade sirve para dos cosas y solo dos: deshacer una migración que acabas de aplicar en tu laptop, y probar que es simétrica. En producción el plan de vuelta atrás no es downgrade, es una migración nueva hacia adelante. De ahí la regla operativa: una migración que borra datos nunca va en el mismo PR que el código que deja de usarlos — primero se despliega el código que ya no los necesita, y en un PR posterior se borra la columna.

Paso 7 Una unidad de negocio, una transacción

Cambiar la configuración de un local implica dos escrituras: actualizar local_config y dejar un Aviso de auditoría. O entran las dos, o no entra ninguna. Eso es una transacción, y en SQLAlchemy 2 se escribe así (en errors.py, una ConfigError(Exception) de base y DiasAnticipacionInvalidos(ConfigError); la práctica 7 las convierte en respuestas HTTP):

services/api/app/locales/config_service.py
"""Servicio de configuración por local: la unidad de negocio es UNA transacción.

`actualizar_config` cambia la config del local Y registra un Aviso de auditoría.
O entran los dos cambios, o ninguno: `async with session.begin()` abre la
transacción y hace commit al salir; si algo lanza dentro, hace rollback.
"""

from datetime import UTC, datetime
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models import Aviso, LocalConfig
from .errors import DiasAnticipacionInvalidos

MAX_DIAS_ANTICIPACION = 365


async def obtener_config(session: AsyncSession, local_id: int) -> LocalConfig | None:
    return await session.get(LocalConfig, local_id)


async def actualizar_config(
    session: AsyncSession,
    local_id: int,
    *,
    permite_reserva_online: bool,
    max_dias_anticipacion: int,
) -> LocalConfig:
    """Crea o actualiza la config y deja un Aviso, atómicamente."""
    async with session.begin():  # una transacción por unidad de trabajo
        config = await session.get(LocalConfig, local_id)
        if config is None:
            config = LocalConfig(local_id=local_id)
            session.add(config)
        config.permite_reserva_online = permite_reserva_online
        config.max_dias_anticipacion = max_dias_anticipacion
        config.updated_at = datetime.now(UTC)
        await session.flush()  # el INSERT/UPDATE ya viajó a la BD (aún sin commit)

        # Validación de negocio DESPUÉS del flush a propósito: demuestra que si
        # falla aquí, el rollback también deshace lo de arriba.
        if not 0 <= max_dias_anticipacion <= MAX_DIAS_ANTICIPACION:
            raise DiasAnticipacionInvalidos(
                f"max_dias_anticipacion={max_dias_anticipacion} fuera de 0..{MAX_DIAS_ANTICIPACION}"
            )

        session.add(Aviso(local_id=local_id, tipo="config_actualizada"))
    return config

El test que le da sentido no es el del camino feliz, es el del camino roto: cuando la validación falla, la base tiene que quedar exactamente como estaba.

services/api/app/locales/tests/test_config_service.py
"""Tests del servicio: la transacción no deja la BD a medias."""

from services.api.app.locales import config_service as svc
from services.api.app.locales.errors import DiasAnticipacionInvalidos
from shared.models import Aviso, LocalConfig
# … + pytest, func/select

pytestmark = pytest.mark.unit


async def _cuenta(session, modelo) -> int:
    return (await session.execute(select(func.count()).select_from(modelo))).scalar_one()


async def test_actualizar_crea_config_y_aviso_juntos(sqlite_session):
    config = await svc.actualizar_config(
        sqlite_session, 11, permite_reserva_online=True, max_dias_anticipacion=30
    )
    assert config.local_id == 11
    assert await _cuenta(sqlite_session, LocalConfig) == 1
    assert await _cuenta(sqlite_session, Aviso) == 1


# … y test_actualizar_existente_no_duplica: dos llamadas seguidas dejan
# 1 fila en local_config y 2 en aviso (un aviso por cambio).


@pytest.mark.parametrize("dias", [-1, 366, 10_000])
async def test_dias_invalidos_no_deja_la_bd_a_medias(sqlite_session, dias):
    with pytest.raises(DiasAnticipacionInvalidos):
        await svc.actualizar_config(
            sqlite_session, 873, permite_reserva_online=True, max_dias_anticipacion=dias
        )
    # el flush ya había viajado a la BD; el rollback lo deshizo: ni config ni aviso
    assert await _cuenta(sqlite_session, LocalConfig) == 0
    assert await _cuenta(sqlite_session, Aviso) == 0

Para creerte que ese test vale, rómpelo. Reemplaza el async with session.begin(): por lo que sale natural cuando uno viene de guardar registro a registro:

config_service.py (versión mala, temporal)
    config = await session.get(LocalConfig, local_id)
    …
    await session.commit()          # <- commit #1: la config ya quedó grabada

    if not 0 <= max_dias_anticipacion <= MAX_DIAS_ANTICIPACION:
        raise DiasAnticipacionInvalidos(...)

    session.add(Aviso(local_id=local_id, tipo="config_actualizada"))
    await session.commit()          # <- commit #2: nunca se llega si lo de arriba lanza
    return config
pytest -m "not integration" -q
Deberías ver
________________ test_dias_invalidos_no_deja_la_bd_a_medias[-1] ________________
services/api/app/locales/tests/test_config_service.py:48: in test_dias_invalidos_no_deja_la_bd_a_medias
    assert await _cuenta(sqlite_session, LocalConfig) == 0
E   assert 1 == 0
=========================== short test summary info ============================
FAILED …::test_dias_invalidos_no_deja_la_bd_a_medias[-1]
FAILED …::test_dias_invalidos_no_deja_la_bd_a_medias[366]
FAILED …::test_dias_invalidos_no_deja_la_bd_a_medias[10000]
================== 3 failed, 2 passed, 2 deselected in 0.16s ===================

Restaura el async with session.begin(): y vuelve al verde.

assert 1 == 0: la config quedó grabada y su aviso no existe. Ese es el estado a medias, y en producción no se ve nunca — se ve tres semanas después, cuando alguien pregunta por qué hay locales con configuración cambiada y sin rastro de quién la cambió. Dos detalles del código bueno: flush() manda el INSERT a la base pero no hace commit (por eso la validación va después, para probar que el rollback lo alcanza), y session.begin() como context manager hace commit al salir del bloque y rollback si sale por una excepción. Es el try/commit/except/rollback que en PHP escribías a mano, con la ventaja de que no se te puede olvidar el rollback.

Paso 8 Tests unit con SQLite, tests integration con Postgres

Los tests del paso 7 no necesitan Docker: corren sobre una SQLite en memoria creada desde los mismos modelos. Pero hay cosas que solo Postgres puede contestar. Dos fixtures, una por nivel:

services/api/app/locales/tests/conftest.py
"""Fixtures de sesión: SQLite en memoria (unit) y Postgres del compose (integration)."""

from shared import models  # noqa: F401 — registra las tablas
# … + os, pytest, AsyncSession/async_sessionmaker/create_async_engine, SQLModel


@pytest.fixture()
async def sqlite_session() -> AsyncGenerator[AsyncSession, None]:
    """Una BD nueva por test: rápida, sin Docker. Para tests unit del servicio."""
    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
    async with engine.begin() as conn:
        await conn.run_sync(SQLModel.metadata.create_all)
    maker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
    async with maker() as session:
        yield session
    await engine.dispose()


@pytest.fixture()
async def pg_session() -> AsyncGenerator[AsyncSession, None]:
    """Postgres real del compose (marca integration). Limpia las tablas al terminar."""
    url = os.getenv("DB_URL")
    if not url or not url.startswith("postgresql+asyncpg://"):
        pytest.skip("DB_URL de Postgres no configurada")
    engine = create_async_engine(url)

    async def _limpiar() -> None:
        async with engine.begin() as conn:
            for table in ("aviso", "local_config"):
                await conn.exec_driver_sql(f"DELETE FROM {table}")  # tablas del código, no input

    await _limpiar()  # un test no hereda filas de nadie (ni de tus inserts a mano)
    maker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
    async with maker() as session:
        yield session
    await _limpiar()
    await engine.dispose()

Declara la marca nueva en pytest.ini, junto a las de la práctica 4:

pytest.ini (en markers)
    integration: Tests against real local services (Postgres del compose).

Y ahora el test que justifica todo el nivel integration: el CHECK que agregaste a mano en la migración 0001 no existe en SQLite, porque SQLite se crea desde SQLModel.metadata y la restricción vive en la migración.

services/api/app/locales/tests/test_config_service_pg.py
"""Lo mismo contra Postgres real: aquí además existe el CHECK de la migración."""

from sqlalchemy.exc import IntegrityError
# … + pytest, func/select, config_service as svc, Aviso/LocalConfig

pytestmark = pytest.mark.integration


async def test_actualizar_en_postgres(pg_session):
    await svc.actualizar_config(
        pg_session, 2246, permite_reserva_online=True, max_dias_anticipacion=45
    )
    total = (await pg_session.execute(select(func.count()).select_from(LocalConfig))).scalar_one()
    assert total == 1


async def test_check_constraint_lo_garantiza_la_bd(pg_session):
    """Aunque el código no validara, Postgres rechaza -5 por el CHECK de la migración 0001."""
    pg_session.add(LocalConfig(local_id=999, max_dias_anticipacion=-5))
    with pytest.raises(IntegrityError):
        await pg_session.commit()
    await pg_session.rollback()
    total = (await pg_session.execute(select(func.count()).select_from(Aviso))).scalar_one()
    assert total == 0
make tests                # unit: SQLite en memoria, sin Docker
make tests-integration    # integration: contra el Postgres del compose
make lint
Deberías ver
…/test_config_service.py::test_actualizar_crea_config_y_aviso_juntos PASSED [ 20%]
…/test_config_service.py::test_actualizar_existente_no_duplica PASSED [ 40%]
…/test_config_service.py::test_dias_invalidos_no_deja_la_bd_a_medias[-1] PASSED [ 60%]
…                                                                       [100%]
======================= 5 passed, 2 deselected in 0.14s ========================

…/test_config_service_pg.py::test_actualizar_en_postgres PASSED [ 50%]
…/test_config_service_pg.py::test_check_constraint_lo_garantiza_la_bd PASSED [100%]
======================= 2 passed, 5 deselected in 0.20s ========================

All checks passed!
15 files already formatted

Compruébalo tú: pide el DDL que SQLModel generó para SQLite y mete el -5 que Postgres rechaza.

Deberías ver
CREATE TABLE local_config (
	local_id INTEGER NOT NULL,
	permite_reserva_online BOOLEAN NOT NULL,
	max_dias_anticipacion INTEGER NOT NULL,
	zona_horaria_override VARCHAR(64),
	updated_at DATETIME NOT NULL,
	PRIMARY KEY (local_id)
)
SQLite aceptó -5: el CHECK vive solo en la migración
Qué atrapa cada nivel. SQLite en memoria es una base nueva por test, arranca en milisegundos y no depende de Docker: perfecta para la lógica del servicio (¿la transacción revierte? ¿se duplica la fila?). Pero es una base distinta, creada desde los modelos y no desde tus migraciones: no tiene el CHECK, guarda DATETIME en vez de timestamp with time zone y no conoce ni jsonb ni ON CONFLICT. Todo lo que dependa del motor real o del esquema que aplica alembic va marcado integration.
git add -A
git commit -m "feat(db): postgres propio con SQLModel y alembic; config_service transaccional"

Entiende el código: qué de esto es igual en m-b-core

Lo que hicisteDónde está en m-b-coreDiferencia
shared/db.py: engine perezoso, async_sessionmaker, db_session()shared/db.py (_ensure_engine(), Engine is created lazily on first use when DB_URL is set, so the app can start without a database)Allí el pool está afinado para PgBouncer (AsyncAdaptedQueuePool, pool_size=5, max_overflow=10, pool_recycle=1800) y pasa application_name en connect_args. El echo=settings.ENV == "staging" es idéntico.
alembic/env.py async con async_engine_from_config + run_sync, y shared/models.py como fuente de verdadalembic/env.py (run_migrations_online, do_run_migrations, from shared.models import * # noqa)Allí además detecta Cloud SQL Proxy por el hostname y decide el SSL (ssl="require" en staging/prod, ssl=False tras el proxy) y sube el timeout de asyncpg a 60 s. El import de modelos es un *; el nuestro es explícito para que se vea qué registra.
Máscara con make_url(...).render_as_string(hide_password=True)alembic/env.py:47 y alembic/env.py:99{DB_URL.split('@')[0]}@***:***@***Aquí no calcamos: corregimos. El original imprime usuario:contraseña en claro y tapa el host. Corre en cada deploy, así que la credencial queda en los logs de Cloud Build. Es un arreglo de dos líneas y un buen primer PR.
alembic/versions/0001_…, 0002_… con --rev-id hablado, y alembic upgrade head antes de arrancar los serviciosalembic/versions/ (0001_create_legacy_schema.py0008_legacy_replica_last_id.py) y CLAUDE.md § Running (docker compose run --rm api alembic upgrade head)Igual: id numerado + descripción, un solo linaje sin ramas. En staging/prod ese mismo upgrade corre en el paso previo del deploy, contra Cloud SQL a través del proxy.
SQL crudo para la legacy, ORM para lo nuestro; marca integration separada de unitCLAUDE.md guideline 10 (Queries are raw SQL via SQLAlchemy text()), pytest.ini, services/api/TESTING.mdIgual. legacy_db_read para tablas que no controlamos, SQLModel + alembic para el esquema propio; allí los integration corren dentro del contenedor api del compose.

Si vienes de Laravel

Casi todo tiene un equivalente exacto; lo que cambia es quién escribe la migración y dónde vive el estado.

Laravel / EloquentAquí (SQLModel + alembic)Ojo con
class LocalConfig extends Model con $table, $fillable, $castsclass LocalConfig(SQLModel, table=True) con __tablename__ y campos tipadosEl tipo Python es el cast y es la validación: no hay $casts aparte.
php artisan make:migration → archivo vacío que escribes túalembic revision --autogenerate → archivo con el diff modelos↔base ya escritoQue venga escrito no significa que esté bien. Se lee siempre (paso 5).
Schema::create(…) · php artisan migrate / migrate:rollbackop.create_table(…) · alembic upgrade head / alembic downgrade -1Mismo DDL, otra sintaxis: op. es el Schema::. head es "la última revisión"; también puedes ir a un id concreto.
Tabla migrations con batch; el orden es la marca de tiempo del nombreTabla alembic_version con una sola fila; el orden lo da down_revision en cada archivoEs un grafo, no una lista: dos ramas paralelas dan Multiple head revisions y se arreglan con alembic merge.
DB::transaction(function () {…}) · LocalConfig::find(11) · $c->save()async with session.begin(): · await session.get(LocalConfig, 11) · session.add(c)Eloquent es Active Record (el objeto se guarda solo); SQLAlchemy es Unit of Work: la sesión junta los cambios y los manda en el flush.
.env con DB_HOST, DB_DATABASE… leído por config/database.phpenv/local.env con un único DB_URL, leído solo por shared/settings.pyUn solo módulo lee el entorno. Nada de os.getenv repartido por el código.

Para leer más: alembic · autogenerate (qué detecta y qué no), alembic · asyncio, SQLAlchemy 2 · sesiones y transacciones, engines y URLs, SQLModel · crear tablas, pytest · marcas. Y de Python base: context managers (los async with), decoradores (@asynccontextmanager, @pytest.fixture), clases, alcance (el global _engine), SQLite desde Python.

Con Claude

Con migraciones el riesgo cambia de forma: aquí un error no rompe un test, borra una columna en producción. Y Claude es especialmente convincente escribiendo migraciones, porque el archivo parece correcto. Dale el contexto que le falta —qué base, qué revisión es la actual, qué no puede tocar— y pídele siempre el downgrade junto con el upgrade.

Así noAgrega un campo de zona horaria a la configuración del local.Va a editar el modelo y, con suerte, correr autogenerate. Con mala suerte te escribe la migración a mano con un nullable=False sin default, que en una tabla con filas falla — o peor, pasa en tu laptop vacía y revienta en staging.
Así síAgrega zona_horaria_override: str | None (max 64) a LocalConfig en shared/models.py. Después genera la migración con alembic revision --autogenerate -m "local_config.zona_horaria_override" --rev-id 0002_zona_horaria_override y pégame el archivo generado sin aplicarlo. La tabla ya tiene filas en staging: la columna debe ser nullable=True y sin server_default. Dime qué hace el downgrade y qué datos se pierden si se ejecuta.Le diste el tipo, el id de revisión, la restricción operativa (hay filas) y le pediste que te explique el vuelta atrás antes de aplicar nada.
Así noLa migración falla, arréglala. / Escribe un test para actualizar_config.Lo primero suele terminar en stamp head o en borrar alembic_version: la base queda desincronizada y el siguiente que corra upgrade no se entera. Lo segundo te da el camino feliz, que pasa igual con dos commit() sueltos y sin transacción.
Así síalembic upgrade head falla con este traceback: [pegar]. Antes de proponer nada dime en qué revisión está la base (alembic current), cuál intenta aplicar y qué operación exacta rompe; no propongas stamp ni borrar alembic_version. Y agrega un test unit con la fixture sqlite_session que llame a actualizar_config con max_dias_anticipacion inválido (parametriza -1, 366, 10000), espere DiasAnticipacionInvalidos y afirme 0 filas en local_config y en aviso: tiene que fallar si alguien reemplaza el session.begin() por dos commit().Primero el diagnóstico, prohibido el atajo que esconde el problema, y el test descrito por el bug que debe atrapar — el criterio del TESTING.md de m-b-core.

Si algo falla

Bind for 0.0.0.0:5445 failed: port is already allocated, o peor: tu base desaparece cuando levantas otra práctica

Dos problemas con la misma raíz. El fácil: alguien ya usa ese puerto del host. Se arregla cambiando el puerto en docker-compose.yml y en env/local.env — los dos, o conectarás a la nada.

Lo que sale
Error response from daemon: failed to set up container networking: driver failed programming
external connectivity on endpoint p5-mesa-postgres-1: Bind for 0.0.0.0:5445 failed: port is already allocated

El feo: como el archivo dice name: mesa-core-practicas, todas las copias del repo comparten stack. Desde otra carpeta con el mismo compose, docker compose ps te muestra el contenedor de la otra copia como si fuera tuyo, y un down -v ahí borra el volumen de la práctica que estabas usando. Antes de cualquier down -v, mira de qué proyecto hablas con docker compose config | head -1; si vas a tener varias prácticas vivas, dale a cada una su name:, su puerto, o levántala con docker compose -p p5-mesa up -d.

RuntimeError: DB_URL requerido para alembic o ValueError: DB_URL no está configurada

El Makefile exporta PYTHONPATH, pero no carga env/local.env: eso es cosa tuya en cada terminal nueva (set -a && . ./env/local.env && set +a). Que falle fuerte y temprano es a propósito: la alternativa —un default tipo localhost/postgres— es lo que hace que un día apliques migraciones contra la base equivocada sin enterarte.

alembic revision --autogenerate genera op.drop_table(...) de TODAS tus tablas

La trampa más peligrosa de la práctica, y la más silenciosa: si env.py no importa shared.models, SQLModel.metadata está vacío. alembic compara "nada" contra tu base y concluye, correctísimamente, que sobra todo:

Lo que sale
INFO  [alembic.autogenerate.compare] Detected removed table 'aviso'
Generating …/alembic/versions/d89ce63f2935.py ...  done

def upgrade() -> None:
    op.drop_table('local_config')
    op.drop_index('ix_aviso_local_id', table_name='aviso')
    op.drop_table('aviso')

El import tiene que estar y tiene que sobrevivir al linter: from shared import models # noqa: F401, con "alembic/env.py" = ["F401"] en [tool.ruff.lint.per-file-ignores]. Comprueba antes de generar nada que SQLModel.metadata.tables lista tus tablas (paso 3). Y la regla que te salva igual: lee siempre el archivo generado antes de aplicarlo.

MissingGreenlet: greenlet_spawn has not been called o 'AsyncTransaction' object has no attribute '__exit__'

Los dos son el mismo malentendido: mezclar sync con async. El primero sale si creas el engine con create_engine() sobre una URL postgresql+asyncpg://; el segundo, si le pasas la AsyncConnection directo a alembic saltándote el run_sync.

Lo que sale
sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here.
Was IO attempted in an unexpected place? (Background on this error at: https://sqlalche.me/e/20/xd2s)

  File "alembic/env.py", line 49, in do_run_migrations
    with context.begin_transaction():
AttributeError: 'AsyncTransaction' object has no attribute '__exit__'. Did you mean: '__aexit__'?

alembic por dentro es síncrono: no sabe hacer await. Usa async_engine_from_config(...) (lo que trae la plantilla -t async) y deja el await connection.run_sync(do_run_migrations), que es el puente: corre código sync sobre una conexión async, en el greenlet correcto. Por eso do_run_migrations se tipa como Connection y no como AsyncConnection.

alembic revision --autogenerate crea un archivo con pass y no sé si eso está bien

Está bien: significa que la base ya coincide con los modelos. Pero alembic siempre crea el archivo, aunque venga vacío (def upgrade(): pass). Si te sale así, bórralo: una revisión vacía alarga el linaje sin decir nada. El INFO [alembic.ddl.postgresql] Detected sequence named 'aviso_id_seq' … assuming SERIAL and omitting que aparece en cada autogenerate tampoco es un problema: alembic reconoce la secuencia del id como parte del SERIAL y la deja fuera del diff.

Los tests integration fallan con Connect call failed, o se saltan sin decir por qué

Con el compose apagado (o el puerto equivocado en DB_URL) sale el error de conexión de asyncpg, con las dos familias de direcciones:

Lo que sale
E   OSError: Multiple exceptions: [Errno 61] Connect call failed ('::1', 5440, 0, 0),
    [Errno 61] Connect call failed ('127.0.0.1', 5440)
======================= 5 deselected, 2 errors in 0.38s ========================

docker compose ps tiene que decir (healthy), no solo Up. Si en cambio ves 2 skipped, es el pytest.skip de pg_session: falta DB_URL, o no empieza con postgresql+asyncpg://. Ese skip es deliberado para que make tests funcione sin Docker, pero no confundas skipped con passed. Y si un test pasa solo pero falla en la suite, es estado compartido: el Postgres del compose es el mismo para todos, y por eso pg_session limpia aviso y local_config antes y después de cada test.

Listo cuando

  • docker compose ps muestra postgres:16 en (healthy), y alembic current responde 0002_zona_horaria_override (head).
  • La contraseña no está en alembic.ini ni en ningún .py: sale de env/local.env, ignorado por git.
  • Tu env.py imprime los asteriscos sobre la contraseña y no sobre el host, y sabes explicar por qué el split('@') de m-b-core no sirve.
  • Leíste las dos migraciones línea por línea y le agregaste a mano a la 0001 algo que --autogenerate no podía saber.
  • Hiciste downgrade -1 y upgrade head con datos dentro, y viste que la columna vuelve y el valor no.
  • Rompiste la transacción con dos commit(), viste fallar los tres tests parametrizados, y restauraste el session.begin().
  • make tests da 5 en verde sin Docker, make tests-integration da 2 contra Postgres, y make lint está limpio.

Siguiente

Ya tienes dónde guardar lo tuyo, y la columna zona_horaria_override esperando. En la Práctica 6 se usa: escribes tz_for(zona_horaria, pais) y now_local(...), y reproduces en un test el bug de las 19:00 —el que en producción hace que desde esa hora un Carbon::now() en UTC se vaya al día siguiente y las reservas de hoy aparezcan mañana—.