add-offline-sync
Enable offline data synchronization with WatermelonDB or other solutions.
Overview
The add-offline-sync command implements offline-first data synchronization for your React Native app, allowing users to work seamlessly without internet connectivity and sync changes when back online.
Usage
bash
/template add-offline-sync <sync-solution> [options]Parameters
<sync-solution>- Synchronization solution:watermelondb,realm,sqlite
Options
--models- Comma-separated list of data models to sync--conflict-resolution- Strategy:last-write-wins,manual,server-wins--background-sync- Enable background synchronization--encryption- Encrypt local database
Examples
Basic WatermelonDB Setup
bash
/template add-offline-sync watermelondb --models "User,Post,Comment"Full Offline System
bash
/template add-offline-sync watermelondb --models "all" --background-sync --encryptionWith Conflict Resolution
bash
/template add-offline-sync watermelondb --conflict-resolution manualWhat It Creates
Offline Sync Structure
src/
├── database/
│ ├── schema.ts # Database schema
│ ├── models/ # Data models
│ │ ├── User.ts
│ │ ├── Post.ts
│ │ └── Comment.ts
│ ├── sync/
│ │ ├── sync.ts # Sync logic
│ │ ├── conflicts.ts # Conflict resolution
│ │ ├── queue.ts # Sync queue
│ │ └── background.ts # Background sync
│ ├── migrations.ts # Schema migrations
│ └── index.ts # Database setupWatermelonDB Schema
typescript
// src/database/schema.ts
import { appSchema, tableSchema } from '@nozbe/watermelondb';
export const schema = appSchema({
version: 1,
tables: [
tableSchema({
name: 'users',
columns: [
{ name: 'server_id', type: 'string', isIndexed: true },
{ name: 'email', type: 'string', isIndexed: true },
{ name: 'name', type: 'string' },
{ name: 'avatar_url', type: 'string', isOptional: true },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
{ name: 'last_synced_at', type: 'number', isOptional: true },
{ name: '_status', type: 'string' }, // created, updated, deleted, synced
],
}),
tableSchema({
name: 'posts',
columns: [
{ name: 'server_id', type: 'string', isIndexed: true },
{ name: 'user_id', type: 'string', isIndexed: true },
{ name: 'title', type: 'string' },
{ name: 'content', type: 'string' },
{ name: 'is_published', type: 'boolean' },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
{ name: 'last_synced_at', type: 'number', isOptional: true },
{ name: '_status', type: 'string' },
],
}),
],
});Data Models
typescript
// src/database/models/Post.ts
import { Model } from '@nozbe/watermelondb';
import { field, date, relation, action, writer } from '@nozbe/watermelondb/decorators';
export default class Post extends Model {
static table = 'posts';
static associations = {
users: { type: 'belongs_to', key: 'user_id' },
comments: { type: 'has_many', foreignKey: 'post_id' },
};
@field('server_id') serverId!: string;
@field('title') title!: string;
@field('content') content!: string;
@field('is_published') isPublished!: boolean;
@date('created_at') createdAt!: Date;
@date('updated_at') updatedAt!: Date;
@date('last_synced_at') lastSyncedAt?: Date;
@field('_status') _status!: string;
@relation('users', 'user_id') user!: Relation<User>;
@action async markAsSynced() {
await this.update((post) => {
post._status = 'synced';
post.lastSyncedAt = new Date();
});
}
@action async updateLocally(changes: Partial<Post>) {
await this.update((post) => {
Object.assign(post, changes);
post._status = 'updated';
post.updatedAt = new Date();
});
}
get needsSync() {
return ['created', 'updated', 'deleted'].includes(this._status);
}
}Sync Implementation
typescript
// src/database/sync/sync.ts
import { synchronize } from '@nozbe/watermelondb/sync';
import { database } from '../index';
export async function sync() {
await synchronize({
database,
pullChanges: async ({ lastPulledAt, schemaVersion, migration }) => {
const response = await api.post('/sync/pull', {
lastPulledAt,
schemaVersion,
migration,
});
return {
changes: response.changes,
timestamp: response.timestamp,
};
},
pushChanges: async ({ changes, lastPulledAt }) => {
const response = await api.post('/sync/push', {
changes,
lastPulledAt,
});
if (response.conflicts) {
await handleConflicts(response.conflicts);
}
},
migrationsEnabledAtVersion: 1,
});
}
// Server-side sync endpoint
export async function handleSyncPull(req: Request) {
const { lastPulledAt } = req.body;
const timestamp = Date.now();
const changes = {
users: {
created: await getCreatedUsers(lastPulledAt),
updated: await getUpdatedUsers(lastPulledAt),
deleted: await getDeletedUserIds(lastPulledAt),
},
posts: {
created: await getCreatedPosts(lastPulledAt),
updated: await getUpdatedPosts(lastPulledAt),
deleted: await getDeletedPostIds(lastPulledAt),
},
};
return { changes, timestamp };
}Conflict Resolution
typescript
// src/database/sync/conflicts.ts
export interface Conflict {
table: string;
localRecord: any;
remoteRecord: any;
localUpdatedAt: number;
remoteUpdatedAt: number;
}
export async function handleConflicts(conflicts: Conflict[]) {
for (const conflict of conflicts) {
const resolution = await resolveConflict(conflict);
switch (resolution.strategy) {
case 'last-write-wins':
await applyLastWriteWins(conflict);
break;
case 'server-wins':
await applyServerWins(conflict);
break;
case 'client-wins':
await applyClientWins(conflict);
break;
case 'manual':
await showConflictDialog(conflict);
break;
}
}
}
async function applyLastWriteWins(conflict: Conflict) {
if (conflict.localUpdatedAt > conflict.remoteUpdatedAt) {
// Keep local version, mark for push
await markForSync(conflict.table, conflict.localRecord.id);
} else {
// Apply remote version
await applyRemoteChanges(conflict.table, conflict.remoteRecord);
}
}
// Manual conflict resolution UI
export function ConflictResolutionDialog({ conflict, onResolve }: Props) {
return (
<Modal>
<Text>Conflict detected in {conflict.table}</Text>
<View>
<Text>Your version (edited {formatTime(conflict.localUpdatedAt)})</Text>
<RecordPreview record={conflict.localRecord} />
</View>
<View>
<Text>Server version (edited {formatTime(conflict.remoteUpdatedAt)})</Text>
<RecordPreview record={conflict.remoteRecord} />
</View>
<Button title="Keep Mine" onPress={() => onResolve('client-wins')} />
<Button title="Use Theirs" onPress={() => onResolve('server-wins')} />
<Button title="Merge" onPress={() => onResolve('merge')} />
</Modal>
);
}Background Sync
typescript
// src/database/sync/background.ts
import BackgroundFetch from 'react-native-background-fetch';
export function setupBackgroundSync() {
BackgroundFetch.configure({
minimumFetchInterval: 15, // minutes
forceAlarmManager: false,
stopOnTerminate: false,
startOnBoot: true,
enableHeadless: true,
}, async (taskId) => {
console.log('[BackgroundSync] Starting sync...');
try {
await sync();
BackgroundFetch.finish(taskId);
} catch (error) {
console.error('[BackgroundSync] Sync failed:', error);
BackgroundFetch.finish(taskId);
}
}, (error) => {
console.error('[BackgroundSync] Failed to configure:', error);
});
// Check sync status
BackgroundFetch.status((status) => {
switch (status) {
case BackgroundFetch.STATUS_RESTRICTED:
console.log('BackgroundSync restricted');
break;
case BackgroundFetch.STATUS_DENIED:
console.log('BackgroundSync denied');
break;
case BackgroundFetch.STATUS_AVAILABLE:
console.log('BackgroundSync available');
break;
}
});
}Sync Queue
typescript
// src/database/sync/queue.ts
export class SyncQueue {
private queue: SyncOperation[] = [];
private isProcessing = false;
async add(operation: SyncOperation) {
this.queue.push(operation);
if (!this.isProcessing) {
await this.process();
}
}
private async process() {
this.isProcessing = true;
while (this.queue.length > 0) {
const operation = this.queue.shift()!;
try {
await this.executeOperation(operation);
} catch (error) {
// Retry logic
if (operation.retries < 3) {
operation.retries++;
this.queue.push(operation);
} else {
await this.handleFailedOperation(operation, error);
}
}
}
this.isProcessing = false;
}
private async executeOperation(operation: SyncOperation) {
switch (operation.type) {
case 'create':
await api.post(`/${operation.table}`, operation.data);
break;
case 'update':
await api.put(`/${operation.table}/${operation.id}`, operation.data);
break;
case 'delete':
await api.delete(`/${operation.table}/${operation.id}`);
break;
}
// Mark as synced
await markAsSynced(operation.table, operation.id);
}
}Offline Detection
Network Status
typescript
// src/hooks/useNetworkStatus.ts
import NetInfo from '@react-native-community/netinfo';
export function useNetworkStatus() {
const [isOnline, setIsOnline] = useState(true);
const [networkType, setNetworkType] = useState<string>();
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
setIsOnline(state.isConnected ?? false);
setNetworkType(state.type);
if (state.isConnected && !isOnline) {
// Back online - trigger sync
sync().catch(console.error);
}
});
return unsubscribe;
}, [isOnline]);
return { isOnline, networkType };
}
// Offline indicator component
export function OfflineIndicator() {
const { isOnline } = useNetworkStatus();
if (isOnline) return null;
return (
<View style={styles.offlineBar}>
<Icon name="cloud-offline" />
<Text>You're offline - changes will sync when connected</Text>
</View>
);
}Data Encryption
Encrypted Database
typescript
// src/database/encryption.ts
import { Database } from '@nozbe/watermelondb';
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite';
import { encrypt } from 'react-native-sqlite-2';
export function createEncryptedDatabase(password: string) {
const adapter = new SQLiteAdapter({
schema,
dbName: 'myapp',
migrations,
jsi: true,
onSetUpError: (error) => {
console.error('Database setup error:', error);
},
});
// Encrypt database
encrypt(adapter.dbName, password);
return new Database({
adapter,
modelClasses: [User, Post, Comment],
});
}Testing
Sync Testing
typescript
describe('Offline Sync', () => {
it('queues changes when offline', async () => {
// Simulate offline
NetInfo.fetch.mockResolvedValueOnce({ isConnected: false });
// Make changes
await database.write(async () => {
await database.collections.get('posts').create((post) => {
post.title = 'Offline post';
post._status = 'created';
});
});
// Verify queued
const queue = await getSyncQueue();
expect(queue).toHaveLength(1);
expect(queue[0].type).toBe('create');
});
it('syncs when back online', async () => {
// Simulate coming back online
NetInfo.fetch.mockResolvedValueOnce({ isConnected: true });
await sync();
// Verify synced
const posts = await database.collections.get('posts').query(
Q.where('_status', 'synced')
).fetch();
expect(posts).toHaveLength(1);
});
});Best Practices
- Optimize Sync Payload: Only sync changed fields
- Handle Large Datasets: Implement pagination
- Monitor Sync Health: Track success/failure rates
- Test Offline Scenarios: Thoroughly test edge cases
- User Communication: Clear offline/sync status
Related Commands
sync-data-models- Define sync modelsadd-api-client- Create sync API endpointssetup-push-notifications- Notify on sync completion
