El bug de las mayúsculas que hacía desaparecer "Continuar viendo"
Móvil y TV escribían "MOVIE", la web escribía "movie", y era la misma columna. Sobre por qué un bug que falla a medias es peor que uno que revienta.
Empiezas una peli en el portátil, la dejas a la mitad, enciendes la tele. Y no está en "Continuar viendo".
Pero entras al catálogo, buscas la peli, y ahí está su ficha con la barra de progreso a la mitad. La tele sabe perfectamente por dónde ibas. Simplemente no te lo ofrece.
Ese fue el bug. Y lo que lo hizo caro no fue lo que costaba arreglarlo —lo arregla una llamada a uppercase()— sino lo que costó creérselo.
Una columna, dos plataformas, dos idiomas
En LoverCast el estado del usuario se guarda local en el dispositivo y se sincroniza a la nube. "Continuar viendo" vive en una columna llamada watch_progress, y cada ítem de esa lista lleva un campo type que dice si es una película o una serie.
Móvil y TV son Kotlin, y tienen un enum:
enum class WatchType { MOVIE, SERIES }
Cuando serializan un ítem para subirlo, usan lo que usa todo el mundo: WatchType.name. Eso produce "MOVIE" y "SERIES", en mayúscula, porque así se llaman las constantes del enum.
La web es TypeScript, y ahí el tipo se escribe como se escribe en TypeScript:
type: "movie" | "series"
Minúscula. Las dos decisiones son correctas por separado. Cada una es lo idiomático en su lenguaje, y nadie hizo nada raro. El problema es que las dos escriben en la misma columna, y al leerla cada plataforma comparaba contra su propia forma.
Resultado: los ítems de la web no salían en "Continuar viendo" del móvil y de la tele. Y los del móvil y la tele no salían en la categoría equivalente de la web. Cada plataforma veía lo suyo y descartaba lo del otro, en silencio.
Por qué fallaba a medias, que es lo peor que puede pasar
Aquí está lo que convirtió un bug de una línea en una tarde de buscar donde no era.
La barra de progreso sí salía. Y salía porque no va por la misma columna. El progreso por ítem vive aparte, en movie_progress, y esa fila no tiene campo type en absoluto:
data class RemoteMovieProgressData(
@SerialName("movie_id") val movieId: String,
@SerialName("progress_ms") val progressMs: Long = 0L,
@SerialName("duration_ms") val durationMs: Long = 0L,
@SerialName("manually_completed") val manuallyCompleted: Boolean = false,
)
Va indexada por movie_id y ya está. No hay nada que interpretar mal, así que no se interpretaba mal. La mitad del sistema que no dependía del acuerdo funcionaba perfectamente.
Piensa en lo que eso hace con el diagnóstico. Si la peli hubiera desaparecido del todo —sin barra, sin progreso, sin rastro— la hipótesis es inmediata: no ha sincronizado. Miras el sync, ves que sí llegó, y en diez minutos estás mirando el campo type.
Pero como el progreso sí estaba, la hipótesis que se te ocurre primero es la contraria: la sincronización funciona, luego el fallo está en la pantalla de inicio. Y te vas a mirar el ViewModel de "Continuar viendo", que es donde no está el problema.
Un fallo total es un cartel. Un fallo parcial es una pista falsa: te demuestra que la parte que sospechas funciona, y te manda lejos.
La corrección: tolerar en los dos sentidos
Lo obvio sería elegir un formato y hacer que todos escriban en él. Y no se hizo, por una razón muy concreta: hay APKs instalados que van a seguir escribiendo "MOVIE" durante meses, y ya hay datos escritos en las dos formas en las filas de gente real. Puedes cambiar lo que escribes a partir de hoy. No puedes cambiar lo que ya está escrito ni lo que escribe una app que alguien no ha actualizado.
Así que la regla que se fijó es tolerar en ambos sentidos. En Kotlin, normalizar al leer de la nube:
// La web escribe el tipo en minúscula ("movie"/"series"); móvil/TV en mayúscula
// (WatchType.name). Normalizamos antes de valueOf para no descartar los ítems de la web.
val watchType = runCatching { WatchType.valueOf(type.uppercase()) }.getOrNull() ?: return null
Y en la web, normalizar al ingerir, no al pintar:
const normalizeType = (x: RemoteWatchProgress): RemoteWatchProgress =>
x.type === "movie" || x.type === "series" ? x : { ...x, type: x.type.toLowerCase() };
El detalle que importa de la versión web es dónde está puesta. Normaliza al entrar el dato, no en cada sitio que lo consume. Si normalizas al pintar, tienes que acordarte de hacerlo en el filtro de Series, en el de Películas, en la conversión a reproducible y en los tres sitios que se escriban el año que viene. Uno se te olvida. Normalizando en la frontera, el resto del código no sabe que el problema existió nunca.
Eso es lo que separa una corrección de un parche: el parche arregla el síntoma en el sitio donde se ve, la corrección lo arregla en el sitio donde entra.
Lo que la tolerancia esconde
Ser tolerante al leer tiene un coste que conviene decir en voz alta, porque es fácil quedarse con la moraleja bonita.
Fíjate en el ?: return null de la versión Kotlin. Si algún día llega un type que no sea ninguno de los dos —una plataforma nueva, un typo, un formato que a alguien le pareció buena idea—, ese ítem se descarta sin ruido. Exactamente el mismo comportamiento que causó el bug original, solo que ahora para un caso que todavía no ha pasado.
Es una decisión defendible: descartar un ítem raro es mejor que reventarle la pantalla de inicio a alguien por un dato malo. Pero es una decisión, no una solución. Y la tolerancia tiene la mala costumbre de tapar el problema lo bastante bien como para que nadie lo arregle de verdad.
Porque el arreglo de verdad no es normalizar mejor. Es que ese campo no debería ser un String libre viajando entre tres plataformas:
data class RemoteWatchProgressData(
val id: String,
val type: String, // ← aquí cabe cualquier cosa
...
)
Cabe "MOVIE", cabe "movie", cabe "Movie" y cabe "pelicula". El sistema de tipos de Kotlin y el de TypeScript son estupendos los dos, y ninguno de los dos te ayuda aquí, porque el acuerdo no vive en ninguno de los dos lenguajes: vive en el hueco entre ellos.
El enemigo no era el bug
Y ese es el fondo del asunto, más allá de LoverCast.
El acuerdo sobre cómo se serializa type existía. Estaba en la cabeza de quien escribió el cliente Android y en la cabeza de quien escribió la web, y en las dos cabezas era distinto, y las dos tenían razón según su propio criterio. Un acuerdo de palabra, sin un sitio que lo defina.
Los sitios donde esto pasa se reconocen fácil una vez has visto uno: una columna JSON que escriben varios clientes, un campo de una API sin esquema compartido, un fichero de configuración que lee un servicio y escribe otro, cualquier String que en realidad es un enum disfrazado. En todos ellos el compilador está tranquilo, los tests de cada lado pasan, y el contrato solo se comprueba en producción con datos de alguien.
La contramedida no es acordarse mejor. Es tener un único sitio que defina la forma, y que ese sitio genere o valide lo que hacen los demás: un esquema compartido, un contract test que escriba con un cliente y lea con otro, o —lo más barato de todo, si lo demás no cabe— una constraint en la base de datos que rechace lo que no sea una de las dos formas. Cualquier cosa que convierta "nos entendimos" en algo que falle solo.
En LoverCast, de momento, la contramedida es más humilde: las reglas de coherencia de "Continuar viendo" y "Ya la he visto" están escritas como invariantes numeradas en SYNC.md, fijadas en código tras una tanda de bugs de este estilo, con la instrucción de mantenerlas en cualquier cambio futuro. Es documentación, no un test. No se ejecuta. Vale menos que un contract test y lo sabemos — pero convierte un acuerdo tácito en uno escrito, que es el primer escalón y costó una tarde.
Lo que nos llevamos
Un bug parcial es más caro que uno total. El fallo que deja media funcionalidad en pie no solo esconde el problema: te entrega una prueba de que la capa culpable funciona. Cuando algo falle "solo un poco", desconfía justo de lo que el síntoma parece descartar.
Normaliza en la frontera, no en el consumidor. Si el dato entra por un sitio, arréglalo ahí. Normalizar donde se pinta es firmar el compromiso de acordarte para siempre, en todos los sitios, incluidos los que aún no existen.
Un String que solo admite dos valores es un enum sin la parte que sirve. El acuerdo entre plataformas no vive en el lenguaje de ninguna de ellas, y por eso ningún compilador te lo va a defender. Si el contrato no está en un sitio que falle solo, no es un contrato: es que os acordáis los dos, de momento.
Nada de esto se ve en la app. Enciendes la tele y la peli que dejaste en el portátil está la primera. Que es donde tenía que haber estado siempre.
LoverCast está en producción y puedes descargarla. Si tienes un contrato entre plataformas que solo se comprueba en producción, cuéntanoslo.