import { access, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SqliteKnowledgeIndex, type SqliteKnowledgeIndexPage } from '../../../src/context/wiki/sqlite-knowledge-index.js'; describe('SqliteKnowledgeIndex', () => { let tempDir: string; let dbPath: string; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'ktx-sqlite-knowledge-index-')); dbPath = join(tempDir, 'db.sqlite'); }); afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); }); function page(overrides: Partial = {}): SqliteKnowledgeIndexPage { return { path: 'wiki/global/revenue.md', key: 'revenue', scope: 'GLOBAL', summary: 'Revenue definition', content: 'Revenue is the sum of paid order amounts.', tags: ['finance'], embedding: null, ...overrides, }; } it('creates a SQLite FTS5 index and returns lexical lane candidates', async () => { const index = new SqliteKnowledgeIndex({ dbPath }); index.sync([ page(), page({ path: 'wiki/global/support.md', key: 'support', summary: 'Support queue', content: 'Tickets are grouped by priority.', tags: ['operations'], }), ]); await expect(access(dbPath)).resolves.toBeUndefined(); expect(index.searchLexicalCandidates({ queryText: 'paid order', limit: 10 })).toEqual([ expect.objectContaining({ id: 'wiki/global/revenue.md', path: 'wiki/global/revenue.md', rank: 1, rawScore: expect.any(Number), }), ]); }); it('removes stale rows when the Markdown source list changes', () => { const index = new SqliteKnowledgeIndex({ dbPath }); index.rebuild([page(), page({ path: 'wiki/global/churn.md', key: 'churn', content: 'Churn risk.' })]); expect(index.search('churn', 10)).toHaveLength(1); index.rebuild([page()]); expect(index.search('churn', 10)).toEqual([]); }); it('clear removes one wiki scope and leaves other scopes intact', async () => { const index = new SqliteKnowledgeIndex({ dbPath }); index.sync([ page({ path: 'wiki/global/revenue.md', key: 'revenue', scope: 'GLOBAL', scopeId: null }), page({ path: 'wiki/user/local/revenue.md', key: 'revenue', scope: 'USER', scopeId: 'local', summary: 'Local revenue', content: 'Local revenue notes.', }), page({ path: 'wiki/user/alex/revenue.md', key: 'revenue', scope: 'USER', scopeId: 'alex', summary: 'Alex revenue', content: 'Alex revenue notes.', }), ]); expect(index.clear('USER', 'local')).toBe(1); expect(index.search('Local', 10)).toEqual([]); expect(index.search('Alex', 10)).toEqual([expect.objectContaining({ path: 'wiki/user/alex/revenue.md' })]); expect(index.search('definition', 10)).toEqual([expect.objectContaining({ path: 'wiki/global/revenue.md' })]); }); it('exposes existing search text and embedding state for incremental refresh', () => { const index = new SqliteKnowledgeIndex({ dbPath }); index.sync([page({ path: 'wiki/global/revenue.md', key: 'revenue', embedding: [1, 0] })]); expect(index.getExistingPages()).toEqual( new Map([ [ 'wiki/global/revenue.md', expect.objectContaining({ searchText: expect.stringContaining('Revenue definition'), embedding: [1, 0], }), ], ]), ); }); it('does not treat empty embeddings as indexed semantic vectors', () => { const index = new SqliteKnowledgeIndex({ dbPath }); index.sync([page({ path: 'wiki/global/revenue.md', key: 'revenue', embedding: [] })]); expect(index.getExistingPages().get('wiki/global/revenue.md')?.embedding).toBeNull(); expect(index.searchSemanticCandidates({ queryEmbedding: [1, 0], limit: 10 })).toEqual([]); }); it('returns semantic lane candidates from stored page embeddings', () => { const index = new SqliteKnowledgeIndex({ dbPath }); index.sync([ page({ path: 'wiki/global/revenue.md', key: 'revenue', embedding: [1, 0] }), page({ path: 'wiki/global/support.md', key: 'support', summary: 'Support queue', embedding: [0, 1] }), ]); expect(index.searchSemanticCandidates({ queryEmbedding: [1, 0], limit: 10 })).toEqual([ expect.objectContaining({ id: 'wiki/global/revenue.md', path: 'wiki/global/revenue.md', rank: 1, rawScore: 1, }), expect.objectContaining({ id: 'wiki/global/support.md', path: 'wiki/global/support.md', rank: 2, rawScore: 0, }), ]); }); it('returns an empty result for blank or punctuation-only queries', () => { const index = new SqliteKnowledgeIndex({ dbPath }); index.rebuild([page()]); expect(index.search(' ', 10)).toEqual([]); expect(index.search('---', 10)).toEqual([]); }); });