diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index de1891c..c15126c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,30 +12,9 @@ import { buildBaselineFileRecord, createDefaultExtractorRegistry } from "./extra import type { ExtractorRegistry } from "./extractors/types.js"; import { detectLanguage } from "./parser/extractor.js"; import { parseSource } from "./parser/grammars.js"; -import { getCoverageReport } from "./query/coverage.js"; -import { - neighborhood, - type NeighborhoodOptions, - type NeighborhoodResult, -} from "./query/neighborhood.js"; -import { getAllFilesQuery } from "./query/files.js"; -import { getPresentEdgeKinds } from "./query/presentEdgeKinds.js"; -import { querySessionSummary } from "./query/sessionSummary.js"; -import { getWorkspaceSubgraph } from "./query/subgraph.js"; -import { getSymbolsForFile } from "./query/symbols.js"; -import { clearAll, clearFile, clearWorkspace } from "./storage/clear.js"; -import { openDatabase, type DatabaseHandle } from "./storage/db.js"; -import { - replaceFileGraph, - replaceWorkspaceFrameworks, - resolveWorkspaceCrossFileEdges, - stampResolutionTier, - synthesizeFolderTree, - setFileFramework, -} from "./storage/repository.js"; -import { applyMigrations } from "./storage/migrations/runner.js"; -import { initializeSchema } from "./storage/schema.js"; -import { validateWorkspaceCache, writeWorkspaceCacheSnapshot } from "./storage/workspaceCache.js"; +import type { NeighborhoodOptions, NeighborhoodResult } from "./query/neighborhood.js"; +import { openDatabase } from "./storage/db.js"; +import { DuckDbGraphRepository } from "./storage/adapters/duckdbGraphRepository.js"; import { SCHEMA_VERSION, type ClearAllSummary, @@ -110,9 +89,6 @@ export type { ExtractorRegistry, KnownSymbol, } from "./extractors/types.js"; -export { getPresentEdgeKinds } from "./query/presentEdgeKinds.js"; -export { getCoverageReport } from "./query/coverage.js"; -export { neighborhood } from "./query/neighborhood.js"; export type { CallResolver, ResolvedEdge, @@ -122,7 +98,8 @@ export type { PreciseLocationResolver, } from "./resolution/types.js"; export type { ForeignWorkspaceGraph, WorkspaceIndexSummary } from "./storage/workspaceRegistry.js"; -export { readWorkspaceGraph, readWorkspaceIndexSummary } from "./storage/workspaceRegistry.js"; +export type { ForeignGraphReader, GraphRepository } from "./storage/graphRepository.js"; +export { DuckDbForeignGraphReader } from "./storage/adapters/duckdbGraphRepository.js"; // NOTE: Lens utilities are NOT re-exported here. They're published via the // `@dextree/core/lenses` subpath export so webview bundlers (Rollup/Vite) @@ -144,7 +121,7 @@ export class SchemaError extends Error { } class DuckTreeIndexer implements Indexer { - private databaseHandle: DatabaseHandle | null = null; + private repo: DuckDbGraphRepository | null = null; private initializationPromise: Promise | null = null; private readonly registry: ExtractorRegistry; private readonly frameworkCache = new Map(); @@ -172,9 +149,10 @@ class DuckTreeIndexer implements Indexer { await mkdir(dirname(this.dbPath), { recursive: true }); } - this.databaseHandle = await openDatabase(this.dbPath); - await initializeSchema(this.databaseHandle.connection); - const migrationResult = await applyMigrations(this.databaseHandle.connection, this.logger); + const handle = await openDatabase(this.dbPath); + this.repo = new DuckDbGraphRepository(handle, this.logger); + await this.repo.initializeSchema(); + const migrationResult = await this.repo.applyMigrations(); if (migrationResult.status === "failed") { throw new SchemaError(migrationResult.reason); } @@ -221,7 +199,7 @@ class DuckTreeIndexer implements Indexer { const startedAt = Date.now(); await this.initialize(); - const database = this.requireDatabaseHandle(); + const repo = this.requireRepo(); const language = detectLanguage(absolutePath); const source = await readFile(absolutePath, "utf8"); @@ -299,27 +277,20 @@ class DuckTreeIndexer implements Indexer { ); } - await replaceFileGraph( - database.connection, - extracted, - extraEdges, - classifications, - result.annotations ?? [], - ); + await repo.replaceFileGraph(extracted, extraEdges, classifications, result.annotations ?? []); if (detected.length > 0) { - await setFileFramework( - database.connection, + await repo.setFileFramework( extracted.file.id, attribution?.framework ?? null, attribution?.role ?? null, ); } - const files = await getAllFilesQuery(database.connection); - const graph = await getWorkspaceSubgraph(database.connection, workspaceRoot); + const files = await repo.getAllFiles(); + const graph = await repo.getWorkspaceSubgraph(workspaceRoot); - await writeWorkspaceCacheSnapshot(database.connection, { + await repo.writeWorkspaceCacheSnapshot({ identity: cacheIdentity ?? { cacheKey: workspaceRoot, workspaceRoot, @@ -332,7 +303,7 @@ class DuckTreeIndexer implements Indexer { graphEdgeCount: graph.edges.length, }); - const symbols = await getSymbolsForFile(database.connection, extracted.file.relativePath); + const symbols = await repo.getSymbolsForFile(extracted.file.relativePath); const elapsedMs = Date.now() - startedAt; this.logger?.debug("indexFile complete", { @@ -354,11 +325,10 @@ class DuckTreeIndexer implements Indexer { async detectWorkspaceFrameworks(workspaceRoot: string): Promise { await this.initialize(); - const database = this.requireDatabaseHandle(); + const repo = this.requireRepo(); const detected = await detectFrameworks(createNodeFsIO(workspaceRoot, this.logger)); this.frameworkCache.set(workspaceRoot, detected); - await replaceWorkspaceFrameworks( - database.connection, + await repo.replaceWorkspaceFrameworks( detected.map((row) => ({ frameworkName: row.frameworkName, detectionSource: row.detectionSource, @@ -374,11 +344,11 @@ class DuckTreeIndexer implements Indexer { async finalizeWorkspace(workspaceRoot: string): Promise { await this.initialize(); - const database = this.requireDatabaseHandle(); - await resolveWorkspaceCrossFileEdges(database.connection, workspaceRoot); - await synthesizeFolderTree(database.connection); + const repo = this.requireRepo(); + await repo.resolveWorkspaceCrossFileEdges(workspaceRoot); + await repo.synthesizeFolderTree(); // Stamp tiers again so the just-created CONTAINS edges get a tier too. - await stampResolutionTier(database.connection); + await repo.stampResolutionTier(); this.logger?.info("Finalized workspace cross-file edges + folder tree", { workspaceRoot }); } @@ -389,86 +359,86 @@ class DuckTreeIndexer implements Indexer { throw new Error("neighborhood: nodeId must be a non-empty string"); } await this.initialize(); - const database = this.requireDatabaseHandle(); - return neighborhood(database.connection, nodeId, options); + const repo = this.requireRepo(); + return repo.neighborhood(nodeId, options); } async validateWorkspaceCache(identity: WorkspaceCacheIdentity) { await this.initialize(); - const database = this.requireDatabaseHandle(); - return validateWorkspaceCache(database.connection, identity); + const repo = this.requireRepo(); + return repo.validateWorkspaceCache(identity); } async getSymbols(relativePath: string): Promise { await this.initialize(); - const database = this.requireDatabaseHandle(); - return getSymbolsForFile(database.connection, relativePath); + const repo = this.requireRepo(); + return repo.getSymbolsForFile(relativePath); } async getAllFiles(): Promise { await this.initialize(); - const database = this.requireDatabaseHandle(); - return getAllFilesQuery(database.connection); + const repo = this.requireRepo(); + return repo.getAllFiles(); } async getWorkspaceSubgraph(workspaceRoot: string) { await this.initialize(); - const database = this.requireDatabaseHandle(); - return getWorkspaceSubgraph(database.connection, workspaceRoot); + const repo = this.requireRepo(); + return repo.getWorkspaceSubgraph(workspaceRoot); } async getPresentEdgeKinds(workspaceRoot: string): Promise { await this.initialize(); - const database = this.requireDatabaseHandle(); - return getPresentEdgeKinds(database.connection, workspaceRoot); + const repo = this.requireRepo(); + return repo.getPresentEdgeKinds(workspaceRoot); } async getCoverageReport(): Promise { await this.initialize(); - const database = this.requireDatabaseHandle(); - return getCoverageReport(database.connection); + const repo = this.requireRepo(); + return repo.getCoverageReport(); } async getSessionSummary(workspaceRoot: string): Promise { await this.initialize(); - const database = this.requireDatabaseHandle(); - return querySessionSummary(database.connection, workspaceRoot); + const repo = this.requireRepo(); + return repo.getSessionSummary(workspaceRoot); } async clearWorkspace(workspaceRoot: string): Promise { await this.initialize(); - const database = this.requireDatabaseHandle(); + const repo = this.requireRepo(); this.frameworkCache.delete(workspaceRoot); - return clearWorkspace(database.connection, workspaceRoot); + return repo.clearWorkspace(workspaceRoot); } async clearFile(filePath: string): Promise { await this.initialize(); - const database = this.requireDatabaseHandle(); - return clearFile(database.connection, filePath); + const repo = this.requireRepo(); + return repo.clearFile(filePath); } async clearAll(): Promise { await this.initialize(); - const database = this.requireDatabaseHandle(); - return clearAll(database.connection); + const repo = this.requireRepo(); + return repo.clearAll(); } async dispose(): Promise { - if (this.databaseHandle !== null) { - this.databaseHandle.close(); - this.databaseHandle = null; + if (this.repo !== null) { + this.repo.dispose(); + this.repo = null; } this.initializationPromise = null; } - private requireDatabaseHandle(): DatabaseHandle { - if (this.databaseHandle === null) { + private requireRepo(): DuckDbGraphRepository { + if (this.repo === null) { throw new Error("Indexer has not been initialized"); } - return this.databaseHandle; + return this.repo; } } diff --git a/packages/core/src/storage/adapters/duckdbGraphRepository.test.ts b/packages/core/src/storage/adapters/duckdbGraphRepository.test.ts new file mode 100644 index 0000000..e0a9ae1 --- /dev/null +++ b/packages/core/src/storage/adapters/duckdbGraphRepository.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; + +import type { ExtractedIndexData } from "../../types.js"; +import { openDatabase } from "../db.js"; +import { DuckDbGraphRepository } from "./duckdbGraphRepository.js"; + +function makeExtractedData(name: string, workspaceRoot = "/workspace"): ExtractedIndexData { + const fileId = `file-${name}`; + return { + file: { + id: fileId, + path: `${workspaceRoot}/src/${name}.ts`, + relativePath: `src/${name}.ts`, + language: "typescript", + loc: 3, + hash: `hash-${name}`, + }, + symbols: [ + { + id: `symbol-${name}`, + fqn: `src/${name}.ts:${name}`, + name, + kind: "function", + fileId, + range: { startLine: 0, startCol: 0, endLine: 0, endCol: 10 }, + language: "typescript", + }, + ], + imports: [], + }; +} + +async function freshRepo() { + const database = await openDatabase(":memory:"); + const repo = new DuckDbGraphRepository(database); + await repo.initializeSchema(); + return { database, repo }; +} + +describe("DuckDbGraphRepository (real DuckDB)", () => { + it("write + read round-trips through the adapter without a passed connection", async () => { + const { database, repo } = await freshRepo(); + try { + await repo.replaceFileGraph(makeExtractedData("alpha")); + + const files = await repo.getAllFiles(); + expect(files.map((f) => f.relativePath)).toEqual(["src/alpha.ts"]); + + const symbols = await repo.getSymbolsForFile("src/alpha.ts"); + expect(symbols.map((s) => s.name)).toEqual(["alpha"]); + + const subgraph = await repo.getWorkspaceSubgraph("/workspace"); + expect(subgraph.nodes.length).toBeGreaterThan(0); + } finally { + database.close(); + } + }); + + it("delegates clearWorkspace to the same result the bare function returns", async () => { + const { database, repo } = await freshRepo(); + try { + await repo.replaceFileGraph(makeExtractedData("alpha")); + await repo.replaceFileGraph(makeExtractedData("beta")); + + const summary = await repo.clearWorkspace("/workspace"); + // deletedEdges is 2: replaceFileGraph synthesizes one DEFINES edge per file. + expect(summary).toEqual({ deletedFiles: 2, deletedSymbols: 2, deletedEdges: 2 }); + + expect(await repo.getAllFiles()).toHaveLength(0); + } finally { + database.close(); + } + }); + + it("clearFile removes a single file's symbols", async () => { + const { database, repo } = await freshRepo(); + try { + await repo.replaceFileGraph(makeExtractedData("alpha")); + await repo.replaceFileGraph(makeExtractedData("beta")); + + // clearFile matches on the absolute stored path (what the watcher passes), + // not the relative path the read methods use. + const summary = await repo.clearFile("/workspace/src/alpha.ts"); + expect(summary.deletedFiles).toBe(1); + expect((await repo.getAllFiles()).map((f) => f.relativePath)).toEqual(["src/beta.ts"]); + } finally { + database.close(); + } + }); + + it("getCoverageReport and getPresentEdgeKinds run via the adapter", async () => { + const { database, repo } = await freshRepo(); + try { + await repo.replaceFileGraph(makeExtractedData("alpha")); + await expect(repo.getCoverageReport()).resolves.toBeDefined(); + await expect(repo.getPresentEdgeKinds("/workspace")).resolves.toBeInstanceOf(Array); + } finally { + database.close(); + } + }); + + it("dispose() closes the underlying handle", async () => { + const database = await openDatabase(":memory:"); + let closed = false; + const closeSpy = database.close.bind(database); + database.close = () => { + closed = true; + closeSpy(); + }; + const repo = new DuckDbGraphRepository(database); + repo.dispose(); + expect(closed).toBe(true); + }); +}); diff --git a/packages/core/src/storage/adapters/duckdbGraphRepository.ts b/packages/core/src/storage/adapters/duckdbGraphRepository.ts new file mode 100644 index 0000000..b7e3919 --- /dev/null +++ b/packages/core/src/storage/adapters/duckdbGraphRepository.ts @@ -0,0 +1,163 @@ +import type { EdgeRow } from "../../extractors/types.js"; +import type { Logger } from "../../types.js"; +import type { + CoverageReport, + ExtractedIndexData, + NeighborhoodOptions, + NeighborhoodResult, + SessionSummary, + StoredFile, + StoredSymbol, + SymbolClassificationRecord, + WorkspaceCacheIdentity, + WorkspaceCacheValidation, + WorkspaceSubgraph, +} from "../../types.js"; +import { getCoverageReport } from "../../query/coverage.js"; +import { getAllFilesQuery } from "../../query/files.js"; +import { neighborhood } from "../../query/neighborhood.js"; +import { getPresentEdgeKinds } from "../../query/presentEdgeKinds.js"; +import { querySessionSummary } from "../../query/sessionSummary.js"; +import { getSymbolsForFile } from "../../query/symbols.js"; +import { getWorkspaceSubgraph } from "../../query/subgraph.js"; +import { clearAll, clearFile, clearWorkspace } from "../clear.js"; +import type { ClearAllResult, ClearFileResult, ClearWorkspaceResult } from "../clear.js"; +import type { ForeignGraphReader, GraphRepository } from "../graphRepository.js"; +import { applyMigrations } from "../migrations/runner.js"; +import type { MigrationResult } from "../migrations/runner.js"; +import { replaceFileGraph, replaceWorkspaceFrameworks, setFileFramework } from "../repository.js"; +import type { DetectedFrameworkRow } from "../repository.js"; +import { resolveWorkspaceCrossFileEdges, stampResolutionTier } from "../resolution.js"; +import { synthesizeFolderTree } from "../folderTree.js"; +import { initializeSchema } from "../schema.js"; +import { writeWorkspaceCacheSnapshot, validateWorkspaceCache } from "../workspaceCache.js"; +import type { WriteWorkspaceCacheSnapshotInput } from "../workspaceCache.js"; +import { + readWorkspaceGraph, + readWorkspaceIndexSummary, + type ForeignWorkspaceGraph, + type WorkspaceIndexSummary, +} from "../workspaceRegistry.js"; +import type { DatabaseHandle } from "./duckdb.js"; + +/** + * DuckDB-backed {@link GraphRepository}. Holds the open {@link DatabaseHandle} and + * delegates each method to the existing connection-first storage/query functions, + * supplying the connection itself — so no consumer passes one. This is a thin + * relocation behind the interface (RULE-ARCH-003): zero behavior change versus + * calling the functions directly. + */ +export class DuckDbGraphRepository implements GraphRepository { + constructor( + private readonly handle: DatabaseHandle, + private readonly logger?: Logger, + ) {} + + private get connection() { + return this.handle.connection; + } + + initializeSchema(): Promise { + return initializeSchema(this.connection); + } + + applyMigrations(): Promise { + return applyMigrations(this.connection, this.logger); + } + + replaceFileGraph( + input: ExtractedIndexData, + extraEdges?: readonly EdgeRow[], + classifications?: ReadonlyMap, + annotations?: readonly unknown[], + ): Promise { + return replaceFileGraph(this.connection, input, extraEdges, classifications, annotations); + } + + setFileFramework(fileId: string, framework: string | null, role: string | null): Promise { + return setFileFramework(this.connection, fileId, framework, role); + } + + replaceWorkspaceFrameworks(rows: readonly DetectedFrameworkRow[]): Promise { + return replaceWorkspaceFrameworks(this.connection, rows); + } + + resolveWorkspaceCrossFileEdges(workspaceRoot: string): Promise { + return resolveWorkspaceCrossFileEdges(this.connection, workspaceRoot); + } + + stampResolutionTier(): Promise { + return stampResolutionTier(this.connection); + } + + synthesizeFolderTree(): Promise { + return synthesizeFolderTree(this.connection); + } + + getWorkspaceSubgraph(workspaceRoot: string): Promise { + return getWorkspaceSubgraph(this.connection, workspaceRoot); + } + + getSymbolsForFile(relativePath: string): Promise { + return getSymbolsForFile(this.connection, relativePath); + } + + getAllFiles(): Promise { + return getAllFilesQuery(this.connection); + } + + getPresentEdgeKinds(workspaceRoot: string): Promise { + return getPresentEdgeKinds(this.connection, workspaceRoot); + } + + getCoverageReport(): Promise { + return getCoverageReport(this.connection); + } + + getSessionSummary(workspaceRoot: string): Promise { + return querySessionSummary(this.connection, workspaceRoot); + } + + neighborhood(nodeId: string, options: NeighborhoodOptions): Promise { + return neighborhood(this.connection, nodeId, options); + } + + writeWorkspaceCacheSnapshot(input: WriteWorkspaceCacheSnapshotInput): Promise { + return writeWorkspaceCacheSnapshot(this.connection, input); + } + + validateWorkspaceCache(identity: WorkspaceCacheIdentity): Promise { + return validateWorkspaceCache(this.connection, identity); + } + + clearWorkspace(workspaceRoot: string): Promise { + return clearWorkspace(this.connection, workspaceRoot); + } + + clearFile(filePath: string): Promise { + return clearFile(this.connection, filePath); + } + + clearAll(): Promise { + return clearAll(this.connection); + } + + dispose(): void { + this.handle.close(); + } +} + +/** + * DuckDB-backed {@link ForeignGraphReader}. Stateless: the underlying functions + * each open the foreign database read-only by path and close it themselves, so + * there is no held connection. + */ +export class DuckDbForeignGraphReader implements ForeignGraphReader { + readWorkspaceIndexSummary(dbPath: string): Promise { + return readWorkspaceIndexSummary(dbPath); + } + + readWorkspaceGraph(dbPath: string, workspaceRoot: string): Promise { + return readWorkspaceGraph(dbPath, workspaceRoot); + } +} diff --git a/packages/core/src/storage/fakeGraphRepository.test.ts b/packages/core/src/storage/fakeGraphRepository.test.ts new file mode 100644 index 0000000..7982547 --- /dev/null +++ b/packages/core/src/storage/fakeGraphRepository.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; + +import type { ExtractedIndexData } from "../types.js"; +import { FakeGraphRepository } from "./fakeGraphRepository.js"; +import type { GraphRepository } from "./graphRepository.js"; + +function makeExtractedData(name: string, workspaceRoot = "/workspace"): ExtractedIndexData { + const fileId = `file-${name}`; + return { + file: { + id: fileId, + path: `${workspaceRoot}/src/${name}.ts`, + relativePath: `src/${name}.ts`, + language: "typescript", + loc: 3, + hash: `hash-${name}`, + }, + symbols: [ + { + id: `symbol-${name}`, + fqn: `src/${name}.ts:${name}`, + name, + kind: "function", + fileId, + range: { startLine: 0, startCol: 0, endLine: 0, endCol: 10 }, + language: "typescript", + }, + ], + imports: [], + }; +} + +describe("FakeGraphRepository (substitutability)", () => { + // The fake is consumed only through the interface — if it satisfies + // GraphRepository structurally and behaves, the repository is substitutable. + it("is usable wherever a GraphRepository is expected", async () => { + const repo: GraphRepository = new FakeGraphRepository(); + await repo.initializeSchema(); + expect((await repo.applyMigrations()).status).toBe("ok"); + + await repo.replaceFileGraph(makeExtractedData("alpha")); + await repo.replaceFileGraph(makeExtractedData("beta")); + + expect((await repo.getAllFiles()).map((f) => f.relativePath).sort()).toEqual([ + "src/alpha.ts", + "src/beta.ts", + ]); + expect((await repo.getSymbolsForFile("src/alpha.ts")).map((s) => s.name)).toEqual(["alpha"]); + + const summary = await repo.getSessionSummary("/workspace"); + expect(summary).toMatchObject({ workspaceName: "workspace", fileCount: 2, symbolCount: 2 }); + }); + + it("clears like the real adapter (workspace + by absolute path)", async () => { + const repo: GraphRepository = new FakeGraphRepository(); + await repo.replaceFileGraph(makeExtractedData("alpha")); + await repo.replaceFileGraph(makeExtractedData("beta")); + + expect(await repo.clearFile("/workspace/src/alpha.ts")).toMatchObject({ deletedFiles: 1 }); + expect((await repo.getAllFiles()).map((f) => f.relativePath)).toEqual(["src/beta.ts"]); + + expect(await repo.clearWorkspace("/workspace")).toMatchObject({ deletedFiles: 1 }); + expect(await repo.getAllFiles()).toHaveLength(0); + }); + + it("round-trips the workspace cache snapshot through validate", async () => { + const repo: GraphRepository = new FakeGraphRepository(); + const identity = { + cacheKey: "/workspace", + workspaceRoot: "/workspace", + repoRoot: null, + repoRemote: null, + }; + + expect((await repo.validateWorkspaceCache(identity)).status).toBe("missing"); + await repo.writeWorkspaceCacheSnapshot({ + identity, + schemaVersion: 1, + indexedFileCount: 0, + graphNodeCount: 0, + graphEdgeCount: 0, + }); + expect((await repo.validateWorkspaceCache(identity)).status).toBe("ready"); + }); +}); diff --git a/packages/core/src/storage/fakeGraphRepository.ts b/packages/core/src/storage/fakeGraphRepository.ts new file mode 100644 index 0000000..029001e --- /dev/null +++ b/packages/core/src/storage/fakeGraphRepository.ts @@ -0,0 +1,141 @@ +import type { EdgeRow } from "../extractors/types.js"; +import type { + CoverageReport, + ExtractedIndexData, + NeighborhoodOptions, + NeighborhoodResult, + SessionSummary, + StoredFile, + StoredSymbol, + SymbolClassificationRecord, + WorkspaceCacheIdentity, + WorkspaceCacheValidation, + WorkspaceSubgraph, +} from "../types.js"; +import type { ClearAllResult, ClearFileResult, ClearWorkspaceResult } from "./clear.js"; +import type { GraphRepository } from "./graphRepository.js"; +import type { MigrationResult } from "./migrations/runner.js"; +import type { DetectedFrameworkRow } from "./repository.js"; +import type { WriteWorkspaceCacheSnapshotInput } from "./workspaceCache.js"; + +/** + * In-memory {@link GraphRepository} for contract tests that need the interface + * without a DuckDB instance. It is a structural proof that the repository is + * substitutable (RULE-ARCH-003): the type checker enforces that this satisfies + * the whole interface, and it implements enough real behavior (per-file symbol + * storage, clears) for round-trip tests. Analytics-heavy reads return valid + * empty shapes — extend them in a test if a scenario needs richer behavior. + */ +export class FakeGraphRepository implements GraphRepository { + /** relativePath → file */ + private readonly files = new Map(); + /** relativePath → symbols */ + private readonly symbolsByFile = new Map(); + private readonly cacheSnapshots = new Map(); + + async initializeSchema(): Promise {} + + async applyMigrations(): Promise { + return { status: "ok", from: 0, to: 0, applied: [] }; + } + + async replaceFileGraph( + input: ExtractedIndexData, + _extraEdges?: readonly EdgeRow[], + _classifications?: ReadonlyMap, + _annotations?: readonly unknown[], + ): Promise { + const rel = input.file.relativePath; + this.files.set(rel, { + id: input.file.id, + relativePath: rel, + language: input.file.language, + path: input.file.path, + hash: input.file.hash, + }); + this.symbolsByFile.set(rel, [...input.symbols]); + } + + async setFileFramework(): Promise {} + + async replaceWorkspaceFrameworks(_rows: readonly DetectedFrameworkRow[]): Promise {} + + async resolveWorkspaceCrossFileEdges(): Promise {} + + async stampResolutionTier(): Promise {} + + async synthesizeFolderTree(): Promise {} + + async getWorkspaceSubgraph(): Promise { + return { nodes: [], edges: [], frameworks: [] }; + } + + async getSymbolsForFile(relativePath: string): Promise { + return [...(this.symbolsByFile.get(relativePath) ?? [])]; + } + + async getAllFiles(): Promise { + return [...this.files.values()]; + } + + async getPresentEdgeKinds(): Promise { + return []; + } + + async getCoverageReport(): Promise { + return { rows: [], totalEdges: 0, resolvedEdges: 0, resolvedRatio: 0 }; + } + + async getSessionSummary(workspaceRoot: string): Promise { + const fileCount = this.files.size; + const symbolCount = [...this.symbolsByFile.values()].reduce((n, s) => n + s.length, 0); + return { + workspaceName: workspaceRoot.split("/").pop() ?? workspaceRoot, + generatedAt: new Date(0), + fileCount, + symbolCount, + } as SessionSummary; + } + + async neighborhood(_nodeId: string, _options: NeighborhoodOptions): Promise { + return { nodes: [], edges: [], truncated: false }; + } + + async writeWorkspaceCacheSnapshot(input: WriteWorkspaceCacheSnapshotInput): Promise { + this.cacheSnapshots.set(input.identity.cacheKey, input); + } + + async validateWorkspaceCache( + identity: WorkspaceCacheIdentity, + ): Promise { + const has = this.cacheSnapshots.has(identity.cacheKey); + return { status: has ? "ready" : "missing", identity, metadata: null }; + } + + async clearWorkspace(_workspaceRoot: string): Promise { + const deletedFiles = this.files.size; + const deletedSymbols = [...this.symbolsByFile.values()].reduce((n, s) => n + s.length, 0); + this.files.clear(); + this.symbolsByFile.clear(); + return { deletedFiles, deletedSymbols, deletedEdges: 0 }; + } + + async clearFile(filePath: string): Promise { + // Mirrors the real adapter: matches on the absolute stored path. + const entry = [...this.files.values()].find((f) => f.path === filePath); + if (entry === undefined) return { deletedFiles: 0, deletedSymbols: 0, deletedEdges: 0 }; + const deletedSymbols = this.symbolsByFile.get(entry.relativePath)?.length ?? 0; + this.files.delete(entry.relativePath); + this.symbolsByFile.delete(entry.relativePath); + return { deletedFiles: 1, deletedSymbols, deletedEdges: 0 }; + } + + async clearAll(): Promise { + this.files.clear(); + this.symbolsByFile.clear(); + this.cacheSnapshots.clear(); + return { clearedTables: 0 }; + } + + dispose(): void {} +} diff --git a/packages/core/src/storage/graphRepository.ts b/packages/core/src/storage/graphRepository.ts new file mode 100644 index 0000000..e8b675c --- /dev/null +++ b/packages/core/src/storage/graphRepository.ts @@ -0,0 +1,85 @@ +import type { EdgeRow } from "../extractors/types.js"; +import type { + CoverageReport, + ExtractedIndexData, + NeighborhoodOptions, + NeighborhoodResult, + SessionSummary, + StoredFile, + StoredSymbol, + SymbolClassificationRecord, + WorkspaceCacheIdentity, + WorkspaceCacheValidation, + WorkspaceSubgraph, +} from "../types.js"; +import type { ClearAllResult, ClearFileResult, ClearWorkspaceResult } from "./clear.js"; +import type { MigrationResult } from "./migrations/runner.js"; +import type { DetectedFrameworkRow } from "./repository.js"; +import type { WriteWorkspaceCacheSnapshotInput } from "./workspaceCache.js"; +import type { ForeignWorkspaceGraph, WorkspaceIndexSummary } from "./workspaceRegistry.js"; + +/** + * The storage + query operations the domain depends on (RULE-ARCH-003). One + * method per persistence operation; the database connection is held by the + * implementing adapter and never appears in a signature, so no consumer touches + * a connection or the driver. The lens selectors / importance computations are + * deliberately absent — they operate on the in-memory graphology graph, not the + * database, so they stay pure functions, not repository methods. + * + * Foreign read-only access to *other* workspaces' database files is a separate + * port ({@link ForeignGraphReader}); those reads open arbitrary databases by + * path and are driven by the extension, not by the connection-owning repository. + */ +export interface GraphRepository { + // ---- lifecycle / schema ---- + initializeSchema(): Promise; + applyMigrations(): Promise; + + // ---- file writes (the indexing write path) ---- + replaceFileGraph( + input: ExtractedIndexData, + extraEdges?: readonly EdgeRow[], + classifications?: ReadonlyMap, + annotations?: readonly unknown[], + ): Promise; + setFileFramework(fileId: string, framework: string | null, role: string | null): Promise; + replaceWorkspaceFrameworks(rows: readonly DetectedFrameworkRow[]): Promise; + + // ---- resolution (the indexer drives all three directly during its pass) ---- + resolveWorkspaceCrossFileEdges(workspaceRoot: string): Promise; + stampResolutionTier(): Promise; + synthesizeFolderTree(): Promise; + + // ---- reads (the query surface consumed by the app + UI) ---- + getWorkspaceSubgraph(workspaceRoot: string): Promise; + getSymbolsForFile(relativePath: string): Promise; + getAllFiles(): Promise; + getPresentEdgeKinds(workspaceRoot: string): Promise; + getCoverageReport(): Promise; + getSessionSummary(workspaceRoot: string): Promise; + neighborhood(nodeId: string, options: NeighborhoodOptions): Promise; + + // ---- workspace cache ---- + writeWorkspaceCacheSnapshot(input: WriteWorkspaceCacheSnapshotInput): Promise; + validateWorkspaceCache(identity: WorkspaceCacheIdentity): Promise; + + // ---- clears ---- + clearWorkspace(workspaceRoot: string): Promise; + clearFile(filePath: string): Promise; + clearAll(): Promise; + + /** Release the underlying connection/instance. */ + dispose(): void; +} + +/** + * Read-only access to *other* workspaces' database files — the workspace + * switcher. Separate from {@link GraphRepository} because these reads open + * arbitrary databases addressed by `dbPath` (read-only, never mutating) and are + * driven by the extension, not the connection-owning repository. Conflating them + * would give one interface two lifecycle owners. + */ +export interface ForeignGraphReader { + readWorkspaceIndexSummary(dbPath: string): Promise; + readWorkspaceGraph(dbPath: string, workspaceRoot: string): Promise; +} diff --git a/packages/core/src/storage/index.ts b/packages/core/src/storage/index.ts index 63adbba..6872b6f 100644 --- a/packages/core/src/storage/index.ts +++ b/packages/core/src/storage/index.ts @@ -1,10 +1,16 @@ -export { openDatabase, readRows, runInTransaction } from "./db.js"; +// Storage layer barrel. Connection-first functions are intentionally NOT +// re-exported: the only supported entry point to storage is the GraphRepository +// interface and its DuckDB adapter (RULE-ARCH-003), so a connection never +// appears in the surface other modules import. export type { DatabaseHandle } from "./db.js"; -export { applyMigrations } from "./migrations/runner.js"; export type { MigrationResult, MigrationResultFailed, MigrationResultOk, } from "./migrations/runner.js"; -export { replaceFileGraph } from "./repository.js"; -export { initializeSchema, REQUIRED_TABLES, SCHEMA_STATEMENTS } from "./schema.js"; +export type { ForeignGraphReader, GraphRepository } from "./graphRepository.js"; +export { + DuckDbForeignGraphReader, + DuckDbGraphRepository, +} from "./adapters/duckdbGraphRepository.js"; +export { REQUIRED_TABLES, SCHEMA_STATEMENTS } from "./schema.js"; diff --git a/packages/extension/src/cache/workspaceRegistry.test.ts b/packages/extension/src/cache/workspaceRegistry.test.ts index 2b86bfc..6e66d23 100644 --- a/packages/extension/src/cache/workspaceRegistry.test.ts +++ b/packages/extension/src/cache/workspaceRegistry.test.ts @@ -15,14 +15,16 @@ import { type WorkspaceRegistry, } from "./workspaceRegistry.js"; +const { mockedReadSummary } = vi.hoisted(() => ({ mockedReadSummary: vi.fn() })); + vi.mock("@dextree/core", () => ({ - readWorkspaceIndexSummary: vi.fn(), + // The registry reads foreign summaries via a DuckDbForeignGraphReader instance; + // mock the class so its instance method is the shared spy. + DuckDbForeignGraphReader: class { + readWorkspaceIndexSummary = mockedReadSummary; + }, })); -import { readWorkspaceIndexSummary } from "@dextree/core"; - -const mockedReadSummary = vi.mocked(readWorkspaceIndexSummary); - let scratchDir: string; beforeEach(async () => { diff --git a/packages/extension/src/cache/workspaceRegistry.ts b/packages/extension/src/cache/workspaceRegistry.ts index 5c3a627..03a8fb3 100644 --- a/packages/extension/src/cache/workspaceRegistry.ts +++ b/packages/extension/src/cache/workspaceRegistry.ts @@ -7,7 +7,7 @@ * `storageUri` is workspace-scoped and offers no enumeration API. */ -import { readWorkspaceIndexSummary } from "@dextree/core"; +import { DuckDbForeignGraphReader } from "@dextree/core"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; @@ -84,9 +84,10 @@ export async function listIndexedWorkspaces( activeWorkspaceRoot: string, ): Promise { const registry = await readWorkspaceRegistry(globalStoragePath); + const foreignReader = new DuckDbForeignGraphReader(); const summaries = await Promise.all( registry.entries.map(async (entry) => { - const summary = await readWorkspaceIndexSummary(entry.dbPath); + const summary = await foreignReader.readWorkspaceIndexSummary(entry.dbPath); if (summary === null) return null; const record: IndexedWorkspaceRecord = { workspaceRoot: summary.workspaceRoot, diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index e8340c9..a3473db 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -1,4 +1,4 @@ -import { createIndexer, readWorkspaceGraph, type Indexer } from "@dextree/core"; +import { createIndexer, DuckDbForeignGraphReader, type Indexer } from "@dextree/core"; import { generateMermaidPreview, type MermaidPreviewResult } from "@dextree/exporters"; import { basename, join } from "node:path"; import * as vscode from "vscode"; @@ -199,7 +199,10 @@ export async function activate(context: ActivationContext): Promise { return; } - const result = await readWorkspaceGraph(entry.dbPath, targetWorkspaceRoot); + const result = await new DuckDbForeignGraphReader().readWorkspaceGraph( + entry.dbPath, + targetWorkspaceRoot, + ); if (result === null) { await vscode.window.showErrorMessage( `Could not open workspace ${basename(targetWorkspaceRoot)}. The index may be missing or corrupt.`,