@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
438 lines • 16.4 kB
JavaScript
/**
* 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