2025-08-26 12:32:21 -07:00
|
|
|
/**
|
|
|
|
|
* Memory Storage Adapter
|
|
|
|
|
* In-memory storage adapter for environments where persistent storage is not available or needed
|
|
|
|
|
*/
|
|
|
|
|
|
2025-10-17 12:29:27 -07:00
|
|
|
import {
|
|
|
|
|
GraphVerb,
|
|
|
|
|
HNSWNoun,
|
|
|
|
|
HNSWVerb,
|
|
|
|
|
NounMetadata,
|
|
|
|
|
VerbMetadata,
|
|
|
|
|
HNSWNounWithMetadata,
|
|
|
|
|
HNSWVerbWithMetadata,
|
fix(storage): v4.8.0 metadata architecture refactoring - FIXES VFS bug
CRITICAL FIX: VFS bug that persisted through v4.5.1-v4.7.4 is NOW FIXED.
Root Cause:
- Storage adapters were not properly extracting standard fields from metadata
- This caused getVerbsBySource_internal() to return 0 relationships despite relationships existing
- VFS PathResolver couldn't navigate directory structure
Solution - Metadata Architecture Refactoring:
1. Move standard fields to top-level of HNSWNounWithMetadata and HNSWVerbWithMetadata
- type, createdAt, updatedAt, confidence, weight, service, data, createdBy
2. Update all 9 storage adapters to extract standard fields from metadata on load
3. Maintain backward compatibility at storage layer (metadata files unchanged)
Changes:
- src/coreTypes.ts: Update HNSWNounWithMetadata and HNSWVerbWithMetadata interfaces
- Add top-level standard fields
- Change data type from unknown to Record<string, any>
- Add confidence field to GraphVerb
- src/storage/baseStorage.ts: Add type cast pattern for standard field extraction
- src/storage/adapters/*.ts: Fix all 9 adapters (memoryStorage, fileSystemStorage, gcsStorage,
s3CompatibleStorage, r2Storage, opfsStorage, azureBlobStorage, typeAwareStorageAdapter)
- Extract standard fields from metadata on load
- Place at top-level of returned entities
- src/api/DataAPI.ts: Read fields from top-level instead of metadata
- src/graph/graphAdjacencyIndex.ts: Convert HNSWVerbWithMetadata to GraphVerb format
- src/utils/metadataIndex.ts: Fix typo (metadata → entityOrMetadata)
- src/types/brainy.types.ts: Add createdBy field to AddParams
- src/types/graphTypes.ts: Add service field to GraphVerb
Test Results:
✅ VFS bug FIXED - vfs.readdir('/') now returns files (was returning empty array)
✅ getVerbsBySource_internal() now returns relationships correctly
✅ Build succeeds with ZERO compilation errors
✅ 95.7% of tests pass (954/997)
Breaking Changes:
- None - backward compatibility maintained at storage layer
Version: 4.8.0
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 15:43:49 -07:00
|
|
|
StatisticsData,
|
|
|
|
|
NounType
|
2025-10-17 12:29:27 -07:00
|
|
|
} from '../../coreTypes.js'
|
2025-08-26 12:32:21 -07:00
|
|
|
import { BaseStorage, STATISTICS_KEY } from '../baseStorage.js'
|
|
|
|
|
import { PaginatedResult } from '../../types/paginationTypes.js'
|
|
|
|
|
|
|
|
|
|
// No type aliases needed - using the original types directly
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* In-memory storage adapter
|
|
|
|
|
* Uses Maps to store data in memory
|
|
|
|
|
*/
|
|
|
|
|
export class MemoryStorage extends BaseStorage {
|
|
|
|
|
// Single map of noun ID to noun
|
|
|
|
|
private nouns: Map<string, HNSWNoun> = new Map()
|
|
|
|
|
private verbs: Map<string, HNSWVerb> = new Map()
|
|
|
|
|
private statistics: StatisticsData | null = null
|
|
|
|
|
|
2025-10-09 13:10:06 -07:00
|
|
|
// Unified object store for primitive operations (replaces metadata, nounMetadata, verbMetadata)
|
|
|
|
|
private objectStore: Map<string, any> = new Map()
|
|
|
|
|
|
|
|
|
|
// Backward compatibility aliases
|
|
|
|
|
private get metadata(): Map<string, any> {
|
|
|
|
|
return this.objectStore
|
|
|
|
|
}
|
|
|
|
|
private get nounMetadata(): Map<string, any> {
|
|
|
|
|
return this.objectStore
|
|
|
|
|
}
|
|
|
|
|
private get verbMetadata(): Map<string, any> {
|
|
|
|
|
return this.objectStore
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
constructor() {
|
|
|
|
|
super()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize the storage adapter
|
|
|
|
|
* Nothing to initialize for in-memory storage
|
|
|
|
|
*/
|
|
|
|
|
public async init(): Promise<void> {
|
|
|
|
|
this.isInitialized = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-17 12:29:27 -07:00
|
|
|
* Save a noun to storage (v4.0.0: pure vector only, no metadata)
|
2025-08-26 12:32:21 -07:00
|
|
|
*/
|
|
|
|
|
protected async saveNoun_internal(noun: HNSWNoun): Promise<void> {
|
2025-09-22 15:45:35 -07:00
|
|
|
const isNew = !this.nouns.has(noun.id)
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
// Create a deep copy to avoid reference issues
|
2025-10-17 12:29:27 -07:00
|
|
|
// v4.0.0: Store ONLY vector data (no metadata field)
|
|
|
|
|
// Metadata is saved separately via saveNounMetadata() by base class
|
2025-08-26 12:32:21 -07:00
|
|
|
const nounCopy: HNSWNoun = {
|
|
|
|
|
id: noun.id,
|
|
|
|
|
vector: [...noun.vector],
|
|
|
|
|
connections: new Map(),
|
2025-10-10 16:25:51 -07:00
|
|
|
level: noun.level || 0
|
2025-10-17 12:29:27 -07:00
|
|
|
// ✅ NO metadata field in v4.0.0
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Copy connections
|
|
|
|
|
for (const [level, connections] of noun.connections.entries()) {
|
|
|
|
|
nounCopy.connections.set(level, new Set(connections))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Save the noun directly in the nouns map
|
|
|
|
|
this.nouns.set(noun.id, nounCopy)
|
2025-09-22 15:45:35 -07:00
|
|
|
|
fix(storage): resolve count synchronization race condition across all storage adapters
Fixed critical bug where entity and relationship counts were not being tracked correctly
during add(), relate(), and import() operations. The root cause was a race condition where
count increment code tried to read metadata before it was saved to storage.
Core Fixes:
- Modified baseStorage.saveNounMetadata_internal to increment counts AFTER metadata is saved
- Modified baseStorage.saveVerbMetadata_internal to increment verb counts AFTER metadata is saved
- Added verb type to VerbMetadata to avoid circular dependency during count tracking
- Refactored verb count methods to prevent mutex deadlocks (synchronous base + async Safe wrapper)
Storage Adapter Cleanup:
- Removed broken count increment code from FileSystemStorage, GcsStorage, R2Storage, AzureBlobStorage
- Updated MemoryStorage comments to reflect centralized fix
- All count tracking now centralized in baseStorage (fixes ALL adapters automatically)
New Utilities:
- Added rebuildCounts utility to repair corrupted counts.json from actual storage data
- Added comprehensive integration tests for count synchronization across all operations
Verification:
- All 8 storage adapters verified (FileSystem, GCS, Memory, S3Compatible, R2, Azure, OPFS, TypeAware)
- All code paths verified (add, relate, import, batch, update, delete)
- 599 tests passing (no regressions)
- No deadlocks (tests complete in 6s vs 150s+)
Fixes #1 and #2 reported by Workshop team
2025-10-21 10:58:44 -07:00
|
|
|
// Count tracking happens in baseStorage.saveNounMetadata_internal (v4.1.2)
|
|
|
|
|
// This fixes the race condition where metadata didn't exist yet
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-17 12:29:27 -07:00
|
|
|
* Get a noun from storage (v4.0.0: returns pure vector only)
|
|
|
|
|
* Base class handles combining with metadata
|
2025-08-26 12:32:21 -07:00
|
|
|
*/
|
|
|
|
|
protected async getNoun_internal(id: string): Promise<HNSWNoun | null> {
|
|
|
|
|
// Get the noun directly from the nouns map
|
|
|
|
|
const noun = this.nouns.get(id)
|
|
|
|
|
|
|
|
|
|
// If not found, return null
|
|
|
|
|
if (!noun) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Return a deep copy to avoid reference issues
|
2025-10-17 12:29:27 -07:00
|
|
|
// v4.0.0: Return ONLY vector data (no metadata field)
|
2025-08-26 12:32:21 -07:00
|
|
|
const nounCopy: HNSWNoun = {
|
|
|
|
|
id: noun.id,
|
|
|
|
|
vector: [...noun.vector],
|
|
|
|
|
connections: new Map(),
|
2025-10-10 16:25:51 -07:00
|
|
|
level: noun.level || 0
|
2025-10-17 12:29:27 -07:00
|
|
|
// ✅ NO metadata field in v4.0.0
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Copy connections
|
|
|
|
|
for (const [level, connections] of noun.connections.entries()) {
|
|
|
|
|
nounCopy.connections.set(level, new Set(connections))
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-17 12:29:27 -07:00
|
|
|
return nounCopy
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get nouns with pagination and filtering
|
2025-10-17 12:29:27 -07:00
|
|
|
* v4.0.0: Returns HNSWNounWithMetadata[] (includes metadata field)
|
2025-08-26 12:32:21 -07:00
|
|
|
* @param options Pagination and filtering options
|
2025-10-17 12:29:27 -07:00
|
|
|
* @returns Promise that resolves to a paginated result of nouns with metadata
|
2025-08-26 12:32:21 -07:00
|
|
|
*/
|
|
|
|
|
public async getNouns(options: {
|
|
|
|
|
pagination?: {
|
|
|
|
|
offset?: number
|
|
|
|
|
limit?: number
|
|
|
|
|
cursor?: string
|
|
|
|
|
}
|
|
|
|
|
filter?: {
|
|
|
|
|
nounType?: string | string[]
|
|
|
|
|
service?: string | string[]
|
|
|
|
|
metadata?: Record<string, any>
|
|
|
|
|
}
|
2025-10-17 12:29:27 -07:00
|
|
|
} = {}): Promise<{ items: HNSWNounWithMetadata[]; totalCount?: number; hasMore: boolean; nextCursor?: string }> {
|
2025-08-26 12:32:21 -07:00
|
|
|
const pagination = options.pagination || {}
|
|
|
|
|
const filter = options.filter || {}
|
|
|
|
|
|
|
|
|
|
// Default values
|
|
|
|
|
const offset = pagination.offset || 0
|
|
|
|
|
const limit = pagination.limit || 100
|
|
|
|
|
|
|
|
|
|
// Convert string types to arrays for consistent handling
|
|
|
|
|
const nounTypes = filter.nounType
|
|
|
|
|
? Array.isArray(filter.nounType) ? filter.nounType : [filter.nounType]
|
|
|
|
|
: undefined
|
|
|
|
|
|
|
|
|
|
const services = filter.service
|
|
|
|
|
? Array.isArray(filter.service) ? filter.service : [filter.service]
|
|
|
|
|
: undefined
|
|
|
|
|
|
|
|
|
|
// First, collect all noun IDs that match the filter criteria
|
|
|
|
|
const matchingIds: string[] = []
|
|
|
|
|
|
|
|
|
|
// Iterate through all nouns to find matches
|
2025-10-17 12:29:27 -07:00
|
|
|
// v4.0.0: Load metadata from separate storage (no embedded metadata field)
|
2025-08-26 12:32:21 -07:00
|
|
|
for (const [nounId, noun] of this.nouns.entries()) {
|
2025-10-17 12:29:27 -07:00
|
|
|
// Get metadata from separate storage
|
|
|
|
|
const metadata = await this.getNounMetadata(nounId)
|
|
|
|
|
|
|
|
|
|
// Skip if no metadata (shouldn't happen in v4.0.0 but be defensive)
|
|
|
|
|
if (!metadata) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
// Filter by noun type if specified
|
2025-09-11 16:23:32 -07:00
|
|
|
if (nounTypes && metadata.noun && !nounTypes.includes(metadata.noun)) {
|
2025-08-26 12:32:21 -07:00
|
|
|
continue
|
|
|
|
|
}
|
2025-10-17 12:29:27 -07:00
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
// Filter by service if specified
|
|
|
|
|
if (services && metadata.service && !services.includes(metadata.service)) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2025-10-17 12:29:27 -07:00
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
// Filter by metadata fields if specified
|
|
|
|
|
if (filter.metadata) {
|
|
|
|
|
let metadataMatch = true
|
|
|
|
|
for (const [key, value] of Object.entries(filter.metadata)) {
|
|
|
|
|
if (metadata[key] !== value) {
|
|
|
|
|
metadataMatch = false
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!metadataMatch) continue
|
|
|
|
|
}
|
2025-10-17 12:29:27 -07:00
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
// If we got here, the noun matches all filters
|
|
|
|
|
matchingIds.push(nounId)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate pagination
|
|
|
|
|
const totalCount = matchingIds.length
|
|
|
|
|
const paginatedIds = matchingIds.slice(offset, offset + limit)
|
|
|
|
|
const hasMore = offset + limit < totalCount
|
|
|
|
|
|
|
|
|
|
// Create cursor for next page if there are more results
|
|
|
|
|
const nextCursor = hasMore ? `${offset + limit}` : undefined
|
|
|
|
|
|
|
|
|
|
// Fetch the actual nouns for the current page
|
2025-10-17 12:29:27 -07:00
|
|
|
// v4.0.0: Return HNSWNounWithMetadata (includes metadata field)
|
|
|
|
|
const items: HNSWNounWithMetadata[] = []
|
2025-08-26 12:32:21 -07:00
|
|
|
for (const id of paginatedIds) {
|
|
|
|
|
const noun = this.nouns.get(id)
|
|
|
|
|
if (!noun) continue
|
2025-10-17 12:29:27 -07:00
|
|
|
|
|
|
|
|
// Get metadata from separate storage
|
2025-10-27 14:23:46 -07:00
|
|
|
// FIX v4.7.4: Don't skip nouns without metadata - metadata is optional in v4.0.0
|
2025-10-17 12:29:27 -07:00
|
|
|
const metadata = await this.getNounMetadata(id)
|
|
|
|
|
|
fix(storage): v4.8.0 metadata architecture refactoring - FIXES VFS bug
CRITICAL FIX: VFS bug that persisted through v4.5.1-v4.7.4 is NOW FIXED.
Root Cause:
- Storage adapters were not properly extracting standard fields from metadata
- This caused getVerbsBySource_internal() to return 0 relationships despite relationships existing
- VFS PathResolver couldn't navigate directory structure
Solution - Metadata Architecture Refactoring:
1. Move standard fields to top-level of HNSWNounWithMetadata and HNSWVerbWithMetadata
- type, createdAt, updatedAt, confidence, weight, service, data, createdBy
2. Update all 9 storage adapters to extract standard fields from metadata on load
3. Maintain backward compatibility at storage layer (metadata files unchanged)
Changes:
- src/coreTypes.ts: Update HNSWNounWithMetadata and HNSWVerbWithMetadata interfaces
- Add top-level standard fields
- Change data type from unknown to Record<string, any>
- Add confidence field to GraphVerb
- src/storage/baseStorage.ts: Add type cast pattern for standard field extraction
- src/storage/adapters/*.ts: Fix all 9 adapters (memoryStorage, fileSystemStorage, gcsStorage,
s3CompatibleStorage, r2Storage, opfsStorage, azureBlobStorage, typeAwareStorageAdapter)
- Extract standard fields from metadata on load
- Place at top-level of returned entities
- src/api/DataAPI.ts: Read fields from top-level instead of metadata
- src/graph/graphAdjacencyIndex.ts: Convert HNSWVerbWithMetadata to GraphVerb format
- src/utils/metadataIndex.ts: Fix typo (metadata → entityOrMetadata)
- src/types/brainy.types.ts: Add createdBy field to AddParams
- src/types/graphTypes.ts: Add service field to GraphVerb
Test Results:
✅ VFS bug FIXED - vfs.readdir('/') now returns files (was returning empty array)
✅ getVerbsBySource_internal() now returns relationships correctly
✅ Build succeeds with ZERO compilation errors
✅ 95.7% of tests pass (954/997)
Breaking Changes:
- None - backward compatibility maintained at storage layer
Version: 4.8.0
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 15:43:49 -07:00
|
|
|
// v4.8.0: Extract standard fields from metadata to top-level
|
|
|
|
|
const metadataObj = (metadata || {}) as NounMetadata
|
|
|
|
|
const { noun: nounType, createdAt, updatedAt, confidence, weight, service, data, createdBy, ...customMetadata } = metadataObj
|
|
|
|
|
|
|
|
|
|
// v4.8.0: Create HNSWNounWithMetadata with standard fields at top-level
|
2025-10-17 12:29:27 -07:00
|
|
|
const nounWithMetadata: HNSWNounWithMetadata = {
|
|
|
|
|
id: noun.id,
|
|
|
|
|
vector: [...noun.vector],
|
|
|
|
|
connections: new Map(),
|
|
|
|
|
level: noun.level || 0,
|
fix(storage): v4.8.0 metadata architecture refactoring - FIXES VFS bug
CRITICAL FIX: VFS bug that persisted through v4.5.1-v4.7.4 is NOW FIXED.
Root Cause:
- Storage adapters were not properly extracting standard fields from metadata
- This caused getVerbsBySource_internal() to return 0 relationships despite relationships existing
- VFS PathResolver couldn't navigate directory structure
Solution - Metadata Architecture Refactoring:
1. Move standard fields to top-level of HNSWNounWithMetadata and HNSWVerbWithMetadata
- type, createdAt, updatedAt, confidence, weight, service, data, createdBy
2. Update all 9 storage adapters to extract standard fields from metadata on load
3. Maintain backward compatibility at storage layer (metadata files unchanged)
Changes:
- src/coreTypes.ts: Update HNSWNounWithMetadata and HNSWVerbWithMetadata interfaces
- Add top-level standard fields
- Change data type from unknown to Record<string, any>
- Add confidence field to GraphVerb
- src/storage/baseStorage.ts: Add type cast pattern for standard field extraction
- src/storage/adapters/*.ts: Fix all 9 adapters (memoryStorage, fileSystemStorage, gcsStorage,
s3CompatibleStorage, r2Storage, opfsStorage, azureBlobStorage, typeAwareStorageAdapter)
- Extract standard fields from metadata on load
- Place at top-level of returned entities
- src/api/DataAPI.ts: Read fields from top-level instead of metadata
- src/graph/graphAdjacencyIndex.ts: Convert HNSWVerbWithMetadata to GraphVerb format
- src/utils/metadataIndex.ts: Fix typo (metadata → entityOrMetadata)
- src/types/brainy.types.ts: Add createdBy field to AddParams
- src/types/graphTypes.ts: Add service field to GraphVerb
Test Results:
✅ VFS bug FIXED - vfs.readdir('/') now returns files (was returning empty array)
✅ getVerbsBySource_internal() now returns relationships correctly
✅ Build succeeds with ZERO compilation errors
✅ 95.7% of tests pass (954/997)
Breaking Changes:
- None - backward compatibility maintained at storage layer
Version: 4.8.0
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 15:43:49 -07:00
|
|
|
// v4.8.0: Standard fields at top-level
|
|
|
|
|
type: (nounType as NounType) || NounType.Thing,
|
|
|
|
|
createdAt: (createdAt as number) || Date.now(),
|
|
|
|
|
updatedAt: (updatedAt as number) || Date.now(),
|
|
|
|
|
confidence: confidence as number | undefined,
|
|
|
|
|
weight: weight as number | undefined,
|
|
|
|
|
service: service as string | undefined,
|
|
|
|
|
data: data as Record<string, any> | undefined,
|
|
|
|
|
createdBy,
|
|
|
|
|
// Only custom user fields in metadata
|
|
|
|
|
metadata: customMetadata
|
2025-10-17 12:29:27 -07:00
|
|
|
}
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
// Copy connections
|
|
|
|
|
for (const [level, connections] of noun.connections.entries()) {
|
2025-10-17 12:29:27 -07:00
|
|
|
nounWithMetadata.connections.set(level, new Set(connections))
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
2025-10-17 12:29:27 -07:00
|
|
|
|
|
|
|
|
items.push(nounWithMetadata)
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
items,
|
|
|
|
|
totalCount,
|
|
|
|
|
hasMore,
|
|
|
|
|
nextCursor
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get nouns with pagination - simplified interface for compatibility
|
2025-10-17 12:29:27 -07:00
|
|
|
* v4.0.0: Returns HNSWNounWithMetadata[] (includes metadata field)
|
2025-08-26 12:32:21 -07:00
|
|
|
*/
|
|
|
|
|
public async getNounsWithPagination(options: {
|
|
|
|
|
limit?: number
|
|
|
|
|
cursor?: string
|
|
|
|
|
filter?: any
|
|
|
|
|
} = {}): Promise<{
|
2025-10-17 12:29:27 -07:00
|
|
|
items: HNSWNounWithMetadata[]
|
2025-08-26 12:32:21 -07:00
|
|
|
totalCount: number
|
|
|
|
|
hasMore: boolean
|
|
|
|
|
nextCursor?: string
|
|
|
|
|
}> {
|
|
|
|
|
// Convert to the getNouns format
|
|
|
|
|
const result = await this.getNouns({
|
|
|
|
|
pagination: {
|
|
|
|
|
offset: options.cursor ? parseInt(options.cursor) : 0,
|
|
|
|
|
limit: options.limit || 100
|
|
|
|
|
},
|
|
|
|
|
filter: options.filter
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
items: result.items,
|
|
|
|
|
totalCount: result.totalCount || 0,
|
|
|
|
|
hasMore: result.hasMore,
|
|
|
|
|
nextCursor: result.nextCursor
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get nouns by noun type
|
|
|
|
|
* @param nounType The noun type to filter by
|
|
|
|
|
* @returns Promise that resolves to an array of nouns of the specified noun type
|
|
|
|
|
* @deprecated Use getNouns() with filter.nounType instead
|
|
|
|
|
*/
|
|
|
|
|
protected async getNounsByNounType_internal(nounType: string): Promise<HNSWNoun[]> {
|
|
|
|
|
const result = await this.getNouns({
|
|
|
|
|
filter: {
|
|
|
|
|
nounType
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
return result.items
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-17 12:29:27 -07:00
|
|
|
* Delete a noun from storage (v4.0.0)
|
2025-08-26 12:32:21 -07:00
|
|
|
*/
|
|
|
|
|
protected async deleteNoun_internal(id: string): Promise<void> {
|
2025-10-17 12:29:27 -07:00
|
|
|
// v4.0.0: Get type from separate metadata storage
|
|
|
|
|
const metadata = await this.getNounMetadata(id)
|
|
|
|
|
if (metadata) {
|
|
|
|
|
const type = metadata.noun || 'default'
|
2025-09-22 15:45:35 -07:00
|
|
|
this.decrementEntityCount(type)
|
|
|
|
|
}
|
2025-08-26 12:32:21 -07:00
|
|
|
this.nouns.delete(id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-17 12:29:27 -07:00
|
|
|
* Save a verb to storage (v4.0.0: pure vector + core fields, no metadata)
|
2025-08-26 12:32:21 -07:00
|
|
|
*/
|
|
|
|
|
protected async saveVerb_internal(verb: HNSWVerb): Promise<void> {
|
2025-09-22 15:45:35 -07:00
|
|
|
const isNew = !this.verbs.has(verb.id)
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
// Create a deep copy to avoid reference issues
|
2025-10-17 12:29:27 -07:00
|
|
|
// v4.0.0: Include core relational fields but NO metadata field
|
2025-08-26 12:32:21 -07:00
|
|
|
const verbCopy: HNSWVerb = {
|
|
|
|
|
id: verb.id,
|
|
|
|
|
vector: [...verb.vector],
|
fix: metadata explosion bug - 69K files reduced to ~1K
Critical fix for metadata indexing that was creating 60+ chunk files per entity.
Root cause: Vector embeddings (384-dimensional arrays) were being indexed in
metadata, causing each dimension to create a separate chunk file with numeric
field names ("0", "1", "2", etc.).
Changes:
- Modified extractIndexableFields() to exclude vector/embedding fields
- Added NEVER_INDEX set: ['vector', 'embedding', 'embeddings', 'connections']
- Added safety check to skip arrays > 10 elements
- Preserves small array indexing (tags, categories, roles)
Impact:
- Reduces metadata files from 69,429 → ~1,200 (58x reduction)
- Fixes server initialization hangs
- Fixes metadata batch loading stalling at batch 23
- Fixes VFS getDescendants() hanging with large datasets
- Fixes Graph View UI not loading
Test Results:
- 7/7 integration tests passing
- Verified: 6 chunk files for 10 entities (was 7,210 before fix)
- 611/622 unit tests passing
Files Modified:
- src/utils/metadataIndex.ts - Core fix
- src/coreTypes.ts - HNSWVerb type enforcement with VerbType enum
- src/storage/adapters/* - Include core relational fields in HNSWVerb
- src/storage/adapters/baseStorageAdapter.ts - Type enforcement (HNSWNoun, GraphVerb)
- tests/integration/metadata-vector-exclusion.test.ts - Comprehensive test coverage
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 16:10:31 -07:00
|
|
|
connections: new Map(),
|
|
|
|
|
|
2025-10-17 12:29:27 -07:00
|
|
|
// CORE RELATIONAL DATA (part of HNSWVerb in v4.0.0)
|
fix: metadata explosion bug - 69K files reduced to ~1K
Critical fix for metadata indexing that was creating 60+ chunk files per entity.
Root cause: Vector embeddings (384-dimensional arrays) were being indexed in
metadata, causing each dimension to create a separate chunk file with numeric
field names ("0", "1", "2", etc.).
Changes:
- Modified extractIndexableFields() to exclude vector/embedding fields
- Added NEVER_INDEX set: ['vector', 'embedding', 'embeddings', 'connections']
- Added safety check to skip arrays > 10 elements
- Preserves small array indexing (tags, categories, roles)
Impact:
- Reduces metadata files from 69,429 → ~1,200 (58x reduction)
- Fixes server initialization hangs
- Fixes metadata batch loading stalling at batch 23
- Fixes VFS getDescendants() hanging with large datasets
- Fixes Graph View UI not loading
Test Results:
- 7/7 integration tests passing
- Verified: 6 chunk files for 10 entities (was 7,210 before fix)
- 611/622 unit tests passing
Files Modified:
- src/utils/metadataIndex.ts - Core fix
- src/coreTypes.ts - HNSWVerb type enforcement with VerbType enum
- src/storage/adapters/* - Include core relational fields in HNSWVerb
- src/storage/adapters/baseStorageAdapter.ts - Type enforcement (HNSWNoun, GraphVerb)
- tests/integration/metadata-vector-exclusion.test.ts - Comprehensive test coverage
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 16:10:31 -07:00
|
|
|
verb: verb.verb,
|
|
|
|
|
sourceId: verb.sourceId,
|
2025-10-17 12:29:27 -07:00
|
|
|
targetId: verb.targetId
|
|
|
|
|
// ✅ NO metadata field in v4.0.0
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Copy connections
|
|
|
|
|
for (const [level, connections] of verb.connections.entries()) {
|
|
|
|
|
verbCopy.connections.set(level, new Set(connections))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Save the verb directly in the verbs map
|
|
|
|
|
this.verbs.set(verb.id, verbCopy)
|
2025-09-22 15:45:35 -07:00
|
|
|
|
2025-10-17 12:29:27 -07:00
|
|
|
// Note: Count tracking happens in saveVerbMetadata since metadata is separate
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-17 12:29:27 -07:00
|
|
|
* Get a verb from storage (v4.0.0: returns pure vector + core fields)
|
|
|
|
|
* Base class handles combining with metadata
|
2025-08-26 12:32:21 -07:00
|
|
|
*/
|
|
|
|
|
protected async getVerb_internal(id: string): Promise<HNSWVerb | null> {
|
|
|
|
|
// Get the verb directly from the verbs map
|
|
|
|
|
const verb = this.verbs.get(id)
|
|
|
|
|
|
|
|
|
|
// If not found, return null
|
|
|
|
|
if (!verb) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Return a deep copy of the HNSWVerb
|
2025-10-17 12:29:27 -07:00
|
|
|
// v4.0.0: Include core relational fields but NO metadata field
|
2025-08-26 12:32:21 -07:00
|
|
|
const verbCopy: HNSWVerb = {
|
|
|
|
|
id: verb.id,
|
|
|
|
|
vector: [...verb.vector],
|
fix: metadata explosion bug - 69K files reduced to ~1K
Critical fix for metadata indexing that was creating 60+ chunk files per entity.
Root cause: Vector embeddings (384-dimensional arrays) were being indexed in
metadata, causing each dimension to create a separate chunk file with numeric
field names ("0", "1", "2", etc.).
Changes:
- Modified extractIndexableFields() to exclude vector/embedding fields
- Added NEVER_INDEX set: ['vector', 'embedding', 'embeddings', 'connections']
- Added safety check to skip arrays > 10 elements
- Preserves small array indexing (tags, categories, roles)
Impact:
- Reduces metadata files from 69,429 → ~1,200 (58x reduction)
- Fixes server initialization hangs
- Fixes metadata batch loading stalling at batch 23
- Fixes VFS getDescendants() hanging with large datasets
- Fixes Graph View UI not loading
Test Results:
- 7/7 integration tests passing
- Verified: 6 chunk files for 10 entities (was 7,210 before fix)
- 611/622 unit tests passing
Files Modified:
- src/utils/metadataIndex.ts - Core fix
- src/coreTypes.ts - HNSWVerb type enforcement with VerbType enum
- src/storage/adapters/* - Include core relational fields in HNSWVerb
- src/storage/adapters/baseStorageAdapter.ts - Type enforcement (HNSWNoun, GraphVerb)
- tests/integration/metadata-vector-exclusion.test.ts - Comprehensive test coverage
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 16:10:31 -07:00
|
|
|
connections: new Map(),
|
|
|
|
|
|
2025-10-17 12:29:27 -07:00
|
|
|
// CORE RELATIONAL DATA (part of HNSWVerb in v4.0.0)
|
fix: metadata explosion bug - 69K files reduced to ~1K
Critical fix for metadata indexing that was creating 60+ chunk files per entity.
Root cause: Vector embeddings (384-dimensional arrays) were being indexed in
metadata, causing each dimension to create a separate chunk file with numeric
field names ("0", "1", "2", etc.).
Changes:
- Modified extractIndexableFields() to exclude vector/embedding fields
- Added NEVER_INDEX set: ['vector', 'embedding', 'embeddings', 'connections']
- Added safety check to skip arrays > 10 elements
- Preserves small array indexing (tags, categories, roles)
Impact:
- Reduces metadata files from 69,429 → ~1,200 (58x reduction)
- Fixes server initialization hangs
- Fixes metadata batch loading stalling at batch 23
- Fixes VFS getDescendants() hanging with large datasets
- Fixes Graph View UI not loading
Test Results:
- 7/7 integration tests passing
- Verified: 6 chunk files for 10 entities (was 7,210 before fix)
- 611/622 unit tests passing
Files Modified:
- src/utils/metadataIndex.ts - Core fix
- src/coreTypes.ts - HNSWVerb type enforcement with VerbType enum
- src/storage/adapters/* - Include core relational fields in HNSWVerb
- src/storage/adapters/baseStorageAdapter.ts - Type enforcement (HNSWNoun, GraphVerb)
- tests/integration/metadata-vector-exclusion.test.ts - Comprehensive test coverage
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 16:10:31 -07:00
|
|
|
verb: verb.verb,
|
|
|
|
|
sourceId: verb.sourceId,
|
2025-10-17 12:29:27 -07:00
|
|
|
targetId: verb.targetId
|
|
|
|
|
// ✅ NO metadata field in v4.0.0
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Copy connections
|
|
|
|
|
for (const [level, connections] of verb.connections.entries()) {
|
|
|
|
|
verbCopy.connections.set(level, new Set(connections))
|
|
|
|
|
}
|
|
|
|
|
|
fix: metadata explosion bug - 69K files reduced to ~1K
Critical fix for metadata indexing that was creating 60+ chunk files per entity.
Root cause: Vector embeddings (384-dimensional arrays) were being indexed in
metadata, causing each dimension to create a separate chunk file with numeric
field names ("0", "1", "2", etc.).
Changes:
- Modified extractIndexableFields() to exclude vector/embedding fields
- Added NEVER_INDEX set: ['vector', 'embedding', 'embeddings', 'connections']
- Added safety check to skip arrays > 10 elements
- Preserves small array indexing (tags, categories, roles)
Impact:
- Reduces metadata files from 69,429 → ~1,200 (58x reduction)
- Fixes server initialization hangs
- Fixes metadata batch loading stalling at batch 23
- Fixes VFS getDescendants() hanging with large datasets
- Fixes Graph View UI not loading
Test Results:
- 7/7 integration tests passing
- Verified: 6 chunk files for 10 entities (was 7,210 before fix)
- 611/622 unit tests passing
Files Modified:
- src/utils/metadataIndex.ts - Core fix
- src/coreTypes.ts - HNSWVerb type enforcement with VerbType enum
- src/storage/adapters/* - Include core relational fields in HNSWVerb
- src/storage/adapters/baseStorageAdapter.ts - Type enforcement (HNSWNoun, GraphVerb)
- tests/integration/metadata-vector-exclusion.test.ts - Comprehensive test coverage
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 16:10:31 -07:00
|
|
|
return verbCopy
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get verbs with pagination and filtering
|
2025-10-17 12:29:27 -07:00
|
|
|
* v4.0.0: Returns HNSWVerbWithMetadata[] (includes metadata field)
|
2025-08-26 12:32:21 -07:00
|
|
|
* @param options Pagination and filtering options
|
2025-10-17 12:29:27 -07:00
|
|
|
* @returns Promise that resolves to a paginated result of verbs with metadata
|
2025-08-26 12:32:21 -07:00
|
|
|
*/
|
|
|
|
|
public async getVerbs(options: {
|
|
|
|
|
pagination?: {
|
|
|
|
|
offset?: number
|
|
|
|
|
limit?: number
|
|
|
|
|
cursor?: string
|
|
|
|
|
}
|
|
|
|
|
filter?: {
|
|
|
|
|
verbType?: string | string[]
|
|
|
|
|
sourceId?: string | string[]
|
|
|
|
|
targetId?: string | string[]
|
|
|
|
|
service?: string | string[]
|
|
|
|
|
metadata?: Record<string, any>
|
|
|
|
|
}
|
2025-10-17 12:29:27 -07:00
|
|
|
} = {}): Promise<{ items: HNSWVerbWithMetadata[]; totalCount?: number; hasMore: boolean; nextCursor?: string }> {
|
2025-08-26 12:32:21 -07:00
|
|
|
const pagination = options.pagination || {}
|
|
|
|
|
const filter = options.filter || {}
|
|
|
|
|
|
|
|
|
|
// Default values
|
|
|
|
|
const offset = pagination.offset || 0
|
|
|
|
|
const limit = pagination.limit || 100
|
|
|
|
|
|
|
|
|
|
// Convert string types to arrays for consistent handling
|
|
|
|
|
const verbTypes = filter.verbType
|
|
|
|
|
? Array.isArray(filter.verbType) ? filter.verbType : [filter.verbType]
|
|
|
|
|
: undefined
|
|
|
|
|
|
|
|
|
|
const sourceIds = filter.sourceId
|
|
|
|
|
? Array.isArray(filter.sourceId) ? filter.sourceId : [filter.sourceId]
|
|
|
|
|
: undefined
|
|
|
|
|
|
|
|
|
|
const targetIds = filter.targetId
|
|
|
|
|
? Array.isArray(filter.targetId) ? filter.targetId : [filter.targetId]
|
|
|
|
|
: undefined
|
|
|
|
|
|
|
|
|
|
const services = filter.service
|
|
|
|
|
? Array.isArray(filter.service) ? filter.service : [filter.service]
|
|
|
|
|
: undefined
|
|
|
|
|
|
|
|
|
|
// First, collect all verb IDs that match the filter criteria
|
|
|
|
|
const matchingIds: string[] = []
|
|
|
|
|
|
|
|
|
|
// Iterate through all verbs to find matches
|
2025-10-17 12:29:27 -07:00
|
|
|
// v4.0.0: Core fields (verb, sourceId, targetId) are in HNSWVerb, not metadata
|
2025-08-26 12:32:21 -07:00
|
|
|
for (const [verbId, hnswVerb] of this.verbs.entries()) {
|
2025-10-17 12:29:27 -07:00
|
|
|
// Get the metadata for service/data filtering
|
2025-10-09 16:33:08 -07:00
|
|
|
const metadata = await this.getVerbMetadata(verbId)
|
2025-10-17 12:29:27 -07:00
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
// Filter by verb type if specified
|
2025-10-17 12:29:27 -07:00
|
|
|
// v4.0.0: verb type is in HNSWVerb.verb
|
|
|
|
|
if (verbTypes && !verbTypes.includes(hnswVerb.verb || '')) {
|
2025-08-26 12:32:21 -07:00
|
|
|
continue
|
|
|
|
|
}
|
2025-10-17 12:29:27 -07:00
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
// Filter by source ID if specified
|
2025-10-17 12:29:27 -07:00
|
|
|
// v4.0.0: sourceId is in HNSWVerb.sourceId
|
|
|
|
|
if (sourceIds && !sourceIds.includes(hnswVerb.sourceId || '')) {
|
2025-08-26 12:32:21 -07:00
|
|
|
continue
|
|
|
|
|
}
|
2025-10-17 12:29:27 -07:00
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
// Filter by target ID if specified
|
2025-10-17 12:29:27 -07:00
|
|
|
// v4.0.0: targetId is in HNSWVerb.targetId
|
|
|
|
|
if (targetIds && !targetIds.includes(hnswVerb.targetId || '')) {
|
2025-08-26 12:32:21 -07:00
|
|
|
continue
|
|
|
|
|
}
|
2025-10-17 12:29:27 -07:00
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
// Filter by metadata fields if specified
|
2025-10-17 12:29:27 -07:00
|
|
|
if (filter.metadata && metadata) {
|
2025-08-26 12:32:21 -07:00
|
|
|
let metadataMatch = true
|
|
|
|
|
for (const [key, value] of Object.entries(filter.metadata)) {
|
2025-10-17 12:29:27 -07:00
|
|
|
const metadataValue = (metadata as any)[key]
|
|
|
|
|
if (metadataValue !== value) {
|
2025-08-26 12:32:21 -07:00
|
|
|
metadataMatch = false
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!metadataMatch) continue
|
|
|
|
|
}
|
2025-10-17 12:29:27 -07:00
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
// Filter by service if specified
|
2025-10-17 12:29:27 -07:00
|
|
|
if (services && metadata && metadata.service && !services.includes(metadata.service)) {
|
2025-08-26 12:32:21 -07:00
|
|
|
continue
|
|
|
|
|
}
|
2025-10-17 12:29:27 -07:00
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
// If we got here, the verb matches all filters
|
|
|
|
|
matchingIds.push(verbId)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate pagination
|
|
|
|
|
const totalCount = matchingIds.length
|
|
|
|
|
const paginatedIds = matchingIds.slice(offset, offset + limit)
|
|
|
|
|
const hasMore = offset + limit < totalCount
|
|
|
|
|
|
|
|
|
|
// Create cursor for next page if there are more results
|
|
|
|
|
const nextCursor = hasMore ? `${offset + limit}` : undefined
|
|
|
|
|
|
|
|
|
|
// Fetch the actual verbs for the current page
|
2025-10-17 12:29:27 -07:00
|
|
|
// v4.0.0: Return HNSWVerbWithMetadata (includes metadata field)
|
|
|
|
|
const items: HNSWVerbWithMetadata[] = []
|
2025-08-26 12:32:21 -07:00
|
|
|
for (const id of paginatedIds) {
|
|
|
|
|
const hnswVerb = this.verbs.get(id)
|
|
|
|
|
if (!hnswVerb) continue
|
2025-10-17 12:29:27 -07:00
|
|
|
|
|
|
|
|
// Get metadata from separate storage
|
2025-10-27 14:23:46 -07:00
|
|
|
// FIX v4.7.4: Don't skip verbs without metadata - metadata is optional in v4.0.0
|
|
|
|
|
// Core fields (verb, sourceId, targetId) are in HNSWVerb itself
|
2025-10-17 12:29:27 -07:00
|
|
|
const metadata = await this.getVerbMetadata(id)
|
|
|
|
|
|
fix(storage): v4.8.0 metadata architecture refactoring - FIXES VFS bug
CRITICAL FIX: VFS bug that persisted through v4.5.1-v4.7.4 is NOW FIXED.
Root Cause:
- Storage adapters were not properly extracting standard fields from metadata
- This caused getVerbsBySource_internal() to return 0 relationships despite relationships existing
- VFS PathResolver couldn't navigate directory structure
Solution - Metadata Architecture Refactoring:
1. Move standard fields to top-level of HNSWNounWithMetadata and HNSWVerbWithMetadata
- type, createdAt, updatedAt, confidence, weight, service, data, createdBy
2. Update all 9 storage adapters to extract standard fields from metadata on load
3. Maintain backward compatibility at storage layer (metadata files unchanged)
Changes:
- src/coreTypes.ts: Update HNSWNounWithMetadata and HNSWVerbWithMetadata interfaces
- Add top-level standard fields
- Change data type from unknown to Record<string, any>
- Add confidence field to GraphVerb
- src/storage/baseStorage.ts: Add type cast pattern for standard field extraction
- src/storage/adapters/*.ts: Fix all 9 adapters (memoryStorage, fileSystemStorage, gcsStorage,
s3CompatibleStorage, r2Storage, opfsStorage, azureBlobStorage, typeAwareStorageAdapter)
- Extract standard fields from metadata on load
- Place at top-level of returned entities
- src/api/DataAPI.ts: Read fields from top-level instead of metadata
- src/graph/graphAdjacencyIndex.ts: Convert HNSWVerbWithMetadata to GraphVerb format
- src/utils/metadataIndex.ts: Fix typo (metadata → entityOrMetadata)
- src/types/brainy.types.ts: Add createdBy field to AddParams
- src/types/graphTypes.ts: Add service field to GraphVerb
Test Results:
✅ VFS bug FIXED - vfs.readdir('/') now returns files (was returning empty array)
✅ getVerbsBySource_internal() now returns relationships correctly
✅ Build succeeds with ZERO compilation errors
✅ 95.7% of tests pass (954/997)
Breaking Changes:
- None - backward compatibility maintained at storage layer
Version: 4.8.0
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 15:43:49 -07:00
|
|
|
// v4.8.0: Extract standard fields from metadata to top-level
|
|
|
|
|
const metadataObj = metadata || {}
|
|
|
|
|
const { createdAt, updatedAt, confidence, weight, service, data, createdBy, ...customMetadata } = metadataObj
|
|
|
|
|
|
|
|
|
|
// v4.8.0: Create HNSWVerbWithMetadata with standard fields at top-level
|
2025-10-17 12:29:27 -07:00
|
|
|
const verbWithMetadata: HNSWVerbWithMetadata = {
|
2025-08-26 12:32:21 -07:00
|
|
|
id: hnswVerb.id,
|
|
|
|
|
vector: [...hnswVerb.vector],
|
2025-10-17 12:29:27 -07:00
|
|
|
connections: new Map(),
|
|
|
|
|
|
|
|
|
|
// Core relational fields (part of HNSWVerb)
|
|
|
|
|
verb: hnswVerb.verb,
|
|
|
|
|
sourceId: hnswVerb.sourceId,
|
|
|
|
|
targetId: hnswVerb.targetId,
|
|
|
|
|
|
fix(storage): v4.8.0 metadata architecture refactoring - FIXES VFS bug
CRITICAL FIX: VFS bug that persisted through v4.5.1-v4.7.4 is NOW FIXED.
Root Cause:
- Storage adapters were not properly extracting standard fields from metadata
- This caused getVerbsBySource_internal() to return 0 relationships despite relationships existing
- VFS PathResolver couldn't navigate directory structure
Solution - Metadata Architecture Refactoring:
1. Move standard fields to top-level of HNSWNounWithMetadata and HNSWVerbWithMetadata
- type, createdAt, updatedAt, confidence, weight, service, data, createdBy
2. Update all 9 storage adapters to extract standard fields from metadata on load
3. Maintain backward compatibility at storage layer (metadata files unchanged)
Changes:
- src/coreTypes.ts: Update HNSWNounWithMetadata and HNSWVerbWithMetadata interfaces
- Add top-level standard fields
- Change data type from unknown to Record<string, any>
- Add confidence field to GraphVerb
- src/storage/baseStorage.ts: Add type cast pattern for standard field extraction
- src/storage/adapters/*.ts: Fix all 9 adapters (memoryStorage, fileSystemStorage, gcsStorage,
s3CompatibleStorage, r2Storage, opfsStorage, azureBlobStorage, typeAwareStorageAdapter)
- Extract standard fields from metadata on load
- Place at top-level of returned entities
- src/api/DataAPI.ts: Read fields from top-level instead of metadata
- src/graph/graphAdjacencyIndex.ts: Convert HNSWVerbWithMetadata to GraphVerb format
- src/utils/metadataIndex.ts: Fix typo (metadata → entityOrMetadata)
- src/types/brainy.types.ts: Add createdBy field to AddParams
- src/types/graphTypes.ts: Add service field to GraphVerb
Test Results:
✅ VFS bug FIXED - vfs.readdir('/') now returns files (was returning empty array)
✅ getVerbsBySource_internal() now returns relationships correctly
✅ Build succeeds with ZERO compilation errors
✅ 95.7% of tests pass (954/997)
Breaking Changes:
- None - backward compatibility maintained at storage layer
Version: 4.8.0
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 15:43:49 -07:00
|
|
|
// v4.8.0: Standard fields at top-level
|
|
|
|
|
createdAt: (createdAt as number) || Date.now(),
|
|
|
|
|
updatedAt: (updatedAt as number) || Date.now(),
|
|
|
|
|
confidence: confidence as number | undefined,
|
|
|
|
|
weight: weight as number | undefined,
|
|
|
|
|
service: service as string | undefined,
|
|
|
|
|
data: data as Record<string, any> | undefined,
|
|
|
|
|
createdBy,
|
|
|
|
|
|
|
|
|
|
// Only custom user fields in metadata
|
|
|
|
|
metadata: customMetadata
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
2025-10-17 12:29:27 -07:00
|
|
|
|
|
|
|
|
// Copy connections
|
|
|
|
|
for (const [level, connections] of hnswVerb.connections.entries()) {
|
|
|
|
|
verbWithMetadata.connections.set(level, new Set(connections))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
items.push(verbWithMetadata)
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
items,
|
|
|
|
|
totalCount,
|
|
|
|
|
hasMore,
|
|
|
|
|
nextCursor
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get verbs by source
|
|
|
|
|
* @deprecated Use getVerbs() with filter.sourceId instead
|
|
|
|
|
*/
|
2025-10-17 12:29:27 -07:00
|
|
|
protected async getVerbsBySource_internal(sourceId: string): Promise<HNSWVerbWithMetadata[]> {
|
2025-08-26 12:32:21 -07:00
|
|
|
const result = await this.getVerbs({
|
|
|
|
|
filter: {
|
|
|
|
|
sourceId
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
return result.items
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get verbs by target
|
|
|
|
|
* @deprecated Use getVerbs() with filter.targetId instead
|
|
|
|
|
*/
|
2025-10-17 12:29:27 -07:00
|
|
|
protected async getVerbsByTarget_internal(targetId: string): Promise<HNSWVerbWithMetadata[]> {
|
2025-08-26 12:32:21 -07:00
|
|
|
const result = await this.getVerbs({
|
|
|
|
|
filter: {
|
|
|
|
|
targetId
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
return result.items
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get verbs by type
|
|
|
|
|
* @deprecated Use getVerbs() with filter.verbType instead
|
|
|
|
|
*/
|
2025-10-17 12:29:27 -07:00
|
|
|
protected async getVerbsByType_internal(type: string): Promise<HNSWVerbWithMetadata[]> {
|
2025-08-26 12:32:21 -07:00
|
|
|
const result = await this.getVerbs({
|
|
|
|
|
filter: {
|
|
|
|
|
verbType: type
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
return result.items
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete a verb from storage
|
|
|
|
|
*/
|
|
|
|
|
protected async deleteVerb_internal(id: string): Promise<void> {
|
2025-10-09 16:33:08 -07:00
|
|
|
// Delete the HNSWVerb from the verbs map
|
2025-08-26 12:32:21 -07:00
|
|
|
this.verbs.delete(id)
|
2025-10-09 16:33:08 -07:00
|
|
|
|
|
|
|
|
// CRITICAL: Also delete verb metadata - this is what getVerbs() uses to find verbs
|
|
|
|
|
// Without this, getVerbsBySource() will still find "deleted" verbs via their metadata
|
|
|
|
|
const metadata = await this.getVerbMetadata(id)
|
|
|
|
|
if (metadata) {
|
|
|
|
|
const verbType = metadata.verb || metadata.type || 'default'
|
2025-10-17 12:29:27 -07:00
|
|
|
this.decrementVerbCount(verbType as string)
|
2025-10-09 16:33:08 -07:00
|
|
|
|
|
|
|
|
// Delete the metadata using the base storage method
|
|
|
|
|
await this.deleteVerbMetadata(id)
|
|
|
|
|
}
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-09 13:10:06 -07:00
|
|
|
* Primitive operation: Write object to path
|
|
|
|
|
* All metadata operations use this internally via base class routing
|
2025-08-26 12:32:21 -07:00
|
|
|
*/
|
2025-10-09 13:10:06 -07:00
|
|
|
protected async writeObjectToPath(path: string, data: any): Promise<void> {
|
|
|
|
|
// Store in unified object store using path as key
|
|
|
|
|
this.objectStore.set(path, JSON.parse(JSON.stringify(data)))
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-09 13:10:06 -07:00
|
|
|
* Primitive operation: Read object from path
|
|
|
|
|
* All metadata operations use this internally via base class routing
|
2025-08-26 12:32:21 -07:00
|
|
|
*/
|
2025-10-09 13:10:06 -07:00
|
|
|
protected async readObjectFromPath(path: string): Promise<any | null> {
|
|
|
|
|
const data = this.objectStore.get(path)
|
|
|
|
|
if (!data) {
|
2025-08-26 12:32:21 -07:00
|
|
|
return null
|
|
|
|
|
}
|
2025-10-09 13:10:06 -07:00
|
|
|
return JSON.parse(JSON.stringify(data))
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-09 13:10:06 -07:00
|
|
|
* Primitive operation: Delete object from path
|
|
|
|
|
* All metadata operations use this internally via base class routing
|
2025-08-26 12:32:21 -07:00
|
|
|
*/
|
2025-10-09 13:10:06 -07:00
|
|
|
protected async deleteObjectFromPath(path: string): Promise<void> {
|
|
|
|
|
this.objectStore.delete(path)
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-09 13:10:06 -07:00
|
|
|
* Primitive operation: List objects under path prefix
|
|
|
|
|
* All metadata operations use this internally via base class routing
|
2025-08-26 12:32:21 -07:00
|
|
|
*/
|
2025-10-09 13:10:06 -07:00
|
|
|
protected async listObjectsUnderPath(prefix: string): Promise<string[]> {
|
|
|
|
|
const paths: string[] = []
|
|
|
|
|
for (const key of this.objectStore.keys()) {
|
|
|
|
|
if (key.startsWith(prefix)) {
|
|
|
|
|
paths.push(key)
|
|
|
|
|
}
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
2025-10-09 13:10:06 -07:00
|
|
|
return paths.sort()
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-09 13:10:06 -07:00
|
|
|
* Get multiple metadata objects in batches (CRITICAL: Prevents socket exhaustion)
|
|
|
|
|
* Memory storage implementation is simple since all data is already in memory
|
2025-08-26 12:32:21 -07:00
|
|
|
*/
|
2025-10-09 13:10:06 -07:00
|
|
|
public async getMetadataBatch(ids: string[]): Promise<Map<string, any>> {
|
|
|
|
|
const results = new Map<string, any>()
|
2025-08-26 12:32:21 -07:00
|
|
|
|
2025-10-09 13:10:06 -07:00
|
|
|
// Memory storage can handle all IDs at once since it's in-memory
|
|
|
|
|
for (const id of ids) {
|
2025-10-10 16:25:51 -07:00
|
|
|
// CRITICAL: Use getNounMetadata() instead of deprecated getMetadata()
|
|
|
|
|
// This ensures we fetch from the correct noun metadata store (2-file system)
|
|
|
|
|
const metadata = await this.getNounMetadata(id)
|
2025-10-09 13:10:06 -07:00
|
|
|
if (metadata) {
|
|
|
|
|
results.set(id, metadata)
|
|
|
|
|
}
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
|
|
|
|
|
2025-10-09 13:10:06 -07:00
|
|
|
return results
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Clear all data from storage
|
|
|
|
|
*/
|
|
|
|
|
public async clear(): Promise<void> {
|
|
|
|
|
this.nouns.clear()
|
|
|
|
|
this.verbs.clear()
|
2025-10-09 13:10:06 -07:00
|
|
|
this.objectStore.clear()
|
2025-08-26 12:32:21 -07:00
|
|
|
this.statistics = null
|
2025-10-09 13:10:06 -07:00
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
// Clear the statistics cache
|
|
|
|
|
this.statisticsCache = null
|
|
|
|
|
this.statisticsModified = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get information about storage usage and capacity
|
|
|
|
|
*/
|
|
|
|
|
public async getStorageStatus(): Promise<{
|
|
|
|
|
type: string
|
|
|
|
|
used: number
|
|
|
|
|
quota: number | null
|
|
|
|
|
details?: Record<string, any>
|
|
|
|
|
}> {
|
|
|
|
|
return {
|
|
|
|
|
type: 'memory',
|
|
|
|
|
used: 0, // In-memory storage doesn't have a meaningful size
|
|
|
|
|
quota: null, // In-memory storage doesn't have a quota
|
|
|
|
|
details: {
|
|
|
|
|
nodeCount: this.nouns.size,
|
|
|
|
|
edgeCount: this.verbs.size,
|
2025-10-09 13:10:06 -07:00
|
|
|
metadataCount: this.objectStore.size
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Save statistics data to storage
|
|
|
|
|
* @param statistics The statistics data to save
|
|
|
|
|
*/
|
|
|
|
|
protected async saveStatisticsData(statistics: StatisticsData): Promise<void> {
|
|
|
|
|
// For memory storage, we just need to store the statistics in memory
|
|
|
|
|
// Create a deep copy to avoid reference issues
|
|
|
|
|
this.statistics = {
|
|
|
|
|
nounCount: {...statistics.nounCount},
|
|
|
|
|
verbCount: {...statistics.verbCount},
|
|
|
|
|
metadataCount: {...statistics.metadataCount},
|
|
|
|
|
hnswIndexSize: statistics.hnswIndexSize,
|
|
|
|
|
lastUpdated: statistics.lastUpdated,
|
|
|
|
|
// Include serviceActivity if present
|
|
|
|
|
...(statistics.serviceActivity && {
|
|
|
|
|
serviceActivity: Object.fromEntries(
|
|
|
|
|
Object.entries(statistics.serviceActivity).map(([k, v]) => [k, {...v}])
|
|
|
|
|
)
|
|
|
|
|
}),
|
|
|
|
|
// Include services if present
|
|
|
|
|
...(statistics.services && {
|
|
|
|
|
services: statistics.services.map(s => ({...s}))
|
|
|
|
|
}),
|
|
|
|
|
// Include distributedConfig if present
|
|
|
|
|
...(statistics.distributedConfig && {
|
|
|
|
|
distributedConfig: JSON.parse(JSON.stringify(statistics.distributedConfig))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Since this is in-memory, there's no need for time-based partitioning
|
|
|
|
|
// or legacy file handling
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get statistics data from storage
|
|
|
|
|
* @returns Promise that resolves to the statistics data or null if not found
|
|
|
|
|
*/
|
|
|
|
|
protected async getStatisticsData(): Promise<StatisticsData | null> {
|
|
|
|
|
if (!this.statistics) {
|
2025-10-11 09:05:16 -07:00
|
|
|
// CRITICAL FIX (v3.37.4): Statistics don't exist yet (first init)
|
|
|
|
|
// Return minimal stats with counts instead of null
|
|
|
|
|
// This prevents HNSW from seeing entityCount=0 during index rebuild
|
|
|
|
|
return {
|
|
|
|
|
nounCount: {},
|
|
|
|
|
verbCount: {},
|
|
|
|
|
metadataCount: {},
|
|
|
|
|
hnswIndexSize: 0,
|
|
|
|
|
totalNodes: this.totalNounCount,
|
|
|
|
|
totalEdges: this.totalVerbCount,
|
|
|
|
|
totalMetadata: 0,
|
|
|
|
|
lastUpdated: new Date().toISOString()
|
|
|
|
|
}
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Return a deep copy to avoid reference issues
|
|
|
|
|
return {
|
|
|
|
|
nounCount: {...this.statistics.nounCount},
|
|
|
|
|
verbCount: {...this.statistics.verbCount},
|
|
|
|
|
metadataCount: {...this.statistics.metadataCount},
|
|
|
|
|
hnswIndexSize: this.statistics.hnswIndexSize,
|
fix: populate totalNodes/totalEdges in ALL storage adapters for HNSW rebuild
Critical fix for v3.37.1 GCS persistence bug where HNSW rebuild reported
"Preloading 0 vectors" and hung indefinitely during container restarts.
Root Cause:
HNSW rebuild (hnswIndex.ts:835) calls storage.getStatistics() and expects
stats.totalNodes to determine entity count for adaptive caching. When
totalNodes was undefined, entityCount evaluated to 0, causing HNSW to
report "0 vectors" and hang waiting for entities that it couldn't find.
Fixes Applied:
- GcsStorage: Populate totalNodes/totalEdges from this.totalNounCount/totalVerbCount
- MemoryStorage: Populate totalNodes/totalEdges from in-memory counts
- OPFSStorage: Populate totalNodes/totalEdges in ALL return paths (cache, current day, yesterday, legacy)
- S3CompatibleStorage: Populate totalNodes/totalEdges in ALL return paths (cache, fresh stats, error fallback)
Impact:
- ✅ GCS container restarts now work correctly
- ✅ HNSW rebuild correctly detects entity count
- ✅ Adaptive caching strategy works as designed
- ✅ All storage adapters now consistent
Files exist in GCS, counts load correctly, but HNSW couldn't see them
because getStatisticsData() didn't populate the totalNodes field that
HNSW depends on.
Closes: BRAINY_V3_37_1_INDEX_REBUILD_BUG.md
Related: v3.37.0 (metadata reconstruction), v3.37.1 (getNoun_internal fix)
2025-10-10 17:48:40 -07:00
|
|
|
// CRITICAL FIX: Populate totalNodes and totalEdges from in-memory counts
|
|
|
|
|
// HNSW rebuild depends on these fields to determine entity count
|
|
|
|
|
totalNodes: this.totalNounCount,
|
|
|
|
|
totalEdges: this.totalVerbCount,
|
2025-08-26 12:32:21 -07:00
|
|
|
lastUpdated: this.statistics.lastUpdated,
|
|
|
|
|
// Include serviceActivity if present
|
|
|
|
|
...(this.statistics.serviceActivity && {
|
|
|
|
|
serviceActivity: Object.fromEntries(
|
|
|
|
|
Object.entries(this.statistics.serviceActivity).map(([k, v]) => [k, {...v}])
|
|
|
|
|
)
|
|
|
|
|
}),
|
|
|
|
|
// Include services if present
|
|
|
|
|
...(this.statistics.services && {
|
|
|
|
|
services: this.statistics.services.map(s => ({...s}))
|
|
|
|
|
}),
|
|
|
|
|
// Include distributedConfig if present
|
fix: populate totalNodes/totalEdges in ALL storage adapters for HNSW rebuild
Critical fix for v3.37.1 GCS persistence bug where HNSW rebuild reported
"Preloading 0 vectors" and hung indefinitely during container restarts.
Root Cause:
HNSW rebuild (hnswIndex.ts:835) calls storage.getStatistics() and expects
stats.totalNodes to determine entity count for adaptive caching. When
totalNodes was undefined, entityCount evaluated to 0, causing HNSW to
report "0 vectors" and hang waiting for entities that it couldn't find.
Fixes Applied:
- GcsStorage: Populate totalNodes/totalEdges from this.totalNounCount/totalVerbCount
- MemoryStorage: Populate totalNodes/totalEdges from in-memory counts
- OPFSStorage: Populate totalNodes/totalEdges in ALL return paths (cache, current day, yesterday, legacy)
- S3CompatibleStorage: Populate totalNodes/totalEdges in ALL return paths (cache, fresh stats, error fallback)
Impact:
- ✅ GCS container restarts now work correctly
- ✅ HNSW rebuild correctly detects entity count
- ✅ Adaptive caching strategy works as designed
- ✅ All storage adapters now consistent
Files exist in GCS, counts load correctly, but HNSW couldn't see them
because getStatisticsData() didn't populate the totalNodes field that
HNSW depends on.
Closes: BRAINY_V3_37_1_INDEX_REBUILD_BUG.md
Related: v3.37.0 (metadata reconstruction), v3.37.1 (getNoun_internal fix)
2025-10-10 17:48:40 -07:00
|
|
|
...(this.statistics.distributedConfig && {
|
|
|
|
|
distributedConfig: JSON.parse(JSON.stringify(this.statistics.distributedConfig))
|
2025-08-26 12:32:21 -07:00
|
|
|
})
|
|
|
|
|
}
|
fix: populate totalNodes/totalEdges in ALL storage adapters for HNSW rebuild
Critical fix for v3.37.1 GCS persistence bug where HNSW rebuild reported
"Preloading 0 vectors" and hung indefinitely during container restarts.
Root Cause:
HNSW rebuild (hnswIndex.ts:835) calls storage.getStatistics() and expects
stats.totalNodes to determine entity count for adaptive caching. When
totalNodes was undefined, entityCount evaluated to 0, causing HNSW to
report "0 vectors" and hang waiting for entities that it couldn't find.
Fixes Applied:
- GcsStorage: Populate totalNodes/totalEdges from this.totalNounCount/totalVerbCount
- MemoryStorage: Populate totalNodes/totalEdges from in-memory counts
- OPFSStorage: Populate totalNodes/totalEdges in ALL return paths (cache, current day, yesterday, legacy)
- S3CompatibleStorage: Populate totalNodes/totalEdges in ALL return paths (cache, fresh stats, error fallback)
Impact:
- ✅ GCS container restarts now work correctly
- ✅ HNSW rebuild correctly detects entity count
- ✅ Adaptive caching strategy works as designed
- ✅ All storage adapters now consistent
Files exist in GCS, counts load correctly, but HNSW couldn't see them
because getStatisticsData() didn't populate the totalNodes field that
HNSW depends on.
Closes: BRAINY_V3_37_1_INDEX_REBUILD_BUG.md
Related: v3.37.0 (metadata reconstruction), v3.37.1 (getNoun_internal fix)
2025-10-10 17:48:40 -07:00
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
// Since this is in-memory, there's no need for fallback mechanisms
|
|
|
|
|
// to check multiple storage locations
|
|
|
|
|
}
|
2025-09-22 15:45:35 -07:00
|
|
|
|
|
|
|
|
/**
|
2025-10-17 12:29:27 -07:00
|
|
|
* Initialize counts from in-memory storage - O(1) operation (v4.0.0)
|
2025-09-22 15:45:35 -07:00
|
|
|
*/
|
|
|
|
|
protected async initializeCounts(): Promise<void> {
|
|
|
|
|
// For memory storage, initialize counts from current in-memory state
|
|
|
|
|
this.totalNounCount = this.nouns.size
|
2025-10-17 12:29:27 -07:00
|
|
|
this.totalVerbCount = this.verbs.size
|
2025-09-22 15:45:35 -07:00
|
|
|
|
2025-10-17 12:29:27 -07:00
|
|
|
// Initialize type-based counts by scanning metadata storage (v4.0.0)
|
2025-09-22 15:45:35 -07:00
|
|
|
this.entityCounts.clear()
|
|
|
|
|
this.verbCounts.clear()
|
|
|
|
|
|
2025-10-17 12:29:27 -07:00
|
|
|
// Count nouns by loading metadata for each
|
|
|
|
|
for (const [nounId, noun] of this.nouns.entries()) {
|
|
|
|
|
const metadata = await this.getNounMetadata(nounId)
|
|
|
|
|
if (metadata) {
|
|
|
|
|
const type = metadata.noun || 'default'
|
|
|
|
|
this.entityCounts.set(type, (this.entityCounts.get(type) || 0) + 1)
|
|
|
|
|
}
|
2025-09-22 15:45:35 -07:00
|
|
|
}
|
|
|
|
|
|
2025-10-17 12:29:27 -07:00
|
|
|
// Count verbs by loading metadata for each
|
|
|
|
|
for (const [verbId, verb] of this.verbs.entries()) {
|
|
|
|
|
const metadata = await this.getVerbMetadata(verbId)
|
|
|
|
|
if (metadata) {
|
|
|
|
|
// VerbMetadata doesn't have verb type - that's in HNSWVerb now
|
|
|
|
|
// Use the verb's type from the HNSWVerb itself
|
|
|
|
|
const type = verb.verb || 'default'
|
|
|
|
|
this.verbCounts.set(type, (this.verbCounts.get(type) || 0) + 1)
|
|
|
|
|
}
|
2025-09-22 15:45:35 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Persist counts to storage - no-op for memory storage
|
|
|
|
|
*/
|
|
|
|
|
protected async persistCounts(): Promise<void> {
|
|
|
|
|
// No persistence needed for in-memory storage
|
|
|
|
|
// Counts are always accurate from the live data structures
|
|
|
|
|
}
|
2025-10-10 11:15:17 -07:00
|
|
|
|
|
|
|
|
// =============================================
|
|
|
|
|
// HNSW Index Persistence (v3.35.0+)
|
|
|
|
|
// =============================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get vector for a noun
|
|
|
|
|
*/
|
|
|
|
|
public async getNounVector(id: string): Promise<number[] | null> {
|
|
|
|
|
const noun = this.nouns.get(id)
|
|
|
|
|
return noun ? [...noun.vector] : null
|
|
|
|
|
}
|
|
|
|
|
|
fix: resolve HNSW concurrency race condition across all storage adapters
Fixes critical P0 bug causing data corruption during bulk imports with 50+ concurrent operations. The non-atomic read-modify-write pattern in saveHNSWData() combined with fire-and-forget neighbor updates was causing 16-32 concurrent writes per entity, resulting in lost HNSW connections and corrupted graph structure.
**Root Cause:**
- saveHNSWData() used non-atomic read-modify-write
- HNSW neighbor updates fired without await (16-32 concurrent writes/entity)
- Popular nodes became hotspots (100 concurrent imports = 3,400 concurrent saveHNSWData calls)
- Result: Lost neighbor connections, 0 search results
**Atomic Write Strategies by Adapter:**
FileSystemStorage:
- Atomic rename with temp files
- Write to {file}.tmp.{timestamp}.{random}
- POSIX-guaranteed atomic rename(temp, final)
GCSStorage:
- Optimistic locking with generation numbers
- preconditionOpts: { ifGenerationMatch }
- 5 retries with exponential backoff (50ms→800ms)
S3/R2/AzureStorage:
- ETag-based optimistic locking
- IfMatch/conditions preconditions
- 5 retries with exponential backoff
MemoryStorage + OPFSStorage:
- Mutex locks per entity path
- Serializes async operations even in single-threaded environments
HNSW Index:
- Changed fire-and-forget .catch() to await
- Serializes 16-32 neighbor updates per entity
- Trade-off: 20-30% slower bulk import vs 100% data integrity
**Sharding Compatibility:**
- ✅ Works with deterministic UUID sharding (256 shards, always on)
- ✅ Works with distributed multi-node sharding (optional)
- ✅ All atomic strategies work in both single-node and distributed deployments
**Index Impact:**
- Only HNSW index modified (saveHNSWData, saveHNSWSystem)
- Other 4 indexes unaffected (Metadata, Graph Adjacency, Deleted Items, Entity ID Mapper)
- No regression risk - isolated code paths
**Testing:**
- 8/8 unit tests passing (real concurrent operations, no mocks)
- Tests verify data integrity after 20 concurrent updates
- Tests verify temp file cleanup and mutex serialization
**Files Modified:**
- All 8 storage adapters (FileSystem, GCS, S3, R2, Azure, Memory, OPFS)
- HNSW Index (neighbor update serialization)
- New test: tests/unit/storage/hnswConcurrency.test.ts (8 passing tests)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 15:24:20 -07:00
|
|
|
// CRITICAL FIX (v4.10.1): Mutex locks for HNSW concurrency control
|
|
|
|
|
// Even in-memory operations need serialization to prevent async race conditions
|
|
|
|
|
private hnswLocks = new Map<string, Promise<void>>()
|
|
|
|
|
|
2025-10-10 11:15:17 -07:00
|
|
|
/**
|
|
|
|
|
* Save HNSW graph data for a noun
|
fix: resolve HNSW concurrency race condition across all storage adapters
Fixes critical P0 bug causing data corruption during bulk imports with 50+ concurrent operations. The non-atomic read-modify-write pattern in saveHNSWData() combined with fire-and-forget neighbor updates was causing 16-32 concurrent writes per entity, resulting in lost HNSW connections and corrupted graph structure.
**Root Cause:**
- saveHNSWData() used non-atomic read-modify-write
- HNSW neighbor updates fired without await (16-32 concurrent writes/entity)
- Popular nodes became hotspots (100 concurrent imports = 3,400 concurrent saveHNSWData calls)
- Result: Lost neighbor connections, 0 search results
**Atomic Write Strategies by Adapter:**
FileSystemStorage:
- Atomic rename with temp files
- Write to {file}.tmp.{timestamp}.{random}
- POSIX-guaranteed atomic rename(temp, final)
GCSStorage:
- Optimistic locking with generation numbers
- preconditionOpts: { ifGenerationMatch }
- 5 retries with exponential backoff (50ms→800ms)
S3/R2/AzureStorage:
- ETag-based optimistic locking
- IfMatch/conditions preconditions
- 5 retries with exponential backoff
MemoryStorage + OPFSStorage:
- Mutex locks per entity path
- Serializes async operations even in single-threaded environments
HNSW Index:
- Changed fire-and-forget .catch() to await
- Serializes 16-32 neighbor updates per entity
- Trade-off: 20-30% slower bulk import vs 100% data integrity
**Sharding Compatibility:**
- ✅ Works with deterministic UUID sharding (256 shards, always on)
- ✅ Works with distributed multi-node sharding (optional)
- ✅ All atomic strategies work in both single-node and distributed deployments
**Index Impact:**
- Only HNSW index modified (saveHNSWData, saveHNSWSystem)
- Other 4 indexes unaffected (Metadata, Graph Adjacency, Deleted Items, Entity ID Mapper)
- No regression risk - isolated code paths
**Testing:**
- 8/8 unit tests passing (real concurrent operations, no mocks)
- Tests verify data integrity after 20 concurrent updates
- Tests verify temp file cleanup and mutex serialization
**Files Modified:**
- All 8 storage adapters (FileSystem, GCS, S3, R2, Azure, Memory, OPFS)
- HNSW Index (neighbor update serialization)
- New test: tests/unit/storage/hnswConcurrency.test.ts (8 passing tests)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 15:24:20 -07:00
|
|
|
*
|
|
|
|
|
* CRITICAL FIX (v4.10.1): Mutex locking to prevent race conditions during concurrent HNSW updates
|
|
|
|
|
* Even in-memory operations can race due to async/await interleaving
|
|
|
|
|
* Prevents data corruption when multiple entities connect to same neighbor simultaneously
|
2025-10-10 11:15:17 -07:00
|
|
|
*/
|
|
|
|
|
public async saveHNSWData(nounId: string, hnswData: {
|
|
|
|
|
level: number
|
|
|
|
|
connections: Record<string, string[]>
|
|
|
|
|
}): Promise<void> {
|
|
|
|
|
const path = `hnsw/${nounId}.json`
|
fix: resolve HNSW concurrency race condition across all storage adapters
Fixes critical P0 bug causing data corruption during bulk imports with 50+ concurrent operations. The non-atomic read-modify-write pattern in saveHNSWData() combined with fire-and-forget neighbor updates was causing 16-32 concurrent writes per entity, resulting in lost HNSW connections and corrupted graph structure.
**Root Cause:**
- saveHNSWData() used non-atomic read-modify-write
- HNSW neighbor updates fired without await (16-32 concurrent writes/entity)
- Popular nodes became hotspots (100 concurrent imports = 3,400 concurrent saveHNSWData calls)
- Result: Lost neighbor connections, 0 search results
**Atomic Write Strategies by Adapter:**
FileSystemStorage:
- Atomic rename with temp files
- Write to {file}.tmp.{timestamp}.{random}
- POSIX-guaranteed atomic rename(temp, final)
GCSStorage:
- Optimistic locking with generation numbers
- preconditionOpts: { ifGenerationMatch }
- 5 retries with exponential backoff (50ms→800ms)
S3/R2/AzureStorage:
- ETag-based optimistic locking
- IfMatch/conditions preconditions
- 5 retries with exponential backoff
MemoryStorage + OPFSStorage:
- Mutex locks per entity path
- Serializes async operations even in single-threaded environments
HNSW Index:
- Changed fire-and-forget .catch() to await
- Serializes 16-32 neighbor updates per entity
- Trade-off: 20-30% slower bulk import vs 100% data integrity
**Sharding Compatibility:**
- ✅ Works with deterministic UUID sharding (256 shards, always on)
- ✅ Works with distributed multi-node sharding (optional)
- ✅ All atomic strategies work in both single-node and distributed deployments
**Index Impact:**
- Only HNSW index modified (saveHNSWData, saveHNSWSystem)
- Other 4 indexes unaffected (Metadata, Graph Adjacency, Deleted Items, Entity ID Mapper)
- No regression risk - isolated code paths
**Testing:**
- 8/8 unit tests passing (real concurrent operations, no mocks)
- Tests verify data integrity after 20 concurrent updates
- Tests verify temp file cleanup and mutex serialization
**Files Modified:**
- All 8 storage adapters (FileSystem, GCS, S3, R2, Azure, Memory, OPFS)
- HNSW Index (neighbor update serialization)
- New test: tests/unit/storage/hnswConcurrency.test.ts (8 passing tests)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 15:24:20 -07:00
|
|
|
|
|
|
|
|
// MUTEX LOCK: Wait for any pending operations on this entity
|
|
|
|
|
while (this.hnswLocks.has(path)) {
|
|
|
|
|
await this.hnswLocks.get(path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Acquire lock by creating a promise that we'll resolve when done
|
|
|
|
|
let releaseLock!: () => void
|
|
|
|
|
const lockPromise = new Promise<void>(resolve => { releaseLock = resolve })
|
|
|
|
|
this.hnswLocks.set(path, lockPromise)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Read existing data (if exists)
|
|
|
|
|
let existingNode: any = {}
|
|
|
|
|
const existing = this.objectStore.get(path)
|
|
|
|
|
if (existing) {
|
|
|
|
|
existingNode = existing
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Preserve id and vector, update only HNSW graph metadata
|
|
|
|
|
const updatedNode = {
|
|
|
|
|
...existingNode, // Preserve all existing fields
|
|
|
|
|
level: hnswData.level,
|
|
|
|
|
connections: hnswData.connections
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Write atomically (in-memory, but now serialized by mutex)
|
|
|
|
|
this.objectStore.set(path, JSON.parse(JSON.stringify(updatedNode)))
|
|
|
|
|
} finally {
|
|
|
|
|
// Release lock
|
|
|
|
|
this.hnswLocks.delete(path)
|
|
|
|
|
releaseLock()
|
|
|
|
|
}
|
2025-10-10 11:15:17 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get HNSW graph data for a noun
|
|
|
|
|
*/
|
|
|
|
|
public async getHNSWData(nounId: string): Promise<{
|
|
|
|
|
level: number
|
|
|
|
|
connections: Record<string, string[]>
|
|
|
|
|
} | null> {
|
|
|
|
|
const path = `hnsw/${nounId}.json`
|
|
|
|
|
const data = await this.readObjectFromPath(path)
|
|
|
|
|
return data || null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Save HNSW system data (entry point, max level)
|
fix: resolve HNSW concurrency race condition across all storage adapters
Fixes critical P0 bug causing data corruption during bulk imports with 50+ concurrent operations. The non-atomic read-modify-write pattern in saveHNSWData() combined with fire-and-forget neighbor updates was causing 16-32 concurrent writes per entity, resulting in lost HNSW connections and corrupted graph structure.
**Root Cause:**
- saveHNSWData() used non-atomic read-modify-write
- HNSW neighbor updates fired without await (16-32 concurrent writes/entity)
- Popular nodes became hotspots (100 concurrent imports = 3,400 concurrent saveHNSWData calls)
- Result: Lost neighbor connections, 0 search results
**Atomic Write Strategies by Adapter:**
FileSystemStorage:
- Atomic rename with temp files
- Write to {file}.tmp.{timestamp}.{random}
- POSIX-guaranteed atomic rename(temp, final)
GCSStorage:
- Optimistic locking with generation numbers
- preconditionOpts: { ifGenerationMatch }
- 5 retries with exponential backoff (50ms→800ms)
S3/R2/AzureStorage:
- ETag-based optimistic locking
- IfMatch/conditions preconditions
- 5 retries with exponential backoff
MemoryStorage + OPFSStorage:
- Mutex locks per entity path
- Serializes async operations even in single-threaded environments
HNSW Index:
- Changed fire-and-forget .catch() to await
- Serializes 16-32 neighbor updates per entity
- Trade-off: 20-30% slower bulk import vs 100% data integrity
**Sharding Compatibility:**
- ✅ Works with deterministic UUID sharding (256 shards, always on)
- ✅ Works with distributed multi-node sharding (optional)
- ✅ All atomic strategies work in both single-node and distributed deployments
**Index Impact:**
- Only HNSW index modified (saveHNSWData, saveHNSWSystem)
- Other 4 indexes unaffected (Metadata, Graph Adjacency, Deleted Items, Entity ID Mapper)
- No regression risk - isolated code paths
**Testing:**
- 8/8 unit tests passing (real concurrent operations, no mocks)
- Tests verify data integrity after 20 concurrent updates
- Tests verify temp file cleanup and mutex serialization
**Files Modified:**
- All 8 storage adapters (FileSystem, GCS, S3, R2, Azure, Memory, OPFS)
- HNSW Index (neighbor update serialization)
- New test: tests/unit/storage/hnswConcurrency.test.ts (8 passing tests)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 15:24:20 -07:00
|
|
|
*
|
|
|
|
|
* CRITICAL FIX (v4.10.1): Mutex locking to prevent race conditions
|
2025-10-10 11:15:17 -07:00
|
|
|
*/
|
|
|
|
|
public async saveHNSWSystem(systemData: {
|
|
|
|
|
entryPointId: string | null
|
|
|
|
|
maxLevel: number
|
|
|
|
|
}): Promise<void> {
|
|
|
|
|
const path = 'system/hnsw-system.json'
|
fix: resolve HNSW concurrency race condition across all storage adapters
Fixes critical P0 bug causing data corruption during bulk imports with 50+ concurrent operations. The non-atomic read-modify-write pattern in saveHNSWData() combined with fire-and-forget neighbor updates was causing 16-32 concurrent writes per entity, resulting in lost HNSW connections and corrupted graph structure.
**Root Cause:**
- saveHNSWData() used non-atomic read-modify-write
- HNSW neighbor updates fired without await (16-32 concurrent writes/entity)
- Popular nodes became hotspots (100 concurrent imports = 3,400 concurrent saveHNSWData calls)
- Result: Lost neighbor connections, 0 search results
**Atomic Write Strategies by Adapter:**
FileSystemStorage:
- Atomic rename with temp files
- Write to {file}.tmp.{timestamp}.{random}
- POSIX-guaranteed atomic rename(temp, final)
GCSStorage:
- Optimistic locking with generation numbers
- preconditionOpts: { ifGenerationMatch }
- 5 retries with exponential backoff (50ms→800ms)
S3/R2/AzureStorage:
- ETag-based optimistic locking
- IfMatch/conditions preconditions
- 5 retries with exponential backoff
MemoryStorage + OPFSStorage:
- Mutex locks per entity path
- Serializes async operations even in single-threaded environments
HNSW Index:
- Changed fire-and-forget .catch() to await
- Serializes 16-32 neighbor updates per entity
- Trade-off: 20-30% slower bulk import vs 100% data integrity
**Sharding Compatibility:**
- ✅ Works with deterministic UUID sharding (256 shards, always on)
- ✅ Works with distributed multi-node sharding (optional)
- ✅ All atomic strategies work in both single-node and distributed deployments
**Index Impact:**
- Only HNSW index modified (saveHNSWData, saveHNSWSystem)
- Other 4 indexes unaffected (Metadata, Graph Adjacency, Deleted Items, Entity ID Mapper)
- No regression risk - isolated code paths
**Testing:**
- 8/8 unit tests passing (real concurrent operations, no mocks)
- Tests verify data integrity after 20 concurrent updates
- Tests verify temp file cleanup and mutex serialization
**Files Modified:**
- All 8 storage adapters (FileSystem, GCS, S3, R2, Azure, Memory, OPFS)
- HNSW Index (neighbor update serialization)
- New test: tests/unit/storage/hnswConcurrency.test.ts (8 passing tests)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 15:24:20 -07:00
|
|
|
|
|
|
|
|
// MUTEX LOCK: Wait for any pending operations
|
|
|
|
|
while (this.hnswLocks.has(path)) {
|
|
|
|
|
await this.hnswLocks.get(path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Acquire lock
|
|
|
|
|
let releaseLock!: () => void
|
|
|
|
|
const lockPromise = new Promise<void>(resolve => { releaseLock = resolve })
|
|
|
|
|
this.hnswLocks.set(path, lockPromise)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Write atomically (serialized by mutex)
|
|
|
|
|
this.objectStore.set(path, JSON.parse(JSON.stringify(systemData)))
|
|
|
|
|
} finally {
|
|
|
|
|
// Release lock
|
|
|
|
|
this.hnswLocks.delete(path)
|
|
|
|
|
releaseLock()
|
|
|
|
|
}
|
2025-10-10 11:15:17 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get HNSW system data
|
|
|
|
|
*/
|
|
|
|
|
public async getHNSWSystem(): Promise<{
|
|
|
|
|
entryPointId: string | null
|
|
|
|
|
maxLevel: number
|
|
|
|
|
} | null> {
|
|
|
|
|
const path = 'system/hnsw-system.json'
|
|
|
|
|
const data = await this.readObjectFromPath(path)
|
|
|
|
|
return data || null
|
|
|
|
|
}
|
2025-08-26 12:32:21 -07:00
|
|
|
}
|