feat: add entity versioning system with critical bug fixes (v5.3.0)
Entity Versioning (NEW): - Add complete entity versioning API (brain.versions.*) with 18 methods - Content-addressable storage with SHA-256 deduplication - Git-style version control: save, restore, compare, undo, prune - Auto-versioning augmentation with pattern-based filtering - Branch-isolated version histories - Complete integration tests and API documentation Critical Bug Fixes: - Fix commit() not updating branch refs (brainy.ts:2385) - Root cause: Passed "heads/main" which normalized to "refs/heads/heads/main" - Impact: All Git-style versioning features were broken - Fix: Pass branch name directly for correct normalization - Fix VFS entities missing isVFSEntity flag - Add isVFSEntity: true to all VFS files/folders for filtering - Resolves pollution of semantic search with filesystem entities - Updated in writeFile(), mkdir(), and root directory init Implementation: - src/versioning/VersionManager.ts - Core versioning engine - src/versioning/VersionStorage.ts - Content-addressable storage - src/versioning/VersionIndex.ts - Metadata indexing - src/versioning/VersionDiff.ts - Version comparison - src/versioning/VersioningAPI.ts - Public API interface - src/augmentations/versioningAugmentation.ts - Auto-versioning - tests/integration/versioning.test.ts - Full integration tests - tests/unit/versioning/ - Unit test suite Documentation: - Complete Entity Versioning API section in docs/api/README.md - VFS entity filtering guide with examples - Updated "What's New" section for v5.3.0 - Strategy docs for both critical bugs Test Results: - 1168 tests passing - Build: PASSING (no TypeScript errors) - Integration tests: ALL PASSING 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
b31997b1ea
commit
c488fa82cc
16 changed files with 5394 additions and 9 deletions
265
src/versioning/VersionStorage.ts
Normal file
265
src/versioning/VersionStorage.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
/**
|
||||
* VersionStorage - Hybrid Storage for Entity Versions (v5.3.0)
|
||||
*
|
||||
* Implements content-addressable storage for entity versions:
|
||||
* - SHA-256 content hashing for deduplication
|
||||
* - Stores versions in .brainy/versions/ directory
|
||||
* - Integrates with COW commit system
|
||||
* - Space-efficient: Only stores unique content
|
||||
*
|
||||
* Storage structure:
|
||||
* .brainy/versions/
|
||||
* ├── entities/
|
||||
* │ └── {entityId}/
|
||||
* │ └── {contentHash}.json # Entity version data
|
||||
* └── index/
|
||||
* └── {entityId}.json # Version index (managed by VersionIndex)
|
||||
*
|
||||
* NO MOCKS - Production implementation
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto'
|
||||
import { BaseStorage } from '../storage/baseStorage.js'
|
||||
import type { NounMetadata } from '../coreTypes.js'
|
||||
import type { EntityVersion } from './VersionManager.js'
|
||||
|
||||
/**
|
||||
* VersionStorage - Content-addressable version storage
|
||||
*/
|
||||
export class VersionStorage {
|
||||
private brain: any // Brainy instance
|
||||
private initialized: boolean = false
|
||||
|
||||
constructor(brain: any) {
|
||||
this.brain = brain
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize version storage directories
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return
|
||||
|
||||
// Version storage uses the same storage adapter as the main database
|
||||
// Directories are created automatically by the storage adapter
|
||||
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate SHA-256 hash of entity content
|
||||
*
|
||||
* Used for content-addressable storage and deduplication
|
||||
*
|
||||
* @param entity Entity to hash
|
||||
* @returns SHA-256 hash (hex string)
|
||||
*/
|
||||
hashEntity(entity: NounMetadata): string {
|
||||
// Create stable JSON representation (sorted keys)
|
||||
const stableJson = this.toStableJson(entity)
|
||||
return createHash('sha256').update(stableJson).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert entity to stable JSON (sorted keys for consistent hashing)
|
||||
*/
|
||||
private toStableJson(obj: any): string {
|
||||
if (obj === null) return 'null'
|
||||
if (obj === undefined) return 'undefined'
|
||||
if (typeof obj !== 'object') return JSON.stringify(obj)
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
const items = obj.map((item) => this.toStableJson(item))
|
||||
return `[${items.join(',') }]`
|
||||
}
|
||||
|
||||
// Sort object keys for stable hashing
|
||||
const sortedKeys = Object.keys(obj).sort()
|
||||
const pairs = sortedKeys.map((key) => {
|
||||
const value = this.toStableJson(obj[key])
|
||||
return `"${key}":${value}`
|
||||
})
|
||||
|
||||
return `{${pairs.join(',')}}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Save entity version to content-addressable storage
|
||||
*
|
||||
* @param version Version metadata
|
||||
* @param entity Entity data
|
||||
*/
|
||||
async saveVersion(
|
||||
version: EntityVersion,
|
||||
entity: NounMetadata
|
||||
): Promise<void> {
|
||||
await this.initialize()
|
||||
|
||||
// Content-addressable path: .brainy/versions/entities/{entityId}/{contentHash}.json
|
||||
const versionPath = this.getVersionPath(
|
||||
version.entityId,
|
||||
version.contentHash
|
||||
)
|
||||
|
||||
// Check if content already exists (deduplication)
|
||||
const exists = await this.contentExists(versionPath)
|
||||
if (exists) {
|
||||
// Content already stored - no need to write again
|
||||
return
|
||||
}
|
||||
|
||||
// Store entity data
|
||||
await this.writeVersionData(versionPath, entity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load entity version from storage
|
||||
*
|
||||
* @param version Version metadata
|
||||
* @returns Entity data or null if not found
|
||||
*/
|
||||
async loadVersion(version: EntityVersion): Promise<NounMetadata | null> {
|
||||
await this.initialize()
|
||||
|
||||
const versionPath = this.getVersionPath(
|
||||
version.entityId,
|
||||
version.contentHash
|
||||
)
|
||||
|
||||
try {
|
||||
return await this.readVersionData(versionPath)
|
||||
} catch (error) {
|
||||
console.error(`Failed to load version ${version.version}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete entity version from storage
|
||||
*
|
||||
* @param version Version to delete
|
||||
*/
|
||||
async deleteVersion(version: EntityVersion): Promise<void> {
|
||||
await this.initialize()
|
||||
|
||||
const versionPath = this.getVersionPath(
|
||||
version.entityId,
|
||||
version.contentHash
|
||||
)
|
||||
|
||||
await this.deleteVersionData(versionPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version storage path
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param contentHash Content hash
|
||||
* @returns Storage path
|
||||
*/
|
||||
private getVersionPath(entityId: string, contentHash: string): string {
|
||||
return `versions/entities/${entityId}/${contentHash}.json`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content exists in storage
|
||||
*
|
||||
* @param path Storage path
|
||||
* @returns True if exists
|
||||
*/
|
||||
private async contentExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
// Use storage adapter's exists check if available
|
||||
const adapter = this.brain.storageAdapter
|
||||
if (adapter && typeof adapter.exists === 'function') {
|
||||
return await adapter.exists(path)
|
||||
}
|
||||
|
||||
// Fallback: Try to read and catch error
|
||||
await this.readVersionData(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write version data to storage
|
||||
*
|
||||
* @param path Storage path
|
||||
* @param entity Entity data
|
||||
*/
|
||||
private async writeVersionData(
|
||||
path: string,
|
||||
entity: NounMetadata
|
||||
): Promise<void> {
|
||||
const adapter = this.brain.storageAdapter
|
||||
|
||||
if (!adapter) {
|
||||
throw new Error('Storage adapter not available')
|
||||
}
|
||||
|
||||
// Serialize entity data
|
||||
const data = JSON.stringify(entity, null, 2)
|
||||
|
||||
// Write to storage using adapter
|
||||
if (typeof adapter.writeFile === 'function') {
|
||||
await adapter.writeFile(path, data)
|
||||
} else if (typeof adapter.set === 'function') {
|
||||
await adapter.set(path, data)
|
||||
} else {
|
||||
throw new Error('Storage adapter does not support write operations')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read version data from storage
|
||||
*
|
||||
* @param path Storage path
|
||||
* @returns Entity data
|
||||
*/
|
||||
private async readVersionData(path: string): Promise<NounMetadata> {
|
||||
const adapter = this.brain.storageAdapter
|
||||
|
||||
if (!adapter) {
|
||||
throw new Error('Storage adapter not available')
|
||||
}
|
||||
|
||||
// Read from storage using adapter
|
||||
let data: string
|
||||
|
||||
if (typeof adapter.readFile === 'function') {
|
||||
data = await adapter.readFile(path)
|
||||
} else if (typeof adapter.get === 'function') {
|
||||
data = await adapter.get(path)
|
||||
} else {
|
||||
throw new Error('Storage adapter does not support read operations')
|
||||
}
|
||||
|
||||
// Parse entity data
|
||||
return JSON.parse(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete version data from storage
|
||||
*
|
||||
* @param path Storage path
|
||||
*/
|
||||
private async deleteVersionData(path: string): Promise<void> {
|
||||
const adapter = this.brain.storageAdapter
|
||||
|
||||
if (!adapter) {
|
||||
throw new Error('Storage adapter not available')
|
||||
}
|
||||
|
||||
// Delete from storage using adapter
|
||||
if (typeof adapter.deleteFile === 'function') {
|
||||
await adapter.deleteFile(path)
|
||||
} else if (typeof adapter.delete === 'function') {
|
||||
await adapter.delete(path)
|
||||
} else {
|
||||
throw new Error('Storage adapter does not support delete operations')
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue