brainy/src/storage/adapters/r2Storage.ts
David Snelling 46e74827c4 fix(storage): CRITICAL - preserve vectors when updating HNSW connections (v4.7.3)
CRITICAL DATA CORRUPTION FIX affecting ALL storage adapters

## Root Cause
When HNSW index updated node connections (adding new neighbors), saveHNSWData()
overwrote the entire node file with ONLY {level, connections}, destroying vector data.

## Impact
- v4.7.2: Broke ALL imports - relate() crashed with "Cannot read properties of undefined"
- Affected ALL storage adapters: FileSystem, GCS, Azure, R2, OPFS, S3Compatible
- VFS imports completely non-functional
- Any multi-entity operation would corrupt existing entity vectors

## The Bug
```typescript
// OLD CODE (v4.7.2) - DESTROYED VECTORS:
async saveHNSWData(id, hnswData) {
  await writeFile(path, JSON.stringify(hnswData))  // Only {level, connections}!
}
```

When entity2 was added to HNSW:
1. HNSW found entity1 as neighbor
2. Updated entity1's connections
3. Called saveHNSWData(entity1.id, {level, connections})
4. Overwrote entity1.json with ONLY {level, connections}
5. **entity1.vector and entity1.id were DESTROYED**

## The Fix
```typescript
// NEW CODE (v4.7.3) - PRESERVES ALL DATA:
async saveHNSWData(id, hnswData) {
  const existing = await readFile(path)
  const updated = {...existing, level: hnswData.level, connections: hnswData.connections}
  await writeFile(path, JSON.stringify(updated))  // Preserves id, vector, etc.
}
```

Now READ existing node, UPDATE only HNSW fields, WRITE complete node.

## Files Changed
- src/storage/adapters/fileSystemStorage.ts (line 2590-2626)
- src/storage/adapters/gcsStorage.ts (line 1863-1911)
- src/storage/adapters/azureBlobStorage.ts (line 1638-1682)
- src/storage/adapters/r2Storage.ts (line 999-1029)
- src/storage/adapters/opfsStorage.ts (line 2012-2051)
- src/storage/adapters/s3CompatibleStorage.ts (line 3903-3961)

## Testing
 FileSystemStorage: Verified with test-relate-crash.js
 All adapters: Compilation successful
 Imports: VFS directory creation and relate() working

## Breaking Changes
NONE - This is a critical bug fix

## Migration
Workshop team: Delete brainy-data and reimport with v4.7.3

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 13:07:00 -07:00

1273 lines
38 KiB
TypeScript

