Files
codegraph/__tests__/fts5-fallback.test.ts
T
0fd259b554 fix(db): graceful FTS5 fallback when Node.js build lacks FTS5 (#1532) (#1810)
* fix(db): graceful FTS5 fallback when Node.js build lacks FTS5 support (#1532)

Official Node.js binaries do not compile FTS5 by default, causing codegraph
init to fail with 'no such module: fts5'. Added runtime FTS5 detection:
- Split schema execution to try FTS5 separately, skip on failure with warning
- Added fts5Available flag to DatabaseConnection and QueryBuilder
- Bulk-load and search paths skip FTS5 operations when unavailable
- Search falls back to LIKE + fuzzy matching when FTS5 is missing

(cherry picked from commit ed708b7f60540367d8a810b0388aaa05ce0a0933)

* fix(db): preserve core schema during FTS5 fallback (#1532)

Keep required tables and indexes after the FTS triggers outside the
optional schema block in the upstream #1625 fix. Without this boundary,
simulated-missing-FTS5 indexing still fails on name_segment_vocab.

Add seven regressions using real SQLite with FTS5 creation intercepted,
covering initialization/open, LIKE and fuzzy search, non-FTS schema parity,
bulk no-ops, and real FTS5 search and bulk-load recovery. Credit
@aniruddhaadak80 under Unreleased fixes.

Validation on Linux x64 with Node v22.19.0:
- npm run build passed, including viewer and grammar asset checks.
- 34 tests passed across fts5-fallback, node-sqlite-backend,
  sqlite-backend, and db-perf.
- Rebuilt CodeGraph initialization, indexing, reopening, search, and
  cross-file callers passed with simulated missing FTS5 and real FTS5.

Fixes #1532.
Supersedes #1625.

---------

Co-authored-by: Aniruddha Adak <aniruddhaadak80@users.noreply.github.com>
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
2026-09-08 19:57:48 -05:00

167 lines
6.5 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { DatabaseConnection } from '../src/db';
import { QueryBuilder } from '../src/db/queries';
import { Node } from '../src/types';
// Use real SQLite for every operation except the unsupported-module error.
// This must exercise fallback even when the test runner's Node has FTS5.
const { DatabaseSync } = require('node:sqlite');
function simulateMissingFts5(): () => number {
const exec = DatabaseSync.prototype.exec;
let attempts = 0;
vi.spyOn(DatabaseSync.prototype, 'exec').mockImplementation(function (this: unknown, sql: string) {
if (/CREATE VIRTUAL TABLE\b[^;]*\bUSING fts5\s*\(/i.test(sql)) {
attempts++;
throw new Error('no such module: fts5');
}
return exec.call(this, sql);
});
return () => attempts;
}
function makeNode(name: string, docstring?: string): Node {
return {
id: name,
kind: 'function',
name,
qualifiedName: name,
filePath: 'src/users.ts',
language: 'typescript',
startLine: 1,
endLine: 1,
startColumn: 0,
endColumn: 0,
docstring,
updatedAt: Date.now(),
};
}
describe('FTS5 fallback (#1532)', () => {
let dir: string;
let connections: DatabaseConnection[];
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fts5-fallback-'));
connections = [];
});
afterEach(() => {
vi.restoreAllMocks();
for (const connection of connections) connection.close();
fs.rmSync(dir, { recursive: true, force: true });
});
function initialize(filename = 'test.db'): DatabaseConnection {
const connection = DatabaseConnection.initialize(path.join(dir, filename));
connections.push(connection);
return connection;
}
function reopen(connection: DatabaseConnection): DatabaseConnection {
connection.close();
const reopened = DatabaseConnection.open(path.join(dir, 'test.db'));
connections.push(reopened);
return reopened;
}
it.each(['initialization', 'reopening'])('uses LIKE and fuzzy search after %s without FTS5', (state) => {
const attempts = simulateMissingFts5();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
let connection = initialize();
expect(attempts()).toBe(1);
expect(connection.fts5Available).toBe(false);
expect(warn).toHaveBeenCalledOnce();
expect(warn).toHaveBeenCalledWith(expect.stringContaining('no such module: fts5'));
expect(warn).toHaveBeenCalledWith(expect.stringContaining('LIKE + fuzzy matching'));
if (state === 'reopening') connection = reopen(connection);
expect(connection.fts5Available).toBe(false);
const db = connection.getDb();
expect(db.prepare("SELECT name FROM sqlite_master WHERE name = 'nodes_fts' OR name IN ('nodes_ai', 'nodes_ad', 'nodes_au')").all()).toEqual([]);
const exec = vi.spyOn(db, 'exec');
connection.beginBulkNodeLoad();
connection.endBulkNodeLoad();
expect(exec).not.toHaveBeenCalled();
const queries = new QueryBuilder(db);
queries.insertNodes([makeNode('getUser'), makeNode('getUserProfile')]);
const prepare = vi.spyOn(db, 'prepare');
expect(queries.searchNodes('User').map(result => result.node.name)).toEqual(expect.arrayContaining(['getUser', 'getUserProfile']));
expect(queries.searchNodes('getUssr').map(result => result.node.name)).toEqual(['getUser']);
// A failed MATCH query is already caught by searchNodesFTS; pin that the
// unavailable path skips the FTS query entirely, rather than retrying it.
expect(prepare.mock.calls.some(([sql]) => /\bnodes_fts\b/.test(sql))).toBe(false);
queries.setMetadata('project_name', 'fts5-fallback');
expect(queries.getMetadata('project_name')).toBe('fts5-fallback');
});
it('keeps every non-FTS table and index when FTS5 creation fails', () => {
const control = initialize('control.db');
const nonFtsSchema = (connection: DatabaseConnection) => connection.getDb().prepare(`
SELECT type, name, sql FROM sqlite_master
WHERE name NOT LIKE 'nodes_fts%'
AND name NOT IN ('nodes_ai', 'nodes_ad', 'nodes_au')
ORDER BY type, name
`).all();
const expected = nonFtsSchema(control);
simulateMissingFts5();
vi.spyOn(console, 'warn').mockImplementation(() => {});
const fallback = initialize();
expect(fallback.fts5Available).toBe(false);
expect(nonFtsSchema(fallback)).toEqual(expected);
});
it.each(['initialization', 'reopening'])('uses real FTS5 after %s', (state) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
let connection = initialize();
expect(connection.fts5Available).toBe(true);
new QueryBuilder(connection.getDb()).insertNode(makeNode('loadRecord', 'quasar nebula'));
if (state === 'reopening') connection = reopen(connection);
expect(connection.fts5Available).toBe(true);
const queries = new QueryBuilder(connection.getDb());
// Only the docstring contains this token: LIKE/fuzzy name search cannot
// make this assertion pass if the FTS path is accidentally disabled.
expect(queries.searchNodes('nebula').map(result => result.node.name)).toEqual(['loadRecord']);
expect(warn).not.toHaveBeenCalled();
});
it('rebuilds real FTS5 after a bulk node load', () => {
const connection = initialize();
const queries = new QueryBuilder(connection.getDb());
connection.beginBulkNodeLoad();
queries.insertNode(makeNode('loadRecord', 'quasar nebula'));
expect(queries.searchNodes('nebula')).toEqual([]);
connection.endBulkNodeLoad();
expect(queries.searchNodes('nebula').map(result => result.node.name)).toEqual(['loadRecord']);
queries.insertNode(makeNode('saveRecord', 'pulsar supernova'));
expect(queries.searchNodes('supernova').map(result => result.node.name)).toEqual(['saveRecord']);
});
it('repairs an interrupted real FTS5 bulk load on open', () => {
let connection = initialize();
connection.beginBulkNodeLoad();
new QueryBuilder(connection.getDb()).insertNode(makeNode('loadRecord', 'quasar nebula'));
connection = reopen(connection);
expect(connection.fts5Available).toBe(true);
const queries = new QueryBuilder(connection.getDb());
expect(queries.searchNodes('nebula').map(result => result.node.name)).toEqual(['loadRecord']);
queries.insertNode(makeNode('saveRecord', 'pulsar supernova'));
expect(queries.searchNodes('supernova').map(result => result.node.name)).toEqual(['saveRecord']);
});
});