gitnexus 1.6.4-rc.26 → 1.6.4-rc.27

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.
@@ -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 = new lbug.Database(dbPath, 0, false, false); // writable
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
- try {
226
- await fsp.rm(tmpPath, { recursive: true, force: true });
227
- }
228
- catch {
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
- try {
380
- await fsp.rm(bakPath, { recursive: true, force: true });
381
- }
382
- catch {
383
- /* ignore */
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
- export async function openBridgeDbReadOnly(groupDir) {
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 null;
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. If Connection construction throws AFTER
422
- // Database was successfully allocated, we'd leak the native Database
423
- // object. Wrap each step separately and tear down the partial handle.
424
- let db;
425
- let conn;
426
- try {
427
- db = new lbug.Database(dbPath, 0, false, true); // readOnly
428
- conn = new lbug.Connection(db);
429
- return { _db: db, _conn: conn, groupDir };
430
- }
431
- catch {
432
- if (conn) {
433
- try {
434
- await conn.close();
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
- if (db) {
441
- try {
442
- await db.close();
443
- }
444
- catch {
445
- /* ignore */
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
- const handle = await openBridgeDbReadOnly(groupDir);
456
- if (!handle)
560
+ if (!(await ensureBridgeDbFileAvailable(groupDir)))
457
561
  return false;
458
- await closeBridgeDb(handle);
459
- return true;
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
- db = new lbug.Database(dbPath);
279
- conn = new lbug.Connection(db);
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 tempDb = new lbug.Database(targetDbPath);
591
- const tempConn = new lbug.Connection(tempDb);
603
+ const tempHandle = await openLbugConnection(lbug, targetDbPath);
592
604
  try {
593
- await tempConn.query(query);
605
+ await tempHandle.conn.query(query);
594
606
  return true;
595
607
  }
596
608
  finally {
597
- try {
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 tempDb = new lbug.Database(dbPath);
639
- const tempConn = new lbug.Connection(tempDb);
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
- try {
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 tempDb = null;
961
+ let tempHandle = null;
964
962
  let tempConn = null;
965
963
  let targetConn = conn;
966
964
  if (usePerQuery) {
967
- tempDb = new lbug.Database(dbPath);
968
- tempConn = new lbug.Connection(tempDb);
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 (tempConn) {
1012
- try {
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
- await createFTSIndex(tableName, indexName, properties, stemmer);
1114
- ensuredFTSIndexes.add(key);
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 = new lbug.Database(dbPath, 0, // bufferManagerSize (default)
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 the VECTOR extension is missing on
932
- // this runtime (e.g. Windows builds without the optional native
933
- // dependency). Emitted once per `LocalBackend` instance lifetime to
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 index unavailable for this runtime; using exact scan fallback');
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
- function findGitNexusDir(startDir) {
43
- let dir = startDir || process.cwd();
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.26",
3
+ "version": "1.6.4-rc.27",
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.15.2",
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 {