@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
1,015 lines • 293 kB
JavaScript
/**
* Entity Router for Parametric Tool Architecture
* @description Central routing system for entity operations across Optimizely platforms.
* Enables scaling from entity-specific tools to parametric tools that can handle
* any entity type through a unified interface.
*
* This router is the foundation of the parametric architecture that allows us to:
* - Scale from 11 tools to ~20 tools while covering 36+ API endpoints
* - Maintain consistent operation patterns across all entity types
* - Support both Web Experimentation and Feature Experimentation platforms
* - Enable future entity types without creating new tools
*/
import { getLogger } from '../logging/Logger.js';
import { MCPErrorMapper } from '../errors/MCPErrorMapping.js';
import { ConfigurableDefaultsManager } from '../defaults/ConfigurableDefaultsManager.js';
import { SchemaAwareEntityBuilder } from '../builders/SchemaAwareEntityBuilder.js';
import { safeIdToString } from '../utils/SafeIdConverter.js';
import { HardStopError, HardStopErrorType } from '../errors/HardStopError.js';
import { PaginationConfigManager } from '../config/PaginationConfig.js';
import { OptimizelyMCPTools } from './OptimizelyMCPTools.js';
/**
* EntityRouter Class
* @description Routes entity operations to appropriate handlers based on entity type
* and platform. Provides a unified interface for all entity operations while handling
* the complexity of different API patterns and requirements.
*/
export class EntityRouter {
apiHelper;
cacheManager;
storage;
defaultsManager;
schemaBuilder;
mcpTools;
/**
* Project type cache to avoid repeated API calls
* Maps project ID to project type information
*/
projectTypeCache = new Map();
/**
* Cache TTL in milliseconds (30 minutes)
*/
CACHE_TTL = 30 * 60 * 1000;
/**
* Entity metadata registry for routing decisions
*/
entityMetadata = {
// Projects
project: {
platform: 'web',
apiEndpoint: '/v2/projects',
supportsArchive: false,
supportsPagination: true,
paginationType: 'page',
identifierField: 'id'
},
// Feature Experimentation entities
flag: {
platform: 'feature',
apiEndpoint: '/flags/v1/projects/{project_id}/flags',
supportsArchive: true,
supportsPagination: true,
paginationType: 'cursor',
identifierField: 'key',
parentEntity: 'project'
},
feature: {
platform: 'feature',
apiEndpoint: '/v2/features',
supportsArchive: true,
supportsPagination: true,
paginationType: 'page',
identifierField: 'id',
parentEntity: 'project'
},
feature_variable: {
platform: 'feature',
apiEndpoint: '/v2/features/{feature_id}/variables',
supportsArchive: false,
supportsPagination: false,
paginationType: 'page',
identifierField: 'id',
parentEntity: 'feature'
},
campaign: {
platform: 'web',
apiEndpoint: '/v2/projects/{project_id}/campaigns',
supportsArchive: true,
supportsPagination: true,
paginationType: 'page',
identifierField: 'id',
parentEntity: 'project'
},
environment: {
platform: 'auto', // Support both Feature and Web Experimentation
apiEndpoint: '/projects/{project_id}/environments', // Web format, Feature uses different base
supportsArchive: true,
supportsPagination: true,
paginationType: 'page',
identifierField: 'key',
parentEntity: 'project'
},
ruleset: {
platform: 'feature',
apiEndpoint: '/flags/v1/projects/{project_id}/flags/{flag_key}/environments/{environment_key}/ruleset',
supportsArchive: false,
supportsPagination: false,
paginationType: 'page',
identifierField: 'id',
parentEntity: 'flag'
},
rule: {
platform: 'feature',
apiEndpoint: '/flags/v1/projects/{project_id}/flags/{flag_key}/environments/{environment_key}/rules',
supportsArchive: false,
supportsPagination: true,
paginationType: 'page',
identifierField: 'id',
parentEntity: 'ruleset'
},
variation: {
platform: 'feature',
apiEndpoint: '/flags/v1/projects/{project_id}/flags/{flag_key}/variations',
supportsArchive: true,
supportsPagination: true,
paginationType: 'page',
identifierField: 'key',
parentEntity: 'flag'
},
variable_definition: {
platform: 'feature',
apiEndpoint: '/flags/v1/projects/{project_id}/flags/{flag_key}/variable_definitions',
supportsArchive: false,
supportsPagination: true,
paginationType: 'page',
identifierField: 'key',
parentEntity: 'flag'
},
// Web Experimentation entities
experiment: {
platform: 'web',
apiEndpoint: '/v2/projects/{project_id}/experiments',
supportsArchive: true,
supportsPagination: true,
paginationType: 'page',
identifierField: 'id',
parentEntity: 'project'
},
audience: {
platform: 'auto', // Audiences are shared across both platforms
apiEndpoint: '/v2/audiences',
supportsArchive: true,
supportsPagination: true,
paginationType: 'page',
identifierField: 'id',
parentEntity: 'project'
},
page: {
platform: 'web',
apiEndpoint: '/v2/pages',
supportsArchive: true,
supportsPagination: true,
paginationType: 'page',
identifierField: 'id',
parentEntity: undefined // Pages are global, not project-scoped
},
event: {
platform: 'auto', // Events are shared across both Feature and Web Experimentation
apiEndpoint: '/v2/events',
supportsArchive: true,
supportsPagination: true,
paginationType: 'page',
identifierField: 'id',
parentEntity: 'project'
},
attribute: {
platform: 'auto', // Attributes are shared across both platforms
apiEndpoint: '/v2/attributes',
supportsArchive: true,
supportsPagination: true,
paginationType: 'page',
identifierField: 'id',
parentEntity: 'project'
},
list_attribute: {
platform: 'web',
apiEndpoint: '/v2/list_attributes',
supportsArchive: false,
supportsPagination: true,
paginationType: 'page',
identifierField: 'id',
parentEntity: undefined // List attributes can be filtered by project but are not strictly project-scoped
},
extension: {
platform: 'web',
apiEndpoint: '/v2/projects/{project_id}/extensions',
supportsArchive: false,
supportsPagination: true,
paginationType: 'page',
identifierField: 'id',
parentEntity: 'project'
},
group: {
platform: 'web',
apiEndpoint: '/v2/projects/{project_id}/groups',
supportsArchive: false,
supportsPagination: true,
paginationType: 'page',
identifierField: 'id',
parentEntity: 'project'
},
segment: {
platform: 'web',
apiEndpoint: '/v2/projects/{project_id}/segments',
supportsArchive: false,
supportsPagination: true,
paginationType: 'page',
identifierField: 'id',
parentEntity: 'project'
},
webhook: {
platform: 'web',
apiEndpoint: '/v2/projects/{project_id}/webhooks',
supportsArchive: false,
supportsPagination: true,
paginationType: 'page',
identifierField: 'id',
parentEntity: 'project'
},
collaborator: {
platform: 'web',
apiEndpoint: '/v2/projects/{project_id}/collaborators',
supportsArchive: false,
supportsPagination: true,
paginationType: 'page',
identifierField: 'id',
parentEntity: 'project'
},
// Analytics entities
report: {
platform: 'web',
apiEndpoint: '/v2/reports',
supportsArchive: false,
supportsPagination: true,
paginationType: 'page',
identifierField: 'id',
parentEntity: 'project'
},
result: {
platform: 'web',
apiEndpoint: '/v2/experiments/{experiment_id}/results',
supportsArchive: false,
supportsPagination: false,
paginationType: 'page',
identifierField: 'id',
parentEntity: 'experiment'
},
recommendation: {
platform: 'web',
apiEndpoint: '/v2/projects/{project_id}/recommendations',
supportsArchive: false,
supportsPagination: true,
paginationType: 'page',
identifierField: 'id',
parentEntity: 'project'
}
};
constructor(apiHelper, cacheManager, storage) {
this.apiHelper = apiHelper;
this.cacheManager = cacheManager;
this.storage = storage;
// Initialize schema-aware entity creation system
this.defaultsManager = new ConfigurableDefaultsManager();
this.schemaBuilder = new SchemaAwareEntityBuilder(this.defaultsManager);
getLogger().info('EntityRouter: Initialized with schema-aware entity creation (jQuery problem solution)');
}
/**
* Get or create OptimizelyMCPTools instance
* @returns OptimizelyMCPTools instance
*/
getMCPTools() {
if (!this.mcpTools) {
this.mcpTools = new OptimizelyMCPTools(this.cacheManager);
}
return this.mcpTools;
}
/**
* Updates local cache with entity data after successful API operations
* @param entityType - Type of entity that was updated
* @param apiResult - Result from API operation
* @param projectId - Project ID for the entity
* @param identifier - Entity identifier (fallback if API result doesn't contain ID)
*/
async updateCacheAfterOperation(entityType, apiResult, projectId, identifier) {
if (!apiResult || (!apiResult.id && !apiResult.key && !identifier)) {
return;
}
try {
const entityIdToCache = apiResult.id || apiResult.key || identifier;
const effectiveProjectId = entityType === 'project' ? String(apiResult.id || identifier) : projectId;
if (!effectiveProjectId || !entityIdToCache) {
return;
}
const tools = this.getMCPTools();
// For entities that don't return full data, fetch the updated entity
let dataToCache = apiResult;
if (!apiResult.id && !apiResult.key && !apiResult.name) {
// API returned minimal response, fetch full entity
try {
const fullEntity = await this.handleGetOperation(entityType, projectId, entityIdToCache);
dataToCache = fullEntity.data || fullEntity;
}
catch (fetchError) {
getLogger().warn(`Failed to fetch updated ${entityType} for caching: ${fetchError.message}`);
// Use partial data rather than skip caching
}
}
await tools.updateEntityInLocalCache(entityType, String(entityIdToCache), effectiveProjectId, dataToCache);
getLogger().info({
entityType,
entityId: entityIdToCache,
projectId: effectiveProjectId,
operation: 'update'
}, 'EntityRouter: Successfully cached updated entity');
}
catch (cacheError) {
getLogger().warn({
entityType,
entityId: apiResult.id || apiResult.key || identifier,
error: cacheError.message
}, 'EntityRouter: Failed to cache updated entity (operation still successful)');
}
}
/**
* Routes an entity operation to the appropriate handler
* @param params - Entity operation parameters
* @returns Promise resolving to operation result
*/
async routeEntityOperation(params) {
const { operation, entityType, projectId, entityId, entityKey, filters, data, options } = params;
const metadata = this.entityMetadata[entityType];
if (!metadata) {
throw MCPErrorMapper.toMCPError(new Error(`Unknown entity type: ${entityType}`), { operation: 'Route entity operation', metadata: { entityType } });
}
// Validate project exists and get project type if projectId is provided
let projectTypeInfo = null;
// CRITICAL FIX: Skip project type lookup when creating a new project
// When creating a project, the project doesn't exist yet, so we can't look up its type
const isCreatingProject = operation === 'create' && entityType === 'project';
if (projectId && !isCreatingProject) {
projectTypeInfo = await this.getProjectType(projectId);
// Validate entity availability for this project type
const validationError = this.validateEntityAvailability(entityType, projectTypeInfo.projectType, operation);
if (validationError) {
throw MCPErrorMapper.toMCPError(new Error(validationError.error), { operation: 'Route entity operation', metadata: validationError });
}
}
getLogger().info({
operation,
entityType,
projectId,
entityId,
entityKey,
platform: metadata.platform,
projectType: projectTypeInfo?.projectType,
projectName: projectTypeInfo?.projectName
}, 'EntityRouter: Routing entity operation');
// Determine platform if not specified
const platform = params.platform || projectTypeInfo?.projectType || metadata.platform;
// Store context for result enhancement
const operationContext = {
projectId,
entityType,
operation,
projectTypeInfo
};
// Route based on operation type
let result;
switch (operation) {
case 'list':
result = await this.handleListOperation(entityType, projectId, filters, options);
break;
case 'get':
result = await this.handleGetOperation(entityType, projectId, entityId || entityKey, options);
break;
case 'create':
result = await this.handleCreateOperation(entityType, projectId, data, options);
break;
case 'update':
result = await this.handleUpdateOperation(entityType, projectId, entityId || entityKey, data, options);
break;
case 'delete':
result = await this.handleDeleteOperation(entityType, projectId, entityId || entityKey, options);
break;
case 'archive':
result = await this.handleArchiveOperation(entityType, projectId, entityId || entityKey, options);
break;
case 'enable':
result = await this.handleEnableOperation(entityType, projectId, entityId || entityKey, options);
break;
case 'disable':
result = await this.handleDisableOperation(entityType, projectId, entityId || entityKey, options);
break;
case 'history':
result = await this.handleHistoryOperation(entityType, projectId, entityId || entityKey, filters, options);
break;
case 'brainstorm':
result = await this.handleBrainstormOperation(entityType, projectId, entityId || entityKey, data, options);
break;
case 'upload':
result = await this.handleUploadOperation(entityType, projectId, entityId || entityKey, data, options);
break;
case 'bulk_update':
result = await this.handleBulkUpdateOperation(entityType, projectId, data, options);
break;
case 'bulk_archive':
result = await this.handleBulkArchiveOperation(entityType, projectId, data, options);
break;
case 'get_results':
result = await this.handleGetResultsOperation(entityType, projectId, entityId || entityKey, filters, options);
break;
default:
throw MCPErrorMapper.toMCPError(new Error(`Unsupported operation: ${operation}`), { operation: 'Route entity operation', metadata: { operation, entityType } });
}
// Enhance result with project metadata if available
return this.enhanceResultWithMetadata(result, projectId, entityType, operation);
}
/**
* Handles list operations for any entity type with cache-first approach
* @returns Object with data array and pagination metadata
*/
async handleListOperation(entityType, projectId, filters, options) {
const metadata = this.entityMetadata[entityType];
// 🚨 CRITICAL RULESET DEBUGGING
if (entityType === 'ruleset') {
getLogger().warn({
entityType,
projectId,
filters,
options,
hasFilters: !!filters,
filterKeys: filters ? Object.keys(filters) : [],
action: 'RULESET_HANDLE_LIST_OPERATION_START'
}, '🚨 DEBUG: handleListOperation called for ruleset');
}
// Check if entity requires project context
// Allow global searches when no projectId is provided for cache-based queries
// Only enforce project requirement for direct API calls
if (metadata.parentEntity === 'project' && !projectId && !this.cacheManager) {
throw MCPErrorMapper.toMCPError(new Error(`Project ID required for listing ${entityType} via API. For global search across all projects, ensure cache is populated.`), { operation: `List ${entityType}`, metadata: { entityType } });
}
// 🔍 CRITICAL PAGE DEBUGGING
if (entityType === 'page') {
getLogger().warn({
entityType,
projectId,
filters,
options,
isInternalOperation: filters?._internal,
pageSize: filters?.page_size,
page: filters?.page,
action: 'PAGE_LIST_START'
}, '🔍 DEBUG: Starting page list operation');
}
// 🚨 SMART EXPERIMENT ROUTING - Handle experiment confusion
// CRITICAL: Skip smart routing if searching for specific experiment by name/id
const isSpecificSearch = filters && (filters.id || filters.name || filters.key);
if (entityType === 'experiment' && projectId && !isSpecificSearch) {
return this.handleSmartExperimentRouting(projectId, filters, options);
}
// 🔄 CACHE-FIRST APPROACH: Try database first, fallback to API
try {
getLogger().debug({
entityType,
projectId,
filters,
hasCache: !!this.cacheManager
}, 'EntityRouter.handleListOperation: Attempting cache query');
const cachedResults = await this.queryFromCache(entityType, projectId, filters, options);
getLogger().debug({
entityType,
projectId,
filters,
cachedResultsCount: cachedResults?.data?.length || 0
}, 'EntityRouter.handleListOperation: Cache query completed');
// If cache has data, return it
if (cachedResults && cachedResults.data && cachedResults.data.length > 0) {
getLogger().debug({
entityType,
projectId,
count: cachedResults.data.length,
hasPagination: !!cachedResults.pagination
}, 'EntityRouter.handleListOperation: Returning cached data');
return cachedResults;
}
// For specific entity searches, if cache returns no results, the entity doesn't exist
// DON'T fall back to API for downloading entire collections
const isSpecificEntitySearch = filters && (filters.id !== undefined ||
filters.key !== undefined ||
filters.name !== undefined);
if (isSpecificEntitySearch && cachedResults && cachedResults.data && cachedResults.data.length === 0) {
// Determine if we can make a single API call based on entity type and filter
let canMakeSingleAPICall = false;
let apiIdentifier;
// Check if we can make an efficient single-entity API call
if (filters && projectId) {
switch (entityType) {
case 'audience':
case 'event':
case 'page':
case 'experiment':
case 'group':
case 'extension':
case 'webhook':
// These entities support GET by ID
if (filters.id) {
canMakeSingleAPICall = true;
apiIdentifier = filters.id;
}
break;
case 'flag':
// Flags only support GET by key, not ID
if (filters.key) {
canMakeSingleAPICall = true;
apiIdentifier = filters.key;
}
else if (filters.id) {
// Flag API doesn't support ID lookups
getLogger().info({
entityType,
projectId,
flagId: filters.id
}, 'EntityRouter.handleListOperation: Flag API requires key, not ID. Cannot fetch by ID.');
}
break;
case 'environment':
case 'feature':
case 'list_attribute':
// These support GET but don't always need projectId
if (filters.id) {
canMakeSingleAPICall = true;
apiIdentifier = filters.id;
}
break;
default:
// Other entity types may not support efficient single GET operations
break;
}
}
// If we can make a single API call, do it
if (canMakeSingleAPICall && apiIdentifier) {
getLogger().info({
entityType,
projectId,
identifier: apiIdentifier,
filterType: filters.id ? 'id' : 'key',
searchType: 'specific_entity_api_fallback'
}, 'EntityRouter.handleListOperation: Entity not in cache, attempting single entity API fetch');
try {
// Use GET operation for single entity fetch
const entity = await this.handleGetOperation(entityType, projectId, apiIdentifier, options);
getLogger().info({
entityType,
projectId,
identifier: apiIdentifier,
found: true
}, 'EntityRouter.handleListOperation: Found entity via API fallback');
// Return in list format to match expected response structure
return {
data: [entity.data || entity],
pagination: {
offset: 0,
limit: 1,
total: 1,
has_more: false
}
};
}
catch (apiError) {
// If API returns 404, entity truly doesn't exist
if (apiError.status === 404 || apiError.message?.includes('not found')) {
getLogger().info({
entityType,
projectId,
identifier: apiIdentifier,
error: apiError.message
}, 'EntityRouter.handleListOperation: Entity not found in API');
return cachedResults;
}
// Re-throw other errors
throw apiError;
}
}
// For searches we can't optimize (name searches, flag ID searches, etc.)
// return empty cache results to avoid downloading entire collection
getLogger().info({
entityType,
projectId,
filters,
searchType: 'specific_entity_no_api_fallback',
reason: canMakeSingleAPICall ? 'No suitable identifier' : 'Entity type does not support efficient single fetch',
cacheResultCount: 0
}, 'EntityRouter.handleListOperation: Cannot make efficient API call for this search');
return cachedResults;
}
// Cache is empty or stale, check if we should fall back to API
if (options?.cache_only) {
getLogger().info({
entityType,
projectId,
isSpecificSearch: isSpecificEntitySearch,
cacheResultCount: cachedResults?.data?.length || 0,
cache_only: true
}, 'EntityRouter.handleListOperation: Cache empty but cache_only=true, returning empty results');
return cachedResults || { data: [], pagination: { offset: 0, limit: 25, total: 0, has_more: false } };
}
getLogger().info({
entityType,
projectId,
isSpecificSearch: isSpecificEntitySearch,
cacheResultCount: cachedResults?.data?.length || 0
}, 'EntityRouter.handleListOperation: Cache empty for general query, falling back to API');
return await this.queryFromAPI(entityType, projectId, filters, options);
}
catch (cacheError) {
// Cache query failed, check if we should fall back to API
if (options?.cache_only) {
getLogger().warn({
entityType,
projectId,
error: cacheError?.message || String(cacheError),
cache_only: true
}, 'EntityRouter.handleListOperation: Cache query failed but cache_only=true, returning empty results');
return { data: [], pagination: { offset: 0, limit: 25, total: 0, has_more: false } };
}
getLogger().warn({
entityType,
projectId,
error: cacheError?.message || String(cacheError)
}, 'EntityRouter.handleListOperation: Cache query failed, falling back to API');
return await this.queryFromAPI(entityType, projectId, filters, options);
}
}
/**
* Query entities from local cache database with pagination support
* @returns Object with data array and pagination metadata
*/
async queryFromCache(entityType, projectId, filters, options) {
// CRITICAL: Check if cacheManager is properly initialized
if (!this.cacheManager) {
getLogger().error({
entityType,
projectId,
hasApiHelper: !!this.apiHelper,
hasStorage: !!this.storage,
hasCacheManager: !!this.cacheManager
}, 'EntityRouter.queryFromCache: CacheManager is undefined - this is a critical initialization error');
throw new Error('EntityRouter: CacheManager is undefined. This indicates a critical initialization error where the EntityRouter was not properly constructed with a valid CacheManager instance.');
}
// Initialize pagination configuration
const paginationConfig = new PaginationConfigManager();
const isInternalOperation = filters?._internal === true;
// Check for pagination bypass
const bypassPagination = isInternalOperation ||
filters?.bypass_pagination === true ||
filters?.page_size === -1 ||
filters?.page_size === 'all' ||
paginationConfig.shouldAutoBypass(entityType);
// 🛡️ PAGINATION CONSENT PROTOCOL
// If user is trying to bypass pagination without consent, require explicit approval
if (bypassPagination && !isInternalOperation && !filters?.user_consent_required && !options?.user_consent_required) {
throw new HardStopError(HardStopErrorType.CONSENT_REQUIRED, 'Pagination bypass requires user consent', `STOP: You are requesting all records for ${entityType}. This could return a large dataset. Ask the user: "Do you want me to fetch all records instead of using pagination?" Then add user_consent_required=true to your request if approved.`);
}
// Use existing CacheManager query methods where available
switch (entityType) {
case 'flag':
// Flags use special queryFlags method - need to handle pagination differently
if (bypassPagination) {
// Clean filters for queryFlags - remove parameters it doesn't understand
const { bypass_pagination, user_consent_required, page, page_size, simplified, ...cleanFilters } = filters || {};
// Get total count first
const allFlags = await this.cacheManager.queryFlags({ project_id: projectId, ...cleanFilters, limit: 10000 });
const totalCount = allFlags.length;
// Check bypass limit (but allow internal operations to exceed)
if (!isInternalOperation && totalCount > paginationConfig.getBypassLimit()) {
throw new HardStopError(HardStopErrorType.PAGINATION_BYPASS_LIMIT_EXCEEDED, 'Pagination bypass limit exceeded', `Cannot bypass pagination for ${totalCount} records (max: ${paginationConfig.getBypassLimit()}). Use export functionality or paginate through results.`);
}
// Even with bypass, check if consent is needed based on actual requested count
const consentRequired = allFlags.length > 50;
return {
data: allFlags,
pagination: {
consent_required: consentRequired,
has_more: false,
bypassed: true,
total_count: totalCount,
total_pages: 1,
current_page: 1,
page_size: totalCount,
next_page: null,
previous_page: null
}
};
}
// Normal pagination
const flagPageSize = filters?.page_size || paginationConfig.getPageSize(entityType, filters?.simplified);
const flagCurrentPage = filters?.page || 1;
// Clean filters for queryFlags - remove parameters it doesn't understand
const { bypass_pagination: _, user_consent_required: __, page: ___, page_size: ____, simplified: _____, ...cleanFilters } = filters || {};
const flagFilters = {
project_id: projectId,
...cleanFilters,
limit: flagPageSize,
offset: (flagCurrentPage - 1) * flagPageSize
};
const flags = await this.cacheManager.queryFlags(flagFilters);
// 🚨 CRITICAL FIX: Don't include offset/limit in count query
// Use the same cleanFilters for count query
const allFlagsForCount = await this.cacheManager.queryFlags({ project_id: projectId, ...cleanFilters, limit: 10000 });
const totalCount = allFlagsForCount.length;
const flagTotalPages = Math.ceil(totalCount / flagPageSize);
const flagHasMore = flagCurrentPage * flagPageSize < totalCount;
const flagConsentRequired = flagPageSize > 50;
return {
data: flags,
pagination: {
consent_required: flagConsentRequired,
has_more: flagHasMore,
total_count: totalCount,
total_pages: flagTotalPages,
current_page: flagCurrentPage,
page_size: flagPageSize,
next_page: flagHasMore ? flagCurrentPage + 1 : null,
previous_page: flagCurrentPage > 1 ? flagCurrentPage - 1 : null
}
};
case 'project':
// ⚠️ CRITICAL PROJECT FILTERING FIX ⚠️
//
// REGRESSION PREVENTION: This case is ESSENTIAL for newly created projects to appear in lists!
//
// ISSUE HISTORY: Projects were falling through to the default case which directly queries
// the database without applying the project filter. This meant newly created projects
// that were added to the filter via addAllowedProjectId() wouldn't appear in lists.
//
// ROOT CAUSE: EntityRouter.queryFromCache had no specific handling for projects, so they
// used the generic SQL query path that bypasses the ProjectFilter.filterProjects() method.
//
// CURRENT FIX:
// 1. Query all projects from the database (no SQL filtering)
// 2. Apply the in-memory project filter which includes newly created projects
// 3. Return the filtered results
//
// FAILURE WITHOUT THIS: Newly created projects exist in database but don't appear
// in list_entities results because they're filtered out at the SQL level.
//
// DO NOT REMOVE: This ensures the ProjectFilter (updated with addAllowedProjectId)
// is properly applied when listing projects from cache.
const tableName = this.getTableName(entityType);
// Projects should already be committed and visible due to explicit COMMITs in updateEntityInLocalCache
const allProjects = await this.cacheManager.storage.query(`SELECT * FROM ${tableName}`, []);
// DO NOT apply project filter to cached results - show all projects in database
// The filter should only be applied during sync operations
getLogger().debug({
allProjectsCount: allProjects.length
}, 'EntityRouter.queryFromCache: Returning all cached projects without filtering');
return { data: allProjects }; // Projects don't need pagination typically
default:
// For all other entity types, query database directly
const defaultTableName = this.getTableName(entityType);
const whereClause = this.buildWhereClause(projectId, filters, entityType);
// Handle pagination bypass
if (bypassPagination) {
// Get total count first
const countQuery = `SELECT COUNT(*) as total FROM ${defaultTableName}${whereClause.clause}`;
// Data should already be committed and visible
const countResult = await this.cacheManager.storage.query(countQuery, whereClause.params);
const totalCount = countResult[0]?.total || 0;
// Check bypass limit (but allow internal operations to exceed)
const bypassLimit = paginationConfig.getBypassLimit();
if (!isInternalOperation && totalCount > bypassLimit) {
throw new HardStopError(HardStopErrorType.PAGINATION_BYPASS_LIMIT_EXCEEDED, 'Pagination bypass limit exceeded', `Cannot bypass pagination for ${totalCount} records (max: ${bypassLimit}). Use export functionality or paginate through results.`);
}
// Return all data with bypass indicator
const allDataQuery = `SELECT * FROM ${defaultTableName}${whereClause.clause}`;
// Data should already be committed and visible
const allData = await this.cacheManager.storage.query(allDataQuery, whereClause.params);
// Even with bypass, check if consent is needed based on actual requested count
const consentRequired = allData.length > 50;
return {
data: allData.map(row => this.deserializeEntityFromStorage(entityType, row)),
pagination: {
consent_required: consentRequired,
has_more: false,
bypassed: true,
total_count: totalCount,
total_pages: 1,
current_page: 1,
page_size: totalCount,
next_page: null,
previous_page: null
}
};
}
// Normal pagination flow
const pageSize = filters?.page_size || paginationConfig.getPageSize(entityType, filters?.simplified);
const currentPage = filters?.page || 1;
const offset = (currentPage - 1) * pageSize;
// Get total count for normal pagination
const countQuery2 = `SELECT COUNT(*) as total FROM ${defaultTableName}${whereClause.clause}`;
// Data should already be committed and visible
const countResult2 = await this.cacheManager.storage.query(countQuery2, whereClause.params);
const totalCount2 = countResult2[0]?.total || 0;
// Get page data
const dataQuery = `SELECT * FROM ${defaultTableName}${whereClause.clause} LIMIT ? OFFSET ?`;
const params = [...whereClause.params, pageSize, offset];
// 🔍 CRITICAL PAGE DEBUGGING
if (entityType === 'page') {
getLogger().warn({
entityType,
tableName: defaultTableName,
query: dataQuery,
params,
projectId,
filters,
whereClause,
pageSize,
offset,
action: 'PAGE_SQL_QUERY'
}, '🔍 DEBUG: Pages SQL query about to execute');
}
// 🚨 CRITICAL RULESET DEBUGGING
if (entityType === 'ruleset') {
getLogger().warn({
entityType,
tableName: defaultTableName,
query: dataQuery,
params,
projectId,
filters,
whereClause,
pageSize,
offset,
action: 'RULESET_SQL_QUERY'
}, '🚨 DEBUG: Ruleset SQL query about to execute');
}
// 🔍 CRITICAL EVENT DEBUGGING
if (entityType === 'event') {
getLogger().warn({
entityType,
tableName: defaultTableName,
query: dataQuery,
params,
projectId,
filters,
whereClause,
pageSize,
offset,
action: 'EVENT_SQL_QUERY_DEBUG'
}, '🔍 DEBUG: Event SQL query about to execute');
}
getLogger().debug({
entityType,
tableName: defaultTableName,
query: dataQuery,
params,
projectId,
filters,
pagination: { pageSize, offset, page: currentPage }
}, 'EntityRouter.queryFromCache: Executing paginated SQL query');
// CRITICAL FIX: Force WAL checkpoint to ensure recently created data is visible
// Data should already be committed and visible from explicit COMMITs
const rawResults = await this.cacheManager.storage.query(dataQuery, params);
// DEBUG: Extra logging for ruleset queries
if (entityType === 'ruleset') {
getLogger().warn({
entityType,
tableName: defaultTableName,
query: dataQuery,
params,
rawResultsCount: rawResults?.length || 0,
rawResults,
projectId,
filters,
whereClause,
action: 'RULESET_QUERY_RESULTS'
}, '🔍 DEBUG: Ruleset SQL query executed and results received');
}
// DEBUG: Extra logging for event queries
if (entityType === 'event') {
getLogger().warn({
entityType,
tableName: defaultTableName,
query: dataQuery,
params,
rawResultsCount: rawResults?.length || 0,
rawResults,
projectId,
filters,
whereClause,
action: 'EVENT_QUERY_RESULTS'
}, '🔍 DEBUG: Event SQL query executed and results received');
}
getLogger().debug({
entityType,
rawResultsCount: rawResults?.length || 0,
firstResult: rawResults?.[0]
}, 'EntityRouter.queryFromCache: SQL query results');
// Build response with pagination metadata
const totalPages2 = Math.ceil(totalCount2 / pageSize);
const hasMore2 = currentPage * pageSize < totalCount2;
const consentRequired2 = pageSize > 50;
return {
data: rawResults.map(row => this.deserializeEntityFromStorage(entityType, row)),
pagination: {
consent_required: consentRequired2,
has_more: hasMore2,
total_count: totalCount2,
total_pages: totalPages2,
current_page: currentPage,
page_size: pageSize,
next_page: hasMore2 ? currentPage + 1 : null,
previous_page: currentPage > 1 ? currentPage - 1 : null
}
};
}
}
/**
* Query entities from API (fallback method) with pagination support
* @returns Object with data array and pagination metadata
*/
async queryFromAPI(entityType, projectId, filters, options) {
// CRITICAL: Check if cacheManager is properly initialized
if (!this.cacheManager) {
getLogger().error({
entityType,
projectId,
hasApiHelper: !!this.apiHelper,
hasStorage: !!this.storage,
hasCacheManager: !!this.cacheManager
}, 'EntityRouter.queryFromAPI: CacheManager is undefined - this is a critical initialization error');
throw new Error('EntityRouter: CacheManager is undefined. This indicates a critical initialization error where the EntityRouter was not properly constructed with a valid CacheManager instance.');
}
// Initialize pagination configuration
const paginationConfig = new PaginationConfigManager();
const pageSize = filters?.page_size || paginationConfig.getPageSize(entityType, filters?.simplified);
const currentPage = filters?.page || 1;
// Route to appropriate API method based on entity type
switch (entityType) {
case 'project':
// Fetch all projects from API - do not filter
// The project filter should only be applied during sync operations
const allProjects = await this.apiHelper.listProjects(filters);
getLogger().debug({
allProjectsCount: allProjects.length
}, 'EntityRouter.queryFromAPI: Returning all API projects without filtering');
return { data: allProjects }; // Projects don't need pagination typically
case 'flag':
// Flags API uses cursor-based pagination
const flagResponse = await this.apiHelper.listFlags(projectId, {
...filters,
per_page: pageSize,
page_token: filters?.cursor || filters?.page_token
});
// Extract pagination info from response
const flagData = flagResponse.items || flagResponse;
const nextToken = flagResponse.next_page_token;
const totalFlags = flagResponse.total_count || flagData.length;
const totalPages = Math.ceil(totalFlags / pageSize);
const hasMore = !!nextToken;
const consentRequired = pageSize > 50;
return {
data: flagData,
pagination: {
consent_required: consentRequired,
has_more: hasMore,
total_count: totalFlags,
total_pages: totalPages,
current_page: currentPage,
page_size: pageSize,
cursor: nextToken,
next_page: hasMore ? currentPage + 1 : null,
previous_page: currentPage > 1 ? currentPage - 1 : null
}
};
case 'feature':
const featureResponse = await this.apiHelper.listFeatures(projectId, {
...filters,
page: currentPage,
per_page: pageSize
});
// Standard pagination handling
return this.buildPaginatedResponse(featureResponse, currentPage, pageSize);
case 'experiment':
const experimentResponse = await this.apiHelper.listExperiments(projectId, {
...filters,
page: currentPage,
per_page: pageSize
});
return this.buildPaginatedResponse(experimentResponse, currentPage, pageSize);
case 'audience':
const audienceResponse = await this.apiHelper.listAudiences(projectId, {
...filters,
page: currentPage,
per_page: pageSize
});
return this.buildPaginatedResponse(audienceResponse, currentPage, pageSize);
case 'event':
const eventResponse = await this.apiHelper.listEvents(projectId, {
...filters,
page: currentPage,
per_page: pageSize
});
return this.buildPaginatedResponse(eventResponse, currentPage, pageSize);
case 'attribute':
const attributeResponse = await this.ap