Saltar al contenido
Plazoleta
· Raúl López

Migrar el esquema sin romper las apps que ya están instaladas

Pasar de una fila por usuario a una por perfil cuando hay APKs ahí fuera que no puedes actualizar, y por qué la vista de compatibilidad te compra menos de lo que crees.

Un usuario nos pidió lo de siempre, que suena a nada: "¿puedo tener mi perfil y el de mi hija separados?".

Netflix lleva la pantalla de "¿Quién está viendo?" desde hace años. Es un selector con dos avatares. Media tarde de trabajo.

Y por debajo es cambiarle la clave primaria a la tabla donde vive todo el estado del usuario, con las apps ya instaladas en la tele del salón de gente que no te conoce.

Lo que había, y por qué no valía

En LoverCast el estado del usuario —favoritos, continuar viendo, orden de canales, categorías ocultas, el proveedor IPTV— se guarda local en el dispositivo y se sincroniza a una tabla en la nube: user_preferences, una fila por usuario. La clave primaria era el user_id. Un usuario, una fila, todos sus datos.

Los perfiles rompen eso de la forma más básica posible: si tu hija y tú tenéis "Continuar viendo" distintos, ya no cabe una fila por usuario. Hace falta una fila por perfil. La PK pasa de user_id a profile_id.

En la web esto es una tarde. Cambias el esquema, despliegas, y el navegador del usuario se traga la versión nueva la próxima vez que entra. Nadie usa la web de ayer.

En Android no existe eso. El APK que alguien instaló hace ocho meses sigue ahí, encendiéndose cada noche, hablando con tu base de datos con el código de hace ocho meses. Puedes publicar una versión nueva. No puedes obligar a nadie a instalarla.

Por qué el caso fácil engaña

La trampa está en que el cambio no rompe nada hasta que alguien lo usa.

Añades la tabla profiles, añades una columna profile_id a user_preferences, rellenas el perfil "Principal" de cada usuario. Despliegas. Todo sigue funcionando. Los clientes viejos ni se enteran: sigue habiendo exactamente una fila por usuario, y una columna de más nunca le ha hecho daño a nadie.

Esa es la migración 0012, y es aditiva y reversible a propósito. No cambia la PK, no pone la columna NOT NULL, no toca ningún valor existente. Es la fase expand del clásico expand-contract: primero añades lo nuevo sin quitar lo viejo, y te quedas viviendo en la ambigüedad todo el tiempo que haga falta.

La bomba explota el día que alguien crea su segundo perfil. Ahí aparece la segunda fila con el mismo user_id, y el APK de hace ocho meses —que pide sus preferencias asumiendo que le va a llegar una fila y solo una— se encuentra dos. Rompe. Y rompe en el dispositivo de alguien que no ha hecho nada raro: simplemente no actualizó.

O sea que el problema real no es cambiar la PK. Es que la app vieja tiene una suposición grabada a fuego y esa suposición vas a dejar de cumplirla.

La vista de compatibilidad

La salida es bonita y poco conocida: si los clientes viejos preguntan por user_preferences esperando una fila, dales exactamente eso — pero que ya no sea la tabla.

La tabla real pasa a llamarse profile_preferences (una fila por perfil, PK profile_id, que es lo que queríamos). Y user_preferences renace como vista que devuelve solo la fila del perfil por defecto:

-- Renombras la tabla real...
alter table public.user_preferences rename to profile_preferences;

-- ...y el nombre viejo pasa a ser una vista del perfil por defecto de cada usuario.
-- security_invoker: la RLS se aplica con el rol de quien consulta, no con el del dueño.
create or replace view public.user_preferences
  with (security_invoker = true) as
  select pp.*
  from public.profile_preferences pp
  where pp.profile_id = (
    select p.id from public.profiles p
    where p.user_id = pp.user_id and p.is_default
    limit 1
  );

El cliente viejo sigue haciendo select ... from user_preferences, sigue recibiendo una fila, y esa fila es la de su perfil principal. No se entera de nada. Y como es una vista sobre una sola tabla con un WHERE, Postgres la hace auto-actualizable: los UPDATE y los DELETE también atraviesan hasta la tabla real, gratis.

El INSERT no, porque un usuario nuevo entrando con una app vieja no tiene ni perfil que enlazar. Eso lo cubre un trigger INSTEAD OF INSERT que le crea el perfil "Principal" al vuelo y mete la fila donde toca.

Hasta aquí, el plan de manual. Tres migraciones: 0012 expande, 0013 hace el cutover de la PK, 0014 renombra y monta la vista. 0013 y 0014 van juntas en la misma ventana, y no te puedes quedar en medio: 0013 sola ya permite varias filas por usuario, pero sin la vista que protege a los viejos.

Donde estaba la dificultad de verdad

Y ahora la parte por la que escribo esto, porque es la que no sale en los artículos sobre expand-contract.

La vista te compra la lectura. La escritura hay que ir a comprobarla cliente por cliente.

La web vieja escribe bien: hace UPDATE plano, y el UPDATE atraviesa la vista auto-actualizable sin despeinarse.

