diff --git a/src/domain/graph/builder/pipeline.ts b/src/domain/graph/builder/pipeline.ts index 2d67624f..6908cf4b 100644 --- a/src/domain/graph/builder/pipeline.ts +++ b/src/domain/graph/builder/pipeline.ts @@ -109,8 +109,10 @@ function setupPipeline(ctx: PipelineContext): void { } catch (err) { warn(`NativeDatabase init failed, falling back to JS: ${(err as Error).message}`); ctx.nativeDb = undefined; - initSchema(ctx.db); } + // Always run JS initSchema so better-sqlite3 sees the schema — + // nativeDb is closed before pipeline stages run (dual-connection guard). + initSchema(ctx.db); } else { initSchema(ctx.db); } @@ -156,6 +158,18 @@ function formatTimingResult(ctx: PipelineContext): BuildResult { // ── Pipeline stages execution ─────────────────────────────────────────── async function runPipelineStages(ctx: PipelineContext): Promise { + // Prevent dual-connection WAL corruption: when both better-sqlite3 (ctx.db) + // and rusqlite (ctx.nativeDb) are open to the same file, Rust writes corrupt + // the DB. Clear nativeDb so all stages use JS fallback paths. See #694. + if (ctx.db && ctx.nativeDb) { + try { + (ctx.nativeDb as { close?: () => void }).close?.(); + } catch { + /* ignore close errors */ + } + ctx.nativeDb = undefined; + } + await collectFiles(ctx); await detectChanges(ctx); diff --git a/src/domain/graph/builder/stages/insert-nodes.ts b/src/domain/graph/builder/stages/insert-nodes.ts index 32cebfd5..816c402b 100644 --- a/src/domain/graph/builder/stages/insert-nodes.ts +++ b/src/domain/graph/builder/stages/insert-nodes.ts @@ -39,6 +39,12 @@ interface PrecomputedFileData { // ── Native fast-path ───────────────────────────────────────────────── function tryNativeInsert(ctx: PipelineContext): boolean { + // Disabled: bulkInsertNodes corrupts the DB when both the JS (better-sqlite3) + // and Rust (rusqlite) connections are open to the same WAL-mode file. + // The native path was never operational before — it always crashed on null + // visibility serialisation. See #694 for the dual-connection fix. + if (ctx.db) return false; + // Use NativeDatabase persistent connection (Phase 6.15+). // Standalone napi functions were removed in 6.17 — falls through to JS if nativeDb unavailable. if (!ctx.nativeDb?.bulkInsertNodes) return false; @@ -52,14 +58,14 @@ function tryNativeInsert(ctx: PipelineContext): boolean { name: string; kind: string; line: number; - endLine?: number | null; - visibility?: string | null; + endLine?: number; + visibility?: string; children: Array<{ name: string; kind: string; line: number; - endLine?: number | null; - visibility?: string | null; + endLine?: number; + visibility?: string; }>; }>; exports: Array<{ name: string; kind: string; line: number }>; @@ -72,14 +78,14 @@ function tryNativeInsert(ctx: PipelineContext): boolean { name: def.name, kind: def.kind, line: def.line, - endLine: def.endLine ?? null, - visibility: def.visibility ?? null, + endLine: def.endLine ?? undefined, + visibility: def.visibility ?? undefined, children: (def.children ?? []).map((c) => ({ name: c.name, kind: c.kind, line: c.line, - endLine: c.endLine ?? null, - visibility: c.visibility ?? null, + endLine: c.endLine ?? undefined, + visibility: c.visibility ?? undefined, })), })), exports: symbols.exports.map((exp) => ({ @@ -340,11 +346,16 @@ export async function insertNodes(ctx: PipelineContext): Promise { const t0 = performance.now(); // Try native Rust path first — single transaction, no JS↔C overhead - if (ctx.engineName === 'native' && tryNativeInsert(ctx)) { - ctx.timing.insertMs = performance.now() - t0; - - // Removed-file hash cleanup is handled inside the native call - return; + if (ctx.engineName === 'native') { + try { + if (tryNativeInsert(ctx)) { + ctx.timing.insertMs = performance.now() - t0; + // Removed-file hash cleanup is handled inside the native call + return; + } + } catch { + // Native insert failed — fall through to JS implementation + } } // JS fallback diff --git a/src/features/sequence.ts b/src/features/sequence.ts index 04fa575f..aa891d78 100644 --- a/src/features/sequence.ts +++ b/src/features/sequence.ts @@ -1,10 +1,11 @@ +import Database from 'better-sqlite3'; import { openRepo, type Repository } from '../db/index.js'; import { SqliteRepository } from '../db/repository/sqlite-repository.js'; import { findMatchingNodes } from '../domain/queries.js'; import { loadConfig } from '../infrastructure/config.js'; import { isTestFile } from '../infrastructure/test-filter.js'; import { paginateResult } from '../shared/paginate.js'; -import type { CodegraphConfig, NodeRowWithFanIn } from '../types.js'; +import type { BetterSqlite3Database, CodegraphConfig, NodeRowWithFanIn } from '../types.js'; import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; // ─── Alias generation ──────────────────────────────────────────────── @@ -150,12 +151,34 @@ function annotateDataflow( repo: Repository, messages: SequenceMessage[], idToNode: Map, + dbPath?: string, ): void { const hasTable = repo.hasDataflowTable(); + if (!hasTable) return; + + let db: BetterSqlite3Database; + let ownDb = false; + if (repo instanceof SqliteRepository) { + db = repo.db; + } else if (dbPath) { + db = new Database(dbPath, { readonly: true }) as unknown as BetterSqlite3Database; + ownDb = true; + } else { + return; + } - if (!hasTable || !(repo instanceof SqliteRepository)) return; + try { + _annotateDataflowImpl(db, messages, idToNode); + } finally { + if (ownDb) db.close(); + } +} - const db = repo.db; +function _annotateDataflowImpl( + db: BetterSqlite3Database, + messages: SequenceMessage[], + idToNode: Map, +): void { const nodeByNameFile = new Map(); for (const n of idToNode.values()) { nodeByNameFile.set(`${n.name}|${n.file}`, n); @@ -308,7 +331,7 @@ export function sequenceData( ); if (opts.dataflow && messages.length > 0) { - annotateDataflow(repo, messages, idToNode); + annotateDataflow(repo, messages, idToNode, dbPath); } messages.sort((a, b) => {