import System from '@/lib/models/System'; /** * SyncService - Handles bidirectional synchronization between local DB and server * Implements conflict resolution and retry logic */ export class SyncService { private syncInterval: NodeJS.Timeout | null = null; private isSyncing: boolean = false; private isOnline: boolean = navigator.onLine; private accessToken: string | null = null; constructor() { // Listen to online/offline events if (typeof window !== 'undefined') { window.addEventListener('online', () => { this.isOnline = true; this.onlineStatusChanged(true); }); window.addEventListener('offline', () => { this.isOnline = false; this.onlineStatusChanged(false); }); } } /** * Start automatic sync every interval * @param intervalMs - Sync interval in milliseconds (default 30 seconds) */ startAutoSync(intervalMs: number = 30000): void { if (this.syncInterval) { clearInterval(this.syncInterval); } this.syncInterval = setInterval(() => { if (this.isOnline && !this.isSyncing) { this.sync(); } }, intervalMs); console.log(`Auto-sync started with interval: ${intervalMs}ms`); } /** * Stop automatic sync */ stopAutoSync(): void { if (this.syncInterval) { clearInterval(this.syncInterval); this.syncInterval = null; } console.log('Auto-sync stopped'); } /** * Set access token for API requests */ setAccessToken(token: string): void { this.accessToken = token; } /** * Check if currently online */ getOnlineStatus(): boolean { return this.isOnline; } /** * Force set online/offline status (for manual toggle) */ setOnlineStatus(online: boolean): void { this.isOnline = online; this.onlineStatusChanged(online); } /** * Handle online/offline status change */ private onlineStatusChanged(online: boolean): void { console.log(`Network status changed: ${online ? 'ONLINE' : 'OFFLINE'}`); if (online && !this.isSyncing) { // When going online, trigger immediate sync setTimeout(() => this.sync(), 1000); } // Notify listeners (will be implemented in OfflineContext) if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('offline-status-changed', { detail: { online } })); } } /** * Perform full bidirectional sync */ async sync(): Promise { if (!this.isOnline) { return { success: false, error: 'Cannot sync while offline', pushedChanges: 0, pulledChanges: 0 }; } if (this.isSyncing) { return { success: false, error: 'Sync already in progress', pushedChanges: 0, pulledChanges: 0 }; } this.isSyncing = true; console.log('Starting sync...'); try { // Check Electron API availability if (typeof window === 'undefined' || !(window as any).electron) { throw new Error('Electron API not available'); } // Step 1: Push local changes to server const pushedChanges = await this.pushChanges(); // Step 2: Pull server changes to local const pulledChanges = await this.pullChanges(); console.log(`Sync completed: pushed ${pushedChanges}, pulled ${pulledChanges} changes`); // Dispatch sync completion event if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('sync-completed', { detail: { pushedChanges, pulledChanges } })); } return { success: true, pushedChanges, pulledChanges }; } catch (error) { console.error('Sync failed:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error', pushedChanges: 0, pulledChanges: 0 }; } finally { this.isSyncing = false; } } /** * Push local changes to server */ private async pushChanges(): Promise { if (!this.accessToken) { console.warn('No access token available for sync'); return 0; } // Get pending changes via Electron IPC const result = await (window as any).electron.dbGetPendingChanges(50); if (!result.success) { console.error('Failed to get pending changes:', result.error); return 0; } const pendingChanges = result.data || []; if (pendingChanges.length === 0) { return 0; } console.log(`Pushing ${pendingChanges.length} pending changes...`); let successCount = 0; const syncedIds: number[] = []; for (const change of pendingChanges) { try { const success = await this.pushSingleChange(change); if (success) { successCount++; syncedIds.push(change.id); } } catch (error) { console.error(`Failed to push change ${change.id}:`, error); // Continue with next change } } // Mark successfully synced changes via IPC if (syncedIds.length > 0) { // TODO: Add IPC handler for marking synced console.log('Synced changes:', syncedIds); } return successCount; } /** * Push a single change to server */ private async pushSingleChange(change: any): Promise { if (!this.accessToken) return false; const { table_name, operation, record_id, data } = change; let url = ''; let method: 'POST' | 'PUT' | 'DELETE' = 'POST'; // Map table names to API endpoints switch (table_name) { case 'erit_books': url = operation === 'DELETE' ? `books/${record_id}` : 'books'; method = operation === 'DELETE' ? 'DELETE' : operation === 'INSERT' ? 'POST' : 'PUT'; break; case 'book_chapters': url = operation === 'DELETE' ? `chapters/${record_id}` : 'chapters'; method = operation === 'DELETE' ? 'DELETE' : operation === 'INSERT' ? 'POST' : 'PUT'; break; case 'book_characters': url = operation === 'DELETE' ? `characters/${record_id}` : 'characters'; method = operation === 'DELETE' ? 'DELETE' : operation === 'INSERT' ? 'POST' : 'PUT'; break; case 'ai_conversations': url = operation === 'DELETE' ? `ai/conversations/${record_id}` : 'ai/conversations'; method = operation === 'DELETE' ? 'DELETE' : operation === 'INSERT' ? 'POST' : 'PUT'; break; default: console.warn(`Unknown table for sync: ${table_name}`); return false; } try { if (method === 'DELETE') { await System.authDeleteToServer(url, {}, this.accessToken); } else if (method === 'PUT') { await System.authPutToServer(url, JSON.parse(data), this.accessToken); } else { await System.authPostToServer(url, JSON.parse(data), this.accessToken); } return true; } catch (error) { console.error(`Failed to sync ${table_name} ${operation}:`, error); return false; } } /** * Pull changes from server */ private async pullChanges(): Promise { if (!this.accessToken) { console.warn('No access token available for sync'); return 0; } // Get sync status via Electron IPC const statusResult = await (window as any).electron.dbGetSyncStatus(); if (!statusResult.success) { console.error('Failed to get sync status:', statusResult.error); return 0; } const syncStatus = statusResult.data || []; let totalPulled = 0; // Pull updates for each table for (const status of syncStatus) { try { const count = await this.pullTableChanges(status.table, status.lastSync); totalPulled += count; } catch (error) { console.error(`Failed to pull changes for ${status.table}:`, error); } } return totalPulled; } /** * Pull changes for a specific table */ private async pullTableChanges(tableName: string, lastSync: number): Promise { if (!this.accessToken) return 0; // Map table names to API endpoints let endpoint = ''; switch (tableName) { case 'erit_books': endpoint = 'books'; break; case 'book_chapters': endpoint = 'chapters'; break; case 'book_characters': endpoint = 'characters'; break; case 'ai_conversations': endpoint = 'ai/conversations'; break; default: return 0; } try { // Request changes since last sync const response = await System.authGetQueryToServer( `${endpoint}/sync?since=${lastSync}`, this.accessToken ); if (!response || !response.data) { return 0; } const changes = Array.isArray(response.data) ? response.data : [response.data]; // Apply changes to local database // This would require implementing merge logic for each table // For now, we'll just log the changes console.log(`Pulled ${changes.length} changes for ${tableName}`); // Update last sync time via IPC // TODO: Add IPC handler for updating last sync return changes.length; } catch (error) { console.error(`Failed to pull changes for ${tableName}:`, error); return 0; } } /** * Resolve conflicts between local and server data * Strategy: Server wins (can be customized) */ private resolveConflict(localData: any, serverData: any): any { // Simple strategy: server wins // TODO: Implement more sophisticated conflict resolution console.warn('Conflict detected, using server data'); return serverData; } /** * Get sync progress */ getSyncProgress(): SyncProgress { // This will be called synchronously, so we return cached state // The actual sync status is updated via events return { isSyncing: this.isSyncing, pendingChanges: 0, // Will be updated via IPC isOnline: this.isOnline }; } } export interface SyncResult { success: boolean; error?: string; pushedChanges: number; pulledChanges: number; } export interface SyncProgress { isSyncing: boolean; pendingChanges: number; isOnline: boolean; tables?: { table: string; lastSync: number; pending: number }[]; } // Singleton instance let syncServiceInstance: SyncService | null = null; export function getSyncService(): SyncService { if (!syncServiceInstance) { syncServiceInstance = new SyncService(); } return syncServiceInstance; }