import mongoose from "mongoose"; import crypto from 'crypto'; import * as diff from 'diff'; const FileVersionSchema = new mongoose.Schema({ fileId: { type: mongoose.Schema.Types.ObjectId, ref: 'File', required: true, index: true }, content: { type: String, required: true, maxlength: 10 * 1024 * 1024, // 10MB max }, timestamp: { type: Date, required: true, default: Date.now, index: true }, author: { type: String, required: true, enum: ['user', 'ai'], index: true }, changeDescription: { type: String, required: true, maxlength: 1000, trim: true }, diff: { type: String, required: true, maxlength: 5 * 1024 * 1024, // 5MB max for diff }, // Version metadata metadata: { version: { type: String, required: true, default: "1.0.0" }, size: { type: Number, required: true, min: 0 }, checksum: { type: String, required: true, maxlength: 64 // SHA-256 hash }, tags: [{ type: String, maxlength: 50 }], isBackup: { type: Boolean, default: false }, isMajorChange: { type: Boolean, default: false } }, // Performance tracking performance: { creationTime: { type: Number, // milliseconds default: 0 }, compressionRatio: { type: Number, // percentage default: 0 } } }, { timestamps: true, // Indexes for better performance indexes: [ { fileId: 1, timestamp: -1 }, // Latest versions first { fileId: 1, author: 1 }, // Versions by author { timestamp: -1 }, // Global timeline { 'metadata.isBackup': 1 }, // Backup versions { 'metadata.isMajorChange': 1 } // Major changes ] }); // Pre-save middleware FileVersionSchema.pre('save', async function(this: mongoose.Document & { content: string; metadata: { size: number; checksum: string; version: string; isMajorChange?: boolean }; isModified: (path: string) => boolean }) { // Calculate file size if (this.isModified('content')) { this.metadata.size = Buffer.byteLength(this.content, 'utf8'); } // Generate checksum for content integrity if (this.isModified('content')) { this.metadata.checksum = crypto .createHash('sha256') .update(this.content) .digest('hex'); } // Auto-generate version number if (!this.metadata?.version || this.metadata.version === "1.0.0") { await (this as unknown as { generateVersionNumber: () => Promise }).generateVersionNumber(); } }); // Instance methods FileVersionSchema.methods.generateVersionNumber = async function(this: mongoose.Document & { fileId: mongoose.Types.ObjectId; metadata: { version: string; isMajorChange?: boolean }; author: 'user' | 'ai'; }) { const Model = this.constructor as unknown as mongoose.Model; const latestVersionDoc = await Model .findOne({ fileId: this.fileId }) .sort({ timestamp: -1 }) .select('metadata.version'); const latestVersion = latestVersionDoc as unknown as { metadata?: { version: string } } | null; if (!latestVersion || !latestVersion.metadata?.version) { this.metadata.version = "1.0.0"; return; } const [major, minor, patch] = latestVersion.metadata.version.split('.').map(Number); if (this.metadata.isMajorChange) { this.metadata.version = `${major + 1}.0.0`; } else if (this.author === 'ai') { this.metadata.version = `${major}.${minor + 1}.0`; } else { this.metadata.version = `${major}.${minor}.${patch + 1}`; } }; FileVersionSchema.methods.generateDiff = function(previousContent: string) { const patches = diff.createPatch( 'file', previousContent || '', this.content, 'Previous version', 'Current version' ); this.diff = JSON.stringify({ type: 'unified', patches: patches, stats: { additions: (this.content.match(/\n/g) || []).length - (previousContent.match(/\n/g) || []).length, deletions: Math.max(0, (previousContent.match(/\n/g) || []).length - (this.content.match(/\n/g) || []).length), changes: diff.diffLines(previousContent || '', this.content).filter(part => part.added || part.removed).length } }); }; FileVersionSchema.methods.applyDiff = function(targetContent: string): string { try { const diffData = JSON.parse(this.diff); // Apply the diff to get the content const patches = diff.parsePatch(diffData.patches); return diff.applyPatch(targetContent, patches[0]) || targetContent; } catch (error) { console.error('Error applying diff:', error); return this.content; // Fallback to stored content } }; FileVersionSchema.methods.getChangesSummary = function() { try { const diffData = JSON.parse(this.diff); return { additions: diffData.stats?.additions || 0, deletions: diffData.stats?.deletions || 0, changes: diffData.stats?.changes || 0, size: this.metadata.size, version: this.metadata.version }; } catch { return { additions: 0, deletions: 0, changes: 0, size: this.metadata.size, version: this.metadata.version }; } }; // Static methods FileVersionSchema.statics.createVersion = async function( fileId: string, content: string, author: 'user' | 'ai', changeDescription: string, options: { isBackup?: boolean; isMajorChange?: boolean; tags?: string[]; } = {} ) { const startTime = Date.now(); // Get previous version for diff const previousVersionDoc = await (this as unknown as mongoose.Model).findOne({ fileId }) .sort({ timestamp: -1 }) .select('content'); const previousVersion = previousVersionDoc as unknown as { content?: string } | null; const version = new (this as unknown as mongoose.Model)({ fileId, content, author, changeDescription, metadata: { isBackup: options.isBackup || false, isMajorChange: options.isMajorChange || false, tags: options.tags || [] } }) as unknown as { generateDiff: (previousContent: string) => void; performance: { creationTime: number; compressionRatio: number }; content: string; save: () => Promise }; // Generate diff version.generateDiff(previousVersion?.content || ''); // Calculate performance metrics version.performance.creationTime = Date.now() - startTime; version.performance.compressionRatio = previousVersion && previousVersion.content && previousVersion.content.length > 0 ? Math.round((1 - (version.content.length / previousVersion.content.length)) * 100) : 0; await version.save(); // Cleanup old versions (keep last 50) await (this as unknown as { cleanupOldVersions: (fileId: string, keepCount?: number) => Promise }).cleanupOldVersions(fileId); return version as unknown as mongoose.Document; }; FileVersionSchema.statics.getFileHistory = function( fileId: string, limit: number = 20, author?: 'user' | 'ai' ) { const query: { fileId: string; author?: 'user' | 'ai' } = { fileId }; if (author) { query.author = author; } return (this as unknown as mongoose.Model).find(query) .sort({ timestamp: -1 }) .limit(limit) .select('-content -diff') // Exclude large fields for list view .lean(); }; FileVersionSchema.statics.getVersionById = function(versionId: string) { return (this as unknown as mongoose.Model).findById(versionId); }; FileVersionSchema.statics.revertToVersion = async function( fileId: string, versionId: string ): Promise { const version = await (this as unknown as mongoose.Model).findById(versionId) as unknown as { fileId: mongoose.Types.ObjectId; metadata: { version: string }; content: string } | null; if (!version || version.fileId.toString() !== fileId) { throw new Error('Version not found or does not belong to file'); } // Create backup of current state before reverting const currentFile = await mongoose.model('File').findById(fileId); if (currentFile) { await (this as unknown as { createVersion: (fileId: string, content: string, author: 'user' | 'ai', changeDescription: string, options?: { isBackup?: boolean }) => Promise }).createVersion( fileId, currentFile.content, 'user', `Backup before reverting to version ${version.metadata.version}`, { isBackup: true } ); } return version.content; }; FileVersionSchema.statics.cleanupOldVersions = async function( fileId: string, keepCount: number = 50 ) { const versions = await (this as unknown as mongoose.Model).find({ fileId }) .sort({ timestamp: -1 }) .select('_id metadata.isBackup') .lean(); if (versions.length <= keepCount) { return; } // Keep important versions (backups, major changes) const typedVersions = versions as unknown as Array<{ _id: mongoose.Types.ObjectId; metadata?: { isBackup?: boolean; isMajorChange?: boolean } }>; const importantVersions = typedVersions.filter(v => v.metadata?.isBackup || v.metadata?.isMajorChange ); // Keep recent versions const recentVersions = typedVersions.slice(0, keepCount); // Combine and deduplicate const keepVersionIds = new Set([ ...importantVersions.map((v) => v._id.toString()), ...recentVersions.map((v) => v._id.toString()) ]); // Delete old versions const deleteVersionIds = typedVersions .filter((v) => !keepVersionIds.has(v._id.toString())) .map((v) => v._id); if (deleteVersionIds.length > 0) { await (this as unknown as mongoose.Model).deleteMany({ _id: { $in: deleteVersionIds } }); } }; FileVersionSchema.statics.getVersionStats = async function(fileId: string) { const stats = await this.aggregate([ { $match: { fileId: new mongoose.Types.ObjectId(fileId) } }, { $group: { _id: null, totalVersions: { $sum: 1 }, userVersions: { $sum: { $cond: [{ $eq: ['$author', 'user'] }, 1, 0] } }, aiVersions: { $sum: { $cond: [{ $eq: ['$author', 'ai'] }, 1, 0] } }, totalSize: { $sum: '$metadata.size' }, averageSize: { $avg: '$metadata.size' }, firstVersion: { $min: '$timestamp' }, lastVersion: { $max: '$timestamp' } } } ]); return stats[0] || { totalVersions: 0, userVersions: 0, aiVersions: 0, totalSize: 0, averageSize: 0, firstVersion: null, lastVersion: null }; }; export interface FileVersionModel extends mongoose.Model { getFileHistory(fileId: string, limit?: number, author?: 'user' | 'ai'): Promise>; createVersion( fileId: string, content: string, author: 'user' | 'ai', changeDescription: string, options?: { isBackup?: boolean; isMajorChange?: boolean; tags?: string[] } ): Promise; } const FileVersionModelTyped = (mongoose.models.FileVersion as FileVersionModel) || (mongoose.model("FileVersion", FileVersionSchema) as unknown as FileVersionModel); export default FileVersionModelTyped;