brainy/src/storage/adapters/opfsStorage.ts
David Snelling 3e8b9aacc8 feat: COW always-on architecture + cloud storage clear() fix (v5.11.0)
Major architectural improvements and critical bug fixes:

## COW Always-On Architecture
- Removed cowEnabled flag from BaseStorage (COW cannot be disabled)
- Eliminated marker file system (checkClearMarker, createClearMarker)
- Simplified all code paths to assume COW is always enabled
- COW automatically re-initializes after clear() operations

## Critical Bug Fix: Cloud Storage clear()
- Fixed GCS clear() using correct paths (branches/ instead of entities/nouns/)
- Fixed S3Compatible clear() path structure
- Fixed R2 clear() implementation
- Fixed Azure, FileSystem, OPFS, Memory clear() COW flag handling
- clear() now deletes: branches/, _cow/, _system/
- Result: Cloud buckets can now be fully cleared (previously impossible)

## Container Memory Detection
- Auto-detect Docker/K8s/Cloud Run memory limits (cgroup v1/v2)
- Smart memory allocation (75% graph data, 25% query operations)
- Environment variable support (CLOUD_RUN_MEMORY, MEMORY_LIMIT)
- Production-grade containerized deployment support

## CommitLog streamHistory Feature
- Added streamable commit history with pagination
- Efficient memory usage for large commit histories
- Support for branch filtering and time ranges

## Comprehensive Storage Documentation
- Complete v5.11.0 file structure reference
- Detailed path construction algorithms
- 8 common storage scenarios with examples
- Type-first storage, sharding, COW architecture explained
- Public docs: docs/architecture/data-storage-architecture.md (1063 lines)

## Files Modified (14 files)
- All 8 storage adapters (GCS, S3, R2, Azure, FS, OPFS, Memory, Historical)
- BaseStorage core architecture
- CommitLog with streaming
- Brainy memory configuration
- Parameter validation with container detection
- Storage architecture documentation

## Breaking Changes
NONE - COW was already enabled by default. This removes the ability to disable it.

## Migration
No action required. Upgrade and clear() will work correctly on cloud storage.

## Impact
- Users can now clear cloud storage buckets completely
- No more corrupted buckets after clear() operations
- Container deployments automatically optimize memory allocation
- COW is mandatory and always enabled (safer, simpler)

v5.11.0 - Production ready
2025-11-18 13:44:02 -08:00

1482 lines
46 KiB
TypeScript

