@dataql/react-native
Version:
DataQL React Native SDK with offline-first capabilities and clean API
307 lines (306 loc) • 11.2 kB
JavaScript
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));
}
}
}