UNPKG

@phroun/paged-buffer

Version:

High-performance buffer system for editing massive files with intelligent memory management and undo/redo capabilities

1,214 lines 48.6 kB
"use strict"; /** * @fileoverview Enhanced PagedBuffer with page coordinate-based marks * @description High-performance buffer with line-aware operations and page coordinate marks support * @author Jeffrey R. Day * @version 2.3.0 - Page coordinate marks system */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.NotificationType = exports.FileChangeStrategy = exports.BufferState = exports.MissingDataRange = exports.PagedBuffer = void 0; const fs_1 = require("fs"); const crypto = __importStar(require("crypto")); const path = __importStar(require("path")); const os = __importStar(require("os")); const logger_1 = require("./utils/logger"); const undo_system_1 = require("./undo-system"); const memory_page_storage_1 = require("./storage/memory-page-storage"); const virtual_page_manager_1 = require("./virtual-page-manager"); const line_marks_manager_1 = require("./utils/line-marks-manager"); // Import buffer-specific types (these would need to be created) var BufferState; (function (BufferState) { BufferState["CLEAN"] = "clean"; BufferState["DETACHED"] = "detached"; BufferState["CORRUPTED"] = "corrupted"; })(BufferState || (exports.BufferState = BufferState = {})); var FileChangeStrategy; (function (FileChangeStrategy) { FileChangeStrategy["REBASE"] = "rebase"; FileChangeStrategy["WARN"] = "warn"; FileChangeStrategy["DETACH"] = "detach"; })(FileChangeStrategy || (exports.FileChangeStrategy = FileChangeStrategy = {})); var NotificationType; (function (NotificationType) { NotificationType["BUFFER_DETACHED"] = "buffer_detached"; NotificationType["FILE_MODIFIED_ON_DISK"] = "file_modified_on_disk"; NotificationType["PAGE_SPLIT"] = "page_split"; NotificationType["PAGE_MERGED"] = "page_merged"; NotificationType["STORAGE_ERROR"] = "storage_error"; })(NotificationType || (exports.NotificationType = NotificationType = {})); class BufferNotificationImpl { constructor(type, severity, message, metadata = {}) { this.type = type; this.severity = severity; this.message = message; this.metadata = metadata; this.timestamp = new Date(); } } /** * Tracks missing data ranges in detached buffers */ class MissingDataRange { constructor(virtualStart, virtualEnd, originalFileStart = null, originalFileEnd = null, reason = 'unknown') { this.virtualStart = virtualStart; this.virtualEnd = virtualEnd; this.originalFileStart = originalFileStart; this.originalFileEnd = originalFileEnd; this.reason = reason; this.size = virtualEnd - virtualStart; } /** * Generate human-readable description of missing data */ toDescription() { const sizeDesc = this.size === 1 ? '1 byte' : `${this.size.toLocaleString()} bytes`; let desc = `[Missing ${sizeDesc} from buffer addresses ${this.virtualStart.toLocaleString()} to ${this.virtualEnd.toLocaleString()}`; if (this.originalFileStart !== null && this.originalFileEnd !== null) { desc += `, original file positions ${this.originalFileStart.toLocaleString()} to ${this.originalFileEnd.toLocaleString()}`; } if (this.reason !== 'unknown') { desc += `, reason: ${this.reason}`; } desc += '.]'; desc += '\n'; return desc; } } exports.MissingDataRange = MissingDataRange; /** * Enhanced PagedBuffer with page coordinate-based marks */ class PagedBuffer { constructor(pageSize = 64 * 1024, storage = null, maxMemoryPages = 100) { // File metadata this.filename = null; this.fileSize = 0; this.fileMtime = null; this.fileChecksum = null; // Virtual file state this.totalSize = 0; // REFACTORED STATE MANAGEMENT: // Data integrity state (clean/detached/corrupted) this.state = BufferState.CLEAN; // Modification state (separate from integrity) this.hasUnsavedChanges = false; // Detached buffer tracking this.missingDataRanges = []; this.detachmentReason = null; // Notification system this.notifications = []; this.notificationCallbacks = []; // Monitoring this.lastFileCheck = null; this.fileCheckInterval = 5000; // Undo/Redo system this.undoSystem = null; this.pageSize = pageSize; this.storage = storage || new memory_page_storage_1.MemoryPageStorage(); this.maxMemoryPages = maxMemoryPages; // Virtual Page Manager this.virtualPageManager = new virtual_page_manager_1.VirtualPageManager(this, pageSize, maxMemoryPages); // Enhanced Line and Marks Manager with page coordinates this.lineAndMarksManager = new line_marks_manager_1.LineAndMarksManager(this.virtualPageManager); this.virtualPageManager.setLineAndMarksManager(this.lineAndMarksManager); // File change detection settings this.changeStrategy = { noEdits: FileChangeStrategy.REBASE, withEdits: FileChangeStrategy.WARN, sizeChanged: FileChangeStrategy.DETACH }; } /** * Mark buffer as detached due to data loss */ _markAsDetached(reason, missingRanges = []) { const wasDetached = this.state === BufferState.DETACHED; // CRITICAL: Always transition to DETACHED when corruption is detected this.state = BufferState.DETACHED; this.detachmentReason = reason; this.missingDataRanges = [...this.missingDataRanges, ...missingRanges]; // Merge overlapping ranges this._mergeMissingRanges(); if (!wasDetached) { this._notify(NotificationType.BUFFER_DETACHED, 'warning', `Buffer detached: ${reason}. Some data may be unavailable.`, { reason, missingRanges: missingRanges.length, totalMissingBytes: missingRanges.reduce((sum, range) => sum + range.size, 0), recommendation: 'Use Save As to save available data to a new file' }); } } /** * Mark buffer as having unsaved changes */ markAsModified() { this.hasUnsavedChanges = true; } /** * Mark buffer as saved (no unsaved changes) */ _markAsSaved() { this.hasUnsavedChanges = false; } /** * Merge overlapping missing data ranges */ _mergeMissingRanges() { if (this.missingDataRanges.length <= 1) return; // Sort by virtual start position this.missingDataRanges.sort((a, b) => a.virtualStart - b.virtualStart); const merged = [this.missingDataRanges[0]]; for (let i = 1; i < this.missingDataRanges.length; i++) { const current = this.missingDataRanges[i]; const last = merged[merged.length - 1]; if (current.virtualStart <= last.virtualEnd) { // Overlapping or adjacent ranges - merge them last.virtualEnd = Math.max(last.virtualEnd, current.virtualEnd); last.size = last.virtualEnd - last.virtualStart; if (current.originalFileEnd !== null && last.originalFileEnd !== null) { last.originalFileEnd = Math.max(last.originalFileEnd, current.originalFileEnd); } } else { merged.push(current); } } this.missingDataRanges = merged; } /** * Add notification callback */ onNotification(callback) { this.notificationCallbacks.push(callback); } /** * Emit a notification */ _notify(type, severity, message, metadata = {}) { const notification = new BufferNotificationImpl(type, severity, message, metadata); this.notifications.push(notification); for (const callback of this.notificationCallbacks) { try { callback(notification); } catch (error) { logger_1.logger.error('Notification callback error:', error); } } } /** * Load a file into the buffer */ async loadFile(filename) { try { const stats = await fs_1.promises.stat(filename); this.filename = filename; this.fileSize = stats.size; this.fileMtime = stats.mtime; this.totalSize = stats.size; this.lastFileCheck = Date.now(); // Clear any previous state this.state = BufferState.CLEAN; this.hasUnsavedChanges = false; this.missingDataRanges = []; this.detachmentReason = null; // Calculate file checksum this.fileChecksum = await this._calculateFileChecksum(filename); // Initialize Virtual Page Manager from file this.virtualPageManager.initializeFromFile(filename, stats.size, this.fileChecksum); this._notify(NotificationType.FILE_MODIFIED_ON_DISK, 'info', 'Loaded file', { filename, size: stats.size, state: this.state, hasUnsavedChanges: this.hasUnsavedChanges }); } catch (error) { throw new Error(`Failed to load file: ${error.message}`); } } /** * Enhanced loadContent with proper initial state */ loadContent(content) { this.filename = null; this.totalSize = Buffer.byteLength(content, 'utf8'); // Clear any previous state this.state = BufferState.CLEAN; this.hasUnsavedChanges = false; this.missingDataRanges = []; this.detachmentReason = null; // Initialize Virtual Page Manager from content const contentBuffer = Buffer.from(content, 'utf8'); this.virtualPageManager.initializeFromContent(contentBuffer); this._notify('buffer_content_loaded', 'info', 'Loaded content', { size: this.totalSize, state: this.state, hasUnsavedChanges: this.hasUnsavedChanges }); } /** * Enhanced loadBinaryContent with proper initial state */ loadBinaryContent(content) { this.filename = null; this.totalSize = content.length; // Clear any previous state this.state = BufferState.CLEAN; this.hasUnsavedChanges = false; this.missingDataRanges = []; this.detachmentReason = null; // Initialize Virtual Page Manager from content this.virtualPageManager.initializeFromContent(content); this._notify('buffer_content_loaded', 'info', 'Loaded binary content', { size: this.totalSize, state: this.state, hasUnsavedChanges: this.hasUnsavedChanges }); } /** * Calculate file checksum for change detection */ async _calculateFileChecksum(filename) { if (this.fileSize === 0) { return 'd41d8cd98f00b204e9800998ecf8427e'; // MD5 of empty string } const hash = crypto.createHash('md5'); const fd = await fs_1.promises.open(filename, 'r'); const buffer = Buffer.alloc(8192); try { let position = 0; while (position < this.fileSize) { const { bytesRead } = await fd.read(buffer, 0, buffer.length, position); if (bytesRead === 0) break; hash.update(buffer.subarray(0, bytesRead)); position += bytesRead; } } finally { await fd.close(); } return hash.digest('hex'); } /** * Check for file changes */ async checkFileChanges() { if (!this.filename) { return { changed: false }; } try { const stats = await fs_1.promises.stat(this.filename); const sizeChanged = stats.size !== this.fileSize; const mtimeChanged = stats.mtime.getTime() !== this.fileMtime.getTime(); const changed = sizeChanged || mtimeChanged; return { changed, sizeChanged, mtimeChanged, newSize: stats.size, newMtime: stats.mtime, deleted: false }; } catch (error) { if (error.code === 'ENOENT') { return { changed: true, deleted: true, sizeChanged: true, mtimeChanged: true }; } throw error; } } // =================== CORE BYTE OPERATIONS WITH MARKS SUPPORT =================== /** * Get bytes from absolute position with optional marks extraction */ async getBytes(start, end, includeMarks = false) { if (start < 0 || end < 0) { throw new Error('Invalid range: positions cannot be negative'); } if (start > end) { return includeMarks ? new line_marks_manager_1.ExtractedContent(Buffer.alloc(0), []) : Buffer.alloc(0); } if (start >= this.totalSize) { return includeMarks ? new line_marks_manager_1.ExtractedContent(Buffer.alloc(0), []) : Buffer.alloc(0); } if (end > this.totalSize) { end = this.totalSize; } try { if (includeMarks) { return await this.lineAndMarksManager.getBytesWithMarks(start, end, true); } else { return await this.virtualPageManager.readRange(start, end); } } catch (error) { // CRITICAL: If VPM fails to read data, this triggers detachment // The VPM should have already called _markAsDetached through _handleCorruption // So we just return empty buffer here return includeMarks ? new line_marks_manager_1.ExtractedContent(Buffer.alloc(0), []) : Buffer.alloc(0); } } /** * Enhanced insertBytes with marks support - FIXED parameter handling */ async insertBytes(position, data, marks = []) { if (position < 0) { throw new Error('Invalid position: cannot be negative'); } if (position > this.totalSize) { throw new Error(`Position ${position} is beyond end of buffer (size: ${this.totalSize})`); } logger_1.logger.debug(`[DEBUG] insertBytes called: position=${position}, dataLen=${data.length}`); // Capture values before execution for undo recording const originalPosition = position; const originalData = Buffer.from(data); const timestamp = this.undoSystem ? this.undoSystem.getClock() : Date.now(); // CRITICAL FIX: Capture marks snapshot BEFORE operation executes const preOpMarksSnapshot = this.undoSystem ? this.undoSystem.captureCurrentMarksState() : null; logger_1.logger.debug('[DEBUG] Pre-op marks:', preOpMarksSnapshot); // FIXED: Handle both MarkInfo objects and RelativeMarkTuple arrays let relativeMarks; if (marks.length > 0) { if (Array.isArray(marks[0])) { // Already tuples relativeMarks = marks; } else { // MarkInfo objects - convert to tuples relativeMarks = marks.map(mark => [mark.name, mark.relativeOffset]); } } else { relativeMarks = []; } // Always use enhanced method (handles both VPM and mark updates) await this.lineAndMarksManager.insertBytesWithMarks(position, data, relativeMarks); // Update buffer state this.totalSize += data.length; this.markAsModified(); logger_1.logger.debug('[DEBUG] Post-op marks:', this.lineAndMarksManager.getAllMarks()); // Record the operation AFTER executing it, with pre-operation snapshot if (this.undoSystem) { this.undoSystem.recordInsert(originalPosition, originalData, timestamp, preOpMarksSnapshot); } } /** * Enhanced deleteBytes with marks reporting - FIXED to handle tuples */ async deleteBytes(start, end, reportMarks = false) { if (start < 0 || end < 0) { throw new Error('Invalid range: positions cannot be negative'); } if (start > end) { throw new Error('Invalid range: start position must be less than or equal to end position'); } if (start >= this.totalSize) { return reportMarks ? new line_marks_manager_1.ExtractedContent(Buffer.alloc(0), []) : Buffer.alloc(0); } if (end > this.totalSize) { end = this.totalSize; } logger_1.logger.debug(`[DEBUG] deleteBytes called: start=${start}, end=${end}, reportMarks=${reportMarks}`); // Capture values before execution for undo recording const originalStart = start; const timestamp = this.undoSystem ? this.undoSystem.getClock() : Date.now(); // CRITICAL FIX: Capture marks snapshot BEFORE operation executes const preOpMarksSnapshot = this.undoSystem ? this.undoSystem.captureCurrentMarksState() : null; logger_1.logger.debug('[DEBUG] Pre-delete marks:', preOpMarksSnapshot); // Always use enhanced method (handles both VPM and mark updates) const result = await this.lineAndMarksManager.deleteBytesWithMarks(start, end, reportMarks); // Update buffer state this.totalSize -= result.data.length; this.markAsModified(); logger_1.logger.debug('[DEBUG] Post-delete marks:', this.lineAndMarksManager.getAllMarks()); // Record the operation AFTER executing it, with pre-operation snapshot if (this.undoSystem) { this.undoSystem.recordDelete(originalStart, result.data, timestamp, preOpMarksSnapshot); } // Return appropriate format based on reportMarks parameter if (reportMarks) { return result; // ExtractedContent with marks info as tuples } else { return result.data; // Just the Buffer for backward compatibility } } /** * Enhanced overwriteBytes with marks support - FIXED to handle tuples */ async overwriteBytes(position, data, marks = []) { if (position < 0) { throw new Error('Invalid position: cannot be negative'); } if (position >= this.totalSize) { throw new Error(`Position ${position} is beyond end of buffer (size: ${this.totalSize})`); } logger_1.logger.debug(`[DEBUG] overwriteBytes called: position=${position}, dataLen=${data.length}`); // Capture values before execution for undo recording const originalPosition = position; const originalData = Buffer.from(data); const timestamp = this.undoSystem ? this.undoSystem.getClock() : Date.now(); // CRITICAL FIX: Capture marks snapshot BEFORE operation executes const preOpMarksSnapshot = this.undoSystem ? this.undoSystem.captureCurrentMarksState() : null; // Calculate overwrite range for undo recording const overwriteEnd = Math.min(position + data.length, this.totalSize); const overwrittenDataForUndo = await this.getBytes(position, overwriteEnd); // FIXED: Handle both MarkInfo objects and RelativeMarkTuple arrays let relativeMarks; if (marks.length > 0) { if (Array.isArray(marks[0])) { // Already tuples relativeMarks = marks; } else { // MarkInfo objects - convert to tuples relativeMarks = marks.map(mark => [mark.name, mark.relativeOffset]); } } else { relativeMarks = []; } // Always use enhanced method (handles both VPM and mark updates) const result = await this.lineAndMarksManager.overwriteBytesWithMarks(position, data, relativeMarks); // Update buffer state const originalSize = overwriteEnd - position; const netSizeChange = data.length - originalSize; this.totalSize += netSizeChange; this.markAsModified(); // Record the operation AFTER executing it, with pre-operation snapshot if (this.undoSystem) { this.undoSystem.recordOverwrite(originalPosition, originalData, overwrittenDataForUndo, timestamp, preOpMarksSnapshot); } // BACKWARD COMPATIBILITY: Return Buffer if no marks provided, ExtractedContent if marks provided if (marks.length > 0) { return result; // ExtractedContent with tuple marks } else { return result.data; // Buffer (legacy behavior) } } // =================== NAMED MARKS API =================== /** * Set a named mark at a byte address */ setMark(markName, byteAddress) { this.lineAndMarksManager.setMark(markName, byteAddress); } /** * Get the byte address of a named mark */ getMark(markName) { return this.lineAndMarksManager.getMark(markName); } /** * Remove a named mark */ removeMark(markName) { return this.lineAndMarksManager.removeMark(markName); } /** * Get all marks between two byte addresses */ getMarksInRange(startAddress, endAddress) { return this.lineAndMarksManager.getMarksInRange(startAddress, endAddress); } /** * Get all marks in the buffer */ getAllMarks() { return this.lineAndMarksManager.getAllMarksForPersistence(); } /** * Set marks from a key-value object (for persistence) */ setMarks(marksObject) { this.lineAndMarksManager.setMarksFromPersistence(marksObject); } /** * Clear all marks */ clearAllMarks() { this.lineAndMarksManager.clearAllMarks(); } // =================== UNDO/REDO SYSTEM =================== /** * Enable undo/redo functionality */ enableUndo(config = {}) { if (!this.undoSystem) { this.undoSystem = new undo_system_1.BufferUndoSystem(this, config.maxUndoLevels); if (config) { this.undoSystem.configure(config); } } } /** * Disable undo/redo functionality */ disableUndo() { if (this.undoSystem) { this.undoSystem.clear(); this.undoSystem = null; } } /** * Begin a named undo transaction */ beginUndoTransaction(name, options = {}) { if (this.undoSystem) { this.undoSystem.beginUndoTransaction(name, options); } } /** * Commit the current undo transaction */ commitUndoTransaction(finalName = null) { if (this.undoSystem) { return this.undoSystem.commitUndoTransaction(finalName); } return false; } /** * Rollback the current undo transaction */ async rollbackUndoTransaction() { if (this.undoSystem) { return await this.undoSystem.rollbackUndoTransaction(); } return false; } /** * Check if currently in an undo transaction */ inUndoTransaction() { return this.undoSystem ? this.undoSystem.inTransaction() : false; } /** * Get current undo transaction info */ getCurrentUndoTransaction() { return this.undoSystem ? this.undoSystem.getCurrentTransaction() : null; } /** * Undo the last operation */ async undo() { if (!this.undoSystem) { return false; } return await this.undoSystem.undo(); } /** * Redo the last undone operation */ async redo() { if (!this.undoSystem) { return false; } return await this.undoSystem.redo(); } /** * Check if undo is available */ canUndo() { return this.undoSystem ? this.undoSystem.canUndo() : false; } /** * Check if redo is available */ canRedo() { return this.undoSystem ? this.undoSystem.canRedo() : false; } // =================== UTILITY METHODS =================== /** * Get total size of buffer */ getTotalSize() { return this.virtualPageManager.getTotalSize(); } /** * Get buffer state (data integrity) */ getState() { // Validate state consistency if (this.state === BufferState.DETACHED && this.missingDataRanges.length === 0) { logger_1.logger.warn('Buffer marked as DETACHED but has no missing data ranges'); } return this.state; } /** * Check if buffer has unsaved changes */ hasChanges() { return this.hasUnsavedChanges; } /** * Check if buffer can be saved to its original location */ canSaveToOriginal() { return this.state !== BufferState.DETACHED; } /** * Get comprehensive buffer status */ getStatus() { return { state: this.state, hasUnsavedChanges: this.hasUnsavedChanges, canSaveToOriginal: this.canSaveToOriginal(), isDetached: this.state === BufferState.DETACHED, isCorrupted: this.state === BufferState.CORRUPTED, missingDataRanges: this.missingDataRanges.length, totalSize: this.getTotalSize(), filename: this.filename }; } /** * Get enhanced memory usage stats with line and marks information */ getMemoryStats() { const vmpStats = this.virtualPageManager.getMemoryStats(); const lmStats = this.lineAndMarksManager.getMemoryStats(); const undoStats = this.undoSystem ? this.undoSystem.getStats() : { undoGroups: 0, redoGroups: 0, totalUndoOperations: 0, totalRedoOperations: 0, currentGroupOperations: 0, memoryUsage: 0 }; return { // VPM stats totalPages: vmpStats.totalPages, loadedPages: vmpStats.loadedPages, dirtyPages: vmpStats.dirtyPages, detachedPages: 0, // Enhanced VPM handles this differently memoryUsed: vmpStats.memoryUsed, maxMemoryPages: this.maxMemoryPages, // Line and marks stats totalLines: lmStats.totalLines, globalMarksCount: lmStats.globalMarksCount, pageIndexSize: lmStats.pageIndexSize, linesMemory: vmpStats.linesMemory + lmStats.estimatedLinesCacheMemory, marksMemory: vmpStats.marksMemory + lmStats.estimatedMarksMemory, lineStartsCacheValid: lmStats.lineStartsCacheValid, // Buffer stats state: this.state, hasUnsavedChanges: this.hasUnsavedChanges, virtualSize: vmpStats.virtualSize, sourceSize: vmpStats.sourceSize, // Undo stats undo: undoStats }; } /** * Get detachment information */ getDetachmentInfo() { return { isDetached: this.state === BufferState.DETACHED, reason: this.detachmentReason, missingRanges: this.missingDataRanges.length, totalMissingBytes: this.missingDataRanges.reduce((sum, range) => sum + range.size, 0), ranges: this.missingDataRanges.map(range => ({ virtualStart: range.virtualStart, virtualEnd: range.virtualEnd, size: range.size, reason: range.reason })) }; } /** * Get all notifications */ getNotifications() { return [...this.notifications]; } /** * Clear notifications */ clearNotifications(type = null) { if (type) { this.notifications = this.notifications.filter(n => n.type !== type); } else { this.notifications = []; } } /** * Set file change handling strategy */ setChangeStrategy(strategies) { this.changeStrategy = { ...this.changeStrategy, ...strategies }; } // =================== FILE METHODS WITH DETACHED BUFFER SUPPORT =================== /** * Generate missing data summary for save operations */ _generateMissingDataSummary() { if (this.missingDataRanges.length === 0) { return ''; } let summary = ''; const header = '--- MISSING DATA SUMMARY ---\n'; summary += header; for (const range of this.missingDataRanges) { summary += range.toDescription(); } const footer = '--- END MISSING DATA ---\n\n'; summary += footer; return summary; } /** * Create marker for missing data at a specific position */ _createMissingDataMarker(missingRange) { const nl = '\n'; // Use newlines for readability let marker = `${nl}--- MISSING ${missingRange.size.toLocaleString()} BYTES `; marker += `FROM BUFFER ADDRESS ${missingRange.virtualStart.toLocaleString()} `; if (missingRange.originalFileStart !== null) { marker += `(ORIGINAL FILE POSITION ${missingRange.originalFileStart.toLocaleString()}) `; } if (missingRange.reason && missingRange.reason !== 'unknown') { marker += `- REASON: ${missingRange.reason.toUpperCase()} `; } marker += `---${nl}`; marker += `--- BEGIN DATA BELONGING AT BUFFER ADDRESS ${missingRange.virtualEnd.toLocaleString()} ---${nl}`; return marker; } /** * Create marker for missing data at end of file */ _createEndOfFileMissingMarker(lastRange, totalSize) { const nl = '\n'; const missingAtEnd = lastRange.virtualEnd - totalSize; if (missingAtEnd <= 0) return ''; let marker = `${nl}--- MISSING ${missingAtEnd.toLocaleString()} BYTES AT END OF FILE `; if (lastRange.originalFileStart !== null) { const originalEnd = lastRange.originalFileEnd || (lastRange.originalFileStart + lastRange.size); const missingOriginalAtEnd = originalEnd - (lastRange.originalFileStart + (totalSize - lastRange.virtualStart)); if (missingOriginalAtEnd > 0) { marker += `(ORIGINAL FILE BYTES ${(originalEnd - missingOriginalAtEnd).toLocaleString()} TO ${originalEnd.toLocaleString()}) `; } } if (lastRange.reason && lastRange.reason !== 'unknown') { marker += `- REASON: ${lastRange.reason.toUpperCase()} `; } marker += `---${nl}`; return marker; } /** * Create emergency marker for data that became unavailable during save */ _createEmergencyMissingMarker(startPos, endPos, reason) { const nl = '\n'; const size = endPos - startPos; let marker = `${nl}--- EMERGENCY: ${size.toLocaleString()} BYTES UNAVAILABLE DURING SAVE `; marker += `FROM BUFFER ADDRESS ${startPos.toLocaleString()} `; marker += `- REASON: ${reason.toUpperCase()} ---${nl}`; marker += `--- BEGIN DATA BELONGING AT BUFFER ADDRESS ${endPos.toLocaleString()} ---${nl}`; // Add this as a new missing range for future reference const emergencyRange = new MissingDataRange(startPos, endPos, startPos, endPos, `save_failure: ${reason}`); if (!this.missingDataRanges.some(range => range.virtualStart === startPos && range.virtualEnd === endPos)) { this.missingDataRanges.push(emergencyRange); this._mergeMissingRanges(); } return marker; } /** * Write data with markers indicating where missing data belongs - FIXED for large files */ async _writeDataWithMissingMarkers(fd) { const totalSize = this.getTotalSize(); if (totalSize === 0) return; // Calculate maximum chunk size to prevent memory issues const maxChunkSize = this.pageSize * this.maxMemoryPages; logger_1.logger.debug(`Writing file with chunk size: ${maxChunkSize.toLocaleString()} bytes`); // Sort missing ranges by position for proper insertion const sortedMissingRanges = [...this.missingDataRanges].sort((a, b) => a.virtualStart - b.virtualStart); let currentPos = 0; let missingRangeIndex = 0; while (currentPos < totalSize || missingRangeIndex < sortedMissingRanges.length) { // Check if we've reached a missing data range if (missingRangeIndex < sortedMissingRanges.length) { const missingRange = sortedMissingRanges[missingRangeIndex]; if (currentPos === missingRange.virtualStart) { // Insert missing data marker const marker = this._createMissingDataMarker(missingRange); await fd.write(Buffer.from(marker)); // Skip over the missing range currentPos = missingRange.virtualEnd; missingRangeIndex++; continue; } } // Find the next chunk boundary (either to end or to next missing range) let segmentEnd = totalSize; if (missingRangeIndex < sortedMissingRanges.length) { segmentEnd = Math.min(segmentEnd, sortedMissingRanges[missingRangeIndex].virtualStart); } if (currentPos < segmentEnd) { // FIXED: Write available data in chunks to prevent memory/buffer issues await this._writeSegmentInChunks(fd, currentPos, segmentEnd, maxChunkSize); currentPos = segmentEnd; } else { break; } } // Check for missing data at the end of file if (sortedMissingRanges.length > 0) { const lastRange = sortedMissingRanges[sortedMissingRanges.length - 1]; if (lastRange.virtualEnd >= totalSize) { const endMarker = this._createEndOfFileMissingMarker(lastRange, totalSize); await fd.write(Buffer.from(endMarker)); } } } /** * Write a segment of data in manageable chunks */ async _writeSegmentInChunks(fd, startPos, endPos, maxChunkSize) { let chunkStart = startPos; while (chunkStart < endPos) { // Calculate this chunk's end (don't exceed segment boundary or max chunk size) const chunkEnd = Math.min(chunkStart + maxChunkSize, endPos); const chunkSize = chunkEnd - chunkStart; try { // Read this chunk from the virtual page manager const chunk = await this.virtualPageManager.readRange(chunkStart, chunkEnd); if (chunk.length > 0) { await fd.write(chunk); // Progress logging for large files if (chunkSize > 1024 * 1024) { // Log for chunks > 1MB const progress = ((chunkEnd - startPos) / (endPos - startPos) * 100).toFixed(1); logger_1.logger.debug(`Written ${chunkEnd.toLocaleString()} / ${endPos.toLocaleString()} bytes (${progress}%)`); } } } catch (error) { // Data became unavailable during save - add an emergency marker logger_1.logger.warn(`Data unavailable for chunk ${chunkStart}-${chunkEnd}: ${error.message}`); const emergencyMarker = this._createEmergencyMissingMarker(chunkStart, chunkEnd, error.message); await fd.write(Buffer.from(emergencyMarker)); } chunkStart = chunkEnd; // CRITICAL: Yield control periodically to prevent event loop blocking if (chunkStart % (maxChunkSize * 10) === 0) { await new Promise(resolve => setImmediate(resolve)); } } } /** * Enhanced save method with smart behavior and atomic operations */ async saveFile(filename = this.filename, options = {}) { if (!filename) { throw new Error('No filename specified'); } // CRITICAL: Check for detached buffer trying to save to original path if (this.state === BufferState.DETACHED) { const isOriginalFile = this.filename && path.resolve(filename) === path.resolve(this.filename); if (isOriginalFile && !options.forcePartialSave) { throw new Error('Refusing to save to original file path with partial data. ' + `Missing ${this.missingDataRanges.length} data range(s). ` + 'Use saveAs() to save to a different location, or pass forcePartialSave=true to override.'); } } // SMART SAVE: If saving to same file and buffer is clean with no changes, it's a no-op const isSameFile = this.filename && path.resolve(filename) === path.resolve(this.filename); if (isSameFile && this.state === BufferState.CLEAN && !this.hasUnsavedChanges && this.filename) { // File is unmodified and we're saving to the same location - no need to save this._notify('save_skipped', 'info', 'Save skipped: buffer is unmodified', { filename, reason: 'unmodified_same_file' }); return; } if (isSameFile) { await this._performAtomicSave(filename, options); } else { await this._performSave(filename, options); } } /** * Enhanced saveAs that handles detached buffers gracefully */ async saveAs(filename, forcePartialOrOptions = {}, options = {}) { if (!filename) { throw new Error('Filename required for saveAs operation'); } let saveOptions = {}; if (typeof forcePartialOrOptions === 'boolean') { // Legacy boolean parameter - ignore it for saveAs saveOptions = { ...options }; } else { saveOptions = { ...forcePartialOrOptions }; } // saveAs always allows saving detached buffers - that's the point await this._performSave(filename, { ...saveOptions, allowDetached: true }); } /** * Enhanced save method with positional missing data markers */ async _performSave(filename, _options = {}) { const fd = await fs_1.promises.open(filename, 'w'); try { // For detached buffers, add missing data summary at the beginning if (this.state === BufferState.DETACHED && this.missingDataRanges.length > 0) { const summary = this._generateMissingDataSummary(); await fd.write(Buffer.from(summary)); this._notify('detached_save_summary', 'info', `Added missing data summary to saved file: ${this.missingDataRanges.length} missing range(s)`, { filename, missingRanges: this.missingDataRanges.length, summarySize: summary.length }); } // Write data with positional markers for missing ranges await this._writeDataWithMissingMarkers(fd); } finally { await fd.close(); } // Update metadata after successful save const stats = await fs_1.promises.stat(filename); this.filename = filename; this.fileSize = stats.size; this.fileMtime = stats.mtime; this.totalSize = this.virtualPageManager.getTotalSize(); // Keep VPM as source of truth // Mark as saved (no unsaved changes) this._markAsSaved(); // Only mark as clean if we're not detached if (this.state !== BufferState.DETACHED) { this.state = BufferState.CLEAN; } } /** * Atomic save that uses temporary copy to prevent corruption */ async _performAtomicSave(filename, options = {}) { let tempCopyPath = null; try { // Step 1: Create temporary copy of original file (if it exists and we need it) if (await this._fileExists(filename)) { tempCopyPath = await this._createTempCopy(filename); this._notify('atomic_save_started', 'info', `Created temporary copy for atomic save: ${tempCopyPath}`, { originalFile: filename, tempCopy: tempCopyPath }); } // Step 2: Update VPM to use temp copy for original file reads if (tempCopyPath) { this._updateVPMSourceFile(tempCopyPath); } // Step 3: Perform the actual save await this._performSave(filename, { ...options, isAtomicSave: true }); // Step 4: Update metadata and state after successful save await this._updateMetadataAfterSave(filename); } catch (error) { // If atomic save fails, we need to restore the VPM source if (tempCopyPath) { this._updateVPMSourceFile(filename); // Restore original } throw error; } finally { // Step 5: Always cleanup temp copy if (tempCopyPath) { await this._cleanupTempCopy(tempCopyPath); } } } /** * Create a temporary copy of the original file */ async _createTempCopy(originalPath) { const tempDir = os.tmpdir(); const baseName = path.basename(originalPath); const tempName = `paged-buffer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${baseName}`; const tempPath = path.join(tempDir, tempName); await fs_1.promises.copyFile(originalPath, tempPath); return tempPath; } /** * Update VPM to use a different source file path */ _updateVPMSourceFile(newPath) { // Update all original-type page descriptors to use the new path for (const descriptor of this.virtualPageManager.addressIndex.getAllPages()) { if (descriptor.sourceType === 'original' && descriptor.sourceInfo.filename) { descriptor.sourceInfo.filename = newPath; } } // Update manager's source file reference this.virtualPageManager.sourceFile = newPath; } /** * Cleanup temporary copy */ async _cleanupTempCopy(tempPath) { try { await fs_1.promises.unlink(tempPath); this._notify('temp_cleanup', 'debug', `Cleaned up temporary copy: ${tempPath}`, { tempPath }); } catch (error) { // Log warning but don't fail the save this._notify('temp_cleanup_failed', 'warning', `Failed to cleanup temporary copy: ${error.message}`, { tempPath, error: error.message }); } } /** * Update metadata after successful save */ async _updateMetadataAfterSave(filename) { try { const stats = await fs_1.promises.stat(filename); this.filename = filename; this.fileSize = stats.size; this.fileMtime = stats.mtime; // Mark as saved this._markAsSaved(); // CRITICAL: Mark buffer as clean after successful save (unless detached) if (this.state !== BufferState.DETACHED) { this.state = BufferState.CLEAN; } // Update VPM source to point back to the saved file this._updateVPMSourceFile(filename); this._notify('save_completed', 'info', `Successfully saved to ${filename}`, { filename, size: stats.size, newState: this.state, hasUnsavedChanges: this.hasUnsavedChanges, wasAtomic: true }); } catch (error) { this._notify('save_metadata_update_failed', 'warning', `Save succeeded but metadata update failed: ${error.message}`, { filename, error: error.message }); } } /** * Check if file exists */ async _fileExists(filePath) { try { await fs_1.promises.access(filePath); return true; } catch { return false; } } /** * Method to manually mark buffer as clean (for testing/special cases) */ _markAsClean() { if (this.state !== BufferState.DETACHED) { this.state = BufferState.CLEAN; } this._markAsSaved(); } /** * Method to check if buffer has been modified * @deprecated Use hasChanges() instead */ isModified() { return this.hasUnsavedChanges; } /** * Method to check if buffer is detached */ isDetached() { return this.state === BufferState.DETACHED; } /** * Method to check if buffer is clean */ isClean() { return this.state === BufferState.CLEAN && !this.hasUnsavedChanges; } // =================== SYNCHRONOUS LINE OPERATIONS API =================== /** * Get total number of lines in the buffer (SYNCHRONOUS) */ getLineCount() { return this.lineAndMarksManager.getTotalLineCount(); } /** * Get information about a specific line (SYNCHRONOUS) */ getLineInfo(lineNumber) { return this.lineAndMarksManager.getLineInfo(lineNumber); } /** * Get information about multiple lines at once (SYNCHRONOUS) */ getMultipleLines(startLine, endLine) { return this.lineAndMarksManager.getMultipleLines(startLine, endLine); } /** * Convert byte address to line number (SYNCHRONOUS) */ getLineNumberFromAddress(byteAddress) { return this.lineAndMarksManager.getLineNumberFromAddress(byteAddress); } /** * Convert line/character position to absolute byte position (SYNCHRONOUS) */ lineCharToBytePosition(pos) { return this.lineAndMarksManager.lineCharToBytePosition(pos); } /** * Convert absolute byte position to line/character position (SYNCHRONOUS) */ byteToLineCharPosition(bytePos) { return this.lineAndMarksManager.byteToLineCharPosition(bytePos); } /** * Ensure page containing address is loaded (ASYNC) */ async seekAddress(address) { return await this.lineAndMarksManager.seekAddress(address); } // =================== CONVENIENCE LINE METHODS =================== /** * Insert content with line/character position (convenience method) */ async insertTextAtPosition(pos, text) { const bytePos = this.lineCharToBytePosition(pos); const textBuffer = Buffer.from(text, 'utf8'); await this.insertBytes(bytePos, textBuffer); const newBytePos = bytePos + textBuffer.length; const newPosition = this.byteToLineCharPosition(newBytePos); return { newPosition }; } /** * Delete content between line/character positions (convenience method) */ async deleteTextBetweenPositions(startPos, endPos) { const startByte = this.lineCharToBytePosition(startPos); const endByte = this.lineCharToBytePosition(endPos); const deletedBytes = await this.deleteBytes(startByte, endByte); const deletedText = deletedBytes.toString('utf8'); return { deletedText }; } } exports.PagedBuffer = PagedBuffer; //# sourceMappingURL=paged-buffer.js.map