sqlite-adapter.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. /**
  2. * SQLite Adapter
  3. *
  4. * Provides a unified interface over better-sqlite3 (native) and
  5. * node-sqlite3-wasm (WASM fallback) for universal cross-platform support.
  6. */
  7. export interface SqliteStatement {
  8. run(...params: any[]): { changes: number; lastInsertRowid: number | bigint };
  9. get(...params: any[]): any;
  10. all(...params: any[]): any[];
  11. }
  12. export interface SqliteDatabase {
  13. prepare(sql: string): SqliteStatement;
  14. exec(sql: string): void;
  15. pragma(str: string): any;
  16. transaction<T>(fn: (...args: any[]) => T): (...args: any[]) => T;
  17. close(): void;
  18. readonly open: boolean;
  19. }
  20. export type SqliteBackend = 'native' | 'wasm';
  21. /**
  22. * One-line summary of the recovery steps shown when WASM fallback is
  23. * active. Single source of truth so the recipe can't drift between the
  24. * stderr banner and the MCP status formatter.
  25. */
  26. export const WASM_FALLBACK_FIX_RECIPE =
  27. '`xcode-select --install` (macOS) or `apt install build-essential` (Debian/Ubuntu), ' +
  28. 'then `npm rebuild better-sqlite3`, or `npm install better-sqlite3 --save` to force-include it.';
  29. /**
  30. * Multi-line banner shown to stderr when `createDatabase` falls back to
  31. * WASM. Replaces a one-line `console.warn` that MCP transports (which
  32. * take stdout for the protocol) typically swallow, leaving users on a
  33. * 5-10x slower backend with no signal.
  34. *
  35. * Exported for unit testing — pinning the recipe content prevents
  36. * future edits from silently stripping the recovery commands.
  37. */
  38. export function buildWasmFallbackBanner(nativeError?: string): string {
  39. const sep = '─'.repeat(72);
  40. const lines = [
  41. sep,
  42. '[CodeGraph] WASM SQLite fallback active (better-sqlite3 unavailable)',
  43. sep,
  44. 'Indexing and sync will be 5-10x slower than the native backend.',
  45. '',
  46. 'Fix on macOS:',
  47. ' xcode-select --install # install C build tools',
  48. ' npm rebuild better-sqlite3 # rebuild native binding for current Node',
  49. '',
  50. 'Fix on Linux:',
  51. ' sudo apt install build-essential python3 make # Debian/Ubuntu',
  52. ' # or: sudo yum groupinstall "Development Tools" # RHEL/Fedora',
  53. ' npm rebuild better-sqlite3',
  54. '',
  55. 'Or force-include as a hard dependency on any platform:',
  56. ' npm install better-sqlite3 --save',
  57. '',
  58. 'Verify after fix: `codegraph status` should show `Backend: native`.',
  59. ];
  60. if (nativeError) {
  61. lines.push('', `Native load error: ${nativeError}`);
  62. }
  63. lines.push(sep);
  64. return lines.join('\n');
  65. }
  66. /**
  67. * Translate @named parameters (better-sqlite3 style) to positional ? params
  68. * for node-sqlite3-wasm, which only supports positional binding.
  69. *
  70. * Returns the rewritten SQL and an ordered list of parameter names.
  71. * If no named params are found, returns null for paramOrder (positional mode).
  72. */
  73. function translateNamedParams(sql: string): { sql: string; paramOrder: string[] | null } {
  74. const paramOrder: string[] = [];
  75. const rewritten = sql.replace(/@(\w+)/g, (_match, name: string) => {
  76. paramOrder.push(name);
  77. return '?';
  78. });
  79. if (paramOrder.length === 0) {
  80. return { sql, paramOrder: null };
  81. }
  82. return { sql: rewritten, paramOrder };
  83. }
  84. /**
  85. * Convert better-sqlite3-style params to a positional array for node-sqlite3-wasm.
  86. *
  87. * Handles three calling conventions:
  88. * - Named object: run({ id: '1', name: 'a' }) → positional array via paramOrder
  89. * - Positional args: run('a', 'b') → ['a', 'b']
  90. * - No args: run() → undefined
  91. */
  92. function resolveParams(params: any[], paramOrder: string[] | null): any {
  93. if (params.length === 0) return undefined;
  94. // If paramOrder exists and first arg is a plain object, do named→positional translation
  95. if (paramOrder && params.length === 1 && params[0] !== null && typeof params[0] === 'object' && !Array.isArray(params[0]) && !(params[0] instanceof Buffer) && !(params[0] instanceof Uint8Array)) {
  96. const obj = params[0];
  97. return paramOrder.map(name => obj[name]);
  98. }
  99. // Positional: single value or already an array
  100. if (params.length === 1) return params[0];
  101. return params;
  102. }
  103. /**
  104. * Wraps node-sqlite3-wasm to match the better-sqlite3 interface.
  105. *
  106. * Key differences handled:
  107. * - better-sqlite3 uses @named params; node-sqlite3-wasm uses positional ? only
  108. * - better-sqlite3 uses variadic args: stmt.run(a, b, c)
  109. * - node-sqlite3-wasm uses a single array/object: stmt.run([a, b, c])
  110. * - node-sqlite3-wasm has `isOpen` instead of `open`
  111. * - node-sqlite3-wasm doesn't have a `pragma()` method
  112. * - node-sqlite3-wasm doesn't have a `transaction()` method
  113. */
  114. class WasmDatabaseAdapter implements SqliteDatabase {
  115. private _db: any;
  116. // Track raw WASM statements so we can finalize them on close.
  117. // node-sqlite3-wasm won't release its file lock if statements are left open.
  118. private _openStmts = new Set<any>();
  119. constructor(dbPath: string) {
  120. // eslint-disable-next-line @typescript-eslint/no-require-imports
  121. const { Database } = require('node-sqlite3-wasm');
  122. this._db = new Database(dbPath);
  123. }
  124. get open(): boolean {
  125. return this._db.isOpen;
  126. }
  127. prepare(sql: string): SqliteStatement {
  128. const { sql: rewrittenSql, paramOrder } = translateNamedParams(sql);
  129. const stmt = this._db.prepare(rewrittenSql);
  130. this._openStmts.add(stmt);
  131. return {
  132. run(...params: any[]) {
  133. const resolved = resolveParams(params, paramOrder);
  134. const result = resolved !== undefined ? stmt.run(resolved) : stmt.run();
  135. return {
  136. changes: result?.changes ?? 0,
  137. lastInsertRowid: result?.lastInsertRowid ?? 0,
  138. };
  139. },
  140. get(...params: any[]) {
  141. const resolved = resolveParams(params, paramOrder);
  142. return resolved !== undefined ? stmt.get(resolved) : stmt.get();
  143. },
  144. all(...params: any[]) {
  145. const resolved = resolveParams(params, paramOrder);
  146. return resolved !== undefined ? stmt.all(resolved) : stmt.all();
  147. },
  148. };
  149. }
  150. exec(sql: string): void {
  151. this._db.exec(sql);
  152. }
  153. pragma(str: string): any {
  154. const trimmed = str.trim();
  155. // Write pragma: "key = value"
  156. if (trimmed.includes('=')) {
  157. const eqIdx = trimmed.indexOf('=');
  158. const key = trimmed.substring(0, eqIdx).trim();
  159. const value = trimmed.substring(eqIdx + 1).trim();
  160. // WAL is not supported in WASM SQLite — use DELETE journal mode
  161. if (key === 'journal_mode' && value.toUpperCase() === 'WAL') {
  162. this._db.exec('PRAGMA journal_mode = DELETE');
  163. return;
  164. }
  165. // mmap is not available in WASM — silently skip
  166. if (key === 'mmap_size') {
  167. return;
  168. }
  169. // synchronous = NORMAL is unsafe without WAL — use FULL
  170. if (key === 'synchronous' && value.toUpperCase() === 'NORMAL') {
  171. this._db.exec('PRAGMA synchronous = FULL');
  172. return;
  173. }
  174. this._db.exec(`PRAGMA ${key} = ${value}`);
  175. return;
  176. }
  177. // Read pragma: "key" — return the value
  178. const stmt = this._db.prepare(`PRAGMA ${trimmed}`);
  179. const result = stmt.get();
  180. stmt.finalize();
  181. return result;
  182. }
  183. transaction<T>(fn: (...args: any[]) => T): (...args: any[]) => T {
  184. return (...args: any[]) => {
  185. this._db.exec('BEGIN');
  186. try {
  187. const result = fn(...args);
  188. this._db.exec('COMMIT');
  189. return result;
  190. } catch (error) {
  191. this._db.exec('ROLLBACK');
  192. throw error;
  193. }
  194. };
  195. }
  196. close(): void {
  197. // Finalize all tracked statements before closing.
  198. // node-sqlite3-wasm won't release its directory-based file lock
  199. // if any prepared statements remain open.
  200. for (const stmt of this._openStmts) {
  201. try { stmt.finalize(); } catch { /* already finalized */ }
  202. }
  203. this._openStmts.clear();
  204. this._db.close();
  205. }
  206. }
  207. /**
  208. * Create a database connection. Tries native better-sqlite3 first,
  209. * falls back to node-sqlite3-wasm. Returns the active backend
  210. * alongside the db so each `DatabaseConnection` can report its own
  211. * backend per-instance — MCP can open multiple project DBs in one
  212. * process (`tools.ts` getCodeGraph cache), so a process-global would
  213. * race / overwrite.
  214. */
  215. export function createDatabase(dbPath: string): { db: SqliteDatabase; backend: SqliteBackend } {
  216. let nativeError: string | undefined;
  217. let wasmError: string | undefined;
  218. // Try native better-sqlite3 first
  219. try {
  220. // eslint-disable-next-line @typescript-eslint/no-require-imports
  221. const Database = require('better-sqlite3');
  222. const db = new Database(dbPath);
  223. return { db: db as SqliteDatabase, backend: 'native' };
  224. } catch (error) {
  225. nativeError = error instanceof Error ? error.message : String(error);
  226. }
  227. // Fall back to WASM
  228. try {
  229. const db = new WasmDatabaseAdapter(dbPath);
  230. console.warn(buildWasmFallbackBanner(nativeError));
  231. return { db, backend: 'wasm' };
  232. } catch (error) {
  233. wasmError = error instanceof Error ? error.message : String(error);
  234. }
  235. throw new Error(
  236. `Failed to load any SQLite backend.\n` +
  237. ` Native (better-sqlite3): ${nativeError}\n` +
  238. ` WASM (node-sqlite3-wasm): ${wasmError}`
  239. );
  240. }