OdoKeep ya está en el App Store. Descárgalo gratis.

Todos los artículos

Datos geográficos

Sustituir consultas geoespaciales por archivos JSON y Cloudflare R2

Una cuadrícula compartida, JSON estático en Cloudflare R2 y una caché que distingue datos ausentes de un mapa vacío.

Actualizado el

En este artículo

Cada persona que consultaba precios de combustible cercanos en OdoKeep hacía una pregunta distinta sobre los mismos datos.

Cambiaban la ubicación, el radio y el combustible. La lista de estaciones era común y se reconstruía a diario.

Eso bastaba para reconsiderar dónde debía ejecutarse la consulta.

OdoKeep es un diario de vehículos en React Native. La función de precios consume fuentes nacionales de Portugal, España, Francia, Italia y Austria. El módulo de celdas documenta un conjunto de unas 46.000 estaciones, aunque el número cambia con las fuentes. Al principio, la aplicación pedía al backend las estaciones cercanas y los recuentos de marcas por país.

Ahora lee archivos JSON en Cloudflare R2 mediante un dominio público propio. Supabase sigue gestionando los datos privados del garaje. El proceso de publicación también lee metadatos de atribución y comunica su estado. Lo que desapareció fue la necesidad de que la base de datos respondiera a cada búsqueda de precios desde un teléfono.

Podría haber publicado un archivo por país y filtrarlo en la aplicación. Sería sencillo, pero buscar cerca no debería exigir descargar todas las estaciones de un país. Necesitaba una partición ajustada al uso real de la pantalla.

Compartir la cuadrícula entre publicación y cliente

La partición es una cuadrícula de medio grado. lib/fuel-prices/tiles.ts contiene el cálculo compartido por el publicador y el cliente:

const TILE_DEGREES = 0.5;

function tileKey(lat: number, lon: number): string {
  return `${Math.floor(lat / TILE_DEGREES)}_${Math.floor(lon / TILE_DEGREES)}`;
}

Lisboa, en 38.72, -9.14, cae en 77_-19. La coordenada negativa hace importante Math.floor: truncar colocaría los puntos al oeste de Greenwich en otra celda.

La estructura publicada se explica sin una especificación de API:

fuel/
  index.json
  sources.json
  brands/
    PT.json
    ES.json
    ...
  tiles/
    77_-19.json
    77_-18.json
    ...

Cada celda contiene datos de estaciones: coordenadas, identidad, dirección, precios, horarios cuando están disponibles y fechas de actualización de la fuente. No contiene la distancia al usuario. Esa distancia pertenece a la consulta, no a la estación.

Descargar un rectángulo y filtrar por distancia

El cliente calcula las celdas que cubren el rectángulo envolvente del círculo buscado. El intervalo aproximado de latitud sale de dividir el radio por los kilómetros por grado. El de longitud se amplía según el coseno de la latitud, porque los grados de longitud se estrechan hacia el norte.

El rectángulo es deliberadamente algo impreciso. Puede descargar una celda de una esquina sin estaciones dentro del círculo. Después usa la distancia haversine para descartar las estaciones fuera del radio y ordenar las demás. Mantiene el mismo radio terrestre que la consulta sustituida.

El tamaño de cuadrícula se justifica por el número de archivos. Medio grado mide unos 56 km de alto, con menor anchura en las latitudes cubiertas. El radio predeterminado de 10 km implica 20 km de diámetro. Las pruebas muestrean la región documentada y comprueban un máximo de cuatro celdas para ese radio y nueve para el límite de 30 km.

Es fácil olvidar el diámetro. «Una búsqueda de 30 km cabe en una celda de 56 km» parece razonable hasta dibujar el círculo.

Una cuadrícula más gruesa reduciría el máximo de peticiones, pero obligaría a descargar archivos mayores para la búsqueda habitual. La elegida gasta más peticiones en radios amplios para mantener pequeñas las piezas de las búsquedas cercanas.

El índice abarata las zonas vacías. Solo enumera celdas con estaciones e incluye tamaño de cuadrícula y fecha de construcción. Una búsqueda costera descarta celdas vacías conocidas antes de pedirlas. El cliente rechaza un índice construido con otra cuadrícula en lugar de interpretar claves con otro significado.

Los recuentos por marca se calculan una vez por país. Cambiar el filtro de marca ya no exige agregar todo un país en la base de datos.

