Implements offline-first calendar sync infrastructure
Adds IndexedDB and operation queue for robust offline synchronization Introduces SyncManager to handle background data synchronization Supports local event operations with automatic remote sync queuing Enhances application reliability and user experience in low/no connectivity scenarios
This commit is contained in:
parent
9c765b35ab
commit
e7011526e3
20 changed files with 3822 additions and 57 deletions
276
src/workers/SyncManager.ts
Normal file
276
src/workers/SyncManager.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import { IEventBus } from '../types/CalendarTypes';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { OperationQueue } from '../storage/OperationQueue';
|
||||
import { IQueueOperation } from '../storage/IndexedDBService';
|
||||
import { IndexedDBService } from '../storage/IndexedDBService';
|
||||
import { ApiEventRepository } from '../repositories/ApiEventRepository';
|
||||
|
||||
/**
|
||||
* SyncManager - Background sync worker
|
||||
* Processes operation queue and syncs with API when online
|
||||
*
|
||||
* Features:
|
||||
* - Monitors online/offline status
|
||||
* - Processes queue with FIFO order
|
||||
* - Exponential backoff retry logic
|
||||
* - Updates syncStatus in IndexedDB after successful sync
|
||||
* - Emits sync events for UI feedback
|
||||
*/
|
||||
export class SyncManager {
|
||||
private eventBus: IEventBus;
|
||||
private queue: OperationQueue;
|
||||
private indexedDB: IndexedDBService;
|
||||
private apiRepository: ApiEventRepository;
|
||||
|
||||
private isOnline: boolean = navigator.onLine;
|
||||
private isSyncing: boolean = false;
|
||||
private syncInterval: number = 5000; // 5 seconds
|
||||
private maxRetries: number = 5;
|
||||
private intervalId: number | null = null;
|
||||
|
||||
constructor(
|
||||
eventBus: IEventBus,
|
||||
queue: OperationQueue,
|
||||
indexedDB: IndexedDBService,
|
||||
apiRepository: ApiEventRepository
|
||||
) {
|
||||
this.eventBus = eventBus;
|
||||
this.queue = queue;
|
||||
this.indexedDB = indexedDB;
|
||||
this.apiRepository = apiRepository;
|
||||
|
||||
this.setupNetworkListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup online/offline event listeners
|
||||
*/
|
||||
private setupNetworkListeners(): void {
|
||||
window.addEventListener('online', () => {
|
||||
this.isOnline = true;
|
||||
this.eventBus.emit(CoreEvents.OFFLINE_MODE_CHANGED, {
|
||||
isOnline: true
|
||||
});
|
||||
console.log('SyncManager: Network online - starting sync');
|
||||
this.startSync();
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
this.isOnline = false;
|
||||
this.eventBus.emit(CoreEvents.OFFLINE_MODE_CHANGED, {
|
||||
isOnline: false
|
||||
});
|
||||
console.log('SyncManager: Network offline - pausing sync');
|
||||
this.stopSync();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start background sync worker
|
||||
*/
|
||||
public startSync(): void {
|
||||
if (this.intervalId) {
|
||||
return; // Already running
|
||||
}
|
||||
|
||||
console.log('SyncManager: Starting background sync');
|
||||
|
||||
// Process immediately
|
||||
this.processQueue();
|
||||
|
||||
// Then poll every syncInterval
|
||||
this.intervalId = window.setInterval(() => {
|
||||
this.processQueue();
|
||||
}, this.syncInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop background sync worker
|
||||
*/
|
||||
public stopSync(): void {
|
||||
if (this.intervalId) {
|
||||
window.clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
console.log('SyncManager: Stopped background sync');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process operation queue
|
||||
* Sends pending operations to API
|
||||
*/
|
||||
private async processQueue(): Promise<void> {
|
||||
// Don't sync if offline
|
||||
if (!this.isOnline) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't start new sync if already syncing
|
||||
if (this.isSyncing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if queue is empty
|
||||
if (await this.queue.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSyncing = true;
|
||||
|
||||
try {
|
||||
const operations = await this.queue.getAll();
|
||||
|
||||
this.eventBus.emit(CoreEvents.SYNC_STARTED, {
|
||||
operationCount: operations.length
|
||||
});
|
||||
|
||||
// Process operations one by one (FIFO)
|
||||
for (const operation of operations) {
|
||||
await this.processOperation(operation);
|
||||
}
|
||||
|
||||
this.eventBus.emit(CoreEvents.SYNC_COMPLETED, {
|
||||
operationCount: operations.length
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('SyncManager: Queue processing error:', error);
|
||||
this.eventBus.emit(CoreEvents.SYNC_FAILED, {
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single operation
|
||||
*/
|
||||
private async processOperation(operation: IQueueOperation): Promise<void> {
|
||||
// Check if max retries exceeded
|
||||
if (operation.retryCount >= this.maxRetries) {
|
||||
console.error(`SyncManager: Max retries exceeded for operation ${operation.id}`, operation);
|
||||
await this.queue.remove(operation.id);
|
||||
await this.markEventAsError(operation.eventId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Send to API based on operation type
|
||||
switch (operation.type) {
|
||||
case 'create':
|
||||
await this.apiRepository.sendCreate(operation.data as any);
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
await this.apiRepository.sendUpdate(operation.eventId, operation.data);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
await this.apiRepository.sendDelete(operation.eventId);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`SyncManager: Unknown operation type ${operation.type}`);
|
||||
await this.queue.remove(operation.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Success - remove from queue and mark as synced
|
||||
await this.queue.remove(operation.id);
|
||||
await this.markEventAsSynced(operation.eventId);
|
||||
|
||||
console.log(`SyncManager: Successfully synced operation ${operation.id}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`SyncManager: Failed to sync operation ${operation.id}:`, error);
|
||||
|
||||
// Increment retry count
|
||||
await this.queue.incrementRetryCount(operation.id);
|
||||
|
||||
// Calculate backoff delay
|
||||
const backoffDelay = this.calculateBackoff(operation.retryCount + 1);
|
||||
|
||||
this.eventBus.emit(CoreEvents.SYNC_RETRY, {
|
||||
operationId: operation.id,
|
||||
retryCount: operation.retryCount + 1,
|
||||
nextRetryIn: backoffDelay
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark event as synced in IndexedDB
|
||||
*/
|
||||
private async markEventAsSynced(eventId: string): Promise<void> {
|
||||
try {
|
||||
const event = await this.indexedDB.getEvent(eventId);
|
||||
if (event) {
|
||||
event.syncStatus = 'synced';
|
||||
await this.indexedDB.saveEvent(event);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`SyncManager: Failed to mark event ${eventId} as synced:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark event as error in IndexedDB
|
||||
*/
|
||||
private async markEventAsError(eventId: string): Promise<void> {
|
||||
try {
|
||||
const event = await this.indexedDB.getEvent(eventId);
|
||||
if (event) {
|
||||
event.syncStatus = 'error';
|
||||
await this.indexedDB.saveEvent(event);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`SyncManager: Failed to mark event ${eventId} as error:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate exponential backoff delay
|
||||
* @param retryCount Current retry count
|
||||
* @returns Delay in milliseconds
|
||||
*/
|
||||
private calculateBackoff(retryCount: number): number {
|
||||
// Exponential backoff: 2^retryCount * 1000ms
|
||||
// Retry 1: 2s, Retry 2: 4s, Retry 3: 8s, Retry 4: 16s, Retry 5: 32s
|
||||
const baseDelay = 1000;
|
||||
const exponentialDelay = Math.pow(2, retryCount) * baseDelay;
|
||||
const maxDelay = 60000; // Max 1 minute
|
||||
return Math.min(exponentialDelay, maxDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger sync (for testing or manual sync button)
|
||||
*/
|
||||
public async triggerManualSync(): Promise<void> {
|
||||
console.log('SyncManager: Manual sync triggered');
|
||||
await this.processQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current sync status
|
||||
*/
|
||||
public getSyncStatus(): {
|
||||
isOnline: boolean;
|
||||
isSyncing: boolean;
|
||||
isRunning: boolean;
|
||||
} {
|
||||
return {
|
||||
isOnline: this.isOnline,
|
||||
isSyncing: this.isSyncing,
|
||||
isRunning: this.intervalId !== null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup - stop sync and remove listeners
|
||||
*/
|
||||
public destroy(): void {
|
||||
this.stopSync();
|
||||
// Note: We don't remove window event listeners as they're global
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue