@codragraph/cli 2.0.0 → 2.1.1

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.
Files changed (101) hide show
  1. package/README.md +60 -22
  2. package/dist/_shared/cgdb/schema-constants.d.ts +16 -0
  3. package/dist/_shared/cgdb/schema-constants.d.ts.map +1 -0
  4. package/dist/_shared/cgdb/schema-constants.js +70 -0
  5. package/dist/_shared/cgdb/schema-constants.js.map +1 -0
  6. package/dist/_shared/feature-clusters.d.ts +99 -0
  7. package/dist/_shared/feature-clusters.d.ts.map +1 -0
  8. package/dist/_shared/feature-clusters.js +2 -0
  9. package/dist/_shared/feature-clusters.js.map +1 -0
  10. package/dist/_shared/graph/types.d.ts +16 -2
  11. package/dist/_shared/graph/types.d.ts.map +1 -1
  12. package/dist/_shared/index.d.ts +3 -2
  13. package/dist/_shared/index.d.ts.map +1 -1
  14. package/dist/_shared/index.js +1 -1
  15. package/dist/_shared/index.js.map +1 -1
  16. package/dist/_shared/pipeline.d.ts +1 -1
  17. package/dist/_shared/pipeline.d.ts.map +1 -1
  18. package/dist/cli/ai-context.js +4 -0
  19. package/dist/cli/analyze.js +30 -27
  20. package/dist/cli/graphstore.js +21 -21
  21. package/dist/cli/index-repo.js +3 -3
  22. package/dist/cli/index.js +37 -0
  23. package/dist/cli/setup.js +9 -5
  24. package/dist/cli/tool.d.ts +25 -0
  25. package/dist/cli/tool.js +74 -0
  26. package/dist/cli/wiki.js +3 -3
  27. package/dist/config/supported-languages.d.ts +3 -3
  28. package/dist/config/supported-languages.js +3 -3
  29. package/dist/core/augmentation/engine.js +7 -7
  30. package/dist/core/cgdb/cgdb-adapter.d.ts +176 -0
  31. package/dist/core/cgdb/cgdb-adapter.js +1336 -0
  32. package/dist/core/cgdb/content-read.d.ts +46 -0
  33. package/dist/core/cgdb/content-read.js +64 -0
  34. package/dist/core/cgdb/csv-generator.d.ts +29 -0
  35. package/dist/core/cgdb/csv-generator.js +523 -0
  36. package/dist/core/cgdb/pool-adapter.d.ts +93 -0
  37. package/dist/core/cgdb/pool-adapter.js +550 -0
  38. package/dist/core/cgdb/schema.d.ts +63 -0
  39. package/dist/core/cgdb/schema.js +557 -0
  40. package/dist/core/embeddings/embedder.js +4 -2
  41. package/dist/core/embeddings/embedding-pipeline.js +4 -4
  42. package/dist/core/graphstore/cgdb-row-source.d.ts +19 -0
  43. package/dist/core/graphstore/cgdb-row-source.js +141 -0
  44. package/dist/core/graphstore/index.d.ts +2 -2
  45. package/dist/core/graphstore/index.js +4 -4
  46. package/dist/core/group/bridge-db.d.ts +2 -2
  47. package/dist/core/group/bridge-db.js +18 -18
  48. package/dist/core/group/bridge-schema.d.ts +4 -4
  49. package/dist/core/group/bridge-schema.js +4 -4
  50. package/dist/core/group/cross-impact.js +3 -3
  51. package/dist/core/group/service.d.ts +16 -0
  52. package/dist/core/group/service.js +360 -0
  53. package/dist/core/group/sync.js +4 -4
  54. package/dist/core/ingestion/emit-references.d.ts +1 -1
  55. package/dist/core/ingestion/emit-references.js +1 -1
  56. package/dist/core/ingestion/feature-cluster-processor.d.ts +62 -0
  57. package/dist/core/ingestion/feature-cluster-processor.js +626 -0
  58. package/dist/core/ingestion/finalize-orchestrator.js +1 -1
  59. package/dist/core/ingestion/model/registration-table.js +1 -0
  60. package/dist/core/ingestion/model/resolve.d.ts +2 -2
  61. package/dist/core/ingestion/model/resolve.js +3 -3
  62. package/dist/core/ingestion/model/semantic-model.d.ts +1 -1
  63. package/dist/core/ingestion/model/semantic-model.js +1 -1
  64. package/dist/core/ingestion/model/symbol-table.d.ts +1 -1
  65. package/dist/core/ingestion/model/symbol-table.js +1 -1
  66. package/dist/core/ingestion/pipeline-phases/feature-clusters.d.ts +17 -0
  67. package/dist/core/ingestion/pipeline-phases/feature-clusters.js +88 -0
  68. package/dist/core/ingestion/pipeline-phases/index.d.ts +1 -0
  69. package/dist/core/ingestion/pipeline-phases/index.js +1 -0
  70. package/dist/core/ingestion/pipeline.d.ts +4 -0
  71. package/dist/core/ingestion/pipeline.js +9 -5
  72. package/dist/core/run-analyze.d.ts +1 -0
  73. package/dist/core/run-analyze.js +36 -30
  74. package/dist/core/search/bm25-index.d.ts +3 -3
  75. package/dist/core/search/bm25-index.js +9 -9
  76. package/dist/core/search/hybrid-search.js +2 -2
  77. package/dist/core/wiki/generator.d.ts +2 -2
  78. package/dist/core/wiki/generator.js +4 -4
  79. package/dist/core/wiki/graph-queries.d.ts +2 -2
  80. package/dist/core/wiki/graph-queries.js +5 -5
  81. package/dist/mcp/core/cgdb-adapter.d.ts +5 -0
  82. package/dist/mcp/core/cgdb-adapter.js +5 -0
  83. package/dist/mcp/core/embedder.js +6 -3
  84. package/dist/mcp/local/local-backend.d.ts +14 -2
  85. package/dist/mcp/local/local-backend.js +396 -18
  86. package/dist/mcp/resources.js +139 -0
  87. package/dist/mcp/server.js +3 -3
  88. package/dist/mcp/tools.js +175 -3
  89. package/dist/server/analyze-worker.js +2 -2
  90. package/dist/server/api.js +147 -31
  91. package/dist/storage/repo-manager.d.ts +10 -5
  92. package/dist/storage/repo-manager.js +10 -6
  93. package/dist/types/pipeline.d.ts +2 -0
  94. package/hooks/claude/codragraph-hook.cjs +4 -4
  95. package/package.json +15 -6
  96. package/scripts/build.js +21 -21
  97. package/skills/codragraph-cli.md +17 -1
  98. package/skills/codragraph-guide.md +6 -2
  99. package/skills/codragraph-onboarding.md +2 -2
  100. package/vendor/tree-sitter-proto/bindings/node/index.js +3 -3
  101. package/vendor/tree-sitter-proto/src/node-types.json +1 -1
