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