2025-11-18 16:37:33 +01:00
|
|
|
import { IEventBus, EntityType, ISync } from '../types/CalendarTypes';
|
2025-11-05 00:37:57 +01:00
|
|
|
import { CoreEvents } from '../constants/CoreEvents';
|
2025-11-20 21:45:09 +01:00
|
|
|
import { OperationQueue, IQueueOperation } from '../storage/OperationQueue';
|
2025-11-18 16:37:33 +01:00
|
|
|
import { IApiRepository } from '../repositories/IApiRepository';
|
|
|
|
|
import { IEntityService } from '../storage/IEntityService';
|
2025-11-05 00:37:57 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* SyncManager - Background sync worker
|
|
|
|
|
* Processes operation queue and syncs with API when online
|
|
|
|
|
*
|
2025-11-18 16:37:33 +01:00
|
|
|
* GENERIC ARCHITECTURE:
|
|
|
|
|
* - Handles all entity types (Event, Booking, Customer, Resource)
|
|
|
|
|
* - Routes operations based on IQueueOperation.dataEntity.typename
|
|
|
|
|
* - Uses IApiRepository<T> pattern for type-safe API calls
|
|
|
|
|
* - Uses IEntityService<T> polymorphism for sync status management
|
|
|
|
|
*
|
|
|
|
|
* POLYMORFI DESIGN:
|
|
|
|
|
* - Services implement IEntityService<T extends ISync> interface
|
|
|
|
|
* - SyncManager uses Array.find() for service lookup (simple, only 4 entities)
|
|
|
|
|
* - Services encapsulate sync status manipulation (markAsSynced, markAsError)
|
|
|
|
|
* - SyncManager does NOT manipulate entity.syncStatus directly
|
|
|
|
|
* - Open/Closed Principle: Adding new entity requires only DI registration
|
|
|
|
|
*
|
2025-11-05 00:37:57 +01:00
|
|
|
* 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;
|
2025-11-18 16:37:33 +01:00
|
|
|
private repositories: Map<EntityType, IApiRepository<any>>;
|
|
|
|
|
private entityServices: IEntityService<any>[];
|
2025-11-05 00:37:57 +01:00
|
|
|
|
|
|
|
|
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,
|
2025-11-18 16:37:33 +01:00
|
|
|
apiRepositories: IApiRepository<any>[],
|
|
|
|
|
entityServices: IEntityService<any>[]
|
2025-11-05 00:37:57 +01:00
|
|
|
) {
|
|
|
|
|
this.eventBus = eventBus;
|
|
|
|
|
this.queue = queue;
|
2025-11-18 16:37:33 +01:00
|
|
|
this.entityServices = entityServices;
|
|
|
|
|
|
|
|
|
|
// Build map: EntityType → IApiRepository
|
|
|
|
|
this.repositories = new Map(
|
|
|
|
|
apiRepositories.map(repo => [repo.entityType, repo])
|
|
|
|
|
);
|
2025-11-05 00:37:57 +01:00
|
|
|
|
|
|
|
|
this.setupNetworkListeners();
|
2025-11-05 20:35:21 +01:00
|
|
|
this.startSync();
|
2025-11-18 16:37:33 +01:00
|
|
|
console.log(`SyncManager initialized with ${apiRepositories.length} entity repositories and ${entityServices.length} entity services`);
|
2025-11-05 00:37:57 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
2025-11-18 16:37:33 +01:00
|
|
|
* Generic - routes to correct API repository based on entity type
|
2025-11-05 00:37:57 +01:00
|
|
|
*/
|
|
|
|
|
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);
|
2025-11-18 16:37:33 +01:00
|
|
|
await this.markEntityAsError(operation.dataEntity.typename, operation.entityId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get the appropriate API repository for this entity type
|
|
|
|
|
const repository = this.repositories.get(operation.dataEntity.typename);
|
|
|
|
|
if (!repository) {
|
|
|
|
|
console.error(`SyncManager: No repository found for entity type ${operation.dataEntity.typename}`);
|
|
|
|
|
await this.queue.remove(operation.id);
|
2025-11-05 00:37:57 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Send to API based on operation type
|
|
|
|
|
switch (operation.type) {
|
|
|
|
|
case 'create':
|
2025-11-18 16:37:33 +01:00
|
|
|
await repository.sendCreate(operation.dataEntity.data);
|
2025-11-05 00:37:57 +01:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'update':
|
2025-11-18 16:37:33 +01:00
|
|
|
await repository.sendUpdate(operation.entityId, operation.dataEntity.data);
|
2025-11-05 00:37:57 +01:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'delete':
|
2025-11-18 16:37:33 +01:00
|
|
|
await repository.sendDelete(operation.entityId);
|
2025-11-05 00:37:57 +01:00
|
|
|
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);
|
2025-11-18 16:37:33 +01:00
|
|
|
await this.markEntityAsSynced(operation.dataEntity.typename, operation.entityId);
|
2025-11-05 00:37:57 +01:00
|
|
|
|
2025-11-18 16:37:33 +01:00
|
|
|
console.log(`SyncManager: Successfully synced ${operation.dataEntity.typename} operation ${operation.id}`);
|
2025-11-05 00:37:57 +01:00
|
|
|
|
|
|
|
|
} 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
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-11-18 16:37:33 +01:00
|
|
|
* Mark entity as synced in IndexedDB
|
|
|
|
|
* Uses polymorphism - delegates to IEntityService.markAsSynced()
|
2025-11-05 00:37:57 +01:00
|
|
|
*/
|
2025-11-18 16:37:33 +01:00
|
|
|
private async markEntityAsSynced(entityType: EntityType, entityId: string): Promise<void> {
|
2025-11-05 00:37:57 +01:00
|
|
|
try {
|
2025-11-18 16:37:33 +01:00
|
|
|
const service = this.entityServices.find(s => s.entityType === entityType);
|
|
|
|
|
if (!service) {
|
|
|
|
|
console.error(`SyncManager: No service found for entity type ${entityType}`);
|
|
|
|
|
return;
|
2025-11-05 00:37:57 +01:00
|
|
|
}
|
2025-11-18 16:37:33 +01:00
|
|
|
|
|
|
|
|
await service.markAsSynced(entityId);
|
2025-11-05 00:37:57 +01:00
|
|
|
} catch (error) {
|
2025-11-18 16:37:33 +01:00
|
|
|
console.error(`SyncManager: Failed to mark ${entityType} ${entityId} as synced:`, error);
|
2025-11-05 00:37:57 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-11-18 16:37:33 +01:00
|
|
|
* Mark entity as error in IndexedDB
|
|
|
|
|
* Uses polymorphism - delegates to IEntityService.markAsError()
|
2025-11-05 00:37:57 +01:00
|
|
|
*/
|
2025-11-18 16:37:33 +01:00
|
|
|
private async markEntityAsError(entityType: EntityType, entityId: string): Promise<void> {
|
2025-11-05 00:37:57 +01:00
|
|
|
try {
|
2025-11-18 16:37:33 +01:00
|
|
|
const service = this.entityServices.find(s => s.entityType === entityType);
|
|
|
|
|
if (!service) {
|
|
|
|
|
console.error(`SyncManager: No service found for entity type ${entityType}`);
|
|
|
|
|
return;
|
2025-11-05 00:37:57 +01:00
|
|
|
}
|
2025-11-18 16:37:33 +01:00
|
|
|
|
|
|
|
|
await service.markAsError(entityId);
|
2025-11-05 00:37:57 +01:00
|
|
|
} catch (error) {
|
2025-11-18 16:37:33 +01:00
|
|
|
console.error(`SyncManager: Failed to mark ${entityType} ${entityId} as error:`, error);
|
2025-11-05 00:37:57 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}
|
|
|
|
|
}
|