queries.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985
  1. /**
  2. * Database Queries
  3. *
  4. * Prepared statements for CRUD operations on the knowledge graph.
  5. */
  6. import Database from 'better-sqlite3';
  7. import {
  8. Node,
  9. Edge,
  10. FileRecord,
  11. UnresolvedReference,
  12. NodeKind,
  13. EdgeKind,
  14. Language,
  15. GraphStats,
  16. SearchOptions,
  17. SearchResult,
  18. } from '../types';
  19. import { safeJsonParse } from '../utils';
  20. import { kindBonus, scorePathRelevance } from '../search/query-utils';
  21. /**
  22. * Database row types (snake_case from SQLite)
  23. */
  24. interface NodeRow {
  25. id: string;
  26. kind: string;
  27. name: string;
  28. qualified_name: string;
  29. file_path: string;
  30. language: string;
  31. start_line: number;
  32. end_line: number;
  33. start_column: number;
  34. end_column: number;
  35. docstring: string | null;
  36. signature: string | null;
  37. visibility: string | null;
  38. is_exported: number;
  39. is_async: number;
  40. is_static: number;
  41. is_abstract: number;
  42. decorators: string | null;
  43. type_parameters: string | null;
  44. updated_at: number;
  45. }
  46. interface EdgeRow {
  47. id: number;
  48. source: string;
  49. target: string;
  50. kind: string;
  51. metadata: string | null;
  52. line: number | null;
  53. col: number | null;
  54. }
  55. interface FileRow {
  56. path: string;
  57. content_hash: string;
  58. language: string;
  59. size: number;
  60. modified_at: number;
  61. indexed_at: number;
  62. node_count: number;
  63. errors: string | null;
  64. }
  65. interface UnresolvedRefRow {
  66. id: number;
  67. from_node_id: string;
  68. reference_name: string;
  69. reference_kind: string;
  70. line: number;
  71. col: number;
  72. file_path: string | null;
  73. language: string | null;
  74. candidates: string | null;
  75. }
  76. /**
  77. * Convert database row to Node object
  78. */
  79. function rowToNode(row: NodeRow): Node {
  80. return {
  81. id: row.id,
  82. kind: row.kind as NodeKind,
  83. name: row.name,
  84. qualifiedName: row.qualified_name,
  85. filePath: row.file_path,
  86. language: row.language as Language,
  87. startLine: row.start_line,
  88. endLine: row.end_line,
  89. startColumn: row.start_column,
  90. endColumn: row.end_column,
  91. docstring: row.docstring ?? undefined,
  92. signature: row.signature ?? undefined,
  93. visibility: row.visibility as Node['visibility'],
  94. isExported: row.is_exported === 1,
  95. isAsync: row.is_async === 1,
  96. isStatic: row.is_static === 1,
  97. isAbstract: row.is_abstract === 1,
  98. decorators: row.decorators ? safeJsonParse(row.decorators, undefined) : undefined,
  99. typeParameters: row.type_parameters ? safeJsonParse(row.type_parameters, undefined) : undefined,
  100. updatedAt: row.updated_at,
  101. };
  102. }
  103. /**
  104. * Convert database row to Edge object
  105. */
  106. function rowToEdge(row: EdgeRow): Edge {
  107. return {
  108. source: row.source,
  109. target: row.target,
  110. kind: row.kind as EdgeKind,
  111. metadata: row.metadata ? safeJsonParse(row.metadata, undefined) : undefined,
  112. line: row.line ?? undefined,
  113. column: row.col ?? undefined,
  114. };
  115. }
  116. /**
  117. * Convert database row to FileRecord object
  118. */
  119. function rowToFileRecord(row: FileRow): FileRecord {
  120. return {
  121. path: row.path,
  122. contentHash: row.content_hash,
  123. language: row.language as Language,
  124. size: row.size,
  125. modifiedAt: row.modified_at,
  126. indexedAt: row.indexed_at,
  127. nodeCount: row.node_count,
  128. errors: row.errors ? safeJsonParse(row.errors, undefined) : undefined,
  129. };
  130. }
  131. /**
  132. * Query builder for the knowledge graph database
  133. */
  134. export class QueryBuilder {
  135. private db: Database.Database;
  136. // Node cache for frequently accessed nodes (LRU-style, max 1000 entries)
  137. private nodeCache: Map<string, Node> = new Map();
  138. private readonly maxCacheSize = 1000;
  139. // Prepared statements (lazily initialized)
  140. private stmts: {
  141. insertNode?: Database.Statement;
  142. updateNode?: Database.Statement;
  143. deleteNode?: Database.Statement;
  144. deleteNodesByFile?: Database.Statement;
  145. getNodeById?: Database.Statement;
  146. getNodesByFile?: Database.Statement;
  147. getNodesByKind?: Database.Statement;
  148. insertEdge?: Database.Statement;
  149. upsertFile?: Database.Statement;
  150. deleteEdgesBySource?: Database.Statement;
  151. deleteEdgesByTarget?: Database.Statement;
  152. getEdgesBySource?: Database.Statement;
  153. getEdgesByTarget?: Database.Statement;
  154. insertFile?: Database.Statement;
  155. updateFile?: Database.Statement;
  156. deleteFile?: Database.Statement;
  157. getFileByPath?: Database.Statement;
  158. getAllFiles?: Database.Statement;
  159. insertUnresolved?: Database.Statement;
  160. deleteUnresolvedByNode?: Database.Statement;
  161. getUnresolvedByName?: Database.Statement;
  162. } = {};
  163. constructor(db: Database.Database) {
  164. this.db = db;
  165. }
  166. // ===========================================================================
  167. // Node Operations
  168. // ===========================================================================
  169. /**
  170. * Insert a new node
  171. */
  172. insertNode(node: Node): void {
  173. if (!this.stmts.insertNode) {
  174. this.stmts.insertNode = this.db.prepare(`
  175. INSERT OR REPLACE INTO nodes (
  176. id, kind, name, qualified_name, file_path, language,
  177. start_line, end_line, start_column, end_column,
  178. docstring, signature, visibility,
  179. is_exported, is_async, is_static, is_abstract,
  180. decorators, type_parameters, updated_at
  181. ) VALUES (
  182. @id, @kind, @name, @qualifiedName, @filePath, @language,
  183. @startLine, @endLine, @startColumn, @endColumn,
  184. @docstring, @signature, @visibility,
  185. @isExported, @isAsync, @isStatic, @isAbstract,
  186. @decorators, @typeParameters, @updatedAt
  187. )
  188. `);
  189. }
  190. // Validate required fields to prevent SQLite bind errors
  191. if (!node.id || !node.kind || !node.name || !node.filePath || !node.language) {
  192. console.error('[CodeGraph] Skipping node with missing required fields:', {
  193. id: node.id,
  194. kind: node.kind,
  195. name: node.name,
  196. filePath: node.filePath,
  197. language: node.language,
  198. });
  199. return;
  200. }
  201. try {
  202. this.stmts.insertNode.run({
  203. id: node.id,
  204. kind: node.kind,
  205. name: node.name,
  206. qualifiedName: node.qualifiedName ?? node.name,
  207. filePath: node.filePath,
  208. language: node.language,
  209. startLine: node.startLine ?? 0,
  210. endLine: node.endLine ?? 0,
  211. startColumn: node.startColumn ?? 0,
  212. endColumn: node.endColumn ?? 0,
  213. docstring: node.docstring ?? null,
  214. signature: node.signature ?? null,
  215. visibility: node.visibility ?? null,
  216. isExported: node.isExported ? 1 : 0,
  217. isAsync: node.isAsync ? 1 : 0,
  218. isStatic: node.isStatic ? 1 : 0,
  219. isAbstract: node.isAbstract ? 1 : 0,
  220. decorators: node.decorators ? JSON.stringify(node.decorators) : null,
  221. typeParameters: node.typeParameters ? JSON.stringify(node.typeParameters) : null,
  222. updatedAt: node.updatedAt ?? Date.now(),
  223. });
  224. } catch (error) {
  225. const { captureException } = require('../sentry');
  226. captureException(error, {
  227. operation: 'insertNode',
  228. nodeId: node.id,
  229. nodeKind: node.kind,
  230. nodeName: node.name,
  231. filePath: node.filePath,
  232. language: node.language,
  233. startLine: node.startLine,
  234. });
  235. throw error;
  236. }
  237. }
  238. /**
  239. * Insert multiple nodes in a transaction
  240. */
  241. insertNodes(nodes: Node[]): void {
  242. this.db.transaction(() => {
  243. for (const node of nodes) {
  244. this.insertNode(node);
  245. }
  246. })();
  247. }
  248. /**
  249. * Update an existing node
  250. */
  251. updateNode(node: Node): void {
  252. if (!this.stmts.updateNode) {
  253. this.stmts.updateNode = this.db.prepare(`
  254. UPDATE nodes SET
  255. kind = @kind,
  256. name = @name,
  257. qualified_name = @qualifiedName,
  258. file_path = @filePath,
  259. language = @language,
  260. start_line = @startLine,
  261. end_line = @endLine,
  262. start_column = @startColumn,
  263. end_column = @endColumn,
  264. docstring = @docstring,
  265. signature = @signature,
  266. visibility = @visibility,
  267. is_exported = @isExported,
  268. is_async = @isAsync,
  269. is_static = @isStatic,
  270. is_abstract = @isAbstract,
  271. decorators = @decorators,
  272. type_parameters = @typeParameters,
  273. updated_at = @updatedAt
  274. WHERE id = @id
  275. `);
  276. }
  277. // Invalidate cache before update
  278. this.nodeCache.delete(node.id);
  279. // Validate required fields
  280. if (!node.id || !node.kind || !node.name || !node.filePath || !node.language) {
  281. console.error('[CodeGraph] Skipping node update with missing required fields:', node.id);
  282. return;
  283. }
  284. this.stmts.updateNode.run({
  285. id: node.id,
  286. kind: node.kind,
  287. name: node.name,
  288. qualifiedName: node.qualifiedName ?? node.name,
  289. filePath: node.filePath,
  290. language: node.language,
  291. startLine: node.startLine ?? 0,
  292. endLine: node.endLine ?? 0,
  293. startColumn: node.startColumn ?? 0,
  294. endColumn: node.endColumn ?? 0,
  295. docstring: node.docstring ?? null,
  296. signature: node.signature ?? null,
  297. visibility: node.visibility ?? null,
  298. isExported: node.isExported ? 1 : 0,
  299. isAsync: node.isAsync ? 1 : 0,
  300. isStatic: node.isStatic ? 1 : 0,
  301. isAbstract: node.isAbstract ? 1 : 0,
  302. decorators: node.decorators ? JSON.stringify(node.decorators) : null,
  303. typeParameters: node.typeParameters ? JSON.stringify(node.typeParameters) : null,
  304. updatedAt: node.updatedAt ?? Date.now(),
  305. });
  306. }
  307. /**
  308. * Delete a node by ID
  309. */
  310. deleteNode(id: string): void {
  311. if (!this.stmts.deleteNode) {
  312. this.stmts.deleteNode = this.db.prepare('DELETE FROM nodes WHERE id = ?');
  313. }
  314. // Invalidate cache
  315. this.nodeCache.delete(id);
  316. this.stmts.deleteNode.run(id);
  317. }
  318. /**
  319. * Delete all nodes for a file
  320. */
  321. deleteNodesByFile(filePath: string): void {
  322. if (!this.stmts.deleteNodesByFile) {
  323. this.stmts.deleteNodesByFile = this.db.prepare('DELETE FROM nodes WHERE file_path = ?');
  324. }
  325. // Invalidate cache for nodes in this file
  326. for (const [id, node] of this.nodeCache) {
  327. if (node.filePath === filePath) {
  328. this.nodeCache.delete(id);
  329. }
  330. }
  331. this.stmts.deleteNodesByFile.run(filePath);
  332. }
  333. /**
  334. * Get a node by ID
  335. */
  336. getNodeById(id: string): Node | null {
  337. // Check cache first
  338. if (this.nodeCache.has(id)) {
  339. const cached = this.nodeCache.get(id)!;
  340. // Move to end to implement LRU (delete and re-add)
  341. this.nodeCache.delete(id);
  342. this.nodeCache.set(id, cached);
  343. return cached;
  344. }
  345. if (!this.stmts.getNodeById) {
  346. this.stmts.getNodeById = this.db.prepare('SELECT * FROM nodes WHERE id = ?');
  347. }
  348. const row = this.stmts.getNodeById.get(id) as NodeRow | undefined;
  349. if (!row) {
  350. return null;
  351. }
  352. const node = rowToNode(row);
  353. this.cacheNode(node);
  354. return node;
  355. }
  356. /**
  357. * Add a node to the cache, evicting oldest if needed
  358. */
  359. private cacheNode(node: Node): void {
  360. if (this.nodeCache.size >= this.maxCacheSize) {
  361. // Evict oldest (first) entry
  362. const firstKey = this.nodeCache.keys().next().value;
  363. if (firstKey) {
  364. this.nodeCache.delete(firstKey);
  365. }
  366. }
  367. this.nodeCache.set(node.id, node);
  368. }
  369. /**
  370. * Clear the node cache
  371. */
  372. clearCache(): void {
  373. this.nodeCache.clear();
  374. }
  375. /**
  376. * Get all nodes in a file
  377. */
  378. getNodesByFile(filePath: string): Node[] {
  379. if (!this.stmts.getNodesByFile) {
  380. this.stmts.getNodesByFile = this.db.prepare(
  381. 'SELECT * FROM nodes WHERE file_path = ? ORDER BY start_line'
  382. );
  383. }
  384. const rows = this.stmts.getNodesByFile.all(filePath) as NodeRow[];
  385. return rows.map(rowToNode);
  386. }
  387. /**
  388. * Get all nodes of a specific kind
  389. */
  390. getNodesByKind(kind: NodeKind): Node[] {
  391. if (!this.stmts.getNodesByKind) {
  392. this.stmts.getNodesByKind = this.db.prepare('SELECT * FROM nodes WHERE kind = ?');
  393. }
  394. const rows = this.stmts.getNodesByKind.all(kind) as NodeRow[];
  395. return rows.map(rowToNode);
  396. }
  397. /**
  398. * Get all nodes in the database
  399. */
  400. getAllNodes(): Node[] {
  401. const rows = this.db.prepare('SELECT * FROM nodes').all() as NodeRow[];
  402. return rows.map(rowToNode);
  403. }
  404. /**
  405. * Search nodes by name using FTS with fallback to LIKE for better matching
  406. *
  407. * Search strategy:
  408. * 1. Try FTS5 prefix match (query*) for word-start matching
  409. * 2. If no results, try LIKE for substring matching (e.g., "signIn" finds "signInWithGoogle")
  410. * 3. Score results based on match quality
  411. */
  412. searchNodes(query: string, options: SearchOptions = {}): SearchResult[] {
  413. const { kinds, languages, limit = 100, offset = 0 } = options;
  414. // First try FTS5 with prefix matching
  415. let results = this.searchNodesFTS(query, { kinds, languages, limit, offset });
  416. // If no FTS results, try LIKE-based substring search
  417. if (results.length === 0 && query.length >= 2) {
  418. results = this.searchNodesLike(query, { kinds, languages, limit, offset });
  419. }
  420. // Apply multi-signal scoring
  421. if (results.length > 0 && query) {
  422. results = results.map(r => ({
  423. ...r,
  424. score: r.score + kindBonus(r.node.kind) + scorePathRelevance(r.node.filePath, query),
  425. }));
  426. results.sort((a, b) => b.score - a.score);
  427. }
  428. return results;
  429. }
  430. /**
  431. * FTS5 search with prefix matching
  432. */
  433. private searchNodesFTS(query: string, options: SearchOptions): SearchResult[] {
  434. const { kinds, languages, limit = 100, offset = 0 } = options;
  435. // Add prefix wildcard for better matching (e.g., "auth" matches "AuthService", "authenticate")
  436. // Escape special FTS5 characters and add prefix wildcard
  437. const ftsQuery = query
  438. .replace(/['"*():^]/g, '') // Remove FTS5 special chars
  439. .split(/\s+/)
  440. .filter(term => term.length > 0)
  441. // Strip FTS5 boolean operators to prevent query manipulation
  442. .filter(term => !/^(AND|OR|NOT|NEAR)$/i.test(term))
  443. .map(term => `"${term}"*`) // Prefix match each term
  444. .join(' OR ');
  445. if (!ftsQuery) {
  446. return [];
  447. }
  448. let sql = `
  449. SELECT nodes.*, bm25(nodes_fts) as score
  450. FROM nodes_fts
  451. JOIN nodes ON nodes_fts.id = nodes.id
  452. WHERE nodes_fts MATCH ?
  453. `;
  454. const params: (string | number)[] = [ftsQuery];
  455. if (kinds && kinds.length > 0) {
  456. sql += ` AND nodes.kind IN (${kinds.map(() => '?').join(',')})`;
  457. params.push(...kinds);
  458. }
  459. if (languages && languages.length > 0) {
  460. sql += ` AND nodes.language IN (${languages.map(() => '?').join(',')})`;
  461. params.push(...languages);
  462. }
  463. sql += ' ORDER BY score LIMIT ? OFFSET ?';
  464. params.push(limit, offset);
  465. try {
  466. const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[];
  467. return rows.map((row) => ({
  468. node: rowToNode(row),
  469. score: Math.abs(row.score), // bm25 returns negative scores
  470. }));
  471. } catch {
  472. // FTS query failed, return empty
  473. return [];
  474. }
  475. }
  476. /**
  477. * LIKE-based substring search for cases where FTS doesn't match
  478. * Useful for camelCase matching (e.g., "signIn" finds "signInWithGoogle")
  479. */
  480. private searchNodesLike(query: string, options: SearchOptions): SearchResult[] {
  481. const { kinds, languages, limit = 100, offset = 0 } = options;
  482. let sql = `
  483. SELECT nodes.*,
  484. CASE
  485. WHEN name = ? THEN 1.0
  486. WHEN name LIKE ? THEN 0.9
  487. WHEN name LIKE ? THEN 0.8
  488. WHEN qualified_name LIKE ? THEN 0.7
  489. ELSE 0.5
  490. END as score
  491. FROM nodes
  492. WHERE (
  493. name LIKE ? OR
  494. qualified_name LIKE ? OR
  495. name LIKE ?
  496. )
  497. `;
  498. // Pattern variants for better matching
  499. const exactMatch = query;
  500. const startsWith = `${query}%`;
  501. const contains = `%${query}%`;
  502. const params: (string | number)[] = [
  503. exactMatch, // Exact match score
  504. startsWith, // Starts with score
  505. contains, // Contains score
  506. contains, // Qualified name score
  507. contains, // WHERE: name contains
  508. contains, // WHERE: qualified_name contains
  509. startsWith, // WHERE: name starts with
  510. ];
  511. if (kinds && kinds.length > 0) {
  512. sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
  513. params.push(...kinds);
  514. }
  515. if (languages && languages.length > 0) {
  516. sql += ` AND language IN (${languages.map(() => '?').join(',')})`;
  517. params.push(...languages);
  518. }
  519. sql += ' ORDER BY score DESC, length(name) ASC LIMIT ? OFFSET ?';
  520. params.push(limit, offset);
  521. const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[];
  522. return rows.map((row) => ({
  523. node: rowToNode(row),
  524. score: row.score,
  525. }));
  526. }
  527. /**
  528. * Find nodes by exact name match
  529. *
  530. * Used for hybrid search - looks up symbols by exact name or case-insensitive match.
  531. * Returns high-confidence matches for known symbol names extracted from query.
  532. *
  533. * @param names - Array of symbol names to look up
  534. * @param options - Search options (kinds, languages, limit)
  535. * @returns SearchResult array with exact matches scored at 1.0
  536. */
  537. findNodesByExactName(names: string[], options: SearchOptions = {}): SearchResult[] {
  538. if (names.length === 0) return [];
  539. const { kinds, languages, limit = 50 } = options;
  540. // Build query with exact matches (case-insensitive)
  541. let sql = `
  542. SELECT nodes.*,
  543. CASE
  544. WHEN name COLLATE NOCASE IN (${names.map(() => '?').join(',')}) THEN 1.0
  545. ELSE 0.9
  546. END as score
  547. FROM nodes
  548. WHERE name COLLATE NOCASE IN (${names.map(() => '?').join(',')})
  549. `;
  550. // Duplicate names for both SELECT and WHERE clauses
  551. const params: (string | number)[] = [...names, ...names];
  552. if (kinds && kinds.length > 0) {
  553. sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
  554. params.push(...kinds);
  555. }
  556. if (languages && languages.length > 0) {
  557. sql += ` AND language IN (${languages.map(() => '?').join(',')})`;
  558. params.push(...languages);
  559. }
  560. sql += ' ORDER BY score DESC, length(name) ASC LIMIT ?';
  561. params.push(limit);
  562. const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[];
  563. return rows.map((row) => ({
  564. node: rowToNode(row),
  565. score: row.score,
  566. }));
  567. }
  568. // ===========================================================================
  569. // Edge Operations
  570. // ===========================================================================
  571. /**
  572. * Insert a new edge
  573. */
  574. insertEdge(edge: Edge): void {
  575. if (!this.stmts.insertEdge) {
  576. this.stmts.insertEdge = this.db.prepare(`
  577. INSERT OR IGNORE INTO edges (source, target, kind, metadata, line, col)
  578. VALUES (@source, @target, @kind, @metadata, @line, @col)
  579. `);
  580. }
  581. this.stmts.insertEdge.run({
  582. source: edge.source,
  583. target: edge.target,
  584. kind: edge.kind,
  585. metadata: edge.metadata ? JSON.stringify(edge.metadata) : null,
  586. line: edge.line ?? null,
  587. col: edge.column ?? null,
  588. });
  589. }
  590. /**
  591. * Insert multiple edges in a transaction
  592. */
  593. insertEdges(edges: Edge[]): void {
  594. this.db.transaction(() => {
  595. for (const edge of edges) {
  596. this.insertEdge(edge);
  597. }
  598. })();
  599. }
  600. /**
  601. * Delete all edges from a source node
  602. */
  603. deleteEdgesBySource(sourceId: string): void {
  604. if (!this.stmts.deleteEdgesBySource) {
  605. this.stmts.deleteEdgesBySource = this.db.prepare('DELETE FROM edges WHERE source = ?');
  606. }
  607. this.stmts.deleteEdgesBySource.run(sourceId);
  608. }
  609. /**
  610. * Get outgoing edges from a node
  611. */
  612. getOutgoingEdges(sourceId: string, kinds?: EdgeKind[]): Edge[] {
  613. if (kinds && kinds.length > 0) {
  614. const sql = `SELECT * FROM edges WHERE source = ? AND kind IN (${kinds.map(() => '?').join(',')})`;
  615. const rows = this.db.prepare(sql).all(sourceId, ...kinds) as EdgeRow[];
  616. return rows.map(rowToEdge);
  617. }
  618. if (!this.stmts.getEdgesBySource) {
  619. this.stmts.getEdgesBySource = this.db.prepare('SELECT * FROM edges WHERE source = ?');
  620. }
  621. const rows = this.stmts.getEdgesBySource.all(sourceId) as EdgeRow[];
  622. return rows.map(rowToEdge);
  623. }
  624. /**
  625. * Get incoming edges to a node
  626. */
  627. getIncomingEdges(targetId: string, kinds?: EdgeKind[]): Edge[] {
  628. if (kinds && kinds.length > 0) {
  629. const sql = `SELECT * FROM edges WHERE target = ? AND kind IN (${kinds.map(() => '?').join(',')})`;
  630. const rows = this.db.prepare(sql).all(targetId, ...kinds) as EdgeRow[];
  631. return rows.map(rowToEdge);
  632. }
  633. if (!this.stmts.getEdgesByTarget) {
  634. this.stmts.getEdgesByTarget = this.db.prepare('SELECT * FROM edges WHERE target = ?');
  635. }
  636. const rows = this.stmts.getEdgesByTarget.all(targetId) as EdgeRow[];
  637. return rows.map(rowToEdge);
  638. }
  639. // ===========================================================================
  640. // File Operations
  641. // ===========================================================================
  642. /**
  643. * Insert or update a file record
  644. */
  645. upsertFile(file: FileRecord): void {
  646. if (!this.stmts.upsertFile) {
  647. this.stmts.upsertFile = this.db.prepare(`
  648. INSERT INTO files (path, content_hash, language, size, modified_at, indexed_at, node_count, errors)
  649. VALUES (@path, @contentHash, @language, @size, @modifiedAt, @indexedAt, @nodeCount, @errors)
  650. ON CONFLICT(path) DO UPDATE SET
  651. content_hash = @contentHash,
  652. language = @language,
  653. size = @size,
  654. modified_at = @modifiedAt,
  655. indexed_at = @indexedAt,
  656. node_count = @nodeCount,
  657. errors = @errors
  658. `);
  659. }
  660. this.stmts.upsertFile.run({
  661. path: file.path,
  662. contentHash: file.contentHash,
  663. language: file.language,
  664. size: file.size,
  665. modifiedAt: file.modifiedAt,
  666. indexedAt: file.indexedAt,
  667. nodeCount: file.nodeCount,
  668. errors: file.errors ? JSON.stringify(file.errors) : null,
  669. });
  670. }
  671. /**
  672. * Delete a file record and its nodes
  673. */
  674. deleteFile(filePath: string): void {
  675. this.db.transaction(() => {
  676. this.deleteNodesByFile(filePath);
  677. if (!this.stmts.deleteFile) {
  678. this.stmts.deleteFile = this.db.prepare('DELETE FROM files WHERE path = ?');
  679. }
  680. this.stmts.deleteFile.run(filePath);
  681. })();
  682. }
  683. /**
  684. * Get a file record by path
  685. */
  686. getFileByPath(filePath: string): FileRecord | null {
  687. if (!this.stmts.getFileByPath) {
  688. this.stmts.getFileByPath = this.db.prepare('SELECT * FROM files WHERE path = ?');
  689. }
  690. const row = this.stmts.getFileByPath.get(filePath) as FileRow | undefined;
  691. return row ? rowToFileRecord(row) : null;
  692. }
  693. /**
  694. * Get all tracked files
  695. */
  696. getAllFiles(): FileRecord[] {
  697. if (!this.stmts.getAllFiles) {
  698. this.stmts.getAllFiles = this.db.prepare('SELECT * FROM files ORDER BY path');
  699. }
  700. const rows = this.stmts.getAllFiles.all() as FileRow[];
  701. return rows.map(rowToFileRecord);
  702. }
  703. /**
  704. * Get files that need re-indexing (hash changed)
  705. */
  706. getStaleFiles(currentHashes: Map<string, string>): FileRecord[] {
  707. const files = this.getAllFiles();
  708. return files.filter((f) => {
  709. const currentHash = currentHashes.get(f.path);
  710. return currentHash && currentHash !== f.contentHash;
  711. });
  712. }
  713. // ===========================================================================
  714. // Unresolved References
  715. // ===========================================================================
  716. /**
  717. * Insert an unresolved reference
  718. */
  719. insertUnresolvedRef(ref: UnresolvedReference): void {
  720. if (!this.stmts.insertUnresolved) {
  721. this.stmts.insertUnresolved = this.db.prepare(`
  722. INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, file_path, language, candidates)
  723. VALUES (@fromNodeId, @referenceName, @referenceKind, @line, @col, @filePath, @language, @candidates)
  724. `);
  725. }
  726. this.stmts.insertUnresolved.run({
  727. fromNodeId: ref.fromNodeId,
  728. referenceName: ref.referenceName,
  729. referenceKind: ref.referenceKind,
  730. line: ref.line,
  731. col: ref.column,
  732. filePath: ref.filePath ?? null,
  733. language: ref.language ?? null,
  734. candidates: ref.candidates ? JSON.stringify(ref.candidates) : null,
  735. });
  736. }
  737. /**
  738. * Insert multiple unresolved references in a single transaction
  739. */
  740. insertUnresolvedRefsBatch(refs: UnresolvedReference[]): void {
  741. if (refs.length === 0) return;
  742. if (!this.stmts.insertUnresolved) {
  743. this.stmts.insertUnresolved = this.db.prepare(`
  744. INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, file_path, language, candidates)
  745. VALUES (@fromNodeId, @referenceName, @referenceKind, @line, @col, @filePath, @language, @candidates)
  746. `);
  747. }
  748. this.db.transaction(() => {
  749. for (const ref of refs) {
  750. this.stmts.insertUnresolved!.run({
  751. fromNodeId: ref.fromNodeId,
  752. referenceName: ref.referenceName,
  753. referenceKind: ref.referenceKind,
  754. line: ref.line,
  755. col: ref.column,
  756. filePath: ref.filePath ?? null,
  757. language: ref.language ?? null,
  758. candidates: ref.candidates ? JSON.stringify(ref.candidates) : null,
  759. });
  760. }
  761. })();
  762. }
  763. /**
  764. * Delete unresolved references from a node
  765. */
  766. deleteUnresolvedByNode(nodeId: string): void {
  767. if (!this.stmts.deleteUnresolvedByNode) {
  768. this.stmts.deleteUnresolvedByNode = this.db.prepare(
  769. 'DELETE FROM unresolved_refs WHERE from_node_id = ?'
  770. );
  771. }
  772. this.stmts.deleteUnresolvedByNode.run(nodeId);
  773. }
  774. /**
  775. * Get unresolved references by name (for resolution)
  776. */
  777. getUnresolvedByName(name: string): UnresolvedReference[] {
  778. if (!this.stmts.getUnresolvedByName) {
  779. this.stmts.getUnresolvedByName = this.db.prepare(
  780. 'SELECT * FROM unresolved_refs WHERE reference_name = ?'
  781. );
  782. }
  783. const rows = this.stmts.getUnresolvedByName.all(name) as UnresolvedRefRow[];
  784. return rows.map((row) => ({
  785. fromNodeId: row.from_node_id,
  786. referenceName: row.reference_name,
  787. referenceKind: row.reference_kind as EdgeKind,
  788. line: row.line,
  789. column: row.col,
  790. filePath: row.file_path ?? undefined,
  791. language: (row.language as Language) ?? undefined,
  792. candidates: row.candidates ? safeJsonParse<string[]>(row.candidates, []) : undefined,
  793. }));
  794. }
  795. /**
  796. * Get all unresolved references
  797. */
  798. getUnresolvedReferences(): UnresolvedReference[] {
  799. const rows = this.db.prepare('SELECT * FROM unresolved_refs').all() as UnresolvedRefRow[];
  800. return rows.map((row) => ({
  801. fromNodeId: row.from_node_id,
  802. referenceName: row.reference_name,
  803. referenceKind: row.reference_kind as EdgeKind,
  804. line: row.line,
  805. column: row.col,
  806. filePath: row.file_path ?? undefined,
  807. language: (row.language as Language) ?? undefined,
  808. candidates: row.candidates ? safeJsonParse<string[]>(row.candidates, []) : undefined,
  809. }));
  810. }
  811. /**
  812. * Delete all unresolved references (after resolution)
  813. */
  814. clearUnresolvedReferences(): void {
  815. this.db.exec('DELETE FROM unresolved_refs');
  816. }
  817. /**
  818. * Delete resolved references by their IDs
  819. */
  820. deleteResolvedReferences(fromNodeIds: string[]): void {
  821. if (fromNodeIds.length === 0) return;
  822. const placeholders = fromNodeIds.map(() => '?').join(',');
  823. this.db.prepare(`DELETE FROM unresolved_refs WHERE from_node_id IN (${placeholders})`).run(...fromNodeIds);
  824. }
  825. // ===========================================================================
  826. // Statistics
  827. // ===========================================================================
  828. /**
  829. * Get graph statistics
  830. */
  831. getStats(): GraphStats {
  832. const nodeCount = (
  833. this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as { count: number }
  834. ).count;
  835. const edgeCount = (
  836. this.db.prepare('SELECT COUNT(*) as count FROM edges').get() as { count: number }
  837. ).count;
  838. const fileCount = (
  839. this.db.prepare('SELECT COUNT(*) as count FROM files').get() as { count: number }
  840. ).count;
  841. const nodesByKind = {} as Record<NodeKind, number>;
  842. const nodeKindRows = this.db
  843. .prepare('SELECT kind, COUNT(*) as count FROM nodes GROUP BY kind')
  844. .all() as Array<{ kind: string; count: number }>;
  845. for (const row of nodeKindRows) {
  846. nodesByKind[row.kind as NodeKind] = row.count;
  847. }
  848. const edgesByKind = {} as Record<EdgeKind, number>;
  849. const edgeKindRows = this.db
  850. .prepare('SELECT kind, COUNT(*) as count FROM edges GROUP BY kind')
  851. .all() as Array<{ kind: string; count: number }>;
  852. for (const row of edgeKindRows) {
  853. edgesByKind[row.kind as EdgeKind] = row.count;
  854. }
  855. const filesByLanguage = {} as Record<Language, number>;
  856. const languageRows = this.db
  857. .prepare('SELECT language, COUNT(*) as count FROM files GROUP BY language')
  858. .all() as Array<{ language: string; count: number }>;
  859. for (const row of languageRows) {
  860. filesByLanguage[row.language as Language] = row.count;
  861. }
  862. return {
  863. nodeCount,
  864. edgeCount,
  865. fileCount,
  866. nodesByKind,
  867. edgesByKind,
  868. filesByLanguage,
  869. dbSizeBytes: 0, // Set by caller using DatabaseConnection.getSize()
  870. lastUpdated: Date.now(),
  871. };
  872. }
  873. /**
  874. * Clear all data from the database
  875. */
  876. clear(): void {
  877. this.nodeCache.clear();
  878. this.db.transaction(() => {
  879. this.db.exec('DELETE FROM unresolved_refs');
  880. this.db.exec('DELETE FROM vectors');
  881. this.db.exec('DELETE FROM edges');
  882. this.db.exec('DELETE FROM nodes');
  883. this.db.exec('DELETE FROM files');
  884. })();
  885. }
  886. }