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("updatedAt", "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:
// While offline, returns at once with the locally applied row.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, del tipo que ya tienen los ids de la colección: un entero negativo donde son números, una cadena UUID donde son cadenas. El signo es la señal — una clave real nunca es negativa —, así que una fila que aún no ha llegado al servidor se reconoce sin consultar nada, e id sigue siendo lo que dice el tipo Row generado. En una colección que este dispositivo nunca ha leído no hay nada local de donde deducir el tipo, y el id es un UUID.
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, en todas las colecciones: un post en cola con author_id apuntando a un autor creado offline se envía con el id que el servidor dio al autor. Si el servidor rechaza al autor, las escrituras que lo referencian se descartan con él y se informan como DEPENDENCY_REJECTED (consulte Cuando el servidor dice que no). Hasta entonces, no guarde un id temporal fuera de la base de datos offline: una clave foránea o una URL guardada que lo contenga apunta a una fila que está a punto de renumerarse.
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.
Cada escritura en cola es una petición por defecto, así que 500 filas escritas sin conexión tardan 500 viajes de ida y vuelta en vaciarse. Con replayBatchSize definido (hasta 1 000), una serie de creaciones offline consecutivas en una colección se reproduce como un solo createMany. Solo se agrupan las creaciones con un id que el SDK generó como cadena, y nunca una que haga referencia a otra fila de la misma serie. Si el servidor rechaza o aplaza la serie por cualquier motivo que no sea una conexión perdida, no se escribe nada (una escritura masiva es una transacción), y la serie se reproduce escritura a escritura, como lo haría por defecto.
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: ["updatedAt", "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. Acepta include igual que findById(), y cada emisión lleva esas relaciones:
const stop = client.data.posts.observeById(postId, (post) => render(post), undefined, { include: ["tags"]});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, y persisted: si el navegador aceptó conservar la base de datos offline (se pregunta cuando se pone en cola la primera escritura; false significa que puede ser desalojada bajo presión de almacenamiento, incluidas las escrituras sin enviar). unverifiedUser es true mientras una recarga sin conexión muestra los datos del último usuario sin una sesión confirmada (consulta keepLastUser) |
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. pending({ orphaned: true }): las que se pusieron en cola sin sesión iniciada — véase Usuarios |
sync() |
Reproducir ahora — se resuelve con { flushed, remaining } |
clear() |
Descartar las escrituras en cola y las filas locales del usuario actual. clear({ orphaned: true }): en su lugar, las que se pusieron en cola sin sesión iniciada |
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}`); } }});Una fila que otra persona eliminó es el único caso que no se revierte. Un delete en cola de ella hizo aquello para lo que servía, así que cuenta como sincronizado y no se informa. Una edición en cola de ella se informa, y la fila se elimina de la base de datos local en lugar de restaurarse, porque el servidor ya no la tiene.
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 create rechazado de una fila creada offline arrastra además consigo toda escritura en cola, en cualquier colección, cuyos valores referencien el id temporal de la fila. Una escritura descartada nunca se envió. Se informa con un RebaseApiError cuyo code es DEPENDENCY_REJECTED y cuyo cause es el rechazo que la condenó, para que puedas distinguirla de la escritura que el servidor realmente rechazó. En 0.23 lleva en cambio el propio error de la escritura rechazada.
Las ediciones consecutivas sobre una misma fila mientras está sin conexión se fusionan en una única escritura en cola, de modo que un formulario en el que se está escribiendo no hace crecer la cola. En una escritura que la cola ya empezó a enviar no se fusiona nada: una edición hecha después de un intento de reproducción cuya respuesta se perdió se pone en cola como una actualización propia, y la eliminación de una fila creada sin conexión se envía en lugar de cancelarse contra su creación, porque el servidor puede tener ya la fila. Si el servidor rechaza la escritura fusionada por su contenido (un 400, 403 o 422 — por ejemplo, un campo eliminado por un cambio de esquema mientras el usuario estaba sin conexión), el motor la divide de nuevo en las ediciones separadas y reproduce cada una. Solo se revierte y se informa la edición que el servidor rechaza; las demás se conservan.
Conflictos
Sección titulada «Conflictos»Por defecto, una edición puesta en cola sin conexión sobrescribe lo que tenga el servidor en los campos que cambia, aunque otra persona los haya cambiado entretanto. Para enterarte en su lugar, pasa onConflict. Un update en cola se reproduce entonces con If-Match, la versión de la fila contra la que se hizo, y cuando la fila ha cambiado desde entonces, el servidor la rechaza con un 412 y onConflict decide:
const client = createRebaseClient({ baseUrl: API_URL, offline: { onConflict: async (error, mutation) => (await askUser(`${mutation.collection} changed while you were offline. Keep your edit?`)) ? "overwrite" : "discard" }});"overwrite"envía la edición de nuevo sin la precondición: gana el último escritor, como sinonConflict."discard"descarta la edición. La fila vuelve a ser lo que era antes de ella, como en un rechazo, y se lee de nuevo del servidor. No se notifica aonSyncError; las escrituras puestas en cola encima de ella sí, comoDEPENDENCY_REJECTED.
La cola espera mientras se ejecuta onConflict, así que puede preguntar al usuario. Si lanza un error, la edición se sobrescribe.
La versión contra la que se hace una edición es la que informó la última lectura de la fila: findById(), una trama de listenById(), o la respuesta de un update() anterior. Una fila leída por última vez en una lista de find() o listen() no tiene versión, ni tampoco una edición hecha encima de otra escritura en cola sobre la misma fila. Esas se reproducen sin precondición. Un ifMatch que pases tú mismo a update() o delete() se conserva cuando la escritura se pone en cola, con onConflict o sin él, y findById() en un cliente con offline activado devuelve la fila con su versión, de modo que etagOf(row) funciona como en línea. Sin onConflict, una escritura en cola rechazada por tu propio ifMatch se revierte y se informa a onSyncError con un 412 PRECONDITION_FAILED.
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.
Esa última garantía proviene de la API Web Locks del navegador, que solo existe en un contexto seguro (HTTPS, o localhost) y no en Safari anterior a la 15.4 ni en algunos webviews integrados. Sin ella, todas las pestañas abiertas reproducen la cola cuando sincronizan. Los creates, las escrituras masivas, los updates y los deletes se reproducen todos con una clave de idempotencia, así que un servidor que almacena claves aplica cada uno una sola vez; lo que puede verse es la misma escritura enviada desde dos pestañas.
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.
Mientras se restaura la sesión
Sección titulada «Mientras se restaura la sesión»Hasta que el cliente sabe quién ha iniciado sesión, no sabe qué base de datos local ni qué cola de salida usar, así que las lecturas y escrituras offline esperan. Es el tiempo que tarda auth.isInitialized(): ninguno para una sesión almacenada que sigue siendo válida, y una petición de refresco cuando la sesión tiene que volver del servidor — en modo cookie, donde no se guarda nada en el dispositivo, eso ocurre en cada carga de página. Una escritura hecha en ese intervalo se pone en cola a nombre de quien resulte ser el usuario de la sesión, y una lectura responde desde su base de datos local. Un evento de tiempo real que llegue mientras tanto se pasa a tu callback tal como lo envió el servidor, y no se guarda en ningún sitio.
Si la sesión no se puede restaurar — el refresco es rechazado o, en modo cookie, no llega al servidor — el cliente queda sin sesión, y las lecturas y escrituras offline son las del usuario sin sesión. En 0.23, el modo cookie trataba todo ese intervalo como sin sesión, así que una escritura hecha en él se ponía en cola como del usuario sin sesión.
Escrituras hechas sin sesión iniciada
Sección titulada «Escrituras hechas sin sesión iniciada»Las escrituras puestas en cola mientras nadie había iniciado sesión no se reproducen cuando alguien inicia sesión. Ese usuario no las hizo, y reproducirlas con sus credenciales pondría los cambios de una persona a nombre de otra — en un dispositivo compartido, las escrituras de un visitante en la cuenta del siguiente usuario. Tampoco se mueven a la cola de salida del usuario que inició sesión. Se conservan y se listan aparte, para que la app decida qué hacer con ellas:
client.auth.onAuthStateChange(async (event) => { if (event !== "SIGNED_IN") return; const orphaned = await client.offline!.pending({ orphaned: true }); if (orphaned.length === 0) return; if (await askToKeep(orphaned.length)) { // Re-issue each one yourself, now as the signed-in user. for (const write of orphaned) await reissue(write); } await client.offline!.clear({ orphaned: true });});clear({ orphaned: true }) las descarta, junto con las filas locales a las que se aplicaron; la cola de salida propia del usuario con sesión iniciada no se toca. Si nadie las toca, se reproducen la próxima vez que nadie haya iniciado sesión. Mientras nadie ha iniciado sesión son simplemente la cola de salida actual, y pending({ orphaned: true }) no lista nada.
Recargas sin conexión en modo cookie
Sección titulada «Recargas sin conexión en modo cookie»En modo cookie no se almacena nada de la sesión en el dispositivo, así que una recarga que no puede alcanzar el servidor no tiene usuario. Por defecto se trata como sin sesión iniciada: las filas locales del usuario se ocultan, y las escrituras hechas entonces se ponen en cola como las del usuario sin sesión.
keepLastUser: true lo cambia. El cliente recuerda al último usuario que
inició sesión en el dispositivo (solo su id), y una recarga sin conexión abre en su lugar la
base de datos local de ese usuario. Las escrituras hechas entonces se ponen en cola como suyas, pero ninguna
se reproduce hasta que se confirma la sesión: cuando vuelve la conexión, el
cliente renueva la sesión, y un inicio de sesión también la confirma. Si el servidor
rechaza la sesión, o inicia sesión otra persona, el dispositivo cambia de usuario, y
las escrituras en cola se quedan con su autor hasta que vuelva a iniciar sesión. Mientras tanto
status().unverifiedUser es true, y eso vale también en línea: hasta que la
renovación responde, las escrituras se ponen en cola en lugar de salir sin
credenciales, y las lecturas se responden desde las filas locales del usuario. Lo
que el servidor envía a una solicitud sin sesión es lo que puede ver quien no ha
iniciado sesión, así que no se conserva.
createRebaseClient({ baseUrl: API_URL, auth: { authFlowMode: "cookie" }, offline: { keepLastUser: true }});Está desactivado por defecto porque en un dispositivo compartido permite leer sin conexión y sin sesión las filas en caché del usuario anterior. Actívalo en instalaciones que pertenezcan a una sola persona. El id se olvida al cerrar sesión y cuando el servidor rechaza la sesión. Nunca se usa cuando el servidor responde que no hay sesión.
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 keepLastUser: false, // open the last user's data on an offline reload (cookie mode) replayBatchSize: 1, // queued creates sent per request on replay (max 1000) onConflict: (error, mutation) => "overwrite", // or "discard"; default: no conflict check 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. findAll()eiterate()leen solo páginas que el servidor respondió en este dispositivo. Un recorrido continúa mientras cada página diga que hay más, y solo el servidor puede decirlo. Sin conexión, un recorrido que necesita una página que este dispositivo nunca obtuvo falla con el errorOFFLINE(isOfflineError) en lugar de devolver parte de la colección como si fuera toda. En 0.24 se detenía en esa página y devolvía lo que tenía.count()sin conexión necesita un conteo que el servidor dio a este dispositivo para la misma consulta. Las escrituras en cola desde entonces lo mueven: creates, ediciones que llevan una fila dentro o fuera del filtro, y deletes. Sin uno, falla con el errorOFFLINEen lugar de contar las filas que este dispositivo casualmente tiene. En 0.24 devolvía ese número.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é. - Los conflictos son de último escritor gana, por campo, salvo que pases
onConflict. Unupdatereproducido envía los campos que el usuario cambió y nada más, así que los campos que no tocó conservan lo que tenga el servidor. Para un campo que sí cambió, un cambio que otra persona hizo en el servidor mientras estaba sin conexión se sobrescribe cuando se reproduce la cola, y ninguno de los dos usuarios es avisado. CononConflict, una edición cuya versión de fila se conoce se compara con la del servidor, y la aplicación decide. Las creaciones, los borrados y las escrituras masivas nunca se comprueban. - 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. - La papelera necesita el servidor. La base de datos local contiene solo filas vivas, así que
trash.find(),trash.count(),trash.restore()ytrash.purge()(consulta borrado lógico) nunca se responden localmente ni se ponen en cola: sin conexión fallan con el errorOFFLINE. Una restauración o purga de una fila con una escritura aún en cola sincroniza primero la cola, y se rechaza conTRASH_PENDING_WRITEsi la escritura aún no se puede enviar. - Un upsert sobre una clave natural necesita el servidor. Qué fila nombra un destino
onConflictsolo lo puede averiguar el servidor, así queupsert()sobre una clave natural (o sin el id de la fila), y uncreateMany()cononConflictque nunca se envió, se rechazan sin conexión conOFFLINE_UPSERT_UNSUPPORTED. Un upsert sobre la clave primaria, con la clave en cada fila, se pone en cola. - 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 y desempata el resto por el id de fila descendente, exactamente como lo hace el servidor — pero contra la copia de las filas que tiene el cliente, que puede estar desactualizada. - Una ordenación que el cliente no puede reproducir no se aplica. Ordenar texto le corresponde al servidor: PostgreSQL lo ordena según la collation de la base de datos, que este proceso nunca ha recibido, y bajo la collation C
Bananase ordena antes queappledonde un collator de JavaScript dice otra cosa. Cuando una columna de ordenación no se puede comparar numéricamente, las filas conservan el orden en que las envió el servidor, y el resultado se marca comopartialen lugar de presentarse como la página ordenada que se pidió. Cada clave de una ordenación multicolumna tiene que ser reproducible, no solo la primera — una ordenación en la que el lado local solo coincide hasta la segunda columna es una con la que discrepa.
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