/** * In-memory filesystem provider for SmartFS * Perfect for testing and temporary storage */ import type { ISmartFsProvider, IProviderCapabilities, TWatchCallback, IWatcherHandle, } from '../interfaces/mod.provider.js'; import type { IFileStats, IDirectoryEntry, IReadOptions, IWriteOptions, IStreamOptions, ICopyOptions, IListOptions, IWatchOptions, ITransactionOperation, IWatchEvent, TWatchEventType, } from '../interfaces/mod.types.js'; /** * In-memory file entry */ interface IMemoryEntry { type: 'file' | 'directory'; content?: Buffer; created: Date; modified: Date; accessed: Date; mode: number; } /** * Watcher registration */ interface IWatcherRegistration { path: string; callback: TWatchCallback; options?: IWatchOptions; } /** * In-memory filesystem provider */ export class SmartFsProviderMemory implements ISmartFsProvider { public readonly name = 'memory'; public readonly capabilities: IProviderCapabilities = { supportsWatch: true, supportsAtomic: true, supportsTransactions: true, supportsStreaming: true, supportsSymlinks: false, // Not implemented yet supportsPermissions: true, }; private storage: Map = new Map(); private watchers: Map = new Map(); private nextWatcherId = 1; constructor() { // Create root directory this.storage.set('/', { type: 'directory', created: new Date(), modified: new Date(), accessed: new Date(), mode: 0o755, }); } // --- File Operations --- public async readFile(path: string, options?: IReadOptions): Promise { const entry = this.storage.get(path); if (!entry) { throw new Error(`ENOENT: no such file or directory, open '${path}'`); } if (entry.type !== 'file') { throw new Error(`EISDIR: illegal operation on a directory, read '${path}'`); } entry.accessed = new Date(); if (!entry.content) { return options?.encoding ? '' : Buffer.alloc(0); } if (options?.encoding && options.encoding !== 'buffer') { return entry.content.toString(options.encoding as BufferEncoding); } return entry.content; } public async writeFile(path: string, content: string | Buffer, options?: IWriteOptions): Promise { const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content, options?.encoding as BufferEncoding); // Ensure parent directory exists await this.ensureParentDirectory(path); const now = new Date(); const entry = this.storage.get(path); if (entry && entry.type === 'directory') { throw new Error(`EISDIR: illegal operation on a directory, open '${path}'`); } this.storage.set(path, { type: 'file', content: buffer, created: entry?.created || now, modified: now, accessed: now, mode: options?.mode || 0o644, }); await this.emitWatchEvent(path, entry ? 'change' : 'add'); } public async appendFile(path: string, content: string | Buffer, options?: IWriteOptions): Promise { const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content, options?.encoding as BufferEncoding); const entry = this.storage.get(path); if (entry && entry.type === 'directory') { throw new Error(`EISDIR: illegal operation on a directory, open '${path}'`); } const existingContent = entry?.content || Buffer.alloc(0); const newContent = Buffer.concat([existingContent, buffer]); await this.writeFile(path, newContent, options); } public async deleteFile(path: string): Promise { const entry = this.storage.get(path); if (!entry) { throw new Error(`ENOENT: no such file or directory, unlink '${path}'`); } if (entry.type === 'directory') { throw new Error(`EISDIR: illegal operation on a directory, unlink '${path}'`); } this.storage.delete(path); await this.emitWatchEvent(path, 'delete'); } public async copyFile(from: string, to: string, options?: ICopyOptions): Promise { const fromEntry = this.storage.get(from); if (!fromEntry) { throw new Error(`ENOENT: no such file or directory, copyfile '${from}'`); } if (fromEntry.type !== 'file') { throw new Error(`EISDIR: illegal operation on a directory, copyfile '${from}'`); } const toEntry = this.storage.get(to); if (toEntry && !options?.overwrite) { throw new Error(`EEXIST: file already exists, copyfile '${from}' -> '${to}'`); } const now = new Date(); this.storage.set(to, { type: 'file', content: fromEntry.content ? Buffer.from(fromEntry.content) : undefined, created: now, modified: options?.preserveTimestamps ? fromEntry.modified : now, accessed: now, mode: fromEntry.mode, }); await this.emitWatchEvent(to, toEntry ? 'change' : 'add'); } public async moveFile(from: string, to: string, options?: ICopyOptions): Promise { await this.copyFile(from, to, options); await this.deleteFile(from); } public async fileExists(path: string): Promise { const entry = this.storage.get(path); return entry !== undefined && entry.type === 'file'; } public async fileStat(path: string): Promise { const entry = this.storage.get(path); if (!entry) { throw new Error(`ENOENT: no such file or directory, stat '${path}'`); } if (entry.type !== 'file') { throw new Error(`EISDIR: illegal operation on a directory, stat '${path}'`); } return { size: entry.content?.length || 0, birthtime: entry.created, mtime: entry.modified, atime: entry.accessed, isFile: true, isDirectory: false, isSymbolicLink: false, mode: entry.mode, }; } public async createReadStream(path: string, options?: IStreamOptions): Promise> { const content = await this.readFile(path); const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content); const chunkSize = options?.chunkSize || 64 * 1024; let offset = 0; return new ReadableStream({ pull(controller) { if (offset >= buffer.length) { controller.close(); return; } const end = Math.min(offset + chunkSize, buffer.length); const chunk = buffer.subarray(offset, end); controller.enqueue(new Uint8Array(chunk)); offset = end; }, }); } public async createWriteStream(path: string, options?: IStreamOptions): Promise> { const chunks: Buffer[] = []; return new WritableStream({ write: async (chunk) => { chunks.push(Buffer.from(chunk)); }, close: async () => { const content = Buffer.concat(chunks); await this.writeFile(path, content); }, }); } // --- Directory Operations --- public async listDirectory(path: string, options?: IListOptions): Promise { const entry = this.storage.get(path); if (!entry) { throw new Error(`ENOENT: no such file or directory, scandir '${path}'`); } if (entry.type !== 'directory') { throw new Error(`ENOTDIR: not a directory, scandir '${path}'`); } const entries: IDirectoryEntry[] = []; const normalizedPath = this.normalizePath(path); const prefix = normalizedPath === '/' ? '/' : `${normalizedPath}/`; for (const [entryPath, entryData] of this.storage.entries()) { if (entryPath === normalizedPath) continue; if (options?.recursive) { // Recursive: include all descendants if (entryPath.startsWith(prefix)) { const relativePath = entryPath.slice(prefix.length); const name = relativePath.split('/').pop()!; const directoryEntry: IDirectoryEntry = { name, path: entryPath, isFile: entryData.type === 'file', isDirectory: entryData.type === 'directory', isSymbolicLink: false, }; if (this.matchesFilter(directoryEntry, options.filter)) { if (options.includeStats) { directoryEntry.stats = await this.getEntryStats(entryPath, entryData); } entries.push(directoryEntry); } } } else { // Non-recursive: only direct children if (entryPath.startsWith(prefix) && !entryPath.slice(prefix.length).includes('/')) { const name = entryPath.slice(prefix.length); const directoryEntry: IDirectoryEntry = { name, path: entryPath, isFile: entryData.type === 'file', isDirectory: entryData.type === 'directory', isSymbolicLink: false, }; if (this.matchesFilter(directoryEntry, options?.filter)) { if (options?.includeStats) { directoryEntry.stats = await this.getEntryStats(entryPath, entryData); } entries.push(directoryEntry); } } } } return entries; } public async createDirectory(path: string, options?: { recursive?: boolean; mode?: number }): Promise { const normalizedPath = this.normalizePath(path); if (options?.recursive) { // Create parent directories const parts = normalizedPath.split('/').filter(Boolean); let currentPath = '/'; for (const part of parts) { currentPath = currentPath === '/' ? `/${part}` : `${currentPath}/${part}`; if (!this.storage.has(currentPath)) { const now = new Date(); this.storage.set(currentPath, { type: 'directory', created: now, modified: now, accessed: now, mode: options?.mode || 0o755, }); await this.emitWatchEvent(currentPath, 'add'); } } } else { const entry = this.storage.get(normalizedPath); if (entry) { throw new Error(`EEXIST: file already exists, mkdir '${normalizedPath}'`); } const now = new Date(); this.storage.set(normalizedPath, { type: 'directory', created: now, modified: now, accessed: now, mode: options?.mode || 0o755, }); await this.emitWatchEvent(normalizedPath, 'add'); } } public async deleteDirectory(path: string, options?: { recursive?: boolean }): Promise { const entry = this.storage.get(path); if (!entry) { throw new Error(`ENOENT: no such file or directory, rmdir '${path}'`); } if (entry.type !== 'directory') { throw new Error(`ENOTDIR: not a directory, rmdir '${path}'`); } if (options?.recursive) { // Delete all descendants const normalizedPath = this.normalizePath(path); const prefix = normalizedPath === '/' ? '/' : `${normalizedPath}/`; const toDelete: string[] = []; for (const entryPath of this.storage.keys()) { if (entryPath.startsWith(prefix) || entryPath === normalizedPath) { toDelete.push(entryPath); } } for (const entryPath of toDelete) { this.storage.delete(entryPath); await this.emitWatchEvent(entryPath, 'delete'); } } else { // Check if directory is empty const children = await this.listDirectory(path); if (children.length > 0) { throw new Error(`ENOTEMPTY: directory not empty, rmdir '${path}'`); } this.storage.delete(path); await this.emitWatchEvent(path, 'delete'); } } public async directoryExists(path: string): Promise { const entry = this.storage.get(path); return entry !== undefined && entry.type === 'directory'; } public async directoryStat(path: string): Promise { const entry = this.storage.get(path); if (!entry) { throw new Error(`ENOENT: no such file or directory, stat '${path}'`); } if (entry.type !== 'directory') { throw new Error(`ENOTDIR: not a directory, stat '${path}'`); } return { size: 0, birthtime: entry.created, mtime: entry.modified, atime: entry.accessed, isFile: false, isDirectory: true, isSymbolicLink: false, mode: entry.mode, }; } // --- Watch Operations --- public async watch(path: string, callback: TWatchCallback, options?: IWatchOptions): Promise { const watcherId = `watcher-${this.nextWatcherId++}`; this.watchers.set(watcherId, { path: this.normalizePath(path), callback, options, }); return { stop: async () => { this.watchers.delete(watcherId); }, }; } // --- Transaction Operations --- public async prepareTransaction(operations: ITransactionOperation[]): Promise { const prepared: ITransactionOperation[] = []; for (const op of operations) { const preparedOp = { ...op }; const entry = this.storage.get(op.path); if (entry && entry.type === 'file') { preparedOp.backup = { existed: true, content: entry.content ? Buffer.from(entry.content) : undefined, stats: await this.getEntryStats(op.path, entry), }; } else { preparedOp.backup = { existed: false, }; } prepared.push(preparedOp); } return prepared; } public async executeTransaction(operations: ITransactionOperation[]): Promise { for (const op of operations) { try { switch (op.type) { case 'write': await this.writeFile(op.path, op.content!, { encoding: op.encoding }); break; case 'append': await this.appendFile(op.path, op.content!, { encoding: op.encoding }); break; case 'delete': await this.deleteFile(op.path); break; case 'copy': await this.copyFile(op.path, op.targetPath!); break; case 'move': await this.moveFile(op.path, op.targetPath!); break; } } catch (error) { // On error, rollback await this.rollbackTransaction(operations); throw error; } } } public async rollbackTransaction(operations: ITransactionOperation[]): Promise { for (let i = operations.length - 1; i >= 0; i--) { const op = operations[i]; if (!op.backup) continue; try { if (op.backup.existed && op.backup.content) { await this.writeFile(op.path, op.backup.content); } else if (!op.backup.existed) { try { await this.deleteFile(op.path); } catch { // Ignore errors } } } catch { // Ignore rollback errors } } } // --- Path Operations --- public normalizePath(path: string): string { // Simple normalization let normalized = path.replace(/\\/g, '/'); normalized = normalized.replace(/\/+/g, '/'); if (normalized !== '/' && normalized.endsWith('/')) { normalized = normalized.slice(0, -1); } if (!normalized.startsWith('/')) { normalized = `/${normalized}`; } return normalized; } public joinPath(...segments: string[]): string { return this.normalizePath(segments.join('/')); } // --- Helper Methods --- private async ensureParentDirectory(path: string): Promise { const parentPath = path.split('/').slice(0, -1).join('/') || '/'; if (!this.storage.has(parentPath)) { await this.createDirectory(parentPath, { recursive: true }); } } private async emitWatchEvent(path: string, type: TWatchEventType): Promise { const normalizedPath = this.normalizePath(path); for (const { path: watchPath, callback, options } of this.watchers.values()) { const shouldTrigger = options?.recursive ? normalizedPath.startsWith(watchPath) : normalizedPath.split('/').slice(0, -1).join('/') === watchPath; if (!shouldTrigger) continue; // Apply filter if (options?.filter && !this.matchesPathFilter(normalizedPath, options.filter)) { continue; } const entry = this.storage.get(normalizedPath); const event: IWatchEvent = { type, path: normalizedPath, timestamp: new Date(), stats: entry ? await this.getEntryStats(normalizedPath, entry) : undefined, }; await callback(event); } } private async getEntryStats(path: string, entry: IMemoryEntry): Promise { return { size: entry.content?.length || 0, birthtime: entry.created, mtime: entry.modified, atime: entry.accessed, isFile: entry.type === 'file', isDirectory: entry.type === 'directory', isSymbolicLink: false, mode: entry.mode, }; } private matchesFilter( entry: IDirectoryEntry, filter?: string | RegExp | ((entry: IDirectoryEntry) => boolean), ): boolean { if (!filter) return true; if (typeof filter === 'function') { return filter(entry); } else if (filter instanceof RegExp) { return filter.test(entry.name); } else { const pattern = filter.replace(/\*/g, '.*'); const regex = new RegExp(`^${pattern}$`); return regex.test(entry.name); } } private matchesPathFilter( path: string, filter: string | RegExp | ((path: string) => boolean), ): boolean { if (typeof filter === 'function') { return filter(path); } else if (filter instanceof RegExp) { return filter.test(path); } else { const pattern = filter.replace(/\*/g, '.*'); const regex = new RegExp(`^${pattern}$`); return regex.test(path); } } /** * Clear all data (useful for testing) */ public clear(): void { this.storage.clear(); // Recreate root this.storage.set('/', { type: 'directory', created: new Date(), modified: new Date(), accessed: new Date(), mode: 0o755, }); } }