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