@the_cfdude/productboard-mcp
Version:
Model Context Protocol server for Productboard REST API with dynamic tool loading
769 lines (668 loc) • 21.3 kB
text/typescript
/**
* Lightweight query engine for high-performance operations
* Optimized for status checks, existence validation, and progress tracking
*/
/* eslint-disable no-undef */
import {
IntelligentCache,
performanceCollector,
memoryMonitor,
} from './performance-monitor.js';
import { ValidationError, NetworkError } from '../errors/index.js';
import { withContext } from './tool-wrapper.js';
export interface LightweightEntity {
id: string;
status?: string;
updatedAt?: string;
exists?: boolean;
name?: string;
[key: string]: unknown;
}
export interface StatusSummary {
total: number;
byStatus: Record<string, number>;
lastUpdated: string;
summary: string;
entities?: LightweightEntity[];
}
export interface ExistenceResult {
missing: string[];
existing: string[];
total: number;
existingCount: number;
missingCount: number;
}
export interface ProgressResult {
completed: number;
pending: number;
total: number;
details?: Array<{
id: string;
status: string;
progress: boolean;
marker?: string;
}>;
summary: string;
}
export interface BatchQueryOptions {
batchSize?: number;
concurrency?: number;
cache?: boolean;
cacheTtl?: number;
fields?: string[];
timeout?: number;
}
export interface HealthCheckResult {
status: 'healthy' | 'degraded' | 'unhealthy';
responseTime: number;
timestamp: number;
checks: {
api: boolean;
cache: boolean;
memory: boolean;
};
details: {
memoryUsage: NodeJS.MemoryUsage;
cacheStats: unknown;
apiLatency: number;
};
}
/**
* Lightweight query engine optimized for performance-critical operations
*/
export class LightweightQueryEngine {
private cache: IntelligentCache;
private defaultBatchSize = 50;
private defaultConcurrency = 5;
private maxBatchSize = 100;
constructor(cache?: IntelligentCache) {
this.cache = cache || new IntelligentCache(2000, 180000, 'LRU'); // 3min TTL for lightweight data
}
/**
* Check status for multiple entities with minimal data transfer
*/
async checkMultipleStatus(
entityType: string,
ids: string[],
options: BatchQueryOptions = {}
): Promise<StatusSummary> {
const metric = performanceCollector.start('lightweight_status_check');
try {
const {
batchSize = this.defaultBatchSize,
concurrency = this.defaultConcurrency,
cache = true,
cacheTtl = 180000, // 3 minutes
fields = ['id', 'status', 'updatedAt'],
} = options;
// Validate inputs
if (ids.length === 0) {
throw new ValidationError('Entity IDs array cannot be empty', 'ids');
}
if (ids.length > 500) {
throw new ValidationError('Too many IDs provided (max 500)', 'ids');
}
// Check cache first
const cacheKey = `status_${entityType}_${this.hashIds(ids)}_${fields.join(',')}`;
if (cache) {
const cached = this.cache.get(cacheKey) as StatusSummary | null;
if (cached) {
performanceCollector.recordCacheHit(metric, true);
performanceCollector.end(metric, true);
return cached;
}
}
performanceCollector.recordCacheHit(metric, false);
// Batch the requests
const batches = this.createBatches(
ids,
Math.min(batchSize, this.maxBatchSize)
);
const results: LightweightEntity[] = [];
// Process batches with controlled concurrency
for (let i = 0; i < batches.length; i += concurrency) {
const batchSlice = batches.slice(i, i + concurrency);
const batchPromises = batchSlice.map(batch =>
this.fetchEntityBatch(entityType, batch, fields)
);
const batchResults = await Promise.allSettled(batchPromises);
for (const result of batchResults) {
if (result.status === 'fulfilled') {
results.push(...result.value);
} else {
console.warn(`Batch request failed:`, result.reason);
// Continue with other batches instead of failing completely
}
}
}
// Create status summary
const summary = this.createStatusSummary(results, entityType);
// Cache the result
if (cache && summary.total > 0) {
this.cache.set(cacheKey, summary, cacheTtl);
}
performanceCollector.recordDataSize(
metric,
JSON.stringify(summary).length
);
performanceCollector.end(metric, true);
return summary;
} catch (error) {
performanceCollector.end(metric, false);
throw error;
}
}
/**
* Validate existence of multiple entities
*/
async validateExistence(
entityType: string,
ids: string[],
options: BatchQueryOptions = {}
): Promise<ExistenceResult> {
const metric = performanceCollector.start('lightweight_existence_check');
try {
const {
batchSize = this.defaultBatchSize,
concurrency = this.defaultConcurrency,
cache = true,
cacheTtl = 300000, // 5 minutes - existence data is more stable
} = options;
if (ids.length === 0) {
return {
missing: [],
existing: [],
total: 0,
existingCount: 0,
missingCount: 0,
};
}
if (ids.length > 1000) {
throw new ValidationError(
'Too many IDs for existence check (max 1000)',
'ids'
);
}
// Check cache
const cacheKey = `exists_${entityType}_${this.hashIds(ids)}`;
if (cache) {
const cached = this.cache.get(cacheKey) as ExistenceResult | null;
if (cached) {
performanceCollector.recordCacheHit(metric, true);
performanceCollector.end(metric, true);
return cached;
}
}
performanceCollector.recordCacheHit(metric, false);
// Use minimal fields for existence check
const minimalFields = ['id'];
const batches = this.createBatches(
ids,
Math.min(batchSize, this.maxBatchSize)
);
const existingEntities: LightweightEntity[] = [];
// Process batches
for (let i = 0; i < batches.length; i += concurrency) {
const batchSlice = batches.slice(i, i + concurrency);
const batchPromises = batchSlice.map(
batch => this.fetchEntityBatch(entityType, batch, minimalFields, true) // Allow partial results
);
const batchResults = await Promise.allSettled(batchPromises);
for (const result of batchResults) {
if (result.status === 'fulfilled') {
existingEntities.push(...result.value);
}
// For existence checks, we continue even if some batches fail
}
}
const existingIds = existingEntities.map(e => e.id);
const missingIds = ids.filter(id => !existingIds.includes(id));
const result: ExistenceResult = {
missing: missingIds,
existing: existingIds,
total: ids.length,
existingCount: existingIds.length,
missingCount: missingIds.length,
};
// Cache the result
if (cache) {
this.cache.set(cacheKey, result, cacheTtl);
}
performanceCollector.recordDataSize(
metric,
JSON.stringify(result).length
);
performanceCollector.end(metric, true);
return result;
} catch (error) {
performanceCollector.end(metric, false);
throw error;
}
}
/**
* Track progress for multiple entities with custom markers
*/
async trackBatchProgress(
entityType: string,
ids: string[],
progressMarker: string,
options: BatchQueryOptions = {}
): Promise<ProgressResult> {
const metric = performanceCollector.start('lightweight_progress_tracking');
try {
const {
batchSize = this.defaultBatchSize,
concurrency = this.defaultConcurrency,
cache = true,
cacheTtl = 120000, // 2 minutes - progress data changes frequently
fields = ['id', 'status', 'customFields', 'updatedAt'],
} = options;
if (ids.length === 0) {
return {
completed: 0,
pending: 0,
total: 0,
summary: 'No entities to track',
};
}
// Check cache
const cacheKey = `progress_${entityType}_${progressMarker}_${this.hashIds(ids)}`;
if (cache) {
const cached = this.cache.get(cacheKey) as ProgressResult | null;
if (cached) {
performanceCollector.recordCacheHit(metric, true);
performanceCollector.end(metric, true);
return cached;
}
}
performanceCollector.recordCacheHit(metric, false);
// Fetch entities with progress-relevant fields
const batches = this.createBatches(
ids,
Math.min(batchSize, this.maxBatchSize)
);
const entities: LightweightEntity[] = [];
for (let i = 0; i < batches.length; i += concurrency) {
const batchSlice = batches.slice(i, i + concurrency);
const batchPromises = batchSlice.map(batch =>
this.fetchEntityBatch(entityType, batch, fields)
);
const batchResults = await Promise.allSettled(batchPromises);
for (const result of batchResults) {
if (result.status === 'fulfilled') {
entities.push(...result.value);
}
}
}
// Analyze progress
const progressDetails = entities.map(entity => {
const hasMarker = this.checkProgressMarker(entity, progressMarker);
const detail = {
id: entity.id,
status: entity.status || 'unknown',
progress: hasMarker,
};
if (hasMarker) {
(detail as any).marker = progressMarker;
}
return detail;
});
const completed = progressDetails.filter(p => p.progress).length;
const pending = progressDetails.length - completed;
const result: ProgressResult = {
completed,
pending,
total: progressDetails.length,
details: progressDetails,
summary: `${completed}/${progressDetails.length} completed (${Math.round((completed / progressDetails.length) * 100)}%)`,
};
// Cache with shorter TTL for progress data
if (cache) {
this.cache.set(cacheKey, result, cacheTtl);
}
performanceCollector.recordDataSize(
metric,
JSON.stringify(result).length
);
performanceCollector.end(metric, true);
return result;
} catch (error) {
performanceCollector.end(metric, false);
throw error;
}
}
/**
* Perform health check on the system
*/
async healthCheck(): Promise<HealthCheckResult> {
const startTime = Date.now();
const checks = {
api: false,
cache: false,
memory: false,
};
try {
// Test API connectivity with a minimal request
await this.testApiConnectivity();
checks.api = true;
} catch (error) {
console.warn('API health check failed:', error);
}
try {
// Test cache functionality
const testKey = `health_${Date.now()}`;
this.cache.set(testKey, { test: true });
const retrieved = this.cache.get(testKey);
checks.cache = retrieved !== null;
} catch (error) {
console.warn('Cache health check failed:', error);
}
try {
// Check memory usage
memoryMonitor.getStats();
checks.memory = !memoryMonitor.isCritical(1500); // 1.5GB threshold
} catch (error) {
console.warn('Memory health check failed:', error);
}
const responseTime = Date.now() - startTime;
const memoryUsage = process.memoryUsage();
const cacheStats = this.cache.getStats();
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
if (!checks.api || !checks.cache) {
status = 'degraded';
}
if (!checks.api && !checks.cache) {
status = 'unhealthy';
}
if (!checks.memory) {
status = status === 'healthy' ? 'degraded' : 'unhealthy';
}
return {
status,
responseTime,
timestamp: Date.now(),
checks,
details: {
memoryUsage,
cacheStats,
apiLatency: responseTime,
},
};
}
/**
* Get count of entities without fetching full data
*/
async getEntityCount(
entityType: string,
filters?: Record<string, unknown>,
options: BatchQueryOptions = {}
): Promise<{ count: number; timestamp: number }> {
const metric = performanceCollector.start('lightweight_entity_count');
try {
const { cache = true, cacheTtl = 300000 } = options;
// Create cache key
const filterStr = filters ? JSON.stringify(filters) : 'all';
const cacheKey = `count_${entityType}_${this.hashString(filterStr)}`;
if (cache) {
const cached = this.cache.get(cacheKey) as {
count: number;
timestamp: number;
} | null;
if (cached) {
performanceCollector.recordCacheHit(metric, true);
performanceCollector.end(metric, true);
return cached;
}
}
performanceCollector.recordCacheHit(metric, false);
// Make a minimal request to get count
const result = await this.fetchEntityCount(entityType, filters);
if (cache) {
this.cache.set(cacheKey, result, cacheTtl);
}
performanceCollector.end(metric, true);
return result;
} catch (error) {
performanceCollector.end(metric, false);
throw error;
}
}
/**
* Fetch entity batch with minimal fields
*/
private async fetchEntityBatch(
entityType: string,
ids: string[],
fields: string[],
allowPartialResults: boolean = false
): Promise<LightweightEntity[]> {
try {
// Use the appropriate handler based on entity type
const handler = this.getEntityHandler(entityType);
const params = {
limit: ids.length,
detail: 'basic',
includeSubData: false,
fields: fields.join(','),
// Add ID filter if supported by the API
ids: ids.join(','),
};
const response = await handler('get_' + entityType, params);
const parsedResponse = this.parseResponse(response);
// Filter to only requested IDs and map to lightweight format
const entities = parsedResponse.data
.filter(item => ids.includes(item.id as string))
.map(item => this.mapToLightweightEntity(item, fields));
return entities;
} catch (error) {
if (allowPartialResults) {
console.warn(`Partial batch failure for ${entityType}:`, error);
return [];
}
throw error;
}
}
/**
* Test API connectivity with minimal request
*/
private async testApiConnectivity(): Promise<void> {
// Make a very lightweight API call
try {
await withContext(async () => {
// Simple connectivity test - could be a health endpoint if available
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const response = await globalThis.fetch(
'https://api.productboard.com/health',
{
method: 'HEAD',
signal: controller.signal,
}
);
clearTimeout(timeoutId);
if (!response.ok && response.status !== 404) {
throw new NetworkError('API connectivity test failed');
}
} catch (fetchError) {
clearTimeout(timeoutId);
throw fetchError;
}
});
} catch {
// If health endpoint doesn't exist, try a minimal actual API call
const handler = this.getEntityHandler('features');
await handler('get_features', { limit: 1, detail: 'basic' });
}
}
/**
* Fetch entity count
*/
private async fetchEntityCount(
entityType: string,
filters?: Record<string, any>
): Promise<{ count: number; timestamp: number }> {
const handler = this.getEntityHandler(entityType);
const params = {
limit: 1, // Minimal request
detail: 'basic',
...filters,
};
const response = await handler('get_' + entityType, params);
const parsedResponse = this.parseResponse(response);
return {
count: parsedResponse.totalRecords || parsedResponse.data?.length || 0,
timestamp: Date.now(),
};
}
/**
* Get appropriate handler for entity type
*/
private getEntityHandler(
entityType: string
): (operation: string, _params: Record<string, unknown>) => Promise<unknown> {
// Simple handler that returns a promise to avoid ESLint issues
return async (operation: string, _params: Record<string, unknown>) => {
// This is a placeholder - in real implementation, would use proper dynamic imports
// For now, throw error to indicate unsupported operation in lightweight mode
throw new ValidationError(
`Lightweight query engine does not support ${operation} on ${entityType}`,
'operation'
);
};
}
/**
* Parse response from tool handlers
*/
private parseResponse(response: unknown): {
data: Record<string, unknown>[];
totalRecords: number;
} {
const typedResponse = response as { content?: Array<{ text?: string }> };
if (typedResponse?.content?.[0]?.text) {
try {
const parsed = JSON.parse(typedResponse.content[0].text);
return {
data: Array.isArray(parsed.data) ? parsed.data : [],
totalRecords: parsed.totalRecords || 0,
};
} catch {
return { data: [], totalRecords: 0 };
}
}
return { data: [], totalRecords: 0 };
}
/**
* Map API response to lightweight entity
*/
private mapToLightweightEntity(
item: Record<string, unknown>,
fields: string[]
): LightweightEntity {
const entity: LightweightEntity = { id: item.id as string };
for (const field of fields) {
if (field === 'status') {
const status = item.status as { name?: string } | string | undefined;
entity.status =
(typeof status === 'object' ? status?.name : status) || 'unknown';
} else if (field === 'updatedAt') {
entity.updatedAt = (item.updatedAt ||
item.updated_at ||
new Date().toISOString()) as string;
} else if (item[field] !== undefined) {
entity[field] = item[field];
}
}
entity.exists = true;
return entity;
}
/**
* Create status summary from entities
*/
private createStatusSummary(
entities: LightweightEntity[],
entityType: string
): StatusSummary {
const statusCounts: Record<string, number> = {};
let latestUpdate = '';
for (const entity of entities) {
const status = entity.status || 'unknown';
statusCounts[status] = (statusCounts[status] || 0) + 1;
if (entity.updatedAt && entity.updatedAt > latestUpdate) {
latestUpdate = entity.updatedAt;
}
}
// Create human-readable summary
const statusEntries = Object.entries(statusCounts);
const summary =
statusEntries.length > 0
? statusEntries
.map(([status, count]) => `${count} ${status}`)
.join(', ')
: 'No entities found';
return {
total: entities.length,
byStatus: statusCounts,
lastUpdated: latestUpdate || new Date().toISOString(),
summary: `${entities.length} ${entityType}: ${summary}`,
entities,
};
}
/**
* Check if entity has progress marker
*/
private checkProgressMarker(
entity: LightweightEntity,
progressMarker: string
): boolean {
// Check custom fields first
if (
entity.customFields &&
(entity.customFields as Record<string, unknown>)[progressMarker]
) {
return true;
}
// Check status-based markers
if (progressMarker.startsWith('status:')) {
const targetStatus = progressMarker.replace('status:', '');
return entity.status === targetStatus;
}
// Check field-based markers
if (progressMarker.includes(':')) {
const [field, value] = progressMarker.split(':', 2);
return entity[field] === value;
}
// Default: check if field exists and is truthy
return Boolean(entity[progressMarker]);
}
/**
* Create batches from array of IDs
*/
private createBatches<T>(items: T[], batchSize: number): T[][] {
const batches: T[][] = [];
for (let i = 0; i < items.length; i += batchSize) {
batches.push(items.slice(i, i + batchSize));
}
return batches;
}
/**
* Hash array of IDs for cache keys
*/
private hashIds(ids: string[]): string {
return this.hashString(ids.sort().join(','));
}
/**
* Simple string hash for cache keys
*/
private hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash).toString(36);
}
}
// Export singleton instance
export const lightweightQueryEngine = new LightweightQueryEngine();