UNPKG

@dataql/react-native

Version:

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

433 lines (381 loc) 12 kB
import { useState, useEffect, useCallback } from "react"; import { useLiveQuery as useDrizzleLiveQuery } from "drizzle-orm/expo-sqlite"; import { OfflineCacheManager } from "../cache/OfflineCacheManager"; import { SyncManager } from "../sync/SyncManager"; import { DatabaseClient } from "../db/client"; import { cachedData } from "../db/schema"; import type { QueryResult, MutationResult, SyncStatus, SyncEvent, } from "../types"; // Global instances - these would be initialized by the DataQLProvider let globalCacheManager: OfflineCacheManager | null = null; let globalSyncManager: SyncManager | null = null; let globalDbClient: DatabaseClient | null = null; export function setGlobalInstances( cacheManager: OfflineCacheManager, syncManager: SyncManager, dbClient: DatabaseClient ) { globalCacheManager = cacheManager; globalSyncManager = syncManager; globalDbClient = dbClient; // Need dbClient for live queries } // Hook for querying data with offline-first approach export function useQuery<T = any>(tableName: string, filter?: any) { const [result, setResult] = useState<QueryResult<T>>({ data: [], isFromCache: true, }); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); const fetchData = useCallback(async () => { if (!globalCacheManager) { setError("DataQL not initialized"); setLoading(false); return; } try { setLoading(true); setError(null); // First, get data from local cache const cacheResult = await globalCacheManager.queryOffline<T>( tableName, filter ); setResult(cacheResult); // If online, try to fetch fresh data from server if (globalSyncManager?.getOnlineStatus()) { try { const serverData = await globalSyncManager.fetchFromServer<T>( tableName, filter ); // Update local cache with server data // This would require additional cache manager methods for bulk updates setResult({ data: serverData, isFromCache: false, lastUpdated: new Date(), }); } catch (serverError) { // Keep using cached data if server fetch fails console.warn( "Failed to fetch from server, using cached data:", serverError ); } } } catch (err) { setError(err instanceof Error ? err.message : "Unknown error"); } finally { setLoading(false); } }, [tableName, filter]); useEffect(() => { fetchData(); }, [fetchData]); const refetch = useCallback(() => { fetchData(); }, [fetchData]); return { data: result.data, loading, error, refetch, isFromCache: result.isFromCache, lastUpdated: result.lastUpdated, }; } // DataQL-style live query hook with consistent signature export function useLiveQuery<T = any>(tableName: string, filter?: any) { if (!globalDbClient) { throw new Error( "DataQL not initialized. Call dataQLClient.initialize() first." ); } const db = globalDbClient.getDatabase(); // Query from the cachedData table that DataQL uses internally // This ensures consistency with other DataQL operations try { const { data, error, updatedAt } = useDrizzleLiveQuery( db.select().from(cachedData) ); // Filter the data to match the requested table and filter const filteredData = (data || []) .filter( (record: any) => record.tableName === tableName && !record.isDeleted ) .map((record: any) => ({ id: record.itemId, ...(record.data as any), _localId: record.id, _createdAt: record.createdAt, _updatedAt: record.updatedAt, })) .filter((item: any) => { if (!filter) return true; // Simple filter matching - can be enhanced return Object.keys(filter).every((key) => item[key] === filter[key]); }) as T[]; return { data: filteredData, error: error ? error.message : null, updatedAt, isFromCache: true, }; } catch (err) { // Fallback to regular useQuery if live query fails console.warn("Live query failed, falling back to regular query:", err); return useQuery<T>(tableName, filter); } } // Hook for mutations (create, update, delete) export function useMutation<T = any>() { const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); const create = useCallback( async (tableName: string, data: T): Promise<MutationResult> => { if (!globalCacheManager) { return { success: false, error: "DataQL not initialized" }; } setLoading(true); setError(null); try { // Use the transparent method that handles offline/online automatically const result = await globalCacheManager.createOffline(tableName, data); if (!result.success) { setError(result.error || "Create failed"); } return result; } catch (err) { const errorMessage = err instanceof Error ? err.message : "Unknown error"; setError(errorMessage); return { success: false, error: errorMessage }; } finally { setLoading(false); } }, [] ); const update = useCallback( async ( tableName: string, id: string, data: Partial<T> ): Promise<MutationResult> => { if (!globalCacheManager) { return { success: false, error: "DataQL not initialized" }; } setLoading(true); setError(null); try { // Use the transparent method that handles offline/online automatically const result = await globalCacheManager.updateOffline( tableName, id, data ); if (!result.success) { setError(result.error || "Update failed"); } return result; } catch (err) { const errorMessage = err instanceof Error ? err.message : "Unknown error"; setError(errorMessage); return { success: false, error: errorMessage }; } finally { setLoading(false); } }, [] ); const upsert = useCallback( async ( tableName: string, data: T & { id: string } ): Promise<MutationResult> => { if (!globalCacheManager) { return { success: false, error: "DataQL not initialized" }; } setLoading(true); setError(null); try { // Use the transparent method that handles offline/online automatically const result = await globalCacheManager.upsertOffline(tableName, data); if (!result.success) { setError(result.error || "Upsert failed"); } return result; } catch (err) { const errorMessage = err instanceof Error ? err.message : "Unknown error"; setError(errorMessage); return { success: false, error: errorMessage }; } finally { setLoading(false); } }, [] ); const deleteRecord = useCallback( async (tableName: string, id: string): Promise<MutationResult> => { if (!globalCacheManager) { return { success: false, error: "DataQL not initialized" }; } setLoading(true); setError(null); try { // Use the transparent method that handles offline/online automatically const result = await globalCacheManager.deleteOffline(tableName, id); if (!result.success) { setError(result.error || "Delete failed"); } return result; } catch (err) { const errorMessage = err instanceof Error ? err.message : "Unknown error"; setError(errorMessage); return { success: false, error: errorMessage }; } finally { setLoading(false); } }, [] ); // SQL-style alias for create const insert = useCallback( async (tableName: string, data: T): Promise<MutationResult> => { return create(tableName, data); }, [create] ); return { create, insert, update, upsert, delete: deleteRecord, loading, error, }; } // Hook for sync operations and status export function useSync() { const [syncStatus, setSyncStatus] = useState<SyncStatus>({ isOnline: true, lastSyncTime: null, pendingOperations: 0, failedOperations: 0, syncInProgress: false, }); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); // Update sync status const updateSyncStatus = useCallback(async () => { if (!globalCacheManager) return; try { const status = await globalCacheManager.getSyncStatus(); setSyncStatus(status); } catch (err) { console.error("Error updating sync status:", err); } }, []); // Sync event listeners useEffect(() => { if (!globalSyncManager) return; const handleSyncEvent = (event: SyncEvent) => { switch (event.type) { case "sync_start": setSyncStatus((prev) => ({ ...prev, syncInProgress: true })); setLoading(true); setError(null); break; case "sync_complete": setSyncStatus((prev) => ({ ...prev, syncInProgress: false, lastSyncTime: event.timestamp, })); setLoading(false); updateSyncStatus(); // Refresh pending operations count break; case "sync_error": setSyncStatus((prev) => ({ ...prev, syncInProgress: false })); setLoading(false); setError(event.error || "Sync error"); break; } }; globalSyncManager.addEventListener("sync_start", handleSyncEvent); globalSyncManager.addEventListener("sync_complete", handleSyncEvent); globalSyncManager.addEventListener("sync_error", handleSyncEvent); // Initial status update updateSyncStatus(); return () => { globalSyncManager?.removeEventListener("sync_start", handleSyncEvent); globalSyncManager?.removeEventListener("sync_complete", handleSyncEvent); globalSyncManager?.removeEventListener("sync_error", handleSyncEvent); }; }, [updateSyncStatus]); // Manual sync trigger const sync = useCallback(async () => { if (!globalSyncManager) { setError("DataQL not initialized"); return false; } setLoading(true); setError(null); try { const success = await globalSyncManager.syncNow(); if (!success) { setError("Sync failed"); } return success; } catch (err) { const errorMessage = err instanceof Error ? err.message : "Sync error"; setError(errorMessage); return false; } finally { setLoading(false); } }, []); // Start/stop auto sync const startAutoSync = useCallback(() => { globalSyncManager?.startAutoSync(); }, []); const stopAutoSync = useCallback(() => { globalSyncManager?.stopAutoSync(); }, []); return { syncStatus, sync, startAutoSync, stopAutoSync, loading, error, isOnline: globalSyncManager?.getOnlineStatus() ?? true, isSyncing: globalSyncManager?.isSyncInProgress() ?? false, }; } // Hook for network status export function useNetworkStatus() { const [isOnline, setIsOnline] = useState(navigator.onLine ?? true); useEffect(() => { const handleOnline = () => setIsOnline(true); const handleOffline = () => setIsOnline(false); if (typeof window !== "undefined") { window.addEventListener("online", handleOnline); window.addEventListener("offline", handleOffline); return () => { window.removeEventListener("online", handleOnline); window.removeEventListener("offline", handleOffline); }; } }, []); return { isOnline }; }