UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

387 lines 16.2 kB
/** * Change History Tracker for Optimizely MCP Server * @description Manages change history tracking and deduplication for incremental sync */ import { getLogger } from '../logging/Logger.js'; import { MCPErrorMapper } from '../errors/MCPErrorMapping.js'; /** * Tracks and manages change history for incremental synchronization */ export class ChangeHistoryTracker { storage; api; constructor(storage, api) { this.storage = storage; this.api = api; } /** * Gets the last sync state for a project */ async getSyncState(projectId) { const result = await this.storage.get('SELECT * FROM sync_state WHERE project_id = ?', [projectId] // Use string project_id to match the TEXT column type ); if (!result) { getLogger().debug({ projectId, projectIdType: typeof projectId, query: 'SELECT * FROM sync_state WHERE project_id = ?' }, 'ChangeHistoryTracker: No sync state found for project'); return null; } getLogger().debug({ projectId, lastSyncTime: result.last_sync_time, lastSuccessfulSync: result.last_successful_sync, syncInProgress: result.sync_in_progress }, 'ChangeHistoryTracker: Found sync state for project'); return { project_id: result.project_id, last_sync_time: result.last_sync_time, last_successful_sync: result.last_successful_sync, sync_in_progress: result.sync_in_progress === 1, error_count: result.error_count || 0, last_error: result.last_error }; } /** * Updates the sync state for a project */ async updateSyncState(projectId, state) { const currentState = await this.getSyncState(projectId); const newState = { ...currentState, project_id: projectId, ...state }; // Log the sync state update for debugging getLogger().info({ projectId, currentState: { last_sync_time: currentState?.last_sync_time, last_successful_sync: currentState?.last_successful_sync, error_count: currentState?.error_count }, updateRequest: state, newState: { last_sync_time: newState.last_sync_time, last_successful_sync: newState.last_successful_sync, error_count: newState.error_count } }, 'ChangeHistoryTracker: Updating sync state'); // Only set last_sync_time if explicitly provided or if it's a new record const lastSyncTime = newState.last_sync_time || (currentState ? currentState.last_sync_time : new Date().toISOString()); await this.storage.run(` INSERT OR REPLACE INTO sync_state (project_id, last_sync_time, last_successful_sync, sync_in_progress, error_count, last_error) VALUES (?, ?, ?, ?, ?, ?) `, [ newState.project_id, lastSyncTime, newState.last_successful_sync || (currentState ? currentState.last_successful_sync : null), newState.sync_in_progress !== undefined ? (newState.sync_in_progress ? 1 : 0) : (currentState ? (currentState.sync_in_progress ? 1 : 0) : 0), newState.error_count !== undefined ? newState.error_count : (currentState ? currentState.error_count : 0), newState.last_error !== undefined ? newState.last_error : (currentState ? currentState.last_error : null) ]); } /** * Gets the project type information from the database */ async getProjectType(projectId) { const result = await this.storage.get('SELECT platform, is_flags_enabled FROM projects WHERE id = ?', [projectId] // Already a string, no parseInt needed ); return { platform: result?.platform || null, is_flags_enabled: result?.is_flags_enabled === 1 }; } /** * Gets changes since a specific timestamp for a project */ async getChangesSince(projectId, since) { try { getLogger().info({ projectId, since: since.toISOString() }, 'ChangeHistoryTracker: Fetching changes since timestamp'); // Get project type to determine which API approach to use const projectType = await this.getProjectType(projectId); getLogger().info({ projectId, platform: projectType.platform, is_flags_enabled: projectType.is_flags_enabled }, 'ChangeHistoryTracker: Project type detected'); if (projectType.is_flags_enabled) { // Feature Experimentation: Use two-step process return await this.getFeatureExperimentationChanges(projectId, since); } else { // Web Experimentation: Use direct change history call return await this.getWebExperimentationChanges(projectId, since); } } catch (error) { getLogger().error({ projectId, error: error.message }, 'ChangeHistoryTracker: Failed to fetch change history'); throw MCPErrorMapper.toMCPError(error, `Failed to fetch change history for project ${projectId}`); } } /** * Gets changes for Feature Experimentation projects */ async getFeatureExperimentationChanges(projectId, since) { getLogger().info({ projectId, since: since.toISOString() }, 'ChangeHistoryTracker: Getting Feature Experimentation changes'); // CRITICAL FIX: For Feature Experimentation projects, we need to get ALL changes, // not just flag-associated entities. Many entities (events, attributes, audiences) // can exist independently of flags and must be synced. // Direct call to change history API without entity_ids constraint // This ensures we capture ALL changes including standalone entities const changes = await this.api.getChangeHistory(projectId, { since: since.toISOString(), per_page: 100 }); getLogger().info({ projectId, rawChangesCount: changes.length, rawChanges: changes.slice(0, 2) // Log first 2 changes for debugging }, 'ChangeHistoryTracker: Retrieved ALL Feature Experimentation changes from API'); // Transform using Feature Experimentation field mapping const changeEvents = this.transformFeatureExperimentationChanges(projectId, changes); getLogger().info({ projectId, changeCount: changeEvents.length }, 'ChangeHistoryTracker: Transformed Feature Experimentation changes'); return changeEvents; } /** * Gets changes for Web Experimentation projects using direct API call */ async getWebExperimentationChanges(projectId, since) { getLogger().info({ projectId }, 'ChangeHistoryTracker: Using Web Experimentation direct call'); // Direct call to change history API const changes = await this.api.getChangeHistory(projectId, { since: since.toISOString(), per_page: 100 }); getLogger().info({ projectId, rawChangesCount: changes.length, rawChanges: changes.slice(0, 2) // Log first 2 changes for debugging }, 'ChangeHistoryTracker: Retrieved Web Experimentation changes from API'); // Transform using Web Experimentation field mapping const changeEvents = this.transformWebExperimentationChanges(projectId, changes); getLogger().info({ projectId, changeCount: changeEvents.length }, 'ChangeHistoryTracker: Retrieved Web Experimentation changes'); return changeEvents; } /** * Transforms Feature Experimentation API responses to ChangeEvent format */ transformFeatureExperimentationChanges(projectId, changes) { // Filter out invalid changes before transformation const validChanges = changes.filter((change, index) => { if (!change.entity?.id || !change.created) { getLogger().warn({ projectId, changeIndex: index, entity: change.entity, created: change.created, change_type: change.change_type }, 'ChangeHistoryTracker: Skipping Feature Experimentation change with missing required API fields'); return false; } // CRITICAL: Filter out changes from other projects! // The Change History API returns changes for ALL projects, not just the one specified if (change.project_id && String(change.project_id) !== String(projectId)) { getLogger().debug({ requestedProjectId: projectId, changeProjectId: change.project_id, entityType: change.entity?.type, entityId: change.entity?.id }, 'ChangeHistoryTracker: Filtering out change from different project'); return false; } return true; }); // Transform using Feature Experimentation field mapping return validChanges.map((change, index) => { try { // Use the actual project_id from the change event if available const changeProjectId = change.project_id || projectId; // Log if there's a mismatch if (change.project_id && String(change.project_id) !== String(projectId)) { getLogger().error({ requestedProjectId: projectId, actualProjectId: change.project_id, entityType: change.entity?.type, entityId: change.entity?.id }, 'ChangeHistoryTracker: CRITICAL - Change event has different project_id than requested!'); } return { project_id: String(changeProjectId), entity_type: this.mapEntityType(change.entity.type), entity_id: String(change.entity.id), entity_name: change.entity.name, // Store the entity name (flag key for flags) action: this.mapChangeType(change.change_type), timestamp: change.created, changed_by: change.user?.id || change.user?.email, change_summary: change.summary || undefined }; } catch (error) { getLogger().error({ projectId, changeIndex: index, change, error: error.message }, 'ChangeHistoryTracker: Error processing Feature Experimentation change event'); throw error; } }); } /** * Transforms Web Experimentation API responses to ChangeEvent format * NOTE: Both Web and Feature Experimentation use the same change history API format */ transformWebExperimentationChanges(projectId, changes) { // Both project types use the same change history API format return this.transformFeatureExperimentationChanges(projectId, changes); } /** * Deduplicates changes to unique entities */ async deduplicateChanges(changes) { const uniqueEntities = new Map(); for (const change of changes) { const entityType = change.entity_type; if (!uniqueEntities.has(entityType)) { uniqueEntities.set(entityType, new Set()); } uniqueEntities.get(entityType).add(change.entity_id); } // Log deduplication results const stats = Array.from(uniqueEntities.entries()).map(([type, ids]) => ({ type, uniqueCount: ids.size })); getLogger().info({ totalChanges: changes.length, uniqueEntities: stats }, 'ChangeHistoryTracker: Deduplicated changes'); return uniqueEntities; } /** * Records a change event in the database */ async recordChange(change) { await this.storage.run(` INSERT OR REPLACE INTO change_history (project_id, entity_type, entity_id, entity_name, action, timestamp, changed_by, change_summary, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, [ change.project_id, change.entity_type, change.entity_id, change.entity_name || null, change.action, change.timestamp, change.changed_by || null, change.change_summary || null ]); } /** * Records multiple change events in a batch */ async recordChanges(changes) { getLogger().info({ changeCount: changes.length }, 'ChangeHistoryTracker: Recording changes to database'); await this.storage.transaction(async () => { for (const change of changes) { await this.recordChange(change); } }); getLogger().info({ changeCount: changes.length }, 'ChangeHistoryTracker: Successfully recorded changes to database'); } /** * Maps Optimizely's entity.type to our entity_type */ mapEntityType(entityType) { if (!entityType) { getLogger().warn({ entityType }, 'ChangeHistoryTracker: entityType is undefined, defaulting to flag'); return 'flag'; } const typeMap = { 'flag': 'flag', 'feature_flag': 'flag', 'experiment': 'experiment', 'campaign': 'campaign', 'audience': 'audience', 'page': 'page', 'event': 'event', 'attribute': 'attribute', 'custom_attribute': 'attribute' }; return typeMap[entityType.toLowerCase()] || 'flag'; } /** * Maps Optimizely's change_type to our action type */ mapChangeType(changeType) { if (!changeType) { getLogger().warn({ changeType }, 'ChangeHistoryTracker: changeType is undefined, defaulting to updated'); return 'updated'; } const actionMap = { 'create': 'created', 'created': 'created', 'update': 'updated', 'updated': 'updated', 'delete': 'deleted', 'deleted': 'deleted', 'archive': 'archived', 'archived': 'archived' }; return actionMap[changeType.toLowerCase()] || 'updated'; } /** * Maps Optimizely's target_type to our entity_type (legacy method for backward compatibility) */ mapTargetType(targetType) { return this.mapEntityType(targetType); } /** * Maps Optimizely's action to our action type (legacy method for backward compatibility) */ mapAction(action) { return this.mapChangeType(action); } /** * Gets the count of unsynced changes */ async getUnsyncedChangeCount(projectId) { const result = await this.storage.get('SELECT COUNT(*) as count FROM change_history WHERE project_id = ? AND synced_at IS NULL', [projectId] // Use string project_id to match the TEXT column type ); return result?.count || 0; } /** * Marks changes as synced */ async markChangesSynced(projectId, entityType, entityId) { await this.storage.run(` UPDATE change_history SET synced_at = CURRENT_TIMESTAMP WHERE project_id = ? AND entity_type = ? AND entity_id = ? AND synced_at IS NULL `, [projectId, entityType, entityId]); // Use string project_id to match the TEXT column type } } //# sourceMappingURL=ChangeHistoryTracker.js.map