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