/** * OPFS (Origin Private File System) Storage Adapter * Provides persistent storage for the vector database using the Origin Private File System API */ import { GraphVerb, HNSWNoun, HNSWVerb, NounMetadata, VerbMetadata, HNSWNounWithMetadata, HNSWVerbWithMetadata, StatisticsData } from '../../coreTypes.js' import { BaseStorage, NOUNS_DIR, VERBS_DIR, METADATA_DIR, NOUN_METADATA_DIR, VERB_METADATA_DIR, INDEX_DIR, STATISTICS_KEY } from '../baseStorage.js' import { getShardIdFromUuid } from '../sharding.js' import '../../types/fileSystemTypes.js' // Type alias for HNSWNode type HNSWNode = HNSWNoun /** * Type alias for HNSWVerb to make the code more readable */ type Edge = HNSWVerb /** * Helper function to safely get a file from a FileSystemHandle * This is needed because TypeScript doesn't recognize that a FileSystemHandle * can be a FileSystemFileHandle which has the getFile method */ async function safeGetFile(handle: FileSystemHandle): Promise { // Type cast to any to avoid TypeScript error return (handle as any).getFile() } // Type aliases for better readability type HNSWNoun_internal = HNSWNoun type Verb = GraphVerb // Root directory name for OPFS storage const ROOT_DIR = 'opfs-vector-db' /** * OPFS storage adapter for browser environments * Uses the Origin Private File System API to store data persistently */ export class OPFSStorage extends BaseStorage { private rootDir: FileSystemDirectoryHandle | null = null private nounsDir: FileSystemDirectoryHandle | null = null private verbsDir: FileSystemDirectoryHandle | null = null private metadataDir: FileSystemDirectoryHandle | null = null private nounMetadataDir: FileSystemDirectoryHandle | null = null private verbMetadataDir: FileSystemDirectoryHandle | null = null private indexDir: FileSystemDirectoryHandle | null = null private isAvailable = false private isPersistentRequested = false private isPersistentGranted = false private statistics: StatisticsData | null = null private activeLocks: Set = new Set() private lockPrefix = 'opfs-lock-' constructor() { super() // Check if OPFS is available this.isAvailable = typeof navigator !== 'undefined' && 'storage' in navigator && 'getDirectory' in navigator.storage } /** * Initialize the storage adapter */ public async init(): Promise { if (this.isInitialized) { return } if (!this.isAvailable) { throw new Error( 'Origin Private File System is not available in this environment' ) } try { // Get the root directory const root = await navigator.storage.getDirectory() // Create or get our app's root directory this.rootDir = await root.getDirectoryHandle(ROOT_DIR, { create: true }) // Create or get nouns directory this.nounsDir = await this.rootDir.getDirectoryHandle(NOUNS_DIR, { create: true }) // Create or get verbs directory this.verbsDir = await this.rootDir.getDirectoryHandle(VERBS_DIR, { create: true }) // Create or get metadata directory this.metadataDir = await this.rootDir.getDirectoryHandle(METADATA_DIR, { create: true }) // Create or get noun metadata directory this.nounMetadataDir = await this.rootDir.getDirectoryHandle( NOUN_METADATA_DIR, { create: true } ) // Create or get verb metadata directory this.verbMetadataDir = await this.rootDir.getDirectoryHandle( VERB_METADATA_DIR, { create: true } ) // Create or get index directory this.indexDir = await this.rootDir.getDirectoryHandle(INDEX_DIR, { create: true }) // Initialize counts from storage await this.initializeCounts() this.isInitialized = true } catch (error) { console.error('Failed to initialize OPFS storage:', error) throw new Error(`Failed to initialize OPFS storage: ${error}`) } } /** * Check if OPFS is available in the current environment */ public isOPFSAvailable(): boolean { return this.isAvailable } /** * Request persistent storage permission from the user * @returns Promise that resolves to true if permission was granted, false otherwise */ public async requestPersistentStorage(): Promise { if (!this.isAvailable) { console.warn('Cannot request persistent storage: OPFS is not available') return false } try { // Check if persistence is already granted this.isPersistentGranted = await navigator.storage.persisted() if (!this.isPersistentGranted) { // Request permission for persistent storage this.isPersistentGranted = await navigator.storage.persist() } this.isPersistentRequested = true return this.isPersistentGranted } catch (error) { console.warn('Failed to request persistent storage:', error) return false } } /** * Check if persistent storage is granted * @returns Promise that resolves to true if persistent storage is granted, false otherwise */ public async isPersistent(): Promise { if (!this.isAvailable) { return false } try { this.isPersistentGranted = await navigator.storage.persisted() return this.isPersistentGranted } catch (error) { console.warn('Failed to check persistent storage status:', error) return false } } /** * Save a noun to storage */ protected async saveNoun_internal(noun: HNSWNoun_internal): Promise { await this.ensureInitialized() try { // CRITICAL: Only save lightweight vector data (no metadata) // Metadata is saved separately via saveNounMetadata() (2-file system) const serializableNoun = { id: noun.id, vector: noun.vector, connections: this.mapToObject(noun.connections, (set) => Array.from(set as Set) ), level: noun.level || 0 // NO metadata field - saved separately for scalability } // Use UUID-based sharding for nouns const shardId = getShardIdFromUuid(noun.id) // Get or create the shard directory const shardDir = await this.nounsDir!.getDirectoryHandle(shardId, { create: true }) // Create or get the file in the shard directory const fileHandle = await shardDir.getFileHandle(`${noun.id}.json`, { create: true }) // Write the noun data to the file const writable = await fileHandle.createWritable() await writable.write(JSON.stringify(serializableNoun)) await writable.close() } catch (error) { console.error(`Failed to save noun ${noun.id}:`, error) throw new Error(`Failed to save noun ${noun.id}: ${error}`) } } /** * Get a noun from storage (internal implementation) * Combines vector data from file with metadata from getNounMetadata() */ protected async getNoun_internal( id: string ): Promise { await this.ensureInitialized() try { // Use UUID-based sharding for nouns const shardId = getShardIdFromUuid(id) // Get the shard directory const shardDir = await this.nounsDir!.getDirectoryHandle(shardId) // Get the file handle from the shard directory const fileHandle = await shardDir.getFileHandle(`${id}.json`) // Read the noun data from the file const file = await fileHandle.getFile() const text = await file.text() const data = JSON.parse(text) // Convert serialized connections back to Map> const connections = new Map>() for (const [level, nounIds] of Object.entries(data.connections)) { connections.set(Number(level), new Set(nounIds as string[])) } // v4.0.0: Return ONLY vector data (no metadata field) const node: HNSWNode = { id: data.id, vector: data.vector, connections, level: data.level || 0 } // Return pure vector structure return node } catch (error) { // Noun not found or other error return null } } /** * Get nouns by noun type (internal implementation) * @param nounType The noun type to filter by * @returns Promise that resolves to an array of nouns of the specified noun type */ protected async getNounsByNounType_internal( nounType: string ): Promise { return this.getNodesByNounType(nounType) } /** * Get nodes by noun type * @param nounType The noun type to filter by * @returns Promise that resolves to an array of nodes of the specified noun type */ protected async getNodesByNounType(nounType: string): Promise { await this.ensureInitialized() const nodes: HNSWNode[] = [] try { // Iterate through all shard directories for await (const [shardName, shardHandle] of this.nounsDir!.entries()) { if (shardHandle.kind === 'directory') { const shardDir = shardHandle as FileSystemDirectoryHandle // Iterate through all files in this shard for await (const [fileName, fileHandle] of shardDir.entries()) { if (fileHandle.kind === 'file') { try { // Read the node data from the file const file = await safeGetFile(fileHandle) const text = await file.text() const data = JSON.parse(text) // Get the metadata to check the noun type const metadata = await this.getMetadata(data.id) // Include the node if its noun type matches the requested type if (metadata && metadata.noun === nounType) { // Convert serialized connections back to Map> const connections = new Map>() for (const [level, nodeIds] of Object.entries(data.connections)) { connections.set(Number(level), new Set(nodeIds as string[])) } nodes.push({ id: data.id, vector: data.vector, connections, level: data.level || 0 }) } } catch (error) { console.error(`Error reading node file ${shardName}/${fileName}:`, error) } } } } } } catch (error) { console.error('Error reading nouns directory:', error) } return nodes } /** * Delete a noun from storage (internal implementation) */ protected async deleteNoun_internal(id: string): Promise { return this.deleteNode(id) } /** * Delete a node from storage */ protected async deleteNode(id: string): Promise { await this.ensureInitialized() try { // Use UUID-based sharding for nouns const shardId = getShardIdFromUuid(id) // Get the shard directory const shardDir = await this.nounsDir!.getDirectoryHandle(shardId) // Delete the file from the shard directory await shardDir.removeEntry(`${id}.json`) } catch (error: any) { // Ignore NotFoundError, which means the file doesn't exist if (error.name !== 'NotFoundError') { console.error(`Error deleting node ${id}:`, error) throw error } } } /** * Save a verb to storage (internal implementation) */ protected async saveVerb_internal(verb: HNSWVerb): Promise { return this.saveEdge(verb) } /** * Save an edge to storage */ protected async saveEdge(edge: Edge): Promise { await this.ensureInitialized() try { // ARCHITECTURAL FIX (v3.50.1): Include core relational fields in verb vector file // These fields are essential for 90% of operations - no metadata lookup needed const serializableEdge = { id: edge.id, vector: edge.vector, connections: this.mapToObject(edge.connections, (set) => Array.from(set as Set) ), // CORE RELATIONAL DATA (v3.50.1+) verb: edge.verb, sourceId: edge.sourceId, targetId: edge.targetId, // User metadata (if any) - saved separately for scalability // metadata field is saved separately via saveVerbMetadata() } // Use UUID-based sharding for verbs const shardId = getShardIdFromUuid(edge.id) // Get or create the shard directory const shardDir = await this.verbsDir!.getDirectoryHandle(shardId, { create: true }) // Create or get the file in the shard directory const fileHandle = await shardDir.getFileHandle(`${edge.id}.json`, { create: true }) // Write the verb data to the file const writable = await fileHandle.createWritable() await writable.write(JSON.stringify(serializableEdge)) await writable.close() } catch (error) { console.error(`Failed to save edge ${edge.id}:`, error) throw new Error(`Failed to save edge ${edge.id}: ${error}`) } } /** * Get a verb from storage (internal implementation) * v4.0.0: Returns ONLY vector + core relational fields (no metadata field) * Base class combines with metadata via getVerb() -> HNSWVerbWithMetadata */ protected async getVerb_internal(id: string): Promise { // v4.0.0: Return ONLY vector + core relational data (no metadata field) const edge = await this.getEdge(id) if (!edge) { return null } // Return pure vector + core fields structure return edge } /** * Get an edge from storage */ protected async getEdge(id: string): Promise { await this.ensureInitialized() try { // Use UUID-based sharding for verbs const shardId = getShardIdFromUuid(id) // Get the shard directory const shardDir = await this.verbsDir!.getDirectoryHandle(shardId) // Get the file handle from the shard directory const fileHandle = await shardDir.getFileHandle(`${id}.json`) // Read the edge data from the file const file = await fileHandle.getFile() const text = await file.text() const data = JSON.parse(text) // Convert serialized connections back to Map> const connections = new Map>() for (const [level, nodeIds] of Object.entries(data.connections)) { connections.set(Number(level), new Set(nodeIds as string[])) } // Create default timestamp if not present const defaultTimestamp = { seconds: Math.floor(Date.now() / 1000), nanoseconds: (Date.now() % 1000) * 1000000 } // Create default createdBy if not present const defaultCreatedBy = { augmentation: 'unknown', version: '1.0' } // v4.0.0: Return HNSWVerb with core relational fields (NO metadata field) return { id: data.id, vector: data.vector, connections, // CORE RELATIONAL DATA (read from vector file) verb: data.verb, sourceId: data.sourceId, targetId: data.targetId // ✅ NO metadata field in v4.0.0 // User metadata retrieved separately via getVerbMetadata() } } catch (error) { // Edge not found or other error return null } } /** * Get all edges from storage */ protected async getAllEdges(): Promise { await this.ensureInitialized() const allEdges: Edge[] = [] try { // Iterate through all shard directories for await (const [shardName, shardHandle] of this.verbsDir!.entries()) { if (shardHandle.kind === 'directory') { const shardDir = shardHandle as FileSystemDirectoryHandle // Iterate through all files in this shard for await (const [fileName, fileHandle] of shardDir.entries()) { if (fileHandle.kind === 'file') { try { // Read the edge data from the file const file = await safeGetFile(fileHandle) const text = await file.text() const data = JSON.parse(text) // Convert serialized connections back to Map> const connections = new Map>() for (const [level, nodeIds] of Object.entries(data.connections)) { connections.set(Number(level), new Set(nodeIds as string[])) } // Create default timestamp if not present const defaultTimestamp = { seconds: Math.floor(Date.now() / 1000), nanoseconds: (Date.now() % 1000) * 1000000 } // Create default createdBy if not present const defaultCreatedBy = { augmentation: 'unknown', version: '1.0' } // v4.0.0: Include core relational fields (NO metadata field) allEdges.push({ id: data.id, vector: data.vector, connections, // CORE RELATIONAL DATA verb: data.verb, sourceId: data.sourceId, targetId: data.targetId // ✅ NO metadata field in v4.0.0 // User metadata retrieved separately via getVerbMetadata() }) } catch (error) { console.error(`Error reading edge file ${shardName}/${fileName}:`, error) } } } } } } catch (error) { console.error('Error reading verbs directory:', error) } return allEdges } /** * Get verbs by source (internal implementation) */ protected async getVerbsBySource_internal( sourceId: string ): Promise { // Use the paginated approach to properly handle HNSWVerb to GraphVerb conversion const result = await this.getVerbsWithPagination({ filter: { sourceId: [sourceId] }, limit: Number.MAX_SAFE_INTEGER // Get all matching results }) return result.items } /** * Get edges by source */ protected async getEdgesBySource(sourceId: string): Promise { // This method is deprecated and would require loading metadata for each edge // For now, return empty array since this is not efficiently implementable with new storage pattern console.warn( 'getEdgesBySource is deprecated and not efficiently supported in new storage pattern' ) return [] } /** * Get verbs by target (internal implementation) */ protected async getVerbsByTarget_internal( targetId: string ): Promise { // Use the paginated approach to properly handle HNSWVerb to GraphVerb conversion const result = await this.getVerbsWithPagination({ filter: { targetId: [targetId] }, limit: Number.MAX_SAFE_INTEGER // Get all matching results }) return result.items } /** * Get edges by target */ protected async getEdgesByTarget(targetId: string): Promise { // This method is deprecated and would require loading metadata for each edge // For now, return empty array since this is not efficiently implementable with new storage pattern console.warn( 'getEdgesByTarget is deprecated and not efficiently supported in new storage pattern' ) return [] } /** * Get verbs by type (internal implementation) */ protected async getVerbsByType_internal(type: string): Promise { // Use the paginated approach to properly handle HNSWVerb to GraphVerb conversion const result = await this.getVerbsWithPagination({ filter: { verbType: [type] }, limit: Number.MAX_SAFE_INTEGER // Get all matching results }) return result.items } /** * Get edges by type */ protected async getEdgesByType(type: string): Promise { // This method is deprecated and would require loading metadata for each edge // For now, return empty array since this is not efficiently implementable with new storage pattern console.warn( 'getEdgesByType is deprecated and not efficiently supported in new storage pattern' ) return [] } /** * Delete a verb from storage (internal implementation) */ protected async deleteVerb_internal(id: string): Promise { return this.deleteEdge(id) } /** * Delete an edge from storage */ protected async deleteEdge(id: string): Promise { await this.ensureInitialized() try { // Use UUID-based sharding for verbs const shardId = getShardIdFromUuid(id) // Get the shard directory const shardDir = await this.verbsDir!.getDirectoryHandle(shardId) // Delete the file from the shard directory await shardDir.removeEntry(`${id}.json`) } catch (error: any) { // Ignore NotFoundError, which means the file doesn't exist if (error.name !== 'NotFoundError') { console.error(`Error deleting edge ${id}:`, error) throw error } } } /** * Primitive operation: Write object to path * All metadata operations use this internally via base class routing */ protected async writeObjectToPath(path: string, data: any): Promise { await this.ensureInitialized() try { // Parse path to get directory structure and filename // Path format: "dir1/dir2/file.json" const parts = path.split('/') const filename = parts.pop()! // Navigate to the correct directory, creating as needed let currentDir = this.rootDir! for (const dirName of parts) { currentDir = await currentDir.getDirectoryHandle(dirName, { create: true }) } // Create or get the file const fileHandle = await currentDir.getFileHandle(filename, { create: true }) // Write the data to the file const writable = await fileHandle.createWritable() await writable.write(JSON.stringify(data, null, 2)) await writable.close() } catch (error) { console.error(`Failed to write object to ${path}:`, error) throw new Error(`Failed to write object to ${path}: ${error}`) } } /** * Primitive operation: Read object from path * All metadata operations use this internally via base class routing */ protected async readObjectFromPath(path: string): Promise { await this.ensureInitialized() try { // Parse path to get directory structure and filename const parts = path.split('/') const filename = parts.pop()! // Navigate to the correct directory let currentDir = this.rootDir! for (const dirName of parts) { currentDir = await currentDir.getDirectoryHandle(dirName) } // Get the file handle const fileHandle = await currentDir.getFileHandle(filename) // Read the data from the file const file = await fileHandle.getFile() const text = await file.text() return JSON.parse(text) } catch (error: any) { // NotFoundError means object doesn't exist if (error.name === 'NotFoundError') { return null } console.error(`Failed to read object from ${path}:`, error) return null } } /** * Primitive operation: Delete object from path * All metadata operations use this internally via base class routing */ protected async deleteObjectFromPath(path: string): Promise { await this.ensureInitialized() try { // Parse path to get directory structure and filename const parts = path.split('/') const filename = parts.pop()! // Navigate to the correct directory let currentDir = this.rootDir! for (const dirName of parts) { currentDir = await currentDir.getDirectoryHandle(dirName) } // Delete the file await currentDir.removeEntry(filename) } catch (error: any) { // NotFoundError is ok (already deleted) if (error.name === 'NotFoundError') { return } console.error(`Failed to delete object from ${path}:`, error) throw new Error(`Failed to delete object from ${path}: ${error}`) } } /** * Primitive operation: List objects under path prefix * All metadata operations use this internally via base class routing */ protected async listObjectsUnderPath(prefix: string): Promise { await this.ensureInitialized() try { const paths: string[] = [] // Parse prefix to get directory structure const parts = prefix.split('/') // Navigate to the directory let currentDir = this.rootDir! for (const dirName of parts) { if (dirName) { currentDir = await currentDir.getDirectoryHandle(dirName) } } // Recursively list all files const listFiles = async (dir: FileSystemDirectoryHandle, pathPrefix: string): Promise => { for await (const [name, handle] of dir.entries()) { const fullPath = pathPrefix ? `${pathPrefix}/${name}` : name if (handle.kind === 'file') { paths.push(`${prefix}${fullPath}`) } else if (handle.kind === 'directory') { await listFiles(handle as FileSystemDirectoryHandle, fullPath) } } } await listFiles(currentDir, '') return paths } catch (error: any) { // NotFoundError means directory doesn't exist if (error.name === 'NotFoundError') { return [] } console.error(`Failed to list objects under ${prefix}:`, error) throw new Error(`Failed to list objects under ${prefix}: ${error}`) } } /** * Get multiple metadata objects in batches (CRITICAL: Prevents socket exhaustion) * OPFS implementation uses controlled concurrency for file operations */ public async getMetadataBatch(ids: string[]): Promise> { await this.ensureInitialized() const results = new Map() const batchSize = 10 // Process 10 files at a time // Process in batches to avoid overwhelming OPFS for (let i = 0; i < ids.length; i += batchSize) { const batch = ids.slice(i, i + batchSize) const batchPromises = batch.map(async (id) => { try { const metadata = await this.getNounMetadata(id) return { id, metadata } } catch (error) { console.debug(`Failed to read metadata for ${id}:`, error) return { id, metadata: null } } }) const batchResults = await Promise.all(batchPromises) for (const { id, metadata } of batchResults) { if (metadata !== null) { results.set(id, metadata) } } // Small yield between batches await new Promise(resolve => setImmediate(resolve)) } return results } /** * Clear all data from storage */ public async clear(): Promise { await this.ensureInitialized() // Helper function to remove all files in a directory const removeDirectoryContents = async ( dirHandle: FileSystemDirectoryHandle ): Promise => { try { for await (const [name, handle] of dirHandle.entries()) { // Use recursive option to handle directories that may contain files await dirHandle.removeEntry(name, { recursive: true }) } } catch (error) { console.error(`Error removing directory contents:`, error) throw error } } try { // Remove all files in the nouns directory await removeDirectoryContents(this.nounsDir!) // Remove all files in the verbs directory await removeDirectoryContents(this.verbsDir!) // Remove all files in the metadata directory await removeDirectoryContents(this.metadataDir!) // Remove all files in the noun metadata directory await removeDirectoryContents(this.nounMetadataDir!) // Remove all files in the verb metadata directory await removeDirectoryContents(this.verbMetadataDir!) // Remove all files in the index directory await removeDirectoryContents(this.indexDir!) // Clear the statistics cache this.statisticsCache = null this.statisticsModified = false } catch (error) { console.error('Error clearing storage:', error) throw error } } /** * Get information about storage usage and capacity */ public async getStorageStatus(): Promise<{ type: string used: number quota: number | null details?: Record }> { await this.ensureInitialized() try { // Calculate the total size of all files in the storage directories let totalSize = 0 // Helper function to calculate directory size const calculateDirSize = async ( dirHandle: FileSystemDirectoryHandle ): Promise => { let size = 0 try { for await (const [name, handle] of dirHandle.entries()) { if (handle.kind === 'file') { const file = await (handle as FileSystemFileHandle).getFile() size += file.size } else if (handle.kind === 'directory') { size += await calculateDirSize( handle as FileSystemDirectoryHandle ) } } } catch (error) { console.warn(`Error calculating size for directory:`, error) } return size } // Helper function to count files in a directory const countFilesInDirectory = async ( dirHandle: FileSystemDirectoryHandle ): Promise => { let count = 0 try { for await (const [name, handle] of dirHandle.entries()) { if (handle.kind === 'file') { count++ } } } catch (error) { console.warn(`Error counting files in directory:`, error) } return count } // Calculate size for each directory if (this.nounsDir) { totalSize += await calculateDirSize(this.nounsDir) } if (this.verbsDir) { totalSize += await calculateDirSize(this.verbsDir) } if (this.metadataDir) { totalSize += await calculateDirSize(this.metadataDir) } if (this.indexDir) { totalSize += await calculateDirSize(this.indexDir) } // Get storage quota information using the Storage API let quota = null let details: Record = { isPersistent: await this.isPersistent(), nounTypes: {} } try { if (navigator.storage && navigator.storage.estimate) { const estimate = await navigator.storage.estimate() quota = estimate.quota || null details = { ...details, usage: estimate.usage, quota: estimate.quota, freePercentage: estimate.quota ? ((estimate.quota - (estimate.usage || 0)) / estimate.quota) * 100 : null } } } catch (error) { console.warn('Unable to get storage estimate:', error) } // Count files in each directory if (this.nounsDir) { details.nounsCount = await countFilesInDirectory(this.nounsDir) } if (this.verbsDir) { details.verbsCount = await countFilesInDirectory(this.verbsDir) } if (this.metadataDir) { details.metadataCount = await countFilesInDirectory(this.metadataDir) } // Count nouns by type using metadata const nounTypeCounts: Record = {} if (this.metadataDir) { for await (const [name, handle] of this.metadataDir.entries()) { if (handle.kind === 'file') { try { const file = await safeGetFile(handle) const text = await file.text() const metadata = JSON.parse(text) if (metadata.noun) { nounTypeCounts[metadata.noun] = (nounTypeCounts[metadata.noun] || 0) + 1 } } catch (error) { console.error(`Error reading metadata file ${name}:`, error) } } } } details.nounTypes = nounTypeCounts return { type: 'opfs', used: totalSize, quota, details } } catch (error) { console.error('Failed to get storage status:', error) return { type: 'opfs', used: 0, quota: null, details: { error: String(error) } } } } /** * Get the statistics key for a specific date * @param date The date to get the key for * @returns The statistics key for the specified date */ private getStatisticsKeyForDate(date: Date): string { const year = date.getUTCFullYear() const month = String(date.getUTCMonth() + 1).padStart(2, '0') const day = String(date.getUTCDate()).padStart(2, '0') return `statistics_${year}${month}${day}.json` } /** * Get the current statistics key * @returns The current statistics key */ private getCurrentStatisticsKey(): string { return this.getStatisticsKeyForDate(new Date()) } /** * Get the legacy statistics key (for backward compatibility) * @returns The legacy statistics key */ private getLegacyStatisticsKey(): string { return 'statistics.json' } /** * Acquire a browser-based lock for coordinating operations across multiple tabs * @param lockKey The key to lock on * @param ttl Time to live for the lock in milliseconds (default: 30 seconds) * @returns Promise that resolves to true if lock was acquired, false otherwise */ private async acquireLock( lockKey: string, ttl: number = 30000 ): Promise { if (typeof localStorage === 'undefined') { console.warn('localStorage not available, proceeding without lock') return false } const lockStorageKey = `${this.lockPrefix}${lockKey}` const lockValue = `${Date.now()}_${Math.random()}_${window.location.href}` const expiresAt = Date.now() + ttl try { // Check if lock already exists and is still valid const existingLock = localStorage.getItem(lockStorageKey) if (existingLock) { try { const lockInfo = JSON.parse(existingLock) if (lockInfo.expiresAt > Date.now()) { // Lock exists and is still valid return false } } catch (error) { // Invalid lock data, we can proceed to create a new lock console.warn(`Invalid lock data for ${lockStorageKey}:`, error) } } // Try to create the lock const lockInfo = { lockValue, expiresAt, tabId: window.location.href, timestamp: Date.now() } localStorage.setItem(lockStorageKey, JSON.stringify(lockInfo)) // Add to active locks for cleanup this.activeLocks.add(lockKey) // Schedule automatic cleanup when lock expires setTimeout(() => { this.releaseLock(lockKey, lockValue).catch((error) => { console.warn(`Failed to auto-release expired lock ${lockKey}:`, error) }) }, ttl) return true } catch (error) { console.warn(`Failed to acquire lock ${lockKey}:`, error) return false } } /** * Release a browser-based lock * @param lockKey The key to unlock * @param lockValue The value used when acquiring the lock (for verification) * @returns Promise that resolves when lock is released */ private async releaseLock( lockKey: string, lockValue?: string ): Promise { if (typeof localStorage === 'undefined') { return } const lockStorageKey = `${this.lockPrefix}${lockKey}` try { // If lockValue is provided, verify it matches before releasing if (lockValue) { const existingLock = localStorage.getItem(lockStorageKey) if (existingLock) { try { const lockInfo = JSON.parse(existingLock) if (lockInfo.lockValue !== lockValue) { // Lock was acquired by someone else, don't release it return } } catch (error) { // Invalid lock data, remove it localStorage.removeItem(lockStorageKey) this.activeLocks.delete(lockKey) return } } } // Remove the lock localStorage.removeItem(lockStorageKey) // Remove from active locks this.activeLocks.delete(lockKey) } catch (error) { console.warn(`Failed to release lock ${lockKey}:`, error) } } /** * Clean up expired locks from localStorage */ private async cleanupExpiredLocks(): Promise { if (typeof localStorage === 'undefined') { return } try { const now = Date.now() const keysToRemove: string[] = [] // Iterate through localStorage to find expired locks for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i) if (key && key.startsWith(this.lockPrefix)) { try { const lockData = localStorage.getItem(key) if (lockData) { const lockInfo = JSON.parse(lockData) if (lockInfo.expiresAt <= now) { keysToRemove.push(key) const lockKey = key.replace(this.lockPrefix, '') this.activeLocks.delete(lockKey) } } } catch (error) { // Invalid lock data, mark for removal keysToRemove.push(key) } } } // Remove expired locks keysToRemove.forEach((key) => { localStorage.removeItem(key) }) if (keysToRemove.length > 0) { console.log(`Cleaned up ${keysToRemove.length} expired locks`) } } catch (error) { console.warn('Failed to cleanup expired locks:', error) } } /** * Save statistics data to storage with browser-based locking * @param statistics The statistics data to save */ protected async saveStatisticsData( statistics: StatisticsData ): Promise { const lockKey = 'statistics' const lockAcquired = await this.acquireLock(lockKey, 10000) // 10 second timeout if (!lockAcquired) { console.warn( 'Failed to acquire lock for statistics update, proceeding without lock' ) } try { // Get existing statistics to merge with new data const existingStats = await this.getStatisticsData() let mergedStats: StatisticsData if (existingStats) { // Merge statistics data mergedStats = { nounCount: { ...existingStats.nounCount, ...statistics.nounCount }, verbCount: { ...existingStats.verbCount, ...statistics.verbCount }, metadataCount: { ...existingStats.metadataCount, ...statistics.metadataCount }, hnswIndexSize: Math.max( statistics.hnswIndexSize || 0, existingStats.hnswIndexSize || 0 ), lastUpdated: new Date().toISOString() } } else { // No existing statistics, use new ones mergedStats = { ...statistics, lastUpdated: new Date().toISOString() } } // Create a deep copy to avoid reference issues this.statistics = { nounCount: { ...mergedStats.nounCount }, verbCount: { ...mergedStats.verbCount }, metadataCount: { ...mergedStats.metadataCount }, hnswIndexSize: mergedStats.hnswIndexSize, lastUpdated: mergedStats.lastUpdated } // Ensure the root directory is initialized await this.ensureInitialized() // Get or create the index directory if (!this.indexDir) { throw new Error('Index directory not initialized') } // Get the current statistics key const currentKey = this.getCurrentStatisticsKey() // Create a file for the statistics data const fileHandle = await this.indexDir.getFileHandle(currentKey, { create: true }) // Create a writable stream const writable = await fileHandle.createWritable() // Write the statistics data to the file await writable.write(JSON.stringify(this.statistics, null, 2)) // Close the stream await writable.close() // Also update the legacy key for backward compatibility, but less frequently if (Math.random() < 0.1) { const legacyKey = this.getLegacyStatisticsKey() const legacyFileHandle = await this.indexDir.getFileHandle(legacyKey, { create: true }) const legacyWritable = await legacyFileHandle.createWritable() await legacyWritable.write(JSON.stringify(this.statistics, null, 2)) await legacyWritable.close() } } catch (error) { console.error('Failed to save statistics data:', error) throw new Error(`Failed to save statistics data: ${error}`) } finally { if (lockAcquired) { await this.releaseLock(lockKey) } } } /** * Get statistics data from storage * @returns Promise that resolves to the statistics data or null if not found */ protected async getStatisticsData(): Promise { // If we have cached statistics, return a deep copy if (this.statistics) { return { nounCount: { ...this.statistics.nounCount }, verbCount: { ...this.statistics.verbCount }, metadataCount: { ...this.statistics.metadataCount }, hnswIndexSize: this.statistics.hnswIndexSize, // 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, lastUpdated: this.statistics.lastUpdated } } try { // Ensure the root directory is initialized await this.ensureInitialized() if (!this.indexDir) { throw new Error('Index directory not initialized') } // First try to get statistics from today's file const currentKey = this.getCurrentStatisticsKey() try { const fileHandle = await this.indexDir.getFileHandle(currentKey, { create: false }) const file = await fileHandle.getFile() const text = await file.text() this.statistics = JSON.parse(text) if (this.statistics) { return { nounCount: { ...this.statistics.nounCount }, verbCount: { ...this.statistics.verbCount }, metadataCount: { ...this.statistics.metadataCount }, hnswIndexSize: this.statistics.hnswIndexSize, // 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, lastUpdated: this.statistics.lastUpdated } } } catch (error) { // If today's file doesn't exist, try yesterday's file const yesterday = new Date() yesterday.setDate(yesterday.getDate() - 1) const yesterdayKey = this.getStatisticsKeyForDate(yesterday) try { const fileHandle = await this.indexDir.getFileHandle(yesterdayKey, { create: false }) const file = await fileHandle.getFile() const text = await file.text() this.statistics = JSON.parse(text) if (this.statistics) { return { nounCount: { ...this.statistics.nounCount }, verbCount: { ...this.statistics.verbCount }, metadataCount: { ...this.statistics.metadataCount }, hnswIndexSize: this.statistics.hnswIndexSize, // 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, lastUpdated: this.statistics.lastUpdated } } } catch (error) { // If yesterday's file doesn't exist, try the legacy file const legacyKey = this.getLegacyStatisticsKey() try { const fileHandle = await this.indexDir.getFileHandle(legacyKey, { create: false }) const file = await fileHandle.getFile() const text = await file.text() this.statistics = JSON.parse(text) if (this.statistics) { return { nounCount: { ...this.statistics.nounCount }, verbCount: { ...this.statistics.verbCount }, metadataCount: { ...this.statistics.metadataCount }, hnswIndexSize: this.statistics.hnswIndexSize, // 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, lastUpdated: this.statistics.lastUpdated } } } catch (error) { // CRITICAL FIX (v3.37.4): No statistics files exist (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() } } } } // If we get here and statistics is null, return minimal stats with counts if (!this.statistics) { return { nounCount: {}, verbCount: {}, metadataCount: {}, hnswIndexSize: 0, totalNodes: this.totalNounCount, totalEdges: this.totalVerbCount, totalMetadata: 0, lastUpdated: new Date().toISOString() } } return this.statistics } catch (error) { console.error('Failed to get statistics data:', error) throw new Error(`Failed to get statistics data: ${error}`) } } /** * Get nouns with pagination support * @param options Pagination and filter options * @returns Promise that resolves to a paginated result of nouns */ public async getNounsWithPagination(options: { limit?: number cursor?: string filter?: { nounType?: string | string[] service?: string | string[] metadata?: Record } } = {}): Promise<{ items: HNSWNounWithMetadata[] totalCount?: number hasMore: boolean nextCursor?: string }> { await this.ensureInitialized() const limit = options.limit || 100 const cursor = options.cursor // Get all noun files from all shards const nounFiles: string[] = [] if (this.nounsDir) { // Iterate through all shard directories for await (const [shardName, shardHandle] of this.nounsDir.entries()) { if (shardHandle.kind === 'directory') { // Iterate through files in this shard const shardDir = shardHandle as FileSystemDirectoryHandle for await (const [fileName, fileHandle] of shardDir.entries()) { if (fileHandle.kind === 'file' && fileName.endsWith('.json')) { nounFiles.push(`${shardName}/${fileName}`) } } } } } // Sort files for consistent ordering nounFiles.sort() // Apply cursor-based pagination let startIndex = 0 if (cursor) { const cursorIndex = nounFiles.findIndex(file => file > cursor) if (cursorIndex >= 0) { startIndex = cursorIndex } } // Get the subset of files for this page const pageFiles = nounFiles.slice(startIndex, startIndex + limit) // v4.0.0: Load nouns from files and combine with metadata const items: HNSWNounWithMetadata[] = [] for (const fileName of pageFiles) { // fileName is in format "shard/uuid.json", extract just the UUID const id = fileName.split('/')[1].replace('.json', '') const noun = await this.getNoun_internal(id) if (noun) { // Load metadata for filtering and combining const metadata = await this.getNounMetadata(id) if (!metadata) continue // Apply filters if provided if (options.filter) { // Filter by noun type if (options.filter.nounType) { const nounTypes = Array.isArray(options.filter.nounType) ? options.filter.nounType : [options.filter.nounType] if (!nounTypes.includes((metadata.type || metadata.noun) as string)) { continue } } // Filter by service if (options.filter.service) { const services = Array.isArray(options.filter.service) ? options.filter.service : [options.filter.service] if (!metadata.createdBy?.augmentation || !services.includes(metadata.createdBy.augmentation as string)) { continue } } // Filter by metadata if (options.filter.metadata) { let matches = true for (const [key, value] of Object.entries(options.filter.metadata)) { if (metadata[key] !== value) { matches = false break } } if (!matches) continue } } // v4.0.0: Create HNSWNounWithMetadata by combining noun with metadata const nounWithMetadata: HNSWNounWithMetadata = { id: noun.id, vector: [...noun.vector], connections: new Map(noun.connections), level: noun.level || 0, metadata: metadata } items.push(nounWithMetadata) } } // Determine if there are more items const hasMore = startIndex + limit < nounFiles.length // Generate next cursor if there are more items const nextCursor = hasMore && pageFiles.length > 0 ? pageFiles[pageFiles.length - 1] : undefined return { items, totalCount: nounFiles.length, hasMore, nextCursor } } /** * Get verbs with pagination support * @param options Pagination and filter options * @returns Promise that resolves to a paginated result of verbs */ public async getVerbsWithPagination(options: { limit?: number cursor?: string filter?: { verbType?: string | string[] sourceId?: string | string[] targetId?: string | string[] service?: string | string[] metadata?: Record } } = {}): Promise<{ items: HNSWVerbWithMetadata[] totalCount?: number hasMore: boolean nextCursor?: string }> { await this.ensureInitialized() const limit = options.limit || 100 const cursor = options.cursor // Get all verb files from all shards const verbFiles: string[] = [] if (this.verbsDir) { // Iterate through all shard directories for await (const [shardName, shardHandle] of this.verbsDir.entries()) { if (shardHandle.kind === 'directory') { // Iterate through files in this shard const shardDir = shardHandle as FileSystemDirectoryHandle for await (const [fileName, fileHandle] of shardDir.entries()) { if (fileHandle.kind === 'file' && fileName.endsWith('.json')) { verbFiles.push(`${shardName}/${fileName}`) } } } } } // Sort files for consistent ordering verbFiles.sort() // Apply cursor-based pagination let startIndex = 0 if (cursor) { const cursorIndex = verbFiles.findIndex(file => file > cursor) if (cursorIndex >= 0) { startIndex = cursorIndex } } // Get the subset of files for this page const pageFiles = verbFiles.slice(startIndex, startIndex + limit) // v4.0.0: Load verbs from files and combine with metadata const items: HNSWVerbWithMetadata[] = [] for (const fileName of pageFiles) { // fileName is in format "shard/uuid.json", extract just the UUID const id = fileName.split('/')[1].replace('.json', '') const hnswVerb = await this.getVerb_internal(id) if (hnswVerb) { // Load metadata for filtering and combining const metadata = await this.getVerbMetadata(id) if (!metadata) continue // Apply filters if provided if (options.filter) { // Filter by verb type // v4.0.0: verb field is in HNSWVerb structure (NOT in metadata) if (options.filter.verbType) { const verbTypes = Array.isArray(options.filter.verbType) ? options.filter.verbType : [options.filter.verbType] if (!hnswVerb.verb || !verbTypes.includes(hnswVerb.verb)) { continue } } // Filter by source ID // v4.0.0: sourceId field is in HNSWVerb structure (NOT in metadata) if (options.filter.sourceId) { const sourceIds = Array.isArray(options.filter.sourceId) ? options.filter.sourceId : [options.filter.sourceId] if (!hnswVerb.sourceId || !sourceIds.includes(hnswVerb.sourceId)) { continue } } // Filter by target ID // v4.0.0: targetId field is in HNSWVerb structure (NOT in metadata) if (options.filter.targetId) { const targetIds = Array.isArray(options.filter.targetId) ? options.filter.targetId : [options.filter.targetId] if (!hnswVerb.targetId || !targetIds.includes(hnswVerb.targetId)) { continue } } // Filter by service if (options.filter.service) { const services = Array.isArray(options.filter.service) ? options.filter.service : [options.filter.service] if (!metadata.createdBy?.augmentation || !services.includes(metadata.createdBy.augmentation as string)) { continue } } // Filter by metadata if (options.filter.metadata) { let matches = true for (const [key, value] of Object.entries(options.filter.metadata)) { if (metadata[key] !== value) { matches = false break } } if (!matches) continue } } // v4.0.0: Create HNSWVerbWithMetadata by combining verb with metadata const verbWithMetadata: HNSWVerbWithMetadata = { id: hnswVerb.id, vector: [...hnswVerb.vector], connections: new Map(hnswVerb.connections), verb: hnswVerb.verb, sourceId: hnswVerb.sourceId, targetId: hnswVerb.targetId, metadata: metadata } items.push(verbWithMetadata) } } // Determine if there are more items const hasMore = startIndex + limit < verbFiles.length // Generate next cursor if there are more items const nextCursor = hasMore && pageFiles.length > 0 ? pageFiles[pageFiles.length - 1] : undefined return { items, totalCount: verbFiles.length, hasMore, nextCursor } } /** * Initialize counts from OPFS storage */ protected async initializeCounts(): Promise { try { // Try to load existing counts from counts.json const systemDir = await this.rootDir!.getDirectoryHandle('system', { create: true }) const countsFile = await systemDir.getFileHandle('counts.json') const file = await countsFile.getFile() const data = await file.text() const counts = JSON.parse(data) // Restore counts from OPFS this.entityCounts = new Map(Object.entries(counts.entityCounts || {})) this.verbCounts = new Map(Object.entries(counts.verbCounts || {})) this.totalNounCount = counts.totalNounCount || 0 this.totalVerbCount = counts.totalVerbCount || 0 } catch (error) { // If counts don't exist, initialize by scanning (one-time operation) await this.initializeCountsFromScan() } } /** * Initialize counts by scanning OPFS (fallback for missing counts file) */ private async initializeCountsFromScan(): Promise { try { // Count nouns across all shards let nounCount = 0 for await (const [shardName, shardHandle] of this.nounsDir!.entries()) { if (shardHandle.kind === 'directory') { const shardDir = shardHandle as FileSystemDirectoryHandle for await (const [, ] of shardDir.entries()) { nounCount++ } } } this.totalNounCount = nounCount // Count verbs across all shards let verbCount = 0 for await (const [shardName, shardHandle] of this.verbsDir!.entries()) { if (shardHandle.kind === 'directory') { const shardDir = shardHandle as FileSystemDirectoryHandle for await (const [, ] of shardDir.entries()) { verbCount++ } } } this.totalVerbCount = verbCount // Save initial counts await this.persistCounts() } catch (error) { console.error('Error initializing counts from OPFS scan:', error) } } /** * Persist counts to OPFS storage */ protected async persistCounts(): Promise { try { const systemDir = await this.rootDir!.getDirectoryHandle('system', { create: true }) const countsFile = await systemDir.getFileHandle('counts.json', { create: true }) const writable = await countsFile.createWritable() const counts = { entityCounts: Object.fromEntries(this.entityCounts), verbCounts: Object.fromEntries(this.verbCounts), totalNounCount: this.totalNounCount, totalVerbCount: this.totalVerbCount, lastUpdated: new Date().toISOString() } await writable.write(JSON.stringify(counts)) await writable.close() } catch (error) { console.error('Error persisting counts to OPFS:', error) } } // HNSW Index Persistence (v3.35.0+) /** * Get a noun's vector for HNSW rebuild */ public async getNounVector(id: string): Promise { await this.ensureInitialized() const noun = await this.getNoun_internal(id) return noun ? noun.vector : null } /** * Save HNSW graph data for a noun * Storage path: nouns/hnsw/{shard}/{id}.json */ public async saveHNSWData(nounId: string, hnswData: { level: number connections: Record }): Promise { await this.ensureInitialized() try { // Get or create the hnsw directory under nouns const hnswDir = await this.nounsDir!.getDirectoryHandle('hnsw', { create: true }) // Use sharded path for HNSW data const shard = getShardIdFromUuid(nounId) const shardDir = await hnswDir.getDirectoryHandle(shard, { create: true }) // Create or get the file in the shard directory const fileHandle = await shardDir.getFileHandle(`${nounId}.json`, { create: true }) // Write the HNSW data to the file const writable = await fileHandle.createWritable() await writable.write(JSON.stringify(hnswData, null, 2)) await writable.close() } catch (error) { console.error(`Failed to save HNSW data for ${nounId}:`, error) throw new Error(`Failed to save HNSW data for ${nounId}: ${error}`) } } /** * Get HNSW graph data for a noun * Storage path: nouns/hnsw/{shard}/{id}.json */ public async getHNSWData(nounId: string): Promise<{ level: number connections: Record } | null> { await this.ensureInitialized() try { // Get the hnsw directory under nouns const hnswDir = await this.nounsDir!.getDirectoryHandle('hnsw') // Use sharded path for HNSW data const shard = getShardIdFromUuid(nounId) const shardDir = await hnswDir.getDirectoryHandle(shard) // Get the file handle from the shard directory const fileHandle = await shardDir.getFileHandle(`${nounId}.json`) // Read the HNSW data from the file const file = await fileHandle.getFile() const text = await file.text() return JSON.parse(text) } catch (error: any) { if (error.name === 'NotFoundError') { return null } console.error(`Failed to get HNSW data for ${nounId}:`, error) throw new Error(`Failed to get HNSW data for ${nounId}: ${error}`) } } /** * Save HNSW system data (entry point, max level) * Storage path: index/hnsw-system.json */ public async saveHNSWSystem(systemData: { entryPointId: string | null maxLevel: number }): Promise { await this.ensureInitialized() try { // Create or get the file in the index directory const fileHandle = await this.indexDir!.getFileHandle('hnsw-system.json', { create: true }) // Write the system data to the file const writable = await fileHandle.createWritable() await writable.write(JSON.stringify(systemData, null, 2)) await writable.close() } catch (error) { console.error('Failed to save HNSW system data:', error) throw new Error(`Failed to save HNSW system data: ${error}`) } } /** * Get HNSW system data (entry point, max level) * Storage path: index/hnsw-system.json */ public async getHNSWSystem(): Promise<{ entryPointId: string | null maxLevel: number } | null> { await this.ensureInitialized() try { // Get the file handle from the index directory const fileHandle = await this.indexDir!.getFileHandle('hnsw-system.json') // Read the system data from the file const file = await fileHandle.getFile() const text = await file.text() return JSON.parse(text) } catch (error: any) { if (error.name === 'NotFoundError') { return null } console.error('Failed to get HNSW system data:', error) throw new Error(`Failed to get HNSW system data: ${error}`) } } }