Saltar al contenido
Plazoleta
· Raúl López

Los días del viaje no existen en la base de datos

En Jornia un viaje tiene paradas, pero no tiene días: se calculan al leer a partir del rango de fechas. Qué estados imposibles evita eso, y la factura que cobra a cambio.

Una app de itinerarios tiene una pantalla que parece pedir su propio modelo de datos: la línea de tiempo, con su "Día 1", "Día 2", "Día 3" y las paradas de cada uno colgando debajo. El esquema que se dibuja solo en la pizarra es viaje → días → paradas, tres tablas, dos claves foráneas. De hecho es lo que ponía la tabla de fases del roadmap de Jornia cuando describía la primera: "Room (viajes→días→paradas)".

Lo que se construyó en la v0.2.0 fue otra cosa, y la nota del roadmap lo dice en una línea: Trip → Stop, y los días "se derivan del rango de fechas, no se persisten (menos filas que mantener coherentes al cambiar fechas)". En la base de datos hay viajes y paradas. Los días no existen.

El estado imposible que no puede darse

La razón es lo que pasa cuando alguien edita las fechas de un viaje. Con una tabla de días, cambiar "del 14 al 16 de marzo" por "del 20 al 21" deja de ser un UPDATE y se convierte en una pequeña reconciliación: borrar los días que sobran, crear los que faltan, decidir qué hacer con las paradas colgadas de los borrados, y hacerlo todo en una transacción para que nadie vea el estado intermedio. Cada una de esas operaciones es un sitio donde puede quedar una fila huérfana, un día duplicado o un viaje de tres días con cuatro filas.

Sin la tabla, el problema no se resuelve: desaparece. El viaje guarda startDate y endDate, y la función que pinta la línea de tiempo recorre ese rango:

var date = trip.startDate
var index = 1
while (date <= trip.endDate) {
    val dayStops = byDate[date].orEmpty().sortedWith(/* hora local; sin hora, al final */)
    days += TimelineDay(index, date, dayStops)
    date = date.plus(DatePeriod(days = 1))
    index++
}

El comentario de esa función fija el contrato: los días son exactamente las fechas del rango. Un día sin paradas existe y se muestra vacío. No hay forma de tener un día de más ni de menos, porque no hay nada donde guardarlo. Es la misma idea que cualquier restricción de integridad, pero llevada al extremo: en vez de proteger una invariante, eliminas el dato que podría violarla.

Hay un detalle que ayuda: las fechas son LocalDate, sin zona horaria. Sumar un día a una fecha de calendario no tiene ninguno de los problemas de sumar 24 horas a un instante. El "día" de la línea de tiempo es el día del lugar, y cada parada guarda su propia fecha local y su zona.

La factura: las paradas que quedan fuera

Ahora, lo que nadie cuenta cuando vende "derivar en vez de persistir": la derivación no es gratis. Cuesta en otro sitio, y aquí cuesta justo en el caso que parecía simplificar.

Si acortas el viaje a "del 20 al 21", las paradas del 14 y el 15 siguen en la base de datos, porque la parada guarda su fecha y no le importa qué rango tiene el viaje. Pero buildTimeline solo construye los días del rango, así que esas paradas no aparecen en ninguna parte. Hubo que decidir qué hacer con ellas, y lo que se decidió está escrito en el código y en los tests:

  • No se borran. Reaparecen si el viaje se vuelve a ampliar. Hay un test que lo comprueba: se mueven las fechas, las dos paradas dejan de contar, se vuelve a ampliar, las dos vuelven.
  • Se avisa antes de guardar. El formulario de edición cuenta cuántas paradas caerían fuera del rango elegido y, si hay alguna, abre un diálogo: "2 paradas quedan fuera de las nuevas fechas y dejarán de verse en la línea de tiempo. Volverán si amplías el viaje otra vez."
  • Al importar una reserva que cae fuera del rango, el viaje se amplía. El comentario de ese código lo dice sin adornos: los días se derivan del rango, y sin ampliarlo la parada no se vería.

Ese tercer punto es el más revelador. El rango del viaje es la única fuente de verdad sobre qué días existen, así que cualquier operación que cree una parada tiene que acordarse de que el rango la cubra. Con la tabla de días, ese acoplamiento estaría en una clave foránea. Sin ella está en la disciplina de quien escribe el siguiente flujo de alta.

Todo el que cuente paradas tiene que saber lo mismo que la línea de tiempo

El coste más sutil apareció después y está documentado en una función concreta, plannedStops. Su comentario explica el síntoma que la motivó: quien cuente paradas o busque la próxima tiene que partir del conjunto que el viaje enseña hoy, "o dirá cosas que la línea de tiempo desmiente («4/8 paradas» en un viaje que por dentro está vacío)".

Es el efecto lateral lógico. Si la línea de tiempo oculta unas paradas pero la tarjeta de la home cuenta todas las de la base, la app se contradice a sí misma. Así que la tarjeta del viaje, la próxima parada de la home, el widget de la pantalla de inicio y el acceso directo dinámico del launcher pasan todos por el mismo filtro antes de calcular nada. El test de plannedStops comprueba los bordes (la parada del primer día, la del último, la del día siguiente al fin) y que no se mezclan viajes.

Y luego está la excepción, que también está escrita: el dinero no pasa por ese filtro. Un vuelo pagado sigue pagado aunque su fecha haya quedado fuera del rango, y la pantalla de gastos lo cuenta a propósito. Dos preguntas que parecen la misma ("¿qué paradas tiene este viaje?") tienen respuestas distintas según se pregunte por lo que se ve o por lo que se debe.

Qué descarta esto y qué no

Conviene no venderlo mejor de lo que es. Derivar los días funciona porque en Jornia un día no tiene atributos propios: no hay notas por día, ni un título, ni nada que viva en el día y no en una parada. Si mañana alguien pidiera "nombre del día" o "meteorología fijada por día", habría que revisar la decisión, porque un dato que solo existe en el día no tiene dónde guardarse sin tabla. Ese disparador no lo dejamos escrito en ningún sitio, y debería estarlo: es una condición de la que depende la decisión y que hoy simplemente se cumple.

Tampoco es el modelo adecuado si los días tienen que sincronizarse entre dispositivos como entidades. En una app que hoy es local, con Room como única fuente de verdad, es la opción más barata. No hace falta defender que sea la correcta en general.

Lo que nos llevamos

Un dato derivado no se puede desincronizar. Los días del viaje no se corrompen, porque no se almacenan. Lo que pagas es que todo lo que los consuma tiene que derivarlos de la misma manera.

El coste de derivar aparece en los bordes. No en el caso feliz, sino en el momento en que el dato original cambia y deja huérfano a otro: las paradas fuera de rango son ese borde. Conviene decidir qué hacer con ellas antes de que alguien las pierda.

Si hay dos respuestas a una pregunta, que sea a propósito. plannedStops para lo que se ve, la suma total para lo que se paga. Lo malo no es que difieran: es que difieran por accidente.

Si tu modelo de datos tiene una tabla que solo existe para que una pantalla tenga de dónde colgar cosas, escríbeme y la miramos juntos.

¿Tienes un problema parecido?

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