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