From bfe68a07abf59f47dae572c5e72be9bc9c85fc20 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:53:50 -0600 Subject: [PATCH 1/3] perf(queries): batched native Rust query methods for read path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 4 batched NativeDatabase methods that run multiple SQLite queries in a single napi call, eliminating JS↔Rust boundary crossings: - getGraphStats: replaces ~11 separate queries in module-map statsData - getDataflowEdges: replaces 6 directional queries per node in dataflow - getHotspots: replaces 4 eagerly-prepared queries in structure-query - batchFanMetrics: replaces N*2 loop queries in branch-compare Add openReadonlyWithNative() connection helper that opens a NativeDatabase alongside better-sqlite3 for incremental adoption. Wire native fast paths with JS fallback in module-map.ts, dataflow.ts, structure-query.ts, and branch-compare.ts. --- crates/codegraph-core/src/native_db.rs | 2 +- crates/codegraph-core/src/read_queries.rs | 427 +++++++++++++++++++++- crates/codegraph-core/src/read_types.rs | 126 +++++++ src/db/connection.ts | 41 +++ src/db/index.ts | 1 + src/domain/analysis/module-map.ts | 111 +++++- src/features/branch-compare.ts | 55 ++- src/features/dataflow.ts | 88 ++++- src/features/structure-query.ts | 47 ++- src/types.ts | 114 ++++++ 10 files changed, 986 insertions(+), 26 deletions(-) diff --git a/crates/codegraph-core/src/native_db.rs b/crates/codegraph-core/src/native_db.rs index 8f55b00c..ec8ee69e 100644 --- a/crates/codegraph-core/src/native_db.rs +++ b/crates/codegraph-core/src/native_db.rs @@ -807,7 +807,7 @@ impl NativeDatabase { } /// Check if a table exists in the database. -fn has_table(conn: &Connection, table: &str) -> bool { +pub(crate) fn has_table(conn: &Connection, table: &str) -> bool { conn.query_row( "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1", params![table], diff --git a/crates/codegraph-core/src/read_queries.rs b/crates/codegraph-core/src/read_queries.rs index d19bed34..412a9525 100644 --- a/crates/codegraph-core/src/read_queries.rs +++ b/crates/codegraph-core/src/read_queries.rs @@ -8,7 +8,7 @@ use std::collections::{HashSet, VecDeque}; use napi_derive::napi; use rusqlite::params; -use crate::native_db::NativeDatabase; +use crate::native_db::{has_table, NativeDatabase}; use crate::read_types::*; // ── Helpers ───────────────────────────────────────────────────────────── @@ -1227,4 +1227,429 @@ impl NativeDatabase { napi::Error::from_reason(format!("query_function_nodes collect: {e}")) }) } + + // ── Batched query methods ────────────────────────────────────────── + + /// Get all graph statistics in a single napi call. + /// Replaces ~11 separate queries in module-map.ts `statsData()`. + #[napi] + pub fn get_graph_stats(&self, no_tests: bool) -> napi::Result { + let conn = self.conn()?; + let tf = if no_tests { + test_filter_clauses("file") + } else { + String::new() + }; + let tf_n = if no_tests { + test_filter_clauses("n.file") + } else { + String::new() + }; + + // ── Node counts by kind ──────────────────────────────────── + let nodes_by_kind = { + let sql = format!( + "SELECT kind, COUNT(*) as c FROM nodes WHERE 1=1 {} GROUP BY kind", + tf + ); + let mut stmt = conn.prepare_cached(&sql) + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats nodes_by_kind: {e}")))?; + let rows = stmt.query_map([], |row| { + Ok(KindCount { + kind: row.get::<_, String>(0)?, + count: row.get::<_, i32>(1)?, + }) + }).map_err(|e| napi::Error::from_reason(format!("get_graph_stats nodes_by_kind query: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats nodes_by_kind collect: {e}")))? + }; + let total_nodes: i32 = nodes_by_kind.iter().map(|k| k.count).sum(); + + // ── Edge counts by kind ──────────────────────────────────── + let edges_by_kind = { + let sql = if no_tests { + format!( + "SELECT e.kind, COUNT(*) as c FROM edges e \ + JOIN nodes ns ON e.source_id = ns.id \ + JOIN nodes nt ON e.target_id = nt.id \ + WHERE 1=1 {} {} GROUP BY e.kind", + test_filter_clauses("ns.file"), + test_filter_clauses("nt.file"), + ) + } else { + "SELECT kind, COUNT(*) as c FROM edges GROUP BY kind".to_string() + }; + let mut stmt = conn.prepare_cached(&sql) + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats edges_by_kind: {e}")))?; + let rows = stmt.query_map([], |row| { + Ok(KindCount { + kind: row.get::<_, String>(0)?, + count: row.get::<_, i32>(1)?, + }) + }).map_err(|e| napi::Error::from_reason(format!("get_graph_stats edges_by_kind query: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats edges_by_kind collect: {e}")))? + }; + let total_edges: i32 = edges_by_kind.iter().map(|k| k.count).sum(); + + // ── File count ───────────────────────────────────────────── + let total_files: i32 = { + let sql = format!( + "SELECT COUNT(*) FROM nodes WHERE kind = 'file' {}", + tf + ); + conn.prepare_cached(&sql) + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats total_files: {e}")))? + .query_row([], |row| row.get(0)) + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats total_files query: {e}")))? + }; + + // ── Role counts ──────────────────────────────────────────── + let role_counts = { + let sql = format!( + "SELECT role, COUNT(*) as c FROM nodes WHERE role IS NOT NULL {} GROUP BY role", + tf + ); + let mut stmt = conn.prepare_cached(&sql) + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats role_counts: {e}")))?; + let rows = stmt.query_map([], |row| { + Ok(RoleCount { + role: row.get::<_, String>(0)?, + count: row.get::<_, i32>(1)?, + }) + }).map_err(|e| napi::Error::from_reason(format!("get_graph_stats role_counts query: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats role_counts collect: {e}")))? + }; + + // ── Quality metrics ──────────────────────────────────────── + let callable_total: i32 = { + let sql = format!( + "SELECT COUNT(*) FROM nodes WHERE kind IN ('function', 'method') {}", + tf + ); + conn.prepare_cached(&sql) + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats callable_total: {e}")))? + .query_row([], |row| row.get(0)) + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats callable_total query: {e}")))? + }; + let callable_with_callers: i32 = { + let sql = format!( + "SELECT COUNT(DISTINCT e.target_id) FROM edges e \ + JOIN nodes n ON e.target_id = n.id \ + WHERE e.kind = 'calls' AND n.kind IN ('function', 'method') {}", + tf_n + ); + conn.prepare_cached(&sql) + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats callable_with_callers: {e}")))? + .query_row([], |row| row.get(0)) + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats callable_with_callers query: {e}")))? + }; + let call_edges: i32 = conn + .prepare_cached("SELECT COUNT(*) FROM edges WHERE kind = 'calls'") + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats call_edges: {e}")))? + .query_row([], |row| row.get(0)) + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats call_edges query: {e}")))?; + let high_conf_call_edges: i32 = conn + .prepare_cached("SELECT COUNT(*) FROM edges WHERE kind = 'calls' AND confidence >= 0.7") + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats high_conf: {e}")))? + .query_row([], |row| row.get(0)) + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats high_conf query: {e}")))?; + + // ── Hotspots (top 5 files by coupling) ───────────────────── + let hotspots = { + let sql = format!( + "SELECT n.file, \ + (SELECT COUNT(*) FROM edges WHERE target_id = n.id) as fan_in, \ + (SELECT COUNT(*) FROM edges WHERE source_id = n.id) as fan_out \ + FROM nodes n WHERE n.kind = 'file' {} \ + ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id) \ + + (SELECT COUNT(*) FROM edges WHERE source_id = n.id) DESC \ + LIMIT 5", + tf_n + ); + let mut stmt = conn.prepare_cached(&sql) + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats hotspots: {e}")))?; + let rows = stmt.query_map([], |row| { + Ok(FileHotspot { + file: row.get(0)?, + fan_in: row.get(1)?, + fan_out: row.get(2)?, + }) + }).map_err(|e| napi::Error::from_reason(format!("get_graph_stats hotspots query: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats hotspots collect: {e}")))? + }; + + // ── Complexity summary ───────────────────────────────────── + let complexity = if has_table(conn, "function_complexity") { + let sql = format!( + "SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.maintainability_index \ + FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id \ + WHERE n.kind IN ('function','method') {}", + tf_n + ); + let mut stmt = conn.prepare_cached(&sql) + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats complexity: {e}")))?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, i32>(0)?, + row.get::<_, i32>(1)?, + row.get::<_, i32>(2)?, + row.get::<_, f64>(3).unwrap_or(0.0), + )) + }).map_err(|e| napi::Error::from_reason(format!("get_graph_stats complexity query: {e}")))?; + let data: Vec<(i32, i32, i32, f64)> = rows + .collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats complexity collect: {e}")))?; + if data.is_empty() { + None + } else { + let n = data.len() as f64; + let sum_cog: i32 = data.iter().map(|d| d.0).sum(); + let sum_cyc: i32 = data.iter().map(|d| d.1).sum(); + let max_cog = data.iter().map(|d| d.0).max().unwrap_or(0); + let max_cyc = data.iter().map(|d| d.1).max().unwrap_or(0); + let sum_mi: f64 = data.iter().map(|d| d.3).sum(); + let min_mi = data.iter().map(|d| d.3).fold(f64::INFINITY, f64::min); + Some(ComplexitySummary { + analyzed: data.len() as i32, + avg_cognitive: (sum_cog as f64 / n * 10.0).round() / 10.0, + avg_cyclomatic: (sum_cyc as f64 / n * 10.0).round() / 10.0, + max_cognitive: max_cog, + max_cyclomatic: max_cyc, + avg_mi: (sum_mi / n * 10.0).round() / 10.0, + min_mi: (min_mi * 10.0).round() / 10.0, + }) + } + } else { + None + }; + + // ── Embeddings info ──────────────────────────────────────── + let embeddings = if has_table(conn, "embeddings") { + let count: i32 = conn + .prepare_cached("SELECT COUNT(*) FROM embeddings") + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats embeddings: {e}")))? + .query_row([], |row| row.get(0)) + .unwrap_or(0); + if count > 0 && has_table(conn, "embedding_meta") { + let mut model: Option = None; + let mut dim: Option = None; + let mut built_at: Option = None; + let mut stmt = conn + .prepare_cached("SELECT key, value FROM embedding_meta") + .map_err(|e| napi::Error::from_reason(format!("get_graph_stats embedding_meta: {e}")))?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }).map_err(|e| napi::Error::from_reason(format!("get_graph_stats embedding_meta query: {e}")))?; + for row in rows { + if let Ok((k, v)) = row { + match k.as_str() { + "model" => model = Some(v), + "dim" => dim = v.parse().ok(), + "built_at" => built_at = Some(v), + _ => {} + } + } + } + Some(EmbeddingInfo { count, model, dim, built_at }) + } else if count > 0 { + Some(EmbeddingInfo { count, model: None, dim: None, built_at: None }) + } else { + None + } + } else { + None + }; + + Ok(GraphStats { + total_nodes, + total_edges, + total_files, + nodes_by_kind, + edges_by_kind, + role_counts, + quality: QualityMetrics { + callable_total, + callable_with_callers, + call_edges, + high_conf_call_edges, + }, + hotspots, + complexity, + embeddings, + }) + } + + /// Get all 6 directional dataflow edge sets for a node in a single napi call. + /// Replaces 6 separate db.prepare() calls in dataflow.ts `dataflowData()`. + #[napi] + pub fn get_dataflow_edges(&self, node_id: i32) -> napi::Result { + let conn = self.conn()?; + + if !has_table(conn, "dataflow") { + return Ok(DataflowEdgesResult { + flows_to_out: vec![], + flows_to_in: vec![], + returns_out: vec![], + returns_in: vec![], + mutates_out: vec![], + mutates_in: vec![], + }); + } + + fn query_outgoing( + conn: &Connection, + node_id: i32, + kind: &str, + ) -> napi::Result> { + let sql = format!( + "SELECT n.name, n.kind, n.file, d.line, d.param_index, d.expression, d.confidence \ + FROM dataflow d JOIN nodes n ON d.target_id = n.id \ + WHERE d.source_id = ?1 AND d.kind = '{}'", + kind + ); + let mut stmt = conn.prepare_cached(&sql) + .map_err(|e| napi::Error::from_reason(format!("get_dataflow_edges out {kind}: {e}")))?; + let rows = stmt.query_map(params![node_id], |row| { + Ok(DataflowQueryEdge { + name: row.get(0)?, + kind: row.get(1)?, + file: row.get(2)?, + line: row.get(3)?, + param_index: row.get(4)?, + expression: row.get(5)?, + confidence: row.get(6)?, + }) + }).map_err(|e| napi::Error::from_reason(format!("get_dataflow_edges out {kind} query: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("get_dataflow_edges out {kind} collect: {e}"))) + } + + fn query_incoming( + conn: &Connection, + node_id: i32, + kind: &str, + ) -> napi::Result> { + let sql = format!( + "SELECT n.name, n.kind, n.file, d.line, d.param_index, d.expression, d.confidence \ + FROM dataflow d JOIN nodes n ON d.source_id = n.id \ + WHERE d.target_id = ?1 AND d.kind = '{}'", + kind + ); + let mut stmt = conn.prepare_cached(&sql) + .map_err(|e| napi::Error::from_reason(format!("get_dataflow_edges in {kind}: {e}")))?; + let rows = stmt.query_map(params![node_id], |row| { + Ok(DataflowQueryEdge { + name: row.get(0)?, + kind: row.get(1)?, + file: row.get(2)?, + line: row.get(3)?, + param_index: row.get(4)?, + expression: row.get(5)?, + confidence: row.get(6)?, + }) + }).map_err(|e| napi::Error::from_reason(format!("get_dataflow_edges in {kind} query: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("get_dataflow_edges in {kind} collect: {e}"))) + } + + Ok(DataflowEdgesResult { + flows_to_out: query_outgoing(conn, node_id, "flows_to")?, + flows_to_in: query_incoming(conn, node_id, "flows_to")?, + returns_out: query_outgoing(conn, node_id, "returns")?, + returns_in: query_incoming(conn, node_id, "returns")?, + mutates_out: query_outgoing(conn, node_id, "mutates")?, + mutates_in: query_incoming(conn, node_id, "mutates")?, + }) + } + + /// Get hotspot rows for a given metric, kind, and limit in a single napi call. + /// Replaces 4 eagerly-prepared queries in structure-query.ts `hotspotsData()`. + #[napi] + pub fn get_hotspots( + &self, + kind: String, + metric: String, + no_tests: bool, + limit: i32, + ) -> napi::Result> { + let conn = self.conn()?; + + if !has_table(conn, "node_metrics") { + return Ok(vec![]); + } + + let test_filter = if no_tests && kind == "file" { + test_filter_clauses("n.name") + } else { + String::new() + }; + + let order_by = match metric.as_str() { + "fan-out" => "nm.fan_out DESC NULLS LAST", + "density" => "nm.symbol_count DESC NULLS LAST", + "coupling" => "(COALESCE(nm.fan_in, 0) + COALESCE(nm.fan_out, 0)) DESC NULLS LAST", + _ => "nm.fan_in DESC NULLS LAST", // default: fan-in + }; + + let sql = format!( + "SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, \ + nm.export_count, nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count \ + FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id \ + WHERE n.kind = ?1 {} ORDER BY {} LIMIT ?2", + test_filter, order_by + ); + + let mut stmt = conn.prepare_cached(&sql) + .map_err(|e| napi::Error::from_reason(format!("get_hotspots: {e}")))?; + let rows = stmt.query_map(params![kind, limit], |row| { + Ok(NativeHotspotRow { + name: row.get(0)?, + kind: row.get(1)?, + line_count: row.get(2)?, + symbol_count: row.get(3)?, + import_count: row.get(4)?, + export_count: row.get(5)?, + fan_in: row.get(6)?, + fan_out: row.get(7)?, + cohesion: row.get(8)?, + file_count: row.get(9)?, + }) + }).map_err(|e| napi::Error::from_reason(format!("get_hotspots query: {e}")))?; + rows.collect::, _>>() + .map_err(|e| napi::Error::from_reason(format!("get_hotspots collect: {e}"))) + } + + /// Batch fan-in/fan-out metrics for multiple node IDs in a single napi call. + /// Replaces N*2 queries in branch-compare.ts `loadSymbolsFromDb()`. + #[napi] + pub fn batch_fan_metrics(&self, node_ids: Vec) -> napi::Result> { + let conn = self.conn()?; + + let mut fan_in_stmt = conn + .prepare_cached("SELECT COUNT(*) FROM edges WHERE target_id = ?1 AND kind = 'calls'") + .map_err(|e| napi::Error::from_reason(format!("batch_fan_metrics fan_in prepare: {e}")))?; + let mut fan_out_stmt = conn + .prepare_cached("SELECT COUNT(*) FROM edges WHERE source_id = ?1 AND kind = 'calls'") + .map_err(|e| napi::Error::from_reason(format!("batch_fan_metrics fan_out prepare: {e}")))?; + + let mut results = Vec::with_capacity(node_ids.len()); + for &nid in &node_ids { + let fan_in: i32 = fan_in_stmt + .query_row(params![nid], |row| row.get(0)) + .unwrap_or(0); + let fan_out: i32 = fan_out_stmt + .query_row(params![nid], |row| row.get(0)) + .unwrap_or(0); + results.push(FanMetric { + node_id: nid, + fan_in, + fan_out, + }); + } + + Ok(results) + } } diff --git a/crates/codegraph-core/src/read_types.rs b/crates/codegraph-core/src/read_types.rs index cf8028f0..24819efc 100644 --- a/crates/codegraph-core/src/read_types.rs +++ b/crates/codegraph-core/src/read_types.rs @@ -175,3 +175,129 @@ pub struct NativeComplexityMetrics { pub maintainability_index: Option, pub halstead_volume: Option, } + +// ── Batched query return types ───────────────────────────────────────── + +/// Kind + count pair for GROUP BY queries. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct KindCount { + pub kind: String, + pub count: i32, +} + +/// Role + count pair for role distribution queries. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct RoleCount { + pub role: String, + pub count: i32, +} + +/// File hotspot entry with fan-in/fan-out. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct FileHotspot { + pub file: String, + pub fan_in: i32, + pub fan_out: i32, +} + +/// Complexity summary statistics. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct ComplexitySummary { + pub analyzed: i32, + pub avg_cognitive: f64, + pub avg_cyclomatic: f64, + pub max_cognitive: i32, + pub max_cyclomatic: i32, + pub avg_mi: f64, + pub min_mi: f64, +} + +/// Embedding metadata. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct EmbeddingInfo { + pub count: i32, + pub model: Option, + pub dim: Option, + pub built_at: Option, +} + +/// Quality metrics for graph stats. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct QualityMetrics { + pub callable_total: i32, + pub callable_with_callers: i32, + pub call_edges: i32, + pub high_conf_call_edges: i32, +} + +/// Combined graph statistics — replaces ~11 separate queries in module-map.ts. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct GraphStats { + pub total_nodes: i32, + pub total_edges: i32, + pub total_files: i32, + pub nodes_by_kind: Vec, + pub edges_by_kind: Vec, + pub role_counts: Vec, + pub quality: QualityMetrics, + pub hotspots: Vec, + pub complexity: Option, + pub embeddings: Option, +} + +/// Dataflow edge with joined node info. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct DataflowQueryEdge { + pub name: String, + pub kind: String, + pub file: String, + pub line: Option, + pub param_index: Option, + pub expression: Option, + pub confidence: Option, +} + +/// All 6 directional dataflow edge sets for a node. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct DataflowEdgesResult { + pub flows_to_out: Vec, + pub flows_to_in: Vec, + pub returns_out: Vec, + pub returns_in: Vec, + pub mutates_out: Vec, + pub mutates_in: Vec, +} + +/// Hotspot row from node_metrics join. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct NativeHotspotRow { + pub name: String, + pub kind: String, + pub line_count: Option, + pub symbol_count: Option, + pub import_count: Option, + pub export_count: Option, + pub fan_in: Option, + pub fan_out: Option, + pub cohesion: Option, + pub file_count: Option, +} + +/// Fan-in/fan-out metrics for a single node. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct FanMetric { + pub node_id: i32, + pub fan_in: i32, + pub fan_out: i32, +} diff --git a/src/db/connection.ts b/src/db/connection.ts index 058e51af..1513717d 100644 --- a/src/db/connection.ts +++ b/src/db/connection.ts @@ -399,3 +399,44 @@ export function openRepo( }, }; } + +/** + * Open a readonly DB with an optional NativeDatabase alongside it. + * + * Returns the better-sqlite3 handle (for backwards compat) plus an optional + * NativeDatabase for modules that can use batched Rust query methods. + * Callers should use nativeDb when available and fall back to db.prepare(). + */ +export function openReadonlyWithNative(customPath?: string): { + db: BetterSqlite3Database; + nativeDb: NativeDatabase | undefined; + close(): void; +} { + const db = openReadonlyOrFail(customPath); + + let nativeDb: NativeDatabase | undefined; + if (isNativeAvailable()) { + try { + const dbPath = findDbPath(customPath); + const native = getNative(); + nativeDb = native.NativeDatabase.openReadonly(dbPath); + } catch (e) { + debug(`openReadonlyWithNative: native path failed: ${(e as Error).message}`); + } + } + + return { + db, + nativeDb, + close() { + db.close(); + if (nativeDb) { + try { + nativeDb.close(); + } catch { + // already closed or not closeable + } + } + }, + }; +} diff --git a/src/db/index.ts b/src/db/index.ts index 7129ca4a..c65f89e5 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -11,6 +11,7 @@ export { flushDeferredClose, openDb, openReadonlyOrFail, + openReadonlyWithNative, openRepo, } from './connection.js'; export { getBuildMeta, initSchema, MIGRATIONS, setBuildMeta } from './migrations.js'; diff --git a/src/domain/analysis/module-map.ts b/src/domain/analysis/module-map.ts index 2a8c9c19..09eeaf32 100644 --- a/src/domain/analysis/module-map.ts +++ b/src/domain/analysis/module-map.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { openReadonlyOrFail, testFilterSQL } from '../../db/index.js'; +import { openReadonlyOrFail, openReadonlyWithNative, testFilterSQL } from '../../db/index.js'; import { cachedStmt } from '../../db/repository/cached-stmt.js'; import { loadConfig } from '../../infrastructure/config.js'; import { debug } from '../../infrastructure/logger.js'; @@ -381,20 +381,115 @@ export function moduleMapData(customDbPath: string, limit = 20, opts: { noTests? } export function statsData(customDbPath: string, opts: { noTests?: boolean; config?: any } = {}) { - const db = openReadonlyOrFail(customDbPath); + const { db, nativeDb, close } = openReadonlyWithNative(customDbPath); try { const noTests = opts.noTests || false; const config = opts.config || loadConfig(); - const testFilter = testFilterSQL('n.file', noTests); + // These always need JS (non-SQL logic) + const files = countFilesByLanguage(db, noTests); + const fileCycles = findCycles(db, { fileLevel: true, noTests }); + const fnCycles = findCycles(db, { fileLevel: false, noTests }); + + // ── Native fast path: batch all SQL aggregations in one napi call ── + if (nativeDb?.getGraphStats) { + const s = nativeDb.getGraphStats(noTests); + const nodesByKind: Record = {}; + for (const k of s.nodesByKind) nodesByKind[k.kind] = k.count; + const edgesByKind: Record = {}; + for (const k of s.edgesByKind) edgesByKind[k.kind] = k.count; + const roles: Record & { dead?: number } = {}; + let deadTotal = 0; + for (const r of s.roleCounts) { + roles[r.role] = r.count; + if (r.role.startsWith(DEAD_ROLE_PREFIX)) deadTotal += r.count; + } + if (deadTotal > 0) roles.dead = deadTotal; + + const callerCoverage = + s.quality.callableTotal > 0 ? s.quality.callableWithCallers / s.quality.callableTotal : 0; + const callConfidence = + s.quality.callEdges > 0 ? s.quality.highConfCallEdges / s.quality.callEdges : 0; + + // False-positive analysis still uses JS (needs FALSE_POSITIVE_NAMES set) + const fpThreshold = config.analysis?.falsePositiveCallers ?? FALSE_POSITIVE_CALLER_THRESHOLD; + const fpRows = db + .prepare(` + SELECT n.name, n.file, n.line, COUNT(e.source_id) as caller_count + FROM nodes n + LEFT JOIN edges e ON n.id = e.target_id AND e.kind = 'calls' + WHERE n.kind IN ('function', 'method') + GROUP BY n.id + HAVING caller_count > ? + ORDER BY caller_count DESC + `) + .all(fpThreshold) as Array<{ + name: string; + file: string; + line: number; + caller_count: number; + }>; + const falsePositiveWarnings = fpRows + .filter((r) => + FALSE_POSITIVE_NAMES.has(r.name.includes('.') ? r.name.split('.').pop()! : r.name), + ) + .map((r) => ({ name: r.name, file: r.file, line: r.line, callerCount: r.caller_count })); + let fpEdgeCount = 0; + for (const fp of falsePositiveWarnings) fpEdgeCount += fp.callerCount; + const falsePositiveRatio = s.quality.callEdges > 0 ? fpEdgeCount / s.quality.callEdges : 0; + const score = Math.round( + callerCoverage * 40 + callConfidence * 40 + (1 - falsePositiveRatio) * 20, + ); + + return { + nodes: { total: s.totalNodes, byKind: nodesByKind }, + edges: { total: s.totalEdges, byKind: edgesByKind }, + files, + cycles: { fileLevel: fileCycles.length, functionLevel: fnCycles.length }, + hotspots: s.hotspots.map((h) => ({ file: h.file, fanIn: h.fanIn, fanOut: h.fanOut })), + embeddings: s.embeddings + ? { + count: s.embeddings.count, + model: s.embeddings.model, + dim: s.embeddings.dim, + builtAt: s.embeddings.builtAt, + } + : null, + quality: { + score, + callerCoverage: { + ratio: callerCoverage, + covered: s.quality.callableWithCallers, + total: s.quality.callableTotal, + }, + callConfidence: { + ratio: callConfidence, + highConf: s.quality.highConfCallEdges, + total: s.quality.callEdges, + }, + falsePositiveWarnings, + }, + roles, + complexity: s.complexity + ? { + analyzed: s.complexity.analyzed, + avgCognitive: s.complexity.avgCognitive, + avgCyclomatic: s.complexity.avgCyclomatic, + maxCognitive: s.complexity.maxCognitive, + maxCyclomatic: s.complexity.maxCyclomatic, + avgMI: s.complexity.avgMi, + minMI: s.complexity.minMi, + } + : null, + }; + } + + // ── JS fallback ─────────────────────────────────────────────────── + const testFilter = testFilterSQL('n.file', noTests); const testFileIds = noTests ? buildTestFileIds(db) : null; const { total: totalNodes, byKind: nodesByKind } = countNodesByKind(db, testFileIds); const { total: totalEdges, byKind: edgesByKind } = countEdgesByKind(db, testFileIds); - const files = countFilesByLanguage(db, noTests); - - const fileCycles = findCycles(db, { fileLevel: true, noTests }); - const fnCycles = findCycles(db, { fileLevel: false, noTests }); const hotspots = findHotspots(db, noTests, 5); const embeddings = getEmbeddingsInfo(db); @@ -415,6 +510,6 @@ export function statsData(customDbPath: string, opts: { noTests?: boolean; confi complexity, }; } finally { - db.close(); + close(); } } diff --git a/src/features/branch-compare.ts b/src/features/branch-compare.ts index f9f849a2..79759c53 100644 --- a/src/features/branch-compare.ts +++ b/src/features/branch-compare.ts @@ -5,8 +5,10 @@ import path from 'node:path'; import { getDatabase } from '../db/better-sqlite3.js'; import { buildGraph } from '../domain/graph/builder.js'; import { kindIcon } from '../domain/queries.js'; +import { debug } from '../infrastructure/logger.js'; +import { getNative, isNativeAvailable } from '../infrastructure/native.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import type { EngineMode } from '../types.js'; +import type { EngineMode, NativeDatabase } from '../types.js'; // ─── Git Helpers ──────────────────────────────────────────────────────── @@ -107,6 +109,18 @@ function loadSymbolsFromDb( ): Map { const Database = getDatabase(); const db = new Database(dbPath, { readonly: true }); + + // Try opening a NativeDatabase for batched fan metrics + let nativeDb: NativeDatabase | undefined; + if (isNativeAvailable()) { + try { + const native = getNative(); + nativeDb = native.NativeDatabase.openReadonly(dbPath); + } catch (e) { + debug(`loadSymbolsFromDb: native path failed: ${(e as Error).message}`); + } + } + try { const symbols = new Map(); @@ -132,6 +146,34 @@ function loadSymbolsFromDb( end_line: number | null; }>; + // Filter first, then batch fan metrics for all surviving rows + const filtered = noTests ? rows.filter((r) => !isTestFile(r.file)) : rows; + + // ── Native fast path: batch all fan-in/fan-out in one napi call ── + if (nativeDb?.batchFanMetrics && filtered.length > 0) { + const nodeIds = filtered.map((r) => r.id); + const metrics = nativeDb.batchFanMetrics(nodeIds); + const metricsMap = new Map(metrics.map((m) => [m.nodeId, m])); + + for (const row of filtered) { + const lineCount = row.end_line ? row.end_line - row.line + 1 : 0; + const m = metricsMap.get(row.id); + const key = makeSymbolKey(row.kind, row.file, row.name); + symbols.set(key, { + id: row.id, + name: row.name, + kind: row.kind, + file: row.file, + line: row.line, + lineCount, + fanIn: m?.fanIn ?? 0, + fanOut: m?.fanOut ?? 0, + }); + } + return symbols; + } + + // ── JS fallback ─────────────────────────────────────────────────── const fanInStmt = db.prepare( `SELECT COUNT(*) AS cnt FROM edges WHERE target_id = ? AND kind = 'calls'`, ); @@ -139,9 +181,7 @@ function loadSymbolsFromDb( `SELECT COUNT(*) AS cnt FROM edges WHERE source_id = ? AND kind = 'calls'`, ); - for (const row of rows) { - if (noTests && isTestFile(row.file)) continue; - + for (const row of filtered) { const lineCount = row.end_line ? row.end_line - row.line + 1 : 0; const fanIn = (fanInStmt.get(row.id) as { cnt: number }).cnt; const fanOut = (fanOutStmt.get(row.id) as { cnt: number }).cnt; @@ -162,6 +202,13 @@ function loadSymbolsFromDb( return symbols; } finally { db.close(); + if (nativeDb) { + try { + nativeDb.close(); + } catch { + /* already closed */ + } + } } } diff --git a/src/features/dataflow.ts b/src/features/dataflow.ts index 8315b524..4d1b1c56 100644 --- a/src/features/dataflow.ts +++ b/src/features/dataflow.ts @@ -19,7 +19,7 @@ import { } from '../ast-analysis/shared.js'; import { walkWithVisitors } from '../ast-analysis/visitor.js'; import { createDataflowVisitor } from '../ast-analysis/visitors/dataflow-visitor.js'; -import { hasDataflowTable, openReadonlyOrFail } from '../db/index.js'; +import { hasDataflowTable, openReadonlyOrFail, openReadonlyWithNative } from '../db/index.js'; import { ALL_SYMBOL_KINDS, normalizeSymbol } from '../domain/queries.js'; import { debug, info } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; @@ -308,7 +308,7 @@ export function dataflowData( customDbPath?: string, opts: { noTests?: boolean; file?: string; kind?: string; limit?: number; offset?: number } = {}, ): Record { - const db = openReadonlyOrFail(customDbPath); + const { db, nativeDb, close } = openReadonlyWithNative(customDbPath); try { const noTests = opts.noTests || false; @@ -331,6 +331,83 @@ export function dataflowData( return { name, results: [] }; } + // ── Native fast path: 6 queries per node → 1 napi call per node ── + if (nativeDb?.getDataflowEdges) { + const hc = new Map(); + const results = nodes.map((node: NodeRow) => { + const sym = normalizeSymbol(node, db, hc); + const d = nativeDb.getDataflowEdges!(node.id); + + const flowsTo = d.flowsToOut.map((r) => ({ + target: r.name, + kind: r.kind, + file: r.file, + line: r.line, + paramIndex: r.paramIndex, + expression: r.expression, + confidence: r.confidence, + })); + const flowsFrom = d.flowsToIn.map((r) => ({ + source: r.name, + kind: r.kind, + file: r.file, + line: r.line, + paramIndex: r.paramIndex, + expression: r.expression, + confidence: r.confidence, + })); + const returnConsumers = d.returnsOut.map((r) => ({ + consumer: r.name, + kind: r.kind, + file: r.file, + line: r.line, + expression: r.expression, + })); + const returnedBy = d.returnsIn.map((r) => ({ + producer: r.name, + kind: r.kind, + file: r.file, + line: r.line, + expression: r.expression, + })); + const mutatesTargets = d.mutatesOut.map((r) => ({ + target: r.name, + expression: r.expression, + line: r.line, + })); + const mutatedBy = d.mutatesIn.map((r) => ({ + source: r.name, + expression: r.expression, + line: r.line, + })); + + if (noTests) { + const filter = (arr: any[]) => arr.filter((r: any) => !isTestFile(r.file)); + return { + ...sym, + flowsTo: filter(flowsTo), + flowsFrom: filter(flowsFrom), + returns: returnConsumers.filter((r) => !isTestFile(r.file)), + returnedBy: returnedBy.filter((r) => !isTestFile(r.file)), + mutates: mutatesTargets, + mutatedBy, + }; + } + return { + ...sym, + flowsTo, + flowsFrom, + returns: returnConsumers, + returnedBy, + mutates: mutatesTargets, + mutatedBy, + }; + }); + const base = { name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } + + // ── JS fallback ─────────────────────────────────────────────────── const flowsToOut = db.prepare( `SELECT d.*, n.name AS target_name, n.kind AS target_kind, n.file AS target_file, n.line AS target_line FROM dataflow d JOIN nodes n ON d.target_id = n.id @@ -375,7 +452,6 @@ export function dataflowData( expression: r.expression, confidence: r.confidence, })); - const flowsFrom = flowsToIn.all(node.id).map((r: any) => ({ source: r.source_name, kind: r.source_kind, @@ -385,7 +461,6 @@ export function dataflowData( expression: r.expression, confidence: r.confidence, })); - const returnConsumers = returnsOut.all(node.id).map((r: any) => ({ consumer: r.target_name, kind: r.target_kind, @@ -393,7 +468,6 @@ export function dataflowData( line: r.line, expression: r.expression, })); - const returnedBy = returnsIn.all(node.id).map((r: any) => ({ producer: r.source_name, kind: r.source_kind, @@ -401,13 +475,11 @@ export function dataflowData( line: r.line, expression: r.expression, })); - const mutatesTargets = mutatesOut.all(node.id).map((r: any) => ({ target: r.target_name, expression: r.expression, line: r.line, })); - const mutatedBy = mutatesIn.all(node.id).map((r: any) => ({ source: r.source_name, expression: r.expression, @@ -441,7 +513,7 @@ export function dataflowData( const base = { name, results }; return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); } finally { - db.close(); + close(); } } diff --git a/src/features/structure-query.ts b/src/features/structure-query.ts index dfec638c..d93a8236 100644 --- a/src/features/structure-query.ts +++ b/src/features/structure-query.ts @@ -7,7 +7,7 @@ * role classification). */ -import { openReadonlyOrFail, testFilterSQL } from '../db/index.js'; +import { openReadonlyOrFail, openReadonlyWithNative, testFilterSQL } from '../db/index.js'; import { loadConfig } from '../infrastructure/config.js'; import { isTestFile } from '../infrastructure/test-filter.js'; import { normalizePath } from '../shared/constants.js'; @@ -221,7 +221,7 @@ export function hotspotsData( limit: number; hotspots: unknown[]; } { - const db = openReadonlyOrFail(customDbPath); + const { db, nativeDb, close } = openReadonlyWithNative(customDbPath); try { const metric = opts.metric || 'fan-in'; const level = opts.level || 'file'; @@ -230,6 +230,46 @@ export function hotspotsData( const kind = level === 'directory' ? 'directory' : 'file'; + const mapRow = (r: { + name: string; + kind: string; + lineCount: number | null; + symbolCount: number | null; + importCount: number | null; + exportCount: number | null; + fanIn: number | null; + fanOut: number | null; + cohesion: number | null; + fileCount: number | null; + }) => ({ + name: r.name, + kind: r.kind, + lineCount: r.lineCount, + symbolCount: r.symbolCount, + importCount: r.importCount, + exportCount: r.exportCount, + fanIn: r.fanIn, + fanOut: r.fanOut, + cohesion: r.cohesion, + fileCount: r.fileCount, + density: + (r.fileCount ?? 0) > 0 + ? (r.symbolCount || 0) / (r.fileCount ?? 1) + : (r.lineCount ?? 0) > 0 + ? (r.symbolCount || 0) / (r.lineCount ?? 1) + : 0, + coupling: (r.fanIn || 0) + (r.fanOut || 0), + }); + + // ── Native fast path: single query instead of 4 eagerly prepared ── + if (nativeDb?.getHotspots) { + const rows = nativeDb.getHotspots(kind, metric, noTests, limit); + const hotspots = rows.map(mapRow); + const base = { metric, level, limit, hotspots }; + return paginateResult(base, 'hotspots', { limit: opts.limit, offset: opts.offset }); + } + + // ── JS fallback ─────────────────────────────────────────────────── const testFilter = testFilterSQL('n.name', noTests && kind === 'file'); const HOTSPOT_QUERIES: Record = { @@ -256,7 +296,6 @@ export function hotspotsData( }; const stmt = HOTSPOT_QUERIES[metric] ?? HOTSPOT_QUERIES['fan-in']; - // stmt is always defined: metric is a valid key or the fallback is a concrete property const rows = stmt!.all(kind, limit); const hotspots = rows.map((r) => ({ @@ -282,7 +321,7 @@ export function hotspotsData( const base = { metric, level, limit, hotspots }; return paginateResult(base, 'hotspots', { limit: opts.limit, offset: opts.offset }); } finally { - db.close(); + close(); } } diff --git a/src/types.ts b/src/types.ts index 00870ef9..63cf912c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2078,6 +2078,120 @@ export interface NativeDatabase { } | null; purgeFilesData(files: string[], purgeHashes?: boolean, reverseDepFiles?: string[]): void; + // ── Batched query methods ──────────────────────────────────────────── + /** All graph statistics in a single napi call (replaces ~11 queries in module-map). */ + getGraphStats?(noTests: boolean): { + totalNodes: number; + totalEdges: number; + totalFiles: number; + nodesByKind: Array<{ kind: string; count: number }>; + edgesByKind: Array<{ kind: string; count: number }>; + roleCounts: Array<{ role: string; count: number }>; + quality: { + callableTotal: number; + callableWithCallers: number; + callEdges: number; + highConfCallEdges: number; + }; + hotspots: Array<{ file: string; fanIn: number; fanOut: number }>; + complexity: { + analyzed: number; + avgCognitive: number; + avgCyclomatic: number; + maxCognitive: number; + maxCyclomatic: number; + avgMi: number; + minMi: number; + } | null; + embeddings: { + count: number; + model: string | null; + dim: number | null; + builtAt: string | null; + } | null; + }; + /** All 6 directional dataflow edge sets for a node in one call. */ + getDataflowEdges?(nodeId: number): { + flowsToOut: Array<{ + name: string; + kind: string; + file: string; + line: number | null; + paramIndex: number | null; + expression: string | null; + confidence: number | null; + }>; + flowsToIn: Array<{ + name: string; + kind: string; + file: string; + line: number | null; + paramIndex: number | null; + expression: string | null; + confidence: number | null; + }>; + returnsOut: Array<{ + name: string; + kind: string; + file: string; + line: number | null; + paramIndex: number | null; + expression: string | null; + confidence: number | null; + }>; + returnsIn: Array<{ + name: string; + kind: string; + file: string; + line: number | null; + paramIndex: number | null; + expression: string | null; + confidence: number | null; + }>; + mutatesOut: Array<{ + name: string; + kind: string; + file: string; + line: number | null; + paramIndex: number | null; + expression: string | null; + confidence: number | null; + }>; + mutatesIn: Array<{ + name: string; + kind: string; + file: string; + line: number | null; + paramIndex: number | null; + expression: string | null; + confidence: number | null; + }>; + }; + /** Hotspot rows for a metric/kind/limit in one call. */ + getHotspots?( + kind: string, + metric: string, + noTests: boolean, + limit: number, + ): Array<{ + name: string; + kind: string; + lineCount: number | null; + symbolCount: number | null; + importCount: number | null; + exportCount: number | null; + fanIn: number | null; + fanOut: number | null; + cohesion: number | null; + fileCount: number | null; + }>; + /** Batch fan-in/fan-out for multiple node IDs in one call. */ + batchFanMetrics?(nodeIds: number[]): Array<{ + nodeId: number; + fanIn: number; + fanOut: number; + }>; + // ── Generic query execution & version validation (6.16) ───────────── /** Execute a parameterized SELECT and return all rows as objects. */ queryAll(sql: string, params: Array): Record[]; From 8bbe891a1e8711d7041548afbaba840a1360de1d Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 30 Mar 2026 03:11:52 -0600 Subject: [PATCH 2/3] fix(native): move batched query methods into #[napi] impl block and address review feedback (#698) - Move get_graph_stats, get_dataflow_edges, get_hotspots, batch_fan_metrics into a #[napi]-annotated impl block so napi-rs can generate bindings for methods that take &self - Remove dead total_files computation from get_graph_stats (unused by TS caller) - Use SQL bind parameters for kind in query_outgoing/query_incoming instead of string interpolation - Replace unwrap_or(0) with map_err in batch_fan_metrics to propagate errors instead of silently returning zeros --- crates/codegraph-core/src/read_queries.rs | 45 ++++++++--------------- crates/codegraph-core/src/read_types.rs | 1 - src/types.ts | 1 - 3 files changed, 15 insertions(+), 32 deletions(-) diff --git a/crates/codegraph-core/src/read_queries.rs b/crates/codegraph-core/src/read_queries.rs index 412a9525..44595fbc 100644 --- a/crates/codegraph-core/src/read_queries.rs +++ b/crates/codegraph-core/src/read_queries.rs @@ -1228,8 +1228,12 @@ impl NativeDatabase { }) } - // ── Batched query methods ────────────────────────────────────────── +} + +// ── Batched query methods ────────────────────────────────────────────── +#[napi] +impl NativeDatabase { /// Get all graph statistics in a single napi call. /// Replaces ~11 separate queries in module-map.ts `statsData()`. #[napi] @@ -1292,18 +1296,6 @@ impl NativeDatabase { }; let total_edges: i32 = edges_by_kind.iter().map(|k| k.count).sum(); - // ── File count ───────────────────────────────────────────── - let total_files: i32 = { - let sql = format!( - "SELECT COUNT(*) FROM nodes WHERE kind = 'file' {}", - tf - ); - conn.prepare_cached(&sql) - .map_err(|e| napi::Error::from_reason(format!("get_graph_stats total_files: {e}")))? - .query_row([], |row| row.get(0)) - .map_err(|e| napi::Error::from_reason(format!("get_graph_stats total_files query: {e}")))? - }; - // ── Role counts ──────────────────────────────────────────── let role_counts = { let sql = format!( @@ -1466,7 +1458,6 @@ impl NativeDatabase { Ok(GraphStats { total_nodes, total_edges, - total_files, nodes_by_kind, edges_by_kind, role_counts, @@ -1504,15 +1495,12 @@ impl NativeDatabase { node_id: i32, kind: &str, ) -> napi::Result> { - let sql = format!( - "SELECT n.name, n.kind, n.file, d.line, d.param_index, d.expression, d.confidence \ + let sql = "SELECT n.name, n.kind, n.file, d.line, d.param_index, d.expression, d.confidence \ FROM dataflow d JOIN nodes n ON d.target_id = n.id \ - WHERE d.source_id = ?1 AND d.kind = '{}'", - kind - ); - let mut stmt = conn.prepare_cached(&sql) + WHERE d.source_id = ?1 AND d.kind = ?2"; + let mut stmt = conn.prepare_cached(sql) .map_err(|e| napi::Error::from_reason(format!("get_dataflow_edges out {kind}: {e}")))?; - let rows = stmt.query_map(params![node_id], |row| { + let rows = stmt.query_map(params![node_id, kind], |row| { Ok(DataflowQueryEdge { name: row.get(0)?, kind: row.get(1)?, @@ -1532,15 +1520,12 @@ impl NativeDatabase { node_id: i32, kind: &str, ) -> napi::Result> { - let sql = format!( - "SELECT n.name, n.kind, n.file, d.line, d.param_index, d.expression, d.confidence \ + let sql = "SELECT n.name, n.kind, n.file, d.line, d.param_index, d.expression, d.confidence \ FROM dataflow d JOIN nodes n ON d.source_id = n.id \ - WHERE d.target_id = ?1 AND d.kind = '{}'", - kind - ); - let mut stmt = conn.prepare_cached(&sql) + WHERE d.target_id = ?1 AND d.kind = ?2"; + let mut stmt = conn.prepare_cached(sql) .map_err(|e| napi::Error::from_reason(format!("get_dataflow_edges in {kind}: {e}")))?; - let rows = stmt.query_map(params![node_id], |row| { + let rows = stmt.query_map(params![node_id, kind], |row| { Ok(DataflowQueryEdge { name: row.get(0)?, kind: row.get(1)?, @@ -1639,10 +1624,10 @@ impl NativeDatabase { for &nid in &node_ids { let fan_in: i32 = fan_in_stmt .query_row(params![nid], |row| row.get(0)) - .unwrap_or(0); + .map_err(|e| napi::Error::from_reason(format!("batch_fan_metrics fan_in query nid={nid}: {e}")))?; let fan_out: i32 = fan_out_stmt .query_row(params![nid], |row| row.get(0)) - .unwrap_or(0); + .map_err(|e| napi::Error::from_reason(format!("batch_fan_metrics fan_out query nid={nid}: {e}")))?; results.push(FanMetric { node_id: nid, fan_in, diff --git a/crates/codegraph-core/src/read_types.rs b/crates/codegraph-core/src/read_types.rs index 24819efc..ffd966e2 100644 --- a/crates/codegraph-core/src/read_types.rs +++ b/crates/codegraph-core/src/read_types.rs @@ -242,7 +242,6 @@ pub struct QualityMetrics { pub struct GraphStats { pub total_nodes: i32, pub total_edges: i32, - pub total_files: i32, pub nodes_by_kind: Vec, pub edges_by_kind: Vec, pub role_counts: Vec, diff --git a/src/types.ts b/src/types.ts index 63cf912c..a99826b7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2083,7 +2083,6 @@ export interface NativeDatabase { getGraphStats?(noTests: boolean): { totalNodes: number; totalEdges: number; - totalFiles: number; nodesByKind: Array<{ kind: string; count: number }>; edgesByKind: Array<{ kind: string; count: number }>; roleCounts: Array<{ role: string; count: number }>; From e234753d71b8bd897a78bbec97457674f3a15493 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 30 Mar 2026 03:15:31 -0600 Subject: [PATCH 3/3] fix(native): resolve Rust compile errors in batched query methods (#698) - Use fully-qualified rusqlite::Connection in inner function signatures since #[napi] macro generates a module scope that hides the type - Add explicit rusqlite::Row type annotations on closure parameters needed after switching from format!() to params![] bind parameters --- crates/codegraph-core/src/read_queries.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/codegraph-core/src/read_queries.rs b/crates/codegraph-core/src/read_queries.rs index 44595fbc..72a5354c 100644 --- a/crates/codegraph-core/src/read_queries.rs +++ b/crates/codegraph-core/src/read_queries.rs @@ -1491,7 +1491,7 @@ impl NativeDatabase { } fn query_outgoing( - conn: &Connection, + conn: &rusqlite::Connection, node_id: i32, kind: &str, ) -> napi::Result> { @@ -1500,7 +1500,7 @@ impl NativeDatabase { WHERE d.source_id = ?1 AND d.kind = ?2"; let mut stmt = conn.prepare_cached(sql) .map_err(|e| napi::Error::from_reason(format!("get_dataflow_edges out {kind}: {e}")))?; - let rows = stmt.query_map(params![node_id, kind], |row| { + let rows = stmt.query_map(params![node_id, kind], |row: &rusqlite::Row| { Ok(DataflowQueryEdge { name: row.get(0)?, kind: row.get(1)?, @@ -1516,7 +1516,7 @@ impl NativeDatabase { } fn query_incoming( - conn: &Connection, + conn: &rusqlite::Connection, node_id: i32, kind: &str, ) -> napi::Result> { @@ -1525,7 +1525,7 @@ impl NativeDatabase { WHERE d.target_id = ?1 AND d.kind = ?2"; let mut stmt = conn.prepare_cached(sql) .map_err(|e| napi::Error::from_reason(format!("get_dataflow_edges in {kind}: {e}")))?; - let rows = stmt.query_map(params![node_id, kind], |row| { + let rows = stmt.query_map(params![node_id, kind], |row: &rusqlite::Row| { Ok(DataflowQueryEdge { name: row.get(0)?, kind: row.get(1)?,