- Replace metadata path queries with brain.getRelations() API - Use graph traversal for parent-child relationships via VerbType.Contains - Remove fallback metadata queries - trust graph relationships completely - Fix getChildren() to properly query relationship graph - Update getRelated() and getRelationships() to use native Brainy APIs This enables full graph benefits: indexes, optimizations, and visualizations now work correctly with VFS. The filesystem structure is now a true graph using Brainy's native relationship system. Co-Authored-By: Claude <noreply@anthropic.com>
456 lines
No EOL
12 KiB
TypeScript
456 lines
No EOL
12 KiB
TypeScript
/**
|
|
* Path Resolution System with High-Performance Caching
|
|
*
|
|
* PRODUCTION-READY path resolution for VFS
|
|
* Handles millions of paths efficiently with multi-layer caching
|
|
*/
|
|
|
|
import { Brainy } from '../brainy.js'
|
|
import { VerbType, NounType } from '../types/graphTypes.js'
|
|
import { VFSEntity, VFSError, VFSErrorCode } from './types.js'
|
|
|
|
/**
|
|
* Path cache entry
|
|
*/
|
|
interface PathCacheEntry {
|
|
entityId: string
|
|
timestamp: number
|
|
hits: number // Track hot paths
|
|
}
|
|
|
|
/**
|
|
* High-performance path resolver with intelligent caching
|
|
*/
|
|
export class PathResolver {
|
|
private brain: Brainy
|
|
private rootEntityId: string
|
|
|
|
// Multi-layer cache system
|
|
private pathCache: Map<string, PathCacheEntry>
|
|
private parentCache: Map<string, Set<string>> // parent ID -> child names
|
|
private hotPaths: Set<string> // Frequently accessed paths
|
|
|
|
// Cache configuration
|
|
private readonly maxCacheSize: number
|
|
private readonly cacheTTL: number
|
|
private readonly hotPathThreshold: number
|
|
|
|
// Statistics
|
|
private cacheHits = 0
|
|
private cacheMisses = 0
|
|
|
|
// Maintenance timer
|
|
private maintenanceTimer: NodeJS.Timeout | null = null
|
|
|
|
constructor(brain: Brainy, rootEntityId: string, config?: {
|
|
maxCacheSize?: number
|
|
cacheTTL?: number
|
|
hotPathThreshold?: number
|
|
}) {
|
|
this.brain = brain
|
|
this.rootEntityId = rootEntityId
|
|
|
|
// Initialize caches
|
|
this.pathCache = new Map()
|
|
this.parentCache = new Map()
|
|
this.hotPaths = new Set()
|
|
|
|
// Configure cache
|
|
this.maxCacheSize = config?.maxCacheSize || 100_000
|
|
this.cacheTTL = config?.cacheTTL || 5 * 60 * 1000 // 5 minutes
|
|
this.hotPathThreshold = config?.hotPathThreshold || 10
|
|
|
|
// Start cache maintenance
|
|
this.startCacheMaintenance()
|
|
}
|
|
|
|
/**
|
|
* Resolve a path to an entity ID
|
|
* Uses multi-layer caching for optimal performance
|
|
*/
|
|
async resolve(path: string, options?: {
|
|
followSymlinks?: boolean
|
|
cache?: boolean
|
|
}): Promise<string> {
|
|
// Normalize path
|
|
const normalizedPath = this.normalizePath(path)
|
|
|
|
// Handle root
|
|
if (normalizedPath === '/') {
|
|
return this.rootEntityId
|
|
}
|
|
|
|
// Check L1 cache (hot paths)
|
|
if (options?.cache !== false && this.hotPaths.has(normalizedPath)) {
|
|
const cached = this.pathCache.get(normalizedPath)
|
|
if (cached && this.isCacheValid(cached)) {
|
|
this.cacheHits++
|
|
cached.hits++
|
|
return cached.entityId
|
|
}
|
|
}
|
|
|
|
// Check L2 cache (regular cache)
|
|
if (options?.cache !== false && this.pathCache.has(normalizedPath)) {
|
|
const cached = this.pathCache.get(normalizedPath)!
|
|
if (this.isCacheValid(cached)) {
|
|
this.cacheHits++
|
|
cached.hits++
|
|
|
|
// Promote to hot path if accessed frequently
|
|
if (cached.hits >= this.hotPathThreshold) {
|
|
this.hotPaths.add(normalizedPath)
|
|
}
|
|
|
|
return cached.entityId
|
|
} else {
|
|
// Remove stale entry
|
|
this.pathCache.delete(normalizedPath)
|
|
}
|
|
}
|
|
|
|
this.cacheMisses++
|
|
|
|
// Try to resolve using parent cache
|
|
const parentPath = this.getParentPath(normalizedPath)
|
|
const name = this.getBasename(normalizedPath)
|
|
|
|
if (parentPath && this.pathCache.has(parentPath)) {
|
|
const parentCached = this.pathCache.get(parentPath)!
|
|
if (this.isCacheValid(parentCached)) {
|
|
// We have the parent, just need to find the child
|
|
const entityId = await this.resolveChild(parentCached.entityId, name)
|
|
if (entityId) {
|
|
this.cachePathEntry(normalizedPath, entityId)
|
|
return entityId
|
|
}
|
|
}
|
|
}
|
|
|
|
// Full resolution required
|
|
const entityId = await this.fullResolve(normalizedPath, options)
|
|
|
|
// Cache the result
|
|
this.cachePathEntry(normalizedPath, entityId)
|
|
|
|
return entityId
|
|
}
|
|
|
|
/**
|
|
* Full path resolution by traversing the graph
|
|
*/
|
|
private async fullResolve(path: string, options?: {
|
|
followSymlinks?: boolean
|
|
}): Promise<string> {
|
|
const parts = this.splitPath(path)
|
|
let currentId = this.rootEntityId
|
|
let currentPath = '/'
|
|
|
|
for (const part of parts) {
|
|
if (!part) continue // Skip empty parts
|
|
|
|
// Find child with matching name
|
|
const childId = await this.resolveChild(currentId, part)
|
|
|
|
if (!childId) {
|
|
throw new VFSError(
|
|
VFSErrorCode.ENOENT,
|
|
`No such file or directory: ${path}`,
|
|
path,
|
|
'resolve'
|
|
)
|
|
}
|
|
|
|
currentPath = this.joinPath(currentPath, part)
|
|
currentId = childId
|
|
|
|
// Cache intermediate paths
|
|
this.cachePathEntry(currentPath, currentId)
|
|
|
|
// Handle symlinks if needed
|
|
if (options?.followSymlinks) {
|
|
const entity = await this.getEntity(currentId)
|
|
if (entity.metadata.vfsType === 'symlink') {
|
|
// Resolve symlink target
|
|
const target = entity.metadata.attributes?.target
|
|
if (target) {
|
|
currentId = await this.resolve(target, options)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return currentId
|
|
}
|
|
|
|
/**
|
|
* Resolve a child entity by name within a parent directory
|
|
* Uses proper graph relationships instead of metadata queries
|
|
*/
|
|
private async resolveChild(parentId: string, name: string): Promise<string | null> {
|
|
// Check parent cache first
|
|
const cachedChildren = this.parentCache.get(parentId)
|
|
if (cachedChildren && cachedChildren.has(name)) {
|
|
// Use cached knowledge to quickly find the child
|
|
// Still need to verify it exists
|
|
}
|
|
|
|
// Use proper graph traversal to find children
|
|
// Get all relationships where parentId contains other entities
|
|
const relations = await this.brain.getRelations({
|
|
from: parentId,
|
|
type: VerbType.Contains
|
|
})
|
|
|
|
// Find the child with matching name
|
|
for (const relation of relations) {
|
|
const childEntity = await this.brain.get(relation.to)
|
|
if (childEntity && childEntity.metadata?.name === name) {
|
|
// Update parent cache
|
|
if (!this.parentCache.has(parentId)) {
|
|
this.parentCache.set(parentId, new Set())
|
|
}
|
|
this.parentCache.get(parentId)!.add(name)
|
|
|
|
return childEntity.id
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Get all children of a directory
|
|
* Uses proper graph relationships to traverse the tree
|
|
*/
|
|
async getChildren(dirId: string): Promise<VFSEntity[]> {
|
|
// Use proper graph API to get all Contains relationships from this directory
|
|
const relations = await this.brain.getRelations({
|
|
from: dirId,
|
|
type: VerbType.Contains
|
|
})
|
|
|
|
const validChildren: VFSEntity[] = []
|
|
const childNames = new Set<string>()
|
|
|
|
// Fetch all child entities
|
|
for (const relation of relations) {
|
|
const entity = await this.brain.get(relation.to)
|
|
if (entity && entity.metadata?.vfsType && entity.metadata?.name) {
|
|
validChildren.push(entity as VFSEntity)
|
|
childNames.add(entity.metadata.name)
|
|
}
|
|
}
|
|
|
|
// Update cache
|
|
this.parentCache.set(dirId, childNames)
|
|
return validChildren
|
|
}
|
|
|
|
/**
|
|
* Create a new path entry (for mkdir/writeFile)
|
|
*/
|
|
async createPath(path: string, entityId: string): Promise<void> {
|
|
const normalizedPath = this.normalizePath(path)
|
|
|
|
// Cache the new path
|
|
this.cachePathEntry(normalizedPath, entityId)
|
|
|
|
// Update parent cache
|
|
const parentPath = this.getParentPath(normalizedPath)
|
|
const name = this.getBasename(normalizedPath)
|
|
|
|
if (parentPath) {
|
|
const parentId = await this.resolve(parentPath)
|
|
if (!this.parentCache.has(parentId)) {
|
|
this.parentCache.set(parentId, new Set())
|
|
}
|
|
this.parentCache.get(parentId)!.add(name)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Invalidate cache entries for a path and its children
|
|
*/
|
|
invalidatePath(path: string, recursive = false): void {
|
|
const normalizedPath = this.normalizePath(path)
|
|
|
|
// Remove from all caches
|
|
this.pathCache.delete(normalizedPath)
|
|
this.hotPaths.delete(normalizedPath)
|
|
|
|
if (recursive) {
|
|
// Remove all paths that start with this path
|
|
const prefix = normalizedPath.endsWith('/') ? normalizedPath : normalizedPath + '/'
|
|
|
|
for (const [cachedPath] of this.pathCache) {
|
|
if (cachedPath.startsWith(prefix)) {
|
|
this.pathCache.delete(cachedPath)
|
|
this.hotPaths.delete(cachedPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear parent cache for the entity
|
|
const cached = this.pathCache.get(normalizedPath)
|
|
if (cached) {
|
|
this.parentCache.delete(cached.entityId)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cache a path entry
|
|
*/
|
|
private cachePathEntry(path: string, entityId: string): void {
|
|
// Evict old entries if cache is full
|
|
if (this.pathCache.size >= this.maxCacheSize) {
|
|
this.evictOldEntries()
|
|
}
|
|
|
|
const existing = this.pathCache.get(path)
|
|
this.pathCache.set(path, {
|
|
entityId,
|
|
timestamp: Date.now(),
|
|
hits: existing?.hits || 0
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Check if a cache entry is still valid
|
|
*/
|
|
private isCacheValid(entry: PathCacheEntry): boolean {
|
|
return (Date.now() - entry.timestamp) < this.cacheTTL
|
|
}
|
|
|
|
/**
|
|
* Evict old cache entries (LRU with TTL)
|
|
*/
|
|
private evictOldEntries(): void {
|
|
const now = Date.now()
|
|
const entries = Array.from(this.pathCache.entries())
|
|
|
|
// Sort by least recently used (combination of timestamp and hits)
|
|
entries.sort((a, b) => {
|
|
const scoreA = a[1].timestamp + (a[1].hits * 60000) // Boost for hits
|
|
const scoreB = b[1].timestamp + (b[1].hits * 60000)
|
|
return scoreA - scoreB
|
|
})
|
|
|
|
// Remove 10% of cache
|
|
const toRemove = Math.floor(this.maxCacheSize * 0.1)
|
|
for (let i = 0; i < toRemove && i < entries.length; i++) {
|
|
const [path] = entries[i]
|
|
this.pathCache.delete(path)
|
|
this.hotPaths.delete(path)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start periodic cache maintenance
|
|
*/
|
|
private startCacheMaintenance(): void {
|
|
this.maintenanceTimer = setInterval(() => {
|
|
// Clean up expired entries
|
|
const now = Date.now()
|
|
for (const [path, entry] of this.pathCache) {
|
|
if (!this.isCacheValid(entry)) {
|
|
this.pathCache.delete(path)
|
|
this.hotPaths.delete(path)
|
|
}
|
|
}
|
|
|
|
// Log cache statistics (in production, send to monitoring)
|
|
const hitRate = this.cacheHits / (this.cacheHits + this.cacheMisses)
|
|
if ((this.cacheHits + this.cacheMisses) % 1000 === 0) {
|
|
console.log(`[PathResolver] Cache stats: ${Math.round(hitRate * 100)}% hit rate, ${this.pathCache.size} entries, ${this.hotPaths.size} hot paths`)
|
|
}
|
|
}, 60000) // Every minute
|
|
}
|
|
|
|
/**
|
|
* Get entity by ID
|
|
*/
|
|
private async getEntity(entityId: string): Promise<VFSEntity> {
|
|
const entity = await this.brain.get(entityId)
|
|
|
|
if (!entity) {
|
|
throw new VFSError(
|
|
VFSErrorCode.ENOENT,
|
|
`Entity not found: ${entityId}`,
|
|
undefined,
|
|
'getEntity'
|
|
)
|
|
}
|
|
|
|
return entity as VFSEntity
|
|
}
|
|
|
|
// ============= Path Utilities =============
|
|
|
|
private normalizePath(path: string): string {
|
|
// Remove multiple slashes, trailing slashes (except for root)
|
|
let normalized = path.replace(/\/+/g, '/')
|
|
if (normalized.length > 1 && normalized.endsWith('/')) {
|
|
normalized = normalized.slice(0, -1)
|
|
}
|
|
return normalized || '/'
|
|
}
|
|
|
|
private splitPath(path: string): string[] {
|
|
return this.normalizePath(path).split('/').filter(Boolean)
|
|
}
|
|
|
|
private joinPath(parent: string, child: string): string {
|
|
if (parent === '/') return `/${child}`
|
|
return `${parent}/${child}`
|
|
}
|
|
|
|
private getParentPath(path: string): string | null {
|
|
const normalized = this.normalizePath(path)
|
|
if (normalized === '/') return null
|
|
|
|
const lastSlash = normalized.lastIndexOf('/')
|
|
if (lastSlash === 0) return '/'
|
|
return normalized.substring(0, lastSlash)
|
|
}
|
|
|
|
private getBasename(path: string): string {
|
|
const normalized = this.normalizePath(path)
|
|
if (normalized === '/') return ''
|
|
|
|
const lastSlash = normalized.lastIndexOf('/')
|
|
return normalized.substring(lastSlash + 1)
|
|
}
|
|
|
|
/**
|
|
* Cleanup resources
|
|
*/
|
|
cleanup(): void {
|
|
if (this.maintenanceTimer) {
|
|
clearInterval(this.maintenanceTimer)
|
|
this.maintenanceTimer = null
|
|
}
|
|
this.pathCache.clear()
|
|
this.parentCache.clear()
|
|
this.hotPaths.clear()
|
|
}
|
|
|
|
/**
|
|
* Get cache statistics
|
|
*/
|
|
getStats(): {
|
|
cacheSize: number
|
|
hotPaths: number
|
|
hitRate: number
|
|
hits: number
|
|
misses: number
|
|
} {
|
|
return {
|
|
cacheSize: this.pathCache.size,
|
|
hotPaths: this.hotPaths.size,
|
|
hitRate: this.cacheHits / (this.cacheHits + this.cacheMisses),
|
|
hits: this.cacheHits,
|
|
misses: this.cacheMisses
|
|
}
|
|
}
|
|
} |