initial
This commit is contained in:
642
ts/providers/smartfs.provider.memory.ts
Normal file
642
ts/providers/smartfs.provider.memory.ts
Normal file
@@ -0,0 +1,642 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user