@firesystem/indexeddb
Version:
IndexedDB implementation of Virtual File System
1,463 lines (1,242 loc) • 43.2 kB
text/typescript
import type {
FileEntry,
FileStat,
FileMetadata,
FSEvent,
Disposable,
FileSystemEventPayloads,
IFileSystemCapabilities,
} from "@firesystem/core";
import {
normalizePath,
dirname,
basename,
join,
TypedEventEmitter,
FileSystemEvents,
BaseFileSystem,
} from "@firesystem/core";
import type { IndexedDBConfig } from "./types";
interface StoredFile {
path: string;
type: "file" | "directory";
content?: any;
size: number;
created: Date;
modified: Date;
metadata?: FileMetadata;
parent: string;
}
interface WatchListener {
id: string;
pattern: string;
callback: (event: FSEvent) => void;
}
interface FileSnapshot {
path: string;
type: "file" | "directory";
modified: Date;
size: number;
contentHash?: string;
metadataHash?: string;
}
export class IndexedDBFileSystem extends BaseFileSystem {
private db: IDBDatabase | null = null;
private config: Required<
Omit<IndexedDBConfig, "enableExternalSync" | "deepChangeDetection">
> & {
enableExternalSync: boolean;
deepChangeDetection: boolean;
};
private watchers: WatchListener[] = [];
private isInitialized = false;
public readonly events = new TypedEventEmitter<FileSystemEventPayloads>();
private broadcastChannel: BroadcastChannel | null = null;
private fileSnapshots: Map<string, FileSnapshot> = new Map();
private pollingInterval: NodeJS.Timeout | null = null;
private enableExternalSync = true;
private deepChangeDetection = false;
// Capabilities do IndexedDB
readonly capabilities: IFileSystemCapabilities = {
readonly: false,
caseSensitive: true,
atomicRename: false, // IndexedDB não tem rename atômico verdadeiro
supportsWatch: true,
supportsMetadata: true,
supportsGlob: true,
maxFileSize: undefined, // Depende do browser e quota
maxPathLength: 1024,
};
constructor(config: IndexedDBConfig) {
super();
this.config = {
dbName: config.dbName,
version: config.version || 1,
storeName: config.storeName || "files",
enableExternalSync: config.enableExternalSync ?? true,
deepChangeDetection: config.deepChangeDetection ?? false,
};
this.enableExternalSync = config.enableExternalSync ?? true;
this.deepChangeDetection = config.deepChangeDetection ?? false;
if (this.enableExternalSync && typeof BroadcastChannel !== "undefined") {
this.setupBroadcastChannel();
}
}
private async ensureDB(): Promise<IDBDatabase> {
if (this.db && this.db.name === this.config.dbName) {
return this.db;
}
return new Promise((resolve, reject) => {
const openWithVersion = async (version: number): Promise<void> => {
const request = indexedDB.open(this.config.dbName, version);
request.onerror = () => {
reject(new Error(`Failed to open IndexedDB: ${request.error}`));
};
request.onsuccess = async () => {
const db = request.result;
// Check if the expected object store exists
if (!db.objectStoreNames.contains(this.config.storeName)) {
// Database exists but with wrong schema - need to recreate
console.warn(
`Database '${this.config.dbName}' exists but missing object store '${this.config.storeName}'. Recreating database...`,
);
db.close();
// Delete the corrupted database
await new Promise<void>((resolve, reject) => {
const deleteRequest = indexedDB.deleteDatabase(
this.config.dbName,
);
deleteRequest.onsuccess = () => resolve();
deleteRequest.onerror = () =>
reject(
new Error(
`Failed to delete corrupted database: ${deleteRequest.error}`,
),
);
});
// Reopen with the same version - will trigger onupgradeneeded since DB was deleted
await openWithVersion(version);
return;
}
this.db = db;
this.isInitialized = true;
// Handle connection close
this.db.onclose = () => {
this.db = null;
this.isInitialized = false;
};
// Ensure root exists
await this.ensureRootExists();
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create object store if it doesn't exist
if (!db.objectStoreNames.contains(this.config.storeName)) {
const store = db.createObjectStore(this.config.storeName, {
keyPath: "path",
});
// Create indexes for efficient queries
store.createIndex("type", "type", { unique: false });
store.createIndex("parent", "parent", { unique: false });
store.createIndex("modified", "modified", { unique: false });
// Add root directory
const now = new Date();
store.add({
path: "/",
type: "directory",
size: 0,
created: now,
modified: now,
parent: "",
});
}
};
};
// Start with the configured version
openWithVersion(this.config.version);
});
}
async initialize(): Promise<void> {
const startTime = Date.now();
this.events.emit(FileSystemEvents.INITIALIZING, undefined as any);
try {
// Ensure database connection
const db = await this.ensureDB();
// Load ALL files from database
const allFiles = await this.loadAllFiles();
const fileCount = allFiles.length;
// Emit progress for each file loaded
for (let i = 0; i < allFiles.length; i++) {
const file = allFiles[i];
// Emit individual file events
if (file.type === "file") {
this.events.emit(FileSystemEvents.FILE_READ, {
path: file.path,
size: file.size,
});
} else {
this.events.emit(FileSystemEvents.DIR_READ, {
path: file.path,
count: 0, // We'll count children later if needed
});
}
// Emit progress
this.events.emit(FileSystemEvents.INIT_PROGRESS, {
loaded: i + 1,
total: fileCount,
phase: "loading-files",
});
}
this.events.emit(FileSystemEvents.INITIALIZED, {
duration: Date.now() - startTime,
});
} catch (error) {
this.events.emit(FileSystemEvents.INIT_ERROR, {
error: error as Error,
});
throw error;
}
}
private async loadAllFiles(): Promise<StoredFile[]> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.config.storeName], "readonly");
const store = transaction.objectStore(this.config.storeName);
const request = store.getAll();
request.onsuccess = () => {
const files = request.result as StoredFile[];
// Exclude root directory from the list
const userFiles = files.filter((f) => f.path !== "/");
resolve(userFiles);
};
request.onerror = () => {
reject(new Error(`Failed to load files: ${request.error}`));
};
});
}
async readFile(path: string): Promise<FileEntry> {
const normalized = normalizePath(path);
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.config.storeName], "readonly");
const store = transaction.objectStore(this.config.storeName);
const request = store.get(normalized);
request.onsuccess = () => {
const file = request.result as StoredFile;
if (!file) {
reject(
new Error(`ENOENT: no such file or directory, open '${path}'`),
);
return;
}
if (file.type === "directory") {
reject(
new Error(
`EISDIR: illegal operation on a directory, read '${path}'`,
),
);
return;
}
resolve({
path: file.path,
name: basename(file.path),
type: file.type,
size: file.size,
created: file.created,
modified: file.modified,
metadata: file.metadata,
content: file.content,
});
};
request.onerror = () => {
reject(new Error(`Failed to read file: ${request.error}`));
};
});
}
async writeFile(
path: string,
content: any,
metadata?: FileMetadata,
): Promise<FileEntry> {
const normalized = normalizePath(path);
const db = await this.ensureDB();
// Ensure parent directory exists
await this.ensureParentExists(normalized);
const now = new Date();
const size = this.calculateSize(content);
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.config.storeName], "readwrite");
const store = transaction.objectStore(this.config.storeName);
// Check if file exists to preserve created date
const getRequest = store.get(normalized);
getRequest.onsuccess = () => {
const existing = getRequest.result as StoredFile | undefined;
const isUpdate = !!existing;
const storedFile: StoredFile = {
path: normalized,
type: "file",
content,
size,
created: existing?.created || now,
modified: now,
metadata,
parent: dirname(normalized),
} as StoredFile;
const putRequest = store.put(storedFile);
putRequest.onsuccess = () => {
const entry: FileEntry = {
path: normalized,
name: basename(normalized),
type: "file",
size,
created: storedFile.created,
modified: storedFile.modified,
metadata,
content,
};
// Notify watchers
const event = {
type: isUpdate ? "updated" : ("created" as "updated" | "created"),
path: normalized,
timestamp: now,
};
this.notifyWatchers(event);
// Broadcast to other tabs/windows
this.broadcastChange({
...event,
size,
isDirectory: false,
fileType: "file" as const,
});
resolve(entry);
};
putRequest.onerror = () => {
reject(new Error(`Failed to write file: ${putRequest.error}`));
};
};
getRequest.onerror = () => {
reject(new Error(`Failed to check existing file: ${getRequest.error}`));
};
});
}
async deleteFile(path: string): Promise<void> {
const normalized = normalizePath(path);
const db = await this.ensureDB();
// First check if it exists and get type
const file = await this.stat(normalized).catch(() => null);
if (!file) {
throw new Error(`ENOENT: no such file or directory, unlink '${path}'`);
}
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.config.storeName], "readwrite");
const store = transaction.objectStore(this.config.storeName);
if (file.type === "directory") {
// Check if directory is empty
const index = store.index("parent");
const request = index.count(normalized);
request.onsuccess = () => {
if (request.result > 0) {
reject(
new Error(`ENOTEMPTY: directory not empty, rmdir '${path}'`),
);
return;
}
// Directory is empty, delete it
const deleteRequest = store.delete(normalized);
deleteRequest.onsuccess = () => {
const event = {
type: "deleted" as const,
path: normalized,
timestamp: new Date(),
};
this.notifyWatchers(event);
this.broadcastChange(event);
resolve();
};
deleteRequest.onerror = () => {
reject(
new Error(`Failed to delete directory: ${deleteRequest.error}`),
);
};
};
request.onerror = () => {
reject(
new Error(`Failed to check directory contents: ${request.error}`),
);
};
} else {
// It's a file, delete directly
const request = store.delete(normalized);
request.onsuccess = () => {
const event = {
type: "deleted" as const,
path: normalized,
timestamp: new Date(),
};
this.notifyWatchers(event);
this.broadcastChange(event);
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete file: ${request.error}`));
};
}
});
}
async exists(path: string): Promise<boolean> {
const normalized = normalizePath(path);
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.config.storeName], "readonly");
const store = transaction.objectStore(this.config.storeName);
const request = store.count(normalized);
request.onsuccess = () => {
resolve(request.result > 0);
};
request.onerror = () => {
reject(new Error(`Failed to check existence: ${request.error}`));
};
});
}
async readDir(path: string): Promise<FileEntry[]> {
const normalized = normalizePath(path);
const db = await this.ensureDB();
// First check if directory exists
if (normalized !== "/" && !(await this.exists(normalized))) {
throw new Error(`ENOENT: no such file or directory, scandir '${path}'`);
}
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.config.storeName], "readonly");
const store = transaction.objectStore(this.config.storeName);
const index = store.index("parent");
const request = index.getAll(normalized);
request.onsuccess = () => {
const files = request.result as StoredFile[];
const entries = files.map((file) => ({
path: file.path,
name: basename(file.path),
type: file.type,
size: file.size,
created: file.created,
modified: file.modified,
metadata: file.metadata,
}));
resolve(entries);
};
request.onerror = () => {
reject(new Error(`Failed to read directory: ${request.error}`));
};
});
}
async mkdir(path: string, recursive: boolean = false): Promise<FileEntry> {
const normalized = normalizePath(path);
if (normalized === "/") {
throw new Error(`EEXIST: file already exists, mkdir '${path}'`);
}
const db = await this.ensureDB();
// Check if already exists
if (await this.exists(normalized)) {
throw new Error(`EEXIST: file already exists, mkdir '${path}'`);
}
// Ensure parent exists if not recursive
const parent = dirname(normalized);
if (!recursive && parent !== "/" && !(await this.exists(parent))) {
throw new Error(`ENOENT: no such file or directory, mkdir '${path}'`);
}
// Create parent directories if recursive
if (recursive && parent !== "/" && !(await this.exists(parent))) {
await this.mkdir(parent, true);
}
const now = new Date();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.config.storeName], "readwrite");
const store = transaction.objectStore(this.config.storeName);
const storedFile: StoredFile = {
path: normalized,
type: "directory",
size: 0,
created: now,
modified: now,
parent: dirname(normalized),
} as StoredFile;
const request = store.add(storedFile);
request.onsuccess = () => {
const entry: FileEntry = {
path: normalized,
name: basename(normalized),
type: "directory",
size: 0,
created: now,
modified: now,
};
const event = {
type: "created" as const,
path: normalized,
timestamp: now,
};
this.notifyWatchers(event);
this.broadcastChange({
...event,
isDirectory: true,
fileType: "directory" as const,
});
resolve(entry);
};
request.onerror = () => {
reject(new Error(`Failed to create directory: ${request.error}`));
};
});
}
async rmdir(path: string, recursive: boolean = false): Promise<void> {
const normalized = normalizePath(path);
if (normalized === "/") {
throw new Error(`EBUSY: resource busy or locked, rmdir '${path}'`);
}
const db = await this.ensureDB();
// Check if directory exists
const stat = await this.stat(normalized).catch(() => null);
if (!stat) {
throw new Error(`ENOENT: no such file or directory, rmdir '${path}'`);
}
if (stat.type !== "directory") {
throw new Error(`ENOTDIR: not a directory, rmdir '${path}'`);
}
if (recursive) {
// Delete all contents recursively
await this.deleteRecursive(normalized);
} else {
// Check if empty
const contents = await this.readDir(normalized);
if (contents.length > 0) {
throw new Error(`ENOTEMPTY: directory not empty, rmdir '${path}'`);
}
}
// Delete the directory itself
await this.deleteFile(normalized);
}
async rename(oldPath: string, newPath: string): Promise<FileEntry> {
const oldNormalized = normalizePath(oldPath);
const newNormalized = normalizePath(newPath);
const db = await this.ensureDB();
// Get the file/directory
const oldStat = await this.stat(oldNormalized);
// Ensure new parent exists
await this.ensureParentExists(newNormalized);
// Check if new path already exists
if (await this.exists(newNormalized)) {
throw new Error(
`EEXIST: file already exists, rename '${oldPath}' -> '${newPath}'`,
);
}
const now = new Date();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.config.storeName], "readwrite");
const store = transaction.objectStore(this.config.storeName);
// If it's a directory, we need to update all children
if (oldStat.type === "directory") {
// Get all descendants
const request = store.openCursor();
const updates: StoredFile[] = [];
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result;
if (cursor) {
const file = cursor.value as StoredFile;
// Check if this file is under the old path
if (
file.path.startsWith(oldNormalized + "/") ||
file.path === oldNormalized
) {
const newFilePath = file.path.replace(
oldNormalized,
newNormalized,
);
updates.push({
...file,
path: newFilePath,
parent: dirname(newFilePath),
modified: file.path === oldNormalized ? now : file.modified,
});
}
cursor.continue();
} else {
// All files collected, now update them
let completed = 0;
const total = updates.length;
if (total === 0) {
reject(new Error(`Directory not found: ${oldPath}`));
return;
}
updates.forEach((file) => {
// Delete old entry
store.delete(file.path.replace(newNormalized, oldNormalized));
// Add new entry
const addRequest = store.add(file);
addRequest.onsuccess = () => {
completed++;
if (completed === total) {
const entry = updates.find((f) => f.path === newNormalized)!;
const event = {
type: "renamed" as const,
path: newNormalized,
oldPath: oldNormalized,
timestamp: now,
};
this.notifyWatchers(event);
this.broadcastChange(event);
resolve({
path: entry.path,
name: basename(entry.path),
type: entry.type,
size: entry.size,
created: entry.created,
modified: entry.modified,
metadata: entry.metadata,
});
}
};
addRequest.onerror = () => {
reject(
new Error(`Failed to rename directory: ${addRequest.error}`),
);
};
});
}
};
request.onerror = () => {
reject(new Error(`Failed to read files: ${request.error}`));
};
} else {
// It's a file, simpler case
const getRequest = store.get(oldNormalized);
getRequest.onsuccess = () => {
const file = getRequest.result as StoredFile;
if (!file) {
reject(new Error(`File not found: ${oldPath}`));
return;
}
// Delete old entry
store.delete(oldNormalized);
// Create new entry
const newFile: StoredFile = {
...file,
path: newNormalized,
parent: dirname(newNormalized),
modified: now,
};
const addRequest = store.add(newFile);
addRequest.onsuccess = () => {
const entry: FileEntry = {
path: newNormalized,
name: basename(newNormalized),
type: file.type,
size: file.size,
created: file.created,
modified: now,
metadata: file.metadata,
content: file.content,
};
const event = {
type: "renamed" as const,
path: newNormalized,
oldPath: oldNormalized,
timestamp: now,
};
this.notifyWatchers(event);
this.broadcastChange(event);
resolve(entry);
};
addRequest.onerror = () => {
reject(new Error(`Failed to rename file: ${addRequest.error}`));
};
};
getRequest.onerror = () => {
reject(new Error(`Failed to read file: ${getRequest.error}`));
};
}
});
}
async move(sourcePaths: string[], targetPath: string): Promise<void> {
const targetNormalized = normalizePath(targetPath);
// Ensure target is a directory
const targetStat = await this.stat(targetNormalized).catch(() => null);
if (!targetStat || targetStat.type !== "directory") {
throw new Error(`ENOTDIR: not a directory, move to '${targetPath}'`);
}
for (const sourcePath of sourcePaths) {
const name = basename(sourcePath);
const newPath = join(targetNormalized, name);
await this.rename(sourcePath, newPath);
}
}
async copy(sourcePath: string, targetPath: string): Promise<FileEntry> {
const sourceNormalized = normalizePath(sourcePath);
const targetNormalized = normalizePath(targetPath);
// Read source file
const source = await this.readFile(sourceNormalized);
// Write to target
return this.writeFile(targetNormalized, source.content, source.metadata);
}
watch(pattern: string, callback: (event: FSEvent) => void): Disposable {
const id = this.generateWatcherId();
const listener: WatchListener = { id, pattern, callback };
this.watchers.push(listener);
return {
dispose: () => {
const index = this.watchers.findIndex((w) => w.id === id);
if (index !== -1) {
this.watchers.splice(index, 1);
}
},
};
}
async stat(path: string): Promise<FileStat> {
const normalized = normalizePath(path);
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.config.storeName], "readonly");
const store = transaction.objectStore(this.config.storeName);
const request = store.get(normalized);
request.onsuccess = () => {
const file = request.result as StoredFile;
if (!file) {
reject(
new Error(`ENOENT: no such file or directory, stat '${path}'`),
);
return;
}
resolve({
path: file.path,
size: file.size,
type: file.type,
created: file.created,
modified: file.modified,
readonly: false, // Sempre false em IndexedDB
});
};
request.onerror = () => {
reject(new Error(`Failed to stat file: ${request.error}`));
};
});
}
async glob(pattern: string): Promise<string[]> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.config.storeName], "readonly");
const store = transaction.objectStore(this.config.storeName);
const request = store.getAll();
request.onsuccess = () => {
const files = request.result as StoredFile[];
const paths = files
.map((file) => file.path)
.filter((path) => this.matchesPattern(path, pattern))
.sort();
resolve(paths);
};
request.onerror = () => {
reject(new Error(`Failed to glob: ${request.error}`));
};
});
}
async clear(): Promise<void> {
const startTime = Date.now();
const operationId = `clear-${startTime}`;
this.events.emit(FileSystemEvents.OPERATION_START, {
operation: "clear",
id: operationId,
});
this.events.emit(FileSystemEvents.STORAGE_CLEARING, undefined as any);
try {
const db = await this.ensureDB();
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction(
[this.config.storeName],
"readwrite",
);
const store = transaction.objectStore(this.config.storeName);
const request = store.clear();
request.onsuccess = () => {
// Always ensure root directory exists after clear
const rootTransaction = db.transaction(
[this.config.storeName],
"readwrite",
);
const rootStore = rootTransaction.objectStore(this.config.storeName);
const now = new Date();
const rootDir: StoredFile = {
path: "/",
type: "directory",
size: 0,
created: now,
modified: now,
parent: "",
};
const rootRequest = rootStore.add(rootDir);
rootRequest.onsuccess = () => resolve();
rootRequest.onerror = () =>
reject(new Error(`Failed to recreate root: ${rootRequest.error}`));
};
request.onerror = () => {
reject(new Error(`Failed to clear store: ${request.error}`));
};
});
const duration = Date.now() - startTime;
this.events.emit(FileSystemEvents.STORAGE_CLEARED, { duration });
this.events.emit(FileSystemEvents.OPERATION_END, {
operation: "clear",
id: operationId,
duration,
});
} catch (error) {
this.events.emit(FileSystemEvents.OPERATION_ERROR, {
operation: "clear",
error: error as Error,
});
throw error;
}
}
async size(): Promise<number> {
const startTime = Date.now();
const operationId = `size-${startTime}`;
this.events.emit(FileSystemEvents.OPERATION_START, {
operation: "size",
id: operationId,
});
try {
const db = await this.ensureDB();
const totalSize = await new Promise<number>((resolve, reject) => {
const transaction = db.transaction([this.config.storeName], "readonly");
const store = transaction.objectStore(this.config.storeName);
const request = store.getAll();
request.onsuccess = () => {
const files = request.result as StoredFile[];
const size = files.reduce((sum, file) => sum + file.size, 0);
resolve(size);
};
request.onerror = () => {
reject(new Error(`Failed to calculate size: ${request.error}`));
};
});
this.events.emit(FileSystemEvents.STORAGE_SIZE_CALCULATED, {
size: totalSize,
});
this.events.emit(FileSystemEvents.OPERATION_END, {
operation: "size",
id: operationId,
duration: Date.now() - startTime,
});
return totalSize;
} catch (error) {
this.events.emit(FileSystemEvents.OPERATION_ERROR, {
operation: "size",
error: error as Error,
});
throw error;
}
}
// Helper methods
private async calculateHash(data: any): Promise<string> {
// Simple hash for content comparison
const str = typeof data === "string" ? data : JSON.stringify(data);
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return hash.toString(36);
}
private calculateSize(content: any): number {
if (content === null || content === undefined) {
return 0;
}
if (typeof content === "string") {
return new Blob([content]).size;
}
if (content instanceof ArrayBuffer) {
return content.byteLength;
}
if (content instanceof Blob) {
return content.size;
}
if (typeof content === "object") {
return new Blob([JSON.stringify(content)]).size;
}
return 0;
}
private async ensureParentExists(path: string): Promise<void> {
const parent = dirname(path);
if (parent === "/" || parent === path) return;
if (!(await this.exists(parent))) {
throw new Error(`ENOENT: no such file or directory, open '${path}'`);
}
}
private async deleteRecursive(path: string): Promise<void> {
const entries = await this.readDir(path);
for (const entry of entries) {
if (entry.type === "directory") {
await this.deleteRecursive(entry.path);
}
await this.deleteFile(entry.path);
}
}
private matchesPattern(path: string, pattern: string): boolean {
// Handle simple cases
if (pattern === "**" || pattern === "**/*") {
return true;
}
if (pattern === "*") {
// * should only match files in root directory
return path.split("/").length === 2 && path !== "/";
}
// Convert glob pattern to regex
let regex = pattern
// Escape special regex characters except glob ones
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
// Handle ** (matches any number of directories)
.replace(/\*\*/g, "___GLOBSTAR___")
// Handle * (matches any characters except /)
.replace(/\*/g, "[^/]*")
// Handle ? (matches single character except /)
.replace(/\?/g, "[^/]")
// Restore ** handling
.replace(/___GLOBSTAR___\//g, "(.*/)?")
.replace(/\/___GLOBSTAR___/g, "(/.*)?")
.replace(/___GLOBSTAR___/g, ".*");
// Anchor the pattern
regex = "^" + regex + "$";
return new RegExp(regex).test(path);
}
private notifyWatchers(event: FSEvent): void {
for (const watcher of this.watchers) {
if (this.matchesPattern(event.path, watcher.pattern)) {
try {
watcher.callback(event);
} catch (error) {
console.error(
`Error in watch callback for pattern "${watcher.pattern}":`,
error,
);
}
}
}
}
private generateWatcherId(): string {
return `watch_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
private async ensureRootExists(): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(
[this.config.storeName],
"readonly",
);
const store = transaction.objectStore(this.config.storeName);
const request = store.get("/");
return new Promise((resolve) => {
request.onsuccess = async () => {
if (!request.result) {
// Root doesn't exist, create it
const writeTransaction = this.db!.transaction(
[this.config.storeName],
"readwrite",
);
const writeStore = writeTransaction.objectStore(
this.config.storeName,
);
const now = new Date();
await new Promise<void>((res, rej) => {
const addRequest = writeStore.add({
path: "/",
type: "directory",
size: 0,
created: now,
modified: now,
parent: "",
});
addRequest.onsuccess = () => res();
addRequest.onerror = () =>
rej(new Error("Failed to create root directory"));
});
}
resolve();
};
request.onerror = () => resolve(); // Ignore errors, we'll try next time
});
}
private setupBroadcastChannel(): void {
this.broadcastChannel = new BroadcastChannel(`vfs-${this.config.dbName}`);
this.broadcastChannel.onmessage = (event) => {
const { type, path, oldPath, timestamp } = event.data;
// Notify local watchers about external changes
this.notifyWatchers({
type,
path,
oldPath,
timestamp: new Date(timestamp),
});
// Emit events for external changes
switch (type) {
case "created":
if (event.data.isDirectory) {
this.events.emit(FileSystemEvents.DIR_CREATED, { path });
} else {
this.events.emit(FileSystemEvents.FILE_WRITTEN, {
path,
size: event.data.size || 0,
});
}
break;
case "updated":
this.events.emit(FileSystemEvents.FILE_WRITTEN, {
path,
size: event.data.size || 0,
});
break;
case "deleted":
this.events.emit(FileSystemEvents.FILE_DELETED, { path });
break;
case "renamed":
// Emit as both deleted and created since there's no FILE_RENAMED event
if (oldPath) {
this.events.emit(FileSystemEvents.FILE_DELETED, { path: oldPath });
}
this.events.emit(FileSystemEvents.FILE_WRITTEN, {
path,
size: event.data.size || 0,
});
break;
}
};
}
private broadcastChange(
event: FSEvent & {
size?: number;
isDirectory?: boolean;
fileType?: "file" | "directory";
},
): void {
if (this.broadcastChannel) {
this.broadcastChannel.postMessage({
...event,
timestamp: event.timestamp.toISOString(),
});
}
}
async startWatching(pollingIntervalMs: number = 250): Promise<void> {
if (!this.enableExternalSync) return;
// Take initial snapshot
await this.updateFileSnapshots();
// Start polling for changes
this.pollingInterval = setInterval(async () => {
await this.checkForExternalChanges();
}, pollingIntervalMs);
}
async stopWatching(): Promise<void> {
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
}
private async updateFileSnapshots(): Promise<void> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.config.storeName], "readonly");
const store = transaction.objectStore(this.config.storeName);
const request = store.getAll();
request.onsuccess = async () => {
const files = request.result as StoredFile[];
this.fileSnapshots.clear();
for (const file of files) {
// Include both files and directories in snapshots
if (file.path !== "/") {
// Skip root directory
const snapshot: FileSnapshot = {
path: file.path,
type: file.type,
modified: file.modified,
size: file.size,
};
// Calculate content hash if deep detection is enabled
if (this.deepChangeDetection && file.type === "file") {
snapshot.contentHash = await this.calculateHash(file.content);
if (file.metadata) {
snapshot.metadataHash = await this.calculateHash(file.metadata);
}
}
this.fileSnapshots.set(file.path, snapshot);
}
}
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to update snapshots: ${request.error}`));
};
});
}
private async checkForExternalChanges(): Promise<void> {
const db = await this.ensureDB();
const currentFiles = new Map<string, FileSnapshot>();
const filesToDeepCheck: string[] = [];
return new Promise((resolve) => {
const transaction = db.transaction([this.config.storeName], "readonly");
const store = transaction.objectStore(this.config.storeName);
const request = store.getAll();
request.onsuccess = async () => {
const files = request.result as StoredFile[];
// Build current state map - include both files and directories
files.forEach((file) => {
if (file.path !== "/") {
// Skip root directory
currentFiles.set(file.path, {
path: file.path,
type: file.type,
modified: file.modified,
size: file.size,
});
}
});
// Phase 1: Quick check (date/size)
currentFiles.forEach((current, path) => {
const snapshot = this.fileSnapshots.get(path);
if (!snapshot) {
// New file or directory
this.notifyWatchers({
type: "created",
path,
timestamp: new Date(),
});
// Also emit proper events
if (current.type === "directory") {
this.events.emit(FileSystemEvents.DIR_CREATED, { path });
} else {
this.events.emit(FileSystemEvents.FILE_WRITTEN, {
path,
size: current.size,
});
}
} else if (
current.modified.getTime() !== snapshot.modified.getTime() ||
current.size !== snapshot.size
) {
// Modified file - detected by date/size
this.notifyWatchers({
type: "updated",
path,
timestamp: new Date(),
});
if (current.type === "file") {
this.events.emit(FileSystemEvents.FILE_WRITTEN, {
path,
size: current.size,
});
}
} else if (this.deepChangeDetection && current.type === "file") {
// Mark for deep check
filesToDeepCheck.push(path);
}
});
// Check for deleted files/directories
this.fileSnapshots.forEach((snapshot, path) => {
if (!currentFiles.has(path)) {
this.notifyWatchers({
type: "deleted",
path,
timestamp: new Date(),
});
// Also emit proper events
if (snapshot.type === "directory") {
this.events.emit(FileSystemEvents.DIR_DELETED, { path });
} else {
this.events.emit(FileSystemEvents.FILE_DELETED, { path });
}
}
});
// Phase 2: Deep check (content) - only if enabled and needed
if (filesToDeepCheck.length > 0) {
await this.performDeepContentCheck(filesToDeepCheck, files);
}
// Update snapshots with content hashes if deep check is enabled
if (this.deepChangeDetection) {
for (const file of files) {
if (file.type === "file" && file.path !== "/") {
const snapshot = currentFiles.get(file.path);
if (snapshot) {
snapshot.contentHash = await this.calculateHash(file.content);
if (file.metadata) {
snapshot.metadataHash = await this.calculateHash(
file.metadata,
);
}
}
}
}
}
// Update snapshots
this.fileSnapshots = currentFiles;
resolve();
};
request.onerror = () => resolve();
});
}
private async performDeepContentCheck(
paths: string[],
allFiles: StoredFile[],
): Promise<void> {
for (const path of paths) {
const file = allFiles.find((f) => f.path === path);
const snapshot = this.fileSnapshots.get(path);
if (file && snapshot) {
const currentContentHash = await this.calculateHash(file.content);
const currentMetadataHash = file.metadata
? await this.calculateHash(file.metadata)
: undefined;
const contentChanged =
snapshot.contentHash && snapshot.contentHash !== currentContentHash;
const metadataChanged =
snapshot.metadataHash &&
currentMetadataHash &&
snapshot.metadataHash !== currentMetadataHash;
if (contentChanged || metadataChanged) {
// Content or metadata changed even though date/size didn't
this.notifyWatchers({
type: "updated",
path,
timestamp: new Date(),
});
this.events.emit(FileSystemEvents.FILE_WRITTEN, {
path,
size: file.size,
});
}
}
}
}
dispose(): void {
this.stopWatching();
if (this.broadcastChannel) {
this.broadcastChannel.close();
this.broadcastChannel = null;
}
if (this.db) {
this.db.close();
this.db = null;
}
this.watchers = [];
this.fileSnapshots.clear();
}
// Sobrescrever métodos de permissão (sempre permitido em IndexedDB)
async canModify(path: string): Promise<boolean> {
return true; // Sempre permitido em IndexedDB
}
async canCreateIn(parentPath: string): Promise<boolean> {
try {
const stat = await this.stat(parentPath);
return stat.type === "directory";
} catch {
// Se não existe, verifica se é o root
return normalizePath(parentPath) === "/";
}
}
// Não sobrescrever writeFileAtomic - usar implementação base
// IndexedDB não tem rename verdadeiramente atômico
}