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
|
||||
}
|
||||
}
|
||||
338
src/versioning/VersionIndex.ts
Normal file
338
src/versioning/VersionIndex.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
/**
|
||||
* VersionIndex - Fast Version Lookup Using Existing Index Infrastructure (v5.3.0)
|
||||
*
|
||||
* Integrates with Brainy's existing index system:
|
||||
* - Uses MetadataIndexManager for field indexing
|
||||
* - Leverages UnifiedCache for memory management
|
||||
* - Uses EntityIdMapper for efficient ID handling
|
||||
* - Uses ChunkManager for adaptive chunking
|
||||
* - Leverages Roaring Bitmaps for fast set operations
|
||||
*
|
||||
* Version metadata is stored as regular entities with type='_version'
|
||||
* This allows us to use existing index infrastructure without modification!
|
||||
*
|
||||
* Fields indexed:
|
||||
* - versionEntityId: Entity being versioned
|
||||
* - versionBranch: Branch version was created on
|
||||
* - versionNumber: Version number
|
||||
* - versionTag: Optional user tag
|
||||
* - versionTimestamp: Creation timestamp
|
||||
* - versionCommitHash: Commit hash
|
||||
*
|
||||
* NO MOCKS - Production implementation
|
||||
*/
|
||||
|
||||
import { BaseStorage } from '../storage/baseStorage.js'
|
||||
import type { EntityVersion } from './VersionManager.js'
|
||||
import type { VersionQuery } from './VersionManager.js'
|
||||
|
||||
/**
|
||||
* VersionIndex - Version lookup and querying using existing indexes
|
||||
*
|
||||
* Strategy: Store version metadata as special entities with type='_version'
|
||||
* This leverages ALL existing index infrastructure automatically!
|
||||
*/
|
||||
export class VersionIndex {
|
||||
private brain: any // Brainy instance
|
||||
private initialized: boolean = false
|
||||
|
||||
constructor(brain: any) {
|
||||
this.brain = brain
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize version index
|
||||
*
|
||||
* No special setup needed - we use existing entity storage and indexes!
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Add version to index
|
||||
*
|
||||
* Stores version metadata as a special entity with type='_version'
|
||||
* This automatically indexes it using existing MetadataIndexManager!
|
||||
*
|
||||
* @param version Version metadata
|
||||
*/
|
||||
async addVersion(version: EntityVersion): Promise<void> {
|
||||
await this.initialize()
|
||||
|
||||
// Generate unique ID for version entity
|
||||
const versionEntityId = this.getVersionEntityId(
|
||||
version.entityId,
|
||||
version.version,
|
||||
version.branch
|
||||
)
|
||||
|
||||
// Store as special entity with type='state' (version is a snapshot/state)
|
||||
// This automatically gets indexed by MetadataIndexManager!
|
||||
await this.brain.saveNounMetadata(versionEntityId, {
|
||||
id: versionEntityId,
|
||||
type: 'state', // Use standard 'state' type (version = snapshot state)
|
||||
name: `Version ${version.version} of ${version.entityId}`,
|
||||
metadata: {
|
||||
// Flag to identify as version metadata
|
||||
_isVersion: true,
|
||||
|
||||
// These fields are automatically indexed by MetadataIndexManager
|
||||
versionEntityId: version.entityId, // Entity being versioned
|
||||
versionBranch: version.branch, // Branch
|
||||
versionNumber: version.version, // Version number
|
||||
versionTag: version.tag, // Optional tag
|
||||
versionTimestamp: version.timestamp, // Timestamp (indexed with bucketing)
|
||||
versionCommitHash: version.commitHash, // Commit hash
|
||||
versionContentHash: version.contentHash, // Content hash
|
||||
versionAuthor: version.author, // Author
|
||||
versionDescription: version.description, // Description
|
||||
versionMetadata: version.metadata // Additional metadata
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get versions for an entity
|
||||
*
|
||||
* Uses existing MetadataIndexManager to query efficiently!
|
||||
*
|
||||
* @param query Version query
|
||||
* @returns List of versions (newest first)
|
||||
*/
|
||||
async getVersions(query: VersionQuery): Promise<EntityVersion[]> {
|
||||
await this.initialize()
|
||||
|
||||
// Build metadata filter using existing query system
|
||||
const filters: Record<string, any> = {
|
||||
type: 'state',
|
||||
_isVersion: true,
|
||||
versionEntityId: query.entityId,
|
||||
versionBranch: query.branch
|
||||
}
|
||||
|
||||
// Add optional filters
|
||||
if (query.tag) {
|
||||
filters.versionTag = query.tag
|
||||
}
|
||||
|
||||
// Query using existing search infrastructure
|
||||
const results = await this.brain.searchByMetadata(filters)
|
||||
|
||||
// Convert entities back to EntityVersion format
|
||||
const versions: EntityVersion[] = []
|
||||
for (const entity of results) {
|
||||
const version = this.entityToVersion(entity)
|
||||
if (version) {
|
||||
// Filter by date range if specified
|
||||
if (query.startDate && version.timestamp < query.startDate) continue
|
||||
if (query.endDate && version.timestamp > query.endDate) continue
|
||||
|
||||
versions.push(version)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by version number (newest first)
|
||||
versions.sort((a, b) => b.version - a.version)
|
||||
|
||||
// Apply pagination
|
||||
const start = query.offset || 0
|
||||
const end = query.limit ? start + query.limit : undefined
|
||||
|
||||
return versions.slice(start, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific version
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param version Version number
|
||||
* @param branch Branch name
|
||||
* @returns Version metadata or null
|
||||
*/
|
||||
async getVersion(
|
||||
entityId: string,
|
||||
version: number,
|
||||
branch: string
|
||||
): Promise<EntityVersion | null> {
|
||||
await this.initialize()
|
||||
|
||||
const versionEntityId = this.getVersionEntityId(entityId, version, branch)
|
||||
|
||||
const entity = await this.brain.getNounMetadata(versionEntityId)
|
||||
if (!entity) return null
|
||||
|
||||
return this.entityToVersion(entity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version by tag
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param tag Version tag
|
||||
* @param branch Branch name
|
||||
* @returns Version metadata or null
|
||||
*/
|
||||
async getVersionByTag(
|
||||
entityId: string,
|
||||
tag: string,
|
||||
branch: string
|
||||
): Promise<EntityVersion | null> {
|
||||
await this.initialize()
|
||||
|
||||
// Query using existing metadata index
|
||||
const results = await this.brain.searchByMetadata({
|
||||
type: 'state',
|
||||
_isVersion: true,
|
||||
versionEntityId: entityId,
|
||||
versionBranch: branch,
|
||||
versionTag: tag
|
||||
})
|
||||
|
||||
if (results.length === 0) return null
|
||||
|
||||
// Return first match (tags should be unique per entity/branch)
|
||||
return this.entityToVersion(results[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version count for entity
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param branch Branch name
|
||||
* @returns Number of versions
|
||||
*/
|
||||
async getVersionCount(entityId: string, branch: string): Promise<number> {
|
||||
await this.initialize()
|
||||
|
||||
// Use existing search infrastructure
|
||||
const results = await this.brain.searchByMetadata({
|
||||
type: 'state',
|
||||
_isVersion: true,
|
||||
versionEntityId: entityId,
|
||||
versionBranch: branch
|
||||
})
|
||||
|
||||
return results.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove version from index
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param version Version number
|
||||
* @param branch Branch name
|
||||
*/
|
||||
async removeVersion(
|
||||
entityId: string,
|
||||
version: number,
|
||||
branch: string
|
||||
): Promise<void> {
|
||||
await this.initialize()
|
||||
|
||||
const versionEntityId = this.getVersionEntityId(entityId, version, branch)
|
||||
|
||||
// Delete version entity (automatically removed from indexes)
|
||||
await this.brain.deleteNounMetadata(versionEntityId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert entity to EntityVersion format
|
||||
*
|
||||
* @param entity Entity from storage
|
||||
* @returns EntityVersion or null if invalid
|
||||
*/
|
||||
private entityToVersion(entity: any): EntityVersion | null {
|
||||
if (!entity || !entity.metadata) return null
|
||||
|
||||
const m = entity.metadata
|
||||
|
||||
if (
|
||||
!m.versionEntityId ||
|
||||
!m.versionBranch ||
|
||||
m.versionNumber === undefined ||
|
||||
!m.versionCommitHash ||
|
||||
!m.versionContentHash ||
|
||||
!m.versionTimestamp
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
version: m.versionNumber,
|
||||
entityId: m.versionEntityId,
|
||||
branch: m.versionBranch,
|
||||
commitHash: m.versionCommitHash,
|
||||
timestamp: m.versionTimestamp,
|
||||
contentHash: m.versionContentHash,
|
||||
tag: m.versionTag,
|
||||
description: m.versionDescription,
|
||||
author: m.versionAuthor,
|
||||
metadata: m.versionMetadata
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique ID for version entity
|
||||
*
|
||||
* Format: _version:{entityId}:{version}:{branch}
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param version Version number
|
||||
* @param branch Branch name
|
||||
* @returns Version entity ID
|
||||
*/
|
||||
private getVersionEntityId(
|
||||
entityId: string,
|
||||
version: number,
|
||||
branch: string
|
||||
): string {
|
||||
return `_version:${entityId}:${version}:${branch}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all versioned entities (for cleanup/debugging)
|
||||
*
|
||||
* @returns List of entity IDs that have versions
|
||||
*/
|
||||
async getVersionedEntities(): Promise<string[]> {
|
||||
await this.initialize()
|
||||
|
||||
// Query all version entities
|
||||
const results = await this.brain.searchByMetadata({
|
||||
type: 'state',
|
||||
_isVersion: true
|
||||
})
|
||||
|
||||
// Extract unique entity IDs
|
||||
const entityIds = new Set<string>()
|
||||
for (const entity of results) {
|
||||
const version = this.entityToVersion(entity)
|
||||
if (version) {
|
||||
entityIds.add(version.entityId)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(entityIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all versions for an entity
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param branch Branch name
|
||||
* @returns Number of versions deleted
|
||||
*/
|
||||
async clearVersions(entityId: string, branch: string): Promise<number> {
|
||||
await this.initialize()
|
||||
|
||||
const versions = await this.getVersions({ entityId, branch })
|
||||
|
||||
for (const version of versions) {
|
||||
await this.removeVersion(entityId, version.version, branch)
|
||||
}
|
||||
|
||||
return versions.length
|
||||
}
|
||||
}
|
||||
549
src/versioning/VersionManager.ts
Normal file
549
src/versioning/VersionManager.ts
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
/**
|
||||
* VersionManager - Entity-Level Versioning Engine (v5.3.0)
|
||||
*
|
||||
* Provides entity-level version control with:
|
||||
* - save() - Create entity version
|
||||
* - restore() - Restore entity to specific version
|
||||
* - list() - List all versions of an entity
|
||||
* - compare() - Deep diff between versions
|
||||
* - prune() - Remove old versions (retention policies)
|
||||
*
|
||||
* Architecture:
|
||||
* - Hybrid storage: COW commits for full snapshots + version index for fast queries
|
||||
* - Content-addressable: SHA-256 hashing for deduplication
|
||||
* - Space-efficient: Only stores changed data
|
||||
* - Branch-aware: Versions tied to current branch
|
||||
*
|
||||
* NO MOCKS - Production implementation
|
||||
*/
|
||||
|
||||
import { BaseStorage } from '../storage/baseStorage.js'
|
||||
import { VersionStorage } from './VersionStorage.js'
|
||||
import { VersionIndex } from './VersionIndex.js'
|
||||
import { VersionDiff, compareEntityVersions } from './VersionDiff.js'
|
||||
import type { NounMetadata } from '../coreTypes.js'
|
||||
|
||||
export interface EntityVersion {
|
||||
/** Version number (1-indexed, sequential per entity) */
|
||||
version: number
|
||||
|
||||
/** Entity ID */
|
||||
entityId: string
|
||||
|
||||
/** Branch this version was created on */
|
||||
branch: string
|
||||
|
||||
/** Commit hash containing this version */
|
||||
commitHash: string
|
||||
|
||||
/** Timestamp of version creation */
|
||||
timestamp: number
|
||||
|
||||
/** Optional user-provided tag (e.g., 'v1.0', 'before-refactor') */
|
||||
tag?: string
|
||||
|
||||
/** Optional description */
|
||||
description?: string
|
||||
|
||||
/** Content hash (SHA-256 of entity data) */
|
||||
contentHash: string
|
||||
|
||||
/** Author of this version */
|
||||
author?: string
|
||||
|
||||
/** Metadata about the version */
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface VersionQuery {
|
||||
/** Entity ID to query versions for */
|
||||
entityId: string
|
||||
|
||||
/** Optional: Filter by branch (default: current branch) */
|
||||
branch?: string
|
||||
|
||||
/** Optional: Limit number of versions returned */
|
||||
limit?: number
|
||||
|
||||
/** Optional: Skip first N versions */
|
||||
offset?: number
|
||||
|
||||
/** Optional: Filter by tag */
|
||||
tag?: string
|
||||
|
||||
/** Optional: Filter by date range */
|
||||
startDate?: number
|
||||
endDate?: number
|
||||
}
|
||||
|
||||
export interface SaveVersionOptions {
|
||||
/** Optional tag for this version (e.g., 'v1.0', 'milestone-1') */
|
||||
tag?: string
|
||||
|
||||
/** Optional description */
|
||||
description?: string
|
||||
|
||||
/** Optional author name */
|
||||
author?: string
|
||||
|
||||
/** Optional custom metadata */
|
||||
metadata?: Record<string, any>
|
||||
|
||||
/** Optional: Create commit automatically (default: false) */
|
||||
createCommit?: boolean
|
||||
|
||||
/** Optional: Commit message if createCommit is true */
|
||||
commitMessage?: string
|
||||
}
|
||||
|
||||
export interface RestoreOptions {
|
||||
/** Optional: Create version before restoring (for undo) */
|
||||
createSnapshot?: boolean
|
||||
|
||||
/** Optional: Tag for snapshot before restore */
|
||||
snapshotTag?: string
|
||||
}
|
||||
|
||||
export interface PruneOptions {
|
||||
/** Keep N most recent versions (required if keepVersions not set) */
|
||||
keepRecent?: number
|
||||
|
||||
/** Keep versions newer than timestamp (optional) */
|
||||
keepAfter?: number
|
||||
|
||||
/** Keep tagged versions (default: true) */
|
||||
keepTagged?: boolean
|
||||
|
||||
/** Dry run - don't actually delete (default: false) */
|
||||
dryRun?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* VersionManager - Core versioning engine
|
||||
*/
|
||||
export class VersionManager {
|
||||
private brain: any // Brainy instance
|
||||
private versionStorage: VersionStorage
|
||||
private versionIndex: VersionIndex
|
||||
private initialized: boolean = false
|
||||
|
||||
constructor(brain: any) {
|
||||
this.brain = brain
|
||||
this.versionStorage = new VersionStorage(brain)
|
||||
this.versionIndex = new VersionIndex(brain)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize versioning system (lazy)
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return
|
||||
|
||||
await this.versionStorage.initialize()
|
||||
await this.versionIndex.initialize()
|
||||
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a version of an entity
|
||||
*
|
||||
* Creates a version snapshot of the current entity state.
|
||||
* If createCommit is true, also creates a commit containing this version.
|
||||
*
|
||||
* @param entityId Entity ID to version
|
||||
* @param options Save options
|
||||
* @returns Created version metadata
|
||||
*/
|
||||
async save(
|
||||
entityId: string,
|
||||
options: SaveVersionOptions = {}
|
||||
): Promise<EntityVersion> {
|
||||
await this.initialize()
|
||||
|
||||
// Get current entity state
|
||||
const entity = await this.brain.getNounMetadata(entityId)
|
||||
if (!entity) {
|
||||
throw new Error(`Entity ${entityId} not found`)
|
||||
}
|
||||
|
||||
// Get current branch
|
||||
const currentBranch = this.brain.currentBranch
|
||||
|
||||
// Get next version number
|
||||
const existingVersions = await this.versionIndex.getVersions({
|
||||
entityId,
|
||||
branch: currentBranch
|
||||
})
|
||||
const nextVersion = existingVersions.length + 1
|
||||
|
||||
// Calculate content hash
|
||||
const contentHash = this.versionStorage.hashEntity(entity)
|
||||
|
||||
// Check for duplicate (same content as last version)
|
||||
if (existingVersions.length > 0) {
|
||||
const lastVersion = existingVersions[existingVersions.length - 1]
|
||||
if (lastVersion.contentHash === contentHash) {
|
||||
// Content unchanged - return last version instead of creating duplicate
|
||||
return lastVersion
|
||||
}
|
||||
}
|
||||
|
||||
// Create commit if requested
|
||||
let commitHash: string
|
||||
if (options.createCommit) {
|
||||
const commitMessage =
|
||||
options.commitMessage || `Version ${nextVersion} of entity ${entityId}`
|
||||
|
||||
// Use brain's commit method (note: single options object)
|
||||
await this.brain.commit({
|
||||
message: commitMessage,
|
||||
author: options.author,
|
||||
metadata: {
|
||||
versionedEntity: entityId,
|
||||
version: nextVersion,
|
||||
...options.metadata
|
||||
}
|
||||
})
|
||||
|
||||
// Get the commit hash that was just created
|
||||
const refManager = this.brain.refManager
|
||||
const ref = await refManager.getRef(currentBranch)
|
||||
commitHash = ref
|
||||
} else {
|
||||
// Use current HEAD commit
|
||||
const refManager = this.brain.refManager
|
||||
const ref = await refManager.getRef(currentBranch)
|
||||
if (!ref) {
|
||||
throw new Error(
|
||||
`No commit exists on branch ${currentBranch}. Create a commit first or use createCommit: true`
|
||||
)
|
||||
}
|
||||
commitHash = ref
|
||||
}
|
||||
|
||||
// Create version metadata
|
||||
const version: EntityVersion = {
|
||||
version: nextVersion,
|
||||
entityId,
|
||||
branch: currentBranch,
|
||||
commitHash,
|
||||
timestamp: Date.now(),
|
||||
contentHash,
|
||||
tag: options.tag,
|
||||
description: options.description,
|
||||
author: options.author,
|
||||
metadata: options.metadata
|
||||
}
|
||||
|
||||
// Store version
|
||||
await this.versionStorage.saveVersion(version, entity)
|
||||
await this.versionIndex.addVersion(version)
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all versions of an entity
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param query Optional query filters
|
||||
* @returns List of versions (newest first)
|
||||
*/
|
||||
async list(
|
||||
entityId: string,
|
||||
query: Partial<VersionQuery> = {}
|
||||
): Promise<EntityVersion[]> {
|
||||
await this.initialize()
|
||||
|
||||
const currentBranch = this.brain.currentBranch
|
||||
|
||||
return this.versionIndex.getVersions({
|
||||
entityId,
|
||||
branch: query.branch || currentBranch,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
tag: query.tag,
|
||||
startDate: query.startDate,
|
||||
endDate: query.endDate
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific version of an entity
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param version Version number (1-indexed)
|
||||
* @returns Version metadata
|
||||
*/
|
||||
async getVersion(
|
||||
entityId: string,
|
||||
version: number
|
||||
): Promise<EntityVersion | null> {
|
||||
await this.initialize()
|
||||
|
||||
const currentBranch = this.brain.currentBranch
|
||||
return this.versionIndex.getVersion(entityId, version, currentBranch)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version by tag
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param tag Version tag
|
||||
* @returns Version metadata
|
||||
*/
|
||||
async getVersionByTag(
|
||||
entityId: string,
|
||||
tag: string
|
||||
): Promise<EntityVersion | null> {
|
||||
await this.initialize()
|
||||
|
||||
const currentBranch = this.brain.currentBranch
|
||||
return this.versionIndex.getVersionByTag(entityId, tag, currentBranch)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore entity to a specific version
|
||||
*
|
||||
* Overwrites current entity state with the specified version.
|
||||
* Optionally creates a snapshot before restoring for undo capability.
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param version Version number or tag
|
||||
* @param options Restore options
|
||||
* @returns Restored version metadata
|
||||
*/
|
||||
async restore(
|
||||
entityId: string,
|
||||
version: number | string,
|
||||
options: RestoreOptions = {}
|
||||
): Promise<EntityVersion> {
|
||||
await this.initialize()
|
||||
|
||||
// Create snapshot before restoring (for undo)
|
||||
if (options.createSnapshot) {
|
||||
await this.save(entityId, {
|
||||
tag: options.snapshotTag || 'before-restore',
|
||||
description: `Snapshot before restoring to version ${version}`,
|
||||
metadata: { restoringTo: version }
|
||||
})
|
||||
}
|
||||
|
||||
// Get target version
|
||||
let targetVersion: EntityVersion | null
|
||||
if (typeof version === 'number') {
|
||||
targetVersion = await this.getVersion(entityId, version)
|
||||
} else {
|
||||
targetVersion = await this.getVersionByTag(entityId, version)
|
||||
}
|
||||
|
||||
if (!targetVersion) {
|
||||
throw new Error(
|
||||
`Version ${version} not found for entity ${entityId}`
|
||||
)
|
||||
}
|
||||
|
||||
// Load versioned entity data
|
||||
const versionedEntity = await this.versionStorage.loadVersion(targetVersion)
|
||||
if (!versionedEntity) {
|
||||
throw new Error(
|
||||
`Version data not found for entity ${entityId} version ${version}`
|
||||
)
|
||||
}
|
||||
|
||||
// Restore entity in storage
|
||||
await this.brain.saveNounMetadata(entityId, versionedEntity)
|
||||
|
||||
return targetVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two versions of an entity
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param fromVersion Version number or tag (older)
|
||||
* @param toVersion Version number or tag (newer)
|
||||
* @returns Diff between versions
|
||||
*/
|
||||
async compare(
|
||||
entityId: string,
|
||||
fromVersion: number | string,
|
||||
toVersion: number | string
|
||||
): Promise<VersionDiff> {
|
||||
await this.initialize()
|
||||
|
||||
// Get versions
|
||||
const fromVer =
|
||||
typeof fromVersion === 'number'
|
||||
? await this.getVersion(entityId, fromVersion)
|
||||
: await this.getVersionByTag(entityId, fromVersion)
|
||||
|
||||
const toVer =
|
||||
typeof toVersion === 'number'
|
||||
? await this.getVersion(entityId, toVersion)
|
||||
: await this.getVersionByTag(entityId, toVersion)
|
||||
|
||||
if (!fromVer) {
|
||||
throw new Error(
|
||||
`Version ${fromVersion} not found for entity ${entityId}`
|
||||
)
|
||||
}
|
||||
if (!toVer) {
|
||||
throw new Error(
|
||||
`Version ${toVersion} not found for entity ${entityId}`
|
||||
)
|
||||
}
|
||||
|
||||
// Load entity data
|
||||
const fromEntity = await this.versionStorage.loadVersion(fromVer)
|
||||
const toEntity = await this.versionStorage.loadVersion(toVer)
|
||||
|
||||
if (!fromEntity || !toEntity) {
|
||||
throw new Error('Failed to load version data for comparison')
|
||||
}
|
||||
|
||||
// Compare versions
|
||||
return compareEntityVersions(fromEntity, toEntity, {
|
||||
fromVersion: fromVer.version,
|
||||
toVersion: toVer.version,
|
||||
entityId
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune old versions based on retention policy
|
||||
*
|
||||
* @param entityId Entity ID (or '*' for all entities)
|
||||
* @param options Prune options
|
||||
* @returns Number of versions deleted
|
||||
*/
|
||||
async prune(
|
||||
entityId: string,
|
||||
options: PruneOptions
|
||||
): Promise<{ deleted: number; kept: number }> {
|
||||
await this.initialize()
|
||||
|
||||
if (!options.keepRecent && !options.keepAfter) {
|
||||
throw new Error(
|
||||
'Must specify either keepRecent or keepAfter in prune options'
|
||||
)
|
||||
}
|
||||
|
||||
const currentBranch = this.brain.currentBranch
|
||||
|
||||
// Get all versions
|
||||
const versions = await this.versionIndex.getVersions({
|
||||
entityId,
|
||||
branch: currentBranch
|
||||
})
|
||||
|
||||
// Determine which versions to keep
|
||||
const toKeep = new Set<number>()
|
||||
const toDelete: EntityVersion[] = []
|
||||
|
||||
// Keep recent versions
|
||||
if (options.keepRecent) {
|
||||
const recentVersions = versions.slice(0, options.keepRecent)
|
||||
recentVersions.forEach((v) => toKeep.add(v.version))
|
||||
}
|
||||
|
||||
// Keep versions after timestamp
|
||||
if (options.keepAfter) {
|
||||
versions
|
||||
.filter((v) => v.timestamp >= options.keepAfter!)
|
||||
.forEach((v) => toKeep.add(v.version))
|
||||
}
|
||||
|
||||
// Keep tagged versions
|
||||
if (options.keepTagged !== false) {
|
||||
versions
|
||||
.filter((v) => v.tag !== undefined)
|
||||
.forEach((v) => toKeep.add(v.version))
|
||||
}
|
||||
|
||||
// Build delete list
|
||||
for (const version of versions) {
|
||||
if (!toKeep.has(version.version)) {
|
||||
toDelete.push(version)
|
||||
}
|
||||
}
|
||||
|
||||
// Dry run - just return counts
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
deleted: toDelete.length,
|
||||
kept: toKeep.size
|
||||
}
|
||||
}
|
||||
|
||||
// Delete versions
|
||||
for (const version of toDelete) {
|
||||
await this.versionStorage.deleteVersion(version)
|
||||
await this.versionIndex.removeVersion(
|
||||
entityId,
|
||||
version.version,
|
||||
currentBranch
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
deleted: toDelete.length,
|
||||
kept: toKeep.size
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version count for an entity
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @returns Number of versions
|
||||
*/
|
||||
async getVersionCount(entityId: string): Promise<number> {
|
||||
await this.initialize()
|
||||
|
||||
const currentBranch = this.brain.currentBranch
|
||||
return this.versionIndex.getVersionCount(entityId, currentBranch)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if entity has versions
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @returns True if entity has versions
|
||||
*/
|
||||
async hasVersions(entityId: string): Promise<boolean> {
|
||||
const count = await this.getVersionCount(entityId)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest version of an entity
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @returns Latest version metadata or null
|
||||
*/
|
||||
async getLatest(entityId: string): Promise<EntityVersion | null> {
|
||||
await this.initialize()
|
||||
|
||||
const versions = await this.list(entityId, { limit: 1 })
|
||||
return versions[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all versions for an entity
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @returns Number of versions deleted
|
||||
*/
|
||||
async clear(entityId: string): Promise<number> {
|
||||
await this.initialize()
|
||||
|
||||
const result = await this.prune(entityId, {
|
||||
keepRecent: 0,
|
||||
keepTagged: false
|
||||
})
|
||||
|
||||
return result.deleted
|
||||
}
|
||||
}
|
||||
265
src/versioning/VersionStorage.ts
Normal file
265
src/versioning/VersionStorage.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
/**
|
||||
* VersionStorage - Hybrid Storage for Entity Versions (v5.3.0)
|
||||
*
|
||||
* Implements content-addressable storage for entity versions:
|
||||
* - SHA-256 content hashing for deduplication
|
||||
* - Stores versions in .brainy/versions/ directory
|
||||
* - Integrates with COW commit system
|
||||
* - Space-efficient: Only stores unique content
|
||||
*
|
||||
* Storage structure:
|
||||
* .brainy/versions/
|
||||
* ├── entities/
|
||||
* │ └── {entityId}/
|
||||
* │ └── {contentHash}.json # Entity version data
|
||||
* └── index/
|
||||
* └── {entityId}.json # Version index (managed by VersionIndex)
|
||||
*
|
||||
* NO MOCKS - Production implementation
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto'
|
||||
import { BaseStorage } from '../storage/baseStorage.js'
|
||||
import type { NounMetadata } from '../coreTypes.js'
|
||||
import type { EntityVersion } from './VersionManager.js'
|
||||
|
||||
/**
|
||||
* VersionStorage - Content-addressable version storage
|
||||
*/
|
||||
export class VersionStorage {
|
||||
private brain: any // Brainy instance
|
||||
private initialized: boolean = false
|
||||
|
||||
constructor(brain: any) {
|
||||
this.brain = brain
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize version storage directories
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return
|
||||
|
||||
// Version storage uses the same storage adapter as the main database
|
||||
// Directories are created automatically by the storage adapter
|
||||
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate SHA-256 hash of entity content
|
||||
*
|
||||
* Used for content-addressable storage and deduplication
|
||||
*
|
||||
* @param entity Entity to hash
|
||||
* @returns SHA-256 hash (hex string)
|
||||
*/
|
||||
hashEntity(entity: NounMetadata): string {
|
||||
// Create stable JSON representation (sorted keys)
|
||||
const stableJson = this.toStableJson(entity)
|
||||
return createHash('sha256').update(stableJson).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert entity to stable JSON (sorted keys for consistent hashing)
|
||||
*/
|
||||
private toStableJson(obj: any): string {
|
||||
if (obj === null) return 'null'
|
||||
if (obj === undefined) return 'undefined'
|
||||
if (typeof obj !== 'object') return JSON.stringify(obj)
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
const items = obj.map((item) => this.toStableJson(item))
|
||||
return `[${items.join(',') }]`
|
||||
}
|
||||
|
||||
// Sort object keys for stable hashing
|
||||
const sortedKeys = Object.keys(obj).sort()
|
||||
const pairs = sortedKeys.map((key) => {
|
||||
const value = this.toStableJson(obj[key])
|
||||
return `"${key}":${value}`
|
||||
})
|
||||
|
||||
return `{${pairs.join(',')}}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Save entity version to content-addressable storage
|
||||
*
|
||||
* @param version Version metadata
|
||||
* @param entity Entity data
|
||||
*/
|
||||
async saveVersion(
|
||||
version: EntityVersion,
|
||||
entity: NounMetadata
|
||||
): Promise<void> {
|
||||
await this.initialize()
|
||||
|
||||
// Content-addressable path: .brainy/versions/entities/{entityId}/{contentHash}.json
|
||||
const versionPath = this.getVersionPath(
|
||||
version.entityId,
|
||||
version.contentHash
|
||||
)
|
||||
|
||||
// Check if content already exists (deduplication)
|
||||
const exists = await this.contentExists(versionPath)
|
||||
if (exists) {
|
||||
// Content already stored - no need to write again
|
||||
return
|
||||
}
|
||||
|
||||
// Store entity data
|
||||
await this.writeVersionData(versionPath, entity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load entity version from storage
|
||||
*
|
||||
* @param version Version metadata
|
||||
* @returns Entity data or null if not found
|
||||
*/
|
||||
async loadVersion(version: EntityVersion): Promise<NounMetadata | null> {
|
||||
await this.initialize()
|
||||
|
||||
const versionPath = this.getVersionPath(
|
||||
version.entityId,
|
||||
version.contentHash
|
||||
)
|
||||
|
||||
try {
|
||||
return await this.readVersionData(versionPath)
|
||||
} catch (error) {
|
||||
console.error(`Failed to load version ${version.version}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete entity version from storage
|
||||
*
|
||||
* @param version Version to delete
|
||||
*/
|
||||
async deleteVersion(version: EntityVersion): Promise<void> {
|
||||
await this.initialize()
|
||||
|
||||
const versionPath = this.getVersionPath(
|
||||
version.entityId,
|
||||
version.contentHash
|
||||
)
|
||||
|
||||
await this.deleteVersionData(versionPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version storage path
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param contentHash Content hash
|
||||
* @returns Storage path
|
||||
*/
|
||||
private getVersionPath(entityId: string, contentHash: string): string {
|
||||
return `versions/entities/${entityId}/${contentHash}.json`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content exists in storage
|
||||
*
|
||||
* @param path Storage path
|
||||
* @returns True if exists
|
||||
*/
|
||||
private async contentExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
// Use storage adapter's exists check if available
|
||||
const adapter = this.brain.storageAdapter
|
||||
if (adapter && typeof adapter.exists === 'function') {
|
||||
return await adapter.exists(path)
|
||||
}
|
||||
|
||||
// Fallback: Try to read and catch error
|
||||
await this.readVersionData(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write version data to storage
|
||||
*
|
||||
* @param path Storage path
|
||||
* @param entity Entity data
|
||||
*/
|
||||
private async writeVersionData(
|
||||
path: string,
|
||||
entity: NounMetadata
|
||||
): Promise<void> {
|
||||
const adapter = this.brain.storageAdapter
|
||||
|
||||
if (!adapter) {
|
||||
throw new Error('Storage adapter not available')
|
||||
}
|
||||
|
||||
// Serialize entity data
|
||||
const data = JSON.stringify(entity, null, 2)
|
||||
|
||||
// Write to storage using adapter
|
||||
if (typeof adapter.writeFile === 'function') {
|
||||
await adapter.writeFile(path, data)
|
||||
} else if (typeof adapter.set === 'function') {
|
||||
await adapter.set(path, data)
|
||||
} else {
|
||||
throw new Error('Storage adapter does not support write operations')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read version data from storage
|
||||
*
|
||||
* @param path Storage path
|
||||
* @returns Entity data
|
||||
*/
|
||||
private async readVersionData(path: string): Promise<NounMetadata> {
|
||||
const adapter = this.brain.storageAdapter
|
||||
|
||||
if (!adapter) {
|
||||
throw new Error('Storage adapter not available')
|
||||
}
|
||||
|
||||
// Read from storage using adapter
|
||||
let data: string
|
||||
|
||||
if (typeof adapter.readFile === 'function') {
|
||||
data = await adapter.readFile(path)
|
||||
} else if (typeof adapter.get === 'function') {
|
||||
data = await adapter.get(path)
|
||||
} else {
|
||||
throw new Error('Storage adapter does not support read operations')
|
||||
}
|
||||
|
||||
// Parse entity data
|
||||
return JSON.parse(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete version data from storage
|
||||
*
|
||||
* @param path Storage path
|
||||
*/
|
||||
private async deleteVersionData(path: string): Promise<void> {
|
||||
const adapter = this.brain.storageAdapter
|
||||
|
||||
if (!adapter) {
|
||||
throw new Error('Storage adapter not available')
|
||||
}
|
||||
|
||||
// Delete from storage using adapter
|
||||
if (typeof adapter.deleteFile === 'function') {
|
||||
await adapter.deleteFile(path)
|
||||
} else if (typeof adapter.delete === 'function') {
|
||||
await adapter.delete(path)
|
||||
} else {
|
||||
throw new Error('Storage adapter does not support delete operations')
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
520
src/versioning/VersioningAPI.ts
Normal file
520
src/versioning/VersioningAPI.ts
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
/**
|
||||
* VersioningAPI - Public API for Entity Versioning (v5.3.0)
|
||||
*
|
||||
* User-friendly wrapper around VersionManager with:
|
||||
* - Clean, simple API
|
||||
* - Smart defaults
|
||||
* - Error handling
|
||||
* - Type safety
|
||||
*
|
||||
* Usage:
|
||||
* const version = await brain.versions.save('entity-123', { tag: 'v1.0' })
|
||||
* const versions = await brain.versions.list('entity-123')
|
||||
* await brain.versions.restore('entity-123', 5)
|
||||
* const diff = await brain.versions.compare('entity-123', 2, 5)
|
||||
*
|
||||
* NO MOCKS - Production implementation
|
||||
*/
|
||||
|
||||
import { VersionManager } from './VersionManager.js'
|
||||
import type {
|
||||
EntityVersion,
|
||||
SaveVersionOptions,
|
||||
RestoreOptions,
|
||||
PruneOptions,
|
||||
VersionQuery
|
||||
} from './VersionManager.js'
|
||||
import type { VersionDiff, DiffOptions } from './VersionDiff.js'
|
||||
import type { BaseStorage } from '../storage/baseStorage.js'
|
||||
|
||||
/**
|
||||
* VersioningAPI - User-friendly versioning interface
|
||||
*/
|
||||
export class VersioningAPI {
|
||||
private manager: VersionManager
|
||||
private brain: any // Brainy instance
|
||||
|
||||
constructor(brain: any) {
|
||||
this.brain = brain
|
||||
this.manager = new VersionManager(brain as BaseStorage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current state of entity as a new version
|
||||
*
|
||||
* Creates a version snapshot of the current entity state.
|
||||
* Automatically handles deduplication - if content hasn't changed,
|
||||
* returns the last version instead of creating a duplicate.
|
||||
*
|
||||
* @param entityId Entity ID to version
|
||||
* @param options Save options
|
||||
* @returns Created (or existing) version metadata
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Simple save
|
||||
* const version = await brain.versions.save('user-123')
|
||||
*
|
||||
* // Save with tag and description
|
||||
* const version = await brain.versions.save('user-123', {
|
||||
* tag: 'v1.0',
|
||||
* description: 'Initial release',
|
||||
* author: 'alice'
|
||||
* })
|
||||
*
|
||||
* // Save and create commit
|
||||
* const version = await brain.versions.save('user-123', {
|
||||
* tag: 'milestone-1',
|
||||
* createCommit: true,
|
||||
* commitMessage: 'Milestone 1 complete'
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
async save(
|
||||
entityId: string,
|
||||
options: SaveVersionOptions = {}
|
||||
): Promise<EntityVersion> {
|
||||
return this.manager.save(entityId, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* List all versions of an entity
|
||||
*
|
||||
* Returns versions sorted by version number (newest first).
|
||||
* Supports filtering by tag, date range, and pagination.
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param options Query options
|
||||
* @returns List of versions (newest first)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Get all versions
|
||||
* const versions = await brain.versions.list('user-123')
|
||||
*
|
||||
* // Get last 10 versions
|
||||
* const recent = await brain.versions.list('user-123', { limit: 10 })
|
||||
*
|
||||
* // Get tagged versions
|
||||
* const tagged = await brain.versions.list('user-123', { tag: 'v*' })
|
||||
*
|
||||
* // Get versions from last 30 days
|
||||
* const recent = await brain.versions.list('user-123', {
|
||||
* startDate: Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
async list(
|
||||
entityId: string,
|
||||
options: Partial<VersionQuery> = {}
|
||||
): Promise<EntityVersion[]> {
|
||||
return this.manager.list(entityId, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific version of an entity
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param version Version number (1-indexed)
|
||||
* @returns Version metadata or null if not found
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const version = await brain.versions.getVersion('user-123', 5)
|
||||
* if (version) {
|
||||
* console.log(`Version ${version.version} created at ${new Date(version.timestamp)}`)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
async getVersion(
|
||||
entityId: string,
|
||||
version: number
|
||||
): Promise<EntityVersion | null> {
|
||||
return this.manager.getVersion(entityId, version)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version by tag
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param tag Version tag
|
||||
* @returns Version metadata or null if not found
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const version = await brain.versions.getVersionByTag('user-123', 'v1.0')
|
||||
* ```
|
||||
*/
|
||||
async getVersionByTag(
|
||||
entityId: string,
|
||||
tag: string
|
||||
): Promise<EntityVersion | null> {
|
||||
return this.manager.getVersionByTag(entityId, tag)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest version of an entity
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @returns Latest version or null if no versions exist
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const latest = await brain.versions.getLatest('user-123')
|
||||
* ```
|
||||
*/
|
||||
async getLatest(entityId: string): Promise<EntityVersion | null> {
|
||||
return this.manager.getLatest(entityId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version content without restoring
|
||||
*
|
||||
* Allows you to preview version data without modifying the current entity.
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param version Version number or tag
|
||||
* @returns Version content
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Preview version 5 without restoring
|
||||
* const oldData = await brain.versions.getContent('user-123', 5)
|
||||
* console.log('Version 5 had name:', oldData.name)
|
||||
*
|
||||
* // Compare with current
|
||||
* const current = await brain.getNounMetadata('user-123')
|
||||
* console.log('Current name:', current.name)
|
||||
* ```
|
||||
*/
|
||||
async getContent(
|
||||
entityId: string,
|
||||
version: number | string
|
||||
): Promise<any> {
|
||||
// Get version metadata
|
||||
let versionMeta: EntityVersion | null
|
||||
if (typeof version === 'number') {
|
||||
versionMeta = await this.manager.getVersion(entityId, version)
|
||||
} else {
|
||||
versionMeta = await this.manager.getVersionByTag(entityId, version)
|
||||
}
|
||||
|
||||
if (!versionMeta) {
|
||||
throw new Error(
|
||||
`Version ${version} not found for entity ${entityId}`
|
||||
)
|
||||
}
|
||||
|
||||
// Load version content
|
||||
const versionStorage = (this.manager as any).versionStorage
|
||||
const content = await versionStorage.loadVersion(versionMeta)
|
||||
|
||||
if (!content) {
|
||||
throw new Error(
|
||||
`Version content not found for entity ${entityId} version ${version}`
|
||||
)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore entity to a specific version
|
||||
*
|
||||
* Overwrites current entity state with the specified version.
|
||||
* Optionally creates a snapshot before restoring for undo capability.
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param version Version number or tag to restore to
|
||||
* @param options Restore options
|
||||
* @returns Restored version metadata
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Simple restore
|
||||
* await brain.versions.restore('user-123', 5)
|
||||
*
|
||||
* // Restore with safety snapshot
|
||||
* await brain.versions.restore('user-123', 5, {
|
||||
* createSnapshot: true,
|
||||
* snapshotTag: 'before-restore'
|
||||
* })
|
||||
*
|
||||
* // Restore by tag
|
||||
* await brain.versions.restore('user-123', 'v1.0')
|
||||
* ```
|
||||
*/
|
||||
async restore(
|
||||
entityId: string,
|
||||
version: number | string,
|
||||
options: RestoreOptions = {}
|
||||
): Promise<EntityVersion> {
|
||||
return this.manager.restore(entityId, version, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two versions of an entity
|
||||
*
|
||||
* Generates a deep diff showing added, removed, modified, and type-changed fields.
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param fromVersion Version number or tag (older)
|
||||
* @param toVersion Version number or tag (newer)
|
||||
* @returns Diff between versions
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Compare version 2 to version 5
|
||||
* const diff = await brain.versions.compare('user-123', 2, 5)
|
||||
*
|
||||
* console.log(`Added fields: ${diff.added.length}`)
|
||||
* console.log(`Removed fields: ${diff.removed.length}`)
|
||||
* console.log(`Modified fields: ${diff.modified.length}`)
|
||||
*
|
||||
* // Print human-readable diff
|
||||
* import { formatDiff } from './VersionDiff.js'
|
||||
* console.log(formatDiff(diff))
|
||||
* ```
|
||||
*/
|
||||
async compare(
|
||||
entityId: string,
|
||||
fromVersion: number | string,
|
||||
toVersion: number | string
|
||||
): Promise<VersionDiff> {
|
||||
return this.manager.compare(entityId, fromVersion, toVersion)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune old versions based on retention policy
|
||||
*
|
||||
* Removes old versions while preserving recent and tagged versions.
|
||||
* Use dryRun to preview what would be deleted without actually deleting.
|
||||
*
|
||||
* @param entityId Entity ID (or '*' for all entities - NOT IMPLEMENTED YET)
|
||||
* @param options Prune options
|
||||
* @returns Count of deleted and kept versions
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Keep last 10 versions, delete rest
|
||||
* const result = await brain.versions.prune('user-123', {
|
||||
* keepRecent: 10
|
||||
* })
|
||||
* console.log(`Deleted ${result.deleted} versions`)
|
||||
*
|
||||
* // Keep last 30 days, preserve tagged versions
|
||||
* const result = await brain.versions.prune('user-123', {
|
||||
* keepAfter: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
||||
* keepTagged: true
|
||||
* })
|
||||
*
|
||||
* // Dry run - see what would be deleted
|
||||
* const result = await brain.versions.prune('user-123', {
|
||||
* keepRecent: 5,
|
||||
* dryRun: true
|
||||
* })
|
||||
* console.log(`Would delete ${result.deleted} versions`)
|
||||
* ```
|
||||
*/
|
||||
async prune(
|
||||
entityId: string,
|
||||
options: PruneOptions
|
||||
): Promise<{ deleted: number; kept: number }> {
|
||||
return this.manager.prune(entityId, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version count for an entity
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @returns Number of versions
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const count = await brain.versions.count('user-123')
|
||||
* console.log(`Entity has ${count} versions`)
|
||||
* ```
|
||||
*/
|
||||
async count(entityId: string): Promise<number> {
|
||||
return this.manager.getVersionCount(entityId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if entity has any versions
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @returns True if entity has versions
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* if (await brain.versions.hasVersions('user-123')) {
|
||||
* console.log('Entity is versioned')
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
async hasVersions(entityId: string): Promise<boolean> {
|
||||
return this.manager.hasVersions(entityId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all versions for an entity
|
||||
*
|
||||
* WARNING: This permanently deletes all version history!
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @returns Number of versions deleted
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const deleted = await brain.versions.clear('user-123')
|
||||
* console.log(`Deleted ${deleted} versions`)
|
||||
* ```
|
||||
*/
|
||||
async clear(entityId: string): Promise<number> {
|
||||
return this.manager.clear(entityId)
|
||||
}
|
||||
|
||||
// ===== CONVENIENCE METHODS =====
|
||||
|
||||
/**
|
||||
* Quick save with auto-generated tag
|
||||
*
|
||||
* Generates tag in format: 'auto-{timestamp}'
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param description Optional description
|
||||
* @returns Created version
|
||||
*/
|
||||
async quickSave(
|
||||
entityId: string,
|
||||
description?: string
|
||||
): Promise<EntityVersion> {
|
||||
return this.save(entityId, {
|
||||
tag: `auto-${Date.now()}`,
|
||||
description
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo last change (restore to previous version)
|
||||
*
|
||||
* Safely restores to previous version with automatic snapshot.
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @returns Restored version metadata
|
||||
*/
|
||||
async undo(entityId: string): Promise<EntityVersion | null> {
|
||||
const versions = await this.list(entityId, { limit: 2 })
|
||||
|
||||
if (versions.length < 2) {
|
||||
// No previous version to restore to
|
||||
return null
|
||||
}
|
||||
|
||||
const previousVersion = versions[1] // Second most recent
|
||||
|
||||
return this.restore(entityId, previousVersion.version, {
|
||||
createSnapshot: true,
|
||||
snapshotTag: 'before-undo'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version history summary
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @returns Summary statistics
|
||||
*/
|
||||
async history(entityId: string): Promise<{
|
||||
total: number
|
||||
oldest?: EntityVersion
|
||||
newest?: EntityVersion
|
||||
tagged: number
|
||||
branches: string[]
|
||||
}> {
|
||||
const versions = await this.list(entityId)
|
||||
|
||||
const tagged = versions.filter((v) => v.tag !== undefined).length
|
||||
const branches = [...new Set(versions.map((v) => v.branch))]
|
||||
|
||||
return {
|
||||
total: versions.length,
|
||||
oldest: versions[versions.length - 1],
|
||||
newest: versions[0],
|
||||
tagged,
|
||||
branches
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert to previous version (alias for undo with better semantics)
|
||||
*
|
||||
* Restores entity to the previous version with automatic safety snapshot.
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @returns Restored version or null if not possible
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Made a mistake? Revert!
|
||||
* await brain.update('user-123', { name: 'Wrong Name' })
|
||||
* await brain.versions.revert('user-123') // Back to previous state
|
||||
* ```
|
||||
*/
|
||||
async revert(entityId: string): Promise<EntityVersion | null> {
|
||||
return this.undo(entityId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag an existing version
|
||||
*
|
||||
* Updates the tag of an existing version.
|
||||
* Note: This creates a new version with the tag, not modifying the existing one.
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param version Version number
|
||||
* @param tag New tag
|
||||
* @returns Updated version
|
||||
*/
|
||||
async tag(
|
||||
entityId: string,
|
||||
version: number,
|
||||
tag: string
|
||||
): Promise<EntityVersion> {
|
||||
// Get the version
|
||||
const existingVersion = await this.getVersion(entityId, version)
|
||||
if (!existingVersion) {
|
||||
throw new Error(`Version ${version} not found for entity ${entityId}`)
|
||||
}
|
||||
|
||||
// Create new version with tag
|
||||
// Note: This is a limitation - we can't modify existing versions
|
||||
// because they're immutable. Instead, we create a new version.
|
||||
return this.save(entityId, {
|
||||
tag,
|
||||
description: `Tagged version ${version} as ${tag}`,
|
||||
metadata: { originalVersion: version }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diff between entity's current state and a version
|
||||
*
|
||||
* @param entityId Entity ID
|
||||
* @param version Version to compare against
|
||||
* @returns Diff showing changes
|
||||
*/
|
||||
async diffWithCurrent(
|
||||
entityId: string,
|
||||
version: number | string
|
||||
): Promise<VersionDiff> {
|
||||
// Save current state (will be deduplicated if unchanged)
|
||||
const currentVersion = await this.save(entityId, {
|
||||
tag: 'temp-compare',
|
||||
description: 'Temporary version for comparison'
|
||||
})
|
||||
|
||||
// Compare
|
||||
return this.compare(entityId, version, currentVersion.version)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue