feat(8.0)!: delete fork/branch/commit/history/versions — superseded by the Db API
The COW version-control surface (fork, branches, checkout, commit,
getHistory/streamHistory, asOfCommit, brain.versions) is gone, along with
its subsystems: src/versioning/, the COW object store (CommitLog,
CommitObject, RefManager, TreeObject), HistoricalStorageAdapter, and the
TypeAwareHNSWIndex path it kept alive. The Db API (now/transact/asOf/
with/persist/restore) is the one versioning model in 8.0.
Survivors and replacements:
- BlobStorage survives (the VFS stores file content through it), relocated
to src/storage/blobStorage.ts with binaryDataCodec.ts; its adapter
interface is now BlobStoreAdapter, slimmed to the consumed surface
(write/read/has/delete/getMetadata + MIME-aware compression policy).
- brain.migrate() backup branches are replaced by persist-before-migrate:
MigrateOptions.backupTo persists a hard-link snapshot of the current
generation before any transform runs; MigrationResult.backupPath reports
it, and brain.restore(path) brings it back wholesale.
- CLI: cow.ts (fork/branch/checkout/history/migrate) is replaced by
snapshot.ts — snapshot <path>, restore <path>, history (tx-log),
generation.
- New public read API: brain.transactionLog({limit}) exposes the reified
tx-log (generation/timestamp/meta, newest first) that backs the CLI
history command; TxLogEntry is exported.
Tests: superseded suites deleted; fork/commit blocks excised from shared
suites; BlobStorage tests relocated + reworked against the slimmed store;
migration tests now prove the backupTo snapshot/restore round trip; new
transactionLog coverage in db-mvcc.
This commit is contained in:
parent
431cd64406
commit
8f93add705
64 changed files with 1444 additions and 16313 deletions
|
|
@ -1,104 +0,0 @@
|
|||
/**
|
||||
* 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({ requireSubtype: false,
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,573 +0,0 @@
|
|||
/**
|
||||
* Commit State Capture Integration Tests (v5.4.0 Phase 1)
|
||||
*
|
||||
* Tests the new captureState parameter on commit() that enables
|
||||
* historical time-travel by capturing entity snapshots in trees.
|
||||
*
|
||||
* Tests:
|
||||
* 1. Lightweight commit (captureState: false uses NULL_HASH)
|
||||
* 2. Default behavior (captureState omitted → captures state)
|
||||
* 3. Empty workspace handling
|
||||
* 4. Performance benchmarks (100, 1K, 10K entities)
|
||||
* 5. BlobStorage deduplication
|
||||
* 6. Tree structure validity
|
||||
* 7. Storage adapter compatibility
|
||||
* 8. VFS entity capture
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../src/brainy.js'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
const TEST_DATA_PATH = './test-commit-state-capture'
|
||||
|
||||
describe('Commit State Capture (v5.4.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 })
|
||||
}
|
||||
fs.mkdirSync(TEST_DATA_PATH, { recursive: true })
|
||||
|
||||
brain = new Brainy({ requireSubtype: false,
|
||||
storage: {
|
||||
adapter: 'filesystem',
|
||||
path: TEST_DATA_PATH
|
||||
},
|
||||
disableAutoRebuild: true,
|
||||
silent: true
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(TEST_DATA_PATH)) {
|
||||
fs.rmSync(TEST_DATA_PATH, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('should use NULL_HASH when captureState is false (backward compat)', async () => {
|
||||
console.log('\n📋 Test 1: Backward compatibility')
|
||||
|
||||
// Add some entities
|
||||
await brain.add({ type: 'concept', data: { title: 'Test' } })
|
||||
|
||||
// Commit with explicit captureState: false (lightweight metadata-only commit)
|
||||
const commitId = await brain.commit({
|
||||
message: 'Test commit',
|
||||
captureState: false // Explicit false — skips state capture
|
||||
})
|
||||
|
||||
console.log(` Commit ID: ${commitId.slice(0, 8)}`)
|
||||
|
||||
// Read commit object
|
||||
const blobStorage = (brain.storage as any).blobStorage
|
||||
const { CommitObject } = await import('../../src/storage/cow/CommitObject.js')
|
||||
const { NULL_HASH } = await import('../../src/storage/cow/constants.js')
|
||||
|
||||
const commit = await CommitObject.read(blobStorage, commitId)
|
||||
|
||||
// Verify tree is NULL_HASH (empty)
|
||||
expect(commit.tree).toBe(NULL_HASH)
|
||||
console.log(` ✅ Tree is NULL_HASH (backward compatible)`)
|
||||
})
|
||||
|
||||
it('should capture state when captureState is omitted (default: true)', async () => {
|
||||
console.log('\n📋 Test 2: Default behavior (captureState omitted → captures state)')
|
||||
|
||||
await brain.add({ type: 'concept', data: { title: 'Test' } })
|
||||
|
||||
// Commit WITHOUT captureState parameter (omitted = default true)
|
||||
const commitId = await brain.commit({
|
||||
message: 'Test commit'
|
||||
// captureState not specified → defaults to true
|
||||
})
|
||||
|
||||
const blobStorage = (brain.storage as any).blobStorage
|
||||
const { CommitObject } = await import('../../src/storage/cow/CommitObject.js')
|
||||
const { NULL_HASH } = await import('../../src/storage/cow/constants.js')
|
||||
|
||||
const commit = await CommitObject.read(blobStorage, commitId)
|
||||
|
||||
// Default behavior now captures state (tree hash is NOT null)
|
||||
expect(commit.tree).not.toBe(NULL_HASH)
|
||||
console.log(` ✅ Tree is ${commit.tree.slice(0, 8)}... (state captured by default)`)
|
||||
})
|
||||
|
||||
it('should capture entity state when captureState is true', async () => {
|
||||
console.log('\n📋 Test 3: State capture enabled')
|
||||
|
||||
// Add entities (add() returns entity ID, not entity object)
|
||||
const entity1Id = await brain.add({
|
||||
type: 'person',
|
||||
data: { name: 'Alice', age: 30 }
|
||||
})
|
||||
|
||||
const entity2Id = await brain.add({
|
||||
type: 'concept',
|
||||
data: { title: 'AI', description: 'Artificial Intelligence' }
|
||||
})
|
||||
|
||||
console.log(` Added 2 entities: ${entity1Id.slice(0, 8)}, ${entity2Id.slice(0, 8)}`)
|
||||
|
||||
// Commit WITH captureState
|
||||
const commitId = await brain.commit({
|
||||
message: 'Snapshot with entities',
|
||||
captureState: true // CAPTURE STATE!
|
||||
})
|
||||
|
||||
console.log(` Commit ID: ${commitId.slice(0, 8)}`)
|
||||
|
||||
// Read commit object
|
||||
const blobStorage = (brain.storage as any).blobStorage
|
||||
const { CommitObject } = await import('../../src/storage/cow/CommitObject.js')
|
||||
const { NULL_HASH, isNullHash } = await import('../../src/storage/cow/constants.js')
|
||||
|
||||
const commit = await CommitObject.read(blobStorage, commitId)
|
||||
|
||||
// Verify tree is NOT NULL_HASH
|
||||
expect(commit.tree).not.toBe(NULL_HASH)
|
||||
expect(isNullHash(commit.tree)).toBe(false)
|
||||
console.log(` ✅ Tree is NOT NULL_HASH: ${commit.tree.slice(0, 8)}`)
|
||||
|
||||
// Verify tree exists and can be read
|
||||
const { TreeObject } = await import('../../src/storage/cow/TreeObject.js')
|
||||
const tree = await TreeObject.read(blobStorage, commit.tree)
|
||||
|
||||
expect(tree).toBeDefined()
|
||||
expect(tree.entries.length).toBeGreaterThan(0)
|
||||
console.log(` ✅ Tree has ${tree.entries.length} entries`)
|
||||
|
||||
// Verify entities are in tree
|
||||
let foundEntities = 0
|
||||
const entityNames: string[] = []
|
||||
for (const entry of tree.entries) {
|
||||
if (entry.name.startsWith('entities/')) {
|
||||
foundEntities++
|
||||
entityNames.push(entry.name)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` Found ${foundEntities} entities: ${entityNames.join(', ')}`)
|
||||
// At least 2 entities should be captured (the ones we added)
|
||||
// There may be system entities (VFS root, metadata, etc.)
|
||||
expect(foundEntities).toBeGreaterThanOrEqual(2)
|
||||
console.log(` ✅ Found ${foundEntities} entities in tree (at least 2 as expected)`)
|
||||
})
|
||||
|
||||
it('should handle empty workspace correctly', async () => {
|
||||
console.log('\n📋 Test 4: Empty workspace (no user entities)')
|
||||
|
||||
// Check how many entities exist before commit
|
||||
const entitiesBefore = await brain.find({ excludeVFS: false })
|
||||
console.log(` Entities before commit: ${entitiesBefore.length}`)
|
||||
|
||||
// Commit with NO user-added entities
|
||||
const commitId = await brain.commit({
|
||||
message: 'Empty snapshot',
|
||||
captureState: true
|
||||
})
|
||||
|
||||
const blobStorage = (brain.storage as any).blobStorage
|
||||
const { CommitObject } = await import('../../src/storage/cow/CommitObject.js')
|
||||
const { NULL_HASH, isNullHash } = await import('../../src/storage/cow/constants.js')
|
||||
|
||||
const commit = await CommitObject.read(blobStorage, commitId)
|
||||
|
||||
// If there were NO entities before commit, tree should be NULL_HASH
|
||||
// If there WERE entities (system entities), tree should contain them
|
||||
if (entitiesBefore.length === 0) {
|
||||
expect(commit.tree).toBe(NULL_HASH)
|
||||
console.log(` ✅ No entities: tree is NULL_HASH (correct)`)
|
||||
} else {
|
||||
expect(isNullHash(commit.tree)).toBe(false)
|
||||
console.log(` ✅ ${entitiesBefore.length} system entities captured in tree`)
|
||||
}
|
||||
})
|
||||
|
||||
it('should perform well with small workspace (100 entities)', async () => {
|
||||
console.log('\n📋 Test 5: Performance - 100 entities')
|
||||
|
||||
// Add 100 entities
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await brain.add({
|
||||
type:'concept',
|
||||
data: { id: i, title: `Concept ${i}` }
|
||||
})
|
||||
}
|
||||
|
||||
console.log(` Added 100 entities`)
|
||||
|
||||
// Measure commit time
|
||||
const start = Date.now()
|
||||
const commitId = await brain.commit({
|
||||
message: '100 entity snapshot',
|
||||
captureState: true
|
||||
})
|
||||
const duration = Date.now() - start
|
||||
|
||||
console.log(` Commit completed in ${duration}ms`)
|
||||
console.log(` Commit ID: ${commitId.slice(0, 8)}`)
|
||||
|
||||
// Should complete in < 1s
|
||||
expect(duration).toBeLessThan(1000)
|
||||
console.log(` ✅ Performance: ${duration}ms < 1000ms`)
|
||||
})
|
||||
|
||||
it('should deduplicate unchanged entities across commits', async () => {
|
||||
console.log('\n📋 Test 6: BlobStorage deduplication')
|
||||
|
||||
// Add initial entities (add() returns entity IDs)
|
||||
const entity1Id = await brain.add({
|
||||
type:'person',
|
||||
data: { name: 'Alice', age: 30 }
|
||||
})
|
||||
|
||||
const entity2Id = await brain.add({
|
||||
type:'person',
|
||||
data: { name: 'Bob', age: 25 }
|
||||
})
|
||||
|
||||
// First commit with captureState
|
||||
const commit1Id = await brain.commit({
|
||||
message: 'First snapshot',
|
||||
captureState: true
|
||||
})
|
||||
|
||||
console.log(` Commit 1: ${commit1Id.slice(0, 8)}`)
|
||||
|
||||
// Modify ONLY entity2
|
||||
await brain.update({
|
||||
id: entity2Id,
|
||||
data: { name: 'Bob', age: 26 } // Changed age
|
||||
})
|
||||
|
||||
// Second commit with captureState
|
||||
const commit2Id = await brain.commit({
|
||||
message: 'Second snapshot',
|
||||
captureState: true
|
||||
})
|
||||
|
||||
console.log(` Commit 2: ${commit2Id.slice(0, 8)}`)
|
||||
|
||||
// Read both trees
|
||||
const blobStorage = (brain.storage as any).blobStorage
|
||||
const { CommitObject } = await import('../../src/storage/cow/CommitObject.js')
|
||||
const { TreeObject } = await import('../../src/storage/cow/TreeObject.js')
|
||||
|
||||
const commit1 = await CommitObject.read(blobStorage, commit1Id)
|
||||
const commit2 = await CommitObject.read(blobStorage, commit2Id)
|
||||
|
||||
const tree1 = await TreeObject.read(blobStorage, commit1.tree)
|
||||
const tree2 = await TreeObject.read(blobStorage, commit2.tree)
|
||||
|
||||
// Find entity1 blob hash in both trees
|
||||
const entity1Entry1 = tree1.entries.find(e => e.name === `entities/${entity1Id}`)
|
||||
const entity1Entry2 = tree2.entries.find(e => e.name === `entities/${entity1Id}`)
|
||||
|
||||
expect(entity1Entry1).toBeDefined()
|
||||
expect(entity1Entry2).toBeDefined()
|
||||
|
||||
// entity1 unchanged, so blob hash should be SAME (deduplication!)
|
||||
expect(entity1Entry1!.hash).toBe(entity1Entry2!.hash)
|
||||
console.log(` ✅ Unchanged entity1 has same blob hash (deduplicated)`)
|
||||
|
||||
// Find entity2 blob hash in both trees
|
||||
const entity2Entry1 = tree1.entries.find(e => e.name === `entities/${entity2Id}`)
|
||||
const entity2Entry2 = tree2.entries.find(e => e.name === `entities/${entity2Id}`)
|
||||
|
||||
expect(entity2Entry1).toBeDefined()
|
||||
expect(entity2Entry2).toBeDefined()
|
||||
|
||||
// entity2 changed, so blob hash should be DIFFERENT
|
||||
expect(entity2Entry1!.hash).not.toBe(entity2Entry2!.hash)
|
||||
console.log(` ✅ Changed entity2 has different blob hash (new version)`)
|
||||
})
|
||||
|
||||
it('should create valid tree structure that can be read back', async () => {
|
||||
console.log('\n📋 Test 7: Tree structure validity')
|
||||
|
||||
// Add entities (add() returns entity IDs)
|
||||
const entity1Id = await brain.add({
|
||||
type:'person',
|
||||
data: { name: 'Alice' }
|
||||
})
|
||||
|
||||
const entity2Id = await brain.add({
|
||||
type:'concept',
|
||||
data: { title: 'AI' }
|
||||
})
|
||||
|
||||
// Commit with captureState
|
||||
const commitId = await brain.commit({
|
||||
message: 'Test snapshot',
|
||||
captureState: true
|
||||
})
|
||||
|
||||
// Read tree
|
||||
const blobStorage = (brain.storage as any).blobStorage
|
||||
const { CommitObject } = await import('../../src/storage/cow/CommitObject.js')
|
||||
const { TreeObject } = await import('../../src/storage/cow/TreeObject.js')
|
||||
|
||||
const commit = await CommitObject.read(blobStorage, commitId)
|
||||
const tree = await TreeObject.read(blobStorage, commit.tree)
|
||||
|
||||
// Verify tree structure
|
||||
expect(tree.entries).toBeDefined()
|
||||
expect(Array.isArray(tree.entries)).toBe(true)
|
||||
|
||||
// Walk tree and deserialize entities
|
||||
let foundEntities = 0
|
||||
for await (const entry of TreeObject.walk(blobStorage, tree)) {
|
||||
if (entry.type !== 'blob' || !entry.name.startsWith('entities/')) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Read blob
|
||||
const blob = await blobStorage.read(entry.hash)
|
||||
const entity = JSON.parse(blob.toString())
|
||||
|
||||
// Verify entity structure
|
||||
expect(entity).toHaveProperty('id')
|
||||
expect(entity).toHaveProperty('type')
|
||||
expect(entity).toHaveProperty('data')
|
||||
expect(entity).toHaveProperty('metadata')
|
||||
|
||||
console.log(` Found entity: ${entity.id.slice(0, 8)} (${entity.type})`)
|
||||
foundEntities++
|
||||
}
|
||||
|
||||
// At least 2 entities should be found (the ones we added)
|
||||
expect(foundEntities).toBeGreaterThanOrEqual(2)
|
||||
console.log(` ✅ Successfully walked tree and deserialized ${foundEntities} entities`)
|
||||
})
|
||||
|
||||
it('should capture VFS entities along with regular entities', async () => {
|
||||
console.log('\n📋 Test 8: VFS entity capture')
|
||||
|
||||
// Add regular entity (add() returns entity ID)
|
||||
const conceptId = await brain.add({
|
||||
type:'concept',
|
||||
data: { title: 'Test Concept' }
|
||||
})
|
||||
|
||||
// Add VFS file
|
||||
await brain.vfs.init()
|
||||
await brain.vfs.writeFile('/test.md', 'Test content')
|
||||
|
||||
console.log(` Added concept: ${conceptId.slice(0, 8)}`)
|
||||
console.log(` Added VFS file: /test.md`)
|
||||
|
||||
// Commit with captureState
|
||||
const commitId = await brain.commit({
|
||||
message: 'Snapshot with VFS',
|
||||
captureState: true
|
||||
})
|
||||
|
||||
// Read tree and verify both types of entities are captured
|
||||
const blobStorage = (brain.storage as any).blobStorage
|
||||
const { CommitObject } = await import('../../src/storage/cow/CommitObject.js')
|
||||
const { TreeObject } = await import('../../src/storage/cow/TreeObject.js')
|
||||
|
||||
const commit = await CommitObject.read(blobStorage, commitId)
|
||||
const tree = await TreeObject.read(blobStorage, commit.tree)
|
||||
|
||||
let regularEntities = 0
|
||||
let vfsEntities = 0
|
||||
|
||||
for await (const entry of TreeObject.walk(blobStorage, tree)) {
|
||||
if (entry.type !== 'blob' || !entry.name.startsWith('entities/')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const blob = await blobStorage.read(entry.hash)
|
||||
const entity = JSON.parse(blob.toString())
|
||||
|
||||
if (entity.metadata?.isVFS || entity.metadata?.vfsType) {
|
||||
vfsEntities++
|
||||
console.log(` Found VFS entity: ${entity.metadata.path}`)
|
||||
} else {
|
||||
regularEntities++
|
||||
console.log(` Found regular entity: ${entity.id.slice(0, 8)} (${entity.type})`)
|
||||
}
|
||||
}
|
||||
|
||||
expect(regularEntities).toBeGreaterThanOrEqual(1)
|
||||
expect(vfsEntities).toBeGreaterThanOrEqual(2) // File + root directory
|
||||
console.log(` ✅ Captured ${regularEntities} regular + ${vfsEntities} VFS entities`)
|
||||
})
|
||||
|
||||
it('should work with Memory storage adapter', async () => {
|
||||
console.log('\n📋 Test 9: Memory storage adapter')
|
||||
|
||||
const memBrain = new Brainy({ requireSubtype: false,
|
||||
storage: { adapter: 'memory' },
|
||||
silent: true
|
||||
})
|
||||
|
||||
await memBrain.init()
|
||||
|
||||
// Add entity
|
||||
await memBrain.add({ type:'concept', data: { title: 'Test' } })
|
||||
|
||||
// Commit with captureState
|
||||
const commitId = await memBrain.commit({
|
||||
message: 'Memory storage test',
|
||||
captureState: true
|
||||
})
|
||||
|
||||
const blobStorage = (memBrain.storage as any).blobStorage
|
||||
const { CommitObject } = await import('../../src/storage/cow/CommitObject.js')
|
||||
const { isNullHash } = await import('../../src/storage/cow/constants.js')
|
||||
|
||||
const commit = await CommitObject.read(blobStorage, commitId)
|
||||
|
||||
expect(isNullHash(commit.tree)).toBe(false)
|
||||
console.log(` ✅ Memory storage: tree captured successfully`)
|
||||
})
|
||||
|
||||
it('should work with TypeAware storage adapter', async () => {
|
||||
console.log('\n📋 Test 10: TypeAware storage adapter')
|
||||
|
||||
const testPath = './test-typeaware-commit-capture'
|
||||
|
||||
if (fs.existsSync(testPath)) {
|
||||
fs.rmSync(testPath, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const typeAwareBrain = new Brainy({ requireSubtype: false,
|
||||
storage: {
|
||||
adapter: 'typeaware',
|
||||
path: testPath
|
||||
},
|
||||
silent: true
|
||||
})
|
||||
|
||||
await typeAwareBrain.init()
|
||||
|
||||
// Add entity
|
||||
await typeAwareBrain.add({ type:'person', data: { name: 'Alice' } })
|
||||
|
||||
// Commit with captureState
|
||||
const commitId = await typeAwareBrain.commit({
|
||||
message: 'TypeAware storage test',
|
||||
captureState: true
|
||||
})
|
||||
|
||||
const blobStorage = (typeAwareBrain.storage as any).blobStorage
|
||||
const { CommitObject } = await import('../../src/storage/cow/CommitObject.js')
|
||||
const { isNullHash } = await import('../../src/storage/cow/constants.js')
|
||||
|
||||
const commit = await CommitObject.read(blobStorage, commitId)
|
||||
|
||||
expect(isNullHash(commit.tree)).toBe(false)
|
||||
console.log(` ✅ TypeAware storage: tree captured successfully`)
|
||||
|
||||
// Cleanup
|
||||
if (fs.existsSync(testPath)) {
|
||||
fs.rmSync(testPath, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('should capture relationships along with entities', async () => {
|
||||
console.log('\n📋 Test 11: Relationship capture (CRITICAL FIX)')
|
||||
|
||||
// Add entities
|
||||
const aliceId = await brain.add({
|
||||
type: 'person',
|
||||
data: { name: 'Alice', role: 'Developer' }
|
||||
})
|
||||
|
||||
const bobId = await brain.add({
|
||||
type: 'person',
|
||||
data: { name: 'Bob', role: 'Designer' }
|
||||
})
|
||||
|
||||
const projectId = await brain.add({
|
||||
type: 'concept',
|
||||
data: { title: 'Project X', description: 'AI Platform' }
|
||||
})
|
||||
|
||||
console.log(` Added 3 entities: Alice, Bob, Project X`)
|
||||
|
||||
// Create relationships (using valid VerbTypes)
|
||||
await brain.relate({
|
||||
from: aliceId,
|
||||
to: projectId,
|
||||
type: 'relatedTo' // Alice works on Project X
|
||||
})
|
||||
|
||||
await brain.relate({
|
||||
from: bobId,
|
||||
to: projectId,
|
||||
type: 'relatedTo' // Bob works on Project X
|
||||
})
|
||||
|
||||
await brain.relate({
|
||||
from: aliceId,
|
||||
to: bobId,
|
||||
type: 'relatedTo' // Alice collaborates with Bob
|
||||
})
|
||||
|
||||
console.log(` Created 3 relationships`)
|
||||
|
||||
// Commit with captureState
|
||||
const commitId = await brain.commit({
|
||||
message: 'Snapshot with entities + relationships',
|
||||
captureState: true
|
||||
})
|
||||
|
||||
console.log(` Commit ID: ${commitId.slice(0, 8)}`)
|
||||
|
||||
// Read tree and verify relationships are captured
|
||||
const blobStorage = (brain.storage as any).blobStorage
|
||||
const { CommitObject } = await import('../../src/storage/cow/CommitObject.js')
|
||||
const { TreeObject } = await import('../../src/storage/cow/TreeObject.js')
|
||||
|
||||
const commit = await CommitObject.read(blobStorage, commitId)
|
||||
const tree = await TreeObject.read(blobStorage, commit.tree)
|
||||
|
||||
// Count entities and relationships in tree
|
||||
let entityCount = 0
|
||||
let relationCount = 0
|
||||
|
||||
for (const entry of tree.entries) {
|
||||
if (entry.name.startsWith('entities/')) {
|
||||
entityCount++
|
||||
} else if (entry.name.startsWith('relations/')) {
|
||||
relationCount++
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` Tree contains: ${entityCount} entities, ${relationCount} relationships`)
|
||||
|
||||
// Verify we have at least the entities and relationships we created
|
||||
expect(entityCount).toBeGreaterThanOrEqual(3)
|
||||
expect(relationCount).toBe(3)
|
||||
console.log(` ✅ Relationships captured in tree!`)
|
||||
|
||||
// Verify we can deserialize relationships from tree
|
||||
let deserializedRelations = 0
|
||||
for (const entry of tree.entries) {
|
||||
if (!entry.name.startsWith('relations/')) continue
|
||||
|
||||
const blob = await blobStorage.read(entry.hash)
|
||||
const relation = JSON.parse(blob.toString())
|
||||
|
||||
// Verify relationship structure
|
||||
expect(relation).toHaveProperty('sourceId')
|
||||
expect(relation).toHaveProperty('targetId')
|
||||
expect(relation).toHaveProperty('verb')
|
||||
|
||||
console.log(` Found relation: ${relation.verb} (${relation.sourceId.slice(0, 8)} → ${relation.targetId.slice(0, 8)})`)
|
||||
deserializedRelations++
|
||||
}
|
||||
|
||||
expect(deserializedRelations).toBe(3)
|
||||
console.log(` ✅ Successfully deserialized ${deserializedRelations} relationships from tree`)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
/**
|
||||
* Regression test for v5.3.3 bug fix:
|
||||
* Commits were being stored as blob:${hash} instead of commit:${hash}
|
||||
*
|
||||
* This caused getHistory() to return empty arrays because it couldn't find
|
||||
* commit objects on disk.
|
||||
*
|
||||
* Related bugs:
|
||||
* - v5.3.0 snapshot regression filed via consumer bug report (BRAINY_V5.3.0_SNAPSHOT_BUG_REPORT.md, internal)
|
||||
* - Root cause: BlobStorage.ts hardcoded 'blob:' prefix in 9 locations
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../src/brainy.js'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
|
||||
describe('COW Commit Storage Type-Aware Prefixes', () => {
|
||||
let brain: Brainy
|
||||
let testDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = path.join('/tmp', `brainy-cow-test-${Date.now()}`)
|
||||
|
||||
brain = new Brainy({ requireSubtype: false,
|
||||
storage: {
|
||||
type: 'filesystem',
|
||||
path: testDir,
|
||||
branch: 'main',
|
||||
enableCompression: true
|
||||
},
|
||||
disableAutoRebuild: true,
|
||||
silent: true
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test directory
|
||||
try {
|
||||
await fs.rm(testDir, { recursive: true, force: true })
|
||||
} catch (err) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
it('should store commits with commit: prefix (not blob: prefix)', async () => {
|
||||
// Create a commit
|
||||
await brain.commit('Test commit for type-aware storage')
|
||||
|
||||
// Check filesystem directly
|
||||
const cowDir = path.join(testDir, '_cow')
|
||||
const files = await fs.readdir(cowDir)
|
||||
|
||||
// Should have commit: files
|
||||
const commitFiles = files.filter(f => f.startsWith('commit:'))
|
||||
expect(commitFiles.length).toBeGreaterThan(0)
|
||||
|
||||
// Should NOT have blob: files that are actually commits
|
||||
// (blob: files should only be for actual blob data)
|
||||
const blobFiles = files.filter(f => f.startsWith('blob:') && !f.includes('-meta'))
|
||||
|
||||
// Read metadata of blob files to ensure none are commits
|
||||
for (const blobFile of blobFiles) {
|
||||
const metaFile = blobFile.replace('blob:', 'blob:-meta:')
|
||||
if (files.includes(metaFile)) {
|
||||
const metaPath = path.join(cowDir, metaFile)
|
||||
const metaContent = await fs.readFile(metaPath, 'utf8')
|
||||
const metadata = JSON.parse(metaContent)
|
||||
expect(metadata.type).not.toBe('commit')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('should allow getHistory() to retrieve commits', async () => {
|
||||
// Create multiple commits
|
||||
await brain.commit('First commit')
|
||||
|
||||
await brain.add({
|
||||
data: 'Testing commit storage',
|
||||
type: 'concept'
|
||||
})
|
||||
|
||||
await brain.commit('Second commit with entity')
|
||||
|
||||
// Get history - should NOT be empty
|
||||
const history = await brain.getHistory({ limit: 10 })
|
||||
|
||||
expect(history).toBeDefined()
|
||||
expect(Array.isArray(history)).toBe(true)
|
||||
expect(history.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Verify commit structure
|
||||
expect(history[0]).toHaveProperty('hash')
|
||||
expect(history[0]).toHaveProperty('message')
|
||||
expect(history[0]).toHaveProperty('timestamp')
|
||||
expect(history[0]).toHaveProperty('author')
|
||||
})
|
||||
|
||||
it('should store trees with tree: prefix (if trees are created)', async () => {
|
||||
// Add an entity and commit
|
||||
await brain.add({
|
||||
data: 'Testing tree storage',
|
||||
type: 'concept'
|
||||
})
|
||||
|
||||
await brain.commit('Commit with tree')
|
||||
|
||||
// Check filesystem - trees might or might not be created depending on implementation
|
||||
// The important thing is that IF trees are created, they use tree: prefix
|
||||
const cowDir = path.join(testDir, '_cow')
|
||||
const files = await fs.readdir(cowDir)
|
||||
|
||||
// Verify commit files exist (this is the critical part)
|
||||
const commitFiles = files.filter(f => f.startsWith('commit:'))
|
||||
expect(commitFiles.length).toBeGreaterThan(0)
|
||||
|
||||
// If tree files exist, they should use tree: prefix (not blob:)
|
||||
const treeFiles = files.filter(f => f.startsWith('tree:'))
|
||||
const blobTrees = files.filter(f => f.startsWith('blob:') && !f.includes('-meta'))
|
||||
|
||||
// Trees should be in tree: files, not blob: files
|
||||
// (Or no tree files at all if implementation doesn't create them)
|
||||
expect(blobTrees.filter(f => f.includes('tree')).length).toBe(0)
|
||||
})
|
||||
|
||||
it('should maintain backward compatibility with old blob: prefix', async () => {
|
||||
// This test verifies that read() auto-detects type by trying multiple prefixes
|
||||
// We'll verify this by checking that the auto-detection code works
|
||||
|
||||
// Create a commit normally (will use commit: prefix)
|
||||
await brain.commit('Test commit for backward compat check')
|
||||
|
||||
// The commit should be readable even though read() tries multiple prefixes
|
||||
const history = await brain.getHistory({ limit: 1 })
|
||||
expect(history.length).toBe(1)
|
||||
|
||||
// Verify the commit hash is readable via BlobStorage
|
||||
const blobStorage = (brain as any).storage.blobStorage
|
||||
const commitHash = history[0].hash
|
||||
|
||||
// This should work via auto-detection
|
||||
const readData = await blobStorage.read(commitHash)
|
||||
expect(readData).toBeDefined()
|
||||
expect(readData.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should handle fork() and snapshot creation with proper commit storage', async () => {
|
||||
// Add some data
|
||||
await brain.add({
|
||||
data: 'Testing snapshots',
|
||||
type: 'concept'
|
||||
})
|
||||
|
||||
await brain.commit('Commit before fork')
|
||||
|
||||
// Create a fork (snapshot branch)
|
||||
const snapshotBranch = `snapshot-${Date.now()}`
|
||||
await brain.fork(snapshotBranch)
|
||||
|
||||
// Get history - should work (this is the critical test)
|
||||
const history = await brain.getHistory({ limit: 10 })
|
||||
expect(history.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// History working proves commits are stored correctly
|
||||
})
|
||||
|
||||
it('should properly delete commits with type-aware prefix', async () => {
|
||||
// Create a commit
|
||||
await brain.commit('Commit to delete')
|
||||
|
||||
const history = await brain.getHistory({ limit: 1 })
|
||||
const commitHash = history[0].hash
|
||||
|
||||
// Delete via BlobStorage
|
||||
const blobStorage = (brain as any).storage.blobStorage
|
||||
await blobStorage.delete(commitHash)
|
||||
|
||||
// Verify deleted
|
||||
const exists = await blobStorage.has(commitHash)
|
||||
expect(exists).toBe(false)
|
||||
|
||||
// Verify files removed from disk
|
||||
const cowDir = path.join(testDir, '_cow')
|
||||
const files = await fs.readdir(cowDir)
|
||||
|
||||
const commitFile = files.find(f => f.includes(commitHash) && f.startsWith('commit:'))
|
||||
expect(commitFile).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,574 +0,0 @@
|
|||
import { NounType } from '../../src/types/graphTypes.js'
|
||||
/**
|
||||
* Comprehensive COW Integration Tests
|
||||
*
|
||||
* Verifies COW works with:
|
||||
* - All 8 storage adapters (Memory, OPFS, FileSystem, S3, R2, GCS, Azure, TypeAware)
|
||||
* - Billion-scale performance
|
||||
* - find() graph-aware queries
|
||||
* - Triple Intelligence natural language
|
||||
* - VFS (Virtual File System)
|
||||
* - All 4 indexes (HNSW, Metadata, GraphAdjacency, DeletedItems)
|
||||
* - Distributed mode (read-only, write-only instances)
|
||||
* - 256 UUID-based sharding
|
||||
*
|
||||
* This is the CRITICAL test that proves v5.0.0 is production-ready.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { Brainy } from '../../src/brainy.js'
|
||||
import type { StorageAdapter } from '../../src/storage/adapters/baseStorageAdapter.js'
|
||||
|
||||
describe('COW Full Integration', () => {
|
||||
describe('Storage Adapter Compatibility', () => {
|
||||
it('should work with Memory adapter', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
storage: { adapter: 'memory' }
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
// Add entity
|
||||
const entity = await brain.add({
|
||||
type: NounType.Person,
|
||||
data: { name: 'Alice' }
|
||||
})
|
||||
|
||||
// Fork (uses COW)
|
||||
const fork = await brain.fork('test-branch')
|
||||
|
||||
// Verify entity exists in fork
|
||||
const retrieved = await fork.get(entity.id)
|
||||
expect(retrieved.data.name).toBe('Alice')
|
||||
|
||||
await brain.destroy()
|
||||
await fork.destroy()
|
||||
})
|
||||
|
||||
it('should work with FileSystem adapter', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
storage: {
|
||||
adapter: 'filesystem',
|
||||
path: '/tmp/brainy-cow-test-fs'
|
||||
}
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
const entity = await brain.add({
|
||||
type: NounType.Document,
|
||||
data: { title: 'Test' }
|
||||
})
|
||||
|
||||
const fork = await brain.fork('fs-branch')
|
||||
|
||||
const retrieved = await fork.get(entity.id)
|
||||
expect(retrieved.data.title).toBe('Test')
|
||||
|
||||
await brain.destroy()
|
||||
await fork.destroy()
|
||||
})
|
||||
|
||||
it('should work with TypeAware adapter (most advanced)', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
storage: {
|
||||
adapter: 'typeaware',
|
||||
path: '/tmp/brainy-cow-test-typeaware'
|
||||
}
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
const entity = await brain.add({
|
||||
type: NounType.Product,
|
||||
data: { name: 'Widget', price: 99.99 }
|
||||
})
|
||||
|
||||
const fork = await brain.fork('typeaware-branch')
|
||||
|
||||
const retrieved = await fork.get(entity.id)
|
||||
expect(retrieved.data.price).toBe(99.99)
|
||||
|
||||
await brain.destroy()
|
||||
await fork.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Billion-Scale Performance', () => {
|
||||
it('should fork 1M entities in < 2 seconds', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
storage: { adapter: 'memory' }
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
// Add 1M entities (representative of billion-scale)
|
||||
console.log('Adding 1M entities...')
|
||||
const start = Date.now()
|
||||
|
||||
for (let i = 0; i < 1_000_000; i++) {
|
||||
await brain.add({
|
||||
type: NounType.Thing,
|
||||
data: { index: i },
|
||||
skipCommit: true // Batch commit
|
||||
})
|
||||
|
||||
if (i % 100_000 === 0) {
|
||||
console.log(` ${i / 1000}K entities added...`)
|
||||
}
|
||||
}
|
||||
|
||||
await brain.commit({ message: 'Add 1M entities' })
|
||||
|
||||
const addTime = Date.now() - start
|
||||
console.log(`Added 1M entities in ${addTime}ms`)
|
||||
|
||||
// Fork (should be instant via COW)
|
||||
console.log('Forking...')
|
||||
const forkStart = Date.now()
|
||||
|
||||
const fork = await brain.fork('million-test')
|
||||
|
||||
const forkTime = Date.now() - forkStart
|
||||
|
||||
console.log(`Forked 1M entities in ${forkTime}ms`)
|
||||
|
||||
// CRITICAL: Fork must be < 2 seconds
|
||||
expect(forkTime).toBeLessThan(2000)
|
||||
|
||||
// Verify data integrity
|
||||
const entity = await fork.get((await brain.find({ limit: 1 }))[0].id)
|
||||
expect(entity.noun).toBe('entity')
|
||||
|
||||
await brain.destroy()
|
||||
await fork.destroy()
|
||||
}, 120000) // 2-minute timeout for 1M entity test
|
||||
|
||||
it('should deduplicate billions of identical vectors', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
storage: { adapter: 'memory' }
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
// Same vector used for all entities (simulates common embeddings)
|
||||
const commonVector = Array(384).fill(0.1)
|
||||
|
||||
// Add 10K entities with same vector
|
||||
for (let i = 0; i < 10_000; i++) {
|
||||
await brain.add({
|
||||
type: NounType.Document,
|
||||
data: { id: i },
|
||||
vector: commonVector,
|
||||
skipCommit: true
|
||||
})
|
||||
}
|
||||
|
||||
await brain.commit({ message: 'Add 10K with duplicate vectors' })
|
||||
|
||||
// Check storage stats
|
||||
const stats = brain.storage.blobStorage.getStats()
|
||||
|
||||
// Should have massive deduplication savings
|
||||
// (10K vectors but only 1 unique blob)
|
||||
expect(stats.dedupSavings).toBeGreaterThan(0)
|
||||
|
||||
console.log(`Deduplication savings: ${(stats.dedupSavings / 1024 / 1024).toFixed(2)}MB`)
|
||||
|
||||
await brain.destroy()
|
||||
}, 60000)
|
||||
})
|
||||
|
||||
describe('find() Integration', () => {
|
||||
it('should work with graph-aware find() queries', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
storage: { adapter: 'memory' }
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
// Create entities with relationships
|
||||
const alice = await brain.add({
|
||||
type: NounType.Person,
|
||||
data: { name: 'Alice' }
|
||||
})
|
||||
|
||||
const bob = await brain.add({
|
||||
type: NounType.Person,
|
||||
data: { name: 'Bob' }
|
||||
})
|
||||
|
||||
await brain.addRelationship(alice.id, 'knows', bob.id)
|
||||
|
||||
// Fork
|
||||
const fork = await brain.fork('find-test')
|
||||
|
||||
// find() should work on fork
|
||||
const results = await fork.find({
|
||||
type: NounType.Person,
|
||||
where: { name: 'Alice' }
|
||||
})
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].data.name).toBe('Alice')
|
||||
|
||||
// Graph traversal should work
|
||||
const connected = await fork.getVerbsBySource(alice.id)
|
||||
expect(connected).toHaveLength(1)
|
||||
expect(connected[0].verb).toBe('knows')
|
||||
|
||||
await brain.destroy()
|
||||
await fork.destroy()
|
||||
})
|
||||
|
||||
it('should preserve metadata index across fork', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
storage: { adapter: 'memory' }
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
// Add entities with indexed metadata
|
||||
await brain.add({
|
||||
type: NounType.Product,
|
||||
data: { price: 100, category: 'electronics' }
|
||||
})
|
||||
|
||||
await brain.add({
|
||||
type: NounType.Product,
|
||||
data: { price: 200, category: 'electronics' }
|
||||
})
|
||||
|
||||
const fork = await brain.fork('metadata-test')
|
||||
|
||||
// Metadata queries should work
|
||||
const cheap = await fork.find({
|
||||
type: NounType.Product,
|
||||
where: { price: { $lt: 150 } }
|
||||
})
|
||||
|
||||
expect(cheap).toHaveLength(1)
|
||||
expect(cheap[0].data.price).toBe(100)
|
||||
|
||||
await brain.destroy()
|
||||
await fork.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Triple Intelligence Integration', () => {
|
||||
it('should preserve Triple Intelligence across fork', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
storage: { adapter: 'memory' },
|
||||
intelligence: {
|
||||
enabled: true,
|
||||
modelDelivery: 'local'
|
||||
}
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
// Add entities
|
||||
await brain.add({
|
||||
type: NounType.Person,
|
||||
data: { name: 'Alice', age: 30 }
|
||||
})
|
||||
|
||||
await brain.add({
|
||||
type: NounType.Person,
|
||||
data: { name: 'Bob', age: 25 }
|
||||
})
|
||||
|
||||
const fork = await brain.fork('intelligence-test')
|
||||
|
||||
// Natural language query should work on fork
|
||||
const results = await fork.query('people older than 28')
|
||||
|
||||
// Should find Alice (age 30)
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
|
||||
await brain.destroy()
|
||||
await fork.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('VFS Integration', () => {
|
||||
it('should preserve VFS structure across fork', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
storage: { adapter: 'memory' },
|
||||
vfs: { enabled: true }
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
// Create VFS structure
|
||||
const folder = await brain.vfs.mkdir('/documents')
|
||||
const file = await brain.vfs.writeFile('/documents/readme.txt', 'Hello World')
|
||||
|
||||
const fork = await brain.fork('vfs-test')
|
||||
|
||||
// VFS should work on fork
|
||||
const content = await fork.vfs.readFile('/documents/readme.txt')
|
||||
expect(content).toBe('Hello World')
|
||||
|
||||
const files = await fork.vfs.readdir('/documents')
|
||||
expect(files).toContain('readme.txt')
|
||||
|
||||
await brain.destroy()
|
||||
await fork.destroy()
|
||||
})
|
||||
|
||||
it('should support VFS paths in billions of entities', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
storage: { adapter: 'memory' },
|
||||
vfs: { enabled: true }
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
// Create deep directory structure
|
||||
await brain.vfs.mkdir('/project/src/components/ui')
|
||||
|
||||
// Add 1000 files
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
await brain.vfs.writeFile(
|
||||
`/project/src/components/ui/component${i}.tsx`,
|
||||
`export default function Component${i}() { return null }`
|
||||
)
|
||||
}
|
||||
|
||||
const fork = await brain.fork('vfs-scale-test')
|
||||
|
||||
// VFS operations should be fast on fork
|
||||
const files = await fork.vfs.readdir('/project/src/components/ui')
|
||||
expect(files).toHaveLength(1000)
|
||||
|
||||
await brain.destroy()
|
||||
await fork.destroy()
|
||||
}, 60000)
|
||||
})
|
||||
|
||||
describe('All 4 Indexes Integration', () => {
|
||||
it('should preserve HNSW index across fork', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
storage: { adapter: 'memory' }
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
// Add entities with vectors
|
||||
const e1 = await brain.add({
|
||||
type: NounType.Document,
|
||||
data: { text: 'machine learning' },
|
||||
vector: [1, 0, 0]
|
||||
})
|
||||
|
||||
const e2 = await brain.add({
|
||||
type: NounType.Document,
|
||||
data: { text: 'artificial intelligence' },
|
||||
vector: [0.9, 0.1, 0]
|
||||
})
|
||||
|
||||
const fork = await brain.fork('hnsw-test')
|
||||
|
||||
// Vector search should work on fork
|
||||
const similar = await fork.search([1, 0, 0], { limit: 1 })
|
||||
|
||||
expect(similar[0].id).toBe(e1.id)
|
||||
|
||||
await brain.destroy()
|
||||
await fork.destroy()
|
||||
})
|
||||
|
||||
it('should preserve GraphAdjacency index across fork', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
storage: { adapter: 'memory' }
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
const a = await brain.add({ type: NounType.Thing, data: { name: 'A' } })
|
||||
const b = await brain.add({ type: NounType.Thing, data: { name: 'B' } })
|
||||
const c = await brain.add({ type: NounType.Thing, data: { name: 'C' } })
|
||||
|
||||
await brain.addRelationship(a.id, 'connects', b.id)
|
||||
await brain.addRelationship(b.id, 'connects', c.id)
|
||||
|
||||
const fork = await brain.fork('graph-test')
|
||||
|
||||
// Graph queries should work
|
||||
const outgoing = await fork.getVerbsBySource(a.id)
|
||||
const incoming = await fork.getVerbsByTarget(b.id)
|
||||
|
||||
expect(outgoing).toHaveLength(1)
|
||||
expect(incoming).toHaveLength(1)
|
||||
|
||||
await brain.destroy()
|
||||
await fork.destroy()
|
||||
})
|
||||
|
||||
it('should preserve DeletedItems index across fork', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
storage: { adapter: 'memory' }
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
const entity = await brain.add({
|
||||
type: NounType.Thing,
|
||||
data: { value: 'test' }
|
||||
})
|
||||
|
||||
await brain.delete(entity.id)
|
||||
|
||||
const fork = await brain.fork('deleted-test')
|
||||
|
||||
// Deleted items should be tracked in fork
|
||||
const exists = await fork.get(entity.id).catch(() => null)
|
||||
expect(exists).toBeNull()
|
||||
|
||||
await brain.destroy()
|
||||
await fork.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Distributed Mode', () => {
|
||||
it('should work with read-only instances', async () => {
|
||||
// Main brain (read-write)
|
||||
const main = new Brainy({ requireSubtype: false,
|
||||
storage: { adapter: 'memory' }
|
||||
})
|
||||
|
||||
await main.init()
|
||||
|
||||
const entity = await main.add({
|
||||
type: NounType.Thing,
|
||||
data: { value: 'test' }
|
||||
})
|
||||
|
||||
await main.commit({ message: 'Add entity' })
|
||||
|
||||
// Read-only instance (shares storage)
|
||||
const readonly = new Brainy({ requireSubtype: false,
|
||||
storage: main.config.storage,
|
||||
readOnly: true
|
||||
})
|
||||
|
||||
await readonly.init()
|
||||
|
||||
// Can read from main
|
||||
const retrieved = await readonly.get(entity.id)
|
||||
expect(retrieved.data.value).toBe('test')
|
||||
|
||||
// Can fork read-only instance
|
||||
const fork = await readonly.fork('readonly-fork')
|
||||
|
||||
const forked = await fork.get(entity.id)
|
||||
expect(forked.data.value).toBe('test')
|
||||
|
||||
await main.destroy()
|
||||
await readonly.destroy()
|
||||
await fork.destroy()
|
||||
})
|
||||
|
||||
it('should work with write-only instances', async () => {
|
||||
// Write-only instance
|
||||
const writeOnly = new Brainy({ requireSubtype: false,
|
||||
storage: { adapter: 'memory' },
|
||||
writeOnly: true
|
||||
})
|
||||
|
||||
await writeOnly.init()
|
||||
|
||||
await writeOnly.add({
|
||||
type: NounType.Message,
|
||||
data: { message: 'test' }
|
||||
})
|
||||
|
||||
await writeOnly.commit({ message: 'Add log' })
|
||||
|
||||
// Fork should work even on write-only
|
||||
const fork = await writeOnly.fork('writeonly-fork')
|
||||
|
||||
expect(fork).toBeTruthy()
|
||||
|
||||
await writeOnly.destroy()
|
||||
await fork.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('256 UUID Sharding', () => {
|
||||
it('should work with sharded storage', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
storage: {
|
||||
adapter: 'memory',
|
||||
sharding: {
|
||||
enabled: true,
|
||||
shardCount: 256
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
// Add entities across shards
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
await brain.add({
|
||||
noun: 'sharded',
|
||||
data: { index: i },
|
||||
skipCommit: true
|
||||
})
|
||||
}
|
||||
|
||||
await brain.commit({ message: 'Add sharded entities' })
|
||||
|
||||
// Fork should work with sharding
|
||||
const fork = await brain.fork('sharded-test')
|
||||
|
||||
const results = await fork.find({ noun: 'sharded' })
|
||||
|
||||
expect(results).toHaveLength(1000)
|
||||
|
||||
await brain.destroy()
|
||||
await fork.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Time-Travel Queries (Enterprise Preview)', () => {
|
||||
it('should support asOf queries', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
storage: { adapter: 'memory' }
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
// Time 1: Add entity
|
||||
await brain.add({
|
||||
type: NounType.Document,
|
||||
data: { version: 1 }
|
||||
})
|
||||
|
||||
await brain.commit({ message: 'Version 1' })
|
||||
|
||||
const time1 = Date.now()
|
||||
|
||||
// Wait a bit
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// Time 2: Update entity
|
||||
const docs = await brain.find({ type: NounType.Document })
|
||||
await brain.update(docs[0].id, { version: 2 })
|
||||
|
||||
await brain.commit({ message: 'Version 2' })
|
||||
|
||||
// asOf(time1) should return version 1
|
||||
const snapshot = await brain.asOf(time1)
|
||||
|
||||
const retrieved = await snapshot.find({ type: NounType.Document })
|
||||
|
||||
expect(retrieved[0].data.version).toBe(1)
|
||||
|
||||
await brain.destroy()
|
||||
await snapshot.destroy()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -952,4 +952,42 @@ describe('8.0 Db API — generational MVCC', () => {
|
|||
await spec1.release()
|
||||
await db.release()
|
||||
})
|
||||
|
||||
it('transactionLog() returns committed entries newest first, with meta and limit', async () => {
|
||||
const brain = await openMemoryBrain()
|
||||
|
||||
// Nothing committed yet — the log is empty (single-op writes do not log).
|
||||
await brain.add({ id: uid('txlog-solo'), type: NounType.Document, data: 'solo', vector: vec(99), subtype: 'note' })
|
||||
expect(await brain.transactionLog()).toEqual([])
|
||||
|
||||
const first = await brain.transact(
|
||||
[{ op: 'add', id: uid('txlog-a'), type: NounType.Document, data: 'a', vector: vec(100), metadata: {} }],
|
||||
{ meta: { author: 'job-1' } }
|
||||
)
|
||||
const second = await brain.transact(
|
||||
[{ op: 'update', id: uid('txlog-a'), metadata: { v: 2 } }],
|
||||
{ meta: { author: 'job-2' } }
|
||||
)
|
||||
const third = await brain.transact([{ op: 'update', id: uid('txlog-a'), metadata: { v: 3 } }])
|
||||
|
||||
const entries = await brain.transactionLog()
|
||||
expect(entries.map((entry) => entry.generation)).toEqual([
|
||||
third.generation,
|
||||
second.generation,
|
||||
first.generation
|
||||
])
|
||||
expect(entries[1].meta).toEqual({ author: 'job-2' })
|
||||
expect(entries[2].meta).toEqual({ author: 'job-1' })
|
||||
expect(entries[0].meta).toBeUndefined()
|
||||
for (const entry of entries) {
|
||||
expect(entry.timestamp).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
const limited = await brain.transactionLog({ limit: 2 })
|
||||
expect(limited.map((entry) => entry.generation)).toEqual([third.generation, second.generation])
|
||||
|
||||
await first.release()
|
||||
await second.release()
|
||||
await third.release()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,144 +0,0 @@
|
|||
/**
|
||||
* Reproduction test for empty tree bug in v5.3.1
|
||||
*
|
||||
* BUG: brain.getHistory() throws "Blob not found: 0000...0" when commits
|
||||
* have empty trees (entityCount: 0).
|
||||
*
|
||||
* The zero hash represents an empty tree and should be handled gracefully.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../src/brainy.js'
|
||||
import * as fs from 'fs'
|
||||
|
||||
const TEST_DATA_PATH = './test-empty-tree-bug-data'
|
||||
|
||||
describe('Empty Tree Bug (v5.3.1)', () => {
|
||||
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({ requireSubtype: false,
|
||||
storage: {
|
||||
type: 'filesystem',
|
||||
path: TEST_DATA_PATH,
|
||||
branch: 'main',
|
||||
enableCompression: true
|
||||
},
|
||||
disableAutoRebuild: true,
|
||||
silent: true
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(TEST_DATA_PATH)) {
|
||||
fs.rmSync(TEST_DATA_PATH, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle getHistory() with empty tree commit', async () => {
|
||||
// Create a commit with NO entities (empty tree)
|
||||
const commitId = await brain.commit({
|
||||
message: 'Empty workspace snapshot',
|
||||
author: 'test-user',
|
||||
metadata: {
|
||||
timestamp: Date.now(),
|
||||
isSnapshot: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(commitId).toBeTruthy()
|
||||
console.log('✅ Commit created:', commitId)
|
||||
|
||||
// Read the commit blob to inspect it
|
||||
const blobStorage = (brain.storage as any).blobStorage
|
||||
const refManager = (brain.storage as any).refManager
|
||||
const { CommitObject } = await import('../../src/storage/cow/CommitObject.js')
|
||||
|
||||
const commit = await CommitObject.read(blobStorage, commitId)
|
||||
console.log('📄 Commit structure:', JSON.stringify(commit, null, 2))
|
||||
|
||||
// Check what the ref points to
|
||||
const refHash = await refManager.resolveRef('main')
|
||||
|
||||
// If ref is zero hash, that's the bug
|
||||
const zeroHash = '0000000000000000000000000000000000000000000000000000000000000000'
|
||||
if (refHash === zeroHash) {
|
||||
throw new Error(`BUG CONFIRMED: Ref "main" points to zero hash! commitId=${commitId}, refHash=${refHash}, tree=${commit.tree}`)
|
||||
}
|
||||
|
||||
// Verify commitId is not zero hash
|
||||
expect(commitId).not.toBe(zeroHash)
|
||||
expect(refHash).toBe(commitId)
|
||||
|
||||
// This should NOT throw "Blob not found: 0000...0"
|
||||
// Empty trees should be handled gracefully
|
||||
try {
|
||||
const history = await brain.getHistory({ limit: 50 })
|
||||
|
||||
expect(history).toBeDefined()
|
||||
expect(Array.isArray(history)).toBe(true)
|
||||
console.log('✅ getHistory() works with empty tree')
|
||||
console.log(' History length:', history.length)
|
||||
} catch (error: any) {
|
||||
console.log('❌ Error during getHistory():', error.message)
|
||||
console.log(' Commit tree hash:', commit.tree)
|
||||
console.log(' Commit parent hash:', commit.parent)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle multiple commits with empty trees', async () => {
|
||||
// Create first empty commit
|
||||
await brain.commit({
|
||||
message: 'First empty snapshot',
|
||||
author: 'user1'
|
||||
})
|
||||
|
||||
// Create second empty commit
|
||||
await brain.commit({
|
||||
message: 'Second empty snapshot',
|
||||
author: 'user2'
|
||||
})
|
||||
|
||||
// Get history - should NOT throw
|
||||
const history = await brain.getHistory({ limit: 10 })
|
||||
|
||||
expect(history).toBeDefined()
|
||||
expect(history.length).toBeGreaterThanOrEqual(2)
|
||||
console.log('✅ Multiple empty commits work')
|
||||
})
|
||||
|
||||
it('should handle mixed commits (empty + with entities)', async () => {
|
||||
// Create empty commit
|
||||
await brain.commit({
|
||||
message: 'Empty snapshot',
|
||||
author: 'user1'
|
||||
})
|
||||
|
||||
// Add entity
|
||||
await brain.add({
|
||||
data: 'Test entity',
|
||||
type: 'concept'
|
||||
})
|
||||
|
||||
// Create commit with entities
|
||||
await brain.commit({
|
||||
message: 'Snapshot with entities',
|
||||
author: 'user2'
|
||||
})
|
||||
|
||||
// Get history - should handle both empty and non-empty trees
|
||||
const history = await brain.getHistory({ limit: 10 })
|
||||
|
||||
expect(history).toBeDefined()
|
||||
expect(history.length).toBeGreaterThanOrEqual(2)
|
||||
console.log('✅ Mixed commits (empty + entities) work')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
/**
|
||||
* Fork Persistence Integration Test
|
||||
*
|
||||
* Tests the complete fork() → listBranches() → checkout() workflow
|
||||
* to prevent regression of the v5.3.6 bug where fork() silently failed
|
||||
* to persist branches to storage (internal bug report).
|
||||
*
|
||||
* @see https://github.com/soulcraftlabs/brainy/issues/XXX
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../src/brainy'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
describe('Fork Persistence (v5.3.6 Bug Fix)', () => {
|
||||
let brain: Brainy
|
||||
let testDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Use filesystem storage to mirror cloud storage behavior
|
||||
testDir = `/tmp/brainy-fork-test-${Date.now()}`
|
||||
brain = new Brainy({ requireSubtype: false,
|
||||
storage: {
|
||||
adapter: 'filesystem',
|
||||
path: testDir
|
||||
},
|
||||
silent: true
|
||||
})
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Cleanup test directory
|
||||
if (fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('should persist forked branches to storage', async () => {
|
||||
// Add data and commit
|
||||
await brain.add({ data: { value: 1 }, type: 'concept' })
|
||||
const commitId = await brain.commit({
|
||||
message: 'Initial commit',
|
||||
author: 'test@example.com'
|
||||
})
|
||||
expect(commitId).toBeTruthy()
|
||||
|
||||
// Fork a new branch
|
||||
const branchName = `test-branch-${Date.now()}`
|
||||
await brain.fork(branchName, {
|
||||
author: 'test@example.com',
|
||||
message: 'Test fork for persistence'
|
||||
})
|
||||
|
||||
// CRITICAL: Verify branch exists in storage (this would fail in v5.3.5)
|
||||
const branches = await brain.listBranches()
|
||||
expect(branches).toContain(branchName)
|
||||
expect(branches).toContain('main')
|
||||
})
|
||||
|
||||
it('should allow checkout of forked branch', async () => {
|
||||
// Setup: Add data, commit, fork
|
||||
await brain.add({ data: { value: 1 }, type: 'concept' })
|
||||
await brain.commit({
|
||||
message: 'Initial commit',
|
||||
author: 'test@example.com'
|
||||
})
|
||||
|
||||
const branchName = `checkout-test-${Date.now()}`
|
||||
await brain.fork(branchName, {
|
||||
author: 'test@example.com',
|
||||
message: 'Test fork for checkout'
|
||||
})
|
||||
|
||||
// CRITICAL: Checkout should succeed (this would fail in v5.3.5)
|
||||
await expect(brain.checkout(branchName)).resolves.not.toThrow()
|
||||
|
||||
// Verify we're on the new branch
|
||||
const currentBranch = await brain.getCurrentBranch()
|
||||
expect(currentBranch).toBe(branchName)
|
||||
})
|
||||
|
||||
it('should persist fork across multiple storage operations', async () => {
|
||||
// Create initial state
|
||||
await brain.add({ data: { step: 1 }, type: 'concept' })
|
||||
await brain.commit({
|
||||
message: 'Step 1',
|
||||
author: 'test@example.com'
|
||||
})
|
||||
|
||||
// Fork branch 1
|
||||
const branch1 = `branch-1-${Date.now()}`
|
||||
await brain.fork(branch1, {
|
||||
author: 'test@example.com',
|
||||
message: 'Fork 1'
|
||||
})
|
||||
|
||||
// Add more data on main
|
||||
await brain.add({ data: { step: 2 }, type: 'concept' })
|
||||
await brain.commit({
|
||||
message: 'Step 2 on main',
|
||||
author: 'test@example.com'
|
||||
})
|
||||
|
||||
// Fork branch 2
|
||||
const branch2 = `branch-2-${Date.now()}`
|
||||
await brain.fork(branch2, {
|
||||
author: 'test@example.com',
|
||||
message: 'Fork 2'
|
||||
})
|
||||
|
||||
// Verify both branches exist
|
||||
const branches = await brain.listBranches()
|
||||
expect(branches).toContain('main')
|
||||
expect(branches).toContain(branch1)
|
||||
expect(branches).toContain(branch2)
|
||||
|
||||
// Verify all branches are accessible
|
||||
await expect(brain.checkout(branch1)).resolves.not.toThrow()
|
||||
await expect(brain.checkout(branch2)).resolves.not.toThrow()
|
||||
await expect(brain.checkout('main')).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('should return new Brainy instance pointing to fork', async () => {
|
||||
// Verify fork() returns a working Brainy instance on the new branch
|
||||
|
||||
await brain.add({ data: { value: 1 }, type: 'concept' })
|
||||
await brain.commit({
|
||||
message: 'Initial commit',
|
||||
author: 'test@example.com'
|
||||
})
|
||||
|
||||
// Fork and get new instance
|
||||
const validBranch = `return-test-${Date.now()}`
|
||||
const forkedBrain = await brain.fork(validBranch, {
|
||||
author: 'test@example.com',
|
||||
message: 'Test fork return value'
|
||||
})
|
||||
|
||||
// Verify the returned instance is on the new branch
|
||||
const forkCurrentBranch = await forkedBrain.getCurrentBranch()
|
||||
expect(forkCurrentBranch).toBe(validBranch)
|
||||
|
||||
// Verify original instance is still on main
|
||||
const mainCurrentBranch = await brain.getCurrentBranch()
|
||||
expect(mainCurrentBranch).toBe('main')
|
||||
|
||||
// Verify both instances can see the branch
|
||||
const mainBranches = await brain.listBranches()
|
||||
const forkBranches = await forkedBrain.listBranches()
|
||||
expect(mainBranches).toContain(validBranch)
|
||||
expect(forkBranches).toContain(validBranch)
|
||||
})
|
||||
|
||||
it('should handle snapshot naming convention (consumer use case)', async () => {
|
||||
// Reproduce exact Consumer snapshot workflow
|
||||
await brain.add({ data: { test: true }, type: 'concept' })
|
||||
|
||||
const commitId = await brain.commit({
|
||||
message: 'Consumer snapshot test',
|
||||
author: 'workshop@example.com'
|
||||
})
|
||||
|
||||
// Use the consumer's exact naming convention
|
||||
const timestamp = Date.now()
|
||||
const snapshotBranch = `snapshot-${timestamp}`
|
||||
|
||||
// Create snapshot branch (this is what failed in the consumer report)
|
||||
await brain.fork(snapshotBranch, {
|
||||
author: 'workshop@example.com',
|
||||
message: `Snapshot: Consumer test`,
|
||||
metadata: {
|
||||
timestamp,
|
||||
userId: 'test-user',
|
||||
isSnapshot: true,
|
||||
snapshotCommit: commitId
|
||||
}
|
||||
})
|
||||
|
||||
// Verify snapshot can be listed
|
||||
const branches = await brain.listBranches()
|
||||
expect(branches).toContain(snapshotBranch)
|
||||
|
||||
// Verify snapshot can be restored (checked out)
|
||||
await expect(brain.checkout(snapshotBranch)).resolves.not.toThrow()
|
||||
|
||||
// Verify we're on the snapshot branch
|
||||
const currentBranch = await brain.getCurrentBranch()
|
||||
expect(currentBranch).toBe(snapshotBranch)
|
||||
})
|
||||
|
||||
it('should verify _cow paths are not branch-scoped', async () => {
|
||||
// This test verifies the resolveBranchPath fix works correctly
|
||||
// by checking that COW metadata is accessible globally
|
||||
|
||||
await brain.add({ data: { value: 1 }, type: 'concept' })
|
||||
await brain.commit({
|
||||
message: 'Test commit',
|
||||
author: 'test@example.com'
|
||||
})
|
||||
|
||||
const branch1 = `branch-a-${Date.now()}`
|
||||
const branch2 = `branch-b-${Date.now()}`
|
||||
|
||||
// Create two branches
|
||||
await brain.fork(branch1, {
|
||||
author: 'test@example.com',
|
||||
message: 'Branch A'
|
||||
})
|
||||
|
||||
await brain.fork(branch2, {
|
||||
author: 'test@example.com',
|
||||
message: 'Branch B'
|
||||
})
|
||||
|
||||
// From any branch, we should be able to list ALL branches
|
||||
// This proves COW metadata is global, not branch-scoped
|
||||
await brain.checkout(branch1)
|
||||
let branches = await brain.listBranches()
|
||||
expect(branches).toContain('main')
|
||||
expect(branches).toContain(branch1)
|
||||
expect(branches).toContain(branch2)
|
||||
|
||||
await brain.checkout(branch2)
|
||||
branches = await brain.listBranches()
|
||||
expect(branches).toContain('main')
|
||||
expect(branches).toContain(branch1)
|
||||
expect(branches).toContain(branch2)
|
||||
|
||||
await brain.checkout('main')
|
||||
branches = await brain.listBranches()
|
||||
expect(branches).toContain('main')
|
||||
expect(branches).toContain(branch1)
|
||||
expect(branches).toContain(branch2)
|
||||
})
|
||||
|
||||
it('should persist fork with metadata', async () => {
|
||||
// Test that metadata is preserved in fork
|
||||
await brain.add({ data: { value: 1 }, type: 'concept' })
|
||||
await brain.commit({
|
||||
message: 'Test commit',
|
||||
author: 'test@example.com'
|
||||
})
|
||||
|
||||
const branchName = `metadata-test-${Date.now()}`
|
||||
const metadata = {
|
||||
timestamp: Date.now(),
|
||||
userId: 'test-user-123',
|
||||
customField: 'test-value',
|
||||
nested: { data: true }
|
||||
}
|
||||
|
||||
await brain.fork(branchName, {
|
||||
author: 'test@example.com',
|
||||
message: 'Fork with metadata',
|
||||
metadata
|
||||
})
|
||||
|
||||
// Verify branch exists
|
||||
const branches = await brain.listBranches()
|
||||
expect(branches).toContain(branchName)
|
||||
|
||||
// Verify branch is accessible
|
||||
await expect(brain.checkout(branchName)).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
/**
|
||||
* Regression test for v5.3.0 history ref resolution bug
|
||||
*
|
||||
* BUG: brain.getHistory() was passing `heads/${branch}` to commitLog.getHistory(),
|
||||
* but RefManager.normalizeRefName() expects either:
|
||||
* - Full ref: 'refs/heads/main'
|
||||
* - Short name: 'main' (normalized to 'refs/heads/main')
|
||||
*
|
||||
* Passing 'heads/main' caused normalization to 'refs/heads/heads/main' → ref not found!
|
||||
*
|
||||
* Fixed in: src/brainy.ts lines 2354, 2856, 2895
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../src/brainy.js'
|
||||
import * as fs from 'fs'
|
||||
|
||||
const TEST_DATA_PATH = './test-history-ref-bug-data'
|
||||
|
||||
describe('History Ref Resolution Bug (v5.3.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({ requireSubtype: false,
|
||||
storage: {
|
||||
type: 'filesystem',
|
||||
path: TEST_DATA_PATH,
|
||||
branch: 'main',
|
||||
enableCompression: true
|
||||
},
|
||||
disableAutoRebuild: true,
|
||||
silent: true
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(TEST_DATA_PATH)) {
|
||||
fs.rmSync(TEST_DATA_PATH, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('should retrieve history after commit (bug: ref not found)', async () => {
|
||||
// Add an entity
|
||||
await brain.add({
|
||||
data: 'Test entity for history bug',
|
||||
type: 'concept',
|
||||
metadata: { test: true }
|
||||
})
|
||||
|
||||
// Create a commit
|
||||
const commitId = await brain.commit({
|
||||
message: 'Test snapshot',
|
||||
author: 'dev-user',
|
||||
metadata: {
|
||||
timestamp: Date.now(),
|
||||
isSnapshot: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(commitId).toBeTruthy()
|
||||
expect(commitId.length).toBe(64) // SHA-256 hash
|
||||
|
||||
// THIS WAS FAILING with "Ref not found: heads/main"
|
||||
// Because getHistory() passed 'heads/main' instead of 'main'
|
||||
// After fix: should not throw "Ref not found" error
|
||||
try {
|
||||
const history = await brain.getHistory({ limit: 50 })
|
||||
|
||||
expect(history).toBeDefined()
|
||||
expect(Array.isArray(history)).toBe(true)
|
||||
// Note: history might be empty due to empty tree blob issue,
|
||||
// but the important part is NO "Ref not found: heads/main" error
|
||||
} catch (error: any) {
|
||||
// Should NOT get "Ref not found: heads/main" error
|
||||
expect(error.message).not.toContain('Ref not found: heads/main')
|
||||
// Other errors (like tree blob) are separate issues
|
||||
if (error.message.includes('Blob not found')) {
|
||||
// This is a known issue with empty tree - not our bug
|
||||
console.log('✅ Ref resolution working (tree blob issue is separate)')
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
it('should not throw ref resolution error for multiple commits', async () => {
|
||||
// Create first commit
|
||||
await brain.add({ data: 'Entity 1', type: 'concept' })
|
||||
await brain.commit({
|
||||
message: 'First commit',
|
||||
author: 'user1'
|
||||
})
|
||||
|
||||
// Create second commit
|
||||
await brain.add({ data: 'Entity 2', type: 'concept' })
|
||||
await brain.commit({
|
||||
message: 'Second commit',
|
||||
author: 'user2'
|
||||
})
|
||||
|
||||
// Get history - should NOT throw "Ref not found: heads/main"
|
||||
try {
|
||||
const history = await brain.getHistory({ limit: 10 })
|
||||
expect(history).toBeDefined()
|
||||
console.log('✅ getHistory() works without ref resolution error')
|
||||
} catch (error: any) {
|
||||
// Should NOT get "Ref not found: heads/main" error
|
||||
expect(error.message).not.toContain('Ref not found: heads/main')
|
||||
expect(error.message).not.toContain('Ref not found: main')
|
||||
// Other errors are acceptable
|
||||
console.log('✅ No ref resolution error (other error is acceptable)')
|
||||
}
|
||||
})
|
||||
|
||||
it('should use fork() to test COW (correct API)', async () => {
|
||||
// fork() is the public API that uses COW internally
|
||||
await brain.add({
|
||||
data: 'Original entity',
|
||||
type: 'concept'
|
||||
})
|
||||
|
||||
// Fork creates a new branch and switches to it
|
||||
const fork = await brain.fork('feature-branch')
|
||||
|
||||
// Add entity in fork
|
||||
await fork.add({
|
||||
data: 'Fork entity',
|
||||
type: 'concept'
|
||||
})
|
||||
|
||||
// Both should work without ref resolution errors
|
||||
expect(fork).toBeDefined()
|
||||
console.log('✅ fork() works (uses COW refs internally)')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
/**
|
||||
* Hybrid Search COW Integration Tests (v7.7.0)
|
||||
*
|
||||
* Verifies that hybrid search works correctly with:
|
||||
* - fork() - COW branching
|
||||
* - asOf() - Historical queries
|
||||
* - VFS entities
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../src/brainy'
|
||||
import { NounType } from '../../src/types/graphTypes'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
|
||||
describe('Hybrid Search with COW (v7.7.0)', () => {
|
||||
let brain: Brainy<any>
|
||||
let testDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Use filesystem storage for COW support
|
||||
testDir = path.join(os.tmpdir(), `brainy-cow-test-${Date.now()}`)
|
||||
fs.mkdirSync(testDir, { recursive: true })
|
||||
|
||||
brain = new Brainy({ requireSubtype: false,
|
||||
storage: {
|
||||
type: 'filesystem',
|
||||
options: { basePath: testDir }
|
||||
}
|
||||
})
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await brain.close()
|
||||
// Cleanup test directory
|
||||
try {
|
||||
fs.rmSync(testDir, { recursive: true, force: true })
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
describe('fork() compatibility', () => {
|
||||
it('should perform hybrid search in forked brain', async () => {
|
||||
// Add entity to main branch
|
||||
const mainId = await brain.add({
|
||||
data: 'Python programming language tutorial',
|
||||
type: NounType.Document,
|
||||
metadata: { branch: 'main' }
|
||||
})
|
||||
|
||||
// Commit main branch
|
||||
await brain.commit({ message: 'Add Python doc' })
|
||||
|
||||
// Fork
|
||||
const fork = await brain.fork('feature-branch')
|
||||
|
||||
// Add entity to fork
|
||||
const forkId = await fork.add({
|
||||
data: 'JavaScript programming guide',
|
||||
type: NounType.Document,
|
||||
metadata: { branch: 'feature' }
|
||||
})
|
||||
|
||||
// Hybrid search in fork should find both
|
||||
const forkResults = await fork.find({
|
||||
query: 'programming',
|
||||
limit: 10
|
||||
})
|
||||
|
||||
expect(forkResults.length).toBeGreaterThanOrEqual(2)
|
||||
expect(forkResults.some(r => r.id === mainId)).toBe(true)
|
||||
expect(forkResults.some(r => r.id === forkId)).toBe(true)
|
||||
|
||||
// Hybrid search in main should only find main entity
|
||||
const mainResults = await brain.find({
|
||||
query: 'programming',
|
||||
limit: 10
|
||||
})
|
||||
|
||||
expect(mainResults.some(r => r.id === mainId)).toBe(true)
|
||||
// Fork entity should NOT be visible in main
|
||||
expect(mainResults.some(r => r.id === forkId)).toBe(false)
|
||||
|
||||
await fork.close()
|
||||
})
|
||||
|
||||
it('should support text-only search in forked brain', async () => {
|
||||
await brain.add({
|
||||
data: 'exact keyword match test',
|
||||
type: NounType.Document,
|
||||
metadata: { test: true }
|
||||
})
|
||||
await brain.commit({ message: 'Add test doc' })
|
||||
|
||||
const fork = await brain.fork('text-search-test')
|
||||
|
||||
const results = await fork.find({
|
||||
query: 'exact keyword',
|
||||
searchMode: 'text',
|
||||
limit: 5
|
||||
})
|
||||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
await fork.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('VFS compatibility', () => {
|
||||
it('should include VFS file content in hybrid search', async () => {
|
||||
// Write a file via VFS
|
||||
const vfs = brain.vfs
|
||||
await vfs.writeFile('/docs/readme.txt', 'This is a readme file with important information')
|
||||
|
||||
// Search should find VFS content
|
||||
const results = await brain.find({
|
||||
query: 'readme important',
|
||||
limit: 10
|
||||
})
|
||||
|
||||
// VFS entities should appear in results
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should exclude VFS with excludeVFS flag in hybrid search', async () => {
|
||||
// Add regular entity
|
||||
const entityId = await brain.add({
|
||||
data: 'regular document about readme files',
|
||||
type: NounType.Document,
|
||||
metadata: { source: 'api' }
|
||||
})
|
||||
|
||||
// Write VFS file
|
||||
await brain.vfs.writeFile('/readme.md', 'VFS readme content')
|
||||
|
||||
// Search with excludeVFS should only find regular entity
|
||||
const results = await brain.find({
|
||||
query: 'readme',
|
||||
excludeVFS: true,
|
||||
limit: 10
|
||||
})
|
||||
|
||||
expect(results.some(r => r.id === entityId)).toBe(true)
|
||||
// VFS entities should be excluded
|
||||
expect(results.every(r => !r.metadata?.vfsType)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
80
tests/integration/hybrid-search-vfs.test.ts
Normal file
80
tests/integration/hybrid-search-vfs.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Hybrid Search VFS Integration Tests
|
||||
*
|
||||
* Verifies that hybrid search works correctly with VFS entities:
|
||||
* file content is searchable by default, and `excludeVFS` filters
|
||||
* VFS entities out of results.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../src/brainy'
|
||||
import { NounType } from '../../src/types/graphTypes'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
|
||||
describe('Hybrid Search with VFS', () => {
|
||||
let brain: Brainy<any>
|
||||
let testDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = path.join(os.tmpdir(), `brainy-hybrid-vfs-test-${Date.now()}`)
|
||||
fs.mkdirSync(testDir, { recursive: true })
|
||||
|
||||
brain = new Brainy({ requireSubtype: false,
|
||||
storage: {
|
||||
type: 'filesystem',
|
||||
options: { basePath: testDir }
|
||||
}
|
||||
})
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await brain.close()
|
||||
// Cleanup test directory
|
||||
try {
|
||||
fs.rmSync(testDir, { recursive: true, force: true })
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
it('should include VFS file content in hybrid search', async () => {
|
||||
// Write a file via VFS
|
||||
const vfs = brain.vfs
|
||||
await vfs.writeFile('/docs/readme.txt', 'This is a readme file with important information')
|
||||
|
||||
// Search should find VFS content
|
||||
const results = await brain.find({
|
||||
query: 'readme important',
|
||||
limit: 10
|
||||
})
|
||||
|
||||
// VFS entities should appear in results
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should exclude VFS with excludeVFS flag in hybrid search', async () => {
|
||||
// Add regular entity
|
||||
const entityId = await brain.add({
|
||||
data: 'regular document about readme files',
|
||||
type: NounType.Document,
|
||||
metadata: { source: 'api' }
|
||||
})
|
||||
|
||||
// Write VFS file
|
||||
await brain.vfs.writeFile('/readme.md', 'VFS readme content')
|
||||
|
||||
// Search with excludeVFS should only find regular entity
|
||||
const results = await brain.find({
|
||||
query: 'readme',
|
||||
excludeVFS: true,
|
||||
limit: 10
|
||||
})
|
||||
|
||||
expect(results.some(r => r.id === entityId)).toBe(true)
|
||||
// VFS entities should be excluded
|
||||
expect(results.every(r => !r.metadata?.vfsType)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
/**
|
||||
* Regression test for v5.3.3 NULL parent bug
|
||||
*
|
||||
* BUG: CommitObject.walk() didn't guard against NULL parent hash, causing
|
||||
* "Blob metadata not found: 0000...0000" error when calling getHistory()
|
||||
* on a fresh database with only the initial commit.
|
||||
*
|
||||
* ROOT CAUSE: Initial commit has parent = null or NULL_HASH ('0000...0000'),
|
||||
* but while(currentHash) treated the string as truthy and tried to read it.
|
||||
*
|
||||
* FIXED IN: v5.3.4
|
||||
* - Added isNullHash() guard in CommitObject.walk()
|
||||
* - Added defensive check in BlobStorage.read()
|
||||
* - Created NULL_HASH constant to prevent hardcoding
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../src/brainy.js'
|
||||
import { NULL_HASH } from '../../src/storage/cow/constants.js'
|
||||
import * as fs from 'fs'
|
||||
|
||||
const TEST_DATA_PATH = './test-null-parent-data'
|
||||
|
||||
describe('Initial Commit NULL Parent Bug (v5.3.3 regression)', () => {
|
||||
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({ requireSubtype: false,
|
||||
storage: {
|
||||
type: 'filesystem',
|
||||
path: TEST_DATA_PATH,
|
||||
branch: 'main',
|
||||
enableCompression: true
|
||||
},
|
||||
disableAutoRebuild: true,
|
||||
silent: true
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(TEST_DATA_PATH)) {
|
||||
fs.rmSync(TEST_DATA_PATH, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle getHistory() on fresh database with only initial commit', async () => {
|
||||
// Fresh brain has only the initial commit (created during init)
|
||||
// This was throwing "Blob metadata not found: 0000...0000" in v5.3.3
|
||||
|
||||
// Get history - should NOT throw
|
||||
const history = await brain.getHistory({ limit: 10 })
|
||||
|
||||
// Should return the initial commit
|
||||
expect(history).toBeDefined()
|
||||
expect(Array.isArray(history)).toBe(true)
|
||||
expect(history.length).toBe(1)
|
||||
|
||||
// Initial commit should have specific properties
|
||||
const initialCommit = history[0]
|
||||
expect(initialCommit.message).toBe('Initial commit')
|
||||
expect(initialCommit.author).toBe('system')
|
||||
// Parent can be null or undefined for initial commit
|
||||
expect(initialCommit.parent == null).toBe(true)
|
||||
})
|
||||
|
||||
it('should stop walk at initial commit (parent = null)', async () => {
|
||||
// Note: brain.commit() creates "Snapshot commit" messages by default
|
||||
// Create 2 commits
|
||||
await brain.add({ data: 'First entity', type: 'concept' })
|
||||
await brain.commit('First user commit')
|
||||
|
||||
await brain.add({ data: 'Second entity', type: 'concept' })
|
||||
await brain.commit('Second user commit')
|
||||
|
||||
// Get full history
|
||||
const history = await brain.getHistory({ limit: 100 })
|
||||
|
||||
// Should have at least 3 commits (2 user + 1 initial)
|
||||
expect(history.length).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// Last commit should be initial commit with no parent
|
||||
const initialCommit = history[history.length - 1]
|
||||
expect(initialCommit.message).toBe('Initial commit')
|
||||
// Parent can be null or undefined for initial commit
|
||||
expect(initialCommit.parent == null).toBe(true)
|
||||
})
|
||||
|
||||
it('should not attempt to read NULL hash from BlobStorage', async () => {
|
||||
// Direct test: attempting to read NULL_HASH should throw clear error
|
||||
const blobStorage = (brain as any).storage.blobStorage
|
||||
|
||||
// Should throw clear error message
|
||||
await expect(
|
||||
blobStorage.read(NULL_HASH)
|
||||
).rejects.toThrow(/sentinel value/)
|
||||
|
||||
// Error message should mention what NULL_HASH is for
|
||||
await expect(
|
||||
blobStorage.read(NULL_HASH)
|
||||
).rejects.toThrow(/no parent/)
|
||||
})
|
||||
|
||||
it('should handle multiple calls to getHistory() consistently', async () => {
|
||||
// Ensure caching doesn't break NULL hash handling
|
||||
|
||||
// First call
|
||||
const history1 = await brain.getHistory({ limit: 10 })
|
||||
expect(history1.length).toBe(1)
|
||||
|
||||
// Add a commit
|
||||
await brain.add({ data: 'Test entity', type: 'concept' })
|
||||
await brain.commit('Test commit')
|
||||
|
||||
// Second call (should now have 2 commits)
|
||||
const history2 = await brain.getHistory({ limit: 10 })
|
||||
expect(history2.length).toBe(2)
|
||||
|
||||
// Third call (should still have 2 commits)
|
||||
const history3 = await brain.getHistory({ limit: 10 })
|
||||
expect(history3.length).toBe(2)
|
||||
|
||||
// All should end with initial commit
|
||||
expect(history1[history1.length - 1].message).toBe('Initial commit')
|
||||
expect(history2[history2.length - 1].message).toBe('Initial commit')
|
||||
expect(history3[history3.length - 1].message).toBe('Initial commit')
|
||||
})
|
||||
|
||||
it('should handle walking with maxDepth on initial commit', async () => {
|
||||
// Edge case: maxDepth = 1 on fresh database
|
||||
|
||||
const history = await brain.getHistory({ limit: 1 })
|
||||
|
||||
expect(history.length).toBe(1)
|
||||
expect(history[0].message).toBe('Initial commit')
|
||||
})
|
||||
})
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
* Integration tests for Memory Enhancements (v5.11.0)
|
||||
*
|
||||
* End-to-end tests verifying:
|
||||
* - streamHistory() works in production scenarios
|
||||
* - Container detection works correctly
|
||||
* - Memory limits are applied correctly
|
||||
* - getMemoryStats() provides accurate information
|
||||
|
|
@ -40,86 +39,6 @@ describe('Memory Enhancements Integration (v5.11.0)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('Production Workflow: Consumer Snapshot Timeline', () => {
|
||||
it('should stream 1000 snapshots efficiently', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
storage: {
|
||||
type: 'filesystem',
|
||||
options: { path: testDir }
|
||||
},
|
||||
silent: true
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
// Create a realistic workflow: user creates entities and saves snapshots
|
||||
for (let i = 0; i < 100; i++) {
|
||||
// Add 10 entities
|
||||
for (let j = 0; j < 10; j++) {
|
||||
await brain.add({
|
||||
type: 'document',
|
||||
data: `Chapter ${i}, Section ${j}`
|
||||
})
|
||||
}
|
||||
|
||||
// Save snapshot
|
||||
await brain.commit({ message: `Version ${i}`, captureState: true })
|
||||
}
|
||||
|
||||
// Stream history efficiently
|
||||
const snapshots: string[] = []
|
||||
const startTime = Date.now()
|
||||
|
||||
for await (const commit of brain.streamHistory({ limit: 100 })) {
|
||||
snapshots.push(commit.message)
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
expect(snapshots.length).toBe(100)
|
||||
expect(duration).toBeLessThan(5000) // Should complete in < 5s
|
||||
|
||||
await brain.close()
|
||||
})
|
||||
|
||||
it('should handle large snapshot with filtering', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
storage: {
|
||||
type: 'filesystem',
|
||||
options: { path: testDir }
|
||||
},
|
||||
silent: true
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
// Create snapshots by different authors
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await brain.add({ type: 'document', data: `Content ${i}` })
|
||||
|
||||
await brain.commit({
|
||||
message: `Snapshot ${i}`,
|
||||
author: i % 3 === 0 ? 'alice' : i % 3 === 1 ? 'bob' : 'charlie',
|
||||
captureState: true
|
||||
})
|
||||
}
|
||||
|
||||
// Stream only alice's snapshots
|
||||
const aliceSnapshots: any[] = []
|
||||
|
||||
for await (const commit of brain.streamHistory({ author: 'alice', limit: 100 })) {
|
||||
aliceSnapshots.push(commit)
|
||||
}
|
||||
|
||||
expect(aliceSnapshots.length).toBeGreaterThan(0)
|
||||
aliceSnapshots.forEach(commit => {
|
||||
expect(commit.author).toBe('alice')
|
||||
})
|
||||
|
||||
await brain.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Production Workflow: Cloud Run Deployment', () => {
|
||||
it('should detect 4GB Cloud Run container and set optimal limits', async () => {
|
||||
process.env.CLOUD_RUN_MEMORY = '4Gi'
|
||||
|
|
@ -190,51 +109,6 @@ describe('Memory Enhancements Integration (v5.11.0)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('Combined Features: Stream + Memory Management', () => {
|
||||
it('should stream large histories within memory limits', async () => {
|
||||
process.env.CLOUD_RUN_MEMORY = '2Gi'
|
||||
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
storage: {
|
||||
type: 'filesystem',
|
||||
options: { path: testDir }
|
||||
},
|
||||
silent: true
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
// Create many snapshots
|
||||
for (let i = 0; i < 200; i++) {
|
||||
await brain.add({ type: 'document', data: `Data ${i}` })
|
||||
|
||||
if (i % 5 === 0) {
|
||||
await brain.commit({ message: `Checkpoint ${i / 5}`, captureState: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Verify memory limits
|
||||
const stats = brain.getMemoryStats()
|
||||
expect(stats.limits.maxQueryLimit).toBe(5000) // 2GB * 0.25
|
||||
|
||||
// Stream all snapshots (should be ~40)
|
||||
const heapBefore = process.memoryUsage().heapUsed
|
||||
|
||||
let count = 0
|
||||
for await (const commit of brain.streamHistory({ limit: 100 })) {
|
||||
count++
|
||||
}
|
||||
|
||||
const heapAfter = process.memoryUsage().heapUsed
|
||||
const heapGrowth = heapAfter - heapBefore
|
||||
|
||||
expect(count).toBeGreaterThan(0)
|
||||
expect(heapGrowth).toBeLessThan(20 * 1024 * 1024) // < 20MB growth
|
||||
|
||||
await brain.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Memory Stats API', () => {
|
||||
it('should provide actionable recommendations', async () => {
|
||||
process.env.CLOUD_RUN_MEMORY = '4Gi'
|
||||
|
|
@ -352,32 +226,5 @@ describe('Memory Enhancements Integration (v5.11.0)', () => {
|
|||
|
||||
await brain.close()
|
||||
})
|
||||
|
||||
it('should keep getHistory() working as before', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
storage: {
|
||||
type: 'filesystem',
|
||||
options: { path: testDir }
|
||||
},
|
||||
silent: true
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
// Create some snapshots
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await brain.add({ type: 'note', data: `Test ${i}` })
|
||||
await brain.commit({ message: `Snapshot ${i}`, captureState: true })
|
||||
}
|
||||
|
||||
// Old API still works
|
||||
const history = await brain.getHistory({ limit: 10 })
|
||||
|
||||
expect(history.length).toBe(10)
|
||||
expect(history[0]).toHaveProperty('hash')
|
||||
expect(history[0]).toHaveProperty('message')
|
||||
|
||||
await brain.close()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -291,40 +291,6 @@ describe('Metadata-Only Comprehensive Integration (v5.11.1)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('COW and Fork', () => {
|
||||
it('should work with metadata-only in forks', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
storage: { adapter: 'memory' },
|
||||
silent: true
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
const id = await brain.add({
|
||||
data: 'Original',
|
||||
type: NounType.Document,
|
||||
metadata: { version: 1 }
|
||||
})
|
||||
|
||||
await brain.commit({ message: 'Initial commit' })
|
||||
|
||||
const fork = await brain.fork('test-branch')
|
||||
|
||||
// Metadata-only in fork
|
||||
const entity = await fork.get(id)
|
||||
expect(entity).toBeTruthy()
|
||||
expect(entity!.metadata.version).toBe(1)
|
||||
expect(entity!.vector).toEqual([])
|
||||
|
||||
// Full entity in fork
|
||||
const full = await fork.get(id, { includeVectors: true })
|
||||
expect(full!.vector.length).toBe(384)
|
||||
|
||||
await brain.close()
|
||||
await fork.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance Verification', () => {
|
||||
it('metadata-only should be significantly faster', async () => {
|
||||
const brain = new Brainy({ requireSubtype: false,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Verifies:
|
||||
* - MigrationRunner with in-memory storage
|
||||
* - brain.migrate() public API (dry-run and apply)
|
||||
* - Backup branch creation with metadata tagging
|
||||
* - Pre-migration snapshots via `backupTo` (persist-before-migrate + restore)
|
||||
* - No-op when MIGRATIONS array is empty
|
||||
* - Warning log when autoMigrate is false
|
||||
* - Multi-tenant independent migration state
|
||||
|
|
@ -12,6 +12,9 @@
|
|||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { Brainy } from '../../src/brainy.js'
|
||||
import { MigrationRunner, MIGRATIONS } from '../../src/migration/index.js'
|
||||
import type { Migration } from '../../src/migration/index.js'
|
||||
|
|
@ -242,13 +245,21 @@ describe('Migration System', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// ─── Backup branch tests ──────────────────────────────────────
|
||||
// ─── Pre-migration snapshot tests ─────────────────────────────
|
||||
|
||||
describe('Backup branches', () => {
|
||||
it('should create a backup branch before migrating', async () => {
|
||||
describe('Pre-migration snapshots (backupTo)', () => {
|
||||
let backupDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
backupDir = path.join(os.tmpdir(), `brainy-migration-backup-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(backupDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('should persist a snapshot before migrating and report its path', async () => {
|
||||
await brain.add({ type: NounType.Concept, data: { name: 'Test' }, metadata: { x: 1 } })
|
||||
// Need a commit for fork to work
|
||||
await brain.commit({ message: 'test', author: 'test' })
|
||||
|
||||
const migration: Migration = {
|
||||
id: 'test-backup',
|
||||
|
|
@ -259,45 +270,19 @@ describe('Migration System', () => {
|
|||
}
|
||||
|
||||
await withMigrations([migration], async () => {
|
||||
const result = await brain.migrate()
|
||||
const result = await brain.migrate({ backupTo: backupDir })
|
||||
const r = result as any
|
||||
expect(r.backupBranch).toBe('pre-migration-2.0.0')
|
||||
expect(r.backupPath).toBe(backupDir)
|
||||
expect(r.migrationsApplied).toContain('test-backup')
|
||||
|
||||
const branches = await brain.listBranches()
|
||||
expect(branches).toContain('pre-migration-2.0.0')
|
||||
// The snapshot is a self-contained store directory
|
||||
expect(fs.existsSync(backupDir)).toBe(true)
|
||||
expect(fs.readdirSync(backupDir).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should tag backup branch with system:backup metadata', async () => {
|
||||
await brain.add({ type: NounType.Concept, data: { name: 'Test' }, metadata: { z: 1 } })
|
||||
await brain.commit({ message: 'test', author: 'test' })
|
||||
|
||||
const migration: Migration = {
|
||||
id: 'test-tag',
|
||||
version: '4.0.0',
|
||||
description: 'Tag test',
|
||||
applies: 'nouns',
|
||||
transform: (m) => 'z' in m ? { ...m, tagged: true } : null
|
||||
}
|
||||
|
||||
await withMigrations([migration], async () => {
|
||||
await brain.migrate()
|
||||
|
||||
// Verify metadata tag on the backup branch ref
|
||||
const refManager = (brain as any).storage.refManager
|
||||
if (refManager) {
|
||||
const ref = await refManager.getRef('pre-migration-4.0.0')
|
||||
expect(ref).toBeDefined()
|
||||
expect(ref?.metadata?.type).toBe('system:backup')
|
||||
expect(ref?.metadata?.migrationVersion).toBe('4.0.0')
|
||||
expect(ref?.metadata?.author).toBe('brainy-migration')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep backup branch as a named restore point', async () => {
|
||||
it('should restore the pre-migration state wholesale from the snapshot', async () => {
|
||||
const id = await brain.add({ type: NounType.Concept, data: { name: 'Restore Test' }, metadata: { original: true } })
|
||||
await brain.commit({ message: 'before migration', author: 'test' })
|
||||
|
||||
const migration: Migration = {
|
||||
id: 'test-restore',
|
||||
|
|
@ -313,29 +298,39 @@ describe('Migration System', () => {
|
|||
}
|
||||
|
||||
await withMigrations([migration], async () => {
|
||||
const result = await brain.migrate()
|
||||
const result = await brain.migrate({ backupTo: backupDir })
|
||||
const r = result as any
|
||||
expect(r.backupPath).toBe(backupDir)
|
||||
|
||||
// Verify migration was applied
|
||||
const migrated = await brain.get(id)
|
||||
expect(migrated?.metadata?.migrated).toBe(true)
|
||||
expect(migrated?.metadata?.original).toBe(false)
|
||||
|
||||
// Verify backup branch exists as a named restore point
|
||||
expect(r.backupBranch).toBe('pre-migration-3.0.0')
|
||||
const branches = await brain.listBranches()
|
||||
expect(branches).toContain('pre-migration-3.0.0')
|
||||
// Restore the snapshot — the store returns to its pre-migration state
|
||||
await brain.restore(backupDir, { confirm: true })
|
||||
const restored = await brain.get(id)
|
||||
expect(restored?.metadata?.original).toBe(true)
|
||||
expect(restored?.metadata?.migrated).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// Verify the backup ref points to the pre-migration commit
|
||||
const refManager = (brain as any).storage.refManager
|
||||
if (refManager) {
|
||||
const mainRef = await refManager.getRef('main')
|
||||
const backupRef = await refManager.getRef('pre-migration-3.0.0')
|
||||
expect(backupRef).toBeDefined()
|
||||
// Backup was forked before migration, so both share the same commit
|
||||
// (the pre-migration commit). After migration, main's overlay has new data,
|
||||
// but the commit pointer is unchanged.
|
||||
expect(backupRef.commitHash).toBe(mainRef.commitHash)
|
||||
}
|
||||
it('should report backupPath null when no backupTo is supplied', async () => {
|
||||
await brain.add({ type: NounType.Concept, data: { name: 'NoBackup' }, metadata: { q: 1 } })
|
||||
|
||||
const migration: Migration = {
|
||||
id: 'test-no-backup',
|
||||
version: '4.0.0',
|
||||
description: 'Add field',
|
||||
applies: 'nouns',
|
||||
transform: (m) => 'q' in m && !('r' in m) ? { ...m, r: 2 } : null
|
||||
}
|
||||
|
||||
await withMigrations([migration], async () => {
|
||||
const result = await brain.migrate()
|
||||
const r = result as any
|
||||
expect(r.backupPath).toBeNull()
|
||||
expect(r.migrationsApplied).toContain('test-no-backup')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -520,86 +515,6 @@ describe('Migration System', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// ─── Multi-branch migration ─────────────────────────────────
|
||||
|
||||
describe('Multi-branch migration', () => {
|
||||
it('should migrate entities on all branches including feature branches', async () => {
|
||||
// Setup: create entities on main, fork a branch, add entities on the branch
|
||||
const mainId = await brain.add({
|
||||
type: NounType.Concept,
|
||||
data: { name: 'Main Entity' },
|
||||
metadata: { legacy: true, source: 'main' }
|
||||
})
|
||||
await brain.commit({ message: 'initial', author: 'test' })
|
||||
|
||||
// Fork a feature branch and add branch-local entity
|
||||
const fork = await brain.fork('feature-x')
|
||||
const branchId = await fork.add({
|
||||
type: NounType.Concept,
|
||||
data: { name: 'Branch Entity' },
|
||||
metadata: { legacy: true, source: 'branch' }
|
||||
})
|
||||
|
||||
// Define migration that transforms the 'legacy' field
|
||||
const migration: Migration = {
|
||||
id: 'test-multi-branch',
|
||||
version: '1.0.0',
|
||||
description: 'Mark legacy entities as upgraded',
|
||||
applies: 'nouns',
|
||||
transform: (m) => {
|
||||
if (m.legacy === true) {
|
||||
return { ...m, legacy: false, upgraded: true }
|
||||
}
|
||||
return null // Already migrated or not applicable
|
||||
}
|
||||
}
|
||||
|
||||
await withMigrations([migration], async () => {
|
||||
// Migrate from main — should reach all branches
|
||||
const result = await brain.migrate()
|
||||
const r = result as any
|
||||
|
||||
expect(r.migrationsApplied).toContain('test-multi-branch')
|
||||
// Main entity + branch-local entity should both be modified
|
||||
expect(r.entitiesModified).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Verify main entity was transformed
|
||||
const mainEntity = await brain.get(mainId)
|
||||
expect(mainEntity?.metadata?.upgraded).toBe(true)
|
||||
expect(mainEntity?.metadata?.legacy).toBe(false)
|
||||
})
|
||||
|
||||
await fork.close()
|
||||
})
|
||||
|
||||
it('should skip system:backup branches during multi-branch migration', async () => {
|
||||
await brain.add({ type: NounType.Concept, data: { name: 'Test' }, metadata: { v: 1 } })
|
||||
await brain.commit({ message: 'test', author: 'test' })
|
||||
|
||||
// Create two migrations — first creates a backup branch, second should skip it
|
||||
const migration1: Migration = {
|
||||
id: 'test-skip-backup-1',
|
||||
version: '5.0.0',
|
||||
description: 'First migration',
|
||||
applies: 'nouns',
|
||||
transform: (m) => typeof m.v === 'number' && !('m1' in m) ? { ...m, m1: true } : null
|
||||
}
|
||||
|
||||
await withMigrations([migration1], async () => {
|
||||
const result = await brain.migrate()
|
||||
const r = result as any
|
||||
expect(r.backupBranch).toBe('pre-migration-5.0.0')
|
||||
|
||||
// Verify backup branch exists
|
||||
const branches = await brain.listBranches()
|
||||
expect(branches).toContain('pre-migration-5.0.0')
|
||||
|
||||
// Migration should not have errored from trying to migrate the backup
|
||||
expect(r.migrationsApplied).toContain('test-skip-backup-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── MigrationRunner unit-level tests ─────────────────────────
|
||||
|
||||
describe('MigrationRunner', () => {
|
||||
|
|
@ -684,47 +599,6 @@ describe('Migration System', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('should propagate errors from branch migrations into the result', async () => {
|
||||
// Create entity on main, commit, fork a branch, add a branch-local entity that will fail
|
||||
await brain.add({ type: NounType.Concept, data: { name: 'MainOk' }, metadata: { branchTest: 1 } })
|
||||
await brain.commit({ message: 'initial', author: 'test' })
|
||||
|
||||
const fork = await brain.fork('branch-with-error')
|
||||
await fork.add({ type: NounType.Concept, data: { name: 'BranchBad' }, metadata: { branchTest: 'crash' } })
|
||||
|
||||
const migration: Migration = {
|
||||
id: 'test-branch-error-prop',
|
||||
version: '1.0.0',
|
||||
description: 'Throws on non-number branchTest',
|
||||
applies: 'nouns',
|
||||
transform: (m) => {
|
||||
if ('branchTest' in m) {
|
||||
if (typeof m.branchTest !== 'number') {
|
||||
throw new Error('branchTest must be a number')
|
||||
}
|
||||
return { ...m, branchTest: (m.branchTest as number) * 10 }
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
await withMigrations([migration], async () => {
|
||||
const result = await brain.migrate()
|
||||
const r = result as any
|
||||
|
||||
// Main entity should have migrated successfully
|
||||
expect(r.migrationsApplied).toContain('test-branch-error-prop')
|
||||
expect(r.entitiesModified).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Branch error should appear in the combined result
|
||||
expect(r.errors.length).toBeGreaterThanOrEqual(1)
|
||||
const branchError = r.errors.find((e: any) => e.error.includes('branchTest must be a number'))
|
||||
expect(branchError).toBeDefined()
|
||||
})
|
||||
|
||||
await fork.close()
|
||||
})
|
||||
|
||||
it('should stop early when maxErrors is exceeded', async () => {
|
||||
// Add many entities that will all fail
|
||||
for (let i = 0; i < 5; i++) {
|
||||
|
|
|
|||
|
|
@ -1,229 +0,0 @@
|
|||
/**
|
||||
* Phase 3 Integration Tests - Type-First Query Optimization
|
||||
*
|
||||
* End-to-end tests verifying Phase 3 works with the complete Brainy system
|
||||
* Target: 8 tests covering real-world scenarios
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../src/brainy.js'
|
||||
import { NounType } from '../../src/types/graphTypes.js'
|
||||
import { TypeAwareHNSWIndex } from '../../src/hnsw/typeAwareHNSWIndex.js'
|
||||
|
||||
describe('Phase 3: Type-First Query Optimization - Integration', () => {
|
||||
let brainy: Brainy<any>
|
||||
|
||||
beforeEach(async () => {
|
||||
// Initialize with memory storage and TypeAwareHNSWIndex
|
||||
brainy = new Brainy({ requireSubtype: false,
|
||||
name: 'phase3-test',
|
||||
dimension: 384,
|
||||
storage: {
|
||||
type: 'memory' // Use memory for fast tests
|
||||
},
|
||||
index: {
|
||||
M: 16,
|
||||
efConstruction: 200,
|
||||
efSearch: 50
|
||||
},
|
||||
debug: false // Disable debug logging for tests
|
||||
})
|
||||
|
||||
await brainy.initialize()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up
|
||||
await brainy.close()
|
||||
})
|
||||
|
||||
// ========== Basic Type Inference Tests (3 tests) ==========
|
||||
|
||||
describe('Basic Type Inference', () => {
|
||||
it('should automatically infer Person type from "engineer" query', async () => {
|
||||
// Add test data
|
||||
await brainy.add({
|
||||
type: NounType.Person,
|
||||
data: { name: 'Alice', role: 'engineer' }
|
||||
})
|
||||
|
||||
await brainy.add({
|
||||
type: NounType.Document,
|
||||
data: { title: 'Engineering Guide' }
|
||||
})
|
||||
|
||||
// Query with natural language
|
||||
const results = await brainy.find('Find engineers')
|
||||
|
||||
// Should find Person, not Document
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
|
||||
// Verify TypeAwareHNSWIndex is being used
|
||||
expect((brainy as any).index).toBeInstanceOf(TypeAwareHNSWIndex)
|
||||
})
|
||||
|
||||
it('should handle queries with explicit type override', async () => {
|
||||
await brainy.add({
|
||||
type: NounType.Person,
|
||||
data: { name: 'Bob', role: 'developer' }
|
||||
})
|
||||
|
||||
await brainy.add({
|
||||
type: NounType.Document,
|
||||
data: { title: 'Development Process' }
|
||||
})
|
||||
|
||||
// Query with explicit type should override inference
|
||||
const results = await brainy.find({
|
||||
query: 'development',
|
||||
type: NounType.Document // Explicit override
|
||||
})
|
||||
|
||||
// Should only find documents
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(results.every(r => r.entity.noun === NounType.Document)).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle multi-type queries efficiently', async () => {
|
||||
// Add diverse data
|
||||
await brainy.add({
|
||||
type: NounType.Person,
|
||||
data: { name: 'Charlie', company: 'TechCorp' }
|
||||
})
|
||||
|
||||
await brainy.add({
|
||||
type: NounType.Organization,
|
||||
data: { name: 'TechCorp', industry: 'Software' }
|
||||
})
|
||||
|
||||
await brainy.add({
|
||||
type: NounType.Document,
|
||||
data: { title: 'TechCorp Overview' }
|
||||
})
|
||||
|
||||
// Query that should infer multiple types
|
||||
const results = await brainy.find('people at TechCorp')
|
||||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
|
||||
// Should find both Person and Organization
|
||||
const types = results.map(r => r.entity.noun)
|
||||
expect(types).toContain(NounType.Person)
|
||||
})
|
||||
})
|
||||
|
||||
// ========== Performance Tests (2 tests) ==========
|
||||
|
||||
describe('Performance Impact', () => {
|
||||
it('should reduce query latency for type-specific queries', async () => {
|
||||
// Add 100 diverse entities
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await brainy.add({
|
||||
type: NounType.Person,
|
||||
data: { name: `Person ${i}`, role: 'engineer' }
|
||||
})
|
||||
}
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await brainy.add({
|
||||
type: NounType.Document,
|
||||
data: { title: `Document ${i}` }
|
||||
})
|
||||
}
|
||||
|
||||
// Measure query with type inference
|
||||
const start = Date.now()
|
||||
const results = await brainy.find('Find engineers')
|
||||
const elapsed = Date.now() - start
|
||||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(elapsed).toBeLessThan(1000) // Should be fast even with 100 entities
|
||||
|
||||
// Verify results are correct type
|
||||
const personResults = results.filter(r => r.entity.noun === NounType.Person)
|
||||
expect(personResults.length).toBeGreaterThan(0)
|
||||
}, 10000)
|
||||
|
||||
it('should handle high-volume queries without degradation', async () => {
|
||||
// Add test data
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await brainy.add({
|
||||
type: NounType.Person,
|
||||
data: { name: `Person ${i}` }
|
||||
})
|
||||
}
|
||||
|
||||
// Run 10 queries in sequence
|
||||
const latencies: number[] = []
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const start = Date.now()
|
||||
await brainy.find('Find people')
|
||||
const elapsed = Date.now() - start
|
||||
latencies.push(elapsed)
|
||||
}
|
||||
|
||||
// Verify consistent performance (no degradation)
|
||||
const avgLatency = latencies.reduce((a, b) => a + b, 0) / latencies.length
|
||||
const maxLatency = Math.max(...latencies)
|
||||
|
||||
expect(maxLatency).toBeLessThan(avgLatency * 2) // Max should not be > 2x avg
|
||||
}, 15000)
|
||||
})
|
||||
|
||||
// ========== Edge Cases (2 tests) ==========
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle queries with no matching types gracefully', async () => {
|
||||
await brainy.add({
|
||||
type: NounType.Person,
|
||||
data: { name: 'Dave' }
|
||||
})
|
||||
|
||||
// Query that won't infer any specific type
|
||||
const results = await brainy.find('random stuff xyz')
|
||||
|
||||
// Should still work (fallback to all-types)
|
||||
expect(Array.isArray(results)).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle empty query gracefully', async () => {
|
||||
await brainy.add({
|
||||
type: NounType.Person,
|
||||
data: { name: 'Eve' }
|
||||
})
|
||||
|
||||
// Empty query should return results
|
||||
const results = await brainy.find({ limit: 5 })
|
||||
|
||||
expect(Array.isArray(results)).toBe(true)
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ========== Backward Compatibility (1 test) ==========
|
||||
|
||||
describe('Backward Compatibility', () => {
|
||||
it('should work with all existing query patterns', async () => {
|
||||
await brainy.add({
|
||||
type: NounType.Person,
|
||||
data: { name: 'Frank', age: 30 }
|
||||
})
|
||||
|
||||
// Test various query patterns
|
||||
const results1 = await brainy.find({ query: 'Frank' })
|
||||
expect(results1.length).toBeGreaterThan(0)
|
||||
|
||||
const results2 = await brainy.find({ type: NounType.Person })
|
||||
expect(results2.length).toBeGreaterThan(0)
|
||||
|
||||
const results3 = await brainy.find({
|
||||
where: { age: 30 }
|
||||
})
|
||||
expect(results3.length).toBeGreaterThan(0)
|
||||
|
||||
const results4 = await brainy.find('Find Frank')
|
||||
expect(results4.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
/**
|
||||
* Storage-Level Batch Operations Test Suite v5.12.0
|
||||
*
|
||||
* Comprehensive testing of new storage-level batch APIs:
|
||||
* Comprehensive testing of storage-level batch APIs:
|
||||
* - storage.getNounMetadataBatch() - Batch metadata reads
|
||||
* - storage.readBatchWithInheritance() - COW-aware batch reads
|
||||
* - storage.getVerbsBySourceBatch() - Batch relationship queries
|
||||
* - brain.batchGet() - High-level batch entity retrieval
|
||||
* - PathResolver.getChildren() - VFS batch operations
|
||||
|
|
@ -11,10 +10,8 @@
|
|||
* Coverage:
|
||||
* ✅ Type-aware storage compatibility
|
||||
* ✅ Sharding preservation
|
||||
* ✅ COW (Copy-on-Write) integration
|
||||
* ✅ fork() and branch isolation
|
||||
* ✅ Write-cache coherence
|
||||
* ✅ Performance improvements (N+1 → batched)
|
||||
* ✅ Cloud adapter native batch APIs
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
|
|
@ -129,13 +126,13 @@ describe('Storage-Level Batch Operations v5.12.0', () => {
|
|||
})
|
||||
|
||||
describe('storage.getNounMetadataBatch() - Storage Layer', () => {
|
||||
it('should batch fetch noun metadata with type caching', async () => {
|
||||
it('should batch fetch noun metadata across types via ID-first paths', async () => {
|
||||
// Add entities of different types
|
||||
const id1 = await brain.add({ type: 'document', data: 'Doc' })
|
||||
const id2 = await brain.add({ type: 'thing', data: 'Thing' })
|
||||
const id3 = await brain.add({ type: 'person', data: 'Person' })
|
||||
|
||||
// Access storage directly
|
||||
// Access storage directly (8.0 layout: ID-first paths, no type lookup)
|
||||
const storage = brain.storage as any
|
||||
const results = await storage.getNounMetadataBatch([id1, id2, id3])
|
||||
|
||||
|
|
@ -143,32 +140,18 @@ describe('Storage-Level Batch Operations v5.12.0', () => {
|
|||
expect(results.get(id1)?.noun).toBe('document')
|
||||
expect(results.get(id2)?.noun).toBe('thing')
|
||||
expect(results.get(id3)?.noun).toBe('person')
|
||||
|
||||
// Type cache should be populated
|
||||
expect(storage.nounTypeCache.has(id1)).toBe(true)
|
||||
expect(storage.nounTypeCache.get(id1)).toBe('document')
|
||||
})
|
||||
|
||||
it('should handle uncached IDs by trying multiple types', async () => {
|
||||
// Add entity
|
||||
it('should omit missing ids from the result map', async () => {
|
||||
const id = await brain.add({ type: 'document', data: 'Test' })
|
||||
const missing = '00000000-0000-4000-8000-000000000000'
|
||||
|
||||
// Clear type cache to simulate uncached scenario
|
||||
const storage = brain.storage as any
|
||||
storage.nounTypeCache.delete(id)
|
||||
|
||||
// Batch fetch should still work (tries all types)
|
||||
const results = await storage.getNounMetadataBatch([id])
|
||||
const results = await storage.getNounMetadataBatch([id, missing])
|
||||
|
||||
expect(results.size).toBe(1)
|
||||
expect(results.get(id)?.noun).toBe('document')
|
||||
|
||||
// Cache should be repopulated (or may still be empty if metadata doesn't populate it)
|
||||
// This is acceptable as long as the data is retrieved correctly
|
||||
const cachedType = storage.nounTypeCache.get(id)
|
||||
if (cachedType !== undefined) {
|
||||
expect(cachedType).toBe('document')
|
||||
}
|
||||
expect(results.has(missing)).toBe(false)
|
||||
})
|
||||
|
||||
it('should preserve sharding in all paths', async () => {
|
||||
|
|
@ -212,32 +195,7 @@ describe('Storage-Level Batch Operations v5.12.0', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('COW Integration - readBatchWithInheritance()', () => {
|
||||
it('should resolve branch paths before reading', async () => {
|
||||
// Add entity on main
|
||||
const id = await brain.add({ type: 'document', data: 'Main branch' })
|
||||
|
||||
// Create fork
|
||||
const fork = await brain.fork('test-branch')
|
||||
|
||||
// Add entity on fork
|
||||
const forkId = await fork.add({ type: 'document', data: 'Fork branch' })
|
||||
|
||||
// Batch get on fork should see fork entity
|
||||
const forkResults = await fork.batchGet([forkId, id])
|
||||
|
||||
expect(forkResults.size).toBe(2)
|
||||
expect(forkResults.get(forkId)?.data).toBe('Fork branch')
|
||||
expect(forkResults.get(id)?.data).toBe('Main branch') // Inherited
|
||||
|
||||
// Batch get on main should NOT see fork entity
|
||||
const mainResults = await brain.batchGet([forkId, id])
|
||||
|
||||
expect(mainResults.size).toBe(1)
|
||||
expect(mainResults.has(forkId)).toBe(false) // Not on main
|
||||
expect(mainResults.get(id)?.data).toBe('Main branch')
|
||||
})
|
||||
|
||||
describe('Write-cache coherence', () => {
|
||||
it('should respect write cache for dirty entities', async () => {
|
||||
// Add entity
|
||||
const id = await brain.add({ type: 'document', data: 'Original' })
|
||||
|
|
@ -250,29 +208,6 @@ describe('Storage-Level Batch Operations v5.12.0', () => {
|
|||
|
||||
expect(results.get(id)?.data).toBe('Updated')
|
||||
})
|
||||
|
||||
it('should inherit from parent commits for missing entities', async () => {
|
||||
// Add entities on main
|
||||
const id1 = await brain.add({ type: 'document', data: 'Main 1' })
|
||||
const id2 = await brain.add({ type: 'document', data: 'Main 2' })
|
||||
|
||||
// Commit
|
||||
await brain.commit('Initial entities')
|
||||
|
||||
// Create fork
|
||||
const fork = await brain.fork('child-branch')
|
||||
|
||||
// Add new entity only on fork
|
||||
const forkId = await fork.add({ type: 'document', data: 'Fork only' })
|
||||
|
||||
// Batch get on fork should inherit main entities
|
||||
const results = await fork.batchGet([id1, id2, forkId])
|
||||
|
||||
expect(results.size).toBe(3)
|
||||
expect(results.get(id1)?.data).toBe('Main 1') // Inherited
|
||||
expect(results.get(id2)?.data).toBe('Main 2') // Inherited
|
||||
expect(results.get(forkId)?.data).toBe('Fork only') // Fork's own
|
||||
})
|
||||
})
|
||||
|
||||
describe('getVerbsBySourceBatch() - Batch Relationship Queries', () => {
|
||||
|
|
|
|||
|
|
@ -1,527 +0,0 @@
|
|||
/**
|
||||
* TypeAwareHNSW Integration Tests
|
||||
*
|
||||
* End-to-end tests for Phase 2 Type-Aware HNSW implementation.
|
||||
* Tests cover:
|
||||
* - Brainy integration (add, find)
|
||||
* - Storage integration (FileSystem, Memory)
|
||||
* - Rebuild functionality
|
||||
* - Cache behavior
|
||||
* - Large datasets
|
||||
* - Performance characteristics
|
||||
*
|
||||
* Total: 15 integration tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { TypeAwareHNSWIndex } from '../../src/hnsw/typeAwareHNSWIndex.js'
|
||||
import type { NounType } from '../../src/types/graphTypes.js'
|
||||
import { euclideanDistance } from '../../src/utils/index.js'
|
||||
import { MemoryStorage } from '../../src/storage/adapters/memoryStorage.js'
|
||||
import { FileSystemStorage } from '../../src/storage/adapters/fileSystemStorage.js'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
describe('TypeAwareHNSW Integration Tests', () => {
|
||||
const TEST_DATA_DIR = path.join(process.cwd(), '.test-data-type-aware-hnsw')
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up test data directory
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// ===================================================================
|
||||
// 1. STORAGE INTEGRATION
|
||||
// ===================================================================
|
||||
|
||||
describe('Storage Integration', () => {
|
||||
it('should work with MemoryStorage', async () => {
|
||||
const storage = new MemoryStorage()
|
||||
const index = new TypeAwareHNSWIndex(
|
||||
{ M: 4, efConstruction: 50, efSearch: 20 },
|
||||
euclideanDistance,
|
||||
{ storage }
|
||||
)
|
||||
|
||||
// Add entities with valid UUIDs
|
||||
const personId = uuidv4()
|
||||
const docId = uuidv4()
|
||||
|
||||
await index.addItem(
|
||||
{ id: personId, vector: [1, 0, 0] },
|
||||
'person' as NounType
|
||||
)
|
||||
await index.addItem(
|
||||
{ id: docId, vector: [0, 1, 0] },
|
||||
'document' as NounType
|
||||
)
|
||||
|
||||
// Search
|
||||
const results = await index.search([1, 0, 0], 2)
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0][0]).toBe(personId) // Closest to [1,0,0]
|
||||
})
|
||||
|
||||
it('should work with FileSystemStorage', async () => {
|
||||
const storage = new FileSystemStorage(TEST_DATA_DIR)
|
||||
await storage.init()
|
||||
|
||||
const index = new TypeAwareHNSWIndex(
|
||||
{ M: 4, efConstruction: 50, efSearch: 20 },
|
||||
euclideanDistance,
|
||||
{ storage }
|
||||
)
|
||||
|
||||
// Add entities with valid UUIDs
|
||||
const personId = uuidv4()
|
||||
const docId = uuidv4()
|
||||
|
||||
await index.addItem(
|
||||
{ id: personId, vector: [1, 0, 0] },
|
||||
'person' as NounType
|
||||
)
|
||||
await index.addItem(
|
||||
{ id: docId, vector: [0, 1, 0] },
|
||||
'document' as NounType
|
||||
)
|
||||
|
||||
// Search should work
|
||||
const results = await index.search([1, 0, 0], 2)
|
||||
expect(results).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ===================================================================
|
||||
// 2. REBUILD FUNCTIONALITY
|
||||
// ===================================================================
|
||||
|
||||
describe('Rebuild Functionality', () => {
|
||||
it('should handle rebuild gracefully when no data exists', async () => {
|
||||
const storage = new MemoryStorage()
|
||||
await storage.init()
|
||||
|
||||
const index = new TypeAwareHNSWIndex(
|
||||
{ M: 4, efConstruction: 50, efSearch: 20 },
|
||||
euclideanDistance,
|
||||
{ storage }
|
||||
)
|
||||
|
||||
// Rebuild with no data - should not crash
|
||||
await index.rebuild()
|
||||
|
||||
expect(index.size()).toBe(0)
|
||||
})
|
||||
|
||||
it('should skip rebuild when no storage adapter', async () => {
|
||||
const index = new TypeAwareHNSWIndex(
|
||||
{ M: 4, efConstruction: 50, efSearch: 20 },
|
||||
euclideanDistance,
|
||||
{ storage: null }
|
||||
)
|
||||
|
||||
// Should not crash when storage is null
|
||||
await index.rebuild()
|
||||
|
||||
expect(index.size()).toBe(0)
|
||||
})
|
||||
|
||||
it('should allow specifying types to rebuild', async () => {
|
||||
const storage = new MemoryStorage()
|
||||
await storage.init()
|
||||
|
||||
const index = new TypeAwareHNSWIndex(
|
||||
{ M: 4, efConstruction: 50, efSearch: 20 },
|
||||
euclideanDistance,
|
||||
{ storage }
|
||||
)
|
||||
|
||||
// Rebuild specific types (no data, but should not crash)
|
||||
await index.rebuild({ types: ['person', 'document'] as NounType[] })
|
||||
|
||||
expect(index.size()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ===================================================================
|
||||
// 3. LARGE DATASET TESTS
|
||||
// ===================================================================
|
||||
|
||||
describe('Large Dataset Tests', () => {
|
||||
it('should handle 1000 entities across 5 types', async () => {
|
||||
const storage = new MemoryStorage()
|
||||
const index = new TypeAwareHNSWIndex(
|
||||
{ M: 8, efConstruction: 100, efSearch: 50 },
|
||||
euclideanDistance,
|
||||
{ storage }
|
||||
)
|
||||
|
||||
const types: NounType[] = [
|
||||
'person',
|
||||
'document',
|
||||
'event',
|
||||
'organization',
|
||||
'location'
|
||||
]
|
||||
|
||||
// Add 200 entities per type = 1000 total
|
||||
for (const type of types) {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
await index.addItem(
|
||||
{
|
||||
id: `${type}-${i}`,
|
||||
vector: [
|
||||
Math.random(),
|
||||
Math.random(),
|
||||
Math.random(),
|
||||
Math.random()
|
||||
]
|
||||
},
|
||||
type
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
expect(index.size()).toBe(1000)
|
||||
expect(index.getActiveTypes()).toHaveLength(5)
|
||||
|
||||
// Verify each type has correct count
|
||||
for (const type of types) {
|
||||
expect(index.sizeForType(type)).toBe(200)
|
||||
}
|
||||
|
||||
// Verify search works
|
||||
const results = await index.search([0.5, 0.5, 0.5, 0.5], 10)
|
||||
expect(results).toHaveLength(10)
|
||||
}, 30000) // 30s timeout for large dataset
|
||||
|
||||
it('should handle unbalanced distribution (1 dominant type)', async () => {
|
||||
const storage = new MemoryStorage()
|
||||
const index = new TypeAwareHNSWIndex(
|
||||
{ M: 8, efConstruction: 100, efSearch: 50 },
|
||||
euclideanDistance,
|
||||
{ storage }
|
||||
)
|
||||
|
||||
// Add 900 person entities (dominant type)
|
||||
for (let i = 0; i < 900; i++) {
|
||||
await index.addItem(
|
||||
{
|
||||
id: `person-${i}`,
|
||||
vector: [Math.random(), Math.random(), Math.random()]
|
||||
},
|
||||
'person' as NounType
|
||||
)
|
||||
}
|
||||
|
||||
// Add 100 document entities
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await index.addItem(
|
||||
{
|
||||
id: `doc-${i}`,
|
||||
vector: [Math.random(), Math.random(), Math.random()]
|
||||
},
|
||||
'document' as NounType
|
||||
)
|
||||
}
|
||||
|
||||
expect(index.size()).toBe(1000)
|
||||
expect(index.sizeForType('person' as NounType)).toBe(900)
|
||||
expect(index.sizeForType('document' as NounType)).toBe(100)
|
||||
|
||||
// Verify search works correctly for both types
|
||||
const personResults = await index.search(
|
||||
[0.5, 0.5, 0.5],
|
||||
10,
|
||||
'person' as NounType
|
||||
)
|
||||
const docResults = await index.search(
|
||||
[0.5, 0.5, 0.5],
|
||||
10,
|
||||
'document' as NounType
|
||||
)
|
||||
|
||||
expect(personResults.length).toBeGreaterThanOrEqual(10)
|
||||
expect(docResults.length).toBeGreaterThanOrEqual(10)
|
||||
|
||||
// All person results should be from person type
|
||||
personResults.forEach((result) => {
|
||||
expect(result[0]).toMatch(/^person-/)
|
||||
})
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
// ===================================================================
|
||||
// 4. TYPE-SPECIFIC QUERIES
|
||||
// ===================================================================
|
||||
|
||||
describe('Type-Specific Queries', () => {
|
||||
let index: TypeAwareHNSWIndex
|
||||
|
||||
beforeEach(async () => {
|
||||
const storage = new MemoryStorage()
|
||||
index = new TypeAwareHNSWIndex(
|
||||
{ M: 4, efConstruction: 50, efSearch: 20 },
|
||||
euclideanDistance,
|
||||
{ storage }
|
||||
)
|
||||
|
||||
// Add entities of different types
|
||||
await index.addItem(
|
||||
{ id: 'person-1', vector: [1, 0, 0] },
|
||||
'person' as NounType
|
||||
)
|
||||
await index.addItem(
|
||||
{ id: 'person-2', vector: [0.9, 0.1, 0] },
|
||||
'person' as NounType
|
||||
)
|
||||
await index.addItem(
|
||||
{ id: 'doc-1', vector: [0, 1, 0] },
|
||||
'document' as NounType
|
||||
)
|
||||
await index.addItem(
|
||||
{ id: 'doc-2', vector: [0, 0.9, 0.1] },
|
||||
'document' as NounType
|
||||
)
|
||||
await index.addItem(
|
||||
{ id: 'event-1', vector: [0, 0, 1] },
|
||||
'event' as NounType
|
||||
)
|
||||
})
|
||||
|
||||
it('should search single type only (fast path)', async () => {
|
||||
const results = await index.search([1, 0, 0], 2, 'person' as NounType)
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0][0]).toBe('person-1')
|
||||
expect(results[1][0]).toBe('person-2')
|
||||
|
||||
// Verify no document or event results
|
||||
results.forEach((result) => {
|
||||
expect(result[0]).toMatch(/^person-/)
|
||||
})
|
||||
})
|
||||
|
||||
it('should search multiple types', async () => {
|
||||
const results = await index.search(
|
||||
[0.5, 0.5, 0],
|
||||
5,
|
||||
['person', 'document'] as NounType[]
|
||||
)
|
||||
|
||||
expect(results).toHaveLength(4) // 2 person + 2 document
|
||||
|
||||
const ids = results.map((r) => r[0])
|
||||
expect(ids).toContain('person-1')
|
||||
expect(ids).toContain('person-2')
|
||||
expect(ids).toContain('doc-1')
|
||||
expect(ids).toContain('doc-2')
|
||||
expect(ids).not.toContain('event-1') // Not searched
|
||||
})
|
||||
|
||||
it('should fall back to all-types search when type unknown', async () => {
|
||||
const results = await index.search([0, 0, 1], 5)
|
||||
|
||||
expect(results).toHaveLength(5)
|
||||
|
||||
const ids = results.map((r) => r[0])
|
||||
expect(ids).toContain('event-1') // Found in all-types search
|
||||
})
|
||||
})
|
||||
|
||||
// ===================================================================
|
||||
// 5. MEMORY ISOLATION
|
||||
// ===================================================================
|
||||
|
||||
describe('Memory Isolation', () => {
|
||||
it('should maintain separate memory for each type', async () => {
|
||||
const storage = new MemoryStorage()
|
||||
const index = new TypeAwareHNSWIndex(
|
||||
{ M: 4, efConstruction: 50, efSearch: 20 },
|
||||
euclideanDistance,
|
||||
{ storage }
|
||||
)
|
||||
|
||||
// Add entities of different types
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await index.addItem(
|
||||
{ id: `person-${i}`, vector: [Math.random(), Math.random(), 0] },
|
||||
'person' as NounType
|
||||
)
|
||||
}
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await index.addItem(
|
||||
{ id: `doc-${i}`, vector: [0, Math.random(), Math.random()] },
|
||||
'document' as NounType
|
||||
)
|
||||
}
|
||||
|
||||
// Get stats to verify memory isolation
|
||||
const stats = index.getStats()
|
||||
|
||||
expect(stats.typeCount).toBe(2)
|
||||
expect(stats.typeStats.has('person' as NounType)).toBe(true)
|
||||
expect(stats.typeStats.has('document' as NounType)).toBe(true)
|
||||
|
||||
const personStats = stats.typeStats.get('person' as NounType)!
|
||||
const docStats = stats.typeStats.get('document' as NounType)!
|
||||
|
||||
expect(personStats.nodeCount).toBe(100)
|
||||
expect(docStats.nodeCount).toBe(100)
|
||||
|
||||
// Verify memory is tracked separately (may be 0 in MemoryStorage)
|
||||
expect(personStats.memoryMB).toBeGreaterThanOrEqual(0)
|
||||
expect(docStats.memoryMB).toBeGreaterThanOrEqual(0)
|
||||
|
||||
// Clear one type and verify other is unaffected
|
||||
index.clearType('person' as NounType)
|
||||
|
||||
expect(index.sizeForType('person' as NounType)).toBe(0)
|
||||
expect(index.sizeForType('document' as NounType)).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
// ===================================================================
|
||||
// 6. CACHE BEHAVIOR
|
||||
// ===================================================================
|
||||
|
||||
describe('Cache Behavior', () => {
|
||||
it('should use UnifiedCache across all type indexes', async () => {
|
||||
const storage = new MemoryStorage()
|
||||
const index = new TypeAwareHNSWIndex(
|
||||
{ M: 4, efConstruction: 50, efSearch: 20 },
|
||||
euclideanDistance,
|
||||
{ storage }
|
||||
)
|
||||
|
||||
// Add entities to different types
|
||||
await index.addItem(
|
||||
{ id: 'person-1', vector: [1, 0, 0] },
|
||||
'person' as NounType
|
||||
)
|
||||
await index.addItem(
|
||||
{ id: 'doc-1', vector: [0, 1, 0] },
|
||||
'document' as NounType
|
||||
)
|
||||
|
||||
// Get cache stats for each type
|
||||
const personStats = index.getStatsForType('person' as NounType)
|
||||
const docStats = index.getStatsForType('document' as NounType)
|
||||
|
||||
expect(personStats).not.toBeNull()
|
||||
expect(docStats).not.toBeNull()
|
||||
|
||||
// Verify cache stats are available
|
||||
expect(personStats!.cacheStats).toBeDefined()
|
||||
expect(docStats!.cacheStats).toBeDefined()
|
||||
|
||||
// UnifiedCache is shared, so both should have cache stats
|
||||
expect(personStats!.cacheStats.hnswCache).toBeDefined()
|
||||
expect(docStats!.cacheStats.hnswCache).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ===================================================================
|
||||
// 7. PERFORMANCE CHARACTERISTICS
|
||||
// ===================================================================
|
||||
|
||||
describe('Performance Characteristics', () => {
|
||||
it('should have faster single-type search than all-types search', async () => {
|
||||
const storage = new MemoryStorage()
|
||||
const index = new TypeAwareHNSWIndex(
|
||||
{ M: 8, efConstruction: 100, efSearch: 50 },
|
||||
euclideanDistance,
|
||||
{ storage }
|
||||
)
|
||||
|
||||
// Add 500 entities across 5 types
|
||||
const types: NounType[] = [
|
||||
'person',
|
||||
'document',
|
||||
'event',
|
||||
'organization',
|
||||
'location'
|
||||
]
|
||||
|
||||
for (const type of types) {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await index.addItem(
|
||||
{
|
||||
id: `${type}-${i}`,
|
||||
vector: [Math.random(), Math.random(), Math.random()]
|
||||
},
|
||||
type
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Measure single-type search time
|
||||
const singleTypeStart = Date.now()
|
||||
await index.search([0.5, 0.5, 0.5], 10, 'person' as NounType)
|
||||
const singleTypeTime = Date.now() - singleTypeStart
|
||||
|
||||
// Measure all-types search time
|
||||
const allTypesStart = Date.now()
|
||||
await index.search([0.5, 0.5, 0.5], 10)
|
||||
const allTypesTime = Date.now() - allTypesStart
|
||||
|
||||
// Single-type should be faster (but this is a loose check for small dataset)
|
||||
// At billion scale, this difference would be 10x
|
||||
expect(singleTypeTime).toBeLessThanOrEqual(allTypesTime * 2)
|
||||
}, 15000)
|
||||
|
||||
it('should demonstrate memory reduction', async () => {
|
||||
const storage = new MemoryStorage()
|
||||
const index = new TypeAwareHNSWIndex(
|
||||
{ M: 8, efConstruction: 100, efSearch: 50 },
|
||||
euclideanDistance,
|
||||
{ storage }
|
||||
)
|
||||
|
||||
// Add 1000 entities across 10 types
|
||||
const types: NounType[] = [
|
||||
'person',
|
||||
'document',
|
||||
'event',
|
||||
'organization',
|
||||
'location',
|
||||
'product',
|
||||
'concept',
|
||||
'project',
|
||||
'task',
|
||||
'message'
|
||||
]
|
||||
|
||||
for (const type of types) {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await index.addItem(
|
||||
{
|
||||
id: `${type}-${i}`,
|
||||
vector: [Math.random(), Math.random(), Math.random(), Math.random()]
|
||||
},
|
||||
type
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const stats = index.getStats()
|
||||
|
||||
expect(stats.totalNodes).toBe(1000)
|
||||
expect(stats.typeCount).toBe(10)
|
||||
|
||||
// Verify memory reduction is calculated
|
||||
expect(stats.estimatedMonolithicMemoryMB).toBeGreaterThan(0)
|
||||
expect(stats.totalMemoryMB).toBeGreaterThanOrEqual(0)
|
||||
expect(stats.memoryReductionPercent).toBeGreaterThanOrEqual(0)
|
||||
|
||||
// At this scale, reduction should be significant
|
||||
expect(stats.totalMemoryMB).toBeLessThan(
|
||||
stats.estimatedMonolithicMemoryMB
|
||||
)
|
||||
}, 30000)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,511 +0,0 @@
|
|||
/**
|
||||
* Entity Versioning Integration Tests (v5.3.0, v6.3.0)
|
||||
*
|
||||
* Tests the complete versioning workflow:
|
||||
* - Save versions
|
||||
* - List versions
|
||||
* - Restore versions
|
||||
* - Compare versions
|
||||
* - Prune versions
|
||||
* - Branch isolation
|
||||
* - Index pollution prevention
|
||||
*
|
||||
* v6.3.0: Pure key-value storage, no index pollution, restore() updates all indexes
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { Brainy } from '../../src/brainy.js'
|
||||
import type { EntityVersion } from '../../src/versioning/VersionManager.js'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
// Helper to generate valid UUIDs for tests
|
||||
const uuid = () => randomUUID().replace(/-/g, '')
|
||||
|
||||
describe('Entity Versioning (v5.3.0)', () => {
|
||||
let brain: Brainy
|
||||
|
||||
beforeEach(async () => {
|
||||
brain = new Brainy({ requireSubtype: false,
|
||||
storage: { type: 'memory' },
|
||||
silent: true
|
||||
})
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
describe('Core Versioning API', () => {
|
||||
it('should save and retrieve versions', async () => {
|
||||
const entityId = uuid()
|
||||
|
||||
// Add initial entity
|
||||
await brain.add({
|
||||
data: 'Alice',
|
||||
id: entityId,
|
||||
type: 'person', // v6.3.0: Use valid NounType
|
||||
metadata: {
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com'
|
||||
}
|
||||
})
|
||||
|
||||
// Save version 1
|
||||
const v1 = await brain.versions.save(entityId, {
|
||||
tag: 'v1.0',
|
||||
description: 'Initial version'
|
||||
})
|
||||
|
||||
expect(v1.version).toBe(1)
|
||||
expect(v1.entityId).toBe(entityId)
|
||||
expect(v1.tag).toBe('v1.0')
|
||||
expect(v1.contentHash).toBeDefined()
|
||||
|
||||
// Update entity
|
||||
await brain.update({ id: entityId, metadata: { name: 'Alice Smith' } })
|
||||
|
||||
// Save version 2
|
||||
const v2 = await brain.versions.save(entityId, {
|
||||
tag: 'v2.0',
|
||||
description: 'Updated name'
|
||||
})
|
||||
|
||||
expect(v2.version).toBe(2)
|
||||
expect(v2.entityId).toBe(entityId)
|
||||
|
||||
// List versions
|
||||
const versions = await brain.versions.list(entityId)
|
||||
expect(versions).toHaveLength(2)
|
||||
expect(versions[0].version).toBe(2) // Newest first
|
||||
expect(versions[1].version).toBe(1)
|
||||
})
|
||||
|
||||
it('should deduplicate identical content', async () => {
|
||||
const entityId = uuid()
|
||||
|
||||
await brain.add({
|
||||
data: 'Doc',
|
||||
id: entityId,
|
||||
type: 'document',
|
||||
metadata: {
|
||||
name: 'Doc',
|
||||
content: 'Hello'
|
||||
}
|
||||
})
|
||||
|
||||
// Save version 1
|
||||
const v1 = await brain.versions.save(entityId, { tag: 'v1' })
|
||||
|
||||
// Save again without changes
|
||||
const v2 = await brain.versions.save(entityId, { 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(entityId)
|
||||
expect(versions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should restore to previous version', async () => {
|
||||
const entityId = uuid()
|
||||
|
||||
await brain.add({
|
||||
data: 'Config',
|
||||
id: entityId,
|
||||
type: 'thing',
|
||||
metadata: {
|
||||
name: 'Config',
|
||||
settings: { theme: 'light' }
|
||||
}
|
||||
})
|
||||
|
||||
// Save v1
|
||||
await brain.versions.save(entityId, { tag: 'v1' })
|
||||
|
||||
// Update
|
||||
await brain.update({ id: entityId, metadata: { settings: { theme: 'dark' } } })
|
||||
|
||||
// Save v2
|
||||
await brain.versions.save(entityId, { tag: 'v2' })
|
||||
|
||||
// Verify current state (getNounMetadata returns flat NounMetadata)
|
||||
let current = await brain.getNounMetadata(entityId)
|
||||
expect(current?.settings?.theme).toBe('dark')
|
||||
|
||||
// Restore to v1
|
||||
await brain.versions.restore(entityId, 1)
|
||||
|
||||
// Verify restored state
|
||||
current = await brain.getNounMetadata(entityId)
|
||||
expect(current?.settings?.theme).toBe('light')
|
||||
})
|
||||
|
||||
it('should compare versions', async () => {
|
||||
const entityId = uuid()
|
||||
|
||||
await brain.add({
|
||||
data: 'Bob',
|
||||
id: entityId,
|
||||
type: 'person', // v6.3.0: Use valid NounType
|
||||
metadata: {
|
||||
name: 'Bob',
|
||||
email: 'bob@example.com',
|
||||
age: 30
|
||||
}
|
||||
})
|
||||
|
||||
await brain.versions.save(entityId, { tag: 'v1' })
|
||||
|
||||
// Update
|
||||
await brain.update({
|
||||
id: entityId,
|
||||
metadata: {
|
||||
name: 'Robert',
|
||||
email: 'robert@example.com',
|
||||
city: 'NYC'
|
||||
}
|
||||
})
|
||||
|
||||
await brain.versions.save(entityId, { tag: 'v2' })
|
||||
|
||||
// Compare versions
|
||||
const diff = await brain.versions.compare(entityId, 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 () => {
|
||||
const entityId = uuid()
|
||||
|
||||
await brain.add({
|
||||
data: 'Note',
|
||||
id: entityId,
|
||||
type: 'document',
|
||||
metadata: {
|
||||
name: 'Note',
|
||||
content: 'Version 1'
|
||||
}
|
||||
})
|
||||
|
||||
await brain.versions.save(entityId, { tag: 'v1' })
|
||||
|
||||
await brain.update({ id: entityId, metadata: { content: 'Version 2' } })
|
||||
await brain.versions.save(entityId, { tag: 'v2' })
|
||||
|
||||
// Get v1 content without restoring (returns flat NounMetadata)
|
||||
const v1Content = await brain.versions.getContent(entityId, 1)
|
||||
expect(v1Content.content).toBe('Version 1')
|
||||
|
||||
// Current should still be v2 (getNounMetadata returns flat NounMetadata)
|
||||
const current = await brain.getNounMetadata(entityId)
|
||||
expect(current?.content).toBe('Version 2')
|
||||
})
|
||||
|
||||
it('should prune old versions', async () => {
|
||||
const entityId = uuid()
|
||||
|
||||
await brain.add({
|
||||
data: 'Log',
|
||||
id: entityId,
|
||||
type: 'document',
|
||||
metadata: {
|
||||
name: 'Log'
|
||||
}
|
||||
})
|
||||
|
||||
// Create 10 versions
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await brain.update({ id: entityId, metadata: { content: `Entry ${i}` } })
|
||||
await brain.versions.save(entityId, { tag: `v${i}` })
|
||||
}
|
||||
|
||||
// Verify all 10 exist
|
||||
let versions = await brain.versions.list(entityId)
|
||||
expect(versions.length).toBeGreaterThanOrEqual(10)
|
||||
|
||||
// Prune to keep only 5 most recent
|
||||
const result = await brain.versions.prune(entityId, {
|
||||
keepRecent: 5,
|
||||
keepTagged: false
|
||||
})
|
||||
|
||||
expect(result.deleted).toBeGreaterThan(0)
|
||||
expect(result.kept).toBe(5)
|
||||
|
||||
// Verify only 5 remain
|
||||
versions = await brain.versions.list(entityId)
|
||||
expect(versions).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('should support version tags', async () => {
|
||||
const entityId = uuid()
|
||||
|
||||
await brain.add({
|
||||
data: 'App',
|
||||
id: entityId,
|
||||
type: 'thing',
|
||||
metadata: {
|
||||
name: 'App'
|
||||
}
|
||||
})
|
||||
|
||||
await brain.versions.save(entityId, { tag: 'alpha' })
|
||||
await brain.update({ id: entityId, metadata: { version: '0.2' } })
|
||||
await brain.versions.save(entityId, { tag: 'beta' })
|
||||
await brain.update({ id: entityId, metadata: { version: '1.0' } })
|
||||
await brain.versions.save(entityId, { tag: 'release' })
|
||||
|
||||
// Get by tag
|
||||
const beta = await brain.versions.getVersionByTag(entityId, 'beta')
|
||||
expect(beta).toBeDefined()
|
||||
expect(beta?.tag).toBe('beta')
|
||||
|
||||
// Restore by tag
|
||||
await brain.versions.restore(entityId, 'beta')
|
||||
const current = await brain.getNounMetadata(entityId)
|
||||
expect(current?.version).toBe('0.2')
|
||||
})
|
||||
|
||||
it('should support undo/revert', async () => {
|
||||
const entityId = uuid()
|
||||
|
||||
await brain.add({
|
||||
data: 'Data',
|
||||
id: entityId,
|
||||
type: 'thing',
|
||||
metadata: {
|
||||
name: 'Data',
|
||||
value: 100
|
||||
}
|
||||
})
|
||||
|
||||
// Save v1 (value=100)
|
||||
await brain.versions.save(entityId, { tag: 'v1' })
|
||||
|
||||
// Update and save v2 (value=200)
|
||||
await brain.update({ id: entityId, metadata: { value: 200 } })
|
||||
await brain.versions.save(entityId, { tag: 'v2' })
|
||||
|
||||
// undo() restores to SECOND-MOST-RECENT version and creates a snapshot
|
||||
// Note: undo() internally calls restore() with createSnapshot: true
|
||||
const undone = await brain.versions.undo(entityId)
|
||||
expect(undone).not.toBeNull()
|
||||
|
||||
// After undo, entity should have v1's value
|
||||
const currentVal = await brain.getNounMetadata(entityId)
|
||||
expect(currentVal?.value).toBe(100) // v1's value
|
||||
|
||||
// Now we have: [before-undo, v2, v1] - undo created a snapshot
|
||||
const versionsAfterUndo = await brain.versions.list(entityId)
|
||||
expect(versionsAfterUndo.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Update and save v3 (value=300)
|
||||
await brain.update({ id: entityId, metadata: { value: 300 } })
|
||||
await brain.versions.save(entityId, { tag: 'v3' })
|
||||
|
||||
// revert() is alias for undo()
|
||||
const reverted = await brain.versions.revert(entityId)
|
||||
expect(reverted).not.toBeNull()
|
||||
|
||||
// The entity should be restored to second-most-recent version
|
||||
const revertedVal = await brain.getNounMetadata(entityId)
|
||||
// After revert from v3, we go to the previous version
|
||||
expect(revertedVal?.value).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Branch Isolation', () => {
|
||||
it('should isolate versions by branch', async () => {
|
||||
const entityId = uuid()
|
||||
|
||||
await brain.add({
|
||||
data: 'Test',
|
||||
id: entityId,
|
||||
type: 'thing',
|
||||
metadata: {
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
|
||||
// Save versions on main
|
||||
await brain.versions.save(entityId, { tag: 'main-v1' })
|
||||
await brain.update({ id: entityId, metadata: { name: 'Main Update' } })
|
||||
await brain.versions.save(entityId, { tag: 'main-v2' })
|
||||
|
||||
// Main should have versions
|
||||
const mainVersionsBefore = await brain.versions.list(entityId)
|
||||
expect(mainVersionsBefore.length).toBeGreaterThanOrEqual(2)
|
||||
// Main versions should have branch='main'
|
||||
expect(mainVersionsBefore.every(v => v.branch === 'main')).toBe(true)
|
||||
|
||||
// Switch to feature branch
|
||||
// Note: fork() creates the branch and returns a NEW instance
|
||||
// We need to checkout() to switch the current instance to the new branch
|
||||
await brain.fork('feature')
|
||||
await brain.checkout('feature')
|
||||
|
||||
// Save version on feature with unique tag
|
||||
await brain.update({ id: entityId, metadata: { name: 'Feature Update' } })
|
||||
await brain.versions.save(entityId, { tag: 'feature-only-v1' })
|
||||
|
||||
// Feature versions list - may include inherited versions from COW
|
||||
const featureVersions = await brain.versions.list(entityId)
|
||||
expect(featureVersions.length).toBeGreaterThan(0)
|
||||
|
||||
// Feature should have our unique tag (this is the NEW version on feature)
|
||||
const featureOnlyVersion = featureVersions.find(v => v.tag === 'feature-only-v1')
|
||||
expect(featureOnlyVersion).toBeDefined()
|
||||
|
||||
// The version we just saved on feature should have branch='feature'
|
||||
expect(featureOnlyVersion!.branch).toBe('feature')
|
||||
|
||||
// Switch back to main
|
||||
await brain.checkout('main')
|
||||
|
||||
// Main versions should not have feature's unique tag
|
||||
const mainVersionsAfter = await brain.versions.list(entityId)
|
||||
const featureTagOnMain = mainVersionsAfter.find(v => v.tag === 'feature-only-v1')
|
||||
expect(featureTagOnMain).toBeUndefined() // Feature-only version not on main
|
||||
|
||||
// Main should still have its original versions
|
||||
expect(mainVersionsAfter.some(v => v.tag === 'main-v1')).toBe(true)
|
||||
expect(mainVersionsAfter.some(v => v.tag === 'main-v2')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle non-existent entities gracefully', async () => {
|
||||
const nonExistentId = uuid()
|
||||
await expect(
|
||||
brain.versions.save(nonExistentId, { tag: 'v1' })
|
||||
).rejects.toThrow(`Entity ${nonExistentId} not found`)
|
||||
})
|
||||
|
||||
it('should handle empty version history', async () => {
|
||||
const entityId = uuid()
|
||||
await brain.add({ data: 'New', id: entityId, type: 'thing', metadata: { name: 'New' } })
|
||||
|
||||
expect(await brain.versions.count(entityId)).toBe(0)
|
||||
expect(await brain.versions.hasVersions(entityId)).toBe(false)
|
||||
expect(await brain.versions.getLatest(entityId)).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle version not found', async () => {
|
||||
const entityId = uuid()
|
||||
await brain.add({ data: 'Test', id: entityId, type: 'thing', metadata: { name: 'Test' } })
|
||||
|
||||
await expect(
|
||||
brain.versions.restore(entityId, 999)
|
||||
).rejects.toThrow('Version 999 not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Index Pollution Prevention (v6.3.0)', () => {
|
||||
it('should NOT pollute find() results with versions', async () => {
|
||||
const entityId = uuid()
|
||||
|
||||
// Add entity
|
||||
await brain.add({
|
||||
data: 'Test entity',
|
||||
id: entityId,
|
||||
type: 'document',
|
||||
metadata: { name: 'Test', category: 'pollution-test' }
|
||||
})
|
||||
|
||||
// Save multiple versions
|
||||
await brain.versions.save(entityId, { tag: 'v1' })
|
||||
await brain.update({ id: entityId, metadata: { name: 'Updated' } })
|
||||
await brain.versions.save(entityId, { tag: 'v2' })
|
||||
await brain.update({ id: entityId, metadata: { name: 'Updated Again' } })
|
||||
await brain.versions.save(entityId, { tag: 'v3' })
|
||||
|
||||
// Verify 3 versions exist
|
||||
const versions = await brain.versions.list(entityId)
|
||||
expect(versions).toHaveLength(3)
|
||||
|
||||
// find() should return ONLY the real entity, not version entries
|
||||
const results = await brain.find({ where: { category: 'pollution-test' } })
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].id).toBe(entityId)
|
||||
|
||||
// Verify no version entities pollute the index
|
||||
// (This was the bug - _isVersion entities appeared in find())
|
||||
const allDocs = await brain.find({ where: { type: 'document' }, limit: 100 })
|
||||
const versionEntities = allDocs.filter((e: any) => e.metadata?._isVersion)
|
||||
expect(versionEntities).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should keep current entity fully indexed after versioning', async () => {
|
||||
const entityId = uuid()
|
||||
|
||||
// Add entity
|
||||
await brain.add({
|
||||
data: 'Searchable content about machine learning',
|
||||
id: entityId,
|
||||
type: 'document',
|
||||
metadata: { name: 'ML Doc', topic: 'ai' }
|
||||
})
|
||||
|
||||
// Save versions
|
||||
await brain.versions.save(entityId, { tag: 'v1' })
|
||||
await brain.update({ id: entityId, metadata: { name: 'Updated ML Doc' } })
|
||||
await brain.versions.save(entityId, { tag: 'v2' })
|
||||
|
||||
// Entity should still be searchable by metadata
|
||||
const byMetadata = await brain.find({ where: { topic: 'ai' } })
|
||||
expect(byMetadata).toHaveLength(1)
|
||||
expect(byMetadata[0].id).toBe(entityId)
|
||||
|
||||
// Entity should still be searchable by vector similarity
|
||||
const bySimilarity = await brain.find({ query: 'machine learning', limit: 5 })
|
||||
const found = bySimilarity.find((e: any) => e.id === entityId)
|
||||
expect(found).toBeDefined()
|
||||
})
|
||||
|
||||
it('should update indexes when restoring a version', async () => {
|
||||
const entityId = uuid()
|
||||
|
||||
// Add entity with initial state
|
||||
await brain.add({
|
||||
data: 'Initial content',
|
||||
id: entityId,
|
||||
type: 'document',
|
||||
metadata: { name: 'Doc', status: 'draft' }
|
||||
})
|
||||
await brain.versions.save(entityId, { tag: 'draft' })
|
||||
|
||||
// Update to published state
|
||||
await brain.update({ id: entityId, metadata: { status: 'published' } })
|
||||
await brain.versions.save(entityId, { tag: 'published' })
|
||||
|
||||
// Verify current state
|
||||
let published = await brain.find({ where: { status: 'published' } })
|
||||
expect(published).toHaveLength(1)
|
||||
|
||||
let drafts = await brain.find({ where: { status: 'draft' } })
|
||||
expect(drafts).toHaveLength(0)
|
||||
|
||||
// Restore to draft version
|
||||
await brain.versions.restore(entityId, 'draft')
|
||||
|
||||
// Indexes should update - now entity is draft again
|
||||
published = await brain.find({ where: { status: 'published' } })
|
||||
expect(published).toHaveLength(0)
|
||||
|
||||
drafts = await brain.find({ where: { status: 'draft' } })
|
||||
expect(drafts).toHaveLength(1)
|
||||
expect(drafts[0].id).toBe(entityId)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,431 +0,0 @@
|
|||
/**
|
||||
* VFS Historical Reads Integration Test (v5.3.7)
|
||||
*
|
||||
* Tests commitId support for time-travel reads:
|
||||
* - readFile(path, { commitId })
|
||||
* - readdir(path, { commitId })
|
||||
* - stat(path, { commitId })
|
||||
* - exists(path, { commitId })
|
||||
*
|
||||
* This is the CRITICAL test for a consumer's time-travel feature.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { Brainy } from '../../src/brainy.js'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
describe('VFS Historical Reads (v5.3.7)', () => {
|
||||
const testDir = path.join(process.cwd(), 'test-vfs-historical')
|
||||
let brain: Brainy
|
||||
|
||||
beforeAll(async () => {
|
||||
// Clean up test directory
|
||||
if (fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true })
|
||||
}
|
||||
fs.mkdirSync(testDir, { recursive: true })
|
||||
|
||||
// Initialize Brainy with filesystem storage (required for COW)
|
||||
brain = new Brainy({ requireSubtype: false,
|
||||
storage: {
|
||||
adapter: 'filesystem',
|
||||
path: testDir
|
||||
}
|
||||
})
|
||||
await brain.init()
|
||||
|
||||
// Initialize VFS
|
||||
await brain.vfs.init()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up test directory
|
||||
if (fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('should read files from historical commits', async () => {
|
||||
console.log('\n📁 Test 1: Historical file reading')
|
||||
|
||||
// Commit 1: Empty workspace
|
||||
await brain.commit({
|
||||
message: 'Initial empty state',
|
||||
author: 'test@example.com'
|
||||
})
|
||||
const history1 = await brain.getHistory({ limit: 1 })
|
||||
const emptyCommitId = history1[0].hash
|
||||
console.log(` Empty commit: ${emptyCommitId.slice(0, 8)}`)
|
||||
|
||||
// Create first file
|
||||
await brain.vfs.writeFile('/test.md', 'Version 1')
|
||||
|
||||
// Commit 2: First file added
|
||||
await brain.commit({
|
||||
message: 'Added test.md',
|
||||
author: 'test@example.com'
|
||||
})
|
||||
const history2 = await brain.getHistory({ limit: 1 })
|
||||
const v1CommitId = history2[0].hash
|
||||
console.log(` V1 commit: ${v1CommitId.slice(0, 8)}`)
|
||||
|
||||
// Update file
|
||||
await brain.vfs.writeFile('/test.md', 'Version 2')
|
||||
|
||||
// Commit 3: File updated
|
||||
await brain.commit({
|
||||
message: 'Updated test.md',
|
||||
author: 'test@example.com'
|
||||
})
|
||||
const history3 = await brain.getHistory({ limit: 1 })
|
||||
const v2CommitId = history3[0].hash
|
||||
console.log(` V2 commit: ${v2CommitId.slice(0, 8)}`)
|
||||
|
||||
// Test 1a: Read current file (should be V2)
|
||||
const currentContent = await brain.vfs.readFile('/test.md')
|
||||
expect(currentContent.toString()).toBe('Version 2')
|
||||
console.log(` ✅ Current content: "${currentContent.toString()}"`)
|
||||
|
||||
// Test 1b: Read file at V1 commit (should be V1)
|
||||
const v1Content = await brain.vfs.readFile('/test.md', { commitId: v1CommitId })
|
||||
expect(v1Content.toString()).toBe('Version 1')
|
||||
console.log(` ✅ V1 content: "${v1Content.toString()}"`)
|
||||
|
||||
// Test 1c: Read file at empty commit (should fail - file didn't exist)
|
||||
await expect(
|
||||
brain.vfs.readFile('/test.md', { commitId: emptyCommitId })
|
||||
).rejects.toThrow('File not found at commit')
|
||||
console.log(` ✅ Empty commit correctly throws ENOENT`)
|
||||
})
|
||||
|
||||
it('should list directory contents from historical commits', async () => {
|
||||
console.log('\n📂 Test 2: Historical directory listing')
|
||||
|
||||
// Get current commit (from previous test, has /test.md)
|
||||
const history1 = await brain.getHistory({ limit: 1 })
|
||||
const beforeProjectCommit = history1[0].hash
|
||||
|
||||
// Create project directory with files
|
||||
await brain.vfs.mkdir('/project')
|
||||
await brain.vfs.writeFile('/project/app.ts', 'console.log("v1")')
|
||||
await brain.vfs.writeFile('/project/utils.ts', 'export const util = 1')
|
||||
|
||||
// Commit: Project created
|
||||
await brain.commit({
|
||||
message: 'Added project directory',
|
||||
author: 'test@example.com'
|
||||
})
|
||||
const history2 = await brain.getHistory({ limit: 1 })
|
||||
const projectCommit = history2[0].hash
|
||||
console.log(` Project commit: ${projectCommit.slice(0, 8)}`)
|
||||
|
||||
// Add more files
|
||||
await brain.vfs.writeFile('/project/config.json', '{"version": 1}')
|
||||
|
||||
// Commit: Config added
|
||||
await brain.commit({
|
||||
message: 'Added config',
|
||||
author: 'test@example.com'
|
||||
})
|
||||
const history3 = await brain.getHistory({ limit: 1 })
|
||||
const configCommit = history3[0].hash
|
||||
console.log(` Config commit: ${configCommit.slice(0, 8)}`)
|
||||
|
||||
// Test 2a: Read current directory (should have 3 files)
|
||||
const currentEntries = await brain.vfs.readdir('/project')
|
||||
expect(currentEntries.length).toBe(3)
|
||||
expect(currentEntries).toContain('app.ts')
|
||||
expect(currentEntries).toContain('utils.ts')
|
||||
expect(currentEntries).toContain('config.json')
|
||||
console.log(` ✅ Current directory: ${currentEntries.length} files`)
|
||||
|
||||
// Test 2b: Read directory at project commit (should have 2 files, no config)
|
||||
const projectEntries = await brain.vfs.readdir('/project', { commitId: projectCommit })
|
||||
expect(projectEntries.length).toBe(2)
|
||||
expect(projectEntries).toContain('app.ts')
|
||||
expect(projectEntries).toContain('utils.ts')
|
||||
expect(projectEntries).not.toContain('config.json')
|
||||
console.log(` ✅ Project commit directory: ${projectEntries.length} files (no config)`)
|
||||
|
||||
// Test 2c: Read directory at before-project commit (should fail - dir didn't exist)
|
||||
await expect(
|
||||
brain.vfs.readdir('/project', { commitId: beforeProjectCommit })
|
||||
).rejects.toThrow('Directory not found at commit')
|
||||
console.log(` ✅ Before project commit correctly throws ENOENT`)
|
||||
|
||||
// Test 2d: Read root directory at different commits
|
||||
const currentRoot = await brain.vfs.readdir('/')
|
||||
const projectRoot = await brain.vfs.readdir('/', { commitId: projectCommit })
|
||||
|
||||
// Current root should have both /test.md and /project
|
||||
expect(currentRoot.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Project commit root should also have both
|
||||
expect(projectRoot.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
console.log(` ✅ Root directory listings work at different commits`)
|
||||
})
|
||||
|
||||
it('should stat files from historical commits', async () => {
|
||||
console.log('\n📊 Test 3: Historical file stats')
|
||||
|
||||
// Create a file and get its initial stats
|
||||
await brain.vfs.writeFile('/stats-test.txt', 'Initial content')
|
||||
|
||||
// Commit: Initial file
|
||||
await brain.commit({
|
||||
message: 'Added stats-test.txt',
|
||||
author: 'test@example.com'
|
||||
})
|
||||
const history1 = await brain.getHistory({ limit: 1 })
|
||||
const initialCommit = history1[0].hash
|
||||
console.log(` Initial commit: ${initialCommit.slice(0, 8)}`)
|
||||
|
||||
// Get initial stats
|
||||
const initialStats = await brain.vfs.stat('/stats-test.txt', { commitId: initialCommit })
|
||||
expect(initialStats.size).toBe('Initial content'.length)
|
||||
expect(initialStats.isFile()).toBe(true)
|
||||
console.log(` ✅ Initial stats: ${initialStats.size} bytes`)
|
||||
|
||||
// Wait a moment to ensure different timestamp
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// Update file with different content
|
||||
await brain.vfs.writeFile('/stats-test.txt', 'Much longer updated content here')
|
||||
|
||||
// Commit: Updated file
|
||||
await brain.commit({
|
||||
message: 'Updated stats-test.txt',
|
||||
author: 'test@example.com'
|
||||
})
|
||||
const history2 = await brain.getHistory({ limit: 1 })
|
||||
const updatedCommit = history2[0].hash
|
||||
console.log(` Updated commit: ${updatedCommit.slice(0, 8)}`)
|
||||
|
||||
// Test 3a: Current stats (should reflect new size)
|
||||
const currentStats = await brain.vfs.stat('/stats-test.txt')
|
||||
expect(currentStats.size).toBe('Much longer updated content here'.length)
|
||||
console.log(` ✅ Current stats: ${currentStats.size} bytes`)
|
||||
|
||||
// Test 3b: Historical stats (should reflect old size)
|
||||
const historicalStats = await brain.vfs.stat('/stats-test.txt', { commitId: initialCommit })
|
||||
expect(historicalStats.size).toBe('Initial content'.length)
|
||||
expect(historicalStats.size).not.toBe(currentStats.size)
|
||||
console.log(` ✅ Historical stats: ${historicalStats.size} bytes (different from current)`)
|
||||
|
||||
// Test 3c: Stat directory at historical commit
|
||||
const dirStats = await brain.vfs.stat('/', { commitId: initialCommit })
|
||||
expect(dirStats.isDirectory()).toBe(true)
|
||||
expect(dirStats.isFile()).toBe(false)
|
||||
console.log(` ✅ Directory stats work with commitId`)
|
||||
})
|
||||
|
||||
it('should check existence from historical commits', async () => {
|
||||
console.log('\n✅ Test 4: Historical existence checks')
|
||||
|
||||
// Get current commit
|
||||
const history1 = await brain.getHistory({ limit: 1 })
|
||||
const beforeNewFileCommit = history1[0].hash
|
||||
|
||||
// Create a new file
|
||||
await brain.vfs.writeFile('/new-file.txt', 'New file content')
|
||||
|
||||
// Commit: New file added
|
||||
await brain.commit({
|
||||
message: 'Added new-file.txt',
|
||||
author: 'test@example.com'
|
||||
})
|
||||
const history2 = await brain.getHistory({ limit: 1 })
|
||||
const afterNewFileCommit = history2[0].hash
|
||||
console.log(` Before new file: ${beforeNewFileCommit.slice(0, 8)}`)
|
||||
console.log(` After new file: ${afterNewFileCommit.slice(0, 8)}`)
|
||||
|
||||
// Test 4a: File exists in current state
|
||||
const existsNow = await brain.vfs.exists('/new-file.txt')
|
||||
expect(existsNow).toBe(true)
|
||||
console.log(` ✅ File exists in current state`)
|
||||
|
||||
// Test 4b: File exists at after-commit
|
||||
const existsAfter = await brain.vfs.exists('/new-file.txt', { commitId: afterNewFileCommit })
|
||||
expect(existsAfter).toBe(true)
|
||||
console.log(` ✅ File exists at after-commit`)
|
||||
|
||||
// Test 4c: File does NOT exist at before-commit
|
||||
const existsBefore = await brain.vfs.exists('/new-file.txt', { commitId: beforeNewFileCommit })
|
||||
expect(existsBefore).toBe(false)
|
||||
console.log(` ✅ File correctly doesn't exist at before-commit`)
|
||||
|
||||
// Test 4d: Non-existent file returns false at any commit
|
||||
const neverExists = await brain.vfs.exists('/never-created.txt', { commitId: afterNewFileCommit })
|
||||
expect(neverExists).toBe(false)
|
||||
console.log(` ✅ Non-existent file returns false at any commit`)
|
||||
|
||||
// Test 4e: Directory existence checks
|
||||
const rootExists = await brain.vfs.exists('/', { commitId: beforeNewFileCommit })
|
||||
expect(rootExists).toBe(true)
|
||||
console.log(` ✅ Root directory exists at all commits`)
|
||||
})
|
||||
|
||||
it('should handle readdir with withFileTypes at historical commits', async () => {
|
||||
console.log('\n📋 Test 5: Historical readdir with withFileTypes')
|
||||
|
||||
// Create mixed content
|
||||
await brain.vfs.mkdir('/mixed')
|
||||
await brain.vfs.writeFile('/mixed/file1.txt', 'File 1')
|
||||
await brain.vfs.mkdir('/mixed/subdir')
|
||||
await brain.vfs.writeFile('/mixed/file2.txt', 'File 2')
|
||||
|
||||
// Commit: Mixed content
|
||||
await brain.commit({
|
||||
message: 'Added mixed directory',
|
||||
author: 'test@example.com'
|
||||
})
|
||||
const history = await brain.getHistory({ limit: 1 })
|
||||
const mixedCommit = history[0].hash
|
||||
console.log(` Mixed commit: ${mixedCommit.slice(0, 8)}`)
|
||||
|
||||
// Test 5a: readdir with withFileTypes at historical commit
|
||||
const entries = await brain.vfs.readdir('/mixed', {
|
||||
withFileTypes: true,
|
||||
commitId: mixedCommit
|
||||
})
|
||||
|
||||
expect(entries.length).toBe(3)
|
||||
|
||||
// Check that we got VFSDirent objects
|
||||
const fileEntries = entries.filter((e: any) => e.type === 'file')
|
||||
const dirEntries = entries.filter((e: any) => e.type === 'directory')
|
||||
|
||||
expect(fileEntries.length).toBe(2)
|
||||
expect(dirEntries.length).toBe(1)
|
||||
|
||||
console.log(` ✅ withFileTypes: ${fileEntries.length} files, ${dirEntries.length} dirs`)
|
||||
|
||||
// Verify entries have correct properties
|
||||
const firstEntry = entries[0] as any
|
||||
expect(firstEntry).toHaveProperty('name')
|
||||
expect(firstEntry).toHaveProperty('path')
|
||||
expect(firstEntry).toHaveProperty('type')
|
||||
expect(firstEntry).toHaveProperty('entityId')
|
||||
console.log(` ✅ VFSDirent objects have all required properties`)
|
||||
})
|
||||
|
||||
it('should handle errors gracefully for invalid commits', async () => {
|
||||
console.log('\n⚠️ Test 6: Error handling for invalid commits')
|
||||
|
||||
const fakeCommitId = 'f'.repeat(64) // Invalid commit hash
|
||||
|
||||
// Test 6a: readFile with invalid commit
|
||||
await expect(
|
||||
brain.vfs.readFile('/test.md', { commitId: fakeCommitId })
|
||||
).rejects.toThrow('Invalid commit ID')
|
||||
console.log(` ✅ readFile throws on invalid commit`)
|
||||
|
||||
// Test 6b: readdir with invalid commit
|
||||
await expect(
|
||||
brain.vfs.readdir('/', { commitId: fakeCommitId })
|
||||
).rejects.toThrow('Invalid commit ID')
|
||||
console.log(` ✅ readdir throws on invalid commit`)
|
||||
|
||||
// Test 6c: stat with invalid commit
|
||||
await expect(
|
||||
brain.vfs.stat('/test.md', { commitId: fakeCommitId })
|
||||
).rejects.toThrow('Invalid commit ID')
|
||||
console.log(` ✅ stat throws on invalid commit`)
|
||||
|
||||
// Test 6d: exists with invalid commit (returns false, doesn't throw)
|
||||
const exists = await brain.vfs.exists('/test.md', { commitId: fakeCommitId })
|
||||
expect(exists).toBe(false)
|
||||
console.log(` ✅ exists returns false on invalid commit (doesn't throw)`)
|
||||
})
|
||||
|
||||
it('should maintain performance at scale', async () => {
|
||||
console.log('\n⚡ Test 7: Performance with multiple files')
|
||||
|
||||
// Create multiple files
|
||||
const fileCount = 50 // Reasonable for integration test
|
||||
for (let i = 0; i < fileCount; i++) {
|
||||
await brain.vfs.writeFile(`/perf-test-${i}.txt`, `File ${i} content`)
|
||||
}
|
||||
|
||||
// Commit: Many files
|
||||
await brain.commit({
|
||||
message: 'Added 50 files for performance test',
|
||||
author: 'test@example.com'
|
||||
})
|
||||
const history = await brain.getHistory({ limit: 1 })
|
||||
const perfCommit = history[0].hash
|
||||
console.log(` Performance commit: ${perfCommit.slice(0, 8)}`)
|
||||
|
||||
// Test 7a: Historical readdir performance
|
||||
const startReaddir = Date.now()
|
||||
const entries = await brain.vfs.readdir('/', { commitId: perfCommit })
|
||||
const readdirTime = Date.now() - startReaddir
|
||||
|
||||
expect(entries.length).toBeGreaterThanOrEqual(fileCount)
|
||||
console.log(` ✅ readdir (${entries.length} entries): ${readdirTime}ms`)
|
||||
expect(readdirTime).toBeLessThan(5000) // Should complete in < 5s
|
||||
|
||||
// Test 7b: Historical readFile performance
|
||||
const startReadFile = Date.now()
|
||||
const content = await brain.vfs.readFile('/perf-test-25.txt', { commitId: perfCommit })
|
||||
const readFileTime = Date.now() - startReadFile
|
||||
|
||||
expect(content.toString()).toBe('File 25 content')
|
||||
console.log(` ✅ readFile: ${readFileTime}ms`)
|
||||
expect(readFileTime).toBeLessThan(1000) // Should complete in < 1s
|
||||
|
||||
// Test 7c: Historical exists performance
|
||||
const startExists = Date.now()
|
||||
const exists = await brain.vfs.exists('/perf-test-25.txt', { commitId: perfCommit })
|
||||
const existsTime = Date.now() - startExists
|
||||
|
||||
expect(exists).toBe(true)
|
||||
console.log(` ✅ exists: ${existsTime}ms`)
|
||||
expect(existsTime).toBeLessThan(1000) // Should complete in < 1s
|
||||
})
|
||||
|
||||
it('should work with nested directory structures', async () => {
|
||||
console.log('\n🌲 Test 8: Nested directories at historical commits')
|
||||
|
||||
// Create nested structure
|
||||
await brain.vfs.mkdir('/deep')
|
||||
await brain.vfs.mkdir('/deep/level1')
|
||||
await brain.vfs.mkdir('/deep/level1/level2')
|
||||
await brain.vfs.writeFile('/deep/level1/level2/deep-file.txt', 'Deep content')
|
||||
|
||||
// Commit: Deep structure
|
||||
await brain.commit({
|
||||
message: 'Added deep directory structure',
|
||||
author: 'test@example.com'
|
||||
})
|
||||
const history1 = await brain.getHistory({ limit: 1 })
|
||||
const deepCommit = history1[0].hash
|
||||
console.log(` Deep structure commit: ${deepCommit.slice(0, 8)}`)
|
||||
|
||||
// Test 8a: Read deep file
|
||||
const content = await brain.vfs.readFile('/deep/level1/level2/deep-file.txt', {
|
||||
commitId: deepCommit
|
||||
})
|
||||
expect(content.toString()).toBe('Deep content')
|
||||
console.log(` ✅ Read file from nested directory`)
|
||||
|
||||
// Test 8b: List intermediate directory
|
||||
const level1Entries = await brain.vfs.readdir('/deep/level1', { commitId: deepCommit })
|
||||
expect(level1Entries).toContain('level2')
|
||||
console.log(` ✅ List intermediate directory`)
|
||||
|
||||
// Test 8c: Stat deep directory
|
||||
const dirStats = await brain.vfs.stat('/deep/level1/level2', { commitId: deepCommit })
|
||||
expect(dirStats.isDirectory()).toBe(true)
|
||||
console.log(` ✅ Stat nested directory`)
|
||||
|
||||
// Test 8d: Check existence of deep path
|
||||
const exists = await brain.vfs.exists('/deep/level1/level2/deep-file.txt', {
|
||||
commitId: deepCommit
|
||||
})
|
||||
expect(exists).toBe(true)
|
||||
console.log(` ✅ Check existence of deep path`)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,307 +0,0 @@
|
|||
/**
|
||||
* VFS File Versioning Integration Tests (v6.3.2)
|
||||
*
|
||||
* Tests the fix for the VFS versioning bug where file content was stale.
|
||||
*
|
||||
* Bug: VFS files store content in BlobStorage, but versioning captured stale
|
||||
* embedding text from entity.data instead of actual file content.
|
||||
*
|
||||
* Fix: VersionManager.save() now reads fresh content from BlobStorage for VFS files,
|
||||
* and restore() writes content back to BlobStorage.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { Brainy } from '../../src/brainy.js'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
describe('VFS File Versioning (v6.3.2 Fix)', () => {
|
||||
const testDir = path.join(process.cwd(), 'test-vfs-versioning-fix')
|
||||
let brain: Brainy
|
||||
|
||||
beforeAll(async () => {
|
||||
// Clean up from any previous run
|
||||
if (fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true })
|
||||
}
|
||||
fs.mkdirSync(testDir, { recursive: true })
|
||||
|
||||
brain = new Brainy({ requireSubtype: false,
|
||||
storage: {
|
||||
type: 'filesystem',
|
||||
options: { path: testDir }
|
||||
},
|
||||
silent: true
|
||||
})
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
// Clean up test directory
|
||||
if (fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('Core VFS Versioning', () => {
|
||||
it('should version actual file content, not stale embedding text', async () => {
|
||||
// Create a text file
|
||||
await brain.vfs.writeFile('/test/file.md', 'Initial content - version 1')
|
||||
|
||||
// Get the file entity
|
||||
const stat = await brain.vfs.stat('/test/file.md')
|
||||
const entityId = stat.entityId
|
||||
|
||||
// Save version 1
|
||||
await brain.versions.save(entityId, { description: 'Version 1' })
|
||||
|
||||
// Modify the file with different content
|
||||
await brain.vfs.writeFile('/test/file.md', 'Modified content - version 2 with more text')
|
||||
|
||||
// Save version 2
|
||||
await brain.versions.save(entityId, { description: 'Version 2' })
|
||||
|
||||
// Retrieve both versions
|
||||
const v1Content = await brain.versions.getContent(entityId, 1)
|
||||
const v2Content = await brain.versions.getContent(entityId, 2)
|
||||
|
||||
// CRITICAL TEST: The data should be DIFFERENT between versions
|
||||
// This was the bug - both had the same stale content
|
||||
expect(v1Content.data).not.toBe(v2Content.data)
|
||||
|
||||
// Verify actual content
|
||||
expect(v1Content.data).toBe('Initial content - version 1')
|
||||
expect(v2Content.data).toBe('Modified content - version 2 with more text')
|
||||
})
|
||||
|
||||
it('should restore VFS file content correctly', async () => {
|
||||
// Create initial file
|
||||
await brain.vfs.writeFile('/docs/readme.md', 'README version 1')
|
||||
|
||||
const stat = await brain.vfs.stat('/docs/readme.md')
|
||||
const entityId = stat.entityId
|
||||
|
||||
// Save version 1
|
||||
await brain.versions.save(entityId, { tag: 'v1' })
|
||||
|
||||
// Modify file
|
||||
await brain.vfs.writeFile('/docs/readme.md', 'README version 2 - updated')
|
||||
|
||||
// Save version 2
|
||||
await brain.versions.save(entityId, { tag: 'v2' })
|
||||
|
||||
// Verify current content is v2
|
||||
let currentContent = await brain.vfs.readFile('/docs/readme.md')
|
||||
expect(currentContent.toString()).toBe('README version 2 - updated')
|
||||
|
||||
// Restore to v1
|
||||
await brain.versions.restore(entityId, 1)
|
||||
|
||||
// Verify content is now v1
|
||||
currentContent = await brain.vfs.readFile('/docs/readme.md')
|
||||
expect(currentContent.toString()).toBe('README version 1')
|
||||
})
|
||||
|
||||
it('should handle multiple file edits and versions', async () => {
|
||||
await brain.vfs.writeFile('/project/config.json', '{"version": 1}')
|
||||
const stat = await brain.vfs.stat('/project/config.json')
|
||||
const entityId = stat.entityId
|
||||
|
||||
// Create 5 versions
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await brain.vfs.writeFile('/project/config.json', `{"version": ${i}}`)
|
||||
await brain.versions.save(entityId, { tag: `v${i}` })
|
||||
}
|
||||
|
||||
// Verify all 5 versions exist
|
||||
const versions = await brain.versions.list(entityId)
|
||||
expect(versions).toHaveLength(5)
|
||||
|
||||
// Verify each version has correct content
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const content = await brain.versions.getContent(entityId, i)
|
||||
expect(content.data).toBe(`{"version": ${i}}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('should restore to any version in history', async () => {
|
||||
await brain.vfs.writeFile('/notes/todo.txt', 'Task 1')
|
||||
const stat = await brain.vfs.stat('/notes/todo.txt')
|
||||
const entityId = stat.entityId
|
||||
|
||||
await brain.versions.save(entityId, { tag: 'initial' })
|
||||
|
||||
await brain.vfs.writeFile('/notes/todo.txt', 'Task 1\nTask 2')
|
||||
await brain.versions.save(entityId, { tag: 'two-tasks' })
|
||||
|
||||
await brain.vfs.writeFile('/notes/todo.txt', 'Task 1\nTask 2\nTask 3')
|
||||
await brain.versions.save(entityId, { tag: 'three-tasks' })
|
||||
|
||||
// Restore to middle version
|
||||
await brain.versions.restore(entityId, 'two-tasks')
|
||||
let content = await brain.vfs.readFile('/notes/todo.txt')
|
||||
expect(content.toString()).toBe('Task 1\nTask 2')
|
||||
|
||||
// Restore to initial version
|
||||
await brain.versions.restore(entityId, 'initial')
|
||||
content = await brain.vfs.readFile('/notes/todo.txt')
|
||||
expect(content.toString()).toBe('Task 1')
|
||||
|
||||
// Restore to latest version
|
||||
await brain.versions.restore(entityId, 3)
|
||||
content = await brain.vfs.readFile('/notes/todo.txt')
|
||||
expect(content.toString()).toBe('Task 1\nTask 2\nTask 3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Binary File Versioning', () => {
|
||||
it('should version binary files with base64 encoding', async () => {
|
||||
// Create a simple binary file (simulated image header)
|
||||
const binaryContent1 = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x01])
|
||||
const binaryContent2 = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x02])
|
||||
|
||||
await brain.vfs.writeFile('/images/test.png', binaryContent1)
|
||||
const stat = await brain.vfs.stat('/images/test.png')
|
||||
const entityId = stat.entityId
|
||||
|
||||
// Save version 1
|
||||
await brain.versions.save(entityId, { tag: 'v1' })
|
||||
|
||||
// Modify binary content
|
||||
await brain.vfs.writeFile('/images/test.png', binaryContent2)
|
||||
|
||||
// Save version 2
|
||||
await brain.versions.save(entityId, { tag: 'v2' })
|
||||
|
||||
// Retrieve and verify both versions are different
|
||||
const v1Content = await brain.versions.getContent(entityId, 1)
|
||||
const v2Content = await brain.versions.getContent(entityId, 2)
|
||||
|
||||
expect(v1Content.data).not.toBe(v2Content.data)
|
||||
|
||||
// Verify binary content can be restored
|
||||
await brain.versions.restore(entityId, 1)
|
||||
const restored = await brain.vfs.readFile('/images/test.png')
|
||||
expect(Buffer.compare(restored, binaryContent1)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Version Deduplication', () => {
|
||||
it('should deduplicate identical VFS file content', async () => {
|
||||
await brain.vfs.writeFile('/cache/data.txt', 'Cached data')
|
||||
const stat = await brain.vfs.stat('/cache/data.txt')
|
||||
const entityId = stat.entityId
|
||||
|
||||
// Save version 1
|
||||
const v1 = await brain.versions.save(entityId, { tag: 'v1' })
|
||||
|
||||
// Save again without changes - should dedupe
|
||||
const v2 = await brain.versions.save(entityId, { tag: 'v2' })
|
||||
|
||||
// Should return same version (content hash match)
|
||||
expect(v2.version).toBe(v1.version)
|
||||
expect(v2.contentHash).toBe(v1.contentHash)
|
||||
|
||||
// Only 1 version should exist
|
||||
const versions = await brain.versions.list(entityId)
|
||||
expect(versions).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mixed Entity Versioning', () => {
|
||||
it('should handle VFS and non-VFS entities in same brain', async () => {
|
||||
// Create a VFS file
|
||||
await brain.vfs.writeFile('/files/doc.txt', 'File content v1')
|
||||
const fileStat = await brain.vfs.stat('/files/doc.txt')
|
||||
const fileId = fileStat.entityId
|
||||
|
||||
// Create a regular entity
|
||||
const regularId = await brain.add({
|
||||
data: 'Regular entity',
|
||||
type: 'document',
|
||||
metadata: { title: 'Regular Doc' }
|
||||
})
|
||||
|
||||
// Version both
|
||||
await brain.versions.save(fileId, { tag: 'file-v1' })
|
||||
await brain.versions.save(regularId, { tag: 'regular-v1' })
|
||||
|
||||
// Update both
|
||||
await brain.vfs.writeFile('/files/doc.txt', 'File content v2')
|
||||
await brain.update({ id: regularId, metadata: { title: 'Updated Regular Doc' } })
|
||||
|
||||
// Version both again
|
||||
await brain.versions.save(fileId, { tag: 'file-v2' })
|
||||
await brain.versions.save(regularId, { tag: 'regular-v2' })
|
||||
|
||||
// Verify VFS file versions have different content
|
||||
const fileV1 = await brain.versions.getContent(fileId, 1)
|
||||
const fileV2 = await brain.versions.getContent(fileId, 2)
|
||||
expect(fileV1.data).toBe('File content v1')
|
||||
expect(fileV2.data).toBe('File content v2')
|
||||
|
||||
// Verify regular entity versions work too
|
||||
const regV1 = await brain.versions.getContent(regularId, 1)
|
||||
const regV2 = await brain.versions.getContent(regularId, 2)
|
||||
expect(regV1.title).toBe('Regular Doc')
|
||||
expect(regV2.title).toBe('Updated Regular Doc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle minimal content files', async () => {
|
||||
// VFS requires non-empty data for embedding, so we use minimal content
|
||||
await brain.vfs.writeFile('/minimal/file.txt', ' ') // Single space
|
||||
const stat = await brain.vfs.stat('/minimal/file.txt')
|
||||
const entityId = stat.entityId
|
||||
|
||||
await brain.versions.save(entityId, { tag: 'minimal' })
|
||||
|
||||
await brain.vfs.writeFile('/minimal/file.txt', 'Now has content')
|
||||
await brain.versions.save(entityId, { tag: 'filled' })
|
||||
|
||||
// Restore to minimal
|
||||
await brain.versions.restore(entityId, 'minimal')
|
||||
const content = await brain.vfs.readFile('/minimal/file.txt')
|
||||
expect(content.toString()).toBe(' ')
|
||||
})
|
||||
|
||||
it('should handle large text files', async () => {
|
||||
const largeContent1 = 'A'.repeat(100000) + ' version 1'
|
||||
const largeContent2 = 'B'.repeat(100000) + ' version 2'
|
||||
|
||||
await brain.vfs.writeFile('/large/file.txt', largeContent1)
|
||||
const stat = await brain.vfs.stat('/large/file.txt')
|
||||
const entityId = stat.entityId
|
||||
|
||||
await brain.versions.save(entityId, { tag: 'v1' })
|
||||
|
||||
await brain.vfs.writeFile('/large/file.txt', largeContent2)
|
||||
await brain.versions.save(entityId, { tag: 'v2' })
|
||||
|
||||
// Verify versions are different
|
||||
const v1 = await brain.versions.getContent(entityId, 1)
|
||||
const v2 = await brain.versions.getContent(entityId, 2)
|
||||
expect(v1.data).toBe(largeContent1)
|
||||
expect(v2.data).toBe(largeContent2)
|
||||
})
|
||||
|
||||
it('should handle special characters in content', async () => {
|
||||
const specialContent = 'Unicode: \u{1F600} \u{1F914} \u{1F4A1}\nNewlines\nAnd\ttabs'
|
||||
|
||||
await brain.vfs.writeFile('/special.txt', specialContent)
|
||||
const stat = await brain.vfs.stat('/special.txt')
|
||||
const entityId = stat.entityId
|
||||
|
||||
await brain.versions.save(entityId, { tag: 'special' })
|
||||
|
||||
await brain.vfs.writeFile('/special.txt', 'Plain text now')
|
||||
await brain.versions.save(entityId, { tag: 'plain' })
|
||||
|
||||
// Restore special content
|
||||
await brain.versions.restore(entityId, 'special')
|
||||
const content = await brain.vfs.readFile('/special.txt')
|
||||
expect(content.toString()).toBe(specialContent)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue