¿Sabes ese momento cuando estás en medio de la nada, tu 4G se convierte en “E”, y tu aplicación móvil decide convertirse en un peso de papel muy caro? Imagínese a un conductor de entrega mirando a un spinner de carga porque la aplicación no puede cargar su ruta sin internet.O un gerente de almacén incapaz de sacar la lista de inventario porque el servidor está abajo. Un montón de nuestros clientes se encuentran con estos agujeros de conectividad: sitios de trabajo remotos, eventos fuera del lugar, pisos de almacén, centros logísticos.Básicamente, dondequiera que el Wi-Fi vaya a morir.La solución? aplicaciones locales primeras: procesar datos localmente, sincronizar cuando pueda. En esta pequeña aventura, te mostraré cómo he construido una aplicación móvil local con Usted va a ver: React Native + RxDB Cómo funciona realmente la sincronización bidireccional sin derretir su servidor. Cómo manejar situaciones “divertidas” como los conflictos de datos. Qué no olvidar al diseñar una de estas bestias. También — voy a compartir un truco de “última actualización gana”. no siempre la elección correcta, pero en nuestro caso... el beso del chef. The Stack (a.k.a. My Chosen Weapons) La pila (a.k.a. Mis armas elegidas) Para esta construcción, he rodado con: React Native — magia de plataformas: una base de código, iOS + Android. react-native-nitro-sqlite – porque el almacenamiento local sin SQLite es como la pizza sin queso. RxDB - primer DB offline, reactivo que juega bien con la sincronización. NestJS + TypeORM + PostgreSQL — el equipo de sueños de backend. El resultado final: la aplicación funciona fuera de línea, se sincroniza más tarde y sobrevive a conexiones desconocidas sin tener un derrumbe. Step 1 — Local Storage via SQLite Paso 1 – Almacenamiento local a través de SQLite SQLite es la elección obvia, pero lo envuelto con algunos poderes adicionales - validación y cifrado - porque la integridad de los datos y la privacidad son importantes (y también porque mi futuro yo me agradecerá). //storage.ts import { getRxStorageSQLiteTrial, getSQLiteBasicsQuickSQLite, } from 'rxdb/plugins/storage-sqlite'; import { open } from 'react-native-nitro-sqlite'; import { wrappedValidateAjvStorage } from 'rxdb/plugins/validate-ajv'; import { wrappedKeyEncryptionCryptoJsStorage } from 'rxdb/plugins/encryption-crypto-js'; const sqliteBasics = getSQLiteBasicsQuickSQLite(open); const storage = getRxStorageSQLiteTrial({ sqliteBasics }); const validatedStorage = wrappedValidateAjvStorage({ storage }); const encryptedStorage = wrappedKeyEncryptionCryptoJsStorage({ storage: validatedStorage, }); export { encryptedStorage }; Sí, eso es tres capas de envoltura, como una cebolla, o una aplicación empresarial con demasiadas capas de middleware. Step 2 — Creating the Database Instance Paso 2 - Crear la instancia de la base de datos A continuación, construimos a Porque si piensas que tener múltiples instancias de tu DB es una buena idea... probablemente también disfrutes de los conflictos de fusión en la producción. RxDatabaseManager Aquí está la clase en toda su gloria: //Instance.ts import { addRxPlugin, createRxDatabase, RxDatabase, WithDeleted } from 'rxdb'; import { RxDBDevModePlugin } from 'rxdb/plugins/dev-mode'; import { replicateRxCollection } from 'rxdb/plugins/replication'; import NetInfo from '@react-native-community/netinfo'; import { CheckPointType, MyDatabaseCollections, ReplicateCollectionDto, } from './types.ts'; import { encryptedStorage } from './storage.ts'; import { defaultConflictHandler } from './utills.ts'; import { usersApi, userSchema, UserType } from '../features/users'; import { RxDBMigrationSchemaPlugin } from 'rxdb/plugins/migration-schema'; import { RxDBQueryBuilderPlugin } from 'rxdb/plugins/query-builder'; import { RxDBUpdatePlugin } from 'rxdb/plugins/update'; // for support query.update method addRxPlugin(RxDBUpdatePlugin); // for support chained query methods addRxPlugin(RxDBQueryBuilderPlugin); // for enabling data migration addRxPlugin(RxDBMigrationSchemaPlugin); export class RxDatabaseManager { private static instance: RxDatabaseManager; private db: RxDatabase<MyDatabaseCollections> | null = null; private isOnline = false; private constructor() {} public static getInstance(): RxDatabaseManager { if (!RxDatabaseManager.instance) { RxDatabaseManager.instance = new RxDatabaseManager(); } return RxDatabaseManager.instance; } public async init(): Promise<RxDatabase<MyDatabaseCollections>> { if (this.db) return this.db; if (__DEV__) { // needs to be added in dev mode addRxPlugin(RxDBDevModePlugin); } this.db = await createRxDatabase<MyDatabaseCollections>({ name: 'myDb', storage: encryptedStorage, multiInstance: false, // No multi-instance support for React Native closeDuplicates: true, // Close duplicate database instances }); await this.db.addCollections({ users: { schema: userSchema, conflictHandler: defaultConflictHandler, migrationStrategies: { // 1: function (oldDoc: UserType) {}, }, }, }); this.setupConnectivityListener(); return this.db; } public getDb(): RxDatabase<MyDatabaseCollections> { if (!this.db) { throw new Error('Database not initialized. Call init() first.'); } return this.db; } private replicateCollection<T>(dto: ReplicateCollectionDto<T>) { const { collection, replicationId, api } = dto; const replicationState = replicateRxCollection<WithDeleted<T>, number>({ collection: collection, replicationIdentifier: replicationId, pull: { async handler(checkpointOrNull: unknown, batchSize: number) { const typedCheckpoint = checkpointOrNull as CheckPointType; const updatedAt = typedCheckpoint ? typedCheckpoint.updatedAt : 0; const id = typedCheckpoint ? typedCheckpoint.id : ''; const response = await api.pull({ updatedAt, id, batchSize }); return { documents: response.data.documents, checkpoint: response.data.checkpoint, }; }, batchSize: 20, }, push: { async handler(changeRows) { console.log('push'); const response = await api.push({ changeRows }); return response.data; }, }, }); replicationState.active$.subscribe(v => { console.log('Replication active$:', v); }); replicationState.canceled$.subscribe(v => { console.log('Replication canceled$:', v); }); replicationState.error$.subscribe(async error => { console.error('Replication error$:', error); }); } private async startReplication() { const db = this.getDb(); this.replicateCollection<UserType>({ collection: db.users, replicationId: '/users/sync', api: { push: usersApi.push, pull: usersApi.pull, }, }); } private setupConnectivityListener() { NetInfo.addEventListener(state => { const wasOffline = !this.isOnline; this.isOnline = Boolean(state.isConnected); if (this.isOnline && wasOffline) { this.onReconnected(); } }); } private async onReconnected() { this.startReplication(); } } Esta pequeña bestia: Coloque la DB. Los relojes para internet como un ex clingy. Se sincroniza tan pronto como estemos de vuelta en línea. Y sí, lo registra todo. El futuro me agradecerá la próxima vez que debugue. Step 3 — Bootstrapping the DB When the App Starts Paso 3 – Bootstrapping el DB cuando se inicia la aplicación Cuando la aplicación se lanza, pongo mi No hay magia aquí — sólo el buen ol’ hacer su cosa. RxDatabaseManager useEffect Si algo explota durante init, lo logro. Porque fingir que los errores no existen es cómo se obtienen aplicaciones asombradas. //App.tsx useEffect(() => { const init = async () => { const dbManager = RxDatabaseManager.getInstance(); dbManager .init() .then(() => { setAppStatus('ready'); }) .catch((error) => { console.log('Error initializing database:', error); setAppStatus('error'); }); }; init(); }, []); Step 4 — Data Replication (a.k.a. Syncing Without Tears) Paso 4 – Replicación de datos (a.k.a. sincronización sin lágrimas) Cuando la aplicación sale de "modo caverna fuera de línea" de vuelta en línea, Esto desactiva la sincronización de datos entre el DB local y el servidor a través de . onReconnected() replicateRxCollection Aquí está el controlador de arrastre básico — RxDB envía un punto de control ( , de ) para que el servidor sepa dónde nos dejamos, porque nadie quiere DB cada vez. updatedAt id totalidad //instance.ts async handler(checkpointOrNull: unknown, batchSize: number) { const typedCheckpoint = checkpointOrNull as CheckPointType; const updatedAt = typedCheckpoint ? typedCheckpoint.updatedAt : 0; const id = typedCheckpoint ? typedCheckpoint.id : ''; const response = await api.pull({ updatedAt, id, batchSize }); return { documents: response.data.documents, checkpoint: response.data.checkpoint, }; }, En el servidor, solo interrogo las cosas nuevas / actualizadas desde el último punto de control. Porque el ancho de banda es precioso, y así es mi paciencia. //users.query-repository.ts async pull(dto: PullUsersDto): Promise<UserViewDto[]> { const { id, updatedAt, batchSize } = dto; const users = await this.users .createQueryBuilder('user') .where('user.updatedAt > :updatedAt', { updatedAt }) .orWhere('user.updatedAt = :updatedAt AND user.id > :id', { updatedAt, id, }) .orderBy('user.updatedAt', 'ASC') .addOrderBy('user.id', 'ASC') .limit(batchSize) .getMany(); return users.map(UserViewDto.mapToView); } Y entonces el servidor envía de vuelta los dos documentos y un nuevo punto de control brillante: //user.service.ts async pull(dto: PullUsersDto) { const users = await this.usersRepository.pull(dto); const newCheckpoint = users.length === 0 ? { id: dto.id, updatedAt: dto.updatedAt } : { id: users.at(-1)!.id, updatedAt: users.at(-1)!.updatedAt, }; return { documents: users, checkpoint: newCheckpoint, }; } Step 5 — Pushing Local Changes Back to the Server Paso 5 - Impulsar los cambios locales de vuelta al servidor RxDB también rastrea lo que ha cambiado localmente desde la última sincronización y lo impulsa.Piensa en ello como Dropbox para los datos de tu aplicación - sin los archivos aleatorios "conflictos de copia" (bien... a menos que trates mal los conflictos). //instance.ts async handler(changeRows) { const response = await api.push({ changeRows }); return response.data; }, En el backend, comprobo cada cambio entrante: Si no hay conflicto, entra directamente. Si la versión del servidor es más reciente, la dejo en la pila de conflictos. //user.service.ts async push(dto: PushUsersDto) { const changeRows = dto.changeRows; const existingUsers = await this.usersRepository.findByIds( changeRows.map((changeRow) => changeRow.newDocumentState.id), ); const existingMap = new Map(existingUsers.map((user) => [user.id, user])); const toSave: UserViewDto[] = []; const conflicts: UserViewDto[] = []; for (const changeRow of changeRows) { const newDoc = changeRow.newDocumentState; const existing = existingMap.get(newDoc.id); const isConflict = existing && existing.updatedAt > newDoc?.updatedAt; if (isConflict) { conflicts.push(existing); } else { toSave.push(newDoc); } if (toSave.length > 0) { await this.usersRepository.save(toSave); } } return conflicts; } Step 6 — Conflict Resolution (The “Last Update Wins” Gambit) Paso 6 – Resolución de conflictos (La “Última actualización gana” Gambit) Aquí es donde la gente suele sobrecomplicar las cosas. Construya una estrategia de fusión de clase de la NASA. O... podrías ir con "las últimas actualizaciones ganan" y llamarlo un día. Podría No siempre es el movimiento correcto, pero en nuestro caso - simple, rápido, lo suficientemente bueno. //utills.ts export const defaultConflictHandler: RxConflictHandler<{ updatedAt: number; }> = { isEqual(a, b) { return a.updatedAt === b.updatedAt; }, resolve({ assumedMasterState, realMasterState, newDocumentState }) { return Promise.resolve(realMasterState); }, }; Step 7 — Keeping the UI in Sync Paso 7 - Mantener la interfaz de usuario en sincronización Después de init, el estado de la aplicación se Sin extraños botones de refresco manual, sin “tap para cargar de nuevo” tonterías. Ready //UsersScreen.tsx export const UsersScreen = () => { const users = useUsersSelector({ sort: [{ updatedAt: 'desc' }], }); const { createUser, deleteUser, updateUser } = useUsersService(); return ( <View style={styles.container}> {users.map(user => ( <Text key={user.id}>{user.name}</Text> ))} <Button title={'Create new user'} onPress={createUser} /> <Button disabled={users.length === 0} title={'Update user'} onPress={() => updateUser(users[0].id)} /> <Button disabled={users.length === 0} title={'Delete user'} onPress={() => deleteUser(users[0].id)} /> </View> ); }; Se suscribe a los cambios en el DB, por lo que la interfaz de usuario se actualiza a sí misma. Este es uno de esos momentos de "función mágica" que no debes pensar demasiado - simplemente disfruta. useUsersSelector //user.selector.tsx export const useUsersSelector = (query?: MangoQuery<UserType>) => { const userModel = RxDatabaseManager.getInstance().getDb().users; const [users, setUsers] = useState<UserType[]>([]); useEffect(() => { const subscription = userModel.find(query).$.subscribe(result => { setUsers(result); }); return () => subscription.unsubscribe(); }, [userModel, query]); return users; }; Final Thoughts Pensamientos finales Hemos creado una app que: Funciona fuera de línea. Se sincroniza automáticamente. No importa si su Wi-Fi está teniendo una crisis existencial. Utilizamos el mismo dispositivo para , donde la conectividad es... digamos simplemente “modo de aventura urbana”. los pilotos ahora pueden completar pedidos, tomar fotos de prueba de entrega y marcar entregas completas sin internet. Sizl’s dark kitchen riders in Chicago Ciertamente, los casos del mundo real pueden ser más complicados: varios dispositivos actualizan el mismo registro, borran datos relacionados, conjuntos de datos masivos.Pero el patrón se mantiene.Sólo tienes que ampliarlo con una resolución de conflictos más inteligente y una arquitectura más flexible. Absolutamente — a menos que disfrute de los boletos de rabia de los usuarios que comienzan con “Yo estaba fuera de línea y...” Click the link in my bio for more info on this one & other projects! To check out our open source admin panel on React see our . Github GitHub