UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

345 lines 13.1 kB
/** * Path Index Generator * @description Generates comprehensive queryable path indices for all entity types * * Purpose: Combine schema-based and data-based path discovery to create * the most comprehensive index of queryable fields for the Dynamic JSON Query Engine. * * Key Features: * - Combines FieldsSchemaResolver and RecursivePathBuilder * - Generates indices for all entity types * - Provides path validation and verification * - Caches generated indices for performance * - Exports indices for use by query engine * * @author Optimizely MCP Server * @version 1.0.0 */ import { join } from 'path'; import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs'; import { getLogger } from '../logging/Logger.js'; import { fieldsSchemaResolver } from './FieldsSchemaResolver.js'; import { recursivePathBuilder } from './RecursivePathBuilder.js'; export class PathIndexGenerator { logger = getLogger(); database; indexPath; cacheDir; constructor(database, cacheDir) { this.database = database; this.cacheDir = cacheDir || join(process.cwd(), 'cache', 'path-indices'); this.indexPath = join(this.cacheDir, 'path-index.json'); // Ensure cache directory exists this.ensureCacheDir(); this.logger.info(`PathIndexGenerator initialized with cache at: ${this.cacheDir}`); } /** * Generate path indices for all entity types */ async generateAllIndices(forceRegenerate = false) { this.logger.info('Generating path indices for all entity types...'); // Check if cached index exists and is recent if (!forceRegenerate && this.isCacheValid()) { this.logger.info('Using cached path indices'); return this.loadCachedIndex(); } // Initialize schema resolver await fieldsSchemaResolver.initialize(); // Get all entity types const entityTypes = fieldsSchemaResolver.getAvailableEntityTypes(); this.logger.info(`Found ${entityTypes.length} entity types to index`); // Initialize path builder const pathBuilder = recursivePathBuilder(this.database); // Generate indices for each entity type const entities = {}; for (const entityType of entityTypes) { try { this.logger.info(`Generating index for ${entityType}...`); const entityIndex = await this.generateEntityIndex(entityType, pathBuilder); entities[entityType] = entityIndex; // Save individual entity index this.saveEntityIndex(entityType, entityIndex); } catch (error) { this.logger.error({ error, entityType }, 'Failed to generate index for entity'); } } // Create collection const collection = { version: '1.0.0', generated: new Date().toISOString(), entities }; // Save complete index this.saveCompleteIndex(collection); this.logger.info(`Generated indices for ${Object.keys(entities).length} entity types`); return collection; } /** * Generate index for a single entity type */ async generateEntityIndex(entityType, pathBuilder) { // Get schema paths const schemaPaths = await fieldsSchemaResolver.getQueryablePaths(entityType); // Get database paths const builder = pathBuilder || recursivePathBuilder(this.database); const analysisResult = await builder.analyzeEntity(entityType, 100); // Merge paths const mergedPaths = await this.mergePaths(schemaPaths, analysisResult.discoveredPaths); // Calculate statistics const statistics = this.calculateStatistics(mergedPaths, schemaPaths.length); return { entityType, lastUpdated: new Date().toISOString(), totalPaths: mergedPaths.length, schemaPaths: schemaPaths.length, databasePaths: analysisResult.discoveredPaths.length, paths: mergedPaths, statistics }; } /** * Merge schema and database paths */ async mergePaths(schemaPaths, databasePaths) { const pathMap = new Map(); // Add schema paths for (const schemaPath of schemaPaths) { pathMap.set(schemaPath.fullPath, { ...schemaPath, source: 'schema', inDatabase: false, occurrences: 0 }); } // Merge database paths for (const dbPath of databasePaths) { const existing = pathMap.get(dbPath.path); if (existing) { // Update existing schema path with database info existing.source = 'both'; existing.inDatabase = true; existing.occurrences = dbPath.occurrences; existing.isArray = dbPath.isArray; if (dbPath.examples && dbPath.examples.length > 0) { existing.example = existing.example || dbPath.examples[0]; } } else { // Add database-only path pathMap.set(dbPath.path, { fullPath: dbPath.path, sqlPath: this.buildSqlPath(dbPath.path), jsonataPath: this.buildJsonataPath(dbPath.path), type: dbPath.type, nullable: dbPath.isNullable, entityType: dbPath.entityType, fieldName: dbPath.path.split('.').pop() || dbPath.path, depth: dbPath.depth, source: 'database', inDatabase: true, occurrences: dbPath.occurrences, isArray: dbPath.isArray, parentPath: dbPath.parentPath, childPaths: dbPath.childPaths, example: dbPath.examples?.[0] }); } } return Array.from(pathMap.values()); } /** * Calculate statistics for paths */ calculateStatistics(paths, schemaPathCount) { const stats = { maxDepth: 0, arrayPaths: 0, nullablePaths: 0, enumPaths: 0, coverage: 0 }; if (paths.length === 0) return stats; // Calculate metrics stats.maxDepth = Math.max(...paths.map(p => p.depth)); stats.arrayPaths = paths.filter(p => p.isArray).length; stats.nullablePaths = paths.filter(p => p.nullable).length; stats.enumPaths = paths.filter(p => p.enum && p.enum.length > 0).length; // Calculate coverage const schemaPathsInDb = paths.filter(p => p.source === 'both').length; stats.coverage = schemaPathCount > 0 ? Math.round((schemaPathsInDb / schemaPathCount) * 100) : 0; return stats; } /** * Build SQL path for JSON_EXTRACT */ buildSqlPath(path) { let sqlPath = '$'; const parts = path.split('.'); for (const part of parts) { if (part.endsWith('[]')) { const fieldName = part.substring(0, part.length - 2); sqlPath += `.${fieldName}[*]`; } else { sqlPath += `.${part}`; } } return sqlPath; } /** * Build JSONata path */ buildJsonataPath(path) { return path.replace(/\[\]/g, ''); } /** * Ensure cache directory exists */ ensureCacheDir() { if (!existsSync(this.cacheDir)) { mkdirSync(this.cacheDir, { recursive: true }); } } /** * Check if cached index is valid (less than 24 hours old) */ isCacheValid() { if (!existsSync(this.indexPath)) { return false; } try { const content = readFileSync(this.indexPath, 'utf-8'); const index = JSON.parse(content); const generated = new Date(index.generated); const age = Date.now() - generated.getTime(); const maxAge = 24 * 60 * 60 * 1000; // 24 hours return age < maxAge; } catch (error) { this.logger.error({ error }, 'Failed to check cache validity'); return false; } } /** * Load cached index */ loadCachedIndex() { const content = readFileSync(this.indexPath, 'utf-8'); return JSON.parse(content); } /** * Save complete index to cache */ saveCompleteIndex(collection) { try { const content = JSON.stringify(collection, null, 2); writeFileSync(this.indexPath, content); this.logger.info(`Saved complete index to ${this.indexPath}`); } catch (error) { this.logger.error({ error }, 'Failed to save complete index'); } } /** * Save individual entity index */ saveEntityIndex(entityType, index) { try { const entityPath = join(this.cacheDir, `${entityType}-paths.json`); const content = JSON.stringify(index, null, 2); writeFileSync(entityPath, content); this.logger.debug(`Saved ${entityType} index to ${entityPath}`); } catch (error) { this.logger.error({ error, entityType }, 'Failed to save entity index'); } } /** * Get paths for a specific entity type */ async getEntityPaths(entityType) { // Try to load from cache first const entityPath = join(this.cacheDir, `${entityType}-paths.json`); if (existsSync(entityPath)) { try { const content = readFileSync(entityPath, 'utf-8'); const index = JSON.parse(content); // Check if recent (less than 24 hours) const age = Date.now() - new Date(index.lastUpdated).getTime(); if (age < 24 * 60 * 60 * 1000) { return index.paths; } } catch (error) { this.logger.warn({ error }, 'Failed to load cached entity index'); } } // Generate fresh index const index = await this.generateEntityIndex(entityType); return index.paths; } /** * Search for paths matching a pattern */ async searchPaths(pattern, entityType) { const regex = new RegExp(pattern, 'i'); const results = []; if (entityType) { // Search specific entity const paths = await this.getEntityPaths(entityType); results.push(...paths.filter(p => regex.test(p.fullPath) || (p.description && regex.test(p.description)))); } else { // Search all entities const collection = await this.generateAllIndices(); for (const [type, index] of Object.entries(collection.entities)) { const matches = index.paths.filter(p => regex.test(p.fullPath) || (p.description && regex.test(p.description))); results.push(...matches); } } return results; } /** * Get path statistics summary */ async getStatisticsSummary() { const collection = await this.generateAllIndices(); const summary = { totalEntities: Object.keys(collection.entities).length, totalPaths: 0, totalSchemaPaths: 0, totalDatabasePaths: 0, averageCoverage: 0, entitiesWithHighCoverage: [], entitiesWithLowCoverage: [], mostComplexEntities: [] }; const coverages = []; const complexities = []; for (const [entityType, index] of Object.entries(collection.entities)) { summary.totalPaths += index.totalPaths; summary.totalSchemaPaths += index.schemaPaths; summary.totalDatabasePaths += index.databasePaths; coverages.push(index.statistics.coverage); complexities.push({ entity: entityType, paths: index.totalPaths }); if (index.statistics.coverage >= 80) { summary.entitiesWithHighCoverage.push(entityType); } else if (index.statistics.coverage < 50) { summary.entitiesWithLowCoverage.push(entityType); } } summary.averageCoverage = Math.round(coverages.reduce((a, b) => a + b, 0) / coverages.length); // Find most complex entities complexities.sort((a, b) => b.paths - a.paths); summary.mostComplexEntities = complexities.slice(0, 5).map(c => c.entity); return summary; } } // Export factory function export const createPathIndexGenerator = (database, cacheDir) => new PathIndexGenerator(database, cacheDir); //# sourceMappingURL=PathIndexGenerator.js.map