UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

273 lines 10.7 kB
/** * Generic State Change Detector * * Compares entity states between syncs to detect and record state changes * that aren't captured by Optimizely's change history API. * * Supports multiple entity types: flags, experiments, variations, rulesets, rules, extensions, webhooks */ import { getLogger } from '../logging/Logger.js'; export class StateChangeDetector { storage; static ENTITY_CONFIGS = { flag: { tableName: 'flag_environments', snapshotTableName: 'flag_state_snapshot', entityType: 'flag', keyColumns: ['flag_key'], stateColumns: ['enabled'], nameColumn: 'flag_key', environmentColumn: 'environment_key' }, experiment: { tableName: 'experiments', snapshotTableName: 'experiment_state_snapshot', entityType: 'experiment', keyColumns: ['id'], stateColumns: ['status'], nameColumn: 'name' }, variation: { tableName: 'variations', snapshotTableName: 'variation_state_snapshot', entityType: 'variation', keyColumns: ['id'], stateColumns: ['enabled'], nameColumn: 'name' }, ruleset: { tableName: 'rulesets', snapshotTableName: 'ruleset_state_snapshot', entityType: 'ruleset', keyColumns: ['id'], stateColumns: ['enabled'], nameColumn: 'id', environmentColumn: 'environment_key' }, rule: { tableName: 'rules', snapshotTableName: 'rule_state_snapshot', entityType: 'rule', keyColumns: ['id'], stateColumns: ['enabled'], nameColumn: 'name' }, extension: { tableName: 'extensions', snapshotTableName: 'extension_state_snapshot', entityType: 'extension', keyColumns: ['id'], stateColumns: ['enabled'], nameColumn: 'name' }, webhook: { tableName: 'webhooks', snapshotTableName: 'webhook_state_snapshot', entityType: 'webhook', keyColumns: ['id'], stateColumns: ['enabled'], nameColumn: 'name' } }; constructor(storage) { this.storage = storage; } /** * Detect and record state changes for a specific entity type */ async detectAndRecordStateChanges(projectId, entityType) { const logger = getLogger(); const config = StateChangeDetector.ENTITY_CONFIGS[entityType]; if (!config) { throw new Error(`Unsupported entity type: ${entityType}`); } try { // Get current states const currentStates = await this.getCurrentStates(projectId, config); // Get previous states from snapshot const previousStates = await this.getPreviousStates(projectId, config); // Create a map of previous states for easy lookup const previousStateMap = new Map(); previousStates.forEach((state) => { const key = this.createStateKey(state, config); previousStateMap.set(key, state); }); let changesDetected = 0; // Compare states and record changes for (const currentState of currentStates) { const key = this.createStateKey(currentState, config); const previousState = previousStateMap.get(key); if (previousState) { const changes = this.detectChanges(previousState, currentState, config); if (changes.length > 0) { for (const change of changes) { await this.recordStateChange(projectId, config, currentState, change); changesDetected++; logger.info({ projectId, entityType: config.entityType, entityId: currentState[config.keyColumns[0]], change }, 'State change detected'); } } } } // Update the snapshot with current states await this.updateStateSnapshot(projectId, config, currentStates); return changesDetected; } catch (error) { logger.error({ error, projectId, entityType }, 'Failed to detect state changes'); // Don't throw - just log and return 0 to prevent crashes return 0; } } /** * Initialize snapshot tables for all supported entity types */ async initializeSnapshotTables() { for (const [entityType, config] of Object.entries(StateChangeDetector.ENTITY_CONFIGS)) { await this.createSnapshotTable(config); } } async createSnapshotTable(config) { const columns = [ 'project_id TEXT NOT NULL', ...config.keyColumns.map(col => `${col} TEXT NOT NULL`), ...config.stateColumns.map(col => `${col} TEXT`), config.environmentColumn ? `${config.environmentColumn} TEXT` : null, 'snapshot_time TEXT NOT NULL' ].filter(Boolean).join(', '); const primaryKey = ['project_id', ...config.keyColumns, config.environmentColumn].filter(Boolean).join(', '); await this.storage.run(` CREATE TABLE IF NOT EXISTS ${config.snapshotTableName} ( ${columns}, PRIMARY KEY (${primaryKey}) ) `); } async getCurrentStates(projectId, config) { const columns = [ ...config.keyColumns, ...config.stateColumns, config.nameColumn, config.environmentColumn ].filter(Boolean).join(', '); return await this.storage.query(` SELECT ${columns} FROM ${config.tableName} WHERE project_id = ? `, [projectId]); } async getPreviousStates(projectId, config) { const columns = [ ...config.keyColumns, ...config.stateColumns, config.environmentColumn ].filter(Boolean).join(', '); return await this.storage.query(` SELECT ${columns} FROM ${config.snapshotTableName} WHERE project_id = ? `, [projectId]); } createStateKey(state, config) { const keyParts = config.keyColumns.map(col => state[col]); if (config.environmentColumn && state[config.environmentColumn]) { keyParts.push(state[config.environmentColumn]); } return keyParts.join(':'); } detectChanges(previousState, currentState, config) { const changes = []; for (const stateColumn of config.stateColumns) { const oldValue = previousState[stateColumn]; const newValue = currentState[stateColumn]; if (oldValue !== newValue) { let action = 'updated'; // Special handling for boolean enabled/disabled fields if (stateColumn === 'enabled') { action = newValue ? 'enabled' : 'disabled'; } else if (stateColumn === 'status') { action = `status_changed_to_${newValue}`; } changes.push({ field: stateColumn, oldValue, newValue, action }); } } return changes; } async recordStateChange(projectId, config, currentState, change) { const entityId = currentState[config.keyColumns[0]]; const entityName = config.nameColumn ? currentState[config.nameColumn] : entityId; let changeSummary = `${config.entityType} ${change.action}`; if (config.environmentColumn && currentState[config.environmentColumn]) { changeSummary += ` in environment: ${currentState[config.environmentColumn]}`; } if (change.field === 'status') { changeSummary = `${config.entityType} status changed from ${change.oldValue} to ${change.newValue}`; } 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, change.action, new Date().toISOString(), 'sync-detector', // We don't know who made the change changeSummary ]); } async updateStateSnapshot(projectId, config, states) { // Clear previous snapshot for this project await this.storage.run(` DELETE FROM ${config.snapshotTableName} WHERE project_id = ? `, [projectId]); // Insert current states as new snapshot for (const state of states) { const columns = ['project_id', ...config.keyColumns, ...config.stateColumns]; const values = [projectId, ...config.keyColumns.map(col => state[col]), ...config.stateColumns.map(col => state[col])]; if (config.environmentColumn) { columns.push(config.environmentColumn); values.push(state[config.environmentColumn]); } columns.push('snapshot_time'); values.push(new Date().toISOString()); const placeholders = values.map(() => '?').join(', '); await this.storage.run(` INSERT INTO ${config.snapshotTableName} (${columns.join(', ')}) VALUES (${placeholders}) `, values); } } /** * Detect state changes for all supported entity types */ async detectAllStateChanges(projectId) { let totalChanges = 0; for (const entityType of Object.keys(StateChangeDetector.ENTITY_CONFIGS)) { try { const changes = await this.detectAndRecordStateChanges(projectId, entityType); totalChanges += changes; } catch (error) { getLogger().error({ error, projectId, entityType }, 'Failed to detect state changes for entity type'); // Continue with other entity types } } return totalChanges; } } //# sourceMappingURL=StateChangeDetector.js.map