Skip to content

Commit 5e3f480

Browse files
Omri Hopsoncursoragent
authored andcommitted
feat(graph): serve graph from SQLite with bounded cache; drop in-RAM hydration
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 9a7bdb8 commit 5e3f480

10 files changed

Lines changed: 166 additions & 52 deletions

File tree

src/api/routes/graph.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type { FastifyInstance } from 'fastify';
1414
import { z } from 'zod';
1515
import DirectedGraph from 'graphology';
1616
import forceAtlas2 from 'graphology-layout-forceatlas2';
17-
import type { CodeGraph } from '../../core/graph.js';
17+
import type { CodeGraphApi } from '../../core/graph.js';
1818

1919
const GraphQuerySchema = z.object({
2020
maxNodes: z.number().int().min(1).max(10000).default(1000),
@@ -23,7 +23,7 @@ const GraphQuerySchema = z.object({
2323
});
2424

2525
export interface GraphRouteOptions {
26-
graph: CodeGraph;
26+
graph: CodeGraphApi;
2727
}
2828

2929
/**

src/api/routes/metrics.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@
1717

1818
import type { FastifyInstance } from 'fastify';
1919
import type { StorageProvider } from '../../store/provider.js';
20-
import type { CodeGraph } from '../../core/graph.js';
20+
import type { CodeGraphApi } from '../../core/graph.js';
2121

2222
export interface MetricsRouteOptions {
2323
storage: StorageProvider;
24-
graph: CodeGraph;
24+
graph: CodeGraphApi;
2525
watcher?: any;
2626
embeddingQueue?: any;
2727
vectorStore?: any;

src/api/routes/repositories.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { z } from 'zod';
1919
import path from 'node:path';
2020
import type { StorageProvider } from '../../store/provider.js';
2121
import { IndexQueueFullError, MemoryPressureError } from '../../core/errors.js';
22-
import type { CodeGraph } from '../../core/graph.js';
22+
import type { CodeGraphApi } from '../../core/graph.js';
2323
import type { WebSocketBroadcaster } from '../websocket.js';
2424
import { WebSocketEvents } from '../websocket.js';
2525
import { isSubpath } from '../../core/path-utils.js';
@@ -31,7 +31,7 @@ const IndexRepositorySchema = z.object({
3131

3232
export interface RepositoryRouteOptions {
3333
storage: StorageProvider;
34-
graph: CodeGraph;
34+
graph: CodeGraphApi;
3535
broadcaster: WebSocketBroadcaster;
3636
workspaceRoot: string;
3737
indexer?: any;

src/api/server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import Fastify from 'fastify';
88
import fastifyStatic from '@fastify/static';
99
import fastifyWebsocket from '@fastify/websocket';
1010
import { basename } from 'path';
11-
import type { CodeGraph } from '../core/graph.js';
11+
import type { CodeGraphApi } from '../core/graph.js';
1212
import type { StorageProvider } from '../store/provider.js';
1313
import {
1414
WebSocketBroadcaster,
@@ -29,7 +29,7 @@ import type { EmlServices } from '../eml/mcp/handlers.js';
2929

3030
export interface APIServerOptions {
3131
storage: StorageProvider;
32-
graph: CodeGraph;
32+
graph: CodeGraphApi;
3333
dashboardPath: string;
3434
workspaceRoot: string;
3535
mountRoot?: string;

src/core/graph-store.ts

Lines changed: 104 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
NodeFilter
2121
} from './types.js';
2222
import { GraphError, NotFoundError } from './errors.js';
23+
import type { CodeGraphApi, ImpactAnalysisResult, ArchitectureSummary } from './graph.js';
2324

2425
// Traversal limits from EML (reused for consistency)
2526
export const MAX_TRAVERSE_DEPTH = 12;
@@ -40,21 +41,7 @@ interface CacheEntry {
4041
accessedAt: number;
4142
}
4243

43-
export interface ImpactAnalysisResult {
44-
affectedNodes: CodeNode[];
45-
affectedFiles: Set<string>;
46-
depth: number;
47-
confidence: number;
48-
}
49-
50-
export interface ArchitectureSummary {
51-
entryPoints: CodeNode[];
52-
modules: Map<string, CodeNode[]>;
53-
keyAbstractions: CodeNode[];
54-
packageStructure: Record<string, number>;
55-
}
56-
57-
export class StorageBackedGraph {
44+
export class StorageBackedGraph implements CodeGraphApi {
5845
private readonly storage: StorageProvider;
5946
private readonly maxCacheEntries: number;
6047
private readonly nodeCache = new Map<string, CachedNode>();
@@ -606,9 +593,106 @@ export class StorageBackedGraph {
606593
}
607594
}
608595

609-
// Invalidate cache entries (will be used by write methods in Phase 12)
610-
// private invalidateCache(): void {
611-
// this.nodeCache.clear();
612-
// this.queryCache.clear();
613-
// }
596+
// ============================================================================
597+
// WRITE METHODS (Phase 12)
598+
// ============================================================================
599+
600+
async addNode(node: CodeNode): Promise<void> {
601+
// Write to storage
602+
this.storage.upsertNodes([node]);
603+
604+
// Cache the new node
605+
this.cacheNode(node);
606+
607+
// Invalidate related queries that might be affected
608+
this.invalidateQueryCache();
609+
}
610+
611+
async addEdge(edge: GraphEdge): Promise<void> {
612+
// Write to storage
613+
this.storage.upsertEdges([edge]);
614+
615+
// Invalidate cache since edge affects traversal results
616+
this.invalidateQueryCache();
617+
}
618+
619+
async bulkLoad(nodes: CodeNode[], edges: GraphEdge[]): Promise<void> {
620+
// Write all nodes and edges to storage
621+
if (nodes.length > 0) {
622+
this.storage.upsertNodes(nodes);
623+
624+
// Cache hot nodes
625+
for (const node of nodes) {
626+
this.cacheNode(node);
627+
}
628+
}
629+
630+
if (edges.length > 0) {
631+
this.storage.upsertEdges(edges);
632+
}
633+
634+
// Clear all caches to ensure consistency
635+
this.invalidateAllCaches();
636+
}
637+
638+
async removeNode(nodeId: string): Promise<void> {
639+
// Remove from storage
640+
this.storage.deleteNode(nodeId);
641+
this.storage.deleteEdgesForNode(nodeId);
642+
643+
// Remove from caches
644+
this.nodeCache.delete(nodeId);
645+
this.invalidateQueryCache();
646+
}
647+
648+
async removeNodesInFile(filePath: string): Promise<void> {
649+
// Remove from storage
650+
this.storage.deleteNodesInFile(filePath);
651+
652+
// Clear caches (could be more surgical but this is safe)
653+
this.invalidateAllCaches();
654+
}
655+
656+
// ============================================================================
657+
// CACHE INVALIDATION
658+
// ============================================================================
659+
660+
private invalidateQueryCache(): void {
661+
this.queryCache.clear();
662+
}
663+
664+
private invalidateAllCaches(): void {
665+
this.nodeCache.clear();
666+
this.queryCache.clear();
667+
}
668+
669+
// ============================================================================
670+
// ADDITIONAL METHODS FOR API COMPATIBILITY
671+
// ============================================================================
672+
673+
getAllEdges(): GraphEdge[] {
674+
return this.storage.getEdges();
675+
}
676+
677+
serialize(): string {
678+
// For StorageBackedGraph, serialization is the SQLite database itself
679+
// Return a marker indicating this is storage-backed
680+
return JSON.stringify({
681+
type: 'StorageBackedGraph',
682+
timestamp: new Date().toISOString(),
683+
nodeCount: this.storage.countNodes(),
684+
edgeCount: this.storage.getStats().edgeCount,
685+
});
686+
}
687+
688+
async deserialize(_data: string): Promise<void> {
689+
// For StorageBackedGraph, deserialization is not needed since data lives in SQLite
690+
// This is a no-op for compatibility
691+
console.log('StorageBackedGraph.deserialize called - no action needed (data in SQLite)');
692+
}
693+
694+
clear(): void {
695+
// Clear caches but don't clear the storage (that would delete all data!)
696+
this.invalidateAllCaches();
697+
}
614698
}

src/core/graph.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,46 @@ export interface ArchitectureSummary {
4848
packageStructure: Record<string, number>;
4949
}
5050

51-
export class CodeGraph {
51+
/**
52+
* Common interface for graph implementations (CodeGraph and StorageBackedGraph)
53+
* This ensures both implementations can be used interchangeably by consumers.
54+
*/
55+
export interface CodeGraphApi {
56+
// Read methods
57+
getNode(nodeId: string): CodeNode | null;
58+
findByName(name: string, filter?: NodeFilter): CodeNode[];
59+
findByPattern(pattern: string, filter?: NodeFilter): CodeNode[];
60+
getNodesInFile(filePath: string): CodeNode[];
61+
getAllNodes(filter?: NodeFilter): CodeNode[];
62+
getCallers(nodeId: string, edgeKinds?: EdgeKind[]): CodeNode[];
63+
getCallees(nodeId: string, edgeKinds?: EdgeKind[]): CodeNode[];
64+
findShortestPath(sourceId: string, targetId: string): CodeNode[] | null;
65+
analyzeImpact(nodeId: string, maxDepth?: number): ImpactAnalysisResult;
66+
computeCentrality(): Map<string, number>;
67+
getCentrality(nodeId: string): number;
68+
findDeadCode(repositoryId?: string): CodeNode[];
69+
explainArchitecture(repositoryId: string, detailLevel?: number): ArchitectureSummary;
70+
getStats(): {
71+
nodeCount: number;
72+
edgeCount: number;
73+
fileCount: number;
74+
languageBreakdown: Record<string, number>;
75+
};
76+
getMemoryFootprint(): number;
77+
getAllEdges(): GraphEdge[];
78+
serialize(): string;
79+
80+
// Write methods
81+
addNode(node: CodeNode): Promise<void>;
82+
addEdge(edge: GraphEdge): Promise<void>;
83+
bulkLoad(nodes: CodeNode[], edges: GraphEdge[]): Promise<void>;
84+
removeNode(nodeId: string): Promise<void>;
85+
removeNodesInFile(filePath: string): Promise<void>;
86+
deserialize(data: string): Promise<void>;
87+
clear(): void;
88+
}
89+
90+
export class CodeGraph implements CodeGraphApi {
5291
private graph: DirectedGraph;
5392
private nameIndex: Map<string, Set<string>>;
5493
private fileIndex: Map<string, Set<string>>;

src/core/indexer.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { readdir, stat, readFile } from 'fs/promises';
3131
import { resolve, relative, basename, dirname, join } from 'path';
3232
import { createHash } from 'crypto';
3333
import { parseFile } from './parser.js';
34-
import type { CodeGraph } from './graph.js';
34+
import type { CodeGraphApi } from './graph.js';
3535
import type { StorageProvider } from '../store/provider.js';
3636
import type {
3737
FileMetadata,
@@ -73,7 +73,7 @@ export class Indexer extends EventEmitter {
7373

7474
constructor(
7575
public storage: StorageProvider,
76-
public graph: CodeGraph,
76+
public graph: CodeGraphApi,
7777
private workspaceRoot: string = '/workspace',
7878
private embeddingQueue?: EmbeddingQueue,
7979
private vectorStore?: LanceDBVectorStore,
@@ -247,7 +247,9 @@ export class Indexer extends EventEmitter {
247247
parsed = await parseFile(relativePath, repositoryId, this.workspaceRoot);
248248
}
249249

250-
// Update graph with mutex protection (outside transaction)
250+
// Single source of truth: StorageBackedGraph writes to SQLite automatically
251+
// The graph operations below persist nodes/edges to storage, so we only need
252+
// to update file metadata and pending references in the transaction
251253
await this.graph.removeNodesInFile(relativePath);
252254

253255
for (const node of parsed.nodes) {
@@ -263,11 +265,8 @@ export class Indexer extends EventEmitter {
263265
}
264266
}
265267

266-
// Update database in transaction
268+
// Update file metadata and pending references in transaction
267269
this.storage.transaction(() => {
268-
this.storage.deleteNodesInFile(relativePath);
269-
this.storage.upsertNodes(parsed.nodes);
270-
this.storage.upsertEdges(edges);
271270

272271
this.pendingReferences.push({
273272
calls: parsed.calls,

src/index.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { loadConfig } from './core/config.js';
1414
import { SqliteStorageProvider } from './store/sqlite.js';
1515
import { ParsePool } from './core/parse-pool.js';
1616
import { LanceDBVectorStore } from './store/lance.js';
17-
import { CodeGraph } from './core/graph.js';
17+
import { StorageBackedGraph } from './core/graph-store.js';
1818
import { Indexer } from './core/indexer.js';
1919
import { MCPServer } from './mcp/server.js';
2020
import { FileWatcher } from './core/watcher.js';
@@ -85,16 +85,8 @@ async function main() {
8585
await vectorStore.initialize(lanceConnection);
8686
console.log('LanceDB vector store initialized');
8787

88-
const graph = new CodeGraph(config.graphMemoryLimitMb.value);
89-
console.log(`Graph engine ready (memory limit: ${config.graphMemoryLimitMb.value}MB)`);
90-
91-
console.log('Hydrating graph from storage...');
92-
const allNodes = storage.getAllNodes();
93-
const allEdges = storage.getEdges();
94-
95-
await graph.bulkLoad(allNodes, allEdges);
96-
97-
console.log(`Graph hydrated: ${allNodes.length} nodes, ${allEdges.length} edges`);
88+
const graph = new StorageBackedGraph(storage, { hotCacheMb: config.graphHotCacheMb.value });
89+
console.log(`Graph store ready (cache: ${config.graphHotCacheMb.value}MB)`);
9890

9991
const embeddingProvider = await createEmbeddingProvider(config.llmProvider.value, {
10092
apiKey: config.llmApiKey.value,

src/mcp/handlers/indexing.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
DeleteRepositoryInputSchema,
1313
} from '../tools.js';
1414
import path from 'node:path';
15-
import type { CodeGraph } from '../../core/graph.js';
15+
import type { CodeGraphApi } from '../../core/graph.js';
1616
import type { StorageProvider } from '../../store/provider.js';
1717
import type { Indexer } from '../../core/indexer.js';
1818
import type { SymbolicSearch } from '../../search/symbolic.js';
@@ -24,7 +24,7 @@ import { isSubpath } from '../../core/path-utils.js';
2424

2525
export interface HandlerContext {
2626
storage: StorageProvider;
27-
graph: CodeGraph;
27+
graph: CodeGraphApi;
2828
indexer: Indexer;
2929
symbolicSearch: SymbolicSearch;
3030
vectorSearch?: VectorSearch;

src/mcp/server.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
3131
import { TOOL_DEFINITIONS, TOOL_DEFINITIONS_COMPACT } from './tools.js';
3232
import { formatMCPResponse } from './formatter.js';
3333
import type { ResponseMode } from '../core/types.js';
34-
import type { CodeGraph } from '../core/graph.js';
34+
import type { CodeGraphApi } from '../core/graph.js';
3535
import type { StorageProvider } from '../store/provider.js';
3636
import type { Indexer } from '../core/indexer.js';
3737
import { SymbolicSearch } from '../search/symbolic.js';
@@ -48,7 +48,7 @@ import { EmlDisabledError } from '../core/errors.js';
4848

4949
export interface MCPServerOptions {
5050
storage: StorageProvider;
51-
graph: CodeGraph;
51+
graph: CodeGraphApi;
5252
indexer: Indexer;
5353
workspaceRoot: string;
5454
vectorStore?: LanceDBVectorStore;
@@ -71,7 +71,7 @@ export interface MCPMetrics {
7171
export class MCPServer {
7272
private server: Server;
7373
private storage: StorageProvider;
74-
private graph: CodeGraph;
74+
private graph: CodeGraphApi;
7575
private indexer: Indexer;
7676
private symbolicSearch: SymbolicSearch;
7777
private vectorSearch?: VectorSearch;

0 commit comments

Comments
 (0)