Stevenic / vectra

Vectra is a local vector database for Node.js with features similar to pinecone but built using local files.
MIT License
321 stars 29 forks source link

Abstract file system #41

Open pelikhan opened 5 months ago

pelikhan commented 5 months ago

Allow swapping a different file system, like a Visual Studio Code fs.

Stevenic commented 2 months ago

Sorry for the delay @pelikhan... I like the idea but may change the way you implemented the abstraction a bit.

pelikhan commented 1 month ago

i can help you with the change if you give me a hint?

Stevenic commented 1 month ago

@pelikhan here's the abstraction I'm using in another related project...

FileStorage.ts

/**
 * Filter to apply when listing files.
 */
export type ListFilesFilter = 'files' | 'folders' | 'all';

/**
 * Abstract file store interface.
 */
export interface FileStorage {
    /**
     * Creates a new file with the given path and content.
     * @remarks
     * Throws an error if the file already exists.
     * @param filePath Path to file to create.
     * @param content Content to write to the file.
     */
    createFile(filePath: string, content: Buffer|string): Promise<void>;

    /**
     * Creates a folder with the given path.
     * @remarks
     * Any missing parent folders will also be created.
     * @param folderPath Path to folder to create.
     */
    createFolder(folderPath: string): Promise<void>;

    /**
     * Deletes the file at the given path if it exists.
     * @param filePath Path to file to delete.
     */
    deleteFile(filePath: string): Promise<void>;

    /**
     * Deletes the folder at the given path if it exists.
     * @remarks
     * All files and folders within the folder will also be deleted.
     * @param folderPath Path to folder to delete.
     */
    deleteFolder(folderPath: string): Promise<void>;

    /**
     * Returns the details of an existing file or folder.
     * @remarks
     * Throws an error if the file or folder does not exist.
     * @param fileOrFolderPath File or folder path to get details for.
     * @returns Details of the file or folder.
     */
    getDetails(fileOrFolderPath: string): Promise<FileDetails>;

    /**
     * Lists all files in the given folder.
     * @param folderPath Folder to list files in.
     * @param filter Optional. Type of entries to return. Defaults to 'all'.
     */
    listFiles(folderPath: string, filter?: ListFilesFilter): Promise<FileDetails[]>;

    /**
     * Returns true if a file or folder exists at the given path.
     * @param fileOrFolderPath File or folder path to check.
     */
    pathExists(fileOrFolderPath: string): Promise<boolean>;

    /**
     * Reads the file at the given path.
     * @param filePath Path to file to read.
     */
    readFile(filePath: string): Promise<Buffer>;

    /**
     * Creates or replaces the file at the given path with the given content.
     * @param filePath Path to file to write.
     * @param content Content to write to the file.
     */
    upsertFile(filePath: string, content: Buffer|string): Promise<void>;
}
/**
 * Details of a file or folder.
 */
export interface FileDetails {
    /**
     * Name of the file or folder.
     */
    name: string;

    /**
     * Path to the file or folder.
     */
    path: string;

    /**
     * True if the entry is a folder.
     */
    isFolder: boolean;
}

LocalFileStorage.ts

import { FileDetails, FileStorage, ListFilesFilter } from "./FileStorage";
import * as fs from 'fs/promises';
import * as path from 'path';

/**
 * A `FileStorage` implementation that uses the local file system.
 */
export class LocalFileStorage implements FileStorage {
    private _rootFolder: string | undefined;

    /**
     * Creates a new `LocalFileStorage` instance.
     * @param rootFolder Optional. Root folder to use for file operations. If not provided, paths passed to operations should be fully qualified.
     */
    constructor(rootFolder?: string) {
        this._rootFolder = rootFolder;
    }

    async createFile(filePath: string, content: Buffer|string): Promise<void> {
        // Convert content to buffer if it's a string
        if (typeof content == 'string') {
            content = Buffer.from(content, 'utf8');
        }

        // Write the file
        await fs.writeFile(this.getFullPath(filePath), content, { flag: 'wx' });
    }

    async createFolder(folderPath: string): Promise<void> {
        await fs.mkdir(this.getFullPath(folderPath), { recursive: true });
    }

    async deleteFile(filePath: string): Promise<void> {
        await fs.unlink(this.getFullPath(filePath));
    }

    async deleteFolder(folderPath: string): Promise<void> {
        await fs.rmdir(this.getFullPath(folderPath), { recursive: true });
    }

    async getDetails(fileOrFolderPath: string): Promise<FileDetails> {
        const stats = await fs.stat(this.getFullPath(fileOrFolderPath));
        return {
            name: path.basename(fileOrFolderPath),
            path: fileOrFolderPath,
            isFolder: stats.isDirectory(),
            fileType: stats.isFile() ? FileStorageUtilities.getFileType(fileOrFolderPath) : undefined
        };
    }

    async listFiles(folderPath: string, filter?: ListFilesFilter | undefined): Promise<FileDetails[]> {
        const folder = this.getFullPath(folderPath);
        const list = await fs.readdir(folder, { withFileTypes: true });
        return list.map((entry) => {
            return {
                name: entry.name,
                path: path.join(folder, entry.name),
                isFolder: entry.isDirectory(),
                fileType: entry.isFile() ? FileStorageUtilities.getFileType(entry.name) : undefined
            };
        }); 
    }

    async pathExists(fileOrFolderPath: string): Promise<boolean> {
        try {
            await fs.access(this.getFullPath(fileOrFolderPath));
            return true;
        } catch (err: unknown) {
            return false;
        }
    }

