UNPKG

@dataql/react-native

Version:

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

307 lines (306 loc) 11.2 kB
export class SyncManager { constructor(cacheManager, config) { this.eventListeners = new Map(); this.isOnline = navigator.onLine ?? true; this.isSyncing = false; this.cacheManager = cacheManager; this.config = config; this.setupNetworkListener(); } setupNetworkListener() { // Listen for network status changes if (typeof window !== "undefined") { window.addEventListener("online", this.handleOnline.bind(this)); window.addEventListener("offline", this.handleOffline.bind(this)); } } handleOnline() { this.isOnline = true; if (this.config.autoSync) { this.syncNow(); } } 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; } } async fetchWithConnectionOrFallback(input, init) { const url = input instanceof Request ? input.url : input; // Priority 1: Use custom connection if provided if (this.config.customConnection) { let options; 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() { 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; } } async syncOperation(operation) { try { const url = `${this.config.workerUrl}/${operation.tableName}`; let 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, listener) { if (!this.eventListeners.has(eventType)) { this.eventListeners.set(eventType, []); } this.eventListeners.get(eventType).push(listener); } removeEventListener(eventType, listener) { const listeners = this.eventListeners.get(eventType); if (listeners) { const index = listeners.indexOf(listener); if (index > -1) { listeners.splice(index, 1); } } } emitEvent(eventType, eventData) { const listeners = this.eventListeners.get(eventType); if (listeners) { const event = { 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(tableName, params) { if (!this.isOnline) { throw new Error("Cannot fetch from server while offline"); } try { const url = new URL(`${this.config.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() { return { ...this.config }; } // Update sync configuration updateConfig(newConfig) { 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() { return this.isSyncing; } // Check online status getOnlineStatus() { 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)); } } }