fix(vfs): resolve two critical VFS bugs causing directory listing corruption

Bug 1: verbCountsByType optimization skipping requested verb types
- After restart, stale statistics could cause VerbType.Contains to be skipped
- readdir() would return empty/incomplete results
- Fixed by never skipping verb types explicitly requested in filter
- Added fast path for sourceId + verbType combo (common VFS pattern)
- Save statistics on first entity of each type (not just every 100th)

Bug 2: UnifiedCache not invalidated on path deletion
- rmdir() cleared local caches but NOT the global UnifiedCache
- When folder recreated, resolve() returned stale entity ID
- Caused "Source entity not found" errors
- Fixed by adding deleteByPrefix() to UnifiedCache
- Fixed invalidatePath() to also clear UnifiedCache entries

Files modified:
- src/storage/baseStorage.ts (verbCountsByType fix + fast path)
- src/utils/unifiedCache.ts (deleteByPrefix method)
- src/vfs/PathResolver.ts (cache invalidation fix)

Tests added:
- tests/unit/storage/vfs-mkdir-bug.test.ts (7 tests)

Reported by: Soulcraft Workshop team

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
David Snelling 2025-12-04 11:22:30 -08:00
parent 1da6048245
commit 2ba69eccdc
4 changed files with 375 additions and 13 deletions

View file

