fix: move metadata routing to base class, fix GCS/S3 system key crashes

Critical fix for GCS/S3 native storage adapters crashing on metadata index keys.

Problem:
- GCS and S3 adapters crashed with "Invalid UUID format" errors
- System keys like __metadata_field_index__status are NOT UUIDs
- Adapters incorrectly tried to shard all metadata keys as UUIDs

Solution:
- Move sharding/routing logic from adapters to BaseStorage class
- Add analyzeKey() method to detect system keys vs entity UUIDs
- System keys route to _system/ directory (no sharding)
- Entity UUIDs route to sharded directories (256 shards)
- All adapters now implement 4 primitive operations:
  * writeObjectToPath(path, data)
  * readObjectFromPath(path)
  * deleteObjectFromPath(path)
  * listObjectsUnderPath(prefix)

Benefits:
- Impossible for future adapters to repeat this mistake
- Zero breaking changes, full backward compatibility
- No data migration required
- Cleaner architecture with better separation of concerns

Updated adapters: GcsStorage, S3CompatibleStorage, OPFSStorage,
FileSystemStorage, MemoryStorage

Added: docs/architecture/data-storage-architecture.md
Updated: README.md with architecture docs link
This commit is contained in:
David Snelling 2025-10-09 13:10:06 -07:00
parent 13303c20c2
commit 1966c39f24
9 changed files with 1330 additions and 630 deletions

View file

@ -17,11 +17,22 @@ export class MemoryStorage extends BaseStorage {
// Single map of noun ID to noun
private nouns: Map<string, HNSWNoun> = new Map()
private verbs: Map<string, HNSWVerb> = new Map()
private metadata: Map<string, any> = new Map()
private nounMetadata: Map<string, any> = new Map()
private verbMetadata: Map<string, any> = new Map()
private statistics: StatisticsData | null = null
// Unified object store for primitive operations (replaces metadata, nounMetadata, verbMetadata)
private objectStore: Map<string, any> = new Map()
// Backward compatibility aliases
private get metadata(): Map<string, any> {
return this.objectStore
}
private get nounMetadata(): Map<string, any> {
return this.objectStore
}
private get verbMetadata(): Map<string, any> {
return this.objectStore
}
constructor() {
super()
}
@ -520,22 +531,46 @@ export class MemoryStorage extends BaseStorage {
}
/**
* Save metadata to storage
* Primitive operation: Write object to path
* All metadata operations use this internally via base class routing
*/
public async saveMetadata(id: string, metadata: any): Promise<void> {
this.metadata.set(id, JSON.parse(JSON.stringify(metadata)))
protected async writeObjectToPath(path: string, data: any): Promise<void> {
// Store in unified object store using path as key
this.objectStore.set(path, JSON.parse(JSON.stringify(data)))
}
/**
* Get metadata from storage
* Primitive operation: Read object from path
* All metadata operations use this internally via base class routing
*/
public async getMetadata(id: string): Promise<any | null> {
const metadata = this.metadata.get(id)
if (!metadata) {
protected async readObjectFromPath(path: string): Promise<any | null> {
const data = this.objectStore.get(path)
if (!data) {
return null
}
return JSON.parse(JSON.stringify(data))
}
return JSON.parse(JSON.stringify(metadata))
/**
* Primitive operation: Delete object from path
* All metadata operations use this internally via base class routing
*/
protected async deleteObjectFromPath(path: string): Promise<void> {
this.objectStore.delete(path)
}
/**
* Primitive operation: List objects under path prefix
* All metadata operations use this internally via base class routing
*/
protected async listObjectsUnderPath(prefix: string): Promise<string[]> {
const paths: string[] = []
for (const key of this.objectStore.keys()) {
if (key.startsWith(prefix)) {
paths.push(key)
}
}
return paths.sort()
}
/**
@ -544,75 +579,27 @@ export class MemoryStorage extends BaseStorage {
*/
public async getMetadataBatch(ids: string[]): Promise<Map<string, any>> {
const results = new Map<string, any>()
// Memory storage can handle all IDs at once since it's in-memory
for (const id of ids) {
const metadata = this.metadata.get(id)
const metadata = await this.getMetadata(id)
if (metadata) {
// Deep clone to prevent mutation
results.set(id, JSON.parse(JSON.stringify(metadata)))
results.set(id, metadata)
}
}
return results
}
/**
* Save noun metadata to storage (internal implementation)
*/
protected async saveNounMetadata_internal(id: string, metadata: any): Promise<void> {
this.nounMetadata.set(id, JSON.parse(JSON.stringify(metadata)))
}
/**
* Get noun metadata from storage
*/
public async getNounMetadata(id: string): Promise<any | null> {
const metadata = this.nounMetadata.get(id)
if (!metadata) {
return null
}
return JSON.parse(JSON.stringify(metadata))
}
/**
* Save verb metadata to storage (internal implementation)
*/
protected async saveVerbMetadata_internal(id: string, metadata: any): Promise<void> {
const isNew = !this.verbMetadata.has(id)
this.verbMetadata.set(id, JSON.parse(JSON.stringify(metadata)))
// Update counts for new verbs
if (isNew) {
const type = metadata?.verb || metadata?.type || 'default'
this.incrementVerbCount(type)
}
}
/**
* Get verb metadata from storage
*/
public async getVerbMetadata(id: string): Promise<any | null> {
const metadata = this.verbMetadata.get(id)
if (!metadata) {
return null
}
return JSON.parse(JSON.stringify(metadata))
}
/**
* Clear all data from storage
*/
public async clear(): Promise<void> {
this.nouns.clear()
this.verbs.clear()
this.metadata.clear()
this.nounMetadata.clear()
this.verbMetadata.clear()
this.objectStore.clear()
this.statistics = null
// Clear the statistics cache
this.statisticsCache = null
this.statisticsModified = false
@ -634,7 +621,7 @@ export class MemoryStorage extends BaseStorage {
details: {
nodeCount: this.nouns.size,
edgeCount: this.verbs.size,
metadataCount: this.metadata.size
metadataCount: this.objectStore.size
}
}
}