UNPKG

@dataql/react-native

Version:

DataQL React Native SDK with offline-first capabilities and clean API

400 lines (351 loc) 10.6 kB
import { OfflineCacheManager } from "../cache/OfflineCacheManager"; import { getWorkerUrl } from "@dataql/core"; import type { SyncConfig, OfflineOperation, SyncEvent, SyncEventType, } from "../types"; export class SyncManager { private cacheManager: OfflineCacheManager; private config: SyncConfig; private syncInterval?: NodeJS.Timeout; private eventListeners: Map<SyncEventType, ((event: SyncEvent) => void)[]> = new Map(); private isOnline: boolean = navigator.onLine ?? true; private isSyncing: boolean = false; constructor(cacheManager: OfflineCacheManager, config: SyncConfig) { this.cacheManager = cacheManager; this.config = config; this.setupNetworkListener(); } private setupNetworkListener() { // Listen for network status changes if (typeof window !== "undefined") { window.addEventListener("online", this.handleOnline.bind(this)); window.addEventListener("offline", this.handleOffline.bind(this)); } } private handleOnline() { this.isOnline = true; if (this.config.autoSync) { this.syncNow(); } } private handleOffline() { this.isOnline = false; this.stopAutoSync(); } // Start automatic sync startAutoSync() { if (this.syncInterval) { clearInterval(this.syncInterval); } if ( this.config.autoSync && this.config.syncInterval && this.config.syncInterval > 0 ) { this.syncInterval = setInterval(() => { if (this.isOnline && !this.isSyncing) { this.syncNow(); } }, this.config.syncInterval); } } // Stop automatic sync stopAutoSync() { if (this.syncInterval) { clearInterval(this.syncInterval); this.syncInterval = undefined; } } private async fetchWithConnectionOrFallback( input: RequestInfo, init?: RequestInit ): Promise<Response> { const url = input instanceof Request ? input.url : input; // Priority 1: Use custom connection if provided if (this.config.customConnection) { let options: RequestInit; if (input instanceof Request) { // Clone the request to avoid consuming the body const clonedRequest = input.clone(); options = { method: clonedRequest.method, headers: Object.fromEntries(clonedRequest.headers.entries()), body: clonedRequest.body, ...init, }; } else { options = init || {}; } console.log("[DataQL Sync] Using customConnection", { url, method: options.method || "GET", }); return await this.config.customConnection.request(url, options); } // Priority 2: Use legacy workerBinding for backward compatibility if (this.config.workerBinding) { // Always construct a Request object const req = input instanceof Request ? input : new Request(input, init); console.log("[DataQL Sync] Using workerBinding", { url: req.url, method: req.method, }); return await this.config.workerBinding.fetch(req); } // Priority 3: Fallback to regular fetch const method = input instanceof Request ? input.method : init?.method || "GET"; console.log("[DataQL Sync] Using fetch", { url, method }); return await fetch(input, init); } // Manually trigger sync async syncNow(): Promise<boolean> { if (!this.isOnline || this.isSyncing) { return false; } this.isSyncing = true; this.emitEvent("sync_start", { timestamp: new Date() }); try { const pendingOperations = await this.cacheManager.getPendingOperations( this.config.batchSize ); if (pendingOperations.length === 0) { this.emitEvent("sync_complete", { timestamp: new Date(), data: { operationsSynced: 0 }, }); return true; } let syncedCount = 0; let failedCount = 0; // Process operations in batches for (const operation of pendingOperations) { try { const success = await this.syncOperation(operation); if (success) { await this.cacheManager.markOperationSynced(operation.id); syncedCount++; this.emitEvent("operation_synced", { timestamp: new Date(), data: operation, }); } else { await this.cacheManager.markOperationFailed( operation.id, "Sync failed" ); failedCount++; } } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; await this.cacheManager.markOperationFailed( operation.id, errorMessage ); failedCount++; } } this.emitEvent("sync_complete", { timestamp: new Date(), data: { operationsSynced: syncedCount, operationsFailed: failedCount, }, }); return failedCount === 0; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Sync failed"; this.emitEvent("sync_error", { timestamp: new Date(), error: errorMessage, }); return false; } finally { this.isSyncing = false; } } private async syncOperation(operation: OfflineOperation): Promise<boolean> { try { const workerUrl = this.config.serverUrl || getWorkerUrl(); const url = `${workerUrl}/${operation.tableName}`; let response: Response; switch (operation.type) { case "create": response = await this.fetchWithConnectionOrFallback(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(operation.data), }); break; case "update": response = await this.fetchWithConnectionOrFallback( `${url}/${operation.data.id}`, { method: "PUT", headers: { "Content-Type": "application/json", }, body: JSON.stringify(operation.data), } ); break; case "upsert": response = await this.fetchWithConnectionOrFallback(`${url}/upsert`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(operation.data), }); break; case "delete": response = await this.fetchWithConnectionOrFallback( `${url}/${operation.data.id}`, { method: "DELETE", headers: { "Content-Type": "application/json", }, } ); break; default: return false; } if (!response.ok) { console.error( `Sync failed for ${operation.type} operation:`, response.statusText ); return false; } // Handle server response const result = await response.json(); // Update local cache with server data if needed if (operation.type === "create" && result.id) { // Update the local record with server ID // This would require additional cache manager methods } return true; } catch (error) { console.error(`Error syncing ${operation.type} operation:`, error); return false; } } // Event management addEventListener( eventType: SyncEventType, listener: (event: SyncEvent) => void ) { if (!this.eventListeners.has(eventType)) { this.eventListeners.set(eventType, []); } this.eventListeners.get(eventType)!.push(listener); } removeEventListener( eventType: SyncEventType, listener: (event: SyncEvent) => void ) { const listeners = this.eventListeners.get(eventType); if (listeners) { const index = listeners.indexOf(listener); if (index > -1) { listeners.splice(index, 1); } } } private emitEvent( eventType: SyncEventType, eventData: Omit<SyncEvent, "type"> ) { const listeners = this.eventListeners.get(eventType); if (listeners) { const event: SyncEvent = { type: eventType, ...eventData, }; listeners.forEach((listener) => { try { listener(event); } catch (error) { console.error("Error in sync event listener:", error); } }); } } // Fetch fresh data from server async fetchFromServer<T>(tableName: string, params?: any): Promise<T[]> { if (!this.isOnline) { throw new Error("Cannot fetch from server while offline"); } try { const workerUrl = this.config.serverUrl || getWorkerUrl(); const url = new URL(`${workerUrl}/${tableName}`); if (params) { Object.keys(params).forEach((key) => { url.searchParams.append(key, params[key]); }); } const response = await this.fetchWithConnectionOrFallback( url.toString(), { method: "GET", headers: { "Content-Type": "application/json", }, } ); if (!response.ok) { throw new Error(`Failed to fetch from server: ${response.statusText}`); } const data = await response.json(); return Array.isArray(data) ? data : [data]; } catch (error) { console.error("Error fetching from server:", error); throw error; } } // Get sync configuration getConfig(): SyncConfig { return { ...this.config }; } // Update sync configuration updateConfig(newConfig: Partial<SyncConfig>) { this.config = { ...this.config, ...newConfig }; // Restart auto sync if interval changed if ( newConfig.syncInterval !== undefined || newConfig.autoSync !== undefined ) { this.stopAutoSync(); if (this.config.autoSync) { this.startAutoSync(); } } } // Check if currently syncing isSyncInProgress(): boolean { return this.isSyncing; } // Check online status getOnlineStatus(): boolean { return this.isOnline; } // Cleanup destroy() { this.stopAutoSync(); this.eventListeners.clear(); if (typeof window !== "undefined") { window.removeEventListener("online", this.handleOnline.bind(this)); window.removeEventListener("offline", this.handleOffline.bind(this)); } } }