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:
David Snelling 2025-11-04 11:19:02 -08:00
parent b31997b1ea
commit c488fa82cc
16 changed files with 5394 additions and 9 deletions

View file

@ -0,0 +1,525 @@
/**
* Versioning Augmentation (v5.3.0)
*
* Provides automatic entity versioning with configurable policies:
* - Auto-save on update()
* - Entity filtering
* - Retention policies
* - Event hooks
*
* NO MOCKS - Production implementation
*/
import { BaseAugmentation } from './brainyAugmentation.js'
import { AugmentationManifest } from './manifest.js'
import type { VersioningAPI } from '../versioning/VersioningAPI.js'
import type { SaveVersionOptions } from '../versioning/VersionManager.js'
export interface VersioningAugmentationConfig {
/** Enable auto-versioning */
enabled?: boolean
/** Auto-save on update() operations */
onUpdate?: boolean
/** Auto-save on add() operations (rarely needed) */
onAdd?: boolean
/** Entity ID patterns to version (glob patterns) */
entities?: string[]
/** Entity ID patterns to exclude (glob patterns) */
excludeEntities?: string[]
/** Entity types to version */
types?: string[]
/** Entity types to exclude */
excludeTypes?: string[]
/** Retention policy: Keep N recent versions per entity */
keepRecent?: number
/** Retention policy: Keep versions newer than timestamp */
keepAfter?: number
/** Retention policy: Keep tagged versions (default: true) */
keepTagged?: boolean
/** Tag prefix for auto-generated versions (default: 'auto-') */
tagPrefix?: string
/** Auto-prune old versions after save */
autoPrune?: boolean
/** Prune interval in milliseconds (default: 1 hour) */
pruneInterval?: number
/** Event hooks */
hooks?: {
/** Called before saving version */
beforeSave?: (entityId: string, options: SaveVersionOptions) => Promise<SaveVersionOptions | null>
/** Called after saving version */
afterSave?: (entityId: string, version: any) => Promise<void>
/** Called before pruning */
beforePrune?: (entityId: string) => Promise<boolean>
/** Called after pruning */
afterPrune?: (entityId: string, deleted: number) => Promise<void>
}
}
/**
* Versioning Augmentation
*
* Automatically versions entities based on configurable policies.
*/
export class VersioningAugmentation extends BaseAugmentation {
readonly name = 'versioning'
readonly timing = 'after' as const // Run after operations complete
readonly metadata = 'readonly' as const
operations = ['update', 'add'] as any // Version on update/add
readonly priority = 50 // Medium priority
// Augmentation metadata
readonly category = 'core' as const
readonly description = 'Automatic entity versioning with configurable retention policies'
private versioningAPI?: VersioningAPI
private brain?: any // Brainy instance for entity fetching
private pruneTimer?: NodeJS.Timeout
private versionCount = 0
constructor(config: VersioningAugmentationConfig = {}) {
super(config)
// Merge with defaults
this.config = {
enabled: config.enabled ?? true,
onUpdate: config.onUpdate ?? true,
onAdd: config.onAdd ?? false,
entities: config.entities ?? ['*'], // Version all entities by default
excludeEntities: config.excludeEntities ?? ['_version:*'], // Exclude version metadata entities
types: config.types ?? [], // Empty = all types
excludeTypes: config.excludeTypes ?? [], // No need to exclude types (versions use 'state' type)
keepRecent: config.keepRecent ?? 10,
keepTagged: config.keepTagged ?? true,
tagPrefix: config.tagPrefix ?? 'auto-',
autoPrune: config.autoPrune ?? true,
pruneInterval: config.pruneInterval ?? 60 * 60 * 1000, // 1 hour
hooks: config.hooks ?? {}
}
}
getManifest(): AugmentationManifest {
return {
id: 'versioning',
name: 'Entity Versioning',
version: '5.3.0',
description: 'Automatic entity versioning with retention policies',
longDescription: 'Provides automatic versioning of entities on update/add operations with configurable retention policies, entity filtering, and event hooks.',
category: 'storage',
configSchema: {
type: 'object',
properties: {
enabled: {
type: 'boolean',
default: true,
description: 'Enable automatic versioning'
},
onUpdate: {
type: 'boolean',
default: true,
description: 'Auto-save version on update()'
},
onAdd: {
type: 'boolean',
default: false,
description: 'Auto-save version on add()'
},
entities: {
type: 'array',
items: { type: 'string' },
default: ['*'],
description: 'Entity ID patterns to version (glob patterns)'
},
excludeEntities: {
type: 'array',
items: { type: 'string' },
default: ['_version:*'],
description: 'Entity ID patterns to exclude (version metadata IDs start with _version:)'
},
types: {
type: 'array',
items: { type: 'string' },
default: [],
description: 'Entity types to version (empty = all)'
},
excludeTypes: {
type: 'array',
items: { type: 'string' },
default: [],
description: 'Entity types to exclude (versions use standard state type)'
},
keepRecent: {
type: 'number',
default: 10,
minimum: 1,
description: 'Keep N recent versions per entity'
},
keepTagged: {
type: 'boolean',
default: true,
description: 'Keep tagged versions during pruning'
},
tagPrefix: {
type: 'string',
default: 'auto-',
description: 'Tag prefix for auto-generated versions'
},
autoPrune: {
type: 'boolean',
default: true,
description: 'Automatically prune old versions'
},
pruneInterval: {
type: 'number',
default: 3600000,
description: 'Prune interval in milliseconds (default: 1 hour)'
}
}
},
configDefaults: {
enabled: true,
onUpdate: true,
onAdd: false,
entities: ['*'],
excludeEntities: ['_version:*'],
types: [],
excludeTypes: [],
keepRecent: 10,
keepTagged: true,
tagPrefix: 'auto-',
autoPrune: true,
pruneInterval: 3600000
},
minBrainyVersion: '5.3.0',
keywords: ['versioning', 'history', 'audit', 'backup'],
documentation: 'https://docs.brainy.dev/versioning',
status: 'stable',
performance: {
memoryUsage: 'low',
cpuUsage: 'low',
networkUsage: 'none'
},
features: [
'auto-versioning',
'retention-policies',
'entity-filtering',
'event-hooks'
],
enhancedOperations: ['update', 'add'],
metrics: [
{
name: 'versions_created',
type: 'counter',
description: 'Total versions created by augmentation'
},
{
name: 'versions_pruned',
type: 'counter',
description: 'Total versions pruned by retention policies'
}
]
}
}
async onAttach(brain: any): Promise<void> {
// Store brain instance for entity fetching
this.brain = brain
// Get VersioningAPI instance
this.versioningAPI = brain.versions
// Start auto-prune timer if enabled
if (this.config.autoPrune && this.config.pruneInterval) {
this.startAutoPrune()
}
}
async onDetach(): Promise<void> {
// Stop auto-prune timer
if (this.pruneTimer) {
clearInterval(this.pruneTimer)
this.pruneTimer = undefined
}
}
/**
* Execute method (required by BaseAugmentation)
*/
async execute(operation: string, params: any[], context: any): Promise<any> {
// Versioning augmentation only runs in 'after' mode
// Actual logic is in after() method
return context.originalResult
}
/**
* After operation hook - auto-save versions
*/
async after(operation: string, params: any[], result: any): Promise<any> {
if (!this.config.enabled || !this.versioningAPI) {
return result
}
// Check if we should version this operation
if (operation === 'update' && !this.config.onUpdate) {
return result
}
if (operation === 'add' && !this.config.onAdd) {
return result
}
// Extract entity ID from params
const entityId = this.extractEntityId(operation, params)
if (!entityId) {
return result
}
// Check if entity should be versioned
if (!(await this.shouldVersionEntity(entityId))) {
return result
}
try {
// Prepare save options
let saveOptions: SaveVersionOptions = {
tag: `${this.config.tagPrefix}${Date.now()}`,
description: `Auto-saved after ${operation}`,
metadata: {
augmentation: 'versioning',
operation,
timestamp: Date.now()
}
}
// Call before-save hook
if (this.config.hooks?.beforeSave) {
const modifiedOptions = await this.config.hooks.beforeSave(entityId, saveOptions)
if (modifiedOptions === null) {
// Hook cancelled the save
return result
}
saveOptions = modifiedOptions
}
// Save version
const version = await this.versioningAPI.save(entityId, saveOptions)
this.versionCount++
// Call after-save hook
if (this.config.hooks?.afterSave) {
await this.config.hooks.afterSave(entityId, version)
}
// Auto-prune if enabled
if (this.config.autoPrune) {
await this.pruneEntity(entityId)
}
} catch (error) {
// Don't fail the operation if versioning fails
console.error(`Versioning augmentation error for ${entityId}:`, error)
}
return result
}
/**
* Extract entity ID from operation params
*/
private extractEntityId(operation: string, params: any[]): string | null {
if (operation === 'update') {
// update(id, data) or update({ id, ... })
if (typeof params[0] === 'string') {
return params[0]
}
if (params[0]?.id) {
return params[0].id
}
}
if (operation === 'add') {
// add(data) - ID might be in data or result
if (params[0]?.id) {
return params[0].id
}
}
return null
}
/**
* Check if entity should be versioned based on filters
*/
private async shouldVersionEntity(entityId: string): Promise<boolean> {
// Check exclude patterns first (ID-based)
if (this.config.excludeEntities) {
for (const pattern of this.config.excludeEntities) {
if (this.matchPattern(entityId, pattern)) {
return false
}
}
}
// Check include patterns (ID-based)
if (this.config.entities && this.config.entities.length > 0) {
let matched = false
for (const pattern of this.config.entities) {
if (this.matchPattern(entityId, pattern)) {
matched = true
break
}
}
if (!matched) return false
}
// Check entity type filters (requires fetching entity)
if (
(this.config.types && this.config.types.length > 0) ||
(this.config.excludeTypes && this.config.excludeTypes.length > 0)
) {
try {
const entity = await this.brain?.getNounMetadata?.(entityId)
if (!entity) return true // If can't fetch, allow versioning
const entityType = entity.type
// Check exclude types
if (this.config.excludeTypes && this.config.excludeTypes.includes(entityType)) {
return false
}
// Check include types
if (this.config.types && this.config.types.length > 0) {
if (!this.config.types.includes(entityType)) {
return false
}
}
} catch (error) {
// If can't fetch entity, allow versioning (fail open)
console.error(`Failed to fetch entity ${entityId} for type filtering:`, error)
return true
}
}
return true
}
/**
* Simple glob pattern matching
*/
private matchPattern(value: string, pattern: string): boolean {
if (pattern === '*') return true
if (!pattern.includes('*')) return value === pattern
// Convert glob to regex
const regex = new RegExp(
'^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$'
)
return regex.test(value)
}
/**
* Prune old versions for an entity
*/
private async pruneEntity(entityId: string): Promise<void> {
if (!this.versioningAPI) return
try {
// Call before-prune hook
if (this.config.hooks?.beforePrune) {
const shouldPrune = await this.config.hooks.beforePrune(entityId)
if (!shouldPrune) return
}
// Prune versions
const result = await this.versioningAPI.prune(entityId, {
keepRecent: this.config.keepRecent,
keepAfter: this.config.keepAfter,
keepTagged: this.config.keepTagged
})
// Call after-prune hook
if (result.deleted > 0 && this.config.hooks?.afterPrune) {
await this.config.hooks.afterPrune(entityId, result.deleted)
}
} catch (error) {
console.error(`Failed to prune versions for ${entityId}:`, error)
}
}
/**
* Start auto-prune timer
*/
private startAutoPrune(): void {
if (this.pruneTimer) {
clearInterval(this.pruneTimer)
}
this.pruneTimer = setInterval(async () => {
await this.pruneAllEntities()
}, this.config.pruneInterval)
}
/**
* Prune all versioned entities
*/
private async pruneAllEntities(): Promise<void> {
if (!this.versioningAPI) return
try {
// Get all versioned entities
const versionIndex = (this.versioningAPI as any).manager.versionIndex
const entities = await versionIndex.getVersionedEntities()
// Prune each entity
for (const entityId of entities) {
if (await this.shouldVersionEntity(entityId)) {
await this.pruneEntity(entityId)
}
}
} catch (error) {
console.error('Auto-prune failed:', error)
}
}
/**
* Get augmentation statistics
*/
getStats(): {
enabled: boolean
versionsCreated: number
entitiesPattern: string[]
excludePattern: string[]
retention: number
} {
return {
enabled: this.config.enabled,
versionsCreated: this.versionCount,
entitiesPattern: this.config.entities || [],
excludePattern: this.config.excludeEntities || [],
retention: this.config.keepRecent || 10
}
}
}
/**
* Factory function for easy augmentation creation
*/
export function createVersioningAugmentation(
config: VersioningAugmentationConfig = {}
): VersioningAugmentation {
return new VersioningAugmentation(config)
}