@ -1582,6 +1582,52 @@ export abstract class BaseStorage extends BaseStorageAdapter {
nextCursor
}
}
// v6.2.9: Fast path for SINGLE sourceId + verbType combo (common VFS pattern)
// This avoids the slow type-iteration fallback for VFS operations
// NOTE: Only use fast path for single sourceId to avoid incomplete results
const isSingleSourceId = options.filter.sourceId &&
!Array.isArray(options.filter.sourceId)
if (
isSingleSourceId &&
options.filter.verbType &&
!options.filter.targetId &&
!options.filter.service &&
!options.filter.metadata
) {
const sourceId = options.filter.sourceId as string
const verbTypes = Array.isArray(options.filter.verbType)
? options.filter.verbType
: [options.filter.verbType]
prodLog.debug(`[BaseStorage] getVerbs: Using fast path for sourceId=${sourceId}, verbTypes=${verbTypes.join(',')}`)
// Get verbs by source (uses GraphAdjacencyIndex if available)
const verbsBySource = await this.getVerbsBySource_internal(sourceId)
// Filter by verbType in memory (fast - usually small number of verbs per source)
const filtered = verbsBySource.filter(v => verbTypes.includes(v.verb))
// Apply pagination
const paginatedVerbs = filtered.slice(offset, offset + limit)
const hasMore = offset + limit < filtered.length
// Set next cursor if there are more items
let nextCursor: string | undefined = undefined
if (hasMore && paginatedVerbs.length > 0) {
const lastItem = paginatedVerbs[paginatedVerbs.length - 1]
nextCursor = lastItem.id
}
prodLog.debug(`[BaseStorage] getVerbs: Fast path returned ${filtered.length} verbs (${paginatedVerbs.length} after pagination)`)
return {
items: paginatedVerbs,
totalCount: filtered.length,
hasMore,
nextCursor
}
}
}
// For more complex filtering or no filtering, use a paginated approach
@ -1656,15 +1702,39 @@ export abstract class BaseStorage extends BaseStorageAdapter {
const totalVerbCountFromArray = this.verbCountsByType.reduce((sum, c) => sum + c, 0)
const useOptimization = totalVerbCountFromArray > 0
// v6.2.9 BUG FIX: Pre-compute requested verb types to avoid skipping them
// When a specific verbType filter is provided, we MUST check that type
// even if verbCountsByType shows 0 (counts can be stale after restart)
const requestedVerbTypes = options?.filter?.verbType
const requestedVerbTypesSet = requestedVerbTypes
? new Set(Array.isArray(requestedVerbTypes) ? requestedVerbTypes : [requestedVerbTypes])
: null
// Iterate through all 127 verb types (Stage 3 CANONICAL) with early termination
// OPTIMIZATION: Skip types with zero count (only if counts are reliable)
for (let i = 0; i < VERB_TYPE_COUNT && collectedVerbs.length < targetCount; i++) {
// Skip empty types for performance (but only if optimization is enabled)
if (useOptimization && this.verbCountsByType[i] === 0) {
const type = TypeUtils.getVerbFromIndex(i)
// v6.2.9 FIX: Never skip a type that's explicitly requested in the filter
// This fixes VFS bug where Contains relationships were skipped after restart
// when verbCountsByType[Contains] was 0 due to stale statistics
const isRequestedType = requestedVerbTypesSet?.has(type) ?? false
const countIsZero = this.verbCountsByType[i] === 0
// Skip empty types for performance (but only if optimization is enabled AND not requested)
if (useOptimization && countIsZero && !isRequestedType) {
continue
}
const type = TypeUtils.getVerbFromIndex(i)
// v6.2.9: Log when we DON'T skip a requested type that would have been skipped
// This helps diagnose stale statistics issues in production
if (useOptimization && countIsZero && isRequestedType) {
prodLog.debug(
`[BaseStorage] getVerbs: NOT skipping type=${type} despite count=0 (type was explicitly requested). ` +
`Statistics may be stale - consider running rebuildTypeCounts().`
)
}
try {
const verbsOfType = await this.getVerbsByType_internal(type)
@ -2650,8 +2720,11 @@ export abstract class BaseStorage extends BaseStorageAdapter {
// COW-aware write (v5.0.1): Use COW helper for branch isolation
await this.writeObjectToBranch(path, noun)
// Periodically save statistics (every 100 saves)
if (this.nounCountsByType[typeIndex] % 100 === 0) {
// Periodically save statistics
// v6.2.9: Also save on first noun of each type to ensure low-count types are tracked
const shouldSave = this.nounCountsByType[typeIndex] === 1 || // First noun of type
this.nounCountsByType[typeIndex] % 100 === 0 // Every 100th
if (shouldSave) {
await this.saveTypeStatistics()
}
}
@ -2773,7 +2846,11 @@ export abstract class BaseStorage extends BaseStorageAdapter {
}
// Periodically save statistics
if (this.verbCountsByType[typeIndex] % 100 === 0) {
// v6.2.9: Also save on first verb of each type to ensure low-count types are tracked
// This prevents stale statistics after restart for types with < 100 verbs (common for VFS)
const shouldSave = this.verbCountsByType[typeIndex] === 1 || // First verb of type
this.verbCountsByType[typeIndex] % 100 === 0 // Every 100th
if (shouldSave) {
await this.saveTypeStatistics()
}
}

View file

@ -415,6 +415,24 @@ export class UnifiedCache {
return false
}
/**
* Delete all items with keys starting with the given prefix
* v6.2.9: Added for VFS cache invalidation (fixes stale parent ID bug)
* @param prefix - The key prefix to match
* @returns Number of items deleted
*/
deleteByPrefix(prefix: string): number {
let deleted = 0
for (const [key, item] of this.cache) {
if (key.startsWith(prefix)) {
this.currentSize -= item.size
this.cache.delete(key)
deleted++
}
}
return deleted
}
/**
* Clear cache or specific type
*/

View file

@ -345,30 +345,44 @@ export class PathResolver {
/**
* Invalidate cache entries for a path and its children
* v6.2.9 FIX: Also invalidates UnifiedCache to prevent stale entity IDs
* This fixes the "Source entity not found" bug after delete+recreate operations
*/
invalidatePath(path: string, recursive = false): void {
const normalizedPath = this.normalizePath(path)
// Remove from all caches
// v6.2.9 FIX: Clear parent cache BEFORE deleting from pathCache
// (we need the entityId from the cache entry)
const cached = this.pathCache.get(normalizedPath)
if (cached) {
this.parentCache.delete(cached.entityId)
}
// Remove from local caches
this.pathCache.delete(normalizedPath)
this.hotPaths.delete(normalizedPath)
// v6.2.9 CRITICAL FIX: Also invalidate UnifiedCache (global LRU cache)
// This was missing before, causing stale entity IDs to be returned after delete
const cacheKey = `vfs:path:${normalizedPath}`
getGlobalCache().delete(cacheKey)
if (recursive) {
// Remove all paths that start with this path
const prefix = normalizedPath.endsWith('/') ? normalizedPath : normalizedPath + '/'
for (const [cachedPath] of this.pathCache) {
for (const [cachedPath, entry] of this.pathCache) {
if (cachedPath.startsWith(prefix)) {
this.pathCache.delete(cachedPath)
this.hotPaths.delete(cachedPath)
// v6.2.9: Also clear parent cache for this entry
this.parentCache.delete(entry.entityId)
}
}
}
// Clear parent cache for the entity
const cached = this.pathCache.get(normalizedPath)
if (cached) {
this.parentCache.delete(cached.entityId)
// v6.2.9 CRITICAL FIX: Also invalidate UnifiedCache entries with this prefix
const globalCachePrefix = `vfs:path:${prefix}`
getGlobalCache().deleteByPrefix(globalCachePrefix)
}
}