-
Notifications
You must be signed in to change notification settings - Fork 15
Expand file tree
/
Copy pathconnection.ts
More file actions
345 lines (320 loc) · 11.3 KB
/
Copy pathconnection.ts
File metadata and controls
345 lines (320 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import Database from 'better-sqlite3';
import { debug, warn } from '../infrastructure/logger.js';
import { DbError } from '../shared/errors.js';
import type { BetterSqlite3Database, NativeDatabase } from '../types.js';
import { Repository } from './repository/base.js';
import { SqliteRepository } from './repository/sqlite-repository.js';
/** Lazy-loaded package version (read once from package.json). */
let _packageVersion: string | undefined;
function getPackageVersion(): string {
if (_packageVersion !== undefined) return _packageVersion;
try {
const connDir = path.dirname(fileURLToPath(import.meta.url));
const pkgPath = path.join(connDir, '..', '..', 'package.json');
_packageVersion = (JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as { version: string })
.version;
} catch {
_packageVersion = '';
}
return _packageVersion;
}
/** Warn once per process when DB version mismatches the running codegraph version. */
let _versionWarned = false;
/** DB instance with optional advisory lock path. */
export type LockedDatabase = BetterSqlite3Database & { __lockPath?: string };
let _cachedRepoRoot: string | null | undefined; // undefined = not computed, null = not a git repo
let _cachedRepoRootCwd: string | undefined; // cwd at the time the cache was populated
/**
* Return the git worktree/repo root for the given directory (or cwd).
* Uses `git rev-parse --show-toplevel` which returns the correct root
* for both regular repos and git worktrees.
* Results are cached per-process when called without arguments.
* The cache is keyed on cwd so it invalidates if the working directory changes
* (e.g. MCP server serving multiple sessions).
*/
export function findRepoRoot(fromDir?: string): string | null {
const dir = fromDir || process.cwd();
if (!fromDir && _cachedRepoRoot !== undefined && _cachedRepoRootCwd === dir) {
return _cachedRepoRoot;
}
let root: string | null = null;
try {
const raw = execFileSync('git', ['rev-parse', '--show-toplevel'], {
cwd: dir,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
// Use realpathSync to resolve symlinks (macOS /var → /private/var) and
// 8.3 short names (Windows RUNNER~1 → runneradmin) so the ceiling path
// matches the realpathSync'd dir in findDbPath.
try {
root = fs.realpathSync(raw);
} catch (e) {
debug(`realpathSync failed for git root "${raw}", using resolve: ${(e as Error).message}`);
root = path.resolve(raw);
}
} catch (e) {
debug(`git rev-parse failed for "${dir}": ${(e as Error).message}`);
root = null;
}
if (!fromDir) {
_cachedRepoRoot = root;
_cachedRepoRootCwd = dir;
}
return root;
}
/** Reset the cached repo root (for testing). */
export function _resetRepoRootCache(): void {
_cachedRepoRoot = undefined;
_cachedRepoRootCwd = undefined;
}
/** Reset the version warning flag (for testing). */
export function _resetVersionWarning(): void {
_versionWarned = false;
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (e) {
debug(`PID ${pid} not alive: ${(e as NodeJS.ErrnoException).code || (e as Error).message}`);
return false;
}
}
function acquireAdvisoryLock(dbPath: string): void {
const lockPath = `${dbPath}.lock`;
try {
if (fs.existsSync(lockPath)) {
const content = fs.readFileSync(lockPath, 'utf-8').trim();
const pid = Number(content);
if (pid && pid !== process.pid && isProcessAlive(pid)) {
warn(`Another process (PID ${pid}) may be using this database. Proceeding with caution.`);
}
}
} catch (e) {
debug(`Advisory lock read failed: ${(e as Error).message}`);
}
try {
fs.writeFileSync(lockPath, String(process.pid), 'utf-8');
} catch (e) {
debug(`Advisory lock write failed: ${(e as Error).message}`);
}
}
function releaseAdvisoryLock(lockPath: string): void {
try {
const content = fs.readFileSync(lockPath, 'utf-8').trim();
if (Number(content) === process.pid) {
fs.unlinkSync(lockPath);
}
} catch (e) {
debug(`Advisory lock release failed for ${lockPath}: ${(e as Error).message}`);
}
}
/**
* Check if two paths refer to the same directory.
* Handles Windows 8.3 short names (RUNNER~1 vs runneradmin) and macOS
* symlinks (/tmp vs /private/tmp) where string comparison fails.
*/
function isSameDirectory(a: string, b: string): boolean {
if (path.resolve(a) === path.resolve(b)) return true;
try {
const sa = fs.statSync(a);
const sb = fs.statSync(b);
return sa.dev === sb.dev && sa.ino === sb.ino;
} catch (e) {
debug(`isSameDirectory stat failed: ${(e as Error).message}`);
return false;
}
}
export function openDb(dbPath: string): LockedDatabase {
// Flush any deferred DB close from a previous build (avoids WAL contention)
flushDeferredClose();
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
acquireAdvisoryLock(dbPath);
const db = new Database(dbPath) as unknown as LockedDatabase;
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
db.__lockPath = `${dbPath}.lock`;
return db;
}
export function closeDb(db: LockedDatabase): void {
db.close();
if (db.__lockPath) releaseAdvisoryLock(db.__lockPath);
}
/** Pending deferred-close DB handles (not yet closed). */
const _deferredDbs: LockedDatabase[] = [];
/**
* Synchronously close any DB handles queued by `closeDbDeferred()`.
* Call before deleting DB files or in test teardown to avoid EBUSY on Windows.
*/
export function flushDeferredClose(): void {
while (_deferredDbs.length > 0) {
const db = _deferredDbs.pop()!;
try {
db.close();
} catch {
/* ignore — handle may already be closed */
}
}
}
/**
* Schedule DB close on the next event loop tick. Useful for incremental
* builds where the WAL checkpoint in db.close() is expensive (~250ms on
* Windows) and doesn't need to block the caller.
*
* The advisory lock is released immediately so subsequent opens succeed.
* The actual handle close (+ WAL checkpoint) happens asynchronously.
* Call `flushDeferredClose()` before deleting the DB file.
*/
export function closeDbDeferred(db: LockedDatabase): void {
// Release the advisory lock immediately so the next open can proceed
if (db.__lockPath) {
releaseAdvisoryLock(db.__lockPath);
db.__lockPath = undefined;
}
_deferredDbs.push(db);
// Defer the expensive WAL checkpoint to after the caller returns
setImmediate(() => {
const idx = _deferredDbs.indexOf(db);
if (idx !== -1) {
_deferredDbs.splice(idx, 1);
try {
db.close();
} catch {
/* ignore — handle may already be closed by flush */
}
}
});
}
// ── Paired close helpers (Phase 6.16) ──────────────────────────────────
// When both a NativeDatabase and better-sqlite3 handle are open on the same
// DB file, these helpers ensure NativeDatabase is closed first (fast, ~1ms)
// before the better-sqlite3 close (which forces a WAL checkpoint, ~250ms).
/** A better-sqlite3 handle optionally paired with a NativeDatabase. */
export interface LockedDatabasePair {
db: LockedDatabase;
nativeDb?: NativeDatabase;
}
/** Close both handles: NativeDatabase first (fast), then better-sqlite3 (releases lock). */
export function closeDbPair(pair: LockedDatabasePair): void {
if (pair.nativeDb) {
try {
pair.nativeDb.close();
} catch {
/* ignore */
}
}
closeDb(pair.db);
}
/** Close NativeDatabase immediately, defer better-sqlite3 WAL checkpoint. */
export function closeDbPairDeferred(pair: LockedDatabasePair): void {
if (pair.nativeDb) {
try {
pair.nativeDb.close();
} catch {
/* ignore */
}
}
closeDbDeferred(pair.db);
}
export function findDbPath(customPath?: string): string {
if (customPath) return path.resolve(customPath);
const rawCeiling = findRepoRoot();
// Normalize ceiling with realpathSync to resolve 8.3 short names (Windows
// RUNNER~1 → runneradmin) and symlinks (macOS /var → /private/var).
// findRepoRoot already applies realpathSync internally, but the git output
// may still contain short names on some Windows CI environments.
let ceiling: string | null;
if (rawCeiling) {
try {
ceiling = fs.realpathSync(rawCeiling);
} catch (e) {
debug(`realpathSync failed for ceiling "${rawCeiling}": ${(e as Error).message}`);
ceiling = rawCeiling;
}
} else {
ceiling = null;
}
// Resolve symlinks (e.g. macOS /var → /private/var) so dir matches ceiling from git
let dir: string;
try {
dir = fs.realpathSync(process.cwd());
} catch (e) {
debug(`realpathSync failed for cwd: ${(e as Error).message}`);
dir = process.cwd();
}
while (true) {
const candidate = path.join(dir, '.codegraph', 'graph.db');
if (fs.existsSync(candidate)) return candidate;
if (ceiling && isSameDirectory(dir, ceiling)) {
debug(`findDbPath: stopped at git ceiling ${ceiling}`);
break;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
const base = ceiling || process.cwd();
return path.join(base, '.codegraph', 'graph.db');
}
/** Open a database in readonly mode, with a user-friendly error if the DB doesn't exist. */
export function openReadonlyOrFail(customPath?: string): BetterSqlite3Database {
const dbPath = findDbPath(customPath);
if (!fs.existsSync(dbPath)) {
throw new DbError(
`No codegraph database found at ${dbPath}.\nRun "codegraph build" first to analyze your codebase.`,
{ file: dbPath },
);
}
const db = new Database(dbPath, { readonly: true }) as unknown as BetterSqlite3Database;
// Warn once per process if the DB was built with a different codegraph version
if (!_versionWarned) {
try {
const row = db
.prepare<{ value: string }>('SELECT value FROM build_meta WHERE key = ?')
.get('codegraph_version');
const buildVersion = row?.value;
const currentVersion = getPackageVersion();
if (buildVersion && currentVersion && buildVersion !== currentVersion) {
warn(
`DB was built with codegraph v${buildVersion}, running v${currentVersion}. Consider: codegraph build --no-incremental`,
);
}
} catch {
// build_meta table may not exist in older DBs — silently ignore
}
_versionWarned = true;
}
return db;
}
/**
* Open a Repository from either an injected instance or a DB path.
*
* When `opts.repo` is a Repository instance, returns it directly (no DB opened).
* Otherwise opens a readonly SQLite DB and wraps it in SqliteRepository.
*/
export function openRepo(
customDbPath?: string,
opts: { repo?: Repository } = {},
): { repo: Repository; close(): void } {
if (opts.repo != null) {
if (!(opts.repo instanceof Repository)) {
throw new TypeError(
`openRepo: opts.repo must be a Repository instance, got ${Object.prototype.toString.call(opts.repo)}`,
);
}
return { repo: opts.repo, close() {} };
}
const db = openReadonlyOrFail(customDbPath);
return {
repo: new SqliteRepository(db),
close() {
db.close();
},
};
}