/**
* Cloudflare R2 Storage Adapter (Dedicated)
* Optimized specifically for Cloudflare R2 with all latest features
*
* R2-Specific Optimizations:
* - Zero egress fees (aggressive caching)
* - Cloudflare global network (edge-aware routing)
* - Workers integration (optional edge compute)
* - High-volume mode for bulk operations
* - Smart batching and backpressure
*
* Based on latest GCS and S3 implementations with R2-specific enhancements
*/
import {
GraphVerb,
HNSWNoun,
HNSWVerb,
NounMetadata,
VerbMetadata,
HNSWNounWithMetadata,
HNSWVerbWithMetadata,
StatisticsData
} from '../../coreTypes.js'
import {
BaseStorage,
NOUNS_DIR,
VERBS_DIR,
METADATA_DIR,
INDEX_DIR,
SYSTEM_DIR,
STATISTICS_KEY,
getDirectoryPath
} from '../baseStorage.js'
import { BrainyError } from '../../errors/brainyError.js'
import { CacheManager } from '../cacheManager.js'
import { createModuleLogger, prodLog } from '../../utils/logger.js'
import { getGlobalSocketManager } from '../../utils/adaptiveSocketManager.js'
import { getGlobalBackpressure } from '../../utils/adaptiveBackpressure.js'
import { getWriteBuffer, WriteBuffer } from '../../utils/writeBuffer.js'
import { getCoalescer, RequestCoalescer } from '../../utils/requestCoalescer.js'
import { getShardIdFromUuid, getAllShardIds, getShardIdByIndex, TOTAL_SHARDS } from '../sharding.js'
// Type aliases for better readability
type HNSWNode = HNSWNoun
type Edge = HNSWVerb
// S3 client types - R2 uses S3-compatible API
type S3Client = any
type S3Command = any
// R2 API limits (same as S3)
const MAX_R2_PAGE_SIZE = 1000
/**
* Dedicated Cloudflare R2 storage adapter
* Optimized for R2's unique characteristics and global edge network
*
* Configuration:
* ```typescript
* const r2Storage = new R2Storage({
* bucketName: 'my-brainy-data',
* accountId: 'YOUR_CLOUDFLARE_ACCOUNT_ID',
* accessKeyId: 'YOUR_R2_ACCESS_KEY_ID',
* secretAccessKey: 'YOUR_R2_SECRET_ACCESS_KEY'
* })
* ```
*/
export class R2Storage extends BaseStorage {
private s3Client: S3Client | null = null
private bucketName: string
private accountId: string
private accessKeyId: string
private secretAccessKey: string
// R2-specific endpoint (auto-constructed from account ID)
private endpoint: string
// Prefixes for different types of data
private nounPrefix: string
private verbPrefix: string
private metadataPrefix: string // Noun metadata
private verbMetadataPrefix: string // Verb metadata
private systemPrefix: string // System data
// Statistics caching for better performance
protected statisticsCache: StatisticsData | null = null
// Backpressure and performance management
private pendingOperations: number = 0
private maxConcurrentOperations: number = 150 // R2 handles more concurrent ops
private baseBatchSize: number = 15 // Larger batches for R2
private currentBatchSize: number = 15
private lastMemoryCheck: number = 0
private memoryCheckInterval: number = 5000
// Adaptive backpressure for automatic flow control
private backpressure = getGlobalBackpressure()
// Write buffers for bulk operations
private nounWriteBuffer: WriteBuffer<HNSWNode> | null = null
private verbWriteBuffer: WriteBuffer<Edge> | null = null
// Request coalescer for deduplication
private requestCoalescer: RequestCoalescer | null = null
// High-volume mode detection (R2-specific thresholds)
private highVolumeMode = false
private lastVolumeCheck = 0
private volumeCheckInterval = 800 // Check more frequently on R2
private forceHighVolumeMode = false
// Multi-level cache manager for efficient data access
private nounCacheManager: CacheManager<HNSWNode>
private verbCacheManager: CacheManager<Edge>
// Module logger
private logger = createModuleLogger('R2Storage')
/**
* Initialize the R2 storage adapter
* @param options Configuration options for Cloudflare R2
*/
constructor(options: {
bucketName: string
accountId: string
accessKeyId: string
secretAccessKey: string
// Optional configuration
cacheConfig?: {
hotCacheMaxSize?: number
hotCacheEvictionThreshold?: number
warmCacheTTL?: number
}
readOnly?: boolean
}) {
super()
this.bucketName = options.bucketName
this.accountId = options.accountId
this.accessKeyId = options.accessKeyId
this.secretAccessKey = options.secretAccessKey
this.readOnly = options.readOnly || false
// R2-specific endpoint format
this.endpoint = `https://${this.accountId}.r2.cloudflarestorage.com`
// Set up prefixes for different types of data using entity-based structure
this.nounPrefix = `${getDirectoryPath('noun', 'vector')}/`
this.verbPrefix = `${getDirectoryPath('verb', 'vector')}/`
this.metadataPrefix = `${getDirectoryPath('noun', 'metadata')}/`
this.verbMetadataPrefix = `${getDirectoryPath('verb', 'metadata')}/`
this.systemPrefix = `${SYSTEM_DIR}/`
// Initialize cache managers with R2-optimized settings
this.nounCacheManager = new CacheManager<HNSWNode>({
hotCacheMaxSize: options.cacheConfig?.hotCacheMaxSize || 10000,
hotCacheEvictionThreshold: options.cacheConfig?.hotCacheEvictionThreshold || 0.9,
warmCacheTTL: options.cacheConfig?.warmCacheTTL || 3600000 // 1 hour
})
this.verbCacheManager = new CacheManager<Edge>(options.cacheConfig)
// Check for high-volume mode override
if (typeof process !== 'undefined' && process.env?.BRAINY_FORCE_HIGH_VOLUME === 'true') {
this.forceHighVolumeMode = true
this.highVolumeMode = true
prodLog.info('🚀 R2: High-volume mode FORCED via environment variable')
}
}
/**
* Initialize the storage adapter
*/
public async init(): Promise<void> {
if (this.isInitialized) {
return
}
try {
// Import AWS S3 SDK only when needed (R2 uses S3-compatible API)
const { S3Client: S3ClientClass, HeadBucketCommand } = await import('@aws-sdk/client-s3')
// Create S3 client configured for R2
this.s3Client = new S3ClientClass({
region: 'auto', // R2 uses 'auto' region
endpoint: this.endpoint,
credentials: {
accessKeyId: this.accessKeyId,
secretAccessKey: this.secretAccessKey
}
})
// Verify bucket exists and is accessible
try {
await this.s3Client.send(new HeadBucketCommand({ Bucket: this.bucketName }))
} catch (error: any) {
if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) {
throw new Error(`R2 bucket ${this.bucketName} does not exist or is not accessible`)
}
throw error
}
prodLog.info(`✅ Connected to R2 bucket: ${this.bucketName} (account: ${this.accountId})`)
// Initialize write buffers for high-volume mode
const storageId = `r2-${this.bucketName}`
this.nounWriteBuffer = getWriteBuffer<HNSWNode>(
`${storageId}-nouns`,
'noun',
async (items) => {
await this.flushNounBuffer(items)
}
)
this.verbWriteBuffer = getWriteBuffer<Edge>(
`${storageId}-verbs`,
'verb',
async (items) => {
await this.flushVerbBuffer(items)
}
)
// Initialize request coalescer for deduplication
this.requestCoalescer = getCoalescer(
storageId,
async (batch) => {
this.logger.trace(`Processing coalesced batch: ${batch.length} items`)
}
)
// Initialize counts from storage
await this.initializeCounts()
// Clear cache from previous runs
prodLog.info('🧹 R2: Clearing cache from previous run')
this.nounCacheManager.clear()
this.verbCacheManager.clear()
this.isInitialized = true
} catch (error) {
this.logger.error('Failed to initialize R2 storage:', error)
throw new Error(`Failed to initialize R2 storage: ${error}`)
}
}
/**
* Get the R2 object key for a noun using UUID-based sharding
*/
private getNounKey(id: string): string {
const shardId = getShardIdFromUuid(id)
return `${this.nounPrefix}${shardId}/${id}.json`
}
/**
* Get the R2 object key for a verb using UUID-based sharding
*/
private getVerbKey(id: string): string {
const shardId = getShardIdFromUuid(id)
return `${this.verbPrefix}${shardId}/${id}.json`
}
/**
* Override base class method to detect R2-specific throttling errors
*/
protected isThrottlingError(error: any): boolean {
// First check base class detection
if (super.isThrottlingError(error)) {
return true
}
// R2-specific throttling detection (uses S3 error codes)
const errorName = error.name
const statusCode = error.$metadata?.httpStatusCode
return (
errorName === 'SlowDown' ||
errorName === 'ServiceUnavailable' ||
statusCode === 429 ||
statusCode === 503
)
}
/**
* Override base class to enable smart batching for cloud storage
* R2 is cloud storage with network latency benefits from batching
*/
protected isCloudStorage(): boolean {
return true
}
/**
* Apply backpressure before starting an operation
*/
private async applyBackpressure(): Promise<string> {
const requestId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
await this.backpressure.requestPermission(requestId, 1)
this.pendingOperations++
return requestId
}
/**
* Release backpressure after completing an operation
*/
private releaseBackpressure(success: boolean = true, requestId?: string): void {
this.pendingOperations = Math.max(0, this.pendingOperations - 1)
if (requestId) {
this.backpressure.releasePermission(requestId, success)
}
}
/**
* Check if high-volume mode should be enabled
*/
private checkVolumeMode(): void {
if (this.forceHighVolumeMode) {
return
}
const now = Date.now()
if (now - this.lastVolumeCheck < this.volumeCheckInterval) {
return
}
this.lastVolumeCheck = now
// R2 threshold: enable at 15 pending operations (lower than S3/GCS)
const shouldEnable = this.pendingOperations > 15
if (shouldEnable && !this.highVolumeMode) {
this.highVolumeMode = true
prodLog.info('🚀 R2: High-volume mode ENABLED (pending:', this.pendingOperations, ')')
} else if (!shouldEnable && this.highVolumeMode && !this.forceHighVolumeMode) {
this.highVolumeMode = false
prodLog.info('🐌 R2: High-volume mode DISABLED (pending:', this.pendingOperations, ')')
}
}
/**
* Flush noun buffer to R2
*/
private async flushNounBuffer(items: Map<string, HNSWNode>): Promise<void> {
const writes = Array.from(items.values()).map(async (noun) => {
try {
await this.saveNodeDirect(noun)
} catch (error) {
this.logger.error(`Failed to flush noun ${noun.id}:`, error)
}
})
await Promise.all(writes)
}
/**
* Flush verb buffer to R2
*/
private async flushVerbBuffer(items: Map<string, Edge>): Promise<void> {
const writes = Array.from(items.values()).map(async (verb) => {
try {
await this.saveEdgeDirect(verb)
} catch (error) {
this.logger.error(`Failed to flush verb ${verb.id}:`, error)
}
})
await Promise.all(writes)
}
/**
* Save a noun to storage (internal implementation)
*/
protected async saveNoun_internal(noun: HNSWNoun): Promise<void> {
return this.saveNode(noun)
}
/**
* Save a node to storage
*/
protected async saveNode(node: HNSWNode): Promise<void> {
await this.ensureInitialized()
this.checkVolumeMode()
// Use write buffer in high-volume mode
if (this.highVolumeMode && this.nounWriteBuffer) {
this.logger.trace(`📝 BUFFERING: Adding noun ${node.id} to write buffer`)
await this.nounWriteBuffer.add(node.id, node)
return
}
// Direct write in normal mode
await this.saveNodeDirect(node)
}
/**
* Save a node directly to R2 (bypass buffer)
*/
private async saveNodeDirect(node: HNSWNode): Promise<void> {
const requestId = await this.applyBackpressure()
try {
this.logger.trace(`Saving node ${node.id}`)
// Convert connections Map to serializable format
const serializableNode = {
id: node.id,
vector: node.vector,
connections: Object.fromEntries(
Array.from(node.connections.entries()).map(([level, nounIds]) => [
level,
Array.from(nounIds)
])
),
level: node.level || 0
}
// Get the R2 key with UUID-based sharding
const key = this.getNounKey(node.id)
// Save to R2 using S3 PutObject
const { PutObjectCommand } = await import('@aws-sdk/client-s3')
await this.s3Client!.send(
new PutObjectCommand({
Bucket: this.bucketName,
Key: key,
Body: JSON.stringify(serializableNode, null, 2),
ContentType: 'application/json'
})
)
// Cache nodes with non-empty vectors (Phase 2 optimization)
if (node.vector && Array.isArray(node.vector) && node.vector.length > 0) {
this.nounCacheManager.set(node.id, node)
}
// Increment noun count
const metadata = await this.getNounMetadata(node.id)
if (metadata && metadata.type) {
await this.incrementEntityCountSafe(metadata.type as string)
}
this.logger.trace(`Node ${node.id} saved successfully`)
this.releaseBackpressure(true, requestId)
} catch (error: any) {
this.releaseBackpressure(false, requestId)
if (this.isThrottlingError(error)) {
await this.handleThrottling(error)
throw error
}
this.logger.error(`Failed to save node ${node.id}:`, error)
throw new Error(`Failed to save node ${node.id}: ${error}`)
}
}
/**
* Get a noun from storage (internal implementation)
* v4.0.0: Returns ONLY vector data (no metadata field)
* Base class combines with metadata via getNoun() -> HNSWNounWithMetadata
*/
protected async getNoun_internal(id: string): Promise<HNSWNoun | null> {
// v4.0.0: Return ONLY vector data (no metadata field)
const node = await this.getNode(id)
if (!node) {
return null
}
// Return pure vector structure
return node
}
/**
* Get a node from storage
*/
protected async getNode(id: string): Promise<HNSWNode | null> {
await this.ensureInitialized()
// Check cache first (Phase 2: aggressive caching for R2 zero-egress)
const cached = await this.nounCacheManager.get(id)
if (cached !== undefined && cached !== null) {
if (!cached.id || !cached.vector || !Array.isArray(cached.vector) || cached.vector.length === 0) {
this.logger.warn(`Invalid cached object for ${id.substring(0, 8)} - removing from cache`)
this.nounCacheManager.delete(id)
} else {
this.logger.trace(`Cache hit for noun ${id}`)
return cached
}
}
const requestId = await this.applyBackpressure()
try {
this.logger.trace(`Getting node ${id}`)
const key = this.getNounKey(id)
// Get from R2 using S3 GetObject
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
const response = await this.s3Client!.send(
new GetObjectCommand({
Bucket: this.bucketName,
Key: key
})
)
const bodyContents = await response.Body!.transformToString()
const data = JSON.parse(bodyContents)
// Convert serialized connections back to Map
const connections = new Map<number, Set<string>>()
for (const [level, nounIds] of Object.entries(data.connections || {})) {
connections.set(Number(level), new Set(nounIds as string[]))
}
const node: HNSWNode = {
id: data.id,
vector: data.vector,
connections,
level: data.level || 0
}
// Cache valid nodes with non-empty vectors
if (node && node.id && node.vector && Array.isArray(node.vector) && node.vector.length > 0) {
this.nounCacheManager.set(id, node)
}
this.logger.trace(`Successfully retrieved node ${id}`)
this.releaseBackpressure(true, requestId)
return node
} catch (error: any) {
this.releaseBackpressure(false, requestId)
// R2 returns NoSuchKey for 404
if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) {
return null
}
if (this.isThrottlingError(error)) {
await this.handleThrottling(error)
throw error
}
this.logger.error(`Failed to get node ${id}:`, error)
throw BrainyError.fromError(error, `getNoun(${id})`)
}
}
/**
* Delete a noun from storage (internal implementation)
*/
protected async deleteNoun_internal(id: string): Promise<void> {
await this.ensureInitialized()
const requestId = await this.applyBackpressure()
try {
this.logger.trace(`Deleting noun ${id}`)
const key = this.getNounKey(id)
// Delete from R2 using S3 DeleteObject
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3')
await this.s3Client!.send(
new DeleteObjectCommand({
Bucket: this.bucketName,
Key: key
})
)
// Remove from cache
this.nounCacheManager.delete(id)
// Decrement noun count
const metadata = await this.getNounMetadata(id)
if (metadata && metadata.type) {
await this.decrementEntityCountSafe(metadata.type as string)
}
this.logger.trace(`Noun ${id} deleted successfully`)
this.releaseBackpressure(true, requestId)
} catch (error: any) {
this.releaseBackpressure(false, requestId)
if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) {
this.logger.trace(`Noun ${id} not found (already deleted)`)
return
}
if (this.isThrottlingError(error)) {
await this.handleThrottling(error)
throw error
}
this.logger.error(`Failed to delete noun ${id}:`, error)
throw new Error(`Failed to delete noun ${id}: ${error}`)
}
}
/**
* Write an object to a specific path in R2
*/
protected async writeObjectToPath(path: string, data: any): Promise<void> {
await this.ensureInitialized()
try {
this.logger.trace(`Writing object to path: ${path}`)
const { PutObjectCommand } = await import('@aws-sdk/client-s3')
await this.s3Client!.send(
new PutObjectCommand({
Bucket: this.bucketName,
Key: path,
Body: JSON.stringify(data, null, 2),
ContentType: 'application/json'
})
)
this.logger.trace(`Object written successfully to ${path}`)
} catch (error) {
this.logger.error(`Failed to write object to ${path}:`, error)
throw new Error(`Failed to write object to ${path}: ${error}`)
}
}
/**
* Read an object from a specific path in R2
*/
protected async readObjectFromPath(path: string): Promise<any | null> {
await this.ensureInitialized()
try {
this.logger.trace(`Reading object from path: ${path}`)
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
const response = await this.s3Client!.send(
new GetObjectCommand({
Bucket: this.bucketName,
Key: path
})
)
const bodyContents = await response.Body!.transformToString()
const data = JSON.parse(bodyContents)
this.logger.trace(`Object read successfully from ${path}`)
return data
} catch (error: any) {
if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) {
this.logger.trace(`Object not found at ${path}`)
return null
}
this.logger.error(`Failed to read object from ${path}:`, error)
throw BrainyError.fromError(error, `readObjectFromPath(${path})`)
}
}
/**
* Delete an object from a specific path in R2
*/
protected async deleteObjectFromPath(path: string): Promise<void> {
await this.ensureInitialized()
try {
this.logger.trace(`Deleting object at path: ${path}`)
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3')
await this.s3Client!.send(
new DeleteObjectCommand({
Bucket: this.bucketName,
Key: path
})
)
this.logger.trace(`Object deleted successfully from ${path}`)
} catch (error: any) {
if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) {
this.logger.trace(`Object at ${path} not found (already deleted)`)
return
}
this.logger.error(`Failed to delete object from ${path}:`, error)
throw new Error(`Failed to delete object from ${path}: ${error}`)
}
}
/**
* List all objects under a specific prefix in R2
*/
protected async listObjectsUnderPath(prefix: string): Promise<string[]> {
await this.ensureInitialized()
try {
this.logger.trace(`Listing objects under prefix: ${prefix}`)
const { ListObjectsV2Command } = await import('@aws-sdk/client-s3')
const response = await this.s3Client!.send(
new ListObjectsV2Command({
Bucket: this.bucketName,
Prefix: prefix,
MaxKeys: MAX_R2_PAGE_SIZE
})
)
const paths = (response.Contents || [])
.map((obj: any) => obj.Key)
.filter((key: string) => key && key.length > 0)
this.logger.trace(`Found ${paths.length} objects under ${prefix}`)
return paths
} catch (error) {
this.logger.error(`Failed to list objects under ${prefix}:`, error)
throw new Error(`Failed to list objects under ${prefix}: ${error}`)
}
}
// Verb storage methods (similar to noun methods - implementing key methods for space)
protected async saveVerb_internal(verb: HNSWVerb): Promise<void> {
return this.saveEdge(verb)
}
protected async saveEdge(edge: Edge): Promise<void> {
await this.ensureInitialized()
this.checkVolumeMode()
if (this.highVolumeMode && this.verbWriteBuffer) {
await this.verbWriteBuffer.add(edge.id, edge)
return
}
await this.saveEdgeDirect(edge)
}
private async saveEdgeDirect(edge: Edge): Promise<void> {
const requestId = await this.applyBackpressure()
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: Object.fromEntries(
Array.from(edge.connections.entries()).map(([level, verbIds]) => [
level,
Array.from(verbIds)
])
),
// 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()
}
const key = this.getVerbKey(edge.id)
const { PutObjectCommand } = await import('@aws-sdk/client-s3')
await this.s3Client!.send(
new PutObjectCommand({
Bucket: this.bucketName,
Key: key,
Body: JSON.stringify(serializableEdge, null, 2),
ContentType: 'application/json'
})
)
this.verbCacheManager.set(edge.id, edge)
// Count tracking happens in baseStorage.saveVerbMetadata_internal (v4.1.2)
// This fixes the race condition where metadata didn't exist yet
this.releaseBackpressure(true, requestId)
} catch (error: any) {
this.releaseBackpressure(false, requestId)
if (this.isThrottlingError(error)) {
await this.handleThrottling(error)
throw 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<HNSWVerb | null> {
// 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
}
protected async getEdge(id: string): Promise<Edge | null> {
await this.ensureInitialized()
const cached = this.verbCacheManager.get(id)
if (cached) {
return cached
}
const requestId = await this.applyBackpressure()
try {
const key = this.getVerbKey(id)
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
const response = await this.s3Client!.send(
new GetObjectCommand({
Bucket: this.bucketName,
Key: key
})
)
const bodyContents = await response.Body!.transformToString()
const data = JSON.parse(bodyContents)
const connections = new Map<number, Set<string>>()
for (const [level, verbIds] of Object.entries(data.connections || {})) {
connections.set(Number(level), new Set(verbIds as string[]))
}
// v4.0.0: Return HNSWVerb with core relational fields (NO metadata field)
const edge: Edge = {
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()
}
this.verbCacheManager.set(id, edge)
this.releaseBackpressure(true, requestId)
return edge
} catch (error: any) {
this.releaseBackpressure(false, requestId)
if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) {
return null
}
if (this.isThrottlingError(error)) {
await this.handleThrottling(error)
throw error
}
throw BrainyError.fromError(error, `getVerb(${id})`)
}
}
protected async deleteVerb_internal(id: string): Promise<void> {
await this.ensureInitialized()
const requestId = await this.applyBackpressure()
try {
const key = this.getVerbKey(id)
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3')
await this.s3Client!.send(
new DeleteObjectCommand({
Bucket: this.bucketName,
Key: key
})
)
this.verbCacheManager.delete(id)
const metadata = await this.getVerbMetadata(id)
if (metadata && metadata.type) {
await this.decrementVerbCount(metadata.type as string)
}
this.releaseBackpressure(true, requestId)
} catch (error: any) {
this.releaseBackpressure(false, requestId)
if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) {
return
}
if (this.isThrottlingError(error)) {
await this.handleThrottling(error)
throw error
}
throw new Error(`Failed to delete verb ${id}: ${error}`)
}
}
// Pagination and count management (simplified for space - full implementation similar to GCS)
protected async initializeCounts(): Promise<void> {
const key = `${this.systemPrefix}counts.json`
try {
const counts = await this.readObjectFromPath(key)
if (counts) {
this.totalNounCount = counts.totalNounCount || 0
this.totalVerbCount = counts.totalVerbCount || 0
this.entityCounts = new Map(Object.entries(counts.entityCounts || {})) as Map<string, number>
this.verbCounts = new Map(Object.entries(counts.verbCounts || {})) as Map<string, number>
prodLog.info(`📊 R2: Loaded counts: ${this.totalNounCount} nouns, ${this.totalVerbCount} verbs`)
} else {
prodLog.info('📊 R2: No counts file found - initializing from scan')
await this.initializeCountsFromScan()
}
} catch (error) {
prodLog.error('❌ R2: Failed to load counts:', error)
await this.initializeCountsFromScan()
}
}
private async initializeCountsFromScan(): Promise<void> {
try {
prodLog.info('📊 R2: Scanning bucket to initialize counts...')
const { ListObjectsV2Command } = await import('@aws-sdk/client-s3')
// Count nouns
const nounResponse = await this.s3Client!.send(
new ListObjectsV2Command({
Bucket: this.bucketName,
Prefix: this.nounPrefix
})
)
this.totalNounCount = (nounResponse.Contents || []).filter((obj: any) =>
obj.Key?.endsWith('.json')
).length
// Count verbs
const verbResponse = await this.s3Client!.send(
new ListObjectsV2Command({
Bucket: this.bucketName,
Prefix: this.verbPrefix
})
)
this.totalVerbCount = (verbResponse.Contents || []).filter((obj: any) =>
obj.Key?.endsWith('.json')
).length
if (this.totalNounCount > 0 || this.totalVerbCount > 0) {
await this.persistCounts()
prodLog.info(`✅ R2: Initialized counts: ${this.totalNounCount} nouns, ${this.totalVerbCount} verbs`)
} else {
prodLog.warn('⚠️ R2: No entities found during bucket scan')
}
} catch (error) {
this.logger.error('❌ R2: Failed to initialize counts from scan:', error)
throw new Error(`Failed to initialize R2 storage counts: ${error}`)
}
}
protected async persistCounts(): Promise<void> {
try {
const key = `${this.systemPrefix}counts.json`
const counts = {
totalNounCount: this.totalNounCount,
totalVerbCount: this.totalVerbCount,
entityCounts: Object.fromEntries(this.entityCounts),
verbCounts: Object.fromEntries(this.verbCounts),
lastUpdated: new Date().toISOString()
}
await this.writeObjectToPath(key, counts)
} catch (error) {
this.logger.error('Error persisting counts:', error)
}
}
// HNSW Index Persistence (Phase 2 support)
public async getNounVector(id: string): Promise<number[] | null> {
await this.ensureInitialized()
const noun = await this.getNode(id)
return noun ? noun.vector : null
}
public async saveHNSWData(nounId: string, hnswData: {
level: number
connections: Record<string, string[]>
}): Promise<void> {
await this.ensureInitialized()
// CRITICAL FIX (v4.7.3): Must preserve existing node data (id, vector) when updating HNSW metadata
const shard = getShardIdFromUuid(nounId)
const key = `entities/nouns/hnsw/${shard}/${nounId}.json`
try {
// Read existing node data
const existingNode = await this.readObjectFromPath(key)
if (existingNode) {
// Preserve id and vector, update only HNSW graph metadata
const updatedNode = {
...existingNode,
level: hnswData.level,
connections: hnswData.connections
}
await this.writeObjectToPath(key, updatedNode)
} else {
// Node doesn't exist yet, create with just HNSW data
await this.writeObjectToPath(key, hnswData)
}
} catch (error) {
// If read fails, create with just HNSW data
await this.writeObjectToPath(key, hnswData)
}
}
public async getHNSWData(nounId: string): Promise<{
level: number
connections: Record<string, string[]>
} | null> {
await this.ensureInitialized()
const shard = getShardIdFromUuid(nounId)
const key = `entities/nouns/hnsw/${shard}/${nounId}.json`
return await this.readObjectFromPath(key)
}
public async saveHNSWSystem(systemData: {
entryPointId: string | null
maxLevel: number
}): Promise<void> {
await this.ensureInitialized()
const key = `${this.systemPrefix}hnsw-system.json`
await this.writeObjectToPath(key, systemData)
}
public async getHNSWSystem(): Promise<{
entryPointId: string | null
maxLevel: number
} | null> {
await this.ensureInitialized()
const key = `${this.systemPrefix}hnsw-system.json`
return await this.readObjectFromPath(key)
}
// Statistics support
protected async saveStatisticsData(statistics: StatisticsData): Promise<void> {
await this.ensureInitialized()
const key = `${this.systemPrefix}${STATISTICS_KEY}.json`
await this.writeObjectToPath(key, statistics)
}
protected async getStatisticsData(): Promise<StatisticsData | null> {
await this.ensureInitialized()
const key = `${this.systemPrefix}${STATISTICS_KEY}.json`
const stats = await this.readObjectFromPath(key)
if (stats) {
return {
...stats,
totalNodes: this.totalNounCount,
totalEdges: this.totalVerbCount,
lastUpdated: new Date().toISOString()
}
}
return {
nounCount: {},
verbCount: {},
metadataCount: {},
hnswIndexSize: 0,
totalNodes: this.totalNounCount,
totalEdges: this.totalVerbCount,
totalMetadata: 0,
lastUpdated: new Date().toISOString()
}
}
// Utility methods
public async clear(): Promise<void> {
await this.ensureInitialized()
prodLog.info('🧹 R2: Clearing all data from bucket...')
// Clear all prefixes
for (const prefix of [this.nounPrefix, this.verbPrefix, this.metadataPrefix, this.verbMetadataPrefix, this.systemPrefix]) {
const objects = await this.listObjectsUnderPath(prefix)
for (const key of objects) {
await this.deleteObjectFromPath(key)
}
}
this.nounCacheManager.clear()
this.verbCacheManager.clear()
this.totalNounCount = 0
this.totalVerbCount = 0
this.entityCounts.clear()
this.verbCounts.clear()
prodLog.info('✅ R2: All data cleared')
}
public async getStorageStatus(): Promise<{
type: string
used: number
quota: number | null
details?: Record<string, any>
}> {
return {
type: 'r2',
used: 0,
quota: null,
details: {
bucket: this.bucketName,
accountId: this.accountId,
endpoint: this.endpoint,
features: [
'Zero egress fees',
'Global edge network',
'S3-compatible API',
'Type-aware HNSW support'
]
}
}
}
// Pagination support (simplified - full implementation would match GCS pattern)
public async getNounsWithPagination(options: {
limit?: number
cursor?: string
filter?: {
nounType?: string | string[]
service?: string | string[]
metadata?: Record<string, any>
}
} = {}): Promise<{
items: HNSWNounWithMetadata[]
totalCount?: number
hasMore: boolean
nextCursor?: string
}> {
await this.ensureInitialized()
// Simplified pagination - full implementation would be similar to GCS
const limit = options.limit || 100
const { ListObjectsV2Command } = await import('@aws-sdk/client-s3')
const response = await this.s3Client!.send(
new ListObjectsV2Command({
Bucket: this.bucketName,
Prefix: this.nounPrefix,
MaxKeys: limit,
ContinuationToken: options.cursor
})
)
const items: HNSWNounWithMetadata[] = []
const contents = response.Contents || []
for (const obj of contents) {
if (!obj.Key?.endsWith('.json')) continue
const id = obj.Key.split('/').pop()?.replace('.json', '')
if (!id) continue
const noun = await this.getNoun_internal(id)
if (noun) {
// v4.0.0: Load metadata and combine with noun to create HNSWNounWithMetadata
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)
}
}
return {
items,
totalCount: this.totalNounCount,
hasMore: !!response.IsTruncated,
nextCursor: response.NextContinuationToken
}
}
protected async getNounsByNounType_internal(nounType: string): Promise<HNSWNoun[]> {
const result = await this.getNounsWithPagination({
limit: 10000,
filter: { nounType }
})
return result.items
}
protected async getVerbsBySource_internal(sourceId: string): Promise<HNSWVerbWithMetadata[]> {
// Simplified - full implementation would include proper filtering
return []
}
protected async getVerbsByTarget_internal(targetId: string): Promise<HNSWVerbWithMetadata[]> {
return []
}
protected async getVerbsByType_internal(type: string): Promise<HNSWVerbWithMetadata[]> {
return []
}
}