/** * Sync Service * Main orchestration service for syncing Autotask data to PostgreSQL */ import postgresClient from './postgres-client'; import autotaskRateLimiter from './rate-limiter'; import { AutotaskClient } from './autotask-client'; import { createEntitySyncService, EntitySyncService } from './entity-sync'; import { EntityType, SyncType, SyncStatus, SyncConfig, SyncResult, SyncProgress, EntitySyncResult, SyncHistoryRecord } from '../types/sync'; import { getEntitySyncOrder, getAllEntitiesInOrder, generateSyncId, getEntityDisplayName } from '../utils/sync-helpers'; import { getLastSyncTime } from '../utils/db-helpers'; import { createSyncLogger, SyncPhase, categorizeError } from '../utils/sync-logger'; /** * Main Sync Service Class */ export class SyncService { private currentSyncId: string | null = null; private isSyncing = false; private autotaskClient: AutotaskClient; private entitySyncService: EntitySyncService; private logger = createSyncLogger({ component: 'SyncService' }); constructor(autotaskClient: AutotaskClient) { this.autotaskClient = autotaskClient; this.entitySyncService = createEntitySyncService(autotaskClient); } /** * Start a full sync of all entities * @param triggeredBy User or system identifier * @param yearsBack Number of years to look back for time-based entities * @returns Sync result */ async fullSync(triggeredBy?: string, yearsBack?: number): Promise { const config: SyncConfig = { syncType: SyncType.FULL, entities: getAllEntitiesInOrder(), triggeredBy, yearsBack, }; return await this.executeSync(config); } /** * Start an incremental sync of all entities * @param triggeredBy User or system identifier * @param yearsBack Number of years to look back for time-based entities * @returns Sync result */ async incrementalSync(triggeredBy?: string, yearsBack?: number): Promise { const config: SyncConfig = { syncType: SyncType.INCREMENTAL, entities: getAllEntitiesInOrder(), triggeredBy, yearsBack, }; return await this.executeSync(config); } /** * Sync specific entities * @param entities Array of entity types to sync * @param syncType Type of sync (full or incremental) * @param triggeredBy User or system identifier * @param yearsBack Number of years to look back for time-based entities * @returns Sync result */ async syncEntities( entities: EntityType[], syncType: SyncType = SyncType.ENTITY_SPECIFIC, triggeredBy?: string, yearsBack?: number ): Promise { const config: SyncConfig = { syncType, entities: getEntitySyncOrder(entities), triggeredBy, yearsBack, }; return await this.executeSync(config); } /** * Execute sync operation * @param config Sync configuration * @returns Sync result */ private async executeSync(config: SyncConfig): Promise { if (this.isSyncing) { throw new Error('A sync operation is already in progress'); } this.isSyncing = true; const syncId = generateSyncId(); this.currentSyncId = syncId; const startTime = new Date(); const entityResults: EntitySyncResult[] = []; const errors: string[] = []; try { const syncLogger = this.logger.child({ syncId, syncType: config.syncType }); syncLogger.info(`Starting ${config.syncType} sync`, { entities: config.entities.map(e => getEntityDisplayName(e)).join(', '), triggeredBy: config.triggeredBy, yearsBack: config.yearsBack, }); // Sync each entity in order for (const entity of config.entities) { try { const entityLogger = syncLogger.child({ entityType: entity }); const entityStartTime = entityLogger.start(`Syncing ${getEntityDisplayName(entity)}`); // Create sync history record const historyId = await this.createSyncHistory( entity, config.syncType, config.triggeredBy ); let recordsAdded = 0; let recordsUpdated = 0; let recordsDeleted = 0; // Determine if incremental sync const isIncremental = config.syncType === SyncType.INCREMENTAL; const yearsBack = config.yearsBack || 2; // Default to 2 years // Execute entity sync const syncStats = await this.entitySyncService.syncEntity(entity, isIncremental, yearsBack); recordsAdded = syncStats.recordsAdded; recordsUpdated = syncStats.recordsUpdated; recordsDeleted = syncStats.recordsDeleted; const duration = Date.now() - entityStartTime; // Update sync history await this.updateSyncHistory( historyId, SyncStatus.COMPLETED, recordsAdded, recordsUpdated, recordsDeleted ); entityResults.push({ entityType: entity, success: true, recordsAdded, recordsUpdated, recordsDeleted, duration, }); entityLogger.complete(`Syncing ${getEntityDisplayName(entity)}`, entityStartTime, { recordsAdded, recordsUpdated, recordsDeleted, }); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); const entityLogger = syncLogger.child({ entityType: entity }); const entityName = getEntityDisplayName(entity); const errorCategory = categorizeError(err); // Log detailed error information entityLogger.error(`Failed to sync ${entityName}`, { errorCategory, syncType: config.syncType, }, err); const fullErrorMessage = `[${errorCategory}] ${err.message}`; errors.push(`${entityName}: ${fullErrorMessage}`); // Try to update sync history with error try { const historyId = await this.createSyncHistory( entity, config.syncType, config.triggeredBy ); await this.updateSyncHistory( historyId, SyncStatus.FAILED, 0, 0, 0, fullErrorMessage ); } catch (historyError) { const histErr = historyError instanceof Error ? historyError : new Error(String(historyError)); entityLogger.error('Failed to update sync history with error', {}, histErr); } entityResults.push({ entityType: entity, success: false, recordsAdded: 0, recordsUpdated: 0, recordsDeleted: 0, duration: 0, error: fullErrorMessage, }); } } const endTime = new Date(); const totalDuration = endTime.getTime() - startTime.getTime(); const result: SyncResult = { syncId, syncType: config.syncType, status: errors.length === 0 ? SyncStatus.COMPLETED : SyncStatus.FAILED, entities: entityResults, totalRecordsAdded: entityResults.reduce((sum, r) => sum + r.recordsAdded, 0), totalRecordsUpdated: entityResults.reduce((sum, r) => sum + r.recordsUpdated, 0), totalRecordsDeleted: entityResults.reduce((sum, r) => sum + r.recordsDeleted, 0), startedAt: startTime, completedAt: endTime, duration: totalDuration, errors, }; syncLogger.info(`Sync completed`, { duration: totalDuration, status: result.status, recordsAdded: result.totalRecordsAdded, recordsUpdated: result.totalRecordsUpdated, recordsDeleted: result.totalRecordsDeleted, errorCount: errors.length, }); return result; } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); const syncLogger = this.logger.child({ syncId, syncType: config.syncType }); syncLogger.error('Sync operation failed', { entitiesAttempted: config.entities.join(', '), successfulEntities: entityResults.filter(r => r.success).length, failedEntities: entityResults.filter(r => !r.success).length, }, err); throw error; } finally { this.isSyncing = false; this.currentSyncId = null; } } /** * Create sync history record * @param entity Entity type * @param syncType Sync type * @param triggeredBy User identifier * @returns Sync history ID */ async createSyncHistory( entity: EntityType, syncType: SyncType, triggeredBy?: string ): Promise { const query = ` INSERT INTO sync_history ( entity_type, sync_type, status, started_at, records_added, records_updated, records_deleted, triggered_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id `; const result = await postgresClient.query<{ id: number }>(query, [ entity, syncType, SyncStatus.STARTED, new Date(), 0, 0, 0, triggeredBy || 'system', ]); return result.rows[0].id; } /** * Update sync history record * @param id Sync history ID * @param status Sync status * @param recordsAdded Number of records added * @param recordsUpdated Number of records updated * @param recordsDeleted Number of records deleted * @param errorMessage Optional error message */ async updateSyncHistory( id: number, status: SyncStatus, recordsAdded: number, recordsUpdated: number, recordsDeleted: number, errorMessage?: string ): Promise { const query = ` UPDATE sync_history SET status = $1, completed_at = $2, records_added = $3, records_updated = $4, records_deleted = $5, error_message = $6 WHERE id = $7 `; await postgresClient.query(query, [ status, new Date(), recordsAdded, recordsUpdated, recordsDeleted, errorMessage || null, id, ]); } /** * Get sync history * @param limit Number of records to return * @param entityType Optional entity type filter * @returns Array of sync history records */ async getSyncHistory( limit: number = 50, entityType?: EntityType ): Promise { let query = ` SELECT * FROM sync_history `; const params: any[] = []; if (entityType) { query += ` WHERE entity_type = $1`; params.push(entityType); } query += ` ORDER BY started_at DESC LIMIT $${params.length + 1}`; params.push(limit); const result = await postgresClient.query(query, params); return result.rows; } /** * Get last sync info for all entities * @returns Map of entity type to last sync record */ async getLastSyncInfo(): Promise> { const query = ` SELECT DISTINCT ON (entity_type) * FROM sync_history WHERE status = 'completed' ORDER BY entity_type, completed_at DESC `; const result = await postgresClient.query(query); const map = new Map(); for (const row of result.rows) { // Check if the entity_type value exists in the EntityType enum values const entityTypeValues = Object.values(EntityType); if (entityTypeValues.includes(row.entity_type as EntityType)) { map.set(row.entity_type as EntityType, row); } } return map; } /** * Check if sync is currently running * @returns True if sync is in progress */ isSyncInProgress(): boolean { return this.isSyncing; } /** * Get current sync ID * @returns Current sync ID or null */ getCurrentSyncId(): string | null { return this.currentSyncId; } /** * Cancel current sync operation */ async cancelSync(): Promise { if (!this.isSyncing) { throw new Error('No sync operation in progress'); } // TODO: Implement graceful cancellation this.isSyncing = false; const cancelledSyncId = this.currentSyncId || 'unknown'; this.currentSyncId = null; this.logger.warn('Sync operation cancelled', { syncId: cancelledSyncId }); } } /** * Create sync service instance * @param autotaskClient Autotask client instance * @returns SyncService instance */ export function createSyncService(autotaskClient: AutotaskClient): SyncService { return new SyncService(autotaskClient); }