Skip to content

Commit b05d118

Browse files
Merge pull request #168 from si-kui-a/fix/graph-fts5-actionable-error
fix(graph): fail with an actionable message when Node's SQLite lacks FTS5
2 parents c3383a1 + fa4c350 commit b05d118

3 files changed

Lines changed: 175 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
### Fixed
8+
- `mex graph` now fails with an actionable message naming the running Node version when the built-in `node:sqlite` module lacks FTS5 support, instead of surfacing SQLite's raw `no such module: fts5` on the first schema statement that needs it. FTS5 availability is not guaranteed by every Node build/version inside the documented `engines` range (#110).
9+
710
## [0.8.0] - 2026-09-02
811

912
### Added
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { mkdtempSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import { assertFts5Available, openGraphDatabase } from "../db/database.js";
6+
import type { SqliteDatabase } from "../db/sqlite.js";
7+
8+
const roots: string[] = [];
9+
10+
afterEach(() => {
11+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
12+
vi.doUnmock("../db/sqlite.js");
13+
});
14+
15+
/** Minimal SqliteDatabase fake: only `exec` is exercised by assertFts5Available. */
16+
function fakeDb(execImpl: (sql: string) => void): SqliteDatabase {
17+
return {
18+
prepare: () => {
19+
throw new Error("not used by this test");
20+
},
21+
exec: execImpl,
22+
pragma: () => {},
23+
transaction: (fn) => fn(),
24+
close: () => {},
25+
open: true,
26+
};
27+
}
28+
29+
/**
30+
* Load a fresh `database.js` whose `assertFts5Available` probes against
31+
* `fakeExec` instead of a real `:memory:` connection, by mocking the
32+
* `openSqlite` it imports from `sqlite.js`. `assertFts5Available` no longer
33+
* takes a `SqliteDatabase` parameter (PR #168 review: it must not touch the
34+
* caller's real graph database) — it opens its own throwaway connection
35+
* internally, so exercising the error paths now goes through this module
36+
* mock rather than an injected fake.
37+
*/
38+
async function assertFts5AvailableWith(fakeExec: (sql: string) => void) {
39+
vi.resetModules();
40+
vi.doMock("../db/sqlite.js", () => ({
41+
openSqlite: () => fakeDb(fakeExec),
42+
}));
43+
const fresh = await import("../db/database.js");
44+
return fresh.assertFts5Available;
45+
}
46+
47+
describe("assertFts5Available", () => {
48+
it("does not throw when FTS5 statements succeed", () => {
49+
// node:sqlite is compiled with FTS5 in every environment these tests run
50+
// in (see sqlite.ts's module comment), so this exercises the real probe
51+
// against a real throwaway :memory: connection end to end.
52+
expect(() => assertFts5Available()).not.toThrow();
53+
});
54+
55+
it("raises an actionable, Node-version-specific message on the exact SQLite error from issue #110", async () => {
56+
const probe = await assertFts5AvailableWith(() => {
57+
throw new Error("no such module: fts5");
58+
});
59+
60+
expect(() => probe()).toThrowError(
61+
new RegExp(`Node \\(${process.version.replace(/[.+]/g, "\\$&")}\\).*FTS5.*no such module: fts5`, "s"),
62+
);
63+
});
64+
65+
it("re-throws an unrelated exec failure unchanged, rather than misattributing it to FTS5", async () => {
66+
const probe = await assertFts5AvailableWith(() => {
67+
throw new Error("database is locked");
68+
});
69+
70+
expect(() => probe()).toThrowError("database is locked");
71+
expect(() => probe()).not.toThrow(/FTS5/);
72+
});
73+
});
74+
75+
describe("openGraphDatabase FTS5 preflight", () => {
76+
it("still opens normally on a machine whose SQLite build has FTS5 (the common case)", () => {
77+
const root = mkdtempSync(join(tmpdir(), "mex-database-fts5-"));
78+
roots.push(root);
79+
80+
const db = openGraphDatabase(join(root, "graph.db"));
81+
try {
82+
// exercised implicitly by openGraphDatabase not throwing; assert the FTS5
83+
// tables schema.sql defines actually exist, confirming the preflight probe
84+
// did not somehow prevent or corrupt the real schema application
85+
const tables = db
86+
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%_fts%'")
87+
.all() as Array<{ name: string }>;
88+
expect(tables.length).toBeGreaterThan(0);
89+
} finally {
90+
db.close();
91+
}
92+
});
93+
94+
it("closes the database handle when the FTS5 preflight fails, instead of leaking it open", async () => {
95+
const root = mkdtempSync(join(tmpdir(), "mex-database-fts5-close-"));
96+
roots.push(root);
97+
98+
vi.resetModules();
99+
vi.doMock("../db/sqlite.js", async () => {
100+
const actual = await vi.importActual<typeof import("../db/sqlite.js")>("../db/sqlite.js");
101+
let realGraphDb: SqliteDatabase | undefined;
102+
return {
103+
...actual,
104+
openSqlite: (path: string, options?: { readOnly?: boolean; immutable?: boolean }) => {
105+
if (path === ":memory:") {
106+
// The FTS5 preflight's own throwaway connection: fail it.
107+
return {
108+
prepare: () => {
109+
throw new Error("not used by this test");
110+
},
111+
exec: () => {
112+
throw new Error("no such module: fts5");
113+
},
114+
pragma: () => {},
115+
transaction: <T>(fn: () => T) => fn(),
116+
close: () => {},
117+
open: true,
118+
} satisfies SqliteDatabase;
119+
}
120+
realGraphDb = actual.openSqlite(path, options);
121+
return realGraphDb;
122+
},
123+
__getRealGraphDb: () => realGraphDb,
124+
};
125+
});
126+
127+
const fresh = await import("../db/database.js");
128+
const sqliteMock = (await import("../db/sqlite.js")) as unknown as {
129+
__getRealGraphDb: () => SqliteDatabase | undefined;
130+
};
131+
132+
expect(() => fresh.openGraphDatabase(join(root, "graph.db"))).toThrow(/FTS5/);
133+
expect(sqliteMock.__getRealGraphDb()?.open).toBe(false);
134+
});
135+
});

src/graph/db/database.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,42 @@ function configureReadOnlyConnection(db: SqliteDatabase): void {
5757
db.pragma("query_only = ON");
5858
}
5959

60+
/**
61+
* Probe for FTS5 support and fail fast with an actionable message if it's missing.
62+
*
63+
* `node:sqlite`'s bundled SQLite is not guaranteed to be built with FTS5 on every
64+
* Node build/version, even within the range `package.json`'s `engines` documents
65+
* as supported (issue #110). Without this check, the first FTS5 statement in
66+
* `schema.sql` throws SQLite's raw `no such module: fts5`, which reads like a mex
67+
* bug rather than a Node/SQLite build limitation. Create-and-drop a throwaway
68+
* virtual table rather than querying `pragma_module_list`, since that pragma is
69+
* unavailable on some `node:sqlite` builds too and FTS5 usage is what actually
70+
* needs to work.
71+
*
72+
* FTS5 availability is a property of the SQLite build the running Node binary
73+
* embeds, not of any particular database file, so the probe runs against a
74+
* throwaway `:memory:` connection rather than the caller's real database.
75+
* Probing in place (an earlier version of this function took the caller's
76+
* `SqliteDatabase`) rewrote the on-disk graph on every successful open, which
77+
* broke a read-path non-mutation regression test in CI (PR #168 review).
78+
*/
79+
export function assertFts5Available(): void {
80+
const probe = openSqlite(":memory:");
81+
try {
82+
probe.exec("CREATE VIRTUAL TABLE __mex_fts5_probe USING fts5(x)");
83+
} catch (error) {
84+
const msg = error instanceof Error ? error.message : String(error);
85+
if (!/fts5/i.test(msg)) throw error; // a different problem; surface it unchanged
86+
throw new Error(
87+
`Your Node (${process.version}) has SQLite built without FTS5 support, which mex's code graph ` +
88+
"requires. Try a different Node build/version - see COMPATIBILITY.md for which versions are " +
89+
`known to work. Underlying error: ${msg}`,
90+
);
91+
} finally {
92+
probe.close();
93+
}
94+
}
95+
6096
/**
6197
* Open the graph DB at `dbPath`, creating the file + parent dir and applying the
6298
* schema when absent. Idempotent: re-opening an existing DB re-applies PRAGMAs
@@ -84,6 +120,7 @@ export function openGraphDatabase(
84120
configureConnection(db);
85121

86122
try {
123+
assertFts5Available();
87124
initializeWritableGraphDatabase(db, readFileSync(schemaPath(), "utf-8"), options);
88125
return db;
89126
} catch (error) {

0 commit comments

Comments
 (0)