Представьте: вы создали приложение, которое работает ровно тогда, когда у пользователя есть интернет. Нет интернета? Поздравляю, у вас мёртвое приложение и куча недовольных пользователей. Ну или курьер, который стоит как дурак и не может выполнять свою работу, потому что приложение зависло. Бизнес стоит, а вы сидите и ждёте, что всё само решится (нет).
Если хотите перестать выглядеть полными профанами и дать юзерам что-то, что не падает при первом же обрыве связи — welcome to local-first apps. Здесь всё про то, чтобы сделать локальную базу, а синхронизация — это такая себе приятная бонусная функция, а не священный грааль.
Технологический стэк для тех, кто не хочет сойти с ума
React Native — потому что «разрабатывать сразу под всё» звучит круто, пока не начнёшь дебажить 15 платформенных багов;
react-native-nitro-sqlite — потому что единственное, что реально работает локально и не вызывает желание забить всё молотком;
RxDB — чтобы локальное хранение не было кошмаром и можно было хоть как-то мутить реактивные штуки;
NestJS + TypeORM + PostgreSQL — чтобы на сервере никто не писал SQL руками, а всё было «круто» и «типа» удобно.
Настройка локалки. Не забудь про шифрование, чтобы скрыть, как плохо всё устроено
//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';
// Вот тут магия — берём SQLite, валидируем и шифруем.
// Потому что кто-то думает, что шифрование — это круто, а не очередной геморрой.
const sqliteBasics = getSQLiteBasicsQuickSQLite(open);
const storage = getRxStorageSQLiteTrial({ sqliteBasics });
const validatedStorage = wrappedValidateAjvStorage({ storage });
const encryptedStorage = wrappedKeyEncryptionCryptoJsStorage({
storage: validatedStorage,
});
export { encryptedStorage };
Инициализация базы и мучения с синглтоном
//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';
// Для тех, кто любит читать документацию: здесь плагин для апдейтов, чтобы можно было обновлять документы, и плагин для цепочек запросов — кому-то же надо мучиться.
addRxPlugin(RxDBUpdatePlugin);
addRxPlugin(RxDBQueryBuilderPlugin);
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__) {
// В деве включаем режим разработчика, чтобы ловить все баги и падения, которых в продакшене и так хватает.
addRxPlugin(RxDBDevModePlugin);
}
this.db = await createRxDatabase<MyDatabaseCollections>({
name: 'myDb',
storage: encryptedStorage,
multiInstance: false, // Потому что React Native и мульти-инстансы — это сродни пытке.
closeDuplicates: true, // Закрываем дубликаты, чтобы база не рвала ваши нервы.
});
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, // Потому что 20 — это не слишком много, и не слишком мало, а просто «как обычно».
},
push: {
async handler(changeRows) {
console.log('push');
// Вот тут мы пытаемся залить свои локальные обновления на сервер,
// а сервер может быть либо в ударе, либо мёртв — пофиг.
const response = await api.push({ changeRows });
return response.data;
},
},
});
Короче, если хотите, чтобы ваше приложение не сдохло при первом же обрыве связи — учитесь локальному хранению. Если не хотите тратить время на понимание всей этой каши — ну, вы всегда можете послать всех и просто ждать, пока клиенты уйдут к конкурентам.
Настройка синхронизации и ловля ошибок (aka «почему всё ломается»)
private setupConnectivityListener() {
NetInfo.addEventListener((state) => {
this.isOnline = state.isConnected ?? false;
if (this.isOnline) {
// Когда наконец-то интернет появился, запускаем синхронизацию.
this.startReplication();
} else {
// Нет интернета — значит можно спокойно игнорировать жалобы пользователей.
console.warn('Нет связи, работаем в оффлайне. Пользователь доволен? Нет.');
}
});
}
private startReplication() {
if (!this.db) {
console.error('База не инициализирована, а вы уже решили синхронизироваться? Молодец.');
return;
}
// Синхронизация для коллекции users — это такой мини-перформанс с ошибками.
const usersCollection = this.db.users;
if (!usersCollection) {
console.error('Коллекция users не найдена, привет багам.');
return;
}
this.replicateCollection<UserType>({
collection: usersCollection,
replicationId: 'users-replication',
api: usersApi,
});
}
Обработка конфликтов — или как выжить, если два пользователя обновили один и тот же документ одновременно
export const defaultConflictHandler = async (input: {
realMasterState: any;
newDocumentState: any;
realMasterStateLastWriteTime: number;
}) => {
// Тут мы просто говорим: "Держи, вот твоя последняя версия, с которой уже никто не спорит"
// Потому что пытаться решить конфликты вручную — это для слабаков.
return {
isEqual: false,
documentData: input.realMasterState,
};
};
Немного о миграциях, которые никто не пишет
migrationStrategies: {
1: function (oldDoc: UserType) {
// В этом месте мы просто делаем вид, что помним о миграциях,
// но на самом деле откладываем этот вопрос до следующего апдейта, когда всё снова сломается.
return oldDoc;
},
}
Если вы ещё не столкнулись с миграциями, то поздравляю — вы ещё не начали серьёзно мучиться. Рано или поздно придётся писать, и тогда вы вспомните нас с благодарностью.
Совет от старшего разработчика с горьким опытом
Никогда не надейтесь на то, что интернет будет всегда.
Никогда не думайте, что пользователь подождёт, пока ваши данные наконец загрузятся.
Никогда не пытайтесь сделать всё идеально с первого раза — баги и конфликты — это часть игры, особенно с RxDB.
В общем, если вы тут ещё — значит не боитесь жёстких реалий локального хранилища и синхронизации. Наш совет: выстраивайте архитектуру с запасом прочности и готовьтесь к тому, что придётся постоянно допиливать код, чтобы хоть как-то удовлетворить требования бизнес-юзера, который понятия не имеет, что такое база данных и оффлайн.