Calendar/src/workers/SyncManager.ts

260 lines
7.2 KiB
TypeScript
Raw Normal View History

import { IEventBus } from '../types/CalendarTypes';
import { CoreEvents } from '../constants/CoreEvents';
import { IAuditEntry } from '../types/AuditTypes';
import { AuditService } from '../storage/audit/AuditService';
import { IApiRepository } from '../repositories/IApiRepository';
/**
* SyncManager - Background sync worker
* Syncs audit entries with backend API when online
*
* NEW ARCHITECTURE:
* - Listens to AUDIT_LOGGED events (triggered after AuditService saves)
* - Polls AuditService for pending audit entries
* - Syncs audit entries to backend API
* - Marks audit entries as synced when successful
*
* EVENT CHAIN:
* Entity change ENTITY_SAVED/DELETED AuditService AUDIT_LOGGED SyncManager
*
* Features:
* - Monitors online/offline status
* - Processes pending audits 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 auditService: AuditService;
private auditApiRepository: IApiRepository<IAuditEntry>;
private isOnline: boolean = navigator.onLine;
private isSyncing: boolean = false;
private syncInterval: number = 5000; // 5 seconds
private maxRetries: number = 5;
private intervalId: number | null = null;
// Track retry counts per audit entry (in memory)
private retryCounts: Map<string, number> = new Map();
constructor(
eventBus: IEventBus,
auditService: AuditService,
auditApiRepository: IApiRepository<IAuditEntry>
) {
this.eventBus = eventBus;
this.auditService = auditService;
this.auditApiRepository = auditApiRepository;
this.setupNetworkListeners();
this.setupAuditListener();
this.startSync();
console.log('SyncManager initialized - listening for AUDIT_LOGGED events');
}
/**
* Setup listener for AUDIT_LOGGED events
* Triggers immediate sync attempt when new audit entry is saved
*/
private setupAuditListener(): void {
this.eventBus.on(CoreEvents.AUDIT_LOGGED, () => {
// New audit entry saved - try to sync if online
if (this.isOnline && !this.isSyncing) {
this.processPendingAudits();
}
});
}
/**
* 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.processPendingAudits();
// Then poll every syncInterval
this.intervalId = window.setInterval(() => {
this.processPendingAudits();
}, 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 pending audit entries
* Fetches from AuditService and syncs to backend
*/
private async processPendingAudits(): Promise<void> {
// Don't sync if offline
if (!this.isOnline) {
return;
}
// Don't start new sync if already syncing
if (this.isSyncing) {
return;
}
this.isSyncing = true;
try {
const pendingAudits = await this.auditService.getPendingAudits();
if (pendingAudits.length === 0) {
this.isSyncing = false;
return;
}
this.eventBus.emit(CoreEvents.SYNC_STARTED, {
operationCount: pendingAudits.length
});
// Process audits one by one (FIFO - oldest first by timestamp)
const sortedAudits = pendingAudits.sort((a, b) => a.timestamp - b.timestamp);
for (const audit of sortedAudits) {
await this.processAuditEntry(audit);
}
this.eventBus.emit(CoreEvents.SYNC_COMPLETED, {
operationCount: pendingAudits.length
});
} catch (error) {
console.error('SyncManager: Audit processing error:', error);
this.eventBus.emit(CoreEvents.SYNC_FAILED, {
error: error instanceof Error ? error.message : 'Unknown error'
});
} finally {
this.isSyncing = false;
}
}
/**
* Process a single audit entry
* Sends to backend API and marks as synced
*/
private async processAuditEntry(audit: IAuditEntry): Promise<void> {
const retryCount = this.retryCounts.get(audit.id) || 0;
// Check if max retries exceeded
if (retryCount >= this.maxRetries) {
console.error(`SyncManager: Max retries exceeded for audit ${audit.id}`);
await this.auditService.markAsError(audit.id);
this.retryCounts.delete(audit.id);
return;
}
try {
// Send audit entry to backend
await this.auditApiRepository.sendCreate(audit);
// Success - mark as synced and clear retry count
await this.auditService.markAsSynced(audit.id);
this.retryCounts.delete(audit.id);
console.log(`SyncManager: Successfully synced audit ${audit.id} (${audit.entityType}:${audit.operation})`);
} catch (error) {
console.error(`SyncManager: Failed to sync audit ${audit.id}:`, error);
// Increment retry count
this.retryCounts.set(audit.id, retryCount + 1);
// Calculate backoff delay
const backoffDelay = this.calculateBackoff(retryCount + 1);
this.eventBus.emit(CoreEvents.SYNC_RETRY, {
auditId: audit.id,
retryCount: retryCount + 1,
nextRetryIn: backoffDelay
});
}
}
/**
* 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.processPendingAudits();
}
/**
* 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();
this.retryCounts.clear();
// Note: We don't remove window event listeners as they're global
}
}