gitnexus 1.6.4-rc.26 → 1.6.4-rc.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/group/bridge-db.js +150 -46
- package/dist/core/lbug/extension-loader.d.ts +1 -1
- package/dist/core/lbug/extension-loader.js +3 -2
- package/dist/core/lbug/lbug-adapter.d.ts +16 -0
- package/dist/core/lbug/lbug-adapter.js +48 -41
- package/dist/core/lbug/lbug-config.d.ts +46 -0
- package/dist/core/lbug/lbug-config.js +60 -0
- package/dist/core/lbug/pool-adapter.js +2 -3
- package/dist/mcp/local/local-backend.js +4 -5
- package/hooks/claude/gitnexus-hook.cjs +51 -2
- package/package.json +2 -2
- package/scripts/install-duckdb-extension.mjs +12 -1
|
@@ -3,7 +3,36 @@ import path from 'node:path';
|
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import lbug from '@ladybugdb/core';
|
|
5
5
|
import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
|
|
6
|
+
import { closeLbugConnection, openLbugConnection, } from '../lbug/lbug-config.js';
|
|
6
7
|
import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
|
|
8
|
+
/**
|
|
9
|
+
* Sidecar files that LadybugDB creates next to a `bridge.lbug` file.
|
|
10
|
+
*
|
|
11
|
+
* - `.wal` — write-ahead log; persists across opens but must be associated
|
|
12
|
+
* with the same database instance (LadybugDB 0.16.0 enforces this via a
|
|
13
|
+
* database-id check and rejects opens with the diagnostic
|
|
14
|
+
* `"Database ID for temporary file 'X.wal' does not match the current
|
|
15
|
+
* database. This file may have been left behind from a previous database
|
|
16
|
+
* with the same name"`).
|
|
17
|
+
* - `.shadow` — non-blocking concurrent checkpoint sidecar (added in
|
|
18
|
+
* LadybugDB 0.15.4); same pairing constraint as `.wal`.
|
|
19
|
+
*
|
|
20
|
+
* `bridge-db` writes to a `bridge.lbug.tmp` file and then atomically renames
|
|
21
|
+
* it into place. The rename only moves the main file; sidecars must be
|
|
22
|
+
* cleaned up explicitly or the next writer trips the database-id check.
|
|
23
|
+
*/
|
|
24
|
+
const LBUG_SIDECAR_SUFFIXES = ['.wal', '.shadow'];
|
|
25
|
+
async function removeLbugFile(basePath) {
|
|
26
|
+
const candidates = [basePath, ...LBUG_SIDECAR_SUFFIXES.map((s) => `${basePath}${s}`)];
|
|
27
|
+
for (const f of candidates) {
|
|
28
|
+
try {
|
|
29
|
+
await fsp.rm(f, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* best-effort: caller will surface real errors via the open path */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
7
36
|
export function contractNodeId(repo, contractId, role, filePath) {
|
|
8
37
|
return createHash('sha256').update(`${repo}\0${contractId}\0${role}\0${filePath}`).digest('hex');
|
|
9
38
|
}
|
|
@@ -74,8 +103,7 @@ export function findContractNode(index, repo, role, symbolUid, filePath, symbolN
|
|
|
74
103
|
export async function openBridgeDb(dbPath) {
|
|
75
104
|
const parentDir = path.dirname(dbPath);
|
|
76
105
|
await fsp.mkdir(parentDir, { recursive: true });
|
|
77
|
-
const db =
|
|
78
|
-
const conn = new lbug.Connection(db);
|
|
106
|
+
const { db, conn } = await openLbugConnection(lbug, dbPath);
|
|
79
107
|
return { _db: db, _conn: conn, groupDir: parentDir };
|
|
80
108
|
}
|
|
81
109
|
/**
|
|
@@ -135,6 +163,18 @@ function unwrapQueryResult(queryResult) {
|
|
|
135
163
|
return queryResult;
|
|
136
164
|
}
|
|
137
165
|
export async function closeBridgeDb(handle) {
|
|
166
|
+
// CHECKPOINT before close so the WAL/.shadow contents are flushed into
|
|
167
|
+
// the main database file. Without this, LadybugDB 0.16.0's non-blocking
|
|
168
|
+
// checkpoint thread can outlive the close call and leave sidecar pages
|
|
169
|
+
// pending on disk, which makes a subsequent read-side open either race
|
|
170
|
+
// with the WAL replay or trip the database-id check on the sidecars.
|
|
171
|
+
// CHECKPOINT is a no-op when there's nothing pending, so it's cheap.
|
|
172
|
+
try {
|
|
173
|
+
await handle._conn.query('CHECKPOINT');
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
/* ignore — older LadybugDB or schemaless DB may not accept it */
|
|
177
|
+
}
|
|
138
178
|
try {
|
|
139
179
|
await handle._conn.close();
|
|
140
180
|
}
|
|
@@ -221,13 +261,11 @@ export async function writeBridge(groupDir, input) {
|
|
|
221
261
|
report.sampleErrors.push({ kind, id, message: errMessage(err) });
|
|
222
262
|
}
|
|
223
263
|
};
|
|
224
|
-
// Clean up any leftover tmp
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
/* ignore */
|
|
230
|
-
}
|
|
264
|
+
// Clean up any leftover tmp main file AND its `.wal` / `.shadow` sidecars.
|
|
265
|
+
// LadybugDB 0.16.0 rejects opening a database whose sidecars belong to a
|
|
266
|
+
// different database instance (database-id check), so any stale sidecar
|
|
267
|
+
// from a crashed previous run will fail the next writeBridge.
|
|
268
|
+
await removeLbugFile(tmpPath);
|
|
231
269
|
// 1. Create temp DB, insert all data.
|
|
232
270
|
//
|
|
233
271
|
// Everything after `openBridgeDb` must run inside a try/finally so that
|
|
@@ -368,20 +406,46 @@ export async function writeBridge(groupDir, input) {
|
|
|
368
406
|
}
|
|
369
407
|
}
|
|
370
408
|
// 3. Atomic swap: old→.bak, tmp→final, rm .bak
|
|
409
|
+
//
|
|
410
|
+
// The current database file (with its `.wal` / `.shadow` sidecars) is
|
|
411
|
+
// moved aside, then the freshly built tmp database takes its place.
|
|
412
|
+
// We move the sidecars together with the main file so the open below
|
|
413
|
+
// and any external readers see a consistent set; orphan sidecars from
|
|
414
|
+
// the tmp namespace are then removed because LadybugDB looks for them
|
|
415
|
+
// under the renamed-to base name and would reject mismatching IDs.
|
|
371
416
|
try {
|
|
372
417
|
await fsp.access(finalPath);
|
|
373
418
|
await retryRename(finalPath, bakPath);
|
|
419
|
+
for (const suffix of LBUG_SIDECAR_SUFFIXES) {
|
|
420
|
+
try {
|
|
421
|
+
await fsp.access(`${finalPath}${suffix}`);
|
|
422
|
+
await retryRename(`${finalPath}${suffix}`, `${bakPath}${suffix}`);
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
/* sidecar absent — nothing to move */
|
|
426
|
+
}
|
|
427
|
+
}
|
|
374
428
|
}
|
|
375
429
|
catch {
|
|
376
430
|
/* no existing db */
|
|
377
431
|
}
|
|
378
432
|
await retryRename(tmpPath, finalPath);
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
433
|
+
for (const suffix of LBUG_SIDECAR_SUFFIXES) {
|
|
434
|
+
// Rename — not delete — so the WAL (which may carry uncommitted-at-
|
|
435
|
+
// close-time pages on a graceful close, depending on
|
|
436
|
+
// `autoCheckpoint` / `checkpointThreshold`) and the `.shadow`
|
|
437
|
+
// checkpoint snapshot stay paired with the database file under its
|
|
438
|
+
// final name. LadybugDB 0.16.0's database-id check rejects an open
|
|
439
|
+
// when the sidecars belong to a different base name.
|
|
440
|
+
try {
|
|
441
|
+
await fsp.access(`${tmpPath}${suffix}`);
|
|
442
|
+
await retryRename(`${tmpPath}${suffix}`, `${finalPath}${suffix}`);
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
/* sidecar absent — nothing to move */
|
|
446
|
+
}
|
|
384
447
|
}
|
|
448
|
+
await removeLbugFile(bakPath);
|
|
385
449
|
// 4. Write meta.json
|
|
386
450
|
await writeBridgeMeta(groupDir, {
|
|
387
451
|
version: BRIDGE_SCHEMA_VERSION,
|
|
@@ -393,10 +457,35 @@ export async function writeBridge(groupDir, input) {
|
|
|
393
457
|
/* ------------------------------------------------------------------ */
|
|
394
458
|
/* openBridgeDbReadOnly */
|
|
395
459
|
/* ------------------------------------------------------------------ */
|
|
396
|
-
|
|
460
|
+
/**
|
|
461
|
+
* Substrings observed in the message of an `Error` raised by the LadybugDB
|
|
462
|
+
* native open path when Windows still holds an exclusive lock on the file
|
|
463
|
+
* after a writer's `Database.close()` returned. LadybugDB 0.16.0's
|
|
464
|
+
* non-blocking checkpoint thread can briefly outlive the close call, so a
|
|
465
|
+
* read-side opener that races in immediately afterwards sees Win32 error
|
|
466
|
+
* 33 ("The process cannot access the file because another process has
|
|
467
|
+
* locked a portion of the file"). Retrying with a small back-off lets the
|
|
468
|
+
* background thread settle and the OS release the handle.
|
|
469
|
+
*/
|
|
470
|
+
const LBUG_OPEN_RETRY_PATTERNS = [
|
|
471
|
+
'process cannot access the file',
|
|
472
|
+
'another process has locked',
|
|
473
|
+
'could not set lock',
|
|
474
|
+
'lock held by another process',
|
|
475
|
+
];
|
|
476
|
+
const LBUG_OPEN_RETRY_ATTEMPTS = 10;
|
|
477
|
+
const LBUG_OPEN_RETRY_BASE_MS = 100;
|
|
478
|
+
/** Cap individual back-off delays so the total wait is bounded (~3s). */
|
|
479
|
+
const LBUG_OPEN_RETRY_MAX_MS = 500;
|
|
480
|
+
function isTransientLockError(err) {
|
|
481
|
+
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
|
482
|
+
return LBUG_OPEN_RETRY_PATTERNS.some((p) => msg.includes(p));
|
|
483
|
+
}
|
|
484
|
+
async function ensureBridgeDbFileAvailable(groupDir) {
|
|
397
485
|
const dbPath = path.join(groupDir, 'bridge.lbug');
|
|
398
486
|
try {
|
|
399
487
|
await fsp.access(dbPath);
|
|
488
|
+
return true;
|
|
400
489
|
}
|
|
401
490
|
catch {
|
|
402
491
|
// Check for .bak recovery. Use `retryRename` (not `fsp.rename`) for the
|
|
@@ -408,53 +497,68 @@ export async function openBridgeDbReadOnly(groupDir) {
|
|
|
408
497
|
try {
|
|
409
498
|
await fsp.access(bakPath);
|
|
410
499
|
await retryRename(bakPath, dbPath);
|
|
500
|
+
for (const suffix of LBUG_SIDECAR_SUFFIXES) {
|
|
501
|
+
try {
|
|
502
|
+
await fsp.access(`${bakPath}${suffix}`);
|
|
503
|
+
await retryRename(`${bakPath}${suffix}`, `${dbPath}${suffix}`);
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
/* sidecar absent */
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return true;
|
|
411
510
|
}
|
|
412
511
|
catch {
|
|
413
|
-
return
|
|
512
|
+
return false;
|
|
414
513
|
}
|
|
415
514
|
}
|
|
515
|
+
}
|
|
516
|
+
export async function openBridgeDbReadOnly(groupDir) {
|
|
517
|
+
const dbPath = path.join(groupDir, 'bridge.lbug');
|
|
518
|
+
if (!(await ensureBridgeDbFileAvailable(groupDir)))
|
|
519
|
+
return null;
|
|
416
520
|
// Version gate: check meta.json version compatibility
|
|
417
521
|
const meta = await readBridgeMeta(groupDir);
|
|
418
522
|
if (meta.version > 0 && meta.version !== BRIDGE_SCHEMA_VERSION) {
|
|
419
523
|
return null; // incompatible schema version — fallback to JSON or re-sync
|
|
420
524
|
}
|
|
421
|
-
// Open the native handle
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
|
|
425
|
-
let
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
catch {
|
|
437
|
-
/* ignore */
|
|
438
|
-
}
|
|
525
|
+
// Open the native handle with a bounded retry on transient OS-level file
|
|
526
|
+
// locks (see LBUG_OPEN_RETRY_PATTERNS). If Connection construction throws
|
|
527
|
+
// AFTER Database was successfully allocated, we'd leak the native Database
|
|
528
|
+
// object — wrap each step separately and tear down the partial handle.
|
|
529
|
+
let lastErr;
|
|
530
|
+
for (let attempt = 1; attempt <= LBUG_OPEN_RETRY_ATTEMPTS; attempt++) {
|
|
531
|
+
let handle;
|
|
532
|
+
try {
|
|
533
|
+
handle = await openLbugConnection(lbug, dbPath, { readOnly: true });
|
|
534
|
+
// Force the lazy native init now so a transient lock surfaces here
|
|
535
|
+
// (where we can retry) instead of on the first user query.
|
|
536
|
+
await handle.db.init();
|
|
537
|
+
await handle.conn.init();
|
|
538
|
+
return { _db: handle.db, _conn: handle.conn, groupDir };
|
|
439
539
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
540
|
+
catch (err) {
|
|
541
|
+
lastErr = err;
|
|
542
|
+
if (handle)
|
|
543
|
+
await closeLbugConnection(handle);
|
|
544
|
+
if (!isTransientLockError(err) || attempt === LBUG_OPEN_RETRY_ATTEMPTS)
|
|
545
|
+
break;
|
|
546
|
+
const delay = Math.min(LBUG_OPEN_RETRY_BASE_MS * attempt, LBUG_OPEN_RETRY_MAX_MS);
|
|
547
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
447
548
|
}
|
|
448
|
-
return null;
|
|
449
549
|
}
|
|
550
|
+
if (process.env.GITNEXUS_DEBUG_BRIDGE) {
|
|
551
|
+
console.warn(`[bridge-db] openBridgeDbReadOnly(${groupDir}) gave up after ` +
|
|
552
|
+
`${LBUG_OPEN_RETRY_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
|
|
553
|
+
}
|
|
554
|
+
return null;
|
|
450
555
|
}
|
|
451
556
|
/* ------------------------------------------------------------------ */
|
|
452
557
|
/* bridgeExists */
|
|
453
558
|
/* ------------------------------------------------------------------ */
|
|
454
559
|
export async function bridgeExists(groupDir) {
|
|
455
|
-
|
|
456
|
-
if (!handle)
|
|
560
|
+
if (!(await ensureBridgeDbFileAvailable(groupDir)))
|
|
457
561
|
return false;
|
|
458
|
-
await
|
|
459
|
-
return
|
|
562
|
+
const meta = await readBridgeMeta(groupDir);
|
|
563
|
+
return meta.version === 0 || meta.version === BRIDGE_SCHEMA_VERSION;
|
|
460
564
|
}
|
|
@@ -33,7 +33,7 @@ export interface ExtensionManagerOptions {
|
|
|
33
33
|
warn?: (message: string) => void;
|
|
34
34
|
}
|
|
35
35
|
export declare const getExtensionInstallTimeoutMs: () => number;
|
|
36
|
-
export declare const getExtensionInstallChildProcessArgs: (extensionName: string) => string[];
|
|
36
|
+
export declare const getExtensionInstallChildProcessArgs: (extensionName: string, maxDbSize?: number) => string[];
|
|
37
37
|
/**
|
|
38
38
|
* Run `INSTALL <extension>` in a short-lived child Node process so the parent
|
|
39
39
|
* event loop is never blocked by DuckDB's synchronous network call.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { LBUG_MAX_DB_SIZE } from './lbug-config.js';
|
|
3
4
|
const DEFAULT_EXTENSION_INSTALL_TIMEOUT_MS = 15_000;
|
|
4
5
|
const EXTENSION_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
5
6
|
const alreadyAvailable = (message) => message.includes('already loaded') ||
|
|
@@ -16,9 +17,9 @@ export const getExtensionInstallTimeoutMs = () => {
|
|
|
16
17
|
const parsed = raw ? Number(raw) : NaN;
|
|
17
18
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_EXTENSION_INSTALL_TIMEOUT_MS;
|
|
18
19
|
};
|
|
19
|
-
export const getExtensionInstallChildProcessArgs = (extensionName) => {
|
|
20
|
+
export const getExtensionInstallChildProcessArgs = (extensionName, maxDbSize = LBUG_MAX_DB_SIZE) => {
|
|
20
21
|
const childScript = new URL('../../../scripts/install-duckdb-extension.mjs', import.meta.url);
|
|
21
|
-
return [fileURLToPath(childScript), extensionName];
|
|
22
|
+
return [fileURLToPath(childScript), extensionName, String(maxDbSize)];
|
|
22
23
|
};
|
|
23
24
|
/**
|
|
24
25
|
* Run `INSTALL <extension>` in a short-lived child Node process so the parent
|
|
@@ -38,6 +38,15 @@ export declare const getDatabase: () => lbug.Database | null;
|
|
|
38
38
|
* `gitnexus serve` running at the same time).
|
|
39
39
|
*/
|
|
40
40
|
export declare const isDbBusyError: (err: unknown) => boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Return true when the error message indicates a write was attempted against
|
|
43
|
+
* a read-only LadybugDB connection. The MCP query pool opens DBs read-only,
|
|
44
|
+
* so any path that calls a `CREATE_*` procedure there will surface this
|
|
45
|
+
* (e.g. defensive `ensureFTSIndex` calls). Owners of the writable analyze
|
|
46
|
+
* path should ignore this error — index creation is owned by `gitnexus
|
|
47
|
+
* analyze` and either already happened or will happen on the next run.
|
|
48
|
+
*/
|
|
49
|
+
export declare const isReadOnlyDbError: (err: unknown) => boolean;
|
|
41
50
|
export declare const initLbug: (dbPath: string) => Promise<{
|
|
42
51
|
db: lbug.Database;
|
|
43
52
|
conn: lbug.Connection;
|
|
@@ -158,6 +167,13 @@ export declare const createFTSIndex: (tableName: string, indexName: string, prop
|
|
|
158
167
|
*
|
|
159
168
|
* Safe to call repeatedly — the in-process Set guarantees only the first
|
|
160
169
|
* call hits LadybugDB. `closeLbug` clears the cache so re-init starts fresh.
|
|
170
|
+
*
|
|
171
|
+
* Defense in depth: if the active connection is read-only (e.g. the MCP
|
|
172
|
+
* pool adapter), `CREATE_FTS_INDEX` will fail with "Cannot execute write
|
|
173
|
+
* operations in a read-only database". Treat that as a no-op and cache
|
|
174
|
+
* the key so callers don't loop on a path that can never succeed here —
|
|
175
|
+
* the index is owned by `gitnexus analyze` (writable) and either already
|
|
176
|
+
* exists or will be created on the next analyze.
|
|
161
177
|
*/
|
|
162
178
|
export declare const ensureFTSIndex: (tableName: string, indexName: string, properties: string[], stemmer?: string) => Promise<void>;
|
|
163
179
|
/**
|
|
@@ -8,6 +8,7 @@ import lbug from '@ladybugdb/core';
|
|
|
8
8
|
import { NODE_TABLES, REL_TABLE_NAME, SCHEMA_QUERIES, EMBEDDING_TABLE_NAME, STALE_HASH_SENTINEL, } from './schema.js';
|
|
9
9
|
import { streamAllCSVsToDisk } from './csv-generator.js';
|
|
10
10
|
import { extensionManager } from './extension-loader.js';
|
|
11
|
+
import { closeLbugConnection, openLbugConnection, } from './lbug-config.js';
|
|
11
12
|
import { isVectorExtensionSupportedByPlatform } from '../platform/capabilities.js';
|
|
12
13
|
/**
|
|
13
14
|
* Split a relationship CSV into per-label-pair files on disk.
|
|
@@ -150,6 +151,18 @@ export const isDbBusyError = (err) => {
|
|
|
150
151
|
msg.includes('already in use') ||
|
|
151
152
|
msg.includes('could not set lock'));
|
|
152
153
|
};
|
|
154
|
+
/**
|
|
155
|
+
* Return true when the error message indicates a write was attempted against
|
|
156
|
+
* a read-only LadybugDB connection. The MCP query pool opens DBs read-only,
|
|
157
|
+
* so any path that calls a `CREATE_*` procedure there will surface this
|
|
158
|
+
* (e.g. defensive `ensureFTSIndex` calls). Owners of the writable analyze
|
|
159
|
+
* path should ignore this error — index creation is owned by `gitnexus
|
|
160
|
+
* analyze` and either already happened or will happen on the next run.
|
|
161
|
+
*/
|
|
162
|
+
export const isReadOnlyDbError = (err) => {
|
|
163
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
164
|
+
return /read-only database/i.test(msg);
|
|
165
|
+
};
|
|
153
166
|
const runWithSessionLock = async (operation) => {
|
|
154
167
|
const previous = sessionLock;
|
|
155
168
|
let release = null;
|
|
@@ -275,14 +288,14 @@ const doInitLbug = async (dbPath) => {
|
|
|
275
288
|
// Ensure parent directory exists
|
|
276
289
|
const parentDir = path.dirname(dbPath);
|
|
277
290
|
await fs.mkdir(parentDir, { recursive: true });
|
|
278
|
-
|
|
279
|
-
|
|
291
|
+
const opened = await openLbugConnection(lbug, dbPath);
|
|
292
|
+
db = opened.db;
|
|
293
|
+
conn = opened.conn;
|
|
280
294
|
for (const schemaQuery of SCHEMA_QUERIES) {
|
|
281
295
|
try {
|
|
282
296
|
await conn.query(schemaQuery);
|
|
283
297
|
}
|
|
284
298
|
catch (err) {
|
|
285
|
-
// Only ignore "already exists" errors - log everything else
|
|
286
299
|
const msg = err instanceof Error ? err.message : String(err);
|
|
287
300
|
if (!msg.includes('already exists')) {
|
|
288
301
|
console.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`);
|
|
@@ -587,21 +600,13 @@ export const insertNodeToLbug = async (label, properties, dbPath) => {
|
|
|
587
600
|
}
|
|
588
601
|
// Use per-query connection if dbPath provided (avoids lock conflicts)
|
|
589
602
|
if (targetDbPath) {
|
|
590
|
-
const
|
|
591
|
-
const tempConn = new lbug.Connection(tempDb);
|
|
603
|
+
const tempHandle = await openLbugConnection(lbug, targetDbPath);
|
|
592
604
|
try {
|
|
593
|
-
await
|
|
605
|
+
await tempHandle.conn.query(query);
|
|
594
606
|
return true;
|
|
595
607
|
}
|
|
596
608
|
finally {
|
|
597
|
-
|
|
598
|
-
await tempConn.close();
|
|
599
|
-
}
|
|
600
|
-
catch { }
|
|
601
|
-
try {
|
|
602
|
-
await tempDb.close();
|
|
603
|
-
}
|
|
604
|
-
catch { }
|
|
609
|
+
await closeLbugConnection(tempHandle);
|
|
605
610
|
}
|
|
606
611
|
}
|
|
607
612
|
else if (conn) {
|
|
@@ -635,8 +640,8 @@ export const batchInsertNodesToLbug = async (nodes, dbPath) => {
|
|
|
635
640
|
return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "''").replace(/\n/g, '\\n').replace(/\r/g, '\\r')}'`;
|
|
636
641
|
};
|
|
637
642
|
// Open a single connection for all inserts
|
|
638
|
-
const
|
|
639
|
-
const tempConn =
|
|
643
|
+
const tempHandle = await openLbugConnection(lbug, dbPath);
|
|
644
|
+
const tempConn = tempHandle.conn;
|
|
640
645
|
let inserted = 0;
|
|
641
646
|
let failed = 0;
|
|
642
647
|
try {
|
|
@@ -679,14 +684,7 @@ export const batchInsertNodesToLbug = async (nodes, dbPath) => {
|
|
|
679
684
|
}
|
|
680
685
|
}
|
|
681
686
|
finally {
|
|
682
|
-
|
|
683
|
-
await tempConn.close();
|
|
684
|
-
}
|
|
685
|
-
catch { }
|
|
686
|
-
try {
|
|
687
|
-
await tempDb.close();
|
|
688
|
-
}
|
|
689
|
-
catch { }
|
|
687
|
+
await closeLbugConnection(tempHandle);
|
|
690
688
|
}
|
|
691
689
|
return { inserted, failed };
|
|
692
690
|
};
|
|
@@ -960,12 +958,12 @@ export const isLbugReady = () => conn !== null && db !== null;
|
|
|
960
958
|
export const deleteNodesForFile = async (filePath, dbPath) => {
|
|
961
959
|
const usePerQuery = !!dbPath;
|
|
962
960
|
// Set up connection (either use existing or create per-query)
|
|
963
|
-
let
|
|
961
|
+
let tempHandle = null;
|
|
964
962
|
let tempConn = null;
|
|
965
963
|
let targetConn = conn;
|
|
966
964
|
if (usePerQuery) {
|
|
967
|
-
|
|
968
|
-
tempConn =
|
|
965
|
+
tempHandle = await openLbugConnection(lbug, dbPath);
|
|
966
|
+
tempConn = tempHandle.conn;
|
|
969
967
|
targetConn = tempConn;
|
|
970
968
|
}
|
|
971
969
|
else if (!conn) {
|
|
@@ -1008,18 +1006,8 @@ export const deleteNodesForFile = async (filePath, dbPath) => {
|
|
|
1008
1006
|
}
|
|
1009
1007
|
finally {
|
|
1010
1008
|
// Close per-query connection if used
|
|
1011
|
-
if (
|
|
1012
|
-
|
|
1013
|
-
await tempConn.close();
|
|
1014
|
-
}
|
|
1015
|
-
catch { }
|
|
1016
|
-
}
|
|
1017
|
-
if (tempDb) {
|
|
1018
|
-
try {
|
|
1019
|
-
await tempDb.close();
|
|
1020
|
-
}
|
|
1021
|
-
catch { }
|
|
1022
|
-
}
|
|
1009
|
+
if (tempHandle)
|
|
1010
|
+
await closeLbugConnection(tempHandle);
|
|
1023
1011
|
}
|
|
1024
1012
|
};
|
|
1025
1013
|
export const getEmbeddingTableName = () => EMBEDDING_TABLE_NAME;
|
|
@@ -1105,13 +1093,32 @@ export const createFTSIndex = async (tableName, indexName, properties, stemmer =
|
|
|
1105
1093
|
*
|
|
1106
1094
|
* Safe to call repeatedly — the in-process Set guarantees only the first
|
|
1107
1095
|
* call hits LadybugDB. `closeLbug` clears the cache so re-init starts fresh.
|
|
1096
|
+
*
|
|
1097
|
+
* Defense in depth: if the active connection is read-only (e.g. the MCP
|
|
1098
|
+
* pool adapter), `CREATE_FTS_INDEX` will fail with "Cannot execute write
|
|
1099
|
+
* operations in a read-only database". Treat that as a no-op and cache
|
|
1100
|
+
* the key so callers don't loop on a path that can never succeed here —
|
|
1101
|
+
* the index is owned by `gitnexus analyze` (writable) and either already
|
|
1102
|
+
* exists or will be created on the next analyze.
|
|
1108
1103
|
*/
|
|
1109
1104
|
export const ensureFTSIndex = async (tableName, indexName, properties, stemmer = 'porter') => {
|
|
1110
1105
|
const key = `${tableName}:${indexName}`;
|
|
1111
1106
|
if (ensuredFTSIndexes.has(key))
|
|
1112
1107
|
return;
|
|
1113
|
-
|
|
1114
|
-
|
|
1108
|
+
try {
|
|
1109
|
+
await createFTSIndex(tableName, indexName, properties, stemmer);
|
|
1110
|
+
ensuredFTSIndexes.add(key);
|
|
1111
|
+
}
|
|
1112
|
+
catch (e) {
|
|
1113
|
+
// Read-only DB: writable analyze owns index creation; silently skip
|
|
1114
|
+
// and cache so callers don't loop on a path that can never succeed
|
|
1115
|
+
// here (the MCP query pool opens DBs read-only by design).
|
|
1116
|
+
if (isReadOnlyDbError(e)) {
|
|
1117
|
+
ensuredFTSIndexes.add(key);
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
throw e;
|
|
1121
|
+
}
|
|
1115
1122
|
};
|
|
1116
1123
|
/**
|
|
1117
1124
|
* Query a full-text search index
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type lbug from '@ladybugdb/core';
|
|
2
|
+
/**
|
|
3
|
+
* Shared configuration for `@ladybugdb/core` `Database` construction.
|
|
4
|
+
*
|
|
5
|
+
* Two values changed meaningfully in `@ladybugdb/core` 0.16.0 and need to be
|
|
6
|
+
* pinned explicitly by every caller, otherwise GitNexus regresses:
|
|
7
|
+
*
|
|
8
|
+
* 1. `maxDBSize` defaults to `0`, which the native runtime interprets as
|
|
9
|
+
* "use the platform's full mmap address space" — typically 8 TB on
|
|
10
|
+
* 64-bit Linux. Constrained environments (CI runners, containers, WSL)
|
|
11
|
+
* cannot reserve that much address space and crash with
|
|
12
|
+
* `Buffer manager exception: Mmap for size 8796093022208 failed.`
|
|
13
|
+
* See LadybugDB upstream JSDoc:
|
|
14
|
+
* > "introduced temporarily for now to get around with the default 8TB
|
|
15
|
+
* > mmap address space limit some environment".
|
|
16
|
+
*
|
|
17
|
+
* 2. `enableCompression` flipped its default from `false` (0.15.x) to
|
|
18
|
+
* `true` (0.16.0). Existing call sites that relied on the positional
|
|
19
|
+
* default must now pass `false` explicitly to preserve behaviour.
|
|
20
|
+
*
|
|
21
|
+
* Putting both in one shared module guarantees every `new lbug.Database(...)`
|
|
22
|
+
* call site agrees on the same ceiling and behaviour.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Upper bound for any single GitNexus LadybugDB file (graph index, group
|
|
26
|
+
* bridge, install scratch, test fixture). 16 GiB is intentionally generous
|
|
27
|
+
* for real-world code graphs (the GitNexus self-index uses < 50 MiB) while
|
|
28
|
+
* remaining far below any 64-bit OS mmap ceiling.
|
|
29
|
+
*
|
|
30
|
+
* Override with the `GITNEXUS_LBUG_MAX_DB_SIZE` environment variable when
|
|
31
|
+
* indexing genuinely huge monorepos. Values are coerced to a positive
|
|
32
|
+
* integer; anything invalid falls back to the default.
|
|
33
|
+
*/
|
|
34
|
+
export declare const LBUG_MAX_DB_SIZE: number;
|
|
35
|
+
type LbugModule = typeof lbug;
|
|
36
|
+
export interface LbugDatabaseOptions {
|
|
37
|
+
readOnly?: boolean;
|
|
38
|
+
}
|
|
39
|
+
export interface LbugConnectionHandle {
|
|
40
|
+
db: lbug.Database;
|
|
41
|
+
conn: lbug.Connection;
|
|
42
|
+
}
|
|
43
|
+
export declare function createLbugDatabase(lbugModule: LbugModule, databasePath: string, options?: LbugDatabaseOptions): lbug.Database;
|
|
44
|
+
export declare function openLbugConnection(lbugModule: LbugModule, databasePath: string, options?: LbugDatabaseOptions): Promise<LbugConnectionHandle>;
|
|
45
|
+
export declare function closeLbugConnection(handle: LbugConnectionHandle): Promise<void>;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared configuration for `@ladybugdb/core` `Database` construction.
|
|
3
|
+
*
|
|
4
|
+
* Two values changed meaningfully in `@ladybugdb/core` 0.16.0 and need to be
|
|
5
|
+
* pinned explicitly by every caller, otherwise GitNexus regresses:
|
|
6
|
+
*
|
|
7
|
+
* 1. `maxDBSize` defaults to `0`, which the native runtime interprets as
|
|
8
|
+
* "use the platform's full mmap address space" — typically 8 TB on
|
|
9
|
+
* 64-bit Linux. Constrained environments (CI runners, containers, WSL)
|
|
10
|
+
* cannot reserve that much address space and crash with
|
|
11
|
+
* `Buffer manager exception: Mmap for size 8796093022208 failed.`
|
|
12
|
+
* See LadybugDB upstream JSDoc:
|
|
13
|
+
* > "introduced temporarily for now to get around with the default 8TB
|
|
14
|
+
* > mmap address space limit some environment".
|
|
15
|
+
*
|
|
16
|
+
* 2. `enableCompression` flipped its default from `false` (0.15.x) to
|
|
17
|
+
* `true` (0.16.0). Existing call sites that relied on the positional
|
|
18
|
+
* default must now pass `false` explicitly to preserve behaviour.
|
|
19
|
+
*
|
|
20
|
+
* Putting both in one shared module guarantees every `new lbug.Database(...)`
|
|
21
|
+
* call site agrees on the same ceiling and behaviour.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Upper bound for any single GitNexus LadybugDB file (graph index, group
|
|
25
|
+
* bridge, install scratch, test fixture). 16 GiB is intentionally generous
|
|
26
|
+
* for real-world code graphs (the GitNexus self-index uses < 50 MiB) while
|
|
27
|
+
* remaining far below any 64-bit OS mmap ceiling.
|
|
28
|
+
*
|
|
29
|
+
* Override with the `GITNEXUS_LBUG_MAX_DB_SIZE` environment variable when
|
|
30
|
+
* indexing genuinely huge monorepos. Values are coerced to a positive
|
|
31
|
+
* integer; anything invalid falls back to the default.
|
|
32
|
+
*/
|
|
33
|
+
export const LBUG_MAX_DB_SIZE = (() => {
|
|
34
|
+
const raw = process.env.GITNEXUS_LBUG_MAX_DB_SIZE;
|
|
35
|
+
if (raw) {
|
|
36
|
+
const parsed = Number(raw);
|
|
37
|
+
if (Number.isFinite(parsed) && parsed > 0)
|
|
38
|
+
return Math.floor(parsed);
|
|
39
|
+
}
|
|
40
|
+
return 16 * 1024 * 1024 * 1024;
|
|
41
|
+
})();
|
|
42
|
+
export function createLbugDatabase(lbugModule, databasePath, options = {}) {
|
|
43
|
+
return new lbugModule.Database(databasePath, 0, false, options.readOnly ?? false, LBUG_MAX_DB_SIZE);
|
|
44
|
+
}
|
|
45
|
+
export async function openLbugConnection(lbugModule, databasePath, options = {}) {
|
|
46
|
+
let db;
|
|
47
|
+
try {
|
|
48
|
+
db = createLbugDatabase(lbugModule, databasePath, options);
|
|
49
|
+
return { db, conn: new lbugModule.Connection(db) };
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
if (db)
|
|
53
|
+
await db.close().catch(() => { });
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function closeLbugConnection(handle) {
|
|
58
|
+
await handle.conn.close().catch(() => { });
|
|
59
|
+
await handle.db.close().catch(() => { });
|
|
60
|
+
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import fs from 'fs/promises';
|
|
18
18
|
import lbug from '@ladybugdb/core';
|
|
19
19
|
import { loadFTSExtension } from './lbug-adapter.js';
|
|
20
|
+
import { createLbugDatabase } from './lbug-config.js';
|
|
20
21
|
const pool = new Map();
|
|
21
22
|
const poolCloseListeners = new Set();
|
|
22
23
|
/**
|
|
@@ -243,9 +244,7 @@ async function doInitLbug(repoId, dbPath) {
|
|
|
243
244
|
for (let attempt = 1; attempt <= LOCK_RETRY_ATTEMPTS; attempt++) {
|
|
244
245
|
silenceStdout();
|
|
245
246
|
try {
|
|
246
|
-
const db =
|
|
247
|
-
false, // enableCompression (default)
|
|
248
|
-
true);
|
|
247
|
+
const db = createLbugDatabase(lbug, dbPath, { readOnly: true });
|
|
249
248
|
restoreStdout();
|
|
250
249
|
shared = { db, refCount: 0, ftsLoaded: false };
|
|
251
250
|
dbCache.set(dbPath, shared);
|
|
@@ -928,12 +928,11 @@ export class LocalBackend {
|
|
|
928
928
|
}
|
|
929
929
|
else if (!this.warnedVectorUnsupported) {
|
|
930
930
|
// Rare diagnostic: surface why we fell back to the exact scan path so
|
|
931
|
-
// operators can see at a glance that
|
|
932
|
-
//
|
|
933
|
-
//
|
|
934
|
-
// avoid noisy stderr on hot semantic-search paths (DoD §2.8).
|
|
931
|
+
// operators can see at a glance that VECTOR is disabled by platform
|
|
932
|
+
// policy. Emitted once per `LocalBackend` instance lifetime to avoid
|
|
933
|
+
// noisy stderr on hot semantic-search paths (DoD §2.8).
|
|
935
934
|
this.warnedVectorUnsupported = true;
|
|
936
|
-
console.error('GitNexus [query:vector]: VECTOR
|
|
935
|
+
console.error('GitNexus [query:vector]: VECTOR extension not supported on this platform; using exact scan fallback');
|
|
937
936
|
}
|
|
938
937
|
if (bestChunks.size === 0) {
|
|
939
938
|
const embeddingCount = Number(tableCheck[0].cnt ?? tableCheck[0][0] ?? 0);
|
|
@@ -39,8 +39,12 @@ function isGlobalRegistryDir(candidate) {
|
|
|
39
39
|
);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Walk up from `startDir` looking for a non-registry `.gitnexus/` folder.
|
|
44
|
+
* Returns the path to `.gitnexus/` or null if not found within 5 levels.
|
|
45
|
+
*/
|
|
46
|
+
function walkForGitNexusDir(startDir) {
|
|
47
|
+
let dir = startDir;
|
|
44
48
|
for (let i = 0; i < 5; i++) {
|
|
45
49
|
const candidate = path.join(dir, '.gitnexus');
|
|
46
50
|
if (fs.existsSync(candidate)) {
|
|
@@ -53,6 +57,51 @@ function findGitNexusDir(startDir) {
|
|
|
53
57
|
return null;
|
|
54
58
|
}
|
|
55
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the canonical (main) worktree root for `cwd`, when `cwd` is inside
|
|
62
|
+
* any git working tree — including a *linked* worktree created via
|
|
63
|
+
* `git worktree add`. Linked worktrees never contain `.gitnexus/`, so the
|
|
64
|
+
* upward walk from cwd alone misses the index. Returns null when `cwd` is
|
|
65
|
+
* not inside a git repo or `git` is not available.
|
|
66
|
+
*
|
|
67
|
+
* Implementation: `git rev-parse --git-common-dir` resolves to the canonical
|
|
68
|
+
* `.git/` directory (or `.git/worktrees/...` parent) that is shared across
|
|
69
|
+
* all linked worktrees. The canonical repo root is its parent directory.
|
|
70
|
+
*/
|
|
71
|
+
function findCanonicalRepoRoot(cwd) {
|
|
72
|
+
try {
|
|
73
|
+
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
|
74
|
+
encoding: 'utf-8',
|
|
75
|
+
timeout: 2000,
|
|
76
|
+
cwd,
|
|
77
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
78
|
+
});
|
|
79
|
+
if (result.error || result.status !== 0) return null;
|
|
80
|
+
const commonDir = (result.stdout || '').trim();
|
|
81
|
+
if (!commonDir || !path.isAbsolute(commonDir)) return null;
|
|
82
|
+
return path.dirname(commonDir);
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function findGitNexusDir(startDir) {
|
|
89
|
+
const cwd = startDir || process.cwd();
|
|
90
|
+
|
|
91
|
+
// Fast path: the cwd is inside the canonical repo (most common case).
|
|
92
|
+
const fromCwd = walkForGitNexusDir(cwd);
|
|
93
|
+
if (fromCwd) return fromCwd;
|
|
94
|
+
|
|
95
|
+
// Fallback: cwd may be inside a linked git worktree whose `.gitnexus/`
|
|
96
|
+
// only lives in the canonical repo root. Resolve the shared git dir
|
|
97
|
+
// and retry from there.
|
|
98
|
+
const canonicalRoot = findCanonicalRepoRoot(cwd);
|
|
99
|
+
if (canonicalRoot && canonicalRoot !== cwd) {
|
|
100
|
+
return walkForGitNexusDir(canonicalRoot);
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
56
105
|
/**
|
|
57
106
|
* Extract search pattern from tool input.
|
|
58
107
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gitnexus",
|
|
3
|
-
"version": "1.6.4-rc.
|
|
3
|
+
"version": "1.6.4-rc.28",
|
|
4
4
|
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
|
|
5
5
|
"author": "Abhigyan Patwari",
|
|
6
6
|
"license": "PolyForm-Noncommercial-1.0.0",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@huggingface/transformers": "^4.1.0",
|
|
56
|
-
"@ladybugdb/core": "^0.
|
|
56
|
+
"@ladybugdb/core": "^0.16.0",
|
|
57
57
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
58
58
|
"@scarf/scarf": "^1.4.0",
|
|
59
59
|
"cli-progress": "^3.12.0",
|
|
@@ -6,6 +6,14 @@ import { createRequire } from 'node:module';
|
|
|
6
6
|
|
|
7
7
|
const EXTENSION_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
8
8
|
|
|
9
|
+
function parseLbugMaxDbSize(raw) {
|
|
10
|
+
const parsed = raw ? Number(raw) : NaN;
|
|
11
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
12
|
+
throw new Error(`Invalid LadybugDB max DB size for extension installer: ${raw ?? '<missing>'}`);
|
|
13
|
+
}
|
|
14
|
+
return Math.floor(parsed);
|
|
15
|
+
}
|
|
16
|
+
|
|
9
17
|
async function installDuckDbExtension(extensionName) {
|
|
10
18
|
if (!extensionName || !EXTENSION_NAME_PATTERN.test(extensionName)) {
|
|
11
19
|
throw new Error(`Invalid DuckDB extension name: ${extensionName ?? '<missing>'}`);
|
|
@@ -14,6 +22,9 @@ async function installDuckDbExtension(extensionName) {
|
|
|
14
22
|
const require = createRequire(import.meta.url);
|
|
15
23
|
const lbugModule = require('@ladybugdb/core');
|
|
16
24
|
const lbug = lbugModule.default ?? lbugModule;
|
|
25
|
+
const lbugMaxDbSize = parseLbugMaxDbSize(
|
|
26
|
+
process.argv[3] ?? process.env.GITNEXUS_LBUG_MAX_DB_SIZE,
|
|
27
|
+
);
|
|
17
28
|
|
|
18
29
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ext-install-'));
|
|
19
30
|
const dbPath = path.join(tmpDir, 'install.lbug');
|
|
@@ -21,7 +32,7 @@ async function installDuckDbExtension(extensionName) {
|
|
|
21
32
|
let conn;
|
|
22
33
|
|
|
23
34
|
try {
|
|
24
|
-
db = new lbug.Database(dbPath);
|
|
35
|
+
db = new lbug.Database(dbPath, 0, false, false, lbugMaxDbSize);
|
|
25
36
|
conn = new lbug.Connection(db);
|
|
26
37
|
await conn.query(`INSTALL ${extensionName}`);
|
|
27
38
|
} finally {
|