Vous savez ce moment où vous êtes au milieu de nulle part, votre 4G se transforme en « E », et votre application mobile décide de devenir un poids de papier très cher? Imaginez un pilote de livraison qui regarde un routeur de chargement parce que l'application ne peut pas charger son itinéraire sans Internet.Ou un gestionnaire de entrepôt incapable de tirer la liste d'inventaire parce que le serveur est en panne.Ce n'est pas un "comportement gênant", c'est une perte de temps, une perte d'argent et des utilisateurs très en colère. Un tas de nos clients ont frappé ces trous de connectivité – sites de travail distants, événements hors site, étages de entrepôts, centres logistiques. En principe, n’importe où le Wi-Fi va mourir. La solution? applications locales d’abord: traitez les données localement, synchronisez-les quand vous le pouvez. Vos utilisateurs restent heureux, et vous ne recevez pas les appels « cassés de l’application » à minuit. Dans cette petite aventure, je vais vous montrer comment j'ai construit une application mobile locale avec Vous verrez : React Native + RxDB Comment la synchronisation bidirectionnelle fonctionne vraiment sans fondre votre serveur. Comment gérer des situations « amusantes » comme les conflits de données. Ce qu'il ne faut pas oublier lors de la conception de l'une de ces bêtes. Aussi — je vais partager un truc « la dernière mise à jour gagne ». non toujours le bon choix, mais dans notre cas... le baiser du chef. The Stack (a.k.a. My Chosen Weapons) The Stack (a.k.a. mes armes choisies) Pour cette construction, j’ai roulé avec : React Native — magie cross-platform: une base de codes, iOS + Android. react-native-nitro-sqlite – parce que le stockage local sans SQLite est comme une pizza sans fromage. RxDB – DB réactif hors ligne qui joue bien avec la synchronisation. NestJS + TypeORM + PostgreSQL – l’équipe de rêve du backend. Le résultat final : l’application fonctionne hors ligne, se synchronise plus tard et survit aux connexions douloureuses sans avoir un effondrement. Step 1 — Local Storage via SQLite Étape 1 – Stockage local via SQLite SQLite est le choix évident, mais je l'ai emballé avec quelques pouvoirs supplémentaires - la validation et le cryptage - parce que l'intégrité des données et la confidentialité sont importantes (et aussi parce que mon futur moi-même me remerciera). //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 }; Oui, c'est trois couches d'emballage. Comme une oignon. Ou une application d'entreprise avec trop de couches de middleware. Step 2 — Creating the Database Instance Étape 2 – Créer l’instance de base de données Ensuite, j’ai construit une Parce que si vous pensez avoir plusieurs instances de votre DB est une bonne idée... vous aimez probablement aussi les conflits de fusion dans la production. RxDatabaseManager Voici la classe dans toute sa splendeur : //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(); } } Cette petite bête : Mettez en place la DB. Regarder pour Internet comme un ex clingy. Synchroniser dès que nous sommes de retour en ligne. Et oui, il enregistre tout. Je serai reconnaissant la prochaine fois que je déboguerai. Step 3 — Bootstrapping the DB When the App Starts Étape 3 – Démarrer le DB lorsque l’application démarre Lorsque l'application se déclenche, j'accroche mon Pas de magie ici – juste le bon ol’ faire sa chose. RxDatabaseManager useEffect Si quelque chose explose pendant init, je l'enregistre.Parce que prétendre que les erreurs n'existent pas, c'est comment vous obtenez des applications haïtiennes. //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) Étape 4 – Replication des données (a.k.a. Synchronisation sans larmes) Lorsque l’application passe du mode « caverne hors ligne » en ligne, Ceci déclenche la synchronisation des données entre le DB local et le serveur via . onReconnected() replicateRxCollection Voici le gestionnaire de traction de base - RxDB envoie un point de contrôle ( , à Le serveur sait où nous nous sommes arrêtés parce que personne ne veut DB à chaque fois. updatedAt id Tout entier //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, }; }, Sur le serveur, je ne demande que les choses nouvelles / mises à jour depuis le dernier point de contrôle. Parce que la bande passante est précieuse, et aussi est ma patience. //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); } Et puis le serveur renvoie les deux documents et un nouveau point de contrôle brillant: //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 Étape 5 - Retourner les modifications locales sur le serveur RxDB suit également ce qui a changé localement depuis la dernière synchronisation et le pousse vers le haut.Pensez-y comme Dropbox pour les données de votre application - sans les fichiers aléatoires "copie en conflit" (bien... à moins que vous ne gériez mal les conflits). //instance.ts async handler(changeRows) { const response = await api.push({ changeRows }); return response.data; }, Sur le backend, je vérifie chaque changement entrant: S’il n’y a pas de conflit, c’est tout droit. Si la version du serveur est plus récente, je la jette dans la pile de conflits. //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) Étape 6 – Résolution des conflits (la « dernière mise à jour gagne » Gambit) C'est là que les gens compliquent généralement les choses. Construisez une stratégie de fusion de la NASA ou... vous pourriez aller avec «la dernière mise à jour gagne» et l’appeler un jour. pouvait Ce n’est pas toujours le bon geste, mais dans notre cas – simple, rapide, assez bon. //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 Étape 7 - Maintenir l'interface UI en synchronisation Après init, l'état de l'application se retourne vers Pas de boutons de rafraîchissement manuels étranges, pas de « toucher pour recharger » bêtise. 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> ); }; Abonnez-vous aux changements dans la DB, de sorte que l'interface utilisateur se met à jour elle-même.C'est l'un de ces moments "qui fonctionnent comme de la magie" que vous ne devriez pas trop penser - profitez-en. 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 Pensées finales Nous avons créé une application qui : fonctionne hors ligne. Synchroniser automatiquement Peu importe si votre Wi-Fi est en crise existentielle. Nous avons utilisé la même méthode pour , où la connectivité est ... disons simplement "mode d'aventure urbaine." Les pilotes peuvent maintenant terminer les commandes, prendre des photos de preuve de livraison et marquer les livraisons complètes sans internet. Sizl’s dark kitchen riders in Chicago Bien sûr, les cas du monde réel peuvent devenir plus difficiles – plusieurs appareils mettant à jour le même enregistrement, les suppressions de données associées, des ensembles de données massifs. Absolument — sauf si vous aimez les billets de la colère des utilisateurs qui commencent par « J’étais hors ligne et ... » 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