mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-09 22:33:39 +00:00
* feat(lbug): add ensureEmbeddingRowDmlSafe VECTOR gate for embedding-row DML LadybugDB refuses every mutation of a table carrying an HNSW index while the VECTOR extension is not loaded on that connection: DELETE and CREATE raise a Binder exception, DROP TABLE is refused while the index references it, and SET segfaults the process. Dropping the index is not an available recovery either — CALL DROP_VECTOR_INDEX is itself a VECTOR-extension function and is undefined in exactly that state. Add a single primitive that loads VECTOR under the analyze install policy and, only when that fails, reads CALL SHOW_INDEXES (which works without the extension) to decide whether an index actually exists to trip over. No call sites yet. Refs #2623 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(lbug): pin the #2623 VECTOR gate for embedding-row DML Three cases: no index + VECTOR unavailable stays safe (no needless escalation); index present + VECTOR unavailable is reported blocked AND the raw deleteNodesForFiles genuinely throws 'extension is not loaded' (proving the hazard is real, not theoretical); index present + VECTOR loadable is safe, the delete works, and the HNSW index survives — the invariant run-analyze relies on when it keeps the index across a surgical incremental run. Refs #2623 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(analyze): load VECTOR before the incremental writeback touches embedding rows Incremental analyze died on every content change once a repo had built code_embedding_idx: Analysis failed: Binder exception: Trying to delete from an index on table CodeEmbedding but its extension is not loaded. The surgical writeback's first statement is deleteNodesForFiles' CodeEmbedding join-delete, but nothing on that path loaded VECTOR until Phase 4 — so the engine refused the delete. This is an ordering defect, not an environment one: it reproduces on machines where VECTOR loads fine. The dirty-flag recovery then forced a full rebuild on the next run, which is why it read as 'just slow'. Call ensureEmbeddingRowDmlSafe() once, before the escalation gate and before any row is touched — the same 'index lifecycle before row DML' seam dropSearchFTSIndexes occupies for FTS (#2589). Unconditional, because a DB carrying the index from an earlier --embeddings run hits the same wall on a plain incremental run. When VECTOR truly cannot load the table is immutable (the index cannot be dropped without the extension either), so the run falls through to the existing wipe-and-COPY escalation with a message naming cause, consequence and remedy. Fixes #2623 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(analyze): pin the #2623 VECTOR-before-embedding-DML ordering end-to-end Sibling of the #2589 FTS drop-before-delete suite, same shape: drive the real runFullAnalysis incremental path over a real git repo and a real LadybugDB, seed real embedding rows, build the HNSW index, then assert the index state at the exact moment deleteNodesForFiles is invoked. Both cases were confirmed to discriminate — with the run-analyze change reverted they fail with the reported 'Trying to delete from an index on table CodeEmbedding but its extension is not loaded', and pass with it: - surgical path: the run completes, the index is still present AND extension_loaded at delete time, exactly one row per nodeId survives, and the untouched file's rows are preserved - blocked path: with GITNEXUS_LBUG_EXTENSION_INSTALL=never the run escalates to a full DB write and says so, instead of crashing Also applies prettier's reindent to the run-analyze log ternary. Refs #2623 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(lbug): cite the pinned LadybugDB version in the #2623 probe note The probe matrix behind ensureEmbeddingRowDmlSafe was first recorded on 0.18.0, but gitnexus/package-lock.json pins 0.18.2 (#2587). Re-ran every case on 0.18.2: refused DELETE, refused CREATE, SIGSEGV on SET, DROP_VECTOR_INDEX undefined, DROP TABLE refused, SHOW_INDEXES readable with extension_loaded intact. Identical on both, so the design is unchanged — only the citation was wrong. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(analyze): preserve embeddings across the VECTOR-blocked rebuild, and check the catalog before loading Three follow-ups from reviewing the fix itself. 1. Data loss on the blocked path. Escalating wipes the DB files, and Phase 3.5 restores embedding rows from cachedEmbeddings — which deriveEmbeddingMode only populates when meta.stats.embeddings > 0. A DB holding embedding rows that its meta does not account for therefore had every vector destroyed silently by a rebuild it never asked for. Probe on a 3-file repo: 3 rows before, 0 after, no warning. Read the rows before escalating (a plain MATCH, no extension needed) so the existing restore has something to restore, and say so in the log. The blocked-path test now asserts the seeded rows survive exactly once, and that assertion fails without this rescue. 2. Catalog before extension. ensureEmbeddingRowDmlSafe loaded VECTOR first and only read SHOW_INDEXES on failure, so every incremental analyze on a machine without VECTOR paid a bounded out-of-process INSTALL attempt plus an 'extension unavailable' warning — including repos that never built an embedding index and can never hit this bug. One local catalog read settles that case first; the load is attempted only when an index actually gates DML, or when the catalog cannot be read. 3. Dead branch. targetConn is always the module singleton there, so the isSharedSingletonConn ternary could never take its second arm. Collapsed to withConnLock. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(doctor): live-probe the VECTOR extension instead of printing the static platform capability Review finding on #2624 (MEDIUM), and exactly what #2623's reporter hit: doctor printed 'VECTOR index: available' — derived from a static platform check — while every incremental analyze on the same machine was dying on an unloaded VECTOR extension. The FTS line was switched to a live LOAD probe for the identical contradiction under #2374; VECTOR now gets the same treatment. probeVectorExtensionLoad shares the FTS probe's implementation (bounded, offline-safe, never runs the installer) and doctor's semantic-mode line now follows the probe, not the platform: without a loadable extension the vector index can be neither built nor queried, so search really is on exact scan. The load-error classifier's remedies are label-parameterized so the VECTOR row stops dispensing FTS-specific advice — 'run analyze --repair-fts' repairs FTS indexes only and was actively wrong for a missing vector extension. Default label stays 'FTS'; every existing caller and pinned remedy string is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(lbug): remove the stale Windows VECTOR gate — the extension ships for win_amd64 The codebase categorically refused VECTOR on Windows (platform !== 'win32' in isVectorExtensionSupportedByPlatform, plus a hard early-return in loadVectorExtension) on the strength of an early-era report that in-process INSTALL VECTOR could SIGSEGV (#1365). That belief is stale, verified directly: - the extension server hosts win_amd64 VECTOR artifacts for every 0.18.x extension version — v0.18.0 and v0.18.1 both serve a real 14 MB PE32+ DLL (curl-probed; 'file' confirms PE32+ x86-64) - the pinned 0.18.2 core resolves its extension directory to 0.18.1 (strace-verified LOAD open()), so the pinned version's Windows artifact exists too - INSTALL now runs in a spawned child (installDuckDbExtensionOutOfProcess), so even a crashing installer kills only the child and degrades to unavailable — the original hazard cannot reach the parent process any more Windows now takes the same runtime path as every other OS: try LOAD, install out-of-process when policy allows, degrade to exact scan when it truly fails. The MCP semantic-search lane loses its static platform gate too — it always attempts the vector index and falls back to the exact scan on runtime failure, with a once-per-backend diagnostic naming the real error instead of a platform-policy message. isVectorExtensionSupportedByPlatform is deleted; getRuntimeCapabilities reports the platform capability as available everywhere and defers machine truth to the live probe. Windows CI is the enforcement: the vector suites skip visibly only when the extension genuinely cannot load, so green Windows lanes now actually exercise VECTOR instead of silently skipping by policy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(lbug): pin the catalog-read-failure fallback in ensureEmbeddingRowDmlSafe Review finding on #2624 (LOW): the one branch where the gate cannot cheaply prove safety — SHOW_INDEXES itself erroring — was exercised only by inference. Force it with a Connection.prototype.query spy over the real DB: the catalog read fails, and the gate must fall through to actually attempting the extension load (asserted via the recorded statement stream) rather than guessing, returning true here because the extension is loadable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): load VECTOR on the pool's shared Database so the semantic vector lane actually works Review finding on #2624 (MEDIUM): extension load scope is per-Database (probe-verified — LOAD on one connection enables QUERY_VECTOR_INDEX on every connection of the same Database), and the pool pre-warm loaded only FTS. So LocalBackend's vector lane has ALWAYS raised 'Catalog exception: function QUERY_VECTOR_INDEX is not defined' through the pool and silently fallen back to the exact scan — repos above the 10k exact-scan cap got empty semantic results. The serve path was unaffected (the embedding pipeline loads the extension itself). Mirror the FTS line at BOTH load sites — doInitLbug's pre-warm and initLbugWithDb's external-Database adoption — under the same load-only contract (the read pool never triggers a network install), tracked by a new SharedDB.vectorLoaded flag reset where ftsLoaded resets. The new pool test is discriminating and deliberately closes the writable core adapter before the pool opens: a shared/injected Database would inherit the VECTOR load from test seeding and pass either way, so the case forces the pool onto its OWN fresh read-only Database where only the pre-warm can make the lane legal. Verified: fails at the pre-fix tree with the exact Catalog exception, passes with the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: run the #2623 ordering suite on Windows/macOS and pre-install VECTOR alongside FTS Two review findings on #2624, both landing in existing seams: - scripts/cross-platform-tests.ts gains incremental-vector-extension-ordering .test.ts: the win32 VECTOR gate is gone in this PR, so the #2623 drop-ordering + blocked-path escalation must be proven on the windows-latest native addon, not just Ubuntu. (The review's claim that lbug-delete-nodes-for-files.test.ts was also missing was wrong — it has been on the roster since #2409.) - scripts/ensure-fts.ts now pre-installs VECTOR under the same best-effort auto-policy contract, so every sharded CI process LOADs from ~/.lbdb instead of racing its own bounded out-of-process INSTALL; the workflow's extension cache already covers it (path is the whole extension dir — key kept for cache continuity). The cross-platform job sets GITNEXUS_REQUIRE_VECTOR=1 beside GITNEXUS_REQUIRE_FTS so a genuinely unavailable VECTOR is a loud failure, never a silent skip. Windows/macOS cannot be executed locally; the PR's CI lanes are the proof for this commit. Linux smoke: ensure-fts.ts reports both extensions ready; all 79 roster entries resolve. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(pool): register loadVectorExtension in the pool unit-suite mocks The pool adapter's new loadVectorExtension import surfaced in four suites that mock lbug-adapter.js with explicit factories (vitest fails loudly on a missing mocked export). Register the export in each — resolving false where the suite's world assumes no vector, true where it mirrors FTS — and extend lbug-pool-fts-load.test.ts, the suite that owns pre-warm extension loading, with the vector pair: successful load cached per shared Database, failed load retried on the next open, both pinned to policy load-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(analyze): use POSIX literals for graph paths in the #2623 ordering suite First Windows CI run of this suite (it joined the cross-platform roster this PR) failed with 'Parser exception: Invalid input <MATCH (n:Function) WHERE n.filePath = '>' — path.join produces backslashes on Windows, and a backslash inside the seed helper's single-quoted Cypher literal breaks the parser. The graph stores repo-relative filePaths with forward slashes on every OS, so graph-side paths are POSIX literals now (the incremental-orchestration convention); path.join stays only for real filesystem access. The same Windows lane also proved the substance this suite exists for: lbug-vector-extension passed 7/7 on windows-latest — the extension installed, loaded, and built a real HNSW index there — and the pool vector-lane and DML gate suites passed too. This commit fixes the harness, not the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Gergo Magyar <abhigyan1.patwari@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
405 lines
17 KiB
TypeScript
405 lines
17 KiB
TypeScript
/**
|
|
* P0 Integration Tests: LadybugDB Connection Pool
|
|
*
|
|
* Tests: initLbug, executeQuery, executeParameterized, closeLbug lifecycle
|
|
* Covers hardening fixes: parameterized queries, query timeout,
|
|
* waiter queue timeout, idle eviction guards, stdout silencing race
|
|
*/
|
|
import { describe, it, expect, afterEach } from 'vitest';
|
|
import {
|
|
initLbug,
|
|
executeQuery,
|
|
executeParameterized,
|
|
closeLbug,
|
|
isLbugReady,
|
|
} from '../../src/mcp/core/lbug-adapter.js';
|
|
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
|
|
|
const POOL_SEED_DATA = [
|
|
`CREATE (f:File {id: 'file:index.ts', name: 'index.ts', filePath: 'src/index.ts', content: ''})`,
|
|
`CREATE (fn:Function {id: 'func:main', name: 'main', filePath: 'src/index.ts', startLine: 1, endLine: 10, isExported: true, content: '', description: ''})`,
|
|
`CREATE (fn2:Function {id: 'func:helper', name: 'helper', filePath: 'src/utils.ts', startLine: 1, endLine: 5, isExported: true, content: '', description: ''})`,
|
|
`MATCH (a:Function), (b:Function)
|
|
WHERE a.id = 'func:main' AND b.id = 'func:helper'
|
|
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'direct', step: 0}]->(b)`,
|
|
];
|
|
|
|
// ─── Pool lifecycle tests — test the pool adapter API directly ───────
|
|
|
|
withTestLbugDB(
|
|
'lbug-pool',
|
|
(handle) => {
|
|
afterEach(async () => {
|
|
try {
|
|
await closeLbug('test-repo');
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
try {
|
|
await closeLbug('repo1');
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
try {
|
|
await closeLbug('repo2');
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
try {
|
|
await closeLbug('');
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
});
|
|
|
|
// ─── Lifecycle: init → query → close ─────────────────────────────────
|
|
|
|
describe('pool lifecycle', () => {
|
|
it('initLbug + executeQuery + closeLbug', async () => {
|
|
await initLbug('test-repo', handle.dbPath);
|
|
expect(isLbugReady('test-repo')).toBe(true);
|
|
|
|
const rows = await executeQuery('test-repo', 'MATCH (n:Function) RETURN n.name AS name');
|
|
expect(rows.length).toBeGreaterThanOrEqual(2);
|
|
const names = rows.map((r: any) => r.name);
|
|
expect(names).toContain('main');
|
|
expect(names).toContain('helper');
|
|
|
|
await closeLbug('test-repo');
|
|
expect(isLbugReady('test-repo')).toBe(false);
|
|
});
|
|
|
|
it('initLbug reuses existing pool entry', async () => {
|
|
await initLbug('test-repo', handle.dbPath);
|
|
await initLbug('test-repo', handle.dbPath); // second call should be no-op
|
|
expect(isLbugReady('test-repo')).toBe(true);
|
|
});
|
|
|
|
it('closeLbug is idempotent', async () => {
|
|
await initLbug('test-repo', handle.dbPath);
|
|
await closeLbug('test-repo');
|
|
await closeLbug('test-repo'); // second close should not throw
|
|
expect(isLbugReady('test-repo')).toBe(false);
|
|
});
|
|
|
|
it('closeLbug with no args closes all repos', async () => {
|
|
await initLbug('repo1', handle.dbPath);
|
|
await initLbug('repo2', handle.dbPath);
|
|
expect(isLbugReady('repo1')).toBe(true);
|
|
expect(isLbugReady('repo2')).toBe(true);
|
|
|
|
await closeLbug();
|
|
expect(isLbugReady('repo1')).toBe(false);
|
|
expect(isLbugReady('repo2')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── closeLbug rejects pending waiters (#2068 follow-up) ─────────────
|
|
//
|
|
// Before the fix, closeOne() never rejected queued waiters: a caller
|
|
// waiting for a free connection when the pool was closed (e.g. a staleness
|
|
// reinit under concurrent query load) hung for WAITER_TIMEOUT_MS (15s) and
|
|
// then surfaced a misleading "pool exhausted" error. Now they reject
|
|
// immediately with an actionable "pool closed" message. The pool caps at
|
|
// MAX_CONNS_PER_REPO (8); firing a synchronous burst larger than that queues
|
|
// the surplus as waiters, and closing synchronously (before any query
|
|
// settles) must reject every queued waiter at once. The default 5s test
|
|
// timeout also guards promptness — a regression would block ~15s and time
|
|
// out rather than reject.
|
|
describe('closeLbug waiter handling (#2068)', () => {
|
|
it('rejects queued waiters promptly with a pool-closed error on close', async () => {
|
|
await initLbug('test-repo', handle.dbPath);
|
|
|
|
// Fire a burst larger than the 8-connection cap WITHOUT awaiting: the
|
|
// first 8 check out connections synchronously, the surplus queue as
|
|
// waiters — all before the synchronous closeLbug below runs.
|
|
const BURST = 24;
|
|
const MAX_CONNS = 8;
|
|
const inflight = Array.from({ length: BURST }, () =>
|
|
executeQuery('test-repo', 'MATCH (n:Function) RETURN n.name AS name'),
|
|
);
|
|
// Close in the same synchronous tick — no microtask has served a waiter.
|
|
const closing = closeLbug('test-repo');
|
|
|
|
const settled = await Promise.allSettled(inflight);
|
|
await closing;
|
|
|
|
const reasons = settled
|
|
.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
|
|
.map((r) => String(r.reason?.message ?? r.reason));
|
|
|
|
// The surplus (BURST - MAX_CONNS) waiters must reject with "pool closed".
|
|
const poolClosed = reasons.filter((m) => /pool closed/i.test(m));
|
|
expect(poolClosed.length).toBeGreaterThanOrEqual(BURST - MAX_CONNS);
|
|
// And none should have hit the 15s "exhausted" waiter-timeout path.
|
|
expect(reasons.some((m) => /waiting for a free connection/i.test(m))).toBe(false);
|
|
|
|
expect(isLbugReady('test-repo')).toBe(false);
|
|
});
|
|
|
|
it('settles in-flight queries and fully tears down when closed mid-flight', async () => {
|
|
// closeOne-vs-checkin interleave (F4b): with 8 connections in-flight and
|
|
// surplus callers queued, a synchronous close must (a) let every promise
|
|
// settle — no hang — and (b) fully delete the pool entry so checked-in
|
|
// connections are closed as orphans rather than handed to a rejected
|
|
// waiter. We assert the observable contract; the "orphan not handed to a
|
|
// rejected waiter" invariant is single-threaded-by-construction (closeOne
|
|
// drains waiters with no await before any checkin can run).
|
|
await initLbug('test-repo', handle.dbPath);
|
|
|
|
const inflight = Array.from({ length: 16 }, () =>
|
|
executeQuery('test-repo', 'MATCH (n:Function) RETURN n.name AS name'),
|
|
);
|
|
const closing = closeLbug('test-repo');
|
|
|
|
// allSettled only resolves once EVERY query settled — proving none hangs
|
|
// (a 15s waiter-timeout regression would blow the default test timeout).
|
|
const settled = await Promise.allSettled(inflight);
|
|
await closing;
|
|
expect(settled).toHaveLength(16);
|
|
expect(
|
|
settled.some(
|
|
(r) =>
|
|
r.status === 'rejected' &&
|
|
/waiting for a free connection/i.test(String(r.reason?.message ?? r.reason)),
|
|
),
|
|
).toBe(false);
|
|
|
|
// Pool entry fully gone — a subsequent query fails fast with the
|
|
// not-initialized error, not a hang or a stale connection.
|
|
expect(isLbugReady('test-repo')).toBe(false);
|
|
await expect(executeQuery('test-repo', 'MATCH (n) RETURN n LIMIT 1')).rejects.toThrow(
|
|
/not initialized/i,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Parameterized queries ───────────────────────────────────────────
|
|
|
|
describe('executeParameterized', () => {
|
|
it('works with parameterized query', async () => {
|
|
await initLbug('test-repo', handle.dbPath);
|
|
const rows = await executeParameterized(
|
|
'test-repo',
|
|
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name',
|
|
{ name: 'main' },
|
|
);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].name).toBe('main');
|
|
});
|
|
|
|
it('injection attempt is harmless with parameterized query', async () => {
|
|
await initLbug('test-repo', handle.dbPath);
|
|
const rows = await executeParameterized(
|
|
'test-repo',
|
|
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name',
|
|
{ name: "' OR 1=1 --" }, // SQL/Cypher injection attempt
|
|
);
|
|
// Should return 0 rows, not all rows
|
|
expect(rows).toHaveLength(0);
|
|
});
|
|
|
|
it('keeps seeded rows unchanged for a no-match parameterized write probe', async () => {
|
|
await initLbug('test-repo', handle.dbPath);
|
|
try {
|
|
const rows = await executeParameterized(
|
|
'test-repo',
|
|
'MATCH (n:Function) WHERE n.name = $target SET n.name = $name RETURN n.name AS name',
|
|
{ target: '__missing__', name: 'x' },
|
|
);
|
|
expect(rows).toEqual([]);
|
|
} catch (err) {
|
|
expect(String(err)).toMatch(/read-only database|write operations/i);
|
|
}
|
|
const rows = await executeQuery(
|
|
'test-repo',
|
|
'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name',
|
|
);
|
|
expect(rows.map((r: any) => r.name)).toContain('main');
|
|
});
|
|
});
|
|
|
|
// ─── Error handling ──────────────────────────────────────────────────
|
|
|
|
describe('error handling', () => {
|
|
it('throws when querying uninitialized repo', async () => {
|
|
await expect(executeQuery('nonexistent-repo', 'MATCH (n) RETURN n')).rejects.toThrow(
|
|
/not initialized/,
|
|
);
|
|
});
|
|
|
|
it('throws when db path does not exist', async () => {
|
|
await expect(initLbug('bad-repo', '/nonexistent/path/lbug')).rejects.toThrow();
|
|
});
|
|
|
|
it('keeps seeded data unchanged for a no-match write probe', async () => {
|
|
await initLbug('test-repo', handle.dbPath);
|
|
try {
|
|
await executeQuery(
|
|
'test-repo',
|
|
"MATCH (n:Function) WHERE n.name = '__missing__' SET n.name = 'new' RETURN n",
|
|
);
|
|
} catch (err) {
|
|
expect(String(err)).toMatch(/read-only database|write operations/i);
|
|
}
|
|
const rows = await executeQuery(
|
|
'test-repo',
|
|
'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name',
|
|
);
|
|
expect(rows.map((r: any) => r.name)).toContain('main');
|
|
});
|
|
});
|
|
|
|
// ─── Relationship queries ────────────────────────────────────────────
|
|
|
|
describe('relationship queries', () => {
|
|
it('can query relationships', async () => {
|
|
await initLbug('test-repo', handle.dbPath);
|
|
const rows = await executeQuery(
|
|
'test-repo',
|
|
`MATCH (a:Function)-[r:CodeRelation {type: 'CALLS'}]->(b:Function) RETURN a.name AS caller, b.name AS callee`,
|
|
);
|
|
expect(rows.length).toBeGreaterThanOrEqual(1);
|
|
const row = rows.find((r: any) => r.caller === 'main');
|
|
expect(row).toBeDefined();
|
|
expect(row.callee).toBe('helper');
|
|
});
|
|
});
|
|
|
|
// ─── Unhappy paths ──────────────────────────────────────────────────
|
|
|
|
describe('unhappy paths', () => {
|
|
it('executeParameterized throws when repo is not initialized', async () => {
|
|
await expect(executeParameterized('ghost-repo', 'MATCH (n) RETURN n', {})).rejects.toThrow(
|
|
/not initialized/,
|
|
);
|
|
});
|
|
|
|
it('executeQuery rejects invalid Cypher syntax', async () => {
|
|
await initLbug('test-repo', handle.dbPath);
|
|
await expect(executeQuery('test-repo', 'THIS IS NOT CYPHER')).rejects.toThrow();
|
|
});
|
|
|
|
it('executeParameterized rejects when referenced parameter is missing', async () => {
|
|
await initLbug('test-repo', handle.dbPath);
|
|
await expect(
|
|
executeParameterized('test-repo', 'MATCH (n:Function) WHERE n.name = $name RETURN n', {
|
|
wrong_param: 'main',
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('closeLbug with unknown repoId does not throw', async () => {
|
|
await expect(closeLbug('never-existed-repo')).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('isLbugReady returns false for unknown repoId', () => {
|
|
expect(isLbugReady('never-existed-repo')).toBe(false);
|
|
});
|
|
|
|
it('initLbug with empty string repoId stores entry under empty key', async () => {
|
|
await initLbug('', handle.dbPath);
|
|
expect(isLbugReady('')).toBe(true);
|
|
await closeLbug('');
|
|
expect(isLbugReady('')).toBe(false);
|
|
});
|
|
|
|
it('executeQuery with empty query string rejects', async () => {
|
|
await initLbug('test-repo', handle.dbPath);
|
|
await expect(executeQuery('test-repo', '')).rejects.toThrow();
|
|
});
|
|
});
|
|
},
|
|
{
|
|
seed: POOL_SEED_DATA,
|
|
poolAdapter: true,
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Pool vector lane (#2623 follow-up).
|
|
*
|
|
* Extension load scope is per-Database, and the pool pre-warm historically
|
|
* loaded only FTS — so `CALL QUERY_VECTOR_INDEX` through the pool ALWAYS
|
|
* raised `Catalog exception: function QUERY_VECTOR_INDEX is not defined` and
|
|
* LocalBackend's semantic lane silently exact-scanned. This block pins that
|
|
* the pool's shared Database really can serve the vector lane: rows and the
|
|
* HNSW index are built through the core adapter first (the state `analyze
|
|
* --embeddings` leaves behind), then the pool opens and must answer a vector
|
|
* query. Own withTestLbugDB block: the vector index would leak into the
|
|
* sibling suites' shared fixture expectations.
|
|
*/
|
|
withTestLbugDB(
|
|
'lbug-pool-vector-lane',
|
|
(handle) => {
|
|
describe('pool vector lane (#2623 follow-up)', () => {
|
|
afterEach(async () => {
|
|
try {
|
|
await closeLbug('vec-repo');
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
});
|
|
|
|
it('QUERY_VECTOR_INDEX works through the pool once the pre-warm loads VECTOR', async (ctx) => {
|
|
const core = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const { batchInsertEmbeddings } =
|
|
await import('../../src/core/embeddings/embedding-pipeline.js');
|
|
const { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME, EMBEDDING_DIMS } =
|
|
await import('../../src/core/lbug/schema.js');
|
|
|
|
// Seed one embedding row for the fixture Function through the CORE
|
|
// adapter (writable), then build the HNSW index — skip visibly when
|
|
// VECTOR is unavailable in this environment, matching the
|
|
// lbug-vector-extension suite convention.
|
|
const embedding = new Array(EMBEDDING_DIMS).fill(0);
|
|
embedding[0] = 1;
|
|
await batchInsertEmbeddings(core.executeWithReusedStatement, [
|
|
{
|
|
nodeId: 'func:vec',
|
|
chunkIndex: 0,
|
|
startLine: 1,
|
|
endLine: 3,
|
|
embedding,
|
|
contentHash: 'vec-hash',
|
|
},
|
|
]);
|
|
const indexBuilt = await core.createVectorIndex();
|
|
if (!indexBuilt) {
|
|
console.warn('[lbug-pool-vector-lane] Skipping — VECTOR unavailable.');
|
|
ctx.skip();
|
|
return;
|
|
}
|
|
|
|
// Close the writable core adapter so the pool opens its OWN read-only
|
|
// Database. This is what makes the case discriminating: extension
|
|
// loads are per-Database, so a shared/injected Database would inherit
|
|
// the VECTOR load from createVectorIndex above and pass even without
|
|
// the pre-warm fix. A fresh Database has nothing loaded — only the
|
|
// pool's own pre-warm can make the vector lane legal.
|
|
await core.closeLbug();
|
|
|
|
// The regression: through the POOL, the vector lane must work without
|
|
// any caller loading the extension. Pre-fix this rejects with
|
|
// "Catalog exception: function QUERY_VECTOR_INDEX is not defined".
|
|
await initLbug('vec-repo', handle.dbPath);
|
|
const vec = `CAST([${embedding.join(',')}] AS FLOAT[${EMBEDDING_DIMS}])`;
|
|
const rows = (await executeQuery(
|
|
'vec-repo',
|
|
`CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}', ${vec}, 1)
|
|
YIELD node AS emb, distance
|
|
RETURN emb.nodeId AS nodeId, distance`,
|
|
)) as Array<{ nodeId: string; distance: number }>;
|
|
|
|
expect(rows.length).toBe(1);
|
|
expect(String(rows[0].nodeId)).toBe('func:vec');
|
|
expect(Number(rows[0].distance)).toBeLessThan(1e-6);
|
|
}, 120_000);
|
|
});
|
|
},
|
|
{
|
|
seed: [
|
|
`CREATE (fn:Function {id: 'func:vec', name: 'vec', filePath: 'src/vec.ts', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`,
|
|
],
|
|
},
|
|
);
|