fix(versioning): VFS file versions now capture actual blob content

VFS files store content in BlobStorage, but versioning was capturing
stale embedding text from entity.data instead of actual file content.

Changes:
- VersionManager.save() now reads fresh content via vfs.readFile()
- VersionManager.restore() writes content back via vfs.writeFile()
- Text files stored as UTF-8, binary as base64 with encoding flag
- Added comprehensive VFS versioning test suite (10 tests)
- Updated API docs with VFS file versioning example

Fixes: Workshop team bug report where all VFS file versions had
identical data despite different metadata.size values.
This commit is contained in:
David Snelling 2025-12-09 16:13:45 -08:00
parent f3765afb9e
commit 3e0f235f8b
3 changed files with 429 additions and 1 deletions

View file

@ -3,7 +3,7 @@
> **Complete API documentation for Brainy v5.0+**
> Zero Configuration • Triple Intelligence • Git-Style Branching • Entity Versioning
**Updated:** 2025-11-06 for v5.5.0
**Updated:** 2025-12-09 for v6.3.2
**All APIs verified against actual code**
---
@ -515,6 +515,7 @@ Entity Versioning provides time-travel and history tracking for individual entit
- **Branch-Isolated** - Versions isolated per branch
- **Selective Auto-Versioning** - Optional augmentation for automatic version creation
- **Production-Scale** - Designed for billions of entities
- **VFS File Support (v6.3.2+)** - Full versioning for VFS files with actual blob content
---
@ -881,6 +882,37 @@ versions.forEach(v => {
})
```
#### VFS File Versioning (v6.3.2+)
```typescript
// VFS files can be versioned with actual blob content
await brain.vfs.writeFile('/docs/readme.md', 'Version 1 content')
// Get the file's entity ID
const stat = await brain.vfs.stat('/docs/readme.md')
// Save version 1
await brain.versions.save(stat.entityId, { tag: 'v1', description: 'Initial draft' })
// Modify the file
await brain.vfs.writeFile('/docs/readme.md', 'Version 2 - updated content')
// Save version 2
await brain.versions.save(stat.entityId, { tag: 'v2', description: 'Updated docs' })
// Compare versions - content is DIFFERENT (fixed in v6.3.2)
const v1 = await brain.versions.getContent(stat.entityId, 1)
const v2 = await brain.versions.getContent(stat.entityId, 2)
console.log(v1.data !== v2.data) // true
// Restore to v1 - writes content back to blob storage
await brain.versions.restore(stat.entityId, 'v1')
// File is now back to v1
const content = await brain.vfs.readFile('/docs/readme.md')
console.log(content.toString()) // 'Version 1 content'
```
---
**[📖 Complete Versioning Guide →](../features/entity-versioning.md)**

View file

@ -139,6 +139,39 @@ export class VersionManager {
this.versionIndex = new VersionIndex(brain)
}
/**
* Check if an entity is a VFS file
* VFS files store content in BlobStorage, not in entity.data
*
* @param entity Entity metadata object
* @returns True if entity is a VFS file
*/
private isVFSFile(entity: any): boolean {
return (
entity?.isVFS === true &&
entity?.vfsType === 'file' &&
typeof entity?.path === 'string'
)
}
/**
* Check if content is text-based for encoding decisions
* @param mimeType MIME type of the content
* @returns True if content should be stored as UTF-8 string
*/
private isTextContent(mimeType?: string): boolean {
if (!mimeType) return false
return (
mimeType.startsWith('text/') ||
mimeType === 'application/json' ||
mimeType === 'application/javascript' ||
mimeType === 'application/typescript' ||
mimeType === 'application/xml' ||
mimeType.includes('+xml') ||
mimeType.includes('+json')
)
}
/**
* Initialize versioning system (lazy)
*/
@ -173,6 +206,31 @@ export class VersionManager {
throw new Error(`Entity ${entityId} not found`)
}
// v6.3.2 FIX: For VFS file entities, fetch current content from blob storage
// The entity.data field contains stale embedding text, not actual file content
// VFS files store their real content in BlobStorage (content-addressable)
if (this.isVFSFile(entity)) {
if (!this.brain.vfs) {
throw new Error(
`Cannot version VFS file ${entityId}: VFS not initialized. ` +
`Ensure brain.vfs is available before versioning VFS files.`
)
}
// Read fresh content from blob storage via VFS
const freshContent: Buffer = await this.brain.vfs.readFile(entity.path)
// Store content with appropriate encoding
// Text files as UTF-8 string (readable, smaller)
// Binary files as base64 (safe for JSON serialization)
if (this.isTextContent(entity.mimeType)) {
entity.data = freshContent.toString('utf-8')
} else {
entity.data = freshContent.toString('base64')
entity._vfsEncoding = 'base64' // Flag for restore to decode
}
}
// Get current branch
const currentBranch = this.brain.currentBranch
@ -358,6 +416,37 @@ export class VersionManager {
)
}
// v6.3.2 FIX: For VFS file entities, write content back to blob storage
// The versioned data contains the actual file content (not stale embedding text)
// Using vfs.writeFile() ensures proper blob creation and metadata update
if (this.isVFSFile(versionedEntity)) {
if (!this.brain.vfs) {
throw new Error(
`Cannot restore VFS file ${entityId}: VFS not initialized. ` +
`Ensure brain.vfs is available before restoring VFS files.`
)
}
// Decode content based on how it was stored
let content: Buffer
if (versionedEntity._vfsEncoding === 'base64') {
// Binary file stored as base64
content = Buffer.from(versionedEntity.data as string, 'base64')
} else {
// Text file stored as UTF-8 string
content = Buffer.from(versionedEntity.data as string, 'utf-8')
}
// Write content back to VFS - this handles:
// - BlobStorage write (new hash)
// - Entity metadata update
// - Path resolver cache update
await this.brain.vfs.writeFile(versionedEntity.path, content)
return targetVersion
}
// For non-VFS entities, use existing brain.update() logic
// Extract standard fields vs custom metadata
// NounMetadata has: noun, data, createdAt, updatedAt, createdBy, service, confidence, weight
const {

View file

@ -0,0 +1,307 @@
/**
* VFS File Versioning Integration Tests (v6.3.2)
*
* Tests the fix for the VFS versioning bug where file content was stale.
*
* Bug: VFS files store content in BlobStorage, but versioning captured stale
* embedding text from entity.data instead of actual file content.
*
* Fix: VersionManager.save() now reads fresh content from BlobStorage for VFS files,
* and restore() writes content back to BlobStorage.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { Brainy } from '../../src/brainy.js'
import * as fs from 'fs'
import * as path from 'path'
describe('VFS File Versioning (v6.3.2 Fix)', () => {
const testDir = path.join(process.cwd(), 'test-vfs-versioning-fix')
let brain: Brainy
beforeAll(async () => {
// Clean up from any previous run
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true })
}
fs.mkdirSync(testDir, { recursive: true })
brain = new Brainy({
storage: {
type: 'filesystem',
options: { path: testDir }
},
silent: true
})
await brain.init()
})
afterAll(() => {
// Clean up test directory
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true })
}
})
describe('Core VFS Versioning', () => {
it('should version actual file content, not stale embedding text', async () => {
// Create a text file
await brain.vfs.writeFile('/test/file.md', 'Initial content - version 1')
// Get the file entity
const stat = await brain.vfs.stat('/test/file.md')
const entityId = stat.entityId
// Save version 1
await brain.versions.save(entityId, { description: 'Version 1' })
// Modify the file with different content
await brain.vfs.writeFile('/test/file.md', 'Modified content - version 2 with more text')
// Save version 2
await brain.versions.save(entityId, { description: 'Version 2' })
// Retrieve both versions
const v1Content = await brain.versions.getContent(entityId, 1)
const v2Content = await brain.versions.getContent(entityId, 2)
// CRITICAL TEST: The data should be DIFFERENT between versions
// This was the bug - both had the same stale content
expect(v1Content.data).not.toBe(v2Content.data)
// Verify actual content
expect(v1Content.data).toBe('Initial content - version 1')
expect(v2Content.data).toBe('Modified content - version 2 with more text')
})
it('should restore VFS file content correctly', async () => {
// Create initial file
await brain.vfs.writeFile('/docs/readme.md', 'README version 1')
const stat = await brain.vfs.stat('/docs/readme.md')
const entityId = stat.entityId
// Save version 1
await brain.versions.save(entityId, { tag: 'v1' })
// Modify file
await brain.vfs.writeFile('/docs/readme.md', 'README version 2 - updated')
// Save version 2
await brain.versions.save(entityId, { tag: 'v2' })
// Verify current content is v2
let currentContent = await brain.vfs.readFile('/docs/readme.md')
expect(currentContent.toString()).toBe('README version 2 - updated')
// Restore to v1
await brain.versions.restore(entityId, 1)
// Verify content is now v1
currentContent = await brain.vfs.readFile('/docs/readme.md')
expect(currentContent.toString()).toBe('README version 1')
})
it('should handle multiple file edits and versions', async () => {
await brain.vfs.writeFile('/project/config.json', '{"version": 1}')
const stat = await brain.vfs.stat('/project/config.json')
const entityId = stat.entityId
// Create 5 versions
for (let i = 1; i <= 5; i++) {
await brain.vfs.writeFile('/project/config.json', `{"version": ${i}}`)
await brain.versions.save(entityId, { tag: `v${i}` })
}
// Verify all 5 versions exist
const versions = await brain.versions.list(entityId)
expect(versions).toHaveLength(5)
// Verify each version has correct content
for (let i = 1; i <= 5; i++) {
const content = await brain.versions.getContent(entityId, i)
expect(content.data).toBe(`{"version": ${i}}`)
}
})
it('should restore to any version in history', async () => {
await brain.vfs.writeFile('/notes/todo.txt', 'Task 1')
const stat = await brain.vfs.stat('/notes/todo.txt')
const entityId = stat.entityId
await brain.versions.save(entityId, { tag: 'initial' })
await brain.vfs.writeFile('/notes/todo.txt', 'Task 1\nTask 2')
await brain.versions.save(entityId, { tag: 'two-tasks' })
await brain.vfs.writeFile('/notes/todo.txt', 'Task 1\nTask 2\nTask 3')
await brain.versions.save(entityId, { tag: 'three-tasks' })
// Restore to middle version
await brain.versions.restore(entityId, 'two-tasks')
let content = await brain.vfs.readFile('/notes/todo.txt')
expect(content.toString()).toBe('Task 1\nTask 2')
// Restore to initial version
await brain.versions.restore(entityId, 'initial')
content = await brain.vfs.readFile('/notes/todo.txt')
expect(content.toString()).toBe('Task 1')
// Restore to latest version
await brain.versions.restore(entityId, 3)
content = await brain.vfs.readFile('/notes/todo.txt')
expect(content.toString()).toBe('Task 1\nTask 2\nTask 3')
})
})
describe('Binary File Versioning', () => {
it('should version binary files with base64 encoding', async () => {
// Create a simple binary file (simulated image header)
const binaryContent1 = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x01])
const binaryContent2 = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x02])
await brain.vfs.writeFile('/images/test.png', binaryContent1)
const stat = await brain.vfs.stat('/images/test.png')
const entityId = stat.entityId
// Save version 1
await brain.versions.save(entityId, { tag: 'v1' })
// Modify binary content
await brain.vfs.writeFile('/images/test.png', binaryContent2)
// Save version 2
await brain.versions.save(entityId, { tag: 'v2' })
// Retrieve and verify both versions are different
const v1Content = await brain.versions.getContent(entityId, 1)
const v2Content = await brain.versions.getContent(entityId, 2)
expect(v1Content.data).not.toBe(v2Content.data)
// Verify binary content can be restored
await brain.versions.restore(entityId, 1)
const restored = await brain.vfs.readFile('/images/test.png')
expect(Buffer.compare(restored, binaryContent1)).toBe(0)
})
})
describe('Version Deduplication', () => {
it('should deduplicate identical VFS file content', async () => {
await brain.vfs.writeFile('/cache/data.txt', 'Cached data')
const stat = await brain.vfs.stat('/cache/data.txt')
const entityId = stat.entityId
// Save version 1
const v1 = await brain.versions.save(entityId, { tag: 'v1' })
// Save again without changes - should dedupe
const v2 = await brain.versions.save(entityId, { tag: 'v2' })
// Should return same version (content hash match)
expect(v2.version).toBe(v1.version)
expect(v2.contentHash).toBe(v1.contentHash)
// Only 1 version should exist
const versions = await brain.versions.list(entityId)
expect(versions).toHaveLength(1)
})
})
describe('Mixed Entity Versioning', () => {
it('should handle VFS and non-VFS entities in same brain', async () => {
// Create a VFS file
await brain.vfs.writeFile('/files/doc.txt', 'File content v1')
const fileStat = await brain.vfs.stat('/files/doc.txt')
const fileId = fileStat.entityId
// Create a regular entity
const regularId = await brain.add({
data: 'Regular entity',
type: 'document',
metadata: { title: 'Regular Doc' }
})
// Version both
await brain.versions.save(fileId, { tag: 'file-v1' })
await brain.versions.save(regularId, { tag: 'regular-v1' })
// Update both
await brain.vfs.writeFile('/files/doc.txt', 'File content v2')
await brain.update({ id: regularId, metadata: { title: 'Updated Regular Doc' } })
// Version both again
await brain.versions.save(fileId, { tag: 'file-v2' })
await brain.versions.save(regularId, { tag: 'regular-v2' })
// Verify VFS file versions have different content
const fileV1 = await brain.versions.getContent(fileId, 1)
const fileV2 = await brain.versions.getContent(fileId, 2)
expect(fileV1.data).toBe('File content v1')
expect(fileV2.data).toBe('File content v2')
// Verify regular entity versions work too
const regV1 = await brain.versions.getContent(regularId, 1)
const regV2 = await brain.versions.getContent(regularId, 2)
expect(regV1.title).toBe('Regular Doc')
expect(regV2.title).toBe('Updated Regular Doc')
})
})
describe('Edge Cases', () => {
it('should handle minimal content files', async () => {
// VFS requires non-empty data for embedding, so we use minimal content
await brain.vfs.writeFile('/minimal/file.txt', ' ') // Single space
const stat = await brain.vfs.stat('/minimal/file.txt')
const entityId = stat.entityId
await brain.versions.save(entityId, { tag: 'minimal' })
await brain.vfs.writeFile('/minimal/file.txt', 'Now has content')
await brain.versions.save(entityId, { tag: 'filled' })
// Restore to minimal
await brain.versions.restore(entityId, 'minimal')
const content = await brain.vfs.readFile('/minimal/file.txt')
expect(content.toString()).toBe(' ')
})
it('should handle large text files', async () => {
const largeContent1 = 'A'.repeat(100000) + ' version 1'
const largeContent2 = 'B'.repeat(100000) + ' version 2'
await brain.vfs.writeFile('/large/file.txt', largeContent1)
const stat = await brain.vfs.stat('/large/file.txt')
const entityId = stat.entityId
await brain.versions.save(entityId, { tag: 'v1' })
await brain.vfs.writeFile('/large/file.txt', largeContent2)
await brain.versions.save(entityId, { tag: 'v2' })
// Verify versions are different
const v1 = await brain.versions.getContent(entityId, 1)
const v2 = await brain.versions.getContent(entityId, 2)
expect(v1.data).toBe(largeContent1)
expect(v2.data).toBe(largeContent2)
})
it('should handle special characters in content', async () => {
const specialContent = 'Unicode: \u{1F600} \u{1F914} \u{1F4A1}\nNewlines\nAnd\ttabs'
await brain.vfs.writeFile('/special.txt', specialContent)
const stat = await brain.vfs.stat('/special.txt')
const entityId = stat.entityId
await brain.versions.save(entityId, { tag: 'special' })
await brain.vfs.writeFile('/special.txt', 'Plain text now')
await brain.versions.save(entityId, { tag: 'plain' })
// Restore special content
await brain.versions.restore(entityId, 'special')
const content = await brain.vfs.readFile('/special.txt')
expect(content.toString()).toBe(specialContent)
})
})
})