Reconciles the vector-index rename to the ruled three-layer naming: the provider contract's identity field is now a REQUIRED readonly name (was optional providerId), self-reported and truthful, rendered as [vector-index:<name>] where the index identifies itself and stamped into the op-name strings journals already parse (AddToVectorIndex(<name>)). The built-in JS engine names itself hnsw-js. A runtime provider instance compiled against the previous optional contract is tolerated - never crashed on, never silently mislabeled: it stamps unknown-provider and emits one loud warning naming the missing field. Graph index operations keep their static names (they never interpolate provider identity), and no public API exports a provider-routed hnsw-carrying name, so no deprecation shim is required.
176 lines
7.4 KiB
TypeScript
176 lines
7.4 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 — `'hnsw-js'` for
|
|
* Brainy's own JS index, a provider's own required `name` when it
|
|
* self-identifies, and the tolerant-loud `'unknown-provider'` literal
|
|
* (plus one console.warn) when a runtime instance lacks `name`
|
|
* altogether (an older native provider compiled against the previous,
|
|
* optional `providerId` contract) — never a silently-wrong guess.
|
|
*/
|
|
import { describe, it, expect, vi, afterEach } 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 name: 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', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('stamps the real JS HNSW index as hnsw-js (self-identifying name)', () => {
|
|
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(hnsw-js)')
|
|
expect(removeOp.name).toBe('RemoveFromVectorIndex(hnsw-js)')
|
|
})
|
|
|
|
it('stamps a self-identifying provider\'s own name, 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('tolerant-loud: stamps "unknown-provider" and warns exactly once when a runtime provider lacks the required `name` (an older native provider compiled against the previous optional `providerId` contract)', () => {
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
// Simulate a native provider compiled before `name` was required — the
|
|
// TypeScript contract requires `name`, but `as unknown as` mirrors what
|
|
// actually reaches this code at runtime from an un-rebuilt native addon.
|
|
const legacyProvider = {
|
|
addItem: async (item: VectorDocument) => item.id,
|
|
removeItem: async () => true,
|
|
search: async () => [],
|
|
size: () => 0,
|
|
clear: () => {},
|
|
rebuild: async () => {},
|
|
flush: async () => 0,
|
|
getPersistMode: () => 'immediate' as const
|
|
} as unknown as VectorIndexProvider
|
|
|
|
const addOp = new AddToVectorIndexOperation(legacyProvider, 'id-1', [1, 2, 3])
|
|
const removeOp = new RemoveFromVectorIndexOperation(legacyProvider, '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('hnsw-js')
|
|
// Loud, not silent — but exactly once per provider instance, not once
|
|
// per op stamped against it.
|
|
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
expect(warnSpy.mock.calls[0][0]).toContain('name')
|
|
})
|
|
})
|
|
})
|