چرا معماری Offline-First در ۲۰۲۶ دیگه یک لوکس نیست؟
بیایید رک باشیم — کاربران امروزی اصلاً حوصله ندارند. اگر اپلیکیشن شما وسط یک تونل مترو یا در یک کافه با اینترنت ضعیف از کار بیفتد، کاربر بدون هیچ تعارفی اپ شما را حذف میکند. معماری Offline-First یعنی اپلیکیشن شما اول از همه با دادههای محلی کار میکند و فقط وقتی شبکه در دسترس باشد، با سرور همگامسازی انجام میدهد.
نتیجه؟ سرعت بارگذاری بیشتر، مصرف باتری کمتر و یک تجربه کاربری که واقعاً حس خوبی به کاربر میدهد.
با عرضه Expo SDK 53 و فعال شدن پیشفرض معماری جدید React Native (یعنی Fabric و TurboModules)، کتابخانههایی مثل expo-sqlite حسابی عملکردشان بهتر شده. توی این راهنما، قدمبهقدم یک اپلیکیشن Offline-First کامل با Expo SQLite میسازیم — از عملیات CRUD گرفته تا تشخیص وضعیت شبکه و استراتژی همگامسازی.
پیشنیازها و نصب ابزارها
قبل از شروع، مطمئن بشید که این ابزارها رو نصب دارید:
- Node.js نسخه ۱۸ یا بالاتر
- Expo CLI و EAS CLI
- یک دستگاه فیزیکی یا شبیهساز برای تست
- آشنایی پایه با TypeScript و React Native
ایجاد پروژه و نصب وابستگیها
خب، بریم سراغ کار. اول یک پروژه جدید Expo بسازید و کتابخانههای مورد نیاز رو نصب کنید:
npx create-expo-app offline-notes-app --template blank-typescript
cd offline-notes-app
npx expo install expo-sqlite @react-native-community/netinfo expo-crypto
هر کدوم از این کتابخانهها یک کار مشخص انجام میدهند: expo-sqlite دسترسی به دیتابیس SQLite محلی رو فراهم میکنه، @react-native-community/netinfo وضعیت شبکه رو تشخیص میده و expo-crypto هم برای تولید شناسههای یکتا (UUID) استفاده میشه.
راهاندازی دیتابیس SQLite با الگوی Singleton
اولین قدم اینه که یک اتصال واحد به دیتابیس بسازیم. چرا Singleton؟ چون نمیخوایم هر بار که یک کامپوننت رندر میشه، یک اتصال جدید باز بشه. با این الگو مطمئن میشیم همه بخشهای اپلیکیشن از یک اتصال مشترک استفاده میکنند:
// lib/database.ts
import * as SQLite from 'expo-sqlite';
const DB_NAME = 'offline_notes.db';
let dbInstance: SQLite.SQLiteDatabase | null = null;
export async function getDatabase(): Promise<SQLite.SQLiteDatabase> {
if (!dbInstance) {
dbInstance = await SQLite.openDatabaseAsync(DB_NAME);
await dbInstance.execAsync('PRAGMA journal_mode = WAL');
await dbInstance.execAsync('PRAGMA foreign_keys = ON');
}
return dbInstance;
}
یک نکته مهم: فعال کردن حالت WAL (مخفف Write-Ahead Logging) عملکرد عملیات نوشتن رو بهطور محسوسی بهبود میده و اجازه خواندن و نوشتن همزمان رو فراهم میکنه. صادقانه بگم، بدون WAL تجربه کاربری اپهای دیتابیسمحور خیلی متفاوت میشه.
تعریف جدولها و مایگریشن
حالا باید جدولهای مورد نیاز رو بسازیم. توی این مثال داریم یک اپلیکیشن یادداشتبرداری آفلاین میسازیم (که به نظرم بهترین پروژه تمرینی برای یادگیری Offline-First هست):
// lib/migrations.ts
import { getDatabase } from './database';
export async function runMigrations(): Promise<void> {
const db = await getDatabase();
await db.execAsync(`
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
is_deleted INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
action TEXT NOT NULL CHECK(action IN ('CREATE', 'UPDATE', 'DELETE')),
payload TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
retry_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT
);
CREATE INDEX IF NOT EXISTS idx_sync_queue_entity
ON sync_queue(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_notes_updated
ON notes(updated_at);
`);
}
جدول sync_queue واقعاً قلب تپنده معماری Offline-First ماست. هر تغییری که کاربر در حالت آفلاین انجام بده، به این صف اضافه میشه تا بعد از وصل شدن اینترنت، با سرور sync بشه. بدون این صف، تمام تغییرات آفلاین از بین میرن.
عملیات CRUD با Expo SQLite
خب، بریم سراغ لایه دسترسی به دادهها. یک نکتهای که خیلی مهمه: تمام کوئریها از پارامترهای bind استفاده میکنند. هرگز — و تأکید میکنم هرگز — رشتهها رو مستقیم توی کوئری SQL قرار ندید. SQL Injection هنوز هم یکی از رایجترین آسیبپذیریهاست.
ایجاد یادداشت جدید
// lib/notesRepository.ts
import { getDatabase } from './database';
import * as Crypto from 'expo-crypto';
export interface Note {
id: string;
title: string;
content: string;
created_at: string;
updated_at: string;
is_deleted: number;
}
export async function createNote(title: string, content: string): Promise<Note> {
const db = await getDatabase();
const id = Crypto.randomUUID();
const now = new Date().toISOString();
await db.runAsync(
'INSERT INTO notes (id, title, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
[id, title, content, now, now]
);
// افزودن به صف همگامسازی
await db.runAsync(
'INSERT INTO sync_queue (entity_type, entity_id, action, payload) VALUES (?, ?, ?, ?)',
['note', id, 'CREATE', JSON.stringify({ id, title, content, created_at: now })]
);
return { id, title, content, created_at: now, updated_at: now, is_deleted: 0 };
}
خواندن یادداشتها
export async function getAllNotes(): Promise<Note[]> {
const db = await getDatabase();
return await db.getAllAsync<Note>(
'SELECT * FROM notes WHERE is_deleted = 0 ORDER BY updated_at DESC'
);
}
export async function getNoteById(id: string): Promise<Note | null> {
const db = await getDatabase();
return await db.getFirstAsync<Note>(
'SELECT * FROM notes WHERE id = ? AND is_deleted = 0',
[id]
);
}
بهروزرسانی یادداشت
export async function updateNote(
id: string,
title: string,
content: string
): Promise<void> {
const db = await getDatabase();
const now = new Date().toISOString();
await db.runAsync(
'UPDATE notes SET title = ?, content = ?, updated_at = ? WHERE id = ?',
[title, content, now, id]
);
await db.runAsync(
'INSERT INTO sync_queue (entity_type, entity_id, action, payload) VALUES (?, ?, ?, ?)',
['note', id, 'UPDATE', JSON.stringify({ id, title, content, updated_at: now })]
);
}
حذف نرم (Soft Delete)
export async function deleteNote(id: string): Promise<void> {
const db = await getDatabase();
const now = new Date().toISOString();
// حذف نرم — رکورد در دیتابیس باقی میماند
await db.runAsync(
'UPDATE notes SET is_deleted = 1, updated_at = ? WHERE id = ?',
[now, id]
);
await db.runAsync(
'INSERT INTO sync_queue (entity_type, entity_id, action, payload) VALUES (?, ?, ?, ?)',
['note', id, 'DELETE', JSON.stringify({ id })]
);
}
شاید بپرسید چرا حذف نرم؟ دلیلش سادهست: اگر رکورد رو واقعاً از دیتابیس حذف کنیم، دیگه اطلاعاتی نداریم که به سرور بگیم «این رکورد باید حذف بشه». Soft Delete تضمین میکنه که اطلاعات لازم برای همگامسازی حذف با سرور حفظ بشه.
تشخیص وضعیت شبکه با NetInfo
برای اینکه بفهمیم دستگاه آنلاینه یا آفلاین، از @react-native-community/netinfo استفاده میکنیم. یک هوک سفارشی میسازیم که وضعیت شبکه رو بهصورت Real-time رصد کنه:
// hooks/useNetworkStatus.ts
import { useEffect, useState, useCallback } from 'react';
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
interface NetworkStatus {
isConnected: boolean;
connectionType: string | null;
}
export function useNetworkStatus(): NetworkStatus {
const [status, setStatus] = useState<NetworkStatus>({
isConnected: true,
connectionType: null,
});
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
setStatus({
isConnected: state.isConnected ?? false,
connectionType: state.type,
});
});
return () => unsubscribe();
}, []);
return status;
}
نمایش وضعیت آفلاین به کاربر
یکی از کارهایی که خیلی از توسعهدهندهها فراموش میکنند (و من هم قبلاً همین اشتباه رو میکردم)، اطلاعرسانی به کاربر درباره وضعیت آفلاین بودنه. یک بنر ساده در بالای صفحه کافیه:
// components/OfflineBanner.tsx
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
export function OfflineBanner() {
const { isConnected } = useNetworkStatus();
if (isConnected) return null;
return (
<View style={styles.banner}>
<Text style={styles.text}>
حالت آفلاین — تغییرات پس از اتصال مجدد همگامسازی میشوند
</Text>
</View>
);
}
const styles = StyleSheet.create({
banner: {
backgroundColor: '#f59e0b',
paddingVertical: 8,
paddingHorizontal: 16,
alignItems: 'center',
},
text: {
color: '#fff',
fontSize: 13,
fontWeight: '600',
},
});
استراتژی همگامسازی با سرور
اینجا به مهمترین بخش ماجرا میرسیم. استراتژی همگامسازی چیزیه که یک اپ Offline-First خوب رو از یک اپ معمولی جدا میکنه. ما از یک سیستم صفمحور استفاده میکنیم که عملیات ناموفق رو با Exponential Backoff دوباره امتحان میکنه:
// lib/syncManager.ts
import { getDatabase } from './database';
import NetInfo from '@react-native-community/netinfo';
const API_BASE = 'https://api.example.com';
const MAX_RETRIES = 5;
interface SyncQueueItem {
id: number;
entity_type: string;
entity_id: string;
action: string;
payload: string | null;
retry_count: number;
}
export async function processSyncQueue(): Promise<void> {
const netState = await NetInfo.fetch();
if (!netState.isConnected) return;
const db = await getDatabase();
const pendingItems = await db.getAllAsync<SyncQueueItem>(
'SELECT * FROM sync_queue WHERE retry_count < ? ORDER BY created_at ASC',
[MAX_RETRIES]
);
for (const item of pendingItems) {
try {
await syncItemToServer(item);
// حذف از صف پس از همگامسازی موفق
await db.runAsync('DELETE FROM sync_queue WHERE id = ?', [item.id]);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
await db.runAsync(
'UPDATE sync_queue SET retry_count = retry_count + 1, last_error = ? WHERE id = ?',
[errorMessage, item.id]
);
}
}
}
async function syncItemToServer(item: SyncQueueItem): Promise<void> {
const payload = item.payload ? JSON.parse(item.payload) : {};
const endpoints: Record<string, { method: string; url: string }> = {
CREATE: { method: 'POST', url: `${API_BASE}/${item.entity_type}s` },
UPDATE: { method: 'PUT', url: `${API_BASE}/${item.entity_type}s/${item.entity_id}` },
DELETE: { method: 'DELETE', url: `${API_BASE}/${item.entity_type}s/${item.entity_id}` },
};
const endpoint = endpoints[item.action];
if (!endpoint) throw new Error(`Unknown action: ${item.action}`);
const response = await fetch(endpoint.url, {
method: endpoint.method,
headers: { 'Content-Type': 'application/json' },
body: item.action !== 'DELETE' ? JSON.stringify(payload) : undefined,
});
if (!response.ok) {
throw new Error(`Server returned ${response.status}`);
}
}
اجرای خودکار همگامسازی
همگامسازی باید هم بهصورت دورهای انجام بشه و هم دقیقاً لحظهای که اینترنت برگشت. این دو حالت رو با هم ترکیب میکنیم:
// hooks/useAutoSync.ts
import { useEffect, useRef } from 'react';
import NetInfo from '@react-native-community/netinfo';
import { processSyncQueue } from '../lib/syncManager';
const SYNC_INTERVAL = 30000; // هر ۳۰ ثانیه
export function useAutoSync() {
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
// همگامسازی دورهای
intervalRef.current = setInterval(processSyncQueue, SYNC_INTERVAL);
// همگامسازی هنگام بازگشت اتصال
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected) {
processSyncQueue();
}
});
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
unsubscribe();
};
}, []);
}
استفاده از SQLiteProvider و Context API
یکی از چیزهای خوب expo-sqlite اینه که یک SQLiteProvider آماده داره که با React Context API ادغام شده. این یعنی دسترسی به دیتابیس توی کامپوننتها خیلی تمیز و ساده میشه:
// App.tsx
import React, { Suspense } from 'react';
import { SQLiteProvider } from 'expo-sqlite';
import { ActivityIndicator, View } from 'react-native';
import { runMigrations } from './lib/migrations';
import { NotesList } from './components/NotesList';
import { OfflineBanner } from './components/OfflineBanner';
export default function App() {
return (
<Suspense fallback={<ActivityIndicator size="large" />}>
<SQLiteProvider
databaseName="offline_notes.db"
onInit={async (db) => {
await db.execAsync('PRAGMA journal_mode = WAL');
await db.execAsync('PRAGMA foreign_keys = ON');
}}
useSuspense
>
<View style={{ flex: 1 }}>
<OfflineBanner />
<NotesList />
</View>
</SQLiteProvider>
</Suspense>
);
}
حالا توی کامپوننتهای فرزند میتونید با هوک useSQLiteContext مستقیماً به دیتابیس دسترسی داشته باشید. دیگه نیازی نیست هر بار getDatabase() رو صدا بزنید:
// components/NotesList.tsx
import React, { useEffect, useState } from 'react';
import { FlatList, Text, View, Pressable, StyleSheet } from 'react-native';
import { useSQLiteContext } from 'expo-sqlite';
import { Note } from '../lib/notesRepository';
export function NotesList() {
const db = useSQLiteContext();
const [notes, setNotes] = useState<Note[]>([]);
useEffect(() => {
loadNotes();
}, []);
async function loadNotes() {
const result = await db.getAllAsync<Note>(
'SELECT * FROM notes WHERE is_deleted = 0 ORDER BY updated_at DESC'
);
setNotes(result);
}
return (
<FlatList
data={notes}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.noteCard}>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.content} numberOfLines={2}>
{item.content}
</Text>
</View>
)}
/>
);
}
const styles = StyleSheet.create({
noteCard: {
padding: 16,
marginHorizontal: 16,
marginVertical: 8,
backgroundColor: '#f8fafc',
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 3,
elevation: 2,
},
title: { fontSize: 18, fontWeight: '700', marginBottom: 4 },
content: { fontSize: 14, color: '#64748b' },
});
استراتژیهای حل تعارض (Conflict Resolution)
وقتی چند کاربر یا چند دستگاه همزمان یک رکورد رو ویرایش کنند، تعارض دادهها پیش میاد. این یکی از چالشبرانگیزترین بخشهای Offline-First هست. سه تا استراتژی رایج وجود داره:
۱. آخرین نوشتن برنده است (Last Write Wins)
سادهترین روش — هر تغییری که آخرین updated_at رو داشته باشه، برنده میشه. برای خیلی از اپها همین کافیه، ولی باید بدونید که ممکنه بعضی تغییرات رو از دست بدید:
async function resolveConflictLWW(
localNote: Note,
serverNote: Note
): Promise<Note> {
const localTime = new Date(localNote.updated_at).getTime();
const serverTime = new Date(serverNote.updated_at).getTime();
return serverTime > localTime ? serverNote : localNote;
}
۲. ادغام در سطح فیلد (Field-Level Merge)
این روش هوشمندانهتره. هر فیلد جداگانه بررسی میشه. مثلاً اگه کاربر A عنوان رو عوض کرده و کاربر B محتوا رو، هر دو تغییر حفظ میشن:
async function resolveConflictFieldLevel(
localNote: Note,
serverNote: Note,
baseNote: Note
): Promise<Note> {
return {
...serverNote,
title: localNote.title !== baseNote.title ? localNote.title : serverNote.title,
content: localNote.content !== baseNote.content ? localNote.content : serverNote.content,
updated_at: new Date().toISOString(),
is_deleted: 0,
id: localNote.id,
};
}
۳. حل دستی توسط کاربر
برای اپلیکیشنهای مشارکتی (Collaborative) که دادهها واقعاً حساسن، بهتره تعارضها رو به کاربر نشون بدید و بذارید خودش تصمیم بگیره. بله، پیادهسازیش سختتره، ولی برای بعضی موارد تنها راه درسته.
مقایسه Expo SQLite با سایر راهحلها
یک سوالی که خیلی پرسیده میشه اینه: «کدوم دیتابیس محلی رو انتخاب کنم؟» خب، بستگی به نیاز پروژهتون داره. این جدول کمکتون میکنه:
| ویژگی | Expo SQLite | op-sqlite | WatermelonDB | AsyncStorage |
|---|---|---|---|---|
| عملکرد | خوب | عالی (JSI) | خوب | ضعیف |
| راهاندازی | بسیار آسان | نیاز به لینک دستی | متوسط | بسیار آسان |
| پشتیبانی وب | بله | محدود | خیر | بله |
| کوئری SQL | بله | بله | ORM-محور | خیر |
| مناسب برای | پروژههای Expo | اپهای سنگین داده | دادههای رابطهای + Sync | تنظیمات ساده |
به نظر من اگه از Expo Managed Workflow استفاده میکنید، Expo SQLite بهترین انتخابه — راهاندازی آسون، مستندات خوب و پشتیبانی وب. ولی اگه اپلیکیشنتون با هزاران رکورد سنگین سر و کار داره، op-sqlite بهخاطر استفاده مستقیم از JSI عملکرد بهتری ارائه میده.
بهترین شیوهها و نکات عملکردی
چند تا نکته که از تجربه شخصیام و پروژههای واقعی یاد گرفتم و رعایتشون خیلی مهمه:
بهینهسازی دیتابیس
- فعالسازی WAL: حالت
PRAGMA journal_mode = WALرو همیشه فعال کنید. سرعت نوشتن رو تا ۵ برابر بیشتر میکنه. - ایندکسگذاری: برای ستونهایی که مرتب توی
WHEREیاORDER BYاستفاده میشن، حتماً ایندکس بسازید. - استفاده از Transaction: عملیاتهای نوشتن متعدد رو در یک Transaction قرار بدید. بهجای چندین بار نوشتن روی دیسک، فقط یک بار I/O انجام میشه.
- کوئریهای پارامتری: همیشه از پارامترهای bind استفاده کنید. تأکید میکنم: هرگز رشتهها رو مستقیم توی کوئری الحاق نکنید.
مدیریت حافظه و ذخیرهسازی
- رکوردهای قدیمی رو بهصورت دورهای پاکسازی کنید تا حجم دیتابیس کنترلنشده بزرگ نشه.
- برای دادههای بزرگ مثل تصاویر، فقط مسیر فایل رو توی دیتابیس ذخیره کنید (نه خود فایل).
- صف همگامسازی (
sync_queue) رو بعد از sync موفق حتماً خالی کنید.
امنیت دادهها
- دادههای حساس مثل توکنها رو در
expo-secure-storeذخیره کنید، نه SQLite. این خیلی مهمه. - هنگام خروج کاربر از حساب، کشهای محلی مربوط به اون کاربر رو پاک کنید.
- اگه نیاز به رمزنگاری دیتابیس دارید، از SQLCipher استفاده کنید. هم Expo SQLite و هم op-sqlite ازش پشتیبانی میکنند.
تست کردن اپلیکیشن Offline-First
تست اپهای Offline-First یکم متفاوته چون باید شرایط شبکهای مختلف رو شبیهسازی کنید. بیاید ببینیم چطوری:
// __tests__/notesRepository.test.ts
import * as SQLite from 'expo-sqlite';
import { createNote, getAllNotes, deleteNote } from '../lib/notesRepository';
describe('Notes Repository', () => {
let testDb: SQLite.SQLiteDatabase;
beforeEach(async () => {
// استفاده از دیتابیس در حافظه برای تست
testDb = await SQLite.openDatabaseAsync(':memory:');
await testDb.execAsync(`
CREATE TABLE notes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
is_deleted INTEGER DEFAULT 0
);
CREATE TABLE sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT,
entity_id TEXT,
action TEXT,
payload TEXT,
created_at TEXT,
retry_count INTEGER DEFAULT 0,
last_error TEXT
);
`);
});
test('should create a note and add to sync queue', async () => {
const note = await createNote('Test Title', 'Test Content');
expect(note.title).toBe('Test Title');
const queue = await testDb.getAllAsync(
'SELECT * FROM sync_queue WHERE entity_id = ?',
[note.id]
);
expect(queue.length).toBe(1);
});
});
برای تست شرایط آفلاین واقعی، چند تا راه دارید: توی شبیهساز iOS از مسیر Hardware → Network Condition، توی شبیهساز Android از Extended Controls → Cellular → Network Type و یا از Chrome DevTools که امکان شبیهسازی حالت آفلاین رو داره.
سوالات متداول (FAQ)
آیا Expo SQLite برای اپلیکیشنهای با دادههای حجیم مناسب است؟
برای اکثر اپها با دیتاستهای متوسط (تا دهها هزار رکورد)، Expo SQLite عملکرد خیلی خوبی داره. ولی اگه با صدها هزار رکورد یا عملیاتهای محاسباتی سنگین سر و کار دارید، op-sqlite گزینه بهتریه — مستقیماً از JSI استفاده میکنه و تا ۵ برابر سریعتر عمل میکنه.
تفاوت AsyncStorage با SQLite چیست و کدام را انتخاب کنم؟
AsyncStorage یک ذخیرهساز ساده کلید-مقدار هست که فقط برای دادههای ساده مثل تنظیمات کاربر خوبه. SQLite اما یک دیتابیس رابطهای کامله با پشتیبانی از کوئریهای پیچیده SQL، روابط بین جدولها و تراکنشهای ACID. اگه دادههاتون ساختار دارن یا نیاز به جستجو و فیلتر دارید، بدون شک SQLite انتخاب درسته.
چگونه میتوانم دیتابیس SQLite را هنگام بهروزرسانی اپلیکیشن مایگریت کنم؟
بهترین روش استفاده از یک جدول schema_version توی دیتابیسه. موقع باز شدن اپ، نسخه فعلی اسکیما رو چک کنید و در صورت نیاز، اسکریپتهای مایگریشن رو اجرا کنید. خبر خوب اینه که SQLiteProvider در Expo SQLite یک هندلر onInit داره که دقیقاً جای مناسبی برای این کاره.
آیا اپلیکیشن Offline-First نیاز به سرور بکاند دارد؟
نه لزوماً. اگه اپ شما فقط نیاز به ذخیره دادهها بهصورت محلی داره (مثل یک اپ یادداشت شخصی)، SQLite به تنهایی کافیه و نیازی به سرور نیست. ولی اگه میخواید دادهها بین دستگاهها sync بشن یا پشتیبانگیری ابری داشته باشید، اونوقت بله — یک بکاند لازمه.
بهترین روش مدیریت تعارض دادهها در اپلیکیشن Offline-First چیست؟
برای اکثر اپها، Last Write Wins سادهترین و عملیترین روشه. برای اپهای مشارکتی پیچیدهتر، Field-Level Merge یا حتی CRDTs رو بررسی کنید. نکته کلیدی اینه که از همون اول تایماستمپ updated_at رو توی تمام رکوردها ثبت کنید — بعداً خیلی به دردتون میخوره.