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>
538 lines
13 KiB
TypeScript
538 lines
13 KiB
TypeScript
/**
|
|
* VersionDiff Unit Tests (v5.3.0)
|
|
*
|
|
* Tests deep object comparison:
|
|
* - Added fields
|
|
* - Removed fields
|
|
* - Modified fields
|
|
* - Type changes
|
|
* - Nested object comparison
|
|
* - Array comparison
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest'
|
|
import { compareEntityVersions } from '../../../src/versioning/VersionDiff.js'
|
|
import type { NounMetadata } from '../../../src/coreTypes.js'
|
|
|
|
describe('VersionDiff', () => {
|
|
describe('compareEntityVersions()', () => {
|
|
it('should detect added fields', () => {
|
|
const from: NounMetadata = {
|
|
id: 'user-1',
|
|
type: 'user',
|
|
name: 'Alice',
|
|
metadata: { email: 'alice@example.com' }
|
|
}
|
|
|
|
const to: NounMetadata = {
|
|
id: 'user-1',
|
|
type: 'user',
|
|
name: 'Alice',
|
|
metadata: {
|
|
email: 'alice@example.com',
|
|
city: 'NYC',
|
|
age: 30
|
|
}
|
|
}
|
|
|
|
const diff = compareEntityVersions(from, to, {
|
|
entityId: 'user-1',
|
|
fromVersion: 1,
|
|
toVersion: 2
|
|
})
|
|
|
|
expect(diff.added.length).toBe(2)
|
|
expect(diff.added.some(c => c.path.includes('city'))).toBe(true)
|
|
expect(diff.added.some(c => c.path.includes('age'))).toBe(true)
|
|
expect(diff.added.find(c => c.path.includes('city'))?.newValue).toBe('NYC')
|
|
})
|
|
|
|
it('should detect removed fields', () => {
|
|
const from: NounMetadata = {
|
|
id: 'user-1',
|
|
type: 'user',
|
|
name: 'Alice',
|
|
metadata: {
|
|
email: 'alice@example.com',
|
|
city: 'NYC',
|
|
age: 30
|
|
}
|
|
}
|
|
|
|
const to: NounMetadata = {
|
|
id: 'user-1',
|
|
type: 'user',
|
|
name: 'Alice',
|
|
metadata: { email: 'alice@example.com' }
|
|
}
|
|
|
|
const diff = compareEntityVersions(from, to, {
|
|
entityId: 'user-1',
|
|
fromVersion: 1,
|
|
toVersion: 2
|
|
})
|
|
|
|
expect(diff.removed.length).toBe(2)
|
|
expect(diff.removed.some(c => c.path.includes('city'))).toBe(true)
|
|
expect(diff.removed.some(c => c.path.includes('age'))).toBe(true)
|
|
expect(diff.removed.find(c => c.path.includes('city'))?.oldValue).toBe('NYC')
|
|
})
|
|
|
|
it('should detect modified fields', () => {
|
|
const from: NounMetadata = {
|
|
id: 'user-1',
|
|
type: 'user',
|
|
name: 'Alice',
|
|
metadata: {
|
|
email: 'alice@example.com',
|
|
status: 'active'
|
|
}
|
|
}
|
|
|
|
const to: NounMetadata = {
|
|
id: 'user-1',
|
|
type: 'user',
|
|
name: 'Alice Smith',
|
|
metadata: {
|
|
email: 'alice.smith@example.com',
|
|
status: 'active'
|
|
}
|
|
}
|
|
|
|
const diff = compareEntityVersions(from, to, {
|
|
entityId: 'user-1',
|
|
fromVersion: 1,
|
|
toVersion: 2
|
|
})
|
|
|
|
expect(diff.modified.length).toBe(2)
|
|
|
|
const nameChange = diff.modified.find(c => c.path.includes('name'))
|
|
expect(nameChange?.oldValue).toBe('Alice')
|
|
expect(nameChange?.newValue).toBe('Alice Smith')
|
|
|
|
const emailChange = diff.modified.find(c => c.path.includes('email'))
|
|
expect(emailChange?.oldValue).toBe('alice@example.com')
|
|
expect(emailChange?.newValue).toBe('alice.smith@example.com')
|
|
})
|
|
|
|
it('should detect type changes', () => {
|
|
const from: NounMetadata = {
|
|
id: 'config-1',
|
|
type: 'thing',
|
|
name: 'Config',
|
|
metadata: { value: '100' }
|
|
}
|
|
|
|
const to: NounMetadata = {
|
|
id: 'config-1',
|
|
type: 'thing',
|
|
name: 'Config',
|
|
metadata: { value: 100 }
|
|
}
|
|
|
|
const diff = compareEntityVersions(from, to, {
|
|
entityId: 'config-1',
|
|
fromVersion: 1,
|
|
toVersion: 2
|
|
})
|
|
|
|
expect(diff.typeChanged.length).toBe(1)
|
|
const typeChange = diff.typeChanged[0]
|
|
expect(typeChange.path).toContain('value')
|
|
expect(typeChange.oldType).toBe('string')
|
|
expect(typeChange.newType).toBe('number')
|
|
expect(typeChange.oldValue).toBe('100')
|
|
expect(typeChange.newValue).toBe(100)
|
|
})
|
|
|
|
it('should detect identical versions', () => {
|
|
const entity: NounMetadata = {
|
|
id: 'doc-1',
|
|
type: 'document',
|
|
name: 'Doc',
|
|
metadata: { content: 'Unchanged' }
|
|
}
|
|
|
|
const diff = compareEntityVersions(entity, entity, {
|
|
entityId: 'doc-1',
|
|
fromVersion: 1,
|
|
toVersion: 2
|
|
})
|
|
|
|
expect(diff.identical).toBe(true)
|
|
expect(diff.totalChanges).toBe(0)
|
|
expect(diff.added).toHaveLength(0)
|
|
expect(diff.removed).toHaveLength(0)
|
|
expect(diff.modified).toHaveLength(0)
|
|
})
|
|
|
|
it('should handle nested object changes', () => {
|
|
const from: NounMetadata = {
|
|
id: 'user-1',
|
|
type: 'user',
|
|
name: 'Alice',
|
|
metadata: {
|
|
address: {
|
|
street: '123 Main St',
|
|
city: 'NYC'
|
|
}
|
|
}
|
|
}
|
|
|
|
const to: NounMetadata = {
|
|
id: 'user-1',
|
|
type: 'user',
|
|
name: 'Alice',
|
|
metadata: {
|
|
address: {
|
|
street: '456 Oak Ave',
|
|
city: 'NYC',
|
|
zip: '10001'
|
|
}
|
|
}
|
|
}
|
|
|
|
const diff = compareEntityVersions(from, to, {
|
|
entityId: 'user-1',
|
|
fromVersion: 1,
|
|
toVersion: 2
|
|
})
|
|
|
|
expect(diff.modified.some(c => c.path.includes('address.street'))).toBe(true)
|
|
expect(diff.added.some(c => c.path.includes('address.zip'))).toBe(true)
|
|
|
|
const streetChange = diff.modified.find(c => c.path.includes('address.street'))
|
|
expect(streetChange?.oldValue).toBe('123 Main St')
|
|
expect(streetChange?.newValue).toBe('456 Oak Ave')
|
|
})
|
|
|
|
it('should handle array changes', () => {
|
|
const from: NounMetadata = {
|
|
id: 'doc-1',
|
|
type: 'document',
|
|
name: 'Doc',
|
|
metadata: {
|
|
tags: ['a', 'b', 'c']
|
|
}
|
|
}
|
|
|
|
const to: NounMetadata = {
|
|
id: 'doc-1',
|
|
type: 'document',
|
|
name: 'Doc',
|
|
metadata: {
|
|
tags: ['a', 'b', 'd']
|
|
}
|
|
}
|
|
|
|
const diff = compareEntityVersions(from, to, {
|
|
entityId: 'doc-1',
|
|
fromVersion: 1,
|
|
toVersion: 2
|
|
})
|
|
|
|
// Array element change detected as modification
|
|
expect(diff.totalChanges).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('should handle null and undefined', () => {
|
|
const from: NounMetadata = {
|
|
id: 'test-1',
|
|
type: 'thing',
|
|
name: 'Test',
|
|
metadata: {
|
|
a: null,
|
|
b: undefined,
|
|
c: 'value'
|
|
}
|
|
}
|
|
|
|
const to: NounMetadata = {
|
|
id: 'test-1',
|
|
type: 'thing',
|
|
name: 'Test',
|
|
metadata: {
|
|
a: 'now-value',
|
|
b: null,
|
|
c: 'value'
|
|
}
|
|
}
|
|
|
|
const diff = compareEntityVersions(from, to, {
|
|
entityId: 'test-1',
|
|
fromVersion: 1,
|
|
toVersion: 2
|
|
})
|
|
|
|
expect(diff.totalChanges).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('should ignore specified fields', () => {
|
|
const from: NounMetadata = {
|
|
id: 'user-1',
|
|
type: 'user',
|
|
name: 'Alice',
|
|
metadata: {
|
|
email: 'alice@example.com',
|
|
lastModified: 1000,
|
|
internal: 'ignore-me'
|
|
}
|
|
}
|
|
|
|
const to: NounMetadata = {
|
|
id: 'user-1',
|
|
type: 'user',
|
|
name: 'Alice Smith',
|
|
metadata: {
|
|
email: 'alice@example.com',
|
|
lastModified: 2000,
|
|
internal: 'changed'
|
|
}
|
|
}
|
|
|
|
const diff = compareEntityVersions(from, to, {
|
|
entityId: 'user-1',
|
|
fromVersion: 1,
|
|
toVersion: 2,
|
|
ignoreFields: ['lastModified', 'internal']
|
|
})
|
|
|
|
// Should only detect name change
|
|
expect(diff.totalChanges).toBe(1)
|
|
expect(diff.modified.some(c => c.path.includes('name'))).toBe(true)
|
|
expect(diff.modified.some(c => c.path.includes('lastModified'))).toBe(false)
|
|
expect(diff.modified.some(c => c.path.includes('internal'))).toBe(false)
|
|
})
|
|
|
|
it('should respect maxDepth option', () => {
|
|
const from: NounMetadata = {
|
|
id: 'test-1',
|
|
type: 'thing',
|
|
name: 'Test',
|
|
metadata: {
|
|
level1: {
|
|
level2: {
|
|
level3: {
|
|
level4: 'deep-value'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const to: NounMetadata = {
|
|
id: 'test-1',
|
|
type: 'thing',
|
|
name: 'Test',
|
|
metadata: {
|
|
level1: {
|
|
level2: {
|
|
level3: {
|
|
level4: 'changed-value'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const diff = compareEntityVersions(from, to, {
|
|
entityId: 'test-1',
|
|
fromVersion: 1,
|
|
toVersion: 2,
|
|
maxDepth: 2 // Stop at level2
|
|
})
|
|
|
|
// Should detect change at metadata.level1.level2 level
|
|
expect(diff.totalChanges).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('should handle empty objects', () => {
|
|
const from: NounMetadata = {
|
|
id: 'test-1',
|
|
type: 'thing',
|
|
name: 'Test',
|
|
metadata: {}
|
|
}
|
|
|
|
const to: NounMetadata = {
|
|
id: 'test-1',
|
|
type: 'thing',
|
|
name: 'Test',
|
|
metadata: { value: 123 }
|
|
}
|
|
|
|
const diff = compareEntityVersions(from, to, {
|
|
entityId: 'test-1',
|
|
fromVersion: 1,
|
|
toVersion: 2
|
|
})
|
|
|
|
expect(diff.added.length).toBe(1)
|
|
expect(diff.added[0].newValue).toBe(123)
|
|
})
|
|
|
|
it('should calculate total changes correctly', () => {
|
|
const from: NounMetadata = {
|
|
id: 'user-1',
|
|
type: 'user',
|
|
name: 'Alice',
|
|
metadata: {
|
|
email: 'alice@example.com',
|
|
status: 'active',
|
|
age: 30
|
|
}
|
|
}
|
|
|
|
const to: NounMetadata = {
|
|
id: 'user-1',
|
|
type: 'user',
|
|
name: 'Alice Smith',
|
|
metadata: {
|
|
email: 'alice.smith@example.com',
|
|
city: 'NYC'
|
|
}
|
|
}
|
|
|
|
const diff = compareEntityVersions(from, to, {
|
|
entityId: 'user-1',
|
|
fromVersion: 1,
|
|
toVersion: 2
|
|
})
|
|
|
|
const expectedTotal =
|
|
diff.added.length +
|
|
diff.removed.length +
|
|
diff.modified.length +
|
|
diff.typeChanged.length
|
|
|
|
expect(diff.totalChanges).toBe(expectedTotal)
|
|
})
|
|
|
|
it('should handle complex real-world changes', () => {
|
|
const from: NounMetadata = {
|
|
id: 'project-1',
|
|
type: 'thing',
|
|
name: 'My Project',
|
|
metadata: {
|
|
description: 'Old description',
|
|
status: 'draft',
|
|
team: ['alice', 'bob'],
|
|
config: {
|
|
theme: 'light',
|
|
notifications: true
|
|
},
|
|
createdAt: 1000
|
|
}
|
|
}
|
|
|
|
const to: NounMetadata = {
|
|
id: 'project-1',
|
|
type: 'thing',
|
|
name: 'My Awesome Project',
|
|
metadata: {
|
|
description: 'New description',
|
|
status: 'active',
|
|
team: ['alice', 'bob', 'charlie'],
|
|
config: {
|
|
theme: 'dark',
|
|
notifications: true,
|
|
language: 'en'
|
|
},
|
|
createdAt: 1000,
|
|
updatedAt: 2000
|
|
}
|
|
}
|
|
|
|
const diff = compareEntityVersions(from, to, {
|
|
entityId: 'project-1',
|
|
fromVersion: 1,
|
|
toVersion: 2
|
|
})
|
|
|
|
expect(diff.totalChanges).toBeGreaterThan(0)
|
|
expect(diff.modified.length).toBeGreaterThan(0)
|
|
expect(diff.added.length).toBeGreaterThan(0)
|
|
|
|
const nameChange = diff.modified.find(c => c.path.includes('name'))
|
|
expect(nameChange?.newValue).toBe('My Awesome Project')
|
|
})
|
|
|
|
it('should handle boolean changes', () => {
|
|
const from: NounMetadata = {
|
|
id: 'feature-1',
|
|
type: 'thing',
|
|
name: 'Feature',
|
|
metadata: { enabled: false }
|
|
}
|
|
|
|
const to: NounMetadata = {
|
|
id: 'feature-1',
|
|
type: 'thing',
|
|
name: 'Feature',
|
|
metadata: { enabled: true }
|
|
}
|
|
|
|
const diff = compareEntityVersions(from, to, {
|
|
entityId: 'feature-1',
|
|
fromVersion: 1,
|
|
toVersion: 2
|
|
})
|
|
|
|
expect(diff.modified.length).toBe(1)
|
|
expect(diff.modified[0].oldValue).toBe(false)
|
|
expect(diff.modified[0].newValue).toBe(true)
|
|
})
|
|
|
|
it('should handle number changes', () => {
|
|
const from: NounMetadata = {
|
|
id: 'counter-1',
|
|
type: 'thing',
|
|
name: 'Counter',
|
|
metadata: { count: 10 }
|
|
}
|
|
|
|
const to: NounMetadata = {
|
|
id: 'counter-1',
|
|
type: 'thing',
|
|
name: 'Counter',
|
|
metadata: { count: 20 }
|
|
}
|
|
|
|
const diff = compareEntityVersions(from, to, {
|
|
entityId: 'counter-1',
|
|
fromVersion: 1,
|
|
toVersion: 2
|
|
})
|
|
|
|
expect(diff.modified.length).toBe(1)
|
|
expect(diff.modified[0].oldValue).toBe(10)
|
|
expect(diff.modified[0].newValue).toBe(20)
|
|
})
|
|
|
|
it('should handle date/timestamp changes', () => {
|
|
const from: NounMetadata = {
|
|
id: 'event-1',
|
|
type: 'thing',
|
|
name: 'Event',
|
|
metadata: { timestamp: 1000 }
|
|
}
|
|
|
|
const to: NounMetadata = {
|
|
id: 'event-1',
|
|
type: 'thing',
|
|
name: 'Event',
|
|
metadata: { timestamp: 2000 }
|
|
}
|
|
|
|
const diff = compareEntityVersions(from, to, {
|
|
entityId: 'event-1',
|
|
fromVersion: 1,
|
|
toVersion: 2
|
|
})
|
|
|
|
expect(diff.modified.length).toBe(1)
|
|
expect(diff.modified[0].path).toContain('timestamp')
|
|
})
|
|
})
|
|
})
|