/**
* 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,
NounType
} from '../../coreTypes.js'
import {
BaseStorage,
StorageBatchConfig,
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<File> {
// 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
*
* v5.4.0: Type-aware storage now built into BaseStorage
* - Removed 10 *_internal method overrides (now inherit from BaseStorage's type-first implementation)
* - Removed 2 pagination method overrides (getNounsWithPagination, getVerbsWithPagination)
* - Updated HNSW methods to use BaseStorage's getNoun/saveNoun (type-first paths)
* - All operations now use type-first paths: entities/nouns/{type}/vectors/{shard}/{id}.json
*/
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<string> = 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
}
/**
* Get OPFS-optimized batch configuration
*
* OPFS (Origin Private File System) is browser-based storage with moderate performance:
* - Moderate batch sizes (100 items)
* - Small delays (10ms) for browser event loop
* - Limited concurrency (50 operations) - browser constraints
* - Sequential processing preferred for stability
*
* @returns OPFS-optimized batch configuration
* @since v4.11.0
*/
public getBatchConfig(): StorageBatchConfig {
return {
maxBatchSize: 100,
batchDelayMs: 10,
maxConcurrent: 50,
supportsParallelWrites: false, // Sequential safer in browser
rateLimit: {
operationsPerSecond: 1000,
burstCapacity: 500
}
}
}
/**
* Initialize the storage adapter
*/
public async init(): Promise<void> {
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<boolean> {
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<boolean> {
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
}
}
// v5.4.0: Removed 10 *_internal method overrides - now inherit from BaseStorage's type-first implementation
/**
* Delete an edge from storage
*/
protected async deleteEdge(id: string): Promise<void> {
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<void> {
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<any | null> {
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<void> {
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<string[]> {
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<void> => {
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<Map<string, any>> {
await this.ensureInitialized()
const results = new Map<string, any>()
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<void> {
await this.ensureInitialized()
// Helper function to remove all files in a directory
const removeDirectoryContents = async (
dirHandle: FileSystemDirectoryHandle
): Promise<void> => {
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!)
// v5.6.1: Remove COW (copy-on-write) version control data
// This directory stores all git-like versioning data (commits, trees, blobs, refs)
// Must be deleted to fully clear all data including version history
try {
// Delete the entire _cow/ directory (not just contents)
await this.rootDir!.removeEntry('_cow', { recursive: true })
// v5.11.0: Reset COW managers (but don't disable COW - it's always enabled)
// COW will re-initialize automatically on next use
this.refManager = undefined
this.blobStorage = undefined
this.commitLog = undefined
} catch (error: any) {
// Ignore if _cow directory doesn't exist (not all instances use COW)
if (error.name !== 'NotFoundError') {
throw error
}
}
// Clear the statistics cache
this.statisticsCache = null
this.statisticsModified = false
// v5.6.1: Reset entity counters (inherited from BaseStorageAdapter)
// These in-memory counters must be reset to 0 after clearing all data
;(this as any).totalNounCount = 0
;(this as any).totalVerbCount = 0
} catch (error) {
console.error('Error clearing storage:', error)
throw error
}
}
/**
* Check if COW has been explicitly disabled via clear()
* v5.10.4: Fixes bug where clear() doesn't persist across instance restarts
* @returns true if marker file exists, false otherwise
* @protected
*/
/**
* v5.11.0: Removed checkClearMarker() and createClearMarker() methods
* COW is now always enabled - marker files are no longer used
*/
// Quota monitoring configuration (v4.0.0)
private quotaWarningThreshold = 0.8 // Warn at 80% usage
private quotaCriticalThreshold = 0.95 // Critical at 95% usage
private lastQuotaCheck: number = 0
private quotaCheckInterval = 60000 // Check every 60 seconds
/**
* Get information about storage usage and capacity
*/
public async getStorageStatus(): Promise<{
type: string
used: number
quota: number | null
details?: Record<string, any>
}> {
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<number> => {
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<number> => {
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<string, any> = {
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<string, number> = {}
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 detailed quota status with warnings (v4.0.0)
* Monitors storage usage and warns when approaching quota limits
*
* @returns Promise that resolves to quota status with warning levels
*
* @example
* const status = await storage.getQuotaStatus()
* if (status.warning) {
* console.warn(`Storage ${status.usagePercent}% full: ${status.warningMessage}`)
* }
*/
public async getQuotaStatus(): Promise<{
usage: number
quota: number | null
usagePercent: number
remaining: number | null
status: 'ok' | 'warning' | 'critical'
warning: boolean
warningMessage?: string
}> {
this.lastQuotaCheck = Date.now()
try {
if (!navigator.storage || !navigator.storage.estimate) {
return {
usage: 0,
quota: null,
usagePercent: 0,
remaining: null,
status: 'ok',
warning: false
}
}
const estimate = await navigator.storage.estimate()
const usage = estimate.usage || 0
const quota = estimate.quota || null
if (!quota) {
return {
usage,
quota: null,
usagePercent: 0,
remaining: null,
status: 'ok',
warning: false
}
}
const usagePercent = (usage / quota) * 100
const remaining = quota - usage
// Determine status
let status: 'ok' | 'warning' | 'critical' = 'ok'
let warning = false
let warningMessage: string | undefined
if (usagePercent >= this.quotaCriticalThreshold * 100) {
status = 'critical'
warning = true
warningMessage = `Critical: Storage ${usagePercent.toFixed(1)}% full. Only ${(remaining / 1024 / 1024).toFixed(1)}MB remaining. Please delete old data.`
} else if (usagePercent >= this.quotaWarningThreshold * 100) {
status = 'warning'
warning = true
warningMessage = `Warning: Storage ${usagePercent.toFixed(1)}% full. ${(remaining / 1024 / 1024).toFixed(1)}MB remaining.`
}
if (warning) {
console.warn(`[OPFS Quota] ${warningMessage}`)
}
return {
usage,
quota,
usagePercent,
remaining,
status,
warning,
warningMessage
}
} catch (error) {
console.error('Failed to get quota status:', error)
return {
usage: 0,
quota: null,
usagePercent: 0,
remaining: null,
status: 'ok',
warning: false
}
}
}
/**
* Monitor quota during operations (v4.0.0)
* Automatically checks quota at regular intervals and warns if approaching limits
* Call this before write operations to ensure quota is available
*
* @returns Promise that resolves when quota check is complete
*
* @example
* await storage.monitorQuota() // Checks quota if interval has passed
* await storage.saveNoun(noun) // Proceed with write operation
*/
public async monitorQuota(): Promise<void> {
const now = Date.now()
// Only check if interval has passed
if (now - this.lastQuotaCheck < this.quotaCheckInterval) {
return
}
const status = await this.getQuotaStatus()
// If critical, throw error to prevent data loss
if (status.status === 'critical' && status.warningMessage) {
throw new Error(`Storage quota critical: ${status.warningMessage}`)
}
}
/**
* 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<boolean> {
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<void> {
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<void> {
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<void> {
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<StatisticsData | null> {
// 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
*/
// v5.4.0: Removed pagination overrides (getNounsWithPagination, getVerbsWithPagination) - use BaseStorage's type-first implementation
/**
* Initialize counts from OPFS storage
*/
protected async initializeCounts(): Promise<void> {
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<void> {
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<void> {
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
*/
/**
* Get vector for a noun
* v5.4.0: Uses BaseStorage's getNoun (type-first paths)
*/
public async getNounVector(id: string): Promise<number[] | null> {
const noun = await this.getNoun(id)
return noun ? noun.vector : null
}
// CRITICAL FIX (v4.10.1): Mutex locks for HNSW concurrency control
// Browser environments are single-threaded but async operations can still interleave
private hnswLocks = new Map<string, Promise<void>>()
/**
* Save HNSW graph data for a noun
*
* v5.4.0: Uses BaseStorage's getNoun/saveNoun (type-first paths)
* CRITICAL: Preserves mutex locking to prevent read-modify-write races
*/
public async saveHNSWData(nounId: string, hnswData: {
level: number
connections: Record<string, string[]>
}): Promise<void> {
const lockKey = `hnsw/${nounId}`
// MUTEX LOCK: Wait for any pending operations on this entity
while (this.hnswLocks.has(lockKey)) {
await this.hnswLocks.get(lockKey)
}
// Acquire lock
let releaseLock!: () => void
const lockPromise = new Promise<void>(resolve => { releaseLock = resolve })
this.hnswLocks.set(lockKey, lockPromise)
try {
// v5.4.0: Use BaseStorage's getNoun (type-first paths)
const existingNoun = await this.getNoun(nounId)
if (!existingNoun) {
throw new Error(`Cannot save HNSW data: noun ${nounId} not found`)
}
// Convert connections from Record to Map format
const connectionsMap = new Map<number, Set<string>>()
for (const [level, nodeIds] of Object.entries(hnswData.connections)) {
connectionsMap.set(Number(level), new Set(nodeIds))
}
// Preserve id and vector, update only HNSW graph metadata
const updatedNoun: HNSWNoun = {
...existingNoun,
level: hnswData.level,
connections: connectionsMap
}
// v5.4.0: Use BaseStorage's saveNoun (type-first paths)
await this.saveNoun(updatedNoun)
} finally {
// Release lock
this.hnswLocks.delete(lockKey)
releaseLock()
}
}
/**
* Get HNSW graph data for a noun
* v5.4.0: Uses BaseStorage's getNoun (type-first paths)
*/
public async getHNSWData(nounId: string): Promise<{
level: number
connections: Record<string, string[]>
} | null> {
const noun = await this.getNoun(nounId)
if (!noun) {
return null
}
// Convert connections from Map to Record format
const connectionsRecord: Record<string, string[]> = {}
if (noun.connections) {
for (const [level, nodeIds] of noun.connections.entries()) {
connectionsRecord[String(level)] = Array.from(nodeIds)
}
}
return {
level: noun.level || 0,
connections: connectionsRecord
}
}
/**
* Save HNSW system data (entry point, max level)
* Storage path: index/hnsw-system.json
*
* CRITICAL FIX (v4.10.1): Mutex locking to prevent race conditions
*/
public async saveHNSWSystem(systemData: {
entryPointId: string | null
maxLevel: number
}): Promise<void> {
await this.ensureInitialized()
const lockKey = 'system/hnsw-system'
// MUTEX LOCK: Wait for any pending operations
while (this.hnswLocks.has(lockKey)) {
await this.hnswLocks.get(lockKey)
}
// Acquire lock
let releaseLock!: () => void
const lockPromise = new Promise<void>(resolve => { releaseLock = resolve })
this.hnswLocks.set(lockKey, lockPromise)
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}`)
} finally {
// Release lock
this.hnswLocks.delete(lockKey)
releaseLock()
}
}
/**
* 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}`)
}
}
}