This commit is contained in:
Colby McHenry
2026-01-18 16:25:00 -06:00
parent 08ccabb5a9
commit cc6e7a5c89
57 changed files with 23315 additions and 1 deletions
+154
View File
@@ -0,0 +1,154 @@
/**
* Database Layer
*
* Handles SQLite database initialization and connection management.
*/
import Database from 'better-sqlite3';
import * as fs from 'fs';
import * as path from 'path';
import { SchemaVersion } from '../types';
import { runMigrations, getCurrentVersion, CURRENT_SCHEMA_VERSION } from './migrations';
/**
* Database connection wrapper with lifecycle management
*/
export class DatabaseConnection {
private db: Database.Database;
private dbPath: string;
private constructor(db: Database.Database, dbPath: string) {
this.db = db;
this.dbPath = dbPath;
}
/**
* Initialize a new database at the given path
*/
static initialize(dbPath: string): DatabaseConnection {
// Ensure parent directory exists
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Create and configure database
const db = new Database(dbPath);
// Enable foreign keys and WAL mode for better performance
db.pragma('foreign_keys = ON');
db.pragma('journal_mode = WAL');
// Run schema initialization
const schemaPath = path.join(__dirname, 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
db.exec(schema);
return new DatabaseConnection(db, dbPath);
}
/**
* Open an existing database
*/
static open(dbPath: string): DatabaseConnection {
if (!fs.existsSync(dbPath)) {
throw new Error(`Database not found: ${dbPath}`);
}
const db = new Database(dbPath);
// Enable foreign keys and WAL mode
db.pragma('foreign_keys = ON');
db.pragma('journal_mode = WAL');
// Check and run migrations if needed
const conn = new DatabaseConnection(db, dbPath);
const currentVersion = getCurrentVersion(db);
if (currentVersion < CURRENT_SCHEMA_VERSION) {
runMigrations(db, currentVersion);
}
return conn;
}
/**
* Get the underlying database instance
*/
getDb(): Database.Database {
return this.db;
}
/**
* Get database file path
*/
getPath(): string {
return this.dbPath;
}
/**
* Get current schema version
*/
getSchemaVersion(): SchemaVersion | null {
const row = this.db
.prepare('SELECT version, applied_at, description FROM schema_versions ORDER BY version DESC LIMIT 1')
.get() as { version: number; applied_at: number; description: string | null } | undefined;
if (!row) return null;
return {
version: row.version,
appliedAt: row.applied_at,
description: row.description ?? undefined,
};
}
/**
* Execute a function within a transaction
*/
transaction<T>(fn: () => T): T {
return this.db.transaction(fn)();
}
/**
* Get database file size in bytes
*/
getSize(): number {
const stats = fs.statSync(this.dbPath);
return stats.size;
}
/**
* Optimize database (vacuum and analyze)
*/
optimize(): void {
this.db.exec('VACUUM');
this.db.exec('ANALYZE');
}
/**
* Close the database connection
*/
close(): void {
this.db.close();
}
/**
* Check if the database connection is open
*/
isOpen(): boolean {
return this.db.open;
}
}
/**
* Default database filename
*/
export const DATABASE_FILENAME = 'codegraph.db';
/**
* Get the default database path for a project
*/
export function getDatabasePath(projectRoot: string): string {
return path.join(projectRoot, '.codegraph', DATABASE_FILENAME);
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Database Migrations
*
* Schema versioning and migration support.
*/
import Database from 'better-sqlite3';
/**
* Current schema version
*/
export const CURRENT_SCHEMA_VERSION = 1;
/**
* Migration definition
*/
interface Migration {
version: number;
description: string;
up: (db: Database.Database) => void;
}
/**
* All migrations in order
*
* Note: Version 1 is the initial schema, handled by schema.sql
* Future migrations go here.
*/
const migrations: Migration[] = [
// Example migration for version 2 (when needed):
// {
// version: 2,
// description: 'Add support for module resolution',
// up: (db) => {
// db.exec(`
// ALTER TABLE nodes ADD COLUMN module_path TEXT;
// CREATE INDEX idx_nodes_module_path ON nodes(module_path);
// `);
// },
// },
];
/**
* Get the current schema version from the database
*/
export function getCurrentVersion(db: Database.Database): number {
try {
const row = db
.prepare('SELECT MAX(version) as version FROM schema_versions')
.get() as { version: number | null } | undefined;
return row?.version ?? 0;
} catch {
// Table doesn't exist yet
return 0;
}
}
/**
* Record a migration as applied
*/
function recordMigration(db: Database.Database, version: number, description: string): void {
db.prepare(
'INSERT INTO schema_versions (version, applied_at, description) VALUES (?, ?, ?)'
).run(version, Date.now(), description);
}
/**
* Run all pending migrations
*/
export function runMigrations(db: Database.Database, fromVersion: number): void {
const pending = migrations.filter((m) => m.version > fromVersion);
if (pending.length === 0) {
return;
}
// Sort by version
pending.sort((a, b) => a.version - b.version);
// Run each migration in a transaction
for (const migration of pending) {
db.transaction(() => {
migration.up(db);
recordMigration(db, migration.version, migration.description);
})();
}
}
/**
* Check if the database needs migration
*/
export function needsMigration(db: Database.Database): boolean {
const current = getCurrentVersion(db);
return current < CURRENT_SCHEMA_VERSION;
}
/**
* Get list of pending migrations
*/
export function getPendingMigrations(db: Database.Database): Migration[] {
const current = getCurrentVersion(db);
return migrations
.filter((m) => m.version > current)
.sort((a, b) => a.version - b.version);
}
/**
* Get migration history from database
*/
export function getMigrationHistory(
db: Database.Database
): Array<{ version: number; appliedAt: number; description: string | null }> {
const rows = db
.prepare('SELECT version, applied_at, description FROM schema_versions ORDER BY version')
.all() as Array<{ version: number; applied_at: number; description: string | null }>;
return rows.map((row) => ({
version: row.version,
appliedAt: row.applied_at,
description: row.description,
}));
}
+746
View File
@@ -0,0 +1,746 @@
/**
* Database Queries
*
* Prepared statements for CRUD operations on the knowledge graph.
*/
import Database from 'better-sqlite3';
import {
Node,
Edge,
FileRecord,
UnresolvedReference,
NodeKind,
EdgeKind,
Language,
GraphStats,
SearchOptions,
SearchResult,
} from '../types';
/**
* Database row types (snake_case from SQLite)
*/
interface NodeRow {
id: string;
kind: string;
name: string;
qualified_name: string;
file_path: string;
language: string;
start_line: number;
end_line: number;
start_column: number;
end_column: number;
docstring: string | null;
signature: string | null;
visibility: string | null;
is_exported: number;
is_async: number;
is_static: number;
is_abstract: number;
decorators: string | null;
type_parameters: string | null;
updated_at: number;
}
interface EdgeRow {
id: number;
source: string;
target: string;
kind: string;
metadata: string | null;
line: number | null;
col: number | null;
}
interface FileRow {
path: string;
content_hash: string;
language: string;
size: number;
modified_at: number;
indexed_at: number;
node_count: number;
errors: string | null;
}
interface UnresolvedRefRow {
id: number;
from_node_id: string;
reference_name: string;
reference_kind: string;
line: number;
col: number;
candidates: string | null;
}
/**
* Convert database row to Node object
*/
function rowToNode(row: NodeRow): Node {
return {
id: row.id,
kind: row.kind as NodeKind,
name: row.name,
qualifiedName: row.qualified_name,
filePath: row.file_path,
language: row.language as Language,
startLine: row.start_line,
endLine: row.end_line,
startColumn: row.start_column,
endColumn: row.end_column,
docstring: row.docstring ?? undefined,
signature: row.signature ?? undefined,
visibility: row.visibility as Node['visibility'],
isExported: row.is_exported === 1,
isAsync: row.is_async === 1,
isStatic: row.is_static === 1,
isAbstract: row.is_abstract === 1,
decorators: row.decorators ? JSON.parse(row.decorators) : undefined,
typeParameters: row.type_parameters ? JSON.parse(row.type_parameters) : undefined,
updatedAt: row.updated_at,
};
}
/**
* Convert database row to Edge object
*/
function rowToEdge(row: EdgeRow): Edge {
return {
source: row.source,
target: row.target,
kind: row.kind as EdgeKind,
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
line: row.line ?? undefined,
column: row.col ?? undefined,
};
}
/**
* Convert database row to FileRecord object
*/
function rowToFileRecord(row: FileRow): FileRecord {
return {
path: row.path,
contentHash: row.content_hash,
language: row.language as Language,
size: row.size,
modifiedAt: row.modified_at,
indexedAt: row.indexed_at,
nodeCount: row.node_count,
errors: row.errors ? JSON.parse(row.errors) : undefined,
};
}
/**
* Query builder for the knowledge graph database
*/
export class QueryBuilder {
private db: Database.Database;
// Node cache for frequently accessed nodes (LRU-style, max 1000 entries)
private nodeCache: Map<string, Node> = new Map();
private readonly maxCacheSize = 1000;
// Prepared statements (lazily initialized)
private stmts: {
insertNode?: Database.Statement;
updateNode?: Database.Statement;
deleteNode?: Database.Statement;
deleteNodesByFile?: Database.Statement;
getNodeById?: Database.Statement;
getNodesByFile?: Database.Statement;
getNodesByKind?: Database.Statement;
insertEdge?: Database.Statement;
upsertFile?: Database.Statement;
deleteEdgesBySource?: Database.Statement;
deleteEdgesByTarget?: Database.Statement;
getEdgesBySource?: Database.Statement;
getEdgesByTarget?: Database.Statement;
insertFile?: Database.Statement;
updateFile?: Database.Statement;
deleteFile?: Database.Statement;
getFileByPath?: Database.Statement;
getAllFiles?: Database.Statement;
insertUnresolved?: Database.Statement;
deleteUnresolvedByNode?: Database.Statement;
getUnresolvedByName?: Database.Statement;
} = {};
constructor(db: Database.Database) {
this.db = db;
}
// ===========================================================================
// Node Operations
// ===========================================================================
/**
* Insert a new node
*/
insertNode(node: Node): void {
if (!this.stmts.insertNode) {
this.stmts.insertNode = this.db.prepare(`
INSERT INTO nodes (
id, kind, name, qualified_name, file_path, language,
start_line, end_line, start_column, end_column,
docstring, signature, visibility,
is_exported, is_async, is_static, is_abstract,
decorators, type_parameters, updated_at
) VALUES (
@id, @kind, @name, @qualifiedName, @filePath, @language,
@startLine, @endLine, @startColumn, @endColumn,
@docstring, @signature, @visibility,
@isExported, @isAsync, @isStatic, @isAbstract,
@decorators, @typeParameters, @updatedAt
)
`);
}
this.stmts.insertNode.run({
id: node.id,
kind: node.kind,
name: node.name,
qualifiedName: node.qualifiedName,
filePath: node.filePath,
language: node.language,
startLine: node.startLine,
endLine: node.endLine,
startColumn: node.startColumn,
endColumn: node.endColumn,
docstring: node.docstring ?? null,
signature: node.signature ?? null,
visibility: node.visibility ?? null,
isExported: node.isExported ? 1 : 0,
isAsync: node.isAsync ? 1 : 0,
isStatic: node.isStatic ? 1 : 0,
isAbstract: node.isAbstract ? 1 : 0,
decorators: node.decorators ? JSON.stringify(node.decorators) : null,
typeParameters: node.typeParameters ? JSON.stringify(node.typeParameters) : null,
updatedAt: node.updatedAt,
});
}
/**
* Insert multiple nodes in a transaction
*/
insertNodes(nodes: Node[]): void {
this.db.transaction(() => {
for (const node of nodes) {
this.insertNode(node);
}
})();
}
/**
* Update an existing node
*/
updateNode(node: Node): void {
if (!this.stmts.updateNode) {
this.stmts.updateNode = this.db.prepare(`
UPDATE nodes SET
kind = @kind,
name = @name,
qualified_name = @qualifiedName,
file_path = @filePath,
language = @language,
start_line = @startLine,
end_line = @endLine,
start_column = @startColumn,
end_column = @endColumn,
docstring = @docstring,
signature = @signature,
visibility = @visibility,
is_exported = @isExported,
is_async = @isAsync,
is_static = @isStatic,
is_abstract = @isAbstract,
decorators = @decorators,
type_parameters = @typeParameters,
updated_at = @updatedAt
WHERE id = @id
`);
}
// Invalidate cache before update
this.nodeCache.delete(node.id);
this.stmts.updateNode.run({
id: node.id,
kind: node.kind,
name: node.name,
qualifiedName: node.qualifiedName,
filePath: node.filePath,
language: node.language,
startLine: node.startLine,
endLine: node.endLine,
startColumn: node.startColumn,
endColumn: node.endColumn,
docstring: node.docstring ?? null,
signature: node.signature ?? null,
visibility: node.visibility ?? null,
isExported: node.isExported ? 1 : 0,
isAsync: node.isAsync ? 1 : 0,
isStatic: node.isStatic ? 1 : 0,
isAbstract: node.isAbstract ? 1 : 0,
decorators: node.decorators ? JSON.stringify(node.decorators) : null,
typeParameters: node.typeParameters ? JSON.stringify(node.typeParameters) : null,
updatedAt: node.updatedAt,
});
}
/**
* Delete a node by ID
*/
deleteNode(id: string): void {
if (!this.stmts.deleteNode) {
this.stmts.deleteNode = this.db.prepare('DELETE FROM nodes WHERE id = ?');
}
// Invalidate cache
this.nodeCache.delete(id);
this.stmts.deleteNode.run(id);
}
/**
* Delete all nodes for a file
*/
deleteNodesByFile(filePath: string): void {
if (!this.stmts.deleteNodesByFile) {
this.stmts.deleteNodesByFile = this.db.prepare('DELETE FROM nodes WHERE file_path = ?');
}
// Invalidate cache for nodes in this file
for (const [id, node] of this.nodeCache) {
if (node.filePath === filePath) {
this.nodeCache.delete(id);
}
}
this.stmts.deleteNodesByFile.run(filePath);
}
/**
* Get a node by ID
*/
getNodeById(id: string): Node | null {
// Check cache first
if (this.nodeCache.has(id)) {
const cached = this.nodeCache.get(id)!;
// Move to end to implement LRU (delete and re-add)
this.nodeCache.delete(id);
this.nodeCache.set(id, cached);
return cached;
}
if (!this.stmts.getNodeById) {
this.stmts.getNodeById = this.db.prepare('SELECT * FROM nodes WHERE id = ?');
}
const row = this.stmts.getNodeById.get(id) as NodeRow | undefined;
if (!row) {
return null;
}
const node = rowToNode(row);
this.cacheNode(node);
return node;
}
/**
* Add a node to the cache, evicting oldest if needed
*/
private cacheNode(node: Node): void {
if (this.nodeCache.size >= this.maxCacheSize) {
// Evict oldest (first) entry
const firstKey = this.nodeCache.keys().next().value;
if (firstKey) {
this.nodeCache.delete(firstKey);
}
}
this.nodeCache.set(node.id, node);
}
/**
* Clear the node cache
*/
clearCache(): void {
this.nodeCache.clear();
}
/**
* Get all nodes in a file
*/
getNodesByFile(filePath: string): Node[] {
if (!this.stmts.getNodesByFile) {
this.stmts.getNodesByFile = this.db.prepare(
'SELECT * FROM nodes WHERE file_path = ? ORDER BY start_line'
);
}
const rows = this.stmts.getNodesByFile.all(filePath) as NodeRow[];
return rows.map(rowToNode);
}
/**
* Get all nodes of a specific kind
*/
getNodesByKind(kind: NodeKind): Node[] {
if (!this.stmts.getNodesByKind) {
this.stmts.getNodesByKind = this.db.prepare('SELECT * FROM nodes WHERE kind = ?');
}
const rows = this.stmts.getNodesByKind.all(kind) as NodeRow[];
return rows.map(rowToNode);
}
/**
* Search nodes by name using FTS
*/
searchNodes(query: string, options: SearchOptions = {}): SearchResult[] {
const { kinds, languages, limit = 100, offset = 0 } = options;
let sql = `
SELECT nodes.*, bm25(nodes_fts) as score
FROM nodes_fts
JOIN nodes ON nodes_fts.id = nodes.id
WHERE nodes_fts MATCH ?
`;
const params: (string | number)[] = [query];
if (kinds && kinds.length > 0) {
sql += ` AND nodes.kind IN (${kinds.map(() => '?').join(',')})`;
params.push(...kinds);
}
if (languages && languages.length > 0) {
sql += ` AND nodes.language IN (${languages.map(() => '?').join(',')})`;
params.push(...languages);
}
sql += ' ORDER BY score LIMIT ? OFFSET ?';
params.push(limit, offset);
const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[];
return rows.map((row) => ({
node: rowToNode(row),
score: Math.abs(row.score), // bm25 returns negative scores
}));
}
// ===========================================================================
// Edge Operations
// ===========================================================================
/**
* Insert a new edge
*/
insertEdge(edge: Edge): void {
if (!this.stmts.insertEdge) {
this.stmts.insertEdge = this.db.prepare(`
INSERT INTO edges (source, target, kind, metadata, line, col)
VALUES (@source, @target, @kind, @metadata, @line, @col)
`);
}
this.stmts.insertEdge.run({
source: edge.source,
target: edge.target,
kind: edge.kind,
metadata: edge.metadata ? JSON.stringify(edge.metadata) : null,
line: edge.line ?? null,
col: edge.column ?? null,
});
}
/**
* Insert multiple edges in a transaction
*/
insertEdges(edges: Edge[]): void {
this.db.transaction(() => {
for (const edge of edges) {
this.insertEdge(edge);
}
})();
}
/**
* Delete all edges from a source node
*/
deleteEdgesBySource(sourceId: string): void {
if (!this.stmts.deleteEdgesBySource) {
this.stmts.deleteEdgesBySource = this.db.prepare('DELETE FROM edges WHERE source = ?');
}
this.stmts.deleteEdgesBySource.run(sourceId);
}
/**
* Get outgoing edges from a node
*/
getOutgoingEdges(sourceId: string, kinds?: EdgeKind[]): Edge[] {
if (kinds && kinds.length > 0) {
const sql = `SELECT * FROM edges WHERE source = ? AND kind IN (${kinds.map(() => '?').join(',')})`;
const rows = this.db.prepare(sql).all(sourceId, ...kinds) as EdgeRow[];
return rows.map(rowToEdge);
}
if (!this.stmts.getEdgesBySource) {
this.stmts.getEdgesBySource = this.db.prepare('SELECT * FROM edges WHERE source = ?');
}
const rows = this.stmts.getEdgesBySource.all(sourceId) as EdgeRow[];
return rows.map(rowToEdge);
}
/**
* Get incoming edges to a node
*/
getIncomingEdges(targetId: string, kinds?: EdgeKind[]): Edge[] {
if (kinds && kinds.length > 0) {
const sql = `SELECT * FROM edges WHERE target = ? AND kind IN (${kinds.map(() => '?').join(',')})`;
const rows = this.db.prepare(sql).all(targetId, ...kinds) as EdgeRow[];
return rows.map(rowToEdge);
}
if (!this.stmts.getEdgesByTarget) {
this.stmts.getEdgesByTarget = this.db.prepare('SELECT * FROM edges WHERE target = ?');
}
const rows = this.stmts.getEdgesByTarget.all(targetId) as EdgeRow[];
return rows.map(rowToEdge);
}
// ===========================================================================
// File Operations
// ===========================================================================
/**
* Insert or update a file record
*/
upsertFile(file: FileRecord): void {
if (!this.stmts.upsertFile) {
this.stmts.upsertFile = this.db.prepare(`
INSERT INTO files (path, content_hash, language, size, modified_at, indexed_at, node_count, errors)
VALUES (@path, @contentHash, @language, @size, @modifiedAt, @indexedAt, @nodeCount, @errors)
ON CONFLICT(path) DO UPDATE SET
content_hash = @contentHash,
language = @language,
size = @size,
modified_at = @modifiedAt,
indexed_at = @indexedAt,
node_count = @nodeCount,
errors = @errors
`);
}
this.stmts.upsertFile.run({
path: file.path,
contentHash: file.contentHash,
language: file.language,
size: file.size,
modifiedAt: file.modifiedAt,
indexedAt: file.indexedAt,
nodeCount: file.nodeCount,
errors: file.errors ? JSON.stringify(file.errors) : null,
});
}
/**
* Delete a file record and its nodes
*/
deleteFile(filePath: string): void {
this.db.transaction(() => {
this.deleteNodesByFile(filePath);
if (!this.stmts.deleteFile) {
this.stmts.deleteFile = this.db.prepare('DELETE FROM files WHERE path = ?');
}
this.stmts.deleteFile.run(filePath);
})();
}
/**
* Get a file record by path
*/
getFileByPath(filePath: string): FileRecord | null {
if (!this.stmts.getFileByPath) {
this.stmts.getFileByPath = this.db.prepare('SELECT * FROM files WHERE path = ?');
}
const row = this.stmts.getFileByPath.get(filePath) as FileRow | undefined;
return row ? rowToFileRecord(row) : null;
}
/**
* Get all tracked files
*/
getAllFiles(): FileRecord[] {
if (!this.stmts.getAllFiles) {
this.stmts.getAllFiles = this.db.prepare('SELECT * FROM files ORDER BY path');
}
const rows = this.stmts.getAllFiles.all() as FileRow[];
return rows.map(rowToFileRecord);
}
/**
* Get files that need re-indexing (hash changed)
*/
getStaleFiles(currentHashes: Map<string, string>): FileRecord[] {
const files = this.getAllFiles();
return files.filter((f) => {
const currentHash = currentHashes.get(f.path);
return currentHash && currentHash !== f.contentHash;
});
}
// ===========================================================================
// Unresolved References
// ===========================================================================
/**
* Insert an unresolved reference
*/
insertUnresolvedRef(ref: UnresolvedReference): void {
if (!this.stmts.insertUnresolved) {
this.stmts.insertUnresolved = this.db.prepare(`
INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, candidates)
VALUES (@fromNodeId, @referenceName, @referenceKind, @line, @col, @candidates)
`);
}
this.stmts.insertUnresolved.run({
fromNodeId: ref.fromNodeId,
referenceName: ref.referenceName,
referenceKind: ref.referenceKind,
line: ref.line,
col: ref.column,
candidates: ref.candidates ? JSON.stringify(ref.candidates) : null,
});
}
/**
* Delete unresolved references from a node
*/
deleteUnresolvedByNode(nodeId: string): void {
if (!this.stmts.deleteUnresolvedByNode) {
this.stmts.deleteUnresolvedByNode = this.db.prepare(
'DELETE FROM unresolved_refs WHERE from_node_id = ?'
);
}
this.stmts.deleteUnresolvedByNode.run(nodeId);
}
/**
* Get unresolved references by name (for resolution)
*/
getUnresolvedByName(name: string): UnresolvedReference[] {
if (!this.stmts.getUnresolvedByName) {
this.stmts.getUnresolvedByName = this.db.prepare(
'SELECT * FROM unresolved_refs WHERE reference_name = ?'
);
}
const rows = this.stmts.getUnresolvedByName.all(name) as UnresolvedRefRow[];
return rows.map((row) => ({
fromNodeId: row.from_node_id,
referenceName: row.reference_name,
referenceKind: row.reference_kind as EdgeKind,
line: row.line,
column: row.col,
candidates: row.candidates ? JSON.parse(row.candidates) : undefined,
}));
}
/**
* Get all unresolved references
*/
getUnresolvedReferences(): UnresolvedReference[] {
const rows = this.db.prepare('SELECT * FROM unresolved_refs').all() as UnresolvedRefRow[];
return rows.map((row) => ({
fromNodeId: row.from_node_id,
referenceName: row.reference_name,
referenceKind: row.reference_kind as EdgeKind,
line: row.line,
column: row.col,
candidates: row.candidates ? JSON.parse(row.candidates) : undefined,
}));
}
/**
* Delete all unresolved references (after resolution)
*/
clearUnresolvedReferences(): void {
this.db.exec('DELETE FROM unresolved_refs');
}
/**
* Delete resolved references by their IDs
*/
deleteResolvedReferences(fromNodeIds: string[]): void {
if (fromNodeIds.length === 0) return;
const placeholders = fromNodeIds.map(() => '?').join(',');
this.db.prepare(`DELETE FROM unresolved_refs WHERE from_node_id IN (${placeholders})`).run(...fromNodeIds);
}
// ===========================================================================
// Statistics
// ===========================================================================
/**
* Get graph statistics
*/
getStats(): GraphStats {
const nodeCount = (
this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as { count: number }
).count;
const edgeCount = (
this.db.prepare('SELECT COUNT(*) as count FROM edges').get() as { count: number }
).count;
const fileCount = (
this.db.prepare('SELECT COUNT(*) as count FROM files').get() as { count: number }
).count;
const nodesByKind = {} as Record<NodeKind, number>;
const nodeKindRows = this.db
.prepare('SELECT kind, COUNT(*) as count FROM nodes GROUP BY kind')
.all() as Array<{ kind: string; count: number }>;
for (const row of nodeKindRows) {
nodesByKind[row.kind as NodeKind] = row.count;
}
const edgesByKind = {} as Record<EdgeKind, number>;
const edgeKindRows = this.db
.prepare('SELECT kind, COUNT(*) as count FROM edges GROUP BY kind')
.all() as Array<{ kind: string; count: number }>;
for (const row of edgeKindRows) {
edgesByKind[row.kind as EdgeKind] = row.count;
}
const filesByLanguage = {} as Record<Language, number>;
const languageRows = this.db
.prepare('SELECT language, COUNT(*) as count FROM files GROUP BY language')
.all() as Array<{ language: string; count: number }>;
for (const row of languageRows) {
filesByLanguage[row.language as Language] = row.count;
}
return {
nodeCount,
edgeCount,
fileCount,
nodesByKind,
edgesByKind,
filesByLanguage,
dbSizeBytes: 0, // Set by caller using DatabaseConnection.getSize()
lastUpdated: Date.now(),
};
}
/**
* Clear all data from the database
*/
clear(): void {
this.nodeCache.clear();
this.db.transaction(() => {
this.db.exec('DELETE FROM unresolved_refs');
this.db.exec('DELETE FROM vectors');
this.db.exec('DELETE FROM edges');
this.db.exec('DELETE FROM nodes');
this.db.exec('DELETE FROM files');
})();
}
}
+149
View File
@@ -0,0 +1,149 @@
-- CodeGraph SQLite Schema
-- Version 1
-- Schema version tracking
CREATE TABLE IF NOT EXISTS schema_versions (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL,
description TEXT
);
-- Insert initial version
INSERT INTO schema_versions (version, applied_at, description)
VALUES (1, strftime('%s', 'now') * 1000, 'Initial schema');
-- =============================================================================
-- Core Tables
-- =============================================================================
-- Nodes: Code symbols (functions, classes, variables, etc.)
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
name TEXT NOT NULL,
qualified_name TEXT NOT NULL,
file_path TEXT NOT NULL,
language TEXT NOT NULL,
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL,
start_column INTEGER NOT NULL,
end_column INTEGER NOT NULL,
docstring TEXT,
signature TEXT,
visibility TEXT,
is_exported INTEGER DEFAULT 0,
is_async INTEGER DEFAULT 0,
is_static INTEGER DEFAULT 0,
is_abstract INTEGER DEFAULT 0,
decorators TEXT, -- JSON array
type_parameters TEXT, -- JSON array
updated_at INTEGER NOT NULL
);
-- Edges: Relationships between nodes
CREATE TABLE IF NOT EXISTS edges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
target TEXT NOT NULL,
kind TEXT NOT NULL,
metadata TEXT, -- JSON object
line INTEGER,
col INTEGER,
FOREIGN KEY (source) REFERENCES nodes(id) ON DELETE CASCADE,
FOREIGN KEY (target) REFERENCES nodes(id) ON DELETE CASCADE
);
-- Files: Tracked source files
CREATE TABLE IF NOT EXISTS files (
path TEXT PRIMARY KEY,
content_hash TEXT NOT NULL,
language TEXT NOT NULL,
size INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
indexed_at INTEGER NOT NULL,
node_count INTEGER DEFAULT 0,
errors TEXT -- JSON array
);
-- Unresolved References: References that need resolution after full indexing
CREATE TABLE IF NOT EXISTS unresolved_refs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_node_id TEXT NOT NULL,
reference_name TEXT NOT NULL,
reference_kind TEXT NOT NULL,
line INTEGER NOT NULL,
col INTEGER NOT NULL,
candidates TEXT, -- JSON array
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
-- =============================================================================
-- Indexes for Query Performance
-- =============================================================================
-- Node indexes
CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind);
CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
CREATE INDEX IF NOT EXISTS idx_nodes_qualified_name ON nodes(qualified_name);
CREATE INDEX IF NOT EXISTS idx_nodes_file_path ON nodes(file_path);
CREATE INDEX IF NOT EXISTS idx_nodes_language ON nodes(language);
CREATE INDEX IF NOT EXISTS idx_nodes_file_line ON nodes(file_path, start_line);
-- Full-text search index on node names and docstrings
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
id,
name,
qualified_name,
docstring,
content='nodes',
content_rowid='rowid'
);
-- Triggers to keep FTS index in sync
CREATE TRIGGER IF NOT EXISTS nodes_ai AFTER INSERT ON nodes BEGIN
INSERT INTO nodes_fts(rowid, id, name, qualified_name, docstring)
VALUES (NEW.rowid, NEW.id, NEW.name, NEW.qualified_name, NEW.docstring);
END;
CREATE TRIGGER IF NOT EXISTS nodes_ad AFTER DELETE ON nodes BEGIN
INSERT INTO nodes_fts(nodes_fts, rowid, id, name, qualified_name, docstring)
VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.qualified_name, OLD.docstring);
END;
CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE ON nodes BEGIN
INSERT INTO nodes_fts(nodes_fts, rowid, id, name, qualified_name, docstring)
VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.qualified_name, OLD.docstring);
INSERT INTO nodes_fts(rowid, id, name, qualified_name, docstring)
VALUES (NEW.rowid, NEW.id, NEW.name, NEW.qualified_name, NEW.docstring);
END;
-- Edge indexes
CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source);
CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target);
CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind);
CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source, kind);
CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target, kind);
-- File indexes
CREATE INDEX IF NOT EXISTS idx_files_language ON files(language);
CREATE INDEX IF NOT EXISTS idx_files_modified_at ON files(modified_at);
-- Unresolved refs indexes
CREATE INDEX IF NOT EXISTS idx_unresolved_from_node ON unresolved_refs(from_node_id);
CREATE INDEX IF NOT EXISTS idx_unresolved_name ON unresolved_refs(reference_name);
-- =============================================================================
-- Vector Storage (for future semantic search)
-- =============================================================================
-- Vector embeddings for semantic search
-- Note: No foreign key constraint to allow standalone vector testing
-- The VectorManager handles node-vector relationship at the application level
CREATE TABLE IF NOT EXISTS vectors (
node_id TEXT PRIMARY KEY,
embedding BLOB NOT NULL, -- Float32 array stored as blob
model TEXT NOT NULL, -- Model used to generate embedding
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_vectors_model ON vectors(model);