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
This commit is contained in:
Aniruddha Adak
2026-08-26 22:16:05 +05:30
parent 44e1812d3b
commit ed708b7f60
2 changed files with 60 additions and 7 deletions
+48 -5
View File
@@ -83,10 +83,17 @@ export class DatabaseConnection {
*/
private openedInode: string | null;
private constructor(db: SqliteDatabase, dbPath: string, backend: SqliteBackend) {
/**
* Whether FTS5 is available in this Node.js build. When false, search
* falls back to LIKE + fuzzy matching (#1532).
*/
readonly fts5Available: boolean;
private constructor(db: SqliteDatabase, dbPath: string, backend: SqliteBackend, fts5Available: boolean) {
this.db = db;
this.dbPath = dbPath;
this.backend = backend;
this.fts5Available = fts5Available;
this.openedInode = statInode(dbPath);
}
@@ -105,10 +112,35 @@ export class DatabaseConnection {
configureConnection(db);
// Run schema initialization
// Run schema initialization, splitting FTS5 from the rest so
// codegraph still works when Node.js was built without FTS5 (#1532).
const schemaPath = path.join(__dirname, 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
db.exec(schema);
const FTS5_MARKER = '-- Full-text search index on node names, docstrings, and signatures';
const ftsIdx = schema.indexOf(FTS5_MARKER);
let fts5Available = true;
if (ftsIdx >= 0) {
const preFts = schema.slice(0, ftsIdx);
const ftsSection = schema.slice(ftsIdx);
// Execute everything before FTS5 first
db.exec(preFts);
// Try FTS5; if it fails, skip it and continue with LIKE-only search
try {
db.exec(ftsSection);
} catch (err: any) {
fts5Available = false;
const msg = err?.message ?? String(err);
console.warn(
`[codegraph] FTS5 not available in this Node.js build (${msg}). ` +
`Search will fall back to LIKE + fuzzy matching. ` +
`For full-text search, use a Node.js build with FTS5 enabled.`
);
}
} else {
db.exec(schema);
}
// Record current schema version so migrations aren't re-applied on open
const currentVersion = getCurrentVersion(db);
@@ -118,7 +150,7 @@ export class DatabaseConnection {
).run(CURRENT_SCHEMA_VERSION, Date.now(), 'Initial schema includes all migrations');
}
return new DatabaseConnection(db, dbPath, backend);
return new DatabaseConnection(db, dbPath, backend, fts5Available);
}
/**
@@ -133,8 +165,16 @@ export class DatabaseConnection {
configureConnection(db);
// Detect FTS5 availability for search fallback (#1532)
let fts5Available = true;
try {
db.exec("SELECT * FROM nodes_fts LIMIT 0");
} catch {
fts5Available = false;
}
// Check and run migrations if needed
const conn = new DatabaseConnection(db, dbPath, backend);
const conn = new DatabaseConnection(db, dbPath, backend, fts5Available);
const currentVersion = getCurrentVersion(db);
if (currentVersion < CURRENT_SCHEMA_VERSION) {
@@ -169,6 +209,7 @@ export class DatabaseConnection {
* row written by anyone during the window is captured by the rebuild.
*/
beginBulkNodeLoad(): void {
if (!this.fts5Available) return;
for (const t of DatabaseConnection.FTS_TRIGGER_NAMES) {
this.db.exec(`DROP TRIGGER IF EXISTS ${t}`);
}
@@ -181,6 +222,7 @@ export class DatabaseConnection {
* IF NOT EXISTS).
*/
endBulkNodeLoad(): void {
if (!this.fts5Available) return;
this.db.exec(`INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')`);
this.recreateFtsTriggers();
}
@@ -355,6 +397,7 @@ export class DatabaseConnection {
/** Recreate the FTS triggers + rebuild if a bulk-load window never closed. */
private healBulkNodeLoad(): void {
if (!this.fts5Available) return;
const row = this.db
.prepare(
`SELECT count(*) AS c FROM sqlite_master WHERE type = 'trigger' AND name IN ('nodes_ai','nodes_ad','nodes_au')`
+12 -2
View File
@@ -223,6 +223,9 @@ export class QueryBuilder {
private projectNameTokens: Set<string> = new Set();
private isDeprioritizedPath: ((filePath: string) => boolean) | undefined;
// FTS5 availability flag — detected once at construction time (#1532)
private _fts5Available: boolean | undefined;
// Node cache for frequently accessed nodes (LRU-style, max 1000 entries)
private nodeCache: Map<string, Node> = new Map();
private readonly maxCacheSize = 1000;
@@ -315,6 +318,13 @@ export class QueryBuilder {
constructor(db: SqliteDatabase) {
this.db = db;
// Detect FTS5 availability once (#1532)
try {
db.prepare("SELECT * FROM nodes_fts LIMIT 0").get();
this._fts5Available = true;
} catch {
this._fts5Available = false;
}
}
/**
@@ -1258,9 +1268,9 @@ export class QueryBuilder {
const kinds = mergedKinds;
const languages = mergedLanguages;
// First try FTS5 with prefix matching
// First try FTS5 with prefix matching (skip if FTS5 not available, #1532)
let results = text
? this.searchNodesFTS(text, { kinds, languages, limit, offset })
? (this._fts5Available !== false ? this.searchNodesFTS(text, { kinds, languages, limit, offset }) : [])
// Over-fetch by 5× when running filter-only (no text). The
// post-scoring path: + name: filters can be very selective, so
// a smaller multiplier risks returning fewer than `limit`