@@ -0,0 +1,93 @@
1
+ /**
2
+ * LadybugDB connection pool (core). Used by MCP, sync, search, wiki, etc.
3
+ *
4
+ * LadybugDB Adapter (Connection Pool)
5
+ *
6
+ * Manages a pool of LadybugDB databases keyed by repoId, each with
7
+ * multiple Connection objects for safe concurrent query execution.
8
+ *
9
+ * LadybugDB Connections are NOT thread-safe — a single Connection
10
+ * segfaults if concurrent .query() calls hit it simultaneously.
11
+ * This adapter provides a checkout/return connection pool so each
12
+ * concurrent query gets its own Connection from the same Database.
13
+ *
14
+ * @see https://docs.ladybugdb.com/concurrency — multiple Connections
15
+ * from the same Database is the officially supported concurrency pattern.
16
+ */
17
+ import cgdb from '@ladybugdb/core';
18
+ /**
19
+ * Listeners notified when a pool entry is torn down (LRU eviction, idle
20
+ * timeout, explicit close). Used by upper layers (e.g. the BM25 search
21
+ * module) to invalidate per-repo caches that must not outlive the pool
22
+ * entry that produced them.
23
+ *
24
+ * Listeners run synchronously inside `closeOne` after the pool entry has
25
+ * been removed; throwing listeners are isolated so one bad listener does
26
+ * not prevent others from firing or break teardown.
27
+ */
28
+ type PoolCloseListener = (repoId: string) => void;
29
+ /**
30
+ * Subscribe to pool-close events. Returns a disposer that removes the
31
+ * listener (handy for tests).
32
+ */
33
+ export declare function addPoolCloseListener(listener: PoolCloseListener): () => void;
34
+ /** Saved real stdout/stderr write — used to silence native module output without race conditions */
35
+ export declare const realStdoutWrite: any;
36
+ export declare const realStderrWrite: any;
37
+ /**
38
+ * Touch a repo to reset its idle timeout.
39
+ * Call this during long-running operations to prevent the connection from being closed.
40
+ */
41
+ export declare const touchRepo: (repoId: string) => void;
42
+ /**
43
+ * Silence stdout by replacing process.stdout.write with a no-op.
44
+ * Uses a reference counter so nested silence/restore pairs are safe.
45
+ * Exported so other modules (e.g. embedder) use the same mechanism instead
46
+ * of independently patching stdout, which causes restore-order conflicts.
47
+ */
48
+ export declare function silenceStdout(): void;
49
+ export declare function restoreStdout(): void;
50
+ /**
51
+ * Initialize (or reuse) a Database + connection pool for a specific repo.
52
+ * Retries on lock errors (e.g., when `codragraph analyze` is running).
53
+ *
54
+ * Concurrent calls for the same repoId are deduplicated — the second caller
55
+ * awaits the first's in-progress init rather than starting a redundant one.
56
+ */
57
+ export declare const initCgdb: (repoId: string, dbPath: string) => Promise<void>;
58
+ /**
59
+ * Initialize a pool entry from a pre-existing Database object.
60
+ *
61
+ * Used in tests to avoid the writable→close→read-only cycle that crashes
62
+ * on macOS due to N-API destructor segfaults. The pool adapter reuses
63
+ * the core adapter's writable Database instead of opening a new read-only one.
64
+ *
65
+ * The Database is registered in the shared dbCache so closeOne() decrements
66
+ * the refCount correctly. If the Database is already cached (e.g. another
67
+ * repoId already injected it), the existing entry is reused.
68
+ */
69
+ export declare function initCgdbWithDb(repoId: string, existingDb: cgdb.Database, dbPath: string): Promise<void>;
70
+ export declare const executeQuery: (repoId: string, cypher: string) => Promise<any[]>;
71
+ /**
72
+ * Execute a parameterized query on a specific repo's connection pool.
73
+ * Uses prepare/execute pattern to prevent Cypher injection.
74
+ */
75
+ export declare const executeParameterized: (repoId: string, cypher: string, params: Record<string, any>) => Promise<any[]>;
76
+ /**
77
+ * Close one or all repo pools.
78
+ * If repoId is provided, close only that repo's connections.
79
+ * If omitted, close all repos.
80
+ */
81
+ export declare const closeCgdb: (repoId?: string) => Promise<void>;
82
+ /**
83
+ * Check if a specific repo's pool is active
84
+ */
85
+ export declare const isCgdbReady: (repoId: string) => boolean;
86
+ /** Regex to detect write operations in user-supplied Cypher queries.
87
+ * Note: CALL is NOT blocked — it's used for read-only FTS (CALL QUERY_FTS_INDEX)
88
+ * and vector search (CALL QUERY_VECTOR_INDEX). The database is opened in
89
+ * read-only mode as defense-in-depth against write procedures. */
90
+ export declare const CYPHER_WRITE_RE: RegExp;
91
+ /** Check if a Cypher query contains write operations */
92
+ export declare function isWriteQuery(query: string): boolean;
93
+ export {};
@@ -0,0 +1,550 @@
1
+ /**
2
+ * LadybugDB connection pool (core). Used by MCP, sync, search, wiki, etc.
3
+ *
4
+ * LadybugDB Adapter (Connection Pool)
5
+ *
6
+ * Manages a pool of LadybugDB databases keyed by repoId, each with
7
+ * multiple Connection objects for safe concurrent query execution.
8
+ *
9
+ * LadybugDB Connections are NOT thread-safe — a single Connection
10
+ * segfaults if concurrent .query() calls hit it simultaneously.
11
+ * This adapter provides a checkout/return connection pool so each
12
+ * concurrent query gets its own Connection from the same Database.
13
+ *
14
+ * @see https://docs.ladybugdb.com/concurrency — multiple Connections
15
+ * from the same Database is the officially supported concurrency pattern.
16
+ */
17
+ import fs from 'fs/promises';
18
+ import cgdb from '@ladybugdb/core';
19
+ const pool = new Map();
20
+ const poolCloseListeners = new Set();
21
+ /**
22
+ * Subscribe to pool-close events. Returns a disposer that removes the
23
+ * listener (handy for tests).
24
+ */
25
+ export function addPoolCloseListener(listener) {
26
+ poolCloseListeners.add(listener);
27
+ return () => {
28
+ poolCloseListeners.delete(listener);
29
+ };
30
+ }
31
+ const dbCache = new Map();
32
+ /** Max repos in the pool (LRU eviction) */
33
+ const MAX_POOL_SIZE = 5;
34
+ /** Idle timeout before closing a repo's connections */
35
+ const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
36
+ /** Max connections per repo (caps concurrent queries per repo) */
37
+ const MAX_CONNS_PER_REPO = 8;
38
+ let idleTimer = null;
39
+ /** Saved real stdout/stderr write — used to silence native module output without race conditions */
40
+ export const realStdoutWrite = process.stdout.write.bind(process.stdout);
41
+ export const realStderrWrite = process.stderr.write.bind(process.stderr);
42
+ let stdoutSilenceCount = 0;
43
+ /** True while pre-warming connections — prevents watchdog from prematurely restoring stdout */
44
+ let preWarmActive = false;
45
+ /**
46
+ * Start the idle cleanup timer (runs every 60s)
47
+ */
48
+ function ensureIdleTimer() {
49
+ if (idleTimer)
50
+ return;
51
+ idleTimer = setInterval(() => {
52
+ const now = Date.now();
53
+ for (const [repoId, entry] of pool) {
54
+ if (now - entry.lastUsed > IDLE_TIMEOUT_MS && entry.checkedOut === 0) {
55
+ closeOne(repoId);
56
+ }
57
+ }
58
+ }, 60_000);
59
+ if (idleTimer && typeof idleTimer === 'object' && 'unref' in idleTimer) {
60
+ idleTimer.unref();
61
+ }
62
+ }
63
+ /**
64
+ * Touch a repo to reset its idle timeout.
65
+ * Call this during long-running operations to prevent the connection from being closed.
66
+ */
67
+ export const touchRepo = (repoId) => {
68
+ const entry = pool.get(repoId);
69
+ if (entry) {
70
+ entry.lastUsed = Date.now();
71
+ }
72
+ };
73
+ /**
74
+ * Evict the least-recently-used repo if pool is at capacity
75
+ */
76
+ function evictLRU() {
77
+ if (pool.size < MAX_POOL_SIZE)
78
+ return;
79
+ let oldestId = null;
80
+ let oldestTime = Infinity;
81
+ for (const [id, entry] of pool) {
82
+ if (entry.checkedOut === 0 && entry.lastUsed < oldestTime) {
83
+ oldestTime = entry.lastUsed;
84
+ oldestId = id;
85
+ }
86
+ }
87
+ if (oldestId) {
88
+ closeOne(oldestId);
89
+ }
90
+ }
91
+ /**
92
+ * Remove a repo from the pool, close its connections, and release its
93
+ * shared Database ref. Only closes the Database when no other repoIds
94
+ * reference it (refCount === 0).
95
+ */
96
+ function closeOne(repoId) {
97
+ const entry = pool.get(repoId);
98
+ if (!entry)
99
+ return;
100
+ entry.closed = true;
101
+ // Close available connections — fire-and-forget with .catch() to prevent
102
+ // unhandled rejections. Native close() returns Promise<void> but can crash
103
+ // the N-API destructor on macOS/Windows; deferring to process exit lets
104
+ // dangerouslyIgnoreUnhandledErrors absorb the crash.
105
+ for (const conn of entry.available) {
106
+ conn.close().catch(() => { });
107
+ }
108
+ entry.available.length = 0;
109
+ // Checked-out connections can't be closed here — they're in-flight.
110
+ // The checkin() function detects entry.closed and closes them on return.
111
+ // Only close the Database when no other repoIds reference it.
112
+ // External databases (injected via initCgdbWithDb) are never closed here —
113
+ // the core adapter owns them and handles their lifecycle.
114
+ const shared = dbCache.get(entry.dbPath);
115
+ if (shared) {
116
+ shared.refCount--;
117
+ if (shared.refCount === 0) {
118
+ if (shared.external) {
119
+ // External databases are owned by the core adapter — don't close
120
+ // or remove from cache. Keep the entry so future initCgdb() calls
121
+ // for the same dbPath reuse it instead of hitting a file lock.
122
+ shared.refCount = 0;
123
+ shared.ftsLoaded = false;
124
+ shared.vectorLoaded = false;
125
+ }
126
+ else {
127
+ shared.db.close().catch(() => { });
128
+ dbCache.delete(entry.dbPath);
129
+ }
130
+ }
131
+ }
132
+ pool.delete(repoId);
133
+ // Notify listeners AFTER the pool entry is gone so any cache-invalidation
134
+ // they perform is consistent with `isCgdbReady(repoId) === false`.
135
+ for (const listener of poolCloseListeners) {
136
+ try {
137
+ listener(repoId);
138
+ }
139
+ catch {
140
+ // Isolate listener failures — teardown must complete.
141
+ }
142
+ }
143
+ }
144
+ /**
145
+ * Create a new Connection from a repo's Database.
146
+ * Silences stdout to prevent native module output from corrupting MCP stdio.
147
+ */
148
+ let activeQueryCount = 0;
149
+ /**
150
+ * Silence stdout by replacing process.stdout.write with a no-op.
151
+ * Uses a reference counter so nested silence/restore pairs are safe.
152
+ * Exported so other modules (e.g. embedder) use the same mechanism instead
153
+ * of independently patching stdout, which causes restore-order conflicts.
154
+ */
155
+ export function silenceStdout() {
156
+ if (stdoutSilenceCount++ === 0) {
157
+ process.stdout.write = (() => true);
158
+ }
159
+ }
160
+ export function restoreStdout() {
161
+ if (--stdoutSilenceCount <= 0) {
162
+ stdoutSilenceCount = 0;
163
+ process.stdout.write = realStdoutWrite;
164
+ }
165
+ }
166
+ // Safety watchdog: restore stdout if it gets stuck silenced (e.g. native crash
167
+ // inside createConnection before restoreStdout runs).
168
+ // Exempts active queries and pre-warm — these legitimately hold silence for
169
+ // longer than 1 second (queries can take up to QUERY_TIMEOUT_MS = 30s).
170
+ setInterval(() => {
171
+ if (stdoutSilenceCount > 0 && !preWarmActive && activeQueryCount === 0) {
172
+ stdoutSilenceCount = 0;
173
+ process.stdout.write = realStdoutWrite;
174
+ }
175
+ }, 1000).unref();
176
+ function createConnection(db) {
177
+ silenceStdout();
178
+ try {
179
+ return new cgdb.Connection(db);
180
+ }
181
+ finally {
182
+ restoreStdout();
183
+ }
184
+ }
185
+ /** Query timeout in milliseconds */
186
+ const QUERY_TIMEOUT_MS = 30_000;
187
+ /** Waiter queue timeout in milliseconds */
188
+ const WAITER_TIMEOUT_MS = 15_000;
189
+ const LOCK_RETRY_ATTEMPTS = 3;
190
+ const LOCK_RETRY_DELAY_MS = 2000;
191
+ /** Deduplicates concurrent initCgdb calls for the same repoId */
192
+ const initPromises = new Map();
193
+ /**
194
+ * Initialize (or reuse) a Database + connection pool for a specific repo.
195
+ * Retries on lock errors (e.g., when `codragraph analyze` is running).
196
+ *
197
+ * Concurrent calls for the same repoId are deduplicated — the second caller
198
+ * awaits the first's in-progress init rather than starting a redundant one.
199
+ */
200
+ export const initCgdb = async (repoId, dbPath) => {
201
+ const existing = pool.get(repoId);
202
+ if (existing) {
203
+ existing.lastUsed = Date.now();
204
+ return;
205
+ }
206
+ // Deduplicate concurrent init calls for the same repoId —
207
+ // prevents double-init race when multiple parallel tool calls
208
+ // trigger initialization for the same repo simultaneously.
209
+ const pending = initPromises.get(repoId);
210
+ if (pending)
211
+ return pending;
212
+ const promise = doInitCgdb(repoId, dbPath);
213
+ initPromises.set(repoId, promise);
214
+ try {
215
+ await promise;
216
+ }
217
+ finally {
218
+ initPromises.delete(repoId);
219
+ }
220
+ };
221
+ /**
222
+ * Internal init — creates DB, pre-warms connections, loads FTS, then registers pool.
223
+ * Pool entry is registered LAST so concurrent executeQuery calls see either
224
+ * "not initialized" (and throw) or a fully ready pool — never a half-built one.
225
+ */
226
+ async function doInitCgdb(repoId, dbPath) {
227
+ // Check if database exists
228
+ try {
229
+ await fs.stat(dbPath);
230
+ }
231
+ catch {
232
+ throw new Error(`LadybugDB not found at ${dbPath}. Run: codragraph analyze`);
233
+ }
234
+ evictLRU();
235
+ // Reuse an existing native Database if another repoId already opened this path.
236
+ // This prevents buffer manager exhaustion from multiple mmap regions on the same file.
237
+ let shared = dbCache.get(dbPath);
238
+ if (!shared) {
239
+ // Open in read-only mode — MCP server never writes to the database.
240
+ // This allows multiple MCP server instances to read concurrently, and
241
+ // avoids lock conflicts when `codragraph analyze` is writing.
242
+ let lastError = null;
243
+ for (let attempt = 1; attempt <= LOCK_RETRY_ATTEMPTS; attempt++) {
244
+ silenceStdout();
245
+ try {
246
+ const db = new cgdb.Database(dbPath, 0, // bufferManagerSize (default)
247
+ false, // enableCompression (default)
248
+ true);
249
+ restoreStdout();
250
+ shared = { db, refCount: 0, ftsLoaded: false, vectorLoaded: false };
251
+ dbCache.set(dbPath, shared);
252
+ break;
253
+ }
254
+ catch (err) {
255
+ restoreStdout();
256
+ lastError = err instanceof Error ? err : new Error(String(err));
257
+ const isLockError = lastError.message.includes('Could not set lock') || lastError.message.includes('lock');
258
+ if (!isLockError || attempt === LOCK_RETRY_ATTEMPTS)
259
+ break;
260
+ await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS * attempt));
261
+ }
262
+ }
263
+ if (!shared) {
264
+ throw new Error(`LadybugDB unavailable for ${repoId}. Another process may be rebuilding the index. ` +
265
+ `Retry later. (${lastError?.message || 'unknown error'})`);
266
+ }
267
+ }
268
+ shared.refCount++;
269
+ const db = shared.db;
270
+ // Pre-create the full pool upfront so createConnection() (which silences
271
+ // stdout) is never called lazily during active query execution.
272
+ // Mark preWarmActive so the watchdog timer doesn't interfere.
273
+ preWarmActive = true;
274
+ const available = [];
275
+ try {
276
+ for (let i = 0; i < MAX_CONNS_PER_REPO; i++) {
277
+ available.push(createConnection(db));
278
+ }
279
+ }
280
+ finally {
281
+ preWarmActive = false;
282
+ }
283
+ // Load FTS + VECTOR extensions on EVERY connection in the pool.
284
+ //
285
+ // CRITICAL: LadybugDB's extension state is per-connection on macOS (and
286
+ // possibly other platforms) — loading on `available[0]` does NOT propagate
287
+ // to the other connections. Since checkout pops from the end of the array,
288
+ // queries would otherwise hit unloaded connections and `QUERY_FTS_INDEX`
289
+ // would silently return 0 rows (catch in queryFTSViaExecutor swallows the
290
+ // "extension not loaded" error). That broke every search on macOS.
291
+ //
292
+ // Load on all connections concurrently, but BEFORE the pool is registered
293
+ // so no checkout can race the load.
294
+ await loadExtensionsOnConnections(available, shared);
295
+ // Register pool entry only after all connections are pre-warmed and FTS is
296
+ // loaded. Concurrent executeQuery calls see either "not initialized"
297
+ // (and throw cleanly) or a fully ready pool — never a half-built one.
298
+ pool.set(repoId, {
299
+ db,
300
+ available,
301
+ checkedOut: 0,
302
+ waiters: [],
303
+ lastUsed: Date.now(),
304
+ dbPath,
305
+ closed: false,
306
+ });
307
+ ensureIdleTimer();
308
+ }
309
+ /**
310
+ * Load FTS + VECTOR extensions on every connection in the pool.
311
+ * Per-connection load is required because LadybugDB's extension state is
312
+ * not always shared across connections of the same Database (observed on
313
+ * macOS native bindings — silent FTS failures otherwise).
314
+ */
315
+ async function loadExtensionsOnConnections(conns, shared) {
316
+ // FTS: every connection needs `LOAD EXTENSION fts` independently.
317
+ await Promise.all(conns.map(async (c) => {
318
+ try {
319
+ await c.query('LOAD EXTENSION fts');
320
+ }
321
+ catch {
322
+ // already-loaded / not-installed — FTS queries fail gracefully if missing
323
+ }
324
+ }));
325
+ shared.ftsLoaded = true;
326
+ // VECTOR: install once on the Database (idempotent), then LOAD on every
327
+ // connection. INSTALL is a no-op after the first call but we run it on
328
+ // conn[0] to guarantee the package is present before fanning out LOADs.
329
+ try {
330
+ await conns[0].query('INSTALL VECTOR');
331
+ }
332
+ catch {
333
+ /* not available — semantic search will be a no-op */
334
+ }
335
+ await Promise.all(conns.map(async (c) => {
336
+ try {
337
+ await c.query('LOAD EXTENSION VECTOR');
338
+ }
339
+ catch {
340
+ /* not available — semantic search will be a no-op */
341
+ }
342
+ }));
343
+ shared.vectorLoaded = true;
344
+ }
345
+ /**
346
+ * Initialize a pool entry from a pre-existing Database object.
347
+ *
348
+ * Used in tests to avoid the writable→close→read-only cycle that crashes
349
+ * on macOS due to N-API destructor segfaults. The pool adapter reuses
350
+ * the core adapter's writable Database instead of opening a new read-only one.
351
+ *
352
+ * The Database is registered in the shared dbCache so closeOne() decrements
353
+ * the refCount correctly. If the Database is already cached (e.g. another
354
+ * repoId already injected it), the existing entry is reused.
355
+ */
356
+ export async function initCgdbWithDb(repoId, existingDb, dbPath) {
357
+ const existing = pool.get(repoId);
358
+ if (existing) {
359
+ existing.lastUsed = Date.now();
360
+ return;
361
+ }
362
+ // Register in dbCache with external: true so other initCgdb() calls
363
+ // for the same dbPath reuse this Database instead of trying to open
364
+ // a new one (which would fail with a file lock error).
365
+ // closeOne() respects the external flag and skips db.close().
366
+ let shared = dbCache.get(dbPath);
367
+ if (!shared) {
368
+ shared = { db: existingDb, refCount: 0, ftsLoaded: false, vectorLoaded: false, external: true };
369
+ dbCache.set(dbPath, shared);
370
+ }
371
+ shared.refCount++;
372
+ const available = [];
373
+ preWarmActive = true;
374
+ try {
375
+ for (let i = 0; i < MAX_CONNS_PER_REPO; i++) {
376
+ available.push(createConnection(existingDb));
377
+ }
378
+ }
379
+ finally {
380
+ preWarmActive = false;
381
+ }
382
+ // Load FTS + VECTOR on every connection (see loadExtensionsOnConnections
383
+ // for why per-connection load is required on macOS native bindings).
384
+ await loadExtensionsOnConnections(available, shared);
385
+ pool.set(repoId, {
386
+ db: existingDb,
387
+ available,
388
+ checkedOut: 0,
389
+ waiters: [],
390
+ lastUsed: Date.now(),
391
+ dbPath,
392
+ closed: false,
393
+ });
394
+ ensureIdleTimer();
395
+ }
396
+ /**
397
+ * Checkout a connection from the pool.
398
+ * Returns an available connection, or creates a new one if under the cap.
399
+ * If all connections are busy and at cap, queues the caller until one is returned.
400
+ */
401
+ function checkout(entry) {
402
+ // Fast path: grab an available connection
403
+ if (entry.available.length > 0) {
404
+ entry.checkedOut++;
405
+ return Promise.resolve(entry.available.pop());
406
+ }
407
+ // Pool was pre-warmed to MAX_CONNS_PER_REPO during init. If we're here
408
+ // with fewer total connections, something leaked — surface the bug rather
409
+ // than silently creating a connection (which would silence stdout mid-query).
410
+ const totalConns = entry.available.length + entry.checkedOut;
411
+ if (totalConns < MAX_CONNS_PER_REPO) {
412
+ throw new Error(`Connection pool integrity error: expected ${MAX_CONNS_PER_REPO} ` +
413
+ `connections but found ${totalConns} (${entry.available.length} available, ` +
414
+ `${entry.checkedOut} checked out)`);
415
+ }
416
+ // At capacity — queue the caller with a timeout.
417
+ return new Promise((resolve, reject) => {
418
+ const waiter = (conn) => {
419
+ clearTimeout(timer);
420
+ resolve(conn);
421
+ };
422
+ const timer = setTimeout(() => {
423
+ const idx = entry.waiters.indexOf(waiter);
424
+ if (idx !== -1)
425
+ entry.waiters.splice(idx, 1);
426
+ reject(new Error(`Connection pool exhausted: timed out after ${WAITER_TIMEOUT_MS}ms waiting for a free connection`));
427
+ }, WAITER_TIMEOUT_MS);
428
+ entry.waiters.push(waiter);
429
+ });
430
+ }
431
+ /**
432
+ * Return a connection to the pool after use.
433
+ * If the pool entry was closed while the connection was checked out (e.g.
434
+ * LRU eviction), close the orphaned connection instead of returning it.
435
+ * If there are queued waiters, hand the connection directly to the next one
436
+ * instead of putting it back in the available array (avoids race conditions).
437
+ */
438
+ function checkin(entry, conn) {
439
+ if (entry.closed) {
440
+ // Pool entry was deleted during checkout — close the orphaned connection
441
+ conn.close().catch(() => { });
442
+ return;
443
+ }
444
+ if (entry.waiters.length > 0) {
445
+ // Hand directly to the next waiter — no intermediate available state
446
+ const waiter = entry.waiters.shift();
447
+ waiter(conn);
448
+ }
449
+ else {
450
+ entry.checkedOut--;
451
+ entry.available.push(conn);
452
+ }
453
+ }
454
+ /**
455
+ * Execute a query on a specific repo's connection pool.
456
+ * Automatically checks out a connection, runs the query, and returns it.
457
+ */
458
+ /** Race a promise against a timeout */
459
+ function withTimeout(promise, ms, label) {
460
+ let timer;
461
+ const timeout = new Promise((_, reject) => {
462
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
463
+ });
464
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
465
+ }
466
+ export const executeQuery = async (repoId, cypher) => {
467
+ const entry = pool.get(repoId);
468
+ if (!entry) {
469
+ throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initCgdb first.`);
470
+ }
471
+ if (isWriteQuery(cypher)) {
472
+ throw new Error('Write operations are not allowed. The pool adapter is read-only.');
473
+ }
474
+ entry.lastUsed = Date.now();
475
+ const conn = await checkout(entry);
476
+ silenceStdout();
477
+ activeQueryCount++;
478
+ try {
479
+ const queryResult = await withTimeout(conn.query(cypher), QUERY_TIMEOUT_MS, 'Query');
480
+ const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
481
+ const rows = await result.getAll();
482
+ return rows;
483
+ }
484
+ finally {
485
+ activeQueryCount--;
486
+ restoreStdout();
487
+ checkin(entry, conn);
488
+ }
489
+ };
490
+ /**
491
+ * Execute a parameterized query on a specific repo's connection pool.
492
+ * Uses prepare/execute pattern to prevent Cypher injection.
493
+ */
494
+ export const executeParameterized = async (repoId, cypher, params) => {
495
+ const entry = pool.get(repoId);
496
+ if (!entry) {
497
+ throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initCgdb first.`);
498
+ }
499
+ entry.lastUsed = Date.now();
500
+ const conn = await checkout(entry);
501
+ silenceStdout();
502
+ activeQueryCount++;
503
+ try {
504
+ const stmt = await withTimeout(conn.prepare(cypher), QUERY_TIMEOUT_MS, 'Prepare');
505
+ if (!stmt.isSuccess()) {
506
+ const errMsg = await stmt.getErrorMessage();
507
+ throw new Error(`Prepare failed: ${errMsg}`);
508
+ }
509
+ const queryResult = await withTimeout(conn.execute(stmt, params), QUERY_TIMEOUT_MS, 'Execute');
510
+ const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
511
+ const rows = await result.getAll();
512
+ return rows;
513
+ }
514
+ finally {
515
+ activeQueryCount--;
516
+ restoreStdout();
517
+ checkin(entry, conn);
518
+ }
519
+ };
520
+ /**
521
+ * Close one or all repo pools.
522
+ * If repoId is provided, close only that repo's connections.
523
+ * If omitted, close all repos.
524
+ */
525
+ export const closeCgdb = async (repoId) => {
526
+ if (repoId) {
527
+ closeOne(repoId);
528
+ return;
529
+ }
530
+ for (const id of [...pool.keys()]) {
531
+ closeOne(id);
532
+ }
533
+ if (idleTimer) {
534
+ clearInterval(idleTimer);
535
+ idleTimer = null;
536
+ }
537
+ };
538
+ /**
539
+ * Check if a specific repo's pool is active
540
+ */
541
+ export const isCgdbReady = (repoId) => pool.has(repoId);
542
+ /** Regex to detect write operations in user-supplied Cypher queries.
543
+ * Note: CALL is NOT blocked — it's used for read-only FTS (CALL QUERY_FTS_INDEX)
544
+ * and vector search (CALL QUERY_VECTOR_INDEX). The database is opened in
545
+ * read-only mode as defense-in-depth against write procedures. */
546
+ export const CYPHER_WRITE_RE = /(?<!:)\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH|FOREACH|INSTALL|LOAD)\b/i;
547
+ /** Check if a Cypher query contains write operations */
548
+ export function isWriteQuery(query) {
549
+ return CYPHER_WRITE_RE.test(query);
550
+ }