    async readFile(filePath: string): Promise<Buffer> {
        return await fs.readFile(this.getFullPath(filePath));
    }

    async upsertFile(filePath: string, content: Buffer|string): Promise<void> {
         // Convert content to buffer if it's a string
         if (typeof content == 'string') {
            content = Buffer.from(content, 'utf8');
        }

        // Write the file
       await fs.writeFile(this.getFullPath(filePath), content, { flag: 'w' });
    }

    private getFullPath(relativePath: string): string {
        if (!this._rootFolder) {
            return relativePath.length > 0 ? relativePath : '.';
        } else if (relativePath.length == 0) {
            return this._rootFolder;
        } else {
            return path.join(this._rootFolder, relativePath);
        }
    }
}
Stevenic commented 1 month ago

And here's an in-memory implementation:

VirtualFileStorage.ts

import { FileDetails, FileStorage, ListFilesFilter } from "./FileStorage";
import * as path from "path";

/**
 * An in-memory `FileStorage` implementation.
 */
export class VirtualFileStorage implements FileStorage {
    private readonly _entries: Map<string, StoredFile> = new Map();

    public createFile(filePath: string, content: Buffer|string): Promise<void> {
        // Check if the file already exists
        const key = path.normalize(filePath);
        if (this._entries.has(key)) {
            throw new Error(`File already exists: ${key}`);
        }

        // Convert content to buffer if it's a string
        if (typeof content == 'string') {
            content = Buffer.from(content, 'utf8');
        }

        // Create the file
        this._entries.set(key, { 
            details: { name: path.basename(key), path: key, isFolder: false, fileType: FileStorageUtilities.getFileType(key) },
            content
        });

        return Promise.resolve();
    }

    public createFolder(folderPath: string): Promise<void> {
        // Check if the folder already exists
        const key = path.normalize(folderPath);
        if (this._entries.has(key)) {
            // Throw error if entry is a file
            if (!this._entries.get(key)!.details.isFolder) {
                throw new Error(`Cannot create folder: ${key} is a file`);
            }
        } else {
            // Create the folder
            this._entries.set(key, {
                details: { name: path.basename(key), path: key, isFolder: true, fileType: undefined }
            });
        }

        return Promise.resolve();
    }

    public deleteFile(filePath: string): Promise<void> {
        // Find entry
        const key = path.normalize(filePath);
        const entry = this._entries.get(key);
        if (entry) {
            // Check for a folder
            if (entry.details.isFolder) {
                throw new Error(`Cannot delete file: ${key} is a folder`);
            }

            // Delete the file
            this._entries.delete(key);
        }

        return Promise.resolve();
    }

    public deleteFolder(folderPath: string): Promise<void> {
        // Find entry
        const key = path.normalize(folderPath);
        const entry = this._entries.get(key);
        if (entry) {
            // Check for a file
            if (!entry.details.isFolder) {
                throw new Error(`Cannot delete folder: ${key} is a file`);
            }

            // Delete the folder
            this._entries.delete(key);
        }

        return Promise.resolve();
    }

    public getDetails(fileOrFolderPath: string): Promise<FileDetails> {
        // Find entry
        const key = path.normalize(fileOrFolderPath);
        const entry = this._entries.get(key);
        if (!entry) {
            throw new Error(`Path not found: ${key}`);
        }
        return Promise.resolve(entry.details);
    }

    public listFiles(folderPath: string, filter: ListFilesFilter = 'all'): Promise<FileDetails[]> {
        // Find entries matching filter
        let baseFolder = folderPath.length > 0 ? path.normalize(folderPath) : '';
        const entries = Array.from(this._entries.values())
            .map(e => e.details)
            .filter(e => {
                // Apply provided filter
                if ((filter == 'files' && e.isFolder) || (filter == 'folders' && !e.isFolder)) {
                    return false;
                }

                // Ensure entry is an immediate child of the folder
                const parts = e.path.split(path.sep);
                const parentFolder = parts.length > 1 ? parts.slice(0, parts.length - 1).join(path.sep)  : '';
                return parentFolder == baseFolder;
            });

        return Promise.resolve(entries);   
    }

    public pathExists(fileOrFolderPath: string): Promise<boolean> {
        const key = path.normalize(fileOrFolderPath);
        return Promise.resolve(this._entries.has(key));
    }

    public readFile(filePath: string): Promise<Buffer> {
        // Find entry
        const key = path.normalize(filePath);
        const entry = this._entries.get(key);
        if (!entry) {
            throw new Error(`File not found: ${key}`);
        } else if (entry.details.isFolder) {
            throw new Error(`Cannot read file: ${key} is a folder`);
        }
        return Promise.resolve(entry.content ?? Buffer.from('', 'utf8'));
    }

    public upsertFile(filePath: string, content: Buffer|string): Promise<void> {
        // Check if the file already exists as a folder
        const key = path.normalize(filePath);
        const entry = this._entries.get(key);
        if (entry && entry.details.isFolder) {
            throw new Error(`Cannot write file: ${key} is a folder`);
        }

        // Convert content to buffer if it's a string
        if (typeof content == 'string') {
            content = Buffer.from(content, 'utf8');
        }

        // Create or update the file
        this._entries.set(key, { 
            details: { name: path.basename(key), path: key, isFolder: false },
            content
        });

        return Promise.resolve();
    }
}

/**
 * @private
 */
interface StoredFile {
    details: FileDetails;
    content?: Buffer;
}