@the_cfdude/productboard-mcp
Version:
Model Context Protocol server for Productboard REST API with dynamic tool loading
413 lines (412 loc) • 15.2 kB
JavaScript
/**
* Simplified bulk operations with diff-only focus
* Provides efficient bulk update capabilities with change tracking
*/
import { ValidationError } from '../errors/index.js';
import { performanceCollector } from './performance-monitor.js';
/**
* Simplified bulk operations engine with diff-only focus
*/
export class BulkOperationsEngine {
defaultBatchSize = 25;
defaultConcurrency = 3;
maxBatchSize = 50;
/**
* Perform bulk updates with diff tracking
*/
async performBulkUpdate(request, entityHandler) {
const metric = performanceCollector.start('bulk_update');
try {
const { entityType, updates, options = {} } = request;
const { batchSize = this.defaultBatchSize, concurrency = this.defaultConcurrency, continueOnError = true, validateBeforeUpdate = true, trackChanges = true, } = options;
// Validate inputs
if (!updates || updates.length === 0) {
throw new ValidationError('Updates array cannot be empty', 'updates');
}
if (updates.length > 500) {
throw new ValidationError('Maximum 500 updates allowed per bulk operation', 'updates');
}
const result = {
successful: [],
failed: [],
skipped: [],
changes: [],
summary: {
total: updates.length,
successCount: 0,
failureCount: 0,
skippedCount: 0,
changesCount: 0,
},
};
// Pre-validate updates if requested
if (validateBeforeUpdate) {
const validationErrors = this.validateUpdates(updates);
if (validationErrors.length > 0) {
throw new ValidationError(`Validation failed: ${validationErrors.join(', ')}`, 'updates');
}
}
// Process updates in batches
const batches = this.createBatches(updates, Math.min(batchSize, this.maxBatchSize));
for (let i = 0; i < batches.length; i += concurrency) {
const batchSlice = batches.slice(i, i + concurrency);
const batchPromises = batchSlice.map(batch => this.processBatch(batch, entityType, entityHandler, trackChanges));
const batchResults = await Promise.allSettled(batchPromises);
for (const batchResult of batchResults) {
if (batchResult.status === 'fulfilled') {
this.mergeBatchResult(result, batchResult.value);
}
else if (!continueOnError) {
throw new Error(`Batch processing failed: ${batchResult.reason}`);
}
else {
console.warn('Batch processing failed:', batchResult.reason);
}
}
}
// Update summary
result.summary.successCount = result.successful.length;
result.summary.failureCount = result.failed.length;
result.summary.skippedCount = result.skipped.length;
result.summary.changesCount = result.changes.length;
performanceCollector.recordDataSize(metric, JSON.stringify(result).length);
performanceCollector.end(metric, true);
return result;
}
catch (error) {
performanceCollector.end(metric, false);
throw error;
}
}
/**
* Process a single batch of updates
*/
async processBatch(batch, entityType, entityHandler, trackChanges) {
const batchResult = {
successful: [],
failed: [],
skipped: [],
changes: [],
};
for (const update of batch) {
try {
let beforeData = {};
// Get current data if tracking changes
if (trackChanges) {
try {
const currentResponse = await entityHandler(`get_${entityType}`, {
id: update.id,
detail: 'basic',
});
beforeData = this.extractEntityData(currentResponse);
}
catch (error) {
// If entity doesn't exist, skip or create new
if (this.isNotFoundError(error)) {
batchResult.skipped?.push(update.id);
continue;
}
throw error;
}
}
// Apply the update
const updateParams = {
id: update.id,
...update.changes,
...(update.expectedVersion && {
expectedVersion: update.expectedVersion,
}),
};
const updateResponse = await entityHandler(`update_${entityType}`, updateParams);
const afterData = this.extractEntityData(updateResponse);
batchResult.successful?.push(update.id);
// Track changes if requested
if (trackChanges) {
const diff = this.createEntityDiff(update.id, beforeData, afterData);
if (diff.hasChanges) {
batchResult.changes?.push({
id: update.id,
before: beforeData,
after: afterData,
diff: this.generateDiffObject(diff),
});
}
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
batchResult.failed?.push({
id: update.id,
error: errorMessage,
originalData: update.changes,
});
}
}
return batchResult;
}
/**
* Generate entity diff between before and after states
*/
createEntityDiff(id, before, after) {
const operations = [];
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]);
for (const key of allKeys) {
const oldValue = before[key];
const newValue = after[key];
if (!(key in before) && key in after) {
// Field was added
operations.push({
operation: 'add',
path: key,
newValue,
});
}
else if (key in before && !(key in after)) {
// Field was removed
operations.push({
operation: 'remove',
path: key,
oldValue,
});
}
else if (this.valuesAreDifferent(oldValue, newValue)) {
// Field was changed
operations.push({
operation: 'change',
path: key,
oldValue,
newValue,
});
}
}
const significantChanges = operations
.filter(op => this.isSignificantChange(op))
.map(op => op.path);
return {
id,
entityType: 'entity', // Will be set by caller
operations,
hasChanges: operations.length > 0,
changeCount: operations.length,
significantChanges,
};
}
/**
* Compare field values for differences
*/
valuesAreDifferent(oldValue, newValue) {
// Handle null/undefined
if (oldValue === null || oldValue === undefined) {
return newValue !== null && newValue !== undefined;
}
if (newValue === null || newValue === undefined) {
return oldValue !== null && oldValue !== undefined;
}
// Handle arrays
if (Array.isArray(oldValue) && Array.isArray(newValue)) {
if (oldValue.length !== newValue.length)
return true;
return oldValue.some((item, index) => this.valuesAreDifferent(item, newValue[index]));
}
// Handle objects
if (typeof oldValue === 'object' && typeof newValue === 'object') {
const oldKeys = Object.keys(oldValue);
const newKeys = Object.keys(newValue);
if (oldKeys.length !== newKeys.length)
return true;
return oldKeys.some(key => this.valuesAreDifferent(oldValue[key], newValue[key]));
}
// Handle primitives
return oldValue !== newValue;
}
/**
* Determine if a change is significant (should be highlighted)
*/
isSignificantChange(operation) {
const significantFields = [
'status',
'name',
'title',
'summary',
'priority',
'assignee',
'dueDate',
'startDate',
'endDate',
'archived',
'deleted',
];
return significantFields.includes(operation.path.toLowerCase());
}
/**
* Generate diff object for API response
*/
generateDiffObject(diff) {
const diffObj = {};
for (const operation of diff.operations) {
switch (operation.operation) {
case 'add':
diffObj[`+${operation.path}`] = operation.newValue;
break;
case 'remove':
diffObj[`-${operation.path}`] = operation.oldValue;
break;
case 'change':
diffObj[`~${operation.path}`] = {
from: operation.oldValue,
to: operation.newValue,
};
break;
}
}
return diffObj;
}
/**
* Validate updates before processing
*/
validateUpdates(updates) {
const errors = [];
for (let i = 0; i < updates.length; i++) {
const update = updates[i];
if (!update.id || typeof update.id !== 'string') {
errors.push(`Update ${i}: id is required and must be a string`);
}
if (!update.changes || typeof update.changes !== 'object') {
errors.push(`Update ${i}: changes is required and must be an object`);
}
if (Object.keys(update.changes || {}).length === 0) {
errors.push(`Update ${i}: changes cannot be empty`);
}
}
return errors;
}
/**
* Extract entity data from API response
*/
extractEntityData(response) {
if (typeof response === 'object' && response !== null) {
const typedResponse = response;
if (typedResponse.content?.[0]?.text) {
try {
const parsed = JSON.parse(typedResponse.content[0].text);
return parsed.data || parsed || {};
}
catch {
return {};
}
}
}
return {};
}
/**
* Check if error indicates entity not found
*/
isNotFoundError(error) {
if (error instanceof Error) {
return (error.message.toLowerCase().includes('not found') ||
error.message.toLowerCase().includes('404'));
}
return false;
}
/**
* Merge batch result into main result
*/
mergeBatchResult(mainResult, batchResult) {
if (batchResult.successful) {
mainResult.successful.push(...batchResult.successful);
}
if (batchResult.failed) {
mainResult.failed.push(...batchResult.failed);
}
if (batchResult.skipped) {
mainResult.skipped.push(...batchResult.skipped);
}
if (batchResult.changes) {
mainResult.changes.push(...batchResult.changes);
}
}
/**
* Create batches from array of updates
*/
createBatches(items, batchSize) {
const batches = [];
for (let i = 0; i < items.length; i += batchSize) {
batches.push(items.slice(i, i + batchSize));
}
return batches;
}
}
/**
* Utility functions for diff operations
*/
export class DiffUtils {
/**
* Generate human-readable summary of changes
*/
static generateChangeSummary(changes) {
if (changes.length === 0) {
return 'No changes detected';
}
const totalChanges = changes.reduce((sum, change) => sum + change.changeCount, 0);
const entitiesWithChanges = changes.filter(change => change.hasChanges).length;
const significantChangesCount = changes.reduce((sum, change) => sum + change.significantChanges.length, 0);
let summary = `${entitiesWithChanges} entities changed, ${totalChanges} total modifications`;
if (significantChangesCount > 0) {
summary += `, ${significantChangesCount} significant changes`;
}
return summary;
}
/**
* Filter changes by significance
*/
static filterSignificantChanges(changes) {
return changes.filter(change => change.significantChanges.length > 0);
}
/**
* Group changes by operation type
*/
static groupChangesByOperation(changes) {
const grouped = {
add: [],
remove: [],
change: [],
};
for (const change of changes) {
for (const operation of change.operations) {
grouped[operation.operation].push(operation);
}
}
return grouped;
}
/**
* Create diff report in various formats
*/
static createDiffReport(changes, format = 'summary') {
if (changes.length === 0) {
return 'No changes detected.';
}
switch (format) {
case 'summary':
return this.generateChangeSummary(changes);
case 'compact':
return changes
.filter(change => change.hasChanges)
.map(change => `${change.id}: ${change.changeCount} changes`)
.join(', ');
case 'detailed':
return changes
.filter(change => change.hasChanges)
.map(change => {
const operations = change.operations
.map(op => ` ${op.operation} ${op.path}`)
.join('\n');
return `${change.id} (${change.changeCount} changes):\n${operations}`;
})
.join('\n\n');
default:
return this.generateChangeSummary(changes);
}
}
}
// Export singleton instance
export const bulkOperationsEngine = new BulkOperationsEngine();