153 lines
6.3 KiB
TypeScript
153 lines
6.3 KiB
TypeScript
|
|
/**
|
||
|
|
* @module tests/unit/transaction/vectorIndexOperations-rename
|
||
|
|
* @description Coverage for the backend-neutral vector-index transaction
|
||
|
|
* operations (formerly `AddToHNSWOperation` / `RemoveFromHNSWOperation`).
|
||
|
|
* These op-name strings surface directly in consumer-visible transaction
|
||
|
|
* journals and timings — a fossil "HNSW" name misdirects an operator running
|
||
|
|
* a native (non-HNSW) vector provider. Verifies:
|
||
|
|
* 1. rollback wiring stayed byte-identical under the rename,
|
||
|
|
* 2. the emitted `name` stamps the active backend — `'js-hnsw'` for
|
||
|
|
* Brainy's own JS index, a provider's own `providerId` when it
|
||
|
|
* self-identifies, and the honest `'unknown-provider'` literal when
|
||
|
|
* neither applies (never a silently-wrong guess).
|
||
|
|
*/
|
||
|
|
import { describe, it, expect } from 'vitest'
|
||
|
|
import {
|
||
|
|
AddToVectorIndexOperation,
|
||
|
|
RemoveFromVectorIndexOperation,
|
||
|
|
BatchAddToVectorIndexOperation
|
||
|
|
} from '../../../src/transaction/operations/IndexOperations.js'
|
||
|
|
import type { VectorIndexProvider } from '../../../src/plugin.js'
|
||
|
|
import type { VectorDocument, Vector } from '../../../src/coreTypes.js'
|
||
|
|
import { JsHnswVectorIndex } from '../../../src/hnsw/hnswIndex.js'
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A minimal, real (not mocked) VectorIndexProvider implementation backed by
|
||
|
|
* a plain Map — exercises the exact contract the operations call through,
|
||
|
|
* without any Brainy internals.
|
||
|
|
*/
|
||
|
|
class FakeVectorProvider implements VectorIndexProvider {
|
||
|
|
readonly items = new Map<string, Vector>()
|
||
|
|
constructor(readonly providerId?: string) {}
|
||
|
|
|
||
|
|
async addItem(item: VectorDocument): Promise<string> {
|
||
|
|
this.items.set(item.id, item.vector)
|
||
|
|
return item.id
|
||
|
|
}
|
||
|
|
async removeItem(id: string): Promise<boolean> {
|
||
|
|
return this.items.delete(id)
|
||
|
|
}
|
||
|
|
async getItem(id: string): Promise<VectorDocument | undefined> {
|
||
|
|
const vector = this.items.get(id)
|
||
|
|
return vector ? { id, vector } : undefined
|
||
|
|
}
|
||
|
|
async search(): Promise<Array<[string, number]>> {
|
||
|
|
return []
|
||
|
|
}
|
||
|
|
size(): number {
|
||
|
|
return this.items.size
|
||
|
|
}
|
||
|
|
clear(): void {
|
||
|
|
this.items.clear()
|
||
|
|
}
|
||
|
|
async rebuild(): Promise<void> {}
|
||
|
|
async flush(): Promise<number> {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
getPersistMode(): 'immediate' | 'deferred' {
|
||
|
|
return 'immediate'
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('Vector index transaction operations — backend-neutral rename', () => {
|
||
|
|
describe('rollback wiring (byte-identical behavior under the rename)', () => {
|
||
|
|
it('AddToVectorIndexOperation rollback removes a newly-added item', async () => {
|
||
|
|
const provider = new FakeVectorProvider('fake-provider')
|
||
|
|
const op = new AddToVectorIndexOperation(provider, 'id-1', [1, 2, 3])
|
||
|
|
|
||
|
|
const rollback = await op.execute()
|
||
|
|
expect(provider.items.has('id-1')).toBe(true)
|
||
|
|
|
||
|
|
await rollback()
|
||
|
|
expect(provider.items.has('id-1')).toBe(false)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('AddToVectorIndexOperation rollback is a no-op when the item pre-existed (update semantics)', async () => {
|
||
|
|
const provider = new FakeVectorProvider('fake-provider')
|
||
|
|
await provider.addItem({ id: 'id-1', vector: [9, 9, 9] })
|
||
|
|
|
||
|
|
const op = new AddToVectorIndexOperation(provider, 'id-1', [1, 2, 3])
|
||
|
|
const rollback = await op.execute()
|
||
|
|
expect(provider.items.get('id-1')).toEqual([1, 2, 3])
|
||
|
|
|
||
|
|
await rollback()
|
||
|
|
// Pre-existing item is NOT removed by rollback — it stays (the update
|
||
|
|
// itself is not undone by this op; that's RemoveFromVectorIndexOperation's job).
|
||
|
|
expect(provider.items.has('id-1')).toBe(true)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('RemoveFromVectorIndexOperation rollback re-adds the item with its original vector', async () => {
|
||
|
|
const provider = new FakeVectorProvider('fake-provider')
|
||
|
|
await provider.addItem({ id: 'id-1', vector: [4, 5, 6] })
|
||
|
|
|
||
|
|
const op = new RemoveFromVectorIndexOperation(provider, 'id-1', [4, 5, 6])
|
||
|
|
const rollback = await op.execute()
|
||
|
|
expect(provider.items.has('id-1')).toBe(false)
|
||
|
|
|
||
|
|
await rollback()
|
||
|
|
expect(provider.items.get('id-1')).toEqual([4, 5, 6])
|
||
|
|
})
|
||
|
|
|
||
|
|
it('BatchAddToVectorIndexOperation rolls back every item in reverse order on undo', async () => {
|
||
|
|
const provider = new FakeVectorProvider('fake-provider')
|
||
|
|
const op = new BatchAddToVectorIndexOperation(provider, [
|
||
|
|
{ id: 'a', vector: [1] },
|
||
|
|
{ id: 'b', vector: [2] },
|
||
|
|
{ id: 'c', vector: [3] }
|
||
|
|
])
|
||
|
|
|
||
|
|
const rollback = await op.execute()
|
||
|
|
expect([...provider.items.keys()].sort()).toEqual(['a', 'b', 'c'])
|
||
|
|
|
||
|
|
await rollback()
|
||
|
|
expect(provider.items.size).toBe(0)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('backend stamping in the emitted op name', () => {
|
||
|
|
it('stamps the real JS HNSW index as js-hnsw (self-identifying providerId)', () => {
|
||
|
|
const index = new JsHnswVectorIndex()
|
||
|
|
const addOp = new AddToVectorIndexOperation(index, 'id-1', [1, 2, 3])
|
||
|
|
const removeOp = new RemoveFromVectorIndexOperation(index, 'id-1', [1, 2, 3])
|
||
|
|
|
||
|
|
expect(addOp.name).toBe('AddToVectorIndex(js-hnsw)')
|
||
|
|
expect(removeOp.name).toBe('RemoveFromVectorIndex(js-hnsw)')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('stamps a self-identifying provider\'s own providerId, never "HNSW"', () => {
|
||
|
|
const provider = new FakeVectorProvider('acme-diskann')
|
||
|
|
const addOp = new AddToVectorIndexOperation(provider, 'id-1', [1, 2, 3])
|
||
|
|
const removeOp = new RemoveFromVectorIndexOperation(provider, 'id-1', [1, 2, 3])
|
||
|
|
const batchOp = new BatchAddToVectorIndexOperation(provider, [{ id: 'a', vector: [1] }])
|
||
|
|
|
||
|
|
expect(addOp.name).toBe('AddToVectorIndex(acme-diskann)')
|
||
|
|
expect(removeOp.name).toBe('RemoveFromVectorIndex(acme-diskann)')
|
||
|
|
expect(batchOp.name).toBe('BatchAddToVectorIndex(acme-diskann)')
|
||
|
|
expect(addOp.name).not.toContain('HNSW')
|
||
|
|
expect(removeOp.name).not.toContain('HNSW')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('honestly reports "unknown-provider" rather than guessing when a provider omits providerId', () => {
|
||
|
|
const provider = new FakeVectorProvider(undefined)
|
||
|
|
const addOp = new AddToVectorIndexOperation(provider, 'id-1', [1, 2, 3])
|
||
|
|
const removeOp = new RemoveFromVectorIndexOperation(provider, 'id-1', [1, 2, 3])
|
||
|
|
|
||
|
|
expect(addOp.name).toBe('AddToVectorIndex(unknown-provider)')
|
||
|
|
expect(removeOp.name).toBe('RemoveFromVectorIndex(unknown-provider)')
|
||
|
|
// Never silently falls back to the JS engine's name for a provider it
|
||
|
|
// knows nothing about — that's the exact fossil-naming bug being fixed.
|
||
|
|
expect(addOp.name).not.toContain('js-hnsw')
|
||
|
|
})
|
||
|
|
})
|
||
|
|
})
|