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
JavaScript
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