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