"No sé" y "está vacío" no son el mismo dato
En LoverCast, ocultar categorías se sincroniza entre dispositivos. Dejar de ocultarlas todas, no. Sobre por qué colapsar la ausencia y el vacío en un solo valor es una puerta de un solo sentido.
Un proveedor de IPTV te trae doscientas categorías y te interesan doce. Así que en el móvil ocultas el resto, dejas la lista limpia, y esa limpieza aparece en la tele: eso sincroniza bien.
Meses después cambias de idea y le das a "mostrar todas". En el móvil vuelven a salir las doscientas. En la tele siguen ocultas.
No hay error, ni un aviso, ni nada en los logs. Y lo más raro: si en vez de mostrarlas todas dejas una sola oculta, entonces sí se propaga. La sincronización funciona para cualquier valor menos uno. El vacío.
Los tres "no sé" del mismo camino
El estado del usuario en LoverCast es local-first: vive en el dispositivo y se sincroniza a una fila de Postgres, una columna por tipo de estado. Al arrancar se hace pull: se baja la fila y se aplica sobre lo local.
Ese pull tiene que responder tres preguntas distintas, y las tres se parecen bastante a "no hay nada":
suspend fun fetchRemote(): RemoteUserPreferences? {
val session = syncSessionStore.getSession() ?: return null
return syncService.loadPreferences(session.accessToken)
}
Uno: no hay sesión, o la nube no contesta. Devuelve null, y el pull entero no aplica nada. Correcto: quedarse sin cobertura no puede parecerse a que te hayan borrado los favoritos.
Dos: la fila existe pero esta columna nunca se ha escrito. Ese usuario nunca ha tocado el historial de búsqueda desde ningún sitio, así que la columna no tiene nada que decir sobre lo que hay en el móvil. No tocar lo local.
Tres: la columna se ha vaciado a propósito. El usuario borró su historial en la tele. Eso sí es una opinión, y hay que propagarla: borrar también en el móvil.
Dos y tres producen exactamente el mismo tamaño de lista — cero — y exigen comportamientos opuestos. Si tu tipo de datos no distingue una de otra, tienes que elegir cuál de los dos casos vas a hacer mal.
Dos repos, dos respuestas, misma carpeta
Lo interesante de LoverCast es que el mismo codebase contiene las dos decisiones, en la misma carpeta y heredando de la misma clase base. Diecinueve repos Synced*, todos con un método applyRemote que aplica la fila descargada sobre el estado local.
Este distingue:
override suspend fun applyRemote(remote: RemoteUserPreferences) {
// null = columna nunca sincronizada → no tocar local.
// [] = set vaciado de verdad → propagar el borrado.
val ids = remote.seriesCompleted ?: return
local.replaceAllLocal(ids.toSet())
}
Y este colapsa:
override suspend fun applyRemote(remote: RemoteUserPreferences) {
if (remote.liveHiddenIds.isNotEmpty()) local.hideAll(remote.liveHiddenIds)
if (remote.vodHiddenIds.isNotEmpty()) local.hideAllVod(remote.vodHiddenIds)
if (remote.seriesHiddenIds.isNotEmpty()) local.hideAllSeries(remote.seriesHiddenIds)
if (remote.liveCategoryOrder.isNotEmpty()) local.setLiveCategoryOrder(remote.liveCategoryOrder)
// …y tres más igual
}
hideAll reemplaza el conjunto local, no le suma. Por eso ocultar funciona, y dejar una oculta funciona, y dejar cero oculta no: la lista llega vacía, isNotEmpty() da falso, y el pull decide que la nube no tenía nada que decir. Lo mismo con el orden de categorías, con los canales ocultos, y con channel_order — que además lo hace por categoría, así que tampoco puedes devolver una categoría a su orden natural.
Fíjate en que el segundo bloque no tiene comentario. No hay una decisión escrita ahí porque nunca se tomó una: isNotEmpty() es lo que escribes cuando la pregunta "¿y si viene vacío?" no se te ha planteado. Es el síntoma más fácil de reconocer de este bug, y no está en la lógica.
El bug estaba en la declaración
Está en el tipo. Estas columnas son la misma clase de dato y no se declaran igual:
data class RemoteUserPreferences(
// Nullable a propósito: null = la columna nunca se sincronizó (no tocar el local
// al hacer pull); [] = lista vaciada de verdad (propagar el borrado).
@SerialName("series_completed") val seriesCompleted: List<String>? = null,
@SerialName("search_history") val searchHistory: List<String>? = null,
@SerialName("live_hidden_ids") val liveHiddenIds: List<String> = emptyList(),
@SerialName("live_category_order") val liveCategoryOrder: List<String> = emptyList(),
)
Arriba, List<String>?: el ?: return que distingue los dos casos es posible porque el tipo tiene un hueco donde ponerlo. Abajo, List<String> = emptyList(): no hay ningún valor de ese tipo que signifique "no sé". Aunque quisieras distinguir, no tienes con qué. isNotEmpty() no es que sea una implementación descuidada — es la única cosa que se puede escribir con ese tipo.
Y ese tipo tampoco se eligió en Kotlin. Está calcando la tabla:
create table if not exists public.user_preferences (
user_id uuid primary key,
channel_favorites jsonb default null,
live_hidden_ids text[] not null default '{}',
vod_hidden_ids text[] not null default '{}',
live_category_order text[] not null default '{}',
...
series_completed text[] default null,
search_history text[] default null,
);
live_hidden_ids y series_completed son el mismo tipo de Postgres. La única diferencia entre la columna que sincroniza bien y la que no es un not null default '{}' escrito en el CREATE TABLE inicial, probablemente en treinta segundos y con la mejor de las intenciones: un array vacío es más cómodo que un null, te ahorra comprobaciones, no tienes nunca un NullPointerException.
Y es verdad que te ahorra todo eso. Lo que no dice el atajo es lo que cobra: una columna not null default '{}' tiene un valor desde el instante en que existe la fila. Nunca ha estado vacía de información, solo vacía de elementos, y esas dos cosas ya no se pueden separar nunca más.
Por qué esto no se arregla con un alter table
Aquí está la parte que hace que merezca un post en vez de un ticket.
Quitar el not null es una línea. Pero el día que la ejecutes, todas las filas que ya existen tienen '{}' — y no hay forma de saber, mirando esa fila, si ese {} significa "este usuario nunca ha ocultado nada" o "este usuario ocultó cosas y luego las mostró todas". La información que necesitas para decidirlo no se guardó nunca. No está en otra tabla, ni en un log, ni se puede deducir: en el momento en que las dos situaciones se escribieron con el mismo valor, la diferencia dejó de existir.
Así que el backfill no es un problema de SQL. Es que tienes que elegir a quién le vas a estropear el estado: si interpretas los {} viejos como "nunca sincronizado", te quedas exactamente donde estabas. Si los interpretas como "vaciado a propósito", el primer pull después de desplegar le muestra las doscientas categorías a todo el que las tenía ocultas y no había abierto la app en un mes.
Eso es lo que quiero decir con puerta de un solo sentido. Nullable → not null es una decisión que puedes revisar. Not null → nullable te devuelve la capacidad de distinguir de aquí en adelante, pero no te devuelve los datos que ya colapsaste. En LoverCast las columnas nuevas se declaran nullable desde el principio y con el comentario puesto; las ocho antiguas siguen ahí, y el coste de arreglarlas no es técnico.
Un camino de escritura sin nadie al otro lado
Hay un detalle más que conviene ver, porque es la forma en que estos bugs se esconden de los tests.
El push sí manda el vacío. Cuando le das a "mostrar todas", el repo sube el conjunto local tal cual, y en la columna se escribe {} de verdad. El dato viaja, llega, se guarda correctamente. Simplemente no hay nadie leyéndolo: el isNotEmpty() del otro lado lo descarta.
Un camino de escritura que funciona perfectamente y cuyo lector ignora el resultado no falla en ningún sitio donde mirarías. El test del push pasa: comprueba que se sube lo que hay. El test del pull pasa: le das dos o tres elementos de fixture y los aplica bien. El caso roto es el de cero elementos, que es justo el que nadie escribe como caso de prueba porque parece el caso trivial.
Y en LoverCast esto está documentado, que es lo más honesto y a la vez lo más incómodo. SYNC.md tiene un aviso que dice que si limpias una columna en la nube a mano, la pongas a [] y no a null, porque null no propaga el borrado. Es un buen aviso. Lo que no dice es que para ocho de esas columnas el consejo no sirve: poner [] tampoco propaga nada, porque el que lee es el isNotEmpty(). La documentación describía la mitad ordenada del sistema.
Dónde más vive esto
Nada de lo de arriba es de Android ni de Postgres. Es lo que pasa siempre que un solo valor tiene que llevar dos significados:
- APIs JSON. Un campo ausente y un campo a
[]deberían ser cosas distintas en unPATCH, y casi nunca lo son. Si tu deserializador convierte "no venía" en "lista vacía" antes de que tu lógica lo vea, has perdido la diferencia en la frontera y ya no la recuperas dentro. - Formularios. "No ha rellenado este campo" y "lo ha borrado a propósito" mandan lo mismo por el cable si el
inputvacío se serializa como cadena vacía. Con "ninguna alergia conocida" y "no le hemos preguntado" en la misma casilla, la diferencia importa bastante. - Configuración. Una clave que no está debería heredar del default. Una clave puesta a vacío debería sobreescribirlo con vacío. Muchos sistemas de config tratan las dos igual, y así aparecen los "pero si lo he puesto a cero y no me hace caso".
- Cachés. "No lo tengo cacheado" y "lo tengo cacheado y el resultado era ninguno" piden lo contrario: una consulta y ninguna consulta. Colapsarlas es cómo se construye sin querer un caché que no cachea los vacíos.
- Listas paginadas. Última página vacía y error tragado se parecen mucho si lo único que devuelves es un array.
El patrón para reconocerlo antes de escribirlo es una sola pregunta, hecha en el momento de declarar el campo y no en el de usarlo: ¿ausencia y vacío significan lo mismo aquí? Si la respuesta es no, el tipo tiene que poder decirlo. Si es sí, escríbelo en un comentario, porque el siguiente que llegue va a asumir lo que le convenga.
Lo que nos llevamos
El vacío es un valor; la ausencia es la falta de valor. Meterlos en el mismo sitio no simplifica el modelo: le quita la capacidad de expresar una de las dos cosas, y no eliges cuál hasta que un usuario se queja. Pasa igual en cualquier herramienta interna donde alguien pueda vaciar un campo a propósito.
not null default '{}' es una decisión de producto disfrazada de comodidad. Se escribe en el CREATE TABLE sin pensarlo y se paga en el pull, meses después, en otro lenguaje y en otro repositorio.
Un isNotEmpty() en un merge es una pregunta sin responder. No siempre está mal — a veces "vacío significa sin opinión" es exactamente lo que quieres. Pero si al lado no hay un comentario que lo diga, casi nunca es que se decidiera: es que no se planteó. Como el String que en realidad era un enum del que ya escribí, el problema no está donde falla.
Colapsar información es irreversible. Casi todo en un esquema se puede cambiar luego. Fundir dos estados en un valor, no: recuperas la capacidad de distinguirlos en adelante, nunca los datos de antes. Es la clase de decisión que hay que tomar despierto, y son treinta segundos al principio contra un backfill que no tiene respuesta correcta.
En la app esto son doce categorías en vez de doscientas. Pero la razón por la que la tele no se enteró no era de la tele: estaba escrita en la primera versión de la tabla, dos años antes, en una columna que no podía decir "no sé".
LoverCast está en producción y puedes descargarla. Si tienes una columna que no distingue el vacío de la ausencia y estás decidiendo qué hacer con el backfill, escríbeme.