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
|
|
@ -1,9 +1,9 @@
|
||||||
# 🧠 Brainy v5.0+ API Reference
|
# 🧠 Brainy v5.0+ API Reference
|
||||||
|
|
||||||
> **Complete API documentation for Brainy v5.0+**
|
> **Complete API documentation for Brainy v5.0+**
|
||||||
> Zero Configuration • Triple Intelligence • Git-Style Branching
|
> Zero Configuration • Triple Intelligence • Git-Style Branching • Entity Versioning
|
||||||
|
|
||||||
**Updated:** 2025-11-02 for v5.1.0
|
**Updated:** 2025-11-04 for v5.3.0
|
||||||
**All APIs verified against actual code**
|
**All APIs verified against actual code**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -34,6 +34,11 @@ const results = await brain.find({
|
||||||
const experiment = await brain.fork('test-feature')
|
const experiment = await brain.fork('test-feature')
|
||||||
await experiment.add({ data: 'test', type: NounType.Content })
|
await experiment.add({ data: 'test', type: NounType.Content })
|
||||||
await experiment.commit({ message: 'Add test data' })
|
await experiment.commit({ message: 'Add test data' })
|
||||||
|
|
||||||
|
// Entity versioning (v5.3.0+)
|
||||||
|
await brain.versions.save(id, { tag: 'v1.0', description: 'Initial version' })
|
||||||
|
await brain.update(id, { category: 'AI' })
|
||||||
|
await brain.versions.save(id, { tag: 'v2.0' })
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -52,6 +57,9 @@ Vector search + Graph traversal + Metadata filtering in one unified query.
|
||||||
### 🌳 Git-Style Branching (v5.0.0+)
|
### 🌳 Git-Style Branching (v5.0.0+)
|
||||||
Fork, experiment, commit, and merge - Snowflake-style copy-on-write isolation.
|
Fork, experiment, commit, and merge - Snowflake-style copy-on-write isolation.
|
||||||
|
|
||||||
|
### 📜 Entity Versioning (v5.3.0+)
|
||||||
|
Time-travel and history tracking for individual entities - Git-like version control with content-addressable storage.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
@ -61,6 +69,7 @@ Fork, experiment, commit, and merge - Snowflake-style copy-on-write isolation.
|
||||||
- [Relationships](#relationships)
|
- [Relationships](#relationships)
|
||||||
- [Batch Operations](#batch-operations)
|
- [Batch Operations](#batch-operations)
|
||||||
- [Branch Management (v5.0+)](#branch-management-v50)
|
- [Branch Management (v5.0+)](#branch-management-v50)
|
||||||
|
- [Entity Versioning (v5.3.0+)](#entity-versioning-v530)
|
||||||
- [Virtual Filesystem (VFS)](#virtual-filesystem-vfs)
|
- [Virtual Filesystem (VFS)](#virtual-filesystem-vfs)
|
||||||
- [Neural API](#neural-api)
|
- [Neural API](#neural-api)
|
||||||
- [Import & Export](#import--export)
|
- [Import & Export](#import--export)
|
||||||
|
|
@ -468,10 +477,427 @@ const history = await brain.getHistory({
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Entity Versioning (v5.3.0+)
|
||||||
|
|
||||||
|
**NEW in v5.3.0:** Git-style versioning for individual entities with content-addressable storage.
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
Entity Versioning provides time-travel and history tracking for individual entities:
|
||||||
|
|
||||||
|
- **Content-Addressable Storage** - Deduplication via SHA-256 hashing
|
||||||
|
- **Zero-Config** - Lazy initialization, uses existing indexes
|
||||||
|
- **Branch-Isolated** - Versions isolated per branch
|
||||||
|
- **Selective Auto-Versioning** - Optional augmentation for automatic version creation
|
||||||
|
- **Production-Scale** - Designed for billions of entities
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `versions.save(entityId, options?)` → `Promise<EntityVersion>`
|
||||||
|
|
||||||
|
Save a new version of an entity.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Save version with tag
|
||||||
|
const version = await brain.versions.save('user-123', {
|
||||||
|
tag: 'v1.0',
|
||||||
|
description: 'Initial user profile',
|
||||||
|
metadata: { author: 'dev@example.com' }
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(version.version) // 1
|
||||||
|
console.log(version.contentHash) // SHA-256 hash
|
||||||
|
console.log(version.createdAt) // Timestamp
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `entityId`: `string` - Entity ID to version
|
||||||
|
- `options?`: `object`
|
||||||
|
- `tag?`: `string` - Version tag (e.g., 'v1.0', 'beta')
|
||||||
|
- `description?`: `string` - Version description
|
||||||
|
- `metadata?`: `object` - Additional version metadata
|
||||||
|
|
||||||
|
**Returns:** `Promise<EntityVersion>` - Created version
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Automatic deduplication (identical content = same version)
|
||||||
|
- Sequential version numbering (1, 2, 3, ...)
|
||||||
|
- Content-addressable storage (SHA-256)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `versions.list(entityId, options?)` → `Promise<EntityVersion[]>`
|
||||||
|
|
||||||
|
List all versions of an entity.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const versions = await brain.versions.list('user-123', {
|
||||||
|
limit: 10,
|
||||||
|
offset: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
versions.forEach(v => {
|
||||||
|
console.log(`Version ${v.version}: ${v.tag} - ${v.description}`)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `entityId`: `string` - Entity ID
|
||||||
|
- `options?`: `object`
|
||||||
|
- `limit?`: `number` - Max versions to return
|
||||||
|
- `offset?`: `number` - Skip versions
|
||||||
|
|
||||||
|
**Returns:** `Promise<EntityVersion[]>` - Versions (newest first)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `versions.restore(entityId, versionOrTag)` → `Promise<void>`
|
||||||
|
|
||||||
|
Restore entity to a previous version.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Restore by version number
|
||||||
|
await brain.versions.restore('user-123', 1)
|
||||||
|
|
||||||
|
// Restore by tag
|
||||||
|
await brain.versions.restore('user-123', 'beta')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `entityId`: `string` - Entity ID
|
||||||
|
- `versionOrTag`: `number | string` - Version number or tag
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `versions.compare(entityId, version1, version2)` → `Promise<VersionDiff>`
|
||||||
|
|
||||||
|
Compare two versions.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const diff = await brain.versions.compare('user-123', 1, 2)
|
||||||
|
|
||||||
|
console.log(diff.totalChanges) // Total changes
|
||||||
|
console.log(diff.modified) // Modified fields
|
||||||
|
console.log(diff.added) // Added fields
|
||||||
|
console.log(diff.removed) // Removed fields
|
||||||
|
|
||||||
|
// Check specific changes
|
||||||
|
const nameChange = diff.modified.find(c => c.path === 'metadata.name')
|
||||||
|
console.log(`${nameChange.oldValue} → ${nameChange.newValue}`)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Returns:** `Promise<VersionDiff>` - Detailed diff with field-level changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `versions.getContent(entityId, versionOrTag)` → `Promise<EntitySnapshot>`
|
||||||
|
|
||||||
|
Get version content without restoring.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// View old version without changing current state
|
||||||
|
const v1Content = await brain.versions.getContent('user-123', 1)
|
||||||
|
console.log(v1Content.metadata.name) // Old name
|
||||||
|
|
||||||
|
// Current state unchanged
|
||||||
|
const current = await brain.get('user-123')
|
||||||
|
console.log(current.metadata.name) // Current name
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `versions.undo(entityId)` → `Promise<void>`
|
||||||
|
|
||||||
|
Undo to previous version (shorthand for restore to latest-1).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Make a bad change
|
||||||
|
await brain.update('user-123', { status: 'deleted' })
|
||||||
|
|
||||||
|
// Undo immediately
|
||||||
|
await brain.versions.undo('user-123')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alias:** `versions.revert(entityId)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `versions.prune(entityId, options)` → `Promise<PruneResult>`
|
||||||
|
|
||||||
|
Clean up old versions.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await brain.versions.prune('user-123', {
|
||||||
|
keepRecent: 10, // Keep 10 most recent
|
||||||
|
keepTagged: true, // Always keep tagged versions
|
||||||
|
olderThan: Date.now() - 30 * 24 * 60 * 60 * 1000 // Older than 30 days
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Deleted ${result.deleted}, kept ${result.kept}`)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `keepRecent?`: `number` - Keep N most recent versions
|
||||||
|
- `keepTagged?`: `boolean` - Always keep tagged versions (default: true)
|
||||||
|
- `olderThan?`: `number` - Only prune versions older than timestamp
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `versions.getLatest(entityId)` → `Promise<EntityVersion | null>`
|
||||||
|
|
||||||
|
Get latest version.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const latest = await brain.versions.getLatest('user-123')
|
||||||
|
if (latest) {
|
||||||
|
console.log(`Latest: v${latest.version} (${latest.tag})`)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `versions.getVersionByTag(entityId, tag)` → `Promise<EntityVersion | null>`
|
||||||
|
|
||||||
|
Get version by tag.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const beta = await brain.versions.getVersionByTag('user-123', 'beta')
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `versions.count(entityId)` → `Promise<number>`
|
||||||
|
|
||||||
|
Count versions for an entity.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const count = await brain.versions.count('user-123')
|
||||||
|
console.log(`${count} versions saved`)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `versions.hasVersions(entityId)` → `Promise<boolean>`
|
||||||
|
|
||||||
|
Check if entity has versions.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (await brain.versions.hasVersions('user-123')) {
|
||||||
|
console.log('Entity has version history')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Auto-Versioning Augmentation
|
||||||
|
|
||||||
|
Automatically create versions on entity updates.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { VersioningAugmentation } from '@soulcraft/brainy'
|
||||||
|
|
||||||
|
// Configure auto-versioning
|
||||||
|
const versioning = new VersioningAugmentation({
|
||||||
|
enabled: true,
|
||||||
|
onUpdate: true, // Version on update()
|
||||||
|
onDelete: false, // Don't version on delete
|
||||||
|
entities: ['user-*'], // Only version users
|
||||||
|
excludeEntities: ['temp-*'],
|
||||||
|
excludeTypes: ['temporary'],
|
||||||
|
keepRecent: 50, // Auto-prune old versions
|
||||||
|
keepTagged: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Apply augmentation
|
||||||
|
brain.augment(versioning)
|
||||||
|
|
||||||
|
// Now updates auto-create versions
|
||||||
|
await brain.update('user-123', { name: 'New Name' })
|
||||||
|
|
||||||
|
// Version automatically created!
|
||||||
|
const versions = await brain.versions.list('user-123')
|
||||||
|
console.log(`Auto-created version: ${versions[0].version}`)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
- `enabled`: `boolean` - Enable/disable augmentation
|
||||||
|
- `onUpdate`: `boolean` - Version on entity updates
|
||||||
|
- `onDelete`: `boolean` - Version before deletion
|
||||||
|
- `entities`: `string[]` - Entity ID patterns (glob-style)
|
||||||
|
- `excludeEntities`: `string[]` - Exclusion patterns
|
||||||
|
- `types`: `string[]` - Entity types to version
|
||||||
|
- `excludeTypes`: `string[]` - Types to exclude
|
||||||
|
- `keepRecent`: `number` - Auto-prune to keep N versions
|
||||||
|
- `keepTagged`: `boolean` - Always keep tagged versions
|
||||||
|
|
||||||
|
**Pattern Matching:**
|
||||||
|
- `['*']` - All entities
|
||||||
|
- `['user-*']` - All IDs starting with "user-"
|
||||||
|
- `['*-prod']` - All IDs ending with "-prod"
|
||||||
|
- `['user-*', 'account-*']` - Multiple patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Branch Isolation
|
||||||
|
|
||||||
|
Versions are isolated per branch.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Save version on main
|
||||||
|
await brain.versions.save('doc-1', { tag: 'main-v1' })
|
||||||
|
|
||||||
|
// Fork and create version
|
||||||
|
const feature = await brain.fork('feature')
|
||||||
|
await feature.update('doc-1', { content: 'Feature update' })
|
||||||
|
await feature.versions.save('doc-1', { tag: 'feature-v1' })
|
||||||
|
|
||||||
|
// Versions are isolated
|
||||||
|
const mainVersions = await brain.versions.list('doc-1')
|
||||||
|
const featureVersions = await feature.versions.list('doc-1')
|
||||||
|
|
||||||
|
console.log(mainVersions.length !== featureVersions.length) // true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
**Content-Addressable Storage:**
|
||||||
|
- SHA-256 hashing for deduplication
|
||||||
|
- Identical content = single storage blob
|
||||||
|
- Efficient for entities with few changes
|
||||||
|
|
||||||
|
**Metadata Indexing:**
|
||||||
|
- Leverages existing MetadataIndexManager
|
||||||
|
- Fast lookups by entity ID
|
||||||
|
- Version number indexing
|
||||||
|
|
||||||
|
**Storage Structure:**
|
||||||
|
```
|
||||||
|
_version:{entityId}:{versionNum}:{branch} // Version metadata
|
||||||
|
_version_blob:{contentHash} // Content blob (deduplicated)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Performance:**
|
||||||
|
- Version save: O(1) if duplicate, O(log N) for index update
|
||||||
|
- Version list: O(K) where K = version count
|
||||||
|
- Version restore: O(log N) lookup + O(1) restore
|
||||||
|
- Pruning: O(K) where K = versions pruned
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
#### Basic Versioning Workflow
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Create entity
|
||||||
|
await brain.add({
|
||||||
|
data: 'User profile',
|
||||||
|
id: 'user-123',
|
||||||
|
type: 'user',
|
||||||
|
metadata: { name: 'Alice', email: 'alice@example.com' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save v1
|
||||||
|
await brain.versions.save('user-123', { tag: 'v1.0' })
|
||||||
|
|
||||||
|
// Make changes
|
||||||
|
await brain.update('user-123', { name: 'Alice Smith' })
|
||||||
|
|
||||||
|
// Save v2
|
||||||
|
await brain.versions.save('user-123', { tag: 'v2.0' })
|
||||||
|
|
||||||
|
// Compare versions
|
||||||
|
const diff = await brain.versions.compare('user-123', 1, 2)
|
||||||
|
|
||||||
|
// Restore to v1 if needed
|
||||||
|
await brain.versions.restore('user-123', 'v1.0')
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Release Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Development workflow
|
||||||
|
await brain.update('app-config', { version: '1.0.0-alpha' })
|
||||||
|
await brain.versions.save('app-config', { tag: 'alpha' })
|
||||||
|
|
||||||
|
await brain.update('app-config', { version: '1.0.0-beta' })
|
||||||
|
await brain.versions.save('app-config', { tag: 'beta' })
|
||||||
|
|
||||||
|
await brain.update('app-config', { version: '1.0.0' })
|
||||||
|
await brain.versions.save('app-config', { tag: 'release' })
|
||||||
|
|
||||||
|
// Rollback to beta if issues found
|
||||||
|
await brain.versions.restore('app-config', 'beta')
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Audit Trail
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Track all changes
|
||||||
|
const versioning = new VersioningAugmentation({
|
||||||
|
enabled: true,
|
||||||
|
onUpdate: true,
|
||||||
|
entities: ['audit-*'],
|
||||||
|
keepRecent: 100 // Keep 100 versions for audit
|
||||||
|
})
|
||||||
|
|
||||||
|
brain.augment(versioning)
|
||||||
|
|
||||||
|
// All updates now tracked
|
||||||
|
await brain.update('audit-record-1', { status: 'modified' })
|
||||||
|
await brain.update('audit-record-1', { status: 'approved' })
|
||||||
|
|
||||||
|
// View complete history
|
||||||
|
const versions = await brain.versions.list('audit-record-1')
|
||||||
|
versions.forEach(v => {
|
||||||
|
console.log(`${v.createdAt}: ${v.description}`)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**[📖 Complete Versioning Guide →](../features/entity-versioning.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Virtual Filesystem (VFS)
|
## Virtual Filesystem (VFS)
|
||||||
|
|
||||||
**Auto-initialized in v5.1.0!** Access via `brain.vfs` (property, not method).
|
**Auto-initialized in v5.1.0!** Access via `brain.vfs` (property, not method).
|
||||||
|
|
||||||
|
### Filtering VFS Entities
|
||||||
|
|
||||||
|
**NEW in v5.3.0:** All VFS entities (files/folders) have `metadata.isVFSEntity: true` set automatically.
|
||||||
|
|
||||||
|
Use this to filter VFS entities from semantic search results:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Exclude VFS entities from semantic search
|
||||||
|
const semanticOnly = await brain.find({
|
||||||
|
query: 'artificial intelligence',
|
||||||
|
where: {
|
||||||
|
isVFSEntity: { notEquals: true } // Only semantic entities
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Or filter to ONLY VFS entities
|
||||||
|
const vfsOnly = await brain.find({
|
||||||
|
where: {
|
||||||
|
isVFSEntity: { equals: true } // Only VFS files/folders
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if an entity is a VFS entity
|
||||||
|
if (entity.metadata.isVFSEntity === true) {
|
||||||
|
console.log('This is a VFS file or folder')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this matters:** Without filtering, VFS files/folders can appear in concept explorers and semantic search results where they don't belong.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Basic File Operations
|
### Basic File Operations
|
||||||
|
|
||||||
#### `vfs.readFile(path, options?)` → `Promise<Buffer>`
|
#### `vfs.readFile(path, options?)` → `Promise<Buffer>`
|
||||||
|
|
@ -1253,7 +1679,17 @@ const tree = await brain.vfs.getTreeStructure('/projects', {
|
||||||
|
|
||||||
## What's New in v5.0
|
## What's New in v5.0
|
||||||
|
|
||||||
### v5.1.0 (Latest)
|
### v5.3.0 (Latest)
|
||||||
|
|
||||||
|
- ✅ **Entity Versioning** - Git-style versioning for individual entities
|
||||||
|
- ✅ **Content-Addressable Storage** - SHA-256 deduplication for versions
|
||||||
|
- ✅ **Auto-Versioning Augmentation** - Automatic version creation on updates
|
||||||
|
- ✅ **Branch-Isolated Versions** - Versions isolated per branch
|
||||||
|
- ✅ **VFS Entity Filtering** - All VFS entities now have `isVFSEntity: true` flag
|
||||||
|
- ✅ **CRITICAL FIX:** commit() now updates branch refs correctly (brainy.ts:2385)
|
||||||
|
- ✅ **CRITICAL FIX:** VFS entities now properly flagged for filtering
|
||||||
|
|
||||||
|
### v5.1.0
|
||||||
|
|
||||||
- ✅ **VFS Auto-Initialization** - No more separate `vfs.init()` calls
|
- ✅ **VFS Auto-Initialization** - No more separate `vfs.init()` calls
|
||||||
- ✅ **VFS Property Access** - Use `brain.vfs.method()` instead of `brain.vfs().method()`
|
- ✅ **VFS Property Access** - Use `brain.vfs.method()` instead of `brain.vfs().method()`
|
||||||
|
|
|
||||||
525
src/augmentations/versioningAugmentation.ts
Normal file
525
src/augmentations/versioningAugmentation.ts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ import { NaturalLanguageProcessor } from './neural/naturalLanguageProcessor.js'
|
||||||
import { NeuralEntityExtractor, ExtractedEntity } from './neural/entityExtractor.js'
|
import { NeuralEntityExtractor, ExtractedEntity } from './neural/entityExtractor.js'
|
||||||
import { TripleIntelligenceSystem } from './triple/TripleIntelligenceSystem.js'
|
import { TripleIntelligenceSystem } from './triple/TripleIntelligenceSystem.js'
|
||||||
import { VirtualFileSystem } from './vfs/VirtualFileSystem.js'
|
import { VirtualFileSystem } from './vfs/VirtualFileSystem.js'
|
||||||
|
import { VersioningAPI } from './versioning/VersioningAPI.js'
|
||||||
import { MetadataIndexManager } from './utils/metadataIndex.js'
|
import { MetadataIndexManager } from './utils/metadataIndex.js'
|
||||||
import { GraphAdjacencyIndex } from './graph/graphAdjacencyIndex.js'
|
import { GraphAdjacencyIndex } from './graph/graphAdjacencyIndex.js'
|
||||||
import { CommitBuilder } from './storage/cow/CommitObject.js'
|
import { CommitBuilder } from './storage/cow/CommitObject.js'
|
||||||
|
|
@ -95,6 +96,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
private _nlp?: NaturalLanguageProcessor
|
private _nlp?: NaturalLanguageProcessor
|
||||||
private _extractor?: NeuralEntityExtractor
|
private _extractor?: NeuralEntityExtractor
|
||||||
private _tripleIntelligence?: TripleIntelligenceSystem
|
private _tripleIntelligence?: TripleIntelligenceSystem
|
||||||
|
private _versions?: VersioningAPI
|
||||||
private _vfs?: VirtualFileSystem
|
private _vfs?: VirtualFileSystem
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
@ -2380,7 +2382,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
const commitHash = await builder.build()
|
const commitHash = await builder.build()
|
||||||
|
|
||||||
// Update branch ref to point to new commit
|
// Update branch ref to point to new commit
|
||||||
await refManager.setRef(`heads/${currentBranch}`, commitHash, {
|
await refManager.setRef(currentBranch, commitHash, {
|
||||||
author: options?.author || 'unknown',
|
author: options?.author || 'unknown',
|
||||||
message: options?.message || 'Snapshot commit'
|
message: options?.message || 'Snapshot commit'
|
||||||
})
|
})
|
||||||
|
|
@ -2937,6 +2939,38 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
return this._neural
|
return this._neural
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versioning API - Entity version control (v5.3.0)
|
||||||
|
*
|
||||||
|
* Provides entity-level versioning with:
|
||||||
|
* - save() - Create version of entity
|
||||||
|
* - restore() - Restore entity to specific version
|
||||||
|
* - list() - List all versions of entity
|
||||||
|
* - compare() - Deep diff between versions
|
||||||
|
* - prune() - Remove old versions (retention policies)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Save current state
|
||||||
|
* const version = await brain.versions.save('user-123', { tag: 'v1.0' })
|
||||||
|
*
|
||||||
|
* // List versions
|
||||||
|
* const versions = await brain.versions.list('user-123')
|
||||||
|
*
|
||||||
|
* // Restore to previous version
|
||||||
|
* await brain.versions.restore('user-123', 5)
|
||||||
|
*
|
||||||
|
* // Compare versions
|
||||||
|
* const diff = await brain.versions.compare('user-123', 2, 5)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
get versions(): VersioningAPI {
|
||||||
|
if (!this._versions) {
|
||||||
|
this._versions = new VersioningAPI(this as any)
|
||||||
|
}
|
||||||
|
return this._versions
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Natural Language Processing API
|
* Natural Language Processing API
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ export class RefManager {
|
||||||
// Check if ref exists
|
// Check if ref exists
|
||||||
const existing = await this.getRef(fullName)
|
const existing = await this.getRef(fullName)
|
||||||
|
|
||||||
|
|
||||||
// Handle createOnly
|
// Handle createOnly
|
||||||
if (options.createOnly && existing) {
|
if (options.createOnly && existing) {
|
||||||
throw new Error(`Ref already exists: ${fullName}`)
|
throw new Error(`Ref already exists: ${fullName}`)
|
||||||
|
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -209,7 +209,8 @@ export class VirtualFileSystem implements IVirtualFileSystem {
|
||||||
path: '/',
|
path: '/',
|
||||||
name: '',
|
name: '',
|
||||||
vfsType: 'directory',
|
vfsType: 'directory',
|
||||||
isVFS: true, // v4.3.3: Mark as VFS entity
|
isVFS: true, // v4.3.3: Mark as VFS entity (internal)
|
||||||
|
isVFSEntity: true, // v5.3.0: Explicit flag for developer filtering
|
||||||
size: 0,
|
size: 0,
|
||||||
permissions: 0o755,
|
permissions: 0o755,
|
||||||
owner: 'root',
|
owner: 'root',
|
||||||
|
|
@ -231,7 +232,8 @@ export class VirtualFileSystem implements IVirtualFileSystem {
|
||||||
path: '/',
|
path: '/',
|
||||||
name: '',
|
name: '',
|
||||||
vfsType: 'directory',
|
vfsType: 'directory',
|
||||||
isVFS: true, // v4.3.3: Mark as VFS entity
|
isVFS: true, // v4.3.3: Mark as VFS entity (internal)
|
||||||
|
isVFSEntity: true, // v5.3.0: Explicit flag for developer filtering
|
||||||
size: 0,
|
size: 0,
|
||||||
permissions: 0o755,
|
permissions: 0o755,
|
||||||
owner: 'root',
|
owner: 'root',
|
||||||
|
|
@ -357,7 +359,8 @@ export class VirtualFileSystem implements IVirtualFileSystem {
|
||||||
name,
|
name,
|
||||||
parent: parentId,
|
parent: parentId,
|
||||||
vfsType: 'file',
|
vfsType: 'file',
|
||||||
isVFS: true, // v4.3.3: Mark as VFS entity
|
isVFS: true, // v4.3.3: Mark as VFS entity (internal)
|
||||||
|
isVFSEntity: true, // v5.3.0: Explicit flag for developer filtering
|
||||||
size: buffer.length,
|
size: buffer.length,
|
||||||
mimeType,
|
mimeType,
|
||||||
extension: this.getExtension(name),
|
extension: this.getExtension(name),
|
||||||
|
|
@ -713,7 +716,8 @@ export class VirtualFileSystem implements IVirtualFileSystem {
|
||||||
name,
|
name,
|
||||||
parent: parentId,
|
parent: parentId,
|
||||||
vfsType: 'directory',
|
vfsType: 'directory',
|
||||||
isVFS: true, // v4.3.3: Mark as VFS entity
|
isVFS: true, // v4.3.3: Mark as VFS entity (internal)
|
||||||
|
isVFSEntity: true, // v5.3.0: Explicit flag for developer filtering
|
||||||
size: 0,
|
size: 0,
|
||||||
permissions: options?.mode || this.config.permissions?.defaultDirectory || 0o755,
|
permissions: options?.mode || this.config.permissions?.defaultDirectory || 0o755,
|
||||||
owner: 'user',
|
owner: 'user',
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@ export interface VFSMetadata {
|
||||||
name: string // Filename or directory name
|
name: string // Filename or directory name
|
||||||
parent?: string // Parent directory entity ID
|
parent?: string // Parent directory entity ID
|
||||||
vfsType: 'file' | 'directory' | 'symlink'
|
vfsType: 'file' | 'directory' | 'symlink'
|
||||||
isVFS?: boolean // v4.3.3: Mark as VFS entity (separates from knowledge graph)
|
isVFS?: boolean // v4.3.3: Mark as VFS entity (internal, separates from knowledge graph)
|
||||||
|
isVFSEntity?: boolean // v5.3.0: Explicit developer-facing flag for filtering VFS entities
|
||||||
|
|
||||||
// File attributes
|
// File attributes
|
||||||
size: number // Size in bytes (0 for directories)
|
size: number // Size in bytes (0 for directories)
|
||||||
|
|
|
||||||
104
tests/integration/commit-ref-bug.test.ts
Normal file
104
tests/integration/commit-ref-bug.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
/**
|
||||||
|
* Integration test to reproduce commit() ref update bug
|
||||||
|
* Bug: commit() creates blobs but doesn't update branch refs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { Brainy } from '../../src/brainy.js'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
import * as zlib from 'zlib'
|
||||||
|
|
||||||
|
const TEST_DATA_PATH = './test-commit-ref-bug-data'
|
||||||
|
|
||||||
|
describe('Commit Ref Update Bug (v5.2.0)', () => {
|
||||||
|
let brain: Brainy
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clean up test data
|
||||||
|
if (fs.existsSync(TEST_DATA_PATH)) {
|
||||||
|
fs.rmSync(TEST_DATA_PATH, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
brain = new Brainy({
|
||||||
|
storage: {
|
||||||
|
type: 'filesystem',
|
||||||
|
path: TEST_DATA_PATH,
|
||||||
|
branch: 'main',
|
||||||
|
enableCompression: true
|
||||||
|
},
|
||||||
|
silent: false // Enable logging
|
||||||
|
})
|
||||||
|
|
||||||
|
await brain.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (fs.existsSync(TEST_DATA_PATH)) {
|
||||||
|
fs.rmSync(TEST_DATA_PATH, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update branch ref after commit', async () => {
|
||||||
|
// Add an entity
|
||||||
|
await brain.add({
|
||||||
|
data: 'Test entity',
|
||||||
|
type: 'concept',
|
||||||
|
metadata: { test: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create first commit
|
||||||
|
const commit1Hash = await brain.commit({
|
||||||
|
message: 'First commit',
|
||||||
|
author: 'test@example.com'
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`\n=== Commit 1 created: ${commit1Hash} ===`)
|
||||||
|
|
||||||
|
// Check the ref file directly
|
||||||
|
const refPath = path.join(TEST_DATA_PATH, '_cow', 'ref:refs', 'heads', 'main.gz')
|
||||||
|
|
||||||
|
expect(fs.existsSync(refPath), 'Ref file should exist').toBe(true)
|
||||||
|
|
||||||
|
const refContent1 = JSON.parse(
|
||||||
|
zlib.gunzipSync(fs.readFileSync(refPath)).toString()
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('Ref after commit 1:', {
|
||||||
|
commitHash: refContent1.commitHash,
|
||||||
|
updatedAt: new Date(refContent1.updatedAt).toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
// THIS IS THE BUG: commitHash should equal commit1Hash
|
||||||
|
expect(refContent1.commitHash).toBe(commit1Hash)
|
||||||
|
expect(refContent1.commitHash).not.toBe('0'.repeat(64))
|
||||||
|
|
||||||
|
// Add another entity
|
||||||
|
await brain.add({
|
||||||
|
data: 'Second entity',
|
||||||
|
type: 'concept'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create second commit
|
||||||
|
const commit2Hash = await brain.commit({
|
||||||
|
message: 'Second commit',
|
||||||
|
author: 'test@example.com'
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`\n=== Commit 2 created: ${commit2Hash} ===`)
|
||||||
|
|
||||||
|
// Check ref again
|
||||||
|
const refContent2 = JSON.parse(
|
||||||
|
zlib.gunzipSync(fs.readFileSync(refPath)).toString()
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('Ref after commit 2:', {
|
||||||
|
commitHash: refContent2.commitHash,
|
||||||
|
updatedAt: new Date(refContent2.updatedAt).toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
// THIS IS THE BUG: commitHash should equal commit2Hash
|
||||||
|
expect(refContent2.commitHash).toBe(commit2Hash)
|
||||||
|
expect(refContent2.updatedAt).toBeGreaterThan(refContent1.updatedAt)
|
||||||
|
})
|
||||||
|
})
|
||||||
409
tests/integration/versioning.test.ts
Normal file
409
tests/integration/versioning.test.ts
Normal file
|
|
@ -0,0 +1,409 @@
|
||||||
|
/**
|
||||||
|
* Entity Versioning Integration Tests (v5.3.0)
|
||||||
|
*
|
||||||
|
* Tests the complete versioning workflow:
|
||||||
|
* - Save versions
|
||||||
|
* - List versions
|
||||||
|
* - Restore versions
|
||||||
|
* - Compare versions
|
||||||
|
* - Prune versions
|
||||||
|
* - Auto-versioning augmentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { Brainy } from '../../src/brainy.js'
|
||||||
|
import type { EntityVersion } from '../../src/versioning/VersionManager.js'
|
||||||
|
import { VersioningAugmentation } from '../../src/augmentations/versioningAugmentation.js'
|
||||||
|
|
||||||
|
describe('Entity Versioning (v5.3.0)', () => {
|
||||||
|
let brain: Brainy
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
brain = new Brainy({
|
||||||
|
storage: { type: 'memory' },
|
||||||
|
silent: true
|
||||||
|
})
|
||||||
|
await brain.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Core Versioning API', () => {
|
||||||
|
it('should save and retrieve versions', async () => {
|
||||||
|
// Add initial entity
|
||||||
|
await brain.add({
|
||||||
|
data: 'Alice',
|
||||||
|
id: 'user-123',
|
||||||
|
type: 'user',
|
||||||
|
metadata: {
|
||||||
|
name: 'Alice',
|
||||||
|
email: 'alice@example.com'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save version 1
|
||||||
|
const v1 = await brain.versions.save('user-123', {
|
||||||
|
tag: 'v1.0',
|
||||||
|
description: 'Initial version'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(v1.version).toBe(1)
|
||||||
|
expect(v1.entityId).toBe('user-123')
|
||||||
|
expect(v1.tag).toBe('v1.0')
|
||||||
|
expect(v1.contentHash).toBeDefined()
|
||||||
|
|
||||||
|
// Update entity
|
||||||
|
await brain.update('user-123', { name: 'Alice Smith' })
|
||||||
|
|
||||||
|
// Save version 2
|
||||||
|
const v2 = await brain.versions.save('user-123', {
|
||||||
|
tag: 'v2.0',
|
||||||
|
description: 'Updated name'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(v2.version).toBe(2)
|
||||||
|
expect(v2.entityId).toBe('user-123')
|
||||||
|
|
||||||
|
// List versions
|
||||||
|
const versions = await brain.versions.list('user-123')
|
||||||
|
expect(versions).toHaveLength(2)
|
||||||
|
expect(versions[0].version).toBe(2) // Newest first
|
||||||
|
expect(versions[1].version).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should deduplicate identical content', async () => {
|
||||||
|
await brain.add({
|
||||||
|
data: 'Doc',
|
||||||
|
id: 'doc-1',
|
||||||
|
type: 'document',
|
||||||
|
metadata: {
|
||||||
|
name: 'Doc',
|
||||||
|
content: 'Hello'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save version 1
|
||||||
|
const v1 = await brain.versions.save('doc-1', { tag: 'v1' })
|
||||||
|
|
||||||
|
// Save again without changes
|
||||||
|
const v2 = await brain.versions.save('doc-1', { tag: 'v2' })
|
||||||
|
|
||||||
|
// Should return existing version (same content hash)
|
||||||
|
expect(v2.version).toBe(v1.version)
|
||||||
|
expect(v2.contentHash).toBe(v1.contentHash)
|
||||||
|
|
||||||
|
// Only one version should exist
|
||||||
|
const versions = await brain.versions.list('doc-1')
|
||||||
|
expect(versions).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should restore to previous version', async () => {
|
||||||
|
await brain.add({
|
||||||
|
data: 'Config',
|
||||||
|
id: 'config-1',
|
||||||
|
type: 'thing',
|
||||||
|
metadata: {
|
||||||
|
name: 'Config',
|
||||||
|
settings: { theme: 'light' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save v1
|
||||||
|
await brain.versions.save('config-1', { tag: 'v1' })
|
||||||
|
|
||||||
|
// Update
|
||||||
|
await brain.update('config-1', { settings: { theme: 'dark' } })
|
||||||
|
|
||||||
|
// Save v2
|
||||||
|
await brain.versions.save('config-1', { tag: 'v2' })
|
||||||
|
|
||||||
|
// Verify current state
|
||||||
|
let current = await brain.getNounMetadata('config-1')
|
||||||
|
expect(current?.metadata?.settings?.theme).toBe('dark')
|
||||||
|
|
||||||
|
// Restore to v1
|
||||||
|
await brain.versions.restore('config-1', 1)
|
||||||
|
|
||||||
|
// Verify restored state
|
||||||
|
current = await brain.getNounMetadata('config-1')
|
||||||
|
expect(current?.metadata?.settings?.theme).toBe('light')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should compare versions', async () => {
|
||||||
|
await brain.add({
|
||||||
|
data: 'Bob',
|
||||||
|
id: 'user-456',
|
||||||
|
type: 'user',
|
||||||
|
metadata: {
|
||||||
|
name: 'Bob',
|
||||||
|
email: 'bob@example.com',
|
||||||
|
age: 30
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await brain.versions.save('user-456', { tag: 'v1' })
|
||||||
|
|
||||||
|
// Update
|
||||||
|
await brain.update('user-456', {
|
||||||
|
name: 'Robert',
|
||||||
|
email: 'robert@example.com',
|
||||||
|
city: 'NYC'
|
||||||
|
})
|
||||||
|
|
||||||
|
await brain.versions.save('user-456', { tag: 'v2' })
|
||||||
|
|
||||||
|
// Compare versions
|
||||||
|
const diff = await brain.versions.compare('user-456', 1, 2)
|
||||||
|
|
||||||
|
expect(diff.totalChanges).toBeGreaterThan(0)
|
||||||
|
expect(diff.modified.length).toBeGreaterThan(0)
|
||||||
|
expect(diff.added.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Check specific changes
|
||||||
|
const nameChange = diff.modified.find(c => c.path.includes('name'))
|
||||||
|
expect(nameChange).toBeDefined()
|
||||||
|
expect(nameChange?.oldValue).toBe('Bob')
|
||||||
|
expect(nameChange?.newValue).toBe('Robert')
|
||||||
|
|
||||||
|
const cityAdd = diff.added.find(c => c.path.includes('city'))
|
||||||
|
expect(cityAdd).toBeDefined()
|
||||||
|
expect(cityAdd?.newValue).toBe('NYC')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should get version content without restoring', async () => {
|
||||||
|
await brain.add({
|
||||||
|
data: 'Note',
|
||||||
|
id: 'note-1',
|
||||||
|
type: 'document',
|
||||||
|
metadata: {
|
||||||
|
name: 'Note',
|
||||||
|
content: 'Version 1'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await brain.versions.save('note-1', { tag: 'v1' })
|
||||||
|
|
||||||
|
await brain.update('note-1', { content: 'Version 2' })
|
||||||
|
await brain.versions.save('note-1', { tag: 'v2' })
|
||||||
|
|
||||||
|
// Get v1 content without restoring
|
||||||
|
const v1Content = await brain.versions.getContent('note-1', 1)
|
||||||
|
expect(v1Content.metadata.content).toBe('Version 1')
|
||||||
|
|
||||||
|
// Current should still be v2
|
||||||
|
const current = await brain.getNounMetadata('note-1')
|
||||||
|
expect(current?.metadata?.content).toBe('Version 2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should prune old versions', async () => {
|
||||||
|
await brain.add({
|
||||||
|
data: 'Log',
|
||||||
|
id: 'log-1',
|
||||||
|
type: 'document',
|
||||||
|
metadata: {
|
||||||
|
name: 'Log'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create 10 versions
|
||||||
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
await brain.update('log-1', { content: `Entry ${i}` })
|
||||||
|
await brain.versions.save('log-1', { tag: `v${i}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all 10 exist
|
||||||
|
let versions = await brain.versions.list('log-1')
|
||||||
|
expect(versions.length).toBeGreaterThanOrEqual(10)
|
||||||
|
|
||||||
|
// Prune to keep only 5 most recent
|
||||||
|
const result = await brain.versions.prune('log-1', {
|
||||||
|
keepRecent: 5,
|
||||||
|
keepTagged: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.deleted).toBeGreaterThan(0)
|
||||||
|
expect(result.kept).toBe(5)
|
||||||
|
|
||||||
|
// Verify only 5 remain
|
||||||
|
versions = await brain.versions.list('log-1')
|
||||||
|
expect(versions).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support version tags', async () => {
|
||||||
|
await brain.add({
|
||||||
|
data: 'App',
|
||||||
|
id: 'app-1',
|
||||||
|
type: 'thing',
|
||||||
|
metadata: {
|
||||||
|
name: 'App'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await brain.versions.save('app-1', { tag: 'alpha' })
|
||||||
|
await brain.update('app-1', { version: '0.2' })
|
||||||
|
await brain.versions.save('app-1', { tag: 'beta' })
|
||||||
|
await brain.update('app-1', { version: '1.0' })
|
||||||
|
await brain.versions.save('app-1', { tag: 'release' })
|
||||||
|
|
||||||
|
// Get by tag
|
||||||
|
const beta = await brain.versions.getVersionByTag('app-1', 'beta')
|
||||||
|
expect(beta).toBeDefined()
|
||||||
|
expect(beta?.tag).toBe('beta')
|
||||||
|
|
||||||
|
// Restore by tag
|
||||||
|
await brain.versions.restore('app-1', 'beta')
|
||||||
|
const current = await brain.getNounMetadata('app-1')
|
||||||
|
expect(current?.metadata?.version).toBe('0.2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support undo/revert', async () => {
|
||||||
|
await brain.add({
|
||||||
|
data: 'Data',
|
||||||
|
id: 'data-1',
|
||||||
|
type: 'thing',
|
||||||
|
metadata: {
|
||||||
|
name: 'Data',
|
||||||
|
value: 100
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await brain.versions.save('data-1')
|
||||||
|
await brain.update('data-1', { value: 200 })
|
||||||
|
await brain.versions.save('data-1')
|
||||||
|
|
||||||
|
// Make a bad change
|
||||||
|
await brain.update('data-1', { value: 999 })
|
||||||
|
|
||||||
|
// Undo (reverts to previous version)
|
||||||
|
await brain.versions.undo('data-1')
|
||||||
|
|
||||||
|
const current = await brain.getNounMetadata('data-1')
|
||||||
|
expect(current?.metadata?.value).toBe(200)
|
||||||
|
|
||||||
|
// Revert (alias for undo)
|
||||||
|
await brain.update('data-1', { value: 999 })
|
||||||
|
await brain.versions.revert('data-1')
|
||||||
|
|
||||||
|
const reverted = await brain.getNounMetadata('data-1')
|
||||||
|
expect(reverted?.metadata?.value).toBe(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Auto-Versioning Augmentation', () => {
|
||||||
|
it('should auto-version on update when enabled', async () => {
|
||||||
|
// Add augmentation
|
||||||
|
const augmentation = new VersioningAugmentation({
|
||||||
|
enabled: true,
|
||||||
|
onUpdate: true,
|
||||||
|
entities: ['*'],
|
||||||
|
keepRecent: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
brain.augment(augmentation as any)
|
||||||
|
|
||||||
|
await brain.add({
|
||||||
|
data: 'Auto User',
|
||||||
|
id: 'auto-1',
|
||||||
|
type: 'user',
|
||||||
|
metadata: {
|
||||||
|
name: 'Auto User'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// No versions yet
|
||||||
|
expect(await brain.versions.count('auto-1')).toBe(0)
|
||||||
|
|
||||||
|
// Update should auto-create version
|
||||||
|
await brain.update('auto-1', { name: 'Auto User Updated' })
|
||||||
|
|
||||||
|
// Give augmentation time to process
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
|
||||||
|
// Should have auto-created version
|
||||||
|
const count = await brain.versions.count('auto-1')
|
||||||
|
expect(count).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter entities by ID pattern', async () => {
|
||||||
|
const augmentation = new VersioningAugmentation({
|
||||||
|
enabled: true,
|
||||||
|
onUpdate: true,
|
||||||
|
entities: ['versioned-*'],
|
||||||
|
excludeEntities: ['temp-*']
|
||||||
|
})
|
||||||
|
|
||||||
|
brain.augment(augmentation as any)
|
||||||
|
|
||||||
|
// This should be versioned
|
||||||
|
await brain.add({ data: 'V1', id: 'versioned-1', type: 'thing', metadata: { name: 'V1' } })
|
||||||
|
await brain.update('versioned-1', { name: 'V1 Updated' })
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
|
||||||
|
// This should NOT be versioned
|
||||||
|
await brain.add({ data: 'O1', id: 'other-1', type: 'thing', metadata: { name: 'O1' } })
|
||||||
|
await brain.update('other-1', { name: 'O1 Updated' })
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
|
||||||
|
expect(await brain.versions.count('versioned-1')).toBeGreaterThan(0)
|
||||||
|
expect(await brain.versions.count('other-1')).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Branch Isolation', () => {
|
||||||
|
it('should isolate versions by branch', async () => {
|
||||||
|
await brain.add({
|
||||||
|
data: 'Test',
|
||||||
|
id: 'branch-test',
|
||||||
|
type: 'thing',
|
||||||
|
metadata: {
|
||||||
|
name: 'Test'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save on main
|
||||||
|
await brain.versions.save('branch-test', { tag: 'main-v1' })
|
||||||
|
|
||||||
|
// Switch to feature branch
|
||||||
|
await brain.fork('feature')
|
||||||
|
|
||||||
|
// Update on feature
|
||||||
|
await brain.update('branch-test', { name: 'Feature Update' })
|
||||||
|
await brain.versions.save('branch-test', { tag: 'feature-v1' })
|
||||||
|
|
||||||
|
// Versions on feature branch
|
||||||
|
const featureVersions = await brain.versions.list('branch-test')
|
||||||
|
expect(featureVersions.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Switch back to main
|
||||||
|
await brain.checkout('main')
|
||||||
|
|
||||||
|
// Versions on main branch
|
||||||
|
const mainVersions = await brain.versions.list('branch-test')
|
||||||
|
|
||||||
|
// Should be isolated
|
||||||
|
expect(mainVersions.length).not.toBe(featureVersions.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle non-existent entities gracefully', async () => {
|
||||||
|
await expect(
|
||||||
|
brain.versions.save('nonexistent', { tag: 'v1' })
|
||||||
|
).rejects.toThrow('Entity nonexistent not found')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty version history', async () => {
|
||||||
|
await brain.add({ data: 'New', id: 'new-1', type: 'thing', metadata: { name: 'New' } })
|
||||||
|
|
||||||
|
expect(await brain.versions.count('new-1')).toBe(0)
|
||||||
|
expect(await brain.versions.hasVersions('new-1')).toBe(false)
|
||||||
|
expect(await brain.versions.getLatest('new-1')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle version not found', async () => {
|
||||||
|
await brain.add({ data: 'Test', id: 'test-1', type: 'thing', metadata: { name: 'Test' } })
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
brain.versions.restore('test-1', 999)
|
||||||
|
).rejects.toThrow('Version 999 not found')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
538
tests/unit/versioning/VersionDiff.test.ts
Normal file
538
tests/unit/versioning/VersionDiff.test.ts
Normal file
|
|
@ -0,0 +1,538 @@
|
||||||
|
/**
|
||||||
|
* VersionDiff Unit Tests (v5.3.0)
|
||||||
|
*
|
||||||
|
* Tests deep object comparison:
|
||||||
|
* - Added fields
|
||||||
|
* - Removed fields
|
||||||
|
* - Modified fields
|
||||||
|
* - Type changes
|
||||||
|
* - Nested object comparison
|
||||||
|
* - Array comparison
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { compareEntityVersions } from '../../../src/versioning/VersionDiff.js'
|
||||||
|
import type { NounMetadata } from '../../../src/coreTypes.js'
|
||||||
|
|
||||||
|
describe('VersionDiff', () => {
|
||||||
|
describe('compareEntityVersions()', () => {
|
||||||
|
it('should detect added fields', () => {
|
||||||
|
const from: NounMetadata = {
|
||||||
|
id: 'user-1',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice',
|
||||||
|
metadata: { email: 'alice@example.com' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const to: NounMetadata = {
|
||||||
|
id: 'user-1',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice',
|
||||||
|
metadata: {
|
||||||
|
email: 'alice@example.com',
|
||||||
|
city: 'NYC',
|
||||||
|
age: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = compareEntityVersions(from, to, {
|
||||||
|
entityId: 'user-1',
|
||||||
|
fromVersion: 1,
|
||||||
|
toVersion: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(diff.added.length).toBe(2)
|
||||||
|
expect(diff.added.some(c => c.path.includes('city'))).toBe(true)
|
||||||
|
expect(diff.added.some(c => c.path.includes('age'))).toBe(true)
|
||||||
|
expect(diff.added.find(c => c.path.includes('city'))?.newValue).toBe('NYC')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect removed fields', () => {
|
||||||
|
const from: NounMetadata = {
|
||||||
|
id: 'user-1',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice',
|
||||||
|
metadata: {
|
||||||
|
email: 'alice@example.com',
|
||||||
|
city: 'NYC',
|
||||||
|
age: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const to: NounMetadata = {
|
||||||
|
id: 'user-1',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice',
|
||||||
|
metadata: { email: 'alice@example.com' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = compareEntityVersions(from, to, {
|
||||||
|
entityId: 'user-1',
|
||||||
|
fromVersion: 1,
|
||||||
|
toVersion: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(diff.removed.length).toBe(2)
|
||||||
|
expect(diff.removed.some(c => c.path.includes('city'))).toBe(true)
|
||||||
|
expect(diff.removed.some(c => c.path.includes('age'))).toBe(true)
|
||||||
|
expect(diff.removed.find(c => c.path.includes('city'))?.oldValue).toBe('NYC')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect modified fields', () => {
|
||||||
|
const from: NounMetadata = {
|
||||||
|
id: 'user-1',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice',
|
||||||
|
metadata: {
|
||||||
|
email: 'alice@example.com',
|
||||||
|
status: 'active'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const to: NounMetadata = {
|
||||||
|
id: 'user-1',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice Smith',
|
||||||
|
metadata: {
|
||||||
|
email: 'alice.smith@example.com',
|
||||||
|
status: 'active'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = compareEntityVersions(from, to, {
|
||||||
|
entityId: 'user-1',
|
||||||
|
fromVersion: 1,
|
||||||
|
toVersion: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(diff.modified.length).toBe(2)
|
||||||
|
|
||||||
|
const nameChange = diff.modified.find(c => c.path.includes('name'))
|
||||||
|
expect(nameChange?.oldValue).toBe('Alice')
|
||||||
|
expect(nameChange?.newValue).toBe('Alice Smith')
|
||||||
|
|
||||||
|
const emailChange = diff.modified.find(c => c.path.includes('email'))
|
||||||
|
expect(emailChange?.oldValue).toBe('alice@example.com')
|
||||||
|
expect(emailChange?.newValue).toBe('alice.smith@example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect type changes', () => {
|
||||||
|
const from: NounMetadata = {
|
||||||
|
id: 'config-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Config',
|
||||||
|
metadata: { value: '100' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const to: NounMetadata = {
|
||||||
|
id: 'config-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Config',
|
||||||
|
metadata: { value: 100 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = compareEntityVersions(from, to, {
|
||||||
|
entityId: 'config-1',
|
||||||
|
fromVersion: 1,
|
||||||
|
toVersion: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(diff.typeChanged.length).toBe(1)
|
||||||
|
const typeChange = diff.typeChanged[0]
|
||||||
|
expect(typeChange.path).toContain('value')
|
||||||
|
expect(typeChange.oldType).toBe('string')
|
||||||
|
expect(typeChange.newType).toBe('number')
|
||||||
|
expect(typeChange.oldValue).toBe('100')
|
||||||
|
expect(typeChange.newValue).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect identical versions', () => {
|
||||||
|
const entity: NounMetadata = {
|
||||||
|
id: 'doc-1',
|
||||||
|
type: 'document',
|
||||||
|
name: 'Doc',
|
||||||
|
metadata: { content: 'Unchanged' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = compareEntityVersions(entity, entity, {
|
||||||
|
entityId: 'doc-1',
|
||||||
|
fromVersion: 1,
|
||||||
|
toVersion: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(diff.identical).toBe(true)
|
||||||
|
expect(diff.totalChanges).toBe(0)
|
||||||
|
expect(diff.added).toHaveLength(0)
|
||||||
|
expect(diff.removed).toHaveLength(0)
|
||||||
|
expect(diff.modified).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle nested object changes', () => {
|
||||||
|
const from: NounMetadata = {
|
||||||
|
id: 'user-1',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice',
|
||||||
|
metadata: {
|
||||||
|
address: {
|
||||||
|
street: '123 Main St',
|
||||||
|
city: 'NYC'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const to: NounMetadata = {
|
||||||
|
id: 'user-1',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice',
|
||||||
|
metadata: {
|
||||||
|
address: {
|
||||||
|
street: '456 Oak Ave',
|
||||||
|
city: 'NYC',
|
||||||
|
zip: '10001'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = compareEntityVersions(from, to, {
|
||||||
|
entityId: 'user-1',
|
||||||
|
fromVersion: 1,
|
||||||
|
toVersion: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(diff.modified.some(c => c.path.includes('address.street'))).toBe(true)
|
||||||
|
expect(diff.added.some(c => c.path.includes('address.zip'))).toBe(true)
|
||||||
|
|
||||||
|
const streetChange = diff.modified.find(c => c.path.includes('address.street'))
|
||||||
|
expect(streetChange?.oldValue).toBe('123 Main St')
|
||||||
|
expect(streetChange?.newValue).toBe('456 Oak Ave')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle array changes', () => {
|
||||||
|
const from: NounMetadata = {
|
||||||
|
id: 'doc-1',
|
||||||
|
type: 'document',
|
||||||
|
name: 'Doc',
|
||||||
|
metadata: {
|
||||||
|
tags: ['a', 'b', 'c']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const to: NounMetadata = {
|
||||||
|
id: 'doc-1',
|
||||||
|
type: 'document',
|
||||||
|
name: 'Doc',
|
||||||
|
metadata: {
|
||||||
|
tags: ['a', 'b', 'd']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = compareEntityVersions(from, to, {
|
||||||
|
entityId: 'doc-1',
|
||||||
|
fromVersion: 1,
|
||||||
|
toVersion: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
// Array element change detected as modification
|
||||||
|
expect(diff.totalChanges).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle null and undefined', () => {
|
||||||
|
const from: NounMetadata = {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: {
|
||||||
|
a: null,
|
||||||
|
b: undefined,
|
||||||
|
c: 'value'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const to: NounMetadata = {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: {
|
||||||
|
a: 'now-value',
|
||||||
|
b: null,
|
||||||
|
c: 'value'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = compareEntityVersions(from, to, {
|
||||||
|
entityId: 'test-1',
|
||||||
|
fromVersion: 1,
|
||||||
|
toVersion: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(diff.totalChanges).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should ignore specified fields', () => {
|
||||||
|
const from: NounMetadata = {
|
||||||
|
id: 'user-1',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice',
|
||||||
|
metadata: {
|
||||||
|
email: 'alice@example.com',
|
||||||
|
lastModified: 1000,
|
||||||
|
internal: 'ignore-me'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const to: NounMetadata = {
|
||||||
|
id: 'user-1',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice Smith',
|
||||||
|
metadata: {
|
||||||
|
email: 'alice@example.com',
|
||||||
|
lastModified: 2000,
|
||||||
|
internal: 'changed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = compareEntityVersions(from, to, {
|
||||||
|
entityId: 'user-1',
|
||||||
|
fromVersion: 1,
|
||||||
|
toVersion: 2,
|
||||||
|
ignoreFields: ['lastModified', 'internal']
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should only detect name change
|
||||||
|
expect(diff.totalChanges).toBe(1)
|
||||||
|
expect(diff.modified.some(c => c.path.includes('name'))).toBe(true)
|
||||||
|
expect(diff.modified.some(c => c.path.includes('lastModified'))).toBe(false)
|
||||||
|
expect(diff.modified.some(c => c.path.includes('internal'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respect maxDepth option', () => {
|
||||||
|
const from: NounMetadata = {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: {
|
||||||
|
level1: {
|
||||||
|
level2: {
|
||||||
|
level3: {
|
||||||
|
level4: 'deep-value'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const to: NounMetadata = {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: {
|
||||||
|
level1: {
|
||||||
|
level2: {
|
||||||
|
level3: {
|
||||||
|
level4: 'changed-value'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = compareEntityVersions(from, to, {
|
||||||
|
entityId: 'test-1',
|
||||||
|
fromVersion: 1,
|
||||||
|
toVersion: 2,
|
||||||
|
maxDepth: 2 // Stop at level2
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should detect change at metadata.level1.level2 level
|
||||||
|
expect(diff.totalChanges).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty objects', () => {
|
||||||
|
const from: NounMetadata = {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const to: NounMetadata = {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: { value: 123 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = compareEntityVersions(from, to, {
|
||||||
|
entityId: 'test-1',
|
||||||
|
fromVersion: 1,
|
||||||
|
toVersion: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(diff.added.length).toBe(1)
|
||||||
|
expect(diff.added[0].newValue).toBe(123)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should calculate total changes correctly', () => {
|
||||||
|
const from: NounMetadata = {
|
||||||
|
id: 'user-1',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice',
|
||||||
|
metadata: {
|
||||||
|
email: 'alice@example.com',
|
||||||
|
status: 'active',
|
||||||
|
age: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const to: NounMetadata = {
|
||||||
|
id: 'user-1',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice Smith',
|
||||||
|
metadata: {
|
||||||
|
email: 'alice.smith@example.com',
|
||||||
|
city: 'NYC'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = compareEntityVersions(from, to, {
|
||||||
|
entityId: 'user-1',
|
||||||
|
fromVersion: 1,
|
||||||
|
toVersion: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
const expectedTotal =
|
||||||
|
diff.added.length +
|
||||||
|
diff.removed.length +
|
||||||
|
diff.modified.length +
|
||||||
|
diff.typeChanged.length
|
||||||
|
|
||||||
|
expect(diff.totalChanges).toBe(expectedTotal)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle complex real-world changes', () => {
|
||||||
|
const from: NounMetadata = {
|
||||||
|
id: 'project-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'My Project',
|
||||||
|
metadata: {
|
||||||
|
description: 'Old description',
|
||||||
|
status: 'draft',
|
||||||
|
team: ['alice', 'bob'],
|
||||||
|
config: {
|
||||||
|
theme: 'light',
|
||||||
|
notifications: true
|
||||||
|
},
|
||||||
|
createdAt: 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const to: NounMetadata = {
|
||||||
|
id: 'project-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'My Awesome Project',
|
||||||
|
metadata: {
|
||||||
|
description: 'New description',
|
||||||
|
status: 'active',
|
||||||
|
team: ['alice', 'bob', 'charlie'],
|
||||||
|
config: {
|
||||||
|
theme: 'dark',
|
||||||
|
notifications: true,
|
||||||
|
language: 'en'
|
||||||
|
},
|
||||||
|
createdAt: 1000,
|
||||||
|
updatedAt: 2000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = compareEntityVersions(from, to, {
|
||||||
|
entityId: 'project-1',
|
||||||
|
fromVersion: 1,
|
||||||
|
toVersion: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(diff.totalChanges).toBeGreaterThan(0)
|
||||||
|
expect(diff.modified.length).toBeGreaterThan(0)
|
||||||
|
expect(diff.added.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const nameChange = diff.modified.find(c => c.path.includes('name'))
|
||||||
|
expect(nameChange?.newValue).toBe('My Awesome Project')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle boolean changes', () => {
|
||||||
|
const from: NounMetadata = {
|
||||||
|
id: 'feature-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Feature',
|
||||||
|
metadata: { enabled: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const to: NounMetadata = {
|
||||||
|
id: 'feature-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Feature',
|
||||||
|
metadata: { enabled: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = compareEntityVersions(from, to, {
|
||||||
|
entityId: 'feature-1',
|
||||||
|
fromVersion: 1,
|
||||||
|
toVersion: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(diff.modified.length).toBe(1)
|
||||||
|
expect(diff.modified[0].oldValue).toBe(false)
|
||||||
|
expect(diff.modified[0].newValue).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle number changes', () => {
|
||||||
|
const from: NounMetadata = {
|
||||||
|
id: 'counter-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Counter',
|
||||||
|
metadata: { count: 10 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const to: NounMetadata = {
|
||||||
|
id: 'counter-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Counter',
|
||||||
|
metadata: { count: 20 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = compareEntityVersions(from, to, {
|
||||||
|
entityId: 'counter-1',
|
||||||
|
fromVersion: 1,
|
||||||
|
toVersion: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(diff.modified.length).toBe(1)
|
||||||
|
expect(diff.modified[0].oldValue).toBe(10)
|
||||||
|
expect(diff.modified[0].newValue).toBe(20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle date/timestamp changes', () => {
|
||||||
|
const from: NounMetadata = {
|
||||||
|
id: 'event-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Event',
|
||||||
|
metadata: { timestamp: 1000 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const to: NounMetadata = {
|
||||||
|
id: 'event-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Event',
|
||||||
|
metadata: { timestamp: 2000 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = compareEntityVersions(from, to, {
|
||||||
|
entityId: 'event-1',
|
||||||
|
fromVersion: 1,
|
||||||
|
toVersion: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(diff.modified.length).toBe(1)
|
||||||
|
expect(diff.modified[0].path).toContain('timestamp')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
654
tests/unit/versioning/VersionManager.test.ts
Normal file
654
tests/unit/versioning/VersionManager.test.ts
Normal file
|
|
@ -0,0 +1,654 @@
|
||||||
|
/**
|
||||||
|
* VersionManager Unit Tests (v5.3.0)
|
||||||
|
*
|
||||||
|
* Tests the core versioning engine in isolation:
|
||||||
|
* - Save versions
|
||||||
|
* - Restore versions
|
||||||
|
* - List versions
|
||||||
|
* - Compare versions
|
||||||
|
* - Prune versions
|
||||||
|
* - Content deduplication
|
||||||
|
* - Branch awareness
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { VersionManager } from '../../../src/versioning/VersionManager.js'
|
||||||
|
import type { NounMetadata } from '../../../src/coreTypes.js'
|
||||||
|
|
||||||
|
describe('VersionManager', () => {
|
||||||
|
let manager: VersionManager
|
||||||
|
let mockBrain: any
|
||||||
|
let mockStorage: Map<string, any>
|
||||||
|
let mockIndex: Map<string, any>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock storage
|
||||||
|
mockStorage = new Map()
|
||||||
|
mockIndex = new Map()
|
||||||
|
|
||||||
|
// Mock Brainy instance
|
||||||
|
mockBrain = {
|
||||||
|
currentBranch: 'main',
|
||||||
|
storageAdapter: {
|
||||||
|
exists: vi.fn(async (path: string) => mockStorage.has(path)),
|
||||||
|
writeFile: vi.fn(async (path: string, data: string) => {
|
||||||
|
mockStorage.set(path, data)
|
||||||
|
}),
|
||||||
|
readFile: vi.fn(async (path: string) => {
|
||||||
|
const data = mockStorage.get(path)
|
||||||
|
if (!data) throw new Error('File not found')
|
||||||
|
return data
|
||||||
|
}),
|
||||||
|
deleteFile: vi.fn(async (path: string) => {
|
||||||
|
mockStorage.delete(path)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
refManager: {
|
||||||
|
getRef: vi.fn(async (branch: string) => ({
|
||||||
|
name: `refs/heads/${branch}`,
|
||||||
|
commitHash: 'commit-hash-123',
|
||||||
|
type: 'branch'
|
||||||
|
})),
|
||||||
|
setRef: vi.fn(async () => {}),
|
||||||
|
resolveRef: vi.fn(async () => 'commit-hash-123')
|
||||||
|
},
|
||||||
|
getNounMetadata: vi.fn(async (id: string) => {
|
||||||
|
const entity = mockIndex.get(id)
|
||||||
|
if (!entity) throw new Error(`Entity ${id} not found`)
|
||||||
|
return entity
|
||||||
|
}),
|
||||||
|
saveNounMetadata: vi.fn(async (id: string, data: NounMetadata) => {
|
||||||
|
mockIndex.set(id, data)
|
||||||
|
}),
|
||||||
|
searchByMetadata: vi.fn(async (query: any) => {
|
||||||
|
const results: any[] = []
|
||||||
|
for (const [id, entity] of mockIndex.entries()) {
|
||||||
|
let matches = true
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
if (key === 'type' && entity.type !== value) {
|
||||||
|
matches = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (entity.metadata?.[key] !== value) {
|
||||||
|
matches = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matches) {
|
||||||
|
results.push({ id, ...entity })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}),
|
||||||
|
commit: vi.fn(async () => 'commit-hash-123')
|
||||||
|
}
|
||||||
|
|
||||||
|
manager = new VersionManager(mockBrain)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('save()', () => {
|
||||||
|
it('should save a new version', async () => {
|
||||||
|
// Add test entity
|
||||||
|
mockIndex.set('user-123', {
|
||||||
|
id: 'user-123',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice',
|
||||||
|
metadata: { email: 'alice@example.com' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const version = await manager.save('user-123', {
|
||||||
|
tag: 'v1.0',
|
||||||
|
description: 'Initial version'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(version.version).toBe(1)
|
||||||
|
expect(version.entityId).toBe('user-123')
|
||||||
|
expect(version.branch).toBe('main')
|
||||||
|
expect(version.tag).toBe('v1.0')
|
||||||
|
expect(version.description).toBe('Initial version')
|
||||||
|
expect(version.contentHash).toBeDefined()
|
||||||
|
expect(version.commitHash).toBe('commit-hash-123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should increment version numbers', async () => {
|
||||||
|
mockIndex.set('doc-1', {
|
||||||
|
id: 'doc-1',
|
||||||
|
type: 'document',
|
||||||
|
name: 'Doc',
|
||||||
|
metadata: { content: 'Version 1' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const v1 = await manager.save('doc-1', { tag: 'v1' })
|
||||||
|
expect(v1.version).toBe(1)
|
||||||
|
|
||||||
|
// Update entity
|
||||||
|
mockIndex.set('doc-1', {
|
||||||
|
id: 'doc-1',
|
||||||
|
type: 'document',
|
||||||
|
name: 'Doc',
|
||||||
|
metadata: { content: 'Version 2' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const v2 = await manager.save('doc-1', { tag: 'v2' })
|
||||||
|
expect(v2.version).toBe(2)
|
||||||
|
|
||||||
|
mockIndex.set('doc-1', {
|
||||||
|
id: 'doc-1',
|
||||||
|
type: 'document',
|
||||||
|
name: 'Doc',
|
||||||
|
metadata: { content: 'Version 3' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const v3 = await manager.save('doc-1', { tag: 'v3' })
|
||||||
|
expect(v3.version).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should deduplicate identical content', async () => {
|
||||||
|
mockIndex.set('note-1', {
|
||||||
|
id: 'note-1',
|
||||||
|
type: 'document',
|
||||||
|
name: 'Note',
|
||||||
|
metadata: { content: 'Unchanged' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const v1 = await manager.save('note-1', { tag: 'v1' })
|
||||||
|
const v2 = await manager.save('note-1', { tag: 'v2' })
|
||||||
|
|
||||||
|
// Should return same version (content unchanged)
|
||||||
|
expect(v2.version).toBe(v1.version)
|
||||||
|
expect(v2.contentHash).toBe(v1.contentHash)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw if entity does not exist', async () => {
|
||||||
|
await expect(
|
||||||
|
manager.save('nonexistent', { tag: 'v1' })
|
||||||
|
).rejects.toThrow('Entity nonexistent not found')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should save without tag or description', async () => {
|
||||||
|
mockIndex.set('test-1', {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const version = await manager.save('test-1')
|
||||||
|
expect(version.version).toBe(1)
|
||||||
|
expect(version.tag).toBeUndefined()
|
||||||
|
expect(version.description).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('restore()', () => {
|
||||||
|
it('should restore entity to specific version', async () => {
|
||||||
|
// Save version 1
|
||||||
|
mockIndex.set('config-1', {
|
||||||
|
id: 'config-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Config',
|
||||||
|
metadata: { theme: 'light', version: 1 }
|
||||||
|
})
|
||||||
|
await manager.save('config-1', { tag: 'v1' })
|
||||||
|
|
||||||
|
// Update to version 2
|
||||||
|
mockIndex.set('config-1', {
|
||||||
|
id: 'config-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Config',
|
||||||
|
metadata: { theme: 'dark', version: 2 }
|
||||||
|
})
|
||||||
|
await manager.save('config-1', { tag: 'v2' })
|
||||||
|
|
||||||
|
// Restore to v1
|
||||||
|
await manager.restore('config-1', 1)
|
||||||
|
|
||||||
|
// Verify saveNounMetadata was called with v1 data
|
||||||
|
expect(mockBrain.saveNounMetadata).toHaveBeenCalledWith(
|
||||||
|
'config-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
metadata: expect.objectContaining({ theme: 'light', version: 1 })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should restore by tag', async () => {
|
||||||
|
mockIndex.set('app-1', {
|
||||||
|
id: 'app-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'App',
|
||||||
|
metadata: { status: 'alpha' }
|
||||||
|
})
|
||||||
|
await manager.save('app-1', { tag: 'alpha' })
|
||||||
|
|
||||||
|
mockIndex.set('app-1', {
|
||||||
|
id: 'app-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'App',
|
||||||
|
metadata: { status: 'beta' }
|
||||||
|
})
|
||||||
|
await manager.save('app-1', { tag: 'beta' })
|
||||||
|
|
||||||
|
// Restore to alpha
|
||||||
|
await manager.restore('app-1', 'alpha')
|
||||||
|
|
||||||
|
expect(mockBrain.saveNounMetadata).toHaveBeenCalledWith(
|
||||||
|
'app-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
metadata: expect.objectContaining({ status: 'alpha' })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw if version not found', async () => {
|
||||||
|
mockIndex.set('test-1', {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: {}
|
||||||
|
})
|
||||||
|
await manager.save('test-1')
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manager.restore('test-1', 999)
|
||||||
|
).rejects.toThrow('Version 999 not found')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('list()', () => {
|
||||||
|
it('should list all versions for entity', async () => {
|
||||||
|
mockIndex.set('log-1', {
|
||||||
|
id: 'log-1',
|
||||||
|
type: 'document',
|
||||||
|
name: 'Log',
|
||||||
|
metadata: { entry: 1 }
|
||||||
|
})
|
||||||
|
|
||||||
|
await manager.save('log-1', { tag: 'v1' })
|
||||||
|
|
||||||
|
mockIndex.set('log-1', {
|
||||||
|
id: 'log-1',
|
||||||
|
type: 'document',
|
||||||
|
name: 'Log',
|
||||||
|
metadata: { entry: 2 }
|
||||||
|
})
|
||||||
|
await manager.save('log-1', { tag: 'v2' })
|
||||||
|
|
||||||
|
mockIndex.set('log-1', {
|
||||||
|
id: 'log-1',
|
||||||
|
type: 'document',
|
||||||
|
name: 'Log',
|
||||||
|
metadata: { entry: 3 }
|
||||||
|
})
|
||||||
|
await manager.save('log-1', { tag: 'v3' })
|
||||||
|
|
||||||
|
const versions = await manager.list('log-1')
|
||||||
|
expect(versions).toHaveLength(3)
|
||||||
|
expect(versions[0].version).toBe(3) // Newest first
|
||||||
|
expect(versions[1].version).toBe(2)
|
||||||
|
expect(versions[2].version).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter by tag pattern', async () => {
|
||||||
|
mockIndex.set('app-1', {
|
||||||
|
id: 'app-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'App',
|
||||||
|
metadata: { v: 1 }
|
||||||
|
})
|
||||||
|
|
||||||
|
await manager.save('app-1', { tag: 'v1.0.0' })
|
||||||
|
|
||||||
|
mockIndex.set('app-1', {
|
||||||
|
id: 'app-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'App',
|
||||||
|
metadata: { v: 2 }
|
||||||
|
})
|
||||||
|
await manager.save('app-1', { tag: 'v1.1.0' })
|
||||||
|
|
||||||
|
mockIndex.set('app-1', {
|
||||||
|
id: 'app-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'App',
|
||||||
|
metadata: { v: 3 }
|
||||||
|
})
|
||||||
|
await manager.save('app-1', { tag: 'v2.0.0' })
|
||||||
|
|
||||||
|
const v1Versions = await manager.list('app-1', { tag: 'v1.*' })
|
||||||
|
expect(v1Versions).toHaveLength(2)
|
||||||
|
expect(v1Versions[0].tag).toBe('v1.1.0')
|
||||||
|
expect(v1Versions[1].tag).toBe('v1.0.0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should limit results', async () => {
|
||||||
|
mockIndex.set('data-1', {
|
||||||
|
id: 'data-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Data',
|
||||||
|
metadata: { v: 1 }
|
||||||
|
})
|
||||||
|
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
mockIndex.set('data-1', {
|
||||||
|
id: 'data-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Data',
|
||||||
|
metadata: { v: i }
|
||||||
|
})
|
||||||
|
await manager.save('data-1', { tag: `v${i}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions = await manager.list('data-1', { limit: 3 })
|
||||||
|
expect(versions).toHaveLength(3)
|
||||||
|
expect(versions[0].version).toBe(5)
|
||||||
|
expect(versions[1].version).toBe(4)
|
||||||
|
expect(versions[2].version).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return empty array if no versions', async () => {
|
||||||
|
mockIndex.set('new-1', {
|
||||||
|
id: 'new-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'New',
|
||||||
|
metadata: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const versions = await manager.list('new-1')
|
||||||
|
expect(versions).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('compare()', () => {
|
||||||
|
it('should compare two versions', async () => {
|
||||||
|
mockIndex.set('user-1', {
|
||||||
|
id: 'user-1',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Bob',
|
||||||
|
metadata: { email: 'bob@example.com', age: 30 }
|
||||||
|
})
|
||||||
|
await manager.save('user-1', { tag: 'v1' })
|
||||||
|
|
||||||
|
mockIndex.set('user-1', {
|
||||||
|
id: 'user-1',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Robert',
|
||||||
|
metadata: { email: 'robert@example.com', age: 30, city: 'NYC' }
|
||||||
|
})
|
||||||
|
await manager.save('user-1', { tag: 'v2' })
|
||||||
|
|
||||||
|
const diff = await manager.compare('user-1', 1, 2)
|
||||||
|
|
||||||
|
expect(diff.totalChanges).toBeGreaterThan(0)
|
||||||
|
expect(diff.modified.length).toBeGreaterThan(0)
|
||||||
|
expect(diff.added.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const nameChange = diff.modified.find(c => c.path.includes('name'))
|
||||||
|
expect(nameChange?.oldValue).toBe('Bob')
|
||||||
|
expect(nameChange?.newValue).toBe('Robert')
|
||||||
|
|
||||||
|
const cityAdd = diff.added.find(c => c.path.includes('city'))
|
||||||
|
expect(cityAdd?.newValue).toBe('NYC')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should compare by tags', async () => {
|
||||||
|
mockIndex.set('doc-1', {
|
||||||
|
id: 'doc-1',
|
||||||
|
type: 'document',
|
||||||
|
name: 'Doc',
|
||||||
|
metadata: { content: 'Alpha' }
|
||||||
|
})
|
||||||
|
await manager.save('doc-1', { tag: 'alpha' })
|
||||||
|
|
||||||
|
mockIndex.set('doc-1', {
|
||||||
|
id: 'doc-1',
|
||||||
|
type: 'document',
|
||||||
|
name: 'Doc',
|
||||||
|
metadata: { content: 'Beta' }
|
||||||
|
})
|
||||||
|
await manager.save('doc-1', { tag: 'beta' })
|
||||||
|
|
||||||
|
const diff = await manager.compare('doc-1', 'alpha', 'beta')
|
||||||
|
|
||||||
|
expect(diff.modified.length).toBeGreaterThan(0)
|
||||||
|
const contentChange = diff.modified.find(c => c.path.includes('content'))
|
||||||
|
expect(contentChange?.oldValue).toBe('Alpha')
|
||||||
|
expect(contentChange?.newValue).toBe('Beta')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect identical versions', async () => {
|
||||||
|
mockIndex.set('same-1', {
|
||||||
|
id: 'same-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Same',
|
||||||
|
metadata: { value: 100 }
|
||||||
|
})
|
||||||
|
await manager.save('same-1', { tag: 'v1' })
|
||||||
|
await manager.save('same-1', { tag: 'v2' }) // Content unchanged
|
||||||
|
|
||||||
|
const diff = await manager.compare('same-1', 1, 1)
|
||||||
|
expect(diff.identical).toBe(true)
|
||||||
|
expect(diff.totalChanges).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('prune()', () => {
|
||||||
|
it('should prune old versions keeping recent', async () => {
|
||||||
|
mockIndex.set('log-1', {
|
||||||
|
id: 'log-1',
|
||||||
|
type: 'document',
|
||||||
|
name: 'Log',
|
||||||
|
metadata: { entry: 1 }
|
||||||
|
})
|
||||||
|
|
||||||
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
mockIndex.set('log-1', {
|
||||||
|
id: 'log-1',
|
||||||
|
type: 'document',
|
||||||
|
name: 'Log',
|
||||||
|
metadata: { entry: i }
|
||||||
|
})
|
||||||
|
await manager.save('log-1', { tag: `v${i}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await manager.prune('log-1', {
|
||||||
|
keepRecent: 5,
|
||||||
|
keepTagged: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.deleted).toBe(5)
|
||||||
|
expect(result.kept).toBe(5)
|
||||||
|
|
||||||
|
const remaining = await manager.list('log-1')
|
||||||
|
expect(remaining).toHaveLength(5)
|
||||||
|
expect(remaining[0].version).toBe(10) // Most recent
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep tagged versions', async () => {
|
||||||
|
mockIndex.set('app-1', {
|
||||||
|
id: 'app-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'App',
|
||||||
|
metadata: { v: 1 }
|
||||||
|
})
|
||||||
|
|
||||||
|
await manager.save('app-1', { tag: 'release' })
|
||||||
|
|
||||||
|
for (let i = 2; i <= 10; i++) {
|
||||||
|
mockIndex.set('app-1', {
|
||||||
|
id: 'app-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'App',
|
||||||
|
metadata: { v: i }
|
||||||
|
})
|
||||||
|
await manager.save('app-1') // No tag
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await manager.prune('app-1', {
|
||||||
|
keepRecent: 3,
|
||||||
|
keepTagged: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const remaining = await manager.list('app-1')
|
||||||
|
const releaseVersion = remaining.find(v => v.tag === 'release')
|
||||||
|
expect(releaseVersion).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respect keepAfter timestamp', async () => {
|
||||||
|
mockIndex.set('data-1', {
|
||||||
|
id: 'data-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Data',
|
||||||
|
metadata: { v: 1 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const oneDayAgo = now - 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
await manager.save('data-1', { tag: 'old' })
|
||||||
|
|
||||||
|
// Simulate newer versions
|
||||||
|
for (let i = 2; i <= 5; i++) {
|
||||||
|
mockIndex.set('data-1', {
|
||||||
|
id: 'data-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Data',
|
||||||
|
metadata: { v: i }
|
||||||
|
})
|
||||||
|
await manager.save('data-1', { tag: `new${i}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await manager.prune('data-1', {
|
||||||
|
keepAfter: oneDayAgo,
|
||||||
|
keepTagged: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.deleted).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getVersion()', () => {
|
||||||
|
it('should get specific version by number', async () => {
|
||||||
|
mockIndex.set('test-1', {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: { v: 1 }
|
||||||
|
})
|
||||||
|
const v1 = await manager.save('test-1', { tag: 'v1' })
|
||||||
|
|
||||||
|
mockIndex.set('test-1', {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: { v: 2 }
|
||||||
|
})
|
||||||
|
await manager.save('test-1', { tag: 'v2' })
|
||||||
|
|
||||||
|
const version = await manager.getVersion('test-1', 1)
|
||||||
|
expect(version).toBeDefined()
|
||||||
|
expect(version?.version).toBe(1)
|
||||||
|
expect(version?.tag).toBe('v1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null if version not found', async () => {
|
||||||
|
mockIndex.set('test-1', {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: {}
|
||||||
|
})
|
||||||
|
await manager.save('test-1')
|
||||||
|
|
||||||
|
const version = await manager.getVersion('test-1', 999)
|
||||||
|
expect(version).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getVersionByTag()', () => {
|
||||||
|
it('should get version by tag', async () => {
|
||||||
|
mockIndex.set('app-1', {
|
||||||
|
id: 'app-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'App',
|
||||||
|
metadata: { status: 'alpha' }
|
||||||
|
})
|
||||||
|
await manager.save('app-1', { tag: 'alpha' })
|
||||||
|
|
||||||
|
mockIndex.set('app-1', {
|
||||||
|
id: 'app-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'App',
|
||||||
|
metadata: { status: 'beta' }
|
||||||
|
})
|
||||||
|
await manager.save('app-1', { tag: 'beta' })
|
||||||
|
|
||||||
|
const betaVersion = await manager.getVersionByTag('app-1', 'beta')
|
||||||
|
expect(betaVersion).toBeDefined()
|
||||||
|
expect(betaVersion?.tag).toBe('beta')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null if tag not found', async () => {
|
||||||
|
mockIndex.set('test-1', {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: {}
|
||||||
|
})
|
||||||
|
await manager.save('test-1', { tag: 'v1' })
|
||||||
|
|
||||||
|
const version = await manager.getVersionByTag('test-1', 'nonexistent')
|
||||||
|
expect(version).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getVersionCount()', () => {
|
||||||
|
it('should count versions', async () => {
|
||||||
|
mockIndex.set('doc-1', {
|
||||||
|
id: 'doc-1',
|
||||||
|
type: 'document',
|
||||||
|
name: 'Doc',
|
||||||
|
metadata: { v: 1 }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await manager.getVersionCount('doc-1')).toBe(0)
|
||||||
|
|
||||||
|
await manager.save('doc-1')
|
||||||
|
expect(await manager.getVersionCount('doc-1')).toBe(1)
|
||||||
|
|
||||||
|
mockIndex.set('doc-1', {
|
||||||
|
id: 'doc-1',
|
||||||
|
type: 'document',
|
||||||
|
name: 'Doc',
|
||||||
|
metadata: { v: 2 }
|
||||||
|
})
|
||||||
|
await manager.save('doc-1')
|
||||||
|
expect(await manager.getVersionCount('doc-1')).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Branch Awareness', () => {
|
||||||
|
it('should track versions per branch', async () => {
|
||||||
|
mockIndex.set('branch-test', {
|
||||||
|
id: 'branch-test',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save on main
|
||||||
|
mockBrain.currentBranch = 'main'
|
||||||
|
const mainV1 = await manager.save('branch-test', { tag: 'main-v1' })
|
||||||
|
expect(mainV1.branch).toBe('main')
|
||||||
|
|
||||||
|
// Save on feature
|
||||||
|
mockBrain.currentBranch = 'feature'
|
||||||
|
const featureV1 = await manager.save('branch-test', { tag: 'feature-v1' })
|
||||||
|
expect(featureV1.branch).toBe('feature')
|
||||||
|
|
||||||
|
// Versions should be independent
|
||||||
|
expect(mainV1.version).toBe(1)
|
||||||
|
expect(featureV1.version).toBe(1) // Each branch starts at 1
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
548
tests/unit/versioning/VersionStorage.test.ts
Normal file
548
tests/unit/versioning/VersionStorage.test.ts
Normal file
|
|
@ -0,0 +1,548 @@
|
||||||
|
/**
|
||||||
|
* VersionStorage Unit Tests (v5.3.0)
|
||||||
|
*
|
||||||
|
* Tests content-addressable storage:
|
||||||
|
* - SHA-256 content hashing
|
||||||
|
* - Content deduplication
|
||||||
|
* - Storage adapter integration
|
||||||
|
* - Save/load/delete operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { VersionStorage } from '../../../src/versioning/VersionStorage.js'
|
||||||
|
import type { NounMetadata } from '../../../src/coreTypes.js'
|
||||||
|
import type { EntityVersion } from '../../../src/versioning/VersionManager.js'
|
||||||
|
|
||||||
|
describe('VersionStorage', () => {
|
||||||
|
let storage: VersionStorage
|
||||||
|
let mockBrain: any
|
||||||
|
let mockFiles: Map<string, string>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFiles = new Map()
|
||||||
|
|
||||||
|
mockBrain = {
|
||||||
|
storageAdapter: {
|
||||||
|
exists: vi.fn(async (path: string) => mockFiles.has(path)),
|
||||||
|
writeFile: vi.fn(async (path: string, data: string) => {
|
||||||
|
mockFiles.set(path, data)
|
||||||
|
}),
|
||||||
|
readFile: vi.fn(async (path: string) => {
|
||||||
|
const data = mockFiles.get(path)
|
||||||
|
if (!data) throw new Error('File not found')
|
||||||
|
return data
|
||||||
|
}),
|
||||||
|
deleteFile: vi.fn(async (path: string) => {
|
||||||
|
mockFiles.delete(path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
storage = new VersionStorage(mockBrain)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hashEntity()', () => {
|
||||||
|
it('should generate consistent SHA-256 hashes', () => {
|
||||||
|
const entity: NounMetadata = {
|
||||||
|
id: 'user-123',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice',
|
||||||
|
metadata: { email: 'alice@example.com' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash1 = storage.hashEntity(entity)
|
||||||
|
const hash2 = storage.hashEntity(entity)
|
||||||
|
|
||||||
|
expect(hash1).toBe(hash2)
|
||||||
|
expect(hash1).toHaveLength(64) // SHA-256 = 64 hex chars
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate different hashes for different content', () => {
|
||||||
|
const entity1: NounMetadata = {
|
||||||
|
id: 'user-1',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice',
|
||||||
|
metadata: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity2: NounMetadata = {
|
||||||
|
id: 'user-1',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Bob',
|
||||||
|
metadata: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash1 = storage.hashEntity(entity1)
|
||||||
|
const hash2 = storage.hashEntity(entity2)
|
||||||
|
|
||||||
|
expect(hash1).not.toBe(hash2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should ignore property order (stable JSON)', () => {
|
||||||
|
const entity1: NounMetadata = {
|
||||||
|
id: 'user-1',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice',
|
||||||
|
metadata: { a: 1, b: 2, c: 3 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity2: NounMetadata = {
|
||||||
|
type: 'user',
|
||||||
|
metadata: { c: 3, b: 2, a: 1 },
|
||||||
|
name: 'Alice',
|
||||||
|
id: 'user-1'
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash1 = storage.hashEntity(entity1)
|
||||||
|
const hash2 = storage.hashEntity(entity2)
|
||||||
|
|
||||||
|
expect(hash1).toBe(hash2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle nested objects', () => {
|
||||||
|
const entity: NounMetadata = {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: {
|
||||||
|
nested: {
|
||||||
|
deep: {
|
||||||
|
value: 'hello'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = storage.hashEntity(entity)
|
||||||
|
expect(hash).toHaveLength(64)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle arrays', () => {
|
||||||
|
const entity: NounMetadata = {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: {
|
||||||
|
tags: ['a', 'b', 'c']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = storage.hashEntity(entity)
|
||||||
|
expect(hash).toHaveLength(64)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle null and undefined', () => {
|
||||||
|
const entity1: NounMetadata = {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: { value: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity2: NounMetadata = {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: { value: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash1 = storage.hashEntity(entity1)
|
||||||
|
const hash2 = storage.hashEntity(entity2)
|
||||||
|
|
||||||
|
expect(hash1).not.toBe(hash2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('saveVersion()', () => {
|
||||||
|
it('should save version content to storage', async () => {
|
||||||
|
const entity: NounMetadata = {
|
||||||
|
id: 'user-123',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice',
|
||||||
|
metadata: { email: 'alice@example.com' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const version: EntityVersion = {
|
||||||
|
version: 1,
|
||||||
|
entityId: 'user-123',
|
||||||
|
branch: 'main',
|
||||||
|
commitHash: 'commit-123',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
contentHash: storage.hashEntity(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.saveVersion(version, entity)
|
||||||
|
|
||||||
|
const expectedPath = `versions/entities/user-123/${version.contentHash}.json`
|
||||||
|
expect(mockFiles.has(expectedPath)).toBe(true)
|
||||||
|
|
||||||
|
const savedData = mockFiles.get(expectedPath)
|
||||||
|
expect(savedData).toBeDefined()
|
||||||
|
expect(JSON.parse(savedData!)).toEqual(entity)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should deduplicate identical content', async () => {
|
||||||
|
const entity: NounMetadata = {
|
||||||
|
id: 'doc-1',
|
||||||
|
type: 'document',
|
||||||
|
name: 'Doc',
|
||||||
|
metadata: { content: 'Same content' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentHash = storage.hashEntity(entity)
|
||||||
|
|
||||||
|
const version1: EntityVersion = {
|
||||||
|
version: 1,
|
||||||
|
entityId: 'doc-1',
|
||||||
|
branch: 'main',
|
||||||
|
commitHash: 'commit-1',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
contentHash
|
||||||
|
}
|
||||||
|
|
||||||
|
const version2: EntityVersion = {
|
||||||
|
version: 2,
|
||||||
|
entityId: 'doc-1',
|
||||||
|
branch: 'main',
|
||||||
|
commitHash: 'commit-2',
|
||||||
|
timestamp: Date.now() + 1000,
|
||||||
|
contentHash // Same hash!
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.saveVersion(version1, entity)
|
||||||
|
const writeCallCount1 = mockBrain.storageAdapter.writeFile.mock.calls.length
|
||||||
|
|
||||||
|
await storage.saveVersion(version2, entity)
|
||||||
|
const writeCallCount2 = mockBrain.storageAdapter.writeFile.mock.calls.length
|
||||||
|
|
||||||
|
// Should not write again (deduplication)
|
||||||
|
expect(writeCallCount2).toBe(writeCallCount1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should store different content separately', async () => {
|
||||||
|
const entity1: NounMetadata = {
|
||||||
|
id: 'doc-1',
|
||||||
|
type: 'document',
|
||||||
|
name: 'Doc',
|
||||||
|
metadata: { content: 'Version 1' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity2: NounMetadata = {
|
||||||
|
id: 'doc-1',
|
||||||
|
type: 'document',
|
||||||
|
name: 'Doc',
|
||||||
|
metadata: { content: 'Version 2' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const version1: EntityVersion = {
|
||||||
|
version: 1,
|
||||||
|
entityId: 'doc-1',
|
||||||
|
branch: 'main',
|
||||||
|
commitHash: 'commit-1',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
contentHash: storage.hashEntity(entity1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const version2: EntityVersion = {
|
||||||
|
version: 2,
|
||||||
|
entityId: 'doc-1',
|
||||||
|
branch: 'main',
|
||||||
|
commitHash: 'commit-2',
|
||||||
|
timestamp: Date.now() + 1000,
|
||||||
|
contentHash: storage.hashEntity(entity2)
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.saveVersion(version1, entity1)
|
||||||
|
await storage.saveVersion(version2, entity2)
|
||||||
|
|
||||||
|
const path1 = `versions/entities/doc-1/${version1.contentHash}.json`
|
||||||
|
const path2 = `versions/entities/doc-1/${version2.contentHash}.json`
|
||||||
|
|
||||||
|
expect(mockFiles.has(path1)).toBe(true)
|
||||||
|
expect(mockFiles.has(path2)).toBe(true)
|
||||||
|
expect(path1).not.toBe(path2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loadVersion()', () => {
|
||||||
|
it('should load version content from storage', async () => {
|
||||||
|
const entity: NounMetadata = {
|
||||||
|
id: 'user-123',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice',
|
||||||
|
metadata: { email: 'alice@example.com' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const version: EntityVersion = {
|
||||||
|
version: 1,
|
||||||
|
entityId: 'user-123',
|
||||||
|
branch: 'main',
|
||||||
|
commitHash: 'commit-123',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
contentHash: storage.hashEntity(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save first
|
||||||
|
await storage.saveVersion(version, entity)
|
||||||
|
|
||||||
|
// Load
|
||||||
|
const loaded = await storage.loadVersion(version)
|
||||||
|
|
||||||
|
expect(loaded).toEqual(entity)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null if version not found', async () => {
|
||||||
|
const version: EntityVersion = {
|
||||||
|
version: 1,
|
||||||
|
entityId: 'nonexistent',
|
||||||
|
branch: 'main',
|
||||||
|
commitHash: 'commit-123',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
contentHash: 'fake-hash'
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = await storage.loadVersion(version)
|
||||||
|
expect(loaded).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle complex nested data', async () => {
|
||||||
|
const entity: NounMetadata = {
|
||||||
|
id: 'complex-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Complex',
|
||||||
|
metadata: {
|
||||||
|
nested: {
|
||||||
|
array: [1, 2, 3],
|
||||||
|
object: { key: 'value' }
|
||||||
|
},
|
||||||
|
tags: ['a', 'b', 'c']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const version: EntityVersion = {
|
||||||
|
version: 1,
|
||||||
|
entityId: 'complex-1',
|
||||||
|
branch: 'main',
|
||||||
|
commitHash: 'commit-123',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
contentHash: storage.hashEntity(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.saveVersion(version, entity)
|
||||||
|
const loaded = await storage.loadVersion(version)
|
||||||
|
|
||||||
|
expect(loaded).toEqual(entity)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deleteVersion()', () => {
|
||||||
|
it('should delete version from storage', async () => {
|
||||||
|
const entity: NounMetadata = {
|
||||||
|
id: 'user-123',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice',
|
||||||
|
metadata: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const version: EntityVersion = {
|
||||||
|
version: 1,
|
||||||
|
entityId: 'user-123',
|
||||||
|
branch: 'main',
|
||||||
|
commitHash: 'commit-123',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
contentHash: storage.hashEntity(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save
|
||||||
|
await storage.saveVersion(version, entity)
|
||||||
|
const path = `versions/entities/user-123/${version.contentHash}.json`
|
||||||
|
expect(mockFiles.has(path)).toBe(true)
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
await storage.deleteVersion(version)
|
||||||
|
expect(mockFiles.has(path)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle deleting non-existent version gracefully', async () => {
|
||||||
|
const version: EntityVersion = {
|
||||||
|
version: 1,
|
||||||
|
entityId: 'nonexistent',
|
||||||
|
branch: 'main',
|
||||||
|
commitHash: 'commit-123',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
contentHash: 'fake-hash'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
await storage.deleteVersion(version)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Storage Adapter Integration', () => {
|
||||||
|
it('should work with memory storage adapter', async () => {
|
||||||
|
const entity: NounMetadata = {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: { value: 123 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const version: EntityVersion = {
|
||||||
|
version: 1,
|
||||||
|
entityId: 'test-1',
|
||||||
|
branch: 'main',
|
||||||
|
commitHash: 'commit-123',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
contentHash: storage.hashEntity(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.saveVersion(version, entity)
|
||||||
|
const loaded = await storage.loadVersion(version)
|
||||||
|
|
||||||
|
expect(loaded).toEqual(entity)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use adapter.set if writeFile unavailable', async () => {
|
||||||
|
// Mock adapter with set/get instead of writeFile/readFile
|
||||||
|
mockBrain.storageAdapter = {
|
||||||
|
exists: vi.fn(async () => false),
|
||||||
|
set: vi.fn(async (path: string, data: string) => {
|
||||||
|
mockFiles.set(path, data)
|
||||||
|
}),
|
||||||
|
get: vi.fn(async (path: string) => {
|
||||||
|
const data = mockFiles.get(path)
|
||||||
|
if (!data) throw new Error('Not found')
|
||||||
|
return data
|
||||||
|
}),
|
||||||
|
delete: vi.fn(async (path: string) => {
|
||||||
|
mockFiles.delete(path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
storage = new VersionStorage(mockBrain)
|
||||||
|
|
||||||
|
const entity: NounMetadata = {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const version: EntityVersion = {
|
||||||
|
version: 1,
|
||||||
|
entityId: 'test-1',
|
||||||
|
branch: 'main',
|
||||||
|
commitHash: 'commit-123',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
contentHash: storage.hashEntity(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.saveVersion(version, entity)
|
||||||
|
expect(mockBrain.storageAdapter.set).toHaveBeenCalled()
|
||||||
|
|
||||||
|
const loaded = await storage.loadVersion(version)
|
||||||
|
expect(mockBrain.storageAdapter.get).toHaveBeenCalled()
|
||||||
|
expect(loaded).toEqual(entity)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw if storage adapter missing', async () => {
|
||||||
|
mockBrain.storageAdapter = null
|
||||||
|
storage = new VersionStorage(mockBrain)
|
||||||
|
|
||||||
|
const entity: NounMetadata = {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const version: EntityVersion = {
|
||||||
|
version: 1,
|
||||||
|
entityId: 'test-1',
|
||||||
|
branch: 'main',
|
||||||
|
commitHash: 'commit-123',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
contentHash: storage.hashEntity(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
storage.saveVersion(version, entity)
|
||||||
|
).rejects.toThrow('Storage adapter not available')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw if adapter does not support required operations', async () => {
|
||||||
|
mockBrain.storageAdapter = {} // No methods
|
||||||
|
storage = new VersionStorage(mockBrain)
|
||||||
|
|
||||||
|
const entity: NounMetadata = {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'thing',
|
||||||
|
name: 'Test',
|
||||||
|
metadata: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const version: EntityVersion = {
|
||||||
|
version: 1,
|
||||||
|
entityId: 'test-1',
|
||||||
|
branch: 'main',
|
||||||
|
commitHash: 'commit-123',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
contentHash: storage.hashEntity(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
storage.saveVersion(version, entity)
|
||||||
|
).rejects.toThrow('does not support write operations')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Path Generation', () => {
|
||||||
|
it('should generate correct storage paths', async () => {
|
||||||
|
const entity: NounMetadata = {
|
||||||
|
id: 'user-123',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice',
|
||||||
|
metadata: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentHash = storage.hashEntity(entity)
|
||||||
|
const version: EntityVersion = {
|
||||||
|
version: 1,
|
||||||
|
entityId: 'user-123',
|
||||||
|
branch: 'main',
|
||||||
|
commitHash: 'commit-123',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
contentHash
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.saveVersion(version, entity)
|
||||||
|
|
||||||
|
const expectedPath = `versions/entities/user-123/${contentHash}.json`
|
||||||
|
expect(mockFiles.has(expectedPath)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle entity IDs with special characters', async () => {
|
||||||
|
const entity: NounMetadata = {
|
||||||
|
id: 'user:123:profile',
|
||||||
|
type: 'user',
|
||||||
|
name: 'Alice',
|
||||||
|
metadata: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentHash = storage.hashEntity(entity)
|
||||||
|
const version: EntityVersion = {
|
||||||
|
version: 1,
|
||||||
|
entityId: 'user:123:profile',
|
||||||
|
branch: 'main',
|
||||||
|
commitHash: 'commit-123',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
contentHash
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.saveVersion(version, entity)
|
||||||
|
|
||||||
|
const expectedPath = `versions/entities/user:123:profile/${contentHash}.json`
|
||||||
|
expect(mockFiles.has(expectedPath)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue