sqlite-adapter.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  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. let activeBackend: SqliteBackend | null = null;
  22. /**
  23. * Get the currently active SQLite backend.
  24. */
  25. export function getActiveBackend(): SqliteBackend | null {
  26. return activeBackend;
  27. }
  28. /**
  29. * Translate @named parameters (better-sqlite3 style) to positional ? params
  30. * for node-sqlite3-wasm, which only supports positional binding.
  31. *
  32. * Returns the rewritten SQL and an ordered list of parameter names.
  33. * If no named params are found, returns null for paramOrder (positional mode).
  34. */
  35. function translateNamedParams(sql: string): { sql: string; paramOrder: string[] | null } {
  36. const paramOrder: string[] = [];
  37. const rewritten = sql.replace(/@(\w+)/g, (_match, name: string) => {
  38. paramOrder.push(name);
  39. return '?';
  40. });
  41. if (paramOrder.length === 0) {
  42. return { sql, paramOrder: null };
  43. }
  44. return { sql: rewritten, paramOrder };
  45. }
  46. /**
  47. * Convert better-sqlite3-style params to a positional array for node-sqlite3-wasm.
  48. *
  49. * Handles three calling conventions:
  50. * - Named object: run({ id: '1', name: 'a' }) → positional array via paramOrder
  51. * - Positional args: run('a', 'b') → ['a', 'b']
  52. * - No args: run() → undefined
  53. */
  54. function resolveParams(params: any[], paramOrder: string[] | null): any {
  55. if (params.length === 0) return undefined;
  56. // If paramOrder exists and first arg is a plain object, do named→positional translation
  57. if (paramOrder && params.length === 1 && params[0] !== null && typeof params[0] === 'object' && !Array.isArray(params[0]) && !(params[0] instanceof Buffer) && !(params[0] instanceof Uint8Array)) {
  58. const obj = params[0];
  59. return paramOrder.map(name => obj[name]);
  60. }
  61. // Positional: single value or already an array
  62. if (params.length === 1) return params[0];
  63. return params;
  64. }
  65. /**
  66. * Wraps node-sqlite3-wasm to match the better-sqlite3 interface.
  67. *
  68. * Key differences handled:
  69. * - better-sqlite3 uses @named params; node-sqlite3-wasm uses positional ? only
  70. * - better-sqlite3 uses variadic args: stmt.run(a, b, c)
  71. * - node-sqlite3-wasm uses a single array/object: stmt.run([a, b, c])
  72. * - node-sqlite3-wasm has `isOpen` instead of `open`
  73. * - node-sqlite3-wasm doesn't have a `pragma()` method
  74. * - node-sqlite3-wasm doesn't have a `transaction()` method
  75. */
  76. class WasmDatabaseAdapter implements SqliteDatabase {
  77. private _db: any;
  78. // Track raw WASM statements so we can finalize them on close.
  79. // node-sqlite3-wasm won't release its file lock if statements are left open.
  80. private _openStmts = new Set<any>();
  81. constructor(dbPath: string) {
  82. // eslint-disable-next-line @typescript-eslint/no-require-imports
  83. const { Database } = require('node-sqlite3-wasm');
  84. this._db = new Database(dbPath);
  85. }
  86. get open(): boolean {
  87. return this._db.isOpen;
  88. }
  89. prepare(sql: string): SqliteStatement {
  90. const { sql: rewrittenSql, paramOrder } = translateNamedParams(sql);
  91. const stmt = this._db.prepare(rewrittenSql);
  92. this._openStmts.add(stmt);
  93. return {
  94. run(...params: any[]) {
  95. const resolved = resolveParams(params, paramOrder);
  96. const result = resolved !== undefined ? stmt.run(resolved) : stmt.run();
  97. return {
  98. changes: result?.changes ?? 0,
  99. lastInsertRowid: result?.lastInsertRowid ?? 0,
  100. };
  101. },
  102. get(...params: any[]) {
  103. const resolved = resolveParams(params, paramOrder);
  104. return resolved !== undefined ? stmt.get(resolved) : stmt.get();
  105. },
  106. all(...params: any[]) {
  107. const resolved = resolveParams(params, paramOrder);
  108. return resolved !== undefined ? stmt.all(resolved) : stmt.all();
  109. },
  110. };
  111. }
  112. exec(sql: string): void {
  113. this._db.exec(sql);
  114. }
  115. pragma(str: string): any {
  116. const trimmed = str.trim();
  117. // Write pragma: "key = value"
  118. if (trimmed.includes('=')) {
  119. const eqIdx = trimmed.indexOf('=');
  120. const key = trimmed.substring(0, eqIdx).trim();
  121. const value = trimmed.substring(eqIdx + 1).trim();
  122. // WAL is not supported in WASM SQLite — use DELETE journal mode
  123. if (key === 'journal_mode' && value.toUpperCase() === 'WAL') {
  124. this._db.exec('PRAGMA journal_mode = DELETE');
  125. return;
  126. }
  127. // mmap is not available in WASM — silently skip
  128. if (key === 'mmap_size') {
  129. return;
  130. }
  131. // synchronous = NORMAL is unsafe without WAL — use FULL
  132. if (key === 'synchronous' && value.toUpperCase() === 'NORMAL') {
  133. this._db.exec('PRAGMA synchronous = FULL');
  134. return;
  135. }
  136. this._db.exec(`PRAGMA ${key} = ${value}`);
  137. return;
  138. }
  139. // Read pragma: "key" — return the value
  140. const stmt = this._db.prepare(`PRAGMA ${trimmed}`);
  141. const result = stmt.get();
  142. stmt.finalize();
  143. return result;
  144. }
  145. transaction<T>(fn: (...args: any[]) => T): (...args: any[]) => T {
  146. return (...args: any[]) => {
  147. this._db.exec('BEGIN');
  148. try {
  149. const result = fn(...args);
  150. this._db.exec('COMMIT');
  151. return result;
  152. } catch (error) {
  153. this._db.exec('ROLLBACK');
  154. throw error;
  155. }
  156. };
  157. }
  158. close(): void {
  159. // Finalize all tracked statements before closing.
  160. // node-sqlite3-wasm won't release its directory-based file lock
  161. // if any prepared statements remain open.
  162. for (const stmt of this._openStmts) {
  163. try { stmt.finalize(); } catch { /* already finalized */ }
  164. }
  165. this._openStmts.clear();
  166. this._db.close();
  167. }
  168. }
  169. /**
  170. * Create a database connection. Tries native better-sqlite3 first,
  171. * falls back to node-sqlite3-wasm.
  172. */
  173. export function createDatabase(dbPath: string): SqliteDatabase {
  174. let nativeError: string | undefined;
  175. let wasmError: string | undefined;
  176. // Try native better-sqlite3 first
  177. try {
  178. // eslint-disable-next-line @typescript-eslint/no-require-imports
  179. const Database = require('better-sqlite3');
  180. const db = new Database(dbPath);
  181. activeBackend = 'native';
  182. return db as SqliteDatabase;
  183. } catch (error) {
  184. nativeError = error instanceof Error ? error.message : String(error);
  185. }
  186. // Fall back to WASM
  187. try {
  188. const db = new WasmDatabaseAdapter(dbPath);
  189. activeBackend = 'wasm';
  190. console.warn('[CodeGraph] Using WASM SQLite backend (native better-sqlite3 unavailable)');
  191. return db;
  192. } catch (error) {
  193. wasmError = error instanceof Error ? error.message : String(error);
  194. }
  195. throw new Error(
  196. `Failed to load any SQLite backend.\n` +
  197. ` Native (better-sqlite3): ${nativeError}\n` +
  198. ` WASM (node-sqlite3-wasm): ${wasmError}`
  199. );
  200. }