UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

438 lines 16.4 kB
/** * Comprehensive Change Detector * * Detects ALL types of changes across ALL Optimizely entities by comparing * complete entity snapshots between syncs. This includes: * - State changes (enabled/disabled, status) * - Property changes (name, description, conditions) * - Structural changes (added/removed entities) * - Configuration changes (targeting, audiences, etc.) */ import { getLogger } from '../logging/Logger.js'; import * as crypto from 'crypto'; export class ComprehensiveChangeDetector { storage; static ENTITY_CONFIGS = { // Entities with state changes flag: { tableName: 'flags', entityType: 'flag', primaryKey: 'key', nameColumn: 'name', trackAllChanges: true, excludeColumns: ['synced_at', 'created_at', 'updated_at'], specialHandling: { jsonColumns: ['data_json'], environmentAware: true } }, flag_environment: { tableName: 'flag_environments', entityType: 'flag', primaryKey: 'flag_key,environment_key', nameColumn: 'flag_key', trackAllChanges: true, excludeColumns: ['synced_at'], specialHandling: { environmentAware: true } }, experiment: { tableName: 'experiments', entityType: 'experiment', primaryKey: 'id', nameColumn: 'name', trackAllChanges: true, excludeColumns: ['synced_at', 'created_at', 'updated_at', 'results_json'], specialHandling: { jsonColumns: ['data_json', 'metrics', 'traffic_allocation', 'variations'] } }, // Content entities that can change audience: { tableName: 'audiences', entityType: 'audience', primaryKey: 'id', nameColumn: 'name', trackAllChanges: true, excludeColumns: ['synced_at', 'created_at', 'updated_at'], specialHandling: { jsonColumns: ['conditions'] } }, page: { tableName: 'pages', entityType: 'page', primaryKey: 'id', nameColumn: 'name', trackAllChanges: true, excludeColumns: ['synced_at', 'created_at', 'updated_at'], specialHandling: { jsonColumns: ['conditions'] } }, event: { tableName: 'events', entityType: 'event', primaryKey: 'id', nameColumn: 'name', trackAllChanges: true, excludeColumns: ['synced_at', 'created_at', 'updated_at'], specialHandling: { jsonColumns: ['event_properties', 'config'] } }, attribute: { tableName: 'attributes', entityType: 'attribute', primaryKey: 'id', nameColumn: 'name', trackAllChanges: true, excludeColumns: ['synced_at', 'created_at', 'updated_at'] }, extension: { tableName: 'extensions', entityType: 'extension', primaryKey: 'id', nameColumn: 'name', trackAllChanges: true, excludeColumns: ['synced_at', 'created_at', 'updated_at'], specialHandling: { jsonColumns: ['fields', 'implementation'] } }, webhook: { tableName: 'webhooks', entityType: 'webhook', primaryKey: 'id', nameColumn: 'name', trackAllChanges: true, excludeColumns: ['synced_at', 'created_at', 'updated_at'], specialHandling: { jsonColumns: ['events'] } }, group: { tableName: 'groups', entityType: 'group', primaryKey: 'id', nameColumn: 'name', trackAllChanges: true, excludeColumns: ['synced_at', 'created_at', 'updated_at'], specialHandling: { jsonColumns: ['entities'] } }, variation: { tableName: 'variations', entityType: 'variation', primaryKey: 'id', nameColumn: 'name', trackAllChanges: true, excludeColumns: ['synced_at', 'created_at', 'updated_at'], specialHandling: { jsonColumns: ['actions', 'variable_values'] } }, ruleset: { tableName: 'rulesets', entityType: 'ruleset', primaryKey: 'flag_key,environment_key', nameColumn: 'flag_key', trackAllChanges: true, excludeColumns: ['synced_at'], specialHandling: { jsonColumns: ['data_json'] } }, rule: { tableName: 'rules', entityType: 'rule', primaryKey: 'key', nameColumn: 'name', trackAllChanges: true, excludeColumns: ['synced_at', 'created_at', 'updated_at'], specialHandling: { jsonColumns: ['conditions', 'data_json'] } } }; constructor(storage) { this.storage = storage; } /** * Initialize snapshot tables for all entities */ async initializeAllSnapshotTables() { const logger = getLogger(); try { // Create the universal snapshot table that stores all entity snapshots await this.storage.run(` CREATE TABLE IF NOT EXISTS entity_snapshots ( project_id TEXT NOT NULL, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, snapshot_hash TEXT NOT NULL, snapshot_data TEXT NOT NULL, snapshot_time TEXT NOT NULL, PRIMARY KEY (project_id, entity_type, entity_id) ) `); // Create index for faster lookups await this.storage.run(` CREATE INDEX IF NOT EXISTS idx_entity_snapshots_type ON entity_snapshots(entity_type, project_id) `); logger.info('Initialized entity snapshot tables'); } catch (error) { logger.error({ error }, 'Failed to initialize snapshot tables'); // Don't throw - allow sync to continue } } /** * Detect and record all changes for a project */ async detectAllChanges(projectId) { const logger = getLogger(); let totalChanges = 0; for (const [entityKey, config] of Object.entries(ComprehensiveChangeDetector.ENTITY_CONFIGS)) { try { const changes = await this.detectEntityChanges(projectId, entityKey, config); totalChanges += changes; } catch (error) { logger.error({ error, entityKey, projectId }, 'Failed to detect changes for entity type'); // Continue with other entities } } return totalChanges; } /** * Detect changes for a specific entity type */ async detectEntityChanges(projectId, entityKey, config) { const logger = getLogger(); try { // Get current entities const currentEntities = await this.getCurrentEntities(projectId, config); // Get previous snapshots const previousSnapshots = await this.getPreviousSnapshots(projectId, config.entityType); // Create maps for comparison const currentMap = new Map(); const previousMap = new Map(); // entity_id -> hash // Process current entities for (const entity of currentEntities) { const entityId = this.getEntityId(entity, config); const entityData = this.prepareEntityData(entity, config); const hash = this.hashEntityData(entityData); currentMap.set(entityId, { entity, data: entityData, hash }); } // Process previous snapshots for (const snapshot of previousSnapshots) { previousMap.set(snapshot.entity_id, snapshot.snapshot_hash); } let changesDetected = 0; // Detect changes and new entities for (const [entityId, current] of currentMap) { const previousHash = previousMap.get(entityId); if (!previousHash) { // New entity await this.recordChange(projectId, config, current.entity, 'created', null); changesDetected++; } else if (previousHash !== current.hash) { // Modified entity const changeType = await this.detectChangeType(current.entity, config, projectId, entityId); await this.recordChange(projectId, config, current.entity, changeType, previousHash); changesDetected++; } // Update snapshot await this.updateSnapshot(projectId, config.entityType, entityId, current.hash, current.data); } // Detect deleted entities for (const [entityId, previousHash] of previousMap) { if (!currentMap.has(entityId)) { // Entity was deleted await this.recordChange(projectId, config, { id: entityId }, 'deleted', previousHash); await this.removeSnapshot(projectId, config.entityType, entityId); changesDetected++; } } if (changesDetected > 0) { logger.info({ projectId, entityType: config.entityType, changesDetected }, 'Detected entity changes'); } return changesDetected; } catch (error) { logger.error({ error, entityKey }, 'Failed to detect entity changes'); return 0; } } /** * Get current entities from the database */ async getCurrentEntities(projectId, config) { const columns = await this.getTableColumns(config.tableName); const query = ` SELECT * FROM ${config.tableName} WHERE project_id = ? ${config.excludeColumns.includes('archived') ? '' : 'AND (archived = 0 OR archived IS NULL)'} `; return await this.storage.query(query, [projectId]); } /** * Get previous snapshots */ async getPreviousSnapshots(projectId, entityType) { return await this.storage.query(` SELECT entity_id, snapshot_hash, snapshot_data FROM entity_snapshots WHERE project_id = ? AND entity_type = ? `, [projectId, entityType]); } /** * Get entity ID based on config */ getEntityId(entity, config) { const keys = config.primaryKey.split(','); return keys.map(key => entity[key.trim()]).join(':'); } /** * Prepare entity data for comparison (exclude certain columns) */ prepareEntityData(entity, config) { const data = { ...entity }; // Remove excluded columns for (const col of config.excludeColumns) { delete data[col]; } // Sort JSON columns for consistent comparison if (config.specialHandling?.jsonColumns) { for (const col of config.specialHandling.jsonColumns) { if (data[col] && typeof data[col] === 'string') { try { data[col] = JSON.stringify(JSON.parse(data[col]), null, 2); } catch { // Keep as is if not valid JSON } } } } return data; } /** * Hash entity data for comparison */ hashEntityData(data) { const str = JSON.stringify(data, Object.keys(data).sort()); return crypto.createHash('sha256').update(str).digest('hex'); } /** * Detect the type of change */ async detectChangeType(entity, config, projectId, entityId) { // For entities with state fields, check if it's a state change if (entity.enabled !== undefined || entity.status !== undefined) { const previousSnapshot = await this.storage.get(` SELECT snapshot_data FROM entity_snapshots WHERE project_id = ? AND entity_type = ? AND entity_id = ? `, [projectId, config.entityType, entityId]); if (previousSnapshot) { try { const previousData = JSON.parse(previousSnapshot.snapshot_data); // Check for state changes if (entity.enabled !== undefined && previousData.enabled !== entity.enabled) { return entity.enabled ? 'enabled' : 'disabled'; } if (entity.status !== undefined && previousData.status !== entity.status) { // Map status changes to action types if (entity.status === 'running' && previousData.status !== 'running') { return 'started'; } else if (entity.status === 'paused' && previousData.status === 'running') { return 'paused'; } else if (entity.status === 'archived') { return 'archived'; } } } catch { // If we can't parse previous data, default to updated } } } return 'updated'; } /** * Record a change in the change_history table */ async recordChange(projectId, config, entity, action, previousHash) { const entityName = entity[config.nameColumn] || entity.name || entity.key || entity.id; const entityId = this.getEntityId(entity, config); // Generate meaningful change summary let changeSummary = `${config.entityType.charAt(0).toUpperCase() + config.entityType.slice(1)} ${action}`; if (config.specialHandling?.environmentAware && entity.environment_key) { changeSummary += ` in environment: ${entity.environment_key}`; } if (entityName && action !== 'deleted') { changeSummary = `${changeSummary}: '${entityName}'`; } await this.storage.run(` INSERT INTO change_history (project_id, entity_type, entity_id, entity_name, action, timestamp, changed_by, change_summary, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, [ projectId, config.entityType, entityId, entityName, action, new Date().toISOString(), 'change-detector', // We don't know who made the change changeSummary ]); } /** * Update entity snapshot */ async updateSnapshot(projectId, entityType, entityId, hash, data) { await this.storage.run(` INSERT OR REPLACE INTO entity_snapshots (project_id, entity_type, entity_id, snapshot_hash, snapshot_data, snapshot_time) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, [ projectId, entityType, entityId, hash, JSON.stringify(data) ]); } /** * Remove entity snapshot (for deleted entities) */ async removeSnapshot(projectId, entityType, entityId) { await this.storage.run(` DELETE FROM entity_snapshots WHERE project_id = ? AND entity_type = ? AND entity_id = ? `, [projectId, entityType, entityId]); } /** * Get table columns to build dynamic queries */ async getTableColumns(tableName) { const columns = await this.storage.query(` PRAGMA table_info(${tableName}) `); return columns.map((col) => col.name); } } //# sourceMappingURL=ComprehensiveChangeDetector.js.map