queries.ts 27 KB

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