Los Android y los Android TV viejos no escriben. Nada. Y no por un fallo nuestro:

  • Guardan con un upsert (Prefer: merge-duplicates de PostgREST), que es lo razonable cuando no sabes si la fila del usuario ya existe.
  • PostgREST implementa el upsert con un ON CONFLICT, y ON CONFLICT exige una restricción única sobre la que resolver el conflicto.
  • Una vista no puede tener restricciones únicas. No es que no la tengamos: es que no puede.
  • Resultado: HTTP 400. El cliente viejo lee su perfil principal perfectamente y no guarda ni un favorito.

Read-only. Silencioso hasta que intentas guardar algo.

Hay una segunda pérdida, más fácil de ver venir pero igual de real: el realtime no funciona sobre vistas. La publicación de Postgres lleva tablas, no vistas. Así que los clientes viejos también pierden la sincronización en caliente entre dispositivos —conservan el pull al arrancar, que es lo que había antes de que existiera el realtime, pero la tele deja de enterarse de lo que haces en el móvil hasta que la reinicies.

Súmalo: la vista de compatibilidad, que en el papel era "los viejos no se enteran", en la práctica es "los viejos leen, no escriben y pierden el tiempo real". Sigue siendo muchísimo mejor que romperlos. Pero llamarlo "cero rotura" sería mentir, y esa clase de mentira te la crees tú antes que nadie.

Qué se decidió y qué se descartó

Sabiendo eso, la decisión fue asumir el read-only temporal. LoverCast es un producto pequeño: se actualizan los dispositivos propios, se publica la versión nueva en la store, y los rezagados vuelven a guardar en cuanto actualizan. El coste está acotado y el beneficio —tener perfiles— compensa.

Escribo esto con el plan cerrado y las migraciones escritas, pero antes de aplicarlas a producción. Cuento el diseño y las decisiones, no un resultado: si al ejecutarlo aparece algo que no habíamos visto, será material para otro post.

Lo que se descartó, y queda apuntado por si algún día hay una base de usuarios que lo justifique, es el rediseño dual-table: dejar el perfil por defecto en una tabla real que siga llamándose user_preferences y meter solo los perfiles extra en profile_preferences. Ahí los clientes viejos escriben sin problema, porque están hablando con una tabla de verdad, con sus restricciones únicas y su sitio en la publicación de realtime. Es más complejo y duplica los caminos de escritura. A esta escala no compensa. A otra, sí.

Esa es la decisión honesta: no es que la vista sea la solución correcta, es que es la solución proporcionada al tamaño del problema. Y está escrita en el doc de despliegue, con nombre y apellidos, para que dentro de dos años nadie tenga que reconstruir por qué.

El detalle que hace que el orden importe

Queda un cabo: el cliente nuevo tiene que funcionar contra un backend con la migración aplicada y contra uno sin ella. Porque si alguien actualiza la app antes de que tú hayas tocado la base de datos, la app nueva se pone a pedir una tabla que no existe.

Se resuelve dejando que el cliente elija la tabla él solo:

// Con perfil activo, el backend tiene el cutover → tabla real `profile_preferences`.
// Sin perfil activo, backend legacy → `user_preferences` (que en prod es la vista).
// Así el MISMO build sirve contra un backend con o sin la migración aplicada.
private val tableUrl get() =
    "$supabaseUrl/rest/v1/" + if (activeProfileId() != null) "profile_preferences" else "user_preferences"

Cuatro líneas que valen por una noche de guardia. Un update que llegue antes que la migración no explota: degrada a legacy y sigue funcionando como siempre.

Aun así, el orden de despliegue es esquema primero, apps después. Eso no es negociable y está en la primera línea del doc, en negrita, porque es el tipo de cosa que se te olvida justo el día que vas con prisa.

Lo que nos llevamos

El cliente que no controlas marca el ritmo. No puedes forzar un update, así que tu esquema tiene que aguantar dos versiones de tu propio código hablándole a la vez. Expand-contract no es una ceremonia burocrática: es lo que te permite estar semanas en el medio sin que se note.

Una vista de compatibilidad no es un espejo. Es un objeto distinto que se parece. Lee igual, escribe casi igual, y no hace realtime. Antes de confiarle tus clientes viejos, prueba las escrituras de cada uno, no solo las lecturas — la nuestra se cayó por cómo PostgREST implementa el upsert, tres capas por debajo de donde estábamos mirando.

"Cero rotura" casi nunca es cero. Es "rotura pequeña, conocida y asumida a propósito". La diferencia entre las dos frases es si lo has medido o te lo estás contando. Escribe en el doc de despliegue exactamente qué pierden los clientes viejos y por cuánto tiempo; si te da vergüenza escribirlo, es que la decisión no era tan buena.

Cuando esto llegue a la tele de alguien, no se verá nada. Saldrá "¿Quién está viendo?", elegirá su cara, y ahí estará su serie. Todo lo de arriba habrá pasado por debajo, y esa es exactamente la idea.


LoverCast está en producción y puedes descargarla. Si estás en medio de una migración parecida y no las tienes todas contigo, cuéntanoslo.

¿Tienes un problema parecido?

Cuéntanoslo. Hablas directamente con quien escribe el código.