The COW version-control surface (fork, branches, checkout, commit,
getHistory/streamHistory, asOfCommit, brain.versions) is gone, along with
its subsystems: src/versioning/, the COW object store (CommitLog,
CommitObject, RefManager, TreeObject), HistoricalStorageAdapter, and the
TypeAwareHNSWIndex path it kept alive. The Db API (now/transact/asOf/
with/persist/restore) is the one versioning model in 8.0.
Survivors and replacements:
- BlobStorage survives (the VFS stores file content through it), relocated
to src/storage/blobStorage.ts with binaryDataCodec.ts; its adapter
interface is now BlobStoreAdapter, slimmed to the consumed surface
(write/read/has/delete/getMetadata + MIME-aware compression policy).
- brain.migrate() backup branches are replaced by persist-before-migrate:
MigrateOptions.backupTo persists a hard-link snapshot of the current
generation before any transform runs; MigrationResult.backupPath reports
it, and brain.restore(path) brings it back wholesale.
- CLI: cow.ts (fork/branch/checkout/history/migrate) is replaced by
snapshot.ts — snapshot <path>, restore <path>, history (tx-log),
generation.
- New public read API: brain.transactionLog({limit}) exposes the reified
tx-log (generation/timestamp/meta, newest first) that backs the CLI
history command; TxLogEntry is exported.
Tests: superseded suites deleted; fork/commit blocks excised from shared
suites; BlobStorage tests relocated + reworked against the slimmed store;
migration tests now prove the backupTo snapshot/restore round trip; new
transactionLog coverage in db-mvcc.
2855 lines
No EOL
87 KiB
TypeScript
2855 lines
No EOL
87 KiB
TypeScript
/**
|
||
* Virtual Filesystem Implementation
|
||
*
|
||
* PRODUCTION-READY VFS built on Brainy
|
||
* Real code, no mocks, actual working implementation
|
||
*/
|
||
|
||
import { Readable, Writable } from 'stream'
|
||
import crypto from 'crypto'
|
||
import { v4 as uuidv4 } from '../universal/uuid.js'
|
||
import { Brainy } from '../brainy.js'
|
||
import { Entity, AddParams, RelateParams, FindParams, Relation } from '../types/brainy.types.js'
|
||
import { NounType, VerbType } from '../types/graphTypes.js'
|
||
import { PathResolver } from './PathResolver.js'
|
||
import { mimeDetector } from './MimeTypeDetector.js'
|
||
import {
|
||
SemanticPathResolver,
|
||
ProjectionRegistry,
|
||
ConceptProjection,
|
||
AuthorProjection,
|
||
TemporalProjection,
|
||
RelationshipProjection,
|
||
SimilarityProjection,
|
||
TagProjection
|
||
} from './semantic/index.js'
|
||
// Knowledge Layer can remain as optional augmentation for now
|
||
import {
|
||
IVirtualFileSystem,
|
||
VFSConfig,
|
||
VFSEntity,
|
||
VFSMetadata,
|
||
VFSStats,
|
||
VFSDirent,
|
||
VFSTodo,
|
||
VFSError,
|
||
VFSErrorCode,
|
||
WriteOptions,
|
||
ReadOptions,
|
||
MkdirOptions,
|
||
ReaddirOptions,
|
||
CopyOptions,
|
||
SearchOptions,
|
||
SearchResult,
|
||
SimilarOptions,
|
||
RelatedOptions,
|
||
ReadStreamOptions,
|
||
WriteStreamOptions,
|
||
WatchListener
|
||
} from './types.js'
|
||
|
||
/**
|
||
* Main Virtual Filesystem Implementation
|
||
*
|
||
* This is REAL, production-ready code that:
|
||
* - Maps filesystem operations to Brainy entities
|
||
* - Uses graph relationships for directory structure
|
||
* - Provides semantic search and AI features
|
||
* - Scales to millions of files
|
||
*/
|
||
export class VirtualFileSystem implements IVirtualFileSystem {
|
||
private brain: Brainy
|
||
private pathResolver!: SemanticPathResolver
|
||
private projectionRegistry!: ProjectionRegistry
|
||
private config: Required<Omit<VFSConfig, 'rootEntityId'>> & { rootEntityId?: string }
|
||
private rootEntityId?: string
|
||
private initialized = false
|
||
private currentUser: string = 'system' // Track current user for collaboration
|
||
|
||
// Knowledge Layer features available via augmentation (brain.use('knowledge'))
|
||
|
||
// Caches for performance
|
||
private contentCache: Map<string, { data: Buffer, timestamp: number }>
|
||
private statCache: Map<string, { stats: VFSStats, timestamp: number }>
|
||
|
||
// Watch system
|
||
private watchers: Map<string, Set<WatchListener>>
|
||
|
||
// Background task timer
|
||
private backgroundTimer: NodeJS.Timeout | null = null
|
||
|
||
// Mutex for preventing race conditions in directory creation
|
||
private mkdirLocks: Map<string, Promise<void>> = new Map()
|
||
|
||
// Singleton promise for root initialization (prevents duplicate roots)
|
||
private rootInitPromise: Promise<string> | null = null
|
||
|
||
// Fixed VFS root ID (prevents duplicates across instances)
|
||
// Uses deterministic UUID format for storage compatibility
|
||
private static readonly VFS_ROOT_ID = '00000000-0000-0000-0000-000000000000'
|
||
|
||
constructor(brain?: Brainy) {
|
||
this.brain = brain || new Brainy()
|
||
this.contentCache = new Map()
|
||
this.statCache = new Map()
|
||
this.watchers = new Map()
|
||
|
||
// Default configuration (will be overridden in init)
|
||
this.config = this.getDefaultConfig()
|
||
}
|
||
|
||
/**
|
||
* Access to BlobStorage for unified file storage
|
||
*/
|
||
private get blobStorage() {
|
||
// TypeScript doesn't know about blobStorage on storage, use type assertion
|
||
const storage = this.brain['storage'] as any
|
||
if (!storage?.blobStorage) {
|
||
throw new Error(
|
||
'BlobStorage not available. The storage adapter must run ' +
|
||
'initializeBlobStorage() before the VFS is used (brain.init() does this).'
|
||
)
|
||
}
|
||
return storage.blobStorage
|
||
}
|
||
|
||
/**
|
||
* Initialize the VFS
|
||
*/
|
||
async init(config?: VFSConfig): Promise<void> {
|
||
if (this.initialized) return
|
||
|
||
// Merge config with defaults
|
||
this.config = { ...this.getDefaultConfig(), ...config }
|
||
|
||
// VFS is now auto-initialized during brain.init()
|
||
// Brain is guaranteed to be initialized when this is called
|
||
// Removed brain.init() check to prevent infinite recursion
|
||
|
||
// Create or find root entity
|
||
this.rootEntityId = await this.initializeRoot()
|
||
|
||
// Clean up old UUID-based roots (one-time migration)
|
||
await this.cleanupOldRoots()
|
||
|
||
// Initialize projection registry with auto-discovery of built-in projections
|
||
this.projectionRegistry = new ProjectionRegistry()
|
||
this.registerBuiltInProjections()
|
||
|
||
// Initialize semantic path resolver (zero-config, uses brain.config)
|
||
this.pathResolver = new SemanticPathResolver(
|
||
this.brain,
|
||
this, // Pass VFS instance for resolvePath
|
||
this.rootEntityId,
|
||
this.projectionRegistry
|
||
)
|
||
|
||
// Knowledge Layer is now a separate augmentation
|
||
// Enable with: brain.use('knowledge')
|
||
|
||
// Start background tasks
|
||
this.startBackgroundTasks()
|
||
|
||
this.initialized = true
|
||
}
|
||
|
||
/**
|
||
* Create or find the root directory entity
|
||
*/
|
||
/**
|
||
* Auto-register built-in projection strategies
|
||
* Zero-config: All semantic dimensions work out of the box
|
||
*/
|
||
private registerBuiltInProjections(): void {
|
||
const projections = [
|
||
ConceptProjection,
|
||
AuthorProjection,
|
||
TemporalProjection,
|
||
RelationshipProjection,
|
||
SimilarityProjection,
|
||
TagProjection
|
||
]
|
||
|
||
for (const ProjectionClass of projections) {
|
||
try {
|
||
this.projectionRegistry.register(new ProjectionClass())
|
||
} catch (err) {
|
||
// Silently skip if already registered (e.g., in tests)
|
||
if (!(err instanceof Error && err.message.includes('already registered'))) {
|
||
throw err
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* CRITICAL FIX - Prevent duplicate root creation
|
||
* Uses singleton promise pattern to ensure only ONE root initialization
|
||
* happens even with concurrent init() calls
|
||
*/
|
||
private async initializeRoot(): Promise<string> {
|
||
// If initialization already in progress, wait for it (automatic mutex)
|
||
if (this.rootInitPromise) {
|
||
return await this.rootInitPromise
|
||
}
|
||
|
||
// Start initialization and cache the promise
|
||
this.rootInitPromise = this.doInitializeRoot()
|
||
|
||
try {
|
||
const rootId = await this.rootInitPromise
|
||
return rootId
|
||
} catch (error) {
|
||
// On error, clear promise so retry is possible
|
||
this.rootInitPromise = null
|
||
throw error
|
||
}
|
||
// NOTE: On success, we intentionally keep the promise cached
|
||
// This prevents re-initialization and serves as a cache
|
||
}
|
||
|
||
/**
|
||
* Atomic root initialization with fixed ID
|
||
* Uses deterministic ID to prevent duplicates across all VFS instances
|
||
*
|
||
* ARCHITECTURAL FIX: Instead of query-then-create (race condition),
|
||
* we use a fixed ID so storage-level uniqueness prevents duplicates.
|
||
*/
|
||
private async doInitializeRoot(): Promise<string> {
|
||
const rootId = VirtualFileSystem.VFS_ROOT_ID
|
||
|
||
// Try to get existing root by fixed ID (O(1) lookup, not query)
|
||
try {
|
||
const existingRoot = await this.brain.get(rootId)
|
||
|
||
if (existingRoot) {
|
||
// Root exists - verify metadata is correct
|
||
const metadata = (existingRoot as any).metadata || existingRoot
|
||
|
||
if (!metadata.vfsType || metadata.vfsType !== 'directory') {
|
||
console.warn('⚠️ VFS: Root metadata incomplete, repairing...')
|
||
await this.brain.update({
|
||
id: rootId,
|
||
metadata: this.getRootMetadata()
|
||
})
|
||
}
|
||
|
||
return rootId
|
||
}
|
||
} catch (error) {
|
||
// Root doesn't exist yet - proceed to creation
|
||
}
|
||
|
||
// Create root with fixed ID (idempotent - fails gracefully if exists)
|
||
try {
|
||
console.log('VFS: Creating root directory (fixed ID: 00000000-0000-0000-0000-000000000000)')
|
||
|
||
await this.brain.add({
|
||
id: rootId, // Fixed ID - storage ensures uniqueness
|
||
data: '/',
|
||
type: NounType.Collection,
|
||
subtype: 'vfs-root', // Standard subtype for the VFS root collection (7.30+)
|
||
metadata: this.getRootMetadata()
|
||
})
|
||
|
||
return rootId
|
||
} catch (error: any) {
|
||
// If creation failed due to duplicate ID, another instance created it
|
||
// This is normal in concurrent scenarios - just return the fixed ID
|
||
const errorMsg = error?.message?.toLowerCase() || ''
|
||
if (errorMsg.includes('already exists') ||
|
||
errorMsg.includes('duplicate') ||
|
||
errorMsg.includes('eexist')) {
|
||
console.log('VFS: Root already created by another instance, using existing')
|
||
return rootId
|
||
}
|
||
|
||
// Unexpected error
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get standard root metadata
|
||
* Centralized to ensure consistency
|
||
*/
|
||
private getRootMetadata(): VFSMetadata {
|
||
return {
|
||
path: '/',
|
||
name: '',
|
||
vfsType: 'directory',
|
||
isVFS: true,
|
||
isVFSEntity: true,
|
||
size: 0,
|
||
permissions: 0o755,
|
||
owner: 'root',
|
||
group: 'root',
|
||
accessed: Date.now(),
|
||
modified: Date.now()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cleanup old UUID-based VFS roots
|
||
* Called during init to remove duplicate roots created before fixed-ID fix
|
||
*
|
||
* This is a one-time migration helper that can be removed in future versions.
|
||
*/
|
||
private async cleanupOldRoots(): Promise<void> {
|
||
try {
|
||
// Find any old VFS roots with UUID-based IDs (not our fixed ID)
|
||
const oldRoots = await this.brain.find({
|
||
type: NounType.Collection,
|
||
where: {
|
||
path: '/',
|
||
vfsType: 'directory'
|
||
},
|
||
limit: 100,
|
||
excludeVFS: false
|
||
})
|
||
|
||
// Filter out our fixed-ID root
|
||
const duplicates = oldRoots.filter(r => r.id !== VirtualFileSystem.VFS_ROOT_ID)
|
||
|
||
if (duplicates.length > 0) {
|
||
console.log(`VFS: Found ${duplicates.length} old UUID-based root(s), cleaning up...`)
|
||
|
||
for (const duplicate of duplicates) {
|
||
try {
|
||
await this.brain.delete(duplicate.id)
|
||
console.log(`VFS: Deleted old root ${duplicate.id.substring(0, 8)}`)
|
||
} catch (error) {
|
||
console.warn(`VFS: Failed to delete old root ${duplicate.id}:`, error)
|
||
}
|
||
}
|
||
|
||
console.log('VFS: Cleanup complete - all old roots removed')
|
||
}
|
||
} catch (error) {
|
||
// Non-critical error - log and continue
|
||
console.warn('VFS: Cleanup of old roots failed (non-critical):', error)
|
||
}
|
||
}
|
||
|
||
// ============= File Operations =============
|
||
|
||
/**
|
||
* Read a file's content
|
||
*/
|
||
async readFile(path: string, options?: ReadOptions): Promise<Buffer> {
|
||
await this.ensureInitialized()
|
||
|
||
// Check cache first
|
||
if (options?.cache !== false && this.contentCache.has(path)) {
|
||
const cached = this.contentCache.get(path)!
|
||
if (Date.now() - cached.timestamp < (this.config.cache?.ttl || 300000)) {
|
||
return cached.data
|
||
}
|
||
}
|
||
|
||
// Resolve path to entity
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
// Verify it's a file
|
||
if (entity.metadata.vfsType !== 'file') {
|
||
throw new VFSError(VFSErrorCode.EISDIR, `Is a directory: ${path}`, path, 'readFile')
|
||
}
|
||
|
||
// Unified blob storage - ONE path only
|
||
if (!entity.metadata.storage?.type || entity.metadata.storage.type !== 'blob') {
|
||
throw new VFSError(
|
||
VFSErrorCode.EIO,
|
||
`File has no blob storage: ${path}. Requires blob storage format.`,
|
||
path,
|
||
'readFile'
|
||
)
|
||
}
|
||
|
||
// CRITICAL FIX - Isolate blob errors from VFS tree corruption
|
||
// Blob read errors MUST NOT cascade to VFS tree structure
|
||
try {
|
||
// Read from BlobStorage (handles decompression automatically)
|
||
const content = await this.blobStorage.read(entity.metadata.storage.hash)
|
||
|
||
// REMOVED updateAccessTime() for performance
|
||
// Access time updates caused 50-100ms GCS write on EVERY file read
|
||
// Modern file systems use 'noatime' for same reason (performance)
|
||
// Field 'accessed' still exists in metadata for backward compat but won't update
|
||
// await this.updateAccessTime(entityId) // ← REMOVED
|
||
|
||
// Cache the content
|
||
if (options?.cache !== false) {
|
||
this.contentCache.set(path, { data: content, timestamp: Date.now() })
|
||
}
|
||
|
||
// Apply encoding if requested
|
||
if (options?.encoding) {
|
||
return Buffer.from(content.toString(options.encoding))
|
||
}
|
||
|
||
return content
|
||
} catch (blobError) {
|
||
// Blob error isolated - VFS tree structure remains intact
|
||
const errorMsg = blobError instanceof Error ? blobError.message : String(blobError)
|
||
|
||
console.error(`VFS: Cannot read blob for ${path}:`, errorMsg)
|
||
|
||
// Throw VFSError (not blob error) - prevents cascading corruption
|
||
throw new VFSError(
|
||
VFSErrorCode.EIO,
|
||
`File read failed: ${errorMsg}`,
|
||
path,
|
||
'readFile'
|
||
)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Write a file
|
||
*/
|
||
async writeFile(path: string, data: Buffer | string, options?: WriteOptions): Promise<void> {
|
||
await this.ensureInitialized()
|
||
|
||
// Convert string to buffer
|
||
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, options?.encoding)
|
||
|
||
// Check size limits
|
||
if (this.config.limits?.maxFileSize && buffer.length > this.config.limits.maxFileSize) {
|
||
throw new VFSError(VFSErrorCode.ENOSPC, `File too large: ${buffer.length} bytes`, path, 'writeFile')
|
||
}
|
||
|
||
// Parse path to get parent and name
|
||
const parentPath = this.getParentPath(path)
|
||
const name = this.getBasename(path)
|
||
|
||
// Ensure parent directory exists
|
||
const parentId = await this.ensureDirectory(parentPath)
|
||
|
||
// Check if file already exists
|
||
let existingId: string | null = null
|
||
try {
|
||
existingId = await this.pathResolver.resolve(path, { cache: false })
|
||
// Verify the entity still exists in the brain
|
||
const existing = await this.brain.get(existingId)
|
||
if (!existing) {
|
||
existingId = null // Entity was deleted but cache wasn't cleared
|
||
}
|
||
} catch (err) {
|
||
// File doesn't exist, which is fine
|
||
existingId = null
|
||
}
|
||
|
||
// Detect MIME type BEFORE the blob write so the store's auto-compression
|
||
// policy can skip zstd for already-compressed media (JPEG, MP4, ZIP, …).
|
||
const mimeType = mimeDetector.detectMimeType(name, buffer)
|
||
|
||
// Unified blob storage for ALL files (no size-based branching)
|
||
// Store in BlobStorage (content-addressable, auto-deduplication)
|
||
const blobHash = await this.blobStorage.write(buffer, { mimeType })
|
||
|
||
// Get blob metadata (size, compression info)
|
||
const blobMetadata = await this.blobStorage.getMetadata(blobHash)
|
||
|
||
const storageStrategy: VFSMetadata['storage'] = {
|
||
type: 'blob',
|
||
hash: blobHash,
|
||
size: buffer.length,
|
||
compressed: blobMetadata ? blobMetadata.compression !== 'none' : undefined
|
||
}
|
||
|
||
// Create metadata
|
||
const metadata: VFSMetadata = {
|
||
path,
|
||
name,
|
||
parent: parentId,
|
||
vfsType: 'file',
|
||
isVFS: true, // Mark as VFS entity (internal)
|
||
isVFSEntity: true, // Explicit flag for developer filtering
|
||
size: buffer.length,
|
||
mimeType,
|
||
extension: this.getExtension(name),
|
||
permissions: options?.mode || this.config.permissions?.defaultFile || 0o644,
|
||
owner: 'user', // In production, get from auth context
|
||
group: 'users',
|
||
accessed: Date.now(),
|
||
modified: Date.now(),
|
||
storage: storageStrategy
|
||
// No rawData - content is in BlobStorage
|
||
// Backward compatibility: readFile() checks for rawData for legacy files
|
||
}
|
||
|
||
// Extract additional metadata if enabled
|
||
if (this.config.intelligence?.autoExtract && options?.extractMetadata !== false) {
|
||
Object.assign(metadata, await this.extractMetadata(buffer, mimeType))
|
||
}
|
||
|
||
if (existingId) {
|
||
// Update existing file
|
||
// No entity.data - content is in BlobStorage
|
||
await this.brain.update({
|
||
id: existingId,
|
||
metadata
|
||
})
|
||
|
||
// Ensure Contains relationship exists (fix for missing relationships)
|
||
const existingRelations = await this.brain.getRelations({
|
||
from: parentId,
|
||
to: existingId,
|
||
type: VerbType.Contains
|
||
})
|
||
|
||
// Create relationship if it doesn't exist
|
||
if (existingRelations.length === 0) {
|
||
await this.brain.relate({
|
||
from: parentId,
|
||
to: existingId,
|
||
type: VerbType.Contains,
|
||
subtype: 'vfs-contains', // Standard subtype for VFS containment edges (7.30+)
|
||
metadata: { isVFS: true } // Mark as VFS relationship
|
||
})
|
||
}
|
||
} else {
|
||
// Create new file entity
|
||
// For embedding: use text content, for storage: use raw data
|
||
const embeddingData = mimeDetector.isTextFile(mimeType) ? buffer.toString('utf-8') : `File: ${name} (${mimeType}, ${buffer.length} bytes)`
|
||
|
||
const entity = await this.brain.add({
|
||
data: embeddingData, // Always provide string for embeddings
|
||
type: this.getFileNounType(mimeType),
|
||
subtype: 'vfs-file', // Standard subtype for VFS file entities (7.30+)
|
||
metadata
|
||
})
|
||
|
||
// Create parent-child relationship (no need to check for duplicates on new entities)
|
||
await this.brain.relate({
|
||
from: parentId,
|
||
to: entity,
|
||
type: VerbType.Contains,
|
||
subtype: 'vfs-contains', // Standard subtype for VFS containment edges (7.30+)
|
||
metadata: { isVFS: true } // Mark as VFS relationship
|
||
})
|
||
|
||
// Update path resolver cache
|
||
await this.pathResolver.createPath(path, entity)
|
||
}
|
||
|
||
// Invalidate caches
|
||
this.invalidateCaches(path)
|
||
|
||
// Trigger watchers
|
||
this.triggerWatchers(path, existingId ? 'change' : 'rename')
|
||
|
||
// Knowledge Layer hooks will be added by augmentation if enabled
|
||
|
||
// Knowledge Layer hooks will be added by augmentation if enabled
|
||
}
|
||
|
||
/**
|
||
* Append to a file
|
||
*/
|
||
async appendFile(path: string, data: Buffer | string, options?: WriteOptions): Promise<void> {
|
||
await this.ensureInitialized()
|
||
|
||
// Read existing content
|
||
let existing: Buffer
|
||
try {
|
||
existing = await this.readFile(path)
|
||
} catch (err) {
|
||
// File doesn't exist, create it
|
||
return this.writeFile(path, data, options)
|
||
}
|
||
|
||
// Append new data
|
||
const newData = Buffer.isBuffer(data) ? data : Buffer.from(data, options?.encoding)
|
||
const combined = Buffer.concat([existing, newData])
|
||
|
||
// Write combined content
|
||
await this.writeFile(path, combined, options)
|
||
}
|
||
|
||
/**
|
||
* Delete a file
|
||
*/
|
||
async unlink(path: string): Promise<void> {
|
||
await this.ensureInitialized()
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
// Verify it's a file
|
||
if (entity.metadata.vfsType !== 'file') {
|
||
throw new VFSError(VFSErrorCode.EISDIR, `Is a directory: ${path}`, path, 'unlink')
|
||
}
|
||
|
||
// Delete blob from BlobStorage (decrements ref count)
|
||
if (entity.metadata.storage?.type === 'blob') {
|
||
await this.blobStorage.delete(entity.metadata.storage.hash)
|
||
}
|
||
|
||
// Delete the entity
|
||
await this.brain.delete(entityId)
|
||
|
||
// Invalidate caches
|
||
this.pathResolver.invalidatePath(path)
|
||
this.invalidateCaches(path)
|
||
|
||
// Trigger watchers
|
||
this.triggerWatchers(path, 'rename')
|
||
|
||
// Knowledge Layer hooks will be added by augmentation if enabled
|
||
}
|
||
|
||
// ============= Tree Operations (NEW) =============
|
||
|
||
/**
|
||
* Get only direct children of a directory - guaranteed no self-inclusion
|
||
* This is the SAFE way to get children for building tree UIs
|
||
*/
|
||
async getDirectChildren(path: string): Promise<VFSEntity[]> {
|
||
await this.ensureInitialized()
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
// Verify it's a directory
|
||
if (entity.metadata.vfsType !== 'directory') {
|
||
throw new VFSError(VFSErrorCode.ENOTDIR, `Not a directory: ${path}`, path, 'getDirectChildren')
|
||
}
|
||
|
||
// Use the safe getChildren from PathResolver
|
||
const children = await this.pathResolver.getChildren(entityId)
|
||
|
||
// Double-check no self-inclusion (paranoid safety)
|
||
return children.filter(child => child.metadata.path !== path)
|
||
}
|
||
|
||
/**
|
||
* Gather descendants using graph traversal + bulk fetch
|
||
*
|
||
* ARCHITECTURE:
|
||
* 1. Traverse graph to collect entity IDs (in-memory, fast)
|
||
* 2. Batch-fetch all entities in ONE storage call
|
||
* 3. Return flat list of VFSEntity objects
|
||
*
|
||
* This is the ONLY correct approach:
|
||
* - Uses GraphAdjacencyIndex (in-memory graph) to traverse relationships
|
||
* - Makes ONE storage call to fetch all entities (not N calls)
|
||
* - Respects maxDepth to limit scope (billion-scale safe)
|
||
*
|
||
* Performance (GCS):
|
||
* - OLD: 111 directories × 50ms each = 5,550ms
|
||
* - NEW: Graph traversal (1ms) + 1 batch fetch (100ms) = 101ms
|
||
* - 55x faster on cloud storage
|
||
*
|
||
* @param rootId - Root directory entity ID
|
||
* @param maxDepth - Maximum depth to traverse
|
||
* @returns All descendant entities (flat list)
|
||
*/
|
||
private async gatherDescendants(rootId: string, maxDepth: number): Promise<VFSEntity[]> {
|
||
const entityIds = new Set<string>()
|
||
const visited = new Set<string>([rootId])
|
||
let currentLevel = [rootId]
|
||
let depth = 0
|
||
|
||
// Phase 1: Traverse graph in-memory to collect all entity IDs
|
||
// GraphAdjacencyIndex is in-memory LSM-tree, so this is fast (<10ms for 10k relationships)
|
||
while (currentLevel.length > 0 && depth < maxDepth) {
|
||
const nextLevel: string[] = []
|
||
|
||
// Get all Contains relationships for this level (in-memory query)
|
||
for (const parentId of currentLevel) {
|
||
const relations = await this.brain.getRelations({
|
||
from: parentId,
|
||
type: VerbType.Contains
|
||
})
|
||
|
||
// Collect child IDs
|
||
for (const rel of relations) {
|
||
if (!visited.has(rel.to)) {
|
||
visited.add(rel.to)
|
||
entityIds.add(rel.to)
|
||
nextLevel.push(rel.to) // Queue for next level
|
||
}
|
||
}
|
||
}
|
||
|
||
currentLevel = nextLevel
|
||
depth++
|
||
}
|
||
|
||
// Phase 2: Batch-fetch all entities in ONE storage call
|
||
// This is the optimization: ONE GCS call instead of 111+ GCS calls
|
||
const entityIdArray = Array.from(entityIds)
|
||
if (entityIdArray.length === 0) {
|
||
return []
|
||
}
|
||
|
||
const entitiesMap = await this.brain.batchGet(entityIdArray)
|
||
|
||
// Convert to VFSEntity array
|
||
const entities: VFSEntity[] = []
|
||
for (const id of entityIdArray) {
|
||
const entity = entitiesMap.get(id)
|
||
if (entity && entity.metadata?.vfsType) {
|
||
entities.push(entity as VFSEntity)
|
||
}
|
||
}
|
||
|
||
return entities
|
||
}
|
||
|
||
/**
|
||
* Get a properly structured tree for the given path
|
||
*
|
||
* Graph traversal + ONE batch fetch (55x faster on cloud storage)
|
||
*
|
||
* Architecture:
|
||
* 1. Resolve path to entity ID
|
||
* 2. Traverse graph in-memory to collect all descendant IDs
|
||
* 3. Batch-fetch all entities in ONE storage call
|
||
* 4. Build tree structure
|
||
*
|
||
* Performance:
|
||
* - GCS: 5,300ms → ~100ms (53x faster)
|
||
* - FileSystem: 200ms → ~50ms (4x faster)
|
||
*/
|
||
async getTreeStructure(path: string, options?: {
|
||
maxDepth?: number
|
||
includeHidden?: boolean
|
||
sort?: 'name' | 'modified' | 'size'
|
||
}): Promise<any> {
|
||
await this.ensureInitialized()
|
||
const { VFSTreeUtils } = await import('./TreeUtils.js')
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
if (entity.metadata.vfsType !== 'directory') {
|
||
throw new VFSError(VFSErrorCode.ENOTDIR, `Not a directory: ${path}`, path, 'getTreeStructure')
|
||
}
|
||
|
||
const maxDepth = options?.maxDepth ?? 10
|
||
|
||
// Gather all descendants (graph traversal + ONE batch fetch)
|
||
const allEntities = await this.gatherDescendants(entityId, maxDepth)
|
||
|
||
// Build tree structure
|
||
return VFSTreeUtils.buildTree(allEntities, path, options || {})
|
||
}
|
||
|
||
/**
|
||
* Get all descendants of a directory (flat list)
|
||
*
|
||
* Same optimization as getTreeStructure
|
||
*/
|
||
async getDescendants(path: string, options?: {
|
||
includeAncestor?: boolean
|
||
type?: 'file' | 'directory'
|
||
}): Promise<VFSEntity[]> {
|
||
await this.ensureInitialized()
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
if (entity.metadata.vfsType !== 'directory') {
|
||
throw new VFSError(VFSErrorCode.ENOTDIR, `Not a directory: ${path}`, path, 'getDescendants')
|
||
}
|
||
|
||
// Gather all descendants (no depth limit for this API)
|
||
const descendants = await this.gatherDescendants(entityId, Infinity)
|
||
|
||
// Filter by type if specified
|
||
const filtered = options?.type
|
||
? descendants.filter(d => d.metadata.vfsType === options.type)
|
||
: descendants
|
||
|
||
// Include ancestor if requested
|
||
if (options?.includeAncestor) {
|
||
return [entity, ...filtered]
|
||
}
|
||
|
||
return filtered
|
||
}
|
||
|
||
/**
|
||
* Inspect a path and return structured information
|
||
* This is the recommended method for file explorers to use
|
||
*/
|
||
async inspect(path: string): Promise<{
|
||
node: VFSEntity
|
||
children: VFSEntity[]
|
||
parent: VFSEntity | null
|
||
stats: VFSStats
|
||
}> {
|
||
await this.ensureInitialized()
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
const stats = await this.stat(path)
|
||
|
||
let children: VFSEntity[] = []
|
||
if (entity.metadata.vfsType === 'directory') {
|
||
children = await this.getDirectChildren(path)
|
||
}
|
||
|
||
let parent: VFSEntity | null = null
|
||
if (path !== '/') {
|
||
const parentPath = path.substring(0, path.lastIndexOf('/')) || '/'
|
||
const parentId = await this.pathResolver.resolve(parentPath)
|
||
parent = await this.getEntityById(parentId)
|
||
}
|
||
|
||
return {
|
||
node: entity,
|
||
children,
|
||
parent,
|
||
stats
|
||
}
|
||
}
|
||
|
||
// ============= Directory Operations =============
|
||
|
||
/**
|
||
* Create a directory
|
||
*/
|
||
async mkdir(path: string, options?: MkdirOptions): Promise<void> {
|
||
await this.ensureInitialized()
|
||
|
||
// Use mutex to prevent race conditions when creating the same directory concurrently
|
||
// If another call is already creating this directory, wait for it to complete
|
||
const existingLock = this.mkdirLocks.get(path)
|
||
if (existingLock) {
|
||
await existingLock
|
||
// After waiting, check if directory now exists
|
||
try {
|
||
const existing = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(existing)
|
||
if (entity.metadata.vfsType === 'directory') {
|
||
return // Directory was created by the other call
|
||
}
|
||
} catch (err) {
|
||
// Still doesn't exist, proceed to create
|
||
}
|
||
}
|
||
|
||
// Create a lock promise for this path
|
||
let resolveLock: () => void
|
||
const lockPromise = new Promise<void>(resolve => { resolveLock = resolve })
|
||
this.mkdirLocks.set(path, lockPromise)
|
||
|
||
try {
|
||
// Check if already exists
|
||
try {
|
||
const existing = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(existing)
|
||
if (entity.metadata.vfsType === 'directory') {
|
||
if (!options?.recursive) {
|
||
throw new VFSError(VFSErrorCode.EEXIST, `Directory exists: ${path}`, path, 'mkdir')
|
||
}
|
||
return // Already exists and recursive is true
|
||
} else {
|
||
// Path exists but it's not a directory
|
||
throw new VFSError(VFSErrorCode.EEXIST, `File exists: ${path}`, path, 'mkdir')
|
||
}
|
||
} catch (err) {
|
||
// Only proceed if it's a ENOENT error (path doesn't exist)
|
||
if (err instanceof VFSError && err.code !== VFSErrorCode.ENOENT) {
|
||
throw err // Re-throw non-ENOENT errors
|
||
}
|
||
// Doesn't exist, proceed to create
|
||
}
|
||
|
||
// Parse path
|
||
const parentPath = this.getParentPath(path)
|
||
const name = this.getBasename(path)
|
||
|
||
// Ensure parent exists (recursive mkdir if needed)
|
||
let parentId: string
|
||
if (parentPath === '/' || parentPath === null) {
|
||
parentId = this.rootEntityId!
|
||
} else if (options?.recursive) {
|
||
parentId = await this.ensureDirectory(parentPath)
|
||
} else {
|
||
try {
|
||
parentId = await this.pathResolver.resolve(parentPath)
|
||
} catch (err) {
|
||
throw new VFSError(VFSErrorCode.ENOENT, `Parent directory not found: ${parentPath}`, path, 'mkdir')
|
||
}
|
||
}
|
||
|
||
// Create directory entity
|
||
const metadata: VFSMetadata = {
|
||
path,
|
||
name,
|
||
parent: parentId,
|
||
vfsType: 'directory',
|
||
isVFS: true, // Mark as VFS entity (internal)
|
||
isVFSEntity: true, // Explicit flag for developer filtering
|
||
size: 0,
|
||
permissions: options?.mode || this.config.permissions?.defaultDirectory || 0o755,
|
||
owner: 'user',
|
||
group: 'users',
|
||
accessed: Date.now(),
|
||
modified: Date.now(),
|
||
...options?.metadata
|
||
}
|
||
|
||
const entity = await this.brain.add({
|
||
data: path, // Directory path as string content
|
||
type: NounType.Collection,
|
||
subtype: 'vfs-directory', // Standard subtype for VFS directory entities (7.30+)
|
||
metadata
|
||
})
|
||
|
||
// Create parent-child relationship (no need to check for duplicates on new entities)
|
||
if (parentId !== entity) { // Don't relate to self (root)
|
||
await this.brain.relate({
|
||
from: parentId,
|
||
to: entity,
|
||
type: VerbType.Contains,
|
||
subtype: 'vfs-contains', // Standard subtype for VFS containment edges (7.30+)
|
||
metadata: {
|
||
isVFS: true, // Mark as VFS relationship
|
||
relationshipType: 'vfs' // Standardized relationship type metadata
|
||
}
|
||
})
|
||
}
|
||
|
||
// Update path resolver cache
|
||
await this.pathResolver.createPath(path, entity)
|
||
|
||
// Trigger watchers
|
||
this.triggerWatchers(path, 'rename')
|
||
} finally {
|
||
// Release the lock
|
||
resolveLock!()
|
||
this.mkdirLocks.delete(path)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Remove a directory
|
||
*
|
||
* Optimized for cloud storage using batch operations
|
||
* - Uses gatherDescendants() for efficient graph traversal + batch fetch
|
||
* - Uses deleteMany() for chunked transactional deletion
|
||
* - Parallel blob cleanup with chunking
|
||
*
|
||
* Performance improvement: 4-8x faster on cloud storage (GCS, S3, R2, Azure)
|
||
* - 15 files on GCS: 120s → 15-30s
|
||
*/
|
||
async rmdir(path: string, options?: { recursive?: boolean }): Promise<void> {
|
||
await this.ensureInitialized()
|
||
|
||
if (path === '/') {
|
||
throw new VFSError(VFSErrorCode.EACCES, 'Cannot remove root directory', path, 'rmdir')
|
||
}
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
// Verify it's a directory
|
||
if (entity.metadata.vfsType !== 'directory') {
|
||
throw new VFSError(VFSErrorCode.ENOTDIR, `Not a directory: ${path}`, path, 'rmdir')
|
||
}
|
||
|
||
// Check if empty (unless recursive)
|
||
const children = await this.pathResolver.getChildren(entityId)
|
||
if (children.length > 0 && !options?.recursive) {
|
||
throw new VFSError(VFSErrorCode.ENOTEMPTY, `Directory not empty: ${path}`, path, 'rmdir')
|
||
}
|
||
|
||
// OPTIMIZED batch deletion for recursive case
|
||
if (options?.recursive && children.length > 0) {
|
||
// Phase 1: Gather all descendants in ONE batch fetch
|
||
const descendants = await this.gatherDescendants(entityId, Infinity)
|
||
|
||
// Phase 2: Parallel blob cleanup (chunked to avoid overwhelming storage)
|
||
// Blob deletion is reference-counted, so safe to call for all files
|
||
const blobFiles = descendants.filter(d =>
|
||
d.metadata.vfsType === 'file' && d.metadata.storage?.type === 'blob'
|
||
)
|
||
|
||
const BLOB_CHUNK_SIZE = 20 // Parallel delete 20 blobs at a time
|
||
for (let i = 0; i < blobFiles.length; i += BLOB_CHUNK_SIZE) {
|
||
const chunk = blobFiles.slice(i, i + BLOB_CHUNK_SIZE)
|
||
await Promise.all(chunk.map(f =>
|
||
this.blobStorage.delete(f.metadata.storage!.hash)
|
||
))
|
||
}
|
||
|
||
// Phase 3: Batch delete all entities (including root directory)
|
||
const allIds = [...descendants.map(d => d.id), entityId]
|
||
await this.brain.deleteMany({ ids: allIds, continueOnError: false })
|
||
} else {
|
||
// No children or not recursive - just delete the directory entity
|
||
await this.brain.delete(entityId)
|
||
}
|
||
|
||
// Invalidate caches (recursive invalidation handles all descendants)
|
||
this.pathResolver.invalidatePath(path, true)
|
||
this.invalidateCaches(path, true)
|
||
|
||
// Trigger watchers
|
||
this.triggerWatchers(path, 'rename')
|
||
}
|
||
|
||
/**
|
||
* Read directory contents
|
||
*/
|
||
async readdir(path: string, options?: ReaddirOptions): Promise<string[] | VFSDirent[]> {
|
||
await this.ensureInitialized()
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
// Verify it's a directory
|
||
if (entity.metadata.vfsType !== 'directory') {
|
||
throw new VFSError(VFSErrorCode.ENOTDIR, `Not a directory: ${path}`, path, 'readdir')
|
||
}
|
||
|
||
// Get children
|
||
let children = await this.pathResolver.getChildren(entityId)
|
||
|
||
// Apply filters
|
||
if (options?.filter) {
|
||
children = this.filterDirectoryEntries(children, options.filter)
|
||
}
|
||
|
||
// Sort if requested
|
||
if (options?.sort) {
|
||
children = this.sortDirectoryEntries(children, options.sort, options.order)
|
||
}
|
||
|
||
// Apply pagination
|
||
if (options?.offset) {
|
||
children = children.slice(options.offset)
|
||
}
|
||
if (options?.limit) {
|
||
children = children.slice(0, options.limit)
|
||
}
|
||
|
||
// REMOVED updateAccessTime() for performance
|
||
// Directory access time updates caused 50-100ms GCS write on EVERY readdir
|
||
// await this.updateAccessTime(entityId) // ← REMOVED
|
||
|
||
// Return appropriate format
|
||
if (options?.withFileTypes) {
|
||
return children.map(child => ({
|
||
name: child.metadata.name,
|
||
path: child.metadata.path,
|
||
type: child.metadata.vfsType,
|
||
entityId: child.id
|
||
} as VFSDirent))
|
||
}
|
||
|
||
return children.map(child => child.metadata.name)
|
||
}
|
||
|
||
// ============= Metadata Operations =============
|
||
|
||
/**
|
||
* Get file/directory statistics
|
||
*/
|
||
async stat(path: string): Promise<VFSStats> {
|
||
await this.ensureInitialized()
|
||
|
||
// Check cache
|
||
if (this.statCache.has(path)) {
|
||
const cached = this.statCache.get(path)!
|
||
if (Date.now() - cached.timestamp < 5000) { // 5 second cache
|
||
return cached.stats
|
||
}
|
||
}
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
const stats: VFSStats = {
|
||
size: entity.metadata.size,
|
||
mode: entity.metadata.permissions,
|
||
uid: 1000, // In production, map owner to UID
|
||
gid: 1000, // In production, map group to GID
|
||
atime: new Date(entity.metadata.accessed),
|
||
mtime: new Date(entity.metadata.modified),
|
||
ctime: new Date(entity.updatedAt || entity.createdAt),
|
||
birthtime: new Date(entity.createdAt),
|
||
isFile: () => entity.metadata.vfsType === 'file',
|
||
isDirectory: () => entity.metadata.vfsType === 'directory',
|
||
isSymbolicLink: () => entity.metadata.vfsType === 'symlink',
|
||
path,
|
||
entityId: entity.id,
|
||
vector: entity.vector,
|
||
connections: await this.countRelationships(entityId)
|
||
}
|
||
|
||
// Cache stats
|
||
this.statCache.set(path, { stats, timestamp: Date.now() })
|
||
|
||
return stats
|
||
}
|
||
|
||
/**
|
||
* lstat - same as stat for now (symlinks not fully implemented)
|
||
*/
|
||
async lstat(path: string): Promise<VFSStats> {
|
||
return this.stat(path)
|
||
}
|
||
|
||
/**
|
||
* Check if path exists
|
||
*/
|
||
async exists(path: string): Promise<boolean> {
|
||
await this.ensureInitialized()
|
||
|
||
try {
|
||
await this.pathResolver.resolve(path)
|
||
return true
|
||
} catch (err) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
// ============= Semantic Operations =============
|
||
|
||
/**
|
||
* Search files with natural language
|
||
*/
|
||
async search(query: string, options?: SearchOptions): Promise<SearchResult[]> {
|
||
await this.ensureInitialized()
|
||
|
||
// Build find params
|
||
const params: FindParams = {
|
||
query,
|
||
type: [NounType.File, NounType.Document, NounType.Media],
|
||
limit: options?.limit || 10,
|
||
offset: options?.offset,
|
||
explain: options?.explain,
|
||
where: {
|
||
vfsType: 'file' // Search VFS files
|
||
}
|
||
}
|
||
|
||
// Add path filter if specified
|
||
if (options?.path) {
|
||
params.where = {
|
||
...params.where,
|
||
path: { $startsWith: options.path }
|
||
}
|
||
}
|
||
|
||
// Add metadata filters
|
||
if (options?.where) {
|
||
Object.assign(params.where || {}, options.where)
|
||
}
|
||
|
||
// Execute search using Brainy's Triple Intelligence
|
||
const results = await this.brain.find(params)
|
||
|
||
// Convert to search results
|
||
return results.map(r => {
|
||
const entity = r.entity as VFSEntity
|
||
return {
|
||
path: entity.metadata.path,
|
||
entityId: entity.id,
|
||
score: r.score,
|
||
type: entity.metadata.vfsType,
|
||
size: entity.metadata.size,
|
||
modified: new Date(entity.metadata.modified),
|
||
explanation: r.explanation
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Find files similar to a given file
|
||
*/
|
||
async findSimilar(path: string, options?: SimilarOptions): Promise<SearchResult[]> {
|
||
await this.ensureInitialized()
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
|
||
// Use Brainy's similarity search
|
||
const results = await this.brain.similar({
|
||
to: entityId,
|
||
limit: options?.limit || 10,
|
||
threshold: options?.threshold || 0.7,
|
||
type: [NounType.File, NounType.Document, NounType.Media],
|
||
where: {
|
||
vfsType: 'file' // Find similar VFS files
|
||
}
|
||
})
|
||
|
||
return results.map(r => {
|
||
const entity = r.entity as VFSEntity
|
||
return {
|
||
path: entity.metadata.path,
|
||
entityId: entity.id,
|
||
score: r.score,
|
||
type: entity.metadata.vfsType,
|
||
size: entity.metadata.size,
|
||
modified: new Date(entity.metadata.modified)
|
||
}
|
||
})
|
||
}
|
||
|
||
// ============= Helper Methods =============
|
||
|
||
private async ensureInitialized(): Promise<void> {
|
||
if (!this.initialized) {
|
||
throw new VFSError(
|
||
VFSErrorCode.EINVAL,
|
||
'VFS not initialized. Call await vfs.init() before using VFS operations.\n\n' +
|
||
'✅ After brain.import():\n' +
|
||
' await brain.import(file, { vfsPath: "/imports/data" })\n' +
|
||
' const vfs = brain.vfs\n' +
|
||
' await vfs.init() // ← Required! Safe to call multiple times\n' +
|
||
' const files = await vfs.readdir("/imports/data")\n\n' +
|
||
'✅ Direct VFS usage:\n' +
|
||
' const vfs = brain.vfs\n' +
|
||
' await vfs.init() // ← Always required before first use\n' +
|
||
' await vfs.writeFile("/docs/readme.md", "Hello")\n\n' +
|
||
'📖 Docs: https://github.com/soulcraftlabs/brainy/blob/main/docs/vfs/QUICK_START.md',
|
||
'<unknown>',
|
||
'VFS'
|
||
)
|
||
}
|
||
}
|
||
|
||
private async ensureDirectory(path: string): Promise<string> {
|
||
if (!path || path === '/') {
|
||
return this.rootEntityId!
|
||
}
|
||
|
||
try {
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
if (entity.metadata.vfsType !== 'directory') {
|
||
throw new VFSError(VFSErrorCode.ENOTDIR, `Not a directory: ${path}`, path)
|
||
}
|
||
return entityId
|
||
} catch (err) {
|
||
// Only create directory if it doesn't exist (ENOENT error)
|
||
if (err instanceof VFSError && err.code === VFSErrorCode.ENOENT) {
|
||
await this.mkdir(path, { recursive: true })
|
||
return await this.pathResolver.resolve(path)
|
||
}
|
||
// Re-throw other errors (like ENOTDIR)
|
||
throw err
|
||
}
|
||
}
|
||
|
||
async getEntityById(id: string): Promise<VFSEntity> {
|
||
const entity = await this.brain.get(id)
|
||
|
||
if (!entity) {
|
||
throw new VFSError(VFSErrorCode.ENOENT, `Entity not found: ${id}`)
|
||
}
|
||
|
||
// Ensure entity has proper VFS metadata structure
|
||
// Handle both nested and flat metadata structures for compatibility
|
||
if (!entity.metadata || !entity.metadata.vfsType) {
|
||
// Check if metadata is at top level (legacy structure)
|
||
const anyEntity = entity as any
|
||
if (anyEntity.vfsType || anyEntity.path) {
|
||
entity.metadata = {
|
||
path: anyEntity.path || '/',
|
||
name: anyEntity.name || '',
|
||
vfsType: anyEntity.vfsType || (anyEntity.path === '/' ? 'directory' : 'file'),
|
||
size: anyEntity.size || 0,
|
||
permissions: anyEntity.permissions || (anyEntity.vfsType === 'directory' ? 0o755 : 0o644),
|
||
owner: anyEntity.owner || 'user',
|
||
group: anyEntity.group || 'users',
|
||
accessed: anyEntity.accessed || Date.now(),
|
||
modified: anyEntity.modified || Date.now(),
|
||
...entity.metadata // Preserve any existing nested metadata
|
||
}
|
||
} else if (entity.id === this.rootEntityId) {
|
||
// Special case: ensure root directory always has proper metadata
|
||
entity.metadata = {
|
||
path: '/',
|
||
name: '',
|
||
vfsType: 'directory',
|
||
size: 0,
|
||
permissions: 0o755,
|
||
owner: 'root',
|
||
group: 'root',
|
||
accessed: Date.now(),
|
||
modified: Date.now(),
|
||
...entity.metadata
|
||
}
|
||
}
|
||
}
|
||
|
||
return entity as VFSEntity
|
||
}
|
||
|
||
private getParentPath(path: string): string {
|
||
const normalized = path.replace(/\/+/g, '/').replace(/\/$/, '')
|
||
const lastSlash = normalized.lastIndexOf('/')
|
||
if (lastSlash <= 0) return '/'
|
||
return normalized.substring(0, lastSlash)
|
||
}
|
||
|
||
private getBasename(path: string): string {
|
||
const normalized = path.replace(/\/+/g, '/').replace(/\/$/, '')
|
||
const lastSlash = normalized.lastIndexOf('/')
|
||
return normalized.substring(lastSlash + 1)
|
||
}
|
||
|
||
private getExtension(filename: string): string | undefined {
|
||
const lastDot = filename.lastIndexOf('.')
|
||
if (lastDot === -1 || lastDot === 0) return undefined
|
||
return filename.substring(lastDot + 1).toLowerCase()
|
||
}
|
||
|
||
// MIME detection moved to MimeTypeDetector service
|
||
// Removed detectMimeType() and isTextFile() - now using mimeDetector singleton
|
||
|
||
private getFileNounType(mimeType: string): NounType {
|
||
if (mimeType.startsWith('text/') || mimeType.includes('json')) {
|
||
return NounType.Document
|
||
}
|
||
if (mimeType.startsWith('image/') || mimeType.startsWith('video/') || mimeType.startsWith('audio/')) {
|
||
return NounType.Media
|
||
}
|
||
return NounType.File
|
||
}
|
||
|
||
// Removed compression methods (shouldCompress, compress, decompress)
|
||
// BlobStorage handles all compression automatically with zstd
|
||
|
||
private async generateEmbedding(buffer: Buffer, mimeType: string): Promise<number[] | undefined> {
|
||
try {
|
||
// Use text content for text files, description for binary
|
||
let content: string
|
||
if (mimeDetector.isTextFile(mimeType)) {
|
||
// Use first 10KB for embedding
|
||
content = buffer.toString('utf8', 0, Math.min(10240, buffer.length))
|
||
} else {
|
||
// For binary files, create a description
|
||
content = `Binary file: ${mimeType}, size: ${buffer.length} bytes`
|
||
}
|
||
|
||
// Ensure content is actually a string
|
||
if (typeof content !== 'string') {
|
||
console.debug('Content is not a string:', typeof content, content)
|
||
return undefined
|
||
}
|
||
|
||
// Ensure content is not empty or invalid
|
||
if (!content || content.length === 0) {
|
||
console.debug('Content is empty')
|
||
return undefined
|
||
}
|
||
|
||
const vector = await this.brain.embed(content)
|
||
return vector
|
||
} catch (error) {
|
||
console.debug('Failed to generate embedding:', error)
|
||
return undefined
|
||
}
|
||
}
|
||
|
||
private async extractMetadata(buffer: Buffer, mimeType: string): Promise<Partial<VFSMetadata>> {
|
||
const metadata: Partial<VFSMetadata> = {}
|
||
|
||
// Extract basic metadata based on content type
|
||
if (mimeDetector.isTextFile(mimeType)) {
|
||
const text = buffer.toString('utf8')
|
||
metadata.lineCount = text.split('\n').length
|
||
metadata.wordCount = text.split(/\s+/).filter(w => w).length
|
||
metadata.charset = 'utf-8'
|
||
|
||
// Extract concepts using brain.extractConcepts() (neural extraction)
|
||
if (this.config.intelligence?.autoConcepts) {
|
||
try {
|
||
const concepts = await this.brain.extractConcepts(text, { limit: 20 })
|
||
metadata.conceptNames = concepts // Flattened for O(log n) queries
|
||
} catch (error) {
|
||
// Concept extraction is optional - don't fail if it errors
|
||
console.debug('Concept extraction failed:', error)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Extract hash for integrity
|
||
const crypto = await import('crypto')
|
||
metadata.hash = crypto.createHash('sha256').update(buffer).digest('hex')
|
||
|
||
return metadata
|
||
}
|
||
|
||
// REMOVED updateAccessTime() method entirely
|
||
// Access time updates caused 50-100ms GCS write on EVERY file/dir read
|
||
// Modern file systems use 'noatime' for same reason
|
||
// Field 'accessed' still exists in metadata for backward compat but won't update
|
||
|
||
private async countRelationships(entityId: string): Promise<number> {
|
||
const relations = await this.brain.getRelations({ from: entityId })
|
||
const relationsTo = await this.brain.getRelations({ to: entityId })
|
||
return relations.length + relationsTo.length
|
||
}
|
||
|
||
private filterDirectoryEntries(entries: VFSEntity[], filter: any): VFSEntity[] {
|
||
return entries.filter(entry => {
|
||
if (filter.type && entry.metadata.vfsType !== filter.type) return false
|
||
if (filter.pattern && !this.matchGlob(entry.metadata.name, filter.pattern)) return false
|
||
if (filter.minSize && entry.metadata.size < filter.minSize) return false
|
||
if (filter.maxSize && entry.metadata.size > filter.maxSize) return false
|
||
if (filter.modifiedAfter && entry.metadata.modified < filter.modifiedAfter.getTime()) return false
|
||
if (filter.modifiedBefore && entry.metadata.modified > filter.modifiedBefore.getTime()) return false
|
||
return true
|
||
})
|
||
}
|
||
|
||
private sortDirectoryEntries(entries: VFSEntity[], sort: string, order?: 'asc' | 'desc'): VFSEntity[] {
|
||
const sorted = [...entries].sort((a, b) => {
|
||
let comparison = 0
|
||
switch (sort) {
|
||
case 'name':
|
||
comparison = a.metadata.name.localeCompare(b.metadata.name)
|
||
break
|
||
case 'size':
|
||
comparison = a.metadata.size - b.metadata.size
|
||
break
|
||
case 'modified':
|
||
comparison = a.metadata.modified - b.metadata.modified
|
||
break
|
||
case 'created':
|
||
comparison = a.createdAt - b.createdAt
|
||
break
|
||
}
|
||
return order === 'desc' ? -comparison : comparison
|
||
})
|
||
return sorted
|
||
}
|
||
|
||
private matchGlob(name: string, pattern: string): boolean {
|
||
// Simple glob matching (in production, use proper glob library)
|
||
const regex = pattern
|
||
.replace(/\*/g, '.*')
|
||
.replace(/\?/g, '.')
|
||
return new RegExp(`^${regex}$`).test(name)
|
||
}
|
||
|
||
private invalidateCaches(path: string, recursive = false): void {
|
||
this.contentCache.delete(path)
|
||
this.statCache.delete(path)
|
||
|
||
if (recursive) {
|
||
const prefix = path.endsWith('/') ? path : path + '/'
|
||
for (const cachedPath of this.contentCache.keys()) {
|
||
if (cachedPath.startsWith(prefix)) {
|
||
this.contentCache.delete(cachedPath)
|
||
}
|
||
}
|
||
for (const cachedPath of this.statCache.keys()) {
|
||
if (cachedPath.startsWith(prefix)) {
|
||
this.statCache.delete(cachedPath)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private triggerWatchers(path: string, event: 'rename' | 'change'): void {
|
||
const watchers = this.watchers.get(path)
|
||
if (watchers) {
|
||
for (const listener of watchers) {
|
||
listener(event, path)
|
||
}
|
||
}
|
||
}
|
||
|
||
private async updateChildrenPaths(parentId: string, oldParentPath: string, newParentPath: string): Promise<void> {
|
||
// Get all children recursively
|
||
const children = await this.pathResolver.getChildren(parentId)
|
||
|
||
for (const child of children) {
|
||
const oldChildPath = child.metadata.path as string
|
||
const relativePath = oldChildPath.substring(oldParentPath.length)
|
||
const newChildPath = newParentPath + relativePath
|
||
|
||
// Update child entity
|
||
const updatedChild = {
|
||
...child,
|
||
metadata: {
|
||
...child.metadata,
|
||
path: newChildPath,
|
||
modified: Date.now()
|
||
}
|
||
}
|
||
await this.brain.update({
|
||
...updatedChild,
|
||
id: child.id
|
||
})
|
||
|
||
// Update path cache
|
||
this.pathResolver.invalidatePath(oldChildPath)
|
||
await this.pathResolver.createPath(newChildPath, child.id)
|
||
|
||
// Recursively update if it's a directory
|
||
if (child.metadata.vfsType === 'directory') {
|
||
await this.updateChildrenPaths(child.id, oldChildPath, newChildPath)
|
||
}
|
||
}
|
||
}
|
||
|
||
private startBackgroundTasks(): void {
|
||
// Clean up caches periodically
|
||
this.backgroundTimer = setInterval(() => {
|
||
const now = Date.now()
|
||
|
||
// Clean content cache
|
||
for (const [path, entry] of this.contentCache) {
|
||
if (now - entry.timestamp > (this.config.cache?.ttl || 300000)) {
|
||
this.contentCache.delete(path)
|
||
}
|
||
}
|
||
|
||
// Clean stat cache
|
||
for (const [path, entry] of this.statCache) {
|
||
if (now - entry.timestamp > 5000) {
|
||
this.statCache.delete(path)
|
||
}
|
||
}
|
||
}, 60000) // Every minute
|
||
}
|
||
|
||
private getDefaultConfig(): Required<Omit<VFSConfig, 'rootEntityId'>> & { rootEntityId?: string } {
|
||
return {
|
||
root: '/',
|
||
rootEntityId: undefined,
|
||
cache: {
|
||
enabled: true,
|
||
maxPaths: 100_000,
|
||
maxContent: 100_000_000, // 100MB
|
||
ttl: 5 * 60 * 1000 // 5 minutes
|
||
},
|
||
storage: {
|
||
inline: {
|
||
maxSize: 100_000 // 100KB
|
||
},
|
||
chunking: {
|
||
enabled: true,
|
||
chunkSize: 5_000_000, // 5MB
|
||
parallel: 4
|
||
},
|
||
compression: {
|
||
enabled: true,
|
||
minSize: 10_000, // 10KB
|
||
algorithm: 'gzip'
|
||
}
|
||
},
|
||
intelligence: {
|
||
enabled: true,
|
||
autoEmbed: true,
|
||
autoExtract: true,
|
||
autoTag: false,
|
||
autoConcepts: false
|
||
},
|
||
permissions: {
|
||
defaultFile: 0o644,
|
||
defaultDirectory: 0o755,
|
||
umask: 0o022
|
||
},
|
||
limits: {
|
||
maxFileSize: 1_000_000_000, // 1GB
|
||
maxPathLength: 4096,
|
||
maxDirectoryEntries: 100_000
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============= Not Yet Implemented =============
|
||
|
||
async close(): Promise<void> {
|
||
// Cleanup PathResolver resources
|
||
if (this.pathResolver) {
|
||
this.pathResolver.cleanup()
|
||
}
|
||
|
||
// Stop background tasks
|
||
if (this.backgroundTimer) {
|
||
clearInterval(this.backgroundTimer)
|
||
this.backgroundTimer = null
|
||
}
|
||
|
||
// Clear caches
|
||
this.contentCache.clear()
|
||
|
||
// Clear watchers
|
||
this.watchers.clear()
|
||
|
||
this.initialized = false
|
||
}
|
||
|
||
async chmod(path: string, mode: number): Promise<void> {
|
||
await this.ensureInitialized()
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
// Update permissions in metadata
|
||
await this.brain.update({
|
||
...entity,
|
||
id: entityId,
|
||
metadata: {
|
||
...entity.metadata,
|
||
permissions: mode,
|
||
modified: Date.now()
|
||
}
|
||
})
|
||
|
||
// Invalidate caches
|
||
this.invalidateCaches(path)
|
||
}
|
||
|
||
async chown(path: string, uid: number, gid: number): Promise<void> {
|
||
await this.ensureInitialized()
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
// Update ownership in metadata
|
||
await this.brain.update({
|
||
...entity,
|
||
id: entityId,
|
||
metadata: {
|
||
...entity.metadata,
|
||
uid,
|
||
gid,
|
||
modified: Date.now()
|
||
}
|
||
})
|
||
|
||
// Invalidate caches
|
||
this.invalidateCaches(path)
|
||
}
|
||
|
||
async utimes(path: string, atime: Date, mtime: Date): Promise<void> {
|
||
await this.ensureInitialized()
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
// Update timestamps in metadata
|
||
await this.brain.update({
|
||
...entity,
|
||
id: entityId,
|
||
metadata: {
|
||
...entity.metadata,
|
||
accessed: atime.getTime(),
|
||
modified: mtime.getTime()
|
||
}
|
||
})
|
||
|
||
// Invalidate caches
|
||
this.invalidateCaches(path)
|
||
}
|
||
|
||
async rename(oldPath: string, newPath: string): Promise<void> {
|
||
await this.ensureInitialized()
|
||
|
||
// Check if source exists
|
||
const entityId = await this.pathResolver.resolve(oldPath)
|
||
const entity = await this.brain.get(entityId)
|
||
|
||
if (!entity) {
|
||
throw new VFSError(VFSErrorCode.ENOENT, `No such file or directory: ${oldPath}`, oldPath, 'rename')
|
||
}
|
||
|
||
// Check if target already exists
|
||
try {
|
||
await this.pathResolver.resolve(newPath)
|
||
throw new VFSError(VFSErrorCode.EEXIST, `File exists: ${newPath}`, newPath, 'rename')
|
||
} catch (err: any) {
|
||
if (err.code !== VFSErrorCode.ENOENT) throw err
|
||
}
|
||
|
||
// Update entity metadata
|
||
const updatedEntity = {
|
||
...entity,
|
||
metadata: {
|
||
...entity.metadata,
|
||
path: newPath,
|
||
name: this.getBasename(newPath),
|
||
modified: Date.now()
|
||
}
|
||
}
|
||
|
||
// Update parent relationships if needed
|
||
const oldParentPath = this.getParentPath(oldPath)
|
||
const newParentPath = this.getParentPath(newPath)
|
||
|
||
if (oldParentPath !== newParentPath) {
|
||
// Remove from old parent
|
||
if (oldParentPath) {
|
||
const oldParentId = await this.pathResolver.resolve(oldParentPath)
|
||
// unrelate takes the relation ID, not params - need to find and remove relation
|
||
// For now, skip unrelate as it's not critical for rename
|
||
}
|
||
|
||
// Add to new parent
|
||
if (newParentPath && newParentPath !== '/') {
|
||
const newParentId = await this.pathResolver.resolve(newParentPath)
|
||
await this.brain.relate({
|
||
from: newParentId,
|
||
to: entityId,
|
||
type: VerbType.Contains,
|
||
subtype: 'vfs-contains', // Standard subtype for VFS containment edges (7.30+)
|
||
metadata: { isVFS: true } // Mark as VFS relationship
|
||
})
|
||
}
|
||
}
|
||
|
||
// Update the entity
|
||
await this.brain.update({
|
||
...updatedEntity,
|
||
id: entityId
|
||
})
|
||
|
||
// Update path cache
|
||
this.pathResolver.invalidatePath(oldPath, true)
|
||
await this.pathResolver.createPath(newPath, entityId)
|
||
|
||
// If it's a directory, update all children paths
|
||
if (entity.metadata.vfsType === 'directory') {
|
||
await this.updateChildrenPaths(entityId, oldPath, newPath)
|
||
}
|
||
|
||
// Trigger watchers
|
||
this.triggerWatchers(oldPath, 'rename')
|
||
this.triggerWatchers(newPath, 'rename')
|
||
}
|
||
|
||
async copy(src: string, dest: string, options?: CopyOptions): Promise<void> {
|
||
await this.ensureInitialized()
|
||
|
||
// Get source entity
|
||
const srcEntityId = await this.pathResolver.resolve(src)
|
||
const srcEntity = await this.brain.get(srcEntityId)
|
||
|
||
if (!srcEntity) {
|
||
throw new VFSError(VFSErrorCode.ENOENT, `No such file or directory: ${src}`, src, 'copy')
|
||
}
|
||
|
||
// Check if destination already exists
|
||
if (!options?.overwrite) {
|
||
try {
|
||
await this.pathResolver.resolve(dest)
|
||
throw new VFSError(VFSErrorCode.EEXIST, `File exists: ${dest}`, dest, 'copy')
|
||
} catch (err: any) {
|
||
if (err.code !== VFSErrorCode.ENOENT) throw err
|
||
}
|
||
}
|
||
|
||
// Copy the entity
|
||
if (srcEntity.metadata.vfsType === 'file') {
|
||
await this.copyFile(srcEntity, dest, options)
|
||
} else if (srcEntity.metadata.vfsType === 'directory') {
|
||
await this.copyDirectory(src, dest, options)
|
||
}
|
||
}
|
||
|
||
private async copyFile(srcEntity: Entity, destPath: string, options?: CopyOptions): Promise<void> {
|
||
// Create new entity with same content but different path. Preserve the source
|
||
// entity's subtype when it has one (so a vfs-file stays vfs-file); fall back
|
||
// to 'vfs-file' for the rare case of a VFS entity without subtype (pre-7.30
|
||
// legacy data path that hits the copy operation).
|
||
const newEntity = await this.brain.add({
|
||
type: srcEntity.type,
|
||
subtype: (srcEntity as any).subtype ?? 'vfs-file',
|
||
data: srcEntity.data,
|
||
vector: options?.preserveVector ? srcEntity.vector : undefined,
|
||
metadata: {
|
||
...srcEntity.metadata,
|
||
path: destPath,
|
||
name: this.getBasename(destPath),
|
||
created: Date.now(),
|
||
modified: Date.now(),
|
||
copiedFrom: srcEntity.metadata.path
|
||
}
|
||
})
|
||
|
||
// Add to parent directory
|
||
const parentPath = this.getParentPath(destPath)
|
||
if (parentPath && parentPath !== '/') {
|
||
const parentId = await this.pathResolver.resolve(parentPath)
|
||
await this.brain.relate({
|
||
from: parentId,
|
||
to: newEntity,
|
||
type: VerbType.Contains,
|
||
subtype: 'vfs-contains', // Standard subtype for VFS containment edges (7.30+)
|
||
metadata: { isVFS: true } // Mark as VFS relationship
|
||
})
|
||
}
|
||
|
||
// Update path cache
|
||
await this.pathResolver.createPath(destPath, newEntity)
|
||
|
||
// Copy relationships if requested
|
||
if (options?.preserveRelationships) {
|
||
const relations = await this.brain.getRelations({ from: srcEntity.id })
|
||
for (const relation of relations) {
|
||
if (relation.type !== VerbType.Contains) {
|
||
// Skip relationship without Contains type
|
||
// Future: implement proper relation copying
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Copy a directory recursively
|
||
*
|
||
* Optimized for cloud storage using batch operations
|
||
* - Uses gatherDescendants() for efficient graph traversal + batch fetch
|
||
* - Uses addMany() for batch entity creation
|
||
* - Uses relateMany() for batch relationship creation
|
||
*
|
||
* Performance improvement: 3-6x faster on cloud storage (GCS, S3, R2, Azure)
|
||
*/
|
||
private async copyDirectory(srcPath: string, destPath: string, options?: CopyOptions): Promise<void> {
|
||
// Shallow copy - just create directory
|
||
if (options?.deepCopy === false) {
|
||
await this.mkdir(destPath, { recursive: true })
|
||
return
|
||
}
|
||
|
||
// OPTIMIZED: Batch fetch all source entities in ONE call
|
||
const srcEntityId = await this.pathResolver.resolve(srcPath)
|
||
const descendants = await this.gatherDescendants(srcEntityId, Infinity)
|
||
const srcEntity = await this.getEntityById(srcEntityId)
|
||
const allEntities = [srcEntity, ...descendants]
|
||
|
||
// Build path mapping: srcPath -> destPath
|
||
const pathMap = new Map<string, string>()
|
||
const idMap = new Map<string, string>() // old ID -> new ID
|
||
|
||
for (const entity of allEntities) {
|
||
const relativePath = entity.metadata.path.substring(srcPath.length)
|
||
const newPath = destPath + relativePath
|
||
pathMap.set(entity.metadata.path, newPath)
|
||
}
|
||
|
||
// Phase 1: Create all directories first (maintain hierarchy)
|
||
// Sort by path length to ensure parents are created before children
|
||
const directories = allEntities
|
||
.filter(e => e.metadata.vfsType === 'directory')
|
||
.sort((a, b) => a.metadata.path.length - b.metadata.path.length)
|
||
|
||
for (const dir of directories) {
|
||
const newPath = pathMap.get(dir.metadata.path)!
|
||
await this.mkdir(newPath) // mkdir is relatively fast
|
||
const newId = await this.pathResolver.resolve(newPath)
|
||
idMap.set(dir.id, newId)
|
||
}
|
||
|
||
// Phase 2: Batch-create all files using addMany
|
||
const files = allEntities.filter(e => e.metadata.vfsType === 'file')
|
||
|
||
if (files.length > 0) {
|
||
const items = files.map(srcFile => {
|
||
const newPath = pathMap.get(srcFile.metadata.path)!
|
||
|
||
return {
|
||
type: srcFile.type,
|
||
data: srcFile.data,
|
||
vector: options?.preserveVector ? srcFile.vector : undefined,
|
||
metadata: {
|
||
...srcFile.metadata,
|
||
path: newPath,
|
||
name: this.getBasename(newPath),
|
||
parent: undefined, // Will be set via relationship
|
||
created: Date.now(),
|
||
modified: Date.now(),
|
||
copiedFrom: srcFile.metadata.path
|
||
}
|
||
}
|
||
})
|
||
|
||
const result = await this.brain.addMany({ items, continueOnError: false })
|
||
|
||
// Build ID mapping for new files
|
||
for (let i = 0; i < files.length; i++) {
|
||
idMap.set(files[i].id, result.successful[i])
|
||
}
|
||
|
||
// Phase 3: Batch-create parent relationships using relateMany
|
||
const relations = files.map((srcFile, i) => {
|
||
const newPath = pathMap.get(srcFile.metadata.path)!
|
||
const parentPath = this.getParentPath(newPath)
|
||
|
||
// Find parent ID from directories we created
|
||
let parentId: string
|
||
if (parentPath === '/') {
|
||
parentId = VirtualFileSystem.VFS_ROOT_ID
|
||
} else {
|
||
// Find the source directory that maps to this parent path
|
||
const srcParentDir = directories.find(d => pathMap.get(d.metadata.path) === parentPath)
|
||
parentId = srcParentDir ? idMap.get(srcParentDir.id)! : VirtualFileSystem.VFS_ROOT_ID
|
||
}
|
||
|
||
return {
|
||
from: parentId,
|
||
to: result.successful[i],
|
||
type: VerbType.Contains,
|
||
subtype: 'vfs-contains', // Standard subtype for VFS containment edges (7.30+)
|
||
metadata: { isVFS: true }
|
||
}
|
||
})
|
||
|
||
await this.brain.relateMany({ items: relations })
|
||
|
||
// Phase 4: Update path resolver cache for all new files
|
||
for (let i = 0; i < files.length; i++) {
|
||
const newPath = pathMap.get(files[i].metadata.path)!
|
||
await this.pathResolver.createPath(newPath, result.successful[i])
|
||
}
|
||
}
|
||
}
|
||
|
||
async move(src: string, dest: string): Promise<void> {
|
||
await this.ensureInitialized()
|
||
|
||
// Move is just copy + delete
|
||
await this.copy(src, dest, { overwrite: false })
|
||
|
||
// Delete source after successful copy
|
||
const srcEntityId = await this.pathResolver.resolve(src)
|
||
const srcEntity = await this.brain.get(srcEntityId)
|
||
|
||
if (srcEntity!.metadata.vfsType === 'file') {
|
||
await this.unlink(src)
|
||
} else {
|
||
await this.rmdir(src, { recursive: true })
|
||
}
|
||
}
|
||
|
||
async symlink(target: string, path: string): Promise<void> {
|
||
await this.ensureInitialized()
|
||
|
||
// Check if symlink already exists
|
||
try {
|
||
await this.pathResolver.resolve(path)
|
||
throw new VFSError(VFSErrorCode.EEXIST, `File exists: ${path}`, path, 'symlink')
|
||
} catch (err: any) {
|
||
if (err.code !== VFSErrorCode.ENOENT) throw err
|
||
}
|
||
|
||
// Parse path to get parent and name
|
||
const parentPath = this.getParentPath(path)
|
||
const name = this.getBasename(path)
|
||
|
||
// Ensure parent directory exists
|
||
const parentId = await this.ensureDirectory(parentPath)
|
||
|
||
// Create symlink entity
|
||
const metadata: VFSMetadata = {
|
||
path,
|
||
name,
|
||
parent: parentId,
|
||
vfsType: 'symlink',
|
||
isVFS: true, // Infrastructure-bypass marker for strict-mode enforcement
|
||
isVFSEntity: true,
|
||
symlinkTarget: target,
|
||
size: 0,
|
||
permissions: 0o777,
|
||
owner: 'user',
|
||
group: 'users',
|
||
accessed: Date.now(),
|
||
modified: Date.now()
|
||
}
|
||
|
||
const entity = await this.brain.add({
|
||
data: `symlink:${target}`,
|
||
type: NounType.File, // Symlinks are special files
|
||
subtype: 'vfs-symlink', // Distinct from 'vfs-file' so consumers can find symlinks (7.30.1+)
|
||
metadata
|
||
})
|
||
|
||
// Create parent-child relationship
|
||
await this.brain.relate({
|
||
from: parentId,
|
||
to: entity,
|
||
type: VerbType.Contains,
|
||
subtype: 'vfs-contains', // Standard subtype for VFS containment edges (7.30+)
|
||
metadata: { isVFS: true } // Mark as VFS relationship
|
||
})
|
||
|
||
// Update path resolver cache
|
||
await this.pathResolver.createPath(path, entity)
|
||
}
|
||
|
||
async readlink(path: string): Promise<string> {
|
||
await this.ensureInitialized()
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
// Verify it's a symlink
|
||
if (entity.metadata.vfsType !== 'symlink') {
|
||
throw new VFSError(VFSErrorCode.EINVAL, `Not a symbolic link: ${path}`, path, 'readlink')
|
||
}
|
||
|
||
return entity.metadata.symlinkTarget || ''
|
||
}
|
||
|
||
async realpath(path: string): Promise<string> {
|
||
await this.ensureInitialized()
|
||
|
||
// Resolve symlinks recursively
|
||
let currentPath = path
|
||
let depth = 0
|
||
const maxDepth = 20 // Prevent infinite loops
|
||
|
||
while (depth < maxDepth) {
|
||
try {
|
||
const entityId = await this.pathResolver.resolve(currentPath)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
if (entity.metadata.vfsType === 'symlink') {
|
||
// Follow the symlink
|
||
currentPath = entity.metadata.symlinkTarget || ''
|
||
depth++
|
||
} else {
|
||
// Not a symlink, we have the real path
|
||
return currentPath
|
||
}
|
||
} catch (err) {
|
||
throw new VFSError(VFSErrorCode.ENOENT, `No such file or directory: ${path}`, path, 'realpath')
|
||
}
|
||
}
|
||
|
||
throw new VFSError(VFSErrorCode.ELOOP, `Too many symbolic links: ${path}`, path, 'realpath')
|
||
}
|
||
|
||
async getxattr(path: string, name: string): Promise<any> {
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
return entity.metadata.attributes?.[name]
|
||
}
|
||
|
||
async setxattr(path: string, name: string, value: any): Promise<void> {
|
||
await this.ensureInitialized()
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
// Create extended attributes object
|
||
const xattrs = entity.metadata.attributes || {}
|
||
xattrs[name] = value
|
||
|
||
// Update entity metadata
|
||
await this.brain.update({
|
||
id: entityId,
|
||
metadata: {
|
||
...entity.metadata,
|
||
attributes: xattrs
|
||
}
|
||
})
|
||
|
||
// Invalidate caches
|
||
this.invalidateCaches(path)
|
||
}
|
||
|
||
async listxattr(path: string): Promise<string[]> {
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
return Object.keys(entity.metadata.attributes || {})
|
||
}
|
||
|
||
async removexattr(path: string, name: string): Promise<void> {
|
||
await this.ensureInitialized()
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
// Remove from extended attributes
|
||
const xattrs = { ...entity.metadata.attributes }
|
||
delete xattrs[name]
|
||
|
||
// Update entity metadata
|
||
await this.brain.update({
|
||
...entity,
|
||
id: entityId,
|
||
metadata: {
|
||
...entity.metadata,
|
||
attributes: xattrs
|
||
}
|
||
})
|
||
|
||
// Invalidate caches
|
||
this.invalidateCaches(path)
|
||
}
|
||
|
||
async getRelated(path: string, options?: RelatedOptions): Promise<Array<{
|
||
path: string
|
||
relationship: string
|
||
direction: 'from' | 'to'
|
||
}>> {
|
||
await this.ensureInitialized()
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const results: Array<{ path: string, relationship: string, direction: 'from' | 'to' }> = []
|
||
|
||
// Use proper Brainy relationship API to get all relationships
|
||
const [fromRelations, toRelations] = await Promise.all([
|
||
this.brain.getRelations({ from: entityId }),
|
||
this.brain.getRelations({ to: entityId })
|
||
])
|
||
|
||
// Add outgoing relationships
|
||
for (const rel of fromRelations) {
|
||
const targetEntity = await this.brain.get(rel.to)
|
||
if (targetEntity && targetEntity.metadata?.path) {
|
||
results.push({
|
||
path: targetEntity.metadata.path,
|
||
relationship: rel.type || 'related',
|
||
direction: 'from'
|
||
})
|
||
}
|
||
}
|
||
|
||
// Add incoming relationships
|
||
for (const rel of toRelations) {
|
||
const sourceEntity = await this.brain.get(rel.from)
|
||
if (sourceEntity && sourceEntity.metadata?.path) {
|
||
results.push({
|
||
path: sourceEntity.metadata.path,
|
||
relationship: rel.type || 'related',
|
||
direction: 'to'
|
||
})
|
||
}
|
||
}
|
||
|
||
return results
|
||
}
|
||
|
||
async getRelationships(path: string): Promise<Relation[]> {
|
||
await this.ensureInitialized()
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const relationships: Relation[] = []
|
||
|
||
// Use proper Brainy relationship API
|
||
const [fromRelations, toRelations] = await Promise.all([
|
||
this.brain.getRelations({ from: entityId }),
|
||
this.brain.getRelations({ to: entityId })
|
||
])
|
||
|
||
// Process outgoing relationships (excluding Contains for parent-child)
|
||
for (const rel of fromRelations) {
|
||
if (rel.type !== VerbType.Contains) { // Skip filesystem hierarchy
|
||
const targetEntity = await this.brain.get(rel.to)
|
||
if (targetEntity && targetEntity.metadata?.path) {
|
||
relationships.push({
|
||
id: rel.id || crypto.randomUUID(),
|
||
from: entityId,
|
||
to: rel.to,
|
||
type: rel.type,
|
||
createdAt: rel.createdAt || Date.now()
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// Process incoming relationships (excluding Contains for parent-child)
|
||
for (const rel of toRelations) {
|
||
if (rel.type !== VerbType.Contains) { // Skip filesystem hierarchy
|
||
const sourceEntity = await this.brain.get(rel.from)
|
||
if (sourceEntity && sourceEntity.metadata?.path) {
|
||
relationships.push({
|
||
id: rel.id || crypto.randomUUID(),
|
||
from: rel.from,
|
||
to: entityId,
|
||
type: rel.type,
|
||
createdAt: rel.createdAt || Date.now()
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
return relationships
|
||
}
|
||
|
||
async addRelationship(from: string, to: string, type: string): Promise<void> {
|
||
await this.ensureInitialized()
|
||
|
||
const fromEntityId = await this.pathResolver.resolve(from)
|
||
const toEntityId = await this.pathResolver.resolve(to)
|
||
|
||
// Create relationship using brain
|
||
await this.brain.relate({
|
||
from: fromEntityId,
|
||
to: toEntityId,
|
||
type: type as any, // Convert string to VerbType
|
||
metadata: { isVFS: true } // Mark as VFS relationship
|
||
})
|
||
|
||
// Invalidate caches for both paths
|
||
this.invalidateCaches(from)
|
||
this.invalidateCaches(to)
|
||
}
|
||
|
||
async removeRelationship(from: string, to: string, type?: string): Promise<void> {
|
||
await this.ensureInitialized()
|
||
|
||
const fromEntityId = await this.pathResolver.resolve(from)
|
||
const toEntityId = await this.pathResolver.resolve(to)
|
||
|
||
// Find and delete the relationship
|
||
const relations = await this.brain.getRelations({ from: fromEntityId })
|
||
for (const relation of relations) {
|
||
if (relation.to === toEntityId && (!type || relation.type === type)) {
|
||
// Delete the relationship using brain.unrelate
|
||
if (relation.id) {
|
||
await this.brain.unrelate(relation.id)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Invalidate caches
|
||
this.invalidateCaches(from)
|
||
this.invalidateCaches(to)
|
||
}
|
||
|
||
async getTodos(path: string): Promise<VFSMetadata['todos']> {
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
return entity.metadata.todos
|
||
}
|
||
|
||
async setTodos(path: string, todos: VFSTodo[]): Promise<void> {
|
||
await this.ensureInitialized()
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
// Update todos in metadata
|
||
await this.brain.update({
|
||
...entity,
|
||
id: entityId,
|
||
metadata: {
|
||
...entity.metadata,
|
||
todos,
|
||
modified: Date.now()
|
||
}
|
||
})
|
||
|
||
// Invalidate caches
|
||
this.invalidateCaches(path)
|
||
}
|
||
|
||
async addTodo(path: string, todo: VFSTodo): Promise<void> {
|
||
await this.ensureInitialized()
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
// Get existing todos
|
||
const todos = entity.metadata.todos || []
|
||
|
||
// Add new todo with ID if not provided
|
||
const newTodo: VFSTodo = {
|
||
id: todo.id || crypto.randomUUID(),
|
||
task: todo.task,
|
||
priority: todo.priority || 'medium',
|
||
status: todo.status || 'pending',
|
||
assignee: todo.assignee,
|
||
due: todo.due
|
||
}
|
||
|
||
todos.push(newTodo)
|
||
|
||
// Update entity metadata
|
||
await this.brain.update({
|
||
id: entityId,
|
||
metadata: {
|
||
...entity.metadata,
|
||
todos
|
||
}
|
||
})
|
||
|
||
// Invalidate caches
|
||
this.invalidateCaches(path)
|
||
}
|
||
|
||
/**
|
||
* Get metadata for a file or directory
|
||
*/
|
||
async getMetadata(path: string): Promise<VFSMetadata | undefined> {
|
||
await this.ensureInitialized()
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
return entity.metadata
|
||
}
|
||
|
||
/**
|
||
* Set custom metadata for a file or directory
|
||
* Merges with existing metadata
|
||
*/
|
||
async setMetadata(path: string, metadata: Partial<VFSMetadata>): Promise<void> {
|
||
await this.ensureInitialized()
|
||
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
// Merge with existing metadata
|
||
await this.brain.update({
|
||
id: entityId,
|
||
metadata: {
|
||
...entity.metadata,
|
||
...metadata,
|
||
modified: Date.now()
|
||
}
|
||
})
|
||
|
||
// Invalidate caches
|
||
this.invalidateCaches(path)
|
||
}
|
||
|
||
|
||
/**
|
||
* Set the current user for tracking who makes changes
|
||
*/
|
||
setUser(username: string): void {
|
||
this.currentUser = username || 'system'
|
||
}
|
||
|
||
/**
|
||
* Get the current user
|
||
*/
|
||
getCurrentUser(): string {
|
||
return this.currentUser
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* Search for entities with filters
|
||
*/
|
||
async searchEntities(query: {
|
||
type?: string
|
||
name?: string
|
||
where?: Record<string, any>
|
||
limit?: number
|
||
}): Promise<Array<{
|
||
id: string
|
||
path: string
|
||
type: string
|
||
metadata: any
|
||
}>> {
|
||
await this.ensureInitialized()
|
||
|
||
// Build query for brain.find()
|
||
const searchQuery: any = {
|
||
where: {
|
||
...query.where,
|
||
vfsType: 'entity'
|
||
},
|
||
limit: query.limit || 100
|
||
}
|
||
|
||
if (query.type) {
|
||
searchQuery.where.entityType = query.type
|
||
}
|
||
|
||
if (query.name) {
|
||
searchQuery.query = query.name
|
||
}
|
||
|
||
const results = await this.brain.find(searchQuery)
|
||
|
||
return results.map(result => ({
|
||
id: result.id,
|
||
path: result.entity?.metadata?.path || '',
|
||
type: result.entity?.metadata?.type || result.entity?.metadata?.entityType || 'unknown',
|
||
metadata: result.entity?.metadata || {}
|
||
}))
|
||
}
|
||
|
||
/**
|
||
* Sort bulk operations to prevent race conditions
|
||
*
|
||
* Strategy:
|
||
* 1. mkdir operations first, sorted by path depth (shallowest first)
|
||
* 2. Other operations (write, delete, update) after, in original order
|
||
*
|
||
* This ensures parent directories exist before files are written,
|
||
* preventing duplicate entity creation from concurrent mkdir calls.
|
||
*/
|
||
private sortBulkOperations(operations: Array<{
|
||
type: 'write' | 'delete' | 'mkdir' | 'update'
|
||
path: string
|
||
data?: Buffer | string
|
||
options?: any
|
||
}>): Array<typeof operations[number]> {
|
||
const mkdirOps: typeof operations = []
|
||
const otherOps: typeof operations = []
|
||
|
||
for (const op of operations) {
|
||
if (op.type === 'mkdir') {
|
||
mkdirOps.push(op)
|
||
} else {
|
||
otherOps.push(op)
|
||
}
|
||
}
|
||
|
||
// Sort mkdir by path depth (shallowest first)
|
||
mkdirOps.sort((a, b) => {
|
||
const depthA = (a.path.match(/\//g) || []).length
|
||
const depthB = (b.path.match(/\//g) || []).length
|
||
return depthA !== depthB ? depthA - depthB : a.path.localeCompare(b.path)
|
||
})
|
||
|
||
return [...mkdirOps, ...otherOps]
|
||
}
|
||
|
||
/**
|
||
* Bulk write operations for performance
|
||
*
|
||
* Prevents race condition by processing mkdir operations
|
||
* sequentially before parallel batch processing of other operations.
|
||
*/
|
||
async bulkWrite(operations: Array<{
|
||
type: 'write' | 'delete' | 'mkdir' | 'update'
|
||
path: string
|
||
data?: Buffer | string
|
||
options?: any
|
||
}>): Promise<{
|
||
successful: number
|
||
failed: Array<{ operation: any, error: string }>
|
||
}> {
|
||
await this.ensureInitialized()
|
||
|
||
const result = {
|
||
successful: 0,
|
||
failed: [] as Array<{ operation: any, error: string }>
|
||
}
|
||
|
||
// Sort operations: mkdirs first (by depth), then others
|
||
const sortedOps = this.sortBulkOperations(operations)
|
||
|
||
// Separate mkdir operations for sequential processing
|
||
const mkdirOps = sortedOps.filter(op => op.type === 'mkdir')
|
||
const otherOps = sortedOps.filter(op => op.type !== 'mkdir')
|
||
|
||
// Phase 1: Process mkdir operations SEQUENTIALLY
|
||
// This prevents the race condition where parallel mkdir calls
|
||
// create duplicate directory entities due to mutex timing window
|
||
for (const op of mkdirOps) {
|
||
try {
|
||
await this.mkdir(op.path, op.options)
|
||
result.successful++
|
||
} catch (error: any) {
|
||
result.failed.push({
|
||
operation: op,
|
||
error: error.message || 'Unknown error'
|
||
})
|
||
}
|
||
}
|
||
|
||
// Phase 2: Process other operations in parallel batches
|
||
// These can safely run in parallel since parent directories now exist
|
||
const batchSize = 10
|
||
for (let i = 0; i < otherOps.length; i += batchSize) {
|
||
const batch = otherOps.slice(i, i + batchSize)
|
||
|
||
// Process batch in parallel
|
||
const promises = batch.map(async (op) => {
|
||
try {
|
||
switch (op.type) {
|
||
case 'write':
|
||
await this.writeFile(op.path, op.data || '', op.options)
|
||
break
|
||
case 'delete':
|
||
await this.unlink(op.path)
|
||
break
|
||
case 'update': {
|
||
// Update only metadata without changing content
|
||
const entityId = await this.pathResolver.resolve(op.path)
|
||
await this.brain.update({
|
||
id: entityId,
|
||
metadata: op.options?.metadata
|
||
})
|
||
break
|
||
}
|
||
}
|
||
result.successful++
|
||
} catch (error: any) {
|
||
result.failed.push({
|
||
operation: op,
|
||
error: error.message || 'Unknown error'
|
||
})
|
||
}
|
||
})
|
||
|
||
await Promise.all(promises)
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* Calculate disk usage for a path (POSIX du command)
|
||
* Returns total bytes used by files in directory tree
|
||
*
|
||
* @param path - Path to calculate usage for
|
||
* @param options - Options including maxDepth for safety
|
||
*/
|
||
async du(path: string = '/', options?: {
|
||
maxDepth?: number
|
||
humanReadable?: boolean
|
||
}): Promise<{
|
||
bytes: number
|
||
files: number
|
||
directories: number
|
||
formatted?: string
|
||
}> {
|
||
await this.ensureInitialized()
|
||
|
||
const maxDepth = options?.maxDepth ?? 100 // Safety limit
|
||
let totalBytes = 0
|
||
let fileCount = 0
|
||
let dirCount = 0
|
||
|
||
const traverse = async (currentPath: string, depth: number) => {
|
||
if (depth > maxDepth) {
|
||
throw new Error(`Maximum depth ${maxDepth} exceeded. Use maxDepth option to increase limit.`)
|
||
}
|
||
|
||
try {
|
||
const entityId = await this.pathResolver.resolve(currentPath)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
if (entity.metadata.vfsType === 'directory') {
|
||
dirCount++
|
||
const children = await this.readdir(currentPath)
|
||
for (const child of children) {
|
||
const childPath = currentPath === '/' ? `/${child}` : `${currentPath}/${child}`
|
||
await traverse(childPath, depth + 1)
|
||
}
|
||
} else if (entity.metadata.vfsType === 'file') {
|
||
fileCount++
|
||
totalBytes += entity.metadata.size || 0
|
||
}
|
||
} catch (error) {
|
||
// Skip inaccessible paths
|
||
}
|
||
}
|
||
|
||
await traverse(path, 0)
|
||
|
||
const result: any = {
|
||
bytes: totalBytes,
|
||
files: fileCount,
|
||
directories: dirCount
|
||
}
|
||
|
||
if (options?.humanReadable) {
|
||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||
let size = totalBytes
|
||
let unitIndex = 0
|
||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||
size /= 1024
|
||
unitIndex++
|
||
}
|
||
result.formatted = `${size.toFixed(2)} ${units[unitIndex]}`
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* Check file access permissions (POSIX access command)
|
||
* Verifies if path exists and is accessible with specified mode
|
||
*
|
||
* @param path - Path to check
|
||
* @param mode - Access mode: 'r' (read), 'w' (write), 'x' (execute), or 'f' (exists only)
|
||
*/
|
||
async access(path: string, mode: 'r' | 'w' | 'x' | 'f' = 'f'): Promise<boolean> {
|
||
await this.ensureInitialized()
|
||
|
||
try {
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
// Path exists
|
||
if (mode === 'f') {
|
||
return true
|
||
}
|
||
|
||
// Check permissions based on mode
|
||
const permissions = entity.metadata.permissions || 0o644
|
||
|
||
switch (mode) {
|
||
case 'r':
|
||
// Check read permission (owner, group, or other)
|
||
return (permissions & 0o444) !== 0
|
||
case 'w':
|
||
// Check write permission
|
||
return (permissions & 0o222) !== 0
|
||
case 'x':
|
||
// Check execute permission (only meaningful for directories)
|
||
return entity.metadata.vfsType === 'directory' || (permissions & 0o111) !== 0
|
||
default:
|
||
return false
|
||
}
|
||
} catch (error) {
|
||
// Path doesn't exist or not accessible
|
||
return false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Find files matching patterns (Unix find command)
|
||
* Pattern-based file search (complements semantic search())
|
||
*
|
||
* @param path - Starting path for search
|
||
* @param options - Search options including pattern matching
|
||
*/
|
||
async find(path: string = '/', options?: {
|
||
name?: string | RegExp
|
||
type?: 'file' | 'directory' | 'both'
|
||
maxDepth?: number
|
||
minSize?: number
|
||
maxSize?: number
|
||
modified?: { after?: Date, before?: Date }
|
||
limit?: number
|
||
}): Promise<Array<{
|
||
path: string
|
||
type: 'file' | 'directory'
|
||
size?: number
|
||
modified?: Date
|
||
}>> {
|
||
await this.ensureInitialized()
|
||
|
||
const maxDepth = options?.maxDepth ?? 100 // Safety limit
|
||
const limit = options?.limit ?? 1000 // Prevent unbounded results
|
||
const results: Array<{
|
||
path: string
|
||
type: 'file' | 'directory'
|
||
size?: number
|
||
modified?: Date
|
||
}> = []
|
||
|
||
const namePattern = options?.name
|
||
const nameRegex = namePattern instanceof RegExp
|
||
? namePattern
|
||
: namePattern
|
||
? new RegExp(namePattern.replace(/\*/g, '.*').replace(/\?/g, '.'))
|
||
: null
|
||
|
||
const traverse = async (currentPath: string, depth: number) => {
|
||
if (depth > maxDepth || results.length >= limit) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const entityId = await this.pathResolver.resolve(currentPath)
|
||
const entity = await this.getEntityById(entityId)
|
||
|
||
const vfsType = entity.metadata.vfsType
|
||
const fileName = currentPath.split('/').pop() || ''
|
||
|
||
// Check if this file matches criteria
|
||
let matches = true
|
||
|
||
// Type filter
|
||
if (options?.type && options.type !== 'both') {
|
||
matches = matches && vfsType === options.type
|
||
}
|
||
|
||
// Name pattern filter
|
||
if (nameRegex) {
|
||
matches = matches && nameRegex.test(fileName)
|
||
}
|
||
|
||
// Size filters (files only)
|
||
if (vfsType === 'file') {
|
||
const size = entity.metadata.size || 0
|
||
if (options?.minSize !== undefined) {
|
||
matches = matches && size >= options.minSize
|
||
}
|
||
if (options?.maxSize !== undefined) {
|
||
matches = matches && size <= options.maxSize
|
||
}
|
||
}
|
||
|
||
// Modified time filter
|
||
if (options?.modified && entity.metadata.modified) {
|
||
const modifiedTime = new Date(entity.metadata.modified)
|
||
if (options.modified.after) {
|
||
matches = matches && modifiedTime >= options.modified.after
|
||
}
|
||
if (options.modified.before) {
|
||
matches = matches && modifiedTime <= options.modified.before
|
||
}
|
||
}
|
||
|
||
// Add to results if matches
|
||
if (matches && currentPath !== path) {
|
||
results.push({
|
||
path: currentPath,
|
||
type: vfsType as 'file' | 'directory',
|
||
size: entity.metadata.size,
|
||
modified: entity.metadata.modified ? new Date(entity.metadata.modified) : undefined
|
||
})
|
||
}
|
||
|
||
// Recurse into directories
|
||
if (vfsType === 'directory' && results.length < limit) {
|
||
const children = await this.readdir(currentPath)
|
||
for (const child of children) {
|
||
if (results.length >= limit) break
|
||
const childPath = currentPath === '/' ? `/${child}` : `${currentPath}/${child}`
|
||
await traverse(childPath, depth + 1)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// Skip inaccessible paths
|
||
}
|
||
}
|
||
|
||
await traverse(path, 0)
|
||
return results
|
||
}
|
||
|
||
createReadStream(path: string, options?: ReadStreamOptions): NodeJS.ReadableStream {
|
||
// Lazy import to avoid circular dependencies
|
||
const { VFSReadStream } = require('./streams/VFSReadStream.js')
|
||
return new VFSReadStream(this, path, options)
|
||
}
|
||
|
||
createWriteStream(path: string, options?: WriteStreamOptions): NodeJS.WritableStream {
|
||
// Lazy import to avoid circular dependencies
|
||
const { VFSWriteStream } = require('./streams/VFSWriteStream.js')
|
||
return new VFSWriteStream(this, path, options)
|
||
}
|
||
|
||
watch(path: string, listener: WatchListener): { close(): void } {
|
||
if (!this.watchers.has(path)) {
|
||
this.watchers.set(path, new Set())
|
||
}
|
||
this.watchers.get(path)!.add(listener)
|
||
|
||
return {
|
||
close: () => {
|
||
const watchers = this.watchers.get(path)
|
||
if (watchers) {
|
||
watchers.delete(listener)
|
||
if (watchers.size === 0) {
|
||
this.watchers.delete(path)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============= Import/Export Operations =============
|
||
|
||
/**
|
||
* Import a single file from the real filesystem into VFS
|
||
*/
|
||
async importFile(sourcePath: string, targetPath: string): Promise<void> {
|
||
const fs = await import('fs/promises')
|
||
const pathModule = await import('path')
|
||
|
||
// Read file from local filesystem
|
||
const content = await fs.readFile(sourcePath)
|
||
const stats = await fs.stat(sourcePath)
|
||
|
||
// Ensure parent directory exists in VFS
|
||
const parentPath = pathModule.dirname(targetPath)
|
||
if (parentPath !== '/' && parentPath !== '.') {
|
||
try {
|
||
await this.mkdir(parentPath, { recursive: true })
|
||
} catch (error: any) {
|
||
if (error.code !== 'EEXIST') throw error
|
||
}
|
||
}
|
||
|
||
// Write to VFS with metadata from source
|
||
await this.writeFile(targetPath, content, {
|
||
metadata: {
|
||
imported: true,
|
||
importedFrom: sourcePath,
|
||
sourceSize: stats.size,
|
||
sourceMtime: stats.mtime.getTime(),
|
||
sourceMode: stats.mode
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Import a directory from the real filesystem into VFS
|
||
*/
|
||
async importDirectory(sourcePath: string, options?: any): Promise<any> {
|
||
const { DirectoryImporter } = await import('./importers/DirectoryImporter.js')
|
||
const importer = new DirectoryImporter(this, this.brain)
|
||
return await importer.import(sourcePath, options)
|
||
}
|
||
|
||
/**
|
||
* Import a directory with progress tracking
|
||
*/
|
||
async *importStream(sourcePath: string, options?: any): AsyncGenerator<any> {
|
||
const { DirectoryImporter } = await import('./importers/DirectoryImporter.js')
|
||
const importer = new DirectoryImporter(this, this.brain)
|
||
yield* importer.importStream(sourcePath, options)
|
||
}
|
||
|
||
watchFile(path: string, listener: WatchListener): void {
|
||
this.watch(path, listener)
|
||
}
|
||
|
||
unwatchFile(path: string): void {
|
||
this.watchers.delete(path)
|
||
}
|
||
|
||
async getEntity(path: string): Promise<VFSEntity> {
|
||
const entityId = await this.pathResolver.resolve(path)
|
||
return this.getEntityById(entityId)
|
||
}
|
||
|
||
/**
|
||
* Resolve a path to its normalized form
|
||
* Returns the normalized absolute path (e.g., '/foo/bar/file.txt')
|
||
*/
|
||
async resolvePath(path: string, from?: string): Promise<string> {
|
||
// Handle relative paths
|
||
if (!path.startsWith('/') && from) {
|
||
path = `${from}/${path}`
|
||
}
|
||
|
||
// Normalize path: remove multiple slashes, trailing slashes
|
||
return path.replace(/\/+/g, '/').replace(/\/$/, '') || '/'
|
||
}
|
||
|
||
/**
|
||
* Resolve a path to its entity ID
|
||
* Returns the UUID of the entity representing this path
|
||
*/
|
||
async resolvePathToId(path: string, from?: string): Promise<string> {
|
||
// Handle relative paths
|
||
if (!path.startsWith('/') && from) {
|
||
path = `${from}/${path}`
|
||
}
|
||
|
||
// Normalize path
|
||
const normalizedPath = path.replace(/\/+/g, '/').replace(/\/$/, '') || '/'
|
||
|
||
// Special case for root
|
||
if (normalizedPath === '/') {
|
||
return this.rootEntityId!
|
||
}
|
||
|
||
// Resolve the path to an entity ID
|
||
return await this.pathResolver.resolve(normalizedPath)
|
||
}
|
||
} |