Con los archivos cargados, cambiar combustible, marca o radio puede reutilizar las estaciones disponibles. La red deja de participar en cada ajuste de la vista.

Vacío y fallido son resultados distintos

La caché considera válidas las entradas durante seis horas. Guarda datos interpretados en memoria, persiste copias para futuros arranques y mantiene una promesa en curso por clave. Dos consumidores que piden simultáneamente una celda comparten la petición. Si falla la red y hay una copia antigua utilizable, puede resolver la consulta con ella.

El cambio más importante fue distinguir tres resultados:

type TileOutcome =
  | { status: "ok"; stations: TileStation[] }
  | { status: "empty" }
  | { status: "failed" };

Una celda vacía y otra que no pude descargar aportan evidencias distintas.

Imaginemos una búsqueda que cruza dos celdas pobladas. Una responde y otra devuelve un 503 sin copia aprovechable. Devolver solo las estaciones de la primera reduciría silenciosamente la zona buscada. Esa lista incompleta podría ordenarse y almacenarse como una respuesta completa.

stationsAround devuelve un fallo si falla cualquier celda necesaria. La capa superior puede recurrir a la caché de una búsqueda anterior o explicar que no pudo recuperar los datos. No convierte una pieza ausente del mapa en un resultado recién completado.

El tratamiento actual de un 404 es más limitado: se considera vacío y se recuerda cinco minutos. Esa duración breve importa durante la publicación, cuando una celda puede estar temporalmente ausente. El índice evita muchas de estas peticiones, pero un 404 no ofrece la certeza de un conjunto versionado y coherente.

Publicar conjuntos completos, primero las celdas

El publicador aplica una regla equivalente de integridad. El constructor TypeScript exige todos los países configurados y se niega a publicar si falla algún adaptador. De otro modo, una caída de una fuente produciría una lista nueva de la que habría desaparecido un país. Es preferible mantener la construcción completa anterior.

La GitHub Action programada ejecuta el constructor con Bun. Usa módulos integrados y archivos fuente del proyecto, por lo que no instala las dependencias de toda la aplicación móvil. Produce un directorio; la subida es otro paso del flujo.

Los JSON se comprimen antes de subirlos y se sirven con la codificación correspondiente. gzip -n omite la fecha del encabezado comprimido: una entrada idéntica no produce bytes distintos por haberse comprimido otro día. Los objetos tienen una directiva de caché pública de una hora.

Las celdas se suben antes del índice. Publicar primero el índice anunciaría claves que una subida fallida podría no entregar nunca.

Ese orden no hace atómico el despliegue. Los archivos se sustituyen en sus ubicaciones y el cliente puede ver construcciones mezcladas. La limpieza también puede borrar una celda antigua mientras un cliente conserva el índice anterior. Para estos precios acepto esa consistencia limitada y un tratamiento breve de celdas ausentes. Un conjunto que exigiera una instantánea coherente necesitaría rutas por generación y cambiar un puntero solo cuando todos los archivos estuvieran disponibles.

Cuándo encaja esta arquitectura

R2 resulta útil porque su tarifa publicada no cobra ancho de banda de salida a internet. El almacenamiento y las operaciones siguen formando parte del modelo de precios de R2, y configurar la caché sigue importando. Un dominio público propio puede usar la caché de Cloudflare, como explica su documentación de buckets públicos. No tengo una factura comparativa que adjuntar: el cambio concreto es que estas búsquedas ya no consumen lecturas ni tráfico de salida de la base de datos.

Publicar a diario tampoco garantiza precios actualizados al minuto. Se conserva por separado la fecha de la fuente y la atribución acompaña a los datos. Las fuentes nacionales tienen distintas frecuencias y condiciones de reutilización. Servir archivos no cambia esas condiciones.

Funciona porque los datos son públicos, compartidos, geográficamente acotados y se actualizan mucho menos de lo que se leen. Elegiría otra solución para datos privados, autorización por usuario o precios en tiempo real dependientes de cada transacción.

El teléfono ya conocía la ubicación, el radio y los filtros. Una porción local reutilizable le permite terminar la consulta sin pedir al servidor que redescubra las mismas estaciones.

Puedes ver el buscador de precios en OdoKeep, junto al diario de vehículos. La aplicación iOS permite probarlo en tus rutas habituales.