PostgreSQL · Python · Salud digital
Migrar una historia clínica de Supabase a PostgreSQL sin reescribir el backend
ClinIA es una historia clínica electrónica multi-consultorio con backend en FastAPI. Empezó sobre Supabase, que resolvía rápido la autenticación y el acceso a datos. Con el tiempo, esa comodidad se volvió un riesgo: el esquema y las políticas quedaban atados a un proveedor, y los datos clínicos tienden a acabar en infraestructura propia por requisito, no por gusto.
La decisión de salir estaba clara. El problema era cómo hacerlo.
El tamaño real del cambio
El backend hablaba con Supabase a través de su cliente de Python, con la API fluida de siempre:
supabase.table("patients").select("*").eq("id", paciente_id).execute()
Hoy el backend tiene 153 llamadas de ese tipo repartidas en 21 archivos. Reescribirlas todas a SQLAlchemy o a SQL plano de una vez era un cambio grande, imposible de probar por partes y con un riesgo alto de romper algo en un sistema con datos de pacientes.
La alternativa: un adaptador con la misma forma
En vez de cambiar las 153 llamadas, escribí un cliente que tiene la misma forma que el de Supabase pero habla con PostgreSQL. Son 223 líneas sobre SQLAlchemy:
table(nombre)refleja la tabla desde la propia base (autoload_with), así que no hay modelos duplicados que mantener.select,insert,updateydelete, con los filtros que el código usaba de verdad:eq,neq,gt,lt,gte,lte,like,ilike,is_,in_, másorder,limit,rangey el conteo exacto.execute()devuelve un objeto con.datay.count, igual que el original, con fechas, UUID y decimales convertidos a JSON.
Implementar solo lo que se usa, y fallar fuerte con lo demás
La tentación es imitar toda la API de PostgREST. No hace falta. Por ejemplo, Supabase permite traer filas relacionadas con select("*, users(*)"). ClinIA solo lo usaba para el usuario, así que el adaptador resuelve exactamente ese caso con una segunda consulta.
Lo mismo con or_(): el código usaba una única expresión, la de las conversaciones entre dos usuarios. El adaptador reconoce esa forma y, ante cualquier otra, lanza un error en lugar de adivinar:
if not match:
raise ValueError("Unsupported OR expression")
Un error ruidoso en desarrollo es mucho mejor que una consulta que devuelve datos equivocados en una historia clínica.
Los nombres de tabla se validan con una expresión regular y las columnas se comprueban contra la tabla reflejada antes de construir la consulta. Así nadie puede colar texto arbitrario por ahí.
Un interruptor, no un salto
La configuración elige el cliente según el entorno:
if DATABASE_URL:
supabase = PostgresClient(DATABASE_URL)
elif SUPABASE_URL and SUPABASE_SERVICE_KEY:
supabase = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY)
La variable se sigue llamando supabase a propósito: ningún archivo de rutas cambió. El entorno de pruebas arrancó sobre PostgreSQL mientras producción seguía en Supabase, hasta que la réplica pasó las pruebas de aceptación. Después, producción cambió de variable de entorno, no de código.
Lo que el adaptador no cubre
Supabase no es solo una base de datos. El adaptador no implementa supabase.storage, así que cualquier código nuevo que suba archivos tiene que ir por otro camino. Quedaron también variables REACT_APP_SUPABASE_* en el frontend que ya no se usan. Ambas cosas están escritas en la documentación de despliegue, porque son justo las trampas que le cuestan una tarde a quien llega después.
Lo que me llevo
- Cuenta antes de decidir. Saber cuántas llamadas hay y en cuántos archivos convierte una discusión abstracta en un número.
- Copia la interfaz, no el producto. El adaptador imita la forma de las llamadas, no todo lo que Supabase sabe hacer.
- Falla fuerte ante lo desconocido. Sobre todo con datos clínicos.
- Cambia por configuración. Poder volver atrás con una variable de entorno es lo que hace que una migración sea tranquila.
El código de ClinIA, sin datos de pacientes, está en GitHub, y el resto del sistema en la página del proyecto.