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>
459 lines
11 KiB
TypeScript
459 lines
11 KiB
TypeScript
/**
|
|
* 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
|
|
}
|
|
}
|