Представьте: вы создали приложение, которое работает ровно тогда, когда у пользователя есть интернет. Нет интернета? Поздравляю, у вас мёртвое приложение и куча недовольных пользователей. Ну или курьер, который стоит как дурак и не может выполнять свою работу, потому что приложение зависло. Бизнес стоит, а вы сидите и ждёте, что всё само решится (нет).

Если хотите перестать выглядеть полными профанами и дать юзерам что-то, что не падает при первом же обрыве связи — 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.

В общем, если вы тут ещё — значит не боитесь жёстких реалий локального хранилища и синхронизации. Наш совет: выстраивайте архитектуру с запасом прочности и готовьтесь к тому, что придётся постоянно допиливать код, чтобы хоть как-то удовлетворить требования бизнес-юзера, который понятия не имеет, что такое база данных и оффлайн.

Комментарии (0)