PowerSync: un SQLite entero en el dispositivo y un servidor que solo replica
PowerSync mantiene una base SQLite completa en el cliente que se lee y se escribe sin red, alimentada por reglas de sincronización declarativas y vaciada por una cola de subida que tu propio backend decide cómo procesar.
Zero puso al servidor por encima del cliente y Electric renunció a la mitad del problema; PowerSync hace la tercera cosa posible, que es tomarse en serio el dispositivo. Aquí el cliente no tiene una vista materializada ni una copia optimista: tiene una base de datos SQLite completa, con sus índices, sus transacciones y su fichero en disco, sobre la que la aplicación lee y escribe exactamente igual esté conectada o no. El servicio de PowerSync se ocupa solo del camino de bajada —replicar tu base de origen, partirla en trozos y empujar a cada cliente los que le tocan— y el camino de subida te lo devuelve entero, igual que Electric, pero con una diferencia decisiva: te lo devuelve con una cola de escritura persistente ya construida, ordenada y transaccional. Ese reparto explica por qué es el motor con más kilómetros en producción móvil del sector, y también por qué su documentación dedica más páginas a explicarte cómo no bloquear esa cola que a vender sus virtudes.
- Entender por qué el cliente no necesita migraciones aunque el esquema del servidor cambie.
- Escribir flujos de sincronización y saber qué parámetros son de confianza y cuáles no.
- Seguir una escritura desde la cola local hasta tu backend y comprender por qué la cola bloquea.
- Situar el soporte real de bases de origen y de plataformas cliente sin exagerarlo.
Conviene fijar desde el principio la asimetría que define al producto, porque no es la misma que la de las dos lecciones anteriores. PowerSync se declara responsable del camino de lectura y solo de ese; el camino de escritura es tuyo, como en Electric. La diferencia está en que Electric te deja en la mano un problema en blanco y PowerSync te deja un problema con la mitad resuelta y una lista de trampas documentadas.
Una base de datos de verdad, no un caché con sintaxis SQL
Lo primero que hay que entender es que el protocolo de PowerSync sincroniza datos sin esquema. El cliente recibe filas y el esquema que tú declaras se aplica encima mediante vistas de SQLite. De ahí se deriva la propiedad que más trabajo ahorra a lo largo de la vida de una aplicación instalada: no hay migraciones en el cliente. Cambias el esquema declarado, la aplicación reconstruye sus vistas y las consultas siguen funcionando; no hay que escribir scripts de actualización que corran en dispositivos que llevan ocho meses sin abrirse.
// El esquema de cliente se declara, no se migra: las vistas se recrean solas
val AppSchema = Schema(
listOf(
Table(
name = "todos",
columns = listOf(
Column.text("list_id"),
Column.text("description"),
Column.integer("completed"),
),
indexes = listOf(Index("list", listOf(IndexedColumn.descending("list_id")))),
),
),
)
// Consulta reactiva: se reejecuta cuando cambian las tablas de las que depende
fun listasPendientes(): Flow<List<ListItem>> =
db.watch("SELECT * FROM lists WHERE state = ?", listOf("pending")) { fila ->
ListItem(id = fila.getString("id"), name = fila.getString("name"))
}
Hay tres detalles de esa declaración que tienen consecuencias prácticas. El primero es que la columna identificadora es obligatoria, se llama id, es de tipo texto y el SDK la crea por su cuenta: no la declares. En MongoDB, donde el identificador nativo se llama de otra manera, la documentación exige renombrarlo en la consulta del flujo. El segundo es que los tipos disponibles en el cliente son tres —texto, entero y real— porque el sistema de tipos de SQLite es el que es; la documentación advierte de que la mayoría de los tipos de origen aterrizan como texto y de que quizá tengas que convertirlos en tu código. El tercero es que los índices se declaran aquí, en el cliente, y no se heredan de arriba: a diferencia de la réplica de Zero, aquí la base local es tuya y la optimizas tú.
Una consecuencia poco anunciada de este diseño es que el SDK sirve como capa de datos local aunque nunca llames a la conexión. La documentación describe un modo de uso puramente local en el que instancias la base, aplicas el esquema y trabajas contra SQLite sin conectar con ningún servicio. También admite tablas exclusivamente locales, que no se sincronizan nunca y por tanto son el sitio natural para el estado de interfaz que no debe viajar entre dispositivos: filtros activos, borradores, preferencias de pantalla. Poder empezar así y encender la sincronización más tarde es una vía de adopción que ningún motor de los anteriores ofrece con la misma naturalidad.
De cubos declarados a flujos consultables
El sistema original de sincronización parcial se llama reglas de sincronización y se escribe en un fichero YAML de definiciones de cubo: cada definición tiene una consulta de parámetros, que selecciona los valores que individualizan al cliente, y una o varias consultas de datos, que usan esos parámetros para filtrar filas. Ese sistema sigue soportado pero la documentación lo marca ya como heredado y recomienda su sucesor para proyectos nuevos y antiguos.
# Reglas de sincronizacion: el modelo heredado, con cubos explicitos
bucket_definitions:
user_lists:
parameters: SELECT request.user_id() as user_id
data:
- SELECT * FROM lists WHERE owner_id = bucket.user_id
# Flujos de sincronizacion: el modelo recomendado, con consultas y suscripcion
config:
edition: 3
streams:
my_lists:
auto_subscribe: true
query: SELECT * FROM lists WHERE owner_id = auth.user_id()
list_todos:
query: |
SELECT * FROM todos
WHERE list_id = subscription.parameter('list_id')
AND list_id IN (SELECT id FROM lists WHERE owner_id = auth.user_id())
Lo que los flujos añaden no es azúcar sintáctico. Añaden consultas más expresivas —subconsultas, uniones internas y expresiones de tabla comunes, aunque siguen sin admitir agrupación, ordenación ni límite—, añaden suscripción bajo demanda desde el cliente y añaden que los cubos dejen de declararse: el servicio los deriva de tus consultas. La bandera de suscripción automática existe precisamente para reproducir el comportamiento antiguo de sincronizarlo todo por adelantado, que es lo que quiere una aplicación pensada para funcionar desconectada.
Los flujos admiten parámetros de tres procedencias y la diferencia entre ellas es de seguridad, no de estilo. Los parámetros de autenticación salen de las reclamaciones del JWT y están firmados: el cliente no puede falsificarlos. Los parámetros de suscripción y los de conexión los envía el cliente cuando quiere y con el valor que quiere. La documentación es taxativa en que los parámetros controlados por el cliente no deben usarse para control de acceso, y por eso el servicio emite un aviso de seguridad cuando aparecen en una consulta, aviso que solo se silencia con una bandera cuyo nombre —aceptar consultas potencialmente peligrosas— está redactado para que nadie la active por descuido. El patrón correcto es el del ejemplo de arriba: el parámetro del cliente elige qué mira, pero la pertenencia se comprueba con una subconsulta anclada en la identidad firmada.
Del lado del cliente, la suscripción es explícita y tiene una propiedad que conviene aprovechar desde el primer día: un tiempo de vida que mantiene los datos en local después de darse de baja del flujo, por defecto veinticuatro horas. Volver a una pantalla ya visitada no vuelve a descargar nada.
// Suscripcion con caducidad, espera de la primera sincronizacion y baja
const sub = await db.syncStream("list_todos", { list_id: "abc123" })
.subscribe({ ttl: 3600 });
await sub.waitForFirstSync();
sub.unsubscribe();
// En React, la consulta puede esperar a que el flujo haya llegado
const { data } = useQuery("SELECT * FROM todos WHERE list_id = ?", [listId], {
streams: [{ name: "list_todos", parameters: { list_id: listId }, waitForStream: true }],
});
El servicio guarda lo replicado en cubos, que son particiones con historia de operaciones y no fotografías del estado actual. Esa decisión sostiene dos propiedades. La primera es la deduplicación: si un cubo se define por organización en lugar de por usuario, los cincuenta miembros de esa organización sincronizan del mismo cubo y el servicio no guarda estado por cada uno, de modo que el consumo de recursos no crece linealmente con el número de clientes. La segunda es que el cliente puede pedir lo que le falta desde donde se quedó en lugar de repetir una descarga completa. A cambio, la historia crece y hay que compactarla, cosa que el servicio alojado hace por su cuenta. Y hay un límite que aparece en producción antes de lo que parece: cada valor distinto de la expresión de filtro crea un cubo, y el tope por cliente está por defecto en mil.
flowchart LR O[base de origen postgres mongodb mysql] -->|captura de cambios| S[servicio powersync] S --> B[cubos con historia de operaciones] B -->|checkpoint consistente| L[sqlite completo en el dispositivo] L --> R[consultas reactivas] L --> C[cola de subida ordenada] C -->|tu funcion de subida| A[tu backend de siempre] A --> O style L fill:#a6e3a1,color:#11111b style C fill:#f9e2af,color:#11111b style A fill:#89b4fa,color:#11111b
Merece la pena notar dónde vive ese estado, porque es una pieza de operación que se olvida al dimensionar. El almacenamiento de cubos es una base de datos aparte —MongoDB o Postgres, según configures— distinta de tu base de origen. El servicio no consulta el origen mientras emite: replica una vez hacia los cubos y sirve desde ahí.
La cola de subida es de bloqueo, y esa es la funcionalidad
Cada escritura que tu aplicación hace contra SQLite queda registrada automáticamente en una cola local persistente como una de tres operaciones —alta, modificación parcial o baja—, agrupadas por transacción de cliente. Tu conector implementa dos funciones: una que obtiene credenciales y otra que vacía la cola contra tu API. Qué API sea y con qué forma es asunto tuyo; la documentación insiste en que no prescribe formato alguno.
Lo que sí prescribe es la disciplina, y aquí está el corazón operativo de PowerSync. El cliente aplica sus mutaciones locales encima del último punto de control recibido y no avanza al siguiente mientras le quede algo en la cola. De ahí se sigue la propiedad más valiosa del diseño: el cliente nunca resuelve conflictos, porque nunca mezcla su estado pendiente con un estado servidor más nuevo. El proyecto declara que ese comportamiento persigue consistencia causal reforzada y remite a una batería de pruebas de consistencia pública que lo verifica.
De esa misma disciplina se derivan dos obligaciones incómodas. La primera es que tu endpoint de escritura debe ser síncrono respecto a la base de origen: si encolas la escritura para procesarla luego, el cliente creerá que ya está confirmada, el siguiente punto de control no la contendrá y el dato desaparecerá de la pantalla unos segundos para reaparecer después. La segunda es que la cola es de primero en entrar, primero en salir y bloqueante: un error devuelto al cliente detiene la cola entera y, con ella, el avance de puntos de control. Por eso la recomendación oficial es contraintuitiva y hay que leerla dos veces.
La guía recomienda reservar las respuestas de error de servidor para los fallos temporales, donde reintentar tiene sentido. Para los errores de validación y los conflictos de escritura pide explícitamente no responder con un código de error de cliente, porque eso bloquea la cola de subida indefinidamente. La alternativa es aceptar la petición y devolver el problema por otro canal: dentro de la propia respuesta correcta, o escribiendo el error en una tabla que se sincroniza de vuelta al dispositivo para que la interfaz lo muestre y el usuario lo resuelva. Es un patrón que desconcierta al principio y que resulta ser la traducción honesta de un hecho: en una aplicación que escribe sin conexión, el rechazo llega horas después del gesto que lo provocó, y para entonces un código de estado ya no es una respuesta, es un atasco.
Por qué es el más asentado en móvil
El argumento no es de elegancia sino de superficie cubierta. Del lado del servidor, la documentación declara soporte para Postgres mediante replicación lógica, MongoDB mediante flujos de cambios y MySQL mediante su registro binario, y añade SQL Server mediante captura de cambios y Convex mediante deltas de documento, estos dos últimos marcados como fase temprana. Ningún otro motor de este nivel se acerca a esa lista, y no es casualidad: si tu cliente es una base SQLite genérica alimentada por un servicio intermedio, el origen puede ser cualquier cosa que sepa emitir sus cambios.
Del lado del cliente, la lista oficial incluye Dart y Flutter, React Native con Expo, JavaScript para web, Kotlin, Swift, y en distintos grados de madurez declarada Capacitor, Node, .NET, Tauri y Rust. El SDK de Kotlin es multiplataforma de verdad: la documentación declara objetivos Android, JVM y Apple —iOS, macOS, tvOS y watchOS—, con la extensión nativa de SQLite enlazada estáticamente desde la versión 1.12, y marca el objetivo web como experimental e incompleto, con la coordinación entre pestañas todavía sin funcionar.
El cliente es SQLite, no una abstracción
Todo lo que sabes de índices, planes y transacciones se aplica igual. La base sobrevive al cierre de la aplicación y se lee sin red desde el primer arranque tras la sincronización inicial.
Sin esquema en el cable, con esquema en la vista
El protocolo transporta filas sin tipar y el esquema se aplica encima con vistas, de modo que el cliente instalado nunca necesita una migración.
Cubos que deduplican
Un cubo por organización lo comparten todos sus miembros, así que el estado que el servicio guarda no crece linealmente con los usuarios. El tope por cliente está en mil cubos.
Nada de estructuras convergentes
El proyecto rechaza el CRDT por innecesario en un modelo cliente-servidor y usa reconciliación con última escritura ganadora por defecto, personalizable en tu backend.
Sobre ese último punto conviene una precisión, porque la postura del proyecto es más matizada que un rechazo. Su página de filosofía argumenta que sin sincronización entre pares las estructuras convergentes son excesivas, y a la vez documenta cómo usarlas dentro de PowerSync cuando el dominio las pide de verdad —edición de texto colaborativa— guardando el documento convergente como un valor más en la base de origen. Es la respuesta correcta al problema y merece subrayarse: el CRDT no es una arquitectura, es un tipo de dato, y se usa donde hace falta en lugar de imponerlo a toda la aplicación.
Puesto todo junto, el perfil de PowerSync es el de un motor que no intenta ser bonito sino soportar el caso duro: una aplicación instalada, con un usuario que pasa horas sin cobertura, contra una base de datos que ya existía antes que la aplicación y que probablemente le sobreviva. Ese caso obliga a resolver cosas que en la web se pueden posponer indefinidamente —persistencia real de lo pendiente, orden estable de las escrituras, esquemas que evolucionan sin tocar el dispositivo— y explica tanto sus virtudes como sus asperezas. Lo que se paga a cambio queda registrado en la propia documentación: un servicio con estado más que operar, un almacenamiento de cubos que dimensionar y una cola cuyo mal manejo se nota en el usuario y no en un panel.
- Levanta un flujo con suscripción automática para los datos del usuario y otro bajo demanda con un parámetro de suscripción.
- Comprueba que el flujo bajo demanda incluye una subconsulta anclada en la identidad firmada, y quítala para ver qué aviso emite el servicio.
- Escribe con el dispositivo en modo avión, cierra la aplicación, ábrela y verifica que la cola sigue ahí y en orden.
- Haz que tu endpoint responda con un error de cliente a una validación y observa cómo se detiene la cola y deja de avanzar el punto de control.
- Sustituye ese error por una respuesta correcta que escriba el fallo en una tabla sincronizada, y píntalo en la interfaz.
- Mide el número de cubos que genera tu flujo más ramificado y calcula a cuántos usuarios te quedas de rozar el tope.