@dataql/react-native
Version:
DataQL React Native SDK with offline-first capabilities and clean API
433 lines (381 loc) • 12 kB
text/typescript
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 };
}