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
459
src/versioning/VersionDiff.ts
Normal file
459
src/versioning/VersionDiff.ts
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
/**
|
||||
* VersionDiff - Deep Object Comparison for Entity Versions (v5.3.0)
|
||||
*
|
||||
* Provides deep diff between entity versions:
|
||||
* - Field-level change detection
|
||||
* - Nested object comparison
|
||||
* - Array diffing
|
||||
* - Type change detection
|
||||
* - Human-readable diff output
|
||||
*
|
||||
* NO MOCKS - Production implementation
|
||||
*/
|
||||
|
||||
import type { NounMetadata } from '../coreTypes.js'
|
||||
|
||||
/**
|
||||
* Types of changes in a diff
|
||||
*/
|
||||
export type ChangeType = 'added' | 'removed' | 'modified' | 'type-changed'
|
||||
|
||||
/**
|
||||
* A single field change in a diff
|
||||
*/
|
||||
export interface FieldChange {
|
||||
/** Path to the field (e.g., 'metadata.user.name') */
|
||||
path: string
|
||||
|
||||
/** Type of change */
|
||||
type: ChangeType
|
||||
|
||||
/** Old value (undefined for 'added') */
|
||||
oldValue?: any
|
||||
|
||||
/** New value (undefined for 'removed') */
|
||||
newValue?: any
|
||||
|
||||
/** Old type (for 'type-changed') */
|
||||
oldType?: string
|
||||
|
||||
/** New type (for 'type-changed') */
|
||||
newType?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete diff between two versions
|
||||
*/
|
||||
export interface VersionDiff {
|
||||
/** Entity ID being compared */
|
||||
entityId: string
|
||||
|
||||
/** From version number */
|
||||
fromVersion: number
|
||||
|
||||
/** To version number */
|
||||
toVersion: number
|
||||
|
||||
/** Fields that were added */
|
||||
added: FieldChange[]
|
||||
|
||||
/** Fields that were removed */
|
||||
removed: FieldChange[]
|
||||
|
||||
/** Fields that were modified */
|
||||
modified: FieldChange[]
|
||||
|
||||
/** Fields whose type changed */
|
||||
typeChanged: FieldChange[]
|
||||
|
||||
/** Total number of changes */
|
||||
totalChanges: number
|
||||
|
||||
/** Whether versions are identical */
|
||||
identical: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for diff comparison
|
||||
*/
|
||||
export interface DiffOptions {
|
||||
/** Entity ID (for context in output) */
|
||||
entityId: string
|
||||
|
||||
/** From version number */
|
||||
fromVersion: number
|
||||
|
||||
/** To version number */
|
||||
toVersion: number
|
||||
|
||||
/** Ignore these fields in comparison */
|
||||
ignoreFields?: string[]
|
||||
|
||||
/** Maximum depth for nested object comparison (default: 10) */
|
||||
maxDepth?: number
|
||||
|
||||
/** Include unchanged fields in output (default: false) */
|
||||
includeUnchanged?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two entity versions and generate diff
|
||||
*
|
||||
* @param from Old version entity
|
||||
* @param to New version entity
|
||||
* @param options Diff options
|
||||
* @returns Diff between versions
|
||||
*/
|
||||
export function compareEntityVersions(
|
||||
from: NounMetadata,
|
||||
to: NounMetadata,
|
||||
options: DiffOptions
|
||||
): VersionDiff {
|
||||
const added: FieldChange[] = []
|
||||
const removed: FieldChange[] = []
|
||||
const modified: FieldChange[] = []
|
||||
const typeChanged: FieldChange[] = []
|
||||
|
||||
const ignoreFields = new Set(options.ignoreFields || [])
|
||||
const maxDepth = options.maxDepth ?? 10
|
||||
|
||||
// Compare objects recursively
|
||||
compareObjects(from, to, '', added, removed, modified, typeChanged, ignoreFields, 0, maxDepth)
|
||||
|
||||
const totalChanges = added.length + removed.length + modified.length + typeChanged.length
|
||||
const identical = totalChanges === 0
|
||||
|
||||
return {
|
||||
entityId: options.entityId,
|
||||
fromVersion: options.fromVersion,
|
||||
toVersion: options.toVersion,
|
||||
added,
|
||||
removed,
|
||||
modified,
|
||||
typeChanged,
|
||||
totalChanges,
|
||||
identical
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively compare two objects
|
||||
*/
|
||||
function compareObjects(
|
||||
from: any,
|
||||
to: any,
|
||||
path: string,
|
||||
added: FieldChange[],
|
||||
removed: FieldChange[],
|
||||
modified: FieldChange[],
|
||||
typeChanged: FieldChange[],
|
||||
ignoreFields: Set<string>,
|
||||
depth: number,
|
||||
maxDepth: number
|
||||
): void {
|
||||
if (depth >= maxDepth) {
|
||||
// Hit max depth - treat as single value
|
||||
if (!deepEqual(from, to)) {
|
||||
modified.push({
|
||||
path,
|
||||
type: 'modified',
|
||||
oldValue: from,
|
||||
newValue: to
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Get all keys from both objects
|
||||
const fromKeys = new Set(Object.keys(from || {}))
|
||||
const toKeys = new Set(Object.keys(to || {}))
|
||||
const allKeys = new Set([...fromKeys, ...toKeys])
|
||||
|
||||
for (const key of allKeys) {
|
||||
const fieldPath = path ? `${path}.${key}` : key
|
||||
|
||||
// Skip ignored fields
|
||||
if (ignoreFields.has(fieldPath) || ignoreFields.has(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const fromHas = fromKeys.has(key)
|
||||
const toHas = toKeys.has(key)
|
||||
|
||||
if (!fromHas && toHas) {
|
||||
// Field added
|
||||
added.push({
|
||||
path: fieldPath,
|
||||
type: 'added',
|
||||
newValue: to[key]
|
||||
})
|
||||
} else if (fromHas && !toHas) {
|
||||
// Field removed
|
||||
removed.push({
|
||||
path: fieldPath,
|
||||
type: 'removed',
|
||||
oldValue: from[key]
|
||||
})
|
||||
} else {
|
||||
// Field exists in both - check for changes
|
||||
const fromValue = from[key]
|
||||
const toValue = to[key]
|
||||
|
||||
const fromType = getValueType(fromValue)
|
||||
const toType = getValueType(toValue)
|
||||
|
||||
if (fromType !== toType) {
|
||||
// Type changed
|
||||
typeChanged.push({
|
||||
path: fieldPath,
|
||||
type: 'type-changed',
|
||||
oldValue: fromValue,
|
||||
newValue: toValue,
|
||||
oldType: fromType,
|
||||
newType: toType
|
||||
})
|
||||
} else if (fromType === 'object' && toType === 'object') {
|
||||
// Recursively compare nested objects
|
||||
compareObjects(
|
||||
fromValue,
|
||||
toValue,
|
||||
fieldPath,
|
||||
added,
|
||||
removed,
|
||||
modified,
|
||||
typeChanged,
|
||||
ignoreFields,
|
||||
depth + 1,
|
||||
maxDepth
|
||||
)
|
||||
} else if (fromType === 'array' && toType === 'array') {
|
||||
// Compare arrays
|
||||
if (!arraysEqual(fromValue, toValue)) {
|
||||
modified.push({
|
||||
path: fieldPath,
|
||||
type: 'modified',
|
||||
oldValue: fromValue,
|
||||
newValue: toValue
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Primitive value comparison
|
||||
if (!deepEqual(fromValue, toValue)) {
|
||||
modified.push({
|
||||
path: fieldPath,
|
||||
type: 'modified',
|
||||
oldValue: fromValue,
|
||||
newValue: toValue
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable type of a value
|
||||
*/
|
||||
function getValueType(value: any): string {
|
||||
if (value === null) return 'null'
|
||||
if (value === undefined) return 'undefined'
|
||||
if (Array.isArray(value)) return 'array'
|
||||
return typeof value
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep equality check
|
||||
*/
|
||||
function deepEqual(a: any, b: any): boolean {
|
||||
if (a === b) return true
|
||||
if (a === null || b === null) return false
|
||||
if (a === undefined || b === undefined) return false
|
||||
|
||||
const typeA = getValueType(a)
|
||||
const typeB = getValueType(b)
|
||||
|
||||
if (typeA !== typeB) return false
|
||||
|
||||
if (typeA === 'array') {
|
||||
return arraysEqual(a, b)
|
||||
}
|
||||
|
||||
if (typeA === 'object') {
|
||||
return objectsEqual(a, b)
|
||||
}
|
||||
|
||||
// Primitive comparison
|
||||
return a === b
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare arrays for equality
|
||||
*/
|
||||
function arraysEqual(a: any[], b: any[]): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (!deepEqual(a[i], b[i])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare objects for equality
|
||||
*/
|
||||
function objectsEqual(a: any, b: any): boolean {
|
||||
const keysA = Object.keys(a)
|
||||
const keysB = Object.keys(b)
|
||||
|
||||
if (keysA.length !== keysB.length) return false
|
||||
|
||||
for (const key of keysA) {
|
||||
if (!keysB.includes(key)) return false
|
||||
if (!deepEqual(a[key], b[key])) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Format diff as human-readable string
|
||||
*
|
||||
* @param diff Diff to format
|
||||
* @returns Formatted string
|
||||
*/
|
||||
export function formatDiff(diff: VersionDiff): string {
|
||||
const lines: string[] = []
|
||||
|
||||
lines.push(`Diff: ${diff.entityId} v${diff.fromVersion} → v${diff.toVersion}`)
|
||||
lines.push('')
|
||||
|
||||
if (diff.identical) {
|
||||
lines.push('No changes')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
lines.push(`Total changes: ${diff.totalChanges}`)
|
||||
lines.push('')
|
||||
|
||||
if (diff.added.length > 0) {
|
||||
lines.push(`Added (${diff.added.length}):`)
|
||||
for (const change of diff.added) {
|
||||
lines.push(` + ${change.path}: ${formatValue(change.newValue)}`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (diff.removed.length > 0) {
|
||||
lines.push(`Removed (${diff.removed.length}):`)
|
||||
for (const change of diff.removed) {
|
||||
lines.push(` - ${change.path}: ${formatValue(change.oldValue)}`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (diff.modified.length > 0) {
|
||||
lines.push(`Modified (${diff.modified.length}):`)
|
||||
for (const change of diff.modified) {
|
||||
lines.push(` ~ ${change.path}:`)
|
||||
lines.push(` ${formatValue(change.oldValue)}`)
|
||||
lines.push(` → ${formatValue(change.newValue)}`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (diff.typeChanged.length > 0) {
|
||||
lines.push(`Type Changed (${diff.typeChanged.length}):`)
|
||||
for (const change of diff.typeChanged) {
|
||||
lines.push(` ! ${change.path}: ${change.oldType} → ${change.newType}`)
|
||||
lines.push(` ${formatValue(change.oldValue)}`)
|
||||
lines.push(` → ${formatValue(change.newValue)}`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format value for display
|
||||
*/
|
||||
function formatValue(value: any): string {
|
||||
if (value === null) return 'null'
|
||||
if (value === undefined) return 'undefined'
|
||||
if (typeof value === 'string') return `"${value}"`
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return '[Object]'
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary statistics about a diff
|
||||
*/
|
||||
export function getDiffStats(diff: VersionDiff): {
|
||||
changedFields: number
|
||||
addedFields: number
|
||||
removedFields: number
|
||||
modifiedFields: number
|
||||
typeChangedFields: number
|
||||
} {
|
||||
return {
|
||||
changedFields: diff.totalChanges,
|
||||
addedFields: diff.added.length,
|
||||
removedFields: diff.removed.length,
|
||||
modifiedFields: diff.modified.length,
|
||||
typeChangedFields: diff.typeChanged.length
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if diff has any changes
|
||||
*/
|
||||
export function hasChanges(diff: VersionDiff): boolean {
|
||||
return !diff.identical
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all changed field paths
|
||||
*/
|
||||
export function getChangedPaths(diff: VersionDiff): string[] {
|
||||
const paths = new Set<string>()
|
||||
|
||||
for (const change of diff.added) paths.add(change.path)
|
||||
for (const change of diff.removed) paths.add(change.path)
|
||||
for (const change of diff.modified) paths.add(change.path)
|
||||
for (const change of diff.typeChanged) paths.add(change.path)
|
||||
|
||||
return Array.from(paths).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter diff to only include specific paths
|
||||
*/
|
||||
export function filterDiff(diff: VersionDiff, paths: string[]): VersionDiff {
|
||||
const pathSet = new Set(paths)
|
||||
|
||||
const filterChanges = (changes: FieldChange[]) =>
|
||||
changes.filter((c) => pathSet.has(c.path) || paths.some((p) => c.path.startsWith(p + '.')))
|
||||
|
||||
const added = filterChanges(diff.added)
|
||||
const removed = filterChanges(diff.removed)
|
||||
const modified = filterChanges(diff.modified)
|
||||
const typeChanged = filterChanges(diff.typeChanged)
|
||||
|
||||
return {
|
||||
...diff,
|
||||
added,
|
||||
removed,
|
||||
modified,
|
||||
typeChanged,
|
||||
totalChanges: added.length + removed.length + modified.length + typeChanged.length,
|
||||
identical: added.length + removed.length + modified.length + typeChanged.length === 0
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue