Sincronización Offline y Local-First
Resumen
Sección titulada «Resumen»El soporte offline convierte la capa de datos del SDK en un motor de sincronización local-first. En lugar de una caché que recuerda respuestas, el cliente mantiene una pequeña base de datos local de filas, responde a las consultas contra ella y trata la red como algo que la rellena y que acaba aceptando sus escrituras.
De ahí se derivan tres cosas:
- Las lecturas sobreviven a la caída de la red. Una consulta que el cliente puede evaluar localmente se evalúa localmente — filtros, ordenación y paginación incluidos — de modo que una lista sigue renderizándose con la conexión caída.
- Las escrituras se deciden localmente. Una escritura hecha offline se aplica de inmediato, se pone en cola y se reproduce en orden cuando vuelve la conexión. Si el servidor la rechaza, el cambio local se revierte.
- Las lecturas son reactivas.
observe()emite primero desde la base de datos local y vuelve a emitir cada vez que algo cambia las filas que cubre — sus propias escrituras, una escritura en cola que llega al servidor, una reversión, otra pestaña del navegador o un evento en tiempo real.
Está desactivado de forma predeterminada. Actívelo con una sola opción:
const client = createRebaseClient({ baseUrl: "https://api.example.com", offline: true});En el navegador todo se persiste en IndexedDB, así que una recarga conserva tanto las filas locales como las escrituras sin enviar. En otros entornos (Node, tests) recurre a la memoria; otros runtimes pueden aportar su propio almacén.
Qué cambia
Sección titulada «Qué cambia»Nada de la API que ya usa cambia de forma. find(), findById(), create(), update(), delete() y el constructor de consultas fluido conservan sus firmas y sus tipos de retorno — simplemente dejan de fallar cuando falla la red.
Lecturas
Sección titulada «Lecturas»Una lectura correcta fusiona sus filas en la base de datos local y recuerda qué ids devolvió el servidor para esa consulta. Cuando una lectura no puede llegar al servidor, se responde localmente:
const drafts = await client.data.posts .where("status", "==", "draft") .orderBy("updated_at", "desc") .find();Sin conexión, esto filtra y ordena las filas que el cliente tiene. Eso incluye filas obtenidas por otras consultas — la base de datos está normalizada, así que una fila se almacena una sola vez sin importar en cuántas listas apareció — y filas que creó offline.
Si realmente no hay nada con qué responder (una colección que la aplicación nunca ha leído), la lectura lanza un error reconocible en lugar de un TypeError pelado:
import { isOfflineError } from "@rebasepro/client";
try { await client.data.posts.find();} catch (error) { if (isOfflineError(error)) showOfflinePlaceholder(); else throw error;}Escrituras
Sección titulada «Escrituras»Mientras se sabe que la conexión está caída, ni siquiera se intenta la escritura — se aplica localmente y se pone en cola, de modo que no cuesta nada en vez de un tiempo de espera agotado:
// Returns immediately, offline or not.const post = await client.data.posts.create({ title: "Draft", status: "draft" });
// Shows up in every matching list, right away.const drafts = await client.data.posts.where("status", "==", "draft").find();Las filas creadas offline reciben un id generado por el cliente. Si el servidor asigna el suyo al reproducirlas, la fila local y cualquier escritura en cola que aún apunte al id temporal se trasladan al id real.
Las escrituras se reproducen en el orden en que las hizo, entre colecciones — así que una creación en una colección sigue llegando antes que la fila de otra que la referencia.
Consultas en vivo
Sección titulada «Consultas en vivo»observe() es la lectura reactiva, y la que conviene usar en una UI:
const unsubscribe = client.data.posts.observe( { where: { status: ["==", "draft"] }, orderBy: ["updated_at", "desc"] }, (result) => { render(result.data); setBadge(result.hasPendingWrites ? "saving…" : null); });La primera emisión viene de la base de datos local sin ninguna solicitud de por medio; una revalidación la sigue en segundo plano. A partir de ahí vuelve a emitir con cada cambio en las filas que cubre. Las emisiones se deduplican — un refresco que no cambia nada no invoca el callback — así que es seguro renderizar directamente desde ella.
Cada resultado lleva lo que una UI necesita para describirse a sí misma:
| Campo | Significado |
|---|---|
data, meta |
La misma forma que devuelve find() |
fromCache |
Las filas vienen de la base de datos local, no de una solicitud completada |
hasPendingWrites |
Al menos una fila de aquí lleva una escritura que el servidor no ha aceptado |
partial |
La base de datos local puede no tener todas las filas coincidentes — trátelo como el mejor esfuerzo posible |
error |
La última revalidación falló |
observeById() hace lo mismo para una sola fila, y pasa undefined cuando esta se elimina.
Ambos enlazan la suscripción en tiempo real cuando el cliente tiene una, de modo que los cambios hechos por otros usuarios también llegan en streaming. Pase { realtime: false } para una suscripción que solo refleje el estado local y los refrescos explícitos.
Sin offline activado, observe() sigue existiendo: obtiene los datos una vez y se mantiene en vivo mediante el tiempo real, con las tres marcas en false.
Estado de sincronización
Sección titulada «Estado de sincronización»client.offline expone el motor, que es a partir de lo que se construye un indicador de sincronización:
const unsubscribe = client.offline!.onStatusChange((status) => { setOnline(status.online); setPending(status.pending); setSyncing(status.syncing);});
// Or read it onceconst { online, pending, syncing, lastSyncedAt, lastError } = client.offline!.status();| Método | Propósito |
|---|---|
status() |
Conectividad actual, profundidad de la cola, actividad de sincronización, último error |
onStatusChange(fn) |
Suscribirse a lo anterior |
onQueueChange(fn) |
Solo el número de escrituras sin enviar, para un indicador |
pending() |
Las mutaciones en cola en sí, de la más antigua a la más reciente |
sync() |
Reproducir ahora — se resuelve con { flushed, remaining } |
clear() |
Descartar las escrituras en cola y las filas locales del usuario actual |
La reproducción ocurre por su cuenta: cuando el navegador dispara online, cuando el usuario inicia sesión y con un backoff exponencial (un segundo, duplicándose hasta un minuto) mientras haya algo en cola. sync() es para un botón de «reintentar ahora».
Cuando el servidor dice que no
Sección titulada «Cuando el servidor dice que no»Una escritura en cola puede ser rechazada — validación, seguridad a nivel de fila, una fila que otra persona eliminó. Eso nunca se resuelve por sí solo, así que el motor revierte las filas locales a como estaban antes de la escritura, descarta las ediciones en cola que se construyeron sobre ella y se lo comunica:
const client = createRebaseClient({ baseUrl: API_URL, offline: { onSyncError: (error, mutation) => { toast(`Couldn't save your change to ${mutation.collection}: ${error.message}`); } }});La cascada es estrecha: un update se descarta junto con la escritura que editaba, porque solo puede fallar de la misma manera. Un create o delete posterior sobre la misma fila se sostiene por sí mismo y se conserva.
Un fallo que es meramente temporal — un 429, un 503, una conexión caída — no es un rechazo. Esos permanecen en cola y se reintentan; solo después de maxRetries aplazamientos se revierte una escritura.
Múltiples pestañas
Sección titulada «Múltiples pestañas»Las pestañas de la misma aplicación comparten una única base de datos IndexedDB, así que comparten las filas locales y la cola de salida (outbox). Una escritura en una aparece en las demás, y solo una pestaña a la vez reproduce la cola. No hay nada que configurar.
Usuarios
Sección titulada «Usuarios»La base de datos local y la cola de salida están particionadas por usuario autenticado. Las filas en caché son lo que la seguridad a nivel de fila dejó ver a ese usuario, y una escritura en cola tiene que reproducirse como su autor — así que cerrar sesión y volver a entrar como otra persona nunca mezcla las dos. Cerrar sesión no necesita limpiar nada.
Configuración
Sección titulada «Configuración»createRebaseClient({ baseUrl: API_URL, offline: { store: myCustomStore, // default: IndexedDB in the browser, memory elsewhere maxCachedRowsPerCollection: 5000, // rows with unsent writes are never evicted maxCachedQueriesPerCollection: 50, // remembered server page compositions syncIntervalMs: 60_000, // ceiling for the retry backoff; 0 disables auto-retry maxRetries: 5, // deferrals before a write is given up on crossTab: true, // default: on for IndexedDB, off for memory onSyncError: (error, mutation) => {} }});Un almacén personalizado
Sección titulada «Un almacén personalizado»Cualquier entorno puede persistir la base de datos local implementando OfflineStore — una superficie clave/valor con espacios de nombres, con un área de caché de lectura y un área de cola. Así es como se respalda con AsyncStorage en React Native, o con el sistema de archivos en Electron:
import type { OfflineStore } from "@rebasepro/client";
class AsyncStorageOfflineStore implements OfflineStore { // Read cache: getCache, setCache, setCacheMany, deleteCache, // listCache, listCacheEntries // Outbox: enqueue, dequeue, listQueue // Both: clear}El único contrato más allá de lo evidente es que los listados por prefijo vuelven en orden lexicográfico de clave — eso es lo que hace que la cola de salida sea FIFO.
Límites
Sección titulada «Límites»El cliente no es una réplica de su base de datos, y no pretende serlo:
- Solo son locales las filas que la aplicación ha leído o escrito. Una consulta que el cliente nunca ha enviado aún puede responderse con lo que tiene, pero a la respuesta pueden faltarle filas que el servidor habría devuelto. Los resultados en vivo lo indican mediante
partial. searchStringse aproxima como un escaneo de subcadena sin distinción de mayúsculas sobre los campos de texto en caché. El servidor ejecuta una búsqueda de texto completo real sobre las columnas configuradas de la colección.- Las relaciones incluidas con
includeno se pueden evaluar localmente — las filas relacionadas viven en colecciones que la consulta nunca cargó. Una consulta así siempre se marca comopartialcuando se responde desde la caché. - La reproducción es al menos una vez. Una escritura que llega al servidor pero cuya respuesta se pierde puede enviarse de nuevo. Prefiera escrituras idempotentes (
createManyconupsert) allí donde los duplicados importen. - Las lecturas locales aplican la semántica de Postgres, no los datos de la base de datos. Los filtros se evalúan como lo haría SQL — las comparaciones contra
NULLson desconocidas,ORDER BYcoloca los nulos al final en orden ascendente — pero contra la copia de las filas que tiene el cliente, que puede estar desactualizada.
Receta: un indicador de estado offline
Sección titulada «Receta: un indicador de estado offline»import React from "react";import type { CreateRebaseClientResult } from "@rebasepro/client";
export function SyncIndicator({ client }: { client: CreateRebaseClientResult }) { const offline = client.offline!; const [status, setStatus] = React.useState(offline.status()); React.useEffect(() => offline.onStatusChange(setStatus), [offline]);
if (!status.online) { return <span className="badge warning"> Offline{status.pending ? ` · ${status.pending} unsaved` : ""} </span>; } if (status.syncing) return <span className="badge">Syncing…</span>; if (status.pending) return <span className="badge">{status.pending} unsaved</span>; return null;}Véase también
Sección titulada «Véase también»- Consultar Datos — la superficie de consulta que
observe()comparte confind() - Suscripciones en Tiempo Real — actualizaciones enviadas por el servidor, sobre las que se construyen las consultas en vivo
