UNPKG

survey-mcp-server

Version:

Survey management server handling survey creation, response collection, analysis, and reporting with database access for data management

305 lines 12.6 kB
import { BaseService } from './base.js'; import { SearchError, NotFoundError, ValidationError } from '../middleware/error-handling.js'; import { securityManager } from '../security/index.js'; import { logger } from '../utils/logger.js'; import { typesenseClient } from '../utils/typesense.js'; export class SearchService extends BaseService { constructor(config) { super('SearchService', config?.circuitBreaker, config?.retry); this.searchCount = 0; this.errorCount = 0; this.lastHealthCheck = new Date(); this.isAvailable = false; this.checkAvailability(); } async initialize() { try { await typesenseClient.initialize(); this.isAvailable = typesenseClient.getAvailability(); if (this.isAvailable) { logger.info('Search service initialized successfully'); } else { logger.warn('Search service initialized but Typesense is not available'); } } catch (error) { logger.error('Search service initialization failed:', error); this.isAvailable = false; // Don't throw error - allow graceful degradation } } async performHealthCheck() { const startTime = Date.now(); try { if (!this.isAvailable) { return { isHealthy: false, lastCheck: new Date(), responseTime: Date.now() - startTime, error: 'Typesense client is not available' }; } // Perform a simple health check by getting cluster stats const health = await typesenseClient.getHealth(); const responseTime = Date.now() - startTime; this.lastHealthCheck = new Date(); return { isHealthy: health.ok === true, lastCheck: new Date(), responseTime, details: { searchCount: this.searchCount, errorCount: this.errorCount, availability: this.isAvailable } }; } catch (error) { return { isHealthy: false, lastCheck: new Date(), responseTime: Date.now() - startTime, error: error.message }; } } async search(collection, searchQuery, options = {}) { const { enableFallback = true, timeout = 5000 } = options; if (!this.isAvailable) { if (enableFallback) { logger.warn('Search service unavailable, using fallback'); return this.fallbackSearch(searchQuery); } else { throw new SearchError('Search service is not available'); } } return this.executeWithResilience(async () => { const startTime = Date.now(); try { // Security validation and sanitization const securityCheck = securityManager.performSecurityCheck(searchQuery, { sanitizationContext: 'typesense' }); if (!securityCheck.isSecure) { throw new ValidationError(`Search query security validation failed: ${securityCheck.issues.join(', ')}`, 'searchQuery'); } const sanitizedQuery = securityCheck.sanitizedInput; // Apply safe defaults const safeQuery = { ...sanitizedQuery, per_page: Math.min(sanitizedQuery.per_page || 20, 250), page: Math.max(sanitizedQuery.page || 1, 1), search_cutoff_ms: Math.min(sanitizedQuery.search_cutoff_ms || timeout, timeout) }; const result = await typesenseClient.search(collection, safeQuery); this.searchCount++; const searchTime = Date.now() - startTime; logger.debug(`Search completed`, { collection, query: this.sanitizeLogData(safeQuery.q), found: result.found, searchTime, requestTime: result.search_time_ms }); return result; } catch (error) { this.errorCount++; const searchTime = Date.now() - startTime; logger.error('Search operation failed', { collection, query: this.sanitizeLogData(searchQuery.q), searchTime, error: error.message }); if (error instanceof ValidationError) { throw error; } // Check if this is a retryable error const isRetryable = this.isRetryableSearchError(error); throw new SearchError(`Search failed for collection ${collection}`, { originalError: error.message, collection, searchTime, query: this.sanitizeLogData(searchQuery.q) }, isRetryable); } }, 'search', { timeout }); } async getCollection(collectionName) { if (!this.isAvailable) { throw new SearchError('Search service is not available'); } return this.executeWithResilience(async () => { try { const client = typesenseClient.getClient(); const collection = await client.collections(collectionName).retrieve(); logger.debug(`Collection info retrieved`, { collection: collectionName, numDocuments: collection.num_documents }); return collection; } catch (error) { logger.error('Failed to get collection info', { collection: collectionName, error: error.message }); if (error.httpStatus === 404) { throw new NotFoundError(`Collection ${collectionName} not found`); } throw new SearchError(`Failed to get collection ${collectionName}`, { originalError: error.message }, this.isRetryableSearchError(error)); } }, 'getCollection'); } async listCollections() { if (!this.isAvailable) { throw new SearchError('Search service is not available'); } return this.executeWithResilience(async () => { try { const client = typesenseClient.getClient(); const collections = await client.collections().retrieve(); const collectionNames = collections.map((collection) => collection.name); logger.debug(`Collections listed`, { count: collectionNames.length, collections: collectionNames }); return collectionNames; } catch (error) { logger.error('Failed to list collections', { error: error.message }); throw new SearchError('Failed to list collections', { originalError: error.message }, this.isRetryableSearchError(error)); } }, 'listCollections'); } async multiSearch(searches, options = {}) { if (!this.isAvailable) { throw new SearchError('Search service is not available'); } const { timeout = 10000 } = options; return this.executeWithResilience(async () => { try { // Validate and sanitize all search queries const sanitizedSearches = searches.map(search => { const securityCheck = securityManager.performSecurityCheck(search.query, { sanitizationContext: 'typesense' }); if (!securityCheck.isSecure) { throw new ValidationError(`Multi-search query security validation failed: ${securityCheck.issues.join(', ')}`, 'searchQuery'); } return { collection: search.collection, query: { ...securityCheck.sanitizedInput, per_page: Math.min(securityCheck.sanitizedInput.per_page || 20, 100), page: Math.max(securityCheck.sanitizedInput.page || 1, 1) } }; }); const client = typesenseClient.getClient(); const searchRequests = sanitizedSearches.map(search => ({ collection: search.collection, ...search.query })); const results = await client.multiSearch.perform({ searches: searchRequests }); this.searchCount += searches.length; logger.debug(`Multi-search completed`, { searchCount: searches.length, collections: searches.map(s => s.collection) }); return results.results; } catch (error) { this.errorCount += searches.length; logger.error('Multi-search operation failed', { searchCount: searches.length, error: error.message }); if (error instanceof ValidationError) { throw error; } throw new SearchError('Multi-search operation failed', { originalError: error.message, searchCount: searches.length }, this.isRetryableSearchError(error)); } }, 'multiSearch', { timeout }); } fallbackSearch(searchQuery) { // Provide a fallback response when search is unavailable logger.info('Using search fallback response'); return { found: 0, out_of: 0, page: searchQuery.page || 1, request_params: searchQuery, search_time_ms: 0, search_cutoff: false, hits: [] }; } isRetryableSearchError(error) { // Typesense specific retryable errors const retryableHttpCodes = [429, 502, 503, 504]; if (error.httpStatus && retryableHttpCodes.includes(error.httpStatus)) { return true; } // Network related errors const retryableNetworkCodes = ['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', 'ECONNRESET']; if (retryableNetworkCodes.includes(error.code)) { return true; } // Timeout errors if (error.message && error.message.includes('timeout')) { return true; } return false; } checkAvailability() { this.isAvailable = typesenseClient.getAvailability(); } sanitizeLogData(data) { return securityManager.sanitizeLogData(data); } getServiceStats() { const successRate = this.searchCount > 0 ? ((this.searchCount - this.errorCount) / this.searchCount) * 100 : 100; return { searchCount: this.searchCount, errorCount: this.errorCount, successRate: Math.round(successRate * 100) / 100, isAvailable: this.isAvailable, lastHealthCheck: this.lastHealthCheck }; } async refreshAvailability() { try { await typesenseClient.initialize(); this.isAvailable = typesenseClient.getAvailability(); logger.info(`Search service availability refreshed: ${this.isAvailable}`); } catch (error) { this.isAvailable = false; logger.warn('Failed to refresh search service availability:', error.message); } } isServiceAvailable() { return this.isAvailable; } async shutdown() { try { await super.shutdown(); logger.info('Search service shutdown completed'); } catch (error) { logger.error('Error during search service shutdown:', error); throw error; } } } export const searchService = new SearchService(); //# sourceMappingURL=search.js.map