Offline-first в React Native: конфликт-безопасная синхронизация с SQLite

#react-native#sqlite#offline-first#data-sync

TL;DR

Реализация надежной offline-first стратегии в React Native требует:

  1. Tombstone-подхода вместо hard-delete
  2. Outbox-паттерна для гарантированной доставки изменений
  3. Детерминированного алгоритма слияния на основе updated_at и deleted_at
  4. Строгой последовательности pull -> push -> ack

Введение: вызовы offline-first разработки

В современных мобильных приложениях offline-режим — не опция, а must-have. Особенно в таких сценариях, как fitness-трекеры, где:

Классические проблемы, с которыми сталкиваются разработчики:

Core Architecture

1. Схема базы данных

// db/schema.ts
import * as SQLite from "expo-sqlite";

export async function initDb(db: SQLite.SQLiteDatabase) {
  await db.execAsync(`PRAGMA journal_mode = WAL;`);
  await db.execAsync(`PRAGMA foreign_keys = ON;`);

  await db.execAsync(`
    CREATE TABLE IF NOT EXISTS sets (
      id TEXT PRIMARY KEY,
      workout_id TEXT NOT NULL,
      exercise_id TEXT NOT NULL,
      reps INTEGER NOT NULL,
      weight REAL NOT NULL,
      updated_at INTEGER NOT NULL,
      deleted_at INTEGER
    );

    CREATE TABLE IF NOT EXISTS outbox (
      id TEXT PRIMARY KEY,
      entity TEXT NOT NULL,
      entity_id TEXT NOT NULL,
      op TEXT NOT NULL,
      payload TEXT NOT NULL,
      created_at INTEGER NOT NULL,
      attempts INTEGER NOT NULL DEFAULT 0,
      last_error TEXT
    );
  `);
}

Ключевые элементы:

2. Работа с данными

// db/sets.ts
export async function deleteSet(db: SQLite.SQLiteDatabase, setId: string) {
  const now = Date.now();
  
  await db.runAsync(
    `UPDATE sets SET deleted_at = ?, updated_at = ? WHERE id = ?`,
    [now, now, setId]
  );

  await enqueueOutbox(db, {
    entity: "sets",
    entity_id: setId,
    op: "delete",
    payload: { id: setId, deleted_at: now, updated_at: now },
  });
}

Важные нюансы:

  1. Всегда сначала изменяем данные, затем добавляем в outbox
  2. Для delete используем soft-delete с timestamp
  3. Payload содержит всю необходимую метаинформацию

Алгоритм синхронизации

Sync Loop Implementation

// sync/syncNow.ts
let syncInFlight: Promise<void> | null = null;

export function syncNow(db: SQLite.SQLiteDatabase) {
  if (!syncInFlight) {
    syncInFlight = (async () => {
      try {
        await pull(db);
        await push(db);
      } finally {
        syncInFlight = null;
      }
    })();
  }
  return syncInFlight;
}

Критически важные моменты:

Conflict Resolution

// sync/merge.ts
export async function mergeRemoteSet(db: SQLite.SQLiteDatabase, remote: RemoteSet) {
  const local = await db.getFirstAsync<{
    updated_at: number;
    deleted_at: number | null;
  }>(`SELECT updated_at, deleted_at FROM sets WHERE id = ?`, [remote.id]);

  // Remote tombstone always wins
  if (remote.deleted_at !== null) {
    if (local?.deleted_at === null || remote.updated_at >= local.updated_at) {
      await applyTombstone(db, remote);
    }
    return;
  }

  // Local tombstone blocks remote upsert
  if (local?.deleted_at !== null) return;

  // Last write wins for non-deleted rows
  if (!local || remote.updated_at > local.updated_at) {
    await upsertRemoteRow(db, remote);
  }
}

Правила слияния:

  1. Tombstone всегда побеждает: если удалено на одном устройстве, должно удалиться везде
  2. Last-write-wins: для конкурирующих изменений
  3. Невозможность воскрешения: удаленные элементы не могут быть восстановлены новыми изменениями

Практические рекомендации

Производительность

  1. Индексы для часто используемых полей:

    CREATE INDEX IF NOT EXISTS idx_sets_updated ON sets(updated_at);
    CREATE INDEX IF NOT EXISTS idx_outbox_created ON outbox(created_at);
    
  2. Батчинг при синхронизации:

    const items = await db.getAllAsync(
      `SELECT id, entity, entity_id, op, payload
       FROM outbox
       ORDER BY created_at ASC
       LIMIT 100`
    );
    
  3. WAL-режим SQLite для конкурентного доступа:

    await db.execAsync(`PRAGMA journal_mode = WAL;`);
    

Отладка синхронизации

  1. Логирование всех операций синхронизации
  2. Верификация через физические устройства
  3. Stress-тесты с частыми переключениями airplane mode

Заключение

Реализация надежной offline-стратегии требует дисциплины в трех областях:

  1. Data Modeling:

    • UUID вместо автоинкремента
    • Обязательные updated_at/deleted_at
    • Outbox как source of truth
  2. Conflict Resolution:

    • Детерминированные правила слияния
    • Tombstone вместо hard-delete
    • Запрет на воскрешение удаленных данных
  3. Sync Mechanics:

    • Гарантированный порядок операций
    • Идемпотентность всех операций
    • Транзакционность изменений

Предложенное решение прошло проверку в production-среде с интенсивным использованием и демонстрирует стабильную работу даже в условиях плохого соединения. Ключевой инсайт: относитесь к удалению как к первоклассной операции, а не как к вторичному состоянию.


Источник: https://dev.to/sathish_daggula/react-native-offline-first-conflict-safe-sqlite-sync-549a