Skip to content

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 --encryption

With Conflict Resolution

bash
/template add-offline-sync watermelondb --conflict-resolution manual

What 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 setup

WatermelonDB 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

  1. Optimize Sync Payload: Only sync changed fields
  2. Handle Large Datasets: Implement pagination
  3. Monitor Sync Health: Track success/failure rates
  4. Test Offline Scenarios: Thoroughly test edge cases
  5. User Communication: Clear offline/sync status

Built with ❤️ for the AI Coding community, by Praney Behl