643 lines
18 KiB
TypeScript
643 lines
18 KiB
TypeScript
/**
|
|
* 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<string, IMemoryEntry> = new Map();
|
|
private watchers: Map<string, IWatcherRegistration> = 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<Buffer | string> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
await this.copyFile(from, to, options);
|
|
await this.deleteFile(from);
|
|
}
|
|
|
|
public async fileExists(path: string): Promise<boolean> {
|
|
const entry = this.storage.get(path);
|
|
return entry !== undefined && entry.type === 'file';
|
|
}
|
|
|
|
public async fileStat(path: string): Promise<IFileStats> {
|
|
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<ReadableStream<Uint8Array>> {
|
|
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<WritableStream<Uint8Array>> {
|
|
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<IDirectoryEntry[]> {
|
|
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<void> {
|
|
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<void> {
|
|
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<boolean> {
|
|
const entry = this.storage.get(path);
|
|
return entry !== undefined && entry.type === 'directory';
|
|
}
|
|
|
|
public async directoryStat(path: string): Promise<IFileStats> {
|
|
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<IWatcherHandle> {
|
|
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<ITransactionOperation[]> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<IFileStats> {
|
|
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,
|
|
});
|
|
}
|
|
}
|