UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

1,015 lines 293 kB
/** * 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