/** * Base Storage Adapter * Provides common functionality for all storage adapters */ import { GraphAdjacencyIndex } from '../graph/graphAdjacencyIndex.js' import { GraphVerb, HNSWNoun, HNSWVerb, NounMetadata, VerbMetadata, HNSWNounWithMetadata, HNSWVerbWithMetadata, StatisticsData } from '../coreTypes.js' import { BaseStorageAdapter } from './adapters/baseStorageAdapter.js' import { validateNounType, validateVerbType } from '../utils/typeValidation.js' import { NounType, VerbType } from '../types/graphTypes.js' import { getShardIdFromUuid } from './sharding.js' import { RefManager } from './cow/RefManager.js' import { BlobStorage, type COWStorageAdapter } from './cow/BlobStorage.js' import { CommitLog } from './cow/CommitLog.js' /** * Storage key analysis result * Used to determine whether a key is a system key or entity key, and its storage path */ interface StorageKeyInfo { original: string isEntity: boolean shardId: string | null directory: string fullPath: string } /** * Storage adapter batch configuration profile * Each storage adapter declares its optimal batch behavior for rate limiting * and performance optimization * * @since v4.11.0 */ export interface StorageBatchConfig { /** Maximum items per batch */ maxBatchSize: number /** Delay between batches in milliseconds (for rate limiting) */ batchDelayMs: number /** Maximum concurrent operations this storage can handle */ maxConcurrent: number /** Whether storage can handle parallel writes efficiently */ supportsParallelWrites: boolean /** Rate limit characteristics of this storage adapter */ rateLimit: { /** Approximate operations per second this storage can handle */ operationsPerSecond: number /** Maximum burst capacity before throttling occurs */ burstCapacity: number } } // Clean directory structure (v4.7.2+) // All storage adapters use this consistent structure export const NOUNS_METADATA_DIR = 'entities/nouns/metadata' export const VERBS_METADATA_DIR = 'entities/verbs/metadata' export const SYSTEM_DIR = '_system' export const STATISTICS_KEY = 'statistics' // DEPRECATED (v4.7.2): Temporary stubs for adapters not yet migrated // TODO: Remove in v4.7.3 after migrating remaining adapters export const NOUNS_DIR = 'entities/nouns/hnsw' export const VERBS_DIR = 'entities/verbs/hnsw' export const METADATA_DIR = 'entities/nouns/metadata' export const NOUN_METADATA_DIR = 'entities/nouns/metadata' export const VERB_METADATA_DIR = 'entities/verbs/metadata' export const INDEX_DIR = 'indexes' export function getDirectoryPath(entityType: 'noun' | 'verb', dataType: 'vector' | 'metadata'): string { if (entityType === 'noun') { return dataType === 'vector' ? NOUNS_DIR : NOUNS_METADATA_DIR } else { return dataType === 'vector' ? VERBS_DIR : VERBS_METADATA_DIR } } /** * Base storage adapter that implements common functionality * This is an abstract class that should be extended by specific storage adapters */ export abstract class BaseStorage extends BaseStorageAdapter { protected isInitialized = false protected graphIndex?: GraphAdjacencyIndex protected readOnly = false // COW (Copy-on-Write) support - v5.0.0 public refManager?: RefManager public blobStorage?: BlobStorage public commitLog?: CommitLog public currentBranch: string = 'main' protected cowEnabled: boolean = false /** * Analyze a storage key to determine its routing and path * @param id - The key to analyze (UUID or system key) * @param context - The context for the key (noun-metadata, verb-metadata, or system) * @returns Storage key information including path and shard ID * @private */ private analyzeKey(id: string, context: 'noun-metadata' | 'verb-metadata' | 'system'): StorageKeyInfo { // v4.8.0: Guard against undefined/null IDs if (!id || typeof id !== 'string') { throw new Error(`Invalid storage key: ${id} (must be a non-empty string)`) } // System resource detection const isSystemKey = id.startsWith('__metadata_') || id.startsWith('__index_') || id.startsWith('__system_') || id.startsWith('statistics_') || id === 'statistics' || id.startsWith('__chunk__') || // Metadata index chunks (roaring bitmap data) id.startsWith('__sparse_index__') // Metadata sparse indices (zone maps + bloom filters) if (isSystemKey) { return { original: id, isEntity: false, shardId: null, directory: SYSTEM_DIR, fullPath: `${SYSTEM_DIR}/${id}.json` } } // UUID validation for entity keys const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i if (!uuidRegex.test(id)) { console.warn(`[Storage] Unknown key format: ${id} - treating as system resource`) return { original: id, isEntity: false, shardId: null, directory: SYSTEM_DIR, fullPath: `${SYSTEM_DIR}/${id}.json` } } // Valid entity UUID - apply sharding const shardId = getShardIdFromUuid(id) if (context === 'noun-metadata') { return { original: id, isEntity: true, shardId, directory: `${NOUNS_METADATA_DIR}/${shardId}`, fullPath: `${NOUNS_METADATA_DIR}/${shardId}/${id}.json` } } else if (context === 'verb-metadata') { return { original: id, isEntity: true, shardId, directory: `${VERBS_METADATA_DIR}/${shardId}`, fullPath: `${VERBS_METADATA_DIR}/${shardId}/${id}.json` } } else { // system context - but UUID format return { original: id, isEntity: false, shardId: null, directory: SYSTEM_DIR, fullPath: `${SYSTEM_DIR}/${id}.json` } } } /** * Initialize the storage adapter * This method should be implemented by each specific adapter */ public abstract init(): Promise /** * Ensure the storage adapter is initialized */ protected async ensureInitialized(): Promise { if (!this.isInitialized) { await this.init() } } /** * Lightweight COW enablement - just enables branch-scoped paths * Called during init() to ensure all data is stored with branch prefixes from the start * RefManager/BlobStorage/CommitLog are lazy-initialized on first fork() * @param branch - Branch name to use (default: 'main') */ public enableCOWLightweight(branch: string = 'main'): void { if (this.cowEnabled) { return } this.currentBranch = branch this.cowEnabled = true // RefManager/BlobStorage/CommitLog remain undefined until first fork() } /** * Initialize COW (Copy-on-Write) support * Creates RefManager and BlobStorage for instant fork() capability * * v5.0.1: Now called automatically by storageFactory (zero-config) * * @param options - COW initialization options * @param options.branch - Initial branch name (default: 'main') * @param options.enableCompression - Enable zstd compression for blobs (default: true) * @returns Promise that resolves when COW is initialized */ public async initializeCOW(options?: { branch?: string enableCompression?: boolean }): Promise { // Check if RefManager already initialized (full COW setup complete) if (this.refManager) { return } // Enable lightweight COW if not already enabled if (!this.cowEnabled) { this.currentBranch = options?.branch || 'main' this.cowEnabled = true } // Create COWStorageAdapter bridge // This adapts BaseStorage's methods to the simple key-value interface const cowAdapter: COWStorageAdapter = { get: async (key: string): Promise => { try { const data = await this.readObjectFromPath(`_cow/${key}`) if (data === null) { return undefined } // Convert to Buffer if (Buffer.isBuffer(data)) { return data } return Buffer.from(JSON.stringify(data)) } catch (error) { return undefined } }, put: async (key: string, data: Buffer): Promise => { // Store as Buffer (for blob data) or parse JSON (for metadata) let obj: any try { // Try to parse as JSON first (for metadata) obj = JSON.parse(data.toString()) } catch { // Not JSON, store as binary (base64 encoded for JSON storage) obj = { _binary: true, data: data.toString('base64') } } await this.writeObjectToPath(`_cow/${key}`, obj) }, delete: async (key: string): Promise => { try { await this.deleteObjectFromPath(`_cow/${key}`) } catch (error) { // Ignore if doesn't exist } }, list: async (prefix: string): Promise => { try { // v5.3.5 fix: Handle file prefixes, not just directory paths // Refs are stored as files like: _cow/ref:refs/heads/main // So list('ref:') should find all files starting with '_cow/ref:' // List the _cow directory and filter by prefix const allPaths = await this.listObjectsUnderPath('_cow/') const filteredPaths = allPaths.filter(p => { // Remove _cow/ prefix to get the key const key = p.replace(/^_cow\//, '') return key.startsWith(prefix) }) // Remove _cow/ prefix and return relative keys return filteredPaths.map(p => p.replace(/^_cow\//, '')) } catch (error: any) { // If _cow directory doesn't exist yet, return empty array return [] } } } // Initialize RefManager this.refManager = new RefManager(cowAdapter) // Initialize BlobStorage this.blobStorage = new BlobStorage(cowAdapter, { enableCompression: options?.enableCompression !== false }) // Initialize CommitLog this.commitLog = new CommitLog(this.blobStorage, this.refManager) // Check if main branch exists, create if not const mainRef = await this.refManager.getRef('main') if (!mainRef) { // Create initial commit with empty tree // v5.3.4: Use NULL_HASH constant instead of hardcoded string const { NULL_HASH } = await import('./cow/constants.js') const emptyTreeHash = NULL_HASH // Import CommitBuilder const { CommitBuilder } = await import('./cow/CommitObject.js') // Create initial commit object const initialCommitHash = await CommitBuilder.create(this.blobStorage) .tree(emptyTreeHash) .parent(null) .message('Initial commit') .author('system') .timestamp(Date.now()) .build() // Create main branch pointing to initial commit await this.refManager.createBranch('main', initialCommitHash, { description: 'Initial branch', author: 'system' }) } // Set HEAD to current branch const currentRef = await this.refManager.getRef(this.currentBranch) if (currentRef) { await this.refManager.setHead(this.currentBranch) } else { // Branch doesn't exist, create it from main const mainCommit = await this.refManager.resolveRef('main') if (mainCommit) { await this.refManager.createBranch(this.currentBranch, mainCommit, { description: `Branch created from main`, author: 'system' }) await this.refManager.setHead(this.currentBranch) } } this.cowEnabled = true } /** * Resolve branch-scoped path for COW isolation * @protected - Available to subclasses for COW implementation */ protected resolveBranchPath(basePath: string, branch?: string): string { if (!this.cowEnabled) { return basePath // COW disabled, use direct path } const targetBranch = branch || this.currentBranch || 'main' // Branch-scoped path: branches// return `branches/${targetBranch}/${basePath}` } /** * Write object to branch-specific path (COW layer) * @protected - Available to subclasses for COW implementation */ protected async writeObjectToBranch(path: string, data: any, branch?: string): Promise { const branchPath = this.resolveBranchPath(path, branch) return this.writeObjectToPath(branchPath, data) } /** * Read object with inheritance from parent branches (COW layer) * Tries current branch first, then walks commit history * @protected - Available to subclasses for COW implementation */ protected async readWithInheritance(path: string, branch?: string): Promise { if (!this.cowEnabled) { // COW disabled, direct read return this.readObjectFromPath(path) } const targetBranch = branch || this.currentBranch || 'main' // Try current branch first const branchPath = this.resolveBranchPath(path, targetBranch) let data = await this.readObjectFromPath(branchPath) if (data !== null) { return data // Found in current branch } // Not in branch, check if we're on main (no inheritance needed) if (targetBranch === 'main') { return null } // Not in branch, walk commit history to find in parent if (this.refManager && this.commitLog) { try { const commitHash = await this.refManager.resolveRef(targetBranch) if (commitHash) { // Walk parent commits until we find the data for await (const commit of this.commitLog.walk(commitHash)) { // Try reading from parent's branch path const parentBranch = commit.metadata?.branch || 'main' if (parentBranch === targetBranch) continue // Skip self const parentPath = this.resolveBranchPath(path, parentBranch) data = await this.readObjectFromPath(parentPath) if (data !== null) { return data // Found in ancestor } } } } catch (error) { // Commit walk failed, fall back to main const mainPath = this.resolveBranchPath(path, 'main') return this.readObjectFromPath(mainPath) } } // Last fallback: try main branch const mainPath = this.resolveBranchPath(path, 'main') return this.readObjectFromPath(mainPath) } /** * Delete object from branch-specific path (COW layer) * @protected - Available to subclasses for COW implementation */ protected async deleteObjectFromBranch(path: string, branch?: string): Promise { const branchPath = this.resolveBranchPath(path, branch) return this.deleteObjectFromPath(branchPath) } /** * List objects under path in branch (COW layer) * @protected - Available to subclasses for COW implementation */ protected async listObjectsInBranch(prefix: string, branch?: string): Promise { const branchPrefix = this.resolveBranchPath(prefix, branch) const paths = await this.listObjectsUnderPath(branchPrefix) // Remove branch prefix from results const targetBranch = branch || this.currentBranch || 'main' const prefixToRemove = `branches/${targetBranch}/` return paths.map(p => p.startsWith(prefixToRemove) ? p.substring(prefixToRemove.length) : p) } /** * List objects with inheritance (v5.0.1) * Lists objects from current branch AND main branch, returns unique paths * This enables fork to see parent's data in pagination operations * * Simplified approach: All branches inherit from main */ protected async listObjectsWithInheritance(prefix: string, branch?: string): Promise { if (!this.cowEnabled) { return this.listObjectsInBranch(prefix, branch) } const targetBranch = branch || this.currentBranch || 'main' // Collect paths from current branch const pathsSet = new Set() const currentBranchPaths = await this.listObjectsInBranch(prefix, targetBranch) currentBranchPaths.forEach(p => pathsSet.add(p)) // If not on main, also list from main (all branches inherit from main) if (targetBranch !== 'main') { const mainPaths = await this.listObjectsInBranch(prefix, 'main') mainPaths.forEach(p => pathsSet.add(p)) } return Array.from(pathsSet) } /** * Save a noun to storage (v4.0.0: vector only, metadata saved separately) * @param noun Pure HNSW vector data (no metadata) */ public async saveNoun(noun: HNSWNoun): Promise { await this.ensureInitialized() // Save the HNSWNoun vector data only // Metadata must be saved separately via saveNounMetadata() await this.saveNoun_internal(noun) } /** * Get a noun from storage (v4.0.0: returns combined HNSWNounWithMetadata) * @param id Entity ID * @returns Combined vector + metadata or null */ public async getNoun(id: string): Promise { await this.ensureInitialized() // Load vector and metadata separately const vector = await this.getNoun_internal(id) if (!vector) { return null } // Load metadata const metadata = await this.getNounMetadata(id) if (!metadata) { console.warn(`[Storage] Noun ${id} has vector but no metadata - this should not happen in v4.0.0`) return null } // Combine into HNSWNounWithMetadata - v4.8.0: Extract standard fields to top-level const { noun, createdAt, updatedAt, confidence, weight, service, data, createdBy, ...customMetadata } = metadata return { id: vector.id, vector: vector.vector, connections: vector.connections, level: vector.level, // v4.8.0: Standard fields at top-level type: (noun 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 | undefined, createdBy, // Only custom user fields remain in metadata metadata: customMetadata } } /** * 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 */ public async getNounsByNounType(nounType: string): Promise { await this.ensureInitialized() // Internal method returns HNSWNoun[], need to combine with metadata const nouns = await this.getNounsByNounType_internal(nounType) // Combine each noun with its metadata - v4.8.0: Extract standard fields to top-level const nounsWithMetadata: HNSWNounWithMetadata[] = [] for (const noun of nouns) { const metadata = await this.getNounMetadata(noun.id) if (metadata) { const { noun: nounType, createdAt, updatedAt, confidence, weight, service, data, createdBy, ...customMetadata } = metadata nounsWithMetadata.push({ ...noun, // 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 | undefined, createdBy, // Only custom user fields in metadata metadata: customMetadata }) } } return nounsWithMetadata } /** * Delete a noun from storage */ public async deleteNoun(id: string): Promise { await this.ensureInitialized() // Delete both the vector file and metadata file (2-file system) await this.deleteNoun_internal(id) // Delete metadata file (if it exists) try { await this.deleteNounMetadata(id) } catch (error) { // Ignore if metadata file doesn't exist console.debug(`No metadata file to delete for noun ${id}`) } } /** * Save a verb to storage (v4.0.0: verb only, metadata saved separately) * * @param verb Pure HNSW verb with core relational fields (verb, sourceId, targetId) */ public async saveVerb(verb: HNSWVerb): Promise { await this.ensureInitialized() // Validate verb type before saving - storage boundary protection validateVerbType(verb.verb) // Save the HNSWVerb vector and core fields only // Metadata must be saved separately via saveVerbMetadata() await this.saveVerb_internal(verb) } /** * Get a verb from storage (v4.0.0: returns combined HNSWVerbWithMetadata) * @param id Entity ID * @returns Combined verb + metadata or null */ public async getVerb(id: string): Promise { await this.ensureInitialized() // Load verb vector and core fields const verb = await this.getVerb_internal(id) if (!verb) { return null } // Load metadata const metadata = await this.getVerbMetadata(id) if (!metadata) { console.warn(`[Storage] Verb ${id} has vector but no metadata - this should not happen in v4.0.0`) return null } // Combine into HNSWVerbWithMetadata - v4.8.0: Extract standard fields to top-level const { createdAt, updatedAt, confidence, weight, service, data, createdBy, ...customMetadata } = metadata return { id: verb.id, vector: verb.vector, connections: verb.connections, verb: verb.verb, sourceId: verb.sourceId, targetId: verb.targetId, // 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 | undefined, createdBy, // Only custom user fields remain in metadata metadata: customMetadata } } /** * Convert HNSWVerb to GraphVerb by combining with metadata * DEPRECATED: For backward compatibility only. Use getVerb() which returns HNSWVerbWithMetadata. * * @deprecated Use getVerb() instead which returns HNSWVerbWithMetadata */ protected async convertHNSWVerbToGraphVerb(hnswVerb: HNSWVerb): Promise { try { // Load metadata const metadata = await this.getVerbMetadata(hnswVerb.id) // Create default timestamp in Firestore format 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' } // Convert flexible timestamp to Firestore format for GraphVerb const normalizeTimestamp = (ts: any) => { if (!ts) return defaultTimestamp if (typeof ts === 'number') { return { seconds: Math.floor(ts / 1000), nanoseconds: (ts % 1000) * 1000000 } } return ts } return { id: hnswVerb.id, vector: hnswVerb.vector, // CORE FIELDS from HNSWVerb verb: hnswVerb.verb, sourceId: hnswVerb.sourceId, targetId: hnswVerb.targetId, // Aliases for backward compatibility type: hnswVerb.verb, source: hnswVerb.sourceId, target: hnswVerb.targetId, // Optional fields from metadata file weight: metadata?.weight || 1.0, metadata: metadata as any || {}, createdAt: normalizeTimestamp(metadata?.createdAt), updatedAt: normalizeTimestamp(metadata?.updatedAt), createdBy: metadata?.createdBy || defaultCreatedBy, data: metadata?.data as Record | undefined, embedding: hnswVerb.vector } } catch (error) { console.error(`Failed to convert HNSWVerb to GraphVerb for ${hnswVerb.id}:`, error) return null } } /** * Internal method for loading all verbs - used by performance optimizations * @internal - Do not use directly, use getVerbs() with pagination instead */ protected async _loadAllVerbsForOptimization(): Promise { await this.ensureInitialized() // Only use this for internal optimizations when safe const result = await this.getVerbs({ pagination: { limit: Number.MAX_SAFE_INTEGER } }) // v4.0.0: Convert HNSWVerbWithMetadata to HNSWVerb (strip metadata) const hnswVerbs: HNSWVerb[] = result.items.map(verbWithMetadata => ({ id: verbWithMetadata.id, vector: verbWithMetadata.vector, connections: verbWithMetadata.connections, verb: verbWithMetadata.verb, sourceId: verbWithMetadata.sourceId, targetId: verbWithMetadata.targetId })) return hnswVerbs } /** * Get verbs by source */ public async getVerbsBySource(sourceId: string): Promise { await this.ensureInitialized() // CRITICAL: Fetch ALL verbs for this source, not just first page // This is needed for delete operations to clean up all relationships const result = await this.getVerbs({ filter: { sourceId }, pagination: { limit: Number.MAX_SAFE_INTEGER } }) return result.items } /** * Get verbs by target */ public async getVerbsByTarget(targetId: string): Promise { await this.ensureInitialized() // CRITICAL: Fetch ALL verbs for this target, not just first page // This is needed for delete operations to clean up all relationships const result = await this.getVerbs({ filter: { targetId }, pagination: { limit: Number.MAX_SAFE_INTEGER } }) return result.items } /** * Get verbs by type */ public async getVerbsByType(type: string): Promise { await this.ensureInitialized() // Fetch ALL verbs of this type (no pagination limit) const result = await this.getVerbs({ filter: { verbType: type }, pagination: { limit: Number.MAX_SAFE_INTEGER } }) return result.items } /** * Internal method for loading all nouns - used by performance optimizations * @internal - Do not use directly, use getNouns() with pagination instead */ protected async _loadAllNounsForOptimization(): Promise { await this.ensureInitialized() // Only use this for internal optimizations when safe const result = await this.getNouns({ pagination: { limit: Number.MAX_SAFE_INTEGER } }) return result.items } /** * Get nouns with pagination and filtering * @param options Pagination and filtering options * @returns Promise that resolves to a paginated result of nouns */ public async getNouns(options?: { pagination?: { offset?: number 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() // Set default pagination values const pagination = options?.pagination || {} const limit = pagination.limit || 100 const offset = pagination.offset || 0 const cursor = pagination.cursor // Optimize for common filter cases to avoid loading all nouns if (options?.filter) { // If filtering by nounType only, use the optimized method if ( options.filter.nounType && !options.filter.service && !options.filter.metadata ) { const nounType = Array.isArray(options.filter.nounType) ? options.filter.nounType[0] : options.filter.nounType // Get nouns by type directly (already combines with metadata) const nounsByType = await this.getNounsByNounType(nounType) // Apply pagination const paginatedNouns = nounsByType.slice(offset, offset + limit) const hasMore = offset + limit < nounsByType.length // Set next cursor if there are more items let nextCursor: string | undefined = undefined if (hasMore && paginatedNouns.length > 0) { const lastItem = paginatedNouns[paginatedNouns.length - 1] nextCursor = lastItem.id } return { items: paginatedNouns, totalCount: nounsByType.length, hasMore, nextCursor } } } // For more complex filtering or no filtering, use a paginated approach // that avoids loading all nouns into memory at once try { // First, try to get a count of total nouns (if the adapter supports it) let totalCount: number | undefined = undefined try { // This is an optional method that adapters may implement if (typeof (this as any).countNouns === 'function') { totalCount = await (this as any).countNouns(options?.filter) } } catch (countError) { // Ignore errors from count method, it's optional console.warn('Error getting noun count:', countError) } // Check if the adapter has a paginated method for getting nouns if (typeof (this as any).getNounsWithPagination === 'function') { // Use the adapter's paginated method - pass offset directly to adapter const result = await (this as any).getNounsWithPagination({ limit, offset, // Let the adapter handle offset for O(1) operation cursor, filter: options?.filter }) // Don't slice here - the adapter should handle offset efficiently const items = result.items // CRITICAL SAFETY CHECK: Prevent infinite loops // If we have no items but hasMore is true, force hasMore to false // This prevents pagination bugs from causing infinite loops const safeHasMore = items.length > 0 ? result.hasMore : false // VALIDATION: Ensure adapter returns totalCount (prevents restart bugs) // If adapter forgets to return totalCount, log warning and use pre-calculated count let finalTotalCount = result.totalCount || totalCount if (result.totalCount === undefined && this.totalNounCount > 0) { console.warn( `⚠️ Storage adapter missing totalCount in getNounsWithPagination result! ` + `Using pre-calculated count (${this.totalNounCount}) as fallback. ` + `Please ensure your storage adapter returns totalCount: this.totalNounCount` ) finalTotalCount = this.totalNounCount } return { items, totalCount: finalTotalCount, hasMore: safeHasMore, nextCursor: result.nextCursor } } // Storage adapter does not support pagination console.error( 'Storage adapter does not support pagination. The deprecated getAllNouns_internal() method has been removed. Please implement getNounsWithPagination() in your storage adapter.' ) return { items: [], totalCount: 0, hasMore: false } } catch (error) { console.error('Error getting nouns with pagination:', error) return { items: [], totalCount: 0, hasMore: false } } } /** * Get verbs with pagination and filtering * @param options Pagination and filtering options * @returns Promise that resolves to a paginated result of verbs */ 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 } }): Promise<{ items: HNSWVerbWithMetadata[] totalCount?: number hasMore: boolean nextCursor?: string }> { await this.ensureInitialized() // Set default pagination values const pagination = options?.pagination || {} const limit = pagination.limit || 100 const offset = pagination.offset || 0 const cursor = pagination.cursor // Optimize for common filter cases to avoid loading all verbs if (options?.filter) { // CRITICAL VFS FIX: If filtering by sourceId + verbType (most common VFS pattern!) // This is the query PathResolver.getChildren() uses: getRelations({ from: dirId, type: VerbType.Contains }) if ( options.filter.sourceId && options.filter.verbType && !options.filter.targetId && !options.filter.service && !options.filter.metadata ) { const sourceId = Array.isArray(options.filter.sourceId) ? options.filter.sourceId[0] : options.filter.sourceId const verbType = Array.isArray(options.filter.verbType) ? options.filter.verbType[0] : options.filter.verbType // Get verbs by source, then filter by type (O(1) graph lookup + O(n) type filter) const verbsBySource = await this.getVerbsBySource_internal(sourceId) const filteredVerbs = verbsBySource.filter(v => v.verb === verbType) // Apply pagination const paginatedVerbs = filteredVerbs.slice(offset, offset + limit) const hasMore = offset + limit < filteredVerbs.length // Set next cursor if there are more items let nextCursor: string | undefined = undefined if (hasMore && paginatedVerbs.length > 0) { const lastItem = paginatedVerbs[paginatedVerbs.length - 1] nextCursor = lastItem.id } return { items: paginatedVerbs, totalCount: filteredVerbs.length, hasMore, nextCursor } } // If filtering by sourceId only, use the optimized method if ( options.filter.sourceId && !options.filter.verbType && !options.filter.targetId && !options.filter.service && !options.filter.metadata ) { const sourceId = Array.isArray(options.filter.sourceId) ? options.filter.sourceId[0] : options.filter.sourceId // Get verbs by source directly const verbsBySource = await this.getVerbsBySource_internal(sourceId) // Apply pagination const paginatedVerbs = verbsBySource.slice(offset, offset + limit) const hasMore = offset + limit < verbsBySource.length // Set next cursor if there are more items let nextCursor: string | undefined = undefined if (hasMore && paginatedVerbs.length > 0) { const lastItem = paginatedVerbs[paginatedVerbs.length - 1] nextCursor = lastItem.id } return { items: paginatedVerbs, totalCount: verbsBySource.length, hasMore, nextCursor } } // If filtering by targetId only, use the optimized method if ( options.filter.targetId && !options.filter.verbType && !options.filter.sourceId && !options.filter.service && !options.filter.metadata ) { const targetId = Array.isArray(options.filter.targetId) ? options.filter.targetId[0] : options.filter.targetId // Get verbs by target directly const verbsByTarget = await this.getVerbsByTarget_internal(targetId) // Apply pagination const paginatedVerbs = verbsByTarget.slice(offset, offset + limit) const hasMore = offset + limit < verbsByTarget.length // Set next cursor if there are more items let nextCursor: string | undefined = undefined if (hasMore && paginatedVerbs.length > 0) { const lastItem = paginatedVerbs[paginatedVerbs.length - 1] nextCursor = lastItem.id } return { items: paginatedVerbs, totalCount: verbsByTarget.length, hasMore, nextCursor } } // If filtering by verbType only, use the optimized method if ( options.filter.verbType && !options.filter.sourceId && !options.filter.targetId && !options.filter.service && !options.filter.metadata ) { const verbType = Array.isArray(options.filter.verbType) ? options.filter.verbType[0] : options.filter.verbType // Get verbs by type directly const verbsByType = await this.getVerbsByType_internal(verbType) // Apply pagination const paginatedVerbs = verbsByType.slice(offset, offset + limit) const hasMore = offset + limit < verbsByType.length // Set next cursor if there are more items let nextCursor: string | undefined = undefined if (hasMore && paginatedVerbs.length > 0) { const lastItem = paginatedVerbs[paginatedVerbs.length - 1] nextCursor = lastItem.id } return { items: paginatedVerbs, totalCount: verbsByType.length, hasMore, nextCursor } } } // For more complex filtering or no filtering, use a paginated approach // that avoids loading all verbs into memory at once try { // First, try to get a count of total verbs (if the adapter supports it) let totalCount: number | undefined = undefined try { // This is an optional method that adapters may implement if (typeof (this as any).countVerbs === 'function') { totalCount = await (this as any).countVerbs(options?.filter) } } catch (countError) { // Ignore errors from count method, it's optional console.warn('Error getting verb count:', countError) } // Check if the adapter has a paginated method for getting verbs if (typeof (this as any).getVerbsWithPagination === 'function') { // Use the adapter's paginated method // Convert offset to cursor if no cursor provided (adapters use cursor for offset) const effectiveCursor = cursor || (offset > 0 ? offset.toString() : undefined) const result = await (this as any).getVerbsWithPagination({ limit, cursor: effectiveCursor, filter: options?.filter }) // Items are already offset by the adapter via cursor, no need to slice const items = result.items // CRITICAL SAFETY CHECK: Prevent infinite loops // If we have no items but hasMore is true, force hasMore to false // This prevents pagination bugs from causing infinite loops const safeHasMore = items.length > 0 ? result.hasMore : false // VALIDATION: Ensure adapter returns totalCount (prevents restart bugs) // If adapter forgets to return totalCount, log warning and use pre-calculated count let finalTotalCount = result.totalCount || totalCount if (result.totalCount === undefined && this.totalVerbCount > 0) { console.warn( `⚠️ Storage adapter missing totalCount in getVerbsWithPagination result! ` + `Using pre-calculated count (${this.totalVerbCount}) as fallback. ` + `Please ensure your storage adapter returns totalCount: this.totalVerbCount` ) finalTotalCount = this.totalVerbCount } return { items, totalCount: finalTotalCount, hasMore: safeHasMore, nextCursor: result.nextCursor } } // Storage adapter does not support pagination console.error( 'Storage adapter does not support pagination. The deprecated getAllVerbs_internal() method has been removed. Please implement getVerbsWithPagination() in your storage adapter.' ) return { items: [], totalCount: 0, hasMore: false } } catch (error) { console.error('Error getting verbs with pagination:', error) return { items: [], totalCount: 0, hasMore: false } } } /** * Delete a verb from storage */ public async deleteVerb(id: string): Promise { await this.ensureInitialized() // Delete both the vector file and metadata file (2-file system) await this.deleteVerb_internal(id) // Delete metadata file (if it exists) try { await this.deleteVerbMetadata(id) } catch (error) { // Ignore if metadata file doesn't exist console.debug(`No metadata file to delete for verb ${id}`) } } /** * Get graph index (lazy initialization) */ async getGraphIndex(): Promise { if (!this.graphIndex) { console.log('Initializing GraphAdjacencyIndex...') this.graphIndex = new GraphAdjacencyIndex(this) // Check if we need to rebuild from existing data const sampleVerbs = await this.getVerbs({ pagination: { limit: 1 } }) if (sampleVerbs.items.length > 0) { console.log('Found existing verbs, rebuilding graph index...') await this.graphIndex.rebuild() } } return this.graphIndex } /** * Clear all data from storage * This method should be implemented by each specific adapter */ public abstract clear(): Promise /** * Get information about storage usage and capacity * This method should be implemented by each specific adapter */ public abstract getStorageStatus(): Promise<{ type: string used: number quota: number | null details?: Record }> /** * Write a JSON object to a specific path in storage * This is a primitive operation that all adapters must implement * @param path - Full path including filename (e.g., "_system/statistics.json" or "entities/nouns/metadata/3f/3fa85f64-....json") * @param data - Data to write (will be JSON.stringify'd) * @protected */ protected abstract writeObjectToPath(path: string, data: any): Promise /** * Read a JSON object from a specific path in storage * This is a primitive operation that all adapters must implement * @param path - Full path including filename * @returns The parsed JSON object, or null if not found * @protected */ protected abstract readObjectFromPath(path: string): Promise /** * Delete an object from a specific path in storage * This is a primitive operation that all adapters must implement * @param path - Full path including filename * @protected */ protected abstract deleteObjectFromPath(path: string): Promise /** * List all object paths under a given prefix * This is a primitive operation that all adapters must implement * @param prefix - Directory prefix to list (e.g., "entities/nouns/metadata/3f/") * @returns Array of full paths * @protected */ protected abstract listObjectsUnderPath(prefix: string): Promise /** * Save metadata to storage (v4.0.0: now typed) * Routes to correct location (system or entity) based on key format */ public async saveMetadata(id: string, metadata: NounMetadata): Promise { await this.ensureInitialized() const keyInfo = this.analyzeKey(id, 'system') return this.writeObjectToBranch(keyInfo.fullPath, metadata) } /** * Get metadata from storage (v4.0.0: now typed) * Routes to correct location (system or entity) based on key format */ public async getMetadata(id: string): Promise { await this.ensureInitialized() const keyInfo = this.analyzeKey(id, 'system') return this.readWithInheritance(keyInfo.fullPath) } /** * Save noun metadata to storage (v4.0.0: now typed) * Routes to correct sharded location based on UUID */ public async saveNounMetadata(id: string, metadata: NounMetadata): Promise { // Validate noun type in metadata - storage boundary protection validateNounType(metadata.noun) return this.saveNounMetadata_internal(id, metadata) } /** * Internal method for saving noun metadata (v4.0.0: now typed) * Uses routing logic to handle both UUIDs (sharded) and system keys (unsharded) * * CRITICAL (v4.1.2): Count synchronization happens here * This ensures counts are updated AFTER metadata exists, fixing the race condition * where storage adapters tried to read metadata before it was saved. * * @protected */ protected async saveNounMetadata_internal(id: string, metadata: NounMetadata): Promise { await this.ensureInitialized() // Determine if this is a new entity by checking if metadata already exists const keyInfo = this.analyzeKey(id, 'noun-metadata') const existingMetadata = await this.readWithInheritance(keyInfo.fullPath) const isNew = !existingMetadata // Save the metadata (COW-aware - writes to branch-specific path) await this.writeObjectToBranch(keyInfo.fullPath, metadata) // CRITICAL FIX (v4.1.2): Increment count for new entities // This runs AFTER metadata is saved, guaranteeing type information is available // Uses synchronous increment since storage operations are already serialized // Fixes Bug #1: Count synchronization failure during add() and import() if (isNew && metadata.noun) { this.incrementEntityCount(metadata.noun) // Persist counts asynchronously (fire and forget) this.scheduleCountPersist().catch(() => { // Ignore persist errors - will retry on next operation }) } } /** * Get noun metadata from storage (v4.0.0: now typed) * Uses routing logic to handle both UUIDs (sharded) and system keys (unsharded) */ public async getNounMetadata(id: string): Promise { await this.ensureInitialized() const keyInfo = this.analyzeKey(id, 'noun-metadata') return this.readWithInheritance(keyInfo.fullPath) } /** * Delete noun metadata from storage * Uses routing logic to handle both UUIDs (sharded) and system keys (unsharded) */ public async deleteNounMetadata(id: string): Promise { await this.ensureInitialized() const keyInfo = this.analyzeKey(id, 'noun-metadata') return this.deleteObjectFromBranch(keyInfo.fullPath) } /** * Save verb metadata to storage (v4.0.0: now typed) * Routes to correct sharded location based on UUID */ public async saveVerbMetadata(id: string, metadata: VerbMetadata): Promise { // Note: verb type is in HNSWVerb, not metadata return this.saveVerbMetadata_internal(id, metadata) } /** * Internal method for saving verb metadata (v4.0.0: now typed) * Uses routing logic to handle both UUIDs (sharded) and system keys (unsharded) * * CRITICAL (v4.1.2): Count synchronization happens here * This ensures verb counts are updated AFTER metadata exists, fixing the race condition * where storage adapters tried to read metadata before it was saved. * * Note: Verb type is now stored in both HNSWVerb (vector file) and VerbMetadata for count tracking * * @protected */ protected async saveVerbMetadata_internal(id: string, metadata: VerbMetadata): Promise { await this.ensureInitialized() // Determine if this is a new verb by checking if metadata already exists const keyInfo = this.analyzeKey(id, 'verb-metadata') const existingMetadata = await this.readWithInheritance(keyInfo.fullPath) const isNew = !existingMetadata // Save the metadata (COW-aware - writes to branch-specific path) await this.writeObjectToBranch(keyInfo.fullPath, metadata) // CRITICAL FIX (v4.1.2): Increment verb count for new relationships // This runs AFTER metadata is saved // Verb type is now stored in metadata (as of v4.1.2) to avoid loading HNSWVerb // Uses synchronous increment since storage operations are already serialized // Fixes Bug #2: Count synchronization failure during relate() and import() if (isNew && (metadata as any).verb) { this.incrementVerbCount((metadata as any).verb) // Persist counts asynchronously (fire and forget) this.scheduleCountPersist().catch(() => { // Ignore persist errors - will retry on next operation }) } } /** * Get verb metadata from storage (v4.0.0: now typed) * Uses routing logic to handle both UUIDs (sharded) and system keys (unsharded) */ public async getVerbMetadata(id: string): Promise { await this.ensureInitialized() const keyInfo = this.analyzeKey(id, 'verb-metadata') return this.readWithInheritance(keyInfo.fullPath) } /** * Delete verb metadata from storage * Uses routing logic to handle both UUIDs (sharded) and system keys (unsharded) */ public async deleteVerbMetadata(id: string): Promise { await this.ensureInitialized() const keyInfo = this.analyzeKey(id, 'verb-metadata') return this.deleteObjectFromBranch(keyInfo.fullPath) } /** * Save a noun to storage * This method should be implemented by each specific adapter */ protected abstract saveNoun_internal(noun: HNSWNoun): Promise /** * Get a noun from storage * This method should be implemented by each specific adapter */ protected abstract getNoun_internal(id: string): Promise /** * Get nouns by noun type * This method should be implemented by each specific adapter */ protected abstract getNounsByNounType_internal( nounType: string ): Promise /** * Delete a noun from storage * This method should be implemented by each specific adapter */ protected abstract deleteNoun_internal(id: string): Promise /** * Save a verb to storage * This method should be implemented by each specific adapter */ protected abstract saveVerb_internal(verb: HNSWVerb): Promise /** * Get a verb from storage * This method should be implemented by each specific adapter */ protected abstract getVerb_internal(id: string): Promise /** * Get verbs by source * This method should be implemented by each specific adapter */ protected abstract getVerbsBySource_internal( sourceId: string ): Promise /** * Get verbs by target * This method should be implemented by each specific adapter */ protected abstract getVerbsByTarget_internal( targetId: string ): Promise /** * Get verbs by type * This method should be implemented by each specific adapter */ protected abstract getVerbsByType_internal(type: string): Promise /** * Delete a verb from storage * This method should be implemented by each specific adapter */ protected abstract deleteVerb_internal(id: string): Promise /** * Helper method to convert a Map to a plain object for serialization */ protected mapToObject( map: Map, valueTransformer: (value: V) => any = (v) => v ): Record { const obj: Record = {} for (const [key, value] of map.entries()) { obj[key.toString()] = valueTransformer(value) } return obj } /** * Save statistics data to storage (public interface) * @param statistics The statistics data to save */ public async saveStatistics(statistics: StatisticsData): Promise { return this.saveStatisticsData(statistics) } /** * Get statistics data from storage (public interface) * @returns Promise that resolves to the statistics data or null if not found */ public async getStatistics(): Promise { return this.getStatisticsData() } /** * Save statistics data to storage * This method should be implemented by each specific adapter * @param statistics The statistics data to save */ protected abstract saveStatisticsData( statistics: StatisticsData ): Promise /** * Get statistics data from storage * This method should be implemented by each specific adapter * @returns Promise that resolves to the statistics data or null if not found */ protected abstract getStatisticsData(): Promise }